payment-kit 1.26.4 → 1.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/api/src/libs/payment.ts +113 -22
  2. package/api/src/libs/queue/index.ts +20 -9
  3. package/api/src/libs/queue/store.ts +11 -7
  4. package/api/src/libs/reference-cache.ts +115 -0
  5. package/api/src/queues/auto-recharge.ts +68 -21
  6. package/api/src/queues/credit-consume.ts +835 -206
  7. package/api/src/routes/checkout-sessions.ts +78 -1
  8. package/api/src/routes/customers.ts +15 -3
  9. package/api/src/routes/donations.ts +4 -4
  10. package/api/src/routes/index.ts +37 -8
  11. package/api/src/routes/invoices.ts +14 -3
  12. package/api/src/routes/meter-events.ts +41 -15
  13. package/api/src/routes/payment-links.ts +2 -2
  14. package/api/src/routes/prices.ts +1 -1
  15. package/api/src/routes/pricing-table.ts +3 -2
  16. package/api/src/routes/products.ts +2 -2
  17. package/api/src/routes/subscription-items.ts +12 -3
  18. package/api/src/routes/subscriptions.ts +27 -9
  19. package/api/src/store/migrations/20260306-checkout-session-indexes.ts +23 -0
  20. package/api/src/store/models/checkout-session.ts +3 -2
  21. package/api/src/store/models/coupon.ts +9 -6
  22. package/api/src/store/models/credit-grant.ts +4 -1
  23. package/api/src/store/models/credit-transaction.ts +3 -2
  24. package/api/src/store/models/customer.ts +9 -6
  25. package/api/src/store/models/exchange-rate-provider.ts +9 -6
  26. package/api/src/store/models/invoice.ts +3 -2
  27. package/api/src/store/models/meter-event.ts +6 -4
  28. package/api/src/store/models/meter.ts +9 -6
  29. package/api/src/store/models/payment-intent.ts +9 -6
  30. package/api/src/store/models/payment-link.ts +9 -6
  31. package/api/src/store/models/payout.ts +3 -2
  32. package/api/src/store/models/price.ts +9 -6
  33. package/api/src/store/models/pricing-table.ts +9 -6
  34. package/api/src/store/models/product.ts +9 -6
  35. package/api/src/store/models/promotion-code.ts +9 -6
  36. package/api/src/store/models/refund.ts +9 -6
  37. package/api/src/store/models/setup-intent.ts +6 -4
  38. package/api/src/store/sequelize.ts +8 -3
  39. package/api/tests/queues/credit-consume-batch.spec.ts +438 -0
  40. package/api/tests/queues/credit-consume.spec.ts +505 -0
  41. package/api/third.d.ts +1 -1
  42. package/blocklet.yml +1 -1
  43. package/package.json +8 -7
  44. package/scripts/benchmark-seed.js +247 -0
  45. package/src/components/customer/credit-overview.tsx +31 -42
  46. package/src/components/invoice-pdf/template.tsx +5 -4
  47. package/src/components/payment-link/actions.tsx +45 -0
  48. package/src/components/payment-link/before-pay.tsx +24 -0
  49. package/src/components/subscription/payment-method-info.tsx +23 -6
  50. package/src/components/subscription/portal/actions.tsx +2 -0
  51. package/src/locales/en.tsx +11 -0
  52. package/src/locales/zh.tsx +10 -0
  53. package/src/pages/admin/products/links/detail.tsx +8 -0
  54. package/src/pages/customer/subscription/detail.tsx +21 -18
@@ -1,20 +1,13 @@
1
1
  import { BN, fromUnitToToken } from '@ocap/util';
2
+ import { Op } from 'sequelize';
3
+ import pAll from 'p-all';
2
4
 
3
5
  import { getLock } from '../libs/lock';
4
6
  import logger from '../libs/logger';
5
- import dayjs from '../libs/dayjs';
6
7
  import createQueue from '../libs/queue';
7
8
  import { createEvent } from '../libs/audit';
8
- import {
9
- MeterEvent,
10
- CreditGrant,
11
- CreditTransaction,
12
- Meter,
13
- Customer,
14
- Subscription,
15
- PaymentCurrency,
16
- TMeterExpanded,
17
- } from '../store/models';
9
+ import { MeterEvent, CreditGrant, CreditTransaction, Customer, Subscription, TMeterExpanded } from '../store/models';
10
+ import { getCachedMeterExpanded } from '../libs/reference-cache';
18
11
 
19
12
  import { MAX_RETRY_COUNT, getNextRetry, formatCreditAmount } from '../libs/util';
20
13
  import { getDaysUntilCancel, getDueUnit, getMeterPriceIdsFromSubscription } from '../libs/subscription';
@@ -27,6 +20,11 @@ type CreditConsumptionJob = {
27
20
  meterEventId: string;
28
21
  };
29
22
 
23
+ type BatchCreditConsumptionJob = {
24
+ meterEventIds: string[];
25
+ batchKey?: string;
26
+ };
27
+
30
28
  type CreditConsumptionContext = {
31
29
  meterEvent: MeterEvent;
32
30
  meter: TMeterExpanded;
@@ -35,6 +33,7 @@ type CreditConsumptionContext = {
35
33
  priceIds?: string[];
36
34
  transactions: string[];
37
35
  warnings: string[];
36
+ _subscriptionId?: string;
38
37
  };
39
38
 
40
39
  type CreditConsumptionResult = {
@@ -78,33 +77,22 @@ async function checkLowBalance(
78
77
  });
79
78
  }
80
79
  }
