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
@@ -3,11 +3,11 @@ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
3
3
  import Toast from '@arcblock/ux/lib/Toast';
4
4
  import {
5
5
  LoadingButton,
6
+ PaymentSummary,
6
7
  PricingTable,
7
8
  api,
8
9
  formatBNStr,
9
10
  formatError,
10
- formatPrice,
11
11
  formatTime,
12
12
  usePaymentContext,
13
13
  } from '@blocklet/payment-react';
@@ -18,11 +18,118 @@ import { useRequest, useSetState } from 'ahooks';
18
18
  import { useRef } from 'react';
19
19
  import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
20
20
 
21
- import InfoCard from '../../../components/info-card';
21
+ import BigNumber from 'bignumber.js';
22
22
  import SectionHeader from '../../../components/section/header';
23
23
  import SubscriptionDescription from '../../../components/subscription/description';
24
24
  import { goBackOrFallback } from '../../../libs/util';
25
25
  import { useArcsphere } from '../../../hooks/browser';
26
+ import { useSubscriptionExchangeRate } from '../../../hooks/subscription';
27
+
28
+ /**
29
+ * Calculate total amount for items using live exchange rate
30
+ * For dynamic pricing items, calculate: base_amount / rate
31
+ * For fixed pricing items, use unit_amount directly
32
+ */
33
+ const calculateTotalWithLiveRate = (
34
+ items: TLineItemExpanded[],
35
+ liveRate: string | undefined,
36
+ currencyDecimal: number
37
+ ): string | null => {
38
+ if (!items?.length) return null;
39
+
40
+ let total = new BigNumber(0);
41
+ const hasAnyDynamicPricing = items.some((item) => {
42
+ const price = (item.upsell_price || item.price) as any;
43
+ return price?.pricing_type === 'dynamic';
44
+ });
45
+
46
+ // If there's dynamic pricing but no live rate, return null
47
+ if (hasAnyDynamicPricing && !liveRate) return null;
48
+
49
+ for (const item of items) {
50
+ const price = (item.upsell_price || item.price) as any;
51
+ const quantity = item.quantity || 1;
52
+
53
+ if (price?.pricing_type === 'dynamic' && price?.base_amount && liveRate) {
54
+ // Dynamic pricing: amount = base_amount / rate * 10^decimal
55
+ const baseAmount = new BigNumber(price.base_amount);
56
+ const rate = new BigNumber(liveRate);
57
+ if (rate.gt(0)) {
58
+ const amount = baseAmount.dividedBy(rate).times(new BigNumber(10).pow(currencyDecimal)).times(quantity);
59
+ // Round up to ensure we don't undercharge
60
+ total = total.plus(amount.integerValue(BigNumber.ROUND_CEIL));
61
+ }
62
+ } else if (price?.unit_amount) {
63
+ // Fixed pricing: use unit_amount directly
64
+ total = total.plus(new BigNumber(price.unit_amount).times(quantity));
65
+ }
66
+ }
67
+
68
+ return total.toString();
69
+ };
70
+
71
+ /**
72
+ * Proration type with additional info from backend
73
+ */
74
+ type ProrationItem = {
75
+ price_id: string;
76
+ amount: string;
77
+ quantity: number;
78
+ description: string;
79
+ pricing_type?: string;
80
+ base_amount_usd?: string | null;
81
+ proration_rate?: number;
82
+ };
83
+
84
+ /**
85
+ * Recalculate prorations using live exchange rate for dynamic pricing items
86
+ * For fixed pricing items, use the original amount from backend
87
+ *
88
+ * IMPORTANT: The recalculated amount is capped at the original payment amount.
89
+ * This prevents the case where token depreciation causes the refund to exceed
90
+ * what the user originally paid.
91
+ */
92
+ const recalculateProrations = (
93
+ prorations: ProrationItem[],
94
+ liveRate: string | undefined,
95
+ currencyDecimal: number
96
+ ): ProrationItem[] => {
97
+ if (!prorations?.length) return [];
98
+
99
+ return prorations.map((p) => {
100
+ // Only recalculate for dynamic pricing items with base_amount_usd
101
+ if (p.pricing_type === 'dynamic' && p.base_amount_usd && p.proration_rate && liveRate) {
102
+ const baseAmountUsd = new BigNumber(p.base_amount_usd);
103
+ const rate = new BigNumber(liveRate);
104
+ const prorationRate = new BigNumber(p.proration_rate);
105
+
106
+ if (rate.gt(0)) {
107
+ // Calculate: base_amount_usd / rate * proration_rate * quantity * 10^decimal
108
+ const calculatedAmount = baseAmountUsd
109
+ .dividedBy(rate)
110
+ .times(prorationRate)
111
+ .times(p.quantity || 1)
112
+ .times(new BigNumber(10).pow(currencyDecimal))
113
+ .integerValue(BigNumber.ROUND_CEIL);
114
+
115
+ // Get the original amount from backend (absolute value)
116
+ const originalAmount = new BigNumber(p.amount.replace('-', ''));
117
+
118
+ // Cap at original amount: don't refund more than what was paid
119
+ // This handles the case where token depreciation would cause
120
+ // the calculated refund to exceed the original payment
121
+ const finalAmount = BigNumber.min(calculatedAmount, originalAmount);
122
+
123
+ return {
124
+ ...p,
125
+ amount: `-${finalAmount.toString()}`, // Proration is negative (credit)
126
+ };
127
+ }
128
+ }
129
+ // For fixed pricing or if we can't recalculate, use original amount
130
+ return p;
131
+ });
132
+ };
26
133
 
