// /relay/credits/* — buy + poll relay credit top-ups via BTCPay. // // Three endpoints: // // GET /relay/credits/packages (no auth) — operator's // configured pricing menu. // The Recap UI fetches this // before showing the modal so // it always reflects current // pricing. // // POST /relay/credits/buy (X-Recap-Install-Id required) // Body: { credits: number } // Mints a BTCPay invoice tied // to this install + the chosen // credit pack. Returns // { invoice_id, checkout_url, // sats, credits, expires_at }. // // GET /relay/credits/invoice/:id (X-Recap-Install-Id required) // Polls BTCPay for invoice // status. Returns // { status: "new"|"processing"| // "settled"|"expired"|"invalid", // credits_remaining }. // // POST /relay/btcpay/webhook (no auth — HMAC validated) // BTCPay calls this on payment // events. We dedupe by // invoice_id and only credit // the install once per invoice. import express from "express"; import { resolveLicense } from "../keysat-client.js"; import { addPurchasedCredits, getOrCreateRow, computeRemaining, licenseFingerprint, extendUserTier, } from "../credits.js"; import { getConfigSnapshot, getTierQuotas, getCreditPackages } from "../config.js"; import { createInvoice, getInvoice, getInvoicePaymentMethods, pickLightningFromPaymentMethods, validateWebhookSignature, BtcPayError, } from "../btcpay-client.js"; import { recordCall } from "../audit-log.js"; import { isWebhookProcessed, markWebhookProcessed } from "../webhook-dedup.js"; import { envelope, errorEnvelope } from "./envelope.js"; // Dedup of processed BTCPay invoice ids. BTCPay retries webhook // deliveries on non-2xx responses or network errors, so the same // InvoiceSettled event can land more than once — we don't want to grant // credits twice for one paid invoice. Backed by the persistent // webhook-dedup store (../webhook-dedup.js) so a duplicate straddling a // relay restart can't double-credit. Keys are namespaced // `|` to share the store with the Zaprite rail. export function creditsRouter() { const router = express.Router(); // ── GET /relay/credits/packages ────────────────────────────────────── router.get("/credits/packages", async (_req, res) => { const packages = await getCreditPackages(); res.json({ packages }); }); // ── POST /relay/credits/buy ────────────────────────────────────────── router.post("/credits/buy", express.json(), async (req, res) => { const installId = req.header("X-Recap-Install-Id"); const auth = req.header("Authorization"); if (!installId) { const e = await errorEnvelope({ error: "missing X-Recap-Install-Id header", statusHint: 400, }); return res.status(400).json(e.body); } const requestedCredits = Number(req.body?.credits); // Optional: where to send the buyer's browser after they pay. // Frontend passes its own origin/page so the buyer lands back on // Recap instead of stuck on BTCPay's "Paid ✓" screen. We trust // the value blindly because Recap is the one calling us — but // even so we only forward it to BTCPay's checkout, never use it // in any sensitive context. const returnUrl = typeof req.body?.return_url === "string" && req.body.return_url.startsWith("http") ? req.body.return_url : null; if (!Number.isFinite(requestedCredits) || requestedCredits <= 0) { const e = await errorEnvelope({ error: "credits must be a positive number", installId, statusHint: 400, }); return res.status(400).json(e.body); } // Match against the configured package list — buyers can't // request arbitrary credit-for-sats ratios. const packages = await getCreditPackages(); const pack = packages.find((p) => p.credits === requestedCredits); if (!pack) { const e = await errorEnvelope({ error: `unknown package: ${requestedCredits} credits`, installId, statusHint: 400, }); return res.status(400).json(e.body); } const license = await resolveLicense(auth); const cfg = await getConfigSnapshot(); if ( !cfg.relay_btcpay_base_url || !cfg.relay_btcpay_store_id || !cfg.relay_btcpay_api_key ) { const e = await errorEnvelope({ error: "BTCPay is not configured on this relay — contact the operator", installId, tier: license.tier, statusHint: 503, }); return res.status(503).json(e.body); } try { const buyerFp = licenseFingerprint(license); const invoice = await createInvoice({ baseURL: cfg.relay_btcpay_internal_url || cfg.relay_btcpay_base_url, storeId: cfg.relay_btcpay_store_id, apiKey: cfg.relay_btcpay_api_key, sats: pack.sats, credits: pack.credits, installId, // Stash the buyer's license fingerprint (if any) on the // invoice so the webhook handler grants credits to their // license-keyed pool — survives restarts because BTCPay echoes // the metadata back on every event. licenseFingerprint: buyerFp, packageLabel: `${pack.credits} relay credits`, redirectURL: returnUrl || undefined, redirectAutomatically: !!returnUrl, }); await recordCall({ install_id: installId, license_fingerprint: buyerFp, tier: license.tier, pipeline: "credits_purchase", backend: null, model: null, status: "invoice_created", credit_charged: 0, duration_ms: 0, cost_usd: 0, purchase_credits: pack.credits, purchase_sats: pack.sats, invoice_id: invoice.id, }); // Fetch the per-payment-method breakdown so we can surface // the raw BOLT11 invoice + Lightning deep-link to the Recap // UI. Recap renders an inline QR + "Open in wallet" using // these fields, removing the redirect-to-BTCPay-checkout // step entirely (Phase 1 of the inline-payment migration). // // Best-effort: BTCPay sometimes generates the Lightning // invoice asynchronously and the destination is empty on the // first hit. We retry once with a short backoff. If still // empty (LN not configured on the store, or slow LND), we // fall through with null — Recap then falls back to the // legacy "open checkout_url in another tab" path. Never // fails the purchase because of this. // // Diagnostic capture: when bolt11 ends up null we surface // enough info on the response (under `_debug`) to figure out // why without operators having to comb through logs. Will be // removed once Phase 1 is verified working everywhere. let bolt11 = null; let lightningPaymentLink = null; let lnDebug = null; try { let methods = await getInvoicePaymentMethods({ 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 ln = pickLightningFromPaymentMethods(methods); if (!ln) { // Retry once after a short backoff — LN invoice generation // is async on some BTCPay configurations (LND, c-lightning // over Tor, etc.). await new Promise((r) => setTimeout(r, 600)); methods = await getInvoicePaymentMethods({ 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, }); ln = pickLightningFromPaymentMethods(methods); } if (ln) { bolt11 = ln.bolt11; lightningPaymentLink = ln.paymentLink; } else { // Dump exactly what BTCPay returned so we can fix the // pick-function heuristic for whatever version / // shape this store uses. Sample first ~3 methods, // exclude the per-method 'payments' history because // those can be large. 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]; if (v === null || ["string", "number", "boolean"].includes(typeof v)) { out[k] = v; } else { out[k] = `<${typeof v}>`; } } return out; }); lnDebug = { reason: "no_lightning_method", methods_count: Array.isArray(methods) ? methods.length : 0, sample, }; console.warn( `[credits/buy] invoice ${invoice.id}: no Lightning method on payment-methods response — sample=${JSON.stringify(sample)}` ); } } catch (err) { lnDebug = { reason: "fetch_failed", message: (err?.message || String(err)).slice(0, 300), status: err && typeof err === "object" && "status" in err ? err.status : null, }; console.warn( `[credits/buy] invoice ${invoice.id}: payment-methods fetch failed (${err?.message || err}) — Recap will fall back to checkout URL` ); } const rawCheckout = invoice.checkoutLink || (invoice.checkout && invoice.checkout.redirectURL) || null; const body = await envelope({ result: { invoice_id: invoice.id, // BTCPay returns the checkout URL with the internal // hostname we called it on (`btcpayserver.startos:23000`). // Rewrite to the operator's browser-facing URL so the // buyer's browser can actually reach it. // Buyer-facing: prefer the clearnet public URL over the // LAN base URL so buyers from anywhere on the internet // can pay. Falls back to base URL when no clearnet is set. checkout_url: rewriteCheckoutUrl( rawCheckout, cfg.relay_btcpay_public_url || cfg.relay_btcpay_base_url ), sats: pack.sats, credits: pack.credits, expires_at: invoice.expirationTime || null, status: invoice.status || "New", // Inline-payment fields (added in Phase 1). May be null // if LN isn't configured on the store, or if BTCPay // hasn't finished generating the LN invoice — Recap is // built to fall back to checkout_url in those cases. bolt11, lightning_payment_link: lightningPaymentLink, // Mirror BTCPay's invoice-level expiration as the // canonical "this invoice is no longer payable" deadline. // The Lightning invoice itself has its own expiry encoded // in the BOLT11, but BTCPay aligns the two by default and // surfacing the BTCPay number keeps things consistent // with the existing expires_at field above. lightning_expires_at: invoice.expirationTime || null, // Diagnostic — only set when bolt11 came back null. Lets // the Recap UI explain WHY the inline path didn't light // up without operators tailing relay logs. Will be // removed in a follow-up once Phase 1 is verified. _ln_debug: lnDebug, }, installId, license, tier: license.tier, }); res.json(body); } catch (err) { console.error(`[credits/buy] failed: ${err?.message || err}`); const status = err instanceof BtcPayError && err.status === 401 ? 502 : 502; const e = await errorEnvelope({ error: err?.message || "btcpay_create_invoice_failed", installId, tier: license.tier, statusHint: status, }); return res.status(status).json(e.body); } }); // ── GET /relay/credits/invoice/:id ─────────────────────────────────── // Poll loop's friend. Returns the BTCPay status PLUS the install's // updated credit balance so the UI can refresh both in one round- // trip after settlement. router.get("/credits/invoice/:id", async (req, res) => { const installId = req.header("X-Recap-Install-Id"); const auth = req.header("Authorization"); if (!installId) { const e = await errorEnvelope({ error: "missing X-Recap-Install-Id header", statusHint: 400, }); return res.status(400).json(e.body); } const invoiceId = (req.params.id || "").trim(); if (!invoiceId) { const e = await errorEnvelope({ error: "missing invoice id", installId, statusHint: 400, }); return res.status(400).json(e.body); } const cfg = await getConfigSnapshot(); const license = await resolveLicense(auth); try { const invoice = await getInvoice({ baseURL: cfg.relay_btcpay_internal_url || cfg.relay_btcpay_base_url, storeId: cfg.relay_btcpay_store_id, apiKey: cfg.relay_btcpay_api_key, invoiceId, }); // Guard: only the install that minted this invoice can poll it. // BTCPay metadata may carry install_id from createInvoice; if // present, refuse cross-install lookups. const meta = invoice.metadata || {}; if (meta.install_id && meta.install_id !== installId) { const e = await errorEnvelope({ error: "invoice belongs to a different install", installId, tier: license.tier, statusHint: 403, }); return res.status(403).json(e.body); } const row = await getOrCreateRow({ installId, license }); const quota = await getTierQuotas(); const balance = computeRemaining(row, quota); const body = await envelope({ result: { invoice_id: invoice.id, status: normalizeStatus(invoice.status), status_raw: invoice.status, credits: Number(meta.credits) || null, sats: invoice.amount ? Number(invoice.amount) : null, checkout_url: rewriteCheckoutUrl( invoice.checkoutLink || null, cfg.relay_btcpay_public_url || cfg.relay_btcpay_base_url ), balance: { tier_remaining: balance.remaining, purchased: balance.purchased, total: balance.total, }, }, installId, license, tier: license.tier, }); res.json(body); } catch (err) { console.error(`[credits/invoice] failed: ${err?.message || err}`); const e = await errorEnvelope({ error: err?.message || "btcpay_get_invoice_failed", installId, tier: license.tier, statusHint: 502, }); return res.status(502).json(e.body); } }); // ── POST /relay/btcpay/webhook ─────────────────────────────────────── // BTCPay calls this on every invoice event. We: // 1. Validate the HMAC-SHA256 signature against the operator-set // webhook secret. // 2. Skip anything that isn't an "InvoiceSettled" event. // 3. Dedupe by invoice id (BTCPay may retry on failures). // 4. Look up the invoice metadata, then credit the install. // // Uses express.raw() so we have the EXACT bytes BTCPay signed — // any whitespace canonicalization after JSON-parse would break the // HMAC check. router.post( "/btcpay/webhook", express.raw({ type: "*/*", limit: "1mb" }), async (req, res) => { const cfg = await getConfigSnapshot(); const secret = cfg.relay_btcpay_webhook_secret; if (!secret) { console.warn("[btcpay/webhook] secret not configured — rejecting"); return res.status(503).json({ error: "webhook_secret_not_configured" }); } const sig = req.header("BTCPay-Sig") || ""; const ok = validateWebhookSignature({ rawBody: req.body, signatureHeader: sig, secret, }); if (!ok) { console.warn( `[btcpay/webhook] signature mismatch (header=${sig.slice(0, 20)}...)` ); return res.status(401).json({ error: "bad_signature" }); } let payload; try { payload = JSON.parse(req.body.toString("utf8")); } catch (err) { return res.status(400).json({ error: "bad_json" }); } // BTCPay event types we care about. "InvoiceSettled" fires once // the invoice is fully paid + confirmed. We deliberately ignore // "InvoiceProcessing" (in-mempool, not yet confirmed) and // "InvoiceExpired" (timed out without payment). const type = payload?.type || ""; const invoiceId = payload?.invoiceId || payload?.invoice_id || null; if (!invoiceId) { return res.status(200).json({ ok: true, ignored: "no_invoice_id" }); } if (type !== "InvoiceSettled") { // Acknowledge to stop BTCPay from retrying, but do nothing. return res.status(200).json({ ok: true, ignored: `type=${type}` }); } const dedupKey = `${cfg.relay_btcpay_store_id}|${invoiceId}`; if (isWebhookProcessed(dedupKey)) { return res .status(200) .json({ ok: true, ignored: "already_processed", invoiceId }); } // Pull the invoice from BTCPay to read its metadata (the webhook // payload doesn't carry our embedded install_id + credits // fields directly — they live on the invoice object). let invoice; try { invoice = await getInvoice({ baseURL: cfg.relay_btcpay_internal_url || cfg.relay_btcpay_base_url, storeId: cfg.relay_btcpay_store_id, apiKey: cfg.relay_btcpay_api_key, invoiceId, }); } catch (err) { console.error( `[btcpay/webhook] getInvoice failed for ${invoiceId}: ${err?.message || err}` ); // Return 5xx so BTCPay retries — this is likely a transient // network error against BTCPay's own API. return res.status(502).json({ error: "btcpay_lookup_failed" }); } const meta = invoice.metadata || {}; // Self-serve subscription purchase (Pro/Max) — different metadata shape // than credit purchases. Extend the buyer's prepaid period and stop. if (meta.product === "recap_tier_subscription") { const subUserId = typeof meta.user_id === "string" ? meta.user_id : ""; const subTier = meta.tier; const periodDays = Number(meta.period_days) || 30; if (!subUserId || (subTier !== "pro" && subTier !== "max")) { await markWebhookProcessed(dedupKey); return res .status(200) .json({ ok: true, ignored: "bad_tier_metadata", invoiceId }); } try { const row = await extendUserTier({ userId: subUserId, tier: subTier, periodDays, }); await markWebhookProcessed(dedupKey); console.log( `[btcpay/webhook] ${subTier} +${periodDays}d for user ${subUserId.slice(0, 8)}… (invoice ${invoiceId}) → expires ${row.subscription_expires_at}`, ); return res.status(200).json({ ok: true, tier: subTier, user: subUserId, expires_at: row.subscription_expires_at, }); } catch (err) { console.error( `[btcpay/webhook] extendUserTier failed: ${err?.message || err}`, ); return res.status(500).json({ error: "tier_grant_failed" }); } } const installId = meta.install_id; const credits = Number(meta.credits); // license_fingerprint was stashed at buy time when the buyer // had an active paid license. Used here to route the credit to // their license-keyed pool instead of an install-keyed pool. // Missing (older invoices, or Core buyers) → falls back to // install-keyed. const buyerFp = meta.license_fingerprint || null; const creditKey = buyerFp ? `lic:${buyerFp}` : null; if (!installId || !Number.isFinite(credits) || credits <= 0) { // Not ours — could be an invoice from another product on the // same store. Mark processed so we don't keep retrying, and // 200 so BTCPay stops calling. await markWebhookProcessed(dedupKey); return res .status(200) .json({ ok: true, ignored: "no_recap_metadata" }); } try { const newBalance = await addPurchasedCredits({ installId, creditKey, amount: credits, }); await markWebhookProcessed(dedupKey); await recordCall({ install_id: installId, license_fingerprint: buyerFp, tier: null, pipeline: "credits_purchase", backend: null, model: null, status: "settled", credit_charged: 0, duration_ms: 0, cost_usd: 0, purchase_credits: credits, purchase_sats: Number(invoice.amount) || null, invoice_id: invoiceId, purchased_balance_after: newBalance, }); console.log( `[btcpay/webhook] credited install ${installId.slice(0, 8)}… with ${credits} credits (invoice ${invoiceId})${creditKey ? ` → pool ${creditKey}` : ""}` ); return res .status(200) .json({ ok: true, credited: credits, install: installId }); } catch (err) { console.error( `[btcpay/webhook] addPurchasedCredits failed: ${err?.message || err}` ); return res .status(500) .json({ error: "credit_grant_failed" }); } } ); return router; } // Rewrite a BTCPay checkout URL's host to the operator's browser- // facing URL. BTCPay builds the checkoutLink using whatever host the // invoice-create API call came in on — and since we hit it via // `http://btcpayserver.startos:23000` (the internal docker network // hostname), the returned URL is `btcpayserver.startos:23000/i/...` // which the buyer's browser can't resolve. // // Fix: parse the URL, swap origin (scheme + host + port) with the // operator's `relay_btcpay_base_url` (set by the one-click setup to // the discovered mDNS / clearnet URL). Path + query are preserved. // If parsing fails for any reason, return the URL unchanged — better // to ship a broken link than crash the buy flow. function rewriteCheckoutUrl(url, browserBase) { if (!url || !browserBase) return url; try { const u = new URL(url); const b = new URL(browserBase); u.protocol = b.protocol; u.hostname = b.hostname; // Explicitly assign port from the base — including the empty // string when the base has no port (HTTPS on default 443). // Using `u.host = b.host` looks equivalent but Node's URL keeps // the original port when the new host string has none, so a // rewrite from `btcpayserver.startos:23000` → `btcpay.keysat.xyz` // ended up as `btcpay.keysat.xyz:23000`. Assigning port directly // clears it. u.port = b.port || ""; return u.toString(); } catch { return url; } } // Recovery: when the BTCPay webhook URL was broken and paid // invoices never got credited, this scans BTCPay's recent settled // invoices and grants credits for ones the relay hasn't processed. // Idempotent via the same persistent webhook-dedup store the webhook // uses, so re-running is safe. // // Exported so the admin route in btcpay-setup.js can call it. Not // exposed via /relay/* because it's operator-initiated, not buyer. export async function rescanSettledInvoices() { const cfg = await getConfigSnapshot(); if ( !cfg.relay_btcpay_base_url || !cfg.relay_btcpay_store_id || !cfg.relay_btcpay_api_key ) { return { ok: false, error: "btcpay_not_configured" }; } const apiBase = ( cfg.relay_btcpay_internal_url || cfg.relay_btcpay_base_url ).replace(/\/$/, ""); const sinceMs = Date.now() - 30 * 24 * 3600 * 1000; let invoices; try { const r = await fetch( `${apiBase}/api/v1/stores/${encodeURIComponent(cfg.relay_btcpay_store_id)}/invoices?take=1000&status=Settled&startDate=${Math.floor(sinceMs / 1000)}`, { headers: { Authorization: `token ${cfg.relay_btcpay_api_key}` } } ); if (!r.ok) { const text = await r.text(); return { ok: false, error: "btcpay_list_invoices_failed", message: text.slice(0, 300), }; } invoices = await r.json(); } catch (err) { return { ok: false, error: "rescan_failed", message: (err?.message || String(err)).slice(0, 200), }; } let credited = 0; let alreadyProcessed = 0; let skipped = 0; const details = []; for (const invoice of invoices || []) { const dedupKey = `${cfg.relay_btcpay_store_id}|${invoice.id}`; if (isWebhookProcessed(dedupKey)) { alreadyProcessed++; continue; } const meta = invoice.metadata || {}; const installId = meta.install_id; const credits = Number(meta.credits); const buyerFp = meta.license_fingerprint || null; const creditKey = buyerFp ? `lic:${buyerFp}` : null; if ( !installId || !Number.isFinite(credits) || credits <= 0 || meta.product !== "recap_credits" ) { skipped++; continue; } try { const newBalance = await addPurchasedCredits({ installId, creditKey, amount: credits, }); await markWebhookProcessed(dedupKey); await recordCall({ install_id: installId, license_fingerprint: buyerFp, tier: null, pipeline: "credits_purchase", backend: null, model: null, status: "settled", credit_charged: 0, duration_ms: 0, cost_usd: 0, purchase_credits: credits, purchase_sats: Number(invoice.amount) || null, invoice_id: invoice.id, purchased_balance_after: newBalance, recovery_rescan: true, }); credited++; details.push({ invoice_id: invoice.id, install: installId.slice(0, 8), credits, new_balance: newBalance, }); } catch (err) { console.error( `[credits/rescan] addPurchasedCredits failed for ${invoice.id}: ${err?.message || err}` ); skipped++; } } console.log( `[credits/rescan] credited=${credited} already=${alreadyProcessed} skipped=${skipped}` ); return { ok: true, credited, already_processed: alreadyProcessed, skipped, details, }; } // BTCPay's status enum → a coarser one Recap can render off of. function normalizeStatus(s) { switch ((s || "").toLowerCase()) { case "new": return "new"; case "processing": return "processing"; case "settled": case "complete": return "settled"; case "expired": return "expired"; case "invalid": return "invalid"; default: return (s || "unknown").toLowerCase(); } }