mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
feat(premium): let self-hosted instances sell premium and gifts (#3025)
This commit is contained in:
1 parent
192cec689a
commit
3736d94d73
159 files changed
+15802
-9067
No files matched your search
@@ -19,6 +19,7 @@ interface SchemaMetadata {
|
||||
bitflagValues?: Array<BitflagEntry>;
|
||||
format?: string;
|
||||
preserveEmptyValues?: boolean;
|
||||
preserveNullFields?: boolean;
|
||||
}
|
||||
|
||||
export const schemaMetadata = z.registry<SchemaMetadata>();
|
||||
|
||||
@@ -23,6 +23,10 @@ import {
|
||||
GatewayRolloutConfigResponse,
|
||||
GatewayRolloutConfigUpdateRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
|
||||
import {
|
||||
InstanceBillingResponse,
|
||||
InstanceBillingUpdateRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/InstanceBillingSchemas';
|
||||
import {
|
||||
ProfileTimezoneConfigResponse,
|
||||
ProfileTimezoneConfigUpdateRequest,
|
||||
@@ -67,6 +71,7 @@ import {
|
||||
withOpenApiType,
|
||||
} from '@fluxer/schema/src/primitives/SchemaPrimitives';
|
||||
import {EmailType} from '@fluxer/schema/src/primitives/UserValidators';
|
||||
import {schemaMetadata} from '@fluxer/schema/src/SchemaMetadata';
|
||||
import {z} from 'zod';
|
||||
|
||||
const ReportStatusSchema = withOpenApiType(
|
||||
@@ -504,6 +509,8 @@ const AppPublicConfigResponse = z.object({
|
||||
theme_color: z.string().nullable(),
|
||||
status_page_url: z.string().nullable(),
|
||||
status_page_incident_history_url: z.string().nullable(),
|
||||
premium_product_name: z.string(),
|
||||
premium_info_url: z.string().nullable(),
|
||||
}),
|
||||
setup: z.object({
|
||||
configured: z.boolean(),
|
||||
@@ -517,6 +524,15 @@ const AppPublicConfigResponse = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
function isAbsoluteHttpUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return (url.protocol === 'http:' || url.protocol === 'https:') && url.hostname.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const AppPublicConfigUpdateRequest = z.object({
|
||||
branding: z
|
||||
.object({
|
||||
@@ -529,7 +545,10 @@ const AppPublicConfigUpdateRequest = z.object({
|
||||
theme_color: z.string().trim().max(64).nullish(),
|
||||
status_page_url: z.string().trim().max(2048).nullish(),
|
||||
status_page_incident_history_url: z.string().trim().max(2048).nullish(),
|
||||
premium_product_name: z.string().trim().min(1).max(40).nullable().optional(),
|
||||
premium_info_url: z.string().trim().max(2048).refine(isAbsoluteHttpUrl).nullish(),
|
||||
})
|
||||
.register(schemaMetadata, {preserveNullFields: true})
|
||||
.nullish(),
|
||||
setup: z
|
||||
.object({
|
||||
@@ -668,6 +687,7 @@ export const InstanceConfigResponse = z.object({
|
||||
policy: InstancePolicyResponse,
|
||||
integrations: InstanceIntegrationsResponse,
|
||||
media: InstanceMediaResponse,
|
||||
billing: InstanceBillingResponse,
|
||||
});
|
||||
|
||||
export type InstanceConfigResponse = z.infer<typeof InstanceConfigResponse>;
|
||||
@@ -804,6 +824,7 @@ export const InstanceConfigUpdateRequest = z.object({
|
||||
})
|
||||
.nullish(),
|
||||
policy: InstancePolicyUpdateSchema.nullish(),
|
||||
billing: InstanceBillingUpdateRequest.nullish(),
|
||||
});
|
||||
|
||||
export type InstanceConfigUpdateRequest = z.infer<typeof InstanceConfigUpdateRequest>;
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {PremiumCurrency} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
|
||||
import {schemaMetadata} from '@fluxer/schema/src/SchemaMetadata';
|
||||
import {z} from 'zod';
|
||||
|
||||
export const BILLING_MAX_CURRENCIES = 64;
|
||||
export const BILLING_MAX_COUNTRY_CURRENCIES = 300;
|
||||
export const BILLING_MAX_LEGACY_SLOTS = 256;
|
||||
export const BILLING_MAX_LEGACY_PRICES_PER_SLOT = 32;
|
||||
|
||||
export const BillingCountryCodeSchema = z.string().regex(/^[A-Z]{2}$/);
|
||||
export const StripePriceIdSchema = z
|
||||
.string()
|
||||
.max(255)
|
||||
.regex(/^price_[A-Za-z0-9]+$/);
|
||||
export const BillingLegacyPriceSlotSchema = z.string().regex(/^(monthly|yearly|gift_1_month|gift_1_year)_[A-Z]{3}$/);
|
||||
export const BillingCatalogModeSchema = z.enum(['env', 'operator']);
|
||||
export type BillingCatalogMode = z.infer<typeof BillingCatalogModeSchema>;
|
||||
|
||||
function maxKeys(max: number) {
|
||||
return (value: Record<string, unknown>) => Object.keys(value).length <= max;
|
||||
}
|
||||
|
||||
export const BillingPriceSetResponse = z.object({
|
||||
monthly: StripePriceIdSchema.nullable(),
|
||||
yearly: StripePriceIdSchema.nullable(),
|
||||
gift_1_month: StripePriceIdSchema.nullable(),
|
||||
gift_1_year: StripePriceIdSchema.nullable(),
|
||||
});
|
||||
export type BillingPriceSetResponse = z.infer<typeof BillingPriceSetResponse>;
|
||||
|
||||
export const BillingPriceSetUpdateRequest = z.object({
|
||||
monthly: StripePriceIdSchema.nullish(),
|
||||
yearly: StripePriceIdSchema.nullish(),
|
||||
gift_1_month: StripePriceIdSchema.nullish(),
|
||||
gift_1_year: StripePriceIdSchema.nullish(),
|
||||
});
|
||||
export type BillingPriceSetUpdateRequest = z.infer<typeof BillingPriceSetUpdateRequest>;
|
||||
|
||||
const BillingPricesSchema = <T extends z.ZodType>(entry: T) =>
|
||||
z
|
||||
.record(PremiumCurrency, entry)
|
||||
.refine(maxKeys(BILLING_MAX_CURRENCIES), {message: `At most ${BILLING_MAX_CURRENCIES} currencies`});
|
||||
|
||||
export const BillingCountryCurrenciesSchema = z
|
||||
.record(BillingCountryCodeSchema, PremiumCurrency)
|
||||
.refine(maxKeys(BILLING_MAX_COUNTRY_CURRENCIES), {
|
||||
message: `At most ${BILLING_MAX_COUNTRY_CURRENCIES} country mappings`,
|
||||
});
|
||||
|
||||
export const BillingLegacyPricesSchema = z
|
||||
.record(BillingLegacyPriceSlotSchema, z.array(StripePriceIdSchema).max(BILLING_MAX_LEGACY_PRICES_PER_SLOT))
|
||||
.refine(maxKeys(BILLING_MAX_LEGACY_SLOTS), {message: `At most ${BILLING_MAX_LEGACY_SLOTS} legacy price slots`});
|
||||
|
||||
export const InstanceBillingResponse = z.object({
|
||||
enabled: z.boolean().nullable(),
|
||||
effective_enabled: z.boolean(),
|
||||
stripe_secret_key_set: z.boolean(),
|
||||
stripe_webhook_secret_set: z.boolean(),
|
||||
stripe_secret_key_stored: z.boolean(),
|
||||
stripe_webhook_secret_stored: z.boolean(),
|
||||
automatic_tax: z.boolean().nullable(),
|
||||
tax_id_collection: z.boolean().nullable(),
|
||||
terms_consent_required: z.boolean().nullable(),
|
||||
effective_automatic_tax: z.boolean(),
|
||||
effective_tax_id_collection: z.boolean(),
|
||||
effective_terms_consent_required: z.boolean(),
|
||||
default_currency: PremiumCurrency.nullable(),
|
||||
prices: z.record(z.string(), BillingPriceSetResponse).nullable(),
|
||||
country_currencies: z.record(z.string(), z.string()).nullable(),
|
||||
legacy_prices: z.record(z.string(), z.array(z.string())).nullable(),
|
||||
billing_active: z.boolean(),
|
||||
stripe_serviceable: z.boolean(),
|
||||
catalog_mode: BillingCatalogModeSchema,
|
||||
webhook_url: z.string(),
|
||||
});
|
||||
export type InstanceBillingResponse = z.infer<typeof InstanceBillingResponse>;
|
||||
|
||||
export const InstanceBillingUpdateRequest = z
|
||||
.object({
|
||||
enabled: z.boolean().nullish(),
|
||||
stripe_secret_key: z.string().trim().min(1).max(4096).nullish(),
|
||||
stripe_webhook_secret: z.string().trim().min(1).max(4096).nullish(),
|
||||
automatic_tax: z.boolean().nullish(),
|
||||
tax_id_collection: z.boolean().nullish(),
|
||||
terms_consent_required: z.boolean().nullish(),
|
||||
default_currency: PremiumCurrency.nullish(),
|
||||
prices: BillingPricesSchema(BillingPriceSetUpdateRequest).nullish(),
|
||||
country_currencies: BillingCountryCurrenciesSchema.nullish(),
|
||||
legacy_prices: BillingLegacyPricesSchema.nullish(),
|
||||
})
|
||||
.register(schemaMetadata, {preserveNullFields: true});
|
||||
export type InstanceBillingUpdateRequest = z.infer<typeof InstanceBillingUpdateRequest>;
|
||||
|
||||
export const StoredBillingConfigSchema = z.object({
|
||||
enabled: z.boolean().nullable().default(null),
|
||||
stripe_secret_key: z
|
||||
.string()
|
||||
.trim()
|
||||
.nullable()
|
||||
.default(null)
|
||||
.transform((value) => value || null),
|
||||
stripe_webhook_secret: z
|
||||
.string()
|
||||
.trim()
|
||||
.nullable()
|
||||
.default(null)
|
||||
.transform((value) => value || null),
|
||||
automatic_tax: z.boolean().nullable().default(null),
|
||||
tax_id_collection: z.boolean().nullable().default(null),
|
||||
terms_consent_required: z.boolean().nullable().default(null),
|
||||
default_currency: PremiumCurrency.nullable().default(null),
|
||||
prices: BillingPricesSchema(
|
||||
z.object({
|
||||
monthly: StripePriceIdSchema.nullable().default(null),
|
||||
yearly: StripePriceIdSchema.nullable().default(null),
|
||||
gift_1_month: StripePriceIdSchema.nullable().default(null),
|
||||
gift_1_year: StripePriceIdSchema.nullable().default(null),
|
||||
}),
|
||||
)
|
||||
.nullable()
|
||||
.default(null),
|
||||
country_currencies: BillingCountryCurrenciesSchema.nullable().default(null),
|
||||
legacy_prices: BillingLegacyPricesSchema.nullable().default(null),
|
||||
});
|
||||
export type StoredBillingConfig = z.output<typeof StoredBillingConfigSchema>;
|
||||
@@ -39,6 +39,8 @@ export const InstanceBrandingSchema = z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe('Optional public status page incident history URL'),
|
||||
premium_product_name: z.string().describe('Name of the premium tier shown by client applications'),
|
||||
premium_info_url: z.string().nullable().describe('Optional absolute URL of a page describing the premium tier'),
|
||||
})
|
||||
.describe('Branding values safe to expose to clients');
|
||||
export type InstanceBranding = z.infer<typeof InstanceBrandingSchema>;
|
||||
@@ -104,7 +106,13 @@ export type InstanceCaptcha = z.infer<typeof InstanceCaptchaSchema>;
|
||||
export const InstanceFeaturesSchema = z
|
||||
.object({
|
||||
voice_enabled: z.boolean().describe('Whether voice/video calling is enabled'),
|
||||
stripe_enabled: z.boolean().describe('Whether Stripe payments are enabled'),
|
||||
stripe_enabled: z.boolean().describe('Whether premium purchases through Stripe are available'),
|
||||
premium_enabled: z
|
||||
.boolean()
|
||||
.describe('Whether this instance has a premium tier, so premium status, gifts and perks apply'),
|
||||
stripe_serviceable: z
|
||||
.boolean()
|
||||
.describe('Whether existing Stripe subscriptions can be managed, cancelled and billed on this instance'),
|
||||
self_hosted: z.boolean().describe('Whether this is a self-hosted instance'),
|
||||
presigned_attachment_uploads: z.boolean().describe('Whether clients can request presigned attachment upload URLs'),
|
||||
emails_enabled: z.boolean().describe('Whether the instance sends emails (verification, password reset, etc.)'),
|
||||
|
||||
@@ -4,7 +4,7 @@ import {createStringType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
|
||||
import {UserPremiumTypesSchema} from '@fluxer/schema/src/primitives/UserSettingsValidators';
|
||||
import {z} from 'zod';
|
||||
|
||||
export const PremiumCurrency = z.enum(['USD', 'EUR', 'BRL', 'DKK', 'INR', 'NOK', 'PLN', 'SEK', 'TRY']);
|
||||
export const PremiumCurrency = z.string().regex(/^[A-Z]{3}$/);
|
||||
|
||||
export type PremiumCurrency = z.infer<typeof PremiumCurrency>;
|
||||
|
||||
@@ -36,7 +36,9 @@ export const PriceIdsResponse = z.object({
|
||||
.describe('Gift 1 month price amount in the currency minor unit'),
|
||||
gift_1_year_amount_minor: z.number().int().nullish().describe('Gift 1 year price amount in the currency minor unit'),
|
||||
currency: PremiumCurrency.describe('Currency for the prices'),
|
||||
gift_currency: PremiumCurrency.describe('Currency for gift prices'),
|
||||
gift_currency: PremiumCurrency.nullable().describe(
|
||||
'Currency for gift prices, null when no gift prices are configured',
|
||||
),
|
||||
});
|
||||
|
||||
export type PriceIdsResponse = z.infer<typeof PriceIdsResponse>;
|
||||
|
||||
Reference in new issue
Block a user