@wopr-network/platform-core 1.47.0 → 1.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/billing/crypto/__tests__/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 +20 -6
- 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-settler.test.js +2 -0
- package/dist/billing/crypto/evm/__tests__/eth-watcher.test.js +31 -4
- 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-watcher.d.ts +11 -8
- package/dist/billing/crypto/evm/eth-watcher.js +27 -13
- 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-webhook.d.ts +17 -4
- package/dist/billing/crypto/key-server-webhook.js +76 -15
- package/dist/billing/crypto/types.d.ts +16 -0
- package/dist/billing/crypto/unified-checkout.d.ts +8 -17
- 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/billing/payment-processor.d.ts +2 -0
- package/dist/billing/payment-processor.test.js +1 -0
- package/dist/billing/stripe/stripe-payment-processor.d.ts +1 -0
- package/dist/billing/stripe/stripe-payment-processor.js +27 -4
- package/dist/billing/stripe/stripe-payment-processor.test.js +95 -1
- 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/dist/monetization/stripe/stripe-payment-processor.d.ts +1 -0
- package/dist/monetization/stripe/stripe-payment-processor.js +25 -3
- package/dist/monetization/stripe/stripe-payment-processor.test.js +73 -1
- package/dist/trpc/org-remove-payment-method-router.test.js +1 -0
- 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__/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 +22 -6
- 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-settler.test.ts +2 -0
- package/src/billing/crypto/evm/__tests__/eth-watcher.test.ts +31 -4
- 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-watcher.ts +34 -14
- 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-webhook.ts +92 -21
- package/src/billing/crypto/types.ts +18 -0
- package/src/billing/crypto/unified-checkout.ts +20 -179
- package/src/billing/crypto/watcher-service.ts +85 -32
- package/src/billing/payment-processor.test.ts +1 -0
- package/src/billing/payment-processor.ts +3 -0
- package/src/billing/stripe/stripe-payment-processor.test.ts +113 -1
- package/src/billing/stripe/stripe-payment-processor.ts +33 -5
- package/src/db/schema/crypto.ts +8 -0
- package/src/monetization/crypto/__tests__/webhook.test.ts +2 -1
- package/src/monetization/stripe/stripe-payment-processor.test.ts +89 -1
- package/src/monetization/stripe/stripe-payment-processor.ts +31 -4
- package/src/trpc/org-remove-payment-method-router.test.ts +1 -0
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { KeyServerWebhookDeps, KeyServerWebhookPayload } from "../key-server-webhook.js";
|
|
3
|
+
import { handleKeyServerWebhook, normalizeStatus } from "../key-server-webhook.js";
|
|
4
|
+
|
|
5
|
+
function mockChargeStore(overrides: Record<string, unknown> = {}) {
|
|
6
|
+
return {
|
|
7
|
+
getByReferenceId: vi.fn().mockResolvedValue({
|
|
8
|
+
referenceId: "btc:bc1qtest",
|
|
9
|
+
tenantId: "t1",
|
|
10
|
+
amountUsdCents: 5000,
|
|
11
|
+
creditedAt: null,
|
|
12
|
+
chain: "bitcoin",
|
|
13
|
+
token: "BTC",
|
|
14
|
+
...overrides,
|
|
15
|
+
}),
|
|
16
|
+
updateProgress: vi.fn().mockResolvedValue(undefined),
|
|
17
|
+
updateStatus: vi.fn().mockResolvedValue(undefined),
|
|
18
|
+
markCredited: vi.fn().mockResolvedValue(undefined),
|
|
19
|
+
get: vi.fn().mockResolvedValue(null),
|
|
20
|
+
create: vi.fn(),
|
|
21
|
+
isCredited: vi.fn(),
|
|
22
|
+
createStablecoinCharge: vi.fn(),
|
|
23
|
+
getByDepositAddress: vi.fn(),
|
|
24
|
+
getNextDerivationIndex: vi.fn(),
|
|
25
|
+
listActiveDepositAddresses: vi.fn(),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function mockLedger() {
|
|
30
|
+
return {
|
|
31
|
+
credit: vi.fn().mockResolvedValue({ id: "j1" }),
|
|
32
|
+
debit: vi.fn(),
|
|
33
|
+
balance: vi.fn(),
|
|
34
|
+
hasReferenceId: vi.fn().mockResolvedValue(false),
|
|
35
|
+
post: vi.fn(),
|
|
36
|
+
expiredCredits: vi.fn(),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function mockReplayGuard() {
|
|
41
|
+
return {
|
|
42
|
+
isDuplicate: vi.fn().mockResolvedValue(false),
|
|
43
|
+
markSeen: vi.fn().mockResolvedValue({ eventId: "", source: "", seenAt: 0 }),
|
|
44
|
+
purgeExpired: vi.fn(),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function makeDeps(overrides: Partial<KeyServerWebhookDeps> = {}): KeyServerWebhookDeps {
|
|
49
|
+
return {
|
|
50
|
+
chargeStore: mockChargeStore() as never,
|
|
51
|
+
creditLedger: mockLedger() as never,
|
|
52
|
+
replayGuard: mockReplayGuard() as never,
|
|
53
|
+
...overrides,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe("normalizeStatus", () => {
|
|
58
|
+
it("maps canonical statuses through unchanged", () => {
|
|
59
|
+
expect(normalizeStatus("confirmed")).toBe("confirmed");
|
|
60
|
+
expect(normalizeStatus("partial")).toBe("partial");
|
|
61
|
+
expect(normalizeStatus("expired")).toBe("expired");
|
|
62
|
+
expect(normalizeStatus("failed")).toBe("failed");
|
|
63
|
+
expect(normalizeStatus("pending")).toBe("pending");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("maps legacy BTCPay statuses to canonical", () => {
|
|
67
|
+
expect(normalizeStatus("Settled")).toBe("confirmed");
|
|
68
|
+
expect(normalizeStatus("Processing")).toBe("partial");
|
|
69
|
+
expect(normalizeStatus("Expired")).toBe("expired");
|
|
70
|
+
expect(normalizeStatus("Invalid")).toBe("failed");
|
|
71
|
+
expect(normalizeStatus("New")).toBe("pending");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("maps BTCPay event type strings to canonical", () => {
|
|
75
|
+
expect(normalizeStatus("InvoiceSettled")).toBe("confirmed");
|
|
76
|
+
expect(normalizeStatus("InvoiceProcessing")).toBe("partial");
|
|
77
|
+
expect(normalizeStatus("InvoiceReceivedPayment")).toBe("partial");
|
|
78
|
+
expect(normalizeStatus("InvoiceExpired")).toBe("expired");
|
|
79
|
+
expect(normalizeStatus("InvoiceInvalid")).toBe("failed");
|
|
80
|
+
expect(normalizeStatus("InvoiceCreated")).toBe("pending");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("defaults unknown statuses to pending", () => {
|
|
84
|
+
expect(normalizeStatus("SomethingWeird")).toBe("pending");
|
|
85
|
+
expect(normalizeStatus("")).toBe("pending");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("handleKeyServerWebhook — confirmation tracking", () => {
|
|
90
|
+
it("calls updateProgress on partial payment (not just terminal)", async () => {
|
|
91
|
+
const chargeStore = mockChargeStore();
|
|
92
|
+
const deps = makeDeps({ chargeStore: chargeStore as never });
|
|
93
|
+
|
|
94
|
+
const payload: KeyServerWebhookPayload = {
|
|
95
|
+
chargeId: "btc:bc1qtest",
|
|
96
|
+
chain: "bitcoin",
|
|
97
|
+
address: "bc1qtest",
|
|
98
|
+
status: "partial",
|
|
99
|
+
amountReceivedCents: 2500,
|
|
100
|
+
confirmations: 2,
|
|
101
|
+
confirmationsRequired: 6,
|
|
102
|
+
txHash: "0xabc",
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const result = await handleKeyServerWebhook(deps, payload);
|
|
106
|
+
|
|
107
|
+
expect(result.handled).toBe(true);
|
|
108
|
+
expect(result.status).toBe("partial");
|
|
109
|
+
expect(result.confirmations).toBe(2);
|
|
110
|
+
expect(result.confirmationsRequired).toBe(6);
|
|
111
|
+
expect(chargeStore.updateProgress).toHaveBeenCalledWith("btc:bc1qtest", {
|
|
112
|
+
status: "partial",
|
|
113
|
+
amountReceivedCents: 2500,
|
|
114
|
+
confirmations: 2,
|
|
115
|
+
confirmationsRequired: 6,
|
|
116
|
+
txHash: "0xabc",
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("calls updateProgress AND credits ledger on confirmed", async () => {
|
|
121
|
+
const chargeStore = mockChargeStore();
|
|
122
|
+
const ledger = mockLedger();
|
|
123
|
+
const deps = makeDeps({ chargeStore: chargeStore as never, creditLedger: ledger as never });
|
|
124
|
+
|
|
125
|
+
const payload: KeyServerWebhookPayload = {
|
|
126
|
+
chargeId: "btc:bc1qtest",
|
|
127
|
+
chain: "bitcoin",
|
|
128
|
+
address: "bc1qtest",
|
|
129
|
+
status: "confirmed",
|
|
130
|
+
amountReceivedCents: 5000,
|
|
131
|
+
confirmations: 6,
|
|
132
|
+
confirmationsRequired: 6,
|
|
133
|
+
txHash: "0xfinal",
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const result = await handleKeyServerWebhook(deps, payload);
|
|
137
|
+
|
|
138
|
+
expect(result.handled).toBe(true);
|
|
139
|
+
expect(result.status).toBe("confirmed");
|
|
140
|
+
expect(result.creditedCents).toBe(5000);
|
|
141
|
+
expect(chargeStore.updateProgress).toHaveBeenCalledWith("btc:bc1qtest", {
|
|
142
|
+
status: "confirmed",
|
|
143
|
+
amountReceivedCents: 5000,
|
|
144
|
+
confirmations: 6,
|
|
145
|
+
confirmationsRequired: 6,
|
|
146
|
+
txHash: "0xfinal",
|
|
147
|
+
});
|
|
148
|
+
expect(ledger.credit).toHaveBeenCalledOnce();
|
|
149
|
+
expect(chargeStore.markCredited).toHaveBeenCalledWith("btc:bc1qtest");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("does NOT credit ledger on partial status", async () => {
|
|
153
|
+
const ledger = mockLedger();
|
|
154
|
+
const deps = makeDeps({ creditLedger: ledger as never });
|
|
155
|
+
|
|
156
|
+
await handleKeyServerWebhook(deps, {
|
|
157
|
+
chargeId: "btc:bc1qtest",
|
|
158
|
+
chain: "bitcoin",
|
|
159
|
+
address: "bc1qtest",
|
|
160
|
+
status: "Processing",
|
|
161
|
+
amountReceivedCents: 2500,
|
|
162
|
+
confirmations: 1,
|
|
163
|
+
confirmationsRequired: 6,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
expect(ledger.credit).not.toHaveBeenCalled();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("does NOT credit ledger on expired status", async () => {
|
|
170
|
+
const ledger = mockLedger();
|
|
171
|
+
const deps = makeDeps({ creditLedger: ledger as never });
|
|
172
|
+
|
|
173
|
+
await handleKeyServerWebhook(deps, {
|
|
174
|
+
chargeId: "btc:bc1qtest",
|
|
175
|
+
chain: "bitcoin",
|
|
176
|
+
address: "bc1qtest",
|
|
177
|
+
status: "expired",
|
|
178
|
+
amountReceivedCents: 0,
|
|
179
|
+
confirmations: 0,
|
|
180
|
+
confirmationsRequired: 6,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
expect(ledger.credit).not.toHaveBeenCalled();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("normalizes legacy 'Settled' status to 'confirmed' and credits", async () => {
|
|
187
|
+
const chargeStore = mockChargeStore();
|
|
188
|
+
const ledger = mockLedger();
|
|
189
|
+
const deps = makeDeps({ chargeStore: chargeStore as never, creditLedger: ledger as never });
|
|
190
|
+
|
|
191
|
+
const result = await handleKeyServerWebhook(deps, {
|
|
192
|
+
chargeId: "btc:bc1qtest",
|
|
193
|
+
chain: "bitcoin",
|
|
194
|
+
address: "bc1qtest",
|
|
195
|
+
status: "Settled",
|
|
196
|
+
amountReceivedCents: 5000,
|
|
197
|
+
confirmations: 6,
|
|
198
|
+
confirmationsRequired: 6,
|
|
199
|
+
txHash: "0xlegacy",
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
expect(result.status).toBe("confirmed");
|
|
203
|
+
expect(ledger.credit).toHaveBeenCalledOnce();
|
|
204
|
+
expect(chargeStore.updateProgress).toHaveBeenCalledWith(
|
|
205
|
+
"btc:bc1qtest",
|
|
206
|
+
expect.objectContaining({ status: "confirmed" }),
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("deduplicates exact same chargeId + status + confirmations", async () => {
|
|
211
|
+
const replayGuard = mockReplayGuard();
|
|
212
|
+
replayGuard.isDuplicate.mockResolvedValue(true);
|
|
213
|
+
const deps = makeDeps({ replayGuard: replayGuard as never });
|
|
214
|
+
|
|
215
|
+
const result = await handleKeyServerWebhook(deps, {
|
|
216
|
+
chargeId: "btc:bc1qtest",
|
|
217
|
+
chain: "bitcoin",
|
|
218
|
+
address: "bc1qtest",
|
|
219
|
+
status: "partial",
|
|
220
|
+
confirmations: 2,
|
|
221
|
+
confirmationsRequired: 6,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
expect(result.duplicate).toBe(true);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("allows same charge with different confirmation counts through", async () => {
|
|
228
|
+
const replayGuard = mockReplayGuard();
|
|
229
|
+
const seenKeys = new Set<string>();
|
|
230
|
+
replayGuard.isDuplicate.mockImplementation(async (key: string) => seenKeys.has(key));
|
|
231
|
+
replayGuard.markSeen.mockImplementation(async (key: string) => {
|
|
232
|
+
seenKeys.add(key);
|
|
233
|
+
return { eventId: key, source: "crypto", seenAt: 0 };
|
|
234
|
+
});
|
|
235
|
+
const deps = makeDeps({ replayGuard: replayGuard as never });
|
|
236
|
+
|
|
237
|
+
const base = {
|
|
238
|
+
chargeId: "btc:bc1qtest",
|
|
239
|
+
chain: "bitcoin",
|
|
240
|
+
address: "bc1qtest",
|
|
241
|
+
status: "partial",
|
|
242
|
+
amountReceivedCents: 5000,
|
|
243
|
+
confirmationsRequired: 6,
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const r1 = await handleKeyServerWebhook(deps, { ...base, confirmations: 1 });
|
|
247
|
+
const r2 = await handleKeyServerWebhook(deps, { ...base, confirmations: 2 });
|
|
248
|
+
const r3 = await handleKeyServerWebhook(deps, { ...base, confirmations: 1 }); // duplicate
|
|
249
|
+
|
|
250
|
+
expect(r1.handled).toBe(true);
|
|
251
|
+
expect(r1.duplicate).toBeUndefined();
|
|
252
|
+
expect(r2.handled).toBe(true);
|
|
253
|
+
expect(r2.duplicate).toBeUndefined();
|
|
254
|
+
expect(r3.duplicate).toBe(true);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("supports deprecated amountUsdCents field as fallback", async () => {
|
|
258
|
+
const chargeStore = mockChargeStore();
|
|
259
|
+
const deps = makeDeps({ chargeStore: chargeStore as never });
|
|
260
|
+
|
|
261
|
+
await handleKeyServerWebhook(deps, {
|
|
262
|
+
chargeId: "btc:bc1qtest",
|
|
263
|
+
chain: "bitcoin",
|
|
264
|
+
address: "bc1qtest",
|
|
265
|
+
status: "partial",
|
|
266
|
+
amountUsdCents: 3000,
|
|
267
|
+
confirmations: 1,
|
|
268
|
+
confirmationsRequired: 6,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
expect(chargeStore.updateProgress).toHaveBeenCalledWith(
|
|
272
|
+
"btc:bc1qtest",
|
|
273
|
+
expect.objectContaining({ amountReceivedCents: 3000 }),
|
|
274
|
+
);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("prefers amountReceivedCents over deprecated amountUsdCents", async () => {
|
|
278
|
+
const chargeStore = mockChargeStore();
|
|
279
|
+
const deps = makeDeps({ chargeStore: chargeStore as never });
|
|
280
|
+
|
|
281
|
+
await handleKeyServerWebhook(deps, {
|
|
282
|
+
chargeId: "btc:bc1qtest",
|
|
283
|
+
chain: "bitcoin",
|
|
284
|
+
address: "bc1qtest",
|
|
285
|
+
status: "partial",
|
|
286
|
+
amountReceivedCents: 4000,
|
|
287
|
+
amountUsdCents: 3000,
|
|
288
|
+
confirmations: 1,
|
|
289
|
+
confirmationsRequired: 6,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
expect(chargeStore.updateProgress).toHaveBeenCalledWith(
|
|
293
|
+
"btc:bc1qtest",
|
|
294
|
+
expect.objectContaining({ amountReceivedCents: 4000 }),
|
|
295
|
+
);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("returns handled: false for unknown charges", async () => {
|
|
299
|
+
const chargeStore = mockChargeStore();
|
|
300
|
+
chargeStore.getByReferenceId.mockResolvedValue(null);
|
|
301
|
+
const deps = makeDeps({ chargeStore: chargeStore as never });
|
|
302
|
+
|
|
303
|
+
const result = await handleKeyServerWebhook(deps, {
|
|
304
|
+
chargeId: "unknown",
|
|
305
|
+
chain: "bitcoin",
|
|
306
|
+
address: "bc1qunknown",
|
|
307
|
+
status: "partial",
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
expect(result.handled).toBe(false);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("defaults confirmations to 0 and confirmationsRequired to 1 when absent", async () => {
|
|
314
|
+
const chargeStore = mockChargeStore();
|
|
315
|
+
const deps = makeDeps({ chargeStore: chargeStore as never });
|
|
316
|
+
|
|
317
|
+
await handleKeyServerWebhook(deps, {
|
|
318
|
+
chargeId: "btc:bc1qtest",
|
|
319
|
+
chain: "bitcoin",
|
|
320
|
+
address: "bc1qtest",
|
|
321
|
+
status: "partial",
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
expect(chargeStore.updateProgress).toHaveBeenCalledWith(
|
|
325
|
+
"btc:bc1qtest",
|
|
326
|
+
expect.objectContaining({ confirmations: 0, confirmationsRequired: 1 }),
|
|
327
|
+
);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("also calls legacy updateStatus for backward compat", async () => {
|
|
331
|
+
const chargeStore = mockChargeStore();
|
|
332
|
+
const deps = makeDeps({ chargeStore: chargeStore as never });
|
|
333
|
+
|
|
334
|
+
await handleKeyServerWebhook(deps, {
|
|
335
|
+
chargeId: "btc:bc1qtest",
|
|
336
|
+
chain: "bitcoin",
|
|
337
|
+
address: "bc1qtest",
|
|
338
|
+
status: "partial",
|
|
339
|
+
amountReceived: "25000",
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
expect(chargeStore.updateStatus).toHaveBeenCalledWith("btc:bc1qtest", "Processing", "BTC", "25000");
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("calls onCreditsPurchased on confirmed and returns reactivatedBots", async () => {
|
|
346
|
+
const chargeStore = mockChargeStore();
|
|
347
|
+
const ledger = mockLedger();
|
|
348
|
+
const onCreditsPurchased = vi.fn().mockResolvedValue(["bot-1", "bot-2"]);
|
|
349
|
+
const deps = makeDeps({
|
|
350
|
+
chargeStore: chargeStore as never,
|
|
351
|
+
creditLedger: ledger as never,
|
|
352
|
+
onCreditsPurchased,
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
const result = await handleKeyServerWebhook(deps, {
|
|
356
|
+
chargeId: "btc:bc1qtest",
|
|
357
|
+
chain: "bitcoin",
|
|
358
|
+
address: "bc1qtest",
|
|
359
|
+
status: "confirmed",
|
|
360
|
+
confirmations: 6,
|
|
361
|
+
confirmationsRequired: 6,
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
expect(onCreditsPurchased).toHaveBeenCalledWith("t1", ledger);
|
|
365
|
+
expect(result.reactivatedBots).toEqual(["bot-1", "bot-2"]);
|
|
366
|
+
});
|
|
367
|
+
});
|