payment-kit 1.18.56 → 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 (214) hide show
  1. package/.eslintrc.js +6 -0
  2. package/api/src/crons/index.ts +8 -0
  3. package/api/src/index.ts +4 -0
  4. package/api/src/libs/credit-grant.ts +146 -0
  5. package/api/src/libs/env.ts +1 -0
  6. package/api/src/libs/invoice.ts +4 -3
  7. package/api/src/libs/notification/template/base.ts +388 -2
  8. package/api/src/libs/notification/template/customer-credit-grant-granted.ts +149 -0
  9. package/api/src/libs/notification/template/customer-credit-grant-low-balance.ts +151 -0
  10. package/api/src/libs/notification/template/customer-credit-insufficient.ts +254 -0
  11. package/api/src/libs/notification/template/subscription-canceled.ts +193 -202
  12. package/api/src/libs/notification/template/subscription-refund-succeeded.ts +215 -237
  13. package/api/src/libs/notification/template/subscription-renewed.ts +130 -200
  14. package/api/src/libs/notification/template/subscription-succeeded.ts +100 -202
  15. package/api/src/libs/notification/template/subscription-trial-start.ts +142 -188
  16. package/api/src/libs/notification/template/subscription-trial-will-end.ts +146 -174
  17. package/api/src/libs/notification/template/subscription-upgraded.ts +96 -192
  18. package/api/src/libs/notification/template/subscription-will-canceled.ts +94 -135
  19. package/api/src/libs/notification/template/subscription-will-renew.ts +220 -245
  20. package/api/src/libs/payment.ts +69 -0
  21. package/api/src/libs/queue/index.ts +3 -2
  22. package/api/src/libs/session.ts +8 -0
  23. package/api/src/libs/subscription.ts +74 -3
  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 +715 -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/index.ts +8 -0
  37. package/api/src/routes/meter-events.ts +347 -0
  38. package/api/src/routes/meters.ts +219 -0
  39. package/api/src/routes/payment-currencies.ts +14 -2
  40. package/api/src/routes/payment-links.ts +1 -1
  41. package/api/src/routes/payment-methods.ts +14 -2
  42. package/api/src/routes/prices.ts +43 -0
  43. package/api/src/routes/pricing-table.ts +13 -7
  44. package/api/src/routes/products.ts +63 -4
  45. package/api/src/routes/settings.ts +1 -1
  46. package/api/src/routes/subscriptions.ts +4 -0
  47. package/api/src/store/migrations/20250610-billing-credit.ts +43 -0
  48. package/api/src/store/models/credit-grant.ts +486 -0
  49. package/api/src/store/models/credit-transaction.ts +268 -0
  50. package/api/src/store/models/customer.ts +8 -0
  51. package/api/src/store/models/index.ts +52 -1
  52. package/api/src/store/models/meter-event.ts +423 -0
  53. package/api/src/store/models/meter.ts +176 -0
  54. package/api/src/store/models/payment-currency.ts +66 -14
  55. package/api/src/store/models/price.ts +6 -0
  56. package/api/src/store/models/product.ts +2 -2
  57. package/api/src/store/models/subscription.ts +24 -0
  58. package/api/src/store/models/types.ts +28 -2
  59. package/api/tests/libs/subscription.spec.ts +53 -0
  60. package/blocklet.yml +9 -1
  61. package/package.json +57 -58
  62. package/scripts/sdk.js +233 -1
  63. package/src/app.tsx +10 -0
  64. package/src/components/actions.tsx +22 -9
  65. package/src/components/balance-list.tsx +40 -12
  66. package/src/components/collapse.tsx +33 -15
  67. package/src/components/copyable.tsx +8 -7
  68. package/src/components/currency.tsx +15 -7
  69. package/src/components/customer/actions.tsx +1 -5
  70. package/src/components/customer/credit-grant-item-list.tsx +99 -0
  71. package/src/components/customer/credit-overview.tsx +233 -0
  72. package/src/components/customer/form.tsx +7 -2
  73. package/src/components/customer/link.tsx +4 -12
  74. package/src/components/customer/notification-preference.tsx +18 -9
  75. package/src/components/customer/overdraft-protection.tsx +112 -41
  76. package/src/components/drawer-form.tsx +42 -18
  77. package/src/components/error.tsx +1 -5
  78. package/src/components/event/list.tsx +9 -10
  79. package/src/components/filter-toolbar.tsx +20 -19
  80. package/src/components/info-card.tsx +32 -18
  81. package/src/components/info-metric.tsx +16 -6
  82. package/src/components/info-row-group.tsx +1 -7
  83. package/src/components/info-row.tsx +30 -24
  84. package/src/components/invoice/action.tsx +1 -7
  85. package/src/components/invoice/list.tsx +34 -26
  86. package/src/components/invoice/recharge.tsx +5 -7
  87. package/src/components/invoice/table.tsx +17 -12
  88. package/src/components/layout/user.tsx +1 -1
  89. package/src/components/metadata/form.tsx +290 -94
  90. package/src/components/metadata/list.tsx +11 -3
  91. package/src/components/meter/actions.tsx +101 -0
  92. package/src/components/meter/add-usage-dialog.tsx +239 -0
  93. package/src/components/meter/events-list.tsx +657 -0
  94. package/src/components/meter/form.tsx +245 -0
  95. package/src/components/meter/products.tsx +264 -0
  96. package/src/components/meter/usage-guide.tsx +174 -0
  97. package/src/components/passport/actions.tsx +9 -4
  98. package/src/components/payment-currency/add.tsx +16 -3
  99. package/src/components/payment-currency/form.tsx +14 -6
  100. package/src/components/payment-intent/actions.tsx +24 -16
  101. package/src/components/payment-intent/list.tsx +30 -9
  102. package/src/components/payment-link/actions.tsx +1 -5
  103. package/src/components/payment-link/after-pay.tsx +4 -2
  104. package/src/components/payment-link/before-pay.tsx +14 -4
  105. package/src/components/payment-link/item.tsx +27 -6
  106. package/src/components/payment-link/preview.tsx +9 -9
  107. package/src/components/payment-link/product-select.tsx +69 -15
  108. package/src/components/payment-method/arcblock.tsx +8 -1
  109. package/src/components/payment-method/base.tsx +8 -1
  110. package/src/components/payment-method/bitcoin.tsx +8 -1
  111. package/src/components/payment-method/ethereum.tsx +8 -1
  112. package/src/components/payment-method/evm-rpc-input.tsx +11 -7
  113. package/src/components/payment-method/form.tsx +2 -7
  114. package/src/components/payment-method/stripe.tsx +2 -0
  115. package/src/components/payouts/actions.tsx +1 -5
  116. package/src/components/payouts/list.tsx +30 -10
  117. package/src/components/payouts/portal/list.tsx +11 -9
  118. package/src/components/price/currency-select.tsx +63 -32
  119. package/src/components/price/form.tsx +895 -370
  120. package/src/components/price/upsell-select.tsx +10 -2
  121. package/src/components/price/upsell.tsx +7 -2
  122. package/src/components/pricing-table/actions.tsx +1 -5
  123. package/src/components/pricing-table/customer-settings.tsx +5 -1
  124. package/src/components/pricing-table/payment-settings.tsx +14 -4
  125. package/src/components/pricing-table/preview.tsx +9 -9
  126. package/src/components/pricing-table/price-item.tsx +6 -1
  127. package/src/components/pricing-table/product-item.tsx +6 -1
  128. package/src/components/pricing-table/product-settings.tsx +17 -4
  129. package/src/components/product/actions.tsx +1 -5
  130. package/src/components/product/add-price.tsx +9 -7
  131. package/src/components/product/create.tsx +8 -9
  132. package/src/components/product/cross-sell-select.tsx +5 -1
  133. package/src/components/product/cross-sell.tsx +7 -2
  134. package/src/components/product/edit-price.tsx +21 -12
  135. package/src/components/product/features.tsx +26 -6
  136. package/src/components/product/form.tsx +115 -72
  137. package/src/components/progress-bar.tsx +1 -1
  138. package/src/components/refund/actions.tsx +1 -7
  139. package/src/components/refund/list.tsx +31 -18
  140. package/src/components/section/header.tsx +12 -14
  141. package/src/components/subscription/actions/cancel.tsx +22 -5
  142. package/src/components/subscription/actions/index.tsx +9 -10
  143. package/src/components/subscription/actions/pause.tsx +32 -6
  144. package/src/components/subscription/actions/slash-stake.tsx +5 -3
  145. package/src/components/subscription/description.tsx +12 -8
  146. package/src/components/subscription/items/index.tsx +31 -16
  147. package/src/components/subscription/items/usage-records.tsx +19 -5
  148. package/src/components/subscription/list.tsx +5 -7
  149. package/src/components/subscription/metrics.tsx +62 -15
  150. package/src/components/subscription/portal/actions.tsx +78 -71
  151. package/src/components/subscription/portal/cancel.tsx +10 -3
  152. package/src/components/subscription/portal/list.tsx +48 -26
  153. package/src/components/uploader.tsx +5 -13
  154. package/src/components/webhook/attempts.tsx +51 -16
  155. package/src/components/webhook/request-info.tsx +8 -6
  156. package/src/contexts/products.tsx +27 -10
  157. package/src/hooks/subscription.ts +34 -0
  158. package/src/libs/meter-utils.ts +196 -0
  159. package/src/libs/util.ts +4 -0
  160. package/src/locales/en.tsx +385 -4
  161. package/src/locales/zh.tsx +364 -0
  162. package/src/pages/admin/billing/index.tsx +61 -33
  163. package/src/pages/admin/billing/invoices/detail.tsx +49 -13
  164. package/src/pages/admin/billing/meters/create.tsx +60 -0
  165. package/src/pages/admin/billing/meters/detail.tsx +435 -0
  166. package/src/pages/admin/billing/meters/index.tsx +210 -0
  167. package/src/pages/admin/billing/meters/meter-event.tsx +346 -0
  168. package/src/pages/admin/billing/subscriptions/detail.tsx +90 -25
  169. package/src/pages/admin/customers/customers/credit-grant/detail.tsx +391 -0
  170. package/src/pages/admin/customers/customers/detail.tsx +67 -14
  171. package/src/pages/admin/customers/customers/index.tsx +6 -1
  172. package/src/pages/admin/customers/index.tsx +5 -0
  173. package/src/pages/admin/developers/events/detail.tsx +37 -11
  174. package/src/pages/admin/developers/index.tsx +1 -1
  175. package/src/pages/admin/developers/webhooks/detail.tsx +41 -11
  176. package/src/pages/admin/index.tsx +15 -2
  177. package/src/pages/admin/overview.tsx +107 -19
  178. package/src/pages/admin/payments/intents/detail.tsx +58 -14
  179. package/src/pages/admin/payments/payouts/detail.tsx +63 -15
  180. package/src/pages/admin/payments/refunds/detail.tsx +58 -14
  181. package/src/pages/admin/products/index.tsx +11 -4
  182. package/src/pages/admin/products/links/create.tsx +22 -4
  183. package/src/pages/admin/products/links/detail.tsx +43 -14
  184. package/src/pages/admin/products/passports/index.tsx +23 -4
  185. package/src/pages/admin/products/prices/actions.tsx +16 -9
  186. package/src/pages/admin/products/prices/detail.tsx +73 -14
  187. package/src/pages/admin/products/prices/list.tsx +15 -3
  188. package/src/pages/admin/products/pricing-tables/create.tsx +45 -12
  189. package/src/pages/admin/products/pricing-tables/detail.tsx +45 -14
  190. package/src/pages/admin/products/products/create.tsx +233 -54
  191. package/src/pages/admin/products/products/detail.tsx +74 -18
  192. package/src/pages/admin/settings/index.tsx +8 -1
  193. package/src/pages/admin/settings/payment-methods/index.tsx +87 -19
  194. package/src/pages/admin/settings/vault-config/edit-form.tsx +42 -28
  195. package/src/pages/admin/settings/vault-config/index.tsx +57 -10
  196. package/src/pages/customer/credit-grant/detail.tsx +308 -0
  197. package/src/pages/customer/index.tsx +76 -17
  198. package/src/pages/customer/invoice/detail.tsx +63 -14
  199. package/src/pages/customer/invoice/past-due.tsx +11 -3
  200. package/src/pages/customer/payout/detail.tsx +56 -13
  201. package/src/pages/customer/recharge/account.tsx +78 -18
  202. package/src/pages/customer/recharge/subscription.tsx +86 -25
  203. package/src/pages/customer/refund/list.tsx +60 -24
  204. package/src/pages/customer/subscription/change-payment.tsx +17 -6
  205. package/src/pages/customer/subscription/change-plan.tsx +34 -7
  206. package/src/pages/customer/subscription/detail.tsx +134 -34
  207. package/src/pages/customer/subscription/embed.tsx +25 -5
  208. package/src/pages/home.tsx +26 -4
  209. package/src/pages/integrations/donations/edit-form.tsx +25 -9
  210. package/src/pages/integrations/donations/index.tsx +26 -9
  211. package/src/pages/integrations/donations/preview.tsx +59 -15
  212. package/src/pages/integrations/index.tsx +10 -1
  213. package/src/pages/integrations/overview.tsx +78 -17
  214. package/vite.config.ts +60 -30
