Files
recap-relay/server/test/money-path.test.js
Keysat 238689ddcc 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.
2026-06-15 18:15:00 -05:00

112 lines
4.8 KiB
JavaScript

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