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

@@ -15,6 +15,12 @@ import {normalizeSsoAllowedEmailDomains} from '@app/api/instance/SsoConfigValida
import {Logger} from '@app/api/Logger';
import {isLimitConfigSnapshot} from '@app/api/limits/LimitConfigValidation';
import {resolveDeferredPhoneGateEnabled, setCachedDeferredPhoneGateEnabled} from '@app/api/risk/DeferredPhoneGateCache';
import {
getEffectiveBillingConfig,
isBillingActive,
isStripeServiceable,
setStoredBillingConfig,
} from '@app/api/stripe/BillingConfigCache';
import {InstanceConfiguration} from '@app/api/Tables';
import {DEFAULT_DECAY_CONSTANTS, DEFAULT_RENEWAL_CONSTANTS} from '@app/api/utils/AttachmentDecay';
import {isJsonRecord} from '@app/api/utils/JsonBoundaryUtils';
@@ -40,6 +46,11 @@ import {
type GatewayRolloutConfig,
GatewayRolloutConfigSchema,
} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
import {
type BillingCatalogMode,
type StoredBillingConfig,
StoredBillingConfigSchema,
} from '@fluxer/schema/src/domains/admin/InstanceBillingSchemas';
import {
type ProfileTimezoneConfig,
ProfileTimezoneConfigSchema,
@@ -90,6 +101,7 @@ const INSTANCE_POLICY_CONFIG_KEY = 'instance_policy_config';
const LIMIT_CONFIG_KEY = 'limit_config';
const INSTANCE_INTEGRATIONS_CONFIG_KEY = 'instance_integrations_config';
const INSTANCE_MEDIA_CONFIG_KEY = 'instance_media_config';
const INSTANCE_BILLING_CONFIG_KEY = 'instance_billing_config';
export const INSTANCE_CONFIG_REFRESH_CHANNEL = 'instance-config-refresh';
export const REGISTRATION_PENDING_APPROVAL_TRAIT = 'registration_pending_approval';
export const REGISTRATION_REJECTED_TRAIT = 'registration_rejected';
@@ -116,6 +128,50 @@ interface InstanceAppPublicConfig extends Omit<InstanceAppPublic, 'setup'> {
setup: Pick<InstanceSetup, 'configured'>;
}
type InstanceBrandingPatch = Partial<Omit<InstanceBranding, 'premium_product_name'>> & {
premium_product_name?: string | null;
};
export type InstanceBillingConfig = StoredBillingConfig;
export type InstanceBillingPriceSetPatch = Partial<NonNullable<StoredBillingConfig['prices']>[string]>;
export interface InstanceBillingConfigPatch {
enabled?: boolean | null;
stripe_secret_key?: string | null;
stripe_webhook_secret?: string | null;
automatic_tax?: boolean | null;
tax_id_collection?: boolean | null;
terms_consent_required?: boolean | null;
default_currency?: string | null;
prices?: Record<string, InstanceBillingPriceSetPatch> | null;
country_currencies?: Record<string, string> | null;
legacy_prices?: Record<string, Array<string>> | null;
}
export interface InstanceBillingAdminConfig {
enabled: boolean | null;
effective_enabled: boolean;
stripe_secret_key_set: boolean;
stripe_webhook_secret_set: boolean;
stripe_secret_key_stored: boolean;
stripe_webhook_secret_stored: boolean;
automatic_tax: boolean | null;
tax_id_collection: boolean | null;
terms_consent_required: boolean | null;
effective_automatic_tax: boolean;
effective_tax_id_collection: boolean;
effective_terms_consent_required: boolean;
default_currency: string | null;
prices: StoredBillingConfig['prices'];
country_currencies: StoredBillingConfig['country_currencies'];
legacy_prices: StoredBillingConfig['legacy_prices'];
billing_active: boolean;
stripe_serviceable: boolean;
catalog_mode: BillingCatalogMode;
webhook_url: string;
}
export type InstancePremiumMode = 'mirror' | 'everyone';
interface LimitConfigInputs {
@@ -356,6 +412,10 @@ function normalizeOptionalPublicString(value: string | null | undefined, fallbac
return value === undefined ? fallback : normalizeOptionalString(value);
}
export function getDefaultPremiumProductName(): string {
return Config.instance.selfHosted ? 'Premium' : 'Plutonium';
}
function getDefaultAppPublicConfig(): InstanceAppPublicConfig {
return {
branding: {
@@ -368,6 +428,8 @@ function getDefaultAppPublicConfig(): InstanceAppPublicConfig {
theme_color: normalizeOptionalString(Config.instance.branding.themeColor),
status_page_url: normalizeOptionalString(Config.instance.branding.statusPageUrl),
status_page_incident_history_url: normalizeOptionalString(Config.instance.branding.statusPageIncidentHistoryUrl),
premium_product_name: getDefaultPremiumProductName(),
premium_info_url: null,
},
setup: {
configured: !Config.instance.selfHosted || Config.instance.setup.configured,
@@ -394,6 +456,7 @@ type StoredConfigSection =
| 'instance policy'
| 'integrations'
| 'media'
| 'billing'
| 'registration'
| 'registration URLs'
| 'pending registrations'
@@ -577,7 +640,10 @@ function parseStoredCollection<T>(schema: z.ZodType<T>, raw: string | null, sect
}
const StoredInstanceAppPublicSchema = InstanceAppPublicSchema.extend({
branding: InstanceAppPublicSchema.shape.branding.partial().optional(),
branding: InstanceAppPublicSchema.shape.branding
.extend({premium_product_name: z.string().max(40).nullable()})
.partial()
.optional(),
setup: InstanceAppPublicSchema.shape.setup.pick({configured: true}).partial().optional(),
legal: InstanceAppPublicSchema.shape.legal.partial().optional(),
registration: InstanceAppPublicSchema.shape.registration.partial().optional(),
@@ -589,10 +655,6 @@ function parseStoredAppPublicConfig(raw: string | null): InstanceAppPublicConfig
);
}
function decodeAppPublicConfig(value: unknown): InstanceAppPublicConfig {
return buildAppPublicConfig(validateStoredConfig(StoredInstanceAppPublicSchema, value, 'app public'));
}
function buildAppPublicConfig(config: z.infer<typeof StoredInstanceAppPublicSchema>): InstanceAppPublicConfig {
const defaults = getDefaultAppPublicConfig();
const {branding = {}, setup = {}, legal = {}, registration = {}} = config;
@@ -610,6 +672,9 @@ function buildAppPublicConfig(config: z.infer<typeof StoredInstanceAppPublicSche
branding.status_page_incident_history_url,
defaults.branding.status_page_incident_history_url,
),
premium_product_name:
normalizeOptionalString(branding.premium_product_name) ?? defaults.branding.premium_product_name,
premium_info_url: normalizeOptionalPublicString(branding.premium_info_url, defaults.branding.premium_info_url),
},
setup: {
configured: setup.configured ?? defaults.setup.configured,
@@ -733,6 +798,30 @@ function parseStoredInstanceIntegrationsConfig(raw: string | null): InstanceInte
);
}
function decodeInstanceBillingConfig(value: unknown): InstanceBillingConfig {
return validateStoredConfig(StoredBillingConfigSchema, value, 'billing');
}
function parseStoredInstanceBillingConfig(raw: string | null): InstanceBillingConfig {
return salvageStoredConfig(StoredBillingConfigSchema, readStoredConfigValue(raw, 'billing'), 'billing');
}
function normalizeBillingPrices(
prices: Record<string, InstanceBillingPriceSetPatch> | null,
): Record<string, InstanceBillingPriceSetPatch> | null {
if (prices === null) return null;
const entries = Object.entries(prices).map(([currency, set]): [string, InstanceBillingPriceSetPatch] => [
currency,
{
monthly: set.monthly ?? null,
yearly: set.yearly ?? null,
gift_1_month: set.gift_1_month ?? null,
gift_1_year: set.gift_1_year ?? null,
},
]);
return entries.length === 0 ? null : Object.fromEntries(entries);
}
function secretIsSet(value: unknown): boolean {
return typeof value === 'string' && value.trim().length > 0;
}
@@ -1239,6 +1328,7 @@ export class InstanceConfigRepository {
parseStoredInstanceIntegrationsConfig(snapshot.get(INSTANCE_INTEGRATIONS_CONFIG_KEY) ?? null),
);
checkStoredConfig('media', () => parseStoredInstanceMediaConfig(snapshot.get(INSTANCE_MEDIA_CONFIG_KEY) ?? null));
setStoredBillingConfig(parseStoredInstanceBillingConfig(snapshot.get(INSTANCE_BILLING_CONFIG_KEY) ?? null));
const appPublic = parseStoredAppPublicConfig(snapshot.get(APP_PUBLIC_CONFIG_KEY) ?? null);
setCachedDeferredPhoneGateEnabled(resolveDeferredPhoneGateEnabled(policy));
setCachedDateOfBirthCollection(appPublic.registration.collect_date_of_birth);
@@ -1433,32 +1523,49 @@ export class InstanceConfigRepository {
}
async setAppPublicConfig(config: {
branding?: Partial<InstanceBranding>;
branding?: InstanceBrandingPatch;
setup?: Partial<InstanceAppPublicConfig['setup']>;
legal?: Partial<InstanceAppPublicConfig['legal']>;
registration?: Partial<InstanceAppPublicConfig['registration']>;
}): Promise<InstanceAppPublicConfig> {
const next = await this.updateStoredConfig(APP_PUBLIC_CONFIG_KEY, (raw) => {
const current = parseStoredAppPublicConfig(raw);
return decodeAppPublicConfig({
branding: {
...current.branding,
...(config.branding ?? {}),
const cache = this.configCache;
const {result: next} = await this.compareAndSetStoredValue(cache, APP_PUBLIC_CONFIG_KEY, (raw) => {
const stored = salvageStoredConfig(
StoredInstanceAppPublicSchema,
readStoredConfigValue(raw, 'app public'),
'app public',
);
const current = buildAppPublicConfig(stored);
const premiumProductName =
config.branding?.premium_product_name !== undefined
? config.branding.premium_product_name
: normalizeOptionalString(stored.branding?.premium_product_name);
const merged = validateStoredConfig(
StoredInstanceAppPublicSchema,
{
branding: {
...current.branding,
...(config.branding ?? {}),
premium_product_name: premiumProductName,
},
setup: {
...current.setup,
...(config.setup ?? {}),
},
legal: {
...current.legal,
...(config.legal ?? {}),
},
registration: {
...current.registration,
...(config.registration ?? {}),
},
},
setup: {
...current.setup,
...(config.setup ?? {}),
},
legal: {
...current.legal,
...(config.legal ?? {}),
},
registration: {
...current.registration,
...(config.registration ?? {}),
},
});
'app public',
);
return {value: JSON.stringify(merged), result: buildAppPublicConfig(merged)};
});
await this.publishRefresh(cache.sourceId);
setCachedDateOfBirthCollection(next.registration.collect_date_of_birth);
return next;
}
@@ -1758,6 +1865,71 @@ export class InstanceConfigRepository {
};
}
async getInstanceBillingConfig(): Promise<InstanceBillingConfig> {
const raw = await this.getConfig(INSTANCE_BILLING_CONFIG_KEY);
const config = parseStoredInstanceBillingConfig(raw);
setStoredBillingConfig(config);
return config;
}
async readStoredInstanceBillingConfig(): Promise<InstanceBillingConfig> {
const cache = this.configCache;
cache.assertActive();
const raw = await this.fetchConfigFromDatabase(INSTANCE_BILLING_CONFIG_KEY);
cache.assertActive();
return parseStoredInstanceBillingConfig(raw);
}
async setInstanceBillingConfig(patch: InstanceBillingConfigPatch): Promise<InstanceBillingConfig> {
const next = await this.updateStoredConfig(INSTANCE_BILLING_CONFIG_KEY, (raw) => {
const current = parseStoredInstanceBillingConfig(raw);
return decodeInstanceBillingConfig({
enabled: patch.enabled === undefined ? current.enabled : patch.enabled,
stripe_secret_key: patch.stripe_secret_key === undefined ? current.stripe_secret_key : patch.stripe_secret_key,
stripe_webhook_secret:
patch.stripe_webhook_secret === undefined ? current.stripe_webhook_secret : patch.stripe_webhook_secret,
automatic_tax: patch.automatic_tax === undefined ? current.automatic_tax : patch.automatic_tax,
tax_id_collection: patch.tax_id_collection === undefined ? current.tax_id_collection : patch.tax_id_collection,
terms_consent_required:
patch.terms_consent_required === undefined ? current.terms_consent_required : patch.terms_consent_required,
default_currency: patch.default_currency === undefined ? current.default_currency : patch.default_currency,
prices: patch.prices === undefined ? current.prices : normalizeBillingPrices(patch.prices),
country_currencies:
patch.country_currencies === undefined ? current.country_currencies : patch.country_currencies,
legacy_prices: patch.legacy_prices === undefined ? current.legacy_prices : patch.legacy_prices,
});
});
setStoredBillingConfig(next);
return next;
}
async getInstanceBillingAdminConfig(): Promise<InstanceBillingAdminConfig> {
const stored = await this.getInstanceBillingConfig();
const effective = getEffectiveBillingConfig();
return {
enabled: stored.enabled,
effective_enabled: effective.enabled,
stripe_secret_key_set: effective.secretKey !== null,
stripe_webhook_secret_set: effective.webhookSecret !== null,
stripe_secret_key_stored: secretIsSet(stored.stripe_secret_key),
stripe_webhook_secret_stored: secretIsSet(stored.stripe_webhook_secret),
automatic_tax: stored.automatic_tax,
tax_id_collection: stored.tax_id_collection,
terms_consent_required: stored.terms_consent_required,
effective_automatic_tax: effective.automaticTax,
effective_tax_id_collection: effective.taxIdCollection,
effective_terms_consent_required: effective.termsConsentRequired,
default_currency: stored.default_currency,
prices: stored.prices,
country_currencies: stored.country_currencies,
legacy_prices: stored.legacy_prices,
billing_active: isBillingActive(effective),
stripe_serviceable: isStripeServiceable(effective),
catalog_mode: effective.catalogMode,
webhook_url: `${Config.endpoints.apiPublic.replace(/\/+$/, '')}/stripe/webhook`,
};
}
async getInstanceCommunityPublicConfig(): Promise<InstanceCommunity> {
const policy = await this.getInstancePolicyConfig();
return {
@@ -0,0 +1,330 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Config} from '@app/api/Config';
import {setCassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
import {InstanceConfigRepository} from '@app/api/instance/InstanceConfigRepository';
import {getCachedInstancePremiumMode, setCachedInstancePremiumMode} from '@app/api/limits/InstancePremiumModeCache';
import {
getEffectiveBillingConfig,
getStoredBillingConfig,
setStoredBillingConfig,
} from '@app/api/stripe/BillingConfigCache';
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
import {MockKVProvider} from '@app/api/test/mocks/MockKVProvider';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
const INSTANCE_BILLING_CONFIG_KEY = 'instance_billing_config';
const APP_PUBLIC_CONFIG_KEY = 'app_public_config';
const GBP_PRICES = {
monthly: 'price_GbpMonthly',
yearly: 'price_GbpYearly',
gift_1_month: 'price_GbpGiftMonth',
gift_1_year: 'price_GbpGiftYear',
};
describe('InstanceConfigRepository billing and premium branding', () => {
const repositories: Array<InstanceConfigRepository> = [];
const originalSelfHosted = Config.instance.selfHosted;
let originalStored = getStoredBillingConfig();
let originalPremiumMode = getCachedInstancePremiumMode();
let executor: InMemoryCassandraQueryExecutor;
beforeEach(() => {
originalStored = getStoredBillingConfig();
originalPremiumMode = getCachedInstancePremiumMode();
executor = new InMemoryCassandraQueryExecutor();
setCassandraQueryExecutorForTesting(executor);
Config.instance.selfHosted = true;
});
afterEach(() => {
for (const repository of repositories) {
repository.shutdown();
}
repositories.length = 0;
Config.instance.selfHosted = originalSelfHosted;
setCachedInstancePremiumMode(originalPremiumMode);
setStoredBillingConfig(originalStored);
});
function createRepository(kvProvider = new MockKVProvider()): InstanceConfigRepository {
const repository = new InstanceConfigRepository(kvProvider);
repositories.push(repository);
return repository;
}
async function readRaw(repository: InstanceConfigRepository, key: string): Promise<unknown> {
const raw = await repository.getConfig(key);
return raw === null ? null : JSON.parse(raw);
}
it('returns an all-null billing config when nothing is stored', async () => {
const repository = createRepository();
expect(await repository.getInstanceBillingConfig()).toEqual({
enabled: null,
stripe_secret_key: null,
stripe_webhook_secret: null,
automatic_tax: null,
tax_id_collection: null,
terms_consent_required: null,
default_currency: null,
prices: null,
country_currencies: null,
legacy_prices: null,
});
expect(getEffectiveBillingConfig().catalogMode).toBe('env');
});
it('merges patches, keeps secrets on undefined, clears them on null and replaces the catalog maps', async () => {
const repository = createRepository();
await repository.setInstanceBillingConfig({
enabled: true,
stripe_secret_key: 'sk_test_operator',
stripe_webhook_secret: 'whsec_operator',
default_currency: 'GBP',
prices: {GBP: GBP_PRICES, CHF: {monthly: 'price_ChfMonthly', yearly: 'price_ChfYearly'}},
country_currencies: {CH: 'CHF'},
legacy_prices: {monthly_GBP: ['price_GbpOld']},
});
const merged = await repository.setInstanceBillingConfig({
default_currency: 'CHF',
prices: {GBP: GBP_PRICES},
country_currencies: {LI: 'CHF'},
});
expect(merged).toEqual({
enabled: true,
stripe_secret_key: 'sk_test_operator',
stripe_webhook_secret: 'whsec_operator',
automatic_tax: null,
tax_id_collection: null,
terms_consent_required: null,
default_currency: 'CHF',
prices: {GBP: GBP_PRICES},
country_currencies: {LI: 'CHF'},
legacy_prices: {monthly_GBP: ['price_GbpOld']},
});
expect(getStoredBillingConfig()).toEqual(merged);
expect(getEffectiveBillingConfig().catalogMode).toBe('operator');
const cleared = await repository.setInstanceBillingConfig({
stripe_webhook_secret: null,
legacy_prices: null,
});
expect(cleared.stripe_secret_key).toBe('sk_test_operator');
expect(cleared.stripe_webhook_secret).toBeNull();
expect(cleared.legacy_prices).toBeNull();
expect(await readRaw(repository, INSTANCE_BILLING_CONFIG_KEY)).toEqual(cleared);
});
it('fills missing price slots with null and treats an empty price map as the env catalog', async () => {
const repository = createRepository();
const partial = await repository.setInstanceBillingConfig({prices: {GBP: {monthly: 'price_GbpMonthly'}}});
expect(partial.prices).toEqual({
GBP: {monthly: 'price_GbpMonthly', yearly: null, gift_1_month: null, gift_1_year: null},
});
const emptied = await repository.setInstanceBillingConfig({prices: {}});
expect(emptied.prices).toBeNull();
expect(getEffectiveBillingConfig().catalogMode).toBe('env');
});
it('rejects a patch that would store an invalid value', async () => {
const repository = createRepository();
await expect(repository.setInstanceBillingConfig({default_currency: 'gbp'})).rejects.toThrow(/billing/);
await expect(repository.setInstanceBillingConfig({prices: {GBP: {monthly: 'not-a-price-id'}}})).rejects.toThrow(
/billing/,
);
expect(await repository.getConfig(INSTANCE_BILLING_CONFIG_KEY)).toBeNull();
});
it('redacts secrets in the admin view and reports the effective state', async () => {
Config.instance.selfHosted = true;
setCachedInstancePremiumMode('mirror');
const repository = createRepository();
await repository.setInstanceBillingConfig({
enabled: true,
stripe_secret_key: 'sk_test_do_not_leak',
stripe_webhook_secret: 'whsec_do_not_leak',
prices: {GBP: GBP_PRICES},
});
const admin = await repository.getInstanceBillingAdminConfig();
expect(JSON.stringify(admin)).not.toContain('do_not_leak');
expect(admin).toEqual({
enabled: true,
effective_enabled: true,
stripe_secret_key_set: true,
stripe_webhook_secret_set: true,
stripe_secret_key_stored: true,
stripe_webhook_secret_stored: true,
automatic_tax: null,
tax_id_collection: null,
terms_consent_required: null,
effective_automatic_tax: false,
effective_tax_id_collection: false,
effective_terms_consent_required: false,
default_currency: null,
prices: {GBP: GBP_PRICES},
country_currencies: null,
legacy_prices: null,
billing_active: true,
stripe_serviceable: true,
catalog_mode: 'operator',
webhook_url: `${Config.endpoints.apiPublic.replace(/\/+$/, '')}/stripe/webhook`,
});
setCachedInstancePremiumMode('everyone');
const everyone = await repository.getInstanceBillingAdminConfig();
expect(everyone.billing_active).toBe(false);
expect(everyone.stripe_serviceable).toBe(false);
});
it('keeps, sets and clears the checkout flags like the other nullable fields', async () => {
const repository = createRepository();
const set = await repository.setInstanceBillingConfig({automatic_tax: true, terms_consent_required: false});
expect(set.automatic_tax).toBe(true);
expect(set.tax_id_collection).toBeNull();
expect(set.terms_consent_required).toBe(false);
const kept = await repository.setInstanceBillingConfig({tax_id_collection: true});
expect(kept.automatic_tax).toBe(true);
expect(kept.tax_id_collection).toBe(true);
expect(kept.terms_consent_required).toBe(false);
const cleared = await repository.setInstanceBillingConfig({automatic_tax: null});
expect(cleared.automatic_tax).toBeNull();
const admin = await repository.getInstanceBillingAdminConfig();
expect(admin.automatic_tax).toBeNull();
expect(admin.tax_id_collection).toBe(true);
expect(admin.terms_consent_required).toBe(false);
expect(admin.effective_automatic_tax).toBe(false);
expect(admin.effective_tax_id_collection).toBe(true);
expect(admin.effective_terms_consent_required).toBe(false);
});
it('reports env-sourced secrets as set but not stored', async () => {
const repository = createRepository();
const admin = await repository.getInstanceBillingAdminConfig();
expect(admin.stripe_secret_key_set).toBe(Boolean(Config.stripe.secretKey));
expect(admin.stripe_webhook_secret_set).toBe(Boolean(Config.stripe.webhookSecret));
expect(admin.stripe_secret_key_stored).toBe(false);
expect(admin.stripe_webhook_secret_stored).toBe(false);
await repository.setInstanceBillingConfig({stripe_webhook_secret: 'whsec_stored_only'});
const stored = await repository.getInstanceBillingAdminConfig();
expect(stored.stripe_secret_key_stored).toBe(false);
expect(stored.stripe_webhook_secret_stored).toBe(true);
});
it('ignores a stored billing config on hosted', async () => {
Config.instance.selfHosted = false;
const repository = createRepository();
await repository.setInstanceBillingConfig({
enabled: false,
stripe_secret_key: 'sk_test_stored',
prices: {GBP: GBP_PRICES},
automatic_tax: false,
tax_id_collection: false,
terms_consent_required: false,
});
const effective = getEffectiveBillingConfig();
expect(effective.catalogMode).toBe('env');
expect(effective.enabled).toBe(Config.stripe.enabled);
expect(effective.secretKey).toBe(Config.stripe.secretKey || null);
const admin = await repository.getInstanceBillingAdminConfig();
expect(admin.catalog_mode).toBe('env');
expect(admin.effective_automatic_tax).toBe(true);
expect(admin.effective_tax_id_collection).toBe(true);
expect(admin.effective_terms_consent_required).toBe(true);
});
it('salvages the valid fields of a partly invalid stored billing config', async () => {
const repository = createRepository();
await repository.setConfig(
INSTANCE_BILLING_CONFIG_KEY,
JSON.stringify({
enabled: 'yes',
stripe_secret_key: 'sk_test_kept',
default_currency: 'GBP',
prices: {
GBP: GBP_PRICES,
gbp: GBP_PRICES,
EUR: {...GBP_PRICES, monthly: 'bogus'},
},
country_currencies: {GB: 'GBP', gb: 'GBP'},
}),
);
const config = await repository.getInstanceBillingConfig();
expect(config.enabled).toBeNull();
expect(config.stripe_secret_key).toBe('sk_test_kept');
expect(config.default_currency).toBe('GBP');
expect(config.prices?.GBP).toEqual(GBP_PRICES);
expect(config.prices).not.toHaveProperty('gbp');
expect(config.prices?.EUR?.monthly ?? null).toBeNull();
expect(config.country_currencies).toEqual({GB: 'GBP'});
});
it('falls back to an empty billing config when the stored value is not JSON', async () => {
const repository = createRepository();
await repository.setConfig(INSTANCE_BILLING_CONFIG_KEY, '{not json');
expect((await repository.getInstanceBillingConfig()).prices).toBeNull();
});
it('updates the process billing cache when another repository publishes a billing change', async () => {
const kvProvider = new MockKVProvider();
const reader = createRepository(kvProvider);
const writer = createRepository(kvProvider);
await reader.getInstanceBillingConfig();
await writer.getInstanceBillingConfig();
setStoredBillingConfig(null);
await writer.setConfig(
INSTANCE_BILLING_CONFIG_KEY,
JSON.stringify({enabled: true, prices: {GBP: GBP_PRICES}, default_currency: 'GBP'}),
);
await vi.waitFor(() => {
expect(getStoredBillingConfig()?.default_currency).toBe('GBP');
});
expect(getEffectiveBillingConfig().catalogMode).toBe('operator');
});
it('defaults the premium product name to Plutonium on hosted and Premium on self-hosted', async () => {
const repository = createRepository();
Config.instance.selfHosted = false;
const hosted = await repository.getAppPublicConfig();
expect(hosted.branding.premium_product_name).toBe('Plutonium');
expect(hosted.branding.premium_info_url).toBeNull();
Config.instance.selfHosted = true;
expect((await repository.getAppPublicConfig()).branding.premium_product_name).toBe('Premium');
});
it('stores the premium name nullable so a reset and unrelated branding saves keep the default', async () => {
Config.instance.selfHosted = true;
const repository = createRepository();
await repository.setAppPublicConfig({branding: {theme_color: '#123456'}});
expect(await readRaw(repository, APP_PUBLIC_CONFIG_KEY)).toMatchObject({
branding: {theme_color: '#123456', premium_product_name: null},
});
const named = await repository.setAppPublicConfig({
branding: {premium_product_name: 'Gold', premium_info_url: 'https://example.com/gold'},
});
expect(named.branding.premium_product_name).toBe('Gold');
expect(named.branding.premium_info_url).toBe('https://example.com/gold');
const unrelated = await repository.setAppPublicConfig({branding: {product_name: 'Example'}});
expect(unrelated.branding.premium_product_name).toBe('Gold');
expect(unrelated.branding.premium_info_url).toBe('https://example.com/gold');
const reset = await repository.setAppPublicConfig({branding: {premium_product_name: null, premium_info_url: null}});
expect(reset.branding.premium_product_name).toBe('Premium');
expect(reset.branding.premium_info_url).toBeNull();
expect(await readRaw(repository, APP_PUBLIC_CONFIG_KEY)).toMatchObject({
branding: {product_name: 'Example', premium_product_name: null, premium_info_url: null},
});
Config.instance.selfHosted = false;
expect((await repository.getAppPublicConfig()).branding.premium_product_name).toBe('Plutonium');
});
});
@@ -12,6 +12,7 @@ import type {InstanceCaptchaEffectiveConfig} from '@app/api/instance/InstanceCon
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import {isBillingActive, isPremiumTieringActive, isStripeServiceable} from '@app/api/stripe/BillingConfigCache';
import type {HonoEnv} from '@app/api/types/HonoEnv';
import {API_CODE_VERSION} from '@fluxer/constants/src/AppConstants';
import {buildDiscoveryResponse, type DiscoveryStaticInput} from '@fluxer/instance_bootstrap/src/BuildDiscovery';
@@ -66,7 +67,9 @@ function buildDiscoveryStaticInput(
},
features: {
voice_enabled: Config.voice.enabled,
stripe_enabled: Config.stripe.enabled,
stripe_enabled: isBillingActive(),
premium_enabled: isPremiumTieringActive(),
stripe_serviceable: isStripeServiceable(),
self_hosted: Config.instance.selfHosted,
presigned_attachment_uploads: Config.presignedAttachmentUploadsEnabled,
emails_enabled: runtime.emailEnabled,