payment-kit 1.19.0 → 1.19.2

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 (139) hide show
  1. package/api/src/crons/index.ts +8 -0
  2. package/api/src/index.ts +4 -0
  3. package/api/src/libs/credit-grant.ts +146 -0
  4. package/api/src/libs/env.ts +1 -0
  5. package/api/src/libs/invoice.ts +4 -3
  6. package/api/src/libs/notification/template/base.ts +388 -2
  7. package/api/src/libs/notification/template/customer-credit-grant-granted.ts +149 -0
  8. package/api/src/libs/notification/template/customer-credit-grant-low-balance.ts +151 -0
  9. package/api/src/libs/notification/template/customer-credit-insufficient.ts +254 -0
  10. package/api/src/libs/notification/template/subscription-canceled.ts +193 -202
  11. package/api/src/libs/notification/template/subscription-refund-succeeded.ts +215 -237
  12. package/api/src/libs/notification/template/subscription-renewed.ts +130 -200
  13. package/api/src/libs/notification/template/subscription-succeeded.ts +100 -202
  14. package/api/src/libs/notification/template/subscription-trial-start.ts +142 -188
  15. package/api/src/libs/notification/template/subscription-trial-will-end.ts +146 -174
  16. package/api/src/libs/notification/template/subscription-upgraded.ts +96 -192
  17. package/api/src/libs/notification/template/subscription-will-canceled.ts +94 -135
  18. package/api/src/libs/notification/template/subscription-will-renew.ts +220 -245
  19. package/api/src/libs/payment.ts +69 -0
  20. package/api/src/libs/queue/index.ts +3 -2
  21. package/api/src/libs/session.ts +8 -0
  22. package/api/src/libs/subscription.ts +74 -3
  23. package/api/src/libs/util.ts +3 -1
  24. package/api/src/libs/ws.ts +23 -1
  25. package/api/src/locales/en.ts +33 -0
  26. package/api/src/locales/zh.ts +31 -0
  27. package/api/src/queues/credit-consume.ts +728 -0
  28. package/api/src/queues/credit-grant.ts +572 -0
  29. package/api/src/queues/notification.ts +173 -128
  30. package/api/src/queues/payment.ts +210 -122
  31. package/api/src/queues/subscription.ts +179 -0
  32. package/api/src/routes/checkout-sessions.ts +157 -9
  33. package/api/src/routes/connect/shared.ts +3 -2
  34. package/api/src/routes/credit-grants.ts +241 -0
  35. package/api/src/routes/credit-transactions.ts +208 -0
  36. package/api/src/routes/customers.ts +34 -5
  37. package/api/src/routes/index.ts +8 -0
  38. package/api/src/routes/meter-events.ts +347 -0
  39. package/api/src/routes/meters.ts +219 -0
  40. package/api/src/routes/payment-currencies.ts +20 -2
  41. package/api/src/routes/payment-links.ts +1 -1
  42. package/api/src/routes/payment-methods.ts +14 -2
  43. package/api/src/routes/prices.ts +43 -0
  44. package/api/src/routes/pricing-table.ts +13 -7
  45. package/api/src/routes/products.ts +63 -4
  46. package/api/src/routes/settings.ts +1 -1
  47. package/api/src/routes/subscriptions.ts +4 -0
  48. package/api/src/routes/webhook-endpoints.ts +0 -3
  49. package/api/src/store/migrations/20250610-billing-credit.ts +43 -0
  50. package/api/src/store/models/credit-grant.ts +486 -0
  51. package/api/src/store/models/credit-transaction.ts +268 -0
  52. package/api/src/store/models/customer.ts +8 -0
  53. package/api/src/store/models/index.ts +52 -1
  54. package/api/src/store/models/meter-event.ts +423 -0
  55. package/api/src/store/models/meter.ts +176 -0
  56. package/api/src/store/models/payment-currency.ts +66 -14
  57. package/api/src/store/models/price.ts +6 -0
  58. package/api/src/store/models/product.ts +2 -2
  59. package/api/src/store/models/subscription.ts +24 -0
  60. package/api/src/store/models/types.ts +28 -2
  61. package/api/tests/libs/subscription.spec.ts +53 -0
  62. package/blocklet.yml +9 -1
  63. package/package.json +4 -4
  64. package/scripts/sdk.js +233 -1
  65. package/src/app.tsx +10 -0
  66. package/src/components/collapse.tsx +11 -1
  67. package/src/components/conditional-section.tsx +87 -0
  68. package/src/components/customer/credit-grant-item-list.tsx +99 -0
  69. package/src/components/customer/credit-overview.tsx +246 -0
  70. package/src/components/customer/form.tsx +7 -3
  71. package/src/components/invoice/list.tsx +19 -1
  72. package/src/components/metadata/form.tsx +287 -91
  73. package/src/components/meter/actions.tsx +101 -0
  74. package/src/components/meter/add-usage-dialog.tsx +239 -0
  75. package/src/components/meter/events-list.tsx +657 -0
  76. package/src/components/meter/form.tsx +245 -0
  77. package/src/components/meter/products.tsx +264 -0
  78. package/src/components/meter/usage-guide.tsx +174 -0
  79. package/src/components/payment-currency/form.tsx +2 -0
  80. package/src/components/payment-intent/list.tsx +19 -1
  81. package/src/components/payment-link/item.tsx +2 -2
  82. package/src/components/payment-link/preview.tsx +1 -1
  83. package/src/components/payment-link/product-select.tsx +52 -12
  84. package/src/components/payment-method/arcblock.tsx +2 -0
  85. package/src/components/payment-method/base.tsx +2 -0
  86. package/src/components/payment-method/bitcoin.tsx +2 -0
  87. package/src/components/payment-method/ethereum.tsx +2 -0
  88. package/src/components/payment-method/stripe.tsx +2 -0
  89. package/src/components/payouts/list.tsx +19 -1
  90. package/src/components/payouts/portal/list.tsx +6 -11
  91. package/src/components/price/currency-select.tsx +56 -32
  92. package/src/components/price/form.tsx +912 -407
  93. package/src/components/pricing-table/preview.tsx +1 -1
  94. package/src/components/product/add-price.tsx +9 -7
  95. package/src/components/product/create.tsx +7 -4
  96. package/src/components/product/edit-price.tsx +21 -12
  97. package/src/components/product/features.tsx +17 -7
  98. package/src/components/product/form.tsx +100 -90
  99. package/src/components/refund/list.tsx +19 -1
  100. package/src/components/section/header.tsx +5 -18
  101. package/src/components/subscription/items/index.tsx +1 -1
  102. package/src/components/subscription/metrics.tsx +37 -5
  103. package/src/components/subscription/portal/actions.tsx +2 -1
  104. package/src/contexts/products.tsx +26 -9
  105. package/src/hooks/subscription.ts +34 -0
  106. package/src/libs/meter-utils.ts +196 -0
  107. package/src/libs/util.ts +4 -0
  108. package/src/locales/en.tsx +389 -5
  109. package/src/locales/zh.tsx +368 -1
  110. package/src/pages/admin/billing/index.tsx +61 -33
  111. package/src/pages/admin/billing/invoices/detail.tsx +1 -1
  112. package/src/pages/admin/billing/meters/create.tsx +60 -0
  113. package/src/pages/admin/billing/meters/detail.tsx +435 -0
  114. package/src/pages/admin/billing/meters/index.tsx +210 -0
  115. package/src/pages/admin/billing/meters/meter-event.tsx +346 -0
  116. package/src/pages/admin/billing/subscriptions/detail.tsx +47 -14
  117. package/src/pages/admin/customers/customers/credit-grant/detail.tsx +391 -0
  118. package/src/pages/admin/customers/customers/detail.tsx +14 -10
  119. package/src/pages/admin/customers/index.tsx +5 -0
  120. package/src/pages/admin/developers/events/detail.tsx +1 -1
  121. package/src/pages/admin/developers/index.tsx +1 -1
  122. package/src/pages/admin/payments/intents/detail.tsx +1 -1
  123. package/src/pages/admin/payments/payouts/detail.tsx +1 -1
  124. package/src/pages/admin/payments/refunds/detail.tsx +1 -1
  125. package/src/pages/admin/products/index.tsx +3 -2
  126. package/src/pages/admin/products/links/detail.tsx +1 -1
  127. package/src/pages/admin/products/prices/actions.tsx +16 -4
  128. package/src/pages/admin/products/prices/detail.tsx +30 -3
  129. package/src/pages/admin/products/prices/list.tsx +8 -1
  130. package/src/pages/admin/products/pricing-tables/detail.tsx +1 -1
  131. package/src/pages/admin/products/products/create.tsx +233 -57
  132. package/src/pages/admin/products/products/detail.tsx +2 -1
  133. package/src/pages/admin/settings/payment-methods/index.tsx +3 -0
  134. package/src/pages/customer/credit-grant/detail.tsx +308 -0
  135. package/src/pages/customer/index.tsx +44 -9
  136. package/src/pages/customer/recharge/account.tsx +5 -5
  137. package/src/pages/customer/subscription/change-payment.tsx +4 -2
  138. package/src/pages/customer/subscription/detail.tsx +48 -14
  139. package/src/pages/customer/subscription/embed.tsx +1 -1
