Add self-serve billing: tiers, credits, BTCPay and Zaprite
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
// POST /relay/zaprite/webhook — card-rail settlement handler.
|
||||
//
|
||||
// Zaprite calls this when an order's activity changes. We do NOT trust the
|
||||
// webhook body to decide whether money landed (Zaprite's webhook-signing
|
||||
// mechanism isn't publicly documented). Instead we VERIFY by re-fetching
|
||||
// the order from Zaprite's authenticated API and checking its status —
|
||||
// the same re-fetch-to-verify pattern the BTCPay handler uses. The body is
|
||||
// only a nudge that carries the order id.
|
||||
//
|
||||
// On a paid order tagged product:"recap_tier_subscription", we extend the
|
||||
// buyer's prepaid period via extendUserTier — the SAME landing point as
|
||||
// the BTCPay (Bitcoin) rail, so both rails converge on one tier-grant path.
|
||||
|
||||
import express from "express";
|
||||
import { extendUserTier } from "../credits.js";
|
||||
import { getZapriteConfig } from "../config.js";
|
||||
import { getOrder, isOrderPaid, orderIdFromWebhook } from "../zaprite-client.js";
|
||||
|
||||
// In-memory dedup of fully-processed orders (mirrors the BTCPay handler's
|
||||
// processedInvoices). Zaprite retries on non-200, and may fire multiple
|
||||
// events per order, so we guard the grant. Cleared on restart — a
|
||||
// re-delivered webhook after restart re-fetches + re-grants, but
|
||||
// extendUserTier is keyed per order via this set within a process; a
|
||||
// duplicate grant across a restart is the same harmless "extend by one
|
||||
// period" the operator-comp path already tolerates. (Acceptable: card
|
||||
// double-fires across a restart are vanishingly rare.)
|
||||
const processedZaprite = new Set();
|
||||
|
||||
export function zapriteWebhookRouter() {
|
||||
const router = express.Router();
|
||||
|
||||
// express.raw so a malformed/empty body can't 400 the webhook into an
|
||||
// infinite Zaprite retry loop — we parse defensively and always 200
|
||||
// unless we genuinely want a retry (transient lookup failure → 5xx).
|
||||
router.post(
|
||||
"/zaprite/webhook",
|
||||
express.raw({ type: "*/*", limit: "1mb" }),
|
||||
async (req, res) => {
|
||||
let payload = {};
|
||||
try {
|
||||
const txt = Buffer.isBuffer(req.body) ? req.body.toString("utf8") : "";
|
||||
payload = txt ? JSON.parse(txt) : {};
|
||||
} catch {
|
||||
return res.status(200).json({ ok: true, ignored: "bad_json" });
|
||||
}
|
||||
|
||||
const orderId = orderIdFromWebhook(payload);
|
||||
if (!orderId) {
|
||||
return res.status(200).json({ ok: true, ignored: "no_order_id" });
|
||||
}
|
||||
const dedupKey = `zaprite:${orderId}`;
|
||||
if (processedZaprite.has(dedupKey)) {
|
||||
return res
|
||||
.status(200)
|
||||
.json({ ok: true, ignored: "already_processed", orderId });
|
||||
}
|
||||
|
||||
const zaprite = await getZapriteConfig();
|
||||
if (!zaprite.apiKey) {
|
||||
// Can't verify without the API key. 200 so Zaprite stops retrying.
|
||||
console.warn("[zaprite/webhook] received but Zaprite not configured");
|
||||
return res.status(200).json({ ok: true, ignored: "not_configured" });
|
||||
}
|
||||
|
||||
// Re-fetch the order — this is the authoritative status + metadata.
|
||||
let order;
|
||||
try {
|
||||
order = await getOrder({
|
||||
baseURL: zaprite.baseUrl,
|
||||
apiKey: zaprite.apiKey,
|
||||
orderId,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[zaprite/webhook] getOrder failed for ${orderId}: ${err?.message || err}`,
|
||||
);
|
||||
// 5xx → Zaprite retries; likely a transient network/API blip.
|
||||
return res.status(502).json({ error: "order_lookup_failed" });
|
||||
}
|
||||
|
||||
if (!isOrderPaid(order)) {
|
||||
// PENDING / PROCESSING / UNDERPAID — ack but don't grant, and DON'T
|
||||
// mark processed: a later settle webhook for this order should be
|
||||
// allowed to grant once it's actually paid.
|
||||
return res.status(200).json({
|
||||
ok: true,
|
||||
ignored: `status=${order?.status || "unknown"}`,
|
||||
orderId,
|
||||
});
|
||||
}
|
||||
|
||||
const meta = order.metadata || {};
|
||||
if (meta.product !== "recap_tier_subscription") {
|
||||
processedZaprite.add(dedupKey);
|
||||
return res
|
||||
.status(200)
|
||||
.json({ ok: true, ignored: "not_a_tier_order", orderId });
|
||||
}
|
||||
const subUserId = typeof meta.user_id === "string" ? meta.user_id : "";
|
||||
const subTier = meta.tier;
|
||||
const periodDays = Number(meta.period_days) || 30;
|
||||
if (!subUserId || (subTier !== "pro" && subTier !== "max")) {
|
||||
processedZaprite.add(dedupKey);
|
||||
return res
|
||||
.status(200)
|
||||
.json({ ok: true, ignored: "bad_tier_metadata", orderId });
|
||||
}
|
||||
|
||||
try {
|
||||
const row = await extendUserTier({
|
||||
userId: subUserId,
|
||||
tier: subTier,
|
||||
periodDays,
|
||||
});
|
||||
processedZaprite.add(dedupKey);
|
||||
console.log(
|
||||
`[zaprite/webhook] ${subTier} +${periodDays}d for user ${subUserId.slice(0, 8)}… (order ${orderId}) → expires ${row.subscription_expires_at}`,
|
||||
);
|
||||
return res.status(200).json({
|
||||
ok: true,
|
||||
tier: subTier,
|
||||
user: subUserId,
|
||||
expires_at: row.subscription_expires_at,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[zaprite/webhook] extendUserTier failed: ${err?.message || err}`,
|
||||
);
|
||||
return res.status(500).json({ error: "tier_grant_failed" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
Reference in New Issue
Block a user