payment-kit 1.13.217 → 1.13.218

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.
@@ -6,9 +6,11 @@ import {
6
6
  batchHandleStripePayments,
7
7
  batchHandleStripeSubscriptions,
8
8
  } from '../integrations/stripe/resource';
9
+ import dayjs from '../libs/dayjs';
9
10
  import {
10
11
  expiredSessionCleanupCronTime,
11
12
  notificationCronTime,
13
+ paymentStatCronTime,
12
14
  revokeStakeCronTime,
13
15
  stripeInvoiceCronTime,
14
16
  stripePaymentCronTime,
@@ -18,6 +20,7 @@ import {
18
20
  import logger from '../libs/logger';
19
21
  import { startSubscriptionQueue } from '../queues/subscription';
20
22
  import { CheckoutSession } from '../store/models';
23
+ import { createPaymentStat } from './payment-stat';
21
24
  import { SubscriptionTrialWillEndSchedule } from './subscription-trial-will-end';
22
25
  import { SubscriptionWillCanceledSchedule } from './subscription-will-canceled';
23
26
  import { SubscriptionWillRenewSchedule } from './subscription-will-renew';
@@ -83,6 +86,12 @@ function init() {
83
86
  fn: checkStakeRevokeTx,
84
87
  options: { runOnInit: false },
85
88
  },
89
+ {
90
+ name: 'payment.stat',
91
+ time: paymentStatCronTime,
92
+ fn: () => createPaymentStat(dayjs().subtract(1, 'day').toDate().toISOString()),
93
+ options: { runOnInit: false },
94
+ },
86
95
  ],
87
96
  onError: (error: Error, name: string) => {
88
97
  logger.error('run job failed', { name, error: error.message, stack: error.stack });
@@ -0,0 +1,101 @@
1
+ /* eslint-disable no-await-in-loop */
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+
5
+ import { BN, fromUnitToToken } from '@ocap/util';
6
+ import { Op } from 'sequelize';
7
+
8
+ import dayjs from '../libs/dayjs';
9
+ import env from '../libs/env';
10
+ import logger from '../libs/logger';
11
+ import { GroupedBN, PaymentCurrency, PaymentIntent, PaymentStat, Payout, Refund } from '../store/models';
12
+
13
+ export function groupByCurrency(items: { amount: string; currency_id: string }[]) {
14
+ return items.reduce((acc: GroupedBN, { amount, currency_id }) => {
15
+ if (!acc[currency_id]) {
16
+ acc[currency_id] = '0';
17
+ }
18
+ acc[currency_id] = new BN(acc[currency_id]).add(new BN(amount)).toString();
19
+ return acc;
20
+ }, {});
21
+ }
22
+
23
+ export async function createPaymentStat(date: string) {
24
+ const start = dayjs(date).startOf('day');
25
+ const end = dayjs(date).add(1, 'day').startOf('day');
26
+ const timestamp = start.unix();
27
+
28
+ // group paymentIntents by currency_id
29
+ const paymentIntents = await PaymentIntent.findAll({
30
+ where: {
31
+ updated_at: { [Op.gte]: start.toDate(), [Op.lt]: end.toDate() },
32
+ status: 'succeeded',
33
+ },
34
+ attributes: ['amount', 'currency_id'],
35
+ });
36
+ const paymentGrouped = groupByCurrency(paymentIntents);
37
+
38
+ // group payouts by currency_id
39
+ const payouts = await Payout.findAll({
40
+ where: {
41
+ updated_at: { [Op.gte]: start.toDate(), [Op.lt]: end.toDate() },
42
+ status: 'paid',
43
+ },
44
+ attributes: ['amount', 'currency_id'],
45
+ });
46
+ const payoutGrouped = groupByCurrency(payouts);
47
+
48
+ // group refunds by currency_id
49
+ const refunds = await Refund.findAll({
50
+ where: {
51
+ updated_at: { [Op.gte]: start.toDate(), [Op.lt]: end.toDate() },
52
+ status: 'succeeded',
53
+ },
54
+ attributes: ['amount', 'currency_id'],
55
+ });
56
+ const refundGrouped = groupByCurrency(refunds);
57
+
58
+ // update for insert
59
+ const currencies = await PaymentCurrency.findAll();
60
+ await Promise.all(
61
+ currencies.map(async (currency) => {
62
+ const exist = await PaymentStat.findOne({ where: { timestamp, currency_id: currency.id } });
63
+ if (exist) {
64
+ await exist.update({
65
+ amount_paid: fromUnitToToken(paymentGrouped[currency.id] || '0', currency.decimal),
66
+ amount_payout: fromUnitToToken(payoutGrouped[currency.id] || '0', currency.decimal),
67
+ amount_refund: fromUnitToToken(refundGrouped[currency.id] || '0', currency.decimal),
68
+ });
69
+ logger.info('PaymentStat updated', { date, timestamp, currency: currency.symbol });
70
+ } else {
71
+ await PaymentStat.create({
72
+ livemode: currency.livemode,
73
+ timestamp,
74
+ currency_id: currency.id,
75
+ amount_paid: fromUnitToToken(paymentGrouped[currency.id] || '0', currency.decimal),
76
+ amount_payout: fromUnitToToken(payoutGrouped[currency.id] || '0', currency.decimal),
77
+ amount_refund: fromUnitToToken(refundGrouped[currency.id] || '0', currency.decimal),
78
+ });
79
+ logger.info('PaymentStat created', { date, timestamp, currency: currency.symbol });
80
+ }
81
+ })
82
+ );
83
+ }
84
+
85
+ export async function ensurePaymentStats() {
86
+ const lock = path.join(env.dataDir, '.payment_stat.lock');
87
+ if (fs.existsSync(lock)) {
88
+ return;
89
+ }
90
+
91
+ const firstPayment = await PaymentIntent.findOne({ order: [['created_at', 'ASC']], attributes: ['created_at'] });
92
+ if (firstPayment) {
93
+ const now = dayjs().unix();
94
+ for (let current = dayjs(firstPayment.created_at); current.unix() < now; current = current.add(1, 'day')) {
95
+ await createPaymentStat(current.toDate().toISOString());
96
+ }
97
+ fs.writeFileSync(lock, '');
98
+ } else {
99
+ logger.warn('No payment found, skip payment stat migration');
100
+ }
101
+ }
@@ -2,6 +2,7 @@ import '@blocklet/sdk/lib/error-handler';
2
2
 
3
3
  import dotenv from 'dotenv-flow';
4
4
 
5
+ import { ensurePaymentStats } from '../crons/payment-stat';
5
6
  import { initPaywallResources } from '../libs/resource';
6
7
  import { initialize } from '../store/models';
7
8
  import { sequelize } from '../store/sequelize';
@@ -12,6 +13,7 @@ dotenv.config({ silent: true });
12
13
  try {
13
14
  initialize(sequelize);
14
15
  await initPaywallResources();
16
+ await ensurePaymentStats();
15
17
  process.exit(0);
16
18
  } catch (err) {
17
19
  console.error('pre-start error', err.message);
@@ -2,6 +2,7 @@
2
2
  /* eslint-disable no-await-in-loop */
3
3
  import assert from 'assert';
4
4
 
5
+ import { isEthereumDid } from '@arcblock/did';
5
6
  import { toStakeAddress } from '@arcblock/did-util';
6
7
  import env from '@blocklet/sdk/lib/env';
7
8
  import { fromUnitToToken, toBN } from '@ocap/util';
@@ -10,6 +11,7 @@ import { wallet } from '../../libs/auth';
10
11
  import { events } from '../../libs/event';
11
12
  import logger from '../../libs/logger';
12
13
  import { Customer, GroupedBN, PaymentCurrency, PaymentMethod, Subscription } from '../../store/models';
14
+ import { fetchErc20Balance, fetchEtherBalance } from '../ethereum/token';
13
15
 
14
16
  export async function ensureStakedForGas() {
15
17
  const currencies = await PaymentCurrency.findAll({ where: { active: true, is_base_currency: true } });
@@ -201,9 +203,9 @@ export async function getStakeSummaryByDid(did: string, livemode: boolean): Prom
201
203
  return results;
202
204
  }
203
205
 
204
- export async function getTokenSummaryByDid(did: string, livemode: boolean): Promise<GroupedBN> {
206
+ export async function getTokenSummaryByDid(did: string, livemode: boolean, type?: string): Promise<GroupedBN> {
205
207
  const methods = await PaymentMethod.findAll({
206
- where: { type: ['arcblock', 'ethereum'], livemode },
208
+ where: { type, livemode },
207
209
  include: [{ model: PaymentCurrency, as: 'payment_currencies' }],
208
210
  });
209
211
  if (methods.length === 0) {
@@ -224,8 +226,18 @@ export async function getTokenSummaryByDid(did: string, livemode: boolean): Prom
224
226
  }
225
227
  });
226
228
  }
227
- if (method.type === 'ethereum') {
228
- // FIXME: how do we get balance for ethereum
229
+ if (method.type === 'ethereum' && isEthereumDid(did)) {
230
+ const client = method.getEvmClient();
231
+ await Promise.all(
232
+ // @ts-ignore
233
+ method.payment_currencies.map(async (c: PaymentCurrency) => {
234
+ if (c.contract) {
235
+ results[c.id] = await fetchErc20Balance(client, c.contract, did);
236
+ } else {
237
+ results[c.id] = await fetchEtherBalance(client, did);
238
+ }
239
+ })
240
+ );
229
241
  }
230
242
  })
231
243
  );
