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,252 @@
|
||||
// Self-serve subscription purchase (multi-mode / cloud only).
|
||||
//
|
||||
// Lets a signed-in cloud user buy their OWN prepaid Pro/Max period
|
||||
// instead of waiting for the operator to grant it by hand. The relay
|
||||
// owns the subscription (keyed by Recaps user-id, per the core-
|
||||
// decoupling); Recaps just brokers the purchase:
|
||||
//
|
||||
// POST /api/billing/buy → ask the relay to mint a BTCPay invoice
|
||||
// for {tier}; return the checkout URL the
|
||||
// frontend opens. On settlement the relay's
|
||||
// webhook extends the user's tier.
|
||||
// GET /api/billing/status → pull the user's current (expiry-enforced)
|
||||
// tier from the relay and refresh the local
|
||||
// users.tier cache so the badge flips the
|
||||
// moment payment lands. The frontend polls
|
||||
// this after opening checkout.
|
||||
//
|
||||
// Auth: both routes require a real signed-in user (req.user.id). Anon /
|
||||
// trial visitors (req.userId = "anon:<cookie>") are refused — a tier is
|
||||
// keyed to a durable user-id, which a trial cookie isn't.
|
||||
//
|
||||
// These live under /api/billing (NOT /api/subscriptions — that prefix is
|
||||
// the channel-subscriptions feature, which is itself Pro-gated; a free
|
||||
// user must be able to reach the buy flow). The prefix is added to the
|
||||
// license middleware's open list so the activation gate lets Core users
|
||||
// through to purchase.
|
||||
|
||||
import { getDb } from "./db.js";
|
||||
import { requireUser } from "./tenant-auth.js";
|
||||
import {
|
||||
createRelayTierInvoice,
|
||||
createRelayZapriteOrder,
|
||||
getRelayUserTier,
|
||||
getRelayTierPlans,
|
||||
} from "./providers/relay.js";
|
||||
|
||||
const BUYABLE_TIERS = new Set(["pro", "max"]);
|
||||
const PAYMENT_METHODS = new Set(["bitcoin", "card"]);
|
||||
|
||||
// Fallback prices (sats / 30-day period) used only when the relay is
|
||||
// unreachable while rendering the picker — matches the relay config
|
||||
// defaults so the UI never shows a blank price. The actual charge is
|
||||
// always computed relay-side at invoice time.
|
||||
const FALLBACK_PLANS = {
|
||||
period_days: 30,
|
||||
plans: [
|
||||
{ tier: "pro", sats: 21000 },
|
||||
{ tier: "max", sats: 42000 },
|
||||
],
|
||||
};
|
||||
|
||||
// Pull the user's effective (expiry-enforced) tier from the relay — the
|
||||
// authoritative subscription owner — and update the cached users.tier if
|
||||
// it drifted. Returns { tier, expires_at, synced } or { synced:false }
|
||||
// when the relay is unreachable / unconfigured (caller falls back to the
|
||||
// cached value rather than erroring the request).
|
||||
export async function syncUserTierFromRelay(userId) {
|
||||
if (!userId) return { synced: false };
|
||||
let report;
|
||||
try {
|
||||
report = await getRelayUserTier({ userId });
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[billing] relay tier read failed for ${userId}: ${err?.message || err}`,
|
||||
);
|
||||
return { synced: false };
|
||||
}
|
||||
// getRelayUserTier swallows errors and returns null when the relay
|
||||
// base URL / operator key isn't configured. Treat that as "can't
|
||||
// sync" rather than "downgrade to core".
|
||||
if (!report || typeof report.tier !== "string") {
|
||||
return { synced: false };
|
||||
}
|
||||
const tier = report.tier; // already expiry-enforced by the relay
|
||||
const expiresAt = report.subscription_expires_at || null;
|
||||
try {
|
||||
const db = getDb();
|
||||
const row = db.prepare("SELECT tier FROM users WHERE id = ?").get(userId);
|
||||
if (row && row.tier !== tier) {
|
||||
db.prepare("UPDATE users SET tier = ? WHERE id = ?").run(tier, userId);
|
||||
console.log(
|
||||
`[billing] synced ${userId} tier ${row.tier || "core"} → ${tier} from relay`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[billing] tier cache update failed for ${userId}: ${err?.message || err}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
tier,
|
||||
expires_at: expiresAt,
|
||||
tier_snapshot: report.tier_snapshot || tier,
|
||||
subscription_expired: !!report.subscription_expired,
|
||||
synced: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Build the buyer-facing origin so the BTCPay checkout can redirect back
|
||||
// to the app after settlement. Honors the reverse-proxy forwarding
|
||||
// headers StartOS sets in front of the service.
|
||||
function originFor(req) {
|
||||
const proto =
|
||||
(req.headers["x-forwarded-proto"] || "").split(",")[0].trim() ||
|
||||
req.protocol ||
|
||||
"https";
|
||||
const host = req.headers["x-forwarded-host"] || req.headers.host || "";
|
||||
return host ? `${proto}://${host}` : "";
|
||||
}
|
||||
|
||||
export function setupBillingRoutes(app) {
|
||||
// GET /api/billing/plans → { period_days, plans: [{tier, sats}] }
|
||||
// Powers the purchase picker. Prices come from the relay (the pricing
|
||||
// source of truth); falls back to the config defaults if the relay is
|
||||
// briefly unreachable so the modal still renders.
|
||||
app.get("/api/billing/plans", requireUser, async (req, res) => {
|
||||
if (!req.user || !req.user.id) {
|
||||
return res.status(403).json({ error: "must_be_signed_in" });
|
||||
}
|
||||
try {
|
||||
const data = await getRelayTierPlans();
|
||||
if (data && Array.isArray(data.plans) && data.plans.length) {
|
||||
return res.json({
|
||||
period_days: data.period_days || FALLBACK_PLANS.period_days,
|
||||
plans: data.plans,
|
||||
// Whether the card (Zaprite) rail is configured — the UI hides
|
||||
// the "Pay by card" link when false so it never offers a rail
|
||||
// that 503s. Bitcoin is always available (the BTCPay rail).
|
||||
card_available: !!data.card_available,
|
||||
source: "relay",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[billing] plans read failed: ${err?.message || err}`);
|
||||
}
|
||||
return res.json({ ...FALLBACK_PLANS, card_available: false, source: "fallback" });
|
||||
});
|
||||
|
||||
// POST /api/billing/buy body: { tier: "pro" | "max", method?: "bitcoin" | "card" }
|
||||
// Bitcoin (default) → BTCPay invoice; card → Zaprite hosted checkout.
|
||||
// Returns { ok, method, checkout_url, tier, period_days, ... }.
|
||||
app.post("/api/billing/buy", requireUser, async (req, res) => {
|
||||
// Must be a real signed-in user — a tier is keyed to a durable
|
||||
// user-id, not an anon trial cookie.
|
||||
if (!req.user || !req.user.id) {
|
||||
return res.status(403).json({
|
||||
error: "must_be_signed_in",
|
||||
message: "Sign in to buy a subscription.",
|
||||
});
|
||||
}
|
||||
const tier = String(req.body?.tier || "").trim().toLowerCase();
|
||||
if (!BUYABLE_TIERS.has(tier)) {
|
||||
return res.status(400).json({
|
||||
error: "bad_tier",
|
||||
message: 'tier must be "pro" or "max"',
|
||||
});
|
||||
}
|
||||
const method = String(req.body?.method || "bitcoin").trim().toLowerCase();
|
||||
if (!PAYMENT_METHODS.has(method)) {
|
||||
return res.status(400).json({
|
||||
error: "bad_method",
|
||||
message: 'method must be "bitcoin" or "card"',
|
||||
});
|
||||
}
|
||||
const origin = originFor(req);
|
||||
// Land back on the app with a marker the frontend uses to kick an
|
||||
// immediate status sync (the modal also polls, so this is a courtesy
|
||||
// for buyers who follow the checkout redirect).
|
||||
const returnUrl = origin ? `${origin}/?billing=success` : null;
|
||||
try {
|
||||
if (method === "card") {
|
||||
const order = await createRelayZapriteOrder({
|
||||
userId: req.user.id,
|
||||
tier,
|
||||
returnUrl,
|
||||
});
|
||||
return res.json({
|
||||
ok: true,
|
||||
method: "card",
|
||||
checkout_url: order.checkout_url || null,
|
||||
order_id: order.order_id || null,
|
||||
amount: order.amount ?? null,
|
||||
currency: order.currency || null,
|
||||
tier: order.tier || tier,
|
||||
period_days: order.period_days ?? null,
|
||||
});
|
||||
}
|
||||
// Bitcoin (default) — BTCPay invoice.
|
||||
const invoice = await createRelayTierInvoice({
|
||||
userId: req.user.id,
|
||||
tier,
|
||||
returnUrl,
|
||||
});
|
||||
res.json({
|
||||
ok: true,
|
||||
method: "bitcoin",
|
||||
checkout_url: invoice.checkout_url || null,
|
||||
invoice_id: invoice.invoice_id || null,
|
||||
sats: invoice.sats ?? null,
|
||||
tier: invoice.tier || tier,
|
||||
period_days: invoice.period_days ?? null,
|
||||
// Lightning BOLT11 for the inline QR (no redirect). Null → the app
|
||||
// falls back to opening the hosted checkout_url.
|
||||
bolt11: invoice.bolt11 || null,
|
||||
lightning_payment_link: invoice.lightning_payment_link || null,
|
||||
lightning_expires_at: invoice.lightning_expires_at || null,
|
||||
});
|
||||
} catch (err) {
|
||||
const status = err?.status || 502;
|
||||
console.error(
|
||||
`[billing] buy failed for ${req.user.id} (${tier}/${method}): ${err?.message || err}`,
|
||||
);
|
||||
// 503 from the relay = that rail isn't configured; surface a hint.
|
||||
const notConfigured =
|
||||
status === 503 || /not[_ ]configured/i.test(err?.message || "");
|
||||
const rail = method === "card" ? "Card" : "Bitcoin";
|
||||
const tool = method === "card" ? "Zaprite" : "BTCPay";
|
||||
res.status(notConfigured ? 503 : 502).json({
|
||||
error: notConfigured ? "payments_unavailable" : "checkout_failed",
|
||||
message: notConfigured
|
||||
? `${rail} payments aren't set up on this server yet. Ask the operator to configure ${tool}.`
|
||||
: "Couldn't start the payment. Please try again in a moment.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/billing/status
|
||||
// Returns { tier, expires_at, synced } — the user's current relay-owned
|
||||
// tier, with the local cache refreshed as a side effect.
|
||||
app.get("/api/billing/status", requireUser, async (req, res) => {
|
||||
if (!req.user || !req.user.id) {
|
||||
return res.status(403).json({ error: "must_be_signed_in" });
|
||||
}
|
||||
const synced = await syncUserTierFromRelay(req.user.id);
|
||||
if (synced.synced) {
|
||||
return res.json({
|
||||
tier: synced.tier,
|
||||
expires_at: synced.expires_at,
|
||||
tier_snapshot: synced.tier_snapshot,
|
||||
subscription_expired: synced.subscription_expired,
|
||||
synced: true,
|
||||
});
|
||||
}
|
||||
// Relay unreachable / unconfigured — fall back to the cached tier so
|
||||
// the UI still renders a sane badge instead of erroring.
|
||||
return res.json({
|
||||
tier: req.user.tier || "core",
|
||||
expires_at: null,
|
||||
synced: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user