payment-kit 1.24.4 → 1.25.1

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 (116) hide show
  1. package/api/src/index.ts +3 -0
  2. package/api/src/libs/credit-utils.ts +21 -0
  3. package/api/src/libs/discount/discount.ts +13 -0
  4. package/api/src/libs/env.ts +5 -0
  5. package/api/src/libs/error.ts +14 -0
  6. package/api/src/libs/exchange-rate/coingecko-provider.ts +193 -0
  7. package/api/src/libs/exchange-rate/coinmarketcap-provider.ts +180 -0
  8. package/api/src/libs/exchange-rate/index.ts +5 -0
  9. package/api/src/libs/exchange-rate/service.ts +583 -0
  10. package/api/src/libs/exchange-rate/token-address-mapping.ts +84 -0
  11. package/api/src/libs/exchange-rate/token-addresses.json +2147 -0
  12. package/api/src/libs/exchange-rate/token-data-provider.ts +142 -0
  13. package/api/src/libs/exchange-rate/types.ts +114 -0
  14. package/api/src/libs/exchange-rate/validator.ts +319 -0
  15. package/api/src/libs/invoice-quote.ts +158 -0
  16. package/api/src/libs/invoice.ts +143 -7
  17. package/api/src/libs/math-utils.ts +46 -0
  18. package/api/src/libs/notification/template/billing-discrepancy.ts +3 -4
  19. package/api/src/libs/notification/template/customer-auto-recharge-failed.ts +174 -79
  20. package/api/src/libs/notification/template/customer-credit-grant-granted.ts +2 -3
  21. package/api/src/libs/notification/template/customer-credit-insufficient.ts +3 -3
  22. package/api/src/libs/notification/template/customer-credit-low-balance.ts +3 -3
  23. package/api/src/libs/notification/template/customer-revenue-succeeded.ts +2 -3
  24. package/api/src/libs/notification/template/customer-reward-succeeded.ts +9 -4
  25. package/api/src/libs/notification/template/exchange-rate-alert.ts +202 -0
  26. package/api/src/libs/notification/template/subscription-slippage-exceeded.ts +203 -0
  27. package/api/src/libs/notification/template/subscription-slippage-warning.ts +212 -0
  28. package/api/src/libs/notification/template/subscription-will-canceled.ts +2 -2
  29. package/api/src/libs/notification/template/subscription-will-renew.ts +22 -8
  30. package/api/src/libs/payment.ts +3 -1
  31. package/api/src/libs/price.ts +4 -1
  32. package/api/src/libs/queue/index.ts +8 -0
  33. package/api/src/libs/quote-service.ts +1132 -0
  34. package/api/src/libs/quote-validation.ts +388 -0
  35. package/api/src/libs/session.ts +686 -39
  36. package/api/src/libs/slippage.ts +135 -0
  37. package/api/src/libs/subscription.ts +185 -15
  38. package/api/src/libs/util.ts +64 -3
  39. package/api/src/locales/en.ts +50 -0
  40. package/api/src/locales/zh.ts +48 -0
  41. package/api/src/queues/auto-recharge.ts +295 -21
  42. package/api/src/queues/exchange-rate-health.ts +242 -0
  43. package/api/src/queues/invoice.ts +48 -1
  44. package/api/src/queues/notification.ts +167 -1
  45. package/api/src/queues/payment.ts +177 -7
  46. package/api/src/queues/refund.ts +41 -9
  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/payment-links.ts +13 -0
  66. package/api/src/routes/prices.ts +84 -2
  67. package/api/src/routes/subscriptions.ts +526 -15
  68. package/api/src/store/migrations/20251220-dynamic-pricing.ts +245 -0
  69. package/api/src/store/migrations/20251223-exchange-rate-provider-type.ts +28 -0
  70. package/api/src/store/migrations/20260110-add-quote-locked-at.ts +23 -0
  71. package/api/src/store/migrations/20260112-add-checkout-session-slippage-percent.ts +22 -0
  72. package/api/src/store/migrations/20260113-add-price-quote-slippage-fields.ts +45 -0
  73. package/api/src/store/migrations/20260116-subscription-slippage.ts +21 -0
  74. package/api/src/store/migrations/20260120-auto-recharge-slippage.ts +21 -0
  75. package/api/src/store/models/auto-recharge-config.ts +12 -0
  76. package/api/src/store/models/checkout-session.ts +7 -0
  77. package/api/src/store/models/exchange-rate-provider.ts +225 -0
  78. package/api/src/store/models/index.ts +6 -0
  79. package/api/src/store/models/payment-intent.ts +6 -0
  80. package/api/src/store/models/price-quote.ts +284 -0
  81. package/api/src/store/models/price.ts +53 -5
  82. package/api/src/store/models/subscription.ts +11 -0
  83. package/api/src/store/models/types.ts +61 -1
  84. package/api/tests/libs/change-payment-plan.spec.ts +282 -0
  85. package/api/tests/libs/exchange-rate-service.spec.ts +341 -0
  86. package/api/tests/libs/quote-service.spec.ts +199 -0
  87. package/api/tests/libs/session.spec.ts +464 -0
  88. package/api/tests/libs/slippage.spec.ts +109 -0
  89. package/api/tests/libs/token-data-provider.spec.ts +267 -0
  90. package/api/tests/models/exchange-rate-provider.spec.ts +121 -0
  91. package/api/tests/models/price-dynamic.spec.ts +100 -0
  92. package/api/tests/models/price-quote.spec.ts +112 -0
  93. package/api/tests/routes/exchange-rate-providers.spec.ts +215 -0
  94. package/api/tests/routes/subscription-slippage.spec.ts +254 -0
  95. package/blocklet.yml +1 -1
  96. package/package.json +7 -6
  97. package/src/components/customer/credit-overview.tsx +14 -0
  98. package/src/components/discount/discount-info.tsx +8 -2
  99. package/src/components/invoice/list.tsx +146 -16
  100. package/src/components/invoice/table.tsx +276 -71
  101. package/src/components/invoice-pdf/template.tsx +3 -7
  102. package/src/components/metadata/form.tsx +6 -8
  103. package/src/components/price/form.tsx +519 -149
  104. package/src/components/promotion/active-redemptions.tsx +5 -3
  105. package/src/components/quote/info.tsx +234 -0
  106. package/src/hooks/subscription.ts +132 -2
  107. package/src/locales/en.tsx +145 -0
  108. package/src/locales/zh.tsx +143 -1
  109. package/src/pages/admin/billing/invoices/detail.tsx +41 -4
  110. package/src/pages/admin/products/exchange-rate-providers/edit-dialog.tsx +354 -0
  111. package/src/pages/admin/products/exchange-rate-providers/index.tsx +363 -0
  112. package/src/pages/admin/products/index.tsx +12 -1
  113. package/src/pages/customer/invoice/detail.tsx +36 -12
  114. package/src/pages/customer/subscription/change-payment.tsx +65 -3
  115. package/src/pages/customer/subscription/change-plan.tsx +207 -38
  116. 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) {
@@ -119,6 +119,18 @@ import {
119
119
  WebhookEndpointFailedEmailTemplate,
120
120
  WebhookEndpointFailedEmailTemplateOptions,
121
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';
122
134
  import type { TJob } from '../store/models/job';
123
135
 
124
136
  export type NotificationQueueJobOptions = any;
@@ -145,7 +157,11 @@ export type NotificationQueueJobType =
145
157
  | 'customer.credit.low_balance'
146
158
  | 'customer.auto_recharge.failed'
147
159
  | 'customer.auto_recharge.daily_limit_exceeded'
148
- | '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';
149
165
 
150
166
  export type NotificationQueueJob = {
151
167
  type: NotificationQueueJobType;
@@ -301,6 +317,21 @@ async function getNotificationTemplate(job: NotificationQueueJob): Promise<BaseE
301
317
  return new WebhookEndpointFailedEmailTemplate(job.options as WebhookEndpointFailedEmailTemplateOptions);
302
318
  }
303
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
+
304
335
  throw new Error(`Unknown job type: ${job.type}`);
305
336
  }
306
337
 
@@ -741,6 +772,141 @@ export async function startNotificationQueue() {
741
772
  }
742
773
  );
