payment-kit 1.23.0 → 1.23.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.
@@ -97,6 +97,40 @@ export function createCodeGenerator(prefix: string, size: number = 24) {
97
97
  return prefix ? () => `${prefix}_${generator()}` : generator;
98
98
  }
99
99
 
100
+ export function hasObjectChanged<T extends Record<string, any>>(
101
+ updates: Partial<T>,
102
+ original: T,
103
+ options?: {
104
+ deepCompare?: string[];
105
+ }
106
+ ): boolean {
107
+ const deepFields = options?.deepCompare || [];
108
+
109
+ for (const [key, newValue] of Object.entries(updates)) {
110
+ if (newValue !== undefined) {
111
+ const oldValue = original[key];
112
+
113
+ if (deepFields.includes(key)) {
114
+ if (typeof newValue === 'object' && newValue !== null && typeof oldValue === 'object' && oldValue !== null) {
115
+ const newObj = newValue as Record<string, any>;
116
+ const oldObj = (oldValue || {}) as Record<string, any>;
117
+ for (const subKey of Object.keys(newObj)) {
118
+ if (newObj[subKey] !== oldObj[subKey]) {
119
+ return true;
120
+ }
121
+ }
122
+ } else if (newValue !== oldValue) {
123
+ return true;
124
+ }
125
+ } else if (newValue !== oldValue) {
126
+ return true;
127
+ }
128
+ }
129
+ }
130
+
131
+ return false;
132
+ }
133
+
100
134
  export function stringToStream(str: string): Readable {
101
135
  const stream = new Readable();
102
136
  stream.push(str);
@@ -54,6 +54,7 @@ import {
54
54
  getConnectQueryParam,
55
55
  getDataObjectFromQuery,
56
56
  getUserOrAppInfo,
57
+ hasObjectChanged,
57
58
  isUserInBlocklist,
58
59
  } from '../libs/util';
59
60
  import {
@@ -387,7 +388,13 @@ export async function calculateAndUpdateAmount(
387
388
 
388
389
  logger.info('Amount calculated', {
389
390
  checkoutSessionId: checkoutSession.id,
390
- amount,
391
+ amount: {
392
+ subtotal: amount.subtotal,
393
+ total: amount.total,
394
+ discount: amount.discount,
395
+ shipping: amount.shipping,
396
+ tax: amount.tax,
397
+ },
391
398
  });
392
399
 
393
400
  if (checkoutSession.mode === 'payment' && new BN(amount.total || '0').lt(new BN('0'))) {
@@ -1190,8 +1197,11 @@ export async function startCheckoutSessionFromPaymentLink(id: string, req: Reque
1190
1197
  item.upsell_price_id = item.price.upsell.upsells_to_id;
1191
1198
  }
1192
1199
  });
1193
- await doc.update({ line_items: updatedItems });
1194
- await doc.update(await getCheckoutSessionAmounts(doc));
1200
+ const amounts = await getCheckoutSessionAmounts(doc);
1201
+ await doc.update({
1202
+ line_items: updatedItems,
1203
+ ...amounts,
1204
+ });
1195
1205
  doc.line_items = await Price.expand(updatedItems, { upsell: true });
1196
1206
  }
1197
1207
 
@@ -1534,29 +1544,43 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
1534
1544
  updates.invoice_prefix = Customer.getInvoicePrefix();
1535
1545
  }
1536
1546
 
