feat(premium): let self-hosted instances sell premium and gifts (#3025)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-28 21:21:51 +02:00
1 parent 192cec689a
commit 3736d94d73
159 files changed
+15802 -9067

No files matched your search

+1
View File
@@ -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>;