payment-kit 1.13.150 → 1.13.152

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 (39) hide show
  1. package/api/src/libs/notification/template/subscription-refund-succeeded.ts +1 -1
  2. package/api/src/queues/invoice.ts +14 -0
  3. package/api/src/queues/refund.ts +1 -0
  4. package/api/src/routes/checkout-sessions.ts +9 -1
  5. package/api/src/routes/connect/shared.ts +1 -0
  6. package/api/src/routes/customers.ts +22 -1
  7. package/api/src/routes/refunds.ts +14 -0
  8. package/api/src/routes/subscriptions.ts +2 -1
  9. package/api/src/store/migrations/20240225-refund-ext.ts +22 -0
  10. package/api/src/store/models/customer.ts +21 -0
  11. package/api/src/store/models/index.ts +1 -1
  12. package/api/src/store/models/invoice.ts +37 -1
  13. package/api/src/store/models/payment-intent.ts +24 -2
  14. package/api/src/store/models/refund.ts +53 -16
  15. package/api/src/store/models/types.ts +2 -0
  16. package/blocklet.yml +1 -1
  17. package/package.json +4 -4
  18. package/src/app.tsx +10 -0
  19. package/src/components/balance-list.tsx +43 -0
  20. package/src/components/info-metric.tsx +2 -2
  21. package/src/components/invoice/table.tsx +1 -1
  22. package/src/components/payment-intent/list.tsx +1 -1
  23. package/src/components/payment-link/after-pay.tsx +0 -19
  24. package/src/components/refund/list.tsx +6 -2
  25. package/src/components/section/header.tsx +3 -1
  26. package/src/components/subscription/items/usage-records.tsx +1 -1
  27. package/src/global.css +0 -1
  28. package/src/locales/en.tsx +2 -71
  29. package/src/locales/zh.tsx +2 -71
  30. package/src/pages/admin/billing/invoices/detail.tsx +7 -0
  31. package/src/pages/admin/billing/subscriptions/detail.tsx +7 -0
  32. package/src/pages/admin/customers/customers/detail.tsx +49 -32
  33. package/src/pages/admin/index.tsx +4 -2
  34. package/src/pages/admin/payments/intents/detail.tsx +17 -13
  35. package/src/pages/admin/payments/links/create.tsx +1 -1
  36. package/src/pages/customer/index.tsx +55 -9
  37. package/src/pages/customer/invoice/past-due.tsx +77 -0
  38. package/src/pages/customer/invoice.tsx +58 -56
  39. package/src/pages/customer/refund/list.tsx +125 -0
@@ -1,36 +1,50 @@
1
+ import DID from '@arcblock/ux/lib/Address';
1
2
  import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
3
  import Toast from '@arcblock/ux/lib/Toast';
3
- import { CustomerInvoiceList, CustomerPaymentList, formatError } from '@blocklet/payment-react';
4
- import type { TCustomerExpanded } from '@blocklet/payment-types';
4
+ import {
5
+ CustomerInvoiceList,
6
+ CustomerPaymentList,
7
+ PaymentProvider,
8
+ formatError,
9
+ getPrefix,
10
+ } from '@blocklet/payment-react';
11
+ import type { GroupedBN, TCustomerExpanded } from '@blocklet/payment-types';
5
12
  import { Edit } from '@mui/icons-material';
6
- import { Alert, Box, Button, CircularProgress, Grid, Stack } from '@mui/material';
13
+ import { Alert, Box, Button, CircularProgress, Grid, Stack, Tooltip } from '@mui/material';
7
14
  import { styled } from '@mui/system';
8
15
  import { useRequest, useSetState } from 'ahooks';
16
+ import { isEmpty } from 'lodash';
17
+ import { useEffect } from 'react';
9
18
  import { FlagEmoji } from 'react-international-phone';
10
19
  import { useNavigate } from 'react-router-dom';
20
+ import { joinURL } from 'ufo';
11
21
 
22
+ import BalanceList from '../../components/balance-list';
12
23
  import EditCustomer from '../../components/customer/edit';
