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