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
@@ -53,6 +53,7 @@ export default {
53
53
  billingThreshold: 0,
54
54
  items: expandedItems,
55
55
  requiredStake: false,
56
+ slippageConfig: autoRechargeConfig.slippage_config || undefined,
56
57
  }),
57
58
  });
58
59
 
@@ -79,6 +80,7 @@ export default {
79
80
  billingThreshold: 0,
80
81
  items: expandedItems,
81
82
  requiredStake: false,
83
+ slippageConfig: autoRechargeConfig.slippage_config || undefined,
82
84
  }),
83
85
  });
84
86
 
@@ -48,6 +48,7 @@ export default {
48
48
  billingThreshold,
49
49
  items,
50
50
  requiredStake: false,
51
+ slippageConfig: subscription?.slippage_config || undefined,
51
52
  }),
52
53
  });
53
54
  return claimsList;
@@ -70,6 +71,7 @@ export default {
70
71
  trialing,
71
72
  billingThreshold,
72
73
  items,
74
+ slippageConfig: subscription?.slippage_config || undefined,
73
75
  }),
74
76
  });
75
77
 
@@ -13,8 +13,9 @@ import {
13
13
  import { ensureStakeInvoice } from '../../libs/invoice';
14
14
  import { EVM_CHAIN_TYPES } from '../../libs/constants';
15
15
  import logger from '../../libs/logger';
16
- import { getFastCheckoutAmount } from '../../libs/session';
16
+ import { getFastCheckoutAmount, getSubscriptionCreateSetup, SlippageOptions } from '../../libs/session';
17
17
  import { isDelegationSufficientForPayment } from '../../libs/payment';
18
+ import { getQuoteService } from '../../libs/quote-service';
18
19
 
19
20
  export default {
20
21
  action: 'change-payment',
@@ -35,19 +36,34 @@ export default {
35
36
  const items = subscription!.items as TLineItemExpanded[];
36
37
  const trialing = true;
37
38
  const billingThreshold = Number(subscription.billing_thresholds?.amount_gte || 0);
38
- const fastCheckoutAmount = await getFastCheckoutAmount({
39
- items,
40
- mode: 'subscription',
41
- currencyId: paymentCurrency.id,
42
- trialing: false,
43
- });
39
+
40
+ // Calculate required amount considering slippage_config for dynamic pricing
41
+ const slippageConfig = subscription?.slippage_config;
42
+ let requiredAmount: string;
43
+ if (slippageConfig?.min_acceptable_rate) {
44
+ // Use slippage_config for precise calculation
45
+ const slippageOptions: SlippageOptions = {
46
+ percent: slippageConfig.percent ?? 0.5,
47
+ minAcceptableRate: slippageConfig.min_acceptable_rate,
48
+ currencyDecimal: paymentCurrency.decimal,
49
+ };
50
+ const setup = getSubscriptionCreateSetup(items, paymentCurrency.id, 0, 0, slippageOptions);
51
+ requiredAmount = setup.amount.setup;
52
+ } else {
53
+ requiredAmount = await getFastCheckoutAmount({
54
+ items,
55
+ mode: 'subscription',
56
+ currencyId: paymentCurrency.id,
57
+ trialing: false,
58
+ });
59
+ }
44
60
 
45
61
  if (paymentMethod.type === 'arcblock') {
46
62
  const delegation = await isDelegationSufficientForPayment({
47
63
  paymentMethod,
48
64
  paymentCurrency,
49
65
  userDid,
50
- amount: fastCheckoutAmount,
66
+ amount: requiredAmount,
51
67
  });
52
68
  const needDelegation = delegation.sufficient === false;
53
69
  const requiredStake = !subscription.billing_thresholds?.no_stake;
@@ -65,6 +81,7 @@ export default {
65
81
  billingThreshold,
66
82
  items,
67
83
  requiredStake,
84
+ slippageConfig: subscription?.slippage_config || undefined,
68
85
  }),
69
86
  });
70
87
  }
