@wopr-network/platform-core 1.14.7 → 1.15.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 (127) hide show
  1. package/.env.example +10 -0
  2. package/dist/account/deletion-executor-repository.d.ts +2 -2
  3. package/dist/account/deletion-executor-repository.js +5 -5
  4. package/dist/{monetization/payram → billing/crypto}/cents-credits-boundary.test.js +14 -17
  5. package/dist/billing/{payram → crypto}/charge-store.d.ts +17 -13
  6. package/dist/billing/{payram → crypto}/charge-store.js +21 -18
  7. package/dist/billing/crypto/charge-store.test.js +64 -0
  8. package/dist/billing/crypto/checkout.d.ts +18 -0
  9. package/dist/billing/crypto/checkout.js +35 -0
  10. package/dist/billing/crypto/checkout.test.js +71 -0
  11. package/dist/billing/crypto/client.d.ts +39 -0
  12. package/dist/billing/crypto/client.js +72 -0
  13. package/dist/billing/crypto/client.test.js +100 -0
  14. package/dist/billing/crypto/index.d.ts +9 -0
  15. package/dist/billing/crypto/index.js +5 -0
  16. package/dist/billing/crypto/types.d.ts +61 -0
  17. package/dist/billing/crypto/types.js +24 -0
  18. package/dist/billing/crypto/webhook.d.ts +34 -0
  19. package/dist/billing/crypto/webhook.js +107 -0
  20. package/dist/billing/crypto/webhook.test.js +266 -0
  21. package/dist/billing/index.d.ts +1 -1
  22. package/dist/billing/index.js +2 -2
  23. package/dist/billing/payment-processor.d.ts +3 -3
  24. package/dist/config/provider-endpoints.d.ts +4 -0
  25. package/dist/config/provider-endpoints.js +10 -6
  26. package/dist/credits/credit-ledger.d.ts +3 -3
  27. package/dist/credits/credit-ledger.js +3 -3
  28. package/dist/db/index.d.ts +1 -1
  29. package/dist/db/index.js +1 -1
  30. package/dist/db/schema/credits.js +1 -1
  31. package/dist/db/schema/{payram.d.ts → crypto.d.ts} +17 -13
  32. package/dist/db/schema/crypto.js +25 -0
  33. package/dist/db/schema/index.d.ts +1 -1
  34. package/dist/db/schema/index.js +1 -1
  35. package/dist/fleet/node-repository.d.ts +1 -3
  36. package/dist/monetization/crypto/__tests__/webhook.test.js +249 -0
  37. package/dist/monetization/crypto/index.d.ts +4 -0
  38. package/dist/monetization/crypto/index.js +2 -0
  39. package/dist/monetization/crypto/webhook.d.ts +24 -0
  40. package/dist/monetization/crypto/webhook.js +88 -0
  41. package/dist/monetization/index.d.ts +3 -3
  42. package/dist/monetization/index.js +1 -1
  43. package/dist/monetization/repository-types.d.ts +1 -1
  44. package/dist/observability/pagerduty.test.js +1 -0
  45. package/dist/security/key-validation.test.js +65 -8
  46. package/drizzle/migrations/0004_crypto_charges.sql +25 -0
  47. package/drizzle/migrations/meta/_journal.json +7 -0
  48. package/package.json +1 -3
  49. package/src/account/deletion-executor-repository.ts +6 -6
  50. package/src/billing/{payram → crypto}/cents-credits-boundary.test.ts +14 -17
  51. package/src/billing/crypto/charge-store.test.ts +81 -0
  52. package/src/billing/{payram → crypto}/charge-store.ts +28 -25
  53. package/src/billing/crypto/checkout.test.ts +93 -0
  54. package/src/billing/crypto/checkout.ts +48 -0
  55. package/src/billing/crypto/client.test.ts +132 -0
  56. package/src/billing/crypto/client.ts +86 -0
  57. package/src/billing/crypto/index.ts +15 -0
  58. package/src/billing/crypto/types.ts +83 -0
  59. package/src/billing/crypto/webhook.test.ts +340 -0
  60. package/src/billing/crypto/webhook.ts +136 -0
  61. package/src/billing/index.ts +2 -2
  62. package/src/billing/payment-processor.ts +3 -3
  63. package/src/config/provider-endpoints.ts +10 -6
  64. package/src/credits/credit-ledger.ts +3 -3
  65. package/src/db/index.ts +1 -2
  66. package/src/db/schema/credits.ts +1 -1
  67. package/src/db/schema/crypto.ts +30 -0
  68. package/src/db/schema/index.ts +1 -1
  69. package/src/fleet/node-repository.ts +8 -3
  70. package/src/monetization/crypto/__tests__/webhook.test.ts +327 -0
  71. package/src/monetization/crypto/index.ts +23 -0
  72. package/src/monetization/crypto/webhook.ts +115 -0
  73. package/src/monetization/index.ts +23 -21
  74. package/src/monetization/repository-types.ts +2 -2
  75. package/src/observability/pagerduty.test.ts +1 -0
  76. package/src/security/key-validation.test.ts +74 -8
  77. package/dist/billing/payram/cents-credits-boundary.test.js +0 -75
  78. package/dist/billing/payram/charge-store.test.js +0 -64
  79. package/dist/billing/payram/checkout.d.ts +0 -15
  80. package/dist/billing/payram/checkout.js +0 -24
  81. package/dist/billing/payram/checkout.test.js +0 -74
  82. package/dist/billing/payram/client.d.ts +0 -7
  83. package/dist/billing/payram/client.js +0 -15
  84. package/dist/billing/payram/client.test.js +0 -52
  85. package/dist/billing/payram/index.d.ts +0 -8
  86. package/dist/billing/payram/index.js +0 -4
  87. package/dist/billing/payram/types.d.ts +0 -40
  88. package/dist/billing/payram/webhook.d.ts +0 -19
  89. package/dist/billing/payram/webhook.js +0 -71
  90. package/dist/billing/payram/webhook.test.d.ts +0 -7
  91. package/dist/billing/payram/webhook.test.js +0 -249
  92. package/dist/db/schema/payram.js +0 -21
  93. package/dist/monetization/payram/charge-store.test.d.ts +0 -1
  94. package/dist/monetization/payram/charge-store.test.js +0 -64
  95. package/dist/monetization/payram/checkout.test.d.ts +0 -1
  96. package/dist/monetization/payram/checkout.test.js +0 -73
  97. package/dist/monetization/payram/client.test.d.ts +0 -1
  98. package/dist/monetization/payram/client.test.js +0 -52
  99. package/dist/monetization/payram/index.d.ts +0 -4
  100. package/dist/monetization/payram/index.js +0 -2
  101. package/dist/monetization/payram/webhook.d.ts +0 -17
  102. package/dist/monetization/payram/webhook.js +0 -71
  103. package/dist/monetization/payram/webhook.test.d.ts +0 -7
  104. package/dist/monetization/payram/webhook.test.js +0 -247
  105. package/src/billing/payram/charge-store.test.ts +0 -84
  106. package/src/billing/payram/checkout.test.ts +0 -99
  107. package/src/billing/payram/checkout.ts +0 -40
  108. package/src/billing/payram/client.test.ts +0 -62
  109. package/src/billing/payram/client.ts +0 -21
  110. package/src/billing/payram/index.ts +0 -14
  111. package/src/billing/payram/types.ts +0 -44
  112. package/src/billing/payram/webhook.test.ts +0 -320
  113. package/src/billing/payram/webhook.ts +0 -94
  114. package/src/db/schema/payram.ts +0 -26
  115. package/src/monetization/payram/cents-credits-boundary.test.ts +0 -84
  116. package/src/monetization/payram/charge-store.test.ts +0 -84
  117. package/src/monetization/payram/checkout.test.ts +0 -98
  118. package/src/monetization/payram/client.test.ts +0 -62
  119. package/src/monetization/payram/index.ts +0 -20
  120. package/src/monetization/payram/webhook.test.ts +0 -327
  121. package/src/monetization/payram/webhook.ts +0 -97
  122. /package/dist/billing/{payram → crypto}/cents-credits-boundary.test.d.ts +0 -0
  123. /package/dist/billing/{payram → crypto}/charge-store.test.d.ts +0 -0
  124. /package/dist/billing/{payram → crypto}/checkout.test.d.ts +0 -0
  125. /package/dist/billing/{payram → crypto}/client.test.d.ts +0 -0
  126. /package/dist/billing/{payram/types.js → crypto/webhook.test.d.ts} +0 -0
  127. /package/dist/monetization/{payram/cents-credits-boundary.test.d.ts → crypto/__tests__/webhook.test.d.ts} +0 -0
