Adds a 2.99 EUR/week plan with a 3-day free trial alongside the existing monthly and yearly subscriptions. Backend: weekly_pro joins the supported subscription products, the available product list and the Discord sales label. No schema change -- weekly Pro grants the same 100 credits per calendar month as monthly Pro, so no column is needed to tell the two apart. Paywall: weekly and yearly are the two prominent cards, monthly is a selectable row below them. Weekly is preselected. Cards only render when their RevenueCat package exists, and the selection falls back to a visible plan so the CTA can never buy a product that is not loaded. Trial eligibility: checkTrialOrIntroductoryPriceEligibility now gates the trial copy. Apple grants one intro offer per subscription group, so with two trial products a second free-trial promise would otherwise be shown to users who get charged immediately. Anything but a clear ELIGIBLE is treated as no trial, as the RevenueCat SDK recommends. Analytics: trial_started previously fired on every subscription purchase, including monthly which never had a trial. It now fires only for products that actually carry one. paywall_viewed distinguishes trial_enabled from trial_eligible and reports selected_plan. Tests: 9 new cases covering the entitlement path, credits, renewal period and trial allowance. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
133 lines
5.1 KiB
JavaScript
133 lines
5.1 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const {
|
|
buildDefaultAccount,
|
|
alignAccountToCurrentCycle,
|
|
getAvailableCredits,
|
|
consumeCredits,
|
|
ensureSufficientCredits,
|
|
getMonthlyAllowanceForPlan,
|
|
getValidProEntitlement,
|
|
applyRevenueCatEntitlementState,
|
|
AVAILABLE_PRODUCTS,
|
|
} = require('../lib/billing');
|
|
|
|
const NOW = new Date('2026-07-06T12:00:00Z');
|
|
|
|
const freeAccount = (overrides = {}) => ({
|
|
...buildDefaultAccount('user-1', NOW),
|
|
...overrides,
|
|
});
|
|
|
|
test('free plan gets 3 monthly credits', () => {
|
|
assert.equal(getMonthlyAllowanceForPlan('free'), 3);
|
|
assert.equal(buildDefaultAccount('u', NOW).monthlyAllowance, 3);
|
|
});
|
|
|
|
test('free account has available credits', () => {
|
|
const account = freeAccount({ monthlyAllowance: 3, usedThisCycle: 1 });
|
|
assert.equal(getAvailableCredits(account), 2);
|
|
});
|
|
|
|
test('free account topup balance counts as available', () => {
|
|
const account = freeAccount({ monthlyAllowance: 3, usedThisCycle: 3, topupBalance: 10 });
|
|
assert.equal(getAvailableCredits(account), 10);
|
|
});
|
|
|
|
test('consumeCredits charges a free account from the monthly allowance', () => {
|
|
const account = freeAccount({ monthlyAllowance: 3, usedThisCycle: 0 });
|
|
const charged = consumeCredits(account, 1);
|
|
assert.equal(charged, 1);
|
|
assert.equal(account.usedThisCycle, 1);
|
|
});
|
|
|
|
test('consumeCredits throws 402 for an exhausted free account', () => {
|
|
const account = freeAccount({ monthlyAllowance: 3, usedThisCycle: 3, topupBalance: 0 });
|
|
assert.throws(() => consumeCredits(account, 1), (error) => {
|
|
assert.equal(error.code, 'INSUFFICIENT_CREDITS');
|
|
assert.equal(error.status, 402);
|
|
assert.deepEqual(error.metadata, { required: 1, available: 0 });
|
|
return true;
|
|
});
|
|
});
|
|
|
|
test('legacy free account with allowance 0 is migrated to 3', () => {
|
|
const account = freeAccount({ monthlyAllowance: 0 });
|
|
const aligned = alignAccountToCurrentCycle(account, NOW);
|
|
assert.equal(aligned.monthlyAllowance, 3);
|
|
});
|
|
|
|
test('pro and trial allowances are unchanged', () => {
|
|
assert.equal(getMonthlyAllowanceForPlan('pro'), 100);
|
|
const trial = freeAccount({ plan: 'pro', monthlyAllowance: 30, usedThisCycle: 5 });
|
|
const aligned = alignAccountToCurrentCycle(trial, NOW);
|
|
assert.equal(aligned.monthlyAllowance, 30); // trial allowance stays allowed
|
|
assert.equal(getAvailableCredits(aligned), 25);
|
|
});
|
|
|
|
test('monthly cycle rollover resets free usage', () => {
|
|
const account = freeAccount({
|
|
monthlyAllowance: 3,
|
|
usedThisCycle: 3,
|
|
cycleEndsAt: '2026-07-01T00:00:00.000Z',
|
|
});
|
|
const aligned = alignAccountToCurrentCycle(account, NOW);
|
|
assert.equal(aligned.usedThisCycle, 0);
|
|
assert.equal(aligned.monthlyAllowance, 3);
|
|
assert.equal(getAvailableCredits(aligned), 3);
|
|
});
|
|
|
|
test('ensureSufficientCredits throws 402 when balance is below cost', () => {
|
|
const account = freeAccount({ monthlyAllowance: 3, usedThisCycle: 2, topupBalance: 0 });
|
|
assert.throws(() => ensureSufficientCredits(account, 2), (error) => {
|
|
assert.equal(error.code, 'INSUFFICIENT_CREDITS');
|
|
assert.equal(error.status, 402);
|
|
assert.deepEqual(error.metadata, { required: 2, available: 1 });
|
|
return true;
|
|
});
|
|
});
|
|
|
|
test('ensureSufficientCredits passes when balance covers cost', () => {
|
|
const account = freeAccount({ monthlyAllowance: 3, usedThisCycle: 1, topupBalance: 0 });
|
|
assert.doesNotThrow(() => ensureSufficientCredits(account, 2));
|
|
});
|
|
|
|
const proEntitlement = (productIdentifier, extra = {}) => ({
|
|
entitlements: { active: { pro: { productIdentifier, ...extra } } },
|
|
allPurchasedProductIdentifiers: [productIdentifier],
|
|
});
|
|
|
|
test('weekly_pro is an offered product', () => {
|
|
assert.ok(AVAILABLE_PRODUCTS.includes('weekly_pro'));
|
|
});
|
|
|
|
test('weekly_pro backs a valid pro entitlement', () => {
|
|
// Ohne diesen Eintrag in SUPPORTED_SUBSCRIPTION_PRODUCTS verwirft der Sync das
|
|
// aktive Entitlement und der Wochenabonnent bleibt nach dem Kauf auf 'free'.
|
|
const entitlement = getValidProEntitlement(proEntitlement('weekly_pro'));
|
|
assert.ok(entitlement);
|
|
assert.equal(entitlement.productIdentifier, 'weekly_pro');
|
|
});
|
|
|
|
test('monthly_pro and yearly_pro still back a valid pro entitlement', () => {
|
|
assert.ok(getValidProEntitlement(proEntitlement('monthly_pro')));
|
|
assert.ok(getValidProEntitlement(proEntitlement('yearly_pro')));
|
|
});
|
|
|
|
test('an unrelated product does not back a pro entitlement', () => {
|
|
// Regressionsschutz: die Produktliste darf sich nicht zu einem Freibrief entwickeln.
|
|
assert.equal(getValidProEntitlement(proEntitlement('topup_small')), null);
|
|
});
|
|
|
|
test('weekly_pro grants the full pro allowance, its trial only the trial allowance', () => {
|
|
const paid = freeAccount();
|
|
applyRevenueCatEntitlementState(paid, { active: true, isTrial: false, renewsAt: null });
|
|
assert.equal(paid.plan, 'pro');
|
|
assert.equal(paid.monthlyAllowance, 100);
|
|
|
|
const trial = freeAccount();
|
|
applyRevenueCatEntitlementState(trial, { active: true, isTrial: true, renewsAt: null });
|
|
assert.equal(trial.plan, 'pro');
|
|
assert.equal(trial.monthlyAllowance, 30);
|
|
});
|