// Recap-side proxy to the relay's credit-purchase endpoints. // // Architecture is identical to license-purchase.js: Recap doesn't // hold the BTCPay credentials, the relay does. Recap just forwards // the buyer's pick to the relay and proxies the polling. The relay // returns the BTCPay checkout URL which the Recap UI displays in a // modal styled to match the license-purchase modal. // // Endpoints: // GET /api/credits/packages → relay GET /relay/credits/packages // POST /api/credits/buy → relay POST /relay/credits/buy // GET /api/credits/invoice/:id → relay GET /relay/credits/invoice/:id // // Auth headers (X-Recap-Install-Id + Authorization Bearer LIC1-...) // are added by this proxy, not by the buyer-side JS — keeping the // install identity + license key out of any client-side code. import { getRelayBaseURL } from "./relay-default.js"; import { getInstallId } from "./install-id.js"; import { getRawLicenseKey } from "./license.js"; // Multi-mode toggle. In multi mode every credit purchase is recorded // in pending_purchases so we know WHO (signed-in user vs. anon trial // cookie) to credit locally when the invoice settles. Single mode is // the legacy "operator-pool only" flow — no local accounting layer, // the relay's credits.json IS the source of truth. const RECAP_MODE = process.env.RECAP_MODE === "multi" ? "multi" : "single"; function relayHeaders({ json = false, req = null } = {}) { const h = {}; // Identity routing for the credit-purchase + credit-poll flow: // // Pro/Max signed-in tenant (req.user.keysat_license set) // → use THEIR install ID + license. The buy invoice gets // stashed with THEIR license_fingerprint so the BTCPay // settle-webhook credits THEIR license-keyed pool — the // same pool /api/relay/status reads when displaying their // balance. Without this, credits land on the operator's // pool and the buyer sees their balance unchanged after // paying (the bug Grant hit on 2026-05-18). // // Anon visitor (trial cookie only) / free signed-in tenant // (no license) / single-mode operator // → fall back to operator identity. The operator's pool is // what's being topped up; Recaps' own accounting layer // (anon_trials / tenant_credits) handles the per-user // attribution locally via pending_purchases. let installId = null; let licenseKey = null; if (req?.user?.keysat_license && req.user.synthetic_install_id) { installId = req.user.synthetic_install_id; licenseKey = req.user.keysat_license; } if (!installId) { try { const id = getInstallId(); if (id) installId = id; } catch {} } if (!licenseKey) { try { const key = getRawLicenseKey(); if (key) licenseKey = key; } catch {} } if (installId) h["X-Recap-Install-Id"] = installId; if (licenseKey) h["Authorization"] = `Bearer ${licenseKey}`; if (json) h["Content-Type"] = "application/json"; return h; } export function setupCreditsPurchaseRoutes(app) { // List bundles the operator has configured. Cheap, no auth gating — // the buyer needs the price menu before they decide whether to pay. app.get("/api/credits/packages", async (_req, res) => { const base = getRelayBaseURL(); if (!base) { return res.status(503).json({ error: "relay_not_configured", message: "Relay base URL not set on this Recaps install.", }); } try { // 10s timeout — was 5s, but a cold relay request from mobile // cellular can take 6-8s, and Safari iOS surfaces the abort as // a generic "Load failed" with no other info, so the buyer // sees an error and has to manually retry. 10s is still snappy // enough that a legit failure doesn't hang the UI for long. const r = await fetch(`${base.replace(/\/$/, "")}/relay/credits/packages`, { signal: AbortSignal.timeout(10000), }); const text = await r.text(); let body = null; try { body = text ? JSON.parse(text) : null; } catch {} if (!r.ok) { return res.status(r.status).json(body || { error: "relay_packages_failed" }); } res.json(body || { packages: [] }); } catch (err) { console.error(`[credits/packages] failed: ${err?.message || err}`); res.status(502).json({ error: "packages_fetch_failed", message: (err?.message || String(err)).slice(0, 300), }); } }); // Initiate a purchase. Body: { credits: 1|5|10|20 }. Returns the // raw relay envelope (so the UI sees credits_remaining + tier + // result.checkout_url + result.invoice_id). // // Multi-mode: identifies the buyer (signed-in user or anon trial // cookie), records a pending_purchases row keyed by the invoice_id // the relay returns. The settle-handler (in /api/credits/invoice/:id // below) uses that row to know WHERE to apply the credits locally. // // Single-mode: skips the pending_purchases bookkeeping entirely; // the operator IS the buyer and the relay's credits.json directly // tracks their pool. app.post("/api/credits/buy", async (req, res) => { const base = getRelayBaseURL(); if (!base) { return res .status(503) .json({ error: "relay_not_configured" }); } const credits = Number(req.body?.credits); const returnUrl = typeof req.body?.return_url === "string" && req.body.return_url.startsWith("http") ? req.body.return_url : null; if (!Number.isFinite(credits) || credits <= 0) { return res .status(400) .json({ error: "credits_required" }); } // Identify the buyer for multi-mode. Either a signed-in user OR // an anon trial cookie. If neither, attempt to auto-mint a trial // cookie — anon visitors who click "Buy more" from the toolbar // (before they've spent their pre-trial allowance) shouldn't be // dead-ended into a sign-in nag. Same auto-mint pattern as // /api/process for pre-trial visitors. Only refuse if trials are // disabled or the IP is over its lifetime cap. let buyerType = null; let buyerId = null; if (RECAP_MODE === "multi") { if (req.user && req.user.id) { buyerType = "user"; buyerId = req.user.id; } else if (req.trial && req.trial.cookie_id) { buyerType = "anon"; buyerId = req.trial.cookie_id; } else { // Try to mint a fresh trial cookie so the purchase has // somewhere to land. forceMint=true bypasses the lifetime // IP cap and the trials-disabled config — a paying buyer is // by definition not abusing a free quota, and without a // tracking cookie the settle handler has nowhere to credit // the purchase locally (the relay still credits the operator // pool; we just lose the visibility to apply it to this // specific buyer). try { const { issueIfEligible } = await import("./anon-trial.js"); const trial = await issueIfEligible({ req, res, forceMint: true }); if (trial) { buyerType = "anon"; buyerId = trial.cookie_id; // Stash on req for downstream code paths req.trial = trial; } } catch (err) { console.warn( "[credits/buy] anon-trial mint failed:", err?.message || err, ); } if (!buyerId) { return res.status(401).json({ error: "buyer_unknown", message: "Couldn't create a buyer record for this purchase. Sign up for a free account so we have somewhere to credit it.", }); } } } try { const r = await fetch(`${base.replace(/\/$/, "")}/relay/credits/buy`, { method: "POST", headers: relayHeaders({ json: true, req }), body: JSON.stringify({ credits, return_url: returnUrl || undefined }), signal: AbortSignal.timeout(15_000), }); const text = await r.text(); let body = null; try { body = text ? JSON.parse(text) : null; } catch {} if (!r.ok) { return res .status(r.status) .json(body || { error: "relay_buy_failed" }); } // Record the pending purchase BEFORE we respond, so even if the // browser refreshes / crashes between buy + settle, the next // poll for this invoice id will still know who to credit. // Invoice id lives under result.invoice_id per the relay's // envelope contract (same shape license-purchase uses). const invoiceId = body?.result?.invoice_id || body?.invoice_id || body?.btcpay_invoice_id || null; if (RECAP_MODE === "multi") { if (!invoiceId) { // Loud warning — without an invoice id we can't reconcile // on settle. Surface the response shape so we can see what // the relay actually returned and fix the field-name // assumption if this fires. console.warn( `[credits/buy] NO invoice_id in relay response — skipping pending_purchases. Top-level keys: ${Object.keys(body || {}).join(", ")} | result keys: ${Object.keys(body?.result || {}).join(", ")}`, ); } else if (!buyerType || !buyerId) { console.warn( `[credits/buy] invoice ${invoiceId}: buyer identity missing — won't auto-apply on settle.`, ); } else { try { const { getDb } = await import("./db.js"); const result = getDb() .prepare( `INSERT OR IGNORE INTO pending_purchases (invoice_id, buyer_type, buyer_id, credits, created_at) VALUES (?, ?, ?, ?, ?)`, ) .run(invoiceId, buyerType, buyerId, credits, Date.now()); console.log( `[credits/buy] tracked pending purchase invoice=${invoiceId} buyer=${buyerType}:${buyerId} credits=${credits} rowsInserted=${result.changes}`, ); } catch (err) { console.error( `[credits/buy] failed to record pending purchase ${invoiceId}: ${err?.message || err}`, ); } } } res.json(body || {}); } catch (err) { console.error(`[credits/buy] failed: ${err?.message || err}`); res.status(502).json({ error: "purchase_failed", message: (err?.message || String(err)).slice(0, 300), }); } }); // Poll an invoice's status. Returns the relay envelope; the UI // reads `result.status` ("new" | "processing" | "settled" | // "expired" | "invalid") and refreshes when settled. // // Multi-mode side effect: when the relay reports settled, we look // up the matching pending_purchases row and apply the credits to // the right local balance. Idempotent via applied_at — if the same // invoice is polled multiple times after settle, only the first // application takes effect. app.get("/api/credits/invoice/:id", async (req, res) => { const base = getRelayBaseURL(); if (!base) { return res.status(503).json({ error: "relay_not_configured" }); } const id = (req.params.id || "").trim(); if (!id) { return res.status(400).json({ error: "missing_invoice_id" }); } try { const r = await fetch( `${base.replace(/\/$/, "")}/relay/credits/invoice/${encodeURIComponent(id)}`, { headers: relayHeaders({ req }), signal: AbortSignal.timeout(10_000), } ); const text = await r.text(); let body = null; try { body = text ? JSON.parse(text) : null; } catch {} if (!r.ok) { return res .status(r.status) .json(body || { error: "relay_poll_failed" }); } // Multi-mode: settle-and-apply. Status path mirrors the // license-purchase poll-settle handler. const status = body?.result?.status || body?.status || null; if (RECAP_MODE === "multi" && status === "settled") { try { await applyPendingPurchase(id); } catch (err) { console.error( `[credits/invoice] apply failed for ${id}: ${err?.message || err}`, ); // Don't fail the response — the relay reported settled and // the operator pool has the credits. Local apply can be // retried by hitting this endpoint again, or by a future // reconciliation tool. } } res.json(body || {}); } catch (err) { console.error(`[credits/invoice] failed: ${err?.message || err}`); res.status(502).json({ error: "poll_failed", message: (err?.message || String(err)).slice(0, 300), }); } }); // POST /api/credits/claim { invoice_id } // Manual self-service recovery: a signed-in user pastes the BTCPay // invoice ID of a purchase they made anonymously (e.g., Safari // Private mode where the trial cookie didn't survive the magic- // link click). We verify the invoice is settled at the relay AND // the pending_purchases row is anon-buyer + unapplied, then credit // their account. // // Safety: // - Requires authenticated user (req.user.id must be set) // - Only claims buyer_type='anon' rows (no user-to-user takeover) // - applied_at idempotency guard prevents double-credit // - BTCPay invoice IDs are 30+ char random — not enumerable // - User-buyer rows are never claimable here, regardless of // ownership — those are the cookie sweep's job app.post("/api/credits/claim", async (req, res) => { if (RECAP_MODE !== "multi") { return res.status(404).json({ error: "not_available" }); } if (!req.user || !req.user.id) { return res.status(401).json({ error: "auth_required", message: "Sign in first to claim a purchase to your account.", }); } const invoiceId = String(req.body?.invoice_id || "").trim(); if (!invoiceId) { return res.status(400).json({ error: "missing_invoice_id", message: "Paste the invoice ID from your purchase email.", }); } const { getDb } = await import("./db.js"); const db = getDb(); const row = db .prepare( `SELECT invoice_id, buyer_type, buyer_id, credits, applied_at FROM pending_purchases WHERE invoice_id = ?`, ) .get(invoiceId); if (!row) { return res.status(404).json({ error: "invoice_not_found", message: "We don't have a record of that invoice ID. Double-check it — the ID is shown in your BTCPay payment confirmation.", }); } if (row.buyer_type !== "anon") { // user-buyer rows are claimable only by their original buyer // (cookie sweep) — refusing this avoids user-to-user takeover. return res.status(403).json({ error: "not_anon_purchase", message: "This invoice was bought from a signed-in account and can only be claimed by that account.", }); } if (row.applied_at) { return res.status(409).json({ error: "already_applied", message: "Those credits were already applied. Check your balance — they may have transferred automatically.", }); } // Verify settled at the relay before crediting. We do NOT trust // the local row alone — the buyer could have initiated the // invoice and never paid; without this check, anyone could // claim N credits just by knowing an invoice ID. const base = getRelayBaseURL(); if (!base) { return res.status(503).json({ error: "relay_not_configured" }); } let status = null; try { const r = await fetch( `${base.replace(/\/$/, "")}/relay/credits/invoice/${encodeURIComponent(invoiceId)}`, { headers: relayHeaders({ req }), signal: AbortSignal.timeout(10_000) }, ); if (r.ok) { const body = await r.json().catch(() => ({})); status = body?.result?.status || body?.status || null; } } catch (err) { console.warn( `[credits/claim] relay status check failed for ${invoiceId}: ${err?.message || err}`, ); return res.status(502).json({ error: "relay_unreachable", message: "Couldn't verify the invoice with the payment server. Try again in a minute.", }); } if (status !== "settled") { return res.status(409).json({ error: "not_settled", message: `That invoice is not settled (status: ${status || "unknown"}). If you just paid, wait a minute and try again.`, }); } try { await applyPendingPurchase(invoiceId, { forceUserId: req.user.id }); } catch (err) { console.error( `[credits/claim] apply failed for ${invoiceId}: ${err?.message || err}`, ); return res.status(500).json({ error: "apply_failed", message: "Something went wrong applying the credits. Try again.", }); } console.log( `[credits/claim] user ${req.user.id} claimed invoice ${invoiceId} (${row.credits} credits)`, ); res.json({ ok: true, credits: row.credits }); }); } // applyPendingPurchase(invoiceId, opts?) — credit the buyer's local // balance for a settled invoice. Idempotent: bails if the row is // already marked applied. If the buyer was an anon trial that has // since been converted to a real user, credits route to the user // instead. // // opts.forceUserId (optional) — route credits to this user instead // of the row's recorded buyer. Used by the manual-claim endpoint: // when a signed-in user pastes a BTCPay invoice ID for an anon // purchase whose trial cookie was lost (e.g., Safari Private mode // where the magic-link click landed in a different cookie jar), we // trust the invoice ID as proof-of-ownership and direct the credits // to their tenant_credits. // // Exported so the sweep helper below — and any future server-side // flow that wants to reconcile a known-settled invoice — can call it // without going through the /api/credits/invoice/:id route. export async function applyPendingPurchase(invoiceId, opts = {}) { const { getDb } = await import("./db.js"); const db = getDb(); const row = db .prepare( `SELECT invoice_id, buyer_type, buyer_id, credits, applied_at FROM pending_purchases WHERE invoice_id = ?`, ) .get(invoiceId); if (!row) { // Either the buy came from a different Recap instance, or the // bookkeeping insert in /api/credits/buy failed earlier. Nothing // to do; the operator pool still has the credits from BTCPay. // Log so operator can reconcile manually if this fires. console.warn( `[credits/invoice] settled invoice ${invoiceId} has NO matching pending_purchases row — local balance NOT auto-applied. The credits ARE in the operator pool at the relay; operator should grant manually to the buyer.`, ); return; } if (row.applied_at) { return; // already applied, idempotent no-op } // Resolve buyer → target user_id (for tenant_credits) or trial // cookie_id (for anon_trials.credits_total). Anon-buyers who have // since converted to a real user get their credits routed to the // user's tenant_credits — that's the cleaner outcome and matches // the "credits transfer on signup" semantics the design promises. let targetUserId = null; let targetCookieId = null; if (opts.forceUserId) { targetUserId = opts.forceUserId; } else if (row.buyer_type === "user") { targetUserId = row.buyer_id; } else if (row.buyer_type === "anon") { const trial = db .prepare( "SELECT cookie_id, converted_to_user_id FROM anon_trials WHERE cookie_id = ?", ) .get(row.buyer_id); if (trial?.converted_to_user_id) { targetUserId = trial.converted_to_user_id; } else { targetCookieId = row.buyer_id; } } // Apply + mark applied in one transaction so a crash mid-way // doesn't leave a half-credited buyer. Purchased credits land in // the PERMANENT bucket (purchased_balance) so they're not wiped on // the next replenishment refresh. const tx = db.transaction(() => { if (targetUserId) { const existing = db .prepare("SELECT user_id FROM tenant_credits WHERE user_id = ?") .get(targetUserId); if (existing) { db.prepare( `UPDATE tenant_credits SET purchased_balance = purchased_balance + ?, lifetime_granted = lifetime_granted + ? WHERE user_id = ?`, ).run(row.credits, row.credits, targetUserId); } else { db.prepare( `INSERT INTO tenant_credits (user_id, purchased_balance, replenish_balance, last_replenish_at, lifetime_granted, lifetime_consumed) VALUES (?, ?, 0, ?, ?, 0)`, ).run(targetUserId, row.credits, Date.now(), row.credits); } } else if (targetCookieId) { // Anon trial: credits go into the trial's credits_total (single // bucket — anons don't have the purchased/replenish split). // They'll move to purchased_balance on signup via linkToUser. db.prepare( `UPDATE anon_trials SET credits_total = credits_total + ? WHERE cookie_id = ?`, ).run(row.credits, targetCookieId); } db.prepare( "UPDATE pending_purchases SET applied_at = ? WHERE invoice_id = ?", ).run(Date.now(), invoiceId); }); tx(); console.log( `[credits/invoice] applied ${row.credits} credits for ${row.buyer_type}:${row.buyer_id} → ${ targetUserId ? "user " + targetUserId : "anon " + targetCookieId }`, ); } // sweepUnappliedPurchases({ buyerType, buyerId, cookieIds }) — catch // up on settled-but-unapplied purchases for a buyer. // // Why this exists: the buy → BTCPay → settle → apply pipeline depends // on the buyer's browser tab polling /api/credits/invoice/:id after // BTCPay redirects back. But BTCPay redirects in the SAME tab (the // poll loop dies before it gets a chance to see "settled"), and even // when the redirect lands back on Recap the buyer might close it // before the next poll tick. Result: the relay knows the invoice is // settled and the operator pool has the credits, but the LOCAL // pending_purchases row never flips to applied — so the buyer's // balance stays stale until they manually re-poll, which they have no // way to do. // // Fix: opportunistically sweep on every /api/account/whoami and // /api/relay/status. Cheap (small bounded query + a few relay HTTP // calls), idempotent (applyPendingPurchase no-ops on already-applied // rows), and self-healing. // // Also called from anon-trial.js linkToUser BEFORE the transfer, so // any anon-bought credits that hadn't yet been applied locally are // rolled into anon_trials.credits_total before we copy them over to // the new user's tenant_credits. // // Scope: only sweeps the buyer's OWN pending rows. cookieIds is an // optional list of additional anon cookie_ids the caller wants // swept on this buyer's behalf (used by /whoami for the new-signup // case where the just-converted cookie may still have unapplied // purchases). Cap at 5 invoices per sweep + 30-minute lookback so a // degenerate case can't fan out into hundreds of relay calls per // request. export async function sweepUnappliedPurchases({ buyerType, buyerId, cookieIds = [], req = null, } = {}) { if (RECAP_MODE !== "multi") return; if (!buyerType && (!cookieIds || cookieIds.length === 0)) return; const base = getRelayBaseURL(); if (!base) return; // no relay configured, nothing to sweep against const { getDb } = await import("./db.js"); const db = getDb(); // 30-minute lookback. Older unapplied purchases probably failed for // a reason we don't want to keep retrying every page-load (relay // unreachable, invoice expired, etc.). Operator can reconcile // manually if they fire. const since = Date.now() - 30 * 60 * 1000; // Build the WHERE clause. Always include the primary buyer; OR in // any extra cookieIds the caller passed. const conditions = []; const params = []; if (buyerType && buyerId) { conditions.push("(buyer_type = ? AND buyer_id = ?)"); params.push(buyerType, buyerId); } for (const cid of cookieIds) { if (typeof cid === "string" && cid) { conditions.push("(buyer_type = 'anon' AND buyer_id = ?)"); params.push(cid); } } if (conditions.length === 0) return; params.push(since); let rows = []; try { rows = db .prepare( `SELECT invoice_id FROM pending_purchases WHERE (${conditions.join(" OR ")}) AND applied_at IS NULL AND created_at >= ? ORDER BY created_at DESC LIMIT 5`, ) .all(...params); } catch (err) { console.warn( `[credits/sweep] query failed: ${err?.message || err}`, ); return; } if (rows.length === 0) return; for (const { invoice_id: invoiceId } of rows) { try { const r = await fetch( `${base.replace(/\/$/, "")}/relay/credits/invoice/${encodeURIComponent(invoiceId)}`, { headers: relayHeaders({ req }), signal: AbortSignal.timeout(5_000), }, ); if (!r.ok) continue; const text = await r.text(); let body = null; try { body = text ? JSON.parse(text) : null; } catch {} const status = body?.result?.status || body?.status || null; if (status === "settled") { await applyPendingPurchase(invoiceId); } } catch (err) { // Best-effort; swallow per-invoice errors so one bad invoice // doesn't block the others (or the page-load). console.warn( `[credits/sweep] invoice ${invoiceId} check failed: ${err?.message || err}`, ); } } }