@wopr-network/platform-core 1.48.0 → 1.49.1

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 (114) hide show
  1. package/dist/billing/crypto/__tests__/key-server.test.js +1 -1
  2. package/dist/billing/crypto/__tests__/unified-checkout.test.d.ts +1 -0
  3. package/dist/billing/crypto/__tests__/unified-checkout.test.js +63 -0
  4. package/dist/billing/crypto/__tests__/watcher-service.test.d.ts +1 -0
  5. package/dist/billing/crypto/__tests__/watcher-service.test.js +174 -0
  6. package/dist/billing/crypto/__tests__/webhook-confirmations.test.d.ts +1 -0
  7. package/dist/billing/crypto/__tests__/webhook-confirmations.test.js +304 -0
  8. package/dist/billing/crypto/btc/__tests__/settler.test.js +1 -0
  9. package/dist/billing/crypto/btc/__tests__/watcher.test.d.ts +1 -0
  10. package/dist/billing/crypto/btc/__tests__/watcher.test.js +170 -0
  11. package/dist/billing/crypto/btc/types.d.ts +3 -1
  12. package/dist/billing/crypto/btc/watcher.d.ts +6 -1
  13. package/dist/billing/crypto/btc/watcher.js +24 -8
  14. package/dist/billing/crypto/charge-store.d.ts +27 -2
  15. package/dist/billing/crypto/charge-store.js +67 -1
  16. package/dist/billing/crypto/charge-store.test.js +180 -1
  17. package/dist/billing/crypto/client.d.ts +2 -0
  18. package/dist/billing/crypto/cursor-store.d.ts +10 -3
  19. package/dist/billing/crypto/cursor-store.js +21 -1
  20. package/dist/billing/crypto/evm/__tests__/eth-checkout.test.js +3 -3
  21. package/dist/billing/crypto/evm/__tests__/eth-settler.test.js +2 -0
  22. package/dist/billing/crypto/evm/__tests__/eth-watcher.test.js +33 -6
  23. package/dist/billing/crypto/evm/__tests__/settler.test.js +2 -0
  24. package/dist/billing/crypto/evm/__tests__/watcher-confirmations.test.d.ts +1 -0
  25. package/dist/billing/crypto/evm/__tests__/watcher-confirmations.test.js +144 -0
  26. package/dist/billing/crypto/evm/__tests__/watcher.test.js +6 -2
  27. package/dist/billing/crypto/evm/eth-checkout.d.ts +2 -2
  28. package/dist/billing/crypto/evm/eth-checkout.js +3 -3
  29. package/dist/billing/crypto/evm/eth-watcher.d.ts +11 -8
  30. package/dist/billing/crypto/evm/eth-watcher.js +29 -15
  31. package/dist/billing/crypto/evm/types.d.ts +5 -1
  32. package/dist/billing/crypto/evm/watcher.d.ts +9 -1
  33. package/dist/billing/crypto/evm/watcher.js +36 -13
  34. package/dist/billing/crypto/index.d.ts +3 -3
  35. package/dist/billing/crypto/index.js +1 -1
  36. package/dist/billing/crypto/key-server-entry.js +7 -2
  37. package/dist/billing/crypto/key-server-webhook.d.ts +17 -4
  38. package/dist/billing/crypto/key-server-webhook.js +76 -15
  39. package/dist/billing/crypto/key-server.js +18 -7
  40. package/dist/billing/crypto/oracle/__tests__/chainlink.test.js +4 -4
  41. package/dist/billing/crypto/oracle/__tests__/coingecko.test.d.ts +1 -0
  42. package/dist/billing/crypto/oracle/__tests__/coingecko.test.js +65 -0
  43. package/dist/billing/crypto/oracle/__tests__/composite.test.d.ts +1 -0
  44. package/dist/billing/crypto/oracle/__tests__/composite.test.js +48 -0
  45. package/dist/billing/crypto/oracle/__tests__/convert.test.js +27 -17
  46. package/dist/billing/crypto/oracle/__tests__/fixed.test.js +5 -5
  47. package/dist/billing/crypto/oracle/chainlink.d.ts +2 -2
  48. package/dist/billing/crypto/oracle/chainlink.js +11 -10
  49. package/dist/billing/crypto/oracle/coingecko.d.ts +22 -0
  50. package/dist/billing/crypto/oracle/coingecko.js +67 -0
  51. package/dist/billing/crypto/oracle/composite.d.ts +14 -0
  52. package/dist/billing/crypto/oracle/composite.js +34 -0
  53. package/dist/billing/crypto/oracle/convert.d.ts +17 -7
  54. package/dist/billing/crypto/oracle/convert.js +26 -13
  55. package/dist/billing/crypto/oracle/fixed.d.ts +2 -2
  56. package/dist/billing/crypto/oracle/fixed.js +9 -7
  57. package/dist/billing/crypto/oracle/index.d.ts +4 -0
  58. package/dist/billing/crypto/oracle/index.js +3 -0
  59. package/dist/billing/crypto/oracle/types.d.ts +12 -3
  60. package/dist/billing/crypto/oracle/types.js +7 -1
  61. package/dist/billing/crypto/types.d.ts +16 -0
  62. package/dist/billing/crypto/unified-checkout.d.ts +10 -19
  63. package/dist/billing/crypto/unified-checkout.js +17 -131
  64. package/dist/billing/crypto/watcher-service.d.ts +22 -2
  65. package/dist/billing/crypto/watcher-service.js +71 -30
  66. package/dist/db/schema/crypto.d.ts +68 -0
  67. package/dist/db/schema/crypto.js +8 -0
  68. package/dist/monetization/crypto/__tests__/webhook.test.js +2 -1
  69. package/drizzle/migrations/0016_charge_progress_columns.sql +4 -0
  70. package/drizzle/migrations/meta/_journal.json +7 -0
  71. package/package.json +1 -1
  72. package/src/billing/crypto/__tests__/key-server.test.ts +1 -1
  73. package/src/billing/crypto/__tests__/unified-checkout.test.ts +83 -0
  74. package/src/billing/crypto/__tests__/watcher-service.test.ts +242 -0
  75. package/src/billing/crypto/__tests__/webhook-confirmations.test.ts +367 -0
  76. package/src/billing/crypto/btc/__tests__/settler.test.ts +1 -0
  77. package/src/billing/crypto/btc/__tests__/watcher.test.ts +201 -0
  78. package/src/billing/crypto/btc/types.ts +3 -1
  79. package/src/billing/crypto/btc/watcher.ts +26 -8
  80. package/src/billing/crypto/charge-store.test.ts +204 -1
  81. package/src/billing/crypto/charge-store.ts +86 -2
  82. package/src/billing/crypto/client.ts +2 -0
  83. package/src/billing/crypto/cursor-store.ts +31 -3
  84. package/src/billing/crypto/evm/__tests__/eth-checkout.test.ts +3 -3
  85. package/src/billing/crypto/evm/__tests__/eth-settler.test.ts +2 -0
  86. package/src/billing/crypto/evm/__tests__/eth-watcher.test.ts +33 -6
  87. package/src/billing/crypto/evm/__tests__/settler.test.ts +2 -0
  88. package/src/billing/crypto/evm/__tests__/watcher-confirmations.test.ts +176 -0
  89. package/src/billing/crypto/evm/__tests__/watcher.test.ts +6 -2
  90. package/src/billing/crypto/evm/eth-checkout.ts +5 -5
  91. package/src/billing/crypto/evm/eth-watcher.ts +36 -16
  92. package/src/billing/crypto/evm/types.ts +5 -1
  93. package/src/billing/crypto/evm/watcher.ts +39 -13
  94. package/src/billing/crypto/index.ts +12 -3
  95. package/src/billing/crypto/key-server-entry.ts +7 -2
  96. package/src/billing/crypto/key-server-webhook.ts +92 -21
  97. package/src/billing/crypto/key-server.ts +17 -7
  98. package/src/billing/crypto/oracle/__tests__/chainlink.test.ts +4 -4
  99. package/src/billing/crypto/oracle/__tests__/coingecko.test.ts +75 -0
  100. package/src/billing/crypto/oracle/__tests__/composite.test.ts +61 -0
  101. package/src/billing/crypto/oracle/__tests__/convert.test.ts +29 -17
  102. package/src/billing/crypto/oracle/__tests__/fixed.test.ts +5 -5
  103. package/src/billing/crypto/oracle/chainlink.ts +11 -10
  104. package/src/billing/crypto/oracle/coingecko.ts +92 -0
  105. package/src/billing/crypto/oracle/composite.ts +35 -0
  106. package/src/billing/crypto/oracle/convert.ts +28 -13
  107. package/src/billing/crypto/oracle/fixed.ts +9 -7
  108. package/src/billing/crypto/oracle/index.ts +4 -0
  109. package/src/billing/crypto/oracle/types.ts +16 -3
  110. package/src/billing/crypto/types.ts +18 -0
  111. package/src/billing/crypto/unified-checkout.ts +22 -181
  112. package/src/billing/crypto/watcher-service.ts +85 -32
  113. package/src/db/schema/crypto.ts +8 -0
  114. package/src/monetization/crypto/__tests__/webhook.test.ts +2 -1
