payment-kit 1.19.0 → 1.19.1

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.
Files changed (133) hide show
  1. package/api/src/crons/index.ts +8 -0
  2. package/api/src/index.ts +4 -0
  3. package/api/src/libs/credit-grant.ts +146 -0
  4. package/api/src/libs/env.ts +1 -0
  5. package/api/src/libs/invoice.ts +4 -3
  6. package/api/src/libs/notification/template/base.ts +388 -2
  7. package/api/src/libs/notification/template/customer-credit-grant-granted.ts +149 -0
  8. package/api/src/libs/notification/template/customer-credit-grant-low-balance.ts +151 -0
  9. package/api/src/libs/notification/template/customer-credit-insufficient.ts +254 -0
  10. package/api/src/libs/notification/template/subscription-canceled.ts +193 -202
  11. package/api/src/libs/notification/template/subscription-refund-succeeded.ts +215 -237
  12. package/api/src/libs/notification/template/subscription-renewed.ts +130 -200
  13. package/api/src/libs/notification/template/subscription-succeeded.ts +100 -202
  14. package/api/src/libs/notification/template/subscription-trial-start.ts +142 -188
  15. package/api/src/libs/notification/template/subscription-trial-will-end.ts +146 -174
  16. package/api/src/libs/notification/template/subscription-upgraded.ts +96 -192
  17. package/api/src/libs/notification/template/subscription-will-canceled.ts +94 -135
  18. package/api/src/libs/notification/template/subscription-will-renew.ts +220 -245
  19. package/api/src/libs/payment.ts +69 -0
  20. package/api/src/libs/queue/index.ts +3 -2
  21. package/api/src/libs/session.ts +8 -0
  22. package/api/src/libs/subscription.ts +74 -3
  23. package/api/src/libs/ws.ts +23 -1
  24. package/api/src/locales/en.ts +33 -0
  25. package/api/src/locales/zh.ts +31 -0
  26. package/api/src/queues/credit-consume.ts +715 -0
  27. package/api/src/queues/credit-grant.ts +572 -0
  28. package/api/src/queues/notification.ts +173 -128
  29. package/api/src/queues/payment.ts +210 -122
  30. package/api/src/queues/subscription.ts +179 -0
  31. package/api/src/routes/checkout-sessions.ts +157 -9
  32. package/api/src/routes/connect/shared.ts +3 -2
  33. package/api/src/routes/credit-grants.ts +241 -0
  34. package/api/src/routes/credit-transactions.ts +208 -0
  35. package/api/src/routes/index.ts +8 -0
  36. package/api/src/routes/meter-events.ts +347 -0
  37. package/api/src/routes/meters.ts +219 -0
  38. package/api/src/routes/payment-currencies.ts +14 -2
  39. package/api/src/routes/payment-links.ts +1 -1
  40. package/api/src/routes/payment-methods.ts +14 -2
  41. package/api/src/routes/prices.ts +43 -0
  42. package/api/src/routes/pricing-table.ts +13 -7
  43. package/api/src/routes/products.ts +63 -4
  44. package/api/src/routes/settings.ts +1 -1
  45. package/api/src/routes/subscriptions.ts +4 -0
  46. package/api/src/store/migrations/20250610-billing-credit.ts +43 -0
  47. package/api/src/store/models/credit-grant.ts +486 -0
  48. package/api/src/store/models/credit-transaction.ts +268 -0
  49. package/api/src/store/models/customer.ts +8 -0
  50. package/api/src/store/models/index.ts +52 -1
  51. package/api/src/store/models/meter-event.ts +423 -0
  52. package/api/src/store/models/meter.ts +176 -0
  53. package/api/src/store/models/payment-currency.ts +66 -14
  54. package/api/src/store/models/price.ts +6 -0
  55. package/api/src/store/models/product.ts +2 -2
  56. package/api/src/store/models/subscription.ts +24 -0
  57. package/api/src/store/models/types.ts +28 -2
  58. package/api/tests/libs/subscription.spec.ts +53 -0
  59. package/blocklet.yml +9 -1
  60. package/package.json +4 -4
  61. package/scripts/sdk.js +233 -1
  62. package/src/app.tsx +10 -0
  63. package/src/components/collapse.tsx +11 -1
  64. package/src/components/customer/credit-grant-item-list.tsx +99 -0
  65. package/src/components/customer/credit-overview.tsx +233 -0
  66. package/src/components/customer/form.tsx +5 -2
  67. package/src/components/invoice/list.tsx +19 -1
  68. package/src/components/metadata/form.tsx +286 -90
  69. package/src/components/meter/actions.tsx +101 -0
  70. package/src/components/meter/add-usage-dialog.tsx +239 -0
  71. package/src/components/meter/events-list.tsx +657 -0
  72. package/src/components/meter/form.tsx +245 -0
  73. package/src/components/meter/products.tsx +264 -0
  74. package/src/components/meter/usage-guide.tsx +174 -0
  75. package/src/components/payment-currency/form.tsx +2 -0
  76. package/src/components/payment-intent/list.tsx +19 -1
  77. package/src/components/payment-link/preview.tsx +1 -1
  78. package/src/components/payment-link/product-select.tsx +52 -12
  79. package/src/components/payment-method/arcblock.tsx +2 -0
  80. package/src/components/payment-method/base.tsx +2 -0
  81. package/src/components/payment-method/bitcoin.tsx +2 -0
  82. package/src/components/payment-method/ethereum.tsx +2 -0
  83. package/src/components/payment-method/stripe.tsx +2 -0
  84. package/src/components/payouts/list.tsx +19 -1
  85. package/src/components/price/currency-select.tsx +51 -31
  86. package/src/components/price/form.tsx +881 -407
  87. package/src/components/pricing-table/preview.tsx +1 -1
  88. package/src/components/product/add-price.tsx +9 -7
  89. package/src/components/product/create.tsx +7 -4
  90. package/src/components/product/edit-price.tsx +21 -12
  91. package/src/components/product/features.tsx +17 -7
  92. package/src/components/product/form.tsx +104 -89
  93. package/src/components/refund/list.tsx +19 -1
  94. package/src/components/section/header.tsx +5 -18
  95. package/src/components/subscription/items/index.tsx +1 -1
  96. package/src/components/subscription/metrics.tsx +37 -5
  97. package/src/components/subscription/portal/actions.tsx +2 -1
  98. package/src/contexts/products.tsx +26 -9
  99. package/src/hooks/subscription.ts +34 -0
  100. package/src/libs/meter-utils.ts +196 -0
  101. package/src/libs/util.ts +4 -0
  102. package/src/locales/en.tsx +385 -4
  103. package/src/locales/zh.tsx +364 -0
  104. package/src/pages/admin/billing/index.tsx +61 -33
  105. package/src/pages/admin/billing/invoices/detail.tsx +1 -1
  106. package/src/pages/admin/billing/meters/create.tsx +60 -0
  107. package/src/pages/admin/billing/meters/detail.tsx +435 -0
  108. package/src/pages/admin/billing/meters/index.tsx +210 -0
  109. package/src/pages/admin/billing/meters/meter-event.tsx +346 -0
  110. package/src/pages/admin/billing/subscriptions/detail.tsx +47 -14
  111. package/src/pages/admin/customers/customers/credit-grant/detail.tsx +391 -0
  112. package/src/pages/admin/customers/customers/detail.tsx +22 -10
  113. package/src/pages/admin/customers/index.tsx +5 -0
  114. package/src/pages/admin/developers/events/detail.tsx +1 -1
  115. package/src/pages/admin/developers/index.tsx +1 -1
  116. package/src/pages/admin/payments/intents/detail.tsx +1 -1
  117. package/src/pages/admin/payments/payouts/detail.tsx +1 -1
  118. package/src/pages/admin/payments/refunds/detail.tsx +1 -1
  119. package/src/pages/admin/products/index.tsx +3 -2
  120. package/src/pages/admin/products/links/detail.tsx +1 -1
  121. package/src/pages/admin/products/prices/actions.tsx +16 -4
  122. package/src/pages/admin/products/prices/detail.tsx +30 -3
  123. package/src/pages/admin/products/prices/list.tsx +8 -1
  124. package/src/pages/admin/products/pricing-tables/detail.tsx +1 -1
  125. package/src/pages/admin/products/products/create.tsx +233 -57
  126. package/src/pages/admin/products/products/detail.tsx +2 -1
  127. package/src/pages/admin/settings/payment-methods/index.tsx +3 -0
  128. package/src/pages/customer/credit-grant/detail.tsx +308 -0
  129. package/src/pages/customer/index.tsx +35 -2
  130. package/src/pages/customer/recharge/account.tsx +5 -5
  131. package/src/pages/customer/subscription/change-payment.tsx +4 -2
  132. package/src/pages/customer/subscription/detail.tsx +48 -14
  133. 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;
@@ -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;