diff --git a/.changeset/stupid-lemons-smash.md b/.changeset/stupid-lemons-smash.md new file mode 100644 index 00000000..37e4db7d --- /dev/null +++ b/.changeset/stupid-lemons-smash.md @@ -0,0 +1,5 @@ +--- +"@godaddy/react": patch +--- + +Refactor currency formatting to respect currency precision diff --git a/packages/react/src/components/checkout/form/checkout-form.tsx b/packages/react/src/components/checkout/form/checkout-form.tsx index dbb35dfa..80ef34e4 100644 --- a/packages/react/src/components/checkout/form/checkout-form.tsx +++ b/packages/react/src/components/checkout/form/checkout-form.tsx @@ -38,6 +38,7 @@ import { ShippingMethodForm } from '@/components/checkout/shipping/shipping-meth import { Target } from '@/components/checkout/target/target'; import { TipsForm } from '@/components/checkout/tips/tips-form'; import { DraftOrderTotals } from '@/components/checkout/totals/totals'; +import { formatCurrency } from '@/components/checkout/utils/format-currency'; import { Accordion, AccordionContent, @@ -105,22 +106,22 @@ export function CheckoutForm({ const { data: totals, isLoading: totalsLoading } = draftOrderTotalsQuery; const { data: order } = draftOrder; - // Order summary calculations - const subtotal = (totals?.subTotal?.value || 0) / 100; - const orderDiscount = (totals?.discountTotal?.value || 0) / 100; + // Order summary calculations - keep all values in minor units + const subtotal = totals?.subTotal?.value || 0; + const orderDiscount = totals?.discountTotal?.value || 0; const shipping = - (order?.shippingLines?.reduce( + order?.shippingLines?.reduce( (sum, line) => sum + (line?.amount?.value || 0), 0 - ) || 0) / 100; - const taxTotal = (totals?.taxTotal?.value || 0) / 100; - const orderTotal = (totals?.total?.value || 0) / 100; - const tipTotal = (tipAmount || 0) / 100; + ) || 0; + const taxTotal = totals?.taxTotal?.value || 0; + const orderTotal = totals?.total?.value || 0; + const tipTotal = tipAmount || 0; const currencyCode = totals?.total?.currencyCode || 'USD'; const itemCount = items.reduce((sum, item) => sum + (item?.quantity || 0), 0); const isFree = orderTotal <= 0; - const showExpressButtons = (totals?.subTotal?.value || 0) > 0; + const showExpressButtons = subtotal > 0; useEffect(() => { if (!totalsLoading && isFree) { @@ -136,8 +137,8 @@ export function CheckoutForm({ eventId: eventIds.checkoutStart, type: TrackingEventType.IMPRESSION, properties: { - subtotal: Math.round(subtotal * 100), - total: Math.round(orderTotal * 100), + subtotal: subtotal, + total: orderTotal, itemCount, currencyCode, }, @@ -244,7 +245,7 @@ export function CheckoutForm({ deliveryMethod: data.deliveryMethod, hasShippingAddress: !!data.shippingAddressLine1, hasBillingAddress: !!data.billingAddressLine1, - total: Math.round(orderTotal * 100), + total: orderTotal, }, }); }; @@ -435,10 +436,11 @@ export function CheckoutForm({ {t.totals.orderSummary} - {new Intl.NumberFormat('en-us', { - style: 'currency', - currency: currencyCode, - }).format(orderTotal)} + {formatCurrency({ + amount: totals?.total?.value || 0, + currencyCode, + isInCents: true, + })} @@ -447,9 +449,11 @@ export function CheckoutForm({ & { export interface DraftOrderLineItemsProps { items: Product[]; currencyCode?: string; + isInCents?: boolean; } export function DraftOrderLineItems({ items, currencyCode = 'USD', + isInCents = false, }: DraftOrderLineItemsProps) { const { t } = useGoDaddyContext(); @@ -129,10 +132,11 @@ export function DraftOrderLineItems({
- {new Intl.NumberFormat('en-us', { - style: 'currency', - currency: currencyCode, - }).format(item.originalPrice * item.quantity)} + {formatCurrency({ + amount: item.originalPrice * item.quantity, + currencyCode, + isInCents, + })}
diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx index 8ada5640..a973aaec 100644 --- a/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx +++ b/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx @@ -34,6 +34,7 @@ import { filterAndSortShippingMethods } from '@/components/checkout/shipping/uti import { useGetShippingMethodByAddress } from '@/components/checkout/shipping/utils/use-get-shipping-methods'; import { useGetTaxes } from '@/components/checkout/taxes/utils/use-get-taxes'; import { mapOrderToFormValues } from '@/components/checkout/utils/checkout-transformers'; +import { formatCurrency } from '@/components/checkout/utils/format-currency'; import { Skeleton } from '@/components/ui/skeleton'; import { useGoDaddyContext } from '@/godaddy-provider'; import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors'; @@ -58,13 +59,6 @@ export function ExpressCheckoutButton() { const [appliedCouponCode, setAppliedCouponCode] = useState( null ); - const [godaddyTotals, setGoDaddyTotals] = useState<{ - taxes: { currencyCode: string; value: number }; - shipping: { currencyCode: string; value: number }; - }>({ - taxes: { currencyCode: 'USD', value: 0 }, - shipping: { currencyCode: 'USD', value: 0 }, - }); const [shippingAddress, setShippingAddress] = useState
(null); const [shippingMethods, setShippingMethods] = useState(null); @@ -72,13 +66,21 @@ export function ExpressCheckoutButton() { const { poyntExpressRequest } = useBuildPaymentRequest(); const { data: totals } = useDraftOrderTotals(); const { data: draftOrder } = useDraftOrder(); + const currencyCode = totals?.total?.currencyCode || 'USD'; + + const [godaddyTotals, setGoDaddyTotals] = useState<{ + taxes: { currencyCode: string; value: number }; + shipping: { currencyCode: string; value: number }; + }>({ + taxes: { currencyCode: currencyCode, value: 0 }, + shipping: { currencyCode: currencyCode, value: 0 }, + }); const form = useFormContext(); const getShippingMethodsByAddress = useGetShippingMethodByAddress(); const getTaxes = useGetTaxes(); const getPriceAdjustments = useGetPriceAdjustments(); const updateTaxes = useUpdateTaxes(); - const currencyCode = totals?.total?.currencyCode || 'USD'; const countryCode = session?.shipping?.originAddress?.countryCode || 'US'; const confirmCheckout = useConfirmCheckout(); @@ -100,6 +102,7 @@ export function ExpressCheckoutButton() { type: 'SHIPPING' as const, subtotalPrice: { currencyCode: currency, + // Wallet APIs provide amounts in major units (e.g., "10.50"), convert to minor units for our API value: Number(amount) * 100 || 0, }, }, @@ -133,10 +136,11 @@ export function ExpressCheckoutButton() { }); const methods = sortedMethods?.map(method => { - const shippingMethodPrice = new Intl.NumberFormat('en-us', { - style: 'currency', - currency: method.cost?.currencyCode || 'USD', - }).format((method.cost?.value || 0) / 100); + const shippingMethodPrice = formatCurrency({ + amount: method.cost?.value || 0, + currencyCode: method.cost?.currencyCode || currencyCode, + isInCents: true, + }); return { id: method?.displayName?.replace(/\s+/g, '-')?.toLowerCase(), @@ -144,7 +148,13 @@ export function ExpressCheckoutButton() { detail: method.description ? `(${method.description}) ${shippingMethodPrice}` : `${shippingMethodPrice}`, - amount: ((method.cost?.value || 0) / 100).toString(), + amount: formatCurrency({ + amount: method.cost?.value || 0, + currencyCode: method.cost?.currencyCode || currencyCode, + isInCents: true, + returnRaw: true, + }), + amountInMinorUnits: method.cost?.value || 0, // Keep original minor unit value }; }); @@ -173,28 +183,43 @@ export function ExpressCheckoutButton() { // Always add the discount line item, using state variables directly updatedLineItems.push({ label: t.totals.discount, - amount: (-priceAdjustment / 100).toString(), + amount: formatCurrency({ + amount: -priceAdjustment, + currencyCode, + isInCents: true, + returnRaw: true, + }), isPending: false, }); - // Calculate the correct total amount that includes the discount - const totalAmount = updatedLineItems.reduce( - (acc, item) => acc + Number(item.amount), - 0 - ); + // Calculate the correct total in minor units + const totalInMinorUnits = + (totals?.subTotal?.value || 0) - priceAdjustment; + + const totalAmount = formatCurrency({ + amount: totalInMinorUnits, + currencyCode, + isInCents: true, + returnRaw: true, + }); expressRequest = { ...expressRequest, lineItems: updatedLineItems, total: { label: t.payment.orderTotal, - amount: totalAmount.toString(), + amount: totalAmount, isPending: false, }, couponCode: { code: appliedCouponCode, label: t.totals.discount, - amount: (-priceAdjustment / 100).toString(), + amount: formatCurrency({ + amount: -priceAdjustment, + currencyCode, + isInCents: true, + returnRaw: true, + }), }, }; } else { @@ -328,7 +353,12 @@ export function ExpressCheckoutButton() { couponConfig = { code: appliedCouponCode, label: t.totals.discount, - amount: (priceAdjustment / 100).toString(), + amount: formatCurrency({ + amount: priceAdjustment, + currencyCode, + isInCents: true, + returnRaw: true, + }), }; } @@ -431,7 +461,7 @@ export function ExpressCheckoutButton() { const convertAddressToShippingLines = useCallback( ( address: Address | null, - selectedMethod: { amount: string; name: string } + selectedMethod: { amountInMinorUnits: number; name: string } ) => { if (!address) return undefined; @@ -439,7 +469,7 @@ export function ExpressCheckoutButton() { { subTotal: { currencyCode: currencyCode, - value: Number(selectedMethod.amount) * 100, + value: selectedMethod.amountInMinorUnits, }, name: selectedMethod.name, }, @@ -471,8 +501,8 @@ export function ExpressCheckoutButton() { // Reset totals for a fresh calculation setGoDaddyTotals({ - taxes: { currencyCode: 'USD', value: 0 }, - shipping: { currencyCode: 'USD', value: 0 }, + taxes: { currencyCode: currencyCode, value: 0 }, + shipping: { currencyCode: currencyCode, value: 0 }, }); form.reset( @@ -520,27 +550,45 @@ export function ExpressCheckoutButton() { if (godaddyTotals.shipping.value > 0) { finalLineItems.push({ label: 'Shipping', - amount: (godaddyTotals.shipping.value / 100).toString(), + amount: formatCurrency({ + amount: godaddyTotals.shipping.value, + currencyCode, + isInCents: true, + returnRaw: true, + }), }); } if (godaddyTotals.taxes.value > 0) { finalLineItems.push({ label: t.totals.estimatedTaxes, - amount: (godaddyTotals.taxes.value / 100).toString(), + amount: formatCurrency({ + amount: godaddyTotals.taxes.value, + currencyCode, + isInCents: true, + returnRaw: true, + }), }); } - // Calculate the total amount - const totalAmount = finalLineItems - .reduce((acc, item) => acc + Number(item.amount), 0) - .toFixed(2); + // Calculate the total in minor units then format with proper currency precision + const totalInMinorUnits = + (totals?.subTotal?.value || 0) + + godaddyTotals.shipping.value + + godaddyTotals.taxes.value; + + const totalAmount = formatCurrency({ + amount: totalInMinorUnits, + currencyCode, + isInCents: true, + returnRaw: true, + }); updatedOrder = { lineItems: finalLineItems, total: { label: t.payment.orderTotal, - amount: totalAmount.toString(), + amount: totalAmount, }, couponCode: { code: '', @@ -555,7 +603,7 @@ export function ExpressCheckoutButton() { let shippingLines: ReturnType; if (shippingAddress && shippingMethod) { const selectedMethodInfo = { - amount: (godaddyTotals.shipping.value / 100).toString(), + amountInMinorUnits: godaddyTotals.shipping.value, name: shippingMethod, }; shippingLines = convertAddressToShippingLines( @@ -580,38 +628,67 @@ export function ExpressCheckoutButton() { if (godaddyTotals.shipping.value > 0) { finalLineItems.push({ label: t.totals.shipping, - amount: (godaddyTotals.shipping.value / 100).toString(), + amount: formatCurrency({ + amount: godaddyTotals.shipping.value, + currencyCode, + isInCents: true, + returnRaw: true, + }), }); } if (godaddyTotals.taxes.value > 0) { finalLineItems.push({ label: t.totals.estimatedTaxes, - amount: (godaddyTotals.taxes.value / 100).toString(), + amount: formatCurrency({ + amount: godaddyTotals.taxes.value, + currencyCode, + isInCents: true, + returnRaw: true, + }), }); } // Add the discount line item finalLineItems.push({ label: t.totals.discount, - amount: (-adjustment / 100).toString(), + amount: formatCurrency({ + amount: -adjustment, + currencyCode, + isInCents: true, + returnRaw: true, + }), }); - // Calculate the total amount - const totalAmount = finalLineItems - .reduce((acc, item) => acc + Number(item.amount), 0) - .toFixed(2); + // Calculate the total in minor units then format with proper currency precision + const totalInMinorUnits = + (totals?.subTotal?.value || 0) + + godaddyTotals.shipping.value + + godaddyTotals.taxes.value - + adjustment; + + const totalAmount = formatCurrency({ + amount: totalInMinorUnits, + currencyCode, + isInCents: true, + returnRaw: true, + }); updatedOrder = { lineItems: finalLineItems, total: { label: t.payment.orderTotal, - amount: totalAmount.toString(), + amount: totalAmount, }, couponCode: { code: couponCode, label: t.totals.discount, - amount: (-adjustment / 100).toString(), + amount: formatCurrency({ + amount: -adjustment, + currencyCode, + isInCents: true, + returnRaw: true, + }), }, }; } else { @@ -748,7 +825,7 @@ export function ExpressCheckoutButton() { subTotal: godaddyTotals.shipping, taxTotal: { value: 0, - currencyCode: 'USD', + currencyCode: currencyCode, }, }, }, @@ -833,6 +910,7 @@ export function ExpressCheckoutButton() { // Handle shipping method change if (e.shippingMethod && shippingAddress) { + // Wallet API provides shipping amount in major units (e.g., "10.50") const shippingAmount = e.shippingMethod?.amount; poyntLineItems.push({ @@ -843,7 +921,8 @@ export function ExpressCheckoutButton() { setGoDaddyTotals(value => ({ ...value, shipping: { - currencyCode: 'USD', + currencyCode: currencyCode, + // Convert wallet API amount from major to minor units for internal storage value: Number(shippingAmount) * 100 || 0, }, })); @@ -854,7 +933,8 @@ export function ExpressCheckoutButton() { const shippingLines = convertAddressToShippingLines( shippingAddress, { - amount: shippingAmount || '0', + // Convert wallet API amount from major to minor units for API request + amountInMinorUnits: Number(shippingAmount) * 100 || 0, name: e.shippingMethod?.label || t.totals.shipping, } ); @@ -897,12 +977,20 @@ export function ExpressCheckoutButton() { if (taxesResult?.value) { poyntLineItems.push({ label: t.totals.estimatedTaxes, - amount: (taxesResult.value / 100).toString(), + amount: formatCurrency({ + amount: taxesResult.value, + currencyCode, + isInCents: true, + returnRaw: true, + }), isPending: false, }); setGoDaddyTotals(value => ({ ...value, - taxes: { currencyCode: 'USD', value: taxesResult.value || 0 }, + taxes: { + currencyCode: currencyCode, + value: taxesResult.value || 0, + }, })); } } catch (_error) { @@ -925,7 +1013,12 @@ export function ExpressCheckoutButton() { if (priceAdjustment && appliedCouponCode) { poyntLineItems.push({ label: t.totals.discount, - amount: (-priceAdjustment / 100).toString(), + amount: formatCurrency({ + amount: -priceAdjustment, + currencyCode, + isInCents: true, + returnRaw: true, + }), isPending: false, }); } @@ -949,7 +1042,12 @@ export function ExpressCheckoutButton() { updatedOrder.couponCode = { code: appliedCouponCode, label: t.totals.discount, - amount: (-priceAdjustment / 100).toString(), + amount: formatCurrency({ + amount: -priceAdjustment, + currencyCode, + isInCents: true, + returnRaw: true, + }), }; } @@ -978,14 +1076,14 @@ export function ExpressCheckoutButton() { setGoDaddyTotals(value => ({ ...value, shipping: { - currencyCode: 'USD', - value: Number(methods?.[0]?.amount) * 100 || 0, + currencyCode: currencyCode, + value: methods?.[0]?.amountInMinorUnits || 0, }, })); poyntLineItems.push({ label: t.totals.shipping, - amount: (Number(methods?.[0]?.amount) || 0).toString(), + amount: methods?.[0]?.amount || '0', isPending: false, }); @@ -999,7 +1097,7 @@ export function ExpressCheckoutButton() { const shippingLines = convertAddressToShippingLines( e.shippingAddress, { - amount: methods[0]?.amount || '0', + amountInMinorUnits: methods[0]?.amountInMinorUnits || 0, name: methods[0]?.label || t.totals.shipping, } ); @@ -1040,7 +1138,7 @@ export function ExpressCheckoutButton() { setGoDaddyTotals(value => ({ ...value, shipping: { - currencyCode: 'USD', + currencyCode: currencyCode, value: 0, }, })); @@ -1060,13 +1158,18 @@ export function ExpressCheckoutButton() { if (taxesResult?.value) { poyntLineItems.push({ label: t.totals.estimatedTaxes, - amount: (taxesResult.value / 100).toString(), + amount: formatCurrency({ + amount: taxesResult.value, + currencyCode, + isInCents: true, + returnRaw: true, + }), isPending: false, }); setGoDaddyTotals(value => ({ ...value, taxes: { - currencyCode: 'USD', + currencyCode: currencyCode, value: taxesResult.value || 0, }, })); @@ -1094,7 +1197,12 @@ export function ExpressCheckoutButton() { if (priceAdjustment && appliedCouponCode) { poyntLineItems.push({ label: t.totals.discount, - amount: (-priceAdjustment / 100).toString(), + amount: formatCurrency({ + amount: -priceAdjustment, + currencyCode, + isInCents: true, + returnRaw: true, + }), isPending: false, }); } @@ -1119,7 +1227,12 @@ export function ExpressCheckoutButton() { updatedOrder.couponCode = { code: appliedCouponCode, label: appliedCouponCode || 'Discount', - amount: (-priceAdjustment / 100).toString(), + amount: formatCurrency({ + amount: -priceAdjustment, + currencyCode, + isInCents: true, + returnRaw: true, + }), }; } else { updatedOrder.couponCode = { diff --git a/packages/react/src/components/checkout/payment/payment-form.tsx b/packages/react/src/components/checkout/payment/payment-form.tsx index 18178852..7a29e5a8 100644 --- a/packages/react/src/components/checkout/payment/payment-form.tsx +++ b/packages/react/src/components/checkout/payment/payment-form.tsx @@ -34,6 +34,7 @@ import { DraftOrderTotals, type DraftOrderTotalsProps, } from '@/components/checkout/totals/totals'; +import { formatCurrency } from '@/components/checkout/utils/format-currency'; import { Accordion, AccordionContent, @@ -447,10 +448,11 @@ export function PaymentForm( {t.totals.orderSummary} - {new Intl.NumberFormat('en-us', { - style: 'currency', - currency: props.currencyCode, - }).format(props.total || 0)} + {formatCurrency({ + amount: props.total || 0, + currencyCode: props.currencyCode || 'USD', + isInCents: true, + })} @@ -459,6 +461,7 @@ export function PaymentForm( @@ -466,6 +469,7 @@ export function PaymentForm(
sum + (line?.amount?.value || 0), 0 - ) || 0) / 100; - const discount = (totals?.discountTotal?.value || 0) / 100; - const total = (totals?.total?.value || 0) / 100; + ) || 0; + const discountMinorUnits = totals?.discountTotal?.value || 0; + const totalMinorUnits = totals?.total?.value || 0; const countryCode = useMemo( () => session?.shipping?.originAddress?.countryCode || 'US', @@ -254,52 +255,62 @@ export function useBuildPaymentRequest(): { merchantCapabilities: ['supports3DS'], total: { label: 'Order Total', - amount: new Intl.NumberFormat('en-us', { - style: 'currency', - currency: currencyCode, - }).format(total), + amount: formatCurrency({ + amount: totals?.total?.value || 0, + currencyCode, + isInCents: true, + }), type: 'final', }, lineItems: [ ...(items || []).map(lineItem => ({ label: lineItem?.name || '', - amount: new Intl.NumberFormat('en-us', { - style: 'currency', - currency: currencyCode, - }).format((lineItem?.originalPrice || 0) * (lineItem?.quantity || 0)), + amount: formatCurrency({ + amount: (lineItem?.originalPrice || 0) * (lineItem?.quantity || 0), + currencyCode, + isInCents: true, + }), type: 'LINE_ITEM', status: 'FINAL', })), { label: 'Subtotal', - amount: new Intl.NumberFormat('en-us', { - style: 'currency', - currency: currencyCode, - }).format(subtotal), + amount: formatCurrency({ + amount: totals?.subTotal?.value || 0, + currencyCode, + isInCents: true, + }), type: 'final', }, { label: 'Tax', - amount: new Intl.NumberFormat('en-us', { - style: 'currency', - currency: currencyCode, - }).format(tax), + amount: formatCurrency({ + amount: totals?.taxTotal?.value || 0, + currencyCode, + isInCents: true, + }), type: 'final', }, { label: 'Shipping', - amount: new Intl.NumberFormat('en-us', { - style: 'currency', - currency: currencyCode, - }).format(shipping), + amount: formatCurrency({ + amount: + order?.shippingLines?.reduce( + (sum, line) => sum + (line?.amount?.value || 0), + 0 + ) || 0, + currencyCode, + isInCents: true, + }), type: 'final', }, { label: 'Discount', - amount: new Intl.NumberFormat('en-us', { - style: 'currency', - currency: currencyCode, - }).format(-1 * discount), + amount: formatCurrency({ + amount: -1 * (totals?.discountTotal?.value || 0), + currencyCode, + isInCents: true, + }), type: 'final', }, ].filter(item => Number.parseFloat(item.amount) !== 0), @@ -338,10 +349,11 @@ export function useBuildPaymentRequest(): { }, transactionInfo: { totalPriceStatus: 'FINAL', - totalPrice: new Intl.NumberFormat('en-us', { - style: 'currency', - currency: currencyCode, - }).format(total), + totalPrice: formatCurrency({ + amount: totals?.total?.value || 0, + currencyCode, + isInCents: true, + }), totalPriceLabel: 'Total', currencyCode, displayItems: [ @@ -353,25 +365,53 @@ export function useBuildPaymentRequest(): { })), { label: 'Subtotal', - price: subtotal, + price: Number.parseFloat( + formatCurrency({ + amount: subtotalMinorUnits, + currencyCode, + isInCents: true, + returnRaw: true, + }) + ), type: 'LINE_ITEM', status: 'FINAL', }, { label: 'Tax', - price: tax, + price: Number.parseFloat( + formatCurrency({ + amount: taxMinorUnits, + currencyCode, + isInCents: true, + returnRaw: true, + }) + ), type: 'LINE_ITEM', status: 'FINAL', }, { label: 'Shipping', - price: shipping, + price: Number.parseFloat( + formatCurrency({ + amount: shippingMinorUnits, + currencyCode, + isInCents: true, + returnRaw: true, + }) + ), type: 'LINE_ITEM', status: 'FINAL', }, { label: 'Discount', - price: -1 * discount, + price: Number.parseFloat( + formatCurrency({ + amount: -1 * discountMinorUnits, + currencyCode, + isInCents: true, + returnRaw: true, + }) + ), type: 'LINE_ITEM', status: 'FINAL', }, @@ -380,30 +420,59 @@ export function useBuildPaymentRequest(): { }; // Create PayPal request with proper breakdown validation - const calculatedTotal = subtotal + tax + shipping - discount; + const calculatedTotalMinorUnits = + subtotalMinorUnits + + taxMinorUnits + + shippingMinorUnits - + discountMinorUnits; const payPalRequest: PayPalRequest = { purchase_units: [ { amount: { currency_code: currencyCode, - value: calculatedTotal.toFixed(2), + value: formatCurrency({ + amount: calculatedTotalMinorUnits, + currencyCode, + isInCents: true, + returnRaw: true, + }), breakdown: { item_total: { currency_code: currencyCode, - value: subtotal.toFixed(2), + value: formatCurrency({ + amount: subtotalMinorUnits, + currencyCode, + isInCents: true, + returnRaw: true, + }), }, tax_total: { currency_code: currencyCode, - value: tax.toFixed(2), + value: formatCurrency({ + amount: taxMinorUnits, + currencyCode, + isInCents: true, + returnRaw: true, + }), }, shipping: { currency_code: currencyCode, - value: shipping.toFixed(2), + value: formatCurrency({ + amount: shippingMinorUnits, + currencyCode, + isInCents: true, + returnRaw: true, + }), }, discount: { currency_code: currencyCode, - value: discount.toFixed(2), + value: formatCurrency({ + amount: discountMinorUnits, + currencyCode, + isInCents: true, + returnRaw: true, + }), }, }, }, @@ -411,7 +480,12 @@ export function useBuildPaymentRequest(): { name: lineItem?.name || '', unit_amount: { currency_code: currencyCode, - value: (lineItem?.originalPrice || 0).toFixed(2), + value: formatCurrency({ + amount: lineItem?.originalPrice || 0, + currencyCode, + isInCents: true, + returnRaw: true, + }), }, quantity: (lineItem?.quantity || 1).toString(), })), @@ -500,15 +574,23 @@ export function useBuildPaymentRequest(): { const poyntExpressRequest: PoyntExpressRequest = { total: { label: 'Order Total', - amount: subtotal.toString(), + amount: formatCurrency({ + amount: subtotalMinorUnits, + currencyCode, + isInCents: true, + returnRaw: true, + }), }, lineItems: [ ...(items || []).map(lineItem => { return { label: lineItem?.name || '', - amount: ( - (lineItem?.originalPrice || 0) * (lineItem?.quantity || 1) - ).toString(), + amount: formatCurrency({ + amount: (lineItem?.originalPrice || 0) * (lineItem?.quantity || 1), + currencyCode, + isInCents: true, + returnRaw: true, + }), }; }), ], @@ -517,28 +599,51 @@ export function useBuildPaymentRequest(): { const poyntStandardRequest: PoyntStandardRequest = { total: { label: 'Order Total', - amount: total.toString(), + amount: formatCurrency({ + amount: totalMinorUnits, + currencyCode, + isInCents: true, + returnRaw: true, + }), }, lineItems: [ ...(items || []).map(lineItem => { return { label: lineItem?.name || '', - amount: ( - (lineItem?.originalPrice || 0) * (lineItem?.quantity || 1) - ).toString(), + amount: formatCurrency({ + amount: (lineItem?.originalPrice || 0) * (lineItem?.quantity || 1), + currencyCode, + isInCents: true, + returnRaw: true, + }), }; }), { label: 'Tax', - amount: tax.toFixed(2), + amount: formatCurrency({ + amount: taxMinorUnits, + currencyCode, + isInCents: true, + returnRaw: true, + }), }, { label: 'Shipping', - amount: shipping.toFixed(2), + amount: formatCurrency({ + amount: shippingMinorUnits, + currencyCode, + isInCents: true, + returnRaw: true, + }), }, { label: 'Discount', - amount: (-1 * discount).toFixed(2), + amount: formatCurrency({ + amount: -1 * discountMinorUnits, + currencyCode, + isInCents: true, + returnRaw: true, + }), }, ], }; diff --git a/packages/react/src/components/checkout/shipping/shipping-method.tsx b/packages/react/src/components/checkout/shipping/shipping-method.tsx index c9023d20..65e35a0a 100644 --- a/packages/react/src/components/checkout/shipping/shipping-method.tsx +++ b/packages/react/src/components/checkout/shipping/shipping-method.tsx @@ -14,6 +14,7 @@ import { ShippingMethodSkeleton } from '@/components/checkout/shipping/shipping- import { filterAndSortShippingMethods } from '@/components/checkout/shipping/utils/filter-shipping-methods'; import { useApplyShippingMethod } from '@/components/checkout/shipping/utils/use-apply-shipping-method'; import { useDraftOrderShippingMethods } from '@/components/checkout/shipping/utils/use-draft-order-shipping-methods'; +import { formatCurrency } from '@/components/checkout/utils/format-currency'; import { Label } from '@/components/ui/label'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { useGoDaddyContext } from '@/godaddy-provider'; @@ -259,10 +260,12 @@ export function ShippingMethodForm() { {t.general.free} ) : ( - {new Intl.NumberFormat('en-us', { - style: 'currency', - currency: shippingMethods[0]?.cost?.currencyCode || 'USD', - }).format((shippingMethods[0]?.cost?.value || 0) / 100)} + {formatCurrency({ + amount: shippingMethods[0]?.cost?.value || 0, + currencyCode: + shippingMethods[0]?.cost?.currencyCode || 'USD', + isInCents: true, + })} )}
@@ -301,10 +304,11 @@ export function ShippingMethodForm() { {t.general.free} ) : ( - {new Intl.NumberFormat('en-us', { - style: 'currency', - currency: method?.cost?.currencyCode || 'USD', - }).format((method?.cost?.value || 0) / 100)} + {formatCurrency({ + amount: method?.cost?.value || 0, + currencyCode: method?.cost?.currencyCode || 'USD', + isInCents: true, + })} )} diff --git a/packages/react/src/components/checkout/tips/tips-form.tsx b/packages/react/src/components/checkout/tips/tips-form.tsx index bfc0aeab..7451436d 100644 --- a/packages/react/src/components/checkout/tips/tips-form.tsx +++ b/packages/react/src/components/checkout/tips/tips-form.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { useFormContext } from 'react-hook-form'; import { useCheckoutContext } from '@/components/checkout/checkout'; +import { formatCurrency } from '@/components/checkout/utils/format-currency'; import { Button } from '@/components/ui/button'; import { FormControl, @@ -27,15 +28,8 @@ export function TipsForm({ total, currencyCode }: TipsFormProps) { const [showCustomTip, setShowCustomTip] = useState(false); const calculateTipAmount = (percentage: number): number => { - return Math.round(((total * percentage) / 100) * 100); - }; - - const formatCurrency = (amount: number): string => { - // Convert from cents to dollars before formatting - return new Intl.NumberFormat('en-US', { - style: 'currency', - currency: currencyCode || 'USD', - }).format(amount / 100); + // total is in minor units, so calculate percentage and return in minor units + return Math.round((total * percentage) / 100); }; const handlePercentageSelect = (percentage: number) => { @@ -116,7 +110,11 @@ export function TipsForm({ total, currencyCode }: TipsFormProps) { > {percentage}% - {formatCurrency(calculateTipAmount(percentage))} + {formatCurrency({ + amount: calculateTipAmount(percentage), + currencyCode: currencyCode || 'USD', + isInCents: true, + })} ))} @@ -168,19 +166,32 @@ export function TipsForm({ total, currencyCode }: TipsFormProps) { {...field} placeholder={t.tips.placeholder} className='h-12' - value={field.value > 0 ? field.value / 100 : ''} + value={ + field.value > 0 + ? formatCurrency({ + amount: field.value, + currencyCode: currencyCode || 'USD', + isInCents: true, + returnRaw: true, + }) + : '' + } onChange={e => { - // Convert dollars to cents when storing - const tipAmount = Math.round( - Number.parseFloat(e.target.value) * 100 - ); - field.onChange(tipAmount); - - // Only track when user stops typing (on blur) + // User inputs in major units (e.g., $10.50), convert to minor units for storage + const inputValue = Number.parseFloat(e.target.value); + if (!Number.isNaN(inputValue)) { + const tipAmount = Math.round(inputValue * 100); + field.onChange(tipAmount); + } else { + field.onChange(0); + } }} onBlur={e => { - const tipAmount = - Math.round(Number.parseFloat(e.target.value) * 100) || 0; + // User inputs in major units (e.g., $10.50), convert to minor units for storage + const inputValue = Number.parseFloat(e.target.value); + const tipAmount = !Number.isNaN(inputValue) + ? Math.round(inputValue * 100) + : 0; // Track custom tip amount entry track({ eventId: eventIds.enterCustomTip, diff --git a/packages/react/src/components/checkout/totals/totals.tsx b/packages/react/src/components/checkout/totals/totals.tsx index ca4c4751..3ae210b6 100644 --- a/packages/react/src/components/checkout/totals/totals.tsx +++ b/packages/react/src/components/checkout/totals/totals.tsx @@ -1,5 +1,6 @@ import { DiscountStandalone } from '@/components/checkout/discount/discount-standalone'; import { TotalLineItemSkeleton } from '@/components/checkout/totals/totals-skeleton'; +import { formatCurrency } from '@/components/checkout/utils/format-currency'; import { useGoDaddyContext } from '@/godaddy-provider'; export interface DraftOrderTotalsProps { @@ -18,6 +19,7 @@ export interface DraftOrderTotalsProps { enableTaxes?: boolean | null; enableDiscounts?: boolean | null; enableShipping?: boolean | null; + isInCents?: boolean; } function TotalLineItem({ @@ -25,11 +27,13 @@ function TotalLineItem({ description, value, currencyCode = 'USD', + isInCents = false, }: { title: string; description?: string; value: number; currencyCode?: string; + isInCents?: boolean; }) { return (
@@ -40,10 +44,11 @@ function TotalLineItem({ ) : null}
- {new Intl.NumberFormat('en-us', { - style: 'currency', - currency: currencyCode, - }).format(value)} + {formatCurrency({ + amount: value, + currencyCode, + isInCents, + })} ); @@ -64,6 +69,7 @@ export function DraftOrderTotals({ isShippingLoading = false, isDiscountLoading = false, enableShipping = true, + isInCents = false, }: DraftOrderTotalsProps) { const { t } = useGoDaddyContext(); const handleDiscountsChange = (discounts: string[]) => { @@ -82,6 +88,7 @@ export function DraftOrderTotals({ : t.totals.noItems } value={subtotal} + isInCents={isInCents} /> {discount > 0 ? ( isDiscountLoading ? ( @@ -91,6 +98,7 @@ export function DraftOrderTotals({ currencyCode={currencyCode} title={t.totals.discount} value={-discount || 0} + isInCents={isInCents} /> ) ) : null} @@ -102,6 +110,7 @@ export function DraftOrderTotals({ currencyCode={currencyCode} title={t.totals.shipping} value={shipping || 0} + isInCents={isInCents} /> ))} {tip ? ( @@ -109,6 +118,7 @@ export function DraftOrderTotals({ currencyCode={currencyCode} title={t.totals.tip} value={tip || 0} + isInCents={isInCents} /> ) : null} {enableTaxes && @@ -119,6 +129,7 @@ export function DraftOrderTotals({ currencyCode={currencyCode} title={t.totals.estimatedTaxes} value={taxes || 0} + isInCents={isInCents} /> ))} @@ -143,10 +154,11 @@ export function DraftOrderTotals({ {currencyCode}{' '} - {new Intl.NumberFormat('en-us', { - style: 'currency', - currency: currencyCode, - }).format(total)} + {formatCurrency({ + amount: total, + currencyCode, + isInCents, + })} diff --git a/packages/react/src/components/checkout/utils/checkout-transformers.ts b/packages/react/src/components/checkout/utils/checkout-transformers.ts index a296fac2..5382367c 100644 --- a/packages/react/src/components/checkout/utils/checkout-transformers.ts +++ b/packages/react/src/components/checkout/utils/checkout-transformers.ts @@ -160,15 +160,12 @@ export function mapSkusToItemsDisplay( image: orderItem.details?.productAssetUrl || skuDetails?.mediaUrls?.[0], quantity: orderItem.quantity || 0, originalPrice: - (orderItem.totals?.subTotal?.value ?? 0) / - (orderItem.quantity || 0) / - 100, + (orderItem.totals?.subTotal?.value ?? 0) / (orderItem.quantity || 0), price: - ((orderItem.totals?.subTotal?.value ?? 0) + - (orderItem.totals?.feeTotal?.value ?? 0) - - // (orderItem.totals?.taxTotal?.value ?? 0) - // do we need taxTotal here? - (orderItem.totals?.discountTotal?.value ?? 0)) / - 100, + (orderItem.totals?.subTotal?.value ?? 0) + + (orderItem.totals?.feeTotal?.value ?? 0) - + // (orderItem.totals?.taxTotal?.value ?? 0) - // do we need taxTotal here? + (orderItem.totals?.discountTotal?.value ?? 0), notes: orderItem.notes ?.filter( note => diff --git a/packages/react/src/components/checkout/utils/format-currency.ts b/packages/react/src/components/checkout/utils/format-currency.ts new file mode 100644 index 00000000..36fbf69d --- /dev/null +++ b/packages/react/src/components/checkout/utils/format-currency.ts @@ -0,0 +1,96 @@ +/** + * Currency configuration map with symbols and decimal precision. + */ +export const currencyConfigs: Record< + string, + { symbol: string; precision: number; pattern?: string } +> = { + AUD: { symbol: '$', precision: 2 }, + CAD: { symbol: '$', precision: 2 }, + HKD: { symbol: '$', precision: 2 }, + SGD: { symbol: '$', precision: 2 }, + NZD: { symbol: '$', precision: 2 }, + USD: { symbol: '$', precision: 2 }, + VND: { symbol: '₫', precision: 0 }, + EUR: { symbol: '€', precision: 2 }, + GBP: { symbol: '£', precision: 2 }, + ARS: { symbol: '$', precision: 2 }, + CLP: { symbol: '$', precision: 0 }, + COP: { symbol: '$', precision: 2 }, + PHP: { symbol: '₱', precision: 2 }, + MXN: { symbol: '$', precision: 2 }, + BRL: { symbol: 'R$', precision: 2 }, + INR: { symbol: '₹', precision: 2 }, + IDR: { symbol: 'Rp', precision: 2 }, + PEN: { symbol: 'S/', precision: 2 }, + AED: { symbol: 'د.إ', precision: 2, pattern: '#!' }, + ILS: { symbol: '₪', precision: 2 }, + TRY: { symbol: '₺', precision: 2 }, + ZAR: { symbol: 'R', precision: 2 }, + CNY: { symbol: '¥', precision: 2 }, + JPY: { symbol: '¥', precision: 0 }, + KRW: { symbol: '₩', precision: 0 }, + TWD: { symbol: 'NT$', precision: 0 }, + KWD: { symbol: 'د.ك', precision: 3 }, + BHD: { symbol: '.د.ب', precision: 3 }, + JOD: { symbol: 'د.ا', precision: 3 }, + OMR: { symbol: 'ر.ع.', precision: 3 }, +}; + +export interface FormatCurrencyOptions { + /** Numeric amount to format or convert */ + amount: number; + /** ISO 4217 currency code (e.g. 'USD', 'VND', 'CLP') */ + currencyCode: string; + /** Optional locale, defaults to 'en-US' */ + locale?: string; + /** + * Indicates whether the input is already in cents (minor units). + * - true → format to currency string (default) + * - false → convert to minor units and return as string + */ + isInCents?: boolean; + /** + * Return raw numeric value without currency symbol. + * - true → returns "10.00" instead of "$10.00" + * - false → returns full currency string (default) + */ + returnRaw?: boolean; +} + +/** + * Formats or converts a currency amount. + * + * - When `isInCents = true` (default): returns formatted string like "$123.45" + * - When `isInCents = false`: returns string representing minor units like "12345" + * - When `returnRaw = true`: returns numeric value without currency symbol like "123.45" + */ +export function formatCurrency({ + amount, + currencyCode, + locale = 'en-US', + isInCents = true, + returnRaw = false, +}: FormatCurrencyOptions): string { + const config = currencyConfigs[currencyCode] || {}; + + const { precision = 2 } = config; + + if (!isInCents) { + // Convert major units to minor units and return as string + return Math.round(amount * Math.pow(10, precision)).toString(); + } + + // Format value already in minor units + const value = amount / Math.pow(10, precision); + + const nfLocale = returnRaw ? 'en-US' : locale; + + return new Intl.NumberFormat(nfLocale, { + style: returnRaw ? 'decimal' : 'currency', + currency: returnRaw ? undefined : currencyCode, + minimumFractionDigits: precision, + maximumFractionDigits: precision, + useGrouping: returnRaw ? false : undefined, + }).format(value); +} diff --git a/packages/react/src/server.ts b/packages/react/src/server.ts index fc9ee3c9..92cee345 100644 --- a/packages/react/src/server.ts +++ b/packages/react/src/server.ts @@ -3,7 +3,7 @@ import * as GoDaddy from '@/lib/godaddy/godaddy'; import { CreateCheckoutSessionInputWithKebabCase } from '@/lib/godaddy/godaddy'; import { getEnvVar } from '@/lib/utils'; -import type { CheckoutSessionInput, CheckoutSessionOptions } from '@/types'; +import type { CheckoutSessionOptions } from '@/types'; let accessToken: string | undefined; let accessTokenExpiresAt: number | undefined; @@ -81,10 +81,9 @@ async function getAccessToken({ ); } - const result = (await response.json()) as { + return (await response.json()) as { access_token: string; scope: string; expires_in: number; }; - return result; }