@@ -4,8 +4,8 @@
4
4
  * Payment flow:
5
5
  * 1. Watcher detects payment → handlePayment()
6
6
  * 2. Accumulate native amount (supports partial payments)
7
- * 3. When totalReceived >= expectedAmount → settle + credit
8
- * 4. Every payment (partial or full) enqueues a webhook delivery
7
+ * 3. When totalReceived >= expectedAmount AND confirmations >= required confirmed + credit
8
+ * 4. Every payment/confirmation change enqueues a webhook delivery
9
9
  * 5. Outbox processor retries failed deliveries with exponential backoff
10
10
  *
11
11
  * Amount comparison is ALWAYS in native crypto units (sats, wei, token base units).
@@ -90,14 +90,19 @@ async function processDeliveries(db, allowedPrefixes, log) {
90
90
  }
91
91
  return delivered;
92
92
  }
93
- // --- Payment handling (partial + full) ---
94
93
  /**
95
94
  * Handle a payment event. Accumulates partial payments in native units.
96
- * Settles when totalReceived >= expectedAmount. Fires webhook on every payment.
95
+ * Fires webhook on every payment/confirmation change with canonical statuses.
97
96
  *
98
- * @param nativeAmount — received amount in native base units (sats for BTC/DOGE, raw token units for ERC20)
97
+ * 3-phase webhook lifecycle:
98
+ * 1. Tx first seen -> status: "partial", confirmations: 0
99
+ * 2. Each new block -> status: "partial", confirmations: current
100
+ * 3. Threshold reached + full payment -> status: "confirmed"
101
+ *
102
+ * @param nativeAmount — received amount in native base units (sats for BTC/DOGE, raw token units for ERC20).
103
+ * Pass "0" for confirmation-only updates (no new payment, just more confirmations).
99
104
  */