@@ -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,14 +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
- PriceForm.defaultProps = {
79
- prefix: '',
80
- simple: false,
81
- };
82
-
83
- const INPUT_WIDTH = 260;
100
+ const INPUT_WIDTH = '100%';
84
101
 
85
102
  const hasMoreCurrency = (methods: TPaymentMethodExpanded[] = []) => {
86
103
  return methods.every((method) => method.payment_currencies.length > 1) || methods.length > 1;
@@ -96,7 +113,11 @@ function stripeCurrencyValidate(v: number, currency: TPaymentCurrencyExpanded |
96
113
  return true;
97
114
  }
98
115
 
99
- export default function PriceForm({ prefix, simple }: 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) {
100
121
  const getFieldName = (name: string) => (prefix ? `${prefix}.${name}` : name);
101
122
 
102
123
  const { t, locale } = useLocaleContext();
@@ -112,6 +133,8 @@ export default function PriceForm({ prefix, simple }: PriceFormProps) {
112
133
  };
113
134
  const { settings, livemode } = usePaymentContext();
114
135
  const currencies = useFieldArray({ control, name: getFieldName('currency_options') });
136
+ const formProductType = useWatch({ control, name: 'type' });
137
+ const currentProductType = productType || formProductType;
115
138
  const priceLocked = useWatch({ control, name: getFieldName('locked') });
116
139
  const isRecurring = useWatch({ control, name: getFieldName('type') }) === 'recurring';
117
140
  const isMetered = useWatch({ control, name: getFieldName('recurring.usage_type') }) === 'metered';
@@ -119,10 +142,37 @@ export default function PriceForm({ prefix, simple }: PriceFormProps) {
119
142
  const model = useWatch({ control, name: getFieldName('model') });
120
143
  const intervalSelectValue = useWatch({ control, name: getFieldName('recurring.interval') });
121
144
  const defaultCurrencyId = useWatch({ control, name: getFieldName('currency_id') });
122
- 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();
123
151
  const quantityPositive = (v: number | undefined) => !v || v.toString().match(/^(0|[1-9]\d*)$/);
124
152
  const intervalCountPositive = (v: number) => Number.isInteger(Number(v)) && v > 0;
125
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
+
126
176
  const isLocked = priceLocked && window.blocklet?.PAYMENT_CHANGE_LOCKED_PRICE !== '1';
127
177
 
128
178
  const validateAmount = (v: number, currency: { maximum_precision?: number }) => {
@@ -155,9 +205,87 @@ export default function PriceForm({ prefix, simple }: PriceFormProps) {
155
205
  ...update,
156
206
  });
157
207
  };
208
+
209
+ const selectedMeter = meters.find((m) => m.id === meteringMeterId);
210
+ const isCreditBilling = model === 'credit_metered';
211
+ const isCreditMode = currentProductType === 'credit';
212
+
158
213
  return (
159
214
  <Root direction="column" alignItems="flex-start" spacing={2}>
160
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
+ />
161
289
  <Controller
162
290
  name={getFieldName('model')}
163
291
  control={control}
@@ -165,8 +293,24 @@ export default function PriceForm({ prefix, simple }: PriceFormProps) {
165
293
  disabled={isLocked}
166
294
  render={({ field }) => (
167
295
  <Box sx={{ width: INPUT_WIDTH }}>
168
- <FormLabel sx={{ color: 'text.primary' }}>{t('admin.price.model')}</FormLabel>
169
- <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
+ }}>
170
314
  <MenuItem value="standard">{t('admin.price.models.standard')}</MenuItem>
171
315
  <MenuItem value="package">{t('admin.price.models.package')}</MenuItem>
172
316
  <MenuItem value="graduated" disabled>
@@ -175,233 +319,305 @@ export default function PriceForm({ prefix, simple }: PriceFormProps) {
175
319
  <MenuItem value="volume" disabled>
176
320
  {t('admin.price.models.volume')}
177
321
  </MenuItem>
178
- <MenuItem value="custom" disabled>
179
- {t('admin.price.models.custom')}
180
- </MenuItem>
322
+ <MenuItem value="credit_metered">{t('admin.price.models.creditMetered')}</MenuItem>
181
323
  </Select>
182
324
  </Box>
183
325
  )}
184
326
  />
185
- <Stack direction="row">
186
- <Controller
187
- name={getFieldName('unit_amount')}
188
- control={control}
189
- rules={{
190
- required: t('admin.price.unit_amount.required'),
191
- validate: (v) => {
192
- const hasStripError = !stripeCurrencyValidate(v, defaultCurrency);
193
- if (hasStripError) {
194
- 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);
195
350
  }
196
- return validateAmount(v, defaultCurrency ?? {});
197
- },
198
- }}
199
- disabled={isLocked}
200
- render={({ field }) => (
201
- <Box>
202
- <FormLabel sx={{ color: 'text.primary' }}>
203
- <Stack direction="row" alignItems="center" spacing={0.5}>
204
- <Typography component="span" color="text.primary">
205
- {t('admin.price.amount')}
206
- </Typography>
207
- <Tooltip title={t('admin.price.amountTip')} placement="top" arrow>
208
- <InfoOutlined fontSize="small" sx={{ color: 'text.secondary' }} />
209
- </Tooltip>
210
- </Stack>
211
- </FormLabel>
351
+ }}
352
+ fullWidth
353
+ renderInput={(params) => (
212
354
  <TextField
213
- {...field}
214
- type="number"
355
+ {...params}
356
+ required
357
+ error={!meteringMeterId}
358
+ helperText={
359
+ !meteringMeterId ? t('admin.price.creditMetered.required') : t('admin.price.creditMetered.help')
360
+ }
215
361
  size="small"
216
- sx={{ width: INPUT_WIDTH }}
217
- error={!!getFieldError(getFieldName('unit_amount'))}
218
- helperText={getFieldError(getFieldName('unit_amount'))?.message}
219
362
  InputProps={{
363
+ ...params.InputProps,
220
364
  endAdornment: (
221
- <InputAdornment position="end">
222
- <CurrencySelect
223
- mode="selected"
224
- hasSelected={(currency) =>
225
- currencies.fields.some((x: any) => x.currency_id === currency.id) ||
226
- currency.id === defaultCurrencyId
227
- }
228
- onSelect={(currencyId) => {
229
- const index = currencies.fields.findIndex((x: any) => x.currency_id === defaultCurrencyId);
230
- if (index > -1) {
231
- // @ts-ignore
232
- handleCurrencyChange(index, currencyId);
233
- }
234
- setValue(getFieldName('currency'), findCurrency(settings.paymentMethods, currencyId), {
235
- shouldValidate: true,
236
- });
237
- setValue(getFieldName('currency_id'), currencyId, { shouldValidate: true });
238
- }}
239
- value={defaultCurrencyId}
240
- disabled={isLocked}
241
- selectSX={{ '.MuiOutlinedInput-notchedOutline': { border: 'none' } }}
242
- />
243
- </InputAdornment>
365
+ <>
366
+ {loadingMeters ? <div>{t('common.loading')}</div> : null}
367
+ {params.InputProps.endAdornment}
368
+ </>
244
369
  ),
245
370
  }}
246
- onChange={(e) => {
247
- const { value } = e.target;
248
- field.onChange(value);
249
- const index = currencies.fields.findIndex((x: any) => x.currency_id === defaultCurrencyId);
250
- if (index === -1) {
251
- return;
252
- }
253
- setValue(getFieldName(`currency_options.${index}.unit_amount`), value, { shouldValidate: true });
254
- }}
255
371
  />
256
- </Box>
257
- )}
258
- />
259
- {model === 'package' && (
260
- <Controller
261
- name={getFieldName('transform_quantity.divide_by')}
262
- control={control}
263
- disabled={isLocked}
264
- render={({ field }) => (
265
- <Box ml={2}>
266
- <FormLabel>&nbsp;</FormLabel>
267
- <TextField
268
- {...field}
269
- type="number"
270
- size="small"
271
- sx={{ width: INPUT_WIDTH }}
272
- InputProps={{
273
- startAdornment: <InputAdornment position="start">{t('common.per')}</InputAdornment>,
274
- endAdornment: <InputAdornment position="end">{t('common.unit')}</InputAdornment>,
275
- }}
276
- />
277
- </Box>
278
372
  )}
279
373
  />
280
- )}
281
- </Stack>
282
- {hasMoreCurrency(settings.paymentMethods) && (
283
- <Stack direction="column" spacing={2}>
284
- {currencies.fields.map((item: any, index: number) => {
285
- if (item.currency_id === defaultCurrencyId) {
286
- return null;
287
- }
288
- const fieldName = getFieldName(`currency_options.${index}.unit_amount`);
289
- const currency = findCurrency(settings.paymentMethods, item.currency_id);
290
- return (
291
- <Stack key={item.currency_id} direction="row" alignItems="start" spacing={1}>
292
- <Controller
293
- name={fieldName}
294
- control={control}
295
- rules={{
296
- required: t('admin.price.unit_amount.required'),
297
- validate: (v) => {
298
- const hasStripError = !stripeCurrencyValidate(v, currency);
299
- if (hasStripError) {
300
- return t('admin.price.unit_amount.stripeTip');
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 ?? {});
401
+ },
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;
301
448
  }
302
- return validateAmount(v, currency ?? {});
303
- },
304
- }}
305
- disabled={isLocked}
306
- render={({ field }) => {
449
+ setValue(getFieldName(`currency_options.${index}.unit_amount`), value, {
450
+ shouldValidate: true,
451
+ });
452
+ }}
453
+ />
454
+ )}
455
+ />
456
+ </Box>
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);
307
466
  return (
308
- <TextField
309
- {...field}
310
- type="number"
311
- size="small"
312
- sx={{ width: INPUT_WIDTH }}
313
- error={!!getFieldError(fieldName)}
314
- helperText={getFieldError(fieldName)?.message as string}
315
- InputProps={{
316
- endAdornment: (
317
- <InputAdornment position="end">
318
- <CurrencySelect
319
- mode="selected"
320
- hasSelected={(c) =>
321
- currencies.fields.some((x: any) => x.currency_id === c.id) ||
322
- c.id === defaultCurrencyId
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');
323
484
  }
324
- onSelect={(currencyId) => {
325
- const cIndex = currencies.fields.findIndex(
326
- (x: any) => x.currency_id === currency?.id
327
- );
328
- if (cIndex > -1) {
329
- // @ts-ignore
330
- handleCurrencyChange(cIndex, currencyId);
331
- }
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
+ ),
332
523
  }}
333
- value={currency?.id!}
334
- disabled={isLocked}
335
- selectSX={{ '.MuiOutlinedInput-notchedOutline': { border: 'none' } }}
336
524
  />
337
- </InputAdornment>
338
- ),
339
- }}
340
- />
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>
341
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 });
342
555
  }}
