@wopr-network/platform-core 1.48.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 (70) 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/db/schema/crypto.d.ts +68 -0
  40. package/dist/db/schema/crypto.js +8 -0
  41. package/dist/monetization/crypto/__tests__/webhook.test.js +2 -1
  42. package/drizzle/migrations/0016_charge_progress_columns.sql +4 -0
  43. package/drizzle/migrations/meta/_journal.json +7 -0
  44. package/package.json +1 -1
  45. package/src/billing/crypto/__tests__/unified-checkout.test.ts +83 -0
  46. package/src/billing/crypto/__tests__/watcher-service.test.ts +242 -0
  47. package/src/billing/crypto/__tests__/webhook-confirmations.test.ts +367 -0
  48. package/src/billing/crypto/btc/__tests__/settler.test.ts +1 -0
  49. package/src/billing/crypto/btc/__tests__/watcher.test.ts +201 -0
  50. package/src/billing/crypto/btc/types.ts +3 -1
  51. package/src/billing/crypto/btc/watcher.ts +22 -6
  52. package/src/billing/crypto/charge-store.test.ts +204 -1
  53. package/src/billing/crypto/charge-store.ts +86 -2
  54. package/src/billing/crypto/client.ts +2 -0
  55. package/src/billing/crypto/cursor-store.ts +31 -3
  56. package/src/billing/crypto/evm/__tests__/eth-settler.test.ts +2 -0
  57. package/src/billing/crypto/evm/__tests__/eth-watcher.test.ts +31 -4
  58. package/src/billing/crypto/evm/__tests__/settler.test.ts +2 -0
  59. package/src/billing/crypto/evm/__tests__/watcher-confirmations.test.ts +176 -0
  60. package/src/billing/crypto/evm/__tests__/watcher.test.ts +6 -2
  61. package/src/billing/crypto/evm/eth-watcher.ts +34 -14
  62. package/src/billing/crypto/evm/types.ts +5 -1
  63. package/src/billing/crypto/evm/watcher.ts +39 -13
  64. package/src/billing/crypto/index.ts +12 -3
  65. package/src/billing/crypto/key-server-webhook.ts +92 -21
  66. package/src/billing/crypto/types.ts +18 -0
  67. package/src/billing/crypto/unified-checkout.ts +20 -179
  68. package/src/billing/crypto/watcher-service.ts +85 -32
  69. package/src/db/schema/crypto.ts +8 -0
  70. package/src/monetization/crypto/__tests__/webhook.test.ts +2 -1