100
- async function handlePayment(db, chargeStore, address, nativeAmount, payload, log) {
105
+ export async function handlePayment(db, chargeStore, address, nativeAmount, payload, log) {
101
106
  const charge = await chargeStore.getByDepositAddress(address);
102
107
  if (!charge) {
103
108
  log("Payment to unknown address", { address });
@@ -106,39 +111,59 @@ async function handlePayment(db, chargeStore, address, nativeAmount, payload, lo
106
111
  if (charge.creditedAt) {
107
112
  return; // Already fully paid and credited
108
113
  }
109
- // Accumulate: add this payment to the running total
114
+ const { confirmations, confirmationsRequired, amountReceivedCents, txHash } = payload;
115
+ // Accumulate: add this payment to the running total (if nativeAmount > 0)
110
116
  const prevReceived = BigInt(charge.receivedAmount ?? "0");
111
117
  const thisPayment = BigInt(nativeAmount);
112
118
  const totalReceived = (prevReceived + thisPayment).toString();
113
119
  const expected = BigInt(charge.expectedAmount ?? "0");
114
120
  const isFull = expected > 0n && BigInt(totalReceived) >= expected;
115
- // Update received_amount in DB
116
- await db
117
- .update(cryptoCharges)
118
- .set({ receivedAmount: totalReceived, filledAmount: totalReceived })
119
- .where(eq(cryptoCharges.referenceId, charge.referenceId));
120
- if (isFull) {
121
- const settled = "Settled";
122
- await chargeStore.updateStatus(charge.referenceId, settled, charge.token ?? undefined, totalReceived);
121
+ const isConfirmed = isFull && confirmations >= confirmationsRequired;
122
+ // Update received_amount in DB (only when there's a new payment)
123
+ if (thisPayment > 0n) {
124
+ await db
125
+ .update(cryptoCharges)
126
+ .set({ receivedAmount: totalReceived, filledAmount: totalReceived })
127
+ .where(eq(cryptoCharges.referenceId, charge.referenceId));
128
+ }
129
+ // Determine canonical status
130
+ const status = isConfirmed ? "confirmed" : "partial";
131
+ // Update progress via new API
132
+ await chargeStore.updateProgress(charge.referenceId, {
133
+ status,
134
+ amountReceivedCents,
135
+ confirmations,
136
+ confirmationsRequired,
137
+ txHash,
138
+ });
139
+ if (isConfirmed) {
123
140
  await chargeStore.markCredited(charge.referenceId);
124
- log("Charge settled", { chargeId: charge.referenceId, expected: expected.toString(), received: totalReceived });
141
+ log("Charge confirmed", {
142
+ chargeId: charge.referenceId,
143
+ confirmations,
144
+ confirmationsRequired,
145
+ });
125
146
  }
126
147
  else {
127
- const processing = "Processing";
128
- await chargeStore.updateStatus(charge.referenceId, processing, charge.token ?? undefined, totalReceived);
129
- log("Partial payment", { chargeId: charge.referenceId, expected: expected.toString(), received: totalReceived });
148
+ log("Payment progress", {
149
+ chargeId: charge.referenceId,
150
+ confirmations,
151
+ confirmationsRequired,
152
+ received: totalReceived,
153
+ });
130
154
  }
131
- // Webhook on every payment — product shows progress to user
155
+ // Webhook on every event — product shows confirmation progress to user
132
156
  if (charge.callbackUrl) {
133
157
  await enqueueWebhook(db, charge.referenceId, charge.callbackUrl, {
134
158
  chargeId: charge.referenceId,
135
159
  chain: charge.chain,
136
160
  address: charge.depositAddress,
137
- expectedAmount: expected.toString(),
138
- receivedAmount: totalReceived,
139
- amountUsdCents: charge.amountUsdCents,
140
- status: isFull ? "confirmed" : "partial",
141
- ...payload,
161
+ amountExpectedCents: charge.amountUsdCents,
162
+ amountReceivedCents,
163
+ confirmations,
164
+ confirmationsRequired,
165
+ txHash,
166
+ status,
142
167
  });
143
168
  }
144
169
  }
@@ -181,11 +206,19 @@ export async function startWatchers(opts) {
181
206
  oracle,
182
207
  cursorStore,
183
208
  onPayment: async (event) => {
184
- log("UTXO payment", { chain: method.chain, address: event.address, txid: event.txid, sats: event.amountSats });
185
- // Pass native amount (sats) — NOT USD cents
209
+ log("UTXO payment", {
210
+ chain: method.chain,
211
+ address: event.address,
212
+ txid: event.txid,
213
+ sats: event.amountSats,
214
+ confirmations: event.confirmations,
215
+ confirmationsRequired: event.confirmationsRequired,
216
+ });
186
217
  await handlePayment(db, chargeStore, event.address, String(event.amountSats), {
187
218
  txHash: event.txid,
188
219
  confirmations: event.confirmations,
220
+ confirmationsRequired: event.confirmationsRequired,
221
+ amountReceivedCents: event.amountUsdCents,
189
222
  }, log);
190
223
  },
191
224
  });
@@ -247,11 +280,19 @@ export async function startWatchers(opts) {
247
280
  watchedAddresses: chainAddresses,
248
281
  cursorStore,
249
282
  onPayment: async (event) => {
250
- log("EVM payment", { chain: event.chain, token: event.token, to: event.to, txHash: event.txHash });
251
- // Pass native amount (raw token units) — NOT USD cents
283
+ log("EVM payment", {
284
+ chain: event.chain,
285
+ token: event.token,
286
+ to: event.to,
287
+ txHash: event.txHash,
288
+ confirmations: event.confirmations,
289
+ confirmationsRequired: event.confirmationsRequired,
290
+ });
252
291
  await handlePayment(db, chargeStore, event.to, event.rawAmount, {
253
292
  txHash: event.txHash,
254
- confirmations: method.confirmations,
293
+ confirmations: event.confirmations,
294
+ confirmationsRequired: event.confirmationsRequired,
295
+ amountReceivedCents: event.amountUsdCents,
255
296
  }, log);
256
297
  },
257
298
  });
@@ -282,6 +282,74 @@ export declare const cryptoCharges: import("drizzle-orm/pg-core").PgTableWithCol
282
282
  identity: undefined;
283
283
  generated: undefined;
284
284
  }, {}, {}>;
285
+ confirmations: import("drizzle-orm/pg-core").PgColumn<{
286
+ name: "confirmations";
287
+ tableName: "crypto_charges";
288
+ dataType: "number";
289
+ columnType: "PgInteger";
290
+ data: number;
291
+ driverParam: string | number;
292
+ notNull: true;
293
+ hasDefault: true;
294
+ isPrimaryKey: false;
295
+ isAutoincrement: false;
296
+ hasRuntimeDefault: false;
297
+ enumValues: undefined;
298
+ baseColumn: never;
299
+ identity: undefined;
300
+ generated: undefined;
301
+ }, {}, {}>;
302
+ confirmationsRequired: import("drizzle-orm/pg-core").PgColumn<{
303
+ name: "confirmations_required";
304
+ tableName: "crypto_charges";
305
+ dataType: "number";
306
+ columnType: "PgInteger";
307
+ data: number;
308
+ driverParam: string | number;
309
+ notNull: true;
310
+ hasDefault: true;
311
+ isPrimaryKey: false;
312
+ isAutoincrement: false;
313
+ hasRuntimeDefault: false;
314
+ enumValues: undefined;
315
+ baseColumn: never;
316
+ identity: undefined;
317
+ generated: undefined;
318
+ }, {}, {}>;
319
+ txHash: import("drizzle-orm/pg-core").PgColumn<{
320
+ name: "tx_hash";
321
+ tableName: "crypto_charges";
322
+ dataType: "string";
323
+ columnType: "PgText";
324
+ data: string;
325
+ driverParam: string;
326
+ notNull: false;
327
+ hasDefault: false;
328
+ isPrimaryKey: false;
329
+ isAutoincrement: false;
330
+ hasRuntimeDefault: false;
331
+ enumValues: [string, ...string[]];
332
+ baseColumn: never;
333
+ identity: undefined;
334
+ generated: undefined;
335
+ }, {}, {}>;
336
+ amountReceivedCents: import("drizzle-orm/pg-core").PgColumn<{
337
+ name: "amount_received_cents";
338
+ tableName: "crypto_charges";
339
+ dataType: "number";
340
+ columnType: "PgInteger";
341
+ data: number;
342
+ driverParam: string | number;
343
+ notNull: true;
344
+ hasDefault: true;
345
+ isPrimaryKey: false;
346
+ isAutoincrement: false;
347
+ hasRuntimeDefault: false;
348
+ enumValues: undefined;
349
+ baseColumn: never;
350
+ identity: undefined;
351
+ generated: undefined;
352
+ }, {}, {}>;
285
353
  };
