payment-kit 1.24.4 → 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 (115) 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 +1 -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/subscription.ts +436 -6
  47. package/api/src/routes/auto-recharge-configs.ts +71 -6
  48. package/api/src/routes/checkout-sessions.ts +1730 -81
  49. package/api/src/routes/connect/auto-recharge-auth.ts +2 -0
  50. package/api/src/routes/connect/change-payer.ts +2 -0
  51. package/api/src/routes/connect/change-payment.ts +61 -8
  52. package/api/src/routes/connect/change-plan.ts +161 -17
  53. package/api/src/routes/connect/collect.ts +9 -6
  54. package/api/src/routes/connect/delegation.ts +1 -0
  55. package/api/src/routes/connect/pay.ts +157 -0
  56. package/api/src/routes/connect/setup.ts +32 -10
  57. package/api/src/routes/connect/shared.ts +159 -13
  58. package/api/src/routes/connect/subscribe.ts +32 -9
  59. package/api/src/routes/credit-grants.ts +99 -0
  60. package/api/src/routes/exchange-rate-providers.ts +248 -0
  61. package/api/src/routes/exchange-rates.ts +87 -0
  62. package/api/src/routes/index.ts +4 -0
  63. package/api/src/routes/invoices.ts +280 -2
  64. package/api/src/routes/payment-links.ts +13 -0
  65. package/api/src/routes/prices.ts +84 -2
  66. package/api/src/routes/subscriptions.ts +526 -15
  67. package/api/src/store/migrations/20251220-dynamic-pricing.ts +245 -0
  68. package/api/src/store/migrations/20251223-exchange-rate-provider-type.ts +28 -0
  69. package/api/src/store/migrations/20260110-add-quote-locked-at.ts +23 -0
  70. package/api/src/store/migrations/20260112-add-checkout-session-slippage-percent.ts +22 -0
  71. package/api/src/store/migrations/20260113-add-price-quote-slippage-fields.ts +45 -0
  72. package/api/src/store/migrations/20260116-subscription-slippage.ts +21 -0
  73. package/api/src/store/migrations/20260120-auto-recharge-slippage.ts +21 -0
  74. package/api/src/store/models/auto-recharge-config.ts +12 -0
  75. package/api/src/store/models/checkout-session.ts +7 -0
  76. package/api/src/store/models/exchange-rate-provider.ts +225 -0
  77. package/api/src/store/models/index.ts +6 -0
  78. package/api/src/store/models/payment-intent.ts +6 -0
  79. package/api/src/store/models/price-quote.ts +284 -0
  80. package/api/src/store/models/price.ts +53 -5
  81. package/api/src/store/models/subscription.ts +11 -0
  82. package/api/src/store/models/types.ts +61 -1
  83. package/api/tests/libs/change-payment-plan.spec.ts +282 -0
  84. package/api/tests/libs/exchange-rate-service.spec.ts +341 -0
  85. package/api/tests/libs/quote-service.spec.ts +199 -0
  86. package/api/tests/libs/session.spec.ts +464 -0
  87. package/api/tests/libs/slippage.spec.ts +109 -0
  88. package/api/tests/libs/token-data-provider.spec.ts +267 -0
  89. package/api/tests/models/exchange-rate-provider.spec.ts +121 -0
  90. package/api/tests/models/price-dynamic.spec.ts +100 -0
  91. package/api/tests/models/price-quote.spec.ts +112 -0
  92. package/api/tests/routes/exchange-rate-providers.spec.ts +215 -0
  93. package/api/tests/routes/subscription-slippage.spec.ts +254 -0
  94. package/blocklet.yml +1 -1
  95. package/package.json +7 -6
  96. package/src/components/customer/credit-overview.tsx +14 -0
  97. package/src/components/discount/discount-info.tsx +8 -2
  98. package/src/components/invoice/list.tsx +146 -16
  99. package/src/components/invoice/table.tsx +276 -71
  100. package/src/components/invoice-pdf/template.tsx +3 -7
  101. package/src/components/metadata/form.tsx +6 -8
  102. package/src/components/price/form.tsx +519 -149
  103. package/src/components/promotion/active-redemptions.tsx +5 -3
  104. package/src/components/quote/info.tsx +234 -0
  105. package/src/hooks/subscription.ts +132 -2
  106. package/src/locales/en.tsx +145 -0
  107. package/src/locales/zh.tsx +143 -1
  108. package/src/pages/admin/billing/invoices/detail.tsx +41 -4
  109. package/src/pages/admin/products/exchange-rate-providers/edit-dialog.tsx +354 -0
  110. package/src/pages/admin/products/exchange-rate-providers/index.tsx +363 -0
  111. package/src/pages/admin/products/index.tsx +12 -1
  112. package/src/pages/customer/invoice/detail.tsx +36 -12
  113. package/src/pages/customer/subscription/change-payment.tsx +65 -3
  114. package/src/pages/customer/subscription/change-plan.tsx +207 -38
  115. package/src/pages/customer/subscription/detail.tsx +599 -419
