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.
112 lines
4.8 KiB
JavaScript
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);
|
|
});
|
|
});
|