556
+ value=""
557
+ width="100%"
343
558
  />
344
- {!isLocked && (
345
- <IconButton size="small" disabled={isLocked} onClick={() => handleRemoveCurrency(index)}>
346
- <DeleteOutlineOutlined color="error" sx={{ opacity: 0.75 }} />
347
- </IconButton>
559
+ </Box>
560
+ )}
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
571
+ sx={{
572
+ visibility: { xs: 'visible', sm: 'hidden' },
573
+ display: 'block',
574
+ }}>
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
+ />
348
592
  )}
349
- </Stack>
350
- );
351
- })}
352
- {!isLocked && (
353
- <CurrencySelect
354
- mode="waiting"
355
- hasSelected={(currency) =>
356
- currencies.fields.some((x: any) => x.currency_id === currency.id) || currency.id === defaultCurrencyId
357
- }
358
- onSelect={(currencyId) => currencies.append({ currency_id: currencyId, unit_amount: 0 })}
359
- value=""
360
- width="260px"
361
- />
593
+ />
594
+ </Box>
362
595
  )}
363
596
  </Stack>
364
597
  )}
365
- <Controller
366
- name={getFieldName('type')}
367
- control={control}
368
- disabled={isLocked}
369
- render={({ field }) => (
370
- <ToggleButtonGroup
371
- {...field}
372
- onChange={(_, value: string) => {
373
- if (value !== null) {
374
- setValue(field.name, value);
375
- }
376
- }}
377
- exclusive>
378
- <ToggleButton value="recurring">{t('admin.price.types.recurring')}</ToggleButton>
379
- <ToggleButton value="one_time">{t('admin.price.types.onetime')}</ToggleButton>
380
- </ToggleButtonGroup>
381
- )}
382
- />
598
+ {/* 周期性配置 */}
383
599
  {isRecurring && (
384
- <Stack direction="row">
385
- <Controller
386
- name={getFieldName('recurring.interval_config')}
387
- control={control}
388
- disabled={isLocked}
389
- rules={{
390
- validate: (val) => {
391
- const hasStripe = currencies.fields?.some((x: any) => {
392
- return !!settings.paymentMethods.find(
393
- (y) => y?.type === 'stripe' && x?.currency_id === y?.default_currency_id
394
- );
395
- });
396
- if (val === 'hour_1' && hasStripe) {
397
- return t('admin.price.recurring.stripeTip');
398
- }
399
- return true;
400
- },
401
- }}
402
- render={({ field }) => (
403
- <Box>
404
- <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 }) => (
405
621
  <Select
406
622
  {...field}
407
623
  value={field.value || 'month_1'}
@@ -412,7 +628,7 @@ export default function PriceForm({ prefix, simple }: PriceFormProps) {
412
628
  setValue(getFieldName('recurring.interval_config'), e.target.value);
413
629
  trigger(getFieldName('recurring.interval_config'));
414
630
  }}
415
- sx={{ width: INPUT_WIDTH }}
631
+ fullWidth
416
632
  error={!!get(errors, getFieldName('recurring.interval_config'))}
417
633
  size="small">
418
634
  {!livemode && <MenuItem value="hour_1">{t('common.hourly')}</MenuItem>}
@@ -424,181 +640,490 @@ export default function PriceForm({ prefix, simple }: PriceFormProps) {
424
640
  <MenuItem value="year_1">{t('common.yearly')}</MenuItem>
425
641
  <MenuItem value="month_2">{t('common.custom')}</MenuItem>
426
642
  </Select>
427
- {get(errors, getFieldName('recurring.interval_config'))?.message && (
428
- <Typography color="error" sx={{ fontSize: '0.65625rem', mt: 0.5, ml: 1.75 }}>
429
- {/* @ts-ignore */}
430
- {get(errors, getFieldName('recurring.interval_config')).message}
431
- </Typography>
432
- )}
433
- </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>
434
650
  )}
435
- />
651
+ </Box>
652
+
653
+ {/* 自定义间隔 */}
436
654
  {isCustomInterval && (
437
- <Controller
438
- name={getFieldName('recurring.interval_count')}
439
- control={control}
440
- disabled={isLocked}
441
- rules={{
442
- validate: (v) => {
443
- if (!intervalCountPositive(v)) {
444
- return t('admin.price.recurring.intervalCountTip');
445
- }
446
- return true;
447
- },
448
- }}
449
- render={({ field }) => (
450
- <Box ml={2}>
451
- <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 }) => (
452
670
  <TextField
453
671
  {...field}
454
672
  type="number"
455
673
  size="small"
456
- sx={{ width: INPUT_WIDTH }}
674
+ fullWidth
457
675
  error={!!get(errors, getFieldName('recurring.interval_count'))}
458
676
  helperText={get(errors, getFieldName('recurring.interval_count'))?.message as string}
459
- InputProps={{
460
- startAdornment: <InputAdornment position="start">{t('common.every')}</InputAdornment>,
461
- endAdornment: (
462
- <InputAdornment position="end">
463
- <select
464
- onChange={(e) => setValue(getFieldName('recurring.interval'), e.target.value)}
465
- value={intervalSelectValue}
466
- style={{ background: 'none', outline: 'none' }}>
467
- <option value="day">{t('common.days')}</option>
468
- <option value="week">{t('common.weeks')}</option>
469
- <option value="month">{t('common.months')}</option>
470
- <option value="year">{t('common.years')}</option>
471
- </select>
472
- </InputAdornment>
473
- ),
677
+ slotProps={{
678
+ input: {
679
+ startAdornment: <InputAdornment position="start">{t('common.every')}</InputAdornment>,
680
+ endAdornment: (
681
+ <InputAdornment position="end">
682
+ <select
683
+ onChange={(e) => setValue(getFieldName('recurring.interval'), e.target.value)}
684
+ value={intervalSelectValue}
685
+ style={{ background: 'none', outline: 'none' }}>
686
+ <option value="day">{t('common.days')}</option>
687
+ <option value="week">{t('common.weeks')}</option>
688
+ <option value="month">{t('common.months')}</option>
689
+ <option value="year">{t('common.years')}</option>
690
+ </select>
691
+ </InputAdornment>
692
+ ),
693
+ },
474
694
  }}
475
695
  />
476
- </Box>
477
- )}
478
- />
696
+ )}
697
+ />
698
+ </Box>
479
699
  )}
