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