@wopr-network/platform-core 1.43.0 → 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.
- package/dist/billing/crypto/__tests__/key-server.test.js +16 -1
- package/dist/billing/crypto/btc/watcher.d.ts +2 -0
- package/dist/billing/crypto/btc/watcher.js +1 -1
- package/dist/billing/crypto/charge-store.d.ts +7 -1
- package/dist/billing/crypto/charge-store.js +7 -1
- package/dist/billing/crypto/client.d.ts +0 -26
- package/dist/billing/crypto/client.js +0 -13
- package/dist/billing/crypto/client.test.js +1 -11
- package/dist/billing/crypto/index.d.ts +5 -7
- package/dist/billing/crypto/index.js +3 -5
- package/dist/billing/crypto/key-server-entry.js +43 -2
- package/dist/billing/crypto/key-server-webhook.d.ts +33 -0
- package/dist/billing/crypto/key-server-webhook.js +73 -0
- package/dist/billing/crypto/key-server.d.ts +2 -0
- package/dist/billing/crypto/key-server.js +25 -1
- package/dist/billing/crypto/watcher-service.d.ts +33 -0
- package/dist/billing/crypto/watcher-service.js +295 -0
- package/dist/billing/index.js +1 -1
- package/dist/db/schema/crypto.d.ts +217 -2
- package/dist/db/schema/crypto.js +25 -2
- package/dist/monetization/crypto/__tests__/webhook.test.js +57 -92
- package/dist/monetization/crypto/index.d.ts +4 -4
- package/dist/monetization/crypto/index.js +2 -2
- package/dist/monetization/crypto/webhook.d.ts +13 -14
- package/dist/monetization/crypto/webhook.js +12 -83
- package/dist/monetization/index.d.ts +2 -2
- package/dist/monetization/index.js +1 -1
- package/drizzle/migrations/0015_callback_url.sql +32 -0
- package/drizzle/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/src/billing/crypto/__tests__/key-server.test.ts +16 -1
- package/src/billing/crypto/btc/watcher.ts +3 -1
- package/src/billing/crypto/charge-store.ts +13 -1
- package/src/billing/crypto/client.test.ts +1 -13
- package/src/billing/crypto/client.ts +0 -21
- package/src/billing/crypto/index.ts +9 -13
- package/src/billing/crypto/key-server-entry.ts +46 -2
- package/src/billing/crypto/key-server-webhook.ts +119 -0
- package/src/billing/crypto/key-server.ts +29 -1
- package/src/billing/crypto/watcher-service.ts +381 -0
- package/src/billing/index.ts +1 -1
- package/src/db/schema/crypto.ts +30 -2
- package/src/monetization/crypto/__tests__/webhook.test.ts +61 -104
- package/src/monetization/crypto/index.ts +9 -11
- package/src/monetization/crypto/webhook.ts +25 -99
- package/src/monetization/index.ts +3 -7
- package/dist/billing/crypto/checkout.d.ts +0 -18
- package/dist/billing/crypto/checkout.js +0 -35
- package/dist/billing/crypto/checkout.test.d.ts +0 -1
- package/dist/billing/crypto/checkout.test.js +0 -71
- package/dist/billing/crypto/webhook.d.ts +0 -34
- package/dist/billing/crypto/webhook.js +0 -107
- package/dist/billing/crypto/webhook.test.d.ts +0 -1
- package/dist/billing/crypto/webhook.test.js +0 -266
- package/src/billing/crypto/checkout.test.ts +0 -93
- package/src/billing/crypto/checkout.ts +0 -48
- package/src/billing/crypto/webhook.test.ts +0 -340
- package/src/billing/crypto/webhook.ts +0 -136
|
@@ -1,340 +0,0 @@
|
|
|
1
|
-
import crypto from "node:crypto";
|
|
2
|
-
import type { PGlite } from "@electric-sql/pglite";
|
|
3
|
-
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
-
import { DrizzleLedger } from "../../credits/ledger.js";
|
|
5
|
-
import type { PlatformDb } from "../../db/index.js";
|
|
6
|
-
import { createTestDb, truncateAllTables } from "../../test/db.js";
|
|
7
|
-
import { DrizzleWebhookSeenRepository } from "../drizzle-webhook-seen-repository.js";
|
|
8
|
-
import { noOpReplayGuard } from "../webhook-seen-repository.js";
|
|
9
|
-
import { CryptoChargeRepository } from "./charge-store.js";
|
|
10
|
-
import type { CryptoWebhookPayload } from "./types.js";
|
|
11
|
-
import { mapBtcPayEventToStatus } from "./types.js";
|
|
12
|
-
import type { CryptoWebhookDeps } from "./webhook.js";
|
|
13
|
-
import { handleCryptoWebhook, verifyCryptoWebhookSignature } from "./webhook.js";
|
|
14
|
-
|
|
15
|
-
function makePayload(overrides: Partial<CryptoWebhookPayload> = {}): CryptoWebhookPayload {
|
|
16
|
-
return {
|
|
17
|
-
deliveryId: "del-001",
|
|
18
|
-
webhookId: "whk-001",
|
|
19
|
-
originalDeliveryId: "del-001",
|
|
20
|
-
isRedelivery: false,
|
|
21
|
-
type: "InvoiceSettled",
|
|
22
|
-
timestamp: Date.now(),
|
|
23
|
-
storeId: "store-test",
|
|
24
|
-
invoiceId: "inv-test-001",
|
|
25
|
-
metadata: { orderId: "order-001" },
|
|
26
|
-
...overrides,
|
|
27
|
-
};
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
let pool: PGlite;
|
|
31
|
-
let db: PlatformDb;
|
|
32
|
-
|
|
33
|
-
beforeAll(async () => {
|
|
34
|
-
({ db, pool } = await createTestDb());
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
afterAll(async () => {
|
|
38
|
-
await pool.close();
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
describe("handleCryptoWebhook", () => {
|
|
42
|
-
let chargeStore: CryptoChargeRepository;
|
|
43
|
-
let creditLedger: DrizzleLedger;
|
|
44
|
-
let deps: CryptoWebhookDeps;
|
|
45
|
-
|
|
46
|
-
beforeEach(async () => {
|
|
47
|
-
await truncateAllTables(pool);
|
|
48
|
-
chargeStore = new CryptoChargeRepository(db);
|
|
49
|
-
creditLedger = new DrizzleLedger(db);
|
|
50
|
-
await creditLedger.seedSystemAccounts();
|
|
51
|
-
deps = { chargeStore, creditLedger, replayGuard: noOpReplayGuard };
|
|
52
|
-
|
|
53
|
-
// Create a default test charge
|
|
54
|
-
await chargeStore.create("inv-test-001", "tenant-a", 2500);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
// ---------------------------------------------------------------------------
|
|
58
|
-
// InvoiceSettled — should credit ledger
|
|
59
|
-
// ---------------------------------------------------------------------------
|
|
60
|
-
|
|
61
|
-
describe("InvoiceSettled", () => {
|
|
62
|
-
it("credits the ledger with the requested USD amount", async () => {
|
|
63
|
-
const result = await handleCryptoWebhook(deps, makePayload({ type: "InvoiceSettled" }));
|
|
64
|
-
|
|
65
|
-
expect(result.handled).toBe(true);
|
|
66
|
-
expect(result.status).toBe("Settled");
|
|
67
|
-
expect(result.tenant).toBe("tenant-a");
|
|
68
|
-
expect(result.creditedCents).toBe(2500);
|
|
69
|
-
|
|
70
|
-
const balance = await creditLedger.balance("tenant-a");
|
|
71
|
-
expect(balance.toCents()).toBe(2500);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it("uses crypto: prefix on reference ID in credit transaction", async () => {
|
|
75
|
-
await handleCryptoWebhook(deps, makePayload({ type: "InvoiceSettled" }));
|
|
76
|
-
|
|
77
|
-
const history = await creditLedger.history("tenant-a");
|
|
78
|
-
expect(history).toHaveLength(1);
|
|
79
|
-
expect(history[0].referenceId).toBe("crypto:inv-test-001");
|
|
80
|
-
expect(history[0].entryType).toBe("purchase");
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it("records fundingSource as crypto", async () => {
|
|
84
|
-
await handleCryptoWebhook(deps, makePayload({ type: "InvoiceSettled" }));
|
|
85
|
-
|
|
86
|
-
const history = await creditLedger.history("tenant-a");
|
|
87
|
-
expect(history[0].metadata?.fundingSource).toBe("crypto");
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it("marks the charge as credited after Settled", async () => {
|
|
91
|
-
await handleCryptoWebhook(deps, makePayload({ type: "InvoiceSettled" }));
|
|
92
|
-
expect(await chargeStore.isCredited("inv-test-001")).toBe(true);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it("is idempotent — duplicate InvoiceSettled does not double-credit", async () => {
|
|
96
|
-
await handleCryptoWebhook(deps, makePayload({ type: "InvoiceSettled" }));
|
|
97
|
-
const result2 = await handleCryptoWebhook(deps, makePayload({ type: "InvoiceSettled" }));
|
|
98
|
-
|
|
99
|
-
expect(result2.handled).toBe(true);
|
|
100
|
-
expect(result2.creditedCents).toBe(0);
|
|
101
|
-
|
|
102
|
-
const balance = await creditLedger.balance("tenant-a");
|
|
103
|
-
expect(balance.toCents()).toBe(2500); // Only credited once
|
|
104
|
-
});
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
// ---------------------------------------------------------------------------
|
|
108
|
-
// Statuses that should NOT credit the ledger
|
|
109
|
-
// ---------------------------------------------------------------------------
|
|
110
|
-
|
|
111
|
-
describe("InvoiceProcessing", () => {
|
|
112
|
-
it("does NOT credit the ledger", async () => {
|
|
113
|
-
const result = await handleCryptoWebhook(deps, makePayload({ type: "InvoiceProcessing" }));
|
|
114
|
-
|
|
115
|
-
expect(result.handled).toBe(true);
|
|
116
|
-
expect(result.tenant).toBe("tenant-a");
|
|
117
|
-
expect(result.creditedCents).toBeUndefined();
|
|
118
|
-
expect((await creditLedger.balance("tenant-a")).toCents()).toBe(0);
|
|
119
|
-
});
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
describe("InvoiceCreated", () => {
|
|
123
|
-
it("does NOT credit the ledger", async () => {
|
|
124
|
-
const result = await handleCryptoWebhook(deps, makePayload({ type: "InvoiceCreated" }));
|
|
125
|
-
|
|
126
|
-
expect(result.handled).toBe(true);
|
|
127
|
-
expect(result.creditedCents).toBeUndefined();
|
|
128
|
-
expect((await creditLedger.balance("tenant-a")).toCents()).toBe(0);
|
|
129
|
-
});
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
describe("InvoiceExpired", () => {
|
|
133
|
-
it("does NOT credit the ledger", async () => {
|
|
134
|
-
const result = await handleCryptoWebhook(deps, makePayload({ type: "InvoiceExpired" }));
|
|
135
|
-
|
|
136
|
-
expect(result.handled).toBe(true);
|
|
137
|
-
expect(result.creditedCents).toBeUndefined();
|
|
138
|
-
expect((await creditLedger.balance("tenant-a")).toCents()).toBe(0);
|
|
139
|
-
});
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
describe("InvoiceInvalid", () => {
|
|
143
|
-
it("does NOT credit the ledger", async () => {
|
|
144
|
-
const result = await handleCryptoWebhook(deps, makePayload({ type: "InvoiceInvalid" }));
|
|
145
|
-
|
|
146
|
-
expect(result.handled).toBe(true);
|
|
147
|
-
expect(result.creditedCents).toBeUndefined();
|
|
148
|
-
expect((await creditLedger.balance("tenant-a")).toCents()).toBe(0);
|
|
149
|
-
});
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
// ---------------------------------------------------------------------------
|
|
153
|
-
// Unknown invoice ID
|
|
154
|
-
// ---------------------------------------------------------------------------
|
|
155
|
-
|
|
156
|
-
describe("unknown invoiceId", () => {
|
|
157
|
-
it("returns handled:false when charge not found", async () => {
|
|
158
|
-
const result = await handleCryptoWebhook(deps, makePayload({ invoiceId: "inv-unknown-999" }));
|
|
159
|
-
|
|
160
|
-
expect(result.handled).toBe(false);
|
|
161
|
-
expect(result.tenant).toBeUndefined();
|
|
162
|
-
});
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
// ---------------------------------------------------------------------------
|
|
166
|
-
// Charge store updates
|
|
167
|
-
// ---------------------------------------------------------------------------
|
|
168
|
-
|
|
169
|
-
describe("charge store updates", () => {
|
|
170
|
-
it("updates charge status on every webhook call", async () => {
|
|
171
|
-
await handleCryptoWebhook(deps, makePayload({ type: "InvoiceProcessing" }));
|
|
172
|
-
|
|
173
|
-
const charge = await chargeStore.getByReferenceId("inv-test-001");
|
|
174
|
-
expect(charge?.status).toBe("Processing");
|
|
175
|
-
});
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
// ---------------------------------------------------------------------------
|
|
179
|
-
// Multiple tenants
|
|
180
|
-
// ---------------------------------------------------------------------------
|
|
181
|
-
|
|
182
|
-
describe("different invoices", () => {
|
|
183
|
-
it("processes multiple invoices independently", async () => {
|
|
184
|
-
await chargeStore.create("inv-b-001", "tenant-b", 5000);
|
|
185
|
-
await chargeStore.create("inv-c-001", "tenant-c", 1500);
|
|
186
|
-
|
|
187
|
-
await handleCryptoWebhook(deps, makePayload({ invoiceId: "inv-b-001", type: "InvoiceSettled" }));
|
|
188
|
-
await handleCryptoWebhook(deps, makePayload({ invoiceId: "inv-c-001", type: "InvoiceSettled" }));
|
|
189
|
-
|
|
190
|
-
expect((await creditLedger.balance("tenant-b")).toCents()).toBe(5000);
|
|
191
|
-
expect((await creditLedger.balance("tenant-c")).toCents()).toBe(1500);
|
|
192
|
-
});
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
// ---------------------------------------------------------------------------
|
|
196
|
-
// Replay guard
|
|
197
|
-
// ---------------------------------------------------------------------------
|
|
198
|
-
|
|
199
|
-
describe("replay guard", () => {
|
|
200
|
-
it("blocks duplicate invoiceId + event type combos", async () => {
|
|
201
|
-
const replayGuard = new DrizzleWebhookSeenRepository(db);
|
|
202
|
-
const depsWithGuard: CryptoWebhookDeps = { ...deps, replayGuard };
|
|
203
|
-
|
|
204
|
-
const first = await handleCryptoWebhook(depsWithGuard, makePayload({ type: "InvoiceSettled" }));
|
|
205
|
-
expect(first.handled).toBe(true);
|
|
206
|
-
expect(first.creditedCents).toBe(2500);
|
|
207
|
-
expect(first.duplicate).toBeUndefined();
|
|
208
|
-
|
|
209
|
-
const second = await handleCryptoWebhook(depsWithGuard, makePayload({ type: "InvoiceSettled" }));
|
|
210
|
-
expect(second.handled).toBe(true);
|
|
211
|
-
expect(second.duplicate).toBe(true);
|
|
212
|
-
expect(second.creditedCents).toBeUndefined();
|
|
213
|
-
|
|
214
|
-
expect((await creditLedger.balance("tenant-a")).toCents()).toBe(2500);
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
it("same invoice with different event type is not blocked", async () => {
|
|
218
|
-
const replayGuard = new DrizzleWebhookSeenRepository(db);
|
|
219
|
-
const depsWithGuard: CryptoWebhookDeps = { ...deps, replayGuard };
|
|
220
|
-
|
|
221
|
-
await handleCryptoWebhook(depsWithGuard, makePayload({ type: "InvoiceProcessing" }));
|
|
222
|
-
const result = await handleCryptoWebhook(depsWithGuard, makePayload({ type: "InvoiceSettled" }));
|
|
223
|
-
|
|
224
|
-
expect(result.duplicate).toBeUndefined();
|
|
225
|
-
expect(result.creditedCents).toBe(2500);
|
|
226
|
-
});
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
// ---------------------------------------------------------------------------
|
|
230
|
-
// Unknown event type
|
|
231
|
-
// ---------------------------------------------------------------------------
|
|
232
|
-
|
|
233
|
-
describe("unknown event type", () => {
|
|
234
|
-
it("throws on unrecognized BTCPay event type", async () => {
|
|
235
|
-
await expect(handleCryptoWebhook(deps, makePayload({ type: "SomeUnknownEvent" }))).rejects.toThrow(
|
|
236
|
-
"Unknown BTCPay event type: SomeUnknownEvent",
|
|
237
|
-
);
|
|
238
|
-
});
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
// ---------------------------------------------------------------------------
|
|
242
|
-
// Resource reactivation
|
|
243
|
-
// ---------------------------------------------------------------------------
|
|
244
|
-
|
|
245
|
-
describe("resource reactivation via onCreditsPurchased", () => {
|
|
246
|
-
it("calls onCreditsPurchased on Settled and includes reactivatedBots", async () => {
|
|
247
|
-
const mockOnCreditsPurchased = vi.fn().mockResolvedValue(["bot-1", "bot-2"]);
|
|
248
|
-
const depsWithCallback: CryptoWebhookDeps = {
|
|
249
|
-
...deps,
|
|
250
|
-
onCreditsPurchased: mockOnCreditsPurchased,
|
|
251
|
-
};
|
|
252
|
-
|
|
253
|
-
const result = await handleCryptoWebhook(depsWithCallback, makePayload({ type: "InvoiceSettled" }));
|
|
254
|
-
|
|
255
|
-
expect(mockOnCreditsPurchased).toHaveBeenCalledWith("tenant-a", creditLedger);
|
|
256
|
-
expect(result.reactivatedBots).toEqual(["bot-1", "bot-2"]);
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
it("does not include reactivatedBots when no resources reactivated", async () => {
|
|
260
|
-
const mockOnCreditsPurchased = vi.fn().mockResolvedValue([]);
|
|
261
|
-
const depsWithCallback: CryptoWebhookDeps = {
|
|
262
|
-
...deps,
|
|
263
|
-
onCreditsPurchased: mockOnCreditsPurchased,
|
|
264
|
-
};
|
|
265
|
-
|
|
266
|
-
const result = await handleCryptoWebhook(depsWithCallback, makePayload({ type: "InvoiceSettled" }));
|
|
267
|
-
|
|
268
|
-
expect(result.reactivatedBots).toBeUndefined();
|
|
269
|
-
});
|
|
270
|
-
});
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
// ---------------------------------------------------------------------------
|
|
274
|
-
// Webhook signature verification
|
|
275
|
-
// ---------------------------------------------------------------------------
|
|
276
|
-
|
|
277
|
-
describe("verifyCryptoWebhookSignature", () => {
|
|
278
|
-
const secret = "test-webhook-secret";
|
|
279
|
-
const body = '{"type":"InvoiceSettled","invoiceId":"inv-001"}';
|
|
280
|
-
|
|
281
|
-
it("returns true for valid signature", () => {
|
|
282
|
-
const sig = `sha256=${crypto.createHmac("sha256", secret).update(body).digest("hex")}`;
|
|
283
|
-
expect(verifyCryptoWebhookSignature(body, sig, secret)).toBe(true);
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
it("returns false for invalid signature", () => {
|
|
287
|
-
expect(verifyCryptoWebhookSignature(body, "sha256=badhex", secret)).toBe(false);
|
|
288
|
-
});
|
|
289
|
-
|
|
290
|
-
it("returns false for wrong secret", () => {
|
|
291
|
-
const sig = `sha256=${crypto.createHmac("sha256", "wrong-secret").update(body).digest("hex")}`;
|
|
292
|
-
expect(verifyCryptoWebhookSignature(body, sig, secret)).toBe(false);
|
|
293
|
-
});
|
|
294
|
-
|
|
295
|
-
it("returns false for tampered body", () => {
|
|
296
|
-
const sig = `sha256=${crypto.createHmac("sha256", secret).update(body).digest("hex")}`;
|
|
297
|
-
expect(verifyCryptoWebhookSignature(`${body}tampered`, sig, secret)).toBe(false);
|
|
298
|
-
});
|
|
299
|
-
});
|
|
300
|
-
|
|
301
|
-
// ---------------------------------------------------------------------------
|
|
302
|
-
// Replay guard unit tests
|
|
303
|
-
// ---------------------------------------------------------------------------
|
|
304
|
-
|
|
305
|
-
// ---------------------------------------------------------------------------
|
|
306
|
-
// mapBtcPayEventToStatus
|
|
307
|
-
// ---------------------------------------------------------------------------
|
|
308
|
-
|
|
309
|
-
describe("mapBtcPayEventToStatus", () => {
|
|
310
|
-
it("maps known event types to CryptoPaymentState", () => {
|
|
311
|
-
expect(mapBtcPayEventToStatus("InvoiceCreated")).toBe("New");
|
|
312
|
-
expect(mapBtcPayEventToStatus("InvoiceReceivedPayment")).toBe("Processing");
|
|
313
|
-
expect(mapBtcPayEventToStatus("InvoiceProcessing")).toBe("Processing");
|
|
314
|
-
expect(mapBtcPayEventToStatus("InvoiceSettled")).toBe("Settled");
|
|
315
|
-
expect(mapBtcPayEventToStatus("InvoicePaymentSettled")).toBe("Settled");
|
|
316
|
-
expect(mapBtcPayEventToStatus("InvoiceExpired")).toBe("Expired");
|
|
317
|
-
expect(mapBtcPayEventToStatus("InvoiceInvalid")).toBe("Invalid");
|
|
318
|
-
});
|
|
319
|
-
|
|
320
|
-
it("throws on unknown event type", () => {
|
|
321
|
-
expect(() => mapBtcPayEventToStatus("SomethingElse")).toThrow("Unknown BTCPay event type: SomethingElse");
|
|
322
|
-
});
|
|
323
|
-
});
|
|
324
|
-
|
|
325
|
-
describe("DrizzleWebhookSeenRepository (crypto replay guard)", () => {
|
|
326
|
-
beforeEach(async () => {
|
|
327
|
-
await truncateAllTables(pool);
|
|
328
|
-
});
|
|
329
|
-
|
|
330
|
-
it("reports unseen keys as not duplicate", async () => {
|
|
331
|
-
const guard = new DrizzleWebhookSeenRepository(db);
|
|
332
|
-
expect(await guard.isDuplicate("inv-001:InvoiceSettled", "crypto")).toBe(false);
|
|
333
|
-
});
|
|
334
|
-
|
|
335
|
-
it("reports seen keys as duplicate", async () => {
|
|
336
|
-
const guard = new DrizzleWebhookSeenRepository(db);
|
|
337
|
-
await guard.markSeen("inv-001:InvoiceSettled", "crypto");
|
|
338
|
-
expect(await guard.isDuplicate("inv-001:InvoiceSettled", "crypto")).toBe(true);
|
|
339
|
-
});
|
|
340
|
-
});
|
|
@@ -1,136 +0,0 @@
|
|
|
1
|
-
import crypto from "node:crypto";
|
|
2
|
-
import { Credit } from "../../credits/credit.js";
|
|
3
|
-
import type { ILedger } from "../../credits/ledger.js";
|
|
4
|
-
import type { IWebhookSeenRepository } from "../webhook-seen-repository.js";
|
|
5
|
-
import type { ICryptoChargeRepository } from "./charge-store.js";
|
|
6
|
-
import type { CryptoWebhookPayload, CryptoWebhookResult } from "./types.js";
|
|
7
|
-
import { mapBtcPayEventToStatus } from "./types.js";
|
|
8
|
-
|
|
9
|
-
export interface CryptoWebhookDeps {
|
|
10
|
-
chargeStore: ICryptoChargeRepository;
|
|
11
|
-
creditLedger: ILedger;
|
|
12
|
-
replayGuard: IWebhookSeenRepository;
|
|
13
|
-
/** Called after credits are purchased — consumer can reactivate suspended resources. Returns reactivated resource IDs. */
|
|
14
|
-
onCreditsPurchased?: (tenantId: string, ledger: ILedger) => Promise<string[]>;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Verify BTCPay webhook signature (HMAC-SHA256).
|
|
19
|
-
*
|
|
20
|
-
* BTCPay sends the signature in the BTCPAY-SIG header as "sha256=<hex>".
|
|
21
|
-
*/
|
|
22
|
-
export function verifyCryptoWebhookSignature(
|
|
23
|
-
rawBody: Buffer | string,
|
|
24
|
-
sigHeader: string | undefined,
|
|
25
|
-
secret: string,
|
|
26
|
-
): boolean {
|
|
27
|
-
if (!sigHeader) return false;
|
|
28
|
-
const expectedSig = `sha256=${crypto.createHmac("sha256", secret).update(rawBody).digest("hex")}`;
|
|
29
|
-
|
|
30
|
-
const expected = Buffer.from(expectedSig, "utf8");
|
|
31
|
-
const received = Buffer.from(sigHeader, "utf8");
|
|
32
|
-
|
|
33
|
-
if (expected.length !== received.length) return false;
|
|
34
|
-
return crypto.timingSafeEqual(expected, received);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Process a BTCPay Server webhook event.
|
|
39
|
-
*
|
|
40
|
-
* Only credits the ledger on InvoiceSettled status.
|
|
41
|
-
* Uses the BTCPay invoice ID mapped to the stored charge record
|
|
42
|
-
* for tenant resolution and idempotency.
|
|
43
|
-
*
|
|
44
|
-
* Idempotency strategy (matches Stripe webhook pattern):
|
|
45
|
-
* Primary: `creditLedger.hasReferenceId("crypto:<invoiceId>")` — atomic,
|
|
46
|
-
* checked inside the ledger's serialized transaction.
|
|
47
|
-
* Secondary: `chargeStore.markCredited()` — advisory flag for queries.
|
|
48
|
-
*
|
|
49
|
-
* CRITICAL: The charge store holds amountUsdCents (USD cents, integer).
|
|
50
|
-
* Credit.fromCents() converts cents → nanodollars for the ledger.
|
|
51
|
-
* Never pass raw cents to the ledger — always go through Credit.fromCents().
|
|
52
|
-
*/
|
|
53
|
-
export async function handleCryptoWebhook(
|
|
54
|
-
deps: CryptoWebhookDeps,
|
|
55
|
-
payload: CryptoWebhookPayload,
|
|
56
|
-
): Promise<CryptoWebhookResult> {
|
|
57
|
-
const { chargeStore, creditLedger } = deps;
|
|
58
|
-
|
|
59
|
-
// Replay guard FIRST: deduplicate by invoiceId + event type.
|
|
60
|
-
// Must run before mapBtcPayEventToStatus() — unknown event types throw,
|
|
61
|
-
// and BTCPay retries webhooks on failure. Without this ordering, an unknown
|
|
62
|
-
// event type causes an infinite retry loop.
|
|
63
|
-
const dedupeKey = `${payload.invoiceId}:${payload.type}`;
|
|
64
|
-
if (await deps.replayGuard.isDuplicate(dedupeKey, "crypto")) {
|
|
65
|
-
return { handled: true, status: "New", duplicate: true };
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// Map BTCPay event type to a CryptoPaymentState (throws on unknown types).
|
|
69
|
-
const status = mapBtcPayEventToStatus(payload.type);
|
|
70
|
-
|
|
71
|
-
// Look up the charge record to find the tenant.
|
|
72
|
-
const charge = await chargeStore.getByReferenceId(payload.invoiceId);
|
|
73
|
-
if (!charge) {
|
|
74
|
-
return { handled: false, status };
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// Update charge status regardless of event type.
|
|
78
|
-
await chargeStore.updateStatus(payload.invoiceId, status);
|
|
79
|
-
|
|
80
|
-
let result: CryptoWebhookResult;
|
|
81
|
-
|
|
82
|
-
if (payload.type === "InvoiceSettled") {
|
|
83
|
-
// Idempotency: use ledger referenceId check (same pattern as Stripe webhook).
|
|
84
|
-
// This is atomic — the referenceId is checked inside the ledger's serialized
|
|
85
|
-
// transaction, eliminating the TOCTOU race of isCredited() + creditLedger().
|
|
86
|
-
const creditRef = `crypto:${payload.invoiceId}`;
|
|
87
|
-
if (await creditLedger.hasReferenceId(creditRef)) {
|
|
88
|
-
result = {
|
|
89
|
-
handled: true,
|
|
90
|
-
status,
|
|
91
|
-
tenant: charge.tenantId,
|
|
92
|
-
creditedCents: 0,
|
|
93
|
-
};
|
|
94
|
-
} else {
|
|
95
|
-
// Credit the original USD amount requested (not the crypto amount).
|
|
96
|
-
// For overpayments, we still credit the requested amount.
|
|
97
|
-
// charge.amountUsdCents is in USD cents (integer).
|
|
98
|
-
// Credit.fromCents() converts to nanodollars for the ledger.
|
|
99
|
-
const creditCents = charge.amountUsdCents;
|
|
100
|
-
|
|
101
|
-
await creditLedger.credit(charge.tenantId, Credit.fromCents(creditCents), "purchase", {
|
|
102
|
-
description: `Crypto credit purchase via BTCPay (invoice: ${payload.invoiceId})`,
|
|
103
|
-
referenceId: creditRef,
|
|
104
|
-
fundingSource: "crypto",
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
// Mark credited (advisory — primary idempotency is the ledger referenceId above).
|
|
108
|
-
await chargeStore.markCredited(payload.invoiceId);
|
|
109
|
-
|
|
110
|
-
// Reactivate suspended resources after credit purchase.
|
|
111
|
-
let reactivatedBots: string[] | undefined;
|
|
112
|
-
if (deps.onCreditsPurchased) {
|
|
113
|
-
reactivatedBots = await deps.onCreditsPurchased(charge.tenantId, creditLedger);
|
|
114
|
-
if (reactivatedBots.length === 0) reactivatedBots = undefined;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
result = {
|
|
118
|
-
handled: true,
|
|
119
|
-
status,
|
|
120
|
-
tenant: charge.tenantId,
|
|
121
|
-
creditedCents: creditCents,
|
|
122
|
-
reactivatedBots,
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
} else {
|
|
126
|
-
// New, Processing, Expired, Invalid — just track status.
|
|
127
|
-
result = {
|
|
128
|
-
handled: true,
|
|
129
|
-
status,
|
|
130
|
-
tenant: charge.tenantId,
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
await deps.replayGuard.markSeen(dedupeKey, "crypto");
|
|
135
|
-
return result;
|
|
136
|
-
}
|