payment-kit 1.24.4 → 1.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/api/src/index.ts +3 -0
  2. package/api/src/libs/credit-utils.ts +21 -0
  3. package/api/src/libs/discount/discount.ts +13 -0
  4. package/api/src/libs/env.ts +5 -0
  5. package/api/src/libs/error.ts +14 -0
  6. package/api/src/libs/exchange-rate/coingecko-provider.ts +193 -0
  7. package/api/src/libs/exchange-rate/coinmarketcap-provider.ts +180 -0
  8. package/api/src/libs/exchange-rate/index.ts +5 -0
  9. package/api/src/libs/exchange-rate/service.ts +583 -0
  10. package/api/src/libs/exchange-rate/token-address-mapping.ts +84 -0
  11. package/api/src/libs/exchange-rate/token-addresses.json +2147 -0
  12. package/api/src/libs/exchange-rate/token-data-provider.ts +142 -0
  13. package/api/src/libs/exchange-rate/types.ts +114 -0
  14. package/api/src/libs/exchange-rate/validator.ts +319 -0
  15. package/api/src/libs/invoice-quote.ts +158 -0
  16. package/api/src/libs/invoice.ts +143 -7
  17. package/api/src/libs/math-utils.ts +46 -0
  18. package/api/src/libs/notification/template/billing-discrepancy.ts +3 -4
  19. package/api/src/libs/notification/template/customer-auto-recharge-failed.ts +174 -79
  20. package/api/src/libs/notification/template/customer-credit-grant-granted.ts +2 -3
  21. package/api/src/libs/notification/template/customer-credit-insufficient.ts +3 -3
  22. package/api/src/libs/notification/template/customer-credit-low-balance.ts +3 -3
  23. package/api/src/libs/notification/template/customer-revenue-succeeded.ts +2 -3
  24. package/api/src/libs/notification/template/customer-reward-succeeded.ts +9 -4
  25. package/api/src/libs/notification/template/exchange-rate-alert.ts +202 -0
  26. package/api/src/libs/notification/template/subscription-slippage-exceeded.ts +203 -0
  27. package/api/src/libs/notification/template/subscription-slippage-warning.ts +212 -0
  28. package/api/src/libs/notification/template/subscription-will-canceled.ts +2 -2
  29. package/api/src/libs/notification/template/subscription-will-renew.ts +22 -8
  30. package/api/src/libs/payment.ts +3 -1
  31. package/api/src/libs/price.ts +4 -1
  32. package/api/src/libs/queue/index.ts +8 -0
  33. package/api/src/libs/quote-service.ts +1132 -0
  34. package/api/src/libs/quote-validation.ts +388 -0
  35. package/api/src/libs/session.ts +686 -39
  36. package/api/src/libs/slippage.ts +135 -0
  37. package/api/src/libs/subscription.ts +185 -15
  38. package/api/src/libs/util.ts +64 -3
  39. package/api/src/locales/en.ts +50 -0
  40. package/api/src/locales/zh.ts +48 -0
  41. package/api/src/queues/auto-recharge.ts +295 -21
  42. package/api/src/queues/exchange-rate-health.ts +242 -0
  43. package/api/src/queues/invoice.ts +48 -1
  44. package/api/src/queues/notification.ts +167 -1
  45. package/api/src/queues/payment.ts +177 -7
  46. package/api/src/queues/refund.ts +41 -9
  47. package/api/src/queues/subscription.ts +436 -6
  48. package/api/src/routes/auto-recharge-configs.ts +71 -6
  49. package/api/src/routes/checkout-sessions.ts +1730 -81
  50. package/api/src/routes/connect/auto-recharge-auth.ts +2 -0
  51. package/api/src/routes/connect/change-payer.ts +2 -0
  52. package/api/src/routes/connect/change-payment.ts +61 -8
  53. package/api/src/routes/connect/change-plan.ts +161 -17
  54. package/api/src/routes/connect/collect.ts +9 -6
  55. package/api/src/routes/connect/delegation.ts +1 -0
  56. package/api/src/routes/connect/pay.ts +157 -0
  57. package/api/src/routes/connect/setup.ts +32 -10
  58. package/api/src/routes/connect/shared.ts +159 -13
  59. package/api/src/routes/connect/subscribe.ts +32 -9
  60. package/api/src/routes/credit-grants.ts +99 -0
  61. package/api/src/routes/exchange-rate-providers.ts +248 -0
  62. package/api/src/routes/exchange-rates.ts +87 -0
  63. package/api/src/routes/index.ts +4 -0
  64. package/api/src/routes/invoices.ts +280 -2
  65. package/api/src/routes/payment-links.ts +13 -0
  66. package/api/src/routes/prices.ts +84 -2
  67. package/api/src/routes/subscriptions.ts +526 -15
  68. package/api/src/store/migrations/20251220-dynamic-pricing.ts +245 -0
  69. package/api/src/store/migrations/20251223-exchange-rate-provider-type.ts +28 -0
  70. package/api/src/store/migrations/20260110-add-quote-locked-at.ts +23 -0
  71. package/api/src/store/migrations/20260112-add-checkout-session-slippage-percent.ts +22 -0
  72. package/api/src/store/migrations/20260113-add-price-quote-slippage-fields.ts +45 -0
  73. package/api/src/store/migrations/20260116-subscription-slippage.ts +21 -0
  74. package/api/src/store/migrations/20260120-auto-recharge-slippage.ts +21 -0
  75. package/api/src/store/models/auto-recharge-config.ts +12 -0
  76. package/api/src/store/models/checkout-session.ts +7 -0
  77. package/api/src/store/models/exchange-rate-provider.ts +225 -0
  78. package/api/src/store/models/index.ts +6 -0
  79. package/api/src/store/models/payment-intent.ts +6 -0
  80. package/api/src/store/models/price-quote.ts +284 -0
  81. package/api/src/store/models/price.ts +53 -5
  82. package/api/src/store/models/subscription.ts +11 -0
  83. package/api/src/store/models/types.ts +61 -1
  84. package/api/tests/libs/change-payment-plan.spec.ts +282 -0
  85. package/api/tests/libs/exchange-rate-service.spec.ts +341 -0
  86. package/api/tests/libs/quote-service.spec.ts +199 -0
  87. package/api/tests/libs/session.spec.ts +464 -0
  88. package/api/tests/libs/slippage.spec.ts +109 -0
  89. package/api/tests/libs/token-data-provider.spec.ts +267 -0
  90. package/api/tests/models/exchange-rate-provider.spec.ts +121 -0
  91. package/api/tests/models/price-dynamic.spec.ts +100 -0
  92. package/api/tests/models/price-quote.spec.ts +112 -0
  93. package/api/tests/routes/exchange-rate-providers.spec.ts +215 -0
  94. package/api/tests/routes/subscription-slippage.spec.ts +254 -0
  95. package/blocklet.yml +1 -1
  96. package/package.json +7 -6
  97. package/src/components/customer/credit-overview.tsx +14 -0
  98. package/src/components/discount/discount-info.tsx +8 -2
  99. package/src/components/invoice/list.tsx +146 -16
  100. package/src/components/invoice/table.tsx +276 -71
  101. package/src/components/invoice-pdf/template.tsx +3 -7
  102. package/src/components/metadata/form.tsx +6 -8
  103. package/src/components/price/form.tsx +519 -149
  104. package/src/components/promotion/active-redemptions.tsx +5 -3
  105. package/src/components/quote/info.tsx +234 -0
  106. package/src/hooks/subscription.ts +132 -2
  107. package/src/locales/en.tsx +145 -0
  108. package/src/locales/zh.tsx +143 -1
  109. package/src/pages/admin/billing/invoices/detail.tsx +41 -4
  110. package/src/pages/admin/products/exchange-rate-providers/edit-dialog.tsx +354 -0
  111. package/src/pages/admin/products/exchange-rate-providers/index.tsx +363 -0
  112. package/src/pages/admin/products/index.tsx +12 -1
  113. package/src/pages/customer/invoice/detail.tsx +36 -12
  114. package/src/pages/customer/subscription/change-payment.tsx +65 -3
  115. package/src/pages/customer/subscription/change-plan.tsx +207 -38
  116. package/src/pages/customer/subscription/detail.tsx +599 -419
