0ae59f3550
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
256 lines
9.3 KiB
JavaScript
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);
|
|
}
|