const { get, run } = require('./postgres'); const FREE_MONTHLY_CREDITS = 15; const PRO_MONTHLY_CREDITS = 250; const TOPUP_DEFAULT_CREDITS = 60; 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, }; const AVAILABLE_PRODUCTS = ['monthly_pro', 'yearly_pro', 'topup_small', 'topup_medium', 'topup_large']; const nowIso = () => new Date().toISOString(); const asIsoDate = (value) => { if (value == null || value === '') return null; if (value instanceof Date) { return Number.isNaN(value.getTime()) ? null : value.toISOString(); } if (typeof value === 'number' && Number.isFinite(value)) { return new Date(value).toISOString(); } if (typeof value === 'string') { const trimmed = value.trim(); if (!trimmed) return null; if (/^\d+$/.test(trimmed)) { return new Date(Number(trimmed)).toISOString(); } const parsed = new Date(trimmed); if (!Number.isNaN(parsed.getTime())) { return parsed.toISOString(); } } return null; }; const startOfUtcMonth = (date) => { return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1, 0, 0, 0, 0)); }; const addUtcMonths = (date, months) => { return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + months, 1, 0, 0, 0, 0)); }; const addDays = (date, days) => { const result = new Date(date.getTime()); result.setUTCDate(result.getUTCDate() + days); return result; }; const getCycleBounds = (now) => { const cycleStartedAt = startOfUtcMonth(now); const cycleEndsAt = addUtcMonths(cycleStartedAt, 1); return { cycleStartedAt, cycleEndsAt }; }; const getMonthlyAllowanceForPlan = (plan) => { return plan === 'pro' ? PRO_MONTHLY_CREDITS : FREE_MONTHLY_CREDITS; }; const createInsufficientCreditsError = (required, available) => { const error = new Error(`Insufficient credits. Required ${required}, available ${available}.`); error.code = 'INSUFFICIENT_CREDITS'; error.status = 402; error.metadata = { required, available }; return error; }; const runInTransaction = async (db, worker) => { const client = typeof db.connect === 'function' ? await db.connect() : db; const release = typeof client.release === 'function' ? () => client.release() : () => {}; await run(client, 'BEGIN'); try { const result = await worker(client); await run(client, 'COMMIT'); return result; } catch (error) { try { await run(client, 'ROLLBACK'); } catch (rollbackError) { console.error('Failed to rollback billing transaction.', rollbackError); } throw error; } finally { release(); } }; const normalizeAccountRow = (row) => { if (!row) return null; const now = new Date(); const { cycleStartedAt: defaultCycleStartedAt, cycleEndsAt: defaultCycleEndsAt } = getCycleBounds(now); return { userId: String(row.userId), plan: row.plan === 'pro' ? 'pro' : 'free', provider: typeof row.provider === 'string' && row.provider ? row.provider : 'revenuecat', cycleStartedAt: asIsoDate(row.cycleStartedAt) || defaultCycleStartedAt.toISOString(), cycleEndsAt: asIsoDate(row.cycleEndsAt) || defaultCycleEndsAt.toISOString(), monthlyAllowance: Number(row.monthlyAllowance) || FREE_MONTHLY_CREDITS, usedThisCycle: Number(row.usedThisCycle) || 0, topupBalance: Number(row.topupBalance) || 0, renewsAt: asIsoDate(row.renewsAt), updatedAt: asIsoDate(row.updatedAt) || now.toISOString(), }; }; const buildDefaultAccount = (userId, now) => { const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now); return { userId, plan: 'free', provider: 'revenuecat', cycleStartedAt: cycleStartedAt.toISOString(), cycleEndsAt: cycleEndsAt.toISOString(), monthlyAllowance: FREE_MONTHLY_CREDITS, usedThisCycle: 0, topupBalance: 0, renewsAt: null, updatedAt: nowIso(), }; }; const alignAccountToCurrentCycle = (account, now) => { const next = { ...account }; const expectedMonthlyAllowance = getMonthlyAllowanceForPlan(next.plan); if (next.monthlyAllowance !== expectedMonthlyAllowance) { next.monthlyAllowance = expectedMonthlyAllowance; } if (!next.renewsAt && next.plan === 'pro') { next.renewsAt = addDays(now, 30).toISOString(); } const cycleEndsAtMs = new Date(next.cycleEndsAt).getTime(); if (Number.isNaN(cycleEndsAtMs) || now.getTime() >= cycleEndsAtMs) { const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now); next.cycleStartedAt = cycleStartedAt.toISOString(); next.cycleEndsAt = cycleEndsAt.toISOString(); next.usedThisCycle = 0; next.monthlyAllowance = expectedMonthlyAllowance; } return next; }; const accountChanged = (a, b) => { return a.userId !== b.userId || a.plan !== b.plan || a.provider !== b.provider || a.cycleStartedAt !== b.cycleStartedAt || a.cycleEndsAt !== b.cycleEndsAt || a.monthlyAllowance !== b.monthlyAllowance || a.usedThisCycle !== b.usedThisCycle || a.topupBalance !== b.topupBalance || a.renewsAt !== b.renewsAt; }; const upsertAccount = async (db, account) => { await run( db, `INSERT INTO billing_accounts ( user_id, plan, provider, cycle_started_at, cycle_ends_at, monthly_allowance, used_this_cycle, topup_balance, renews_at, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT (user_id) DO UPDATE SET plan = EXCLUDED.plan, provider = EXCLUDED.provider, cycle_started_at = EXCLUDED.cycle_started_at, cycle_ends_at = EXCLUDED.cycle_ends_at, monthly_allowance = EXCLUDED.monthly_allowance, used_this_cycle = EXCLUDED.used_this_cycle, topup_balance = EXCLUDED.topup_balance, renews_at = EXCLUDED.renews_at, updated_at = EXCLUDED.updated_at`, [ account.userId, account.plan, account.provider, account.cycleStartedAt, account.cycleEndsAt, account.monthlyAllowance, account.usedThisCycle, account.topupBalance, account.renewsAt, account.updatedAt, ], ); }; const getOrCreateAccount = async (db, userId) => { const row = await get( db, `SELECT user_id AS "userId", plan, provider, cycle_started_at AS "cycleStartedAt", cycle_ends_at AS "cycleEndsAt", monthly_allowance AS "monthlyAllowance", used_this_cycle AS "usedThisCycle", topup_balance AS "topupBalance", renews_at AS "renewsAt", updated_at AS "updatedAt" FROM billing_accounts WHERE user_id = $1`, [userId], ); const now = new Date(); if (!row) { const created = buildDefaultAccount(userId, now); await upsertAccount(db, created); return created; } const existing = normalizeAccountRow(row); const aligned = alignAccountToCurrentCycle(existing, now); if (accountChanged(existing, aligned)) { aligned.updatedAt = nowIso(); await upsertAccount(db, aligned); } return aligned; }; const getAvailableCredits = (account) => { const monthlyRemaining = Math.max(0, account.monthlyAllowance - account.usedThisCycle); return monthlyRemaining + Math.max(0, account.topupBalance); }; const buildBillingSummary = (account) => { return { entitlement: { plan: account.plan, provider: account.provider, status: account.plan === 'pro' ? 'active' : 'inactive', renewsAt: account.renewsAt, }, credits: { monthlyAllowance: account.monthlyAllowance, usedThisCycle: account.usedThisCycle, topupBalance: account.topupBalance, available: getAvailableCredits(account), cycleStartedAt: account.cycleStartedAt, cycleEndsAt: account.cycleEndsAt, }, availableProducts: AVAILABLE_PRODUCTS, }; }; const isSupportedTopupProduct = (productId) => { return typeof productId === 'string' && productId.startsWith('topup_') && typeof TOPUP_CREDITS_BY_PRODUCT[productId] === 'number'; }; const normalizeRevenueCatTransactions = (customerInfo) => { const nonSubscriptions = customerInfo?.nonSubscriptions; if (!nonSubscriptions || typeof nonSubscriptions !== 'object') return []; return Object.values(nonSubscriptions).flatMap((entries) => (Array.isArray(entries) ? entries : [])); }; const summarizeRevenueCatCustomerInfo = (customerInfo) => { const activeEntitlements = customerInfo?.entitlements?.active || {}; return { appUserId: customerInfo?.appUserId || customerInfo?.app_user_id || null, originalAppUserId: customerInfo?.originalAppUserId || customerInfo?.original_app_user_id || null, activeEntitlements: Object.entries(activeEntitlements).map(([id, entitlement]) => ({ id, productIdentifier: entitlement?.productIdentifier || null, expirationDate: entitlement?.expirationDate || entitlement?.expiresDate || null, })), allPurchasedProductIdentifiers: Array.isArray(customerInfo?.allPurchasedProductIdentifiers) ? customerInfo.allPurchasedProductIdentifiers : [], nonSubscriptionTransactions: normalizeRevenueCatTransactions(customerInfo).map((transaction) => ({ productIdentifier: transaction?.productIdentifier || null, transactionIdentifier: transaction?.transactionIdentifier || transaction?.transactionId || null, })), }; }; const getValidProEntitlement = (customerInfo) => { const activeEntitlements = customerInfo?.entitlements?.active || {}; const proEntitlement = activeEntitlements[REVENUECAT_PRO_ENTITLEMENT_ID]; if (!proEntitlement) { return null; } if ( typeof proEntitlement?.productIdentifier === 'string' && SUPPORTED_SUBSCRIPTION_PRODUCTS.has(proEntitlement.productIdentifier) ) { return proEntitlement; } console.warn('[Billing] Ignoring unsupported RevenueCat pro entitlement', summarizeRevenueCatCustomerInfo(customerInfo)); return null; }; const applyRevenueCatEntitlementState = (account, options) => { const now = new Date(); const nextPlan = options.active ? 'pro' : 'free'; const planChanged = account.plan !== nextPlan; account.plan = nextPlan; account.provider = 'revenuecat'; account.monthlyAllowance = getMonthlyAllowanceForPlan(account.plan); account.renewsAt = options.active ? options.renewsAt || account.renewsAt || addDays(now, 30).toISOString() : null; if (planChanged) { const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now); account.cycleStartedAt = cycleStartedAt.toISOString(); account.cycleEndsAt = cycleEndsAt.toISOString(); account.usedThisCycle = 0; } }; const parseStoredJson = (raw) => { if (raw == null) return null; if (typeof raw === 'object') return raw; if (typeof raw !== 'string') return null; try { return JSON.parse(raw); } catch { return null; } }; const readIdempotentValue = async (db, key) => { const row = await get( db, 'SELECT response_json AS "responseJson" FROM billing_idempotency WHERE id = $1', [key], ); if (!row) return null; return parseStoredJson(row.responseJson); }; const writeIdempotentValue = async (db, key, value) => { await run( db, `INSERT INTO billing_idempotency (id, response_json, created_at) VALUES ($1, CAST($2 AS jsonb), $3) ON CONFLICT (id) DO UPDATE SET response_json = EXCLUDED.response_json, created_at = EXCLUDED.created_at`, [key, JSON.stringify(value), nowIso()], ); }; const grantRevenueCatTopupIfNeeded = async (db, account, transactionId, productId) => { if (!transactionId || !isSupportedTopupProduct(productId)) { return false; } const idempotencyId = `revenuecat-topup:${transactionId}`; const existing = await readIdempotentValue(db, idempotencyId); if (existing) return false; account.topupBalance += TOPUP_CREDITS_BY_PRODUCT[productId]; await writeIdempotentValue(db, idempotencyId, { transactionId, productId, creditedAt: nowIso() }); return true; }; const syncRevenueCatCustomerInfo = async (db, userId, customerInfo, options = {}) => { return runInTransaction(db, async (tx) => { const account = await getOrCreateAccount(tx, userId); const proEntitlement = getValidProEntitlement(customerInfo); const source = typeof options.source === 'string' ? options.source : 'app_init'; console.log('[Billing] Syncing RevenueCat customer info', { userId, source, customerInfo: summarizeRevenueCatCustomerInfo(customerInfo), }); if (source !== 'topup_purchase') { applyRevenueCatEntitlementState(account, { active: Boolean(proEntitlement), renewsAt: asIsoDate(proEntitlement?.expirationDate || proEntitlement?.expiresDate), }); } const transactions = normalizeRevenueCatTransactions(customerInfo); for (const transaction of transactions) { await grantRevenueCatTopupIfNeeded( tx, account, transaction?.transactionIdentifier || transaction?.transactionId, transaction?.productIdentifier, ); } account.updatedAt = nowIso(); await upsertAccount(tx, account); return { billing: buildBillingSummary(account), syncedAt: nowIso(), }; }); }; const shouldGrantRevenueCatSubscription = (eventType) => { return new Set([ 'INITIAL_PURCHASE', 'RENEWAL', 'PRODUCT_CHANGE', 'UNCANCELLATION', 'TEMPORARY_ENTITLEMENT_GRANT', ]).has(String(eventType || '').toUpperCase()); }; const shouldRevokeRevenueCatSubscription = (eventType) => { return new Set([ 'EXPIRATION', 'SUBSCRIPTION_PAUSED', ]).has(String(eventType || '').toUpperCase()); }; const syncRevenueCatWebhookEvent = async (db, eventPayload) => { const appUserId = String( eventPayload?.app_user_id || eventPayload?.appUserId || eventPayload?.original_app_user_id || '', ).trim(); if (!appUserId) { const error = new Error('RevenueCat webhook is missing app_user_id.'); error.code = 'BAD_REQUEST'; error.status = 400; throw error; } return runInTransaction(db, async (tx) => { const account = await getOrCreateAccount(tx, appUserId); const eventType = String(eventPayload?.type || '').toUpperCase(); const productId = typeof eventPayload?.product_id === 'string' ? eventPayload.product_id : ''; const entitlementIds = Array.isArray(eventPayload?.entitlement_ids) ? eventPayload.entitlement_ids : []; const hasSubscriptionProduct = SUPPORTED_SUBSCRIPTION_PRODUCTS.has(productId); const hasTopupProduct = isSupportedTopupProduct(productId); const affectsProEntitlement = hasSubscriptionProduct || (entitlementIds.includes(REVENUECAT_PRO_ENTITLEMENT_ID) && !hasTopupProduct); if (entitlementIds.includes(REVENUECAT_PRO_ENTITLEMENT_ID) && hasTopupProduct) { console.warn('[Billing] Ignoring RevenueCat webhook entitlement for top-up product', { appUserId, eventType, productId, entitlementIds, }); } if (affectsProEntitlement && shouldGrantRevenueCatSubscription(eventType)) { applyRevenueCatEntitlementState(account, { active: true, renewsAt: asIsoDate(eventPayload?.expiration_at_ms || eventPayload?.expiration_at), }); } else if (affectsProEntitlement && shouldRevokeRevenueCatSubscription(eventType)) { applyRevenueCatEntitlementState(account, { active: false, renewsAt: null, }); } if (isSupportedTopupProduct(productId)) { await grantRevenueCatTopupIfNeeded( tx, account, eventPayload?.transaction_id || eventPayload?.store_transaction_id || eventPayload?.id, productId, ); } account.updatedAt = nowIso(); await upsertAccount(tx, account); return { billing: buildBillingSummary(account), syncedAt: nowIso(), }; }); }; const consumeCredits = (account, cost) => { if (cost <= 0) return 0; const available = getAvailableCredits(account); if (available < cost) { throw createInsufficientCreditsError(cost, available); } let remaining = cost; const monthlyRemaining = Math.max(0, account.monthlyAllowance - account.usedThisCycle); if (monthlyRemaining > 0) { const monthlyUsage = Math.min(monthlyRemaining, remaining); account.usedThisCycle += monthlyUsage; remaining -= monthlyUsage; } if (remaining > 0 && account.topupBalance > 0) { const topupUsage = Math.min(account.topupBalance, remaining); account.topupBalance -= topupUsage; remaining -= topupUsage; } return cost; }; const endpointKey = (scope, userId, idempotencyKey) => { return `endpoint:${scope}:${userId}:${idempotencyKey}`; }; const chargeKey = (scope, userId, idempotencyKey) => { return `charge:${scope}:${userId}:${idempotencyKey}`; }; const consumeCreditsWithIdempotency = async (db, userId, key, cost) => { return runInTransaction(db, async (tx) => { const existing = await readIdempotentValue(tx, key); if (existing && typeof existing.charged === 'number') return existing.charged; const account = await getOrCreateAccount(tx, userId); const charged = consumeCredits(account, cost); account.updatedAt = nowIso(); await upsertAccount(tx, account); await writeIdempotentValue(tx, key, { charged }); return charged; }); }; const getBillingSummary = async (db, userId) => { if (userId === 'guest') { return { entitlement: { plan: 'free', provider: 'mock', status: 'active', renewsAt: null }, credits: { monthlyAllowance: 5, usedThisCycle: 0, topupBalance: 0, available: 5, cycleStartedAt: nowIso(), cycleEndsAt: nowIso(), }, availableProducts: AVAILABLE_PRODUCTS, }; } return runInTransaction(db, async (tx) => { const account = await getOrCreateAccount(tx, userId); account.updatedAt = nowIso(); await upsertAccount(tx, account); return buildBillingSummary(account); }); }; const getAccountSnapshot = async (db, userId) => { if (userId === 'guest') { return { userId: 'guest', plan: 'free', provider: 'mock', cycleStartedAt: nowIso(), cycleEndsAt: nowIso(), monthlyAllowance: 5, usedThisCycle: 0, topupBalance: 0, renewsAt: null, updatedAt: nowIso(), }; } return runInTransaction(db, async (tx) => { const account = await getOrCreateAccount(tx, userId); account.updatedAt = nowIso(); await upsertAccount(tx, account); return account; }); }; const getEndpointResponse = async (db, key) => { const cached = await readIdempotentValue(db, key); return cached || null; }; const storeEndpointResponse = async (db, key, response) => { await writeIdempotentValue(db, key, response); }; const simulatePurchase = async (db, userId, idempotencyKey, productId) => { const endpointId = endpointKey('simulate-purchase', userId, idempotencyKey); const cached = await getEndpointResponse(db, endpointId); if (cached) return cached; return runInTransaction(db, async (tx) => { const existingInsideTx = await readIdempotentValue(tx, endpointId); if (existingInsideTx) return existingInsideTx; const account = await getOrCreateAccount(tx, userId); if (productId === 'monthly_pro' || productId === 'yearly_pro') { const now = new Date(); const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now); account.plan = 'pro'; account.provider = 'revenuecat'; account.monthlyAllowance = PRO_MONTHLY_CREDITS; account.usedThisCycle = 0; account.cycleStartedAt = cycleStartedAt.toISOString(); account.cycleEndsAt = cycleEndsAt.toISOString(); account.renewsAt = addDays(now, 30).toISOString(); } else { const credits = TOPUP_CREDITS_BY_PRODUCT[productId]; if (typeof credits !== 'number') { const error = new Error(`Unsupported product: ${productId}`); error.code = 'BAD_REQUEST'; error.status = 400; throw error; } account.topupBalance += credits; } account.updatedAt = nowIso(); await upsertAccount(tx, account); const payload = { appliedProduct: productId, billing: buildBillingSummary(account), }; await storeEndpointResponse(tx, endpointId, payload); return payload; }); }; const simulateWebhook = async (db, userId, idempotencyKey, event, payload = {}) => { const endpointId = endpointKey('simulate-webhook', userId, idempotencyKey); const cached = await getEndpointResponse(db, endpointId); if (cached) return cached; return runInTransaction(db, async (tx) => { const existingInsideTx = await readIdempotentValue(tx, endpointId); if (existingInsideTx) return existingInsideTx; const account = await getOrCreateAccount(tx, userId); if (event === 'entitlement_granted') { const now = new Date(); const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now); account.plan = 'pro'; account.provider = 'revenuecat'; account.monthlyAllowance = PRO_MONTHLY_CREDITS; account.usedThisCycle = 0; account.cycleStartedAt = cycleStartedAt.toISOString(); account.cycleEndsAt = cycleEndsAt.toISOString(); account.renewsAt = addDays(now, 30).toISOString(); } else if (event === 'entitlement_revoked') { const now = new Date(); const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now); account.plan = 'free'; account.provider = 'revenuecat'; account.monthlyAllowance = FREE_MONTHLY_CREDITS; account.usedThisCycle = 0; account.cycleStartedAt = cycleStartedAt.toISOString(); account.cycleEndsAt = cycleEndsAt.toISOString(); account.renewsAt = null; } else if (event === 'topup_granted') { const credits = Math.max(1, Number(payload.credits) || TOPUP_DEFAULT_CREDITS); account.topupBalance += credits; } else if (event === 'credits_depleted') { account.usedThisCycle = account.monthlyAllowance; account.topupBalance = 0; } else { const error = new Error(`Unsupported webhook event: ${event}`); error.code = 'BAD_REQUEST'; error.status = 400; throw error; } account.updatedAt = nowIso(); await upsertAccount(tx, account); const payloadResponse = { event, billing: buildBillingSummary(account), }; await storeEndpointResponse(tx, endpointId, payloadResponse); return payloadResponse; }); }; const ensureBillingSchema = async (db) => { await run( db, `CREATE TABLE IF NOT EXISTS billing_accounts ( user_id TEXT PRIMARY KEY, plan TEXT NOT NULL DEFAULT 'free', provider TEXT NOT NULL DEFAULT 'revenuecat', cycle_started_at TIMESTAMPTZ NOT NULL, cycle_ends_at TIMESTAMPTZ NOT NULL, monthly_allowance INTEGER NOT NULL DEFAULT 15, used_this_cycle INTEGER NOT NULL DEFAULT 0, topup_balance INTEGER NOT NULL DEFAULT 0, renews_at TIMESTAMPTZ, updated_at TIMESTAMPTZ NOT NULL )`, ); await run( db, `CREATE TABLE IF NOT EXISTS billing_idempotency ( id TEXT PRIMARY KEY, response_json JSONB NOT NULL, created_at TIMESTAMPTZ NOT NULL )`, ); await run( db, `CREATE INDEX IF NOT EXISTS idx_billing_idempotency_created_at ON billing_idempotency (created_at DESC)`, ); }; const isInsufficientCreditsError = (error) => { return Boolean(error && typeof error === 'object' && error.code === 'INSUFFICIENT_CREDITS'); }; module.exports = { AVAILABLE_PRODUCTS, chargeKey, consumeCreditsWithIdempotency, endpointKey, ensureBillingSchema, getAccountSnapshot, getBillingSummary, getEndpointResponse, getMonthlyAllowanceForPlan, isInsufficientCreditsError, runInTransaction, simulatePurchase, simulateWebhook, syncRevenueCatCustomerInfo, syncRevenueCatWebhookEvent, storeEndpointResponse, };