payment-kit 1.13.151 → 1.13.153
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.
- package/api/src/routes/checkout-sessions.ts +8 -0
- package/api/src/routes/connect/shared.ts +1 -0
- package/api/src/routes/customers.ts +22 -1
- package/api/src/store/models/customer.ts +21 -0
- package/api/src/store/models/invoice.ts +37 -1
- package/api/src/store/models/payment-intent.ts +24 -2
- package/api/src/store/models/refund.ts +24 -2
- package/api/src/store/models/types.ts +2 -0
- package/blocklet.yml +1 -1
- package/package.json +4 -4
- package/src/app.tsx +12 -2
- package/src/components/balance-list.tsx +43 -0
- package/src/components/info-metric.tsx +2 -2
- package/src/global.css +0 -1
- package/src/locales/en.tsx +1 -0
- package/src/locales/zh.tsx +1 -0
- package/src/pages/admin/customers/customers/detail.tsx +49 -32
- package/src/pages/customer/index.tsx +55 -9
- package/src/pages/customer/{invoice.tsx → invoice/detail.tsx} +66 -67
- package/src/pages/customer/invoice/past-due.tsx +77 -0
- package/src/pages/customer/refund/list.tsx +1 -1
|
@@ -504,6 +504,14 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
|
|
|
504
504
|
|
|
505
505
|
await customer.update(updates);
|
|
506
506
|
}
|
|
507
|
+
const canMakeNewPurchase = await customer.canMakeNewPurchase(checkoutSession.invoice_id);
|
|
508
|
+
if (!canMakeNewPurchase) {
|
|
509
|
+
return res.status(403).json({
|
|
510
|
+
code: 'CUSTOMER_LIMITED',
|
|
511
|
+
error: 'Customer can not make new purchase, maybe you have unpaid invoices from previous purchases',
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
|
|
507
515
|
await checkoutSession.update({ customer_id: customer.id, customer_did: req.user.did });
|
|
508
516
|
|
|
509
517
|
// payment intent is only created when checkout session is in payment mode
|
|
@@ -532,6 +532,7 @@ export async function ensureInvoiceForCollect(invoiceId: string) {
|
|
|
532
532
|
throw new Error(`Payment intent already canceled for invoice ${invoiceId}`);
|
|
533
533
|
}
|
|
534
534
|
if (paymentIntent.status === 'succeeded') {
|
|
535
|
+
await invoice.update({ status: 'paid' });
|
|
535
536
|
throw new Error(`Payment intent already succeeded for invoice ${invoiceId}`);
|
|
536
537
|
}
|
|
537
538
|
|
|
@@ -102,7 +102,12 @@ router.get('/me', user(), async (req, res) => {
|
|
|
102
102
|
|
|
103
103
|
try {
|
|
104
104
|
const doc = await Customer.findByPkOrDid(req.user.did as string);
|
|
105
|
-
|
|
105
|
+
if (!doc) {
|
|
106
|
+
res.status(404).json({ error: 'Customer not found' });
|
|
107
|
+
} else {
|
|
108
|
+
const summary = await doc.getSummary();
|
|
109
|
+
res.json({ ...doc.toJSON(), summary });
|
|
110
|
+
}
|
|
106
111
|
} catch (err) {
|
|
107
112
|
console.error(err);
|
|
108
113
|
res.json(null);
|
|
@@ -119,6 +124,22 @@ router.get('/:id', auth, async (req, res) => {
|
|
|
119
124
|
}
|
|
120
125
|
});
|
|
121
126
|
|
|
127
|
+
router.get('/:id/summary', auth, async (req, res) => {
|
|
128
|
+
try {
|
|
129
|
+
const doc = await Customer.findByPkOrDid(req.params.id as string);
|
|
130
|
+
if (!doc) {
|
|
131
|
+
res.status(404).json({ error: 'Customer not found' });
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const result = await doc.getSummary();
|
|
136
|
+
res.json(result);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
console.error(err);
|
|
139
|
+
res.json(null);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
122
143
|
// eslint-disable-next-line consistent-return
|
|
123
144
|
router.put('/:id', authPortal, async (req, res) => {
|
|
124
145
|
try {
|
|
@@ -154,6 +154,27 @@ export class Customer extends Model<InferAttributes<Customer>, InferCreationAttr
|
|
|
154
154
|
return `${this.invoice_prefix}-${padStart(sequence.toString(), 4, '0')}`;
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
public async getSummary() {
|
|
158
|
+
const { PaymentIntent, Refund, Invoice } = this.sequelize.models;
|
|
159
|
+
const [paid, due, refunded] = await Promise.all([
|
|
160
|
+
// @ts-ignore
|
|
161
|
+
PaymentIntent!.getPaidAmountByCustomer(this.id),
|
|
162
|
+
// @ts-ignore
|
|
163
|
+
Invoice!.getUncollectibleAmountByCustomer(this.id),
|
|
164
|
+
// @ts-ignore
|
|
165
|
+
Refund!.getRefundAmountByCustomer(this.id),
|
|
166
|
+
]);
|
|
167
|
+
|
|
168
|
+
return { paid, due, refunded };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
public async canMakeNewPurchase(excludedInvoiceId: string = '') {
|
|
172
|
+
const { Invoice } = this.sequelize.models;
|
|
173
|
+
// @ts-ignore
|
|
174
|
+
const result = await Invoice!.getUncollectibleAmountByCustomer(this.id, excludedInvoiceId);
|
|
175
|
+
return Object.entries(result).every(([, amount]) => new BN(amount).lte(new BN(0)));
|
|
176
|
+
}
|
|
177
|
+
|
|
157
178
|
public getBalanceToApply(currencyId: string, amount: string) {
|
|
158
179
|
const tokens = this.token_balance || {};
|
|
159
180
|
const balance = tokens[currencyId] || '0';
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/indent */
|
|
2
2
|
/* eslint-disable @typescript-eslint/lines-between-class-members */
|
|
3
|
-
import {
|
|
3
|
+
import { BN } from '@ocap/util';
|
|
4
|
+
import {
|
|
5
|
+
CreationOptional,
|
|
6
|
+
DataTypes,
|
|
7
|
+
InferAttributes,
|
|
8
|
+
InferCreationAttributes,
|
|
9
|
+
Model,
|
|
10
|
+
Op,
|
|
11
|
+
WhereOptions,
|
|
12
|
+
} from 'sequelize';
|
|
4
13
|
import type { LiteralUnion } from 'type-fest';
|
|
5
14
|
|
|
6
15
|
import { createEvent, createStatusEvent } from '../../libs/audit';
|
|
@@ -9,6 +18,7 @@ import type {
|
|
|
9
18
|
CustomerAddress,
|
|
10
19
|
CustomerShipping,
|
|
11
20
|
DiscountAmount,
|
|
21
|
+
GroupedBN,
|
|
12
22
|
PaymentError,
|
|
13
23
|
PaymentSettings,
|
|
14
24
|
SimpleCustomField,
|
|
@@ -511,6 +521,32 @@ export class Invoice extends Model<InferAttributes<Invoice>, InferCreationAttrib
|
|
|
511
521
|
public isImmutable() {
|
|
512
522
|
return ['paid', 'void'].includes(this.status);
|
|
513
523
|
}
|
|
524
|
+
|
|
525
|
+
public static async getUncollectibleAmountByCustomer(
|
|
526
|
+
customerId: string,
|
|
527
|
+
excludedInvoiceId?: string
|
|
528
|
+
): Promise<GroupedBN> {
|
|
529
|
+
const where: WhereOptions<Invoice> = {
|
|
530
|
+
status: 'uncollectible',
|
|
531
|
+
customer_id: customerId,
|
|
532
|
+
amount_remaining: { [Op.gt]: '0' },
|
|
533
|
+
};
|
|
534
|
+
if (excludedInvoiceId) {
|
|
535
|
+
where.id = { [Op.not]: excludedInvoiceId };
|
|
536
|
+
}
|
|
537
|
+
const invoices = await Invoice.findAll({ where });
|
|
538
|
+
|
|
539
|
+
return invoices.reduce((acc: GroupedBN, invoice) => {
|
|
540
|
+
const key = invoice.currency_id;
|
|
541
|
+
if (!acc[key]) {
|
|
542
|
+
acc[key] = '0';
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
acc[key] = new BN(acc[key]).add(new BN(invoice.amount_remaining)).toString();
|
|
546
|
+
|
|
547
|
+
return acc;
|
|
548
|
+
}, {});
|
|
549
|
+
}
|
|
514
550
|
}
|
|
515
551
|
|
|
516
552
|
export type TInvoice = InferAttributes<Invoice>;
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/lines-between-class-members */
|
|
2
2
|
/* eslint-disable @typescript-eslint/indent */
|
|
3
|
-
import {
|
|
3
|
+
import { BN } from '@ocap/util';
|
|
4
|
+
import { CreationOptional, DataTypes, InferAttributes, InferCreationAttributes, Model, Op } from 'sequelize';
|
|
4
5
|
import type { LiteralUnion } from 'type-fest';
|
|
5
6
|
|
|
6
7
|
import { createEvent, createStatusEvent } from '../../libs/audit';
|
|
7
8
|
import { createIdGenerator } from '../../libs/util';
|
|
8
|
-
import type { PaymentDetails, PaymentError } from './types';
|
|
9
|
+
import type { GroupedBN, PaymentDetails, PaymentError } from './types';
|
|
9
10
|
|
|
10
11
|
export const nextPaymentIntentId = createIdGenerator('pi', 24);
|
|
11
12
|
|
|
@@ -280,6 +281,27 @@ export class PaymentIntent extends Model<InferAttributes<PaymentIntent>, InferCr
|
|
|
280
281
|
public isImmutable() {
|
|
281
282
|
return ['canceled', 'succeeded'].includes(this.status);
|
|
282
283
|
}
|
|
284
|
+
|
|
285
|
+
public static async getPaidAmountByCustomer(customerId: string): Promise<GroupedBN> {
|
|
286
|
+
const payments = await PaymentIntent.findAll({
|
|
287
|
+
where: {
|
|
288
|
+
status: 'succeeded',
|
|
289
|
+
customer_id: customerId,
|
|
290
|
+
amount_received: { [Op.gt]: '0' },
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
return payments.reduce((acc: GroupedBN, payment) => {
|
|
295
|
+
const key = payment.currency_id;
|
|
296
|
+
if (!acc[key]) {
|
|
297
|
+
acc[key] = '0';
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
acc[key] = new BN(acc[key]).add(new BN(payment.amount_received)).toString();
|
|
301
|
+
|
|
302
|
+
return acc;
|
|
303
|
+
}, {});
|
|
304
|
+
}
|
|
283
305
|
}
|
|
284
306
|
|
|
285
307
|
export type TPaymentIntent = InferAttributes<PaymentIntent>;
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/lines-between-class-members */
|
|
2
2
|
/* eslint-disable @typescript-eslint/indent */
|
|
3
|
-
import {
|
|
3
|
+
import { BN } from '@ocap/util';
|
|
4
|
+
import { CreationOptional, DataTypes, InferAttributes, InferCreationAttributes, Model, Op } from 'sequelize';
|
|
4
5
|
import type { LiteralUnion } from 'type-fest';
|
|
5
6
|
|
|
6
7
|
import { createEvent, createStatusEvent } from '../../libs/audit';
|
|
7
8
|
import { createIdGenerator } from '../../libs/util';
|
|
8
|
-
import type { PaymentDetails, PaymentError } from './types';
|
|
9
|
+
import type { GroupedBN, PaymentDetails, PaymentError } from './types';
|
|
9
10
|
|
|
10
11
|
export const nextRefundId = createIdGenerator('re', 24);
|
|
11
12
|
|
|
@@ -236,6 +237,27 @@ export class Refund extends Model<InferAttributes<Refund>, InferCreationAttribut
|
|
|
236
237
|
public isImmutable() {
|
|
237
238
|
return ['canceled', 'succeeded'].includes(this.status);
|
|
238
239
|
}
|
|
240
|
+
|
|
241
|
+
public static async getRefundAmountByCustomer(customerId: string, status: string = 'succeeded'): Promise<GroupedBN> {
|
|
242
|
+
const refunds = await Refund.findAll({
|
|
243
|
+
where: {
|
|
244
|
+
status,
|
|
245
|
+
customer_id: customerId,
|
|
246
|
+
amount: { [Op.gt]: '0' },
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
return refunds.reduce((acc: GroupedBN, refund) => {
|
|
251
|
+
const key = refund.currency_id;
|
|
252
|
+
if (!acc[key]) {
|
|
253
|
+
acc[key] = '0';
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
acc[key] = new BN(acc[key]).add(new BN(refund.amount)).toString();
|
|
257
|
+
|
|
258
|
+
return acc;
|
|
259
|
+
}, {});
|
|
260
|
+
}
|
|
239
261
|
}
|
|
240
262
|
|
|
241
263
|
export type TRefund = InferAttributes<Refund>;
|
package/blocklet.yml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "payment-kit",
|
|
3
|
-
"version": "1.13.
|
|
3
|
+
"version": "1.13.153",
|
|
4
4
|
"scripts": {
|
|
5
5
|
"dev": "cross-env COMPONENT_STORE_URL=https://test.store.blocklet.dev blocklet dev --open",
|
|
6
6
|
"eject": "vite eject",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"@arcblock/jwt": "^1.18.110",
|
|
51
51
|
"@arcblock/ux": "^2.9.29",
|
|
52
52
|
"@blocklet/logger": "1.16.23",
|
|
53
|
-
"@blocklet/payment-react": "1.13.
|
|
53
|
+
"@blocklet/payment-react": "1.13.153",
|
|
54
54
|
"@blocklet/sdk": "1.16.23",
|
|
55
55
|
"@blocklet/ui-react": "^2.9.29",
|
|
56
56
|
"@blocklet/uploader": "^0.0.73",
|
|
@@ -110,7 +110,7 @@
|
|
|
110
110
|
"devDependencies": {
|
|
111
111
|
"@abtnode/types": "1.16.23",
|
|
112
112
|
"@arcblock/eslint-config-ts": "^0.2.4",
|
|
113
|
-
"@blocklet/payment-types": "1.13.
|
|
113
|
+
"@blocklet/payment-types": "1.13.153",
|
|
114
114
|
"@types/cookie-parser": "^1.4.6",
|
|
115
115
|
"@types/cors": "^2.8.17",
|
|
116
116
|
"@types/dotenv-flow": "^3.3.3",
|
|
@@ -149,5 +149,5 @@
|
|
|
149
149
|
"parser": "typescript"
|
|
150
150
|
}
|
|
151
151
|
},
|
|
152
|
-
"gitHead": "
|
|
152
|
+
"gitHead": "db16f61414e8f72da55dfa2faf9c17d79fcb6655"
|
|
153
153
|
}
|
package/src/app.tsx
CHANGED
|
@@ -20,7 +20,8 @@ const HomePage = React.lazy(() => import('./pages/home'));
|
|
|
20
20
|
const CheckoutPage = React.lazy(() => import('./pages/checkout'));
|
|
21
21
|
const AdminPage = React.lazy(() => import('./pages/admin'));
|
|
22
22
|
const CustomerHome = React.lazy(() => import('./pages/customer/index'));
|
|
23
|
-
const
|
|
23
|
+
const CustomerInvoiceDetail = React.lazy(() => import('./pages/customer/invoice/detail'));
|
|
24
|
+
const CustomerInvoicePastDue = React.lazy(() => import('./pages/customer/invoice/past-due'));
|
|
24
25
|
const CustomerSubscriptionDetail = React.lazy(() => import('./pages/customer/subscription/detail'));
|
|
25
26
|
const CustomerSubscriptionUpdate = React.lazy(() => import('./pages/customer/subscription/update'));
|
|
26
27
|
|
|
@@ -79,12 +80,21 @@ function App() {
|
|
|
79
80
|
}
|
|
80
81
|
/>
|
|
81
82
|
<Route key="subscription-embed" path="/customer/embed/subscription" element={<MiniInvoiceList />} />,
|
|
83
|
+
<Route
|
|
84
|
+
key="customer-due"
|
|
85
|
+
path="/customer/invoice/past-due"
|
|
86
|
+
element={
|
|
87
|
+
<Layout>
|
|
88
|
+
<CustomerInvoicePastDue />
|
|
89
|
+
</Layout>
|
|
90
|
+
}
|
|
91
|
+
/>
|
|
82
92
|
<Route
|
|
83
93
|
key="customer-invoice"
|
|
84
94
|
path="/customer/invoice/:id"
|
|
85
95
|
element={
|
|
86
96
|
<Layout>
|
|
87
|
-
<
|
|
97
|
+
<CustomerInvoiceDetail />
|
|
88
98
|
</Layout>
|
|
89
99
|
}
|
|
90
100
|
/>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { formatAmount, usePaymentContext } from '@blocklet/payment-react';
|
|
2
|
+
import type { GroupedBN } from '@blocklet/payment-types';
|
|
3
|
+
import { Stack, Typography } from '@mui/material';
|
|
4
|
+
import flatten from 'lodash/flatten';
|
|
5
|
+
import isEmpty from 'lodash/isEmpty';
|
|
6
|
+
|
|
7
|
+
type Props = {
|
|
8
|
+
data?: GroupedBN;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export default function BalanceList(props: Props) {
|
|
12
|
+
const { settings } = usePaymentContext();
|
|
13
|
+
const currencies = flatten(settings.paymentMethods.map((method) => method.payment_currencies));
|
|
14
|
+
|
|
15
|
+
if (isEmpty(props.data)) {
|
|
16
|
+
return <Typography>None</Typography>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<Stack direction="column" alignItems="flex-start" sx={{ width: '100%' }}>
|
|
21
|
+
{Object.entries(props.data).map(([currencyId, amount]) => {
|
|
22
|
+
const currency = currencies.find((c) => c.id === currencyId);
|
|
23
|
+
if (!currency) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
return (
|
|
27
|
+
<Stack key={currencyId} sx={{ width: '100%' }} direction="row" spacing={1}>
|
|
28
|
+
<Typography sx={{ flex: 1 }} color="text.primary">
|
|
29
|
+
{formatAmount(amount, currency.decimal)}
|
|
30
|
+
</Typography>
|
|
31
|
+
<Typography sx={{ width: '32px' }} color="text.secondary">
|
|
32
|
+
{currency.symbol}
|
|
33
|
+
</Typography>
|
|
34
|
+
</Stack>
|
|
35
|
+
);
|
|
36
|
+
})}
|
|
37
|
+
</Stack>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
BalanceList.defaultProps = {
|
|
42
|
+
data: {},
|
|
43
|
+
};
|
|
@@ -12,7 +12,7 @@ export default function InfoMetric(props: Props) {
|
|
|
12
12
|
return (
|
|
13
13
|
<>
|
|
14
14
|
<Stack direction="column" alignItems="flex-start">
|
|
15
|
-
<Typography variant="body1" mb={1} color="text.secondary">
|
|
15
|
+
<Typography component="div" variant="body1" mb={1} color="text.secondary">
|
|
16
16
|
{props.label}
|
|
17
17
|
{!!props.tip && (
|
|
18
18
|
<Tooltip title={props.tip}>
|
|
@@ -20,7 +20,7 @@ export default function InfoMetric(props: Props) {
|
|
|
20
20
|
</Tooltip>
|
|
21
21
|
)}
|
|
22
22
|
</Typography>
|
|
23
|
-
<Typography variant="body1" color="text.primary">
|
|
23
|
+
<Typography component="div" variant="body1" color="text.primary" sx={{ width: '100%' }}>
|
|
24
24
|
{props.value}
|
|
25
25
|
</Typography>
|
|
26
26
|
</Stack>
|
package/src/global.css
CHANGED
package/src/locales/en.tsx
CHANGED
package/src/locales/zh.tsx
CHANGED
|
@@ -3,7 +3,7 @@ import DidAddress from '@arcblock/ux/lib/DID';
|
|
|
3
3
|
import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
|
|
4
4
|
import Toast from '@arcblock/ux/lib/Toast';
|
|
5
5
|
import { api, formatError, formatTime, usePaymentContext } from '@blocklet/payment-react';
|
|
6
|
-
import type { TCustomerExpanded, TPaymentMethodExpanded } from '@blocklet/payment-types';
|
|
6
|
+
import type { GroupedBN, TCustomerExpanded, TPaymentMethodExpanded } from '@blocklet/payment-types';
|
|
7
7
|
import { ArrowBackOutlined, Edit } from '@mui/icons-material';
|
|
8
8
|
import { Alert, Box, Button, CircularProgress, Stack, Typography } from '@mui/material';
|
|
9
9
|
import { styled } from '@mui/system';
|
|
@@ -13,6 +13,7 @@ import { isEmpty } from 'lodash';
|
|
|
13
13
|
import { FlagEmoji } from 'react-international-phone';
|
|
14
14
|
import { Link, useNavigate } from 'react-router-dom';
|
|
15
15
|
|
|
16
|
+
import BalanceList from '../../../../components/balance-list';
|
|
16
17
|
import Copyable from '../../../../components/copyable';
|
|
17
18
|
import CustomerActions from '../../../../components/customer/actions';
|
|
18
19
|
import EditCustomer from '../../../../components/customer/edit';
|
|
@@ -25,8 +26,18 @@ import PaymentList from '../../../../components/payment-intent/list';
|
|
|
25
26
|
import SectionHeader from '../../../../components/section/header';
|
|
26
27
|
import SubscriptionList from '../../../../components/subscription/list';
|
|
27
28
|
|
|
28
|
-
const fetchData = (
|
|
29
|
-
|
|
29
|
+
const fetchData = async (
|
|
30
|
+
id: string
|
|
31
|
+
): Promise<{ customer: TCustomerExpanded; summary: { [key: string]: GroupedBN } }> => {
|
|
32
|
+
const results = await Promise.all([
|
|
33
|
+
api.get(`/api/customers/${id}`).then((res) => res.data),
|
|
34
|
+
api.get(`/api/customers/${id}/summary`).then((res) => res.data),
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
customer: results[0],
|
|
39
|
+
summary: results[1],
|
|
40
|
+
};
|
|
30
41
|
};
|
|
31
42
|
|
|
32
43
|
function getTokenBalances(customer: TCustomerExpanded, paymentMethods: TPaymentMethodExpanded[]) {
|
|
@@ -123,7 +134,7 @@ export default function CustomerDetail(props: { id: string }) {
|
|
|
123
134
|
}
|
|
124
135
|
};
|
|
125
136
|
|
|
126
|
-
const tokenBalances = getTokenBalances(data, settings.paymentMethods);
|
|
137
|
+
const tokenBalances = getTokenBalances(data.customer, settings.paymentMethods);
|
|
127
138
|
|
|
128
139
|
return (
|
|
129
140
|
<Root direction="column" spacing={4} sx={{ mb: 4 }}>
|
|
@@ -142,9 +153,9 @@ export default function CustomerDetail(props: { id: string }) {
|
|
|
142
153
|
<Box mt={2}>
|
|
143
154
|
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
|
144
155
|
<Typography variant="h5" sx={{ fontWeight: 600 }}>
|
|
145
|
-
{data.name}
|
|
156
|
+
{data.customer.name}
|
|
146
157
|
</Typography>
|
|
147
|
-
<CustomerActions data={data} onChange={onChange} variant="normal" />
|
|
158
|
+
<CustomerActions data={data.customer} onChange={onChange} variant="normal" />
|
|
148
159
|
</Stack>
|
|
149
160
|
<Stack
|
|
150
161
|
className="section-body"
|
|
@@ -153,9 +164,15 @@ export default function CustomerDetail(props: { id: string }) {
|
|
|
153
164
|
justifyContent="flex-start"
|
|
154
165
|
flexWrap="wrap"
|
|
155
166
|
sx={{ pt: 2, mt: 2, borderTop: '1px solid #eee' }}>
|
|
156
|
-
<InfoMetric label={t('common.createdAt')} value={formatTime(data.created_at)} divider />
|
|
157
|
-
<InfoMetric label={t('common.updatedAt')} value={formatTime(data.updated_at)} divider />
|
|
158
|
-
<InfoMetric label={t('admin.customer.spent')} value={
|
|
167
|
+
<InfoMetric label={t('common.createdAt')} value={formatTime(data.customer.created_at)} divider />
|
|
168
|
+
<InfoMetric label={t('common.updatedAt')} value={formatTime(data.customer.updated_at)} divider />
|
|
169
|
+
<InfoMetric label={t('admin.customer.spent')} value={<BalanceList data={data.summary.paid} />} divider />
|
|
170
|
+
<InfoMetric
|
|
171
|
+
label={t('admin.customer.refund')}
|
|
172
|
+
value={<BalanceList data={data.summary.refunded} />}
|
|
173
|
+
divider
|
|
174
|
+
/>
|
|
175
|
+
<InfoMetric label={t('admin.customer.due')} value={<BalanceList data={data.summary.due} />} divider />
|
|
159
176
|
{tokenBalances.map((x) => (
|
|
160
177
|
<InfoMetric
|
|
161
178
|
key={x.currency}
|
|
@@ -181,13 +198,13 @@ export default function CustomerDetail(props: { id: string }) {
|
|
|
181
198
|
</Button>
|
|
182
199
|
</SectionHeader>
|
|
183
200
|
<Stack>
|
|
184
|
-
<InfoRow label={t('common.did')} value={<DidAddress did={data.did} />} />
|
|
185
|
-
<InfoRow label={t('admin.customer.name')} value={data.name} />
|
|
186
|
-
<InfoRow label={t('admin.customer.phone')} value={data.phone} />
|
|
187
|
-
<InfoRow label={t('admin.customer.email')} value={data.email} />
|
|
188
|
-
<InfoRow label={t('admin.customer.invoicePrefix')} value={data.invoice_prefix} />
|
|
189
|
-
<InfoRow label={t('common.createdAt')} value={formatTime(data.created_at)} />
|
|
190
|
-
<InfoRow label={t('common.updatedAt')} value={formatTime(data.updated_at)} />
|
|
201
|
+
<InfoRow label={t('common.did')} value={<DidAddress did={data.customer.did} />} />
|
|
202
|
+
<InfoRow label={t('admin.customer.name')} value={data.customer.name} />
|
|
203
|
+
<InfoRow label={t('admin.customer.phone')} value={data.customer.phone} />
|
|
204
|
+
<InfoRow label={t('admin.customer.email')} value={data.customer.email} />
|
|
205
|
+
<InfoRow label={t('admin.customer.invoicePrefix')} value={data.customer.invoice_prefix} />
|
|
206
|
+
<InfoRow label={t('common.createdAt')} value={formatTime(data.customer.created_at)} />
|
|
207
|
+
<InfoRow label={t('common.updatedAt')} value={formatTime(data.customer.updated_at)} />
|
|
191
208
|
<InfoRow
|
|
192
209
|
alignItems="flex-start"
|
|
193
210
|
label={t('admin.customer.address.label')}
|
|
@@ -196,24 +213,24 @@ export default function CustomerDetail(props: { id: string }) {
|
|
|
196
213
|
<InfoRow
|
|
197
214
|
label={t('admin.customer.address.country')}
|
|
198
215
|
value={
|
|
199
|
-
data.address?.country ? (
|
|
200
|
-
<FlagEmoji iso2={data.address?.country} style={{ display: 'flex', width: 24 }} />
|
|
216
|
+
data.customer.address?.country ? (
|
|
217
|
+
<FlagEmoji iso2={data.customer.address?.country} style={{ display: 'flex', width: 24 }} />
|
|
201
218
|
) : (
|
|
202
219
|
''
|
|
203
220
|
)
|
|
204
221
|
}
|
|
205
222
|
/>
|
|
206
|
-
<InfoRow label={t('admin.customer.address.state')} value={data.address?.state} />
|
|
207
|
-
<InfoRow label={t('admin.customer.address.city')} value={data.address?.city} />
|
|
208
|
-
<InfoRow label={t('admin.customer.address.line1')} value={data.address?.line1} />
|
|
209
|
-
<InfoRow label={t('admin.customer.address.line2')} value={data.address?.line2} />
|
|
210
|
-
<InfoRow label={t('admin.customer.address.postal_code')} value={data.address?.postal_code} />
|
|
223
|
+
<InfoRow label={t('admin.customer.address.state')} value={data.customer.address?.state} />
|
|
224
|
+
<InfoRow label={t('admin.customer.address.city')} value={data.customer.address?.city} />
|
|
225
|
+
<InfoRow label={t('admin.customer.address.line1')} value={data.customer.address?.line1} />
|
|
226
|
+
<InfoRow label={t('admin.customer.address.line2')} value={data.customer.address?.line2} />
|
|
227
|
+
<InfoRow label={t('admin.customer.address.postal_code')} value={data.customer.address?.postal_code} />
|
|
211
228
|
</Stack>
|
|
212
229
|
}
|
|
213
230
|
/>
|
|
214
231
|
{state.editing.customer && (
|
|
215
232
|
<EditCustomer
|
|
216
|
-
data={data}
|
|
233
|
+
data={data.customer}
|
|
217
234
|
loading={state.loading.customer}
|
|
218
235
|
onSave={onUpdateInfo}
|
|
219
236
|
onCancel={() => setState((prev) => ({ editing: { ...prev.editing, customer: false } }))}
|
|
@@ -224,19 +241,19 @@ export default function CustomerDetail(props: { id: string }) {
|
|
|
224
241
|
<Box className="section">
|
|
225
242
|
<SectionHeader title={t('admin.subscriptions')} mb={0} />
|
|
226
243
|
<Box className="section-body">
|
|
227
|
-
<SubscriptionList features={{ customer: false, toolbar: false }} customer_id={data.id} />
|
|
244
|
+
<SubscriptionList features={{ customer: false, toolbar: false }} customer_id={data.customer.id} />
|
|
228
245
|
</Box>
|
|
229
246
|
</Box>
|
|
230
247
|
<Box className="section">
|
|
231
248
|
<SectionHeader title={t('admin.payments')} mb={0} />
|
|
232
249
|
<Box className="section-body">
|
|
233
|
-
<PaymentList features={{ customer: false, toolbar: false }} customer_id={data.id} />
|
|
250
|
+
<PaymentList features={{ customer: false, toolbar: false }} customer_id={data.customer.id} />
|
|
234
251
|
</Box>
|
|
235
252
|
</Box>
|
|
236
253
|
<Box className="section">
|
|
237
254
|
<SectionHeader title={t('admin.invoices')} mb={0} />
|
|
238
255
|
<Box className="section-body">
|
|
239
|
-
<InvoiceList features={{ customer: false, toolbar: false }} customer_id={data.id} />
|
|
256
|
+
<InvoiceList features={{ customer: false, toolbar: false }} customer_id={data.customer.id} />
|
|
240
257
|
</Box>
|
|
241
258
|
</Box>
|
|
242
259
|
<Box className="section">
|
|
@@ -253,16 +270,16 @@ export default function CustomerDetail(props: { id: string }) {
|
|
|
253
270
|
</SectionHeader>
|
|
254
271
|
<Box className="section-body">
|
|
255
272
|
{!state.editing.metadata &&
|
|
256
|
-
(isEmpty(data.metadata) ? (
|
|
273
|
+
(isEmpty(data.customer.metadata) ? (
|
|
257
274
|
<Typography color="text.secondary">{t('common.metadata.empty')}</Typography>
|
|
258
275
|
) : (
|
|
259
|
-
Object.keys(data.metadata || {}).map((key) => (
|
|
260
|
-
<InfoRow key={key} label={key} value={data.metadata[key]} />
|
|
276
|
+
Object.keys(data.customer.metadata || {}).map((key) => (
|
|
277
|
+
<InfoRow key={key} label={key} value={data.customer.metadata[key]} />
|
|
261
278
|
))
|
|
262
279
|
))}
|
|
263
280
|
{state.editing.metadata && (
|
|
264
281
|
<MetadataEditor
|
|
265
|
-
data={data}
|
|
282
|
+
data={data.customer}
|
|
266
283
|
loading={state.loading.metadata}
|
|
267
284
|
onSave={onUpdateMetadata}
|
|
268
285
|
onCancel={() => setState((prev) => ({ editing: { ...prev.editing, metadata: false } }))}
|
|
@@ -273,7 +290,7 @@ export default function CustomerDetail(props: { id: string }) {
|
|
|
273
290
|
<Box className="section">
|
|
274
291
|
<SectionHeader title={t('admin.events')} />
|
|
275
292
|
<Box className="section-body">
|
|
276
|
-
<EventList features={{ toolbar: false }} object_id={data.id} />
|
|
293
|
+
<EventList features={{ toolbar: false }} object_id={data.customer.id} />
|
|
277
294
|
</Box>
|
|
278
295
|
</Box>
|
|
279
296
|
</Root>
|
|
@@ -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 {
|
|
4
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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>
|
|
@@ -1,15 +1,7 @@
|
|
|
1
1
|
/* eslint-disable jsx-a11y/anchor-is-valid */
|
|
2
2
|
import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
|
|
3
3
|
import Toast from '@arcblock/ux/lib/Toast';
|
|
4
|
-
import {
|
|
5
|
-
PaymentProvider,
|
|
6
|
-
Status,
|
|
7
|
-
TxLink,
|
|
8
|
-
api,
|
|
9
|
-
formatError,
|
|
10
|
-
formatTime,
|
|
11
|
-
getInvoiceStatusColor,
|
|
12
|
-
} from '@blocklet/payment-react';
|
|
4
|
+
import { Status, TxLink, api, formatError, formatTime, getInvoiceStatusColor } from '@blocklet/payment-react';
|
|
13
5
|
import type { TInvoiceExpanded } from '@blocklet/payment-types';
|
|
14
6
|
import { ArrowBackOutlined } from '@mui/icons-material';
|
|
15
7
|
import { Alert, Box, Button, CircularProgress, Stack, Typography } from '@mui/material';
|
|
@@ -17,22 +9,22 @@ import { useRequest, useSetState } from 'ahooks';
|
|
|
17
9
|
import { useEffect } from 'react';
|
|
18
10
|
import { Link, useParams, useSearchParams } from 'react-router-dom';
|
|
19
11
|
|
|
20
|
-
import Currency from '
|
|
21
|
-
import InfoRow from '
|
|
22
|
-
import InvoiceTable from '
|
|
23
|
-
import SectionHeader from '
|
|
24
|
-
import { useSessionContext } from '
|
|
25
|
-
import CustomerRefundList from '
|
|
12
|
+
import Currency from '../../../components/currency';
|
|
13
|
+
import InfoRow from '../../../components/info-row';
|
|
14
|
+
import InvoiceTable from '../../../components/invoice/table';
|
|
15
|
+
import SectionHeader from '../../../components/section/header';
|
|
16
|
+
import { useSessionContext } from '../../../contexts/session';
|
|
17
|
+
import CustomerRefundList from '../refund/list';
|
|
26
18
|
|
|
27
19
|
const fetchData = (id: string): Promise<TInvoiceExpanded> => {
|
|
28
20
|
return api.get(`/api/invoices/${id}`).then((res) => res.data);
|
|
29
21
|
};
|
|
30
22
|
|
|
31
23
|
// TODO: download feature using: https://react-pdf.org/
|
|
32
|
-
export default function
|
|
24
|
+
export default function CustomerInvoiceDetail() {
|
|
33
25
|
const { t } = useLocaleContext();
|
|
34
26
|
const [searchParams] = useSearchParams();
|
|
35
|
-
const {
|
|
27
|
+
const { connectApi } = useSessionContext();
|
|
36
28
|
const params = useParams<{ id: string }>();
|
|
37
29
|
const [state, setState] = useSetState({
|
|
38
30
|
downloading: false,
|
|
@@ -98,8 +90,8 @@ export default function CustomerHome() {
|
|
|
98
90
|
}
|
|
99
91
|
|
|
100
92
|
return (
|
|
101
|
-
<
|
|
102
|
-
<
|
|
93
|
+
<Stack direction="column" spacing={3} sx={{ my: 2, maxWidth: 'lg' }}>
|
|
94
|
+
<Stack direction="row" justifyContent="space-between">
|
|
103
95
|
<Link to="/customer">
|
|
104
96
|
<Stack direction="row" alignItems="center" sx={{ fontWeight: 'normal', padding: '10px 0' }}>
|
|
105
97
|
<ArrowBackOutlined fontSize="small" sx={{ mr: 0.5, color: 'text.secondary' }} />
|
|
@@ -108,68 +100,75 @@ export default function CustomerHome() {
|
|
|
108
100
|
</Typography>
|
|
109
101
|
</Stack>
|
|
110
102
|
</Link>
|
|
111
|
-
<
|
|
112
|
-
<SectionHeader title={t('payment.customer.invoice.summary')} mb={0} mt={1} />
|
|
113
|
-
<Stack sx={{ mt: 1, display: 'grid', gridTemplateColumns: '50% 50%' }}>
|
|
114
|
-
<InfoRow label={t('admin.invoice.number')} value={data.number} />
|
|
115
|
-
<InfoRow label={t('admin.invoice.from')} value={data.statement_descriptor || blocklet.appName} />
|
|
116
|
-
<InfoRow label={t('admin.invoice.customer')} value={data.customer.name} />
|
|
117
|
-
<InfoRow
|
|
118
|
-
label={t('common.status')}
|
|
119
|
-
value={<Status label={data.status} color={getInvoiceStatusColor(data.status)} />}
|
|
120
|
-
/>
|
|
121
|
-
{data.period_start > 0 && data.period_end > 0 && (
|
|
122
|
-
<InfoRow
|
|
123
|
-
label={t('admin.subscription.currentPeriod')}
|
|
124
|
-
value={[formatTime(data.period_start * 1000), formatTime(data.period_end * 1000)].join(' ~ ')}
|
|
125
|
-
/>
|
|
126
|
-
)}
|
|
127
|
-
{data.status_transitions?.paid_at && (
|
|
128
|
-
<InfoRow label={t('admin.invoice.paidAt')} value={formatTime(data.status_transitions.paid_at * 1000)} />
|
|
129
|
-
)}
|
|
130
|
-
<InfoRow
|
|
131
|
-
label={t('admin.paymentCurrency.name')}
|
|
132
|
-
value={<Currency logo={data.paymentCurrency.logo} name={data.paymentCurrency.symbol} />}
|
|
133
|
-
/>
|
|
134
|
-
<InfoRow
|
|
135
|
-
label={t('common.txHash')}
|
|
136
|
-
value={
|
|
137
|
-
data.paymentIntent?.payment_details ? (
|
|
138
|
-
<TxLink details={data.paymentIntent.payment_details} method={data.paymentMethod} mode="customer" />
|
|
139
|
-
) : (
|
|
140
|
-
''
|
|
141
|
-
)
|
|
142
|
-
}
|
|
143
|
-
/>
|
|
144
|
-
</Stack>
|
|
145
|
-
</Box>
|
|
146
|
-
<SectionHeader title={t('payment.customer.invoice.details')} mb={0} mt={1}>
|
|
103
|
+
<Stack direction="row" justifyContent="flex-end" alignItems="center">
|
|
147
104
|
{['open', 'paid', 'uncollectible'].includes(data.status) && (
|
|
148
105
|
<Button
|
|
149
106
|
variant="contained"
|
|
150
|
-
color="
|
|
107
|
+
color="secondary"
|
|
151
108
|
size="small"
|
|
152
109
|
disabled={state.downloading}
|
|
153
110
|
onClick={() => setState({ downloading: true })}>
|
|
154
111
|
{t('payment.customer.invoice.download')}
|
|
155
112
|
</Button>
|
|
156
113
|
)}
|
|
157
|
-
</SectionHeader>
|
|
158
|
-
<InvoiceTable invoice={data} simple />
|
|
159
|
-
<Stack direction="row" justifyContent="flex-end" alignItems="center" mt={2}>
|
|
160
114
|
{['open', 'uncollectible'].includes(data.status) && (
|
|
161
|
-
<Button
|
|
115
|
+
<Button
|
|
116
|
+
variant="contained"
|
|
117
|
+
color="primary"
|
|
118
|
+
size="small"
|
|
119
|
+
disabled={state.paying}
|
|
120
|
+
sx={{ mr: 1 }}
|
|
121
|
+
onClick={onPay}>
|
|
162
122
|
{t('payment.customer.invoice.pay')}
|
|
163
123
|
</Button>
|
|
164
124
|
)}
|
|
165
125
|
</Stack>
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
126
|
+
</Stack>
|
|
127
|
+
<Box>
|
|
128
|
+
<SectionHeader title={t('payment.customer.invoice.summary')} />
|
|
129
|
+
<Stack sx={{ mt: 1, display: 'grid', gridTemplateColumns: '50% 50%' }}>
|
|
130
|
+
<InfoRow label={t('admin.invoice.number')} value={data.number} />
|
|
131
|
+
<InfoRow
|
|
132
|
+
label={t('common.status')}
|
|
133
|
+
value={<Status label={data.status} color={getInvoiceStatusColor(data.status)} />}
|
|
134
|
+
/>
|
|
135
|
+
<InfoRow label={t('admin.invoice.from')} value={data.statement_descriptor || blocklet.appName} />
|
|
136
|
+
<InfoRow label={t('admin.invoice.customer')} value={data.customer.name} />
|
|
137
|
+
{data.period_start > 0 && data.period_end > 0 && (
|
|
138
|
+
<InfoRow
|
|
139
|
+
label={t('admin.subscription.currentPeriod')}
|
|
140
|
+
value={[formatTime(data.period_start * 1000), formatTime(data.period_end * 1000)].join(' ~ ')}
|
|
141
|
+
/>
|
|
142
|
+
)}
|
|
143
|
+
{data.status_transitions?.paid_at && (
|
|
144
|
+
<InfoRow label={t('admin.invoice.paidAt')} value={formatTime(data.status_transitions.paid_at * 1000)} />
|
|
145
|
+
)}
|
|
146
|
+
<InfoRow
|
|
147
|
+
label={t('admin.paymentCurrency.name')}
|
|
148
|
+
value={<Currency logo={data.paymentCurrency.logo} name={data.paymentCurrency.symbol} />}
|
|
149
|
+
/>
|
|
150
|
+
<InfoRow
|
|
151
|
+
label={t('common.txHash')}
|
|
152
|
+
value={
|
|
153
|
+
data.paymentIntent?.payment_details ? (
|
|
154
|
+
<TxLink details={data.paymentIntent.payment_details} method={data.paymentMethod} mode="customer" />
|
|
155
|
+
) : (
|
|
156
|
+
''
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
/>
|
|
160
|
+
</Stack>
|
|
161
|
+
</Box>
|
|
162
|
+
<Box>
|
|
163
|
+
<SectionHeader title={t('payment.customer.invoice.details')} />
|
|
164
|
+
<InvoiceTable invoice={data} simple />
|
|
165
|
+
</Box>
|
|
166
|
+
<Box className="section">
|
|
167
|
+
<SectionHeader title={t('admin.refunds')} mb={0} mt={0} />
|
|
168
|
+
<Box className="section-body">
|
|
169
|
+
<CustomerRefundList invoice_id={data.id} />
|
|
171
170
|
</Box>
|
|
172
|
-
</
|
|
173
|
-
</
|
|
171
|
+
</Box>
|
|
172
|
+
</Stack>
|
|
174
173
|
);
|
|
175
174
|
}
|
|
@@ -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
|
+
`;
|
|
@@ -50,7 +50,7 @@ export default function CustomerRefundList({ invoice_id }: Props) {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
if (data && data.list.length === 0) {
|
|
53
|
-
return <Typography color="text.secondary">{t('payment.customer.
|
|
53
|
+
return <Typography color="text.secondary">{t('payment.customer.refund.empty')}</Typography>;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
const hasMore = data && data.list.length < data.count;
|