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
@@ -1,6 +1,7 @@
1
+ /* eslint-disable no-nested-ternary */
1
2
  import { env } from '@blocklet/sdk/lib/config';
2
3
  import type { TransactionInput } from '@ocap/client';
3
- import { BN, fromTokenToUnit } from '@ocap/util';
4
+ import { BN, fromTokenToUnit, fromUnitToToken } from '@ocap/util';
4
5
  import cloneDeep from 'lodash/cloneDeep';
5
6
  import isEqual from 'lodash/isEqual';
6
7
  import pAll from 'p-all';
@@ -15,10 +16,10 @@ import {
15
16
  Invoice,
16
17
  PaymentCurrency,
17
18
  PaymentMethod,
19
+ PriceQuote,
18
20
  SetupIntent,
19
21
  Subscription,
20
22
  SubscriptionItem,
21
- TPriceExpanded,
22
23
  type CheckoutSession,
23
24
  type TLineItemExpanded,
24
25
  type TPaymentCurrency,
@@ -26,10 +27,21 @@ import {
26
27
  } from '../store/models';
27
28
  import { Price, Price as TPrice } from '../store/models/price';
28
29
  import type { Product } from '../store/models/product';
29
- import type { PaymentBeneficiary, PriceRecurring, SubscriptionBillingThresholds } from '../store/models/types';
30
+ import type {
31
+ ChainType,
32
+ PaymentBeneficiary,
33
+ PriceRecurring,
34
+ SubscriptionBillingThresholds,
35
+ } from '../store/models/types';
30
36
  import { wallet } from './auth';
31
37
  import logger from './logger';
32
38
  import { applyDiscountsToLineItems } from './discount/discount';
39
+ import { getQuoteService, QuoteResponse } from './quote-service';
40
+ import { getExchangeRateService } from './exchange-rate';
41
+ import { getExchangeRateSymbol } from './exchange-rate/token-address-mapping';
42
+ import { trimDecimals } from './math-utils';
43
+ import { normalizeSlippageConfigFromMetadata } from './slippage';
44
+ import { getSubscriptionItemPrice } from './credit-utils';
33
45
 
34
46
  export function getStatementDescriptor(items: any[]) {
35
47
  for (const item of items) {
@@ -102,34 +114,70 @@ export async function getCheckoutAmount(
102
114
  }
103
115
  }
104
116
 
105
- const subtotal = processedItems
106
- .reduce((acc, x) => {
107
- if (x.custom_amount) {
108
- return acc.add(new BN(x.custom_amount));
109
- }
117
+ // Use async reduce to support quote_id fallback
118
+ const subtotal = await processedItems
119
+ .reduce(
120
+ async (accPromise, x) => {
121
+ const acc = await accPromise;
110
122
 
111
- const price = x.upsell_price || x.price;
112
- const unitPrice = getPriceUintAmountByCurrency(price, currencyId);
113
- if (price.custom_unit_amount) {
114
- if (unitPrice) {
115
- return acc.add(new BN(unitPrice).mul(new BN(x.quantity)));
123
+ // Priority 1: Use custom_amount if available
124
+ if (x.custom_amount) {
125
+ return acc.add(new BN(x.custom_amount));
126
+ }
127
+
128
+ // Priority 2: If has quote_id but no custom_amount, fetch from Quote table
129
+ if ((x as any).quote_id) {
130
+ try {
131
+ const quote = await PriceQuote.findByPk((x as any).quote_id);
132
+ if (quote) {
133
+ logger.info('Using quoted_amount from quote_id for checkout amount calculation', {
134
+ priceId: x.price.id,
135
+ quoteId: (x as any).quote_id,
136
+ quotedAmount: quote.quoted_amount,
137
+ });
138
+ return acc.add(new BN(quote.quoted_amount));
139
+ }
140
+
141
+ // Quote not found - log warning and fall through to price calculation
142
+ logger.warn('Quote not found for quote_id in line_item', {
143
+ priceId: x.price.id,
144
+ quoteId: (x as any).quote_id,
145
+ });
146
+ } catch (error) {
147
+ logger.error('Failed to fetch quote for quote_id', {
148
+ priceId: x.price.id,
149
+ quoteId: (x as any).quote_id,
150
+ error: (error as Error).message,
151
+ });
152
+ }
116
153
  }
117
- }
118
154
 
119
- if (price?.type === 'recurring') {
120
- renew = renew.add(new BN(unitPrice).mul(new BN(x.quantity)));
155
+ // Priority 3: Calculate from price (fixed pricing or quote query failed)
156
+ const price = x.upsell_price || x.price;
157
+ const unitPrice = getPriceUintAmountByCurrency(price, currencyId);
121
158
 
122
- if (trialing) {
123
- return acc;
159
+ if (price.custom_unit_amount) {
160
+ if (unitPrice) {
161
+ return acc.add(new BN(unitPrice).mul(new BN(x.quantity)));
162
+ }
124
163
  }
125
- if (price?.recurring?.usage_type === 'metered') {
126
- return acc;
164
+
165
+ if (price?.type === 'recurring') {
166
+ renew = renew.add(new BN(unitPrice).mul(new BN(x.quantity)));
167
+
168
+ if (trialing) {
169
+ return acc;
170
+ }
171
+ if (price?.recurring?.usage_type === 'metered') {
172
+ return acc;
173
+ }
127
174
  }
128
- }
129
175
 
130
- return acc.add(new BN(unitPrice).mul(new BN(x.quantity)));
131
- }, new BN(0))
132
- .toString();
176
+ return acc.add(new BN(unitPrice).mul(new BN(x.quantity)));
177
+ },
178
+ Promise.resolve(new BN(0))
179
+ )
180
+ .then((bn) => bn.toString());
133
181
 
134
182
  // Calculate total discount amount from processed line items
135
183
  let totalDiscountAmount = new BN(0);
@@ -220,7 +268,11 @@ export function getSupportedPaymentMethods(
220
268
 
221
269
  export function getSupportedPaymentCurrencies(items: TLineItemExpanded[]) {
222
270
  const currencies = items.reduce((acc: string[], x: any) => {
223
- return acc.concat(getPriceCurrencyOptions(x.price).map((c: any) => c.currency_id));
271
+ const price = x.upsell_price || x.price;
272
+ if (!price) {
273
+ return acc;
274
+ }
275
+ return acc.concat(getPriceCurrencyOptions(price).map((c: any) => c.currency_id));
224
276
  }, []);
225
277
  return Array.from(new Set(currencies));
226
278
  }
@@ -462,21 +514,89 @@ export function formatSubscriptionProduct(items: TLineItemExpanded[], maxLength
462
514
  return names.length > maxLength ? `${names.slice(0, maxLength).join(', ')} ...` : names.join(', ');
463
515
  }
464
516
 
517
+ /**
518
+ * Slippage configuration for subscription authorization calculation
519
+ */
520
+ export type SlippageOptions = {
521
+ /** Slippage percentage (e.g., 0.5 for 0.5%, 5 for 5%). Used when minAcceptableRate is not provided. */
522
+ percent?: number;
523
+ /** Minimum acceptable exchange rate (USD per token). When provided, used for precise authorization calculation. */
524
+ minAcceptableRate?: string;
525
+ /** Token decimal places. Required when minAcceptableRate is provided. */
526
+ currencyDecimal?: number;
527
+ };
528
+
529
+ const DEFAULT_SLIPPAGE_PERCENT = 0.5;
530
+ const USD_DECIMALS = 8;
531
+
465
532
  /**
466
533
  * Setup subscription creation parameters
534
+ * @param items - Line items for the subscription
535
+ * @param currencyId - Payment currency ID
536
+ * @param trialInDays - Trial period in days (optional)
537
+ * @param trialEnd - Trial end timestamp (optional)
538
+ * @param slippageOptions - Slippage configuration for dynamic pricing authorization
467
539
  */
468
540
  export function getSubscriptionCreateSetup(
469
541
  items: TLineItemExpanded[],
470
542
  currencyId: string,
471
543
  trialInDays = 0,
472
- trialEnd = 0
544
+ trialEnd = 0,
545
+ slippageOptions?: SlippageOptions | number
473
546
  ) {
547
+ // Normalize slippage options (support legacy number parameter for backward compatibility)
548
+ const options: SlippageOptions =
549
+ typeof slippageOptions === 'number'
550
+ ? { percent: slippageOptions }
551
+ : slippageOptions || { percent: DEFAULT_SLIPPAGE_PERCENT };
552
+
474
553
  let setup = new BN(0);
554
+ let hasDynamicPricing = false;
555
+ let totalBaseAmountBN = new BN(0); // Track total USD base amount for dynamic pricing items
556
+
557
+ // Debug log to verify items have correct upsell_price
558
+ logger.debug('getSubscriptionCreateSetup: items check', {
559
+ itemCount: items.length,
560
+ items: items.map((x) => ({
561
+ price_id: x.price?.id,
562
+ upsell_price_id: x.upsell_price?.id,
563
+ has_upsell: !!x.upsell_price,
564
+ selected_price_id: getSubscriptionItemPrice(x)?.id,
565
+ price_base_amount: x.price?.base_amount,
566
+ upsell_base_amount: x.upsell_price?.base_amount,
567
+ })),
568
+ });
475
569
 
476
570
  items.forEach((x) => {
477
- const price = x.upsell_price || x.price;
478
- const unit = getPriceUintAmountByCurrency(price, currencyId);
479
- const amount = new BN(unit).mul(new BN(x.quantity));
571
+ const price = getSubscriptionItemPrice(x);
572
+
573
+ // Priority: use custom_amount if available (for dynamic pricing with pre-calculated amount)
574
+ // This allows callers to pass in the actual quoted amount from Quote system
575
+ let amount: BN;
576
+ if ((x as any).custom_amount) {
577
+ amount = new BN((x as any).custom_amount);
578
+ } else {
579
+ const unit = getPriceUintAmountByCurrency(price, currencyId);
580
+ amount = new BN(unit).mul(new BN(x.quantity));
581
+ }
582
+
583
+ // Check if this price uses dynamic pricing
584
+ if (price.pricing_type === 'dynamic') {
585
+ hasDynamicPricing = true;
586
+ // Accumulate base amount for dynamic pricing items (stored in 10^8 scale)
587
+ if (price.base_amount) {
588
+ const itemBaseAmount = fromTokenToUnit(price.base_amount, USD_DECIMALS);
589
+ const contribution = itemBaseAmount.mul(new BN(x.quantity));
590
+ totalBaseAmountBN = totalBaseAmountBN.add(contribution);
591
+ logger.debug('getSubscriptionCreateSetup: processing dynamic pricing item', {
592
+ priceId: price.id,
593
+ baseAmount: price.base_amount,
594
+ quantity: x.quantity,
595
+ contributionUSD: fromUnitToToken(contribution.toString(), USD_DECIMALS),
596
+ });
597
+ }
598
+ }
599
+
480
600
  if (price.type === 'recurring') {
481
601
  if (price.recurring?.usage_type === 'licensed') {
482
602
  setup = setup.add(amount);
@@ -487,9 +607,54 @@ export function getSubscriptionCreateSetup(
487
607
  }
488
608
  });
489
609
 
610
+ // Apply slippage protection for subscriptions with dynamic pricing
611
+ if (hasDynamicPricing && setup.gt(new BN(0))) {
612
+ const { percent, minAcceptableRate, currencyDecimal } = options;
613
+
614
+ // Priority: minAcceptableRate > percent
615
+ // When minAcceptableRate is provided, calculate: authorization = base_amount / min_acceptable_rate
616
+ // This is more accurate as it uses the exact rate threshold configured by user
617
+ if (
618
+ minAcceptableRate &&
619
+ Number(minAcceptableRate) > 0 &&
620
+ currencyDecimal !== undefined &&
621
+ totalBaseAmountBN.gt(new BN(0))
622
+ ) {
623
+ // Calculate authorization using min_acceptable_rate
624
+ // Formula: authorization_token = ceil(base_amount_usd / min_acceptable_rate)
625
+ const minRateBN = fromTokenToUnit(minAcceptableRate, USD_DECIMALS);
626
+ if (minRateBN.gt(new BN(0))) {
627
+ const numerator = totalBaseAmountBN.mul(new BN(10).pow(new BN(currencyDecimal)));
628
+ // Ceiling division: ceil(a/b) = (a + b - 1) / b
629
+ setup = numerator.add(minRateBN).sub(new BN(1)).div(minRateBN);
630
+ logger.info('Applied subscription authorization with min_acceptable_rate', {
631
+ hasDynamicPricing,
632
+ authorizationAmountRaw: setup.toString(),
633
+ authorizationAmountDisplay: fromUnitToToken(setup.toString(), currencyDecimal),
634
+ minAcceptableRate,
635
+ totalBaseAmountUSD: fromUnitToToken(totalBaseAmountBN.toString(), USD_DECIMALS),
636
+ currencyDecimal,
637
+ });
638
+ }
639
+ } else {
640
+ // Fallback: Apply slippage percent multiplier
641
+ // Authorization amount = base * (1 + percent/100)
642
+ const normalizedSlippage = Number.isFinite(percent) && percent! >= 0 ? percent! : DEFAULT_SLIPPAGE_PERCENT;
643
+ // Convert slippage percent to multiplier: 0.5% -> 1005/1000, 5% -> 1050/1000
644
+ const SLIPPAGE_MULTIPLIER = new BN(Math.round(1000 + normalizedSlippage * 10));
645
+ const SLIPPAGE_DIVISOR = new BN(1000);
646
+ setup = setup.mul(SLIPPAGE_MULTIPLIER).div(SLIPPAGE_DIVISOR);
647
+ logger.info('Applied subscription authorization slippage protection', {
648
+ hasDynamicPricing,
649
+ authorizationAmount: setup.toString(),
650
+ slippageRate: `${normalizedSlippage}%`,
651
+ });
652
+ }
653
+ }
654
+
490
655
  const now = dayjs().unix();
491
- const item = items.find((x) => (x.upsell_price || x.price).type === 'recurring');
492
- const recurring = (item?.upsell_price || item?.price)?.recurring as PriceRecurring;
656
+ const item = items.find((x) => getSubscriptionItemPrice(x)?.type === 'recurring');
657
+ const recurring = (item ? getSubscriptionItemPrice(item)?.recurring : undefined) as PriceRecurring;
493
658
  const cycle = getRecurringPeriod(recurring);
494
659
 
495
660
  let trialStartAt = 0;
@@ -760,11 +925,49 @@ async function createOrUpdateSubscription(params: {
760
925
 
761
926
  const { trialInDays = 0, trialEnd = 0 } = trialConfig;
762
927
  const { method: paymentMethod, currency: paymentCurrency } = paymentSettings;
928
+ const normalizePercent = (value: any) => {
929
+ const normalized = typeof value === 'string' ? Number(value) : value;
930
+ if (!Number.isFinite(normalized) || normalized < 0) {
931
+ return DEFAULT_SLIPPAGE_PERCENT;
932
+ }
933
+ return normalized;
934
+ };
935
+ const buildSlippageConfig = () => {
936
+ const fromMeta = metadata?.slippage;
937
+ if (fromMeta && typeof fromMeta === 'object') {
938
+ const mode: 'percent' | 'rate' = fromMeta.mode === 'rate' ? 'rate' : 'percent';
939
+ const percent = normalizePercent(fromMeta.percent);
940
+ const minRate = typeof fromMeta.min_acceptable_rate === 'string' ? fromMeta.min_acceptable_rate : undefined;
941
+ const baseCurrency = typeof fromMeta.base_currency === 'string' ? fromMeta.base_currency : undefined;
942
+ const updatedAtMs = Number.isFinite(Number(fromMeta.updated_at_ms)) ? Number(fromMeta.updated_at_ms) : undefined;
943
+ return {
944
+ mode,
945
+ percent,
946
+ // Always include min_acceptable_rate if available (for both percent and rate modes)
947
+ ...(minRate ? { min_acceptable_rate: minRate } : {}),
948
+ ...(baseCurrency ? { base_currency: baseCurrency } : {}),
949
+ ...(updatedAtMs ? { updated_at_ms: updatedAtMs } : {}),
950
+ };
951
+ }
952
+ const fromSessionMeta = normalizeSlippageConfigFromMetadata(checkoutSession.metadata, DEFAULT_SLIPPAGE_PERCENT);
953
+ if (fromSessionMeta) {
954
+ return fromSessionMeta;
955
+ }
956
+ const percent = normalizePercent((checkoutSession as any).slippage_percent);
957
+ return { mode: 'percent' as const, percent };
958
+ };
959
+ const slippageConfig = buildSlippageConfig();
763
960
 
764
961
  try {
765
962
  const itemsSubscriptionData = mergeSubscriptionDataFromLineItems(lineItems);
766
963
  let subscription = null;
767
- const setup = getSubscriptionCreateSetup(lineItems, paymentCurrency.id, trialInDays, trialEnd);
964
+ // Build slippage options with min_acceptable_rate for precise authorization calculation
965
+ const slippageOptions: SlippageOptions = {
966
+ percent: slippageConfig.percent,
967
+ minAcceptableRate: slippageConfig.min_acceptable_rate,
968
+ currencyDecimal: paymentCurrency.decimal,
969
+ };
970
+ const setup = getSubscriptionCreateSetup(lineItems, paymentCurrency.id, trialInDays, trialEnd, slippageOptions);
768
971
 
769
972
  if (existingSubscriptionId) {
770
973
  subscription = await Subscription.findByPk(existingSubscriptionId);
@@ -793,6 +996,7 @@ async function createOrUpdateSubscription(params: {
793
996
  trial_start: setup.trial.start,
794
997
  pending_invoice_item_interval: setup.recurring,
795
998
  pending_setup_intent: setupIntent?.id,
999
+ slippage_config: slippageConfig,
796
1000
  });
797
1001
 
798
1002
  logger.info('Subscription updated', {
@@ -861,6 +1065,7 @@ async function createOrUpdateSubscription(params: {
861
1065
  default_payment_method_id: paymentMethod.id,
862
1066
  cancel_at_period_end: false,
863
1067
  collection_method: 'charge_automatically',
1068
+ slippage_config: slippageConfig,
864
1069
  description: subscriptionData.description || formatSubscriptionProduct(lineItems),
865
1070
  proration_behavior: subscriptionData.proration_behavior || 'none',
866
1071
  payment_behavior: 'default_incomplete',
@@ -1024,13 +1229,8 @@ export async function getSubscriptionLineItems(
1024
1229
  return subItems;
1025
1230
  }
1026
1231
 
1027
- export function isCreditMetered(price: TPrice | TPriceExpanded) {
1028
- return price.type === 'recurring' && price.recurring?.usage_type === 'metered' && !!price.recurring?.meter_id;
1029
- }
1030
-
1031
- export function isCreditMeteredLineItems(lineItems: TLineItemExpanded[]) {
1032
- return lineItems.every((item) => item.price && isCreditMetered(item.price));
1033
- }
1232
+ // Re-exported from credit-utils.ts to maintain backward compatibility
1233
+ export { isCreditMetered, isCreditMeteredLineItems } from './credit-utils';
1034
1234
 
1035
1235
  export function validateStripePaymentAmounts(amount: string, currency: PaymentCurrency) {
1036
1236
  const minAmountInUnits = fromTokenToUnit(0.5, currency.decimal);
@@ -1053,6 +1253,24 @@ export async function validatePaymentAmounts(
1053
1253
  const oneTimeItems = getOneTimeLineItems(lineItems);
1054
1254
  const recurringItems = getRecurringLineItems(lineItems);
1055
1255
 
1256
+ // Check for mixed pricing types (fixed + dynamic)
1257
+ const hasDynamicPricing = lineItems.some((item) => {
1258
+ const price = item.upsell_price || item.price;
1259
+ return (price as any)?.pricing_type === 'dynamic';
1260
+ });
1261
+
1262
+ const hasFixedPricing = lineItems.some((item) => {
1263
+ const price = item.upsell_price || item.price;
1264
+ return !(price as any)?.pricing_type || (price as any)?.pricing_type === 'fixed';
1265
+ });
1266
+
1267
+ if (hasDynamicPricing && hasFixedPricing) {
1268
+ return {
1269
+ valid: false,
1270
+ error: 'Cannot mix fixed pricing and dynamic pricing in the same checkout session',
1271
+ };
1272
+ }
1273
+
1056
1274
  const minAmountInUnits = fromTokenToUnit(0.5, currency.decimal);
1057
1275
 
1058
1276
  // Case 1: All one-time items - validate total payment amount
@@ -1254,3 +1472,432 @@ export async function getCheckoutSessionAmounts(checkoutSession: CheckoutSession
1254
1472
  discounts,
1255
1473
  };
1256
1474
  }
1475
+
1476
+ const QUOTE_LOCK_DURATION_SECONDS = 180;
1477
+ function calculateTotalBaseAmount(baseAmount: string, quantity: number): string {
1478
+ const baseAmountBN = fromTokenToUnit(trimDecimals(baseAmount, USD_DECIMALS), USD_DECIMALS);
1479
+ const quantityBN = new BN(quantity);
1480
+ const totalBaseAmountBN = baseAmountBN.mul(quantityBN);
1481
+ return fromUnitToToken(totalBaseAmountBN.toString(), USD_DECIMALS);
1482
+ }
1483
+
1484
+ /**
1485
+ * Enrich checkout session with dynamic pricing quotes
1486
+ * For each dynamic price, create or reuse a quote and attach quote info to line items
1487
+ *
1488
+ * @param options.skipGeneration - If true, only fetch existing quotes via quote_id, don't generate new ones
1489
+ */
1490
+ export async function enrichCheckoutSessionWithQuotes(
1491
+ checkoutSession: CheckoutSession,
1492
+ lineItems: TLineItemExpanded[],
1493
+ currencyId: string,
1494
+ options: { skipGeneration?: boolean; forceRefresh?: boolean } = {}
1495
+ ): Promise<{
1496
+ lineItems: TLineItemExpanded[];
1497
+ quotes: Record<
1498
+ string,
1499
+ {
1500
+ quote_id: string;
1501
+ expires_at: number;
1502
+ quoted_amount: string;
1503
+ exchange_rate?: string;
1504
+ rate_provider_name?: string;
1505
+ rate_provider_id?: string;
1506
+ }
1507
+ >;
1508
+ rateUnavailable?: boolean;
1509
+ rateError?: string;
1510
+ regenerated?: boolean;
1511
+ refreshed?: boolean;
1512
+ }> {
1513
+ const { skipGeneration = false, forceRefresh = false } = options;
1514
+ const quoteService = getQuoteService();
1515
+
1516
+ // Filter out items with missing price (data integrity issue)
1517
+ const invalidItems = lineItems.filter((item) => !item.price);
1518
+ if (invalidItems.length > 0) {
1519
+ logger.error('Line items with missing price detected - possible data integrity issue', {
1520
+ sessionId: checkoutSession.id,
1521
+ invalidPriceIds: invalidItems.map((item) => item.price_id),
1522
+ totalItems: lineItems.length,
1523
+ invalidCount: invalidItems.length,
1524
+ });
1525
+ }
1526
+ // Only process items with valid price
1527
+ const validLineItems = lineItems.filter((item) => item.price);
1528
+ if (validLineItems.length === 0) {
1529
+ logger.warn('No valid line items with price found', { sessionId: checkoutSession.id });
1530
+ return { lineItems, quotes: {}, refreshed: false };
1531
+ }
1532
+
1533
+ const quotes: Record<
1534
+ string,
1535
+ {
1536
+ quote_id: string;
1537
+ expires_at: number;
1538
+ quoted_amount: string;
1539
+ exchange_rate: string;
1540
+ rate_provider_name: string;
1541
+ rate_provider_id: string;
1542
+ }
1543
+ > = {};
1544
+
1545
+ // First, check if line_items already have quote_id references
1546
+ // If so, fetch quote data from PriceQuote table and check if expired
1547
+ const lineItemsWithQuoteIds = validLineItems.filter((item) => (item as any).quote_id);
1548
+ const now = Math.floor(Date.now() / 1000);
1549
+ const quoteLockedAt = checkoutSession.metadata?.quote_locked_at;
1550
+ const lockStart =
1551
+ typeof quoteLockedAt === 'number'
1552
+ ? quoteLockedAt
1553
+ : typeof quoteLockedAt === 'string'
1554
+ ? Number(quoteLockedAt)
1555
+ : null;
1556
+ const lockActive = Number.isFinite(lockStart) && now - (lockStart as number) < QUOTE_LOCK_DURATION_SECONDS;
1557
+ const slippagePercentValue =
1558
+ (checkoutSession.metadata as any)?.slippage?.percent ?? (checkoutSession as any).slippage_percent;
1559
+ const slippagePercentValueNumber = Number(slippagePercentValue);
1560
+ const slippagePercent = Number.isFinite(slippagePercentValueNumber) ? slippagePercentValueNumber : 0.5;
1561
+ const quotesById = new Map<string, PriceQuote>();
1562
+ const refreshQuoteIds = new Set<string>();
1563
+ let hasExpiredQuotes = false;
1564
+ let hasStaleQuotes = false;
1565
+ let hasMissingQuotes = false;
1566
+ let regenerated = false;
1567
+ let createdNewQuotes = false;
1568
+ const applyQuoteToItem = (item: TLineItemExpanded, quote: PriceQuote) => {
1569
+ (item as any).custom_amount = quote.quoted_amount;
1570
+ (item as any).quoted_amount = quote.quoted_amount;
1571
+ (item as any).quote_id = quote.id;
1572
+ (item as any).exchange_rate = quote.exchange_rate;
1573
+ (item as any).rate_provider_name = quote.rate_provider_name;
1574
+ (item as any).rate_provider_id = quote.rate_provider_id;
1575
+ (item as any).rate_timestamp_ms = quote.rate_timestamp_ms;
1576
+ (item as any).expires_at = quote.expires_at;
1577
+ // Store the currency ID this quote was created for - essential for frontend validation
1578
+ (item as any).quote_currency_id = quote.target_currency_id;
1579
+
1580
+ const quoteKey = `${item.price?.id || item.price_id}_${currencyId}`;
1581
+ quotes[quoteKey] = {
1582
+ quote_id: quote.id,
1583
+ expires_at: quote.expires_at,
1584
+ quoted_amount: quote.quoted_amount,
1585
+ exchange_rate: quote.exchange_rate,
1586
+ rate_provider_name: quote.rate_provider_name,
1587
+ rate_provider_id: quote.rate_provider_id,
1588
+ };
1589
+ };
1590
+
1591
+ if (lineItemsWithQuoteIds.length > 0) {
1592
+ const quoteIds = Array.from(new Set(lineItemsWithQuoteIds.map((item) => (item as any).quote_id).filter(Boolean)));
1593
+ const existingQuotes = await PriceQuote.findAll({
1594
+ where: { id: quoteIds },
1595
+ });
1596
+
1597
+ existingQuotes.forEach((quote) => {
1598
+ quotesById.set(quote.id, quote);
1599
+ });
1600
+ if (existingQuotes.length !== quoteIds.length) {
1601
+ hasMissingQuotes = true;
1602
+ logger.warn('Some quote references are missing from database', {
1603
+ sessionId: checkoutSession.id,
1604
+ totalQuoteIds: quoteIds.length,
1605
+ foundQuotes: existingQuotes.length,
1606
+ });
1607
+ }
1608
+
1609
+ lineItemsWithQuoteIds.forEach((item) => {
1610
+ const quoteId = (item as any).quote_id;
1611
+ const quote = quotesById.get(quoteId);
1612
+ if (!quote) {
1613
+ if (!skipGeneration) {
1614
+ refreshQuoteIds.add(quoteId);
1615
+ }
1616
+ return;
1617
+ }
1618
+
1619
+ const isPaid = quote.status === 'paid';
1620
+ const isUsed = quote.status === 'used';
1621
+ if (isPaid || (isUsed && (lockActive || skipGeneration))) {
1622
+ applyQuoteToItem(item, quote);
1623
+ return;
1624
+ }
1625
+ if (isUsed && !lockActive && !skipGeneration) {
1626
+ hasExpiredQuotes = true;
1627
+ refreshQuoteIds.add(quoteId);
1628
+ logger.info('Detected used quote with expired lock, will refresh', {
1629
+ sessionId: checkoutSession.id,
1630
+ quoteId,
1631
+ status: quote.status,
1632
+ });
1633
+ return;
1634
+ }
1635
+
1636
+ if (forceRefresh && !skipGeneration) {
1637
+ if (!lockActive) {
1638
+ refreshQuoteIds.add(quoteId);
1639
+ logger.info('Force refresh quote for checkout session', {
1640
+ sessionId: checkoutSession.id,
1641
+ quoteId,
1642
+ });
1643
+ } else {
1644
+ logger.info('Skip force refresh due to active quote lock', {
1645
+ sessionId: checkoutSession.id,
1646
+ quoteId,
1647
+ });
1648
+ }
1649
+ return;
1650
+ }
1651
+
1652
+ const price = (item.upsell_price || item.price) as any;
1653
+ if (
1654
+ !skipGeneration &&
1655
+ !lockActive &&
1656
+ quote.status !== 'paid' &&
1657
+ price?.pricing_type === 'dynamic' &&
1658
+ price?.base_amount
1659
+ ) {
1660
+ const expectedBaseAmount = calculateTotalBaseAmount(price.base_amount, Number(item.quantity || 0));
1661
+ const expectedTrimmed = trimDecimals(expectedBaseAmount, USD_DECIMALS);
1662
+ const quoteTrimmed = trimDecimals(quote.base_amount || '0', USD_DECIMALS);
1663
+ if (quoteTrimmed !== expectedTrimmed) {
1664
+ hasStaleQuotes = true;
1665
+ refreshQuoteIds.add(quoteId);
1666
+ logger.info('Detected quote base_amount mismatch, will refresh quote', {
1667
+ sessionId: checkoutSession.id,
1668
+ quoteId,
1669
+ priceId: item.price_id || item.price?.id,
1670
+ expectedBaseAmount: expectedTrimmed,
1671
+ quoteBaseAmount: quoteTrimmed,
1672
+ quantity: item.quantity,
1673
+ });
1674
+ return;
1675
+ }
1676
+ }
1677
+
1678
+ const isExpired = quote.status !== 'paid' && (quote.expires_at < now || quote.status === 'expired');
1679
+ if (!skipGeneration && !lockActive && isExpired) {
1680
+ hasExpiredQuotes = true;
1681
+ refreshQuoteIds.add(quoteId);
1682
+ logger.info('Detected expired quote', {
1683
+ sessionId: checkoutSession.id,
1684
+ expiredQuoteId: quote.id,
1685
+ expiresAt: new Date(quote.expires_at * 1000).toISOString(),
1686
+ now: new Date(now * 1000).toISOString(),
1687
+ });
1688
+ return;
1689
+ }
1690
+
1691
+ const isInactive = quote.status !== 'active' && quote.status !== 'paid';
1692
+ if (!skipGeneration && !lockActive && isInactive) {
1693
+ hasExpiredQuotes = true;
1694
+ refreshQuoteIds.add(quoteId);
1695
+ logger.info('Detected inactive quote, will refresh', {
1696
+ sessionId: checkoutSession.id,
1697
+ quoteId: quote.id,
1698
+ status: quote.status,
1699
+ });
1700
+ return;
1701
+ }
1702
+
1703
+ // Use existing quotes (either not expired, or skipGeneration)
1704
+ applyQuoteToItem(item, quote);
1705
+ });
1706
+
1707
+ logger.info('Processed existing quotes from database', {
1708
+ sessionId: checkoutSession.id,
1709
+ totalQuoteIds: lineItemsWithQuoteIds.length,
1710
+ foundQuotes: quotesById.size,
1711
+ hasExpiredQuotes,
1712
+ hasStaleQuotes,
1713
+ hasMissingQuotes,
1714
+ skipGeneration,
1715
+ refreshQuotes: refreshQuoteIds.size,
1716
+ });
1717
+
1718
+ if (skipGeneration || refreshQuoteIds.size === 0) {
1719
+ return { lineItems, quotes, refreshed: false };
1720
+ }
1721
+ }
1722
+
1723
+ // Find all dynamic pricing items that need new quotes or refresh
1724
+ const dynamicItems = validLineItems.filter((item) => {
1725
+ const price = getSubscriptionItemPrice(item);
1726
+ return (
1727
+ price?.pricing_type === 'dynamic' && ((item as any).quote_id ? refreshQuoteIds.has((item as any).quote_id) : true)
1728
+ );
1729
+ });
1730
+
1731
+ // If no dynamic items need new quotes, return early
1732
+ if (dynamicItems.length === 0) {
1733
+ return { lineItems, quotes, refreshed: false };
1734
+ }
1735
+
1736
+ // If skipGeneration is enabled, don't generate new quotes for items without quote_id
1737
+ // This is used for submitted/completed checkout sessions where we only want to use existing quotes
1738
+ if (skipGeneration) {
1739
+ logger.info('Skipping quote generation for dynamic items (skipGeneration enabled)', {
1740
+ sessionId: checkoutSession.id,
1741
+ dynamicItemsWithoutQuotes: dynamicItems.length,
1742
+ });
1743
+ return { lineItems, quotes, refreshed: false };
1744
+ }
1745
+
1746
+ // Fetch exchange rate once for all dynamic items (they all use the same target currency)
1747
+ const currency = (await PaymentCurrency.findByPk(currencyId, {
1748
+ include: [{ model: PaymentMethod, as: 'payment_method' }],
1749
+ })) as PaymentCurrency & { payment_method: PaymentMethod };
1750
+ if (!currency) {
1751
+ throw new Error(`Currency ${currencyId} not found`);
1752
+ }
1753
+
1754
+ if (currency.payment_method?.type === 'stripe') {
1755
+ return { lineItems, quotes, refreshed: false };
1756
+ }
1757
+
1758
+ const exchangeRateService = getExchangeRateService();
1759
+
1760
+ // Try to get exchange rate, but don't block checkout if unavailable
1761
+ let rateResult;
1762
+ try {
1763
+ // For ArcBlock payment method, always use ABT for exchange rate
1764
+ // For other methods, use the currency's symbol
1765
+ const rateSymbol = getExchangeRateSymbol(currency.symbol, currency.payment_method?.type as ChainType);
1766
+ rateResult = await exchangeRateService.getRate(rateSymbol);
1767
+ } catch (error: any) {
1768
+ logger.warn('Exchange rate unavailable for checkout session', {
1769
+ checkoutSessionId: checkoutSession.id,
1770
+ currencyId,
1771
+ currencySymbol: currency.symbol,
1772
+ error: error.message,
1773
+ });
1774
+
1775
+ // Return without quotes, frontend will show warning
1776
+ return {
1777
+ lineItems,
1778
+ quotes: {},
1779
+ rateUnavailable: true,
1780
+ rateError: error.message || 'Exchange rate service unavailable',
1781
+ refreshed: false,
1782
+ };
1783
+ }
1784
+
1785
+ logger.info('Fetched exchange rate for batch quote creation', {
1786
+ currencyId,
1787
+ currencySymbol: currency.symbol,
1788
+ rate: rateResult.rate,
1789
+ provider: rateResult.provider_name,
1790
+ dynamicItemsCount: dynamicItems.length,
1791
+ });
1792
+
1793
+ // Create or refresh quotes for dynamic items using the same exchange rate
1794
+ const quoteResults = await Promise.all(
1795
+ dynamicItems.map(async (item) => {
1796
+ // Use upsell_price if available, otherwise use price
1797
+ const price = getSubscriptionItemPrice(item);
1798
+ const { quantity } = item;
1799
+
1800
+ try {
1801
+ const quoteId = (item as any).quote_id;
1802
+ let quoteResponse: QuoteResponse;
1803
+
1804
+ const existingQuote = quoteId ? quotesById.get(quoteId) : null;
1805
+ const shouldCreateNewQuote = !existingQuote || (existingQuote.status === 'used' && !lockActive);
1806
+ if (!shouldCreateNewQuote && quoteId && existingQuote) {
1807
+ quoteResponse = await quoteService.refreshQuoteWithRate({
1808
+ quote_id: quoteId,
1809
+ price_id: price?.id,
1810
+ session_id: checkoutSession.id,
1811
+ target_currency_id: currencyId,
1812
+ quantity,
1813
+ rateResult,
1814
+ slippage_percent: slippagePercent,
1815
+ });
1816
+ } else {
1817
+ quoteResponse = await quoteService.createQuoteWithRate({
1818
+ price_id: price?.id,
1819
+ session_id: checkoutSession.id,
1820
+ target_currency_id: currencyId,
1821
+ quantity,
1822
+ rateResult,
1823
+ slippage_percent: slippagePercent,
1824
+ });
1825
+ createdNewQuotes = true;
1826
+ }
1827
+
1828
+ return {
1829
+ item,
1830
+ quoteResponse,
1831
+ };
1832
+ } catch (error) {
1833
+ const errorMessage = error instanceof Error ? error.message : String(error);
1834
+ logger.error('Failed to create quote for dynamic price', {
1835
+ priceId: price.id,
1836
+ checkoutSessionId: checkoutSession.id,
1837
+ currencyId,
1838
+ errorMessage,
1839
+ errorName: (error as any)?.name,
1840
+ });
1841
+ throw error;
1842
+ }
1843
+ })
1844
+ );
1845
+
1846
+ // Build quote map for quick lookup
1847
+ const quoteByPriceId = new Map<string, (typeof quoteResults)[0]>();
1848
+ quoteResults.forEach((result) => {
1849
+ const priceId = result.item.price?.id || result.item.price_id;
1850
+ if (priceId) {
1851
+ quoteByPriceId.set(priceId, result);
1852
+ }
1853
+ });
1854
+
1855
+ // Extract quote info helper
1856
+ const extractQuoteInfo = (quoteResponse: QuoteResponse) => ({
1857
+ quote_id: quoteResponse.quote.id,
1858
+ expires_at: quoteResponse.expires_at,
1859
+ quoted_amount: quoteResponse.computed_unit_amount,
1860
+ exchange_rate: quoteResponse.quote.exchange_rate,
1861
+ rate_provider_name: quoteResponse.quote.rate_provider_name,
1862
+ rate_provider_id: quoteResponse.quote.rate_provider_id,
1863
+ rate_timestamp_ms: quoteResponse.quote.rate_timestamp_ms,
1864
+ });
1865
+
1866
+ // Build quotes object
1867
+ quoteResults.forEach((result) => {
1868
+ const priceId = result.item.price?.id || result.item.price_id;
1869
+ if (priceId) {
1870
+ const quoteKey = `${priceId}_${currencyId}`;
1871
+ quotes[quoteKey] = extractQuoteInfo(result.quoteResponse);
1872
+ }
1873
+ });
1874
+
1875
+ regenerated = createdNewQuotes;
1876
+ const refreshed = refreshQuoteIds.size > 0 || createdNewQuotes;
1877
+
1878
+ // Enrich line items with quote information
1879
+ const enrichedLineItems = lineItems.map((item) => {
1880
+ // Skip items with missing price
1881
+ if (!item.price) {
1882
+ return item;
1883
+ }
1884
+ const quoteInfo = quoteByPriceId.get(item.price.id);
1885
+ if (!quoteInfo) {
1886
+ return item;
1887
+ }
1888
+
1889
+ const quoteData = extractQuoteInfo(quoteInfo.quoteResponse);
1890
+ return {
1891
+ ...item,
1892
+ ...quoteData,
1893
+ custom_amount: quoteInfo.quoteResponse.computed_unit_amount,
1894
+ };
1895
+ });
1896
+
1897
+ return {
1898
+ lineItems: enrichedLineItems,
1899
+ quotes,
1900
+ regenerated,
1901
+ refreshed,
1902
+ };
1903
+ }