238689ddcc
Replace the in-memory dedup Sets in the BTCPay and Zaprite webhook handlers (and the BTCPay rescan path) with a persistent JSON-backed store (server/webhook-dedup.js). The in-memory sets were cleared on restart, so a duplicate webhook delivery straddling a relay restart could double-credit (BTCPay) or double-extend a subscription (Zaprite). The store atomically writes /data/processed-webhooks.json, namespaces keys per rail (storeId|invoiceId vs zaprite:orderId), and prunes entries older than 180 days (safely beyond any retry window). Also: - BTCPay is a required running dependency (operator decision). Config was already optional:false/kind:'running'; corrected the contradictory "optional" comment in the manifest to match. - Scope cors() to /relay/* only — off /admin/* and the same-origin dashboard, which don't need permissive CORS. - Add money-path unit tests (commitCredit/refundCredit/applyTierPromotion) and webhook-dedup tests (incl. the survives-a-restart guarantee). - Fix two AGENTS.md auth-doc drifts; refresh Current state. Version 0.2.125 -> 0.2.126.
134 lines
5.0 KiB
JavaScript
134 lines
5.0 KiB
JavaScript
// 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";
|
|
import { isWebhookProcessed, markWebhookProcessed } from "../webhook-dedup.js";
|
|
|
|
// Dedup of fully-processed orders (mirrors the BTCPay handler). Zaprite
|
|
// retries on non-200, and may fire multiple events per order, so we
|
|
// guard the grant. Backed by the persistent webhook-dedup store
|
|
// (../webhook-dedup.js) so a re-delivered webhook straddling a relay
|
|
// restart can't double-extend a subscription. Keys are namespaced
|
|
// `zaprite:<orderId>` to share the store with the BTCPay rail.
|
|
|
|
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 (isWebhookProcessed(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") {
|
|
await markWebhookProcessed(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")) {
|
|
await markWebhookProcessed(dedupKey);
|
|
return res
|
|
.status(200)
|
|
.json({ ok: true, ignored: "bad_tier_metadata", orderId });
|
|
}
|
|
|
|
try {
|
|
const row = await extendUserTier({
|
|
userId: subUserId,
|
|
tier: subTier,
|
|
periodDays,
|
|
});
|
|
await markWebhookProcessed(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;
|
|
}
|