// POST /relay/user-tier + GET /relay/user-tier/:userId (core-decoupling) // // Operator-only. The cloud Recaps server (recaps.cc) calls these to SET and // READ a cloud user's Pro/Max tier — the relay is the source of truth for // cloud tiers, so granting Pro/Max means writing the user's credit row // here. Authenticated by the shared operator key (X-Recap-Operator-Key); // no per-user Keysat license is involved. // // POST body: { user_id, tier: "core"|"pro"|"max", expires_at?: ISO } // (self-serve subscription purchase is a later slice; for now tiers are // operator-set, and "revoke" = set tier back to "core".) import express from "express"; import { verifyOperatorKey, isSubscriptionExpired } from "../identity.js"; import { setUserTier, getUserCreditRow, computeRemaining, snapshotAll, } from "../credits.js"; import { getTierQuotas, getTierPricesSats, getTierPricesFiatCents, getSubscriptionPeriodDays, getZapriteConfig, getConfigSnapshot, } from "../config.js"; import { createTierInvoice, getInvoicePaymentMethods, pickLightningFromPaymentMethods, } from "../btcpay-client.js"; import { createOrder as createZapriteOrder } from "../zaprite-client.js"; const TIERS = new Set(["core", "pro", "max"]); const USER_ID_RE = /^[A-Za-z0-9_-]{1,128}$/; async function reportRow(userId, row) { const quota = await getTierQuotas(); const balance = computeRemaining(row, quota); // `tier` is the EFFECTIVE tier (expiry-enforced) — what callers gate on // and what Recaps caches. `tier_snapshot` is the raw stored value, so the // operator can still see "paid but lapsed". const expired = isSubscriptionExpired(row); const effectiveTier = expired ? "core" : row.tier_snapshot || "core"; return { ok: true, user_id: userId, tier: effectiveTier, tier_snapshot: row.tier_snapshot || "core", subscription_expired: expired, subscription_expires_at: row.subscription_expires_at || null, credits_remaining: balance.total, // null = unlimited (Max) tier_remaining: balance.remaining, purchased_balance: balance.purchased, }; } export function userTierRouter() { const router = express.Router(); router.post("/user-tier", express.json({ limit: "16kb" }), async (req, res) => { if (!(await verifyOperatorKey(req))) { return res.status(401).json({ error: "invalid_operator_key" }); } const userId = typeof req.body?.user_id === "string" ? req.body.user_id.trim() : ""; const tier = typeof req.body?.tier === "string" ? req.body.tier.trim() : ""; const expiresAt = typeof req.body?.expires_at === "string" && req.body.expires_at.trim() ? req.body.expires_at.trim() : null; if (!USER_ID_RE.test(userId)) { return res.status(400).json({ error: "invalid_user_id" }); } if (!TIERS.has(tier)) { return res.status(400).json({ error: "tier_must_be_core_pro_or_max" }); } const row = await setUserTier({ userId, tier, expiresAt }); console.log(`[user-tier] set ${userId} → ${tier}${expiresAt ? ` (expires ${expiresAt})` : ""}`); res.json(await reportRow(userId, row)); }); // Create a BTCPay invoice to buy a prepaid period of pro/max for a user. // The Recaps server calls this (operator-key authed) when a signed-in user // hits "Pay with Bitcoin"; it returns the checkout URL + invoice id. On // settlement the BTCPay webhook (routes/credits.js) calls extendUserTier. router.post("/tier-invoice", express.json({ limit: "16kb" }), async (req, res) => { if (!(await verifyOperatorKey(req))) { return res.status(401).json({ error: "invalid_operator_key" }); } const userId = typeof req.body?.user_id === "string" ? req.body.user_id.trim() : ""; const tier = typeof req.body?.tier === "string" ? req.body.tier.trim() : ""; const returnUrl = typeof req.body?.return_url === "string" ? req.body.return_url.trim() : ""; if (!USER_ID_RE.test(userId)) { return res.status(400).json({ error: "invalid_user_id" }); } if (tier !== "pro" && tier !== "max") { return res.status(400).json({ error: "tier_must_be_pro_or_max" }); } const prices = await getTierPricesSats(); const sats = prices[tier]; if (!Number.isFinite(sats) || sats <= 0) { return res.status(400).json({ error: "tier_not_priced" }); } const periodDays = await getSubscriptionPeriodDays(); const cfg = await getConfigSnapshot(); if ( !cfg.relay_btcpay_base_url || !cfg.relay_btcpay_store_id || !cfg.relay_btcpay_api_key ) { return res.status(503).json({ error: "btcpay_not_configured" }); } try { const invoice = await createTierInvoice({ baseURL: cfg.relay_btcpay_internal_url || cfg.relay_btcpay_base_url, storeId: cfg.relay_btcpay_store_id, apiKey: cfg.relay_btcpay_api_key, sats, userId, tier, periodDays, redirectURL: returnUrl || undefined, redirectAutomatically: !!returnUrl, }); // Prefer the operator's public BTCPay host for the buyer-facing link // (the invoice may have been created against an internal URL). let checkoutUrl = invoice.checkoutLink || null; if (checkoutUrl && cfg.relay_btcpay_public_url) { try { const u = new URL(checkoutUrl); const pub = new URL(cfg.relay_btcpay_public_url); u.protocol = pub.protocol; u.host = pub.host; checkoutUrl = u.toString(); } catch {} } // Fetch the Lightning BOLT11 so the Recaps app can render an INLINE // QR/invoice (no redirect to the hosted BTCPay page) — same as the // credit-pack flow. BTCPay generates the LN invoice asynchronously on // some configs, so retry once after a short backoff. On total failure // bolt11 stays null and the app falls back to the hosted checkout_url. let bolt11 = null; let lightningPaymentLink = null; let lnDebug = null; try { const pmArgs = { baseURL: cfg.relay_btcpay_internal_url || cfg.relay_btcpay_base_url, storeId: cfg.relay_btcpay_store_id, apiKey: cfg.relay_btcpay_api_key, invoiceId: invoice.id, }; let methods = await getInvoicePaymentMethods(pmArgs); let ln = pickLightningFromPaymentMethods(methods); if (!ln) { await new Promise((r) => setTimeout(r, 600)); methods = await getInvoicePaymentMethods(pmArgs); ln = pickLightningFromPaymentMethods(methods); } if (ln) { bolt11 = ln.bolt11; lightningPaymentLink = ln.paymentLink; } else { // Capture a sanitized sample of the payment-methods response so // operators can diagnose why no BOLT11 came back (BTCPay version // differences, LN not configured, etc.) — same diagnostic the // credit-pack flow records. const sample = (Array.isArray(methods) ? methods : []) .slice(0, 3) .map((m) => { const out = {}; for (const k of Object.keys(m || {})) { if (k === "payments") continue; const v = m[k]; out[k] = v === null || ["string", "number", "boolean"].includes(typeof v) ? v : `<${typeof v}>`; } return out; }); lnDebug = { reason: "no_lightning_method", methods_count: Array.isArray(methods) ? methods.length : 0, sample, }; console.warn( `[tier-invoice] invoice ${invoice.id}: no Lightning method on payment-methods — falling back to hosted checkout. sample=${JSON.stringify(sample)}`, ); } } catch (err) { lnDebug = { reason: "fetch_failed", message: (err?.message || String(err)).slice(0, 300), }; console.warn( `[tier-invoice] invoice ${invoice.id}: payment-methods fetch failed (${err?.message || err}) — falling back to hosted checkout`, ); } console.log( `[tier-invoice] ${tier} ${sats} sats / ${periodDays}d for ${userId.slice(0, 8)}… (invoice ${invoice.id}, ln=${bolt11 ? "yes" : "no"})`, ); res.json({ ok: true, invoice_id: invoice.id, checkout_url: checkoutUrl, sats, tier, period_days: periodDays, bolt11, lightning_payment_link: lightningPaymentLink, lightning_expires_at: invoice.expirationTime || null, expires_at: invoice.expirationTime || null, _ln_debug: lnDebug, }); } catch (err) { console.error(`[tier-invoice] createTierInvoice failed: ${err?.message || err}`); res .status(502) .json({ error: "invoice_create_failed", message: err?.message || String(err) }); } }); // Create a Zaprite hosted-checkout order to buy a prepaid Pro/Max period // for a user with a CARD. Operator-key authed (the Recaps server proxies // this when a signed-in user clicks "Pay by card"). Returns the checkout // URL + order id. On settlement the Zaprite webhook (below) re-fetches // the order and calls extendUserTier — the same landing point as the // BTCPay rail. router.post("/tier-zaprite-order", express.json({ limit: "16kb" }), async (req, res) => { if (!(await verifyOperatorKey(req))) { return res.status(401).json({ error: "invalid_operator_key" }); } const userId = typeof req.body?.user_id === "string" ? req.body.user_id.trim() : ""; const tier = typeof req.body?.tier === "string" ? req.body.tier.trim() : ""; const returnUrl = typeof req.body?.return_url === "string" ? req.body.return_url.trim() : ""; if (!USER_ID_RE.test(userId)) { return res.status(400).json({ error: "invalid_user_id" }); } if (tier !== "pro" && tier !== "max") { return res.status(400).json({ error: "tier_must_be_pro_or_max" }); } const prices = await getTierPricesFiatCents(); const amount = prices[tier]; if (!Number.isFinite(amount) || amount <= 0) { return res.status(400).json({ error: "tier_not_priced" }); } const periodDays = await getSubscriptionPeriodDays(); const zaprite = await getZapriteConfig(); if (!zaprite.apiKey) { return res.status(503).json({ error: "zaprite_not_configured" }); } try { const order = await createZapriteOrder({ baseURL: zaprite.baseUrl, apiKey: zaprite.apiKey, amount, currency: zaprite.currency, label: `Recaps ${tier.toUpperCase()} — ${periodDays} days`, metadata: { product: "recap_tier_subscription", user_id: userId, tier, period_days: periodDays, }, redirectUrl: returnUrl || undefined, }); const checkoutUrl = order?.checkoutUrl || null; if (!order?.id || !checkoutUrl) { throw new Error("Zaprite order missing id/checkoutUrl"); } console.log( `[tier-zaprite] ${tier} ${amount} ${zaprite.currency} / ${periodDays}d for ${userId.slice(0, 8)}… (order ${order.id})`, ); res.json({ ok: true, order_id: order.id, checkout_url: checkoutUrl, amount, currency: zaprite.currency, tier, period_days: periodDays, }); } catch (err) { console.error(`[tier-zaprite] createOrder failed: ${err?.message || err}`); res .status(502) .json({ error: "zaprite_order_failed", message: err?.message || String(err) }); } }); // List the buyable subscription plans + their sats prices. Operator-key // authed (the Recaps server proxies this to its purchase UI so the tier // prices stay sourced from the relay's config, never hardcoded in the // app). Returns { ok, period_days, plans: [{tier, sats}] }. router.get("/tier-plans", async (req, res) => { if (!(await verifyOperatorKey(req))) { return res.status(401).json({ error: "invalid_operator_key" }); } const prices = await getTierPricesSats(); const fiat = await getTierPricesFiatCents(); const periodDays = await getSubscriptionPeriodDays(); const zaprite = await getZapriteConfig(); const quotas = await getTierQuotas(); const cardAvailable = !!zaprite.apiKey; const plans = ["pro", "max"] .map((tier) => ({ tier, sats: prices[tier], // Card-rail price in the currency's smallest unit (cents for USD). fiat_amount: fiat[tier], fiat_currency: zaprite.currency, // Monthly relay-credit allotment for this tier, sourced from the // operator's Adjust-Tier-Quotas config. A number is the real // per-period credit count (e.g. Pro 50); null means unlimited. // The card shows whichever — so it always reflects the live config. credits_per_period: typeof quotas?.[tier]?.monthly === "number" ? quotas[tier].monthly : null, })) .filter((p) => Number.isFinite(p.sats) && p.sats > 0); res.json({ ok: true, period_days: periodDays, plans, // The UI hides the "Pay by card" link when the operator hasn't // configured Zaprite (so it never offers a rail that 503s). card_available: cardAvailable, }); }); // List cloud users whose prepaid period expires within the next // `within_days` OR lapsed within the last `lapsed_days`. Operator-key // authed. The relay owns the expiry (it's the subscription source of // truth), but not the email — so the Recaps server calls this to decide // who to send expiry-reminder emails to, then maps user_id → email on // its side. Returns { ok, now, subscriptions: [{user_id, tier, // expires_at, expired, days_left}] }, paid tiers only. router.get("/expiring-subscriptions", async (req, res) => { if (!(await verifyOperatorKey(req))) { return res.status(401).json({ error: "invalid_operator_key" }); } const clampInt = (v, def, lo, hi) => { const n = parseInt(v, 10); if (!Number.isFinite(n)) return def; return Math.max(lo, Math.min(hi, n)); }; const withinDays = clampInt(req.query.within_days, 7, 0, 120); const lapsedDays = clampInt(req.query.lapsed_days, 3, 0, 120); const now = Date.now(); const DAY = 86_400_000; const upperMs = now + withinDays * DAY; const lowerMs = now - lapsedDays * DAY; const out = []; for (const row of snapshotAll()) { const key = row.credit_key || ""; if (!key.startsWith("user:")) continue; const tier = row.tier_snapshot || "core"; if (tier !== "pro" && tier !== "max") continue; const exp = row.subscription_expires_at; if (!exp) continue; // open-ended grant (operator comp) — never expires const t = new Date(exp).getTime(); if (!Number.isFinite(t)) continue; if (t > upperMs || t < lowerMs) continue; // outside the reminder window out.push({ user_id: key.slice("user:".length), tier, expires_at: exp, expired: t < now, days_left: Math.ceil((t - now) / DAY), }); } res.json({ ok: true, now: new Date(now).toISOString(), subscriptions: out }); }); router.get("/user-tier/:userId", async (req, res) => { if (!(await verifyOperatorKey(req))) { return res.status(401).json({ error: "invalid_operator_key" }); } const userId = (req.params.userId || "").trim(); if (!USER_ID_RE.test(userId)) { return res.status(400).json({ error: "invalid_user_id" }); } const row = await getUserCreditRow(userId); res.json(await reportRow(userId, row)); }); return router; }