@@ -0,0 +1,135 @@
1
+ import { BN, fromTokenToUnit, fromUnitToToken } from '@ocap/util';
2
+
3
+ import { trimDecimals } from './math-utils';
4
+
5
+ export const SLIPPAGE_BPS_BASE = new BN(10000);
6
+ export const DEFAULT_SLIPPAGE_PERCENT = 0.5;
7
+
8
+ export type SlippageConfig = {
9
+ mode: 'percent' | 'rate';
10
+ percent: number;
11
+ min_acceptable_rate?: string;
12
+ base_currency?: string;
13
+ updated_at_ms?: number;
14
+ };
15
+
16
+ type SlippageSource = {
17
+ slippage_percent?: number | string | null;
18
+ metadata?: any;
19
+ };
20
+
21
+ export function normalizeSlippagePercent(value: unknown, fallback = DEFAULT_SLIPPAGE_PERCENT): number {
22
+ const normalized = typeof value === 'string' ? Number(value) : Number(value);
23
+ if (!Number.isFinite(normalized) || normalized < 0) {
24
+ return fallback;
25
+ }
26
+ return normalized;
27
+ }
28
+
29
+ export function normalizeSlippageConfigFromMetadata(
30
+ metadata: any,
31
+ fallbackPercent: number = DEFAULT_SLIPPAGE_PERCENT
32
+ ): SlippageConfig | null {
33
+ const fromMeta = metadata?.slippage;
34
+ if (!fromMeta || typeof fromMeta !== 'object') {
35
+ return null;
36
+ }
37
+ const mode = fromMeta.mode === 'rate' ? 'rate' : 'percent';
38
+ const percent = normalizeSlippagePercent(fromMeta.percent, fallbackPercent);
39
+ return {
40
+ mode,
41
+ percent,
42
+ min_acceptable_rate: fromMeta.min_acceptable_rate,
43
+ base_currency: fromMeta.base_currency,
44
+ updated_at_ms: fromMeta.updated_at_ms,
45
+ };
46
+ }
47
+
48
+ export function resolveSlippagePercent(
49
+ quote: SlippageSource,
50
+ checkoutSession?: SlippageSource,
51
+ fallbackPercent: number = DEFAULT_SLIPPAGE_PERCENT
52
+ ): number {
53
+ const fromQuote = quote.slippage_percent ?? quote.metadata?.slippage?.percent;
54
+ const fromSessionMeta = normalizeSlippageConfigFromMetadata(checkoutSession?.metadata, fallbackPercent)?.percent;
55
+ const fromSession = checkoutSession?.slippage_percent;
56
+ const value = fromQuote ?? fromSessionMeta ?? fromSession ?? fallbackPercent;
57
+ return normalizeSlippagePercent(value, fallbackPercent);
58
+ }
59
+
60
+ export function buildSlippageSnapshot(params: {
61
+ quote: SlippageSource & { quoted_amount?: string | null; exchange_rate?: string | null };
62
+ checkoutSession?: SlippageSource;
63
+ nowMs?: number;
64
+ }): { percent: number; max_payable_token: string; min_acceptable_rate: string; derived_at_ms: number } | null {
65
+ const { quote, checkoutSession, nowMs = Date.now() } = params;
66
+ if (!quote.quoted_amount || !quote.exchange_rate) {
67
+ return null;
68
+ }
69
+
70
+ // Check if user is using rate mode with a specific min_acceptable_rate
71
+ // In this case, preserve user's precise rate value instead of recalculating from percent
72
+ const sessionSlippage = checkoutSession?.metadata?.slippage;
73
+ const userSetMinRate = sessionSlippage?.mode === 'rate' && sessionSlippage?.min_acceptable_rate;
74
+
75
+ const slippagePercent = resolveSlippagePercent(quote, checkoutSession);
76
+ const slippageBps = Math.round(slippagePercent * 100);
77
+ const multiplier = SLIPPAGE_BPS_BASE.add(new BN(slippageBps));
78
+ const quotedAmountUnit = new BN(quote.quoted_amount);
79
+ const maxPayableToken = quotedAmountUnit
80
+ .mul(multiplier)
81
+ .add(SLIPPAGE_BPS_BASE.sub(new BN(1)))
82
+ .div(SLIPPAGE_BPS_BASE);
83
+
84
+ let minAcceptableRate: string;
85
+ if (userSetMinRate) {
86
+ // Rate mode: preserve user's precise min_acceptable_rate value
87
+ minAcceptableRate = sessionSlippage.min_acceptable_rate;
88
+ } else {
89
+ // Percent mode: calculate min_acceptable_rate from percent
90
+ const USD_DECIMALS = 8;
91
+ const rateBN = fromTokenToUnit(trimDecimals(quote.exchange_rate, USD_DECIMALS), USD_DECIMALS);
92
+ const minRateBN = rateBN.mul(SLIPPAGE_BPS_BASE).div(multiplier);
93
+ minAcceptableRate = fromUnitToToken(minRateBN.toString(), USD_DECIMALS);
94
+ }
95
+
96
+ return {
97
+ percent: slippagePercent,
98
+ max_payable_token: maxPayableToken.toString(),
99
+ min_acceptable_rate: minAcceptableRate,
100
+ derived_at_ms: nowMs,
101
+ };
102
+ }
103
+
104
+ export function isRateBelowMinAcceptableRate(
105
+ currentRate?: string | number | null,
106
+ minAcceptableRate?: string | number | null
107
+ ): boolean {
108
+ const current = Number(currentRate);
109
+ const min = Number(minAcceptableRate);
110
+ if (!Number.isFinite(current) || !Number.isFinite(min)) {
111
+ return false;
112
+ }
113
+ return current < min;
114
+ }
115
+
116
+ /**
117
+ * Apply slippage to an amount to get the maximum payable amount
118
+ * This is used for delegation authorization to ensure sufficient buffer
119
+ *
120
+ * @param amount - Base amount in smallest unit (e.g., wei)
121
+ * @param slippagePercent - Slippage percentage (e.g., 15 for 15%)
122
+ * @returns Maximum amount with slippage applied (rounded up)
123
+ */
124
+ export function applySlippageToAmount(amount: BN, slippagePercent: number): BN {
125
+ if (!slippagePercent || slippagePercent <= 0) {
126
+ return amount;
127
+ }
128
+ const slippageBps = Math.round(slippagePercent * 100);
129
+ const multiplier = SLIPPAGE_BPS_BASE.add(new BN(slippageBps));
130
+ // Round up: (amount * multiplier + BASE - 1) / BASE
131
+ return amount
132
+ .mul(multiplier)
133
+ .add(SLIPPAGE_BPS_BASE.sub(new BN(1)))
134
+ .div(SLIPPAGE_BPS_BASE);
135
+ }
@@ -1,6 +1,6 @@
1
1
  /* eslint-disable no-await-in-loop */
