@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.
Files changed (86) hide show
  1. package/dist/billing/crypto/__tests__/unified-checkout.test.d.ts +1 -0
  2. package/dist/billing/crypto/__tests__/unified-checkout.test.js +63 -0
  3. package/dist/billing/crypto/__tests__/watcher-service.test.d.ts +1 -0
  4. package/dist/billing/crypto/__tests__/watcher-service.test.js +174 -0
  5. package/dist/billing/crypto/__tests__/webhook-confirmations.test.d.ts +1 -0
  6. package/dist/billing/crypto/__tests__/webhook-confirmations.test.js +304 -0
  7. package/dist/billing/crypto/btc/__tests__/settler.test.js +1 -0
  8. package/dist/billing/crypto/btc/__tests__/watcher.test.d.ts +1 -0
  9. package/dist/billing/crypto/btc/__tests__/watcher.test.js +170 -0
  10. package/dist/billing/crypto/btc/types.d.ts +3 -1
  11. package/dist/billing/crypto/btc/watcher.d.ts +6 -1
  12. package/dist/billing/crypto/btc/watcher.js +20 -6
  13. package/dist/billing/crypto/charge-store.d.ts +27 -2
  14. package/dist/billing/crypto/charge-store.js +67 -1
  15. package/dist/billing/crypto/charge-store.test.js +180 -1
  16. package/dist/billing/crypto/client.d.ts +2 -0
  17. package/dist/billing/crypto/cursor-store.d.ts +10 -3
  18. package/dist/billing/crypto/cursor-store.js +21 -1
  19. package/dist/billing/crypto/evm/__tests__/eth-settler.test.js +2 -0
  20. package/dist/billing/crypto/evm/__tests__/eth-watcher.test.js +31 -4
  21. package/dist/billing/crypto/evm/__tests__/settler.test.js +2 -0
  22. package/dist/billing/crypto/evm/__tests__/watcher-confirmations.test.d.ts +1 -0
  23. package/dist/billing/crypto/evm/__tests__/watcher-confirmations.test.js +144 -0
  24. package/dist/billing/crypto/evm/__tests__/watcher.test.js +6 -2
  25. package/dist/billing/crypto/evm/eth-watcher.d.ts +11 -8
  26. package/dist/billing/crypto/evm/eth-watcher.js +27 -13
  27. package/dist/billing/crypto/evm/types.d.ts +5 -1
  28. package/dist/billing/crypto/evm/watcher.d.ts +9 -1
  29. package/dist/billing/crypto/evm/watcher.js +36 -13
  30. package/dist/billing/crypto/index.d.ts +3 -3
  31. package/dist/billing/crypto/index.js +1 -1
  32. package/dist/billing/crypto/key-server-webhook.d.ts +17 -4
  33. package/dist/billing/crypto/key-server-webhook.js +76 -15
  34. package/dist/billing/crypto/types.d.ts +16 -0
  35. package/dist/billing/crypto/unified-checkout.d.ts +8 -17
  36. package/dist/billing/crypto/unified-checkout.js +17 -131
  37. package/dist/billing/crypto/watcher-service.d.ts +22 -2
  38. package/dist/billing/crypto/watcher-service.js +71 -30
  39. package/dist/billing/payment-processor.d.ts +2 -0
  40. package/dist/billing/payment-processor.test.js +1 -0
  41. package/dist/billing/stripe/stripe-payment-processor.d.ts +1 -0
  42. package/dist/billing/stripe/stripe-payment-processor.js +27 -4
  43. package/dist/billing/stripe/stripe-payment-processor.test.js +95 -1
  44. package/dist/db/schema/crypto.d.ts +68 -0
  45. package/dist/db/schema/crypto.js +8 -0
  46. package/dist/monetization/crypto/__tests__/webhook.test.js +2 -1
  47. package/dist/monetization/stripe/stripe-payment-processor.d.ts +1 -0
  48. package/dist/monetization/stripe/stripe-payment-processor.js +25 -3
  49. package/dist/monetization/stripe/stripe-payment-processor.test.js +73 -1
  50. package/dist/trpc/org-remove-payment-method-router.test.js +1 -0
  51. package/drizzle/migrations/0016_charge_progress_columns.sql +4 -0
  52. package/drizzle/migrations/meta/_journal.json +7 -0
  53. package/package.json +1 -1
  54. package/src/billing/crypto/__tests__/unified-checkout.test.ts +83 -0
  55. package/src/billing/crypto/__tests__/watcher-service.test.ts +242 -0
  56. package/src/billing/crypto/__tests__/webhook-confirmations.test.ts +367 -0
  57. package/src/billing/crypto/btc/__tests__/settler.test.ts +1 -0
  58. package/src/billing/crypto/btc/__tests__/watcher.test.ts +201 -0
  59. package/src/billing/crypto/btc/types.ts +3 -1
  60. package/src/billing/crypto/btc/watcher.ts +22 -6
  61. package/src/billing/crypto/charge-store.test.ts +204 -1
  62. package/src/billing/crypto/charge-store.ts +86 -2
  63. package/src/billing/crypto/client.ts +2 -0
  64. package/src/billing/crypto/cursor-store.ts +31 -3
  65. package/src/billing/crypto/evm/__tests__/eth-settler.test.ts +2 -0
  66. package/src/billing/crypto/evm/__tests__/eth-watcher.test.ts +31 -4
  67. package/src/billing/crypto/evm/__tests__/settler.test.ts +2 -0
  68. package/src/billing/crypto/evm/__tests__/watcher-confirmations.test.ts +176 -0
  69. package/src/billing/crypto/evm/__tests__/watcher.test.ts +6 -2
  70. package/src/billing/crypto/evm/eth-watcher.ts +34 -14
  71. package/src/billing/crypto/evm/types.ts +5 -1
  72. package/src/billing/crypto/evm/watcher.ts +39 -13
  73. package/src/billing/crypto/index.ts +12 -3
  74. package/src/billing/crypto/key-server-webhook.ts +92 -21
  75. package/src/billing/crypto/types.ts +18 -0
  76. package/src/billing/crypto/unified-checkout.ts +20 -179
  77. package/src/billing/crypto/watcher-service.ts +85 -32
  78. package/src/billing/payment-processor.test.ts +1 -0
  79. package/src/billing/payment-processor.ts +3 -0
  80. package/src/billing/stripe/stripe-payment-processor.test.ts +113 -1
  81. package/src/billing/stripe/stripe-payment-processor.ts +33 -5
  82. package/src/db/schema/crypto.ts +8 -0
  83. package/src/monetization/crypto/__tests__/webhook.test.ts +2 -1
  84. package/src/monetization/stripe/stripe-payment-processor.test.ts +89 -1
  85. package/src/monetization/stripe/stripe-payment-processor.ts +31 -4
  86. package/src/trpc/org-remove-payment-method-router.test.ts +1 -0
