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