2
2
  import component from '@blocklet/sdk/lib/component';
3
- import { BN, fromUnitToToken } from '@ocap/util';
3
+ import { BN, fromTokenToUnit, fromUnitToToken } from '@ocap/util';
4
4
  import isEmpty from 'lodash/isEmpty';
5
5
  import trim from 'lodash/trim';
6
6
  import pick from 'lodash/pick';
@@ -9,6 +9,7 @@ import { withQuery } from 'ufo';
9
9
 
10
10
  import { Op } from 'sequelize';
11
11
  import {
12
+ ChainType,
12
13
  Customer,
13
14
  Invoice,
14
15
  InvoiceItem,
@@ -30,13 +31,17 @@ import { createEvent } from './audit';
30
31
  import dayjs from './dayjs';
31
32
  import env from './env';
32
33
  import logger from './logger';
34
+ import { getExchangeRateService } from './exchange-rate';
35
+ import { getExchangeRateSymbol } from './exchange-rate/token-address-mapping';
36
+ import { trimDecimals, limitTokenPrecision } from './math-utils';
33
37
  import { getPriceCurrencyOptions, getPriceUintAmountByCurrency } from './price';
34
- import { getRecurringPeriod, getSubscriptionCreateSetup } from './session';
38
+ import { getRecurringPeriod, getSubscriptionCreateSetup, SlippageOptions } from './session';
35
39
  import { getConnectQueryParam, getCustomerStakeAddress } from './util';
36
40
  import { wallet } from './auth';
37
41
  import { getGasPayerExtra } from './payment';
38
42
  import { getLock } from './lock';
39
43
  import { emitAsync } from './event';
44
+ import { getSubscriptionItemPrice } from './credit-utils';
40
45
 
41
46
  export function getCustomerSubscriptionPageUrl({
42
47
  subscriptionId,
@@ -263,9 +268,16 @@ export function getSubscriptionCycleAmount(items: TLineItemExpanded[], currencyI
263
268
  let amount = new BN(0);
264
269
 
265
270
  items.forEach((x) => {
266
- amount = amount.add(
267
- new BN(getPriceUintAmountByCurrency(getSubscriptionItemPrice(x) as any, currencyId)).mul(new BN(x.quantity))
268
- );
271
+ const price = getSubscriptionItemPrice(x) as any;
272
+ const dynamicAmount =
273
+ price?.pricing_type === 'dynamic' ? (x as any).custom_amount || (x as any).quoted_amount : null;
274
+
275
+ if (dynamicAmount) {
276
+ amount = amount.add(new BN(dynamicAmount));
277
+ return;
278
+ }
279
+
280
+ amount = amount.add(new BN(getPriceUintAmountByCurrency(price, currencyId)).mul(new BN(x.quantity)));
269
281
  });
270
282
 
271
283
  return {
@@ -273,9 +285,8 @@ export function getSubscriptionCycleAmount(items: TLineItemExpanded[], currencyI
273
285
  };
274
286
  }
275
287
 
276
- export function getSubscriptionItemPrice(item: TLineItemExpanded) {
277
- return item.upsell_price || item.price;
278
- }
288
+ // Re-exported from credit-utils.ts to maintain backward compatibility
289
+ export { getSubscriptionItemPrice } from './credit-utils';
279
290
 
280
291
  export async function createProration(
281
292
  subscription: Subscription,
@@ -330,17 +341,29 @@ export async function createProration(
330
341
  const prorations = await Promise.all(
331
342
  prorationItems.map((x: TLineItemExpanded & { [key: string]: any }) => {
332
343
  const price = getSubscriptionItemPrice(x);
333
- const unitAmount = getPriceUintAmountByCurrency(price, lastInvoice.currency_id);
334
- const amount = new BN(unitAmount)
335
- .mul(new BN(x.quantity))
336
- .mul(new BN(prorationRate))
337
- .div(new BN(precision))
338
- .toString();
344
+ // For dynamic pricing, use the actual paid amount from invoice item
345
+ // For fixed pricing, use the standard unit_amount calculation
346
+ let baseAmount: string;
347
+ if (price.pricing_type === 'dynamic') {
348
+ // Use invoice item's actual amount (from Quote)
349
+ baseAmount = x.amount || '0';
350
+ logger.info('Using dynamic pricing amount for proration', {
351
+ subscriptionId: subscription.id,
352
+ priceId: price.id,
353
+ actualAmount: baseAmount,
354
+ });
355
+ } else {
356
+ const unitAmount = getPriceUintAmountByCurrency(price, lastInvoice.currency_id);
357
+ baseAmount = new BN(unitAmount).mul(new BN(x.quantity)).toString();
358
+ }
359
+
360
+ const amount = new BN(baseAmount).mul(new BN(prorationRate)).div(new BN(precision)).toString();
339
361
  logger.info('subscription proration item', {
340
362
  subscription: subscription.id,
341
363
  invoice: x.invoice_id,
342
364
  invoiceItem: x.id,
343
365
  amount,
366
+ isDynamic: price.pricing_type === 'dynamic',
344
367
  });
345
368
  unused = unused.add(new BN(amount));
346
369
 
@@ -362,6 +385,10 @@ export async function createProration(
362
385
  invoice_line_items: [x.id],
363
386
  },
364
387
  },
388
+ // Additional info for frontend to recalculate with current exchange rate
389
+ pricing_type: price.pricing_type,
390
+ base_amount_usd: price.pricing_type === 'dynamic' ? price.base_amount : null,
391
+ proration_rate: prorationRate / precision, // e.g., 0.85 means 85% unused time
365
392
  };
366
393
  })
367
394
  );
@@ -437,7 +464,17 @@ export async function createProration(
437
464
  export async function getSubscriptionRefundSetup(subscription: Subscription, anchor: number, currencyId?: string) {
438
465
  const items = await SubscriptionItem.findAll({ where: { subscription_id: subscription.id } });
439
466
  const expanded = await Price.expand(items.map((x) => x.toJSON()));
440
- const setup = getSubscriptionCreateSetup(expanded, currencyId || subscription.currency_id, 0);
467
+ const targetCurrencyId = currencyId || subscription.currency_id;
468
+
469
+ // Build slippage options with min_acceptable_rate for precise authorization calculation
470
+ const slippageConfig = subscription.slippage_config;
471
+ const currency = await PaymentCurrency.findByPk(targetCurrencyId);
472
+ const slippageOptions: SlippageOptions = {
473
+ percent: slippageConfig?.percent ?? 0.5,
474
+ minAcceptableRate: slippageConfig?.min_acceptable_rate,
475
+ currencyDecimal: currency?.decimal,
476
+ };
477
+ const setup = getSubscriptionCreateSetup(expanded, targetCurrencyId, 0, 0, slippageOptions);
441
478
  return createProration(subscription, setup, anchor);
442
479
  }
443
480
 
@@ -488,8 +525,13 @@ export async function getUpcomingInvoiceAmount(subscriptionId: string) {
488
525
 
489
526
  let amount = new BN(0);
490
527
  let minExpectedAmount = new BN(0);
528
+ let hasDynamicPricing = false;
529
+
491
530
  for (const item of expanded) {
492
531
  const price = getSubscriptionItemPrice(item);
532
+ if (price.pricing_type === 'dynamic') {
533
+ hasDynamicPricing = true;
534
+ }
493
535
  if (price.type === 'recurring') {
494
536
  const unit = getPriceUintAmountByCurrency(price, subscription.currency_id);
495
537
  if (price.recurring?.usage_type === 'licensed') {
@@ -513,12 +555,43 @@ export async function getUpcomingInvoiceAmount(subscriptionId: string) {
513
555
  }
514
556
  }
515
557
 
558
+ // Get exchange rate info for dynamic pricing subscriptions
559
+ let quoteInfo: {
560
+ exchange_rate: string;
561
+ rate_timestamp_ms: number;
562
+ providers: Array<{ provider_name: string; rate?: string }>;
563
+ } | null = null;
564
+
565
+ if (hasDynamicPricing && currency) {
566
+ try {
567
+ const paymentMethod = await PaymentMethod.findByPk(currency.payment_method_id);
568
+ const exchangeRateService = getExchangeRateService();
569
+ const rateSymbol = getExchangeRateSymbol(currency.symbol, paymentMethod?.type as ChainType);
570
+ if (rateSymbol) {
571
+ const rateResult = await exchangeRateService.getRate(rateSymbol);
572
+ if (rateResult?.rate) {
573
+ quoteInfo = {
574
+ exchange_rate: rateResult.rate,
575
+ rate_timestamp_ms: rateResult.timestamp_ms || Date.now(),
576
+ providers: rateResult.providers || [],
577
+ };
578
+ }
579
+ }
580
+ } catch (err) {
581
+ logger.warn('Failed to fetch exchange rate for upcoming invoice', {
582
+ subscriptionId,
583
+ error: err,
584
+ });
585
+ }
586
+ }
587
+
516
588
  return {
517
589
  amount: amount.toString(),
518
590
  minExpectedAmount: minExpectedAmount.toString(),
519
591
  start: subscription.current_period_start,
520
592
  end: subscription.current_period_end,
521
593
  currency,
594
+ quoteInfo,
522
595
  };
523
596
  }
524
597
 
@@ -1153,6 +1226,103 @@ export async function getPaymentAmountForCycleSubscription(
1153
1226
  return 0;
1154
1227
  }
1155
1228
 
1229
+ export async function getEstimatedPaymentAmountForCycleSubscription(
1230
+ subscription: Subscription,
1231
+ paymentCurrency: PaymentCurrency
1232
+ ): Promise<{ amount: number; estimatedByRate: boolean }> {
1233
+ const subscriptionItems = await SubscriptionItem.findAll({ where: { subscription_id: subscription.id } });
1234
+ if (subscriptionItems.length === 0) {
1235
+ logger.info('subscription items not found in getEstimatedPaymentAmountForCycleSubscription', {
1236
+ subscription: subscription.id,
1237
+ });
1238
+ return { amount: 0, estimatedByRate: false };
1239
+ }
1240
+ let expandedItems = await Price.expand(
1241
+ subscriptionItems.map((x) => ({ id: x.id, price_id: x.price_id, quantity: x.quantity })),
1242
+ { product: true }
1243
+ );
1244
+ if (expandedItems.length === 0) {
1245
+ logger.info('expanded items not found in getEstimatedPaymentAmountForCycleSubscription', {
1246
+ subscription: subscription.id,
1247
+ });
1248
+ return { amount: 0, estimatedByRate: false };
1249
+ }
1250
+ const previousPeriodEnd =
1251
+ subscription.status === 'trialing' ? subscription.trial_end : subscription.current_period_end;
1252
+ const setup = getSubscriptionCycleSetup(subscription.pending_invoice_item_interval, previousPeriodEnd as number);
1253
+ expandedItems = await Promise.all(
1254
+ expandedItems.map(async (x: any) => {
1255
+ if (x.price.recurring?.usage_type === 'metered') {
1256
+ const rawQuantity = await UsageRecord.getSummary({
1257
+ id: x.id,
1258
+ start: setup.period.start - setup.cycle / 1000,
1259
+ end: setup.period.end - setup.cycle / 1000,
1260
+ method: x.price.recurring?.aggregate_usage,
1261
+ dryRun: true,
1262
+ });
1263
+ x.quantity = x.price.transformQuantity(rawQuantity);
1264
+ x.metadata = x.metadata || {};
1265
+ x.metadata.quantity = rawQuantity;
1266
+ }
1267
+ return x;
1268
+ })
1269
+ );
1270
+ if (expandedItems.length === 0) {
1271
+ return { amount: 0, estimatedByRate: false };
1272
+ }
1273
+
1274
+ let estimatedByRate = false;
1275
+ const hasDynamicItems = expandedItems.some((item: any) => item.price?.pricing_type === 'dynamic');
1276
+ if (hasDynamicItems) {
1277
+ try {
1278
+ const paymentMethod = await PaymentMethod.findByPk(paymentCurrency.payment_method_id);
1279
+ if (!paymentMethod) {
1280
+ throw new Error(`PaymentMethod not found in ${subscription.id}`);
1281
+ }
1282
+ const exchangeRateService = getExchangeRateService();
1283
+ const rateSymbol = getExchangeRateSymbol(paymentCurrency.symbol, paymentMethod.type as any);
1284
+ const rateResult = await exchangeRateService.getRate(rateSymbol);
1285
+ const USD_DECIMALS = 8;
1286
+ const rateBN = fromTokenToUnit(trimDecimals(rateResult.rate, USD_DECIMALS), USD_DECIMALS);
1287
+
1288
+ expandedItems = expandedItems.map((item: any) => {
1289
+ const { price } = item;
1290
+ if (price?.pricing_type !== 'dynamic') {
1291
+ return item;
1292
+ }
1293
+ if (!price.base_amount) {
1294
+ logger.warn('Dynamic price missing base_amount for estimate', {
1295
+ subscriptionId: subscription.id,
1296
+ priceId: price.id,
1297
+ });
1298
+ return item;
1299
+ }
1300
+
1301
+ const baseAmountBN = fromTokenToUnit(trimDecimals(price.base_amount, USD_DECIMALS), USD_DECIMALS);
1302
+ const quantityBN = new BN(item.quantity || 0);
1303
+ const totalBaseAmountBN = baseAmountBN.mul(quantityBN);
1304
+ const numerator = totalBaseAmountBN.mul(new BN(10).pow(new BN(paymentCurrency.decimal)));
1305
+ const quotedAmountRaw = numerator.add(rateBN).sub(new BN(1)).div(rateBN);
1306
+ const quotedAmount = limitTokenPrecision(quotedAmountRaw, paymentCurrency.decimal, 10);
1307
+ estimatedByRate = true;
1308
+ return {
1309
+ ...item,
1310
+ custom_amount: quotedAmount.toString(),
1311
+ quoted_amount: quotedAmount.toString(),
1312
+ };
1313
+ });
1314
+ } catch (error: any) {
1315
+ logger.warn('Failed to estimate dynamic pricing amount for subscription', {
1316
+ subscriptionId: subscription.id,
1317
+ error: error?.message || error,
1318
+ });
1319
+ }
1320
+ }
1321
+
1322
+ const amount = getSubscriptionCycleAmount(expandedItems, paymentCurrency.id);
1323
+ return { amount: +fromUnitToToken(amount?.total || '0', paymentCurrency.decimal), estimatedByRate };
1324
+ }
1325
+
1156
1326
  // check if subscription overdraft protection is enabled
1157
1327
  export async function isSubscriptionOverdraftProtectionEnabled(subscription: Subscription, paymentCurrencyId?: string) {
1158
1328
  try {
@@ -521,6 +521,26 @@ export function getCustomerIndexUrl({ locale, userDid }: { locale: string; userD
521
521
  return getUrl(withQuery('customer', { locale, ...getConnectQueryParam({ userDid }) }));
522
522
  }
523
523
 
524
+ export function getCustomerAutoRechargeSettingsUrl({
525
+ locale,
526
+ userDid,
527
+ currencyId,
528
+ }: {
529
+ locale: string;
530
+ userDid: string;
531
+ currencyId?: string;
532
+ }) {
533
+ const query: Record<string, string> = {
534
+ locale,
535
+ action: 'auto-recharge',
536
+ ...getConnectQueryParam({ userDid }),
537
+ };
538
+ if (currencyId) {
539
+ query.currencyId = currencyId;
540
+ }
541
+ return getUrl(withQuery('customer', query));
542
+ }
543
+
524
544
  // Check if user is in blocklist
525
545
  export async function isUserInBlocklist(did: string, paymentMethod: PaymentMethod): Promise<boolean> {
526
546
  try {
@@ -584,20 +604,61 @@ export function formatCurrencyInfo(
584
604
  paymentMethod?: PaymentMethod | null,
585
605
  isToken?: boolean
586
606
  ) {
587
- let amountStr = '';
588
607
  const defaultPaymentCurrency = {
589
608
  symbol: '',
590
609
  decimal: 18,
591
610
  };
592
611
 
612
+ const decimal = paymentCurrency.decimal ?? defaultPaymentCurrency.decimal;
613
+ const symbol = paymentCurrency.symbol ?? defaultPaymentCurrency.symbol;
614
+
615
+ let formattedAmount: string;
593
616
  if (isToken) {
594
- amountStr = `${amount || '0'} ${paymentCurrency.symbol ?? defaultPaymentCurrency.symbol}`;
617
+ // Already in token format, apply precision formatting
618
+ const numericValue = Number(amount || '0');
619
+ if (!Number.isFinite(numericValue)) {
620
+ formattedAmount = String(amount || '0');
621
+ } else {
622
+ const abs = Math.abs(numericValue);
623
+ // If amount > 0.01, show 2 decimal places; otherwise show full precision
624
+ const targetPrecision = abs > 0 && abs < 0.01 ? decimal : 2;
625
+ formattedAmount = formatNumber(numericValue, targetPrecision, true, false) || '0';
626
+ }
595
627
  } else {
596
- amountStr = `${fromUnitToToken(amount || '0', paymentCurrency.decimal ?? defaultPaymentCurrency.decimal)} ${paymentCurrency.symbol ?? defaultPaymentCurrency.symbol}`;
628
+ // Convert from unit to token format, then apply precision formatting
629
+ const tokenAmount = fromUnitToToken(amount || '0', decimal);
630
+ const numericValue = Number(tokenAmount);
631
+ if (!Number.isFinite(numericValue)) {
632
+ formattedAmount = tokenAmount;
633
+ } else {
634
+ const abs = Math.abs(numericValue);
635
+ // If amount > 0.01, show 2 decimal places; otherwise show full precision
636
+ const targetPrecision = abs > 0 && abs < 0.01 ? decimal : 2;
637
+ formattedAmount = formatNumber(numericValue, targetPrecision, true, false) || '0';
638
+ }
597
639
  }
640
+
641
+ const amountStr = `${formattedAmount} ${symbol}`;
598
642
  return paymentMethod && paymentMethod.type !== 'arcblock' ? `${amountStr} (${paymentMethod.name})` : amountStr;
599
643
  }
600
644
 
645
+ /**
646
+ * Format amount from unit to token with consistent precision
647
+ * - If amount > 0.01, show 2 decimal places
648
+ * - If 0 < amount <= 0.01, show full precision
649
+ * - Returns formatted string without symbol
650
+ */
651
+ export function formatTokenAmount(amount: string | number, decimal: number): string {
652
+ const tokenAmount = fromUnitToToken(amount || '0', decimal);
653
+ const numericValue = Number(tokenAmount);
654
+ if (!Number.isFinite(numericValue)) {
655
+ return tokenAmount;
656
+ }
657
+ const abs = Math.abs(numericValue);
658
+ const targetPrecision = abs > 0 && abs < 0.01 ? decimal : 2;
659
+ return formatNumber(numericValue, targetPrecision, true, false) || '0';
660
+ }
661
+
601
662
  export function getExplorerTxUrl({
602
663
  explorerHost,
603
664
  txHash,
@@ -159,6 +159,8 @@ export default flat({
159
159
  'The estimated payment amount is {price}, but your current balance is insufficient ({balance}). Please ensure your account has enough balance to avoid payment failure.',
160
160
  renewAmount: 'Payment amount',
161
161
  estimatedAmountNote: 'Estimate {amount}, billed based on final usage',
162
+ estimatedAmountNoteRate: 'Estimate {amount}, based on the exchange rate at billing time',
163
+ estimatedAmountNoteRateAndUsage: 'Estimate {amount}, based on final usage and the exchange rate at billing time',
162
164
  },
163
165
 
164
166
  subscriptionRenewed: {
@@ -204,7 +206,21 @@ export default flat({
204
206
  noEnoughToken: 'Your account token balance is {balance}, which is insufficient for {price}. Please add funds',
205
207
  noSupported: 'Automatic payment with tokens is not supported. Please check your package',
206
208
  txSendFailed: 'Failed to send automatic payment transaction',
209
+ // Skipped reasons (dynamic pricing)
210
+ slippageExceeded:
211
+ 'Current {paymentCurrency} rate is {currentRate}, below your limit of {minAcceptableRate}. System will auto-retry when rate recovers.',
212
+ exchangeRateNotSupported:
213
+ '{paymentCurrency} rate is not available for your payment method. Please check your payment method configuration.',
214
+ exchangeRateFetchFailed:
215
+ 'Unable to retrieve {paymentCurrency} rate at this time. System will auto-retry shortly.',
207
216
  },
217
+ // For skipped scenarios (no invoice)
218
+ titleSkipped: 'Auto Top-Up Skipped',
219
+ titleSkippedSlippageExceeded: 'Auto Top-Up Skipped: Rate Below Your Limit',
220
+ titleSkippedExchangeRateNotSupported: 'Auto Top-Up Skipped: Currency Not Supported',
221
+ titleSkippedExchangeRateFetchFailed: 'Auto Top-Up Skipped: Rate Unavailable',
222
+ bodySkipped: 'Your {creditCurrencyName} auto top-up scheduled for {at} was skipped. Details:',
223
+ adjustSettings: 'Adjust Settings',
208
224
  },
209
225
 
210
226
  autoRechargeDailyLimitExceeded: {
@@ -332,5 +348,39 @@ export default flat({
332
348
  status: 'Status',
333
349
  lessThanOnePercent: 'less than 1%',
334
350
  },
351
+
352
+ subscriptionSlippageWarning: {
353
+ title: 'Rate Alert: {productName} Renewal',
354
+ body: 'Your {productName} subscription will renew in {timeUntilRenewal}. The current exchange rate ({currentRate}) is below your minimum acceptable rate ({minAcceptableRate}). If the rate does not recover before renewal, automatic payment may fail.',
355
+ currentRate: 'Current Rate',
356
+ minAcceptableRate: 'Minimum Acceptable Rate',
357
+ renewalTime: 'Renewal Time',
358
+ adjustSlippage: 'Adjust Slippage Settings',
359
+ },
360
+
361
+ subscriptionSlippageExceeded: {
362
+ title: 'Payment Paused: {productName}',
363
+ body: 'Your {productName} subscription renewal has been paused because the current exchange rate ({currentRate}) is below your minimum acceptable rate ({minAcceptableRate}). The system will not auto-retry. Please pay manually to restore your subscription.',
364
+ currentRate: 'Current Rate',
365
+ minAcceptableRate: 'Minimum Acceptable Rate',
366
+ payNow: 'Pay Now',
367
+ adjustSlippage: 'Adjust Slippage Settings',
368
+ },
369
+
370
+ exchangeRateAlert: {
371
+ spread_exceeded: {
372
+ title: 'Exchange Rate Alert: High Spread Detected ({symbol})',
373
+ body: 'The exchange rate spread for {symbol} has reached {spreadPercent}%, exceeding the threshold of {threshold}%. This may indicate rate source instability.',
374
+ },
375
+ providers_unavailable: {
376
+ title: 'Exchange Rate Alert: All Providers Unavailable ({symbol})',
377
+ body: 'All exchange rate providers for {symbol} are currently unavailable. Exchange rate queries will fail until providers recover.',
378
+ },
379
+ timestamp: 'Time',
380
+ symbol: 'Symbol',
381
+ spread: 'Spread',
382
+ providers: 'Provider Rates',
383
+ viewProviders: 'View Provider Settings',
384
+ },
335
385
  },
336
386
  });
@@ -157,6 +157,8 @@ export default flat({
157
157
  '预计扣款金额为 {price},但当前余额不足(余额为 {balance}),请确保您的账户余额充足,避免扣费失败。',
158
158
  renewAmount: '扣费金额',
159
159
  estimatedAmountNote: '预估 {amount},按最终用量计费',
160
+ estimatedAmountNoteRate: '预估 {amount},按扣费时汇率计算',
161
+ estimatedAmountNoteRateAndUsage: '预估 {amount},按最终用量与扣费时汇率计算',
160
162
  },
161
163
 
162
164
  subscriptionRenewed: {
@@ -195,7 +197,19 @@ export default flat({
195
197
  noEnoughToken: '您的账户代币余额为 {balance},不足 {price},请充值代币。',
196
198
  noSupported: '不支持使用代币扣费,请检查您的套餐。',
197
199
  txSendFailed: '扣费交易发送失败。',
200
+ // 跳过原因(动态定价)
201
+ slippageExceeded:
202
+ '当前 {paymentCurrency} 汇率为 {currentRate},低于您设置的下限 {minAcceptableRate}。汇率恢复后系统将自动重试。',
203
+ exchangeRateNotSupported: '您的支付方式暂不支持 {paymentCurrency} 汇率查询,请检查支付方式配置。',
204
+ exchangeRateFetchFailed: '暂时无法获取 {paymentCurrency} 汇率,系统将稍后自动重试。',
198
205
  },
206
+ // 跳过场景(无 invoice)
207
+ titleSkipped: '自动充值跳过',
208
+ titleSkippedSlippageExceeded: '自动充值跳过:汇率低于您设置的下限',
209
+ titleSkippedExchangeRateNotSupported: '自动充值跳过:货币暂不支持',
210
+ titleSkippedExchangeRateFetchFailed: '自动充值跳过:暂无法获取汇率',
211
+ bodySkipped: '您的 {creditCurrencyName} 自动充值(原定于 {at})已跳过。详情:',
212
+ adjustSettings: '调整设置',
199
213
  },
200
214
 
201
215
  autoRechargeDailyLimitExceeded: {
@@ -320,5 +334,39 @@ export default flat({
320
334
  status: '状态',
321
335
  lessThanOnePercent: '低于 1%',
322
336
  },
337
+
338
+ subscriptionSlippageWarning: {
339
+ title: '汇率提醒:{productName} 续费',
340
+ body: '您的 {productName} 订阅将在 {timeUntilRenewal} 后续费。当前汇率({currentRate})低于您设置的最低可接受汇率({minAcceptableRate})。如果续费前汇率未恢复,自动付款可能会失败。',
341
+ currentRate: '当前汇率',
342
+ minAcceptableRate: '最低可接受汇率',
343
+ renewalTime: '续费时间',
344
+ adjustSlippage: '调整滑点设置',
345
+ },
346
+
347
+ subscriptionSlippageExceeded: {
348
+ title: '支付已暂停:{productName}',
349
+ body: '您的 {productName} 订阅续费已暂停,因为当前汇率({currentRate})低于您设置的最低可接受汇率({minAcceptableRate})。系统不会自动重试,请手动付款以恢复订阅。',
350
+ currentRate: '当前汇率',
351
+ minAcceptableRate: '最低可接受汇率',
352
+ payNow: '立即付款',
353
+ adjustSlippage: '调整滑点设置',
354
+ },
355
+
356
+ exchangeRateAlert: {
357
+ spread_exceeded: {
358
+ title: '汇率警报:检测到高价差 ({symbol})',
359
+ body: '{symbol} 的汇率价差已达到 {spreadPercent}%,超过阈值 {threshold}%。这可能表明汇率数据源不稳定。',
360
+ },
361
+ providers_unavailable: {
362
+ title: '汇率警报:所有提供商不可用 ({symbol})',
363
+ body: '{symbol} 的所有汇率提供商当前不可用。在提供商恢复之前,汇率查询将失败。',
364
+ },
365
+ timestamp: '时间',
366
+ symbol: '货币对',
367
+ spread: '价差',
368
+ providers: '提供商汇率',
369
+ viewProviders: '查看提供商设置',
370
+ },
323
371
  },
324
372
  });