Add self-serve billing: tiers, credits, BTCPay and Zaprite

This commit is contained in:
Keysat
2026-06-13 13:36:05 -05:00
parent 84d56c94c9
commit 0aa648706e
17 changed files with 3781 additions and 116 deletions
+136
View File
@@ -0,0 +1,136 @@
// Node-built-in test runner. Run with `node --test server/test/`.
//
// Targets the credit-key resolver added in the license-keyed-credits
// refactor. The headline guarantee: same license + different
// install_ids resolve to the SAME credit key. Plus a handful of
// adjacent cases worth pinning so the contract doesn't drift.
import { test } from "node:test";
import assert from "node:assert/strict";
import { getCreditKey, licenseFingerprint } from "../credits.js";
test("licenseFingerprint: stable hash from licenseUuid", () => {
const fp1 = licenseFingerprint({
tier: "pro",
licenseUuid: "11111111-2222-3333-4444-555555555555",
});
const fp2 = licenseFingerprint({
tier: "pro",
licenseUuid: "11111111-2222-3333-4444-555555555555",
});
assert.equal(typeof fp1, "string");
assert.equal(fp1.length, 16);
assert.equal(fp1, fp2, "same licenseUuid should yield same fingerprint");
});
test("licenseFingerprint: different UUIDs yield different fingerprints", () => {
const fp1 = licenseFingerprint({
tier: "pro",
licenseUuid: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
});
const fp2 = licenseFingerprint({
tier: "pro",
licenseUuid: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
});
assert.notEqual(fp1, fp2);
});
test("licenseFingerprint: missing licenseUuid returns null", () => {
assert.equal(licenseFingerprint(null), null);
assert.equal(licenseFingerprint({ tier: "pro" }), null);
assert.equal(licenseFingerprint({ tier: "pro", licenseUuid: null }), null);
});
test("getCreditKey: Core falls back to inst:<installId>", () => {
const k = getCreditKey({
installId: "abc-123",
license: { tier: "core" },
});
assert.equal(k, "inst:abc-123");
});
test("getCreditKey: anonymous (no license) uses inst:<installId>", () => {
assert.equal(getCreditKey({ installId: "xyz" }), "inst:xyz");
assert.equal(getCreditKey({ installId: "xyz", license: null }), "inst:xyz");
});
test("getCreditKey: Pro license routes to lic:<fingerprint>", () => {
const k = getCreditKey({
installId: "any-install",
license: {
tier: "pro",
licenseUuid: "11111111-2222-3333-4444-555555555555",
},
});
assert.match(k, /^lic:[0-9a-f]{16}$/);
});
// Headline guarantee for the refactor: one license activated on two
// different installs MUST resolve to the same credit-key, so both
// devices share one Pro monthly pool.
test("getCreditKey: same license + different installs → same key", () => {
const license = {
tier: "pro",
licenseUuid: "11111111-2222-3333-4444-555555555555",
};
const k1 = getCreditKey({ installId: "install-A", license });
const k2 = getCreditKey({ installId: "install-B", license });
assert.equal(k1, k2);
assert.match(k1, /^lic:/);
});
test("getCreditKey: same install + different licenses → different keys", () => {
const installId = "shared-install";
const k1 = getCreditKey({
installId,
license: {
tier: "pro",
licenseUuid: "11111111-2222-3333-4444-555555555555",
},
});
const k2 = getCreditKey({
installId,
license: {
tier: "pro",
licenseUuid: "99999999-9999-9999-9999-999999999999",
},
});
assert.notEqual(k1, k2);
});
test("getCreditKey: Max tier also routes to lic:<fingerprint>", () => {
const k = getCreditKey({
installId: "any-install",
license: {
tier: "max",
licenseUuid: "11111111-2222-3333-4444-555555555555",
},
});
assert.match(k, /^lic:[0-9a-f]{16}$/);
});
// Paid tier with no resolvable fingerprint (license object missing
// licenseUuid) should defensively fall back to install-keyed rather
// than throwing or producing a phantom "lic:" key — keeps the ledger
// behaving correctly when the keysat verifier returns degraded data.
test("getCreditKey: paid tier without licenseUuid falls back to inst:", () => {
const k = getCreditKey({
installId: "ins-77",
license: { tier: "pro" }, // no licenseUuid
});
assert.equal(k, "inst:ins-77");
});
test("getCreditKey: throws when neither installId nor a usable license is present", () => {
assert.throws(
() => getCreditKey({}),
/installId required/i,
"should throw on empty input"
);
assert.throws(
() => getCreditKey({ license: { tier: "core" } }),
/installId required/i,
"should throw on Core license with no installId"
);
});
+79
View File
@@ -0,0 +1,79 @@
// Phase 1 of self-serve subscriptions: prepaid-period expiry enforcement +
// the extend-from-current-expiry logic both payment rails will call.
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 { identityTier, isSubscriptionExpired } from "../identity.js";
import { initCredits, extendUserTier, getUserCreditRow } from "../credits.js";
const DAY = 24 * 60 * 60 * 1000;
describe("subscription expiry enforcement (pure)", () => {
test("isSubscriptionExpired: null / future / past / invalid", () => {
assert.equal(isSubscriptionExpired({}), false);
assert.equal(isSubscriptionExpired({ subscription_expires_at: null }), false);
assert.equal(
isSubscriptionExpired({ subscription_expires_at: new Date(Date.now() + DAY).toISOString() }),
false,
);
assert.equal(
isSubscriptionExpired({ subscription_expires_at: new Date(Date.now() - DAY).toISOString() }),
true,
);
assert.equal(isSubscriptionExpired({ subscription_expires_at: "not-a-date" }), false);
});
test("identityTier: cloud tier honored until expiry, then Core", () => {
const cloud = { kind: "cloud" };
// No expiry = operator comp grant → never expires.
assert.equal(identityTier(cloud, { tier_snapshot: "max" }), "max");
// Future expiry → tier honored.
assert.equal(
identityTier(cloud, { tier_snapshot: "max", subscription_expires_at: new Date(Date.now() + DAY).toISOString() }),
"max",
);
// Past expiry → effectively Core.
assert.equal(
identityTier(cloud, { tier_snapshot: "max", subscription_expires_at: new Date(Date.now() - DAY).toISOString() }),
"core",
);
});
test("identityTier: license path is unaffected by expiry", () => {
assert.equal(identityTier({ kind: "license", license: { tier: "pro" } }, null), "pro");
});
});
describe("extendUserTier (prepaid periods)", () => {
before(async () => {
await initCredits({ dataDir: mkdtempSync(path.join(tmpdir(), "relay-tier-")) });
});
test("first purchase sets the tier + ~periodDays out", async () => {
const row = await extendUserTier({ userId: "u1", tier: "pro", periodDays: 30 });
assert.equal(row.tier_snapshot, "pro");
const days = (new Date(row.subscription_expires_at).getTime() - Date.now()) / DAY;
assert.ok(days > 29.9 && days < 30.1, `~30 days, got ${days}`);
});
test("early renewal EXTENDS from the current expiry (adds time)", async () => {
const first = await extendUserTier({ userId: "u2", tier: "max", periodDays: 30 });
const firstExp = new Date(first.subscription_expires_at).getTime();
const second = await extendUserTier({ userId: "u2", tier: "max", periodDays: 30 });
const addedDays = (new Date(second.subscription_expires_at).getTime() - firstExp) / DAY;
assert.ok(addedDays > 29.9 && addedDays < 30.1, `added ~30 days, got ${addedDays}`);
});
test("renewing AFTER expiry starts fresh from now", async () => {
const row = await getUserCreditRow("u3");
row.tier_snapshot = "pro";
row.subscription_expires_at = new Date(Date.now() - 5 * DAY).toISOString(); // expired 5d ago
const renewed = await extendUserTier({ userId: "u3", tier: "pro", periodDays: 30 });
const days = (new Date(renewed.subscription_expires_at).getTime() - Date.now()) / DAY;
assert.ok(days > 29.9 && days < 30.1, `fresh ~30 days from now, got ${days}`);
});
});
+52
View File
@@ -0,0 +1,52 @@
// Zaprite (card rail) pure-logic tests: the paid-status gate that decides
// whether a webhook should grant a tier, and the order-id extraction that
// tolerates Zaprite's loosely-documented webhook payload shapes.
import { test, describe } from "node:test";
import assert from "node:assert/strict";
import { isOrderPaid, orderIdFromWebhook } from "../zaprite-client.js";
describe("zaprite isOrderPaid", () => {
test("paid statuses grant; everything else does not", () => {
for (const status of ["PAID", "COMPLETE", "OVERPAID"]) {
assert.equal(isOrderPaid({ status }), true, `${status} should be paid`);
}
for (const status of ["PENDING", "PROCESSING", "UNDERPAID", "", "WAT"]) {
assert.equal(isOrderPaid({ status }), false, `${status} should NOT be paid`);
}
});
test("case-insensitive + null-safe", () => {
assert.equal(isOrderPaid({ status: "paid" }), true);
assert.equal(isOrderPaid({ status: "complete" }), true);
assert.equal(isOrderPaid(null), false);
assert.equal(isOrderPaid({}), false);
assert.equal(isOrderPaid(undefined), false);
});
});
describe("zaprite orderIdFromWebhook", () => {
test("extracts id from the common payload shapes", () => {
assert.equal(orderIdFromWebhook({ id: "o1" }), "o1");
assert.equal(orderIdFromWebhook({ orderId: "o2" }), "o2");
assert.equal(orderIdFromWebhook({ order: { id: "o3" } }), "o3");
assert.equal(orderIdFromWebhook({ data: { id: "o4" } }), "o4");
assert.equal(orderIdFromWebhook({ data: { orderId: "o5" } }), "o5");
assert.equal(orderIdFromWebhook({ data: { order: { id: "o6" } } }), "o6");
});
test("prefers explicit orderId over a nested id", () => {
assert.equal(
orderIdFromWebhook({ orderId: "explicit", order: { id: "nested" } }),
"explicit",
);
});
test("returns null when no id is present", () => {
assert.equal(orderIdFromWebhook({}), null);
assert.equal(orderIdFromWebhook(null), null);
assert.equal(orderIdFromWebhook("nope"), null);
assert.equal(orderIdFromWebhook({ foo: "bar" }), null);
});
});