payment-kit 1.26.4 → 1.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/api/src/libs/payment.ts +113 -22
  2. package/api/src/libs/queue/index.ts +20 -9
  3. package/api/src/libs/queue/store.ts +11 -7
  4. package/api/src/libs/reference-cache.ts +115 -0
  5. package/api/src/queues/auto-recharge.ts +68 -21
  6. package/api/src/queues/credit-consume.ts +835 -206
  7. package/api/src/routes/checkout-sessions.ts +78 -1
  8. package/api/src/routes/customers.ts +15 -3
  9. package/api/src/routes/donations.ts +4 -4
  10. package/api/src/routes/index.ts +37 -8
  11. package/api/src/routes/invoices.ts +14 -3
  12. package/api/src/routes/meter-events.ts +41 -15
  13. package/api/src/routes/payment-links.ts +2 -2
  14. package/api/src/routes/prices.ts +1 -1
  15. package/api/src/routes/pricing-table.ts +3 -2
  16. package/api/src/routes/products.ts +2 -2
  17. package/api/src/routes/subscription-items.ts +12 -3
  18. package/api/src/routes/subscriptions.ts +27 -9
  19. package/api/src/store/migrations/20260306-checkout-session-indexes.ts +23 -0
  20. package/api/src/store/models/checkout-session.ts +3 -2
  21. package/api/src/store/models/coupon.ts +9 -6
  22. package/api/src/store/models/credit-grant.ts +4 -1
  23. package/api/src/store/models/credit-transaction.ts +3 -2
  24. package/api/src/store/models/customer.ts +9 -6
  25. package/api/src/store/models/exchange-rate-provider.ts +9 -6
  26. package/api/src/store/models/invoice.ts +3 -2
  27. package/api/src/store/models/meter-event.ts +6 -4
  28. package/api/src/store/models/meter.ts +9 -6
  29. package/api/src/store/models/payment-intent.ts +9 -6
  30. package/api/src/store/models/payment-link.ts +9 -6
  31. package/api/src/store/models/payout.ts +3 -2
  32. package/api/src/store/models/price.ts +9 -6
  33. package/api/src/store/models/pricing-table.ts +9 -6
  34. package/api/src/store/models/product.ts +9 -6
  35. package/api/src/store/models/promotion-code.ts +9 -6
  36. package/api/src/store/models/refund.ts +9 -6
  37. package/api/src/store/models/setup-intent.ts +6 -4
  38. package/api/src/store/sequelize.ts +8 -3
  39. package/api/tests/queues/credit-consume-batch.spec.ts +438 -0
  40. package/api/tests/queues/credit-consume.spec.ts +505 -0
  41. package/api/third.d.ts +1 -1
  42. package/blocklet.yml +1 -1
  43. package/package.json +8 -7
  44. package/scripts/benchmark-seed.js +247 -0
  45. package/src/components/customer/credit-overview.tsx +31 -42
  46. package/src/components/invoice-pdf/template.tsx +5 -4
  47. package/src/components/payment-link/actions.tsx +45 -0
  48. package/src/components/payment-link/before-pay.tsx +24 -0
  49. package/src/components/subscription/payment-method-info.tsx +23 -6
  50. package/src/components/subscription/portal/actions.tsx +2 -0
  51. package/src/locales/en.tsx +11 -0
  52. package/src/locales/zh.tsx +10 -0
  53. package/src/pages/admin/products/links/detail.tsx +8 -0
  54. package/src/pages/customer/subscription/detail.tsx +21 -18
