payment-kit 1.24.3 → 1.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/api/src/crons/overdue-detection.ts +10 -1
  2. package/api/src/index.ts +3 -0
  3. package/api/src/libs/credit-utils.ts +21 -0
  4. package/api/src/libs/discount/discount.ts +13 -0
  5. package/api/src/libs/env.ts +5 -0
  6. package/api/src/libs/error.ts +14 -0
  7. package/api/src/libs/exchange-rate/coingecko-provider.ts +193 -0
  8. package/api/src/libs/exchange-rate/coinmarketcap-provider.ts +180 -0
  9. package/api/src/libs/exchange-rate/index.ts +5 -0
  10. package/api/src/libs/exchange-rate/service.ts +583 -0
  11. package/api/src/libs/exchange-rate/token-address-mapping.ts +84 -0
  12. package/api/src/libs/exchange-rate/token-addresses.json +2147 -0
  13. package/api/src/libs/exchange-rate/token-data-provider.ts +142 -0
  14. package/api/src/libs/exchange-rate/types.ts +114 -0
  15. package/api/src/libs/exchange-rate/validator.ts +319 -0
  16. package/api/src/libs/invoice-quote.ts +158 -0
  17. package/api/src/libs/invoice.ts +143 -7
  18. package/api/src/libs/math-utils.ts +46 -0
  19. package/api/src/libs/notification/template/billing-discrepancy.ts +3 -4
  20. package/api/src/libs/notification/template/customer-auto-recharge-failed.ts +174 -79
  21. package/api/src/libs/notification/template/customer-credit-grant-granted.ts +2 -3
  22. package/api/src/libs/notification/template/customer-credit-insufficient.ts +3 -3
  23. package/api/src/libs/notification/template/customer-credit-low-balance.ts +3 -3
  24. package/api/src/libs/notification/template/customer-revenue-succeeded.ts +2 -3
  25. package/api/src/libs/notification/template/customer-reward-succeeded.ts +9 -4
  26. package/api/src/libs/notification/template/exchange-rate-alert.ts +202 -0
  27. package/api/src/libs/notification/template/subscription-slippage-exceeded.ts +203 -0
  28. package/api/src/libs/notification/template/subscription-slippage-warning.ts +212 -0
  29. package/api/src/libs/notification/template/subscription-will-canceled.ts +2 -2
  30. package/api/src/libs/notification/template/subscription-will-renew.ts +22 -8
  31. package/api/src/libs/payment.ts +1 -1
  32. package/api/src/libs/price.ts +4 -1
  33. package/api/src/libs/queue/index.ts +8 -0
  34. package/api/src/libs/quote-service.ts +1132 -0
  35. package/api/src/libs/quote-validation.ts +388 -0
  36. package/api/src/libs/session.ts +686 -39
  37. package/api/src/libs/slippage.ts +135 -0
  38. package/api/src/libs/subscription.ts +185 -15
  39. package/api/src/libs/util.ts +64 -3
  40. package/api/src/locales/en.ts +50 -0
  41. package/api/src/locales/zh.ts +48 -0
  42. package/api/src/queues/auto-recharge.ts +295 -21
  43. package/api/src/queues/exchange-rate-health.ts +242 -0
  44. package/api/src/queues/invoice.ts +48 -1
  45. package/api/src/queues/notification.ts +190 -3
  46. package/api/src/queues/payment.ts +177 -7
  47. package/api/src/queues/subscription.ts +436 -6
  48. package/api/src/routes/auto-recharge-configs.ts +71 -6
  49. package/api/src/routes/checkout-sessions.ts +1730 -81
  50. package/api/src/routes/connect/auto-recharge-auth.ts +2 -0
  51. package/api/src/routes/connect/change-payer.ts +2 -0
  52. package/api/src/routes/connect/change-payment.ts +61 -8
  53. package/api/src/routes/connect/change-plan.ts +161 -17
  54. package/api/src/routes/connect/collect.ts +9 -6
  55. package/api/src/routes/connect/delegation.ts +1 -0
  56. package/api/src/routes/connect/pay.ts +157 -0
  57. package/api/src/routes/connect/setup.ts +32 -10
  58. package/api/src/routes/connect/shared.ts +159 -13
  59. package/api/src/routes/connect/subscribe.ts +32 -9
  60. package/api/src/routes/credit-grants.ts +99 -0
  61. package/api/src/routes/exchange-rate-providers.ts +248 -0
  62. package/api/src/routes/exchange-rates.ts +87 -0
  63. package/api/src/routes/index.ts +4 -0
  64. package/api/src/routes/invoices.ts +280 -2
  65. package/api/src/routes/meter-events.ts +3 -0
  66. package/api/src/routes/payment-links.ts +13 -0
  67. package/api/src/routes/prices.ts +84 -2
  68. package/api/src/routes/subscriptions.ts +526 -15
  69. package/api/src/store/migrations/20251220-dynamic-pricing.ts +245 -0
  70. package/api/src/store/migrations/20251223-exchange-rate-provider-type.ts +28 -0
  71. package/api/src/store/migrations/20260110-add-quote-locked-at.ts +23 -0
  72. package/api/src/store/migrations/20260112-add-checkout-session-slippage-percent.ts +22 -0
  73. package/api/src/store/migrations/20260113-add-price-quote-slippage-fields.ts +45 -0
  74. package/api/src/store/migrations/20260116-subscription-slippage.ts +21 -0
  75. package/api/src/store/migrations/20260120-auto-recharge-slippage.ts +21 -0
  76. package/api/src/store/models/auto-recharge-config.ts +12 -0
  77. package/api/src/store/models/checkout-session.ts +7 -0
  78. package/api/src/store/models/exchange-rate-provider.ts +225 -0
  79. package/api/src/store/models/index.ts +6 -0
  80. package/api/src/store/models/payment-intent.ts +6 -0
  81. package/api/src/store/models/price-quote.ts +284 -0
  82. package/api/src/store/models/price.ts +53 -5
  83. package/api/src/store/models/subscription.ts +11 -0
  84. package/api/src/store/models/types.ts +61 -1
  85. package/api/tests/libs/change-payment-plan.spec.ts +282 -0
  86. package/api/tests/libs/exchange-rate-service.spec.ts +341 -0
  87. package/api/tests/libs/quote-service.spec.ts +199 -0
  88. package/api/tests/libs/session.spec.ts +464 -0
  89. package/api/tests/libs/slippage.spec.ts +109 -0
  90. package/api/tests/libs/token-data-provider.spec.ts +267 -0
  91. package/api/tests/models/exchange-rate-provider.spec.ts +121 -0
  92. package/api/tests/models/price-dynamic.spec.ts +100 -0
  93. package/api/tests/models/price-quote.spec.ts +112 -0
  94. package/api/tests/routes/exchange-rate-providers.spec.ts +215 -0
  95. package/api/tests/routes/subscription-slippage.spec.ts +254 -0
  96. package/blocklet.yml +1 -1
  97. package/package.json +7 -6
  98. package/src/components/customer/credit-overview.tsx +14 -0
  99. package/src/components/discount/discount-info.tsx +8 -2
  100. package/src/components/invoice/list.tsx +146 -16
  101. package/src/components/invoice/table.tsx +276 -71
  102. package/src/components/invoice-pdf/template.tsx +3 -7
  103. package/src/components/metadata/form.tsx +6 -8
  104. package/src/components/price/form.tsx +519 -149
  105. package/src/components/promotion/active-redemptions.tsx +5 -3
  106. package/src/components/quote/info.tsx +234 -0
  107. package/src/hooks/subscription.ts +132 -2
  108. package/src/locales/en.tsx +145 -0
  109. package/src/locales/zh.tsx +143 -1
  110. package/src/pages/admin/billing/invoices/detail.tsx +41 -4
  111. package/src/pages/admin/products/exchange-rate-providers/edit-dialog.tsx +354 -0
  112. package/src/pages/admin/products/exchange-rate-providers/index.tsx +363 -0
  113. package/src/pages/admin/products/index.tsx +12 -1
  114. package/src/pages/customer/invoice/detail.tsx +36 -12
  115. package/src/pages/customer/subscription/change-payment.tsx +65 -3
  116. package/src/pages/customer/subscription/change-plan.tsx +207 -38
  117. 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 = await Price.expand(checkoutSession.line_items, { product: true, upsell: true });
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
- await checkoutSession.update({
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: checkoutSession.payment_intent_data?.metadata || checkoutSession.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 && existInvoice.status === 'paid') {
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(doc.line_items, { upsell: true });
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: trialInDays > 0 || trialEnd > now,
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
- const items = cloneDeep(checkoutSession.line_items);
2507
- const targetItem = items.find((x) => x.price_id === itemId);
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
- await checkoutSession.update({ line_items: items });
2513
- const lineItems = await Price.expand(checkoutSession.line_items);
2514
- res.json({ ...checkoutSession.toJSON(), line_items: lineItems });
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: expandedItems,
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
- const now = dayjs().unix();
2852
- const trialSetup = getSubscriptionTrialSetup(checkoutSession.subscription_data as any, currency.id);
2853
- const isTrialing = trialSetup.trialInDays > 0 || trialSetup.trialEnd > now;
2854
-
2855
- // Apply discount with new currency
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
- // Create updated discount configuration
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: discountResult.discountSummary.totalDiscountAmount,
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 recalculated discount
4398
+ // Update checkout session with discountable flags only
2919
4399
  await checkoutSession.update({
2920
4400
  discounts: [discountConfig],
2921
- line_items: discountResult.enhancedLineItems.map((item: TLineItemExpanded) => {
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
- // Create enhanced line items with complete coupon information for response
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: enhancedLineItemsWithCoupon,
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;