@blocklet/payment-react
Version:
Reusable react components for payment kit v2
394 lines (393 loc) • 14 kB
JavaScript
import { useMemo } from "react";
import { BN, fromTokenToUnit, fromUnitToToken } from "@ocap/util";
import {
formatDateTime,
formatDynamicPrice,
formatExchangeRate,
formatUsdAmount,
getUsdAmountFromTokenUnits
} from "../libs/util.js";
function extractQuoteMeta(items) {
if (!items?.length) {
return null;
}
let exchangeRate = null;
let baseCurrency = null;
let expiresAt = null;
let providerName = null;
let providerId = null;
let rateTimestampMs = null;
let slippagePercent = null;
items.forEach((item) => {
const price = item.upsell_price || item.price;
const rate = item?.exchange_rate;
if (!exchangeRate && rate) {
exchangeRate = rate;
}
const base = price?.base_currency;
if (!baseCurrency && base) {
baseCurrency = base;
}
const expires = item?.expires_at;
if (expires) {
expiresAt = expiresAt === null ? expires : Math.min(expiresAt, expires);
}
const itemProviderName = item?.rate_provider_name;
if (!providerName && itemProviderName) {
providerName = itemProviderName;
}
const itemProviderId = item?.rate_provider_id;
if (!providerId && itemProviderId) {
providerId = itemProviderId;
}
const itemRateTimestamp = item?.rate_timestamp_ms;
if (!rateTimestampMs && Number.isFinite(Number(itemRateTimestamp))) {
rateTimestampMs = Number(itemRateTimestamp);
}
const itemSlippage = item?.slippage_percent;
if (slippagePercent === null && Number.isFinite(Number(itemSlippage))) {
slippagePercent = Number(itemSlippage);
}
});
return {
exchangeRate,
baseCurrency: baseCurrency || "USD",
expiresAt,
providerName,
providerId,
rateTimestampMs,
slippagePercent
};
}
function calculateTokenAmount(items, rate, currencyDecimal, trialing = false) {
const usdTotal = items.reduce((acc, item) => {
const price = item.upsell_price || item.price;
if (trialing && price?.type === "recurring") {
return acc;
}
if (price?.type === "recurring" && price?.recurring?.usage_type === "metered") {
return acc;
}
const baseAmount = price?.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;
}
const tokenAmount = usdTotal / rateNum;
const tokenAmountUnit = fromTokenToUnit(tokenAmount.toFixed(currencyDecimal || 8), currencyDecimal || 8);
return tokenAmountUnit.toString();
}
function extractQuoteLockedAt(paymentIntent, checkoutSession) {
if (paymentIntent?.quote_locked_at) {
const lockedAtMs = new Date(paymentIntent.quote_locked_at).getTime();
if (Number.isFinite(lockedAtMs)) {
return Math.floor(lockedAtMs / 1e3);
}
}
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;
}
export function useDynamicPricing(options) {
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;
const currentTime = Math.floor(Date.now() / 1e3);
const trialing = trialInDays > 0 || trialEnd > currentTime;
const hasDynamicPricing = useMemo(
() => items.some((item) => (item.upsell_price || item.price)?.pricing_type === "dynamic"),
[items]
);
const quoteMeta = useMemo(() => hasDynamicPricing ? extractQuoteMeta(items) : null, [items, hasDynamicPricing]);
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]
);
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]);
const calculatedDiscountAmount = useMemo(() => {
if (!discounts?.length) {
return null;
}
const couponDetails = discounts[0]?.coupon_details;
if (!couponDetails) {
return null;
}
if (isStripePayment) {
const discountableSubtotalCents = items.reduce((sum, item) => {
if (!item.discountable) {
return sum;
}
const price = item.upsell_price || item.price;
if (trialing && price?.type === "recurring") {
return sum;
}
if (price?.type === "recurring" && price?.recurring?.usage_type === "metered") {
return sum;
}
const isDynamic = price?.pricing_type === "dynamic";
const baseAmount = price?.base_amount;
let amountCents;
if (isDynamic && baseAmount !== void 0 && baseAmount !== null) {
amountCents = Number(baseAmount) * 100;
} else {
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;
}
if (couponDetails.percent_off && couponDetails.percent_off > 0) {
const discountCents = Math.round(discountableSubtotalCents * couponDetails.percent_off / 100);
return discountCents.toString();
}
if (couponDetails.amount_off) {
const amountOffCents = Number(couponDetails.amount_off);
if (Number.isFinite(amountOffCents) && amountOffCents > 0) {
return Math.min(amountOffCents, discountableSubtotalCents).toString();
}
}
return null;
}
if (!hasDynamicPricing || !liveRate?.rate) {
return null;
}
const rateNum = Number(liveRate.rate);
if (rateNum <= 0) {
return null;
}
const discountableSubtotal = items.reduce((sum, item) => {
if (!item.discountable) {
return sum;
}
const price = item.upsell_price || item.price;
if (trialing && price?.type === "recurring") {
return sum;
}
if (price?.type === "recurring" && price?.recurring?.usage_type === "metered") {
return sum;
}
const baseAmount = price?.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;
}
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();
}
if (couponDetails.amount_off) {
const amountOff = couponDetails.currency_id === currency.id ? couponDetails.amount_off : couponDetails.currency_options?.[currency.id]?.amount_off;
if (!amountOff) {
return null;
}
const subtotalUnit = fromTokenToUnit(discountableSubtotal.toFixed(currency.decimal || 8), currency.decimal || 8);
const amountOffBN = new BN(amountOff);
const subtotalBN = new BN(subtotalUnit);
return BN.min(amountOffBN, subtotalBN).toString();
}
return null;
}, [isStripePayment, hasDynamicPricing, liveRate?.rate, discounts, items, trialing, currency.decimal, currency.id]);
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]);
const quoteLockedAt = useMemo(
() => extractQuoteLockedAt(paymentIntent, checkoutSession),
[paymentIntent, checkoutSession]
);
const isPriceLocked = useMemo(() => {
if (!quoteLockedAt) return false;
const now = Math.floor(Date.now() / 1e3);
const lockRemaining = quoteLockedAt + 180 - now;
return lockRemaining > 0;
}, [quoteLockedAt]);
const lockExpired = useMemo(() => {
if (!quoteLockedAt) return false;
const now = Math.floor(Date.now() / 1e3);
const lockRemaining = quoteLockedAt + 180 - now;
return lockRemaining <= 0;
}, [quoteLockedAt]);
const currentSlippagePercent = useMemo(() => {
let slippageValue = quoteMeta?.slippagePercent;
if (slippageValue === null || slippageValue === void 0) {
slippageValue = slippageConfig?.percent ?? checkoutSession?.metadata?.slippage?.percent ?? checkoutSession?.slippage_percent;
}
if (slippageValue === null || slippageValue === void 0) {
slippageValue = 0.5;
}
return slippageValue;
}, [quoteMeta?.slippagePercent, slippageConfig?.percent, checkoutSession]);
const providerDisplay = useMemo(() => {
const fallback = "\u2014";
if (liveRate?.provider_display) {
return liveRate.provider_display;
}
if (!rateInfo.providerName && !rateInfo.providerId) {
return fallback;
}
return rateInfo.providerName || rateInfo.providerId || fallback;
}, [liveRate?.provider_display, rateInfo.providerName, rateInfo.providerId]);
const formatTotalDisplay = (totalAmountValue, fallback = "\u2014") => {
if (hasDynamicPricing && calculatedTokenAmount) {
const displayAmount = fromUnitToToken(calculatedTokenAmount, currency.decimal);
const numericValue2 = Number(displayAmount);
if (Number.isFinite(numericValue2)) {
return formatDynamicPrice(displayAmount, true, 6);
}
}
if (hasDynamicPricing && liveQuoteSnapshot?.quoted_amount) {
const snapshotAmount = fromUnitToToken(liveQuoteSnapshot.quoted_amount, currency.decimal);
const numericValue2 = Number(snapshotAmount);
if (Number.isFinite(numericValue2)) {
return formatDynamicPrice(snapshotAmount, true, 6);
}
}
if (totalAmountValue === null || totalAmountValue === void 0 || totalAmountValue === "") {
return fallback;
}
const numericValue = Number(totalAmountValue);
if (!Number.isFinite(numericValue)) {
return fallback;
}
return formatDynamicPrice(totalAmountValue, hasDynamicPricing, 6);
};
const calculateUsdDisplay = (totalAmountValue) => {
const fallback = "\u2014";
if (!hasDynamicPricing || !totalAmountValue || totalAmountValue === fallback) {
return null;
}
const numericValue = Number(totalAmountValue);
if (!Number.isFinite(numericValue) || numericValue < 0) {
return null;
}
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);
};
const buildQuoteDetailRows = (t) => {
if (!hasDynamicPricing) {
return [];
}
const rows = [];
const fallback = "\u2014";
if (rateDisplay) {
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 }
);
}
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;