@@ -1,20 +1,31 @@
1
1
  /* eslint-disable no-nested-ternary */
2
2
  import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
3
- import { findCurrency, formatAmountPrecisionLimit, usePaymentContext } from '@blocklet/payment-react';
3
+ import {
4
+ findCurrency,
5
+ formatAmountPrecisionLimit,
6
+ usePaymentContext,
7
+ api,
8
+ FormLabel,
9
+ isCreditMetered,
10
+ formatPrice,
11
+ } from '@blocklet/payment-react';
4
12
  import type {
5
13
  InferFormType,
6
14
  PriceRecurring,
15
+ TMeter,
16
+ TMeterExpanded,
17
+ TPaymentCurrency,
7
18
  TPaymentCurrencyExpanded,
8
19
  TPaymentMethodExpanded,
20
+ TPrice,
9
21
  TPriceExpanded,
10
22
  } from '@blocklet/payment-types';
11
- import { DeleteOutlineOutlined, InfoOutlined } from '@mui/icons-material';
23
+ import { DeleteOutlineOutlined } from '@mui/icons-material';
12
24
  import {
13
25
  Alert,
14
26
  Box,
15
27
  Checkbox,
16
28
  FormControlLabel,
17
- FormLabel,
18
29
  IconButton,
19
30
  InputAdornment,
20
31
  MenuItem,
@@ -23,19 +34,27 @@ import {
23
34
  TextField,
24
35
  ToggleButton,
25
36
  ToggleButtonGroup,
26
- Tooltip,
27
37
  Typography,
38
+ Autocomplete,
39
+ Divider,
28
40
  } from '@mui/material';
41
+
29
42
  import { styled } from '@mui/system';
30
43
  import { Controller, useFieldArray, useFormContext, useWatch } from 'react-hook-form';
31
44
  import type { LiteralUnion } from 'type-fest';
45
+ import { useMemo, useState } from 'react';
32
46
 
33
47
  import { get } from 'lodash';
48
+ import { useRequest } from 'ahooks';
49
+ import ProductSelect from '../payment-link/product-select';
34
50
  import Collapse from '../collapse';
51
+ import { useProductsContext } from '../../contexts/products';
35
52
  import CurrencySelect from './currency-select';
53
+ import { getProductByPriceId } from '../../libs/util';
54
+ import InfoCard from '../info-card';
36
55
 
37
56
  export type Price = Omit<InferFormType<TPriceExpanded>, 'product_id' | 'object'> & {
38
- model: LiteralUnion<'standard' | 'package' | 'graduated' | 'volume' | 'custom', string>;
57
+ model: LiteralUnion<'standard' | 'package' | 'graduated' | 'volume' | 'custom' | 'credit_metered', string>;
39
58
  recurring: Omit<PriceRecurring, 'usage_type'> & {
40
59
  interval_config: string;
41
60
  };
@@ -62,7 +81,7 @@ export const DEFAULT_PRICE: Omit<Price, 'product' | 'currency' | 'quantity_sold'
62
81
  round: 'up',
63
82
  },
64
83
  tiers: [],
65
- metadata: [],
84
+ metadata: {} as any,
66
85
  custom_unit_amount: null,
67
86
  currency_options: [],
68
87
  tiers_mode: null,
@@ -73,9 +92,11 @@ export const DEFAULT_PRICE: Omit<Price, 'product' | 'currency' | 'quantity_sold'
73
92
  type PriceFormProps = {
74
93
  prefix?: string;
75
94
  simple?: boolean;
95
+ meterId?: string;
96
+ productType?: string;
76
97
  };
77
98
 
78
- const INPUT_WIDTH = 260;
99
+ const INPUT_WIDTH = '100%';
79
100
 
80
101
  const hasMoreCurrency = (methods: TPaymentMethodExpanded[] = []) => {
81
102
  return methods.every((method) => method.payment_currencies.length > 1) || methods.length > 1;
@@ -91,7 +112,11 @@ function stripeCurrencyValidate(v: number, currency: TPaymentCurrencyExpanded |
91
112
  return true;
92
113
  }
93
114
 
94
- export default function PriceForm({ prefix = '', simple = false }: PriceFormProps) {
115
+ const fetchMeters = (): Promise<{ list: TMeterExpanded[]; count: number }> => {
116
+ return api.get('/api/meters?status=active&limit=100').then((res: any) => res.data);
117
+ };
118
+
119
+ export default function PriceForm({ prefix = '', simple = false, productType = undefined }: PriceFormProps) {
95
120
  const getFieldName = (name: string) => (prefix ? `${prefix}.${name}` : name);
96
121
 
97
122
  const { t, locale } = useLocaleContext();
@@ -107,6 +132,8 @@ export default function PriceForm({ prefix = '', simple = false }: PriceFormProp
107
132
  };
108
133
  const { settings, livemode } = usePaymentContext();
109
134
  const currencies = useFieldArray({ control, name: getFieldName('currency_options') });
135
+ const formProductType = useWatch({ control, name: 'type' });
136
+ const currentProductType = productType || formProductType;
110
137
  const priceLocked = useWatch({ control, name: getFieldName('locked') });
111
138
  const isRecurring = useWatch({ control, name: getFieldName('type') }) === 'recurring';
112
139
  const isMetered = useWatch({ control, name: getFieldName('recurring.usage_type') }) === 'metered';
@@ -114,10 +141,37 @@ export default function PriceForm({ prefix = '', simple = false }: PriceFormProp
114
141
  const model = useWatch({ control, name: getFieldName('model') });
115
142
  const intervalSelectValue = useWatch({ control, name: getFieldName('recurring.interval') });
116
143
  const defaultCurrencyId = useWatch({ control, name: getFieldName('currency_id') });
117
- const defaultCurrency = findCurrency(settings.paymentMethods, defaultCurrencyId);
144
+ const creditCurrencies = useMemo(() => {
145
+ return settings.paymentMethods.flatMap((method: TPaymentMethodExpanded) =>
146
+ method.payment_currencies.filter((c: TPaymentCurrency) => c.type === 'credit')
147
+ );
148
+ }, [settings.paymentMethods]);
149
+ const { products } = useProductsContext();
118
150
  const quantityPositive = (v: number | undefined) => !v || v.toString().match(/^(0|[1-9]\d*)$/);
119
151
  const intervalCountPositive = (v: number) => Number.isInteger(Number(v)) && v > 0;
120
152
 
153
+ const meteringMeterId = useWatch({ control, name: getFieldName('recurring.meter_id') });
154
+ const [meters, setMeters] = useState<TMeterExpanded[]>([]);
155
+
156
+ const { loading: loadingMeters } = useRequest(
157
+ () => {
158
+ if (model === 'credit_metered') {
159
+ return fetchMeters();
160
+ }
161
+ return Promise.resolve({ list: [] as any, count: 0 });
162
+ },
163
+ {
164
+ refreshDeps: [model],
165
+ onSuccess: (result) => {
166
+ setMeters(result.list);
167
+ },
168
+ }
169
+ );
170
+
171
+ const getMeterDisplayName = (meter: TMeter) => {
172
+ return `${meter.name} (${meter.event_name} • ${meter.unit})`;
173
+ };
174
+
121
175
  const isLocked = priceLocked && window.blocklet?.PAYMENT_CHANGE_LOCKED_PRICE !== '1';
122
176
 
123
177
  const validateAmount = (v: number, currency: { maximum_precision?: number }) => {
@@ -150,9 +204,90 @@ export default function PriceForm({ prefix = '', simple = false }: PriceFormProp
150
204
  ...update,
151
205
  });
152
206
  };
207
+
208
+ const selectedMeter = meters.find((m) => m.id === meteringMeterId);
209
+ const isCreditBilling = model === 'credit_metered';
210
+ const isCreditMode = currentProductType === 'credit';
211
+
153
212
  return (
154
213
  <Root direction="column" alignItems="flex-start" spacing={2}>
155
214
  {isLocked && <Alert severity="info">{t('admin.price.locked')}</Alert>}
215
+ <Controller
216
+ name={getFieldName('type')}
217
+ control={control}
218
+ disabled={isLocked}
219
+ render={({ field }) => (
220
+ <Box sx={{ width: INPUT_WIDTH }}>
221
+ <ToggleButtonGroup
222
+ {...field}
223
+ onChange={(_, value: string) => {
224
+ if (value !== null) {
225
+ setValue(field.name, value);
226
+ }
227
+ if (value === 'one_time') {
228
+ setValue(getFieldName('model'), 'standard');
229
+ setValue(getFieldName('currency_id'), settings.baseCurrency?.id);
230
+ setValue(getFieldName('recurring.meter_id'), '');
231
+ setValue(getFieldName('recurring.usage_type'), 'licensed');
232
+ }
233
+ }}
234
+ exclusive
235
+ fullWidth
236
+ sx={{
237
+ height: 'auto',
238
+ '& .MuiToggleButton-root': {
239
+ flexDirection: 'column',
240
+ alignItems: 'flex-start',
241
+ justifyContent: 'flex-start',
242
+ textAlign: 'left',
243
+ height: 'auto',
244
+ py: 1,
245
+ },
246
+ }}>
247
+ <ToggleButton value="recurring">
248
+ <Box>
249
+ <Typography
250
+ variant="body2"
251
+ sx={{
252
+ fontWeight: 'medium',
253
+ color: 'text.primary',
254
+ }}>
255
+ {t('admin.price.types.recurring')}
256
+ </Typography>
257
+ <Typography
258
+ variant="caption"
259
+ sx={{
260
+ color: 'text.secondary',
261
+ mt: 0.5,
262
+ }}>
263
+ {t('admin.price.types.recurringDesc')}
264
+ </Typography>
265
+ </Box>
266
+ </ToggleButton>
267
+ <ToggleButton value="one_time">
268
+ <Box>
269
+ <Typography
270
+ variant="body2"
271
+ sx={{
272
+ fontWeight: 'medium',
273
+ color: 'text.primary',
274
+ }}>
275
+ {t('admin.price.types.onetime')}
276
+ </Typography>
277
+ <Typography
278
+ variant="caption"
279
+ sx={{
280
+ color: 'text.secondary',
281
+ mt: 0.5,
282
+ }}>
283
+ {t('admin.price.types.onetimeDesc')}
284
+ </Typography>
285
+ </Box>
286
+ </ToggleButton>
287
+ </ToggleButtonGroup>
288
+ </Box>
289
+ )}
290
+ />
156
291
  <Controller
157
292
  name={getFieldName('model')}
158
293
  control={control}
@@ -160,267 +295,333 @@ export default function PriceForm({ prefix = '', simple = false }: PriceFormProp
160
295
  disabled={isLocked}
161
296
  render={({ field }) => (
162
297
  <Box sx={{ width: INPUT_WIDTH }}>
163
- <FormLabel sx={{ color: 'text.primary' }}>{t('admin.price.model')}</FormLabel>
164
- <Select {...field} fullWidth size="small">
298
+ <FormLabel>{t('admin.price.model')}</FormLabel>
299
+ <Select
300
+ {...field}
301
+ fullWidth
302
+ size="small"
303
+ onChange={(e) => {
304
+ if (e.target.value === 'standard') {
305
+ setValue(getFieldName('currency_id'), settings.baseCurrency?.id);
306
+ }
307
+ field.onChange(e.target.value);
308
+ if (e.target.value === 'credit_metered') {
309
+ setValue(getFieldName('type'), 'recurring');
310
+ setValue(getFieldName('recurring.usage_type'), 'metered');
311
+ } else {
312
+ setValue(getFieldName('recurring.usage_type'), 'licensed');
313
+ setValue(getFieldName('recurring.meter_id'), '');
314
+ }
315
+ }}>
165
316
  <MenuItem value="standard">{t('admin.price.models.standard')}</MenuItem>
166
317
  <MenuItem value="package">{t('admin.price.models.package')}</MenuItem>
318
+ <MenuItem value="credit_metered" disabled={isCreditMode}>
319
+ {t('admin.price.models.creditMetered')}
320
+ </MenuItem>
167
321
  <MenuItem value="graduated" disabled>
168
322
  {t('admin.price.models.graduated')}
169
323
  </MenuItem>
170
324
  <MenuItem value="volume" disabled>
171
325
  {t('admin.price.models.volume')}
172
326
  </MenuItem>
173
- <MenuItem value="custom" disabled>
174
- {t('admin.price.models.custom')}
175
- </MenuItem>
176
327
  </Select>
177
328
  </Box>
178
329
  )}
179
330
  />
180
- <Stack direction="row">
181
- <Controller
182
- name={getFieldName('unit_amount')}
183
- control={control}
184
- rules={{
185
- required: t('admin.price.unit_amount.required'),
186
- validate: (v) => {
187
- const hasStripError = !stripeCurrencyValidate(v, defaultCurrency);
188
- if (hasStripError) {
189
- return t('admin.price.unit_amount.stripeTip');
331
+ {/* 3. Credit 计量计费专用区域 */}
332
+ {isCreditBilling && (
333
+ <Box sx={{ width: INPUT_WIDTH }}>
334
+ <Alert severity="info" sx={{ mb: 2 }}>
335
+ {t('admin.price.creditMetered.description')}
336
+ </Alert>
337
+ <FormLabel sx={{ color: 'text.primary' }}>
338
+ {t('admin.price.creditMetered.selectMeter')}{' '}
339
+ <Typography component="span" sx={{ color: 'error.main' }}>
340
+ *
341
+ </Typography>
342
+ </FormLabel>
343
+ <Autocomplete
344
+ options={meters}
345
+ getOptionLabel={getMeterDisplayName}
346
+ loading={loadingMeters}
347
+ value={selectedMeter || null}
348
+ disabled={isLocked}
349
+ onChange={(_, meter: TMeterExpanded | null) => {
350
+ setValue(getFieldName('recurring.meter_id'), meter?.id || '');
351
+ if (meter?.paymentCurrency) {
352
+ setValue(getFieldName('currency_id'), meter.paymentCurrency.id);
353
+ setValue(getFieldName('unit_amount'), 1);
190
354
  }
191
- return validateAmount(v, defaultCurrency ?? {});
192
- },
193
- }}
194
- disabled={isLocked}
195
- render={({ field }) => (
196
- <Box>
197
- <FormLabel sx={{ color: 'text.primary' }}>
198
- <Stack
199
- direction="row"
200
- spacing={0.5}
201
- sx={{
202
- alignItems: 'center',
203
- }}>
204
- <Typography
205
- component="span"
206
- sx={{
207
- color: 'text.primary',
208
- }}>
209
- {t('admin.price.amount')}
210
- </Typography>
211
- <Tooltip title={t('admin.price.amountTip')} placement="top" arrow>
212
- <InfoOutlined fontSize="small" sx={{ color: 'text.secondary' }} />
213
- </Tooltip>
214
- </Stack>
215
- </FormLabel>
355
+ }}
356
+ fullWidth
357
+ renderInput={(params) => (
216
358
  <TextField
217
- {...field}
218
- type="number"
359
+ {...params}
360
+ required
361
+ error={!meteringMeterId}
362
+ helperText={
363
+ !meteringMeterId ? t('admin.price.creditMetered.required') : t('admin.price.creditMetered.help')
364
+ }
219
365
  size="small"
220
- sx={{ width: INPUT_WIDTH }}
221
- error={!!getFieldError(getFieldName('unit_amount'))}
222
- helperText={getFieldError(getFieldName('unit_amount'))?.message}
223
- onChange={(e) => {
224
- const { value } = e.target;
225
- field.onChange(value);
226
- const index = currencies.fields.findIndex((x: any) => x.currency_id === defaultCurrencyId);
227
- if (index === -1) {
228
- return;
229
- }
230
- setValue(getFieldName(`currency_options.${index}.unit_amount`), value, { shouldValidate: true });
366
+ InputProps={{
367
+ ...params.InputProps,
368
+ endAdornment: (
369
+ <>
370
+ {loadingMeters ? <div>{t('common.loading')}</div> : null}
371
+ {params.InputProps.endAdornment}
372
+ </>
373
+ ),
231
374
  }}
232
- slotProps={{
233
- input: {
234
- endAdornment: (
235
- <InputAdornment position="end">
236
- <CurrencySelect
237
- mode="selected"
238
- hasSelected={(currency) =>
239
- currencies.fields.some((x: any) => x.currency_id === currency.id) ||
240
- currency.id === defaultCurrencyId
241
- }
242
- onSelect={(currencyId) => {
243
- const index = currencies.fields.findIndex((x: any) => x.currency_id === defaultCurrencyId);
244
- if (index > -1) {
245
- // @ts-ignore
246
- handleCurrencyChange(index, currencyId);
247
- }
248
- setValue(getFieldName('currency'), findCurrency(settings.paymentMethods, currencyId), {
249
- shouldValidate: true,
250
- });
251
- setValue(getFieldName('currency_id'), currencyId, { shouldValidate: true });
252
- }}
253
- value={defaultCurrencyId}
254
- disabled={isLocked}
255
- selectSX={{ '.MuiOutlinedInput-notchedOutline': { border: 'none' } }}
256
- />
257
- </InputAdornment>
258
- ),
375
+ />
376
+ )}
377
+ />
378
+ </Box>
379
+ )}
380
+ {!isCreditBilling && (
381
+ <Stack
382
+ direction={{ xs: 'column', sm: 'row' }}
383
+ spacing={2}
384
+ sx={{
385
+ width: INPUT_WIDTH,
386
+ '&': {
387
+ flexWrap: { xs: 'nowrap', sm: 'wrap' },
388
+ },
389
+ }}>
390
+ <Stack spacing={2} sx={{ flex: 1, minWidth: { xs: 'auto', sm: '300px' }, '>div': { width: '100%' } }}>
391
+ <Box sx={{ width: '100%' }}>
392
+ <FormLabel tooltip={t('admin.price.amountTip')}>{t('admin.price.amount')}</FormLabel>
393
+ <Controller
394
+ name={getFieldName('unit_amount')}
395
+ control={control}
396
+ rules={{
397
+ required: t('admin.price.unit_amount.required'),
398
+ validate: (v) => {
399
+ const currency = findCurrency(settings.paymentMethods, defaultCurrencyId);
400
+ const hasStripError = !stripeCurrencyValidate(v, currency);
401
+ if (hasStripError) {
402
+ return t('admin.price.unit_amount.stripeTip');
403
+ }
404
+ return validateAmount(v, currency ?? {});
259
405
  },
260
406
  }}
407
+ disabled={isLocked}
408
+ render={({ field }) => (
409
+ <TextField
410
+ {...field}
411
+ type="number"
412
+ size="small"
413
+ fullWidth
414
+ error={!!getFieldError(getFieldName('unit_amount'))}
415
+ helperText={getFieldError(getFieldName('unit_amount'))?.message}
416
+ InputProps={{
417
+ endAdornment: (
418
+ <InputAdornment position="end">
419
+ <CurrencySelect
420
+ mode="selected"
421
+ hasSelected={(currency) =>
422
+ currencies.fields.some((x: any) => x.currency_id === currency.id) ||
423
+ currency.id === defaultCurrencyId
424
+ }
425
+ currencyFilter={(c) => c.type !== 'credit'}
426
+ onSelect={(currencyId) => {
427
+ const index = currencies.fields.findIndex(
428
+ (x: any) => x.currency_id === defaultCurrencyId
429
+ );
430
+ if (index > -1) {
431
+ // @ts-ignore
432
+ handleCurrencyChange(index, currencyId);
433
+ }
434
+ setValue(getFieldName('currency'), findCurrency(settings.paymentMethods, currencyId), {
435
+ shouldValidate: true,
436
+ });
437
+ setValue(getFieldName('currency_id'), currencyId, { shouldValidate: true });
438
+ }}
439
+ value={defaultCurrencyId}
440
+ disabled={isLocked}
441
+ selectSX={{ '.MuiOutlinedInput-notchedOutline': { border: 'none' } }}
442
+ />
443
+ </InputAdornment>
444
+ ),
445
+ }}
446
+ onChange={(e) => {
447
+ const { value } = e.target;
448
+ field.onChange(value);
449
+ const index = currencies.fields.findIndex((x: any) => x.currency_id === defaultCurrencyId);
450
+ if (index === -1) {
451
+ return;
452
+ }
453
+ setValue(getFieldName(`currency_options.${index}.unit_amount`), value, {
454
+ shouldValidate: true,
455
+ });
456
+ }}
457
+ />
458
+ )}
261
459
  />
262
460
  </Box>
263
- )}
264
- />
265
- {model === 'package' && (
266
- <Controller
267
- name={getFieldName('transform_quantity.divide_by')}
268
- control={control}
269
- disabled={isLocked}
270
- render={({ field }) => (
271
- <Box
272
- sx={{
273
- ml: 2,
274
- }}>
275
- <FormLabel>&nbsp;</FormLabel>
276
- <TextField
277
- {...field}
278
- type="number"
279
- size="small"
280
- sx={{ width: INPUT_WIDTH }}
281
- slotProps={{
282
- input: {
283
- startAdornment: <InputAdornment position="start">{t('common.per')}</InputAdornment>,
284
- endAdornment: <InputAdornment position="end">{t('common.unit')}</InputAdornment>,
285
- },
461
+ {hasMoreCurrency(settings.paymentMethods) &&
462
+ currencies.fields.filter((x: any) => x.currency_id !== defaultCurrencyId).length > 0 && (
463
+ <Stack spacing={1.5} sx={{ width: INPUT_WIDTH }}>
464
+ {currencies.fields.map((item: any, index: number) => {
465
+ if (item.currency_id === defaultCurrencyId) {
466
+ return null;
467
+ }
468
+ const fieldName = getFieldName(`currency_options.${index}.unit_amount`);
469
+ const currency = findCurrency(settings.paymentMethods, item.currency_id);
470
+ return (
471
+ <Stack
472
+ key={item.currency_id}
473
+ direction="row"
474
+ spacing={1}
475
+ sx={{
476
+ alignItems: 'start',
477
+ }}>
478
+ <Box sx={{ flex: 1 }}>
479
+ <Controller
480
+ name={fieldName}
481
+ control={control}
482
+ rules={{
483
+ required: t('admin.price.unit_amount.required'),
484
+ validate: (v) => {
485
+ const hasStripError = !stripeCurrencyValidate(v, currency);
486
+ if (hasStripError) {
487
+ return t('admin.price.unit_amount.stripeTip');
488
+ }
489
+ return validateAmount(v, currency ?? {});
490
+ },
491
+ }}
492
+ disabled={isLocked}
493
+ render={({ field }) => (
494
+ <TextField
495
+ {...field}
496
+ type="number"
497
+ size="small"
498
+ fullWidth
499
+ sx={{ minWidth: '300px' }}
500
+ error={!!getFieldError(fieldName)}
501
+ helperText={getFieldError(fieldName)?.message as string}
502
+ InputProps={{
503
+ endAdornment: (
504
+ <InputAdornment position="end">
505
+ <CurrencySelect
506
+ mode="selected"
507
+ hasSelected={(c) =>
508
+ currencies.fields.some((x: any) => x.currency_id === c.id) ||
509
+ c.id === defaultCurrencyId
510
+ }
511
+ currencyFilter={(c) => c.type !== 'credit'}
512
+ onSelect={(currencyId) => {
513
+ const cIndex = currencies.fields.findIndex(
514
+ (x: any) => x.currency_id === currency?.id
515
+ );
516
+ if (cIndex > -1) {
517
+ // @ts-ignore
518
+ handleCurrencyChange(cIndex, currencyId);
519
+ }
520
+ }}
521
+ value={currency?.id!}
522
+ disabled={isLocked}
523
+ selectSX={{ '.MuiOutlinedInput-notchedOutline': { border: 'none' } }}
524
+ />
525
+ </InputAdornment>
526
+ ),
527
+ }}
528
+ />
529
+ )}
530
+ />
531
+ </Box>
532
+ {model === 'package' && <Box sx={{ flex: 1 }} />}
533
+ {!isLocked && (
534
+ <IconButton
535
+ size="small"
536
+ disabled={isLocked}
537
+ onClick={() => handleRemoveCurrency(index)}
538
+ sx={{ mt: 0.5, ml: -1 }}>
539
+ <DeleteOutlineOutlined color="error" sx={{ opacity: 0.75 }} />
540
+ </IconButton>
541
+ )}
542
+ </Stack>
543
+ );
544
+ })}
545
+ </Stack>
546
+ )}
547
+ {/* 添加更多货币 */}
548
+ {!isLocked && hasMoreCurrency(settings.paymentMethods) && !isCreditBilling && (
549
+ <Box sx={{ width: INPUT_WIDTH }}>
550
+ <CurrencySelect
551
+ mode="waiting"
552
+ hasSelected={(currency) =>
553
+ currencies.fields.some((x: any) => x.currency_id === currency.id) ||
554
+ currency.id === defaultCurrencyId
555
+ }
556
+ currencyFilter={(c) => c.type !== 'credit'}
557
+ onSelect={(currencyId) => {
558
+ currencies.append({ currency_id: currencyId, unit_amount: 0 });
286
559
  }}
560
+ value=""
561
+ width="100%"
287
562
  />
288
563
  </Box>
289
564
  )}
290
- />
291
- )}
292
- </Stack>
293
- {hasMoreCurrency(settings.paymentMethods) && (
294
- <Stack direction="column" spacing={2}>
295
- {currencies.fields.map((item: any, index: number) => {
296
- if (item.currency_id === defaultCurrencyId) {
297
- return null;
298
- }
299
- const fieldName = getFieldName(`currency_options.${index}.unit_amount`);
300
- const currency = findCurrency(settings.paymentMethods, item.currency_id);
301
- return (
302
- <Stack
303
- key={item.currency_id}
304
- direction="row"
305
- spacing={1}
565
+ </Stack>
566
+
567
+ {model === 'package' && (
568
+ <Box
569
+ sx={{
570
+ flex: { xs: 'none', sm: 1 },
571
+ width: { xs: '100%', sm: 'auto' },
572
+ mt: { xs: 0, sm: 0 },
573
+ }}>
574
+ <FormLabel
306
575
  sx={{
307
- alignItems: 'start',
576
+ visibility: { xs: 'visible', sm: 'hidden' },
577
+ display: 'block',
308
578
  }}>
309
- <Controller
310
- name={fieldName}
311
- control={control}
312
- rules={{
313
- required: t('admin.price.unit_amount.required'),
314
- validate: (v) => {
315
- const hasStripError = !stripeCurrencyValidate(v, currency);
316
- if (hasStripError) {
317
- return t('admin.price.unit_amount.stripeTip');
318
- }
319
- return validateAmount(v, currency ?? {});
320
- },
321
- }}
322
- disabled={isLocked}
323
- render={({ field }) => {
324
- return (
325
- <TextField
326
- {...field}
327
- type="number"
328
- size="small"
329
- sx={{ width: INPUT_WIDTH }}
330
- error={!!getFieldError(fieldName)}
331
- helperText={getFieldError(fieldName)?.message as string}
332
- slotProps={{
333
- input: {
334
- endAdornment: (
335
- <InputAdornment position="end">
336
- <CurrencySelect
337
- mode="selected"
338
- hasSelected={(c) =>
339
- currencies.fields.some((x: any) => x.currency_id === c.id) ||
340
- c.id === defaultCurrencyId
341
- }
342
- onSelect={(currencyId) => {
343
- const cIndex = currencies.fields.findIndex(
344
- (x: any) => x.currency_id === currency?.id
345
- );
346
- if (cIndex > -1) {
347
- // @ts-ignore
348
- handleCurrencyChange(cIndex, currencyId);
349
- }
350
- }}
351
- value={currency?.id!}
352
- disabled={isLocked}
353
- selectSX={{ '.MuiOutlinedInput-notchedOutline': { border: 'none' } }}
354
- />
355
- </InputAdornment>
356
- ),
357
- },
358
- }}
359
- />
360
- );
361
- }}
362
- />
363
- {!isLocked && (
364
- <IconButton size="small" disabled={isLocked} onClick={() => handleRemoveCurrency(index)}>
365
- <DeleteOutlineOutlined color="error" sx={{ opacity: 0.75 }} />
366
- </IconButton>
579
+ {t('admin.price.perUnit')}
580
+ </FormLabel>
581
+ <Controller
582
+ name={getFieldName('transform_quantity.divide_by')}
583
+ control={control}
584
+ disabled={isLocked}
585
+ render={({ field }) => (
586
+ <TextField
587
+ {...field}
588
+ type="number"
589
+ size="small"
590
+ fullWidth
591
+ InputProps={{
592
+ startAdornment: <InputAdornment position="start">{t('common.per')}</InputAdornment>,
593
+ endAdornment: <InputAdornment position="end">{t('common.unit')}</InputAdornment>,
594
+ }}
595
+ />
367
596
  )}
368
- </Stack>
369
- );
370
- })}
371
- {!isLocked && (
372
- <CurrencySelect
373
- mode="waiting"
374
- hasSelected={(currency) =>
375
- currencies.fields.some((x: any) => x.currency_id === currency.id) || currency.id === defaultCurrencyId
376
- }
377
- onSelect={(currencyId) => currencies.append({ currency_id: currencyId, unit_amount: 0 })}
378
- value=""
379
- width="260px"
380
- />
597
+ />
598
+ </Box>
381
599
  )}
382
600
  </Stack>
383
601
  )}
384
- <Controller
385
- name={getFieldName('type')}
386
- control={control}
387
- disabled={isLocked}
388
- render={({ field }) => (
389
- <ToggleButtonGroup
390
- {...field}
391
- onChange={(_, value: string) => {
392
- if (value !== null) {
393
- setValue(field.name, value);
394
- }
395
- }}
396
- exclusive>
397
- <ToggleButton value="recurring">{t('admin.price.types.recurring')}</ToggleButton>
398
- <ToggleButton value="one_time">{t('admin.price.types.onetime')}</ToggleButton>
399
- </ToggleButtonGroup>
400
- )}
401
- />
602
+ {/* 周期性配置 */}
402
603
  {isRecurring && (
403
- <Stack direction="row">
404
- <Controller
405
- name={getFieldName('recurring.interval_config')}
406
- control={control}
407
- disabled={isLocked}
408
- rules={{
409
- validate: (val) => {
410
- const hasStripe = currencies.fields?.some((x: any) => {
411
- return !!settings.paymentMethods.find(
412
- (y) => y?.type === 'stripe' && x?.currency_id === y?.default_currency_id
413
- );
414
- });
415
- if (val === 'hour_1' && hasStripe) {
416
- return t('admin.price.recurring.stripeTip');
417
- }
418
- return true;
419
- },
420
- }}
421
- render={({ field }) => (
422
- <Box>
423
- <FormLabel sx={{ color: 'text.primary' }}>{t('admin.price.recurring.interval')}</FormLabel>
604
+ <Stack direction="row" spacing={2} sx={{ width: INPUT_WIDTH }}>
605
+ <Box sx={{ flex: 1 }}>
606
+ <FormLabel>{t('admin.price.recurring.interval')}</FormLabel>
607
+ <Controller
608
+ name={getFieldName('recurring.interval_config')}
609
+ control={control}
610
+ disabled={isLocked}
611
+ rules={{
612
+ validate: (val) => {
613
+ const hasStripe = currencies.fields?.some((x: any) => {
614
+ return !!settings.paymentMethods.find(
615
+ (y: TPaymentMethodExpanded) => y?.type === 'stripe' && x?.currency_id === y?.default_currency_id
616
+ );
617
+ });
618
+ if (val === 'hour_1' && hasStripe) {
619
+ return t('admin.price.recurring.stripeTip');
620
+ }
621
+ return true;
622
+ },
623
+ }}
624
+ render={({ field }) => (
424
625
  <Select
425
626
  {...field}
426
627
  value={field.value || 'month_1'}
@@ -431,7 +632,7 @@ export default function PriceForm({ prefix = '', simple = false }: PriceFormProp
431
632
  setValue(getFieldName('recurring.interval_config'), e.target.value);
432
633
  trigger(getFieldName('recurring.interval_config'));
433
634
  }}
434
- sx={{ width: INPUT_WIDTH }}
635
+ fullWidth
435
636
  error={!!get(errors, getFieldName('recurring.interval_config'))}
436
637
  size="small">
437
638
  {!livemode && <MenuItem value="hour_1">{t('common.hourly')}</MenuItem>}
@@ -443,39 +644,38 @@ export default function PriceForm({ prefix = '', simple = false }: PriceFormProp
443
644
  <MenuItem value="year_1">{t('common.yearly')}</MenuItem>
444
645
  <MenuItem value="month_2">{t('common.custom')}</MenuItem>
445
646
  </Select>
446
- {get(errors, getFieldName('recurring.interval_config'))?.message && (
447
- <Typography color="error" sx={{ fontSize: '0.65625rem', mt: 0.5, ml: 1.75 }}>
448
- {/* @ts-ignore */}
449
- {get(errors, getFieldName('recurring.interval_config')).message}
450
- </Typography>
451
- )}
452
- </Box>
647
+ )}
648
+ />
649
+ {get(errors, getFieldName('recurring.interval_config'))?.message && (
650
+ <Typography color="error" sx={{ fontSize: '0.75rem', mt: 0.5 }}>
651
+ {/* @ts-ignore */}
652
+ {get(errors, getFieldName('recurring.interval_config')).message}
653
+ </Typography>
453
654
  )}
454
- />
655
+ </Box>
656
+
657
+ {/* 自定义间隔 */}
455
658
  {isCustomInterval && (
456
- <Controller
457
- name={getFieldName('recurring.interval_count')}
458
- control={control}
459
- disabled={isLocked}
460
- rules={{
461
- validate: (v) => {
462
- if (!intervalCountPositive(v)) {
463
- return t('admin.price.recurring.intervalCountTip');
464
- }
465
- return true;
466
- },
467
- }}
468
- render={({ field }) => (
469
- <Box
470
- sx={{
471
- ml: 2,
472
- }}>
473
- <FormLabel>&nbsp;</FormLabel>
659
+ <Box sx={{ flex: 1 }}>
660
+ <FormLabel sx={{ visibility: 'hidden' }}>placeholder</FormLabel>
661
+ <Controller
662
+ name={getFieldName('recurring.interval_count')}
663
+ control={control}
664
+ disabled={isLocked}
665
+ rules={{
666
+ validate: (v) => {
667
+ if (!intervalCountPositive(v)) {
668
+ return t('admin.price.recurring.intervalCountTip');
669
+ }
670
+ return true;
671
+ },
672
+ }}
673
+ render={({ field }) => (
474
674
  <TextField
475
675
  {...field}
476
676
  type="number"
477
677
  size="small"
478
- sx={{ width: INPUT_WIDTH }}
678
+ fullWidth
479
679
  error={!!get(errors, getFieldName('recurring.interval_count'))}
480
680
  helperText={get(errors, getFieldName('recurring.interval_count'))?.message as string}
481
681
  slotProps={{
@@ -497,159 +697,464 @@ export default function PriceForm({ prefix = '', simple = false }: PriceFormProp
497
697
  },
498
698
  }}
499
699
  />
500
- </Box>
501
- )}
502
- />
700
+ )}
701
+ />
702
+ </Box>
503
703
  )}
504
704
  </Stack>
505
705
  )}
506
- {isRecurring && (
507
- <Controller
508
- name={getFieldName('recurring.usage_type')}
509
- control={control}
510
- disabled={isLocked}
511
- render={({ field }) => (
512
- <FormControlLabel
513
- sx={{ alignItems: 'flex-start' }}
514
- control={
515
- <Checkbox
516
- checked={isMetered}
517
- {...field}
518
- onChange={(_, checked: boolean) => setValue(field.name, checked ? 'metered' : 'licensed')}
519
- />
520
- }
521
- label={
522
- <Stack>
523
- <Typography
524
- sx={{
525
- color: 'text.primary',
526
- }}>
527
- {t('admin.price.recurring.metered')}
528
- </Typography>
529
- <Typography
530
- sx={{
531
- color: 'text.secondary',
532
- maxWidth: '80%',
533
- }}>
534
- {t('admin.price.recurring.meteredTip')}
535
- </Typography>
536
- </Stack>
537
- }
538
- />
539
- )}
540
- />
706
+ {isRecurring && !isCreditMode && !isCreditBilling && (
707
+ <Box sx={{ width: INPUT_WIDTH }}>
708
+ <Controller
709
+ name={getFieldName('recurring.usage_type')}
710
+ control={control}
711
+ disabled={isLocked}
712
+ render={({ field }) => (
713
+ <FormControlLabel
714
+ sx={{ alignItems: 'flex-start' }}
715
+ control={
716
+ <Checkbox
717
+ checked={field.value === 'metered'}
718
+ {...field}
719
+ onChange={(_, checked: boolean) => setValue(field.name, checked ? 'metered' : 'licensed')}
720
+ />
721
+ }
722
+ label={
723
+ <Stack>
724
+ <Typography
725
+ sx={{
726
+ color: 'text.primary',
727
+ }}>
728
+ {t('admin.price.recurring.metered')}
729
+ </Typography>
730
+ <Typography
731
+ sx={{
732
+ color: 'text.secondary',
733
+ maxWidth: '80%',
734
+ }}>
735
+ {t('admin.price.recurring.meteredTip')}
736
+ </Typography>
737
+ </Stack>
738
+ }
739
+ />
740
+ )}
741
+ />
742
+ </Box>
541
743
  )}
542
- {isRecurring && isMetered && (
543
- <Controller
544
- name={getFieldName('recurring.aggregate_usage')}
545
- control={control}
546
- disabled={isLocked}
547
- render={({ field }) => (
548
- <Box>
549
- <FormLabel sx={{ color: 'text.primary' }}>{t('admin.price.recurring.aggregate')}</FormLabel>
550
- <Select {...field} sx={{ width: INPUT_WIDTH }} size="small">
744
+ {/* 聚合方式选择 - 仅在按量计费时显示 */}
745
+ {isRecurring && isMetered && !isCreditBilling && (
746
+ <Box sx={{ width: INPUT_WIDTH }}>
747
+ <FormLabel>{t('admin.price.recurring.aggregate')}</FormLabel>
748
+ <Controller
749
+ name={getFieldName('recurring.aggregate_usage')}
750
+ control={control}
751
+ disabled={isLocked}
752
+ render={({ field }) => (
753
+ <Select {...field} fullWidth size="small">
551
754
  <MenuItem value="sum">{t('admin.price.aggregate.sum')}</MenuItem>
552
755
  <MenuItem value="max">{t('admin.price.aggregate.max')}</MenuItem>
553
756
  <MenuItem value="last_ever">{t('admin.price.aggregate.last_ever')}</MenuItem>
554
757
  <MenuItem value="last_during_period">{t('admin.price.aggregate.last_during_period')}</MenuItem>
555
758
  </Select>
759
+ )}
760
+ />
761
+ </Box>
762
+ )}
763
+ {/* Credit 模式的特殊配置 */}
764
+ {isCreditMode && (
765
+ <>
766
+ <Divider sx={{ width: '100%' }} />
767
+ <Collapse
768
+ trigger={
769
+ <Typography sx={{ fontWeight: 500 }}>
770
+ {t('admin.creditProduct.settings')}
771
+ <Typography component="span" color="error" sx={{ ml: 0.5 }}>
772
+ *
773
+ </Typography>
774
+ </Typography>
775
+ }
776
+ expanded={isLocked}
777
+ style={{ width: INPUT_WIDTH }}>
778
+ <Box sx={{ width: INPUT_WIDTH, mb: 2, pl: 2, pr: 1 }}>
779
+ {/* Credit 数量配置 */}
780
+ <Controller
781
+ name={getFieldName('metadata')}
782
+ control={control}
783
+ render={({ field }) => (
784
+ <Box sx={{ width: '100%', mb: 2 }}>
785
+ <FormLabel required>{t('admin.creditProduct.creditAmount.label')}</FormLabel>
786
+ <TextField
787
+ size="small"
788
+ fullWidth
789
+ type="number"
790
+ placeholder={t('admin.creditProduct.creditAmount.placeholder')}
791
+ value={field.value?.credit_config?.credit_amount || ''}
792
+ disabled={isLocked}
793
+ onChange={(e) => {
794
+ const metadata = field.value || {};
795
+ const creditConfig = metadata.credit_config || {};
796
+ if (e.target.value) {
797
+ creditConfig.credit_amount = e.target.value;
798
+ } else {
799
+ delete creditConfig.credit_amount;
800
+ }
801
+ metadata.credit_config = creditConfig;
802
+ field.onChange(metadata);
803
+ }}
804
+ inputProps={{ min: 0, step: 'any' }}
805
+ // eslint-disable-next-line react/jsx-no-duplicate-props
806
+ InputProps={{
807
+ endAdornment: (
808
+ <InputAdornment position="end">
809
+ <CurrencySelect
810
+ mode="selected"
811
+ hasSelected={(c) => currencies.fields.some((x: any) => x.currency_id === c.id)}
812
+ currencyFilter={(c) => c.type === 'credit'}
813
+ onSelect={(currencyId) => {
814
+ const metadata = field.value || {};
815
+ const creditConfig = metadata.credit_config || {};
816
+ creditConfig.currency_id = currencyId;
817
+ metadata.credit_config = creditConfig;
818
+ field.onChange(metadata);
819
+ }}
820
+ value={field.value?.credit_config?.currency_id || creditCurrencies?.[0]?.id}
821
+ disabled={isLocked}
822
+ hideMethod
823
+ selectSX={{ '.MuiOutlinedInput-notchedOutline': { border: 'none' } }}
824
+ />
825
+ </InputAdornment>
826
+ ),
827
+ }}
828
+ />
829
+ <Typography
830
+ variant="caption"
831
+ sx={{
832
+ color: 'text.secondary',
833
+ display: 'block',
834
+ mt: 0.5,
835
+ }}>
836
+ {t('admin.creditProduct.creditAmount.description')}
837
+ </Typography>
838
+ </Box>
839
+ )}
840
+ />
841
+
842
+ {/* 可用时长配置 */}
843
+ <Box sx={{ width: '100%', mb: 2 }}>
844
+ <FormLabel tooltip={t('admin.creditProduct.validDuration.help')}>
845
+ {t('admin.creditProduct.validDuration.label')}
846
+ </FormLabel>
847
+ <Stack
848
+ direction="row"
849
+ spacing={1}
850
+ sx={{
851
+ alignItems: 'center',
852
+ }}>
853
+ <Controller
854
+ name={getFieldName('metadata.credit_config.valid_duration_value')}
855
+ control={control}
856
+ render={({ field }) => (
857
+ <TextField
858
+ {...field}
859
+ size="small"
860
+ type="number"
861
+ sx={{ flex: 1 }}
862
+ value={field.value ?? '0'}
863
+ placeholder="0"
864
+ disabled={isLocked}
865
+ inputProps={{ min: 0 }}
866
+ />
867
+ )}
868
+ />
869
+ <Controller
870
+ name={getFieldName('metadata.credit_config.valid_duration_unit')}
871
+ control={control}
872
+ render={({ field }) => (
873
+ <Select
874
+ {...field}
875
+ size="small"
876
+ sx={{ minWidth: 120 }}
877
+ disabled={isLocked}
878
+ value={field.value || 'days'}>
879
+ {!livemode && <MenuItem value="hours">{t('admin.creditProduct.validDuration.hours')}</MenuItem>}
880
+ <MenuItem value="days">{t('admin.creditProduct.validDuration.days')}</MenuItem>
881
+ <MenuItem value="weeks">{t('admin.creditProduct.validDuration.weeks')}</MenuItem>
882
+ <MenuItem value="months">{t('admin.creditProduct.validDuration.months')}</MenuItem>
883
+ <MenuItem value="years">{t('admin.creditProduct.validDuration.years')}</MenuItem>
884
+ </Select>
885
+ )}
886
+ />
887
+ </Stack>
888
+ <Typography
889
+ variant="caption"
890
+ sx={{
891
+ color: 'text.secondary',
892
+ display: 'block',
893
+ mt: 0.5,
894
+ }}>
895
+ {t('admin.creditProduct.validDuration.description')}
896
+ </Typography>
897
+ </Box>
898
+
899
+ {/* 关联特定价格 */}
900
+ <Controller
901
+ name={getFieldName('metadata')}
902
+ control={control}
903
+ render={({ field }) => (
904
+ <Box sx={{ width: '100%', mb: 2 }}>
905
+ <FormLabel tooltip={t('admin.creditProduct.associatedPrices.help')}>
906
+ {t('admin.creditProduct.associatedPrices.label')}
907
+ </FormLabel>
908
+
909
+ {/* 显示已选择的价格 */}
910
+ {field.value?.credit_config?.applicable_prices &&
911
+ Array.isArray(field.value.credit_config.applicable_prices) &&
912
+ field.value.credit_config.applicable_prices.length > 0 && (
913
+ <Stack spacing={1} sx={{ mb: 2 }}>
914
+ {field.value.credit_config.applicable_prices.map((priceId: string) => {
915
+ const product = getProductByPriceId(products, priceId);
916
+ if (!product) {
917
+ return null;
918
+ }
919
+ const productCurrency =
920
+ findCurrency(settings.paymentMethods, product.prices[0]?.currency_id ?? '') ||
921
+ settings.baseCurrency;
922
+
923
+ return (
924
+ <Box
925
+ key={priceId}
926
+ sx={{
927
+ display: 'flex',
928
+ alignItems: 'center',
929
+ justifyContent: 'space-between',
930
+ p: 1,
931
+ border: '1px solid',
932
+ borderColor: 'divider',
933
+ borderRadius: 1,
934
+ backgroundColor: 'background.paper',
935
+ }}>
936
+ <InfoCard
937
+ logo={product.images[0]}
938
+ name={product.name}
939
+ description={formatPrice(product.prices[0] as TPrice, productCurrency!)}
940
+ />
941
+ <IconButton
942
+ size="small"
943
+ onClick={() => {
944
+ const metadata = field.value || {};
945
+ const creditConfig = metadata.credit_config || {};
946
+ const currentPrices = creditConfig.applicable_prices || [];
947
+ const newPrices = currentPrices.filter((id: string) => id !== priceId);
948
+ if (newPrices.length > 0) {
949
+ creditConfig.applicable_prices = newPrices;
950
+ } else {
951
+ delete creditConfig.applicable_prices;
952
+ }
953
+ metadata.credit_config = creditConfig;
954
+ field.onChange(metadata);
955
+ }}>
956
+ <DeleteOutlineOutlined fontSize="small" />
957
+ </IconButton>
958
+ </Box>
959
+ );
960
+ })}
961
+ </Stack>
962
+ )}
963
+
964
+ {/* 价格选择器 */}
965
+ <ProductSelect
966
+ mode="selecting"
967
+ addProduct={false}
968
+ filterPrice={(x) => {
969
+ return isCreditMetered(x) && x.currency_id === field.value?.credit_config?.currency_id;
970
+ }}
971
+ hasSelected={(price) => {
972
+ const selectedPrices = field.value?.credit_config?.applicable_prices || [];
973
+ return selectedPrices.includes(price.id);
974
+ }}
975
+ onSelect={(priceId) => {
976
+ const metadata = field.value || {};
977
+ const creditConfig = metadata.credit_config || {};
978
+ const currentPrices = creditConfig.applicable_prices || [];
979
+ creditConfig.applicable_prices = [...currentPrices, priceId];
980
+ metadata.credit_config = creditConfig;
981
+ field.onChange(metadata);
982
+ }}
983
+ />
984
+
985
+ <Typography
986
+ variant="caption"
987
+ sx={{
988
+ color: 'text.secondary',
989
+ display: 'block',
990
+ mt: 0.5,
991
+ }}>
992
+ {t('admin.creditProduct.associatedPrices.description')}
993
+ </Typography>
994
+ </Box>
995
+ )}
996
+ />
997
+
998
+ {/* 优先级设置 */}
999
+ <Controller
1000
+ name={getFieldName('metadata')}
1001
+ control={control}
1002
+ disabled={isLocked}
1003
+ render={({ field }) => (
1004
+ <Box sx={{ width: '100%' }}>
1005
+ <FormLabel tooltip={t('admin.creditProduct.priority.help')} required>
1006
+ {t('admin.creditProduct.priority.label')}
1007
+ </FormLabel>
1008
+ <TextField
1009
+ size="small"
1010
+ fullWidth
1011
+ type="number"
1012
+ placeholder="50"
1013
+ value={field.value?.credit_config?.priority ?? '50'}
1014
+ onChange={(e) => {
1015
+ const metadata = field.value || {};
1016
+ const creditConfig = metadata.credit_config || {};
1017
+ const value = e.target.value ?? '50';
1018
+ creditConfig.priority = value;
1019
+ metadata.credit_config = creditConfig;
1020
+ field.onChange(metadata);
1021
+ }}
1022
+ inputProps={{ min: 0, max: 100 }}
1023
+ />
1024
+ <Typography
1025
+ variant="caption"
1026
+ sx={{
1027
+ color: 'text.secondary',
1028
+ display: 'block',
1029
+ mt: 0.5,
1030
+ }}>
1031
+ {t('admin.creditProduct.priority.description')}
1032
+ </Typography>
1033
+ </Box>
1034
+ )}
1035
+ />
556
1036
  </Box>
557
- )}
558
- />
1037
+ </Collapse>
1038
+ </>
559
1039
  )}
560
1040
  {!simple && (
561
- <Collapse trigger={t('admin.price.additional')} expanded={isLocked}>
562
- <Stack
563
- spacing={2}
564
- sx={{
565
- alignItems: 'flex-start',
1041
+ <>
1042
+ <Divider sx={{ width: '100%' }} />
1043
+ <Collapse
1044
+ trigger={t('admin.price.advanced')}
1045
+ expanded={isLocked}
1046
+ style={{
1047
+ width: INPUT_WIDTH,
1048
+ '.MuiCollapse-root': {
1049
+ width: '100%',
1050
+ },
566
1051
  }}>
567
- <Controller
568
- name={getFieldName('quantity_available')}
569
- control={control}
570
- render={({ field }) => (
571
- <Box>
572
- <FormLabel sx={{ color: 'text.primary' }}>{t('admin.price.quantityAvailable.label')}</FormLabel>
573
- <TextField
574
- {...field}
575
- size="small"
576
- sx={{ width: INPUT_WIDTH }}
577
- type="number"
578
- placeholder={t('admin.price.quantityAvailable.placeholder')}
579
- error={!quantityPositive(field.value)}
580
- helperText={!quantityPositive(field.value) && t('admin.price.quantity.tip')}
581
- />
582
- </Box>
583
- )}
584
- />
585
- <Controller
586
- name={getFieldName('quantity_limit_per_checkout')}
587
- control={control}
588
- render={({ field }) => (
589
- <Box>
590
- <FormLabel sx={{ color: 'text.primary' }}>
591
- {t('admin.price.quantityLimitPerCheckout.label')}
592
- </FormLabel>
593
- <TextField
594
- {...field}
595
- size="small"
596
- sx={{ width: INPUT_WIDTH }}
597
- type="number"
598
- placeholder={t('admin.price.quantityLimitPerCheckout.placeholder')}
599
- error={!quantityPositive(field.value)}
600
- helperText={!quantityPositive(field.value) && t('admin.price.quantity.tip')}
601
- />
602
- </Box>
603
- )}
604
- />
605
- <Controller
606
- name={getFieldName('nickname')}
607
- control={control}
608
- rules={{
609
- maxLength: {
610
- value: 64,
611
- message: t('common.maxLength', { len: 64 }),
612
- },
613
- }}
614
- render={({ field }) => (
615
- <Box>
616
- <FormLabel sx={{ color: 'text.primary' }}>{t('admin.price.nickname.label')}</FormLabel>
617
- <TextField
618
- {...field}
619
- size="small"
620
- sx={{ width: INPUT_WIDTH }}
621
- slotProps={{
622
- htmlInput: { maxLength: 64 },
623
- }}
624
- />
625
- </Box>
626
- )}
627
- />
628
- <Controller
629
- name={getFieldName('lookup_key')}
630
- control={control}
631
- rules={{
632
- maxLength: {
633
- value: 64,
634
- message: t('common.maxLength', { len: 64 }),
635
- },
636
- }}
637
- render={({ field }) => (
638
- <Box>
639
- <FormLabel sx={{ color: 'text.primary' }}>{t('admin.price.lookup_key.label')}</FormLabel>
640
- <TextField
641
- {...field}
642
- size="small"
643
- sx={{ width: INPUT_WIDTH }}
644
- slotProps={{
645
- htmlInput: { maxLength: 64 },
646
- }}
647
- />
648
- </Box>
649
- )}
650
- />
651
- </Stack>
652
- </Collapse>
1052
+ <Stack
1053
+ sx={{
1054
+ alignItems: 'flex-start',
1055
+ width: INPUT_WIDTH,
1056
+ pl: 2,
1057
+ pr: 1,
1058
+ }}>
1059
+ <Controller
1060
+ name={getFieldName('quantity_available')}
1061
+ control={control}
1062
+ render={({ field }) => (
1063
+ <Box sx={{ width: '100%', mb: 2 }}>
1064
+ <FormLabel>{t('admin.price.quantityAvailable.label')}</FormLabel>
1065
+ <TextField
1066
+ {...field}
1067
+ size="small"
1068
+ sx={{ width: INPUT_WIDTH }}
1069
+ type="number"
1070
+ placeholder={t('admin.price.quantityAvailable.placeholder')}
1071
+ error={!quantityPositive(field.value)}
1072
+ helperText={!quantityPositive(field.value) && t('admin.price.quantity.tip')}
1073
+ />
1074
+ <Typography
1075
+ variant="caption"
1076
+ sx={{
1077
+ color: 'text.secondary',
1078
+ display: 'block',
1079
+ mt: 0.5,
1080
+ }}>
1081
+ {t('admin.price.quantityAvailable.description')}
1082
+ </Typography>
1083
+ </Box>
1084
+ )}
1085
+ />
1086
+ <Controller
1087
+ name={getFieldName('quantity_limit_per_checkout')}
1088
+ control={control}
1089
+ render={({ field }) => (
1090
+ <Box sx={{ width: '100%', mb: 2 }}>
1091
+ <FormLabel>{t('admin.price.quantityLimitPerCheckout.label')}</FormLabel>
1092
+ <TextField
1093
+ {...field}
1094
+ size="small"
1095
+ sx={{ width: INPUT_WIDTH }}
1096
+ type="number"
1097
+ fullWidth
1098
+ placeholder={t('admin.price.quantityLimitPerCheckout.placeholder')}
1099
+ error={!quantityPositive(field.value)}
1100
+ helperText={!quantityPositive(field.value) && t('admin.price.quantity.tip')}
1101
+ />
1102
+ <Typography
1103
+ variant="caption"
1104
+ sx={{
1105
+ color: 'text.secondary',
1106
+ display: 'block',
1107
+ mt: 0.5,
1108
+ }}>
1109
+ {t('admin.price.quantityLimitPerCheckout.description')}
1110
+ </Typography>
1111
+ </Box>
1112
+ )}
1113
+ />
1114
+ <Controller
1115
+ name={getFieldName('nickname')}
1116
+ control={control}
1117
+ rules={{
1118
+ maxLength: {
1119
+ value: 64,
1120
+ message: t('common.maxLength', { len: 64 }),
1121
+ },
1122
+ }}
1123
+ render={({ field }) => (
1124
+ <Box sx={{ width: '100%', mb: 2 }}>
1125
+ <FormLabel>{t('admin.price.nickname.label')}</FormLabel>
1126
+ <TextField {...field} size="small" sx={{ width: INPUT_WIDTH }} inputProps={{ maxLength: 64 }} />
1127
+ </Box>
1128
+ )}
1129
+ />
1130
+ <Controller
1131
+ name={getFieldName('lookup_key')}
1132
+ control={control}
1133
+ rules={{
1134
+ maxLength: {
1135
+ value: 64,
1136
+ message: t('common.maxLength', { len: 64 }),
1137
+ },
1138
+ }}
1139
+ render={({ field }) => (
1140
+ <Box sx={{ width: '100%', mb: 2 }}>
1141
+ <FormLabel>{t('admin.price.lookup_key.label')}</FormLabel>
1142
+ <TextField {...field} size="small" sx={{ width: INPUT_WIDTH }} inputProps={{ maxLength: 64 }} />
1143
+ <Typography
1144
+ variant="caption"
1145
+ sx={{
1146
+ color: 'text.secondary',
1147
+ display: 'block',
1148
+ mt: 0.5,
1149
+ }}>
1150
+ {t('admin.price.lookup_key.description')}
1151
+ </Typography>
1152
+ </Box>
1153
+ )}
1154
+ />
1155
+ </Stack>
1156
+ </Collapse>
1157
+ </>
653
1158
  )}
654
1159
  </Root>
655
1160
  );