const { get, run } = require('./sqlite'); const FREE_MONTHLY_CREDITS = 15; const PRO_MONTHLY_CREDITS = 50; const TOPUP_DEFAULT_CREDITS = 60; const TOPUP_CREDITS_BY_PRODUCT = { pro_monthly: 0, topup_small: 50, topup_medium: 120, topup_large: 300, }; const AVAILABLE_PRODUCTS = ['pro_monthly', 'topup_small', 'topup_medium', 'topup_large']; const nowIso = () => new Date().toISOString(); 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) => { await run(db, 'BEGIN IMMEDIATE TRANSACTION'); try { const result = await worker(); await run(db, 'COMMIT'); return result; } catch (error) { try { await run(db, 'ROLLBACK'); } catch (rollbackError) { console.error('Failed to rollback SQLite transaction.', rollbackError); } throw error; } }; const normalizeAccountRow = (row) => { if (!row) return null; return { userId: String(row.userId), plan: row.plan === 'pro' ? 'pro' : 'free', provider: typeof row.provider === 'string' && row.provider ? row.provider : 'stripe', cycleStartedAt: String(row.cycleStartedAt), cycleEndsAt: String(row.cycleEndsAt), monthlyAllowance: Number(row.monthlyAllowance) || FREE_MONTHLY_CREDITS, usedThisCycle: Number(row.usedThisCycle) || 0, topupBalance: Number(row.topupBalance) || 0, renewsAt: row.renewsAt ? String(row.renewsAt) : null, updatedAt: row.updatedAt ? String(row.updatedAt) : nowIso(), }; }; const buildDefaultAccount = (userId, now) => { const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now); return { userId, plan: 'free', provider: 'stripe', 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 ( userId, plan, provider, cycleStartedAt, cycleEndsAt, monthlyAllowance, usedThisCycle, topupBalance, renewsAt, updatedAt ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(userId) DO UPDATE SET plan = excluded.plan, provider = excluded.provider, cycleStartedAt = excluded.cycleStartedAt, cycleEndsAt = excluded.cycleEndsAt, monthlyAllowance = excluded.monthlyAllowance, usedThisCycle = excluded.usedThisCycle, topupBalance = excluded.topupBalance, renewsAt = excluded.renewsAt, updatedAt = excluded.updatedAt`, [ 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 userId, plan, provider, cycleStartedAt, cycleEndsAt, monthlyAllowance, usedThisCycle, topupBalance, renewsAt, updatedAt FROM billing_accounts WHERE userId = ?`, [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: 'active', 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 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 parseStoredJson = (raw) => { if (!raw || typeof raw !== 'string') return null; try { return JSON.parse(raw); } catch { return null; } }; const readIdempotentValue = async (db, key) => { const row = await get(db, 'SELECT responseJson FROM billing_idempotency WHERE id = ?', [key]); if (!row || typeof row.responseJson !== 'string') return null; return parseStoredJson(row.responseJson); }; const writeIdempotentValue = async (db, key, value) => { await run( db, `INSERT INTO billing_idempotency (id, responseJson, createdAt) VALUES (?, ?, ?) ON CONFLICT(id) DO UPDATE SET responseJson = excluded.responseJson, createdAt = excluded.createdAt`, [key, JSON.stringify(value), nowIso()], ); }; 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 () => { const existing = await readIdempotentValue(db, key); if (existing && typeof existing.charged === 'number') return existing.charged; const account = await getOrCreateAccount(db, userId); const charged = consumeCredits(account, cost); account.updatedAt = nowIso(); await upsertAccount(db, account); await writeIdempotentValue(db, key, { charged }); return charged; }); }; const getBillingSummary = async (db, userId) => { return runInTransaction(db, async () => { const account = await getOrCreateAccount(db, userId); account.updatedAt = nowIso(); await upsertAccount(db, account); return buildBillingSummary(account); }); }; const getAccountSnapshot = async (db, userId) => { return runInTransaction(db, async () => { const account = await getOrCreateAccount(db, userId); account.updatedAt = nowIso(); await upsertAccount(db, 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; const response = await runInTransaction(db, async () => { const existingInsideTx = await readIdempotentValue(db, endpointId); if (existingInsideTx) return existingInsideTx; const account = await getOrCreateAccount(db, userId); if (productId === 'pro_monthly') { const now = new Date(); const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now); account.plan = 'pro'; account.provider = 'stripe'; 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(db, account); const payload = { appliedProduct: productId, billing: buildBillingSummary(account), }; await storeEndpointResponse(db, endpointId, payload); return payload; }); return response; }; 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; const response = await runInTransaction(db, async () => { const existingInsideTx = await readIdempotentValue(db, endpointId); if (existingInsideTx) return existingInsideTx; const account = await getOrCreateAccount(db, 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(db, account); const payloadResponse = { event, billing: buildBillingSummary(account), }; await storeEndpointResponse(db, endpointId, payloadResponse); return payloadResponse; }); return response; }; const ensureBillingSchema = async (db) => { await run( db, `CREATE TABLE IF NOT EXISTS billing_accounts ( userId TEXT PRIMARY KEY, plan TEXT NOT NULL DEFAULT 'free', provider TEXT NOT NULL DEFAULT 'stripe', cycleStartedAt TEXT NOT NULL, cycleEndsAt TEXT NOT NULL, monthlyAllowance INTEGER NOT NULL DEFAULT 15, usedThisCycle INTEGER NOT NULL DEFAULT 0, topupBalance INTEGER NOT NULL DEFAULT 0, renewsAt TEXT, updatedAt TEXT NOT NULL )`, ); await run( db, `CREATE TABLE IF NOT EXISTS billing_idempotency ( id TEXT PRIMARY KEY, responseJson TEXT NOT NULL, createdAt TEXT NOT NULL )`, ); await run( db, `CREATE INDEX IF NOT EXISTS idx_billing_idempotency_created_at ON billing_idempotency(createdAt 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, storeEndpointResponse, };