@@ -1249,7 +1249,7 @@ export async function startCheckoutSessionFromPaymentLink(id: string, req: Reque
1249
1249
  raw.livemode = link.livemode;
1250
1250
  raw.created_via = 'portal';
1251
1251
  raw.submit_type = link.submit_type;
1252
- raw.currency_id = link.currency_id || req.currency.id;
1252
+ raw.currency_id = link.currency_id || req.baseCurrency.id;
1253
1253
  if (!raw.currency_id) {
1254
1254
  res.status(400).json({ error: 'Currency not found in payment link' });
1255
1255
  return;
@@ -1539,6 +1539,83 @@ router.post('/:id/abort-stripe', user, ensureCheckoutSessionOpen, async (req, re
1539
1539
  }
1540
1540
  });
1541
1541
 
1542
+ // Skip payment method for $0 subscription — user chose "Skip, bind later"
1543
+ // Keeps the subscription but sets cancel_at_period_end so it won't renew without a payment method
1544
+ router.post('/:id/skip-payment-method', user, ensureCheckoutSessionOpen, async (req, res) => {
1545
+ try {
1546
+ if (!req.user) {
1547
+ return res.status(403).json({ code: 'REQUIRE_LOGIN', error: 'Please login to continue' });
1548
+ }
1549
+
1550
+ const checkoutSession = req.doc as CheckoutSession;
1551
+
1552
+ if (!['subscription', 'setup'].includes(checkoutSession.mode)) {
1553
+ return res.status(400).json({ error: 'Skip payment method is only supported for subscriptions' });
1554
+ }
1555
+
1556
+ const subscriptionIds = getCheckoutSessionSubscriptionIds(checkoutSession);
1557
+ const subscriptions = await Subscription.findAll({ where: { id: subscriptionIds } });
1558
+
1559
+ if (!subscriptions.length) {
1560
+ return res.status(400).json({ error: 'No subscriptions found for this checkout session' });
1561
+ }
1562
+
1563
+ // Cancel Stripe setup intents and activate subscriptions concurrently
1564
+ await Promise.all(
1565
+ subscriptions.map(async (sub) => {
1566
+ const stripeSubId = sub.payment_details?.stripe?.subscription_id;
1567
+ if (stripeSubId) {
1568
+ const method = await PaymentMethod.findByPk(sub.default_payment_method_id);
1569
+ if (method?.type === 'stripe') {
1570
+ const client = method.getStripeClient();
1571
+ try {
1572
+ const stripeSub = await client.subscriptions.retrieve(stripeSubId, {
1573
+ expand: ['pending_setup_intent'],
1574
+ });
1575
+ if (stripeSub.pending_setup_intent && typeof stripeSub.pending_setup_intent !== 'string') {
1576
+ await client.setupIntents.cancel(stripeSub.pending_setup_intent.id);
1577
+ }
1578
+ await client.subscriptions.update(stripeSubId, { cancel_at_period_end: true });
1579
+ } catch (err: any) {
1580
+ logger.error('Failed to update Stripe subscription for skip-payment-method', {
1581
+ checkoutSessionId: checkoutSession.id,
1582
+ subscriptionId: sub.id,
1583
+ stripeSubId,
1584
+ error: err.message,
1585
+ });
1586
+ }
1587
+ }
1588
+ }
1589
+
1590
+ // Activate the local subscription with cancel_at_period_end
1591
+ await sub.update({
1592
+ status: sub.trial_end && sub.trial_end > Date.now() / 1000 ? 'trialing' : 'active',
1593
+ cancel_at_period_end: true,
1594
+ });
1595
+ await addSubscriptionJob(sub, 'cycle', false, sub.trial_end);
1596
+ })
1597
+ );
1598
+
1599
+ // Complete the checkout session
1600
+ await checkoutSession.update({
1601
+ status: 'complete',
1602
+ payment_status: 'no_payment_required',
1603
+ });
1604
+
1605
+ return res.json({
1606
+ checkoutSession: { id: checkoutSession.id, status: 'complete' },
1607
+ skipped: true,
1608
+ });
1609
+ } catch (err: any) {
1610
+ logger.error('Error in skip-payment-method', {
1611
+ sessionId: req.params.id,
1612
+ error: err.message,
1613
+ stack: err.stack,
1614
+ });
1615
+ res.status(500).json({ error: err.message });
1616
+ }
1617
+ });
1618
+
1542
1619
  // for checkout page
1543
1620
  router.get('/retrieve/:id', user, async (req, res) => {
1544
1621
  const doc = await CheckoutSession.findByPk(req.params.id);
@@ -312,11 +312,23 @@ router.get('/recharge', sessionMiddleware({ accessKey: true }), async (req, res)
312
312
  include: [{ model: SubscriptionItem, as: 'items' }],
313
313
  });
314
314
 
315
- const products = (await Product.findAll()).map((x) => x.toJSON());
316
- const prices = (await Price.findAll()).map((x) => x.toJSON());
317
315
  subscriptions = subscriptions.map((x) => x.toJSON());
316
+ // S1 optimization: Load only referenced prices (with product) instead of full table scans
317
+ const priceIds = [
318
+ ...new Set(subscriptions.flatMap((x: any) => (x.items || []).map((i: any) => i.price_id)).filter(Boolean)),
319
+ ];
320
+ const pricesWithProduct =
321
+ priceIds.length > 0
322
+ ? await Price.findAll({ where: { id: priceIds }, include: [{ model: Product, as: 'product' }] })
323
+ : [];
324
+ const pricesJson = pricesWithProduct.map((x) => x.toJSON());
325
+ const productMap = new Map<string, any>();
326
+ pricesJson.forEach((p: any) => {
327
+ if (p.product) productMap.set(p.product.id, p.product);
328
+ });
329
+ const products = Array.from(productMap.values());
318
330
  // @ts-ignore
319
- subscriptions.forEach((x) => expandLineItems(x.items, products, prices));
331
+ subscriptions.forEach((x) => expandLineItems(x.items, products, pricesJson));
320
332
 
321
333
  const relatedSubscriptions = subscriptions.filter((sub) => {
322
334
  const payerAddress = getSubscriptionPaymentAddress(sub, paymentMethod.type);
@@ -78,7 +78,7 @@ router.post('/', async (req, res) => {
78
78
  livemode: !!req.livemode,
79
79
  name: payload.title,
80
80
  description: payload.description,
81
- currency_id: req.currency.id,
81
+ currency_id: req.baseCurrency.id,
82
82
  via: 'donation',
83
83
  prices: [
84
84
  {
@@ -103,7 +103,7 @@ router.post('/', async (req, res) => {
103
103
  const result = await createPaymentLink({
104
104
  livemode: !!req.livemode,
105
105
  created_via: req.user?.via,
106
- currency_id: req.currency.id,
106
+ currency_id: req.baseCurrency.id,
107
107
  name: payload.title,
108
108
  submit_type: 'donate',
109
109
  line_items: [{ price_id: price.id, quantity: 1 }],
@@ -149,7 +149,7 @@ router.get('/', async (req, res) => {
149
149
  limit: pageSize,
150
150
  });
151
151
 
152
- const method = await PaymentMethod.findByPk(req.currency.payment_method_id);
152
+ const method = await PaymentMethod.findByPk(req.baseCurrency.payment_method_id);
153
153
 
154
154
  const totalAmount: string = rows
155
155
  .map((x) => x.toJSON())
@@ -158,7 +158,7 @@ router.get('/', async (req, res) => {
158
158
 
159
159
  res.json({
160
160
  supporters: rows,
161
- currency: req.currency,
161
+ currency: req.baseCurrency,
162
162
  method,
163
163
  total: count,
164
164
  totalAmount,
@@ -56,19 +56,48 @@ router.use((req, _, next) => {
56
56
  next();
57
57
  });
58
58
 
59
- router.use(async (req, _, next) => {
60
- req.currency = await PaymentCurrency.findOne({ where: { is_base_currency: true, livemode: req.livemode } });
59
+ // Lazy-load base currency with TTL cache to avoid DB query on every request
60
+ const baseCurrencyCache = new Map<string, { data: any; expires: number }>();
61
+ const BASE_CURRENCY_TTL = 5 * 60_000; // 5 minutes (invalidated on model update)
62
+
63
+ // Lazily register hooks after models are initialized (avoid top-level addHook before Sequelize init)
64
+ let baseCurrencyHooksRegistered = false;
65
+ function ensureBaseCurrencyHooks() {
66
+ if (baseCurrencyHooksRegistered) return;
67
+ baseCurrencyHooksRegistered = true;
68
+ PaymentCurrency.addHook('afterUpdate', 'invalidateBaseCurrencyCache', () => {
69
+ baseCurrencyCache.clear();
70
+ });
71
+ PaymentCurrency.addHook('afterDestroy', 'invalidateBaseCurrencyCacheOnDelete', () => {
72
+ baseCurrencyCache.clear();
73
+ });
74
+ }
75
+
76
+ // Base currency middleware — only needed by routes that use req.currency
77
+ // (checkout-sessions, donations, payment-links, prices, products)
78
+ const loadBaseCurrency = async (req: any, _: any, next: any) => {
79
+ ensureBaseCurrencyHooks();
80
+ const key = `base_${req.livemode}`;
81
+ const cached = baseCurrencyCache.get(key);
82
+ if (cached && cached.expires > Date.now()) {
83
+ req.baseCurrency = cached.data;
84
+ } else {
85
+ req.baseCurrency = await PaymentCurrency.findOne({ where: { is_base_currency: true, livemode: req.livemode } });
86
+ if (req.baseCurrency) {
87
+ baseCurrencyCache.set(key, { data: req.baseCurrency, expires: Date.now() + BASE_CURRENCY_TTL });
88
+ }
89
+ }
61
90
  next();
62
- });
91
+ };
63
92
 
64
93
  router.use('/auto-recharge-configs', autoRechargeConfigs);
65
- router.use('/checkout-sessions', checkoutSessions);
94
+ router.use('/checkout-sessions', loadBaseCurrency, checkoutSessions);
66
95
  router.use('/coupons', coupons);
67
96
  router.use('/credit-grants', creditGrants);
68
97
  router.use('/credit-tokens', creditTokens);
69
98
  router.use('/credit-transactions', creditTransactions);
70
99
  router.use('/customers', customers);
71
- router.use('/donations', donations);
100
+ router.use('/donations', loadBaseCurrency, donations);
72
101
  router.use('/events', events);
73
102
  router.use('/invoices', invoices);
74
103
  router.use('/integrations/stripe', stripe);
@@ -76,14 +105,14 @@ router.use('/meter-events', meterEvents);
76
105
  router.use('/meters', meters);
77
106
  router.use('/passports', passports);
78
107
  router.use('/payment-intents', paymentIntents);
79
- router.use('/payment-links', paymentLinks);
108
+ router.use('/payment-links', loadBaseCurrency, paymentLinks);
80
109
  router.use('/payment-methods', paymentMethods);
81
110
  router.use('/payment-currencies', paymentCurrencies);
82
111
  router.use('/payment-stats', paymentStats);
83
- router.use('/prices', prices);
112
+ router.use('/prices', loadBaseCurrency, prices);
84
113
  router.use('/pricing-tables', pricingTables);
85
114
  router.use('/tax-rates', taxRates);
86
- router.use('/products', products);
115
+ router.use('/products', loadBaseCurrency, products);
87
116
  router.use('/promotion-codes', promotionCodes);
88
117
  router.use('/exchange-rate-providers', exchangeRateProviders);
89
118
  router.use('/exchange-rates', exchangeRates);
@@ -833,8 +833,19 @@ router.get('/:id', authPortal, async (req, res) => {
833
833
  };
834
834
  });
835
835
  }
836
- const products = (await Product.findAll()).map((x) => x.toJSON());
837
- const prices = (await Price.findAll()).map((x) => x.toJSON());
836
+ // S1 optimization: Load only referenced prices (with product) instead of full table scans
837
+ const priceIds: string[] = [
838
+ ...new Set((json.lines || []).map((i: any) => i.price_id).filter(Boolean) as string[]),
839
+ ];
840
+ const pricesWithProduct =
841
+ priceIds.length > 0
842
+ ? await Price.findAll({ where: { id: priceIds }, include: [{ model: Product, as: 'product' }] })
843
+ : [];
844
+ const pricesJson = pricesWithProduct.map((x: any) => x.toJSON());
845
+ const productMap = new Map<string, any>();
846
+ pricesJson.forEach((p: any) => {
847
+ if (p.product) productMap.set(p.product.id, p.product);
848
+ });
838
849
  const paymentCurrencies = (
839
850
  await PaymentCurrency.findAll({
840
851
  where: {
@@ -843,7 +854,7 @@ router.get('/:id', authPortal, async (req, res) => {
843
854
  })
844
855
  ).map((x) => x.toJSON());
845
856
  // @ts-ignore
846
- expandLineItems(json.lines, products, prices, paymentCurrencies);
857
+ expandLineItems(json.lines, Array.from(productMap.values()), pricesJson, paymentCurrencies);
847
858
 
848
859
  // Get discount details from total_discount_amounts
849
860
  let discountDetails: any[] = [];
@@ -10,6 +10,8 @@ import { formatMetadata } from '../libs/util';
10
10
  import { trimDecimals } from '../libs/math-utils';
11
11
  import { Customer, Meter, MeterEvent, MeterEventStatus, PaymentCurrency, Subscription } from '../store/models';
12
12
 
13
+ import { getCachedMeter, getCachedCurrency } from '../libs/reference-cache';
14
+
13
15
  const router = Router();
14
16
  const auth = authenticate<MeterEvent>({ component: true, roles: ['owner', 'admin'] });
15
17
  const authMine = authenticate<MeterEvent>({ component: true, roles: ['owner', 'admin'], mine: true });
@@ -225,40 +227,54 @@ router.get('/stats', authMine, async (req, res) => {
225
227
  });
226
228
 
227
229
  router.post('/', auth, async (req, res) => {
230
+ const t0 = performance.now();
228
231
  try {
229
232
  const { error } = meterEventSchema.validate(req.body);
230
233
  if (error) {
231
234
  return res.status(400).json({ error: `Meter event create request invalid: ${error.message}` });
232
235
  }
233
236
 
234
- const existing = await MeterEvent.isEventExists(req.body.identifier);
237
+ // Phase 1: dedupe + meter + customer lookups in parallel (all independent)
238
+ let t1 = performance.now();
239
+ const [existing, meter, customer] = await Promise.all([
240
+ MeterEvent.isEventExists(req.body.identifier),
241
+ getCachedMeter(req.body.event_name),
242
+ Customer.findByPkOrDid(req.body.payload.customer_id),
243
+ ]);
244
+ const tPhase1 = performance.now() - t1;
245
+
235
246
  if (existing) {
236
247
  return res.status(400).json({ error: `Event with identifier "${req.body.identifier}" already exists` });
237
248
  }
238
-
239
- const meter = await Meter.getMeterByEventName(req.body.event_name);
240
249
  if (!meter) {
241
250
  return res
242
251
  .status(400)
243
252
  .json({ error: `Meter not found for event name "${req.body.event_name}"`, code: 'METER_NOT_FOUND' });
244
253
  }
245
-
246
254
  if (meter.status !== 'active') {
247
255
  return res
248
256
  .status(400)
249
257
  .json({ error: 'Meter is not active, please activate it first.', code: 'METER_NOT_ACTIVE' });
250
258
  }
259
+ if (!customer) {
260
+ return res.status(400).json({ error: 'Customer not found' });
261
+ }
262
+
263
+ // Phase 2: currency + subscription in parallel (currency depends on meter)
264
+ t1 = performance.now();
265
+ const [paymentCurrency, subscription] = await Promise.all([
266
+ getCachedCurrency(meter.currency_id),
267
+ req.body.payload.subscription_id
268
+ ? Subscription.findByPk(req.body.payload.subscription_id)
269
+ : Promise.resolve(null),
270
+ ]);
271
+ const tPhase2 = performance.now() - t1;
251
272
 
252
- const paymentCurrency = await PaymentCurrency.findByPk(meter.currency_id);
253
273
  if (!paymentCurrency) {
254
274
  return res.status(400).json({ error: `Payment currency not found for meter "${meter.id}"` });
255
275
  }
256
276
 
257
- if (req.body.payload.subscription_id) {
258
- const subscription = await Subscription.findByPk(req.body.payload.subscription_id);
259
- if (!subscription) {
260
- return res.status(400).json({ error: `Subscription not found for meter event "${req.body.event_name}"` });
261
- }
277
+ if (subscription) {
262
278
  if (subscription.currency_id !== paymentCurrency.id) {
263
279
  return res.status(400).json({ error: 'Subscription currency does not match meter currency' });
264
280
  }
@@ -271,11 +287,8 @@ router.post('/', auth, async (req, res) => {
271
287
  if (subscription.isImmutable()) {
272
288
  return res.status(400).json({ error: 'Subscription is immutable' });
273
289
  }
274
- }
275
-
276
- const customer = await Customer.findByPkOrDid(req.body.payload.customer_id);
277
- if (!customer) {
278
- return res.status(400).json({ error: 'Customer not found' });
290
+ } else if (req.body.payload.subscription_id) {
291
+ return res.status(400).json({ error: `Subscription not found for meter event "${req.body.event_name}"` });
279
292
  }
280
293
 
281
294
  const timestamp = req.body.timestamp || req.body.payload.timestamp || Math.floor(Date.now() / 1000);
@@ -304,13 +317,26 @@ router.post('/', auth, async (req, res) => {
304
317
  timestamp,
305
318
  };
306
319
 
320
+ t1 = performance.now();
307
321
  const event = await MeterEvent.create(eventData);
322
+ const tCreate = performance.now() - t1;
323
+
324
+ const tTotal = performance.now() - t0;
308
325
 
309
326
  logger.info('Meter event created and will be queued for processing via afterCreate hook', {
310
327
  eventId: event.id,
311
328
  eventName: event.event_name,
312
329
  });
313
330
 
331
+ // Server-Timing header for benchmark observability
332
+ const timings = [
333
+ `phase1;dur=${tPhase1.toFixed(1)}`,
334
+ `phase2;dur=${tPhase2.toFixed(1)}`,
335
+ `create;dur=${tCreate.toFixed(1)}`,
336
+ `total;dur=${tTotal.toFixed(1)}`,
337
+ ];
338
+ res.set('Server-Timing', timings.join(', '));
339
+
314
340
  return res.json({
315
341
  ...event.toJSON(),
316
342
  processing: {
@@ -231,7 +231,7 @@ router.post('/', auth, async (req, res) => {
231
231
  ...req.body,
232
232
  livemode: !!req.livemode,
233
233
  created_via: req.user?.via,
234
- currency_id: req.body.currency_id || req.currency.id,
234
+ currency_id: req.body.currency_id || req.baseCurrency.id,
235
235
  metadata: formatMetadata(req.body.metadata),
236
236
  });
237
237
  logger.info('Payment link created successfully', { id: result.id, user: req.user?.did });
@@ -437,7 +437,7 @@ router.post('/stash', auth, async (req, res) => {
437
437
  raw.active = true;
438
438
  raw.livemode = !!req.livemode;
439
439
  raw.created_via = req.user?.via;
440
- raw.currency_id = raw.currency_id || req.currency.id;
440
+ raw.currency_id = raw.currency_id || req.baseCurrency.id;
441
441
  // Merge existing metadata with preview flag
442
442
  raw.metadata = { ...raw.metadata, preview: '1' };
443
443
 
@@ -337,7 +337,7 @@ router.post('/', auth, async (req, res) => {
337
337
  const result = await createPrice({
338
338
  ...req.body,
339
339
  livemode: !!req.livemode,
340
- currency_id: req.body.currency_id || req.currency.id,
340
+ currency_id: req.body.currency_id || req.baseCurrency.id,
341
341
  created_via: req.user?.via as string,
342
342
  quantity_sold: 0,
343
343
  });
@@ -115,14 +115,15 @@ router.get('/', auth, async (req, res) => {
115
115
 
116
116
  const priceIds: string[] = uniq(list.reduce((acc: string[], x) => acc.concat(x.items.map((i) => i.price_id)), []));
117
117
  const prices = await Price.findAll({ where: { id: priceIds }, include: [{ model: Product, as: 'product' }] });
118
- const products = await Product.findAll({ where: { id: uniq(prices.map((x) => x.product_id)) } });
118
+ // Derive products from the already-included price.product association (avoids redundant Product.findAll)
119
+ const productMap = new Map(prices.filter((p) => (p as any).product).map((p) => [p.product_id, (p as any).product]));
119
120
 
120
121
  list.forEach((x) => {
121
122
  x.items.forEach((i) => {
122
123
  // @ts-ignore
123
124
  i.price = prices.find((p) => p.id === i.price_id);
124
125
  // @ts-ignore
125
- i.product = products.find((p) => p.id === i.product_id);
126
+ i.product = productMap.get(i.product_id);
126
127
  });
127
128
  });
128
129
 
@@ -296,7 +296,7 @@ router.post('/', auth, async (req, res) => {
296
296
  type: req.body.type || 'service',
297
297
  livemode: !!req.livemode,
298
298
  created_via: req.user?.via,
299
- currency_id: req.currency.id,
299
+ currency_id: req.baseCurrency.id,
300
300
  metadata: formatMetadata(req.body.metadata),
301
301
  });
302
302
  logger.info('Product and prices created', {
@@ -609,7 +609,7 @@ router.post('/batch-price-update', auth, async (req, res) => {
609
609
 
610
610
  const prices = await Price.findAll({ where: { product_id: product.id } });
611
611
  for (const price of prices) {
612
- if (price.currency_id === req.currency.id) {
612
+ if (price.currency_id === req.baseCurrency.id) {
613
613
  const unit = new BN(price.unit_amount).div(new BN(factor)).toString();
614
614
  const options = cloneDeep(price.currency_options);
615
615
  const option = options.find((x) => x.currency_id === price.currency_id);
@@ -87,10 +87,19 @@ router.get('/', auth, async (req, res) => {
87
87
  });
88
88
  const list = rows.map((x) => x.toJSON());
89
89
  if (where.subscription_id) {
90
- const products = (await Product.findAll()).map((x) => x.toJSON());
91
- const prices = (await Price.findAll()).map((x) => x.toJSON());
90
+ // S1 optimization: Load only referenced prices (with product) instead of full table scans
91
+ const priceIds = [...new Set(list.map((i: any) => i.price_id).filter(Boolean))];
92
+ const pricesWithProduct =
93
+ priceIds.length > 0
94
+ ? await Price.findAll({ where: { id: priceIds }, include: [{ model: Product, as: 'product' }] })
95
+ : [];
96
+ const pricesJson = pricesWithProduct.map((x) => x.toJSON());
97
+ const productMap = new Map<string, any>();
98
+ pricesJson.forEach((p: any) => {
99
+ if (p.product) productMap.set(p.product.id, p.product);
100
+ });
92
101
  // @ts-ignore
93
- expandLineItems(list, products, prices);
102
+ expandLineItems(list, Array.from(productMap.values()), pricesJson);
94
103
  }
95
104
  res.json({ count, list, paging: { page, pageSize } });
96
105
  } catch (err) {
@@ -77,6 +77,28 @@ import { ensureOverdraftProtectionPrice } from '../libs/overdraft-protection';
77
77
  import { CHARGE_SUPPORTED_CHAIN_TYPES } from '../libs/constants';
78
78
  import { getSubscriptionDiscountStats } from '../libs/discount/redemption';
79
79
 
80
+ // S1 optimization: Load only the products/prices referenced by a set of subscriptions,
81
+ // instead of Product.findAll() + Price.findAll() full table scans.
82
+ async function loadProductsAndPricesForSubscriptions(docs: any[]): Promise<{ products: any[]; prices: any[] }> {
83
+ const priceIds = uniq(docs.flatMap((x) => (x.items || []).map((i: any) => i.price_id)).filter(Boolean));
84
+ if (priceIds.length === 0) {
85
+ return { products: [], prices: [] };
86
+ }
87
+ const prices = await Price.findAll({
88
+ where: { id: priceIds },
89
+ include: [{ model: Product, as: 'product' }],
90
+ });
91
+ const pricesJson = prices.map((x) => x.toJSON());
92
+ // Derive products from the already-included price.product association
93
+ const productMap = new Map<string, any>();
94
+ pricesJson.forEach((p: any) => {
95
+ if (p.product) {
96
+ productMap.set(p.product.id, p.product);
97
+ }
98
+ });
99
+ return { products: Array.from(productMap.values()), prices: pricesJson };
100
+ }
101
+
80
102
  const router = Router();
81
103
  const auth = authenticate<Subscription>({ component: true, roles: ['owner', 'admin'] });
82
104
  const authMine = authenticate<Subscription>({ component: true, roles: ['owner', 'admin'], mine: true });
@@ -436,9 +458,8 @@ router.post('/', auth, async (req, res) => {
436
458
  ],
437
459
  });
438
460
 
439
- const products = (await Product.findAll()).map((x) => x.toJSON());
440
- const allPrices = (await Price.findAll()).map((x) => x.toJSON());
441
461
  const doc = result!.toJSON();
462
+ const { products, prices: allPrices } = await loadProductsAndPricesForSubscriptions([doc]);
442
463
  // @ts-ignore
443
464
  expandLineItems(doc.items, products, allPrices);
444
465
 
@@ -523,9 +544,8 @@ router.get('/', authMine, async (req, res) => {
523
544
  // https://github.com/sequelize/sequelize/issues/9481
524
545
  distinct: true,
525
546
  });
526
- const products = (await Product.findAll()).map((x) => x.toJSON());
527
- const prices = (await Price.findAll()).map((x) => x.toJSON());
528
547
  const docs = list.map((x) => x.toJSON());
548
+ const { products, prices } = await loadProductsAndPricesForSubscriptions(docs);
529
549
  // @ts-ignore
530
550
  docs.forEach((x) => expandLineItems(x.items, products, prices));
531
551
  if (includeLatestInvoiceQuote) {
@@ -592,9 +612,8 @@ router.get('/search', auth, async (req, res) => {
592
612
  ],
593
613
  });
594
614
 
595
- const products = (await Product.findAll()).map((x) => x.toJSON());
596
- const prices = (await Price.findAll()).map((x) => x.toJSON());
597
615
  const docs = list.map((x) => x.toJSON());
616
+ const { products, prices } = await loadProductsAndPricesForSubscriptions(docs);
598
617
  // @ts-ignore
599
618
  docs.forEach((x) => expandLineItems(x.items, products, prices));
600
619
  if (includeLatestInvoiceQuote) {
@@ -624,8 +643,7 @@ router.get('/:id', authPortal, async (req, res) => {
624
643
  const json: any = doc.toJSON();
625
644
  const isConsumesCredit = await doc.isConsumesCredit();
626
645
  const serviceType = isConsumesCredit ? 'credit' : 'standard';
627
- const products = (await Product.findAll()).map((x) => x.toJSON());
628
- const prices = (await Price.findAll()).map((x) => x.toJSON());
646
+ const { products, prices } = await loadProductsAndPricesForSubscriptions([json]);
629
647
  // @ts-ignore
630
648
  expandLineItems(json.items, products, prices);
631
649
  // @ts-ignore
@@ -645,7 +663,7 @@ router.get('/:id', authPortal, async (req, res) => {
645
663
  // Get payment method details
646
664
  let paymentMethodDetails = null;
647
665
  try {
648
- const paymentMethod = await PaymentMethod.findByPk(doc.default_payment_method_id);
666
+ const paymentMethod = (doc as any).paymentMethod as PaymentMethod | null;
649
667
  if (paymentMethod?.type === 'stripe' && json.payment_details?.stripe?.subscription_id) {
650
668
  const client = paymentMethod.getStripeClient();
651
669
  const stripeSubscription = await client.subscriptions.retrieve(json.payment_details.stripe.subscription_id, {
@@ -0,0 +1,23 @@
1
+ /* eslint-disable no-console */
2
+ import { createIndexIfNotExists, type Migration } from '../migrate';
3
+
4
+ export const up: Migration = async ({ context }) => {
5
+ console.log('🚀 Adding checkout_sessions performance indexes...');
6
+
7
+ await createIndexIfNotExists(
8
+ context,
9
+ 'checkout_sessions',
10
+ ['payment_intent_id'],
11
+ 'idx_checkout_sessions_payment_intent'
12
+ );
13
+ await createIndexIfNotExists(context, 'checkout_sessions', ['subscription_id'], 'idx_checkout_sessions_subscription');
14
+ await createIndexIfNotExists(context, 'checkout_sessions', ['status'], 'idx_checkout_sessions_status');
15
+
16
+ console.log('✅ checkout_sessions indexes created');
17
+ };
18
+
19
+ export const down: Migration = async ({ context }) => {
20
+ await context.removeIndex('checkout_sessions', 'idx_checkout_sessions_payment_intent');
21
+ await context.removeIndex('checkout_sessions', 'idx_checkout_sessions_subscription');
22
+ await context.removeIndex('checkout_sessions', 'idx_checkout_sessions_status');
23
+ };
@@ -502,8 +502,9 @@ export class CheckoutSession extends Model<InferAttributes<CheckoutSession>, Inf
502
502
  createdAt: 'created_at',
503
503
  updatedAt: 'updated_at',
504
504
  hooks: {
505
- afterCreate: (model: CheckoutSession, options) =>
506
- createEvent('CheckoutSession', 'checkout.session.created', model, options).catch(console.error),
505
+ afterCreate: (model: CheckoutSession, options) => {
506
+ createEvent('CheckoutSession', 'checkout.session.created', model, options).catch(console.error);
507
+ },
507
508
  afterUpdate: (model: CheckoutSession, options) => {
508
509
  createStatusEvent(
509
510
  'CheckoutSession',
@@ -153,12 +153,15 @@ export class Coupon extends Model<InferAttributes<Coupon>, InferCreationAttribut
153
153
  createdAt: 'created_at',
154
154
  updatedAt: 'updated_at',
155
155
  hooks: {
156
- afterCreate: (model: Coupon, options) =>
157
- createEvent('Coupon', 'coupon.created', model, options).catch(console.error),
158
- afterUpdate: (model: Coupon, options) =>
159
- createEvent('Coupon', 'coupon.updated', model, options).catch(console.error),
160
- afterDestroy: (model: Coupon, options) =>
161
- createEvent('Coupon', 'coupon.deleted', model, options).catch(console.error),
156
+ afterCreate: (model: Coupon, options) => {
157
+ createEvent('Coupon', 'coupon.created', model, options).catch(console.error);
158
+ },
159
+ afterUpdate: (model: Coupon, options) => {
160
+ createEvent('Coupon', 'coupon.updated', model, options).catch(console.error);
161
+ },
162
+ afterDestroy: (model: Coupon, options) => {
163
+ createEvent('Coupon', 'coupon.deleted', model, options).catch(console.error);
164
+ },
162
165
  },
163
166
  });
164
167
  }
@@ -262,7 +262,10 @@ export class CreditGrant extends Model<InferAttributes<CreditGrant>, InferCreati
262
262
 
263
263
  await this.save();
264
264
 
265
- await createEvent('CreditGrant', 'customer.credit_grant.consumed', this).catch(console.error);
265
+ // Fire-and-forget: audit event should not block the consumption hot path.
266
+ // Trade-off: if the process crashes between save() and this call, the audit event is lost,
267
+ // but grant state is already persisted and can be reconciled from DB.
268
+ createEvent('CreditGrant', 'customer.credit_grant.consumed', this).catch(console.error);
266
269
  }
267
270
 
268
271
  return {
@@ -126,8 +126,9 @@ export class CreditTransaction extends Model<
126
126
  { fields: ['transfer_status'] },
127
127
  ],
128
128
  hooks: {
129
- afterCreate: (model: CreditTransaction, options) =>
130
- createEvent('CreditTransaction', 'customer.credit_transaction.created', model, options).catch(console.error),
129
+ afterCreate: (model: CreditTransaction, options) => {
130
+ createEvent('CreditTransaction', 'customer.credit_transaction.created', model, options).catch(console.error);
131
+ },
131
132
  },
132
133
  });
133
134
  }
@@ -258,12 +258,15 @@ export class Customer extends Model<InferAttributes<Customer>, InferCreationAttr
258
258
  createdAt: 'created_at',
259
259
  updatedAt: 'updated_at',
260
260
  hooks: {
261
- afterCreate: (model: Customer, options) =>
262
- createEvent('Customer', 'customer.created', model, options).catch(console.error),
263
- afterUpdate: (model: Customer, options) =>
264
- createEvent('Customer', 'customer.updated', model, options).catch(console.error),
265
- afterDestroy: (model: Customer, options) =>
266
- createEvent('Customer', 'customer.deleted', model, options).catch(console.error),
261
+ afterCreate: (model: Customer, options) => {
262
+ createEvent('Customer', 'customer.created', model, options).catch(console.error);
263
+ },
264
+ afterUpdate: (model: Customer, options) => {
265
+ createEvent('Customer', 'customer.updated', model, options).catch(console.error);
266
+ },
267
+ afterDestroy: (model: Customer, options) => {
268
+ createEvent('Customer', 'customer.deleted', model, options).catch(console.error);
269
+ },
267
270
  },
268
271
  }
269
272
  );