mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +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
@@ -5,6 +5,7 @@ import {requireAdminACL} from '@app/api/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
|
||||
import {isPremiumTieringActive} from '@app/api/stripe/BillingConfigCache';
|
||||
import type {HonoApp} from '@app/api/types/HonoEnv';
|
||||
import {Validator} from '@app/api/Validator';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
@@ -25,14 +26,14 @@ export function CodesAdminController(app: HonoApp) {
|
||||
operationId: 'create_admin_gift_codes',
|
||||
summary: 'Issue gift codes',
|
||||
description:
|
||||
'Create one-use Plutonium gift codes with an explicit positive duration and return their complete redemption links. Lifetime gifts are not supported. Not available on self-hosted instances. Requires GIFT_CODES_GENERATE permission.',
|
||||
'Create one-use premium gift codes with an explicit positive duration and return their complete redemption links. Lifetime gifts are not supported. On self-hosted instances the premium mode must be mirror. Requires GIFT_CODES_GENERATE permission.',
|
||||
responseSchema: CodesResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
if (Config.instance.selfHosted) {
|
||||
if (!isPremiumTieringActive()) {
|
||||
throw new FeatureNotAvailableSelfHostedError();
|
||||
}
|
||||
const adminService = ctx.get('adminService');
|
||||
|
||||
@@ -22,6 +22,7 @@ import {RateLimitConfigs} from '@app/api/RateLimitConfig';
|
||||
import type {HonoApp, HonoEnv} from '@app/api/types/HonoEnv';
|
||||
import {Validator} from '@app/api/Validator';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {InstancePolicyTransitionNotAllowedError} from '@fluxer/errors/src/domains/core/InstancePolicyTransitionNotAllowedError';
|
||||
import {
|
||||
BrandingAssetUploadRequest,
|
||||
@@ -88,12 +89,13 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
instanceConfigRepository.getRegistrationUrlsForAdmin(),
|
||||
instanceConfigRepository.getPendingRegistrations(),
|
||||
]);
|
||||
const [appPublic, policy, resolvedServices, integrations, media] = await Promise.all([
|
||||
const [appPublic, policy, resolvedServices, integrations, media, billing] = await Promise.all([
|
||||
instanceConfigRepository.getAppPublicConfig(),
|
||||
instanceConfigRepository.getInstancePolicyConfig(),
|
||||
instanceConfigRepository.getResolvedServicesConfig(),
|
||||
instanceConfigRepository.getInstanceIntegrationsAdminConfig(),
|
||||
instanceConfigRepository.getInstanceMediaAdminConfig(),
|
||||
instanceConfigRepository.getInstanceBillingAdminConfig(),
|
||||
]);
|
||||
return {
|
||||
sso: {
|
||||
@@ -151,6 +153,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
},
|
||||
integrations,
|
||||
media,
|
||||
billing,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -216,6 +219,95 @@ function relayConsentStamp(
|
||||
: {relay_consent_accepted_at: null, relay_consent_accepted_by: null};
|
||||
}
|
||||
|
||||
function assertSelfHostedBillingSections(data: InstanceConfigUpdateRequest): void {
|
||||
if (Config.instance.selfHosted) {
|
||||
return;
|
||||
}
|
||||
if (data.billing) {
|
||||
throw InputValidationError.create('billing', 'Billing is configured through the environment on this instance');
|
||||
}
|
||||
const branding = data.app_public?.branding;
|
||||
if (!branding) {
|
||||
return;
|
||||
}
|
||||
for (const field of ['premium_product_name', 'premium_info_url'] as const) {
|
||||
if (readOptionalField(branding, field) !== undefined) {
|
||||
throw InputValidationError.create(
|
||||
`app_public.branding.${field}`,
|
||||
'This setting is only available on self-hosted instances',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function assertBillingCompatibleWithStoredPremiumMode(
|
||||
billing: NonNullable<InstanceConfigUpdateRequest['billing']>,
|
||||
): Promise<void> {
|
||||
const requestedEnabled = readOptionalField(billing, 'enabled');
|
||||
if (requestedEnabled !== true) {
|
||||
return;
|
||||
}
|
||||
const policy = await getInstanceConfigRepository().readStoredInstancePolicyConfig();
|
||||
if (policy.premium_mode === 'everyone') {
|
||||
throw InputValidationError.create('billing.enabled', 'Billing can only be enabled when the premium mode is mirror');
|
||||
}
|
||||
}
|
||||
|
||||
async function assertPremiumModeCompatibleWithStoredBilling(
|
||||
requestedBillingEnabled: boolean | null | undefined,
|
||||
): Promise<void> {
|
||||
if (!Config.instance.selfHosted) {
|
||||
return;
|
||||
}
|
||||
const repository = getInstanceConfigRepository();
|
||||
const [policy, billing] = await Promise.all([
|
||||
repository.readStoredInstancePolicyConfig(),
|
||||
repository.readStoredInstanceBillingConfig(),
|
||||
]);
|
||||
if (policy.premium_mode === 'everyone') {
|
||||
return;
|
||||
}
|
||||
const nextEnabled = requestedBillingEnabled === undefined ? billing.enabled : requestedBillingEnabled;
|
||||
if (nextEnabled === true) {
|
||||
throw InputValidationError.create(
|
||||
'policy.premium_mode',
|
||||
'Disable billing before switching the premium mode to everyone',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertBillingCompatibleWithPremiumMode(data: InstanceConfigUpdateRequest): Promise<void> {
|
||||
if (!Config.instance.selfHosted) {
|
||||
return;
|
||||
}
|
||||
const requestedEnabled = data.billing ? readOptionalField(data.billing, 'enabled') : undefined;
|
||||
const requestedPremiumMode = data.policy ? readOptionalField(data.policy, 'premium_mode') : undefined;
|
||||
if (requestedEnabled === undefined && requestedPremiumMode === undefined) {
|
||||
return;
|
||||
}
|
||||
const currentPremiumMode = (await getInstanceConfigRepository().getInstancePolicyConfig()).premium_mode;
|
||||
const nextPremiumMode = requestedPremiumMode ?? currentPremiumMode;
|
||||
if (nextPremiumMode !== 'everyone') {
|
||||
return;
|
||||
}
|
||||
const nextEnabled =
|
||||
requestedEnabled === undefined
|
||||
? (await getInstanceConfigRepository().readStoredInstanceBillingConfig()).enabled
|
||||
: requestedEnabled;
|
||||
if (nextEnabled !== true) {
|
||||
return;
|
||||
}
|
||||
if (requestedEnabled !== undefined) {
|
||||
throw InputValidationError.create('billing.enabled', 'Billing can only be enabled when the premium mode is mirror');
|
||||
}
|
||||
if (currentPremiumMode !== 'everyone') {
|
||||
throw InputValidationError.create(
|
||||
'policy.premium_mode',
|
||||
'Disable billing before switching the premium mode to everyone',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function listSuppliedSections(data: InstanceConfigUpdateRequest): string | undefined {
|
||||
const sections = Object.entries(data)
|
||||
.filter(([, value]) => value != null)
|
||||
@@ -271,6 +363,8 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
}),
|
||||
async (ctx) => {
|
||||
const data = ctx.req.valid('json');
|
||||
assertSelfHostedBillingSections(data);
|
||||
await assertBillingCompatibleWithPremiumMode(data);
|
||||
const appPublicBeforeUpdate = completesInitialSetup(data, false)
|
||||
? await instanceConfigRepository.getAppPublicConfig()
|
||||
: null;
|
||||
@@ -412,6 +506,8 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
data.app_public.branding,
|
||||
'status_page_incident_history_url',
|
||||
),
|
||||
premium_product_name: readOptionalField(data.app_public.branding, 'premium_product_name'),
|
||||
premium_info_url: readOptionalField(data.app_public.branding, 'premium_info_url'),
|
||||
})
|
||||
: undefined,
|
||||
legal: data.app_public.legal
|
||||
@@ -504,7 +600,28 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
});
|
||||
}
|
||||
if (data.policy) {
|
||||
await applyInstancePolicyUpdate(ctx, data.policy);
|
||||
await applyInstancePolicyUpdate(
|
||||
ctx,
|
||||
data.policy,
|
||||
data.billing ? readOptionalField(data.billing, 'enabled') : undefined,
|
||||
);
|
||||
}
|
||||
if (data.billing) {
|
||||
await assertBillingCompatibleWithStoredPremiumMode(data.billing);
|
||||
await instanceConfigRepository.setInstanceBillingConfig(
|
||||
omitUndefinedFields({
|
||||
enabled: readOptionalField(data.billing, 'enabled'),
|
||||
stripe_secret_key: readOptionalField(data.billing, 'stripe_secret_key'),
|
||||
stripe_webhook_secret: readOptionalField(data.billing, 'stripe_webhook_secret'),
|
||||
default_currency: readOptionalField(data.billing, 'default_currency'),
|
||||
prices: readOptionalField(data.billing, 'prices'),
|
||||
country_currencies: readOptionalField(data.billing, 'country_currencies'),
|
||||
legacy_prices: readOptionalField(data.billing, 'legacy_prices'),
|
||||
automatic_tax: readOptionalField(data.billing, 'automatic_tax'),
|
||||
tax_id_collection: readOptionalField(data.billing, 'tax_id_collection'),
|
||||
terms_consent_required: readOptionalField(data.billing, 'terms_consent_required'),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (data.app_public?.setup) {
|
||||
await instanceConfigRepository.setAppPublicConfig({
|
||||
@@ -700,6 +817,7 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
async function applyInstancePolicyUpdate(
|
||||
ctx: Context<HonoEnv>,
|
||||
policy: NonNullable<InstanceConfigUpdateRequest['policy']>,
|
||||
requestedBillingEnabled: boolean | null | undefined,
|
||||
): Promise<void> {
|
||||
const instanceConfigRepository = getInstanceConfigRepository();
|
||||
const appPublic = await instanceConfigRepository.getAppPublicConfig();
|
||||
@@ -707,6 +825,9 @@ async function applyInstancePolicyUpdate(
|
||||
policy.single_community_enabled === true
|
||||
? await ctx.get('userRepository').findUnique(ctx.get('adminUserId'))
|
||||
: null;
|
||||
if (policy.premium_mode === 'everyone') {
|
||||
await assertPremiumModeCompatibleWithStoredBilling(requestedBillingEnabled);
|
||||
}
|
||||
let enablesSingleCommunity = false;
|
||||
await instanceConfigRepository.updateInstancePolicyConfig((current) => {
|
||||
const planned = planInstancePolicyPatch(policy, current, {
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {getConfig} from '@app/api/Config';
|
||||
import {getCachedInstancePremiumMode, setCachedInstancePremiumMode} from '@app/api/limits/InstancePremiumModeCache';
|
||||
import {getAdminRepository, getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {getStoredBillingConfig, setStoredBillingConfig} from '@app/api/stripe/BillingConfigCache';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import type {InstanceConfigResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const OPERATOR_SECRET_KEY = 'sk_test_operator_secret_value';
|
||||
const OPERATOR_WEBHOOK_SECRET = 'whsec_operator_secret_value';
|
||||
|
||||
interface GlobalState {
|
||||
selfHosted: boolean;
|
||||
premiumMode: ReturnType<typeof getCachedInstancePremiumMode>;
|
||||
storedBilling: ReturnType<typeof getStoredBillingConfig>;
|
||||
}
|
||||
|
||||
function captureGlobalState(): GlobalState {
|
||||
return {
|
||||
selfHosted: getConfig().instance.selfHosted,
|
||||
premiumMode: getCachedInstancePremiumMode(),
|
||||
storedBilling: getStoredBillingConfig(),
|
||||
};
|
||||
}
|
||||
|
||||
function restoreGlobalState(state: GlobalState): void {
|
||||
getConfig().instance.selfHosted = state.selfHosted;
|
||||
setCachedInstancePremiumMode(state.premiumMode);
|
||||
setStoredBillingConfig(state.storedBilling);
|
||||
}
|
||||
|
||||
function useInstanceHarness(selfHosted: boolean) {
|
||||
const context: {harness: ApiTestHarness} = {harness: undefined as unknown as ApiTestHarness};
|
||||
let original: GlobalState;
|
||||
beforeAll(async () => {
|
||||
original = captureGlobalState();
|
||||
getConfig().instance.selfHosted = selfHosted;
|
||||
context.harness = await createApiTestHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await context.harness.reset();
|
||||
setStoredBillingConfig(null);
|
||||
setCachedInstancePremiumMode('everyone');
|
||||
});
|
||||
afterAll(async () => {
|
||||
await context.harness.shutdown();
|
||||
restoreGlobalState(original);
|
||||
});
|
||||
return context;
|
||||
}
|
||||
|
||||
async function createAdmin(harness: ApiTestHarness): Promise<TestAccount> {
|
||||
return await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.INSTANCE_CONFIG_VIEW,
|
||||
AdminACLs.INSTANCE_CONFIG_UPDATE,
|
||||
]);
|
||||
}
|
||||
|
||||
function patchConfig(harness: ApiTestHarness, admin: TestAccount, body: Record<string, unknown>) {
|
||||
return createBuilder<InstanceConfigResponse>(harness, admin.token).patch('/admin/instance/config').body(body);
|
||||
}
|
||||
|
||||
const OPERATOR_BILLING = {
|
||||
enabled: true,
|
||||
stripe_secret_key: OPERATOR_SECRET_KEY,
|
||||
stripe_webhook_secret: OPERATOR_WEBHOOK_SECRET,
|
||||
default_currency: 'GBP',
|
||||
prices: {
|
||||
GBP: {
|
||||
monthly: 'price_monthlygbp',
|
||||
yearly: 'price_yearlygbp',
|
||||
gift_1_month: 'price_gift1monthgbp',
|
||||
gift_1_year: 'price_gift1yeargbp',
|
||||
},
|
||||
},
|
||||
country_currencies: {GB: 'GBP'},
|
||||
legacy_prices: {monthly_GBP: ['price_oldmonthlygbp']},
|
||||
};
|
||||
|
||||
describe('instance config billing on a self-hosted instance', () => {
|
||||
const context = useInstanceHarness(true);
|
||||
|
||||
it('rejects enabling billing while the premium mode is everyone', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
await patchConfig(context.harness, admin, {billing: {enabled: true}})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
|
||||
.execute();
|
||||
await patchConfig(context.harness, admin, {billing: {enabled: true}, policy: {premium_mode: 'everyone'}})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
|
||||
.execute();
|
||||
const current = await createBuilder<InstanceConfigResponse>(context.harness, admin.token)
|
||||
.get('/admin/instance/config')
|
||||
.execute();
|
||||
expect(current.billing.enabled).toBeNull();
|
||||
expect(current.policy.premium_mode).toBe('everyone');
|
||||
});
|
||||
|
||||
it('saves the environment fallback in everyone mode even when the environment enables billing', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
const originalEnabled = getConfig().stripe.enabled;
|
||||
getConfig().stripe.enabled = true;
|
||||
try {
|
||||
const updated = await patchConfig(context.harness, admin, {
|
||||
app_public: {branding: {premium_product_name: 'Gold'}},
|
||||
billing: {enabled: null},
|
||||
}).execute();
|
||||
expect(updated.billing.enabled).toBeNull();
|
||||
expect(updated.billing.billing_active).toBe(false);
|
||||
expect(updated.app_public.branding.premium_product_name).toBe('Gold');
|
||||
await patchConfig(context.harness, admin, {policy: {premium_mode: 'everyone'}}).execute();
|
||||
} finally {
|
||||
getConfig().stripe.enabled = originalEnabled;
|
||||
}
|
||||
});
|
||||
|
||||
it('enables billing and mirror mode from one request and redacts the secrets', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
const updated = await patchConfig(context.harness, admin, {
|
||||
billing: OPERATOR_BILLING,
|
||||
policy: {premium_mode: 'mirror'},
|
||||
}).execute();
|
||||
expect(updated.policy.premium_mode).toBe('mirror');
|
||||
expect(updated.billing).toMatchObject({
|
||||
enabled: true,
|
||||
effective_enabled: true,
|
||||
stripe_secret_key_set: true,
|
||||
stripe_webhook_secret_set: true,
|
||||
default_currency: 'GBP',
|
||||
prices: OPERATOR_BILLING.prices,
|
||||
country_currencies: {GB: 'GBP'},
|
||||
legacy_prices: {monthly_GBP: ['price_oldmonthlygbp']},
|
||||
billing_active: true,
|
||||
catalog_mode: 'operator',
|
||||
});
|
||||
expect(updated.billing.webhook_url).toMatch(/\/stripe\/webhook$/);
|
||||
const {text} = await createBuilder(context.harness, admin.token).get('/admin/instance/config').executeRaw();
|
||||
expect(text).not.toContain(OPERATOR_SECRET_KEY);
|
||||
expect(text).not.toContain(OPERATOR_WEBHOOK_SECRET);
|
||||
expect(JSON.parse(text).billing).toMatchObject({stripe_secret_key_set: true, billing_active: true});
|
||||
});
|
||||
|
||||
it('rejects switching the premium mode to everyone while billing is enabled', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
await patchConfig(context.harness, admin, {billing: OPERATOR_BILLING, policy: {premium_mode: 'mirror'}}).execute();
|
||||
await patchConfig(context.harness, admin, {policy: {premium_mode: 'everyone'}})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
|
||||
.execute();
|
||||
const unchanged = await createBuilder<InstanceConfigResponse>(context.harness, admin.token)
|
||||
.get('/admin/instance/config')
|
||||
.execute();
|
||||
expect(unchanged.policy.premium_mode).toBe('mirror');
|
||||
const switched = await patchConfig(context.harness, admin, {
|
||||
billing: {enabled: false},
|
||||
policy: {premium_mode: 'everyone'},
|
||||
}).execute();
|
||||
expect(switched.policy.premium_mode).toBe('everyone');
|
||||
expect(switched.billing).toMatchObject({enabled: false, effective_enabled: false, billing_active: false});
|
||||
});
|
||||
|
||||
it('clears secrets and the operator catalog with null', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
await patchConfig(context.harness, admin, {billing: OPERATOR_BILLING, policy: {premium_mode: 'mirror'}}).execute();
|
||||
const kept = await patchConfig(context.harness, admin, {billing: {default_currency: 'GBP'}}).execute();
|
||||
expect(kept.billing.prices).toEqual(OPERATOR_BILLING.prices);
|
||||
const cleared = await patchConfig(context.harness, admin, {
|
||||
billing: {enabled: true, stripe_webhook_secret: null, prices: null, legacy_prices: null},
|
||||
}).execute();
|
||||
expect(cleared.billing).toMatchObject({
|
||||
enabled: true,
|
||||
prices: null,
|
||||
legacy_prices: null,
|
||||
catalog_mode: 'env',
|
||||
stripe_secret_key_set: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects malformed billing input', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
for (const billing of [
|
||||
{prices: {gbp: {monthly: 'price_x'}}},
|
||||
{prices: {GBP: {monthly: 'not_a_price'}}},
|
||||
{country_currencies: {GBR: 'GBP'}},
|
||||
{legacy_prices: {weekly_GBP: ['price_x']}},
|
||||
{default_currency: 'pounds'},
|
||||
]) {
|
||||
await patchConfig(context.harness, admin, {billing})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
|
||||
.execute();
|
||||
}
|
||||
});
|
||||
|
||||
it('clears the premium name and info URL when both are sent as null', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
await patchConfig(context.harness, admin, {
|
||||
app_public: {branding: {premium_product_name: 'Gold', premium_info_url: 'https://example.com/gold'}},
|
||||
}).execute();
|
||||
const reset = await patchConfig(context.harness, admin, {
|
||||
app_public: {branding: {premium_product_name: null, premium_info_url: null}},
|
||||
}).execute();
|
||||
expect(reset.app_public.branding).toMatchObject({premium_product_name: 'Premium', premium_info_url: null});
|
||||
await patchConfig(context.harness, admin, {
|
||||
app_public: {branding: {premium_product_name: 'Gold', premium_info_url: 'https://example.com/gold'}},
|
||||
}).execute();
|
||||
const resetWithBilling = await patchConfig(context.harness, admin, {
|
||||
app_public: {branding: {premium_product_name: null, premium_info_url: null}},
|
||||
billing: {enabled: false},
|
||||
}).execute();
|
||||
expect(resetWithBilling.app_public.branding).toMatchObject({
|
||||
premium_product_name: 'Premium',
|
||||
premium_info_url: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('applies billing sections that only hold nulls', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
await patchConfig(context.harness, admin, {billing: OPERATOR_BILLING, policy: {premium_mode: 'mirror'}}).execute();
|
||||
const envCatalog = await patchConfig(context.harness, admin, {billing: {prices: null}}).execute();
|
||||
expect(envCatalog.billing).toMatchObject({prices: null, catalog_mode: 'env', enabled: true});
|
||||
const followsEnv = await patchConfig(context.harness, admin, {billing: {enabled: null}}).execute();
|
||||
expect(followsEnv.billing).toMatchObject({enabled: null, effective_enabled: getConfig().stripe.enabled});
|
||||
});
|
||||
|
||||
it('applies the policy before billing so a failed policy change stores no billing', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
await patchConfig(context.harness, admin, {app_public: {setup: {configured: true}}}).execute();
|
||||
await patchConfig(context.harness, admin, {
|
||||
billing: {enabled: true},
|
||||
policy: {premium_mode: 'mirror', single_community_enabled: true},
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
const current = await createBuilder<InstanceConfigResponse>(context.harness, admin.token)
|
||||
.get('/admin/instance/config')
|
||||
.execute();
|
||||
expect(current.policy.premium_mode).toBe('everyone');
|
||||
expect(current.billing.enabled).toBeNull();
|
||||
});
|
||||
|
||||
it('re-checks the stored premium mode right before the billing write', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
const repository = getInstanceConfigRepository();
|
||||
const stored = await repository.getInstancePolicyConfig();
|
||||
const spy = vi.spyOn(repository, 'getInstancePolicyConfig').mockResolvedValue({...stored, premium_mode: 'mirror'});
|
||||
try {
|
||||
await patchConfig(context.harness, admin, {billing: {enabled: true}})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
|
||||
.execute();
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
const current = await createBuilder<InstanceConfigResponse>(context.harness, admin.token)
|
||||
.get('/admin/instance/config')
|
||||
.execute();
|
||||
expect(current.billing.enabled).toBeNull();
|
||||
});
|
||||
|
||||
it('re-checks the stored billing right before the premium mode write', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
await patchConfig(context.harness, admin, {billing: OPERATOR_BILLING, policy: {premium_mode: 'mirror'}}).execute();
|
||||
const cached = getStoredBillingConfig();
|
||||
expect(cached?.enabled).toBe(true);
|
||||
setStoredBillingConfig(cached === null ? null : {...cached, enabled: false});
|
||||
await patchConfig(context.harness, admin, {policy: {premium_mode: 'everyone'}})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
|
||||
.execute();
|
||||
const current = await createBuilder<InstanceConfigResponse>(context.harness, admin.token)
|
||||
.get('/admin/instance/config')
|
||||
.execute();
|
||||
expect(current.policy.premium_mode).toBe('mirror');
|
||||
expect(current.billing.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('records the billing section in the audit log without secrets', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
await patchConfig(context.harness, admin, {billing: OPERATOR_BILLING, policy: {premium_mode: 'mirror'}}).execute();
|
||||
const logs = await getAdminRepository().listAllAuditLogsPaginated(100000);
|
||||
const update = logs.find((log) => log.action === 'update_instance_config');
|
||||
expect(update?.metadata.get('sections')).toBe('billing,policy');
|
||||
const serialized = JSON.stringify(logs.map((log) => [...log.metadata.entries()]));
|
||||
expect(serialized).not.toContain(OPERATOR_SECRET_KEY);
|
||||
expect(serialized).not.toContain(OPERATOR_WEBHOOK_SECRET);
|
||||
});
|
||||
|
||||
it('stores the premium name and info URL and resets the name to the self-hosted default', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
const initial = await createBuilder<InstanceConfigResponse>(context.harness, admin.token)
|
||||
.get('/admin/instance/config')
|
||||
.execute();
|
||||
expect(initial.app_public.branding).toMatchObject({premium_product_name: 'Premium', premium_info_url: null});
|
||||
const named = await patchConfig(context.harness, admin, {
|
||||
app_public: {branding: {premium_product_name: 'Gold', premium_info_url: 'https://example.com/gold'}},
|
||||
}).execute();
|
||||
expect(named.app_public.branding).toMatchObject({
|
||||
premium_product_name: 'Gold',
|
||||
premium_info_url: 'https://example.com/gold',
|
||||
});
|
||||
const renamedProduct = await patchConfig(context.harness, admin, {
|
||||
app_public: {branding: {product_name: 'Example Chat'}},
|
||||
}).execute();
|
||||
expect(renamedProduct.app_public.branding.premium_product_name).toBe('Gold');
|
||||
const reset = await patchConfig(context.harness, admin, {
|
||||
app_public: {branding: {product_name: 'Example Chat', premium_product_name: null, premium_info_url: null}},
|
||||
}).execute();
|
||||
expect(reset.app_public.branding).toMatchObject({premium_product_name: 'Premium', premium_info_url: null});
|
||||
await patchConfig(context.harness, admin, {app_public: {branding: {premium_info_url: 'javascript:alert(1)'}}})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('instance config billing on a hosted instance', () => {
|
||||
const context = useInstanceHarness(false);
|
||||
|
||||
it('keeps the Plutonium default and env billing', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
const current = await createBuilder<InstanceConfigResponse>(context.harness, admin.token)
|
||||
.get('/admin/instance/config')
|
||||
.execute();
|
||||
expect(current.app_public.branding.premium_product_name).toBe('Plutonium');
|
||||
expect(current.billing).toMatchObject({enabled: null, catalog_mode: 'env', stripe_secret_key_set: true});
|
||||
const updated = await patchConfig(context.harness, admin, {policy: {premium_mode: 'everyone'}}).execute();
|
||||
expect(updated.policy.premium_mode).toBe('everyone');
|
||||
});
|
||||
|
||||
it('rejects the billing section and the premium branding fields', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
for (const body of [
|
||||
{billing: {enabled: true}},
|
||||
{billing: {enabled: null}},
|
||||
{billing: {prices: null}},
|
||||
{billing: OPERATOR_BILLING},
|
||||
{app_public: {branding: {premium_product_name: 'Gold'}}},
|
||||
{app_public: {branding: {premium_info_url: 'https://example.com/gold'}}},
|
||||
{app_public: {branding: {premium_product_name: null, premium_info_url: null}}},
|
||||
{app_public: {branding: {product_name: 'Fluxer', premium_product_name: 'Gold'}}},
|
||||
]) {
|
||||
await patchConfig(context.harness, admin, body)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
|
||||
.execute();
|
||||
}
|
||||
const current = await createBuilder<InstanceConfigResponse>(context.harness, admin.token)
|
||||
.get('/admin/instance/config')
|
||||
.execute();
|
||||
expect(current.billing).toMatchObject({enabled: null, catalog_mode: 'env', prices: null});
|
||||
expect(current.app_public.branding).toMatchObject({premium_product_name: 'Plutonium', premium_info_url: null});
|
||||
expect(getStoredBillingConfig()?.prices ?? null).toBeNull();
|
||||
});
|
||||
|
||||
it('still accepts other branding fields', async () => {
|
||||
const admin = await createAdmin(context.harness);
|
||||
const updated = await patchConfig(context.harness, admin, {
|
||||
app_public: {branding: {theme_color: '#123456'}},
|
||||
}).execute();
|
||||
expect(updated.app_public.branding.theme_color).toBe('#123456');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user