payment-kit 1.22.31 → 1.23.0

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 (35) hide show
  1. package/api/src/index.ts +4 -0
  2. package/api/src/integrations/arcblock/token.ts +599 -0
  3. package/api/src/libs/credit-grant.ts +7 -6
  4. package/api/src/queues/credit-consume.ts +29 -4
  5. package/api/src/queues/credit-grant.ts +245 -50
  6. package/api/src/queues/credit-reconciliation.ts +253 -0
  7. package/api/src/queues/refund.ts +263 -30
  8. package/api/src/queues/token-transfer.ts +331 -0
  9. package/api/src/routes/checkout-sessions.ts +1 -1
  10. package/api/src/routes/credit-grants.ts +27 -7
  11. package/api/src/routes/credit-tokens.ts +38 -0
  12. package/api/src/routes/index.ts +2 -0
  13. package/api/src/routes/meter-events.ts +1 -1
  14. package/api/src/routes/meters.ts +32 -10
  15. package/api/src/routes/payment-currencies.ts +103 -0
  16. package/api/src/routes/products.ts +2 -2
  17. package/api/src/routes/settings.ts +4 -3
  18. package/api/src/store/migrations/20251120-add-token-config-to-currencies.ts +20 -0
  19. package/api/src/store/migrations/20251204-add-chain-fields.ts +74 -0
  20. package/api/src/store/models/credit-grant.ts +57 -10
  21. package/api/src/store/models/credit-transaction.ts +18 -1
  22. package/api/src/store/models/meter-event.ts +48 -25
  23. package/api/src/store/models/payment-currency.ts +31 -4
  24. package/api/src/store/models/refund.ts +12 -2
  25. package/api/src/store/models/types.ts +48 -0
  26. package/api/third.d.ts +2 -0
  27. package/blocklet.yml +1 -1
  28. package/package.json +7 -6
  29. package/src/components/customer/credit-overview.tsx +1 -1
  30. package/src/components/meter/form.tsx +191 -18
  31. package/src/components/price/form.tsx +49 -37
  32. package/src/locales/en.tsx +24 -0
  33. package/src/locales/zh.tsx +26 -0
  34. package/src/pages/admin/billing/meters/create.tsx +42 -13
  35. package/src/pages/admin/billing/meters/detail.tsx +56 -5
@@ -1,23 +1,54 @@
1
1
  import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
- import { FormInput, FormLabel, Collapse } from '@blocklet/payment-react';
3
- import { FormControl, Select, MenuItem, Typography, Box, FormHelperText, Stack } from '@mui/material';
4
- import { useFormContext, useWatch } from 'react-hook-form';
2
+ import { FormInput, FormLabel, Collapse, usePaymentContext } from '@blocklet/payment-react';
3
+ import {
4
+ FormControl,
5
+ Select,
6
+ MenuItem,
7
+ Typography,
8
+ Box,
9
+ FormHelperText,
10
+ Stack,
11
+ Radio,
12
+ RadioGroup,
13
+ FormControlLabel,
14
+ Switch,
15
+ Autocomplete,
16
+ TextField,
17
+ } from '@mui/material';
18
+ import { useFormContext, useWatch, Controller } from 'react-hook-form';
5
19
  import { InfoOutlined } from '@mui/icons-material';
20
+ import { useRequest } from 'ahooks';
21
+ import { useState, useMemo } from 'react';
22
+ import GraphQLClient from '@ocap/client';
6
23
 
7
- import type { TMeter } from '@blocklet/payment-types';
24
+ import type { TMeter, TPaymentCurrency } from '@blocklet/payment-types';
8
25
  import MetadataForm from '../metadata/form';
9
26
 
27
+ export type TokenConfig = { name: string; symbol: string; tokenFactoryAddress: string };
28
+
10
29
  type Props = {
11
30
  mode?: 'create' | 'edit';
31
+ tokenConfig?: TokenConfig;
32
+ onTokenConfigChange?: (next: TokenConfig) => void;
33
+ paymentCurrency?: TPaymentCurrency;
34
+ };
35
+
36
+ type TMeterForm = TMeter & {
37
+ mode?: 'OffChain' | 'OnChain';
12
38
  };
