// 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:") 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, }); }); }