feat(billing): add weekly_pro subscription plan

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>
This commit is contained in:
Timo
2026-08-10 12:50:12 +02:00
parent 5da8027b68
commit e3a28b0a1c
9 changed files with 800 additions and 161 deletions

View File

@@ -7,6 +7,9 @@ const {
consumeCredits,
ensureSufficientCredits,
getMonthlyAllowanceForPlan,
getValidProEntitlement,
applyRevenueCatEntitlementState,
AVAILABLE_PRODUCTS,
} = require('../lib/billing');
const NOW = new Date('2026-07-06T12:00:00Z');
@@ -88,3 +91,42 @@ 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);
});