@wopr-network/platform-core 1.42.3 → 1.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/.github/workflows/key-server-image.yml +35 -0
  2. package/Dockerfile.key-server +20 -0
  3. package/GATEWAY_BILLING_RESEARCH.md +430 -0
  4. package/biome.json +2 -9
  5. package/dist/billing/crypto/__tests__/key-server.test.js +240 -0
  6. package/dist/billing/crypto/btc/watcher.d.ts +2 -0
  7. package/dist/billing/crypto/btc/watcher.js +1 -1
  8. package/dist/billing/crypto/charge-store.d.ts +7 -1
  9. package/dist/billing/crypto/charge-store.js +7 -1
  10. package/dist/billing/crypto/client.d.ts +68 -30
  11. package/dist/billing/crypto/client.js +63 -46
  12. package/dist/billing/crypto/client.test.js +66 -83
  13. package/dist/billing/crypto/index.d.ts +8 -8
  14. package/dist/billing/crypto/index.js +4 -5
  15. package/dist/billing/crypto/key-server-entry.js +84 -0
  16. package/dist/billing/crypto/key-server-webhook.d.ts +33 -0
  17. package/dist/billing/crypto/key-server-webhook.js +73 -0
  18. package/dist/billing/crypto/key-server.d.ts +20 -0
  19. package/dist/billing/crypto/key-server.js +263 -0
  20. package/dist/billing/crypto/watcher-service.d.ts +33 -0
  21. package/dist/billing/crypto/watcher-service.js +295 -0
  22. package/dist/billing/index.js +1 -1
  23. package/dist/db/schema/crypto.d.ts +464 -2
  24. package/dist/db/schema/crypto.js +60 -6
  25. package/dist/monetization/crypto/__tests__/webhook.test.js +57 -92
  26. package/dist/monetization/crypto/index.d.ts +4 -4
  27. package/dist/monetization/crypto/index.js +2 -2
  28. package/dist/monetization/crypto/webhook.d.ts +13 -14
  29. package/dist/monetization/crypto/webhook.js +12 -83
  30. package/dist/monetization/index.d.ts +2 -2
  31. package/dist/monetization/index.js +1 -1
  32. package/drizzle/migrations/0014_crypto_key_server.sql +60 -0
  33. package/drizzle/migrations/0015_callback_url.sql +32 -0
  34. package/drizzle/migrations/meta/_journal.json +28 -0
  35. package/package.json +2 -1
  36. package/src/billing/crypto/__tests__/key-server.test.ts +262 -0
  37. package/src/billing/crypto/btc/watcher.ts +3 -1
  38. package/src/billing/crypto/charge-store.ts +13 -1
  39. package/src/billing/crypto/client.test.ts +70 -98
  40. package/src/billing/crypto/client.ts +118 -59
  41. package/src/billing/crypto/index.ts +19 -14
  42. package/src/billing/crypto/key-server-entry.ts +96 -0
  43. package/src/billing/crypto/key-server-webhook.ts +119 -0
  44. package/src/billing/crypto/key-server.ts +343 -0
  45. package/src/billing/crypto/watcher-service.ts +381 -0
  46. package/src/billing/index.ts +1 -1
  47. package/src/db/schema/crypto.ts +75 -6
  48. package/src/monetization/crypto/__tests__/webhook.test.ts +61 -104
  49. package/src/monetization/crypto/index.ts +9 -11
  50. package/src/monetization/crypto/webhook.ts +25 -99
  51. package/src/monetization/index.ts +3 -7
  52. package/dist/billing/crypto/checkout.d.ts +0 -18
  53. package/dist/billing/crypto/checkout.js +0 -35
  54. package/dist/billing/crypto/checkout.test.js +0 -71
  55. package/dist/billing/crypto/webhook.d.ts +0 -34
  56. package/dist/billing/crypto/webhook.js +0 -107
  57. package/dist/billing/crypto/webhook.test.js +0 -266
  58. package/src/billing/crypto/checkout.test.ts +0 -93
  59. package/src/billing/crypto/checkout.ts +0 -48
  60. package/src/billing/crypto/webhook.test.ts +0 -340
  61. package/src/billing/crypto/webhook.ts +0 -136
  62. /package/dist/billing/crypto/{checkout.test.d.ts → __tests__/key-server.test.d.ts} +0 -0
  63. /package/dist/billing/crypto/{webhook.test.d.ts → key-server-entry.d.ts} +0 -0
