payment-kit 1.23.9 → 1.23.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api/src/libs/invoice.ts +9 -2
- package/api/src/queues/auto-recharge.ts +35 -0
- package/api/src/queues/credit-consume.ts +105 -33
- package/api/src/routes/auto-recharge-configs.ts +11 -0
- package/api/src/routes/credit-grants.ts +84 -1
- package/api/src/routes/meter-events.ts +0 -1
- package/api/src/store/models/meter-event.ts +1 -1
- package/blocklet.yml +1 -1
- package/package.json +6 -6
package/api/src/libs/invoice.ts
CHANGED
|
@@ -1121,20 +1121,27 @@ export async function retryUncollectibleInvoices(options: {
|
|
|
1121
1121
|
failed: [] as Array<{ id: string; reason: string }>,
|
|
1122
1122
|
};
|
|
1123
1123
|
|
|
1124
|
+
const now = dayjs().unix();
|
|
1125
|
+
const BATCH_SIZE = 10;
|
|
1126
|
+
const BATCH_DELAY_SECONDS = 0.1;
|
|
1127
|
+
|
|
1124
1128
|
const settledResults = await Promise.allSettled(
|
|
1125
1129
|
overdueInvoices.map(async (invoice, index) => {
|
|
1126
1130
|
const { paymentIntent } = invoice;
|
|
1127
|
-
const delay = index * 2;
|
|
1128
1131
|
if (!paymentIntent) {
|
|
1129
1132
|
throw new Error('No payment intent found');
|
|
1130
1133
|
}
|
|
1131
1134
|
|
|
1132
1135
|
await paymentIntent.update({ status: 'requires_capture' });
|
|
1136
|
+
|
|
1137
|
+
const batchIndex = Math.floor(index / BATCH_SIZE);
|
|
1138
|
+
const runAt = now + batchIndex * BATCH_DELAY_SECONDS;
|
|
1139
|
+
|
|
1133
1140
|
await emitAsync(
|
|
1134
1141
|
'payment.queued',
|
|
1135
1142
|
paymentIntent.id,
|
|
1136
1143
|
{ paymentIntentId: paymentIntent.id, retryOnError: true, ignoreMaxRetryCheck: true },
|
|
1137
|
-
{ sync: false,
|
|
1144
|
+
{ sync: false, runAt }
|
|
1138
1145
|
);
|
|
1139
1146
|
|
|
1140
1147
|
return invoice;
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
CreditGrant,
|
|
8
8
|
Customer,
|
|
9
9
|
Invoice,
|
|
10
|
+
Meter,
|
|
10
11
|
PaymentCurrency,
|
|
11
12
|
PaymentMethod,
|
|
12
13
|
Price,
|
|
@@ -51,6 +52,22 @@ export async function processAutoRecharge(job: AutoRechargeJobData) {
|
|
|
51
52
|
return;
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
// Check if the associated meter is inactive
|
|
56
|
+
if (currency.type === 'credit') {
|
|
57
|
+
// Find meter by currency_id (meter.currency_id -> PaymentCurrency) or by metadata.meter_id
|
|
58
|
+
const meter = await Meter.findOne({
|
|
59
|
+
where: { currency_id: currencyId },
|
|
60
|
+
});
|
|
61
|
+
if (meter && meter.status === 'inactive') {
|
|
62
|
+
logger.info('Meter is inactive, skipping auto recharge', {
|
|
63
|
+
customerId,
|
|
64
|
+
currencyId,
|
|
65
|
+
meterId: meter.id,
|
|
66
|
+
});
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
54
71
|
// 1. find auto recharge config
|
|
55
72
|
const config = (await AutoRechargeConfig.findOne({
|
|
56
73
|
where: {
|
|
@@ -302,6 +319,24 @@ export async function checkAndTriggerAutoRecharge(
|
|
|
302
319
|
currencyId,
|
|
303
320
|
currentBalance,
|
|
304
321
|
});
|
|
322
|
+
|
|
323
|
+
// Check if the associated meter is inactive
|
|
324
|
+
const currency = await PaymentCurrency.findByPk(currencyId);
|
|
325
|
+
if (currency?.type === 'credit') {
|
|
326
|
+
// Find meter by currency_id (meter.currency_id -> PaymentCurrency)
|
|
327
|
+
const meter = await Meter.findOne({
|
|
328
|
+
where: { currency_id: currencyId },
|
|
329
|
+
});
|
|
330
|
+
if (meter && meter.status === 'inactive') {
|
|
331
|
+
logger.info('Meter is inactive, skipping auto recharge check', {
|
|
332
|
+
customerId: customer.id,
|
|
333
|
+
currencyId,
|
|
334
|
+
meterId: meter.id,
|
|
335
|
+
});
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
305
340
|
const config = await AutoRechargeConfig.findOne({
|
|
306
341
|
where: {
|
|
307
342
|
customer_id: customer.id,
|
|
@@ -2,6 +2,7 @@ import { BN, fromUnitToToken } from '@ocap/util';
|
|
|
2
2
|
|
|
3
3
|
import { getLock } from '../libs/lock';
|
|
4
4
|
import logger from '../libs/logger';
|
|
5
|
+
import dayjs from '../libs/dayjs';
|
|
5
6
|
import createQueue from '../libs/queue';
|
|
6
7
|
import { createEvent } from '../libs/audit';
|
|
7
8
|
import {
|
|
@@ -551,7 +552,7 @@ export async function handleCreditConsumption(job: CreditConsumptionJob) {
|
|
|
551
552
|
});
|
|
552
553
|
await context.meterEvent.markAsCompleted();
|
|
553
554
|
if (context.subscription && context.subscription.status === 'past_due') {
|
|
554
|
-
handlePastDueSubscriptionRecovery(context.subscription, null);
|
|
555
|
+
await handlePastDueSubscriptionRecovery(context.subscription, null);
|
|
555
556
|
}
|
|
556
557
|
} else {
|
|
557
558
|
logger.warn('Credit consumption partially completed - insufficient balance', {
|
|
@@ -622,6 +623,7 @@ export const creditQueue = createQueue<CreditConsumptionJob>({
|
|
|
622
623
|
options: {
|
|
623
624
|
concurrency: 1,
|
|
624
625
|
maxRetries: 0,
|
|
626
|
+
enableScheduledJob: true,
|
|
625
627
|
},
|
|
626
628
|
});
|
|
627
629
|
|
|
@@ -755,58 +757,128 @@ events.on('customer.credit_grant.granted', async (creditGrant: CreditGrant) => {
|
|
|
755
757
|
});
|
|
756
758
|
|
|
757
759
|
async function retryFailedEventsForCustomer(creditGrant: CreditGrant): Promise<void> {
|
|
760
|
+
const grant = await CreditGrant.findByPk(creditGrant.id);
|
|
761
|
+
if (!grant) {
|
|
762
|
+
logger.error('Credit grant not found', {
|
|
763
|
+
creditGrantId: creditGrant.id,
|
|
764
|
+
});
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const customerId = grant.customer_id;
|
|
769
|
+
const currencyId = grant.currency_id;
|
|
770
|
+
const lock = getLock(`retry-failed-events-${customerId}-${currencyId}`);
|
|
771
|
+
|
|
758
772
|
try {
|
|
773
|
+
await lock.acquire();
|
|
774
|
+
|
|
759
775
|
logger.info('Retrying failed events for customer', {
|
|
760
|
-
customerId
|
|
761
|
-
currencyId
|
|
762
|
-
livemode:
|
|
776
|
+
customerId,
|
|
777
|
+
currencyId,
|
|
778
|
+
livemode: grant.livemode,
|
|
779
|
+
grantAmount: grant.amount,
|
|
780
|
+
grantRemaining: grant.remaining_amount,
|
|
763
781
|
});
|
|
782
|
+
|
|
783
|
+
const availableGrants = await CreditGrant.getAvailableCreditsForCustomer(customerId, currencyId);
|
|
784
|
+
const totalAvailableCredit = availableGrants.reduce((sum, g) => sum.add(new BN(g.remaining_amount)), new BN(0));
|
|
785
|
+
|
|
764
786
|
const [, , failedEvents] = await MeterEvent.getPendingAmounts({
|
|
765
|
-
customerId
|
|
766
|
-
currencyId
|
|
787
|
+
customerId,
|
|
788
|
+
currencyId,
|
|
767
789
|
status: ['requires_action', 'requires_capture'],
|
|
768
|
-
livemode:
|
|
790
|
+
livemode: grant.livemode,
|
|
769
791
|
});
|
|
770
792
|
|
|
771
793
|
if (failedEvents.length === 0) {
|
|
772
794
|
logger.debug('No failed events with pending credit found', {
|
|
773
|
-
customerId
|
|
774
|
-
currencyId
|
|
795
|
+
customerId,
|
|
796
|
+
currencyId,
|
|
775
797
|
});
|
|
776
798
|
return;
|
|
777
799
|
}
|
|
778
800
|
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
801
|
+
if (totalAvailableCredit.lte(new BN(0))) {
|
|
802
|
+
logger.debug('No available credit to retry failed events', {
|
|
803
|
+
customerId,
|
|
804
|
+
currencyId,
|
|
805
|
+
totalAvailableCredit: totalAvailableCredit.toString(),
|
|
806
|
+
});
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const sortedEvents = failedEvents.sort((a, b) => {
|
|
811
|
+
const aPending = new BN(a.credit_pending);
|
|
812
|
+
const bPending = new BN(b.credit_pending);
|
|
813
|
+
return aPending.cmp(bPending);
|
|
783
814
|
});
|
|
784
815
|
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
next_attempt: undefined,
|
|
791
|
-
})
|
|
792
|
-
)
|
|
793
|
-
);
|
|
816
|
+
let cumulativePending = new BN(0);
|
|
817
|
+
const affordableEvents: MeterEvent[] = [];
|
|
818
|
+
const now = dayjs().unix();
|
|
819
|
+
const BATCH_SIZE = 10;
|
|
820
|
+
const BATCH_DELAY_SECONDS = 0.1;
|
|
794
821
|
|
|
795
|
-
|
|
796
|
-
const
|
|
797
|
-
|
|
798
|
-
|
|
822
|
+
for (let i = 0; i < sortedEvents.length; i++) {
|
|
823
|
+
const event = sortedEvents[i];
|
|
824
|
+
if (!event) {
|
|
825
|
+
break;
|
|
826
|
+
}
|
|
799
827
|
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
828
|
+
const eventPending = new BN(event.credit_pending);
|
|
829
|
+
const newCumulative = cumulativePending.add(eventPending);
|
|
830
|
+
|
|
831
|
+
if (newCumulative.gt(totalAvailableCredit)) {
|
|
832
|
+
if (eventPending.gt(totalAvailableCredit)) {
|
|
833
|
+
break;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// eslint-disable-next-line no-await-in-loop
|
|
838
|
+
await event.update({
|
|
839
|
+
status: 'pending',
|
|
840
|
+
attempt_count: 0,
|
|
841
|
+
next_attempt: undefined,
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
const batchIndex = Math.floor(i / BATCH_SIZE);
|
|
845
|
+
const runAt = now + batchIndex * BATCH_DELAY_SECONDS;
|
|
846
|
+
|
|
847
|
+
try {
|
|
848
|
+
// eslint-disable-next-line no-await-in-loop
|
|
849
|
+
await addCreditConsumptionJob(event.id, true, { runAt });
|
|
850
|
+
} catch (jobError: any) {
|
|
851
|
+
logger.error('Failed to add credit consumption job after status update', {
|
|
852
|
+
eventId: event.id,
|
|
853
|
+
customerId,
|
|
854
|
+
currencyId,
|
|
855
|
+
error: jobError.message,
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
affordableEvents.push(event);
|
|
860
|
+
cumulativePending = newCumulative;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const skippedEvents = failedEvents.length - affordableEvents.length;
|
|
864
|
+
|
|
865
|
+
logger.info('Completed retrying failed events', {
|
|
866
|
+
customerId,
|
|
867
|
+
currencyId,
|
|
868
|
+
totalEvents: failedEvents.length,
|
|
869
|
+
affordableEvents: affordableEvents.length,
|
|
870
|
+
skippedEvents,
|
|
871
|
+
totalAvailableCredit: totalAvailableCredit.toString(),
|
|
872
|
+
totalAffordablePending: cumulativePending.toString(),
|
|
873
|
+
grantCount: availableGrants.length,
|
|
804
874
|
});
|
|
805
875
|
} catch (error: any) {
|
|
806
|
-
logger.error('Failed to
|
|
807
|
-
customerId:
|
|
808
|
-
currencyId:
|
|
876
|
+
logger.error('Failed to retry failed events for customer', {
|
|
877
|
+
customerId: grant.customer_id,
|
|
878
|
+
currencyId: grant.currency_id,
|
|
809
879
|
error: error.message,
|
|
810
880
|
});
|
|
881
|
+
} finally {
|
|
882
|
+
lock.release();
|
|
811
883
|
}
|
|
812
884
|
}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
AutoRechargeConfig,
|
|
11
11
|
Customer,
|
|
12
12
|
EVMChainType,
|
|
13
|
+
Meter,
|
|
13
14
|
PaymentCurrency,
|
|
14
15
|
PaymentMethod,
|
|
15
16
|
Price,
|
|
@@ -339,6 +340,16 @@ router.post('/submit', async (req, res) => {
|
|
|
339
340
|
throw new CustomError(400, `Currency not found: ${value.currency_id}`);
|
|
340
341
|
}
|
|
341
342
|
|
|
343
|
+
// Check if the associated meter is active when enabling auto-recharge
|
|
344
|
+
if (configData.enabled && currency.type === 'credit') {
|
|
345
|
+
const meter = await Meter.findOne({
|
|
346
|
+
where: { currency_id: value.currency_id },
|
|
347
|
+
});
|
|
348
|
+
if (meter && meter.status === 'inactive') {
|
|
349
|
+
throw new CustomError(400, 'Cannot enable auto top-up: the associated meter is inactive');
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
342
353
|
if (currency.recharge_config?.base_price_id && currency.recharge_config?.base_price_id !== value.price_id) {
|
|
343
354
|
throw new CustomError(400, 'Price is not the base price');
|
|
344
355
|
}
|
|
@@ -2,7 +2,7 @@ import { Router } from 'express';
|
|
|
2
2
|
import Joi from 'joi';
|
|
3
3
|
import { BN, fromTokenToUnit } from '@ocap/util';
|
|
4
4
|
|
|
5
|
-
import { literal, OrderItem } from 'sequelize';
|
|
5
|
+
import { literal, OrderItem, fn, col, Op } from 'sequelize';
|
|
6
6
|
import pick from 'lodash/pick';
|
|
7
7
|
import { createListParamSchema, getOrder, getWhereFromKvQuery, MetadataSchema } from '../libs/api';
|
|
8
8
|
import logger from '../libs/logger';
|
|
@@ -165,6 +165,89 @@ router.get('/summary', authMine, async (req, res) => {
|
|
|
165
165
|
}
|
|
166
166
|
});
|
|
167
167
|
|
|
168
|
+
const holdersSchema = Joi.object({
|
|
169
|
+
currency_id: Joi.string().required(),
|
|
170
|
+
page: Joi.number().integer().min(1).default(1),
|
|
171
|
+
pageSize: Joi.number().integer().min(0).optional(), // 0 or undefined = return all
|
|
172
|
+
livemode: Joi.boolean().optional(),
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// Get all holders (customers with balance) for a specific credit currency
|
|
176
|
+
router.get('/holders', auth, async (req, res) => {
|
|
177
|
+
try {
|
|
178
|
+
const { error, value } = holdersSchema.validate(req.query, { stripUnknown: true });
|
|
179
|
+
if (error) {
|
|
180
|
+
return res.status(400).json({ error: error.message });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const { currency_id: currencyId, page, pageSize, livemode } = value;
|
|
184
|
+
|
|
185
|
+
const currency = await PaymentCurrency.findByPk(currencyId);
|
|
186
|
+
if (!currency) {
|
|
187
|
+
return res.status(404).json({ error: `PaymentCurrency ${currencyId} not found` });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (currency.type !== 'credit') {
|
|
191
|
+
return res.status(400).json({ error: 'Currency must be of type credit' });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Build where clause for credit grants
|
|
195
|
+
const grantWhere: any = {
|
|
196
|
+
currency_id: currencyId,
|
|
197
|
+
status: { [Op.in]: ['granted', 'pending'] }, // Only active grants
|
|
198
|
+
};
|
|
199
|
+
if (typeof livemode === 'boolean') {
|
|
200
|
+
grantWhere.livemode = livemode;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Use database aggregation - only customer_id, no JOIN needed
|
|
204
|
+
const aggregatedData = (await CreditGrant.findAll({
|
|
205
|
+
where: grantWhere,
|
|
206
|
+
attributes: [
|
|
207
|
+
'customer_id',
|
|
208
|
+
[fn('COUNT', col('id')), 'grantCount'],
|
|
209
|
+
[fn('SUM', literal('CAST(remaining_amount AS DECIMAL(40,0))')), 'totalBalance'],
|
|
210
|
+
],
|
|
211
|
+
group: ['customer_id'], // Only group by customer_id - much faster!
|
|
212
|
+
order: [[literal('totalBalance'), 'DESC']],
|
|
213
|
+
raw: true,
|
|
214
|
+
})) as any[];
|
|
215
|
+
|
|
216
|
+
const totalCount = aggregatedData.length;
|
|
217
|
+
|
|
218
|
+
// Paginate (pageSize = 0 or undefined means return all)
|
|
219
|
+
const shouldPaginate = pageSize && pageSize > 0;
|
|
220
|
+
const paginatedData = shouldPaginate
|
|
221
|
+
? aggregatedData.slice((page - 1) * pageSize, page * pageSize)
|
|
222
|
+
: aggregatedData;
|
|
223
|
+
|
|
224
|
+
const holders = paginatedData.map((item) => ({
|
|
225
|
+
customer_id: item.customer_id,
|
|
226
|
+
balance: (item.totalBalance || '0').toString(),
|
|
227
|
+
grantCount: parseInt(item.grantCount || '0', 10),
|
|
228
|
+
}));
|
|
229
|
+
|
|
230
|
+
return res.json({
|
|
231
|
+
holders,
|
|
232
|
+
currency: {
|
|
233
|
+
id: currency.id,
|
|
234
|
+
name: currency.name,
|
|
235
|
+
symbol: currency.symbol,
|
|
236
|
+
decimal: currency.decimal,
|
|
237
|
+
},
|
|
238
|
+
paging: {
|
|
239
|
+
page: shouldPaginate ? page : 1,
|
|
240
|
+
pageSize: shouldPaginate ? pageSize : totalCount,
|
|
241
|
+
total: totalCount,
|
|
242
|
+
totalPages: shouldPaginate ? Math.ceil(totalCount / pageSize) : 1,
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
} catch (err: any) {
|
|
246
|
+
logger.error('Error getting credit holders', { error: err.message });
|
|
247
|
+
return res.status(400).json({ error: err.message });
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
168
251
|
const checkAutoRechargeSchema = Joi.object({
|
|
169
252
|
customer_id: Joi.string().required(),
|
|
170
253
|
currency_id: Joi.string().required(),
|
|
@@ -327,7 +327,6 @@ router.post('/', auth, async (req, res) => {
|
|
|
327
327
|
router.get('/pending-amount', authMine, async (req, res) => {
|
|
328
328
|
try {
|
|
329
329
|
const params: any = {
|
|
330
|
-
status: ['requires_action', 'requires_capture'],
|
|
331
330
|
livemode: !!req.livemode,
|
|
332
331
|
};
|
|
333
332
|
if (req.query.subscription_id) {
|
package/blocklet.yml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "payment-kit",
|
|
3
|
-
"version": "1.23.
|
|
3
|
+
"version": "1.23.11",
|
|
4
4
|
"scripts": {
|
|
5
5
|
"dev": "blocklet dev --open",
|
|
6
6
|
"prelint": "npm run types",
|
|
@@ -59,9 +59,9 @@
|
|
|
59
59
|
"@blocklet/error": "^0.3.5",
|
|
60
60
|
"@blocklet/js-sdk": "^1.17.8-beta-20260104-120132-cb5b1914",
|
|
61
61
|
"@blocklet/logger": "^1.17.8-beta-20260104-120132-cb5b1914",
|
|
62
|
-
"@blocklet/payment-broker-client": "1.23.
|
|
63
|
-
"@blocklet/payment-react": "1.23.
|
|
64
|
-
"@blocklet/payment-vendor": "1.23.
|
|
62
|
+
"@blocklet/payment-broker-client": "1.23.11",
|
|
63
|
+
"@blocklet/payment-react": "1.23.11",
|
|
64
|
+
"@blocklet/payment-vendor": "1.23.11",
|
|
65
65
|
"@blocklet/sdk": "^1.17.8-beta-20260104-120132-cb5b1914",
|
|
66
66
|
"@blocklet/ui-react": "^3.3.10",
|
|
67
67
|
"@blocklet/uploader": "^0.3.19",
|
|
@@ -131,7 +131,7 @@
|
|
|
131
131
|
"devDependencies": {
|
|
132
132
|
"@abtnode/types": "^1.17.8-beta-20260104-120132-cb5b1914",
|
|
133
133
|
"@arcblock/eslint-config-ts": "^0.3.3",
|
|
134
|
-
"@blocklet/payment-types": "1.23.
|
|
134
|
+
"@blocklet/payment-types": "1.23.11",
|
|
135
135
|
"@types/cookie-parser": "^1.4.9",
|
|
136
136
|
"@types/cors": "^2.8.19",
|
|
137
137
|
"@types/debug": "^4.1.12",
|
|
@@ -178,5 +178,5 @@
|
|
|
178
178
|
"parser": "typescript"
|
|
179
179
|
}
|
|
180
180
|
},
|
|
181
|
-
"gitHead": "
|
|
181
|
+
"gitHead": "72af80fc6e91e88058665212985002966f55787c"
|
|
182
182
|
}
|