480
700
  </Stack>
481
701
  )}
482
- {isRecurring && (
483
- <Controller
484
- name={getFieldName('recurring.usage_type')}
485
- control={control}
486
- disabled={isLocked}
487
- render={({ field }) => (
488
- <FormControlLabel
489
- sx={{ alignItems: 'flex-start' }}
490
- control={
491
- <Checkbox
492
- checked={isMetered}
493
- {...field}
494
- onChange={(_, checked: boolean) => setValue(field.name, checked ? 'metered' : 'licensed')}
495
- />
496
- }
497
- label={
498
- <Stack>
499
- <Typography color="text.primary">{t('admin.price.recurring.metered')}</Typography>
500
- <Typography color="text.secondary" sx={{ maxWidth: '80%' }}>
501
- {t('admin.price.recurring.meteredTip')}
502
- </Typography>
503
- </Stack>
504
- }
505
- />
506
- )}
507
- />
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>
508
739
  )}
509
- {isRecurring && isMetered && (
510
- <Controller
511
- name={getFieldName('recurring.aggregate_usage')}
512
- control={control}
513
- disabled={isLocked}
514
- render={({ field }) => (
515
- <Box>
516
- <FormLabel sx={{ color: 'text.primary' }}>{t('admin.price.recurring.aggregate')}</FormLabel>
517
- <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">
518
750
  <MenuItem value="sum">{t('admin.price.aggregate.sum')}</MenuItem>
519
751
  <MenuItem value="max">{t('admin.price.aggregate.max')}</MenuItem>
520
752
  <MenuItem value="last_ever">{t('admin.price.aggregate.last_ever')}</MenuItem>
521
753
  <MenuItem value="last_during_period">{t('admin.price.aggregate.last_during_period')}</MenuItem>
522
754
  </Select>
523
- </Box>
524
- )}
525
- />
755
+ )}
756
+ />
757
+ </Box>
526
758
  )}