@@ -1,71 +0,0 @@
1
- import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
2
- import { beginTestTransaction, createTestDb, endTestTransaction, rollbackTestTransaction } from "../../test/db.js";
3
- import { CryptoChargeRepository } from "./charge-store.js";
4
- import { createCryptoCheckout, MIN_PAYMENT_USD } from "./checkout.js";
5
- function createMockClient(overrides = {}) {
6
- return {
7
- createInvoice: overrides.createInvoice ??
8
- vi.fn().mockResolvedValue({
9
- id: "inv-mock-001",
10
- checkoutLink: "https://btcpay.example.com/i/inv-mock-001",
11
- }),
12
- };
13
- }
14
- describe("createCryptoCheckout", () => {
15
- let pool;
16
- let db;
17
- let chargeStore;
18
- let client;
19
- beforeAll(async () => {
20
- ({ db, pool } = await createTestDb());
21
- await beginTestTransaction(pool);
22
- });
23
- afterAll(async () => {
24
- await endTestTransaction(pool);
25
- await pool.close();
26
- });
27
- beforeEach(async () => {
28
- await rollbackTestTransaction(pool);
29
- chargeStore = new CryptoChargeRepository(db);
30
- client = createMockClient();
31
- });
32
- it("rejects amounts below $10 minimum", async () => {
33
- await expect(createCryptoCheckout(client, chargeStore, { tenant: "t-1", amountUsd: 5 })).rejects.toThrow(`Minimum payment amount is $${MIN_PAYMENT_USD}`);
34
- });
35
- it("rejects amounts of exactly $0", async () => {
36
- await expect(createCryptoCheckout(client, chargeStore, { tenant: "t-1", amountUsd: 0 })).rejects.toThrow();
37
- });
38
- it("calls client.createInvoice with correct params", async () => {
39
- const createInvoice = vi.fn().mockResolvedValue({
40
- id: "inv-abc",
41
- checkoutLink: "https://btcpay.example.com/i/inv-abc",
42
- });
43
- const mockClient = createMockClient({ createInvoice });
44
- await createCryptoCheckout(mockClient, chargeStore, { tenant: "t-test", amountUsd: 25 });
45
- expect(createInvoice).toHaveBeenCalledOnce();
46
- const args = createInvoice.mock.calls[0][0];
47
- expect(args.amountUsd).toBe(25);
48
- expect(args.buyerEmail).toContain("t-test@");
49
- });
50
- it("stores the charge with correct amountUsdCents (converts from USD)", async () => {
51
- const createInvoice = vi.fn().mockResolvedValue({
52
- id: "inv-store-test",
53
- checkoutLink: "https://btcpay.example.com/i/inv-store-test",
54
- });
55
- const mockClient = createMockClient({ createInvoice });
56
- await createCryptoCheckout(mockClient, chargeStore, { tenant: "t-2", amountUsd: 25 });
57
- const charge = await chargeStore.getByReferenceId("inv-store-test");
58
- expect(charge).not.toBeNull();
59
- expect(charge?.tenantId).toBe("t-2");
60
- expect(charge?.amountUsdCents).toBe(2500); // $25.00 = 2500 cents
61
- expect(charge?.status).toBe("New");
62
- });
63
- it("returns referenceId and url", async () => {
64
- const result = await createCryptoCheckout(client, chargeStore, { tenant: "t-3", amountUsd: 10 });
65
- expect(result.referenceId).toBe("inv-mock-001");
66
- expect(result.url).toBe("https://btcpay.example.com/i/inv-mock-001");
67
- });
68
- it("accepts exactly $10 (minimum boundary)", async () => {
69
- await expect(createCryptoCheckout(client, chargeStore, { tenant: "t-4", amountUsd: 10 })).resolves.not.toBeNull();
70
- });
71
- });
@@ -1,34 +0,0 @@
1
- import type { ILedger } from "../../credits/ledger.js";
2
- import type { IWebhookSeenRepository } from "../webhook-seen-repository.js";
3
- import type { ICryptoChargeRepository } from "./charge-store.js";
4
- import type { CryptoWebhookPayload, CryptoWebhookResult } from "./types.js";
5
- export interface CryptoWebhookDeps {
6
- chargeStore: ICryptoChargeRepository;
7
- creditLedger: ILedger;
8
- replayGuard: IWebhookSeenRepository;
9
- /** Called after credits are purchased — consumer can reactivate suspended resources. Returns reactivated resource IDs. */
10
- onCreditsPurchased?: (tenantId: string, ledger: ILedger) => Promise<string[]>;
11
- }
12
- /**
13
- * Verify BTCPay webhook signature (HMAC-SHA256).
14
- *
15
- * BTCPay sends the signature in the BTCPAY-SIG header as "sha256=<hex>".
16
- */
17
- export declare function verifyCryptoWebhookSignature(rawBody: Buffer | string, sigHeader: string | undefined, secret: string): boolean;
18
- /**
19
- * Process a BTCPay Server webhook event.
20
- *
21
- * Only credits the ledger on InvoiceSettled status.
22
- * Uses the BTCPay invoice ID mapped to the stored charge record
23
- * for tenant resolution and idempotency.
24
- *
25
- * Idempotency strategy (matches Stripe webhook pattern):
26
- * Primary: `creditLedger.hasReferenceId("crypto:<invoiceId>")` — atomic,
27
- * checked inside the ledger's serialized transaction.
28
- * Secondary: `chargeStore.markCredited()` — advisory flag for queries.
29
- *
30
- * CRITICAL: The charge store holds amountUsdCents (USD cents, integer).
31
- * Credit.fromCents() converts cents → nanodollars for the ledger.
32
- * Never pass raw cents to the ledger — always go through Credit.fromCents().
33
- */
34
- export declare function handleCryptoWebhook(deps: CryptoWebhookDeps, payload: CryptoWebhookPayload): Promise<CryptoWebhookResult>;
@@ -1,107 +0,0 @@
1
- import crypto from "node:crypto";
2
- import { Credit } from "../../credits/credit.js";
3
- import { mapBtcPayEventToStatus } from "./types.js";
4
- /**
5
- * Verify BTCPay webhook signature (HMAC-SHA256).
6
- *
7
- * BTCPay sends the signature in the BTCPAY-SIG header as "sha256=<hex>".
8
- */
9
- export function verifyCryptoWebhookSignature(rawBody, sigHeader, secret) {
10
- if (!sigHeader)
11
- return false;
12
- const expectedSig = `sha256=${crypto.createHmac("sha256", secret).update(rawBody).digest("hex")}`;
13
- const expected = Buffer.from(expectedSig, "utf8");
14
- const received = Buffer.from(sigHeader, "utf8");
15
- if (expected.length !== received.length)
16
- return false;
17
- return crypto.timingSafeEqual(expected, received);
18
- }
19
- /**
20
- * Process a BTCPay Server webhook event.
21
- *
22
- * Only credits the ledger on InvoiceSettled status.
23
- * Uses the BTCPay invoice ID mapped to the stored charge record
24
- * for tenant resolution and idempotency.
25
- *
26
- * Idempotency strategy (matches Stripe webhook pattern):
27
- * Primary: `creditLedger.hasReferenceId("crypto:<invoiceId>")` — atomic,
28
- * checked inside the ledger's serialized transaction.
29
- * Secondary: `chargeStore.markCredited()` — advisory flag for queries.
30
- *
31
- * CRITICAL: The charge store holds amountUsdCents (USD cents, integer).
32
- * Credit.fromCents() converts cents → nanodollars for the ledger.
33
- * Never pass raw cents to the ledger — always go through Credit.fromCents().
34
- */
35
- export async function handleCryptoWebhook(deps, payload) {
36
- const { chargeStore, creditLedger } = deps;
37
- // Replay guard FIRST: deduplicate by invoiceId + event type.
38
- // Must run before mapBtcPayEventToStatus() — unknown event types throw,
39
- // and BTCPay retries webhooks on failure. Without this ordering, an unknown
40
- // event type causes an infinite retry loop.
41
- const dedupeKey = `${payload.invoiceId}:${payload.type}`;
42
- if (await deps.replayGuard.isDuplicate(dedupeKey, "crypto")) {
43
- return { handled: true, status: "New", duplicate: true };
44
- }
45
- // Map BTCPay event type to a CryptoPaymentState (throws on unknown types).
46
- const status = mapBtcPayEventToStatus(payload.type);
47
- // Look up the charge record to find the tenant.
48
- const charge = await chargeStore.getByReferenceId(payload.invoiceId);
49
- if (!charge) {
50
- return { handled: false, status };
51
- }
52
- // Update charge status regardless of event type.
53
- await chargeStore.updateStatus(payload.invoiceId, status);
54
- let result;
55
- if (payload.type === "InvoiceSettled") {
56
- // Idempotency: use ledger referenceId check (same pattern as Stripe webhook).
57
- // This is atomic — the referenceId is checked inside the ledger's serialized
58
- // transaction, eliminating the TOCTOU race of isCredited() + creditLedger().
59
- const creditRef = `crypto:${payload.invoiceId}`;
60
- if (await creditLedger.hasReferenceId(creditRef)) {
61
- result = {
62
- handled: true,
63
- status,
64
- tenant: charge.tenantId,
65
- creditedCents: 0,
66
- };
67
- }
68
- else {
69
- // Credit the original USD amount requested (not the crypto amount).
70
- // For overpayments, we still credit the requested amount.
71
- // charge.amountUsdCents is in USD cents (integer).
72
- // Credit.fromCents() converts to nanodollars for the ledger.
73
- const creditCents = charge.amountUsdCents;
74
- await creditLedger.credit(charge.tenantId, Credit.fromCents(creditCents), "purchase", {
75
- description: `Crypto credit purchase via BTCPay (invoice: ${payload.invoiceId})`,
76
- referenceId: creditRef,
77
- fundingSource: "crypto",
78
- });
79
- // Mark credited (advisory — primary idempotency is the ledger referenceId above).
80
- await chargeStore.markCredited(payload.invoiceId);
81
- // Reactivate suspended resources after credit purchase.
82
- let reactivatedBots;
83
- if (deps.onCreditsPurchased) {
84
- reactivatedBots = await deps.onCreditsPurchased(charge.tenantId, creditLedger);
85
- if (reactivatedBots.length === 0)
86
- reactivatedBots = undefined;
87
- }
88
- result = {
89
- handled: true,
90
- status,
91
- tenant: charge.tenantId,
92
- creditedCents: creditCents,
93
- reactivatedBots,
94
- };
95
- }
96
- }
97
- else {
98
- // New, Processing, Expired, Invalid — just track status.
99
- result = {
100
- handled: true,
101
- status,
102
- tenant: charge.tenantId,
103
- };
104
- }
105
- await deps.replayGuard.markSeen(dedupeKey, "crypto");
106
- return result;
107
- }
@@ -1,266 +0,0 @@
1
- import crypto from "node:crypto";
2
- import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
3
- import { DrizzleLedger } from "../../credits/ledger.js";
4
- import { createTestDb, truncateAllTables } from "../../test/db.js";
5
- import { DrizzleWebhookSeenRepository } from "../drizzle-webhook-seen-repository.js";
6
- import { noOpReplayGuard } from "../webhook-seen-repository.js";
7
- import { CryptoChargeRepository } from "./charge-store.js";
8
- import { mapBtcPayEventToStatus } from "./types.js";
9
- import { handleCryptoWebhook, verifyCryptoWebhookSignature } from "./webhook.js";
10
- function makePayload(overrides = {}) {
11
- return {
12
- deliveryId: "del-001",
13
- webhookId: "whk-001",
14
- originalDeliveryId: "del-001",
15
- isRedelivery: false,
16
- type: "InvoiceSettled",
17
- timestamp: Date.now(),
18
- storeId: "store-test",
19
- invoiceId: "inv-test-001",
20
- metadata: { orderId: "order-001" },
21
- ...overrides,
22
- };
23
- }
24
- let pool;
25
- let db;
26
- beforeAll(async () => {
27
- ({ db, pool } = await createTestDb());
28
- });
29
- afterAll(async () => {
30
- await pool.close();
31
- });
32
- describe("handleCryptoWebhook", () => {
33
- let chargeStore;
34
- let creditLedger;
35
- let deps;
36
- beforeEach(async () => {
37
- await truncateAllTables(pool);
38
- chargeStore = new CryptoChargeRepository(db);
39
- creditLedger = new DrizzleLedger(db);
40
- await creditLedger.seedSystemAccounts();
41
- deps = { chargeStore, creditLedger, replayGuard: noOpReplayGuard };
42
- // Create a default test charge
43
- await chargeStore.create("inv-test-001", "tenant-a", 2500);
44
- });
45
- // ---------------------------------------------------------------------------
46
- // InvoiceSettled — should credit ledger
47
- // ---------------------------------------------------------------------------
48
- describe("InvoiceSettled", () => {
49
- it("credits the ledger with the requested USD amount", async () => {
50
- const result = await handleCryptoWebhook(deps, makePayload({ type: "InvoiceSettled" }));
51
- expect(result.handled).toBe(true);
52
- expect(result.status).toBe("Settled");
53
- expect(result.tenant).toBe("tenant-a");
54
- expect(result.creditedCents).toBe(2500);
55
- const balance = await creditLedger.balance("tenant-a");
56
- expect(balance.toCents()).toBe(2500);
57
- });
58
- it("uses crypto: prefix on reference ID in credit transaction", async () => {
59
- await handleCryptoWebhook(deps, makePayload({ type: "InvoiceSettled" }));
60
- const history = await creditLedger.history("tenant-a");
61
- expect(history).toHaveLength(1);
62
- expect(history[0].referenceId).toBe("crypto:inv-test-001");
63
- expect(history[0].entryType).toBe("purchase");
64
- });
65
- it("records fundingSource as crypto", async () => {
66
- await handleCryptoWebhook(deps, makePayload({ type: "InvoiceSettled" }));
67
- const history = await creditLedger.history("tenant-a");
68
- expect(history[0].metadata?.fundingSource).toBe("crypto");
69
- });
70
- it("marks the charge as credited after Settled", async () => {
71
- await handleCryptoWebhook(deps, makePayload({ type: "InvoiceSettled" }));
72
- expect(await chargeStore.isCredited("inv-test-001")).toBe(true);
73
- });
74
- it("is idempotent — duplicate InvoiceSettled does not double-credit", async () => {
75
- await handleCryptoWebhook(deps, makePayload({ type: "InvoiceSettled" }));
76
- const result2 = await handleCryptoWebhook(deps, makePayload({ type: "InvoiceSettled" }));
77
- expect(result2.handled).toBe(true);
78
- expect(result2.creditedCents).toBe(0);
79
- const balance = await creditLedger.balance("tenant-a");
80
- expect(balance.toCents()).toBe(2500); // Only credited once
81
- });
82
- });
83
- // ---------------------------------------------------------------------------
84
- // Statuses that should NOT credit the ledger
85
- // ---------------------------------------------------------------------------
86
- describe("InvoiceProcessing", () => {
87
- it("does NOT credit the ledger", async () => {
88
- const result = await handleCryptoWebhook(deps, makePayload({ type: "InvoiceProcessing" }));
89
- expect(result.handled).toBe(true);
90
- expect(result.tenant).toBe("tenant-a");
91
- expect(result.creditedCents).toBeUndefined();
92
- expect((await creditLedger.balance("tenant-a")).toCents()).toBe(0);
93
- });
94
- });
95
- describe("InvoiceCreated", () => {
96
- it("does NOT credit the ledger", async () => {
97
- const result = await handleCryptoWebhook(deps, makePayload({ type: "InvoiceCreated" }));
98
- expect(result.handled).toBe(true);
99
- expect(result.creditedCents).toBeUndefined();
100
- expect((await creditLedger.balance("tenant-a")).toCents()).toBe(0);
101
- });
102
- });
103
- describe("InvoiceExpired", () => {
104
- it("does NOT credit the ledger", async () => {
105
- const result = await handleCryptoWebhook(deps, makePayload({ type: "InvoiceExpired" }));
106
- expect(result.handled).toBe(true);
107
- expect(result.creditedCents).toBeUndefined();
108
- expect((await creditLedger.balance("tenant-a")).toCents()).toBe(0);
109
- });
110
- });
111
- describe("InvoiceInvalid", () => {
112
- it("does NOT credit the ledger", async () => {
113
- const result = await handleCryptoWebhook(deps, makePayload({ type: "InvoiceInvalid" }));
114
- expect(result.handled).toBe(true);
115
- expect(result.creditedCents).toBeUndefined();
116
- expect((await creditLedger.balance("tenant-a")).toCents()).toBe(0);
117
- });
118
- });
119
- // ---------------------------------------------------------------------------
120
- // Unknown invoice ID
121
- // ---------------------------------------------------------------------------
122
- describe("unknown invoiceId", () => {
123
- it("returns handled:false when charge not found", async () => {
124
- const result = await handleCryptoWebhook(deps, makePayload({ invoiceId: "inv-unknown-999" }));
125
- expect(result.handled).toBe(false);
126
- expect(result.tenant).toBeUndefined();
127
- });
128
- });
129
- // ---------------------------------------------------------------------------
130
- // Charge store updates
131
- // ---------------------------------------------------------------------------
132
- describe("charge store updates", () => {
133
- it("updates charge status on every webhook call", async () => {
134
- await handleCryptoWebhook(deps, makePayload({ type: "InvoiceProcessing" }));
135
- const charge = await chargeStore.getByReferenceId("inv-test-001");
136
- expect(charge?.status).toBe("Processing");
137
- });
138
- });
139
- // ---------------------------------------------------------------------------
140
- // Multiple tenants
141
- // ---------------------------------------------------------------------------
142
- describe("different invoices", () => {
143
- it("processes multiple invoices independently", async () => {
144
- await chargeStore.create("inv-b-001", "tenant-b", 5000);
145
- await chargeStore.create("inv-c-001", "tenant-c", 1500);
146
- await handleCryptoWebhook(deps, makePayload({ invoiceId: "inv-b-001", type: "InvoiceSettled" }));
147
- await handleCryptoWebhook(deps, makePayload({ invoiceId: "inv-c-001", type: "InvoiceSettled" }));
148
- expect((await creditLedger.balance("tenant-b")).toCents()).toBe(5000);
149
- expect((await creditLedger.balance("tenant-c")).toCents()).toBe(1500);
150
- });
151
- });
152
- // ---------------------------------------------------------------------------
153
- // Replay guard
154
- // ---------------------------------------------------------------------------
155
- describe("replay guard", () => {
156
- it("blocks duplicate invoiceId + event type combos", async () => {
157
- const replayGuard = new DrizzleWebhookSeenRepository(db);
158
- const depsWithGuard = { ...deps, replayGuard };
159
- const first = await handleCryptoWebhook(depsWithGuard, makePayload({ type: "InvoiceSettled" }));
160
- expect(first.handled).toBe(true);
161
- expect(first.creditedCents).toBe(2500);
162
- expect(first.duplicate).toBeUndefined();
163
- const second = await handleCryptoWebhook(depsWithGuard, makePayload({ type: "InvoiceSettled" }));
164
- expect(second.handled).toBe(true);
165
- expect(second.duplicate).toBe(true);
166
- expect(second.creditedCents).toBeUndefined();
167
- expect((await creditLedger.balance("tenant-a")).toCents()).toBe(2500);
168
- });
169
- it("same invoice with different event type is not blocked", async () => {
170
- const replayGuard = new DrizzleWebhookSeenRepository(db);
171
- const depsWithGuard = { ...deps, replayGuard };
172
- await handleCryptoWebhook(depsWithGuard, makePayload({ type: "InvoiceProcessing" }));
173
- const result = await handleCryptoWebhook(depsWithGuard, makePayload({ type: "InvoiceSettled" }));
174
- expect(result.duplicate).toBeUndefined();
175
- expect(result.creditedCents).toBe(2500);
176
- });
177
- });
178
- // ---------------------------------------------------------------------------
179
- // Unknown event type
180
- // ---------------------------------------------------------------------------
181
- describe("unknown event type", () => {
182
- it("throws on unrecognized BTCPay event type", async () => {
183
- await expect(handleCryptoWebhook(deps, makePayload({ type: "SomeUnknownEvent" }))).rejects.toThrow("Unknown BTCPay event type: SomeUnknownEvent");
184
- });
185
- });
186
- // ---------------------------------------------------------------------------
187
- // Resource reactivation
188
- // ---------------------------------------------------------------------------
189
- describe("resource reactivation via onCreditsPurchased", () => {
190
- it("calls onCreditsPurchased on Settled and includes reactivatedBots", async () => {
191
- const mockOnCreditsPurchased = vi.fn().mockResolvedValue(["bot-1", "bot-2"]);
192
- const depsWithCallback = {
193
- ...deps,
194
- onCreditsPurchased: mockOnCreditsPurchased,
195
- };
196
- const result = await handleCryptoWebhook(depsWithCallback, makePayload({ type: "InvoiceSettled" }));
197
- expect(mockOnCreditsPurchased).toHaveBeenCalledWith("tenant-a", creditLedger);
198
- expect(result.reactivatedBots).toEqual(["bot-1", "bot-2"]);
199
- });
200
- it("does not include reactivatedBots when no resources reactivated", async () => {
201
- const mockOnCreditsPurchased = vi.fn().mockResolvedValue([]);
202
- const depsWithCallback = {
203
- ...deps,
204
- onCreditsPurchased: mockOnCreditsPurchased,
205
- };
206
- const result = await handleCryptoWebhook(depsWithCallback, makePayload({ type: "InvoiceSettled" }));
207
- expect(result.reactivatedBots).toBeUndefined();
208
- });
209
- });
210
- });
211
- // ---------------------------------------------------------------------------
212
- // Webhook signature verification
213
- // ---------------------------------------------------------------------------
214
- describe("verifyCryptoWebhookSignature", () => {
215
- const secret = "test-webhook-secret";
216
- const body = '{"type":"InvoiceSettled","invoiceId":"inv-001"}';
217
- it("returns true for valid signature", () => {
218
- const sig = `sha256=${crypto.createHmac("sha256", secret).update(body).digest("hex")}`;
219
- expect(verifyCryptoWebhookSignature(body, sig, secret)).toBe(true);
220
- });
221
- it("returns false for invalid signature", () => {
222
- expect(verifyCryptoWebhookSignature(body, "sha256=badhex", secret)).toBe(false);
223
- });
224
- it("returns false for wrong secret", () => {
225
- const sig = `sha256=${crypto.createHmac("sha256", "wrong-secret").update(body).digest("hex")}`;
226
- expect(verifyCryptoWebhookSignature(body, sig, secret)).toBe(false);
227
- });
228
- it("returns false for tampered body", () => {
229
- const sig = `sha256=${crypto.createHmac("sha256", secret).update(body).digest("hex")}`;
230
- expect(verifyCryptoWebhookSignature(`${body}tampered`, sig, secret)).toBe(false);
231
- });
232
- });
233
- // ---------------------------------------------------------------------------
234
- // Replay guard unit tests
235
- // ---------------------------------------------------------------------------
236
- // ---------------------------------------------------------------------------
237
- // mapBtcPayEventToStatus
238
- // ---------------------------------------------------------------------------
239
- describe("mapBtcPayEventToStatus", () => {
240
- it("maps known event types to CryptoPaymentState", () => {
241
- expect(mapBtcPayEventToStatus("InvoiceCreated")).toBe("New");
242
- expect(mapBtcPayEventToStatus("InvoiceReceivedPayment")).toBe("Processing");
243
- expect(mapBtcPayEventToStatus("InvoiceProcessing")).toBe("Processing");
244
- expect(mapBtcPayEventToStatus("InvoiceSettled")).toBe("Settled");
245
- expect(mapBtcPayEventToStatus("InvoicePaymentSettled")).toBe("Settled");
246
- expect(mapBtcPayEventToStatus("InvoiceExpired")).toBe("Expired");
247
- expect(mapBtcPayEventToStatus("InvoiceInvalid")).toBe("Invalid");
248
- });
249
- it("throws on unknown event type", () => {
250
- expect(() => mapBtcPayEventToStatus("SomethingElse")).toThrow("Unknown BTCPay event type: SomethingElse");
251
- });
252
- });
253
- describe("DrizzleWebhookSeenRepository (crypto replay guard)", () => {
254
- beforeEach(async () => {
255
- await truncateAllTables(pool);
256
- });
257
- it("reports unseen keys as not duplicate", async () => {
258
- const guard = new DrizzleWebhookSeenRepository(db);
259
- expect(await guard.isDuplicate("inv-001:InvoiceSettled", "crypto")).toBe(false);
260
- });
261
- it("reports seen keys as duplicate", async () => {
262
- const guard = new DrizzleWebhookSeenRepository(db);
263
- await guard.markSeen("inv-001:InvoiceSettled", "crypto");
264
- expect(await guard.isDuplicate("inv-001:InvoiceSettled", "crypto")).toBe(true);
265
- });
266
- });
@@ -1,93 +0,0 @@
1
- import type { PGlite } from "@electric-sql/pglite";
2
- import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
3
- import type { PlatformDb } from "../../db/index.js";
4
- import { beginTestTransaction, createTestDb, endTestTransaction, rollbackTestTransaction } from "../../test/db.js";
5
- import { CryptoChargeRepository } from "./charge-store.js";
6
- import { createCryptoCheckout, MIN_PAYMENT_USD } from "./checkout.js";
7
- import type { BTCPayClient } from "./client.js";
8
-
9
- function createMockClient(overrides: { createInvoice?: ReturnType<typeof vi.fn> } = {}): BTCPayClient {
10
- return {
11
- createInvoice:
12
- overrides.createInvoice ??
13
- vi.fn().mockResolvedValue({
14
- id: "inv-mock-001",
15
- checkoutLink: "https://btcpay.example.com/i/inv-mock-001",
16
- }),
17
- } as unknown as BTCPayClient;
18
- }
19
-
20
- describe("createCryptoCheckout", () => {
21
- let pool: PGlite;
22
- let db: PlatformDb;
23
- let chargeStore: CryptoChargeRepository;
24
- let client: BTCPayClient;
25
-
26
- beforeAll(async () => {
27
- ({ db, pool } = await createTestDb());
28
- await beginTestTransaction(pool);
29
- });
30
-
31
- afterAll(async () => {
32
- await endTestTransaction(pool);
33
- await pool.close();
34
- });
35
-
36
- beforeEach(async () => {
37
- await rollbackTestTransaction(pool);
38
- chargeStore = new CryptoChargeRepository(db);
39
- client = createMockClient();
40
- });
41
-
42
- it("rejects amounts below $10 minimum", async () => {
43
- await expect(createCryptoCheckout(client, chargeStore, { tenant: "t-1", amountUsd: 5 })).rejects.toThrow(
44
- `Minimum payment amount is $${MIN_PAYMENT_USD}`,
45
- );
46
- });
47
-
48
- it("rejects amounts of exactly $0", async () => {
49
- await expect(createCryptoCheckout(client, chargeStore, { tenant: "t-1", amountUsd: 0 })).rejects.toThrow();
50
- });
51
-
52
- it("calls client.createInvoice with correct params", async () => {
53
- const createInvoice = vi.fn().mockResolvedValue({
54
- id: "inv-abc",
55
- checkoutLink: "https://btcpay.example.com/i/inv-abc",
56
- });
57
- const mockClient = createMockClient({ createInvoice });
58
-
59
- await createCryptoCheckout(mockClient, chargeStore, { tenant: "t-test", amountUsd: 25 });
60
-
61
- expect(createInvoice).toHaveBeenCalledOnce();
62
- const args = createInvoice.mock.calls[0][0];
63
- expect(args.amountUsd).toBe(25);
64
- expect(args.buyerEmail).toContain("t-test@");
65
- });
66
-
67
- it("stores the charge with correct amountUsdCents (converts from USD)", async () => {
68
- const createInvoice = vi.fn().mockResolvedValue({
69
- id: "inv-store-test",
70
- checkoutLink: "https://btcpay.example.com/i/inv-store-test",
71
- });
72
- const mockClient = createMockClient({ createInvoice });
73
-
74
- await createCryptoCheckout(mockClient, chargeStore, { tenant: "t-2", amountUsd: 25 });
75
-
76
- const charge = await chargeStore.getByReferenceId("inv-store-test");
77
- expect(charge).not.toBeNull();
78
- expect(charge?.tenantId).toBe("t-2");
79
- expect(charge?.amountUsdCents).toBe(2500); // $25.00 = 2500 cents
80
- expect(charge?.status).toBe("New");
81
- });
82
-
83
- it("returns referenceId and url", async () => {
84
- const result = await createCryptoCheckout(client, chargeStore, { tenant: "t-3", amountUsd: 10 });
85
-
86
- expect(result.referenceId).toBe("inv-mock-001");
87
- expect(result.url).toBe("https://btcpay.example.com/i/inv-mock-001");
88
- });
89
-
90
- it("accepts exactly $10 (minimum boundary)", async () => {
91
- await expect(createCryptoCheckout(client, chargeStore, { tenant: "t-4", amountUsd: 10 })).resolves.not.toBeNull();
92
- });
93
- });
@@ -1,48 +0,0 @@
1
- import crypto from "node:crypto";
2
- import { Credit } from "../../credits/credit.js";
3
- import type { ICryptoChargeRepository } from "./charge-store.js";
4
- import type { BTCPayClient } from "./client.js";
5
- import type { CryptoCheckoutOpts } from "./types.js";
6
-
7
- /** Minimum payment amount in USD. */
8
- export const MIN_PAYMENT_USD = 10;
9
-
10
- /**
11
- * Create a BTCPay invoice and store the charge record.
12
- *
13
- * Returns the BTCPay-hosted checkout page URL and invoice ID.
14
- * The user is redirected to checkoutLink to complete the crypto payment.
15
- *
16
- * NOTE: amountUsd is converted to cents (integer) for the charge store.
17
- * The charge store holds USD cents, NOT nanodollars.
18
- */
19
- export async function createCryptoCheckout(
20
- client: BTCPayClient,
21
- chargeStore: ICryptoChargeRepository,
22
- opts: CryptoCheckoutOpts,
23
- ): Promise<{ referenceId: string; url: string }> {
24
- if (opts.amountUsd < MIN_PAYMENT_USD) {
25
- throw new Error(`Minimum payment amount is $${MIN_PAYMENT_USD}`);
26
- }
27
-
28
- const orderId = `crypto:${opts.tenant}:${crypto.randomUUID()}`;
29
-
30
- const invoice = await client.createInvoice({
31
- amountUsd: opts.amountUsd,
32
- orderId,
33
- buyerEmail: `${opts.tenant}@${process.env.PLATFORM_DOMAIN ?? "wopr.bot"}`,
34
- });
35
-
36
- // Store the charge record for webhook correlation.
37
- // amountUsdCents = USD * 100 (cents, NOT nanodollars).
38
- // Credit.fromDollars() handles the float → integer boundary safely via Math.round
39
- // on the nanodollar scale, then toCentsRounded() converts back to integer cents.
40
- // This avoids direct floating-point multiplication for the cents conversion.
41
- const amountUsdCents = Credit.fromDollars(opts.amountUsd).toCentsRounded();
42
- await chargeStore.create(invoice.id, opts.tenant, amountUsdCents);
43
-
44
- return {
45
- referenceId: invoice.id,
46
- url: invoice.checkoutLink,
47
- };
48
- }