payment-kit 1.24.4 → 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.
- package/api/src/index.ts +3 -0
- package/api/src/libs/credit-utils.ts +21 -0
- package/api/src/libs/discount/discount.ts +13 -0
- package/api/src/libs/env.ts +5 -0
- package/api/src/libs/error.ts +14 -0
- package/api/src/libs/exchange-rate/coingecko-provider.ts +193 -0
- package/api/src/libs/exchange-rate/coinmarketcap-provider.ts +180 -0
- package/api/src/libs/exchange-rate/index.ts +5 -0
- package/api/src/libs/exchange-rate/service.ts +583 -0
- package/api/src/libs/exchange-rate/token-address-mapping.ts +84 -0
- package/api/src/libs/exchange-rate/token-addresses.json +2147 -0
- package/api/src/libs/exchange-rate/token-data-provider.ts +142 -0
- package/api/src/libs/exchange-rate/types.ts +114 -0
- package/api/src/libs/exchange-rate/validator.ts +319 -0
- package/api/src/libs/invoice-quote.ts +158 -0
- package/api/src/libs/invoice.ts +143 -7
- package/api/src/libs/math-utils.ts +46 -0
- package/api/src/libs/notification/template/billing-discrepancy.ts +3 -4
- package/api/src/libs/notification/template/customer-auto-recharge-failed.ts +174 -79
- package/api/src/libs/notification/template/customer-credit-grant-granted.ts +2 -3
- package/api/src/libs/notification/template/customer-credit-insufficient.ts +3 -3
- package/api/src/libs/notification/template/customer-credit-low-balance.ts +3 -3
- package/api/src/libs/notification/template/customer-revenue-succeeded.ts +2 -3
- package/api/src/libs/notification/template/customer-reward-succeeded.ts +9 -4
- package/api/src/libs/notification/template/exchange-rate-alert.ts +202 -0
- package/api/src/libs/notification/template/subscription-slippage-exceeded.ts +203 -0
- package/api/src/libs/notification/template/subscription-slippage-warning.ts +212 -0
- package/api/src/libs/notification/template/subscription-will-canceled.ts +2 -2
- package/api/src/libs/notification/template/subscription-will-renew.ts +22 -8
- package/api/src/libs/payment.ts +1 -1
- package/api/src/libs/price.ts +4 -1
- package/api/src/libs/queue/index.ts +8 -0
- package/api/src/libs/quote-service.ts +1132 -0
- package/api/src/libs/quote-validation.ts +388 -0
- package/api/src/libs/session.ts +686 -39
- package/api/src/libs/slippage.ts +135 -0
- package/api/src/libs/subscription.ts +185 -15
- package/api/src/libs/util.ts +64 -3
- package/api/src/locales/en.ts +50 -0
- package/api/src/locales/zh.ts +48 -0
- package/api/src/queues/auto-recharge.ts +295 -21
- package/api/src/queues/exchange-rate-health.ts +242 -0
- package/api/src/queues/invoice.ts +48 -1
- package/api/src/queues/notification.ts +167 -1
- package/api/src/queues/payment.ts +177 -7
- package/api/src/queues/subscription.ts +436 -6
- package/api/src/routes/auto-recharge-configs.ts +71 -6
- package/api/src/routes/checkout-sessions.ts +1730 -81
- package/api/src/routes/connect/auto-recharge-auth.ts +2 -0
- package/api/src/routes/connect/change-payer.ts +2 -0
- package/api/src/routes/connect/change-payment.ts +61 -8
- package/api/src/routes/connect/change-plan.ts +161 -17
- package/api/src/routes/connect/collect.ts +9 -6
- package/api/src/routes/connect/delegation.ts +1 -0
- package/api/src/routes/connect/pay.ts +157 -0
- package/api/src/routes/connect/setup.ts +32 -10
- package/api/src/routes/connect/shared.ts +159 -13
- package/api/src/routes/connect/subscribe.ts +32 -9
- package/api/src/routes/credit-grants.ts +99 -0
- package/api/src/routes/exchange-rate-providers.ts +248 -0
- package/api/src/routes/exchange-rates.ts +87 -0
- package/api/src/routes/index.ts +4 -0
- package/api/src/routes/invoices.ts +280 -2
- package/api/src/routes/payment-links.ts +13 -0
- package/api/src/routes/prices.ts +84 -2
- package/api/src/routes/subscriptions.ts +526 -15
- package/api/src/store/migrations/20251220-dynamic-pricing.ts +245 -0
- package/api/src/store/migrations/20251223-exchange-rate-provider-type.ts +28 -0
- package/api/src/store/migrations/20260110-add-quote-locked-at.ts +23 -0
- package/api/src/store/migrations/20260112-add-checkout-session-slippage-percent.ts +22 -0
- package/api/src/store/migrations/20260113-add-price-quote-slippage-fields.ts +45 -0
- package/api/src/store/migrations/20260116-subscription-slippage.ts +21 -0
- package/api/src/store/migrations/20260120-auto-recharge-slippage.ts +21 -0
- package/api/src/store/models/auto-recharge-config.ts +12 -0
- package/api/src/store/models/checkout-session.ts +7 -0
- package/api/src/store/models/exchange-rate-provider.ts +225 -0
- package/api/src/store/models/index.ts +6 -0
- package/api/src/store/models/payment-intent.ts +6 -0
- package/api/src/store/models/price-quote.ts +284 -0
- package/api/src/store/models/price.ts +53 -5
- package/api/src/store/models/subscription.ts +11 -0
- package/api/src/store/models/types.ts +61 -1
- package/api/tests/libs/change-payment-plan.spec.ts +282 -0
- package/api/tests/libs/exchange-rate-service.spec.ts +341 -0
- package/api/tests/libs/quote-service.spec.ts +199 -0
- package/api/tests/libs/session.spec.ts +464 -0
- package/api/tests/libs/slippage.spec.ts +109 -0
- package/api/tests/libs/token-data-provider.spec.ts +267 -0
- package/api/tests/models/exchange-rate-provider.spec.ts +121 -0
- package/api/tests/models/price-dynamic.spec.ts +100 -0
- package/api/tests/models/price-quote.spec.ts +112 -0
- package/api/tests/routes/exchange-rate-providers.spec.ts +215 -0
- package/api/tests/routes/subscription-slippage.spec.ts +254 -0
- package/blocklet.yml +1 -1
- package/package.json +7 -6
- package/src/components/customer/credit-overview.tsx +14 -0
- package/src/components/discount/discount-info.tsx +8 -2
- package/src/components/invoice/list.tsx +146 -16
- package/src/components/invoice/table.tsx +276 -71
- package/src/components/invoice-pdf/template.tsx +3 -7
- package/src/components/metadata/form.tsx +6 -8
- package/src/components/price/form.tsx +519 -149
- package/src/components/promotion/active-redemptions.tsx +5 -3
- package/src/components/quote/info.tsx +234 -0
- package/src/hooks/subscription.ts +132 -2
- package/src/locales/en.tsx +145 -0
- package/src/locales/zh.tsx +143 -1
- package/src/pages/admin/billing/invoices/detail.tsx +41 -4
- package/src/pages/admin/products/exchange-rate-providers/edit-dialog.tsx +354 -0
- package/src/pages/admin/products/exchange-rate-providers/index.tsx +363 -0
- package/src/pages/admin/products/index.tsx +12 -1
- package/src/pages/customer/invoice/detail.tsx +36 -12
- package/src/pages/customer/subscription/change-payment.tsx +65 -3
- package/src/pages/customer/subscription/change-plan.tsx +207 -38
- package/src/pages/customer/subscription/detail.tsx +599 -419
package/api/src/libs/session.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
/* eslint-disable no-nested-ternary */
|
|
1
2
|
import { env } from '@blocklet/sdk/lib/config';
|
|
2
3
|
import type { TransactionInput } from '@ocap/client';
|
|
3
|
-
import { BN, fromTokenToUnit } from '@ocap/util';
|
|
4
|
+
import { BN, fromTokenToUnit, fromUnitToToken } from '@ocap/util';
|
|
4
5
|
import cloneDeep from 'lodash/cloneDeep';
|
|
5
6
|
import isEqual from 'lodash/isEqual';
|
|
6
7
|
import pAll from 'p-all';
|
|
@@ -15,10 +16,10 @@ import {
|
|
|
15
16
|
Invoice,
|
|
16
17
|
PaymentCurrency,
|
|
17
18
|
PaymentMethod,
|
|
19
|
+
PriceQuote,
|
|
18
20
|
SetupIntent,
|
|
19
21
|
Subscription,
|
|
20
22
|
SubscriptionItem,
|
|
21
|
-
TPriceExpanded,
|
|
22
23
|
type CheckoutSession,
|
|
23
24
|
type TLineItemExpanded,
|
|
24
25
|
type TPaymentCurrency,
|
|
@@ -26,10 +27,21 @@ import {
|
|
|
26
27
|
} from '../store/models';
|
|
27
28
|
import { Price, Price as TPrice } from '../store/models/price';
|
|
28
29
|
import type { Product } from '../store/models/product';
|
|
29
|
-
import type {
|
|
30
|
+
import type {
|
|
31
|
+
ChainType,
|
|
32
|
+
PaymentBeneficiary,
|
|
33
|
+
PriceRecurring,
|
|
34
|
+
SubscriptionBillingThresholds,
|
|
35
|
+
} from '../store/models/types';
|
|
30
36
|
import { wallet } from './auth';
|
|
31
37
|
import logger from './logger';
|
|
32
38
|
import { applyDiscountsToLineItems } from './discount/discount';
|
|
39
|
+
import { getQuoteService, QuoteResponse } from './quote-service';
|
|
40
|
+
import { getExchangeRateService } from './exchange-rate';
|
|
41
|
+
import { getExchangeRateSymbol } from './exchange-rate/token-address-mapping';
|
|
42
|
+
import { trimDecimals } from './math-utils';
|
|
43
|
+
import { normalizeSlippageConfigFromMetadata } from './slippage';
|
|
44
|
+
import { getSubscriptionItemPrice } from './credit-utils';
|
|
33
45
|
|
|
34
46
|
export function getStatementDescriptor(items: any[]) {
|
|
35
47
|
for (const item of items) {
|
|
@@ -102,34 +114,70 @@ export async function getCheckoutAmount(
|
|
|
102
114
|
}
|
|
103
115
|
}
|
|
104
116
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
117
|
+
// Use async reduce to support quote_id fallback
|
|
118
|
+
const subtotal = await processedItems
|
|
119
|
+
.reduce(
|
|
120
|
+
async (accPromise, x) => {
|
|
121
|
+
const acc = await accPromise;
|
|
110
122
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
123
|
+
// Priority 1: Use custom_amount if available
|
|
124
|
+
if (x.custom_amount) {
|
|
125
|
+
return acc.add(new BN(x.custom_amount));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Priority 2: If has quote_id but no custom_amount, fetch from Quote table
|
|
129
|
+
if ((x as any).quote_id) {
|
|
130
|
+
try {
|
|
131
|
+
const quote = await PriceQuote.findByPk((x as any).quote_id);
|
|
132
|
+
if (quote) {
|
|
133
|
+
logger.info('Using quoted_amount from quote_id for checkout amount calculation', {
|
|
134
|
+
priceId: x.price.id,
|
|
135
|
+
quoteId: (x as any).quote_id,
|
|
136
|
+
quotedAmount: quote.quoted_amount,
|
|
137
|
+
});
|
|
138
|
+
return acc.add(new BN(quote.quoted_amount));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Quote not found - log warning and fall through to price calculation
|
|
142
|
+
logger.warn('Quote not found for quote_id in line_item', {
|
|
143
|
+
priceId: x.price.id,
|
|
144
|
+
quoteId: (x as any).quote_id,
|
|
145
|
+
});
|
|
146
|
+
} catch (error) {
|
|
147
|
+
logger.error('Failed to fetch quote for quote_id', {
|
|
148
|
+
priceId: x.price.id,
|
|
149
|
+
quoteId: (x as any).quote_id,
|
|
150
|
+
error: (error as Error).message,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
116
153
|
}
|
|
117
|
-
}
|
|
118
154
|
|
|
119
|
-
|
|
120
|
-
|
|
155
|
+
// Priority 3: Calculate from price (fixed pricing or quote query failed)
|
|
156
|
+
const price = x.upsell_price || x.price;
|
|
157
|
+
const unitPrice = getPriceUintAmountByCurrency(price, currencyId);
|
|
121
158
|
|
|
122
|
-
if (
|
|
123
|
-
|
|
159
|
+
if (price.custom_unit_amount) {
|
|
160
|
+
if (unitPrice) {
|
|
161
|
+
return acc.add(new BN(unitPrice).mul(new BN(x.quantity)));
|
|
162
|
+
}
|
|
124
163
|
}
|
|
125
|
-
|
|
126
|
-
|
|
164
|
+
|
|
165
|
+
if (price?.type === 'recurring') {
|
|
166
|
+
renew = renew.add(new BN(unitPrice).mul(new BN(x.quantity)));
|
|
167
|
+
|
|
168
|
+
if (trialing) {
|
|
169
|
+
return acc;
|
|
170
|
+
}
|
|
171
|
+
if (price?.recurring?.usage_type === 'metered') {
|
|
172
|
+
return acc;
|
|
173
|
+
}
|
|
127
174
|
}
|
|
128
|
-
}
|
|
129
175
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
176
|
+
return acc.add(new BN(unitPrice).mul(new BN(x.quantity)));
|
|
177
|
+
},
|
|
178
|
+
Promise.resolve(new BN(0))
|
|
179
|
+
)
|
|
180
|
+
.then((bn) => bn.toString());
|
|
133
181
|
|
|
134
182
|
// Calculate total discount amount from processed line items
|
|
135
183
|
let totalDiscountAmount = new BN(0);
|
|
@@ -220,7 +268,11 @@ export function getSupportedPaymentMethods(
|
|
|
220
268
|
|
|
221
269
|
export function getSupportedPaymentCurrencies(items: TLineItemExpanded[]) {
|
|
222
270
|
const currencies = items.reduce((acc: string[], x: any) => {
|
|
223
|
-
|
|
271
|
+
const price = x.upsell_price || x.price;
|
|
272
|
+
if (!price) {
|
|
273
|
+
return acc;
|
|
274
|
+
}
|
|
275
|
+
return acc.concat(getPriceCurrencyOptions(price).map((c: any) => c.currency_id));
|
|
224
276
|
}, []);
|
|
225
277
|
return Array.from(new Set(currencies));
|
|
226
278
|
}
|
|
@@ -462,21 +514,89 @@ export function formatSubscriptionProduct(items: TLineItemExpanded[], maxLength
|
|
|
462
514
|
return names.length > maxLength ? `${names.slice(0, maxLength).join(', ')} ...` : names.join(', ');
|
|
463
515
|
}
|
|
464
516
|
|
|
517
|
+
/**
|
|
518
|
+
* Slippage configuration for subscription authorization calculation
|
|
519
|
+
*/
|
|
520
|
+
export type SlippageOptions = {
|
|
521
|
+
/** Slippage percentage (e.g., 0.5 for 0.5%, 5 for 5%). Used when minAcceptableRate is not provided. */
|
|
522
|
+
percent?: number;
|
|
523
|
+
/** Minimum acceptable exchange rate (USD per token). When provided, used for precise authorization calculation. */
|
|
524
|
+
minAcceptableRate?: string;
|
|
525
|
+
/** Token decimal places. Required when minAcceptableRate is provided. */
|
|
526
|
+
currencyDecimal?: number;
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
const DEFAULT_SLIPPAGE_PERCENT = 0.5;
|
|
530
|
+
const USD_DECIMALS = 8;
|
|
531
|
+
|
|
465
532
|
/**
|
|
466
533
|
* Setup subscription creation parameters
|
|
534
|
+
* @param items - Line items for the subscription
|
|
535
|
+
* @param currencyId - Payment currency ID
|
|
536
|
+
* @param trialInDays - Trial period in days (optional)
|
|
537
|
+
* @param trialEnd - Trial end timestamp (optional)
|
|
538
|
+
* @param slippageOptions - Slippage configuration for dynamic pricing authorization
|
|
467
539
|
*/
|
|
468
540
|
export function getSubscriptionCreateSetup(
|
|
469
541
|
items: TLineItemExpanded[],
|
|
470
542
|
currencyId: string,
|
|
471
543
|
trialInDays = 0,
|
|
472
|
-
trialEnd = 0
|
|
544
|
+
trialEnd = 0,
|
|
545
|
+
slippageOptions?: SlippageOptions | number
|
|
473
546
|
) {
|
|
547
|
+
// Normalize slippage options (support legacy number parameter for backward compatibility)
|
|
548
|
+
const options: SlippageOptions =
|
|
549
|
+
typeof slippageOptions === 'number'
|
|
550
|
+
? { percent: slippageOptions }
|
|
551
|
+
: slippageOptions || { percent: DEFAULT_SLIPPAGE_PERCENT };
|
|
552
|
+
|
|
474
553
|
let setup = new BN(0);
|
|
554
|
+
let hasDynamicPricing = false;
|
|
555
|
+
let totalBaseAmountBN = new BN(0); // Track total USD base amount for dynamic pricing items
|
|
556
|
+
|
|
557
|
+
// Debug log to verify items have correct upsell_price
|
|
558
|
+
logger.debug('getSubscriptionCreateSetup: items check', {
|
|
559
|
+
itemCount: items.length,
|
|
560
|
+
items: items.map((x) => ({
|
|
561
|
+
price_id: x.price?.id,
|
|
562
|
+
upsell_price_id: x.upsell_price?.id,
|
|
563
|
+
has_upsell: !!x.upsell_price,
|
|
564
|
+
selected_price_id: getSubscriptionItemPrice(x)?.id,
|
|
565
|
+
price_base_amount: x.price?.base_amount,
|
|
566
|
+
upsell_base_amount: x.upsell_price?.base_amount,
|
|
567
|
+
})),
|
|
568
|
+
});
|
|
475
569
|
|
|
476
570
|
items.forEach((x) => {
|
|
477
|
-
const price = x
|
|
478
|
-
|
|
479
|
-
|
|
571
|
+
const price = getSubscriptionItemPrice(x);
|
|
572
|
+
|
|
573
|
+
// Priority: use custom_amount if available (for dynamic pricing with pre-calculated amount)
|
|
574
|
+
// This allows callers to pass in the actual quoted amount from Quote system
|
|
575
|
+
let amount: BN;
|
|
576
|
+
if ((x as any).custom_amount) {
|
|
577
|
+
amount = new BN((x as any).custom_amount);
|
|
578
|
+
} else {
|
|
579
|
+
const unit = getPriceUintAmountByCurrency(price, currencyId);
|
|
580
|
+
amount = new BN(unit).mul(new BN(x.quantity));
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Check if this price uses dynamic pricing
|
|
584
|
+
if (price.pricing_type === 'dynamic') {
|
|
585
|
+
hasDynamicPricing = true;
|
|
586
|
+
// Accumulate base amount for dynamic pricing items (stored in 10^8 scale)
|
|
587
|
+
if (price.base_amount) {
|
|
588
|
+
const itemBaseAmount = fromTokenToUnit(price.base_amount, USD_DECIMALS);
|
|
589
|
+
const contribution = itemBaseAmount.mul(new BN(x.quantity));
|
|
590
|
+
totalBaseAmountBN = totalBaseAmountBN.add(contribution);
|
|
591
|
+
logger.debug('getSubscriptionCreateSetup: processing dynamic pricing item', {
|
|
592
|
+
priceId: price.id,
|
|
593
|
+
baseAmount: price.base_amount,
|
|
594
|
+
quantity: x.quantity,
|
|
595
|
+
contributionUSD: fromUnitToToken(contribution.toString(), USD_DECIMALS),
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
480
600
|
if (price.type === 'recurring') {
|
|
481
601
|
if (price.recurring?.usage_type === 'licensed') {
|
|
482
602
|
setup = setup.add(amount);
|
|
@@ -487,9 +607,54 @@ export function getSubscriptionCreateSetup(
|
|
|
487
607
|
}
|
|
488
608
|
});
|
|
489
609
|
|
|
610
|
+
// Apply slippage protection for subscriptions with dynamic pricing
|
|
611
|
+
if (hasDynamicPricing && setup.gt(new BN(0))) {
|
|
612
|
+
const { percent, minAcceptableRate, currencyDecimal } = options;
|
|
613
|
+
|
|
614
|
+
// Priority: minAcceptableRate > percent
|
|
615
|
+
// When minAcceptableRate is provided, calculate: authorization = base_amount / min_acceptable_rate
|
|
616
|
+
// This is more accurate as it uses the exact rate threshold configured by user
|
|
617
|
+
if (
|
|
618
|
+
minAcceptableRate &&
|
|
619
|
+
Number(minAcceptableRate) > 0 &&
|
|
620
|
+
currencyDecimal !== undefined &&
|
|
621
|
+
totalBaseAmountBN.gt(new BN(0))
|
|
622
|
+
) {
|
|
623
|
+
// Calculate authorization using min_acceptable_rate
|
|
624
|
+
// Formula: authorization_token = ceil(base_amount_usd / min_acceptable_rate)
|
|
625
|
+
const minRateBN = fromTokenToUnit(minAcceptableRate, USD_DECIMALS);
|
|
626
|
+
if (minRateBN.gt(new BN(0))) {
|
|
627
|
+
const numerator = totalBaseAmountBN.mul(new BN(10).pow(new BN(currencyDecimal)));
|
|
628
|
+
// Ceiling division: ceil(a/b) = (a + b - 1) / b
|
|
629
|
+
setup = numerator.add(minRateBN).sub(new BN(1)).div(minRateBN);
|
|
630
|
+
logger.info('Applied subscription authorization with min_acceptable_rate', {
|
|
631
|
+
hasDynamicPricing,
|
|
632
|
+
authorizationAmountRaw: setup.toString(),
|
|
633
|
+
authorizationAmountDisplay: fromUnitToToken(setup.toString(), currencyDecimal),
|
|
634
|
+
minAcceptableRate,
|
|
635
|
+
totalBaseAmountUSD: fromUnitToToken(totalBaseAmountBN.toString(), USD_DECIMALS),
|
|
636
|
+
currencyDecimal,
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
} else {
|
|
640
|
+
// Fallback: Apply slippage percent multiplier
|
|
641
|
+
// Authorization amount = base * (1 + percent/100)
|
|
642
|
+
const normalizedSlippage = Number.isFinite(percent) && percent! >= 0 ? percent! : DEFAULT_SLIPPAGE_PERCENT;
|
|
643
|
+
// Convert slippage percent to multiplier: 0.5% -> 1005/1000, 5% -> 1050/1000
|
|
644
|
+
const SLIPPAGE_MULTIPLIER = new BN(Math.round(1000 + normalizedSlippage * 10));
|
|
645
|
+
const SLIPPAGE_DIVISOR = new BN(1000);
|
|
646
|
+
setup = setup.mul(SLIPPAGE_MULTIPLIER).div(SLIPPAGE_DIVISOR);
|
|
647
|
+
logger.info('Applied subscription authorization slippage protection', {
|
|
648
|
+
hasDynamicPricing,
|
|
649
|
+
authorizationAmount: setup.toString(),
|
|
650
|
+
slippageRate: `${normalizedSlippage}%`,
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
490
655
|
const now = dayjs().unix();
|
|
491
|
-
const item = items.find((x) => (x
|
|
492
|
-
const recurring = (item
|
|
656
|
+
const item = items.find((x) => getSubscriptionItemPrice(x)?.type === 'recurring');
|
|
657
|
+
const recurring = (item ? getSubscriptionItemPrice(item)?.recurring : undefined) as PriceRecurring;
|
|
493
658
|
const cycle = getRecurringPeriod(recurring);
|
|
494
659
|
|
|
495
660
|
let trialStartAt = 0;
|
|
@@ -760,11 +925,49 @@ async function createOrUpdateSubscription(params: {
|
|
|
760
925
|
|
|
761
926
|
const { trialInDays = 0, trialEnd = 0 } = trialConfig;
|
|
762
927
|
const { method: paymentMethod, currency: paymentCurrency } = paymentSettings;
|
|
928
|
+
const normalizePercent = (value: any) => {
|
|
929
|
+
const normalized = typeof value === 'string' ? Number(value) : value;
|
|
930
|
+
if (!Number.isFinite(normalized) || normalized < 0) {
|
|
931
|
+
return DEFAULT_SLIPPAGE_PERCENT;
|
|
932
|
+
}
|
|
933
|
+
return normalized;
|
|
934
|
+
};
|
|
935
|
+
const buildSlippageConfig = () => {
|
|
936
|
+
const fromMeta = metadata?.slippage;
|
|
937
|
+
if (fromMeta && typeof fromMeta === 'object') {
|
|
938
|
+
const mode: 'percent' | 'rate' = fromMeta.mode === 'rate' ? 'rate' : 'percent';
|
|
939
|
+
const percent = normalizePercent(fromMeta.percent);
|
|
940
|
+
const minRate = typeof fromMeta.min_acceptable_rate === 'string' ? fromMeta.min_acceptable_rate : undefined;
|
|
941
|
+
const baseCurrency = typeof fromMeta.base_currency === 'string' ? fromMeta.base_currency : undefined;
|
|
942
|
+
const updatedAtMs = Number.isFinite(Number(fromMeta.updated_at_ms)) ? Number(fromMeta.updated_at_ms) : undefined;
|
|
943
|
+
return {
|
|
944
|
+
mode,
|
|
945
|
+
percent,
|
|
946
|
+
// Always include min_acceptable_rate if available (for both percent and rate modes)
|
|
947
|
+
...(minRate ? { min_acceptable_rate: minRate } : {}),
|
|
948
|
+
...(baseCurrency ? { base_currency: baseCurrency } : {}),
|
|
949
|
+
...(updatedAtMs ? { updated_at_ms: updatedAtMs } : {}),
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
const fromSessionMeta = normalizeSlippageConfigFromMetadata(checkoutSession.metadata, DEFAULT_SLIPPAGE_PERCENT);
|
|
953
|
+
if (fromSessionMeta) {
|
|
954
|
+
return fromSessionMeta;
|
|
955
|
+
}
|
|
956
|
+
const percent = normalizePercent((checkoutSession as any).slippage_percent);
|
|
957
|
+
return { mode: 'percent' as const, percent };
|
|
958
|
+
};
|
|
959
|
+
const slippageConfig = buildSlippageConfig();
|
|
763
960
|
|
|
764
961
|
try {
|
|
765
962
|
const itemsSubscriptionData = mergeSubscriptionDataFromLineItems(lineItems);
|
|
766
963
|
let subscription = null;
|
|
767
|
-
|
|
964
|
+
// Build slippage options with min_acceptable_rate for precise authorization calculation
|
|
965
|
+
const slippageOptions: SlippageOptions = {
|
|
966
|
+
percent: slippageConfig.percent,
|
|
967
|
+
minAcceptableRate: slippageConfig.min_acceptable_rate,
|
|
968
|
+
currencyDecimal: paymentCurrency.decimal,
|
|
969
|
+
};
|
|
970
|
+
const setup = getSubscriptionCreateSetup(lineItems, paymentCurrency.id, trialInDays, trialEnd, slippageOptions);
|
|
768
971
|
|
|
769
972
|
if (existingSubscriptionId) {
|
|
770
973
|
subscription = await Subscription.findByPk(existingSubscriptionId);
|
|
@@ -793,6 +996,7 @@ async function createOrUpdateSubscription(params: {
|
|
|
793
996
|
trial_start: setup.trial.start,
|
|
794
997
|
pending_invoice_item_interval: setup.recurring,
|
|
795
998
|
pending_setup_intent: setupIntent?.id,
|
|
999
|
+
slippage_config: slippageConfig,
|
|
796
1000
|
});
|
|
797
1001
|
|
|
798
1002
|
logger.info('Subscription updated', {
|
|
@@ -861,6 +1065,7 @@ async function createOrUpdateSubscription(params: {
|
|
|
861
1065
|
default_payment_method_id: paymentMethod.id,
|
|
862
1066
|
cancel_at_period_end: false,
|
|
863
1067
|
collection_method: 'charge_automatically',
|
|
1068
|
+
slippage_config: slippageConfig,
|
|
864
1069
|
description: subscriptionData.description || formatSubscriptionProduct(lineItems),
|
|
865
1070
|
proration_behavior: subscriptionData.proration_behavior || 'none',
|
|
866
1071
|
payment_behavior: 'default_incomplete',
|
|
@@ -1024,13 +1229,8 @@ export async function getSubscriptionLineItems(
|
|
|
1024
1229
|
return subItems;
|
|
1025
1230
|
}
|
|
1026
1231
|
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
}
|
|
1030
|
-
|
|
1031
|
-
export function isCreditMeteredLineItems(lineItems: TLineItemExpanded[]) {
|
|
1032
|
-
return lineItems.every((item) => item.price && isCreditMetered(item.price));
|
|
1033
|
-
}
|
|
1232
|
+
// Re-exported from credit-utils.ts to maintain backward compatibility
|
|
1233
|
+
export { isCreditMetered, isCreditMeteredLineItems } from './credit-utils';
|
|
1034
1234
|
|
|
1035
1235
|
export function validateStripePaymentAmounts(amount: string, currency: PaymentCurrency) {
|
|
1036
1236
|
const minAmountInUnits = fromTokenToUnit(0.5, currency.decimal);
|
|
@@ -1053,6 +1253,24 @@ export async function validatePaymentAmounts(
|
|
|
1053
1253
|
const oneTimeItems = getOneTimeLineItems(lineItems);
|
|
1054
1254
|
const recurringItems = getRecurringLineItems(lineItems);
|
|
1055
1255
|
|
|
1256
|
+
// Check for mixed pricing types (fixed + dynamic)
|
|
1257
|
+
const hasDynamicPricing = lineItems.some((item) => {
|
|
1258
|
+
const price = item.upsell_price || item.price;
|
|
1259
|
+
return (price as any)?.pricing_type === 'dynamic';
|
|
1260
|
+
});
|
|
1261
|
+
|
|
1262
|
+
const hasFixedPricing = lineItems.some((item) => {
|
|
1263
|
+
const price = item.upsell_price || item.price;
|
|
1264
|
+
return !(price as any)?.pricing_type || (price as any)?.pricing_type === 'fixed';
|
|
1265
|
+
});
|
|
1266
|
+
|
|
1267
|
+
if (hasDynamicPricing && hasFixedPricing) {
|
|
1268
|
+
return {
|
|
1269
|
+
valid: false,
|
|
1270
|
+
error: 'Cannot mix fixed pricing and dynamic pricing in the same checkout session',
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1056
1274
|
const minAmountInUnits = fromTokenToUnit(0.5, currency.decimal);
|
|
1057
1275
|
|
|
1058
1276
|
// Case 1: All one-time items - validate total payment amount
|
|
@@ -1254,3 +1472,432 @@ export async function getCheckoutSessionAmounts(checkoutSession: CheckoutSession
|
|
|
1254
1472
|
discounts,
|
|
1255
1473
|
};
|
|
1256
1474
|
}
|
|
1475
|
+
|
|
1476
|
+
const QUOTE_LOCK_DURATION_SECONDS = 180;
|
|
1477
|
+
function calculateTotalBaseAmount(baseAmount: string, quantity: number): string {
|
|
1478
|
+
const baseAmountBN = fromTokenToUnit(trimDecimals(baseAmount, USD_DECIMALS), USD_DECIMALS);
|
|
1479
|
+
const quantityBN = new BN(quantity);
|
|
1480
|
+
const totalBaseAmountBN = baseAmountBN.mul(quantityBN);
|
|
1481
|
+
return fromUnitToToken(totalBaseAmountBN.toString(), USD_DECIMALS);
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
/**
|
|
1485
|
+
* Enrich checkout session with dynamic pricing quotes
|
|
1486
|
+
* For each dynamic price, create or reuse a quote and attach quote info to line items
|
|
1487
|
+
*
|
|
1488
|
+
* @param options.skipGeneration - If true, only fetch existing quotes via quote_id, don't generate new ones
|
|
1489
|
+
*/
|
|
1490
|
+
export async function enrichCheckoutSessionWithQuotes(
|
|
1491
|
+
checkoutSession: CheckoutSession,
|
|
1492
|
+
lineItems: TLineItemExpanded[],
|
|
1493
|
+
currencyId: string,
|
|
1494
|
+
options: { skipGeneration?: boolean; forceRefresh?: boolean } = {}
|
|
1495
|
+
): Promise<{
|
|
1496
|
+
lineItems: TLineItemExpanded[];
|
|
1497
|
+
quotes: Record<
|
|
1498
|
+
string,
|
|
1499
|
+
{
|
|
1500
|
+
quote_id: string;
|
|
1501
|
+
expires_at: number;
|
|
1502
|
+
quoted_amount: string;
|
|
1503
|
+
exchange_rate?: string;
|
|
1504
|
+
rate_provider_name?: string;
|
|
1505
|
+
rate_provider_id?: string;
|
|
1506
|
+
}
|
|
1507
|
+
>;
|
|
1508
|
+
rateUnavailable?: boolean;
|
|
1509
|
+
rateError?: string;
|
|
1510
|
+
regenerated?: boolean;
|
|
1511
|
+
refreshed?: boolean;
|
|
1512
|
+
}> {
|
|
1513
|
+
const { skipGeneration = false, forceRefresh = false } = options;
|
|
1514
|
+
const quoteService = getQuoteService();
|
|
1515
|
+
|
|
1516
|
+
// Filter out items with missing price (data integrity issue)
|
|
1517
|
+
const invalidItems = lineItems.filter((item) => !item.price);
|
|
1518
|
+
if (invalidItems.length > 0) {
|
|
1519
|
+
logger.error('Line items with missing price detected - possible data integrity issue', {
|
|
1520
|
+
sessionId: checkoutSession.id,
|
|
1521
|
+
invalidPriceIds: invalidItems.map((item) => item.price_id),
|
|
1522
|
+
totalItems: lineItems.length,
|
|
1523
|
+
invalidCount: invalidItems.length,
|
|
1524
|
+
});
|
|
1525
|
+
}
|
|
1526
|
+
// Only process items with valid price
|
|
1527
|
+
const validLineItems = lineItems.filter((item) => item.price);
|
|
1528
|
+
if (validLineItems.length === 0) {
|
|
1529
|
+
logger.warn('No valid line items with price found', { sessionId: checkoutSession.id });
|
|
1530
|
+
return { lineItems, quotes: {}, refreshed: false };
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
const quotes: Record<
|
|
1534
|
+
string,
|
|
1535
|
+
{
|
|
1536
|
+
quote_id: string;
|
|
1537
|
+
expires_at: number;
|
|
1538
|
+
quoted_amount: string;
|
|
1539
|
+
exchange_rate: string;
|
|
1540
|
+
rate_provider_name: string;
|
|
1541
|
+
rate_provider_id: string;
|
|
1542
|
+
}
|
|
1543
|
+
> = {};
|
|
1544
|
+
|
|
1545
|
+
// First, check if line_items already have quote_id references
|
|
1546
|
+
// If so, fetch quote data from PriceQuote table and check if expired
|
|
1547
|
+
const lineItemsWithQuoteIds = validLineItems.filter((item) => (item as any).quote_id);
|
|
1548
|
+
const now = Math.floor(Date.now() / 1000);
|
|
1549
|
+
const quoteLockedAt = checkoutSession.metadata?.quote_locked_at;
|
|
1550
|
+
const lockStart =
|
|
1551
|
+
typeof quoteLockedAt === 'number'
|
|
1552
|
+
? quoteLockedAt
|
|
1553
|
+
: typeof quoteLockedAt === 'string'
|
|
1554
|
+
? Number(quoteLockedAt)
|
|
1555
|
+
: null;
|
|
1556
|
+
const lockActive = Number.isFinite(lockStart) && now - (lockStart as number) < QUOTE_LOCK_DURATION_SECONDS;
|
|
1557
|
+
const slippagePercentValue =
|
|
1558
|
+
(checkoutSession.metadata as any)?.slippage?.percent ?? (checkoutSession as any).slippage_percent;
|
|
1559
|
+
const slippagePercentValueNumber = Number(slippagePercentValue);
|
|
1560
|
+
const slippagePercent = Number.isFinite(slippagePercentValueNumber) ? slippagePercentValueNumber : 0.5;
|
|
1561
|
+
const quotesById = new Map<string, PriceQuote>();
|
|
1562
|
+
const refreshQuoteIds = new Set<string>();
|
|
1563
|
+
let hasExpiredQuotes = false;
|
|
1564
|
+
let hasStaleQuotes = false;
|
|
1565
|
+
let hasMissingQuotes = false;
|
|
1566
|
+
let regenerated = false;
|
|
1567
|
+
let createdNewQuotes = false;
|
|
1568
|
+
const applyQuoteToItem = (item: TLineItemExpanded, quote: PriceQuote) => {
|
|
1569
|
+
(item as any).custom_amount = quote.quoted_amount;
|
|
1570
|
+
(item as any).quoted_amount = quote.quoted_amount;
|
|
1571
|
+
(item as any).quote_id = quote.id;
|
|
1572
|
+
(item as any).exchange_rate = quote.exchange_rate;
|
|
1573
|
+
(item as any).rate_provider_name = quote.rate_provider_name;
|
|
1574
|
+
(item as any).rate_provider_id = quote.rate_provider_id;
|
|
1575
|
+
(item as any).rate_timestamp_ms = quote.rate_timestamp_ms;
|
|
1576
|
+
(item as any).expires_at = quote.expires_at;
|
|
1577
|
+
// Store the currency ID this quote was created for - essential for frontend validation
|
|
1578
|
+
(item as any).quote_currency_id = quote.target_currency_id;
|
|
1579
|
+
|
|
1580
|
+
const quoteKey = `${item.price?.id || item.price_id}_${currencyId}`;
|
|
1581
|
+
quotes[quoteKey] = {
|
|
1582
|
+
quote_id: quote.id,
|
|
1583
|
+
expires_at: quote.expires_at,
|
|
1584
|
+
quoted_amount: quote.quoted_amount,
|
|
1585
|
+
exchange_rate: quote.exchange_rate,
|
|
1586
|
+
rate_provider_name: quote.rate_provider_name,
|
|
1587
|
+
rate_provider_id: quote.rate_provider_id,
|
|
1588
|
+
};
|
|
1589
|
+
};
|
|
1590
|
+
|
|
1591
|
+
if (lineItemsWithQuoteIds.length > 0) {
|
|
1592
|
+
const quoteIds = Array.from(new Set(lineItemsWithQuoteIds.map((item) => (item as any).quote_id).filter(Boolean)));
|
|
1593
|
+
const existingQuotes = await PriceQuote.findAll({
|
|
1594
|
+
where: { id: quoteIds },
|
|
1595
|
+
});
|
|
1596
|
+
|
|
1597
|
+
existingQuotes.forEach((quote) => {
|
|
1598
|
+
quotesById.set(quote.id, quote);
|
|
1599
|
+
});
|
|
1600
|
+
if (existingQuotes.length !== quoteIds.length) {
|
|
1601
|
+
hasMissingQuotes = true;
|
|
1602
|
+
logger.warn('Some quote references are missing from database', {
|
|
1603
|
+
sessionId: checkoutSession.id,
|
|
1604
|
+
totalQuoteIds: quoteIds.length,
|
|
1605
|
+
foundQuotes: existingQuotes.length,
|
|
1606
|
+
});
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
lineItemsWithQuoteIds.forEach((item) => {
|
|
1610
|
+
const quoteId = (item as any).quote_id;
|
|
1611
|
+
const quote = quotesById.get(quoteId);
|
|
1612
|
+
if (!quote) {
|
|
1613
|
+
if (!skipGeneration) {
|
|
1614
|
+
refreshQuoteIds.add(quoteId);
|
|
1615
|
+
}
|
|
1616
|
+
return;
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
const isPaid = quote.status === 'paid';
|
|
1620
|
+
const isUsed = quote.status === 'used';
|
|
1621
|
+
if (isPaid || (isUsed && (lockActive || skipGeneration))) {
|
|
1622
|
+
applyQuoteToItem(item, quote);
|
|
1623
|
+
return;
|
|
1624
|
+
}
|
|
1625
|
+
if (isUsed && !lockActive && !skipGeneration) {
|
|
1626
|
+
hasExpiredQuotes = true;
|
|
1627
|
+
refreshQuoteIds.add(quoteId);
|
|
1628
|
+
logger.info('Detected used quote with expired lock, will refresh', {
|
|
1629
|
+
sessionId: checkoutSession.id,
|
|
1630
|
+
quoteId,
|
|
1631
|
+
status: quote.status,
|
|
1632
|
+
});
|
|
1633
|
+
return;
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
if (forceRefresh && !skipGeneration) {
|
|
1637
|
+
if (!lockActive) {
|
|
1638
|
+
refreshQuoteIds.add(quoteId);
|
|
1639
|
+
logger.info('Force refresh quote for checkout session', {
|
|
1640
|
+
sessionId: checkoutSession.id,
|
|
1641
|
+
quoteId,
|
|
1642
|
+
});
|
|
1643
|
+
} else {
|
|
1644
|
+
logger.info('Skip force refresh due to active quote lock', {
|
|
1645
|
+
sessionId: checkoutSession.id,
|
|
1646
|
+
quoteId,
|
|
1647
|
+
});
|
|
1648
|
+
}
|
|
1649
|
+
return;
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
const price = (item.upsell_price || item.price) as any;
|
|
1653
|
+
if (
|
|
1654
|
+
!skipGeneration &&
|
|
1655
|
+
!lockActive &&
|
|
1656
|
+
quote.status !== 'paid' &&
|
|
1657
|
+
price?.pricing_type === 'dynamic' &&
|
|
1658
|
+
price?.base_amount
|
|
1659
|
+
) {
|
|
1660
|
+
const expectedBaseAmount = calculateTotalBaseAmount(price.base_amount, Number(item.quantity || 0));
|
|
1661
|
+
const expectedTrimmed = trimDecimals(expectedBaseAmount, USD_DECIMALS);
|
|
1662
|
+
const quoteTrimmed = trimDecimals(quote.base_amount || '0', USD_DECIMALS);
|
|
1663
|
+
if (quoteTrimmed !== expectedTrimmed) {
|
|
1664
|
+
hasStaleQuotes = true;
|
|
1665
|
+
refreshQuoteIds.add(quoteId);
|
|
1666
|
+
logger.info('Detected quote base_amount mismatch, will refresh quote', {
|
|
1667
|
+
sessionId: checkoutSession.id,
|
|
1668
|
+
quoteId,
|
|
1669
|
+
priceId: item.price_id || item.price?.id,
|
|
1670
|
+
expectedBaseAmount: expectedTrimmed,
|
|
1671
|
+
quoteBaseAmount: quoteTrimmed,
|
|
1672
|
+
quantity: item.quantity,
|
|
1673
|
+
});
|
|
1674
|
+
return;
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
const isExpired = quote.status !== 'paid' && (quote.expires_at < now || quote.status === 'expired');
|
|
1679
|
+
if (!skipGeneration && !lockActive && isExpired) {
|
|
1680
|
+
hasExpiredQuotes = true;
|
|
1681
|
+
refreshQuoteIds.add(quoteId);
|
|
1682
|
+
logger.info('Detected expired quote', {
|
|
1683
|
+
sessionId: checkoutSession.id,
|
|
1684
|
+
expiredQuoteId: quote.id,
|
|
1685
|
+
expiresAt: new Date(quote.expires_at * 1000).toISOString(),
|
|
1686
|
+
now: new Date(now * 1000).toISOString(),
|
|
1687
|
+
});
|
|
1688
|
+
return;
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
const isInactive = quote.status !== 'active' && quote.status !== 'paid';
|
|
1692
|
+
if (!skipGeneration && !lockActive && isInactive) {
|
|
1693
|
+
hasExpiredQuotes = true;
|
|
1694
|
+
refreshQuoteIds.add(quoteId);
|
|
1695
|
+
logger.info('Detected inactive quote, will refresh', {
|
|
1696
|
+
sessionId: checkoutSession.id,
|
|
1697
|
+
quoteId: quote.id,
|
|
1698
|
+
status: quote.status,
|
|
1699
|
+
});
|
|
1700
|
+
return;
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
// Use existing quotes (either not expired, or skipGeneration)
|
|
1704
|
+
applyQuoteToItem(item, quote);
|
|
1705
|
+
});
|
|
1706
|
+
|
|
1707
|
+
logger.info('Processed existing quotes from database', {
|
|
1708
|
+
sessionId: checkoutSession.id,
|
|
1709
|
+
totalQuoteIds: lineItemsWithQuoteIds.length,
|
|
1710
|
+
foundQuotes: quotesById.size,
|
|
1711
|
+
hasExpiredQuotes,
|
|
1712
|
+
hasStaleQuotes,
|
|
1713
|
+
hasMissingQuotes,
|
|
1714
|
+
skipGeneration,
|
|
1715
|
+
refreshQuotes: refreshQuoteIds.size,
|
|
1716
|
+
});
|
|
1717
|
+
|
|
1718
|
+
if (skipGeneration || refreshQuoteIds.size === 0) {
|
|
1719
|
+
return { lineItems, quotes, refreshed: false };
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
// Find all dynamic pricing items that need new quotes or refresh
|
|
1724
|
+
const dynamicItems = validLineItems.filter((item) => {
|
|
1725
|
+
const price = getSubscriptionItemPrice(item);
|
|
1726
|
+
return (
|
|
1727
|
+
price?.pricing_type === 'dynamic' && ((item as any).quote_id ? refreshQuoteIds.has((item as any).quote_id) : true)
|
|
1728
|
+
);
|
|
1729
|
+
});
|
|
1730
|
+
|
|
1731
|
+
// If no dynamic items need new quotes, return early
|
|
1732
|
+
if (dynamicItems.length === 0) {
|
|
1733
|
+
return { lineItems, quotes, refreshed: false };
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
// If skipGeneration is enabled, don't generate new quotes for items without quote_id
|
|
1737
|
+
// This is used for submitted/completed checkout sessions where we only want to use existing quotes
|
|
1738
|
+
if (skipGeneration) {
|
|
1739
|
+
logger.info('Skipping quote generation for dynamic items (skipGeneration enabled)', {
|
|
1740
|
+
sessionId: checkoutSession.id,
|
|
1741
|
+
dynamicItemsWithoutQuotes: dynamicItems.length,
|
|
1742
|
+
});
|
|
1743
|
+
return { lineItems, quotes, refreshed: false };
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
// Fetch exchange rate once for all dynamic items (they all use the same target currency)
|
|
1747
|
+
const currency = (await PaymentCurrency.findByPk(currencyId, {
|
|
1748
|
+
include: [{ model: PaymentMethod, as: 'payment_method' }],
|
|
1749
|
+
})) as PaymentCurrency & { payment_method: PaymentMethod };
|
|
1750
|
+
if (!currency) {
|
|
1751
|
+
throw new Error(`Currency ${currencyId} not found`);
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
if (currency.payment_method?.type === 'stripe') {
|
|
1755
|
+
return { lineItems, quotes, refreshed: false };
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
const exchangeRateService = getExchangeRateService();
|
|
1759
|
+
|
|
1760
|
+
// Try to get exchange rate, but don't block checkout if unavailable
|
|
1761
|
+
let rateResult;
|
|
1762
|
+
try {
|
|
1763
|
+
// For ArcBlock payment method, always use ABT for exchange rate
|
|
1764
|
+
// For other methods, use the currency's symbol
|
|
1765
|
+
const rateSymbol = getExchangeRateSymbol(currency.symbol, currency.payment_method?.type as ChainType);
|
|
1766
|
+
rateResult = await exchangeRateService.getRate(rateSymbol);
|
|
1767
|
+
} catch (error: any) {
|
|
1768
|
+
logger.warn('Exchange rate unavailable for checkout session', {
|
|
1769
|
+
checkoutSessionId: checkoutSession.id,
|
|
1770
|
+
currencyId,
|
|
1771
|
+
currencySymbol: currency.symbol,
|
|
1772
|
+
error: error.message,
|
|
1773
|
+
});
|
|
1774
|
+
|
|
1775
|
+
// Return without quotes, frontend will show warning
|
|
1776
|
+
return {
|
|
1777
|
+
lineItems,
|
|
1778
|
+
quotes: {},
|
|
1779
|
+
rateUnavailable: true,
|
|
1780
|
+
rateError: error.message || 'Exchange rate service unavailable',
|
|
1781
|
+
refreshed: false,
|
|
1782
|
+
};
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
logger.info('Fetched exchange rate for batch quote creation', {
|
|
1786
|
+
currencyId,
|
|
1787
|
+
currencySymbol: currency.symbol,
|
|
1788
|
+
rate: rateResult.rate,
|
|
1789
|
+
provider: rateResult.provider_name,
|
|
1790
|
+
dynamicItemsCount: dynamicItems.length,
|
|
1791
|
+
});
|
|
1792
|
+
|
|
1793
|
+
// Create or refresh quotes for dynamic items using the same exchange rate
|
|
1794
|
+
const quoteResults = await Promise.all(
|
|
1795
|
+
dynamicItems.map(async (item) => {
|
|
1796
|
+
// Use upsell_price if available, otherwise use price
|
|
1797
|
+
const price = getSubscriptionItemPrice(item);
|
|
1798
|
+
const { quantity } = item;
|
|
1799
|
+
|
|
1800
|
+
try {
|
|
1801
|
+
const quoteId = (item as any).quote_id;
|
|
1802
|
+
let quoteResponse: QuoteResponse;
|
|
1803
|
+
|
|
1804
|
+
const existingQuote = quoteId ? quotesById.get(quoteId) : null;
|
|
1805
|
+
const shouldCreateNewQuote = !existingQuote || (existingQuote.status === 'used' && !lockActive);
|
|
1806
|
+
if (!shouldCreateNewQuote && quoteId && existingQuote) {
|
|
1807
|
+
quoteResponse = await quoteService.refreshQuoteWithRate({
|
|
1808
|
+
quote_id: quoteId,
|
|
1809
|
+
price_id: price?.id,
|
|
1810
|
+
session_id: checkoutSession.id,
|
|
1811
|
+
target_currency_id: currencyId,
|
|
1812
|
+
quantity,
|
|
1813
|
+
rateResult,
|
|
1814
|
+
slippage_percent: slippagePercent,
|
|
1815
|
+
});
|
|
1816
|
+
} else {
|
|
1817
|
+
quoteResponse = await quoteService.createQuoteWithRate({
|
|
1818
|
+
price_id: price?.id,
|
|
1819
|
+
session_id: checkoutSession.id,
|
|
1820
|
+
target_currency_id: currencyId,
|
|
1821
|
+
quantity,
|
|
1822
|
+
rateResult,
|
|
1823
|
+
slippage_percent: slippagePercent,
|
|
1824
|
+
});
|
|
1825
|
+
createdNewQuotes = true;
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
return {
|
|
1829
|
+
item,
|
|
1830
|
+
quoteResponse,
|
|
1831
|
+
};
|
|
1832
|
+
} catch (error) {
|
|
1833
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1834
|
+
logger.error('Failed to create quote for dynamic price', {
|
|
1835
|
+
priceId: price.id,
|
|
1836
|
+
checkoutSessionId: checkoutSession.id,
|
|
1837
|
+
currencyId,
|
|
1838
|
+
errorMessage,
|
|
1839
|
+
errorName: (error as any)?.name,
|
|
1840
|
+
});
|
|
1841
|
+
throw error;
|
|
1842
|
+
}
|
|
1843
|
+
})
|
|
1844
|
+
);
|
|
1845
|
+
|
|
1846
|
+
// Build quote map for quick lookup
|
|
1847
|
+
const quoteByPriceId = new Map<string, (typeof quoteResults)[0]>();
|
|
1848
|
+
quoteResults.forEach((result) => {
|
|
1849
|
+
const priceId = result.item.price?.id || result.item.price_id;
|
|
1850
|
+
if (priceId) {
|
|
1851
|
+
quoteByPriceId.set(priceId, result);
|
|
1852
|
+
}
|
|
1853
|
+
});
|
|
1854
|
+
|
|
1855
|
+
// Extract quote info helper
|
|
1856
|
+
const extractQuoteInfo = (quoteResponse: QuoteResponse) => ({
|
|
1857
|
+
quote_id: quoteResponse.quote.id,
|
|
1858
|
+
expires_at: quoteResponse.expires_at,
|
|
1859
|
+
quoted_amount: quoteResponse.computed_unit_amount,
|
|
1860
|
+
exchange_rate: quoteResponse.quote.exchange_rate,
|
|
1861
|
+
rate_provider_name: quoteResponse.quote.rate_provider_name,
|
|
1862
|
+
rate_provider_id: quoteResponse.quote.rate_provider_id,
|
|
1863
|
+
rate_timestamp_ms: quoteResponse.quote.rate_timestamp_ms,
|
|
1864
|
+
});
|
|
1865
|
+
|
|
1866
|
+
// Build quotes object
|
|
1867
|
+
quoteResults.forEach((result) => {
|
|
1868
|
+
const priceId = result.item.price?.id || result.item.price_id;
|
|
1869
|
+
if (priceId) {
|
|
1870
|
+
const quoteKey = `${priceId}_${currencyId}`;
|
|
1871
|
+
quotes[quoteKey] = extractQuoteInfo(result.quoteResponse);
|
|
1872
|
+
}
|
|
1873
|
+
});
|
|
1874
|
+
|
|
1875
|
+
regenerated = createdNewQuotes;
|
|
1876
|
+
const refreshed = refreshQuoteIds.size > 0 || createdNewQuotes;
|
|
1877
|
+
|
|
1878
|
+
// Enrich line items with quote information
|
|
1879
|
+
const enrichedLineItems = lineItems.map((item) => {
|
|
1880
|
+
// Skip items with missing price
|
|
1881
|
+
if (!item.price) {
|
|
1882
|
+
return item;
|
|
1883
|
+
}
|
|
1884
|
+
const quoteInfo = quoteByPriceId.get(item.price.id);
|
|
1885
|
+
if (!quoteInfo) {
|
|
1886
|
+
return item;
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
const quoteData = extractQuoteInfo(quoteInfo.quoteResponse);
|
|
1890
|
+
return {
|
|
1891
|
+
...item,
|
|
1892
|
+
...quoteData,
|
|
1893
|
+
custom_amount: quoteInfo.quoteResponse.computed_unit_amount,
|
|
1894
|
+
};
|
|
1895
|
+
});
|
|
1896
|
+
|
|
1897
|
+
return {
|
|
1898
|
+
lineItems: enrichedLineItems,
|
|
1899
|
+
quotes,
|
|
1900
|
+
regenerated,
|
|
1901
|
+
refreshed,
|
|
1902
|
+
};
|
|
1903
|
+
}
|