527
- {!simple && (
528
- <Collapse trigger={t('admin.price.additional')} expanded={isLocked}>
529
- <Stack spacing={2} alignItems="flex-start">
530
- <Controller
531
- name={getFieldName('quantity_available')}
532
- control={control}
533
- render={({ field }) => (
534
- <Box>
535
- <FormLabel sx={{ color: 'text.primary' }}>{t('admin.price.quantityAvailable.label')}</FormLabel>
536
- <TextField
537
- {...field}
538
- size="small"
539
- sx={{ width: INPUT_WIDTH }}
540
- type="number"
541
- placeholder={t('admin.price.quantityAvailable.placeholder')}
542
- error={!quantityPositive(field.value)}
543
- helperText={!quantityPositive(field.value) && t('admin.price.quantity.tip')}
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
+ )}
544
863
  />
545
- </Box>
546
- )}
547
- />
548
- <Controller
549
- name={getFieldName('quantity_limit_per_checkout')}
550
- control={control}
551
- render={({ field }) => (
552
- <Box>
553
- <FormLabel sx={{ color: 'text.primary' }}>
554
- {t('admin.price.quantityLimitPerCheckout.label')}
555
- </FormLabel>
556
- <TextField
557
- {...field}
558
- size="small"
559
- sx={{ width: INPUT_WIDTH }}
560
- type="number"
561
- placeholder={t('admin.price.quantityLimitPerCheckout.placeholder')}
562
- error={!quantityPositive(field.value)}
563
- helperText={!quantityPositive(field.value) && t('admin.price.quantity.tip')}
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
+ )}
564
881
  />