@@ -4,11 +4,12 @@ import dayjs from '../../libs/dayjs';
4
4
  import logger from '../../libs/logger';
5
5
  import { isDelegationSufficientForPayment } from '../../libs/payment';
6
6
  import { getSubscriptionTrialSetup } from '../../libs/subscription';
7
- import { getFastCheckoutAmount } from '../../libs/session';
7
+ import { getFastCheckoutAmount, getSubscriptionCreateSetup, type SlippageOptions } from '../../libs/session';
8
8
  import { getTxMetadata } from '../../libs/util';
9
+ import { normalizeSlippageConfigFromMetadata } from '../../libs/slippage';
9
10
  import { invoiceQueue } from '../../queues/invoice';
10
11
  import { addSubscriptionJob } from '../../queues/subscription';
11
- import type { TLineItemExpanded } from '../../store/models';
12
+ import { Price } from '../../store/models';
12
13
  import {
13
14
  ensureSetupIntent,
14
15
  executeOcapTransactions,
@@ -43,7 +44,9 @@ export default {
43
44
 
44
45
  const claimsList: any[] = [];
45
46
  const now = dayjs().unix();
46
- const items = checkoutSession.line_items as TLineItemExpanded[];
47
+ // Expand line_items to include full price and upsell_price objects
48
+ // This is critical for getSubscriptionCreateSetup to correctly use upsell_price when calculating authorization amount
49
+ const items = await Price.expand(checkoutSession.line_items);
47
50
  const { trialEnd, trialInDays } = getSubscriptionTrialSetup(
48
51
  checkoutSession.subscription_data as any,
49
52
  paymentCurrency.id
@@ -52,19 +55,36 @@ export default {
52
55
  const trialing = trialInDays > 0 || trialEnd > now;
53
56
  const billingThreshold = Number(checkoutSession.subscription_data?.billing_threshold_amount || 0);
54
57
  const minStakeAmount = Number(checkoutSession.subscription_data?.min_stake_amount || 0);
55
- const fastCheckoutAmount = await getFastCheckoutAmount({
56
- items,
57
- mode: checkoutSession.mode,
58
- currencyId: paymentCurrency.id,
59
- trialing,
60
- });
58
+
59
+ // Calculate required amount considering slippage_config for dynamic pricing
60
+ // Priority: subscription.slippage_config > checkoutSession.metadata.slippage
61
+ const slippageConfig =
62
+ subscription?.slippage_config || normalizeSlippageConfigFromMetadata(checkoutSession.metadata);
63
+ let requiredAmount: string;
64
+ if (slippageConfig?.min_acceptable_rate) {
65
+ // Use slippage_config for precise calculation
66
+ const slippageOptions: SlippageOptions = {
67
+ percent: slippageConfig.percent ?? 0.5,
68
+ minAcceptableRate: slippageConfig.min_acceptable_rate,
69
+ currencyDecimal: paymentCurrency.decimal,
70
+ };
71
+ const setup = getSubscriptionCreateSetup(items, paymentCurrency.id, trialInDays, trialEnd, slippageOptions);
72
+ requiredAmount = setup.amount.setup;
73
+ } else {
74
+ requiredAmount = await getFastCheckoutAmount({
75
+ items,
76
+ mode: checkoutSession.mode,
77
+ currencyId: paymentCurrency.id,
78
+ trialing,
79
+ });
80
+ }
61
81
 
62
82
  if (paymentMethod.type === 'arcblock') {
63
83
  const delegation = await isDelegationSufficientForPayment({
64
84
  paymentMethod,
65
85
  paymentCurrency,
66
86
  userDid,
67
- amount: fastCheckoutAmount,
87
+ amount: requiredAmount,
68
88
  });
69
89
  // if we can complete purchase without any wallet interaction
70
90
  if (delegation.sufficient === false) {
@@ -80,6 +100,7 @@ export default {
80
100
  trialing,
81
101
  billingThreshold: Math.max(minStakeAmount, billingThreshold),
82
102
  items,
103
+ slippageConfig: subscription?.slippage_config || undefined,
83
104
  }),
84
105
  });
85
106
  }
@@ -116,6 +137,7 @@ export default {
116
137
  trialing,
117
138
  billingThreshold,
118
139
  items,
140
+ slippageConfig: subscription?.slippage_config || undefined,
119
141
  }),
120
142
  });
