Add self-serve billing: tiers, credits, BTCPay and Zaprite
This commit is contained in:
@@ -0,0 +1,748 @@
|
||||
// /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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user