Files
recap-relay/server/routes/credits.js
T

749 lines
28 KiB
JavaScript

// /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 { envelope, errorEnvelope } from "./envelope.js";
// In-memory 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.
//
// Cleared on relay restart, which means an unlucky webhook duplicate
// straddling a restart would double-credit. Acceptable tradeoff for
// v1 (operator can manually adjust the ledger if it happens), but
// worth swapping for a persistent set once the relay sees real load.
const processedInvoices = new Set();
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 (processedInvoices.has(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")) {
processedInvoices.add(dedupKey);
return res
.status(200)
.json({ ok: true, ignored: "bad_tier_metadata", invoiceId });
}
try {
const row = await extendUserTier({
userId: subUserId,
tier: subTier,
periodDays,
});
processedInvoices.add(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.
processedInvoices.add(dedupKey);
return res
.status(200)
.json({ ok: true, ignored: "no_recap_metadata" });
}
try {
const newBalance = await addPurchasedCredits({
installId,
creditKey,
amount: credits,
});
processedInvoices.add(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 processedInvoices dedup 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 (processedInvoices.has(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,
});
processedInvoices.add(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();
}
}