payment-kit 1.24.3 → 1.25.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 (117) hide show
  1. package/api/src/crons/overdue-detection.ts +10 -1
  2. package/api/src/index.ts +3 -0
  3. package/api/src/libs/credit-utils.ts +21 -0
  4. package/api/src/libs/discount/discount.ts +13 -0
  5. package/api/src/libs/env.ts +5 -0
  6. package/api/src/libs/error.ts +14 -0
  7. package/api/src/libs/exchange-rate/coingecko-provider.ts +193 -0
  8. package/api/src/libs/exchange-rate/coinmarketcap-provider.ts +180 -0
  9. package/api/src/libs/exchange-rate/index.ts +5 -0
  10. package/api/src/libs/exchange-rate/service.ts +583 -0
  11. package/api/src/libs/exchange-rate/token-address-mapping.ts +84 -0
  12. package/api/src/libs/exchange-rate/token-addresses.json +2147 -0
  13. package/api/src/libs/exchange-rate/token-data-provider.ts +142 -0
  14. package/api/src/libs/exchange-rate/types.ts +114 -0
  15. package/api/src/libs/exchange-rate/validator.ts +319 -0
  16. package/api/src/libs/invoice-quote.ts +158 -0
  17. package/api/src/libs/invoice.ts +143 -7
  18. package/api/src/libs/math-utils.ts +46 -0
  19. package/api/src/libs/notification/template/billing-discrepancy.ts +3 -4
  20. package/api/src/libs/notification/template/customer-auto-recharge-failed.ts +174 -79
  21. package/api/src/libs/notification/template/customer-credit-grant-granted.ts +2 -3
  22. package/api/src/libs/notification/template/customer-credit-insufficient.ts +3 -3
  23. package/api/src/libs/notification/template/customer-credit-low-balance.ts +3 -3
  24. package/api/src/libs/notification/template/customer-revenue-succeeded.ts +2 -3
  25. package/api/src/libs/notification/template/customer-reward-succeeded.ts +9 -4
  26. package/api/src/libs/notification/template/exchange-rate-alert.ts +202 -0
  27. package/api/src/libs/notification/template/subscription-slippage-exceeded.ts +203 -0
  28. package/api/src/libs/notification/template/subscription-slippage-warning.ts +212 -0
  29. package/api/src/libs/notification/template/subscription-will-canceled.ts +2 -2
  30. package/api/src/libs/notification/template/subscription-will-renew.ts +22 -8
  31. package/api/src/libs/payment.ts +1 -1
  32. package/api/src/libs/price.ts +4 -1
  33. package/api/src/libs/queue/index.ts +8 -0
  34. package/api/src/libs/quote-service.ts +1132 -0
  35. package/api/src/libs/quote-validation.ts +388 -0
  36. package/api/src/libs/session.ts +686 -39
  37. package/api/src/libs/slippage.ts +135 -0
  38. package/api/src/libs/subscription.ts +185 -15
  39. package/api/src/libs/util.ts +64 -3
  40. package/api/src/locales/en.ts +50 -0
  41. package/api/src/locales/zh.ts +48 -0
  42. package/api/src/queues/auto-recharge.ts +295 -21
  43. package/api/src/queues/exchange-rate-health.ts +242 -0
  44. package/api/src/queues/invoice.ts +48 -1
  45. package/api/src/queues/notification.ts +190 -3
  46. package/api/src/queues/payment.ts +177 -7
  47. package/api/src/queues/subscription.ts +436 -6
  48. package/api/src/routes/auto-recharge-configs.ts +71 -6
  49. package/api/src/routes/checkout-sessions.ts +1730 -81
  50. package/api/src/routes/connect/auto-recharge-auth.ts +2 -0
  51. package/api/src/routes/connect/change-payer.ts +2 -0
  52. package/api/src/routes/connect/change-payment.ts +61 -8
  53. package/api/src/routes/connect/change-plan.ts +161 -17
  54. package/api/src/routes/connect/collect.ts +9 -6
  55. package/api/src/routes/connect/delegation.ts +1 -0
  56. package/api/src/routes/connect/pay.ts +157 -0
  57. package/api/src/routes/connect/setup.ts +32 -10
  58. package/api/src/routes/connect/shared.ts +159 -13
  59. package/api/src/routes/connect/subscribe.ts +32 -9
  60. package/api/src/routes/credit-grants.ts +99 -0
  61. package/api/src/routes/exchange-rate-providers.ts +248 -0
  62. package/api/src/routes/exchange-rates.ts +87 -0
  63. package/api/src/routes/index.ts +4 -0
  64. package/api/src/routes/invoices.ts +280 -2
  65. package/api/src/routes/meter-events.ts +3 -0
  66. package/api/src/routes/payment-links.ts +13 -0
  67. package/api/src/routes/prices.ts +84 -2
  68. package/api/src/routes/subscriptions.ts +526 -15
  69. package/api/src/store/migrations/20251220-dynamic-pricing.ts +245 -0
  70. package/api/src/store/migrations/20251223-exchange-rate-provider-type.ts +28 -0
  71. package/api/src/store/migrations/20260110-add-quote-locked-at.ts +23 -0
  72. package/api/src/store/migrations/20260112-add-checkout-session-slippage-percent.ts +22 -0
  73. package/api/src/store/migrations/20260113-add-price-quote-slippage-fields.ts +45 -0
  74. package/api/src/store/migrations/20260116-subscription-slippage.ts +21 -0
  75. package/api/src/store/migrations/20260120-auto-recharge-slippage.ts +21 -0
  76. package/api/src/store/models/auto-recharge-config.ts +12 -0
  77. package/api/src/store/models/checkout-session.ts +7 -0
  78. package/api/src/store/models/exchange-rate-provider.ts +225 -0
  79. package/api/src/store/models/index.ts +6 -0
  80. package/api/src/store/models/payment-intent.ts +6 -0
  81. package/api/src/store/models/price-quote.ts +284 -0
  82. package/api/src/store/models/price.ts +53 -5
  83. package/api/src/store/models/subscription.ts +11 -0
  84. package/api/src/store/models/types.ts +61 -1
  85. package/api/tests/libs/change-payment-plan.spec.ts +282 -0
  86. package/api/tests/libs/exchange-rate-service.spec.ts +341 -0
  87. package/api/tests/libs/quote-service.spec.ts +199 -0
  88. package/api/tests/libs/session.spec.ts +464 -0
  89. package/api/tests/libs/slippage.spec.ts +109 -0
  90. package/api/tests/libs/token-data-provider.spec.ts +267 -0
  91. package/api/tests/models/exchange-rate-provider.spec.ts +121 -0
  92. package/api/tests/models/price-dynamic.spec.ts +100 -0
  93. package/api/tests/models/price-quote.spec.ts +112 -0
  94. package/api/tests/routes/exchange-rate-providers.spec.ts +215 -0
  95. package/api/tests/routes/subscription-slippage.spec.ts +254 -0
  96. package/blocklet.yml +1 -1
  97. package/package.json +7 -6
  98. package/src/components/customer/credit-overview.tsx +14 -0
  99. package/src/components/discount/discount-info.tsx +8 -2
  100. package/src/components/invoice/list.tsx +146 -16
  101. package/src/components/invoice/table.tsx +276 -71
  102. package/src/components/invoice-pdf/template.tsx +3 -7
  103. package/src/components/metadata/form.tsx +6 -8
  104. package/src/components/price/form.tsx +519 -149
  105. package/src/components/promotion/active-redemptions.tsx +5 -3
  106. package/src/components/quote/info.tsx +234 -0
  107. package/src/hooks/subscription.ts +132 -2
  108. package/src/locales/en.tsx +145 -0
  109. package/src/locales/zh.tsx +143 -1
  110. package/src/pages/admin/billing/invoices/detail.tsx +41 -4
  111. package/src/pages/admin/products/exchange-rate-providers/edit-dialog.tsx +354 -0
  112. package/src/pages/admin/products/exchange-rate-providers/index.tsx +363 -0
  113. package/src/pages/admin/products/index.tsx +12 -1
  114. package/src/pages/customer/invoice/detail.tsx +36 -12
  115. package/src/pages/customer/subscription/change-payment.tsx +65 -3
  116. package/src/pages/customer/subscription/change-plan.tsx +207 -38
  117. package/src/pages/customer/subscription/detail.tsx +599 -419