27
134
  const fetchData = async (
28
135
  id: string
@@ -75,6 +182,25 @@ export default function CustomerSubscriptionChangePlan() {
75
182
  paid: false,
76
183
  });
77
184
 
185
+ let planItems: TLineItemExpanded[] = [];
186
+ if (state.items.length > 0) {
187
+ planItems = state.items as TLineItemExpanded[];
188
+ } else if (data?.subscription?.items) {
189
+ planItems = data.subscription.items as unknown as TLineItemExpanded[];
190
+ }
191
+ const planHasDynamicPricing =
192
+ planItems?.length &&
193
+ planItems.some((item) => {
194
+ const price = (item.upsell_price || item.price) as any;
195
+ return price?.pricing_type === 'dynamic';
196
+ });
197
+ const planNeedsExchangeRate = planHasDynamicPricing && data?.subscription?.paymentMethod?.type !== 'stripe';
198
+ const { liveRateInfo, liveRateUnavailable } = useSubscriptionExchangeRate({
199
+ subscriptionId: data?.subscription?.id,
200
+ currencyId: data?.subscription?.paymentCurrency?.id,
201
+ enabled: !!planNeedsExchangeRate,
202
+ });
203
+
78
204
  if (error) {
79
205
  return <Alert severity="error">{formatError(error)}</Alert>;
80
206
  }
@@ -87,6 +213,18 @@ export default function CustomerSubscriptionChangePlan() {
87
213
  return <Alert severity="error">{t('payment.customer.changePlan.subscriptionNotFound')}</Alert>;
88
214
  }
89
215
 
