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:
@@ -5,9 +5,10 @@ const TRIAL_MONTHLY_CREDITS = 30;
|
||||
const PRO_MONTHLY_CREDITS = 100;
|
||||
const TOPUP_DEFAULT_CREDITS = 100;
|
||||
const REVENUECAT_PRO_ENTITLEMENT_ID = (process.env.REVENUECAT_PRO_ENTITLEMENT_ID || 'pro').trim() || 'pro';
|
||||
const SUPPORTED_SUBSCRIPTION_PRODUCTS = new Set(['monthly_pro', 'yearly_pro']);
|
||||
const SUPPORTED_SUBSCRIPTION_PRODUCTS = new Set(['weekly_pro', 'monthly_pro', 'yearly_pro']);
|
||||
|
||||
const TOPUP_CREDITS_BY_PRODUCT = {
|
||||
weekly_pro: 0,
|
||||
monthly_pro: 0,
|
||||
yearly_pro: 0,
|
||||
topup_small: 30,
|
||||
@@ -15,7 +16,7 @@ const TOPUP_CREDITS_BY_PRODUCT = {
|
||||
topup_large: 250,
|
||||
};
|
||||
|
||||
const AVAILABLE_PRODUCTS = ['monthly_pro', 'yearly_pro', 'topup_small', 'topup_medium', 'topup_large'];
|
||||
const AVAILABLE_PRODUCTS = ['weekly_pro', 'monthly_pro', 'yearly_pro', 'topup_small', 'topup_medium', 'topup_large'];
|
||||
|
||||
const nowIso = () => new Date().toISOString();
|
||||
|
||||
@@ -872,4 +873,6 @@ module.exports = {
|
||||
getAvailableCredits,
|
||||
consumeCredits,
|
||||
buildBillingSummary,
|
||||
getValidProEntitlement,
|
||||
applyRevenueCatEntitlementState,
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ const PURCHASE_EVENT_TYPES = new Set([
|
||||
]);
|
||||
|
||||
const PLAN_NAMES_BY_PRODUCT = {
|
||||
weekly_pro: 'Pro (wöchentlich)',
|
||||
monthly_pro: 'Pro (monatlich)',
|
||||
yearly_pro: 'Pro (jährlich)',
|
||||
topup_small: 'Top-up Small (30 Credits)',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user