13
39
 
14
- function MeterForm({ mode = 'create' }: Props) {
40
+ function MeterForm({
41
+ mode = 'create',
42
+ tokenConfig = undefined,
43
+ onTokenConfigChange = undefined,
44
+ paymentCurrency = undefined,
45
+ }: Props) {
15
46
  const { t } = useLocaleContext();
16
47
  const {
17
48
  register,
18
49
  control,
19
50
  formState: { errors },
20
- } = useFormContext<TMeter>();
51
+ } = useFormContext<TMeterForm>();
21
52
 
22
53
  // 监听聚合方法的变化,用于显示动态帮助信息
23
54
  const aggregationMethod = useWatch({
@@ -26,20 +57,52 @@ function MeterForm({ mode = 'create' }: Props) {
26
57
  defaultValue: 'sum',
27
58
  });
28
59
 
29
- const getAggregationDescription = (method: string) => {
30
- switch (method) {
31
- case 'sum':
32
- return t('admin.meter.aggregationMethod.sumDescription');
33
- case 'count':
34
- return t('admin.meter.aggregationMethod.countDescription');
35
- case 'last':
36
- return t('admin.meter.aggregationMethod.lastDescription');
37
- default:
38
- return '';
39
- }
40
- };
60
+ const creditMode = useWatch({
61
+ control,
62
+ name: 'mode',
63
+ defaultValue: 'OffChain',
64
+ });
65
+
66
+ const [tokenType, setTokenType] = useState<'new' | 'existing'>(tokenConfig?.tokenFactoryAddress ? 'existing' : 'new');
41
67
 
42
68
  const isEditing = mode === 'edit';
69
+ const { settings } = usePaymentContext();
70
+
71
+ // Check if current currency already has token_config
72
+ const hasTokenConfig = !!paymentCurrency?.token_config;
73
+
74
+ // Get GraphQL client for arcblock chain
75
+ const arcblockClient = useMemo(() => {
76
+ const arcblockMethod = settings.paymentMethods?.find((m) => m.type === 'arcblock') as any;
77
+ const apiHost = arcblockMethod?.api_host;
78
+ return apiHost ? new GraphQLClient(apiHost) : null;
79
+ }, [settings.paymentMethods]);
80
+
81
+ // Fetch tokens created by blocklet owner
82
+ const { data: tokens = [] } = useRequest(
83
+ async () => {
84
+ if (!arcblockClient || !window.blocklet?.appId) {
85
+ return [];
86
+ }
87
+ const result = await arcblockClient.listTokens({
88
+ issuerAddress: window.blocklet.appId,
89
+ });
90
+ return result?.tokens || [];
91
+ },
92
+ {
93
+ refreshDeps: [creditMode, arcblockClient],
94
+ ready: creditMode === 'OnChain' && !hasTokenConfig && !!arcblockClient,
95
+ }
96
+ );
97
+
98
+ const getAggregationDescription = (method: string) => {
99
+ const map: Record<string, string> = {
100
+ sum: t('admin.meter.aggregationMethod.sumDescription'),
101
+ count: t('admin.meter.aggregationMethod.countDescription'),
102
+ last: t('admin.meter.aggregationMethod.lastDescription'),
103
+ };
104
+ return map[method] || '';
105
+ };
43
106
 
44
107
  return (
45
108
  <Box sx={{ maxWidth: 600 }}>
@@ -223,6 +286,116 @@ function MeterForm({ mode = 'create' }: Props) {
223
286
  </FormHelperText>
224
287
  </Box>
225
288
 
289
+ {/* On-chain token configuration */}
290
+ <Box sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
291
+ <Controller
292
+ name="mode"
293
+ control={control}
294
+ render={({ field }) => (
295
+ <Stack direction="row" justifyContent="space-between" alignItems="center">
296
+ <Box>
297
+ <Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
298
+ {t('admin.meter.creditMode.onchain')}
299
+ </Typography>
300
+ <Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
301
+ {t('admin.meter.creditMode.onChainDescription')}
302
+ </Typography>
303
+ </Box>
304
+ <Switch
305
+ checked={hasTokenConfig ? field.value !== 'OffChain' : field.value === 'OnChain'}
306
+ onChange={(e) => field.onChange(e.target.checked ? 'OnChain' : 'OffChain')}
307
+ />
308
+ </Stack>
309
+ )}
310
+ />
311
+
312
+ {(hasTokenConfig || creditMode === 'OnChain') && (
313
+ <Box sx={{ mt: 3, pt: 3, borderTop: '1px solid', borderColor: 'divider' }}>
314
+ <Typography variant="subtitle2" sx={{ mb: 2 }}>
315
+ {t('admin.meter.creditMode.tokenConfiguration')}
316
+ </Typography>
317
+ {!hasTokenConfig && (
318
+ <RadioGroup
319
+ value={tokenType}
320
+ onChange={(e) => {
321
+ setTokenType(e.target.value as any);
322
+ onTokenConfigChange?.({ name: '', symbol: '', tokenFactoryAddress: '' });
323
+ }}
324
+ row>
325
+ <FormControlLabel
326
+ value="new"
327
+ control={<Radio />}
328
+ label={t('admin.meter.creditMode.createNewToken')}
329
+ />
330
+ <FormControlLabel
331
+ value="existing"
332
+ control={<Radio />}
333
+ label={t('admin.meter.creditMode.useExistingToken')}
334
+ />
335
+ </RadioGroup>
336
+ )}
337
+ {!hasTokenConfig && tokenType === 'new' && tokenConfig && onTokenConfigChange ? (
338
+ <Box sx={{ mt: 2 }}>
339
+ <TextField
340
+ value={tokenConfig.name}
341
+ onChange={(e) => onTokenConfigChange({ ...tokenConfig, name: e.target.value })}
342
+ fullWidth
343
+ label={t('admin.meter.creditMode.tokenName')}
344
+ inputProps={{ maxLength: 64 }}
345
+ sx={{ mb: 2 }}
346
+ />
347
+ <TextField
348
+ value={tokenConfig.symbol}
349
+ onChange={(e) => onTokenConfigChange({ ...tokenConfig, symbol: e.target.value.toUpperCase() })}
350
+ fullWidth
351
+ label={t('admin.meter.creditMode.tokenSymbol')}
352
+ inputProps={{ maxLength: 6, style: { textTransform: 'uppercase', fontFamily: 'monospace' } }}
353
+ />
354
+ </Box>
355
+ ) : (
356
+ <Box sx={{ mt: 2 }}>
357
+ {hasTokenConfig && paymentCurrency?.token_config ? (
358
+ <TextField
359
+ fullWidth
360
+ label={t('admin.meter.creditMode.selectToken')}
361
+ value={`${paymentCurrency.token_config.symbol} (${paymentCurrency.token_config.name})`}
362
+ disabled
363
+ />
364
+ ) : (
365
+ <Autocomplete
366
+ options={tokens}
367
+ getOptionLabel={(o: any) => (typeof o === 'string' ? o : `${o.symbol} (${o.name})`)}
368
+ value={
369
+ tokens.find((f: any) => f.tokenFactoryAddress === tokenConfig?.tokenFactoryAddress) || null
370
+ }
371
+ onChange={(_, v: any) =>
372
+ onTokenConfigChange?.({
373
+ name: v?.name || '',
374
+ symbol: v?.symbol || '',
375
+ tokenFactoryAddress: v?.tokenFactoryAddress || '',
376
+ })
377
+ }
378
+ renderInput={(params) => (
379
+ <TextField {...params} label={t('admin.meter.creditMode.selectToken')} />
380
+ )}
381
+ renderOption={(props, o: any) => (
382
+ <li {...props}>
383
+ <Box>
384
+ <Typography variant="body1">{o.symbol}</Typography>
385
+ <Typography variant="caption" color="text.secondary">
386
+ {o.name}
387
+ </Typography>
388
+ </Box>
389
+ </li>
390
+ )}
391
+ />
392
+ )}
393
+ </Box>
394
+ )}
395
+ </Box>
396
+ )}
397
+ </Box>
398
+
226
399
  {/* Metadata Section */}
227
400
  <Collapse trigger={t('common.metadata.label')}>
228
401
  <Box sx={{ mb: 2 }}>
@@ -835,65 +835,77 @@ export default function PriceForm({ prefix = '', simple = false, productType = u
835
835
  />
836
836
 
837
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 }) => (
838
+ <Controller
839
+ name={getFieldName('metadata')}
840
+ control={control}
841
+ render={({ field }) => (
842
+ <Box sx={{ width: '100%', mb: 2 }}>
843
+ <FormLabel tooltip={t('admin.creditProduct.validDuration.help')}>
844
+ {t('admin.creditProduct.validDuration.label')}
845
+ </FormLabel>
846
+ <Stack
847
+ direction="row"
848
+ spacing={1}
849
+ sx={{
850
+ alignItems: 'center',
851
+ }}>
852
852
  <TextField
853
- {...field}
854
853
  size="small"
855
854
  type="number"
856
855
  sx={{ flex: 1 }}
857
- value={field.value ?? '0'}
856
+ value={field.value?.credit_config?.valid_duration_value ?? '0'}
858
857
  placeholder="0"
859
858
  disabled={isLocked}
859
+ onChange={(e) => {
860
+ const metadata = field.value || {};
861
+ const creditConfig = metadata.credit_config || {};
862
+ const value = +e.target.value;
863
+ if (!Number.isNaN(value) && value >= 0) {
864
+ creditConfig.valid_duration_value = +value;
865
+ } else {
866
+ delete creditConfig.valid_duration_value;
867
+ }
868
+ metadata.credit_config = creditConfig;
869
+ field.onChange(metadata);
870
+ }}
860
871
  slotProps={{
861
872
  htmlInput: {
862
873
  min: 0,
874
+ step: 0.1,
863
875
  },
864
876
  }}
865
877
  />
866
- )}
867
- />
868
- <Controller
869
- name={getFieldName('metadata.credit_config.valid_duration_unit')}
870
- control={control}
871
- render={({ field }) => (
872
878
  <Select
873
- {...field}
874
879
  size="small"
875
880
  sx={{ minWidth: 120 }}
876
881
  disabled={isLocked}
877
- value={field.value || 'days'}>
882
+ value={field.value?.credit_config?.valid_duration_unit || 'days'}
883
+ onChange={(e) => {
884
+ const metadata = field.value || {};
885
+ const creditConfig = metadata.credit_config || {};
886
+ creditConfig.valid_duration_unit = e.target.value;
887
+ metadata.credit_config = creditConfig;
888
+ field.onChange(metadata);
889
+ }}>
878
890
  {!livemode && <MenuItem value="hours">{t('admin.creditProduct.validDuration.hours')}</MenuItem>}
879
891
  <MenuItem value="days">{t('admin.creditProduct.validDuration.days')}</MenuItem>
880
892
  <MenuItem value="weeks">{t('admin.creditProduct.validDuration.weeks')}</MenuItem>
881
893
  <MenuItem value="months">{t('admin.creditProduct.validDuration.months')}</MenuItem>
882
894
  <MenuItem value="years">{t('admin.creditProduct.validDuration.years')}</MenuItem>
883
895
  </Select>
884
- )}
885
- />
886
- </Stack>
887
- <Typography
888
- variant="caption"
889
- sx={{
890
- color: 'text.secondary',
891
- display: 'block',
892
- mt: 0.5,
893
- }}>
894
- {t('admin.creditProduct.validDuration.description')}
895
- </Typography>
896
- </Box>
896
+ </Stack>
897
+ <Typography
898
+ variant="caption"
899
+ sx={{
900
+ color: 'text.secondary',
901
+ display: 'block',
902
+ mt: 0.5,
903
+ }}>
904
+ {t('admin.creditProduct.validDuration.description')}
905
+ </Typography>
906
+ </Box>
907
+ )}
908
+ />
897
909
 
898
910
  {/* 关联特定价格 */}
899
911
  <Controller
@@ -273,6 +273,30 @@ export default flat({
273
273
  edit: 'Edit meter',
274
274
  save: 'Save meter',
275
275
  saved: 'Meter successfully saved',
276
+ creditMode: {
277
+ onchain: 'OnChain',
278
+ onChainDescription: 'Once enabled, your credit will be published as a Token on the ArcBlock blockchain',
279
+ tokenConfiguration: 'Token Configuration',
280
+ createNewToken: 'Create New Token',
281
+ useExistingToken: 'Use Existing Token',
282
+ createTokenHint: 'Fill in the token name and symbol to create a new Token',
283
+ tokenName: 'Token Name',
284
+ tokenNamePlaceholder: 'Enter token name',
285
+ tokenNameRequired: 'Token name is required',
286
+ tokenSymbol: 'Token Symbol',
287
+ tokenSymbolPlaceholder: 'Enter token symbol',
288
+ tokenSymbolRequired: 'Token symbol is required',
289
+ tokenSymbolFormat: 'Symbol must be 1-6 uppercase letters or numbers',
290
+ createTokenButton: 'Create Token',
291
+ creatingToken: 'Creating...',
292
+ tokenCreated: 'Token created successfully',
293
+ tokenCreatedLabel: 'Token Created',
294
+ tokenAddress: 'Token Address',
295
+ selectToken: 'Select Token',
296
+ selectTokenPlaceholder: 'Select an existing token',
297
+ tokenAddressRequired: 'Token address is required',
298
+ enabled: 'Enabled',
299
+ },
276
300
  activate: 'Activate meter',
277
301
  activated: 'Meter activated successfully',
278
302
  deactivate: 'Deactivate meter',
@@ -283,6 +283,32 @@ export default flat({
283
283
  basicInfo: '基本信息',
284
284
  basicInfoDescription: '配置计量器的核心设置以跟踪使用事件。',
285
285
  editDescription: '更新计量器名称、描述和元数据。事件名称和聚合方法等核心设置无法更改。',
286
+
287
+ creditMode: {
288
+ onchain: 'OnChain',
289
+ onChainDescription:
290
+ '开启后你的 Credit 将作为 Token 发布在 ArcBlock 区块链上,用户的充值、退款和消费将在链上公开记录且不可篡改。',
291
+ tokenConfiguration: 'Token 配置',
292
+ createNewToken: '创建新 Token',
293
+ useExistingToken: '使用现有 Token',
294
+ createTokenHint: '填写 Token 名称和符号以创建新 Token',
295
+ tokenName: 'Token 名称',
296
+ tokenNamePlaceholder: '请输入 Token 名称',
297
+ tokenNameRequired: 'Token 名称不能为空',
298
+ tokenSymbol: 'Token 符号',
299
+ tokenSymbolPlaceholder: '请输入 Token 符号',
300
+ tokenSymbolRequired: 'Token 符号不能为空',
301
+ tokenSymbolFormat: '符号必须为 1-6 个大写字母或数字',
302
+ createTokenButton: '创建 Token',
303
+ creatingToken: '创建中...',
304
+ tokenCreated: 'Token 创建成功',
305
+ tokenCreatedLabel: 'Token 已创建',
306
+ tokenAddress: 'Token 地址',
307
+ selectToken: '选择 Token',
308
+ selectTokenPlaceholder: '请选择一个现有的 Token',
309
+ tokenAddressRequired: 'Token 地址不能为空',
310
+ enabled: '已启用',
311
+ },
286
312
  inactive: '计量器未激活',
287
313
  inactiveTip: '此计量器未收集使用数据。激活它以开始跟踪事件。',
288
314
  name: {
@@ -5,15 +5,24 @@ import { api, formatError, usePaymentContext } from '@blocklet/payment-react';
5
5
  import { useForm, FormProvider } from 'react-hook-form';
6
6
  import Toast from '@arcblock/ux/lib/Toast';
7
7
  import { dispatch } from 'use-bus';
8
+ import { useLocalStorageState } from 'ahooks';
8
9
 
9
10
  import type { TMeter } from '@blocklet/payment-types';
10
- import MeterForm from '../../../../components/meter/form';
11
+ import MeterForm, { type TokenConfig } from '../../../../components/meter/form';
11
12
  import DrawerForm from '../../../../components/drawer-form';
12
13
 
14
+ type TMeterForm = TMeter & {
15
+ mode?: 'OffChain' | 'OnChain';
16
+ tokenFactoryAddress?: string;
17
+ };
18
+
13
19
  export default function MeterCreate() {
14
20
  const { t } = useLocaleContext();
15
21
  const { refresh } = usePaymentContext();
16
- const methods = useForm<TMeter>({
22
+ const [tokenConfig, setTokenConfig] = useLocalStorageState<TokenConfig>('payment_kit_token_config', {
23
+ defaultValue: { name: '', symbol: '', tokenFactoryAddress: '' },
24
+ });
25
+ const methods = useForm<TMeterForm>({
17
26
  defaultValues: {
18
27
  name: '',
19
28
  event_name: '',
@@ -21,22 +30,38 @@ export default function MeterCreate() {
21
30
  unit: '',
22
31
  description: '',
23
32
  metadata: {},
33
+ mode: 'OffChain',
24
34
  },
25
35
  });
26
36
 
27
- const { handleSubmit, reset, clearErrors } = methods;
28
-
29
- const onSubmit = async (data: TMeter) => {
37
+ const onSubmit = async (data: TMeterForm) => {
30
38
  try {
31
- await api.post('/api/meters', data);
39
+ const submitData: any = { ...data };
40
+ delete submitData.mode;
41
+
42
+ if (data.mode === 'OnChain') {
43
+ let tokenFactoryAddress = tokenConfig?.tokenFactoryAddress;
44
+ // Create new token if no existing token selected
45
+ if (!tokenFactoryAddress && tokenConfig?.symbol) {
46
+ const { data: factory } = await api.post('/api/credit-tokens', {
47
+ name: tokenConfig.name,
48
+ symbol: tokenConfig.symbol,
49
+ });
50
+ tokenFactoryAddress = factory.address;
51
+ }
52
+ if (tokenFactoryAddress) {
53
+ submitData.token = { tokenFactoryAddress };
54
+ }
55
+ }
56
+
57
+ await api.post('/api/meters', submitData);
32
58
  Toast.success(t('admin.meter.saved'));
33
- reset();
34
- clearErrors();
59
+ setTokenConfig({ name: '', symbol: '', tokenFactoryAddress: '' });
60
+ methods.reset();
35
61
  dispatch('meter.created');
36
62
  dispatch('drawer.submitted');
37
63
  refresh(true);
38
- } catch (err) {
39
- console.error(err);
64
+ } catch (err: any) {
40
65
  Toast.error(formatError(err));
41
66
  }
42
67
  };
@@ -45,15 +70,19 @@ export default function MeterCreate() {
45
70
  <DrawerForm
46
71
  icon={<AddOutlined />}
47
72
  text={t('admin.meter.add')}
48
- onClose={() => clearErrors()}
73
+ onClose={methods.clearErrors}
49
74
  width={640}
50
75
  addons={
51
- <Button variant="contained" size="small" onClick={handleSubmit(onSubmit)}>
76
+ <Button
77
+ variant="contained"
78
+ size="small"
79
+ disabled={methods.formState.isSubmitting}
80
+ onClick={methods.handleSubmit(onSubmit as any)}>
52
81
  {t('admin.meter.save')}
53
82
  </Button>
54
83
  }>
55
84
  <FormProvider {...methods}>
56
- <MeterForm />
85
+ <MeterForm tokenConfig={tokenConfig!} onTokenConfigChange={setTokenConfig} />
57
86
  </FormProvider>
58
87
  </DrawerForm>
59
88
  );
@@ -20,7 +20,7 @@ import SectionHeader from '../../../../components/section/header';
20
20
  import { goBackOrFallback } from '../../../../libs/util';
21
21
  import MeterEventsList from '../../../../components/meter/events-list';
22
22
  import MeterProducts from '../../../../components/meter/products';
23
- import MeterForm from '../../../../components/meter/form';
23
+ import MeterForm, { type TokenConfig } from '../../../../components/meter/form';
24
24
  import DrawerForm from '../../../../components/drawer-form';
25
25
 
26
26
  const getMeter = (id: string): Promise<TMeter & { paymentCurrency: TPaymentCurrency }> => {
@@ -58,9 +58,14 @@ export default function MeterDetail(props: { id: string }) {
58
58
  product: false,
59
59
  creditProduct: false,
60
60
  },
61
+ tokenConfig: { name: '', symbol: '', tokenFactoryAddress: '' } as TokenConfig,
61
62
  });
62
63
 
63
- const methods = useForm<TMeter>({
64
+ type TMeterForm = TMeter & {
65
+ mode?: 'OffChain' | 'OnChain';
66
+ };
67
+
68
+ const methods = useForm<TMeterForm>({
64
69
  defaultValues: {
65
70
  name: '',
66
71
  event_name: '',
@@ -68,6 +73,7 @@ export default function MeterDetail(props: { id: string }) {
68
73
  unit: '',
69
74
  description: '',
70
75
  metadata: {},
76
+ mode: 'OffChain',
71
77
  },
72
78
  });
73
79
 
@@ -108,6 +114,7 @@ export default function MeterDetail(props: { id: string }) {
108
114
  };
109
115
 
110
116
  const handleEditMeter = () => {
117
+ const hasTokenConfig = !!data.paymentCurrency?.token_config;
111
118
  // 设置表单默认值
112
119
  methods.reset({
113
120
  name: data.name,
@@ -116,14 +123,53 @@ export default function MeterDetail(props: { id: string }) {
116
123
  unit: data.unit,
117
124
  description: data.description || '',
118
125
  metadata: data.metadata || {},
126
+ mode: hasTokenConfig ? 'OnChain' : 'OffChain',
119
127
  });
120
- setState((prev) => ({ editing: { ...prev.editing, meter: true } }));
128
+ setState((prev) => ({
129
+ editing: { ...prev.editing, meter: true },
130
+ tokenConfig: { name: '', symbol: '', tokenFactoryAddress: '' },
131
+ }));
121
132
  };
122
133
 
123
- const onUpdateMeter = async (formData: TMeter) => {
134
+ const onUpdateMeter = async (formData: TMeterForm) => {
124
135
  try {
125
136
  setState((prev) => ({ loading: { ...prev.loading, meter: true } }));
137
+
138
+ const currencyId = data.paymentCurrency?.id;
139
+ const hasTokenConfig = !!data.paymentCurrency?.token_config;
140
+
141
+ // If enabling on-chain and no token_config yet
142
+ if (currencyId && !hasTokenConfig && formData.mode === 'OnChain') {
143
+ let { tokenFactoryAddress } = state.tokenConfig;
144
+
145
+ // Create new token if not using existing
146
+ if (!tokenFactoryAddress && state.tokenConfig.symbol) {
147
+ const { data: factory } = await api.post('/api/credit-tokens', {
148
+ name: state.tokenConfig.name,
149
+ symbol: state.tokenConfig.symbol,
150
+ });
151
+ tokenFactoryAddress = factory.address;
152
+ }
153
+
154
+ // Update currency with token_config
155
+ if (tokenFactoryAddress) {
156
+ await api.put(`/api/payment-currencies/${currencyId}/token-config`, {
157
+ token_factory_address: tokenFactoryAddress,
158
+ });
159
+ }
160
+ }
161
+
162
+ // If disabling on-chain and has token_config, remove it
163
+ if (currencyId && hasTokenConfig && formData.mode === 'OffChain') {
164
+ await api.delete(`/api/payment-currencies/${currencyId}/token-config`);
165
+ }
166
+
167
+ // Clean up frontend-only fields before sending
168
+ delete formData.mode;
169
+
170
+ // Update meter basic info
126
171
  await api.put(`/api/meters/${props.id}`, formData);
172
+
127
173
  Toast.success(t('admin.meter.saved'));
128
174
  setState((prev) => ({ editing: { ...prev.editing, meter: false } }));
129
175
  runAsync();
@@ -442,7 +488,12 @@ export default function MeterDetail(props: { id: string }) {
442
488
  </Button>
443
489
  }>
444
490
  <FormProvider {...methods}>
445
- <MeterForm mode="edit" />
491
+ <MeterForm
492
+ mode="edit"
493
+ paymentCurrency={data.paymentCurrency}
494
+ tokenConfig={state.tokenConfig}
495
+ onTokenConfigChange={(next) => setState({ tokenConfig: next })}
496
+ />
446
497
  </FormProvider>
447
498
  </DrawerForm>
448
499
  )}