payment-kit 1.24.2 → 1.24.4

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,7 @@ export default flat({
97
97
  creditGrant: '信用额度',
98
98
  date: '日期',
99
99
  subscription: '订阅',
100
+ meter: '计量器',
100
101
  meterEvent: '计量事件',
101
102
  creditAmount: '额度',
102
103
  createdAt: '创建时间',
@@ -180,6 +181,14 @@ export default flat({
180
181
  transaction: '交易指标',
181
182
  essential: '关键指标',
182
183
  },
184
+ overdue: {
185
+ title: '欠费额度',
186
+ subtitle: '待支付的额度',
187
+ customers: '{count} 位用户',
188
+ events: '{count} 笔欠费记录',
189
+ viewAll: '查看全部',
190
+ noOverdue: '暂无欠费',
191
+ },
183
192
  },
184
193
  payments: '支付管理',
185
194
  connections: '连接',
@@ -267,6 +276,13 @@ export default flat({
267
276
  meterEvents: {
268
277
  title: '计量事件',
269
278
  },
279
+ overdue: {
280
+ title: '欠费额度',
281
+ pendingAmount: '欠费额度',
282
+ eventCount: '欠费笔数',
283
+ noOverdue: '暂无欠费',
284
+ selectCurrency: '币种',
285
+ },
270
286
  meter: {
271
287
  add: '添加计量器',
272
288
  edit: '编辑计量器',
@@ -479,6 +495,7 @@ export default flat({
479
495
  },
480
496
  meterEvent: {
481
497
  title: '计量事件详情',
498
+ id: '事件ID',
482
499
  totalEvents: '总事件数:{count}',
483
500
  noEvents: '暂无事件',
484
501
  noEventsHint: '您可以在测试模式下手动添加测试事件。',
@@ -489,6 +506,9 @@ export default flat({
489
506
  subscription: '订阅',
490
507
  creditConsumed: '消耗额度',
491
508
  usageValue: '使用量',
509
+ settlementAmount: '结算额度',
510
+ reportedAmount: '上报额度',
511
+ overdueAmount: '欠费额度',
492
512
  reportedAt: '上报时间',
493
513
  processedAt: '处理时间',
494
514
  eventIdentifier: '事件标识符',
@@ -16,6 +16,8 @@ const pages = {
16
16
  invoices: React.lazy(() => import('./invoices')),
17
17
  subscriptions: React.lazy(() => import('./subscriptions')),
18
18
  meters: React.lazy(() => import('./meters')),
19
+ 'meter-events': React.lazy(() => import('./meter-events')),
20
+ overdue: React.lazy(() => import('./overdue')),
19
21
  };
20
22
 
21
23
  export default function BillingIndex() {
@@ -52,6 +54,8 @@ export default function BillingIndex() {
52
54
  { label: t('admin.invoices'), value: 'invoices' },
53
55
  { label: t('admin.subscriptions'), value: 'subscriptions' },
54
56
  { label: t('admin.meters'), value: 'meters' },
57
+ { label: t('admin.meterEvents.title'), value: 'meter-events' },
58
+ { label: t('admin.overdue.title'), value: 'overdue' },
55
59
  ];
56
60
 
57
61
  let extra = null;
@@ -0,0 +1,588 @@
1
+ /* eslint-disable react/no-unstable-nested-components */
2
+ import { getDurableData } from '@arcblock/ux/lib/Datatable';
3
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
4
+ import { api, formatTime, Table, formatBNStr, getCustomerAvatar } from '@blocklet/payment-react';
5
+ import type { TCustomer, TMeter, TMeterEventExpanded } from '@blocklet/payment-types';
6
+ import {
7
+ CircularProgress,
8
+ Chip,
9
+ Box,
10
+ Button,
11
+ Menu,
12
+ MenuItem,
13
+ TextField,
14
+ styled,
15
+ Checkbox,
16
+ ListItemText,
17
+ } from '@mui/material';
18
+ import { useEffect, useMemo, useState } from 'react';
19
+ import { Link, useSearchParams } from 'react-router-dom';
20
+ import { Add, Close } from '@mui/icons-material';
21
+
22
+ import { useRequest } from 'ahooks';
23
+ import CustomerLink from '../../../../components/customer/link';
24
+ import InfoCard from '../../../../components/info-card';
25
+
26
+ const fetchData = (params: Record<string, any> = {}): Promise<{ list: TMeterEventExpanded[]; count: number }> => {
27
+ const search = new URLSearchParams();
28
+ Object.keys(params).forEach((key) => {
29
+ const v = params[key];
30
+ if (v !== undefined && v !== null && v !== '') {
31
+ if (key === 'q') {
32
+ const queryStr = Object.entries(v)
33
+ .map((x) => x.join(':'))
34
+ .join(' ');
35
+ search.set(key, queryStr);
36
+ } else {
37
+ search.set(key, String(v));
38
+ }
39
+ }
40
+ });
41
+ return api.get(`/api/meter-events?${search.toString()}`).then((res) => res.data);
42
+ };
43
+
44
+ const fetchCustomers = (params: Record<string, any> = {}): Promise<{ list: TCustomer[]; count: number }> => {
45
+ const search = new URLSearchParams();
46
+ Object.keys(params).forEach((key) => {
47
+ let v = params[key];
48
+ if (key === 'q') {
49
+ v = Object.entries(v)
50
+ .map((x) => x.join(':'))
51
+ .join(' ');
52
+ }
53
+ search.set(key, String(v));
54
+ });
55
+ return api.get(`/api/customers?${search.toString()}`).then((res) => res.data);
56
+ };
57
+
58
+ const fetchMeters = (): Promise<{ list: TMeter[]; count: number }> => {
59
+ return api.get('/api/meters?pageSize=100').then((res) => res.data);
60
+ };
61
+
62
+ type SearchProps = {
63
+ status: string;
64
+ customer_id: string;
65
+ meter_id: string;
66
+ pageSize: number;
67
+ page: number;
68
+ q?: any;
69
+ o?: string;
70
+ };
71
+
72
+ const parseSearchParams = (searchParamsString: string) => {
73
+ if (!searchParamsString) {
74
+ return {
75
+ customer_id: '',
76
+ status: '',
77
+ meter_id: '',
78
+ };
79
+ }
80
+
81
+ const params = new URLSearchParams(searchParamsString);
82
+ return {
83
+ customer_id: params.get('customer_id') || '',
84
+ status: params.get('status') || '',
85
+ meter_id: params.get('meter_id') || '',
86
+ };
87
+ };
88
+
89
+ export default function MeterEventsList() {
90
+ const listKey = 'meter-events';
91
+ const persisted = getDurableData(listKey);
92
+
93
+ const { t } = useLocaleContext();
94
+ const [searchParams] = useSearchParams();
95
+ const searchParamsString = searchParams.toString();
96
+ const [search, setSearch] = useState<SearchProps>(() => {
97
+ const initialFilters = parseSearchParams(searchParamsString);
98
+ return {
99
+ status: initialFilters.status,
100
+ customer_id: initialFilters.customer_id,
101
+ meter_id: initialFilters.meter_id,
102
+ pageSize: persisted.rowsPerPage || 20,
103
+ page: persisted.page ? persisted.page + 1 : 1,
104
+ };
105
+ });
106
+
107
+ const [data, setData] = useState<{ list: TMeterEventExpanded[]; count: number } | null>(null);
108
+ const [loading, setLoading] = useState(true);
109
+ const [meters, setMeters] = useState<TMeter[]>([]);
110
+
111
+ useEffect(() => {
112
+ const { customer_id: customerId, status, meter_id: meterId } = parseSearchParams(searchParamsString);
113
+
114
+ if (!customerId && !status && !meterId) return;
115
+
116
+ setSearch((prev: any) => ({
117
+ ...prev,
118
+ customer_id: customerId,
119
+ status,
120
+ meter_id: meterId,
121
+ page: 1,
122
+ }));
123
+ }, [searchParamsString]);
124
+
125
+ useEffect(() => {
126
+ setLoading(true);
127
+ fetchData(search)
128
+ .then((res) => setData(res))
129
+ .finally(() => setLoading(false));
130
+ }, [search]);
131
+
132
+ useEffect(() => {
133
+ fetchMeters()
134
+ .then((res) => setMeters(res.list))
135
+ .catch(() => setMeters([]));
136
+ }, []);
137
+
138
+ const meterByEventName = useMemo(() => {
139
+ return new Map(meters.map((meter) => [meter.event_name, meter]));
140
+ }, [meters]);
141
+
142
+ if (loading && !data) {
143
+ return <CircularProgress />;
144
+ }
145
+
146
+ const getStatusColor = (status: string) => {
147
+ switch (status) {
148
+ case 'pending':
149
+ return 'info';
150
+ case 'requires_action':
151
+ return 'warning';
152
+ case 'requires_capture':
153
+ return 'warning';
154
+ case 'completed':
155
+ return 'success';
156
+ case 'processing':
157
+ return 'info';
158
+ case 'canceled':
159
+ return 'default';
160
+ default:
161
+ return 'default';
162
+ }
163
+ };
164
+
165
+ const handleSearchChange = (updates: Partial<SearchProps>) => {
166
+ setSearch((prev: any) => ({
167
+ ...prev,
168
+ ...updates,
169
+ page: 1,
170
+ }));
171
+ };
172
+
173
+ const columns = [
174
+ {
175
+ label: t('common.customer'),
176
+ name: 'customer',
177
+ options: {
178
+ filter: false,
179
+ customBodyRenderLite: (_: string, index: number) => {
180
+ const item = data?.list[index] as TMeterEventExpanded;
181
+ return item.customer ? (
182
+ <Link to={`/admin/customers/${item.customer.id}`}>
183
+ <CustomerLink customer={item.customer} size="small" />
184
+ </Link>
185
+ ) : (
186
+ '-'
187
+ );
188
+ },
189
+ },
190
+ },
191
+ {
192
+ label: t('common.meter'),
193
+ name: 'event_name',
194
+ options: {
195
+ filter: false,
196
+ customBodyRenderLite: (_: string, index: number) => {
197
+ const item = data?.list[index] as TMeterEventExpanded | undefined;
198
+ if (!item) {
199
+ return '-';
200
+ }
201
+
202
+ const meter = item.meter || meterByEventName.get(item.event_name);
203
+ if (!meter) {
204
+ return item.event_name || '-';
205
+ }
206
+
207
+ return <Link to={`/admin/billing/${meter.id}`}>{item.event_name}</Link>;
208
+ },
209
+ },
210
+ },
211
+ {
212
+ label: t('admin.meterEvent.settlementAmount'),
213
+ name: 'settlement_amount',
214
+ options: {
215
+ filter: false,
216
+ customBodyRenderLite: (_: string, index: number) => {
217
+ const item = data?.list[index] as TMeterEventExpanded;
218
+ // payload.decimal 是存储在数据中的 decimal 值,更准确
219
+ const reportedDecimal = (item.payload as any)?.decimal ?? item.paymentCurrency?.decimal ?? 18;
220
+ const reportedValue = item.payload?.value;
221
+ const consumedValue = item.credit_consumed;
222
+ const hasReported = reportedValue !== undefined && reportedValue !== null && reportedValue !== '';
223
+ const hasConsumed = consumedValue !== undefined && consumedValue !== null && consumedValue !== '';
224
+
225
+ if (!hasReported && !hasConsumed) {
226
+ return <Link to={`/admin/billing/${item.id}`}>-</Link>;
227
+ }
228
+
229
+ const formattedReported = hasReported ? formatBNStr(reportedValue, reportedDecimal) : '-';
230
+ const consumedDecimal = item.paymentCurrency?.decimal ?? reportedDecimal;
231
+ const formattedConsumed = hasConsumed ? formatBNStr(consumedValue, consumedDecimal) : '-';
232
+ const symbol = item.paymentCurrency?.symbol || '';
233
+ const isUnsettled = Boolean(item.credit_pending && item.credit_pending !== '0');
234
+
235
+ return (
236
+ <Link to={`/admin/billing/${item.id}`}>
237
+ <Box
238
+ component="span"
239
+ sx={{
240
+ color: isUnsettled ? 'warning.main' : 'text.primary',
241
+ fontWeight: isUnsettled ? 600 : 400,
242
+ }}>
243
+ {formattedConsumed} / {formattedReported}
244
+ {symbol ? ` ${symbol}` : ''}
245
+ </Box>
246
+ </Link>
247
+ );
248
+ },
249
+ },
250
+ },
251
+ {
252
+ label: t('common.status'),
253
+ name: 'status',
254
+ options: {
255
+ filter: false,
256
+ customBodyRenderLite: (_: string, index: number) => {
257
+ const item = data?.list[index] as TMeterEventExpanded;
258
+ return (
259
+ <Link to={`/admin/billing/${item.id}`}>
260
+ <Chip
261
+ label={item.status}
262
+ color={getStatusColor(item.status)}
263
+ size="small"
264
+ sx={{ textTransform: 'capitalize' }}
265
+ />
266
+ </Link>
267
+ );
268
+ },
269
+ },
270
+ },
271
+ {
272
+ label: t('admin.meterEvent.reportedAt'),
273
+ name: 'timestamp',
274
+ options: {
275
+ sort: true,
276
+ customBodyRenderLite: (_: string, index: number) => {
277
+ const item = data?.list[index] as TMeterEventExpanded;
278
+ return <Link to={`/admin/billing/${item.id}`}>{formatTime(item.timestamp * 1000)}</Link>;
279
+ },
280
+ },
281
+ },
282
+ ];
283
+
284
+ const onTableChange = ({ page, rowsPerPage }: any) => {
285
+ if (search!.pageSize !== rowsPerPage) {
286
+ setSearch((x: any) => ({ ...x, pageSize: rowsPerPage, page: 1 }));
287
+ } else if (search!.page !== page + 1) {
288
+ setSearch((x: any) => ({ ...x, page: page + 1 }));
289
+ }
290
+ };
291
+
292
+ return (
293
+ <Table
294
+ hasRowLink
295
+ durable={`__${listKey}__`}
296
+ durableKeys={['page', 'rowsPerPage', 'searchText']}
297
+ data={data?.list || []}
298
+ columns={columns}
299
+ loading={loading}
300
+ onChange={onTableChange}
301
+ options={{
302
+ count: data?.count || 0,
303
+ page: search!.page - 1,
304
+ rowsPerPage: search!.pageSize,
305
+ onColumnSortChange(_: any, order: any) {
306
+ setSearch({
307
+ ...search!,
308
+ q: search!.q || {},
309
+ o: order,
310
+ });
311
+ },
312
+ onSearchChange: (text: string) => {
313
+ if (text) {
314
+ setSearch({
315
+ ...search!,
316
+ q: {
317
+ 'like-event_name': text,
318
+ 'like-id': text,
319
+ },
320
+ pageSize: 100,
321
+ page: 1,
322
+ });
323
+ } else {
324
+ setSearch({
325
+ ...search!,
326
+ pageSize: 100,
327
+ page: 1,
328
+ q: {},
329
+ });
330
+ }
331
+ },
332
+ }}
333
+ title={<FilterToolbar search={search} onSearchChange={handleSearchChange} meters={meters} />}
334
+ />
335
+ );
336
+ }
337
+
338
+ function FilterToolbar({
339
+ search,
340
+ onSearchChange,
341
+ meters,
342
+ }: {
343
+ search: SearchProps;
344
+ onSearchChange: (updates: Partial<SearchProps>) => void;
345
+ meters: TMeter[];
346
+ }) {
347
+ return (
348
+ <Root>
349
+ <Box className="table-toolbar-left">
350
+ <StatusFilter search={search} onSearchChange={onSearchChange} />
351
+ <CustomerFilter search={search} onSearchChange={onSearchChange} />
352
+ <MeterFilter search={search} onSearchChange={onSearchChange} meters={meters} />
353
+ </Box>
354
+ </Root>
355
+ );
356
+ }
357
+
358
+ function StatusFilter({
359
+ search,
360
+ onSearchChange,
361
+ }: {
362
+ search: SearchProps;
363
+ onSearchChange: (updates: Partial<SearchProps>) => void;
364
+ }) {
365
+ const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
366
+ const { t } = useLocaleContext();
367
+ const statuses = ['pending', 'processing', 'requires_action', 'requires_capture', 'completed', 'canceled'];
368
+ const selectedStatuses = search.status ? search.status.split(',').filter(Boolean) : [];
369
+
370
+ const handleToggleStatus = (status: string) => {
371
+ const newStatuses = selectedStatuses.includes(status)
372
+ ? selectedStatuses.filter((s) => s !== status)
373
+ : [...selectedStatuses, status];
374
+
375
+ onSearchChange({ status: newStatuses.join(',') });
376
+ };
377
+
378
+ return (
379
+ <section>
380
+ <Button className="option-btn" variant="text" onClick={(e) => setAnchorEl(e.currentTarget)}>
381
+ {search.status ? (
382
+ <Close
383
+ sx={{ color: 'text.secondary', cursor: 'pointer', fontSize: '1.05rem' }}
384
+ onClick={(e) => {
385
+ e.stopPropagation();
386
+ onSearchChange({ status: '' });
387
+ setAnchorEl(null);
388
+ }}
389
+ />
390
+ ) : (
391
+ <Add sx={{ color: 'text.secondary', cursor: 'pointer', fontSize: '1.05rem' }} />
392
+ )}
393
+ {t('common.status')}
394
+ <span>{selectedStatuses.length > 0 ? `${selectedStatuses.length} selected` : ''}</span>
395
+ </Button>
396
+ <Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={() => setAnchorEl(null)} className="status-options">
397
+ {statuses.map((status) => (
398
+ <MenuItem key={status} onClick={() => handleToggleStatus(status)} sx={{ py: 0.5 }}>
399
+ <Checkbox checked={selectedStatuses.includes(status)} size="small" />
400
+ <ListItemText primary={status} />
401
+ </MenuItem>
402
+ ))}
403
+ </Menu>
404
+ </section>
405
+ );
406
+ }
407
+
408
+ function CustomerFilter({
409
+ search,
410
+ onSearchChange,
411
+ }: {
412
+ search: SearchProps;
413
+ onSearchChange: (updates: Partial<SearchProps>) => void;
414
+ }) {
415
+ const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
416
+ const [customers, setCustomers] = useState<TCustomer[]>([]);
417
+ const [searchText, setSearchText] = useState('');
418
+ const { t } = useLocaleContext();
419
+
420
+ const customerRequest = useRequest(
421
+ (text: string) => {
422
+ return fetchCustomers({
423
+ q: {
424
+ 'like-name': text,
425
+ 'like-email': text,
426
+ 'like-did': text,
427
+ },
428
+ page: 1,
429
+ pageSize: 10,
430
+ });
431
+ },
432
+ {
433
+ onSuccess: (data) => {
434
+ setCustomers(data.list);
435
+ },
436
+ debounceWait: 500,
437
+ }
438
+ );
439
+
440
+ useEffect(() => {
441
+ customerRequest.run(searchText);
442
+ }, [searchText]);
443
+
444
+ const selectedCustomer = customers.find((c) => c.id === search.customer_id);
445
+
446
+ return (
447
+ <section>
448
+ <Button className="option-btn" variant="text" onClick={(e) => setAnchorEl(e.currentTarget)}>
449
+ {search.customer_id ? (
450
+ <Close
451
+ sx={{ color: 'text.secondary', cursor: 'pointer', fontSize: '1.05rem' }}
452
+ onClick={(e) => {
453
+ e.stopPropagation();
454
+ onSearchChange({ customer_id: '' });
455
+ setAnchorEl(null);
456
+ }}
457
+ />
458
+ ) : (
459
+ <Add sx={{ color: 'text.secondary', cursor: 'pointer', fontSize: '1.05rem' }} />
460
+ )}
461
+ {t('common.customer')}
462
+ <span>{selectedCustomer?.name || ''}</span>
463
+ </Button>
464
+ <Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={() => setAnchorEl(null)}>
465
+ <Box sx={{ p: 1 }}>
466
+ <TextField
467
+ placeholder="Search customer"
468
+ size="small"
469
+ sx={{ width: '100%' }}
470
+ value={searchText}
471
+ onChange={(e) => {
472
+ setSearchText(e.target.value);
473
+ e.stopPropagation();
474
+ }}
475
+ onClick={(e) => e.stopPropagation()}
476
+ />
477
+ </Box>
478
+ {customers.map((customer) => (
479
+ <MenuItem
480
+ key={customer.id}
481
+ onClick={(e) => {
482
+ e.stopPropagation();
483
+ onSearchChange({ customer_id: customer.id });
484
+ setAnchorEl(null);
485
+ }}>
486
+ <InfoCard
487
+ logo={getCustomerAvatar(
488
+ customer?.did,
489
+ customer?.updated_at ? new Date(customer.updated_at).toISOString() : undefined,
490
+ 48
491
+ )}
492
+ name={customer.name || customer.email}
493
+ key={customer.id}
494
+ description={`${customer.did.slice(0, 6)}...${customer.did.slice(-6)}`}
495
+ />
496
+ </MenuItem>
497
+ ))}
498
+ </Menu>
499
+ </section>
500
+ );
501
+ }
502
+
503
+ function MeterFilter({
504
+ search,
505
+ onSearchChange,
506
+ meters,
507
+ }: {
508
+ search: SearchProps;
509
+ onSearchChange: (updates: Partial<SearchProps>) => void;
510
+ meters: TMeter[];
511
+ }) {
512
+ const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
513
+ const { t } = useLocaleContext();
514
+
515
+ const selectedMeter = meters.find((m) => m.id === search.meter_id);
516
+
517
+ return (
518
+ <section>
519
+ <Button className="option-btn" variant="text" onClick={(e) => setAnchorEl(e.currentTarget)}>
520
+ {search.meter_id ? (
521
+ <Close
522
+ sx={{ color: 'text.secondary', cursor: 'pointer', fontSize: '1.05rem' }}
523
+ onClick={(e) => {
524
+ e.stopPropagation();
525
+ onSearchChange({ meter_id: '' });
526
+ setAnchorEl(null);
527
+ }}
528
+ />
529
+ ) : (
530
+ <Add sx={{ color: 'text.secondary', cursor: 'pointer', fontSize: '1.05rem' }} />
531
+ )}
532
+ {t('admin.meters')}
533
+ <span>{selectedMeter?.name || ''}</span>
534
+ </Button>
535
+ <Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={() => setAnchorEl(null)} className="status-options">
536
+ {meters.map((meter) => (
537
+ <MenuItem
538
+ key={meter.id}
539
+ onClick={() => {
540
+ onSearchChange({ meter_id: meter.id });
541
+ setAnchorEl(null);
542
+ }}>
543
+ {meter.name} ({meter.event_name})
544
+ </MenuItem>
545
+ ))}
546
+ </Menu>
547
+ </section>
548
+ );
549
+ }
550
+
551
+ const Root = styled(Box)`
552
+ .table-toolbar-left {
553
+ display: flex;
554
+ align-items: center;
555
+ }
556
+
557
+ .table-toolbar-left section {
558
+ position: relative;
559
+ list-style: none;
560
+ font-size: 14px;
561
+ font-weight: normal;
562
+ padding: 0 5px;
563
+ cursor: pointer;
564
+ }
565
+
566
+ .option-btn {
567
+ display: flex;
568
+ align-items: center;
569
+ border-radius: 25px;
570
+ background: ${({ theme }) => theme.palette.grey[100]};
571
+ padding: 5px 10px;
572
+ color: ${({ theme }) => theme.palette.text.secondary};
573
+ font-size: 14px;
574
+ line-height: 14px;
575
+ overflow: visible;
576
+ }
577
+
578
+ .option-btn span {
579
+ color: #3773f2;
580
+ padding: 0 3px;
581
+ overflow: visible;
582
+ }
583
+
584
+ .status-options {
585
+ max-height: 300px;
586
+ overflow-y: auto;
587
+ }
588
+ `;