@@ -101,6 +118,7 @@ export default {
101
118
  trialing,
102
119
  billingThreshold,
103
120
  items,
121
+ slippageConfig: subscription?.slippage_config || undefined,
104
122
  }),
105
123
  });
106
124
 
@@ -183,6 +201,41 @@ export default {
183
201
  },
184
202
  });
185
203
 
204
+ // Create quotes for dynamic pricing items after payment method change
205
+ // @ts-ignore
206
+ const items = subscription!.items as TLineItemExpanded[];
207
+ const dynamicItems = items.filter(
208
+ (item: TLineItemExpanded) => (item.upsell_price || item.price)?.pricing_type === 'dynamic'
209
+ );
210
+ if (dynamicItems.length > 0) {
211
+ const quoteService = getQuoteService();
212
+ for (const item of dynamicItems) {
213
+ try {
214
+ const price = item.upsell_price || item.price;
215
+ // eslint-disable-next-line no-await-in-loop
216
+ await quoteService.getOrCreateQuote({
217
+ price_id: price.id,
218
+ target_currency_id: paymentCurrency.id,
219
+ quantity: item.quantity || 1,
220
+ idempotency_key_salt: `change-payment-${subscription!.id}-${Date.now()}`,
221
+ });
222
+ logger.info('Created quote for dynamic pricing item after payment change', {
223
+ subscriptionId: subscription!.id,
224
+ priceId: price.id,
225
+ currencyId: paymentCurrency.id,
226
+ });
227
+ } catch (error) {
228
+ logger.error('Failed to create quote for dynamic pricing item after payment change', {
229
+ subscriptionId: subscription!.id,
230
+ priceId: (item.upsell_price || item.price)?.id,
231
+ currencyId: paymentCurrency.id,
232
+ error,
233
+ });
234
+ // Continue with other items even if one fails
235
+ }
236
+ }
237
+ }
238
+
186
239
  await Lock.acquire(`${subscription.id}-change-plan`, subscription.current_period_end);
187
240
  // update stripe subscription
188
241
  await updateStripeSubscriptionAfterChangePayment(setupIntent, subscription);
@@ -1,12 +1,13 @@
1
1
  import { executeEvmTransaction, waitForEvmTxConfirm } from '../../integrations/ethereum/tx';
2
2
  import type { CallbackArgs } from '../../libs/auth';
3
3
  import { isDelegationSufficientForPayment } from '../../libs/payment';
4
- import { getFastCheckoutAmount } from '../../libs/session';
5
- import { onSubscriptionUpdateConnected } from '../../libs/subscription';
4
+ import { getFastCheckoutAmount, getSubscriptionCreateSetup, type SlippageOptions } from '../../libs/session';
5
+ import { checkRemainingStake, onSubscriptionUpdateConnected } from '../../libs/subscription';
6
6
  import { getTxMetadata } from '../../libs/util';
7
7
  import { invoiceQueue } from '../../queues/invoice';
8
8
  import { addSubscriptionJob, subscriptionQueue } from '../../queues/subscription';
9
9
  import type { TLineItemExpanded } from '../../store/models';