24
+ import InfoMetric from '../../components/info-metric';
13
25
  import InfoRow from '../../components/info-row';
14
26
  import SectionHeader from '../../components/section/header';
15
27
  import CurrentSubscriptions from '../../components/subscription/portal/list';
16
28
  import { useSessionContext } from '../../contexts/session';
17
29
  import api from '../../libs/api';
18
30
 
19
- const fetchData = (): Promise<TCustomerExpanded> => {
31
+ const fetchData = (): Promise<TCustomerExpanded & { summary: { [key: string]: GroupedBN } }> => {
20
32
  return api.get('/api/customers/me').then((res) => res.data);
21
33
  };
22
34
 
23
35
  export default function CustomerHome() {
24
36
  const { t } = useLocaleContext();
25
- const { events } = useSessionContext();
37
+ const { events, session, connectApi } = useSessionContext();
26
38
  const [state, setState] = useSetState({ editing: false, loading: false });
27
39
  const navigate = useNavigate();
28
40
 
29
41
  const { loading, error, data, runAsync } = useRequest(fetchData);
30
42
 
31
- events.once('switch-did', () => {
32
- runAsync().catch(console.error);
33
- });
43
+ useEffect(() => {
44
+ events.once('switch-did', () => {
45
+ runAsync().catch(console.error);
46
+ });
47
+ }, []);
34
48
 
35
49
  if (error) {
36
50
  return <Alert severity="error">{formatError(error)}</Alert>;
@@ -82,7 +96,23 @@ export default function CustomerHome() {
82
96
  </Box>
83
97
  </Box>
84
98
  <Box className="section">
85
- <SectionHeader title={t('payment.customer.invoices')} mb={0} />
99
+ <SectionHeader title={t('payment.customer.invoices')} mb={0}>
100
+ {isEmpty(data.summary.due) === false && (
101
+ <Tooltip title={t('payment.customer.pastDue.warning')}>
102
+ <Button
103
+ variant="contained"
104
+ color="error"
105
+ component="a"
106
+ size="small"
107
+ href={joinURL(window.location.origin, getPrefix(), '/customer/invoice/past-due')}
108
+ target="_blank"
109
+ rel="noreferrer"
110
+ style={{ textDecoration: 'none' }}>
111
+ {t('payment.customer.pastDue.invoices')}
112
+ </Button>
113
+ </Tooltip>
114
+ )}
115
+ </SectionHeader>
86
116
  <Box className="section-body">
87
117
  <CustomerInvoiceList customer_id={data.id} />
88
118
  </Box>
@@ -110,6 +140,7 @@ export default function CustomerHome() {
110
140
  </Button>
111
141
  </SectionHeader>
112
142
  <Stack>
143
+ <InfoRow sizes={[1, 2]} label={t('common.did')} value={<DID copyable>{data.did}</DID>} />
113
144
  <InfoRow sizes={[1, 2]} label={t('admin.customer.name')} value={data.name} />
114
145
  <InfoRow sizes={[1, 2]} label={t('admin.customer.phone')} value={data.phone} />
115
146
  <InfoRow sizes={[1, 2]} label={t('admin.customer.email')} value={data.email} />
@@ -143,6 +174,21 @@ export default function CustomerHome() {
143
174
  />
144
175
  )}
145
176
  </Box>
177
+ <Box className="section">
178
+ <SectionHeader title={t('payment.customer.summary')} />
179
+ <PaymentProvider session={session} connect={connectApi}>
180
+ <Stack
181
+ className="section-body"
182
+ direction="column"
183
+ spacing={2}
184
+ justifyContent="flex-start"
185
+ sx={{ width: '100%' }}>
186
+ <InfoMetric label={t('admin.customer.spent')} value={<BalanceList data={data.summary.paid} />} />
187
+ <InfoMetric label={t('admin.customer.due')} value={<BalanceList data={data.summary.due} />} />
188
+ <InfoMetric label={t('admin.customer.refund')} value={<BalanceList data={data.summary.refunded} />} />
189
+ </Stack>
190
+ </PaymentProvider>
191
+ </Box>
146
192
  </Root>
147
193
  </Grid>
148
194
  </Grid>
@@ -0,0 +1,77 @@
1
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
+ import { CustomerInvoiceList, formatError } from '@blocklet/payment-react';
3
+ import type { TCustomerExpanded } from '@blocklet/payment-types';
4
+ import { ArrowBackOutlined } from '@mui/icons-material';
5
+ import { Alert, Box, CircularProgress, Stack, Typography } from '@mui/material';
6
+ import { styled } from '@mui/system';
7
+ import { useRequest } from 'ahooks';
8
+ import { useEffect } from 'react';
9
+ import { Link } from 'react-router-dom';
10
+
11
+ import SectionHeader from '../../../components/section/header';
12
+ import { useSessionContext } from '../../../contexts/session';
13
+ import api from '../../../libs/api';
14
+
15
+ const fetchData = (): Promise<TCustomerExpanded> => {
16
+ return api.get('/api/customers/me').then((res) => res.data);
17
+ };
18
+
19
+ export default function CustomerInvoicePastDue() {
20
+ const { t } = useLocaleContext();
21
+ const { events } = useSessionContext();
22
+
23
+ const { loading, error, data, runAsync } = useRequest(fetchData);
24
+
25
+ useEffect(() => {
26
+ events.once('switch-did', () => {
27
+ runAsync().catch(console.error);
28
+ });
29
+ }, []);
30
+
31
+ if (error) {
32
+ return <Alert severity="error">{formatError(error)}</Alert>;
33
+ }
34
+
35
+ if (loading) {
36
+ return <CircularProgress />;
37
+ }
38
+
39
+ if (!data) {
40
+ return (
41
+ <Alert sx={{ mt: 3 }} severity="info">
42
+ {t('payment.customer.empty')}
43
+ </Alert>
44
+ );
45
+ }
46
+
47
+ return (
48
+ <Stack direction="column" spacing={3} sx={{ my: 2, maxWidth: '960px' }}>
49
+ <Link to="/customer">
50
+ <Stack direction="row" alignItems="center" sx={{ fontWeight: 'normal' }}>
51
+ <ArrowBackOutlined fontSize="small" sx={{ mr: 0.5, color: 'text.secondary' }} />
52
+ <Typography variant="h6" sx={{ color: 'text.secondary', fontWeight: 'normal' }}>
53
+ {t('common.previous')}
54
+ </Typography>
55
+ </Stack>
56
+ </Link>
57
+ <Root direction="column" spacing={3}>
58
+ <Box className="section">
59
+ <SectionHeader title={t('payment.customer.pastDue.invoices')} mb={0}>
60
+ <Typography variant="body1" color="error">
61
+ {t('payment.customer.pastDue.warning')}
62
+ </Typography>
63
+ </SectionHeader>
64
+ <Box className="section-body">
65
+ <CustomerInvoiceList customer_id={data.id} pageSize={100} status="uncollectible" target="_blank" />
66
+ </Box>
67
+ </Box>
68
+ </Root>
69
+ </Stack>
70
+ );
71
+ }
72
+
73
+ const Root = styled(Stack)`
74
+ a {
75
+ text-decoration: underline;
76
+ }
77
+ `;
@@ -4,7 +4,7 @@ import Toast from '@arcblock/ux/lib/Toast';
4
4
  import { Status, TxLink, api, formatError, formatTime, getInvoiceStatusColor } from '@blocklet/payment-react';
5
5
  import type { TInvoiceExpanded } from '@blocklet/payment-types';
6
6
  import { ArrowBackOutlined } from '@mui/icons-material';
7
- import { Alert, Box, Button, CircularProgress, Grid, Stack, Typography } from '@mui/material';
7
+ import { Alert, Box, Button, CircularProgress, Stack, Typography } from '@mui/material';
8
8
  import { useRequest, useSetState } from 'ahooks';
9
9
  import { useEffect } from 'react';
10
10
  import { Link, useParams, useSearchParams } from 'react-router-dom';
@@ -14,13 +14,14 @@ import InfoRow from '../../components/info-row';
14
14
  import InvoiceTable from '../../components/invoice/table';
15
15
  import SectionHeader from '../../components/section/header';
16
16
  import { useSessionContext } from '../../contexts/session';
17
+ import CustomerRefundList from './refund/list';
17
18
 
18
19
  const fetchData = (id: string): Promise<TInvoiceExpanded> => {
19
20
  return api.get(`/api/invoices/${id}`).then((res) => res.data);
20
21
  };
21
22
 
22
23
  // TODO: download feature using: https://react-pdf.org/
23
- export default function CustomerHome() {
24
+ export default function CustomerInvoiceDetail() {
24
25
  const { t } = useLocaleContext();
25
26
  const [searchParams] = useSearchParams();
26
27
  const { connectApi } = useSessionContext();
@@ -89,58 +90,22 @@ export default function CustomerHome() {
89
90
  }
90
91
 
91
92
  return (
92
- <Grid container spacing={3} sx={{ mt: 1 }}>
93
- <Grid item xs={12} md={12}>
93
+ <Stack direction="column" spacing={3} sx={{ my: 2, maxWidth: 'lg' }}>
94
+ <Stack direction="row" justifyContent="space-between">
94
95
  <Link to="/customer">
95
- <Stack direction="row" alignItems="center" sx={{ fontWeight: 'normal' }}>
96
+ <Stack direction="row" alignItems="center" sx={{ fontWeight: 'normal', padding: '10px 0' }}>
96
97
  <ArrowBackOutlined fontSize="small" sx={{ mr: 0.5, color: 'text.secondary' }} />
97
98
  <Typography variant="h6" sx={{ color: 'text.secondary', fontWeight: 'normal' }}>
98
99
  {t('common.previous')}
99
100
  </Typography>
100
101
  </Stack>
101
102
  </Link>
102
- </Grid>
103
- <Grid item xs={12} md={5}>
104
- <Box>
105
- <SectionHeader title={t('payment.customer.invoice.summary')} mb={0} />
106
- <Stack sx={{ mt: 1 }}>
107
- <InfoRow label={t('admin.invoice.number')} value={data.number} />
108
- <InfoRow label={t('admin.invoice.from')} value={data.statement_descriptor || blocklet.appName} />
109
- <InfoRow label={t('admin.invoice.customer')} value={data.customer.name} />
110
- <InfoRow
111
- label={t('common.status')}
112
- value={<Status label={data.status} color={getInvoiceStatusColor(data.status)} />}
113
- />
114
- <InfoRow
115
- label={t('admin.subscription.currentPeriod')}
116
- value={
117
- data.period_start && data.period_end
118
- ? [formatTime(data.period_start * 1000), formatTime(data.period_end * 1000)].join(' ~ ')
119
- : ''
120
- }
121
- />
122
- {data.status_transitions?.paid_at && (
123
- <InfoRow label={t('admin.invoice.paidAt')} value={formatTime(data.status_transitions.paid_at * 1000)} />
124
- )}
125
- <InfoRow
126
- label={t('admin.paymentCurrency.name')}
127
- value={<Currency logo={data.paymentCurrency.logo} name={data.paymentCurrency.symbol} />}
128
- />
129
- <InfoRow
130
- label={t('common.txHash')}
131
- value={
132
- data.paymentIntent?.payment_details ? (
133
- <TxLink details={data.paymentIntent.payment_details} method={data.paymentMethod} mode="customer" />
134
- ) : (
135
- ''
136
- )
137
- }
138
- />
139
- </Stack>
140
- </Box>
141
- </Grid>
142
- <Grid item xs={12} md={7}>
143
- <SectionHeader title={t('payment.customer.invoice.details')} mb={0}>
103
+ <Stack direction="row" justifyContent="flex-end" alignItems="center">
104
+ {['open', 'uncollectible'].includes(data.status) && (
105
+ <Button variant="contained" color="primary" disabled={state.paying} onClick={onPay}>
106
+ {t('payment.customer.invoice.pay')}
107
+ </Button>
108
+ )}
144
109
  {['open', 'paid', 'uncollectible'].includes(data.status) && (
145
110
  <Button
146
111
  variant="contained"
@@ -151,16 +116,53 @@ export default function CustomerHome() {
151
116
  {t('payment.customer.invoice.download')}
152
117
  </Button>
153
118
  )}
154
- </SectionHeader>
155
- <InvoiceTable invoice={data} simple />
156
- <Stack direction="row" justifyContent="flex-end" alignItems="center" mt={2}>
157
- {['open', 'uncollectible'].includes(data.status) && (
158
- <Button variant="contained" color="primary" disabled={state.paying} onClick={onPay}>
159
- {t('payment.customer.invoice.pay')}
160
- </Button>
119
+ </Stack>
120
+ </Stack>
121
+ <Box>
122
+ <SectionHeader title={t('payment.customer.invoice.summary')} />
123
+ <Stack sx={{ mt: 1, display: 'grid', gridTemplateColumns: '50% 50%' }}>
124
+ <InfoRow label={t('admin.invoice.number')} value={data.number} />
125
+ <InfoRow label={t('admin.invoice.from')} value={data.statement_descriptor || blocklet.appName} />
126
+ <InfoRow label={t('admin.invoice.customer')} value={data.customer.name} />
127
+ <InfoRow
128
+ label={t('common.status')}
129
+ value={<Status label={data.status} color={getInvoiceStatusColor(data.status)} />}
130
+ />
131
+ {data.period_start > 0 && data.period_end > 0 && (
132
+ <InfoRow
133
+ label={t('admin.subscription.currentPeriod')}
134
+ value={[formatTime(data.period_start * 1000), formatTime(data.period_end * 1000)].join(' ~ ')}
135
+ />
161
136
  )}
137
+ {data.status_transitions?.paid_at && (
138
+ <InfoRow label={t('admin.invoice.paidAt')} value={formatTime(data.status_transitions.paid_at * 1000)} />
139
+ )}
140
+ <InfoRow
141
+ label={t('admin.paymentCurrency.name')}
142
+ value={<Currency logo={data.paymentCurrency.logo} name={data.paymentCurrency.symbol} />}
143
+ />
144
+ <InfoRow
145
+ label={t('common.txHash')}
146
+ value={
147
+ data.paymentIntent?.payment_details ? (
148
+ <TxLink details={data.paymentIntent.payment_details} method={data.paymentMethod} mode="customer" />
149
+ ) : (
150
+ ''
151
+ )
152
+ }
153
+ />
162
154
  </Stack>
163
- </Grid>
164
- </Grid>
155
+ </Box>
156
+ <Box>
157
+ <SectionHeader title={t('payment.customer.invoice.details')} />
158
+ <InvoiceTable invoice={data} simple />
159
+ </Box>
160
+ <Box className="section">
161
+ <SectionHeader title={t('admin.refunds')} mb={0} mt={0} />
162
+ <Box className="section-body">
163
+ <CustomerRefundList invoice_id={data.id} />
164
+ </Box>
165
+ </Box>
166
+ </Stack>
165
167
  );
166
168
  }
@@ -0,0 +1,125 @@
1
+ /* eslint-disable react/no-unstable-nested-components */
2
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
3
+ import { Status, TxLink, api, formatToDate, getPaymentIntentStatusColor } from '@blocklet/payment-react';
4
+ import type { Paginated, PaymentDetails, TPaymentIntentExpanded } from '@blocklet/payment-types';
5
+ import { Box, Button, CircularProgress, Stack, Typography } from '@mui/material';
6
+ import { fromUnitToToken } from '@ocap/util';
7
+ import { useInfiniteScroll } from 'ahooks';
8
+
9
+ const groupByDate = (items: TPaymentIntentExpanded[]) => {
10
+ const grouped: { [key: string]: TPaymentIntentExpanded[] } = {};
11
+ items.forEach((item) => {
12
+ const date = new Date(item.created_at).toLocaleDateString();
13
+ if (!grouped[date]) {
14
+ grouped[date] = [];
15
+ }
16
+ grouped[date]?.push(item);
17
+ });
18
+ return grouped;
19
+ };
20
+
21
+ const fetchData = (params: Record<string, any> = {}): Promise<Paginated<TPaymentIntentExpanded>> => {
22
+ const search = new URLSearchParams();
23
+ Object.keys(params).forEach((key) => {
24
+ search.set(key, String(params[key]));
25
+ });
26
+ return api.get(`/api/refunds?${search.toString()}`).then((res) => res.data);
27
+ };
28
+
29
+ type Props = {
30
+ invoice_id: string;
31
+ };
32
+
33
+ const pageSize = 10;
34
+
35
+ export default function CustomerRefundList({ invoice_id }: Props) {
36
+ const { t } = useLocaleContext();
37
+
38
+ const { data, loadMore, loadingMore, loading } = useInfiniteScroll<Paginated<TPaymentIntentExpanded>>(
39
+ (d) => {
40
+ const page = d ? Math.ceil(d.list.length / pageSize) + 1 : 1;
41
+ return fetchData({ page, pageSize, invoice_id });
42
+ },
43
+ {
44
+ reloadDeps: [invoice_id],
45
+ }
46
+ );
47
+
48
+ if (loading || !data) {
49
+ return <CircularProgress />;
50
+ }
51
+
52
+ if (data && data.list.length === 0) {
53
+ return <Typography color="text.secondary">{t('payment.customer.payment.empty')}</Typography>;
54
+ }
55
+
56
+ const hasMore = data && data.list.length < data.count;
57
+
58
+ const grouped = groupByDate(data.list as any);
59
+
60
+ return (
61
+ <Stack direction="column" gap={1} sx={{ mt: 1 }}>
62
+ {Object.entries(grouped).map(([date, payments]) => (
63
+ <Box key={date}>
64
+ <Typography sx={{ fontWeight: 'bold', color: 'text.secondary', mt: 2, mb: 1 }}>{date}</Typography>
65
+ {payments.map((item: any) => {
66
+ return (
67
+ <Stack
68
+ key={item.id}
69
+ direction={{
70
+ xs: 'column',
71
+ sm: 'row',
72
+ }}
73
+ sx={{ my: 1 }}
74
+ gap={{
75
+ xs: 0.5,
76
+ sm: 1.5,
77
+ md: 3,
78
+ }}
79
+ flexWrap="nowrap">
80
+ <Box flex={3}>
81
+ <Typography>{formatToDate(item.created_at)}</Typography>
82
+ </Box>
83
+ <Box flex={2}>
84
+ <Typography textAlign="right">
85
+ {fromUnitToToken(item.amount, item.paymentCurrency.decimal)}&nbsp;
86
+ {item.paymentCurrency.symbol}
87
+ </Typography>
88
+ </Box>
89
+ <Box flex={3}>
90
+ <Status label={item.status} color={getPaymentIntentStatusColor(item.status)} />
91
+ </Box>
92
+ <Box flex={3}>
93
+ <Typography>{item.description || '-'}</Typography>
94
+ </Box>
95
+ <Box flex={3} sx={{ minWidth: '220px' }}>
96
+ {item.payment_details?.arcblock?.tx_hash && (
97
+ <TxLink
98
+ details={item.payment_details as PaymentDetails}
99
+ method={item.paymentMethod}
100
+ mode="customer"
101
+ />
102
+ )}
103
+ </Box>
104
+ </Stack>
105
+ );
106
+ })}
107
+ </Box>
108
+ ))}
109
+ <Box>
110
+ {hasMore && (
111
+ <Button variant="text" type="button" color="inherit" onClick={loadMore} disabled={loadingMore}>
112
+ {loadingMore
113
+ ? t('common.loadingMore', { resource: t('payment.customer.payments') })
114
+ : t('common.loadMore', { resource: t('payment.customer.payments') })}
115
+ </Button>
116
+ )}
117
+ {!hasMore && data.count > pageSize && (
118
+ <Typography color="text.secondary">
119
+ {t('common.noMore', { resource: t('payment.customer.payments') })}
120
+ </Typography>
121
+ )}
122
+ </Box>
123
+ </Stack>
124
+ );
125
+ }