payment-kit 1.19.0 → 1.19.2
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 +8 -0
- package/api/src/index.ts +4 -0
- package/api/src/libs/credit-grant.ts +146 -0
- package/api/src/libs/env.ts +1 -0
- package/api/src/libs/invoice.ts +4 -3
- package/api/src/libs/notification/template/base.ts +388 -2
- package/api/src/libs/notification/template/customer-credit-grant-granted.ts +149 -0
- package/api/src/libs/notification/template/customer-credit-grant-low-balance.ts +151 -0
- package/api/src/libs/notification/template/customer-credit-insufficient.ts +254 -0
- package/api/src/libs/notification/template/subscription-canceled.ts +193 -202
- package/api/src/libs/notification/template/subscription-refund-succeeded.ts +215 -237
- package/api/src/libs/notification/template/subscription-renewed.ts +130 -200
- package/api/src/libs/notification/template/subscription-succeeded.ts +100 -202
- package/api/src/libs/notification/template/subscription-trial-start.ts +142 -188
- package/api/src/libs/notification/template/subscription-trial-will-end.ts +146 -174
- package/api/src/libs/notification/template/subscription-upgraded.ts +96 -192
- package/api/src/libs/notification/template/subscription-will-canceled.ts +94 -135
- package/api/src/libs/notification/template/subscription-will-renew.ts +220 -245
- package/api/src/libs/payment.ts +69 -0
- package/api/src/libs/queue/index.ts +3 -2
- package/api/src/libs/session.ts +8 -0
- package/api/src/libs/subscription.ts +74 -3
- package/api/src/libs/util.ts +3 -1
- package/api/src/libs/ws.ts +23 -1
- package/api/src/locales/en.ts +33 -0
- package/api/src/locales/zh.ts +31 -0
- package/api/src/queues/credit-consume.ts +728 -0
- package/api/src/queues/credit-grant.ts +572 -0
- package/api/src/queues/notification.ts +173 -128
- package/api/src/queues/payment.ts +210 -122
- package/api/src/queues/subscription.ts +179 -0
- package/api/src/routes/checkout-sessions.ts +157 -9
- package/api/src/routes/connect/shared.ts +3 -2
- package/api/src/routes/credit-grants.ts +241 -0
- package/api/src/routes/credit-transactions.ts +208 -0
- package/api/src/routes/customers.ts +34 -5
- package/api/src/routes/index.ts +8 -0
- package/api/src/routes/meter-events.ts +347 -0
- package/api/src/routes/meters.ts +219 -0
- package/api/src/routes/payment-currencies.ts +20 -2
- package/api/src/routes/payment-links.ts +1 -1
- package/api/src/routes/payment-methods.ts +14 -2
- package/api/src/routes/prices.ts +43 -0
- package/api/src/routes/pricing-table.ts +13 -7
- package/api/src/routes/products.ts +63 -4
- package/api/src/routes/settings.ts +1 -1
- package/api/src/routes/subscriptions.ts +4 -0
- package/api/src/routes/webhook-endpoints.ts +0 -3
- package/api/src/store/migrations/20250610-billing-credit.ts +43 -0
- package/api/src/store/models/credit-grant.ts +486 -0
- package/api/src/store/models/credit-transaction.ts +268 -0
- package/api/src/store/models/customer.ts +8 -0
- package/api/src/store/models/index.ts +52 -1
- package/api/src/store/models/meter-event.ts +423 -0
- package/api/src/store/models/meter.ts +176 -0
- package/api/src/store/models/payment-currency.ts +66 -14
- package/api/src/store/models/price.ts +6 -0
- package/api/src/store/models/product.ts +2 -2
- package/api/src/store/models/subscription.ts +24 -0
- package/api/src/store/models/types.ts +28 -2
- package/api/tests/libs/subscription.spec.ts +53 -0
- package/blocklet.yml +9 -1
- package/package.json +4 -4
- package/scripts/sdk.js +233 -1
- package/src/app.tsx +10 -0
- package/src/components/collapse.tsx +11 -1
- package/src/components/conditional-section.tsx +87 -0
- package/src/components/customer/credit-grant-item-list.tsx +99 -0
- package/src/components/customer/credit-overview.tsx +246 -0
- package/src/components/customer/form.tsx +7 -3
- package/src/components/invoice/list.tsx +19 -1
- package/src/components/metadata/form.tsx +287 -91
- package/src/components/meter/actions.tsx +101 -0
- package/src/components/meter/add-usage-dialog.tsx +239 -0
- package/src/components/meter/events-list.tsx +657 -0
- package/src/components/meter/form.tsx +245 -0
- package/src/components/meter/products.tsx +264 -0
- package/src/components/meter/usage-guide.tsx +174 -0
- package/src/components/payment-currency/form.tsx +2 -0
- package/src/components/payment-intent/list.tsx +19 -1
- package/src/components/payment-link/item.tsx +2 -2
- package/src/components/payment-link/preview.tsx +1 -1
- package/src/components/payment-link/product-select.tsx +52 -12
- package/src/components/payment-method/arcblock.tsx +2 -0
- package/src/components/payment-method/base.tsx +2 -0
- package/src/components/payment-method/bitcoin.tsx +2 -0
- package/src/components/payment-method/ethereum.tsx +2 -0
- package/src/components/payment-method/stripe.tsx +2 -0
- package/src/components/payouts/list.tsx +19 -1
- package/src/components/payouts/portal/list.tsx +6 -11
- package/src/components/price/currency-select.tsx +56 -32
- package/src/components/price/form.tsx +912 -407
- package/src/components/pricing-table/preview.tsx +1 -1
- package/src/components/product/add-price.tsx +9 -7
- package/src/components/product/create.tsx +7 -4
- package/src/components/product/edit-price.tsx +21 -12
- package/src/components/product/features.tsx +17 -7
- package/src/components/product/form.tsx +100 -90
- package/src/components/refund/list.tsx +19 -1
- package/src/components/section/header.tsx +5 -18
- package/src/components/subscription/items/index.tsx +1 -1
- package/src/components/subscription/metrics.tsx +37 -5
- package/src/components/subscription/portal/actions.tsx +2 -1
- package/src/contexts/products.tsx +26 -9
- package/src/hooks/subscription.ts +34 -0
- package/src/libs/meter-utils.ts +196 -0
- package/src/libs/util.ts +4 -0
- package/src/locales/en.tsx +389 -5
- package/src/locales/zh.tsx +368 -1
- package/src/pages/admin/billing/index.tsx +61 -33
- package/src/pages/admin/billing/invoices/detail.tsx +1 -1
- package/src/pages/admin/billing/meters/create.tsx +60 -0
- package/src/pages/admin/billing/meters/detail.tsx +435 -0
- package/src/pages/admin/billing/meters/index.tsx +210 -0
- package/src/pages/admin/billing/meters/meter-event.tsx +346 -0
- package/src/pages/admin/billing/subscriptions/detail.tsx +47 -14
- package/src/pages/admin/customers/customers/credit-grant/detail.tsx +391 -0
- package/src/pages/admin/customers/customers/detail.tsx +14 -10
- package/src/pages/admin/customers/index.tsx +5 -0
- package/src/pages/admin/developers/events/detail.tsx +1 -1
- package/src/pages/admin/developers/index.tsx +1 -1
- package/src/pages/admin/payments/intents/detail.tsx +1 -1
- package/src/pages/admin/payments/payouts/detail.tsx +1 -1
- package/src/pages/admin/payments/refunds/detail.tsx +1 -1
- package/src/pages/admin/products/index.tsx +3 -2
- package/src/pages/admin/products/links/detail.tsx +1 -1
- package/src/pages/admin/products/prices/actions.tsx +16 -4
- package/src/pages/admin/products/prices/detail.tsx +30 -3
- package/src/pages/admin/products/prices/list.tsx +8 -1
- package/src/pages/admin/products/pricing-tables/detail.tsx +1 -1
- package/src/pages/admin/products/products/create.tsx +233 -57
- package/src/pages/admin/products/products/detail.tsx +2 -1
- package/src/pages/admin/settings/payment-methods/index.tsx +3 -0
- package/src/pages/customer/credit-grant/detail.tsx +308 -0
- package/src/pages/customer/index.tsx +44 -9
- package/src/pages/customer/recharge/account.tsx +5 -5
- package/src/pages/customer/subscription/change-payment.tsx +4 -2
- package/src/pages/customer/subscription/detail.tsx +48 -14
- package/src/pages/customer/subscription/embed.tsx +1 -1
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import Joi from 'joi';
|
|
3
|
+
|
|
4
|
+
import { Op } from 'sequelize';
|
|
5
|
+
import { createListParamSchema, getOrder, getWhereFromKvQuery } from '../libs/api';
|
|
6
|
+
import logger from '../libs/logger';
|
|
7
|
+
import { authenticate } from '../libs/security';
|
|
8
|
+
import { CreditTransaction, Customer, CreditGrant, Meter, Subscription, PaymentCurrency } from '../store/models';
|
|
9
|
+
|
|
10
|
+
const router = Router();
|
|
11
|
+
const authMine = authenticate<CreditTransaction>({ component: true, roles: ['owner', 'admin'], mine: true });
|
|
12
|
+
const authPortal = authenticate<CreditTransaction>({
|
|
13
|
+
component: true,
|
|
14
|
+
roles: ['owner', 'admin'],
|
|
15
|
+
record: {
|
|
16
|
+
// @ts-ignore
|
|
17
|
+
model: CreditTransaction,
|
|
18
|
+
field: 'customer_id',
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const listSchema = createListParamSchema<{
|
|
23
|
+
customer_id?: string;
|
|
24
|
+
subscription_id?: string;
|
|
25
|
+
credit_grant_id?: string;
|
|
26
|
+
start?: number;
|
|
27
|
+
end?: number;
|
|
28
|
+
source?: string;
|
|
29
|
+
}>({
|
|
30
|
+
customer_id: Joi.string().empty(''),
|
|
31
|
+
subscription_id: Joi.string().empty(''),
|
|
32
|
+
credit_grant_id: Joi.string().empty(''),
|
|
33
|
+
start: Joi.number().integer().optional(),
|
|
34
|
+
end: Joi.number().integer().optional(),
|
|
35
|
+
source: Joi.string().empty(''),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
router.get('/', authMine, async (req, res) => {
|
|
39
|
+
try {
|
|
40
|
+
const { page, pageSize, ...query } = await listSchema.validateAsync(req.query, { stripUnknown: true });
|
|
41
|
+
const where = getWhereFromKvQuery(query.q);
|
|
42
|
+
|
|
43
|
+
if (query.customer_id) {
|
|
44
|
+
where.customer_id = query.customer_id;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (query.subscription_id) {
|
|
48
|
+
where.subscription_id = query.subscription_id;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (query.credit_grant_id) {
|
|
52
|
+
where.credit_grant_id = query.credit_grant_id;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (query.source) {
|
|
56
|
+
where.source = query.source;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (query.start) {
|
|
60
|
+
where.created_at = {
|
|
61
|
+
[Op.gte]: new Date(query.start * 1000),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (query.end) {
|
|
66
|
+
where.created_at = {
|
|
67
|
+
[Op.lte]: new Date(query.end * 1000),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const { rows: list, count } = await CreditTransaction.findAndCountAll({
|
|
72
|
+
where,
|
|
73
|
+
order: getOrder(query, [['created_at', query.o === 'asc' ? 'ASC' : 'DESC']]),
|
|
74
|
+
offset: (page - 1) * pageSize,
|
|
75
|
+
limit: pageSize,
|
|
76
|
+
include: [
|
|
77
|
+
{
|
|
78
|
+
model: Customer,
|
|
79
|
+
as: 'customer',
|
|
80
|
+
attributes: ['id', 'name', 'email', 'did'],
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
model: Meter,
|
|
84
|
+
as: 'meter',
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
model: Subscription,
|
|
88
|
+
as: 'subscription',
|
|
89
|
+
attributes: ['id', 'description', 'status'],
|
|
90
|
+
required: false,
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
model: CreditGrant,
|
|
94
|
+
as: 'creditGrant',
|
|
95
|
+
attributes: ['id', 'name'],
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const paymentCurrencies = await PaymentCurrency.findAll({
|
|
101
|
+
attributes: ['id', 'symbol', 'decimal', 'maximum_precision'],
|
|
102
|
+
where: {
|
|
103
|
+
type: 'credit',
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const result = list.map((item) => {
|
|
108
|
+
return {
|
|
109
|
+
...item.toJSON(),
|
|
110
|
+
// @ts-ignore
|
|
111
|
+
paymentCurrency: paymentCurrencies.find((x) => x.id === item.meter?.currency_id),
|
|
112
|
+
};
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
return res.json({ count, list: result, paging: { page, pageSize } });
|
|
116
|
+
} catch (err) {
|
|
117
|
+
logger.error('Error listing credit transactions', err);
|
|
118
|
+
return res.status(400).json({ error: err.message });
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const summarySchema = Joi.object({
|
|
123
|
+
customer_id: Joi.string().optional(),
|
|
124
|
+
subscription_id: Joi.string().optional(),
|
|
125
|
+
currency_id: Joi.string().optional(),
|
|
126
|
+
start: Joi.number().integer().optional(),
|
|
127
|
+
end: Joi.number().integer().optional(),
|
|
128
|
+
}).unknown(true);
|
|
129
|
+
|
|
130
|
+
// get credit transaction summary for customer or subscription
|
|
131
|
+
router.get('/summary', authMine, async (req, res) => {
|
|
132
|
+
try {
|
|
133
|
+
const {
|
|
134
|
+
customer_id: customerId,
|
|
135
|
+
subscription_id: subscriptionId,
|
|
136
|
+
currency_id: currencyId,
|
|
137
|
+
start,
|
|
138
|
+
end,
|
|
139
|
+
} = await summarySchema.validateAsync(req.query, { stripUnknown: true });
|
|
140
|
+
|
|
141
|
+
const result = await CreditTransaction.getUsageSummary({
|
|
142
|
+
customerId,
|
|
143
|
+
subscriptionId,
|
|
144
|
+
currencyId,
|
|
145
|
+
startTime: start ? new Date(start * 1000) : undefined,
|
|
146
|
+
endTime: end ? new Date(end * 1000) : undefined,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
return res.json(result);
|
|
150
|
+
} catch (err) {
|
|
151
|
+
logger.error('get credit transaction summary failed', err);
|
|
152
|
+
return res.status(400).json({ error: err.message });
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
router.get('/:id', authPortal, async (req, res) => {
|
|
157
|
+
try {
|
|
158
|
+
const transaction = await CreditTransaction.findByPk(req.params.id, {
|
|
159
|
+
include: [
|
|
160
|
+
{
|
|
161
|
+
model: Customer,
|
|
162
|
+
as: 'customer',
|
|
163
|
+
attributes: ['id', 'name', 'email', 'did', 'phone', 'description'],
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
model: CreditGrant,
|
|
167
|
+
as: 'creditGrant',
|
|
168
|
+
include: [
|
|
169
|
+
{
|
|
170
|
+
model: PaymentCurrency,
|
|
171
|
+
as: 'paymentCurrency',
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
model: Meter,
|
|
177
|
+
as: 'meter',
|
|
178
|
+
attributes: ['id', 'name', 'event_name', 'unit', 'status', 'description'],
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
model: Subscription,
|
|
182
|
+
as: 'subscription',
|
|
183
|
+
attributes: [
|
|
184
|
+
'id',
|
|
185
|
+
'description',
|
|
186
|
+
'status',
|
|
187
|
+
'current_period_start',
|
|
188
|
+
'current_period_end',
|
|
189
|
+
'cancel_at_period_end',
|
|
190
|
+
'canceled_at',
|
|
191
|
+
],
|
|
192
|
+
required: false,
|
|
193
|
+
},
|
|
194
|
+
],
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
if (!transaction) {
|
|
198
|
+
return res.status(404).json({ error: 'Credit transaction not found' });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return res.json(transaction);
|
|
202
|
+
} catch (err) {
|
|
203
|
+
logger.error('get credit transaction failed', err);
|
|
204
|
+
return res.status(400).json({ error: err.message });
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
export default router;
|
|
@@ -45,9 +45,6 @@ router.get('/', auth, async (req, res) => {
|
|
|
45
45
|
const { page, pageSize, ...query } = await schema.validateAsync(req.query, { stripUnknown: true });
|
|
46
46
|
const where = getWhereFromKvQuery(query.q);
|
|
47
47
|
|
|
48
|
-
if (typeof query.livemode === 'boolean') {
|
|
49
|
-
where.livemode = query.livemode;
|
|
50
|
-
}
|
|
51
48
|
if (query.did) {
|
|
52
49
|
where.did = query.did;
|
|
53
50
|
}
|
|
@@ -386,16 +383,48 @@ router.get('/payer-token', sessionMiddleware({ accessKey: true }), async (req, r
|
|
|
386
383
|
});
|
|
387
384
|
|
|
388
385
|
router.get('/:id', auth, async (req, res) => {
|
|
386
|
+
if (!req.params.id) {
|
|
387
|
+
return res.status(400).json({ error: 'Customer ID is required' });
|
|
388
|
+
}
|
|
389
389
|
try {
|
|
390
390
|
const doc = await Customer.findByPkOrDid(req.params.id as string);
|
|
391
391
|
if (doc) {
|
|
392
392
|
res.json(doc);
|
|
393
393
|
} else {
|
|
394
|
-
|
|
394
|
+
if (req.body.create) {
|
|
395
|
+
if (!req.user) {
|
|
396
|
+
return res.status(403).json({ error: 'Unauthorized' });
|
|
397
|
+
}
|
|
398
|
+
const { user } = await blocklet.getUser(req.params.id);
|
|
399
|
+
if (!user) {
|
|
400
|
+
return res.status(404).json({ error: 'User not found' });
|
|
401
|
+
}
|
|
402
|
+
const customer = await Customer.create({
|
|
403
|
+
livemode: true,
|
|
404
|
+
did: user.did,
|
|
405
|
+
name: user.fullName,
|
|
406
|
+
email: user.email,
|
|
407
|
+
phone: user.phone,
|
|
408
|
+
address: Customer.formatAddressFromUser(user),
|
|
409
|
+
description: user.remark,
|
|
410
|
+
metadata: {},
|
|
411
|
+
balance: '0',
|
|
412
|
+
next_invoice_sequence: 1,
|
|
413
|
+
delinquent: false,
|
|
414
|
+
invoice_prefix: Customer.getInvoicePrefix(),
|
|
415
|
+
});
|
|
416
|
+
logger.info('customer created', {
|
|
417
|
+
customerId: customer.id,
|
|
418
|
+
did: customer.did,
|
|
419
|
+
});
|
|
420
|
+
return res.json(customer);
|
|
421
|
+
}
|
|
422
|
+
return res.status(404).json(null);
|
|
395
423
|
}
|
|
424
|
+
return res.status(404).json(null);
|
|
396
425
|
} catch (err) {
|
|
397
426
|
logger.error(err);
|
|
398
|
-
res.status(500).json({ error: `Failed to get customer: ${err.message}` });
|
|
427
|
+
return res.status(500).json({ error: `Failed to get customer: ${err.message}` });
|
|
399
428
|
}
|
|
400
429
|
});
|
|
401
430
|
|
package/api/src/routes/index.ts
CHANGED
|
@@ -2,11 +2,15 @@ import { Router } from 'express';
|
|
|
2
2
|
|
|
3
3
|
import { PaymentCurrency } from '../store/models/payment-currency';
|
|
4
4
|
import checkoutSessions from './checkout-sessions';
|
|
5
|
+
import creditGrants from './credit-grants';
|
|
6
|
+
import creditTransactions from './credit-transactions';
|
|
5
7
|
import customers from './customers';
|
|
6
8
|
import donations from './donations';
|
|
7
9
|
import events from './events';
|
|
8
10
|
import stripe from './integrations/stripe';
|
|
9
11
|
import invoices from './invoices';
|
|
12
|
+
import meterEvents from './meter-events';
|
|
13
|
+
import meters from './meters';
|
|
10
14
|
import passports from './passports';
|
|
11
15
|
import paymentCurrencies from './payment-currencies';
|
|
12
16
|
import paymentIntents from './payment-intents';
|
|
@@ -48,11 +52,15 @@ router.use(async (req, _, next) => {
|
|
|
48
52
|
});
|
|
49
53
|
|
|
50
54
|
router.use('/checkout-sessions', checkoutSessions);
|
|
55
|
+
router.use('/credit-grants', creditGrants);
|
|
56
|
+
router.use('/credit-transactions', creditTransactions);
|
|
51
57
|
router.use('/customers', customers);
|
|
52
58
|
router.use('/donations', donations);
|
|
53
59
|
router.use('/events', events);
|
|
54
60
|
router.use('/invoices', invoices);
|
|
55
61
|
router.use('/integrations/stripe', stripe);
|
|
62
|
+
router.use('/meter-events', meterEvents);
|
|
63
|
+
router.use('/meters', meters);
|
|
56
64
|
router.use('/passports', passports);
|
|
57
65
|
router.use('/payment-intents', paymentIntents);
|
|
58
66
|
router.use('/payment-links', paymentLinks);
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import Joi from 'joi';
|
|
3
|
+
import { Op, QueryTypes } from 'sequelize';
|
|
4
|
+
|
|
5
|
+
import { fromTokenToUnit } from '@ocap/util';
|
|
6
|
+
import { createListParamSchema, getOrder, getWhereFromKvQuery, MetadataSchema } from '../libs/api';
|
|
7
|
+
import logger from '../libs/logger';
|
|
8
|
+
import { authenticate } from '../libs/security';
|
|
9
|
+
import { formatMetadata } from '../libs/util';
|
|
10
|
+
import { Customer, Meter, MeterEvent, MeterEventStatus, PaymentCurrency, Subscription } from '../store/models';
|
|
11
|
+
|
|
12
|
+
const router = Router();
|
|
13
|
+
const auth = authenticate<MeterEvent>({ component: true, roles: ['owner', 'admin'] });
|
|
14
|
+
const authMine = authenticate<MeterEvent>({ component: true, roles: ['owner', 'admin'], mine: true });
|
|
15
|
+
|
|
16
|
+
const meterEventSchema = Joi.object({
|
|
17
|
+
event_name: Joi.string().max(128).required(),
|
|
18
|
+
payload: Joi.object({
|
|
19
|
+
customer_id: Joi.string().required(),
|
|
20
|
+
value: Joi.number().greater(0).required(),
|
|
21
|
+
subscription_id: Joi.string().max(128).optional(),
|
|
22
|
+
}).required(),
|
|
23
|
+
timestamp: Joi.number().integer().optional(),
|
|
24
|
+
identifier: Joi.string().max(255).required(),
|
|
25
|
+
metadata: MetadataSchema,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const listSchema = createListParamSchema<{
|
|
29
|
+
event_name?: string;
|
|
30
|
+
meter_id?: string;
|
|
31
|
+
customer_id?: string;
|
|
32
|
+
start?: number;
|
|
33
|
+
end?: number;
|
|
34
|
+
}>({
|
|
35
|
+
event_name: Joi.string().empty(''),
|
|
36
|
+
meter_id: Joi.string().empty(''),
|
|
37
|
+
customer_id: Joi.string().empty(''),
|
|
38
|
+
start: Joi.number().integer().optional(),
|
|
39
|
+
end: Joi.number().integer().optional(),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const statsSchema = Joi.object({
|
|
43
|
+
meter_id: Joi.string().required(),
|
|
44
|
+
start: Joi.number().integer().required(),
|
|
45
|
+
end: Joi.number().integer().required(),
|
|
46
|
+
customer_id: Joi.string().optional(),
|
|
47
|
+
granularity: Joi.string().valid('minute', 'hour', 'day').default('day'),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
router.get('/', authMine, async (req, res) => {
|
|
51
|
+
try {
|
|
52
|
+
const { page, pageSize, ...query } = await listSchema.validateAsync(req.query, { stripUnknown: true });
|
|
53
|
+
const where = getWhereFromKvQuery(query.q);
|
|
54
|
+
|
|
55
|
+
if (typeof query.livemode === 'boolean') {
|
|
56
|
+
where.livemode = query.livemode;
|
|
57
|
+
}
|
|
58
|
+
if (query.event_name) {
|
|
59
|
+
where.event_name = query.event_name;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (query.meter_id) {
|
|
63
|
+
const meter = await Meter.findByPk(query.meter_id);
|
|
64
|
+
if (meter) {
|
|
65
|
+
where.event_name = meter.event_name;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (query.customer_id) {
|
|
70
|
+
where['payload.customer_id'] = query.customer_id;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (query.start || query.end) {
|
|
74
|
+
where.created_at = {};
|
|
75
|
+
if (query.start) {
|
|
76
|
+
where.created_at[Op.gte] = new Date(query.start * 1000);
|
|
77
|
+
}
|
|
78
|
+
if (query.end) {
|
|
79
|
+
where.created_at[Op.lte] = new Date(query.end * 1000);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const { rows: list, count } = await MeterEvent.findAndCountAll({
|
|
84
|
+
where,
|
|
85
|
+
order: getOrder(query, [['created_at', query.o === 'asc' ? 'ASC' : 'DESC']]),
|
|
86
|
+
offset: (page - 1) * pageSize,
|
|
87
|
+
limit: pageSize,
|
|
88
|
+
});
|
|
89
|
+
const expanded = await MeterEvent.expand(list, !!req.livemode, {
|
|
90
|
+
customer: true,
|
|
91
|
+
subscription: true,
|
|
92
|
+
meter: !!query.meter_id,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
res.json({ count, list: expanded, paging: { page, pageSize } });
|
|
96
|
+
} catch (err) {
|
|
97
|
+
logger.error('Error listing meter events', err);
|
|
98
|
+
res.status(400).json({ error: err?.message });
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
router.get('/stats', authMine, async (req, res) => {
|
|
103
|
+
try {
|
|
104
|
+
const {
|
|
105
|
+
meter_id: meterId,
|
|
106
|
+
start,
|
|
107
|
+
end,
|
|
108
|
+
customer_id: customerId,
|
|
109
|
+
granularity,
|
|
110
|
+
} = await statsSchema.validateAsync(req.query, { stripUnknown: true });
|
|
111
|
+
|
|
112
|
+
const meter = await Meter.findByPk(meterId);
|
|
113
|
+
if (!meter) {
|
|
114
|
+
return res.status(404).json({ error: 'Meter not found' });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const startDate = new Date(start * 1000);
|
|
118
|
+
const endDate = new Date(end * 1000);
|
|
119
|
+
|
|
120
|
+
// 根据granularity选择聚合方式
|
|
121
|
+
let dateFormat;
|
|
122
|
+
let groupBy;
|
|
123
|
+
if (granularity === 'minute') {
|
|
124
|
+
// 分钟级别聚合(实际按小时聚合,前端处理更细粒度显示)
|
|
125
|
+
dateFormat = "DATE_FORMAT(created_at, '%Y-%m-%d %H:00:00')";
|
|
126
|
+
groupBy = "DATE_FORMAT(created_at, '%Y-%m-%d %H')";
|
|
127
|
+
} else if (granularity === 'hour') {
|
|
128
|
+
dateFormat = "DATE_FORMAT(created_at, '%Y-%m-%d %H:00:00')";
|
|
129
|
+
groupBy = "DATE_FORMAT(created_at, '%Y-%m-%d %H')";
|
|
130
|
+
} else {
|
|
131
|
+
dateFormat = 'DATE(created_at)';
|
|
132
|
+
groupBy = 'DATE(created_at)';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 构建查询条件
|
|
136
|
+
let whereClause =
|
|
137
|
+
'event_name = :eventName AND livemode = :livemode AND created_at >= :startDate AND created_at <= :endDate';
|
|
138
|
+
const replacements: any = {
|
|
139
|
+
eventName: meter.event_name,
|
|
140
|
+
livemode: !!req.livemode,
|
|
141
|
+
startDate: startDate.toISOString(),
|
|
142
|
+
endDate: endDate.toISOString(),
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
if (customerId) {
|
|
146
|
+
whereClause += " AND payload->>'customer_id' = :customerId";
|
|
147
|
+
replacements.customerId = customerId;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const statsQuery = `
|
|
151
|
+
SELECT
|
|
152
|
+
${dateFormat} as date,
|
|
153
|
+
${dateFormat} as timestamp,
|
|
154
|
+
COUNT(*) as event_count,
|
|
155
|
+
COALESCE(SUM(CAST(payload->>'value' AS DECIMAL)), 0) as total_value
|
|
156
|
+
FROM meter_events
|
|
157
|
+
WHERE ${whereClause}
|
|
158
|
+
GROUP BY ${groupBy}
|
|
159
|
+
ORDER BY ${groupBy}
|
|
160
|
+
`;
|
|
161
|
+
|
|
162
|
+
const { sequelize } = MeterEvent;
|
|
163
|
+
if (!sequelize) {
|
|
164
|
+
return res.status(500).json({ error: 'Database connection not available' });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const stats = await sequelize.query(statsQuery, {
|
|
168
|
+
replacements,
|
|
169
|
+
type: QueryTypes.SELECT,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
return res.json({
|
|
173
|
+
count: stats.length,
|
|
174
|
+
list: stats.map((item: any) => ({
|
|
175
|
+
...item,
|
|
176
|
+
timestamp: new Date(item.date).toISOString(),
|
|
177
|
+
})),
|
|
178
|
+
});
|
|
179
|
+
} catch (err) {
|
|
180
|
+
logger.error('Error getting meter event stats', err);
|
|
181
|
+
return res.status(400).json({ error: err?.message });
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
router.post('/', auth, async (req, res) => {
|
|
186
|
+
try {
|
|
187
|
+
const { error } = meterEventSchema.validate(req.body);
|
|
188
|
+
if (error) {
|
|
189
|
+
return res.status(400).json({ error: `Meter event create request invalid: ${error.message}` });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const existing = await MeterEvent.isEventExists(req.body.identifier);
|
|
193
|
+
if (existing) {
|
|
194
|
+
return res.status(400).json({ error: `Event with identifier "${req.body.identifier}" already exists` });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const meter = await Meter.getMeterByEventName(req.body.event_name, !!req.livemode);
|
|
198
|
+
if (!meter) {
|
|
199
|
+
return res
|
|
200
|
+
.status(400)
|
|
201
|
+
.json({ error: `Meter not found for event name "${req.body.event_name}"`, code: 'METER_NOT_FOUND' });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (meter.status !== 'active') {
|
|
205
|
+
return res
|
|
206
|
+
.status(400)
|
|
207
|
+
.json({ error: 'Meter is not active, please activate it first.', code: 'METER_NOT_ACTIVE' });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const paymentCurrency = await PaymentCurrency.findByPk(meter.currency_id);
|
|
211
|
+
if (!paymentCurrency) {
|
|
212
|
+
return res.status(400).json({ error: `Payment currency not found for meter "${meter.id}"` });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (req.body.payload.subscription_id) {
|
|
216
|
+
const subscription = await Subscription.findByPk(req.body.payload.subscription_id);
|
|
217
|
+
if (!subscription) {
|
|
218
|
+
return res.status(400).json({ error: `Subscription not found for meter event "${req.body.event_name}"` });
|
|
219
|
+
}
|
|
220
|
+
if (subscription.currency_id !== paymentCurrency.id) {
|
|
221
|
+
return res.status(400).json({ error: 'Subscription currency does not match meter currency' });
|
|
222
|
+
}
|
|
223
|
+
if (!subscription.isConsumesCredit()) {
|
|
224
|
+
return res.status(400).json({ error: 'Subscription does not consume credit' });
|
|
225
|
+
}
|
|
226
|
+
if (subscription.customer_id !== req.body.payload.customer_id) {
|
|
227
|
+
return res.status(400).json({ error: 'This is not your subscription' });
|
|
228
|
+
}
|
|
229
|
+
if (subscription.isImmutable()) {
|
|
230
|
+
return res.status(400).json({ error: 'Subscription is immutable' });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const customer = await Customer.findByPkOrDid(req.body.payload.customer_id);
|
|
235
|
+
if (!customer) {
|
|
236
|
+
return res.status(400).json({ error: 'Customer not found' });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const timestamp = req.body.timestamp || req.body.payload.timestamp || Math.floor(Date.now() / 1000);
|
|
240
|
+
|
|
241
|
+
const eventData = {
|
|
242
|
+
event_name: req.body.event_name,
|
|
243
|
+
payload: {
|
|
244
|
+
customer_id: customer.id,
|
|
245
|
+
currency_id: paymentCurrency.id,
|
|
246
|
+
decimal: paymentCurrency.decimal,
|
|
247
|
+
unit: paymentCurrency.name,
|
|
248
|
+
subscription_id: req.body.payload.subscription_id,
|
|
249
|
+
value: fromTokenToUnit(req.body.payload.value, paymentCurrency.decimal).toString(),
|
|
250
|
+
},
|
|
251
|
+
identifier: req.body.identifier,
|
|
252
|
+
livemode: !!req.livemode,
|
|
253
|
+
processed: false,
|
|
254
|
+
status: 'pending' as MeterEventStatus,
|
|
255
|
+
attempt_count: 0,
|
|
256
|
+
credit_consumed: '0',
|
|
257
|
+
credit_pending: fromTokenToUnit(req.body.payload.value, paymentCurrency.decimal).toString(),
|
|
258
|
+
created_via: req.user?.via || 'api',
|
|
259
|
+
metadata: formatMetadata(req.body.metadata),
|
|
260
|
+
timestamp,
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const event = await MeterEvent.create(eventData);
|
|
264
|
+
|
|
265
|
+
logger.info('Meter event created and will be queued for processing via afterCreate hook', {
|
|
266
|
+
eventId: event.id,
|
|
267
|
+
eventName: event.event_name,
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
return res.json({
|
|
271
|
+
...event.toJSON(),
|
|
272
|
+
processing: {
|
|
273
|
+
queued: true,
|
|
274
|
+
message: 'Credit consumption will be processed asynchronously',
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
} catch (err) {
|
|
278
|
+
logger.error('create meter event failed', { error: err?.message, request: req.body });
|
|
279
|
+
return res.status(400).json({ error: err?.message });
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
router.get('/pending-amount', authMine, async (req, res) => {
|
|
284
|
+
try {
|
|
285
|
+
const where: any = {
|
|
286
|
+
status: 'requires_action',
|
|
287
|
+
livemode: !!req.livemode,
|
|
288
|
+
};
|
|
289
|
+
if (req.query.subscription_id) {
|
|
290
|
+
where['payload.subscription_id'] = req.query.subscription_id;
|
|
291
|
+
}
|
|
292
|
+
if (req.query.customer_id) {
|
|
293
|
+
where['payload.customer_id'] = req.query.customer_id;
|
|
294
|
+
}
|
|
295
|
+
const [summary] = await MeterEvent.getPendingAmounts({
|
|
296
|
+
subscriptionId: req.query.subscription_id as string,
|
|
297
|
+
livemode: !!req.livemode,
|
|
298
|
+
currencyId: req.query.currency_id as string,
|
|
299
|
+
status: ['requires_action', 'requires_capture'],
|
|
300
|
+
customerId: req.query.customer_id as string,
|
|
301
|
+
});
|
|
302
|
+
return res.json(summary);
|
|
303
|
+
} catch (err) {
|
|
304
|
+
logger.error('Error getting meter event pending amount', err);
|
|
305
|
+
return res.status(400).json({ error: err?.message });
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
router.get('/:id', authMine, async (req, res) => {
|
|
309
|
+
try {
|
|
310
|
+
logger.info('get meter event', { id: req.params.id });
|
|
311
|
+
const event = await MeterEvent.findByPk(req.params.id);
|
|
312
|
+
|
|
313
|
+
if (!event) {
|
|
314
|
+
return res.status(404).json({ error: 'Meter event not found' });
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const customerId = event.getCustomerId();
|
|
318
|
+
const subscriptionId = event.getSubscriptionId();
|
|
319
|
+
const subscription = subscriptionId ? await Subscription.findByPk(subscriptionId) : null;
|
|
320
|
+
const customer = customerId ? await Customer.findByPk(customerId) : null;
|
|
321
|
+
if (!customer) {
|
|
322
|
+
return res.status(404).json({ error: 'Customer not found' });
|
|
323
|
+
}
|
|
324
|
+
if (customer.did !== req.user?.did && !['owner', 'admin'].includes(req.user?.role || '')) {
|
|
325
|
+
return res.status(403).json({ error: 'You are not allowed to access this resource' });
|
|
326
|
+
}
|
|
327
|
+
const meter = await Meter.getMeterByEventName(event.event_name, event.livemode);
|
|
328
|
+
let paymentCurrency = null;
|
|
329
|
+
if (meter) {
|
|
330
|
+
paymentCurrency = await PaymentCurrency.findByPk(meter.currency_id);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const result = {
|
|
334
|
+
...event.toJSON(),
|
|
335
|
+
customer,
|
|
336
|
+
subscription,
|
|
337
|
+
meter,
|
|
338
|
+
paymentCurrency,
|
|
339
|
+
};
|
|
340
|
+
return res.json(result);
|
|
341
|
+
} catch (err) {
|
|
342
|
+
logger.error('Error getting meter event', err);
|
|
343
|
+
return res.status(400).json({ error: err?.message });
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
export default router;
|