Persist payment-webhook dedup; declare BTCPay required; scope CORS
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.
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
// Core billing primitives: commitCredit (debit), refundCredit (inverse),
|
||||
// and applyTierPromotion (the Core→paid upgrade bookkeeping). Default
|
||||
// quotas (no config file → getTierQuotas falls back): Core lifetime=10,
|
||||
// geminiCapLifetime=5; Pro monthly=50, geminiCapMonthly=25.
|
||||
|
||||
import { test, describe, before } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
initCredits,
|
||||
getOrCreateRow,
|
||||
getUserCreditRow,
|
||||
setUserTier,
|
||||
commitCredit,
|
||||
refundCredit,
|
||||
applyTierPromotion,
|
||||
} from "../credits.js";
|
||||
|
||||
before(async () => {
|
||||
await initCredits({ dataDir: mkdtempSync(path.join(tmpdir(), "relay-money-")) });
|
||||
});
|
||||
|
||||
describe("commitCredit", () => {
|
||||
test("Core debits the lifetime bucket; Gemini tracked separately", async () => {
|
||||
const creditKey = "inst:core-commit";
|
||||
await commitCredit({ creditKey, tier: "core", backend: "gemini" });
|
||||
let row = await getOrCreateRow({ creditKey });
|
||||
assert.equal(row.lifetime_consumed, 1);
|
||||
assert.equal(row.lifetime_gemini_consumed, 1);
|
||||
|
||||
// A hardware call bumps lifetime but NOT the Gemini sub-counter.
|
||||
await commitCredit({ creditKey, tier: "core", backend: "hardware" });
|
||||
row = await getOrCreateRow({ creditKey });
|
||||
assert.equal(row.lifetime_consumed, 2);
|
||||
assert.equal(row.lifetime_gemini_consumed, 1);
|
||||
});
|
||||
|
||||
test("paid tier debits the monthly bucket", async () => {
|
||||
await setUserTier({ userId: "p-commit", tier: "pro" });
|
||||
await commitCredit({ creditKey: "user:p-commit", tier: "pro", backend: "gemini" });
|
||||
const row = await getUserCreditRow("p-commit");
|
||||
assert.equal(row.monthly_consumed, 1);
|
||||
assert.equal(row.monthly_gemini_consumed, 1);
|
||||
});
|
||||
|
||||
test("spend order: once the tier bucket is exhausted, debit purchased", async () => {
|
||||
const creditKey = "inst:core-exhausted";
|
||||
const row = await getOrCreateRow({ creditKey });
|
||||
row.lifetime_consumed = 10; // at the Core lifetime cap
|
||||
row.purchased_balance = 3;
|
||||
await commitCredit({ creditKey, tier: "core", backend: "hardware" });
|
||||
const after = await getOrCreateRow({ creditKey });
|
||||
assert.equal(after.lifetime_consumed, 10, "tier counter must not exceed the cap");
|
||||
assert.equal(after.purchased_balance, 2, "overflow comes out of purchased");
|
||||
});
|
||||
});
|
||||
|
||||
describe("refundCredit", () => {
|
||||
test("mirrors a Core commit (tier bucket first, Gemini sub-counter too)", async () => {
|
||||
const creditKey = "inst:core-refund";
|
||||
await commitCredit({ creditKey, tier: "core", backend: "gemini" });
|
||||
await refundCredit({ creditKey, tier: "core", backend: "gemini" });
|
||||
const row = await getOrCreateRow({ creditKey });
|
||||
assert.equal(row.lifetime_consumed, 0);
|
||||
assert.equal(row.lifetime_gemini_consumed, 0);
|
||||
});
|
||||
|
||||
test("refund with an empty tier bucket credits the purchased bucket", async () => {
|
||||
const creditKey = "inst:refund-to-purchased";
|
||||
const row = await getOrCreateRow({ creditKey });
|
||||
row.lifetime_consumed = 0;
|
||||
row.purchased_balance = 0;
|
||||
await refundCredit({ creditKey, tier: "core", backend: "hardware" });
|
||||
const after = await getOrCreateRow({ creditKey });
|
||||
assert.equal(after.lifetime_consumed, 0, "must floor at 0, not go negative");
|
||||
assert.equal(after.purchased_balance, 1, "refund lands in purchased when tier is already 0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyTierPromotion", () => {
|
||||
test("Core→paid transfers leftover Core credits to purchased and resets the cycle", async () => {
|
||||
const row = await getOrCreateRow({ creditKey: "inst:promo" });
|
||||
row.lifetime_consumed = 4; // 6 of 10 Core credits unused
|
||||
row.monthly_consumed = 2; // stale paid-counter noise that must be zeroed
|
||||
const promoted = await applyTierPromotion(row, "pro");
|
||||
assert.equal(promoted, true);
|
||||
assert.equal(row.tier_snapshot, "pro");
|
||||
assert.equal(row.purchased_balance, 6, "leftover Core credits carry forward as durable top-up");
|
||||
assert.equal(row.purchased_total_ever, 6);
|
||||
assert.equal(row.monthly_consumed, 0, "promotion starts a fresh monthly cycle");
|
||||
});
|
||||
|
||||
test("idempotent: a second promotion on an already-paid row is a no-op", async () => {
|
||||
const row = await getOrCreateRow({ creditKey: "inst:promo-again" });
|
||||
row.lifetime_consumed = 4;
|
||||
await applyTierPromotion(row, "pro"); // first: fires
|
||||
const purchasedAfterFirst = row.purchased_balance;
|
||||
const promoted = await applyTierPromotion(row, "max"); // second: must bail
|
||||
assert.equal(promoted, false);
|
||||
assert.equal(row.purchased_balance, purchasedAfterFirst, "no second leftover transfer");
|
||||
assert.equal(row.tier_snapshot, "pro", "bails before flipping tier again");
|
||||
});
|
||||
|
||||
test("promoting to Core is a no-op", async () => {
|
||||
const row = await getOrCreateRow({ creditKey: "inst:promo-core" });
|
||||
assert.equal(await applyTierPromotion(row, "core"), false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
// Persistent webhook dedup — the store that stops a settled-payment
|
||||
// webhook duplicate from double-crediting (BTCPay) or double-extending
|
||||
// (Zaprite) when the duplicate straddles a relay restart.
|
||||
|
||||
import { test, describe } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
initWebhookDedup,
|
||||
isWebhookProcessed,
|
||||
markWebhookProcessed,
|
||||
} from "../webhook-dedup.js";
|
||||
|
||||
// The module is a singleton; each test inits a fresh dir to reset state.
|
||||
const freshDir = () => mkdtempSync(path.join(tmpdir(), "relay-dedup-"));
|
||||
|
||||
describe("webhook dedup (persistent)", () => {
|
||||
test("unknown key is not processed; marking makes it processed", async () => {
|
||||
await initWebhookDedup({ dataDir: freshDir() });
|
||||
assert.equal(isWebhookProcessed("store1|inv1"), false);
|
||||
await markWebhookProcessed("store1|inv1");
|
||||
assert.equal(isWebhookProcessed("store1|inv1"), true);
|
||||
});
|
||||
|
||||
// Headline guarantee for this change.
|
||||
test("processed keys survive a restart (reload from disk)", async () => {
|
||||
const dir = freshDir();
|
||||
await initWebhookDedup({ dataDir: dir });
|
||||
await markWebhookProcessed("store1|inv-restart");
|
||||
// Simulate a relay restart: re-init from the SAME data dir.
|
||||
await initWebhookDedup({ dataDir: dir });
|
||||
assert.equal(
|
||||
isWebhookProcessed("store1|inv-restart"),
|
||||
true,
|
||||
"a key marked before restart must still be deduped after restart"
|
||||
);
|
||||
});
|
||||
|
||||
test("BTCPay and Zaprite keys share the store without colliding", async () => {
|
||||
await initWebhookDedup({ dataDir: freshDir() });
|
||||
await markWebhookProcessed("store1|inv9");
|
||||
await markWebhookProcessed("zaprite:order9");
|
||||
assert.equal(isWebhookProcessed("store1|inv9"), true);
|
||||
assert.equal(isWebhookProcessed("zaprite:order9"), true);
|
||||
assert.equal(isWebhookProcessed("zaprite:inv9"), false);
|
||||
});
|
||||
|
||||
test("marking is idempotent", async () => {
|
||||
await initWebhookDedup({ dataDir: freshDir() });
|
||||
await markWebhookProcessed("k");
|
||||
await markWebhookProcessed("k"); // must not throw or duplicate
|
||||
assert.equal(isWebhookProcessed("k"), true);
|
||||
});
|
||||
|
||||
test("stale entries past the retention window are pruned on load", async () => {
|
||||
const dir = freshDir();
|
||||
const ancient = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const recent = new Date().toISOString();
|
||||
writeFileSync(
|
||||
path.join(dir, "processed-webhooks.json"),
|
||||
JSON.stringify({ keys: { "store1|old": ancient, "store1|new": recent } })
|
||||
);
|
||||
await initWebhookDedup({ dataDir: dir });
|
||||
assert.equal(isWebhookProcessed("store1|old"), false, "1y-old key should be pruned");
|
||||
assert.equal(isWebhookProcessed("store1|new"), true, "recent key should survive");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user