121
143
 
@@ -21,6 +21,7 @@ import {
21
21
  getSubscriptionCreateSetup,
22
22
  isDonationCheckoutSession,
23
23
  getSubscriptionLineItems,
24
+ SlippageOptions,
24
25
  } from '../../libs/session';
25
26
  import { getDiscountRecordsForCheckout } from '../../libs/discount/coupon';
26
27
  import {
@@ -40,6 +41,7 @@ import { PaymentCurrency } from '../../store/models/payment-currency';
40
41
  import { PaymentIntent } from '../../store/models/payment-intent';
41
42
  import { PaymentMethod } from '../../store/models/payment-method';
42
43
  import { Price } from '../../store/models/price';
44
+ import { PriceQuote } from '../../store/models/price-quote';
43
45
  import { SetupIntent } from '../../store/models/setup-intent';
44
46
  import { Subscription } from '../../store/models/subscription';
45
47
  import { ensureInvoiceAndItems } from '../../libs/invoice';
@@ -200,7 +202,7 @@ export async function ensurePaymentIntent(
200
202
  throw new Error(`Payment method ${paymentMethod.type} should not be here`);
201
203
  }
202
204
 
203
- checkoutSession.line_items = await Price.expand(checkoutSession.line_items);
205
+ checkoutSession.line_items = await Price.expand(checkoutSession.line_items, { upsell: true });
204
206
 
205
207
  return {
206
208
  checkoutSession,
@@ -262,7 +264,7 @@ export async function ensureSetupIntent(checkoutSessionId: string, skipInvoice?:
262
264
  throw new Error(`Payment method ${paymentMethod.type} should not be here`);
263
265
  }
264
266
 
265
- checkoutSession.line_items = await Price.expand(checkoutSession.line_items);
267
+ checkoutSession.line_items = await Price.expand(checkoutSession.line_items, { upsell: true });
266
268
 
267
269
  let invoice;
268
270
  if (subscription && !skipInvoice) {
@@ -421,6 +423,9 @@ export async function ensureInvoiceForCheckout({
421
423
  );
422
424
  }
423
425
 
426
+ // Final Freeze: Quotes in 'used' or 'payment_failed' status can be retried directly
427
+ // No status reset needed - quote price is immutable once created
428
+
424
429
  return {
425
430
  invoice,
426
431
  items: await InvoiceItem.findAll({ where: { invoice_id: existingInvoice } }),
@@ -462,7 +467,61 @@ export async function ensureInvoiceForCheckout({
462
467
  const trialInDays = Number(checkoutSession.subscription_data?.trial_period_days || 0);
463
468
  const trialEnd = Number(checkoutSession.subscription_data?.trial_end || 0);
464
469
  const now = dayjs().unix();
465
- const invoiceItems = lineItems || (await Price.expand(checkoutSession.line_items, { product: true }));
470
+ let invoiceItems = lineItems || (await Price.expand(checkoutSession.line_items, { product: true, upsell: true }));
471
+
472
+ // For items with quote_id, fetch full quote info and attach to line item metadata
473
+ // This ensures invoice line items have complete quote info for display
474
+ const itemsWithQuoteIds = invoiceItems.filter((item) => (item as any).quote_id);
475
+
476
+ if (itemsWithQuoteIds.length > 0) {
477
+ const quoteIds = itemsWithQuoteIds.map((item) => (item as any).quote_id).filter(Boolean);
478
+ const quotes = await PriceQuote.findAll({
479
+ where: { id: quoteIds },
480
+ });
481
+
482
+ const quotesById = new Map(quotes.map((q) => [q.id, q]));
483
+
484
+ // Fill custom_amount and metadata.quote from quotes
485
+ invoiceItems = invoiceItems.map((item) => {
486
+ const quoteId = (item as any).quote_id;
487
+ if (quoteId) {
488
+ const quote = quotesById.get(quoteId);
489
+ if (quote) {
490
+ return {
491
+ ...item,
492
+ quote_id: quote.id, // Add quote_id directly to line item for invoice item creation
493
+ custom_amount: item.custom_amount || quote.quoted_amount,
494
+ metadata: {
495
+ ...((item as any).metadata || {}),
496
+ quote_id: quote.id, // Also in metadata for audit trail
497
+ quote: {
498
+ id: quote.id,
499
+ base_currency: quote.base_currency,
500
+ base_amount: quote.base_amount,
501
+ quoted_amount: quote.quoted_amount,
502
+ target_currency_id: quote.target_currency_id,
503
+ rate_currency_symbol: quote.rate_currency_symbol,
504
+ exchange_rate: quote.exchange_rate,
505
+ rate_provider_name: quote.rate_provider_name,
506
+ rate_provider_id: quote.rate_provider_id,
507
+ rate_timestamp_ms: quote.rate_timestamp_ms,
508
+ slippage_percent: quote.slippage_percent ?? (quote.metadata as any)?.slippage?.percent ?? 0.5,
509
+ quantity: item.quantity,
510
+ status: quote.status,
511
+ },
512
+ },
513
+ };
514
+ }
515
+ }
516
+ return item;
517
+ });
518
+
519
+ logger.info('Enriched line items with quote metadata for invoice creation', {
520
+ checkoutSessionId: checkoutSession.id,
521
+ itemCount: itemsWithQuoteIds.length,
522
+ foundQuotes: quotesById.size,
523
+ });
524
+ }
466
525
 
467
526
  // Get discount records for this checkout session
468
527
  let discountInfo: { appliedDiscounts: string[]; discountBreakdown: Array<{ amount: string; discount: string }> } = {
@@ -562,6 +621,23 @@ export async function ensureInvoiceForCheckout({
562
621
  await subscription.update({ latest_invoice_id: invoice.id });
563
622
  }
564
623
 
624
+ // Update quotes with invoice_id for audit trail
625
+ // Find quote_ids from invoice items and update the quotes
626
+ const quoteIdsFromItems = items
627
+ .map((item) => item.metadata?.quote_id)
628
+ .filter((id): id is string => typeof id === 'string' && id.length > 0);
629
+
630
+ if (quoteIdsFromItems.length > 0) {
631
+ await PriceQuote.update(
632
+ { invoice_id: invoice.id },
633
+ { where: { id: quoteIdsFromItems } }
634
+ );
635
+ logger.info('Updated quotes with invoice_id', {
636
+ invoiceId: invoice.id,
637
+ quoteIds: quoteIdsFromItems,
638
+ });
639
+ }
640
+
565
641
  return { invoice, items };
566
642
  }
567
643
 
@@ -578,7 +654,7 @@ export async function ensureInvoicesForSubscriptions({
578
654
  return { invoices: [] };
579
655
  }
580
656
 
581
- const lineItems = await Price.expand(checkoutSession.line_items, { product: true });
657
+ const lineItems = await Price.expand(checkoutSession.line_items, { product: true, upsell: true });
582
658
 
583
659
  const primarySubscription = (subscriptions.find((x) => x.metadata.is_primary_subscription) ||
584
660
  subscriptions[0]) as Subscription;
@@ -607,7 +683,7 @@ export async function ensureInvoicesForSubscriptions({
607
683
  return { invoices: createdInvoices as Invoice[] };
608
684
  }
609
685
 
610
- export async function ensureInvoiceForCollect(invoiceId: string) {
686
+ export async function ensureInvoiceForCollect(invoiceId: string, options?: { allowCreatePI?: boolean }) {
611
687
  const invoice = await Invoice.findByPk(invoiceId);
612
688
  if (!invoice) {
613
689
  throw new Error(`Invoice ${invoiceId} not found`);
@@ -623,11 +699,41 @@ export async function ensureInvoiceForCollect(invoiceId: string) {
623
699
  }
624
700
 
625
701
  if (!invoice.payment_intent_id) {
626
- await invoiceQueue.pushAndWait({
627
- id: invoice.id,
628
- job: { invoiceId: invoice.id, retryOnError: false, justCreate: true },
629
- });
630
- await invoice.reload();
702
+ // For open invoices, use invoiceQueue to create PI
703
+ if (invoice.status === 'open') {
704
+ await invoiceQueue.pushAndWait({
705
+ id: invoice.id,
706
+ job: { invoiceId: invoice.id, retryOnError: false, justCreate: true },
707
+ });
708
+ await invoice.reload();
709
+ }
710
+
711
+ // For uncollectible invoices with allowCreatePI flag, create PI directly
712
+ if (invoice.status === 'uncollectible' && options?.allowCreatePI) {
713
+ const customer = await Customer.findByPk(invoice.customer_id);
714
+ const paymentIntent = await PaymentIntent.create({
715
+ livemode: !!invoice.livemode,
716
+ amount: invoice.amount_remaining,
717
+ amount_received: '0',
718
+ amount_capturable: '0',
719
+ customer_id: invoice.customer_id,
720
+ description: 'Manual payment for uncollectible invoice',
721
+ currency_id: invoice.currency_id,
722
+ payment_method_id: invoice.default_payment_method_id,
723
+ invoice_id: invoice.id,
724
+ status: 'requires_action',
725
+ capture_method: 'manual',
726
+ confirmation_method: 'manual',
727
+ payment_method_types: [],
728
+ receipt_email: customer?.email || invoice.customer_email,
729
+ statement_descriptor: invoice.statement_descriptor,
730
+ });
731
+ await invoice.update({ payment_intent_id: paymentIntent.id });
732
+ logger.info('Created PI for uncollectible invoice via allowCreatePI', {
733
+ invoiceId: invoice.id,
734
+ paymentIntentId: paymentIntent.id,
735
+ });
736
+ }
631
737
  }
632
738
 
633
739
  const paymentIntent = await PaymentIntent.findByPk(invoice.payment_intent_id);
@@ -754,6 +860,7 @@ export async function getDelegationTxClaim({
754
860
  trialing = false,
755
861
  billingThreshold = 0,
756
862
  requiredStake = true,
863
+ slippageConfig,
757
864
  }: {
758
865
  userDid: string;
759
866
  userPk: string;
@@ -767,8 +874,33 @@ export async function getDelegationTxClaim({
767
874
  billingThreshold?: number;
768
875
  requiredStake?: boolean;
769
876
  requiredAmount?: string;
877
+ slippageConfig?: {
878
+ mode?: 'percent' | 'rate';
879
+ percent?: number;
880
+ min_acceptable_rate?: string;
881
+ base_currency?: string;
882
+ };
770
883
  }) {
771
- const amount = await getFastCheckoutAmount({ items, mode, currencyId: paymentCurrency.id });
884
+ // Calculate authorization amount
885
+ // When slippage_config with min_acceptable_rate is provided, use it for precise calculation
886
+ let amount: string;
887
+ if (slippageConfig?.min_acceptable_rate) {
888
+ const slippageOptions: SlippageOptions = {
889
+ percent: slippageConfig.percent ?? 0.5,
890
+ minAcceptableRate: slippageConfig.min_acceptable_rate,
891
+ currencyDecimal: paymentCurrency.decimal,
892
+ };
893
+ const setup = getSubscriptionCreateSetup(items, paymentCurrency.id, 0, 0, slippageOptions);
894
+ amount = setup.amount.setup;
895
+ logger.info('getDelegationTxClaim: Using slippage_config for authorization calculation', {
896
+ mode,
897
+ slippageConfig,
898
+ authorizationAmount: amount,
899
+ });
900
+ } else {
901
+ amount = await getFastCheckoutAmount({ items, mode, currencyId: paymentCurrency.id });
902
+ }
903
+
772
904
  const address = toDelegateAddress(userDid, wallet.address);
773
905
  const tokenLimits = await getTokenLimitsForDelegation(items, paymentMethod, paymentCurrency, address, amount);
774
906
  let tokenRequirements = await getTokenRequirements({
@@ -939,7 +1071,14 @@ export async function getStakeTxClaim({
939
1071
  ),
940
1072
  };
941
1073
 
942
- const setup = getSubscriptionCreateSetup(items, paymentCurrency.id, 0, 0);
1074
+ // Build slippage options with min_acceptable_rate for precise authorization calculation
1075
+ const slippageConfig = subscription.slippage_config;
1076
+ const slippageOptions = {
1077
+ percent: slippageConfig?.percent ?? 0.5,
1078
+ minAcceptableRate: slippageConfig?.min_acceptable_rate,
1079
+ currencyDecimal: paymentCurrency.decimal,
1080
+ };
1081
+ const setup = getSubscriptionCreateSetup(items, paymentCurrency.id, 0, 0, slippageOptions);
943
1082
 
944
1083
  return {
945
1084
  type: 'StakeTx',
@@ -1030,7 +1169,14 @@ export async function getOverdraftProtectionStakeTxClaim({
1030
1169
  ),
1031
1170
  };
1032
1171
 
1033
- const setup = getSubscriptionCreateSetup(items, paymentCurrency.id, 0, 0);
1172
+ // Build slippage options with min_acceptable_rate for precise authorization calculation
1173
+ const slippageConfig = subscription.slippage_config;
1174
+ const slippageOptions = {
1175
+ percent: slippageConfig?.percent ?? 0.5,
1176
+ minAcceptableRate: slippageConfig?.min_acceptable_rate,
1177
+ currencyDecimal: paymentCurrency.decimal,
1178
+ };
1179
+ const setup = getSubscriptionCreateSetup(items, paymentCurrency.id, 0, 0, slippageOptions);
1034
1180
 
1035
1181
  return {
1036
1182
  type: 'StakeTx',
@@ -5,11 +5,13 @@ import dayjs from '../../libs/dayjs';
5
5
  import logger from '../../libs/logger';
6
6
  import { isDelegationSufficientForPayment, checkTokenBalance } from '../../libs/payment';
7
7
  import { getSubscriptionTrialSetup } from '../../libs/subscription';
8
- import { getFastCheckoutAmount } from '../../libs/session';
8
+ import { getFastCheckoutAmount, getSubscriptionCreateSetup, type SlippageOptions } from '../../libs/session';
9
9
  import { getTxMetadata } from '../../libs/util';
10
+ import { normalizeSlippageConfigFromMetadata } from '../../libs/slippage';
10
11
  import { invoiceQueue } from '../../queues/invoice';
11
12
  import { addSubscriptionJob } from '../../queues/subscription';
12
13
  import type { Invoice, Subscription, TLineItemExpanded } from '../../store/models';
14
+ import { Price } from '../../store/models';
13
15
  import {
14
16
  ensureInvoicesForSubscriptions,
15
17
  ensurePaymentIntent,
@@ -65,7 +67,9 @@ export default {
65
67
  }
66
68
 
67
69
  const now = dayjs().unix();
68
- const items = checkoutSession.line_items as TLineItemExpanded[];
70
+ // Expand line_items to include full price and upsell_price objects
71
+ // This is critical for getSubscriptionCreateSetup to correctly use upsell_price when calculating authorization amount
72
+ const items = await Price.expand(checkoutSession.line_items);
69
73
  const { trialEnd, trialInDays } = getSubscriptionTrialSetup(
70
74
  checkoutSession.subscription_data as any,
71
75
  paymentCurrency.id
@@ -73,12 +77,29 @@ export default {
73
77
  const trialing = trialInDays > 0 || trialEnd > now;
74
78
  const billingThreshold = Number(checkoutSession.subscription_data?.billing_threshold_amount || 0);
75
79
  const minStakeAmount = Number(checkoutSession.subscription_data?.min_stake_amount || 0);
76
- const fastCheckoutAmount = await getFastCheckoutAmount({
77
- items,
78
- mode: checkoutSession.mode,
79
- currencyId: paymentCurrency.id,
80
- trialing,
81
- });
80
+
81
+ // Calculate required amount considering slippage_config for dynamic pricing
82
+ // Priority: primarySubscription.slippage_config > checkoutSession.metadata.slippage
83
+ const slippageConfig =
84
+ primarySubscription?.slippage_config || normalizeSlippageConfigFromMetadata(checkoutSession.metadata);
85
+ let requiredAmount: string;
86
+ if (slippageConfig?.min_acceptable_rate) {
87
+ // Use slippage_config for precise calculation
88
+ const slippageOptions: SlippageOptions = {
89
+ percent: slippageConfig.percent ?? 0.5,
90
+ minAcceptableRate: slippageConfig.min_acceptable_rate,
91
+ currencyDecimal: paymentCurrency.decimal,
92
+ };
93
+ const setup = getSubscriptionCreateSetup(items, paymentCurrency.id, trialInDays, trialEnd, slippageOptions);
94
+ requiredAmount = setup.amount.setup;
95
+ } else {
96
+ requiredAmount = await getFastCheckoutAmount({
97
+ items,
98
+ mode: checkoutSession.mode,
99
+ currencyId: paymentCurrency.id,
100
+ trialing,
101
+ });
102
+ }
82
103
  const claimsList: any[] = [];
83
104
 
84
105
  const allSubscriptionIds = subscriptions.map((sub) => sub.id);
@@ -88,7 +109,7 @@ export default {
88
109
  paymentMethod,
89
110
  paymentCurrency,
90
111
  userDid,
91
- amount: fastCheckoutAmount,
112
+ amount: requiredAmount,
92
113
  });
93
114
 
94
115
  // if we can complete purchase without any wallet interaction
@@ -111,6 +132,7 @@ export default {
111
132
  billingThreshold: Math.max(minStakeAmount, billingThreshold),
112
133
  items,
113
134
  requiredStake,
135
+ slippageConfig: primarySubscription?.slippage_config || undefined,
114
136
  }),
115
137
  });
116
138
  }
@@ -157,6 +179,7 @@ export default {
157
179
  trialing,
158
180
  billingThreshold,
159
181
  items,
182
+ slippageConfig: primarySubscription?.slippage_config || undefined,
160
183
  }),
161
184
  });
162
185
 
@@ -9,6 +9,7 @@ import logger from '../libs/logger';
9
9
  import { authenticate } from '../libs/security';
10
10
  import {
11
11
  AutoRechargeConfig,
12
+ ChainType,
12
13
  CreditGrant,
13
14
  Customer,
14
15
  Invoice,
@@ -26,6 +27,9 @@ import { blocklet } from '../libs/auth';
26
27
  import { formatMetadata } from '../libs/util';
27
28
  import { getPriceUintAmountByCurrency } from '../libs/price';
28
29
  import { checkTokenBalance } from '../libs/payment';
30
+ import { getExchangeRateService } from '../libs/exchange-rate/service';
31
+ import { getExchangeRateSymbol, hasTokenAddress } from '../libs/exchange-rate/token-address-mapping';
32
+ import { isRateBelowMinAcceptableRate } from '../libs/slippage';
29
33
  import { trimDecimals } from '../libs/math-utils';
30
34
  import { systemMaxPendingAmount } from '../libs/env';
31
35
 
@@ -546,11 +550,106 @@ router.get('/verify-availability', authMine, async (req, res) => {
546
550
  }
547
551
  }
548
552
 
553
+ // 7. Check dynamic pricing constraints
554
+ const isDynamicPricing = config.price.pricing_type === 'dynamic';
555
+ let exchangeRateInfo: { rate: string; timestamp_ms: number; degraded?: boolean } | null = null;
556
+ let slippageCheck: { passed: boolean; reason?: string } | null = null;
557
+ let canCoverPending = true;
558
+
559
+ if (isDynamicPricing) {
560
+ // 7a. Check if exchange rate is available
561
+ const methodType = config.paymentMethod.type as ChainType;
562
+ if (!hasTokenAddress(config.rechargeCurrency.symbol, methodType)) {
563
+ return res.json({
564
+ can_continue: false,
565
+ reason: 'exchange_rate_not_supported',
566
+ is_dynamic_pricing: true,
567
+ });
568
+ }
569
+
570
+ // 7b. Get current exchange rate
571
+ try {
572
+ const exchangeRateService = getExchangeRateService();
573
+ const rateSymbol = getExchangeRateSymbol(config.rechargeCurrency.symbol, methodType);
574
+ const rateResult = await exchangeRateService.getRate(rateSymbol);
575
+ exchangeRateInfo = {
576
+ rate: rateResult.rate,
577
+ timestamp_ms: rateResult.timestamp_ms,
578
+ degraded: rateResult.degraded,
579
+ };
580
+ } catch (err: any) {
581
+ logger.warn('Failed to fetch exchange rate for auto-recharge check', {
582
+ error: err.message,
583
+ symbol: config.rechargeCurrency.symbol,
584
+ });
585
+ return res.json({
586
+ can_continue: false,
587
+ reason: 'exchange_rate_fetch_failed',
588
+ is_dynamic_pricing: true,
589
+ error: err.message,
590
+ });
591
+ }
592
+
593
+ // 7c. Check slippage if config exists
594
+ if (config.slippage_config?.min_acceptable_rate && exchangeRateInfo) {
595
+ const rateBelowMin = isRateBelowMinAcceptableRate(
596
+ exchangeRateInfo.rate,
597
+ config.slippage_config.min_acceptable_rate
598
+ );
599
+ slippageCheck = {
600
+ passed: !rateBelowMin,
601
+ reason: rateBelowMin ? 'rate_below_min_acceptable' : undefined,
602
+ };
603
+
604
+ if (rateBelowMin) {
605
+ return res.json({
606
+ can_continue: false,
607
+ reason: 'slippage_exceeded',
608
+ is_dynamic_pricing: true,
609
+ exchange_rate: exchangeRateInfo,
610
+ slippage_check: slippageCheck,
611
+ slippage_config: {
612
+ min_acceptable_rate: config.slippage_config.min_acceptable_rate,
613
+ percent: config.slippage_config.percent,
614
+ },
615
+ });
616
+ }
617
+ }
618
+
619
+ // 7d. For dynamic pricing, check if single recharge can cover pending amount
620
+ if (pendingAmountBN.gt(new BN(0))) {
621
+ // Calculate credit amount from single recharge
622
+ const creditConfig = config.price.metadata?.credit_config as { credit_amount?: string | number } | undefined;
623
+ const creditAmount = creditConfig?.credit_amount
624
+ ? new BN(String(creditConfig.credit_amount)).mul(new BN(config.quantity ?? 1))
625
+ : new BN(0);
626
+
627
+ // Convert pending amount to credit currency for comparison
628
+ canCoverPending = creditAmount.gte(pendingAmountBN);
629
+
630
+ if (!canCoverPending) {
631
+ return res.json({
632
+ can_continue: false,
633
+ reason: 'cannot_cover_pending_single_recharge',
634
+ is_dynamic_pricing: true,
635
+ exchange_rate: exchangeRateInfo,
636
+ slippage_check: slippageCheck,
637
+ pending_amount: pendingAmount,
638
+ single_recharge_credit: creditAmount.toString(),
639
+ });
640
+ }
641
+ }
642
+ }
643
+
549
644
  return res.json({
550
645
  can_continue: true,
551
646
  payment_account_sufficient: true,
552
647
  payment_account_balance: balanceResult?.token?.balance || '0',
553
648
  pending_amount: pendingAmount,
649
+ is_dynamic_pricing: isDynamicPricing,
650
+ ...(exchangeRateInfo && { exchange_rate: exchangeRateInfo }),
651
+ ...(slippageCheck && { slippage_check: slippageCheck }),
652
+ can_cover_pending: canCoverPending,
554
653
  });
555
654
  } catch (err: any) {
556
655
  logger.error('check auto recharge failed', {