// Thin wrapper around Zaprite's hosted-checkout API for the card rail of // the self-serve subscription purchase. Zaprite is the "Pay by card" // counterpart to BTCPay (the Bitcoin rail) — both end at extendUserTier. // // What this module owns: // 1. createOrder() — POST /v1/orders to mint a hosted checkout priced // in the operator's fiat currency. Embeds {product, user_id, tier, // period_days} in order metadata so the webhook can grant the tier. // 2. getOrder() — GET /v1/orders/{id} to re-fetch an order and // confirm its paid status server-side. This is how we VERIFY a // webhook: rather than trust the webhook body (Zaprite's signing // mechanism isn't publicly documented), we re-fetch the order from // the authenticated API and check its status — the same // re-fetch-to-verify pattern the BTCPay handler uses. // 3. isOrderPaid() — true once an order's status means the buyer's // money has landed. // // Operator config (StartOS "Set Zaprite Connection" action): // relay_zaprite_base_url default https://api.zaprite.com // relay_zaprite_api_key Zaprite API key (Settings > API in Zaprite) // relay_zaprite_currency fiat the card is charged in (default USD) // // API shape (api.zaprite.com): // POST /v1/orders Bearer auth → { id, checkoutUrl, status, metadata } // body: { amount, currency, label?, redirectUrl?, metadata?, externalUniqId? } // amount is an integer in the currency's smallest unit (cents for USD). // GET /v1/orders/{id} Bearer auth → the order object // status ∈ PENDING | PROCESSING | PAID | OVERPAID | UNDERPAID | COMPLETE export class ZapriteError extends Error { constructor(status, message, body) { super(message); this.name = "ZapriteError"; this.status = status; this.body = body; } } // Statuses that mean the buyer has paid in full (or more). PAID = paid // but awaiting manual fulfillment; COMPLETE = paid and fulfilled; // OVERPAID = paid more than due. All three are "grant the tier". // UNDERPAID / PENDING / PROCESSING are NOT sufficient. const PAID_STATUSES = new Set(["PAID", "COMPLETE", "OVERPAID"]); export function isOrderPaid(order) { const status = String(order?.status || "").toUpperCase(); return PAID_STATUSES.has(status); } // Create a Zaprite hosted-checkout order for a prepaid Pro/Max period. // `amount` is in the smallest unit of `currency` (cents for USD). // `metadata` values are coerced to strings (Zaprite requires string // values). Returns the raw order object — caller reads id + checkoutUrl. export async function createOrder({ baseURL, apiKey, amount, currency, label = "", metadata = {}, redirectUrl = null, externalUniqId = null, }) { assertConfigured({ baseURL, apiKey }); if (!Number.isFinite(amount) || amount <= 0) { throw new Error("Zaprite createOrder: amount must be a positive integer"); } // Zaprite metadata values must be strings (<=1000 chars). Coerce. const safeMeta = {}; for (const [k, v] of Object.entries(metadata || {})) { if (v == null) continue; safeMeta[k] = String(v).slice(0, 1000); } const body = { amount: Math.round(amount), currency: String(currency || "USD").toUpperCase(), metadata: safeMeta, }; if (label) body.label = label; if (redirectUrl) body.redirectUrl = redirectUrl; if (externalUniqId) body.externalUniqId = externalUniqId; const url = `${trimSlash(baseURL)}/v1/orders`; const res = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${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 ZapriteError( res.status, `Zaprite createOrder ${res.status}: ${text?.slice(0, 300) || res.statusText}`, parsed, ); } return parsed; } // Re-fetch a single order by its Zaprite id (or externalUniqId). Used by // the webhook to confirm a paid status before granting the tier. export async function getOrder({ baseURL, apiKey, orderId }) { assertConfigured({ baseURL, apiKey }); if (!orderId) throw new Error("Zaprite getOrder: orderId required"); const url = `${trimSlash(baseURL)}/v1/orders/${encodeURIComponent(orderId)}`; const res = await fetch(url, { headers: { Authorization: `Bearer ${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 ZapriteError( res.status, `Zaprite getOrder ${res.status}: ${text?.slice(0, 300) || res.statusText}`, parsed, ); } return parsed; } // Pull the order id out of a webhook payload. Zaprite's webhook body // shape isn't strongly documented, so check the common locations rather // than assume one — we only need the id (the authoritative status comes // from re-fetching the order). export function orderIdFromWebhook(payload) { if (!payload || typeof payload !== "object") return null; return ( payload.orderId || payload.id || payload.order?.id || payload.data?.id || payload.data?.orderId || payload.data?.order?.id || null ); } function assertConfigured({ baseURL, apiKey }) { if (!baseURL || !apiKey) { throw new Error( "Zaprite is not configured — set the API key via the StartOS 'Set Zaprite Connection' action", ); } } function trimSlash(s) { return (s || "").replace(/\/$/, ""); }