payment-kit 1.24.2 → 1.24.4
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 +734 -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 +140 -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 +172 -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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Op } from 'sequelize';
|
|
2
|
+
import debounce from 'lodash/debounce';
|
|
2
3
|
/* eslint-disable @typescript-eslint/indent */
|
|
3
4
|
import { events } from '../libs/event';
|
|
4
5
|
import logger from '../libs/logger';
|
|
@@ -102,6 +103,10 @@ import {
|
|
|
102
103
|
CustomerAutoRechargeFailedEmailTemplate,
|
|
103
104
|
CustomerAutoRechargeFailedEmailTemplateOptions,
|
|
104
105
|
} from '../libs/notification/template/customer-auto-recharge-failed';
|
|
106
|
+
import {
|
|
107
|
+
CustomerAutoRechargeDailyLimitExceededEmailTemplate,
|
|
108
|
+
CustomerAutoRechargeDailyLimitExceededEmailTemplateOptions,
|
|
109
|
+
} from '../libs/notification/template/customer-auto-recharge-daily-limit-exceeded';
|
|
105
110
|
import {
|
|
106
111
|
CustomerRevenueSucceededEmailTemplate,
|
|
107
112
|
CustomerRevenueSucceededEmailTemplateOptions,
|
|
@@ -139,6 +144,7 @@ export type NotificationQueueJobType =
|
|
|
139
144
|
| 'customer.credit_grant.granted'
|
|
140
145
|
| 'customer.credit.low_balance'
|
|
141
146
|
| 'customer.auto_recharge.failed'
|
|
147
|
+
| 'customer.auto_recharge.daily_limit_exceeded'
|
|
142
148
|
| 'webhook.endpoint.failed';
|
|
143
149
|
|
|
144
150
|
export type NotificationQueueJob = {
|
|
@@ -285,6 +291,12 @@ async function getNotificationTemplate(job: NotificationQueueJob): Promise<BaseE
|
|
|
285
291
|
return new CustomerAutoRechargeFailedEmailTemplate(job.options as CustomerAutoRechargeFailedEmailTemplateOptions);
|
|
286
292
|
}
|
|
287
293
|
|
|
294
|
+
if (job.type === 'customer.auto_recharge.daily_limit_exceeded') {
|
|
295
|
+
return new CustomerAutoRechargeDailyLimitExceededEmailTemplate(
|
|
296
|
+
job.options as CustomerAutoRechargeDailyLimitExceededEmailTemplateOptions
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
288
300
|
if (job.type === 'webhook.endpoint.failed') {
|
|
289
301
|
return new WebhookEndpointFailedEmailTemplate(job.options as WebhookEndpointFailedEmailTemplateOptions);
|
|
290
302
|
}
|
|
@@ -296,6 +308,18 @@ async function handleNotificationJob(job: NotificationQueueJob): Promise<void> {
|
|
|
296
308
|
try {
|
|
297
309
|
const template = await getNotificationTemplate(job);
|
|
298
310
|
|
|
311
|
+
// Check if template class has a preflightCheck
|
|
312
|
+
const TemplateClass = template.constructor as any;
|
|
313
|
+
if (TemplateClass.preflightCheck) {
|
|
314
|
+
const shouldSend = await TemplateClass.preflightCheck(job.options);
|
|
315
|
+
if (!shouldSend) {
|
|
316
|
+
logger.info('handleNotificationJob.skipped: preflight check returned false', {
|
|
317
|
+
type: job.type,
|
|
318
|
+
});
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
299
323
|
await new Notification(template, job.type).send();
|
|
300
324
|
logger.info('handleImmediateNotificationJob.success', { job });
|
|
301
325
|
} catch (error) {
|
|
@@ -314,6 +338,8 @@ interface NotificationItem<T = Record<string, any>> {
|
|
|
314
338
|
|
|
315
339
|
// 内存缓存,记录最近发送的通知
|
|
316
340
|
const notificationCache = new Map<string, number>();
|
|
341
|
+
// 去抖函数(按 key 去重)
|
|
342
|
+
const debounceHandlers = new Map<string, ReturnType<typeof debounce>>();
|
|
317
343
|
|
|
318
344
|
// 清理过期缓存的定时器
|
|
319
345
|
setInterval(
|
|
@@ -329,6 +355,19 @@ setInterval(
|
|
|
329
355
|
60 * 60 * 1000
|
|
330
356
|
); // 每小时清理一次
|
|
331
357
|
|
|
358
|
+
/**
|
|
359
|
+
* Clear notification cache entries that match given prefix
|
|
360
|
+
* Used to reset rate limiting when conditions change (e.g., credit recharged)
|
|
361
|
+
*/
|
|
362
|
+
function clearNotificationCache(prefix: string) {
|
|
363
|
+
for (const [key] of notificationCache.entries()) {
|
|
364
|
+
if (key.startsWith(prefix)) {
|
|
365
|
+
notificationCache.delete(key);
|
|
366
|
+
logger.info('Notification cache cleared', { key });
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
332
371
|
/**
|
|
333
372
|
* Handles immediate notifications by pushing them directly to the notification queue
|
|
334
373
|
*/
|
|
@@ -624,7 +663,7 @@ export async function startNotificationQueue() {
|
|
|
624
663
|
});
|
|
625
664
|
|
|
626
665
|
events.on('customer.credit.low_balance', (customer: Customer, { metadata }: { metadata: any }) => {
|
|
627
|
-
|
|
666
|
+
addNotificationJobWithDelay(
|
|
628
667
|
'customer.credit.low_balance',
|
|
629
668
|
{
|
|
630
669
|
customerId: customer.id,
|
|
@@ -678,6 +717,54 @@ export async function startNotificationQueue() {
|
|
|
678
717
|
}
|
|
679
718
|
}
|
|
680
719
|
);
|
|
720
|
+
|
|
721
|
+
events.on(
|
|
722
|
+
'customer.auto_recharge.daily_limit_exceeded',
|
|
723
|
+
(customer: Customer, { autoRechargeConfigId, currencyId, currentBalance }) => {
|
|
724
|
+
logger.info('addNotificationJob:customer.auto_recharge.daily_limit_exceeded', {
|
|
725
|
+
customerId: customer.id,
|
|
726
|
+
autoRechargeConfigId,
|
|
727
|
+
currencyId,
|
|
728
|
+
});
|
|
729
|
+
addNotificationJob(
|
|
730
|
+
'customer.auto_recharge.daily_limit_exceeded',
|
|
731
|
+
{
|
|
732
|
+
customerId: customer.id,
|
|
733
|
+
autoRechargeConfigId,
|
|
734
|
+
currencyId,
|
|
735
|
+
currentBalance,
|
|
736
|
+
},
|
|
737
|
+
[customer.id, autoRechargeConfigId, currencyId],
|
|
738
|
+
true, // prevent duplicate
|
|
739
|
+
24 * 3600 // 24 hours
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
);
|
|
743
|
+
|
|
744
|
+
// Clear credit notification cache when customer recharges
|
|
745
|
+
// This allows customer to receive insufficient/low_balance notifications again
|
|
746
|
+
events.on('customer.credit_grant.granted', (creditGrant: CreditGrant) => {
|
|
747
|
+
const cacheKeyBase = `${creditGrant.customer_id}.${creditGrant.currency_id}`;
|
|
748
|
+
const debounceKey = `credit_grant_clear.${cacheKeyBase}`;
|
|
749
|
+
const delayMs = 5 * 60 * 1000;
|
|
750
|
+
|
|
751
|
+
let handler = debounceHandlers.get(debounceKey);
|
|
752
|
+
if (!handler) {
|
|
753
|
+
handler = debounce(() => {
|
|
754
|
+
clearNotificationCache(`customer.credit.insufficient.${cacheKeyBase}`);
|
|
755
|
+
clearNotificationCache(`customer.credit.low_balance.${cacheKeyBase}`);
|
|
756
|
+
debounceHandlers.delete(debounceKey);
|
|
757
|
+
logger.info('Notification cache cleared after delay', {
|
|
758
|
+
customerId: creditGrant.customer_id,
|
|
759
|
+
currencyId: creditGrant.currency_id,
|
|
760
|
+
delayMs,
|
|
761
|
+
});
|
|
762
|
+
}, delayMs);
|
|
763
|
+
debounceHandlers.set(debounceKey, handler);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
handler();
|
|
767
|
+
});
|
|
681
768
|
}
|
|
682
769
|
|
|
683
770
|
export async function handleNotificationPreferenceChange(
|
|
@@ -812,3 +899,55 @@ export function addNotificationJob(
|
|
|
812
899
|
},
|
|
813
900
|
});
|
|
814
901
|
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* Add a notification job with delay support
|
|
905
|
+
* Use this when the template class has a static `delay` property
|
|
906
|
+
*/
|
|
907
|
+
export async function addNotificationJobWithDelay(
|
|
908
|
+
type: NotificationQueueJobType,
|
|
909
|
+
options: NotificationQueueJobOptions,
|
|
910
|
+
extraIds: string[] = [],
|
|
911
|
+
preventDuplicate: boolean = false,
|
|
912
|
+
duplicateWindow: number = 600
|
|
913
|
+
) {
|
|
914
|
+
const idParts = [type];
|
|
915
|
+
|
|
916
|
+
if (extraIds.length) {
|
|
917
|
+
idParts.push(...extraIds);
|
|
918
|
+
} else {
|
|
919
|
+
idParts.push(Date.now().toString());
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
if (preventDuplicate) {
|
|
923
|
+
const cacheKey = `${type}.${extraIds.join('.')}`;
|
|
924
|
+
const lastSent = notificationCache.get(cacheKey);
|
|
925
|
+
const now = Date.now();
|
|
926
|
+
|
|
927
|
+
if (lastSent && now - lastSent < duplicateWindow * 1000) {
|
|
928
|
+
logger.info('Notification skipped due to duplicate prevention', {
|
|
929
|
+
type,
|
|
930
|
+
cacheKey,
|
|
931
|
+
lastSent: new Date(lastSent),
|
|
932
|
+
duplicateWindow,
|
|
933
|
+
});
|
|
934
|
+
return Promise.resolve();
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
notificationCache.set(cacheKey, now);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// Get delay from template class
|
|
941
|
+
const template = await getNotificationTemplate({ type, options });
|
|
942
|
+
const TemplateClass = template.constructor as any;
|
|
943
|
+
const { delay } = TemplateClass;
|
|
944
|
+
|
|
945
|
+
return notificationQueue.push({
|
|
946
|
+
id: idParts.join('.'),
|
|
947
|
+
job: {
|
|
948
|
+
type,
|
|
949
|
+
options,
|
|
950
|
+
},
|
|
951
|
+
...(delay && delay > 0 ? { delay } : {}),
|
|
952
|
+
});
|
|
953
|
+
}
|
|
@@ -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,178 @@ 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
|
+
AND m.status = 'active'
|
|
376
|
+
GROUP BY m.currency_id
|
|
377
|
+
HAVING total_pending > 0
|
|
378
|
+
ORDER BY total_pending DESC`,
|
|
379
|
+
{
|
|
380
|
+
replacements: { livemode: req.livemode ? 1 : 0 },
|
|
381
|
+
type: QueryTypes.SELECT,
|
|
382
|
+
}
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
// Fetch currency details
|
|
386
|
+
const currencyIds = results.map((r) => r.currency_id);
|
|
387
|
+
const currencies =
|
|
388
|
+
currencyIds.length > 0 ? await PaymentCurrency.findAll({ where: { id: { [Op.in]: currencyIds } } }) : [];
|
|
389
|
+
const currencyMap = new Map(currencies.map((c) => [c.id, c]));
|
|
390
|
+
|
|
391
|
+
const list = results.map((r) => ({
|
|
392
|
+
currency: currencyMap.get(r.currency_id),
|
|
393
|
+
total_pending: r.total_pending,
|
|
394
|
+
customer_count: r.customer_count,
|
|
395
|
+
event_count: r.event_count,
|
|
396
|
+
}));
|
|
397
|
+
|
|
398
|
+
return res.json({ list });
|
|
399
|
+
} catch (err) {
|
|
400
|
+
logger.error('Error getting overdue summary', err);
|
|
401
|
+
return res.status(400).json({ error: err?.message });
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
// Get all customers with credit overdue (action-required amount > 0), grouped by customer + currency
|
|
406
|
+
router.get('/overdue-customers', auth, async (req, res) => {
|
|
407
|
+
try {
|
|
408
|
+
const page = Math.max(1, parseInt(String(req.query.page || '1'), 10));
|
|
409
|
+
const pageSize = Math.min(100, Math.max(1, parseInt(String(req.query.pageSize || '20'), 10)));
|
|
410
|
+
const offset = (page - 1) * pageSize;
|
|
411
|
+
const currencyId = req.query.currency_id as string | undefined;
|
|
412
|
+
const searchQuery = req.query.q as string | undefined;
|
|
413
|
+
|
|
414
|
+
// If search query provided, first find matching customer IDs
|
|
415
|
+
let customerFilter = '';
|
|
416
|
+
const replacements: any = { livemode: req.livemode ? 1 : 0, limit: pageSize, offset };
|
|
417
|
+
const countReplacements: any = { livemode: req.livemode ? 1 : 0 };
|
|
418
|
+
|
|
419
|
+
// Build currency filter
|
|
420
|
+
let currencyFilter = '';
|
|
421
|
+
if (currencyId) {
|
|
422
|
+
currencyFilter = 'AND m.currency_id = :currencyId';
|
|
423
|
+
replacements.currencyId = currencyId;
|
|
424
|
+
countReplacements.currencyId = currencyId;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (searchQuery) {
|
|
428
|
+
// Escape LIKE wildcards to prevent unintended matches
|
|
429
|
+
const escapedQuery = searchQuery.replace(/[%_]/g, '\\$&');
|
|
430
|
+
const searchCustomers = await Customer.findAll({
|
|
431
|
+
where: {
|
|
432
|
+
[Op.or]: [
|
|
433
|
+
{ name: { [Op.like]: `%${escapedQuery}%` } },
|
|
434
|
+
{ email: { [Op.like]: `%${escapedQuery}%` } },
|
|
435
|
+
{ did: { [Op.like]: `%${escapedQuery}%` } },
|
|
436
|
+
],
|
|
437
|
+
},
|
|
438
|
+
attributes: ['id'],
|
|
439
|
+
});
|
|
440
|
+
const searchCustomerIds = searchCustomers.map((c) => c.id);
|
|
441
|
+
if (searchCustomerIds.length === 0) {
|
|
442
|
+
return res.json({ list: [], count: 0, paging: { page, pageSize } });
|
|
443
|
+
}
|
|
444
|
+
// Generate parameterized placeholders for customer IDs
|
|
445
|
+
const placeholders = searchCustomerIds.map((_, i) => `:searchId${i}`);
|
|
446
|
+
customerFilter = `AND json_extract(me.payload, '$.customer_id') IN (${placeholders.join(',')})`;
|
|
447
|
+
searchCustomerIds.forEach((id, i) => {
|
|
448
|
+
replacements[`searchId${i}`] = id;
|
|
449
|
+
countReplacements[`searchId${i}`] = id;
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Query to aggregate credit_pending by customer + currency, order by event_count DESC
|
|
454
|
+
const results = await MeterEvent.sequelize!.query<{
|
|
455
|
+
customer_id: string;
|
|
456
|
+
currency_id: string;
|
|
457
|
+
total_pending: string;
|
|
458
|
+
event_count: number;
|
|
459
|
+
}>(
|
|
460
|
+
`SELECT
|
|
461
|
+
json_extract(me.payload, '$.customer_id') as customer_id,
|
|
462
|
+
m.currency_id as currency_id,
|
|
463
|
+
SUM(CAST(me.credit_pending AS DECIMAL(40,0))) as total_pending,
|
|
464
|
+
COUNT(*) as event_count
|
|
465
|
+
FROM meter_events me
|
|
466
|
+
JOIN meters m ON me.event_name = m.event_name
|
|
467
|
+
WHERE me.livemode = :livemode
|
|
468
|
+
AND me.status IN ('requires_capture', 'requires_action')
|
|
469
|
+
AND m.status = 'active'
|
|
470
|
+
${currencyFilter}
|
|
471
|
+
${customerFilter}
|
|
472
|
+
GROUP BY json_extract(me.payload, '$.customer_id'), m.currency_id
|
|
473
|
+
HAVING total_pending > 0
|
|
474
|
+
ORDER BY event_count DESC
|
|
475
|
+
LIMIT :limit OFFSET :offset`,
|
|
476
|
+
{
|
|
477
|
+
replacements,
|
|
478
|
+
type: QueryTypes.SELECT,
|
|
479
|
+
}
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
// Get total count
|
|
483
|
+
const countResult = await MeterEvent.sequelize!.query<{ count: number }>(
|
|
484
|
+
`SELECT COUNT(*) as count FROM (
|
|
485
|
+
SELECT json_extract(me.payload, '$.customer_id') as customer_id, m.currency_id
|
|
486
|
+
FROM meter_events me
|
|
487
|
+
JOIN meters m ON me.event_name = m.event_name
|
|
488
|
+
WHERE me.livemode = :livemode
|
|
489
|
+
AND me.status IN ('requires_capture', 'requires_action')
|
|
490
|
+
AND m.status = 'active'
|
|
491
|
+
${currencyFilter}
|
|
492
|
+
${customerFilter}
|
|
493
|
+
GROUP BY json_extract(me.payload, '$.customer_id'), m.currency_id
|
|
494
|
+
HAVING SUM(CAST(me.credit_pending AS DECIMAL(40,0))) > 0
|
|
495
|
+
)`,
|
|
496
|
+
{
|
|
497
|
+
replacements: countReplacements,
|
|
498
|
+
type: QueryTypes.SELECT,
|
|
499
|
+
}
|
|
500
|
+
);
|
|
501
|
+
|
|
502
|
+
const count = countResult[0]?.count || 0;
|
|
503
|
+
const customerIds = [...new Set(results.map((r) => r.customer_id))];
|
|
504
|
+
const currencyIds = [...new Set(results.map((r) => r.currency_id))];
|
|
505
|
+
|
|
506
|
+
// Fetch customer and currency details
|
|
507
|
+
const customers = customerIds.length > 0 ? await Customer.findAll({ where: { id: { [Op.in]: customerIds } } }) : [];
|
|
508
|
+
const customerMap = new Map(customers.map((c) => [c.id, c]));
|
|
509
|
+
|
|
510
|
+
const currencies =
|
|
511
|
+
currencyIds.length > 0 ? await PaymentCurrency.findAll({ where: { id: { [Op.in]: currencyIds } } }) : [];
|
|
512
|
+
const currencyMap = new Map(currencies.map((c) => [c.id, c]));
|
|
513
|
+
|
|
514
|
+
const list = results.map((r) => ({
|
|
515
|
+
customer: customerMap.get(r.customer_id),
|
|
516
|
+
currency: currencyMap.get(r.currency_id),
|
|
517
|
+
total_pending: r.total_pending,
|
|
518
|
+
event_count: r.event_count,
|
|
519
|
+
}));
|
|
520
|
+
|
|
521
|
+
return res.json({ list, count, paging: { page, pageSize } });
|
|
522
|
+
} catch (err) {
|
|
523
|
+
logger.error('Error getting overdue customers', err);
|
|
524
|
+
return res.status(400).json({ error: err?.message });
|
|
525
|
+
}
|
|
526
|
+
});
|
|
355
527
|
router.get('/:id', authMine, async (req, res) => {
|
|
356
528
|
try {
|
|
357
529
|
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.4",
|
|
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.4",
|
|
63
|
+
"@blocklet/payment-react": "1.24.4",
|
|
64
|
+
"@blocklet/payment-vendor": "1.24.4",
|
|
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.4",
|
|
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": "d4a5f67e657cafa8862912bb8de38a3d56a7919d"
|
|
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',
|