payment-kit 1.13.151 → 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.
@@ -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
- res.json(doc);
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 { CreationOptional, DataTypes, InferAttributes, InferCreationAttributes, Model } from 'sequelize';
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 { CreationOptional, DataTypes, InferAttributes, InferCreationAttributes, Model } from 'sequelize';
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 { CreationOptional, DataTypes, InferAttributes, InferCreationAttributes, Model } from 'sequelize';
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>;
@@ -1,6 +1,8 @@
1
1
  /* eslint-disable @typescript-eslint/indent */
2
2
  import type { LiteralUnion } from 'type-fest';
3
3
 
4
+ export type GroupedBN = { [currencyId: string]: string };
5
+
4
6
  export type Pagination<T = any> = T & {
5
7
  // offset based
6
8
  page?: number;
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.13.151
17
+ version: 1.13.152
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.13.151",
3
+ "version": "1.13.152",
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.151",
53
+ "@blocklet/payment-react": "1.13.152",
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.151",
113
+ "@blocklet/payment-types": "1.13.152",
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": "1697697b65f9372e868fb10d5766edb3144f44f8"
152
+ "gitHead": "5d6d2b6a8c0422e1055ddcec4e3179fd734cdbc5"
153
153
  }
package/src/app.tsx CHANGED
@@ -21,6 +21,7 @@ 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
23
  const CustomerInvoice = React.lazy(() => import('./pages/customer/invoice'));
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,6 +80,15 @@ 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"
@@ -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
@@ -10,7 +10,6 @@ a {
10
10
  color: rgba(0, 0, 0, 0.87);
11
11
  }
12
12
  a:hover {
13
- color: rgba(0, 0, 0, 0.87);
14
13
  text-decoration: underline;
15
14
  }
16
15
 
@@ -392,6 +392,7 @@ export default flat({
392
392
  spent: 'Spent Amount',
393
393
  refund: 'Refund Amount',
394
394
  dispute: 'Dispute Amount',
395
+ due: 'Due Amount',
395
396
  name: 'Name',
396
397
  email: 'Email',
397
398
  phone: 'Phone',
@@ -383,6 +383,7 @@ export default flat({
383
383
  spent: '花费金额',
384
384
  refund: '退款金额',
385
385
  dispute: '争议金额',
386
+ due: '欠款金额',
386
387
  name: '名称',
387
388
  email: '电子邮件',
388
389
  phone: '电话',
@@ -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 = (id: string): Promise<TCustomerExpanded> => {
29
- return api.get(`/api/customers/${id}`).then((res) => res.data);
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={0} divider />
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 { 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
+ `;
@@ -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';
@@ -29,10 +21,10 @@ const fetchData = (id: string): Promise<TInvoiceExpanded> => {
29
21
  };
30
22
 
31
23
  // TODO: download feature using: https://react-pdf.org/
32
- export default function CustomerHome() {
24
+ export default function CustomerInvoiceDetail() {
33
25
  const { t } = useLocaleContext();
34
26
  const [searchParams] = useSearchParams();
35
- const { session, connectApi } = useSessionContext();
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
- <Box sx={{ maxWidth: '1200px' }}>
102
- <PaymentProvider session={session} connect={connectApi}>
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,42 +100,12 @@ export default function CustomerHome() {
108
100
  </Typography>
109
101
  </Stack>
110
102
  </Link>
111
- <Box>
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">
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
+ )}
147
109
  {['open', 'paid', 'uncollectible'].includes(data.status) && (
148
110
  <Button
149
111
  variant="contained"
@@ -154,22 +116,53 @@ export default function CustomerHome() {
154
116
  {t('payment.customer.invoice.download')}
155
117
  </Button>
156
118
  )}
157
- </SectionHeader>
158
- <InvoiceTable invoice={data} simple />
159
- <Stack direction="row" justifyContent="flex-end" alignItems="center" mt={2}>
160
- {['open', 'uncollectible'].includes(data.status) && (
161
- <Button variant="contained" color="primary" disabled={state.paying} onClick={onPay}>
162
- {t('payment.customer.invoice.pay')}
163
- </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
+ />
164
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
+ />
165
154
  </Stack>
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} />
170
- </Box>
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} />
171
164
  </Box>
172
- </PaymentProvider>
173
- </Box>
165
+ </Box>
166
+ </Stack>
174
167
  );
175
168
  }