payment-kit 1.26.5 → 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 +1 -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/api/src/libs/payment.ts
CHANGED
|
@@ -36,6 +36,60 @@ import { CHARGE_SUPPORTED_CHAIN_TYPES, EVM_CHAIN_TYPES } from './constants';
|
|
|
36
36
|
import { getTokenByAddress } from '../integrations/arcblock/stake';
|
|
37
37
|
import { isCreditMetered } from './credit-utils';
|
|
38
38
|
|
|
39
|
+
// ============================================================================
|
|
40
|
+
// Delegation cache - avoids redundant chain queries for the same user
|
|
41
|
+
// ============================================================================
|
|
42
|
+
const DELEGATION_CACHE_TTL_MS = 30_000; // 30 seconds
|
|
43
|
+
|
|
44
|
+
// Only cache immutable lookup results (address, source), NOT DelegateState.
|
|
45
|
+
// DelegateState contains on-chain token balances that change with every consumption;
|
|
46
|
+
// caching it could allow stale balances to pass sufficiency checks, causing overdraft.
|
|
47
|
+
interface DelegationCacheEntry {
|
|
48
|
+
delegator: string;
|
|
49
|
+
delegationAddress: string;
|
|
50
|
+
source: string;
|
|
51
|
+
needsBackfill: boolean;
|
|
52
|
+
cachedAt: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const delegationCache = new Map<string, DelegationCacheEntry>();
|
|
56
|
+
|
|
57
|
+
function getDelegationCacheKey(userDid: string, paymentMethodId: string): string {
|
|
58
|
+
return `${userDid}:${paymentMethodId}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function getCachedDelegation(key: string): DelegationCacheEntry | null {
|
|
62
|
+
const entry = delegationCache.get(key);
|
|
63
|
+
if (!entry) return null;
|
|
64
|
+
if (Date.now() - entry.cachedAt > DELEGATION_CACHE_TTL_MS) {
|
|
65
|
+
delegationCache.delete(key);
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
return entry;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function setCachedDelegation(key: string, entry: Omit<DelegationCacheEntry, 'cachedAt'>): void {
|
|
72
|
+
// Evict old entries if cache grows too large
|
|
73
|
+
if (delegationCache.size > 1000) {
|
|
74
|
+
const now = Date.now();
|
|
75
|
+
for (const [k, v] of delegationCache) {
|
|
76
|
+
if (now - v.cachedAt > DELEGATION_CACHE_TTL_MS) {
|
|
77
|
+
delegationCache.delete(k);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
delegationCache.set(key, { ...entry, cachedAt: Date.now() });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Invalidate cache for a specific user (e.g., after delegation changes)
|
|
85
|
+
export function invalidateDelegationCache(userDid: string): void {
|
|
86
|
+
for (const key of delegationCache.keys()) {
|
|
87
|
+
if (key.startsWith(`${userDid}:`)) {
|
|
88
|
+
delegationCache.delete(key);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
39
93
|
export interface SufficientForPaymentResult {
|
|
40
94
|
sufficient: boolean;
|
|
41
95
|
reason?: LiteralUnion<
|
|
@@ -263,39 +317,76 @@ export async function isDelegationSufficientForPayment(args: {
|
|
|
263
317
|
});
|
|
264
318
|
|
|
265
319
|
const client = paymentMethod.getOcapClient();
|
|
320
|
+
const cacheKey = getDelegationCacheKey(delegator, paymentMethod.id);
|
|
321
|
+
const cached = getCachedDelegation(cacheKey);
|
|
266
322
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
logger.error('isDelegationSufficientForPayment: no delegation address found', {
|
|
323
|
+
let delegationAddress: string;
|
|
324
|
+
let source: string;
|
|
325
|
+
|
|
326
|
+
if (cached) {
|
|
327
|
+
// Use cached delegation address (immutable), but always query fresh state
|
|
328
|
+
delegationAddress = cached.delegationAddress;
|
|
329
|
+
source = cached.source;
|
|
330
|
+
logger.info('isDelegationSufficientForPayment: using cached delegation address', {
|
|
276
331
|
delegator,
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
332
|
+
delegationAddress,
|
|
333
|
+
source,
|
|
334
|
+
cacheAgeMs: Date.now() - cached.cachedAt,
|
|
280
335
|
});
|
|
281
|
-
|
|
282
|
-
|
|
336
|
+
} else {
|
|
337
|
+
// have delegated before? Use migration-aware fallback query
|
|
338
|
+
// Priority: storedDelegationAddress > subscription.payment_details.arcblock.delegation_address
|
|
339
|
+
const delegationResult = await getDelegationAddressWithFallback({
|
|
340
|
+
storedAddress: storedDelegationAddress || subscription?.payment_details?.arcblock?.delegation_address,
|
|
341
|
+
delegator,
|
|
342
|
+
client,
|
|
343
|
+
});
|
|
344
|
+
if (!delegationResult) {
|
|
345
|
+
logger.error('isDelegationSufficientForPayment: no delegation address found', {
|
|
346
|
+
delegator,
|
|
347
|
+
storedDelegationAddress,
|
|
348
|
+
subscriptionDelegationAddress: subscription?.payment_details?.arcblock?.delegation_address,
|
|
349
|
+
subscriptionId: subscription?.id,
|
|
350
|
+
});
|
|
351
|
+
return { sufficient: false, reason: 'NO_DELEGATION' };
|
|
352
|
+
}
|
|
283
353
|
|
|
284
|
-
|
|
354
|
+
const { address, needsBackfill } = delegationResult;
|
|
355
|
+
delegationAddress = address;
|
|
356
|
+
source = delegationResult.source;
|
|
357
|
+
|
|
358
|
+
// Backfill if needed and subscription is available
|
|
359
|
+
if (needsBackfill && subscription) {
|
|
360
|
+
await backfillDelegationAddress(subscription.id, address);
|
|
361
|
+
}
|
|
285
362
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
363
|
+
// Cache the immutable lookup result (address + source), not the state
|
|
364
|
+
setCachedDelegation(cacheKey, {
|
|
365
|
+
delegator,
|
|
366
|
+
delegationAddress: address,
|
|
367
|
+
source,
|
|
368
|
+
needsBackfill,
|
|
369
|
+
});
|
|
289
370
|
}
|
|
290
371
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
372
|
+
// Always query fresh DelegateState — balances change with every consumption
|
|
373
|
+
const delegateResult = await client.getDelegateState({ address: delegationAddress });
|
|
374
|
+
if (!delegateResult.state) {
|
|
375
|
+
logger.error('isDelegationSufficientForPayment: no delegation state', {
|
|
376
|
+
address: delegationAddress,
|
|
377
|
+
delegator,
|
|
378
|
+
source,
|
|
379
|
+
});
|
|
294
380
|
return { sufficient: false, reason: 'NO_DELEGATION' };
|
|
295
381
|
}
|
|
382
|
+
const { state } = delegateResult;
|
|
296
383
|
|
|
297
384
|
if (!state.ops || state.ops?.length === 0) {
|
|
298
|
-
logger.error('isDelegationSufficientForPayment: no delegation ops', {
|
|
385
|
+
logger.error('isDelegationSufficientForPayment: no delegation ops', {
|
|
386
|
+
address: delegationAddress,
|
|
387
|
+
delegator,
|
|
388
|
+
source,
|
|
389
|
+
});
|
|
299
390
|
return { sufficient: false, reason: 'NO_DELEGATION' };
|
|
300
391
|
}
|
|
301
392
|
|
|
@@ -45,6 +45,7 @@ type PushParams<T> = {
|
|
|
45
45
|
persist?: boolean;
|
|
46
46
|
delay?: number; // in seconds
|
|
47
47
|
runAt?: number; // unix timestamp in seconds
|
|
48
|
+
skipDuplicateCheck?: boolean; // Q1: skip addJob's findOne when caller guarantees no duplicate
|
|
48
49
|
};
|
|
49
50
|
|
|
50
51
|
export default function createQueue<T = any>({ name, onJob, options = defaults }: QueueParams<T>) {
|
|
@@ -107,7 +108,7 @@ export default function createQueue<T = any>({ name, onJob, options = defaults }
|
|
|
107
108
|
// @ts-ignore
|
|
108
109
|
}, concurrency);
|
|
109
110
|
|
|
110
|
-
const push = ({ job, id, persist = true, delay, runAt }: PushParams<T>) => {
|
|
111
|
+
const push = ({ job, id, persist = true, delay, runAt, skipDuplicateCheck = false }: PushParams<T>) => {
|
|
111
112
|
const jobEvents = new EventEmitter();
|
|
112
113
|
const emit = (e: string, data: any) => {
|
|
113
114
|
queueEvents.emit(e, data);
|
|
@@ -142,7 +143,7 @@ export default function createQueue<T = any>({ name, onJob, options = defaults }
|
|
|
142
143
|
}
|
|
143
144
|
|
|
144
145
|
store
|
|
145
|
-
.addJob(jobId, job, attrs)
|
|
146
|
+
.addJob(jobId, job, attrs, skipDuplicateCheck)
|
|
146
147
|
.then(() => {
|
|
147
148
|
emit('queued', { id: jobId, job, attrs, persist });
|
|
148
149
|
logger.info('delayed or scheduled job queued', { id: jobId, job, attrs, persist });
|
|
@@ -222,7 +223,7 @@ export default function createQueue<T = any>({ name, onJob, options = defaults }
|
|
|
222
223
|
|
|
223
224
|
if (persist) {
|
|
224
225
|
store
|
|
225
|
-
.addJob(jobId, job)
|
|
226
|
+
.addJob(jobId, job, {}, skipDuplicateCheck)
|
|
226
227
|
.then(queueJob)
|
|
227
228
|
.catch((err) => {
|
|
228
229
|
logger.error('Can not add job to store', { error: err });
|
|
@@ -263,18 +264,28 @@ export default function createQueue<T = any>({ name, onJob, options = defaults }
|
|
|
263
264
|
return doc ? doc.job : null;
|
|
264
265
|
};
|
|
265
266
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
267
|
+
// Q1: Accept knownExists flag to skip redundant getJob read when caller already checked
|
|
268
|
+
const deleteJob = async (id: string, knownExists: boolean = false): Promise<boolean> => {
|
|
269
|
+
if (!knownExists) {
|
|
270
|
+
const exists = await getJob(id);
|
|
271
|
+
if (!exists) {
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
271
274
|
}
|
|
272
275
|
try {
|
|
273
|
-
|
|
276
|
+
// Q1: Try direct delete first (1 DB op). If delete fails, fall back to cancel
|
|
277
|
+
// so the job is at least marked cancelled=true in DB for crash recovery.
|
|
278
|
+
cancelledJobs.add(id);
|
|
274
279
|
await store.deleteJob(id);
|
|
275
280
|
logger.info(`Job deleted successfully: ${id}`);
|
|
276
281
|
return true;
|
|
277
282
|
} catch (error) {
|
|
283
|
+
// Delete failed — ensure job is marked cancelled in DB as fallback
|
|
284
|
+
try {
|
|
285
|
+
await cancel(id);
|
|
286
|
+
} catch (cancelError) {
|
|
287
|
+
console.error(`Error during job cancellation fallback for ${id}:`, cancelError);
|
|
288
|
+
}
|
|
278
289
|
console.error(`Error during job deletion process for ${id}:`, error);
|
|
279
290
|
return false;
|
|
280
291
|
}
|
|
@@ -46,14 +46,18 @@ export default function createQueueStore(queue: string) {
|
|
|
46
46
|
|
|
47
47
|
return job.update(updates);
|
|
48
48
|
},
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
// Q1: skipDuplicateCheck skips the pre-create findOne when caller guarantees no duplicate
|
|
50
|
+
// (e.g., after deleteJob). Unique constraint catch still handles concurrent edge cases.
|
|
51
|
+
async addJob(id: string, job: any, attrs: Partial<TJob> = {}, skipDuplicateCheck: boolean = false): Promise<TJob> {
|
|
52
|
+
if (!skipDuplicateCheck) {
|
|
53
|
+
const existingJob = await Job.findOne({ where: { id } });
|
|
51
54
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
if (existingJob) {
|
|
56
|
+
throw new CustomError(
|
|
57
|
+
'JOB_DUPLICATE',
|
|
58
|
+
`Job with id ${id} already exists ${existingJob.queue === queue ? 'in the same queue' : `in queue ${existingJob.queue}`}`
|
|
59
|
+
);
|
|
60
|
+
}
|
|
57
61
|
}
|
|
58
62
|
|
|
59
63
|
try {
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { Meter, PaymentCurrency } from '../store/models';
|
|
2
|
+
import logger from './logger';
|
|
3
|
+
|
|
4
|
+
// Shared TTL cache for reference data (meter, currency) that rarely changes.
|
|
5
|
+
// Used by both ingestion route and credit consumption pipeline.
|
|
6
|
+
const CACHE_TTL = 5 * 60_000; // 5 minutes (invalidated on model update/destroy)
|
|
7
|
+
const CACHE_MAX_SIZE = 500; // safety cap; meter/currency counts are typically < 100
|
|
8
|
+
|
|
9
|
+
// Performance instrumentation: cache hit/miss counters (logged every 60s)
|
|
10
|
+
const cacheStats = { meterHit: 0, meterMiss: 0, currencyHit: 0, currencyMiss: 0, expandedHit: 0, expandedMiss: 0 };
|
|
11
|
+
setInterval(() => {
|
|
12
|
+
const total =
|
|
13
|
+
cacheStats.meterHit +
|
|
14
|
+
cacheStats.meterMiss +
|
|
15
|
+
cacheStats.currencyHit +
|
|
16
|
+
cacheStats.currencyMiss +
|
|
17
|
+
cacheStats.expandedHit +
|
|
18
|
+
cacheStats.expandedMiss;
|
|
19
|
+
if (total > 0) {
|
|
20
|
+
logger.info('Reference cache stats (60s)', { ...cacheStats });
|
|
21
|
+
cacheStats.meterHit = 0;
|
|
22
|
+
cacheStats.meterMiss = 0;
|
|
23
|
+
cacheStats.currencyHit = 0;
|
|
24
|
+
cacheStats.currencyMiss = 0;
|
|
25
|
+
cacheStats.expandedHit = 0;
|
|
26
|
+
cacheStats.expandedMiss = 0;
|
|
27
|
+
}
|
|
28
|
+
}, 60_000).unref();
|
|
29
|
+
type CacheEntry = { data: any; expires: number };
|
|
30
|
+
|
|
31
|
+
// Evict expired entries when cache exceeds max size
|
|
32
|
+
function evictExpired(cache: Map<string, CacheEntry>) {
|
|
33
|
+
if (cache.size <= CACHE_MAX_SIZE) return;
|
|
34
|
+
const now = Date.now();
|
|
35
|
+
for (const [k, v] of cache) {
|
|
36
|
+
if (v.expires < now) cache.delete(k);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const meterCache = new Map<string, CacheEntry>();
|
|
41
|
+
const currencyCache = new Map<string, CacheEntry>();
|
|
42
|
+
|
|
43
|
+
// Lazily register hooks after models are initialized (avoid top-level addHook before Sequelize init)
|
|
44
|
+
let hooksRegistered = false;
|
|
45
|
+
function ensureCacheHooks() {
|
|
46
|
+
if (hooksRegistered) return;
|
|
47
|
+
hooksRegistered = true;
|
|
48
|
+
Meter.addHook('afterUpdate', 'invalidateMeterCache', (model: any) => {
|
|
49
|
+
meterCache.delete(model.event_name);
|
|
50
|
+
meterExpandedCache.delete(model.event_name);
|
|
51
|
+
});
|
|
52
|
+
Meter.addHook('afterDestroy', 'invalidateMeterCacheOnDelete', (model: any) => {
|
|
53
|
+
meterCache.delete(model.event_name);
|
|
54
|
+
meterExpandedCache.delete(model.event_name);
|
|
55
|
+
});
|
|
56
|
+
PaymentCurrency.addHook('afterUpdate', 'invalidateCurrencyCache', (model: any) => {
|
|
57
|
+
currencyCache.delete(model.id);
|
|
58
|
+
// Also invalidate expanded meter cache entries that reference this currency
|
|
59
|
+
meterExpandedCache.clear();
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function getCachedMeter(eventName: string) {
|
|
64
|
+
ensureCacheHooks();
|
|
65
|
+
const cached = meterCache.get(eventName);
|
|
66
|
+
if (cached && cached.expires > Date.now()) {
|
|
67
|
+
cacheStats.meterHit++;
|
|
68
|
+
return cached.data;
|
|
69
|
+
}
|
|
70
|
+
cacheStats.meterMiss++;
|
|
71
|
+
const meter = await Meter.getMeterByEventName(eventName);
|
|
72
|
+
if (meter) {
|
|
73
|
+
evictExpired(meterCache);
|
|
74
|
+
meterCache.set(eventName, { data: meter, expires: Date.now() + CACHE_TTL });
|
|
75
|
+
}
|
|
76
|
+
return meter;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function getCachedCurrency(currencyId: string) {
|
|
80
|
+
ensureCacheHooks();
|
|
81
|
+
const cached = currencyCache.get(currencyId);
|
|
82
|
+
if (cached && cached.expires > Date.now()) {
|
|
83
|
+
cacheStats.currencyHit++;
|
|
84
|
+
return cached.data;
|
|
85
|
+
}
|
|
86
|
+
cacheStats.currencyMiss++;
|
|
87
|
+
const currency = await PaymentCurrency.findByPk(currencyId);
|
|
88
|
+
if (currency) {
|
|
89
|
+
evictExpired(currencyCache);
|
|
90
|
+
currencyCache.set(currencyId, { data: currency, expires: Date.now() + CACHE_TTL });
|
|
91
|
+
}
|
|
92
|
+
return currency;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Q3: For consumption pipeline — cached meter with PaymentCurrency included
|
|
96
|
+
const meterExpandedCache = new Map<string, CacheEntry>();
|
|
97
|
+
|
|
98
|
+
export async function getCachedMeterExpanded(eventName: string) {
|
|
99
|
+
ensureCacheHooks();
|
|
100
|
+
const cached = meterExpandedCache.get(eventName);
|
|
101
|
+
if (cached && cached.expires > Date.now()) {
|
|
102
|
+
cacheStats.expandedHit++;
|
|
103
|
+
return cached.data;
|
|
104
|
+
}
|
|
105
|
+
cacheStats.expandedMiss++;
|
|
106
|
+
const meter = await Meter.findOne({
|
|
107
|
+
where: { event_name: eventName },
|
|
108
|
+
include: [{ model: PaymentCurrency, as: 'paymentCurrency' }],
|
|
109
|
+
});
|
|
110
|
+
if (meter) {
|
|
111
|
+
evictExpired(meterExpandedCache);
|
|
112
|
+
meterExpandedCache.set(eventName, { data: meter, expires: Date.now() + CACHE_TTL });
|
|
113
|
+
}
|
|
114
|
+
return meter;
|
|
115
|
+
}
|
|
@@ -590,10 +590,28 @@ export const autoRechargeQueue = createQueue<AutoRechargeJobData>({
|
|
|
590
590
|
/**
|
|
591
591
|
* 检查并触发自动充值 (添加到队列)
|
|
592
592
|
*/
|
|
593
|
+
// Q2: TTL cache for AutoRechargeConfig existence check (99%+ returns null, rarely changes)
|
|
594
|
+
const AUTO_RECHARGE_CACHE_TTL = 5 * 60_000; // 5 minutes
|
|
595
|
+
const AUTO_RECHARGE_CACHE_MAX_SIZE = 500;
|
|
596
|
+
const autoRechargeConfigCache = new Map<string, { exists: boolean; expires: number }>();
|
|
597
|
+
|
|
598
|
+
let autoRechargeHooksRegistered = false;
|
|
599
|
+
function ensureAutoRechargeHooks() {
|
|
600
|
+
if (autoRechargeHooksRegistered) return;
|
|
601
|
+
autoRechargeHooksRegistered = true;
|
|
602
|
+
const invalidateCache = (model: any) => {
|
|
603
|
+
autoRechargeConfigCache.delete(`${model.customer_id}:${model.currency_id}`);
|
|
604
|
+
};
|
|
605
|
+
AutoRechargeConfig.addHook('afterCreate', 'invalidateAutoRechargeCache', invalidateCache);
|
|
606
|
+
AutoRechargeConfig.addHook('afterUpdate', 'invalidateAutoRechargeCacheOnUpdate', invalidateCache);
|
|
607
|
+
AutoRechargeConfig.addHook('afterDestroy', 'invalidateAutoRechargeCacheOnDelete', invalidateCache);
|
|
608
|
+
}
|
|
609
|
+
|
|
593
610
|
export async function checkAndTriggerAutoRecharge(
|
|
594
611
|
customer: Customer,
|
|
595
612
|
currencyId: string,
|
|
596
|
-
currentBalance: string
|
|
613
|
+
currentBalance: string,
|
|
614
|
+
options?: { currency?: any; meter?: any }
|
|
597
615
|
): Promise<void> {
|
|
598
616
|
try {
|
|
599
617
|
logger.info('----- Checking and triggering auto recharge -----', {
|
|
@@ -602,30 +620,59 @@ export async function checkAndTriggerAutoRecharge(
|
|
|
602
620
|
currentBalance,
|
|
603
621
|
});
|
|
604
622
|
|
|
605
|
-
// Check
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
623
|
+
// Q2: Check cache first — most customers have no auto-recharge config
|
|
624
|
+
ensureAutoRechargeHooks();
|
|
625
|
+
const cacheKey = `${customer.id}:${currencyId}`;
|
|
626
|
+
const cached = autoRechargeConfigCache.get(cacheKey);
|
|
627
|
+
if (cached && cached.expires > Date.now() && !cached.exists) {
|
|
628
|
+
logger.debug('Auto recharge config not found (cached)', {
|
|
629
|
+
customerId: customer.id,
|
|
630
|
+
currencyId,
|
|
611
631
|
});
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// T2b: Reuse caller-provided currency to avoid redundant DB query
|
|
636
|
+
const currency = options?.currency ?? (await PaymentCurrency.findByPk(currencyId));
|
|
637
|
+
|
|
638
|
+
// Reuse caller-provided meter when available to avoid redundant DB query
|
|
639
|
+
let meterPromise: Promise<any>;
|
|
640
|
+
if (options?.meter != null) {
|
|
641
|
+
meterPromise = Promise.resolve(options.meter);
|
|
642
|
+
} else if (currency?.type === 'credit') {
|
|
643
|
+
meterPromise = Meter.findOne({ where: { currency_id: currencyId } });
|
|
644
|
+
} else {
|
|
645
|
+
meterPromise = Promise.resolve(null);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const [meter, config] = await Promise.all([
|
|
649
|
+
meterPromise,
|
|
650
|
+
AutoRechargeConfig.findOne({
|
|
651
|
+
where: {
|
|
652
|
+
customer_id: customer.id,
|
|
653
|
+
currency_id: currencyId,
|
|
654
|
+
enabled: true,
|
|
655
|
+
},
|
|
656
|
+
}),
|
|
657
|
+
]);
|
|
658
|
+
|
|
659
|
+
// Update cache with result (evict expired entries if cache grows too large)
|
|
660
|
+
if (autoRechargeConfigCache.size > AUTO_RECHARGE_CACHE_MAX_SIZE) {
|
|
661
|
+
const now = Date.now();
|
|
662
|
+
for (const [k, v] of autoRechargeConfigCache) {
|
|
663
|
+
if (v.expires < now) autoRechargeConfigCache.delete(k);
|
|
619
664
|
}
|
|
620
665
|
}
|
|
666
|
+
autoRechargeConfigCache.set(cacheKey, { exists: !!config, expires: Date.now() + AUTO_RECHARGE_CACHE_TTL });
|
|
621
667
|
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
}
|
|
628
|
-
|
|
668
|
+
if (meter && meter.status === 'inactive') {
|
|
669
|
+
logger.info('Meter is inactive, skipping auto recharge check', {
|
|
670
|
+
customerId: customer.id,
|
|
671
|
+
currencyId,
|
|
672
|
+
meterId: meter.id,
|
|
673
|
+
});
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
629
676
|
|
|
630
677
|
if (!config) {
|
|
631
678
|
logger.debug('No auto recharge config found', {
|