306 lines
11 KiB
JavaScript
306 lines
11 KiB
JavaScript
// Thin wrapper around BTCPay's Greenfield API for the Recap credit-
|
|
// purchase flow. Three things this module owns:
|
|
//
|
|
// 1. createInvoice() — POST /api/v1/stores/{storeId}/invoices to
|
|
// mint an invoice priced in sats. Embeds install_id + credits
|
|
// in invoice metadata so the webhook later knows which install
|
|
// and how many credits to grant.
|
|
//
|
|
// 2. getInvoice() — GET /api/v1/stores/{storeId}/invoices/{id} for
|
|
// polling settlement state from the Recap UI side.
|
|
//
|
|
// 3. validateWebhookSignature() — HMAC-SHA256 check on the
|
|
// BTCPay-Sig header so we only trust webhook posts that came
|
|
// from BTCPay with the operator-configured secret.
|
|
//
|
|
// Operator config (set via StartOS "Set BTCPay Connection" action):
|
|
// relay_btcpay_base_url e.g. https://btcpay.keysat.xyz
|
|
// relay_btcpay_store_id uuid-shaped store id from BTCPay
|
|
// relay_btcpay_api_key Greenfield token (scope: btcpay.store.canCreateInvoice + canViewInvoices)
|
|
// relay_btcpay_webhook_secret shared secret for webhook HMAC
|
|
|
|
import crypto from "crypto";
|
|
|
|
// All sats here are quoted in the BTCPay store's display currency
|
|
// (which the operator set to "SATS"). BTCPay accepts the integer
|
|
// amount + currency code "SATS" and prices the invoice in sats
|
|
// directly — no fiat conversion involved.
|
|
const CURRENCY = "SATS";
|
|
|
|
export class BtcPayError extends Error {
|
|
constructor(status, message, body) {
|
|
super(message);
|
|
this.name = "BtcPayError";
|
|
this.status = status;
|
|
this.body = body;
|
|
}
|
|
}
|
|
|
|
// Create a new invoice for `sats` SATS, tagging it with the install
|
|
// + credit-package metadata so the webhook (later) knows what to
|
|
// grant. Returns the raw Greenfield invoice object — caller picks
|
|
// out `id`, `checkoutLink`, `expirationTime`, etc.
|
|
export async function createInvoice({
|
|
baseURL,
|
|
storeId,
|
|
apiKey,
|
|
sats,
|
|
credits,
|
|
installId,
|
|
// Optional license fingerprint captured at buy time. When the buyer
|
|
// has an active Pro/Max license, the credit-grant should land on
|
|
// their license-keyed credit pool (which is shared across every
|
|
// install that uses the license) rather than the install-keyed pool.
|
|
// Stashed in invoice metadata so the webhook handler can route the
|
|
// credit to the right pool even after a restart.
|
|
licenseFingerprint = null,
|
|
packageLabel = "",
|
|
redirectURL = null,
|
|
redirectAutomatically = false,
|
|
}) {
|
|
assertConfigured({ baseURL, storeId, apiKey });
|
|
const body = {
|
|
amount: String(sats),
|
|
currency: CURRENCY,
|
|
metadata: {
|
|
// Top-level metadata fields are surfaced in webhook events and
|
|
// are what the webhook handler reads to credit the install.
|
|
install_id: installId,
|
|
license_fingerprint: licenseFingerprint || null,
|
|
credits,
|
|
package_label: packageLabel,
|
|
product: "recap_credits",
|
|
},
|
|
checkout: {
|
|
// Buyers should be able to leave the checkout page back to
|
|
// Recap after a successful payment. The Recap UI is also
|
|
// polling so the modal will update even if they don't follow
|
|
// this redirect; this is a courtesy that closes the loop
|
|
// when the buyer left the BTCPay tab in the foreground.
|
|
redirectURL: redirectURL || undefined,
|
|
// Auto-redirect once the invoice settles. Without this the
|
|
// buyer has to click "Return to merchant" themselves; with it
|
|
// the checkout page navigates back to the redirectURL as
|
|
// soon as payment confirms.
|
|
redirectAutomatically: redirectAutomatically === true,
|
|
// Speed up small-amount flows by enabling Lightning. Operator
|
|
// has Lightning configured on this store per their setup
|
|
// message; we just nudge the checkout to prefer it.
|
|
defaultPaymentMethod: "BTC-LightningLike",
|
|
},
|
|
};
|
|
return postInvoice({ baseURL, storeId, apiKey, body });
|
|
}
|
|
|
|
// Create a BTCPay invoice for a self-serve Pro/Max subscription period. The
|
|
// metadata (product:"recap_tier_subscription", user_id, tier, period_days)
|
|
// is what the webhook reads to call extendUserTier on settlement.
|
|
export async function createTierInvoice({
|
|
baseURL,
|
|
storeId,
|
|
apiKey,
|
|
sats,
|
|
userId,
|
|
tier,
|
|
periodDays,
|
|
redirectURL = null,
|
|
redirectAutomatically = false,
|
|
}) {
|
|
assertConfigured({ baseURL, storeId, apiKey });
|
|
const body = {
|
|
amount: String(sats),
|
|
currency: CURRENCY,
|
|
metadata: {
|
|
product: "recap_tier_subscription",
|
|
user_id: userId,
|
|
tier,
|
|
period_days: periodDays,
|
|
},
|
|
checkout: {
|
|
redirectURL: redirectURL || undefined,
|
|
redirectAutomatically: redirectAutomatically === true,
|
|
defaultPaymentMethod: "BTC-LightningLike",
|
|
},
|
|
};
|
|
return postInvoice({ baseURL, storeId, apiKey, body });
|
|
}
|
|
|
|
// Shared invoice POST. Both createInvoice (credits) + createTierInvoice
|
|
// (subscriptions) build their own body and hand it here.
|
|
async function postInvoice({ baseURL, storeId, apiKey, body }) {
|
|
const url = `${trimSlash(baseURL)}/api/v1/stores/${encodeURIComponent(storeId)}/invoices`;
|
|
const res = await fetch(url, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `token ${apiKey}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(body),
|
|
signal: AbortSignal.timeout(10_000),
|
|
});
|
|
const text = await res.text();
|
|
let parsed = null;
|
|
try {
|
|
parsed = text ? JSON.parse(text) : null;
|
|
} catch {}
|
|
if (!res.ok) {
|
|
throw new BtcPayError(
|
|
res.status,
|
|
`BTCPay createInvoice ${res.status}: ${text?.slice(0, 300) || res.statusText}`,
|
|
parsed
|
|
);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
export async function getInvoice({ baseURL, storeId, apiKey, invoiceId }) {
|
|
assertConfigured({ baseURL, storeId, apiKey });
|
|
const url = `${trimSlash(baseURL)}/api/v1/stores/${encodeURIComponent(storeId)}/invoices/${encodeURIComponent(invoiceId)}`;
|
|
const res = await fetch(url, {
|
|
headers: { Authorization: `token ${apiKey}` },
|
|
signal: AbortSignal.timeout(10_000),
|
|
});
|
|
const text = await res.text();
|
|
let parsed = null;
|
|
try {
|
|
parsed = text ? JSON.parse(text) : null;
|
|
} catch {}
|
|
if (!res.ok) {
|
|
throw new BtcPayError(
|
|
res.status,
|
|
`BTCPay getInvoice ${res.status}: ${text?.slice(0, 300) || res.statusText}`,
|
|
parsed
|
|
);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
// Fetch the per-payment-method breakdown for an invoice. This is the
|
|
// endpoint that returns the actual BOLT11 invoice string + Lightning
|
|
// payment link, which we need to render an inline QR on the Recap
|
|
// side (Phase 1 of the inline-payment migration). Caller decides
|
|
// which paymentMethod to extract from the array — for the credits
|
|
// flow we only care about BTC-LightningNetwork.
|
|
//
|
|
// Note: BTCPay generates the Lightning invoice asynchronously on
|
|
// some store configurations, so there's a small window after
|
|
// createInvoice() where the LN destination may not yet be populated.
|
|
// Caller can retry once with a short backoff if needed, or fall
|
|
// back to the checkout_url for the buyer to use BTCPay's hosted page.
|
|
export async function getInvoicePaymentMethods({
|
|
baseURL,
|
|
storeId,
|
|
apiKey,
|
|
invoiceId,
|
|
}) {
|
|
assertConfigured({ baseURL, storeId, apiKey });
|
|
const url = `${trimSlash(baseURL)}/api/v1/stores/${encodeURIComponent(storeId)}/invoices/${encodeURIComponent(invoiceId)}/payment-methods`;
|
|
const res = await fetch(url, {
|
|
headers: { Authorization: `token ${apiKey}` },
|
|
signal: AbortSignal.timeout(10_000),
|
|
});
|
|
const text = await res.text();
|
|
let parsed = null;
|
|
try {
|
|
parsed = text ? JSON.parse(text) : null;
|
|
} catch {}
|
|
if (!res.ok) {
|
|
throw new BtcPayError(
|
|
res.status,
|
|
`BTCPay getInvoicePaymentMethods ${res.status}: ${text?.slice(0, 300) || res.statusText}`,
|
|
parsed
|
|
);
|
|
}
|
|
return Array.isArray(parsed) ? parsed : [];
|
|
}
|
|
|
|
// Helper: pick the Lightning BOLT11 + payment link out of a
|
|
// payment-methods array. Returns { bolt11, paymentLink } or null
|
|
// if no usable LN method exists (e.g., store doesn't have LN
|
|
// configured, or BTCPay hasn't generated it yet). The shape varies
|
|
// across BTCPay versions, particularly between 1.x and 2.x:
|
|
// • BTCPay 1.x uses `paymentMethod` with values like
|
|
// "BTC-LightningNetwork" or "BTC_LightningLike"
|
|
// • BTCPay 2.x uses `paymentMethodId` with values like
|
|
// "BTC-LN" (BOLT11-direct) or "BTC-LNURL" (LNURL-pay)
|
|
// We accept either field name. Among LN-ish entries we prefer
|
|
// BOLT11-direct over LNURL because every Lightning wallet supports
|
|
// BOLT11 but LNURL needs an LNURL-pay capable wallet (less common,
|
|
// and BTCPay's LNURL entry sometimes has a null destination until
|
|
// the buyer initiates a scan, which we can't render to a QR).
|
|
export function pickLightningFromPaymentMethods(methods) {
|
|
if (!Array.isArray(methods)) return null;
|
|
const candidates = methods.filter((m) => {
|
|
const id = String(m?.paymentMethodId || m?.paymentMethod || "");
|
|
return (
|
|
id === "BTC-LN" ||
|
|
id === "BTC-LightningNetwork" ||
|
|
id === "BTC_LightningLike" ||
|
|
/lightning/i.test(id) ||
|
|
/-LN$/i.test(id) ||
|
|
/-LNURL$/i.test(id)
|
|
);
|
|
});
|
|
// Prefer BOLT11-direct over LNURL. Stable sort: LNURL last,
|
|
// everything else preserves original order.
|
|
candidates.sort((a, b) => {
|
|
const aId = String(a?.paymentMethodId || a?.paymentMethod || "");
|
|
const bId = String(b?.paymentMethodId || b?.paymentMethod || "");
|
|
const aLnurl = /lnurl/i.test(aId) ? 1 : 0;
|
|
const bLnurl = /lnurl/i.test(bId) ? 1 : 0;
|
|
return aLnurl - bLnurl;
|
|
});
|
|
for (const m of candidates) {
|
|
const destination = String(m?.destination || "").trim();
|
|
if (!destination) continue; // skip empty (BTC-LNURL before scan)
|
|
if (!/^ln[a-z0-9]+$/i.test(destination)) continue; // must look like a bolt11
|
|
const paymentLink =
|
|
typeof m.paymentLink === "string" && m.paymentLink
|
|
? m.paymentLink
|
|
: `lightning:${destination}`;
|
|
return { bolt11: destination, paymentLink };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Webhook signature validation. BTCPay sends `BTCPay-Sig: sha256=<hex>`
|
|
// where <hex> is HMAC-SHA256(rawBody, webhookSecret). We re-compute
|
|
// and constant-time-compare. Returns true on match, false otherwise.
|
|
//
|
|
// Caller is responsible for passing the RAW request body bytes (not
|
|
// the parsed JSON object) — Express's body-parser doesn't preserve
|
|
// the exact bytes so we register a raw-body capture for the webhook
|
|
// route specifically. See routes/credits.js.
|
|
export function validateWebhookSignature({ rawBody, signatureHeader, secret }) {
|
|
if (!secret) return false;
|
|
if (typeof signatureHeader !== "string") return false;
|
|
const m = signatureHeader.match(/^sha256\s*=\s*([0-9a-f]{64})$/i);
|
|
if (!m) return false;
|
|
const expected = crypto
|
|
.createHmac("sha256", secret)
|
|
.update(rawBody)
|
|
.digest("hex");
|
|
const provided = m[1].toLowerCase();
|
|
if (provided.length !== expected.length) return false;
|
|
try {
|
|
return crypto.timingSafeEqual(
|
|
Buffer.from(provided, "hex"),
|
|
Buffer.from(expected, "hex")
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function assertConfigured({ baseURL, storeId, apiKey }) {
|
|
if (!baseURL || !storeId || !apiKey) {
|
|
throw new Error(
|
|
"BTCPay is not configured — set base URL, store ID, and API key via the StartOS 'Set BTCPay Connection' action"
|
|
);
|
|
}
|
|
}
|
|
|
|
function trimSlash(s) {
|
|
return (s || "").replace(/\/$/, "");
|
|
}
|