777 lines
25 KiB
JavaScript
777 lines
25 KiB
JavaScript
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;
|
|
}
|
|
|
|
// Fallback: entitlement is active but backed by a non-subscription product (e.g. a topup
|
|
// that was previously misconfigured to grant the pro entitlement). If the user also has a
|
|
// supported subscription product in their purchase history, honour the entitlement anyway.
|
|
const purchased = Array.isArray(customerInfo?.allPurchasedProductIdentifiers)
|
|
? customerInfo.allPurchasedProductIdentifiers
|
|
: [];
|
|
const hasSubscription = purchased.some((id) => SUPPORTED_SUBSCRIPTION_PRODUCTS.has(id));
|
|
if (hasSubscription) {
|
|
console.warn('[Billing] Pro entitlement backed by unsupported product but subscription found — honouring entitlement', summarizeRevenueCatCustomerInfo(customerInfo));
|
|
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,
|
|
);
|
|
}
|
|
|
|
// Fallback: also check active entitlements for topup products.
|
|
// This handles cases where a topup product is misconfigured in RevenueCat
|
|
// to grant an entitlement instead of being treated as a consumable.
|
|
const rawActiveEntitlements = Object.values(customerInfo?.entitlements?.active || {});
|
|
for (const entitlement of rawActiveEntitlements) {
|
|
const productId = entitlement?.productIdentifier;
|
|
if (isSupportedTopupProduct(productId)) {
|
|
const purchaseDate = entitlement?.latestPurchaseDate || entitlement?.originalPurchaseDate;
|
|
const txId = purchaseDate ? `entitlement:${productId}:${purchaseDate}` : null;
|
|
await grantRevenueCatTopupIfNeeded(tx, account, txId, productId);
|
|
}
|
|
}
|
|
|
|
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,
|
|
};
|