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
253 lines
9.8 KiB
JavaScript
253 lines
9.8 KiB
JavaScript
// 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,
|
|
});
|
|
});
|
|
}
|