Hard paywall

This commit is contained in:
2026-04-28 20:35:53 +02:00
parent 05efbb9910
commit 86631a9bc0
15 changed files with 15251 additions and 14164 deletions

View File

@@ -1,17 +1,18 @@
const { get, run } = require('./postgres');
const FREE_MONTHLY_CREDITS = 15;
const PRO_MONTHLY_CREDITS = 250;
const TOPUP_DEFAULT_CREDITS = 60;
const FREE_MONTHLY_CREDITS = 0;
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 TOPUP_CREDITS_BY_PRODUCT = {
monthly_pro: 0,
yearly_pro: 0,
topup_small: 25,
topup_medium: 120,
topup_large: 300,
topup_small: 30,
topup_medium: 100,
topup_large: 250,
};
const AVAILABLE_PRODUCTS = ['monthly_pro', 'yearly_pro', 'topup_small', 'topup_medium', 'topup_large'];
@@ -64,6 +65,22 @@ const getMonthlyAllowanceForPlan = (plan) => {
return plan === 'pro' ? PRO_MONTHLY_CREDITS : FREE_MONTHLY_CREDITS;
};
const getRevenueCatPeriodType = (source) => {
return String(source?.periodType || source?.period_type || '').trim().toLowerCase();
};
const isRevenueCatTrial = (source) => {
return getRevenueCatPeriodType(source) === 'trial';
};
const isAllowedMonthlyAllowance = (account) => {
if (account.plan === 'pro') {
return account.monthlyAllowance === PRO_MONTHLY_CREDITS
|| account.monthlyAllowance === TRIAL_MONTHLY_CREDITS;
}
return account.monthlyAllowance === FREE_MONTHLY_CREDITS;
};
const createInsufficientCreditsError = (required, available) => {
const error = new Error(`Insufficient credits. Required ${required}, available ${available}.`);
error.code = 'INSUFFICIENT_CREDITS';
@@ -130,7 +147,7 @@ const buildDefaultAccount = (userId, now) => {
const alignAccountToCurrentCycle = (account, now) => {
const next = { ...account };
const expectedMonthlyAllowance = getMonthlyAllowanceForPlan(next.plan);
if (next.monthlyAllowance !== expectedMonthlyAllowance) {
if (!isAllowedMonthlyAllowance(next)) {
next.monthlyAllowance = expectedMonthlyAllowance;
}
@@ -238,6 +255,7 @@ const getOrCreateAccount = async (db, userId) => {
};
const getAvailableCredits = (account) => {
if (account.plan !== 'pro') return 0;
const monthlyRemaining = Math.max(0, account.monthlyAllowance - account.usedThisCycle);
return monthlyRemaining + Math.max(0, account.topupBalance);
};
@@ -326,14 +344,22 @@ const getValidProEntitlement = (customerInfo) => {
const applyRevenueCatEntitlementState = (account, options) => {
const now = new Date();
const previousPlan = account.plan;
const previousMonthlyAllowance = account.monthlyAllowance;
const nextPlan = options.active ? 'pro' : 'free';
const planChanged = account.plan !== nextPlan;
const nextMonthlyAllowance = options.active && options.isTrial
? TRIAL_MONTHLY_CREDITS
: getMonthlyAllowanceForPlan(nextPlan);
const planChanged = previousPlan !== nextPlan;
const trialConvertedToPaid = previousPlan === 'pro'
&& previousMonthlyAllowance === TRIAL_MONTHLY_CREDITS
&& nextMonthlyAllowance === PRO_MONTHLY_CREDITS;
account.plan = nextPlan;
account.provider = 'revenuecat';
account.monthlyAllowance = getMonthlyAllowanceForPlan(account.plan);
account.monthlyAllowance = nextMonthlyAllowance;
account.renewsAt = options.active ? options.renewsAt || account.renewsAt || addDays(now, 30).toISOString() : null;
if (planChanged) {
if (planChanged || trialConvertedToPaid) {
const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now);
account.cycleStartedAt = cycleStartedAt.toISOString();
account.cycleEndsAt = cycleEndsAt.toISOString();
@@ -401,6 +427,7 @@ const syncRevenueCatCustomerInfo = async (db, userId, customerInfo, options = {}
if (source !== 'topup_purchase') {
applyRevenueCatEntitlementState(account, {
active: Boolean(proEntitlement),
isTrial: isRevenueCatTrial(proEntitlement),
renewsAt: asIsoDate(proEntitlement?.expirationDate || proEntitlement?.expiresDate),
});
}
@@ -490,6 +517,7 @@ const syncRevenueCatWebhookEvent = async (db, eventPayload) => {
if (affectsProEntitlement && shouldGrantRevenueCatSubscription(eventType)) {
applyRevenueCatEntitlementState(account, {
active: true,
isTrial: isRevenueCatTrial(eventPayload),
renewsAt: asIsoDate(eventPayload?.expiration_at_ms || eventPayload?.expiration_at),
});
} else if (affectsProEntitlement && shouldRevokeRevenueCatSubscription(eventType)) {
@@ -519,6 +547,9 @@ const syncRevenueCatWebhookEvent = async (db, eventPayload) => {
const consumeCredits = (account, cost) => {
if (cost <= 0) return 0;
if (account.plan !== 'pro') {
throw createInsufficientCreditsError(cost, 0);
}
const available = getAvailableCredits(account);
if (available < cost) {
@@ -567,12 +598,12 @@ const consumeCreditsWithIdempotency = async (db, userId, key, cost) => {
const getBillingSummary = async (db, userId) => {
if (userId === 'guest') {
return {
entitlement: { plan: 'free', provider: 'mock', status: 'active', renewsAt: null },
entitlement: { plan: 'free', provider: 'mock', status: 'inactive', renewsAt: null },
credits: {
monthlyAllowance: 5,
monthlyAllowance: 0,
usedThisCycle: 0,
topupBalance: 0,
available: 5,
available: 0,
cycleStartedAt: nowIso(),
cycleEndsAt: nowIso(),
},
@@ -595,7 +626,7 @@ const getAccountSnapshot = async (db, userId) => {
provider: 'mock',
cycleStartedAt: nowIso(),
cycleEndsAt: nowIso(),
monthlyAllowance: 5,
monthlyAllowance: 0,
usedThisCycle: 0,
topupBalance: 0,
renewsAt: null,