519 lines
15 KiB
JavaScript
519 lines
15 KiB
JavaScript
const { get, run } = require('./sqlite');
|
|
|
|
const FREE_MONTHLY_CREDITS = 15;
|
|
const PRO_MONTHLY_CREDITS = 250;
|
|
const TOPUP_DEFAULT_CREDITS = 60;
|
|
|
|
const TOPUP_CREDITS_BY_PRODUCT = {
|
|
pro_monthly: 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: '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) => {
|
|
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,
|
|
storeEndpointResponse,
|
|
};
|