@@ -1,5 +1,6 @@
1
1
  import env from '@blocklet/sdk/lib/env';
2
2
 
3
+ export const paymentStatCronTime: string = '0 1 0 * * *'; // 默认每天一次,计算前一天的
3
4
  export const subscriptionCronTime: string = process.env.SUBSCRIPTION_CRON_TIME || '0 */30 * * * *'; // 默认每 30 min 行一次
4
5
  export const notificationCronTime: string = process.env.NOTIFICATION_CRON_TIME || '0 5 */6 * * *'; // 默认每6个小时执行一次
5
6
  export const expiredSessionCleanupCronTime: string = process.env.EXPIRED_SESSION_CLEANUP_CRON_TIME || '0 1 * * * *'; // 默认每小时执行一次
@@ -12,6 +12,7 @@ import paymentCurrencies from './payment-currencies';
12
12
  import paymentIntents from './payment-intents';
13
13
  import paymentLinks from './payment-links';
14
14
  import paymentMethods from './payment-methods';
15
+ import paymentStats from './payment-stats';
15
16
  import payouts from './payouts';
16
17
  import prices from './prices';
17
18
  import pricingTables from './pricing-table';
@@ -57,6 +58,7 @@ router.use('/payment-intents', paymentIntents);
57
58
  router.use('/payment-links', paymentLinks);
58
59
  router.use('/payment-methods', paymentMethods);
59
60
  router.use('/payment-currencies', paymentCurrencies);
61
+ router.use('/payment-stats', paymentStats);
60
62
  router.use('/prices', prices);
61
63
  router.use('/pricing-tables', pricingTables);
62
64
  router.use('/products', products);
