/** * useDynamicPricing Hook * * Extracts and centralizes all dynamic pricing related calculations * for the checkout summary component. * * Final Freeze Architecture: Quotes are created at Submit time only. * This hook handles preview-time display calculations. */ import { useMemo } from 'react'; import type { TCheckoutSession, TLineItemExpanded, TPaymentCurrency, TPaymentIntent } from '@blocklet/payment-types'; import { BN, fromTokenToUnit, fromUnitToToken } from '@ocap/util'; import { formatDateTime, formatDynamicPrice, formatExchangeRate, formatUsdAmount, getUsdAmountFromTokenUnits, } from '../libs/util'; export interface LiveRateInfo { rate?: string; provider_id?: string; provider_name?: string; provider_display?: string; // Human-readable: "CoinGecko" or "CoinGecko (2 sources)" base_currency?: string; timestamp_ms?: number; fetched_at?: number; // When we fetched the data (updates every 30s) } export interface LiveQuoteSnapshot { id: string; quoted_amount: string; exchange_rate: string; expires_at: number; rate_timestamp_ms?: number | null; renewed?: boolean; } export interface DiscountInfo { promotion_code?: string; coupon?: string; discount_amount?: string; coupon_details?: { percent_off?: number; amount_off?: string; currency_id?: string; currency_options?: Record; }; } export interface DynamicPricingOptions { items: TLineItemExpanded[]; currency: TPaymentCurrency; liveRate?: LiveRateInfo; liveQuoteSnapshot?: LiveQuoteSnapshot; checkoutSession?: TCheckoutSession; paymentIntent?: TPaymentIntent | null; locale?: string; isStripePayment?: boolean; isSubscription?: boolean; slippageConfig?: { mode?: 'percent' | 'rate'; percent?: number | null; min_acceptable_rate?: string; base_currency?: string; }; trialInDays?: number; trialEnd?: number; discounts?: DiscountInfo[]; } export interface RateInfo { exchangeRate: string | null; baseCurrency: string; providerName: string | null; providerId: string | null; timestampMs: number | null; fetchedAt: number | null; // When we fetched (for display) } export interface QuoteMeta { exchangeRate: string | null; baseCurrency: string; expiresAt: number | null; providerName: string | null; providerId: string | null; rateTimestampMs: number | null; slippagePercent: number | null; } /** * Extract quote metadata from line items */ function extractQuoteMeta(items: TLineItemExpanded[]): QuoteMeta | null { if (!items?.length) { return null; } let exchangeRate: string | null = null; let baseCurrency: string | null = null; let expiresAt: number | null = null; let providerName: string | null = null; let providerId: string | null = null; let rateTimestampMs: number | null = null; let slippagePercent: number | null = null; items.forEach((item) => { const price = item.upsell_price || item.price; const rate = (item as any)?.exchange_rate; if (!exchangeRate && rate) { exchangeRate = rate; } const base = (price as any)?.base_currency; if (!baseCurrency && base) { baseCurrency = base; } const expires = (item as any)?.expires_at; if (expires) { expiresAt = expiresAt === null ? expires : Math.min(expiresAt, expires); } const itemProviderName = (item as any)?.rate_provider_name; if (!providerName && itemProviderName) { providerName = itemProviderName; } const itemProviderId = (item as any)?.rate_provider_id; if (!providerId && itemProviderId) { providerId = itemProviderId; } const itemRateTimestamp = (item as any)?.rate_timestamp_ms; if (!rateTimestampMs && Number.isFinite(Number(itemRateTimestamp))) { rateTimestampMs = Number(itemRateTimestamp); } const itemSlippage = (item as any)?.slippage_percent; if (slippagePercent === null && Number.isFinite(Number(itemSlippage))) { slippagePercent = Number(itemSlippage); } }); return { exchangeRate, baseCurrency: baseCurrency || 'USD', expiresAt, providerName, providerId, rateTimestampMs, slippagePercent, }; } /** * Calculate token amount from USD base amount and exchange rate * @param items - Line items to calculate * @param rate - Exchange rate * @param currencyDecimal - Currency decimal places * @param trialing - Whether currently in trial period (skips recurring items if true) */ function calculateTokenAmount( items: TLineItemExpanded[], rate: string, currencyDecimal: number, trialing: boolean = false ): string | null { // Get USD base amount from items const usdTotal = items.reduce((acc, item) => { const price = item.upsell_price || item.price; // Skip recurring items when in trial period (they don't need payment during trial) if (trialing && price?.type === 'recurring') { return acc; } // Skip metered items (pay-as-you-go, no upfront payment) if (price?.type === 'recurring' && price?.recurring?.usage_type === 'metered') { return acc; } const baseAmount = (price as any)?.base_amount; if (baseAmount) { const quantity = item.quantity || 1; return acc + Number(baseAmount) * quantity; } return acc; }, 0); if (usdTotal <= 0) { return null; } const rateNum = Number(rate); if (rateNum <= 0) { return null; } // Calculate token amount: usdTotal / exchangeRate const tokenAmount = usdTotal / rateNum; const tokenAmountUnit = fromTokenToUnit(tokenAmount.toFixed(currencyDecimal || 8), currencyDecimal || 8); return tokenAmountUnit.toString(); } /** * Extract quote lock timestamp from various sources */ function extractQuoteLockedAt( paymentIntent?: TPaymentIntent | null, checkoutSession?: TCheckoutSession ): number | null { // @ts-ignore if (paymentIntent?.quote_locked_at) { // @ts-ignore const lockedAtMs = new Date(paymentIntent.quote_locked_at).getTime(); if (Number.isFinite(lockedAtMs)) { return Math.floor(lockedAtMs / 1000); } } const metaLockedAt = checkoutSession?.metadata?.quote_locked_at; if (typeof metaLockedAt === 'number') { return metaLockedAt; } if (typeof metaLockedAt === 'string') { const parsed = Number(metaLockedAt); if (Number.isFinite(parsed)) { return parsed; } } return null; } /** * Custom hook for dynamic pricing calculations */ export function useDynamicPricing(options: DynamicPricingOptions) { const { items, currency, liveRate, liveQuoteSnapshot, checkoutSession, paymentIntent, locale = 'en', isStripePayment = false, isSubscription = checkoutSession?.mode === 'subscription' || checkoutSession?.mode === 'setup', slippageConfig, trialInDays = 0, trialEnd = 0, discounts, } = options; // Check if currently in trial period const currentTime = Math.floor(Date.now() / 1000); const trialing = trialInDays > 0 || trialEnd > currentTime; // Check if any items have dynamic pricing const hasDynamicPricing = useMemo( () => items.some((item) => ((item.upsell_price || item.price) as any)?.pricing_type === 'dynamic'), [items] ); // Extract quote metadata from line items const quoteMeta = useMemo(() => (hasDynamicPricing ? extractQuoteMeta(items) : null), [items, hasDynamicPricing]); // Combine live rate with quote metadata const rateInfo = useMemo( () => ({ exchangeRate: isStripePayment ? null : liveRate?.rate || quoteMeta?.exchangeRate || null, baseCurrency: liveRate?.base_currency || quoteMeta?.baseCurrency || 'USD', providerName: isStripePayment ? null : liveRate?.provider_name || quoteMeta?.providerName || null, providerId: isStripePayment ? null : liveRate?.provider_id || quoteMeta?.providerId || null, timestampMs: isStripePayment ? null : liveRate?.timestamp_ms || quoteMeta?.rateTimestampMs || null, fetchedAt: isStripePayment ? null : liveRate?.fetched_at || null, // When we fetched the data }), [liveRate, quoteMeta, isStripePayment] ); // Calculate token amount from live rate (Final Freeze: no quote during preview) // When trialing, skip recurring items as they don't need payment during trial const calculatedTokenAmount = useMemo(() => { if (isStripePayment || !hasDynamicPricing || !liveRate?.rate) { return null; } return calculateTokenAmount(items, liveRate.rate, currency.decimal, trialing); }, [hasDynamicPricing, liveRate?.rate, items, currency.decimal, isStripePayment, trialing]); // Calculate discount amount dynamically when rate changes // For percent_off: subtotal * percent_off / 100 // For amount_off: min(amount_off, subtotal) - because discount can't exceed subtotal const calculatedDiscountAmount = useMemo(() => { if (!discounts?.length) { return null; } const couponDetails = discounts[0]?.coupon_details; if (!couponDetails) { return null; } // Stripe payment: calculate discount from USD amounts if (isStripePayment) { // Calculate discountable subtotal in cents (USD smallest unit) const discountableSubtotalCents = items.reduce((sum, item) => { // Only include discountable items if (!(item as any).discountable) { return sum; } const price = item.upsell_price || item.price; // Skip recurring items during trial if (trialing && price?.type === 'recurring') { return sum; } // Skip metered items if (price?.type === 'recurring' && price?.recurring?.usage_type === 'metered') { return sum; } // For dynamic pricing, use base_amount (USD dollars), not unit_amount (token wei) const isDynamic = (price as any)?.pricing_type === 'dynamic'; const baseAmount = (price as any)?.base_amount; let amountCents: number; if (isDynamic && baseAmount !== undefined && baseAmount !== null) { // base_amount is in dollars, convert to cents amountCents = Number(baseAmount) * 100; } else { // For non-dynamic pricing, unit_amount is in cents const unitAmount = price?.unit_amount; if (!unitAmount) { return sum; } amountCents = Number(unitAmount); } const quantity = item.quantity || 1; return sum + amountCents * quantity; }, 0); if (discountableSubtotalCents <= 0) { return null; } // Percent off discount: subtotal * percent_off / 100 if (couponDetails.percent_off && couponDetails.percent_off > 0) { const discountCents = Math.round((discountableSubtotalCents * couponDetails.percent_off) / 100); return discountCents.toString(); } // Fixed amount discount: min(amount_off, subtotal) if (couponDetails.amount_off) { // For Stripe, use USD amount_off directly (should be in cents) const amountOffCents = Number(couponDetails.amount_off); if (Number.isFinite(amountOffCents) && amountOffCents > 0) { return Math.min(amountOffCents, discountableSubtotalCents).toString(); } } return null; } // Dynamic pricing: calculate discount from token amounts if (!hasDynamicPricing || !liveRate?.rate) { return null; } const rateNum = Number(liveRate.rate); if (rateNum <= 0) { return null; } // Calculate discountable subtotal in tokens (shared by both percent_off and amount_off) const discountableSubtotal = items.reduce((sum, item) => { // Only include discountable items if (!(item as any).discountable) { return sum; } const price = item.upsell_price || item.price; // Skip recurring items during trial if (trialing && price?.type === 'recurring') { return sum; } // Skip metered items if (price?.type === 'recurring' && price?.recurring?.usage_type === 'metered') { return sum; } const baseAmount = (price as any)?.base_amount; if (!baseAmount) { return sum; } const quantity = item.quantity || 1; const tokenAmount = (Number(baseAmount) * quantity) / rateNum; return sum + tokenAmount; }, 0); if (discountableSubtotal <= 0) { return null; } // Percent off discount: subtotal * percent_off / 100 if (couponDetails.percent_off && couponDetails.percent_off > 0) { const discountAmount = (discountableSubtotal * couponDetails.percent_off) / 100; const discountAmountUnit = fromTokenToUnit(discountAmount.toFixed(currency.decimal || 8), currency.decimal || 8); return discountAmountUnit.toString(); } // Fixed amount discount: min(amount_off, subtotal) // When subtotal changes due to rate change, discount may also change if (couponDetails.amount_off) { // Get amount_off for current currency const amountOff = couponDetails.currency_id === currency.id ? couponDetails.amount_off : couponDetails.currency_options?.[currency.id]?.amount_off; if (!amountOff) { return null; } // Convert subtotal to unit for comparison const subtotalUnit = fromTokenToUnit(discountableSubtotal.toFixed(currency.decimal || 8), currency.decimal || 8); const amountOffBN = new BN(amountOff); const subtotalBN = new BN(subtotalUnit); // Return min(amount_off, subtotal) return BN.min(amountOffBN, subtotalBN).toString(); } return null; }, [isStripePayment, hasDynamicPricing, liveRate?.rate, discounts, items, trialing, currency.decimal, currency.id]); // Format rate for display const rateDisplay = useMemo(() => { if (!rateInfo.exchangeRate) { return null; } const formattedRate = formatExchangeRate(rateInfo.exchangeRate); if (!formattedRate) { return null; } if (rateInfo.baseCurrency === 'USD') { return `$${formattedRate}`; } return `${formattedRate} ${rateInfo.baseCurrency}`; }, [rateInfo]); // Quote lock status const quoteLockedAt = useMemo( () => extractQuoteLockedAt(paymentIntent, checkoutSession), [paymentIntent, checkoutSession] ); const isPriceLocked = useMemo(() => { if (!quoteLockedAt) return false; const now = Math.floor(Date.now() / 1000); const lockRemaining = quoteLockedAt + 180 - now; return lockRemaining > 0; }, [quoteLockedAt]); const lockExpired = useMemo(() => { if (!quoteLockedAt) return false; const now = Math.floor(Date.now() / 1000); const lockRemaining = quoteLockedAt + 180 - now; return lockRemaining <= 0; }, [quoteLockedAt]); // Current slippage value const currentSlippagePercent = useMemo(() => { let slippageValue: number | null | undefined = quoteMeta?.slippagePercent; if (slippageValue === null || slippageValue === undefined) { slippageValue = slippageConfig?.percent ?? (checkoutSession as any)?.metadata?.slippage?.percent ?? (checkoutSession as any)?.slippage_percent; } if (slippageValue === null || slippageValue === undefined) { slippageValue = 0.5; // default } return slippageValue as number; }, [quoteMeta?.slippagePercent, slippageConfig?.percent, checkoutSession]); // Provider display string - use provider_display from backend for human-readable format const providerDisplay = useMemo(() => { const fallback = '—'; // Prefer provider_display from liveRate (e.g., "CoinGecko (2 sources)") if (liveRate?.provider_display) { return liveRate.provider_display; } // Fallback to provider name/id if (!rateInfo.providerName && !rateInfo.providerId) { return fallback; } return rateInfo.providerName || rateInfo.providerId || fallback; }, [liveRate?.provider_display, rateInfo.providerName, rateInfo.providerId]); // Format total amount for display const formatTotalDisplay = (totalAmountValue: string, fallback = '—') => { // Final Freeze: Use calculated token amount from live rate if (hasDynamicPricing && calculatedTokenAmount) { const displayAmount = fromUnitToToken(calculatedTokenAmount, currency.decimal); const numericValue = Number(displayAmount); if (Number.isFinite(numericValue)) { return formatDynamicPrice(displayAmount, true, 6); } } // Fallback to liveQuoteSnapshot if available (for backward compatibility) if (hasDynamicPricing && liveQuoteSnapshot?.quoted_amount) { const snapshotAmount = fromUnitToToken(liveQuoteSnapshot.quoted_amount, currency.decimal); const numericValue = Number(snapshotAmount); if (Number.isFinite(numericValue)) { return formatDynamicPrice(snapshotAmount, true, 6); } } if (totalAmountValue === null || totalAmountValue === undefined || totalAmountValue === '') { return fallback; } const numericValue = Number(totalAmountValue); if (!Number.isFinite(numericValue)) { return fallback; } return formatDynamicPrice(totalAmountValue, hasDynamicPricing, 6); }; // Calculate USD equivalent display const calculateUsdDisplay = (totalAmountValue: string) => { const fallback = '—'; if (!hasDynamicPricing || !totalAmountValue || totalAmountValue === fallback) { return null; } const numericValue = Number(totalAmountValue); if (!Number.isFinite(numericValue) || numericValue < 0) { return null; } // For zero amount (e.g., free trial), display $0 if (numericValue === 0) { return formatUsdAmount('0', locale); } const { exchangeRate } = rateInfo; if (!exchangeRate) { return null; } const totalAmountInUnits = fromTokenToUnit(totalAmountValue, currency.decimal); const totalUsd = getUsdAmountFromTokenUnits(new BN(totalAmountInUnits), currency.decimal, exchangeRate); if (!totalUsd) { return null; } return formatUsdAmount(totalUsd, locale); }; // Build quote detail rows for display const buildQuoteDetailRows = (t: (key: string) => string) => { if (!hasDynamicPricing) { return []; } const rows: Array<{ label: string; value: string | React.ReactNode; isSlippage?: boolean; tooltip?: string }> = []; const fallback = '—'; // Only show provider and updatedAt if we have rate info if (rateDisplay) { // Use fetched_at (when we fetched) instead of timestamp_ms (provider's data timestamp) // This shows users that we're actively fetching, even if provider data hasn't changed const displayTimestamp = rateInfo.fetchedAt || rateInfo.timestampMs; const updatedAt = displayTimestamp ? formatDateTime(displayTimestamp) : fallback; rows.push( { label: t('payment.checkout.quote.detailProvider'), value: providerDisplay }, { label: t('payment.checkout.quote.detailUpdatedAt'), value: updatedAt } ); } // Always show slippage for subscriptions with dynamic pricing (even metered without current rate) if (isSubscription) { let slippageDisplay = `${currentSlippagePercent}%`; if (slippageConfig?.mode === 'rate' && slippageConfig.min_acceptable_rate) { const formattedRate = formatExchangeRate(slippageConfig.min_acceptable_rate); const displayRate = formattedRate || slippageConfig.min_acceptable_rate; const displayCurrency = slippageConfig.base_currency || rateInfo.baseCurrency || 'USD'; const displayText = displayCurrency === 'USD' ? `$${displayRate}` : `${displayRate} ${displayCurrency}`; slippageDisplay = `${displayText}`; } rows.push({ label: t('payment.checkout.quote.detailSlippage'), value: slippageDisplay, isSlippage: true, tooltip: t('payment.checkout.quote.slippage.tooltip'), }); } return rows; }; return { // Flags hasDynamicPricing, isPriceLocked, lockExpired, // Data quoteMeta, rateInfo, quoteLockedAt, calculatedTokenAmount, calculatedDiscountAmount, currentSlippagePercent, // Display helpers rateDisplay, providerDisplay, formatTotalDisplay, calculateUsdDisplay, buildQuoteDetailRows, }; } export default useDynamicPricing;