@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
|
@@ -101,7 +101,7 @@ function mockDeps() {
|
|
|
101
101
|
db: createMockDb(),
|
|
102
102
|
chargeStore: chargeStore,
|
|
103
103
|
methodStore: methodStore,
|
|
104
|
-
oracle: { getPrice: vi.fn().mockResolvedValue({
|
|
104
|
+
oracle: { getPrice: vi.fn().mockResolvedValue({ priceMicros: 65_000_000_000, updatedAt: new Date() }) },
|
|
105
105
|
};
|
|
106
106
|
}
|
|
107
107
|
describe("key-server routes", () => {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createUnifiedCheckout, MIN_CHECKOUT_USD } from "../unified-checkout.js";
|
|
3
|
+
function mockCryptoService() {
|
|
4
|
+
return {
|
|
5
|
+
createCharge: vi.fn().mockResolvedValue({
|
|
6
|
+
chargeId: "btc:bc1qtest",
|
|
7
|
+
address: "bc1qtest",
|
|
8
|
+
chain: "bitcoin",
|
|
9
|
+
token: "BTC",
|
|
10
|
+
amountUsd: 50,
|
|
11
|
+
displayAmount: "0.00076923 BTC",
|
|
12
|
+
derivationIndex: 7,
|
|
13
|
+
expiresAt: "2026-03-21T23:00:00Z",
|
|
14
|
+
}),
|
|
15
|
+
listChains: vi.fn(),
|
|
16
|
+
deriveAddress: vi.fn(),
|
|
17
|
+
getCharge: vi.fn(),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
describe("createUnifiedCheckout", () => {
|
|
21
|
+
it("delegates to CryptoServiceClient.createCharge", async () => {
|
|
22
|
+
const service = mockCryptoService();
|
|
23
|
+
const result = await createUnifiedCheckout({ cryptoService: service }, "btc", { tenant: "t-1", amountUsd: 50 });
|
|
24
|
+
expect(result.depositAddress).toBe("bc1qtest");
|
|
25
|
+
expect(result.displayAmount).toBe("0.00076923 BTC");
|
|
26
|
+
expect(result.amountUsd).toBe(50);
|
|
27
|
+
expect(result.token).toBe("BTC");
|
|
28
|
+
expect(result.chain).toBe("bitcoin");
|
|
29
|
+
expect(result.referenceId).toBe("btc:bc1qtest");
|
|
30
|
+
expect(service.createCharge).toHaveBeenCalledWith({
|
|
31
|
+
chain: "btc",
|
|
32
|
+
amountUsd: 50,
|
|
33
|
+
callbackUrl: undefined,
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
it("passes callbackUrl to createCharge", async () => {
|
|
37
|
+
const service = mockCryptoService();
|
|
38
|
+
await createUnifiedCheckout({ cryptoService: service }, "base-usdc", {
|
|
39
|
+
tenant: "t-1",
|
|
40
|
+
amountUsd: 25,
|
|
41
|
+
callbackUrl: "https://example.com/hook",
|
|
42
|
+
});
|
|
43
|
+
expect(service.createCharge).toHaveBeenCalledWith({
|
|
44
|
+
chain: "base-usdc",
|
|
45
|
+
amountUsd: 25,
|
|
46
|
+
callbackUrl: "https://example.com/hook",
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
it("rejects amount below minimum", async () => {
|
|
50
|
+
const service = mockCryptoService();
|
|
51
|
+
await expect(createUnifiedCheckout({ cryptoService: service }, "btc", { tenant: "t-1", amountUsd: 5 })).rejects.toThrow(`Minimum payment amount is $${MIN_CHECKOUT_USD}`);
|
|
52
|
+
expect(service.createCharge).not.toHaveBeenCalled();
|
|
53
|
+
});
|
|
54
|
+
it("rejects non-finite amount", async () => {
|
|
55
|
+
const service = mockCryptoService();
|
|
56
|
+
await expect(createUnifiedCheckout({ cryptoService: service }, "btc", { tenant: "t-1", amountUsd: NaN })).rejects.toThrow(`Minimum payment amount is $${MIN_CHECKOUT_USD}`);
|
|
57
|
+
});
|
|
58
|
+
it("propagates createCharge errors", async () => {
|
|
59
|
+
const service = mockCryptoService();
|
|
60
|
+
service.createCharge.mockRejectedValue(new Error("CryptoService createCharge failed (500): Internal Server Error"));
|
|
61
|
+
await expect(createUnifiedCheckout({ cryptoService: service }, "btc", { tenant: "t-1", amountUsd: 50 })).rejects.toThrow("CryptoService createCharge failed (500)");
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { handlePayment } from "../watcher-service.js";
|
|
3
|
+
function mockChargeStore(overrides = {}) {
|
|
4
|
+
return {
|
|
5
|
+
getByDepositAddress: vi.fn().mockResolvedValue({
|
|
6
|
+
referenceId: "btc:test",
|
|
7
|
+
tenantId: "t1",
|
|
8
|
+
amountUsdCents: 5000,
|
|
9
|
+
creditedAt: null,
|
|
10
|
+
chain: "bitcoin",
|
|
11
|
+
depositAddress: "bc1qtest",
|
|
12
|
+
token: "BTC",
|
|
13
|
+
callbackUrl: "https://example.com/hook",
|
|
14
|
+
expectedAmount: "50000",
|
|
15
|
+
receivedAmount: "0",
|
|
16
|
+
confirmations: 0,
|
|
17
|
+
confirmationsRequired: 6,
|
|
18
|
+
...overrides,
|
|
19
|
+
}),
|
|
20
|
+
updateProgress: vi.fn().mockResolvedValue(undefined),
|
|
21
|
+
updateStatus: vi.fn().mockResolvedValue(undefined),
|
|
22
|
+
markCredited: vi.fn().mockResolvedValue(undefined),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function mockDb() {
|
|
26
|
+
return {
|
|
27
|
+
insert: vi.fn().mockReturnValue({ values: vi.fn().mockResolvedValue(undefined) }),
|
|
28
|
+
update: vi.fn().mockReturnValue({
|
|
29
|
+
set: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }),
|
|
30
|
+
}),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
const noop = () => { };
|
|
34
|
+
describe("handlePayment", () => {
|
|
35
|
+
it("fires webhook with confirmations: 0 on first tx detection", async () => {
|
|
36
|
+
const chargeStore = mockChargeStore();
|
|
37
|
+
const db = mockDb();
|
|
38
|
+
const enqueuedPayloads = [];
|
|
39
|
+
db.insert = vi.fn().mockReturnValue({
|
|
40
|
+
values: vi.fn().mockImplementation((val) => {
|
|
41
|
+
if (val.payload)
|
|
42
|
+
enqueuedPayloads.push(JSON.parse(val.payload));
|
|
43
|
+
return Promise.resolve(undefined);
|
|
44
|
+
}),
|
|
45
|
+
});
|
|
46
|
+
await handlePayment(db, chargeStore, "bc1qtest", "50000", {
|
|
47
|
+
txHash: "abc123",
|
|
48
|
+
confirmations: 0,
|
|
49
|
+
confirmationsRequired: 6,
|
|
50
|
+
amountReceivedCents: 5000,
|
|
51
|
+
}, noop);
|
|
52
|
+
expect(enqueuedPayloads).toHaveLength(1);
|
|
53
|
+
expect(enqueuedPayloads[0]).toMatchObject({
|
|
54
|
+
chargeId: "btc:test",
|
|
55
|
+
status: "partial",
|
|
56
|
+
confirmations: 0,
|
|
57
|
+
confirmationsRequired: 6,
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
it("fires webhook on each confirmation increment", async () => {
|
|
61
|
+
const chargeStore = mockChargeStore({ confirmations: 2 });
|
|
62
|
+
const db = mockDb();
|
|
63
|
+
const enqueuedPayloads = [];
|
|
64
|
+
db.insert = vi.fn().mockReturnValue({
|
|
65
|
+
values: vi.fn().mockImplementation((val) => {
|
|
66
|
+
if (val.payload)
|
|
67
|
+
enqueuedPayloads.push(JSON.parse(val.payload));
|
|
68
|
+
return Promise.resolve(undefined);
|
|
69
|
+
}),
|
|
70
|
+
});
|
|
71
|
+
await handlePayment(db, chargeStore, "bc1qtest", "0", // no additional payment, just confirmation update
|
|
72
|
+
{
|
|
73
|
+
txHash: "abc123",
|
|
74
|
+
confirmations: 3,
|
|
75
|
+
confirmationsRequired: 6,
|
|
76
|
+
amountReceivedCents: 5000,
|
|
77
|
+
}, noop);
|
|
78
|
+
expect(enqueuedPayloads).toHaveLength(1);
|
|
79
|
+
expect(enqueuedPayloads[0]).toMatchObject({
|
|
80
|
+
status: "partial",
|
|
81
|
+
confirmations: 3,
|
|
82
|
+
confirmationsRequired: 6,
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
it("fires final webhook with status confirmed at threshold", async () => {
|
|
86
|
+
const chargeStore = mockChargeStore({
|
|
87
|
+
receivedAmount: "50000",
|
|
88
|
+
confirmations: 5,
|
|
89
|
+
});
|
|
90
|
+
const db = mockDb();
|
|
91
|
+
const enqueuedPayloads = [];
|
|
92
|
+
db.insert = vi.fn().mockReturnValue({
|
|
93
|
+
values: vi.fn().mockImplementation((val) => {
|
|
94
|
+
if (val.payload)
|
|
95
|
+
enqueuedPayloads.push(JSON.parse(val.payload));
|
|
96
|
+
return Promise.resolve(undefined);
|
|
97
|
+
}),
|
|
98
|
+
});
|
|
99
|
+
await handlePayment(db, chargeStore, "bc1qtest", "0", {
|
|
100
|
+
txHash: "abc123",
|
|
101
|
+
confirmations: 6,
|
|
102
|
+
confirmationsRequired: 6,
|
|
103
|
+
amountReceivedCents: 5000,
|
|
104
|
+
}, noop);
|
|
105
|
+
expect(enqueuedPayloads).toHaveLength(1);
|
|
106
|
+
expect(enqueuedPayloads[0]).toMatchObject({
|
|
107
|
+
status: "confirmed",
|
|
108
|
+
confirmations: 6,
|
|
109
|
+
confirmationsRequired: 6,
|
|
110
|
+
});
|
|
111
|
+
expect(chargeStore.markCredited).toHaveBeenCalledOnce();
|
|
112
|
+
});
|
|
113
|
+
it("all webhooks use canonical status values only", async () => {
|
|
114
|
+
const chargeStore = mockChargeStore();
|
|
115
|
+
const db = mockDb();
|
|
116
|
+
const enqueuedPayloads = [];
|
|
117
|
+
db.insert = vi.fn().mockReturnValue({
|
|
118
|
+
values: vi.fn().mockImplementation((val) => {
|
|
119
|
+
if (val.payload)
|
|
120
|
+
enqueuedPayloads.push(JSON.parse(val.payload));
|
|
121
|
+
return Promise.resolve(undefined);
|
|
122
|
+
}),
|
|
123
|
+
});
|
|
124
|
+
await handlePayment(db, chargeStore, "bc1qtest", "50000", {
|
|
125
|
+
txHash: "abc123",
|
|
126
|
+
confirmations: 0,
|
|
127
|
+
confirmationsRequired: 6,
|
|
128
|
+
amountReceivedCents: 5000,
|
|
129
|
+
}, noop);
|
|
130
|
+
const validStatuses = ["pending", "partial", "confirmed", "expired", "failed"];
|
|
131
|
+
for (const payload of enqueuedPayloads) {
|
|
132
|
+
expect(validStatuses).toContain(payload.status);
|
|
133
|
+
}
|
|
134
|
+
// Must NEVER contain legacy statuses
|
|
135
|
+
for (const payload of enqueuedPayloads) {
|
|
136
|
+
expect(payload.status).not.toBe("Settled");
|
|
137
|
+
expect(payload.status).not.toBe("Processing");
|
|
138
|
+
expect(payload.status).not.toBe("New");
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
it("updates charge progress via updateProgress()", async () => {
|
|
142
|
+
const chargeStore = mockChargeStore();
|
|
143
|
+
const db = mockDb();
|
|
144
|
+
db.insert = vi.fn().mockReturnValue({
|
|
145
|
+
values: vi.fn().mockResolvedValue(undefined),
|
|
146
|
+
});
|
|
147
|
+
await handlePayment(db, chargeStore, "bc1qtest", "25000", {
|
|
148
|
+
txHash: "abc123",
|
|
149
|
+
confirmations: 2,
|
|
150
|
+
confirmationsRequired: 6,
|
|
151
|
+
amountReceivedCents: 2500,
|
|
152
|
+
}, noop);
|
|
153
|
+
expect(chargeStore.updateProgress).toHaveBeenCalledWith("btc:test", {
|
|
154
|
+
status: "partial",
|
|
155
|
+
amountReceivedCents: 2500,
|
|
156
|
+
confirmations: 2,
|
|
157
|
+
confirmationsRequired: 6,
|
|
158
|
+
txHash: "abc123",
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
it("skips already-credited charges", async () => {
|
|
162
|
+
const chargeStore = mockChargeStore({ creditedAt: "2026-01-01" });
|
|
163
|
+
const db = mockDb();
|
|
164
|
+
await handlePayment(db, chargeStore, "bc1qtest", "50000", { txHash: "abc123", confirmations: 6, confirmationsRequired: 6, amountReceivedCents: 5000 }, noop);
|
|
165
|
+
expect(chargeStore.updateProgress).not.toHaveBeenCalled();
|
|
166
|
+
});
|
|
167
|
+
it("skips unknown addresses", async () => {
|
|
168
|
+
const chargeStore = mockChargeStore();
|
|
169
|
+
chargeStore.getByDepositAddress.mockResolvedValue(null);
|
|
170
|
+
const db = mockDb();
|
|
171
|
+
await handlePayment(db, chargeStore, "bc1qunknown", "50000", { txHash: "abc123", confirmations: 0, confirmationsRequired: 6, amountReceivedCents: 5000 }, noop);
|
|
172
|
+
expect(chargeStore.updateProgress).not.toHaveBeenCalled();
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { handleKeyServerWebhook, normalizeStatus } from "../key-server-webhook.js";
|
|
3
|
+
function mockChargeStore(overrides = {}) {
|
|
4
|
+
return {
|
|
5
|
+
getByReferenceId: vi.fn().mockResolvedValue({
|
|
6
|
+
referenceId: "btc:bc1qtest",
|
|
7
|
+
tenantId: "t1",
|
|
8
|
+
amountUsdCents: 5000,
|
|
9
|
+
creditedAt: null,
|
|
10
|
+
chain: "bitcoin",
|
|
11
|
+
token: "BTC",
|
|
12
|
+
...overrides,
|
|
13
|
+
}),
|
|
14
|
+
updateProgress: vi.fn().mockResolvedValue(undefined),
|
|
15
|
+
updateStatus: vi.fn().mockResolvedValue(undefined),
|
|
16
|
+
markCredited: vi.fn().mockResolvedValue(undefined),
|
|
17
|
+
get: vi.fn().mockResolvedValue(null),
|
|
18
|
+
create: vi.fn(),
|
|
19
|
+
isCredited: vi.fn(),
|
|
20
|
+
createStablecoinCharge: vi.fn(),
|
|
21
|
+
getByDepositAddress: vi.fn(),
|
|
22
|
+
getNextDerivationIndex: vi.fn(),
|
|
23
|
+
listActiveDepositAddresses: vi.fn(),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function mockLedger() {
|
|
27
|
+
return {
|
|
28
|
+
credit: vi.fn().mockResolvedValue({ id: "j1" }),
|
|
29
|
+
debit: vi.fn(),
|
|
30
|
+
balance: vi.fn(),
|
|
31
|
+
hasReferenceId: vi.fn().mockResolvedValue(false),
|
|
32
|
+
post: vi.fn(),
|
|
33
|
+
expiredCredits: vi.fn(),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function mockReplayGuard() {
|
|
37
|
+
return {
|
|
38
|
+
isDuplicate: vi.fn().mockResolvedValue(false),
|
|
39
|
+
markSeen: vi.fn().mockResolvedValue({ eventId: "", source: "", seenAt: 0 }),
|
|
40
|
+
purgeExpired: vi.fn(),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function makeDeps(overrides = {}) {
|
|
44
|
+
return {
|
|
45
|
+
chargeStore: mockChargeStore(),
|
|
46
|
+
creditLedger: mockLedger(),
|
|
47
|
+
replayGuard: mockReplayGuard(),
|
|
48
|
+
...overrides,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
describe("normalizeStatus", () => {
|
|
52
|
+
it("maps canonical statuses through unchanged", () => {
|
|
53
|
+
expect(normalizeStatus("confirmed")).toBe("confirmed");
|
|
54
|
+
expect(normalizeStatus("partial")).toBe("partial");
|
|
55
|
+
expect(normalizeStatus("expired")).toBe("expired");
|
|
56
|
+
expect(normalizeStatus("failed")).toBe("failed");
|
|
57
|
+
expect(normalizeStatus("pending")).toBe("pending");
|
|
58
|
+
});
|
|
59
|
+
it("maps legacy BTCPay statuses to canonical", () => {
|
|
60
|
+
expect(normalizeStatus("Settled")).toBe("confirmed");
|
|
61
|
+
expect(normalizeStatus("Processing")).toBe("partial");
|
|
62
|
+
expect(normalizeStatus("Expired")).toBe("expired");
|
|
63
|
+
expect(normalizeStatus("Invalid")).toBe("failed");
|
|
64
|
+
expect(normalizeStatus("New")).toBe("pending");
|
|
65
|
+
});
|
|
66
|
+
it("maps BTCPay event type strings to canonical", () => {
|
|
67
|
+
expect(normalizeStatus("InvoiceSettled")).toBe("confirmed");
|
|
68
|
+
expect(normalizeStatus("InvoiceProcessing")).toBe("partial");
|
|
69
|
+
expect(normalizeStatus("InvoiceReceivedPayment")).toBe("partial");
|
|
70
|
+
expect(normalizeStatus("InvoiceExpired")).toBe("expired");
|
|
71
|
+
expect(normalizeStatus("InvoiceInvalid")).toBe("failed");
|
|
72
|
+
expect(normalizeStatus("InvoiceCreated")).toBe("pending");
|
|
73
|
+
});
|
|
74
|
+
it("defaults unknown statuses to pending", () => {
|
|
75
|
+
expect(normalizeStatus("SomethingWeird")).toBe("pending");
|
|
76
|
+
expect(normalizeStatus("")).toBe("pending");
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
describe("handleKeyServerWebhook — confirmation tracking", () => {
|
|
80
|
+
it("calls updateProgress on partial payment (not just terminal)", async () => {
|
|
81
|
+
const chargeStore = mockChargeStore();
|
|
82
|
+
const deps = makeDeps({ chargeStore: chargeStore });
|
|
83
|
+
const payload = {
|
|
84
|
+
chargeId: "btc:bc1qtest",
|
|
85
|
+
chain: "bitcoin",
|
|
86
|
+
address: "bc1qtest",
|
|
87
|
+
status: "partial",
|
|
88
|
+
amountReceivedCents: 2500,
|
|
89
|
+
confirmations: 2,
|
|
90
|
+
confirmationsRequired: 6,
|
|
91
|
+
txHash: "0xabc",
|
|
92
|
+
};
|
|
93
|
+
const result = await handleKeyServerWebhook(deps, payload);
|
|
94
|
+
expect(result.handled).toBe(true);
|
|
95
|
+
expect(result.status).toBe("partial");
|
|
96
|
+
expect(result.confirmations).toBe(2);
|
|
97
|
+
expect(result.confirmationsRequired).toBe(6);
|
|
98
|
+
expect(chargeStore.updateProgress).toHaveBeenCalledWith("btc:bc1qtest", {
|
|
99
|
+
status: "partial",
|
|
100
|
+
amountReceivedCents: 2500,
|
|
101
|
+
confirmations: 2,
|
|
102
|
+
confirmationsRequired: 6,
|
|
103
|
+
txHash: "0xabc",
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
it("calls updateProgress AND credits ledger on confirmed", async () => {
|
|
107
|
+
const chargeStore = mockChargeStore();
|
|
108
|
+
const ledger = mockLedger();
|
|
109
|
+
const deps = makeDeps({ chargeStore: chargeStore, creditLedger: ledger });
|
|
110
|
+
const payload = {
|
|
111
|
+
chargeId: "btc:bc1qtest",
|
|
112
|
+
chain: "bitcoin",
|
|
113
|
+
address: "bc1qtest",
|
|
114
|
+
status: "confirmed",
|
|
115
|
+
amountReceivedCents: 5000,
|
|
116
|
+
confirmations: 6,
|
|
117
|
+
confirmationsRequired: 6,
|
|
118
|
+
txHash: "0xfinal",
|
|
119
|
+
};
|
|
120
|
+
const result = await handleKeyServerWebhook(deps, payload);
|
|
121
|
+
expect(result.handled).toBe(true);
|
|
122
|
+
expect(result.status).toBe("confirmed");
|
|
123
|
+
expect(result.creditedCents).toBe(5000);
|
|
124
|
+
expect(chargeStore.updateProgress).toHaveBeenCalledWith("btc:bc1qtest", {
|
|
125
|
+
status: "confirmed",
|
|
126
|
+
amountReceivedCents: 5000,
|
|
127
|
+
confirmations: 6,
|
|
128
|
+
confirmationsRequired: 6,
|
|
129
|
+
txHash: "0xfinal",
|
|
130
|
+
});
|
|
131
|
+
expect(ledger.credit).toHaveBeenCalledOnce();
|
|
132
|
+
expect(chargeStore.markCredited).toHaveBeenCalledWith("btc:bc1qtest");
|
|
133
|
+
});
|
|
134
|
+
it("does NOT credit ledger on partial status", async () => {
|
|
135
|
+
const ledger = mockLedger();
|
|
136
|
+
const deps = makeDeps({ creditLedger: ledger });
|
|
137
|
+
await handleKeyServerWebhook(deps, {
|
|
138
|
+
chargeId: "btc:bc1qtest",
|
|
139
|
+
chain: "bitcoin",
|
|
140
|
+
address: "bc1qtest",
|
|
141
|
+
status: "Processing",
|
|
142
|
+
amountReceivedCents: 2500,
|
|
143
|
+
confirmations: 1,
|
|
144
|
+
confirmationsRequired: 6,
|
|
145
|
+
});
|
|
146
|
+
expect(ledger.credit).not.toHaveBeenCalled();
|
|
147
|
+
});
|
|
148
|
+
it("does NOT credit ledger on expired status", async () => {
|
|
149
|
+
const ledger = mockLedger();
|
|
150
|
+
const deps = makeDeps({ creditLedger: ledger });
|
|
151
|
+
await handleKeyServerWebhook(deps, {
|
|
152
|
+
chargeId: "btc:bc1qtest",
|
|
153
|
+
chain: "bitcoin",
|
|
154
|
+
address: "bc1qtest",
|
|
155
|
+
status: "expired",
|
|
156
|
+
amountReceivedCents: 0,
|
|
157
|
+
confirmations: 0,
|
|
158
|
+
confirmationsRequired: 6,
|
|
159
|
+
});
|
|
160
|
+
expect(ledger.credit).not.toHaveBeenCalled();
|
|
161
|
+
});
|
|
162
|
+
it("normalizes legacy 'Settled' status to 'confirmed' and credits", async () => {
|
|
163
|
+
const chargeStore = mockChargeStore();
|
|
164
|
+
const ledger = mockLedger();
|
|
165
|
+
const deps = makeDeps({ chargeStore: chargeStore, creditLedger: ledger });
|
|
166
|
+
const result = await handleKeyServerWebhook(deps, {
|
|
167
|
+
chargeId: "btc:bc1qtest",
|
|
168
|
+
chain: "bitcoin",
|
|
169
|
+
address: "bc1qtest",
|
|
170
|
+
status: "Settled",
|
|
171
|
+
amountReceivedCents: 5000,
|
|
172
|
+
confirmations: 6,
|
|
173
|
+
confirmationsRequired: 6,
|
|
174
|
+
txHash: "0xlegacy",
|
|
175
|
+
});
|
|
176
|
+
expect(result.status).toBe("confirmed");
|
|
177
|
+
expect(ledger.credit).toHaveBeenCalledOnce();
|
|
178
|
+
expect(chargeStore.updateProgress).toHaveBeenCalledWith("btc:bc1qtest", expect.objectContaining({ status: "confirmed" }));
|
|
179
|
+
});
|
|
180
|
+
it("deduplicates exact same chargeId + status + confirmations", async () => {
|
|
181
|
+
const replayGuard = mockReplayGuard();
|
|
182
|
+
replayGuard.isDuplicate.mockResolvedValue(true);
|
|
183
|
+
const deps = makeDeps({ replayGuard: replayGuard });
|
|
184
|
+
const result = await handleKeyServerWebhook(deps, {
|
|
185
|
+
chargeId: "btc:bc1qtest",
|
|
186
|
+
chain: "bitcoin",
|
|
187
|
+
address: "bc1qtest",
|
|
188
|
+
status: "partial",
|
|
189
|
+
confirmations: 2,
|
|
190
|
+
confirmationsRequired: 6,
|
|
191
|
+
});
|
|
192
|
+
expect(result.duplicate).toBe(true);
|
|
193
|
+
});
|
|
194
|
+
it("allows same charge with different confirmation counts through", async () => {
|
|
195
|
+
const replayGuard = mockReplayGuard();
|
|
196
|
+
const seenKeys = new Set();
|
|
197
|
+
replayGuard.isDuplicate.mockImplementation(async (key) => seenKeys.has(key));
|
|
198
|
+
replayGuard.markSeen.mockImplementation(async (key) => {
|
|
199
|
+
seenKeys.add(key);
|
|
200
|
+
return { eventId: key, source: "crypto", seenAt: 0 };
|
|
201
|
+
});
|
|
202
|
+
const deps = makeDeps({ replayGuard: replayGuard });
|
|
203
|
+
const base = {
|
|
204
|
+
chargeId: "btc:bc1qtest",
|
|
205
|
+
chain: "bitcoin",
|
|
206
|
+
address: "bc1qtest",
|
|
207
|
+
status: "partial",
|
|
208
|
+
amountReceivedCents: 5000,
|
|
209
|
+
confirmationsRequired: 6,
|
|
210
|
+
};
|
|
211
|
+
const r1 = await handleKeyServerWebhook(deps, { ...base, confirmations: 1 });
|
|
212
|
+
const r2 = await handleKeyServerWebhook(deps, { ...base, confirmations: 2 });
|
|
213
|
+
const r3 = await handleKeyServerWebhook(deps, { ...base, confirmations: 1 }); // duplicate
|
|
214
|
+
expect(r1.handled).toBe(true);
|
|
215
|
+
expect(r1.duplicate).toBeUndefined();
|
|
216
|
+
expect(r2.handled).toBe(true);
|
|
217
|
+
expect(r2.duplicate).toBeUndefined();
|
|
218
|
+
expect(r3.duplicate).toBe(true);
|
|
219
|
+
});
|
|
220
|
+
it("supports deprecated amountUsdCents field as fallback", async () => {
|
|
221
|
+
const chargeStore = mockChargeStore();
|
|
222
|
+
const deps = makeDeps({ chargeStore: chargeStore });
|
|
223
|
+
await handleKeyServerWebhook(deps, {
|
|
224
|
+
chargeId: "btc:bc1qtest",
|
|
225
|
+
chain: "bitcoin",
|
|
226
|
+
address: "bc1qtest",
|
|
227
|
+
status: "partial",
|
|
228
|
+
amountUsdCents: 3000,
|
|
229
|
+
confirmations: 1,
|
|
230
|
+
confirmationsRequired: 6,
|
|
231
|
+
});
|
|
232
|
+
expect(chargeStore.updateProgress).toHaveBeenCalledWith("btc:bc1qtest", expect.objectContaining({ amountReceivedCents: 3000 }));
|
|
233
|
+
});
|
|
234
|
+
it("prefers amountReceivedCents over deprecated amountUsdCents", async () => {
|
|
235
|
+
const chargeStore = mockChargeStore();
|
|
236
|
+
const deps = makeDeps({ chargeStore: chargeStore });
|
|
237
|
+
await handleKeyServerWebhook(deps, {
|
|
238
|
+
chargeId: "btc:bc1qtest",
|
|
239
|
+
chain: "bitcoin",
|
|
240
|
+
address: "bc1qtest",
|
|
241
|
+
status: "partial",
|
|
242
|
+
amountReceivedCents: 4000,
|
|
243
|
+
amountUsdCents: 3000,
|
|
244
|
+
confirmations: 1,
|
|
245
|
+
confirmationsRequired: 6,
|
|
246
|
+
});
|
|
247
|
+
expect(chargeStore.updateProgress).toHaveBeenCalledWith("btc:bc1qtest", expect.objectContaining({ amountReceivedCents: 4000 }));
|
|
248
|
+
});
|
|
249
|
+
it("returns handled: false for unknown charges", async () => {
|
|
250
|
+
const chargeStore = mockChargeStore();
|
|
251
|
+
chargeStore.getByReferenceId.mockResolvedValue(null);
|
|
252
|
+
const deps = makeDeps({ chargeStore: chargeStore });
|
|
253
|
+
const result = await handleKeyServerWebhook(deps, {
|
|
254
|
+
chargeId: "unknown",
|
|
255
|
+
chain: "bitcoin",
|
|
256
|
+
address: "bc1qunknown",
|
|
257
|
+
status: "partial",
|
|
258
|
+
});
|
|
259
|
+
expect(result.handled).toBe(false);
|
|
260
|
+
});
|
|
261
|
+
it("defaults confirmations to 0 and confirmationsRequired to 1 when absent", async () => {
|
|
262
|
+
const chargeStore = mockChargeStore();
|
|
263
|
+
const deps = makeDeps({ chargeStore: chargeStore });
|
|
264
|
+
await handleKeyServerWebhook(deps, {
|
|
265
|
+
chargeId: "btc:bc1qtest",
|
|
266
|
+
chain: "bitcoin",
|
|
267
|
+
address: "bc1qtest",
|
|
268
|
+
status: "partial",
|
|
269
|
+
});
|
|
270
|
+
expect(chargeStore.updateProgress).toHaveBeenCalledWith("btc:bc1qtest", expect.objectContaining({ confirmations: 0, confirmationsRequired: 1 }));
|
|
271
|
+
});
|
|
272
|
+
it("also calls legacy updateStatus for backward compat", async () => {
|
|
273
|
+
const chargeStore = mockChargeStore();
|
|
274
|
+
const deps = makeDeps({ chargeStore: chargeStore });
|
|
275
|
+
await handleKeyServerWebhook(deps, {
|
|
276
|
+
chargeId: "btc:bc1qtest",
|
|
277
|
+
chain: "bitcoin",
|
|
278
|
+
address: "bc1qtest",
|
|
279
|
+
status: "partial",
|
|
280
|
+
amountReceived: "25000",
|
|
281
|
+
});
|
|
282
|
+
expect(chargeStore.updateStatus).toHaveBeenCalledWith("btc:bc1qtest", "Processing", "BTC", "25000");
|
|
283
|
+
});
|
|
284
|
+
it("calls onCreditsPurchased on confirmed and returns reactivatedBots", async () => {
|
|
285
|
+
const chargeStore = mockChargeStore();
|
|
286
|
+
const ledger = mockLedger();
|
|
287
|
+
const onCreditsPurchased = vi.fn().mockResolvedValue(["bot-1", "bot-2"]);
|
|
288
|
+
const deps = makeDeps({
|
|
289
|
+
chargeStore: chargeStore,
|
|
290
|
+
creditLedger: ledger,
|
|
291
|
+
onCreditsPurchased,
|
|
292
|
+
});
|
|
293
|
+
const result = await handleKeyServerWebhook(deps, {
|
|
294
|
+
chargeId: "btc:bc1qtest",
|
|
295
|
+
chain: "bitcoin",
|
|
296
|
+
address: "bc1qtest",
|
|
297
|
+
status: "confirmed",
|
|
298
|
+
confirmations: 6,
|
|
299
|
+
confirmationsRequired: 6,
|
|
300
|
+
});
|
|
301
|
+
expect(onCreditsPurchased).toHaveBeenCalledWith("t1", ledger);
|
|
302
|
+
expect(result.reactivatedBots).toEqual(["bot-1", "bot-2"]);
|
|
303
|
+
});
|
|
304
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|