@@ -0,0 +1,81 @@
1
+ import { Router } from 'express';
2
+ import Joi from 'joi';
3
+ import { Op } from 'sequelize';
4
+
5
+ import { getTokenSummaryByDid } from '../integrations/arcblock/stake';
6
+ import { createListParamSchema } from '../libs/api';
7
+ import { ethWallet, wallet } from '../libs/auth';
8
+ import dayjs from '../libs/dayjs';
9
+ import { authenticate } from '../libs/security';
10
+ import { Invoice, PaymentIntent, Payout, Refund, Subscription } from '../store/models';
11
+ import { PaymentStat } from '../store/models/payment-stat';
12
+
13
+ const router = Router();
14
+ const auth = authenticate<PaymentStat>({ component: true, roles: ['owner', 'admin'] });
15
+
16
+ const schema = createListParamSchema<{ currency_id?: string; start?: number; end?: number }>({
17
+ currency_id: Joi.string().optional().empty(''),
18
+ start: Joi.number().positive().optional().empty(''),
19
+ end: Joi.number().positive().optional().empty(''),
20
+ });
21
+ router.get('/', auth, async (req, res) => {
22
+ const { page, pageSize, ...query } = await schema.validateAsync(req.query, { stripUnknown: true });
23
+ const where: any = {};
24
+
25
+ if (typeof query.livemode === 'boolean') {
26
+ where.livemode = query.livemode;
27
+ }
28
+ if (query.currency_id) {
29
+ where.currency_id = query.currency_id;
30
+ }
31
+ where.timestamp = {};
32
+ if (query.start) {
33
+ where.timestamp[Op.gte] = query.start;
34
+ } else {
35
+ where.timestamp[Op.gte] = dayjs().subtract(30, 'days').startOf('day').unix();
36
+ }
37
+ if (query.end) {
38
+ where.timestamp[Op.lt] = query.end;
39
+ } else {
40
+ where.timestamp[Op.lt] = dayjs().startOf('day').unix();
41
+ }
42
+
43
+ try {
44
+ const { rows: list, count } = await PaymentStat.findAndCountAll({
45
+ where,
46
+ order: [['created_at', 'ASC']],
47
+ include: [],
48
+ });
49
+
50
+ res.json({ count, list });
51
+ } catch (err) {
52
+ console.error(err);
53
+ res.json({ count: 0, list: [] });
54
+ }
55
+ });
56
+
57
+ // eslint-disable-next-line consistent-return
58
+ router.get('/summary', auth, async (req, res) => {
59
+ try {
60
+ const [arcblock, ethereum] = await Promise.all([
61
+ getTokenSummaryByDid(wallet.address, !!req.livemode, 'arcblock'),
62
+ getTokenSummaryByDid(ethWallet.address, !!req.livemode, 'ethereum'),
63
+ ]);
64
+ res.json({
65
+ balances: { ...arcblock, ...ethereum },
66
+ addresses: { arcblock: wallet.address, ethereum: ethWallet.address },
67
+ summary: {
68
+ subscription: await Subscription.getSummary(!!req.livemode),
69
+ invoice: await Invoice.getSummary(!!req.livemode),
70
+ payment: await PaymentIntent.getSummary(!!req.livemode),
71
+ payout: await Payout.getSummary(!!req.livemode),
72
+ refund: await Refund.getSummary(!!req.livemode),
73
+ },
74
+ });
75
+ } catch (err) {
76
+ console.error(err);
77
+ res.json(null);
78
+ }
79
+ });
80
+
81
+ export default router;
@@ -0,0 +1,10 @@
1
+ import type { Migration } from '../migrate';
2
+ import models from '../models';
3
+
4
+ export const up: Migration = async ({ context }) => {
5
+ await context.createTable('payment_stats', models.PaymentStat.GENESIS_ATTRIBUTES);
6
+ };
7
+
8
+ export const down: Migration = async ({ context }) => {
9
+ await context.dropTable('payment_stats');
10
+ };
@@ -11,6 +11,7 @@ import { PaymentCurrency, TPaymentCurrency } from './payment-currency';
11
11
  import { PaymentIntent, TPaymentIntent } from './payment-intent';
12
12
  import { PaymentLink, TPaymentLink } from './payment-link';
13
13
  import { PaymentMethod, TPaymentMethod } from './payment-method';
14
+ import { PaymentStat } from './payment-stat';
14
15
  import { Payout, TPayout } from './payout';
15
16
  import { Price, TPrice } from './price';
16
17
  import { PricingTable, TPricingTable } from './pricing-table';
@@ -37,6 +38,7 @@ const models = {
37
38
  PaymentCurrency,
38
39
  PaymentIntent,
39
40
  PaymentLink,
41
+ PaymentStat,
40
42
  Payout,
41
43
  PaymentMethod,
42
44
  Price,
@@ -79,6 +81,7 @@ export * from './payment-currency';
79
81
  export * from './payment-intent';
80
82
  export * from './payment-link';
81
83
  export * from './payment-method';
84
+ export * from './payment-stat';
82
85
  export * from './payout';
83
86
  export * from './price';
84
87
  export * from './pricing-table';
@@ -8,6 +8,7 @@ import {
8
8
  InferCreationAttributes,
9
9
  Model,
10
10
  Op,
11
+ Sequelize,
11
12
  WhereOptions,
12
13
  } from 'sequelize';
13
14
  import type { LiteralUnion } from 'type-fest';
@@ -578,6 +579,15 @@ export class Invoice extends Model<InferAttributes<Invoice>, InferCreationAttrib
578
579
 
579
580
  return this._getUncollectibleAmount(where);
580
581
  }
582
+
583
+ public static getSummary(livemode: boolean = true, field: string = 'status') {
584
+ return this.findAndCountAll({
585
+ where: { livemode },
586
+ attributes: [field, [Sequelize.fn('COUNT', Sequelize.col(field)), 'count']],
587
+ group: [field],
588
+ raw: true,
589
+ }).then((res) => res.rows);
590
+ }
581
591
  }
582
592
 
583
593
  export type TInvoice = InferAttributes<Invoice>;
@@ -1,7 +1,7 @@
1
1
  /* eslint-disable @typescript-eslint/lines-between-class-members */
2
2
  /* eslint-disable @typescript-eslint/indent */
3
3
  import { BN } from '@ocap/util';
4
- import { CreationOptional, DataTypes, InferAttributes, InferCreationAttributes, Model, Op } from 'sequelize';
4
+ import { CreationOptional, DataTypes, InferAttributes, InferCreationAttributes, Model, Op, Sequelize } from 'sequelize';
5
5
  import type { LiteralUnion } from 'type-fest';
6
6
 
7
7
  import { createEvent, createStatusEvent } from '../../libs/audit';
@@ -315,6 +315,15 @@ export class PaymentIntent extends Model<InferAttributes<PaymentIntent>, InferCr
315
315
  return acc;
316
316
  }, {});
317
317
  }
318
+
319
+ public static getSummary(livemode: boolean = true, field: string = 'status') {
320
+ return this.findAndCountAll({
321
+ where: { livemode },
322
+ attributes: [field, [Sequelize.fn('COUNT', Sequelize.col(field)), 'count']],
323
+ group: [field],
324
+ raw: true,
325
+ }).then((res) => res.rows);
326
+ }
318
327
  }
319
328
 
320
329
  export type TPaymentIntent = InferAttributes<PaymentIntent>;