286
354
  dialect: "pg";
287
355
  }>;
@@ -27,6 +27,14 @@ export const cryptoCharges = pgTable("crypto_charges", {
27
27
  expectedAmount: text("expected_amount"),
28
28
  /** Running total of received crypto in native units. Accumulates across partial payments. */
29
29
  receivedAmount: text("received_amount"),
30
+ /** Number of blockchain confirmations observed so far. */
31
+ confirmations: integer("confirmations").notNull().default(0),
32
+ /** Required confirmations for settlement (copied from payment method at creation). */
33
+ confirmationsRequired: integer("confirmations_required").notNull().default(1),
34
+ /** Blockchain transaction hash for the payment. */
35
+ txHash: text("tx_hash"),
36
+ /** Amount received so far in USD cents (integer). Converted from crypto at time of receipt. */
37
+ amountReceivedCents: integer("amount_received_cents").notNull().default(0),
30
38
  }, (table) => [
31
39
  index("idx_crypto_charges_tenant").on(table.tenantId),
32
40
  index("idx_crypto_charges_status").on(table.status),
@@ -120,7 +120,8 @@ describe("handleCryptoWebhook (monetization layer)", () => {
120
120
  it("updates charge status on every webhook call", async () => {
121
121
  await handleCryptoWebhook(deps, makePayload({ status: "partial" }));
122
122
  const charge = await chargeStore.getByReferenceId("chg-test-001");
123
- expect(charge?.status).toBe("partial");
123
+ // DB stores legacy status values; "partial" maps to "Processing" internally
124
+ expect(charge?.status).toBe("Processing");
124
125
  });
125
126
  it("settles charge when status is confirmed", async () => {
126
127
  await handleCryptoWebhook(deps, makePayload({ status: "confirmed" }));
@@ -0,0 +1,4 @@
1
+ ALTER TABLE "crypto_charges" ADD COLUMN "confirmations" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
2
+ ALTER TABLE "crypto_charges" ADD COLUMN "confirmations_required" integer DEFAULT 1 NOT NULL;--> statement-breakpoint
3
+ ALTER TABLE "crypto_charges" ADD COLUMN "tx_hash" text;--> statement-breakpoint
4
+ ALTER TABLE "crypto_charges" ADD COLUMN "amount_received_cents" integer DEFAULT 0 NOT NULL;
@@ -113,6 +113,13 @@
113
113
  "when": 1742918400000,
114
114
  "tag": "0015_callback_url",
115
115
  "breakpoints": true
116
+ },
117
+ {
118
+ "idx": 16,
119
+ "version": "7",
120
+ "when": 1743004800000,
121
+ "tag": "0016_charge_progress_columns",
122
+ "breakpoints": true
116
123
  }
117
124
  ]
118
125
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/platform-core",
3
- "version": "1.48.0",
3
+ "version": "1.49.1",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -110,7 +110,7 @@ function mockDeps(): KeyServerDeps & {
110
110
  db: createMockDb() as never,
111
111
  chargeStore: chargeStore as never,
112
112
  methodStore: methodStore as never,
113
- oracle: { getPrice: vi.fn().mockResolvedValue({ priceCents: 6_500_000, updatedAt: new Date() }) } as never,
113
+ oracle: { getPrice: vi.fn().mockResolvedValue({ priceMicros: 65_000_000_000, updatedAt: new Date() }) } as never,
114
114
  };
115
115
  }
116
116
 
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { CryptoServiceClient } from "../client.js";
3
+ import { createUnifiedCheckout, MIN_CHECKOUT_USD } from "../unified-checkout.js";
4
+
5
+ function mockCryptoService(): CryptoServiceClient {
6
+ return {
7
+ createCharge: vi.fn().mockResolvedValue({
8
+ chargeId: "btc:bc1qtest",
9
+ address: "bc1qtest",
10
+ chain: "bitcoin",
11
+ token: "BTC",
12
+ amountUsd: 50,
13
+ displayAmount: "0.00076923 BTC",
14
+ derivationIndex: 7,
15
+ expiresAt: "2026-03-21T23:00:00Z",
16
+ }),
17
+ listChains: vi.fn(),
18
+ deriveAddress: vi.fn(),
19
+ getCharge: vi.fn(),
20
+ } as unknown as CryptoServiceClient;
21
+ }
22
+
23
+ describe("createUnifiedCheckout", () => {
24
+ it("delegates to CryptoServiceClient.createCharge", async () => {
25
+ const service = mockCryptoService();
26
+ const result = await createUnifiedCheckout({ cryptoService: service }, "btc", { tenant: "t-1", amountUsd: 50 });
27
+
28
+ expect(result.depositAddress).toBe("bc1qtest");
29
+ expect(result.displayAmount).toBe("0.00076923 BTC");
30
+ expect(result.amountUsd).toBe(50);
31
+ expect(result.token).toBe("BTC");
32
+ expect(result.chain).toBe("bitcoin");
33
+ expect(result.referenceId).toBe("btc:bc1qtest");
34
+
35
+ expect(service.createCharge).toHaveBeenCalledWith({
36
+ chain: "btc",
37
+ amountUsd: 50,
38
+ callbackUrl: undefined,
39
+ });
40
+ });
41
+
42
+ it("passes callbackUrl to createCharge", async () => {
43
+ const service = mockCryptoService();
44
+ await createUnifiedCheckout({ cryptoService: service }, "base-usdc", {
45
+ tenant: "t-1",
46
+ amountUsd: 25,
47
+ callbackUrl: "https://example.com/hook",
48
+ });
49
+
50
+ expect(service.createCharge).toHaveBeenCalledWith({
51
+ chain: "base-usdc",
52
+ amountUsd: 25,
53
+ callbackUrl: "https://example.com/hook",
54
+ });
55
+ });
56
+
57
+ it("rejects amount below minimum", async () => {
58
+ const service = mockCryptoService();
59
+ await expect(
60
+ createUnifiedCheckout({ cryptoService: service }, "btc", { tenant: "t-1", amountUsd: 5 }),
61
+ ).rejects.toThrow(`Minimum payment amount is $${MIN_CHECKOUT_USD}`);
62
+
63
+ expect(service.createCharge).not.toHaveBeenCalled();
64
+ });
65
+
66
+ it("rejects non-finite amount", async () => {
67
+ const service = mockCryptoService();
68
+ await expect(
69
+ createUnifiedCheckout({ cryptoService: service }, "btc", { tenant: "t-1", amountUsd: NaN }),
70
+ ).rejects.toThrow(`Minimum payment amount is $${MIN_CHECKOUT_USD}`);
71
+ });
72
+
73
+ it("propagates createCharge errors", async () => {
74
+ const service = mockCryptoService();
75
+ (service.createCharge as ReturnType<typeof vi.fn>).mockRejectedValue(
76
+ new Error("CryptoService createCharge failed (500): Internal Server Error"),
77
+ );
78
+
79
+ await expect(
80
+ createUnifiedCheckout({ cryptoService: service }, "btc", { tenant: "t-1", amountUsd: 50 }),
81
+ ).rejects.toThrow("CryptoService createCharge failed (500)");
82
+ });
83
+ });
@@ -0,0 +1,242 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { handlePayment } from "../watcher-service.js";
3
+
4
+ function mockChargeStore(overrides: Record<string, unknown> = {}) {
5
+ return {
6
+ getByDepositAddress: vi.fn().mockResolvedValue({
7
+ referenceId: "btc:test",
8
+ tenantId: "t1",
9
+ amountUsdCents: 5000,
10
+ creditedAt: null,
11
+ chain: "bitcoin",
12
+ depositAddress: "bc1qtest",
13
+ token: "BTC",
14
+ callbackUrl: "https://example.com/hook",
15
+ expectedAmount: "50000",
16
+ receivedAmount: "0",
17
+ confirmations: 0,
18
+ confirmationsRequired: 6,
19
+ ...overrides,
20
+ }),
21
+ updateProgress: vi.fn().mockResolvedValue(undefined),
22
+ updateStatus: vi.fn().mockResolvedValue(undefined),
23
+ markCredited: vi.fn().mockResolvedValue(undefined),
24
+ };
25
+ }
26
+
27
+ function mockDb() {
28
+ return {
29
+ insert: vi.fn().mockReturnValue({ values: vi.fn().mockResolvedValue(undefined) }),
30
+ update: vi.fn().mockReturnValue({
31
+ set: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }),
32
+ }),
33
+ };
34
+ }
35
+
36
+ const noop = () => {};
37
+
38
+ describe("handlePayment", () => {
39
+ it("fires webhook with confirmations: 0 on first tx detection", async () => {
40
+ const chargeStore = mockChargeStore();
41
+ const db = mockDb();
42
+ const enqueuedPayloads: Record<string, unknown>[] = [];
43
+ db.insert = vi.fn().mockReturnValue({
44
+ values: vi.fn().mockImplementation((val: Record<string, unknown>) => {
45
+ if (val.payload) enqueuedPayloads.push(JSON.parse(val.payload as string));
46
+ return Promise.resolve(undefined);
47
+ }),
48
+ });
49
+
50
+ await handlePayment(
51
+ db as never,
52
+ chargeStore as never,
53
+ "bc1qtest",
54
+ "50000",
55
+ {
56
+ txHash: "abc123",
57
+ confirmations: 0,
58
+ confirmationsRequired: 6,
59
+ amountReceivedCents: 5000,
60
+ },
61
+ noop,
62
+ );
63
+
64
+ expect(enqueuedPayloads).toHaveLength(1);
65
+ expect(enqueuedPayloads[0]).toMatchObject({
66
+ chargeId: "btc:test",
67
+ status: "partial",
68
+ confirmations: 0,
69
+ confirmationsRequired: 6,
70
+ });
71
+ });
72
+
73
+ it("fires webhook on each confirmation increment", async () => {
74
+ const chargeStore = mockChargeStore({ confirmations: 2 });
75
+ const db = mockDb();
76
+ const enqueuedPayloads: Record<string, unknown>[] = [];
77
+ db.insert = vi.fn().mockReturnValue({
78
+ values: vi.fn().mockImplementation((val: Record<string, unknown>) => {
79
+ if (val.payload) enqueuedPayloads.push(JSON.parse(val.payload as string));
80
+ return Promise.resolve(undefined);
81
+ }),
82
+ });
83
+
84
+ await handlePayment(
85
+ db as never,
86
+ chargeStore as never,
87
+ "bc1qtest",
88
+ "0", // no additional payment, just confirmation update
89
+ {
90
+ txHash: "abc123",
91
+ confirmations: 3,
92
+ confirmationsRequired: 6,
93
+ amountReceivedCents: 5000,
94
+ },
95
+ noop,
96
+ );
97
+
98
+ expect(enqueuedPayloads).toHaveLength(1);
99
+ expect(enqueuedPayloads[0]).toMatchObject({
100
+ status: "partial",
101
+ confirmations: 3,
102
+ confirmationsRequired: 6,
103
+ });
104
+ });
105
+
106
+ it("fires final webhook with status confirmed at threshold", async () => {
107
+ const chargeStore = mockChargeStore({
108
+ receivedAmount: "50000",
109
+ confirmations: 5,
110
+ });
111
+ const db = mockDb();
112
+ const enqueuedPayloads: Record<string, unknown>[] = [];
113
+ db.insert = vi.fn().mockReturnValue({
114
+ values: vi.fn().mockImplementation((val: Record<string, unknown>) => {
115
+ if (val.payload) enqueuedPayloads.push(JSON.parse(val.payload as string));
116
+ return Promise.resolve(undefined);
117
+ }),
118
+ });
119
+
120
+ await handlePayment(
121
+ db as never,
122
+ chargeStore as never,
123
+ "bc1qtest",
124
+ "0",
125
+ {
126
+ txHash: "abc123",
127
+ confirmations: 6,
128
+ confirmationsRequired: 6,
129
+ amountReceivedCents: 5000,
130
+ },
131
+ noop,
132
+ );
133
+
134
+ expect(enqueuedPayloads).toHaveLength(1);
135
+ expect(enqueuedPayloads[0]).toMatchObject({
136
+ status: "confirmed",
137
+ confirmations: 6,
138
+ confirmationsRequired: 6,
139
+ });
140
+ expect(chargeStore.markCredited).toHaveBeenCalledOnce();
141
+ });
142
+
143
+ it("all webhooks use canonical status values only", async () => {
144
+ const chargeStore = mockChargeStore();
145
+ const db = mockDb();
146
+ const enqueuedPayloads: Record<string, unknown>[] = [];
147
+ db.insert = vi.fn().mockReturnValue({
148
+ values: vi.fn().mockImplementation((val: Record<string, unknown>) => {
149
+ if (val.payload) enqueuedPayloads.push(JSON.parse(val.payload as string));
150
+ return Promise.resolve(undefined);
151
+ }),
152
+ });
153
+
154
+ await handlePayment(
155
+ db as never,
156
+ chargeStore as never,
157
+ "bc1qtest",
158
+ "50000",
159
+ {
160
+ txHash: "abc123",
161
+ confirmations: 0,
162
+ confirmationsRequired: 6,
163
+ amountReceivedCents: 5000,
164
+ },
165
+ noop,
166
+ );
167
+
168
+ const validStatuses = ["pending", "partial", "confirmed", "expired", "failed"];
169
+ for (const payload of enqueuedPayloads) {
170
+ expect(validStatuses).toContain(payload.status);
171
+ }
172
+ // Must NEVER contain legacy statuses
173
+ for (const payload of enqueuedPayloads) {
174
+ expect(payload.status).not.toBe("Settled");
175
+ expect(payload.status).not.toBe("Processing");
176
+ expect(payload.status).not.toBe("New");
177
+ }
178
+ });
179
+
180
+ it("updates charge progress via updateProgress()", async () => {
181
+ const chargeStore = mockChargeStore();
182
+ const db = mockDb();
183
+ db.insert = vi.fn().mockReturnValue({
184
+ values: vi.fn().mockResolvedValue(undefined),
185
+ });
186
+
187
+ await handlePayment(
188
+ db as never,
189
+ chargeStore as never,
190
+ "bc1qtest",
191
+ "25000",
192
+ {
193
+ txHash: "abc123",
194
+ confirmations: 2,
195
+ confirmationsRequired: 6,
196
+ amountReceivedCents: 2500,
197
+ },
198
+ noop,
199
+ );
200
+
201
+ expect(chargeStore.updateProgress).toHaveBeenCalledWith("btc:test", {
202
+ status: "partial",
203
+ amountReceivedCents: 2500,
204
+ confirmations: 2,
205
+ confirmationsRequired: 6,
206
+ txHash: "abc123",
207
+ });
208
+ });
209
+
210
+ it("skips already-credited charges", async () => {
211
+ const chargeStore = mockChargeStore({ creditedAt: "2026-01-01" });
212
+ const db = mockDb();
213
+
214
+ await handlePayment(
215
+ db as never,
216
+ chargeStore as never,
217
+ "bc1qtest",
218
+ "50000",
219
+ { txHash: "abc123", confirmations: 6, confirmationsRequired: 6, amountReceivedCents: 5000 },
220
+ noop,
221
+ );
222
+
223
+ expect(chargeStore.updateProgress).not.toHaveBeenCalled();
224
+ });
225
+
226
+ it("skips unknown addresses", async () => {
227
+ const chargeStore = mockChargeStore();
228
+ chargeStore.getByDepositAddress.mockResolvedValue(null);
229
+ const db = mockDb();
230
+
231
+ await handlePayment(
232
+ db as never,
233
+ chargeStore as never,
234
+ "bc1qunknown",
235
+ "50000",
236
+ { txHash: "abc123", confirmations: 0, confirmationsRequired: 6, amountReceivedCents: 5000 },
237
+ noop,
238
+ );
239
+
240
+ expect(chargeStore.updateProgress).not.toHaveBeenCalled();
241
+ });
242
+ });