payment-kit 1.23.9 → 1.23.11

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.
@@ -1121,20 +1121,27 @@ export async function retryUncollectibleInvoices(options: {
1121
1121
  failed: [] as Array<{ id: string; reason: string }>,
1122
1122
  };
1123
1123
 
1124
+ const now = dayjs().unix();
1125
+ const BATCH_SIZE = 10;
1126
+ const BATCH_DELAY_SECONDS = 0.1;
1127
+
1124
1128
  const settledResults = await Promise.allSettled(
1125
1129
  overdueInvoices.map(async (invoice, index) => {
1126
1130
  const { paymentIntent } = invoice;
1127
- const delay = index * 2;
1128
1131
  if (!paymentIntent) {
1129
1132
  throw new Error('No payment intent found');
1130
1133
  }
1131
1134
 
1132
1135
  await paymentIntent.update({ status: 'requires_capture' });
1136
+
1137
+ const batchIndex = Math.floor(index / BATCH_SIZE);
1138
+ const runAt = now + batchIndex * BATCH_DELAY_SECONDS;
1139
+
1133
1140
  await emitAsync(
1134
1141
  'payment.queued',
1135
1142
  paymentIntent.id,
1136
1143
  { paymentIntentId: paymentIntent.id, retryOnError: true, ignoreMaxRetryCheck: true },
1137
- { sync: false, delay }
1144
+ { sync: false, runAt }
1138
1145
  );
1139
1146
 
1140
1147
  return invoice;
@@ -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,
@@ -2,6 +2,7 @@ import { BN, fromUnitToToken } from '@ocap/util';
2
2
 
3
3
  import { getLock } from '../libs/lock';
4
4
  import logger from '../libs/logger';
5
+ import dayjs from '../libs/dayjs';
5
6
  import createQueue from '../libs/queue';
6
7
  import { createEvent } from '../libs/audit';
7
8
  import {
@@ -551,7 +552,7 @@ export async function handleCreditConsumption(job: CreditConsumptionJob) {
551
552
  });
552
553
  await context.meterEvent.markAsCompleted();
553
554
  if (context.subscription && context.subscription.status === 'past_due') {
554
- handlePastDueSubscriptionRecovery(context.subscription, null);
555
+ await handlePastDueSubscriptionRecovery(context.subscription, null);
555
556
  }
556
557
  } else {
557
558
  logger.warn('Credit consumption partially completed - insufficient balance', {
@@ -622,6 +623,7 @@ export const creditQueue = createQueue<CreditConsumptionJob>({
622
623
  options: {
623
624
  concurrency: 1,
624
625
  maxRetries: 0,
626
+ enableScheduledJob: true,
625
627
  },
626
628
  });
627
629
 
@@ -755,58 +757,128 @@ events.on('customer.credit_grant.granted', async (creditGrant: CreditGrant) => {
755
757
  });
756
758
 
757
759
  async function retryFailedEventsForCustomer(creditGrant: CreditGrant): Promise<void> {
760
+ const grant = await CreditGrant.findByPk(creditGrant.id);
761
+ if (!grant) {
762
+ logger.error('Credit grant not found', {
763
+ creditGrantId: creditGrant.id,
764
+ });
765
+ return;
766
+ }
767
+
768
+ const customerId = grant.customer_id;
769
+ const currencyId = grant.currency_id;
770
+ const lock = getLock(`retry-failed-events-${customerId}-${currencyId}`);
771
+
758
772
  try {
773
+ await lock.acquire();
774
+
759
775
  logger.info('Retrying failed events for customer', {
760
- customerId: creditGrant.customer_id,
761
- currencyId: creditGrant.currency_id,
762
- livemode: creditGrant.livemode,
776
+ customerId,
777
+ currencyId,
778
+ livemode: grant.livemode,
779
+ grantAmount: grant.amount,
780
+ grantRemaining: grant.remaining_amount,
763
781
  });
782
+
783
+ const availableGrants = await CreditGrant.getAvailableCreditsForCustomer(customerId, currencyId);
784
+ const totalAvailableCredit = availableGrants.reduce((sum, g) => sum.add(new BN(g.remaining_amount)), new BN(0));
785
+
764
786
  const [, , failedEvents] = await MeterEvent.getPendingAmounts({
765
- customerId: creditGrant.customer_id,
766
- currencyId: creditGrant.currency_id,
787
+ customerId,
788
+ currencyId,
767
789
  status: ['requires_action', 'requires_capture'],
768
- livemode: creditGrant.livemode,
790
+ livemode: grant.livemode,
769
791
  });
770
792
 
771
793
  if (failedEvents.length === 0) {
772
794
  logger.debug('No failed events with pending credit found', {
773
- customerId: creditGrant.customer_id,
774
- currencyId: creditGrant.currency_id,
795
+ customerId,
796
+ currencyId,
775
797
  });
776
798
  return;
777
799
  }
778
800
 
779
- logger.info('Updating failed events status after credit grant', {
780
- customerId: creditGrant.customer_id,
781
- currencyId: creditGrant.currency_id,
782
- eventCount: failedEvents.length,
801
+ if (totalAvailableCredit.lte(new BN(0))) {
802
+ logger.debug('No available credit to retry failed events', {
803
+ customerId,
804
+ currencyId,
805
+ totalAvailableCredit: totalAvailableCredit.toString(),
806
+ });
807
+ return;
808
+ }
809
+
810
+ const sortedEvents = failedEvents.sort((a, b) => {
811
+ const aPending = new BN(a.credit_pending);
812
+ const bPending = new BN(b.credit_pending);
813
+ return aPending.cmp(bPending);
783
814
  });
784
815
 
785
- await Promise.all(
786
- failedEvents.map((event) =>
787
- event.update({
788
- status: 'pending',
789
- attempt_count: 0,
790
- next_attempt: undefined,
791
- })
792
- )
793
- );
816
+ let cumulativePending = new BN(0);
817
+ const affordableEvents: MeterEvent[] = [];
818
+ const now = dayjs().unix();
819
+ const BATCH_SIZE = 10;
820
+ const BATCH_DELAY_SECONDS = 0.1;
794
821
 
795
- failedEvents.forEach((event, index) => {
796
- const delay = index * 1000;
797
- addCreditConsumptionJob(event.id, true, { delay });
798
- });
822
+ for (let i = 0; i < sortedEvents.length; i++) {
823
+ const event = sortedEvents[i];
824
+ if (!event) {
825
+ break;
826
+ }
799
827
 
800
- logger.info('Successfully updated failed events status', {
801
- customerId: creditGrant.customer_id,
802
- currencyId: creditGrant.currency_id,
803
- eventCount: failedEvents.length,
828
+ const eventPending = new BN(event.credit_pending);
829
+ const newCumulative = cumulativePending.add(eventPending);
830
+
831
+ if (newCumulative.gt(totalAvailableCredit)) {
832
+ if (eventPending.gt(totalAvailableCredit)) {
833
+ break;
834
+ }
835
+ }
836
+
837
+ // eslint-disable-next-line no-await-in-loop
838
+ await event.update({
839
+ status: 'pending',
840
+ attempt_count: 0,
841
+ next_attempt: undefined,
842
+ });
843
+
844
+ const batchIndex = Math.floor(i / BATCH_SIZE);
845
+ const runAt = now + batchIndex * BATCH_DELAY_SECONDS;
846
+
847
+ try {
848
+ // eslint-disable-next-line no-await-in-loop
849
+ await addCreditConsumptionJob(event.id, true, { runAt });
850
+ } catch (jobError: any) {
851
+ logger.error('Failed to add credit consumption job after status update', {
852
+ eventId: event.id,
853
+ customerId,
854
+ currencyId,
855
+ error: jobError.message,
856
+ });
857
+ }
858
+
859
+ affordableEvents.push(event);
860
+ cumulativePending = newCumulative;
861
+ }
862
+
863
+ const skippedEvents = failedEvents.length - affordableEvents.length;
864
+
865
+ logger.info('Completed retrying failed events', {
866
+ customerId,
867
+ currencyId,
868
+ totalEvents: failedEvents.length,
869
+ affordableEvents: affordableEvents.length,
870
+ skippedEvents,
871
+ totalAvailableCredit: totalAvailableCredit.toString(),
872
+ totalAffordablePending: cumulativePending.toString(),
873
+ grantCount: availableGrants.length,
804
874
  });
805
875
  } catch (error: any) {
806
- logger.error('Failed to update failed events for customer', {
807
- customerId: creditGrant.customer_id,
808
- currencyId: creditGrant.currency_id,
876
+ logger.error('Failed to retry failed events for customer', {
877
+ customerId: grant.customer_id,
878
+ currencyId: grant.currency_id,
809
879
  error: error.message,
810
880
  });
881
+ } finally {
882
+ lock.release();
811
883
  }
812
884
  }
@@ -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
  }
@@ -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';
@@ -165,6 +165,89 @@ router.get('/summary', authMine, async (req, res) => {
165
165
  }
166
166
  });
167
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
+
168
251
  const checkAutoRechargeSchema = Joi.object({
169
252
  customer_id: Joi.string().required(),
170
253
  currency_id: Joi.string().required(),
@@ -327,7 +327,6 @@ router.post('/', auth, async (req, res) => {
327
327
  router.get('/pending-amount', authMine, async (req, res) => {
328
328
  try {
329
329
  const params: any = {
330
- status: ['requires_action', 'requires_capture'],
331
330
  livemode: !!req.livemode,
332
331
  };
333
332
  if (req.query.subscription_id) {
@@ -427,7 +427,7 @@ export class MeterEvent extends Model<InferAttributes<MeterEvent>, InferCreation
427
427
  subscriptionId,
428
428
  livemode,
429
429
  currencyId,
430
- status = ['requires_action', 'requires_capture'],
430
+ status,
431
431
  customerId,
432
432
  }: {
433
433
  subscriptionId?: string;
package/blocklet.yml CHANGED
@@ -14,7 +14,7 @@ repository:
14
14
  type: git
15
15
  url: git+https://github.com/blocklet/payment-kit.git
16
16
  specVersion: 1.2.8
17
- version: 1.23.9
17
+ version: 1.23.11
18
18
  logo: logo.png
19
19
  files:
20
20
  - dist
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "payment-kit",
3
- "version": "1.23.9",
3
+ "version": "1.23.11",
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.23.9",
63
- "@blocklet/payment-react": "1.23.9",
64
- "@blocklet/payment-vendor": "1.23.9",
62
+ "@blocklet/payment-broker-client": "1.23.11",
63
+ "@blocklet/payment-react": "1.23.11",
64
+ "@blocklet/payment-vendor": "1.23.11",
65
65
  "@blocklet/sdk": "^1.17.8-beta-20260104-120132-cb5b1914",
66
66
  "@blocklet/ui-react": "^3.3.10",
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.23.9",
134
+ "@blocklet/payment-types": "1.23.11",
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": "98f0267cd0769183f21ea954a8fe0b4573f7d684"
181
+ "gitHead": "72af80fc6e91e88058665212985002966f55787c"
182
182
  }