// 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; }