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.
Files changed (46) hide show
  1. package/api/src/libs/payment.ts +113 -22
  2. package/api/src/libs/queue/index.ts +20 -9
  3. package/api/src/libs/queue/store.ts +11 -7
  4. package/api/src/libs/reference-cache.ts +115 -0
  5. package/api/src/queues/auto-recharge.ts +68 -21
  6. package/api/src/queues/credit-consume.ts +835 -206
  7. package/api/src/routes/checkout-sessions.ts +1 -1
  8. package/api/src/routes/customers.ts +15 -3
  9. package/api/src/routes/donations.ts +4 -4
  10. package/api/src/routes/index.ts +37 -8
  11. package/api/src/routes/invoices.ts +14 -3
  12. package/api/src/routes/meter-events.ts +41 -15
  13. package/api/src/routes/payment-links.ts +2 -2
  14. package/api/src/routes/prices.ts +1 -1
  15. package/api/src/routes/pricing-table.ts +3 -2
  16. package/api/src/routes/products.ts +2 -2
  17. package/api/src/routes/subscription-items.ts +12 -3
  18. package/api/src/routes/subscriptions.ts +27 -9
  19. package/api/src/store/migrations/20260306-checkout-session-indexes.ts +23 -0
  20. package/api/src/store/models/checkout-session.ts +3 -2
  21. package/api/src/store/models/coupon.ts +9 -6
  22. package/api/src/store/models/credit-grant.ts +4 -1
  23. package/api/src/store/models/credit-transaction.ts +3 -2
  24. package/api/src/store/models/customer.ts +9 -6
  25. package/api/src/store/models/exchange-rate-provider.ts +9 -6
  26. package/api/src/store/models/invoice.ts +3 -2
  27. package/api/src/store/models/meter-event.ts +6 -4
  28. package/api/src/store/models/meter.ts +9 -6
  29. package/api/src/store/models/payment-intent.ts +9 -6
  30. package/api/src/store/models/payment-link.ts +9 -6
  31. package/api/src/store/models/payout.ts +3 -2
  32. package/api/src/store/models/price.ts +9 -6
  33. package/api/src/store/models/pricing-table.ts +9 -6
  34. package/api/src/store/models/product.ts +9 -6
  35. package/api/src/store/models/promotion-code.ts +9 -6
  36. package/api/src/store/models/refund.ts +9 -6
  37. package/api/src/store/models/setup-intent.ts +6 -4
  38. package/api/src/store/sequelize.ts +8 -3
  39. package/api/tests/queues/credit-consume-batch.spec.ts +438 -0
  40. package/api/tests/queues/credit-consume.spec.ts +505 -0
  41. package/api/third.d.ts +1 -1
  42. package/blocklet.yml +1 -1
  43. package/package.json +8 -7
  44. package/scripts/benchmark-seed.js +247 -0
  45. package/src/components/customer/credit-overview.tsx +31 -42
  46. package/src/components/invoice-pdf/template.tsx +5 -4
@@ -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
- // have delegated before? Use migration-aware fallback query
268
- // Priority: storedDelegationAddress > subscription.payment_details.arcblock.delegation_address
269
- const delegationResult = await getDelegationAddressWithFallback({
270
- storedAddress: storedDelegationAddress || subscription?.payment_details?.arcblock?.delegation_address,
271
- delegator,
272
- client,
273
- });
274
- if (!delegationResult) {
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
- storedDelegationAddress,
278
- subscriptionDelegationAddress: subscription?.payment_details?.arcblock?.delegation_address,
279
- subscriptionId: subscription?.id,
332
+ delegationAddress,
333
+ source,
334
+ cacheAgeMs: Date.now() - cached.cachedAt,
280
335
  });
281
- return { sufficient: false, reason: 'NO_DELEGATION' };
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
- const { address, needsBackfill, source } = delegationResult;
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
- // Backfill if needed and subscription is available
287
- if (needsBackfill && subscription) {
288
- await backfillDelegationAddress(subscription.id, address);
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
- const { state } = await client.getDelegateState({ address });
292
- if (!state) {
293
- logger.error('isDelegationSufficientForPayment: no delegation state', { address, delegator, source });
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', { address, delegator, source });
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
- const deleteJob = async (id: string): Promise<boolean> => {
267
- const exists = await getJob(id);
268
-
269
- if (!exists) {
270
- return false;
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
- await cancel(id);
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
- async addJob(id: string, job: any, attrs: Partial<TJob> = {}): Promise<TJob> {
50
- const existingJob = await Job.findOne({ where: { id } });
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
- if (existingJob) {
53
- throw new CustomError(
54
- 'JOB_DUPLICATE',
55
- `Job with id ${id} already exists ${existingJob.queue === queue ? 'in the same queue' : `in queue ${existingJob.queue}`}`
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 if the associated meter is inactive
606
- const currency = await PaymentCurrency.findByPk(currencyId);
607
- if (currency?.type === 'credit') {
608
- // Find meter by currency_id (meter.currency_id -> PaymentCurrency)
609
- const meter = await Meter.findOne({
610
- where: { currency_id: currencyId },
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
- if (meter && meter.status === 'inactive') {
613
- logger.info('Meter is inactive, skipping auto recharge check', {
614
- customerId: customer.id,
615
- currencyId,
616
- meterId: meter.id,
617
- });
618
- return;
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
- const config = await AutoRechargeConfig.findOne({
623
- where: {
624
- customer_id: customer.id,
625
- currency_id: currencyId,
626
- enabled: true,
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', {