payment-kit 1.24.4 → 1.25.1
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/index.ts +3 -0
- package/api/src/libs/credit-utils.ts +21 -0
- package/api/src/libs/discount/discount.ts +13 -0
- package/api/src/libs/env.ts +5 -0
- package/api/src/libs/error.ts +14 -0
- package/api/src/libs/exchange-rate/coingecko-provider.ts +193 -0
- package/api/src/libs/exchange-rate/coinmarketcap-provider.ts +180 -0
- package/api/src/libs/exchange-rate/index.ts +5 -0
- package/api/src/libs/exchange-rate/service.ts +583 -0
- package/api/src/libs/exchange-rate/token-address-mapping.ts +84 -0
- package/api/src/libs/exchange-rate/token-addresses.json +2147 -0
- package/api/src/libs/exchange-rate/token-data-provider.ts +142 -0
- package/api/src/libs/exchange-rate/types.ts +114 -0
- package/api/src/libs/exchange-rate/validator.ts +319 -0
- package/api/src/libs/invoice-quote.ts +158 -0
- package/api/src/libs/invoice.ts +143 -7
- package/api/src/libs/math-utils.ts +46 -0
- package/api/src/libs/notification/template/billing-discrepancy.ts +3 -4
- package/api/src/libs/notification/template/customer-auto-recharge-failed.ts +174 -79
- package/api/src/libs/notification/template/customer-credit-grant-granted.ts +2 -3
- package/api/src/libs/notification/template/customer-credit-insufficient.ts +3 -3
- package/api/src/libs/notification/template/customer-credit-low-balance.ts +3 -3
- package/api/src/libs/notification/template/customer-revenue-succeeded.ts +2 -3
- package/api/src/libs/notification/template/customer-reward-succeeded.ts +9 -4
- package/api/src/libs/notification/template/exchange-rate-alert.ts +202 -0
- package/api/src/libs/notification/template/subscription-slippage-exceeded.ts +203 -0
- package/api/src/libs/notification/template/subscription-slippage-warning.ts +212 -0
- package/api/src/libs/notification/template/subscription-will-canceled.ts +2 -2
- package/api/src/libs/notification/template/subscription-will-renew.ts +22 -8
- package/api/src/libs/payment.ts +3 -1
- package/api/src/libs/price.ts +4 -1
- package/api/src/libs/queue/index.ts +8 -0
- package/api/src/libs/quote-service.ts +1132 -0
- package/api/src/libs/quote-validation.ts +388 -0
- package/api/src/libs/session.ts +686 -39
- package/api/src/libs/slippage.ts +135 -0
- package/api/src/libs/subscription.ts +185 -15
- package/api/src/libs/util.ts +64 -3
- package/api/src/locales/en.ts +50 -0
- package/api/src/locales/zh.ts +48 -0
- package/api/src/queues/auto-recharge.ts +295 -21
- package/api/src/queues/exchange-rate-health.ts +242 -0
- package/api/src/queues/invoice.ts +48 -1
- package/api/src/queues/notification.ts +167 -1
- package/api/src/queues/payment.ts +177 -7
- package/api/src/queues/refund.ts +41 -9
- package/api/src/queues/subscription.ts +436 -6
- package/api/src/routes/auto-recharge-configs.ts +71 -6
- package/api/src/routes/checkout-sessions.ts +1730 -81
- package/api/src/routes/connect/auto-recharge-auth.ts +2 -0
- package/api/src/routes/connect/change-payer.ts +2 -0
- package/api/src/routes/connect/change-payment.ts +61 -8
- package/api/src/routes/connect/change-plan.ts +161 -17
- package/api/src/routes/connect/collect.ts +9 -6
- package/api/src/routes/connect/delegation.ts +1 -0
- package/api/src/routes/connect/pay.ts +157 -0
- package/api/src/routes/connect/setup.ts +32 -10
- package/api/src/routes/connect/shared.ts +159 -13
- package/api/src/routes/connect/subscribe.ts +32 -9
- package/api/src/routes/credit-grants.ts +99 -0
- package/api/src/routes/exchange-rate-providers.ts +248 -0
- package/api/src/routes/exchange-rates.ts +87 -0
- package/api/src/routes/index.ts +4 -0
- package/api/src/routes/invoices.ts +280 -2
- package/api/src/routes/payment-links.ts +13 -0
- package/api/src/routes/prices.ts +84 -2
- package/api/src/routes/subscriptions.ts +526 -15
- package/api/src/store/migrations/20251220-dynamic-pricing.ts +245 -0
- package/api/src/store/migrations/20251223-exchange-rate-provider-type.ts +28 -0
- package/api/src/store/migrations/20260110-add-quote-locked-at.ts +23 -0
- package/api/src/store/migrations/20260112-add-checkout-session-slippage-percent.ts +22 -0
- package/api/src/store/migrations/20260113-add-price-quote-slippage-fields.ts +45 -0
- package/api/src/store/migrations/20260116-subscription-slippage.ts +21 -0
- package/api/src/store/migrations/20260120-auto-recharge-slippage.ts +21 -0
- package/api/src/store/models/auto-recharge-config.ts +12 -0
- package/api/src/store/models/checkout-session.ts +7 -0
- package/api/src/store/models/exchange-rate-provider.ts +225 -0
- package/api/src/store/models/index.ts +6 -0
- package/api/src/store/models/payment-intent.ts +6 -0
- package/api/src/store/models/price-quote.ts +284 -0
- package/api/src/store/models/price.ts +53 -5
- package/api/src/store/models/subscription.ts +11 -0
- package/api/src/store/models/types.ts +61 -1
- package/api/tests/libs/change-payment-plan.spec.ts +282 -0
- package/api/tests/libs/exchange-rate-service.spec.ts +341 -0
- package/api/tests/libs/quote-service.spec.ts +199 -0
- package/api/tests/libs/session.spec.ts +464 -0
- package/api/tests/libs/slippage.spec.ts +109 -0
- package/api/tests/libs/token-data-provider.spec.ts +267 -0
- package/api/tests/models/exchange-rate-provider.spec.ts +121 -0
- package/api/tests/models/price-dynamic.spec.ts +100 -0
- package/api/tests/models/price-quote.spec.ts +112 -0
- package/api/tests/routes/exchange-rate-providers.spec.ts +215 -0
- package/api/tests/routes/subscription-slippage.spec.ts +254 -0
- package/blocklet.yml +1 -1
- package/package.json +7 -6
- package/src/components/customer/credit-overview.tsx +14 -0
- package/src/components/discount/discount-info.tsx +8 -2
- package/src/components/invoice/list.tsx +146 -16
- package/src/components/invoice/table.tsx +276 -71
- package/src/components/invoice-pdf/template.tsx +3 -7
- package/src/components/metadata/form.tsx +6 -8
- package/src/components/price/form.tsx +519 -149
- package/src/components/promotion/active-redemptions.tsx +5 -3
- package/src/components/quote/info.tsx +234 -0
- package/src/hooks/subscription.ts +132 -2
- package/src/locales/en.tsx +145 -0
- package/src/locales/zh.tsx +143 -1
- package/src/pages/admin/billing/invoices/detail.tsx +41 -4
- package/src/pages/admin/products/exchange-rate-providers/edit-dialog.tsx +354 -0
- package/src/pages/admin/products/exchange-rate-providers/index.tsx +363 -0
- package/src/pages/admin/products/index.tsx +12 -1
- package/src/pages/customer/invoice/detail.tsx +36 -12
- package/src/pages/customer/subscription/change-payment.tsx +65 -3
- package/src/pages/customer/subscription/change-plan.tsx +207 -38
- package/src/pages/customer/subscription/detail.tsx +599 -419
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { isValid } from '@arcblock/did';
|
|
3
3
|
import { getUrl } from '@blocklet/sdk/lib/component';
|
|
4
4
|
import { sessionMiddleware } from '@blocklet/sdk/lib/middlewares/session';
|
|
5
|
-
import { BN, fromUnitToToken } from '@ocap/util';
|
|
5
|
+
import { BN, fromTokenToUnit, fromUnitToToken } from '@ocap/util';
|
|
6
6
|
import { NextFunction, Request, Response, Router } from 'express';
|
|
7
7
|
import Joi from 'joi';
|
|
8
8
|
import cloneDeep from 'lodash/cloneDeep';
|
|
@@ -12,6 +12,7 @@ import pick from 'lodash/pick';
|
|
|
12
12
|
import sortBy from 'lodash/sortBy';
|
|
13
13
|
import uniq from 'lodash/uniq';
|
|
14
14
|
import type { WhereOptions } from 'sequelize';
|
|
15
|
+
import { Op } from 'sequelize';
|
|
15
16
|
|
|
16
17
|
import { CustomError, formatError, getStatusFromError } from '@blocklet/error';
|
|
17
18
|
import pAll from 'p-all';
|
|
@@ -20,7 +21,14 @@ import { MetadataSchema } from '../libs/api';
|
|
|
20
21
|
import { checkPassportForPaymentLink } from '../integrations/blocklet/passport';
|
|
21
22
|
import dayjs from '../libs/dayjs';
|
|
22
23
|
import logger from '../libs/logger';
|
|
24
|
+
import { trimDecimals } from '../libs/math-utils';
|
|
23
25
|
import { authenticate } from '../libs/security';
|
|
26
|
+
import {
|
|
27
|
+
buildSlippageSnapshot,
|
|
28
|
+
DEFAULT_SLIPPAGE_PERCENT,
|
|
29
|
+
isRateBelowMinAcceptableRate,
|
|
30
|
+
normalizeSlippageConfigFromMetadata,
|
|
31
|
+
} from '../libs/slippage';
|
|
24
32
|
import {
|
|
25
33
|
canPayWithDelegation,
|
|
26
34
|
canUpsell,
|
|
@@ -45,6 +53,7 @@ import {
|
|
|
45
53
|
validatePaymentAmounts,
|
|
46
54
|
processCheckoutSessionDiscounts,
|
|
47
55
|
getCheckoutSessionAmounts,
|
|
56
|
+
enrichCheckoutSessionWithQuotes,
|
|
48
57
|
} from '../libs/session';
|
|
49
58
|
import { getDaysUntilCancel, getDaysUntilDue, getSubscriptionTrialSetup } from '../libs/subscription';
|
|
50
59
|
import {
|
|
@@ -71,6 +80,7 @@ import {
|
|
|
71
80
|
Coupon,
|
|
72
81
|
PromotionCode,
|
|
73
82
|
} from '../store/models';
|
|
83
|
+
import type { ChainType } from '../store/models/types';
|
|
74
84
|
import { CheckoutSession } from '../store/models/checkout-session';
|
|
75
85
|
import { Customer } from '../store/models/customer';
|
|
76
86
|
import { PaymentCurrency } from '../store/models/payment-currency';
|
|
@@ -78,6 +88,7 @@ import { PaymentIntent } from '../store/models/payment-intent';
|
|
|
78
88
|
import { PaymentLink } from '../store/models/payment-link';
|
|
79
89
|
import { PaymentMethod } from '../store/models/payment-method';
|
|
80
90
|
import { Price } from '../store/models/price';
|
|
91
|
+
import { PriceQuote } from '../store/models/price-quote';
|
|
81
92
|
import { Product } from '../store/models/product';
|
|
82
93
|
import {
|
|
83
94
|
ensureStripePaymentIntent,
|
|
@@ -110,6 +121,10 @@ import { rollbackDiscountUsageForCheckoutSession, applyDiscountsToLineItems } fr
|
|
|
110
121
|
import { formatToShortUrl } from '../libs/url';
|
|
111
122
|
import { destroyExistingInvoice } from '../libs/invoice';
|
|
112
123
|
import { getApproveFunction } from '../integrations/ethereum/contract';
|
|
124
|
+
import { getQuoteService } from '../libs/quote-service';
|
|
125
|
+
import { getExchangeRateService } from '../libs/exchange-rate/service';
|
|
126
|
+
import { getExchangeRateSymbol } from '../libs/exchange-rate/token-address-mapping';
|
|
127
|
+
import { sequelize } from '../store/sequelize';
|
|
113
128
|
|
|
114
129
|
const router = Router();
|
|
115
130
|
|
|
@@ -117,6 +132,16 @@ const user = sessionMiddleware({ accessKey: true });
|
|
|
117
132
|
const auth = authenticate<CheckoutSession>({ component: true, roles: ['owner', 'admin'] });
|
|
118
133
|
|
|
119
134
|
const authLogin = authenticate<CheckoutSession>({ component: true, roles: ['owner', 'admin'], ensureLogin: true });
|
|
135
|
+
const exchangeRateService = getExchangeRateService();
|
|
136
|
+
const DEFAULT_RATE_VOLATILITY_THRESHOLD = 0.1;
|
|
137
|
+
|
|
138
|
+
const getRateVolatilityThreshold = () => {
|
|
139
|
+
const raw = Number(process.env.PAYMENT_RATE_VOLATILITY_THRESHOLD || DEFAULT_RATE_VOLATILITY_THRESHOLD);
|
|
140
|
+
if (!Number.isFinite(raw) || raw <= 0) {
|
|
141
|
+
return DEFAULT_RATE_VOLATILITY_THRESHOLD;
|
|
142
|
+
}
|
|
143
|
+
return raw > 1 ? raw / 100 : raw;
|
|
144
|
+
};
|
|
120
145
|
|
|
121
146
|
const getPaymentMethods = async (doc: CheckoutSession) => {
|
|
122
147
|
const paymentMethods = await PaymentMethod.expand(doc.livemode, { type: doc.payment_method_types });
|
|
@@ -328,10 +353,12 @@ async function validatePromotionCodesOnSubmit(
|
|
|
328
353
|
export async function calculateAndUpdateAmount(
|
|
329
354
|
checkoutSession: CheckoutSession,
|
|
330
355
|
paymentCurrencyId: string,
|
|
331
|
-
useTrialSetting: boolean = false
|
|
356
|
+
useTrialSetting: boolean = false,
|
|
357
|
+
options: { lineItemsOverride?: TLineItemExpanded[] } = {}
|
|
332
358
|
) {
|
|
333
359
|
const now = dayjs().unix();
|
|
334
|
-
const lineItems =
|
|
360
|
+
const lineItems =
|
|
361
|
+
options.lineItemsOverride || (await Price.expand(checkoutSession.line_items, { product: true, upsell: true }));
|
|
335
362
|
|
|
336
363
|
let trialInDays = 0;
|
|
337
364
|
let trialEnd = 0;
|
|
@@ -376,7 +403,8 @@ export async function calculateAndUpdateAmount(
|
|
|
376
403
|
discountConfig
|
|
377
404
|
);
|
|
378
405
|
|
|
379
|
-
|
|
406
|
+
// Build update payload
|
|
407
|
+
const updatePayload: any = {
|
|
380
408
|
amount_subtotal: amount.subtotal,
|
|
381
409
|
amount_total: amount.total,
|
|
382
410
|
total_details: {
|
|
@@ -384,7 +412,44 @@ export async function calculateAndUpdateAmount(
|
|
|
384
412
|
amount_shipping: amount.shipping,
|
|
385
413
|
amount_tax: amount.tax,
|
|
386
414
|
},
|
|
387
|
-
}
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
// Update discounts[0].discount_amount with recalculated value at submit time
|
|
418
|
+
// recalculate-promotion intentionally removes discount_amount (frontend calculates dynamically)
|
|
419
|
+
// but we need to persist the final calculated value at submit for createDiscountRecordsForCheckout
|
|
420
|
+
if (checkoutSession.discounts && checkoutSession.discounts.length > 0 && amount.discount) {
|
|
421
|
+
updatePayload.discounts = checkoutSession.discounts.map((discount: any, index: number) =>
|
|
422
|
+
index === 0 ? { ...discount, discount_amount: amount.discount } : discount
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Update line_items with recalculated discount_amounts at submit time
|
|
427
|
+
// recalculate-promotion clears discount_amounts (frontend calculates dynamically)
|
|
428
|
+
// but we need to persist the final calculated values at submit for invoice display
|
|
429
|
+
if (amount.processedItems?.length > 0) {
|
|
430
|
+
const discountAmountsMap = new Map(
|
|
431
|
+
amount.processedItems
|
|
432
|
+
.filter((item: any) => item.discount_amounts?.length > 0)
|
|
433
|
+
.map((item: any) => [item.price_id, item.discount_amounts])
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
if (discountAmountsMap.size > 0) {
|
|
437
|
+
updatePayload.line_items = checkoutSession.line_items.map((item: any) => {
|
|
438
|
+
const discountAmounts = discountAmountsMap.get(item.price_id);
|
|
439
|
+
return discountAmounts ? { ...item, discount_amounts: discountAmounts } : item;
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
await checkoutSession.update(updatePayload);
|
|
445
|
+
|
|
446
|
+
// Sync in-memory instance with persisted values
|
|
447
|
+
if (updatePayload.discounts) {
|
|
448
|
+
checkoutSession.discounts = updatePayload.discounts;
|
|
449
|
+
}
|
|
450
|
+
if (updatePayload.line_items) {
|
|
451
|
+
checkoutSession.line_items = updatePayload.line_items;
|
|
452
|
+
}
|
|
388
453
|
|
|
389
454
|
logger.info('Amount calculated', {
|
|
390
455
|
checkoutSessionId: checkoutSession.id,
|
|
@@ -395,6 +460,8 @@ export async function calculateAndUpdateAmount(
|
|
|
395
460
|
shipping: amount.shipping,
|
|
396
461
|
tax: amount.tax,
|
|
397
462
|
},
|
|
463
|
+
discountsUpdated: !!updatePayload.discounts,
|
|
464
|
+
lineItemsDiscountsUpdated: !!updatePayload.line_items,
|
|
398
465
|
});
|
|
399
466
|
|
|
400
467
|
if (checkoutSession.mode === 'payment' && new BN(amount.total || '0').lt(new BN('0'))) {
|
|
@@ -410,6 +477,16 @@ export async function calculateAndUpdateAmount(
|
|
|
410
477
|
};
|
|
411
478
|
}
|
|
412
479
|
|
|
480
|
+
const QUOTE_LOCK_DURATION_SECONDS = 180;
|
|
481
|
+
const QUOTE_LOCK_DURATION_MS = QUOTE_LOCK_DURATION_SECONDS * 1000;
|
|
482
|
+
|
|
483
|
+
// Removed unused functions getQuoteLockStart and isQuoteLockActive
|
|
484
|
+
|
|
485
|
+
const fetchCurrentExchangeRate = (paymentCurrency: PaymentCurrency, paymentMethod: PaymentMethod) => {
|
|
486
|
+
const rateSymbol = getExchangeRateSymbol(paymentCurrency.symbol, paymentMethod.type as ChainType);
|
|
487
|
+
return exchangeRateService.getRate(rateSymbol);
|
|
488
|
+
};
|
|
489
|
+
|
|
413
490
|
/**
|
|
414
491
|
* 创建或更新支付意向
|
|
415
492
|
*/
|
|
@@ -420,7 +497,8 @@ async function createOrUpdatePaymentIntent(
|
|
|
420
497
|
lineItems: any[],
|
|
421
498
|
customerId?: string,
|
|
422
499
|
customerEmail?: string,
|
|
423
|
-
formData?: any
|
|
500
|
+
formData?: any,
|
|
501
|
+
options: { quoteIds?: string[] } = {}
|
|
424
502
|
) {
|
|
425
503
|
let paymentIntent: PaymentIntent | null = null;
|
|
426
504
|
|
|
@@ -434,6 +512,9 @@ async function createOrUpdatePaymentIntent(
|
|
|
434
512
|
|
|
435
513
|
const beneficiaries =
|
|
436
514
|
paymentLink?.payment_intent_data?.beneficiaries || paymentLink?.donation_settings?.beneficiaries || [];
|
|
515
|
+
const quoteIds = options.quoteIds || [];
|
|
516
|
+
const shouldLockQuotes = quoteIds.length > 0;
|
|
517
|
+
const nowMs = Date.now();
|
|
437
518
|
|
|
438
519
|
if (checkoutSession.payment_intent_id) {
|
|
439
520
|
paymentIntent = await PaymentIntent.findByPk(checkoutSession.payment_intent_id);
|
|
@@ -459,8 +540,27 @@ async function createOrUpdatePaymentIntent(
|
|
|
459
540
|
payment_method_id: paymentMethod.id,
|
|
460
541
|
last_payment_error: null,
|
|
461
542
|
beneficiaries: createPaymentBeneficiaries(checkoutSession.amount_total, beneficiaries),
|
|
543
|
+
metadata: {
|
|
544
|
+
...(paymentIntent.metadata || {}),
|
|
545
|
+
...(checkoutSession.payment_intent_data?.metadata || {}),
|
|
546
|
+
...(quoteIds.length ? { quote_ids: quoteIds } : {}),
|
|
547
|
+
},
|
|
462
548
|
};
|
|
463
549
|
|
|
550
|
+
if (shouldLockQuotes) {
|
|
551
|
+
const lockedAtMs = paymentIntent.quote_locked_at ? new Date(paymentIntent.quote_locked_at).getTime() : null;
|
|
552
|
+
if (lockedAtMs && nowMs - lockedAtMs > QUOTE_LOCK_DURATION_MS) {
|
|
553
|
+
await paymentIntent.update({ quote_locked_at: undefined });
|
|
554
|
+
await checkoutSession.update({
|
|
555
|
+
metadata: { ...checkoutSession.metadata, quote_locked_at: undefined },
|
|
556
|
+
});
|
|
557
|
+
throw new CustomError(409, 'QUOTE_LOCK_EXPIRED', 'Quote lock expired, please refresh and retry');
|
|
558
|
+
}
|
|
559
|
+
if (!lockedAtMs) {
|
|
560
|
+
updateData.quote_locked_at = new Date(nowMs);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
464
564
|
if (customerId) {
|
|
465
565
|
updateData.customer_id = customerId;
|
|
466
566
|
}
|
|
@@ -470,6 +570,11 @@ async function createOrUpdatePaymentIntent(
|
|
|
470
570
|
}
|
|
471
571
|
|
|
472
572
|
paymentIntent = await paymentIntent.update(updateData);
|
|
573
|
+
if (updateData.quote_locked_at) {
|
|
574
|
+
await checkoutSession.update({
|
|
575
|
+
metadata: { ...checkoutSession.metadata, quote_locked_at: Math.floor(nowMs / 1000) },
|
|
576
|
+
});
|
|
577
|
+
}
|
|
473
578
|
logger.info('payment intent for checkout session reset', {
|
|
474
579
|
session: checkoutSession.id,
|
|
475
580
|
intent: paymentIntent.id,
|
|
@@ -493,9 +598,16 @@ async function createOrUpdatePaymentIntent(
|
|
|
493
598
|
statement_descriptor_suffix: '',
|
|
494
599
|
setup_future_usage: 'on_session',
|
|
495
600
|
beneficiaries: createPaymentBeneficiaries(checkoutSession.amount_total, beneficiaries),
|
|
496
|
-
metadata:
|
|
601
|
+
metadata: {
|
|
602
|
+
...(checkoutSession.payment_intent_data?.metadata || checkoutSession.metadata),
|
|
603
|
+
...(quoteIds.length ? { quote_ids: quoteIds } : {}),
|
|
604
|
+
},
|
|
497
605
|
};
|
|
498
606
|
|
|
607
|
+
if (shouldLockQuotes) {
|
|
608
|
+
createData.quote_locked_at = new Date(nowMs);
|
|
609
|
+
}
|
|
610
|
+
|
|
499
611
|
if (customerId) {
|
|
500
612
|
createData.customer_id = customerId;
|
|
501
613
|
}
|
|
@@ -527,6 +639,12 @@ async function createOrUpdatePaymentIntent(
|
|
|
527
639
|
updateData.metadata = {
|
|
528
640
|
...checkoutSession.metadata,
|
|
529
641
|
is_donation: true,
|
|
642
|
+
...(shouldLockQuotes ? { quote_locked_at: Math.floor(nowMs / 1000) } : {}),
|
|
643
|
+
};
|
|
644
|
+
} else if (shouldLockQuotes) {
|
|
645
|
+
updateData.metadata = {
|
|
646
|
+
...checkoutSession.metadata,
|
|
647
|
+
quote_locked_at: Math.floor(nowMs / 1000),
|
|
530
648
|
};
|
|
531
649
|
}
|
|
532
650
|
|
|
@@ -1033,6 +1151,12 @@ router.post('/', authLogin, async (req, res) => {
|
|
|
1033
1151
|
|
|
1034
1152
|
const doc = await CheckoutSession.create(raw as any);
|
|
1035
1153
|
|
|
1154
|
+
try {
|
|
1155
|
+
await prefetchQuotesForCheckoutSession(doc as CheckoutSession);
|
|
1156
|
+
} catch (error: any) {
|
|
1157
|
+
return res.status(400).json({ error: error.message });
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1036
1160
|
let url = getUrl(`/checkout/${doc.submit_type}/${doc.id}`);
|
|
1037
1161
|
if (createMine && currentUserDid) {
|
|
1038
1162
|
url = withQuery(url, getConnectQueryParam({ userDid: currentUserDid }));
|
|
@@ -1205,6 +1329,34 @@ export async function startCheckoutSessionFromPaymentLink(id: string, req: Reque
|
|
|
1205
1329
|
doc.line_items = await Price.expand(updatedItems, { upsell: true });
|
|
1206
1330
|
}
|
|
1207
1331
|
|
|
1332
|
+
// Final Freeze: Don't create Quotes during Preview
|
|
1333
|
+
// Quotes are created ONLY at Submit time via createUsedQuoteWithValidation
|
|
1334
|
+
// Frontend uses /exchange-rate endpoint for live rate display
|
|
1335
|
+
// @see Intent: blocklets/core/ai/intent/20260112-dynamic-price.md
|
|
1336
|
+
let quotes = {};
|
|
1337
|
+
let rateUnavailable = false;
|
|
1338
|
+
let rateError: string | undefined;
|
|
1339
|
+
try {
|
|
1340
|
+
const quoteResult = await enrichCheckoutSessionWithQuotes(
|
|
1341
|
+
doc,
|
|
1342
|
+
doc.line_items as TLineItemExpanded[],
|
|
1343
|
+
doc.currency_id,
|
|
1344
|
+
{ skipGeneration: true } // Final Freeze: Don't create quotes during Preview
|
|
1345
|
+
);
|
|
1346
|
+
doc.line_items = quoteResult.lineItems;
|
|
1347
|
+
quotes = quoteResult.quotes;
|
|
1348
|
+
rateUnavailable = quoteResult.rateUnavailable || false;
|
|
1349
|
+
rateError = quoteResult.rateError;
|
|
1350
|
+
} catch (error: any) {
|
|
1351
|
+
logger.warn('Failed to enrich checkout session with quote info', {
|
|
1352
|
+
checkoutSessionId: doc.id,
|
|
1353
|
+
error: error.message,
|
|
1354
|
+
});
|
|
1355
|
+
// Set error flags so frontend can display warning
|
|
1356
|
+
rateUnavailable = true;
|
|
1357
|
+
rateError = error.message || 'Failed to get price quote info';
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1208
1360
|
let paymentUrl = getUrl(`/checkout/pay/${doc.id}`);
|
|
1209
1361
|
if (needShortUrl) {
|
|
1210
1362
|
const validUntil = dayjs().add(20, 'minutes').format('YYYY-MM-DDTHH:mm:ss+00:00');
|
|
@@ -1215,6 +1367,9 @@ export async function startCheckoutSessionFromPaymentLink(id: string, req: Reque
|
|
|
1215
1367
|
res.json({
|
|
1216
1368
|
paymentUrl,
|
|
1217
1369
|
checkoutSession: doc.toJSON(),
|
|
1370
|
+
quotes, // Include quotes information for frontend
|
|
1371
|
+
rateUnavailable, // Warning flag for frontend
|
|
1372
|
+
rateError, // Error message for frontend
|
|
1218
1373
|
paymentMethods: await getPaymentMethods(doc),
|
|
1219
1374
|
paymentLink: link,
|
|
1220
1375
|
paymentIntent: null,
|
|
@@ -1306,7 +1461,7 @@ router.post('/:id/abort-stripe', user, ensureCheckoutSessionOpen, async (req, re
|
|
|
1306
1461
|
// remove related invoice if created
|
|
1307
1462
|
try {
|
|
1308
1463
|
const existInvoice = await Invoice.findOne({ where: { checkout_session_id: checkoutSession.id } });
|
|
1309
|
-
if (existInvoice
|
|
1464
|
+
if (existInvoice) {
|
|
1310
1465
|
await destroyExistingInvoice(existInvoice);
|
|
1311
1466
|
}
|
|
1312
1467
|
} catch (error: any) {
|
|
@@ -1354,8 +1509,9 @@ router.get('/retrieve/:id', user, async (req, res) => {
|
|
|
1354
1509
|
});
|
|
1355
1510
|
}
|
|
1356
1511
|
|
|
1512
|
+
const rawLineItems = doc.line_items;
|
|
1357
1513
|
// @ts-ignore
|
|
1358
|
-
doc.line_items = await Price.expand(
|
|
1514
|
+
doc.line_items = await Price.expand(rawLineItems, { upsell: true });
|
|
1359
1515
|
|
|
1360
1516
|
// Enhance line items with coupon information if discounts are applied
|
|
1361
1517
|
if (doc.discounts?.length) {
|
|
@@ -1369,14 +1525,103 @@ router.get('/retrieve/:id', user, async (req, res) => {
|
|
|
1369
1525
|
// Expand discounts with complete details
|
|
1370
1526
|
const enhancedDiscounts = await expandDiscountsWithDetails(doc.discounts);
|
|
1371
1527
|
|
|
1528
|
+
// Handle dynamic pricing: create or reuse quotes
|
|
1529
|
+
// Skip quote generation when checkout session is confirmed and should not change:
|
|
1530
|
+
// 1. Payment completed (payment_status === 'paid')
|
|
1531
|
+
// 2. Subscription created (has subscription_id) - includes free trial scenarios
|
|
1532
|
+
// 3. Status is 'complete'
|
|
1533
|
+
const isCheckoutConfirmed = doc.payment_status === 'paid' || doc.status === 'complete' || !!doc.subscription_id;
|
|
1534
|
+
const forceRefresh = req.query.forceRefresh === '1' || req.query.forceRefresh === 'true';
|
|
1535
|
+
|
|
1536
|
+
let quotes = {};
|
|
1537
|
+
let rateUnavailable = false;
|
|
1538
|
+
let rateError: string | undefined;
|
|
1539
|
+
let quotesRefreshed = false;
|
|
1540
|
+
|
|
1541
|
+
try {
|
|
1542
|
+
const quoteResult = await enrichCheckoutSessionWithQuotes(
|
|
1543
|
+
doc,
|
|
1544
|
+
doc.line_items as TLineItemExpanded[],
|
|
1545
|
+
doc.currency_id,
|
|
1546
|
+
{ skipGeneration: isCheckoutConfirmed, forceRefresh } // Skip for confirmed checkouts
|
|
1547
|
+
);
|
|
1548
|
+
doc.line_items = quoteResult.lineItems;
|
|
1549
|
+
quotes = quoteResult.quotes;
|
|
1550
|
+
rateUnavailable = quoteResult.rateUnavailable || false;
|
|
1551
|
+
rateError = quoteResult.rateError;
|
|
1552
|
+
quotesRefreshed = quoteResult.refreshed || false;
|
|
1553
|
+
|
|
1554
|
+
if (quoteResult.regenerated && Array.isArray(rawLineItems)) {
|
|
1555
|
+
const quoteIdByPriceId = new Map<string, string>();
|
|
1556
|
+
quoteResult.lineItems.forEach((item) => {
|
|
1557
|
+
const priceId = item.price_id || item.price?.id;
|
|
1558
|
+
const quoteId = (item as any).quote_id;
|
|
1559
|
+
if (priceId && quoteId) {
|
|
1560
|
+
quoteIdByPriceId.set(priceId, quoteId);
|
|
1561
|
+
}
|
|
1562
|
+
});
|
|
1563
|
+
|
|
1564
|
+
if (quoteIdByPriceId.size > 0) {
|
|
1565
|
+
const updatedLineItems = rawLineItems.map((item: any) => ({
|
|
1566
|
+
...item,
|
|
1567
|
+
quote_id: quoteIdByPriceId.get(item.price_id) || item.quote_id,
|
|
1568
|
+
}));
|
|
1569
|
+
|
|
1570
|
+
await doc.update({ line_items: updatedLineItems });
|
|
1571
|
+
// @ts-ignore
|
|
1572
|
+
doc.line_items = await Price.expand(updatedLineItems, { upsell: true });
|
|
1573
|
+
logger.info('Updated checkout session line_items with refreshed quote_ids', {
|
|
1574
|
+
checkoutSessionId: doc.id,
|
|
1575
|
+
updatedQuotes: quoteIdByPriceId.size,
|
|
1576
|
+
});
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
if (isCheckoutConfirmed && Object.keys(quotes).length > 0) {
|
|
1581
|
+
logger.info('Retrieved locked quotes for confirmed checkout', {
|
|
1582
|
+
checkoutSessionId: doc.id,
|
|
1583
|
+
paymentStatus: doc.payment_status,
|
|
1584
|
+
status: doc.status,
|
|
1585
|
+
hasSubscription: !!doc.subscription_id,
|
|
1586
|
+
quotesCount: Object.keys(quotes).length,
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
} catch (error: any) {
|
|
1590
|
+
logger.warn('Failed to process quotes for checkout session', {
|
|
1591
|
+
checkoutSessionId: doc.id,
|
|
1592
|
+
paymentStatus: doc.payment_status,
|
|
1593
|
+
status: doc.status,
|
|
1594
|
+
hasSubscription: !!doc.subscription_id,
|
|
1595
|
+
isCheckoutConfirmed,
|
|
1596
|
+
error: error.message,
|
|
1597
|
+
});
|
|
1598
|
+
// Set error flags so frontend can display warning
|
|
1599
|
+
rateUnavailable = true;
|
|
1600
|
+
rateError = error.message || 'Failed to process price quotes';
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1372
1603
|
// check payment intent
|
|
1373
1604
|
const paymentIntent = doc.payment_intent_id ? await PaymentIntent.findByPk(doc.payment_intent_id) : null;
|
|
1605
|
+
if ((forceRefresh || quotesRefreshed) && !isCheckoutConfirmed) {
|
|
1606
|
+
if (doc.metadata?.quote_locked_at) {
|
|
1607
|
+
await doc.update({
|
|
1608
|
+
metadata: { ...doc.metadata, quote_locked_at: null },
|
|
1609
|
+
});
|
|
1610
|
+
}
|
|
1611
|
+
if (paymentIntent?.quote_locked_at) {
|
|
1612
|
+
await paymentIntent.update({ quote_locked_at: undefined });
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1374
1616
|
res.json({
|
|
1375
1617
|
checkoutSession: {
|
|
1376
1618
|
...doc.toJSON(),
|
|
1377
1619
|
discounts: enhancedDiscounts,
|
|
1378
1620
|
subscriptions,
|
|
1379
1621
|
},
|
|
1622
|
+
quotes, // Include quotes information for frontend
|
|
1623
|
+
rateUnavailable, // Warning flag for frontend
|
|
1624
|
+
rateError, // Error message for frontend
|
|
1380
1625
|
paymentMethods: await getPaymentMethods(doc),
|
|
1381
1626
|
paymentLink: doc.payment_link_id ? await PaymentLink.findByPk(doc.payment_link_id) : null,
|
|
1382
1627
|
paymentIntent,
|
|
@@ -1384,6 +1629,73 @@ router.get('/retrieve/:id', user, async (req, res) => {
|
|
|
1384
1629
|
});
|
|
1385
1630
|
});
|
|
1386
1631
|
|
|
1632
|
+
// Fetch latest exchange rate for checkout session (for UI display/monitoring only)
|
|
1633
|
+
// Also renews active Quote's expires_at if it's about to expire
|
|
1634
|
+
/**
|
|
1635
|
+
* Exchange Rate Endpoint (Final Freeze Architecture)
|
|
1636
|
+
*
|
|
1637
|
+
* Returns rate snapshot ONLY for display purposes.
|
|
1638
|
+
* Does NOT create or modify Quotes (Quotes are created at Submit time only).
|
|
1639
|
+
*
|
|
1640
|
+
* @see Intent: blocklets/core/ai/intent/20260112-dynamic-price.md
|
|
1641
|
+
*/
|
|
1642
|
+
router.get('/:id/exchange-rate', user, async (req, res) => {
|
|
1643
|
+
const doc = await CheckoutSession.findByPk(req.params.id);
|
|
1644
|
+
if (!doc) {
|
|
1645
|
+
return res.status(404).json({ error: 'Checkout session not found, you may have incorrectly copied the link.' });
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
const currencyId = (req.query.currency_id as string) || doc.currency_id;
|
|
1649
|
+
const paymentCurrency = (await PaymentCurrency.findByPk(currencyId, {
|
|
1650
|
+
include: [{ model: PaymentMethod, as: 'payment_method' }],
|
|
1651
|
+
})) as (PaymentCurrency & { payment_method: PaymentMethod }) | null;
|
|
1652
|
+
|
|
1653
|
+
if (!paymentCurrency) {
|
|
1654
|
+
return res.status(400).json({ error: 'Payment currency not found' });
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
if (paymentCurrency.payment_method?.type === 'stripe') {
|
|
1658
|
+
return res.status(400).json({ error: 'Exchange rate is not supported for stripe payments' });
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
const serverNow = Math.floor(Date.now() / 1000);
|
|
1662
|
+
|
|
1663
|
+
try {
|
|
1664
|
+
const rateResult = await fetchCurrentExchangeRate(paymentCurrency, paymentCurrency.payment_method);
|
|
1665
|
+
|
|
1666
|
+
// Final Freeze: Only return rate snapshot, no quote data
|
|
1667
|
+
return res.json({
|
|
1668
|
+
server_now: serverNow,
|
|
1669
|
+
rate: rateResult.rate,
|
|
1670
|
+
timestamp_ms: rateResult.timestamp_ms,
|
|
1671
|
+
fetched_at: rateResult.fetched_at, // When we fetched (updates every 30s)
|
|
1672
|
+
provider_id: rateResult.provider_id,
|
|
1673
|
+
provider_name: rateResult.provider_name,
|
|
1674
|
+
provider_display: rateResult.provider_display, // Human-readable: "CoinGecko" or "CoinGecko (2 sources)"
|
|
1675
|
+
providers: rateResult.providers,
|
|
1676
|
+
consensus_method: rateResult.consensus_method,
|
|
1677
|
+
degraded: rateResult.degraded,
|
|
1678
|
+
degraded_reason: rateResult.degraded_reason,
|
|
1679
|
+
currency: paymentCurrency.symbol,
|
|
1680
|
+
base_currency: 'USD',
|
|
1681
|
+
volatility_threshold: getRateVolatilityThreshold(),
|
|
1682
|
+
// quote_snapshot removed - Quotes are created at Submit time only
|
|
1683
|
+
});
|
|
1684
|
+
} catch (error: any) {
|
|
1685
|
+
logger.error('Exchange rate fetch failed', {
|
|
1686
|
+
sessionId: doc.id,
|
|
1687
|
+
currencyId,
|
|
1688
|
+
error: error?.message,
|
|
1689
|
+
});
|
|
1690
|
+
|
|
1691
|
+
// Return 503 with RATE_UNAVAILABLE code
|
|
1692
|
+
return res.status(503).json({
|
|
1693
|
+
code: 'RATE_UNAVAILABLE',
|
|
1694
|
+
error: error?.message || 'Exchange rate unavailable',
|
|
1695
|
+
});
|
|
1696
|
+
}
|
|
1697
|
+
});
|
|
1698
|
+
|
|
1387
1699
|
// for checkout page
|
|
1388
1700
|
router.get('/broker-status/:id', user, async (req, res) => {
|
|
1389
1701
|
const { needShortUrl = false } = req.query;
|
|
@@ -1422,6 +1734,7 @@ router.get('/broker-status/:id', user, async (req, res) => {
|
|
|
1422
1734
|
res.json({
|
|
1423
1735
|
checkoutSession: {
|
|
1424
1736
|
...doc.toJSON(),
|
|
1737
|
+
line_items: doc.line_items, // Override with expanded line_items (includes price object)
|
|
1425
1738
|
},
|
|
1426
1739
|
paymentLink,
|
|
1427
1740
|
});
|
|
@@ -1432,13 +1745,762 @@ async function checkVendorConfig(items: TLineItemExpanded[]) {
|
|
|
1432
1745
|
return lineItems?.some((item: TLineItemExpanded) => !!item?.price?.product?.vendor_config?.length);
|
|
1433
1746
|
}
|
|
1434
1747
|
|
|
1748
|
+
type QuoteInput = {
|
|
1749
|
+
price_id: string;
|
|
1750
|
+
quote_id: string;
|
|
1751
|
+
};
|
|
1752
|
+
|
|
1753
|
+
function getQuoteErrorCode(error: any): string {
|
|
1754
|
+
if (!error) {
|
|
1755
|
+
return '';
|
|
1756
|
+
}
|
|
1757
|
+
if (typeof error.code === 'string') {
|
|
1758
|
+
return error.code;
|
|
1759
|
+
}
|
|
1760
|
+
if (typeof error.message === 'string' && error.message.startsWith('QUOTE_')) {
|
|
1761
|
+
return error.message;
|
|
1762
|
+
}
|
|
1763
|
+
return '';
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
/**
|
|
1767
|
+
* Calculate total base amount for quote validation
|
|
1768
|
+
* Uses same precision logic as quote creation
|
|
1769
|
+
*/
|
|
1770
|
+
function calculateTotalBaseAmount(baseAmount: string, quantity: number): string {
|
|
1771
|
+
const USD_DECIMALS = 8;
|
|
1772
|
+
const baseAmountBN = fromTokenToUnit(trimDecimals(baseAmount, USD_DECIMALS), USD_DECIMALS);
|
|
1773
|
+
const quantityBN = new BN(quantity);
|
|
1774
|
+
const totalBaseAmountBN = baseAmountBN.mul(quantityBN);
|
|
1775
|
+
return fromUnitToToken(totalBaseAmountBN.toString(), USD_DECIMALS);
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
function isSqliteBusyError(error: any): boolean {
|
|
1779
|
+
const message = String(error?.message || '').toUpperCase();
|
|
1780
|
+
const code = error?.original?.code || error?.code;
|
|
1781
|
+
return code === 'SQLITE_BUSY' || message.includes('SQLITE_BUSY');
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
async function updateQuoteWithRetry(
|
|
1785
|
+
quote: PriceQuote,
|
|
1786
|
+
payload: Record<string, any>,
|
|
1787
|
+
transaction: any,
|
|
1788
|
+
maxRetries = 3
|
|
1789
|
+
): Promise<PriceQuote> {
|
|
1790
|
+
// Retry loop with exponential backoff for SQLite busy errors
|
|
1791
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1792
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
1793
|
+
try {
|
|
1794
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1795
|
+
return await quote.update(payload, { transaction });
|
|
1796
|
+
} catch (err: any) {
|
|
1797
|
+
const isBusy = isSqliteBusyError(err);
|
|
1798
|
+
const isLastAttempt = attempt === maxRetries;
|
|
1799
|
+
|
|
1800
|
+
if (!isBusy || isLastAttempt) {
|
|
1801
|
+
throw err;
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
// Exponential backoff: 50ms, 100ms, 200ms
|
|
1805
|
+
const delay = 50 * 2 ** (attempt - 1);
|
|
1806
|
+
console.warn(`[Quote] SQLITE_BUSY, retry ${attempt}/${maxRetries} after ${delay}ms`);
|
|
1807
|
+
// eslint-disable-next-line no-await-in-loop, no-promise-executor-return
|
|
1808
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
// TypeScript 需要这个(虽然逻辑上不会到达)
|
|
1813
|
+
throw new Error('Unexpected: exceeded max retries');
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
/**
|
|
1817
|
+
* Create Quotes at Submit time (Final Freeze Architecture)
|
|
1818
|
+
*
|
|
1819
|
+
* This function creates quotes directly as 'used' with dual-layer validation.
|
|
1820
|
+
* It replaces the old flow where quotes were pre-created during Preview.
|
|
1821
|
+
*
|
|
1822
|
+
* @see Intent: blocklets/core/ai/intent/20260112-dynamic-price.md
|
|
1823
|
+
*/
|
|
1824
|
+
async function createQuotesAtSubmit(options: {
|
|
1825
|
+
checkoutSession: CheckoutSession;
|
|
1826
|
+
lineItems: TLineItemExpanded[];
|
|
1827
|
+
paymentCurrencyId: string;
|
|
1828
|
+
idempotencyKey: string;
|
|
1829
|
+
previewRate?: string;
|
|
1830
|
+
priceConfirmed?: boolean;
|
|
1831
|
+
trialing?: boolean;
|
|
1832
|
+
}): Promise<{
|
|
1833
|
+
lineItems: TLineItemExpanded[];
|
|
1834
|
+
consumedQuotes: PriceQuote[];
|
|
1835
|
+
validationError?: {
|
|
1836
|
+
code: 'PRICE_UNAVAILABLE' | 'PRICE_UNSTABLE' | 'PRICE_CHANGED' | 'RATE_BELOW_SLIPPAGE_LIMIT';
|
|
1837
|
+
message: string;
|
|
1838
|
+
changePercent?: number;
|
|
1839
|
+
currentRate?: string;
|
|
1840
|
+
minAcceptableRate?: string;
|
|
1841
|
+
};
|
|
1842
|
+
}> {
|
|
1843
|
+
const {
|
|
1844
|
+
checkoutSession,
|
|
1845
|
+
lineItems,
|
|
1846
|
+
paymentCurrencyId,
|
|
1847
|
+
idempotencyKey,
|
|
1848
|
+
previewRate,
|
|
1849
|
+
priceConfirmed = false,
|
|
1850
|
+
trialing = false,
|
|
1851
|
+
} = options;
|
|
1852
|
+
|
|
1853
|
+
// Filter dynamic items, but exclude items that don't require payment (trialing recurring or metered)
|
|
1854
|
+
// This matches the logic in getCheckoutAmount (session.ts) to ensure quote creation
|
|
1855
|
+
// is consistent with actual payment amount calculation
|
|
1856
|
+
const dynamicItems = lineItems.filter((item) => {
|
|
1857
|
+
const price = item.upsell_price || item.price;
|
|
1858
|
+
if (price.pricing_type !== 'dynamic') {
|
|
1859
|
+
return false;
|
|
1860
|
+
}
|
|
1861
|
+
// Skip recurring items during trial period (they don't need payment during trial)
|
|
1862
|
+
if (price.type === 'recurring' && trialing) {
|
|
1863
|
+
return false;
|
|
1864
|
+
}
|
|
1865
|
+
// Skip metered items (usage-based billing, no upfront payment)
|
|
1866
|
+
if (price.type === 'recurring' && price.recurring?.usage_type === 'metered') {
|
|
1867
|
+
return false;
|
|
1868
|
+
}
|
|
1869
|
+
return true;
|
|
1870
|
+
});
|
|
1871
|
+
|
|
1872
|
+
if (!dynamicItems.length) {
|
|
1873
|
+
return { lineItems, consumedQuotes: [] };
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
// Skip quote creation for Stripe payments - they use base_amount (USD) directly
|
|
1877
|
+
// No exchange rate conversion needed
|
|
1878
|
+
const paymentCurrency = (await PaymentCurrency.findByPk(paymentCurrencyId, {
|
|
1879
|
+
include: [{ model: PaymentMethod, as: 'payment_method' }],
|
|
1880
|
+
})) as (PaymentCurrency & { payment_method: PaymentMethod }) | null;
|
|
1881
|
+
if (paymentCurrency?.payment_method?.type === 'stripe') {
|
|
1882
|
+
logger.info('Skipping quote creation for Stripe payment (base_amount is already USD)', {
|
|
1883
|
+
sessionId: checkoutSession.id,
|
|
1884
|
+
currencyId: paymentCurrencyId,
|
|
1885
|
+
dynamicItemCount: dynamicItems.length,
|
|
1886
|
+
});
|
|
1887
|
+
return { lineItems, consumedQuotes: [] };
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
const quoteService = getQuoteService();
|
|
1891
|
+
const slippageConfig = normalizeSlippageConfigFromMetadata(checkoutSession.metadata, DEFAULT_SLIPPAGE_PERCENT);
|
|
1892
|
+
const slippagePercent = slippageConfig?.percent ?? DEFAULT_SLIPPAGE_PERCENT;
|
|
1893
|
+
const minAcceptableRate = slippageConfig?.min_acceptable_rate;
|
|
1894
|
+
|
|
1895
|
+
// Early check: if user has set min_acceptable_rate, verify current rate is above it
|
|
1896
|
+
// This prevents users from submitting when the rate has already dropped below their limit
|
|
1897
|
+
if (minAcceptableRate) {
|
|
1898
|
+
try {
|
|
1899
|
+
const rateResult = await fetchCurrentExchangeRate(
|
|
1900
|
+
paymentCurrency as PaymentCurrency,
|
|
1901
|
+
paymentCurrency?.payment_method as PaymentMethod
|
|
1902
|
+
);
|
|
1903
|
+
|
|
1904
|
+
if (isRateBelowMinAcceptableRate(rateResult.rate, minAcceptableRate)) {
|
|
1905
|
+
logger.info('Current rate below user slippage limit', {
|
|
1906
|
+
sessionId: checkoutSession.id,
|
|
1907
|
+
currentRate: rateResult.rate,
|
|
1908
|
+
minAcceptableRate,
|
|
1909
|
+
slippagePercent,
|
|
1910
|
+
});
|
|
1911
|
+
|
|
1912
|
+
return {
|
|
1913
|
+
lineItems,
|
|
1914
|
+
consumedQuotes: [],
|
|
1915
|
+
validationError: {
|
|
1916
|
+
code: 'RATE_BELOW_SLIPPAGE_LIMIT',
|
|
1917
|
+
message: `Current exchange rate (${rateResult.rate}) is below your minimum acceptable rate (${minAcceptableRate}). Please update your slippage settings.`,
|
|
1918
|
+
currentRate: rateResult.rate,
|
|
1919
|
+
minAcceptableRate,
|
|
1920
|
+
},
|
|
1921
|
+
};
|
|
1922
|
+
}
|
|
1923
|
+
} catch (error: any) {
|
|
1924
|
+
logger.warn('Failed to fetch rate for slippage limit check', {
|
|
1925
|
+
sessionId: checkoutSession.id,
|
|
1926
|
+
error: error.message,
|
|
1927
|
+
});
|
|
1928
|
+
// Continue with normal flow if rate fetch fails - will be caught later
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
const consumedQuotes: PriceQuote[] = [];
|
|
1933
|
+
const updatedLineItems = [...lineItems];
|
|
1934
|
+
|
|
1935
|
+
// Create quotes for each dynamic item using the new validation flow
|
|
1936
|
+
// Sequential processing is required: fail fast on first validation error
|
|
1937
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1938
|
+
for (const item of dynamicItems) {
|
|
1939
|
+
// Use upsell_price_id if available, otherwise use price_id
|
|
1940
|
+
// This ensures quote is created with the correct base_amount (upsell price may have different base_amount)
|
|
1941
|
+
const priceId = item.upsell_price_id || item.price_id;
|
|
1942
|
+
const quantity = item.quantity || 1;
|
|
1943
|
+
|
|
1944
|
+
// Generate unique idempotency key per price
|
|
1945
|
+
const itemIdempotencyKey = `${idempotencyKey}:${priceId}`;
|
|
1946
|
+
|
|
1947
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1948
|
+
const result = await quoteService.createUsedQuoteWithValidation({
|
|
1949
|
+
price_id: priceId,
|
|
1950
|
+
session_id: checkoutSession.id,
|
|
1951
|
+
idempotency_key: itemIdempotencyKey,
|
|
1952
|
+
target_currency_id: paymentCurrencyId,
|
|
1953
|
+
quantity,
|
|
1954
|
+
preview_rate: previewRate,
|
|
1955
|
+
slippage_percent: slippagePercent,
|
|
1956
|
+
price_confirmed: priceConfirmed,
|
|
1957
|
+
});
|
|
1958
|
+
|
|
1959
|
+
// Check for validation errors
|
|
1960
|
+
if (!result.validation_passed) {
|
|
1961
|
+
logger.info('Quote creation validation failed', {
|
|
1962
|
+
sessionId: checkoutSession.id,
|
|
1963
|
+
priceId,
|
|
1964
|
+
errorCode: result.error_code,
|
|
1965
|
+
errorMessage: result.error_message,
|
|
1966
|
+
});
|
|
1967
|
+
|
|
1968
|
+
return {
|
|
1969
|
+
lineItems,
|
|
1970
|
+
consumedQuotes: [],
|
|
1971
|
+
validationError: {
|
|
1972
|
+
code: result.error_code!,
|
|
1973
|
+
message: result.error_message!,
|
|
1974
|
+
changePercent: result.change_percent,
|
|
1975
|
+
},
|
|
1976
|
+
};
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
consumedQuotes.push(result.quote);
|
|
1980
|
+
|
|
1981
|
+
// Update line item with quote reference
|
|
1982
|
+
// NOTE: Must use item.price_id (original) to find the line item, not priceId (which may be upsell_price_id)
|
|
1983
|
+
// Line items are always keyed by original price_id, even when upsell pricing is used
|
|
1984
|
+
const itemIndex = updatedLineItems.findIndex((li) => li.price_id === item.price_id);
|
|
1985
|
+
if (itemIndex >= 0) {
|
|
1986
|
+
(updatedLineItems[itemIndex] as any).quote_id = result.quote.id;
|
|
1987
|
+
(updatedLineItems[itemIndex] as any).quoted_amount = result.quote.quoted_amount;
|
|
1988
|
+
(updatedLineItems[itemIndex] as any).custom_amount = result.quote.quoted_amount;
|
|
1989
|
+
(updatedLineItems[itemIndex] as any).exchange_rate = result.quote.exchange_rate;
|
|
1990
|
+
(updatedLineItems[itemIndex] as any).rate_timestamp_ms = result.quote.rate_timestamp_ms;
|
|
1991
|
+
// Store the currency ID this quote was created for - essential for frontend validation
|
|
1992
|
+
(updatedLineItems[itemIndex] as any).quote_currency_id = result.quote.target_currency_id;
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
logger.info('Created quotes at submit (Final Freeze)', {
|
|
1997
|
+
sessionId: checkoutSession.id,
|
|
1998
|
+
quoteCount: consumedQuotes.length,
|
|
1999
|
+
quoteIds: consumedQuotes.map((q) => q.id),
|
|
2000
|
+
});
|
|
2001
|
+
|
|
2002
|
+
return { lineItems: updatedLineItems, consumedQuotes };
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
async function attachQuotesToLineItems(options: {
|
|
2006
|
+
checkoutSession: CheckoutSession;
|
|
2007
|
+
lineItems: TLineItemExpanded[];
|
|
2008
|
+
paymentCurrencyId: string;
|
|
2009
|
+
quotesInput?: QuoteInput[];
|
|
2010
|
+
strict?: boolean;
|
|
2011
|
+
}) {
|
|
2012
|
+
const { checkoutSession, lineItems, paymentCurrencyId, quotesInput = [], strict = false } = options;
|
|
2013
|
+
const dynamicItems = lineItems.filter((item) => (item.upsell_price || item.price).pricing_type === 'dynamic');
|
|
2014
|
+
const now = Math.floor(Date.now() / 1000);
|
|
2015
|
+
const quoteLockedAt = checkoutSession.metadata?.quote_locked_at;
|
|
2016
|
+
let lockStart = null;
|
|
2017
|
+
if (typeof quoteLockedAt === 'number') {
|
|
2018
|
+
lockStart = quoteLockedAt;
|
|
2019
|
+
} else if (typeof quoteLockedAt === 'string') {
|
|
2020
|
+
lockStart = Number(quoteLockedAt);
|
|
2021
|
+
}
|
|
2022
|
+
const lockActive = Number.isFinite(lockStart) && now - (lockStart as number) < QUOTE_LOCK_DURATION_SECONDS;
|
|
2023
|
+
// Final Freeze: Quotes only have 'used', 'paid', 'payment_failed' statuses
|
|
2024
|
+
// 'used' quotes are valid for payment (created at submit, immutable)
|
|
2025
|
+
const isQuoteUsable = (quote: PriceQuote | null) => {
|
|
2026
|
+
if (!quote) {
|
|
2027
|
+
return false;
|
|
2028
|
+
}
|
|
2029
|
+
// 'used' status means quote was created at submit and is ready for payment
|
|
2030
|
+
if (quote.status === 'used') {
|
|
2031
|
+
return true;
|
|
2032
|
+
}
|
|
2033
|
+
// 'payment_failed' quotes can be retried
|
|
2034
|
+
if (quote.status === 'payment_failed') {
|
|
2035
|
+
return true;
|
|
2036
|
+
}
|
|
2037
|
+
return false;
|
|
2038
|
+
};
|
|
2039
|
+
|
|
2040
|
+
if (!dynamicItems.length) {
|
|
2041
|
+
return { lineItems, consumedQuotes: [] as PriceQuote[] };
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
let quotes: PriceQuote[] = [];
|
|
2045
|
+
|
|
2046
|
+
// ✅ Auto-find quotes if not provided by frontend
|
|
2047
|
+
if (quotesInput.length === 0) {
|
|
2048
|
+
if (strict) {
|
|
2049
|
+
const quoteMap = new Map<string, string>();
|
|
2050
|
+
dynamicItems.forEach((item) => {
|
|
2051
|
+
const quoteId = (item as any).quote_id;
|
|
2052
|
+
if (quoteId) {
|
|
2053
|
+
quoteMap.set(item.price_id, quoteId);
|
|
2054
|
+
}
|
|
2055
|
+
});
|
|
2056
|
+
|
|
2057
|
+
if (quoteMap.size < dynamicItems.length) {
|
|
2058
|
+
throw new CustomError(400, 'QUOTE_REQUIRED');
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
const quoteIds = Array.from(new Set(Array.from(quoteMap.values())));
|
|
2062
|
+
quotes = await PriceQuote.findAll({ where: { id: quoteIds } });
|
|
2063
|
+
const quotesById = new Map(quotes.map((q) => [q.id, q]));
|
|
2064
|
+
|
|
2065
|
+
// ✅ Pre-fetch payment currency and rate once (optimization: avoid repeated fetches in loop)
|
|
2066
|
+
let paymentCurrency: (PaymentCurrency & { payment_method: PaymentMethod }) | null = null;
|
|
2067
|
+
let rateResult: any = null;
|
|
2068
|
+
let slippagePercent = 0.5;
|
|
2069
|
+
|
|
2070
|
+
// Final Freeze: Quotes are immutable after creation
|
|
2071
|
+
// No refresh logic needed - quotes are created at submit with final price
|
|
2072
|
+
// Just verify all quotes are in usable state
|
|
2073
|
+
const unusableQuotes = dynamicItems
|
|
2074
|
+
.map((item) => {
|
|
2075
|
+
const quoteId = quoteMap.get(item.price_id);
|
|
2076
|
+
const quoteRecord = quoteId ? quotesById.get(quoteId) : null;
|
|
2077
|
+
return quoteRecord && !isQuoteUsable(quoteRecord) ? { item, quoteRecord } : null;
|
|
2078
|
+
})
|
|
2079
|
+
.filter(Boolean) as Array<{ item: any; quoteRecord: PriceQuote }>;
|
|
2080
|
+
|
|
2081
|
+
if (unusableQuotes.length > 0) {
|
|
2082
|
+
logger.warn('Found unusable quotes - payment may fail', {
|
|
2083
|
+
count: unusableQuotes.length,
|
|
2084
|
+
quoteIds: unusableQuotes.map((q) => q.quoteRecord.id),
|
|
2085
|
+
statuses: unusableQuotes.map((q) => q.quoteRecord.status),
|
|
2086
|
+
});
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
// Legacy code below kept for backward compatibility but should not execute
|
|
2090
|
+
// in Final Freeze architecture since quotes are never refreshed
|
|
2091
|
+
const quotesNeedingRefresh: Array<{ item: any; quoteRecord: PriceQuote }> = [];
|
|
2092
|
+
|
|
2093
|
+
if (quotesNeedingRefresh.length > 0) {
|
|
2094
|
+
logger.info('Detected quotes needing refresh, fetching rate once', {
|
|
2095
|
+
count: quotesNeedingRefresh.length,
|
|
2096
|
+
quoteIds: quotesNeedingRefresh.map((q) => q.quoteRecord.id),
|
|
2097
|
+
});
|
|
2098
|
+
|
|
2099
|
+
// Fetch payment currency and rate once for all quotes
|
|
2100
|
+
paymentCurrency = (await PaymentCurrency.findByPk(paymentCurrencyId, {
|
|
2101
|
+
include: [{ model: PaymentMethod, as: 'payment_method' }],
|
|
2102
|
+
})) as PaymentCurrency & { payment_method: PaymentMethod };
|
|
2103
|
+
|
|
2104
|
+
if (!paymentCurrency) {
|
|
2105
|
+
throw new Error(`Currency ${paymentCurrencyId} not found`);
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
const rateSymbol = getExchangeRateSymbol(
|
|
2109
|
+
paymentCurrency.symbol,
|
|
2110
|
+
paymentCurrency.payment_method?.type as ChainType
|
|
2111
|
+
);
|
|
2112
|
+
rateResult = await exchangeRateService.getRate(rateSymbol);
|
|
2113
|
+
|
|
2114
|
+
slippagePercent =
|
|
2115
|
+
normalizeSlippageConfigFromMetadata(checkoutSession.metadata, DEFAULT_SLIPPAGE_PERCENT)?.percent ??
|
|
2116
|
+
DEFAULT_SLIPPAGE_PERCENT;
|
|
2117
|
+
|
|
2118
|
+
// ✅ Refresh quotes sequentially to avoid SQLite BUSY errors
|
|
2119
|
+
// Using pAll with concurrency 1 ensures no concurrent writes to the database
|
|
2120
|
+
const quoteService = getQuoteService();
|
|
2121
|
+
try {
|
|
2122
|
+
const refreshTasks = quotesNeedingRefresh.map(({ item, quoteRecord }) => {
|
|
2123
|
+
return async () => {
|
|
2124
|
+
logger.info('Auto-refreshing quote on backend', {
|
|
2125
|
+
quoteId: quoteRecord.id,
|
|
2126
|
+
status: quoteRecord.status,
|
|
2127
|
+
lockActive,
|
|
2128
|
+
});
|
|
2129
|
+
|
|
2130
|
+
const result = await quoteService.refreshQuoteWithRate({
|
|
2131
|
+
quote_id: quoteRecord.id,
|
|
2132
|
+
price_id: item.price_id,
|
|
2133
|
+
session_id: checkoutSession.id,
|
|
2134
|
+
invoice_id: undefined,
|
|
2135
|
+
target_currency_id: paymentCurrencyId,
|
|
2136
|
+
quantity: item.quantity || 1,
|
|
2137
|
+
rateResult,
|
|
2138
|
+
slippage_percent: slippagePercent,
|
|
2139
|
+
});
|
|
2140
|
+
|
|
2141
|
+
return { quoteRecord, result };
|
|
2142
|
+
};
|
|
2143
|
+
});
|
|
2144
|
+
|
|
2145
|
+
// Use concurrency 1 to avoid SQLite BUSY errors
|
|
2146
|
+
const refreshResults = await pAll(refreshTasks, { concurrency: 1 });
|
|
2147
|
+
|
|
2148
|
+
// Update quoteRecord references with refreshed data
|
|
2149
|
+
refreshResults.forEach(({ quoteRecord, result }) => {
|
|
2150
|
+
const refreshedQuote = result.quote;
|
|
2151
|
+
logger.info('Quote auto-refreshed successfully on backend', {
|
|
2152
|
+
quoteId: refreshedQuote.id,
|
|
2153
|
+
newExpiresAt: refreshedQuote.expires_at,
|
|
2154
|
+
newStatus: refreshedQuote.status,
|
|
2155
|
+
});
|
|
2156
|
+
Object.assign(quoteRecord, refreshedQuote.toJSON());
|
|
2157
|
+
});
|
|
2158
|
+
} catch (refreshError: any) {
|
|
2159
|
+
logger.error('Failed to auto-refresh quotes on backend', {
|
|
2160
|
+
error: refreshError.message,
|
|
2161
|
+
quoteIds: quotesNeedingRefresh.map((q) => q.quoteRecord.id),
|
|
2162
|
+
});
|
|
2163
|
+
throw new CustomError(400, 'QUOTE_REFRESH_FAILED', 'Failed to refresh quote automatically');
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
// ✅ Validate all quotes (synchronous loop is fine for validation)
|
|
2168
|
+
for (const item of dynamicItems) {
|
|
2169
|
+
const quoteId = quoteMap.get(item.price_id);
|
|
2170
|
+
if (!quoteId) {
|
|
2171
|
+
throw new CustomError(400, 'QUOTE_REQUIRED');
|
|
2172
|
+
}
|
|
2173
|
+
const quoteRecord = quotesById.get(quoteId);
|
|
2174
|
+
if (!quoteRecord) {
|
|
2175
|
+
throw new CustomError(400, 'QUOTE_NOT_FOUND');
|
|
2176
|
+
}
|
|
2177
|
+
// Use effective price_id: upsell_price_id takes precedence (quote was created with upsell price)
|
|
2178
|
+
const effectivePriceId = item.upsell_price_id || item.price_id;
|
|
2179
|
+
if (quoteRecord.price_id !== effectivePriceId) {
|
|
2180
|
+
throw new CustomError(400, 'QUOTE_PRICE_MISMATCH');
|
|
2181
|
+
}
|
|
2182
|
+
if (quoteRecord.target_currency_id !== paymentCurrencyId) {
|
|
2183
|
+
throw new CustomError(400, 'QUOTE_CURRENCY_MISMATCH');
|
|
2184
|
+
}
|
|
2185
|
+
const price = (item.upsell_price || item.price) as any;
|
|
2186
|
+
if (quoteRecord.base_currency !== price.base_currency) {
|
|
2187
|
+
throw new CustomError(400, 'QUOTE_BASE_CURRENCY_MISMATCH');
|
|
2188
|
+
}
|
|
2189
|
+
if (quoteRecord.session_id && quoteRecord.session_id !== checkoutSession.id) {
|
|
2190
|
+
throw new CustomError(400, 'QUOTE_SESSION_MISMATCH');
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
const expectedBaseAmount = calculateTotalBaseAmount(price.base_amount || '0', Number(item.quantity || 0));
|
|
2194
|
+
const USD_DECIMALS = 8;
|
|
2195
|
+
const expectedBaseAmountTrimmed = trimDecimals(expectedBaseAmount, USD_DECIMALS);
|
|
2196
|
+
const quoteBaseAmountTrimmed = trimDecimals(quoteRecord.base_amount, USD_DECIMALS);
|
|
2197
|
+
if (quoteBaseAmountTrimmed !== expectedBaseAmountTrimmed) {
|
|
2198
|
+
throw new CustomError(
|
|
2199
|
+
400,
|
|
2200
|
+
'QUOTE_AMOUNT_MISMATCH',
|
|
2201
|
+
`Quote amount mismatch for price ${item.price_id}. Expected ${expectedBaseAmountTrimmed} but got ${quoteBaseAmountTrimmed}. Please refresh the page.`
|
|
2202
|
+
);
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
if (!isQuoteUsable(quoteRecord)) {
|
|
2206
|
+
throw new CustomError(400, 'QUOTE_EXPIRED_OR_USED', 'Quote not usable');
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
} else {
|
|
2210
|
+
logger.info('Auto-finding quotes for checkout session', { sessionId: checkoutSession.id });
|
|
2211
|
+
|
|
2212
|
+
// Find all active quotes for this session
|
|
2213
|
+
// Use effective price_id: upsell_price_id takes precedence (quote was created with upsell price)
|
|
2214
|
+
const priceIds = dynamicItems.map((item) => item.upsell_price_id || item.price_id);
|
|
2215
|
+
quotes = await PriceQuote.findAll({
|
|
2216
|
+
where: {
|
|
2217
|
+
session_id: checkoutSession.id,
|
|
2218
|
+
target_currency_id: paymentCurrencyId,
|
|
2219
|
+
status: 'active',
|
|
2220
|
+
expires_at: { [Op.gt]: Math.floor(Date.now() / 1000) },
|
|
2221
|
+
price_id: { [Op.in]: priceIds },
|
|
2222
|
+
},
|
|
2223
|
+
order: [['created_at', 'DESC']], // Use latest quote if multiple exist
|
|
2224
|
+
});
|
|
2225
|
+
|
|
2226
|
+
// Validate: each dynamic item must have a corresponding quote
|
|
2227
|
+
// Pick the latest quote per price_id (query is ordered by created_at DESC)
|
|
2228
|
+
const quotesByPriceId = new Map<string, PriceQuote>();
|
|
2229
|
+
for (const quote of quotes) {
|
|
2230
|
+
if (!quotesByPriceId.has(quote.price_id)) {
|
|
2231
|
+
quotesByPriceId.set(quote.price_id, quote);
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
quotes = Array.from(quotesByPriceId.values());
|
|
2235
|
+
|
|
2236
|
+
for (const item of dynamicItems) {
|
|
2237
|
+
const effectivePriceId = item.upsell_price_id || item.price_id;
|
|
2238
|
+
const quote = quotesByPriceId.get(effectivePriceId);
|
|
2239
|
+
if (!quote) {
|
|
2240
|
+
throw new CustomError(
|
|
2241
|
+
400,
|
|
2242
|
+
'QUOTE_NOT_FOUND',
|
|
2243
|
+
`No active quote found for price ${effectivePriceId}. Please refresh the page to generate a new quote.`
|
|
2244
|
+
);
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
// Validate base_amount matches (quantity * unit_price)
|
|
2248
|
+
// Use same precision calculation as quote creation
|
|
2249
|
+
const price = (item.upsell_price || item.price) as any;
|
|
2250
|
+
const expectedBaseAmount = calculateTotalBaseAmount(price.base_amount || '0', Number(item.quantity || 0));
|
|
2251
|
+
const USD_DECIMALS = 8;
|
|
2252
|
+
const expectedBaseAmountTrimmed = trimDecimals(expectedBaseAmount, USD_DECIMALS);
|
|
2253
|
+
const quoteBaseAmountTrimmed = trimDecimals(quote.base_amount, USD_DECIMALS);
|
|
2254
|
+
if (quoteBaseAmountTrimmed !== expectedBaseAmountTrimmed) {
|
|
2255
|
+
throw new CustomError(
|
|
2256
|
+
400,
|
|
2257
|
+
'QUOTE_AMOUNT_MISMATCH',
|
|
2258
|
+
`Quote amount mismatch for price ${item.price_id}. Expected ${expectedBaseAmountTrimmed} but got ${quoteBaseAmountTrimmed}. Please refresh the page.`
|
|
2259
|
+
);
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
logger.info('Auto-found quotes successfully', {
|
|
2264
|
+
sessionId: checkoutSession.id,
|
|
2265
|
+
quoteCount: quotes.length,
|
|
2266
|
+
quoteIds: quotes.map((q) => q.id),
|
|
2267
|
+
});
|
|
2268
|
+
}
|
|
2269
|
+
} else {
|
|
2270
|
+
// Legacy: use quotes provided by frontend (for backward compatibility)
|
|
2271
|
+
logger.info('Using frontend-provided quotes', {
|
|
2272
|
+
sessionId: checkoutSession.id,
|
|
2273
|
+
quotesInput,
|
|
2274
|
+
});
|
|
2275
|
+
|
|
2276
|
+
const quoteMap = new Map<string, string>();
|
|
2277
|
+
quotesInput.forEach((x) => {
|
|
2278
|
+
if (x.price_id && x.quote_id) {
|
|
2279
|
+
quoteMap.set(x.price_id, x.quote_id);
|
|
2280
|
+
}
|
|
2281
|
+
});
|
|
2282
|
+
|
|
2283
|
+
if (quoteMap.size < dynamicItems.length) {
|
|
2284
|
+
throw new CustomError(400, 'QUOTE_REQUIRED');
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
const quoteIds = Array.from(quoteMap.values());
|
|
2288
|
+
|
|
2289
|
+
// Step 1: Pre-validation outside transaction (fast fail)
|
|
2290
|
+
quotes = await PriceQuote.findAll({
|
|
2291
|
+
where: { id: quoteIds },
|
|
2292
|
+
});
|
|
2293
|
+
const quotesById = new Map(quotes.map((q) => [q.id, q]));
|
|
2294
|
+
|
|
2295
|
+
// Validate all quotes before starting transaction
|
|
2296
|
+
for (const item of dynamicItems) {
|
|
2297
|
+
const quoteId = quoteMap.get(item.price_id);
|
|
2298
|
+
if (!quoteId) {
|
|
2299
|
+
throw new CustomError(400, 'QUOTE_REQUIRED');
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
const quoteRecord = quotesById.get(quoteId);
|
|
2303
|
+
if (!quoteRecord) {
|
|
2304
|
+
throw new CustomError(400, 'QUOTE_NOT_FOUND');
|
|
2305
|
+
}
|
|
2306
|
+
// Use effective price_id: upsell_price_id takes precedence (quote was created with upsell price)
|
|
2307
|
+
const effectivePriceId = item.upsell_price_id || item.price_id;
|
|
2308
|
+
if (quoteRecord.price_id !== effectivePriceId) {
|
|
2309
|
+
throw new CustomError(400, 'QUOTE_PRICE_MISMATCH');
|
|
2310
|
+
}
|
|
2311
|
+
if (quoteRecord.target_currency_id !== paymentCurrencyId) {
|
|
2312
|
+
throw new CustomError(400, 'QUOTE_CURRENCY_MISMATCH');
|
|
2313
|
+
}
|
|
2314
|
+
const price = (item.upsell_price || item.price) as any;
|
|
2315
|
+
if (quoteRecord.base_currency !== price.base_currency) {
|
|
2316
|
+
throw new CustomError(400, 'QUOTE_BASE_CURRENCY_MISMATCH');
|
|
2317
|
+
}
|
|
2318
|
+
if (quoteRecord.session_id && quoteRecord.session_id !== checkoutSession.id) {
|
|
2319
|
+
throw new CustomError(400, 'QUOTE_SESSION_MISMATCH');
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
if (quoteRecord.status === 'used' && !lockActive) {
|
|
2323
|
+
throw new CustomError(409, 'QUOTE_LOCK_EXPIRED', 'Quote lock expired, please refresh and retry');
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
const expectedBaseAmount = calculateTotalBaseAmount(price.base_amount || '0', Number(item.quantity || 0));
|
|
2327
|
+
const USD_DECIMALS = 8;
|
|
2328
|
+
const expectedBaseAmountTrimmed = trimDecimals(expectedBaseAmount, USD_DECIMALS);
|
|
2329
|
+
const quoteBaseAmountTrimmed = trimDecimals(quoteRecord.base_amount, USD_DECIMALS);
|
|
2330
|
+
if (quoteBaseAmountTrimmed !== expectedBaseAmountTrimmed) {
|
|
2331
|
+
throw new CustomError(
|
|
2332
|
+
400,
|
|
2333
|
+
'QUOTE_AMOUNT_MISMATCH',
|
|
2334
|
+
`Quote amount mismatch for price ${item.price_id}. Expected ${expectedBaseAmountTrimmed} but got ${quoteBaseAmountTrimmed}. Please refresh the page.`
|
|
2335
|
+
);
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
// Check if active
|
|
2339
|
+
if (!isQuoteUsable(quoteRecord)) {
|
|
2340
|
+
throw new CustomError(400, 'QUOTE_EXPIRED_OR_USED');
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
// Get quote IDs for locking
|
|
2346
|
+
const quoteIds = quotes.map((q) => q.id);
|
|
2347
|
+
|
|
2348
|
+
// Step 2: Minimal transaction - batch update status only
|
|
2349
|
+
const transaction = await sequelize.transaction();
|
|
2350
|
+
const consumedQuotes: PriceQuote[] = [];
|
|
2351
|
+
|
|
2352
|
+
try {
|
|
2353
|
+
// Re-fetch with lock and verify status didn't change
|
|
2354
|
+
const lockedQuotes = await PriceQuote.findAll({
|
|
2355
|
+
where: { id: quoteIds },
|
|
2356
|
+
transaction,
|
|
2357
|
+
lock: transaction.LOCK.UPDATE,
|
|
2358
|
+
});
|
|
2359
|
+
const lockedQuotesById = new Map(lockedQuotes.map((q) => [q.id, q]));
|
|
2360
|
+
|
|
2361
|
+
// Double-check all quotes are still active under lock
|
|
2362
|
+
for (const quoteId of quoteIds) {
|
|
2363
|
+
const quote = lockedQuotesById.get(quoteId) || null;
|
|
2364
|
+
if (!isQuoteUsable(quote)) {
|
|
2365
|
+
if (quote?.status === 'used' && !lockActive) {
|
|
2366
|
+
throw new CustomError(409, 'QUOTE_LOCK_EXPIRED', 'Quote lock expired, please refresh and retry');
|
|
2367
|
+
}
|
|
2368
|
+
throw new CustomError(400, 'QUOTE_EXPIRED_OR_USED');
|
|
2369
|
+
}
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
// Update quotes to 'used' with slippage snapshot
|
|
2373
|
+
const updatedQuotes: PriceQuote[] = [];
|
|
2374
|
+
// Sequential updates needed for transaction consistency
|
|
2375
|
+
for (const quoteId of quoteIds) {
|
|
2376
|
+
const quote = lockedQuotesById.get(quoteId);
|
|
2377
|
+
if (quote) {
|
|
2378
|
+
if (quote.status === 'used') {
|
|
2379
|
+
updatedQuotes.push(quote);
|
|
2380
|
+
} else {
|
|
2381
|
+
const slippageSnapshot = buildSlippageSnapshot({ quote, checkoutSession });
|
|
2382
|
+
const updatePayload: Record<string, any> = { status: 'used' };
|
|
2383
|
+
if (slippageSnapshot) {
|
|
2384
|
+
updatePayload.slippage_percent = slippageSnapshot.percent;
|
|
2385
|
+
updatePayload.max_payable_token = slippageSnapshot.max_payable_token;
|
|
2386
|
+
updatePayload.min_acceptable_rate = slippageSnapshot.min_acceptable_rate;
|
|
2387
|
+
updatePayload.slippage_derived_at_ms = slippageSnapshot.derived_at_ms;
|
|
2388
|
+
updatePayload.metadata = {
|
|
2389
|
+
...(quote.metadata || {}),
|
|
2390
|
+
slippage: {
|
|
2391
|
+
...((quote.metadata as any)?.slippage || {}),
|
|
2392
|
+
...slippageSnapshot,
|
|
2393
|
+
},
|
|
2394
|
+
};
|
|
2395
|
+
}
|
|
2396
|
+
// eslint-disable-next-line no-await-in-loop
|
|
2397
|
+
const updatedQuote = await updateQuoteWithRetry(quote, updatePayload, transaction);
|
|
2398
|
+
updatedQuotes.push(updatedQuote);
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
consumedQuotes.push(...updatedQuotes);
|
|
2403
|
+
|
|
2404
|
+
await transaction.commit();
|
|
2405
|
+
|
|
2406
|
+
const quotesByPriceId = new Map(consumedQuotes.map((q) => [q.price_id, q]));
|
|
2407
|
+
const enrichedLineItems = lineItems.map((item) => {
|
|
2408
|
+
// Use effective price_id: upsell_price takes precedence (quote was created with upsell price)
|
|
2409
|
+
const effectivePriceId = item.upsell_price?.id || item.price.id;
|
|
2410
|
+
const quote = quotesByPriceId.get(effectivePriceId);
|
|
2411
|
+
if (!quote) {
|
|
2412
|
+
return item;
|
|
2413
|
+
}
|
|
2414
|
+
return {
|
|
2415
|
+
...item,
|
|
2416
|
+
quote_id: quote.id,
|
|
2417
|
+
quoted_amount: quote.quoted_amount,
|
|
2418
|
+
expires_at: quote.expires_at,
|
|
2419
|
+
exchange_rate: quote.exchange_rate,
|
|
2420
|
+
rate_provider_name: quote.rate_provider_name,
|
|
2421
|
+
rate_provider_id: quote.rate_provider_id,
|
|
2422
|
+
custom_amount: quote.quoted_amount,
|
|
2423
|
+
// Store the currency ID this quote was created for - essential for frontend validation
|
|
2424
|
+
quote_currency_id: quote.target_currency_id,
|
|
2425
|
+
};
|
|
2426
|
+
});
|
|
2427
|
+
|
|
2428
|
+
return { lineItems: enrichedLineItems, consumedQuotes };
|
|
2429
|
+
} catch (err) {
|
|
2430
|
+
await transaction.rollback();
|
|
2431
|
+
throw err;
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2435
|
+
async function lockCheckoutSessionQuotes(checkoutSession: CheckoutSession) {
|
|
2436
|
+
const lockedAt = Math.floor(Date.now() / 1000);
|
|
2437
|
+
const metadata = {
|
|
2438
|
+
...(checkoutSession.metadata || {}),
|
|
2439
|
+
quote_locked_at: lockedAt,
|
|
2440
|
+
};
|
|
2441
|
+
await checkoutSession.update({ metadata });
|
|
2442
|
+
checkoutSession.metadata = metadata;
|
|
2443
|
+
return lockedAt;
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
/**
|
|
2447
|
+
* @deprecated Final Freeze: Quotes are NO longer prefetched during session creation.
|
|
2448
|
+
* Quotes are created ONLY at Submit time via createUsedQuoteWithValidation.
|
|
2449
|
+
* This function is kept for backward compatibility but does nothing.
|
|
2450
|
+
* @see Intent: blocklets/core/ai/intent/20260112-dynamic-price.md
|
|
2451
|
+
*/
|
|
2452
|
+
async function prefetchQuotesForCheckoutSession(checkoutSession: CheckoutSession) {
|
|
2453
|
+
const expandedLineItems = await Price.expand(checkoutSession.line_items, { product: true, upsell: true });
|
|
2454
|
+
const hasDynamic = expandedLineItems.some((item) => (item.upsell_price || item.price).pricing_type === 'dynamic');
|
|
2455
|
+
if (!hasDynamic) {
|
|
2456
|
+
return;
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2459
|
+
// Final Freeze: Don't prefetch/create Quotes during session creation
|
|
2460
|
+
// Quotes are created ONLY at Submit time
|
|
2461
|
+
logger.info('Skipping quote prefetch for checkout session (Final Freeze)', {
|
|
2462
|
+
checkoutSessionId: checkoutSession.id,
|
|
2463
|
+
hasDynamicPricing: true,
|
|
2464
|
+
});
|
|
2465
|
+
}
|
|
2466
|
+
|
|
1435
2467
|
// submit order
|
|
1436
2468
|
router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
|
|
2469
|
+
let consumedQuotes: PriceQuote[] = [];
|
|
2470
|
+
const checkoutSession = req.doc as CheckoutSession;
|
|
2471
|
+
|
|
1437
2472
|
try {
|
|
1438
2473
|
if (!req.user) {
|
|
1439
2474
|
return res.status(403).json({ code: 'REQUIRE_LOGIN', error: 'Please login to continue' });
|
|
1440
2475
|
}
|
|
1441
2476
|
|
|
2477
|
+
// Idempotency check: If already complete/paid, return existing state
|
|
2478
|
+
if (checkoutSession.status === 'complete' || checkoutSession.payment_status === 'paid') {
|
|
2479
|
+
await checkoutSession.reload();
|
|
2480
|
+
const existingPaymentIntent = checkoutSession.payment_intent_id
|
|
2481
|
+
? await PaymentIntent.findByPk(checkoutSession.payment_intent_id)
|
|
2482
|
+
: null;
|
|
2483
|
+
const existingInvoice = checkoutSession.invoice_id
|
|
2484
|
+
? await Invoice.findOne({ where: { checkout_session_id: checkoutSession.id } })
|
|
2485
|
+
: null;
|
|
2486
|
+
|
|
2487
|
+
const quoteIds = (existingPaymentIntent?.metadata as any)?.quoteIds || [];
|
|
2488
|
+
const quotes = quoteIds.length > 0 ? await PriceQuote.findAll({ where: { id: quoteIds } }) : [];
|
|
2489
|
+
|
|
2490
|
+
return res.status(200).json({
|
|
2491
|
+
checkoutSession,
|
|
2492
|
+
paymentIntent: existingPaymentIntent,
|
|
2493
|
+
invoice: existingInvoice,
|
|
2494
|
+
quotes: quotes.map((q) => ({
|
|
2495
|
+
id: q.id,
|
|
2496
|
+
status: q.status,
|
|
2497
|
+
quoted_amount: q.quoted_amount,
|
|
2498
|
+
expires_at: q.expires_at,
|
|
2499
|
+
})),
|
|
2500
|
+
message: 'Checkout session already completed',
|
|
2501
|
+
});
|
|
2502
|
+
}
|
|
2503
|
+
|
|
1442
2504
|
const hasVendorConfig = await checkVendorConfig(req.doc.line_items);
|
|
1443
2505
|
if (hasVendorConfig) {
|
|
1444
2506
|
const { user: userDetail } = await blocklet.getUser(req.user.did, { enableConnectedAccount: true });
|
|
@@ -1450,7 +2512,6 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
|
|
|
1450
2512
|
}
|
|
1451
2513
|
}
|
|
1452
2514
|
|
|
1453
|
-
const checkoutSession = req.doc as CheckoutSession;
|
|
1454
2515
|
if (checkoutSession.line_items) {
|
|
1455
2516
|
try {
|
|
1456
2517
|
await validateInventory(checkoutSession.line_items);
|
|
@@ -1485,6 +2546,198 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
|
|
|
1485
2546
|
metadata: { ...checkoutSession.metadata, preferred_locale: req.locale },
|
|
1486
2547
|
});
|
|
1487
2548
|
|
|
2549
|
+
const expandedLineItems = await Price.expand(checkoutSession.line_items, { product: true, upsell: true });
|
|
2550
|
+
let lineItemsWithQuotes = expandedLineItems;
|
|
2551
|
+
|
|
2552
|
+
// Get trial status for subscription mode - needed to skip quote creation for trialing items
|
|
2553
|
+
const submitNow = dayjs().unix();
|
|
2554
|
+
const trialSetup = getSubscriptionTrialSetup(checkoutSession.subscription_data as any, paymentCurrency.id);
|
|
2555
|
+
const isTrialing = trialSetup.trialInDays > 0 || trialSetup.trialEnd > submitNow;
|
|
2556
|
+
|
|
2557
|
+
// Check if using Final Freeze flow (idempotency_key provided)
|
|
2558
|
+
const useFinalFreezeFlow = !!req.body.idempotency_key;
|
|
2559
|
+
|
|
2560
|
+
if (useFinalFreezeFlow) {
|
|
2561
|
+
// Final Freeze: Create quotes at submit time with dual-layer validation
|
|
2562
|
+
logger.info('Using Final Freeze quote creation flow', {
|
|
2563
|
+
sessionId: checkoutSession.id,
|
|
2564
|
+
idempotencyKey: req.body.idempotency_key,
|
|
2565
|
+
isTrialing,
|
|
2566
|
+
});
|
|
2567
|
+
|
|
2568
|
+
const quoteResult = await createQuotesAtSubmit({
|
|
2569
|
+
checkoutSession,
|
|
2570
|
+
lineItems: expandedLineItems,
|
|
2571
|
+
paymentCurrencyId: paymentCurrency.id,
|
|
2572
|
+
idempotencyKey: req.body.idempotency_key,
|
|
2573
|
+
previewRate: req.body.preview_rate,
|
|
2574
|
+
priceConfirmed: req.body.price_confirmed === true,
|
|
2575
|
+
trialing: isTrialing,
|
|
2576
|
+
});
|
|
2577
|
+
|
|
2578
|
+
// Handle validation errors (PRICE_UNAVAILABLE, PRICE_UNSTABLE, PRICE_CHANGED, RATE_BELOW_SLIPPAGE_LIMIT)
|
|
2579
|
+
if (quoteResult.validationError) {
|
|
2580
|
+
const { code, message, changePercent, currentRate, minAcceptableRate } = quoteResult.validationError;
|
|
2581
|
+
// RATE_BELOW_SLIPPAGE_LIMIT: 400 - user needs to update slippage settings
|
|
2582
|
+
// PRICE_CHANGED: 409 - price changed, needs confirmation
|
|
2583
|
+
// PRICE_UNAVAILABLE/PRICE_UNSTABLE: 503 - service temporarily unavailable
|
|
2584
|
+
let statusCode = 503;
|
|
2585
|
+
if (code === 'RATE_BELOW_SLIPPAGE_LIMIT') {
|
|
2586
|
+
statusCode = 400;
|
|
2587
|
+
} else if (code === 'PRICE_CHANGED') {
|
|
2588
|
+
statusCode = 409;
|
|
2589
|
+
}
|
|
2590
|
+
return res.status(statusCode).json({
|
|
2591
|
+
code,
|
|
2592
|
+
error: message,
|
|
2593
|
+
change_percent: changePercent,
|
|
2594
|
+
// Include extra data for PRICE_CHANGED to help frontend display confirmation dialog
|
|
2595
|
+
...(code === 'PRICE_CHANGED' && {
|
|
2596
|
+
requires_confirmation: true,
|
|
2597
|
+
}),
|
|
2598
|
+
// Include rate info for RATE_BELOW_SLIPPAGE_LIMIT to help frontend display update dialog
|
|
2599
|
+
...(code === 'RATE_BELOW_SLIPPAGE_LIMIT' && {
|
|
2600
|
+
current_rate: currentRate,
|
|
2601
|
+
min_acceptable_rate: minAcceptableRate,
|
|
2602
|
+
}),
|
|
2603
|
+
});
|
|
2604
|
+
}
|
|
2605
|
+
|
|
2606
|
+
consumedQuotes = quoteResult.consumedQuotes;
|
|
2607
|
+
lineItemsWithQuotes = quoteResult.lineItems;
|
|
2608
|
+
|
|
2609
|
+
if (consumedQuotes.length) {
|
|
2610
|
+
await lockCheckoutSessionQuotes(checkoutSession);
|
|
2611
|
+
}
|
|
2612
|
+
} else {
|
|
2613
|
+
// Legacy flow: Use pre-created quotes
|
|
2614
|
+
try {
|
|
2615
|
+
const quoteResult = await attachQuotesToLineItems({
|
|
2616
|
+
checkoutSession,
|
|
2617
|
+
lineItems: expandedLineItems,
|
|
2618
|
+
paymentCurrencyId: paymentCurrency.id,
|
|
2619
|
+
quotesInput: req.body.quotes,
|
|
2620
|
+
strict: true,
|
|
2621
|
+
});
|
|
2622
|
+
consumedQuotes = quoteResult.consumedQuotes;
|
|
2623
|
+
lineItemsWithQuotes = quoteResult.lineItems;
|
|
2624
|
+
if (consumedQuotes.length) {
|
|
2625
|
+
await lockCheckoutSessionQuotes(checkoutSession);
|
|
2626
|
+
}
|
|
2627
|
+
} catch (err) {
|
|
2628
|
+
if (err instanceof CustomError) {
|
|
2629
|
+
const errCode = getQuoteErrorCode(err) || err.code || 'QUOTE_INVALID';
|
|
2630
|
+
if (
|
|
2631
|
+
[
|
|
2632
|
+
'QUOTE_AMOUNT_MISMATCH',
|
|
2633
|
+
'QUOTE_EXPIRED_OR_USED',
|
|
2634
|
+
'QUOTE_NOT_FOUND',
|
|
2635
|
+
'QUOTE_REQUIRED',
|
|
2636
|
+
'QUOTE_LOCK_EXPIRED',
|
|
2637
|
+
].includes(String(errCode))
|
|
2638
|
+
) {
|
|
2639
|
+
return res.status(409).json({ code: String(errCode), error: err.message });
|
|
2640
|
+
}
|
|
2641
|
+
return res.status(400).json({ code: String(errCode), error: err.message });
|
|
2642
|
+
}
|
|
2643
|
+
throw err;
|
|
2644
|
+
}
|
|
2645
|
+
}
|
|
2646
|
+
|
|
2647
|
+
// Save quote_id and quote_currency_id to CheckoutSession.line_items for future reference
|
|
2648
|
+
// Preserve all existing fields in line_items, but update quote-related fields
|
|
2649
|
+
// Quote data is the single source of truth in PriceQuote table
|
|
2650
|
+
// Also update metadata.slippage with min_acceptable_rate for subscription creation
|
|
2651
|
+
const quoteWithMinRate = consumedQuotes.find((q) => q.min_acceptable_rate);
|
|
2652
|
+
let updatedMetadata = checkoutSession.metadata;
|
|
2653
|
+
|
|
2654
|
+
if (quoteWithMinRate) {
|
|
2655
|
+
// Use min_acceptable_rate from quote
|
|
2656
|
+
updatedMetadata = {
|
|
2657
|
+
...(checkoutSession.metadata || {}),
|
|
2658
|
+
slippage: {
|
|
2659
|
+
...((checkoutSession.metadata as any)?.slippage || {}),
|
|
2660
|
+
mode: 'rate', // Use 'rate' mode so buildSlippageConfig will include min_acceptable_rate
|
|
2661
|
+
min_acceptable_rate: quoteWithMinRate.min_acceptable_rate,
|
|
2662
|
+
percent: quoteWithMinRate.slippage_percent ?? 0.5,
|
|
2663
|
+
updated_at_ms: Date.now(),
|
|
2664
|
+
},
|
|
2665
|
+
};
|
|
2666
|
+
} else {
|
|
2667
|
+
// No quote (e.g., trial period) - calculate min_acceptable_rate from current rate if needed
|
|
2668
|
+
const existingSlippage = (checkoutSession.metadata as any)?.slippage;
|
|
2669
|
+
const hasDynamicPricing = expandedLineItems.some(
|
|
2670
|
+
(item) => ((item.upsell_price || item.price) as any)?.pricing_type === 'dynamic'
|
|
2671
|
+
);
|
|
2672
|
+
|
|
2673
|
+
// If dynamic pricing and no min_acceptable_rate yet, calculate from current rate
|
|
2674
|
+
if (hasDynamicPricing && existingSlippage && !existingSlippage.min_acceptable_rate) {
|
|
2675
|
+
try {
|
|
2676
|
+
const rateResult = await fetchCurrentExchangeRate(paymentCurrency, paymentMethod);
|
|
2677
|
+
if (rateResult?.rate) {
|
|
2678
|
+
const rate = Number(rateResult.rate);
|
|
2679
|
+
const percent = existingSlippage.percent ?? DEFAULT_SLIPPAGE_PERCENT;
|
|
2680
|
+
if (Number.isFinite(rate) && rate > 0) {
|
|
2681
|
+
// min_rate = rate / (1 + percent/100)
|
|
2682
|
+
const minRate = rate / (1 + percent / 100);
|
|
2683
|
+
updatedMetadata = {
|
|
2684
|
+
...(checkoutSession.metadata || {}),
|
|
2685
|
+
slippage: {
|
|
2686
|
+
...existingSlippage,
|
|
2687
|
+
min_acceptable_rate: minRate.toFixed(8).replace(/\.?0+$/, ''),
|
|
2688
|
+
base_currency: 'USD', // Exchange rates are always USD-based
|
|
2689
|
+
updated_at_ms: Date.now(),
|
|
2690
|
+
},
|
|
2691
|
+
};
|
|
2692
|
+
logger.info('Calculated min_acceptable_rate for trial subscription', {
|
|
2693
|
+
sessionId: checkoutSession.id,
|
|
2694
|
+
currentRate: rateResult.rate,
|
|
2695
|
+
percent,
|
|
2696
|
+
minAcceptableRate: (updatedMetadata as any).slippage.min_acceptable_rate,
|
|
2697
|
+
});
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2700
|
+
} catch (error: any) {
|
|
2701
|
+
logger.warn('Failed to fetch rate for min_acceptable_rate calculation', {
|
|
2702
|
+
sessionId: checkoutSession.id,
|
|
2703
|
+
error: error.message,
|
|
2704
|
+
});
|
|
2705
|
+
// Continue without min_acceptable_rate - not a fatal error
|
|
2706
|
+
}
|
|
2707
|
+
}
|
|
2708
|
+
}
|
|
2709
|
+
|
|
2710
|
+
await checkoutSession.update({
|
|
2711
|
+
metadata: updatedMetadata,
|
|
2712
|
+
line_items: lineItemsWithQuotes.map((item) => {
|
|
2713
|
+
const baseItem = checkoutSession.line_items.find((li: any) => li.price_id === item.price_id);
|
|
2714
|
+
const quoteId = (item as any).quote_id || (baseItem as any)?.quote_id;
|
|
2715
|
+
const quoteCurrencyId = (item as any).quote_currency_id || (baseItem as any)?.quote_currency_id;
|
|
2716
|
+
const result: any = {
|
|
2717
|
+
...baseItem, // Preserve all original fields
|
|
2718
|
+
};
|
|
2719
|
+
if (quoteId) {
|
|
2720
|
+
result.quote_id = String(quoteId); // Ensure quote_id is string
|
|
2721
|
+
// Also save quote_currency_id so backend can validate custom_amount against currency
|
|
2722
|
+
if (quoteCurrencyId) {
|
|
2723
|
+
result.quote_currency_id = quoteCurrencyId;
|
|
2724
|
+
}
|
|
2725
|
+
// Update custom_amount from the new quote if available
|
|
2726
|
+
if ((item as any).custom_amount) {
|
|
2727
|
+
result.custom_amount = (item as any).custom_amount;
|
|
2728
|
+
}
|
|
2729
|
+
}
|
|
2730
|
+
return result as LineItem;
|
|
2731
|
+
}) as LineItem[],
|
|
2732
|
+
});
|
|
2733
|
+
|
|
2734
|
+
logger.info('Saved quote_id references to checkout session line_items', {
|
|
2735
|
+
checkoutSessionId: checkoutSession.id,
|
|
2736
|
+
lineItemsCount: lineItemsWithQuotes.length,
|
|
2737
|
+
quoteIds: consumedQuotes.map((q) => q.id),
|
|
2738
|
+
savedMinAcceptableRate: quoteWithMinRate?.min_acceptable_rate,
|
|
2739
|
+
});
|
|
2740
|
+
|
|
1488
2741
|
let customer = await Customer.findOne({ where: { did: req.user.did } });
|
|
1489
2742
|
if (!customer) {
|
|
1490
2743
|
const { user: userInfo } = await blocklet.getUser(req.user.did);
|
|
@@ -1605,7 +2858,10 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
|
|
|
1605
2858
|
const { lineItems, trialInDays, trialEnd, now } = await calculateAndUpdateAmount(
|
|
1606
2859
|
checkoutSession,
|
|
1607
2860
|
paymentCurrency.id,
|
|
1608
|
-
true
|
|
2861
|
+
true,
|
|
2862
|
+
{
|
|
2863
|
+
lineItemsOverride: lineItemsWithQuotes,
|
|
2864
|
+
}
|
|
1609
2865
|
);
|
|
1610
2866
|
|
|
1611
2867
|
// Validate payment amounts meet minimum requirements
|
|
@@ -1656,7 +2912,9 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
|
|
|
1656
2912
|
paymentCurrency,
|
|
1657
2913
|
lineItems,
|
|
1658
2914
|
customer.id,
|
|
1659
|
-
customer.email
|
|
2915
|
+
customer.email,
|
|
2916
|
+
undefined,
|
|
2917
|
+
{ quoteIds: consumedQuotes.map((q) => q.id) }
|
|
1660
2918
|
);
|
|
1661
2919
|
paymentIntent = result.paymentIntent;
|
|
1662
2920
|
|
|
@@ -1683,6 +2941,11 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
|
|
|
1683
2941
|
});
|
|
1684
2942
|
}
|
|
1685
2943
|
}
|
|
2944
|
+
|
|
2945
|
+
if (paymentIntent?.status === 'succeeded' && paymentIntent.invoice_id) {
|
|
2946
|
+
const quoteService = getQuoteService();
|
|
2947
|
+
await quoteService.markQuotesAsPaidByInvoice(paymentIntent.invoice_id);
|
|
2948
|
+
}
|
|
1686
2949
|
}
|
|
1687
2950
|
|
|
1688
2951
|
// SetupIntent processing
|
|
@@ -1836,11 +3099,21 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
|
|
|
1836
3099
|
}
|
|
1837
3100
|
}
|
|
1838
3101
|
|
|
3102
|
+
// Calculate actual current payment amount (excludes future renewals)
|
|
3103
|
+
const trialing = trialInDays > 0 || trialEnd > now;
|
|
3104
|
+
const { total: currentPaymentTotal } = await getCheckoutAmount(
|
|
3105
|
+
lineItems,
|
|
3106
|
+
paymentCurrency.id,
|
|
3107
|
+
trialing,
|
|
3108
|
+
discountConfig
|
|
3109
|
+
);
|
|
3110
|
+
const noPaymentRequired = currentPaymentTotal === '0';
|
|
3111
|
+
|
|
1839
3112
|
const fastCheckoutAmount = await getFastCheckoutAmount({
|
|
1840
3113
|
items: lineItems,
|
|
1841
3114
|
mode: checkoutSession.mode,
|
|
1842
3115
|
currencyId: paymentCurrency.id,
|
|
1843
|
-
trialing
|
|
3116
|
+
trialing,
|
|
1844
3117
|
minimumCycle: 1,
|
|
1845
3118
|
discountConfig,
|
|
1846
3119
|
});
|
|
@@ -2052,8 +3325,31 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
|
|
|
2052
3325
|
balance: canFastPay ? balance : null,
|
|
2053
3326
|
fastPayInfo,
|
|
2054
3327
|
creditSufficient,
|
|
3328
|
+
noPaymentRequired,
|
|
2055
3329
|
});
|
|
2056
3330
|
} catch (err) {
|
|
3331
|
+
if (consumedQuotes.length) {
|
|
3332
|
+
const quoteService = getQuoteService();
|
|
3333
|
+
await Promise.all(
|
|
3334
|
+
consumedQuotes.map((q) =>
|
|
3335
|
+
quoteService.markAsPaymentFailed(q.id).catch((error) =>
|
|
3336
|
+
logger.error('Failed to mark quote as payment failed', {
|
|
3337
|
+
quoteId: q.id,
|
|
3338
|
+
sessionId: req.params.id,
|
|
3339
|
+
error: error.message,
|
|
3340
|
+
})
|
|
3341
|
+
)
|
|
3342
|
+
)
|
|
3343
|
+
);
|
|
3344
|
+
}
|
|
3345
|
+
if (err instanceof CustomError) {
|
|
3346
|
+
if (err.code === 'RATE_UNAVAILABLE') {
|
|
3347
|
+
return res.status(503).json({ code: err.code, error: err.message });
|
|
3348
|
+
}
|
|
3349
|
+
if (['QUOTE_LOCK_EXPIRED'].includes(String(err.code))) {
|
|
3350
|
+
return res.status(409).json({ code: String(err.code), error: err.message });
|
|
3351
|
+
}
|
|
3352
|
+
}
|
|
2057
3353
|
logger.error('Error submitting checkout session', {
|
|
2058
3354
|
sessionId: req.params.id,
|
|
2059
3355
|
error: err,
|
|
@@ -2065,6 +3361,7 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
|
|
|
2065
3361
|
|
|
2066
3362
|
// 打赏(不强制登录)
|
|
2067
3363
|
router.put('/:id/donate-submit', ensureCheckoutSessionOpen, async (req, res) => {
|
|
3364
|
+
let consumedQuotes: PriceQuote[] = [];
|
|
2068
3365
|
try {
|
|
2069
3366
|
const checkoutSession = req.doc as CheckoutSession;
|
|
2070
3367
|
if (!isDonationCheckoutSession(checkoutSession)) {
|
|
@@ -2102,16 +3399,60 @@ router.put('/:id/donate-submit', ensureCheckoutSessionOpen, async (req, res) =>
|
|
|
2102
3399
|
);
|
|
2103
3400
|
await checkoutSession.update({ currency_id: paymentCurrency.id });
|
|
2104
3401
|
|
|
3402
|
+
const expandedLineItems = await Price.expand(checkoutSession.line_items, { product: true, upsell: true });
|
|
3403
|
+
let lineItemsWithQuotes = expandedLineItems;
|
|
3404
|
+
try {
|
|
3405
|
+
const quoteResult = await attachQuotesToLineItems({
|
|
3406
|
+
checkoutSession,
|
|
3407
|
+
lineItems: expandedLineItems,
|
|
3408
|
+
paymentCurrencyId: paymentCurrency.id,
|
|
3409
|
+
quotesInput: req.body.quotes,
|
|
3410
|
+
strict: true,
|
|
3411
|
+
});
|
|
3412
|
+
consumedQuotes = quoteResult.consumedQuotes;
|
|
3413
|
+
lineItemsWithQuotes = quoteResult.lineItems;
|
|
3414
|
+
if (consumedQuotes.length) {
|
|
3415
|
+
await lockCheckoutSessionQuotes(checkoutSession);
|
|
3416
|
+
}
|
|
3417
|
+
} catch (err) {
|
|
3418
|
+
if (err instanceof CustomError) {
|
|
3419
|
+
const errCode = String(getQuoteErrorCode(err) || err.code || 'QUOTE_INVALID');
|
|
3420
|
+
if (
|
|
3421
|
+
[
|
|
3422
|
+
'QUOTE_AMOUNT_MISMATCH',
|
|
3423
|
+
'QUOTE_EXPIRED_OR_USED',
|
|
3424
|
+
'QUOTE_NOT_FOUND',
|
|
3425
|
+
'QUOTE_REQUIRED',
|
|
3426
|
+
'QUOTE_LOCK_EXPIRED',
|
|
3427
|
+
].includes(errCode)
|
|
3428
|
+
) {
|
|
3429
|
+
return res.status(409).json({ code: errCode, error: err.message });
|
|
3430
|
+
}
|
|
3431
|
+
return res.status(400).json({ code: errCode, error: err.message });
|
|
3432
|
+
}
|
|
3433
|
+
throw err;
|
|
3434
|
+
}
|
|
3435
|
+
|
|
2105
3436
|
// calculate amount and update checkout session
|
|
2106
|
-
const { lineItems } = await calculateAndUpdateAmount(checkoutSession, paymentCurrency.id, false
|
|
3437
|
+
const { lineItems } = await calculateAndUpdateAmount(checkoutSession, paymentCurrency.id, false, {
|
|
3438
|
+
lineItemsOverride: lineItemsWithQuotes,
|
|
3439
|
+
});
|
|
2107
3440
|
|
|
2108
3441
|
const { paymentIntent } = await createOrUpdatePaymentIntent(
|
|
2109
3442
|
checkoutSession,
|
|
2110
3443
|
paymentMethod,
|
|
2111
3444
|
paymentCurrency,
|
|
2112
|
-
lineItems
|
|
3445
|
+
lineItems,
|
|
3446
|
+
undefined,
|
|
3447
|
+
undefined,
|
|
3448
|
+
{ quoteIds: consumedQuotes.map((q) => q.id) }
|
|
2113
3449
|
);
|
|
2114
3450
|
|
|
3451
|
+
if (paymentIntent?.status === 'succeeded' && paymentIntent.invoice_id) {
|
|
3452
|
+
const quoteService = getQuoteService();
|
|
3453
|
+
await quoteService.markQuotesAsPaidByInvoice(paymentIntent.invoice_id);
|
|
3454
|
+
}
|
|
3455
|
+
|
|
2115
3456
|
return res.json({
|
|
2116
3457
|
paymentIntent,
|
|
2117
3458
|
checkoutSession,
|
|
@@ -2120,6 +3461,28 @@ router.put('/:id/donate-submit', ensureCheckoutSessionOpen, async (req, res) =>
|
|
|
2120
3461
|
formData: req.body,
|
|
2121
3462
|
});
|
|
2122
3463
|
} catch (err) {
|
|
3464
|
+
if (consumedQuotes.length) {
|
|
3465
|
+
const quoteService = getQuoteService();
|
|
3466
|
+
await Promise.all(
|
|
3467
|
+
consumedQuotes.map((q) =>
|
|
3468
|
+
quoteService.markAsPaymentFailed(q.id).catch((error) =>
|
|
3469
|
+
logger.error('Failed to mark quote as payment failed', {
|
|
3470
|
+
quoteId: q.id,
|
|
3471
|
+
sessionId: req.params.id,
|
|
3472
|
+
error: error.message,
|
|
3473
|
+
})
|
|
3474
|
+
)
|
|
3475
|
+
)
|
|
3476
|
+
);
|
|
3477
|
+
}
|
|
3478
|
+
if (err instanceof CustomError) {
|
|
3479
|
+
if (err.code === 'RATE_UNAVAILABLE') {
|
|
3480
|
+
return res.status(503).json({ code: err.code, error: err.message });
|
|
3481
|
+
}
|
|
3482
|
+
if (['QUOTE_LOCK_EXPIRED'].includes(String(err.code))) {
|
|
3483
|
+
return res.status(409).json({ code: String(err.code), error: err.message });
|
|
3484
|
+
}
|
|
3485
|
+
}
|
|
2123
3486
|
logger.error('Error processing donation submission', {
|
|
2124
3487
|
sessionId: req.params.id,
|
|
2125
3488
|
error: err.message,
|
|
@@ -2130,6 +3493,7 @@ router.put('/:id/donate-submit', ensureCheckoutSessionOpen, async (req, res) =>
|
|
|
2130
3493
|
});
|
|
2131
3494
|
|
|
2132
3495
|
router.post('/:id/fast-checkout-confirm', user, ensureCheckoutSessionOpen, async (req, res) => {
|
|
3496
|
+
let consumedQuotes: PriceQuote[] = [];
|
|
2133
3497
|
try {
|
|
2134
3498
|
if (!req.user) {
|
|
2135
3499
|
return res.status(403).json({ code: 'REQUIRE_LOGIN', error: 'Please login to continue' });
|
|
@@ -2197,10 +3561,47 @@ router.post('/:id/fast-checkout-confirm', user, ensureCheckoutSessionOpen, async
|
|
|
2197
3561
|
});
|
|
2198
3562
|
}
|
|
2199
3563
|
|
|
3564
|
+
const expandedLineItems = await Price.expand(checkoutSession.line_items, { product: true, upsell: true });
|
|
3565
|
+
let lineItemsWithQuotes = expandedLineItems;
|
|
3566
|
+
try {
|
|
3567
|
+
const quoteResult = await attachQuotesToLineItems({
|
|
3568
|
+
checkoutSession,
|
|
3569
|
+
lineItems: expandedLineItems,
|
|
3570
|
+
paymentCurrencyId: paymentCurrency.id,
|
|
3571
|
+
quotesInput: req.body.quotes,
|
|
3572
|
+
strict: true,
|
|
3573
|
+
});
|
|
3574
|
+
consumedQuotes = quoteResult.consumedQuotes;
|
|
3575
|
+
lineItemsWithQuotes = quoteResult.lineItems;
|
|
3576
|
+
if (consumedQuotes.length) {
|
|
3577
|
+
await lockCheckoutSessionQuotes(checkoutSession);
|
|
3578
|
+
}
|
|
3579
|
+
} catch (err) {
|
|
3580
|
+
if (err instanceof CustomError) {
|
|
3581
|
+
const errCode = String(getQuoteErrorCode(err) || err.code || 'QUOTE_INVALID');
|
|
3582
|
+
if (
|
|
3583
|
+
[
|
|
3584
|
+
'QUOTE_AMOUNT_MISMATCH',
|
|
3585
|
+
'QUOTE_EXPIRED_OR_USED',
|
|
3586
|
+
'QUOTE_NOT_FOUND',
|
|
3587
|
+
'QUOTE_REQUIRED',
|
|
3588
|
+
'QUOTE_LOCK_EXPIRED',
|
|
3589
|
+
].includes(errCode)
|
|
3590
|
+
) {
|
|
3591
|
+
return res.status(409).json({ code: errCode, error: err.message });
|
|
3592
|
+
}
|
|
3593
|
+
return res.status(400).json({ code: errCode, error: err.message });
|
|
3594
|
+
}
|
|
3595
|
+
throw err;
|
|
3596
|
+
}
|
|
3597
|
+
|
|
2200
3598
|
const { lineItems, trialInDays, trialEnd, now } = await calculateAndUpdateAmount(
|
|
2201
3599
|
checkoutSession,
|
|
2202
3600
|
paymentCurrency.id,
|
|
2203
|
-
true
|
|
3601
|
+
true,
|
|
3602
|
+
{
|
|
3603
|
+
lineItemsOverride: lineItemsWithQuotes,
|
|
3604
|
+
}
|
|
2204
3605
|
);
|
|
2205
3606
|
|
|
2206
3607
|
let paymentIntent: PaymentIntent | null = null;
|
|
@@ -2211,7 +3612,9 @@ router.post('/:id/fast-checkout-confirm', user, ensureCheckoutSessionOpen, async
|
|
|
2211
3612
|
paymentCurrency,
|
|
2212
3613
|
lineItems,
|
|
2213
3614
|
customer.id,
|
|
2214
|
-
customer.email
|
|
3615
|
+
customer.email,
|
|
3616
|
+
undefined,
|
|
3617
|
+
{ quoteIds: consumedQuotes.map((q) => q.id) }
|
|
2215
3618
|
);
|
|
2216
3619
|
paymentIntent = result.paymentIntent;
|
|
2217
3620
|
}
|
|
@@ -2378,6 +3781,11 @@ router.post('/:id/fast-checkout-confirm', user, ensureCheckoutSessionOpen, async
|
|
|
2378
3781
|
return res.status(400).json({ error: 'Payment method not supported for fast checkout' });
|
|
2379
3782
|
}
|
|
2380
3783
|
|
|
3784
|
+
if (paymentIntent?.status === 'succeeded' && paymentIntent?.invoice_id) {
|
|
3785
|
+
const quoteService = getQuoteService();
|
|
3786
|
+
await quoteService.markQuotesAsPaidByInvoice(paymentIntent.invoice_id);
|
|
3787
|
+
}
|
|
3788
|
+
|
|
2381
3789
|
logger.info('Checkout session submitted successfully', {
|
|
2382
3790
|
sessionId: req.params.id,
|
|
2383
3791
|
paymentIntentId: paymentIntent?.id,
|
|
@@ -2391,6 +3799,28 @@ router.post('/:id/fast-checkout-confirm', user, ensureCheckoutSessionOpen, async
|
|
|
2391
3799
|
fastPaid,
|
|
2392
3800
|
});
|
|
2393
3801
|
} catch (err) {
|
|
3802
|
+
if (consumedQuotes.length) {
|
|
3803
|
+
const quoteService = getQuoteService();
|
|
3804
|
+
await Promise.all(
|
|
3805
|
+
consumedQuotes.map((q) =>
|
|
3806
|
+
quoteService.markAsPaymentFailed(q.id).catch((error) =>
|
|
3807
|
+
logger.error('Failed to mark quote as payment failed', {
|
|
3808
|
+
quoteId: q.id,
|
|
3809
|
+
sessionId: req.params.id,
|
|
3810
|
+
error: error.message,
|
|
3811
|
+
})
|
|
3812
|
+
)
|
|
3813
|
+
)
|
|
3814
|
+
);
|
|
3815
|
+
}
|
|
3816
|
+
if (err instanceof CustomError) {
|
|
3817
|
+
if (err.code === 'RATE_UNAVAILABLE') {
|
|
3818
|
+
return res.status(503).json({ code: err.code, error: err.message });
|
|
3819
|
+
}
|
|
3820
|
+
if (['QUOTE_LOCK_EXPIRED'].includes(String(err.code))) {
|
|
3821
|
+
return res.status(409).json({ code: String(err.code), error: err.message });
|
|
3822
|
+
}
|
|
3823
|
+
}
|
|
2394
3824
|
logger.error('Error confirming fast checkout', {
|
|
2395
3825
|
sessionId: req.params.id,
|
|
2396
3826
|
error: err.message,
|
|
@@ -2494,7 +3924,7 @@ router.put('/:id/downsell', user, ensureCheckoutSessionOpen, async (req, res) =>
|
|
|
2494
3924
|
router.put('/:id/adjust-quantity', user, ensureCheckoutSessionOpen, async (req, res) => {
|
|
2495
3925
|
try {
|
|
2496
3926
|
const checkoutSession = req.doc as CheckoutSession;
|
|
2497
|
-
const { itemId, quantity } = req.body;
|
|
3927
|
+
const { itemId, quantity, currency_id: currencyIdInput } = req.body;
|
|
2498
3928
|
if (!checkoutSession.line_items) {
|
|
2499
3929
|
return res.status(400).json({ error: 'Line items not found' });
|
|
2500
3930
|
}
|
|
@@ -2503,15 +3933,59 @@ router.put('/:id/adjust-quantity', user, ensureCheckoutSessionOpen, async (req,
|
|
|
2503
3933
|
if (!item) {
|
|
2504
3934
|
return res.status(400).json({ error: 'Item not found' });
|
|
2505
3935
|
}
|
|
2506
|
-
|
|
2507
|
-
|
|
3936
|
+
|
|
3937
|
+
// Final Freeze: When adjusting quantity, clear all quote-related fields
|
|
3938
|
+
// Quote will be created only at Submit time
|
|
3939
|
+
const items = cloneDeep(checkoutSession.line_items).map((lineItem: any) => {
|
|
3940
|
+
const cleaned = { ...lineItem };
|
|
3941
|
+
// Clear quote-related fields - price will be calculated by frontend using live rate
|
|
3942
|
+
delete cleaned.quote_id;
|
|
3943
|
+
delete cleaned.quoted_amount;
|
|
3944
|
+
delete cleaned.custom_amount;
|
|
3945
|
+
delete cleaned.exchange_rate;
|
|
3946
|
+
delete cleaned.rate_provider_name;
|
|
3947
|
+
delete cleaned.rate_provider_id;
|
|
3948
|
+
delete cleaned.rate_timestamp_ms;
|
|
3949
|
+
delete cleaned.expires_at;
|
|
3950
|
+
delete cleaned.quote_currency_id;
|
|
3951
|
+
return cleaned;
|
|
3952
|
+
});
|
|
3953
|
+
|
|
3954
|
+
const targetItem = items.find((x: any) => x.price_id === itemId);
|
|
2508
3955
|
if (targetItem) {
|
|
2509
3956
|
targetItem.quantity = quantity;
|
|
2510
3957
|
}
|
|
2511
3958
|
await validateInventory(items, true);
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
3959
|
+
|
|
3960
|
+
// Build update payload - clear quote_locked_at since we're in preview mode
|
|
3961
|
+
const updatePayload: { line_items: any; currency_id?: string; metadata?: any } = { line_items: items };
|
|
3962
|
+
if (currencyIdInput && currencyIdInput !== checkoutSession.currency_id) {
|
|
3963
|
+
updatePayload.currency_id = currencyIdInput;
|
|
3964
|
+
}
|
|
3965
|
+
// Clear quote lock since quantity changed
|
|
3966
|
+
if (checkoutSession.metadata?.quote_locked_at) {
|
|
3967
|
+
updatePayload.metadata = { ...checkoutSession.metadata, quote_locked_at: undefined };
|
|
3968
|
+
}
|
|
3969
|
+
await checkoutSession.update(updatePayload);
|
|
3970
|
+
|
|
3971
|
+
// Also clear quote_locked_at on payment intent if exists
|
|
3972
|
+
if (checkoutSession.payment_intent_id) {
|
|
3973
|
+
const paymentIntent = await PaymentIntent.findByPk(checkoutSession.payment_intent_id);
|
|
3974
|
+
if (paymentIntent?.quote_locked_at) {
|
|
3975
|
+
await paymentIntent.update({ quote_locked_at: undefined });
|
|
3976
|
+
}
|
|
3977
|
+
}
|
|
3978
|
+
|
|
3979
|
+
// Expand line items for response (no quote enrichment needed - frontend uses live rate)
|
|
3980
|
+
const lineItems = await Price.expand(checkoutSession.line_items, { upsell: true });
|
|
3981
|
+
|
|
3982
|
+
logger.info('Adjusted quantity and cleared quote data', {
|
|
3983
|
+
checkoutSessionId: checkoutSession.id,
|
|
3984
|
+
itemId,
|
|
3985
|
+
quantity,
|
|
3986
|
+
});
|
|
3987
|
+
|
|
3988
|
+
res.json({ ...checkoutSession.toJSON(), line_items: lineItems, quotes: {}, rateUnavailable: false });
|
|
2515
3989
|
} catch (err) {
|
|
2516
3990
|
logger.error(err);
|
|
2517
3991
|
res.status(400).json({ error: err.message });
|
|
@@ -2687,9 +4161,19 @@ router.post('/:id/apply-promotion', user, ensureCheckoutSessionOpen, async (req,
|
|
|
2687
4161
|
}
|
|
2688
4162
|
|
|
2689
4163
|
const checkoutItems = checkoutSession.line_items || [];
|
|
2690
|
-
// Get line items
|
|
4164
|
+
// Get line items with quote data for accurate discount calculation
|
|
2691
4165
|
const expandedItems = await Price.expand(checkoutSession.line_items || [], { product: true, upsell: true });
|
|
2692
4166
|
|
|
4167
|
+
// Enrich line items with quote data for dynamic pricing
|
|
4168
|
+
// This ensures discount calculation uses the same amounts as the actual payment
|
|
4169
|
+
const quoteResult = await enrichCheckoutSessionWithQuotes(
|
|
4170
|
+
checkoutSession,
|
|
4171
|
+
expandedItems,
|
|
4172
|
+
curCurrencyId,
|
|
4173
|
+
{ skipGeneration: true } // Don't generate new quotes, just use existing ones
|
|
4174
|
+
);
|
|
4175
|
+
const itemsWithQuotes = quoteResult.lineItems;
|
|
4176
|
+
|
|
2693
4177
|
// Calculate trial status for discount application (same logic as in calculateAndUpdateAmount)
|
|
2694
4178
|
const now = dayjs().unix();
|
|
2695
4179
|
const trialSetup = getSubscriptionTrialSetup(checkoutSession.subscription_data as any, currency.id);
|
|
@@ -2697,7 +4181,7 @@ router.post('/:id/apply-promotion', user, ensureCheckoutSessionOpen, async (req,
|
|
|
2697
4181
|
|
|
2698
4182
|
// Apply discount using our new function
|
|
2699
4183
|
const discountResult = await applyDiscountsToLineItems({
|
|
2700
|
-
lineItems:
|
|
4184
|
+
lineItems: itemsWithQuotes,
|
|
2701
4185
|
promotionCodeId: foundPromotionCode.id,
|
|
2702
4186
|
couponId: foundPromotionCode.coupon_id,
|
|
2703
4187
|
customerId: customer.id,
|
|
@@ -2818,7 +4302,7 @@ router.post('/:id/recalculate-promotion', user, ensureCheckoutSessionOpen, async
|
|
|
2818
4302
|
}
|
|
2819
4303
|
|
|
2820
4304
|
const checkoutItems = checkoutSession.line_items || [];
|
|
2821
|
-
// Get line items
|
|
4305
|
+
// Get line items - no need for quote data since frontend calculates discount amounts
|
|
2822
4306
|
const expandedItems = await Price.expand(checkoutSession.line_items || [], { product: true, upsell: true });
|
|
2823
4307
|
|
|
2824
4308
|
// Get the first discount (assuming only one promotion code at a time)
|
|
@@ -2848,27 +4332,13 @@ router.post('/:id/recalculate-promotion', user, ensureCheckoutSessionOpen, async
|
|
|
2848
4332
|
return res.status(400).json({ error: 'Coupon no longer valid' });
|
|
2849
4333
|
}
|
|
2850
4334
|
|
|
2851
|
-
|
|
2852
|
-
const
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
const discountResult = await applyDiscountsToLineItems({
|
|
2857
|
-
lineItems: expandedItems,
|
|
2858
|
-
promotionCodeId,
|
|
2859
|
-
couponId,
|
|
2860
|
-
customerId: customer.id,
|
|
2861
|
-
currency,
|
|
2862
|
-
billingContext: {
|
|
2863
|
-
trialing: isTrialing,
|
|
2864
|
-
},
|
|
2865
|
-
});
|
|
2866
|
-
|
|
2867
|
-
// Check if discount can still be applied with the new currency
|
|
2868
|
-
if (!discountResult.discountSummary.appliedCoupon) {
|
|
2869
|
-
// Calculate original total without discount (finalTotal when no discount is applied)
|
|
2870
|
-
const originalTotal = discountResult.discountSummary.finalTotal;
|
|
4335
|
+
// Check if coupon can be applied with the new currency (for fixed amount coupons)
|
|
4336
|
+
const canApplyWithCurrency =
|
|
4337
|
+
coupon.percent_off > 0 || // Percentage discounts always work
|
|
4338
|
+
coupon.currency_id === currency.id || // Same currency
|
|
4339
|
+
coupon.currency_options?.[currency.id]?.amount_off; // Has currency option
|
|
2871
4340
|
|
|
4341
|
+
if (!canApplyWithCurrency) {
|
|
2872
4342
|
// Remove discount if it can't be applied with new currency
|
|
2873
4343
|
await checkoutSession.update({
|
|
2874
4344
|
discounts: [],
|
|
@@ -2876,15 +4346,11 @@ router.post('/:id/recalculate-promotion', user, ensureCheckoutSessionOpen, async
|
|
|
2876
4346
|
const cur = checkoutItems.find((x) => x.price_id === item.price_id) as LineItem;
|
|
2877
4347
|
return {
|
|
2878
4348
|
...cur,
|
|
4349
|
+
discountable: false,
|
|
4350
|
+
discount_amounts: [],
|
|
2879
4351
|
};
|
|
2880
4352
|
}),
|
|
2881
4353
|
currency_id: newCurrencyId,
|
|
2882
|
-
amount_subtotal: originalTotal,
|
|
2883
|
-
amount_total: originalTotal,
|
|
2884
|
-
total_details: {
|
|
2885
|
-
...checkoutSession.total_details,
|
|
2886
|
-
amount_discount: '0',
|
|
2887
|
-
},
|
|
2888
4354
|
metadata: {
|
|
2889
4355
|
...omit(checkoutSession.metadata, ['promotion_code']),
|
|
2890
4356
|
},
|
|
@@ -2906,57 +4372,52 @@ router.post('/:id/recalculate-promotion', user, ensureCheckoutSessionOpen, async
|
|
|
2906
4372
|
});
|
|
2907
4373
|
}
|
|
2908
4374
|
|
|
2909
|
-
//
|
|
4375
|
+
// Mark which items are discountable based on coupon product restrictions
|
|
4376
|
+
const enhancedLineItems = expandedItems.map((item) => {
|
|
4377
|
+
const isDiscountable =
|
|
4378
|
+
!coupon.applies_to?.products?.length ||
|
|
4379
|
+
coupon.applies_to.products.some((productId: string) => item.price?.product_id === productId);
|
|
4380
|
+
|
|
4381
|
+
const cur = checkoutItems.find((x) => x.price_id === item.price_id) as LineItem;
|
|
4382
|
+
return {
|
|
4383
|
+
...cur,
|
|
4384
|
+
discountable: isDiscountable,
|
|
4385
|
+
discount_amounts: [], // Frontend will calculate actual amounts
|
|
4386
|
+
};
|
|
4387
|
+
});
|
|
4388
|
+
|
|
4389
|
+
// Create updated discount configuration - NO discount_amount, frontend calculates it
|
|
2910
4390
|
const discountConfig = {
|
|
2911
4391
|
promotion_code: promotionCodeId,
|
|
2912
4392
|
coupon: couponId,
|
|
2913
|
-
discount_amount
|
|
4393
|
+
// discount_amount removed - frontend calculates based on live exchange rate
|
|
2914
4394
|
verification_method: existingDiscount.verification_method,
|
|
2915
4395
|
verification_data: existingDiscount.verification_data,
|
|
2916
4396
|
};
|
|
2917
4397
|
|
|
2918
|
-
// Update checkout session with
|
|
4398
|
+
// Update checkout session with discountable flags only
|
|
2919
4399
|
await checkoutSession.update({
|
|
2920
4400
|
discounts: [discountConfig],
|
|
2921
|
-
line_items:
|
|
2922
|
-
const cur = checkoutItems.find((x) => x.price_id === item.price_id) as LineItem;
|
|
2923
|
-
return {
|
|
2924
|
-
...cur,
|
|
2925
|
-
discountable: item.discountable,
|
|
2926
|
-
discount_amounts: item.discount_amounts,
|
|
2927
|
-
};
|
|
2928
|
-
}),
|
|
4401
|
+
line_items: enhancedLineItems,
|
|
2929
4402
|
currency_id: newCurrencyId,
|
|
2930
|
-
amount_subtotal: checkoutSession.amount_total, // Original amount
|
|
2931
|
-
amount_total: discountResult.discountSummary.finalTotal, // Discounted amount
|
|
2932
|
-
total_details: {
|
|
2933
|
-
...checkoutSession.total_details,
|
|
2934
|
-
amount_discount: discountResult.discountSummary.totalDiscountAmount,
|
|
2935
|
-
},
|
|
2936
4403
|
});
|
|
2937
4404
|
|
|
2938
|
-
|
|
2939
|
-
const enhancedLineItemsWithCoupon = await expandLineItemsWithCouponInfo(
|
|
2940
|
-
discountResult.enhancedLineItems,
|
|
2941
|
-
[discountConfig],
|
|
2942
|
-
currency.id
|
|
2943
|
-
);
|
|
2944
|
-
|
|
2945
|
-
logger.info('Promotion code recalculated for new currency', {
|
|
4405
|
+
logger.info('Promotion code validated for new currency (frontend calculates amount)', {
|
|
2946
4406
|
sessionId: checkoutSession.id,
|
|
2947
4407
|
oldCurrencyId: checkoutSession.currency_id,
|
|
2948
4408
|
newCurrencyId,
|
|
2949
4409
|
promotionCode: foundPromotionCode.code,
|
|
2950
|
-
oldDiscountAmount: existingDiscount?.discount_amount || '0',
|
|
2951
|
-
newDiscountAmount: discountResult.discountSummary.totalDiscountAmount,
|
|
2952
4410
|
});
|
|
2953
4411
|
|
|
2954
|
-
// Expand discounts with complete details for response
|
|
4412
|
+
// Expand discounts with complete details for response (coupon_details for frontend calculation)
|
|
2955
4413
|
const enhancedDiscounts = await expandDiscountsWithDetails([discountConfig]);
|
|
2956
4414
|
|
|
4415
|
+
// Expand line items for response
|
|
4416
|
+
const responseItems = await Price.expand(enhancedLineItems, { product: true, upsell: true });
|
|
4417
|
+
|
|
2957
4418
|
res.json({
|
|
2958
4419
|
...checkoutSession.toJSON(),
|
|
2959
|
-
line_items:
|
|
4420
|
+
line_items: responseItems,
|
|
2960
4421
|
discounts: enhancedDiscounts,
|
|
2961
4422
|
discount_applied: true,
|
|
2962
4423
|
discount_recalculated: true,
|
|
@@ -3288,4 +4749,192 @@ router.put('/:id', auth, async (req, res) => {
|
|
|
3288
4749
|
res.json(doc);
|
|
3289
4750
|
});
|
|
3290
4751
|
|
|
4752
|
+
router.put('/:id/slippage', user, ensureCheckoutSessionOpen, async (req, res) => {
|
|
4753
|
+
try {
|
|
4754
|
+
const checkoutSession = req.doc as CheckoutSession;
|
|
4755
|
+
const { slippage_percent: slippagePercent } = req.body;
|
|
4756
|
+
const rawConfig = req.body?.slippage_config || req.body?.slippage || null;
|
|
4757
|
+
|
|
4758
|
+
const normalizePercent = (value: any) => {
|
|
4759
|
+
const normalized = typeof value === 'string' ? Number(value) : value;
|
|
4760
|
+
// Only validate that it's a non-negative finite number, no upper limit
|
|
4761
|
+
if (!Number.isFinite(normalized) || normalized < 0) {
|
|
4762
|
+
return null;
|
|
4763
|
+
}
|
|
4764
|
+
return normalized;
|
|
4765
|
+
};
|
|
4766
|
+
|
|
4767
|
+
let config: any = null;
|
|
4768
|
+
if (rawConfig && typeof rawConfig === 'object') {
|
|
4769
|
+
const mode = rawConfig.mode === 'rate' ? 'rate' : 'percent';
|
|
4770
|
+
const minRate = rawConfig.min_acceptable_rate ?? rawConfig.minAcceptableRate;
|
|
4771
|
+
|
|
4772
|
+
// For rate mode, min_acceptable_rate is required; percent is derived
|
|
4773
|
+
if (mode === 'rate') {
|
|
4774
|
+
if (minRate === undefined || minRate === null || minRate === '') {
|
|
4775
|
+
return res.status(400).json({ error: 'min_acceptable_rate is required for rate mode' });
|
|
4776
|
+
}
|
|
4777
|
+
// Accept any non-negative percent value for rate mode (calculated from rate)
|
|
4778
|
+
const percent = normalizePercent(rawConfig.percent);
|
|
4779
|
+
const baseCurrency = rawConfig.base_currency ?? rawConfig.baseCurrency;
|
|
4780
|
+
config = {
|
|
4781
|
+
mode,
|
|
4782
|
+
percent: percent ?? 0,
|
|
4783
|
+
min_acceptable_rate: String(minRate),
|
|
4784
|
+
...(baseCurrency ? { base_currency: String(baseCurrency) } : {}),
|
|
4785
|
+
updated_at_ms: Date.now(),
|
|
4786
|
+
};
|
|
4787
|
+
} else {
|
|
4788
|
+
// Percent mode: validate percent
|
|
4789
|
+
const percent = normalizePercent(rawConfig.percent);
|
|
4790
|
+
if (percent === null) {
|
|
4791
|
+
return res.status(400).json({ error: 'slippage_percent must be a non-negative number' });
|
|
4792
|
+
}
|
|
4793
|
+
const baseCurrency = rawConfig.base_currency ?? rawConfig.baseCurrency;
|
|
4794
|
+
config = {
|
|
4795
|
+
mode,
|
|
4796
|
+
percent,
|
|
4797
|
+
// Also save min_acceptable_rate if provided (calculated by frontend from current rate)
|
|
4798
|
+
...(minRate ? { min_acceptable_rate: String(minRate) } : {}),
|
|
4799
|
+
...(baseCurrency ? { base_currency: String(baseCurrency) } : {}),
|
|
4800
|
+
updated_at_ms: Date.now(),
|
|
4801
|
+
};
|
|
4802
|
+
}
|
|
4803
|
+
} else if (slippagePercent !== undefined && slippagePercent !== null) {
|
|
4804
|
+
const slippageValue = normalizePercent(slippagePercent);
|
|
4805
|
+
if (slippageValue === null) {
|
|
4806
|
+
return res.status(400).json({ error: 'slippage_percent must be a non-negative number' });
|
|
4807
|
+
}
|
|
4808
|
+
config = {
|
|
4809
|
+
mode: 'percent',
|
|
4810
|
+
percent: slippageValue,
|
|
4811
|
+
updated_at_ms: Date.now(),
|
|
4812
|
+
};
|
|
4813
|
+
} else {
|
|
4814
|
+
return res.status(400).json({ error: 'slippage config is required' });
|
|
4815
|
+
}
|
|
4816
|
+
|
|
4817
|
+
const nextMetadata = {
|
|
4818
|
+
...(checkoutSession.metadata || {}),
|
|
4819
|
+
slippage: config,
|
|
4820
|
+
};
|
|
4821
|
+
await checkoutSession.update({ metadata: nextMetadata });
|
|
4822
|
+
logger.info('Checkout session slippage updated', {
|
|
4823
|
+
sessionId: checkoutSession.id,
|
|
4824
|
+
slippageConfig: config,
|
|
4825
|
+
});
|
|
4826
|
+
|
|
4827
|
+
// Final Freeze: Don't create Quotes during Preview (slippage change)
|
|
4828
|
+
// Slippage is applied at Submit time when Quote is created
|
|
4829
|
+
const items = await Price.expand(checkoutSession.line_items, { upsell: true });
|
|
4830
|
+
const enriched = await enrichCheckoutSessionWithQuotes(checkoutSession, items, checkoutSession.currency_id, {
|
|
4831
|
+
skipGeneration: true, // Final Freeze: Don't create quotes during Preview
|
|
4832
|
+
});
|
|
4833
|
+
|
|
4834
|
+
res.json({
|
|
4835
|
+
...checkoutSession.toJSON(),
|
|
4836
|
+
line_items: enriched.lineItems,
|
|
4837
|
+
quotes: enriched.quotes,
|
|
4838
|
+
rateUnavailable: enriched.rateUnavailable,
|
|
4839
|
+
rateError: enriched.rateError,
|
|
4840
|
+
});
|
|
4841
|
+
} catch (err) {
|
|
4842
|
+
logger.error('Error updating checkout session slippage', {
|
|
4843
|
+
sessionId: req.params.id,
|
|
4844
|
+
error: err,
|
|
4845
|
+
});
|
|
4846
|
+
if (err instanceof CustomError) {
|
|
4847
|
+
return res.status(getStatusFromError(err)).json({ code: err.code, error: err.message });
|
|
4848
|
+
}
|
|
4849
|
+
res.status(500).json({ error: err.message });
|
|
4850
|
+
}
|
|
4851
|
+
});
|
|
4852
|
+
|
|
4853
|
+
/**
|
|
4854
|
+
* Switch payment currency for checkout session
|
|
4855
|
+
* This endpoint clears quote-related fields when currency changes
|
|
4856
|
+
* because quotes are currency-specific (e.g., TBA quote amount with 18 decimals
|
|
4857
|
+
* cannot be used for USD with 2 decimals)
|
|
4858
|
+
*/
|
|
4859
|
+
router.put('/:id/switch-currency', user, ensureCheckoutSessionOpen, async (req, res) => {
|
|
4860
|
+
try {
|
|
4861
|
+
const checkoutSession = req.doc as CheckoutSession;
|
|
4862
|
+
const { currency_id: newCurrencyId } = req.body;
|
|
4863
|
+
|
|
4864
|
+
if (!newCurrencyId) {
|
|
4865
|
+
return res.status(400).json({ error: 'currency_id is required' });
|
|
4866
|
+
}
|
|
4867
|
+
|
|
4868
|
+
// Validate the new currency exists
|
|
4869
|
+
const newCurrency = await PaymentCurrency.findByPk(newCurrencyId);
|
|
4870
|
+
if (!newCurrency) {
|
|
4871
|
+
return res.status(400).json({ error: 'Currency not found' });
|
|
4872
|
+
}
|
|
4873
|
+
|
|
4874
|
+
const oldCurrencyId = checkoutSession.currency_id;
|
|
4875
|
+
const currencyChanged = oldCurrencyId && oldCurrencyId !== newCurrencyId;
|
|
4876
|
+
|
|
4877
|
+
if (currencyChanged) {
|
|
4878
|
+
// Clear quote-related fields from line_items
|
|
4879
|
+
const cleanedLineItems = checkoutSession.line_items.map((item: any) => {
|
|
4880
|
+
const {
|
|
4881
|
+
quote_id: quoteId,
|
|
4882
|
+
quoted_amount: quotedAmount,
|
|
4883
|
+
custom_amount: customAmount,
|
|
4884
|
+
quote_currency_id: quoteCurrencyId,
|
|
4885
|
+
...rest
|
|
4886
|
+
} = item;
|
|
4887
|
+
return rest;
|
|
4888
|
+
});
|
|
4889
|
+
|
|
4890
|
+
// Clear discount amounts - they will be recalculated by recalculate-promotion
|
|
4891
|
+
// This prevents showing stale values (e.g., ABT units when switching to USD)
|
|
4892
|
+
const cleanedDiscounts = checkoutSession.discounts?.map((discount: any) => ({
|
|
4893
|
+
...discount,
|
|
4894
|
+
discount_amount: null, // Will be recalculated
|
|
4895
|
+
}));
|
|
4896
|
+
|
|
4897
|
+
await checkoutSession.update({
|
|
4898
|
+
line_items: cleanedLineItems,
|
|
4899
|
+
currency_id: newCurrencyId,
|
|
4900
|
+
discounts: cleanedDiscounts,
|
|
4901
|
+
total_details: {
|
|
4902
|
+
...checkoutSession.total_details,
|
|
4903
|
+
amount_discount: '0', // Clear until recalculated
|
|
4904
|
+
},
|
|
4905
|
+
});
|
|
4906
|
+
|
|
4907
|
+
logger.info('Cleared quote-related fields due to currency switch', {
|
|
4908
|
+
checkoutSessionId: checkoutSession.id,
|
|
4909
|
+
oldCurrencyId,
|
|
4910
|
+
newCurrencyId,
|
|
4911
|
+
});
|
|
4912
|
+
} else {
|
|
4913
|
+
// Just update the currency_id if it's the same (first time setting)
|
|
4914
|
+
await checkoutSession.update({
|
|
4915
|
+
currency_id: newCurrencyId,
|
|
4916
|
+
});
|
|
4917
|
+
}
|
|
4918
|
+
|
|
4919
|
+
// Reload and expand line items
|
|
4920
|
+
await checkoutSession.reload();
|
|
4921
|
+
const expandedLineItems = await Price.expand(checkoutSession.line_items, { product: true, upsell: true });
|
|
4922
|
+
|
|
4923
|
+
res.json({
|
|
4924
|
+
...checkoutSession.toJSON(),
|
|
4925
|
+
line_items: expandedLineItems,
|
|
4926
|
+
currency_changed: currencyChanged,
|
|
4927
|
+
});
|
|
4928
|
+
} catch (err) {
|
|
4929
|
+
logger.error('Error switching checkout session currency', {
|
|
4930
|
+
sessionId: req.params.id,
|
|
4931
|
+
error: err,
|
|
4932
|
+
});
|
|
4933
|
+
if (err instanceof CustomError) {
|
|
4934
|
+
return res.status(getStatusFromError(err)).json({ code: err.code, error: err.message });
|
|
4935
|
+
}
|
|
4936
|
+
res.status(500).json({ error: err.message });
|
|
4937
|
+
}
|
|
4938
|
+
});
|
|
4939
|
+
|
|
3291
4940
|
export default router;
|