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
@@ -3,9 +3,9 @@ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
3
3
  import Toast from '@arcblock/ux/lib/Toast';
4
4
  import { api, findCurrency, formatError, formatPrice, usePaymentContext } from '@blocklet/payment-react';
5
5
  import { AddOutlined } from '@mui/icons-material';
6
- import { Box, Button, Divider, Typography } from '@mui/material';
6
+ import { Box, Button, Divider } from '@mui/material';
7
7
  import { cloneDeep } from 'lodash';
8
- import { Fragment } from 'react';
8
+ import { Fragment, useState, useEffect } from 'react';
9
9
  import { FormProvider, useFieldArray, useForm } from 'react-hook-form';
10
10
  import { dispatch } from 'use-bus';
11
11
 
@@ -14,31 +14,145 @@ import DrawerForm from '../../../../components/drawer-form';
14
14
  import PriceActions from '../../../../components/price/actions';
15
15
  import PriceForm, { DEFAULT_PRICE } from '../../../../components/price/form';
16
16
  import ProductForm, { Product } from '../../../../components/product/form';
17
+ import { ProductsProvider } from '../../../../contexts/products';
17
18
 
18
- export default function ProductsCreate() {
19
+ type InitialPrice = {
20
+ currency_id?: string;
21
+ priceModel?: string;
22
+ name?: string;
23
+ metadata?: Record<string, any>;
24
+ };
25
+ type Props = {
26
+ open?: boolean;
27
+ onClose?: () => void;
28
+ onSubmit?: () => void;
29
+ mode?: 'standard' | 'credit';
30
+ meterId?: string;
31
+ initialPrice?: InitialPrice;
32
+ };
33
+
34
+ export default function ProductsCreate({
35
+ open = true,
36
+ onClose = () => {},
37
+ mode = 'standard',
38
+ meterId = '',
39
+ onSubmit: onSubmitCallback = () => {},
40
+ initialPrice = {} as InitialPrice,
41
+ }: Props) {
19
42
  const { t, locale } = useLocaleContext();
20
43
  const { settings } = usePaymentContext();
21
44
 
45
+ // 管理收折状态,Credit 模式下默认展开定价部分
46
+ const [expandedSection, setExpandedSection] = useState<'product' | 'pricing'>(
47
+ mode === 'credit' ? 'pricing' : 'product'
48
+ );
49
+
50
+ // Credit 模式下的默认价格配置
51
+ const getCreditDefaultPrice = () => ({
52
+ ...DEFAULT_PRICE,
53
+ currency_id: settings.baseCurrency.id,
54
+ type: 'one_time' as const,
55
+ nickname: t('admin.creditProduct.defaultNickname'),
56
+ metadata: {
57
+ ...(initialPrice?.metadata || {}),
58
+ ...(meterId ? { meter_id: meterId } : ({} as any)),
59
+ credit_config: {
60
+ priority: 50,
61
+ valid_duration_value: 0,
62
+ valid_duration_unit: 'days',
63
+ ...(initialPrice?.metadata?.credit_config || {}),
64
+ },
65
+ },
66
+ });
67
+
22
68
  const methods = useForm<Product>({
23
69
  mode: 'onChange',
24
70
  defaultValues: {
25
- type: 'service',
26
- name: '',
27
- description: '',
71
+ type: mode === 'credit' ? 'credit' : 'service',
72
+ name:
73
+ mode === 'credit'
74
+ ? t('admin.creditProduct.defaultName', {
75
+ name: initialPrice?.name || 'Credit',
76
+ })
77
+ : '',
78
+ description: mode === 'credit' ? t('admin.creditProduct.defaultDescription') : '',
28
79
  images: [],
29
80
  statement_descriptor: '',
30
- unit_label: '',
81
+ unit_label: mode === 'credit' ? t('admin.creditProduct.unitLabel') : '',
31
82
  features: [],
32
- prices: [{ ...DEFAULT_PRICE, currency_id: settings.baseCurrency.id }],
33
- metadata: [],
83
+ prices: [
84
+ mode === 'credit'
85
+ ? getCreditDefaultPrice()
86
+ : {
87
+ ...DEFAULT_PRICE,
88
+ recurring: {
89
+ ...DEFAULT_PRICE.recurring,
90
+ meter_id: meterId,
91
+ usage_type:
92
+ initialPrice?.priceModel === 'credit_metered' ? 'metered' : DEFAULT_PRICE.recurring?.usage_type,
93
+ },
94
+ unit_amount: '1',
95
+ model: initialPrice?.priceModel || 'standard',
96
+ currency_id: initialPrice?.currency_id || settings.baseCurrency.id,
97
+ },
98
+ ],
34
99
  },
35
100
  });
36
- const { control, handleSubmit, getValues, clearErrors } = methods;
101
+ const {
102
+ control,
103
+ handleSubmit,
104
+ getValues,
105
+ clearErrors,
106
+ formState: { errors },
107
+ } = methods;
37
108
 
38
109
  const prices = useFieldArray({ control, name: 'prices' });
39
110
  const getPrice = (index: number) => methods.getValues().prices[index];
40
111
 
112
+ useEffect(() => {
113
+ if (Object.keys(errors).length > 0) {
114
+ const hasProductErrors =
115
+ errors.name ||
116
+ errors.description ||
117
+ errors.images ||
118
+ errors.statement_descriptor ||
119
+ errors.unit_label ||
120
+ errors.features ||
121
+ errors.metadata;
122
+
123
+ const hasPriceErrors = errors.prices;
124
+
125
+ if (hasProductErrors) {
126
+ setExpandedSection('product');
127
+ } else if (hasPriceErrors) {
128
+ setExpandedSection('pricing');
129
+ }
130
+ }
131
+ }, [errors]);
132
+
41
133
  const onSubmit = (data: Product) => {
134
+ if (mode === 'credit') {
135
+ data.type = 'credit';
136
+ data.prices.forEach((price: any) => {
137
+ if (meterId) {
138
+ price.metadata = price.metadata || {};
139
+ const meterIdExists = price.metadata.meter_id;
140
+ if (!meterIdExists) {
141
+ price.metadata.meter_id = meterId;
142
+ }
143
+ }
144
+ });
145
+ } else {
146
+ data.prices.forEach((price: any) => {
147
+ if (price.billing_type === 'metered') {
148
+ price.type = 'recurring';
149
+ if (price.recurring) {
150
+ price.recurring.usage_type = 'metered';
151
+ }
152
+ }
153
+ });
154
+ }
155
+
42
156
  api
43
157
  .post('/api/products', data)
44
158
  .then(() => {
@@ -46,69 +160,131 @@ export default function ProductsCreate() {
46
160
  methods.reset();
47
161
  dispatch('drawer.submitted');
48
162
  dispatch('project.created');
163
+ if (onSubmitCallback) {
164
+ onSubmitCallback();
165
+ }
166
+ if (onClose) {
167
+ onClose();
168
+ }
49
169
  })
50
- .catch((err) => {
170
+ .catch((err: any) => {
51
171
  console.error(err);
52
172
  Toast.error(formatError(err));
53
173
  });
54
174
  };
55
175
 
176
+ const onSubmitError = (formErrors: any) => {
177
+ // 表单验证失败时,错误会通过 useEffect 自动处理展开逻辑
178
+ // eslint-disable-next-line no-console
179
+ console.log('Form validation errors:', formErrors);
180
+ };
181
+
182
+ const handleClose = () => {
183
+ clearErrors();
184
+ if (onClose) {
185
+ onClose();
186
+ }
187
+ };
188
+
189
+ const getDrawerTitle = () => {
190
+ if (mode === 'credit') {
191
+ return t('admin.creditProduct.create');
192
+ }
193
+ return t('admin.product.add');
194
+ };
195
+
56
196
  return (
57
197
  <DrawerForm
58
198
  icon={<AddOutlined />}
59
- text={t('admin.product.add')}
60
- onClose={() => clearErrors()}
199
+ text={getDrawerTitle()}
200
+ open={open}
201
+ onClose={handleClose}
61
202
  width={640}
62
203
  addons={
63
- <Button variant="contained" size="small" onClick={handleSubmit(onSubmit)}>
204
+ <Button variant="contained" size="small" onClick={handleSubmit(onSubmit, onSubmitError)}>
64
205
  {t('admin.product.save')}
65
206
  </Button>
66
207
  }>
67
208
  <FormProvider {...methods}>
68
- <Typography variant="h6" sx={{ mb: 3, fontWeight: 600 }}>
69
- {t('admin.product.info')}
70
- </Typography>
71
- <ProductForm />
72
- <Typography variant="h6" sx={{ mt: 5, mb: 3, fontWeight: 600 }}>
73
- {t('admin.price.info')}
74
- </Typography>
75
- <Box>
76
- {prices.fields.map((price, index) => (
77
- <Fragment key={price.id}>
78
- <Collapse
79
- expanded
80
- style={{ fontWeight: 'bold', width: '50%' }}
81
- addons={<PriceActions onDuplicate={() => prices.append(price)} onRemove={() => prices.remove(index)} />}
82
- trigger={(expanded: boolean) => {
83
- if (expanded) {
84
- return t('admin.price.detail');
85
- }
86
- const priceItem = getPrice(index);
87
- const currency =
88
- findCurrency(settings.paymentMethods, priceItem?.currency_id || '') || settings.baseCurrency;
89
-
90
- // @ts-ignore
91
- return formatPrice(priceItem as any, currency, getValues().unit_label, 1, false, locale);
209
+ <ProductsProvider>
210
+ <Collapse
211
+ expanded={expandedSection === 'product'}
212
+ value="product"
213
+ card
214
+ onChange={(_, expanded) => {
215
+ if (expanded) {
216
+ setExpandedSection('product');
217
+ }
218
+ }}
219
+ style={{ mb: 2 }}
220
+ trigger={t('admin.product.info')}>
221
+ <Box sx={{ p: 1, pl: 2 }}>
222
+ <ProductForm />
223
+ </Box>
224
+ </Collapse>
225
+
226
+ <Collapse
227
+ expanded={expandedSection === 'pricing'}
228
+ value="pricing"
229
+ onChange={(_, expanded) => {
230
+ if (expanded) {
231
+ setExpandedSection('pricing');
232
+ }
233
+ }}
234
+ card
235
+ trigger={
236
+ expandedSection === 'pricing'
237
+ ? t('admin.price.plan')
238
+ : `${t('admin.price.plan')} (${prices.fields.length} 个价格)`
239
+ }>
240
+ <Box sx={{ p: 1, pl: 2 }}>
241
+ {prices.fields.map((price, index) => (
242
+ <Fragment key={price.id}>
243
+ <Collapse
244
+ expanded
245
+ style={{ fontWeight: 'bold', width: '100%' }}
246
+ addons={
247
+ <PriceActions onDuplicate={() => prices.append(price)} onRemove={() => prices.remove(index)} />
248
+ }
249
+ trigger={(expanded: boolean) => {
250
+ if (expanded) {
251
+ return t('admin.price.order', {
252
+ order: index + 1,
253
+ });
254
+ }
255
+ const priceItem = getPrice(index);
256
+ const currency =
257
+ findCurrency(settings.paymentMethods, priceItem?.currency_id || '') || settings.baseCurrency;
258
+
259
+ // @ts-ignore
260
+ return formatPrice(priceItem as any, currency, getValues().unit_label, 1, false, locale);
261
+ }}>
262
+ <Box sx={{ width: '100%', p: 1 }}>
263
+ <PriceForm prefix={`prices.${index}`} meterId={meterId} />
264
+ </Box>
265
+ </Collapse>
266
+ <Divider sx={{ mt: 2, mb: 2 }} />
267
+ </Fragment>
268
+ ))}
269
+ <Button
270
+ variant="outlined"
271
+ color="primary"
272
+ sx={{
273
+ color: 'text.primary',
274
+ width: '100%',
275
+ }}
276
+ onClick={() => {
277
+ const newPrice =
278
+ mode === 'credit'
279
+ ? getCreditDefaultPrice()
280
+ : ({ ...cloneDeep(DEFAULT_PRICE), currency_id: settings.baseCurrency.id } as any);
281
+ prices.append(newPrice);
92
282
  }}>
93
- <PriceForm prefix={`prices.${index}`} />
94
- </Collapse>
95
- <Divider sx={{ mt: 2, mb: 4 }} />
96
- </Fragment>
97
- ))}
98
- <Box
99
- sx={{
100
- mt: 1,
101
- }}>
102
- <Button
103
- size="small"
104
- variant="outlined"
105
- color="inherit"
106
- // @ts-ignore
107
- onClick={() => prices.append({ ...cloneDeep(DEFAULT_PRICE), currency_id: settings.baseCurrency.id })}>
108
- <AddOutlined fontSize="small" /> Add another price
109
- </Button>
110
- </Box>
111
- </Box>
283
+ <AddOutlined fontSize="small" /> {t('admin.price.addAnother')}
284
+ </Button>
285
+ </Box>
286
+ </Collapse>
287
+ </ProductsProvider>
112
288
  </FormProvider>
113
289
  </DrawerForm>
114
290
  );
@@ -379,6 +379,7 @@ export default function ProductDetail(props: { id: string }) {
379
379
  <AddPrice
380
380
  loading={state.loading.price}
381
381
  onSave={onAddPrice}
382
+ productType={data.type}
382
383
  onCancel={() => setState((prev) => ({ adding: { ...prev.adding, price: false } }))}
383
384
  />
384
385
  )}
@@ -386,7 +387,7 @@ export default function ProductDetail(props: { id: string }) {
386
387
  </Box>
387
388
  <Divider />
388
389
  <Box className="section">
389
- <SectionHeader title={t('admin.events')} />
390
+ <SectionHeader title={t('admin.events.title')} />
390
391
  <Box className="section-body">
391
392
  <EventList
392
393
  features={{ toolbar: false }}
@@ -549,6 +549,9 @@ export default function PaymentMethods() {
549
549
  if (!currency) {
550
550
  return null;
551
551
  }
552
+ if (currency.type === 'credit') {
553
+ return null;
554
+ }
552
555
  return (
553
556
  <ListItem
554
557
  key={currency.id}
@@ -0,0 +1,308 @@
1
+ /* eslint-disable react/no-unstable-nested-components */
2
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
3
+ import {
4
+ api,
5
+ formatBNStr,
6
+ formatTime,
7
+ CreditTransactionsList,
8
+ CreditStatusChip,
9
+ getCustomerAvatar,
10
+ } from '@blocklet/payment-react';
11
+ import type { TCreditGrantExpanded } from '@blocklet/payment-types';
12
+ import { ArrowBackOutlined } from '@mui/icons-material';
13
+ import { Alert, Avatar, Box, CircularProgress, Divider, Stack, Typography } from '@mui/material';
14
+ import { useRequest } from 'ahooks';
15
+ import { useNavigate, useParams } from 'react-router-dom';
16
+ import { styled } from '@mui/system';
17
+ import { useCallback } from 'react';
18
+ import InfoMetric from '../../../components/info-metric';
19
+ import { useSessionContext } from '../../../contexts/session';
20
+ import SectionHeader from '../../../components/section/header';
21
+ import CreditGrantItemList from '../../../components/customer/credit-grant-item-list';
22
+ import InfoRow from '../../../components/info-row';
23
+ import InfoRowGroup from '../../../components/info-row-group';
24
+
25
+ const fetchData = (id: string | undefined): Promise<TCreditGrantExpanded> => {
26
+ return api.get(`/api/credit-grants/${id}`).then((res: any) => res.data);
27
+ };
28
+
29
+ export default function CustomerCreditGrantDetail() {
30
+ const { id } = useParams() as { id: string };
31
+ const navigate = useNavigate();
32
+ const { t } = useLocaleContext();
33
+ const { session } = useSessionContext();
34
+ const { loading, error, data } = useRequest(() => fetchData(id));
35
+
36
+ const handleBack = useCallback(() => {
37
+ navigate('/customer', { replace: true });
38
+ }, [navigate]);
39
+
40
+ if (data?.customer?.did && session?.user?.did && data.customer.did !== session.user.did) {
41
+ return <Alert severity="error">You do not have permission to access other customer data</Alert>;
42
+ }
43
+ if (error) {
44
+ return <Alert severity="error">{error.message}</Alert>;
45
+ }
46
+
47
+ if (loading || !data) {
48
+ return <CircularProgress />;
49
+ }
50
+
51
+ const isDepleted = data?.status === 'depleted';
52
+
53
+ const getStatusText = (status: string) => {
54
+ switch (status) {
55
+ case 'granted':
56
+ return t('admin.customer.creditGrants.status.granted');
57
+ case 'pending':
58
+ return t('admin.customer.creditGrants.status.pending');
59
+ case 'depleted':
60
+ return t('admin.customer.creditGrants.status.depleted');
61
+ case 'expired':
62
+ return t('admin.customer.creditGrants.status.expired');
63
+ case 'voided':
64
+ return t('admin.customer.creditGrants.status.voided');
65
+ default:
66
+ return status;
67
+ }
68
+ };
69
+
70
+ const getUsagePercentage = () => {
71
+ if (!data.amount || !data.remaining_amount) return 0;
72
+ const total = parseFloat(data.amount);
73
+ const remaining = parseFloat(data.remaining_amount);
74
+ return ((total - remaining) / total) * 100;
75
+ };
76
+
77
+ return (
78
+ <Root>
79
+ <Box>
80
+ <Stack
81
+ className="page-header"
82
+ direction="row"
83
+ justifyContent="space-between"
84
+ alignItems="center"
85
+ sx={{ position: 'relative' }}>
86
+ <Stack
87
+ direction="row"
88
+ onClick={handleBack}
89
+ alignItems="center"
90
+ sx={{ fontWeight: 'normal', cursor: 'pointer' }}>
91
+ <ArrowBackOutlined fontSize="small" sx={{ mr: 0.5, color: 'text.secondary' }} />
92
+ <Typography variant="h6" sx={{ color: 'text.secondary', fontWeight: 'normal' }}>
93
+ {t('admin.customer.creditGrants.title')}
94
+ </Typography>
95
+ </Stack>
96
+ </Stack>
97
+ <Box
98
+ mt={4}
99
+ sx={{
100
+ display: 'flex',
101
+ gap: {
102
+ xs: 2,
103
+ sm: 2,
104
+ md: 5,
105
+ },
106
+ flexWrap: 'wrap',
107
+ flexDirection: {
108
+ xs: 'column',
109
+ sm: 'column',
110
+ md: 'row',
111
+ },
112
+ alignItems: {
113
+ xs: 'flex-start',
114
+ sm: 'flex-start',
115
+ md: 'center',
116
+ },
117
+ }}>
118
+ <Stack direction="row" justifyContent="space-between" alignItems="center">
119
+ <Stack direction="row" alignItems="center" flexWrap="wrap" gap={1}>
120
+ <Stack direction="column" alignItems="flex-start" justifyContent="space-around">
121
+ <Typography variant="h2" color="text.primary">
122
+ {data.name || data.id}
123
+ </Typography>
124
+ </Stack>
125
+ </Stack>
126
+ </Stack>
127
+ <Stack
128
+ className="section-body"
129
+ justifyContent="flex-start"
130
+ flexWrap="wrap"
131
+ sx={{
132
+ 'hr.MuiDivider-root:last-child': {
133
+ display: 'none',
134
+ },
135
+ flexDirection: {
136
+ xs: 'column',
137
+ sm: 'column',
138
+ md: 'row',
139
+ },
140
+ alignItems: 'flex-start',
141
+ gap: {
142
+ xs: 1,
143
+ sm: 1,
144
+ md: 3,
145
+ },
146
+ }}>
147
+ <InfoMetric
148
+ label={t('common.status')}
149
+ value={<CreditStatusChip status={data.status} label={getStatusText(data.status)} />}
150
+ />
151
+ <InfoMetric
152
+ label={t('admin.customer.creditGrants.originalAmount')}
153
+ value={
154
+ <Stack direction="row" alignItems="center" spacing={0.5}>
155
+ <Avatar
156
+ src={data.paymentCurrency?.logo}
157
+ sx={{ width: 16, height: 16 }}
158
+ alt={data.paymentCurrency?.symbol}
159
+ />
160
+ <Typography variant="body2">
161
+ {formatBNStr(data.amount, data.paymentCurrency.decimal)} {data.paymentCurrency.symbol}
162
+ </Typography>
163
+ </Stack>
164
+ }
165
+ divider
166
+ />
167
+ <InfoMetric
168
+ label={t('common.remainingCredit')}
169
+ value={
170
+ <Stack direction="row" alignItems="center" spacing={0.5}>
171
+ <Avatar
172
+ src={data.paymentCurrency?.logo}
173
+ sx={{ width: 16, height: 16 }}
174
+ alt={data.paymentCurrency?.symbol}
175
+ />
176
+ <Typography variant="body2">
177
+ {formatBNStr(data.remaining_amount, data.paymentCurrency.decimal)} {data.paymentCurrency.symbol}
178
+ </Typography>
179
+ </Stack>
180
+ }
181
+ divider
182
+ />
183
+ <InfoMetric
184
+ label={t('admin.customer.creditGrants.usage')}
185
+ value={
186
+ <Typography variant="body2" color={isDepleted ? 'error.main' : 'text.primary'}>
187
+ {getUsagePercentage().toFixed(1)}%
188
+ </Typography>
189
+ }
190
+ divider
191
+ />
192
+ <InfoMetric
193
+ label={t('common.effectiveDate')}
194
+ value={formatTime(data.effective_at ? data.effective_at * 1000 : data.created_at, 'YYYY-MM-DD HH:mm:ss')}
195
+ />
196
+ {data.expires_at && (
197
+ <InfoMetric
198
+ label={t('common.expirationDate')}
199
+ value={formatTime(data.expires_at * 1000, 'YYYY-MM-DD HH:mm:ss')}
200
+ />
201
+ )}
202
+ </Stack>
203
+ </Box>
204
+ </Box>
205
+ <Box className="section" sx={{ containerType: 'inline-size' }}>
206
+ <SectionHeader title={t('admin.details')} />
207
+ <InfoRowGroup
208
+ sx={{
209
+ display: 'grid',
210
+ gridTemplateColumns: {
211
+ xs: 'repeat(1, 1fr)',
212
+ xl: 'repeat(2, 1fr)',
213
+ },
214
+ '@container (min-width: 1000px)': {
215
+ gridTemplateColumns: 'repeat(2, 1fr)',
216
+ },
217
+ '.info-row-wrapper': {
218
+ gap: 1,
219
+ flexDirection: {
220
+ xs: 'column',
221
+ xl: 'row',
222
+ },
223
+ alignItems: {
224
+ xs: 'flex-start',
225
+ xl: 'center',
226
+ },
227
+ '@container (min-width: 1000px)': {
228
+ flexDirection: 'row',
229
+ alignItems: 'center',
230
+ },
231
+ },
232
+ }}>
233
+ <InfoRow
234
+ label={t('common.customer')}
235
+ value={
236
+ <Stack direction="row" alignItems="center" spacing={1}>
237
+ <Avatar
238
+ src={getCustomerAvatar(
239
+ data.customer?.did,
240
+ data.customer?.updated_at ? new Date(data.customer.updated_at).toISOString() : '',
241
+ 24
242
+ )}
243
+ alt={data.customer?.name}
244
+ sx={{ width: 24, height: 24 }}
245
+ />
246
+ <Typography>{data.customer?.name}</Typography>
247
+ </Stack>
248
+ }
249
+ />
250
+ <InfoRow
251
+ label={t('common.scope')}
252
+ value={
253
+ <Typography>
254
+ {data.applicability_config?.scope?.prices ? t('common.specific') : t('common.general')}
255
+ </Typography>
256
+ }
257
+ />
258
+ <InfoRow label={t('admin.creditProduct.priority.label')} value={<Typography>{data.priority}</Typography>} />
259
+ <InfoRow label={t('common.createdAt')} value={formatTime(data.created_at)} />
260
+ </InfoRowGroup>
261
+ </Box>
262
+
263
+ <Divider />
264
+ {data.items && data.items.length > 0 && (
265
+ <>
266
+ <Box className="section">
267
+ <SectionHeader title={t('admin.creditProduct.associatedPrices.label')} />
268
+ <Box className="section-body">
269
+ <CreditGrantItemList data={data.items} currency={data.paymentCurrency} />
270
+ </Box>
271
+ </Box>
272
+ <Divider />
273
+ </>
274
+ )}
275
+
276
+ <Divider />
277
+ <Box className="section">
278
+ <Typography variant="h3" className="section-header">
279
+ {t('admin.creditTransactions.title')}
280
+ </Typography>
281
+ <Box className="section-body">
282
+ <CreditTransactionsList
283
+ customer_id={data.customer_id}
284
+ credit_grant_id={data.id}
285
+ showAdminColumns={false}
286
+ showTimeFilter
287
+ />
288
+ </Box>
289
+ </Box>
290
+ </Root>
291
+ );
292
+ }
293
+
294
+ const Root = styled(Stack)`
295
+ margin-bottom: 24px;
296
+ gap: 24px;
297
+ flex-direction: column;
298
+ @media (max-width: ${({ theme }) => theme.breakpoints.values.md}px) {
299
+ . {
300
+ border: none;
301
+ box-shadow: none;
302
+ padding: 0;
303
+ }
304
+ .section-header {
305
+ font-size: 18px;
306
+ }
307
+ }
308
+ `;