Files
Greenlens/server/lib/billing.js

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,
};