@@ -0,0 +1,79 @@
1
+ /* eslint-disable @typescript-eslint/lines-between-class-members */
2
+ import { CreationOptional, DataTypes, InferAttributes, InferCreationAttributes, Model } from 'sequelize';
3
+
4
+ import { createIdGenerator } from '../../libs/util';
5
+
6
+ const nextId = createIdGenerator('ps', 24);
7
+
8
+ // eslint-disable-next-line prettier/prettier
9
+ export class PaymentStat extends Model<InferAttributes<PaymentStat>, InferCreationAttributes<PaymentStat>> {
10
+ declare id: CreationOptional<string>;
11
+ declare livemode: boolean;
12
+
13
+ declare timestamp: number;
14
+ declare currency_id: string;
15
+
16
+ declare amount_paid: string;
17
+ declare amount_payout: string;
18
+ declare amount_refund: string;
19
+
20
+ declare created_at: CreationOptional<Date>;
21
+ declare updated_at: CreationOptional<Date>;
22
+
23
+ public static readonly GENESIS_ATTRIBUTES = {
24
+ id: {
25
+ type: DataTypes.STRING(30),
26
+ primaryKey: true,
27
+ allowNull: false,
28
+ defaultValue: nextId,
29
+ },
30
+ livemode: {
31
+ type: DataTypes.BOOLEAN,
32
+ allowNull: false,
33
+ },
34
+ timestamp: {
35
+ type: DataTypes.INTEGER,
36
+ defaultValue: 0,
37
+ },
38
+ currency_id: {
39
+ type: DataTypes.STRING(16),
40
+ allowNull: false,
41
+ },
42
+ amount_paid: {
43
+ type: DataTypes.STRING(64),
44
+ defaultValue: '0',
45
+ },
46
+ amount_payout: {
47
+ type: DataTypes.STRING(64),
48
+ defaultValue: '0',
49
+ },
50
+ amount_refund: {
51
+ type: DataTypes.STRING(64),
52
+ defaultValue: '0',
53
+ },
54
+ created_at: {
55
+ type: DataTypes.DATE,
56
+ defaultValue: DataTypes.NOW,
57
+ allowNull: false,
58
+ },
59
+ updated_at: {
60
+ type: DataTypes.DATE,
61
+ defaultValue: DataTypes.NOW,
62
+ allowNull: false,
63
+ },
64
+ };
65
+
66
+ public static initialize(sequelize: any) {
67
+ this.init(PaymentStat.GENESIS_ATTRIBUTES, {
68
+ sequelize,
69
+ modelName: 'PaymentStat',
70
+ tableName: 'payment_stats',
71
+ createdAt: 'created_at',
72
+ updatedAt: 'updated_at',
73
+ });
74
+ }
75
+
76
+ public static associate() {}
77
+ }
78
+
79
+ export type TPaymentStat = InferAttributes<PaymentStat>;
@@ -1,7 +1,7 @@
1
1
  /* eslint-disable @typescript-eslint/lines-between-class-members */
2
2
  /* eslint-disable @typescript-eslint/indent */
3
3
  import { BN } from '@ocap/util';
4
- import { CreationOptional, DataTypes, InferAttributes, InferCreationAttributes, Model, Op } from 'sequelize';
4
+ import { CreationOptional, DataTypes, InferAttributes, InferCreationAttributes, Model, Op, Sequelize } from 'sequelize';
5
5
  import type { LiteralUnion } from 'type-fest';
6
6
 
7
7
  import { createEvent, createStatusEvent } from '../../libs/audit';
@@ -238,6 +238,15 @@ export class Payout extends Model<InferAttributes<Payout>, InferCreationAttribut
238
238
  return acc;
239
239
  }, {});
240
240
  }
241
+
242
+ public static getSummary(livemode: boolean = true, field: string = 'status') {
243
+ return this.findAndCountAll({
244
+ where: { livemode },
245
+ attributes: [field, [Sequelize.fn('COUNT', Sequelize.col(field)), 'count']],
246
+ group: [field],
247
+ raw: true,
248
+ }).then((res) => res.rows);
249
+ }
241
250
  }
242
251
 
243
252
  export type TPayout = InferAttributes<Payout>;
@@ -1,6 +1,6 @@
1
1
  /* eslint-disable @typescript-eslint/lines-between-class-members */
2
2
  import pick from 'lodash/pick';
3
- import { CreationOptional, DataTypes, InferAttributes, InferCreationAttributes, Model } from 'sequelize';
3
+ import { CreationOptional, DataTypes, InferAttributes, InferCreationAttributes, Model, Sequelize } from 'sequelize';
4
4
  import type { LiteralUnion } from 'type-fest';
5
5
 
6
6
  import { createEvent } from '../../libs/audit';
@@ -213,6 +213,15 @@ export class Product extends Model<InferAttributes<Product>, InferCreationAttrib
213
213
 
214
214
  return null;
215
215
  }
216
+
217
+ public static getSummary(livemode: boolean = true, field: string = 'status') {
218
+ return this.findAndCountAll({
219
+ where: { livemode },
220
+ attributes: [field, [Sequelize.fn('COUNT', Sequelize.col(field)), 'count']],
221
+ group: [field],
222
+ raw: true,
223
+ }).then((res) => res.rows);
224
+ }
216
225
  }
217
226
 
218
227
  export type TProduct = InferAttributes<Product>;
@@ -1,7 +1,7 @@
1
1
  /* eslint-disable @typescript-eslint/lines-between-class-members */
2
2
  /* eslint-disable @typescript-eslint/indent */
3
3
  import { BN } from '@ocap/util';
4
- import { CreationOptional, DataTypes, InferAttributes, InferCreationAttributes, Model, Op } from 'sequelize';
4
+ import { CreationOptional, DataTypes, InferAttributes, InferCreationAttributes, Model, Op, Sequelize } from 'sequelize';
5
5
  import type { LiteralUnion } from 'type-fest';
6
6
 
7
7
  import { createEvent, createStatusEvent } from '../../libs/audit';
@@ -258,6 +258,15 @@ export class Refund extends Model<InferAttributes<Refund>, InferCreationAttribut
258
258
  return acc;
259
259
  }, {});
260
260
  }
261
+
262
+ public static getSummary(livemode: boolean = true, field: string = 'status') {
263
+ return this.findAndCountAll({
264
+ where: { livemode },
265
+ attributes: [field, [Sequelize.fn('COUNT', Sequelize.col(field)), 'count']],
266
+ group: [field],
267
+ raw: true,
268
+ }).then((res) => res.rows);
269
+ }
261
270
  }
262
271
 
263
272
  export type TRefund = InferAttributes<Refund>;
@@ -1,6 +1,6 @@
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 { CreationOptional, DataTypes, InferAttributes, InferCreationAttributes, Model, Sequelize } from 'sequelize';
4
4
  import type { LiteralUnion } from 'type-fest';
5
5
 
6
6
  import { createCustomEvent, createEvent, createStatusEvent } from '../../libs/audit';
@@ -401,6 +401,15 @@ export class Subscription extends Model<InferAttributes<Subscription>, InferCrea
401
401
 
402
402
  return true;
403
403
  }
404
+
405
+ public static getSummary(livemode: boolean = true, field: string = 'status') {
406
+ return this.findAndCountAll({
407
+ where: { livemode },
408
+ attributes: [field, [Sequelize.fn('COUNT', Sequelize.col(field)), 'count']],
409
+ group: [field],
410
+ raw: true,
411
+ }).then((res) => res.rows);
412
+ }
404
413
  }
