payment-kit 1.23.8 → 1.23.10

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.
@@ -43,6 +43,13 @@ export const sequelizeOptionsPoolIdle: number = process.env.SEQUELIZE_OPTIONS_PO
43
43
  export const updateDataConcurrency: number = process.env.UPDATE_DATA_CONCURRENCY
44
44
  ? +process.env.UPDATE_DATA_CONCURRENCY
45
45
  : 5; // 默认并发数为 5
46
+
47
+ // System-level maximum pending amount limit (in token format, e.g., "10")
48
+ // Default is 0 (disabled). Set PAYMENT_KIT_MAX_PENDING_AMOUNT to enable this limit.
49
+ export const systemMaxPendingAmount: number = process.env.PAYMENT_KIT_MAX_PENDING_AMOUNT
50
+ ? +process.env.PAYMENT_KIT_MAX_PENDING_AMOUNT
51
+ : 5;
52
+
46
53
  export default {
47
54
  ...env,
48
55
  };
@@ -7,6 +7,7 @@ import {
7
7
  CreditGrant,
8
8
  Customer,
9
9
  Invoice,
10
+ Meter,
10
11
  PaymentCurrency,
11
12
  PaymentMethod,
12
13
  Price,
@@ -51,6 +52,22 @@ export async function processAutoRecharge(job: AutoRechargeJobData) {
51
52
  return;
52
53
  }
53
54
 
55
+ // Check if the associated meter is inactive
56
+ if (currency.type === 'credit') {
57
+ // Find meter by currency_id (meter.currency_id -> PaymentCurrency) or by metadata.meter_id
58
+ const meter = await Meter.findOne({
59
+ where: { currency_id: currencyId },
60
+ });
61
+ if (meter && meter.status === 'inactive') {
62
+ logger.info('Meter is inactive, skipping auto recharge', {
63
+ customerId,
64
+ currencyId,
65
+ meterId: meter.id,
66
+ });
67
+ return;
68
+ }
69
+ }
70
+
54
71
  // 1. find auto recharge config
55
72
  const config = (await AutoRechargeConfig.findOne({
56
73
  where: {
@@ -302,6 +319,24 @@ export async function checkAndTriggerAutoRecharge(
302
319
  currencyId,
303
320
  currentBalance,
304
321
  });
322
+
323
+ // Check if the associated meter is inactive
324
+ const currency = await PaymentCurrency.findByPk(currencyId);
325
+ if (currency?.type === 'credit') {
326
+ // Find meter by currency_id (meter.currency_id -> PaymentCurrency)
327
+ const meter = await Meter.findOne({
328
+ where: { currency_id: currencyId },
329
+ });
330
+ if (meter && meter.status === 'inactive') {
331
+ logger.info('Meter is inactive, skipping auto recharge check', {
332
+ customerId: customer.id,
333
+ currencyId,
334
+ meterId: meter.id,
335
+ });
336
+ return;
337
+ }
338
+ }
339
+
305
340
  const config = await AutoRechargeConfig.findOne({
306
341
  where: {
307
342
  customer_id: customer.id,
@@ -10,6 +10,7 @@ import {
10
10
  AutoRechargeConfig,
11
11
  Customer,
12
12
  EVMChainType,
13
+ Meter,
13
14
  PaymentCurrency,
14
15
  PaymentMethod,
15
16
  Price,
@@ -339,6 +340,16 @@ router.post('/submit', async (req, res) => {
339
340
  throw new CustomError(400, `Currency not found: ${value.currency_id}`);
340
341
  }
341
342
 
343
+ // Check if the associated meter is active when enabling auto-recharge
344
+ if (configData.enabled && currency.type === 'credit') {
345
+ const meter = await Meter.findOne({
346
+ where: { currency_id: value.currency_id },
347
+ });
348
+ if (meter && meter.status === 'inactive') {
349
+ throw new CustomError(400, 'Cannot enable auto top-up: the associated meter is inactive');
350
+ }
351
+ }
352
+
342
353
  if (currency.recharge_config?.base_price_id && currency.recharge_config?.base_price_id !== value.price_id) {
343
354
  throw new CustomError(400, 'Price is not the base price');
344
355
  }
@@ -1555,8 +1555,6 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
1555
1555
  });
1556
1556
 
1557
1557
  try {
1558
- // eslint-disable-next-line no-console
1559
- console.time('updateUserAddress');
1560
1558
  await blocklet.updateUserAddress(
1561
1559
  {
1562
1560
  did: customer.did,
@@ -1570,8 +1568,6 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
1570
1568
  },
1571
1569
  }
1572
1570
  );
1573
- // eslint-disable-next-line no-console
1574
- console.timeEnd('updateUserAddress');
1575
1571
  logger.info('updateUserAddress success', {
1576
1572
  did: customer.did,
1577
1573
  });
@@ -2,7 +2,7 @@ import { Router } from 'express';
2
2
  import Joi from 'joi';
3
3
  import { BN, fromTokenToUnit } from '@ocap/util';
4
4
 
5
- import { literal, OrderItem } from 'sequelize';
5
+ import { literal, OrderItem, fn, col, Op } from 'sequelize';
6
6
  import pick from 'lodash/pick';
7
7
  import { createListParamSchema, getOrder, getWhereFromKvQuery, MetadataSchema } from '../libs/api';
8
8
  import logger from '../libs/logger';
@@ -25,6 +25,8 @@ import { blocklet } from '../libs/auth';
25
25
  import { formatMetadata } from '../libs/util';
26
26
  import { getPriceUintAmountByCurrency } from '../libs/price';
27
27
  import { checkTokenBalance } from '../libs/payment';
28
+ import { trimDecimals } from '../libs/math-utils';
29
+ import { systemMaxPendingAmount } from '../libs/env';
28
30
 
29
31
  const router = Router();
30
32
  const auth = authenticate<CreditGrant>({ component: true, roles: ['owner', 'admin'] });
@@ -163,10 +165,97 @@ router.get('/summary', authMine, async (req, res) => {
163
165
  }
164
166
  });
165
167
 
168
+ const holdersSchema = Joi.object({
169
+ currency_id: Joi.string().required(),
170
+ page: Joi.number().integer().min(1).default(1),
171
+ pageSize: Joi.number().integer().min(0).optional(), // 0 or undefined = return all
172
+ livemode: Joi.boolean().optional(),
173
+ });
174
+
175
+ // Get all holders (customers with balance) for a specific credit currency
176
+ router.get('/holders', auth, async (req, res) => {
177
+ try {
178
+ const { error, value } = holdersSchema.validate(req.query, { stripUnknown: true });
179
+ if (error) {
180
+ return res.status(400).json({ error: error.message });
181
+ }
182
+
183
+ const { currency_id: currencyId, page, pageSize, livemode } = value;
184
+
185
+ const currency = await PaymentCurrency.findByPk(currencyId);
186
+ if (!currency) {
187
+ return res.status(404).json({ error: `PaymentCurrency ${currencyId} not found` });
188
+ }
189
+
190
+ if (currency.type !== 'credit') {
191
+ return res.status(400).json({ error: 'Currency must be of type credit' });
192
+ }
193
+
194
+ // Build where clause for credit grants
195
+ const grantWhere: any = {
196
+ currency_id: currencyId,
197
+ status: { [Op.in]: ['granted', 'pending'] }, // Only active grants
198
+ };
199
+ if (typeof livemode === 'boolean') {
200
+ grantWhere.livemode = livemode;
201
+ }
202
+
203
+ // Use database aggregation - only customer_id, no JOIN needed
204
+ const aggregatedData = (await CreditGrant.findAll({
205
+ where: grantWhere,
206
+ attributes: [
207
+ 'customer_id',
208
+ [fn('COUNT', col('id')), 'grantCount'],
209
+ [fn('SUM', literal('CAST(remaining_amount AS DECIMAL(40,0))')), 'totalBalance'],
210
+ ],
211
+ group: ['customer_id'], // Only group by customer_id - much faster!
212
+ order: [[literal('totalBalance'), 'DESC']],
213
+ raw: true,
214
+ })) as any[];
215
+
216
+ const totalCount = aggregatedData.length;
217
+
218
+ // Paginate (pageSize = 0 or undefined means return all)
219
+ const shouldPaginate = pageSize && pageSize > 0;
220
+ const paginatedData = shouldPaginate
221
+ ? aggregatedData.slice((page - 1) * pageSize, page * pageSize)
222
+ : aggregatedData;
223
+
224
+ const holders = paginatedData.map((item) => ({
225
+ customer_id: item.customer_id,
226
+ balance: (item.totalBalance || '0').toString(),
227
+ grantCount: parseInt(item.grantCount || '0', 10),
228
+ }));
229
+
230
+ return res.json({
231
+ holders,
232
+ currency: {
233
+ id: currency.id,
234
+ name: currency.name,
235
+ symbol: currency.symbol,
236
+ decimal: currency.decimal,
237
+ },
238
+ paging: {
239
+ page: shouldPaginate ? page : 1,
240
+ pageSize: shouldPaginate ? pageSize : totalCount,
241
+ total: totalCount,
242
+ totalPages: shouldPaginate ? Math.ceil(totalCount / pageSize) : 1,
243
+ },
244
+ });
245
+ } catch (err: any) {
246
+ logger.error('Error getting credit holders', { error: err.message });
247
+ return res.status(400).json({ error: err.message });
248
+ }
249
+ });
250
+
166
251
  const checkAutoRechargeSchema = Joi.object({
167
252
  customer_id: Joi.string().required(),
168
253
  currency_id: Joi.string().required(),
169
254
  pending_amount: Joi.string().optional(),
255
+ max_recharge_times: Joi.number().integer().min(1).max(10).optional().default(3),
256
+ max_pending_amount: Joi.string()
257
+ .pattern(/^\d+(\.\d+)?$/)
258
+ .optional(),
170
259
  });
171
260
 
172
261
  router.get('/verify-availability', authMine, async (req, res) => {
@@ -255,6 +344,7 @@ router.get('/verify-availability', authMine, async (req, res) => {
255
344
  });
256
345
  }
257
346
 
347
+ // totalAmount: payment amount required for one auto recharge (priceAmount * quantity)
258
348
  const totalAmount = new BN(priceAmount).mul(new BN(config.quantity ?? 1));
259
349
 
260
350
  // 4. Get pending amount if not provided
@@ -267,46 +357,45 @@ router.get('/verify-availability', authMine, async (req, res) => {
267
357
  pendingAmount = pendingSummary?.[currencyId] || '0';
268
358
  }
269
359
 
270
- // 5. Check daily limit
271
360
  const pendingAmountBN = new BN(pendingAmount);
272
- const today = new Date().toISOString().split('T')[0];
273
- const isNewDay = config.last_recharge_date !== today;
274
-
275
- // Calculate required recharge times: if pendingAmount > 0, calculate needed times; otherwise check if at least one recharge is possible
276
- const requiredRechargeTimes = pendingAmountBN.gt(new BN(0))
277
- ? pendingAmountBN.add(totalAmount).sub(new BN(1)).div(totalAmount).toNumber()
278
- : 1;
279
-
280
- if (!isNewDay && config.daily_stats && config.daily_limits) {
281
- // Check attempt limit
282
- const maxAttempts = Number(config.daily_limits.max_attempts);
283
- if (maxAttempts > 0) {
284
- const remainingAttempts = maxAttempts - Number(config.daily_stats.attempt_count);
285
- if (requiredRechargeTimes > remainingAttempts) {
286
- return res.json({
287
- can_continue: false,
288
- reason: 'daily_limit_reached',
289
- detail: 'attempt_limit_exceeded',
290
- });
291
- }
361
+
362
+ // 5. Check system-level maximum pending amount limit (highest priority, cannot be bypassed)
363
+ // systemMaxPendingAmount is configured via PAYMENT_KIT_MAX_PENDING_AMOUNT (in token format)
364
+ if (systemMaxPendingAmount > 0 && pendingAmountBN.gt(new BN(0))) {
365
+ const systemMaxPendingAmountBN = fromTokenToUnit(
366
+ trimDecimals(String(systemMaxPendingAmount), currency.decimal),
367
+ currency.decimal
368
+ );
369
+ if (pendingAmountBN.gt(systemMaxPendingAmountBN)) {
370
+ return res.json({
371
+ can_continue: false,
372
+ reason: 'system_pending_limit_exceeded',
373
+ pending_amount: pendingAmount,
374
+ system_max_pending_amount: systemMaxPendingAmountBN.toString(),
375
+ detail: 'Pending amount exceeds system maximum limit configured in Payment Kit',
376
+ });
292
377
  }
378
+ }
293
379
 
294
- // Check amount limit
295
- const maxAmount = new BN(config.daily_limits.max_amount || '0');
296
- if (maxAmount.gt(new BN(0))) {
297
- const requiredTotalAmount = totalAmount.mul(new BN(requiredRechargeTimes));
298
- const remainingAmount = maxAmount.sub(new BN(config.daily_stats.total_amount || '0'));
299
- if (requiredTotalAmount.gt(remainingAmount)) {
300
- return res.json({
301
- can_continue: false,
302
- reason: 'daily_limit_reached',
303
- detail: 'amount_limit_exceeded',
304
- });
305
- }
380
+ // 6. Check caller-specified max pending amount limit (if provided)
381
+ // Note: max_pending_amount is in token format (e.g., "100"), need to convert to unit format for comparison
382
+ if (value.max_pending_amount && pendingAmountBN.gt(new BN(0))) {
383
+ const maxPendingAmountBN = fromTokenToUnit(
384
+ trimDecimals(value.max_pending_amount, currency.decimal),
385
+ currency.decimal
386
+ );
387
+ if (pendingAmountBN.gt(maxPendingAmountBN)) {
388
+ return res.json({
389
+ can_continue: false,
390
+ reason: 'pending_amount_exceeds_limit',
391
+ pending_amount: pendingAmount,
392
+ max_pending_amount: maxPendingAmountBN.toString(),
393
+ detail: 'Current pending amount exceeds the maximum allowed limit',
394
+ });
306
395
  }
307
396
  }
308
397
 
309
- // 6. Check payment account balance
398
+ // 7. Get payer
310
399
  const payer =
311
400
  config.payment_settings?.payment_method_options?.[
312
401
  config.paymentMethod.type as keyof typeof config.payment_settings.payment_method_options
@@ -319,29 +408,130 @@ router.get('/verify-availability', authMine, async (req, res) => {
319
408
  });
320
409
  }
321
410
 
322
- // Check token balance: if pendingAmount > 0, check if balance can cover pending; otherwise check if balance can cover at least one recharge
323
- const amountToCheck = pendingAmountBN.gt(new BN(0)) ? pendingAmount : totalAmount.toString();
324
- const balanceResult = await checkTokenBalance({
325
- paymentMethod: config.paymentMethod,
326
- paymentCurrency: config.rechargeCurrency,
327
- userDid: payer,
328
- amount: amountToCheck,
329
- skipUserCheck: true,
330
- });
411
+ let balanceResult: { sufficient: boolean; token?: { balance: string } } | null = null;
412
+ // 8. If user has pending amount, check if they can pay it off
413
+ if (pendingAmountBN.gt(new BN(0))) {
414
+ // Get credit amount per recharge from price metadata
415
+ const creditConfig = config.price.metadata?.credit_config;
416
+ if (!creditConfig || !creditConfig.credit_amount) {
417
+ return res.json({
418
+ can_continue: false,
419
+ reason: 'credit_config_not_found',
420
+ });
421
+ }
331
422
 
332
- if (!balanceResult.sufficient) {
333
- return res.json({
334
- can_continue: false,
335
- reason: 'insufficient_balance',
336
- payment_account_balance: balanceResult.token?.balance || '0',
337
- pending_amount: pendingAmount,
423
+ // Convert credit_amount from token to unit, then multiply by quantity
424
+ // creditAmountPerRecharge: credits amount obtained from one auto recharge
425
+ const creditAmountPerUnit = fromTokenToUnit(creditConfig.credit_amount, currency.decimal);
426
+ const creditAmountPerRecharge = new BN(creditAmountPerUnit).mul(new BN(config.quantity ?? 1));
427
+
428
+ // Calculate how many recharges are needed to pay off the pending amount
429
+ // Formula: ceil(pendingAmount / creditAmountPerRecharge)
430
+ const requiredRechargeTimesBN = pendingAmountBN
431
+ .add(creditAmountPerRecharge)
432
+ .sub(new BN(1))
433
+ .div(creditAmountPerRecharge);
434
+
435
+ // Check if required recharge times exceeds limit
436
+ const maxRechargeTimes = value.max_recharge_times;
437
+ if (requiredRechargeTimesBN.gt(new BN(maxRechargeTimes))) {
438
+ return res.json({
439
+ can_continue: false,
440
+ reason: 'too_many_recharges_required',
441
+ pending_amount: pendingAmount,
442
+ required_recharge_times: requiredRechargeTimesBN.toString(),
443
+ max_allowed_times: maxRechargeTimes,
444
+ });
445
+ }
446
+
447
+ // Calculate required payment amount to pay off pending
448
+ // requiredPaymentAmount = totalAmount (one recharge cost) * requiredRechargeTimes
449
+ const requiredPaymentAmount = totalAmount.mul(requiredRechargeTimesBN);
450
+
451
+ // Check daily limit
452
+ const today = new Date().toISOString().split('T')[0];
453
+ const isNewDay = config.last_recharge_date !== today;
454
+
455
+ if (!isNewDay && config.daily_stats && config.daily_limits) {
456
+ const { max_attempts: maxAttemptsRaw, max_amount: maxAmountRaw } = config.daily_limits;
457
+ const { attempt_count: attemptCount, total_amount: totalAmountStats } = config.daily_stats;
458
+
459
+ // Check attempt limit
460
+ const maxAttempts = Number(maxAttemptsRaw);
461
+ if (maxAttempts > 0) {
462
+ // Safe to convert to number since requiredRechargeTimesBN is already checked to be <= 3
463
+ const requiredRechargeTimes = requiredRechargeTimesBN.toNumber();
464
+ const remainingAttempts = maxAttempts - Number(attemptCount);
465
+ if (requiredRechargeTimes > remainingAttempts) {
466
+ return res.json({
467
+ can_continue: false,
468
+ reason: 'daily_limit_reached',
469
+ detail: 'attempt_limit_exceeded',
470
+ });
471
+ }
472
+ }
473
+
474
+ // Check amount limit
475
+ const maxAmount = new BN(maxAmountRaw || '0');
476
+ if (maxAmount.gt(new BN(0))) {
477
+ const remainingAmount = maxAmount.sub(new BN(totalAmountStats || '0'));
478
+ if (requiredPaymentAmount.gt(remainingAmount)) {
479
+ return res.json({
480
+ can_continue: false,
481
+ reason: 'daily_limit_reached',
482
+ detail: 'amount_limit_exceeded',
483
+ });
484
+ }
485
+ }
486
+ }
487
+
488
+ // Check payment account balance: balance must be sufficient to pay off pending AND cover at least one more recharge
489
+ // This ensures user can pay off pending amount and still have balance for continued usage
490
+ // minimumRequiredBalance = requiredPaymentAmount (to pay off pending) + totalAmount (for one more recharge)
491
+ const minimumRequiredBalance = requiredPaymentAmount.add(totalAmount);
492
+ balanceResult = await checkTokenBalance({
493
+ paymentMethod: config.paymentMethod,
494
+ paymentCurrency: config.rechargeCurrency,
495
+ userDid: payer,
496
+ amount: minimumRequiredBalance.toString(),
497
+ skipUserCheck: true,
498
+ });
499
+
500
+ if (!balanceResult.sufficient) {
501
+ return res.json({
502
+ can_continue: false,
503
+ reason: 'insufficient_balance',
504
+ payment_account_balance: balanceResult.token?.balance || '0',
505
+ pending_amount: pendingAmount,
506
+ required_amount: minimumRequiredBalance.toString(),
507
+ detail: 'balance_must_cover_pending_plus_one_recharge',
508
+ });
509
+ }
510
+ } else {
511
+ // No pending amount: check if balance can cover at least one recharge
512
+ balanceResult = await checkTokenBalance({
513
+ paymentMethod: config.paymentMethod,
514
+ paymentCurrency: config.rechargeCurrency,
515
+ userDid: payer,
516
+ amount: totalAmount.toString(),
517
+ skipUserCheck: true,
338
518
  });
519
+
520
+ if (!balanceResult.sufficient) {
521
+ return res.json({
522
+ can_continue: false,
523
+ reason: 'insufficient_balance',
524
+ payment_account_balance: balanceResult.token?.balance || '0',
525
+ pending_amount: pendingAmount,
526
+ required_amount: totalAmount.toString(),
527
+ });
528
+ }
339
529
  }
340
530
 
341
531
  return res.json({
342
532
  can_continue: true,
343
533
  payment_account_sufficient: true,
344
- payment_account_balance: balanceResult.token?.balance || '0',
534
+ payment_account_balance: balanceResult?.token?.balance || '0',
345
535
  pending_amount: pendingAmount,
346
536
  });
347
537
  } catch (err: any) {
@@ -59,12 +59,14 @@ const listSchema = createListParamSchema<{
59
59
  customer_id?: string;
60
60
  start?: number;
61
61
  end?: number;
62
+ status?: string;
62
63
  }>({
63
64
  event_name: Joi.string().empty(''),
64
65
  meter_id: Joi.string().empty(''),
65
66
  customer_id: Joi.string().empty(''),
66
67
  start: Joi.number().integer().optional(),
67
68
  end: Joi.number().integer().optional(),
69
+ status: Joi.string().empty(''),
68
70
  });
69
71
 
70
72
  const statsSchema = Joi.object({
@@ -111,6 +113,15 @@ router.get('/', authMine, async (req, res) => {
111
113
  }
112
114
  }
113
115
 
116
+ if (query.status) {
117
+ where.status = {
118
+ [Op.in]: query.status
119
+ ?.split(',')
120
+ .map((x) => x.trim())
121
+ .filter(Boolean),
122
+ };
123
+ }
124
+
114
125
  const { rows: list, count } = await MeterEvent.findAndCountAll({
115
126
  where,
116
127
  order: getOrder(query, [['created_at', query.o === 'asc' ? 'ASC' : 'DESC']]),
@@ -335,11 +346,7 @@ router.get('/pending-amount', authMine, async (req, res) => {
335
346
  }
336
347
  params.customerId = customer.id;
337
348
  }
338
- // eslint-disable-next-line no-console
339
- console.time('pending-amount: getPendingAmounts');
340
349
  const [summary] = await MeterEvent.getPendingAmounts(params);
341
- // eslint-disable-next-line no-console
342
- console.timeEnd('pending-amount: getPendingAmounts');
343
350
  return res.json(summary);
344
351
  } catch (err) {
345
352
  logger.error('Error getting meter event pending amount', err);