@@ -66,7 +66,12 @@ describe("EvmWatcher", () => {
66
66
 
67
67
  it("skips blocks not yet confirmed", async () => {
68
68
  const events: unknown[] = [];
69
- const mockRpc = vi.fn().mockResolvedValueOnce(`0x${(50).toString(16)}`);
69
+ // latest = 50, cursor = 50 → latest < cursor is false, but range is empty (50..50)
70
+ // With intermediate confirmations, we still scan the range but find no logs
71
+ const mockRpc = vi
72
+ .fn()
73
+ .mockResolvedValueOnce(`0x${(50).toString(16)}`) // eth_blockNumber
74
+ .mockResolvedValueOnce([]); // eth_getLogs (empty)
70
75
 
71
76
  const watcher = new EvmWatcher({
72
77
  chain: "base",
@@ -81,7 +86,6 @@ describe("EvmWatcher", () => {
81
86
 
82
87
  await watcher.poll();
83
88
  expect(events).toHaveLength(0);
84
- expect(mockRpc).toHaveBeenCalledTimes(1);
85
89
  });
86
90
 
87
91
  it("processes multiple logs in one poll", async () => {
@@ -6,7 +6,7 @@ import type { EvmChain } from "./types.js";
6
6
 
7
7
  type RpcCall = (method: string, params: unknown[]) => Promise<unknown>;
8
8
 
9
- /** Event emitted when a native ETH deposit is detected and confirmed. */
9
+ /** Event emitted on each confirmation increment for a native ETH deposit. */
10
10
  export interface EthPaymentEvent {
11
11
  readonly chain: EvmChain;
12
12
  readonly from: string;
@@ -17,6 +17,10 @@ export interface EthPaymentEvent {
17
17
  readonly amountUsdCents: number;
18
18
  readonly txHash: string;
19
19
  readonly blockNumber: number;
20
+ /** Current confirmation count (latest block - tx block). */
21
+ readonly confirmations: number;
22
+ /** Required confirmations for this chain. */
23
+ readonly confirmationsRequired: number;
20
24
  }
21
25
 
22
26
  export interface EthWatcherOpts {
@@ -44,9 +48,9 @@ interface RpcTransaction {
44
48
  * this scans blocks for transactions where `to` matches a watched deposit
45
49
  * address and `value > 0`.
46
50
  *
47
- * Processes one block at a time and persists cursor after each block.
48
- * On restart, resumes from the last committed cursor — no replay, no
49
- * unbounded in-memory state.
51
+ * Scans up to latest block (not just confirmed) to detect pending txs.
52
+ * Emits events on each confirmation increment. Only advances cursor
53
+ * past fully-confirmed blocks.
50
54
  */
51
55
  export class EthWatcher {
52
56
  private _cursor: number;
@@ -87,11 +91,10 @@ export class EthWatcher {
87
91
  }
88
92
 
89
93
  /**
90
- * Poll for new native ETH transfers to watched addresses.
94
+ * Poll for native ETH transfers to watched addresses, including unconfirmed blocks.
91
95
  *
92
- * Processes one block at a time. After each block is fully processed,
93
- * the cursor is persisted to the DB. If onPayment fails mid-block,
94
- * the cursor hasn't advanced — the entire block is retried on next poll.
96
+ * Scans from cursor to latest block. Emits events with current confirmation count.
97
+ * Re-emits on each confirmation increment. Only advances cursor past fully-confirmed blocks.
95
98
  */
96
99
  async poll(): Promise<void> {
97
100
  if (this._watchedAddresses.size === 0) return;
@@ -100,17 +103,20 @@ export class EthWatcher {
100
103
  const latest = Number.parseInt(latestHex, 16);
101
104
  const confirmed = latest - this.confirmations;
102
105
 
103
- if (confirmed < this._cursor) return;
106
+ if (latest < this._cursor) return;
104
107
 
105
108
  const { priceCents } = await this.oracle.getPrice("ETH");
106
109
 
107
- for (let blockNum = this._cursor; blockNum <= confirmed; blockNum++) {
110
+ // Scan up to latest (not just confirmed) to detect pending txs
111
+ for (let blockNum = this._cursor; blockNum <= latest; blockNum++) {
108
112
  const block = (await this.rpc("eth_getBlockByNumber", [`0x${blockNum.toString(16)}`, true])) as {
109
113
  transactions: RpcTransaction[];
110
114
  } | null;
111
115
 
112
116
  if (!block) continue;
113
117
 
118
+ const confs = latest - blockNum;
119
+
114
120
  for (const tx of block.transactions) {
115
121
  if (!tx.to) continue;
116
122
  const to = tx.to.toLowerCase();
@@ -119,6 +125,12 @@ export class EthWatcher {
119
125
  const valueWei = BigInt(tx.value);
120
126
  if (valueWei === 0n) continue;
121
127
 
128
+ // Skip if we already emitted at this confirmation count
129
+ if (this.cursorStore) {
130
+ const lastConf = await this.cursorStore.getConfirmationCount(this.watcherId, tx.hash);
131
+ if (lastConf !== null && confs <= lastConf) continue;
132
+ }
133
+
122
134
  const amountUsdCents = nativeToCents(valueWei, priceCents, 18);
123
135
 
124
136
  const event: EthPaymentEvent = {
@@ -129,15 +141,23 @@ export class EthWatcher {
129
141
  amountUsdCents,
130
142
  txHash: tx.hash,
131
143
  blockNumber: blockNum,
144
+ confirmations: confs,
145
+ confirmationsRequired: this.confirmations,
132
146
  };
133
147
 
134
148
  await this.onPayment(event);
149
+
150
+ if (this.cursorStore) {
151
+ await this.cursorStore.saveConfirmationCount(this.watcherId, tx.hash, confs);
152
+ }
135
153
  }
136
154
 
137
- // Block fully processed — persist cursor so we never re-scan it.
138
- this._cursor = blockNum + 1;
139
- if (this.cursorStore) {
140
- await this.cursorStore.save(this.watcherId, this._cursor);
155
+ // Only advance cursor past fully-confirmed blocks
156
+ if (blockNum <= confirmed) {
157
+ this._cursor = blockNum + 1;
158
+ if (this.cursorStore) {
159
+ await this.cursorStore.save(this.watcherId, this._cursor);
160
+ }
141
161
  }
142
162
  }
143
163
  }
@@ -21,7 +21,7 @@ export interface TokenConfig {
21
21
  readonly decimals: number;
22
22
  }
23
23
 
24
- /** Event emitted when a Transfer is detected and confirmed. */
24
+ /** Event emitted on each confirmation increment for a Transfer. */
25
25
  export interface EvmPaymentEvent {
26
26
  readonly chain: EvmChain;
27
27
  readonly token: StablecoinToken;
@@ -34,6 +34,10 @@ export interface EvmPaymentEvent {
34
34
  readonly txHash: string;
35
35
  readonly blockNumber: number;
36
36
  readonly logIndex: number;
37
+ /** Current confirmation count (latest block - tx block). */
38
+ readonly confirmations: number;
39
+ /** Required confirmations for this chain. */
40
+ readonly confirmationsRequired: number;
37
41
  }
38
42
 
39
43
  /** Options for creating a stablecoin checkout. */
@@ -72,7 +72,15 @@ export class EvmWatcher {
72
72
  return this._cursor;
73
73
  }
74
74
 
75
- /** Poll for new Transfer events. Call on an interval. */
75
+ /**
76
+ * Poll for Transfer events, including pending (unconfirmed) blocks.
77
+ *
78
+ * Two-phase scan:
79
+ * 1. Scan cursor..latest for new/updated txs, emit with current confirmation count
80
+ * 2. Re-check pending txs automatically since cursor doesn't advance past unconfirmed blocks
81
+ *
82
+ * Cursor only advances past fully-confirmed blocks.
83
+ */
76
84
  async poll(): Promise<void> {
77
85
  if (this._watchedAddresses.length === 0) return; // nothing to watch
78
86
 
@@ -80,27 +88,25 @@ export class EvmWatcher {
80
88
  const latest = Number.parseInt(latestHex, 16);
81
89
  const confirmed = latest - this.confirmations;
82
90
 
83
- if (confirmed < this._cursor) return;
91
+ if (latest < this._cursor) return;
84
92
 
85
93
  // Filter by topic[2] (to address) when watched addresses are set.
86
- // This avoids fetching ALL USDC transfers on the chain (millions/day on Base).
87
- // topic[2] values are 32-byte zero-padded: 0x000000000000000000000000<address>
88
94
  const toFilter =
89
95
  this._watchedAddresses.length > 0
90
96
  ? this._watchedAddresses.map((a) => `0x000000000000000000000000${a.slice(2)}`)
91
97
  : null;
92
98
 
99
+ // Scan from cursor to latest (not just confirmed) to detect pending txs
93
100
  const logs = (await this.rpc("eth_getLogs", [
94
101
  {
95
102
  address: this.contractAddress,
96
103
  topics: [TRANSFER_TOPIC, null, toFilter],
97
104
  fromBlock: `0x${this._cursor.toString(16)}`,
98
- toBlock: `0x${confirmed.toString(16)}`,
105
+ toBlock: `0x${latest.toString(16)}`,
99
106
  },
100
107
  ])) as RpcLog[];
101
108
 
102
- // Group logs by block for incremental cursor checkpointing.
103
- // If onPayment fails mid-batch, only the current block is replayed on next poll.
109
+ // Group logs by block
104
110
  const logsByBlock = new Map<number, RpcLog[]>();
105
111
  for (const log of logs) {
106
112
  const bn = Number.parseInt(log.blockNumber, 16);
@@ -109,10 +115,20 @@ export class EvmWatcher {
109
115
  else logsByBlock.set(bn, [log]);
110
116
  }
111
117
 
112
- // Process blocks in order, checkpoint after each.
118
+ // Process all blocks (including unconfirmed), emit with confirmation count
113
119
  const blockNums = [...logsByBlock.keys()].sort((a, b) => a - b);
114
120
  for (const blockNum of blockNums) {
121
+ const confs = latest - blockNum;
122
+
115
123
  for (const log of logsByBlock.get(blockNum) ?? []) {
124
+ const txKey = `${log.transactionHash}:${log.logIndex}`;
125
+
126
+ // Skip if we already emitted at this confirmation count
127
+ if (this.cursorStore) {
128
+ const lastConf = await this.cursorStore.getConfirmationCount(this.watcherId, txKey);
129
+ if (lastConf !== null && confs <= lastConf) continue;
130
+ }
131
+
116
132
  const to = `0x${log.topics[2].slice(26)}`.toLowerCase();
117
133
  const from = `0x${log.topics[1].slice(26)}`.toLowerCase();
118
134
  const rawAmount = BigInt(log.data);
@@ -128,19 +144,29 @@ export class EvmWatcher {
128
144
  txHash: log.transactionHash,
129
145
  blockNumber: blockNum,
130
146
  logIndex: Number.parseInt(log.logIndex, 16),
147
+ confirmations: confs,
148
+ confirmationsRequired: this.confirmations,
131
149
  };
132
150
 
133
151
  await this.onPayment(event);
152
+
153
+ // Track confirmation count
154
+ if (this.cursorStore) {
155
+ await this.cursorStore.saveConfirmationCount(this.watcherId, txKey, confs);
156
+ }
134
157
  }
135
158
 
136
- this._cursor = blockNum + 1;
137
- if (this.cursorStore) {
138
- await this.cursorStore.save(this.watcherId, this._cursor);
159
+ // Only advance cursor past fully-confirmed blocks
160
+ if (blockNum <= confirmed) {
161
+ this._cursor = blockNum + 1;
162
+ if (this.cursorStore) {
163
+ await this.cursorStore.save(this.watcherId, this._cursor);
164
+ }
139
165
  }
140
166
  }
141
167
 
142
- // Advance cursor even if no logs were found in the range.
143
- if (blockNums.length === 0) {
168
+ // Advance cursor if no logs found but confirmed blocks exist
169
+ if (blockNums.length === 0 && confirmed >= this._cursor) {
144
170
  this._cursor = confirmed + 1;
145
171
  if (this.cursorStore) {
146
172
  await this.cursorStore.save(this.watcherId, this._cursor);
@@ -1,5 +1,10 @@
1
1
  export * from "./btc/index.js";
2
- export type { CryptoChargeRecord, CryptoDepositChargeInput, ICryptoChargeRepository } from "./charge-store.js";
2
+ export type {
3
+ CryptoChargeProgressUpdate,
4
+ CryptoChargeRecord,
5
+ CryptoDepositChargeInput,
6
+ ICryptoChargeRepository,
7
+ } from "./charge-store.js";
3
8
  export { CryptoChargeRepository, DrizzleCryptoChargeRepository } from "./charge-store.js";
4
9
  export type {
5
10
  ChainInfo,
@@ -20,10 +25,14 @@ export type {
20
25
  KeyServerWebhookPayload as CryptoWebhookPayload,
21
26
  KeyServerWebhookResult as CryptoWebhookResult,
22
27
  } from "./key-server-webhook.js";
23
- export { handleKeyServerWebhook, handleKeyServerWebhook as handleCryptoWebhook } from "./key-server-webhook.js";
28
+ export {
29
+ handleKeyServerWebhook,
30
+ handleKeyServerWebhook as handleCryptoWebhook,
31
+ normalizeStatus,
32
+ } from "./key-server-webhook.js";
24
33
  export * from "./oracle/index.js";
25
34
  export type { IPaymentMethodStore, PaymentMethodRecord } from "./payment-method-store.js";
26
35
  export { DrizzlePaymentMethodStore } from "./payment-method-store.js";
27
- export type { CryptoPaymentState } from "./types.js";
36
+ export type { CryptoCharge, CryptoChargeStatus, CryptoPaymentState } from "./types.js";
28
37
  export type { UnifiedCheckoutDeps, UnifiedCheckoutResult } from "./unified-checkout.js";
29
38
  export { createUnifiedCheckout, MIN_CHECKOUT_USD as MIN_PAYMENT_USD, MIN_CHECKOUT_USD } from "./unified-checkout.js";
@@ -1,17 +1,23 @@
1
1
  /**
2
- * Key Server webhook handler — processes payment confirmations from the
2
+ * Key Server webhook handler — processes payment events from the
3
3
  * centralized crypto key server.
4
4
  *
5
+ * Called on EVERY status update (not just terminal):
6
+ * - "partial" / "Processing" → update progress, no credit
7
+ * - "confirmed" / "Settled" → update progress + credit ledger
8
+ * - "expired" / "failed" → update progress, no credit
9
+ *
5
10
  * Payload shape (from watcher-service.ts):
6
11
  * {
7
12
  * chargeId: "btc:bc1q...",
8
13
  * chain: "bitcoin",
9
14
  * address: "bc1q...",
10
- * amountUsdCents: 5000,
15
+ * amountReceivedCents: 5000,
11
16
  * status: "confirmed",
12
17
  * txHash: "abc123...",
13
18
  * amountReceived: "50000 sats",
14
- * confirmations: 6
19
+ * confirmations: 6,
20
+ * confirmationsRequired: 6
15
21
  * }
16
22
  *
17
23
  * Replaces handleCryptoWebhook() for products using the key server.
@@ -20,16 +26,20 @@ import { Credit } from "../../credits/credit.js";
20
26
  import type { ILedger } from "../../credits/ledger.js";
21
27
  import type { IWebhookSeenRepository } from "../webhook-seen-repository.js";
22
28
  import type { ICryptoChargeRepository } from "./charge-store.js";
29
+ import type { CryptoChargeStatus } from "./types.js";
23
30
 
24
31
  export interface KeyServerWebhookPayload {
25
32
  chargeId: string;
26
33
  chain: string;
27
34
  address: string;
28
- amountUsdCents: number;
35
+ /** @deprecated Use amountReceivedCents instead. Kept for one release cycle. */
36
+ amountUsdCents?: number;
37
+ amountReceivedCents?: number;
29
38
  status: string;
30
39
  txHash?: string;
31
40
  amountReceived?: string;
32
41
  confirmations?: number;
42
+ confirmationsRequired?: number;
33
43
  }
34
44
 
35
45
  export interface KeyServerWebhookDeps {
@@ -45,13 +55,49 @@ export interface KeyServerWebhookResult {
45
55
  tenant?: string;
46
56
  creditedCents?: number;
47
57
  reactivatedBots?: string[];
58
+ status?: CryptoChargeStatus;
59
+ confirmations?: number;
60
+ confirmationsRequired?: number;
61
+ }
62
+
63
+ /**
64
+ * Map legacy/watcher status strings to canonical CryptoChargeStatus.
65
+ * Accepts both old BTCPay-style ("Settled", "Processing") and new canonical ("confirmed", "partial").
66
+ */
67
+ export function normalizeStatus(raw: string): CryptoChargeStatus {
68
+ switch (raw) {
69
+ case "confirmed":
70
+ case "Settled":
71
+ case "InvoiceSettled":
72
+ return "confirmed";
73
+ case "partial":
74
+ case "Processing":
75
+ case "InvoiceProcessing":
76
+ case "InvoiceReceivedPayment":
77
+ return "partial";
78
+ case "expired":
79
+ case "Expired":
80
+ case "InvoiceExpired":
81
+ return "expired";
82
+ case "failed":
83
+ case "Invalid":
84
+ case "InvoiceInvalid":
85
+ return "failed";
86
+ case "pending":
87
+ case "New":
88
+ case "InvoiceCreated":
89
+ return "pending";
90
+ default:
91
+ return "pending";
92
+ }
48
93
  }
49
94
 
50
95
  /**
51
- * Process a payment confirmation from the crypto key server.
96
+ * Process a payment webhook from the crypto key server.
52
97
  *
53
- * Credits the ledger when status is "confirmed".
54
- * Idempotency: ledger referenceId + replay guard (same pattern as Stripe handler).
98
+ * Idempotency: deduplicate by chargeId + status + confirmations so that
99
+ * multiple progress updates (0→1→2→...→6 confirmations) each get through,
100
+ * but exact duplicates are rejected.
55
101
  */
56
102
  export async function handleKeyServerWebhook(
57
103
  deps: KeyServerWebhookDeps,
@@ -59,8 +105,15 @@ export async function handleKeyServerWebhook(
59
105
  ): Promise<KeyServerWebhookResult> {
60
106
  const { chargeStore, creditLedger } = deps;
61
107
 
62
- // Replay guard: deduplicate by chargeId
63
- const dedupeKey = `ks:${payload.chargeId}`;
108
+ const status = normalizeStatus(payload.status);
109
+ const confirmations = payload.confirmations ?? 0;
110
+ const confirmationsRequired = payload.confirmationsRequired ?? 1;
111
+ // Support deprecated amountUsdCents field as fallback
112
+ const amountReceivedCents = payload.amountReceivedCents ?? payload.amountUsdCents ?? 0;
113
+
114
+ // Replay guard: deduplicate by chargeId + status + confirmations
115
+ // This allows multiple progress updates for the same charge
116
+ const dedupeKey = `ks:${payload.chargeId}:${status}:${confirmations}`;
64
117
  if (await deps.replayGuard.isDuplicate(dedupeKey, "crypto")) {
65
118
  return { handled: true, duplicate: true };
66
119
  }
@@ -71,15 +124,36 @@ export async function handleKeyServerWebhook(
71
124
  return { handled: false };
72
125
  }
73
126
 
74
- if (payload.status === "confirmed") {
75
- // Only settle when payment is confirmed
76
- await chargeStore.updateStatus(payload.chargeId, "Settled", charge.token ?? undefined, payload.amountReceived);
127
+ // Always update progress on every webhook
128
+ await chargeStore.updateProgress(payload.chargeId, {
129
+ status,
130
+ amountReceivedCents,
131
+ confirmations,
132
+ confirmationsRequired,
133
+ txHash: payload.txHash,
134
+ });
77
135
 
136
+ // Also call deprecated updateStatus for backward compat with downstream consumers
137
+ const legacyStatusMap: Record<CryptoChargeStatus, string> = {
138
+ pending: "New",
139
+ partial: "Processing",
140
+ confirmed: "Settled",
141
+ expired: "Expired",
142
+ failed: "Invalid",
143
+ };
144
+ await chargeStore.updateStatus(
145
+ payload.chargeId,
146
+ legacyStatusMap[status] as "Settled",
147
+ charge.token ?? undefined,
148
+ payload.amountReceived,
149
+ );
150
+
151
+ if (status === "confirmed") {
78
152
  // Idempotency: check ledger referenceId (atomic, same as BTCPay handler)
79
153
  const creditRef = `crypto:${payload.chargeId}`;
80
154
  if (await creditLedger.hasReferenceId(creditRef)) {
81
155
  await deps.replayGuard.markSeen(dedupeKey, "crypto");
82
- return { handled: true, duplicate: true, tenant: charge.tenantId };
156
+ return { handled: true, duplicate: true, tenant: charge.tenantId, status, confirmations, confirmationsRequired };
83
157
  }
84
158
 
85
159
  // Credit the original USD amount requested.
@@ -104,16 +178,13 @@ export async function handleKeyServerWebhook(
104
178
  tenant: charge.tenantId,
105
179
  creditedCents: charge.amountUsdCents,
106
180
  reactivatedBots,
181
+ status,
182
+ confirmations,
183
+ confirmationsRequired,
107
184
  };
108
185
  }
109
186
 
110
- // Non-confirmed status — update status but don't settle or credit
111
- await chargeStore.updateStatus(
112
- payload.chargeId,
113
- payload.status as "Processing",
114
- charge.token ?? undefined,
115
- payload.amountReceived,
116
- );
187
+ // Non-confirmed status — progress already updated above, no credit
117
188
  await deps.replayGuard.markSeen(dedupeKey, "crypto");
118
- return { handled: true, tenant: charge.tenantId };
189
+ return { handled: true, tenant: charge.tenantId, status, confirmations, confirmationsRequired };
119
190
  }
@@ -1,6 +1,24 @@
1
1
  /** BTCPay Server invoice states (Greenfield API v1). */
2
2
  export type CryptoPaymentState = "New" | "Processing" | "Expired" | "Invalid" | "Settled";
3
3
 
4
+ /** Charge status for the UI-facing payment lifecycle. */
5
+ export type CryptoChargeStatus = "pending" | "partial" | "confirmed" | "expired" | "failed";
6
+
7
+ /** Full charge record for UI display — includes partial payment progress and confirmations. */
8
+ export interface CryptoCharge {
9
+ id: string;
10
+ tenantId: string;
11
+ chain: string;
12
+ status: CryptoChargeStatus;
13
+ amountExpectedCents: number;
14
+ amountReceivedCents: number;
15
+ confirmations: number;
16
+ confirmationsRequired: number;
17
+ txHash?: string;
18
+ credited: boolean;
19
+ createdAt: Date;
20
+ }
21
+
4
22
  /** Options for creating a crypto payment session. */
5
23
  export interface CryptoCheckoutOpts {
6
24
  /** Internal tenant ID. */