mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user