// Per-tenant credit ledger — operations on the tenant_credits SQLite // table. Two buckets per user: // purchased_balance — permanent (a la carte purchases + admin grants // + anon-trial carry-over on signup). Never // wiped or refilled. // replenish_balance — refillable (initial signup grant + periodic // anniversary refill to tenant_default_credits). // Leftovers at the end of a period are FORFEIT. // // Spend order: replenish first, then purchased — refillable bucket // is "use it or lose it" so it makes sense to burn first. // // Multi-mode only. Single-mode doesn't use this table at all. import { getDb } from "./db.js"; import { getConfigSnapshot } from "./config.js"; const DAY_MS = 24 * 60 * 60 * 1000; // addMonthClamped(date) — calendar-month add for monthly replenishment. // Mirrors the relay's same-named helper: Jan 31 → Feb 28/29 (clamped), // Feb 28 → Mar 28 (preserve day-of-month). Returns a Date. function addMonthClamped(date) { const d = new Date(date.getTime()); const year = d.getUTCFullYear(); const month = d.getUTCMonth(); const day = d.getUTCDate(); const lastDayOfTargetMonth = new Date( Date.UTC(year, month + 2, 0), ).getUTCDate(); const targetDay = Math.min(day, lastDayOfTargetMonth); return new Date( Date.UTC( year, month + 1, targetDay, d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds(), d.getUTCMilliseconds(), ), ); } // Resolve the next anniversary boundary after `last` for the configured // period. Returns null if period is "off" (no replenishment). function nextReplenishAt(lastEpochMs, period) { if (period === "off" || !lastEpochMs) return null; if (period === "daily") return lastEpochMs + DAY_MS; if (period === "weekly") return lastEpochMs + 7 * DAY_MS; if (period === "monthly") { return addMonthClamped(new Date(lastEpochMs)).getTime(); } return null; } // Read the current operator config relevant to credits. Cached per- // request by getConfigSnapshot (which polls config.js's snapshot). async function readCreditConfig() { const snap = await getConfigSnapshot(); const period = snap?.tenant_credit_replenish_period && ["off", "daily", "weekly", "monthly"].includes( snap.tenant_credit_replenish_period, ) ? snap.tenant_credit_replenish_period : "off"; const defaultCredits = Math.max( 0, parseInt(snap?.tenant_default_credits ?? 5, 10) || 0, ); return { period, defaultCredits }; } // Internal: ensure a tenant_credits row exists for this user. New users // (just signed up but no row yet — shouldn't happen post-auth-routes-fix // but defensive) get a row with replenish_balance = current default. function ensureRow(userId, defaultCredits) { const db = getDb(); const existing = db .prepare("SELECT * FROM tenant_credits WHERE user_id = ?") .get(userId); if (existing) return existing; const now = Date.now(); db.prepare( `INSERT INTO tenant_credits (user_id, purchased_balance, replenish_balance, last_replenish_at, lifetime_granted, lifetime_consumed) VALUES (?, 0, ?, ?, ?, 0)`, ).run(userId, defaultCredits, now, defaultCredits); return db .prepare("SELECT * FROM tenant_credits WHERE user_id = ?") .get(userId); } // Apply periodic refill if due. Returns the (possibly updated) row. // Anniversary semantics: if last_replenish_at + period_ms <= now, the // replenish bucket is RESET to defaultCredits (any leftover is // forfeit), and last_replenish_at is advanced. Idempotent if called // multiple times in the same period (no-op). // // "Multi-period catch-up" rule: if a user has been idle for several // periods, only ONE refill is applied (we don't stack refills from // missed periods). They effectively lost the credits for the missed // days — same as a per-day allowance in any other SaaS. function maybeReplenish(row, period, defaultCredits) { if (period === "off") return row; const due = nextReplenishAt(row.last_replenish_at, period); if (due === null) return row; const now = Date.now(); if (now < due) return row; const db = getDb(); db.prepare( `UPDATE tenant_credits SET replenish_balance = ?, last_replenish_at = ? WHERE user_id = ?`, ).run(defaultCredits, now, row.user_id); return { ...row, replenish_balance: defaultCredits, last_replenish_at: now, }; } // ── Public API ────────────────────────────────────────────────────────── // getOrInit(userId) — fetch the tenant_credits row, lazily refilling if // the period boundary has passed. Returns the canonical shape for // callers that just want to display + compute totals. export async function getOrInit(userId) { if (!userId) return null; const { period, defaultCredits } = await readCreditConfig(); let row = ensureRow(userId, defaultCredits); row = maybeReplenish(row, period, defaultCredits); return { user_id: row.user_id, purchased: row.purchased_balance, replenish: row.replenish_balance, total: row.purchased_balance + row.replenish_balance, last_replenish_at: row.last_replenish_at, lifetime_granted: row.lifetime_granted, lifetime_consumed: row.lifetime_consumed, period, // surfaces config in returned shape for UI hints }; } // gateAndDebit(userId) — atomic: refill if due, check total > 0, debit // one credit (replenish first, then purchased). Returns // { ok: true, total, source: "replenish"|"purchased" } on success, // { ok: false, reason: "no_credits", total: 0 } if nothing available. export async function gateAndDebit(userId) { if (!userId) return { ok: false, reason: "no_user_id" }; const state = await getOrInit(userId); if (!state) return { ok: false, reason: "no_user_id" }; if (state.total <= 0) { return { ok: false, reason: "no_credits", total: 0 }; } const db = getDb(); const tx = db.transaction(() => { if (state.replenish > 0) { db.prepare( `UPDATE tenant_credits SET replenish_balance = replenish_balance - 1, lifetime_consumed = lifetime_consumed + 1 WHERE user_id = ?`, ).run(userId); return "replenish"; } db.prepare( `UPDATE tenant_credits SET purchased_balance = purchased_balance - 1, lifetime_consumed = lifetime_consumed + 1 WHERE user_id = ?`, ).run(userId); return "purchased"; }); const source = tx(); // Re-read for the new total. Cheap — same row, no replenish-check // needed since we just touched it. const fresh = db .prepare( "SELECT purchased_balance, replenish_balance FROM tenant_credits WHERE user_id = ?", ) .get(userId); return { ok: true, total: (fresh.purchased_balance || 0) + (fresh.replenish_balance || 0), source, }; } // addPurchased(userId, amount) — increment the permanent bucket. Used // by admin grants AND a la carte purchase apply AND anon-trial // carry-over on signup. Increments lifetime_granted too. // // We don't replenish-check here — adding to the permanent bucket // shouldn't trigger a refill side-effect. The refill happens lazily // on the next getOrInit() call. export function addPurchased(userId, amount) { if (!userId || !Number.isFinite(amount) || amount <= 0) return null; const db = getDb(); const existing = db .prepare("SELECT user_id FROM tenant_credits WHERE user_id = ?") .get(userId); if (existing) { db.prepare( `UPDATE tenant_credits SET purchased_balance = purchased_balance + ?, lifetime_granted = lifetime_granted + ? WHERE user_id = ?`, ).run(amount, amount, userId); } else { // Row didn't exist — initialize WITHOUT a replenishable seed // (this user hasn't been through the signup flow on this Recap; // probably an admin granting credits before they sign in). const now = Date.now(); db.prepare( `INSERT INTO tenant_credits (user_id, purchased_balance, replenish_balance, last_replenish_at, lifetime_granted, lifetime_consumed) VALUES (?, ?, 0, ?, ?, 0)`, ).run(userId, amount, now, amount); } return db .prepare("SELECT * FROM tenant_credits WHERE user_id = ?") .get(userId); } // seedSignup(userId, amount?) — initialize a tenant_credits row at // signup. Seeds replenish_balance with the configured default // (overridable for testing), sets last_replenish_at = now so the // first refill boundary is computed correctly. export async function seedSignup(userId, amountOverride) { if (!userId) return null; const { defaultCredits } = await readCreditConfig(); const amount = typeof amountOverride === "number" && amountOverride >= 0 ? amountOverride : defaultCredits; const db = getDb(); const existing = db .prepare("SELECT user_id FROM tenant_credits WHERE user_id = ?") .get(userId); if (existing) return existing; // don't re-seed const now = Date.now(); db.prepare( `INSERT INTO tenant_credits (user_id, purchased_balance, replenish_balance, last_replenish_at, lifetime_granted, lifetime_consumed) VALUES (?, 0, ?, ?, ?, 0)`, ).run(userId, amount, now, amount); return db .prepare("SELECT * FROM tenant_credits WHERE user_id = ?") .get(userId); }