Files
recap/server/tenant-credits.js
T
Keysat 0ae59f3550 Add multi-tenant cloud mode: self-serve purchase, credit metering, core-decoupling
Introduces RECAP_MODE=multi alongside single-mode self-host:
- Tenant auth + accounts (magic-link via System SMTP), per-tenant credit pool,
  anonymous trial minting with per-IP/-64 caps
- Self-serve Pro/Max purchase: inline Lightning (BTCPay) + card (Zaprite),
  prepaid 30-day periods, expiry-reminder emails
- Core-decoupling: relay owns cloud tier/expiry keyed by Recaps user-id
- SQLite (better-sqlite3) schema for multi-mode; filesystem unchanged for single
- StartOS actions/versions through 0.2.155
2026-06-13 14:25:05 -05:00

256 lines
9.3 KiB
JavaScript

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