565
- </Box>
566
- )}
567
- />
568
- <Controller
569
- name={getFieldName('nickname')}
570
- control={control}
571
- rules={{
572
- maxLength: {
573
- value: 64,
574
- message: t('common.maxLength', { len: 64 }),
575
- },
576
- }}
577
- render={({ field }) => (
578
- <Box>
579
- <FormLabel sx={{ color: 'text.primary' }}>{t('admin.price.nickname.label')}</FormLabel>
580
- <TextField {...field} size="small" sx={{ width: INPUT_WIDTH }} inputProps={{ maxLength: 64 }} />
581
- </Box>
582
- )}
583
- />
584
- <Controller
585
- name={getFieldName('lookup_key')}
586
- control={control}
587
- rules={{
588
- maxLength: {
589
- value: 64,
590
- message: t('common.maxLength', { len: 64 }),
591
- },
592
- }}
593
- render={({ field }) => (
594
- <Box>
595
- <FormLabel sx={{ color: 'text.primary' }}>{t('admin.price.lookup_key.label')}</FormLabel>
596
- <TextField {...field} size="small" sx={{ width: INPUT_WIDTH }} inputProps={{ maxLength: 64 }} />
597
- </Box>
598
- )}
599
- />
600
- </Stack>
601
- </Collapse>
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
+ />
1031
+ </Box>
1032
+ </Collapse>
1033
+ </>
1034
+ )}
1035
+ {!simple && (
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
+ },
1046
+ }}>
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
+ </>
602
1127
  )}
603
1128
  </Root>
604
1129
  );