Files
recap-relay/server/credits.js
T
Keysat d2caa98248 Fix credit-counter reset on early subscription renewal
extendUserTier called setUserTier, which unconditionally zeroed
monthly_consumed and re-anchored the cycle. A user who renewed mid-cycle
(or a webhook double-firing across a restart) got their full monthly
allotment back for free. The monthly cycle already rolls on its own
anniversary via ensureRenewalRollover, so renewal must not reset it. Add
resetCycle to setUserTier (default true, preserving operator-grant
behavior); extendUserTier passes false for an in-force subscription and
true only for a brand-new or lapsed one. Add regression tests.
2026-06-13 16:23:26 -05:00

817 lines
34 KiB
JavaScript

// 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:<fingerprint>` when license.tier is "pro" or "max"
// - `inst:<installId>` 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:<installId>`, 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:<fp>`
// 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:<fp>` 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:<installId>`.
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:<fp>` rows that's whichever install most recently
// committed against the license; for `inst:<installId>` 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:<installId>` and no such row exists, check for
// a legacy row stored under bare `<installId>` (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:<fp>` 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:<fingerprint>` for paid tiers and
// `inst:<installId>` otherwise — so a single Pro license activated on
// two installs shares one row.
//
// When the credit-key is `inst:<installId>` 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:<fp>` 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:<fp>` 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:<id>`). 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,
}));
}