216
+ const planItemsAfterData = state.items.length
217
+ ? (state.items as TLineItemExpanded[])
218
+ : (data.subscription.items as unknown as TLineItemExpanded[]);
219
+ const planHasDynamicPricingAfterData =
220
+ planItemsAfterData?.length &&
221
+ planItemsAfterData.some((item) => {
222
+ const price = (item.upsell_price || item.price) as any;
223
+ return price?.pricing_type === 'dynamic';
224
+ });
225
+ const planNeedsExchangeRateAfterData =
226
+ planHasDynamicPricingAfterData && data.subscription.paymentMethod?.type !== 'stripe';
227
+
90
228
  const handleSelect = async (priceId: string) => {
91
229
  try {
92
230
  if (state.priceId === priceId) {
@@ -219,6 +357,53 @@ export default function CustomerSubscriptionChangePlan() {
219
357
  const { recurring } = data.subscription.items.find((x) => x.price.type === 'recurring')?.price || {};
220
358
  const interval = [recurring?.interval, recurring?.interval_count].join('-');
221
359
 
360
+ // Calculate display values using live exchange rate for dynamic pricing
361
+ // For fixed pricing, use backend's values directly
362
+ const needsLiveRateCalculation = planNeedsExchangeRateAfterData && liveRateInfo?.rate;
363
+
364
+ // Recalculate prorations with current exchange rate for dynamic pricing items
365
+ const displayProrations = needsLiveRateCalculation
366
+ ? recalculateProrations(
367
+ state.prorations as ProrationItem[],
368
+ liveRateInfo?.rate,
369
+ data.subscription.paymentCurrency.decimal
370
+ )
371
+ : state.prorations;
372
+
373
+ // Calculate total with current exchange rate
374
+ const calculatedTotal = needsLiveRateCalculation
375
+ ? calculateTotalWithLiveRate(planItemsAfterData, liveRateInfo?.rate, data.subscription.paymentCurrency.decimal)
376
+ : null;
377
+ const displayTotal = calculatedTotal || state.total;
378
+
379
+ // Recalculate due and newCredit: total + sum(prorations) - appliedCredit
380
+ const calculateDisplayValues = (): { due: string; newCredit: string } => {
381
+ if (!needsLiveRateCalculation) {
382
+ return { due: state.due, newCredit: state.newCredit };
383
+ }
384
+ const totalBN = new BigNumber(displayTotal || '0');
385
+ const prorationsSum = displayProrations.reduce(
386
+ (sum: BigNumber, p: any) => sum.plus(new BigNumber(p.amount || '0')),
387
+ new BigNumber(0)
388
+ );
389
+ const appliedCreditBN = new BigNumber(state.appliedCredit || '0');
390
+ const balanceBN = totalBN.plus(prorationsSum).minus(appliedCreditBN);
391
+
392
+ // If balance is negative, due=0 and the excess becomes newCredit
393
+ // If balance is positive, due=balance and newCredit=0
394
+ if (balanceBN.lt(0)) {
395
+ return {
396
+ due: '0',
397
+ newCredit: balanceBN.abs().toString(),
398
+ };
399
+ }
400
+ return {
401
+ due: balanceBN.toString(),
402
+ newCredit: '0',
403
+ };
404
+ };
405
+ const { due: displayDue, newCredit: displayNewCredit } = calculateDisplayValues();
406
+
222
407
  const getInfoRow = (label: string, value: string, prefix?: string) => {
223
408
  return (
224
409
  <Stack
@@ -232,7 +417,7 @@ export default function CustomerSubscriptionChangePlan() {
232
417
  </Typography>
233
418
  <Typography component="p" style={{ fontWeight: 'bold' }}>
234
419
  {prefix}
235
- {formatBNStr(value, data.subscription.paymentCurrency.decimal)} {data.subscription.paymentCurrency.symbol}
420
+ {formatBNStr(value, data.subscription.paymentCurrency.decimal, 2)} {data.subscription.paymentCurrency.symbol}
236
421
  </Typography>
237
422
  </Stack>
238
423
  );
@@ -278,47 +463,31 @@ export default function CustomerSubscriptionChangePlan() {
278
463
  {state.priceId && state.total && state.setup && (
279
464
  <Stack direction="column" spacing={3} sx={{ maxWidth: 640 }}>
280
465
  <SectionHeader title={t('payment.customer.changePlan.confirm')} />
281
- <Stack direction="column" spacing={2}>
282
- <Typography variant="h6" sx={{ fontWeight: 'normal' }}>
283
- {t('payment.customer.changePlan.summary', {
284
- // @ts-ignore
285
- date: formatTime(state.setup.period.end * 1000, 'lll', locale),
286
- })}
287
- </Typography>
288
- {state.items.map((x: TLineItemExpanded) => {
289
- const { product } = x.price;
290
- return (
291
- <Stack
292
- key={x.price_id}
293
- direction="row"
294
- sx={{
295
- alignItems: 'center',
296
- justifyContent: 'space-between',
297
- }}>
298
- <InfoCard logo={product.images[0]} name={product.name} description={product.description} />
299
- <Typography component="p" style={{ fontWeight: 'bold' }}>
300
- {formatPrice(
301
- x.price,
302
- data.subscription.paymentCurrency,
303
- x.price.product.unit_label,
304
- 1,
305
- true,
306
- locale
307
- )}
308
- </Typography>
309
- </Stack>
310
- );
466
+ <PaymentSummary
467
+ items={planItemsAfterData}
468
+ currency={data.subscription.paymentCurrency}
469
+ trialInDays={0}
470
+ billingThreshold={0}
471
+ showStaking={false}
472
+ liveRate={liveRateInfo}
473
+ rateUnavailable={!!(planNeedsExchangeRateAfterData && liveRateUnavailable)}
474
+ action={t('payment.customer.changePlan.summary', {
475
+ // @ts-ignore
476
+ date: formatTime(state.setup.period.end * 1000, 'lll', locale),
311
477
  })}
312
- </Stack>
478
+ completed
479
+ isStripePayment={data.subscription.paymentMethod?.type === 'stripe'}
480
+ />
313
481
  <Divider />
314
482
  <Stack direction="column" spacing={1}>
315
- {getInfoRow(t('payment.customer.changePlan.total'), state.total)}
316
- {state.prorations.map((x: any) => getInfoRow(x.description, x.amount))}
483
+ {getInfoRow(t('payment.customer.changePlan.total'), displayTotal)}
484
+ {displayProrations.map((x: any) => getInfoRow(x.description, x.amount))}
317
485
  {state.appliedCredit > '0' &&
318
486
  getInfoRow(t('payment.customer.changePlan.appliedCredit'), state.appliedCredit, '-')}
319
- {state.newCredit > '0' && getInfoRow(t('payment.customer.changePlan.newCredit'), state.newCredit)}
487
+ {new BigNumber(displayNewCredit).gt(0) &&
488
+ getInfoRow(t('payment.customer.changePlan.newCredit'), displayNewCredit)}
320
489
  <Divider />
321
- {getInfoRow(t('payment.customer.changePlan.remaining'), state.due)}
490
+ {getInfoRow(t('payment.customer.changePlan.remaining'), displayDue)}
322
491
  <Divider />
323
492
  <Stack
324
493
  direction="row"