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.
- package/api/src/crons/index.ts +9 -0
- package/api/src/crons/payment-stat.ts +101 -0
- package/api/src/hooks/pre-start.ts +2 -0
- package/api/src/integrations/arcblock/stake.ts +16 -4
- package/api/src/libs/env.ts +1 -0
- package/api/src/routes/index.ts +2 -0
- package/api/src/routes/payment-stats.ts +81 -0
- package/api/src/store/migrations/20240416-stat.ts +10 -0
- package/api/src/store/models/index.ts +3 -0
- package/api/src/store/models/invoice.ts +10 -0
- package/api/src/store/models/payment-intent.ts +10 -1
- package/api/src/store/models/payment-stat.ts +79 -0
- package/api/src/store/models/payout.ts +10 -1
- package/api/src/store/models/product.ts +10 -1
- package/api/src/store/models/refund.ts +10 -1
- package/api/src/store/models/subscription.ts +10 -1
- package/blocklet.yml +1 -1
- package/package.json +6 -4
- package/src/components/chart.tsx +88 -0
- package/src/components/subscription/items/usage-records.tsx +3 -2
- package/src/locales/en.tsx +11 -0
- package/src/locales/zh.tsx +12 -0
- package/src/pages/admin/overview.tsx +313 -17
package/api/src/crons/index.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
);
|
package/api/src/libs/env.ts
CHANGED
|
@@ -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 * * * *'; // 默认每小时执行一次
|
package/api/src/routes/index.ts
CHANGED
|
@@ -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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "payment-kit",
|
|
3
|
-
"version": "1.13.
|
|
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.
|
|
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.
|
|
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": "
|
|
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>
|
package/src/locales/en.tsx
CHANGED
|
@@ -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
|
});
|
package/src/locales/zh.tsx
CHANGED
|
@@ -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 {
|
|
3
|
-
import {
|
|
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
|
-
<
|
|
9
|
-
<
|
|
10
|
-
{
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
}
|