Add self-serve billing: tiers, credits, BTCPay and Zaprite

This commit is contained in:
Keysat
2026-06-13 13:36:05 -05:00
parent 84d56c94c9
commit 0aa648706e
17 changed files with 3781 additions and 116 deletions
+305
View File
@@ -0,0 +1,305 @@
// 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(/\/$/, "");
}