payment-kit 1.24.1 → 1.24.3
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 +5 -5
- package/api/src/crons/overdue-detection.ts +725 -0
- package/api/src/libs/env.ts +2 -0
- package/api/src/libs/notification/template/customer-auto-recharge-daily-limit-exceeded.ts +222 -0
- package/api/src/libs/notification/template/customer-credit-insufficient.ts +20 -63
- package/api/src/libs/notification/template/customer-credit-low-balance.ts +57 -7
- package/api/src/locales/en.ts +74 -36
- package/api/src/locales/zh.ts +77 -39
- package/api/src/queues/auto-recharge.ts +7 -0
- package/api/src/queues/credit-consume.ts +23 -2
- package/api/src/queues/notification.ts +119 -1
- package/api/src/routes/credit-transactions.ts +85 -7
- package/api/src/routes/invoices.ts +12 -0
- package/api/src/routes/meter-events.ts +169 -0
- package/blocklet.yml +1 -1
- package/package.json +6 -6
- package/src/components/customer/credit-overview.tsx +7 -1
- package/src/locales/en.tsx +20 -0
- package/src/locales/zh.tsx +20 -0
- package/src/pages/admin/billing/index.tsx +4 -0
- package/src/pages/admin/billing/meter-events/index.tsx +588 -0
- package/src/pages/admin/billing/overdue/index.tsx +289 -0
- package/src/pages/admin/customers/customers/credit-transaction/detail.tsx +15 -0
- package/src/pages/admin/overview.tsx +129 -1
- package/src/pages/customer/credit-transaction/detail.tsx +12 -0
|
@@ -102,6 +102,10 @@ import {
|
|
|
102
102
|
CustomerAutoRechargeFailedEmailTemplate,
|
|
103
103
|
CustomerAutoRechargeFailedEmailTemplateOptions,
|
|
104
104
|
} from '../libs/notification/template/customer-auto-recharge-failed';
|
|
105
|
+
import {
|
|
106
|
+
CustomerAutoRechargeDailyLimitExceededEmailTemplate,
|
|
107
|
+
CustomerAutoRechargeDailyLimitExceededEmailTemplateOptions,
|
|
108
|
+
} from '../libs/notification/template/customer-auto-recharge-daily-limit-exceeded';
|
|
105
109
|
import {
|
|
106
110
|
CustomerRevenueSucceededEmailTemplate,
|
|
107
111
|
CustomerRevenueSucceededEmailTemplateOptions,
|
|
@@ -139,6 +143,7 @@ export type NotificationQueueJobType =
|
|
|
139
143
|
| 'customer.credit_grant.granted'
|
|
140
144
|
| 'customer.credit.low_balance'
|
|
141
145
|
| 'customer.auto_recharge.failed'
|
|
146
|
+
| 'customer.auto_recharge.daily_limit_exceeded'
|
|
142
147
|
| 'webhook.endpoint.failed';
|
|
143
148
|
|
|
144
149
|
export type NotificationQueueJob = {
|
|
@@ -285,6 +290,12 @@ async function getNotificationTemplate(job: NotificationQueueJob): Promise<BaseE
|
|
|
285
290
|
return new CustomerAutoRechargeFailedEmailTemplate(job.options as CustomerAutoRechargeFailedEmailTemplateOptions);
|
|
286
291
|
}
|
|
287
292
|
|
|
293
|
+
if (job.type === 'customer.auto_recharge.daily_limit_exceeded') {
|
|
294
|
+
return new CustomerAutoRechargeDailyLimitExceededEmailTemplate(
|
|
295
|
+
job.options as CustomerAutoRechargeDailyLimitExceededEmailTemplateOptions
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
288
299
|
if (job.type === 'webhook.endpoint.failed') {
|
|
289
300
|
return new WebhookEndpointFailedEmailTemplate(job.options as WebhookEndpointFailedEmailTemplateOptions);
|
|
290
301
|
}
|
|
@@ -296,6 +307,18 @@ async function handleNotificationJob(job: NotificationQueueJob): Promise<void> {
|
|
|
296
307
|
try {
|
|
297
308
|
const template = await getNotificationTemplate(job);
|
|
298
309
|
|
|
310
|
+
// Check if template class has a preflightCheck
|
|
311
|
+
const TemplateClass = template.constructor as any;
|
|
312
|
+
if (TemplateClass.preflightCheck) {
|
|
313
|
+
const shouldSend = await TemplateClass.preflightCheck(job.options);
|
|
314
|
+
if (!shouldSend) {
|
|
315
|
+
logger.info('handleNotificationJob.skipped: preflight check returned false', {
|
|
316
|
+
type: job.type,
|
|
317
|
+
});
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
299
322
|
await new Notification(template, job.type).send();
|
|
300
323
|
logger.info('handleImmediateNotificationJob.success', { job });
|
|
301
324
|
} catch (error) {
|
|
@@ -329,6 +352,19 @@ setInterval(
|
|
|
329
352
|
60 * 60 * 1000
|
|
330
353
|
); // 每小时清理一次
|
|
331
354
|
|
|
355
|
+
/**
|
|
356
|
+
* Clear notification cache entries that match given prefix
|
|
357
|
+
* Used to reset rate limiting when conditions change (e.g., credit recharged)
|
|
358
|
+
*/
|
|
359
|
+
function clearNotificationCache(prefix: string) {
|
|
360
|
+
for (const [key] of notificationCache.entries()) {
|
|
361
|
+
if (key.startsWith(prefix)) {
|
|
362
|
+
notificationCache.delete(key);
|
|
363
|
+
logger.info('Notification cache cleared', { key });
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
332
368
|
/**
|
|
333
369
|
* Handles immediate notifications by pushing them directly to the notification queue
|
|
334
370
|
*/
|
|
@@ -624,7 +660,7 @@ export async function startNotificationQueue() {
|
|
|
624
660
|
});
|
|
625
661
|
|
|
626
662
|
events.on('customer.credit.low_balance', (customer: Customer, { metadata }: { metadata: any }) => {
|
|
627
|
-
|
|
663
|
+
addNotificationJobWithDelay(
|
|
628
664
|
'customer.credit.low_balance',
|
|
629
665
|
{
|
|
630
666
|
customerId: customer.id,
|
|
@@ -678,6 +714,36 @@ export async function startNotificationQueue() {
|
|
|
678
714
|
}
|
|
679
715
|
}
|
|
680
716
|
);
|
|
717
|
+
|
|
718
|
+
events.on(
|
|
719
|
+
'customer.auto_recharge.daily_limit_exceeded',
|
|
720
|
+
(customer: Customer, { autoRechargeConfigId, currencyId, currentBalance }) => {
|
|
721
|
+
logger.info('addNotificationJob:customer.auto_recharge.daily_limit_exceeded', {
|
|
722
|
+
customerId: customer.id,
|
|
723
|
+
autoRechargeConfigId,
|
|
724
|
+
currencyId,
|
|
725
|
+
});
|
|
726
|
+
addNotificationJob(
|
|
727
|
+
'customer.auto_recharge.daily_limit_exceeded',
|
|
728
|
+
{
|
|
729
|
+
customerId: customer.id,
|
|
730
|
+
autoRechargeConfigId,
|
|
731
|
+
currencyId,
|
|
732
|
+
currentBalance,
|
|
733
|
+
},
|
|
734
|
+
[customer.id, autoRechargeConfigId, currencyId],
|
|
735
|
+
true, // prevent duplicate
|
|
736
|
+
24 * 3600 // 24 hours
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
);
|
|
740
|
+
|
|
741
|
+
// Clear credit notification cache when customer recharges
|
|
742
|
+
// This allows customer to receive insufficient/low_balance notifications again
|
|
743
|
+
events.on('customer.credit_grant.granted', (creditGrant: CreditGrant) => {
|
|
744
|
+
clearNotificationCache(`customer.credit.insufficient.${creditGrant.customer_id}.${creditGrant.currency_id}`);
|
|
745
|
+
clearNotificationCache(`customer.credit.low_balance.${creditGrant.customer_id}.${creditGrant.currency_id}`);
|
|
746
|
+
});
|
|
681
747
|
}
|
|
682
748
|
|
|
683
749
|
export async function handleNotificationPreferenceChange(
|
|
@@ -812,3 +878,55 @@ export function addNotificationJob(
|
|
|
812
878
|
},
|
|
813
879
|
});
|
|
814
880
|
}
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* Add a notification job with delay support
|
|
884
|
+
* Use this when the template class has a static `delay` property
|
|
885
|
+
*/
|
|
886
|
+
export async function addNotificationJobWithDelay(
|
|
887
|
+
type: NotificationQueueJobType,
|
|
888
|
+
options: NotificationQueueJobOptions,
|
|
889
|
+
extraIds: string[] = [],
|
|
890
|
+
preventDuplicate: boolean = false,
|
|
891
|
+
duplicateWindow: number = 600
|
|
892
|
+
) {
|
|
893
|
+
const idParts = [type];
|
|
894
|
+
|
|
895
|
+
if (extraIds.length) {
|
|
896
|
+
idParts.push(...extraIds);
|
|
897
|
+
} else {
|
|
898
|
+
idParts.push(Date.now().toString());
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
if (preventDuplicate) {
|
|
902
|
+
const cacheKey = `${type}.${extraIds.join('.')}`;
|
|
903
|
+
const lastSent = notificationCache.get(cacheKey);
|
|
904
|
+
const now = Date.now();
|
|
905
|
+
|
|
906
|
+
if (lastSent && now - lastSent < duplicateWindow * 1000) {
|
|
907
|
+
logger.info('Notification skipped due to duplicate prevention', {
|
|
908
|
+
type,
|
|
909
|
+
cacheKey,
|
|
910
|
+
lastSent: new Date(lastSent),
|
|
911
|
+
duplicateWindow,
|
|
912
|
+
});
|
|
913
|
+
return Promise.resolve();
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
notificationCache.set(cacheKey, now);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// Get delay from template class
|
|
920
|
+
const template = await getNotificationTemplate({ type, options });
|
|
921
|
+
const TemplateClass = template.constructor as any;
|
|
922
|
+
const { delay } = TemplateClass;
|
|
923
|
+
|
|
924
|
+
return notificationQueue.push({
|
|
925
|
+
id: idParts.join('.'),
|
|
926
|
+
job: {
|
|
927
|
+
type,
|
|
928
|
+
options,
|
|
929
|
+
},
|
|
930
|
+
...(delay && delay > 0 ? { delay } : {}),
|
|
931
|
+
});
|
|
932
|
+
}
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
Subscription,
|
|
16
16
|
PaymentCurrency,
|
|
17
17
|
PaymentMethod,
|
|
18
|
+
Invoice,
|
|
18
19
|
TCreditTransactionExpanded,
|
|
19
20
|
} from '../store/models';
|
|
20
21
|
|
|
@@ -118,7 +119,12 @@ router.get('/', authMine, async (req, res) => {
|
|
|
118
119
|
{ model: Meter, as: 'meter' },
|
|
119
120
|
{ model: Subscription, as: 'subscription', attributes: ['id', 'description', 'status'], required: false },
|
|
120
121
|
{ model: CreditGrant, as: 'creditGrant', attributes: ['id', 'name', 'currency_id'] },
|
|
121
|
-
{
|
|
122
|
+
{
|
|
123
|
+
model: MeterEvent,
|
|
124
|
+
as: 'meterEvent',
|
|
125
|
+
attributes: ['id', 'source_data', 'timestamp', 'processed_at', 'created_at'],
|
|
126
|
+
required: false,
|
|
127
|
+
},
|
|
122
128
|
],
|
|
123
129
|
});
|
|
124
130
|
// Transform transactions
|
|
@@ -135,6 +141,9 @@ router.get('/', authMine, async (req, res) => {
|
|
|
135
141
|
customer_id: query.customer_id,
|
|
136
142
|
status: ['granted', 'depleted', 'expired'],
|
|
137
143
|
};
|
|
144
|
+
if (query.subscription_id) {
|
|
145
|
+
grantWhere['metadata.subscription_id'] = query.subscription_id;
|
|
146
|
+
}
|
|
138
147
|
if (query.start) {
|
|
139
148
|
grantWhere.created_at = {
|
|
140
149
|
[Op.gte]: new Date(query.start * 1000),
|
|
@@ -162,11 +171,80 @@ router.get('/', authMine, async (req, res) => {
|
|
|
162
171
|
},
|
|
163
172
|
],
|
|
164
173
|
});
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
174
|
+
|
|
175
|
+
// Fetch invoice info for paid grants
|
|
176
|
+
const invoiceIds = rows
|
|
177
|
+
.map((item: any) => item.metadata?.invoice_id)
|
|
178
|
+
.filter((id: string | undefined): id is string => !!id);
|
|
179
|
+
|
|
180
|
+
// For first scheduled grants (schedule_seq === 1) without invoice_id, find invoice by subscription_id
|
|
181
|
+
const subscriptionIdsForFirstGrant = rows
|
|
182
|
+
.filter(
|
|
183
|
+
(item: any) =>
|
|
184
|
+
item.metadata?.schedule_seq === 1 && !item.metadata?.invoice_id && item.metadata?.subscription_id
|
|
185
|
+
)
|
|
186
|
+
.map((item: any) => item.metadata.subscription_id);
|
|
187
|
+
|
|
188
|
+
const [invoicesByIds, invoicesBySubscription] = await Promise.all([
|
|
189
|
+
invoiceIds.length > 0
|
|
190
|
+
? Invoice.findAll({
|
|
191
|
+
where: { id: { [Op.in]: invoiceIds } },
|
|
192
|
+
attributes: ['id', 'total', 'amount_paid', 'currency_id', 'billing_reason', 'subscription_id'],
|
|
193
|
+
include: [
|
|
194
|
+
{
|
|
195
|
+
model: PaymentCurrency,
|
|
196
|
+
as: 'paymentCurrency',
|
|
197
|
+
attributes: ['id', 'symbol', 'decimal', 'type'],
|
|
198
|
+
},
|
|
199
|
+
],
|
|
200
|
+
})
|
|
201
|
+
: [],
|
|
202
|
+
subscriptionIdsForFirstGrant.length > 0
|
|
203
|
+
? Invoice.findAll({
|
|
204
|
+
where: {
|
|
205
|
+
subscription_id: { [Op.in]: subscriptionIdsForFirstGrant },
|
|
206
|
+
status: 'paid',
|
|
207
|
+
},
|
|
208
|
+
attributes: ['id', 'total', 'amount_paid', 'currency_id', 'billing_reason', 'subscription_id'],
|
|
209
|
+
include: [
|
|
210
|
+
{
|
|
211
|
+
model: PaymentCurrency,
|
|
212
|
+
as: 'paymentCurrency',
|
|
213
|
+
attributes: ['id', 'symbol', 'decimal', 'type'],
|
|
214
|
+
},
|
|
215
|
+
],
|
|
216
|
+
order: [['created_at', 'ASC']],
|
|
217
|
+
})
|
|
218
|
+
: [],
|
|
219
|
+
]);
|
|
220
|
+
|
|
221
|
+
const invoiceMap = new Map(invoicesByIds.map((inv: any) => [inv.id, inv]));
|
|
222
|
+
for (const inv of invoicesBySubscription) {
|
|
223
|
+
const subId = inv.subscription_id as string;
|
|
224
|
+
if (subId && !invoiceMap.has(subId)) {
|
|
225
|
+
invoiceMap.set(subId, inv);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Transform grants with invoice info
|
|
230
|
+
return rows.map((item: any) => {
|
|
231
|
+
const grantData = item.toJSON();
|
|
232
|
+
const invoice = invoiceMap.get(grantData.metadata.invoice_id);
|
|
233
|
+
return {
|
|
234
|
+
...grantData,
|
|
235
|
+
activity_type: 'grant',
|
|
236
|
+
invoice: invoice
|
|
237
|
+
? {
|
|
238
|
+
total: invoice.total,
|
|
239
|
+
amount_paid: invoice.amount_paid,
|
|
240
|
+
currency_id: invoice.currency_id,
|
|
241
|
+
billing_reason: invoice.billing_reason,
|
|
242
|
+
subscription_id: invoice.subscription_id,
|
|
243
|
+
paymentCurrency: invoice.paymentCurrency,
|
|
244
|
+
}
|
|
245
|
+
: null,
|
|
246
|
+
};
|
|
247
|
+
});
|
|
170
248
|
},
|
|
171
249
|
meta: { type: 'database' },
|
|
172
250
|
};
|
|
@@ -344,7 +422,7 @@ router.get('/:id', authPortal, async (req, res) => {
|
|
|
344
422
|
{
|
|
345
423
|
model: MeterEvent,
|
|
346
424
|
as: 'meterEvent',
|
|
347
|
-
attributes: ['id', 'source_data'],
|
|
425
|
+
attributes: ['id', 'source_data', 'timestamp', 'processed_at', 'created_at'],
|
|
348
426
|
required: false,
|
|
349
427
|
},
|
|
350
428
|
],
|
|
@@ -311,6 +311,18 @@ router.get('/', authMine, async (req, res) => {
|
|
|
311
311
|
{ model: PaymentCurrency, as: 'paymentCurrency' },
|
|
312
312
|
{ model: PaymentMethod, as: 'paymentMethod' },
|
|
313
313
|
{ model: Subscription, as: 'subscription', attributes: ['id', 'description'] },
|
|
314
|
+
{
|
|
315
|
+
model: InvoiceItem,
|
|
316
|
+
as: 'lines',
|
|
317
|
+
include: [
|
|
318
|
+
{
|
|
319
|
+
model: Price,
|
|
320
|
+
as: 'price',
|
|
321
|
+
attributes: ['id', 'metadata'],
|
|
322
|
+
include: [{ model: Product, as: 'product', attributes: ['id', 'type', 'name'] }],
|
|
323
|
+
},
|
|
324
|
+
],
|
|
325
|
+
},
|
|
314
326
|
{ model: Customer, as: 'customer' },
|
|
315
327
|
],
|
|
316
328
|
});
|
|
@@ -352,6 +352,175 @@ router.get('/pending-amount', authMine, async (req, res) => {
|
|
|
352
352
|
return res.status(400).json({ error: err?.message });
|
|
353
353
|
}
|
|
354
354
|
});
|
|
355
|
+
|
|
356
|
+
// Get overdue summary by credit currency (action-required amounts grouped by currency)
|
|
357
|
+
router.get('/overdue-summary', auth, async (req, res) => {
|
|
358
|
+
try {
|
|
359
|
+
// Query to aggregate credit_pending by currency, showing overdue amounts per credit currency
|
|
360
|
+
const results = await MeterEvent.sequelize!.query<{
|
|
361
|
+
currency_id: string;
|
|
362
|
+
total_pending: string;
|
|
363
|
+
customer_count: number;
|
|
364
|
+
event_count: number;
|
|
365
|
+
}>(
|
|
366
|
+
`SELECT
|
|
367
|
+
m.currency_id,
|
|
368
|
+
SUM(CAST(me.credit_pending AS DECIMAL(40,0))) as total_pending,
|
|
369
|
+
COUNT(DISTINCT json_extract(me.payload, '$.customer_id')) as customer_count,
|
|
370
|
+
COUNT(*) as event_count
|
|
371
|
+
FROM meter_events me
|
|
372
|
+
JOIN meters m ON me.event_name = m.event_name
|
|
373
|
+
WHERE me.livemode = :livemode
|
|
374
|
+
AND me.status IN ('requires_capture', 'requires_action')
|
|
375
|
+
GROUP BY m.currency_id
|
|
376
|
+
HAVING total_pending > 0
|
|
377
|
+
ORDER BY total_pending DESC`,
|
|
378
|
+
{
|
|
379
|
+
replacements: { livemode: req.livemode ? 1 : 0 },
|
|
380
|
+
type: QueryTypes.SELECT,
|
|
381
|
+
}
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
// Fetch currency details
|
|
385
|
+
const currencyIds = results.map((r) => r.currency_id);
|
|
386
|
+
const currencies =
|
|
387
|
+
currencyIds.length > 0 ? await PaymentCurrency.findAll({ where: { id: { [Op.in]: currencyIds } } }) : [];
|
|
388
|
+
const currencyMap = new Map(currencies.map((c) => [c.id, c]));
|
|
389
|
+
|
|
390
|
+
const list = results.map((r) => ({
|
|
391
|
+
currency: currencyMap.get(r.currency_id),
|
|
392
|
+
total_pending: r.total_pending,
|
|
393
|
+
customer_count: r.customer_count,
|
|
394
|
+
event_count: r.event_count,
|
|
395
|
+
}));
|
|
396
|
+
|
|
397
|
+
return res.json({ list });
|
|
398
|
+
} catch (err) {
|
|
399
|
+
logger.error('Error getting overdue summary', err);
|
|
400
|
+
return res.status(400).json({ error: err?.message });
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
// Get all customers with credit overdue (action-required amount > 0), grouped by customer + currency
|
|
405
|
+
router.get('/overdue-customers', auth, async (req, res) => {
|
|
406
|
+
try {
|
|
407
|
+
const page = Math.max(1, parseInt(String(req.query.page || '1'), 10));
|
|
408
|
+
const pageSize = Math.min(100, Math.max(1, parseInt(String(req.query.pageSize || '20'), 10)));
|
|
409
|
+
const offset = (page - 1) * pageSize;
|
|
410
|
+
const currencyId = req.query.currency_id as string | undefined;
|
|
411
|
+
const searchQuery = req.query.q as string | undefined;
|
|
412
|
+
|
|
413
|
+
// If search query provided, first find matching customer IDs
|
|
414
|
+
let customerFilter = '';
|
|
415
|
+
const replacements: any = { livemode: req.livemode ? 1 : 0, limit: pageSize, offset };
|
|
416
|
+
const countReplacements: any = { livemode: req.livemode ? 1 : 0 };
|
|
417
|
+
|
|
418
|
+
// Build currency filter
|
|
419
|
+
let currencyFilter = '';
|
|
420
|
+
if (currencyId) {
|
|
421
|
+
currencyFilter = 'AND m.currency_id = :currencyId';
|
|
422
|
+
replacements.currencyId = currencyId;
|
|
423
|
+
countReplacements.currencyId = currencyId;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (searchQuery) {
|
|
427
|
+
// Escape LIKE wildcards to prevent unintended matches
|
|
428
|
+
const escapedQuery = searchQuery.replace(/[%_]/g, '\\$&');
|
|
429
|
+
const searchCustomers = await Customer.findAll({
|
|
430
|
+
where: {
|
|
431
|
+
[Op.or]: [
|
|
432
|
+
{ name: { [Op.like]: `%${escapedQuery}%` } },
|
|
433
|
+
{ email: { [Op.like]: `%${escapedQuery}%` } },
|
|
434
|
+
{ did: { [Op.like]: `%${escapedQuery}%` } },
|
|
435
|
+
],
|
|
436
|
+
},
|
|
437
|
+
attributes: ['id'],
|
|
438
|
+
});
|
|
439
|
+
const searchCustomerIds = searchCustomers.map((c) => c.id);
|
|
440
|
+
if (searchCustomerIds.length === 0) {
|
|
441
|
+
return res.json({ list: [], count: 0, paging: { page, pageSize } });
|
|
442
|
+
}
|
|
443
|
+
// Generate parameterized placeholders for customer IDs
|
|
444
|
+
const placeholders = searchCustomerIds.map((_, i) => `:searchId${i}`);
|
|
445
|
+
customerFilter = `AND json_extract(me.payload, '$.customer_id') IN (${placeholders.join(',')})`;
|
|
446
|
+
searchCustomerIds.forEach((id, i) => {
|
|
447
|
+
replacements[`searchId${i}`] = id;
|
|
448
|
+
countReplacements[`searchId${i}`] = id;
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Query to aggregate credit_pending by customer + currency, order by event_count DESC
|
|
453
|
+
const results = await MeterEvent.sequelize!.query<{
|
|
454
|
+
customer_id: string;
|
|
455
|
+
currency_id: string;
|
|
456
|
+
total_pending: string;
|
|
457
|
+
event_count: number;
|
|
458
|
+
}>(
|
|
459
|
+
`SELECT
|
|
460
|
+
json_extract(me.payload, '$.customer_id') as customer_id,
|
|
461
|
+
m.currency_id as currency_id,
|
|
462
|
+
SUM(CAST(me.credit_pending AS DECIMAL(40,0))) as total_pending,
|
|
463
|
+
COUNT(*) as event_count
|
|
464
|
+
FROM meter_events me
|
|
465
|
+
JOIN meters m ON me.event_name = m.event_name
|
|
466
|
+
WHERE me.livemode = :livemode
|
|
467
|
+
AND me.status IN ('requires_capture', 'requires_action')
|
|
468
|
+
${currencyFilter}
|
|
469
|
+
${customerFilter}
|
|
470
|
+
GROUP BY json_extract(me.payload, '$.customer_id'), m.currency_id
|
|
471
|
+
HAVING total_pending > 0
|
|
472
|
+
ORDER BY event_count DESC
|
|
473
|
+
LIMIT :limit OFFSET :offset`,
|
|
474
|
+
{
|
|
475
|
+
replacements,
|
|
476
|
+
type: QueryTypes.SELECT,
|
|
477
|
+
}
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
// Get total count
|
|
481
|
+
const countResult = await MeterEvent.sequelize!.query<{ count: number }>(
|
|
482
|
+
`SELECT COUNT(*) as count FROM (
|
|
483
|
+
SELECT json_extract(me.payload, '$.customer_id') as customer_id, m.currency_id
|
|
484
|
+
FROM meter_events me
|
|
485
|
+
JOIN meters m ON me.event_name = m.event_name
|
|
486
|
+
WHERE me.livemode = :livemode
|
|
487
|
+
AND me.status IN ('requires_capture', 'requires_action')
|
|
488
|
+
${currencyFilter}
|
|
489
|
+
${customerFilter}
|
|
490
|
+
GROUP BY json_extract(me.payload, '$.customer_id'), m.currency_id
|
|
491
|
+
HAVING SUM(CAST(me.credit_pending AS DECIMAL(40,0))) > 0
|
|
492
|
+
)`,
|
|
493
|
+
{
|
|
494
|
+
replacements: countReplacements,
|
|
495
|
+
type: QueryTypes.SELECT,
|
|
496
|
+
}
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
const count = countResult[0]?.count || 0;
|
|
500
|
+
const customerIds = [...new Set(results.map((r) => r.customer_id))];
|
|
501
|
+
const currencyIds = [...new Set(results.map((r) => r.currency_id))];
|
|
502
|
+
|
|
503
|
+
// Fetch customer and currency details
|
|
504
|
+
const customers = customerIds.length > 0 ? await Customer.findAll({ where: { id: { [Op.in]: customerIds } } }) : [];
|
|
505
|
+
const customerMap = new Map(customers.map((c) => [c.id, c]));
|
|
506
|
+
|
|
507
|
+
const currencies =
|
|
508
|
+
currencyIds.length > 0 ? await PaymentCurrency.findAll({ where: { id: { [Op.in]: currencyIds } } }) : [];
|
|
509
|
+
const currencyMap = new Map(currencies.map((c) => [c.id, c]));
|
|
510
|
+
|
|
511
|
+
const list = results.map((r) => ({
|
|
512
|
+
customer: customerMap.get(r.customer_id),
|
|
513
|
+
currency: currencyMap.get(r.currency_id),
|
|
514
|
+
total_pending: r.total_pending,
|
|
515
|
+
event_count: r.event_count,
|
|
516
|
+
}));
|
|
517
|
+
|
|
518
|
+
return res.json({ list, count, paging: { page, pageSize } });
|
|
519
|
+
} catch (err) {
|
|
520
|
+
logger.error('Error getting overdue customers', err);
|
|
521
|
+
return res.status(400).json({ error: err?.message });
|
|
522
|
+
}
|
|
523
|
+
});
|
|
355
524
|
router.get('/:id', authMine, async (req, res) => {
|
|
356
525
|
try {
|
|
357
526
|
logger.info('get meter event', { id: req.params.id });
|
package/blocklet.yml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "payment-kit",
|
|
3
|
-
"version": "1.24.
|
|
3
|
+
"version": "1.24.3",
|
|
4
4
|
"scripts": {
|
|
5
5
|
"dev": "blocklet dev --open",
|
|
6
6
|
"prelint": "npm run types",
|
|
@@ -59,9 +59,9 @@
|
|
|
59
59
|
"@blocklet/error": "^0.3.5",
|
|
60
60
|
"@blocklet/js-sdk": "^1.17.8-beta-20260104-120132-cb5b1914",
|
|
61
61
|
"@blocklet/logger": "^1.17.8-beta-20260104-120132-cb5b1914",
|
|
62
|
-
"@blocklet/payment-broker-client": "1.24.
|
|
63
|
-
"@blocklet/payment-react": "1.24.
|
|
64
|
-
"@blocklet/payment-vendor": "1.24.
|
|
62
|
+
"@blocklet/payment-broker-client": "1.24.3",
|
|
63
|
+
"@blocklet/payment-react": "1.24.3",
|
|
64
|
+
"@blocklet/payment-vendor": "1.24.3",
|
|
65
65
|
"@blocklet/sdk": "^1.17.8-beta-20260104-120132-cb5b1914",
|
|
66
66
|
"@blocklet/ui-react": "^3.4.7",
|
|
67
67
|
"@blocklet/uploader": "^0.3.19",
|
|
@@ -131,7 +131,7 @@
|
|
|
131
131
|
"devDependencies": {
|
|
132
132
|
"@abtnode/types": "^1.17.8-beta-20260104-120132-cb5b1914",
|
|
133
133
|
"@arcblock/eslint-config-ts": "^0.3.3",
|
|
134
|
-
"@blocklet/payment-types": "1.24.
|
|
134
|
+
"@blocklet/payment-types": "1.24.3",
|
|
135
135
|
"@types/cookie-parser": "^1.4.9",
|
|
136
136
|
"@types/cors": "^2.8.19",
|
|
137
137
|
"@types/debug": "^4.1.12",
|
|
@@ -178,5 +178,5 @@
|
|
|
178
178
|
"parser": "typescript"
|
|
179
179
|
}
|
|
180
180
|
},
|
|
181
|
-
"gitHead": "
|
|
181
|
+
"gitHead": "6f2a963875ed4aced1db11f1c3a044c7f5deedd6"
|
|
182
182
|
}
|
|
@@ -259,7 +259,13 @@ export default function CreditOverview({ customerId, settings, mode = 'portal' }
|
|
|
259
259
|
|
|
260
260
|
const filteredCreditCurrencies = useMemo(() => {
|
|
261
261
|
return creditCurrencies.filter((currency: TPaymentCurrency) => {
|
|
262
|
-
|
|
262
|
+
const grantData = creditSummary?.grants?.[currency.id];
|
|
263
|
+
if (!grantData) return false;
|
|
264
|
+
// Filter out credits with zero balance
|
|
265
|
+
if (grantData.remainingAmount === '0') {
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
return true;
|
|
263
269
|
});
|
|
264
270
|
}, [creditCurrencies, creditSummary?.grants]);
|
|
265
271
|
|
package/src/locales/en.tsx
CHANGED
|
@@ -98,6 +98,7 @@ export default flat({
|
|
|
98
98
|
creditGrant: 'Grant',
|
|
99
99
|
date: 'Date',
|
|
100
100
|
subscription: 'Subscription',
|
|
101
|
+
meter: 'Meter',
|
|
101
102
|
meterEvent: 'Meter Event',
|
|
102
103
|
creditAmount: 'Credit',
|
|
103
104
|
createdAt: 'Created At',
|
|
@@ -181,6 +182,14 @@ export default flat({
|
|
|
181
182
|
transaction: 'Transaction Metrics',
|
|
182
183
|
essential: 'Essential Metrics',
|
|
183
184
|
},
|
|
185
|
+
overdue: {
|
|
186
|
+
title: 'Pending Consumption',
|
|
187
|
+
subtitle: 'Unpaid credit consumption',
|
|
188
|
+
customers: '{count} customers',
|
|
189
|
+
events: '{count} pending records',
|
|
190
|
+
viewAll: 'View All',
|
|
191
|
+
noOverdue: 'No pending consumption',
|
|
192
|
+
},
|
|
184
193
|
},
|
|
185
194
|
payments: 'Payments',
|
|
186
195
|
connections: 'Connections',
|
|
@@ -270,6 +279,13 @@ export default flat({
|
|
|
270
279
|
meterEvents: {
|
|
271
280
|
title: 'Meter Events',
|
|
272
281
|
},
|
|
282
|
+
overdue: {
|
|
283
|
+
title: 'Pending Consumption',
|
|
284
|
+
pendingAmount: 'Pending Amount',
|
|
285
|
+
eventCount: 'Pending Records',
|
|
286
|
+
noOverdue: 'No pending consumption',
|
|
287
|
+
selectCurrency: 'Currency',
|
|
288
|
+
},
|
|
273
289
|
meter: {
|
|
274
290
|
add: 'Add meter',
|
|
275
291
|
edit: 'Edit meter',
|
|
@@ -482,6 +498,7 @@ export default flat({
|
|
|
482
498
|
},
|
|
483
499
|
meterEvent: {
|
|
484
500
|
title: 'Meter Event Details',
|
|
501
|
+
id: 'Event ID',
|
|
485
502
|
totalEvents: 'Total events: {count}',
|
|
486
503
|
noEvents: 'No events found',
|
|
487
504
|
noEventsHint: 'You can manually add test events in test mode.',
|
|
@@ -492,6 +509,9 @@ export default flat({
|
|
|
492
509
|
subscription: 'Subscription',
|
|
493
510
|
creditConsumed: 'Credit Consumed',
|
|
494
511
|
usageValue: 'Usage Value',
|
|
512
|
+
settlementAmount: 'Settlement Amount',
|
|
513
|
+
reportedAmount: 'Reported Amount',
|
|
514
|
+
overdueAmount: 'Overdue Amount',
|
|
495
515
|
reportedAt: 'Reported At',
|
|
496
516
|
processedAt: 'Processed At',
|
|
497
517
|
eventIdentifier: 'Event Identifier',
|
package/src/locales/zh.tsx
CHANGED
|
@@ -97,6 +97,7 @@ export default flat({
|
|
|
97
97
|
creditGrant: '信用额度',
|
|
98
98
|
date: '日期',
|
|
99
99
|
subscription: '订阅',
|
|
100
|
+
meter: '计量器',
|
|
100
101
|
meterEvent: '计量事件',
|
|
101
102
|
creditAmount: '额度',
|
|
102
103
|
createdAt: '创建时间',
|
|
@@ -180,6 +181,14 @@ export default flat({
|
|
|
180
181
|
transaction: '交易指标',
|
|
181
182
|
essential: '关键指标',
|
|
182
183
|
},
|
|
184
|
+
overdue: {
|
|
185
|
+
title: '欠费额度',
|
|
186
|
+
subtitle: '待支付的额度',
|
|
187
|
+
customers: '{count} 位用户',
|
|
188
|
+
events: '{count} 笔欠费记录',
|
|
189
|
+
viewAll: '查看全部',
|
|
190
|
+
noOverdue: '暂无欠费',
|
|
191
|
+
},
|
|
183
192
|
},
|
|
184
193
|
payments: '支付管理',
|
|
185
194
|
connections: '连接',
|
|
@@ -267,6 +276,13 @@ export default flat({
|
|
|
267
276
|
meterEvents: {
|
|
268
277
|
title: '计量事件',
|
|
269
278
|
},
|
|
279
|
+
overdue: {
|
|
280
|
+
title: '欠费额度',
|
|
281
|
+
pendingAmount: '欠费额度',
|
|
282
|
+
eventCount: '欠费笔数',
|
|
283
|
+
noOverdue: '暂无欠费',
|
|
284
|
+
selectCurrency: '币种',
|
|
285
|
+
},
|
|
270
286
|
meter: {
|
|
271
287
|
add: '添加计量器',
|
|
272
288
|
edit: '编辑计量器',
|
|
@@ -479,6 +495,7 @@ export default flat({
|
|
|
479
495
|
},
|
|
480
496
|
meterEvent: {
|
|
481
497
|
title: '计量事件详情',
|
|
498
|
+
id: '事件ID',
|
|
482
499
|
totalEvents: '总事件数:{count}',
|
|
483
500
|
noEvents: '暂无事件',
|
|
484
501
|
noEventsHint: '您可以在测试模式下手动添加测试事件。',
|
|
@@ -489,6 +506,9 @@ export default flat({
|
|
|
489
506
|
subscription: '订阅',
|
|
490
507
|
creditConsumed: '消耗额度',
|
|
491
508
|
usageValue: '使用量',
|
|
509
|
+
settlementAmount: '结算额度',
|
|
510
|
+
reportedAmount: '上报额度',
|
|
511
|
+
overdueAmount: '欠费额度',
|
|
492
512
|
reportedAt: '上报时间',
|
|
493
513
|
processedAt: '处理时间',
|
|
494
514
|
eventIdentifier: '事件标识符',
|
|
@@ -16,6 +16,8 @@ const pages = {
|
|
|
16
16
|
invoices: React.lazy(() => import('./invoices')),
|
|
17
17
|
subscriptions: React.lazy(() => import('./subscriptions')),
|
|
18
18
|
meters: React.lazy(() => import('./meters')),
|
|
19
|
+
'meter-events': React.lazy(() => import('./meter-events')),
|
|
20
|
+
overdue: React.lazy(() => import('./overdue')),
|
|
19
21
|
};
|
|
20
22
|
|
|
21
23
|
export default function BillingIndex() {
|
|
@@ -52,6 +54,8 @@ export default function BillingIndex() {
|
|
|
52
54
|
{ label: t('admin.invoices'), value: 'invoices' },
|
|
53
55
|
{ label: t('admin.subscriptions'), value: 'subscriptions' },
|
|
54
56
|
{ label: t('admin.meters'), value: 'meters' },
|
|
57
|
+
{ label: t('admin.meterEvents.title'), value: 'meter-events' },
|
|
58
|
+
{ label: t('admin.overdue.title'), value: 'overdue' },
|
|
55
59
|
];
|
|
56
60
|
|
|
57
61
|
let extra = null;
|