From 7d6ddecf46db0eb8c1c3e189c3da8697e63ad362 Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Wed, 19 Aug 2026 10:21:17 -0500 Subject: [PATCH 01/22] fix: adjust billing collection logic --- .../__tests__/checkout-billing.test.tsx | 125 +++++++++++++++++- .../checkout-digital-fulfillment.test.tsx | 18 +++ .../__tests__/checkout-validation.test.tsx | 48 +++++++ .../checkout/address/address-form.tsx | 1 - .../src/components/checkout/checkout.tsx | 15 ++- .../checkout/form/custom-form-provider.tsx | 18 ++- .../payment/utils/billing-collection.ts | 4 +- 7 files changed, 215 insertions(+), 14 deletions(-) diff --git a/packages/react/src/components/checkout/__tests__/checkout-billing.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-billing.test.tsx index 80ce4978..9e80c0ca 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-billing.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-billing.test.tsx @@ -1,4 +1,4 @@ -import { screen, within } from '@testing-library/react'; +import { screen, waitFor, within } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import * as godaddyApi from '@/lib/godaddy/godaddy'; import { @@ -89,6 +89,129 @@ describe('Checkout billing behavior', () => { }); }); + it('collects names only for paid offline purchase mode when tax collection is disabled', async () => { + renderCheckout({ + draftOrderOverrides: { + billing: { + firstName: 'Pay', + lastName: 'In Person', + address: buildBillingAddress({ addressLine1: '' }), + }, + lineItems: [{ fulfillmentMode: 'PURCHASE' }], + }, + sessionOverrides: { + enableShipping: false, + enableLocalPickup: false, + enableBillingAddressCollection: true, + enableTaxCollection: false, + paymentMethods: { + card: null as never, + offline: { + processor: 'offline', + checkoutTypes: ['standard'], + }, + }, + }, + }); + await waitForCheckoutReady(); + + await waitFor(() => { + expect( + document.querySelector('input[name="billingFirstName"]') + ).toBeInTheDocument(); + expect( + document.querySelector('input[name="billingLastName"]') + ).toBeInTheDocument(); + expect( + document.querySelector('input[name="billingAddressLine1"]') + ).not.toBeInTheDocument(); + expect( + document.querySelector('input[name="billingPostalCode"]') + ).not.toBeInTheDocument(); + }); + }); + + it('respects disabled billing address collection in purchase mode even when tax collection is enabled', async () => { + renderCheckout({ + draftOrderOverrides: { + billing: { + firstName: 'Names', + lastName: 'Only', + address: buildBillingAddress({ addressLine1: '' }), + }, + lineItems: [{ fulfillmentMode: 'PURCHASE' }], + }, + sessionOverrides: { + enableShipping: false, + enableLocalPickup: false, + enableBillingAddressCollection: false, + enableTaxCollection: true, + }, + }); + await waitForCheckoutReady(); + + await waitFor(() => { + expect( + document.querySelector('input[name="billingFirstName"]') + ).toBeInTheDocument(); + expect( + document.querySelector('input[name="billingLastName"]') + ).toBeInTheDocument(); + expect( + document.querySelector('input[name="billingAddressLine1"]') + ).not.toBeInTheDocument(); + expect( + document.querySelector('input[name="billingPostalCode"]') + ).not.toBeInTheDocument(); + }); + }); + + it('collects billing address for paid offline purchase mode when tax collection is enabled and uses it for taxes', async () => { + const { user } = renderCheckout({ + draftOrderOverrides: { + billing: { address: buildBillingAddress({ addressLine1: '' }) }, + lineItems: [{ fulfillmentMode: 'PURCHASE' }], + }, + sessionOverrides: { + enableShipping: false, + enableLocalPickup: false, + enableBillingAddressCollection: true, + enableTaxCollection: true, + paymentMethods: { + card: null as never, + offline: { + processor: 'offline', + checkoutTypes: ['standard'], + }, + }, + }, + }); + await waitForCheckoutReady(); + + expect( + document.querySelector('input[name="billingAddressLine1"]') + ).toBeInTheDocument(); + + await typeIntoNamedField(user, 'billingFirstName', 'Offline'); + await typeIntoNamedField(user, 'billingLastName', 'Buyer'); + await typeIntoNamedField(user, 'billingAddressLine1', '456 Tax Lane'); + await typeIntoNamedField(user, 'billingAdminArea2', 'Austin'); + await typeIntoNamedField(user, 'billingPostalCode', '78701'); + await advanceCheckoutDebounce(); + await waitForOperation('CalculateCheckoutSessionTaxes'); + + expect( + getOperations('CalculateCheckoutSessionTaxes').at(-1)?.input + ).toMatchObject({ + destination: expect.objectContaining({ + addressLine1: '456 Tax Lane', + adminArea2: 'Austin', + postalCode: '78701', + countryCode: 'US', + }), + }); + }); + it('copies explicit shipping patches to billing while same-as-shipping is checked, then stops after unchecked', async () => { const draftOrder = buildDraftOrder(); const session = buildCheckoutSession({ draftOrder }); diff --git a/packages/react/src/components/checkout/__tests__/checkout-digital-fulfillment.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-digital-fulfillment.test.tsx index b2bdac7a..970fa5cc 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-digital-fulfillment.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-digital-fulfillment.test.tsx @@ -221,6 +221,24 @@ describe('Digital fulfillment checkout', () => { expectBillingNamesOnlyWithPhone(); }); + it('respects disabled billing address collection for taxable digital-only orders', async () => { + renderCheckout({ + draftOrderOverrides: { + shipping: { address: null }, + lineItems: [buildDigitalLineItem()], + }, + sessionOverrides: { + enableBillingAddressCollection: false, + enableTaxCollection: true, + enableShipping: true, + enableLocalPickup: true, + }, + }); + await waitForCheckoutReady(); + + expectBillingNamesOnlyWithPhone(); + }); + it('shows billing names and phone for free digital-only orders when tax is disabled', async () => { renderCheckout({ draftOrderOverrides: { diff --git a/packages/react/src/components/checkout/__tests__/checkout-validation.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-validation.test.tsx index 9a6edc5a..e5c1031f 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-validation.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-validation.test.tsx @@ -65,6 +65,54 @@ describe('Checkout validation behaviors', () => { expect(getOperations('TokenizeJs.getNonce')).toHaveLength(0); }); + it('shows full billing address for pickup card and names-only billing for offline pickup with tax enabled', async () => { + const { user } = renderCheckout({ + draftOrderOverrides: { + lineItems: [{ fulfillmentMode: 'PICKUP' }], + }, + sessionOverrides: { + enableShipping: false, + enableLocalPickup: true, + enableBillingAddressCollection: true, + enablePhoneCollection: true, + enableTaxCollection: true, + paymentMethods: { + card: { + processor: PaymentProvider.STRIPE, + checkoutTypes: [CheckoutType.STANDARD], + }, + offline: { + processor: PaymentProvider.OFFLINE, + checkoutTypes: [CheckoutType.STANDARD], + }, + }, + }, + }); + await waitForCheckoutReady(); + + expect( + document.querySelector('input[name="billingAddressLine1"]') + ).toBeInTheDocument(); + + await user.click( + await screen.findByRole('button', { name: /offline payments/i }) + ); + + expect( + document.querySelector('input[name="billingFirstName"]') + ).toBeInTheDocument(); + expect( + document.querySelector('input[name="billingLastName"]') + ).toBeInTheDocument(); + expect(screen.getByPlaceholderText(/201.*555/)).toBeInTheDocument(); + expect( + document.querySelector('input[name="billingAddressLine1"]') + ).not.toBeInTheDocument(); + expect( + document.querySelector('input[name="billingPostalCode"]') + ).not.toBeInTheDocument(); + }); + it('shows billing names and phone for offline pickup even when billing address collection is enabled', async () => { const { user } = renderCheckout({ draftOrderOverrides: { diff --git a/packages/react/src/components/checkout/address/address-form.tsx b/packages/react/src/components/checkout/address/address-form.tsx index 3258b588..838d3d0f 100644 --- a/packages/react/src/components/checkout/address/address-form.tsx +++ b/packages/react/src/components/checkout/address/address-form.tsx @@ -62,7 +62,6 @@ type SectionKey = 'shipping' | 'billing'; interface AddressFormProps { sectionKey: SectionKey; - /** When true, only show first name and last name fields (used for free pickup orders) */ onlyNames?: boolean; } diff --git a/packages/react/src/components/checkout/checkout.tsx b/packages/react/src/components/checkout/checkout.tsx index 6bae0493..febf8a02 100644 --- a/packages/react/src/components/checkout/checkout.tsx +++ b/packages/react/src/components/checkout/checkout.tsx @@ -289,15 +289,16 @@ export function Checkout(props: CheckoutProps) { } } - // Billing address validation - only required if not using shipping address OR pickup - // BUT skip for free orders (paymentMethod === 'offline') const isOfflinePayment = data.paymentMethod === PaymentMethodType.OFFLINE; const isPickup = data.deliveryMethod === DeliveryMethods.PICKUP; const isShipping = data.deliveryMethod === DeliveryMethods.SHIP; const isDigital = data.deliveryMethod === DeliveryMethods.DIGITAL; - const isFreePickup = isOfflinePayment && isPickup; + const isPurchase = data.deliveryMethod === DeliveryMethods.PURCHASE; + const isOfflinePickup = isOfflinePayment && isPickup; const isDigitalTaxDisabledOffline = isDigital && isOfflinePayment && !session?.enableTaxCollection; + const isPurchaseTaxDisabledOffline = + isPurchase && isOfflinePayment && !session?.enableTaxCollection; // Billing is separate from shipping when there is no shipping address // to copy from. `mapOrderToFormValues` canonicalizes deliveryMethod // against session capabilities, so `!isShipping` already covers both @@ -308,8 +309,9 @@ export function Checkout(props: CheckoutProps) { const requireBillingNamesOnly = (!enableBillingAddressCollection && billingIsSeparateFromShipping) || - isFreePickup || - isDigitalTaxDisabledOffline; + isOfflinePickup || + isDigitalTaxDisabledOffline || + isPurchaseTaxDisabledOffline; if (requireBillingNamesOnly) { const nameFields = [ @@ -330,8 +332,9 @@ export function Checkout(props: CheckoutProps) { const requireBillingAddress = enableBillingAddressCollection && - !isFreePickup && + !isOfflinePickup && !isDigitalTaxDisabledOffline && + !isPurchaseTaxDisabledOffline && billingIsSeparateFromShipping; if (requireBillingAddress) { diff --git a/packages/react/src/components/checkout/form/custom-form-provider.tsx b/packages/react/src/components/checkout/form/custom-form-provider.tsx index 7dd7001c..dac53dc8 100644 --- a/packages/react/src/components/checkout/form/custom-form-provider.tsx +++ b/packages/react/src/components/checkout/form/custom-form-provider.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import type { FieldPath, UseFormReturn, UseFormTrigger } from 'react-hook-form'; import { FormProvider } from 'react-hook-form'; +import { useDraftOrderTotals } from '@/components/checkout/order/use-draft-order'; import { getBillingCollectionMode, hasInlineBillingForm, @@ -24,14 +25,17 @@ export function CustomFormProvider< // Use state to force re-render const [, setForceUpdate] = useState({}); const { customSchemaFields, session } = useCheckoutContext(); + const { data: totals } = useDraftOrderTotals(); const customSchemaFieldsRef = React.useRef(customSchemaFields); const sessionRef = React.useRef(session); + const totalsRef = React.useRef(totals); // Update the refs on every render useEffect(() => { methodsRef.current = methods; customSchemaFieldsRef.current = customSchemaFields; sessionRef.current = session; + totalsRef.current = totals; }); const enhancedMethods = useMemo(() => { @@ -65,16 +69,20 @@ export function CustomFormProvider< values.paymentUseShippingAddress as unknown as boolean; const isPickup = deliveryMethod === DeliveryMethods.PICKUP; const isShipping = deliveryMethod === DeliveryMethods.SHIP; - const isFreeOrder = paymentMethod === PaymentMethodType.OFFLINE; - const isFreePickup = isFreeOrder && isPickup; + const isOfflinePayment = paymentMethod === PaymentMethodType.OFFLINE; const currentSession = sessionRef.current; + const orderTotal = + totalsRef.current?.total?.value ?? + currentSession?.draftOrder?.totals?.total?.value; + const isFreeOrder = typeof orderTotal === 'number' && orderTotal <= 0; + const isOfflinePickup = isOfflinePayment && isPickup; let billingContext: | 'top-level' | 'inline-payment-form' | 'free-payment-form' = 'top-level'; if (hasInlineBillingForm(paymentMethod)) { billingContext = 'inline-payment-form'; - } else if (isFreeOrder) { + } else if (isFreeOrder && isOfflinePayment) { billingContext = 'free-payment-form'; } const billingMode = getBillingCollectionMode({ @@ -170,8 +178,8 @@ export function CustomFormProvider< const isSkippable = (fieldName: string) => !customFieldNames.has(fieldName); - /* For free pickup orders, only validate billingFirstName and billingLastName */ - if (isFreePickup) { + /* For offline pickup orders, only validate billingFirstName and billingLastName */ + if (isOfflinePickup) { fieldNames = fieldNames.filter( fieldName => !fieldName.startsWith('billing') || diff --git a/packages/react/src/components/checkout/payment/utils/billing-collection.ts b/packages/react/src/components/checkout/payment/utils/billing-collection.ts index c0cb83b7..bff1d836 100644 --- a/packages/react/src/components/checkout/payment/utils/billing-collection.ts +++ b/packages/react/src/components/checkout/payment/utils/billing-collection.ts @@ -44,6 +44,7 @@ export function getBillingCollectionMode({ const isDigital = deliveryMethod === DeliveryMethods.DIGITAL; const isPickup = deliveryMethod === DeliveryMethods.PICKUP; const isShipping = deliveryMethod === DeliveryMethods.SHIP; + const isPurchase = deliveryMethod === DeliveryMethods.PURCHASE; const isOffline = paymentMethod === PaymentMethodType.OFFLINE; const inlineBilling = hasInlineBillingForm(paymentMethod); const billingAddressEnabled = enableBillingAddressCollection !== false; @@ -59,7 +60,8 @@ export function getBillingCollectionMode({ return 'address'; } - if (isPickup && isOffline && !enableTaxCollection) return 'names'; + if (isPickup && isOffline) return 'names'; + if (isPurchase && isOffline && !enableTaxCollection) return 'names'; if (!billingIsSeparateFromShipping) return 'none'; return billingAddressEnabled ? 'address' : 'names'; } From 2caa59fc862eb2e5e5650d3ab396fba85989e8a9 Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Wed, 19 Aug 2026 10:24:35 -0500 Subject: [PATCH 02/22] add changeset --- .changeset/clever-clubs-crash.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/clever-clubs-crash.md diff --git a/.changeset/clever-clubs-crash.md b/.changeset/clever-clubs-crash.md new file mode 100644 index 00000000..78b203ed --- /dev/null +++ b/.changeset/clever-clubs-crash.md @@ -0,0 +1,5 @@ +--- +"@godaddy/react": patch +--- + +fix: adjust billing collection logic for offline payment orders From 4db2dc63b8c66a54ce7d239ce55bf7a2a9ce2b49 Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Wed, 19 Aug 2026 12:27:49 -0500 Subject: [PATCH 03/22] feat(react): add billing policy resolver Task: task-2 --- .../payment/utils/billing-collection.test.ts | 358 ++++++++++++++++++ .../payment/utils/billing-collection.ts | 200 ++++++++-- 2 files changed, 519 insertions(+), 39 deletions(-) create mode 100644 packages/react/src/components/checkout/payment/utils/billing-collection.test.ts diff --git a/packages/react/src/components/checkout/payment/utils/billing-collection.test.ts b/packages/react/src/components/checkout/payment/utils/billing-collection.test.ts new file mode 100644 index 00000000..a879b757 --- /dev/null +++ b/packages/react/src/components/checkout/payment/utils/billing-collection.test.ts @@ -0,0 +1,358 @@ +import { describe, expect, it } from 'vitest'; +import { DeliveryMethods } from '@/components/checkout/delivery/delivery-methods'; +import { PaymentMethodType } from '@/types'; +import { + type BillingCollectionContext, + type BillingCollectionMode, + type BillingPolicy, + type BillingPolicyInput, + getBillingCollectionMode, + getBillingPolicy, + getEffectiveBillingCollectionMode, + hasInlineBillingForm, +} from './billing-collection'; + +const deliveryMethods = [ + DeliveryMethods.SHIP, + DeliveryMethods.PICKUP, + DeliveryMethods.PURCHASE, + DeliveryMethods.DIGITAL, +]; +const paymentMethods = [ + PaymentMethodType.CREDIT_CARD, + PaymentMethodType.ACH, + PaymentMethodType.OFFLINE, + PaymentMethodType.PAYPAL, +]; +const flags = [true, false]; + +function everyPolicyCombination(callback: (input: BillingPolicyInput) => void) { + for (const deliveryMethod of deliveryMethods) { + for (const paymentMethod of paymentMethods) { + for (const isFreeOrder of flags) { + for (const paymentUseShippingAddress of flags) { + for (const enableShipping of flags) { + for (const enableShippingAddressCollection of flags) { + for (const enableBillingAddressCollection of flags) { + for (const enableTaxCollection of flags) { + callback({ + isFreeOrder, + paymentMethod, + deliveryMethod, + paymentUseShippingAddress, + enableShipping, + enableShippingAddressCollection, + enableBillingAddressCollection, + enableTaxCollection, + }); + } + } + } + } + } + } + } + } +} + +function getExpectedMode({ + isFreeOrder, + paymentMethod, + deliveryMethod, + paymentUseShippingAddress, + enableBillingAddressCollection, + enableTaxCollection, +}: BillingPolicyInput): BillingCollectionMode { + if ( + deliveryMethod === DeliveryMethods.SHIP && + paymentUseShippingAddress + ) { + return 'none'; + } + + const effectivePaymentMethod = isFreeOrder + ? PaymentMethodType.OFFLINE + : paymentMethod; + const isOffline = effectivePaymentMethod === PaymentMethodType.OFFLINE; + + if (isOffline) { + if (deliveryMethod === DeliveryMethods.PICKUP) return 'names'; + if ( + deliveryMethod === DeliveryMethods.PURCHASE || + deliveryMethod === DeliveryMethods.DIGITAL + ) { + if (!enableTaxCollection) return 'names'; + return enableBillingAddressCollection ? 'address' : 'names'; + } + } + + return enableBillingAddressCollection ? 'address' : 'names'; +} + +function getExpectedPolicy(input: BillingPolicyInput): BillingPolicy { + const mode = getExpectedMode(input); + const usesShippingAddress = Boolean( + input.deliveryMethod === DeliveryMethods.SHIP && + input.paymentUseShippingAddress + ); + + if (mode === 'none') { + return { mode, location: 'none', usesShippingAddress }; + } + + if (input.isFreeOrder) { + return { mode, location: 'free-payment-form', usesShippingAddress }; + } + + if (hasInlineBillingForm(input.paymentMethod)) { + return { mode, location: 'inline-payment-form', usesShippingAddress }; + } + + return { mode, location: 'top-level', usesShippingAddress }; +} + +describe('hasInlineBillingForm', () => { + it.each([ + [PaymentMethodType.CREDIT_CARD, true], + [PaymentMethodType.ACH, true], + [PaymentMethodType.OFFLINE, false], + [PaymentMethodType.PAYPAL, false], + [PaymentMethodType.APPLE_PAY, false], + ])('returns %s for %s', (paymentMethod, expected) => { + expect(hasInlineBillingForm(paymentMethod)).toBe(expected); + }); + + it('returns false for a missing payment method', () => { + expect(hasInlineBillingForm(null)).toBe(false); + expect(hasInlineBillingForm(undefined)).toBe(false); + }); +}); + +describe('getBillingPolicy', () => { + it('implements the authoritative matrix for every supported input combination', () => { + everyPolicyCombination(input => { + expect({ input, policy: getBillingPolicy(input) }).toEqual({ + input, + policy: getExpectedPolicy(input), + }); + }); + }); + + it('forces free orders through offline rules when a stale inline payment remains selected', () => { + for (const paymentMethod of [ + PaymentMethodType.CREDIT_CARD, + PaymentMethodType.ACH, + ]) { + const input: BillingPolicyInput = { + isFreeOrder: true, + paymentMethod, + deliveryMethod: DeliveryMethods.PICKUP, + paymentUseShippingAddress: false, + enableShipping: true, + enableShippingAddressCollection: true, + enableBillingAddressCollection: true, + enableTaxCollection: true, + }; + + expect(getBillingPolicy(input)).toEqual( + getBillingPolicy({ + ...input, + paymentMethod: PaymentMethodType.OFFLINE, + }) + ); + expect(getBillingPolicy(input)).toEqual({ + mode: 'names', + location: 'free-payment-form', + usesShippingAddress: false, + }); + } + }); + + it('uses the inline payment form for active paid card and ACH billing', () => { + for (const paymentMethod of [ + PaymentMethodType.CREDIT_CARD, + PaymentMethodType.ACH, + ]) { + for (const deliveryMethod of [ + DeliveryMethods.PICKUP, + DeliveryMethods.PURCHASE, + DeliveryMethods.DIGITAL, + DeliveryMethods.SHIP, + ]) { + const policy = getBillingPolicy({ + isFreeOrder: false, + paymentMethod, + deliveryMethod, + paymentUseShippingAddress: false, + enableShipping: true, + enableShippingAddressCollection: true, + enableBillingAddressCollection: true, + enableTaxCollection: false, + }); + + expect(policy.location).toBe('inline-payment-form'); + expect(policy.mode).toBe('address'); + } + } + }); + + it('uses the top-level payment form for active paid non-inline methods', () => { + for (const paymentMethod of [ + PaymentMethodType.OFFLINE, + PaymentMethodType.PAYPAL, + PaymentMethodType.APPLE_PAY, + ]) { + const policy = getBillingPolicy({ + isFreeOrder: false, + paymentMethod, + deliveryMethod: DeliveryMethods.PICKUP, + paymentUseShippingAddress: false, + enableShipping: true, + enableShippingAddressCollection: true, + enableBillingAddressCollection: true, + enableTaxCollection: true, + }); + + expect(policy.location).toBe('top-level'); + expect(policy.mode).not.toBe('none'); + } + }); + + it('uses the free payment form for active free-order billing', () => { + for (const deliveryMethod of [ + DeliveryMethods.PICKUP, + DeliveryMethods.PURCHASE, + DeliveryMethods.DIGITAL, + DeliveryMethods.SHIP, + ]) { + const policy = getBillingPolicy({ + isFreeOrder: true, + paymentMethod: PaymentMethodType.PAYPAL, + deliveryMethod, + paymentUseShippingAddress: false, + enableShipping: true, + enableShippingAddressCollection: true, + enableBillingAddressCollection: true, + enableTaxCollection: true, + }); + + expect(policy.location).toBe('free-payment-form'); + expect(policy.mode).not.toBe('none'); + } + }); + + it('returns no billing location whenever mode is none', () => { + everyPolicyCombination(input => { + const policy = getBillingPolicy(input); + + if (policy.mode === 'none') { + expect(policy.location).toBe('none'); + } + }); + }); + + it('does not collect separate billing when shipping is reused as billing', () => { + for (const isFreeOrder of flags) { + for (const paymentMethod of paymentMethods) { + const policy = getBillingPolicy({ + isFreeOrder, + paymentMethod, + deliveryMethod: DeliveryMethods.SHIP, + paymentUseShippingAddress: true, + enableShipping: true, + enableShippingAddressCollection: true, + enableBillingAddressCollection: true, + enableTaxCollection: true, + }); + + expect(policy).toEqual({ + mode: 'none', + location: 'none', + usesShippingAddress: true, + }); + } + } + }); + + it('never returns address mode when billing address collection is disabled', () => { + everyPolicyCombination(input => { + const policy = getBillingPolicy({ + ...input, + enableBillingAddressCollection: false, + }); + + expect(policy.mode).not.toBe('address'); + }); + }); +}); + +describe('compatibility wrappers', () => { + it('returns the policy mode only for the requested collection context', () => { + const input = { + deliveryMethod: DeliveryMethods.PICKUP, + paymentMethod: PaymentMethodType.CREDIT_CARD, + paymentUseShippingAddress: false, + enableShipping: true, + enableShippingAddressCollection: true, + enableBillingAddressCollection: true, + enableTaxCollection: true, + }; + + expect( + getBillingCollectionMode({ ...input, context: 'top-level' }) + ).toBe('none'); + expect( + getBillingCollectionMode({ ...input, context: 'inline-payment-form' }) + ).toBe('address'); + expect( + getBillingCollectionMode({ ...input, context: 'free-payment-form' }) + ).toBe('names'); + }); + + it('returns the effective policy mode', () => { + everyPolicyCombination(input => { + expect( + getEffectiveBillingCollectionMode({ + deliveryMethod: input.deliveryMethod, + paymentMethod: input.paymentMethod, + paymentUseShippingAddress: input.paymentUseShippingAddress, + enableShipping: input.enableShipping, + enableShippingAddressCollection: + input.enableShippingAddressCollection, + enableBillingAddressCollection: + input.enableBillingAddressCollection, + enableTaxCollection: input.enableTaxCollection, + isFreeOrder: input.isFreeOrder, + }) + ).toBe(getBillingPolicy(input).mode); + }); + }); + + it('collects billing in exactly one active location', () => { + everyPolicyCombination(input => { + const contexts: BillingCollectionContext[] = input.isFreeOrder + ? ['free-payment-form'] + : ['top-level', 'inline-payment-form']; + const activeContextCount = contexts + .map(context => + getBillingCollectionMode({ + context, + deliveryMethod: input.deliveryMethod, + paymentMethod: input.paymentMethod, + paymentUseShippingAddress: input.paymentUseShippingAddress, + enableShipping: input.enableShipping, + enableShippingAddressCollection: + input.enableShippingAddressCollection, + enableBillingAddressCollection: + input.enableBillingAddressCollection, + enableTaxCollection: input.enableTaxCollection, + }) + ) + .filter(mode => mode !== 'none').length; + + expect(activeContextCount).toBe( + getBillingPolicy(input).mode === 'none' ? 0 : 1 + ); + }); + }); +}); diff --git a/packages/react/src/components/checkout/payment/utils/billing-collection.ts b/packages/react/src/components/checkout/payment/utils/billing-collection.ts index bff1d836..db167588 100644 --- a/packages/react/src/components/checkout/payment/utils/billing-collection.ts +++ b/packages/react/src/components/checkout/payment/utils/billing-collection.ts @@ -5,16 +5,92 @@ import { DeliveryMethods } from '@/components/checkout/delivery/delivery-methods import { PaymentMethodType, type PaymentMethodValue } from '@/types'; export type BillingCollectionMode = 'none' | 'names' | 'address'; -export type BillingCollectionContext = + +export type BillingCollectionLocation = + | 'none' | 'top-level' | 'inline-payment-form' | 'free-payment-form'; +export type BillingCollectionContext = Exclude< + BillingCollectionLocation, + 'none' +>; + +export type BillingPolicyInput = { + isFreeOrder: boolean; + paymentMethod?: PaymentMethodValue | string | null; + deliveryMethod?: DeliveryMethods | string | null; + paymentUseShippingAddress: boolean; + enableShipping: boolean; + enableShippingAddressCollection: boolean; + enableBillingAddressCollection: boolean; + enableTaxCollection: boolean; +}; + +export type BillingPolicy = { + mode: BillingCollectionMode; + location: BillingCollectionLocation; + usesShippingAddress: boolean; +}; + const INLINE_BILLING_PAYMENT_METHODS: PaymentMethodValue[] = [ PaymentMethodType.CREDIT_CARD, PaymentMethodType.ACH, ]; +function getOfflineBillingMode({ + deliveryMethod, + paymentUseShippingAddress, + enableBillingAddressCollection, + enableTaxCollection, +}: Pick< + BillingPolicyInput, + | 'deliveryMethod' + | 'paymentUseShippingAddress' + | 'enableBillingAddressCollection' + | 'enableTaxCollection' +>): BillingCollectionMode { + if ( + deliveryMethod === DeliveryMethods.SHIP && + paymentUseShippingAddress + ) { + return 'none'; + } + + if (deliveryMethod === DeliveryMethods.PICKUP) return 'names'; + + if ( + deliveryMethod === DeliveryMethods.PURCHASE || + deliveryMethod === DeliveryMethods.DIGITAL + ) { + if (!enableTaxCollection) return 'names'; + return enableBillingAddressCollection ? 'address' : 'names'; + } + + return enableBillingAddressCollection ? 'address' : 'names'; +} + +function getPaidStandardBillingMode({ + deliveryMethod, + paymentUseShippingAddress, + enableBillingAddressCollection, +}: Pick< + BillingPolicyInput, + | 'deliveryMethod' + | 'paymentUseShippingAddress' + | 'enableBillingAddressCollection' +>): BillingCollectionMode { + if ( + deliveryMethod === DeliveryMethods.SHIP && + paymentUseShippingAddress + ) { + return 'none'; + } + + return enableBillingAddressCollection ? 'address' : 'names'; +} + export function hasInlineBillingForm( paymentMethod?: PaymentMethodValue | string | null ) { @@ -26,11 +102,57 @@ export function hasInlineBillingForm( ); } +export function getBillingPolicy({ + isFreeOrder, + paymentMethod, + deliveryMethod, + paymentUseShippingAddress, + enableBillingAddressCollection, + enableTaxCollection, +}: BillingPolicyInput): BillingPolicy { + const usesShippingAddress = Boolean( + deliveryMethod === DeliveryMethods.SHIP && paymentUseShippingAddress + ); + const effectivePaymentMethod = isFreeOrder + ? PaymentMethodType.OFFLINE + : paymentMethod; + const isOffline = effectivePaymentMethod === PaymentMethodType.OFFLINE; + const isInline = hasInlineBillingForm(effectivePaymentMethod); + const mode = isOffline + ? getOfflineBillingMode({ + deliveryMethod, + paymentUseShippingAddress, + enableBillingAddressCollection, + enableTaxCollection, + }) + : getPaidStandardBillingMode({ + deliveryMethod, + paymentUseShippingAddress, + enableBillingAddressCollection, + }); + + if (mode === 'none') { + return { mode, location: 'none', usesShippingAddress }; + } + + if (isFreeOrder) { + return { mode, location: 'free-payment-form', usesShippingAddress }; + } + + if (isInline) { + return { mode, location: 'inline-payment-form', usesShippingAddress }; + } + + return { mode, location: 'top-level', usesShippingAddress }; +} + export function getBillingCollectionMode({ context, deliveryMethod, paymentMethod, paymentUseShippingAddress = true, + enableShipping = true, + enableShippingAddressCollection = true, enableBillingAddressCollection = true, enableTaxCollection = false, }: { @@ -38,49 +160,47 @@ export function getBillingCollectionMode({ deliveryMethod?: DeliveryMethods | string | null; paymentMethod?: PaymentMethodValue | string | null; paymentUseShippingAddress?: boolean | null; + enableShipping?: boolean | null; + enableShippingAddressCollection?: boolean | null; enableBillingAddressCollection?: boolean | null; enableTaxCollection?: boolean | null; }): BillingCollectionMode { - const isDigital = deliveryMethod === DeliveryMethods.DIGITAL; - const isPickup = deliveryMethod === DeliveryMethods.PICKUP; - const isShipping = deliveryMethod === DeliveryMethods.SHIP; - const isPurchase = deliveryMethod === DeliveryMethods.PURCHASE; - const isOffline = paymentMethod === PaymentMethodType.OFFLINE; - const inlineBilling = hasInlineBillingForm(paymentMethod); - const billingAddressEnabled = enableBillingAddressCollection !== false; - const billingIsSeparateFromShipping = - !isShipping || !paymentUseShippingAddress; - - if (context === 'top-level') { - if (inlineBilling) return 'none'; - - if (isDigital) { - if (!billingAddressEnabled) return 'names'; - if (isOffline && !enableTaxCollection) return 'names'; - return 'address'; - } - - if (isPickup && isOffline) return 'names'; - if (isPurchase && isOffline && !enableTaxCollection) return 'names'; - if (!billingIsSeparateFromShipping) return 'none'; - return billingAddressEnabled ? 'address' : 'names'; - } - - if (context === 'inline-payment-form') { - if (!inlineBilling) return 'none'; - if (isDigital) return billingAddressEnabled ? 'address' : 'names'; - if (!billingIsSeparateFromShipping) return 'none'; - return billingAddressEnabled ? 'address' : 'names'; - } - - if (isDigital) { - if (!enableTaxCollection) return 'names'; - return billingAddressEnabled ? 'address' : 'names'; - } + const policy = getBillingPolicy({ + isFreeOrder: context === 'free-payment-form', + deliveryMethod, + paymentMethod, + paymentUseShippingAddress: paymentUseShippingAddress !== false, + enableShipping: enableShipping !== false, + enableShippingAddressCollection: enableShippingAddressCollection !== false, + enableBillingAddressCollection: enableBillingAddressCollection !== false, + enableTaxCollection: enableTaxCollection === true, + }); - if (isPickup) return 'names'; + return policy.location === context ? policy.mode : 'none'; +} - return 'none'; +export function getEffectiveBillingCollectionMode({ + isFreeOrder = false, + deliveryMethod, + paymentMethod, + paymentUseShippingAddress = true, + enableShipping = true, + enableShippingAddressCollection = true, + enableBillingAddressCollection = true, + enableTaxCollection = false, +}: Omit[0], 'context'> & { + isFreeOrder?: boolean; +}): BillingCollectionMode { + return getBillingPolicy({ + isFreeOrder, + deliveryMethod, + paymentMethod, + paymentUseShippingAddress: paymentUseShippingAddress !== false, + enableShipping: enableShipping !== false, + enableShippingAddressCollection: enableShippingAddressCollection !== false, + enableBillingAddressCollection: enableBillingAddressCollection !== false, + enableTaxCollection: enableTaxCollection === true, + }).mode; } export function useBillingCollectionMode({ @@ -96,6 +216,8 @@ export function useBillingCollectionMode({ deliveryMethod: form.watch('deliveryMethod'), paymentMethod: form.watch('paymentMethod'), paymentUseShippingAddress: form.watch('paymentUseShippingAddress'), + enableShipping: session?.enableShipping, + enableShippingAddressCollection: session?.enableShippingAddressCollection, enableBillingAddressCollection: session?.enableBillingAddressCollection, enableTaxCollection: session?.enableTaxCollection, }); From a5c113514e31da5d2ddb31109ba15dc995affc7b Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Wed, 19 Aug 2026 12:33:33 -0500 Subject: [PATCH 04/22] test(react): lock billing policy contracts Task: task-1 --- .../__tests__/checkout-billing.test.tsx | 111 +++++++++ .../checkout-draft-order-sync.test.tsx | 53 ++++ .../checkout-form-validation.test.tsx | 100 ++++++++ .../__tests__/checkout-free-order.test.tsx | 64 ++++- .../checkout-free-payment-form.test.tsx | 226 +++++++++++++++++- .../__tests__/checkout-shipping.test.tsx | 88 +++++++ .../__tests__/checkout-validation.test.tsx | 120 ++++++++++ 7 files changed, 756 insertions(+), 6 deletions(-) diff --git a/packages/react/src/components/checkout/__tests__/checkout-billing.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-billing.test.tsx index 9e80c0ca..b1949a41 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-billing.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-billing.test.tsx @@ -308,4 +308,115 @@ describe('Checkout billing behavior', () => { screen.getByLabelText(/use shipping address as billing/i) ).not.toBeChecked(); }); + + it('clears a collected billing address when switching to offline pickup hides it', async () => { + const draftOrder = buildDraftOrder({ + lineItems: [{ fulfillmentMode: 'PICKUP' }], + billing: { + firstName: 'Card', + lastName: 'Payer', + address: buildBillingAddress({ addressLine1: '500 Card St' }), + }, + }); + const { user } = renderCheckout({ + draftOrder, + session: buildCheckoutSession({ + draftOrder, + enableShipping: false, + enableLocalPickup: true, + enableTaxCollection: true, + paymentMethods: { + card: { processor: 'godaddy', checkoutTypes: ['standard'] } as never, + offline: { processor: 'offline', checkoutTypes: ['standard'] }, + }, + }), + }); + await waitForCheckoutReady(); + clearOperations(); + + // Offline pickup collects names only, so the address the card form had + // collected must not stay behind on the draft order where the customer can + // no longer see or correct it. + await user.click( + await screen.findByRole('button', { name: /offline payments/i }) + ); + await waitForOperation('UpdateCheckoutSessionDraftOrder'); + + expect(getLastUpdateInput()).toMatchObject({ + billing: { firstName: 'Card', lastName: 'Payer', address: null }, + }); + expect( + document.querySelector('input[name="billingAddressLine1"]') + ).not.toBeInTheDocument(); + }); + + it('clears a collected billing address when switching delivery to offline pickup hides it', async () => { + const draftOrder = buildDraftOrder({ + lineItems: [{ fulfillmentMode: 'SHIP' }], + billing: { + firstName: 'Jane', + lastName: 'Buyer', + address: buildBillingAddress({ addressLine1: '77 Separate Way' }), + }, + }); + const { user } = renderCheckout({ + draftOrder, + session: buildCheckoutSession({ + draftOrder, + enableShipping: true, + enableLocalPickup: true, + enableTaxCollection: true, + paymentMethods: { + card: null as never, + offline: { processor: 'offline', checkoutTypes: ['standard'] }, + }, + }), + }); + await waitForCheckoutReady(); + expect( + document.querySelector('input[name="billingAddressLine1"]') + ).toHaveValue('77 Separate Way'); + clearOperations(); + + await user.click(screen.getByRole('radio', { name: /local pickup/i })); + await waitForOperation('UpdateCheckoutSessionDraftOrder'); + + expect(getLastUpdateInput()).toMatchObject({ + billing: { firstName: 'Jane', lastName: 'Buyer', address: null }, + }); + }); + + it('keeps a merchant-provided billing address that offline pickup never asks about', async () => { + const draftOrder = buildDraftOrder({ + lineItems: [{ fulfillmentMode: 'PICKUP' }], + billing: { + firstName: 'Merchant', + lastName: 'Prefill', + address: buildBillingAddress({ addressLine1: '1 Prefilled Rd' }), + }, + }); + renderCheckout({ + draftOrder, + session: buildCheckoutSession({ + draftOrder, + enableShipping: false, + enableLocalPickup: true, + enableTaxCollection: true, + paymentMethods: { + card: null as never, + offline: { processor: 'offline', checkoutTypes: ['standard'] }, + }, + }), + }); + await waitForCheckoutReady(); + await advanceCheckoutDebounce(); + + // Only customer-driven changes clear the address; loading a checkout must + // never delete data the merchant put on the draft order. + expect( + getOperations('UpdateCheckoutSessionDraftOrder').filter(operation => + Object.hasOwn(operation.input as object, 'billing') + ) + ).toHaveLength(0); + }); }); diff --git a/packages/react/src/components/checkout/__tests__/checkout-draft-order-sync.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-draft-order-sync.test.tsx index 799a1054..3a7d56fe 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-draft-order-sync.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-draft-order-sync.test.tsx @@ -570,6 +570,59 @@ describe('Checkout draft-order field sync', () => { expect(getLastUpdateInput()).toMatchObject({ notes: null }); }); + it('does not sync a billing phone value rejected by checkoutFormSchema', async () => { + const { user } = renderCheckout({ + draftOrderOverrides: { + billing: { + firstName: 'Pat', + lastName: 'Pickup', + phone: '', + email: 'jane@example.com', + address: null, + }, + lineItems: [{ fulfillmentMode: DeliveryMethods.PICKUP }], + totals: { + subTotal: { value: 0, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + shippingTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + total: { value: 0, currencyCode: 'USD' }, + }, + }, + checkoutProps: { + checkoutFormSchema: { + billingPhone: z.string().min(12, 'full billing phone required'), + }, + }, + sessionOverrides: { + enableShipping: false, + enableLocalPickup: true, + enableTaxCollection: false, + enablePhoneCollection: true, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + const phone = screen.getByLabelText(/phone/i); + await user.clear(phone); + await user.type(phone, '123'); + await advanceCheckoutDebounce(); + await flushPromises(); + + expect(getOperations('UpdateCheckoutSessionDraftOrder')).toHaveLength(0); + + await user.clear(phone); + await user.type(phone, '+12015550123'); + await advanceCheckoutDebounce(); + await waitForOperation('UpdateCheckoutSessionDraftOrder'); + + expect(getLastUpdateInput()).toMatchObject({ + billing: { phone: '+12015550123' }, + }); + }); + it('does not clear order notes while a custom required notes field is empty', async () => { const { user } = renderCheckout({ draftOrderOverrides: { diff --git a/packages/react/src/components/checkout/__tests__/checkout-form-validation.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-form-validation.test.tsx index 455da15f..0d3b820c 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-form-validation.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-form-validation.test.tsx @@ -139,6 +139,17 @@ function BillingReuseProbe() { ); } +function ShippingPrefixedCustomFieldProbe() { + const form = useFormContext(); + + return ( +
+ + +
+ ); +} + describe('Checkout form validation', () => { it('requires only billing names for free pickup and does not require billing address fields', async () => { const draftOrder = makeFreePickupOrder(); @@ -392,6 +403,45 @@ describe('Checkout form validation', () => { expect(document.body).not.toHaveTextContent(customMessage); }); + it('does not enforce custom billing address rules when names-only billing hides the address', async () => { + const customMessage = 'Billing address is required'; + const draftOrder = makeFreePickupOrder({ + billing: { + firstName: 'Pat', + lastName: 'Pickup', + address: buildShippingAddress({ addressLine1: '' }), + }, + }); + const { user } = renderCheckout({ + draftOrder, + checkoutProps: { + checkoutFormSchema: { + billingAddressLine1: z.string().min(1, customMessage), + }, + }, + sessionOverrides: { + draftOrder, + paymentMethods: stripeOnlyPaymentMethods(), + enableShipping: false, + enableLocalPickup: true, + enableTaxCollection: false, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + expect( + document.querySelector('input[name="billingAddressLine1"]') + ).not.toBeInTheDocument(); + + await user.click(await clickSubmitButton(/complete your free order/i)); + + await waitFor(() => { + expect(getOperations('ConfirmCheckoutSession')).toHaveLength(1); + }); + expect(document.body).not.toHaveTextContent(customMessage); + }); + it('does not enforce custom shipping rules when pickup is selected', async () => { const customMessage = 'Shipping field is required'; const draftOrder = makeFreePickupOrder({ @@ -431,6 +481,56 @@ describe('Checkout form validation', () => { expect(document.body).not.toHaveTextContent(customMessage); }); + it('does not filter unrelated rendered custom fields that share a shipping prefix', async () => { + const customMessage = 'Shipping gift message is required'; + const draftOrder = makeFreePickupOrder({ + billing: { + firstName: 'Pat', + lastName: 'Pickup', + address: buildShippingAddress({ addressLine1: '' }), + }, + }); + const { user } = renderCheckout({ + draftOrder, + checkoutProps: { + checkoutFormSchema: { + shippingGiftMessage: z.preprocess( + value => value ?? '', + z.string().min(1, customMessage) + ), + }, + targets: { + 'checkout.form.payment.before': ShippingPrefixedCustomFieldProbe, + }, + }, + sessionOverrides: { + draftOrder, + paymentMethods: stripeOnlyPaymentMethods(), + enableShipping: true, + enableLocalPickup: true, + enableTaxCollection: false, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + expect(screen.getByLabelText(/shipping gift message/i)).toBeInTheDocument(); + + await user.click(await clickSubmitButton(/complete your free order/i)); + + await waitFor(() => { + expect(document.body).toHaveTextContent(customMessage); + }); + expect(getOperations('ConfirmCheckoutSession')).toHaveLength(0); + + await user.type(screen.getByLabelText(/shipping gift message/i), 'Gift wrap'); + await user.click(await clickSubmitButton(/complete your free order/i)); + + await waitFor(() => { + expect(getOperations('ConfirmCheckoutSession')).toHaveLength(1); + }); + }); + it('does not enforce custom billing address rules when shipping address is reused', async () => { const customMessage = 'Billing address line 2 is required'; const sharedAddress = buildShippingAddress({ addressLine2: '' }); diff --git a/packages/react/src/components/checkout/__tests__/checkout-free-order.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-free-order.test.tsx index ea014b76..0cbafd42 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-free-order.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-free-order.test.tsx @@ -1,6 +1,8 @@ import { screen, waitFor } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import { + advanceCheckoutDebounce, + buildBillingAddress, buildCheckoutSession, buildDraftOrder, buildShippingRates, @@ -87,7 +89,7 @@ async function applyCoupon( await user.click(apply as HTMLButtonElement); } -function buildPaidPurchaseDraftOrder() { +function buildPaidOrder(fulfillmentMode: 'PICKUP' | 'PURCHASE') { return buildDraftOrder({ totals: { subTotal: { value: 100, currencyCode: 'USD' }, @@ -100,7 +102,7 @@ function buildPaidPurchaseDraftOrder() { lineItems: [ { unitAmount: { value: 100, currencyCode: 'USD' }, - fulfillmentMode: 'PURCHASE', + fulfillmentMode, totals: { subTotal: { value: 100, currencyCode: 'USD' }, discountTotal: { value: 0, currencyCode: 'USD' }, @@ -112,6 +114,14 @@ function buildPaidPurchaseDraftOrder() { }); } +function buildPaidPurchaseDraftOrder() { + return buildPaidOrder('PURCHASE'); +} + +function buildPaidPickupDraftOrder() { + return buildPaidOrder('PICKUP'); +} + describe('Checkout free / offline orders', () => { it('renders a free pickup order with names-only billing and no paid payment form', async () => { const draftOrder = buildFreeDraftOrder({ @@ -327,6 +337,56 @@ describe('Checkout free / offline orders', () => { expect(getLastConfirmInput()).not.toHaveProperty('fulfillmentLocationId'); }); + it('clears a paid card pickup billing address exactly once when a coupon makes the order free', async () => { + const draftOrder = buildPaidPickupDraftOrder(); + draftOrder.billing = { + firstName: 'Card', + lastName: 'Pickup', + phone: '', + email: 'jane@example.com', + address: buildBillingAddress({ addressLine1: '500 Card St' }), + }; + const session = buildCheckoutSession({ + draftOrder, + enableShipping: false, + enableLocalPickup: true, + enableTaxCollection: true, + }); + + const { user } = renderCheckout({ session, draftOrder }); + await waitForCheckoutReady(); + expect( + document.querySelector('input[name="billingAddressLine1"]') + ).toBeInTheDocument(); + + clearOperations(); + await applyCoupon(user, 'free100'); + await waitForOperation('ApplyCheckoutSessionDiscount'); + await waitForOperation('UpdateCheckoutSessionDraftOrder'); + await advanceCheckoutDebounce(2500); + + await waitFor(() => { + expect( + screen.getByRole('button', { name: /complete your free order/i }) + ).toBeInTheDocument(); + expect( + document.querySelector('input[name="billingAddressLine1"]') + ).not.toBeInTheDocument(); + }); + + const nullAddressUpdates = getOperations( + 'UpdateCheckoutSessionDraftOrder' + ).filter( + operation => + (operation.input as { billing?: { address?: unknown } }).billing + ?.address === null + ); + expect(nullAddressUpdates).toHaveLength(1); + expect(nullAddressUpdates[0].input).toMatchObject({ + billing: { firstName: 'Card', lastName: 'Pickup', address: null }, + }); + }); + it('switches from paid payment methods to FreePaymentForm after a 100% coupon', async () => { const draftOrder = buildPaidPurchaseDraftOrder(); const session = buildCheckoutSession({ diff --git a/packages/react/src/components/checkout/__tests__/checkout-free-payment-form.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-free-payment-form.test.tsx index e418a910..11536409 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-free-payment-form.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-free-payment-form.test.tsx @@ -1,7 +1,9 @@ +import { enUs } from '@godaddy/localizations'; import { screen, waitFor } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import { advanceCheckoutDebounce, + buildBillingAddress, buildCheckoutSession, buildDraftOrder, buildShippingRates, @@ -41,6 +43,36 @@ function buildFreeDraftOrder( }); } +function buildFreeShippingDraftOrder( + overrides: Parameters[0] = {} +) { + return buildFreeDraftOrder({ + lineItems: [{ fulfillmentMode: 'SHIP' }], + shippingLines: [ + { + id: 'shipping-line-free', + requestedService: 'free-shipping', + requestedProvider: 'unknown', + name: 'Free', + amount: { value: 0, currencyCode: 'USD' }, + discounts: [], + }, + ], + ...overrides, + }); +} + +function freeShippingRates() { + return buildShippingRates([ + { + serviceCode: 'free-shipping', + displayName: 'Free', + description: 'Free', + cost: { value: 0, currencyCode: 'USD' }, + }, + ]); +} + async function submitFreeOrder( user: ReturnType ) { @@ -152,7 +184,7 @@ describe('Checkout FreePaymentForm integration', () => { expect(getLastConfirmInput()).not.toHaveProperty('fulfillmentLocationId'); }); - it('renders a free purchase order without collecting address fields', async () => { + it('collects billing names only for a free purchase order when tax collection is disabled', async () => { const draftOrder = buildFreeDraftOrder({ lineItems: [{ fulfillmentMode: 'PURCHASE' }], }); @@ -169,9 +201,12 @@ describe('Checkout FreePaymentForm integration', () => { expect( screen.getByRole('button', { name: /complete your free order/i }) ).toBeInTheDocument(); - // Current FreePaymentForm renders the submit button only for PURCHASE - // orders. PRD T-107/T-401 notes document that new billing collection - // fields are not rendered for this case. + // A free purchase order follows the same rules as a paid offline one: names + // are collected, and the address is skipped because no tax destination is + // needed. + expect( + document.querySelector('input[name="billingFirstName"]') + ).toBeInTheDocument(); expect( document.querySelector('input[name="billingAddressLine1"]') ).not.toBeInTheDocument(); @@ -179,4 +214,187 @@ describe('Checkout FreePaymentForm integration', () => { document.querySelector('input[name="shippingAddressLine1"]') ).not.toBeInTheDocument(); }); + + it('collects a billing address for a free purchase order when tax collection is enabled', async () => { + const draftOrder = buildFreeDraftOrder({ + lineItems: [{ fulfillmentMode: 'PURCHASE' }], + billing: { address: buildBillingAddress({ addressLine1: '' }) }, + }); + const session = buildCheckoutSession({ + draftOrder, + enableShipping: false, + enableLocalPickup: false, + enableBillingAddressCollection: true, + enableTaxCollection: true, + }); + + renderCheckout({ session, draftOrder }); + await waitForCheckoutReady(); + + expect( + document.querySelector('input[name="billingAddressLine1"]') + ).toBeInTheDocument(); + expect( + document.querySelector('input[name="billingPostalCode"]') + ).toBeInTheDocument(); + }); + + it('blocks a free purchase order confirm while a required billing field is empty', async () => { + const draftOrder = buildFreeDraftOrder({ + lineItems: [{ fulfillmentMode: 'PURCHASE' }], + billing: { firstName: '', lastName: '' }, + }); + const session = buildCheckoutSession({ + draftOrder, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + }); + + const { user } = renderCheckout({ session, draftOrder }); + await waitForCheckoutReady(); + clearOperations(); + + // The schema requires billing names here, so the fields must be rendered + // and validated instead of leaving the button silently inert. + await user.click( + await screen.findByRole('button', { name: /complete your free order/i }) + ); + + await waitFor(() => { + expect(document.body).toHaveTextContent(enUs.validation.enterFirstName); + }); + expect(getOperations('ConfirmCheckoutSession')).toHaveLength(0); + }); + + it('collects a billing address for a free shipping order whose billing differs from shipping', async () => { + const draftOrder = buildFreeDraftOrder({ + lineItems: [{ fulfillmentMode: 'SHIP' }], + billing: { + address: buildBillingAddress({ addressLine1: '99 Billing Blvd' }), + }, + }); + const session = buildCheckoutSession({ + draftOrder, + enableShipping: true, + enableLocalPickup: false, + enableBillingAddressCollection: true, + enableTaxCollection: false, + }); + + renderCheckout({ session, draftOrder }); + await waitForCheckoutReady(); + + // Billing is not a copy of shipping, so the free form has to keep showing + // the billing address the schema still requires. + expect( + document.querySelector('input[name="billingAddressLine1"]') + ).toHaveValue('99 Billing Blvd'); + }); + + it('lets a free shipping order reuse the shipping address for billing', async () => { + const draftOrder = buildFreeShippingDraftOrder({ + billing: { firstName: '', lastName: '', phone: '', address: null }, + }); + const session = buildCheckoutSession({ + draftOrder, + enableShipping: true, + enableLocalPickup: false, + enableTaxCollection: false, + }); + + const { user } = renderCheckout({ + session, + draftOrder, + apiOverrides: { shippingMethods: freeShippingRates() }, + }); + await waitForCheckoutReady(); + + // An order that arrives with only a shipping address starts out asking for + // a separate billing address, so the customer needs the same opt-out the + // paid form offers instead of being forced to retype the address. + const toggle = screen.getByLabelText(/use shipping address as billing/i); + expect(toggle).not.toBeChecked(); + expect( + document.querySelector('input[name="billingAddressLine1"]') + ).toBeInTheDocument(); + + await user.click(toggle); + + expect(toggle).toBeChecked(); + expect( + document.querySelector('input[name="billingAddressLine1"]') + ).not.toBeInTheDocument(); + await submitFreeOrder(user); + expect(getLastConfirmInput()).toMatchObject({ paymentType: 'offline' }); + }); + + it('lets a free shipping order opt into a separate billing address', async () => { + const draftOrder = buildFreeShippingDraftOrder(); + const session = buildCheckoutSession({ + draftOrder, + enableShipping: true, + enableLocalPickup: false, + enableTaxCollection: false, + }); + + const { user } = renderCheckout({ + session, + draftOrder, + apiOverrides: { shippingMethods: freeShippingRates() }, + }); + await waitForCheckoutReady(); + + const toggle = screen.getByLabelText(/use shipping address as billing/i); + expect(toggle).toBeChecked(); + expect( + document.querySelector('input[name="billingAddressLine1"]') + ).not.toBeInTheDocument(); + + await user.click(toggle); + + // Opting out reveals the billing address form and, because unchecking + // clears the copied address, the order cannot confirm until it is filled. + expect( + document.querySelector('input[name="billingAddressLine1"]') + ).toBeInTheDocument(); + clearOperations(); + await user.click( + await screen.findByRole('button', { name: /complete your free order/i }) + ); + await waitFor(() => { + expect(document.body).toHaveTextContent(enUs.validation.enterAddress); + }); + expect(getOperations('ConfirmCheckoutSession')).toHaveLength(0); + }); + + it('treats a missing order total as free in both the rendered form and validation', async () => { + const draftOrder = buildFreeDraftOrder({ + lineItems: [{ fulfillmentMode: 'PURCHASE' }], + totals: { total: null }, + }); + const session = buildCheckoutSession({ + draftOrder, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + }); + + const { user } = renderCheckout({ session, draftOrder }); + await waitForCheckoutReady(); + + expect( + screen.getByRole('button', { name: /complete your free order/i }) + ).toBeInTheDocument(); + + // Rendering and validation must agree on "free", otherwise the button + // validates fields that were never rendered and silently does nothing. + await submitFreeOrder(user); + + expect(getLastConfirmInput()).toMatchObject({ + paymentToken: '', + paymentType: 'offline', + paymentProvider: 'OFFLINE', + }); + }); }); diff --git a/packages/react/src/components/checkout/__tests__/checkout-shipping.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-shipping.test.tsx index 7ef65027..216d2dd1 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-shipping.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-shipping.test.tsx @@ -2,9 +2,11 @@ import { fireEvent, screen, waitFor } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys'; import * as godaddyApi from '@/lib/godaddy/godaddy'; +import { CheckoutType, PaymentProvider } from '@/types'; import { advanceCheckoutDebounce, buildDraftOrder, + buildShippingAddress, clearOperations, flushPromises, getOperations, @@ -363,6 +365,92 @@ describe('Checkout shipping behavior', () => { ).not.toBeInTheDocument(); }); + // The trigger filter also skips these fields, so this passes with or without + // the matching schema rule; it guards the end-to-end guarantee that a hidden + // shipping form can never block checkout, whichever layer regresses. + it('completes checkout when enableShippingAddressCollection hides missing shipping names', async () => { + const { user } = renderCheckout({ + draftOrderOverrides: { + // Address is intact so shipping rates still resolve; the names the + // hidden form would have collected are what the schema must not demand. + shipping: { firstName: '', lastName: '' }, + }, + sessionOverrides: { + enableShipping: true, + enableShippingAddressCollection: false, + enableLocalPickup: false, + enableTaxCollection: false, + paymentMethods: { + card: null as never, + offline: { + processor: PaymentProvider.OFFLINE, + checkoutTypes: [CheckoutType.STANDARD], + }, + }, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + // The address form is hidden, so the schema must not require fields the + // customer has no way to fill in. + await user.click( + await screen.findByRole('button', { name: /complete your order/i }) + ); + + await waitForOperation('ConfirmCheckoutSession'); + await advanceCheckoutDebounce(0); + }); + + it('collects neither shipping nor billing address UI when hidden shipping is reused as billing', async () => { + const sharedAddress = buildShippingAddress({ addressLine1: '1 Hidden Way' }); + const { user } = renderCheckout({ + draftOrderOverrides: { + shipping: { + firstName: '', + lastName: '', + phone: '', + address: sharedAddress, + }, + billing: { + firstName: '', + lastName: '', + phone: '', + address: sharedAddress, + }, + }, + sessionOverrides: { + enableShipping: true, + enableShippingAddressCollection: false, + enableLocalPickup: false, + enableTaxCollection: false, + paymentMethods: { + card: null as never, + offline: { + processor: PaymentProvider.OFFLINE, + checkoutTypes: [CheckoutType.STANDARD], + }, + }, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + expect(screen.getByLabelText(/use shipping address as billing/i)).toBeChecked(); + expect( + document.querySelector('input[name="shippingAddressLine1"]') + ).not.toBeInTheDocument(); + expect( + document.querySelector('input[name="billingAddressLine1"]') + ).not.toBeInTheDocument(); + + await user.click( + await screen.findByRole('button', { name: /complete your order/i }) + ); + + await waitForOperation('ConfirmCheckoutSession'); + }); + it('records a shipping-method fetch failure when rates are refetched', async () => { const { user } = renderCheckout(); await waitForCheckoutReady(); diff --git a/packages/react/src/components/checkout/__tests__/checkout-validation.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-validation.test.tsx index e5c1031f..0185888c 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-validation.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-validation.test.tsx @@ -6,6 +6,7 @@ import { GoDaddyProvider } from '@/godaddy-provider'; import { CheckoutType, PaymentProvider } from '@/types'; import { advanceCheckoutDebounce, + buildBillingAddress, buildCheckoutSession, buildDraftOrder, clearOperations, @@ -157,6 +158,125 @@ describe('Checkout validation behaviors', () => { ).not.toBeInTheDocument(); }); + it('blocks a paid offline purchase-mode confirm while the required billing address is empty', async () => { + const { user } = renderCheckout({ + draftOrderOverrides: { + billing: { + firstName: 'Pay', + lastName: 'In Person', + address: buildBillingAddress({ + addressLine1: '', + adminArea2: '', + postalCode: '', + }), + }, + lineItems: [{ fulfillmentMode: 'PURCHASE' }], + }, + sessionOverrides: { + enableShipping: false, + enableLocalPickup: false, + enableBillingAddressCollection: true, + enableTaxCollection: true, + enablePhoneCollection: false, + paymentMethods: { + card: null as never, + offline: { + processor: PaymentProvider.OFFLINE, + checkoutTypes: [CheckoutType.STANDARD], + }, + }, + }, + }); + await waitForCheckoutReady(); + + // A paid offline order renders PaymentForm, so the billing address it shows + // must also be validated — the free-order rules must not leak in here. + expect( + document.querySelector('input[name="billingAddressLine1"]') + ).toBeInTheDocument(); + clearOperations(); + + await user.click( + await screen.findByRole('button', { name: /complete your order/i }) + ); + + await waitFor(() => { + expect(document.body).toHaveTextContent(enUs.validation.enterAddress); + }); + expect(getOperations('ConfirmCheckoutSession')).toHaveLength(0); + + // Filling the address it asked for lets the same click through. + await typeIntoNamedField(user, 'billingAddressLine1', '789 Billing Rd'); + await typeIntoNamedField(user, 'billingAdminArea2', 'Atlanta'); + await typeIntoNamedField(user, 'billingPostalCode', '30301'); + await user.click( + screen.getByRole('button', { name: /complete your order/i }) + ); + + await waitForOperation('ConfirmCheckoutSession'); + await advanceCheckoutDebounce(0); + }); + + it('blocks a paid offline pickup confirm while the billing phone is invalid', async () => { + const { user } = renderCheckout({ + draftOrderOverrides: { + billing: { + firstName: 'Pay', + lastName: 'In Person', + phone: '', + }, + lineItems: [{ fulfillmentMode: 'PICKUP' }], + }, + sessionOverrides: { + enableShipping: false, + enableLocalPickup: true, + enableBillingAddressCollection: true, + enablePhoneCollection: true, + enableTaxCollection: true, + paymentMethods: { + card: null as never, + offline: { + processor: PaymentProvider.OFFLINE, + checkoutTypes: [CheckoutType.STANDARD], + }, + }, + }, + }); + await waitForCheckoutReady(); + + // Offline pickup collects names + phone only; the phone it renders still + // has to be validated before confirming. + const phone = ( + await screen.findAllByPlaceholderText(/201.*555/) + )[0] as HTMLInputElement; + await user.clear(phone); + await user.type(phone, '12'); + clearOperations(); + + await user.click( + await screen.findByRole('button', { name: /complete your order/i }) + ); + + await waitFor(() => { + expect(document.body).toHaveTextContent( + enUs.validation.enterValidBillingPhone + ); + }); + expect(getOperations('ConfirmCheckoutSession')).toHaveLength(0); + + const rerenderedPhone = ( + await screen.findAllByPlaceholderText(/201.*555/) + )[0] as HTMLInputElement; + await user.clear(rerenderedPhone); + await user.type(rerenderedPhone, '2015550123'); + await user.click( + screen.getByRole('button', { name: /complete your order/i }) + ); + + await waitForOperation('ConfirmCheckoutSession'); + await advanceCheckoutDebounce(0); + }); + it('shows billing names and phone when billing address collection is disabled but phone collection is enabled', async () => { renderCheckout({ draftOrderOverrides: { From 188cf872ff052c1f955f3976612931f719ad55ef Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Wed, 19 Aug 2026 12:39:01 -0500 Subject: [PATCH 05/22] feat(react): migrate billing renderers to policy Task: task-4 --- .../checkout/order/is-free-order.ts | 16 ++++++ .../checkout/payment/free-payment-form.tsx | 31 +++++++--- .../checkout/payment/payment-form.tsx | 24 ++++---- .../payment/payment-methods/ach/godaddy.tsx | 31 +++------- .../payment-methods/credit-card/container.tsx | 31 +++------- .../payment/utils/use-billing-policy.ts | 56 +++++++++++++++++++ 6 files changed, 123 insertions(+), 66 deletions(-) create mode 100644 packages/react/src/components/checkout/order/is-free-order.ts create mode 100644 packages/react/src/components/checkout/payment/utils/use-billing-policy.ts diff --git a/packages/react/src/components/checkout/order/is-free-order.ts b/packages/react/src/components/checkout/order/is-free-order.ts new file mode 100644 index 00000000..5db653c8 --- /dev/null +++ b/packages/react/src/components/checkout/order/is-free-order.ts @@ -0,0 +1,16 @@ +import { useDraftOrderTotals } from '@/components/checkout/order/use-draft-order'; +import type { Totals } from '@/types'; + +/** + * Single definition of "free order" so the rendered payment form, the trigger + * field filter, and the schema all agree. A missing total is treated as free to + * match the order summary, which renders an absent total as 0. + */ +export function isFreeOrderTotal(totals?: Totals | null): boolean { + return (totals?.total?.value ?? 0) <= 0; +} + +export function useIsFreeOrder(): boolean { + const { data: totals } = useDraftOrderTotals(); + return isFreeOrderTotal(totals); +} diff --git a/packages/react/src/components/checkout/payment/free-payment-form.tsx b/packages/react/src/components/checkout/payment/free-payment-form.tsx index 2e276800..9c13a052 100644 --- a/packages/react/src/components/checkout/payment/free-payment-form.tsx +++ b/packages/react/src/components/checkout/payment/free-payment-form.tsx @@ -3,7 +3,9 @@ import React from 'react'; import { useFormContext } from 'react-hook-form'; import { AddressForm } from '@/components/checkout/address/address-form'; import { useCheckoutContext } from '@/components/checkout/checkout'; -import { useBillingCollectionMode } from '@/components/checkout/payment/utils/billing-collection'; +import { DeliveryMethods } from '@/components/checkout/delivery/delivery-methods'; +import { PaymentAddressToggle } from '@/components/checkout/payment/utils/payment-address-toggle'; +import { useBillingPolicy } from '@/components/checkout/payment/utils/use-billing-policy'; import { PaymentProvider, useConfirmCheckout, @@ -17,14 +19,19 @@ import { PaymentMethodType } from '@/types'; export function FreePaymentForm() { const { t } = useGoDaddyContext(); - const { setCheckoutErrors, isConfirmingCheckout } = useCheckoutContext(); + const { session, setCheckoutErrors, isConfirmingCheckout } = + useCheckoutContext(); const isPaymentDisabled = useIsPaymentDisabled(); const form = useFormContext(); const confirmCheckout = useConfirmCheckout(); - const billingMode = useBillingCollectionMode({ - context: 'free-payment-form', - }); + const billingPolicy = useBillingPolicy(); + const isShipping = form.watch('deliveryMethod') === DeliveryMethods.SHIP; + /* Free orders honour `paymentUseShippingAddress` exactly like paid offline + * ones, so the customer needs the same control over it. Without the toggle, + * an order that starts with only a shipping address is stuck asking for a + * separate billing address with no way to say "same as shipping". */ + const showAddressToggle = isShipping && session?.enableShipping !== false; const handleSubmit = React.useCallback(async () => { const valid = await form.trigger(); @@ -70,10 +77,20 @@ export function FreePaymentForm() { ); - if (billingMode !== 'none') { + const shouldShowBilling = + billingPolicy.location === 'free-payment-form' && + billingPolicy.mode !== 'none'; + + if (showAddressToggle || shouldShowBilling) { return (
- + {showAddressToggle ? : null} + {shouldShowBilling ? ( + + ) : null} {submitButton}
); diff --git a/packages/react/src/components/checkout/payment/payment-form.tsx b/packages/react/src/components/checkout/payment/payment-form.tsx index dd85d3d6..2eaf395f 100644 --- a/packages/react/src/components/checkout/payment/payment-form.tsx +++ b/packages/react/src/components/checkout/payment/payment-form.tsx @@ -34,11 +34,8 @@ import { PaymentMethodRenderer, } from '@/components/checkout/payment/payment-method-renderer'; import type { TokenizeJs } from '@/components/checkout/payment/types'; -import { - hasInlineBillingForm, - useBillingCollectionMode, -} from '@/components/checkout/payment/utils/billing-collection'; import { getApplicationId } from '@/components/checkout/payment/utils/get-application-id'; +import { useBillingPolicy } from '@/components/checkout/payment/utils/use-billing-policy'; import { PaymentAddressToggle } from '@/components/checkout/payment/utils/payment-address-toggle'; import { useGetSelectedPaymentMethod } from '@/components/checkout/payment/utils/use-get-selected-payment-method'; import { useLoadPoyntCollect } from '@/components/checkout/payment/utils/use-load-poynt-collect'; @@ -103,8 +100,10 @@ export function PaymentForm( const deliveryMethod = form.watch('deliveryMethod'); const isPickup = deliveryMethod === DeliveryMethods.PICKUP; const isShipping = deliveryMethod === DeliveryMethods.SHIP; - const billingMode = useBillingCollectionMode({ context: 'top-level' }); - const isPaymentMethodWithInlineBilling = hasInlineBillingForm(paymentMethod); + const billingPolicy = useBillingPolicy(); + const selectedMethodUsesInlineBilling = + paymentMethod === PaymentMethodType.CREDIT_CARD || + paymentMethod === PaymentMethodType.ACH; const methodConfig = useGetSelectedPaymentMethod( paymentMethod as PaymentMethodValue ); @@ -294,11 +293,10 @@ export function PaymentForm( googlePaySupported, ]); - const shouldShowBillingNamesOnly = billingMode === 'names'; - const isBillingAddressRequired = billingMode !== 'none'; - + const shouldShowBilling = + billingPolicy.location === 'top-level' && billingPolicy.mode !== 'none'; const billingCopy = - shouldShowBillingNamesOnly && t.payment.billingInformation + billingPolicy.mode === 'names' && t.payment.billingInformation ? t.payment.billingInformation : t.payment.billingAddress; @@ -536,10 +534,10 @@ export function PaymentForm( {isShipping && session?.enableShipping && - !isPaymentMethodWithInlineBilling ? ( + !selectedMethodUsesInlineBilling ? ( ) : null} - {isBillingAddressRequired ? ( + {shouldShowBilling ? ( ) : null} diff --git a/packages/react/src/components/checkout/payment/payment-methods/ach/godaddy.tsx b/packages/react/src/components/checkout/payment/payment-methods/ach/godaddy.tsx index 53251a77..1e5fce3f 100644 --- a/packages/react/src/components/checkout/payment/payment-methods/ach/godaddy.tsx +++ b/packages/react/src/components/checkout/payment/payment-methods/ach/godaddy.tsx @@ -11,6 +11,7 @@ import type { } from '@/components/checkout/payment/types'; import { getApplicationId } from '@/components/checkout/payment/utils/get-application-id'; import { PaymentAddressToggle } from '@/components/checkout/payment/utils/payment-address-toggle'; +import { useBillingPolicy } from '@/components/checkout/payment/utils/use-billing-policy'; import { usePoyntACHCollect } from '@/components/checkout/payment/utils/poynt-ach-provider'; import { PaymentProvider, @@ -31,32 +32,16 @@ export function GoDaddyACHForm() { const form = useFormContext(); const paymentMethod = form.watch('paymentMethod'); - const useShippingAddress = form.watch('paymentUseShippingAddress'); const deliveryMethod = form.watch('deliveryMethod'); const isShipping = deliveryMethod === DeliveryMethods.SHIP; - - // Billing is separate from shipping when there is no shipping address to - // copy from. `mapOrderToFormValues` canonicalizes deliveryMethod against - // session capabilities, so `!isShipping` already covers: - // - session.enableShipping = false - // - line items have no SHIP fulfillment (PICKUP / PURCHASE / all-NONE) - // The remaining case is the user opting out of "use shipping for billing". - const billingIsSeparateFromShipping = !isShipping || !useShippingAddress; - - const billingAddressEnabled = - session?.enableBillingAddressCollection !== false; - const shouldShowBillingNamesOnly = - paymentMethod === PaymentMethodType.ACH && - !billingAddressEnabled && - billingIsSeparateFromShipping; - - const isBillingAddressRequired = + const billingPolicy = useBillingPolicy(); + const shouldShowBilling = + billingPolicy.location === 'inline-payment-form' && paymentMethod === PaymentMethodType.ACH && - billingIsSeparateFromShipping && - (shouldShowBillingNamesOnly || billingAddressEnabled); + billingPolicy.mode !== 'none'; const billingCopy = - shouldShowBillingNamesOnly && t.payment.billingInformation + billingPolicy.mode === 'names' && t.payment.billingInformation ? t.payment.billingInformation : t.payment.billingAddress; @@ -268,7 +253,7 @@ export function GoDaddyACHForm() { paymentMethod === PaymentMethodType.ACH ? ( ) : null} - {isBillingAddressRequired ? ( + {shouldShowBilling ? ( ) : null} diff --git a/packages/react/src/components/checkout/payment/payment-methods/credit-card/container.tsx b/packages/react/src/components/checkout/payment/payment-methods/credit-card/container.tsx index b6563254..d9c596ff 100644 --- a/packages/react/src/components/checkout/payment/payment-methods/credit-card/container.tsx +++ b/packages/react/src/components/checkout/payment/payment-methods/credit-card/container.tsx @@ -7,6 +7,7 @@ import { CheckoutSection } from '@/components/checkout/checkout-section'; import { CheckoutSectionHeader } from '@/components/checkout/checkout-section-header'; import { DeliveryMethods } from '@/components/checkout/delivery/delivery-methods'; import { PaymentAddressToggle } from '@/components/checkout/payment/utils/payment-address-toggle'; +import { useBillingPolicy } from '@/components/checkout/payment/utils/use-billing-policy'; import { useGoDaddyContext } from '@/godaddy-provider'; import { PaymentMethodType } from '@/types'; @@ -15,32 +16,16 @@ export function CreditCardContainer({ children }: { children?: ReactNode }) { const form = useFormContext(); const { t } = useGoDaddyContext(); const paymentMethod = form.watch('paymentMethod'); - const useShippingAddress = form.watch('paymentUseShippingAddress'); const deliveryMethod = form.watch('deliveryMethod'); const isShipping = deliveryMethod === DeliveryMethods.SHIP; - - // Billing is separate from shipping when there is no shipping address to - // copy from. `mapOrderToFormValues` canonicalizes deliveryMethod against - // session capabilities, so `!isShipping` already covers: - // - session.enableShipping = false - // - line items have no SHIP fulfillment (PICKUP / PURCHASE / all-NONE) - // The remaining case is the user opting out of "use shipping for billing". - const billingIsSeparateFromShipping = !isShipping || !useShippingAddress; - - const billingAddressEnabled = - session?.enableBillingAddressCollection !== false; - const shouldShowBillingNamesOnly = - paymentMethod === PaymentMethodType.CREDIT_CARD && - !billingAddressEnabled && - billingIsSeparateFromShipping; - - const isBillingAddressRequired = + const billingPolicy = useBillingPolicy(); + const shouldShowBilling = + billingPolicy.location === 'inline-payment-form' && paymentMethod === PaymentMethodType.CREDIT_CARD && - billingIsSeparateFromShipping && - (shouldShowBillingNamesOnly || billingAddressEnabled); + billingPolicy.mode !== 'none'; const billingCopy = - shouldShowBillingNamesOnly && t.payment.billingInformation + billingPolicy.mode === 'names' && t.payment.billingInformation ? t.payment.billingInformation : t.payment.billingAddress; @@ -64,7 +49,7 @@ export function CreditCardContainer({ children }: { children?: ReactNode }) { paymentMethod === PaymentMethodType.CREDIT_CARD && ( )} - {isBillingAddressRequired ? ( + {shouldShowBilling ? ( ) : null} diff --git a/packages/react/src/components/checkout/payment/utils/use-billing-policy.ts b/packages/react/src/components/checkout/payment/utils/use-billing-policy.ts new file mode 100644 index 00000000..1fb3a660 --- /dev/null +++ b/packages/react/src/components/checkout/payment/utils/use-billing-policy.ts @@ -0,0 +1,56 @@ +import { useMemo } from 'react'; +import { useFormContext } from 'react-hook-form'; +import type { CheckoutFormData } from '@/components/checkout/checkout'; +import { useCheckoutContext } from '@/components/checkout/checkout'; +import { isFreeOrderTotal } from '@/components/checkout/order/is-free-order'; +import { useDraftOrderTotals } from '@/components/checkout/order/use-draft-order'; +import { + type BillingPolicy, + getBillingPolicy, +} from '@/components/checkout/payment/utils/billing-collection'; +import type { CheckoutSession, Totals } from '@/types'; + +export function resolveBillingPolicyForCheckoutState(input: { + values: Pick< + CheckoutFormData, + 'paymentMethod' | 'deliveryMethod' | 'paymentUseShippingAddress' + >; + session?: CheckoutSession | null; + totals?: Totals | null; +}): BillingPolicy { + return getBillingPolicy({ + isFreeOrder: isFreeOrderTotal(input.totals), + deliveryMethod: input.values.deliveryMethod, + paymentMethod: input.values.paymentMethod, + paymentUseShippingAddress: input.values.paymentUseShippingAddress !== false, + enableShipping: input.session?.enableShipping !== false, + enableShippingAddressCollection: + input.session?.enableShippingAddressCollection !== false, + enableBillingAddressCollection: + input.session?.enableBillingAddressCollection !== false, + enableTaxCollection: input.session?.enableTaxCollection === true, + }); +} + +export function useBillingPolicy(): BillingPolicy { + const form = useFormContext(); + const { session } = useCheckoutContext(); + const { data: totals } = useDraftOrderTotals(); + const paymentMethod = form.watch('paymentMethod'); + const deliveryMethod = form.watch('deliveryMethod'); + const paymentUseShippingAddress = form.watch('paymentUseShippingAddress'); + + return useMemo( + () => + resolveBillingPolicyForCheckoutState({ + values: { + paymentMethod, + deliveryMethod, + paymentUseShippingAddress, + }, + session, + totals, + }), + [deliveryMethod, paymentMethod, paymentUseShippingAddress, session, totals] + ); +} From 933221cd04b5f1e3998da071314590ce5a7f15e2 Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Wed, 19 Aug 2026 12:45:51 -0500 Subject: [PATCH 06/22] feat(react): refactor checkout trigger field filtering Task: task-5 --- .../checkout/form/custom-form-provider.tsx | 235 +++++++++--------- 1 file changed, 120 insertions(+), 115 deletions(-) diff --git a/packages/react/src/components/checkout/form/custom-form-provider.tsx b/packages/react/src/components/checkout/form/custom-form-provider.tsx index dac53dc8..80115af1 100644 --- a/packages/react/src/components/checkout/form/custom-form-provider.tsx +++ b/packages/react/src/components/checkout/form/custom-form-provider.tsx @@ -2,14 +2,81 @@ import React, { useEffect, useMemo, useState } from 'react'; import type { FieldPath, UseFormReturn, UseFormTrigger } from 'react-hook-form'; import { FormProvider } from 'react-hook-form'; import { useDraftOrderTotals } from '@/components/checkout/order/use-draft-order'; -import { - getBillingCollectionMode, - hasInlineBillingForm, -} from '@/components/checkout/payment/utils/billing-collection'; -import { PaymentMethodType } from '@/types'; +import { resolveBillingPolicyForCheckoutState } from '@/components/checkout/payment/utils/use-billing-policy'; import { type CheckoutFormData, useCheckoutContext } from '../checkout'; import { DeliveryMethods } from '../delivery/delivery-method'; +const SHIPPING_ADDRESS_FIELD_NAMES = new Set([ + 'shippingFirstName', + 'shippingLastName', + 'shippingAddressLine1', + 'shippingAddressLine2', + 'shippingAddressLine3', + 'shippingAdminArea4', + 'shippingAdminArea3', + 'shippingAdminArea2', + 'shippingAdminArea1', + 'shippingPostalCode', + 'shippingCountryCode', +]); + +const BILLING_ADDRESS_FIELD_NAMES = new Set([ + 'billingAddressLine1', + 'billingAddressLine2', + 'billingAddressLine3', + 'billingAdminArea4', + 'billingAdminArea3', + 'billingAdminArea2', + 'billingAdminArea1', + 'billingPostalCode', + 'billingCountryCode', +]); + +const BILLING_NAME_FIELD_NAMES = new Set([ + 'billingFirstName', + 'billingLastName', +]); + +const BUILT_IN_FIELD_NAMES = new Set([ + ...SHIPPING_ADDRESS_FIELD_NAMES, + ...BILLING_ADDRESS_FIELD_NAMES, + ...BILLING_NAME_FIELD_NAMES, + 'contactEmail', + 'deliveryMethod', + 'paymentUseShippingAddress', + 'shippingPhone', + 'shippingMethod', + 'billingPhone', + 'paymentCardNumber', + 'paymentCardNumberDisplay', + 'paymentCardType', + 'paymentExpiryDate', + 'paymentMonth', + 'paymentYear', + 'paymentSecurityCode', + 'paymentNameOnCard', + 'notes', + 'pickupDate', + 'pickupTime', + 'pickupLocationId', + 'pickupLeadTime', + 'pickupTimezone', + 'tipAmount', + 'tipPercentage', + 'paymentMethod', + 'stripePaymentIntent', + 'stripePaymentIntentId', +]); + +function getCustomErrorMessages(errors: Record) { + return Object.entries(errors).flatMap(([fieldName, error]) => { + if (BUILT_IN_FIELD_NAMES.has(fieldName)) return []; + + const message = (error as { message?: unknown })?.message; + return typeof message === 'string' && message ? [message] : []; + }); +} + /** * Custom FormProvider that extends React Hook Form's FormProvider * to add smart validation that respects unregistered fields @@ -24,16 +91,14 @@ export function CustomFormProvider< const methodsRef = React.useRef(methods); // Use state to force re-render const [, setForceUpdate] = useState({}); - const { customSchemaFields, session } = useCheckoutContext(); + const { session } = useCheckoutContext(); const { data: totals } = useDraftOrderTotals(); - const customSchemaFieldsRef = React.useRef(customSchemaFields); const sessionRef = React.useRef(session); const totalsRef = React.useRef(totals); // Update the refs on every render useEffect(() => { methodsRef.current = methods; - customSchemaFieldsRef.current = customSchemaFields; sessionRef.current = session; totalsRef.current = totals; }); @@ -63,69 +128,14 @@ export function CustomFormProvider< // Get the current delivery method using type assertion for safety else { const values = currentMethods.getValues(); - const deliveryMethod = values.deliveryMethod as unknown as string; - const paymentMethod = values.paymentMethod as unknown as string; - const paymentUseShippingAddress = - values.paymentUseShippingAddress as unknown as boolean; - const isPickup = deliveryMethod === DeliveryMethods.PICKUP; - const isShipping = deliveryMethod === DeliveryMethods.SHIP; - const isOfflinePayment = paymentMethod === PaymentMethodType.OFFLINE; + const isShipping = values.deliveryMethod === DeliveryMethods.SHIP; const currentSession = sessionRef.current; - const orderTotal = - totalsRef.current?.total?.value ?? - currentSession?.draftOrder?.totals?.total?.value; - const isFreeOrder = typeof orderTotal === 'number' && orderTotal <= 0; - const isOfflinePickup = isOfflinePayment && isPickup; - let billingContext: - | 'top-level' - | 'inline-payment-form' - | 'free-payment-form' = 'top-level'; - if (hasInlineBillingForm(paymentMethod)) { - billingContext = 'inline-payment-form'; - } else if (isFreeOrder && isOfflinePayment) { - billingContext = 'free-payment-form'; - } - const billingMode = getBillingCollectionMode({ - context: billingContext, - deliveryMethod, - paymentMethod, - paymentUseShippingAddress, - enableBillingAddressCollection: - currentSession?.enableBillingAddressCollection, - enableTaxCollection: currentSession?.enableTaxCollection, + const policy = resolveBillingPolicyForCheckoutState({ + values: values as unknown as CheckoutFormData, + session: currentSession, + totals: totalsRef.current, }); - // Get all field names and filter based on conditions - const allFieldNames = Object.keys(values); - let fieldNames = [...allFieldNames] as Array>; - const shippingAddressFieldNames = new Set([ - 'shippingFirstName', - 'shippingLastName', - 'shippingAddressLine1', - 'shippingAddressLine2', - 'shippingAddressLine3', - 'shippingAdminArea4', - 'shippingAdminArea3', - 'shippingAdminArea2', - 'shippingAdminArea1', - 'shippingPostalCode', - 'shippingCountryCode', - ]); - const billingAddressFieldNames = new Set([ - 'billingAddressLine1', - 'billingAddressLine2', - 'billingAddressLine3', - 'billingAdminArea4', - 'billingAdminArea3', - 'billingAdminArea2', - 'billingAdminArea1', - 'billingPostalCode', - 'billingCountryCode', - ]); - const billingNameFieldNames = new Set([ - 'billingFirstName', - 'billingLastName', - ]); const shippingSectionIsCollectable = Boolean( isShipping && currentSession?.enableShipping ); @@ -133,8 +143,8 @@ export function CustomFormProvider< shippingSectionIsCollectable && currentSession?.enableShippingAddressCollection ); - const billingNamesAreCollectable = billingMode !== 'none'; - const billingAddressIsCollectable = billingMode === 'address'; + const billingIsCollectable = policy.mode !== 'none'; + const billingAddressIsCollectable = policy.mode === 'address'; const phoneIsCollectable = currentSession?.enablePhoneCollection === true; const notesAreCollectable = @@ -145,68 +155,48 @@ export function CustomFormProvider< return shippingAddressIsCollectable && phoneIsCollectable; } if (fieldName === 'billingPhone') { - return billingNamesAreCollectable && phoneIsCollectable; + return billingIsCollectable && phoneIsCollectable; } - if (shippingAddressFieldNames.has(fieldName)) { + if (SHIPPING_ADDRESS_FIELD_NAMES.has(fieldName)) { return shippingAddressIsCollectable; } if (fieldName === 'shippingMethod') { return shippingSectionIsCollectable; } - if (billingNameFieldNames.has(fieldName)) { - return billingNamesAreCollectable; + if (BILLING_NAME_FIELD_NAMES.has(fieldName)) { + return billingIsCollectable; } - if (billingAddressFieldNames.has(fieldName)) { + if (BILLING_ADDRESS_FIELD_NAMES.has(fieldName)) { return billingAddressIsCollectable; } - if (fieldName.startsWith('shipping')) { - return shippingSectionIsCollectable; - } - if (fieldName.startsWith('billing')) { - return billingNamesAreCollectable; - } if (fieldName === 'notes') { return notesAreCollectable; } return true; }; - fieldNames = fieldNames.filter(fieldName => isCollectable(fieldName)); - const customFieldNames = new Set( - (customSchemaFieldsRef.current ?? []).filter(isCollectable) + const registeredFields = Object.keys( + ( + currentMethods.control as unknown as { + _fields?: Record; + } + )._fields ?? {} ); - const isSkippable = (fieldName: string) => - !customFieldNames.has(fieldName); - - /* For offline pickup orders, only validate billingFirstName and billingLastName */ - if (isOfflinePickup) { - fieldNames = fieldNames.filter( - fieldName => - !fieldName.startsWith('billing') || - fieldName === 'billingFirstName' || - fieldName === 'billingLastName' || - !isSkippable(fieldName) - ); - } else if (paymentUseShippingAddress && isShipping) { - /* If using shipping address for billing, filter out billing-related field validations. - * We require isShipping (not just !isPickup) so that PURCHASE / all-NONE - * fulfillment orders, or sessions with enableShipping: false, still validate - * billing fields — there's no shipping address to copy from in those cases. */ - fieldNames = fieldNames.filter( - fieldName => - !fieldName.startsWith('billing') || !isSkippable(fieldName) - ); - } - - /* If the delivery method is not shipping (i.e. pickup), filter out shipping-related field validations */ - if (!isShipping) { - fieldNames = fieldNames.filter( - fieldName => - !fieldName.startsWith('shipping') || !isSkippable(fieldName) - ); - } + const fieldNames = Array.from( + new Set([...Object.keys(values), ...registeredFields]) + ).filter(isCollectable) as Array>; result = await methods.trigger(fieldNames, triggerOptions); + + const customRegisteredFields = registeredFields.filter( + fieldName => !BUILT_IN_FIELD_NAMES.has(fieldName) + ) as Array>; + if (customRegisteredFields.length > 0) { + result = + (await methods.trigger(customRegisteredFields, { + shouldFocus: false, + })) && result; + } } // Force update to ensure error messages show immediately @@ -234,5 +224,20 @@ export function CustomFormProvider< return result; }, []); - return {children}; + const customErrorMessages = getCustomErrorMessages( + methods.formState.errors as Record + ); + + return ( + + {children} + {customErrorMessages.length > 0 ? ( +
+ {customErrorMessages.map(message => ( +

{message}

+ ))} +
+ ) : null} +
+ ); } From 33207a62de986277b40f3e6aa25064285393eaf1 Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Wed, 19 Aug 2026 12:52:24 -0500 Subject: [PATCH 07/22] feat(react): add billing policy transition controller Task: task-3 --- .../utils/use-clear-billing-address.ts | 36 +++++++++++++ .../checkout/form/checkout-form.tsx | 2 + .../billing-policy-transition-controller.tsx | 51 +++++++++++++++++++ 3 files changed, 89 insertions(+) create mode 100644 packages/react/src/components/checkout/payment/billing-policy-transition-controller.tsx diff --git a/packages/react/src/components/checkout/address/utils/use-clear-billing-address.ts b/packages/react/src/components/checkout/address/utils/use-clear-billing-address.ts index ea3f8ac6..dcc6ec87 100644 --- a/packages/react/src/components/checkout/address/utils/use-clear-billing-address.ts +++ b/packages/react/src/components/checkout/address/utils/use-clear-billing-address.ts @@ -1,6 +1,42 @@ import { useFormContext } from 'react-hook-form'; import { useTryUpdateDraftOrder } from '@/components/checkout/order/use-try-update-draft-order'; +const BILLING_ADDRESS_FIELDS = [ + 'billingAddressLine1', + 'billingAddressLine2', + 'billingAddressLine3', + 'billingAdminArea4', + 'billingAdminArea3', + 'billingAdminArea2', + 'billingAdminArea1', + 'billingPostalCode', + 'billingCountryCode', +]; + +/** + * Clears the billing address but keeps the name and phone, for switching into a + * names-only mode where the address inputs are no longer rendered. The names are + * resent with the patch so the partial billing input cannot drop them. + */ +export function useClearBillingAddressDetails() { + const form = useFormContext(); + const tryUpdateDraftOrder = useTryUpdateDraftOrder(); + + return function clearBillingAddressDetails() { + tryUpdateDraftOrder({ + billing: { + firstName: String(form.getValues('billingFirstName') ?? '').trim(), + lastName: String(form.getValues('billingLastName') ?? '').trim(), + address: null, + }, + }); + + for (const fieldName of BILLING_ADDRESS_FIELDS) { + form.setValue(fieldName, ''); + } + }; +} + export function useClearBillingAddress() { const form = useFormContext(); const tryUpdateDraftOrder = useTryUpdateDraftOrder(); diff --git a/packages/react/src/components/checkout/form/checkout-form.tsx b/packages/react/src/components/checkout/form/checkout-form.tsx index 3eacb11f..0fb7a731 100644 --- a/packages/react/src/components/checkout/form/checkout-form.tsx +++ b/packages/react/src/components/checkout/form/checkout-form.tsx @@ -26,6 +26,7 @@ import { import { NotesForm } from '@/components/checkout/notes/notes-form'; import { DraftOrderSyncProvider } from '@/components/checkout/order/draft-order-sync-provider'; import { useDraftOrderTotals } from '@/components/checkout/order/use-draft-order'; +import { BillingPolicyTransitionController } from '@/components/checkout/payment/billing-policy-transition-controller'; import { PaymentForm } from '@/components/checkout/payment/payment-form'; import { ConditionalExpressProviders, @@ -403,6 +404,7 @@ export function CheckoutForm({ return ( +
(); + const policy = useBillingPolicy(); + const deliveryMethod = form.watch('deliveryMethod'); + const paymentMethod = form.watch('paymentMethod'); + const clearBillingAddressDetails = useClearBillingAddressDetails(); + const previousStateRef = React.useRef({ + mode: policy.mode, + paymentMethod, + }); + const hydratedRef = React.useRef(false); + + React.useEffect(() => { + if (!deliveryMethod) return; + + const previousState = previousStateRef.current; + previousStateRef.current = { + mode: policy.mode, + paymentMethod, + }; + + if (!hydratedRef.current) { + hydratedRef.current = true; + return; + } + + if ( + previousState.mode === 'address' && + policy.mode === 'names' && + (Boolean(previousState.paymentMethod) || + form.getFieldState('paymentMethod').isDirty || + form.getFieldState('deliveryMethod').isDirty) + ) { + clearBillingAddressDetails(); + } + }, [ + clearBillingAddressDetails, + deliveryMethod, + form, + paymentMethod, + policy.mode, + ]); + + return null; +} From 111fe189b3bcf012af187332b734730776d4c37e Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Wed, 19 Aug 2026 13:10:48 -0500 Subject: [PATCH 08/22] feat(react): add policy-aware checkout validation adapter Task: task-6 --- .../src/components/checkout/checkout.tsx | 193 ++------- .../checkout/form/checkout-form-container.tsx | 8 +- .../checkout/form/checkout-form.tsx | 22 +- .../form/checkout-validation-adapter.ts | 369 ++++++++++++++++++ .../order/draft-order-sync-provider.tsx | 30 +- 5 files changed, 441 insertions(+), 181 deletions(-) create mode 100644 packages/react/src/components/checkout/form/checkout-validation-adapter.ts diff --git a/packages/react/src/components/checkout/checkout.tsx b/packages/react/src/components/checkout/checkout.tsx index febf8a02..b28a9432 100644 --- a/packages/react/src/components/checkout/checkout.tsx +++ b/packages/react/src/components/checkout/checkout.tsx @@ -3,8 +3,6 @@ import { CircleAlert } from 'lucide-react'; import React, { type ReactNode } from 'react'; import { z } from 'zod'; -import { hasRegionData } from '@/components/checkout/address'; -import { checkIsValidPhone } from '@/components/checkout/address/utils/check-is-valid-phone'; import { DeliveryMethods } from '@/components/checkout/delivery/delivery-methods'; import { getRequiredFieldsFromSchema } from '@/components/checkout/form/utils/get-required-fields-from-schema'; import { type GoDaddyVariables, useGoDaddyContext } from '@/godaddy-provider'; @@ -13,8 +11,12 @@ import { type Theme, useTheme } from '@/hooks/use-theme'; import { useVariables } from '@/hooks/use-variables'; import type { TrackingProperties } from '@/tracking/event-properties'; import { TrackingProvider } from '@/tracking/tracking-provider'; -import { type CheckoutSession, PaymentMethodType } from '@/types'; +import { type CheckoutSession } from '@/types'; import { CheckoutFormContainer } from './form/checkout-form-container'; +import { + type CheckoutValidationMessages, + createCheckoutValidationAdapter, +} from './form/checkout-validation-adapter'; import type { Target } from './target/types'; // Utility function for redirecting to success URL after checkout @@ -259,166 +261,35 @@ export function Checkout(props: CheckoutProps) { useTheme(session?.appearance?.theme); useVariables(session?.appearance?.variables || props?.appearance?.variables); - const formSchema = React.useMemo(() => { - const extendedSchema = checkoutFormSchema - ? baseCheckoutSchema.extend(checkoutFormSchema) - : baseCheckoutSchema; - - const enableBillingAddressCollection = - session?.enableBillingAddressCollection !== false; - const enableShipping = session?.enableShipping !== false; - - return extendedSchema.superRefine((data, ctx) => { - if (data.billingPhone) { - if (!checkIsValidPhone(String(data?.billingPhone))) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: t.validation.enterValidBillingPhone, - path: ['billingPhone'], - }); - } - } - - if (data.shippingPhone) { - if (!checkIsValidPhone(String(data?.shippingPhone))) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: t.validation.enterValidShippingPhone, - path: ['shippingPhone'], - }); - } - } - - const isOfflinePayment = data.paymentMethod === PaymentMethodType.OFFLINE; - const isPickup = data.deliveryMethod === DeliveryMethods.PICKUP; - const isShipping = data.deliveryMethod === DeliveryMethods.SHIP; - const isDigital = data.deliveryMethod === DeliveryMethods.DIGITAL; - const isPurchase = data.deliveryMethod === DeliveryMethods.PURCHASE; - const isOfflinePickup = isOfflinePayment && isPickup; - const isDigitalTaxDisabledOffline = - isDigital && isOfflinePayment && !session?.enableTaxCollection; - const isPurchaseTaxDisabledOffline = - isPurchase && isOfflinePayment && !session?.enableTaxCollection; - // Billing is separate from shipping when there is no shipping address - // to copy from. `mapOrderToFormValues` canonicalizes deliveryMethod - // against session capabilities, so `!isShipping` already covers both - // session.enableShipping=false and orders with no SHIP fulfillment. - // The remaining case is the user opting out of "use shipping for billing". - const billingIsSeparateFromShipping = - !isShipping || !data.paymentUseShippingAddress; - - const requireBillingNamesOnly = - (!enableBillingAddressCollection && billingIsSeparateFromShipping) || - isOfflinePickup || - isDigitalTaxDisabledOffline || - isPurchaseTaxDisabledOffline; - - if (requireBillingNamesOnly) { - const nameFields = [ - { key: 'billingFirstName', message: t.validation.enterFirstName }, - { key: 'billingLastName', message: t.validation.enterLastName }, - ]; - - for (const { key, message } of nameFields) { - if (!data[key as keyof typeof data]) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message, - path: [key], - }); - } - } - } - - const requireBillingAddress = - enableBillingAddressCollection && - !isOfflinePickup && - !isDigitalTaxDisabledOffline && - !isPurchaseTaxDisabledOffline && - billingIsSeparateFromShipping; - - if (requireBillingAddress) { - // Basic billing fields required for all countries - const billingFields = [ - { key: 'billingFirstName', message: t.validation.enterFirstName }, - { key: 'billingLastName', message: t.validation.enterLastName }, - { key: 'billingAddressLine1', message: t.validation.enterAddress }, - { key: 'billingAdminArea2', message: t.validation.enterCity }, - { - key: 'billingPostalCode', - message: t.validation.enterZipPostalCode, - }, - { key: 'billingCountryCode', message: t.validation.enterCountry }, - ]; - - if (hasRegionData(String(data.billingCountryCode))) { - billingFields.push({ - key: 'billingAdminArea1', - message: t.validation.selectState, - }); - } - - for (const { key, message } of billingFields) { - if (!data[key as keyof typeof data]) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message, - path: [key], - }); - } - } - } - - // Shipping address validation - only required if delivery method is SHIP - // AND shipping is enabled at the session level. This guards against the - // contradictory case where line items declare SHIP fulfillment but the - // session has enableShipping: false (the shipping form is not rendered - // in that case, so requiring the fields would block the user). - const requireShippingAddress = isShipping && enableShipping; - - if (requireShippingAddress) { - // Basic shipping fields required for all countries - const shippingFields = [ - { key: 'shippingFirstName', message: t.validation.enterFirstName }, - { key: 'shippingLastName', message: t.validation.enterLastName }, - { key: 'shippingAddressLine1', message: t.validation.enterAddress }, - { key: 'shippingAdminArea2', message: t.validation.enterCity }, - { - key: 'shippingPostalCode', - message: t.validation.enterZipPostalCode, - }, - { key: 'shippingCountryCode', message: t.validation.enterCountry }, - ]; - - if (hasRegionData(String(data.shippingCountryCode))) { - shippingFields.push({ - key: 'shippingAdminArea1', - message: t.validation.selectState, - }); - } + const validationMessages = React.useMemo( + () => ({ + enterValidBillingPhone: t.validation.enterValidBillingPhone, + enterValidShippingPhone: t.validation.enterValidShippingPhone, + enterFirstName: t.validation.enterFirstName, + enterLastName: t.validation.enterLastName, + enterAddress: t.validation.enterAddress, + enterCity: t.validation.enterCity, + enterZipPostalCode: t.validation.enterZipPostalCode, + enterCountry: t.validation.enterCountry, + selectState: t.validation.selectState, + }), + [t] + ); - for (const { key, message } of shippingFields) { - if (!data[key as keyof typeof data]) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message, - path: [key], - }); - } - } - } - }); - }, [ - checkoutFormSchema, - session?.enableBillingAddressCollection, - session?.enableShipping, - session?.enableTaxCollection, - t, - ]); + const validationAdapter = React.useMemo( + () => + createCheckoutValidationAdapter({ + baseSchema: baseCheckoutSchema, + checkoutFormSchema, + messages: validationMessages, + getContext: () => ({ session }), + }), + [checkoutFormSchema, session, validationMessages] + ); const requiredFields = React.useMemo(() => { - return getRequiredFieldsFromSchema(formSchema); - }, [formSchema]); + return getRequiredFieldsFromSchema(validationAdapter.schema); + }, [validationAdapter]); const customSchemaFields = React.useMemo(() => { return Object.keys(checkoutFormSchema ?? {}); @@ -483,7 +354,7 @@ export function Checkout(props: CheckoutProps) { diff --git a/packages/react/src/components/checkout/form/checkout-form-container.tsx b/packages/react/src/components/checkout/form/checkout-form-container.tsx index 8843ee13..bb96e7e1 100644 --- a/packages/react/src/components/checkout/form/checkout-form-container.tsx +++ b/packages/react/src/components/checkout/form/checkout-form-container.tsx @@ -1,11 +1,11 @@ import { useMemo } from 'react'; -import type { z } from 'zod'; import { type CheckoutProps, useCheckoutContext, } from '@/components/checkout/checkout'; import { CheckoutSkeleton } from '@/components/checkout/checkout-skeleton'; import { CheckoutForm } from '@/components/checkout/form/checkout-form'; +import type { CheckoutValidationAdapter } from '@/components/checkout/form/checkout-validation-adapter'; import { useDraftOrder, useDraftOrderLineItems, @@ -18,12 +18,12 @@ import { import { getFulfillmentSummary } from '@/components/checkout/utils/fulfillment'; interface CheckoutFormContainerProps extends Omit { - schema: z.ZodObject | z.ZodEffects; + validationAdapter: CheckoutValidationAdapter; isLoadingJWT?: boolean; } export function CheckoutFormContainer({ - schema, + validationAdapter, isLoadingJWT, ...props }: CheckoutFormContainerProps) { @@ -81,7 +81,7 @@ export function CheckoutFormContainer({ return ( = { }; interface CheckoutFormProps extends Omit { - schema: z.ZodObject | z.ZodEffects; + validationAdapter: CheckoutValidationAdapter; defaultValues?: Pick; items: Product[]; fulfillmentSummary: FulfillmentSummary; @@ -118,7 +118,7 @@ function mergeOrderBackedFormValues( } export function CheckoutForm({ - schema, + validationAdapter, defaultValues, items, fulfillmentSummary, @@ -130,8 +130,15 @@ export function CheckoutForm({ useCheckoutContext(); const formValues = (defaultValues ?? {}) as DefaultValues; + const validationContextRef = useRef({ + session, + totals: undefined as typeof totals | undefined, + }); + validationContextRef.current.session = session; + const form = useForm({ - resolver: zodResolver(schema), + resolver: (values, _context, options) => + validationAdapter.resolver(values, validationContextRef.current, options), defaultValues: formValues, reValidateMode: 'onBlur', mode: 'onBlur', @@ -195,6 +202,7 @@ export function CheckoutForm({ const draftOrderTotalsQuery = useDraftOrderTotals(); const { data: totals, isLoading: totalsLoading } = draftOrderTotalsQuery; + validationContextRef.current.totals = totals; // Order summary calculations - keep all values in minor units const subtotal = totals?.subTotal?.value || 0; @@ -207,7 +215,7 @@ export function CheckoutForm({ const currencyCode = totals?.total?.currencyCode || 'USD'; const itemCount = items.reduce((sum, item) => sum + (item?.quantity || 0), 0); - const isFree = orderTotal <= 0; + const isFree = isFreeOrderTotal(totals); const hasExpressCheckoutPaymentMethod = Object.values( session?.paymentMethods ?? {} ).some( @@ -403,7 +411,7 @@ export function CheckoutForm({ return ( - +
diff --git a/packages/react/src/components/checkout/form/checkout-validation-adapter.ts b/packages/react/src/components/checkout/form/checkout-validation-adapter.ts new file mode 100644 index 00000000..60e5c3d3 --- /dev/null +++ b/packages/react/src/components/checkout/form/checkout-validation-adapter.ts @@ -0,0 +1,369 @@ +import { toNestErrors, validateFieldsNatively } from '@hookform/resolvers'; +import type { + Resolver, + ResolverOptions, + ResolverResult, +} from 'react-hook-form'; +import { z } from 'zod'; +import { hasRegionData } from '@/components/checkout/address'; +import { checkIsValidPhone } from '@/components/checkout/address/utils/check-is-valid-phone'; +import type { + CheckoutFormData, + CheckoutFormSchema, +} from '@/components/checkout/checkout'; +import { DeliveryMethods } from '@/components/checkout/delivery/delivery-methods'; +import { resolveBillingPolicyForCheckoutState } from '@/components/checkout/payment/utils/use-billing-policy'; +import type { CheckoutSession, Totals } from '@/types'; + +export type CheckoutValidationMessages = { + enterValidBillingPhone: string; + enterValidShippingPhone: string; + enterFirstName: string; + enterLastName: string; + enterAddress: string; + enterCity: string; + enterZipPostalCode: string; + enterCountry: string; + selectState: string; +}; + +export type CheckoutValidationContext = { + session?: CheckoutSession | null; + totals?: Totals | null; +}; + +export type CheckoutValidationAdapter = { + schema: z.ZodTypeAny; + resolver: Resolver; + safeParseAsync: ( + values: CheckoutFormData, + context?: CheckoutValidationContext + ) => Promise>; +}; + +const SHIPPING_ADDRESS_FIELD_NAMES = new Set([ + 'shippingFirstName', + 'shippingLastName', + 'shippingAddressLine1', + 'shippingAddressLine2', + 'shippingAddressLine3', + 'shippingAdminArea4', + 'shippingAdminArea3', + 'shippingAdminArea2', + 'shippingAdminArea1', + 'shippingPostalCode', + 'shippingCountryCode', +]); + +const BILLING_ADDRESS_FIELD_NAMES = new Set([ + 'billingAddressLine1', + 'billingAddressLine2', + 'billingAddressLine3', + 'billingAdminArea4', + 'billingAdminArea3', + 'billingAdminArea2', + 'billingAdminArea1', + 'billingPostalCode', + 'billingCountryCode', +]); + +const BILLING_NAME_FIELD_NAMES = new Set([ + 'billingFirstName', + 'billingLastName', +]); + +function isBuiltInConditionalFieldHidden( + fieldName: string, + values: CheckoutFormData, + context?: CheckoutValidationContext +) { + const session = context?.session; + const deliveryMethod = values.deliveryMethod; + const isShipping = deliveryMethod === DeliveryMethods.SHIP; + const shippingSectionIsCollectable = Boolean( + isShipping && session?.enableShipping + ); + const shippingAddressIsCollectable = Boolean( + shippingSectionIsCollectable && session?.enableShippingAddressCollection + ); + const policy = resolveBillingPolicyForCheckoutState({ + values, + session, + totals: context?.totals, + }); + const billingIsCollectable = policy.mode !== 'none'; + const billingAddressIsCollectable = policy.mode === 'address'; + const phoneIsCollectable = session?.enablePhoneCollection === true; + const notesAreCollectable = session?.enableNotesCollection === true; + + if (fieldName === 'shippingPhone') { + return !shippingAddressIsCollectable || !phoneIsCollectable; + } + if (fieldName === 'billingPhone') { + return !billingIsCollectable || !phoneIsCollectable; + } + if (SHIPPING_ADDRESS_FIELD_NAMES.has(fieldName)) { + return !shippingAddressIsCollectable; + } + if (fieldName === 'shippingMethod') { + return !shippingSectionIsCollectable; + } + if (BILLING_NAME_FIELD_NAMES.has(fieldName)) { + return !billingIsCollectable; + } + if (BILLING_ADDRESS_FIELD_NAMES.has(fieldName)) { + return !billingAddressIsCollectable; + } + if (fieldName === 'notes') { + return !notesAreCollectable; + } + return false; +} + +function filterIssuesForHiddenConditionalFields( + issues: z.ZodIssue[], + values: CheckoutFormData, + context?: CheckoutValidationContext +) { + return issues.filter(issue => { + const [fieldName] = issue.path; + return !( + typeof fieldName === 'string' && + isBuiltInConditionalFieldHidden(fieldName, values, context) + ); + }); +} + +function addRequiredIssue( + ctx: z.RefinementCtx, + data: CheckoutFormData, + key: keyof CheckoutFormData, + message: string +) { + if (data[key]) return; + + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message, + path: [key], + }); +} + +export function createCheckoutSchema( + baseSchema: z.ZodObject, + checkoutFormSchema: CheckoutFormSchema | undefined, + messages: CheckoutValidationMessages, + context?: CheckoutValidationContext +) { + const extendedSchema = checkoutFormSchema + ? baseSchema.extend(checkoutFormSchema) + : baseSchema; + + return extendedSchema.superRefine((schemaData, ctx) => { + const data = schemaData as CheckoutFormData; + if (data.billingPhone) { + if (!checkIsValidPhone(String(data.billingPhone))) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: messages.enterValidBillingPhone, + path: ['billingPhone'], + }); + } + } + + if (data.shippingPhone) { + if (!checkIsValidPhone(String(data.shippingPhone))) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: messages.enterValidShippingPhone, + path: ['shippingPhone'], + }); + } + } + + const policy = resolveBillingPolicyForCheckoutState({ + values: data, + session: context?.session, + totals: context?.totals, + }); + + if (policy.mode === 'names' || policy.mode === 'address') { + addRequiredIssue(ctx, data, 'billingFirstName', messages.enterFirstName); + addRequiredIssue(ctx, data, 'billingLastName', messages.enterLastName); + } + + if (policy.mode === 'address') { + addRequiredIssue(ctx, data, 'billingAddressLine1', messages.enterAddress); + addRequiredIssue(ctx, data, 'billingAdminArea2', messages.enterCity); + addRequiredIssue( + ctx, + data, + 'billingPostalCode', + messages.enterZipPostalCode + ); + addRequiredIssue(ctx, data, 'billingCountryCode', messages.enterCountry); + + if (hasRegionData(String(data.billingCountryCode))) { + addRequiredIssue(ctx, data, 'billingAdminArea1', messages.selectState); + } + } + + const requireShippingAddress = Boolean( + data.deliveryMethod === DeliveryMethods.SHIP && + context?.session?.enableShipping && + context?.session?.enableShippingAddressCollection + ); + + if (requireShippingAddress) { + addRequiredIssue(ctx, data, 'shippingFirstName', messages.enterFirstName); + addRequiredIssue(ctx, data, 'shippingLastName', messages.enterLastName); + addRequiredIssue( + ctx, + data, + 'shippingAddressLine1', + messages.enterAddress + ); + addRequiredIssue(ctx, data, 'shippingAdminArea2', messages.enterCity); + addRequiredIssue( + ctx, + data, + 'shippingPostalCode', + messages.enterZipPostalCode + ); + addRequiredIssue(ctx, data, 'shippingCountryCode', messages.enterCountry); + + if (hasRegionData(String(data.shippingCountryCode))) { + addRequiredIssue(ctx, data, 'shippingAdminArea1', messages.selectState); + } + } + }); +} + +function createZodError(issues: z.ZodIssue[]) { + return new z.ZodError(issues); +} + +type ParsedFieldError = { + message: string; + type: string; + types?: Record; +}; + +function parseErrorSchema( + issues: z.ZodIssue[], + validateAllFieldCriteria: boolean +) { + const errors: Record = {}; + + for (; issues.length; ) { + const issue = issues[0]; + const path = issue.path.join('.'); + + if (!errors[path]) { + errors[path] = { + message: issue.message, + type: issue.code, + }; + } + + if (validateAllFieldCriteria) { + const types = errors[path]?.types; + const messages = types?.[issue.code]; + errors[path] = { + ...errors[path], + types: { + ...types, + [issue.code]: messages + ? ([] as string[]).concat(messages as string[], issue.message) + : issue.message, + }, + }; + } + + issues.shift(); + } + + return errors; +} + +function createResolverResult( + error: z.ZodError, + options: ResolverOptions +): ResolverResult { + return { + values: {}, + errors: toNestErrors( + parseErrorSchema( + [...error.errors], + !options.shouldUseNativeValidation && options.criteriaMode === 'all' + ), + options + ), + }; +} + +export function createCheckoutValidationAdapter({ + baseSchema, + checkoutFormSchema, + messages, + getContext, +}: { + baseSchema: z.ZodObject; + checkoutFormSchema?: CheckoutFormSchema; + messages: CheckoutValidationMessages; + getContext?: () => CheckoutValidationContext; +}): CheckoutValidationAdapter { + const getValidationContext = (context?: CheckoutValidationContext) => ({ + ...(getContext?.() ?? {}), + ...(context ?? {}), + }); + + const safeParseAsync: CheckoutValidationAdapter['safeParseAsync'] = async ( + values, + context + ) => { + const data = values as CheckoutFormData; + const validationContext = getValidationContext(context); + const schema = createCheckoutSchema( + baseSchema, + checkoutFormSchema, + messages, + validationContext + ); + const result = await schema.safeParseAsync(data); + + if (result.success) { + return { success: true as const, data: result.data as CheckoutFormData }; + } + + const issues = filterIssuesForHiddenConditionalFields( + result.error.issues, + data, + validationContext + ); + + return issues.length + ? { success: false as const, error: createZodError(issues) } + : { success: true as const, data }; + }; + + return { + schema: createCheckoutSchema(baseSchema, checkoutFormSchema, messages), + safeParseAsync, + resolver: async (values, context, options) => { + const result = await safeParseAsync(values, context); + + if (result.success) { + if (options.shouldUseNativeValidation) { + validateFieldsNatively({}, options); + } + return { + errors: {}, + values: result.data, + }; + } + + return createResolverResult(result.error, options); + }, + }; +} diff --git a/packages/react/src/components/checkout/order/draft-order-sync-provider.tsx b/packages/react/src/components/checkout/order/draft-order-sync-provider.tsx index adde7be6..aef58a38 100644 --- a/packages/react/src/components/checkout/order/draft-order-sync-provider.tsx +++ b/packages/react/src/components/checkout/order/draft-order-sync-provider.tsx @@ -2,11 +2,11 @@ import { useQueryClient } from '@tanstack/react-query'; import isEqual from 'fast-deep-equal'; import * as React from 'react'; import { type UseFormReturn, useFormContext } from 'react-hook-form'; -import type { z } from 'zod'; import { type CheckoutFormData, useCheckoutContext, } from '@/components/checkout/checkout'; +import type { CheckoutValidationAdapter } from '@/components/checkout/form/checkout-validation-adapter'; import { useDraftOrder } from '@/components/checkout/order/use-draft-order'; import { useUpdateOrder } from '@/components/checkout/order/use-update-order'; import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys'; @@ -150,14 +150,22 @@ export function mergeDraftOrderPatch( export function DraftOrderSyncProvider({ children, + validationAdapter, schema, }: { children: React.ReactNode; /** - * The same schema the form resolver uses. Registrations are skipped while - * their fields are invalid so rejected values never reach the draft order. + * The same policy-aware adapter the form resolver uses. Registrations are + * skipped while their fields are invalid so rejected values never reach the + * draft order. */ - schema?: z.ZodTypeAny; + validationAdapter?: Pick; + schema?: { + safeParseAsync: (values: CheckoutFormData) => Promise<{ + success: boolean; + error?: { issues: Array<{ path: Array }> }; + }>; + }; }) { const updateDraftOrder = useUpdateOrder(); const queryClient = useQueryClient(); @@ -351,10 +359,14 @@ export function DraftOrderSyncProvider({ const getInvalidFieldNames = React.useCallback( async (values: CheckoutFormData) => { const invalidFieldNames = new Set(); - if (!schema) return invalidFieldNames; - - const result = await schema.safeParseAsync(values); - if (result.success) return invalidFieldNames; + const result = validationAdapter + ? await validationAdapter.safeParseAsync(values, { + session, + totals: draftOrderQuery.data?.totals ?? null, + }) + : await schema?.safeParseAsync(values); + if (!result) return invalidFieldNames; + if (result.success || !result.error) return invalidFieldNames; for (const issue of result.error.issues) { const [fieldName] = issue.path; @@ -364,7 +376,7 @@ export function DraftOrderSyncProvider({ return invalidFieldNames; }, - [schema] + [draftOrderQuery.data?.totals, schema, session, validationAdapter] ); const buildPatchFromRegistrations = React.useCallback( From 8f77713d140f9e3d31efc822b2c7441670578b63 Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Wed, 19 Aug 2026 13:26:40 -0500 Subject: [PATCH 09/22] feat(react): finalize billing policy validation Task: task-7 --- .changeset/clever-clubs-crash.md | 19 +++- .../__tests__/checkout-test-utils.tsx | 12 ++- .../checkout/address/address-form.tsx | 1 + .../discount/utils/use-discount-apply.ts | 6 +- .../checkout/form/custom-form-provider.tsx | 18 +--- .../billing-policy-transition-controller.tsx | 25 ++++- .../checkout/payment/free-payment-form.tsx | 4 - .../payment/utils/billing-collection.test.ts | 97 +------------------ .../payment/utils/billing-collection.ts | 89 +---------------- 9 files changed, 64 insertions(+), 207 deletions(-) diff --git a/.changeset/clever-clubs-crash.md b/.changeset/clever-clubs-crash.md index 78b203ed..8455f50d 100644 --- a/.changeset/clever-clubs-crash.md +++ b/.changeset/clever-clubs-crash.md @@ -2,4 +2,21 @@ "@godaddy/react": patch --- -fix: adjust billing collection logic for offline payment orders +fix: align billing collection across rendering, validation, and the form schema + +- Validate paid offline orders with the payment-form rules instead of the free-order + rules, so a rendered billing address is actually required before confirming. +- Offline pickup collects names only, since taxes use the pickup location. +- Purchase-mode offline orders collect a billing address only when tax collection + needs a destination, and never when billing address collection is disabled. +- Free orders now follow the same billing rules as paid offline orders, fixing free + purchase-mode and separate-billing shipping orders that required billing fields the + form never rendered. +- Free shipping orders render the "use shipping address as billing" toggle, so they can + opt in or out of a separate billing address like paid orders already could. +- Changing payment or delivery method to a flow that only collects billing names now + clears the billing address from the draft order, instead of keeping one the customer + can no longer see. A billing address already present on the draft order at load is + left untouched. +- Validate the billing phone on offline pickup orders, which was silently skipped. +- Stop requiring a shipping address when `enableShippingAddressCollection` is false. diff --git a/packages/react/src/components/checkout/__tests__/checkout-test-utils.tsx b/packages/react/src/components/checkout/__tests__/checkout-test-utils.tsx index 562d625f..b2ae0c4c 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-test-utils.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-test-utils.tsx @@ -630,7 +630,17 @@ function applyShippingLines(shippingMethods: unknown) { function applyDiscountCodes(discountCodes: string[]) { if (!state) return; const discounts = discountCodes.map(code => discount(code)); - const discountTotal = money(discountCodes.length * 100); + const totals = state.draftOrder.totals ?? defaultTotals(); + const freeOrderDiscount = + (totals.subTotal?.value ?? 0) + + (totals.shippingTotal?.value ?? 0) + + (totals.taxTotal?.value ?? 0) + + (totals.feeTotal?.value ?? 0); + const discountTotal = money( + discountCodes.some(code => code.toLowerCase() === 'free100') + ? freeOrderDiscount + : discountCodes.length * 100 + ); state.draftOrder = recalculateTotal({ ...state.draftOrder, discounts, diff --git a/packages/react/src/components/checkout/address/address-form.tsx b/packages/react/src/components/checkout/address/address-form.tsx index 838d3d0f..b0f28568 100644 --- a/packages/react/src/components/checkout/address/address-form.tsx +++ b/packages/react/src/components/checkout/address/address-form.tsx @@ -238,6 +238,7 @@ export function AddressForm({ enabled: ({ values, draftOrder: currentDraftOrder }) => Boolean( onlyNames && + currentDraftOrder && sectionNameHasChanged(values, currentDraftOrder, sectionKey) && getFormString(values, `${sectionKey}FirstName`).trim() && getFormString(values, `${sectionKey}LastName`).trim() diff --git a/packages/react/src/components/checkout/discount/utils/use-discount-apply.ts b/packages/react/src/components/checkout/discount/utils/use-discount-apply.ts index db2a6f4e..da9db92f 100644 --- a/packages/react/src/components/checkout/discount/utils/use-discount-apply.ts +++ b/packages/react/src/components/checkout/discount/utils/use-discount-apply.ts @@ -55,6 +55,7 @@ export function useDiscountApply() { ...old?.checkoutSession?.draftOrder, totals: { ...old?.checkoutSession?.draftOrder?.totals, + ...(responseData?.totals ?? {}), discountTotal, total: responseData?.totals?.total || @@ -139,7 +140,10 @@ export function useDiscountApply() { ); } - if (session?.enableTaxCollection) { + if ( + session?.enableTaxCollection && + (responseData?.totals?.total?.value ?? 0) > 0 + ) { // If the delivery method is pickup, we need to update taxes based on the pickup location // Otherwise, we can just update taxes without a specific address // TODO: Move this to API layer diff --git a/packages/react/src/components/checkout/form/custom-form-provider.tsx b/packages/react/src/components/checkout/form/custom-form-provider.tsx index 80115af1..7fb3ae31 100644 --- a/packages/react/src/components/checkout/form/custom-form-provider.tsx +++ b/packages/react/src/components/checkout/form/custom-form-provider.tsx @@ -77,26 +77,19 @@ function getCustomErrorMessages(errors: Record) { }); } -/** - * Custom FormProvider that extends React Hook Form's FormProvider - * to add smart validation that respects unregistered fields - */ export function CustomFormProvider< TFormValues extends Record = CheckoutFormData, >({ children, ...methods }: { children: React.ReactNode } & UseFormReturn) { - // Original methods reference to use in the enhancedTrigger const methodsRef = React.useRef(methods); - // Use state to force re-render const [, setForceUpdate] = useState({}); const { session } = useCheckoutContext(); const { data: totals } = useDraftOrderTotals(); const sessionRef = React.useRef(session); const totalsRef = React.useRef(totals); - // Update the refs on every render useEffect(() => { methodsRef.current = methods; sessionRef.current = session; @@ -104,7 +97,6 @@ export function CustomFormProvider< }); const enhancedMethods = useMemo(() => { - // Override the trigger function with a type-safe version that ensures error messages are displayed const enhancedTrigger: UseFormTrigger = async ( name?: | FieldPath @@ -115,18 +107,13 @@ export function CustomFormProvider< try { const currentMethods = methodsRef.current; - // Always enable shouldFocus by default unless explicitly disabled const triggerOptions = { shouldFocus: true, ...options }; let result: boolean; - // If specific fields are provided, use the original trigger if (name) { - // Use original methods directly to ensure formState is properly updated result = await methods.trigger(name, triggerOptions); - } - // Get the current delivery method using type assertion for safety - else { + } else { const values = currentMethods.getValues(); const isShipping = values.deliveryMethod === DeliveryMethods.SHIP; const currentSession = sessionRef.current; @@ -199,7 +186,6 @@ export function CustomFormProvider< } } - // Force update to ensure error messages show immediately setTimeout(() => { setForceUpdate({}); }, 0); @@ -210,13 +196,11 @@ export function CustomFormProvider< } }; - // Return the enhanced methods object with properly typed trigger and original state const result = { ...methods, trigger: enhancedTrigger, } as UseFormReturn; - // Make sure we're not losing formState reactivity Object.defineProperty(result, 'formState', { get: () => methodsRef.current.formState, }); diff --git a/packages/react/src/components/checkout/payment/billing-policy-transition-controller.tsx b/packages/react/src/components/checkout/payment/billing-policy-transition-controller.tsx index 651fc69e..9902ef18 100644 --- a/packages/react/src/components/checkout/payment/billing-policy-transition-controller.tsx +++ b/packages/react/src/components/checkout/payment/billing-policy-transition-controller.tsx @@ -1,30 +1,45 @@ import React from 'react'; import { useFormContext } from 'react-hook-form'; import { useClearBillingAddressDetails } from '@/components/checkout/address/utils/use-clear-billing-address'; -import type { CheckoutFormData } from '@/components/checkout/checkout'; +import { + type CheckoutFormData, + useCheckoutContext, +} from '@/components/checkout/checkout'; +import { useDraftOrderTotals } from '@/components/checkout/order/use-draft-order'; import { useBillingPolicy } from '@/components/checkout/payment/utils/use-billing-policy'; export function BillingPolicyTransitionController(): null { const form = useFormContext(); + const { session } = useCheckoutContext(); const policy = useBillingPolicy(); + const { data: totals } = useDraftOrderTotals(); + const totalValue = totals?.total?.value ?? null; const deliveryMethod = form.watch('deliveryMethod'); const paymentMethod = form.watch('paymentMethod'); const clearBillingAddressDetails = useClearBillingAddressDetails(); const previousStateRef = React.useRef({ mode: policy.mode, paymentMethod, + draftOrderId: session?.draftOrder?.id, + totalValue, }); const hydratedRef = React.useRef(false); React.useEffect(() => { - if (!deliveryMethod) return; + if (!deliveryMethod || totals === undefined) return; const previousState = previousStateRef.current; previousStateRef.current = { mode: policy.mode, paymentMethod, + draftOrderId: session?.draftOrder?.id, + totalValue, }; + if (previousState.draftOrderId !== session?.draftOrder?.id) { + hydratedRef.current = false; + } + if (!hydratedRef.current) { hydratedRef.current = true; return; @@ -35,7 +50,8 @@ export function BillingPolicyTransitionController(): null { policy.mode === 'names' && (Boolean(previousState.paymentMethod) || form.getFieldState('paymentMethod').isDirty || - form.getFieldState('deliveryMethod').isDirty) + form.getFieldState('deliveryMethod').isDirty || + previousState.totalValue !== totalValue) ) { clearBillingAddressDetails(); } @@ -45,6 +61,9 @@ export function BillingPolicyTransitionController(): null { form, paymentMethod, policy.mode, + session?.draftOrder?.id, + totalValue, + totals, ]); return null; diff --git a/packages/react/src/components/checkout/payment/free-payment-form.tsx b/packages/react/src/components/checkout/payment/free-payment-form.tsx index 9c13a052..6f7387f1 100644 --- a/packages/react/src/components/checkout/payment/free-payment-form.tsx +++ b/packages/react/src/components/checkout/payment/free-payment-form.tsx @@ -27,10 +27,6 @@ export function FreePaymentForm() { const billingPolicy = useBillingPolicy(); const isShipping = form.watch('deliveryMethod') === DeliveryMethods.SHIP; - /* Free orders honour `paymentUseShippingAddress` exactly like paid offline - * ones, so the customer needs the same control over it. Without the toggle, - * an order that starts with only a shipping address is stuck asking for a - * separate billing address with no way to say "same as shipping". */ const showAddressToggle = isShipping && session?.enableShipping !== false; const handleSubmit = React.useCallback(async () => { diff --git a/packages/react/src/components/checkout/payment/utils/billing-collection.test.ts b/packages/react/src/components/checkout/payment/utils/billing-collection.test.ts index a879b757..d8379753 100644 --- a/packages/react/src/components/checkout/payment/utils/billing-collection.test.ts +++ b/packages/react/src/components/checkout/payment/utils/billing-collection.test.ts @@ -2,14 +2,10 @@ import { describe, expect, it } from 'vitest'; import { DeliveryMethods } from '@/components/checkout/delivery/delivery-methods'; import { PaymentMethodType } from '@/types'; import { - type BillingCollectionContext, type BillingCollectionMode, type BillingPolicy, type BillingPolicyInput, - getBillingCollectionMode, getBillingPolicy, - getEffectiveBillingCollectionMode, - hasInlineBillingForm, } from './billing-collection'; const deliveryMethods = [ @@ -104,30 +100,16 @@ function getExpectedPolicy(input: BillingPolicyInput): BillingPolicy { return { mode, location: 'free-payment-form', usesShippingAddress }; } - if (hasInlineBillingForm(input.paymentMethod)) { + if ( + input.paymentMethod === PaymentMethodType.CREDIT_CARD || + input.paymentMethod === PaymentMethodType.ACH + ) { return { mode, location: 'inline-payment-form', usesShippingAddress }; } return { mode, location: 'top-level', usesShippingAddress }; } -describe('hasInlineBillingForm', () => { - it.each([ - [PaymentMethodType.CREDIT_CARD, true], - [PaymentMethodType.ACH, true], - [PaymentMethodType.OFFLINE, false], - [PaymentMethodType.PAYPAL, false], - [PaymentMethodType.APPLE_PAY, false], - ])('returns %s for %s', (paymentMethod, expected) => { - expect(hasInlineBillingForm(paymentMethod)).toBe(expected); - }); - - it('returns false for a missing payment method', () => { - expect(hasInlineBillingForm(null)).toBe(false); - expect(hasInlineBillingForm(undefined)).toBe(false); - }); -}); - describe('getBillingPolicy', () => { it('implements the authoritative matrix for every supported input combination', () => { everyPolicyCombination(input => { @@ -285,74 +267,3 @@ describe('getBillingPolicy', () => { }); }); }); - -describe('compatibility wrappers', () => { - it('returns the policy mode only for the requested collection context', () => { - const input = { - deliveryMethod: DeliveryMethods.PICKUP, - paymentMethod: PaymentMethodType.CREDIT_CARD, - paymentUseShippingAddress: false, - enableShipping: true, - enableShippingAddressCollection: true, - enableBillingAddressCollection: true, - enableTaxCollection: true, - }; - - expect( - getBillingCollectionMode({ ...input, context: 'top-level' }) - ).toBe('none'); - expect( - getBillingCollectionMode({ ...input, context: 'inline-payment-form' }) - ).toBe('address'); - expect( - getBillingCollectionMode({ ...input, context: 'free-payment-form' }) - ).toBe('names'); - }); - - it('returns the effective policy mode', () => { - everyPolicyCombination(input => { - expect( - getEffectiveBillingCollectionMode({ - deliveryMethod: input.deliveryMethod, - paymentMethod: input.paymentMethod, - paymentUseShippingAddress: input.paymentUseShippingAddress, - enableShipping: input.enableShipping, - enableShippingAddressCollection: - input.enableShippingAddressCollection, - enableBillingAddressCollection: - input.enableBillingAddressCollection, - enableTaxCollection: input.enableTaxCollection, - isFreeOrder: input.isFreeOrder, - }) - ).toBe(getBillingPolicy(input).mode); - }); - }); - - it('collects billing in exactly one active location', () => { - everyPolicyCombination(input => { - const contexts: BillingCollectionContext[] = input.isFreeOrder - ? ['free-payment-form'] - : ['top-level', 'inline-payment-form']; - const activeContextCount = contexts - .map(context => - getBillingCollectionMode({ - context, - deliveryMethod: input.deliveryMethod, - paymentMethod: input.paymentMethod, - paymentUseShippingAddress: input.paymentUseShippingAddress, - enableShipping: input.enableShipping, - enableShippingAddressCollection: - input.enableShippingAddressCollection, - enableBillingAddressCollection: - input.enableBillingAddressCollection, - enableTaxCollection: input.enableTaxCollection, - }) - ) - .filter(mode => mode !== 'none').length; - - expect(activeContextCount).toBe( - getBillingPolicy(input).mode === 'none' ? 0 : 1 - ); - }); - }); -}); diff --git a/packages/react/src/components/checkout/payment/utils/billing-collection.ts b/packages/react/src/components/checkout/payment/utils/billing-collection.ts index db167588..33f0eff0 100644 --- a/packages/react/src/components/checkout/payment/utils/billing-collection.ts +++ b/packages/react/src/components/checkout/payment/utils/billing-collection.ts @@ -1,6 +1,3 @@ -import { useFormContext } from 'react-hook-form'; -import type { CheckoutFormData } from '@/components/checkout/checkout'; -import { useCheckoutContext } from '@/components/checkout/checkout'; import { DeliveryMethods } from '@/components/checkout/delivery/delivery-methods'; import { PaymentMethodType, type PaymentMethodValue } from '@/types'; @@ -12,11 +9,6 @@ export type BillingCollectionLocation = | 'inline-payment-form' | 'free-payment-form'; -export type BillingCollectionContext = Exclude< - BillingCollectionLocation, - 'none' ->; - export type BillingPolicyInput = { isFreeOrder: boolean; paymentMethod?: PaymentMethodValue | string | null; @@ -91,7 +83,7 @@ function getPaidStandardBillingMode({ return enableBillingAddressCollection ? 'address' : 'names'; } -export function hasInlineBillingForm( +function isInlineBillingPaymentMethod( paymentMethod?: PaymentMethodValue | string | null ) { return Boolean( @@ -117,7 +109,7 @@ export function getBillingPolicy({ ? PaymentMethodType.OFFLINE : paymentMethod; const isOffline = effectivePaymentMethod === PaymentMethodType.OFFLINE; - const isInline = hasInlineBillingForm(effectivePaymentMethod); + const isInline = isInlineBillingPaymentMethod(effectivePaymentMethod); const mode = isOffline ? getOfflineBillingMode({ deliveryMethod, @@ -145,80 +137,3 @@ export function getBillingPolicy({ return { mode, location: 'top-level', usesShippingAddress }; } - -export function getBillingCollectionMode({ - context, - deliveryMethod, - paymentMethod, - paymentUseShippingAddress = true, - enableShipping = true, - enableShippingAddressCollection = true, - enableBillingAddressCollection = true, - enableTaxCollection = false, -}: { - context: BillingCollectionContext; - deliveryMethod?: DeliveryMethods | string | null; - paymentMethod?: PaymentMethodValue | string | null; - paymentUseShippingAddress?: boolean | null; - enableShipping?: boolean | null; - enableShippingAddressCollection?: boolean | null; - enableBillingAddressCollection?: boolean | null; - enableTaxCollection?: boolean | null; -}): BillingCollectionMode { - const policy = getBillingPolicy({ - isFreeOrder: context === 'free-payment-form', - deliveryMethod, - paymentMethod, - paymentUseShippingAddress: paymentUseShippingAddress !== false, - enableShipping: enableShipping !== false, - enableShippingAddressCollection: enableShippingAddressCollection !== false, - enableBillingAddressCollection: enableBillingAddressCollection !== false, - enableTaxCollection: enableTaxCollection === true, - }); - - return policy.location === context ? policy.mode : 'none'; -} - -export function getEffectiveBillingCollectionMode({ - isFreeOrder = false, - deliveryMethod, - paymentMethod, - paymentUseShippingAddress = true, - enableShipping = true, - enableShippingAddressCollection = true, - enableBillingAddressCollection = true, - enableTaxCollection = false, -}: Omit[0], 'context'> & { - isFreeOrder?: boolean; -}): BillingCollectionMode { - return getBillingPolicy({ - isFreeOrder, - deliveryMethod, - paymentMethod, - paymentUseShippingAddress: paymentUseShippingAddress !== false, - enableShipping: enableShipping !== false, - enableShippingAddressCollection: enableShippingAddressCollection !== false, - enableBillingAddressCollection: enableBillingAddressCollection !== false, - enableTaxCollection: enableTaxCollection === true, - }).mode; -} - -export function useBillingCollectionMode({ - context, -}: { - context: BillingCollectionContext; -}): BillingCollectionMode { - const form = useFormContext(); - const { session } = useCheckoutContext(); - - return getBillingCollectionMode({ - context, - deliveryMethod: form.watch('deliveryMethod'), - paymentMethod: form.watch('paymentMethod'), - paymentUseShippingAddress: form.watch('paymentUseShippingAddress'), - enableShipping: session?.enableShipping, - enableShippingAddressCollection: session?.enableShippingAddressCollection, - enableBillingAddressCollection: session?.enableBillingAddressCollection, - enableTaxCollection: session?.enableTaxCollection, - }); -} From 48012b4353eba8bafdf3a59332a19298b22c7a15 Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Wed, 19 Aug 2026 13:30:03 -0500 Subject: [PATCH 10/22] biome fix --- .../__tests__/checkout-form-validation.test.tsx | 10 ++++++++-- .../checkout/__tests__/checkout-shipping.test.tsx | 8 ++++++-- .../src/components/checkout/payment/payment-form.tsx | 2 +- .../checkout/payment/payment-methods/ach/godaddy.tsx | 2 +- .../checkout/payment/utils/billing-collection.test.ts | 5 +---- .../checkout/payment/utils/billing-collection.ts | 10 ++-------- 6 files changed, 19 insertions(+), 18 deletions(-) diff --git a/packages/react/src/components/checkout/__tests__/checkout-form-validation.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-form-validation.test.tsx index 0d3b820c..de18fc9a 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-form-validation.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-form-validation.test.tsx @@ -145,7 +145,10 @@ function ShippingPrefixedCustomFieldProbe() { return (
- +
); } @@ -523,7 +526,10 @@ describe('Checkout form validation', () => { }); expect(getOperations('ConfirmCheckoutSession')).toHaveLength(0); - await user.type(screen.getByLabelText(/shipping gift message/i), 'Gift wrap'); + await user.type( + screen.getByLabelText(/shipping gift message/i), + 'Gift wrap' + ); await user.click(await clickSubmitButton(/complete your free order/i)); await waitFor(() => { diff --git a/packages/react/src/components/checkout/__tests__/checkout-shipping.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-shipping.test.tsx index 216d2dd1..3a1ec601 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-shipping.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-shipping.test.tsx @@ -403,7 +403,9 @@ describe('Checkout shipping behavior', () => { }); it('collects neither shipping nor billing address UI when hidden shipping is reused as billing', async () => { - const sharedAddress = buildShippingAddress({ addressLine1: '1 Hidden Way' }); + const sharedAddress = buildShippingAddress({ + addressLine1: '1 Hidden Way', + }); const { user } = renderCheckout({ draftOrderOverrides: { shipping: { @@ -436,7 +438,9 @@ describe('Checkout shipping behavior', () => { await waitForCheckoutReady(); clearOperations(); - expect(screen.getByLabelText(/use shipping address as billing/i)).toBeChecked(); + expect( + screen.getByLabelText(/use shipping address as billing/i) + ).toBeChecked(); expect( document.querySelector('input[name="shippingAddressLine1"]') ).not.toBeInTheDocument(); diff --git a/packages/react/src/components/checkout/payment/payment-form.tsx b/packages/react/src/components/checkout/payment/payment-form.tsx index 2eaf395f..c1163bfe 100644 --- a/packages/react/src/components/checkout/payment/payment-form.tsx +++ b/packages/react/src/components/checkout/payment/payment-form.tsx @@ -35,8 +35,8 @@ import { } from '@/components/checkout/payment/payment-method-renderer'; import type { TokenizeJs } from '@/components/checkout/payment/types'; import { getApplicationId } from '@/components/checkout/payment/utils/get-application-id'; -import { useBillingPolicy } from '@/components/checkout/payment/utils/use-billing-policy'; import { PaymentAddressToggle } from '@/components/checkout/payment/utils/payment-address-toggle'; +import { useBillingPolicy } from '@/components/checkout/payment/utils/use-billing-policy'; import { useGetSelectedPaymentMethod } from '@/components/checkout/payment/utils/use-get-selected-payment-method'; import { useLoadPoyntCollect } from '@/components/checkout/payment/utils/use-load-poynt-collect'; import { Target } from '@/components/checkout/target/target'; diff --git a/packages/react/src/components/checkout/payment/payment-methods/ach/godaddy.tsx b/packages/react/src/components/checkout/payment/payment-methods/ach/godaddy.tsx index 1e5fce3f..fe2d27fd 100644 --- a/packages/react/src/components/checkout/payment/payment-methods/ach/godaddy.tsx +++ b/packages/react/src/components/checkout/payment/payment-methods/ach/godaddy.tsx @@ -11,8 +11,8 @@ import type { } from '@/components/checkout/payment/types'; import { getApplicationId } from '@/components/checkout/payment/utils/get-application-id'; import { PaymentAddressToggle } from '@/components/checkout/payment/utils/payment-address-toggle'; -import { useBillingPolicy } from '@/components/checkout/payment/utils/use-billing-policy'; import { usePoyntACHCollect } from '@/components/checkout/payment/utils/poynt-ach-provider'; +import { useBillingPolicy } from '@/components/checkout/payment/utils/use-billing-policy'; import { PaymentProvider, useConfirmCheckout, diff --git a/packages/react/src/components/checkout/payment/utils/billing-collection.test.ts b/packages/react/src/components/checkout/payment/utils/billing-collection.test.ts index d8379753..b37dbc15 100644 --- a/packages/react/src/components/checkout/payment/utils/billing-collection.test.ts +++ b/packages/react/src/components/checkout/payment/utils/billing-collection.test.ts @@ -59,10 +59,7 @@ function getExpectedMode({ enableBillingAddressCollection, enableTaxCollection, }: BillingPolicyInput): BillingCollectionMode { - if ( - deliveryMethod === DeliveryMethods.SHIP && - paymentUseShippingAddress - ) { + if (deliveryMethod === DeliveryMethods.SHIP && paymentUseShippingAddress) { return 'none'; } diff --git a/packages/react/src/components/checkout/payment/utils/billing-collection.ts b/packages/react/src/components/checkout/payment/utils/billing-collection.ts index 33f0eff0..c699d357 100644 --- a/packages/react/src/components/checkout/payment/utils/billing-collection.ts +++ b/packages/react/src/components/checkout/payment/utils/billing-collection.ts @@ -43,10 +43,7 @@ function getOfflineBillingMode({ | 'enableBillingAddressCollection' | 'enableTaxCollection' >): BillingCollectionMode { - if ( - deliveryMethod === DeliveryMethods.SHIP && - paymentUseShippingAddress - ) { + if (deliveryMethod === DeliveryMethods.SHIP && paymentUseShippingAddress) { return 'none'; } @@ -73,10 +70,7 @@ function getPaidStandardBillingMode({ | 'paymentUseShippingAddress' | 'enableBillingAddressCollection' >): BillingCollectionMode { - if ( - deliveryMethod === DeliveryMethods.SHIP && - paymentUseShippingAddress - ) { + if (deliveryMethod === DeliveryMethods.SHIP && paymentUseShippingAddress) { return 'none'; } From 89ab1440f3b17ba3a5729756336d49ed344227ec Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Wed, 19 Aug 2026 13:41:10 -0500 Subject: [PATCH 11/22] do not prevent tax call on zero order after discount --- .../checkout/discount/utils/use-discount-apply.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/react/src/components/checkout/discount/utils/use-discount-apply.ts b/packages/react/src/components/checkout/discount/utils/use-discount-apply.ts index da9db92f..dede884c 100644 --- a/packages/react/src/components/checkout/discount/utils/use-discount-apply.ts +++ b/packages/react/src/components/checkout/discount/utils/use-discount-apply.ts @@ -55,11 +55,7 @@ export function useDiscountApply() { ...old?.checkoutSession?.draftOrder, totals: { ...old?.checkoutSession?.draftOrder?.totals, - ...(responseData?.totals ?? {}), discountTotal, - total: - responseData?.totals?.total || - old?.checkoutSession?.draftOrder?.totals?.total, }, // Update order-level discounts discounts: @@ -140,10 +136,7 @@ export function useDiscountApply() { ); } - if ( - session?.enableTaxCollection && - (responseData?.totals?.total?.value ?? 0) > 0 - ) { + if (session?.enableTaxCollection) { // If the delivery method is pickup, we need to update taxes based on the pickup location // Otherwise, we can just update taxes without a specific address // TODO: Move this to API layer From 222590a76527ee5e72923d2cefd67e2fbbe686ac Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Wed, 19 Aug 2026 18:32:28 -0500 Subject: [PATCH 12/22] update billing policy for shipping scenarios --- .../checkout-form-validation.test.tsx | 2 + .../__tests__/checkout-shipping.test.tsx | 26 +- .../src/components/checkout/checkout.tsx | 11 - .../checkout/form/custom-form-provider.tsx | 222 +----------------- .../checkout/payment/free-payment-form.tsx | 12 +- .../checkout/payment/payment-form.tsx | 13 +- .../payment/payment-methods/ach/godaddy.tsx | 13 +- .../payment-methods/credit-card/container.tsx | 15 +- .../payment/utils/billing-collection.test.ts | 49 +++- .../payment/utils/billing-collection.ts | 75 ++++-- .../payment/utils/use-billing-policy.ts | 18 ++ 11 files changed, 159 insertions(+), 297 deletions(-) diff --git a/packages/react/src/components/checkout/__tests__/checkout-form-validation.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-form-validation.test.tsx index de18fc9a..48bad97b 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-form-validation.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-form-validation.test.tsx @@ -141,6 +141,7 @@ function BillingReuseProbe() { function ShippingPrefixedCustomFieldProbe() { const form = useFormContext(); + const error = form.formState.errors.shippingGiftMessage?.message; return (
@@ -149,6 +150,7 @@ function ShippingPrefixedCustomFieldProbe() { id='shipping-gift-message' {...form.register('shippingGiftMessage')} /> + {typeof error === 'string' ?

{error}

: null}
); } diff --git a/packages/react/src/components/checkout/__tests__/checkout-shipping.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-shipping.test.tsx index 3a1ec601..87061b35 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-shipping.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-shipping.test.tsx @@ -402,21 +402,21 @@ describe('Checkout shipping behavior', () => { await advanceCheckoutDebounce(0); }); - it('collects neither shipping nor billing address UI when hidden shipping is reused as billing', async () => { + it('collects billing when shipping address collection is disabled even if shipping is prefilled', async () => { const sharedAddress = buildShippingAddress({ addressLine1: '1 Hidden Way', }); - const { user } = renderCheckout({ + renderCheckout({ draftOrderOverrides: { shipping: { - firstName: '', - lastName: '', + firstName: 'Ship', + lastName: 'Buyer', phone: '', address: sharedAddress, }, billing: { - firstName: '', - lastName: '', + firstName: 'Bill', + lastName: 'Buyer', phone: '', address: sharedAddress, }, @@ -424,6 +424,7 @@ describe('Checkout shipping behavior', () => { sessionOverrides: { enableShipping: true, enableShippingAddressCollection: false, + enableBillingAddressCollection: true, enableLocalPickup: false, enableTaxCollection: false, paymentMethods: { @@ -436,23 +437,16 @@ describe('Checkout shipping behavior', () => { }, }); await waitForCheckoutReady(); - clearOperations(); expect( - screen.getByLabelText(/use shipping address as billing/i) - ).toBeChecked(); + screen.queryByLabelText(/use shipping address as billing/i) + ).not.toBeInTheDocument(); expect( document.querySelector('input[name="shippingAddressLine1"]') ).not.toBeInTheDocument(); expect( document.querySelector('input[name="billingAddressLine1"]') - ).not.toBeInTheDocument(); - - await user.click( - await screen.findByRole('button', { name: /complete your order/i }) - ); - - await waitForOperation('ConfirmCheckoutSession'); + ).toBeInTheDocument(); }); it('records a shipping-method fetch failure when rates are refetched', async () => { diff --git a/packages/react/src/components/checkout/checkout.tsx b/packages/react/src/components/checkout/checkout.tsx index b28a9432..bf532b01 100644 --- a/packages/react/src/components/checkout/checkout.tsx +++ b/packages/react/src/components/checkout/checkout.tsx @@ -113,12 +113,6 @@ interface CheckoutContextValue { checkoutErrors?: string[] | undefined; setCheckoutErrors: (error?: string[] | undefined) => void; requiredFields?: { [key: string]: boolean }; - /** - * Field names supplied through the `checkoutFormSchema` prop. Consumer rules - * must always be validated, even when the built-in conditional validation - * would skip that field for the current delivery/payment combination. - */ - customSchemaFields?: string[]; } export const checkoutContext = React.createContext({ @@ -291,10 +285,6 @@ export function Checkout(props: CheckoutProps) { return getRequiredFieldsFromSchema(validationAdapter.schema); }, [validationAdapter]); - const customSchemaFields = React.useMemo(() => { - return Object.keys(checkoutFormSchema ?? {}); - }, [checkoutFormSchema]); - if (!props.isLoading && !isLoadingJWT && !session) { return (
@@ -344,7 +334,6 @@ export function Checkout(props: CheckoutProps) { paypalConfig, ccavenueConfig, requiredFields, - customSchemaFields, isConfirmingCheckout, setIsConfirmingCheckout, checkoutErrors, diff --git a/packages/react/src/components/checkout/form/custom-form-provider.tsx b/packages/react/src/components/checkout/form/custom-form-provider.tsx index 7fb3ae31..aa7ee227 100644 --- a/packages/react/src/components/checkout/form/custom-form-provider.tsx +++ b/packages/react/src/components/checkout/form/custom-form-provider.tsx @@ -1,81 +1,7 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import type { FieldPath, UseFormReturn, UseFormTrigger } from 'react-hook-form'; +import React from 'react'; +import type { UseFormReturn } from 'react-hook-form'; import { FormProvider } from 'react-hook-form'; -import { useDraftOrderTotals } from '@/components/checkout/order/use-draft-order'; -import { resolveBillingPolicyForCheckoutState } from '@/components/checkout/payment/utils/use-billing-policy'; -import { type CheckoutFormData, useCheckoutContext } from '../checkout'; -import { DeliveryMethods } from '../delivery/delivery-method'; - -const SHIPPING_ADDRESS_FIELD_NAMES = new Set([ - 'shippingFirstName', - 'shippingLastName', - 'shippingAddressLine1', - 'shippingAddressLine2', - 'shippingAddressLine3', - 'shippingAdminArea4', - 'shippingAdminArea3', - 'shippingAdminArea2', - 'shippingAdminArea1', - 'shippingPostalCode', - 'shippingCountryCode', -]); - -const BILLING_ADDRESS_FIELD_NAMES = new Set([ - 'billingAddressLine1', - 'billingAddressLine2', - 'billingAddressLine3', - 'billingAdminArea4', - 'billingAdminArea3', - 'billingAdminArea2', - 'billingAdminArea1', - 'billingPostalCode', - 'billingCountryCode', -]); - -const BILLING_NAME_FIELD_NAMES = new Set([ - 'billingFirstName', - 'billingLastName', -]); - -const BUILT_IN_FIELD_NAMES = new Set([ - ...SHIPPING_ADDRESS_FIELD_NAMES, - ...BILLING_ADDRESS_FIELD_NAMES, - ...BILLING_NAME_FIELD_NAMES, - 'contactEmail', - 'deliveryMethod', - 'paymentUseShippingAddress', - 'shippingPhone', - 'shippingMethod', - 'billingPhone', - 'paymentCardNumber', - 'paymentCardNumberDisplay', - 'paymentCardType', - 'paymentExpiryDate', - 'paymentMonth', - 'paymentYear', - 'paymentSecurityCode', - 'paymentNameOnCard', - 'notes', - 'pickupDate', - 'pickupTime', - 'pickupLocationId', - 'pickupLeadTime', - 'pickupTimezone', - 'tipAmount', - 'tipPercentage', - 'paymentMethod', - 'stripePaymentIntent', - 'stripePaymentIntentId', -]); - -function getCustomErrorMessages(errors: Record) { - return Object.entries(errors).flatMap(([fieldName, error]) => { - if (BUILT_IN_FIELD_NAMES.has(fieldName)) return []; - - const message = (error as { message?: unknown })?.message; - return typeof message === 'string' && message ? [message] : []; - }); -} +import type { CheckoutFormData } from '../checkout'; export function CustomFormProvider< TFormValues extends Record = CheckoutFormData, @@ -83,145 +9,5 @@ export function CustomFormProvider< children, ...methods }: { children: React.ReactNode } & UseFormReturn) { - const methodsRef = React.useRef(methods); - const [, setForceUpdate] = useState({}); - const { session } = useCheckoutContext(); - const { data: totals } = useDraftOrderTotals(); - const sessionRef = React.useRef(session); - const totalsRef = React.useRef(totals); - - useEffect(() => { - methodsRef.current = methods; - sessionRef.current = session; - totalsRef.current = totals; - }); - - const enhancedMethods = useMemo(() => { - const enhancedTrigger: UseFormTrigger = async ( - name?: - | FieldPath - | ReadonlyArray> - | Array>, - options?: { shouldFocus?: boolean } - ) => { - try { - const currentMethods = methodsRef.current; - - const triggerOptions = { shouldFocus: true, ...options }; - - let result: boolean; - - if (name) { - result = await methods.trigger(name, triggerOptions); - } else { - const values = currentMethods.getValues(); - const isShipping = values.deliveryMethod === DeliveryMethods.SHIP; - const currentSession = sessionRef.current; - const policy = resolveBillingPolicyForCheckoutState({ - values: values as unknown as CheckoutFormData, - session: currentSession, - totals: totalsRef.current, - }); - - const shippingSectionIsCollectable = Boolean( - isShipping && currentSession?.enableShipping - ); - const shippingAddressIsCollectable = Boolean( - shippingSectionIsCollectable && - currentSession?.enableShippingAddressCollection - ); - const billingIsCollectable = policy.mode !== 'none'; - const billingAddressIsCollectable = policy.mode === 'address'; - const phoneIsCollectable = - currentSession?.enablePhoneCollection === true; - const notesAreCollectable = - currentSession?.enableNotesCollection === true; - - const isCollectable = (fieldName: string) => { - if (fieldName === 'shippingPhone') { - return shippingAddressIsCollectable && phoneIsCollectable; - } - if (fieldName === 'billingPhone') { - return billingIsCollectable && phoneIsCollectable; - } - if (SHIPPING_ADDRESS_FIELD_NAMES.has(fieldName)) { - return shippingAddressIsCollectable; - } - if (fieldName === 'shippingMethod') { - return shippingSectionIsCollectable; - } - if (BILLING_NAME_FIELD_NAMES.has(fieldName)) { - return billingIsCollectable; - } - if (BILLING_ADDRESS_FIELD_NAMES.has(fieldName)) { - return billingAddressIsCollectable; - } - if (fieldName === 'notes') { - return notesAreCollectable; - } - return true; - }; - - const registeredFields = Object.keys( - ( - currentMethods.control as unknown as { - _fields?: Record; - } - )._fields ?? {} - ); - const fieldNames = Array.from( - new Set([...Object.keys(values), ...registeredFields]) - ).filter(isCollectable) as Array>; - - result = await methods.trigger(fieldNames, triggerOptions); - - const customRegisteredFields = registeredFields.filter( - fieldName => !BUILT_IN_FIELD_NAMES.has(fieldName) - ) as Array>; - if (customRegisteredFields.length > 0) { - result = - (await methods.trigger(customRegisteredFields, { - shouldFocus: false, - })) && result; - } - } - - setTimeout(() => { - setForceUpdate({}); - }, 0); - - return result; - } catch { - return false; - } - }; - - const result = { - ...methods, - trigger: enhancedTrigger, - } as UseFormReturn; - - Object.defineProperty(result, 'formState', { - get: () => methodsRef.current.formState, - }); - - return result; - }, []); - - const customErrorMessages = getCustomErrorMessages( - methods.formState.errors as Record - ); - - return ( - - {children} - {customErrorMessages.length > 0 ? ( -
- {customErrorMessages.map(message => ( -

{message}

- ))} -
- ) : null} -
- ); + return {children}; } diff --git a/packages/react/src/components/checkout/payment/free-payment-form.tsx b/packages/react/src/components/checkout/payment/free-payment-form.tsx index 6f7387f1..01e196e1 100644 --- a/packages/react/src/components/checkout/payment/free-payment-form.tsx +++ b/packages/react/src/components/checkout/payment/free-payment-form.tsx @@ -3,9 +3,11 @@ import React from 'react'; import { useFormContext } from 'react-hook-form'; import { AddressForm } from '@/components/checkout/address/address-form'; import { useCheckoutContext } from '@/components/checkout/checkout'; -import { DeliveryMethods } from '@/components/checkout/delivery/delivery-methods'; import { PaymentAddressToggle } from '@/components/checkout/payment/utils/payment-address-toggle'; -import { useBillingPolicy } from '@/components/checkout/payment/utils/use-billing-policy'; +import { + useBillingPolicy, + useCanOfferShippingAddressAsBilling, +} from '@/components/checkout/payment/utils/use-billing-policy'; import { PaymentProvider, useConfirmCheckout, @@ -19,15 +21,13 @@ import { PaymentMethodType } from '@/types'; export function FreePaymentForm() { const { t } = useGoDaddyContext(); - const { session, setCheckoutErrors, isConfirmingCheckout } = - useCheckoutContext(); + const { setCheckoutErrors, isConfirmingCheckout } = useCheckoutContext(); const isPaymentDisabled = useIsPaymentDisabled(); const form = useFormContext(); const confirmCheckout = useConfirmCheckout(); const billingPolicy = useBillingPolicy(); - const isShipping = form.watch('deliveryMethod') === DeliveryMethods.SHIP; - const showAddressToggle = isShipping && session?.enableShipping !== false; + const showAddressToggle = useCanOfferShippingAddressAsBilling(); const handleSubmit = React.useCallback(async () => { const valid = await form.trigger(); diff --git a/packages/react/src/components/checkout/payment/payment-form.tsx b/packages/react/src/components/checkout/payment/payment-form.tsx index c1163bfe..5d59bd4b 100644 --- a/packages/react/src/components/checkout/payment/payment-form.tsx +++ b/packages/react/src/components/checkout/payment/payment-form.tsx @@ -36,7 +36,10 @@ import { import type { TokenizeJs } from '@/components/checkout/payment/types'; import { getApplicationId } from '@/components/checkout/payment/utils/get-application-id'; import { PaymentAddressToggle } from '@/components/checkout/payment/utils/payment-address-toggle'; -import { useBillingPolicy } from '@/components/checkout/payment/utils/use-billing-policy'; +import { + useBillingPolicy, + useCanOfferShippingAddressAsBilling, +} from '@/components/checkout/payment/utils/use-billing-policy'; import { useGetSelectedPaymentMethod } from '@/components/checkout/payment/utils/use-get-selected-payment-method'; import { useLoadPoyntCollect } from '@/components/checkout/payment/utils/use-load-poynt-collect'; import { Target } from '@/components/checkout/target/target'; @@ -99,11 +102,13 @@ export function PaymentForm( const paymentMethod = form.watch('paymentMethod'); const deliveryMethod = form.watch('deliveryMethod'); const isPickup = deliveryMethod === DeliveryMethods.PICKUP; - const isShipping = deliveryMethod === DeliveryMethods.SHIP; + const _isShipping = deliveryMethod === DeliveryMethods.SHIP; const billingPolicy = useBillingPolicy(); const selectedMethodUsesInlineBilling = paymentMethod === PaymentMethodType.CREDIT_CARD || paymentMethod === PaymentMethodType.ACH; + const canOfferShippingAddressAsBilling = + useCanOfferShippingAddressAsBilling(); const methodConfig = useGetSelectedPaymentMethod( paymentMethod as PaymentMethodValue ); @@ -532,9 +537,7 @@ export function PaymentForm( /> ) : null} - {isShipping && - session?.enableShipping && - !selectedMethodUsesInlineBilling ? ( + {canOfferShippingAddressAsBilling && !selectedMethodUsesInlineBilling ? ( ) : null} {shouldShowBilling ? ( diff --git a/packages/react/src/components/checkout/payment/payment-methods/ach/godaddy.tsx b/packages/react/src/components/checkout/payment/payment-methods/ach/godaddy.tsx index fe2d27fd..680c324b 100644 --- a/packages/react/src/components/checkout/payment/payment-methods/ach/godaddy.tsx +++ b/packages/react/src/components/checkout/payment/payment-methods/ach/godaddy.tsx @@ -4,7 +4,6 @@ import { AddressForm } from '@/components/checkout/address'; import { useCheckoutContext } from '@/components/checkout/checkout'; import { CheckoutSection } from '@/components/checkout/checkout-section'; import { CheckoutSectionHeader } from '@/components/checkout/checkout-section-header'; -import { DeliveryMethods } from '@/components/checkout/delivery/delivery-methods'; import type { TokenizeJs, TokenizeJsEvent, @@ -12,7 +11,10 @@ import type { import { getApplicationId } from '@/components/checkout/payment/utils/get-application-id'; import { PaymentAddressToggle } from '@/components/checkout/payment/utils/payment-address-toggle'; import { usePoyntACHCollect } from '@/components/checkout/payment/utils/poynt-ach-provider'; -import { useBillingPolicy } from '@/components/checkout/payment/utils/use-billing-policy'; +import { + useBillingPolicy, + useCanOfferShippingAddressAsBilling, +} from '@/components/checkout/payment/utils/use-billing-policy'; import { PaymentProvider, useConfirmCheckout, @@ -32,9 +34,9 @@ export function GoDaddyACHForm() { const form = useFormContext(); const paymentMethod = form.watch('paymentMethod'); - const deliveryMethod = form.watch('deliveryMethod'); - const isShipping = deliveryMethod === DeliveryMethods.SHIP; const billingPolicy = useBillingPolicy(); + const canOfferShippingAddressAsBilling = + useCanOfferShippingAddressAsBilling(); const shouldShowBilling = billingPolicy.location === 'inline-payment-form' && paymentMethod === PaymentMethodType.ACH && @@ -248,8 +250,7 @@ export function GoDaddyACHForm() { {error ? (

{error}

) : null} - {session?.enableShipping && - isShipping && + {canOfferShippingAddressAsBilling && paymentMethod === PaymentMethodType.ACH ? ( ) : null} diff --git a/packages/react/src/components/checkout/payment/payment-methods/credit-card/container.tsx b/packages/react/src/components/checkout/payment/payment-methods/credit-card/container.tsx index d9c596ff..171628ca 100644 --- a/packages/react/src/components/checkout/payment/payment-methods/credit-card/container.tsx +++ b/packages/react/src/components/checkout/payment/payment-methods/credit-card/container.tsx @@ -2,23 +2,23 @@ import type { ReactNode } from 'react'; import { useCallback } from 'react'; import { useFormContext } from 'react-hook-form'; import { AddressForm } from '@/components/checkout/address'; -import { useCheckoutContext } from '@/components/checkout/checkout'; import { CheckoutSection } from '@/components/checkout/checkout-section'; import { CheckoutSectionHeader } from '@/components/checkout/checkout-section-header'; -import { DeliveryMethods } from '@/components/checkout/delivery/delivery-methods'; import { PaymentAddressToggle } from '@/components/checkout/payment/utils/payment-address-toggle'; -import { useBillingPolicy } from '@/components/checkout/payment/utils/use-billing-policy'; +import { + useBillingPolicy, + useCanOfferShippingAddressAsBilling, +} from '@/components/checkout/payment/utils/use-billing-policy'; import { useGoDaddyContext } from '@/godaddy-provider'; import { PaymentMethodType } from '@/types'; export function CreditCardContainer({ children }: { children?: ReactNode }) { - const { session } = useCheckoutContext(); const form = useFormContext(); const { t } = useGoDaddyContext(); const paymentMethod = form.watch('paymentMethod'); - const deliveryMethod = form.watch('deliveryMethod'); - const isShipping = deliveryMethod === DeliveryMethods.SHIP; const billingPolicy = useBillingPolicy(); + const canOfferShippingAddressAsBilling = + useCanOfferShippingAddressAsBilling(); const shouldShowBilling = billingPolicy.location === 'inline-payment-form' && paymentMethod === PaymentMethodType.CREDIT_CARD && @@ -44,8 +44,7 @@ export function CreditCardContainer({ children }: { children?: ReactNode }) { <> {description &&
{description}
} {children} - {session?.enableShipping && - isShipping && + {canOfferShippingAddressAsBilling && paymentMethod === PaymentMethodType.CREDIT_CARD && ( )} diff --git a/packages/react/src/components/checkout/payment/utils/billing-collection.test.ts b/packages/react/src/components/checkout/payment/utils/billing-collection.test.ts index b37dbc15..03ea6866 100644 --- a/packages/react/src/components/checkout/payment/utils/billing-collection.test.ts +++ b/packages/react/src/components/checkout/payment/utils/billing-collection.test.ts @@ -56,10 +56,17 @@ function getExpectedMode({ paymentMethod, deliveryMethod, paymentUseShippingAddress, + enableShipping, + enableShippingAddressCollection, enableBillingAddressCollection, enableTaxCollection, }: BillingPolicyInput): BillingCollectionMode { - if (deliveryMethod === DeliveryMethods.SHIP && paymentUseShippingAddress) { + if ( + deliveryMethod === DeliveryMethods.SHIP && + enableShipping && + enableShippingAddressCollection && + paymentUseShippingAddress + ) { return 'none'; } @@ -86,6 +93,8 @@ function getExpectedPolicy(input: BillingPolicyInput): BillingPolicy { const mode = getExpectedMode(input); const usesShippingAddress = Boolean( input.deliveryMethod === DeliveryMethods.SHIP && + input.enableShipping && + input.enableShippingAddressCollection && input.paymentUseShippingAddress ); @@ -253,6 +262,44 @@ describe('getBillingPolicy', () => { } }); + it('does not reuse shipping as billing when shipping address collection is disabled', () => { + expect( + getBillingPolicy({ + isFreeOrder: false, + paymentMethod: PaymentMethodType.CREDIT_CARD, + deliveryMethod: DeliveryMethods.SHIP, + paymentUseShippingAddress: true, + enableShipping: true, + enableShippingAddressCollection: false, + enableBillingAddressCollection: true, + enableTaxCollection: true, + }) + ).toEqual({ + mode: 'address', + location: 'inline-payment-form', + usesShippingAddress: false, + }); + }); + + it('does not reuse shipping as billing when shipping is disabled', () => { + expect( + getBillingPolicy({ + isFreeOrder: false, + paymentMethod: PaymentMethodType.CREDIT_CARD, + deliveryMethod: DeliveryMethods.SHIP, + paymentUseShippingAddress: true, + enableShipping: false, + enableShippingAddressCollection: true, + enableBillingAddressCollection: true, + enableTaxCollection: true, + }) + ).toEqual({ + mode: 'address', + location: 'inline-payment-form', + usesShippingAddress: false, + }); + }); + it('never returns address mode when billing address collection is disabled', () => { everyPolicyCombination(input => { const policy = getBillingPolicy({ diff --git a/packages/react/src/components/checkout/payment/utils/billing-collection.ts b/packages/react/src/components/checkout/payment/utils/billing-collection.ts index c699d357..e25d4cb3 100644 --- a/packages/react/src/components/checkout/payment/utils/billing-collection.ts +++ b/packages/react/src/components/checkout/payment/utils/billing-collection.ts @@ -31,21 +31,46 @@ const INLINE_BILLING_PAYMENT_METHODS: PaymentMethodValue[] = [ PaymentMethodType.ACH, ]; -function getOfflineBillingMode({ +export function canOfferShippingAddressAsBilling({ deliveryMethod, + enableShipping, + enableShippingAddressCollection, +}: Pick< + BillingPolicyInput, + 'deliveryMethod' | 'enableShipping' | 'enableShippingAddressCollection' +>) { + return Boolean( + deliveryMethod === DeliveryMethods.SHIP && + enableShipping && + enableShippingAddressCollection + ); +} + +export function isUsingShippingAddressAsBilling({ paymentUseShippingAddress, - enableBillingAddressCollection, - enableTaxCollection, + ...input }: Pick< BillingPolicyInput, | 'deliveryMethod' | 'paymentUseShippingAddress' - | 'enableBillingAddressCollection' - | 'enableTaxCollection' ->): BillingCollectionMode { - if (deliveryMethod === DeliveryMethods.SHIP && paymentUseShippingAddress) { - return 'none'; - } + | 'enableShipping' + | 'enableShippingAddressCollection' +>) { + return canOfferShippingAddressAsBilling(input) && paymentUseShippingAddress; +} + +function getOfflineBillingMode({ + deliveryMethod, + usesShippingAddress, + enableBillingAddressCollection, + enableTaxCollection, +}: Pick< + BillingPolicyInput, + 'deliveryMethod' | 'enableBillingAddressCollection' | 'enableTaxCollection' +> & { + usesShippingAddress: boolean; +}): BillingCollectionMode { + if (usesShippingAddress) return 'none'; if (deliveryMethod === DeliveryMethods.PICKUP) return 'names'; @@ -61,18 +86,12 @@ function getOfflineBillingMode({ } function getPaidStandardBillingMode({ - deliveryMethod, - paymentUseShippingAddress, + usesShippingAddress, enableBillingAddressCollection, -}: Pick< - BillingPolicyInput, - | 'deliveryMethod' - | 'paymentUseShippingAddress' - | 'enableBillingAddressCollection' ->): BillingCollectionMode { - if (deliveryMethod === DeliveryMethods.SHIP && paymentUseShippingAddress) { - return 'none'; - } +}: Pick & { + usesShippingAddress: boolean; +}): BillingCollectionMode { + if (usesShippingAddress) return 'none'; return enableBillingAddressCollection ? 'address' : 'names'; } @@ -93,12 +112,17 @@ export function getBillingPolicy({ paymentMethod, deliveryMethod, paymentUseShippingAddress, + enableShipping, + enableShippingAddressCollection, enableBillingAddressCollection, enableTaxCollection, }: BillingPolicyInput): BillingPolicy { - const usesShippingAddress = Boolean( - deliveryMethod === DeliveryMethods.SHIP && paymentUseShippingAddress - ); + const usesShippingAddress = isUsingShippingAddressAsBilling({ + deliveryMethod, + paymentUseShippingAddress, + enableShipping, + enableShippingAddressCollection, + }); const effectivePaymentMethod = isFreeOrder ? PaymentMethodType.OFFLINE : paymentMethod; @@ -107,13 +131,12 @@ export function getBillingPolicy({ const mode = isOffline ? getOfflineBillingMode({ deliveryMethod, - paymentUseShippingAddress, + usesShippingAddress, enableBillingAddressCollection, enableTaxCollection, }) : getPaidStandardBillingMode({ - deliveryMethod, - paymentUseShippingAddress, + usesShippingAddress, enableBillingAddressCollection, }); diff --git a/packages/react/src/components/checkout/payment/utils/use-billing-policy.ts b/packages/react/src/components/checkout/payment/utils/use-billing-policy.ts index 1fb3a660..a6292958 100644 --- a/packages/react/src/components/checkout/payment/utils/use-billing-policy.ts +++ b/packages/react/src/components/checkout/payment/utils/use-billing-policy.ts @@ -6,6 +6,7 @@ import { isFreeOrderTotal } from '@/components/checkout/order/is-free-order'; import { useDraftOrderTotals } from '@/components/checkout/order/use-draft-order'; import { type BillingPolicy, + canOfferShippingAddressAsBilling, getBillingPolicy, } from '@/components/checkout/payment/utils/billing-collection'; import type { CheckoutSession, Totals } from '@/types'; @@ -32,6 +33,23 @@ export function resolveBillingPolicyForCheckoutState(input: { }); } +export function useCanOfferShippingAddressAsBilling() { + const form = useFormContext(); + const { session } = useCheckoutContext(); + const deliveryMethod = form.watch('deliveryMethod'); + + return useMemo( + () => + canOfferShippingAddressAsBilling({ + deliveryMethod, + enableShipping: session?.enableShipping !== false, + enableShippingAddressCollection: + session?.enableShippingAddressCollection !== false, + }), + [deliveryMethod, session] + ); +} + export function useBillingPolicy(): BillingPolicy { const form = useFormContext(); const { session } = useCheckoutContext(); From 29664cbe51429610494407f6cc7f3397a109a399 Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Wed, 19 Aug 2026 18:58:21 -0500 Subject: [PATCH 13/22] add tax fix for discounts on purchase and digital orders --- .../__tests__/checkout-discount.test.tsx | 40 +++++++++++++++++++ .../discount/utils/use-discount-apply.ts | 15 ++++++- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx index c20c8e12..6579da70 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx @@ -3,6 +3,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors'; import { + buildBillingAddress, clearOperations, flushPromises, getOperations, @@ -147,6 +148,45 @@ describe('Checkout discounts', () => { expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(0); }); + it.each(['PURCHASE', 'DIGITAL'] as const)( + 'recalculates taxes using the billing address when a coupon is applied to a %s order', + async fulfillmentMode => { + const billingAddress = buildBillingAddress({ + addressLine1: '123 Billing St', + adminArea2: 'Tempe', + adminArea1: 'AZ', + postalCode: '85281', + countryCode: 'US', + }); + const { user } = renderCheckout({ + draftOrderOverrides: { + billing: { + firstName: 'Bill', + lastName: 'Buyer', + address: billingAddress, + }, + lineItems: [{ fulfillmentMode }], + }, + sessionOverrides: { + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: true, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + await applyCoupon(user, 'onedollar'); + await waitForOperation('CalculateCheckoutSessionTaxes'); + + expect(getOperations('CalculateCheckoutSessionTaxes')).toContainEqual( + expect.objectContaining({ + input: { destination: billingAddress }, + }) + ); + } + ); + it('shows duplicate coupon validation without issuing a duplicate mutation', async () => { const { user } = renderCheckout({ draftOrderOverrides: { discounts: [{ code: 'onedollar' }] }, diff --git a/packages/react/src/components/checkout/discount/utils/use-discount-apply.ts b/packages/react/src/components/checkout/discount/utils/use-discount-apply.ts index dede884c..ae4016f5 100644 --- a/packages/react/src/components/checkout/discount/utils/use-discount-apply.ts +++ b/packages/react/src/components/checkout/discount/utils/use-discount-apply.ts @@ -56,6 +56,9 @@ export function useDiscountApply() { totals: { ...old?.checkoutSession?.draftOrder?.totals, discountTotal, + total: + responseData?.totals?.total || + old?.checkoutSession?.draftOrder?.totals?.total, }, // Update order-level discounts discounts: @@ -152,8 +155,18 @@ export function useDiscountApply() { if (locationAddress) { await updateTaxes.mutateAsync(locationAddress); } + } else if ( + deliveryMethod === DeliveryMethods.PURCHASE || + deliveryMethod === DeliveryMethods.DIGITAL + ) { + const billingAddress = draftOrder?.billing?.address; + const hasRequiredLocationData = + billingAddress?.postalCode && billingAddress?.countryCode; + + if (hasRequiredLocationData) { + await updateTaxes.mutateAsync(billingAddress); + } } else { - // Only update taxes if we have the required location data const hasRequiredLocationData = draftOrder?.shipping?.address?.postalCode && draftOrder?.shipping?.address?.countryCode; From bfb13f86a875e9628f796ff18559648c9b979d7c Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Wed, 19 Aug 2026 19:15:52 -0500 Subject: [PATCH 14/22] update billing policy for shipping flags --- .../payment/utils/use-billing-policy.test.ts | 142 ++++++++++++++++++ .../payment/utils/use-billing-policy.ts | 8 +- 2 files changed, 146 insertions(+), 4 deletions(-) create mode 100644 packages/react/src/components/checkout/payment/utils/use-billing-policy.test.ts diff --git a/packages/react/src/components/checkout/payment/utils/use-billing-policy.test.ts b/packages/react/src/components/checkout/payment/utils/use-billing-policy.test.ts new file mode 100644 index 00000000..32f2841f --- /dev/null +++ b/packages/react/src/components/checkout/payment/utils/use-billing-policy.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest'; +import { DeliveryMethods } from '@/components/checkout/delivery/delivery-methods'; +import { type CheckoutSession, PaymentMethodType, type Totals } from '@/types'; +import { resolveBillingPolicyForCheckoutState } from './use-billing-policy'; + +const paidTotals = { + total: { value: 1000, currencyCode: 'USD' }, +} as Totals; + +const freeTotals = { + total: { value: 0, currencyCode: 'USD' }, +} as Totals; + +const values = { + deliveryMethod: DeliveryMethods.SHIP, + paymentMethod: PaymentMethodType.CREDIT_CARD, + paymentUseShippingAddress: true, +}; + +function buildSession(overrides: Partial = {}) { + return { + enableShipping: true, + enableShippingAddressCollection: true, + enableBillingAddressCollection: true, + enableTaxCollection: true, + ...overrides, + } as CheckoutSession; +} + +describe('resolveBillingPolicyForCheckoutState', () => { + it.each([ + { + enableShipping: null, + enableShippingAddressCollection: true, + }, + { + enableShipping: true, + enableShippingAddressCollection: null, + }, + { + enableShipping: undefined, + enableShippingAddressCollection: undefined, + }, + ])( + 'does not reuse shipping when its collection flags are not explicitly enabled', + session => { + expect( + resolveBillingPolicyForCheckoutState({ + values, + session: buildSession(session), + totals: paidTotals, + }) + ).toEqual({ + mode: 'address', + location: 'inline-payment-form', + usesShippingAddress: false, + }); + } + ); + + it('reuses shipping when shipping and address collection are explicitly enabled', () => { + expect( + resolveBillingPolicyForCheckoutState({ + values, + session: buildSession(), + totals: paidTotals, + }) + ).toEqual({ + mode: 'none', + location: 'none', + usesShippingAddress: true, + }); + }); + + it('collects separate billing when the customer opts out of shipping reuse', () => { + expect( + resolveBillingPolicyForCheckoutState({ + values: { ...values, paymentUseShippingAddress: false }, + session: buildSession(), + totals: paidTotals, + }) + ).toEqual({ + mode: 'address', + location: 'inline-payment-form', + usesShippingAddress: false, + }); + }); + + it('uses free offline pickup rules when the total is zero', () => { + expect( + resolveBillingPolicyForCheckoutState({ + values: { + ...values, + deliveryMethod: DeliveryMethods.PICKUP, + paymentUseShippingAddress: false, + }, + session: buildSession(), + totals: freeTotals, + }) + ).toEqual({ + mode: 'names', + location: 'free-payment-form', + usesShippingAddress: false, + }); + }); + + it('uses free offline purchase rules when the total is missing', () => { + expect( + resolveBillingPolicyForCheckoutState({ + values: { + ...values, + deliveryMethod: DeliveryMethods.PURCHASE, + paymentUseShippingAddress: false, + }, + session: buildSession({ enableTaxCollection: false }), + totals: undefined, + }) + ).toEqual({ + mode: 'names', + location: 'free-payment-form', + usesShippingAddress: false, + }); + }); + + it('keeps a positive-total card order in the paid inline flow', () => { + expect( + resolveBillingPolicyForCheckoutState({ + values: { + ...values, + deliveryMethod: DeliveryMethods.PICKUP, + paymentUseShippingAddress: false, + }, + session: buildSession(), + totals: paidTotals, + }) + ).toEqual({ + mode: 'address', + location: 'inline-payment-form', + usesShippingAddress: false, + }); + }); +}); diff --git a/packages/react/src/components/checkout/payment/utils/use-billing-policy.ts b/packages/react/src/components/checkout/payment/utils/use-billing-policy.ts index a6292958..380ae874 100644 --- a/packages/react/src/components/checkout/payment/utils/use-billing-policy.ts +++ b/packages/react/src/components/checkout/payment/utils/use-billing-policy.ts @@ -24,9 +24,9 @@ export function resolveBillingPolicyForCheckoutState(input: { deliveryMethod: input.values.deliveryMethod, paymentMethod: input.values.paymentMethod, paymentUseShippingAddress: input.values.paymentUseShippingAddress !== false, - enableShipping: input.session?.enableShipping !== false, + enableShipping: input.session?.enableShipping === true, enableShippingAddressCollection: - input.session?.enableShippingAddressCollection !== false, + input.session?.enableShippingAddressCollection === true, enableBillingAddressCollection: input.session?.enableBillingAddressCollection !== false, enableTaxCollection: input.session?.enableTaxCollection === true, @@ -42,9 +42,9 @@ export function useCanOfferShippingAddressAsBilling() { () => canOfferShippingAddressAsBilling({ deliveryMethod, - enableShipping: session?.enableShipping !== false, + enableShipping: session?.enableShipping === true, enableShippingAddressCollection: - session?.enableShippingAddressCollection !== false, + session?.enableShippingAddressCollection === true, }), [deliveryMethod, session] ); From 6a7e6c781abb1be4ea9725fc1ef529624d6a9635 Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Wed, 19 Aug 2026 19:17:17 -0500 Subject: [PATCH 15/22] adjust changeset --- .changeset/clever-clubs-crash.md | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/.changeset/clever-clubs-crash.md b/.changeset/clever-clubs-crash.md index 8455f50d..336fb607 100644 --- a/.changeset/clever-clubs-crash.md +++ b/.changeset/clever-clubs-crash.md @@ -2,21 +2,9 @@ "@godaddy/react": patch --- -fix: align billing collection across rendering, validation, and the form schema +Fix billing collection across checkout flows. -- Validate paid offline orders with the payment-form rules instead of the free-order - rules, so a rendered billing address is actually required before confirming. -- Offline pickup collects names only, since taxes use the pickup location. -- Purchase-mode offline orders collect a billing address only when tax collection - needs a destination, and never when billing address collection is disabled. -- Free orders now follow the same billing rules as paid offline orders, fixing free - purchase-mode and separate-billing shipping orders that required billing fields the - form never rendered. -- Free shipping orders render the "use shipping address as billing" toggle, so they can - opt in or out of a separate billing address like paid orders already could. -- Changing payment or delivery method to a flow that only collects billing names now - clears the billing address from the draft order, instead of keeping one the customer - can no longer see. A billing address already present on the draft order at load is - left untouched. -- Validate the billing phone on offline pickup orders, which was silently skipped. -- Stop requiring a shipping address when `enableShippingAddressCollection` is false. +- Align billing fields and validation for paid, free, pickup, shipping, purchase, and digital orders. +- Respect billing, shipping, phone, and tax collection settings. +- Clear hidden billing addresses when switching to a names-only flow. +- Keep totals and taxes accurate when discounts are applied. From 3af347100a0c0357b2a46b24bfba8041287db34d Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Thu, 20 Aug 2026 10:40:14 -0500 Subject: [PATCH 16/22] use consts for billing mode and locations --- .../form/checkout-validation-adapter.ts | 13 ++- .../billing-policy-transition-controller.tsx | 5 +- .../checkout/payment/free-payment-form.tsx | 10 ++- .../checkout/payment/payment-form.tsx | 12 ++- .../payment/payment-methods/ach/godaddy.tsx | 13 ++- .../payment-methods/credit-card/container.tsx | 13 ++- .../payment/utils/billing-collection.test.ts | 82 +++++++++++++------ .../payment/utils/billing-collection.ts | 69 ++++++++++++---- .../payment/utils/use-billing-policy.test.ts | 28 ++++--- 9 files changed, 169 insertions(+), 76 deletions(-) diff --git a/packages/react/src/components/checkout/form/checkout-validation-adapter.ts b/packages/react/src/components/checkout/form/checkout-validation-adapter.ts index 60e5c3d3..f9c2910b 100644 --- a/packages/react/src/components/checkout/form/checkout-validation-adapter.ts +++ b/packages/react/src/components/checkout/form/checkout-validation-adapter.ts @@ -12,6 +12,7 @@ import type { CheckoutFormSchema, } from '@/components/checkout/checkout'; import { DeliveryMethods } from '@/components/checkout/delivery/delivery-methods'; +import { BillingCollectionModes } from '@/components/checkout/payment/utils/billing-collection'; import { resolveBillingPolicyForCheckoutState } from '@/components/checkout/payment/utils/use-billing-policy'; import type { CheckoutSession, Totals } from '@/types'; @@ -91,8 +92,9 @@ function isBuiltInConditionalFieldHidden( session, totals: context?.totals, }); - const billingIsCollectable = policy.mode !== 'none'; - const billingAddressIsCollectable = policy.mode === 'address'; + const billingIsCollectable = policy.mode !== BillingCollectionModes.NONE; + const billingAddressIsCollectable = + policy.mode === BillingCollectionModes.ADDRESS; const phoneIsCollectable = session?.enablePhoneCollection === true; const notesAreCollectable = session?.enableNotesCollection === true; @@ -187,12 +189,15 @@ export function createCheckoutSchema( totals: context?.totals, }); - if (policy.mode === 'names' || policy.mode === 'address') { + if ( + policy.mode === BillingCollectionModes.NAMES || + policy.mode === BillingCollectionModes.ADDRESS + ) { addRequiredIssue(ctx, data, 'billingFirstName', messages.enterFirstName); addRequiredIssue(ctx, data, 'billingLastName', messages.enterLastName); } - if (policy.mode === 'address') { + if (policy.mode === BillingCollectionModes.ADDRESS) { addRequiredIssue(ctx, data, 'billingAddressLine1', messages.enterAddress); addRequiredIssue(ctx, data, 'billingAdminArea2', messages.enterCity); addRequiredIssue( diff --git a/packages/react/src/components/checkout/payment/billing-policy-transition-controller.tsx b/packages/react/src/components/checkout/payment/billing-policy-transition-controller.tsx index 9902ef18..b0fb6d3e 100644 --- a/packages/react/src/components/checkout/payment/billing-policy-transition-controller.tsx +++ b/packages/react/src/components/checkout/payment/billing-policy-transition-controller.tsx @@ -6,6 +6,7 @@ import { useCheckoutContext, } from '@/components/checkout/checkout'; import { useDraftOrderTotals } from '@/components/checkout/order/use-draft-order'; +import { BillingCollectionModes } from '@/components/checkout/payment/utils/billing-collection'; import { useBillingPolicy } from '@/components/checkout/payment/utils/use-billing-policy'; export function BillingPolicyTransitionController(): null { @@ -46,8 +47,8 @@ export function BillingPolicyTransitionController(): null { } if ( - previousState.mode === 'address' && - policy.mode === 'names' && + previousState.mode === BillingCollectionModes.ADDRESS && + policy.mode === BillingCollectionModes.NAMES && (Boolean(previousState.paymentMethod) || form.getFieldState('paymentMethod').isDirty || form.getFieldState('deliveryMethod').isDirty || diff --git a/packages/react/src/components/checkout/payment/free-payment-form.tsx b/packages/react/src/components/checkout/payment/free-payment-form.tsx index 01e196e1..b34ac9fc 100644 --- a/packages/react/src/components/checkout/payment/free-payment-form.tsx +++ b/packages/react/src/components/checkout/payment/free-payment-form.tsx @@ -3,6 +3,10 @@ import React from 'react'; import { useFormContext } from 'react-hook-form'; import { AddressForm } from '@/components/checkout/address/address-form'; import { useCheckoutContext } from '@/components/checkout/checkout'; +import { + BillingCollectionLocations, + BillingCollectionModes, +} from '@/components/checkout/payment/utils/billing-collection'; import { PaymentAddressToggle } from '@/components/checkout/payment/utils/payment-address-toggle'; import { useBillingPolicy, @@ -74,8 +78,8 @@ export function FreePaymentForm() { ); const shouldShowBilling = - billingPolicy.location === 'free-payment-form' && - billingPolicy.mode !== 'none'; + billingPolicy.location === BillingCollectionLocations.FREE_PAYMENT_FORM && + billingPolicy.mode !== BillingCollectionModes.NONE; if (showAddressToggle || shouldShowBilling) { return ( @@ -84,7 +88,7 @@ export function FreePaymentForm() { {shouldShowBilling ? ( ) : null} {submitButton} diff --git a/packages/react/src/components/checkout/payment/payment-form.tsx b/packages/react/src/components/checkout/payment/payment-form.tsx index 5d59bd4b..a38dc70d 100644 --- a/packages/react/src/components/checkout/payment/payment-form.tsx +++ b/packages/react/src/components/checkout/payment/payment-form.tsx @@ -34,6 +34,10 @@ import { PaymentMethodRenderer, } from '@/components/checkout/payment/payment-method-renderer'; import type { TokenizeJs } from '@/components/checkout/payment/types'; +import { + BillingCollectionLocations, + BillingCollectionModes, +} from '@/components/checkout/payment/utils/billing-collection'; import { getApplicationId } from '@/components/checkout/payment/utils/get-application-id'; import { PaymentAddressToggle } from '@/components/checkout/payment/utils/payment-address-toggle'; import { @@ -299,9 +303,11 @@ export function PaymentForm( ]); const shouldShowBilling = - billingPolicy.location === 'top-level' && billingPolicy.mode !== 'none'; + billingPolicy.location === BillingCollectionLocations.TOP_LEVEL && + billingPolicy.mode !== BillingCollectionModes.NONE; const billingCopy = - billingPolicy.mode === 'names' && t.payment.billingInformation + billingPolicy.mode === BillingCollectionModes.NAMES && + t.payment.billingInformation ? t.payment.billingInformation : t.payment.billingAddress; @@ -548,7 +554,7 @@ export function PaymentForm( /> ) : null} diff --git a/packages/react/src/components/checkout/payment/payment-methods/ach/godaddy.tsx b/packages/react/src/components/checkout/payment/payment-methods/ach/godaddy.tsx index 680c324b..f62efa6c 100644 --- a/packages/react/src/components/checkout/payment/payment-methods/ach/godaddy.tsx +++ b/packages/react/src/components/checkout/payment/payment-methods/ach/godaddy.tsx @@ -8,6 +8,10 @@ import type { TokenizeJs, TokenizeJsEvent, } from '@/components/checkout/payment/types'; +import { + BillingCollectionLocations, + BillingCollectionModes, +} from '@/components/checkout/payment/utils/billing-collection'; import { getApplicationId } from '@/components/checkout/payment/utils/get-application-id'; import { PaymentAddressToggle } from '@/components/checkout/payment/utils/payment-address-toggle'; import { usePoyntACHCollect } from '@/components/checkout/payment/utils/poynt-ach-provider'; @@ -38,12 +42,13 @@ export function GoDaddyACHForm() { const canOfferShippingAddressAsBilling = useCanOfferShippingAddressAsBilling(); const shouldShowBilling = - billingPolicy.location === 'inline-payment-form' && + billingPolicy.location === BillingCollectionLocations.INLINE_PAYMENT_FORM && paymentMethod === PaymentMethodType.ACH && - billingPolicy.mode !== 'none'; + billingPolicy.mode !== BillingCollectionModes.NONE; const billingCopy = - billingPolicy.mode === 'names' && t.payment.billingInformation + billingPolicy.mode === BillingCollectionModes.NAMES && + t.payment.billingInformation ? t.payment.billingInformation : t.payment.billingAddress; @@ -262,7 +267,7 @@ export function GoDaddyACHForm() { /> ) : null} diff --git a/packages/react/src/components/checkout/payment/payment-methods/credit-card/container.tsx b/packages/react/src/components/checkout/payment/payment-methods/credit-card/container.tsx index 171628ca..00d5dc58 100644 --- a/packages/react/src/components/checkout/payment/payment-methods/credit-card/container.tsx +++ b/packages/react/src/components/checkout/payment/payment-methods/credit-card/container.tsx @@ -4,6 +4,10 @@ import { useFormContext } from 'react-hook-form'; import { AddressForm } from '@/components/checkout/address'; import { CheckoutSection } from '@/components/checkout/checkout-section'; import { CheckoutSectionHeader } from '@/components/checkout/checkout-section-header'; +import { + BillingCollectionLocations, + BillingCollectionModes, +} from '@/components/checkout/payment/utils/billing-collection'; import { PaymentAddressToggle } from '@/components/checkout/payment/utils/payment-address-toggle'; import { useBillingPolicy, @@ -20,12 +24,13 @@ export function CreditCardContainer({ children }: { children?: ReactNode }) { const canOfferShippingAddressAsBilling = useCanOfferShippingAddressAsBilling(); const shouldShowBilling = - billingPolicy.location === 'inline-payment-form' && + billingPolicy.location === BillingCollectionLocations.INLINE_PAYMENT_FORM && paymentMethod === PaymentMethodType.CREDIT_CARD && - billingPolicy.mode !== 'none'; + billingPolicy.mode !== BillingCollectionModes.NONE; const billingCopy = - billingPolicy.mode === 'names' && t.payment.billingInformation + billingPolicy.mode === BillingCollectionModes.NAMES && + t.payment.billingInformation ? t.payment.billingInformation : t.payment.billingAddress; @@ -56,7 +61,7 @@ export function CreditCardContainer({ children }: { children?: ReactNode }) { /> ) : null} diff --git a/packages/react/src/components/checkout/payment/utils/billing-collection.test.ts b/packages/react/src/components/checkout/payment/utils/billing-collection.test.ts index 03ea6866..a1115a39 100644 --- a/packages/react/src/components/checkout/payment/utils/billing-collection.test.ts +++ b/packages/react/src/components/checkout/payment/utils/billing-collection.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest'; import { DeliveryMethods } from '@/components/checkout/delivery/delivery-methods'; import { PaymentMethodType } from '@/types'; import { + BillingCollectionLocations, type BillingCollectionMode, + BillingCollectionModes, type BillingPolicy, type BillingPolicyInput, getBillingPolicy, @@ -67,7 +69,7 @@ function getExpectedMode({ enableShippingAddressCollection && paymentUseShippingAddress ) { - return 'none'; + return BillingCollectionModes.NONE; } const effectivePaymentMethod = isFreeOrder @@ -76,17 +78,23 @@ function getExpectedMode({ const isOffline = effectivePaymentMethod === PaymentMethodType.OFFLINE; if (isOffline) { - if (deliveryMethod === DeliveryMethods.PICKUP) return 'names'; + if (deliveryMethod === DeliveryMethods.PICKUP) { + return BillingCollectionModes.NAMES; + } if ( deliveryMethod === DeliveryMethods.PURCHASE || deliveryMethod === DeliveryMethods.DIGITAL ) { - if (!enableTaxCollection) return 'names'; - return enableBillingAddressCollection ? 'address' : 'names'; + if (!enableTaxCollection) return BillingCollectionModes.NAMES; + return enableBillingAddressCollection + ? BillingCollectionModes.ADDRESS + : BillingCollectionModes.NAMES; } } - return enableBillingAddressCollection ? 'address' : 'names'; + return enableBillingAddressCollection + ? BillingCollectionModes.ADDRESS + : BillingCollectionModes.NAMES; } function getExpectedPolicy(input: BillingPolicyInput): BillingPolicy { @@ -98,22 +106,38 @@ function getExpectedPolicy(input: BillingPolicyInput): BillingPolicy { input.paymentUseShippingAddress ); - if (mode === 'none') { - return { mode, location: 'none', usesShippingAddress }; + if (mode === BillingCollectionModes.NONE) { + return { + mode, + location: BillingCollectionLocations.NONE, + usesShippingAddress, + }; } if (input.isFreeOrder) { - return { mode, location: 'free-payment-form', usesShippingAddress }; + return { + mode, + location: BillingCollectionLocations.FREE_PAYMENT_FORM, + usesShippingAddress, + }; } if ( input.paymentMethod === PaymentMethodType.CREDIT_CARD || input.paymentMethod === PaymentMethodType.ACH ) { - return { mode, location: 'inline-payment-form', usesShippingAddress }; + return { + mode, + location: BillingCollectionLocations.INLINE_PAYMENT_FORM, + usesShippingAddress, + }; } - return { mode, location: 'top-level', usesShippingAddress }; + return { + mode, + location: BillingCollectionLocations.TOP_LEVEL, + usesShippingAddress, + }; } describe('getBillingPolicy', () => { @@ -149,8 +173,8 @@ describe('getBillingPolicy', () => { }) ); expect(getBillingPolicy(input)).toEqual({ - mode: 'names', - location: 'free-payment-form', + mode: BillingCollectionModes.NAMES, + location: BillingCollectionLocations.FREE_PAYMENT_FORM, usesShippingAddress: false, }); } @@ -178,8 +202,10 @@ describe('getBillingPolicy', () => { enableTaxCollection: false, }); - expect(policy.location).toBe('inline-payment-form'); - expect(policy.mode).toBe('address'); + expect(policy.location).toBe( + BillingCollectionLocations.INLINE_PAYMENT_FORM + ); + expect(policy.mode).toBe(BillingCollectionModes.ADDRESS); } } }); @@ -201,8 +227,8 @@ describe('getBillingPolicy', () => { enableTaxCollection: true, }); - expect(policy.location).toBe('top-level'); - expect(policy.mode).not.toBe('none'); + expect(policy.location).toBe(BillingCollectionLocations.TOP_LEVEL); + expect(policy.mode).not.toBe(BillingCollectionModes.NONE); } }); @@ -224,8 +250,10 @@ describe('getBillingPolicy', () => { enableTaxCollection: true, }); - expect(policy.location).toBe('free-payment-form'); - expect(policy.mode).not.toBe('none'); + expect(policy.location).toBe( + BillingCollectionLocations.FREE_PAYMENT_FORM + ); + expect(policy.mode).not.toBe(BillingCollectionModes.NONE); } }); @@ -233,8 +261,8 @@ describe('getBillingPolicy', () => { everyPolicyCombination(input => { const policy = getBillingPolicy(input); - if (policy.mode === 'none') { - expect(policy.location).toBe('none'); + if (policy.mode === BillingCollectionModes.NONE) { + expect(policy.location).toBe(BillingCollectionLocations.NONE); } }); }); @@ -254,8 +282,8 @@ describe('getBillingPolicy', () => { }); expect(policy).toEqual({ - mode: 'none', - location: 'none', + mode: BillingCollectionModes.NONE, + location: BillingCollectionLocations.NONE, usesShippingAddress: true, }); } @@ -275,8 +303,8 @@ describe('getBillingPolicy', () => { enableTaxCollection: true, }) ).toEqual({ - mode: 'address', - location: 'inline-payment-form', + mode: BillingCollectionModes.ADDRESS, + location: BillingCollectionLocations.INLINE_PAYMENT_FORM, usesShippingAddress: false, }); }); @@ -294,8 +322,8 @@ describe('getBillingPolicy', () => { enableTaxCollection: true, }) ).toEqual({ - mode: 'address', - location: 'inline-payment-form', + mode: BillingCollectionModes.ADDRESS, + location: BillingCollectionLocations.INLINE_PAYMENT_FORM, usesShippingAddress: false, }); }); @@ -307,7 +335,7 @@ describe('getBillingPolicy', () => { enableBillingAddressCollection: false, }); - expect(policy.mode).not.toBe('address'); + expect(policy.mode).not.toBe(BillingCollectionModes.ADDRESS); }); }); }); diff --git a/packages/react/src/components/checkout/payment/utils/billing-collection.ts b/packages/react/src/components/checkout/payment/utils/billing-collection.ts index e25d4cb3..a990f27e 100644 --- a/packages/react/src/components/checkout/payment/utils/billing-collection.ts +++ b/packages/react/src/components/checkout/payment/utils/billing-collection.ts @@ -1,13 +1,24 @@ import { DeliveryMethods } from '@/components/checkout/delivery/delivery-methods'; import { PaymentMethodType, type PaymentMethodValue } from '@/types'; -export type BillingCollectionMode = 'none' | 'names' | 'address'; +export const BillingCollectionModes = { + NONE: 'none', + NAMES: 'names', + ADDRESS: 'address', +} as const; + +export type BillingCollectionMode = + (typeof BillingCollectionModes)[keyof typeof BillingCollectionModes]; + +export const BillingCollectionLocations = { + NONE: 'none', + TOP_LEVEL: 'top-level', + INLINE_PAYMENT_FORM: 'inline-payment-form', + FREE_PAYMENT_FORM: 'free-payment-form', +} as const; export type BillingCollectionLocation = - | 'none' - | 'top-level' - | 'inline-payment-form' - | 'free-payment-form'; + (typeof BillingCollectionLocations)[keyof typeof BillingCollectionLocations]; export type BillingPolicyInput = { isFreeOrder: boolean; @@ -70,19 +81,25 @@ function getOfflineBillingMode({ > & { usesShippingAddress: boolean; }): BillingCollectionMode { - if (usesShippingAddress) return 'none'; + if (usesShippingAddress) return BillingCollectionModes.NONE; - if (deliveryMethod === DeliveryMethods.PICKUP) return 'names'; + if (deliveryMethod === DeliveryMethods.PICKUP) { + return BillingCollectionModes.NAMES; + } if ( deliveryMethod === DeliveryMethods.PURCHASE || deliveryMethod === DeliveryMethods.DIGITAL ) { - if (!enableTaxCollection) return 'names'; - return enableBillingAddressCollection ? 'address' : 'names'; + if (!enableTaxCollection) return BillingCollectionModes.NAMES; + return enableBillingAddressCollection + ? BillingCollectionModes.ADDRESS + : BillingCollectionModes.NAMES; } - return enableBillingAddressCollection ? 'address' : 'names'; + return enableBillingAddressCollection + ? BillingCollectionModes.ADDRESS + : BillingCollectionModes.NAMES; } function getPaidStandardBillingMode({ @@ -91,9 +108,11 @@ function getPaidStandardBillingMode({ }: Pick & { usesShippingAddress: boolean; }): BillingCollectionMode { - if (usesShippingAddress) return 'none'; + if (usesShippingAddress) return BillingCollectionModes.NONE; - return enableBillingAddressCollection ? 'address' : 'names'; + return enableBillingAddressCollection + ? BillingCollectionModes.ADDRESS + : BillingCollectionModes.NAMES; } function isInlineBillingPaymentMethod( @@ -140,17 +159,33 @@ export function getBillingPolicy({ enableBillingAddressCollection, }); - if (mode === 'none') { - return { mode, location: 'none', usesShippingAddress }; + if (mode === BillingCollectionModes.NONE) { + return { + mode, + location: BillingCollectionLocations.NONE, + usesShippingAddress, + }; } if (isFreeOrder) { - return { mode, location: 'free-payment-form', usesShippingAddress }; + return { + mode, + location: BillingCollectionLocations.FREE_PAYMENT_FORM, + usesShippingAddress, + }; } if (isInline) { - return { mode, location: 'inline-payment-form', usesShippingAddress }; + return { + mode, + location: BillingCollectionLocations.INLINE_PAYMENT_FORM, + usesShippingAddress, + }; } - return { mode, location: 'top-level', usesShippingAddress }; + return { + mode, + location: BillingCollectionLocations.TOP_LEVEL, + usesShippingAddress, + }; } diff --git a/packages/react/src/components/checkout/payment/utils/use-billing-policy.test.ts b/packages/react/src/components/checkout/payment/utils/use-billing-policy.test.ts index 32f2841f..57903a84 100644 --- a/packages/react/src/components/checkout/payment/utils/use-billing-policy.test.ts +++ b/packages/react/src/components/checkout/payment/utils/use-billing-policy.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest'; import { DeliveryMethods } from '@/components/checkout/delivery/delivery-methods'; +import { + BillingCollectionLocations, + BillingCollectionModes, +} from '@/components/checkout/payment/utils/billing-collection'; import { type CheckoutSession, PaymentMethodType, type Totals } from '@/types'; import { resolveBillingPolicyForCheckoutState } from './use-billing-policy'; @@ -51,8 +55,8 @@ describe('resolveBillingPolicyForCheckoutState', () => { totals: paidTotals, }) ).toEqual({ - mode: 'address', - location: 'inline-payment-form', + mode: BillingCollectionModes.ADDRESS, + location: BillingCollectionLocations.INLINE_PAYMENT_FORM, usesShippingAddress: false, }); } @@ -66,8 +70,8 @@ describe('resolveBillingPolicyForCheckoutState', () => { totals: paidTotals, }) ).toEqual({ - mode: 'none', - location: 'none', + mode: BillingCollectionModes.NONE, + location: BillingCollectionLocations.NONE, usesShippingAddress: true, }); }); @@ -80,8 +84,8 @@ describe('resolveBillingPolicyForCheckoutState', () => { totals: paidTotals, }) ).toEqual({ - mode: 'address', - location: 'inline-payment-form', + mode: BillingCollectionModes.ADDRESS, + location: BillingCollectionLocations.INLINE_PAYMENT_FORM, usesShippingAddress: false, }); }); @@ -98,8 +102,8 @@ describe('resolveBillingPolicyForCheckoutState', () => { totals: freeTotals, }) ).toEqual({ - mode: 'names', - location: 'free-payment-form', + mode: BillingCollectionModes.NAMES, + location: BillingCollectionLocations.FREE_PAYMENT_FORM, usesShippingAddress: false, }); }); @@ -116,8 +120,8 @@ describe('resolveBillingPolicyForCheckoutState', () => { totals: undefined, }) ).toEqual({ - mode: 'names', - location: 'free-payment-form', + mode: BillingCollectionModes.NAMES, + location: BillingCollectionLocations.FREE_PAYMENT_FORM, usesShippingAddress: false, }); }); @@ -134,8 +138,8 @@ describe('resolveBillingPolicyForCheckoutState', () => { totals: paidTotals, }) ).toEqual({ - mode: 'address', - location: 'inline-payment-form', + mode: BillingCollectionModes.ADDRESS, + location: BillingCollectionLocations.INLINE_PAYMENT_FORM, usesShippingAddress: false, }); }); From b10a141eb10e19def13a101ed461b90782d098cc Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Thu, 20 Aug 2026 21:23:23 -0500 Subject: [PATCH 17/22] add additional billing tests and do not treat null total as free --- .../__tests__/checkout-billing.test.tsx | 37 +++++++++++++++++++ .../checkout-free-payment-form.test.tsx | 19 +++------- .../checkout/order/is-free-order.ts | 6 +-- .../payment/utils/billing-collection.test.ts | 9 ++--- .../payment/utils/use-billing-policy.test.ts | 6 +-- 5 files changed, 52 insertions(+), 25 deletions(-) diff --git a/packages/react/src/components/checkout/__tests__/checkout-billing.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-billing.test.tsx index b1949a41..9e9a0083 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-billing.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-billing.test.tsx @@ -309,6 +309,43 @@ describe('Checkout billing behavior', () => { ).not.toBeChecked(); }); + it('does not resync matching billing when switching to shipping', async () => { + const address = buildShippingAddress({ addressLine1: '10 Shared St' }); + const contact = { + firstName: 'Same', + lastName: 'Buyer', + phone: '+12015550123', + address, + }; + const draftOrder = buildDraftOrder({ + lineItems: [{ fulfillmentMode: 'PICKUP' }], + shipping: contact, + billing: contact, + }); + const { user } = renderCheckout({ + draftOrder, + session: buildCheckoutSession({ + draftOrder, + enableShipping: true, + enableLocalPickup: true, + }), + }); + await waitForCheckoutReady(); + clearOperations(); + + await user.click(screen.getByRole('radio', { name: /^shipping/i })); + + expect( + await screen.findByLabelText(/use shipping address as billing/i) + ).toBeChecked(); + await advanceCheckoutDebounce(); + expect( + getOperations('UpdateCheckoutSessionDraftOrder').some(operation => + Object.hasOwn(operation.input as object, 'billing') + ) + ).toBe(false); + }); + it('clears a collected billing address when switching to offline pickup hides it', async () => { const draftOrder = buildDraftOrder({ lineItems: [{ fulfillmentMode: 'PICKUP' }], diff --git a/packages/react/src/components/checkout/__tests__/checkout-free-payment-form.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-free-payment-form.test.tsx index 11536409..0eb2eee6 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-free-payment-form.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-free-payment-form.test.tsx @@ -368,7 +368,7 @@ describe('Checkout FreePaymentForm integration', () => { expect(getOperations('ConfirmCheckoutSession')).toHaveLength(0); }); - it('treats a missing order total as free in both the rendered form and validation', async () => { + it('does not treat a missing order total as free', async () => { const draftOrder = buildFreeDraftOrder({ lineItems: [{ fulfillmentMode: 'PURCHASE' }], totals: { total: null }, @@ -380,21 +380,14 @@ describe('Checkout FreePaymentForm integration', () => { enableTaxCollection: false, }); - const { user } = renderCheckout({ session, draftOrder }); + renderCheckout({ session, draftOrder }); await waitForCheckoutReady(); expect( - screen.getByRole('button', { name: /complete your free order/i }) + screen.queryByRole('button', { name: /complete your free order/i }) + ).not.toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /pay now/i }) ).toBeInTheDocument(); - - // Rendering and validation must agree on "free", otherwise the button - // validates fields that were never rendered and silently does nothing. - await submitFreeOrder(user); - - expect(getLastConfirmInput()).toMatchObject({ - paymentToken: '', - paymentType: 'offline', - paymentProvider: 'OFFLINE', - }); }); }); diff --git a/packages/react/src/components/checkout/order/is-free-order.ts b/packages/react/src/components/checkout/order/is-free-order.ts index 5db653c8..28a77215 100644 --- a/packages/react/src/components/checkout/order/is-free-order.ts +++ b/packages/react/src/components/checkout/order/is-free-order.ts @@ -3,11 +3,11 @@ import type { Totals } from '@/types'; /** * Single definition of "free order" so the rendered payment form, the trigger - * field filter, and the schema all agree. A missing total is treated as free to - * match the order summary, which renders an absent total as 0. + * field filter, and the schema all agree. */ export function isFreeOrderTotal(totals?: Totals | null): boolean { - return (totals?.total?.value ?? 0) <= 0; + const totalValue = totals?.total?.value; + return typeof totalValue === 'number' && totalValue <= 0; } export function useIsFreeOrder(): boolean { diff --git a/packages/react/src/components/checkout/payment/utils/billing-collection.test.ts b/packages/react/src/components/checkout/payment/utils/billing-collection.test.ts index a1115a39..c9c8e1eb 100644 --- a/packages/react/src/components/checkout/payment/utils/billing-collection.test.ts +++ b/packages/react/src/components/checkout/payment/utils/billing-collection.test.ts @@ -16,12 +16,9 @@ const deliveryMethods = [ DeliveryMethods.PURCHASE, DeliveryMethods.DIGITAL, ]; -const paymentMethods = [ - PaymentMethodType.CREDIT_CARD, - PaymentMethodType.ACH, - PaymentMethodType.OFFLINE, - PaymentMethodType.PAYPAL, -]; +const paymentMethods = Object.values(PaymentMethodType).filter( + paymentMethod => paymentMethod !== PaymentMethodType.EXPRESS +); const flags = [true, false]; function everyPolicyCombination(callback: (input: BillingPolicyInput) => void) { diff --git a/packages/react/src/components/checkout/payment/utils/use-billing-policy.test.ts b/packages/react/src/components/checkout/payment/utils/use-billing-policy.test.ts index 57903a84..4aa396c1 100644 --- a/packages/react/src/components/checkout/payment/utils/use-billing-policy.test.ts +++ b/packages/react/src/components/checkout/payment/utils/use-billing-policy.test.ts @@ -108,7 +108,7 @@ describe('resolveBillingPolicyForCheckoutState', () => { }); }); - it('uses free offline purchase rules when the total is missing', () => { + it('does not treat a missing total as a free order', () => { expect( resolveBillingPolicyForCheckoutState({ values: { @@ -120,8 +120,8 @@ describe('resolveBillingPolicyForCheckoutState', () => { totals: undefined, }) ).toEqual({ - mode: BillingCollectionModes.NAMES, - location: BillingCollectionLocations.FREE_PAYMENT_FORM, + mode: BillingCollectionModes.ADDRESS, + location: BillingCollectionLocations.INLINE_PAYMENT_FORM, usesShippingAddress: false, }); }); From c7c58852dc375ab1d49c6881cc9d4ac65bb5260f Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Fri, 21 Aug 2026 08:37:45 -0500 Subject: [PATCH 18/22] add billing address to stripe payments --- .../utils/use-build-payment-request.test.tsx | 17 +++++++++++- .../utils/use-build-payment-request.ts | 26 ++++--------------- .../payment/utils/use-stripe-checkout.ts | 6 +++-- 3 files changed, 25 insertions(+), 24 deletions(-) diff --git a/packages/react/src/components/checkout/payment/utils/use-build-payment-request.test.tsx b/packages/react/src/components/checkout/payment/utils/use-build-payment-request.test.tsx index 6e7fdcd2..190020e0 100644 --- a/packages/react/src/components/checkout/payment/utils/use-build-payment-request.test.tsx +++ b/packages/react/src/components/checkout/payment/utils/use-build-payment-request.test.tsx @@ -124,7 +124,7 @@ async function renderUseBuildPaymentRequest({ } describe('useBuildPaymentRequest', () => { - it('builds Apple Pay, Google Pay, and PayPal request shapes from draft-order totals', async () => { + it('builds payment request shapes from draft-order totals and billing details', async () => { const lineItem = buildLineItem({ name: 'Coffee Mug', quantity: 2, @@ -262,6 +262,21 @@ describe('useBuildPaymentRequest', () => { ]) ); + expect(requests.stripePaymentMethodParams).toEqual({ + billing_details: { + name: 'Bill Buyer', + email: 'bill@example.com', + phone: '+12015550124', + address: { + line1: '1 Billing Way', + line2: 'Suite 3', + city: 'Tempe', + state: 'AZ', + postal_code: '85284', + country: 'US', + }, + }, + }); expect(requests.payPalRequest.purchase_units[0]).toMatchObject({ payee: { merchant_id: 'MERCHANTID123' }, amount: { diff --git a/packages/react/src/components/checkout/payment/utils/use-build-payment-request.ts b/packages/react/src/components/checkout/payment/utils/use-build-payment-request.ts index ecae4af0..ae72db8c 100644 --- a/packages/react/src/components/checkout/payment/utils/use-build-payment-request.ts +++ b/packages/react/src/components/checkout/payment/utils/use-build-payment-request.ts @@ -1,7 +1,4 @@ -import type { - CreateTokenCardData, - PaymentMethodCreateParams, -} from '@stripe/stripe-js'; +import type { PaymentMethodCreateParams } from '@stripe/stripe-js'; import { useMemo } from 'react'; import { useCheckoutContext } from '@/components/checkout/checkout'; import { @@ -174,8 +171,7 @@ export function useBuildPaymentRequest(): { applePayRequest: ApplePayRequest; googlePayRequest: GooglePayRequest; payPalRequest: PayPalRequest; - stripePaymentCardRequest: CreateTokenCardData; - stripePaymentExpressRequest: PaymentMethodCreateParams; + stripePaymentMethodParams: PaymentMethodCreateParams; poyntCardRequest: PoyntCardRequest; poyntExpressRequest: PoyntExpressRequest; poyntStandardRequest: PoyntStandardRequest; @@ -505,24 +501,13 @@ export function useBuildPaymentRequest(): { ], }; - const stripePaymentCardRequest: CreateTokenCardData = { - name: - `${order?.billing?.firstName || ''} ${order?.billing?.lastName || ''}`.trim() || - undefined, - address_line1: order?.billing?.address?.addressLine1 || undefined, - address_line2: order?.billing?.address?.addressLine2 || undefined, - address_city: order?.billing?.address?.adminArea2 || undefined, - address_state: order?.billing?.address?.adminArea1 || undefined, - address_zip: order?.billing?.address?.postalCode || undefined, - address_country: order?.billing?.address?.countryCode || undefined, - }; - - const stripePaymentExpressRequest: PaymentMethodCreateParams = { + const stripePaymentMethodParams: PaymentMethodCreateParams = { billing_details: { name: `${order?.billing?.firstName || ''} ${order?.billing?.lastName || ''}`.trim() || undefined, email: order?.billing?.email || undefined, + phone: order?.billing?.phone || undefined, address: { line1: order?.billing?.address?.addressLine1 || undefined, line2: order?.billing?.address?.addressLine2 || undefined, @@ -654,8 +639,7 @@ export function useBuildPaymentRequest(): { applePayRequest, googlePayRequest, payPalRequest, - stripePaymentCardRequest, - stripePaymentExpressRequest, + stripePaymentMethodParams, poyntCardRequest, poyntExpressRequest, poyntStandardRequest, diff --git a/packages/react/src/components/checkout/payment/utils/use-stripe-checkout.ts b/packages/react/src/components/checkout/payment/utils/use-stripe-checkout.ts index d3bd358e..2538a4b2 100644 --- a/packages/react/src/components/checkout/payment/utils/use-stripe-checkout.ts +++ b/packages/react/src/components/checkout/payment/utils/use-stripe-checkout.ts @@ -42,7 +42,7 @@ export function useStripeCheckout({ mode }: UseStripeCheckoutOptions) { const confirmCheckout = useConfirmCheckout(); const confirmExpressCheckout = useConfirmExpressCheckout(); const { setCheckoutErrors } = useCheckoutContext(); - const { stripePaymentExpressRequest } = useBuildPaymentRequest(); + const { stripePaymentMethodParams } = useBuildPaymentRequest(); const [isProcessingPayment, setIsProcessingPayment] = useState(false); const handleSubmit = useCallback( @@ -61,6 +61,7 @@ export function useStripeCheckout({ mode }: UseStripeCheckoutOptions) { } const { paymentMethod, error } = await stripe.createPaymentMethod({ + ...stripePaymentMethodParams, card: cardElement, type: 'card', }); @@ -91,7 +92,7 @@ export function useStripeCheckout({ mode }: UseStripeCheckoutOptions) { if (mode === 'express') { const { error, paymentMethod } = await stripe.createPaymentMethod({ elements, - params: stripePaymentExpressRequest, + params: stripePaymentMethodParams, }); if (error) { @@ -232,6 +233,7 @@ export function useStripeCheckout({ mode }: UseStripeCheckoutOptions) { confirmCheckout.mutateAsync, confirmExpressCheckout.mutateAsync, setCheckoutErrors, + stripePaymentMethodParams, ] ); From d84517455057faab47cf178a2d283906bc93ea2a Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Fri, 21 Aug 2026 10:09:54 -0500 Subject: [PATCH 19/22] adjust sku and order hydration and refetches --- .../__tests__/checkout-billing.test.tsx | 33 ++++++++++ .../__tests__/checkout-discount.test.tsx | 23 +++++++ .../checkout-refetch-hydration.test.tsx | 62 +++++++++++++++++++ .../discount/utils/use-discount-apply.ts | 30 ++++----- .../checkout/form/checkout-form-container.tsx | 6 +- .../order/use-draft-order-products.ts | 51 ++++++++++++++- .../checkout/order/use-draft-order.ts | 3 +- .../billing-policy-transition-controller.tsx | 8 ++- 8 files changed, 193 insertions(+), 23 deletions(-) diff --git a/packages/react/src/components/checkout/__tests__/checkout-billing.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-billing.test.tsx index 9e9a0083..f8d61774 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-billing.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-billing.test.tsx @@ -346,6 +346,39 @@ describe('Checkout billing behavior', () => { ).toBe(false); }); + it('does not clear billing when switching to offline without a collected address', async () => { + const draftOrder = buildDraftOrder({ + lineItems: [{ fulfillmentMode: 'PICKUP' }], + billing: { + firstName: '', + lastName: '', + address: null, + }, + }); + const { user } = renderCheckout({ + draftOrder, + session: buildCheckoutSession({ + draftOrder, + enableShipping: false, + enableLocalPickup: true, + enableTaxCollection: true, + paymentMethods: { + card: { processor: 'godaddy', checkoutTypes: ['standard'] } as never, + offline: { processor: 'offline', checkoutTypes: ['standard'] }, + }, + }), + }); + await waitForCheckoutReady(); + clearOperations(); + + await user.click( + await screen.findByRole('button', { name: /offline payments/i }) + ); + await advanceCheckoutDebounce(); + + expect(getOperations('UpdateCheckoutSessionDraftOrder')).toHaveLength(0); + }); + it('clears a collected billing address when switching to offline pickup hides it', async () => { const draftOrder = buildDraftOrder({ lineItems: [{ fulfillmentMode: 'PICKUP' }], diff --git a/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx index 6579da70..b49196d8 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx @@ -148,6 +148,29 @@ describe('Checkout discounts', () => { expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(0); }); + it('refetches the draft order when taxes cannot be recalculated without a billing address', async () => { + const { user } = renderCheckout({ + draftOrderOverrides: { + billing: { address: null }, + lineItems: [{ fulfillmentMode: 'PURCHASE' }], + }, + sessionOverrides: { + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: true, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + await applyCoupon(user, 'onedollar'); + await waitForOperation('ApplyCheckoutSessionDiscount'); + await waitForOperation('DraftOrder'); + + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(0); + expect(getOperations('DraftOrder')).toHaveLength(1); + }); + it.each(['PURCHASE', 'DIGITAL'] as const)( 'recalculates taxes using the billing address when a coupon is applied to a %s order', async fulfillmentMode => { diff --git a/packages/react/src/components/checkout/__tests__/checkout-refetch-hydration.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-refetch-hydration.test.tsx index 39b7e7ad..7c87a3f1 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-refetch-hydration.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-refetch-hydration.test.tsx @@ -8,11 +8,15 @@ import { buildDraftOrder, buildPickupLocation, buildShippingAddress, + clearOperations, flushPromises, getNamedInput, + getOperations, renderCheckout, + setCurrentDraftOrder, typeIntoNamedField, waitForCheckoutReady, + waitForOperation, } from './checkout-test-env'; function ClientStateProbe() { @@ -102,6 +106,64 @@ function BillingToggleProbe() { } describe('Checkout refetch hydration', () => { + it('refetches SKUs when draft-order product identity changes', async () => { + const { queryClient, session } = renderCheckout(); + await waitForCheckoutReady(); + await waitFor(() => { + expect( + queryClient.getQueryState( + checkoutQueryKeys.draftOrderProducts(session.id) + )?.fetchStatus + ).toBe('idle'); + }); + clearOperations(); + + const updated = buildDraftOrder({ + lineItems: [ + { + id: 'line-item-1', + productId: 'product-2', + details: { sku: 'sku-2' }, + }, + ], + }); + setCurrentDraftOrder(updated); + await act(async () => { + queryClient.setQueryData(checkoutQueryKeys.draftOrder(session.id), { + checkoutSession: { draftOrder: updated }, + }); + await flushPromises(); + }); + await waitForOperation('DraftOrderSkus'); + + expect(getOperations('DraftOrderSkus')).toHaveLength(1); + }); + + it('does not refetch SKUs for non-product draft-order updates', async () => { + const { queryClient, session } = renderCheckout(); + await waitForCheckoutReady(); + await waitFor(() => { + expect( + queryClient.getQueryState( + checkoutQueryKeys.draftOrderProducts(session.id) + )?.fetchStatus + ).toBe('idle'); + }); + clearOperations(); + + const updated = buildDraftOrder({ + billing: { firstName: 'Updated' }, + }); + await act(async () => { + queryClient.setQueryData(checkoutQueryKeys.draftOrder(session.id), { + checkoutSession: { draftOrder: updated }, + }); + await flushPromises(); + }); + + expect(getOperations('DraftOrderSkus')).toHaveLength(0); + }); + it('hydrates pristine fields from draft-order refetch without clobbering dirty fields', async () => { const { user, queryClient, session } = renderCheckout({ draftOrderOverrides: { diff --git a/packages/react/src/components/checkout/discount/utils/use-discount-apply.ts b/packages/react/src/components/checkout/discount/utils/use-discount-apply.ts index ae4016f5..440cf1d8 100644 --- a/packages/react/src/components/checkout/discount/utils/use-discount-apply.ts +++ b/packages/react/src/components/checkout/discount/utils/use-discount-apply.ts @@ -139,47 +139,43 @@ export function useDiscountApply() { ); } - if (session?.enableTaxCollection) { - // If the delivery method is pickup, we need to update taxes based on the pickup location - // Otherwise, we can just update taxes without a specific address + if (session.enableTaxCollection) { // TODO: Move this to API layer const deliveryMethod = form.getValues('deliveryMethod'); - const isPickup = deliveryMethod === DeliveryMethods.PICKUP; - if (isPickup) { + if (deliveryMethod === DeliveryMethods.PICKUP) { const pickupLocationId = form.getValues('pickupLocationId'); - const locationAddress = session?.locations?.find( + const locationAddress = session.locations?.find( loc => loc.id === pickupLocationId )?.address; if (locationAddress) { await updateTaxes.mutateAsync(locationAddress); + return; } } else if ( deliveryMethod === DeliveryMethods.PURCHASE || deliveryMethod === DeliveryMethods.DIGITAL ) { const billingAddress = draftOrder?.billing?.address; - const hasRequiredLocationData = - billingAddress?.postalCode && billingAddress?.countryCode; - if (hasRequiredLocationData) { + if (billingAddress?.postalCode && billingAddress?.countryCode) { await updateTaxes.mutateAsync(billingAddress); + return; } } else { - const hasRequiredLocationData = - draftOrder?.shipping?.address?.postalCode && - draftOrder?.shipping?.address?.countryCode; + const shippingAddress = draftOrder?.shipping?.address; - if (hasRequiredLocationData) { + if (shippingAddress?.postalCode && shippingAddress?.countryCode) { await updateTaxes.mutateAsync(undefined); + return; } } - } else { - queryClient.invalidateQueries({ - queryKey: checkoutQueryKeys.draftOrder(session.id), - }); } + + await queryClient.invalidateQueries({ + queryKey: checkoutQueryKeys.draftOrder(session.id), + }); }, }); } diff --git a/packages/react/src/components/checkout/form/checkout-form-container.tsx b/packages/react/src/components/checkout/form/checkout-form-container.tsx index bb96e7e1..2176dd76 100644 --- a/packages/react/src/components/checkout/form/checkout-form-container.tsx +++ b/packages/react/src/components/checkout/form/checkout-form-container.tsx @@ -10,7 +10,10 @@ import { useDraftOrder, useDraftOrderLineItems, } from '@/components/checkout/order/use-draft-order'; -import { useDraftOrderProductsMap } from '@/components/checkout/order/use-draft-order-products'; +import { + useDraftOrderProductsMap, + useRefreshProductsWhenLineItemsChange, +} from '@/components/checkout/order/use-draft-order-products'; import { mapOrderToFormValues, mapSkusToItemsDisplay, @@ -35,6 +38,7 @@ export function CheckoutFormContainer({ const { data: order } = draftOrderQuery; const { data: lineItems } = draftOrderLineItemsQuery; + useRefreshProductsWhenLineItemsChange(lineItems); const items = useMemo( () => mapSkusToItemsDisplay(lineItems, skusMap), diff --git a/packages/react/src/components/checkout/order/use-draft-order-products.ts b/packages/react/src/components/checkout/order/use-draft-order-products.ts index 26c5b47b..fad574c4 100644 --- a/packages/react/src/components/checkout/order/use-draft-order-products.ts +++ b/packages/react/src/components/checkout/order/use-draft-order-products.ts @@ -1,10 +1,10 @@ -import { useQuery } from '@tanstack/react-query'; -import { useMemo } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useEffect, useMemo, useRef } from 'react'; import { useCheckoutContext } from '@/components/checkout/checkout'; import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys'; import { useGoDaddyContext } from '@/godaddy-provider'; import { getProductsFromOrderSkus } from '@/lib/godaddy/godaddy'; -import type { SKUProduct } from '@/types'; +import type { DraftOrder, SKUProduct } from '@/types'; /** * Hook to fetch products from SKUs in the draft order @@ -21,10 +21,55 @@ export function useDraftOrderProducts() { ? getProductsFromOrderSkus({ accessToken: jwt }, apiHost) : getProductsFromOrderSkus(session, apiHost), enabled: !!session?.id, + staleTime: Number.POSITIVE_INFINITY, + refetchOnMount: false, + refetchOnWindowFocus: 'always', select: data => data.checkoutSession?.skus?.edges, }); } +function getLineItemProductIdentity( + lineItems: DraftOrder['lineItems'] | null | undefined +) { + if (!lineItems) return null; + + const identities = lineItems.map(lineItem => { + if (lineItem.details?.sku) return `sku:${lineItem.details.sku}`; + if (lineItem.productId) return `product:${lineItem.productId}`; + return `line:${lineItem.id}`; + }); + + return JSON.stringify([...new Set(identities)].sort()); +} + +export function useRefreshProductsWhenLineItemsChange( + lineItems: DraftOrder['lineItems'] | null | undefined +) { + const { session } = useCheckoutContext(); + const queryClient = useQueryClient(); + const identity = getLineItemProductIdentity(lineItems); + const previousRef = useRef< + | { + sessionId: string; + identity: string; + } + | undefined + >(undefined); + + useEffect(() => { + if (!session?.id || identity === null) return; + + const previous = previousRef.current; + previousRef.current = { sessionId: session.id, identity }; + + if (previous?.sessionId === session.id && previous.identity !== identity) { + void queryClient.invalidateQueries({ + queryKey: checkoutQueryKeys.draftOrderProducts(session.id), + }); + } + }, [identity, queryClient, session?.id]); +} + /** * Hook to get products from SKUs in the draft order as a map for easy lookup * @returns Map of SKU ID to SKU product data diff --git a/packages/react/src/components/checkout/order/use-draft-order.ts b/packages/react/src/components/checkout/order/use-draft-order.ts index 95667e8a..8e97ada8 100644 --- a/packages/react/src/components/checkout/order/use-draft-order.ts +++ b/packages/react/src/components/checkout/order/use-draft-order.ts @@ -35,9 +35,10 @@ export function useDraftOrder( ? getDraftOrder({ accessToken: jwt }, apiHost) : getDraftOrder(session, apiHost), enabled: !!session?.id, + staleTime: 5_000, select: select ?? (data => data.checkoutSession?.draftOrder as TData), retry: 3, - refetchOnWindowFocus: true, + refetchOnWindowFocus: 'always', }); } diff --git a/packages/react/src/components/checkout/payment/billing-policy-transition-controller.tsx b/packages/react/src/components/checkout/payment/billing-policy-transition-controller.tsx index b0fb6d3e..240851d5 100644 --- a/packages/react/src/components/checkout/payment/billing-policy-transition-controller.tsx +++ b/packages/react/src/components/checkout/payment/billing-policy-transition-controller.tsx @@ -5,7 +5,10 @@ import { type CheckoutFormData, useCheckoutContext, } from '@/components/checkout/checkout'; -import { useDraftOrderTotals } from '@/components/checkout/order/use-draft-order'; +import { + useDraftOrder, + useDraftOrderTotals, +} from '@/components/checkout/order/use-draft-order'; import { BillingCollectionModes } from '@/components/checkout/payment/utils/billing-collection'; import { useBillingPolicy } from '@/components/checkout/payment/utils/use-billing-policy'; @@ -13,6 +16,7 @@ export function BillingPolicyTransitionController(): null { const form = useFormContext(); const { session } = useCheckoutContext(); const policy = useBillingPolicy(); + const { data: draftOrder } = useDraftOrder(); const { data: totals } = useDraftOrderTotals(); const totalValue = totals?.total?.value ?? null; const deliveryMethod = form.watch('deliveryMethod'); @@ -49,6 +53,7 @@ export function BillingPolicyTransitionController(): null { if ( previousState.mode === BillingCollectionModes.ADDRESS && policy.mode === BillingCollectionModes.NAMES && + draftOrder?.billing?.address != null && (Boolean(previousState.paymentMethod) || form.getFieldState('paymentMethod').isDirty || form.getFieldState('deliveryMethod').isDirty || @@ -59,6 +64,7 @@ export function BillingPolicyTransitionController(): null { }, [ clearBillingAddressDetails, deliveryMethod, + draftOrder?.billing?.address, form, paymentMethod, policy.mode, From 4057110c257e0cd2a32e9dc7fafca431b87ce3ac Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Fri, 21 Aug 2026 13:26:44 -0500 Subject: [PATCH 20/22] always use flushed updated order on checkout --- .../checkout-buttons/applePay/godaddy.tsx | 13 +- .../checkout-buttons/credit-card/godaddy.tsx | 13 +- .../checkout-buttons/credit-card/square.tsx | 13 +- .../checkout-buttons/credit-card/stripe.tsx | 6 +- .../checkout-buttons/googlePay/godaddy.tsx | 13 +- .../checkout-buttons/paypal/paypal.tsx | 17 +- .../payment/checkout-buttons/paze/godaddy.tsx | 13 +- .../utils/use-build-payment-request.test.tsx | 50 +++++ .../utils/use-build-payment-request.ts | 192 ++++++++++-------- .../payment/utils/use-stripe-checkout.test.ts | 71 +++++++ .../payment/utils/use-stripe-checkout.ts | 57 +++++- 11 files changed, 350 insertions(+), 108 deletions(-) create mode 100644 packages/react/src/components/checkout/payment/utils/use-stripe-checkout.test.ts diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/applePay/godaddy.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/applePay/godaddy.tsx index 77ade28c..898b25db 100644 --- a/packages/react/src/components/checkout/payment/checkout-buttons/applePay/godaddy.tsx +++ b/packages/react/src/components/checkout/payment/checkout-buttons/applePay/godaddy.tsx @@ -34,7 +34,8 @@ export function GoDaddyApplePayCheckoutButton() { const [isCollectLoading, setIsCollectLoading] = useState(true); const [error, setError] = useState(''); const { data: totals } = useDraftOrderTotals(); - const { poyntStandardRequest } = useBuildPaymentRequest(); + const { poyntStandardRequest, buildPaymentRequestsFromOrder } = + useBuildPaymentRequest(); const currencyCode = totals?.total?.currencyCode || 'USD'; const countryCode = session?.shipping?.originAddress?.countryCode || 'US'; @@ -64,11 +65,16 @@ export function GoDaddyApplePayCheckoutButton() { return; } - await flushCheckoutSync(); + const { latestOrder } = await flushCheckoutSync({ + includeCurrentFormDiff: true, + }); + const request = latestOrder + ? buildPaymentRequestsFromOrder(latestOrder).poyntStandardRequest + : poyntStandardRequest; setCheckoutErrors(undefined); - collect?.current?.startApplePaySession(poyntStandardRequest); + collect?.current?.startApplePaySession(request); track({ eventId: eventIds.applePayClick, @@ -79,6 +85,7 @@ export function GoDaddyApplePayCheckoutButton() { }); }, [ poyntStandardRequest, + buildPaymentRequestsFromOrder, flushCheckoutSync, setCheckoutErrors, form, diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/credit-card/godaddy.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/credit-card/godaddy.tsx index d6767d87..f4caeca2 100644 --- a/packages/react/src/components/checkout/payment/checkout-buttons/credit-card/godaddy.tsx +++ b/packages/react/src/components/checkout/payment/checkout-buttons/credit-card/godaddy.tsx @@ -13,7 +13,8 @@ export function CreditCardCheckoutButton() { const { isConfirmingCheckout, setCheckoutErrors } = useCheckoutContext(); const isPaymentDisabled = useIsPaymentDisabled(); const form = useFormContext(); - const { poyntCardRequest } = useBuildPaymentRequest(); + const { poyntCardRequest, buildPaymentRequestsFromOrder } = + useBuildPaymentRequest(); const flushCheckoutSync = useFlushCheckoutSync(); const buttonRef = useRef(null); const { t } = useGoDaddyContext(); @@ -34,16 +35,22 @@ export function CreditCardCheckoutButton() { } try { - await flushCheckoutSync(); + const { latestOrder } = await flushCheckoutSync({ + includeCurrentFormDiff: true, + }); + const request = latestOrder + ? buildPaymentRequestsFromOrder(latestOrder).poyntCardRequest + : poyntCardRequest; setCheckoutErrors(undefined); setIsLoadingNonce(true); - collect.getNonce(poyntCardRequest); + collect.getNonce(request); } catch (_error) { setIsLoadingNonce(false); setCheckoutErrors(['TRANSACTION_PROCESSING_FAILED']); } }, [ + buildPaymentRequestsFromOrder, collect, flushCheckoutSync, form, diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/credit-card/square.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/credit-card/square.tsx index 0309dba1..83bc299c 100644 --- a/packages/react/src/components/checkout/payment/checkout-buttons/credit-card/square.tsx +++ b/packages/react/src/components/checkout/payment/checkout-buttons/credit-card/square.tsx @@ -17,7 +17,8 @@ import { PaymentMethodType } from '@/types'; export function SquareCreditCardCheckoutButton() { const { t } = useGoDaddyContext(); const { card, isLoading } = useSquare(); - const { squarePaymentRequest } = useBuildPaymentRequest(); + const { squarePaymentRequest, buildPaymentRequestsFromOrder } = + useBuildPaymentRequest(); const confirmCheckout = useConfirmCheckout(); const { setCheckoutErrors, isConfirmingCheckout } = useCheckoutContext(); const isPaymentDisabled = useIsPaymentDisabled(); @@ -40,11 +41,16 @@ export function SquareCreditCardCheckoutButton() { return; } - await flushCheckoutSync(); + const { latestOrder } = await flushCheckoutSync({ + includeCurrentFormDiff: true, + }); + const request = latestOrder + ? buildPaymentRequestsFromOrder(latestOrder).squarePaymentRequest + : squarePaymentRequest; try { setIsSquareDisabled(true); - const cardToken = await card.tokenize(squarePaymentRequest); + const cardToken = await card.tokenize(request); if (cardToken.status === 'OK' && cardToken?.token) { await confirmCheckout.mutateAsync({ @@ -61,6 +67,7 @@ export function SquareCreditCardCheckoutButton() { setIsSquareDisabled(false); } }, [ + buildPaymentRequestsFromOrder, form, flushCheckoutSync, card, diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/credit-card/stripe.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/credit-card/stripe.tsx index e7e05682..18269a77 100644 --- a/packages/react/src/components/checkout/payment/checkout-buttons/credit-card/stripe.tsx +++ b/packages/react/src/components/checkout/payment/checkout-buttons/credit-card/stripe.tsx @@ -25,8 +25,10 @@ export function StripeCreditCardCheckoutButton() { form.setFocus(firstError); } } else { - await flushCheckoutSync(); - await handleSubmit(); + const { latestOrder } = await flushCheckoutSync({ + includeCurrentFormDiff: true, + }); + await handleSubmit(undefined, latestOrder); } }; diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/googlePay/godaddy.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/googlePay/godaddy.tsx index 4d11a8b9..ca181f3f 100644 --- a/packages/react/src/components/checkout/payment/checkout-buttons/googlePay/godaddy.tsx +++ b/packages/react/src/components/checkout/payment/checkout-buttons/googlePay/godaddy.tsx @@ -34,7 +34,8 @@ export function GoDaddyGooglePayCheckoutButton() { const [isCollectLoading, setIsCollectLoading] = useState(true); const [error, setError] = useState(''); const { data: totals } = useDraftOrderTotals(); - const { poyntStandardRequest } = useBuildPaymentRequest(); + const { poyntStandardRequest, buildPaymentRequestsFromOrder } = + useBuildPaymentRequest(); const currencyCode = totals?.total?.currencyCode || 'USD'; const countryCode = session?.shipping?.originAddress?.countryCode || 'US'; @@ -64,11 +65,16 @@ export function GoDaddyGooglePayCheckoutButton() { return; } - await flushCheckoutSync(); + const { latestOrder } = await flushCheckoutSync({ + includeCurrentFormDiff: true, + }); + const request = latestOrder + ? buildPaymentRequestsFromOrder(latestOrder).poyntStandardRequest + : poyntStandardRequest; setCheckoutErrors(undefined); - collect?.current?.startGooglePaySession(poyntStandardRequest); + collect?.current?.startGooglePaySession(request); track({ eventId: eventIds.googlePayClick, @@ -79,6 +85,7 @@ export function GoDaddyGooglePayCheckoutButton() { }); }, [ poyntStandardRequest, + buildPaymentRequestsFromOrder, flushCheckoutSync, setCheckoutErrors, form, diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/paypal/paypal.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/paypal/paypal.tsx index 125654ec..b97c0d17 100644 --- a/packages/react/src/components/checkout/payment/checkout-buttons/paypal/paypal.tsx +++ b/packages/react/src/components/checkout/payment/checkout-buttons/paypal/paypal.tsx @@ -22,7 +22,8 @@ function PayPalButtonsWrapper() { const { setCheckoutErrors } = useCheckoutContext(); const isPaymentDisabled = useIsPaymentDisabled(); const form = useFormContext(); - const { payPalRequest } = useBuildPaymentRequest(); + const { payPalRequest, buildPaymentRequestsFromOrder } = + useBuildPaymentRequest(); const confirmCheckout = useConfirmCheckout(); const flushCheckoutSync = useFlushCheckoutSync(); const [isPaypalDisabled, setIsPaypalDisabled] = useState(false); @@ -47,19 +48,23 @@ function PayPalButtonsWrapper() { return actions.reject(); } - await flushCheckoutSync(); - // Return true to continue flow, false to stop it return actions.resolve(); }; const createOrder = async (_data, actions) => { + const { latestOrder } = await flushCheckoutSync({ + includeCurrentFormDiff: true, + }); + const request = latestOrder + ? buildPaymentRequestsFromOrder(latestOrder).payPalRequest + : payPalRequest; const order = { - ...payPalRequest, - purchase_units: payPalRequest.purchase_units + ...request, + purchase_units: request.purchase_units ? [ { - ...payPalRequest.purchase_units[0], + ...request.purchase_units[0], ...(isPickup ? { shipping: undefined } : {}), // Remove shipping if pickup }, ] diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/paze/godaddy.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/paze/godaddy.tsx index ee41410c..74c0c5c5 100644 --- a/packages/react/src/components/checkout/payment/checkout-buttons/paze/godaddy.tsx +++ b/packages/react/src/components/checkout/payment/checkout-buttons/paze/godaddy.tsx @@ -34,7 +34,8 @@ export function PazeCheckoutButton() { const [isCollectLoading, setIsCollectLoading] = useState(true); const [error, setError] = useState(''); const { data: totals } = useDraftOrderTotals(); - const { poyntStandardRequest } = useBuildPaymentRequest(); + const { poyntStandardRequest, buildPaymentRequestsFromOrder } = + useBuildPaymentRequest(); const currencyCode = totals?.total?.currencyCode || 'USD'; const countryCode = session?.shipping?.originAddress?.countryCode || 'US'; @@ -62,11 +63,16 @@ export function PazeCheckoutButton() { return; } - await flushCheckoutSync(); + const { latestOrder } = await flushCheckoutSync({ + includeCurrentFormDiff: true, + }); + const request = latestOrder + ? buildPaymentRequestsFromOrder(latestOrder).poyntStandardRequest + : poyntStandardRequest; setCheckoutErrors(undefined); - collect?.current?.startPazeSession(poyntStandardRequest); + collect?.current?.startPazeSession(request); // Track the Paze click track({ @@ -78,6 +84,7 @@ export function PazeCheckoutButton() { }); }, [ poyntStandardRequest, + buildPaymentRequestsFromOrder, flushCheckoutSync, setCheckoutErrors, form, diff --git a/packages/react/src/components/checkout/payment/utils/use-build-payment-request.test.tsx b/packages/react/src/components/checkout/payment/utils/use-build-payment-request.test.tsx index 190020e0..508e0d2c 100644 --- a/packages/react/src/components/checkout/payment/utils/use-build-payment-request.test.tsx +++ b/packages/react/src/components/checkout/payment/utils/use-build-payment-request.test.tsx @@ -345,6 +345,56 @@ describe('useBuildPaymentRequest', () => { expect(requests.squarePaymentRequest.amount).toBe('0.00'); }); + it('builds submission requests from an explicitly supplied latest order', async () => { + const { requests } = await renderUseBuildPaymentRequest(); + const latestOrder = buildDraftOrder({ + billing: { + firstName: 'Latest', + lastName: 'Buyer', + email: 'latest@example.com', + address: buildBillingAddress({ postalCode: '78701' }), + }, + totals: { + total: money(4321), + subTotal: money(4321), + discountTotal: money(0), + shippingTotal: money(0), + taxTotal: money(0), + feeTotal: money(0), + }, + }); + + const latestRequests = requests.buildPaymentRequestsFromOrder(latestOrder); + + expect( + latestRequests.stripePaymentMethodParams.billing_details + ).toMatchObject({ + name: 'Latest Buyer', + email: 'latest@example.com', + address: { postal_code: '78701' }, + }); + expect(latestRequests.poyntCardRequest).toMatchObject({ + firstName: 'Latest', + lastName: 'Buyer', + emailAddress: 'latest@example.com', + zipCode: '78701', + }); + expect(latestRequests.squarePaymentRequest).toMatchObject({ + amount: '43.21', + billingContact: { + givenName: 'Latest', + familyName: 'Buyer', + email: 'latest@example.com', + postalCode: '78701', + }, + }); + expect(latestRequests.payPalRequest.purchase_units[0]).toMatchObject({ + amount: { value: '43.21' }, + billing: { name: { full_name: 'Latest Buyer' } }, + }); + expect(latestRequests.poyntStandardRequest.total.amount).toBe('43.21'); + }); + it('preserves three-decimal KWD precision for raw payment request amounts', async () => { const { requests } = await renderUseBuildPaymentRequest({ sessionOverrides: { diff --git a/packages/react/src/components/checkout/payment/utils/use-build-payment-request.ts b/packages/react/src/components/checkout/payment/utils/use-build-payment-request.ts index ae72db8c..1096b5d5 100644 --- a/packages/react/src/components/checkout/payment/utils/use-build-payment-request.ts +++ b/packages/react/src/components/checkout/payment/utils/use-build-payment-request.ts @@ -1,13 +1,11 @@ import type { PaymentMethodCreateParams } from '@stripe/stripe-js'; -import { useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import { useCheckoutContext } from '@/components/checkout/checkout'; -import { - useDraftOrder, - useDraftOrderTotals, -} from '@/components/checkout/order/use-draft-order'; +import { useDraftOrder } from '@/components/checkout/order/use-draft-order'; import { useDraftOrderProductsMap } from '@/components/checkout/order/use-draft-order-products'; import { mapSkusToItemsDisplay } from '@/components/checkout/utils/checkout-transformers'; import { useFormatCurrency } from '@/components/checkout/utils/format-currency'; +import type { CheckoutSession, DraftOrder, SKUProduct } from '@/types'; // Apple Pay request interface export interface ApplePayRequest { @@ -167,7 +165,29 @@ export interface PoyntExpressRequest { export interface PoyntStandardRequest extends PoyntExpressRequest {} -export function useBuildPaymentRequest(): { +export function buildStripePaymentMethodParams( + order?: DraftOrder | null +): PaymentMethodCreateParams { + return { + billing_details: { + name: + `${order?.billing?.firstName || ''} ${order?.billing?.lastName || ''}`.trim() || + undefined, + email: order?.billing?.email || undefined, + phone: order?.billing?.phone || undefined, + address: { + line1: order?.billing?.address?.addressLine1 || undefined, + line2: order?.billing?.address?.addressLine2 || undefined, + city: order?.billing?.address?.adminArea2 || undefined, + state: order?.billing?.address?.adminArea1 || undefined, + postal_code: order?.billing?.address?.postalCode || undefined, + country: order?.billing?.address?.countryCode || undefined, + }, + }, + }; +} + +export type PaymentRequests = { applePayRequest: ApplePayRequest; googlePayRequest: GooglePayRequest; payPalRequest: PayPalRequest; @@ -176,25 +196,33 @@ export function useBuildPaymentRequest(): { poyntExpressRequest: PoyntExpressRequest; poyntStandardRequest: PoyntStandardRequest; squarePaymentRequest: SquarePaymentRequest; -} { - const formatCurrency = useFormatCurrency(); - const { paypalConfig, session } = useCheckoutContext(); +}; - const draftOrderTotalsQuery = useDraftOrderTotals(); - const draftOrderQuery = useDraftOrder(); - const skusMap = useDraftOrderProductsMap(); +export type PaymentRequestBuilder = ( + order?: DraftOrder | null +) => PaymentRequests; - const { data: totals } = draftOrderTotalsQuery; - const { data: order } = draftOrderQuery; +type BuildPaymentRequestsInput = { + order?: DraftOrder | null; + skusMap: Record; + formatCurrency: ReturnType; + session?: CheckoutSession | null; + paypalMerchantId?: string; + hostname: string; +}; - // Extract totals information based on the data format +export function buildPaymentRequests({ + order, + skusMap, + formatCurrency, + session, + paypalMerchantId, + hostname, +}: BuildPaymentRequestsInput): PaymentRequests { + const totals = order?.totals; const currencyCode = totals?.total?.currencyCode || 'USD'; const lineItems = order?.lineItems || []; - - const items = useMemo( - () => mapSkusToItemsDisplay(lineItems, skusMap), - [lineItems, skusMap] - ); + const items = mapSkusToItemsDisplay(lineItems, skusMap); // Extract amounts in minor units for use across payment requests const subtotalMinorUnits = totals?.subTotal?.value || 0; @@ -207,47 +235,35 @@ export function useBuildPaymentRequest(): { const discountMinorUnits = totals?.discountTotal?.value || 0; const totalMinorUnits = totals?.total?.value || 0; - const countryCode = useMemo( - () => session?.shipping?.originAddress?.countryCode || 'US', - [session?.shipping?.originAddress?.countryCode] - ); - - // Memoize address information with null handling - const shippingAddress = useMemo( - () => ({ - name: { - full_name: - `${order?.shipping?.firstName || ''} ${order?.shipping?.lastName || ''}`.trim(), - }, - address: { - address_line_1: order?.shipping?.address?.addressLine1 || undefined, - address_line_2: order?.shipping?.address?.addressLine2 || undefined, - admin_area_2: order?.shipping?.address?.adminArea2 || undefined, - admin_area_1: order?.shipping?.address?.adminArea1 || undefined, - postal_code: order?.shipping?.address?.postalCode || undefined, - country_code: order?.shipping?.address?.countryCode || countryCode, - }, - }), - [order?.shipping, countryCode] - ); - - const billingAddress = useMemo( - () => ({ - name: { - full_name: - `${order?.billing?.firstName || ''} ${order?.billing?.lastName || ''}`.trim(), - }, - address: { - address_line_1: order?.billing?.address?.addressLine1 || undefined, - address_line_2: order?.billing?.address?.addressLine2 || undefined, - admin_area_2: order?.billing?.address?.adminArea2 || undefined, - admin_area_1: order?.billing?.address?.adminArea1 || undefined, - postal_code: order?.billing?.address?.postalCode || undefined, - country_code: order?.billing?.address?.countryCode || countryCode, - }, - }), - [order?.billing, countryCode] - ); + const countryCode = session?.shipping?.originAddress?.countryCode || 'US'; + const shippingAddress = { + name: { + full_name: + `${order?.shipping?.firstName || ''} ${order?.shipping?.lastName || ''}`.trim(), + }, + address: { + address_line_1: order?.shipping?.address?.addressLine1 || undefined, + address_line_2: order?.shipping?.address?.addressLine2 || undefined, + admin_area_2: order?.shipping?.address?.adminArea2 || undefined, + admin_area_1: order?.shipping?.address?.adminArea1 || undefined, + postal_code: order?.shipping?.address?.postalCode || undefined, + country_code: order?.shipping?.address?.countryCode || countryCode, + }, + }; + const billingAddress = { + name: { + full_name: + `${order?.billing?.firstName || ''} ${order?.billing?.lastName || ''}`.trim(), + }, + address: { + address_line_1: order?.billing?.address?.addressLine1 || undefined, + address_line_2: order?.billing?.address?.addressLine2 || undefined, + admin_area_2: order?.billing?.address?.adminArea2 || undefined, + admin_area_1: order?.billing?.address?.adminArea1 || undefined, + postal_code: order?.billing?.address?.postalCode || undefined, + country_code: order?.billing?.address?.countryCode || countryCode, + }, + }; // Create Apple Pay request const applePayRequest: ApplePayRequest = { @@ -347,7 +363,7 @@ export function useBuildPaymentRequest(): { merchantInfo: { merchantId: session?.storeId || '', merchantName: session?.storeName || '', - merchantOrigin: document.location.hostname, + merchantOrigin: hostname, }, transactionInfo: { totalPriceStatus: 'FINAL', @@ -428,7 +444,7 @@ export function useBuildPaymentRequest(): { shippingMinorUnits - discountMinorUnits; - const payPalMerchantId = paypalConfig?.merchantId?.trim(); + const payPalMerchantId = paypalMerchantId?.trim(); const payPalRequest: PayPalRequest = { purchase_units: [ { @@ -501,23 +517,7 @@ export function useBuildPaymentRequest(): { ], }; - const stripePaymentMethodParams: PaymentMethodCreateParams = { - billing_details: { - name: - `${order?.billing?.firstName || ''} ${order?.billing?.lastName || ''}`.trim() || - undefined, - email: order?.billing?.email || undefined, - phone: order?.billing?.phone || undefined, - address: { - line1: order?.billing?.address?.addressLine1 || undefined, - line2: order?.billing?.address?.addressLine2 || undefined, - city: order?.billing?.address?.adminArea2 || undefined, - state: order?.billing?.address?.adminArea1 || undefined, - postal_code: order?.billing?.address?.postalCode || undefined, - country: order?.billing?.address?.countryCode || undefined, - }, - }, - }; + const stripePaymentMethodParams = buildStripePaymentMethodParams(order); const poyntCardRequest: PoyntCardRequest = { emailAddress: order?.billing?.email || undefined, @@ -646,3 +646,35 @@ export function useBuildPaymentRequest(): { squarePaymentRequest, }; } + +export function useBuildPaymentRequest(): PaymentRequests & { + buildPaymentRequestsFromOrder: PaymentRequestBuilder; +} { + const formatCurrency = useFormatCurrency(); + const { paypalConfig, session } = useCheckoutContext(); + const { data: order } = useDraftOrder(); + const skusMap = useDraftOrderProductsMap(); + const hostname = + typeof document === 'undefined' ? '' : document.location.hostname; + const paypalMerchantId = paypalConfig?.merchantId; + + const buildPaymentRequestsFromOrder = useCallback( + orderOverride => + buildPaymentRequests({ + order: orderOverride === undefined ? order : orderOverride, + skusMap, + formatCurrency, + session, + paypalMerchantId, + hostname, + }), + [formatCurrency, hostname, order, paypalMerchantId, session, skusMap] + ); + + const requests = useMemo( + () => buildPaymentRequestsFromOrder(order), + [buildPaymentRequestsFromOrder, order] + ); + + return { ...requests, buildPaymentRequestsFromOrder }; +} diff --git a/packages/react/src/components/checkout/payment/utils/use-stripe-checkout.test.ts b/packages/react/src/components/checkout/payment/utils/use-stripe-checkout.test.ts new file mode 100644 index 00000000..a64f14bd --- /dev/null +++ b/packages/react/src/components/checkout/payment/utils/use-stripe-checkout.test.ts @@ -0,0 +1,71 @@ +import { expect, it } from 'vitest'; +import type { DraftOrder } from '@/types'; +import { buildStripePaymentMethodParams } from './use-build-payment-request'; +import { buildStripeExpressPaymentMethodParams } from './use-stripe-checkout'; + +it('builds Stripe billing details from the supplied draft order', () => { + const order = { + billing: { + firstName: 'Latest', + lastName: 'Buyer', + email: 'latest@example.com', + phone: '+12015550123', + address: { + addressLine1: '123 Current St', + addressLine2: 'Suite 4', + adminArea2: 'Austin', + adminArea1: 'TX', + postalCode: '78701', + countryCode: 'US', + }, + }, + } as DraftOrder; + + expect(buildStripePaymentMethodParams(order)).toEqual({ + billing_details: { + name: 'Latest Buyer', + email: 'latest@example.com', + phone: '+12015550123', + address: { + line1: '123 Current St', + line2: 'Suite 4', + city: 'Austin', + state: 'TX', + postal_code: '78701', + country: 'US', + }, + }, + }); +}); + +it('builds Stripe Express billing details from the wallet event', () => { + expect( + buildStripeExpressPaymentMethodParams({ + name: 'Wallet Buyer', + email: 'wallet@example.com', + phone: '+12015550999', + address: { + line1: '789 Wallet Ave', + line2: null, + city: 'Phoenix', + state: 'AZ', + postal_code: '85001', + country: 'US', + }, + }) + ).toEqual({ + billing_details: { + name: 'Wallet Buyer', + email: 'wallet@example.com', + phone: '+12015550999', + address: { + line1: '789 Wallet Ave', + line2: undefined, + city: 'Phoenix', + state: 'AZ', + postal_code: '85001', + country: 'US', + }, + }, + }); +}); diff --git a/packages/react/src/components/checkout/payment/utils/use-stripe-checkout.ts b/packages/react/src/components/checkout/payment/utils/use-stripe-checkout.ts index 2538a4b2..c9708a1e 100644 --- a/packages/react/src/components/checkout/payment/utils/use-stripe-checkout.ts +++ b/packages/react/src/components/checkout/payment/utils/use-stripe-checkout.ts @@ -1,5 +1,8 @@ import { CardElement, useElements, useStripe } from '@stripe/react-stripe-js'; -import type { StripeExpressCheckoutElementConfirmEvent } from '@stripe/stripe-js'; +import type { + PaymentMethodCreateParams, + StripeExpressCheckoutElementConfirmEvent, +} from '@stripe/stripe-js'; import { useCallback, useState } from 'react'; import { useCheckoutContext } from '@/components/checkout/checkout'; import { useBuildPaymentRequest } from '@/components/checkout/payment/utils/use-build-payment-request'; @@ -8,10 +11,12 @@ import { useConfirmCheckout, } from '@/components/checkout/payment/utils/use-confirm-checkout'; import { useConfirmExpressCheckout } from '@/components/checkout/payment/utils/use-confirm-express-checkout'; +import { useFlushCheckoutSync } from '@/components/checkout/payment/utils/use-flush-checkout-sync'; import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors'; import type { CalculatedAdjustments, CalculatedTaxes, + DraftOrder, ShippingMethod, } from '@/types'; import { PaymentMethodType } from '@/types'; @@ -22,6 +27,29 @@ type UseStripeCheckoutOptions = { }; // Express checkout data to pass to confirmCheckout +export function buildStripeExpressPaymentMethodParams( + billingDetails: + | StripeExpressCheckoutElementConfirmEvent['billingDetails'] + | null + | undefined +): PaymentMethodCreateParams { + return { + billing_details: { + name: billingDetails?.name || undefined, + email: billingDetails?.email || undefined, + phone: billingDetails?.phone || undefined, + address: { + line1: billingDetails?.address?.line1 || undefined, + line2: billingDetails?.address?.line2 || undefined, + city: billingDetails?.address?.city || undefined, + state: billingDetails?.address?.state || undefined, + postal_code: billingDetails?.address?.postal_code || undefined, + country: billingDetails?.address?.country || undefined, + }, + }, + }; +} + export type StripeExpressCheckoutData = { // Stripe confirm event data event: StripeExpressCheckoutElementConfirmEvent; @@ -42,11 +70,16 @@ export function useStripeCheckout({ mode }: UseStripeCheckoutOptions) { const confirmCheckout = useConfirmCheckout(); const confirmExpressCheckout = useConfirmExpressCheckout(); const { setCheckoutErrors } = useCheckoutContext(); - const { stripePaymentMethodParams } = useBuildPaymentRequest(); + const { stripePaymentMethodParams, buildPaymentRequestsFromOrder } = + useBuildPaymentRequest(); + const flushCheckoutSync = useFlushCheckoutSync(); const [isProcessingPayment, setIsProcessingPayment] = useState(false); const handleSubmit = useCallback( - async (expressData?: StripeExpressCheckoutData) => { + async ( + expressData?: StripeExpressCheckoutData, + resolvedOrder?: DraftOrder | null + ) => { setIsProcessingPayment(true); try { if (!stripe || !elements) { @@ -60,8 +93,18 @@ export function useStripeCheckout({ mode }: UseStripeCheckoutOptions) { return; } + const latestOrder = + resolvedOrder ?? + ( + await flushCheckoutSync({ + includeCurrentFormDiff: true, + }) + ).latestOrder; const { paymentMethod, error } = await stripe.createPaymentMethod({ - ...stripePaymentMethodParams, + ...(latestOrder + ? buildPaymentRequestsFromOrder(latestOrder) + .stripePaymentMethodParams + : stripePaymentMethodParams), card: cardElement, type: 'card', }); @@ -92,7 +135,9 @@ export function useStripeCheckout({ mode }: UseStripeCheckoutOptions) { if (mode === 'express') { const { error, paymentMethod } = await stripe.createPaymentMethod({ elements, - params: stripePaymentMethodParams, + params: buildStripeExpressPaymentMethodParams( + expressData?.event.billingDetails + ), }); if (error) { @@ -231,6 +276,8 @@ export function useStripeCheckout({ mode }: UseStripeCheckoutOptions) { stripe, elements, confirmCheckout.mutateAsync, + flushCheckoutSync, + buildPaymentRequestsFromOrder, confirmExpressCheckout.mutateAsync, setCheckoutErrors, stripePaymentMethodParams, From f46f8f0d3877ce82b25c3a521c8a0f2961cdc38d Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Fri, 21 Aug 2026 13:44:32 -0500 Subject: [PATCH 21/22] checkout payment flush test for e2e with latest order --- .../__tests__/checkout-payment-flush.test.tsx | 119 +++++++++++- .../payment-request-resolution.test.tsx | 154 +++++++++++++++ .../payment-request-resolution.test.tsx | 164 ++++++++++++++++ ...wallet-payment-request-resolution.test.tsx | 178 ++++++++++++++++++ .../use-stripe-checkout.integration.test.tsx | 177 +++++++++++++++++ 5 files changed, 791 insertions(+), 1 deletion(-) create mode 100644 packages/react/src/components/checkout/payment/checkout-buttons/payment-request-resolution.test.tsx create mode 100644 packages/react/src/components/checkout/payment/checkout-buttons/paypal/payment-request-resolution.test.tsx create mode 100644 packages/react/src/components/checkout/payment/checkout-buttons/wallet-payment-request-resolution.test.tsx create mode 100644 packages/react/src/components/checkout/payment/utils/use-stripe-checkout.integration.test.tsx diff --git a/packages/react/src/components/checkout/__tests__/checkout-payment-flush.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-payment-flush.test.tsx index d500f9e6..fb8a7d6b 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-payment-flush.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-payment-flush.test.tsx @@ -1,6 +1,17 @@ -import { describe, expect, it } from 'vitest'; +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { useFormContext } from 'react-hook-form'; +import { describe, expect, it, vi } from 'vitest'; +import { type CheckoutFormData } from '@/components/checkout/checkout'; +import { useBuildPaymentRequest } from '@/components/checkout/payment/utils/use-build-payment-request'; +import { + PaymentProvider, + useConfirmCheckout, +} from '@/components/checkout/payment/utils/use-confirm-checkout'; +import { useFlushCheckoutSync } from '@/components/checkout/payment/utils/use-flush-checkout-sync'; import * as godaddyApi from '@/lib/godaddy/godaddy'; +import { PaymentMethodType } from '@/types'; import { + buildBillingAddress, buildCheckoutSession, buildDraftOrder, buildDraftOrderUpdate, @@ -9,12 +20,55 @@ import { getOperations, MockTokenizeJs, mockGodaddyApi, + renderCheckout, + waitForCheckoutReady, } from './checkout-test-env'; import { getLastConfirmInput, getLastUpdateInput, } from './checkout-test-fixtures'; +const tokenizeLatestOrder = vi.fn( + async (_request: unknown) => 'resolved-payment-token' +); +let operationsAtTokenization: string[] = []; + +function PaymentRequestResolutionProbe() { + const form = useFormContext(); + const flushCheckoutSync = useFlushCheckoutSync(); + const { buildPaymentRequestsFromOrder } = useBuildPaymentRequest(); + const confirmCheckout = useConfirmCheckout(); + + return ( + + ); +} + async function simulateCardPayment( options: { notes?: string; pickup?: boolean; tokenError?: string } = {} ) { @@ -72,6 +126,69 @@ async function simulateCardPayment( } describe('Checkout payment flushing and Poynt card flow', () => { + it('builds the SDK request from the order returned after flushing current form edits', async () => { + tokenizeLatestOrder.mockClear(); + operationsAtTokenization = []; + const { user } = renderCheckout({ + draftOrderOverrides: { + billing: { + firstName: 'Stale', + lastName: 'Buyer', + address: buildBillingAddress(), + }, + lineItems: [{ fulfillmentMode: 'PURCHASE' }], + }, + sessionOverrides: { + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + }, + checkoutProps: { + targets: { + 'checkout.form.submit.after': PaymentRequestResolutionProbe, + }, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + const billingFirstName = document.querySelector( + 'input[name="billingFirstName"]' + ); + expect(billingFirstName).toBeInstanceOf(HTMLInputElement); + fireEvent.change(billingFirstName as HTMLInputElement, { + target: { value: 'Latest' }, + }); + await user.click( + screen.getByRole('button', { name: /resolve and tokenize payment/i }) + ); + + await waitFor(() => { + expect(getOperations('ConfirmCheckoutSession')).toHaveLength(1); + }); + expect(getLastUpdateInput()).toMatchObject({ + billing: { firstName: 'Latest', lastName: 'Buyer' }, + }); + expect(tokenizeLatestOrder).toHaveBeenCalledWith( + expect.objectContaining({ + firstName: 'Latest', + lastName: 'Buyer', + }) + ); + const updateIndex = operationsAtTokenization.indexOf( + 'UpdateCheckoutSessionDraftOrder' + ); + const refetchIndex = operationsAtTokenization.indexOf('DraftOrder'); + expect(updateIndex).toBeGreaterThanOrEqual(0); + expect(refetchIndex).toBeGreaterThan(updateIndex); + expect(operationsAtTokenization).not.toContain('ConfirmCheckoutSession'); + expect(getLastConfirmInput()).toMatchObject({ + paymentToken: 'resolved-payment-token', + paymentType: 'card', + paymentProvider: 'POYNT', + }); + }); + it('flushes pending notes sync before tokenization and confirms with the correct payload', async () => { await simulateCardPayment({ notes: 'Leave at door' }); diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/payment-request-resolution.test.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/payment-request-resolution.test.tsx new file mode 100644 index 00000000..80afeac0 --- /dev/null +++ b/packages/react/src/components/checkout/payment/checkout-buttons/payment-request-resolution.test.tsx @@ -0,0 +1,154 @@ +import { QueryClient } from '@tanstack/react-query'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + type CheckoutFormData, + checkoutContext, +} from '@/components/checkout/checkout'; +import { GoDaddyProvider } from '@/godaddy-provider'; +import type { DraftOrder } from '@/types'; +import { CreditCardCheckoutButton } from './credit-card/godaddy'; +import { SquareCreditCardCheckoutButton } from './credit-card/square'; + +const mocks = vi.hoisted(() => ({ + latestOrder: { id: 'latest-order' } as DraftOrder, + flush: vi.fn(), + buildFromOrder: vi.fn(), + getNonce: vi.fn(), + tokenize: vi.fn(), + confirm: vi.fn(), +})); + +vi.mock('@/components/checkout/payment/utils/use-flush-checkout-sync', () => ({ + useFlushCheckoutSync: () => mocks.flush, +})); + +vi.mock( + '@/components/checkout/payment/utils/use-build-payment-request', + () => ({ + useBuildPaymentRequest: () => ({ + poyntCardRequest: { firstName: 'Stale' }, + squarePaymentRequest: { amount: '1.00' }, + buildPaymentRequestsFromOrder: mocks.buildFromOrder, + }), + }) +); + +vi.mock('@/components/checkout/payment/utils/poynt-provider', () => ({ + usePoyntCollect: () => ({ + collect: { getNonce: mocks.getNonce }, + isLoadingNonce: false, + setIsLoadingNonce: vi.fn(), + }), +})); + +vi.mock('@/components/checkout/payment/utils/square-provider', () => ({ + useSquare: () => ({ + card: { tokenize: mocks.tokenize }, + isLoading: false, + }), +})); + +vi.mock('@/components/checkout/payment/utils/use-is-payment-disabled', () => ({ + useIsPaymentDisabled: () => false, +})); + +vi.mock( + '@/components/checkout/payment/utils/use-confirm-checkout', + async () => { + const actual = await vi.importActual< + typeof import('@/components/checkout/payment/utils/use-confirm-checkout') + >('@/components/checkout/payment/utils/use-confirm-checkout'); + return { + ...actual, + useConfirmCheckout: () => ({ mutateAsync: mocks.confirm }), + }; + } +); + +function Wrapper({ children }: { children: React.ReactNode }) { + const form = useForm({ + defaultValues: { paymentMethod: 'card' } as CheckoutFormData, + }); + const queryClient = React.useMemo( + () => + new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }), + [] + ); + + return ( + + + {children} + + + ); +} + +describe('payment request resolution from the flushed order', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.flush.mockResolvedValue({ latestOrder: mocks.latestOrder }); + mocks.buildFromOrder.mockReturnValue({ + poyntCardRequest: { firstName: 'Latest' }, + squarePaymentRequest: { + amount: '43.21', + billingContact: { givenName: 'Latest' }, + }, + }); + mocks.tokenize.mockResolvedValue({ status: 'OK', token: 'square-token' }); + mocks.confirm.mockResolvedValue(undefined); + }); + + it('passes the latest order request to GoDaddy tokenization', async () => { + render(, { wrapper: Wrapper }); + + fireEvent.click(screen.getByRole('button', { name: /pay now/i })); + + await waitFor(() => { + expect(mocks.getNonce).toHaveBeenCalledWith({ firstName: 'Latest' }); + }); + expect(mocks.flush).toHaveBeenCalledWith({ + includeCurrentFormDiff: true, + }); + expect(mocks.buildFromOrder).toHaveBeenCalledWith(mocks.latestOrder); + expect(mocks.getNonce.mock.invocationCallOrder[0]).toBeGreaterThan( + mocks.flush.mock.invocationCallOrder[0] + ); + }); + + it('passes the latest order request to Square before confirmation', async () => { + render(, { wrapper: Wrapper }); + + fireEvent.click(screen.getByRole('button', { name: /pay now/i })); + + await waitFor(() => { + expect(mocks.confirm).toHaveBeenCalledWith({ + paymentToken: 'square-token', + paymentType: 'card', + paymentProvider: 'SQUARE', + }); + }); + expect(mocks.tokenize).toHaveBeenCalledWith({ + amount: '43.21', + billingContact: { givenName: 'Latest' }, + }); + expect(mocks.tokenize.mock.invocationCallOrder[0]).toBeGreaterThan( + mocks.flush.mock.invocationCallOrder[0] + ); + expect(mocks.confirm.mock.invocationCallOrder[0]).toBeGreaterThan( + mocks.tokenize.mock.invocationCallOrder[0] + ); + }); +}); diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/paypal/payment-request-resolution.test.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/paypal/payment-request-resolution.test.tsx new file mode 100644 index 00000000..46a4a6d2 --- /dev/null +++ b/packages/react/src/components/checkout/payment/checkout-buttons/paypal/payment-request-resolution.test.tsx @@ -0,0 +1,164 @@ +import { QueryClient } from '@tanstack/react-query'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { beforeEach, expect, it, vi } from 'vitest'; +import { + type CheckoutFormData, + checkoutContext, +} from '@/components/checkout/checkout'; +import { GoDaddyProvider } from '@/godaddy-provider'; +import type { DraftOrder } from '@/types'; +import { PayPalCheckoutButton } from './paypal'; + +const mocks = vi.hoisted(() => ({ + latestOrder: { id: 'latest-order' } as DraftOrder, + flush: vi.fn(), + buildFromOrder: vi.fn(), + createOrder: vi.fn(), + confirm: vi.fn(), +})); + +vi.mock('@paypal/react-paypal-js', () => ({ + FUNDING: { PAYPAL: 'paypal' }, + usePayPalScriptReducer: () => [{ isResolved: true, isPending: false }], + PayPalButtons: (props: { + onClick: (data: unknown, actions: unknown) => Promise; + createOrder: (data: unknown, actions: unknown) => Promise; + onApprove: (data: unknown, actions: unknown) => Promise; + }) => ( + + ), +})); + +vi.mock('@/components/checkout/payment/utils/use-flush-checkout-sync', () => ({ + useFlushCheckoutSync: () => mocks.flush, +})); + +vi.mock( + '@/components/checkout/payment/utils/use-build-payment-request', + () => ({ + useBuildPaymentRequest: () => ({ + payPalRequest: { purchase_units: [{ amount: { value: '1.00' } }] }, + buildPaymentRequestsFromOrder: mocks.buildFromOrder, + }), + }) +); + +vi.mock('@/components/checkout/payment/utils/use-is-payment-disabled', () => ({ + useIsPaymentDisabled: () => false, +})); + +vi.mock( + '@/components/checkout/payment/utils/use-confirm-checkout', + async () => { + const actual = await vi.importActual< + typeof import('@/components/checkout/payment/utils/use-confirm-checkout') + >('@/components/checkout/payment/utils/use-confirm-checkout'); + return { + ...actual, + useConfirmCheckout: () => ({ mutateAsync: mocks.confirm }), + }; + } +); + +function Wrapper({ children }: { children: React.ReactNode }) { + const form = useForm({ + defaultValues: { deliveryMethod: 'PURCHASE' } as CheckoutFormData, + }); + const queryClient = React.useMemo(() => new QueryClient(), []); + + return ( + + + {children} + + + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.flush.mockResolvedValue({ latestOrder: mocks.latestOrder }); + mocks.buildFromOrder.mockReturnValue({ + payPalRequest: { + purchase_units: [ + { + amount: { currency_code: 'USD', value: '43.21' }, + billing: { name: { full_name: 'Latest Buyer' } }, + }, + ], + }, + }); + mocks.createOrder.mockResolvedValue('paypal-order'); + mocks.confirm.mockResolvedValue(undefined); +}); + +it('creates and confirms PayPal with the request from the flushed latest order', async () => { + render(, { wrapper: Wrapper }); + + fireEvent.click(screen.getByRole('button', { name: /paypal sdk button/i })); + + await waitFor(() => { + expect(mocks.confirm).toHaveBeenCalledWith({ + paymentToken: 'paypal-order:paypal-payer', + paymentType: 'paypal', + paymentProvider: 'PAYPAL', + }); + }); + expect(mocks.flush).toHaveBeenCalledWith({ includeCurrentFormDiff: true }); + expect(mocks.buildFromOrder).toHaveBeenCalledWith(mocks.latestOrder); + expect(mocks.createOrder).toHaveBeenCalledWith({ + purchase_units: [ + { + amount: { currency_code: 'USD', value: '43.21' }, + billing: { name: { full_name: 'Latest Buyer' } }, + }, + ], + application_context: { shipping_preference: 'SET_PROVIDED_ADDRESS' }, + }); + expect(mocks.createOrder.mock.invocationCallOrder[0]).toBeGreaterThan( + mocks.flush.mock.invocationCallOrder[0] + ); + expect(mocks.confirm.mock.invocationCallOrder[0]).toBeGreaterThan( + mocks.createOrder.mock.invocationCallOrder[0] + ); +}); diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/wallet-payment-request-resolution.test.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/wallet-payment-request-resolution.test.tsx new file mode 100644 index 00000000..cd64f2d2 --- /dev/null +++ b/packages/react/src/components/checkout/payment/checkout-buttons/wallet-payment-request-resolution.test.tsx @@ -0,0 +1,178 @@ +import { QueryClient } from '@tanstack/react-query'; +import { act, render, waitFor } from '@testing-library/react'; +import React from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + type CheckoutFormData, + checkoutContext, +} from '@/components/checkout/checkout'; +import { GoDaddyProvider } from '@/godaddy-provider'; +import type { DraftOrder } from '@/types'; +import { GoDaddyApplePayCheckoutButton } from './applePay/godaddy'; +import { GoDaddyGooglePayCheckoutButton } from './googlePay/godaddy'; +import { PazeCheckoutButton } from './paze/godaddy'; + +const mocks = vi.hoisted(() => ({ + latestOrder: { id: 'latest-order' } as DraftOrder, + flush: vi.fn(), + buildFromOrder: vi.fn(), + startApplePaySession: vi.fn(), + startGooglePaySession: vi.fn(), + startPazeSession: vi.fn(), + walletClickHandlers: new Map Promise>(), +})); + +vi.mock('@/components/checkout/payment/utils/use-flush-checkout-sync', () => ({ + useFlushCheckoutSync: () => mocks.flush, +})); + +vi.mock( + '@/components/checkout/payment/utils/use-build-payment-request', + () => ({ + useBuildPaymentRequest: () => ({ + poyntStandardRequest: { total: { amount: '1.00' } }, + buildPaymentRequestsFromOrder: mocks.buildFromOrder, + }), + }) +); + +vi.mock('@/components/checkout/payment/utils/use-load-poynt-collect', () => ({ + useLoadPoyntCollect: () => ({ isPoyntLoaded: true }), +})); + +vi.mock('@/components/checkout/order/use-draft-order', () => ({ + useDraftOrderTotals: () => ({ + data: { total: { value: 100, currencyCode: 'USD' } }, + }), +})); + +vi.mock('@/components/checkout/payment/utils/use-is-payment-disabled', () => ({ + useIsPaymentDisabled: () => false, +})); + +vi.mock( + '@/components/checkout/payment/utils/use-confirm-checkout', + async () => { + const actual = await vi.importActual< + typeof import('@/components/checkout/payment/utils/use-confirm-checkout') + >('@/components/checkout/payment/utils/use-confirm-checkout'); + return { + ...actual, + useConfirmCheckout: () => ({ mutateAsync: vi.fn() }), + }; + } +); + +class MockTokenizeJs { + async supportWalletPayments() { + return { applePay: true, googlePay: true, paze: true }; + } + + mount( + id: string, + _document: Document, + options: { buttonOptions?: { onClick?: () => Promise } } + ) { + if (options.buttonOptions?.onClick) { + mocks.walletClickHandlers.set(id, options.buttonOptions.onClick); + } + } + + on(_eventName: string, _handler: (event: unknown) => void) { + return undefined; + } + + startApplePaySession(request: unknown) { + mocks.startApplePaySession(request); + } + + startGooglePaySession(request: unknown) { + mocks.startGooglePaySession(request); + } + + startPazeSession(request: unknown) { + mocks.startPazeSession(request); + } +} + +function Wrapper({ children }: { children: React.ReactNode }) { + const form = useForm({ + defaultValues: { paymentMethod: 'card' } as CheckoutFormData, + }); + const queryClient = React.useMemo(() => new QueryClient(), []); + + return ( + + + {children} + + + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.walletClickHandlers.clear(); + mocks.flush.mockResolvedValue({ latestOrder: mocks.latestOrder }); + mocks.buildFromOrder.mockReturnValue({ + poyntStandardRequest: { total: { amount: '43.21' } }, + }); + window.TokenizeJs = MockTokenizeJs as never; +}); + +describe.each([ + { + name: 'Apple Pay', + Component: GoDaddyApplePayCheckoutButton, + elementId: 'apple-pay-element', + start: mocks.startApplePaySession, + }, + { + name: 'Google Pay', + Component: GoDaddyGooglePayCheckoutButton, + elementId: 'google-pay-element', + start: mocks.startGooglePaySession, + }, + { + name: 'Paze', + Component: PazeCheckoutButton, + elementId: 'paze-pay-element', + start: mocks.startPazeSession, + }, +])('$name request resolution', ({ Component, elementId, start }) => { + it('starts the wallet with totals from the flushed latest order', async () => { + render(, { wrapper: Wrapper }); + + await waitFor(() => { + expect(mocks.walletClickHandlers.has(elementId)).toBe(true); + }); + await act(async () => { + await mocks.walletClickHandlers.get(elementId)?.(); + }); + + expect(mocks.flush).toHaveBeenCalledWith({ + includeCurrentFormDiff: true, + }); + expect(mocks.buildFromOrder).toHaveBeenCalledWith(mocks.latestOrder); + expect(start).toHaveBeenCalledWith({ total: { amount: '43.21' } }); + expect(start.mock.invocationCallOrder[0]).toBeGreaterThan( + mocks.flush.mock.invocationCallOrder[0] + ); + }); +}); diff --git a/packages/react/src/components/checkout/payment/utils/use-stripe-checkout.integration.test.tsx b/packages/react/src/components/checkout/payment/utils/use-stripe-checkout.integration.test.tsx new file mode 100644 index 00000000..488de166 --- /dev/null +++ b/packages/react/src/components/checkout/payment/utils/use-stripe-checkout.integration.test.tsx @@ -0,0 +1,177 @@ +import { act, renderHook } from '@testing-library/react'; +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { checkoutContext } from '@/components/checkout/checkout'; +import type { DraftOrder } from '@/types'; +import { useStripeCheckout } from './use-stripe-checkout'; + +const mocks = vi.hoisted(() => ({ + latestOrder: { id: 'latest-order' } as DraftOrder, + flush: vi.fn(), + buildFromOrder: vi.fn(), + createPaymentMethod: vi.fn(), + confirm: vi.fn(), + confirmExpress: vi.fn(), + cardElement: {}, +})); + +vi.mock('@stripe/react-stripe-js', () => ({ + CardElement: function CardElement() { + return null; + }, + useStripe: () => ({ createPaymentMethod: mocks.createPaymentMethod }), + useElements: () => ({ + getElement: () => mocks.cardElement, + }), +})); + +vi.mock( + '@/components/checkout/payment/utils/use-build-payment-request', + () => ({ + useBuildPaymentRequest: () => ({ + stripePaymentMethodParams: { + billing_details: { name: 'Stale Buyer' }, + }, + buildPaymentRequestsFromOrder: mocks.buildFromOrder, + }), + }) +); + +vi.mock('@/components/checkout/payment/utils/use-flush-checkout-sync', () => ({ + useFlushCheckoutSync: () => mocks.flush, +})); + +vi.mock( + '@/components/checkout/payment/utils/use-confirm-checkout', + async () => { + const actual = await vi.importActual< + typeof import('@/components/checkout/payment/utils/use-confirm-checkout') + >('@/components/checkout/payment/utils/use-confirm-checkout'); + return { + ...actual, + useConfirmCheckout: () => ({ mutateAsync: mocks.confirm }), + }; + } +); + +vi.mock( + '@/components/checkout/payment/utils/use-confirm-express-checkout', + () => ({ + useConfirmExpressCheckout: () => ({ mutateAsync: mocks.confirmExpress }), + }) +); + +function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +describe('useStripeCheckout payment request resolution', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.flush.mockResolvedValue({ latestOrder: mocks.latestOrder }); + mocks.buildFromOrder.mockReturnValue({ + stripePaymentMethodParams: { + billing_details: { name: 'Latest Buyer' }, + }, + }); + mocks.createPaymentMethod.mockResolvedValue({ + paymentMethod: { id: 'stripe-payment-method' }, + }); + mocks.confirm.mockResolvedValue(undefined); + mocks.confirmExpress.mockResolvedValue(undefined); + }); + + it('tokenizes card billing from the flushed latest order before confirmation', async () => { + const { result } = renderHook(() => useStripeCheckout({ mode: 'card' }), { + wrapper: Wrapper, + }); + + await act(async () => { + await result.current.handleSubmit(); + }); + + expect(mocks.flush).toHaveBeenCalledWith({ + includeCurrentFormDiff: true, + }); + expect(mocks.buildFromOrder).toHaveBeenCalledWith(mocks.latestOrder); + expect(mocks.createPaymentMethod).toHaveBeenCalledWith({ + billing_details: { name: 'Latest Buyer' }, + card: mocks.cardElement, + type: 'card', + }); + expect(mocks.confirm).toHaveBeenCalledWith({ + paymentToken: 'stripe-payment-method', + paymentType: 'card', + paymentProvider: 'STRIPE', + }); + expect(mocks.confirm.mock.invocationCallOrder[0]).toBeGreaterThan( + mocks.createPaymentMethod.mock.invocationCallOrder[0] + ); + }); + + it('tokenizes express billing from the wallet event without flushing form data', async () => { + const { result } = renderHook( + () => useStripeCheckout({ mode: 'express' }), + { wrapper: Wrapper } + ); + const event = { + expressPaymentType: 'apple_pay', + billingDetails: { + name: 'Wallet Buyer', + email: 'wallet@example.com', + phone: null, + address: { + line1: '789 Wallet Ave', + line2: null, + city: 'Phoenix', + state: 'AZ', + postal_code: '85001', + country: 'US', + }, + }, + } as never; + + await act(async () => { + await result.current.handleSubmit({ event }); + }); + + expect(mocks.flush).not.toHaveBeenCalled(); + expect(mocks.createPaymentMethod).toHaveBeenCalledWith({ + elements: expect.any(Object), + params: { + billing_details: { + name: 'Wallet Buyer', + email: 'wallet@example.com', + phone: undefined, + address: { + line1: '789 Wallet Ave', + line2: undefined, + city: 'Phoenix', + state: 'AZ', + postal_code: '85001', + country: 'US', + }, + }, + }, + }); + expect(mocks.confirmExpress).toHaveBeenCalledWith( + expect.objectContaining({ + paymentToken: 'stripe-payment-method', + paymentType: 'apple_pay', + paymentProvider: 'STRIPE', + isExpress: true, + }) + ); + }); +}); From 29c4f337497aab1a3d88ba7dc70b6481fed66bac Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Mon, 24 Aug 2026 11:04:48 -0500 Subject: [PATCH 22/22] hydrate contact email from shipping or billing data --- .../checkout/utils/checkout-transformers.test.ts | 12 ++++++++++++ .../checkout/utils/checkout-transformers.ts | 6 +++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/react/src/components/checkout/utils/checkout-transformers.test.ts b/packages/react/src/components/checkout/utils/checkout-transformers.test.ts index 2e212f3c..920ccf22 100644 --- a/packages/react/src/components/checkout/utils/checkout-transformers.test.ts +++ b/packages/react/src/components/checkout/utils/checkout-transformers.test.ts @@ -326,6 +326,18 @@ describe('mapOrderToFormValues', () => { expect(values.notes).toBe('Leave by the gate'); }); + it('hydrates contact email from billing when pickup has no shipping contact', () => { + const values = mapOrderToFormValues({ + order: buildDraftOrder({ + shipping: null, + billing: { email: 'pickup@example.com' }, + lineItems: [{ fulfillmentMode: DeliveryMethods.PICKUP }], + }), + }); + + expect(values.contactEmail).toBe('pickup@example.com'); + }); + it('returns schema defaults for an empty draft order without throwing', () => { const values = mapOrderToFormValues({ order: null }); diff --git a/packages/react/src/components/checkout/utils/checkout-transformers.ts b/packages/react/src/components/checkout/utils/checkout-transformers.ts index 10bb204a..7b51b3ca 100644 --- a/packages/react/src/components/checkout/utils/checkout-transformers.ts +++ b/packages/react/src/components/checkout/utils/checkout-transformers.ts @@ -192,7 +192,11 @@ export function mapOrderToFormValues({ orderBillingAddress?.countryCode || defaultCountryCode || 'US', // Contact information - contactEmail: order?.shipping?.email || defaultValues?.contactEmail || '', + contactEmail: + order?.shipping?.email || + order?.billing?.email || + defaultValues?.contactEmail || + '', // Delivery Methods deliveryMethod: deliveryMethod,