81
- async function validateAndLoadData(meterEventId: string): Promise<CreditConsumptionContext | null> {
82
- const meterEvent = await MeterEvent.findByPk(meterEventId);
83
- if (!meterEvent) {
84
- logger.warn('Skipping credit consumption job: MeterEvent not found', { meterEventId });
85
- return null;
86
- }
87
-
88
- if (meterEvent.status === 'completed' || meterEvent.status === 'canceled') {
89
- logger.info('Skipping credit consumption job: MeterEvent already processed', {
90
- meterEventId,
91
- status: meterEvent.status,
92
- });
80
+ async function loadReferenceData(
81
+ meterEventId: string,
82
+ meterEvent: MeterEvent
83
+ ): Promise<CreditConsumptionContext | null> {
84
+ const customerId = meterEvent.getCustomerId();
85
+ if (!customerId) {
86
+ logger.warn('Skipping credit consumption job: MeterEvent missing customer_id', { meterEventId });
93
87
  return null;
94
88
  }
95
89
 
96
- if (meterEvent.status === 'requires_action') {
97
- logger.warn('Skipping credit consumption job: MeterEvent requires manual intervention', {
98
- meterEventId,
99
- attemptCount: meterEvent.attempt_count,
100
- });
101
- return null;
102
- }
90
+ const subscriptionId = meterEvent.getSubscriptionId();
103
91
 
104
- const meter = (await Meter.findOne({
105
- where: { event_name: meterEvent.event_name },
106
- include: [{ model: PaymentCurrency, as: 'paymentCurrency' }],
107
- })) as TMeterExpanded | null;
92
+ const [meter, customer] = await Promise.all([
93
+ getCachedMeterExpanded(meterEvent.event_name) as Promise<TMeterExpanded | null>,
94
+ Customer.findByPk(customerId),
95
+ ]);
108
96
 
109
97
  if (!meter) {
110
98
  logger.warn('Skipping credit consumption job: Meter not found', {
@@ -122,13 +110,6 @@ async function validateAndLoadData(meterEventId: string): Promise<CreditConsumpt
122
110
  return null;
123
111
  }
124
112
 
125
- const customerId = meterEvent.getCustomerId();
126
- if (!customerId) {
127
- logger.warn('Skipping credit consumption job: MeterEvent missing customer_id', { meterEventId });
128
- return null;
129
- }
130
-
131
- const customer = await Customer.findByPk(customerId);
132
113
  if (!customer) {
133
114
  logger.warn('Skipping credit consumption job: Customer not found', {
134
115
  meterEventId,
@@ -137,36 +118,13 @@ async function validateAndLoadData(meterEventId: string): Promise<CreditConsumpt
137
118
  return null;
138
119
  }
139
120
 
140
- const subscriptionId = meterEvent.getSubscriptionId();
141
- if (subscriptionId) {
142
- const subscription = await Subscription.findByPk(subscriptionId);
143
- if (!subscription) {
144
- logger.warn('Skipping credit consumption job: Subscription not found', {
145
- meterEventId,
146
- subscriptionId,
147
- });
148
- return null;
149
- }
150
-
151
- const priceIds = await getMeterPriceIdsFromSubscription(subscription);
152
-
153
- return {
154
- meterEvent,
155
- meter,
156
- customer,
157
- subscription: subscription!,
158
- transactions: [],
159
- warnings: [],
160
- priceIds,
161
- };
162
- }
163
-
164
121
  return {
165
122
  meterEvent,
166
123
  meter,
167
124
  customer,
168
125
  transactions: [],
169
126
  warnings: [],
127
+ _subscriptionId: subscriptionId,
170
128
  };
171
129
  }
172
130
 
@@ -184,6 +142,8 @@ async function consumeAvailableCredits(
184
142
  },
185
143
  });
186
144
 
145
+ const existingTxByGrantId = new Map(existingTransactions.map((tx) => [tx.credit_grant_id, tx]));
146
+
187
147
  const alreadyConsumed = existingTransactions.reduce((sum, tx) => sum.add(new BN(tx.credit_amount)), new BN(0));
188
148
  const remainingToConsume = new BN(totalRequiredAmount).sub(alreadyConsumed);
189
149
 
@@ -205,7 +165,6 @@ async function consumeAvailableCredits(
205
165
  };
206
166
  }
207
167
 
208
- // Get all available grants sorted by priority
209
168
  const availableGrants = await CreditGrant.getAvailableCreditsForCustomer(customerId, currencyId, context.priceIds);
210
169
 
211
170
  const totalCreditAmountBN = availableGrants.reduce((sum, grant) => sum.add(new BN(grant.amount)), new BN(0));
@@ -229,7 +188,7 @@ async function consumeAvailableCredits(
229
188
 
230
189
  // if consumeAmount is greater than 0, process grant consumption
231
190
  if (consumeAmount.gt(new BN(0))) {
232
- await processGrantConsumption(context, grant, consumeAmount.toString()); // eslint-disable-line no-await-in-loop
191
+ await processGrantConsumption(context, grant, consumeAmount.toString(), existingTxByGrantId); // eslint-disable-line no-await-in-loop
233
192
  consumed = consumed.add(consumeAmount);
234
193
  remaining = remaining.sub(consumeAmount);
235
194
  }
@@ -246,12 +205,48 @@ async function consumeAvailableCredits(
246
205
  newlyConsumed: consumed.toString(),
247
206
  totalConsumed: totalConsumed.toString(),
248
207
  finalPending: finalPending.toString(),
208
+ grantCount: availableGrants.length,
249
209
  });
250
210
 
251
- const pendingAmount = Math.max(0, finalPending.toNumber()).toString();
211
+ const pendingAmount = finalPending.lte(new BN(0)) ? '0' : finalPending.toString();
252
212
  const remainingBalance = totalAvailable.sub(consumed).toString();
253
213
 
254
- // Track whether insufficient event was triggered to skip low_balance notification
214
+ // Handle insufficient/low balance events
215
+ await handlePostConsumptionEvents(context, {
216
+ consumed: consumed.toString(),
217
+ remainingToConsume,
218
+ totalAvailable,
219
+ pendingAmount,
220
+ remainingBalance,
221
+ totalCreditAmountBN,
222
+ availableGrants,
223
+ });
224
+
225
+ return {
226
+ consumed: totalConsumed.toString(),
227
+ pending: pendingAmount,
228
+ available_balance: remainingBalance,
229
+ fully_consumed: finalPending.lte(new BN(0)),
230
+ };
231
+ }
232
+
233
+ async function handlePostConsumptionEvents(
234
+ context: CreditConsumptionContext,
235
+ params: {
236
+ consumed: string;
237
+ remainingToConsume: BN;
238
+ totalAvailable: BN;
239
+ pendingAmount: string;
240
+ remainingBalance: string;
241
+ totalCreditAmountBN: BN;
242
+ availableGrants: CreditGrant[];
243
+ }
244
+ ): Promise<void> {
245
+ const customerId = context.meterEvent.getCustomerId();
246
+ const currencyId = context.meter.currency_id!;
247
+ const { consumed, remainingToConsume, totalAvailable, pendingAmount, remainingBalance, totalCreditAmountBN } = params;
248
+ const finalPending = new BN(pendingAmount);
249
+
255
250
  let insufficientTriggered = false;
256
251
 
257
252
  // 如果无法完全消费所需额度,记录不足事件
@@ -259,9 +254,9 @@ async function consumeAvailableCredits(
259
254
  logger.warn('Insufficient credit balance', {
260
255
  customerId,
261
256
  currencyId,
262
- totalRequiredAmount,
257
+ requiredAmount: remainingToConsume.toString(),
263
258
  availableAmount: totalAvailable.toString(),
264
- totalConsumed: totalConsumed.toString(),
259
+ consumedAmount: consumed,
265
260
  pendingAmount,
266
261
  });
267
262
  if ((context.subscription && context.subscription.isActive()) || !context.subscription) {
@@ -273,7 +268,7 @@ async function consumeAvailableCredits(
273
268
  meter_event_name: context.meterEvent.event_name,
274
269
  required_amount: remainingToConsume.toString(),
275
270
  available_amount: totalAvailable.toString(),
276
- consumed_amount: consumed.toString(),
271
+ consumed_amount: consumed,
277
272
  pending_amount: pendingAmount,
278
273
  currency_id: currencyId,
279
274
  subscription_id: context.subscription?.id,
@@ -302,7 +297,7 @@ async function consumeAvailableCredits(
302
297
  },
303
298
  });
304
299
  }
305
- } else if (remainingBalance === '0') {
300
+ } else if (new BN(remainingBalance).lte(new BN(0))) {
306
301
  insufficientTriggered = true;
307
302
  await createEvent('Customer', 'customer.credit.insufficient', context.customer, {
308
303
  metadata: {
@@ -311,7 +306,7 @@ async function consumeAvailableCredits(
311
306
  meter_event_name: context.meterEvent.event_name,
312
307
  required_amount: remainingToConsume.toString(),
313
308
  available_amount: '0',
314
- consumed_amount: consumed.toString(),
309
+ consumed_amount: consumed,
315
310
  pending_amount: pendingAmount,
316
311
  currency_id: currencyId,
317
312
  subscription_id: context.subscription?.id,
@@ -319,23 +314,16 @@ async function consumeAvailableCredits(
319
314
  }).catch(console.error);
320
315
  }
321
316
 
322
- // Skip low_balance check if insufficient was already triggered (insufficient takes priority)
323
317
  if (!insufficientTriggered) {
324
318
  await checkLowBalance(customerId, currencyId, totalCreditAmountBN.toString(), remainingBalance, context);
325
319
  }
326
-
327
- return {
328
- consumed: totalConsumed.toString(),
329
- pending: pendingAmount,
330
- available_balance: remainingBalance,
331
- fully_consumed: finalPending.lte(new BN(0)),
332
- };
333
320
  }
334
321
 
335
322
  async function processGrantConsumption(
336
323
  context: CreditConsumptionContext,
337
324
  creditGrant: CreditGrant,
338
- consumeAmount: string
325
+ consumeAmount: string,
326
+ existingTxByGrantId?: Map<string, CreditTransaction>
339
327
  ): Promise<void> {
340
328
  logger.info('Processing grant consumption', {
341
329
  creditGrantId: creditGrant.id,
@@ -347,7 +335,7 @@ async function processGrantConsumption(
347
335
  meter_event_id: context.meterEvent.id,
348
336
  });
349
337
 
350
- const transactionId = await createCreditTransaction(context, creditGrant, consumeAmount, result);
338
+ const transactionId = await createCreditTransaction(context, creditGrant, consumeAmount, result, existingTxByGrantId);
351
339
 
352
340
  if (CreditGrant.hasOnchainToken(creditGrant)) {
353
341
  addTokenTransferJob({
@@ -371,18 +359,13 @@ async function createCreditTransaction(
371
359
  context: CreditConsumptionContext,
372
360
  creditGrant: CreditGrant,
373
361
  consumeAmount: string,
374
- consumeResult: any
362
+ consumeResult: any,
363
+ existingTxByGrantId?: Map<string, CreditTransaction>
375
364
  ): Promise<string> {
376
365
  const meterEventId = context.meterEvent.id;
377
366
  const creditGrantId = creditGrant.id;
378
367
 
379
- // 检查是否已经为这个 MeterEvent CreditGrant 创建过 transaction
380
- const existingTransaction = await CreditTransaction.findOne({
381
- where: {
382
- source: meterEventId,
383
- credit_grant_id: creditGrantId,
384
- },
385
- });
368
+ const existingTransaction = existingTxByGrantId?.get(creditGrantId) ?? null;
386
369
 
387
370
  if (existingTransaction) {
388
371
  logger.warn('CreditTransaction already exists for this MeterEvent and CreditGrant', {
@@ -487,13 +470,39 @@ async function createCreditTransaction(
487
470
  }
488
471
  }
489
472
 
473
+ // ============================================================================
474
+ // Single-event handler
475
+ // ============================================================================
476
+
490
477
  export async function handleCreditConsumption(job: CreditConsumptionJob) {
491
478
  const { meterEventId } = job;
479
+ const jobStart = performance.now();
492
480
 
493
481
  logger.info('Starting credit consumption job', { meterEventId });
494
482
 
495
- // Load and validate data first to get customer_id
496
- const context: CreditConsumptionContext | null = await validateAndLoadData(meterEventId);
483
+ // Pre-check before acquiring lock
484
+ const preCheckEvent = await MeterEvent.findByPk(meterEventId);
485
+ if (!preCheckEvent) {
486
+ logger.warn('Skipping credit consumption job: MeterEvent not found', { meterEventId });
487
+ return;
488
+ }
489
+ if (preCheckEvent.status === 'completed' || preCheckEvent.status === 'canceled') {
490
+ logger.info('Skipping credit consumption job: MeterEvent already processed', {
491
+ meterEventId,
492
+ status: preCheckEvent.status,
493
+ });
494
+ return;
495
+ }
496
+ if (preCheckEvent.status === 'requires_action') {
497
+ logger.warn('Skipping credit consumption job: MeterEvent requires manual intervention', { meterEventId });
498
+ return;
499
+ }
500
+
501
+ // Load reference data
502
+ const validateStart = performance.now();
503
+ const context: CreditConsumptionContext | null = await loadReferenceData(meterEventId, preCheckEvent);
504
+ const validateTime = performance.now() - validateStart;
505
+
497
506
  if (!context) {
498
507
  logger.warn('Credit consumption context not found, skipping job', { meterEventId });
499
508
  return;
@@ -502,32 +511,62 @@ export async function handleCreditConsumption(job: CreditConsumptionJob) {
502
511
  const customerId = context.customer.id;
503
512
  const lock = getLock(`credit-consumption-customer-${customerId}`);
504
513
 
514
+ let rechargeParams: {
515
+ customer: Customer;
516
+ currencyId: string;
517
+ availableBalance: string;
518
+ currency: any;
519
+ meter: TMeterExpanded;
520
+ } | null = null;
521
+ let rechargeTime = 0;
522
+
505
523
  try {
524
+ const lockStart = performance.now();
506
525
  await lock.acquire();
526
+ const lockTime = performance.now() - lockStart;
527
+
528
+ // Fresh loads inside lock for consistency
529
+ const [freshEvent, freshSubscription] = await Promise.all([
530
+ MeterEvent.findByPk(meterEventId),
531
+ context._subscriptionId ? Subscription.findByPk(context._subscriptionId) : Promise.resolve(null),
532
+ ]);
533
+ if (!freshEvent) {
534
+ logger.warn('MeterEvent disappeared after lock acquired', { meterEventId });
535
+ return;
536
+ }
537
+ context.meterEvent = freshEvent;
538
+
539
+ if (context._subscriptionId && !freshSubscription) {
540
+ logger.warn('Skipping credit consumption: Subscription not found inside lock', {
541
+ meterEventId,
542
+ subscriptionId: context._subscriptionId,
543
+ });
544
+ return;
545
+ }
546
+ if (freshSubscription) {
547
+ context.subscription = freshSubscription;
548
+ context.priceIds = await getMeterPriceIdsFromSubscription(freshSubscription);
549
+ }
507
550
 
508
- // 重新加载MeterEvent以获取最新状态
509
- await context.meterEvent.reload();
510
- if (context.meterEvent.status === 'completed' || context.meterEvent.status === 'canceled') {
551
+ if (freshEvent.status === 'completed' || freshEvent.status === 'canceled') {
511
552
  logger.info('MeterEvent already processed, skipping', {
512
553
  meterEventId,
513
- status: context.meterEvent.status,
554
+ status: freshEvent.status,
514
555
  });
515
556
  return;
516
557
  }
517
558
 
518
- // 检查是否正在处理中,避免重复处理
519
- if (context.meterEvent.status === 'processing') {
559
+ if (freshEvent.status === 'processing') {
520
560
  logger.warn('MeterEvent is already being processed, skipping duplicate job', {
521
561
  meterEventId,
522
- status: context.meterEvent.status,
562
+ status: freshEvent.status,
523
563
  });
524
564
  return;
525
565
  }
526
566
 
527
567
  // Mark as processing to prevent duplicate processing
528
- await context.meterEvent.markAsProcessing();
529
-
530
- const totalRequiredAmount = context.meterEvent.getValue();
568
+ await freshEvent.markAsProcessing();
569
+ const totalRequiredAmount = freshEvent.getValue();
531
570
 
532
571
  logger.info('Attempting to consume credits', {
533
572
  meterEventId,
@@ -536,35 +575,34 @@ export async function handleCreditConsumption(job: CreditConsumptionJob) {
536
575
  currencyId: context.meter.currency_id,
537
576
  });
538
577
 
539
- // Consume available credits (handles existing transactions internally)
578
+ const consumeStart = performance.now();
540
579
  const consumptionResult = await consumeAvailableCredits(context, totalRequiredAmount);
580
+ const consumeTime = performance.now() - consumeStart;
541
581
 
542
- // Check for auto recharge after successful consumption
543
- try {
544
- await checkAndTriggerAutoRecharge(
545
- context.customer,
546
- context.meter.currency_id!,
547
- consumptionResult.available_balance
548
- );
549
- } catch (error: any) {
550
- logger.warn('Auto recharge check failed after credit consumption', {
551
- meterEventId,
552
- customerId,
553
- error: error.message,
582
+ if (consumptionResult.fully_consumed) {
583
+ await freshEvent.update({
584
+ status: 'completed',
585
+ processed_at: Math.floor(Date.now() / 1000),
586
+ next_attempt: undefined,
587
+ credit_consumed: consumptionResult.consumed,
588
+ credit_pending: consumptionResult.pending,
589
+ metadata: {
590
+ ...freshEvent.metadata,
591
+ consumption_result: consumptionResult,
592
+ last_error: undefined,
593
+ failed_at: undefined,
594
+ },
554
595
  });
555
- }
556
- // Update MeterEvent with consumption details
557
- await context.meterEvent.update({
558
- credit_consumed: consumptionResult.consumed,
559
- credit_pending: consumptionResult.pending,
560
- metadata: {
561
- ...context.meterEvent.metadata,
562
- consumption_result: consumptionResult,
563
- processed_at: new Date().toISOString(),
564
- },
565
- });
566
596
 
567
- if (consumptionResult.fully_consumed) {
597
+ rechargeParams = {
598
+ customer: context.customer,
599
+ currencyId: context.meter.currency_id!,
600
+ availableBalance: consumptionResult.available_balance,
601
+ currency: context.meter.paymentCurrency,
602
+ meter: context.meter,
603
+ };
604
+
605
+ const totalTime = performance.now() - jobStart;
568
606
  logger.info('Credit consumption completed successfully', {
569
607
  meterEventId,
570
608
  customerId,
@@ -572,12 +610,29 @@ export async function handleCreditConsumption(job: CreditConsumptionJob) {
572
610
  consumed: consumptionResult.consumed,
573
611
  pending: consumptionResult.pending,
574
612
  transactionCount: context.transactions.length,
613
+ timing: {
614
+ validateMs: Math.round(validateTime),
615
+ lockMs: Math.round(lockTime),
616
+ consumeMs: Math.round(consumeTime),
617
+ totalMs: Math.round(totalTime),
618
+ },
575
619
  });
576
- await context.meterEvent.markAsCompleted();
620
+
577
621
  if (context.subscription && context.subscription.status === 'past_due') {
578
622
  await handlePastDueSubscriptionRecovery(context.subscription, null);
579
623
  }
580
624
  } else {
625
+ // Save partial consumption progress before throwing
626
+ await freshEvent.update({
627
+ credit_consumed: consumptionResult.consumed,
628
+ credit_pending: consumptionResult.pending,
629
+ metadata: {
630
+ ...freshEvent.metadata,
631
+ consumption_result: consumptionResult,
632
+ processed_at: new Date().toISOString(),
633
+ },
634
+ });
635
+
581
636
  logger.warn('Credit consumption partially completed - insufficient balance', {
582
637
  meterEventId,
583
638
  customerId,
@@ -586,6 +641,16 @@ export async function handleCreditConsumption(job: CreditConsumptionJob) {
586
641
  finalPending: consumptionResult.pending,
587
642
  availableBalance: consumptionResult.available_balance,
588
643
  });
644
+
645
+ // Trigger auto-recharge even for partial consumption
646
+ rechargeParams = {
647
+ customer: context.customer,
648
+ currencyId: context.meter.currency_id!,
649
+ availableBalance: consumptionResult.available_balance,
650
+ currency: context.meter.paymentCurrency,
651
+ meter: context.meter,
652
+ };
653
+
589
654
  throw new Error(
590
655
  `Insufficient credit balance: required ${totalRequiredAmount}, consumed ${consumptionResult.consumed}, pending ${consumptionResult.pending}`
591
656
  );
@@ -614,8 +679,7 @@ export async function handleCreditConsumption(job: CreditConsumptionJob) {
614
679
  const nextAttemptTime = getNextRetry(attemptCount);
615
680
  await meterEvent.markAsRequiresCapture(error.message, nextAttemptTime);
616
681
 
617
- // Reschedule the job with exponential backoff
618
- addCreditConsumptionJob(meterEventId, true, { runAt: nextAttemptTime });
682
+ addCreditConsumptionJob(meterEventId, true, { runAt: nextAttemptTime, skipStatusCheck: true });
619
683
 
620
684
  logger.warn('Credit consumption retry scheduled', {
621
685
  meterEventId,
@@ -637,14 +701,541 @@ export async function handleCreditConsumption(job: CreditConsumptionJob) {
637
701
  throw error;
638
702
  } finally {
639
703
  lock.release();
704
+
705
+ // Auto-recharge check outside lock to reduce lock hold time
706
+ // Runs for both successful and partial consumption (insufficient balance)
707
+ if (rechargeParams) {
708
+ const rechargeStart = performance.now();
709
+ try {
710
+ await checkAndTriggerAutoRecharge(
711
+ rechargeParams.customer,
712
+ rechargeParams.currencyId,
713
+ rechargeParams.availableBalance,
714
+ {
715
+ currency: rechargeParams.currency,
716
+ meter: rechargeParams.meter,
717
+ }
718
+ );
719
+ } catch (error: any) {
720
+ logger.warn('Auto recharge check failed after credit consumption', {
721
+ meterEventId,
722
+ customerId,
723
+ error: error.message,
724
+ });
725
+ }
726
+ rechargeTime = performance.now() - rechargeStart;
727
+ if (rechargeTime > 50) {
728
+ logger.info('Auto recharge check timing', { meterEventId, rechargeMs: Math.round(rechargeTime) });
729
+ }
730
+ }
731
+ }
732
+ }
733
+
734
+ // ============================================================================
735
+ // Batch credit consumption
736
+ // ============================================================================
737
+ const CREDIT_BATCH_SIZE = Math.max(1, parseInt(process.env.CREDIT_BATCH_SIZE || '50', 10));
738
+ const CREDIT_BATCH_WINDOW_MS = Math.max(10, parseInt(process.env.CREDIT_BATCH_WINDOW_MS || '3000', 10));
739
+
740
+ type PendingBatch = { eventIds: string[]; timer: NodeJS.Timeout | null };
741
+ const pendingBatches = new Map<string, PendingBatch>();
742
+ // Track in-flight batch jobs per key to avoid head-of-line blocking.
743
+ // Only one batch job per key runs at a time; new events accumulate until it finishes.
744
+ const inflightBatches = new Set<string>();
745
+
746
+ function getBatchKey(customerId: string, eventName: string, subscriptionId?: string): string {
747
+ return `${customerId}::${eventName}::${subscriptionId || ''}`;
748
+ }
749
+
750
+ function addToBatch(customerId: string, eventName: string, meterEventId: string, subscriptionId?: string) {
751
+ const key = getBatchKey(customerId, eventName, subscriptionId);
752
+ let batch = pendingBatches.get(key);
753
+ if (!batch) {
754
+ batch = { eventIds: [], timer: null };
755
+ pendingBatches.set(key, batch);
756
+ }
757
+
758
+ batch.eventIds.push(meterEventId);
759
+
760
+ if (batch.eventIds.length >= CREDIT_BATCH_SIZE) {
761
+ flushBatch(key);
762
+ return;
763
+ }
764
+
765
+ if (!batch.timer) {
766
+ batch.timer = setTimeout(() => flushBatch(key), CREDIT_BATCH_WINDOW_MS);
767
+ }
768
+ }
769
+
770
+ function flushBatch(key: string) {
771
+ const batch = pendingBatches.get(key);
772
+ if (!batch || batch.eventIds.length === 0) {
773
+ pendingBatches.delete(key);
774
+ return;
775
+ }
776
+
777
+ // If a batch for this key is already processing, keep events pending.
778
+ // They will be flushed when the in-flight batch completes (see onBatchComplete).
779
+ if (inflightBatches.has(key)) {
780
+ return;
781
+ }
782
+
783
+ if (batch.timer) {
784
+ clearTimeout(batch.timer);
785
+ }
786
+ const eventIds = batch.eventIds.splice(0);
787
+ pendingBatches.delete(key);
788
+
789
+ inflightBatches.add(key);
790
+ const jobId = `batch-credit-${key}-${Date.now()}`;
791
+ creditQueue.push({
792
+ id: jobId,
793
+ job: { meterEventIds: eventIds, batchKey: key } as any,
794
+ skipDuplicateCheck: true,
795
+ });
796
+ }
797
+
798
+ function onBatchComplete(key: string) {
799
+ inflightBatches.delete(key);
800
+ // Flush any events that accumulated while this batch was processing
801
+ if (pendingBatches.has(key)) {
802
+ flushBatch(key);
803
+ }
804
+ }
805
+
806
+ // Graceful shutdown: flush pending batches. If push doesn't persist before exit,
807
+ // events remain 'pending' and are recovered by startCreditConsumeQueue on restart.
808
+ function flushAllBatches() {
809
+ const keys = Array.from(pendingBatches.keys());
810
+ keys.forEach((key) => flushBatch(key));
811
+ }
812
+ process.on('SIGTERM', flushAllBatches);
813
+ process.on('SIGINT', flushAllBatches);
814
+
815
+ export async function handleBatchCreditConsumption(job: BatchCreditConsumptionJob) {
816
+ const { meterEventIds, batchKey } = job;
817
+ const batchStart = performance.now();
818
+
819
+ try {
820
+ await handleBatchCreditConsumptionInner(meterEventIds, batchStart);
821
+ } finally {
822
+ if (batchKey) {
823
+ onBatchComplete(batchKey);
824
+ }
640
825
  }
641
826
  }
642
827
 
643
- export const creditQueue = createQueue<CreditConsumptionJob>({
828
+ async function handleBatchCreditConsumptionInner(meterEventIds: string[], batchStart: number) {
829
+ if (!meterEventIds || meterEventIds.length === 0) return;
830
+
831
+ if (meterEventIds.length === 1) {
832
+ await handleCreditConsumption({ meterEventId: meterEventIds[0]! });
833
+ return;
834
+ }
835
+
836
+ // ==========================================
837
+ // Pre-check: filter already-processed events
838
+ // ==========================================
839
+ const preCheckEvents = await MeterEvent.findAll({
840
+ where: { id: { [Op.in]: meterEventIds } },
841
+ });
842
+
843
+ const processableEvents = preCheckEvents.filter(
844
+ (e) => !['completed', 'canceled', 'requires_action'].includes(e.status)
845
+ );
846
+ if (processableEvents.length === 0) return;
847
+
848
+ const customerId = processableEvents[0]!.getCustomerId();
849
+ const eventName = processableEvents[0]!.event_name;
850
+
851
+ const mismatchedEvents = processableEvents.filter((e) => e.event_name !== eventName);
852
+ if (mismatchedEvents.length > 0) {
853
+ logger.error('Batch contains mixed event_names, falling back to single processing', {
854
+ customerId,
855
+ expectedEventName: eventName,
856
+ mismatchedCount: mismatchedEvents.length,
857
+ });
858
+ for (const event of processableEvents) {
859
+ addCreditConsumptionJob(event.id, true, { skipStatusCheck: true });
860
+ }
861
+ return;
862
+ }
863
+
864
+ // ==========================================
865
+ // Load reference data
866
+ // ==========================================
867
+ const [meter, customer] = await Promise.all([
868
+ getCachedMeterExpanded(eventName) as Promise<TMeterExpanded | null>,
869
+ Customer.findByPk(customerId),
870
+ ]);
871
+
872
+ if (!meter || !meter.currency_id || !customer) {
873
+ logger.warn('Batch credit consumption: reference data not found', {
874
+ batchSize: meterEventIds.length,
875
+ customerId,
876
+ });
877
+ return;
878
+ }
879
+
880
+ const currencyId = meter.currency_id;
881
+ const lock = getLock(`credit-consumption-customer-${customerId}`);
882
+
883
+ let rechargeParams: {
884
+ customer: Customer;
885
+ currencyId: string;
886
+ availableBalance: string;
887
+ currency: any;
888
+ meter: TMeterExpanded;
889
+ } | null = null;
890
+
891
+ try {
892
+ const lockStart = performance.now();
893
+ await lock.acquire();
894
+ const lockTime = performance.now() - lockStart;
895
+
896
+ // ==========================================
897
+ // Fresh read inside lock
898
+ // ==========================================
899
+ const processableIds = processableEvents.map((e) => e.id);
900
+ const freshEvents = await MeterEvent.findAll({
901
+ where: { id: { [Op.in]: processableIds } },
902
+ });
903
+
904
+ const freshProcessable = freshEvents.filter(
905
+ (e) => !['completed', 'canceled', 'processing', 'requires_action'].includes(e.status)
906
+ );
907
+ if (freshProcessable.length === 0) return;
908
+
909
+ // Subscription fresh read (once for entire batch)
910
+ const subscriptionId = freshProcessable[0]!.getSubscriptionId();
911
+ let subscription: Subscription | null = null;
912
+ let priceIds: string[] | undefined;
913
+
914
+ if (subscriptionId) {
915
+ subscription = await Subscription.findByPk(subscriptionId);
916
+ if (!subscription) {
917
+ logger.warn('Batch: Subscription not found inside lock, skipping', {
918
+ customerId,
919
+ subscriptionId,
920
+ });
921
+ return;
922
+ }
923
+ priceIds = await getMeterPriceIdsFromSubscription(subscription);
924
+ }
925
+
926
+ // Mark all processable events as 'processing' in one SQL
927
+ await MeterEvent.update(
928
+ { status: 'processing' },
929
+ { where: { id: { [Op.in]: freshProcessable.map((e) => e.id) } } }
930
+ );
931
+ // Sync in-memory status for subsequent logic
932
+ freshProcessable.forEach((e) => {
933
+ e.status = 'processing';
934
+ });
935
+
936
+ // ==========================================
937
+ // Batch idempotency check
938
+ // ==========================================
939
+ const allEventIds = freshProcessable.map((e) => e.id);
940
+ const existingTransactions = await CreditTransaction.findAll({
941
+ where: { source: { [Op.in]: allEventIds } },
942
+ });
943
+ const txBySource = new Map<string, CreditTransaction[]>();
944
+ for (const tx of existingTransactions) {
945
+ const source = tx.source!;
946
+ const arr = txBySource.get(source) || [];
947
+ arr.push(tx);
948
+ txBySource.set(source, arr);
949
+ }
950
+
951
+ // ==========================================
952
+ // Query available grants once for entire batch
953
+ // ==========================================
954
+ const availableGrants = await CreditGrant.getAvailableCreditsForCustomer(customerId, currencyId, priceIds);
955
+
956
+ const totalCreditAmountBN = availableGrants.reduce((sum, grant) => sum.add(new BN(grant.amount)), new BN(0));
957
+
958
+ // ==========================================
959
+ // Consume credits per event — MUST be sequential because events share
960
+ // the same grant objects and consumeCredit() mutates remaining_amount.
961
+ // ==========================================
962
+ /* eslint-disable no-await-in-loop */
963
+ const successEvents: Array<MeterEvent & { _consumptionResult?: CreditConsumptionResult }> = [];
964
+ const failedEvents: { event: MeterEvent; error: Error }[] = [];
965
+
966
+ // FIFO order by creation time
967
+ freshProcessable.sort((a, b) => {
968
+ const aTime = a.created_at instanceof Date ? a.created_at.getTime() : Number(a.created_at);
969
+ const bTime = b.created_at instanceof Date ? b.created_at.getTime() : Number(b.created_at);
970
+ return aTime - bTime;
971
+ });
972
+
973
+ for (const freshEvent of freshProcessable) {
974
+ try {
975
+ const context: CreditConsumptionContext = {
976
+ meterEvent: freshEvent,
977
+ meter,
978
+ customer,
979
+ subscription: subscription || undefined,
980
+ priceIds,
981
+ transactions: [],
982
+ warnings: [],
983
+ };
984
+
985
+ const totalRequiredAmount = freshEvent.getValue();
986
+
987
+ // Use pre-fetched existingTx data (from batch query)
988
+ const eventExistingTx = txBySource.get(freshEvent.id) || [];
989
+ const existingTxByGrantId = new Map(eventExistingTx.map((tx) => [tx.credit_grant_id, tx]));
990
+ const alreadyConsumed = eventExistingTx.reduce((sum, tx) => sum.add(new BN(tx.credit_amount)), new BN(0));
991
+ const remainingToConsume = new BN(totalRequiredAmount).sub(alreadyConsumed);
992
+
993
+ if (remainingToConsume.lte(new BN(0))) {
994
+ // Already fully consumed
995
+ (freshEvent as any)._consumptionResult = {
996
+ consumed: alreadyConsumed.toString(),
997
+ pending: '0',
998
+ available_balance: '0',
999
+ fully_consumed: true,
1000
+ };
1001
+ successEvents.push(freshEvent);
1002
+ continue; // eslint-disable-line no-continue
1003
+ }
1004
+
1005
+ // Consume from grants (grant.remaining_amount is updated in-memory by consumeCredit)
1006
+ let remaining = remainingToConsume;
1007
+ let consumed = new BN(0);
1008
+
1009
+ for (const grant of availableGrants) {
1010
+ if (remaining.lte(new BN(0))) break;
1011
+
1012
+ // Read directly from grant object — consumeCredit() updates this.remaining_amount after each call
1013
+ const grantBalance = new BN(grant.remaining_amount);
1014
+ if (grantBalance.lte(new BN(0))) continue; // eslint-disable-line no-continue
1015
+
1016
+ const consumeAmount = remaining.lte(grantBalance) ? remaining : grantBalance;
1017
+
1018
+ if (consumeAmount.gt(new BN(0))) {
1019
+ await processGrantConsumption(context, grant, consumeAmount.toString(), existingTxByGrantId);
1020
+ consumed = consumed.add(consumeAmount);
1021
+ remaining = remaining.sub(consumeAmount);
1022
+ }
1023
+ }
1024
+
1025
+ const totalConsumed = alreadyConsumed.add(consumed);
1026
+ const finalPending = new BN(totalRequiredAmount).sub(totalConsumed);
1027
+ const totalAvailable = availableGrants.reduce((sum, g) => sum.add(new BN(g.remaining_amount)), new BN(0));
1028
+ const remainingBalance = totalAvailable.toString();
1029
+
1030
+ if (finalPending.lte(new BN(0))) {
1031
+ (freshEvent as any)._consumptionResult = {
1032
+ consumed: totalConsumed.toString(),
1033
+ pending: '0',
1034
+ available_balance: remainingBalance,
1035
+ fully_consumed: true,
1036
+ };
1037
+ successEvents.push(freshEvent);
1038
+ } else {
1039
+ // Insufficient balance — handle events + save partial progress
1040
+ const pendingAmount = finalPending.toString();
1041
+ await handlePostConsumptionEvents(context, {
1042
+ consumed: consumed.toString(),
1043
+ remainingToConsume,
1044
+ totalAvailable,
1045
+ pendingAmount,
1046
+ remainingBalance,
1047
+ totalCreditAmountBN,
1048
+ availableGrants,
1049
+ });
1050
+
1051
+ // Save partial consumption progress
1052
+ await freshEvent.update({
1053
+ credit_consumed: totalConsumed.toString(),
1054
+ credit_pending: pendingAmount,
1055
+ metadata: {
1056
+ ...freshEvent.metadata,
1057
+ consumption_result: {
1058
+ consumed: totalConsumed.toString(),
1059
+ pending: pendingAmount,
1060
+ available_balance: remainingBalance,
1061
+ fully_consumed: false,
1062
+ },
1063
+ processed_at: new Date().toISOString(),
1064
+ },
1065
+ });
1066
+
1067
+ failedEvents.push({
1068
+ event: freshEvent,
1069
+ error: new Error(
1070
+ `Insufficient credit balance: required ${totalRequiredAmount}, consumed ${totalConsumed.toString()}, pending ${pendingAmount}`
1071
+ ),
1072
+ });
1073
+ }
1074
+ } catch (error: any) {
1075
+ failedEvents.push({ event: freshEvent, error });
1076
+ }
1077
+ }
1078
+ /* eslint-enable no-await-in-loop */
1079
+ // ==========================================
1080
+ // Update successful events
1081
+ // ==========================================
1082
+ if (successEvents.length > 0) {
1083
+ const now = Math.floor(Date.now() / 1000);
1084
+ await pAll(
1085
+ successEvents.map(
1086
+ (event) => () =>
1087
+ event.update({
1088
+ status: 'completed',
1089
+ processed_at: now,
1090
+ next_attempt: undefined,
1091
+ credit_consumed: (event as any)._consumptionResult?.consumed || '0',
1092
+ credit_pending: '0',
1093
+ metadata: {
1094
+ ...event.metadata,
1095
+ consumption_result: (event as any)._consumptionResult,
1096
+ last_error: undefined,
1097
+ failed_at: undefined,
1098
+ batch_processed: true,
1099
+ },
1100
+ })
1101
+ ),
1102
+ { concurrency: 5 }
1103
+ );
1104
+ }
1105
+
1106
+ // Calculate actual available balance for auto-recharge
1107
+ const finalAvailableBalance = availableGrants
1108
+ .reduce((sum, g) => sum.add(new BN(g.remaining_amount)), new BN(0))
1109
+ .toString();
1110
+
1111
+ // Check low balance after batch consumption (only if all events succeeded, no insufficient already triggered)
1112
+ if (successEvents.length > 0 && failedEvents.length === 0) {
1113
+ await checkLowBalance(customerId, currencyId, totalCreditAmountBN.toString(), finalAvailableBalance, {
1114
+ meterEvent: successEvents[successEvents.length - 1]!,
1115
+ meter,
1116
+ customer,
1117
+ subscription: subscription || undefined,
1118
+ priceIds,
1119
+ transactions: [],
1120
+ warnings: [],
1121
+ });
1122
+ }
1123
+
1124
+ rechargeParams = {
1125
+ customer,
1126
+ currencyId,
1127
+ availableBalance: finalAvailableBalance,
1128
+ currency: meter.paymentCurrency,
1129
+ meter,
1130
+ };
1131
+
1132
+ // ==========================================
1133
+ // Retry failed events — each is independent, safe to parallelize
1134
+ // ==========================================
1135
+ await pAll(
1136
+ failedEvents.map(({ event, error: failError }) => async () => {
1137
+ try {
1138
+ const attemptCount = event.attempt_count + 1;
1139
+ if (attemptCount >= MAX_RETRY_COUNT) {
1140
+ await event.markAsRequiresAction(failError.message);
1141
+ } else {
1142
+ const nextAttemptTime = getNextRetry(attemptCount);
1143
+ await event.markAsRequiresCapture(failError.message, nextAttemptTime);
1144
+ addCreditConsumptionJob(event.id, true, { runAt: nextAttemptTime, skipStatusCheck: true });
1145
+ }
1146
+ } catch (updateError: any) {
1147
+ logger.error('Batch: failed to update meter event failure status', {
1148
+ meterEventId: event.id,
1149
+ error: updateError.message,
1150
+ });
1151
+ }
1152
+ }),
1153
+ { concurrency: 5 }
1154
+ );
1155
+
1156
+ // Past due recovery
1157
+ if (subscription && subscription.status === 'past_due' && successEvents.length > 0) {
1158
+ await handlePastDueSubscriptionRecovery(subscription, null);
1159
+ }
1160
+
1161
+ const totalTime = performance.now() - batchStart;
1162
+ logger.info('Batch credit consumption completed', {
1163
+ customerId,
1164
+ batchSize: meterEventIds.length,
1165
+ processable: freshProcessable.length,
1166
+ succeeded: successEvents.length,
1167
+ failed: failedEvents.length,
1168
+ grantCount: availableGrants.length,
1169
+ timing: {
1170
+ lockMs: Math.round(lockTime),
1171
+ totalMs: Math.round(totalTime),
1172
+ avgPerEvent: Math.round(totalTime / freshProcessable.length),
1173
+ },
1174
+ });
1175
+ } catch (error: any) {
1176
+ logger.error('Batch credit consumption failed, falling back to single processing', {
1177
+ customerId,
1178
+ batchSize: meterEventIds.length,
1179
+ error: error.message,
1180
+ });
1181
+ // Batch-level error: reset only events still in 'processing' to 'requires_capture'.
1182
+ // Some events may have already been completed before the error occurred.
1183
+ // Use requires_capture (not pending) so attempt_count increments, preventing infinite retry loops.
1184
+ try {
1185
+ await MeterEvent.update(
1186
+ { status: 'requires_capture' },
1187
+ { where: { id: { [Op.in]: meterEventIds }, status: 'processing' } }
1188
+ );
1189
+ } catch (resetError: any) {
1190
+ logger.error('Failed to reset event status before single fallback', { error: resetError.message });
1191
+ }
1192
+ for (const id of meterEventIds) {
1193
+ addCreditConsumptionJob(id, true, { skipStatusCheck: false });
1194
+ }
1195
+ } finally {
1196
+ lock.release();
1197
+ }
1198
+
1199
+ // Auto-recharge check outside lock
1200
+ if (rechargeParams) {
1201
+ try {
1202
+ await checkAndTriggerAutoRecharge(
1203
+ rechargeParams.customer,
1204
+ rechargeParams.currencyId,
1205
+ rechargeParams.availableBalance,
1206
+ {
1207
+ currency: rechargeParams.currency,
1208
+ meter: rechargeParams.meter,
1209
+ }
1210
+ );
1211
+ } catch (error: any) {
1212
+ logger.warn('Auto recharge check failed after batch consumption', {
1213
+ customerId,
1214
+ error: error.message,
1215
+ });
1216
+ }
1217
+ }
1218
+ }
1219
+
1220
+ // ============================================================================
1221
+ // Queue setup
1222
+ // ============================================================================
1223
+
1224
+ const creditQueueConcurrency = Math.max(
1225
+ 1,
1226
+ Math.min(20, parseInt(process.env.CREDIT_QUEUE_CONCURRENCY || '5', 10) || 5)
1227
+ );
1228
+
1229
+ export const creditQueue = createQueue<CreditConsumptionJob | BatchCreditConsumptionJob>({
644
1230
  name: 'credit-consumption',
645
- onJob: handleCreditConsumption,
1231
+ onJob: (job) => {
1232
+ if ('meterEventIds' in job && Array.isArray((job as BatchCreditConsumptionJob).meterEventIds)) {
1233
+ return handleBatchCreditConsumption(job as BatchCreditConsumptionJob);
1234
+ }
1235
+ return handleCreditConsumption(job as CreditConsumptionJob);
1236
+ },
646
1237
  options: {
647
- concurrency: 1,
1238
+ concurrency: creditQueueConcurrency,
648
1239
  maxRetries: 0,
649
1240
  enableScheduledJob: true,
650
1241
  },
@@ -664,7 +1255,7 @@ creditQueue.on('failed', ({ id, job, error }) => {
664
1255
  const addCreditConsumptionJob = async (
665
1256
  meterEventId: string,
666
1257
  replace: boolean = false,
667
- options: { delay?: number; runAt?: number } = {}
1258
+ options: { delay?: number; runAt?: number; skipStatusCheck?: boolean } = {}
668
1259
  ) => {
669
1260
  const jobId = `meter-event-${meterEventId}`;
670
1261
 
@@ -680,7 +1271,7 @@ const addCreditConsumptionJob = async (
680
1271
  }
681
1272
 
682
1273
  if (existingJob && replace) {
683
- await creditQueue.delete(jobId);
1274
+ await creditQueue.delete(jobId, true);
684
1275
  logger.info('Replaced existing credit consumption job', {
685
1276
  meterEventId,
686
1277
  jobId,
@@ -688,26 +1279,26 @@ const addCreditConsumptionJob = async (
688
1279
  });
689
1280
  }
690
1281
 
691
- // 检查 MeterEvent 状态,避免为已完成的事件添加任务
692
- const meterEvent = await MeterEvent.findByPk(meterEventId);
693
- if (!meterEvent) {
694
- logger.warn('Cannot add credit consumption job: MeterEvent not found', { meterEventId });
695
- return;
696
- }
1282
+ if (!options.skipStatusCheck) {
1283
+ const meterEvent = await MeterEvent.findByPk(meterEventId);
1284
+ if (!meterEvent) {
1285
+ logger.warn('Cannot add credit consumption job: MeterEvent not found', { meterEventId });
1286
+ return;
1287
+ }
697
1288
 
698
- if (meterEvent.status === 'completed' || meterEvent.status === 'canceled') {
699
- logger.debug('Skipping credit consumption job: MeterEvent already processed', {
700
- meterEventId,
701
- status: meterEvent.status,
702
- });
703
- return;
1289
+ if (meterEvent.status === 'completed' || meterEvent.status === 'canceled') {
1290
+ logger.debug('Skipping credit consumption job: MeterEvent already processed', {
1291
+ meterEventId,
1292
+ status: meterEvent.status,
1293
+ });
1294
+ return;
1295
+ }
704
1296
  }
705
1297
 
706
- creditQueue.push({ id: jobId, job: { meterEventId }, ...options });
1298
+ creditQueue.push({ id: jobId, job: { meterEventId }, skipDuplicateCheck: true, ...options });
707
1299
  logger.info('Credit consumption job added to queue', {
708
1300
  meterEventId,
709
1301
  jobId,
710
- meterEventStatus: meterEvent.status,
711
1302
  });
712
1303
  } catch (error: any) {
713
1304
  logger.error('Failed to add credit consumption job', {
@@ -732,30 +1323,63 @@ export async function startCreditConsumeQueue(): Promise<void> {
732
1323
  await lock.acquire();
733
1324
  logger.info('Starting credit queue and processing pending meter events');
734
1325
 
735
- // Find pending and requires_capture events that need processing
736
- const pendingEvents = await MeterEvent.findAll({
737
- where: {
738
- status: ['pending', 'requires_capture'],
739
- },
740
- limit: 50,
741
- });
1326
+ // Find pending, requires_capture, and processing (stale from crash/restart) events
1327
+ // Process in batches of 50 to avoid loading too many records at once
1328
+ let totalProcessed = 0;
1329
+ let totalFailed = 0;
1330
+ let batchEvents: MeterEvent[];
742
1331
 
743
- const results = await Promise.allSettled(
744
- pendingEvents.map(async (event) => {
745
- const jobId = `meter-event-${event.id}`;
746
- const existingJob = await creditQueue.get(jobId);
747
- if (!existingJob) {
748
- addCreditConsumptionJob(event.id, true);
749
- }
750
- })
751
- );
1332
+ do {
1333
+ // eslint-disable-next-line no-await-in-loop
1334
+ batchEvents = await MeterEvent.findAll({
1335
+ where: {
1336
+ status: ['pending', 'requires_capture', 'processing'],
1337
+ },
1338
+ limit: 50,
1339
+ offset: totalProcessed,
1340
+ });
1341
+
1342
+ // Reset stale 'processing' events back to 'pending' — these were mid-consumption
1343
+ // when the process crashed/restarted. Without reset, both single and batch handlers
1344
+ // skip 'processing' events (line ~559, ~905), causing them to be stuck forever.
1345
+ // Only reset events that have been in 'processing' for > 5 minutes to avoid
1346
+ // interfering with events actively being processed by a concurrent handler.
1347
+ const STALE_PROCESSING_THRESHOLD = 5 * 60 * 1000; // 5 minutes
1348
+ const staleProcessing = batchEvents.filter(
1349
+ (e) => e.status === 'processing' && Date.now() - new Date(e.updated_at).getTime() > STALE_PROCESSING_THRESHOLD
1350
+ );
1351
+ if (staleProcessing.length > 0) {
1352
+ // eslint-disable-next-line no-await-in-loop
1353
+ await MeterEvent.update(
1354
+ { status: 'pending' },
1355
+ { where: { id: { [Op.in]: staleProcessing.map((e) => e.id) } } }
1356
+ );
1357
+ logger.info('Reset stale processing events', {
1358
+ count: staleProcessing.length,
1359
+ eventIds: staleProcessing.map((e) => e.id),
1360
+ });
1361
+ }
1362
+
1363
+ // eslint-disable-next-line no-await-in-loop
1364
+ const results = await Promise.allSettled(
1365
+ batchEvents.map(async (event) => {
1366
+ const jobId = `meter-event-${event.id}`;
1367
+ const existingJob = await creditQueue.get(jobId);
1368
+ if (!existingJob) {
1369
+ addCreditConsumptionJob(event.id, true);
1370
+ }
1371
+ })
1372
+ );
1373
+
1374
+ totalFailed += results.filter((r) => r.status === 'rejected').length;
1375
+ totalProcessed += batchEvents.length;
1376
+ } while (batchEvents.length === 50);
752
1377
 
753
- const failed = results.filter((r) => r.status === 'rejected').length;
754
- if (failed > 0) {
755
- logger.warn(`Failed to queue ${failed} pending meter events`);
1378
+ if (totalFailed > 0) {
1379
+ logger.warn(`Failed to queue ${totalFailed} pending meter events`);
756
1380
  }
757
1381
 
758
- logger.info(`Credit queue started, processed ${pendingEvents.length} pending events`);
1382
+ logger.info(`Credit queue started, processed ${totalProcessed} pending events`);
759
1383
  } catch (error: any) {
760
1384
  logger.error('Failed to start credit queue', { error: error.message });
761
1385
  } finally {
@@ -764,7 +1388,12 @@ export async function startCreditConsumeQueue(): Promise<void> {
764
1388
  }
765
1389
 
766
1390
  events.on('billing.meter_event.created', (meterEvent) => {
767
- addCreditConsumptionJob(meterEvent.id, true);
1391
+ const customerId = meterEvent.payload?.customer_id;
1392
+ if (customerId) {
1393
+ addToBatch(customerId, meterEvent.event_name, meterEvent.id, meterEvent.payload?.subscription_id);
1394
+ } else {
1395
+ addCreditConsumptionJob(meterEvent.id, true, { skipStatusCheck: true });
1396
+ }
768
1397
  });
769
1398
 
770
1399
  events.on('customer.credit_grant.granted', async (creditGrant: CreditGrant) => {
@@ -838,49 +1467,49 @@ async function retryFailedEventsForCustomer(creditGrant: CreditGrant): Promise<v
838
1467
 
839
1468
  let cumulativePending = new BN(0);
840
1469
  const affordableEvents: MeterEvent[] = [];
841
- const now = dayjs().unix();
842
- const BATCH_SIZE = 10;
843
- const BATCH_DELAY_SECONDS = 0.1;
844
1470
 
845
- for (let i = 0; i < sortedEvents.length; i++) {
846
- const event = sortedEvents[i];
847
- if (!event) {
1471
+ for (const event of sortedEvents) {
1472
+ const eventPending = new BN(event.credit_pending);
1473
+
1474
+ affordableEvents.push(event);
1475
+ cumulativePending = cumulativePending.add(eventPending);
1476
+ if (cumulativePending.gt(totalAvailableCredit)) {
848
1477
  break;
849
1478
  }
1479
+ }
850
1480
 
851
- const eventPending = new BN(event.credit_pending);
852
- const newCumulative = cumulativePending.add(eventPending);
1481
+ if (affordableEvents.length > 0) {
1482
+ const eventIds = affordableEvents.map((e) => e.id);
1483
+ await MeterEvent.update(
1484
+ { status: 'pending', attempt_count: 0, next_attempt: undefined },
1485
+ { where: { id: { [Op.in]: eventIds } } }
1486
+ );
853
1487
 
854
- if (newCumulative.gt(totalAvailableCredit)) {
855
- if (eventPending.gt(totalAvailableCredit)) {
856
- break;
1488
+ // Group by subscription_id before pushing batch jobs, because the batch handler
1489
+ // reads subscription from the first event only (line ~910) and applies its priceIds
1490
+ // to all events in the batch.
1491
+ const bySubscription = new Map<string, string[]>();
1492
+ for (const event of affordableEvents) {
1493
+ const subKey = event.getSubscriptionId() || 'default';
1494
+ const group = bySubscription.get(subKey);
1495
+ if (group) {
1496
+ group.push(event.id);
1497
+ } else {
1498
+ bySubscription.set(subKey, [event.id]);
857
1499
  }
858
1500
  }
859
1501
 
860
- // eslint-disable-next-line no-await-in-loop
861
- await event.update({
862
- status: 'pending',
863
- attempt_count: 0,
864
- next_attempt: undefined,
865
- });
866
-
867
- const batchIndex = Math.floor(i / BATCH_SIZE);
868
- const runAt = now + batchIndex * BATCH_DELAY_SECONDS;
869
-
870
- try {
871
- // eslint-disable-next-line no-await-in-loop
872
- await addCreditConsumptionJob(event.id, true, { runAt });
873
- } catch (jobError: any) {
874
- logger.error('Failed to add credit consumption job after status update', {
875
- eventId: event.id,
876
- customerId,
877
- currencyId,
878
- error: jobError.message,
879
- });
1502
+ let chunkIndex = 0;
1503
+ for (const [, subEventIds] of bySubscription) {
1504
+ for (let i = 0; i < subEventIds.length; i += CREDIT_BATCH_SIZE) {
1505
+ const chunk = subEventIds.slice(i, i + CREDIT_BATCH_SIZE);
1506
+ creditQueue.push({
1507
+ id: `retry-batch-${customerId}-${Date.now()}-${chunkIndex++}`,
1508
+ job: { meterEventIds: chunk } as any,
1509
+ skipDuplicateCheck: true,
1510
+ });
1511
+ }
880
1512
  }
881
-
882
- affordableEvents.push(event);
883
- cumulativePending = newCumulative;
884
1513
  }
885
1514
 
886
1515
  const skippedEvents = failedEvents.length - affordableEvents.length;