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

@@ -241,6 +241,76 @@ describe('mockBackendService billing simulation', () => {
expect(response.billing.entitlement.renewsAt).toBe('2026-04-30T00:00:00.000Z');
});
it('grants pro from a weekly_pro purchase and renews after 7 days', async () => {
const response = await mockBackendService.simulatePurchase({
userId: 'test-user-weekly-purchase',
idempotencyKey: 'weekly-1',
productId: 'weekly_pro',
});
expect(response.appliedProduct).toBe('weekly_pro');
expect(response.billing.entitlement.plan).toBe('pro');
expect(response.billing.credits.monthlyAllowance).toBe(100);
// Das Wochenabo verlaengert sich nach 7 Tagen, nicht nach 30 wie die anderen Plaene.
const renewsInDays = (new Date(response.billing.entitlement.renewsAt as string).getTime() - Date.now())
/ (24 * 60 * 60 * 1000);
expect(renewsInDays).toBeCloseTo(7, 3);
});
it('offers weekly_pro as a purchasable product', async () => {
const response = await mockBackendService.simulatePurchase({
userId: 'test-user-weekly-products',
idempotencyKey: 'weekly-products-1',
productId: 'topup_small',
});
expect(response.billing.availableProducts).toContain('weekly_pro');
});
it('syncs pro entitlement from a weekly_pro RevenueCat subscription', async () => {
// Regressionsschutz: fehlt weekly_pro im unterstuetzten Produkt-Set, verwirft der
// lokale Sync das aktive Entitlement und der Kaeufer bleibt auf 'free'.
const response = await mockBackendService.syncRevenueCatState({
userId: 'test-user-rc-weekly',
customerInfo: {
entitlements: {
active: {
pro: {
productIdentifier: 'weekly_pro',
expirationDate: '2026-04-30T00:00:00.000Z',
},
},
},
nonSubscriptions: {},
},
});
expect(response.billing.entitlement.plan).toBe('pro');
expect(response.billing.entitlement.status).toBe('active');
});
it('limits a weekly_pro trial to trial credits', async () => {
const response = await mockBackendService.syncRevenueCatState({
userId: 'test-user-rc-weekly-trial',
customerInfo: {
entitlements: {
active: {
pro: {
productIdentifier: 'weekly_pro',
expirationDate: '2026-04-30T00:00:00.000Z',
periodType: 'TRIAL',
},
},
},
nonSubscriptions: {},
},
});
expect(response.billing.entitlement.plan).toBe('pro');
expect(response.billing.credits.monthlyAllowance).toBe(30);
});
it('limits RevenueCat trial entitlement to trial credits', async () => {
const response = await mockBackendService.syncRevenueCatState({
userId: 'test-user-rc-trial',