payment-kit 1.19.0 → 1.19.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/api/src/crons/index.ts +8 -0
  2. package/api/src/index.ts +4 -0
  3. package/api/src/libs/credit-grant.ts +146 -0
  4. package/api/src/libs/env.ts +1 -0
  5. package/api/src/libs/invoice.ts +4 -3
  6. package/api/src/libs/notification/template/base.ts +388 -2
  7. package/api/src/libs/notification/template/customer-credit-grant-granted.ts +149 -0
  8. package/api/src/libs/notification/template/customer-credit-grant-low-balance.ts +151 -0
  9. package/api/src/libs/notification/template/customer-credit-insufficient.ts +254 -0
  10. package/api/src/libs/notification/template/subscription-canceled.ts +193 -202
  11. package/api/src/libs/notification/template/subscription-refund-succeeded.ts +215 -237
  12. package/api/src/libs/notification/template/subscription-renewed.ts +130 -200
  13. package/api/src/libs/notification/template/subscription-succeeded.ts +100 -202
  14. package/api/src/libs/notification/template/subscription-trial-start.ts +142 -188
  15. package/api/src/libs/notification/template/subscription-trial-will-end.ts +146 -174
  16. package/api/src/libs/notification/template/subscription-upgraded.ts +96 -192
  17. package/api/src/libs/notification/template/subscription-will-canceled.ts +94 -135
  18. package/api/src/libs/notification/template/subscription-will-renew.ts +220 -245
  19. package/api/src/libs/payment.ts +69 -0
  20. package/api/src/libs/queue/index.ts +3 -2
  21. package/api/src/libs/session.ts +8 -0
  22. package/api/src/libs/subscription.ts +74 -3
  23. package/api/src/libs/util.ts +3 -1
  24. package/api/src/libs/ws.ts +23 -1
  25. package/api/src/locales/en.ts +33 -0
  26. package/api/src/locales/zh.ts +31 -0
  27. package/api/src/queues/credit-consume.ts +728 -0
  28. package/api/src/queues/credit-grant.ts +572 -0
  29. package/api/src/queues/notification.ts +173 -128
  30. package/api/src/queues/payment.ts +210 -122
  31. package/api/src/queues/subscription.ts +179 -0
  32. package/api/src/routes/checkout-sessions.ts +157 -9
  33. package/api/src/routes/connect/shared.ts +3 -2
  34. package/api/src/routes/credit-grants.ts +241 -0
  35. package/api/src/routes/credit-transactions.ts +208 -0
  36. package/api/src/routes/customers.ts +34 -5
  37. package/api/src/routes/index.ts +8 -0
  38. package/api/src/routes/meter-events.ts +347 -0
  39. package/api/src/routes/meters.ts +219 -0
  40. package/api/src/routes/payment-currencies.ts +20 -2
  41. package/api/src/routes/payment-links.ts +1 -1
  42. package/api/src/routes/payment-methods.ts +14 -2
  43. package/api/src/routes/prices.ts +43 -0
  44. package/api/src/routes/pricing-table.ts +13 -7
  45. package/api/src/routes/products.ts +63 -4
  46. package/api/src/routes/settings.ts +1 -1
  47. package/api/src/routes/subscriptions.ts +4 -0
  48. package/api/src/routes/webhook-endpoints.ts +0 -3
  49. package/api/src/store/migrations/20250610-billing-credit.ts +43 -0
  50. package/api/src/store/models/credit-grant.ts +486 -0
  51. package/api/src/store/models/credit-transaction.ts +268 -0
  52. package/api/src/store/models/customer.ts +8 -0
  53. package/api/src/store/models/index.ts +52 -1
  54. package/api/src/store/models/meter-event.ts +423 -0
  55. package/api/src/store/models/meter.ts +176 -0
  56. package/api/src/store/models/payment-currency.ts +66 -14
  57. package/api/src/store/models/price.ts +6 -0
  58. package/api/src/store/models/product.ts +2 -2
  59. package/api/src/store/models/subscription.ts +24 -0
  60. package/api/src/store/models/types.ts +28 -2
  61. package/api/tests/libs/subscription.spec.ts +53 -0
  62. package/blocklet.yml +9 -1
  63. package/package.json +4 -4
  64. package/scripts/sdk.js +233 -1
  65. package/src/app.tsx +10 -0
  66. package/src/components/collapse.tsx +11 -1
  67. package/src/components/conditional-section.tsx +87 -0
  68. package/src/components/customer/credit-grant-item-list.tsx +99 -0
  69. package/src/components/customer/credit-overview.tsx +246 -0
  70. package/src/components/customer/form.tsx +7 -3
  71. package/src/components/invoice/list.tsx +19 -1
  72. package/src/components/metadata/form.tsx +287 -91
  73. package/src/components/meter/actions.tsx +101 -0
  74. package/src/components/meter/add-usage-dialog.tsx +239 -0
  75. package/src/components/meter/events-list.tsx +657 -0
  76. package/src/components/meter/form.tsx +245 -0
  77. package/src/components/meter/products.tsx +264 -0
  78. package/src/components/meter/usage-guide.tsx +174 -0
  79. package/src/components/payment-currency/form.tsx +2 -0
  80. package/src/components/payment-intent/list.tsx +19 -1
  81. package/src/components/payment-link/item.tsx +2 -2
  82. package/src/components/payment-link/preview.tsx +1 -1
  83. package/src/components/payment-link/product-select.tsx +52 -12
  84. package/src/components/payment-method/arcblock.tsx +2 -0
  85. package/src/components/payment-method/base.tsx +2 -0
  86. package/src/components/payment-method/bitcoin.tsx +2 -0
  87. package/src/components/payment-method/ethereum.tsx +2 -0
  88. package/src/components/payment-method/stripe.tsx +2 -0
  89. package/src/components/payouts/list.tsx +19 -1
  90. package/src/components/payouts/portal/list.tsx +6 -11
  91. package/src/components/price/currency-select.tsx +56 -32
  92. package/src/components/price/form.tsx +912 -407
  93. package/src/components/pricing-table/preview.tsx +1 -1
  94. package/src/components/product/add-price.tsx +9 -7
  95. package/src/components/product/create.tsx +7 -4
  96. package/src/components/product/edit-price.tsx +21 -12
  97. package/src/components/product/features.tsx +17 -7
  98. package/src/components/product/form.tsx +100 -90
  99. package/src/components/refund/list.tsx +19 -1
  100. package/src/components/section/header.tsx +5 -18
  101. package/src/components/subscription/items/index.tsx +1 -1
  102. package/src/components/subscription/metrics.tsx +37 -5
  103. package/src/components/subscription/portal/actions.tsx +2 -1
  104. package/src/contexts/products.tsx +26 -9
  105. package/src/hooks/subscription.ts +34 -0
  106. package/src/libs/meter-utils.ts +196 -0
  107. package/src/libs/util.ts +4 -0
  108. package/src/locales/en.tsx +389 -5
  109. package/src/locales/zh.tsx +368 -1
  110. package/src/pages/admin/billing/index.tsx +61 -33
  111. package/src/pages/admin/billing/invoices/detail.tsx +1 -1
  112. package/src/pages/admin/billing/meters/create.tsx +60 -0
  113. package/src/pages/admin/billing/meters/detail.tsx +435 -0
  114. package/src/pages/admin/billing/meters/index.tsx +210 -0
  115. package/src/pages/admin/billing/meters/meter-event.tsx +346 -0
  116. package/src/pages/admin/billing/subscriptions/detail.tsx +47 -14
  117. package/src/pages/admin/customers/customers/credit-grant/detail.tsx +391 -0
  118. package/src/pages/admin/customers/customers/detail.tsx +14 -10
  119. package/src/pages/admin/customers/index.tsx +5 -0
  120. package/src/pages/admin/developers/events/detail.tsx +1 -1
  121. package/src/pages/admin/developers/index.tsx +1 -1
  122. package/src/pages/admin/payments/intents/detail.tsx +1 -1
  123. package/src/pages/admin/payments/payouts/detail.tsx +1 -1
  124. package/src/pages/admin/payments/refunds/detail.tsx +1 -1
  125. package/src/pages/admin/products/index.tsx +3 -2
  126. package/src/pages/admin/products/links/detail.tsx +1 -1
  127. package/src/pages/admin/products/prices/actions.tsx +16 -4
  128. package/src/pages/admin/products/prices/detail.tsx +30 -3
  129. package/src/pages/admin/products/prices/list.tsx +8 -1
  130. package/src/pages/admin/products/pricing-tables/detail.tsx +1 -1
  131. package/src/pages/admin/products/products/create.tsx +233 -57
  132. package/src/pages/admin/products/products/detail.tsx +2 -1
  133. package/src/pages/admin/settings/payment-methods/index.tsx +3 -0
  134. package/src/pages/customer/credit-grant/detail.tsx +308 -0
  135. package/src/pages/customer/index.tsx +44 -9
  136. package/src/pages/customer/recharge/account.tsx +5 -5
  137. package/src/pages/customer/subscription/change-payment.tsx +4 -2
  138. package/src/pages/customer/subscription/detail.tsx +48 -14
  139. package/src/pages/customer/subscription/embed.tsx +1 -1
