Files
recap-relay/server/zaprite-client.js
T

161 lines
5.6 KiB
JavaScript

// 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(/\/$/, "");
}