@wopr-network/platform-core 1.43.0 → 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/dist/billing/crypto/__tests__/key-server.test.js +16 -1
- 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 +0 -26
- package/dist/billing/crypto/client.js +0 -13
- package/dist/billing/crypto/client.test.js +1 -11
- package/dist/billing/crypto/index.d.ts +5 -7
- package/dist/billing/crypto/index.js +3 -5
- package/dist/billing/crypto/key-server-entry.js +43 -2
- 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 +2 -0
- package/dist/billing/crypto/key-server.js +25 -1
- 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 +217 -2
- package/dist/db/schema/crypto.js +25 -2
- 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/0015_callback_url.sql +32 -0
- package/drizzle/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/src/billing/crypto/__tests__/key-server.test.ts +16 -1
- 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 +1 -13
- package/src/billing/crypto/client.ts +0 -21
- package/src/billing/crypto/index.ts +9 -13
- package/src/billing/crypto/key-server-entry.ts +46 -2
- package/src/billing/crypto/key-server-webhook.ts +119 -0
- package/src/billing/crypto/key-server.ts +29 -1
- package/src/billing/crypto/watcher-service.ts +381 -0
- package/src/billing/index.ts +1 -1
- package/src/db/schema/crypto.ts +30 -2
- 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.d.ts +0 -1
- 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.d.ts +0 -1
- 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
|
@@ -7,15 +7,12 @@ import { createTestDb, truncateAllTables } from "../../../test/db.js";
|
|
|
7
7
|
import { handleCryptoWebhook } from "../webhook.js";
|
|
8
8
|
function makePayload(overrides = {}) {
|
|
9
9
|
return {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
storeId: "store-test",
|
|
17
|
-
invoiceId: "inv-test-001",
|
|
18
|
-
metadata: { orderId: "order-001" },
|
|
10
|
+
chargeId: "chg-test-001",
|
|
11
|
+
chain: "bitcoin",
|
|
12
|
+
address: "bc1q-test-address",
|
|
13
|
+
amountUsdCents: 2500,
|
|
14
|
+
status: "confirmed",
|
|
15
|
+
txHash: "tx-abc123",
|
|
19
16
|
...overrides,
|
|
20
17
|
};
|
|
21
18
|
}
|
|
@@ -38,160 +35,128 @@ describe("handleCryptoWebhook (monetization layer)", () => {
|
|
|
38
35
|
await creditLedger.seedSystemAccounts();
|
|
39
36
|
deps = { chargeStore, creditLedger, replayGuard: noOpReplayGuard };
|
|
40
37
|
// Default test charge: $25 = 2500 cents
|
|
41
|
-
await chargeStore.create("
|
|
38
|
+
await chargeStore.create("chg-test-001", "tenant-a", 2500);
|
|
42
39
|
});
|
|
43
40
|
// ---------------------------------------------------------------------------
|
|
44
|
-
//
|
|
41
|
+
// confirmed — credits ledger
|
|
45
42
|
// ---------------------------------------------------------------------------
|
|
46
|
-
describe("
|
|
43
|
+
describe("confirmed status", () => {
|
|
47
44
|
it("credits the ledger with the USD amount in cents", async () => {
|
|
48
|
-
const result = await handleCryptoWebhook(deps, makePayload({
|
|
45
|
+
const result = await handleCryptoWebhook(deps, makePayload({ status: "confirmed" }));
|
|
49
46
|
expect(result.handled).toBe(true);
|
|
50
|
-
expect(result.status).toBe("Settled");
|
|
51
47
|
expect(result.tenant).toBe("tenant-a");
|
|
52
48
|
expect(result.creditedCents).toBe(2500);
|
|
53
49
|
const balance = await creditLedger.balance("tenant-a");
|
|
54
50
|
expect(balance.toCents()).toBe(2500);
|
|
55
51
|
});
|
|
56
|
-
it("marks the charge as credited after
|
|
57
|
-
await handleCryptoWebhook(deps, makePayload({
|
|
58
|
-
expect(await chargeStore.isCredited("
|
|
52
|
+
it("marks the charge as credited after confirmation", async () => {
|
|
53
|
+
await handleCryptoWebhook(deps, makePayload({ status: "confirmed" }));
|
|
54
|
+
expect(await chargeStore.isCredited("chg-test-001")).toBe(true);
|
|
59
55
|
});
|
|
60
56
|
it("uses crypto: prefix on reference ID in ledger entry", async () => {
|
|
61
|
-
await handleCryptoWebhook(deps, makePayload({
|
|
57
|
+
await handleCryptoWebhook(deps, makePayload({ status: "confirmed" }));
|
|
62
58
|
const history = await creditLedger.history("tenant-a");
|
|
63
59
|
expect(history).toHaveLength(1);
|
|
64
|
-
expect(history[0].referenceId).toBe("crypto:
|
|
60
|
+
expect(history[0].referenceId).toBe("crypto:chg-test-001");
|
|
65
61
|
expect(history[0].entryType).toBe("purchase");
|
|
66
62
|
});
|
|
67
63
|
it("records fundingSource as crypto", async () => {
|
|
68
|
-
await handleCryptoWebhook(deps, makePayload({
|
|
64
|
+
await handleCryptoWebhook(deps, makePayload({ status: "confirmed" }));
|
|
69
65
|
const history = await creditLedger.history("tenant-a");
|
|
70
66
|
expect(history[0].metadata?.fundingSource).toBe("crypto");
|
|
71
67
|
});
|
|
72
|
-
it("is idempotent — second
|
|
73
|
-
await handleCryptoWebhook(deps, makePayload({
|
|
74
|
-
const result2 = await handleCryptoWebhook(deps, makePayload({
|
|
68
|
+
it("is idempotent — second confirmed does not double-credit", async () => {
|
|
69
|
+
await handleCryptoWebhook(deps, makePayload({ status: "confirmed" }));
|
|
70
|
+
const result2 = await handleCryptoWebhook(deps, makePayload({ status: "confirmed" }));
|
|
75
71
|
expect(result2.handled).toBe(true);
|
|
76
|
-
expect(result2.creditedCents).
|
|
72
|
+
expect(result2.creditedCents).toBeUndefined();
|
|
77
73
|
// Balance is still $25, not $50
|
|
78
74
|
const balance = await creditLedger.balance("tenant-a");
|
|
79
75
|
expect(balance.toCents()).toBe(2500);
|
|
80
76
|
});
|
|
81
77
|
});
|
|
82
78
|
// ---------------------------------------------------------------------------
|
|
83
|
-
// Non-
|
|
79
|
+
// Non-confirmed statuses — no ledger credit
|
|
84
80
|
// ---------------------------------------------------------------------------
|
|
85
|
-
describe("
|
|
81
|
+
describe("pending status", () => {
|
|
86
82
|
it("does NOT credit the ledger", async () => {
|
|
87
|
-
const result = await handleCryptoWebhook(deps, makePayload({
|
|
83
|
+
const result = await handleCryptoWebhook(deps, makePayload({ status: "pending" }));
|
|
88
84
|
expect(result.handled).toBe(true);
|
|
89
85
|
expect(result.tenant).toBe("tenant-a");
|
|
90
86
|
expect(result.creditedCents).toBeUndefined();
|
|
91
87
|
expect((await creditLedger.balance("tenant-a")).toCents()).toBe(0);
|
|
92
88
|
});
|
|
93
89
|
});
|
|
94
|
-
describe("
|
|
90
|
+
describe("expired status", () => {
|
|
95
91
|
it("does NOT credit the ledger", async () => {
|
|
96
|
-
const result = await handleCryptoWebhook(deps, makePayload({
|
|
92
|
+
const result = await handleCryptoWebhook(deps, makePayload({ status: "expired" }));
|
|
97
93
|
expect(result.handled).toBe(true);
|
|
98
94
|
expect(result.creditedCents).toBeUndefined();
|
|
99
95
|
expect((await creditLedger.balance("tenant-a")).toCents()).toBe(0);
|
|
100
96
|
});
|
|
101
97
|
});
|
|
102
|
-
describe("
|
|
98
|
+
describe("failed status", () => {
|
|
103
99
|
it("does NOT credit the ledger", async () => {
|
|
104
|
-
const result = await handleCryptoWebhook(deps, makePayload({
|
|
105
|
-
expect(result.handled).toBe(true);
|
|
106
|
-
expect(result.creditedCents).toBeUndefined();
|
|
107
|
-
expect((await creditLedger.balance("tenant-a")).toCents()).toBe(0);
|
|
108
|
-
});
|
|
109
|
-
});
|
|
110
|
-
describe("InvoiceInvalid", () => {
|
|
111
|
-
it("does NOT credit the ledger", async () => {
|
|
112
|
-
const result = await handleCryptoWebhook(deps, makePayload({ type: "InvoiceInvalid" }));
|
|
100
|
+
const result = await handleCryptoWebhook(deps, makePayload({ status: "failed" }));
|
|
113
101
|
expect(result.handled).toBe(true);
|
|
114
102
|
expect(result.creditedCents).toBeUndefined();
|
|
115
103
|
expect((await creditLedger.balance("tenant-a")).toCents()).toBe(0);
|
|
116
104
|
});
|
|
117
105
|
});
|
|
118
106
|
// ---------------------------------------------------------------------------
|
|
119
|
-
// Unknown
|
|
107
|
+
// Unknown chargeId — returns handled:false
|
|
120
108
|
// ---------------------------------------------------------------------------
|
|
121
109
|
describe("missing charge", () => {
|
|
122
|
-
it("returns handled:false when
|
|
123
|
-
const result = await handleCryptoWebhook(deps, makePayload({
|
|
110
|
+
it("returns handled:false when chargeId is not in the charge store", async () => {
|
|
111
|
+
const result = await handleCryptoWebhook(deps, makePayload({ chargeId: "chg-unknown-999" }));
|
|
124
112
|
expect(result.handled).toBe(false);
|
|
125
113
|
expect(result.tenant).toBeUndefined();
|
|
126
114
|
});
|
|
127
115
|
});
|
|
128
116
|
// ---------------------------------------------------------------------------
|
|
129
|
-
// Status mapping for all known event types
|
|
130
|
-
// ---------------------------------------------------------------------------
|
|
131
|
-
describe("status mapping", () => {
|
|
132
|
-
it.each([
|
|
133
|
-
["InvoiceCreated", "New"],
|
|
134
|
-
["InvoiceProcessing", "Processing"],
|
|
135
|
-
["InvoiceReceivedPayment", "Processing"],
|
|
136
|
-
["InvoiceSettled", "Settled"],
|
|
137
|
-
["InvoicePaymentSettled", "Settled"],
|
|
138
|
-
["InvoiceExpired", "Expired"],
|
|
139
|
-
["InvoiceInvalid", "Invalid"],
|
|
140
|
-
])("maps %s event to %s status", async (eventType, expectedStatus) => {
|
|
141
|
-
const result = await handleCryptoWebhook(deps, makePayload({ type: eventType }));
|
|
142
|
-
expect(result.status).toBe(expectedStatus);
|
|
143
|
-
});
|
|
144
|
-
it("throws on unknown event types", async () => {
|
|
145
|
-
await expect(handleCryptoWebhook(deps, makePayload({ type: "InvoiceSomeUnknownEvent" }))).rejects.toThrow("Unknown BTCPay event type: InvoiceSomeUnknownEvent");
|
|
146
|
-
});
|
|
147
|
-
});
|
|
148
|
-
// ---------------------------------------------------------------------------
|
|
149
117
|
// Charge store status updates
|
|
150
118
|
// ---------------------------------------------------------------------------
|
|
151
119
|
describe("charge store updates", () => {
|
|
152
120
|
it("updates charge status on every webhook call", async () => {
|
|
153
|
-
await handleCryptoWebhook(deps, makePayload({
|
|
154
|
-
const charge = await chargeStore.getByReferenceId("
|
|
155
|
-
expect(charge?.status).toBe("
|
|
121
|
+
await handleCryptoWebhook(deps, makePayload({ status: "partial" }));
|
|
122
|
+
const charge = await chargeStore.getByReferenceId("chg-test-001");
|
|
123
|
+
expect(charge?.status).toBe("partial");
|
|
124
|
+
});
|
|
125
|
+
it("settles charge when status is confirmed", async () => {
|
|
126
|
+
await handleCryptoWebhook(deps, makePayload({ status: "confirmed" }));
|
|
127
|
+
const charge = await chargeStore.getByReferenceId("chg-test-001");
|
|
128
|
+
expect(charge?.status).toBe("Settled");
|
|
156
129
|
});
|
|
157
130
|
});
|
|
158
131
|
// ---------------------------------------------------------------------------
|
|
159
132
|
// Replay guard / idempotency
|
|
160
133
|
// ---------------------------------------------------------------------------
|
|
161
134
|
describe("replay guard", () => {
|
|
162
|
-
it("blocks duplicate
|
|
135
|
+
it("blocks duplicate chargeId via ks: dedupe key", async () => {
|
|
163
136
|
const replayGuard = new DrizzleWebhookSeenRepository(db);
|
|
164
137
|
const depsWithGuard = { ...deps, replayGuard };
|
|
165
|
-
const first = await handleCryptoWebhook(depsWithGuard, makePayload({
|
|
138
|
+
const first = await handleCryptoWebhook(depsWithGuard, makePayload({ status: "confirmed" }));
|
|
166
139
|
expect(first.handled).toBe(true);
|
|
167
140
|
expect(first.creditedCents).toBe(2500);
|
|
168
141
|
expect(first.duplicate).toBeUndefined();
|
|
169
|
-
const second = await handleCryptoWebhook(depsWithGuard, makePayload({
|
|
142
|
+
const second = await handleCryptoWebhook(depsWithGuard, makePayload({ status: "confirmed" }));
|
|
170
143
|
expect(second.handled).toBe(true);
|
|
171
144
|
expect(second.duplicate).toBe(true);
|
|
172
145
|
expect(second.creditedCents).toBeUndefined();
|
|
173
146
|
// Balance is still $25, not $50
|
|
174
147
|
expect((await creditLedger.balance("tenant-a")).toCents()).toBe(2500);
|
|
175
148
|
});
|
|
176
|
-
it("same invoice with a different event type is not blocked", async () => {
|
|
177
|
-
const replayGuard = new DrizzleWebhookSeenRepository(db);
|
|
178
|
-
const depsWithGuard = { ...deps, replayGuard };
|
|
179
|
-
await handleCryptoWebhook(depsWithGuard, makePayload({ type: "InvoiceProcessing" }));
|
|
180
|
-
const result = await handleCryptoWebhook(depsWithGuard, makePayload({ type: "InvoiceSettled" }));
|
|
181
|
-
expect(result.duplicate).toBeUndefined();
|
|
182
|
-
expect(result.creditedCents).toBe(2500);
|
|
183
|
-
});
|
|
184
149
|
});
|
|
185
150
|
// ---------------------------------------------------------------------------
|
|
186
151
|
// BotBilling reactivation — WOPR-specific behaviour
|
|
187
152
|
// ---------------------------------------------------------------------------
|
|
188
153
|
describe("BotBilling reactivation", () => {
|
|
189
|
-
it("calls botBilling.checkReactivation on
|
|
154
|
+
it("calls botBilling.checkReactivation on confirmed and returns reactivatedBots", async () => {
|
|
190
155
|
const mockBotBilling = {
|
|
191
156
|
checkReactivation: vi.fn().mockResolvedValue(["bot-1", "bot-2"]),
|
|
192
157
|
};
|
|
193
158
|
const depsWithBots = { ...deps, botBilling: mockBotBilling };
|
|
194
|
-
const result = await handleCryptoWebhook(depsWithBots, makePayload({
|
|
159
|
+
const result = await handleCryptoWebhook(depsWithBots, makePayload({ status: "confirmed" }));
|
|
195
160
|
expect(mockBotBilling.checkReactivation).toHaveBeenCalledWith("tenant-a", creditLedger);
|
|
196
161
|
expect(result.reactivatedBots).toEqual(["bot-1", "bot-2"]);
|
|
197
162
|
});
|
|
@@ -200,15 +165,15 @@ describe("handleCryptoWebhook (monetization layer)", () => {
|
|
|
200
165
|
checkReactivation: vi.fn().mockResolvedValue([]),
|
|
201
166
|
};
|
|
202
167
|
const depsWithBots = { ...deps, botBilling: mockBotBilling };
|
|
203
|
-
const result = await handleCryptoWebhook(depsWithBots, makePayload({
|
|
168
|
+
const result = await handleCryptoWebhook(depsWithBots, makePayload({ status: "confirmed" }));
|
|
204
169
|
expect(result.reactivatedBots).toBeUndefined();
|
|
205
170
|
});
|
|
206
|
-
it("does NOT call botBilling on non-
|
|
171
|
+
it("does NOT call botBilling on non-confirmed statuses", async () => {
|
|
207
172
|
const mockBotBilling = {
|
|
208
173
|
checkReactivation: vi.fn().mockResolvedValue(["bot-1"]),
|
|
209
174
|
};
|
|
210
175
|
const depsWithBots = { ...deps, botBilling: mockBotBilling };
|
|
211
|
-
await handleCryptoWebhook(depsWithBots, makePayload({
|
|
176
|
+
await handleCryptoWebhook(depsWithBots, makePayload({ status: "pending" }));
|
|
212
177
|
expect(mockBotBilling.checkReactivation).not.toHaveBeenCalled();
|
|
213
178
|
});
|
|
214
179
|
it("does NOT call botBilling when charge is already credited (idempotency path)", async () => {
|
|
@@ -216,16 +181,16 @@ describe("handleCryptoWebhook (monetization layer)", () => {
|
|
|
216
181
|
checkReactivation: vi.fn().mockResolvedValue(["bot-1"]),
|
|
217
182
|
};
|
|
218
183
|
const depsWithBots = { ...deps, botBilling: mockBotBilling };
|
|
219
|
-
// First
|
|
220
|
-
await handleCryptoWebhook(depsWithBots, makePayload({
|
|
184
|
+
// First confirmation — should call reactivation
|
|
185
|
+
await handleCryptoWebhook(depsWithBots, makePayload({ status: "confirmed" }));
|
|
221
186
|
expect(mockBotBilling.checkReactivation).toHaveBeenCalledTimes(1);
|
|
222
|
-
// Second
|
|
223
|
-
await handleCryptoWebhook(depsWithBots, makePayload({
|
|
187
|
+
// Second confirmation — charge already credited, should NOT call reactivation again
|
|
188
|
+
await handleCryptoWebhook(depsWithBots, makePayload({ status: "confirmed" }));
|
|
224
189
|
expect(mockBotBilling.checkReactivation).toHaveBeenCalledTimes(1);
|
|
225
190
|
});
|
|
226
191
|
it("operates correctly when botBilling is not provided", async () => {
|
|
227
192
|
// No botBilling dependency — should complete without error
|
|
228
|
-
const result = await handleCryptoWebhook(deps, makePayload({
|
|
193
|
+
const result = await handleCryptoWebhook(deps, makePayload({ status: "confirmed" }));
|
|
229
194
|
expect(result.handled).toBe(true);
|
|
230
195
|
expect(result.creditedCents).toBe(2500);
|
|
231
196
|
expect(result.reactivatedBots).toBeUndefined();
|
|
@@ -235,14 +200,14 @@ describe("handleCryptoWebhook (monetization layer)", () => {
|
|
|
235
200
|
// Multiple tenants — independent processing
|
|
236
201
|
// ---------------------------------------------------------------------------
|
|
237
202
|
describe("multiple tenants", () => {
|
|
238
|
-
it("processes
|
|
239
|
-
await chargeStore.create("
|
|
240
|
-
await chargeStore.create("
|
|
241
|
-
await handleCryptoWebhook(deps, makePayload({
|
|
242
|
-
await handleCryptoWebhook(deps, makePayload({
|
|
203
|
+
it("processes charges for different tenants independently", async () => {
|
|
204
|
+
await chargeStore.create("chg-b-001", "tenant-b", 5000);
|
|
205
|
+
await chargeStore.create("chg-c-001", "tenant-c", 1500);
|
|
206
|
+
await handleCryptoWebhook(deps, makePayload({ chargeId: "chg-b-001", status: "confirmed" }));
|
|
207
|
+
await handleCryptoWebhook(deps, makePayload({ chargeId: "chg-c-001", status: "confirmed" }));
|
|
243
208
|
expect((await creditLedger.balance("tenant-b")).toCents()).toBe(5000);
|
|
244
209
|
expect((await creditLedger.balance("tenant-c")).toCents()).toBe(1500);
|
|
245
|
-
// Original tenant-a was not
|
|
210
|
+
// Original tenant-a was not confirmed in this test
|
|
246
211
|
expect((await creditLedger.balance("tenant-a")).toCents()).toBe(0);
|
|
247
212
|
});
|
|
248
213
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type {
|
|
2
|
-
export {
|
|
3
|
-
export type { CryptoWebhookDeps } from "./webhook.js";
|
|
4
|
-
export { handleCryptoWebhook } from "./webhook.js";
|
|
1
|
+
export type { CryptoChargeRecord, CryptoConfig, CryptoPaymentState, CryptoWebhookDeps, CryptoWebhookPayload, CryptoWebhookResult, ICryptoChargeRepository, } from "../../billing/crypto/index.js";
|
|
2
|
+
export { CryptoChargeRepository, CryptoServiceClient, DrizzleCryptoChargeRepository, handleCryptoWebhook, handleKeyServerWebhook, loadCryptoConfig, MIN_PAYMENT_USD, } from "../../billing/crypto/index.js";
|
|
3
|
+
export type { CryptoWebhookDeps as WoprCryptoWebhookDeps } from "./webhook.js";
|
|
4
|
+
export { handleCryptoWebhook as handleWoprCryptoWebhook } from "./webhook.js";
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export {
|
|
2
|
-
export { handleCryptoWebhook } from "./webhook.js";
|
|
1
|
+
export { CryptoChargeRepository, CryptoServiceClient, DrizzleCryptoChargeRepository, handleCryptoWebhook, handleKeyServerWebhook, loadCryptoConfig, MIN_PAYMENT_USD, } from "../../billing/crypto/index.js";
|
|
2
|
+
export { handleCryptoWebhook as handleWoprCryptoWebhook } from "./webhook.js";
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type { CryptoWebhookPayload,
|
|
2
|
-
import type {
|
|
1
|
+
import type { CryptoWebhookPayload, ICryptoChargeRepository } from "../../billing/crypto/index.js";
|
|
2
|
+
import type { IWebhookSeenRepository } from "../../billing/webhook-seen-repository.js";
|
|
3
|
+
import type { ILedger } from "../../credits/ledger.js";
|
|
3
4
|
import type { BotBilling } from "../credits/bot-billing.js";
|
|
4
5
|
export interface CryptoWebhookDeps {
|
|
5
6
|
chargeStore: ICryptoChargeRepository;
|
|
@@ -8,17 +9,15 @@ export interface CryptoWebhookDeps {
|
|
|
8
9
|
replayGuard: IWebhookSeenRepository;
|
|
9
10
|
}
|
|
10
11
|
/**
|
|
11
|
-
* Process a
|
|
12
|
+
* Process a crypto payment webhook from the key server (WOPR-specific version).
|
|
12
13
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* Idempotency strategy (matches Stripe webhook pattern):
|
|
17
|
-
* Primary: `creditLedger.hasReferenceId("crypto:<invoiceId>")` — atomic,
|
|
18
|
-
* checked inside the ledger's serialized transaction.
|
|
19
|
-
* Secondary: `chargeStore.markCredited()` — advisory flag for queries.
|
|
20
|
-
*
|
|
21
|
-
* CRITICAL: charge.amountUsdCents is in USD cents (integer).
|
|
22
|
-
* Credit.fromCents() converts cents → nanodollars for the ledger.
|
|
14
|
+
* Delegates to handleKeyServerWebhook() for charge lookup, ledger crediting,
|
|
15
|
+
* and idempotency. Adds WOPR-specific bot reactivation via botBilling.
|
|
23
16
|
*/
|
|
24
|
-
export declare function handleCryptoWebhook(deps: CryptoWebhookDeps, payload: CryptoWebhookPayload): Promise<
|
|
17
|
+
export declare function handleCryptoWebhook(deps: CryptoWebhookDeps, payload: CryptoWebhookPayload): Promise<{
|
|
18
|
+
handled: boolean;
|
|
19
|
+
duplicate?: boolean;
|
|
20
|
+
tenant?: string;
|
|
21
|
+
creditedCents?: number;
|
|
22
|
+
reactivatedBots?: string[];
|
|
23
|
+
}>;
|
|
@@ -1,88 +1,17 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { Credit } from "@wopr-network/platform-core/credits";
|
|
1
|
+
import { handleKeyServerWebhook } from "../../billing/crypto/key-server-webhook.js";
|
|
3
2
|
/**
|
|
4
|
-
* Process a
|
|
3
|
+
* Process a crypto payment webhook from the key server (WOPR-specific version).
|
|
5
4
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* Idempotency strategy (matches Stripe webhook pattern):
|
|
10
|
-
* Primary: `creditLedger.hasReferenceId("crypto:<invoiceId>")` — atomic,
|
|
11
|
-
* checked inside the ledger's serialized transaction.
|
|
12
|
-
* Secondary: `chargeStore.markCredited()` — advisory flag for queries.
|
|
13
|
-
*
|
|
14
|
-
* CRITICAL: charge.amountUsdCents is in USD cents (integer).
|
|
15
|
-
* Credit.fromCents() converts cents → nanodollars for the ledger.
|
|
5
|
+
* Delegates to handleKeyServerWebhook() for charge lookup, ledger crediting,
|
|
6
|
+
* and idempotency. Adds WOPR-specific bot reactivation via botBilling.
|
|
16
7
|
*/
|
|
17
8
|
export async function handleCryptoWebhook(deps, payload) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
// Map BTCPay event type to a CryptoPaymentState (throws on unknown types).
|
|
28
|
-
const status = mapBtcPayEventToStatus(payload.type);
|
|
29
|
-
// Look up the charge record to find the tenant.
|
|
30
|
-
const charge = await chargeStore.getByReferenceId(payload.invoiceId);
|
|
31
|
-
if (!charge) {
|
|
32
|
-
return { handled: false, status };
|
|
33
|
-
}
|
|
34
|
-
// Update charge status regardless of event type.
|
|
35
|
-
await chargeStore.updateStatus(payload.invoiceId, status);
|
|
36
|
-
let result;
|
|
37
|
-
if (payload.type === "InvoiceSettled") {
|
|
38
|
-
// Idempotency: use ledger referenceId check (same pattern as Stripe webhook).
|
|
39
|
-
// This is atomic — the referenceId is checked inside the ledger's serialized
|
|
40
|
-
// transaction, eliminating the TOCTOU race of isCredited() + creditLedger().
|
|
41
|
-
const creditRef = `crypto:${payload.invoiceId}`;
|
|
42
|
-
if (await creditLedger.hasReferenceId(creditRef)) {
|
|
43
|
-
result = {
|
|
44
|
-
handled: true,
|
|
45
|
-
status,
|
|
46
|
-
tenant: charge.tenantId,
|
|
47
|
-
creditedCents: 0,
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
else {
|
|
51
|
-
// Credit the original USD amount requested (not the crypto amount).
|
|
52
|
-
// charge.amountUsdCents is in USD cents (integer).
|
|
53
|
-
// Credit.fromCents() converts to nanodollars for the ledger.
|
|
54
|
-
const creditCents = charge.amountUsdCents;
|
|
55
|
-
await creditLedger.credit(charge.tenantId, Credit.fromCents(creditCents), "purchase", {
|
|
56
|
-
description: `Crypto credit purchase via BTCPay (invoice: ${payload.invoiceId})`,
|
|
57
|
-
referenceId: creditRef,
|
|
58
|
-
fundingSource: "crypto",
|
|
59
|
-
});
|
|
60
|
-
// Mark credited (advisory — primary idempotency is the ledger referenceId above).
|
|
61
|
-
await chargeStore.markCredited(payload.invoiceId);
|
|
62
|
-
// Reactivate suspended bots (same as Stripe webhook).
|
|
63
|
-
let reactivatedBots;
|
|
64
|
-
if (deps.botBilling) {
|
|
65
|
-
reactivatedBots = await deps.botBilling.checkReactivation(charge.tenantId, creditLedger);
|
|
66
|
-
if (reactivatedBots.length === 0)
|
|
67
|
-
reactivatedBots = undefined;
|
|
68
|
-
}
|
|
69
|
-
result = {
|
|
70
|
-
handled: true,
|
|
71
|
-
status,
|
|
72
|
-
tenant: charge.tenantId,
|
|
73
|
-
creditedCents: creditCents,
|
|
74
|
-
reactivatedBots,
|
|
75
|
-
};
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
else {
|
|
79
|
-
// New, Processing, Expired, Invalid — just track status.
|
|
80
|
-
result = {
|
|
81
|
-
handled: true,
|
|
82
|
-
status,
|
|
83
|
-
tenant: charge.tenantId,
|
|
84
|
-
};
|
|
85
|
-
}
|
|
86
|
-
await deps.replayGuard.markSeen(dedupeKey, "crypto");
|
|
87
|
-
return result;
|
|
9
|
+
return handleKeyServerWebhook({
|
|
10
|
+
chargeStore: deps.chargeStore,
|
|
11
|
+
creditLedger: deps.creditLedger,
|
|
12
|
+
replayGuard: deps.replayGuard,
|
|
13
|
+
onCreditsPurchased: deps.botBilling
|
|
14
|
+
? (tenantId, ledger) => deps.botBilling?.checkReactivation(tenantId, ledger) ?? Promise.resolve([])
|
|
15
|
+
: undefined,
|
|
16
|
+
}, payload);
|
|
88
17
|
}
|
|
@@ -41,8 +41,8 @@ export type { BudgetCheckerConfig, BudgetCheckResult, SpendLimits } from "./budg
|
|
|
41
41
|
export { BudgetChecker, DrizzleBudgetChecker } from "./budget/index.js";
|
|
42
42
|
export type { BillingState, CreditType, DebitType, GetActiveBotCount, HistoryOptions, ILedger, JournalEntry, OnSuspend, RuntimeCronConfig, RuntimeCronResult, TransactionType, } from "./credits/index.js";
|
|
43
43
|
export { BotBilling, buildResourceTierCosts, DAILY_BOT_COST, DrizzleBotBilling, DrizzleLedger, grantSignupCredits, InsufficientBalanceError, Ledger, runRuntimeDeductions, SIGNUP_GRANT, SUSPENSION_GRACE_DAYS, } from "./credits/index.js";
|
|
44
|
-
export type {
|
|
45
|
-
export {
|
|
44
|
+
export type { CryptoConfig, CryptoPaymentState, CryptoWebhookDeps, CryptoWebhookPayload, CryptoWebhookResult, } from "./crypto/index.js";
|
|
45
|
+
export { CryptoChargeRepository, CryptoServiceClient, DrizzleCryptoChargeRepository, handleCryptoWebhook, handleKeyServerWebhook, loadCryptoConfig, MIN_PAYMENT_USD, } from "./crypto/index.js";
|
|
46
46
|
export { type CreditGateConfig, createBalanceGate, createCreditGate, createFeatureGate, type FeatureGateConfig, type GetUserBalance, type ResolveTenantId, } from "./feature-gate.js";
|
|
47
47
|
export type { BillingPeriod, BillingPeriodSummary, MeterEventRow, UsageSummary, } from "./metering/index.js";
|
|
48
48
|
export { DrizzleMeterAggregator, DrizzleMeterEmitter, MeterAggregator, MeterEmitter, } from "./metering/index.js";
|
|
@@ -50,7 +50,7 @@ export { withMargin, } from "./adapters/types.js";
|
|
|
50
50
|
export { ArbitrageRouter, NoProviderAvailableError, ProviderRegistry, } from "./arbitrage/index.js";
|
|
51
51
|
export { BudgetChecker, DrizzleBudgetChecker } from "./budget/index.js";
|
|
52
52
|
export { BotBilling, buildResourceTierCosts, DAILY_BOT_COST, DrizzleBotBilling, DrizzleLedger, grantSignupCredits, InsufficientBalanceError, Ledger, runRuntimeDeductions, SIGNUP_GRANT, SUSPENSION_GRACE_DAYS, } from "./credits/index.js";
|
|
53
|
-
export {
|
|
53
|
+
export { CryptoChargeRepository, CryptoServiceClient, DrizzleCryptoChargeRepository, handleCryptoWebhook, handleKeyServerWebhook, loadCryptoConfig, MIN_PAYMENT_USD, } from "./crypto/index.js";
|
|
54
54
|
// Feature gating middleware (WOP-384 — replaced tier gates with balance gates)
|
|
55
55
|
export { createBalanceGate, createCreditGate, createFeatureGate, } from "./feature-gate.js";
|
|
56
56
|
export { DrizzleMeterAggregator, DrizzleMeterEmitter, MeterAggregator, MeterEmitter, } from "./metering/index.js";
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
-- Watcher service schema additions: webhook outbox + charge amount tracking.
|
|
2
|
+
|
|
3
|
+
-- 1. callback_url for webhook delivery
|
|
4
|
+
ALTER TABLE "crypto_charges" ADD COLUMN "callback_url" text;
|
|
5
|
+
--> statement-breakpoint
|
|
6
|
+
|
|
7
|
+
-- 2. Expected crypto amount in native base units (locked at charge creation)
|
|
8
|
+
ALTER TABLE "crypto_charges" ADD COLUMN "expected_amount" text;
|
|
9
|
+
--> statement-breakpoint
|
|
10
|
+
|
|
11
|
+
-- 3. Running total of received crypto in native base units (partial payments)
|
|
12
|
+
ALTER TABLE "crypto_charges" ADD COLUMN "received_amount" text;
|
|
13
|
+
--> statement-breakpoint
|
|
14
|
+
|
|
15
|
+
-- 4. Webhook delivery outbox — durable retry for payment callbacks
|
|
16
|
+
CREATE TABLE IF NOT EXISTS "webhook_deliveries" (
|
|
17
|
+
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
|
18
|
+
"charge_id" text NOT NULL,
|
|
19
|
+
"callback_url" text NOT NULL,
|
|
20
|
+
"payload" text NOT NULL,
|
|
21
|
+
"status" text NOT NULL DEFAULT 'pending',
|
|
22
|
+
"attempts" integer NOT NULL DEFAULT 0,
|
|
23
|
+
"next_retry_at" text,
|
|
24
|
+
"last_error" text,
|
|
25
|
+
"created_at" text NOT NULL DEFAULT (now())
|
|
26
|
+
);
|
|
27
|
+
--> statement-breakpoint
|
|
28
|
+
|
|
29
|
+
CREATE INDEX IF NOT EXISTS "idx_webhook_deliveries_status" ON "webhook_deliveries" ("status");
|
|
30
|
+
--> statement-breakpoint
|
|
31
|
+
|
|
32
|
+
CREATE INDEX IF NOT EXISTS "idx_webhook_deliveries_charge" ON "webhook_deliveries" ("charge_id");
|
package/package.json
CHANGED
|
@@ -87,7 +87,21 @@ function mockDeps(): KeyServerDeps & {
|
|
|
87
87
|
},
|
|
88
88
|
]),
|
|
89
89
|
listAll: vi.fn(),
|
|
90
|
-
getById: vi.fn()
|
|
90
|
+
getById: vi.fn().mockResolvedValue({
|
|
91
|
+
id: "btc",
|
|
92
|
+
type: "native",
|
|
93
|
+
token: "BTC",
|
|
94
|
+
chain: "bitcoin",
|
|
95
|
+
decimals: 8,
|
|
96
|
+
displayName: "Bitcoin",
|
|
97
|
+
contractAddress: null,
|
|
98
|
+
confirmations: 6,
|
|
99
|
+
oracleAddress: "0x64c911996D3c6aC71f9b455B1E8E7266BcbD848F",
|
|
100
|
+
xpub: null,
|
|
101
|
+
displayOrder: 0,
|
|
102
|
+
enabled: true,
|
|
103
|
+
rpcUrl: null,
|
|
104
|
+
}),
|
|
91
105
|
listByType: vi.fn(),
|
|
92
106
|
upsert: vi.fn().mockResolvedValue(undefined),
|
|
93
107
|
setEnabled: vi.fn().mockResolvedValue(undefined),
|
|
@@ -96,6 +110,7 @@ function mockDeps(): KeyServerDeps & {
|
|
|
96
110
|
db: createMockDb() as never,
|
|
97
111
|
chargeStore: chargeStore as never,
|
|
98
112
|
methodStore: methodStore as never,
|
|
113
|
+
oracle: { getPrice: vi.fn().mockResolvedValue({ priceCents: 6_500_000, updatedAt: new Date() }) } as never,
|
|
99
114
|
};
|
|
100
115
|
}
|
|
101
116
|
|
|
@@ -14,6 +14,8 @@ export interface BtcWatcherOpts {
|
|
|
14
14
|
oracle: IPriceOracle;
|
|
15
15
|
/** Required — BTC has no block cursor, so txid dedup must be persisted. */
|
|
16
16
|
cursorStore: IWatcherCursorStore;
|
|
17
|
+
/** Override chain identity for cursor namespace (default: config.network). Prevents txid collisions across BTC/LTC/DOGE. */
|
|
18
|
+
chainId?: string;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
interface ReceivedByAddress {
|
|
@@ -39,7 +41,7 @@ export class BtcWatcher {
|
|
|
39
41
|
this.minConfirmations = opts.config.confirmations;
|
|
40
42
|
this.oracle = opts.oracle;
|
|
41
43
|
this.cursorStore = opts.cursorStore;
|
|
42
|
-
this.watcherId = `btc:${opts.config.network}`;
|
|
44
|
+
this.watcherId = `btc:${opts.chainId ?? opts.config.network}`;
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
/** Update the set of watched addresses. */
|
|
@@ -17,6 +17,9 @@ export interface CryptoChargeRecord {
|
|
|
17
17
|
token: string | null;
|
|
18
18
|
depositAddress: string | null;
|
|
19
19
|
derivationIndex: number | null;
|
|
20
|
+
callbackUrl: string | null;
|
|
21
|
+
expectedAmount: string | null;
|
|
22
|
+
receivedAmount: string | null;
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
export interface CryptoDepositChargeInput {
|
|
@@ -27,6 +30,9 @@ export interface CryptoDepositChargeInput {
|
|
|
27
30
|
token: string;
|
|
28
31
|
depositAddress: string;
|
|
29
32
|
derivationIndex: number;
|
|
33
|
+
callbackUrl?: string;
|
|
34
|
+
/** Expected crypto amount in native base units (sats for BTC, base units for ERC20). */
|
|
35
|
+
expectedAmount?: string;
|
|
30
36
|
}
|
|
31
37
|
|
|
32
38
|
export interface ICryptoChargeRepository {
|
|
@@ -50,7 +56,7 @@ export interface ICryptoChargeRepository {
|
|
|
50
56
|
/**
|
|
51
57
|
* Manages crypto charge records in PostgreSQL.
|
|
52
58
|
*
|
|
53
|
-
* Each charge maps a
|
|
59
|
+
* Each charge maps a deposit address to a tenant and tracks
|
|
54
60
|
* the payment lifecycle (New → Processing → Settled/Expired/Invalid).
|
|
55
61
|
*
|
|
56
62
|
* amountUsdCents stores the requested amount in USD cents (integer).
|
|
@@ -92,6 +98,9 @@ export class DrizzleCryptoChargeRepository implements ICryptoChargeRepository {
|
|
|
92
98
|
token: row.token ?? null,
|
|
93
99
|
depositAddress: row.depositAddress ?? null,
|
|
94
100
|
derivationIndex: row.derivationIndex ?? null,
|
|
101
|
+
callbackUrl: row.callbackUrl ?? null,
|
|
102
|
+
expectedAmount: row.expectedAmount ?? null,
|
|
103
|
+
receivedAmount: row.receivedAmount ?? null,
|
|
95
104
|
};
|
|
96
105
|
}
|
|
97
106
|
|
|
@@ -146,6 +155,9 @@ export class DrizzleCryptoChargeRepository implements ICryptoChargeRepository {
|
|
|
146
155
|
token: input.token,
|
|
147
156
|
depositAddress: input.depositAddress.toLowerCase(),
|
|
148
157
|
derivationIndex: input.derivationIndex,
|
|
158
|
+
callbackUrl: input.callbackUrl,
|
|
159
|
+
expectedAmount: input.expectedAmount,
|
|
160
|
+
receivedAmount: "0",
|
|
149
161
|
});
|
|
150
162
|
}
|
|
151
163
|
|