payment-kit 1.26.4 → 1.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api/src/libs/payment.ts +113 -22
- package/api/src/libs/queue/index.ts +20 -9
- package/api/src/libs/queue/store.ts +11 -7
- package/api/src/libs/reference-cache.ts +115 -0
- package/api/src/queues/auto-recharge.ts +68 -21
- package/api/src/queues/credit-consume.ts +835 -206
- package/api/src/routes/checkout-sessions.ts +78 -1
- package/api/src/routes/customers.ts +15 -3
- package/api/src/routes/donations.ts +4 -4
- package/api/src/routes/index.ts +37 -8
- package/api/src/routes/invoices.ts +14 -3
- package/api/src/routes/meter-events.ts +41 -15
- package/api/src/routes/payment-links.ts +2 -2
- package/api/src/routes/prices.ts +1 -1
- package/api/src/routes/pricing-table.ts +3 -2
- package/api/src/routes/products.ts +2 -2
- package/api/src/routes/subscription-items.ts +12 -3
- package/api/src/routes/subscriptions.ts +27 -9
- package/api/src/store/migrations/20260306-checkout-session-indexes.ts +23 -0
- package/api/src/store/models/checkout-session.ts +3 -2
- package/api/src/store/models/coupon.ts +9 -6
- package/api/src/store/models/credit-grant.ts +4 -1
- package/api/src/store/models/credit-transaction.ts +3 -2
- package/api/src/store/models/customer.ts +9 -6
- package/api/src/store/models/exchange-rate-provider.ts +9 -6
- package/api/src/store/models/invoice.ts +3 -2
- package/api/src/store/models/meter-event.ts +6 -4
- package/api/src/store/models/meter.ts +9 -6
- package/api/src/store/models/payment-intent.ts +9 -6
- package/api/src/store/models/payment-link.ts +9 -6
- package/api/src/store/models/payout.ts +3 -2
- package/api/src/store/models/price.ts +9 -6
- package/api/src/store/models/pricing-table.ts +9 -6
- package/api/src/store/models/product.ts +9 -6
- package/api/src/store/models/promotion-code.ts +9 -6
- package/api/src/store/models/refund.ts +9 -6
- package/api/src/store/models/setup-intent.ts +6 -4
- package/api/src/store/sequelize.ts +8 -3
- package/api/tests/queues/credit-consume-batch.spec.ts +438 -0
- package/api/tests/queues/credit-consume.spec.ts +505 -0
- package/api/third.d.ts +1 -1
- package/blocklet.yml +1 -1
- package/package.json +8 -7
- package/scripts/benchmark-seed.js +247 -0
- package/src/components/customer/credit-overview.tsx +31 -42
- package/src/components/invoice-pdf/template.tsx +5 -4
- package/src/components/payment-link/actions.tsx +45 -0
- package/src/components/payment-link/before-pay.tsx +24 -0
- package/src/components/subscription/payment-method-info.tsx +23 -6
- package/src/components/subscription/portal/actions.tsx +2 -0
- package/src/locales/en.tsx +11 -0
- package/src/locales/zh.tsx +10 -0
- package/src/pages/admin/products/links/detail.tsx +8 -0
- package/src/pages/customer/subscription/detail.tsx +21 -18
|
@@ -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.
|
|
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,
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
161
|
+
currency: req.baseCurrency,
|
|
162
162
|
method,
|
|
163
163
|
total: count,
|
|
164
164
|
totalAmount,
|
package/api/src/routes/index.ts
CHANGED
|
@@ -56,19 +56,48 @@ router.use((req, _, next) => {
|
|
|
56
56
|
next();
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
837
|
-
const
|
|
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,
|
|
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
|
-
|
|
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 (
|
|
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.
|
|
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.
|
|
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
|
|
package/api/src/routes/prices.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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.
|
|
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
|
-
|
|
91
|
-
const
|
|
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,
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
);
|