743
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
+
744
910
  // Clear credit notification cache when customer recharges
745
911
  // This allows customer to receive insufficient/low_balance notifications again
746
912
  events.on('customer.credit_grant.granted', (creditGrant: CreditGrant) => {
@@ -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: result.reason,
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(result.reason, '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,
@@ -3,7 +3,7 @@ import { isRefundReasonSupportedByStripe } from '../libs/refund';
3
3
  import { checkRemainingStake, getSubscriptionStakeAddress } from '../libs/subscription';
4
4
  import { sendErc20ToUser } from '../integrations/ethereum/token';
5
5
  import { wallet } from '../libs/auth';
6
- import CustomError from '../libs/error';
6
+ import CustomError, { NonRetryableError } from '../libs/error';
7
7
  import { events } from '../libs/event';
8
8
  import logger from '../libs/logger';
9
9
  import { getGasPayerExtra, isBalanceSufficientForRefund } from '../libs/payment';
@@ -35,6 +35,21 @@ type Updates = {
35
35
  };
36
36
  };
37
37
 
38
+ const markRefundNonRetryable = async (refund: Refund, code: string, message: string, paymentMethod?: PaymentMethod) => {
39
+ await refund.update({
40
+ status: 'requires_action',
41
+ last_attempt_error: {
42
+ type: 'invalid_request_error',
43
+ code,
44
+ message,
45
+ payment_method_id: paymentMethod?.id,
46
+ payment_method_type: paymentMethod?.type,
47
+ },
48
+ attempt_count: refund.attempt_count + 1,
49
+ attempted: true,
50
+ });
51
+ };
52
+
38
53
  export const handleRefundFailed = (refund: Refund, error: PaymentError) => {
39
54
  const attemptCount = refund.attempt_count + 1;
40
55
  const updates: Updates = {
@@ -94,24 +109,27 @@ export const handleRefund = async (job: RefundJob) => {
94
109
  const paymentCurrency = await PaymentCurrency.findByPk(refund.currency_id);
95
110
  if (!paymentCurrency) {
96
111
  logger.warn(`PaymentCurrency not found: ${refund.currency_id}`);
112
+ await markRefundNonRetryable(refund, 'CURRENCY_NOT_FOUND', 'Payment currency not found');
97
113
  return;
98
114
  }
99
115
  const paymentMethod = await PaymentMethod.findByPk(paymentCurrency.payment_method_id);
100
116
  if (!paymentMethod) {
101
117
  logger.warn(`PaymentMethod not found: ${paymentCurrency.payment_method_id}`);
118
+ await markRefundNonRetryable(refund, 'PAYMENT_METHOD_NOT_FOUND', 'Payment method not found');
102
119
  return;
103
120
  }
104
121
 
105
122
  const customer = await Customer.findByPk(refund.customer_id);
106
123
  if (!customer) {
107
124
  logger.warn(`Customer not found: ${refund.customer_id}`);
125
+ await markRefundNonRetryable(refund, 'CUSTOMER_NOT_FOUND', 'Customer not found', paymentMethod);
108
126
  return;
109
127
  }
110
128
 
111
129
  if (refund?.type === 'stake_return') {
112
- handleStakeReturnJob(job, refund, paymentCurrency, paymentMethod, customer);
130
+ await handleStakeReturnJob(job, refund, paymentCurrency, paymentMethod, customer);
113
131
  } else if (paymentMethod) {
114
- handleRefundJob(job, refund, paymentCurrency, paymentMethod, customer);
132
+ await handleRefundJob(job, refund, paymentCurrency, paymentMethod, customer);
115
133
  }
116
134
  };
117
135
 
@@ -164,18 +182,26 @@ const handleRefundJob = async (
164
182
  const refundSupport = ['arcblock', 'ethereum', 'stripe', 'base'].includes(paymentMethod.type);
165
183
  if (refundSupport === false) {
166
184
  logger.warn(`PaymentMethod does not support auto charge: ${paymentCurrency.payment_method_id}`);
185
+ await markRefundNonRetryable(
186
+ refund,
187
+ 'PAYMENT_METHOD_NOT_SUPPORTED',
188
+ 'Payment method does not support refund',
189
+ paymentMethod
190
+ );
167
191
  return;
168
192
  }
169
193
 
170
194
  // try refund transfer and reschedule on error
171
195
  logger.info('Refund transfer attempt', { id: refund.id, attempt: refund.attempt_count });
172
- let result;
173
- const paymentIntent = await PaymentIntent.findByPk(refund.payment_intent_id);
174
- if (!paymentIntent) {
175
- throw new Error('PaymentIntent not found');
176
- }
177
-
178
196
  try {
197
+ if (!refund.payment_intent_id) {
198
+ throw new NonRetryableError('PAYMENT_INTENT_NOT_FOUND', 'payment_intent_id is missing for refund');
199
+ }
200
+ const paymentIntent = await PaymentIntent.findByPk(refund.payment_intent_id);
201
+ if (!paymentIntent) {
202
+ throw new NonRetryableError('PAYMENT_INTENT_NOT_FOUND', 'PaymentIntent not found');
203
+ }
204
+ let result;
179
205
  if (paymentMethod.type === 'arcblock') {
180
206
  const client = paymentMethod.getOcapClient();
181
207
  // check balance before transfer with transaction
@@ -288,6 +314,12 @@ const handleRefundJob = async (
288
314
  } catch (err) {
289
315
  logger.error('refund transfer failed', { error: err, id: refund.id });
290
316
 
317
+ if (err instanceof NonRetryableError || err?.nonRetryable === true) {
318
+ await markRefundNonRetryable(refund, err.code, err.message, paymentMethod);
319
+ logger.error('refund transfer aborted: non-retryable error', { id: refund.id, code: err.code });
320
+ return;
321
+ }
322
+
291
323
  const error: PaymentError = {
292
324
  type: 'card_error',
293
325
  code: err.code,