@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
@@ -1,32 +1,76 @@
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.
18
24
  */
19
25
  import { Credit } from "../../credits/credit.js";
20
26
  /**
21
- * Process a payment confirmation from the crypto key server.
27
+ * Map legacy/watcher status strings to canonical CryptoChargeStatus.
28
+ * Accepts both old BTCPay-style ("Settled", "Processing") and new canonical ("confirmed", "partial").
29
+ */
30
+ export function normalizeStatus(raw) {
31
+ switch (raw) {
32
+ case "confirmed":
33
+ case "Settled":
34
+ case "InvoiceSettled":
35
+ return "confirmed";
36
+ case "partial":
37
+ case "Processing":
38
+ case "InvoiceProcessing":
39
+ case "InvoiceReceivedPayment":
40
+ return "partial";
41
+ case "expired":
42
+ case "Expired":
43
+ case "InvoiceExpired":
44
+ return "expired";
45
+ case "failed":
46
+ case "Invalid":
47
+ case "InvoiceInvalid":
48
+ return "failed";
49
+ case "pending":
50
+ case "New":
51
+ case "InvoiceCreated":
52
+ return "pending";
53
+ default:
54
+ return "pending";
55
+ }
56
+ }
57
+ /**
58
+ * Process a payment webhook from the crypto key server.
22
59
  *
23
- * Credits the ledger when status is "confirmed".
24
- * Idempotency: ledger referenceId + replay guard (same pattern as Stripe handler).
60
+ * Idempotency: deduplicate by chargeId + status + confirmations so that
61
+ * multiple progress updates (0→1→2→...→6 confirmations) each get through,
62
+ * but exact duplicates are rejected.
25
63
  */
