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:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user