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.
- package/api/src/libs/payment.ts +113 -22
- package/api/src/libs/queue/index.ts +20 -9
- package/api/src/libs/queue/store.ts +11 -7
- package/api/src/libs/reference-cache.ts +115 -0
- package/api/src/queues/auto-recharge.ts +68 -21
- package/api/src/queues/credit-consume.ts +835 -206
- package/api/src/routes/checkout-sessions.ts +78 -1
- package/api/src/routes/customers.ts +15 -3
- package/api/src/routes/donations.ts +4 -4
- package/api/src/routes/index.ts +37 -8
- package/api/src/routes/invoices.ts +14 -3
- package/api/src/routes/meter-events.ts +41 -15
- package/api/src/routes/payment-links.ts +2 -2
- package/api/src/routes/prices.ts +1 -1
- package/api/src/routes/pricing-table.ts +3 -2
- package/api/src/routes/products.ts +2 -2
- package/api/src/routes/subscription-items.ts +12 -3
- package/api/src/routes/subscriptions.ts +27 -9
- package/api/src/store/migrations/20260306-checkout-session-indexes.ts +23 -0
- package/api/src/store/models/checkout-session.ts +3 -2
- package/api/src/store/models/coupon.ts +9 -6
- package/api/src/store/models/credit-grant.ts +4 -1
- package/api/src/store/models/credit-transaction.ts +3 -2
- package/api/src/store/models/customer.ts +9 -6
- package/api/src/store/models/exchange-rate-provider.ts +9 -6
- package/api/src/store/models/invoice.ts +3 -2
- package/api/src/store/models/meter-event.ts +6 -4
- package/api/src/store/models/meter.ts +9 -6
- package/api/src/store/models/payment-intent.ts +9 -6
- package/api/src/store/models/payment-link.ts +9 -6
- package/api/src/store/models/payout.ts +3 -2
- package/api/src/store/models/price.ts +9 -6
- package/api/src/store/models/pricing-table.ts +9 -6
- package/api/src/store/models/product.ts +9 -6
- package/api/src/store/models/promotion-code.ts +9 -6
- package/api/src/store/models/refund.ts +9 -6
- package/api/src/store/models/setup-intent.ts +6 -4
- package/api/src/store/sequelize.ts +8 -3
- package/api/tests/queues/credit-consume-batch.spec.ts +438 -0
- package/api/tests/queues/credit-consume.spec.ts +505 -0
- package/api/third.d.ts +1 -1
- package/blocklet.yml +1 -1
- package/package.json +8 -7
- package/scripts/benchmark-seed.js +247 -0
- package/src/components/customer/credit-overview.tsx +31 -42
- package/src/components/invoice-pdf/template.tsx +5 -4
- package/src/components/payment-link/actions.tsx +45 -0
- package/src/components/payment-link/before-pay.tsx +24 -0
- package/src/components/subscription/payment-method-info.tsx +23 -6
- package/src/components/subscription/portal/actions.tsx +2 -0
- package/src/locales/en.tsx +11 -0
- package/src/locales/zh.tsx +10 -0
- package/src/pages/admin/products/links/detail.tsx +8 -0
- 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
|
-
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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 =
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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 =
|
|
211
|
+
const pendingAmount = finalPending.lte(new BN(0)) ? '0' : finalPending.toString();
|
|
252
212
|
const remainingBalance = totalAvailable.sub(consumed).toString();
|
|
253
213
|
|
|
254
|
-
//
|
|
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
|
-
|
|
257
|
+
requiredAmount: remainingToConsume.toString(),
|
|
263
258
|
availableAmount: totalAvailable.toString(),
|
|
264
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
496
|
-
const
|
|
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
|
-
|
|
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:
|
|
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:
|
|
562
|
+
status: freshEvent.status,
|
|
523
563
|
});
|
|
524
564
|
return;
|
|
525
565
|
}
|
|
526
566
|
|
|
527
567
|
// Mark as processing to prevent duplicate processing
|
|
528
|
-
await
|
|
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
|
-
|
|
578
|
+
const consumeStart = performance.now();
|
|
540
579
|
const consumptionResult = await consumeAvailableCredits(context, totalRequiredAmount);
|
|
580
|
+
const consumeTime = performance.now() - consumeStart;
|
|
541
581
|
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
consumptionResult.
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
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
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
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
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
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
|
-
|
|
754
|
-
|
|
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 ${
|
|
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
|
-
|
|
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 (
|
|
846
|
-
const
|
|
847
|
-
|
|
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
|
-
|
|
852
|
-
const
|
|
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
|
-
|
|
855
|
-
|
|
856
|
-
|
|
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
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
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;
|