405
414
 
406
415
  export type TSubscription = InferAttributes<Subscription>;
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.217
17
+ version: 1.13.218
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.217",
3
+ "version": "1.13.218",
4
4
  "scripts": {
5
5
  "dev": "cross-env COMPONENT_STORE_URL=https://test.store.blocklet.dev blocklet dev --open",
6
6
  "eject": "vite eject",
@@ -51,7 +51,7 @@
51
51
  "@arcblock/ux": "^2.9.66",
52
52
  "@arcblock/validator": "^1.18.115",
53
53
  "@blocklet/logger": "1.16.25",
54
- "@blocklet/payment-react": "1.13.217",
54
+ "@blocklet/payment-react": "1.13.218",
55
55
  "@blocklet/sdk": "1.16.25",
56
56
  "@blocklet/ui-react": "^2.9.66",
57
57
  "@blocklet/uploader": "^0.0.76",
@@ -75,6 +75,7 @@
75
75
  "cookie-parser": "^1.4.6",
76
76
  "copy-to-clipboard": "^3.3.3",
77
77
  "cors": "^2.8.5",
78
+ "date-fns": "^3.6.0",
78
79
  "dayjs": "^1.11.10",
79
80
  "dotenv-flow": "^3.3.0",
80
81
  "erc-20-abi": "^1.0.1",
@@ -90,6 +91,7 @@
90
91
  "json-stable-stringify": "^1.1.1",
91
92
  "lodash": "^4.17.21",
92
93
  "morgan": "^1.10.0",
94
+ "mui-daterange-picker": "^1.0.5",
93
95
  "nanoid": "3",
94
96
  "p-all": "3.0.0",
95
97
  "p-wait-for": "3",
@@ -115,7 +117,7 @@
115
117
  "devDependencies": {
116
118
  "@abtnode/types": "1.16.25",
117
119
  "@arcblock/eslint-config-ts": "^0.3.0",
118
- "@blocklet/payment-types": "1.13.217",
120
+ "@blocklet/payment-types": "1.13.218",
119
121
  "@types/cookie-parser": "^1.4.6",
120
122
  "@types/cors": "^2.8.17",
121
123
  "@types/dotenv-flow": "^3.3.3",
@@ -154,5 +156,5 @@
154
156
  "parser": "typescript"
155
157
  }
156
158
  },
157
- "gitHead": "3ce8954d7c26c881e0a7a46925447318954adca9"
159
+ "gitHead": "cb421c928f2045ee64b4d283d936fd8ad222010c"
158
160
  }
