Files
Greenlens/server/lib/billing.js
2026-03-29 10:26:38 -05:00

680 lines
21 KiB
JavaScript

const { get, run } = require('./sqlite');
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 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 : 'revenuecat',
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: '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 (
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: 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 asIsoDate = (value) => {
if (value == null || value === '') return null;
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 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 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 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) => {
return runInTransaction(db, async () => {
const account = await getOrCreateAccount(db, userId);
const activeEntitlements = customerInfo?.entitlements?.active || {};
const proEntitlement = activeEntitlements[REVENUECAT_PRO_ENTITLEMENT_ID];
applyRevenueCatEntitlementState(account, {
active: Boolean(proEntitlement),
renewsAt: asIsoDate(proEntitlement?.expirationDate || proEntitlement?.expiresDate),
});
const transactions = normalizeRevenueCatTransactions(customerInfo);
for (const transaction of transactions) {
await grantRevenueCatTopupIfNeeded(
db,
account,
transaction?.transactionIdentifier || transaction?.transactionId,
transaction?.productIdentifier,
);
}
account.updatedAt = nowIso();
await upsertAccount(db, 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 () => {
const account = await getOrCreateAccount(db, 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 affectsProEntitlement = entitlementIds.includes(REVENUECAT_PRO_ENTITLEMENT_ID) || SUPPORTED_SUBSCRIPTION_PRODUCTS.has(productId);
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(
db,
account,
eventPayload?.transaction_id || eventPayload?.store_transaction_id || eventPayload?.id,
productId,
);
}
account.updatedAt = nowIso();
await upsertAccount(db, 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 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) => {
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 () => {
const account = await getOrCreateAccount(db, userId);
account.updatedAt = nowIso();
await upsertAccount(db, 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 () => {
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 === '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(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 'revenuecat',
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,
syncRevenueCatCustomerInfo,
syncRevenueCatWebhookEvent,
storeEndpointResponse,
};