801 lines
34 KiB
JavaScript
801 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. Resets the monthly counters and
|
|
// anchors the renewal to now (so the monthly cycle starts on the grant
|
|
// date), mirroring applyTierPromotion. `expiresAt` is stored for
|
|
// reporting / future self-serve billing but NOT auto-enforced in this
|
|
// slice — to revoke, set tier back to "core".
|
|
export async function setUserTier({ userId, tier, expiresAt = null }) {
|
|
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;
|
|
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.
|
|
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 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 });
|
|
}
|
|
|
|
// 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,
|
|
}));
|
|
}
|