@blocklet/payment-react
Version:
Reusable react components for payment kit v2
620 lines (550 loc) • 20.4 kB
text/typescript
/**
* 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<string, { amount_off?: string }>;
};
}
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<RateInfo>(
() => ({
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;