@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.
- package/dist/billing/crypto/__tests__/key-server.test.js +1 -1
- package/dist/billing/crypto/__tests__/unified-checkout.test.d.ts +1 -0
- package/dist/billing/crypto/__tests__/unified-checkout.test.js +63 -0
- package/dist/billing/crypto/__tests__/watcher-service.test.d.ts +1 -0
- package/dist/billing/crypto/__tests__/watcher-service.test.js +174 -0
- package/dist/billing/crypto/__tests__/webhook-confirmations.test.d.ts +1 -0
- package/dist/billing/crypto/__tests__/webhook-confirmations.test.js +304 -0
- package/dist/billing/crypto/btc/__tests__/settler.test.js +1 -0
- package/dist/billing/crypto/btc/__tests__/watcher.test.d.ts +1 -0
- package/dist/billing/crypto/btc/__tests__/watcher.test.js +170 -0
- package/dist/billing/crypto/btc/types.d.ts +3 -1
- package/dist/billing/crypto/btc/watcher.d.ts +6 -1
- package/dist/billing/crypto/btc/watcher.js +24 -8
- package/dist/billing/crypto/charge-store.d.ts +27 -2
- package/dist/billing/crypto/charge-store.js +67 -1
- package/dist/billing/crypto/charge-store.test.js +180 -1
- package/dist/billing/crypto/client.d.ts +2 -0
- package/dist/billing/crypto/cursor-store.d.ts +10 -3
- package/dist/billing/crypto/cursor-store.js +21 -1
- package/dist/billing/crypto/evm/__tests__/eth-checkout.test.js +3 -3
- package/dist/billing/crypto/evm/__tests__/eth-settler.test.js +2 -0
- package/dist/billing/crypto/evm/__tests__/eth-watcher.test.js +33 -6
- package/dist/billing/crypto/evm/__tests__/settler.test.js +2 -0
- package/dist/billing/crypto/evm/__tests__/watcher-confirmations.test.d.ts +1 -0
- package/dist/billing/crypto/evm/__tests__/watcher-confirmations.test.js +144 -0
- package/dist/billing/crypto/evm/__tests__/watcher.test.js +6 -2
- package/dist/billing/crypto/evm/eth-checkout.d.ts +2 -2
- package/dist/billing/crypto/evm/eth-checkout.js +3 -3
- package/dist/billing/crypto/evm/eth-watcher.d.ts +11 -8
- package/dist/billing/crypto/evm/eth-watcher.js +29 -15
- package/dist/billing/crypto/evm/types.d.ts +5 -1
- package/dist/billing/crypto/evm/watcher.d.ts +9 -1
- package/dist/billing/crypto/evm/watcher.js +36 -13
- package/dist/billing/crypto/index.d.ts +3 -3
- package/dist/billing/crypto/index.js +1 -1
- package/dist/billing/crypto/key-server-entry.js +7 -2
- package/dist/billing/crypto/key-server-webhook.d.ts +17 -4
- package/dist/billing/crypto/key-server-webhook.js +76 -15
- package/dist/billing/crypto/key-server.js +18 -7
- package/dist/billing/crypto/oracle/__tests__/chainlink.test.js +4 -4
- package/dist/billing/crypto/oracle/__tests__/coingecko.test.d.ts +1 -0
- package/dist/billing/crypto/oracle/__tests__/coingecko.test.js +65 -0
- package/dist/billing/crypto/oracle/__tests__/composite.test.d.ts +1 -0
- package/dist/billing/crypto/oracle/__tests__/composite.test.js +48 -0
- package/dist/billing/crypto/oracle/__tests__/convert.test.js +27 -17
- package/dist/billing/crypto/oracle/__tests__/fixed.test.js +5 -5
- package/dist/billing/crypto/oracle/chainlink.d.ts +2 -2
- package/dist/billing/crypto/oracle/chainlink.js +11 -10
- package/dist/billing/crypto/oracle/coingecko.d.ts +22 -0
- package/dist/billing/crypto/oracle/coingecko.js +67 -0
- package/dist/billing/crypto/oracle/composite.d.ts +14 -0
- package/dist/billing/crypto/oracle/composite.js +34 -0
- package/dist/billing/crypto/oracle/convert.d.ts +17 -7
- package/dist/billing/crypto/oracle/convert.js +26 -13
- package/dist/billing/crypto/oracle/fixed.d.ts +2 -2
- package/dist/billing/crypto/oracle/fixed.js +9 -7
- package/dist/billing/crypto/oracle/index.d.ts +4 -0
- package/dist/billing/crypto/oracle/index.js +3 -0
- package/dist/billing/crypto/oracle/types.d.ts +12 -3
- package/dist/billing/crypto/oracle/types.js +7 -1
- package/dist/billing/crypto/types.d.ts +16 -0
- package/dist/billing/crypto/unified-checkout.d.ts +10 -19
- package/dist/billing/crypto/unified-checkout.js +17 -131
- package/dist/billing/crypto/watcher-service.d.ts +22 -2
- package/dist/billing/crypto/watcher-service.js +71 -30
- package/dist/db/schema/crypto.d.ts +68 -0
- package/dist/db/schema/crypto.js +8 -0
- package/dist/monetization/crypto/__tests__/webhook.test.js +2 -1
- package/drizzle/migrations/0016_charge_progress_columns.sql +4 -0
- package/drizzle/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/src/billing/crypto/__tests__/key-server.test.ts +1 -1
- package/src/billing/crypto/__tests__/unified-checkout.test.ts +83 -0
- package/src/billing/crypto/__tests__/watcher-service.test.ts +242 -0
- package/src/billing/crypto/__tests__/webhook-confirmations.test.ts +367 -0
- package/src/billing/crypto/btc/__tests__/settler.test.ts +1 -0
- package/src/billing/crypto/btc/__tests__/watcher.test.ts +201 -0
- package/src/billing/crypto/btc/types.ts +3 -1
- package/src/billing/crypto/btc/watcher.ts +26 -8
- package/src/billing/crypto/charge-store.test.ts +204 -1
- package/src/billing/crypto/charge-store.ts +86 -2
- package/src/billing/crypto/client.ts +2 -0
- package/src/billing/crypto/cursor-store.ts +31 -3
- package/src/billing/crypto/evm/__tests__/eth-checkout.test.ts +3 -3
- package/src/billing/crypto/evm/__tests__/eth-settler.test.ts +2 -0
- package/src/billing/crypto/evm/__tests__/eth-watcher.test.ts +33 -6
- package/src/billing/crypto/evm/__tests__/settler.test.ts +2 -0
- package/src/billing/crypto/evm/__tests__/watcher-confirmations.test.ts +176 -0
- package/src/billing/crypto/evm/__tests__/watcher.test.ts +6 -2
- package/src/billing/crypto/evm/eth-checkout.ts +5 -5
- package/src/billing/crypto/evm/eth-watcher.ts +36 -16
- package/src/billing/crypto/evm/types.ts +5 -1
- package/src/billing/crypto/evm/watcher.ts +39 -13
- package/src/billing/crypto/index.ts +12 -3
- package/src/billing/crypto/key-server-entry.ts +7 -2
- package/src/billing/crypto/key-server-webhook.ts +92 -21
- package/src/billing/crypto/key-server.ts +17 -7
- package/src/billing/crypto/oracle/__tests__/chainlink.test.ts +4 -4
- package/src/billing/crypto/oracle/__tests__/coingecko.test.ts +75 -0
- package/src/billing/crypto/oracle/__tests__/composite.test.ts +61 -0
- package/src/billing/crypto/oracle/__tests__/convert.test.ts +29 -17
- package/src/billing/crypto/oracle/__tests__/fixed.test.ts +5 -5
- package/src/billing/crypto/oracle/chainlink.ts +11 -10
- package/src/billing/crypto/oracle/coingecko.ts +92 -0
- package/src/billing/crypto/oracle/composite.ts +35 -0
- package/src/billing/crypto/oracle/convert.ts +28 -13
- package/src/billing/crypto/oracle/fixed.ts +9 -7
- package/src/billing/crypto/oracle/index.ts +4 -0
- package/src/billing/crypto/oracle/types.ts +16 -3
- package/src/billing/crypto/types.ts +18 -0
- package/src/billing/crypto/unified-checkout.ts +22 -181
- package/src/billing/crypto/watcher-service.ts +85 -32
- package/src/db/schema/crypto.ts +8 -0
- 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 →
|
|
8
|
-
* 4. Every payment
|
|
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
|
-
*
|
|
95
|
+
* Fires webhook on every payment/confirmation change with canonical statuses.
|
|
97
96
|
*
|
|
98
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
141
|
+
log("Charge confirmed", {
|
|
142
|
+
chargeId: charge.referenceId,
|
|
143
|
+
confirmations,
|
|
144
|
+
confirmationsRequired,
|
|
145
|
+
});
|
|
125
146
|
}
|
|
126
147
|
else {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
148
|
+
log("Payment progress", {
|
|
149
|
+
chargeId: charge.referenceId,
|
|
150
|
+
confirmations,
|
|
151
|
+
confirmationsRequired,
|
|
152
|
+
received: totalReceived,
|
|
153
|
+
});
|
|
130
154
|
}
|
|
131
|
-
// Webhook on every
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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", {
|
|
185
|
-
|
|
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", {
|
|
251
|
-
|
|
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:
|
|
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
|
}>;
|
package/dist/db/schema/crypto.js
CHANGED
|
@@ -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
|
-
|
|
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
|
@@ -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({
|
|
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
|
+
});
|