@@ -9,7 +9,7 @@ export default function PricingTablePreview({
9
9
  id,
10
10
  version = 1,
11
11
  }: { id: string; version?: number } & {
12
- ref?: React.RefObject<unknown>;
12
+ ref?: React.RefObject<unknown | null>;
13
13
  }) {
14
14
  const innerRef = useRef(null);
15
15
  const size = useSize(innerRef);
@@ -6,16 +6,16 @@ import type { EventHandler } from 'react';
6
6
  import { FormProvider, useForm } from 'react-hook-form';
7
7
 
8
8
  import PriceForm, { DEFAULT_PRICE, Price } from '../price/form';
9
+ import { ProductsProvider } from '../../contexts/products';
9
10
 
10
- export default function AddPrice({
11
- loading,
12
- onSave,
13
- onCancel,
14
- }: {
11
+ interface AddPriceProps {
15
12
  loading: boolean;
16
13
  onSave: EventHandler<any>;
17
14
  onCancel: EventHandler<any>;
18
- }) {
15
+ productType?: string;
16
+ }
17
+
18
+ export default function AddPrice({ loading, onSave, onCancel, productType = '' }: AddPriceProps) {
19
19
  const { t } = useLocaleContext();
20
20
  const { settings } = usePaymentContext();
21
21
  const methods = useForm<Price>({
@@ -63,7 +63,9 @@ export default function AddPrice({
63
63
  </Stack>
64
64
  }>
65
65
  <FormProvider {...methods}>
66
- <PriceForm />
66
+ <ProductsProvider>
67
+ <PriceForm productType={productType} />
68
+ </ProductsProvider>
67
69
  </FormProvider>
68
70
  </Dialog>
69
71
  );
@@ -9,6 +9,7 @@ import { FormProvider, useFieldArray, useForm } from 'react-hook-form';
9
9
 
10
10
  import PriceForm, { DEFAULT_PRICE } from '../price/form';
11
11
  import ProductForm, { Product } from './form';
12
+ import { ProductsProvider } from '../../contexts/products';
12
13
 
13
14
  export default function CreateProduct({
14
15
  simple = true,
@@ -92,10 +93,12 @@ export default function CreateProduct({
92
93
  </Stack>
93
94
  }>
94
95
  <FormProvider {...methods}>
95
- <ProductForm simple={simple} />
96
- {prices.fields.map((price, index) => (
97
- <PriceForm key={price.id} prefix={`prices.${index}`} simple={simple} />
98
- ))}
96
+ <ProductsProvider>
97
+ <ProductForm simple={simple} />
98
+ {prices.fields.map((price, index) => (
99
+ <PriceForm key={price.id} prefix={`prices.${index}`} simple={simple} />
100
+ ))}
101
+ </ProductsProvider>
99
102
  </FormProvider>
100
103
  </Dialog>
101
104
  );
@@ -10,19 +10,28 @@ import { FormProvider, useForm } from 'react-hook-form';
10
10
 
11
11
  import { getPricingModel } from '../../libs/util';
12
12
  import PriceForm, { DEFAULT_PRICE, Price } from '../price/form';
13
+ import { ProductsProvider } from '../../contexts/products';
13
14
 
14
- export default function EditPrice({
15
- price,
16
- loading,
17
- onSave,
18
- onCancel,
19
- }: {
15
+ interface EditPriceProps {
20
16
  price: Price;
21
17
  loading: boolean;
22
18
  onSave: EventHandler<any>;
23
19
  onCancel: EventHandler<any>;
24
- }) {
20
+ productType?: string;
21
+ }
22
+
23
+ export default function EditPrice({ price, loading, onSave, onCancel, productType = '' }: EditPriceProps) {
25
24
  const { t } = useLocaleContext();
25
+
26
+ // 处理 metadata 的回填逻辑
27
+ const processMetadata = (metadata: any) => {
28
+ if (isEmpty(metadata)) {
29
+ return {};
30
+ }
31
+
32
+ return metadata;
33
+ };
34
+
26
35
  const methods = useForm<Price>({
27
36
  mode: 'onChange',
28
37
  defaultValues: {
@@ -30,10 +39,8 @@ export default function EditPrice({
30
39
  unit_amount: fromUnitToToken(price.unit_amount, price.currency?.decimal),
31
40
  // @ts-ignore
32
41
  model: getPricingModel(price as any),
33
- metadata: isEmpty(price.metadata)
34
- ? []
35
- : // @ts-ignore
36
- Object.keys(price.metadata).map((x) => ({ key: x, value: price.metadata[x] })),
42
+ // @ts-ignore
43
+ metadata: processMetadata(price.metadata),
37
44
  recurring: price.recurring
38
45
  ? {
39
46
  ...price.recurring,
@@ -90,7 +97,9 @@ export default function EditPrice({
90
97
  </Stack>
91
98
  }>
92
99
  <FormProvider {...methods}>
93
- <PriceForm />
100
+ <ProductsProvider>
101
+ <PriceForm productType={productType || price.product?.type} />
102
+ </ProductsProvider>
94
103
  </FormProvider>
95
104
  </Dialog>
96
105
  );
@@ -17,15 +17,12 @@ export default function MetadataForm() {
17
17
  {features.fields.map((feature, index) => (
18
18
  <Box
19
19
  key={feature.id}
20
- sx={{
21
- mt: 2,
22
- width: 1,
23
- }}>
20
+ sx={{ width: 1, gap: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 2 }}>
24
21
  <Controller
25
22
  render={({ field }) => (
26
23
  <TextField
27
24
  {...field}
28
- sx={{ width: '80%' }}
25
+ sx={{ flex: 1, width: 'calc(100% - 60px)' }}
29
26
  size="small"
30
27
  error={!!get(errors, field.name)}
31
28
  helperText={get(errors, field.name)?.message as string}
@@ -38,7 +35,15 @@ export default function MetadataForm() {
38
35
  name={`features.${index}.name`}
39
36
  control={control}
40
37
  />
41
- <IconButton size="small" sx={{ ml: 1 }} onClick={() => features.remove(index)}>
38
+ <IconButton
39
+ size="small"
40
+ onClick={() => features.remove(index)}
41
+ sx={{
42
+ border: '1px solid',
43
+ borderColor: 'grey.100',
44
+ borderRadius: 1,
45
+ padding: '8px',
46
+ }}>
42
47
  <DeleteOutlineOutlined color="error" sx={{ opacity: 0.75 }} />
43
48
  </IconButton>
44
49
  </Box>
@@ -47,7 +52,12 @@ export default function MetadataForm() {
47
52
  sx={{
48
53
  mt: features.fields.length ? 2 : 1,
49
54
  }}>
50
- <Button size="small" variant="outlined" color="inherit" onClick={() => features.append({ name: '' })}>
55
+ <Button
56
+ size="small"
57
+ variant="outlined"
58
+ color="primary"
59
+ sx={{ color: 'text.primary' }}
60
+ onClick={() => features.append({ name: '' })}>
51
61
  <AddOutlined fontSize="small" /> {t('admin.product.features.add')}
52
62
  </Button>
53
63
  </Box>
@@ -1,9 +1,9 @@
1
1
  /* eslint-disable no-nested-ternary */
2
2
  import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
3
- import { FormInput } from '@blocklet/payment-react';
3
+ import { FormInput, FormLabel } from '@blocklet/payment-react';
4
4
  import type { InferFormType, TProduct } from '@blocklet/payment-types';
5
- import { Box, Stack, Typography } from '@mui/material';
6
- import { useFormContext, useWatch } from 'react-hook-form';
5
+ import { Box, Stack, Typography, Select, MenuItem } from '@mui/material';
6
+ import { useFormContext, useWatch, Controller } from 'react-hook-form';
7
7
 
8
8
  import Collapse from '../collapse';
9
9
  import MetadataForm from '../metadata/form';
@@ -19,17 +19,11 @@ type Props = {
19
19
  simple?: boolean;
20
20
  };
21
21
 
22
- export default function ProductForm(rawProps: Props) {
23
- const props = Object.assign(
24
- {
25
- simple: false,
26
- },
27
- rawProps
28
- );
29
-
22
+ export default function ProductForm({ simple = false }: Props) {
30
23
  const { t } = useLocaleContext();
31
24
  const { control, setValue, formState } = useFormContext();
32
25
  const images = useWatch({ control, name: 'images' });
26
+ const productType = useWatch({ control, name: 'type' }) ?? '';
33
27
 
34
28
  const onUploaded = (result: any) => {
35
29
  if (result.url) {
@@ -42,97 +36,113 @@ export default function ProductForm(rawProps: Props) {
42
36
 
43
37
  return (
44
38
  <Stack
45
- spacing={2}
46
- direction="row"
39
+ spacing={3}
47
40
  sx={{
48
41
  mb: 3,
49
- alignItems: 'flex-start',
50
42
  }}>
43
+ <Controller
44
+ name="type"
45
+ control={control}
46
+ rules={{ required: true }}
47
+ render={({ field }) => (
48
+ <Box sx={{ width: '100%' }}>
49
+ <FormLabel sx={{ color: 'text.primary', fontSize: '0.875rem' }}>{t('admin.product.type.label')}</FormLabel>
50
+ <Select {...field} fullWidth size="small">
51
+ <MenuItem value="good">{t('admin.product.type.good')}</MenuItem>
52
+ <MenuItem value="service">{t('admin.product.type.service')}</MenuItem>
53
+ <MenuItem value="credit">{t('admin.product.type.credit')}</MenuItem>
54
+ </Select>
55
+ </Box>
56
+ )}
57
+ />
51
58
  <Stack
52
- spacing={2}
59
+ direction="row"
53
60
  sx={{
54
- flex: 2,
55
61
  alignItems: 'flex-start',
56
- }}>
57
- <FormInput
58
- name="name"
59
- rules={{
60
- required: t('admin.product.name.required'),
61
- maxLength: {
62
- value: 64,
63
- message: t('common.maxLength', { len: 64 }),
64
- },
65
- }}
66
- label={t('admin.product.name.label')}
67
- placeholder={t('admin.product.name.placeholder')}
68
- error={!!formState.errors.name}
69
- helperText={formState.errors.name?.message as string}
70
- autoFocus
71
- inputProps={{ maxLength: 64 }}
72
- />
73
- <FormInput
74
- name="description"
75
- rules={{
76
- required: t('admin.product.description.required'),
77
- maxLength: {
78
- value: 250,
79
- message: t('common.maxLength', { len: 250 }),
80
- },
81
- }}
82
- label={t('admin.product.description.label')}
83
- placeholder={t('admin.product.description.placeholder')}
84
- error={!!formState.errors.description}
85
- helperText={formState.errors.description?.message as string}
86
- multiline
87
- minRows={2}
88
- maxRows={4}
89
- inputProps={{ maxLength: 250 }}
90
- />
91
- <Collapse trigger={t('admin.product.additional')}>
92
- <Stack
93
- spacing={2}
94
- sx={{
95
- alignItems: 'flex-start',
96
- }}>
97
- <FormInput
98
- name="statement_descriptor"
99
- label={t('admin.product.statement_descriptor.label')}
100
- rules={{
101
- maxLength: { value: 22, message: t('common.maxLength', { len: 22 }) },
102
- pattern: {
103
- value: /^(?=.*[A-Za-z])[^\u4e00-\u9fa5<>"’\\]*$/,
104
- message: t('common.latinOnly'),
105
- },
106
- }}
107
- />
108
- <FormInput
109
- name="unit_label"
110
- label={t('admin.product.unit_label.label')}
111
- rules={{
112
- maxLength: { value: 12, message: t('common.maxLength', { len: 12 }) },
113
- }}
114
- />
115
- {/* eslint-disable-next-line react/prop-types */}
116
- {!props.simple && <ProductFeatures />}
117
- {/* eslint-disable-next-line react/prop-types */}
118
- {!props.simple && <MetadataForm title={t('common.metadata.label')} />}
119
- </Stack>
120
- </Collapse>
121
- </Stack>
122
- <Box
123
- sx={{
124
62
  flex: 1,
63
+ gap: 2,
125
64
  }}>
126
- <Stack direction="column">
127
- <Typography
128
- sx={{
129
- mb: 1,
130
- }}>
65
+ <Stack sx={{ flex: 1, gap: 2 }}>
66
+ <FormInput
67
+ name="name"
68
+ rules={{
69
+ required: t('admin.product.name.required'),
70
+ maxLength: {
71
+ value: 64,
72
+ message: t('common.maxLength', { len: 64 }),
73
+ },
74
+ }}
75
+ label={t('admin.product.name.label')}
76
+ required
77
+ placeholder={
78
+ productType === 'credit' ? t('admin.creditProduct.name.placeholder') : t('admin.product.name.placeholder')
79
+ }
80
+ error={!!formState.errors.name}
81
+ helperText={formState.errors.name?.message as string}
82
+ autoFocus
83
+ inputProps={{ maxLength: 64 }}
84
+ />
85
+
86
+ <FormInput
87
+ name="description"
88
+ rules={{
89
+ required: t('admin.product.description.required'),
90
+ maxLength: {
91
+ value: 250,
92
+ message: t('common.maxLength', { len: 250 }),
93
+ },
94
+ }}
95
+ label={t('admin.product.description.label')}
96
+ placeholder={
97
+ productType === 'credit'
98
+ ? t('admin.creditProduct.description.placeholder')
99
+ : t('admin.product.description.placeholder')
100
+ }
101
+ error={!!formState.errors.description}
102
+ helperText={formState.errors.description?.message as string}
103
+ multiline
104
+ required
105
+ minRows={2}
106
+ maxRows={4}
107
+ inputProps={{ maxLength: 250 }}
108
+ />
109
+ </Stack>
110
+
111
+ <Box sx={{ width: '100%', maxWidth: 160 }}>
112
+ <Typography variant="body2" sx={{ mb: 1, fontWeight: 500 }}>
131
113
  {t('admin.product.image.label')}
132
114
  </Typography>
133
115
  <Uploader onUploaded={onUploaded} preview={images[0]} />
116
+ </Box>
117
+ </Stack>
118
+ <Collapse trigger={t('admin.product.additional')}>
119
+ <Stack
120
+ spacing={2}
121
+ sx={{
122
+ alignItems: 'flex-start',
123
+ }}>
124
+ <FormInput
125
+ name="statement_descriptor"
126
+ label={t('admin.product.statement_descriptor.label')}
127
+ rules={{
128
+ maxLength: { value: 22, message: t('common.maxLength', { len: 22 }) },
129
+ pattern: {
130
+ value: /^(?=.*[A-Za-z])[^\u4e00-\u9fa5<>"'\\]*$/,
131
+ message: t('common.latinOnly'),
132
+ },
133
+ }}
134
+ />
135
+ <FormInput
136
+ name="unit_label"
137
+ label={t('admin.product.unit_label.label')}
138
+ rules={{
139
+ maxLength: { value: 12, message: t('common.maxLength', { len: 12 }) },
140
+ }}
141
+ />
142
+ {!simple && <ProductFeatures />}
143
+ {!simple && <MetadataForm title={t('common.metadata.label')} color="inherit" />}
134
144
  </Stack>
135
- </Box>
145
+ </Collapse>
136
146
  </Stack>
137
147
  );
138
148
  }
@@ -10,7 +10,7 @@ import {
10
10
  useDefaultPageSize,
11
11
  } from '@blocklet/payment-react';
12
12
  import type { TRefundExpanded } from '@blocklet/payment-types';
13
- import { CircularProgress, Typography } from '@mui/material';
13
+ import { Avatar, CircularProgress, Typography } from '@mui/material';
14
14
  import { useLocalStorageState } from 'ahooks';
15
15
  import { useEffect, useState } from 'react';
16
16
  import { Link } from 'react-router-dom';
@@ -143,6 +143,24 @@ export default function RefundList({
143
143
  },
144
144
  },
145
145
  },
146
+ {
147
+ label: t('common.paymentMethod'),
148
+ name: 'paymentMethod',
149
+ width: 120,
150
+ options: {
151
+ customBodyRenderLite: (_: string, index: number) => {
152
+ const item = data.list[index] as TRefundExpanded;
153
+ return (
154
+ <Link to={`/admin/payments/${item.id}`}>
155
+ <Typography sx={{ display: 'flex', alignItems: 'center', whiteSpace: 'nowrap' }}>
156
+ <Avatar src={item.paymentMethod.logo} sx={{ width: 18, height: 18, mr: 1 }} />
157
+ {item.paymentMethod.name}
158
+ </Typography>
159
+ </Link>
160
+ );
161
+ },
162
+ },
163
+ },
146
164
  {
147
165
  label: t('common.status'),
148
166
  name: 'status',
@@ -8,16 +8,7 @@ type Props = {
8
8
  mt?: number;
9
9
  };
10
10
 
11
- export default function SectionHeader(rawProps: Props) {
12
- const props = Object.assign(
13
- {
14
- children: null,
15
- mb: 1.5,
16
- mt: 1.5,
17
- },
18
- rawProps
19
- );
20
-
11
+ export default function SectionHeader({ title, children = null, mb = 1.5, mt = 1.5 }: Props) {
21
12
  return (
22
13
  <Stack
23
14
  className="section-header"
@@ -27,10 +18,8 @@ export default function SectionHeader(rawProps: Props) {
27
18
  alignItems: 'center',
28
19
  flexWrap: 'wrap',
29
20
  gap: 1,
30
- // eslint-disable-next-line react/prop-types
31
- mb: props.mb,
32
- // eslint-disable-next-line react/prop-types
33
- mt: props.mt,
21
+ mb,
22
+ mt,
34
23
  pb: 1,
35
24
  }}>
36
25
  <Typography
@@ -42,11 +31,9 @@ export default function SectionHeader(rawProps: Props) {
42
31
  },
43
32
  }}
44
33
  component="div">
45
- {/* eslint-disable-next-line react/prop-types */}
46
- {props.title}
34
+ {title}
47
35
  </Typography>
48
- {/* eslint-disable-next-line react/prop-types */}
49
- {props.children}
36
+ {children}
50
37
  </Stack>
51
38
  );
52
39
  }
@@ -109,7 +109,7 @@ export default function SubscriptionItemList({ data, currency, mode = 'customer'
109
109
  },
110
110
  },
111
111
  },
112
- {
112
+ currency.type !== 'credit' && {
113
113
  label: t('common.quantity'),
114
114
  name: 'quantity',
115
115
  options: {
@@ -1,6 +1,6 @@
1
1
  import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
2
  import { api, formatBNStr, formatTime } from '@blocklet/payment-react';
3
- import type { TSubscriptionExpanded } from '@blocklet/payment-types';
3
+ import type { TSubscriptionExpanded, CreditGrantSummary } from '@blocklet/payment-types';
4
4
  import { useRequest } from 'ahooks';
5
5
 
6
6
  import { Button, Stack, Typography, Tooltip, Avatar, Box, CircularProgress, Skeleton } from '@mui/material';
@@ -23,8 +23,21 @@ const fetchPayer = (id: string): Promise<{ token: string; paymentAddress: string
23
23
  return api.get(`/api/subscriptions/${id}/payer-token`).then((res) => res.data);
24
24
  };
25
25
 
26
+ const fetchCreditBalance = ({
27
+ customerId,
28
+ subscriptionId,
29
+ }: {
30
+ customerId: string;
31
+ subscriptionId: string;
32
+ }): Promise<{ [key: string]: CreditGrantSummary }> => {
33
+ return api
34
+ .get(`/api/credit-grants/summary?customer_id=${customerId}&subscription_id=${subscriptionId}`)
35
+ .then((res) => res.data);
36
+ };
37
+
26
38
  export default function SubscriptionMetrics({ subscription, showBalance = true }: Props) {
27
39
  const { t } = useLocaleContext();
40
+ const isCredit = subscription.paymentCurrency?.type === 'credit';
28
41
  const { data: upcoming, loading: upcomingLoading } = useRequest(() => fetchUpcoming(subscription.id));
29
42
  const navigate = useNavigate();
30
43
 
@@ -32,6 +45,17 @@ export default function SubscriptionMetrics({ subscription, showBalance = true }
32
45
  ready: showBalance,
33
46
  });
34
47
 
48
+ const { data: creditBalance } = useRequest(
49
+ () =>
50
+ fetchCreditBalance({
51
+ customerId: subscription.customer_id,
52
+ subscriptionId: subscription.id,
53
+ }),
54
+ {
55
+ ready: isCredit,
56
+ }
57
+ );
58
+
35
59
  const supportShowBalance = showBalance && ['arcblock', 'ethereum', 'base'].includes(subscription.paymentMethod.type);
36
60
  // let scheduleToCancelTime = 0;
37
61
  // if (['active', 'trialing', 'past_due'].includes(subscription.status) && subscription.cancel_at) {
@@ -41,12 +65,20 @@ export default function SubscriptionMetrics({ subscription, showBalance = true }
41
65
  // }
42
66
 
43
67
  const isInsufficientBalance = new BN(payerValue?.token || '0').lt(new BN(upcoming?.amount || '0'));
68
+
69
+ const handleRecharge = () => {
70
+ if (isCredit) {
71
+ return;
72
+ }
73
+ navigate(`/customer/subscription/${subscription.id}/recharge`);
74
+ };
44
75
  const renderBalanceValue = () => {
76
+ const balance = isCredit ? creditBalance?.[subscription.paymentCurrency.id]?.remainingAmount : payerValue?.token;
45
77
  if (upcomingLoading || payerLoading) {
46
78
  return <CircularProgress size={16} />;
47
79
  }
48
80
 
49
- if (isInsufficientBalance) {
81
+ if (isInsufficientBalance && !isCredit) {
50
82
  return (
51
83
  <Button
52
84
  component="a"
@@ -60,7 +92,6 @@ export default function SubscriptionMetrics({ subscription, showBalance = true }
60
92
 
61
93
  return (
62
94
  <Stack
63
- onClick={() => navigate(`/customer/subscription/${subscription.id}/recharge`)}
64
95
  sx={{
65
96
  flexDirection: 'row',
66
97
  alignItems: 'center',
@@ -69,7 +100,8 @@ export default function SubscriptionMetrics({ subscription, showBalance = true }
69
100
  fontWeight: 500,
70
101
  cursor: 'pointer',
71
102
  '&:hover': { color: 'primary.main' },
72
- }}>
103
+ }}
104
+ onClick={handleRecharge}>
73
105
  <Avatar
74
106
  src={subscription.paymentCurrency?.logo}
75
107
  sx={{ width: 16, height: 16 }}
@@ -80,7 +112,7 @@ export default function SubscriptionMetrics({ subscription, showBalance = true }
80
112
  display: 'flex',
81
113
  alignItems: 'baseline',
82
114
  }}>
83
- {formatBNStr(payerValue?.token, subscription.paymentCurrency.decimal)}
115
+ {formatBNStr(balance, subscription.paymentCurrency.decimal)}
84
116
  <Typography sx={{ fontSize: '14px', ml: 0.5 }}>{subscription.paymentCurrency.symbol}</Typography>
85
117
  </Box>
86
118
  </Stack>
@@ -113,7 +113,8 @@ const fetchBatchPay = async (id: string): Promise<string> => {
113
113
  const supportRecharge = (subscription: TSubscriptionExpanded) => {
114
114
  return (
115
115
  ['active', 'trialing', 'past_due'].includes(subscription?.status) &&
116
- ['arcblock', 'ethereum', 'base'].includes(subscription?.paymentMethod?.type)
116
+ ['arcblock', 'ethereum', 'base'].includes(subscription?.paymentMethod?.type) &&
117
+ subscription.paymentCurrency?.type !== 'credit'
117
118
  );
118
119
  };
119
120
 
@@ -1,28 +1,39 @@
1
- import { api } from '@blocklet/payment-react';
1
+ import { api, CachedRequest } from '@blocklet/payment-react';
2
2
  import type { TProductExpanded } from '@blocklet/payment-types';
3
3
  import { Alert, CircularProgress } from '@mui/material';
4
4
  import { useRequest } from 'ahooks';
5
5
  import { createContext, useContext, type JSX } from 'react';
6
+ import useBus from 'use-bus';
6
7
 
7
8
  type ProductsContextType = {
8
9
  products: TProductExpanded[];
9
- refresh: () => void;
10
+ refresh: (forceRefresh?: boolean) => void;
10
11
  };
11
12
 
12
13
  // @ts-ignore
13
14
  const ProductsContext = createContext<ProductsContextType>({ api });
14
15
  const { Provider, Consumer } = ProductsContext;
15
16
 
16
- const getProducts = async (): Promise<TProductExpanded[]> => {
17
- // FIXME: pagination here
18
- const { data } = await api.get('/api/products?active=true&page=1&pageSize=100&donation=hide');
19
- return data.list || [];
17
+ const fetchProducts = (forceRefresh = false): Promise<{ list: TProductExpanded[] }> => {
18
+ const livemode = localStorage.getItem('livemode') !== 'false';
19
+ const cacheKey = `products-${livemode}`;
20
+
21
+ const cachedRequest = new CachedRequest(
22
+ cacheKey,
23
+ () => api.get('/api/products?active=true&page=1&pageSize=100&donation=hide'),
24
+ {
25
+ ttl: 1000 * 60 * 10, // 10分钟缓存
26
+ strategy: 'session',
27
+ }
28
+ );
29
+
30
+ return cachedRequest.fetch(forceRefresh);
20
31
  };
21
32
 
22
33
  // eslint-disable-next-line react/prop-types
23
34
  function ProductsProvider({ children }: { children: any }): JSX.Element {
24
- const { data, error, run, loading } = useRequest(getProducts);
25
-
35
+ const { data, error, run, loading } = useRequest((forceRefresh = false) => fetchProducts(forceRefresh));
36
+ useBus('project.created', () => run(true), []);
26
37
  if (error) {
27
38
  return <Alert severity="error">{error.message}</Alert>;
28
39
  }
@@ -31,7 +42,7 @@ function ProductsProvider({ children }: { children: any }): JSX.Element {
31
42
  return <CircularProgress />;
32
43
  }
33
44
 
34
- return <Provider value={{ products: data, refresh: run }}>{children}</Provider>;
45
+ return <Provider value={{ products: data?.list ?? [], refresh: () => run(true) }}>{children}</Provider>;
35
46
  }
36
47
 
37
48
  function useProductsContext() {
@@ -39,4 +50,10 @@ function useProductsContext() {
39
50
  return context;
40
51
  }
41
52
 
53
+ export const clearProductsCache = () => {
54
+ const livemode = localStorage.getItem('livemode') !== 'false';
55
+ const cacheKey = `products-${livemode}`;
56
+ sessionStorage.removeItem(cacheKey);
57
+ };
58
+
42
59
  export { ProductsContext, ProductsProvider, Consumer as ProductsConsumer, useProductsContext };