@@ -0,0 +1,88 @@
1
+ /* eslint-disable react/require-default-props */
2
+ import type { TPaymentCurrency, TPaymentMethod } from '@blocklet/payment-types';
3
+ import { Box, Skeleton } from '@mui/material';
4
+ import { useState } from 'react';
5
+ import { Area, AreaChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
6
+
7
+ export type TCurrency = TPaymentCurrency & { color: string; method: TPaymentMethod };
8
+ export type TCurrencyMap = { [key: string]: TCurrency };
9
+
10
+ export default function Chart({
11
+ loading,
12
+ data,
13
+ currencies,
14
+ height = 320,
15
+ }: {
16
+ loading: boolean;
17
+ data: any[];
18
+ currencies: TCurrencyMap;
19
+ height?: number;
20
+ }) {
21
+ const [series, setSeries] = useState<any>(
22
+ Object.keys(currencies).reduce(
23
+ (acc, key) => {
24
+ // @ts-ignore
25
+ acc[currencies[key].symbol] = false;
26
+ return acc;
27
+ },
28
+ { hover: null }
29
+ )
30
+ );
31
+
32
+ const onMouseOver = (e: any) => {
33
+ if (!series[e.dataKey]) {
34
+ setSeries({ ...series, hover: e.dataKey });
35
+ }
36
+ };
37
+
38
+ const onMouseOut = () => {
39
+ setSeries({ ...series, hover: null });
40
+ };
41
+
42
+ const onSelect = (e: any) => {
43
+ setSeries({ ...series, [e.dataKey]: !series[e.dataKey], hover: null });
44
+ };
45
+
46
+ if (loading) {
47
+ return <Skeleton variant="rounded" width="100%" height={height} />;
48
+ }
49
+
50
+ return (
51
+ <Box sx={{ width: '100%', height }}>
52
+ <ResponsiveContainer width="100%" height="100%">
53
+ <AreaChart
54
+ width={height * 1.5}
55
+ height={height}
56
+ data={data}
57
+ margin={{
58
+ top: 10,
59
+ right: 0,
60
+ left: 0,
61
+ bottom: 0,
62
+ }}>
63
+ <CartesianGrid strokeDasharray="3 3" />
64
+ <XAxis dataKey="timestamp" />
65
+ <YAxis orientation="left" mirror />
66
+ <Legend onClick={onSelect} onMouseOver={onMouseOver} onMouseOut={onMouseOut} />
67
+ <Tooltip />
68
+ {Object.keys(currencies).map((x: string, i) => {
69
+ const dataKey = currencies[x]?.symbol as string;
70
+ const color = currencies[x]?.color as string;
71
+ return (
72
+ <Area
73
+ type="monotone"
74
+ key={x}
75
+ dataKey={dataKey}
76
+ stackId={i}
77
+ fill={color}
78
+ stroke={color}
79
+ hide={series[dataKey] === true}
80
+ fillOpacity={Number(series.hover === dataKey || !series.hover ? 1 : 0.6)}
81
+ />
82
+ );
83
+ })}
84
+ </AreaChart>
85
+ </ResponsiveContainer>
86
+ </Box>
87
+ );
88
+ }
@@ -4,7 +4,7 @@ import type { TUsageRecord } from '@blocklet/payment-types';
4
4
  import { Alert, Box, Button, CircularProgress } from '@mui/material';
5
5
  import { useRequest } from 'ahooks';
6
6
  import { useState } from 'react';
7
- import { Bar, BarChart, Rectangle, Tooltip, XAxis, YAxis } from 'recharts';
7
+ import { Bar, BarChart, Legend, Rectangle, Tooltip, XAxis, YAxis } from 'recharts';
8
8
 
9
9
  const fetchData = (subscriptionId: string, id: string): Promise<{ list: TUsageRecord[]; count: number }> => {
10
10
  return api
@@ -58,7 +58,8 @@ export function UsageRecordDialog(props: { subscriptionId: string; id: string; o
58
58
  bottom: 5,
59
59
  }}>
60
60
  <XAxis dataKey="date" />
61
- <YAxis />
61
+ <YAxis mirror />
62
+ <Legend />
62
63
  <Tooltip />
63
64
  <Bar dataKey="quantity" fill="#8884d8" activeBar={<Rectangle fill="pink" stroke="blue" />} />
64
65
  </BarChart>
@@ -12,6 +12,11 @@ export default flat({
12
12
  },
13
13
  },
14
14
  admin: {
15
+ balances: 'Balances',
16
+ trends: 'Trends',
17
+ addresses: 'Addresses',
18
+ metrics: 'Metrics',
19
+ attention: 'Attention',
15
20
  overview: 'Overview',
16
21
  payments: 'Payments',
17
22
  connections: 'Connections',
@@ -239,12 +244,14 @@ export default flat({
239
244
  empty: 'No payment intent',
240
245
  refund: 'Refund payment',
241
246
  received: 'Received',
247
+ attention: 'Failed payments',
242
248
  },
243
249
  payout: {
244
250
  list: 'Payouts',
245
251
  name: 'Payout',
246
252
  view: 'View payout',
247
253
  empty: 'No payout',
254
+ attention: 'Failed payouts',
248
255
  },
249
256
  paymentMethod: {
250
257
  _name: 'Payment Method',
@@ -348,6 +355,7 @@ export default flat({
348
355
  },
349
356
  invoice: {
350
357
  view: 'View invoice',
358
+ attention: 'Uncollectible invoices',
351
359
  name: 'Invoice',
352
360
  from: 'Billed from',
353
361
  empty: 'No invoice',
@@ -366,6 +374,7 @@ export default flat({
366
374
  view: 'View subscription',
367
375
  name: 'Subscription',
368
376
  empty: 'No subscription',
377
+ attention: 'Past due subscriptions',
369
378
  product: 'Product',
370
379
  collectionMethod: 'Billing',
371
380
  currentPeriod: 'Current Period',
@@ -488,7 +497,9 @@ export default flat({
488
497
  },
489
498
  },
490
499
  refund: {
500
+ name: 'Refunds',
491
501
  view: 'View refund detail',
502
+ attention: 'Failed refunds',
492
503
  },
493
504
  },
494
505
  });
@@ -12,6 +12,11 @@ export default flat({
12
12
  },
13
13
  },
14
14
  admin: {
15
+ balances: '余额',
16
+ addresses: '地址',
17
+ trends: '趋势',
18
+ metrics: '指标',
19
+ attention: '注意',
15
20
  overview: '总览',
16
21
  payments: '支付管理',
17
22
  connections: '连接',
@@ -29,6 +34,7 @@ export default flat({
29
34
  webhooks: '钩子',
30
35
  events: '事件',
31
36
  refunds: '退款记录',
37
+ payouts: '出款记录',
32
38
  logs: '日志',
33
39
  passports: '通行证',
34
40
  details: '详情',
@@ -208,6 +214,7 @@ export default flat({
208
214
  name: '对外支付',
209
215
  view: '查看对外支付',
210
216
  empty: '没有记录',
217
+ attention: '失败的对外支付',
211
218
  },
212
219
  pricingTable: {
213
220
  view: '查看定价表',
@@ -236,6 +243,7 @@ export default flat({
236
243
  empty: '没有付款记录',
237
244
  refund: '退款',
238
245
  received: '实收金额',
246
+ attention: '失败的付款',
239
247
  },
240
248
  paymentMethod: {
241
249
  _name: '支付方式',
@@ -352,12 +360,14 @@ export default flat({
352
360
  download: '下载PDF',
353
361
  edit: '编辑账单',
354
362
  duplicate: '复制账单',
363
+ attention: '未完成的账单',
355
364
  },
356
365
  subscription: {
357
366
  view: '查看订阅',
358
367
  name: '订阅',
359
368
  empty: '没有订阅',
360
369
  product: '产品',
370
+ attention: '将过期的订阅',
361
371
  collectionMethod: '计费',
362
372
  currentPeriod: '当前周期',
363
373
  trialingPeriod: '试用期',
@@ -477,6 +487,8 @@ export default flat({
477
487
  },
478
488
  },
479
489
  refund: {
490
+ name: '退款',
491
+ attention: '失败的退款',
480
492
  view: '查看退款详情',
481
493
  },
482
494
  },
@@ -1,23 +1,319 @@
1
+ /* eslint-disable react/require-default-props */
2
+ /* eslint-disable no-bitwise */
3
+ import DID from '@arcblock/ux/lib/DID';
1
4
  import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
- import { Button, Stack } from '@mui/material';
3
- import { Link } from 'react-router-dom';
5
+ import { api, formatAmount, formatToDate, usePaymentContext } from '@blocklet/payment-react';
6
+ import type { GroupedBN, TPaymentMethod, TPaymentStat } from '@blocklet/payment-types';
7
+ import { Avatar, Box, Button, Card, Grid, Popover, Stack, Typography } from '@mui/material';
8
+ import { useRequest, useSetState } from 'ahooks';
9
+ import omit from 'lodash/omit';
10
+ import { DateRangePicker } from 'mui-daterange-picker';
11
+ import { useEffect } from 'react';
12
+
13
+ import Chart, { TCurrencyMap } from '../../components/chart';
14
+ import dayjs from '../../libs/dayjs';
15
+
16
+ type TSummary = {
17
+ balances: GroupedBN;
18
+ addresses: GroupedBN;
19
+ summary: { [key: string]: { status: string; count: number }[] };
20
+ };
21
+
22
+ const stringToColor = (str: string) => {
23
+ let hash = 0;
24
+ for (let i = 0; i < str.length; i++) {
25
+ hash = str.charCodeAt(i) + ((hash << 4) - hash);
26
+ }
27
+
28
+ let color = '#';
29
+ for (let i = 0; i < 3; i++) {
30
+ const value = (hash >> (i * 8)) & 0xff;
31
+ color += `00${value.toString(16)}`.substr(-2);
32
+ }
33
+ return color;
34
+ };
35
+
36
+ export const getDefaultRanges = (date: string) => [
37
+ {
38
+ label: 'This Week',
39
+ startDate: dayjs(date).startOf('week').toDate(),
40
+ endDate: dayjs(date).endOf('week').toDate(),
41
+ },
42
+ {
43
+ label: 'Last Week',
44
+ startDate: dayjs(date).subtract(1, 'week').startOf('week').toDate(),
45
+ endDate: dayjs(date).subtract(1, 'week').endOf('week').toDate(),
46
+ },
47
+ {
48
+ label: 'Last 7 Days',
49
+ startDate: dayjs(date).subtract(7, 'day').startOf('day').toDate(),
50
+ endDate: dayjs(date).toDate(),
51
+ },
52
+ {
53
+ label: 'This Month',
54
+ startDate: dayjs(date).startOf('month').toDate(),
55
+ endDate: dayjs(date).endOf('month').toDate(),
56
+ },
57
+ {
58
+ label: 'Last Month',
59
+ startDate: dayjs(date).subtract(1, 'month').startOf('month').toDate(),
60
+ endDate: dayjs(date).subtract(1, 'month').endOf('month').toDate(),
61
+ },
62
+ {
63
+ label: 'This Year',
64
+ startDate: dayjs(date).startOf('year').toDate(),
65
+ endDate: dayjs(date).endOf('year').toDate(),
66
+ },
67
+ {
68
+ label: 'Last Year',
69
+ startDate: dayjs(date).subtract(1, 'year').startOf('year').toDate(),
70
+ endDate: dayjs(date).subtract(1, 'year').endOf('year').toDate(),
71
+ },
72
+ ];
73
+
74
+ export const groupData = (data: TPaymentStat[], currencies: { [key: string]: any }, key: string, locale: string) => {
75
+ const grouped: { [key: string]: object } = {};
76
+ data.forEach((x) => {
77
+ if (!grouped[x.timestamp]) {
78
+ grouped[x.timestamp] = { timestamp: formatToDate(x.timestamp * 1000, locale, 'YYYY-MM-DD') };
79
+ }
80
+
81
+ const { symbol } = currencies[x.currency_id];
82
+ // @ts-ignore
83
+ grouped[x.timestamp][symbol] = +x[key];
84
+ });
85
+
86
+ return Object.values(grouped);
87
+ };
4
88
 
5
89
  export default function Overview() {
6
- const { t } = useLocaleContext();
90
+ const { t, locale } = useLocaleContext();
91
+ const { settings } = usePaymentContext();
92
+ const maxDate = dayjs().subtract(1, 'day').toDate();
93
+ const [state, setState] = useSetState({
94
+ anchorEl: null,
95
+ startDate: dayjs().subtract(30, 'day').toDate(),
96
+ endDate: maxDate,
97
+ });
98
+
99
+ const trend = useRequest<TPaymentStat[], any>(async () => {
100
+ const result = await api.get('/api/payment-stats', {
101
+ params: {
102
+ start: dayjs(state.startDate).unix(),
103
+ end: dayjs(state.endDate).unix(),
104
+ },
105
+ });
106
+ return result.data.list;
107
+ });
108
+
109
+ const summary = useRequest<TSummary, any>(async () => {
110
+ const result = await api.get('/api/payment-stats/summary');
111
+ return result.data;
112
+ });
113
+
114
+ useEffect(() => {
115
+ if (trend.loading) {
116
+ return;
117
+ }
118
+ if (state.startDate && state.endDate) {
119
+ trend.runAsync();
120
+ }
121
+ }, [state.startDate, state.endDate]);
122
+
123
+ const onTogglePicker = (e: any) => {
124
+ if (state.anchorEl) {
125
+ setState({ anchorEl: null });
126
+ } else {
127
+ setState({ anchorEl: e.currentTarget });
128
+ }
129
+ };
130
+
131
+ const onRangeChange = (range: any) => {
132
+ setState({
133
+ startDate: range.startDate,
134
+ endDate: range.endDate,
135
+ anchorEl: null,
136
+ });
137
+ };
138
+
139
+ const open = Boolean(state.anchorEl);
140
+ const id = open ? 'date-range-picker-popover' : undefined;
141
+ const currencies: TCurrencyMap = {};
142
+ for (const method of settings.paymentMethods) {
143
+ for (const currency of method.payment_currencies) {
144
+ currencies[currency.id] = {
145
+ ...currency,
146
+ method: omit(method, ['payment_currencies']) as TPaymentMethod,
147
+ color: stringToColor(currency.id),
148
+ };
149
+ }
150
+ }
151
+
152
+ const payments = groupData(trend.data || [], currencies, 'amount_paid', locale);
153
+ const payouts = groupData(trend.data || [], currencies, 'amount_payout', locale);
154
+ const refunds = groupData(trend.data || [], currencies, 'amount_refund', locale);
155
+
156
+ const metrics = [];
157
+ const attentions = [];
158
+ if (summary.data?.summary) {
159
+ const { subscription, invoice, refund, payment, payout } = summary.data.summary;
160
+ metrics.push({ type: 'subscription', count: subscription?.find((x) => x.status === 'active')?.count || 0 });
161
+ metrics.push({ type: 'invoice', count: invoice?.find((x) => x.status === 'paid')?.count || 0 });
162
+ metrics.push({ type: 'refund', count: refund?.find((x) => x.status === 'succeeded')?.count || 0 });
163
+ metrics.push({ type: 'paymentIntent', count: payment?.find((x) => x.status === 'succeeded')?.count || 0 });
164
+ metrics.push({ type: 'payout', count: payout?.find((x) => x.status === 'paid')?.count || 0 });
165
+
166
+ attentions.push({ type: 'subscription', count: subscription?.find((x) => x.status === 'past_due')?.count || 0 });
167
+ attentions.push({ type: 'invoice', count: invoice?.find((x) => x.status === 'uncollectible')?.count || 0 });
168
+ attentions.push({ type: 'refund', count: refund?.find((x) => x.status === 'failed')?.count || 0 });
169
+ attentions.push({ type: 'paymentIntent', count: payment?.find((x) => x.status === 'requires_action')?.count || 0 });
170
+ attentions.push({ type: 'payout', count: payout?.find((x) => x.status === 'failed')?.count || 0 });
171
+ }
172
+
7
173
  return (
8
- <Stack direction="row" spacing={2}>
9
- <Button component={Link} variant="outlined" to="/admin/products">
10
- {t('admin.products')}
11
- </Button>
12
- <Button component={Link} variant="outlined" to="/admin/payments">
13
- {t('admin.payments')}
14
- </Button>
15
- <Button component={Link} variant="outlined" to="/admin/customers">
16
- {t('admin.customers')}
17
- </Button>
18
- <Button component={Link} variant="outlined" to="/admin/billing/subscriptions">
19
- {t('admin.subscriptions')}
20
- </Button>
21
- </Stack>
174
+ <Grid container gap={{ xs: 2, sm: 5, md: 8 }} sx={{ mb: 4 }}>
175
+ <Grid item xs={12} sm={12} md={8}>
176
+ <Stack direction="column" gap={{ xs: 1, sm: 3 }}>
177
+ <Stack direction="row" alignItems="flex-end" justifyContent="space-between">
178
+ <Typography component="h3" variant="h4">
179
+ {t('admin.trends')}
180
+ </Typography>
181
+ <Button onClick={onTogglePicker} variant="outlined" color="secondary">
182
+ {formatToDate(state.startDate, locale, 'YYYY-MM-DD')} -{' '}
183
+ {formatToDate(state.endDate, locale, 'YYYY-MM-DD')}
184
+ </Button>
185
+ </Stack>
186
+ <Stack direction="column" gap={1}>
187
+ <Typography component="h4" variant="h5">
188
+ {t('admin.paymentIntent.list')}
189
+ </Typography>
190
+ <Chart loading={!trend.data} height={320} data={payments} currencies={currencies} />
191
+ </Stack>
192
+ <Stack direction="column" gap={1}>
193
+ <Typography component="h4" variant="h5">
194
+ {t('admin.payouts')}
195
+ </Typography>
196
+ <Chart loading={!trend.data} height={320} data={payouts} currencies={currencies} />
197
+ </Stack>
198
+ <Stack direction="column" gap={1}>
199
+ <Typography component="h4" variant="h5">
200
+ {t('admin.refunds')}
201
+ </Typography>
202
+ <Chart loading={!trend.data} height={320} data={refunds} currencies={currencies} />
203
+ </Stack>
204
+ </Stack>
205
+ </Grid>
206
+ <Grid item xs={12} sm={12} md={3}>
207
+ <Stack direction="column" spacing={2}>
208
+ {summary.data && summary.data.addresses && (
209
+ <Box>
210
+ <Stack direction="row" alignItems="flex-end" justifyContent="space-between">
211
+ <Typography component="h3" variant="h4">
212
+ {t('admin.addresses')}
213
+ </Typography>
214
+ </Stack>
215
+ <Stack direction="column" spacing={1} sx={{ mt: 2 }}>
216
+ {Object.keys(summary.data.addresses).map((chain) => (
217
+ <DID key={chain} did={summary.data?.addresses?.[chain] as string} copyable showQrcode />
218
+ ))}
219
+ </Stack>
220
+ </Box>
221
+ )}
222
+ {summary.data && summary.data.balances && (
223
+ <Box>
224
+ <Stack direction="row" alignItems="flex-end" justifyContent="space-between">
225
+ <Typography component="h3" variant="h4">
226
+ {t('admin.balances')}
227
+ </Typography>
228
+ </Stack>
229
+ <Stack direction="column" spacing={1} sx={{ mt: 2 }}>
230
+ {Object.keys(summary.data.balances).map((currencyId) => (
231
+ <Card key={currencyId} variant="outlined" sx={{ padding: 1 }}>
232
+ <Stack direction="row" alignItems="center">
233
+ <Avatar
234
+ src={currencies[currencyId]?.logo}
235
+ alt={currencies[currencyId]?.name}
236
+ sx={{ width: 36, height: 36, marginRight: 1 }}
237
+ />
238
+ <Box>
239
+ <Typography variant="h5" component="div" sx={{ fontSize: '2rem' }}>
240
+ {formatAmount(
241
+ summary.data?.balances?.[currencyId] as string,
242
+ currencies[currencyId]?.decimal as number
243
+ )}
244
+ </Typography>
245
+ <Typography sx={{ fontSize: 14 }} color="text.secondary">
246
+ {currencies[currencyId]?.symbol} on {currencies[currencyId]?.method.name}
247
+ </Typography>
248
+ </Box>
249
+ </Stack>
250
+ </Card>
251
+ ))}
252
+ </Stack>
253
+ </Box>
254
+ )}
255
+ {metrics.length > 0 && (
256
+ <Box>
257
+ <Stack direction="row" alignItems="flex-end" justifyContent="space-between">
258
+ <Typography component="h3" variant="h4">
259
+ {t('admin.metrics')}
260
+ </Typography>
261
+ </Stack>
262
+ <Stack direction="row" flexWrap="wrap" gap={1} sx={{ mt: 2 }}>
263
+ {metrics.map((metric) => (
264
+ <Card key={metric.type} variant="outlined" sx={{ padding: 1, width: 0.48 }}>
265
+ <Box>
266
+ <Typography component="div" sx={{ fontSize: '2rem' }}>
267
+ {metric.count}
268
+ </Typography>
269
+ <Typography sx={{ fontSize: 14 }} color="text.secondary">
270
+ {t(`admin.${metric.type}.name`)}
271
+ </Typography>
272
+ </Box>
273
+ </Card>
274
+ ))}
275
+ </Stack>
276
+ </Box>
277
+ )}
278
+ {attentions.filter((x) => x.count > 0).length > 0 && (
279
+ <Box>
280
+ <Stack direction="row" alignItems="flex-end" justifyContent="space-between">
281
+ <Typography component="h3" variant="h4" color="error">
282
+ {t('admin.attention')}
283
+ </Typography>
284
+ </Stack>
285
+ <Stack direction="row" flexWrap="wrap" gap={1} sx={{ mt: 2 }}>
286
+ {attentions.map((metric) => (
287
+ <Card key={metric.type} variant="outlined" sx={{ padding: 1, width: 0.48 }}>
288
+ <Box>
289
+ <Typography component="div" sx={{ fontSize: '2rem' }}>
290
+ {metric.count}
291
+ </Typography>
292
+ <Typography sx={{ fontSize: 14 }} color="text.secondary">
293
+ {t(`admin.${metric.type}.attention`)}
294
+ </Typography>
295
+ </Box>
296
+ </Card>
297
+ ))}
298
+ </Stack>
299
+ </Box>
300
+ )}
301
+ </Stack>
302
+ </Grid>
303
+ <Popover
304
+ id={id}
305
+ open={open}
306
+ anchorEl={state.anchorEl}
307
+ onClose={() => setState({ anchorEl: null })}
308
+ anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}>
309
+ <DateRangePicker
310
+ open
311
+ toggle={onTogglePicker as any}
312
+ maxDate={maxDate}
313
+ definedRanges={getDefaultRanges(maxDate.toISOString())}
314
+ onChange={onRangeChange}
315
+ />
316
+ </Popover>
317
+ </Grid>
22
318
  );
23
319
  }