@@ -0,0 +1,201 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { BtcWatcher } from "../watcher.js";
3
+
4
+ function makeCursorStore() {
5
+ const processed = new Set<string>();
6
+ const confirmationCounts = new Map<string, number>();
7
+ return {
8
+ get: vi.fn().mockResolvedValue(null),
9
+ save: vi.fn().mockResolvedValue(undefined),
10
+ hasProcessedTx: vi.fn().mockImplementation(async (_: string, txId: string) => processed.has(txId)),
11
+ markProcessedTx: vi.fn().mockImplementation(async (_: string, txId: string) => {
12
+ processed.add(txId);
13
+ }),
14
+ getConfirmationCount: vi
15
+ .fn()
16
+ .mockImplementation(async (_: string, txId: string) => confirmationCounts.get(txId) ?? null),
17
+ saveConfirmationCount: vi.fn().mockImplementation(async (_: string, txId: string, count: number) => {
18
+ confirmationCounts.set(txId, count);
19
+ }),
20
+ _processed: processed,
21
+ _confirmationCounts: confirmationCounts,
22
+ };
23
+ }
24
+
25
+ function makeOracle() {
26
+ return { getPrice: vi.fn().mockResolvedValue({ priceCents: 6_500_000 }) };
27
+ }
28
+
29
+ describe("BtcWatcher — intermediate confirmations", () => {
30
+ it("fires onPayment at 0 confirmations when tx first detected", async () => {
31
+ const events: Array<{ confirmations: number; confirmationsRequired: number }> = [];
32
+ const cursorStore = makeCursorStore();
33
+ const rpc = vi
34
+ .fn()
35
+ .mockResolvedValueOnce([{ address: "bc1qtest", amount: 0.0005, confirmations: 0, txids: ["tx1"] }])
36
+ .mockResolvedValueOnce({
37
+ details: [{ address: "bc1qtest", amount: 0.0005, category: "receive" }],
38
+ confirmations: 0,
39
+ });
40
+
41
+ const watcher = new BtcWatcher({
42
+ config: { rpcUrl: "http://localhost", rpcUser: "u", rpcPassword: "p", network: "regtest", confirmations: 3 },
43
+ rpcCall: rpc,
44
+ watchedAddresses: ["bc1qtest"],
45
+ oracle: makeOracle(),
46
+ cursorStore,
47
+ onPayment: (evt) => {
48
+ events.push(evt);
49
+ },
50
+ });
51
+
52
+ await watcher.poll();
53
+
54
+ expect(events).toHaveLength(1);
55
+ expect(events[0].confirmations).toBe(0);
56
+ expect(events[0].confirmationsRequired).toBe(3);
57
+ });
58
+
59
+ it("fires onPayment on each confirmation increment", async () => {
60
+ const events: Array<{ confirmations: number }> = [];
61
+ const cursorStore = makeCursorStore();
62
+ cursorStore._confirmationCounts.set("tx1", 1);
63
+
64
+ const rpc = vi
65
+ .fn()
66
+ .mockResolvedValueOnce([{ address: "bc1qtest", amount: 0.0005, confirmations: 2, txids: ["tx1"] }])
67
+ .mockResolvedValueOnce({
68
+ details: [{ address: "bc1qtest", amount: 0.0005, category: "receive" }],
69
+ confirmations: 2,
70
+ });
71
+
72
+ const watcher = new BtcWatcher({
73
+ config: { rpcUrl: "http://localhost", rpcUser: "u", rpcPassword: "p", network: "regtest", confirmations: 3 },
74
+ rpcCall: rpc,
75
+ watchedAddresses: ["bc1qtest"],
76
+ oracle: makeOracle(),
77
+ cursorStore,
78
+ onPayment: (evt) => {
79
+ events.push(evt);
80
+ },
81
+ });
82
+
83
+ await watcher.poll();
84
+
85
+ expect(events).toHaveLength(1);
86
+ expect(events[0].confirmations).toBe(2);
87
+ });
88
+
89
+ it("does not fire when confirmation count unchanged", async () => {
90
+ const events: Array<{ confirmations: number }> = [];
91
+ const cursorStore = makeCursorStore();
92
+ cursorStore._confirmationCounts.set("tx1", 2);
93
+
94
+ const rpc = vi
95
+ .fn()
96
+ .mockResolvedValueOnce([{ address: "bc1qtest", amount: 0.0005, confirmations: 2, txids: ["tx1"] }])
97
+ .mockResolvedValueOnce({
98
+ details: [{ address: "bc1qtest", amount: 0.0005, category: "receive" }],
99
+ confirmations: 2,
100
+ });
101
+
102
+ const watcher = new BtcWatcher({
103
+ config: { rpcUrl: "http://localhost", rpcUser: "u", rpcPassword: "p", network: "regtest", confirmations: 3 },
104
+ rpcCall: rpc,
105
+ watchedAddresses: ["bc1qtest"],
106
+ oracle: makeOracle(),
107
+ cursorStore,
108
+ onPayment: (evt) => {
109
+ events.push(evt);
110
+ },
111
+ });
112
+
113
+ await watcher.poll();
114
+
115
+ expect(events).toHaveLength(0);
116
+ });
117
+
118
+ it("marks tx as processed once confirmations reach threshold", async () => {
119
+ const events: Array<{ confirmations: number }> = [];
120
+ const cursorStore = makeCursorStore();
121
+ cursorStore._confirmationCounts.set("tx1", 2);
122
+
123
+ const rpc = vi
124
+ .fn()
125
+ .mockResolvedValueOnce([{ address: "bc1qtest", amount: 0.0005, confirmations: 3, txids: ["tx1"] }])
126
+ .mockResolvedValueOnce({
127
+ details: [{ address: "bc1qtest", amount: 0.0005, category: "receive" }],
128
+ confirmations: 3,
129
+ });
130
+
131
+ const watcher = new BtcWatcher({
132
+ config: { rpcUrl: "http://localhost", rpcUser: "u", rpcPassword: "p", network: "regtest", confirmations: 3 },
133
+ rpcCall: rpc,
134
+ watchedAddresses: ["bc1qtest"],
135
+ oracle: makeOracle(),
136
+ cursorStore,
137
+ onPayment: (evt) => {
138
+ events.push(evt);
139
+ },
140
+ });
141
+
142
+ await watcher.poll();
143
+
144
+ expect(events).toHaveLength(1);
145
+ expect(events[0].confirmations).toBe(3);
146
+ expect(cursorStore.markProcessedTx).toHaveBeenCalledWith(expect.any(String), "tx1");
147
+ });
148
+
149
+ it("skips fully-processed txids", async () => {
150
+ const events: unknown[] = [];
151
+ const cursorStore = makeCursorStore();
152
+ cursorStore._processed.add("tx1");
153
+
154
+ const rpc = vi
155
+ .fn()
156
+ .mockResolvedValueOnce([{ address: "bc1qtest", amount: 0.0005, confirmations: 6, txids: ["tx1"] }]);
157
+
158
+ const watcher = new BtcWatcher({
159
+ config: { rpcUrl: "http://localhost", rpcUser: "u", rpcPassword: "p", network: "regtest", confirmations: 3 },
160
+ rpcCall: rpc,
161
+ watchedAddresses: ["bc1qtest"],
162
+ oracle: makeOracle(),
163
+ cursorStore,
164
+ onPayment: (evt) => {
165
+ events.push(evt);
166
+ },
167
+ });
168
+
169
+ await watcher.poll();
170
+
171
+ expect(events).toHaveLength(0);
172
+ });
173
+
174
+ it("includes confirmationsRequired in event", async () => {
175
+ const events: Array<{ confirmationsRequired: number }> = [];
176
+ const cursorStore = makeCursorStore();
177
+
178
+ const rpc = vi
179
+ .fn()
180
+ .mockResolvedValueOnce([{ address: "bc1qtest", amount: 0.001, confirmations: 0, txids: ["txNew"] }])
181
+ .mockResolvedValueOnce({
182
+ details: [{ address: "bc1qtest", amount: 0.001, category: "receive" }],
183
+ confirmations: 0,
184
+ });
185
+
186
+ const watcher = new BtcWatcher({
187
+ config: { rpcUrl: "http://localhost", rpcUser: "u", rpcPassword: "p", network: "regtest", confirmations: 6 },
188
+ rpcCall: rpc,
189
+ watchedAddresses: ["bc1qtest"],
190
+ oracle: makeOracle(),
191
+ cursorStore,
192
+ onPayment: (evt) => {
193
+ events.push(evt);
194
+ },
195
+ });
196
+
197
+ await watcher.poll();
198
+
199
+ expect(events[0].confirmationsRequired).toBe(6);
200
+ });
201
+ });
@@ -1,4 +1,4 @@
1
- /** BTC payment event emitted when a deposit is confirmed. */
1
+ /** BTC payment event emitted on each confirmation increment. */
2
2
  export interface BtcPaymentEvent {
3
3
  readonly address: string;
4
4
  readonly txid: string;
@@ -7,6 +7,8 @@ export interface BtcPaymentEvent {
7
7
  /** USD cents equivalent (integer). */
8
8
  readonly amountUsdCents: number;
9
9
  readonly confirmations: number;
10
+ /** Required confirmations for this chain (from config). */
11
+ readonly confirmationsRequired: number;
10
12
  }
11
13
 
12
14
  /** Options for creating a BTC checkout. */
@@ -74,12 +74,18 @@ export class BtcWatcher {
74
74
  this.addresses.add(address);
75
75
  }
76
76
 
77
- /** Poll for confirmed payments to watched addresses. */
77
+ /**
78
+ * Poll for payments to watched addresses, including unconfirmed txs.
79
+ *
80
+ * Fires onPayment on every confirmation increment (0, 1, 2, ... threshold).
81
+ * Only marks a tx as fully processed once it reaches the confirmation threshold.
82
+ */
78
83
  async poll(): Promise<void> {
79
84
  if (this.addresses.size === 0) return;
80
85
 
86
+ // Poll with minconf=0 to see unconfirmed txs
81
87
  const received = (await this.rpc("listreceivedbyaddress", [
82
- this.minConfirmations,
88
+ 0, // minconf=0: see ALL txs including unconfirmed
83
89
  false, // include_empty
84
90
  true, // include_watchonly
85
91
  ])) as ReceivedByAddress[];
@@ -90,7 +96,7 @@ export class BtcWatcher {
90
96
  if (!this.addresses.has(entry.address)) continue;
91
97
 
92
98
  for (const txid of entry.txids) {
93
- // Skip already-processed txids (persisted to DB, survives restart)
99
+ // Skip fully-processed txids (already reached threshold, persisted to DB)
94
100
  if (await this.cursorStore.hasProcessedTx(this.watcherId, txid)) continue;
95
101
 
96
102
  // Get transaction details for the exact amount sent to this address
@@ -102,8 +108,11 @@ export class BtcWatcher {
102
108
  const detail = tx.details.find((d) => d.address === entry.address && d.category === "receive");
103
109
  if (!detail) continue;
104
110
 
111
+ // Check if confirmations have increased since last seen
112
+ const lastSeen = await this.cursorStore.getConfirmationCount(this.watcherId, txid);
113
+ if (lastSeen !== null && tx.confirmations <= lastSeen) continue; // No change
114
+
105
115
  const amountSats = Math.round(detail.amount * 100_000_000);
106
- // priceCents is cents per 1 BTC. detail.amount is in BTC.
107
116
  const amountUsdCents = Math.round((amountSats * priceCents) / 100_000_000);
108
117
 
109
118
  const event: BtcPaymentEvent = {
@@ -112,11 +121,18 @@ export class BtcWatcher {
112
121
  amountSats,
113
122
  amountUsdCents,
114
123
  confirmations: tx.confirmations,
124
+ confirmationsRequired: this.minConfirmations,
115
125
  };
116
126
 
117
127
  await this.onPayment(event);
118
- // Persist AFTER successful onPayment — survives restart, no unbounded memory
119
- await this.cursorStore.markProcessedTx(this.watcherId, txid);
128
+
129
+ // Persist confirmation count
130
+ await this.cursorStore.saveConfirmationCount(this.watcherId, txid, tx.confirmations);
131
+
132
+ // Mark as fully processed once we reach the threshold
133
+ if (tx.confirmations >= this.minConfirmations) {
134
+ await this.cursorStore.markProcessedTx(this.watcherId, txid);
135
+ }
120
136
  }
121
137
  }
122
138
  }
@@ -34,6 +34,10 @@ describe("CryptoChargeRepository", () => {
34
34
  expect(charge?.amountUsdCents).toBe(2500);
35
35
  expect(charge?.status).toBe("New");
36
36
  expect(charge?.creditedAt).toBeNull();
37
+ expect(charge?.confirmations).toBe(0);
38
+ expect(charge?.confirmationsRequired).toBe(1);
39
+ expect(charge?.txHash).toBeNull();
40
+ expect(charge?.amountReceivedCents).toBe(0);
37
41
  });
38
42
 
39
43
  it("getByReferenceId() returns null when not found", async () => {
@@ -41,7 +45,7 @@ describe("CryptoChargeRepository", () => {
41
45
  expect(charge).toBeNull();
42
46
  });
43
47
 
44
- it("updateStatus() updates status, currency and filled_amount", async () => {
48
+ it("updateStatus() updates status, currency and filled_amount (deprecated compat)", async () => {
45
49
  await store.create("inv-002", "tenant-2", 5000);
46
50
  await store.updateStatus("inv-002", "Settled", "BTC", "0.00025");
47
51
 
@@ -79,6 +83,205 @@ describe("CryptoChargeRepository", () => {
79
83
  expect(await store.isCredited("inv-006")).toBe(true);
80
84
  });
81
85
 
86
+ describe("updateProgress", () => {
87
+ it("updates partial payment progress", async () => {
88
+ await store.createStablecoinCharge({
89
+ referenceId: "prog-001",
90
+ tenantId: "t-1",
91
+ amountUsdCents: 5000,
92
+ chain: "base",
93
+ token: "USDC",
94
+ depositAddress: "0xprog001",
95
+ derivationIndex: 0,
96
+ });
97
+
98
+ await store.updateProgress("prog-001", {
99
+ status: "partial",
100
+ amountReceivedCents: 2500,
101
+ confirmations: 2,
102
+ confirmationsRequired: 6,
103
+ txHash: "0xabc123",
104
+ });
105
+
106
+ const record = await store.getByReferenceId("prog-001");
107
+ expect(record?.status).toBe("Processing");
108
+ expect(record?.amountReceivedCents).toBe(2500);
109
+ expect(record?.confirmations).toBe(2);
110
+ expect(record?.confirmationsRequired).toBe(6);
111
+ expect(record?.txHash).toBe("0xabc123");
112
+ });
113
+
114
+ it("increments confirmations over multiple updates", async () => {
115
+ await store.createStablecoinCharge({
116
+ referenceId: "prog-002",
117
+ tenantId: "t-2",
118
+ amountUsdCents: 1000,
119
+ chain: "base",
120
+ token: "USDC",
121
+ depositAddress: "0xprog002",
122
+ derivationIndex: 1,
123
+ });
124
+
125
+ await store.updateProgress("prog-002", {
126
+ status: "partial",
127
+ amountReceivedCents: 1000,
128
+ confirmations: 1,
129
+ confirmationsRequired: 6,
130
+ txHash: "0xdef456",
131
+ });
132
+
133
+ await store.updateProgress("prog-002", {
134
+ status: "partial",
135
+ amountReceivedCents: 1000,
136
+ confirmations: 3,
137
+ confirmationsRequired: 6,
138
+ txHash: "0xdef456",
139
+ });
140
+
141
+ const record = await store.getByReferenceId("prog-002");
142
+ expect(record?.confirmations).toBe(3);
143
+ });
144
+
145
+ it("maps confirmed status to Settled in DB", async () => {
146
+ await store.createStablecoinCharge({
147
+ referenceId: "prog-003",
148
+ tenantId: "t-3",
149
+ amountUsdCents: 2000,
150
+ chain: "base",
151
+ token: "USDC",
152
+ depositAddress: "0xprog003",
153
+ derivationIndex: 2,
154
+ });
155
+
156
+ await store.updateProgress("prog-003", {
157
+ status: "confirmed",
158
+ amountReceivedCents: 2000,
159
+ confirmations: 6,
160
+ confirmationsRequired: 6,
161
+ txHash: "0xfinal",
162
+ });
163
+
164
+ const record = await store.getByReferenceId("prog-003");
165
+ expect(record?.status).toBe("Settled");
166
+ });
167
+ });
168
+
169
+ describe("get (UI-facing CryptoCharge)", () => {
170
+ it("returns null when not found", async () => {
171
+ const charge = await store.get("nonexistent");
172
+ expect(charge).toBeNull();
173
+ });
174
+
175
+ it("returns full CryptoCharge with all fields for a new charge", async () => {
176
+ await store.createStablecoinCharge({
177
+ referenceId: "get-001",
178
+ tenantId: "t-get",
179
+ amountUsdCents: 5000,
180
+ chain: "base",
181
+ token: "USDC",
182
+ depositAddress: "0xget001",
183
+ derivationIndex: 10,
184
+ });
185
+
186
+ const charge = await store.get("get-001");
187
+ expect(charge).not.toBeNull();
188
+ expect(charge?.id).toBe("get-001");
189
+ expect(charge?.tenantId).toBe("t-get");
190
+ expect(charge?.chain).toBe("base");
191
+ expect(charge?.status).toBe("pending");
192
+ expect(charge?.amountExpectedCents).toBe(5000);
193
+ expect(charge?.amountReceivedCents).toBe(0);
194
+ expect(charge?.confirmations).toBe(0);
195
+ expect(charge?.confirmationsRequired).toBe(1);
196
+ expect(charge?.txHash).toBeUndefined();
197
+ expect(charge?.credited).toBe(false);
198
+ expect(charge?.createdAt).toBeInstanceOf(Date);
199
+ });
200
+
201
+ it("reflects partial payment progress", async () => {
202
+ await store.createStablecoinCharge({
203
+ referenceId: "get-002",
204
+ tenantId: "t-get2",
205
+ amountUsdCents: 5000,
206
+ chain: "base",
207
+ token: "USDC",
208
+ depositAddress: "0xget002",
209
+ derivationIndex: 11,
210
+ });
211
+
212
+ await store.updateProgress("get-002", {
213
+ status: "partial",
214
+ amountReceivedCents: 2500,
215
+ confirmations: 3,
216
+ confirmationsRequired: 6,
217
+ txHash: "0xpartial",
218
+ });
219
+
220
+ const charge = await store.get("get-002");
221
+ expect(charge?.status).toBe("partial");
222
+ expect(charge?.amountReceivedCents).toBe(2500);
223
+ expect(charge?.confirmations).toBe(3);
224
+ expect(charge?.confirmationsRequired).toBe(6);
225
+ expect(charge?.txHash).toBe("0xpartial");
226
+ expect(charge?.credited).toBe(false);
227
+ });
228
+
229
+ it("shows confirmed+credited status after markCredited", async () => {
230
+ await store.createStablecoinCharge({
231
+ referenceId: "get-003",
232
+ tenantId: "t-get3",
233
+ amountUsdCents: 1000,
234
+ chain: "base",
235
+ token: "USDC",
236
+ depositAddress: "0xget003",
237
+ derivationIndex: 12,
238
+ });
239
+
240
+ await store.updateProgress("get-003", {
241
+ status: "confirmed",
242
+ amountReceivedCents: 1000,
243
+ confirmations: 6,
244
+ confirmationsRequired: 6,
245
+ txHash: "0xfull",
246
+ });
247
+ await store.markCredited("get-003");
248
+
249
+ const charge = await store.get("get-003");
250
+ expect(charge?.status).toBe("confirmed");
251
+ expect(charge?.credited).toBe(true);
252
+ expect(charge?.amountReceivedCents).toBe(1000);
253
+ });
254
+
255
+ it("maps expired status correctly", async () => {
256
+ await store.createStablecoinCharge({
257
+ referenceId: "get-004",
258
+ tenantId: "t-get4",
259
+ amountUsdCents: 3000,
260
+ chain: "base",
261
+ token: "USDC",
262
+ depositAddress: "0xget004",
263
+ derivationIndex: 13,
264
+ });
265
+
266
+ await store.updateProgress("get-004", {
267
+ status: "expired",
268
+ amountReceivedCents: 0,
269
+ confirmations: 0,
270
+ confirmationsRequired: 6,
271
+ });
272
+
273
+ const charge = await store.get("get-004");
274
+ expect(charge?.status).toBe("expired");
275
+ });
276
+
277
+ it("returns chain as 'unknown' for legacy charges without chain", async () => {
278
+ await store.create("get-005", "t-get5", 500);
279
+
280
+ const charge = await store.get("get-005");
281
+ expect(charge?.chain).toBe("unknown");
282
+ });
283
+ });
284
+
82
285
  describe("stablecoin charges", () => {
83
286
  it("creates a stablecoin charge with chain/token/address", async () => {
84
287
  await store.createStablecoinCharge({
@@ -1,7 +1,7 @@
1
1
  import { and, eq, isNotNull, isNull, sql } from "drizzle-orm";
2
2
  import type { PlatformDb } from "../../db/index.js";
3
3
  import { cryptoCharges } from "../../db/schema/crypto.js";
4
- import type { CryptoPaymentState } from "./types.js";
4
+ import type { CryptoCharge, CryptoChargeStatus, CryptoPaymentState } from "./types.js";
5
5
 
6
6
  export interface CryptoChargeRecord {
7
7
  referenceId: string;
@@ -20,6 +20,10 @@ export interface CryptoChargeRecord {
20
20
  callbackUrl: string | null;
21
21
  expectedAmount: string | null;
22
22
  receivedAmount: string | null;
23
+ confirmations: number;
24
+ confirmationsRequired: number;
25
+ txHash: string | null;
26
+ amountReceivedCents: number;
23
27
  }
24
28
 
25
29
  export interface CryptoDepositChargeInput {
@@ -35,15 +39,28 @@ export interface CryptoDepositChargeInput {
35
39
  expectedAmount?: string;
36
40
  }
37
41
 
42
+ export interface CryptoChargeProgressUpdate {
43
+ status: CryptoChargeStatus;
44
+ amountReceivedCents: number;
45
+ confirmations: number;
46
+ confirmationsRequired: number;
47
+ txHash?: string;
48
+ }
49
+
38
50
  export interface ICryptoChargeRepository {
39
51
  create(referenceId: string, tenantId: string, amountUsdCents: number): Promise<void>;
40
52
  getByReferenceId(referenceId: string): Promise<CryptoChargeRecord | null>;
53
+ /** @deprecated Use updateProgress() instead. Kept for one release cycle. */
41
54
  updateStatus(
42
55
  referenceId: string,
43
56
  status: CryptoPaymentState,
44
57
  currency?: string,
45
58
  filledAmount?: string,
46
59
  ): Promise<void>;
60
+ /** Update partial payment progress, confirmations, and tx hash. */
61
+ updateProgress(referenceId: string, update: CryptoChargeProgressUpdate): Promise<void>;
62
+ /** Get a charge as a UI-facing CryptoCharge with all progress fields. */
63
+ get(referenceId: string): Promise<CryptoCharge | null>;
47
64
  markCredited(referenceId: string): Promise<void>;
48
65
  isCredited(referenceId: string): Promise<boolean>;
49
66
  createStablecoinCharge(input: CryptoDepositChargeInput): Promise<void>;
@@ -101,10 +118,77 @@ export class DrizzleCryptoChargeRepository implements ICryptoChargeRepository {
101
118
  callbackUrl: row.callbackUrl ?? null,
102
119
  expectedAmount: row.expectedAmount ?? null,
103
120
  receivedAmount: row.receivedAmount ?? null,
121
+ confirmations: row.confirmations,
122
+ confirmationsRequired: row.confirmationsRequired,
123
+ txHash: row.txHash ?? null,
124
+ amountReceivedCents: row.amountReceivedCents,
125
+ };
126
+ }
127
+
128
+ /** Map DB status strings to CryptoChargeStatus for UI consumption. */
129
+ private mapStatus(dbStatus: string, credited: boolean): CryptoChargeStatus {
130
+ if (credited) return "confirmed";
131
+ switch (dbStatus) {
132
+ case "New":
133
+ return "pending";
134
+ case "Processing":
135
+ return "partial";
136
+ case "Settled":
137
+ return "confirmed";
138
+ case "Expired":
139
+ return "expired";
140
+ case "Invalid":
141
+ return "failed";
142
+ default:
143
+ return "pending";
144
+ }
145
+ }
146
+
147
+ /** Get a charge as a UI-facing CryptoCharge with all progress fields. */
148
+ async get(referenceId: string): Promise<CryptoCharge | null> {
149
+ const row = (await this.db.select().from(cryptoCharges).where(eq(cryptoCharges.referenceId, referenceId)))[0];
150
+ if (!row) return null;
151
+ return {
152
+ id: row.referenceId,
153
+ tenantId: row.tenantId,
154
+ chain: row.chain ?? "unknown",
155
+ status: this.mapStatus(row.status, row.creditedAt != null),
156
+ amountExpectedCents: row.amountUsdCents,
157
+ amountReceivedCents: row.amountReceivedCents,
158
+ confirmations: row.confirmations,
159
+ confirmationsRequired: row.confirmationsRequired,
160
+ txHash: row.txHash ?? undefined,
161
+ credited: row.creditedAt != null,
162
+ createdAt: new Date(row.createdAt),
104
163
  };
105
164
  }
106
165
 
107
- /** Update charge status and payment details from webhook. */
166
+ /** Update partial payment progress, confirmations, and tx hash. */
167
+ async updateProgress(referenceId: string, update: CryptoChargeProgressUpdate): Promise<void> {
168
+ const statusMap: Record<CryptoChargeStatus, string> = {
169
+ pending: "New",
170
+ partial: "Processing",
171
+ confirmed: "Settled",
172
+ expired: "Expired",
173
+ failed: "Invalid",
174
+ };
175
+ await this.db
176
+ .update(cryptoCharges)
177
+ .set({
178
+ status: statusMap[update.status],
179
+ amountReceivedCents: update.amountReceivedCents,
180
+ confirmations: update.confirmations,
181
+ confirmationsRequired: update.confirmationsRequired,
182
+ txHash: update.txHash,
183
+ updatedAt: sql`now()`,
184
+ })
185
+ .where(eq(cryptoCharges.referenceId, referenceId));
186
+ }
187
+
188
+ /**
189
+ * @deprecated Use updateProgress() instead. Kept for one release cycle.
190
+ * Update charge status and payment details from webhook.
191
+ */
108
192
  async updateStatus(
109
193
  referenceId: string,
110
194
  status: CryptoPaymentState,
@@ -29,6 +29,8 @@ export interface CreateChargeResult {
29
29
  amountUsd: number;
30
30
  derivationIndex: number;
31
31
  expiresAt: string;
32
+ displayAmount?: string;
33
+ priceCents?: number;
32
34
  }
33
35
 
34
36
  export interface ChargeStatus {
@@ -7,18 +7,23 @@ export interface IWatcherCursorStore {
7
7
  get(watcherId: string): Promise<number | null>;
8
8
  /** Save block cursor after processing a range. */
9
9
  save(watcherId: string, cursorBlock: number): Promise<void>;
10
- /** Check if a specific tx has been processed (for watchers without block cursors). */
10
+ /** Check if a specific tx has been fully processed (reached confirmation threshold). */
11
11
  hasProcessedTx(watcherId: string, txId: string): Promise<boolean>;
12
- /** Mark a tx as processed (for watchers without block cursors). */
12
+ /** Mark a tx as fully processed (reached confirmation threshold). */
13
13
  markProcessedTx(watcherId: string, txId: string): Promise<void>;
14
+ /** Get the last-seen confirmation count for a tx (for intermediate confirmation tracking). */
15
+ getConfirmationCount(watcherId: string, txId: string): Promise<number | null>;
16
+ /** Save the current confirmation count for a tx (for intermediate confirmation tracking). */
17
+ saveConfirmationCount(watcherId: string, txId: string, count: number): Promise<void>;
14
18
  }
15
19
 
16
20
  /**
17
21
  * Persists watcher state to PostgreSQL.
18
22
  *
19
- * Two patterns:
23
+ * Three patterns:
20
24
  * - Block cursor (EVM watchers): save/get cursor block number
21
25
  * - Processed txids (BTC watcher): hasProcessedTx/markProcessedTx
26
+ * - Confirmation counts (all watchers): getConfirmationCount/saveConfirmationCount
22
27
  *
23
28
  * Eliminates all in-memory watcher state. Clean restart recovery.
24
29
  */
@@ -58,4 +63,27 @@ export class DrizzleWatcherCursorStore implements IWatcherCursorStore {
58
63
  async markProcessedTx(watcherId: string, txId: string): Promise<void> {
59
64
  await this.db.insert(watcherProcessed).values({ watcherId, txId }).onConflictDoNothing();
60
65
  }
66
+
67
+ async getConfirmationCount(watcherId: string, txId: string): Promise<number | null> {
68
+ // Store confirmation counts as synthetic cursor entries: "watcherId:conf:txId" -> count
69
+ const key = `${watcherId}:conf:${txId}`;
70
+ const row = (
71
+ await this.db
72
+ .select({ cursorBlock: watcherCursors.cursorBlock })
73
+ .from(watcherCursors)
74
+ .where(eq(watcherCursors.watcherId, key))
75
+ )[0];
76
+ return row?.cursorBlock ?? null;
77
+ }
78
+
79
+ async saveConfirmationCount(watcherId: string, txId: string, count: number): Promise<void> {
80
+ const key = `${watcherId}:conf:${txId}`;
81
+ await this.db
82
+ .insert(watcherCursors)
83
+ .values({ watcherId: key, cursorBlock: count })
84
+ .onConflictDoUpdate({
85
+ target: watcherCursors.watcherId,
86
+ set: { cursorBlock: count, updatedAt: sql`(now())` },
87
+ });
88
+ }
61
89
  }