// Credit ledger. JSON-file backed (single file at /data/credits.json). // Write throughput is low — at most one mutation per relay request — // so a plain JSON file with mutex-style serial writes is plenty. Swap // to SQLite if a single relay starts seeing dozens of req/sec sustained. // // ── Key model ──────────────────────────────────────────────────────── // Free-tier (Core) rows are keyed by install_id. Paid-tier (Pro / Max) // rows are keyed by a stable fingerprint of the license key — so a // single Pro license activated on two installs (e.g. cloud account // AND self-hosted instance) drains the SAME monthly pool instead of // getting two independent budgets. // // `getCreditKey({ installId, license })` resolves to: // - `lic:` when license.tier is "pro" or "max" // - `inst:` otherwise (anonymous, invalid, or Core) // // Rows still carry `install_id` (last-seen install that touched them) // for diagnostics, but the LEDGER KEY is what determines pool identity. // // ── Migration / backwards compatibility ────────────────────────────── // Existing pre-refactor rows are keyed by bare install_id. We leave // them in place — they continue to serve correctly for Core users // (whose key is now `inst:`, which still matches the legacy // bare-installId row because getOrCreateRow looks up by the resolved // key first and falls back to the legacy installId only when no // `inst:<...>` row exists yet — see lookupRow() below). // // Existing Pro/Max installs keep using their legacy installId-keyed // row until they next interact with the relay AFTER the new build is // live. The first such interaction will create a fresh `lic:` // row; the old installId row continues to exist as orphaned ledger // state. Self-heals within one billing cycle for licensed users. // NO retroactive migration — operator policy is "tolerate the value // leak for one month rather than risk a buggy bulk-migration on real // customer balances". // // ── Billing-period anchor ──────────────────────────────────────────── // Billing periods are CALENDAR-ANNIVERSARY, not calendar-month. A user // whose first paid request lands on the 17th of October renews on the // 17th of every subsequent month — not the 1st. This matches how typical // subscription billing works (Stripe et al.) so the relay's monthly-cap // resets line up with the actual renewal date the user is being charged // on. Edge cases (Jan 31 → Feb 28/29) clamp to the last day of the // target month, then resume the original day-of-month the next time // it's available, same as standard subscription convention. import fs from "fs/promises"; import path from "path"; import crypto from "crypto"; let dataDir = "/data"; let ledgerPath = "/data/credits.json"; let ledger = { rows: {} }; let writing = null; // serializes concurrent writes // ── License fingerprint helpers ───────────────────────────────────── // Centralized hash so every caller (credits.js, job-credits.js, // audit-log entries) derives the SAME identifier from the SAME license. // 16 hex chars = 64 bits — plenty against collision and short enough // to stay readable in log lines and admin-dashboard tables. // // The "raw key" we hash is the licenseUuid resolved by keysat-client // when available, otherwise a stable stringified form of the resolved // license object. licenseUuid is the field set by the offline verifier // and is stable across reactivations and across machines — exactly // what we want for a per-user pool identifier. export function licenseFingerprint(license) { if (!license) return null; const seed = license.licenseUuid || license.license_uuid || null; if (!seed) return null; return crypto .createHash("sha256") .update(String(seed)) .digest("hex") .slice(0, 16); } // Resolve the ledger-key for a given (install, license) pair. Paid // tiers route to `lic:` so a single license activated on multiple // installs shares ONE monthly pool. Anonymous / invalid / Core (and // any paid case missing a fingerprint, e.g. licenseUuid couldn't be // extracted) fall back to the install-scoped key `inst:`. export function getCreditKey({ installId, license }) { const tier = license?.tier || "core"; if (tier === "pro" || tier === "max") { const fp = licenseFingerprint(license); if (fp) return `lic:${fp}`; } if (!installId) { throw new Error("getCreditKey: installId required (no license fingerprint either)"); } return `inst:${installId}`; } export async function initCredits({ dataDir: dd }) { if (dd) dataDir = dd; ledgerPath = path.join(dataDir, "credits.json"); await fs.mkdir(dataDir, { recursive: true }).catch(() => {}); try { const raw = await fs.readFile(ledgerPath, "utf8"); ledger = JSON.parse(raw) || { rows: {} }; if (!ledger.rows) ledger.rows = {}; } catch (err) { if (err.code !== "ENOENT") { console.warn(`[credits] failed to read ledger: ${err.message} — starting empty`); } ledger = { rows: {} }; } // Migrate any rows that still carry the old { month: "YYYY-MM" } // shape from pre-anniversary ledger versions. Conservative: anchor // their last_renewal_at at the first of that month so they don't // get a surprise re-issue. let migrated = 0; for (const row of Object.values(ledger.rows)) { if (row.last_renewal_at) continue; if (typeof row.month === "string" && /^\d{4}-\d{2}$/.test(row.month)) { const [y, m] = row.month.split("-").map(Number); row.last_renewal_at = new Date(Date.UTC(y, m - 1, 1, 0, 0, 0)).toISOString(); delete row.month; migrated += 1; } else { // No prior period info — anchor to now so the next renewal // happens one calendar month from this point. row.last_renewal_at = new Date().toISOString(); if ("month" in row) delete row.month; migrated += 1; } } if (migrated > 0) { console.log(`[credits] migrated ${migrated} row(s) from calendar-month to anniversary-renewal shape`); await persist(); } console.log(`[credits] loaded ${Object.keys(ledger.rows).length} install rows from ${ledgerPath}`); } // Add one calendar month to the given Date, clamping the day to the // last valid day of the target month. Examples (all UTC): // Jan 31 → Feb 28 (or Feb 29 in leap years) // Feb 28 → Mar 28 (note: NOT Mar 31 — we use the original day) // Mar 31 → Apr 30 // May 31 → Jun 30 → Jul 31 (clamping is per-step, not absorbed) // // Note the third example. We anchor to the ORIGINAL anniversary day // each step, so a user who started on the 31st keeps getting renewals // on the 31st in months that have one, and on the last day for months // that don't. This is the standard subscription-billing rule. export function nextRenewalAfter(d, originalDay) { // originalDay is the anniversary day-of-month. If not supplied, use // d's own date — useful for the first hop from a freshly-set // last_renewal_at. const anchor = originalDay ?? d.getUTCDate(); const targetMonth = d.getUTCMonth() + 1; const overflow = targetMonth > 11 ? 1 : 0; const targetYear = d.getUTCFullYear() + overflow; const actualMonth = targetMonth % 12; // Last day of the target month (e.g. for Feb non-leap, this is 28). const lastDayOfTarget = new Date(Date.UTC(targetYear, actualMonth + 1, 0)).getUTCDate(); const day = Math.min(anchor, lastDayOfTarget); return new Date( Date.UTC( targetYear, actualMonth, day, d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds() ) ); } // Determine the install's anniversary day-of-month, which stays stable // even if a clamp pushed last_renewal_at to a shorter month. Stored // explicitly as `anniversary_day` once the row is created at a non- // month-end date; for legacy rows without it we infer from // last_renewal_at as a one-time best-effort. function anniversaryDay(row) { if (typeof row.anniversary_day === "number" && row.anniversary_day >= 1 && row.anniversary_day <= 31) { return row.anniversary_day; } const d = new Date(row.last_renewal_at); return Number.isFinite(d.getTime()) ? d.getUTCDate() : 1; } // Step the renewal forward until next_renewal > now. Handles rows // that have been dormant for several months and need multiple rollovers // in one shot. Mutates `row` in place; caller is responsible for // persisting when something changed. function ensureRenewalRollover(row) { if (!row.last_renewal_at) { row.last_renewal_at = new Date().toISOString(); return false; } const now = Date.now(); const anchorDay = anniversaryDay(row); let last = new Date(row.last_renewal_at); let next = nextRenewalAfter(last, anchorDay); let rolled = false; while (next.getTime() <= now) { last = next; next = nextRenewalAfter(last, anchorDay); rolled = true; } if (rolled) { row.last_renewal_at = last.toISOString(); row.monthly_consumed = 0; row.monthly_gemini_consumed = 0; } return rolled; } function blankRow({ installId, license }) { const now = new Date(); const fp = licenseFingerprint(license); return { // install_id captures the LAST install that touched this row. // For `lic:` rows that's whichever install most recently // committed against the license; for `inst:` rows it // matches the key. Kept on the row for diagnostics / dashboard // display — not used for ledger lookup. install_id: installId || null, license_fingerprint: fp, tier_snapshot: "core", lifetime_consumed: 0, lifetime_gemini_consumed: 0, last_renewal_at: now.toISOString(), anniversary_day: now.getUTCDate(), monthly_consumed: 0, monthly_gemini_consumed: 0, last_active_at: now.toISOString(), // Top-up credits the user bought via BTCPay. Never expire (per // operator policy). Consumed AFTER the tier allotment so the // user always gets their monthly/lifetime allowance first. purchased_balance: 0, purchased_total_ever: 0, }; } // Look up a row by its resolved credit-key, with one back-compat fallback: // when the key is `inst:` and no such row exists, check for // a legacy row stored under bare `` (pre-refactor format). // Returns { row, key } where `key` is the actual key under which the // row lives in ledger.rows. Returns { row: null } when nothing was found. // // The fallback is one-directional only: we DO NOT promote a legacy // install-keyed row to a `lic:` key just because the caller now // has a license. That's intentional — see the migration note at the // top of the file. A previously-Pro install that re-presents its // license will silently start a fresh license-keyed pool; its old // install-keyed row stays put with whatever monthly_consumed it had, // usable again next time it falls back to Core. function lookupRow(key) { if (ledger.rows[key]) return { row: ledger.rows[key], key }; if (key.startsWith("inst:")) { const bareInstall = key.slice("inst:".length); if (ledger.rows[bareInstall]) { return { row: ledger.rows[bareInstall], key: bareInstall }; } } return { row: null, key }; } async function persist() { // Coalesce concurrent writes — multiple in-flight mutations resolve // against the same persisted snapshot in fifo order. if (writing) await writing; writing = (async () => { const tmp = ledgerPath + ".tmp"; await fs.writeFile(tmp, JSON.stringify(ledger), { mode: 0o600 }); await fs.rename(tmp, ledgerPath); })(); try { await writing; } finally { writing = null; } } // Returns the row for an (install, license) pair, creating + persisting // a blank one if this is the first time we've seen its resolved // credit-key. The credit-key is `lic:` for paid tiers and // `inst:` otherwise — so a single Pro license activated on // two installs shares one row. // // When the credit-key is `inst:` and no row exists under // that key, we fall back to a legacy bare-installId row (pre-refactor // format) before creating a new one — keeps existing Core users on // their existing balances without any retroactive migration. See the // "Migration / backwards compatibility" comment at the top of the file. // // `creditKey` is an optional explicit override. It bypasses // getCreditKey() and looks up directly under the supplied key. Used by // job-credits.refundJob to route a refund to the SAME row a charge // landed on even when the original license object isn't in scope // anymore (e.g. after a relay restart, refund time only knows the // stored fingerprint, not the raw licenseUuid). export async function getOrCreateRow({ installId, license, creditKey = null, } = {}) { if (!installId && !license && !creditKey) { throw new Error("getOrCreateRow: installId, license, or creditKey required"); } const key = creditKey || getCreditKey({ installId, license }); let { row } = lookupRow(key); let dirty = false; if (!row) { row = blankRow({ installId, license }); // For explicit-creditKey rows whose license object isn't available // (refund-after-restart), stamp the fingerprint extracted from the // key itself onto the row so the dashboard surfaces it. if (creditKey && key.startsWith("lic:") && !row.license_fingerprint) { row.license_fingerprint = key.slice("lic:".length); } // When creating a fresh `lic:` row but an install row already // exists for this installId, seed the new row's lifetime_consumed // from the install row. Why: applyTierPromotion below treats this // moment as a Core → Paid upgrade and transfers `coreQuota.lifetime // - lifetime_consumed` as leftover bonus credits. If we left // lifetime_consumed at 0, the user would get the FULL Core lifetime // cap as bonus on top of their Pro monthly allotment — effectively // double-credited (they already burned some of those Core credits // on the install row before upgrading). Carrying over // lifetime_consumed lines the math up so the leftover transfer // reflects the REAL unused-Core balance at the moment of upgrade. // // Special case: when the install row's tier_snapshot is ALREADY // paid (Pro/Max), this is a legacy-Pro user landing on a fresh // license row post-refactor. They already received any Core-leftover // transfer on the install row when they first upgraded; doing it // again here would double-issue. We flag the new row by pre-flipping // its tier_snapshot to the install row's snapshot — applyTierPromotion // bails out when tier_snapshot is already non-Core, so the transfer // skips. Purchased balance carries forward so any top-up credits // the user had stay accessible on the new license-keyed row. if (key.startsWith("lic:") && installId) { const { row: installRow } = lookupRow(`inst:${installId}`); if (installRow) { const installAlreadyPaid = installRow.tier_snapshot === "pro" || installRow.tier_snapshot === "max"; row.lifetime_consumed = installRow.lifetime_consumed || 0; row.lifetime_gemini_consumed = installRow.lifetime_gemini_consumed || 0; if (installAlreadyPaid) { row.tier_snapshot = installRow.tier_snapshot; row.purchased_balance = installRow.purchased_balance || 0; row.purchased_total_ever = installRow.purchased_total_ever || 0; } } } ledger.rows[key] = row; dirty = true; } else { // Keep the most recently seen install/fingerprint stamped on the // row so the admin dashboard can show "which device of this user // last touched this license pool" without trawling audit logs. if (installId && row.install_id !== installId) { row.install_id = installId; dirty = true; } const fp = licenseFingerprint(license); if (fp && row.license_fingerprint !== fp) { row.license_fingerprint = fp; dirty = true; } } if (ensureRenewalRollover(row)) dirty = true; if (dirty) await persist(); return row; } // Compute the remaining balance for a row against its tier's quota. // Returns: // { // remaining: number | null, // tier portion only; null = unlimited // capped: "lifetime" | "monthly" | "none", // gemini_remaining: number | null, // null = no Gemini cap on this tier // purchased: number, // top-up credits the user bought via BTCPay // total: number | null, // remaining + purchased; null = unlimited // } // // Spend order is implemented by callers: tier portion is debited // first (commitCredit increments lifetime_consumed / monthly_consumed); // only when that hits zero do we touch purchased_balance. This keeps // the user's purchased credits as a true durable top-up rather than // crowding out the monthly allotment they're already entitled to. export function computeRemaining(row, quota) { const tier = row.tier_snapshot; const tierQuota = quota[tier] || quota.core; const purchased = Math.max(0, row.purchased_balance || 0); if (tierQuota.lifetime != null) { const tierRemaining = Math.max(0, tierQuota.lifetime - (row.lifetime_consumed || 0)); // Core tier may carve out a portion of the lifetime budget for // Gemini specifically (geminiCapLifetime). When set, remaining // Gemini credits = cap - lifetime_gemini_consumed; the rest of // the lifetime budget falls through to operator hardware. When // null, lifetime tier ignores the Gemini/hardware split and uses // whichever backend is available. const geminiRemaining = tierQuota.geminiCapLifetime == null ? null : Math.max( 0, tierQuota.geminiCapLifetime - (row.lifetime_gemini_consumed || 0) ); return { remaining: tierRemaining, capped: "lifetime", gemini_remaining: geminiRemaining, purchased, total: tierRemaining + purchased, }; } let tierRemaining; if (tierQuota.monthly == null) { tierRemaining = null; // unlimited } else { tierRemaining = Math.max(0, tierQuota.monthly - (row.monthly_consumed || 0)); } const geminiRemaining = tierQuota.geminiCapMonthly == null ? null : Math.max(0, tierQuota.geminiCapMonthly - (row.monthly_gemini_consumed || 0)); return { remaining: tierRemaining, capped: "monthly", gemini_remaining: geminiRemaining, purchased, total: tierRemaining == null ? null : tierRemaining + purchased, }; } // Decide what backend a request should go to and whether it can be // served at all. Returns { allowed, backend: "gemini"|"hardware", // reason }. Does NOT debit — that's a separate commit step after the // backend call succeeds. // // `preference` is the operator-configured routing strategy for the // current pipeline step (transcribe or analyze), one of: // - "gemini_first" try Gemini until cap is exceeded, then hardware // (default — best quality routing on operator's // Gemini budget, hardware as overflow) // - "hardware_first" try hardware first, fall back to Gemini when // hardware isn't configured (lets the operator // conserve Gemini budget for premium use cases) // - "gemini_only" Gemini only, fail when cap exceeded (caps the // operator's spend at the per-tier limit) // - "hardware_only" Hardware only, fail when not configured (good // for fully local / offline deployments) // // The Gemini cap (geminiCapMonthly / geminiCapLifetime on the tier // quota) still applies regardless of preference — preference just // controls the order in which backends are tried. export function planBackend(row, quota, { hasHardware, preference = "gemini_first" }) { const balance = computeRemaining(row, quota); // Out of credits entirely? Tier allotment exhausted AND no purchased // top-up remaining. (balance.total === null means unlimited.) if (balance.total === 0) { return { allowed: false, backend: null, reason: "out_of_credits" }; } // Gemini availability has two paths: either the tier's Gemini-cap // portion has headroom (gemini_remaining > 0 or null) OR the user // has purchased top-up credits. Purchased credits bypass the per- // tier Gemini cap because the operator has already been paid for // those calls — the cap exists to bound free/comped Gemini spend, // not paid-for spend. const geminiAvailable = balance.gemini_remaining === null || balance.gemini_remaining > 0 || balance.purchased > 0; switch (preference) { case "hardware_only": if (hasHardware) { return { allowed: true, backend: "hardware", reason: null }; } return { allowed: false, backend: null, reason: "hardware_only_not_configured", }; case "gemini_only": if (geminiAvailable) { return { allowed: true, backend: "gemini", reason: null }; } return { allowed: false, backend: null, reason: "gemini_cap_exceeded_no_fallback", }; case "hardware_first": if (hasHardware) { return { allowed: true, backend: "hardware", reason: null }; } if (geminiAvailable) { return { allowed: true, backend: "gemini", reason: null }; } return { allowed: false, backend: null, reason: "no_backend_available", }; case "gemini_first": default: if (geminiAvailable) { return { allowed: true, backend: "gemini", reason: null }; } if (hasHardware) { return { allowed: true, backend: "hardware", reason: null }; } return { allowed: false, backend: null, reason: "gemini_cap_exceeded_no_hardware", }; } } // Debit one credit on a successful call. Persists immediately. // Tracks Gemini-vs-hardware separately for Core (lifetime_gemini_consumed) // and paid tiers (monthly_gemini_consumed) so the planner can enforce // the per-tier Gemini cap. // // When a previously-Core install presents a paid license for the first // time, we treat THIS moment as the start of their billing period and // anchor last_renewal_at + anniversary_day to now. That way a user who // upgrades on the 17th gets renewals on the 17th going forward, not // at some earlier date that happens to be when their install_id was // first seen. // Inverse of commitCredit — returns one charged credit back to the // install's ledger when the work that consumed it ended up failing. // Mirrors commitCredit field-by-field so the same row that was // incremented gets decremented; floors at 0 so we never accidentally // hand a user negative consumption from a buggy refund sequence. // // Called from the route handlers via job-credits.refundJob when a // backend call fails after the credit was already charged (typical // case: transcribe succeeded + committed, analyze failed, so the // job's credit needs to be returned because the summary didn't // actually complete). export async function refundCredit({ installId, license, creditKey = null, backend, tier, }) { const row = await getOrCreateRow({ installId, license, creditKey }); // Mirror commitCredit's spend order: tier bucket gets refunded // first (which is where the credit was charged); only if the tier // counter is already at zero do we credit back to purchased_balance // (which means the original commit came out of the top-up bucket). if (tier === "core") { if ((row.lifetime_consumed || 0) > 0) { row.lifetime_consumed -= 1; if (backend === "gemini" && (row.lifetime_gemini_consumed || 0) > 0) { row.lifetime_gemini_consumed -= 1; } } else { row.purchased_balance = (row.purchased_balance || 0) + 1; } } else { if ((row.monthly_consumed || 0) > 0) { row.monthly_consumed -= 1; if (backend === "gemini" && (row.monthly_gemini_consumed || 0) > 0) { row.monthly_gemini_consumed -= 1; } } else { row.purchased_balance = (row.purchased_balance || 0) + 1; } } row.last_active_at = new Date().toISOString(); await persist(); } // Loads the quota for the install's tier so we can decide whether // to debit the tier portion or the purchased top-up portion. Imported // lazily to avoid a circular dep with config.js → credits.js. async function getCommitQuota(tier) { const { getTierQuotas } = await import("./config.js"); const all = await getTierQuotas(); return all[tier] || all.core; } // Apply the Core → paid-tier promotion bookkeeping in a single place. // Idempotent: only fires the FIRST time we see a paid tier on a row // whose tier_snapshot is still "core". On promotion we: // - Anchor the user's billing-anniversary to right now so monthly // renewals line up with the upgrade moment (not their install // creation date). // - Zero out monthly counters so the user gets their full first // month, even if they made it past the Core lifetime cap by // burning some monthly counter earlier. // - Transfer any UNUSED Core lifetime credits into purchased_balance. // This way the 6 leftover credits a Core user had don't vanish on // upgrade — they stack on top of the paid tier's monthly allotment // as durable bonus credit. Total after upgrade = monthly cap + // leftover Core credits + any prior top-up purchases. // - Flip tier_snapshot to the new tier last so the spend-order check // below routes the next debit to the right bucket. // // Mutates `row` in place AND persists the ledger when a promotion // fires — so the leftover transfer survives a relay restart even if // the calling route doesn't otherwise persist (the /relay/balance // route, for example, mutates tier_snapshot in memory without a // follow-up persist). // // Returns true if a promotion was applied, false otherwise. export async function applyTierPromotion(row, newTier) { if (newTier === "core") return false; if (row.tier_snapshot !== "core") return false; // Compute leftover Core credits BEFORE we flip tier_snapshot. If // Core's lifetime cap isn't set (unlimited), there's nothing to // transfer — the user already had unlimited. const coreQuota = await getCommitQuota("core"); let transferred = 0; if (typeof coreQuota.lifetime === "number" && coreQuota.lifetime > 0) { transferred = Math.max( 0, coreQuota.lifetime - (row.lifetime_consumed || 0) ); if (transferred > 0) { row.purchased_balance = (row.purchased_balance || 0) + transferred; row.purchased_total_ever = (row.purchased_total_ever || 0) + transferred; } } const now = new Date(); row.last_renewal_at = now.toISOString(); row.anniversary_day = now.getUTCDate(); row.monthly_consumed = 0; row.monthly_gemini_consumed = 0; row.tier_snapshot = newTier; row.last_active_at = now.toISOString(); await persist(); if (transferred > 0) { console.log( `[credits] tier promotion core → ${newTier} for ${row.install_id || row.license_fingerprint || "(unknown)"}: ` + `transferred ${transferred} leftover Core credit(s) to purchased_balance ` + `(now ${row.purchased_balance})` ); } return true; } export async function commitCredit({ installId, license, creditKey = null, backend, tier }) { const row = await getOrCreateRow({ installId, license, creditKey }); const promoted = await applyTierPromotion(row, tier); // If no promotion fired, applyTierPromotion left tier_snapshot // untouched (it only flips on Core → paid). Still want to keep the // snapshot current for paid → paid moves (Pro → Max, etc.) so the // ledger reflects the most recent license tier seen. if (!promoted) { row.tier_snapshot = tier; } // Spend order: tier allotment first, purchased top-up second. // Figure out whether THIS credit comes out of the tier bucket or // the purchased bucket by checking remaining tier headroom against // the current quota. const tierQuota = await getCommitQuota(tier); let tierHasRoom = false; if (tier === "core") { tierHasRoom = tierQuota.lifetime == null || (row.lifetime_consumed || 0) < tierQuota.lifetime; } else { tierHasRoom = tierQuota.monthly == null || (row.monthly_consumed || 0) < tierQuota.monthly; } if (tierHasRoom) { if (tier === "core") { row.lifetime_consumed = (row.lifetime_consumed || 0) + 1; if (backend === "gemini") { row.lifetime_gemini_consumed = (row.lifetime_gemini_consumed || 0) + 1; } } else { row.monthly_consumed = (row.monthly_consumed || 0) + 1; if (backend === "gemini") { row.monthly_gemini_consumed = (row.monthly_gemini_consumed || 0) + 1; } } } else { // Tier allotment exhausted — debit the purchased top-up. Capped // at zero so a refundCredit miss can't bring this negative. row.purchased_balance = Math.max(0, (row.purchased_balance || 0) - 1); } row.last_active_at = new Date().toISOString(); await persist(); } // Add purchased credits to the install's top-up bucket. Used by the // BTCPay webhook after a successful invoice settlement. Idempotent // at the webhook layer via processed-invoice tracking (the webhook // handler dedupes by invoice_id before calling this). // Purchased credits land on whichever row backs the buying install at // the time of purchase. The caller passes (installId, license, creditKey), // in priority order: an explicit creditKey wins, otherwise the resolved // (installId, license) decides. // // Why creditKey is accepted as an explicit override: the BTCPay webhook // re-enters this path AFTER a restart, with only invoice metadata in // hand (install_id + license_fingerprint stashed at buy time, no live // license object). The webhook constructs `lic:` from the stored // fingerprint and passes it as creditKey so the credit lands on the // SAME pool the buyer was looking at when they minted the invoice. // // Anonymous / Core buyers (no fingerprint stashed) fall through to the // install-keyed row — the credit follows the install. Once they // upgrade to Pro/Max later, applyTierPromotion transfers any leftover // Core tier credits to purchased_balance — see commitCredit's path. export async function addPurchasedCredits({ installId, license = null, creditKey = null, amount, }) { if (!Number.isFinite(amount) || amount <= 0) return null; const row = await getOrCreateRow({ installId, license, creditKey }); row.purchased_balance = (row.purchased_balance || 0) + amount; row.purchased_total_ever = (row.purchased_total_ever || 0) + amount; row.last_active_at = new Date().toISOString(); await persist(); return row.purchased_balance; } // ── Cloud user tier (core-decoupling) ─────────────────────────────── // The relay is the source of truth for a cloud user's Pro/Max tier, // stored on the user's credit row (keyed `user:`). Set by the // operator (today) and the self-serve purchase flow (later slice). // Operator-set a cloud user's tier. With `resetCycle` (the default) it // starts a fresh monthly cycle anchored to now — so an operator comp // grant, or a first/lapsed self-serve purchase, begins its allowance on // the grant date (mirroring applyTierPromotion). A renewal of an // in-force subscription passes `resetCycle: false` so it extends the // expiry WITHOUT zeroing monthly_consumed — see extendUserTier. // `expiresAt` is stored for reporting / self-serve billing but NOT // auto-enforced here — to revoke, set tier back to "core". export async function setUserTier({ userId, tier, expiresAt = null, resetCycle = true }) { if (!userId) throw new Error("setUserTier: userId required"); const t = tier === "pro" || tier === "max" ? tier : "core"; const row = await getOrCreateRow({ creditKey: `user:${userId}` }); const now = new Date(); row.tier_snapshot = t; if (resetCycle) { row.monthly_consumed = 0; row.monthly_gemini_consumed = 0; row.last_renewal_at = now.toISOString(); row.anniversary_day = now.getUTCDate(); } row.subscription_expires_at = expiresAt || null; row.last_active_at = now.toISOString(); await persist(); return row; } // Buy / extend a PREPAID PERIOD of `tier` (self-serve subscriptions). The // new expiry extends from whichever is later — now, or the user's current // (still-active) expiry — so paying early ADDS time rather than resetting // it. `periodDays` defaults to 30. Both payment rails (BTCPay + Zaprite) // land here on a settled payment. Returns the updated row. // // Renewing an IN-FORCE subscription must NOT reset the monthly credit // counter — otherwise a user who paid early (or a webhook that double- // fired across a restart) would get their whole monthly allotment back // for free. The monthly cycle rolls on its own anniversary via // ensureRenewalRollover, independent of renewals. Only a brand-new or // lapsed subscription starts a fresh cycle. export async function extendUserTier({ userId, tier, periodDays = 30 }) { if (!userId) throw new Error("extendUserTier: userId required"); const t = tier === "pro" || tier === "max" ? tier : "core"; const now = Date.now(); const row = await getOrCreateRow({ creditKey: `user:${userId}` }); const curExp = row.subscription_expires_at ? new Date(row.subscription_expires_at).getTime() : 0; const hasActiveSub = Number.isFinite(curExp) && curExp > now && (row.tier_snapshot === "pro" || row.tier_snapshot === "max"); const base = Math.max(now, Number.isFinite(curExp) ? curExp : 0); const expiresAt = new Date( base + periodDays * 24 * 60 * 60 * 1000, ).toISOString(); return setUserTier({ userId, tier: t, expiresAt, resetCycle: !hasActiveSub }); } // Read a cloud user's credit row (creates a blank Core row if none yet). export async function getUserCreditRow(userId) { if (!userId) throw new Error("getUserCreditRow: userId required"); return getOrCreateRow({ creditKey: `user:${userId}` }); } // For the admin dashboard. Includes the ledger-key (`credit_key`) so // the dashboard can render "license pool" vs "install pool" rows // distinctly — license-keyed rows aggregate spend across every install // that uses the same license, install-keyed rows aggregate one install. export function snapshotAll() { return Object.entries(ledger.rows).map(([credit_key, r]) => ({ credit_key, ...r, })); }