26
64
  export async function handleKeyServerWebhook(deps, payload) {
27
65
  const { chargeStore, creditLedger } = deps;
28
- // Replay guard: deduplicate by chargeId
29
- const dedupeKey = `ks:${payload.chargeId}`;
66
+ const status = normalizeStatus(payload.status);
67
+ const confirmations = payload.confirmations ?? 0;
68
+ const confirmationsRequired = payload.confirmationsRequired ?? 1;
69
+ // Support deprecated amountUsdCents field as fallback
70
+ const amountReceivedCents = payload.amountReceivedCents ?? payload.amountUsdCents ?? 0;
71
+ // Replay guard: deduplicate by chargeId + status + confirmations
72
+ // This allows multiple progress updates for the same charge
73
+ const dedupeKey = `ks:${payload.chargeId}:${status}:${confirmations}`;
30
74
  if (await deps.replayGuard.isDuplicate(dedupeKey, "crypto")) {
31
75
  return { handled: true, duplicate: true };
32
76
  }
@@ -35,14 +79,29 @@ export async function handleKeyServerWebhook(deps, payload) {
35
79
  if (!charge) {
36
80
  return { handled: false };
37
81
  }
38
- if (payload.status === "confirmed") {
39
- // Only settle when payment is confirmed
40
- await chargeStore.updateStatus(payload.chargeId, "Settled", charge.token ?? undefined, payload.amountReceived);
82
+ // Always update progress on every webhook
83
+ await chargeStore.updateProgress(payload.chargeId, {
84
+ status,
85
+ amountReceivedCents,
86
+ confirmations,
87
+ confirmationsRequired,
88
+ txHash: payload.txHash,
89
+ });
90
+ // Also call deprecated updateStatus for backward compat with downstream consumers
91
+ const legacyStatusMap = {
92
+ pending: "New",
93
+ partial: "Processing",
94
+ confirmed: "Settled",
95
+ expired: "Expired",
96
+ failed: "Invalid",
97
+ };
98
+ await chargeStore.updateStatus(payload.chargeId, legacyStatusMap[status], charge.token ?? undefined, payload.amountReceived);
99
+ if (status === "confirmed") {
41
100
  // Idempotency: check ledger referenceId (atomic, same as BTCPay handler)
42
101
  const creditRef = `crypto:${payload.chargeId}`;
43
102
  if (await creditLedger.hasReferenceId(creditRef)) {
44
103
  await deps.replayGuard.markSeen(dedupeKey, "crypto");
45
- return { handled: true, duplicate: true, tenant: charge.tenantId };
104
+ return { handled: true, duplicate: true, tenant: charge.tenantId, status, confirmations, confirmationsRequired };
46
105
  }
47
106
  // Credit the original USD amount requested.
48
107
  // charge.amountUsdCents is integer cents. Credit.fromCents() → nanodollars.
@@ -64,10 +123,12 @@ export async function handleKeyServerWebhook(deps, payload) {
64
123
  tenant: charge.tenantId,
65
124
  creditedCents: charge.amountUsdCents,
66
125
  reactivatedBots,
126
+ status,
127
+ confirmations,
128
+ confirmationsRequired,
67
129
  };
68
130
  }
69
- // Non-confirmed status — update status but don't settle or credit
70
- await chargeStore.updateStatus(payload.chargeId, payload.status, charge.token ?? undefined, payload.amountReceived);
131
+ // Non-confirmed status — progress already updated above, no credit
71
132
  await deps.replayGuard.markSeen(dedupeKey, "crypto");
72
- return { handled: true, tenant: charge.tenantId };
133
+ return { handled: true, tenant: charge.tenantId, status, confirmations, confirmationsRequired };
73
134
  }
@@ -1,5 +1,21 @@
1
1
  /** BTCPay Server invoice states (Greenfield API v1). */
2
2
  export type CryptoPaymentState = "New" | "Processing" | "Expired" | "Invalid" | "Settled";
3
+ /** Charge status for the UI-facing payment lifecycle. */
4
+ export type CryptoChargeStatus = "pending" | "partial" | "confirmed" | "expired" | "failed";
5
+ /** Full charge record for UI display — includes partial payment progress and confirmations. */
6
+ export interface CryptoCharge {
7
+ id: string;
8
+ tenantId: string;
9
+ chain: string;
10
+ status: CryptoChargeStatus;
11
+ amountExpectedCents: number;
12
+ amountReceivedCents: number;
13
+ confirmations: number;
14
+ confirmationsRequired: number;
15
+ txHash?: string;
16
+ credited: boolean;
17
+ createdAt: Date;
18
+ }
3
19
  /** Options for creating a crypto payment session. */
4
20
  export interface CryptoCheckoutOpts {
5
21
  /** Internal tenant ID. */
@@ -1,14 +1,7 @@
1
- import type { ICryptoChargeRepository } from "./charge-store.js";
2
- import type { IPriceOracle } from "./oracle/types.js";
3
- import type { PaymentMethodRecord } from "./payment-method-store.js";
1
+ import type { CryptoServiceClient } from "./client.js";
4
2
  export declare const MIN_CHECKOUT_USD = 10;
5
3
  export interface UnifiedCheckoutDeps {
6
- chargeStore: Pick<ICryptoChargeRepository, "getNextDerivationIndex" | "createStablecoinCharge">;
7
- oracle: IPriceOracle;
8
- evmXpub: string;
9
- btcXpub?: string;
10
- /** UTXO network override (auto-detected from node in production). Default: "mainnet". */
11
- utxoNetwork?: "mainnet" | "testnet" | "regtest";
4
+ cryptoService: CryptoServiceClient;
12
5
  }
13
6
  export interface UnifiedCheckoutResult {
14
7
  depositAddress: string;
@@ -22,16 +15,14 @@ export interface UnifiedCheckoutResult {
22
15
  priceCents?: number;
23
16
  }
24
17
  /**
25
- * Unified checkout — one entry point for all payment methods.
18
+ * Unified checkout — delegates to CryptoServiceClient.createCharge().
26
19
  *
27
- * Looks up the method record, routes by type:
28
- * - erc20: derives EVM address, computes token amount (1:1 USD for stablecoins)
29
- * - native (ETH): derives EVM address, oracle-priced
30
- * - native (BTC): derives BTC address, oracle-priced
31
- *
32
- * CRITICAL: amountUsd → integer cents via Credit.fromDollars().toCentsRounded().
20
+ * The pay server handles xpub management, address derivation, and charge
21
+ * creation. This function is a thin wrapper that validates the amount
22
+ * and maps the response to `UnifiedCheckoutResult`.
33
23
  */
34
- export declare function createUnifiedCheckout(deps: UnifiedCheckoutDeps, method: PaymentMethodRecord, opts: {
24
+ export declare function createUnifiedCheckout(deps: UnifiedCheckoutDeps, chain: string, opts: {
35
25
  tenant: string;
36
26
  amountUsd: number;
27
+ callbackUrl?: string;
37
28
  }): Promise<UnifiedCheckoutResult>;
@@ -1,141 +1,27 @@
1
- import { Credit } from "../../credits/credit.js";
2
- import { deriveAddress, deriveP2pkhAddress } from "./btc/address-gen.js";
3
- import { deriveDepositAddress } from "./evm/address-gen.js";
4
- import { centsToNative } from "./oracle/convert.js";
5
1
  export const MIN_CHECKOUT_USD = 10;
6
2
  /**
7
- * Unified checkout — one entry point for all payment methods.
3
+ * Unified checkout — delegates to CryptoServiceClient.createCharge().
8
4
  *
9
- * Looks up the method record, routes by type:
10
- * - erc20: derives EVM address, computes token amount (1:1 USD for stablecoins)
11
- * - native (ETH): derives EVM address, oracle-priced
12
- * - native (BTC): derives BTC address, oracle-priced
13
- *
14
- * CRITICAL: amountUsd → integer cents via Credit.fromDollars().toCentsRounded().
5
+ * The pay server handles xpub management, address derivation, and charge
6
+ * creation. This function is a thin wrapper that validates the amount
7
+ * and maps the response to `UnifiedCheckoutResult`.
15
8
  */
16
- export async function createUnifiedCheckout(deps, method, opts) {
9
+ export async function createUnifiedCheckout(deps, chain, opts) {
17
10
  if (!Number.isFinite(opts.amountUsd) || opts.amountUsd < MIN_CHECKOUT_USD) {
18
11
  throw new Error(`Minimum payment amount is $${MIN_CHECKOUT_USD}`);
19
12
  }
20
- const amountUsdCents = Credit.fromDollars(opts.amountUsd).toCentsRounded();
21
- if (method.type === "erc20") {
22
- return handleErc20(deps, method, opts.tenant, amountUsdCents, opts.amountUsd);
23
- }
24
- if (method.type === "native" && method.chain === "base") {
25
- return handleNativeEvm(deps, method, opts.tenant, amountUsdCents, opts.amountUsd);
26
- }
27
- if (method.type === "native") {
28
- return handleNativeUtxo(deps, method, opts.tenant, amountUsdCents, opts.amountUsd);
29
- }
30
- throw new Error(`Unsupported payment method type: ${method.type}/${method.token}`);
31
- }
32
- async function handleErc20(deps, method, tenant, amountUsdCents, amountUsd) {
33
- const depositAddress = await deriveAndStore(deps, method, tenant, amountUsdCents);
34
- return {
35
- depositAddress,
36
- displayAmount: `${amountUsd} ${method.token}`,
37
- amountUsd,
38
- token: method.token,
39
- chain: method.chain,
40
- referenceId: `erc20:${method.chain}:${depositAddress}`,
41
- };
42
- }
43
- async function handleNativeEvm(deps, method, tenant, amountUsdCents, amountUsd) {
44
- const { priceCents } = await deps.oracle.getPrice("ETH");
45
- const expectedWei = centsToNative(amountUsdCents, priceCents, 18);
46
- const depositAddress = await deriveAndStore(deps, method, tenant, amountUsdCents);
47
- const divisor = BigInt("1000000000000000000");
48
- const whole = expectedWei / divisor;
49
- const frac = (expectedWei % divisor).toString().padStart(18, "0").slice(0, 6);
13
+ const result = await deps.cryptoService.createCharge({
14
+ chain,
15
+ amountUsd: opts.amountUsd,
16
+ callbackUrl: opts.callbackUrl,
17
+ });
50
18
  return {
51
- depositAddress,
52
- displayAmount: `${whole}.${frac} ETH`,
53
- amountUsd,
54
- token: "ETH",
55
- chain: method.chain,
56
- referenceId: `${method.type}:${method.chain}:${depositAddress}`,
57
- priceCents,
19
+ depositAddress: result.address,
20
+ displayAmount: result.displayAmount ?? `${opts.amountUsd} ${result.token}`,
21
+ amountUsd: opts.amountUsd,
22
+ token: result.token,
23
+ chain: result.chain,
24
+ referenceId: result.chargeId,
25
+ priceCents: result.priceCents,
58
26
  };
59
27
  }
60
- /**
61
- * Handle native UTXO coins (BTC, LTC, DOGE, BCH, etc.).
62
- * Uses the xpub from the payment method record (DB-driven).
63
- * Derives bech32 addresses for BTC/LTC, Base58 P2PKH for DOGE.
64
- */
65
- async function handleNativeUtxo(deps, method, tenant, amountUsdCents, amountUsd) {
66
- const xpub = method.xpub ?? deps.btcXpub;
67
- if (!xpub)
68
- throw new Error(`${method.token} payments not configured (no xpub)`);
69
- const { priceCents } = await deps.oracle.getPrice(method.token);
70
- const rawAmount = centsToNative(amountUsdCents, priceCents, method.decimals);
71
- const maxRetries = 3;
72
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
73
- const derivationIndex = await deps.chargeStore.getNextDerivationIndex();
74
- // Derive address by chain type
75
- let depositAddress;
76
- if (method.chain === "dogecoin") {
77
- depositAddress = deriveP2pkhAddress(xpub, derivationIndex, "dogecoin");
78
- }
79
- else {
80
- depositAddress = deriveAddress(xpub, derivationIndex, deps.utxoNetwork ?? "mainnet", method.chain);
81
- }
82
- const referenceId = `${method.token.toLowerCase()}:${depositAddress}`;
83
- try {
84
- await deps.chargeStore.createStablecoinCharge({
85
- referenceId,
86
- tenantId: tenant,
87
- amountUsdCents,
88
- chain: method.chain,
89
- token: method.token,
90
- depositAddress,
91
- derivationIndex,
92
- });
93
- const divisor = 10 ** method.decimals;
94
- const displayAmt = (Number(rawAmount) / divisor).toFixed(method.decimals);
95
- return {
96
- depositAddress,
97
- displayAmount: `${displayAmt} ${method.token}`,
98
- amountUsd,
99
- token: method.token,
100
- chain: method.chain,
101
- referenceId,
102
- priceCents,
103
- };
104
- }
105
- catch (err) {
106
- const code = err.code;
107
- const isConflict = code === "23505" || (err instanceof Error && err.message.includes("unique_violation"));
108
- if (!isConflict || attempt === maxRetries)
109
- throw err;
110
- }
111
- }
112
- throw new Error("Failed to claim derivation index after retries");
113
- }
114
- /** Derive an EVM deposit address and store the charge. Retries on unique conflict. */
115
- async function deriveAndStore(deps, method, tenant, amountUsdCents) {
116
- const maxRetries = 3;
117
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
118
- const derivationIndex = await deps.chargeStore.getNextDerivationIndex();
119
- const depositAddress = deriveDepositAddress(deps.evmXpub, derivationIndex);
120
- const referenceId = `${method.type}:${method.chain}:${depositAddress}`;
121
- try {
122
- await deps.chargeStore.createStablecoinCharge({
123
- referenceId,
124
- tenantId: tenant,
125
- amountUsdCents,
126
- chain: method.chain,
127
- token: method.token,
128
- depositAddress,
129
- derivationIndex,
130
- });
131
- return depositAddress;
132
- }
133
- catch (err) {
134
- const code = err.code;
135
- const isConflict = code === "23505" || (err instanceof Error && err.message.includes("unique_violation"));
136
- if (!isConflict || attempt === maxRetries)
137
- throw err;
138
- }
139
- }
140
- throw new Error("Failed to claim derivation index after retries");
141
- }
@@ -4,8 +4,8 @@
4
4
  * Payment flow:
5
5
  * 1. Watcher detects payment → handlePayment()
6
6
  * 2. Accumulate native amount (supports partial payments)
7
- * 3. When totalReceived >= expectedAmount → settle + credit
8
- * 4. Every payment (partial or full) enqueues a webhook delivery
7
+ * 3. When totalReceived >= expectedAmount AND confirmations >= required confirmed + credit
8
+ * 4. Every payment/confirmation change enqueues a webhook delivery
9
9
  * 5. Outbox processor retries failed deliveries with exponential backoff
10
10
  *
11
11
  * Amount comparison is ALWAYS in native crypto units (sats, wei, token base units).
@@ -30,4 +30,24 @@ export interface WatcherServiceOpts {
30
30
  /** Allowed callback URL prefixes. Default: ["https://"] — enforces HTTPS. */
31
31
  allowedCallbackPrefixes?: string[];
32
32
  }
33
+ export interface PaymentPayload {
34
+ txHash: string;
35
+ confirmations: number;
36
+ confirmationsRequired: number;
37
+ amountReceivedCents: number;
38
+ [key: string]: unknown;
39
+ }
40
+ /**
41
+ * Handle a payment event. Accumulates partial payments in native units.
42
+ * Fires webhook on every payment/confirmation change with canonical statuses.
43
+ *
44
+ * 3-phase webhook lifecycle:
45
+ * 1. Tx first seen -> status: "partial", confirmations: 0
46
+ * 2. Each new block -> status: "partial", confirmations: current
47
+ * 3. Threshold reached + full payment -> status: "confirmed"
48
+ *
49
+ * @param nativeAmount — received amount in native base units (sats for BTC/DOGE, raw token units for ERC20).
50
+ * Pass "0" for confirmation-only updates (no new payment, just more confirmations).
51
+ */
52
+ export declare function handlePayment(db: DrizzleDb, chargeStore: ICryptoChargeRepository, address: string, nativeAmount: string, payload: PaymentPayload, log: (msg: string, meta?: Record<string, unknown>) => void): Promise<void>;
33
53
  export declare function startWatchers(opts: WatcherServiceOpts): Promise<() => void>;
@@ -4,8 +4,8 @@
4
4
  * Payment flow:
5
5
  * 1. Watcher detects payment → handlePayment()
6
6
  * 2. Accumulate native amount (supports partial payments)
7
- * 3. When totalReceived >= expectedAmount → settle + credit
8
- * 4. Every payment (partial or full) enqueues a webhook delivery
7
+ * 3. When totalReceived >= expectedAmount AND confirmations >= required confirmed + credit
8
+ * 4. Every payment/confirmation change enqueues a webhook delivery
9
9
  * 5. Outbox processor retries failed deliveries with exponential backoff
10
10
  *
11
11
  * Amount comparison is ALWAYS in native crypto units (sats, wei, token base units).
@@ -90,14 +90,19 @@ async function processDeliveries(db, allowedPrefixes, log) {
90
90
  }
91
91
  return delivered;
92
92
  }
93
- // --- Payment handling (partial + full) ---
94
93
  /**
95
94
  * Handle a payment event. Accumulates partial payments in native units.
96
- * Settles when totalReceived >= expectedAmount. Fires webhook on every payment.
95
+ * Fires webhook on every payment/confirmation change with canonical statuses.
97
96
  *
98
- * @param nativeAmount — received amount in native base units (sats for BTC/DOGE, raw token units for ERC20)
97
+ * 3-phase webhook lifecycle:
98
+ * 1. Tx first seen -> status: "partial", confirmations: 0
99
+ * 2. Each new block -> status: "partial", confirmations: current
100
+ * 3. Threshold reached + full payment -> status: "confirmed"
101
+ *
102
+ * @param nativeAmount — received amount in native base units (sats for BTC/DOGE, raw token units for ERC20).
103
+ * Pass "0" for confirmation-only updates (no new payment, just more confirmations).
99
104
  */
100
- async function handlePayment(db, chargeStore, address, nativeAmount, payload, log) {
105
+ export async function handlePayment(db, chargeStore, address, nativeAmount, payload, log) {
101
106
  const charge = await chargeStore.getByDepositAddress(address);
102
107
  if (!charge) {
103
108
  log("Payment to unknown address", { address });
@@ -106,39 +111,59 @@ async function handlePayment(db, chargeStore, address, nativeAmount, payload, lo
106
111
  if (charge.creditedAt) {
107
112
  return; // Already fully paid and credited
108
113
  }
109
- // Accumulate: add this payment to the running total
114
+ const { confirmations, confirmationsRequired, amountReceivedCents, txHash } = payload;
115
+ // Accumulate: add this payment to the running total (if nativeAmount > 0)
110
116
  const prevReceived = BigInt(charge.receivedAmount ?? "0");
111
117
  const thisPayment = BigInt(nativeAmount);
112
118
  const totalReceived = (prevReceived + thisPayment).toString();
113
119
  const expected = BigInt(charge.expectedAmount ?? "0");
114
120
  const isFull = expected > 0n && BigInt(totalReceived) >= expected;
115
- // Update received_amount in DB
116
- await db
117
- .update(cryptoCharges)
118
- .set({ receivedAmount: totalReceived, filledAmount: totalReceived })
119
- .where(eq(cryptoCharges.referenceId, charge.referenceId));
120
- if (isFull) {
121
- const settled = "Settled";
122
- await chargeStore.updateStatus(charge.referenceId, settled, charge.token ?? undefined, totalReceived);
121
+ const isConfirmed = isFull && confirmations >= confirmationsRequired;
122
+ // Update received_amount in DB (only when there's a new payment)
123
+ if (thisPayment > 0n) {
124
+ await db
125
+ .update(cryptoCharges)
126
+ .set({ receivedAmount: totalReceived, filledAmount: totalReceived })
127
+ .where(eq(cryptoCharges.referenceId, charge.referenceId));
128
+ }
129
+ // Determine canonical status
130
+ const status = isConfirmed ? "confirmed" : "partial";
131
+ // Update progress via new API
132
+ await chargeStore.updateProgress(charge.referenceId, {
133
+ status,
134
+ amountReceivedCents,
135
+ confirmations,
136
+ confirmationsRequired,
137
+ txHash,
138
+ });
139
+ if (isConfirmed) {
123
140
  await chargeStore.markCredited(charge.referenceId);
124
- log("Charge settled", { chargeId: charge.referenceId, expected: expected.toString(), received: totalReceived });
141
+ log("Charge confirmed", {
142
+ chargeId: charge.referenceId,
143
+ confirmations,
144
+ confirmationsRequired,
145
+ });
125
146
  }
126
147
  else {
127
- const processing = "Processing";
128
- await chargeStore.updateStatus(charge.referenceId, processing, charge.token ?? undefined, totalReceived);
129
- log("Partial payment", { chargeId: charge.referenceId, expected: expected.toString(), received: totalReceived });
148
+ log("Payment progress", {
149
+ chargeId: charge.referenceId,
150
+ confirmations,
151
+ confirmationsRequired,
152
+ received: totalReceived,
153
+ });
130
154
  }
131
- // Webhook on every payment — product shows progress to user
155
+ // Webhook on every event — product shows confirmation progress to user
132
156
  if (charge.callbackUrl) {
133
157
  await enqueueWebhook(db, charge.referenceId, charge.callbackUrl, {
134
158
  chargeId: charge.referenceId,
135
159
  chain: charge.chain,
136
160
  address: charge.depositAddress,
137
- expectedAmount: expected.toString(),
138
- receivedAmount: totalReceived,
139
- amountUsdCents: charge.amountUsdCents,
140
- status: isFull ? "confirmed" : "partial",
141
- ...payload,
161
+ amountExpectedCents: charge.amountUsdCents,
162
+ amountReceivedCents,
163
+ confirmations,
164
+ confirmationsRequired,
165
+ txHash,
166
+ status,
142
167
  });
143
168
  }
144
169
  }
@@ -181,11 +206,19 @@ export async function startWatchers(opts) {
181
206
  oracle,
182
207
  cursorStore,
183
208
  onPayment: async (event) => {
184
- log("UTXO payment", { chain: method.chain, address: event.address, txid: event.txid, sats: event.amountSats });
185
- // Pass native amount (sats) — NOT USD cents
209
+ log("UTXO payment", {
210
+ chain: method.chain,
211
+ address: event.address,
212
+ txid: event.txid,
213
+ sats: event.amountSats,
214
+ confirmations: event.confirmations,
215
+ confirmationsRequired: event.confirmationsRequired,
216
+ });
186
217
  await handlePayment(db, chargeStore, event.address, String(event.amountSats), {
187
218
  txHash: event.txid,
188
219
  confirmations: event.confirmations,
220
+ confirmationsRequired: event.confirmationsRequired,
221
+ amountReceivedCents: event.amountUsdCents,
189
222
  }, log);
190
223
  },
191
224
  });
@@ -247,11 +280,19 @@ export async function startWatchers(opts) {
247
280
  watchedAddresses: chainAddresses,
248
281
  cursorStore,
249
282
  onPayment: async (event) => {
250
- log("EVM payment", { chain: event.chain, token: event.token, to: event.to, txHash: event.txHash });
251
- // Pass native amount (raw token units) — NOT USD cents
283
+ log("EVM payment", {
284
+ chain: event.chain,
285
+ token: event.token,
286
+ to: event.to,
287
+ txHash: event.txHash,
288
+ confirmations: event.confirmations,
289
+ confirmationsRequired: event.confirmationsRequired,
290
+ });
252
291
  await handlePayment(db, chargeStore, event.to, event.rawAmount, {
253
292
  txHash: event.txHash,
254
- confirmations: method.confirmations,
293
+ confirmations: event.confirmations,
294
+ confirmationsRequired: event.confirmationsRequired,
295
+ amountReceivedCents: event.amountUsdCents,
255
296
  }, log);
256
297
  },
257
298
  });
@@ -105,6 +105,8 @@ export interface IPaymentProcessor {
105
105
  charge(opts: ChargeOpts): Promise<ChargeResult>;
106
106
  /** Detach a payment method from the tenant's account. */
107
107
  detachPaymentMethod(tenant: string, paymentMethodId: string): Promise<void>;
108
+ /** Set a payment method as the tenant's default for future invoices. */
109
+ setDefaultPaymentMethod(tenant: string, paymentMethodId: string): Promise<void>;
108
110
  /** Get the billing email for a tenant's customer account. Returns "" if no customer exists. */
109
111
  getCustomerEmail(tenantId: string): Promise<string>;
110
112
  /** Update the billing email for a tenant's customer account. */
@@ -45,6 +45,7 @@ describe("IPaymentProcessor types", () => {
45
45
  setupPaymentMethod: async () => ({ clientSecret: "cs" }),
46
46
  listPaymentMethods: async () => [],
47
47
  detachPaymentMethod: async () => undefined,
48
+ setDefaultPaymentMethod: async () => undefined,
48
49
  charge: async () => ({ success: true }),
49
50
  getCustomerEmail: async () => "",
50
51
  updateCustomerEmail: async () => undefined,
@@ -41,6 +41,7 @@ export declare class StripePaymentProcessor implements IPaymentProcessor {
41
41
  }>;
42
42
  setupPaymentMethod(tenant: string): Promise<SetupResult>;
43
43
  listPaymentMethods(tenant: string): Promise<SavedPaymentMethod[]>;
44
+ setDefaultPaymentMethod(tenant: string, paymentMethodId: string): Promise<void>;
44
45
  detachPaymentMethod(tenant: string, paymentMethodId: string): Promise<void>;
45
46
  getCustomerEmail(tenantId: string): Promise<string>;
46
47
  updateCustomerEmail(tenantId: string, email: string): Promise<void>;
@@ -86,20 +86,43 @@ export class StripePaymentProcessor {
86
86
  if (!mapping) {
87
87
  return [];
88
88
  }
89
- const methods = await this.stripe.customers.listPaymentMethods(mapping.processor_customer_id);
90
- return methods.data.map((pm, index) => ({
89
+ const [methods, customer] = await Promise.all([
90
+ this.stripe.customers.listPaymentMethods(mapping.processor_customer_id),
91
+ this.stripe.customers.retrieve(mapping.processor_customer_id),
92
+ ]);
93
+ const defaultPmId = !customer.deleted && customer.invoice_settings?.default_payment_method
94
+ ? typeof customer.invoice_settings.default_payment_method === "string"
95
+ ? customer.invoice_settings.default_payment_method
96
+ : customer.invoice_settings.default_payment_method.id
97
+ : null;
98
+ return methods.data.map((pm) => ({
91
99
  id: pm.id,
92
100
  label: formatPaymentMethodLabel(pm),
93
- isDefault: index === 0,
101
+ isDefault: defaultPmId ? pm.id === defaultPmId : false,
94
102
  }));
95
103
  }
104
+ async setDefaultPaymentMethod(tenant, paymentMethodId) {
105
+ const mapping = await this.tenantRepo.getByTenant(tenant);
106
+ if (!mapping) {
107
+ throw new Error(`No Stripe customer found for tenant: ${tenant}`);
108
+ }
109
+ const pm = await this.stripe.paymentMethods.retrieve(paymentMethodId);
110
+ const pmCustomerId = typeof pm.customer === "string" ? pm.customer : (pm.customer?.id ?? null);
111
+ if (!pmCustomerId || pmCustomerId !== mapping.processor_customer_id) {
112
+ throw new PaymentMethodOwnershipError();
113
+ }
114
+ await this.stripe.customers.update(mapping.processor_customer_id, {
115
+ invoice_settings: { default_payment_method: paymentMethodId },
116
+ });
117
+ }
96
118
  async detachPaymentMethod(tenant, paymentMethodId) {
97
119
  const mapping = await this.tenantRepo.getByTenant(tenant);
98
120
  if (!mapping) {
99
121
  throw new Error(`No Stripe customer found for tenant: ${tenant}`);
100
122
  }
101
123
  const pm = await this.stripe.paymentMethods.retrieve(paymentMethodId);
102
- if (!pm.customer || pm.customer !== mapping.processor_customer_id) {
124
+ const pmCustomerId = typeof pm.customer === "string" ? pm.customer : (pm.customer?.id ?? null);
125
+ if (!pmCustomerId || pmCustomerId !== mapping.processor_customer_id) {
103
126
  throw new PaymentMethodOwnershipError();
104
127
  }
105
128
  await this.stripe.paymentMethods.detach(paymentMethodId);