749 lines
28 KiB
JavaScript
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();
|
|
}
|
|
}
|