1537
- await customer.update(updates);
1538
- try {
1539
- await blocklet.updateUserAddress(
1540
- {
1541
- did: customer.did,
1542
- address: Customer.formatAddressFromCustomer(customer),
1543
- // @ts-ignore
1544
- phone: customer.phone,
1545
- },
1546
- {
1547
- headers: {
1548
- cookie: req.headers.cookie || '',
1549
- },
1550
- }
1551
- );
1552
- logger.info('updateUserAddress success', {
1547
+ if (!hasObjectChanged(updates, customer, { deepCompare: ['address'] })) {
1548
+ logger.info('customer update skipped (no changes)', {
1553
1549
  did: customer.did,
1554
1550
  });
1555
- } catch (err) {
1556
- logger.error('updateUserAddress failed', {
1557
- error: err,
1558
- customerId: customer.id,
1551
+ } else {
1552
+ await customer.update(updates);
1553
+ logger.info('customer updated', {
1554
+ did: customer.did,
1559
1555
  });
1556
+
1557
+ try {
1558
+ // eslint-disable-next-line no-console
1559
+ console.time('updateUserAddress');
1560
+ await blocklet.updateUserAddress(
1561
+ {
1562
+ did: customer.did,
1563
+ address: Customer.formatAddressFromCustomer(customer),
1564
+ // @ts-ignore
1565
+ phone: customer.phone,
1566
+ },
1567
+ {
1568
+ headers: {
1569
+ cookie: req.headers.cookie || '',
1570
+ },
1571
+ }
1572
+ );
1573
+ // eslint-disable-next-line no-console
1574
+ console.timeEnd('updateUserAddress');
1575
+ logger.info('updateUserAddress success', {
1576
+ did: customer.did,
1577
+ });
1578
+ } catch (err) {
1579
+ logger.error('updateUserAddress failed', {
1580
+ error: err,
1581
+ customerId: customer.id,
1582
+ });
1583
+ }
1560
1584
  }
1561
1585
  }
1562
1586
 
@@ -3027,10 +3051,27 @@ router.delete('/:id/remove-promotion', user, ensureCheckoutSessionOpen, async (r
3027
3051
  }
3028
3052
  });
3029
3053
 
3054
+ const amountSchema = Joi.object({
3055
+ amount: Joi.string()
3056
+ .pattern(/^\d+(\.\d+)?$/)
3057
+ .required()
3058
+ .messages({
3059
+ 'string.pattern.base': 'Amount must be a valid number',
3060
+ 'any.required': 'Amount is required',
3061
+ }),
3062
+ priceId: Joi.string().required(),
3063
+ });
3030
3064
  // change payment amount
3031
3065
  router.put('/:id/amount', ensureCheckoutSessionOpen, async (req, res) => {
3032
3066
  try {
3033
- const { amount, priceId } = req.body;
3067
+ const { error, value } = amountSchema.validate(req.body, {
3068
+ stripUnknown: true,
3069
+ });
3070
+ if (error) {
3071
+ return res.status(400).json({ error: error.message });
3072
+ }
3073
+
3074
+ const { amount, priceId } = value;
3034
3075
  const checkoutSession = req.doc as CheckoutSession;
3035
3076
  const items = await Price.expand(checkoutSession.line_items);
3036
3077
  const item = items.find((x) => x.price_id === priceId);
@@ -3090,7 +3131,7 @@ router.put('/:id/amount', ensureCheckoutSessionOpen, async (req, res) => {
3090
3131
  newItem.custom_amount = amount;
3091
3132
  }
3092
3133
  await checkoutSession.update({ line_items: newItems.map((x) => omit(x, ['price'])) as LineItem[] });
3093
- logger.info('CheckoutSession updated on amount', { id: req.params.id, ...req.body, newItem });
3134
+ logger.info('CheckoutSession updated on amount', { id: req.params.id, amount, priceId });
3094
3135
 
3095
3136
  // recalculate amount
3096
3137
  await checkoutSession.update(await getCheckoutSessionAmounts(checkoutSession));
@@ -3185,11 +3226,35 @@ router.get('/', auth, async (req, res) => {
3185
3226
  include: [],
3186
3227
  });
3187
3228
 
3188
- const condition = { where: { livemode: !!req.livemode } };
3189
- const products = (await Product.findAll(condition)).map((x) => x.toJSON());
3190
- const prices = (await Price.findAll(condition)).map((x) => x.toJSON());
3191
3229
  const docs = list.map((x) => x.toJSON());
3192
3230
 
3231
+ const productIds = new Set<string>();
3232
+ const priceIds = new Set<string>();
3233
+ docs.forEach((x) => {
3234
+ x.line_items?.forEach((item: any) => {
3235
+ if (item.price_id) {
3236
+ priceIds.add(item.price_id);
3237
+ }
3238
+ if (item.product_id) {
3239
+ productIds.add(item.product_id);
3240
+ }
3241
+ });
3242
+ });
3243
+
3244
+ const condition = { where: { livemode: !!req.livemode } };
3245
+ const products =
3246
+ productIds.size > 0
3247
+ ? (await Product.findAll({ ...condition, where: { ...condition.where, id: Array.from(productIds) } })).map(
3248
+ (x) => x.toJSON()
3249
+ )
3250
+ : [];
3251
+ const prices =
3252
+ priceIds.size > 0
3253
+ ? (await Price.findAll({ ...condition, where: { ...condition.where, id: Array.from(priceIds) } })).map((x) =>
3254
+ x.toJSON()
3255
+ )
3256
+ : [];
3257
+
3193
3258
  docs.forEach((x) => {
3194
3259
  // @ts-ignore
3195
3260
  expandLineItems(x.line_items, products, prices);
@@ -355,19 +355,25 @@ router.get('/verify-availability', authMine, async (req, res) => {
355
355
  });
356
356
 
357
357
  router.get('/:id', authPortal, async (req, res) => {
358
- const creditGrant = await CreditGrant.findByPk(req.params.id, {
358
+ const creditGrant = (await CreditGrant.findByPk(req.params.id, {
359
359
  include: [
360
360
  { model: Customer, as: 'customer' },
361
361
  { model: PaymentCurrency, as: 'paymentCurrency' },
362
362
  ],
363
- });
363
+ })) as CreditGrant & { paymentCurrency?: PaymentCurrency };
364
364
  if (!creditGrant) {
365
365
  return res.status(404).json({ error: `Credit grant ${req.params.id} not found` });
366
366
  }
367
+
368
+ let paymentMethod = null;
369
+ if (creditGrant.paymentCurrency) {
370
+ paymentMethod = await PaymentMethod.findByPk(creditGrant.paymentCurrency.payment_method_id);
371
+ }
367
372
  const expandedPrices = await expandScopePrices(creditGrant);
368
373
  return res.json({
369
374
  ...creditGrant.toJSON(),
370
375
  items: expandedPrices,
376
+ paymentMethod,
371
377
  });
372
378
  });
373
379
 
@@ -14,6 +14,8 @@ import {
14
14
  MeterEvent,
15
15
  Subscription,
16
16
  PaymentCurrency,
17
+ PaymentMethod,
18
+ TCreditTransactionExpanded,
17
19
  } from '../store/models';
18
20
 
19
21
  const router = Router();
@@ -303,7 +305,7 @@ router.get('/summary', authMine, async (req, res) => {
303
305
 
304
306
  router.get('/:id', authPortal, async (req, res) => {
305
307
  try {
306
- const transaction = await CreditTransaction.findByPk(req.params.id, {
308
+ const transaction = (await CreditTransaction.findByPk(req.params.id, {
307
309
  include: [
308
310
  {
309
311
  model: Customer,
@@ -339,14 +341,29 @@ router.get('/:id', authPortal, async (req, res) => {
339
341
  ],
340
342
  required: false,
341
343
  },
344
+ {
345
+ model: MeterEvent,
346
+ as: 'meterEvent',
347
+ attributes: ['id', 'source_data'],
348
+ required: false,
349
+ },
342
350
  ],
343
- });
351
+ })) as CreditTransaction &
352
+ TCreditTransactionExpanded & { creditGrant?: CreditGrant & { paymentCurrency?: PaymentCurrency } };
344
353
 
345
354
  if (!transaction) {
346
355
  return res.status(404).json({ error: 'Credit transaction not found' });
347
356
  }
348
357
 
349
- return res.json(transaction);
358
+ let paymentMethod = null;
359
+ if (transaction.creditGrant?.paymentCurrency?.payment_method_id) {
360
+ paymentMethod = await PaymentMethod.findByPk(transaction.creditGrant.paymentCurrency.payment_method_id);
361
+ }
362
+
363
+ return res.json({
364
+ ...transaction.toJSON(),
365
+ paymentMethod,
366
+ });
350
367
  } catch (err) {
351
368
  logger.error('get credit transaction failed', err);
352
369
  return res.status(400).json({ error: err.message });
@@ -335,7 +335,11 @@ router.get('/pending-amount', authMine, async (req, res) => {
335
335
  }
336
336
  params.customerId = customer.id;
337
337
  }
338
+ // eslint-disable-next-line no-console
339
+ console.time('pending-amount: getPendingAmounts');
338
340
  const [summary] = await MeterEvent.getPendingAmounts(params);
341
+ // eslint-disable-next-line no-console
342
+ console.timeEnd('pending-amount: getPendingAmounts');
339
343
  return res.json(summary);
340
344
  } catch (err) {
341
345
  logger.error('Error getting meter event pending amount', err);
@@ -260,10 +260,12 @@ router.get('/', auth, async (req, res) => {
260
260
  const priceIds: string[] = list.reduce((acc: string[], x) => acc.concat(x.line_items.map((i) => i.price_id)), []);
261
261
  const prices = await Price.findAll({ where: { id: priceIds }, include: [{ model: Product, as: 'product' }] });
262
262
 
263
+ const priceMap = new Map(prices.map((p) => [p.id, p]));
264
+
263
265
  list.forEach((x) => {
264
266
  x.line_items.forEach((i) => {
265
267
  // @ts-ignore
266
- i.price = prices.find((p) => p.id === i.price_id);
268
+ i.price = priceMap.get(i.price_id);
267
269
  });
268
270
  });
269
271
 
@@ -0,0 +1,33 @@
1
+ /* eslint-disable no-console */
2
+ import { createIndexIfNotExists, type Migration } from '../migrate';
3
+
4
+ export const up: Migration = async ({ context }) => {
5
+ await createIndexIfNotExists(
6
+ context,
7
+ 'meter_events',
8
+ ['livemode', 'event_name', 'timestamp'],
9
+ 'idx_meter_events_livemode_event_timestamp'
10
+ );
11
+
12
+ await createIndexIfNotExists(
13
+ context,
14
+ 'payment_currencies',
15
+ ['is_base_currency', 'livemode'],
16
+ 'idx_payment_currencies_base_livemode'
17
+ );
18
+
19
+ await createIndexIfNotExists(context, 'jobs', ['queue', 'id'], 'idx_jobs_queue_id');
20
+ await createIndexIfNotExists(
21
+ context,
22
+ 'jobs',
23
+ ['queue', 'cancelled', 'will_run_at', 'delay'],
24
+ 'idx_jobs_queue_cancelled_run_at_delay'
25
+ );
26
+ };
27
+
28
+ export const down: Migration = async ({ context }) => {
29
+ await context.removeIndex('meter_events', 'idx_meter_events_livemode_event_timestamp');
30
+ await context.removeIndex('payment_currencies', 'idx_payment_currencies_base_livemode');
31
+ await context.removeIndex('jobs', 'idx_jobs_queue_id');
32
+ await context.removeIndex('jobs', 'idx_jobs_queue_cancelled_run_at_delay');
33
+ };
@@ -305,9 +305,10 @@ export type TCreditTransactionExpanded = TCreditTransaction & {
305
305
  customer: TCustomer;
306
306
  paymentCurrency: TPaymentCurrency;
307
307
  paymentMethod?: TPaymentMethod;
308
- creditGrant: TCreditGrant;
308
+ creditGrant: TCreditGrant & { paymentCurrency?: TPaymentCurrency };
309
309
  meter: TMeter;
310
310
  subscription: TSubscription;
311
+ meterEvent?: TMeterEvent;
311
312
  };
312
313
 
313
314
  export type TMeterEventExpanded = TMeterEvent & {
@@ -17,6 +17,7 @@ Sequelize.useCLS(namespace);
17
17
  export const sequelize = new Sequelize({
18
18
  dialect: 'sqlite',
19
19
  logging: process.env.SQL_LOG === '1',
20
+ benchmark: process.env.SQL_LOG === '1' && process.env.SQL_BENCHMARK === '1',
20
21
  storage: join(env.dataDir, 'payment-kit.db'),
21
22
  pool: {
22
23
  min: sequelizeOptionsPoolMin,
package/blocklet.yml CHANGED
@@ -14,7 +14,7 @@ repository:
14
14
  type: git
15
15
  url: git+https://github.com/blocklet/payment-kit.git
16
16
  specVersion: 1.2.8
17
- version: 1.23.0
17
+ version: 1.23.1
18
18
  logo: logo.png
19
19
  files:
20
20
  - dist
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "payment-kit",
3
- "version": "1.23.0",
3
+ "version": "1.23.1",
4
4
  "scripts": {
5
5
  "dev": "blocklet dev --open",
6
6
  "lint": "tsc --noEmit && eslint src api/src --ext .mjs,.js,.jsx,.ts,.tsx",
@@ -58,9 +58,9 @@
58
58
  "@blocklet/error": "^0.3.4",
59
59
  "@blocklet/js-sdk": "^1.17.4",
60
60
  "@blocklet/logger": "^1.17.4",
61
- "@blocklet/payment-broker-client": "1.23.0",
62
- "@blocklet/payment-react": "1.23.0",
63
- "@blocklet/payment-vendor": "1.23.0",
61
+ "@blocklet/payment-broker-client": "1.23.1",
62
+ "@blocklet/payment-react": "1.23.1",
63
+ "@blocklet/payment-vendor": "1.23.1",
64
64
  "@blocklet/sdk": "^1.17.4",
65
65
  "@blocklet/ui-react": "^3.2.11",
66
66
  "@blocklet/uploader": "^0.3.14",
@@ -130,7 +130,7 @@
130
130
  "devDependencies": {
131
131
  "@abtnode/types": "^1.17.4",
132
132
  "@arcblock/eslint-config-ts": "^0.3.3",
133
- "@blocklet/payment-types": "1.23.0",
133
+ "@blocklet/payment-types": "1.23.1",
134
134
  "@types/cookie-parser": "^1.4.9",
135
135
  "@types/cors": "^2.8.19",
136
136
  "@types/debug": "^4.1.12",
@@ -177,5 +177,5 @@
177
177
  "parser": "typescript"
178
178
  }
179
179
  },
180
- "gitHead": "d791ace5b095ff78f4b7328dac8a9575e6b2e265"
180
+ "gitHead": "393e83d16adecbe9aa9d145732c45b2198a2e8b9"
181
181
  }
package/src/app.tsx CHANGED
@@ -28,6 +28,7 @@ const CustomerSubscriptionEmbed = React.lazy(() => import('./pages/customer/subs
28
28
  const CustomerSubscriptionChangePlan = React.lazy(() => import('./pages/customer/subscription/change-plan'));
29
29
  const CustomerSubscriptionChangePayment = React.lazy(() => import('./pages/customer/subscription/change-payment'));
30
30
  const CustomerCreditGrantDetail = React.lazy(() => import('./pages/customer/credit-grant/detail'));
31
+ const CustomerCreditTransactionDetail = React.lazy(() => import('./pages/customer/credit-transaction/detail'));
31
32
  const CustomerRecharge = React.lazy(() => import('./pages/customer/recharge/subscription'));
32
33
  const CustomerPayoutDetail = React.lazy(() => import('./pages/customer/payout/detail'));
33
34
  const IntegrationsPage = React.lazy(() => import('./pages/integrations'));
@@ -161,6 +162,15 @@ function App() {
161
162
  </UserLayout>
162
163
  }
163
164
  />
165
+ <Route
166
+ key="customer-credit-transaction"
167
+ path="/customer/credit-transaction/:id"
168
+ element={
169
+ <UserLayout>
170
+ <CustomerCreditTransactionDetail />
171
+ </UserLayout>
172
+ }
173
+ />
164
174
  <Route key="customer-fallback" path="/customer/*" element={<Navigate to="/customer" />} />,
165
175
  <Route path="*" element={<Navigate to="/" />} />
166
176
  </Routes>
@@ -136,7 +136,7 @@ export default function CreditOverview({ customerId, settings, mode = 'portal' }
136
136
  const grantData = creditSummary?.grants?.[currencyId];
137
137
  const pendingAmount = creditSummary?.pendingAmount?.[currencyId] || '0';
138
138
 
139
- if (!grantData) {
139
+ if (!grantData || grantData.status === 'inactive') {
140
140
  return null;
141
141
  }
142
142
 
@@ -145,6 +145,8 @@ export default function CreditOverview({ customerId, settings, mode = 'portal' }
145
145
  const totalAmount = grantData.totalAmount || '0';
146
146
  const remainingAmount = grantData.remainingAmount || '0';
147
147
 
148
+ const cardTitle = grantData.meter?.name || currency.name;
149
+
148
150
  return (
149
151
  <Card
150
152
  key={currency.id}
@@ -173,7 +175,7 @@ export default function CreditOverview({ customerId, settings, mode = 'portal' }
173
175
  pb: 2,
174
176
  }}>
175
177
  <Typography variant="h6" component="div">
176
- {grantData.meter?.name || currency.name} ({currency.symbol})
178
+ {cardTitle}
177
179
  </Typography>
178
180
  {showRecharge && (
179
181
  <SplitButton
@@ -217,7 +219,15 @@ export default function CreditOverview({ customerId, settings, mode = 'portal' }
217
219
  {totalAmount === '0' && remainingAmount === '0' ? (
218
220
  <>0 </>
219
221
  ) : (
220
- <>{formatBNStr(remainingAmount, currency.decimal, 6, true)}</>
222
+ <>
223
+ {formatBNStr(remainingAmount, currency.decimal, 6, true)}
224
+ {currency.symbol !== cardTitle && (
225
+ <Typography variant="body2" component="span">
226
+ {' '}
227
+ {currency.symbol}
228
+ </Typography>
229
+ )}
230
+ </>
221
231
  )}
222
232
  </Typography>
223
233
  </Box>
@@ -239,6 +249,12 @@ export default function CreditOverview({ customerId, settings, mode = 'portal' }
239
249
  color: 'error.main',
240
250
  }}>
241
251
  {formatBNStr(pendingAmount, currency.decimal, 6, true)}
252
+ {currency.symbol !== cardTitle && (
253
+ <Typography variant="body2" component="span">
254
+ {' '}
255
+ {currency.symbol}
256
+ </Typography>
257
+ )}
242
258
  </Typography>
243
259
  </Box>
244
260
  )}
@@ -317,7 +317,7 @@ export default flat({
317
317
  label: 'Meter name',
318
318
  required: 'Meter name is required',
319
319
  placeholder: 'API requests',
320
- help: 'A descriptive name for this meter that will be displayed in your dashboard.',
320
+ help: "A descriptive name for this meter that will be displayed in your dashboard and user's personal bill.",
321
321
  },
322
322
  eventName: {
323
323
  label: 'Event name',
@@ -315,7 +315,7 @@ export default flat({
315
315
  label: '计量器名称',
316
316
  required: '计量器名称为必填项',
317
317
  placeholder: 'API 请求',
318
- help: '此计量器的描述性名称,将在您的仪表板中显示。',
318
+ help: '此计量器的描述性名称,将在您的仪表板以及用户个人账单中显示。',
319
319
  },
320
320
  eventName: {
321
321
  label: '事件名称',
@@ -9,6 +9,7 @@ import {
9
9
  CreditTransactionsList,
10
10
  CreditStatusChip,
11
11
  getCustomerAvatar,
12
+ TxLink,
12
13
  } from '@blocklet/payment-react';
13
14
  import type { TCreditGrantExpanded } from '@blocklet/payment-types';
14
15
  import { ArrowBackOutlined } from '@mui/icons-material';
@@ -325,6 +326,18 @@ export default function AdminCreditGrantDetail({ id }: { id: string }) {
325
326
  {data.expires_at && (
326
327
  <InfoRow label={t('common.expirationDate')} value={formatTime(data.expires_at * 1000)} />
327
328
  )}
329
+ {data.chain_detail?.mint?.hash && data.paymentMethod?.type === 'arcblock' && (
330
+ <InfoRow
331
+ label={t('common.mintTxHash')}
332
+ value={
333
+ <TxLink
334
+ details={{ arcblock: { tx_hash: data.chain_detail.mint.hash, payer: '' } }}
335
+ method={data.paymentMethod}
336
+ mode="dashboard"
337
+ />
338
+ }
339
+ />
340
+ )}
328
341
  </InfoRowGroup>
329
342
  </Box>
330
343
 
@@ -0,0 +1,324 @@
1
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
+ import { api, formatBNStr, formatError, getCustomerAvatar, TxLink, SourceDataViewer } from '@blocklet/payment-react';
3
+ import type { TCreditTransactionExpanded } from '@blocklet/payment-types';
4
+ import { ArrowBackOutlined } from '@mui/icons-material';
5
+ import { Alert, Avatar, Box, Button, Chip, CircularProgress, Divider, Stack, Typography } from '@mui/material';
6
+ import { useRequest } from 'ahooks';
7
+ import { styled } from '@mui/system';
8
+ import { useCallback } from 'react';
9
+ import { useNavigate } from 'react-router-dom';
10
+
11
+ import InfoMetric from '../../../../../components/info-metric';
12
+ import InfoRow from '../../../../../components/info-row';
13
+ import InfoRowGroup from '../../../../../components/info-row-group';
14
+ import Copyable from '../../../../../components/copyable';
15
+ import SectionHeader from '../../../../../components/section/header';
16
+ import MetadataList from '../../../../../components/metadata/list';
17
+ import { goBackOrFallback } from '../../../../../libs/util';
18
+ import EventList from '../../../../../components/event/list';
19
+
20
+ const fetchData = (id: string | undefined): Promise<TCreditTransactionExpanded> => {
21
+ return api.get(`/api/credit-transactions/${id}`).then((res) => res.data);
22
+ };
23
+
24
+ export default function AdminCreditTransactionDetail({ id }: { id: string }) {
25
+ const { t } = useLocaleContext();
26
+ const navigate = useNavigate();
27
+
28
+ const { loading, error, data } = useRequest(() => fetchData(id));
29
+
30
+ const handleBack = useCallback(() => {
31
+ goBackOrFallback('/admin/customers');
32
+ }, []);
33
+
34
+ if (error) {
35
+ return <Alert severity="error">{formatError(error)}</Alert>;
36
+ }
37
+
38
+ if (loading || !data) {
39
+ return <CircularProgress />;
40
+ }
41
+
42
+ const getTransferStatusChip = (status: string | null | undefined) => {
43
+ if (!status) return null;
44
+
45
+ const statusConfig = {
46
+ pending: { label: t('common.pending'), color: 'warning' as const },
47
+ completed: { label: t('common.completed'), color: 'success' as const },
48
+ failed: { label: t('common.failed'), color: 'error' as const },
49
+ };
50
+
51
+ const config = statusConfig[status as keyof typeof statusConfig] || {
52
+ label: status,
53
+ color: 'default' as const,
54
+ };
55
+
56
+ return <Chip label={config.label} size="small" color={config.color} />;
57
+ };
58
+
59
+ return (
60
+ <Root direction="column" spacing={2.5} sx={{ mb: 4 }}>
61
+ <Box>
62
+ <Stack className="page-header" direction="row" justifyContent="space-between" alignItems="center">
63
+ <Stack
64
+ direction="row"
65
+ alignItems="center"
66
+ sx={{ fontWeight: 'normal', cursor: 'pointer' }}
67
+ onClick={handleBack}>
68
+ <ArrowBackOutlined fontSize="small" sx={{ mr: 0.5, color: 'text.secondary' }} />
69
+ <Typography variant="h6" sx={{ color: 'text.secondary', fontWeight: 'normal' }}>
70
+ {t('admin.creditTransactions.title')}
71
+ </Typography>
72
+ </Stack>
73
+ <Stack direction="row" spacing={1}>
74
+ {data.creditGrant && (
75
+ <Button
76
+ variant="outlined"
77
+ size="small"
78
+ onClick={() => navigate(`/admin/customers/${data.credit_grant_id}`)}>
79
+ {t('common.viewGrant')}
80
+ </Button>
81
+ )}
82
+ {data.subscription && (
83
+ <Button
84
+ variant="outlined"
85
+ size="small"
86
+ onClick={() => navigate(`/admin/billing/${data.subscription_id}`)}>
87
+ {t('common.viewSubscription')}
88
+ </Button>
89
+ )}
90
+ </Stack>
91
+ </Stack>
92
+
93
+ <Box
94
+ mt={4}
95
+ mb={3}
96
+ sx={{
97
+ display: 'flex',
98
+ gap: {
99
+ xs: 2,
100
+ sm: 2,
101
+ md: 5,
102
+ },
103
+ flexWrap: 'wrap',
104
+ flexDirection: {
105
+ xs: 'column',
106
+ sm: 'column',
107
+ md: 'row',
108
+ },
109
+ alignItems: {
110
+ xs: 'flex-start',
111
+ sm: 'flex-start',
112
+ md: 'center',
113
+ },
114
+ }}>
115
+ <Stack direction="row" justifyContent="space-between" alignItems="center">
116
+ <Stack direction="column" spacing={0.5}>
117
+ <Typography variant="h2" sx={{ fontWeight: 600 }}>
118
+ {data.description || t('common.creditTransaction')}
119
+ </Typography>
120
+ <Copyable text={data.id} />
121
+ </Stack>
122
+ </Stack>
123
+
124
+ <Stack
125
+ className="section-body"
126
+ justifyContent="flex-start"
127
+ flexWrap="wrap"
128
+ sx={{
129
+ 'hr.MuiDivider-root:last-child': {
130
+ display: 'none',
131
+ },
132
+ flexDirection: {
133
+ xs: 'column',
134
+ sm: 'column',
135
+ md: 'row',
136
+ },
137
+ alignItems: 'flex-start',
138
+ gap: {
139
+ xs: 1,
140
+ sm: 1,
141
+ md: 3,
142
+ },
143
+ }}>
144
+ <InfoMetric
145
+ label={t('common.creditAmount')}
146
+ value={
147
+ <Stack direction="row" alignItems="center" spacing={0.5}>
148
+ <Typography variant="body2" sx={{ color: 'error.main' }}>
149
+ -{formatBNStr(data.credit_amount, data.creditGrant?.paymentCurrency?.decimal || 0)}{' '}
150
+ {data.creditGrant?.paymentCurrency?.symbol}
151
+ </Typography>
152
+ </Stack>
153
+ }
154
+ divider={!!data.transfer_status}
155
+ />
156
+ {data.transfer_status && (
157
+ <InfoMetric label={t('common.transferStatus')} value={getTransferStatusChip(data.transfer_status)} />
158
+ )}
159
+ </Stack>
160
+ </Box>
161
+ <Divider />
162
+ </Box>
163
+
164
+ <Stack
165
+ sx={{
166
+ flexDirection: {
167
+ xs: 'column',
168
+ lg: 'row',
169
+ },
170
+ gap: {
171
+ xs: 2.5,
172
+ md: 4,
173
+ },
174
+ '.transaction-column-1': {
175
+ minWidth: {
176
+ xs: '100%',
177
+ lg: '600px',
178
+ },
179
+ },
180
+ '.transaction-column-2': {
181
+ width: {
182
+ xs: '100%',
183
+ md: '100%',
184
+ lg: '320px',
185
+ },
186
+ maxWidth: {
187
+ xs: '100%',
188
+ md: '33%',
189
+ },
190
+ },
191
+ }}>
192
+ <Box flex={1} className="transaction-column-1" sx={{ gap: 2.5, display: 'flex', flexDirection: 'column' }}>
193
+ <Box className="section" sx={{ containerType: 'inline-size' }}>
194
+ <SectionHeader title={t('admin.details')} />
195
+ <InfoRowGroup
196
+ sx={{
197
+ display: 'grid',
198
+ gridTemplateColumns: {
199
+ xs: 'repeat(1, 1fr)',
200
+ xl: 'repeat(2, 1fr)',
201
+ },
202
+ '@container (min-width: 1000px)': {
203
+ gridTemplateColumns: 'repeat(2, 1fr)',
204
+ },
205
+ '.info-row-wrapper': {
206
+ gap: 1,
207
+ flexDirection: {
208
+ xs: 'column',
209
+ xl: 'row',
210
+ },
211
+ alignItems: {
212
+ xs: 'flex-start',
213
+ xl: 'center',
214
+ },
215
+ '@container (min-width: 1000px)': {
216
+ flexDirection: 'row',
217
+ alignItems: 'center',
218
+ },
219
+ },
220
+ }}>
221
+ <InfoRow
222
+ label={t('common.customer')}
223
+ value={
224
+ <Stack direction="row" alignItems="center" spacing={1}>
225
+ <Avatar
226
+ src={getCustomerAvatar(
227
+ data.customer?.did,
228
+ data.customer?.updated_at ? new Date(data.customer.updated_at).toISOString() : '',
229
+ 24
230
+ )}
231
+ alt={data.customer?.name}
232
+ sx={{ width: 24, height: 24 }}
233
+ />
234
+ <Typography>{data.customer?.name}</Typography>
235
+ </Stack>
236
+ }
237
+ />
238
+ <InfoRow
239
+ label={t('common.creditGrant')}
240
+ value={
241
+ <Typography
242
+ component="span"
243
+ onClick={() => navigate(`/admin/customers/${data.credit_grant_id}`)}
244
+ sx={{ color: 'text.link', cursor: 'pointer' }}>
245
+ {data.creditGrant?.name || data.credit_grant_id}
246
+ </Typography>
247
+ }
248
+ />
249
+ {data.meter && (
250
+ <InfoRow
251
+ label={t('common.meterEvent')}
252
+ value={
253
+ <Typography
254
+ component="span"
255
+ onClick={() => navigate(`/admin/billing/${data.meter_id}`)}
256
+ sx={{ color: 'text.link', cursor: 'pointer' }}>
257
+ {data.meter.name || data.meter.event_name}
258
+ </Typography>
259
+ }
260
+ />
261
+ )}
262
+ {data.subscription && (
263
+ <InfoRow
264
+ label={t('admin.subscription.name')}
265
+ value={
266
+ <Typography
267
+ component="span"
268
+ onClick={() => navigate(`/admin/billing/${data.subscription_id}`)}
269
+ sx={{ color: 'text.link', cursor: 'pointer' }}>
270
+ {data.subscription.description || data.subscription_id}
271
+ </Typography>
272
+ }
273
+ />
274
+ )}
275
+ {data.transfer_hash && data.paymentMethod && (
276
+ <InfoRow
277
+ label={t('common.transferTxHash')}
278
+ value={
279
+ <TxLink
280
+ details={{ arcblock: { tx_hash: data.transfer_hash, payer: '' } }}
281
+ method={data.paymentMethod}
282
+ mode="dashboard"
283
+ />
284
+ }
285
+ />
286
+ )}
287
+ </InfoRowGroup>
288
+ </Box>
289
+
290
+ {data.meterEvent?.source_data && (
291
+ <>
292
+ <Divider />
293
+ <Box className="section">
294
+ <SectionHeader title={t('common.sourceData')} />
295
+ <Box className="section-body">
296
+ <SourceDataViewer data={data.meterEvent.source_data} />
297
+ </Box>
298
+ </Box>
299
+ </>
300
+ )}
301
+
302
+ <Divider />
303
+ <Box className="section">
304
+ <SectionHeader title={t('admin.events.title')} />
305
+ <Box className="section-body">
306
+ <EventList features={{ toolbar: false }} object_id={data.id} />
307
+ </Box>
308
+ </Box>
309
+ </Box>
310
+
311
+ <Box className="transaction-column-2" sx={{ gap: 2.5, display: 'flex', flexDirection: 'column' }}>
312
+ <Box className="section">
313
+ <SectionHeader title={t('common.metadata.label')} />
314
+ <Box className="section-body">
315
+ <MetadataList data={data.metadata} />
316
+ </Box>
317
+ </Box>
318
+ </Box>
319
+ </Stack>
320
+ </Root>
321
+ );
322
+ }
323
+
324
+ const Root = styled(Stack)``;
@@ -7,6 +7,7 @@ import { useTransitionContext } from '../../../components/progress-bar';
7
7
 
8
8
  const CustomerDetail = React.lazy(() => import('./customers/detail'));
9
9
  const CreditGrantDetail = React.lazy(() => import('./customers/credit-grant/detail'));
10
+ const CreditTransactionDetail = React.lazy(() => import('./customers/credit-transaction/detail'));
10
11
 
11
12
  const pages = {
12
13
  overview: React.lazy(() => import('./customers')),
@@ -26,6 +27,10 @@ export default function CustomerIndex() {
26
27
  return <CreditGrantDetail id={page} />;
27
28
  }
28
29
 
30
+ if (page.startsWith('cbtxn_')) {
31
+ return <CreditTransactionDetail id={page} />;
32
+ }
33
+
29
34
  const onTabChange = (newTab: string) => {
30
35
  startTransition(() => {
31
36
  navigate(`/admin/customers/${newTab}`);
@@ -7,6 +7,7 @@ import {
7
7
  CreditTransactionsList,
8
8
  CreditStatusChip,
9
9
  getCustomerAvatar,
10
+ TxLink,
10
11
  } from '@blocklet/payment-react';
11
12
  import type { TCreditGrantExpanded } from '@blocklet/payment-types';
12
13
  import { ArrowBackOutlined } from '@mui/icons-material';
@@ -40,7 +41,7 @@ export default function CustomerCreditGrantDetail() {
40
41
  }, [navigate]);
41
42
 
42
43
  if (data?.customer?.did && session?.user?.did && data.customer.did !== session.user.did) {
43
- return <Alert severity="error">You do not have permission to access other customer data</Alert>;
44
+ return <Alert severity="error">{t('common.accessDenied')}</Alert>;
44
45
  }
45
46
  if (error) {
46
47
  return <Alert severity="error">{error.message}</Alert>;
@@ -271,6 +272,18 @@ export default function CustomerCreditGrantDetail() {
271
272
  />
272
273
  <InfoRow label={t('admin.creditProduct.priority.label')} value={<Typography>{data.priority}</Typography>} />
273
274
  <InfoRow label={t('common.createdAt')} value={formatTime(data.created_at)} />
275
+ {data.chain_detail?.mint?.hash && data.paymentMethod?.type === 'arcblock' && (
276
+ <InfoRow
277
+ label={t('common.mintTxHash')}
278
+ value={
279
+ <TxLink
280
+ details={{ arcblock: { tx_hash: data.chain_detail.mint.hash, payer: '' } }}
281
+ method={data.paymentMethod}
282
+ mode="customer"
283
+ />
284
+ }
285
+ />
286
+ )}
274
287
  </InfoRowGroup>
275
288
  </Box>
276
289
 
@@ -0,0 +1,289 @@
1
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
+ import { api, formatBNStr, formatTime, getCustomerAvatar, TxLink, SourceDataViewer } from '@blocklet/payment-react';
3
+ import type { TCreditTransactionExpanded } from '@blocklet/payment-types';
4
+ import { ArrowBackOutlined } from '@mui/icons-material';
5
+ import { Alert, Avatar, Box, Button, Chip, CircularProgress, Divider, Stack, Typography } from '@mui/material';
6
+ import { useRequest } from 'ahooks';
7
+ import { useNavigate, useParams } from 'react-router-dom';
8
+ import { styled } from '@mui/system';
9
+ import { useCallback } from 'react';
10
+ import InfoMetric from '../../../components/info-metric';
11
+ import { useSessionContext } from '../../../contexts/session';
12
+ import SectionHeader from '../../../components/section/header';
13
+ import InfoRow from '../../../components/info-row';
14
+ import InfoRowGroup from '../../../components/info-row-group';
15
+ import { useArcsphere } from '../../../hooks/browser';
16
+
17
+ const fetchData = (id: string | undefined): Promise<TCreditTransactionExpanded> => {
18
+ return api.get(`/api/credit-transactions/${id}`).then((res: any) => res.data);
19
+ };
20
+
21
+ export default function CustomerCreditTransactionDetail() {
22
+ const { id } = useParams() as { id: string };
23
+ const navigate = useNavigate();
24
+ const { t } = useLocaleContext();
25
+ const { session } = useSessionContext();
26
+ const inArcsphere = useArcsphere();
27
+ const { loading, error, data } = useRequest(() => fetchData(id));
28
+
29
+ const handleBack = useCallback(() => {
30
+ navigate('/customer', { replace: true });
31
+ }, [navigate]);
32
+
33
+ if (data?.customer?.did && session?.user?.did && data.customer.did !== session.user.did) {
34
+ return <Alert severity="error">{t('common.accessDenied')}</Alert>;
35
+ }
36
+
37
+ if (error) {
38
+ return <Alert severity="error">{error.message}</Alert>;
39
+ }
40
+
41
+ if (loading || !data) {
42
+ return <CircularProgress />;
43
+ }
44
+
45
+ const getTransferStatusChip = (status: string | null | undefined) => {
46
+ if (!status) return null;
47
+
48
+ const statusConfig = {
49
+ pending: { label: t('common.pending'), color: 'warning' as const },
50
+ completed: { label: t('common.completed'), color: 'success' as const },
51
+ failed: { label: t('common.failed'), color: 'error' as const },
52
+ };
53
+
54
+ const config = statusConfig[status as keyof typeof statusConfig] || {
55
+ label: status,
56
+ color: 'default' as const,
57
+ };
58
+
59
+ return <Chip label={config.label} size="small" color={config.color} />;
60
+ };
61
+
62
+ return (
63
+ <Root>
64
+ <Box>
65
+ <Stack
66
+ className="page-header"
67
+ direction="row"
68
+ justifyContent="space-between"
69
+ alignItems="center"
70
+ sx={{ position: 'relative' }}>
71
+ {!inArcsphere ? (
72
+ <Stack
73
+ direction="row"
74
+ onClick={handleBack}
75
+ alignItems="center"
76
+ sx={{ fontWeight: 'normal', cursor: 'pointer' }}>
77
+ <ArrowBackOutlined fontSize="small" sx={{ mr: 0.5, color: 'text.secondary' }} />
78
+ <Typography variant="h6" sx={{ color: 'text.secondary', fontWeight: 'normal' }}>
79
+ {t('admin.creditTransactions.title')}
80
+ </Typography>
81
+ </Stack>
82
+ ) : (
83
+ <Box />
84
+ )}
85
+ <Stack direction="row" spacing={1}>
86
+ {data.creditGrant && (
87
+ <Button
88
+ variant="outlined"
89
+ size="small"
90
+ onClick={() => navigate(`/customer/credit-grant/${data.credit_grant_id}`)}>
91
+ {t('common.viewGrant')}
92
+ </Button>
93
+ )}
94
+ {data.subscription && (
95
+ <Button
96
+ variant="outlined"
97
+ size="small"
98
+ onClick={() => navigate(`/customer/subscription/${data.subscription_id}`)}>
99
+ {t('common.viewSubscription')}
100
+ </Button>
101
+ )}
102
+ </Stack>
103
+ </Stack>
104
+
105
+ <Box
106
+ mt={4}
107
+ sx={{
108
+ display: 'flex',
109
+ gap: {
110
+ xs: 2,
111
+ sm: 2,
112
+ md: 5,
113
+ },
114
+ flexWrap: 'wrap',
115
+ flexDirection: {
116
+ xs: 'column',
117
+ sm: 'column',
118
+ md: 'row',
119
+ },
120
+ alignItems: {
121
+ xs: 'flex-start',
122
+ sm: 'flex-start',
123
+ md: 'center',
124
+ },
125
+ }}>
126
+ <Stack direction="row" justifyContent="space-between" alignItems="center">
127
+ <Stack direction="column" alignItems="flex-start" justifyContent="space-around">
128
+ <Typography variant="h2" color="text.primary">
129
+ {data.description || t('common.creditTransaction')}
130
+ </Typography>
131
+ <Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
132
+ {data.id}
133
+ </Typography>
134
+ </Stack>
135
+ </Stack>
136
+
137
+ <Stack
138
+ className="section-body"
139
+ justifyContent="flex-start"
140
+ flexWrap="wrap"
141
+ sx={{
142
+ 'hr.MuiDivider-root:last-child': {
143
+ display: 'none',
144
+ },
145
+ flexDirection: {
146
+ xs: 'column',
147
+ sm: 'column',
148
+ md: 'row',
149
+ },
150
+ alignItems: 'flex-start',
151
+ gap: {
152
+ xs: 1,
153
+ sm: 1,
154
+ md: 3,
155
+ },
156
+ }}>
157
+ <InfoMetric
158
+ label={t('common.creditAmount')}
159
+ value={
160
+ <Stack direction="row" alignItems="center" spacing={0.5}>
161
+ <Typography variant="body2" sx={{ color: 'error.main' }}>
162
+ -{formatBNStr(data.credit_amount, data.creditGrant?.paymentCurrency?.decimal || 0)}{' '}
163
+ {data.creditGrant?.paymentCurrency?.symbol}
164
+ </Typography>
165
+ </Stack>
166
+ }
167
+ divider
168
+ />
169
+ {data.transfer_status && (
170
+ <InfoMetric
171
+ label={t('common.transferStatus')}
172
+ value={getTransferStatusChip(data.transfer_status)}
173
+ divider
174
+ />
175
+ )}
176
+ <InfoMetric label={t('common.createdAt')} value={formatTime(data.created_at)} />
177
+ </Stack>
178
+ </Box>
179
+ </Box>
180
+
181
+ <Box className="section" sx={{ containerType: 'inline-size' }}>
182
+ <SectionHeader title={t('admin.details')} />
183
+ <InfoRowGroup
184
+ sx={{
185
+ display: 'grid',
186
+ gridTemplateColumns: {
187
+ xs: 'repeat(1, 1fr)',
188
+ xl: 'repeat(2, 1fr)',
189
+ },
190
+ '@container (min-width: 1000px)': {
191
+ gridTemplateColumns: 'repeat(2, 1fr)',
192
+ },
193
+ '.info-row-wrapper': {
194
+ gap: 1,
195
+ flexDirection: {
196
+ xs: 'column',
197
+ xl: 'row',
198
+ },
199
+ alignItems: {
200
+ xs: 'flex-start',
201
+ xl: 'center',
202
+ },
203
+ '@container (min-width: 1000px)': {
204
+ flexDirection: 'row',
205
+ alignItems: 'center',
206
+ },
207
+ },
208
+ }}>
209
+ <InfoRow
210
+ label={t('common.customer')}
211
+ value={
212
+ <Stack direction="row" alignItems="center" spacing={1}>
213
+ <Avatar
214
+ src={getCustomerAvatar(
215
+ data.customer?.did,
216
+ data.customer?.updated_at ? new Date(data.customer.updated_at).toISOString() : '',
217
+ 24
218
+ )}
219
+ alt={data.customer?.name}
220
+ sx={{ width: 24, height: 24 }}
221
+ />
222
+ <Typography>{data.customer?.name}</Typography>
223
+ </Stack>
224
+ }
225
+ />
226
+ <InfoRow
227
+ label={t('common.creditGrant')}
228
+ value={
229
+ <Typography
230
+ component="span"
231
+ onClick={() => navigate(`/customer/credit-grant/${data.credit_grant_id}`)}
232
+ sx={{ color: 'text.link', cursor: 'pointer' }}>
233
+ {data.creditGrant?.name || data.credit_grant_id}
234
+ </Typography>
235
+ }
236
+ />
237
+ {data.subscription && (
238
+ <InfoRow
239
+ label={t('admin.subscription.name')}
240
+ value={
241
+ <Typography
242
+ component="span"
243
+ onClick={() => navigate(`/customer/subscription/${data.subscription_id}`)}
244
+ sx={{ color: 'text.link', cursor: 'pointer' }}>
245
+ {data.subscription.description || data.subscription_id}
246
+ </Typography>
247
+ }
248
+ />
249
+ )}
250
+ {data.transfer_hash && data.paymentMethod && (
251
+ <InfoRow
252
+ label={t('common.transferTxHash')}
253
+ value={
254
+ <TxLink
255
+ details={{ arcblock: { tx_hash: data.transfer_hash, payer: '' } }}
256
+ method={data.paymentMethod}
257
+ mode="customer"
258
+ />
259
+ }
260
+ />
261
+ )}
262
+ </InfoRowGroup>
263
+ </Box>
264
+
265
+ {data.meterEvent?.source_data && (
266
+ <>
267
+ <Divider />
268
+ <Box className="section">
269
+ <SectionHeader title={t('common.sourceData')} />
270
+ <Box className="section-body">
271
+ <SourceDataViewer data={data.meterEvent.source_data} />
272
+ </Box>
273
+ </Box>
274
+ </>
275
+ )}
276
+ </Root>
277
+ );
278
+ }
279
+
280
+ const Root = styled(Stack)`
281
+ margin-bottom: 24px;
282
+ gap: 24px;
283
+ flex-direction: column;
284
+ @media (max-width: ${({ theme }) => theme.breakpoints.values.md}px) {
285
+ .section-header {
286
+ font-size: 18px;
287
+ }
288
+ }
289
+ `;
@@ -122,7 +122,7 @@ export default function CustomerInvoiceDetail() {
122
122
  }, [error]);
123
123
 
124
124
  if (data?.customer?.did && session?.user?.did && data.customer.did !== session.user.did) {
125
- return <Alert severity="error">You do not have permission to access other customer data</Alert>;
125
+ return <Alert severity="error">{t('common.accessDenied')}</Alert>;
126
126
  }
127
127
 
128
128
  if (error) {
@@ -228,7 +228,7 @@ export default function RechargePage() {
228
228
  }
229
229
 
230
230
  if (subscription?.customer?.did && session?.user?.did && subscription.customer.did !== session.user.did) {
231
- return <Alert severity="error">You do not have permission to access other customer data</Alert>;
231
+ return <Alert severity="error">{t('common.accessDenied')}</Alert>;
232
232
  }
233
233
  const currentBalance = formatBNStr(payerValue?.token || '0', paymentCurrency?.decimal, 6, false);
234
234
 
@@ -119,7 +119,7 @@ export default function CustomerSubscriptionDetail() {
119
119
  }, []);
120
120
 
121
121
  if (data?.customer?.did && session?.user?.did && data.customer.did !== session.user.did) {
122
- return <Alert severity="error">You do not have permission to access other customer data</Alert>;
122
+ return <Alert severity="error">{t('common.accessDenied')}</Alert>;
123
123
  }
124
124
  if (error) {
125
125
  return <Alert severity="error">{error.message}</Alert>;