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
@@ -9,6 +9,7 @@ import {
9
9
  isCreditMetered,
10
10
  formatPrice,
11
11
  Collapse,
12
+ formatNumber,
12
13
  } from '@blocklet/payment-react';
13
14
  import type {
14
15
  InferFormType,
@@ -24,7 +25,9 @@ import { DeleteOutlineOutlined } from '@mui/icons-material';
24
25
  import {
25
26
  Alert,
26
27
  Box,
28
+ Button,
27
29
  Checkbox,
30
+ Collapse as MuiCollapse,
28
31
  FormControlLabel,
29
32
  IconButton,
30
33
  InputAdornment,
@@ -42,10 +45,10 @@ import {
42
45
  import { styled } from '@mui/system';
43
46
  import { Controller, useFieldArray, useFormContext, useWatch } from 'react-hook-form';
44
47
  import type { LiteralUnion } from 'type-fest';
45
- import { useMemo, useState } from 'react';
48
+ import { useMemo, useState, useEffect } from 'react';
46
49
 
47
50
  import { get } from 'lodash';
48
- import { useRequest } from 'ahooks';
51
+ import { useRequest, useSetState } from 'ahooks';
49
52
  import ProductSelect from '../payment-link/product-select';
50
53
  import { useProductsContext } from '../../contexts/products';
51
54
  import CurrencySelect from './currency-select';
@@ -57,6 +60,9 @@ export type Price = Omit<InferFormType<TPriceExpanded>, 'product_id' | 'object'>
57
60
  recurring: Omit<PriceRecurring, 'usage_type'> & {
58
61
  interval_config: string;
59
62
  };
63
+ pricing_type?: 'fixed' | 'dynamic';
64
+ base_currency?: string;
65
+ base_amount?: string;
60
66
  };
61
67
 
62
68
  export const DEFAULT_PRICE: Omit<Price, 'product' | 'currency' | 'quantity_sold'> = {
@@ -86,6 +92,10 @@ export const DEFAULT_PRICE: Omit<Price, 'product' | 'currency' | 'quantity_sold'
86
92
  tiers_mode: null,
87
93
  quantity_available: 0,
88
94
  quantity_limit_per_checkout: 0,
95
+ pricing_type: 'fixed',
96
+ base_currency: 'USD',
97
+ base_amount: '0',
98
+ dynamic_pricing_config: null,
89
99
  };
90
100
 
91
101
  type PriceFormProps = {
@@ -130,6 +140,10 @@ export default function PriceForm({ prefix = '', simple = false, productType = u
130
140
  const model = useWatch({ control, name: getFieldName('model') });
131
141
  const intervalSelectValue = useWatch({ control, name: getFieldName('recurring.interval') });
132
142
  const defaultCurrencyId = useWatch({ control, name: getFieldName('currency_id') });
143
+ const pricingType = useWatch({ control, name: getFieldName('pricing_type') });
144
+ const baseAmount = useWatch({ control, name: getFieldName('base_amount') });
145
+ const unitAmount = useWatch({ control, name: getFieldName('unit_amount') });
146
+ const currencyOptions = useWatch({ control, name: getFieldName('currency_options') });
133
147
  const creditCurrencies = useMemo(() => {
134
148
  return settings.paymentMethods.flatMap((method: TPaymentMethodExpanded) =>
135
149
  method.payment_currencies.filter((c: TPaymentCurrency) => c.type === 'credit')
@@ -142,6 +156,84 @@ export default function PriceForm({ prefix = '', simple = false, productType = u
142
156
  const meteringMeterId = useWatch({ control, name: getFieldName('recurring.meter_id') });
143
157
  const [meters, setMeters] = useState<TMeterExpanded[]>([]);
144
158
 
159
+ // Exchange rate validation state for each currency option
160
+ const [currencyRateValidation, setCurrencyRateValidation] = useSetState<
161
+ Record<
162
+ string,
163
+ {
164
+ validating: boolean;
165
+ valid: boolean;
166
+ error: string | null;
167
+ rate?: string;
168
+ }
169
+ >
170
+ >({});
171
+ const [suggestedApplied, setSuggestedApplied] = useSetState<Record<string, boolean>>({});
172
+ // Track which amount input is focused (by currency ID)
173
+ const [amountInputFocused, setAmountInputFocused] = useSetState<Record<string, boolean>>({});
174
+
175
+ const shouldShowRateInfo = pricingType === 'dynamic';
176
+
177
+ // Validate exchange rate for a specific currency
178
+ const validateCurrencyRate = async (currencyId: string) => {
179
+ if (!shouldShowRateInfo) {
180
+ return;
181
+ }
182
+
183
+ const currency = findCurrency(settings.paymentMethods, currencyId);
184
+ if (!currency) {
185
+ return;
186
+ }
187
+
188
+ if (currency.type === 'credit' || currency.paymentMethod?.type === 'stripe') {
189
+ setCurrencyRateValidation({
190
+ [currencyId]: {
191
+ validating: false,
192
+ valid: false,
193
+ error: null,
194
+ },
195
+ });
196
+ return;
197
+ }
198
+
199
+ setCurrencyRateValidation({
200
+ [currencyId]: {
201
+ validating: true,
202
+ valid: false,
203
+ error: null,
204
+ },
205
+ });
206
+
207
+ try {
208
+ const res: any = await api.post('/api/exchange-rates/validate', { currency: currency.id });
209
+ setCurrencyRateValidation({
210
+ [currencyId]: {
211
+ validating: false,
212
+ valid: res.data.supported,
213
+ error: null,
214
+ rate: res.data.rate,
215
+ },
216
+ });
217
+ } catch (err: any) {
218
+ setCurrencyRateValidation({
219
+ [currencyId]: {
220
+ validating: false,
221
+ valid: false,
222
+ error: t('admin.price.dynamicPricing.validation.error'),
223
+ },
224
+ });
225
+ }
226
+ };
227
+
228
+ // Note: Exchange rate validation is now triggered only when amount input is focused
229
+ // to avoid showing rate info automatically when editing a price
230
+
231
+ useEffect(() => {
232
+ if (model === 'package' && pricingType === 'dynamic') {
233
+ setValue(getFieldName('pricing_type'), 'fixed', { shouldValidate: true });
234
+ }
235
+ }, [model, pricingType, setValue]);
236
+
145
237
  const { loading: loadingMeters } = useRequest(
146
238
  () => {
147
239
  if (model === 'credit_metered') {
@@ -174,6 +266,32 @@ export default function PriceForm({ prefix = '', simple = false, productType = u
174
266
  return true;
175
267
  };
176
268
 
269
+ const buildSuggestedAmount = (rate?: string) => {
270
+ if (!baseAmount || !rate) return null;
271
+ const base = Number(baseAmount);
272
+ const rateValue = Number(rate);
273
+ if (!Number.isFinite(base) || !Number.isFinite(rateValue) || base <= 0 || rateValue <= 0) {
274
+ return null;
275
+ }
276
+ const rawAmount = base / rateValue;
277
+ if (rawAmount < 0.01) {
278
+ return rawAmount.toFixed(6).replace(/\.?0+$/, '');
279
+ }
280
+ return formatNumber(rawAmount, 2);
281
+ };
282
+
283
+ const applySuggestedAmount = (currencyId: string | undefined, amount: string) => {
284
+ if (isLocked || !currencyId) return;
285
+ if (currencyId === defaultCurrencyId) {
286
+ setValue(getFieldName('unit_amount'), amount, { shouldValidate: true });
287
+ }
288
+ const index = currencies.fields.findIndex((x: any) => x.currency_id === currencyId);
289
+ if (index > -1) {
290
+ setValue(getFieldName(`currency_options.${index}.unit_amount`), amount, { shouldValidate: true });
291
+ }
292
+ setSuggestedApplied({ [currencyId]: true });
293
+ };
294
+
177
295
  const handleRemoveCurrency = async (index: number) => {
178
296
  await currencies.remove(index);
179
297
  trigger(getFieldName('recurring.interval_config'));
@@ -197,6 +315,8 @@ export default function PriceForm({ prefix = '', simple = false, productType = u
197
315
  const selectedMeter = meters.find((m) => m.id === meteringMeterId);
198
316
  const isCreditBilling = model === 'credit_metered';
199
317
  const isCreditMode = currentProductType === 'credit';
318
+ const defaultCurrencySymbol = findCurrency(settings.paymentMethods, defaultCurrencyId)?.symbol || '';
319
+ const defaultSuggestedAmount = buildSuggestedAmount(currencyRateValidation[defaultCurrencyId]?.rate);
200
320
 
201
321
  return (
202
322
  <Root direction="column" alignItems="flex-start" spacing={2}>
@@ -368,21 +488,122 @@ export default function PriceForm({ prefix = '', simple = false, productType = u
368
488
  />
369
489
  </Box>
370
490
  )}
491
+ {/* Dynamic Pricing Toggle */}
492
+ {!isCreditBilling && model === 'standard' && (
493
+ <Box sx={{ width: INPUT_WIDTH }}>
494
+ <Controller
495
+ name={getFieldName('pricing_type')}
496
+ control={control}
497
+ disabled={isLocked}
498
+ render={({ field }) => (
499
+ <FormControlLabel
500
+ sx={{ alignItems: 'flex-start' }}
501
+ control={
502
+ <Checkbox
503
+ checked={field.value === 'dynamic'}
504
+ {...field}
505
+ onChange={(_, checked: boolean) => {
506
+ setValue(field.name, checked ? 'dynamic' : 'fixed');
507
+ }}
508
+ />
509
+ }
510
+ label={
511
+ <Stack>
512
+ <Typography
513
+ sx={{
514
+ color: 'text.primary',
515
+ }}>
516
+ {t('admin.price.dynamicPricing.label')}
517
+ </Typography>
518
+ <Typography
519
+ sx={{
520
+ color: 'text.secondary',
521
+ }}>
522
+ {t('admin.price.dynamicPricing.description')}
523
+ </Typography>
524
+ </Stack>
525
+ }
526
+ />
527
+ )}
528
+ />
529
+ </Box>
530
+ )}
531
+ {/* Dynamic Pricing Configuration - Base Amount */}
532
+ {pricingType === 'dynamic' && !isCreditBilling && (
533
+ <Box sx={{ width: INPUT_WIDTH }}>
534
+ <FormLabel required>{t('admin.price.dynamicPricing.config.baseAmount.label')}</FormLabel>
535
+ <Controller
536
+ name={getFieldName('base_amount')}
537
+ control={control}
538
+ disabled={isLocked}
539
+ rules={{
540
+ required: t('admin.price.dynamicPricing.config.baseAmount.required'),
541
+ validate: (v) => {
542
+ if (Number(v) <= 0) {
543
+ return t('admin.price.unit_amount.positive');
544
+ }
545
+ return true;
546
+ },
547
+ }}
548
+ render={({ field }) => (
549
+ <TextField
550
+ {...field}
551
+ type="number"
552
+ size="small"
553
+ fullWidth
554
+ error={!!getFieldError(getFieldName('base_amount'))}
555
+ helperText={getFieldError(getFieldName('base_amount'))?.message}
556
+ slotProps={{
557
+ input: {
558
+ endAdornment: (
559
+ <InputAdornment position="end">
560
+ <Controller
561
+ name={getFieldName('base_currency')}
562
+ control={control}
563
+ disabled={isLocked}
564
+ rules={{ required: true }}
565
+ render={({ field: currencyField }) => (
566
+ <Select
567
+ {...currencyField}
568
+ size="small"
569
+ sx={{ '.MuiOutlinedInput-notchedOutline': { border: 'none' } }}>
570
+ <MenuItem value="USD">USD</MenuItem>
571
+ </Select>
572
+ )}
573
+ />
574
+ </InputAdornment>
575
+ ),
576
+ },
577
+ htmlInput: {
578
+ min: 0,
579
+ step: 0.01,
580
+ },
581
+ }}
582
+ />
583
+ )}
584
+ />
585
+ <Typography
586
+ variant="caption"
587
+ sx={{
588
+ color: 'text.secondary',
589
+ display: 'block',
590
+ mt: 0.5,
591
+ }}>
592
+ {t('admin.price.dynamicPricing.config.baseAmount.description')}
593
+ </Typography>
594
+ </Box>
595
+ )}
371
596
  {!isCreditBilling && (
372
- <Stack
373
- direction={{ xs: 'column', sm: 'row' }}
374
- spacing={2}
375
- sx={{
376
- width: INPUT_WIDTH,
377
- '&': {
378
- flexWrap: { xs: 'nowrap', sm: 'wrap' },
379
- },
380
- }}>
381
- <Stack spacing={2} sx={{ flex: 1, minWidth: { xs: 'auto', sm: '300px' }, '>div': { width: '100%' } }}>
382
- <Box sx={{ width: '100%' }}>
383
- <FormLabel tooltip={t('admin.price.amountTip')} description={t('admin.price.amountDescription')}>
384
- {t('admin.price.amount')}
385
- </FormLabel>
597
+ <Stack spacing={2} sx={{ width: INPUT_WIDTH }}>
598
+ <FormLabel tooltip={t('admin.price.amountTip')} description={t('admin.price.amountDescription')}>
599
+ {t('admin.price.amount')}
600
+ </FormLabel>
601
+ <Stack
602
+ direction={{ xs: 'column', sm: 'row' }}
603
+ spacing={2}
604
+ alignItems="flex-start"
605
+ sx={{ width: INPUT_WIDTH, '&': { flexWrap: { xs: 'nowrap', sm: 'wrap' } } }}>
606
+ <Box sx={{ flex: 1, minWidth: { xs: 'auto', sm: '300px' } }}>
386
607
  <Controller
387
608
  name={getFieldName('unit_amount')}
388
609
  control={control}
@@ -425,6 +646,10 @@ export default function PriceForm({ prefix = '', simple = false, productType = u
425
646
  shouldValidate: true,
426
647
  });
427
648
  setValue(getFieldName('currency_id'), currencyId, { shouldValidate: true });
649
+
650
+ if (shouldShowRateInfo) {
651
+ validateCurrencyRate(currencyId);
652
+ }
428
653
  }}
429
654
  value={defaultCurrencyId}
430
655
  disabled={isLocked}
@@ -434,9 +659,25 @@ export default function PriceForm({ prefix = '', simple = false, productType = u
434
659
  ),
435
660
  },
436
661
  }}
662
+ onFocus={() => {
663
+ if (defaultCurrencyId) {
664
+ setAmountInputFocused({ [defaultCurrencyId]: true });
665
+ if (shouldShowRateInfo) {
666
+ validateCurrencyRate(defaultCurrencyId);
667
+ }
668
+ }
669
+ }}
670
+ onBlur={() => {
671
+ if (defaultCurrencyId) {
672
+ setAmountInputFocused({ [defaultCurrencyId]: false });
673
+ }
674
+ }}
437
675
  onChange={(e) => {
438
676
  const { value } = e.target;
439
677
  field.onChange(value);
678
+ if (defaultCurrencyId) {
679
+ setSuggestedApplied({ [defaultCurrencyId]: false });
680
+ }
440
681
  const index = currencies.fields.findIndex((x: any) => x.currency_id === defaultCurrencyId);
441
682
  if (index === -1) {
442
683
  return;
@@ -448,146 +689,275 @@ export default function PriceForm({ prefix = '', simple = false, productType = u
448
689
  />
449
690
  )}
450
691
  />
451
- </Box>
452
- {hasMoreCurrency(settings.paymentMethods) &&
453
- currencies.fields.filter((x: any) => x.currency_id !== defaultCurrencyId).length > 0 && (
454
- <Stack spacing={1.5} sx={{ width: INPUT_WIDTH }}>
455
- {currencies.fields.map((item: any, index: number) => {
456
- if (item.currency_id === defaultCurrencyId) {
457
- return null;
458
- }
459
- const fieldName = getFieldName(`currency_options.${index}.unit_amount`);
460
- const currency = findCurrency(settings.paymentMethods, item.currency_id);
461
- return (
462
- <Stack
463
- key={item.currency_id}
464
- direction="row"
465
- spacing={1}
466
- sx={{
467
- alignItems: 'start',
468
- }}>
469
- <Box sx={{ flex: 1 }}>
470
- <Controller
471
- name={fieldName}
472
- control={control}
473
- rules={{
474
- required: t('admin.price.unit_amount.required'),
475
- validate: (v) => {
476
- return validateAmount(v, currency ?? {});
477
- },
478
- }}
479
- disabled={isLocked}
480
- render={({ field }) => (
481
- <TextField
482
- {...field}
483
- type="number"
484
- size="small"
485
- fullWidth
486
- sx={{ minWidth: '300px' }}
487
- error={!!getFieldError(fieldName)}
488
- helperText={getFieldError(fieldName)?.message as string}
489
- slotProps={{
490
- input: {
491
- endAdornment: (
492
- <InputAdornment position="end">
493
- <CurrencySelect
494
- mode="selected"
495
- hasSelected={(c) =>
496
- currencies.fields.some((x: any) => x.currency_id === c.id) ||
497
- c.id === defaultCurrencyId
498
- }
499
- currencyFilter={(c) => c.type !== 'credit'}
500
- onSelect={(currencyId) => {
501
- const cIndex = currencies.fields.findIndex(
502
- (x: any) => x.currency_id === currency?.id
503
- );
504
- if (cIndex > -1) {
505
- // @ts-ignore
506
- handleCurrencyChange(cIndex, currencyId);
692
+ {shouldShowRateInfo && !isLocked && currencyRateValidation[defaultCurrencyId] && (
693
+ <MuiCollapse in={amountInputFocused[defaultCurrencyId]} timeout={200}>
694
+ {currencyRateValidation[defaultCurrencyId].validating && (
695
+ <Alert severity="info" sx={{ mt: 1 }}>
696
+ {t('admin.price.dynamicPricing.validation.checking')}
697
+ </Alert>
698
+ )}
699
+ {!currencyRateValidation[defaultCurrencyId].validating &&
700
+ currencyRateValidation[defaultCurrencyId].valid && (
701
+ <Alert severity="success" sx={{ mt: 1 }}>
702
+ <Stack spacing={0.5}>
703
+ <Typography variant="body2">
704
+ {t('admin.price.dynamicPricing.validation.rateLine', {
705
+ currency: defaultCurrencySymbol,
706
+ rate: formatNumber(currencyRateValidation[defaultCurrencyId].rate || '0', 2),
707
+ })}
708
+ </Typography>
709
+ {Number(unitAmount) > 0 && currencyRateValidation[defaultCurrencyId]?.rate && (
710
+ <Typography variant="body2" color="text.secondary">
711
+ {t('admin.price.dynamicPricing.validation.usdLine', {
712
+ amount: formatNumber(
713
+ Number(unitAmount) * Number(currencyRateValidation[defaultCurrencyId].rate),
714
+ 2
715
+ ),
716
+ })}
717
+ </Typography>
718
+ )}
719
+ {Number(unitAmount) <= 0 &&
720
+ defaultSuggestedAmount &&
721
+ !suggestedApplied[defaultCurrencyId] && (
722
+ <Stack direction="row" spacing={1} alignItems="center">
723
+ <Typography variant="body2" color="text.secondary">
724
+ {t('admin.price.dynamicPricing.validation.estimatedLine', {
725
+ amount: defaultSuggestedAmount,
726
+ currency: defaultCurrencySymbol,
727
+ })}
728
+ </Typography>
729
+ <Button
730
+ size="small"
731
+ variant="text"
732
+ sx={{ minWidth: 'auto', px: 0.5 }}
733
+ onMouseDown={(e) => e.preventDefault()}
734
+ onClick={() => applySuggestedAmount(defaultCurrencyId, defaultSuggestedAmount)}>
735
+ {t('admin.price.dynamicPricing.validation.useAmount')}
736
+ </Button>
737
+ </Stack>
738
+ )}
739
+ </Stack>
740
+ </Alert>
741
+ )}
742
+ {!currencyRateValidation[defaultCurrencyId].validating &&
743
+ currencyRateValidation[defaultCurrencyId].error && (
744
+ <Alert severity="error" sx={{ mt: 1 }}>
745
+ {currencyRateValidation[defaultCurrencyId].error}
746
+ </Alert>
747
+ )}
748
+ </MuiCollapse>
749
+ )}
750
+ {hasMoreCurrency(settings.paymentMethods) &&
751
+ currencies.fields.filter((x: any) => x.currency_id !== defaultCurrencyId).length > 0 && (
752
+ <Stack spacing={1.5} sx={{ width: INPUT_WIDTH, mt: 1.5 }}>
753
+ {currencies.fields.map((item: any, index: number) => {
754
+ if (item.currency_id === defaultCurrencyId) {
755
+ return null;
756
+ }
757
+ const fieldName = getFieldName(`currency_options.${index}.unit_amount`);
758
+ const currency = findCurrency(settings.paymentMethods, item.currency_id);
759
+ const suggestedAmount = buildSuggestedAmount(currencyRateValidation[item.currency_id]?.rate);
760
+ return (
761
+ <Stack
762
+ key={item.currency_id}
763
+ direction="row"
764
+ spacing={1}
765
+ sx={{
766
+ alignItems: 'start',
767
+ }}>
768
+ <Box sx={{ flex: 1 }}>
769
+ <Controller
770
+ name={fieldName}
771
+ control={control}
772
+ rules={{
773
+ required: t('admin.price.unit_amount.required'),
774
+ validate: (v) => {
775
+ return validateAmount(v, currency ?? {});
776
+ },
777
+ }}
778
+ disabled={isLocked}
779
+ render={({ field }) => (
780
+ <TextField
781
+ {...field}
782
+ type="number"
783
+ size="small"
784
+ fullWidth
785
+ sx={{ minWidth: '300px' }}
786
+ error={!!getFieldError(fieldName)}
787
+ helperText={getFieldError(fieldName)?.message as string}
788
+ onFocus={() => {
789
+ setAmountInputFocused({ [item.currency_id]: true });
790
+ if (shouldShowRateInfo) {
791
+ validateCurrencyRate(item.currency_id);
792
+ }
793
+ }}
794
+ onBlur={() => {
795
+ setAmountInputFocused({ [item.currency_id]: false });
796
+ }}
797
+ onChange={(e) => {
798
+ const { value } = e.target;
799
+ field.onChange(value);
800
+ setSuggestedApplied({ [item.currency_id]: false });
801
+ }}
802
+ slotProps={{
803
+ input: {
804
+ endAdornment: (
805
+ <InputAdornment position="end">
806
+ <CurrencySelect
807
+ mode="selected"
808
+ hasSelected={(c) =>
809
+ currencies.fields.some((x: any) => x.currency_id === c.id) ||
810
+ c.id === defaultCurrencyId
507
811
  }
508
- }}
509
- value={currency?.id!}
510
- disabled={isLocked}
511
- selectSX={{ '.MuiOutlinedInput-notchedOutline': { border: 'none' } }}
512
- />
513
- </InputAdornment>
514
- ),
515
- },
516
- }}
517
- />
812
+ currencyFilter={(c) => c.type !== 'credit'}
813
+ onSelect={(currencyId) => {
814
+ const cIndex = currencies.fields.findIndex(
815
+ (x: any) => x.currency_id === currency?.id
816
+ );
817
+ if (cIndex > -1) {
818
+ // @ts-ignore
819
+ handleCurrencyChange(cIndex, currencyId);
820
+ }
821
+
822
+ if (shouldShowRateInfo) {
823
+ validateCurrencyRate(currencyId);
824
+ }
825
+ }}
826
+ value={currency?.id!}
827
+ disabled={isLocked}
828
+ selectSX={{ '.MuiOutlinedInput-notchedOutline': { border: 'none' } }}
829
+ />
830
+ </InputAdornment>
831
+ ),
832
+ },
833
+ }}
834
+ />
835
+ )}
836
+ />
837
+ {shouldShowRateInfo && !isLocked && currencyRateValidation[item.currency_id] && (
838
+ <MuiCollapse in={amountInputFocused[item.currency_id]} timeout={200}>
839
+ {currencyRateValidation[item.currency_id]?.validating && (
840
+ <Alert severity="info" sx={{ mt: 0.5, fontSize: '0.75rem' }}>
841
+ {t('admin.price.dynamicPricing.validation.checking')}
842
+ </Alert>
843
+ )}
844
+ {!currencyRateValidation[item.currency_id]?.validating &&
845
+ currencyRateValidation[item.currency_id]?.valid && (
846
+ <Alert severity="success" sx={{ mt: 0.5, fontSize: '0.75rem' }}>
847
+ <Stack spacing={0.5}>
848
+ <Typography variant="body2">
849
+ {t('admin.price.dynamicPricing.validation.rateLine', {
850
+ currency: currency?.symbol || '',
851
+ rate: formatNumber(
852
+ currencyRateValidation[item.currency_id]?.rate || '0',
853
+ 2
854
+ ),
855
+ })}
856
+ </Typography>
857
+ {Number(currencyOptions?.[index]?.unit_amount) > 0 &&
858
+ currencyRateValidation[item.currency_id]?.rate && (
859
+ <Typography variant="body2" color="text.secondary">
860
+ {t('admin.price.dynamicPricing.validation.usdLine', {
861
+ amount: formatNumber(
862
+ Number(currencyOptions?.[index]?.unit_amount) *
863
+ Number(currencyRateValidation[item.currency_id]?.rate),
864
+ 2
865
+ ),
866
+ })}
867
+ </Typography>
868
+ )}
869
+ {Number(currencyOptions?.[index]?.unit_amount) <= 0 &&
870
+ suggestedAmount &&
871
+ !suggestedApplied[item.currency_id] && (
872
+ <Stack direction="row" spacing={1} alignItems="center">
873
+ <Typography variant="body2" color="text.secondary">
874
+ {t('admin.price.dynamicPricing.validation.estimatedLine', {
875
+ amount: suggestedAmount,
876
+ currency: currency?.symbol || '',
877
+ })}
878
+ </Typography>
879
+ <Button
880
+ size="small"
881
+ variant="text"
882
+ sx={{ minWidth: 'auto', px: 0.5 }}
883
+ onMouseDown={(e) => e.preventDefault()}
884
+ onClick={() => applySuggestedAmount(item.currency_id, suggestedAmount)}>
885
+ {t('admin.price.dynamicPricing.validation.useAmount')}
886
+ </Button>
887
+ </Stack>
888
+ )}
889
+ </Stack>
890
+ </Alert>
891
+ )}
892
+ {!currencyRateValidation[item.currency_id]?.validating &&
893
+ currencyRateValidation[item.currency_id]?.error && (
894
+ <Alert severity="error" sx={{ mt: 0.5, fontSize: '0.75rem' }}>
895
+ {currencyRateValidation[item.currency_id]?.error}
896
+ </Alert>
897
+ )}
898
+ </MuiCollapse>
518
899
  )}
519
- />
520
- </Box>
521
- {model === 'package' && <Box sx={{ flex: 1 }} />}
522
- {!isLocked && (
523
- <IconButton
524
- size="small"
525
- disabled={isLocked}
526
- onClick={() => handleRemoveCurrency(index)}
527
- sx={{ mt: 0.5, ml: -1 }}>
528
- <DeleteOutlineOutlined color="error" sx={{ opacity: 0.75 }} />
529
- </IconButton>
530
- )}
531
- </Stack>
532
- );
533
- })}
534
- </Stack>
900
+ </Box>
901
+ {!isLocked && (
902
+ <IconButton
903
+ size="small"
904
+ disabled={isLocked}
905
+ onClick={() => handleRemoveCurrency(index)}
906
+ sx={{ mt: 0.5, ml: -1 }}>
907
+ <DeleteOutlineOutlined color="error" sx={{ opacity: 0.75 }} />
908
+ </IconButton>
909
+ )}
910
+ </Stack>
911
+ );
912
+ })}
913
+ </Stack>
914
+ )}
915
+ {!isLocked && hasMoreCurrency(settings.paymentMethods) && !isCreditBilling && (
916
+ <Box sx={{ width: INPUT_WIDTH, mt: 1.5 }}>
917
+ <CurrencySelect
918
+ mode="waiting"
919
+ hasSelected={(currency) =>
920
+ currencies.fields.some((x: any) => x.currency_id === currency.id) ||
921
+ currency.id === defaultCurrencyId
922
+ }
923
+ currencyFilter={(c) => c.type !== 'credit'}
924
+ onSelect={(currencyId) => {
925
+ currencies.append({ currency_id: currencyId, unit_amount: 0 });
926
+
927
+ if (shouldShowRateInfo) {
928
+ validateCurrencyRate(currencyId);
929
+ }
930
+ }}
931
+ value=""
932
+ width="100%"
933
+ />
934
+ </Box>
535
935
  )}
536
- {/* 添加更多货币 */}
537
- {!isLocked && hasMoreCurrency(settings.paymentMethods) && !isCreditBilling && (
538
- <Box sx={{ width: INPUT_WIDTH }}>
539
- <CurrencySelect
540
- mode="waiting"
541
- hasSelected={(currency) =>
542
- currencies.fields.some((x: any) => x.currency_id === currency.id) ||
543
- currency.id === defaultCurrencyId
544
- }
545
- currencyFilter={(c) => c.type !== 'credit'}
546
- onSelect={(currencyId) => {
547
- currencies.append({ currency_id: currencyId, unit_amount: 0 });
548
- }}
549
- value=""
550
- width="100%"
936
+ </Box>
937
+ {model === 'package' && (
938
+ <Box sx={{ flex: { xs: 'none', sm: 1 }, width: { xs: '100%', sm: 'auto' } }}>
939
+ <Controller
940
+ name={getFieldName('transform_quantity.divide_by')}
941
+ control={control}
942
+ disabled={isLocked}
943
+ render={({ field }) => (
944
+ <TextField
945
+ {...field}
946
+ type="number"
947
+ size="small"
948
+ fullWidth
949
+ slotProps={{
950
+ input: {
951
+ startAdornment: <InputAdornment position="start">{t('common.per')}</InputAdornment>,
952
+ endAdornment: <InputAdornment position="end">{t('common.unit')}</InputAdornment>,
953
+ },
954
+ }}
955
+ />
956
+ )}
551
957
  />
552
958
  </Box>
553
959
  )}
554
960
  </Stack>
555
-
556
- {model === 'package' && (
557
- <Box
558
- sx={{
559
- flex: { xs: 'none', sm: 1 },
560
- width: { xs: '100%', sm: 'auto' },
561
- mt: { xs: 0, sm: 0 },
562
- }}>
563
- <FormLabel
564
- sx={{
565
- visibility: { xs: 'visible', sm: 'hidden' },
566
- display: 'block',
567
- }}>
568
- {t('admin.price.perUnit')}
569
- </FormLabel>
570
- <Controller
571
- name={getFieldName('transform_quantity.divide_by')}
572
- control={control}
573
- disabled={isLocked}
574
- render={({ field }) => (
575
- <TextField
576
- {...field}
577
- type="number"
578
- size="small"
579
- fullWidth
580
- slotProps={{
581
- input: {
582
- startAdornment: <InputAdornment position="start">{t('common.per')}</InputAdornment>,
583
- endAdornment: <InputAdornment position="end">{t('common.unit')}</InputAdornment>,
584
- },
585
- }}
586
- />
587
- )}
588
- />
589
- </Box>
590
- )}
591
961
  </Stack>
592
962
  )}
593
963
  {/* 周期性配置 */}