@wopr-network/platform-core 1.42.3 → 1.44.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/.github/workflows/key-server-image.yml +35 -0
- package/Dockerfile.key-server +20 -0
- package/GATEWAY_BILLING_RESEARCH.md +430 -0
- package/biome.json +2 -9
- package/dist/billing/crypto/__tests__/key-server.test.js +240 -0
- package/dist/billing/crypto/btc/watcher.d.ts +2 -0
- package/dist/billing/crypto/btc/watcher.js +1 -1
- package/dist/billing/crypto/charge-store.d.ts +7 -1
- package/dist/billing/crypto/charge-store.js +7 -1
- package/dist/billing/crypto/client.d.ts +68 -30
- package/dist/billing/crypto/client.js +63 -46
- package/dist/billing/crypto/client.test.js +66 -83
- package/dist/billing/crypto/index.d.ts +8 -8
- package/dist/billing/crypto/index.js +4 -5
- package/dist/billing/crypto/key-server-entry.js +84 -0
- package/dist/billing/crypto/key-server-webhook.d.ts +33 -0
- package/dist/billing/crypto/key-server-webhook.js +73 -0
- package/dist/billing/crypto/key-server.d.ts +20 -0
- package/dist/billing/crypto/key-server.js +263 -0
- package/dist/billing/crypto/watcher-service.d.ts +33 -0
- package/dist/billing/crypto/watcher-service.js +295 -0
- package/dist/billing/index.js +1 -1
- package/dist/db/schema/crypto.d.ts +464 -2
- package/dist/db/schema/crypto.js +60 -6
- package/dist/monetization/crypto/__tests__/webhook.test.js +57 -92
- package/dist/monetization/crypto/index.d.ts +4 -4
- package/dist/monetization/crypto/index.js +2 -2
- package/dist/monetization/crypto/webhook.d.ts +13 -14
- package/dist/monetization/crypto/webhook.js +12 -83
- package/dist/monetization/index.d.ts +2 -2
- package/dist/monetization/index.js +1 -1
- package/drizzle/migrations/0014_crypto_key_server.sql +60 -0
- package/drizzle/migrations/0015_callback_url.sql +32 -0
- package/drizzle/migrations/meta/_journal.json +28 -0
- package/package.json +2 -1
- package/src/billing/crypto/__tests__/key-server.test.ts +262 -0
- package/src/billing/crypto/btc/watcher.ts +3 -1
- package/src/billing/crypto/charge-store.ts +13 -1
- package/src/billing/crypto/client.test.ts +70 -98
- package/src/billing/crypto/client.ts +118 -59
- package/src/billing/crypto/index.ts +19 -14
- package/src/billing/crypto/key-server-entry.ts +96 -0
- package/src/billing/crypto/key-server-webhook.ts +119 -0
- package/src/billing/crypto/key-server.ts +343 -0
- package/src/billing/crypto/watcher-service.ts +381 -0
- package/src/billing/index.ts +1 -1
- package/src/db/schema/crypto.ts +75 -6
- package/src/monetization/crypto/__tests__/webhook.test.ts +61 -104
- package/src/monetization/crypto/index.ts +9 -11
- package/src/monetization/crypto/webhook.ts +25 -99
- package/src/monetization/index.ts +3 -7
- package/dist/billing/crypto/checkout.d.ts +0 -18
- package/dist/billing/crypto/checkout.js +0 -35
- package/dist/billing/crypto/checkout.test.js +0 -71
- package/dist/billing/crypto/webhook.d.ts +0 -34
- package/dist/billing/crypto/webhook.js +0 -107
- package/dist/billing/crypto/webhook.test.js +0 -266
- package/src/billing/crypto/checkout.test.ts +0 -93
- package/src/billing/crypto/checkout.ts +0 -48
- package/src/billing/crypto/webhook.test.ts +0 -340
- package/src/billing/crypto/webhook.ts +0 -136
- /package/dist/billing/crypto/{checkout.test.d.ts → __tests__/key-server.test.d.ts} +0 -0
- /package/dist/billing/crypto/{webhook.test.d.ts → key-server-entry.d.ts} +0 -0
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
* Unit tests for the monetization crypto webhook handler.
|
|
3
3
|
*
|
|
4
4
|
* This handler is the WOPR-specific layer on top of the platform-core
|
|
5
|
-
* billing/crypto webhook. Key differences from billing
|
|
5
|
+
* billing/crypto key-server webhook. Key differences from billing layer:
|
|
6
6
|
* - Uses BotBilling.checkReactivation() instead of onCreditsPurchased callback
|
|
7
7
|
* - Imports charge store / replay guard types from billing layer (relative)
|
|
8
8
|
*/
|
|
9
9
|
import type { PGlite } from "@electric-sql/pglite";
|
|
10
10
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
11
11
|
import { CryptoChargeRepository } from "../../../billing/crypto/charge-store.js";
|
|
12
|
-
import type { CryptoWebhookPayload } from "../../../billing/crypto/
|
|
12
|
+
import type { CryptoWebhookPayload } from "../../../billing/crypto/index.js";
|
|
13
13
|
import { DrizzleWebhookSeenRepository } from "../../../billing/drizzle-webhook-seen-repository.js";
|
|
14
14
|
import { noOpReplayGuard } from "../../../billing/webhook-seen-repository.js";
|
|
15
15
|
import { DrizzleLedger } from "../../../credits/ledger.js";
|
|
@@ -20,15 +20,12 @@ import { handleCryptoWebhook } from "../webhook.js";
|
|
|
20
20
|
|
|
21
21
|
function makePayload(overrides: Partial<CryptoWebhookPayload> = {}): CryptoWebhookPayload {
|
|
22
22
|
return {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
storeId: "store-test",
|
|
30
|
-
invoiceId: "inv-test-001",
|
|
31
|
-
metadata: { orderId: "order-001" },
|
|
23
|
+
chargeId: "chg-test-001",
|
|
24
|
+
chain: "bitcoin",
|
|
25
|
+
address: "bc1q-test-address",
|
|
26
|
+
amountUsdCents: 2500,
|
|
27
|
+
status: "confirmed",
|
|
28
|
+
txHash: "tx-abc123",
|
|
32
29
|
...overrides,
|
|
33
30
|
};
|
|
34
31
|
}
|
|
@@ -57,19 +54,18 @@ describe("handleCryptoWebhook (monetization layer)", () => {
|
|
|
57
54
|
deps = { chargeStore, creditLedger, replayGuard: noOpReplayGuard };
|
|
58
55
|
|
|
59
56
|
// Default test charge: $25 = 2500 cents
|
|
60
|
-
await chargeStore.create("
|
|
57
|
+
await chargeStore.create("chg-test-001", "tenant-a", 2500);
|
|
61
58
|
});
|
|
62
59
|
|
|
63
60
|
// ---------------------------------------------------------------------------
|
|
64
|
-
//
|
|
61
|
+
// confirmed — credits ledger
|
|
65
62
|
// ---------------------------------------------------------------------------
|
|
66
63
|
|
|
67
|
-
describe("
|
|
64
|
+
describe("confirmed status", () => {
|
|
68
65
|
it("credits the ledger with the USD amount in cents", async () => {
|
|
69
|
-
const result = await handleCryptoWebhook(deps, makePayload({
|
|
66
|
+
const result = await handleCryptoWebhook(deps, makePayload({ status: "confirmed" }));
|
|
70
67
|
|
|
71
68
|
expect(result.handled).toBe(true);
|
|
72
|
-
expect(result.status).toBe("Settled");
|
|
73
69
|
expect(result.tenant).toBe("tenant-a");
|
|
74
70
|
expect(result.creditedCents).toBe(2500);
|
|
75
71
|
|
|
@@ -77,33 +73,33 @@ describe("handleCryptoWebhook (monetization layer)", () => {
|
|
|
77
73
|
expect(balance.toCents()).toBe(2500);
|
|
78
74
|
});
|
|
79
75
|
|
|
80
|
-
it("marks the charge as credited after
|
|
81
|
-
await handleCryptoWebhook(deps, makePayload({
|
|
82
|
-
expect(await chargeStore.isCredited("
|
|
76
|
+
it("marks the charge as credited after confirmation", async () => {
|
|
77
|
+
await handleCryptoWebhook(deps, makePayload({ status: "confirmed" }));
|
|
78
|
+
expect(await chargeStore.isCredited("chg-test-001")).toBe(true);
|
|
83
79
|
});
|
|
84
80
|
|
|
85
81
|
it("uses crypto: prefix on reference ID in ledger entry", async () => {
|
|
86
|
-
await handleCryptoWebhook(deps, makePayload({
|
|
82
|
+
await handleCryptoWebhook(deps, makePayload({ status: "confirmed" }));
|
|
87
83
|
|
|
88
84
|
const history = await creditLedger.history("tenant-a");
|
|
89
85
|
expect(history).toHaveLength(1);
|
|
90
|
-
expect(history[0].referenceId).toBe("crypto:
|
|
86
|
+
expect(history[0].referenceId).toBe("crypto:chg-test-001");
|
|
91
87
|
expect(history[0].entryType).toBe("purchase");
|
|
92
88
|
});
|
|
93
89
|
|
|
94
90
|
it("records fundingSource as crypto", async () => {
|
|
95
|
-
await handleCryptoWebhook(deps, makePayload({
|
|
91
|
+
await handleCryptoWebhook(deps, makePayload({ status: "confirmed" }));
|
|
96
92
|
|
|
97
93
|
const history = await creditLedger.history("tenant-a");
|
|
98
94
|
expect(history[0].metadata?.fundingSource).toBe("crypto");
|
|
99
95
|
});
|
|
100
96
|
|
|
101
|
-
it("is idempotent — second
|
|
102
|
-
await handleCryptoWebhook(deps, makePayload({
|
|
103
|
-
const result2 = await handleCryptoWebhook(deps, makePayload({
|
|
97
|
+
it("is idempotent — second confirmed does not double-credit", async () => {
|
|
98
|
+
await handleCryptoWebhook(deps, makePayload({ status: "confirmed" }));
|
|
99
|
+
const result2 = await handleCryptoWebhook(deps, makePayload({ status: "confirmed" }));
|
|
104
100
|
|
|
105
101
|
expect(result2.handled).toBe(true);
|
|
106
|
-
expect(result2.creditedCents).
|
|
102
|
+
expect(result2.creditedCents).toBeUndefined();
|
|
107
103
|
|
|
108
104
|
// Balance is still $25, not $50
|
|
109
105
|
const balance = await creditLedger.balance("tenant-a");
|
|
@@ -112,12 +108,12 @@ describe("handleCryptoWebhook (monetization layer)", () => {
|
|
|
112
108
|
});
|
|
113
109
|
|
|
114
110
|
// ---------------------------------------------------------------------------
|
|
115
|
-
// Non-
|
|
111
|
+
// Non-confirmed statuses — no ledger credit
|
|
116
112
|
// ---------------------------------------------------------------------------
|
|
117
113
|
|
|
118
|
-
describe("
|
|
114
|
+
describe("pending status", () => {
|
|
119
115
|
it("does NOT credit the ledger", async () => {
|
|
120
|
-
const result = await handleCryptoWebhook(deps, makePayload({
|
|
116
|
+
const result = await handleCryptoWebhook(deps, makePayload({ status: "pending" }));
|
|
121
117
|
|
|
122
118
|
expect(result.handled).toBe(true);
|
|
123
119
|
expect(result.tenant).toBe("tenant-a");
|
|
@@ -126,9 +122,9 @@ describe("handleCryptoWebhook (monetization layer)", () => {
|
|
|
126
122
|
});
|
|
127
123
|
});
|
|
128
124
|
|
|
129
|
-
describe("
|
|
125
|
+
describe("expired status", () => {
|
|
130
126
|
it("does NOT credit the ledger", async () => {
|
|
131
|
-
const result = await handleCryptoWebhook(deps, makePayload({
|
|
127
|
+
const result = await handleCryptoWebhook(deps, makePayload({ status: "expired" }));
|
|
132
128
|
|
|
133
129
|
expect(result.handled).toBe(true);
|
|
134
130
|
expect(result.creditedCents).toBeUndefined();
|
|
@@ -136,19 +132,9 @@ describe("handleCryptoWebhook (monetization layer)", () => {
|
|
|
136
132
|
});
|
|
137
133
|
});
|
|
138
134
|
|
|
139
|
-
describe("
|
|
135
|
+
describe("failed status", () => {
|
|
140
136
|
it("does NOT credit the ledger", async () => {
|
|
141
|
-
const result = await handleCryptoWebhook(deps, makePayload({
|
|
142
|
-
|
|
143
|
-
expect(result.handled).toBe(true);
|
|
144
|
-
expect(result.creditedCents).toBeUndefined();
|
|
145
|
-
expect((await creditLedger.balance("tenant-a")).toCents()).toBe(0);
|
|
146
|
-
});
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
describe("InvoiceInvalid", () => {
|
|
150
|
-
it("does NOT credit the ledger", async () => {
|
|
151
|
-
const result = await handleCryptoWebhook(deps, makePayload({ type: "InvoiceInvalid" }));
|
|
137
|
+
const result = await handleCryptoWebhook(deps, makePayload({ status: "failed" }));
|
|
152
138
|
|
|
153
139
|
expect(result.handled).toBe(true);
|
|
154
140
|
expect(result.creditedCents).toBeUndefined();
|
|
@@ -157,53 +143,35 @@ describe("handleCryptoWebhook (monetization layer)", () => {
|
|
|
157
143
|
});
|
|
158
144
|
|
|
159
145
|
// ---------------------------------------------------------------------------
|
|
160
|
-
// Unknown
|
|
146
|
+
// Unknown chargeId — returns handled:false
|
|
161
147
|
// ---------------------------------------------------------------------------
|
|
162
148
|
|
|
163
149
|
describe("missing charge", () => {
|
|
164
|
-
it("returns handled:false when
|
|
165
|
-
const result = await handleCryptoWebhook(deps, makePayload({
|
|
150
|
+
it("returns handled:false when chargeId is not in the charge store", async () => {
|
|
151
|
+
const result = await handleCryptoWebhook(deps, makePayload({ chargeId: "chg-unknown-999" }));
|
|
166
152
|
|
|
167
153
|
expect(result.handled).toBe(false);
|
|
168
154
|
expect(result.tenant).toBeUndefined();
|
|
169
155
|
});
|
|
170
156
|
});
|
|
171
157
|
|
|
172
|
-
// ---------------------------------------------------------------------------
|
|
173
|
-
// Status mapping for all known event types
|
|
174
|
-
// ---------------------------------------------------------------------------
|
|
175
|
-
|
|
176
|
-
describe("status mapping", () => {
|
|
177
|
-
it.each([
|
|
178
|
-
["InvoiceCreated", "New"],
|
|
179
|
-
["InvoiceProcessing", "Processing"],
|
|
180
|
-
["InvoiceReceivedPayment", "Processing"],
|
|
181
|
-
["InvoiceSettled", "Settled"],
|
|
182
|
-
["InvoicePaymentSettled", "Settled"],
|
|
183
|
-
["InvoiceExpired", "Expired"],
|
|
184
|
-
["InvoiceInvalid", "Invalid"],
|
|
185
|
-
] as const)("maps %s event to %s status", async (eventType, expectedStatus) => {
|
|
186
|
-
const result = await handleCryptoWebhook(deps, makePayload({ type: eventType }));
|
|
187
|
-
expect(result.status).toBe(expectedStatus);
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
it("throws on unknown event types", async () => {
|
|
191
|
-
await expect(handleCryptoWebhook(deps, makePayload({ type: "InvoiceSomeUnknownEvent" }))).rejects.toThrow(
|
|
192
|
-
"Unknown BTCPay event type: InvoiceSomeUnknownEvent",
|
|
193
|
-
);
|
|
194
|
-
});
|
|
195
|
-
});
|
|
196
|
-
|
|
197
158
|
// ---------------------------------------------------------------------------
|
|
198
159
|
// Charge store status updates
|
|
199
160
|
// ---------------------------------------------------------------------------
|
|
200
161
|
|
|
201
162
|
describe("charge store updates", () => {
|
|
202
163
|
it("updates charge status on every webhook call", async () => {
|
|
203
|
-
await handleCryptoWebhook(deps, makePayload({
|
|
164
|
+
await handleCryptoWebhook(deps, makePayload({ status: "partial" }));
|
|
204
165
|
|
|
205
|
-
const charge = await chargeStore.getByReferenceId("
|
|
206
|
-
expect(charge?.status).toBe("
|
|
166
|
+
const charge = await chargeStore.getByReferenceId("chg-test-001");
|
|
167
|
+
expect(charge?.status).toBe("partial");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("settles charge when status is confirmed", async () => {
|
|
171
|
+
await handleCryptoWebhook(deps, makePayload({ status: "confirmed" }));
|
|
172
|
+
|
|
173
|
+
const charge = await chargeStore.getByReferenceId("chg-test-001");
|
|
174
|
+
expect(charge?.status).toBe("Settled");
|
|
207
175
|
});
|
|
208
176
|
});
|
|
209
177
|
|
|
@@ -212,16 +180,16 @@ describe("handleCryptoWebhook (monetization layer)", () => {
|
|
|
212
180
|
// ---------------------------------------------------------------------------
|
|
213
181
|
|
|
214
182
|
describe("replay guard", () => {
|
|
215
|
-
it("blocks duplicate
|
|
183
|
+
it("blocks duplicate chargeId via ks: dedupe key", async () => {
|
|
216
184
|
const replayGuard = new DrizzleWebhookSeenRepository(db);
|
|
217
185
|
const depsWithGuard: CryptoWebhookDeps = { ...deps, replayGuard };
|
|
218
186
|
|
|
219
|
-
const first = await handleCryptoWebhook(depsWithGuard, makePayload({
|
|
187
|
+
const first = await handleCryptoWebhook(depsWithGuard, makePayload({ status: "confirmed" }));
|
|
220
188
|
expect(first.handled).toBe(true);
|
|
221
189
|
expect(first.creditedCents).toBe(2500);
|
|
222
190
|
expect(first.duplicate).toBeUndefined();
|
|
223
191
|
|
|
224
|
-
const second = await handleCryptoWebhook(depsWithGuard, makePayload({
|
|
192
|
+
const second = await handleCryptoWebhook(depsWithGuard, makePayload({ status: "confirmed" }));
|
|
225
193
|
expect(second.handled).toBe(true);
|
|
226
194
|
expect(second.duplicate).toBe(true);
|
|
227
195
|
expect(second.creditedCents).toBeUndefined();
|
|
@@ -229,17 +197,6 @@ describe("handleCryptoWebhook (monetization layer)", () => {
|
|
|
229
197
|
// Balance is still $25, not $50
|
|
230
198
|
expect((await creditLedger.balance("tenant-a")).toCents()).toBe(2500);
|
|
231
199
|
});
|
|
232
|
-
|
|
233
|
-
it("same invoice with a different event type is not blocked", async () => {
|
|
234
|
-
const replayGuard = new DrizzleWebhookSeenRepository(db);
|
|
235
|
-
const depsWithGuard: CryptoWebhookDeps = { ...deps, replayGuard };
|
|
236
|
-
|
|
237
|
-
await handleCryptoWebhook(depsWithGuard, makePayload({ type: "InvoiceProcessing" }));
|
|
238
|
-
const result = await handleCryptoWebhook(depsWithGuard, makePayload({ type: "InvoiceSettled" }));
|
|
239
|
-
|
|
240
|
-
expect(result.duplicate).toBeUndefined();
|
|
241
|
-
expect(result.creditedCents).toBe(2500);
|
|
242
|
-
});
|
|
243
200
|
});
|
|
244
201
|
|
|
245
202
|
// ---------------------------------------------------------------------------
|
|
@@ -247,13 +204,13 @@ describe("handleCryptoWebhook (monetization layer)", () => {
|
|
|
247
204
|
// ---------------------------------------------------------------------------
|
|
248
205
|
|
|
249
206
|
describe("BotBilling reactivation", () => {
|
|
250
|
-
it("calls botBilling.checkReactivation on
|
|
207
|
+
it("calls botBilling.checkReactivation on confirmed and returns reactivatedBots", async () => {
|
|
251
208
|
const mockBotBilling = {
|
|
252
209
|
checkReactivation: vi.fn().mockResolvedValue(["bot-1", "bot-2"]),
|
|
253
210
|
} as unknown as BotBilling;
|
|
254
211
|
const depsWithBots: CryptoWebhookDeps = { ...deps, botBilling: mockBotBilling };
|
|
255
212
|
|
|
256
|
-
const result = await handleCryptoWebhook(depsWithBots, makePayload({
|
|
213
|
+
const result = await handleCryptoWebhook(depsWithBots, makePayload({ status: "confirmed" }));
|
|
257
214
|
|
|
258
215
|
expect(mockBotBilling.checkReactivation).toHaveBeenCalledWith("tenant-a", creditLedger);
|
|
259
216
|
expect(result.reactivatedBots).toEqual(["bot-1", "bot-2"]);
|
|
@@ -265,18 +222,18 @@ describe("handleCryptoWebhook (monetization layer)", () => {
|
|
|
265
222
|
} as unknown as BotBilling;
|
|
266
223
|
const depsWithBots: CryptoWebhookDeps = { ...deps, botBilling: mockBotBilling };
|
|
267
224
|
|
|
268
|
-
const result = await handleCryptoWebhook(depsWithBots, makePayload({
|
|
225
|
+
const result = await handleCryptoWebhook(depsWithBots, makePayload({ status: "confirmed" }));
|
|
269
226
|
|
|
270
227
|
expect(result.reactivatedBots).toBeUndefined();
|
|
271
228
|
});
|
|
272
229
|
|
|
273
|
-
it("does NOT call botBilling on non-
|
|
230
|
+
it("does NOT call botBilling on non-confirmed statuses", async () => {
|
|
274
231
|
const mockBotBilling = {
|
|
275
232
|
checkReactivation: vi.fn().mockResolvedValue(["bot-1"]),
|
|
276
233
|
} as unknown as BotBilling;
|
|
277
234
|
const depsWithBots: CryptoWebhookDeps = { ...deps, botBilling: mockBotBilling };
|
|
278
235
|
|
|
279
|
-
await handleCryptoWebhook(depsWithBots, makePayload({
|
|
236
|
+
await handleCryptoWebhook(depsWithBots, makePayload({ status: "pending" }));
|
|
280
237
|
|
|
281
238
|
expect(mockBotBilling.checkReactivation).not.toHaveBeenCalled();
|
|
282
239
|
});
|
|
@@ -287,18 +244,18 @@ describe("handleCryptoWebhook (monetization layer)", () => {
|
|
|
287
244
|
} as unknown as BotBilling;
|
|
288
245
|
const depsWithBots: CryptoWebhookDeps = { ...deps, botBilling: mockBotBilling };
|
|
289
246
|
|
|
290
|
-
// First
|
|
291
|
-
await handleCryptoWebhook(depsWithBots, makePayload({
|
|
247
|
+
// First confirmation — should call reactivation
|
|
248
|
+
await handleCryptoWebhook(depsWithBots, makePayload({ status: "confirmed" }));
|
|
292
249
|
expect(mockBotBilling.checkReactivation).toHaveBeenCalledTimes(1);
|
|
293
250
|
|
|
294
|
-
// Second
|
|
295
|
-
await handleCryptoWebhook(depsWithBots, makePayload({
|
|
251
|
+
// Second confirmation — charge already credited, should NOT call reactivation again
|
|
252
|
+
await handleCryptoWebhook(depsWithBots, makePayload({ status: "confirmed" }));
|
|
296
253
|
expect(mockBotBilling.checkReactivation).toHaveBeenCalledTimes(1);
|
|
297
254
|
});
|
|
298
255
|
|
|
299
256
|
it("operates correctly when botBilling is not provided", async () => {
|
|
300
257
|
// No botBilling dependency — should complete without error
|
|
301
|
-
const result = await handleCryptoWebhook(deps, makePayload({
|
|
258
|
+
const result = await handleCryptoWebhook(deps, makePayload({ status: "confirmed" }));
|
|
302
259
|
|
|
303
260
|
expect(result.handled).toBe(true);
|
|
304
261
|
expect(result.creditedCents).toBe(2500);
|
|
@@ -311,16 +268,16 @@ describe("handleCryptoWebhook (monetization layer)", () => {
|
|
|
311
268
|
// ---------------------------------------------------------------------------
|
|
312
269
|
|
|
313
270
|
describe("multiple tenants", () => {
|
|
314
|
-
it("processes
|
|
315
|
-
await chargeStore.create("
|
|
316
|
-
await chargeStore.create("
|
|
271
|
+
it("processes charges for different tenants independently", async () => {
|
|
272
|
+
await chargeStore.create("chg-b-001", "tenant-b", 5000);
|
|
273
|
+
await chargeStore.create("chg-c-001", "tenant-c", 1500);
|
|
317
274
|
|
|
318
|
-
await handleCryptoWebhook(deps, makePayload({
|
|
319
|
-
await handleCryptoWebhook(deps, makePayload({
|
|
275
|
+
await handleCryptoWebhook(deps, makePayload({ chargeId: "chg-b-001", status: "confirmed" }));
|
|
276
|
+
await handleCryptoWebhook(deps, makePayload({ chargeId: "chg-c-001", status: "confirmed" }));
|
|
320
277
|
|
|
321
278
|
expect((await creditLedger.balance("tenant-b")).toCents()).toBe(5000);
|
|
322
279
|
expect((await creditLedger.balance("tenant-c")).toCents()).toBe(1500);
|
|
323
|
-
// Original tenant-a was not
|
|
280
|
+
// Original tenant-a was not confirmed in this test
|
|
324
281
|
expect((await creditLedger.balance("tenant-a")).toCents()).toBe(0);
|
|
325
282
|
});
|
|
326
283
|
});
|
|
@@ -1,23 +1,21 @@
|
|
|
1
|
-
// Re-export
|
|
1
|
+
// Re-export from billing/crypto module.
|
|
2
2
|
export type {
|
|
3
|
-
CryptoBillingConfig,
|
|
4
3
|
CryptoChargeRecord,
|
|
5
|
-
CryptoCheckoutOpts,
|
|
6
4
|
CryptoConfig,
|
|
7
5
|
CryptoPaymentState,
|
|
6
|
+
CryptoWebhookDeps,
|
|
8
7
|
CryptoWebhookPayload,
|
|
9
8
|
CryptoWebhookResult,
|
|
10
9
|
ICryptoChargeRepository,
|
|
11
|
-
} from "
|
|
10
|
+
} from "../../billing/crypto/index.js";
|
|
12
11
|
export {
|
|
13
|
-
BTCPayClient,
|
|
14
12
|
CryptoChargeRepository,
|
|
15
|
-
|
|
13
|
+
CryptoServiceClient,
|
|
16
14
|
DrizzleCryptoChargeRepository,
|
|
15
|
+
handleCryptoWebhook,
|
|
16
|
+
handleKeyServerWebhook,
|
|
17
17
|
loadCryptoConfig,
|
|
18
18
|
MIN_PAYMENT_USD,
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
} from "
|
|
22
|
-
export type { CryptoWebhookDeps } from "./webhook.js";
|
|
23
|
-
export { handleCryptoWebhook } from "./webhook.js";
|
|
19
|
+
} from "../../billing/crypto/index.js";
|
|
20
|
+
export type { CryptoWebhookDeps as WoprCryptoWebhookDeps } from "./webhook.js";
|
|
21
|
+
export { handleCryptoWebhook as handleWoprCryptoWebhook } from "./webhook.js";
|
|
@@ -1,12 +1,7 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
IWebhookSeenRepository,
|
|
6
|
-
} from "@wopr-network/platform-core/billing";
|
|
7
|
-
import { mapBtcPayEventToStatus } from "@wopr-network/platform-core/billing";
|
|
8
|
-
import type { ILedger } from "@wopr-network/platform-core/credits";
|
|
9
|
-
import { Credit } from "@wopr-network/platform-core/credits";
|
|
1
|
+
import type { CryptoWebhookPayload, ICryptoChargeRepository } from "../../billing/crypto/index.js";
|
|
2
|
+
import { handleKeyServerWebhook } from "../../billing/crypto/key-server-webhook.js";
|
|
3
|
+
import type { IWebhookSeenRepository } from "../../billing/webhook-seen-repository.js";
|
|
4
|
+
import type { ILedger } from "../../credits/ledger.js";
|
|
10
5
|
import type { BotBilling } from "../credits/bot-billing.js";
|
|
11
6
|
|
|
12
7
|
export interface CryptoWebhookDeps {
|
|
@@ -17,99 +12,30 @@ export interface CryptoWebhookDeps {
|
|
|
17
12
|
}
|
|
18
13
|
|
|
19
14
|
/**
|
|
20
|
-
* Process a
|
|
15
|
+
* Process a crypto payment webhook from the key server (WOPR-specific version).
|
|
21
16
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* Idempotency strategy (matches Stripe webhook pattern):
|
|
26
|
-
* Primary: `creditLedger.hasReferenceId("crypto:<invoiceId>")` — atomic,
|
|
27
|
-
* checked inside the ledger's serialized transaction.
|
|
28
|
-
* Secondary: `chargeStore.markCredited()` — advisory flag for queries.
|
|
29
|
-
*
|
|
30
|
-
* CRITICAL: charge.amountUsdCents is in USD cents (integer).
|
|
31
|
-
* Credit.fromCents() converts cents → nanodollars for the ledger.
|
|
17
|
+
* Delegates to handleKeyServerWebhook() for charge lookup, ledger crediting,
|
|
18
|
+
* and idempotency. Adds WOPR-specific bot reactivation via botBilling.
|
|
32
19
|
*/
|
|
33
20
|
export async function handleCryptoWebhook(
|
|
34
21
|
deps: CryptoWebhookDeps,
|
|
35
22
|
payload: CryptoWebhookPayload,
|
|
36
|
-
): Promise<
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
return { handled: false, status };
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// Update charge status regardless of event type.
|
|
58
|
-
await chargeStore.updateStatus(payload.invoiceId, status);
|
|
59
|
-
|
|
60
|
-
let result: CryptoWebhookResult;
|
|
61
|
-
|
|
62
|
-
if (payload.type === "InvoiceSettled") {
|
|
63
|
-
// Idempotency: use ledger referenceId check (same pattern as Stripe webhook).
|
|
64
|
-
// This is atomic — the referenceId is checked inside the ledger's serialized
|
|
65
|
-
// transaction, eliminating the TOCTOU race of isCredited() + creditLedger().
|
|
66
|
-
const creditRef = `crypto:${payload.invoiceId}`;
|
|
67
|
-
if (await creditLedger.hasReferenceId(creditRef)) {
|
|
68
|
-
result = {
|
|
69
|
-
handled: true,
|
|
70
|
-
status,
|
|
71
|
-
tenant: charge.tenantId,
|
|
72
|
-
creditedCents: 0,
|
|
73
|
-
};
|
|
74
|
-
} else {
|
|
75
|
-
// Credit the original USD amount requested (not the crypto amount).
|
|
76
|
-
// charge.amountUsdCents is in USD cents (integer).
|
|
77
|
-
// Credit.fromCents() converts to nanodollars for the ledger.
|
|
78
|
-
const creditCents = charge.amountUsdCents;
|
|
79
|
-
|
|
80
|
-
await creditLedger.credit(charge.tenantId, Credit.fromCents(creditCents), "purchase", {
|
|
81
|
-
description: `Crypto credit purchase via BTCPay (invoice: ${payload.invoiceId})`,
|
|
82
|
-
referenceId: creditRef,
|
|
83
|
-
fundingSource: "crypto",
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
// Mark credited (advisory — primary idempotency is the ledger referenceId above).
|
|
87
|
-
await chargeStore.markCredited(payload.invoiceId);
|
|
88
|
-
|
|
89
|
-
// Reactivate suspended bots (same as Stripe webhook).
|
|
90
|
-
let reactivatedBots: string[] | undefined;
|
|
91
|
-
if (deps.botBilling) {
|
|
92
|
-
reactivatedBots = await deps.botBilling.checkReactivation(charge.tenantId, creditLedger);
|
|
93
|
-
if (reactivatedBots.length === 0) reactivatedBots = undefined;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
result = {
|
|
97
|
-
handled: true,
|
|
98
|
-
status,
|
|
99
|
-
tenant: charge.tenantId,
|
|
100
|
-
creditedCents: creditCents,
|
|
101
|
-
reactivatedBots,
|
|
102
|
-
};
|
|
103
|
-
}
|
|
104
|
-
} else {
|
|
105
|
-
// New, Processing, Expired, Invalid — just track status.
|
|
106
|
-
result = {
|
|
107
|
-
handled: true,
|
|
108
|
-
status,
|
|
109
|
-
tenant: charge.tenantId,
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
await deps.replayGuard.markSeen(dedupeKey, "crypto");
|
|
114
|
-
return result;
|
|
23
|
+
): Promise<{
|
|
24
|
+
handled: boolean;
|
|
25
|
+
duplicate?: boolean;
|
|
26
|
+
tenant?: string;
|
|
27
|
+
creditedCents?: number;
|
|
28
|
+
reactivatedBots?: string[];
|
|
29
|
+
}> {
|
|
30
|
+
return handleKeyServerWebhook(
|
|
31
|
+
{
|
|
32
|
+
chargeStore: deps.chargeStore,
|
|
33
|
+
creditLedger: deps.creditLedger,
|
|
34
|
+
replayGuard: deps.replayGuard,
|
|
35
|
+
onCreditsPurchased: deps.botBilling
|
|
36
|
+
? (tenantId, ledger) => deps.botBilling?.checkReactivation(tenantId, ledger) ?? Promise.resolve([])
|
|
37
|
+
: undefined,
|
|
38
|
+
},
|
|
39
|
+
payload,
|
|
40
|
+
);
|
|
115
41
|
}
|
|
@@ -158,10 +158,8 @@ export {
|
|
|
158
158
|
SIGNUP_GRANT,
|
|
159
159
|
SUSPENSION_GRACE_DAYS,
|
|
160
160
|
} from "./credits/index.js";
|
|
161
|
-
// Crypto payments (BTCPay
|
|
161
|
+
// Crypto payments (key server — native BTC/EVM watchers, no BTCPay)
|
|
162
162
|
export type {
|
|
163
|
-
CryptoBillingConfig,
|
|
164
|
-
CryptoCheckoutOpts,
|
|
165
163
|
CryptoConfig,
|
|
166
164
|
CryptoPaymentState,
|
|
167
165
|
CryptoWebhookDeps,
|
|
@@ -169,15 +167,13 @@ export type {
|
|
|
169
167
|
CryptoWebhookResult,
|
|
170
168
|
} from "./crypto/index.js";
|
|
171
169
|
export {
|
|
172
|
-
BTCPayClient,
|
|
173
170
|
CryptoChargeRepository,
|
|
174
|
-
|
|
171
|
+
CryptoServiceClient,
|
|
175
172
|
DrizzleCryptoChargeRepository,
|
|
176
173
|
handleCryptoWebhook,
|
|
174
|
+
handleKeyServerWebhook,
|
|
177
175
|
loadCryptoConfig,
|
|
178
176
|
MIN_PAYMENT_USD,
|
|
179
|
-
mapBtcPayEventToStatus,
|
|
180
|
-
verifyCryptoWebhookSignature,
|
|
181
177
|
} from "./crypto/index.js";
|
|
182
178
|
// Feature gating middleware (WOP-384 — replaced tier gates with balance gates)
|
|
183
179
|
export {
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import type { ICryptoChargeRepository } from "./charge-store.js";
|
|
2
|
-
import type { BTCPayClient } from "./client.js";
|
|
3
|
-
import type { CryptoCheckoutOpts } from "./types.js";
|
|
4
|
-
/** Minimum payment amount in USD. */
|
|
5
|
-
export declare const MIN_PAYMENT_USD = 10;
|
|
6
|
-
/**
|
|
7
|
-
* Create a BTCPay invoice and store the charge record.
|
|
8
|
-
*
|
|
9
|
-
* Returns the BTCPay-hosted checkout page URL and invoice ID.
|
|
10
|
-
* The user is redirected to checkoutLink to complete the crypto payment.
|
|
11
|
-
*
|
|
12
|
-
* NOTE: amountUsd is converted to cents (integer) for the charge store.
|
|
13
|
-
* The charge store holds USD cents, NOT nanodollars.
|
|
14
|
-
*/
|
|
15
|
-
export declare function createCryptoCheckout(client: BTCPayClient, chargeStore: ICryptoChargeRepository, opts: CryptoCheckoutOpts): Promise<{
|
|
16
|
-
referenceId: string;
|
|
17
|
-
url: string;
|
|
18
|
-
}>;
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
import crypto from "node:crypto";
|
|
2
|
-
import { Credit } from "../../credits/credit.js";
|
|
3
|
-
/** Minimum payment amount in USD. */
|
|
4
|
-
export const MIN_PAYMENT_USD = 10;
|
|
5
|
-
/**
|
|
6
|
-
* Create a BTCPay invoice and store the charge record.
|
|
7
|
-
*
|
|
8
|
-
* Returns the BTCPay-hosted checkout page URL and invoice ID.
|
|
9
|
-
* The user is redirected to checkoutLink to complete the crypto payment.
|
|
10
|
-
*
|
|
11
|
-
* NOTE: amountUsd is converted to cents (integer) for the charge store.
|
|
12
|
-
* The charge store holds USD cents, NOT nanodollars.
|
|
13
|
-
*/
|
|
14
|
-
export async function createCryptoCheckout(client, chargeStore, opts) {
|
|
15
|
-
if (opts.amountUsd < MIN_PAYMENT_USD) {
|
|
16
|
-
throw new Error(`Minimum payment amount is $${MIN_PAYMENT_USD}`);
|
|
17
|
-
}
|
|
18
|
-
const orderId = `crypto:${opts.tenant}:${crypto.randomUUID()}`;
|
|
19
|
-
const invoice = await client.createInvoice({
|
|
20
|
-
amountUsd: opts.amountUsd,
|
|
21
|
-
orderId,
|
|
22
|
-
buyerEmail: `${opts.tenant}@${process.env.PLATFORM_DOMAIN ?? "wopr.bot"}`,
|
|
23
|
-
});
|
|
24
|
-
// Store the charge record for webhook correlation.
|
|
25
|
-
// amountUsdCents = USD * 100 (cents, NOT nanodollars).
|
|
26
|
-
// Credit.fromDollars() handles the float → integer boundary safely via Math.round
|
|
27
|
-
// on the nanodollar scale, then toCentsRounded() converts back to integer cents.
|
|
28
|
-
// This avoids direct floating-point multiplication for the cents conversion.
|
|
29
|
-
const amountUsdCents = Credit.fromDollars(opts.amountUsd).toCentsRounded();
|
|
30
|
-
await chargeStore.create(invoice.id, opts.tenant, amountUsdCents);
|
|
31
|
-
return {
|
|
32
|
-
referenceId: invoice.id,
|
|
33
|
-
url: invoice.checkoutLink,
|
|
34
|
-
};
|
|
35
|
-
}
|