@@ -5,11 +5,13 @@ import { createEvent } from '../libs/audit';
5
5
  import dayjs from '../libs/dayjs';
6
6
  import logger from '../libs/logger';
7
7
  import createQueue from '../libs/queue';
8
- import { PaymentMethod, Invoice } from '../store/models';
8
+ import { PaymentMethod, Invoice, InvoiceItem } from '../store/models';
9
9
  import { CheckoutSession } from '../store/models/checkout-session';
10
10
  import { PaymentIntent } from '../store/models/payment-intent';
11
11
  import { Subscription } from '../store/models/subscription';
12
+ import { PriceQuote } from '../store/models/price-quote';
12
13
  import { paymentQueue } from './payment';
14
+ import { getQuoteService } from '../libs/quote-service';
13
15
 
14
16
  import { getLock } from '../libs/lock';
15
17
  import { events } from '../libs/event';
@@ -280,6 +282,51 @@ events.on('invoice.paid', async ({ id: invoiceId }) => {
280
282
  await handleOverdraftProtectionInvoiceAfterPayment(invoice);
281
283
  });
282
284
 
285
+ // Separate listener to mark quotes as paid - independent of other invoice.paid handlers
286
+ events.on('invoice.paid', async ({ id: invoiceId }) => {
287
+ try {
288
+ const invoiceItems = await InvoiceItem.findAll({
289
+ where: { invoice_id: invoiceId },
290
+ });
291
+
292
+ const quoteIds = invoiceItems
293
+ .map((item) => item.metadata?.quote_id)
294
+ .filter((id): id is string => typeof id === 'string' && id.length > 0);
295
+
296
+ if (quoteIds.length === 0) {
297
+ return;
298
+ }
299
+
300
+ const quoteService = getQuoteService();
301
+ const markedQuoteIds: string[] = [];
302
+
303
+ await Promise.all(
304
+ quoteIds.map(async (quoteId) => {
305
+ try {
306
+ const quote = await PriceQuote.findByPk(quoteId);
307
+ if (quote && quote.status !== 'paid') {
308
+ await quote.update({ invoice_id: invoiceId });
309
+ await quoteService.markAsPaid(quoteId);
310
+ markedQuoteIds.push(quoteId);
311
+ }
312
+ } catch (err: any) {
313
+ logger.warn('Failed to mark quote as paid', { quoteId, invoiceId, error: err.message });
314
+ }
315
+ })
316
+ );
317
+
318
+ if (markedQuoteIds.length > 0) {
319
+ logger.info('Marked quotes as paid for invoice', {
320
+ invoiceId,
321
+ quoteIds: markedQuoteIds,
322
+ totalQuotes: quoteIds.length,
323
+ });
324
+ }
325
+ } catch (err: any) {
326
+ logger.error('Failed to process quotes for paid invoice', { invoiceId, error: err.message });
327
+ }
328
+ });
329
+
283
330
  events.on('invoice.queued', async (id, job, args = {}) => {
284
331
  const { sync, ...extraArgs } = args;
285
332
  if (sync) {
@@ -1,4 +1,5 @@
1
1
  import { Op } from 'sequelize';
2
+ import debounce from 'lodash/debounce';
2
3
  /* eslint-disable @typescript-eslint/indent */
3
4
  import { events } from '../libs/event';
4
5
  import logger from '../libs/logger';
@@ -118,6 +119,18 @@ import {
118
119
  WebhookEndpointFailedEmailTemplate,
119
120
  WebhookEndpointFailedEmailTemplateOptions,
120
121
  } from '../libs/notification/template/webhook-endpoint-failed';
122
+ import {
123
+ SubscriptionSlippageWarningEmailTemplate,
124
+ SubscriptionSlippageWarningEmailTemplateOptions,
125
+ } from '../libs/notification/template/subscription-slippage-warning';
126
+ import {
127
+ SubscriptionSlippageExceededEmailTemplate,
128
+ SubscriptionSlippageExceededEmailTemplateOptions,
129
+ } from '../libs/notification/template/subscription-slippage-exceeded';
130
+ import {
131
+ ExchangeRateAlertEmailTemplate,
132
+ ExchangeRateAlertEmailTemplateOptions,
133
+ } from '../libs/notification/template/exchange-rate-alert';
121
134
  import type { TJob } from '../store/models/job';
122
135
 
123
136
  export type NotificationQueueJobOptions = any;
@@ -144,7 +157,11 @@ export type NotificationQueueJobType =
144
157
  | 'customer.credit.low_balance'
145
158
  | 'customer.auto_recharge.failed'
146
159
  | 'customer.auto_recharge.daily_limit_exceeded'
147
- | 'webhook.endpoint.failed';
160
+ | 'webhook.endpoint.failed'
161
+ | 'exchange_rate.providers_unavailable'
162
+ | 'exchange_rate.spread_exceeded'
163
+ | 'subscription.slippage_warning'
164
+ | 'subscription.slippage_exceeded';
148
165
 
149
166
  export type NotificationQueueJob = {
150
167
  type: NotificationQueueJobType;
@@ -300,6 +317,21 @@ async function getNotificationTemplate(job: NotificationQueueJob): Promise<BaseE
300
317
  return new WebhookEndpointFailedEmailTemplate(job.options as WebhookEndpointFailedEmailTemplateOptions);
301
318
  }
302
319
 
320
+ if (job.type === 'subscription.slippage_warning') {
321
+ return new SubscriptionSlippageWarningEmailTemplate(job.options as SubscriptionSlippageWarningEmailTemplateOptions);
322
+ }
323
+
324
+ if (job.type === 'subscription.slippage_exceeded') {
325
+ return new SubscriptionSlippageExceededEmailTemplate(
326
+ job.options as SubscriptionSlippageExceededEmailTemplateOptions
327
+ );
328
+ }
329
+
330
+ // Exchange rate alerts (admin notifications)
331
+ if (job.type === 'exchange_rate.providers_unavailable' || job.type === 'exchange_rate.spread_exceeded') {
332
+ return new ExchangeRateAlertEmailTemplate(job.options as ExchangeRateAlertEmailTemplateOptions);
333
+ }
334
+
303
335
  throw new Error(`Unknown job type: ${job.type}`);
304
336
  }
305
337
 
@@ -337,6 +369,8 @@ interface NotificationItem<T = Record<string, any>> {
337
369
 
338
370
  // 内存缓存,记录最近发送的通知
339
371
  const notificationCache = new Map<string, number>();
372
+ // 去抖函数(按 key 去重)
373
+ const debounceHandlers = new Map<string, ReturnType<typeof debounce>>();
340
374
 
341
375
  // 清理过期缓存的定时器
342
376
  setInterval(
@@ -738,11 +772,164 @@ export async function startNotificationQueue() {
738
772
  }
739
773
  );
740
774
 
775
+ // System events - Exchange rate alerts (admin notifications)
776
+ events.on('exchange_rate.providers_unavailable', (alertData: Record<string, any>) => {
777
+ logger.info('Exchange rate providers unavailable event received', { alertData });
778
+ addNotificationJob(
779
+ 'exchange_rate.providers_unavailable',
780
+ alertData,
781
+ ['exchange_rate.providers_unavailable', alertData.symbol],
782
+ true, // Prevent duplicate
783
+ 30 * 60 // 30 minutes window
784
+ );
785
+ });
786
+
787
+ events.on('exchange_rate.spread_exceeded', (alertData: Record<string, any>) => {
788
+ logger.info('Exchange rate spread exceeded event received', { alertData });
789
+ addNotificationJob(
790
+ 'exchange_rate.spread_exceeded',
791
+ alertData,
792
+ ['exchange_rate.spread_exceeded', alertData.symbol],
793
+ true, // Prevent duplicate
794
+ 30 * 60 // 30 minutes window (matches service rate limit)
795
+ );
796
+ });
797
+
798
+ // Auto-recharge skipped due to dynamic pricing constraints
799
+ events.on(
800
+ 'auto_recharge.skipped',
801
+ async (data: {
802
+ config_id: string;
803
+ customer_id: string;
804
+ reason: 'slippage_exceeded' | 'exchange_rate_not_supported' | 'exchange_rate_fetch_failed';
805
+ current_rate?: string;
806
+ min_acceptable_rate?: string;
807
+ payment_currency_symbol?: string;
808
+ payment_currency_name?: string;
809
+ credit_currency_name?: string;
810
+ }) => {
811
+ logger.info('Auto recharge skipped event received', {
812
+ configId: data.config_id,
813
+ customerId: data.customer_id,
814
+ reason: data.reason,
815
+ currentRate: data.current_rate,
816
+ minAcceptableRate: data.min_acceptable_rate,
817
+ paymentCurrencySymbol: data.payment_currency_symbol,
818
+ });
819
+
820
+ // Reuse customer.auto_recharge.failed notification with skipped reason
821
+ // This ensures users are notified when auto-recharge cannot proceed
822
+ const customer = await Customer.findByPk(data.customer_id);
823
+ if (customer) {
824
+ addNotificationJob(
825
+ 'customer.auto_recharge.failed',
826
+ {
827
+ customerId: data.customer_id,
828
+ autoRechargeConfigId: data.config_id,
829
+ result: {
830
+ sufficient: false,
831
+ reason: data.reason,
832
+ currentRate: data.current_rate,
833
+ minAcceptableRate: data.min_acceptable_rate,
834
+ paymentCurrencySymbol: data.payment_currency_symbol,
835
+ paymentCurrencyName: data.payment_currency_name,
836
+ creditCurrencyName: data.credit_currency_name,
837
+ },
838
+ },
839
+ [data.customer_id, data.config_id, data.reason],
840
+ true, // Prevent duplicate notifications
841
+ 6 * 3600 // 6 hours window - avoid spamming if rate stays bad
842
+ );
843
+ }
844
+ }
845
+ );
846
+
847
+ // Slippage exceeded notification
848
+ events.on(
849
+ 'subscription.slippage_exceeded',
850
+ (
851
+ subscription: Subscription,
852
+ {
853
+ invoiceId,
854
+ paymentIntentId,
855
+ currentRate,
856
+ minAcceptableRate,
857
+ }: {
858
+ invoiceId: string;
859
+ paymentIntentId: string;
860
+ currentRate: string;
861
+ minAcceptableRate: string;
862
+ }
863
+ ) => {
864
+ addNotificationJob(
865
+ 'subscription.slippage_exceeded',
866
+ {
867
+ subscriptionId: subscription.id,
868
+ invoiceId,
869
+ paymentIntentId,
870
+ currentRate,
871
+ minAcceptableRate,
872
+ },
873
+ [subscription.id, invoiceId],
874
+ true, // Prevent duplicate
875
+ 24 * 3600 // 24 hours window
876
+ );
877
+ }
878
+ );
879
+
880
+ // Slippage warning notification (1 hour before renewal)
881
+ events.on(
882
+ 'subscription.slippage_warning',
883
+ (
884
+ subscription: Subscription,
885
+ {
886
+ currentRate,
887
+ minAcceptableRate,
888
+ renewalTime,
889
+ }: {
890
+ currentRate: string;
891
+ minAcceptableRate: string;
892
+ renewalTime: number;
893
+ }
894
+ ) => {
895
+ addNotificationJob(
896
+ 'subscription.slippage_warning',
897
+ {
898
+ subscriptionId: subscription.id,
899
+ currentRate,
900
+ minAcceptableRate,
901
+ renewalTime,
902
+ },
903
+ [subscription.id],
904
+ true, // Prevent duplicate
905
+ 3600 // 1 hour window - avoid duplicate warnings
906
+ );
907
+ }
908
+ );
909
+
741
910
  // Clear credit notification cache when customer recharges
742
911
  // This allows customer to receive insufficient/low_balance notifications again
743
912
  events.on('customer.credit_grant.granted', (creditGrant: CreditGrant) => {
744
- clearNotificationCache(`customer.credit.insufficient.${creditGrant.customer_id}.${creditGrant.currency_id}`);
745
- clearNotificationCache(`customer.credit.low_balance.${creditGrant.customer_id}.${creditGrant.currency_id}`);
913
+ const cacheKeyBase = `${creditGrant.customer_id}.${creditGrant.currency_id}`;
914
+ const debounceKey = `credit_grant_clear.${cacheKeyBase}`;
915
+ const delayMs = 5 * 60 * 1000;
916
+
917
+ let handler = debounceHandlers.get(debounceKey);
918
+ if (!handler) {
919
+ handler = debounce(() => {
920
+ clearNotificationCache(`customer.credit.insufficient.${cacheKeyBase}`);
921
+ clearNotificationCache(`customer.credit.low_balance.${cacheKeyBase}`);
922
+ debounceHandlers.delete(debounceKey);
923
+ logger.info('Notification cache cleared after delay', {
924
+ customerId: creditGrant.customer_id,
925
+ currencyId: creditGrant.currency_id,
926
+ delayMs,
927
+ });
928
+ }, delayMs);
929
+ debounceHandlers.set(debounceKey, handler);
930
+ }
931
+
932
+ handler();
746
933
  });
747
934
  }
748
935
 
@@ -11,6 +11,8 @@ import CustomError from '../libs/error';
11
11
  import { events } from '../libs/event';
12
12
  import logger from '../libs/logger';
13
13
  import { getGasPayerExtra, isDelegationSufficientForPayment, checkDepositVaultAmount } from '../libs/payment';
14
+ import { validateQuoteForPayment, handleExpiredQuotePayment } from '../libs/quote-validation';
15
+ import { getQuoteService } from '../libs/quote-service';
14
16
  import {
15
17
  checkRemainingStake,
16
18
  getDaysUntilCancel,
@@ -42,7 +44,7 @@ import { AutoRechargeConfig, Lock, MeterEvent } from '../store/models';
42
44
  import { ensureOverdraftProtectionPrice } from '../libs/overdraft-protection';
43
45
  import createQueue from '../libs/queue';
44
46
  import { CHARGE_SUPPORTED_CHAIN_TYPES, EVM_CHAIN_TYPES } from '../libs/constants';
45
- import { getCheckoutSessionSubscriptionIds, getSubscriptionCreateSetup } from '../libs/session';
47
+ import { getCheckoutSessionSubscriptionIds, getSubscriptionCreateSetup, SlippageOptions } from '../libs/session';
46
48
  import { syncStripeSubscriptionAfterRecovery } from '../integrations/stripe/handlers/subscription';
47
49
  import { getLock } from '../libs/lock';
48
50
 
@@ -214,7 +216,15 @@ export async function handlePastDueSubscriptionRecovery(
214
216
  // Reset billing cycle
215
217
  const subscriptionItems = await SubscriptionItem.findAll({ where: { subscription_id: subscription.id } });
216
218
  const lineItems = await Price.expand(subscriptionItems.map((x) => x.toJSON()));
217
- const setup = getSubscriptionCreateSetup(lineItems, subscription.currency_id, 0);
219
+ // Use the subscription's slippage config if available, otherwise use default 0.5%
220
+ const slippageConfig = subscription.slippage_config;
221
+ const currency = await PaymentCurrency.findByPk(subscription.currency_id);
222
+ const slippageOptions: SlippageOptions = {
223
+ percent: slippageConfig?.percent ?? 0.5,
224
+ minAcceptableRate: slippageConfig?.min_acceptable_rate,
225
+ currencyDecimal: currency?.decimal,
226
+ };
227
+ const setup = getSubscriptionCreateSetup(lineItems, subscription.currency_id, 0, 0, slippageOptions);
218
228
  await subscription.update({
219
229
  status: 'active',
220
230
  pending_invoice_item_interval: setup.recurring,
@@ -403,6 +413,18 @@ export const handlePaymentSucceed = async (
403
413
  logger.info(`Invoice ${invoice.id} updated on payment done: ${paymentIntent.id}`);
404
414
  }
405
415
 
416
+ // Mark quotes as paid after successful payment
417
+ // Query quotes by invoiceId - more reliable than depending on metadata
418
+ if (invoice?.id) {
419
+ const quoteService = getQuoteService();
420
+ const markedIds = await quoteService.markQuotesAsPaidByInvoice(invoice.id);
421
+ if (markedIds.length > 0) {
422
+ logger.info('Marked quotes as paid after successful payment', {
423
+ invoiceId: invoice.id,
424
+ quoteIds: markedIds,
425
+ });
426
+ }
427
+ }
406
428
  // Update subscription status
407
429
  if (invoice && invoice.subscription_id && !slashStake) {
408
430
  const subscription = await Subscription.findByPk(invoice.subscription_id);
@@ -919,6 +941,55 @@ export const handlePayment = async (job: PaymentJob) => {
919
941
  return;
920
942
  }
921
943
 
944
+ // Queue delay monitoring - critical for quote lock window observability
945
+ const queueDelayMs = Date.now() - new Date(paymentIntent.created_at).getTime();
946
+ const QUOTE_LOCK_WINDOW_MS = 180000; // 180 seconds
947
+
948
+ logger.info('Queue processing payment intent', {
949
+ paymentIntentId: paymentIntent.id,
950
+ queueDelayMs,
951
+ delaySeconds: Math.round(queueDelayMs / 1000),
952
+ lockWindowSeconds: QUOTE_LOCK_WINDOW_MS / 1000,
953
+ });
954
+
955
+ if (queueDelayMs > QUOTE_LOCK_WINDOW_MS) {
956
+ logger.warn('CRITICAL: Queue delay exceeds quote lock window', {
957
+ paymentIntentId: paymentIntent.id,
958
+ invoiceId: paymentIntent.invoice_id,
959
+ queueDelayMs,
960
+ delaySeconds: Math.round(queueDelayMs / 1000),
961
+ lockWindowSeconds: QUOTE_LOCK_WINDOW_MS / 1000,
962
+ riskLevel: 'HIGH - Quote lock expired, slippage protection critical',
963
+ });
964
+ }
965
+
966
+ const preQuoteValidation = await validateQuoteForPayment({
967
+ checkoutSessionId: invoice?.checkout_session_id,
968
+ invoiceId: paymentIntent.invoice_id,
969
+ amount: paymentIntent.amount,
970
+ });
971
+
972
+ if (!preQuoteValidation.valid) {
973
+ // Enhanced error logging with queue delay context
974
+ let failureReason = 'QUOTE_VALIDATION_FAILED';
975
+ if (preQuoteValidation.reason?.includes('QUOTE_MAX_PAYABLE_EXCEEDED')) {
976
+ failureReason = 'SLIPPAGE_EXCEEDED';
977
+ } else if (preQuoteValidation.reason?.includes('expired')) {
978
+ failureReason = 'QUOTE_EXPIRED';
979
+ }
980
+
981
+ logger.error('Payment aborted due to quote validation before capture', {
982
+ paymentIntentId: paymentIntent.id,
983
+ invoiceId: paymentIntent.invoice_id,
984
+ quoteId: preQuoteValidation.quoteId,
985
+ reason: preQuoteValidation.reason,
986
+ failureReason,
987
+ queueDelayMs,
988
+ delaySeconds: Math.round(queueDelayMs / 1000),
989
+ });
990
+ throw new CustomError(failureReason, `Payment validation failed: ${preQuoteValidation.reason || 'unknown'}`);
991
+ }
992
+
922
993
  await paymentIntent.update({ status: 'processing', last_payment_error: null });
923
994
 
924
995
  if (paymentMethod.type === 'arcblock') {
@@ -938,11 +1009,15 @@ export const handlePayment = async (job: PaymentJob) => {
938
1009
  });
939
1010
 
940
1011
  if (result.sufficient === false) {
941
- logger.error('PaymentIntent capture aborted on preCheck', {
942
- id: paymentIntent.id,
1012
+ logger.error('PaymentIntent capture aborted - insufficient balance/delegation', {
1013
+ paymentIntentId: paymentIntent.id,
1014
+ invoiceId: paymentIntent.invoice_id,
1015
+ failureReason: 'INSUFFICIENT_BALANCE',
943
1016
  result,
1017
+ queueDelayMs,
1018
+ delaySeconds: Math.round(queueDelayMs / 1000),
944
1019
  });
945
- throw new CustomError(result.reason, 'payer balance or delegation not sufficient for this payment');
1020
+ throw new CustomError('INSUFFICIENT_BALANCE', 'Payer balance or delegation not sufficient for this payment');
946
1021
  }
947
1022
 
948
1023
  const signed = await client.signTransferV2Tx({
@@ -978,6 +1053,50 @@ export const handlePayment = async (job: PaymentJob) => {
978
1053
  logger.info('PaymentIntent txHash', { txHash });
979
1054
  logger.info('PaymentIntent capture done', { id: paymentIntent.id, txHash });
980
1055
 
1056
+ // CRITICAL: Validate quote before marking payment as succeeded
1057
+ const quoteValidation = await validateQuoteForPayment({
1058
+ checkoutSessionId: undefined, // Will be found through invoice if exists
1059
+ invoiceId: paymentIntent.invoice_id,
1060
+ amount: paymentIntent.amount,
1061
+ });
1062
+
1063
+ if (!quoteValidation.valid) {
1064
+ if (quoteValidation.action === 'reject') {
1065
+ // Reject the payment - mark as failed
1066
+ logger.error('Automated payment rejected due to quote validation', {
1067
+ paymentIntentId: paymentIntent.id,
1068
+ invoiceId: paymentIntent.invoice_id,
1069
+ quoteId: quoteValidation.quoteId,
1070
+ reason: quoteValidation.reason,
1071
+ txHash,
1072
+ });
1073
+ throw new CustomError('quote_validation_failed', `Payment validation failed: ${quoteValidation.reason}`);
1074
+ }
1075
+
1076
+ if (quoteValidation.action === 'require_manual_review') {
1077
+ // Hold for manual review
1078
+ logger.error('Automated payment requires manual review due to expired quote', {
1079
+ paymentIntentId: paymentIntent.id,
1080
+ invoiceId: paymentIntent.invoice_id,
1081
+ quoteId: quoteValidation.quoteId,
1082
+ reason: quoteValidation.reason,
1083
+ txHash,
1084
+ });
1085
+
1086
+ await handleExpiredQuotePayment({
1087
+ quoteId: quoteValidation.quoteId!,
1088
+ checkoutSessionId: undefined,
1089
+ invoiceId: paymentIntent.invoice_id,
1090
+ paymentIntentId: paymentIntent.id,
1091
+ amount: paymentIntent.amount,
1092
+ currencyId: paymentIntent.currency_id,
1093
+ txHash,
1094
+ });
1095
+
1096
+ throw new CustomError('quote_expired', `Payment held for manual review: ${quoteValidation.reason}`);
1097
+ }
1098
+ }
1099
+
981
1100
  await paymentIntent.update({
982
1101
  status: 'succeeded',
983
1102
  last_payment_error: null,
@@ -1011,14 +1130,65 @@ export const handlePayment = async (job: PaymentJob) => {
1011
1130
  amount: paymentIntent.amount,
1012
1131
  });
1013
1132
  if (result.sufficient === false) {
1014
- logger.error('PaymentIntent capture aborted on preCheck', { id: paymentIntent.id, result });
1015
- throw new CustomError(result.reason, 'payer balance or delegation not sufficient for this payment');
1133
+ logger.error('PaymentIntent capture aborted - insufficient balance/delegation', {
1134
+ paymentIntentId: paymentIntent.id,
1135
+ invoiceId: paymentIntent.invoice_id,
1136
+ failureReason: 'INSUFFICIENT_BALANCE',
1137
+ result,
1138
+ queueDelayMs,
1139
+ delaySeconds: Math.round(queueDelayMs / 1000),
1140
+ });
1141
+ throw new CustomError('INSUFFICIENT_BALANCE', 'Payer balance or delegation not sufficient for this payment');
1016
1142
  }
1017
1143
 
1018
1144
  // do the capture
1019
1145
  const receipt = await transferErc20FromUser(client, paymentCurrency.contract, payer, paymentIntent.amount);
1020
1146
  logger.info('PaymentIntent capture done', { id: paymentIntent.id, txHash: receipt.hash });
1021
1147
 
1148
+ // CRITICAL: Validate quote before marking payment as succeeded
1149
+ const quoteValidation = await validateQuoteForPayment({
1150
+ checkoutSessionId: undefined, // Will be found through invoice if exists
1151
+ invoiceId: paymentIntent.invoice_id,
1152
+ amount: paymentIntent.amount,
1153
+ });
1154
+
1155
+ if (!quoteValidation.valid) {
1156
+ if (quoteValidation.action === 'reject') {
1157
+ // Reject the payment - mark as failed
1158
+ logger.error('Automated EVM payment rejected due to quote validation', {
1159
+ paymentIntentId: paymentIntent.id,
1160
+ invoiceId: paymentIntent.invoice_id,
1161
+ quoteId: quoteValidation.quoteId,
1162
+ reason: quoteValidation.reason,
1163
+ txHash: receipt.hash,
1164
+ });
1165
+ throw new CustomError('quote_validation_failed', `Payment validation failed: ${quoteValidation.reason}`);
1166
+ }
1167
+
1168
+ if (quoteValidation.action === 'require_manual_review') {
1169
+ // Hold for manual review
1170
+ logger.error('Automated EVM payment requires manual review due to expired quote', {
1171
+ paymentIntentId: paymentIntent.id,
1172
+ invoiceId: paymentIntent.invoice_id,
1173
+ quoteId: quoteValidation.quoteId,
1174
+ reason: quoteValidation.reason,
1175
+ txHash: receipt.hash,
1176
+ });
1177
+
1178
+ await handleExpiredQuotePayment({
1179
+ quoteId: quoteValidation.quoteId!,
1180
+ checkoutSessionId: undefined,
1181
+ invoiceId: paymentIntent.invoice_id,
1182
+ paymentIntentId: paymentIntent.id,
1183
+ amount: paymentIntent.amount,
1184
+ currencyId: paymentIntent.currency_id,
1185
+ txHash: receipt.hash,
1186
+ });
1187
+
1188
+ throw new CustomError('quote_expired', `Payment held for manual review: ${quoteValidation.reason}`);
1189
+ }
1190
+ }
1191
+
1022
1192
  await paymentIntent.update({
1023
1193
  status: 'succeeded',
1024
1194
  last_payment_error: null,