10
+ import { Invoice } from '../../store/models/invoice';
10
11
  import {
11
12
  ensureSubscription,
12
13
  executeOcapTransactions,
@@ -37,23 +38,68 @@ export default {
37
38
  const items = subscription!.items as TLineItemExpanded[];
38
39
  const trialing = false;
39
40
  const billingThreshold = Number(subscription!.billing_thresholds?.amount_gte || 0);
40
- const fastCheckoutAmount = await getFastCheckoutAmount({
41
- items,
42
- mode: 'subscription',
43
- currencyId: paymentCurrency.id,
44
- trialing: false,
45
- });
41
+
42
+ // Calculate required amount considering slippage_config for dynamic pricing
43
+ const slippageConfig = subscription?.slippage_config;
44
+ let requiredAmount: string;
45
+ if (slippageConfig?.min_acceptable_rate) {
46
+ // Use slippage_config for precise calculation
47
+ const slippageOptions: SlippageOptions = {
48
+ percent: slippageConfig.percent ?? 0.5,
49
+ minAcceptableRate: slippageConfig.min_acceptable_rate,
50
+ currencyDecimal: paymentCurrency.decimal,
51
+ };
52
+ const setup = getSubscriptionCreateSetup(items, paymentCurrency.id, 0, 0, slippageOptions);
53
+ requiredAmount = setup.amount.setup;
54
+ } else {
55
+ requiredAmount = await getFastCheckoutAmount({
56
+ items,
57
+ mode: 'subscription',
58
+ currencyId: paymentCurrency.id,
59
+ trialing: false,
60
+ });
61
+ }
46
62
 
47
63
  if (paymentMethod.type === 'arcblock') {
64
+ const requiredStake = !subscription!.billing_thresholds?.no_stake;
65
+
66
+ // 1. First check if stake is needed and if there's a valid existing stake
67
+ let needsNewStake = false;
68
+ let existingStakeAddress: string | undefined;
69
+ if (requiredStake) {
70
+ existingStakeAddress = subscription?.payment_details?.arcblock?.staking?.address;
71
+ if (existingStakeAddress) {
72
+ const stakeCheck = await checkRemainingStake(paymentMethod, paymentCurrency, existingStakeAddress, '1');
73
+ needsNewStake = !stakeCheck.enough;
74
+ logger.info('Change plan: checking existing stake', {
75
+ subscriptionId: subscription!.id,
76
+ existingStakeAddress,
77
+ hasValidExistingStake: stakeCheck.enough,
78
+ needsNewStake,
79
+ stakeCheck,
80
+ });
81
+ } else {
82
+ needsNewStake = true;
83
+ }
84
+ }
85
+
86
+ // 2. Check if delegation is sufficient
48
87
  const delegation = await isDelegationSufficientForPayment({
49
88
  paymentMethod,
50
89
  paymentCurrency,
51
90
  userDid,
52
- amount: fastCheckoutAmount,
91
+ amount: requiredAmount,
53
92
  });
54
93
 
55
- const requiredStake = !subscription!.billing_thresholds?.no_stake;
94
+ logger.info('Change plan: delegation check', {
95
+ subscriptionId: subscription!.id,
96
+ delegationSufficient: delegation.sufficient,
97
+ needsNewStake,
98
+ requiredStake,
99
+ });
56
100
 
101
+ // 3. Add delegation claim if needed
102
+ // Need delegation if: delegation is not sufficient, OR we don't require stake (legacy behavior)
57
103
  if (delegation.sufficient === false || !requiredStake) {
58
104
  claimsList.push({
59
105
  signature: await getDelegationTxClaim({
@@ -67,11 +113,13 @@ export default {
67
113
  trialing,
68
114
  billingThreshold,
69
115
  items,
116
+ slippageConfig: subscription?.slippage_config || undefined,
70
117
  }),
71
118
  });
72
119
  }
73
120
 
74
- if (requiredStake) {
121
+ // 4. Add stake claim only if we need a new stake
122
+ if (needsNewStake) {
75
123
  claimsList.push({
76
124
  prepareTx: await getStakeTxClaim({
77
125
  userDid,
@@ -82,10 +130,22 @@ export default {
82
130
  subscription: subscription!,
83
131
  }),
84
132
  });
133
+ } else if (requiredStake && existingStakeAddress) {
134
+ logger.info('Change plan: skipping stake request, reusing existing stake', {
135
+ subscriptionId: subscription!.id,
136
+ existingStakeAddress,
137
+ });
85
138
  }
86
139
 
140
+ // 5. If no claims needed, this is an error - API should have prevented entering connect flow
87
141
  if (claimsList.length === 0) {
88
- throw new Error('No available claims for your subscription at this time.');
142
+ logger.warn('Change plan: no claims needed but entered connect flow', {
143
+ subscriptionId: subscription!.id,
144
+ delegationSufficient: delegation.sufficient,
145
+ needsNewStake,
146
+ requiredStake,
147
+ });
148
+ throw new Error('No authorization needed for this subscription update. Please try again.');
89
149
  }
90
150
 
91
151
  return claimsList;
@@ -108,6 +168,7 @@ export default {
108
168
  trialing,
109
169
  billingThreshold,
110
170
  items,
171
+ slippageConfig: subscription?.slippage_config || undefined,
111
172
  }),
112
173
  });
113
174
 
@@ -119,8 +180,46 @@ export default {
119
180
 
120
181
  onAuth: async ({ request, userDid, userPk, claims, extraParams, updateSession, step }: CallbackArgs) => {
121
182
  const { subscriptionId } = extraParams;
122
- const { invoice, paymentMethod, subscription, paymentCurrency, customer } =
123
- await ensureSubscription(subscriptionId);
183
+ const {
184
+ invoice: latestInvoice,
185
+ paymentMethod,
186
+ subscription,
187
+ paymentCurrency,
188
+ customer,
189
+ } = await ensureSubscription(subscriptionId);
190
+
191
+ // For plan change, use the pending_update invoice if exists, not the latest_invoice_id
192
+ // The plan change invoice is created in subscriptions.ts and stored in pending_update.updates.latest_invoice_id
193
+ const pendingInvoiceId = subscription?.pending_update?.updates?.latest_invoice_id;
194
+ let invoice = latestInvoice;
195
+ if (pendingInvoiceId && pendingInvoiceId !== latestInvoice?.id) {
196
+ const pendingInvoice = await Invoice.findByPk(pendingInvoiceId);
197
+ if (pendingInvoice) {
198
+ invoice = pendingInvoice;
199
+ logger.info('Change plan: using pending_update invoice instead of latest_invoice', {
200
+ subscriptionId,
201
+ pendingInvoiceId,
202
+ latestInvoiceId: latestInvoice?.id,
203
+ });
204
+ } else {
205
+ logger.error('Change plan: pending invoice not found in database', {
206
+ subscriptionId,
207
+ pendingInvoiceId,
208
+ latestInvoiceId: latestInvoice?.id,
209
+ });
210
+ }
211
+ }
212
+
213
+ // Validate invoice exists - change-plan should always have an invoice to process
214
+ if (!invoice) {
215
+ logger.error('Change plan: no invoice found for subscription update', {
216
+ subscriptionId,
217
+ pendingInvoiceId,
218
+ latestInvoiceId: latestInvoice?.id,
219
+ pendingUpdate: subscription?.pending_update,
220
+ });
221
+ throw new Error('Invoice not found for subscription plan change. Please try again.');
222
+ }
124
223
 
125
224
  const result = request?.context?.store?.result || [];
126
225
  result.push({
@@ -133,10 +232,22 @@ export default {
133
232
 
134
233
  const requiredStake = !subscription!.billing_thresholds?.no_stake;
135
234
 
235
+ // Check if we have a valid existing stake (which means no new staking claim was requested)
236
+ let hasValidExistingStake = false;
237
+ if (requiredStake && paymentMethod.type === 'arcblock') {
238
+ const existingStakeAddress = subscription?.payment_details?.arcblock?.staking?.address;
239
+ if (existingStakeAddress) {
240
+ const stakeCheck = await checkRemainingStake(paymentMethod, paymentCurrency, existingStakeAddress, '1');
241
+ hasValidExistingStake = stakeCheck.enough;
242
+ }
243
+ }
244
+
136
245
  // 判断是否为最后一步
246
+ // Final step if: we have a staking claim, OR stake is not required, OR we're reusing existing stake
137
247
  const staking = result.find((x: any) => x.claim?.type === 'prepareTx' && x.claim?.meta?.purpose === 'staking');
138
248
  const isFinalStep =
139
- (paymentMethod.type === 'arcblock' && (staking || !requiredStake)) || paymentMethod.type !== 'arcblock';
249
+ (paymentMethod.type === 'arcblock' && (staking || !requiredStake || hasValidExistingStake)) ||
250
+ paymentMethod.type !== 'arcblock';
140
251
 
141
252
  if (!isFinalStep) {
142
253
  await updateSession({
@@ -172,13 +283,46 @@ export default {
172
283
  await subscription?.update({ payment_details: { [paymentMethod.type]: paymentDetails } });
173
284
 
174
285
  if (invoice) {
175
- if (invoice.status === 'uncollectible') {
176
- await invoice.update({ status: 'open' });
286
+ // Update invoice status and payment_settings before processing
287
+ const invoiceUpdates: any = {
288
+ payment_settings: {
289
+ payment_method_types: [paymentMethod.type],
290
+ payment_method_options: {
291
+ [paymentMethod.type]: { payer: userDid },
292
+ },
293
+ },
294
+ };
295
+ if (invoice.status === 'uncollectible' || invoice.status === 'draft') {
296
+ invoiceUpdates.status = 'open';
177
297
  }
298
+ await invoice.update(invoiceUpdates);
299
+ logger.info('Change plan: invoice updated before queue processing', {
300
+ invoiceId: invoice.id,
301
+ status: invoiceUpdates.status || invoice.status,
302
+ paymentMethodType: paymentMethod.type,
303
+ });
304
+
178
305
  await invoiceQueue.pushAndWait({
179
306
  id: invoice.id,
180
307
  job: { invoiceId: invoice.id, retryOnError: false, waitForPayment: true },
181
308
  });
309
+
310
+ // Reload invoice to check processing result
311
+ await invoice.reload();
312
+ logger.info('Change plan: invoice queue processing completed', {
313
+ invoiceId: invoice.id,
314
+ status: invoice.status,
315
+ paymentIntentId: invoice.payment_intent_id,
316
+ paid: invoice.paid,
317
+ });
318
+
319
+ if (!invoice.payment_intent_id && invoice.amount_remaining !== '0') {
320
+ logger.error('Change plan: invoice processed but no paymentIntent created', {
321
+ invoiceId: invoice.id,
322
+ status: invoice.status,
323
+ amountRemaining: invoice.amount_remaining,
324
+ });
325
+ }
182
326
  }
183
327
  if (subscription) {
184
328
  await onSubscriptionUpdateConnected(subscriptionId);
@@ -22,13 +22,16 @@ export default {
22
22
  authPrincipal: false,
23
23
  claims: {
24
24
  authPrincipal: async ({ extraParams }: CallbackArgs) => {
25
- const { paymentMethod } = await ensureInvoiceForCollect(extraParams.invoiceId);
25
+ const { allowCreatePI } = extraParams;
26
+ const { paymentMethod } = await ensureInvoiceForCollect(extraParams.invoiceId, { allowCreatePI });
26
27
  return getAuthPrincipalClaim(paymentMethod, 'pay');
27
28
  },
28
29
  },
29
30
  onConnect: async ({ userDid, userPk, extraParams }: CallbackArgs) => {
30
- const { invoiceId, action } = extraParams;
31
- const { invoice, paymentIntent, paymentCurrency, paymentMethod } = await ensureInvoiceForCollect(invoiceId);
31
+ const { invoiceId, action, allowCreatePI } = extraParams;
32
+ const { invoice, paymentIntent, paymentCurrency, paymentMethod } = await ensureInvoiceForCollect(invoiceId, {
33
+ allowCreatePI,
34
+ });
32
35
 
33
36
  if (paymentMethod.type === 'arcblock') {
34
37
  const tokens = [{ address: paymentCurrency.contract as string, value: invoice.amount_due }];
@@ -69,6 +72,7 @@ export default {
69
72
  paymentMethod,
70
73
  items,
71
74
  requiredStake: false,
75
+ slippageConfig: subscription?.slippage_config || undefined,
72
76
  });
73
77
  }
74
78
 
@@ -100,9 +104,8 @@ export default {
100
104
  throw new Error(`Payment method ${paymentMethod.type} not supported`);
101
105
  },
102
106
  onAuth: async ({ request, userDid, claims, extraParams }: CallbackArgs) => {
103
- const { invoiceId } = extraParams;
104
- const { invoice, paymentIntent, paymentMethod } = await ensureInvoiceForCollect(invoiceId);
105
-
107
+ const { invoiceId, allowCreatePI } = extraParams;
108
+ const { invoice, paymentIntent, paymentMethod } = await ensureInvoiceForCollect(invoiceId, { allowCreatePI });
106
109
  const afterTxExecution = async (paymentDetails: any) => {
107
110
  await paymentIntent.update({
108
111
  status: 'succeeded',
@@ -43,6 +43,7 @@ export default {
43
43
  billingThreshold,
44
44
  items: subscription!.items as TLineItemExpanded[],
45
45
  requiredStake: false,
46
+ slippageConfig: subscription?.slippage_config || undefined,
46
47
  }),
47
48
  ],
48
49
  };
@@ -13,6 +13,7 @@ import { handlePaymentSucceed } from '../../queues/payment';
13
13
  import { ensureInvoiceForCheckout, ensurePaymentIntent, getAuthPrincipalClaim } from './shared';
14
14
  import { EVMChainType } from '../../store/models';
15
15
  import { EVM_CHAIN_TYPES } from '../../libs/constants';
16
+ import { validateQuoteForPayment, handleExpiredQuotePayment } from '../../libs/quote-validation';
16
17
 
17
18
  export default {
18
19
  action: 'payment',
@@ -98,6 +99,31 @@ export default {
98
99
 
99
100
  await ensureInvoiceForCheckout({ checkoutSession, customer, paymentIntent });
100
101
 
102
+ const preQuoteValidation = await validateQuoteForPayment({
103
+ checkoutSessionId: checkoutSession?.id,
104
+ invoiceId: paymentIntent.invoice_id,
105
+ amount: paymentIntent.amount,
106
+ });
107
+
108
+ if (!preQuoteValidation.valid) {
109
+ const errorCode =
110
+ preQuoteValidation.action === 'require_manual_review' ? 'quote_expired' : 'quote_validation_failed';
111
+ logger.error('Payment blocked due to quote validation before submit', {
112
+ paymentIntentId: paymentIntent.id,
113
+ quoteId: preQuoteValidation.quoteId,
114
+ reason: preQuoteValidation.reason,
115
+ });
116
+ await paymentIntent.update({
117
+ status: 'requires_action',
118
+ last_payment_error: {
119
+ type: 'validation_error',
120
+ code: errorCode,
121
+ message: preQuoteValidation.reason || 'Quote validation failed',
122
+ },
123
+ });
124
+ throw new Error(`Payment validation failed: ${preQuoteValidation.reason}`);
125
+ }
126
+
101
127
  if (paymentMethod.type === 'arcblock') {
102
128
  try {
103
129
  await paymentIntent.update({ status: 'processing' });
@@ -118,6 +144,72 @@ export default {
118
144
  await getGasPayerExtra(buffer, client.pickGasPayerHeaders(request))
119
145
  );
120
146
 
147
+ const quoteValidation = await validateQuoteForPayment({
148
+ checkoutSessionId: checkoutSession?.id,
149
+ invoiceId: paymentIntent.invoice_id,
150
+ amount: paymentIntent.amount,
151
+ });
152
+
153
+ if (!quoteValidation.valid) {
154
+ if (quoteValidation.action === 'reject') {
155
+ // Reject the payment - mark as failed
156
+ logger.error('Payment rejected due to quote validation', {
157
+ paymentIntentId: paymentIntent.id,
158
+ quoteId: quoteValidation.quoteId,
159
+ reason: quoteValidation.reason,
160
+ txHash,
161
+ });
162
+ await paymentIntent.update({
163
+ status: 'requires_action',
164
+ last_payment_error: {
165
+ type: 'validation_error',
166
+ code: 'quote_validation_failed',
167
+ message: quoteValidation.reason || 'Quote validation failed',
168
+ },
169
+ });
170
+ throw new Error(`Payment validation failed: ${quoteValidation.reason}`);
171
+ }
172
+
173
+ if (quoteValidation.action === 'require_manual_review') {
174
+ // Hold for manual review
175
+ logger.error('Payment requires manual review due to expired quote', {
176
+ paymentIntentId: paymentIntent.id,
177
+ quoteId: quoteValidation.quoteId,
178
+ reason: quoteValidation.reason,
179
+ txHash,
180
+ });
181
+
182
+ await handleExpiredQuotePayment({
183
+ quoteId: quoteValidation.quoteId!,
184
+ checkoutSessionId: checkoutSession?.id,
185
+ invoiceId: paymentIntent.invoice_id,
186
+ paymentIntentId: paymentIntent.id,
187
+ amount: paymentIntent.amount,
188
+ currencyId: paymentIntent.currency_id,
189
+ txHash,
190
+ });
191
+
192
+ await paymentIntent.update({
193
+ status: 'requires_action',
194
+ last_payment_error: {
195
+ type: 'validation_error',
196
+ code: 'quote_expired',
197
+ message: quoteValidation.reason || 'Quote expired - manual review required',
198
+ },
199
+ metadata: {
200
+ ...paymentIntent.metadata,
201
+ quote_validation: {
202
+ status: 'expired',
203
+ quoteId: quoteValidation.quoteId,
204
+ reason: quoteValidation.reason,
205
+ held_for_review: true,
206
+ },
207
+ },
208
+ });
209
+ throw new Error(`Payment held for manual review: ${quoteValidation.reason}`);
210
+ }
211
+ }
212
+
121
213
  await paymentIntent.update({
122
214
  status: 'succeeded',
123
215
  amount_received: paymentIntent.amount,
@@ -153,6 +245,71 @@ export default {
153
245
  paymentMethod.confirmation.block
154
246
  )
155
247
  .then(async () => {
248
+ const quoteValidation = await validateQuoteForPayment({
249
+ checkoutSessionId: checkoutSession?.id,
250
+ invoiceId: paymentIntent.invoice_id,
251
+ });
252
+
253
+ if (!quoteValidation.valid) {
254
+ if (quoteValidation.action === 'reject') {
255
+ // Reject the payment - mark as failed
256
+ logger.error('Payment rejected due to quote validation', {
257
+ paymentIntentId: paymentIntent.id,
258
+ quoteId: quoteValidation.quoteId,
259
+ reason: quoteValidation.reason,
260
+ txHash: paymentDetails.tx_hash,
261
+ });
262
+ await paymentIntent.update({
263
+ status: 'requires_action',
264
+ last_payment_error: {
265
+ type: 'validation_error',
266
+ code: 'quote_validation_failed',
267
+ message: quoteValidation.reason || 'Quote validation failed',
268
+ },
269
+ });
270
+ return;
271
+ }
272
+
273
+ if (quoteValidation.action === 'require_manual_review') {
274
+ // Hold for manual review
275
+ logger.error('Payment requires manual review due to expired quote', {
276
+ paymentIntentId: paymentIntent.id,
277
+ quoteId: quoteValidation.quoteId,
278
+ reason: quoteValidation.reason,
279
+ txHash: paymentDetails.tx_hash,
280
+ });
281
+
282
+ await handleExpiredQuotePayment({
283
+ quoteId: quoteValidation.quoteId!,
284
+ checkoutSessionId: checkoutSession?.id,
285
+ invoiceId: paymentIntent.invoice_id,
286
+ paymentIntentId: paymentIntent.id,
287
+ amount: paymentIntent.amount,
288
+ currencyId: paymentIntent.currency_id,
289
+ txHash: paymentDetails.tx_hash,
290
+ });
291
+
292
+ await paymentIntent.update({
293
+ status: 'requires_action',
294
+ last_payment_error: {
295
+ type: 'validation_error',
296
+ code: 'quote_expired',
297
+ message: quoteValidation.reason || 'Quote expired - manual review required',
298
+ },
299
+ metadata: {
300
+ ...paymentIntent.metadata,
301
+ quote_validation: {
302
+ status: 'expired',
303
+ quoteId: quoteValidation.quoteId,
304
+ reason: quoteValidation.reason,
305
+ held_for_review: true,
306
+ },
307
+ },
308
+ });
309
+ return;
310
+ }
311
+ }
312
+
156
313
  await paymentIntent.update({
157
314
  status: 'succeeded',
158
315
  amount_received: paymentIntent.amount,