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
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
// 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);
|
||||
}
|
||||
Reference in New Issue
Block a user