@@ -0,0 +1,327 @@
1
+ /**
2
+ * Unit tests for the monetization crypto webhook handler.
3
+ *
4
+ * This handler is the WOPR-specific layer on top of the platform-core
5
+ * billing/crypto webhook. Key differences from billing/crypto/webhook.ts:
6
+ * - Uses BotBilling.checkReactivation() instead of onCreditsPurchased callback
7
+ * - Imports charge store / replay guard types from billing layer (relative)
8
+ */
9
+ import type { PGlite } from "@electric-sql/pglite";
10
+ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
11
+ import { CryptoChargeRepository } from "../../../billing/crypto/charge-store.js";
12
+ import type { CryptoWebhookPayload } from "../../../billing/crypto/types.js";
13
+ import { DrizzleWebhookSeenRepository } from "../../../billing/drizzle-webhook-seen-repository.js";
14
+ import { noOpReplayGuard } from "../../../billing/webhook-seen-repository.js";
15
+ import { DrizzleLedger } from "../../../credits/ledger.js";
16
+ import { createTestDb, truncateAllTables } from "../../../test/db.js";
17
+ import type { BotBilling } from "../../credits/bot-billing.js";
18
+ import type { CryptoWebhookDeps } from "../webhook.js";
19
+ import { handleCryptoWebhook } from "../webhook.js";
20
+
21
+ function makePayload(overrides: Partial<CryptoWebhookPayload> = {}): CryptoWebhookPayload {
22
+ return {
23
+ deliveryId: "del-001",
24
+ webhookId: "whk-001",
25
+ originalDeliveryId: "del-001",
26
+ isRedelivery: false,
27
+ type: "InvoiceSettled",
28
+ timestamp: Date.now(),
29
+ storeId: "store-test",
30
+ invoiceId: "inv-test-001",
31
+ metadata: { orderId: "order-001" },
32
+ ...overrides,
33
+ };
34
+ }
35
+
36
+ let pool: PGlite;
37
+ let db: Awaited<ReturnType<typeof createTestDb>>["db"];
38
+
39
+ beforeAll(async () => {
40
+ ({ db, pool } = await createTestDb());
41
+ });
42
+
43
+ afterAll(async () => {
44
+ await pool.close();
45
+ });
46
+
47
+ describe("handleCryptoWebhook (monetization layer)", () => {
48
+ let chargeStore: CryptoChargeRepository;
49
+ let creditLedger: DrizzleLedger;
50
+ let deps: CryptoWebhookDeps;
51
+
52
+ beforeEach(async () => {
53
+ await truncateAllTables(pool);
54
+ chargeStore = new CryptoChargeRepository(db);
55
+ creditLedger = new DrizzleLedger(db);
56
+ await creditLedger.seedSystemAccounts();
57
+ deps = { chargeStore, creditLedger, replayGuard: noOpReplayGuard };
58
+
59
+ // Default test charge: $25 = 2500 cents
60
+ await chargeStore.create("inv-test-001", "tenant-a", 2500);
61
+ });
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // InvoiceSettled — credits ledger
65
+ // ---------------------------------------------------------------------------
66
+
67
+ describe("InvoiceSettled", () => {
68
+ it("credits the ledger with the USD amount in cents", async () => {
69
+ const result = await handleCryptoWebhook(deps, makePayload({ type: "InvoiceSettled" }));
70
+
71
+ expect(result.handled).toBe(true);
72
+ expect(result.status).toBe("Settled");
73
+ expect(result.tenant).toBe("tenant-a");
74
+ expect(result.creditedCents).toBe(2500);
75
+
76
+ const balance = await creditLedger.balance("tenant-a");
77
+ expect(balance.toCents()).toBe(2500);
78
+ });
79
+
80
+ it("marks the charge as credited after settlement", async () => {
81
+ await handleCryptoWebhook(deps, makePayload({ type: "InvoiceSettled" }));
82
+ expect(await chargeStore.isCredited("inv-test-001")).toBe(true);
83
+ });
84
+
85
+ it("uses crypto: prefix on reference ID in ledger entry", async () => {
86
+ await handleCryptoWebhook(deps, makePayload({ type: "InvoiceSettled" }));
87
+
88
+ const history = await creditLedger.history("tenant-a");
89
+ expect(history).toHaveLength(1);
90
+ expect(history[0].referenceId).toBe("crypto:inv-test-001");
91
+ expect(history[0].entryType).toBe("purchase");
92
+ });
93
+
94
+ it("records fundingSource as crypto", async () => {
95
+ await handleCryptoWebhook(deps, makePayload({ type: "InvoiceSettled" }));
96
+
97
+ const history = await creditLedger.history("tenant-a");
98
+ expect(history[0].metadata?.fundingSource).toBe("crypto");
99
+ });
100
+
101
+ it("is idempotent — second InvoiceSettled does not double-credit", async () => {
102
+ await handleCryptoWebhook(deps, makePayload({ type: "InvoiceSettled" }));
103
+ const result2 = await handleCryptoWebhook(deps, makePayload({ type: "InvoiceSettled" }));
104
+
105
+ expect(result2.handled).toBe(true);
106
+ expect(result2.creditedCents).toBe(0);
107
+
108
+ // Balance is still $25, not $50
109
+ const balance = await creditLedger.balance("tenant-a");
110
+ expect(balance.toCents()).toBe(2500);
111
+ });
112
+ });
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Non-settlement event types — no ledger credit
116
+ // ---------------------------------------------------------------------------
117
+
118
+ describe("InvoiceProcessing", () => {
119
+ it("does NOT credit the ledger", async () => {
120
+ const result = await handleCryptoWebhook(deps, makePayload({ type: "InvoiceProcessing" }));
121
+
122
+ expect(result.handled).toBe(true);
123
+ expect(result.tenant).toBe("tenant-a");
124
+ expect(result.creditedCents).toBeUndefined();
125
+ expect((await creditLedger.balance("tenant-a")).toCents()).toBe(0);
126
+ });
127
+ });
128
+
129
+ describe("InvoiceCreated", () => {
130
+ it("does NOT credit the ledger", async () => {
131
+ const result = await handleCryptoWebhook(deps, makePayload({ type: "InvoiceCreated" }));
132
+
133
+ expect(result.handled).toBe(true);
134
+ expect(result.creditedCents).toBeUndefined();
135
+ expect((await creditLedger.balance("tenant-a")).toCents()).toBe(0);
136
+ });
137
+ });
138
+
139
+ describe("InvoiceExpired", () => {
140
+ it("does NOT credit the ledger", async () => {
141
+ const result = await handleCryptoWebhook(deps, makePayload({ type: "InvoiceExpired" }));
142
+
143
+ expect(result.handled).toBe(true);
144
+ expect(result.creditedCents).toBeUndefined();
145
+ expect((await creditLedger.balance("tenant-a")).toCents()).toBe(0);
146
+ });
147
+ });
148
+
149
+ describe("InvoiceInvalid", () => {
150
+ it("does NOT credit the ledger", async () => {
151
+ const result = await handleCryptoWebhook(deps, makePayload({ type: "InvoiceInvalid" }));
152
+
153
+ expect(result.handled).toBe(true);
154
+ expect(result.creditedCents).toBeUndefined();
155
+ expect((await creditLedger.balance("tenant-a")).toCents()).toBe(0);
156
+ });
157
+ });
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // Unknown invoiceId — returns handled:false
161
+ // ---------------------------------------------------------------------------
162
+
163
+ describe("missing charge", () => {
164
+ it("returns handled:false when invoiceId is not in the charge store", async () => {
165
+ const result = await handleCryptoWebhook(deps, makePayload({ invoiceId: "inv-unknown-999" }));
166
+
167
+ expect(result.handled).toBe(false);
168
+ expect(result.tenant).toBeUndefined();
169
+ });
170
+ });
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // Status mapping for all known event types
174
+ // ---------------------------------------------------------------------------
175
+
176
+ describe("status mapping", () => {
177
+ it.each([
178
+ ["InvoiceCreated", "New"],
179
+ ["InvoiceProcessing", "Processing"],
180
+ ["InvoiceReceivedPayment", "Processing"],
181
+ ["InvoiceSettled", "Settled"],
182
+ ["InvoicePaymentSettled", "Settled"],
183
+ ["InvoiceExpired", "Expired"],
184
+ ["InvoiceInvalid", "Invalid"],
185
+ ] as const)("maps %s event to %s status", async (eventType, expectedStatus) => {
186
+ const result = await handleCryptoWebhook(deps, makePayload({ type: eventType }));
187
+ expect(result.status).toBe(expectedStatus);
188
+ });
189
+
190
+ it("throws on unknown event types", async () => {
191
+ await expect(handleCryptoWebhook(deps, makePayload({ type: "InvoiceSomeUnknownEvent" }))).rejects.toThrow(
192
+ "Unknown BTCPay event type: InvoiceSomeUnknownEvent",
193
+ );
194
+ });
195
+ });
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // Charge store status updates
199
+ // ---------------------------------------------------------------------------
200
+
201
+ describe("charge store updates", () => {
202
+ it("updates charge status on every webhook call", async () => {
203
+ await handleCryptoWebhook(deps, makePayload({ type: "InvoiceProcessing" }));
204
+
205
+ const charge = await chargeStore.getByReferenceId("inv-test-001");
206
+ expect(charge?.status).toBe("Processing");
207
+ });
208
+ });
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // Replay guard / idempotency
212
+ // ---------------------------------------------------------------------------
213
+
214
+ describe("replay guard", () => {
215
+ it("blocks duplicate invoiceId + event type combinations", async () => {
216
+ const replayGuard = new DrizzleWebhookSeenRepository(db);
217
+ const depsWithGuard: CryptoWebhookDeps = { ...deps, replayGuard };
218
+
219
+ const first = await handleCryptoWebhook(depsWithGuard, makePayload({ type: "InvoiceSettled" }));
220
+ expect(first.handled).toBe(true);
221
+ expect(first.creditedCents).toBe(2500);
222
+ expect(first.duplicate).toBeUndefined();
223
+
224
+ const second = await handleCryptoWebhook(depsWithGuard, makePayload({ type: "InvoiceSettled" }));
225
+ expect(second.handled).toBe(true);
226
+ expect(second.duplicate).toBe(true);
227
+ expect(second.creditedCents).toBeUndefined();
228
+
229
+ // Balance is still $25, not $50
230
+ expect((await creditLedger.balance("tenant-a")).toCents()).toBe(2500);
231
+ });
232
+
233
+ it("same invoice with a different event type is not blocked", async () => {
234
+ const replayGuard = new DrizzleWebhookSeenRepository(db);
235
+ const depsWithGuard: CryptoWebhookDeps = { ...deps, replayGuard };
236
+
237
+ await handleCryptoWebhook(depsWithGuard, makePayload({ type: "InvoiceProcessing" }));
238
+ const result = await handleCryptoWebhook(depsWithGuard, makePayload({ type: "InvoiceSettled" }));
239
+
240
+ expect(result.duplicate).toBeUndefined();
241
+ expect(result.creditedCents).toBe(2500);
242
+ });
243
+ });
244
+
245
+ // ---------------------------------------------------------------------------
246
+ // BotBilling reactivation — WOPR-specific behaviour
247
+ // ---------------------------------------------------------------------------
248
+
249
+ describe("BotBilling reactivation", () => {
250
+ it("calls botBilling.checkReactivation on InvoiceSettled and returns reactivatedBots", async () => {
251
+ const mockBotBilling = {
252
+ checkReactivation: vi.fn().mockResolvedValue(["bot-1", "bot-2"]),
253
+ } as unknown as BotBilling;
254
+ const depsWithBots: CryptoWebhookDeps = { ...deps, botBilling: mockBotBilling };
255
+
256
+ const result = await handleCryptoWebhook(depsWithBots, makePayload({ type: "InvoiceSettled" }));
257
+
258
+ expect(mockBotBilling.checkReactivation).toHaveBeenCalledWith("tenant-a", creditLedger);
259
+ expect(result.reactivatedBots).toEqual(["bot-1", "bot-2"]);
260
+ });
261
+
262
+ it("omits reactivatedBots when no bots are reactivated", async () => {
263
+ const mockBotBilling = {
264
+ checkReactivation: vi.fn().mockResolvedValue([]),
265
+ } as unknown as BotBilling;
266
+ const depsWithBots: CryptoWebhookDeps = { ...deps, botBilling: mockBotBilling };
267
+
268
+ const result = await handleCryptoWebhook(depsWithBots, makePayload({ type: "InvoiceSettled" }));
269
+
270
+ expect(result.reactivatedBots).toBeUndefined();
271
+ });
272
+
273
+ it("does NOT call botBilling on non-settled events", async () => {
274
+ const mockBotBilling = {
275
+ checkReactivation: vi.fn().mockResolvedValue(["bot-1"]),
276
+ } as unknown as BotBilling;
277
+ const depsWithBots: CryptoWebhookDeps = { ...deps, botBilling: mockBotBilling };
278
+
279
+ await handleCryptoWebhook(depsWithBots, makePayload({ type: "InvoiceProcessing" }));
280
+
281
+ expect(mockBotBilling.checkReactivation).not.toHaveBeenCalled();
282
+ });
283
+
284
+ it("does NOT call botBilling when charge is already credited (idempotency path)", async () => {
285
+ const mockBotBilling = {
286
+ checkReactivation: vi.fn().mockResolvedValue(["bot-1"]),
287
+ } as unknown as BotBilling;
288
+ const depsWithBots: CryptoWebhookDeps = { ...deps, botBilling: mockBotBilling };
289
+
290
+ // First settlement — should call reactivation
291
+ await handleCryptoWebhook(depsWithBots, makePayload({ type: "InvoiceSettled" }));
292
+ expect(mockBotBilling.checkReactivation).toHaveBeenCalledTimes(1);
293
+
294
+ // Second settlement — charge already credited, should NOT call reactivation again
295
+ await handleCryptoWebhook(depsWithBots, makePayload({ type: "InvoiceSettled" }));
296
+ expect(mockBotBilling.checkReactivation).toHaveBeenCalledTimes(1);
297
+ });
298
+
299
+ it("operates correctly when botBilling is not provided", async () => {
300
+ // No botBilling dependency — should complete without error
301
+ const result = await handleCryptoWebhook(deps, makePayload({ type: "InvoiceSettled" }));
302
+
303
+ expect(result.handled).toBe(true);
304
+ expect(result.creditedCents).toBe(2500);
305
+ expect(result.reactivatedBots).toBeUndefined();
306
+ });
307
+ });
308
+
309
+ // ---------------------------------------------------------------------------
310
+ // Multiple tenants — independent processing
311
+ // ---------------------------------------------------------------------------
312
+
313
+ describe("multiple tenants", () => {
314
+ it("processes invoices for different tenants independently", async () => {
315
+ await chargeStore.create("inv-b-001", "tenant-b", 5000);
316
+ await chargeStore.create("inv-c-001", "tenant-c", 1500);
317
+
318
+ await handleCryptoWebhook(deps, makePayload({ invoiceId: "inv-b-001", type: "InvoiceSettled" }));
319
+ await handleCryptoWebhook(deps, makePayload({ invoiceId: "inv-c-001", type: "InvoiceSettled" }));
320
+
321
+ expect((await creditLedger.balance("tenant-b")).toCents()).toBe(5000);
322
+ expect((await creditLedger.balance("tenant-c")).toCents()).toBe(1500);
323
+ // Original tenant-a was not settled in this test
324
+ expect((await creditLedger.balance("tenant-a")).toCents()).toBe(0);
325
+ });
326
+ });
327
+ });
@@ -0,0 +1,23 @@
1
+ // Re-export everything from the billing/crypto module.
2
+ export type {
3
+ CryptoBillingConfig,
4
+ CryptoChargeRecord,
5
+ CryptoCheckoutOpts,
6
+ CryptoConfig,
7
+ CryptoPaymentState,
8
+ CryptoWebhookPayload,
9
+ CryptoWebhookResult,
10
+ ICryptoChargeRepository,
11
+ } from "@wopr-network/platform-core/billing";
12
+ export {
13
+ BTCPayClient,
14
+ CryptoChargeRepository,
15
+ createCryptoCheckout,
16
+ DrizzleCryptoChargeRepository,
17
+ loadCryptoConfig,
18
+ MIN_PAYMENT_USD,
19
+ mapBtcPayEventToStatus,
20
+ verifyCryptoWebhookSignature,
21
+ } from "@wopr-network/platform-core/billing";
22
+ export type { CryptoWebhookDeps } from "./webhook.js";
23
+ export { handleCryptoWebhook } from "./webhook.js";
@@ -0,0 +1,115 @@
1
+ import type {
2
+ CryptoWebhookPayload,
3
+ CryptoWebhookResult,
4
+ ICryptoChargeRepository,
5
+ IWebhookSeenRepository,
6
+ } from "@wopr-network/platform-core/billing";
7
+ import { mapBtcPayEventToStatus } from "@wopr-network/platform-core/billing";
8
+ import type { ILedger } from "@wopr-network/platform-core/credits";
9
+ import { Credit } from "@wopr-network/platform-core/credits";
10
+ import type { BotBilling } from "../credits/bot-billing.js";
11
+
12
+ export interface CryptoWebhookDeps {
13
+ chargeStore: ICryptoChargeRepository;
14
+ creditLedger: ILedger;
15
+ botBilling?: BotBilling;
16
+ replayGuard: IWebhookSeenRepository;
17
+ }
18
+
19
+ /**
20
+ * Process a BTCPay Server webhook event (WOPR-specific version).
21
+ *
22
+ * Only credits the ledger on InvoiceSettled.
23
+ * Uses botBilling.checkReactivation for WOPR bot suspension recovery.
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: charge.amountUsdCents is in USD cents (integer).
31
+ * Credit.fromCents() converts cents → nanodollars for the ledger.
32
+ */
33
+ export async function handleCryptoWebhook(
34
+ deps: CryptoWebhookDeps,
35
+ payload: CryptoWebhookPayload,
36
+ ): Promise<CryptoWebhookResult> {
37
+ const { chargeStore, creditLedger } = deps;
38
+
39
+ // Replay guard FIRST: deduplicate by invoiceId + event type.
40
+ // Must run before mapBtcPayEventToStatus() — unknown event types throw,
41
+ // and BTCPay retries webhooks on failure. Without this ordering, an unknown
42
+ // event type causes an infinite retry loop.
43
+ const dedupeKey = `${payload.invoiceId}:${payload.type}`;
44
+ if (await deps.replayGuard.isDuplicate(dedupeKey, "crypto")) {
45
+ return { handled: true, status: "New", duplicate: true };
46
+ }
47
+
48
+ // Map BTCPay event type to a CryptoPaymentState (throws on unknown types).
49
+ const status = mapBtcPayEventToStatus(payload.type);
50
+
51
+ // Look up the charge record to find the tenant.
52
+ const charge = await chargeStore.getByReferenceId(payload.invoiceId);
53
+ if (!charge) {
54
+ return { handled: false, status };
55
+ }
56
+
57
+ // Update charge status regardless of event type.
58
+ await chargeStore.updateStatus(payload.invoiceId, status);
59
+
60
+ let result: CryptoWebhookResult;
61
+
62
+ if (payload.type === "InvoiceSettled") {
63
+ // Idempotency: use ledger referenceId check (same pattern as Stripe webhook).
64
+ // This is atomic — the referenceId is checked inside the ledger's serialized
65
+ // transaction, eliminating the TOCTOU race of isCredited() + creditLedger().
66
+ const creditRef = `crypto:${payload.invoiceId}`;
67
+ if (await creditLedger.hasReferenceId(creditRef)) {
68
+ result = {
69
+ handled: true,
70
+ status,
71
+ tenant: charge.tenantId,
72
+ creditedCents: 0,
73
+ };
74
+ } else {
75
+ // Credit the original USD amount requested (not the crypto amount).
76
+ // charge.amountUsdCents is in USD cents (integer).
77
+ // Credit.fromCents() converts to nanodollars for the ledger.
78
+ const creditCents = charge.amountUsdCents;
79
+
80
+ await creditLedger.credit(charge.tenantId, Credit.fromCents(creditCents), "purchase", {
81
+ description: `Crypto credit purchase via BTCPay (invoice: ${payload.invoiceId})`,
82
+ referenceId: creditRef,
83
+ fundingSource: "crypto",
84
+ });
85
+
86
+ // Mark credited (advisory — primary idempotency is the ledger referenceId above).
87
+ await chargeStore.markCredited(payload.invoiceId);
88
+
89
+ // Reactivate suspended bots (same as Stripe webhook).
90
+ let reactivatedBots: string[] | undefined;
91
+ if (deps.botBilling) {
92
+ reactivatedBots = await deps.botBilling.checkReactivation(charge.tenantId, creditLedger);
93
+ if (reactivatedBots.length === 0) reactivatedBots = undefined;
94
+ }
95
+
96
+ result = {
97
+ handled: true,
98
+ status,
99
+ tenant: charge.tenantId,
100
+ creditedCents: creditCents,
101
+ reactivatedBots,
102
+ };
103
+ }
104
+ } else {
105
+ // New, Processing, Expired, Invalid — just track status.
106
+ result = {
107
+ handled: true,
108
+ status,
109
+ tenant: charge.tenantId,
110
+ };
111
+ }
112
+
113
+ await deps.replayGuard.markSeen(dedupeKey, "crypto");
114
+ return result;
115
+ }
@@ -158,6 +158,27 @@ export {
158
158
  SIGNUP_GRANT,
159
159
  SUSPENSION_GRACE_DAYS,
160
160
  } from "./credits/index.js";
161
+ // Crypto payments (BTCPay Server)
162
+ export type {
163
+ CryptoBillingConfig,
164
+ CryptoCheckoutOpts,
165
+ CryptoConfig,
166
+ CryptoPaymentState,
167
+ CryptoWebhookDeps,
168
+ CryptoWebhookPayload,
169
+ CryptoWebhookResult,
170
+ } from "./crypto/index.js";
171
+ export {
172
+ BTCPayClient,
173
+ CryptoChargeRepository,
174
+ createCryptoCheckout,
175
+ DrizzleCryptoChargeRepository,
176
+ handleCryptoWebhook,
177
+ loadCryptoConfig,
178
+ MIN_PAYMENT_USD,
179
+ mapBtcPayEventToStatus,
180
+ verifyCryptoWebhookSignature,
181
+ } from "./crypto/index.js";
161
182
  // Feature gating middleware (WOP-384 — replaced tier gates with balance gates)
162
183
  export {
163
184
  type CreditGateConfig,
@@ -181,25 +202,6 @@ export {
181
202
  MeterAggregator,
182
203
  MeterEmitter,
183
204
  } from "./metering/index.js";
184
- // PayRam crypto payments (WOP-407)
185
- export type {
186
- PayRamBillingConfig,
187
- PayRamCheckoutOpts,
188
- PayRamConfig,
189
- PayRamPaymentState,
190
- PayRamWebhookDeps,
191
- PayRamWebhookPayload,
192
- PayRamWebhookResult,
193
- } from "./payram/index.js";
194
- export {
195
- createPayRamCheckout,
196
- createPayRamClient,
197
- DrizzlePayRamChargeRepository,
198
- handlePayRamWebhook,
199
- loadPayRamConfig,
200
- MIN_PAYMENT_USD,
201
- PayRamChargeRepository,
202
- } from "./payram/index.js";
203
205
  export {
204
206
  checkInstanceQuota,
205
207
  DEFAULT_INSTANCE_LIMITS,
@@ -214,13 +216,13 @@ export {
214
216
  } from "./quotas/resource-limits.js";
215
217
  // Repository interfaces (WOP-899)
216
218
  export type {
219
+ CryptoChargeRecord,
217
220
  IBotBilling,
218
221
  IBudgetChecker,
222
+ ICryptoChargeRepository,
219
223
  IMeterAggregator,
220
224
  IMeterEmitter,
221
- IPayRamChargeRepository,
222
225
  ITenantCustomerRepository,
223
- PayRamChargeRecord,
224
226
  } from "./repository-types.js";
225
227
  // Socket layer — adapter orchestrator (WOP-376)
226
228
  export { AdapterSocket, type SocketConfig, type SocketRequest } from "./socket/socket.js";
@@ -1,9 +1,9 @@
1
1
  // Re-export all monetization repository interfaces for callers that want a single import point.
2
2
 
3
3
  export type {
4
- IPayRamChargeRepository,
4
+ CryptoChargeRecord,
5
+ ICryptoChargeRepository,
5
6
  ITenantCustomerRepository,
6
- PayRamChargeRecord,
7
7
  } from "@wopr-network/platform-core/billing";
8
8
  export type { IAutoTopupSettingsRepository, ILedger } from "@wopr-network/platform-core/credits";
9
9
  export type { IMeterAggregator, IMeterEmitter } from "@wopr-network/platform-core/metering";
@@ -7,6 +7,7 @@ describe("PagerDutyNotifier", () => {
7
7
  });
8
8
 
9
9
  afterEach(() => {
10
+ vi.useRealTimers();
10
11
  vi.restoreAllMocks();
11
12
  });
12
13
 
@@ -1,16 +1,22 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
- import { PROVIDER_API_URLS } from "../config/provider-endpoints.js";
3
2
  import { PROVIDER_ENDPOINTS, validateProviderKey } from "./key-validation.js";
4
3
 
5
4
  describe("key-validation", () => {
6
5
  describe("PROVIDER_API_URLS", () => {
7
- it("exports a URL for every supported provider", () => {
8
- expect(PROVIDER_API_URLS.anthropic).toBe("https://api.anthropic.com/v1/models");
9
- expect(PROVIDER_API_URLS.openai).toBe("https://api.openai.com/v1/models");
10
- expect(PROVIDER_API_URLS.google).toBe("https://generativelanguage.googleapis.com/v1/models");
11
- expect(PROVIDER_API_URLS.discord).toBe("https://discord.com/api/v10/users/@me");
12
- expect(PROVIDER_API_URLS.elevenlabs).toBe("https://api.elevenlabs.io/v1/user");
13
- expect(PROVIDER_API_URLS.deepgram).toBe("https://api.deepgram.com/v1/projects");
6
+ afterEach(() => {
7
+ vi.unstubAllEnvs();
8
+ vi.resetModules();
9
+ });
10
+
11
+ it("exports a URL for every supported provider", async () => {
12
+ vi.resetModules();
13
+ const { PROVIDER_API_URLS: urls } = await import("../config/provider-endpoints.js");
14
+ expect(urls.anthropic).toBe("https://api.anthropic.com/v1/models");
15
+ expect(urls.openai).toBe("https://api.openai.com/v1/models");
16
+ expect(urls.google).toBe("https://generativelanguage.googleapis.com/v1/models");
17
+ expect(urls.discord).toBe("https://discord.com/api/v10/users/@me");
18
+ expect(urls.elevenlabs).toBe("https://api.elevenlabs.io/v1/user");
19
+ expect(urls.deepgram).toBe("https://api.deepgram.com/v1/projects");
14
20
  });
15
21
  });
16
22
 
@@ -108,4 +114,64 @@ describe("key-validation", () => {
108
114
  );
109
115
  });
110
116
  });
117
+
118
+ describe("PROVIDER_API_URLS env overrides", () => {
119
+ afterEach(() => {
120
+ vi.unstubAllEnvs();
121
+ vi.resetModules();
122
+ });
123
+
124
+ it("uses ANTHROPIC_API_URL when set", async () => {
125
+ vi.stubEnv("ANTHROPIC_API_URL", "https://custom-anthropic.example.com/v1/models");
126
+ vi.resetModules();
127
+ const { PROVIDER_API_URLS: urls } = await import("../config/provider-endpoints.js");
128
+ expect(urls.anthropic).toBe("https://custom-anthropic.example.com/v1/models");
129
+ });
130
+
131
+ it("uses OPENAI_API_URL when set", async () => {
132
+ vi.stubEnv("OPENAI_API_URL", "https://custom-openai.example.com/v1/models");
133
+ vi.resetModules();
134
+ const { PROVIDER_API_URLS: urls } = await import("../config/provider-endpoints.js");
135
+ expect(urls.openai).toBe("https://custom-openai.example.com/v1/models");
136
+ });
137
+
138
+ it("uses GOOGLE_API_URL when set", async () => {
139
+ vi.stubEnv("GOOGLE_API_URL", "https://custom-google.example.com/v1/models");
140
+ vi.resetModules();
141
+ const { PROVIDER_API_URLS: urls } = await import("../config/provider-endpoints.js");
142
+ expect(urls.google).toBe("https://custom-google.example.com/v1/models");
143
+ });
144
+
145
+ it("uses DISCORD_API_URL when set", async () => {
146
+ vi.stubEnv("DISCORD_API_URL", "https://custom-discord.example.com/api/v10/users/@me");
147
+ vi.resetModules();
148
+ const { PROVIDER_API_URLS: urls } = await import("../config/provider-endpoints.js");
149
+ expect(urls.discord).toBe("https://custom-discord.example.com/api/v10/users/@me");
150
+ });
151
+
152
+ it("uses ELEVENLABS_API_URL when set", async () => {
153
+ vi.stubEnv("ELEVENLABS_API_URL", "https://custom-elevenlabs.example.com/v1/user");
154
+ vi.resetModules();
155
+ const { PROVIDER_API_URLS: urls } = await import("../config/provider-endpoints.js");
156
+ expect(urls.elevenlabs).toBe("https://custom-elevenlabs.example.com/v1/user");
157
+ });
158
+
159
+ it("uses DEEPGRAM_API_URL when set", async () => {
160
+ vi.stubEnv("DEEPGRAM_API_URL", "https://custom-deepgram.example.com/v1/projects");
161
+ vi.resetModules();
162
+ const { PROVIDER_API_URLS: urls } = await import("../config/provider-endpoints.js");
163
+ expect(urls.deepgram).toBe("https://custom-deepgram.example.com/v1/projects");
164
+ });
165
+
166
+ it("falls back to defaults when env vars are not set", async () => {
167
+ vi.resetModules();
168
+ const { PROVIDER_API_URLS: urls } = await import("../config/provider-endpoints.js");
169
+ expect(urls.anthropic).toBe("https://api.anthropic.com/v1/models");
170
+ expect(urls.openai).toBe("https://api.openai.com/v1/models");
171
+ expect(urls.google).toBe("https://generativelanguage.googleapis.com/v1/models");
172
+ expect(urls.discord).toBe("https://discord.com/api/v10/users/@me");
173
+ expect(urls.elevenlabs).toBe("https://api.elevenlabs.io/v1/user");
174
+ expect(urls.deepgram).toBe("https://api.deepgram.com/v1/projects");
175
+ });
176
+ });
111
177
  });