From 7d6ddecf46db0eb8c1c3e189c3da8697e63ad362 Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Wed, 19 Aug 2026 10:21:17 -0500 Subject: [PATCH 01/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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, From 6da79fbbc1b2108132a76c9f49342c6ac81ce68b Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Wed, 26 Aug 2026 11:43:18 -0500 Subject: [PATCH 23/32] refetch shipping methods and recalc taxes on coupon application --- .../__tests__/checkout-discount.test.tsx | 303 ++++++++++++++++++ .../__tests__/checkout-express.test.tsx | 41 +++ .../__tests__/checkout-free-order.test.tsx | 13 +- .../__tests__/checkout-shipping.test.tsx | 29 +- .../__tests__/checkout-test-utils.tsx | 33 +- .../get-draft-order-discount-codes.test.ts | 26 ++ .../utils/get-draft-order-discount-codes.ts | 25 ++ .../discount/utils/use-apply-discount-core.ts | 134 ++++++++ .../discount/utils/use-discount-apply.ts | 164 ++-------- .../checkout/form/checkout-form.tsx | 5 +- .../checkout-buttons/express/godaddy.tsx | 95 ++---- .../checkout-buttons/express/stripe.tsx | 94 +----- .../checkout/shipping/shipping-method.tsx | 36 +-- .../shipping/utils/filter-shipping-methods.ts | 41 --- .../requires-shipping-reconciliation.test.ts | 108 +++++++ .../utils/requires-shipping-reconciliation.ts | 63 ++++ .../shipping/utils/sort-shipping-methods.ts | 18 ++ .../utils/use-apply-shipping-method-core.ts | 89 +++++ .../utils/use-apply-shipping-method.ts | 92 +----- .../src/lib/godaddy/checkout-mutations.ts | 4 - .../react/src/lib/godaddy/checkout-queries.ts | 4 - 21 files changed, 942 insertions(+), 475 deletions(-) create mode 100644 packages/react/src/components/checkout/discount/utils/get-draft-order-discount-codes.test.ts create mode 100644 packages/react/src/components/checkout/discount/utils/get-draft-order-discount-codes.ts create mode 100644 packages/react/src/components/checkout/discount/utils/use-apply-discount-core.ts delete mode 100644 packages/react/src/components/checkout/shipping/utils/filter-shipping-methods.ts create mode 100644 packages/react/src/components/checkout/shipping/utils/requires-shipping-reconciliation.test.ts create mode 100644 packages/react/src/components/checkout/shipping/utils/requires-shipping-reconciliation.ts create mode 100644 packages/react/src/components/checkout/shipping/utils/sort-shipping-methods.ts create mode 100644 packages/react/src/components/checkout/shipping/utils/use-apply-shipping-method-core.ts 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 b49196d8..d5a08510 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx @@ -4,11 +4,13 @@ import { describe, expect, it } from 'vitest'; import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors'; import { buildBillingAddress, + buildShippingRates, clearOperations, flushPromises, getOperations, renderCheckout, setApiError, + setShippingMethods, waitForCheckoutReady, waitForOperation, } from './checkout-test-env'; @@ -148,6 +150,307 @@ describe('Checkout discounts', () => { expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(0); }); + it('refetches shipping methods when a coupon is applied', async () => { + const { user } = renderCheckout({ + sessionOverrides: { enableTaxCollection: false }, + }); + await waitForCheckoutReady(); + clearOperations(); + + await applyCoupon(user, 'onedollar'); + await waitForOperation('DraftOrderShippingRates'); + + expect(getOperations('DraftOrderShippingRates')).toHaveLength(1); + }); + + it('calculates taxes once after a discount changes the selected shipping cost', async () => { + const paidShipping = buildShippingRates([ + { + serviceCode: 'standard', + carrierCode: 'carrier', + displayName: 'Standard', + cost: { value: 1000, currencyCode: 'USD' }, + }, + ]); + const { user } = renderCheckout({ + apiOverrides: { shippingMethods: paidShipping }, + draftOrderOverrides: { + shippingLines: [ + { + requestedService: 'standard', + requestedProvider: 'carrier', + name: 'Standard', + amount: { value: 1000, currencyCode: 'USD' }, + }, + ], + totals: { + shippingTotal: { value: 1000, currencyCode: 'USD' }, + total: { value: 3500, currencyCode: 'USD' }, + }, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + setShippingMethods( + buildShippingRates([ + { + serviceCode: 'standard', + carrierCode: 'carrier', + displayName: 'Standard', + cost: { value: 0, currencyCode: 'USD' }, + }, + ]) + ); + + await applyCoupon(user, 'onedollar'); + + await waitFor(() => { + expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength( + 1 + ); + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1); + }); + + await flushPromises(); + + const operations = getOperations(); + const shippingIndex = operations.findIndex( + operation => operation.op === 'ApplyCheckoutSessionShippingMethod' + ); + const taxIndex = operations.findIndex( + operation => operation.op === 'CalculateCheckoutSessionTaxes' + ); + const lastDiscountIndex = operations + .map(operation => operation.op) + .lastIndexOf('ApplyCheckoutSessionDiscount'); + + expect(getOperations('DraftOrderShippingRates')).toHaveLength(1); + expect(getOperations('ApplyCheckoutSessionDiscount')).toHaveLength(2); + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1); + expect(taxIndex).toBeGreaterThan(shippingIndex); + expect(taxIndex).toBeGreaterThan(lastDiscountIndex); + }); + + it('applies a newly available free method before calculating taxes', async () => { + const paidShipping = buildShippingRates([ + { + serviceCode: 'standard', + carrierCode: 'carrier', + displayName: 'Standard', + cost: { value: 1000, currencyCode: 'USD' }, + }, + ]); + const { user } = renderCheckout({ + apiOverrides: { shippingMethods: paidShipping }, + draftOrderOverrides: { + shippingLines: [ + { + requestedService: 'standard', + requestedProvider: 'carrier', + name: 'Standard', + amount: { value: 1000, currencyCode: 'USD' }, + }, + ], + totals: { + shippingTotal: { value: 1000, currencyCode: 'USD' }, + total: { value: 3500, currencyCode: 'USD' }, + }, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + setShippingMethods([ + ...paidShipping, + ...buildShippingRates([ + { + serviceCode: 'free', + carrierCode: 'carrier', + displayName: 'Free', + cost: { value: 0, currencyCode: 'USD' }, + }, + ]), + ]); + + await applyCoupon(user, 'onedollar'); + + await waitFor(() => { + expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength( + 1 + ); + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1); + }); + + expect( + getOperations('ApplyCheckoutSessionShippingMethod')[0].input + ).toContainEqual( + expect.objectContaining({ + requestedService: 'free', + subTotal: { value: 0, currencyCode: 'USD' }, + }) + ); + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1); + }); + + it('calculates taxes once without applying shipping when refreshed shipping is unchanged', async () => { + const paidShipping = buildShippingRates([ + { + serviceCode: 'standard', + carrierCode: 'carrier', + displayName: 'Standard', + cost: { value: 1000, currencyCode: 'USD' }, + }, + ]); + const { user } = renderCheckout({ + apiOverrides: { shippingMethods: paidShipping }, + draftOrderOverrides: { + shippingLines: [ + { + requestedService: 'standard', + requestedProvider: 'carrier', + name: 'Standard', + amount: { value: 1000, currencyCode: 'USD' }, + }, + ], + totals: { + shippingTotal: { value: 1000, currencyCode: 'USD' }, + total: { value: 3500, currencyCode: 'USD' }, + }, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + await applyCoupon(user, 'onedollar'); + + await waitFor(() => { + expect(getOperations('DraftOrderShippingRates')).toHaveLength(1); + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1); + }); + + await flushPromises(); + + expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength(0); + expect(getOperations('ApplyCheckoutSessionDiscount')).toHaveLength(1); + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1); + }); + + it('reapplies a shipping discount before taxes when the shipping method changes', async () => { + const shippingMethods = buildShippingRates([ + { + serviceCode: 'flat-rate', + carrierCode: 'carrier', + displayName: 'Flat Rate', + cost: { value: 10, currencyCode: 'USD' }, + }, + { + serviceCode: 'premium-rate', + carrierCode: 'carrier', + displayName: 'Premium Rate', + cost: { value: 100, currencyCode: 'USD' }, + }, + ]); + const { user } = renderCheckout({ + apiOverrides: { shippingMethods }, + draftOrderOverrides: { + shippingLines: [ + { + requestedService: 'flat-rate', + requestedProvider: 'carrier', + name: 'Flat Rate', + amount: { value: 10, currencyCode: 'USD' }, + }, + ], + totals: { + shippingTotal: { value: 10, currencyCode: 'USD' }, + total: { value: 2510, currencyCode: 'USD' }, + }, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + await applyCoupon(user, 'freeship'); + + await waitFor(() => { + expect(getOperations('DraftOrderShippingRates')).toHaveLength(1); + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1); + }); + expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength(0); + expect( + screen.getAllByRole('button', { name: /remove freeship/i }).length + ).toBeGreaterThan(0); + + await flushPromises(); + clearOperations(); + await user.click(screen.getByRole('radio', { name: /premium rate/i })); + + await waitFor(() => { + expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength( + 1 + ); + expect(getOperations('ApplyCheckoutSessionDiscount')).toHaveLength(1); + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1); + }); + + const operationNames = getOperations().map(operation => operation.op); + expect( + operationNames.indexOf('ApplyCheckoutSessionDiscount') + ).toBeGreaterThan( + operationNames.indexOf('ApplyCheckoutSessionShippingMethod') + ); + expect( + operationNames.indexOf('CalculateCheckoutSessionTaxes') + ).toBeGreaterThan(operationNames.indexOf('ApplyCheckoutSessionDiscount')); + expect(getOperations('ApplyCheckoutSessionDiscount')[0].input).toEqual({ + discountCodes: ['freeship'], + }); + + await flushPromises(); + clearOperations(); + await user.click( + screen + .getAllByRole('button', { name: /remove freeship/i }) + .at(-1) as HTMLButtonElement + ); + + await waitFor(() => { + expect(getOperations('DraftOrderShippingRates')).toHaveLength(1); + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1); + }); + expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength(0); + expect(getOperations('ApplyCheckoutSessionDiscount')[0].input).toEqual({ + discountCodes: [], + }); + }); + + it('does not fetch shipping or taxes when a coupon is applied without a shipping address', async () => { + const { user } = renderCheckout({ + draftOrderOverrides: { + shipping: null, + billing: null, + shippingLines: null, + lineItems: [{ fulfillmentMode: 'PURCHASE' }], + }, + sessionOverrides: { + enableShipping: true, + enableLocalPickup: false, + enableTaxCollection: true, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + await applyCoupon(user, 'freeship'); + await waitForOperation('ApplyCheckoutSessionDiscount'); + await waitForOperation('DraftOrder'); + + expect(getOperations('DraftOrderShippingRates')).toHaveLength(0); + expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength(0); + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(0); + }); + it('refetches the draft order when taxes cannot be recalculated without a billing address', async () => { const { user } = renderCheckout({ draftOrderOverrides: { diff --git a/packages/react/src/components/checkout/__tests__/checkout-express.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-express.test.tsx index 4a847df2..edd941d6 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-express.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-express.test.tsx @@ -72,6 +72,47 @@ describe('Express checkout section visibility', () => { expect(screen.getByRole('button', { name: /pay now/i })).toBeVisible(); }); + it('renders express checkout for a shipping-enabled session with purchase fulfillment', async () => { + renderCheckout({ + sessionOverrides: { + enableShipping: true, + paymentMethods: { + card: { processor: 'stripe', checkoutTypes: ['standard'] }, + express: { processor: 'godaddy', checkoutTypes: ['express'] }, + }, + }, + draftOrderOverrides: { + lineItems: [{ fulfillmentMode: 'PURCHASE' }], + }, + }); + await waitForCheckoutReady(); + + expect( + await screen.findByTestId('mock-godaddy-express-button') + ).toBeVisible(); + }); + + it('does not render express checkout for a digital-only order', async () => { + renderCheckout({ + sessionOverrides: { + enableShipping: true, + paymentMethods: { + card: { processor: 'stripe', checkoutTypes: ['standard'] }, + express: { processor: 'godaddy', checkoutTypes: ['express'] }, + }, + }, + draftOrderOverrides: { + lineItems: [{ type: 'DIGITAL', fulfillmentMode: 'DIGITAL' }], + }, + }); + await waitForCheckoutReady(); + + expect( + screen.queryByTestId('mock-godaddy-express-button') + ).not.toBeInTheDocument(); + expect(screen.queryByText(/^OR$/)).not.toBeInTheDocument(); + }); + it('renders the Stripe express button when paymentMethods.express is configured for stripe', async () => { renderCheckout({ sessionOverrides: { 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 0cbafd42..449a0d93 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 @@ -445,7 +445,7 @@ describe('Checkout free / offline orders', () => { ).not.toBeInTheDocument(); }); - it('switches to FreePaymentForm when selecting a free shipping rate makes the total zero', async () => { + it('switches to FreePaymentForm when the cheapest shipping rate makes the total zero', async () => { const draftOrder = buildDraftOrder({ totals: { subTotal: { value: 0, currencyCode: 'USD' }, @@ -482,23 +482,14 @@ describe('Checkout free / offline orders', () => { enableShipping: true, enableLocalPickup: false, enableTaxCollection: false, - experimental_rules: { - freeShipping: { enabled: true, minimumOrderTotal: 0 }, - }, }); - const { user } = renderCheckout({ + renderCheckout({ session, draftOrder, apiOverrides: { shippingMethods: buildShippingRates() }, }); await waitForCheckoutReady(); - expect( - await screen.findByRole('button', { name: /pay now/i }) - ).toBeInTheDocument(); - - clearOperations(); - await user.click(screen.getByRole('radio', { name: /free/i })); await waitForOperation('ApplyCheckoutSessionShippingMethod'); expect( 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 87061b35..ad93332f 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-shipping.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-shipping.test.tsx @@ -95,7 +95,7 @@ describe('Checkout shipping behavior', () => { ).not.toBeInTheDocument(); }); - it('filters free shipping below the minimum order total and shows it once the subtotal qualifies', async () => { + it('shows free shipping returned by the API', async () => { const shippingMethods = [ { serviceCode: 'free-shipping', @@ -118,35 +118,12 @@ describe('Checkout shipping behavior', () => { cost: { value: 500, currencyCode: 'USD' }, }, ]; - const experimental_rules = { - freeShipping: { enabled: true, minimumOrderTotal: 5000 }, - }; - const { unmount } = renderCheckout({ - sessionOverrides: { experimental_rules }, - apiOverrides: { shippingMethods }, - }); - await waitForCheckoutReady(); - - expect( - screen.queryByRole('radio', { name: /free/i }) - ).not.toBeInTheDocument(); - expect(screen.getAllByText('Paid Rate').length).toBeGreaterThan(0); - - unmount(); - renderCheckout({ - sessionOverrides: { experimental_rules }, - draftOrderOverrides: { - totals: { - subTotal: { value: 5000, currencyCode: 'USD' }, - total: { value: 5000, currencyCode: 'USD' }, - }, - }, - apiOverrides: { shippingMethods }, - }); + renderCheckout({ apiOverrides: { shippingMethods } }); await waitForCheckoutReady(); expect(screen.getByRole('radio', { name: /free/i })).toBeInTheDocument(); + expect(screen.getAllByText('Paid Rate').length).toBeGreaterThan(0); }); it('renders FREE for a single zero-cost shipping method', async () => { 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 b2ae0c4c..765100bf 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-test-utils.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-test-utils.tsx @@ -629,21 +629,45 @@ function applyShippingLines(shippingMethods: unknown) { function applyDiscountCodes(discountCodes: string[]) { if (!state) return; - const discounts = discountCodes.map(code => discount(code)); const totals = state.draftOrder.totals ?? defaultTotals(); + const hasFreeShipping = discountCodes.some( + code => code.toLowerCase() === 'freeship' + ); + const orderDiscountCodes = hasFreeShipping + ? discountCodes.filter(code => code.toLowerCase() !== 'freeship') + : discountCodes; + const discounts = orderDiscountCodes.map(code => discount(code)); const freeOrderDiscount = (totals.subTotal?.value ?? 0) + (totals.shippingTotal?.value ?? 0) + (totals.taxTotal?.value ?? 0) + (totals.feeTotal?.value ?? 0); + const shippingDiscount = hasFreeShipping + ? (totals.shippingTotal?.value ?? 0) + : 0; const discountTotal = money( discountCodes.some(code => code.toLowerCase() === 'free100') ? freeOrderDiscount - : discountCodes.length * 100 + : orderDiscountCodes.length * 100 + shippingDiscount ); + const shippingLines = + state.draftOrder.shippingLines?.map(shippingLine => ({ + ...shippingLine, + discounts: hasFreeShipping + ? [ + { + ...discount('freeship'), + amount: money(shippingLine.amount?.value ?? 0), + metafields: [], + }, + ] + : [], + })) ?? null; + state.draftOrder = recalculateTotal({ ...state.draftOrder, discounts, + shippingLines, totals: { ...(state.draftOrder.totals ?? defaultTotals()), discountTotal, @@ -998,6 +1022,11 @@ export function setPriceAdjustments(adjustments: unknown[]) { state.priceAdjustments = adjustments; } +export function setShippingMethods(shippingMethods: ShippingMethod[]) { + if (!state) throw new Error('mockGodaddyApi must be called first'); + state.shippingMethods = shippingMethods; +} + export function getOperations(op?: OperationName) { const operations = state?.operations ?? []; return op ? operations.filter(operation => operation.op === op) : operations; diff --git a/packages/react/src/components/checkout/discount/utils/get-draft-order-discount-codes.test.ts b/packages/react/src/components/checkout/discount/utils/get-draft-order-discount-codes.test.ts new file mode 100644 index 00000000..4b980309 --- /dev/null +++ b/packages/react/src/components/checkout/discount/utils/get-draft-order-discount-codes.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import type { DraftOrder } from '@/types'; +import { getDraftOrderDiscountCodes } from './get-draft-order-discount-codes'; + +describe('getDraftOrderDiscountCodes', () => { + it('collects unique order, line-item, and shipping-line discount codes', () => { + const draftOrder = { + discounts: [{ code: 'order' }], + lineItems: [{ discounts: [{ code: 'line' }, { code: 'shared' }] }], + shippingLines: [ + { discounts: [{ code: 'shipping' }, { code: 'shared' }] }, + ], + } as DraftOrder; + + expect(getDraftOrderDiscountCodes(draftOrder)).toEqual([ + 'line', + 'order', + 'shared', + 'shipping', + ]); + }); + + it('returns an empty list without a draft order', () => { + expect(getDraftOrderDiscountCodes()).toEqual([]); + }); +}); diff --git a/packages/react/src/components/checkout/discount/utils/get-draft-order-discount-codes.ts b/packages/react/src/components/checkout/discount/utils/get-draft-order-discount-codes.ts new file mode 100644 index 00000000..800d1c9b --- /dev/null +++ b/packages/react/src/components/checkout/discount/utils/get-draft-order-discount-codes.ts @@ -0,0 +1,25 @@ +import type { DraftOrder } from '@/types'; + +export function getDraftOrderDiscountCodes( + draftOrder?: DraftOrder | null +): string[] { + const codes = new Set(); + + for (const discount of draftOrder?.discounts ?? []) { + if (discount.code) codes.add(discount.code); + } + + for (const lineItem of draftOrder?.lineItems ?? []) { + for (const discount of lineItem.discounts ?? []) { + if (discount.code) codes.add(discount.code); + } + } + + for (const shippingLine of draftOrder?.shippingLines ?? []) { + for (const discount of shippingLine.discounts ?? []) { + if (discount.code) codes.add(discount.code); + } + } + + return Array.from(codes).sort(); +} diff --git a/packages/react/src/components/checkout/discount/utils/use-apply-discount-core.ts b/packages/react/src/components/checkout/discount/utils/use-apply-discount-core.ts new file mode 100644 index 00000000..486cf2fe --- /dev/null +++ b/packages/react/src/components/checkout/discount/utils/use-apply-discount-core.ts @@ -0,0 +1,134 @@ +import type { QueryClient } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import type { ResultOf } from 'gql.tada'; +import { useCheckoutContext } from '@/components/checkout/checkout'; +import { + checkoutMutationKeys, + checkoutQueryKeys, +} from '@/components/checkout/utils/query-keys'; +import { useGoDaddyContext } from '@/godaddy-provider'; +import { ApplyCheckoutSessionDiscountMutation } from '@/lib/godaddy/checkout-mutations.ts'; +import { DraftOrderQuery } from '@/lib/godaddy/checkout-queries.ts'; +import { applyDiscount } from '@/lib/godaddy/godaddy'; +import type { ApplyCheckoutSessionDiscountInput } from '@/types'; + +type DiscountMutationResult = ResultOf< + typeof ApplyCheckoutSessionDiscountMutation +>; +type DiscountOrder = NonNullable< + DiscountMutationResult['applyCheckoutSessionDiscount'] +>; + +export interface ApplyDiscountVariables { + discountCodes: ApplyCheckoutSessionDiscountInput['input']['discountCodes']; +} + +interface UseApplyDiscountCoreOptions { + onSuccess?: ( + data: DiscountMutationResult, + variables: ApplyDiscountVariables + ) => Promise | void; +} + +export function updateDiscountCache( + queryClient: QueryClient, + sessionId: string, + updatedOrder: DiscountOrder, + discountCodes: ApplyDiscountVariables['discountCodes'] +) { + queryClient.setQueryData( + checkoutQueryKeys.draftOrder(sessionId), + (cached: ResultOf | undefined) => { + const currentOrder = cached?.checkoutSession?.draftOrder; + if (!cached || !currentOrder) return cached; + + return { + ...cached, + checkoutSession: { + ...cached.checkoutSession, + draftOrder: { + ...currentOrder, + totals: { + ...currentOrder.totals, + discountTotal: + updatedOrder.totals?.discountTotal ?? + currentOrder.totals?.discountTotal, + total: updatedOrder.totals?.total ?? currentOrder.totals?.total, + }, + discounts: + updatedOrder.discounts ?? + (discountCodes?.length ? currentOrder.discounts : []), + lineItems: currentOrder.lineItems?.map(currentLineItem => { + const updatedLineItem = updatedOrder.lineItems?.find( + lineItem => lineItem.id === currentLineItem.id + ); + + if (!updatedLineItem) { + return discountCodes?.length + ? currentLineItem + : { ...currentLineItem, discounts: [] }; + } + + return { + ...currentLineItem, + discounts: updatedLineItem.discounts ?? [], + totals: { + ...currentLineItem.totals, + discountTotal: + updatedLineItem.totals?.discountTotal ?? + currentLineItem.totals?.discountTotal, + }, + }; + }), + shippingLines: + currentOrder.shippingLines?.map((currentShippingLine, index) => { + const updatedShippingLine = updatedOrder.shippingLines?.[index]; + + if (!updatedShippingLine) { + return discountCodes?.length + ? currentShippingLine + : { ...currentShippingLine, discounts: [] }; + } + + return { + ...currentShippingLine, + discounts: updatedShippingLine.discounts ?? [], + }; + }) ?? null, + }, + }, + }; + } + ); +} + +export function useApplyDiscountCore( + options: UseApplyDiscountCoreOptions = {} +) { + const { session, jwt } = useCheckoutContext(); + const { apiHost } = useGoDaddyContext(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: checkoutMutationKeys.applyDiscount(session?.id), + mutationFn: async ({ discountCodes }: ApplyDiscountVariables) => + jwt + ? applyDiscount(discountCodes, { accessToken: jwt }, apiHost) + : applyDiscount(discountCodes, session, apiHost), + onSuccess: async (data, variables) => { + if (!session) return; + + const updatedOrder = data.applyCheckoutSessionDiscount; + if (updatedOrder) { + updateDiscountCache( + queryClient, + session.id, + updatedOrder, + variables.discountCodes + ); + } + + await options.onSuccess?.(data, variables); + }, + }); +} 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 440cf1d8..368e1e12 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 @@ -1,147 +1,50 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import type { ResultOf } from 'gql.tada'; +import { useQueryClient } from '@tanstack/react-query'; import { useFormContext } from 'react-hook-form'; import { useCheckoutContext } from '@/components/checkout/checkout'; import { DeliveryMethods } from '@/components/checkout/delivery/delivery-methods'; import { useDraftOrder } from '@/components/checkout/order/use-draft-order'; import { useUpdateTaxes } from '@/components/checkout/order/use-update-taxes'; -import { - checkoutMutationKeys, - checkoutQueryKeys, -} from '@/components/checkout/utils/query-keys'; -import { useGoDaddyContext } from '@/godaddy-provider'; -import type { DraftOrderQuery } from '@/lib/godaddy/checkout-queries.ts'; -import { applyDiscount } from '@/lib/godaddy/godaddy'; -import type { ApplyCheckoutSessionDiscountInput } from '@/types'; +import { requiresShippingReconciliation } from '@/components/checkout/shipping/utils/requires-shipping-reconciliation'; +import { useDraftOrderShippingMethods } from '@/components/checkout/shipping/utils/use-draft-order-shipping-methods'; +import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys'; +import { useApplyDiscountCore } from './use-apply-discount-core'; export function useDiscountApply() { - const { session, jwt } = useCheckoutContext(); - const { apiHost } = useGoDaddyContext(); + const { session } = useCheckoutContext(); const form = useFormContext(); const queryClient = useQueryClient(); const updateTaxes = useUpdateTaxes(); const { data: draftOrder } = useDraftOrder(); + const shippingMethodsQuery = useDraftOrderShippingMethods(); - return useMutation({ - mutationKey: checkoutMutationKeys.applyDiscount(session?.id), - mutationFn: async ({ - discountCodes, - }: { - discountCodes: ApplyCheckoutSessionDiscountInput['input']['discountCodes']; - }) => { - const data = jwt - ? await applyDiscount(discountCodes, { accessToken: jwt }, apiHost) - : await applyDiscount(discountCodes, session, apiHost); - return data; - }, - onSuccess: async (data, { discountCodes }) => { + return useApplyDiscountCore({ + onSuccess: async () => { if (!session) return; - const discountTotal = - data?.applyCheckoutSessionDiscount?.totals?.discountTotal; - const responseData = data?.applyCheckoutSessionDiscount; - // Update the cached draft-order query (includes totals) + const deliveryMethod = form.getValues('deliveryMethod'); - if (discountTotal) { - queryClient.setQueryData( - checkoutQueryKeys.draftOrder(session.id), - (old: ResultOf | undefined) => { - if (!old) return old; - return { - ...old, - checkoutSession: { - ...old.checkoutSession, - draftOrder: { - ...old?.checkoutSession?.draftOrder, - totals: { - ...old?.checkoutSession?.draftOrder?.totals, - discountTotal, - total: - responseData?.totals?.total || - old?.checkoutSession?.draftOrder?.totals?.total, - }, - // Update order-level discounts - discounts: - responseData?.discounts || - old?.checkoutSession?.draftOrder?.discounts || - [], - // Update lineItem discounts - lineItems: - responseData?.lineItems - ?.map(responseLineItem => { - const existingLineItem = - old?.checkoutSession?.draftOrder?.lineItems?.find( - li => li.id === responseLineItem.id - ); - return existingLineItem - ? { - ...existingLineItem, - discounts: responseLineItem.discounts || [], - } - : existingLineItem; - }) - .filter(Boolean) || - old?.checkoutSession?.draftOrder?.lineItems, - // Update shippingLine discounts - shippingLines: - responseData?.shippingLines - ?.map((responseShippingLine, index) => { - const existingShippingLine = - old?.checkoutSession?.draftOrder?.shippingLines?.[ - index - ]; - return existingShippingLine - ? { - ...existingShippingLine, - discounts: responseShippingLine.discounts || [], - } - : existingShippingLine; - }) - .filter(Boolean) || - old?.checkoutSession?.draftOrder?.shippingLines, - }, - }, - }; - } - ); - } + const shippingAddress = draftOrder?.shipping?.address; + const hasShippingDestination = Boolean( + shippingAddress?.addressLine1 && + shippingAddress.postalCode && + shippingAddress.countryCode + ); - if (!discountCodes?.length) { - // If no discount codes, we need to remove any existing discounts from the cache - queryClient.setQueryData( - checkoutQueryKeys.draftOrder(session.id), - (old: ResultOf | undefined) => { - if (!old) return old; - return { - ...old, - checkoutSession: { - ...old.checkoutSession, - draftOrder: { - ...old?.checkoutSession?.draftOrder, - discounts: [], - lineItems: old?.checkoutSession?.draftOrder?.lineItems?.map( - li => ({ - ...li, - discounts: [], - }) - ), - shippingLines: - old?.checkoutSession?.draftOrder?.shippingLines?.map( - sl => ({ - ...sl, - discounts: [], - }) - ) || null, - }, - }, - }; - } - ); + if (deliveryMethod === DeliveryMethods.SHIP && hasShippingDestination) { + const previousShippingMethods = shippingMethodsQuery.data ?? []; + const { data: refreshedMethods } = await shippingMethodsQuery.refetch(); + const shippingRequiresReconciliation = requiresShippingReconciliation({ + shippingMethods: refreshedMethods ?? [], + previousShippingMethods, + currentShippingLine: draftOrder?.shippingLines?.[0], + selectedServiceCode: form.getValues('shippingMethod'), + }); + + if (shippingRequiresReconciliation) return; } if (session.enableTaxCollection) { // TODO: Move this to API layer - const deliveryMethod = form.getValues('deliveryMethod'); if (deliveryMethod === DeliveryMethods.PICKUP) { const pickupLocationId = form.getValues('pickupLocationId'); @@ -163,13 +66,12 @@ export function useDiscountApply() { await updateTaxes.mutateAsync(billingAddress); return; } - } else { - const shippingAddress = draftOrder?.shipping?.address; - - if (shippingAddress?.postalCode && shippingAddress?.countryCode) { - await updateTaxes.mutateAsync(undefined); - return; - } + } else if ( + shippingAddress?.postalCode && + shippingAddress?.countryCode + ) { + await updateTaxes.mutateAsync(undefined); + return; } } diff --git a/packages/react/src/components/checkout/form/checkout-form.tsx b/packages/react/src/components/checkout/form/checkout-form.tsx index 7d570fa7..f8de2d7a 100644 --- a/packages/react/src/components/checkout/form/checkout-form.tsx +++ b/packages/react/src/components/checkout/form/checkout-form.tsx @@ -230,10 +230,7 @@ export function CheckoutForm({ hasExpressCheckoutPaymentMethod && session?.enableShipping === true && !fulfillmentSummary.isDigitalOnly && - deliveryMethod !== DeliveryMethods.PURCHASE && - deliveryMethod !== DeliveryMethods.DIGITAL && - !fulfillmentSummary.hasPickupLineItems && - !fulfillmentSummary.hasPurchaseLineItems + !fulfillmentSummary.hasPickupLineItems ); const enableDelivery = Boolean( !fulfillmentSummary.isDigitalOnly && diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx index 5d2ddd19..ed606416 100644 --- a/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx +++ b/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCheckoutContext } from '@/components/checkout/checkout'; +import { getDraftOrderDiscountCodes } from '@/components/checkout/discount/utils/get-draft-order-discount-codes'; import { useGetPriceAdjustments } from '@/components/checkout/discount/utils/use-get-price-adjustments'; import { useDraftOrder, @@ -24,7 +25,7 @@ import { import { useConfirmExpressCheckout } from '@/components/checkout/payment/utils/use-confirm-express-checkout'; import { useIsPaymentDisabled } from '@/components/checkout/payment/utils/use-is-payment-disabled'; import { useLoadPoyntCollect } from '@/components/checkout/payment/utils/use-load-poynt-collect'; -import { filterAndSortShippingMethods } from '@/components/checkout/shipping/utils/filter-shipping-methods'; +import { sortShippingMethods } from '@/components/checkout/shipping/utils/sort-shipping-methods'; import { useGetShippingMethodByAddress } from '@/components/checkout/shipping/utils/use-get-shipping-methods'; import { useGetTaxes } from '@/components/checkout/taxes/utils/use-get-taxes'; import { @@ -144,13 +145,7 @@ export function ExpressCheckoutButton() { setShippingMethods(shippingMethodsData); - const orderSubTotal = totals?.subTotal?.value || 0; - - const sortedMethods = filterAndSortShippingMethods({ - shippingMethods: shippingMethodsData || [], - orderSubTotal, - experimentalRules: session?.experimental_rules, - }); + const sortedMethods = sortShippingMethods(shippingMethodsData || []); const methods = sortedMethods?.map(method => { const shippingMethodPrice = formatCurrency({ @@ -177,7 +172,7 @@ export function ExpressCheckoutButton() { return methods; }, - [getShippingMethodsByAddress.mutateAsync, session, totals] + [getShippingMethodsByAddress.mutateAsync, currencyCode, formatCurrency] ); const handleExpressPayClick = useCallback( @@ -307,83 +302,33 @@ export function ExpressCheckoutButton() { 'idle' | 'fetching' | 'done' >('idle'); - // Extract discount codes from draft order for comparison - const draftOrderDiscountCodes = useMemo(() => { - const allCodes = new Set(); - - // Add order-level discount codes - if (draftOrder?.discounts) { - for (const discount of draftOrder.discounts) { - if (discount.code) { - allCodes.add(discount.code); - } - } - } - - // Add line item-level discount codes - if (draftOrder?.lineItems) { - for (const lineItem of draftOrder.lineItems) { - if (lineItem.discounts) { - for (const discount of lineItem.discounts) { - if (discount.code) { - allCodes.add(discount.code); - } - } - } - } - } - - return Array.from(allCodes).sort().join(','); // Stable string for comparison - }, [draftOrder]); + const draftOrderDiscountCodes = useMemo( + () => getDraftOrderDiscountCodes(draftOrder), + [draftOrder] + ); + const discountCodesKey = JSON.stringify(draftOrderDiscountCodes); + const hasDraftOrder = Boolean(draftOrder); + const areCouponAdjustmentsReady = + draftOrderDiscountCodes.length === 0 || couponFetchStatus === 'done'; useEffect(() => { - if (!draftOrder) return; - // Prevent concurrent fetches (but allow new fetches when draft order changes) - if (couponFetchStatus === 'fetching') return; + if (!hasDraftOrder || couponFetchStatus === 'fetching') return; const fetchPriceAdjustments = async () => { setCouponFetchStatus('fetching'); try { - const allCodes = new Set(); - - // Add order-level discount codes - if (draftOrder?.discounts) { - for (const discount of draftOrder.discounts) { - if (discount.code) { - allCodes.add(discount.code); - } - } - } - - // Add line item-level discount codes - if (draftOrder?.lineItems) { - for (const lineItem of draftOrder.lineItems) { - if (lineItem.discounts) { - for (const discount of lineItem.discounts) { - if (discount.code) { - allCodes.add(discount.code); - } - } - } - } - } - - const discountCodes = Array.from(allCodes); - - // Update refs based on what's in the draft order - if (discountCodes?.length && discountCodes?.[0]) { + const couponCode = draftOrderDiscountCodes[0]; + if (couponCode) { const result = await getPriceAdjustments.mutateAsync({ - discountCodes: [discountCodes?.[0]], + discountCodes: [couponCode], }); if (result) { - // Update refs with current coupon state - appliedCouponCodeRef.current = discountCodes?.[0]; + appliedCouponCodeRef.current = couponCode; calculatedAdjustmentsRef.current = result; } } else { - // No coupons in draft order - clear refs appliedCouponCodeRef.current = null; calculatedAdjustmentsRef.current = null; } @@ -394,7 +339,7 @@ export function ExpressCheckoutButton() { fetchPriceAdjustments(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [draftOrder, draftOrderDiscountCodes]); + }, [hasDraftOrder, discountCodesKey]); // Initialize the TokenizeJs instance when the component mounts // But only after price adjustments have been fetched @@ -407,7 +352,7 @@ export function ExpressCheckoutButton() { !isCollectLoading || !draftOrder || hasMounted.current || - couponFetchStatus !== 'done' + !areCouponAdjustmentsReady ) return; @@ -502,7 +447,7 @@ export function ExpressCheckoutButton() { businessId, isCollectLoading, draftOrder, - couponFetchStatus, + areCouponAdjustmentsReady, countryCode, currencyCode, session?.storeId, diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.tsx index 03fb32fc..b4f221c6 100644 --- a/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.tsx +++ b/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.tsx @@ -10,6 +10,7 @@ import type { } from '@stripe/stripe-js'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCheckoutContext } from '@/components/checkout/checkout'; +import { getDraftOrderDiscountCodes } from '@/components/checkout/discount/utils/get-draft-order-discount-codes'; import { useGetPriceAdjustments } from '@/components/checkout/discount/utils/use-get-price-adjustments'; import { useDraftOrder, @@ -18,7 +19,7 @@ import { import { useIsPaymentDisabled } from '@/components/checkout/payment/utils/use-is-payment-disabled'; import { useStripeCheckout } from '@/components/checkout/payment/utils/use-stripe-checkout'; import { useStripePaymentIntent } from '@/components/checkout/payment/utils/use-stripe-payment-intent'; -import { filterAndSortShippingMethods } from '@/components/checkout/shipping/utils/filter-shipping-methods'; +import { sortShippingMethods } from '@/components/checkout/shipping/utils/sort-shipping-methods'; import { useGetShippingMethodByAddress } from '@/components/checkout/shipping/utils/use-get-shipping-methods'; import { useGetTaxes } from '@/components/checkout/taxes/utils/use-get-taxes'; @@ -83,84 +84,31 @@ export function StripeExpressCheckoutForm() { const appliedCouponCodeRef = useRef(null); const calculatedAdjustmentsRef = useRef(null); - // Extract discount codes from draft order for comparison (stable string) - const draftOrderDiscountCodes = useMemo(() => { - const allCodes = new Set(); - - // Add order-level discount codes - if (draftOrder?.discounts) { - for (const discount of draftOrder.discounts) { - if (discount.code) { - allCodes.add(discount.code); - } - } - } - - // Add line item-level discount codes - if (draftOrder?.lineItems) { - for (const lineItem of draftOrder.lineItems) { - if (lineItem.discounts) { - for (const discount of lineItem.discounts) { - if (discount.code) { - allCodes.add(discount.code); - } - } - } - } - } - - return Array.from(allCodes).sort().join(','); // Stable string for comparison - }, [draftOrder]); + const draftOrderDiscountCodes = useMemo( + () => getDraftOrderDiscountCodes(draftOrder), + [draftOrder] + ); + const discountCodesKey = JSON.stringify(draftOrderDiscountCodes); + const hasDraftOrder = Boolean(draftOrder); - // Fetch and cache price adjustments for pre-applied coupons useEffect(() => { - if (!draftOrder) return; - // Prevent concurrent fetches (but allow new fetches when draft order changes) - if (couponFetchStatus === 'fetching') return; + if (!hasDraftOrder || couponFetchStatus === 'fetching') return; const fetchPriceAdjustments = async () => { setCouponFetchStatus('fetching'); try { - const allCodes = new Set(); - - // Add order-level discount codes - if (draftOrder?.discounts) { - for (const discount of draftOrder.discounts) { - if (discount.code) { - allCodes.add(discount.code); - } - } - } - - // Add line item-level discount codes - if (draftOrder?.lineItems) { - for (const lineItem of draftOrder.lineItems) { - if (lineItem.discounts) { - for (const discount of lineItem.discounts) { - if (discount.code) { - allCodes.add(discount.code); - } - } - } - } - } - - const discountCodes = Array.from(allCodes); - - // Update refs based on what's in the draft order - if (discountCodes?.length && discountCodes?.[0]) { + const couponCode = draftOrderDiscountCodes[0]; + if (couponCode) { const result = await getPriceAdjustments.mutateAsync({ - discountCodes: [discountCodes[0]], + discountCodes: [couponCode], }); if (result) { - // Update refs with current coupon state - appliedCouponCodeRef.current = discountCodes[0]; + appliedCouponCodeRef.current = couponCode; calculatedAdjustmentsRef.current = result; } } else { - // No coupons in draft order - clear refs appliedCouponCodeRef.current = null; calculatedAdjustmentsRef.current = null; } @@ -171,7 +119,7 @@ export function StripeExpressCheckoutForm() { fetchPriceAdjustments(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [draftOrder, draftOrderDiscountCodes]); + }, [hasDraftOrder, discountCodesKey]); // Calculate taxes for express checkout const calculateExpressTaxes = useCallback( @@ -224,19 +172,9 @@ export function StripeExpressCheckoutForm() { setShippingMethods(shippingMethodsData || null); - const orderSubTotal = totals?.subTotal?.value || 0; - - return filterAndSortShippingMethods({ - shippingMethods: shippingMethodsData || [], - orderSubTotal, - experimentalRules: session?.experimental_rules, - }); + return sortShippingMethods(shippingMethodsData || []); }, - [ - getShippingMethodsByAddress, - session?.experimental_rules, - totals?.subTotal?.value, - ] + [getShippingMethodsByAddress] ); // Convert shipping methods to Stripe ShippingRate format diff --git a/packages/react/src/components/checkout/shipping/shipping-method.tsx b/packages/react/src/components/checkout/shipping/shipping-method.tsx index d14bcab2..60c81578 100644 --- a/packages/react/src/components/checkout/shipping/shipping-method.tsx +++ b/packages/react/src/components/checkout/shipping/shipping-method.tsx @@ -7,16 +7,19 @@ import { useDraftOrder, useDraftOrderShipping, useDraftOrderShippingAddress, - useDraftOrderTotals, } from '@/components/checkout/order/use-draft-order'; import { useUpdateTaxes } from '@/components/checkout/order/use-update-taxes'; import { useIsPaymentDisabled } from '@/components/checkout/payment/utils/use-is-payment-disabled'; import { ShippingMethodSkeleton } from '@/components/checkout/shipping/shipping-method-skeleton'; -import { filterAndSortShippingMethods } from '@/components/checkout/shipping/utils/filter-shipping-methods'; +import { + getShippingMethodsKey, + selectShippingMethod, +} from '@/components/checkout/shipping/utils/requires-shipping-reconciliation'; import { getShippingFulfillmentSyncKey, shouldApplyShippingMethod, } from '@/components/checkout/shipping/utils/should-apply-shipping-method'; +import { sortShippingMethods } from '@/components/checkout/shipping/utils/sort-shipping-methods'; import { useApplyShippingMethod } from '@/components/checkout/shipping/utils/use-apply-shipping-method'; import { useDraftOrderShippingMethods } from '@/components/checkout/shipping/utils/use-draft-order-shipping-methods'; import { useFormatCurrency } from '@/components/checkout/utils/format-currency'; @@ -61,7 +64,6 @@ export function ShippingMethodForm() { useDraftOrderShippingMethods(); const { data: shippingAddress, isLoading: isShippingAddressLoading } = useDraftOrderShippingAddress(); - const { data: totals } = useDraftOrderTotals(); const { data: order, isLoading: isDraftOrderLoading } = useDraftOrder(); const { data: shippingLines } = useDraftOrderShipping(); @@ -74,15 +76,10 @@ export function ShippingMethodForm() { const fulfillmentSyncKey = getShippingFulfillmentSyncKey(order?.lineItems); const hasLineItemsMissingShippingFulfillment = Boolean(fulfillmentSyncKey); - const orderSubTotal = totals?.subTotal?.value || 0; - - const shippingMethods = filterAndSortShippingMethods({ - shippingMethods: shippingMethodsData || [], - orderSubTotal, - experimentalRules: session?.experimental_rules, - }); + const shippingMethods = sortShippingMethods(shippingMethodsData || []); const applyShippingMethod = useApplyShippingMethod(); + const lastShippingMethodsKeyRef = useRef(null); // Track the last processed state to avoid duplicate API calls const lastProcessedStateRef = useRef<{ @@ -126,6 +123,7 @@ export function ShippingMethodForm() { // Case 1: No shipping methods available - clear shipping and set fulfillment to SHIP if (!hasShippingMethods && hasShippingAddress) { + lastShippingMethodsKeyRef.current = getShippingMethodsKey([]); // Apply empty shipping method if: // - Pickup mode and has shipping code OR wasn't pickup before // - Shipping mode and (had methods before OR haven't cleared yet) @@ -158,19 +156,17 @@ export function ShippingMethodForm() { // Case 2: Shipping methods available - apply or re-apply as needed if (hasShippingMethods) { - const firstMethod = shippingMethods[0]; const currentFormMethod = form.getValues('shippingMethod'); const existingMethod = currentFormMethod || currentServiceCode; + const { selectedMethod: methodToApply, methodsKey } = + selectShippingMethod({ + shippingMethods, + currentServiceCode: existingMethod, + previousMethodsKey: lastShippingMethodsKeyRef.current, + }); + lastShippingMethodsKeyRef.current = methodsKey; - // Try to find the existing method in available methods. Prefer the - // current form selection so an in-flight explicit user click is not - // overwritten by the stale draft-order shipping line while the mutation - // and refetch settle. - const matchedMethod = existingMethod - ? shippingMethods.find(m => m.serviceCode === existingMethod) - : null; - - const methodToApply = matchedMethod || firstMethod; + if (!methodToApply) return; // Check if we've already processed this exact state. If cart contents // changed after a shipping method was selected, shippingLines can still // match the selected rate while new line items are NONE. In that case we diff --git a/packages/react/src/components/checkout/shipping/utils/filter-shipping-methods.ts b/packages/react/src/components/checkout/shipping/utils/filter-shipping-methods.ts deleted file mode 100644 index 677fed18..00000000 --- a/packages/react/src/components/checkout/shipping/utils/filter-shipping-methods.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { CheckoutSession, ShippingMethod } from '@/types'; - -interface FilterShippingMethodsParams { - shippingMethods: ShippingMethod[]; - orderSubTotal: number; - experimentalRules?: CheckoutSession['experimental_rules']; -} - -export function filterAndSortShippingMethods({ - shippingMethods, - orderSubTotal, - experimentalRules, -}: FilterShippingMethodsParams): ShippingMethod[] { - const enableFreeShippingRule = experimentalRules?.freeShipping?.enabled; - const freeShippingMinimumOrderTotal = - experimentalRules?.freeShipping?.minimumOrderTotal || 0; - - return shippingMethods - .filter( - method => - !( - enableFreeShippingRule && - method?.cost?.value === 0 && - orderSubTotal < freeShippingMinimumOrderTotal - ) - ) - .sort((a, b) => { - const costA = a?.cost?.value || 0; - const costB = b?.cost?.value || 0; - - // First sort by cost - if (costA !== costB) { - return costA - costB; - } - - // If costs are equal, sort by name - const nameA = a?.displayName || ''; - const nameB = b?.displayName || ''; - return nameA.localeCompare(nameB); - }); -} diff --git a/packages/react/src/components/checkout/shipping/utils/requires-shipping-reconciliation.test.ts b/packages/react/src/components/checkout/shipping/utils/requires-shipping-reconciliation.test.ts new file mode 100644 index 00000000..ab8764f6 --- /dev/null +++ b/packages/react/src/components/checkout/shipping/utils/requires-shipping-reconciliation.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; +import type { ShippingLines, ShippingMethod } from '@/types'; +import { requiresShippingReconciliation } from './requires-shipping-reconciliation'; + +function shippingMethod(serviceCode: string, cost: number): ShippingMethod { + return { + serviceCode, + carrierCode: 'carrier', + displayName: serviceCode, + description: null, + features: [], + minDeliveryDate: null, + maxDeliveryDate: null, + cost: { value: cost, currencyCode: 'USD' }, + }; +} + +function shippingLine(serviceCode: string, cost: number): ShippingLines { + return { + id: `shipping-${serviceCode}`, + requestedService: serviceCode, + requestedProvider: 'carrier', + name: serviceCode, + amount: { value: cost, currencyCode: 'USD' }, + discounts: [], + }; +} + +describe('requiresShippingReconciliation', () => { + it('returns false when the selected service and cost are unchanged', () => { + expect( + requiresShippingReconciliation({ + shippingMethods: [shippingMethod('standard', 1000)], + currentShippingLine: shippingLine('standard', 1000), + selectedServiceCode: 'standard', + }) + ).toBe(false); + }); + + it('returns true when a cheaper default method becomes available', () => { + expect( + requiresShippingReconciliation({ + shippingMethods: [ + shippingMethod('standard', 1000), + shippingMethod('free', 0), + ], + currentShippingLine: shippingLine('standard', 1000), + selectedServiceCode: 'standard', + }) + ).toBe(true); + }); + + it('preserves the selected method when available methods are unchanged', () => { + const shippingMethods = [ + shippingMethod('standard', 1000), + shippingMethod('free', 0), + ]; + + expect( + requiresShippingReconciliation({ + shippingMethods, + previousShippingMethods: shippingMethods, + currentShippingLine: shippingLine('standard', 1000), + selectedServiceCode: 'standard', + }) + ).toBe(false); + }); + + it('returns true when the selected service becomes free', () => { + expect( + requiresShippingReconciliation({ + shippingMethods: [shippingMethod('standard', 0)], + currentShippingLine: shippingLine('standard', 1000), + selectedServiceCode: 'standard', + }) + ).toBe(true); + }); + + it('returns true when the selected service is no longer available', () => { + expect( + requiresShippingReconciliation({ + shippingMethods: [shippingMethod('express', 1500)], + currentShippingLine: shippingLine('standard', 1000), + selectedServiceCode: 'standard', + }) + ).toBe(true); + }); + + it('returns true when no methods remain for an applied shipping line', () => { + expect( + requiresShippingReconciliation({ + shippingMethods: [], + currentShippingLine: shippingLine('standard', 1000), + selectedServiceCode: 'standard', + }) + ).toBe(true); + }); + + it('returns false when there are no methods and no applied shipping line', () => { + expect( + requiresShippingReconciliation({ + shippingMethods: [], + currentShippingLine: null, + selectedServiceCode: null, + }) + ).toBe(false); + }); +}); diff --git a/packages/react/src/components/checkout/shipping/utils/requires-shipping-reconciliation.ts b/packages/react/src/components/checkout/shipping/utils/requires-shipping-reconciliation.ts new file mode 100644 index 00000000..a68ae6a7 --- /dev/null +++ b/packages/react/src/components/checkout/shipping/utils/requires-shipping-reconciliation.ts @@ -0,0 +1,63 @@ +import type { ShippingLines, ShippingMethod } from '@/types'; +import { sortShippingMethods } from './sort-shipping-methods'; + +interface SelectShippingMethodParams { + shippingMethods: ShippingMethod[]; + currentServiceCode?: string | null; + previousMethodsKey?: string | null; +} + +interface RequiresShippingReconciliationParams { + shippingMethods: ShippingMethod[]; + previousShippingMethods?: ShippingMethod[]; + currentShippingLine?: ShippingLines | null; + selectedServiceCode?: string | null; +} + +export function getShippingMethodsKey(shippingMethods: ShippingMethod[]) { + return JSON.stringify( + sortShippingMethods(shippingMethods).map(method => ({ + serviceCode: method.serviceCode, + carrierCode: method.carrierCode, + cost: method.cost, + })) + ); +} + +export function selectShippingMethod({ + shippingMethods, + currentServiceCode, + previousMethodsKey, +}: SelectShippingMethodParams) { + const availableMethods = sortShippingMethods(shippingMethods); + const methodsKey = getShippingMethodsKey(availableMethods); + const methodsChanged = methodsKey !== previousMethodsKey; + const selectedMethod = methodsChanged + ? availableMethods[0] + : availableMethods.find( + method => method.serviceCode === currentServiceCode + ) || availableMethods[0]; + + return { selectedMethod, methodsKey }; +} + +export function requiresShippingReconciliation({ + shippingMethods, + previousShippingMethods = [], + currentShippingLine, + selectedServiceCode, +}: RequiresShippingReconciliationParams) { + const currentServiceCode = + selectedServiceCode || currentShippingLine?.requestedService; + const { selectedMethod } = selectShippingMethod({ + shippingMethods, + currentServiceCode, + previousMethodsKey: getShippingMethodsKey(previousShippingMethods), + }); + + return selectedMethod + ? selectedMethod.serviceCode !== currentShippingLine?.requestedService || + (selectedMethod.cost?.value ?? null) !== + (currentShippingLine?.amount?.value ?? null) + : Boolean(currentShippingLine?.requestedService); +} diff --git a/packages/react/src/components/checkout/shipping/utils/sort-shipping-methods.ts b/packages/react/src/components/checkout/shipping/utils/sort-shipping-methods.ts new file mode 100644 index 00000000..7aafc090 --- /dev/null +++ b/packages/react/src/components/checkout/shipping/utils/sort-shipping-methods.ts @@ -0,0 +1,18 @@ +import type { ShippingMethod } from '@/types'; + +export function sortShippingMethods( + shippingMethods: ShippingMethod[] +): ShippingMethod[] { + return [...shippingMethods].sort((a, b) => { + const costA = a?.cost?.value || 0; + const costB = b?.cost?.value || 0; + + if (costA !== costB) { + return costA - costB; + } + + const nameA = a?.displayName || ''; + const nameB = b?.displayName || ''; + return nameA.localeCompare(nameB); + }); +} diff --git a/packages/react/src/components/checkout/shipping/utils/use-apply-shipping-method-core.ts b/packages/react/src/components/checkout/shipping/utils/use-apply-shipping-method-core.ts new file mode 100644 index 00000000..9d504917 --- /dev/null +++ b/packages/react/src/components/checkout/shipping/utils/use-apply-shipping-method-core.ts @@ -0,0 +1,89 @@ +import type { QueryClient } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import type { ResultOf } from 'gql.tada'; +import { useCheckoutContext } from '@/components/checkout/checkout'; +import { + checkoutMutationKeys, + checkoutQueryKeys, +} from '@/components/checkout/utils/query-keys'; +import { useGoDaddyContext } from '@/godaddy-provider'; +import { ApplyCheckoutSessionShippingMethodMutation } from '@/lib/godaddy/checkout-mutations.ts'; +import { DraftOrderQuery } from '@/lib/godaddy/checkout-queries.ts'; +import { applyShippingMethod } from '@/lib/godaddy/godaddy'; +import type { ApplyCheckoutSessionShippingMethodInput } from '@/types'; + +type ShippingMutationResult = ResultOf< + typeof ApplyCheckoutSessionShippingMethodMutation +>; +type ShippingMethods = ApplyCheckoutSessionShippingMethodInput['input']; + +interface UseApplyShippingMethodCoreOptions { + onSuccess?: ( + data: ShippingMutationResult, + shippingMethods: ShippingMethods + ) => Promise | void; + onError?: (error: Error) => void; +} + +export function updateShippingMethodCache( + queryClient: QueryClient, + sessionId: string, + data: ShippingMutationResult +) { + const shippingTotal = + data.applyCheckoutSessionShippingMethod?.draftOrder?.totals?.shippingTotal; + if (!shippingTotal) return; + + queryClient.setQueryData( + checkoutQueryKeys.draftOrder(sessionId), + (cached: ResultOf | undefined) => { + if (!cached) return cached; + + return { + ...cached, + checkoutSession: { + ...cached.checkoutSession, + draftOrder: { + ...cached.checkoutSession?.draftOrder, + shippingLines: [ + { + ...cached.checkoutSession?.draftOrder?.shippingLines?.[0], + amount: { ...shippingTotal }, + }, + ], + totals: { + ...cached.checkoutSession?.draftOrder?.totals, + shippingTotal: { ...shippingTotal }, + }, + }, + }, + }; + } + ); +} + +export function useApplyShippingMethodCore( + options: UseApplyShippingMethodCoreOptions = {} +) { + const { session, jwt } = useCheckoutContext(); + const { apiHost } = useGoDaddyContext(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: checkoutMutationKeys.applyShippingMethod(session?.id), + mutationFn: async (shippingMethods: ShippingMethods) => { + if (!session) return; + + return jwt + ? applyShippingMethod(shippingMethods, { accessToken: jwt }, apiHost) + : applyShippingMethod(shippingMethods, session, apiHost); + }, + onSuccess: async (data, shippingMethods) => { + if (!session || !data) return; + + updateShippingMethodCache(queryClient, session.id, data); + await options.onSuccess?.(data, shippingMethods); + }, + onError: options.onError, + }); +} diff --git a/packages/react/src/components/checkout/shipping/utils/use-apply-shipping-method.ts b/packages/react/src/components/checkout/shipping/utils/use-apply-shipping-method.ts index 331fedee..896f6ae2 100644 --- a/packages/react/src/components/checkout/shipping/utils/use-apply-shipping-method.ts +++ b/packages/react/src/components/checkout/shipping/utils/use-apply-shipping-method.ts @@ -1,88 +1,26 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import type { ResultOf } from 'gql.tada'; +import { useQueryClient } from '@tanstack/react-query'; import { useCheckoutContext } from '@/components/checkout/checkout'; -import { useDiscountApply } from '@/components/checkout/discount'; +import { useApplyDiscountCore } from '@/components/checkout/discount/utils/use-apply-discount-core'; import { useDraftOrder } from '@/components/checkout/order/use-draft-order'; import { useUpdateTaxes } from '@/components/checkout/order/use-update-taxes'; -import { - checkoutMutationKeys, - checkoutQueryKeys, -} from '@/components/checkout/utils/query-keys'; -import { useGoDaddyContext } from '@/godaddy-provider'; -import type { DraftOrderQuery } from '@/lib/godaddy/checkout-queries.ts'; -import { applyShippingMethod } from '@/lib/godaddy/godaddy'; +import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys'; import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors'; -import type { ApplyCheckoutSessionShippingMethodInput } from '@/types'; +import { useApplyShippingMethodCore } from './use-apply-shipping-method-core'; export function useApplyShippingMethod() { - const { session, jwt, setCheckoutErrors } = useCheckoutContext(); - const { apiHost } = useGoDaddyContext(); + const { session, setCheckoutErrors } = useCheckoutContext(); const { data: order } = useDraftOrder(); const updateTaxes = useUpdateTaxes(); - const applyDiscount = useDiscountApply(); + const applyDiscount = useApplyDiscountCore(); const queryClient = useQueryClient(); - return useMutation({ - mutationKey: checkoutMutationKeys.applyShippingMethod(session?.id), - mutationFn: async ( - shippingMethods: ApplyCheckoutSessionShippingMethodInput['input'] - ) => { - if (!session) return; - const data = jwt - ? await applyShippingMethod( - shippingMethods, - { accessToken: jwt }, - apiHost - ) - : await applyShippingMethod(shippingMethods, session, apiHost); - return data; - }, - onSuccess: async data => { + return useApplyShippingMethodCore({ + onSuccess: async () => { setCheckoutErrors(undefined); if (!session) return; - // Extract shippingTotal from mutation response - const shippingTotal = - data?.applyCheckoutSessionShippingMethod?.draftOrder?.totals - ?.shippingTotal; - - // Update the cached draft-order query (includes totals) - if (shippingTotal) { - queryClient.setQueryData( - checkoutQueryKeys.draftOrder(session.id), - (old: ResultOf | undefined) => { - if (!old) return old; - - return { - ...old, - checkoutSession: { - ...old.checkoutSession, - draftOrder: { - ...old?.checkoutSession?.draftOrder, - shippingLines: [ - { - ...old?.checkoutSession?.draftOrder?.shippingLines?.[0], - amount: { - ...shippingTotal, - }, - }, - ], - totals: { - ...old?.checkoutSession?.draftOrder?.totals, - shippingTotal: { - ...shippingTotal, - }, - }, - }, - }, - }; - } - ); - } - const allCodes = new Set(); - // Add order-level discount codes if (order?.discounts) { for (const discount of order.discounts) { if (discount.code) { @@ -91,9 +29,6 @@ export function useApplyShippingMethod() { } } - // Line item-level discount codes do not need to be re-applied as they would not be affected by shipping method changes - - // Add shipping line-level discount codes if (order?.shippingLines) { for (const shippingLine of order.shippingLines) { if (shippingLine.discounts) { @@ -108,12 +43,11 @@ export function useApplyShippingMethod() { const discountCodes = Array.from(allCodes); - if (session?.enablePromotionCodes && discountCodes?.length) { - /* should re-apply discounts if they were previously applied */ - await applyDiscount.mutateAsync({ - discountCodes, - }); - } else if (session?.enableTaxCollection) { + if (session.enablePromotionCodes && discountCodes.length) { + await applyDiscount.mutateAsync({ discountCodes }); + } + + if (session.enableTaxCollection) { await updateTaxes.mutateAsync(undefined); } else { await queryClient.invalidateQueries({ diff --git a/packages/react/src/lib/godaddy/checkout-mutations.ts b/packages/react/src/lib/godaddy/checkout-mutations.ts index 39f4780a..0c6f4ff1 100644 --- a/packages/react/src/lib/godaddy/checkout-mutations.ts +++ b/packages/react/src/lib/godaddy/checkout-mutations.ts @@ -58,10 +58,6 @@ export const CreateCheckoutSessionMutation = graphql(` } } experimental_rules { - freeShipping { - enabled - minimumOrderTotal - } gopay_override { enabled goPayAppId diff --git a/packages/react/src/lib/godaddy/checkout-queries.ts b/packages/react/src/lib/godaddy/checkout-queries.ts index 4e3e6185..815e3e53 100644 --- a/packages/react/src/lib/godaddy/checkout-queries.ts +++ b/packages/react/src/lib/godaddy/checkout-queries.ts @@ -58,10 +58,6 @@ export const GetCheckoutSessionQuery = graphql(` } } experimental_rules { - freeShipping { - enabled - minimumOrderTotal - } gopay_override { enabled goPayAppId From b284c92905399e765251fe4520b6bcb1a1150cca Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Wed, 26 Aug 2026 11:59:23 -0500 Subject: [PATCH 24/32] add tests and changeset --- .changeset/calm-coupons-ship.md | 5 +++ .../checkout-digital-fulfillment.test.tsx | 6 +-- .../__tests__/checkout-express.test.tsx | 43 +++++++++++++++++++ 3 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 .changeset/calm-coupons-ship.md diff --git a/.changeset/calm-coupons-ship.md b/.changeset/calm-coupons-ship.md new file mode 100644 index 00000000..5adcce0d --- /dev/null +++ b/.changeset/calm-coupons-ship.md @@ -0,0 +1,5 @@ +--- +"@godaddy/react": patch +--- + +Keep shipping rates, discounts, taxes, and express checkout in sync when coupons change. 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 970fa5cc..c22d9165 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 @@ -342,7 +342,7 @@ describe('Digital fulfillment checkout', () => { ).toBeVisible(); }); - it('hides express for mixed digital and pickup orders', async () => { + it('shows express for mixed digital and pickup orders when shipping is enabled', async () => { renderCheckout({ draftOrderOverrides: { lineItems: [ @@ -362,8 +362,8 @@ describe('Digital fulfillment checkout', () => { await waitForCheckoutReady(); expect( - screen.queryByTestId('mock-godaddy-express-button') - ).not.toBeInTheDocument(); + await screen.findByTestId('mock-godaddy-express-button') + ).toBeVisible(); }); it('does not let digital NONE lines trigger shipping fulfillment sync', async () => { diff --git a/packages/react/src/components/checkout/__tests__/checkout-express.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-express.test.tsx index edd941d6..09236f2c 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-express.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-express.test.tsx @@ -92,6 +92,49 @@ describe('Express checkout section visibility', () => { ).toBeVisible(); }); + it('does not render express checkout for a purchase-only session', async () => { + renderCheckout({ + sessionOverrides: { + enableShipping: false, + enableLocalPickup: false, + paymentMethods: { + card: { processor: 'stripe', checkoutTypes: ['standard'] }, + express: { processor: 'godaddy', checkoutTypes: ['express'] }, + }, + }, + draftOrderOverrides: { + lineItems: [{ fulfillmentMode: 'PURCHASE' }], + }, + }); + await waitForCheckoutReady(); + + expect( + screen.queryByTestId('mock-godaddy-express-button') + ).not.toBeInTheDocument(); + expect(screen.queryByText(/^OR$/)).not.toBeInTheDocument(); + }); + + it('renders express checkout for pickup fulfillment when shipping is enabled', async () => { + renderCheckout({ + sessionOverrides: { + enableShipping: true, + enableLocalPickup: true, + paymentMethods: { + card: { processor: 'stripe', checkoutTypes: ['standard'] }, + express: { processor: 'godaddy', checkoutTypes: ['express'] }, + }, + }, + draftOrderOverrides: { + lineItems: [{ fulfillmentMode: 'PICKUP' }], + }, + }); + await waitForCheckoutReady(); + + expect( + await screen.findByTestId('mock-godaddy-express-button') + ).toBeVisible(); + }); + it('does not render express checkout for a digital-only order', async () => { renderCheckout({ sessionOverrides: { From 12d9f5b4138fdf2c64c115287f144f99a9134970 Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Wed, 26 Aug 2026 12:40:01 -0500 Subject: [PATCH 25/32] add reconcileAfterDiscount hook --- .../discount/utils/use-discount-apply.ts | 79 +----------- .../utils/use-reconcile-after-discount.ts | 117 ++++++++++++++++++ .../checkout/form/checkout-form.tsx | 3 +- .../checkout/shipping/shipping-method.tsx | 51 ++++---- .../shipping/utils/build-shipping-payload.ts | 18 +++ .../utils/use-apply-shipping-method-core.ts | 24 ++-- 6 files changed, 179 insertions(+), 113 deletions(-) create mode 100644 packages/react/src/components/checkout/discount/utils/use-reconcile-after-discount.ts create mode 100644 packages/react/src/components/checkout/shipping/utils/build-shipping-payload.ts 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 368e1e12..5470a350 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 @@ -1,83 +1,12 @@ -import { useQueryClient } from '@tanstack/react-query'; -import { useFormContext } from 'react-hook-form'; -import { useCheckoutContext } from '@/components/checkout/checkout'; -import { DeliveryMethods } from '@/components/checkout/delivery/delivery-methods'; -import { useDraftOrder } from '@/components/checkout/order/use-draft-order'; -import { useUpdateTaxes } from '@/components/checkout/order/use-update-taxes'; -import { requiresShippingReconciliation } from '@/components/checkout/shipping/utils/requires-shipping-reconciliation'; -import { useDraftOrderShippingMethods } from '@/components/checkout/shipping/utils/use-draft-order-shipping-methods'; -import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys'; import { useApplyDiscountCore } from './use-apply-discount-core'; +import { useReconcileAfterDiscount } from './use-reconcile-after-discount'; export function useDiscountApply() { - const { session } = useCheckoutContext(); - const form = useFormContext(); - const queryClient = useQueryClient(); - const updateTaxes = useUpdateTaxes(); - const { data: draftOrder } = useDraftOrder(); - const shippingMethodsQuery = useDraftOrderShippingMethods(); + const reconcileAfterDiscount = useReconcileAfterDiscount(); return useApplyDiscountCore({ - onSuccess: async () => { - if (!session) return; - - const deliveryMethod = form.getValues('deliveryMethod'); - - const shippingAddress = draftOrder?.shipping?.address; - const hasShippingDestination = Boolean( - shippingAddress?.addressLine1 && - shippingAddress.postalCode && - shippingAddress.countryCode - ); - - if (deliveryMethod === DeliveryMethods.SHIP && hasShippingDestination) { - const previousShippingMethods = shippingMethodsQuery.data ?? []; - const { data: refreshedMethods } = await shippingMethodsQuery.refetch(); - const shippingRequiresReconciliation = requiresShippingReconciliation({ - shippingMethods: refreshedMethods ?? [], - previousShippingMethods, - currentShippingLine: draftOrder?.shippingLines?.[0], - selectedServiceCode: form.getValues('shippingMethod'), - }); - - if (shippingRequiresReconciliation) return; - } - - if (session.enableTaxCollection) { - // TODO: Move this to API layer - - if (deliveryMethod === DeliveryMethods.PICKUP) { - const pickupLocationId = form.getValues('pickupLocationId'); - 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; - - if (billingAddress?.postalCode && billingAddress?.countryCode) { - await updateTaxes.mutateAsync(billingAddress); - return; - } - } else if ( - shippingAddress?.postalCode && - shippingAddress?.countryCode - ) { - await updateTaxes.mutateAsync(undefined); - return; - } - } - - await queryClient.invalidateQueries({ - queryKey: checkoutQueryKeys.draftOrder(session.id), - }); + onSuccess: async (_data, variables) => { + await reconcileAfterDiscount(variables); }, }); } diff --git a/packages/react/src/components/checkout/discount/utils/use-reconcile-after-discount.ts b/packages/react/src/components/checkout/discount/utils/use-reconcile-after-discount.ts new file mode 100644 index 00000000..0d7ab832 --- /dev/null +++ b/packages/react/src/components/checkout/discount/utils/use-reconcile-after-discount.ts @@ -0,0 +1,117 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { useFormContext } from 'react-hook-form'; +import { useCheckoutContext } from '@/components/checkout/checkout'; +import { DeliveryMethods } from '@/components/checkout/delivery/delivery-methods'; +import { useDraftOrder } from '@/components/checkout/order/use-draft-order'; +import { useUpdateTaxes } from '@/components/checkout/order/use-update-taxes'; +import { buildShippingPayload } from '@/components/checkout/shipping/utils/build-shipping-payload'; +import { + getShippingMethodsKey, + requiresShippingReconciliation, + selectShippingMethod, +} from '@/components/checkout/shipping/utils/requires-shipping-reconciliation'; +import { useApplyShippingMethodCore } from '@/components/checkout/shipping/utils/use-apply-shipping-method-core'; +import { useDraftOrderShippingMethods } from '@/components/checkout/shipping/utils/use-draft-order-shipping-methods'; +import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys'; +import { + type ApplyDiscountVariables, + useApplyDiscountCore, +} from './use-apply-discount-core'; + +export function useReconcileAfterDiscount() { + const { session } = useCheckoutContext(); + const form = useFormContext(); + const queryClient = useQueryClient(); + const updateTaxes = useUpdateTaxes(); + const { data: draftOrder } = useDraftOrder(); + const shippingMethodsQuery = useDraftOrderShippingMethods(); + const applyShippingMethod = useApplyShippingMethodCore(); + const reapplyDiscount = useApplyDiscountCore(); + + return async (variables: ApplyDiscountVariables) => { + if (!session) return; + + const deliveryMethod = form.getValues('deliveryMethod'); + const shippingAddress = draftOrder?.shipping?.address; + const hasShippingDestination = Boolean( + shippingAddress?.addressLine1 && + shippingAddress.postalCode && + shippingAddress.countryCode + ); + + if (deliveryMethod === DeliveryMethods.SHIP && hasShippingDestination) { + const previousShippingMethods = shippingMethodsQuery.data ?? []; + const { data: refreshedMethods } = await shippingMethodsQuery.refetch(); + const shippingRequiresReconciliation = requiresShippingReconciliation({ + shippingMethods: refreshedMethods ?? [], + previousShippingMethods, + currentShippingLine: draftOrder?.shippingLines?.[0], + selectedServiceCode: form.getValues('shippingMethod'), + }); + + if (shippingRequiresReconciliation) { + const currentServiceCode = + form.getValues('shippingMethod') || + draftOrder?.shippingLines?.[0]?.requestedService; + const { selectedMethod } = selectShippingMethod({ + shippingMethods: refreshedMethods ?? [], + currentServiceCode, + previousMethodsKey: getShippingMethodsKey(previousShippingMethods), + }); + + form.setValue('shippingMethod', selectedMethod?.serviceCode ?? '', { + shouldDirty: false, + }); + await applyShippingMethod.mutateAsync( + selectedMethod ? buildShippingPayload(selectedMethod) : [] + ); + + if (session.enablePromotionCodes && variables.discountCodes?.length) { + await reapplyDiscount.mutateAsync(variables); + } + + if (session.enableTaxCollection) { + await updateTaxes.mutateAsync(undefined); + } else { + await invalidateDraftOrder(); + } + return; + } + } + + if (session.enableTaxCollection) { + if (deliveryMethod === DeliveryMethods.PICKUP) { + const pickupLocationId = form.getValues('pickupLocationId'); + const locationAddress = session.locations?.find( + location => location.id === pickupLocationId + )?.address; + + if (locationAddress) { + await updateTaxes.mutateAsync(locationAddress); + return; + } + } else if ( + deliveryMethod === DeliveryMethods.PURCHASE || + deliveryMethod === DeliveryMethods.DIGITAL + ) { + const billingAddress = draftOrder?.billing?.address; + + if (billingAddress?.postalCode && billingAddress?.countryCode) { + await updateTaxes.mutateAsync(billingAddress); + return; + } + } else if (shippingAddress?.postalCode && shippingAddress?.countryCode) { + await updateTaxes.mutateAsync(undefined); + return; + } + } + + await invalidateDraftOrder(); + }; + + function invalidateDraftOrder() { + return queryClient.invalidateQueries({ + queryKey: checkoutQueryKeys.draftOrder(session?.id), + }); + } +} diff --git a/packages/react/src/components/checkout/form/checkout-form.tsx b/packages/react/src/components/checkout/form/checkout-form.tsx index f8de2d7a..bcecac7f 100644 --- a/packages/react/src/components/checkout/form/checkout-form.tsx +++ b/packages/react/src/components/checkout/form/checkout-form.tsx @@ -229,8 +229,7 @@ export function CheckoutForm({ subtotal > 0 && hasExpressCheckoutPaymentMethod && session?.enableShipping === true && - !fulfillmentSummary.isDigitalOnly && - !fulfillmentSummary.hasPickupLineItems + !fulfillmentSummary.isDigitalOnly ); const enableDelivery = Boolean( !fulfillmentSummary.isDigitalOnly && diff --git a/packages/react/src/components/checkout/shipping/shipping-method.tsx b/packages/react/src/components/checkout/shipping/shipping-method.tsx index 60c81578..a0b1789e 100644 --- a/packages/react/src/components/checkout/shipping/shipping-method.tsx +++ b/packages/react/src/components/checkout/shipping/shipping-method.tsx @@ -1,4 +1,4 @@ -import { useQueryClient } from '@tanstack/react-query'; +import { useIsMutating, useQueryClient } from '@tanstack/react-query'; import { useEffect, useRef } from 'react'; import { useFormContext } from 'react-hook-form'; import { useCheckoutContext } from '@/components/checkout/checkout'; @@ -8,9 +8,9 @@ import { useDraftOrderShipping, useDraftOrderShippingAddress, } from '@/components/checkout/order/use-draft-order'; -import { useUpdateTaxes } from '@/components/checkout/order/use-update-taxes'; import { useIsPaymentDisabled } from '@/components/checkout/payment/utils/use-is-payment-disabled'; import { ShippingMethodSkeleton } from '@/components/checkout/shipping/shipping-method-skeleton'; +import { buildShippingPayload } from '@/components/checkout/shipping/utils/build-shipping-payload'; import { getShippingMethodsKey, selectShippingMethod, @@ -23,40 +23,22 @@ import { sortShippingMethods } from '@/components/checkout/shipping/utils/sort-s import { useApplyShippingMethod } from '@/components/checkout/shipping/utils/use-apply-shipping-method'; import { useDraftOrderShippingMethods } from '@/components/checkout/shipping/utils/use-draft-order-shipping-methods'; import { useFormatCurrency } from '@/components/checkout/utils/format-currency'; -import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys'; +import { + checkoutMutationKeys, + checkoutQueryKeys, +} from '@/components/checkout/utils/query-keys'; import { Label } from '@/components/ui/label'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { useGoDaddyContext } from '@/godaddy-provider'; import { cn } from '@/lib/utils'; import { eventIds } from '@/tracking/events'; import { TrackingEventType, track } from '@/tracking/track'; -import type { ShippingMethod } from '@/types'; - -// Helper function to build the shipping payload -function buildShippingPayload(method: ShippingMethod) { - return [ - { - taxTotal: { - value: 0, - currencyCode: method?.cost?.currencyCode || 'USD', - }, - subTotal: { - value: method?.cost?.value || 0, - currencyCode: method?.cost?.currencyCode || 'USD', - }, - requestedService: method?.serviceCode, - requestedProvider: method?.carrierCode, - name: method?.displayName || '', - }, - ]; -} export function ShippingMethodForm() { const formatCurrency = useFormatCurrency(); const form = useFormContext(); const { t } = useGoDaddyContext(); const { session, isConfirmingCheckout } = useCheckoutContext(); - const updateTaxes = useUpdateTaxes(); const queryClient = useQueryClient(); const isPaymentDisabled = useIsPaymentDisabled(); @@ -79,6 +61,10 @@ export function ShippingMethodForm() { const shippingMethods = sortShippingMethods(shippingMethodsData || []); const applyShippingMethod = useApplyShippingMethod(); + const isApplyingDiscount = + useIsMutating({ + mutationKey: checkoutMutationKeys.applyDiscount(session?.id), + }) > 0; const lastShippingMethodsKeyRef = useRef(null); // Track the last processed state to avoid duplicate API calls @@ -99,6 +85,18 @@ export function ShippingMethodForm() { }); useEffect(() => { + if (isApplyingDiscount) { + lastShippingMethodsKeyRef.current = + getShippingMethodsKey(shippingMethods); + lastProcessedStateRef.current = { + ...lastProcessedStateRef.current, + serviceCode: shippingLines?.requestedService ?? null, + cost: shippingLines?.amount?.value ?? null, + hadShippingMethods: shippingMethods.length > 0, + }; + return; + } + if ( isShippingMethodsLoading || isDraftOrderLoading || @@ -206,8 +204,6 @@ export function ShippingMethodForm() { }); }, }); - } else if (session?.enableTaxCollection) { - updateTaxes.mutate(undefined); } lastProcessedStateRef.current = { @@ -224,14 +220,13 @@ export function ShippingMethodForm() { } }, [ isConfirmingCheckout, + isApplyingDiscount, shippingMethods, shippingLines, hasShippingAddress, isShippingMethodsLoading, form, applyShippingMethod, - updateTaxes.mutate, - session?.enableTaxCollection, queryClient, session?.id, isPickup, diff --git a/packages/react/src/components/checkout/shipping/utils/build-shipping-payload.ts b/packages/react/src/components/checkout/shipping/utils/build-shipping-payload.ts new file mode 100644 index 00000000..f393d5b5 --- /dev/null +++ b/packages/react/src/components/checkout/shipping/utils/build-shipping-payload.ts @@ -0,0 +1,18 @@ +import type { ShippingMethod } from '@/types'; + +export function buildShippingPayload(method: ShippingMethod) { + const currencyCode = method.cost?.currencyCode || 'USD'; + + return [ + { + taxTotal: { value: 0, currencyCode }, + subTotal: { + value: method.cost?.value || 0, + currencyCode, + }, + requestedService: method.serviceCode, + requestedProvider: method.carrierCode, + name: method.displayName || '', + }, + ]; +} diff --git a/packages/react/src/components/checkout/shipping/utils/use-apply-shipping-method-core.ts b/packages/react/src/components/checkout/shipping/utils/use-apply-shipping-method-core.ts index 9d504917..9181d0ef 100644 --- a/packages/react/src/components/checkout/shipping/utils/use-apply-shipping-method-core.ts +++ b/packages/react/src/components/checkout/shipping/utils/use-apply-shipping-method-core.ts @@ -28,7 +28,8 @@ interface UseApplyShippingMethodCoreOptions { export function updateShippingMethodCache( queryClient: QueryClient, sessionId: string, - data: ShippingMutationResult + data: ShippingMutationResult, + shippingMethods: ShippingMethods ) { const shippingTotal = data.applyCheckoutSessionShippingMethod?.draftOrder?.totals?.shippingTotal; @@ -45,12 +46,19 @@ export function updateShippingMethodCache( ...cached.checkoutSession, draftOrder: { ...cached.checkoutSession?.draftOrder, - shippingLines: [ - { - ...cached.checkoutSession?.draftOrder?.shippingLines?.[0], - amount: { ...shippingTotal }, - }, - ], + shippingLines: shippingMethods[0] + ? [ + { + ...cached.checkoutSession?.draftOrder?.shippingLines?.[0], + name: shippingMethods[0].name, + requestedProvider: + shippingMethods[0].requestedProvider ?? null, + requestedService: + shippingMethods[0].requestedService ?? null, + amount: { ...shippingTotal }, + }, + ] + : [], totals: { ...cached.checkoutSession?.draftOrder?.totals, shippingTotal: { ...shippingTotal }, @@ -81,7 +89,7 @@ export function useApplyShippingMethodCore( onSuccess: async (data, shippingMethods) => { if (!session || !data) return; - updateShippingMethodCache(queryClient, session.id, data); + updateShippingMethodCache(queryClient, session.id, data, shippingMethods); await options.onSuccess?.(data, shippingMethods); }, onError: options.onError, From a6da3cc9f7c266822f622519275f3883665c932e Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Wed, 26 Aug 2026 15:01:31 -0500 Subject: [PATCH 26/32] fix duplicate shipping reconciliation when shipping rates fails or returns no methods --- .../__tests__/checkout-discount.test.tsx | 44 ++++++++++++++++ .../checkout-buttons/express/godaddy.tsx | 46 ++++++++++------- .../checkout-buttons/express/stripe.tsx | 50 +++++++++---------- .../checkout/shipping/shipping-method.tsx | 17 +++++++ 4 files changed, 115 insertions(+), 42 deletions(-) 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 d5a08510..42c77c0d 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx @@ -232,6 +232,50 @@ describe('Checkout discounts', () => { expect(taxIndex).toBeGreaterThan(lastDiscountIndex); }); + it('clears applied shipping once when the discount rate refresh returns no methods', async () => { + const paidShipping = buildShippingRates([ + { + serviceCode: 'standard', + carrierCode: 'carrier', + displayName: 'Standard', + cost: { value: 1000, currencyCode: 'USD' }, + }, + ]); + const { user } = renderCheckout({ + apiOverrides: { shippingMethods: paidShipping }, + draftOrderOverrides: { + shippingLines: [ + { + requestedService: 'standard', + requestedProvider: 'carrier', + name: 'Standard', + amount: { value: 1000, currencyCode: 'USD' }, + }, + ], + }, + }); + await waitForCheckoutReady(); + clearOperations(); + setShippingMethods([]); + + await applyCoupon(user, 'onedollar'); + await waitFor(() => { + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1); + }); + await flushPromises(); + await flushPromises(); + + expect(getOperations('DraftOrderShippingRates')).toHaveLength(1); + expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength(1); + expect( + getOperations('ApplyCheckoutSessionShippingMethod')[0].input + ).toEqual([]); + expect(getOperations('ApplyCheckoutSessionDiscount')).toHaveLength(2); + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1); + expect(screen.queryByText('Standard')).not.toBeInTheDocument(); + expect(document.body).toHaveTextContent(/no shipping methods found/i); + }); + it('applies a newly available free method before calculating taxes', async () => { const paidShipping = buildShippingRates([ { diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx index ed606416..f7f2efa0 100644 --- a/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx +++ b/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx @@ -95,6 +95,7 @@ export function ExpressCheckoutButton() { // Use refs to store current coupon state to avoid stale closures in event handlers const appliedCouponCodeRef = useRef(null); const calculatedAdjustmentsRef = useRef(null); + const couponSyncRequestRef = useRef(0); const calculateGodaddyExpressTaxes = useCallback( async ({ @@ -301,6 +302,7 @@ export function ExpressCheckoutButton() { const [couponFetchStatus, setCouponFetchStatus] = useState< 'idle' | 'fetching' | 'done' >('idle'); + const [couponSyncRevision, setCouponSyncRevision] = useState(0); const draftOrderDiscountCodes = useMemo( () => getDraftOrderDiscountCodes(draftOrder), @@ -312,34 +314,43 @@ export function ExpressCheckoutButton() { draftOrderDiscountCodes.length === 0 || couponFetchStatus === 'done'; useEffect(() => { - if (!hasDraftOrder || couponFetchStatus === 'fetching') return; + if (!hasDraftOrder) return; - const fetchPriceAdjustments = async () => { - setCouponFetchStatus('fetching'); + const requestId = ++couponSyncRequestRef.current; + const couponCode = draftOrderDiscountCodes[0]; + setCouponFetchStatus('fetching'); + const syncPriceAdjustments = async () => { try { - const couponCode = draftOrderDiscountCodes[0]; - if (couponCode) { - const result = await getPriceAdjustments.mutateAsync({ - discountCodes: [couponCode], - }); - - if (result) { - appliedCouponCodeRef.current = couponCode; - calculatedAdjustmentsRef.current = result; - } - } else { + if (!couponCode) { appliedCouponCodeRef.current = null; calculatedAdjustmentsRef.current = null; + return; } + + const result = await getPriceAdjustments.mutateAsync({ + discountCodes: [couponCode], + }); + + if (requestId !== couponSyncRequestRef.current) return; + + appliedCouponCodeRef.current = result ? couponCode : null; + calculatedAdjustmentsRef.current = result ?? null; + } catch { + if (requestId !== couponSyncRequestRef.current) return; + + appliedCouponCodeRef.current = null; + calculatedAdjustmentsRef.current = null; } finally { - setCouponFetchStatus('done'); + if (requestId === couponSyncRequestRef.current) { + setCouponFetchStatus('done'); + } } }; - fetchPriceAdjustments(); + syncPriceAdjustments(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [hasDraftOrder, discountCodesKey]); + }, [hasDraftOrder, discountCodesKey, couponSyncRevision]); // Initialize the TokenizeJs instance when the component mounts // But only after price adjustments have been fetched @@ -492,6 +503,7 @@ export function ExpressCheckoutButton() { // Reset coupon fetch status to trigger re-sync with draft order on next open // This ensures any coupon changes made inside the wallet (but not committed) are discarded setCouponFetchStatus('idle'); + setCouponSyncRevision(value => value + 1); setCalculatedTaxes(null); // Clear coupon refs - will be re-synced with draft order on next fetch diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.tsx index b4f221c6..31f5a9e5 100644 --- a/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.tsx +++ b/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.tsx @@ -75,14 +75,10 @@ export function StripeExpressCheckoutForm() { const [shippingAddress, setShippingAddress] = useState(null); - // Track the status of coupon code fetching - const [couponFetchStatus, setCouponFetchStatus] = useState< - 'idle' | 'fetching' | 'done' - >('idle'); - // Use refs for values needed in event handlers to avoid stale closures const appliedCouponCodeRef = useRef(null); const calculatedAdjustmentsRef = useRef(null); + const couponSyncRequestRef = useRef(0); const draftOrderDiscountCodes = useMemo( () => getDraftOrderDiscountCodes(draftOrder), @@ -92,32 +88,36 @@ export function StripeExpressCheckoutForm() { const hasDraftOrder = Boolean(draftOrder); useEffect(() => { - if (!hasDraftOrder || couponFetchStatus === 'fetching') return; + if (!hasDraftOrder) return; + + const requestId = ++couponSyncRequestRef.current; + const couponCode = draftOrderDiscountCodes[0]; - const fetchPriceAdjustments = async () => { - setCouponFetchStatus('fetching'); + const syncPriceAdjustments = async () => { + if (!couponCode) { + appliedCouponCodeRef.current = null; + calculatedAdjustmentsRef.current = null; + return; + } try { - const couponCode = draftOrderDiscountCodes[0]; - if (couponCode) { - const result = await getPriceAdjustments.mutateAsync({ - discountCodes: [couponCode], - }); - - if (result) { - appliedCouponCodeRef.current = couponCode; - calculatedAdjustmentsRef.current = result; - } - } else { - appliedCouponCodeRef.current = null; - calculatedAdjustmentsRef.current = null; - } - } finally { - setCouponFetchStatus('done'); + const result = await getPriceAdjustments.mutateAsync({ + discountCodes: [couponCode], + }); + + if (requestId !== couponSyncRequestRef.current) return; + + appliedCouponCodeRef.current = result ? couponCode : null; + calculatedAdjustmentsRef.current = result ?? null; + } catch { + if (requestId !== couponSyncRequestRef.current) return; + + appliedCouponCodeRef.current = null; + calculatedAdjustmentsRef.current = null; } }; - fetchPriceAdjustments(); + syncPriceAdjustments(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [hasDraftOrder, discountCodesKey]); diff --git a/packages/react/src/components/checkout/shipping/shipping-method.tsx b/packages/react/src/components/checkout/shipping/shipping-method.tsx index a0b1789e..b2b69fbf 100644 --- a/packages/react/src/components/checkout/shipping/shipping-method.tsx +++ b/packages/react/src/components/checkout/shipping/shipping-method.tsx @@ -66,6 +66,7 @@ export function ShippingMethodForm() { mutationKey: checkoutMutationKeys.applyDiscount(session?.id), }) > 0; const lastShippingMethodsKeyRef = useRef(null); + const wasApplyingDiscountRef = useRef(false); // Track the last processed state to avoid duplicate API calls const lastProcessedStateRef = useRef<{ @@ -86,6 +87,7 @@ export function ShippingMethodForm() { useEffect(() => { if (isApplyingDiscount) { + wasApplyingDiscountRef.current = true; lastShippingMethodsKeyRef.current = getShippingMethodsKey(shippingMethods); lastProcessedStateRef.current = { @@ -105,6 +107,8 @@ export function ShippingMethodForm() { ) return; + const discountJustSettled = wasApplyingDiscountRef.current; + wasApplyingDiscountRef.current = false; const hasShippingMethods = (shippingMethods?.length ?? 0) > 0; const currentServiceCode = shippingLines?.requestedService || null; const lastState = lastProcessedStateRef.current; @@ -122,6 +126,19 @@ export function ShippingMethodForm() { // Case 1: No shipping methods available - clear shipping and set fulfillment to SHIP if (!hasShippingMethods && hasShippingAddress) { lastShippingMethodsKeyRef.current = getShippingMethodsKey([]); + + if (discountJustSettled && !currentServiceCode) { + lastProcessedStateRef.current = { + serviceCode: null, + cost: null, + hadShippingMethods: false, + wasPickup: isPickup, + clearedShippingMethod: true, + blockedFulfillmentKey: null, + }; + return; + } + // Apply empty shipping method if: // - Pickup mode and has shipping code OR wasn't pickup before // - Shipping mode and (had methods before OR haven't cleared yet) From b64fff27f2611c80347fd7953c638b9a432cb6ea Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Wed, 26 Aug 2026 15:12:53 -0500 Subject: [PATCH 27/32] bail on shipping and methods if API failure --- .../__tests__/checkout-discount.test.tsx | 91 ++++++++++--------- .../utils/use-reconcile-after-discount.ts | 7 +- .../checkout/shipping/shipping-method.tsx | 11 ++- 3 files changed, 62 insertions(+), 47 deletions(-) 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 42c77c0d..9e3cc135 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx @@ -232,49 +232,58 @@ describe('Checkout discounts', () => { expect(taxIndex).toBeGreaterThan(lastDiscountIndex); }); - it('clears applied shipping once when the discount rate refresh returns no methods', async () => { - const paidShipping = buildShippingRates([ - { - serviceCode: 'standard', - carrierCode: 'carrier', - displayName: 'Standard', - cost: { value: 1000, currencyCode: 'USD' }, - }, - ]); - const { user } = renderCheckout({ - apiOverrides: { shippingMethods: paidShipping }, - draftOrderOverrides: { - shippingLines: [ - { - requestedService: 'standard', - requestedProvider: 'carrier', - name: 'Standard', - amount: { value: 1000, currencyCode: 'USD' }, - }, - ], - }, - }); - await waitForCheckoutReady(); - clearOperations(); - setShippingMethods([]); + it.each(['empty', 'error'] as const)( + 'clears applied shipping once when the discount rate refresh result is %s', + async result => { + const paidShipping = buildShippingRates([ + { + serviceCode: 'standard', + carrierCode: 'carrier', + displayName: 'Standard', + cost: { value: 1000, currencyCode: 'USD' }, + }, + ]); + const { user } = renderCheckout({ + apiOverrides: { shippingMethods: paidShipping }, + draftOrderOverrides: { + shippingLines: [ + { + requestedService: 'standard', + requestedProvider: 'carrier', + name: 'Standard', + amount: { value: 1000, currencyCode: 'USD' }, + }, + ], + }, + }); + await waitForCheckoutReady(); + clearOperations(); + if (result === 'error') { + setApiError('getDraftOrderShippingMethods', 'rates failed'); + } else { + setShippingMethods([]); + } - await applyCoupon(user, 'onedollar'); - await waitFor(() => { - expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1); - }); - await flushPromises(); - await flushPromises(); + await applyCoupon(user, 'onedollar'); + await waitFor(() => { + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1); + }); + await flushPromises(); + await flushPromises(); - expect(getOperations('DraftOrderShippingRates')).toHaveLength(1); - expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength(1); - expect( - getOperations('ApplyCheckoutSessionShippingMethod')[0].input - ).toEqual([]); - expect(getOperations('ApplyCheckoutSessionDiscount')).toHaveLength(2); - expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1); - expect(screen.queryByText('Standard')).not.toBeInTheDocument(); - expect(document.body).toHaveTextContent(/no shipping methods found/i); - }); + expect(getOperations('DraftOrderShippingRates')).toHaveLength(1); + expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength( + 1 + ); + expect( + getOperations('ApplyCheckoutSessionShippingMethod')[0].input + ).toEqual([]); + expect(getOperations('ApplyCheckoutSessionDiscount')).toHaveLength(2); + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1); + expect(screen.queryByText('Standard')).not.toBeInTheDocument(); + expect(document.body).toHaveTextContent(/no shipping methods found/i); + } + ); it('applies a newly available free method before calculating taxes', async () => { const paidShipping = buildShippingRates([ diff --git a/packages/react/src/components/checkout/discount/utils/use-reconcile-after-discount.ts b/packages/react/src/components/checkout/discount/utils/use-reconcile-after-discount.ts index 0d7ab832..4e252a83 100644 --- a/packages/react/src/components/checkout/discount/utils/use-reconcile-after-discount.ts +++ b/packages/react/src/components/checkout/discount/utils/use-reconcile-after-discount.ts @@ -41,9 +41,10 @@ export function useReconcileAfterDiscount() { if (deliveryMethod === DeliveryMethods.SHIP && hasShippingDestination) { const previousShippingMethods = shippingMethodsQuery.data ?? []; - const { data: refreshedMethods } = await shippingMethodsQuery.refetch(); + const { data, isError } = await shippingMethodsQuery.refetch(); + const refreshedMethods = isError ? [] : (data ?? []); const shippingRequiresReconciliation = requiresShippingReconciliation({ - shippingMethods: refreshedMethods ?? [], + shippingMethods: refreshedMethods, previousShippingMethods, currentShippingLine: draftOrder?.shippingLines?.[0], selectedServiceCode: form.getValues('shippingMethod'), @@ -54,7 +55,7 @@ export function useReconcileAfterDiscount() { form.getValues('shippingMethod') || draftOrder?.shippingLines?.[0]?.requestedService; const { selectedMethod } = selectShippingMethod({ - shippingMethods: refreshedMethods ?? [], + shippingMethods: refreshedMethods, currentServiceCode, previousMethodsKey: getShippingMethodsKey(previousShippingMethods), }); diff --git a/packages/react/src/components/checkout/shipping/shipping-method.tsx b/packages/react/src/components/checkout/shipping/shipping-method.tsx index b2b69fbf..96eab9e9 100644 --- a/packages/react/src/components/checkout/shipping/shipping-method.tsx +++ b/packages/react/src/components/checkout/shipping/shipping-method.tsx @@ -42,8 +42,11 @@ export function ShippingMethodForm() { const queryClient = useQueryClient(); const isPaymentDisabled = useIsPaymentDisabled(); - const { data: shippingMethodsData, isLoading: isShippingMethodsLoading } = - useDraftOrderShippingMethods(); + const { + data: shippingMethodsData, + isError: isShippingMethodsError, + isLoading: isShippingMethodsLoading, + } = useDraftOrderShippingMethods(); const { data: shippingAddress, isLoading: isShippingAddressLoading } = useDraftOrderShippingAddress(); const { data: order, isLoading: isDraftOrderLoading } = useDraftOrder(); @@ -58,7 +61,9 @@ export function ShippingMethodForm() { const fulfillmentSyncKey = getShippingFulfillmentSyncKey(order?.lineItems); const hasLineItemsMissingShippingFulfillment = Boolean(fulfillmentSyncKey); - const shippingMethods = sortShippingMethods(shippingMethodsData || []); + const shippingMethods = sortShippingMethods( + isShippingMethodsError ? [] : shippingMethodsData || [] + ); const applyShippingMethod = useApplyShippingMethod(); const isApplyingDiscount = From 58857d03b4fda0cb1347b3e26c6a791a6b9774c8 Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Wed, 26 Aug 2026 15:27:46 -0500 Subject: [PATCH 28/32] keeps the previous shipping selection when discount reconciliation fails --- .../__tests__/checkout-discount.test.tsx | 123 ++++++++++++++++++ .../utils/use-reconcile-after-discount.ts | 6 +- .../checkout/shipping/shipping-method.tsx | 17 ++- 3 files changed, 142 insertions(+), 4 deletions(-) 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 9e3cc135..1efa1c16 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx @@ -285,6 +285,129 @@ describe('Checkout discounts', () => { } ); + it('keeps the previous shipping selection when discount reconciliation fails', async () => { + const paidShipping = buildShippingRates([ + { + serviceCode: 'standard', + carrierCode: 'carrier', + displayName: 'Standard', + cost: { value: 1000, currencyCode: 'USD' }, + }, + ]); + const { user } = renderCheckout({ + apiOverrides: { shippingMethods: paidShipping }, + draftOrderOverrides: { + shippingLines: [ + { + requestedService: 'standard', + requestedProvider: 'carrier', + name: 'Standard', + amount: { value: 1000, currencyCode: 'USD' }, + }, + ], + }, + }); + await waitForCheckoutReady(); + clearOperations(); + setApiError('applyShippingMethod', 'apply failed'); + setShippingMethods([ + ...paidShipping, + ...buildShippingRates([ + { + serviceCode: 'free', + carrierCode: 'carrier', + displayName: 'Free', + cost: { value: 0, currencyCode: 'USD' }, + }, + ]), + ]); + + await applyCoupon(user, 'onedollar'); + await waitFor(() => { + expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength( + 2 + ); + }); + await flushPromises(); + await flushPromises(); + + expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength(2); + expect(screen.getByRole('radio', { name: /standard/i })).toBeChecked(); + expect(screen.getByRole('radio', { name: /free/i })).not.toBeChecked(); + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(0); + }); + + it.each(['empty', 'replacement'] as const)( + 'does not display a failed %s automatic shipping selection', + async result => { + const paidShipping = buildShippingRates([ + { + serviceCode: 'standard', + carrierCode: 'carrier', + displayName: 'Standard', + cost: { value: 1000, currencyCode: 'USD' }, + }, + ]); + const { user } = renderCheckout({ + apiOverrides: { shippingMethods: paidShipping }, + draftOrderOverrides: { + shippingLines: [ + { + requestedService: 'standard', + requestedProvider: 'carrier', + name: 'Standard', + amount: { value: 1000, currencyCode: 'USD' }, + }, + ], + }, + }); + await waitForCheckoutReady(); + clearOperations(); + setApiError('applyShippingMethod', 'apply failed'); + setShippingMethods( + result === 'empty' + ? [] + : buildShippingRates([ + { + serviceCode: 'express', + carrierCode: 'carrier', + displayName: 'Express', + cost: { value: 1500, currencyCode: 'USD' }, + }, + { + serviceCode: 'overnight', + carrierCode: 'carrier', + displayName: 'Overnight', + cost: { value: 2000, currencyCode: 'USD' }, + }, + ]) + ); + + await applyCoupon(user, 'onedollar'); + await waitFor(() => { + expect( + getOperations('ApplyCheckoutSessionShippingMethod') + ).toHaveLength(2); + }); + await flushPromises(); + await flushPromises(); + + expect(getOperations('ApplyCheckoutSessionDiscount')).toHaveLength(1); + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(0); + + if (result === 'empty') { + expect(document.body).toHaveTextContent(/no shipping methods found/i); + } else { + expect( + screen.getByRole('radio', { name: /express/i }) + ).not.toBeChecked(); + expect( + screen.getByRole('radio', { name: /overnight/i }) + ).not.toBeChecked(); + } + } + ); + it('applies a newly available free method before calculating taxes', async () => { const paidShipping = buildShippingRates([ { diff --git a/packages/react/src/components/checkout/discount/utils/use-reconcile-after-discount.ts b/packages/react/src/components/checkout/discount/utils/use-reconcile-after-discount.ts index 4e252a83..b4d0d8f4 100644 --- a/packages/react/src/components/checkout/discount/utils/use-reconcile-after-discount.ts +++ b/packages/react/src/components/checkout/discount/utils/use-reconcile-after-discount.ts @@ -60,12 +60,12 @@ export function useReconcileAfterDiscount() { previousMethodsKey: getShippingMethodsKey(previousShippingMethods), }); - form.setValue('shippingMethod', selectedMethod?.serviceCode ?? '', { - shouldDirty: false, - }); await applyShippingMethod.mutateAsync( selectedMethod ? buildShippingPayload(selectedMethod) : [] ); + form.setValue('shippingMethod', selectedMethod?.serviceCode ?? '', { + shouldDirty: false, + }); if (session.enablePromotionCodes && variables.discountCodes?.length) { await reapplyDiscount.mutateAsync(variables); diff --git a/packages/react/src/components/checkout/shipping/shipping-method.tsx b/packages/react/src/components/checkout/shipping/shipping-method.tsx index 96eab9e9..ef545db3 100644 --- a/packages/react/src/components/checkout/shipping/shipping-method.tsx +++ b/packages/react/src/components/checkout/shipping/shipping-method.tsx @@ -152,8 +152,16 @@ export function ShippingMethodForm() { : lastState.hadShippingMethods || !lastState.clearedShippingMethod; if (shouldClearShipping) { + const previousShippingMethod = + form.getValues('shippingMethod') || currentServiceCode || ''; form.setValue('shippingMethod', '', { shouldDirty: false }); - applyShippingMethod.mutate([]); + applyShippingMethod.mutate([], { + onError: () => { + form.setValue('shippingMethod', previousShippingMethod, { + shouldDirty: false, + }); + }, + }); lastProcessedStateRef.current = { serviceCode: null, cost: null, @@ -217,7 +225,14 @@ export function ShippingMethodForm() { }; } + const previousShippingMethod = + currentFormMethod || currentServiceCode || ''; applyShippingMethod.mutate(buildShippingPayload(methodToApply), { + onError: () => { + form.setValue('shippingMethod', previousShippingMethod, { + shouldDirty: false, + }); + }, onSuccess: () => { if (!isFulfillmentSync || !session?.id) return; From 8a2e5687e0617a4136f47296f80345798a6cc848 Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Mon, 21 Sep 2026 16:55:59 -0500 Subject: [PATCH 29/32] show retry on shipping method fetch failure --- .changeset/retry-shipping-rates.md | 6 + packages/localizations/src/deDe.ts | 3 + packages/localizations/src/enAu.ts | 2 + packages/localizations/src/enIe.ts | 2 + packages/localizations/src/enUs.ts | 2 + packages/localizations/src/esAr.ts | 3 + packages/localizations/src/esCl.ts | 3 + packages/localizations/src/esCo.ts | 3 + packages/localizations/src/esEs.ts | 3 + packages/localizations/src/esMx.ts | 3 + packages/localizations/src/esPe.ts | 3 + packages/localizations/src/esUs.ts | 3 + packages/localizations/src/frCa.ts | 3 + packages/localizations/src/frFr.ts | 3 + packages/localizations/src/idId.ts | 3 + packages/localizations/src/itIt.ts | 3 + packages/localizations/src/ptBr.ts | 3 + packages/localizations/src/qaPs.ts | 2 + packages/localizations/src/trTr.ts | 3 + packages/localizations/src/viVn.ts | 3 + packages/localizations/src/zhCn.ts | 2 + packages/localizations/src/zhSg.ts | 2 + .../checkout-confirm-errors.test.tsx | 19 +- .../__tests__/checkout-discount.test.tsx | 206 ++++++++++++++++-- .../__tests__/checkout-shipping.test.tsx | 27 ++- .../utils/use-flush-checkout-sync.test.tsx | 31 +++ .../checkout/shipping/shipping-method.tsx | 44 +++- .../utils/use-draft-order-shipping-methods.ts | 19 +- 28 files changed, 377 insertions(+), 32 deletions(-) create mode 100644 .changeset/retry-shipping-rates.md diff --git a/.changeset/retry-shipping-rates.md b/.changeset/retry-shipping-rates.md new file mode 100644 index 00000000..0b51e816 --- /dev/null +++ b/.changeset/retry-shipping-rates.md @@ -0,0 +1,6 @@ +--- +"@godaddy/react": patch +"@godaddy/localizations": patch +--- + +Show a localized retry action when shipping rates cannot be loaded. Clear applied shipping when rates cannot be loaded, and apply the refreshed default method after a successful retry. diff --git a/packages/localizations/src/deDe.ts b/packages/localizations/src/deDe.ts index 33dfc622..af9669e4 100644 --- a/packages/localizations/src/deDe.ts +++ b/packages/localizations/src/deDe.ts @@ -70,6 +70,9 @@ export const deDe = { noShippingMethodAddress: 'Geben Sie Ihre Adresse ein, um verfügbare Versandmethoden zu sehen.', noShippingMethods: 'Keine Versandmethoden gefunden.', + failedToLoadMethods: + 'Versandarten konnten nicht geladen werden. Bitte versuche es erneut.', + retryMethods: 'Erneut versuchen', phone: 'Telefonnummer', country: 'Land', selectCountry: 'Land auswählen', diff --git a/packages/localizations/src/enAu.ts b/packages/localizations/src/enAu.ts index 59815771..39d3bbd3 100644 --- a/packages/localizations/src/enAu.ts +++ b/packages/localizations/src/enAu.ts @@ -70,6 +70,8 @@ export const enAu = { noShippingMethodAddress: 'Enter your address to see available shipping methods.', noShippingMethods: 'No shipping methods found.', + failedToLoadMethods: 'Unable to load shipping methods. Please try again.', + retryMethods: 'Try again', phone: 'Phone Number', country: 'Country', selectCountry: 'Select country', diff --git a/packages/localizations/src/enIe.ts b/packages/localizations/src/enIe.ts index 89470301..2cb6f8d5 100644 --- a/packages/localizations/src/enIe.ts +++ b/packages/localizations/src/enIe.ts @@ -70,6 +70,8 @@ export const enIe = { noShippingMethodAddress: 'Enter your address to see available shipping methods.', noShippingMethods: 'No shipping methods found.', + failedToLoadMethods: 'Unable to load shipping methods. Please try again.', + retryMethods: 'Try again', phone: 'Phone Number', country: 'Country', selectCountry: 'Select country', diff --git a/packages/localizations/src/enUs.ts b/packages/localizations/src/enUs.ts index 95a556da..9a1852f4 100644 --- a/packages/localizations/src/enUs.ts +++ b/packages/localizations/src/enUs.ts @@ -70,6 +70,8 @@ export const enUs = { noShippingMethodAddress: 'Enter your address to see available shipping methods.', noShippingMethods: 'No shipping methods found.', + failedToLoadMethods: 'Unable to load shipping methods. Please try again.', + retryMethods: 'Try again', phone: 'Phone Number', country: 'Country', selectCountry: 'Select country', diff --git a/packages/localizations/src/esAr.ts b/packages/localizations/src/esAr.ts index 8128fee0..0eebdfc4 100644 --- a/packages/localizations/src/esAr.ts +++ b/packages/localizations/src/esAr.ts @@ -70,6 +70,9 @@ export const esAr = { noShippingMethodAddress: 'Ingresá tu dirección para ver los métodos de envío disponibles.', noShippingMethods: 'No se encontraron métodos de envío.', + failedToLoadMethods: + 'No se pudieron cargar los métodos de envío. Inténtalo de nuevo.', + retryMethods: 'Intentar de nuevo', phone: 'Número de Teléfono', country: 'País', selectCountry: 'Seleccionar país', diff --git a/packages/localizations/src/esCl.ts b/packages/localizations/src/esCl.ts index ccb43c9a..81446be8 100644 --- a/packages/localizations/src/esCl.ts +++ b/packages/localizations/src/esCl.ts @@ -70,6 +70,9 @@ export const esCl = { noShippingMethodAddress: 'Ingresa tu dirección para ver los métodos de envío disponibles.', noShippingMethods: 'No se encontraron métodos de envío.', + failedToLoadMethods: + 'No se pudieron cargar los métodos de envío. Inténtalo de nuevo.', + retryMethods: 'Intentar de nuevo', phone: 'Número de Teléfono', country: 'País', selectCountry: 'Seleccionar país', diff --git a/packages/localizations/src/esCo.ts b/packages/localizations/src/esCo.ts index b80b2f9c..31ea0b86 100644 --- a/packages/localizations/src/esCo.ts +++ b/packages/localizations/src/esCo.ts @@ -70,6 +70,9 @@ export const esCo = { noShippingMethodAddress: 'Ingresa tu dirección para ver los métodos de envío disponibles.', noShippingMethods: 'No se encontraron métodos de envío.', + failedToLoadMethods: + 'No se pudieron cargar los métodos de envío. Inténtalo de nuevo.', + retryMethods: 'Intentar de nuevo', phone: 'Número de teléfono', country: 'País', selectCountry: 'Seleccionar país', diff --git a/packages/localizations/src/esEs.ts b/packages/localizations/src/esEs.ts index 013cf3a6..76167844 100644 --- a/packages/localizations/src/esEs.ts +++ b/packages/localizations/src/esEs.ts @@ -70,6 +70,9 @@ export const esEs = { noShippingMethodAddress: 'Introduce tu dirección para ver los métodos de envío disponibles.', noShippingMethods: 'No se encontraron métodos de envío.', + failedToLoadMethods: + 'No se pudieron cargar los métodos de envío. Inténtalo de nuevo.', + retryMethods: 'Intentar de nuevo', phone: 'Número de teléfono', country: 'País', selectCountry: 'Seleccionar país', diff --git a/packages/localizations/src/esMx.ts b/packages/localizations/src/esMx.ts index 171dc3c8..a0907b11 100644 --- a/packages/localizations/src/esMx.ts +++ b/packages/localizations/src/esMx.ts @@ -70,6 +70,9 @@ export const esMx = { noShippingMethodAddress: 'Ingrese su dirección para ver los métodos de envío disponibles.', noShippingMethods: 'No se encontraron métodos de envío.', + failedToLoadMethods: + 'No se pudieron cargar los métodos de envío. Inténtalo de nuevo.', + retryMethods: 'Intentar de nuevo', phone: 'Número de Teléfono', country: 'País', selectCountry: 'Seleccionar país', diff --git a/packages/localizations/src/esPe.ts b/packages/localizations/src/esPe.ts index 3d1f4f36..ea086c41 100644 --- a/packages/localizations/src/esPe.ts +++ b/packages/localizations/src/esPe.ts @@ -70,6 +70,9 @@ export const esPe = { noShippingMethodAddress: 'Ingrese su dirección para ver los métodos de envío disponibles.', noShippingMethods: 'No se encontraron métodos de envío.', + failedToLoadMethods: + 'No se pudieron cargar los métodos de envío. Inténtalo de nuevo.', + retryMethods: 'Intentar de nuevo', phone: 'Número de Teléfono', country: 'País', selectCountry: 'Seleccionar país', diff --git a/packages/localizations/src/esUs.ts b/packages/localizations/src/esUs.ts index fc77429b..5be7c6a4 100644 --- a/packages/localizations/src/esUs.ts +++ b/packages/localizations/src/esUs.ts @@ -70,6 +70,9 @@ export const esUs = { noShippingMethodAddress: 'Ingrese su dirección para ver los métodos de envío disponibles.', noShippingMethods: 'No se encontraron métodos de envío.', + failedToLoadMethods: + 'No se pudieron cargar los métodos de envío. Inténtalo de nuevo.', + retryMethods: 'Intentar de nuevo', phone: 'Número de Teléfono', country: 'País', selectCountry: 'Seleccionar país', diff --git a/packages/localizations/src/frCa.ts b/packages/localizations/src/frCa.ts index 4f4eed86..e65d2dcc 100644 --- a/packages/localizations/src/frCa.ts +++ b/packages/localizations/src/frCa.ts @@ -70,6 +70,9 @@ export const frCa = { noShippingMethodAddress: "Entrez votre adresse pour voir les méthodes d'expédition disponibles.", noShippingMethods: "Aucune méthode d'expédition trouvée.", + failedToLoadMethods: + 'Impossible de charger les modes de livraison. Veuillez réessayer.', + retryMethods: 'Réessayer', phone: 'Numéro de téléphone', country: 'Pays', selectCountry: 'Sélectionner un pays', diff --git a/packages/localizations/src/frFr.ts b/packages/localizations/src/frFr.ts index 09d4c91a..e786253c 100644 --- a/packages/localizations/src/frFr.ts +++ b/packages/localizations/src/frFr.ts @@ -70,6 +70,9 @@ export const frFr = { noShippingMethodAddress: 'Entrez votre adresse pour voir les méthodes de livraison disponibles.', noShippingMethods: "Aucune méthode d'expédition trouvée.", + failedToLoadMethods: + 'Impossible de charger les modes de livraison. Veuillez réessayer.', + retryMethods: 'Réessayer', phone: 'Numéro de téléphone', country: 'Pays', selectCountry: 'Sélectionner un pays', diff --git a/packages/localizations/src/idId.ts b/packages/localizations/src/idId.ts index 4bdc412e..be48d21a 100644 --- a/packages/localizations/src/idId.ts +++ b/packages/localizations/src/idId.ts @@ -70,6 +70,9 @@ export const idId = { noShippingMethodAddress: 'Masukkan alamat Anda untuk melihat metode pengiriman yang tersedia.', noShippingMethods: 'Tidak ada metode pengiriman ditemukan.', + failedToLoadMethods: + 'Tidak dapat memuat metode pengiriman. Silakan coba lagi.', + retryMethods: 'Coba lagi', phone: 'Nomor Telepon', country: 'Negara', selectCountry: 'Pilih negara', diff --git a/packages/localizations/src/itIt.ts b/packages/localizations/src/itIt.ts index de2515f5..e11df876 100644 --- a/packages/localizations/src/itIt.ts +++ b/packages/localizations/src/itIt.ts @@ -70,6 +70,9 @@ export const itIt = { noShippingMethodAddress: 'Inserisci il tuo indirizzo per vedere i metodi di spedizione disponibili.', noShippingMethods: 'Nessun metodo di spedizione trovato.', + failedToLoadMethods: + 'Impossibile caricare i metodi di spedizione. Riprova.', + retryMethods: 'Riprova', phone: 'Numero di Telefono', country: 'Paese', selectCountry: 'Seleziona paese', diff --git a/packages/localizations/src/ptBr.ts b/packages/localizations/src/ptBr.ts index d47a9bba..f0856279 100644 --- a/packages/localizations/src/ptBr.ts +++ b/packages/localizations/src/ptBr.ts @@ -70,6 +70,9 @@ export const ptBr = { noShippingMethodAddress: 'Digite seu endereço para ver os métodos de envio disponíveis.', noShippingMethods: 'Nenhum método de envio encontrado.', + failedToLoadMethods: + 'Não foi possível carregar as formas de envio. Tente novamente.', + retryMethods: 'Tentar novamente', phone: 'Número de Telefone', country: 'País', selectCountry: 'Selecionar país', diff --git a/packages/localizations/src/qaPs.ts b/packages/localizations/src/qaPs.ts index 208d5935..7c97ec6e 100644 --- a/packages/localizations/src/qaPs.ts +++ b/packages/localizations/src/qaPs.ts @@ -70,6 +70,8 @@ export const qaPs = { noShippingMethodAddress: '[Ëñťër ÿöür âddrëšš ťö šëë âvâîlâblë šhîþþîñg mëťhödš fôr ÿöür löçâťîöñ.]', noShippingMethods: '[Ñö šhîþþîñg mëťhödš föüñd fôr ţhîš löçâţîöñ.]', + failedToLoadMethods: '[Üñâblë ťö löâd šhîþþîñg mëťhödš. Þléâšé ťrÿ âgâîñ.]', + retryMethods: '[Ťrÿ âgâîñ]', phone: '[Þhöñë Ñümkër Îñförmâťîöñ]', country: '[Çöüñţrÿ Šëlëçťîöñ]', selectCountry: '[Šëlëçť çöüñţrÿ frôm lîšť]', diff --git a/packages/localizations/src/trTr.ts b/packages/localizations/src/trTr.ts index daa63b5e..4ea7f7d0 100644 --- a/packages/localizations/src/trTr.ts +++ b/packages/localizations/src/trTr.ts @@ -70,6 +70,9 @@ export const trTr = { noShippingMethodAddress: 'Mevcut kargo yöntemlerini görmek için adresinizi girin.', noShippingMethods: 'Kargo yöntemi bulunamadı.', + failedToLoadMethods: + 'Gönderim yöntemleri yüklenemedi. Lütfen tekrar deneyin.', + retryMethods: 'Tekrar dene', phone: 'Telefon Numarası', country: 'Ülke', selectCountry: 'Ülke seçin', diff --git a/packages/localizations/src/viVn.ts b/packages/localizations/src/viVn.ts index 37ff3b91..5ae72923 100644 --- a/packages/localizations/src/viVn.ts +++ b/packages/localizations/src/viVn.ts @@ -70,6 +70,9 @@ export const viVn = { noShippingMethodAddress: 'Nhập địa chỉ của bạn để xem các phương thức vận chuyển có sẵn.', noShippingMethods: 'Không tìm thấy phương thức vận chuyển nào.', + failedToLoadMethods: + 'Không thể tải phương thức vận chuyển. Vui lòng thử lại.', + retryMethods: 'Thử lại', phone: 'Số điện thoại', country: 'Quốc gia', selectCountry: 'Chọn quốc gia', diff --git a/packages/localizations/src/zhCn.ts b/packages/localizations/src/zhCn.ts index 19eb5bf9..4dbb9c3e 100644 --- a/packages/localizations/src/zhCn.ts +++ b/packages/localizations/src/zhCn.ts @@ -66,6 +66,8 @@ export const zhCn = { method: '配送方式', noShippingMethodAddress: '请输入您的地址以查看可用的配送方式。', noShippingMethods: '未找到配送方式。', + failedToLoadMethods: '无法加载配送方式。请重试。', + retryMethods: '重试', phone: '手机号码', country: '国家', selectCountry: '选择国家', diff --git a/packages/localizations/src/zhSg.ts b/packages/localizations/src/zhSg.ts index 52f1938b..a480ebec 100644 --- a/packages/localizations/src/zhSg.ts +++ b/packages/localizations/src/zhSg.ts @@ -66,6 +66,8 @@ export const zhSg = { method: '运送方式', noShippingMethodAddress: '输入您的地址以查看可用的运送方式。', noShippingMethods: '未找到运送方式。', + failedToLoadMethods: '无法加载配送方式。请重试。', + retryMethods: '重试', phone: '电话号码', country: '国家', selectCountry: '选择国家', diff --git a/packages/react/src/components/checkout/__tests__/checkout-confirm-errors.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-confirm-errors.test.tsx index 1724ab89..e6c57367 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-confirm-errors.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-confirm-errors.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, screen, waitFor } from '@testing-library/react'; import { useState } from 'react'; import { useFormContext } from 'react-hook-form'; import { describe, expect, it } from 'vitest'; @@ -9,6 +9,7 @@ import { PaymentProvider, useConfirmCheckout, } from '@/components/checkout/payment/utils/use-confirm-checkout'; +import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys'; import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors'; import { buildCheckoutSession, @@ -508,7 +509,7 @@ describe('Checkout confirm errors', () => { const draftOrder = buildDraftOrder({ shippingLines: [] }); const session = buildCheckoutSession({ draftOrder }); - const { user } = renderCheckout({ + const { user, queryClient } = renderCheckout({ session, draftOrder, checkoutProps: { @@ -521,6 +522,20 @@ describe('Checkout confirm errors', () => { }); await waitForCheckoutReady(); clearOperations(); + setApiError('getDraftOrderShippingMethods', 'rates failed'); + await act(async () => { + await queryClient.refetchQueries({ + queryKey: checkoutQueryKeys.draftOrderShippingMethods(session.id), + }); + }); + expect( + queryClient + .getQueryCache() + .findAll({ + queryKey: checkoutQueryKeys.draftOrderShippingMethods(session.id), + }) + .some(query => query.state.status === 'error') + ).toBe(true); await user.click( await screen.findByRole('button', { name: /express confirm seam/i }) 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 1efa1c16..2539704a 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx @@ -1,10 +1,14 @@ import { enUs } from '@godaddy/localizations'; import { fireEvent, screen, waitFor } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys'; +import { getDraftOrderShippingMethods } from '@/lib/godaddy/godaddy'; import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors'; +import { CheckoutType, PaymentProvider } from '@/types'; import { buildBillingAddress, buildShippingRates, + clearApiError, clearOperations, flushPromises, getOperations, @@ -232,9 +236,62 @@ describe('Checkout discounts', () => { expect(taxIndex).toBeGreaterThan(lastDiscountIndex); }); - it.each(['empty', 'error'] as const)( - 'clears applied shipping once when the discount rate refresh result is %s', - async result => { + it('clears applied shipping once when the discount rate refresh returns an empty array', async () => { + const paidShipping = buildShippingRates([ + { + serviceCode: 'standard', + carrierCode: 'carrier', + displayName: 'Standard', + cost: { value: 1000, currencyCode: 'USD' }, + }, + ]); + const { user } = renderCheckout({ + apiOverrides: { shippingMethods: paidShipping }, + draftOrderOverrides: { + shippingLines: [ + { + requestedService: 'standard', + requestedProvider: 'carrier', + name: 'Standard', + amount: { value: 1000, currencyCode: 'USD' }, + }, + ], + }, + }); + await waitForCheckoutReady(); + clearOperations(); + setShippingMethods([]); + + await applyCoupon(user, 'onedollar'); + await waitFor(() => { + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1); + }); + await flushPromises(); + await flushPromises(); + + expect(getOperations('DraftOrderShippingRates')).toHaveLength(1); + expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength(1); + expect( + getOperations('ApplyCheckoutSessionShippingMethod')[0].input + ).toEqual([]); + expect(getOperations('ApplyCheckoutSessionDiscount')).toHaveLength(2); + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1); + expect(screen.queryByText('Standard')).not.toBeInTheDocument(); + expect(document.body).toHaveTextContent(/no shipping methods found/i); + expect( + screen.queryByRole('button', { name: enUs.shipping.retryMethods }) + ).not.toBeInTheDocument(); + }); + + it.each([ + ['error', 'same'], + ['null', 'same'], + ['error', 'changed'], + ['error', 'default'], + ['error', 'empty'], + ] as const)( + 'clears shipping after a %s rate failure and recovers with %s rates', + async (failure, recovery) => { const paidShipping = buildShippingRates([ { serviceCode: 'standard', @@ -243,8 +300,29 @@ describe('Checkout discounts', () => { cost: { value: 1000, currencyCode: 'USD' }, }, ]); - const { user } = renderCheckout({ + if (recovery === 'default') { + paidShipping.push( + ...buildShippingRates([ + { + serviceCode: 'express', + carrierCode: 'carrier', + displayName: 'Express', + cost: { value: 2000, currencyCode: 'USD' }, + }, + ]) + ); + } + const { user, queryClient, session } = renderCheckout({ apiOverrides: { shippingMethods: paidShipping }, + sessionOverrides: { + paymentMethods: { + card: null as never, + offline: { + processor: PaymentProvider.OFFLINE, + checkoutTypes: [CheckoutType.STANDARD], + }, + }, + }, draftOrderOverrides: { shippingLines: [ { @@ -257,31 +335,125 @@ describe('Checkout discounts', () => { }, }); await waitForCheckoutReady(); + if (recovery === 'default') { + await user.click(screen.getByRole('radio', { name: /express/i })); + await waitFor(() => { + expect(queryClient.isMutating()).toBe(0); + expect(screen.getByRole('radio', { name: /express/i })).toBeChecked(); + }); + } clearOperations(); - if (result === 'error') { + if (failure === 'error') { setApiError('getDraftOrderShippingMethods', 'rates failed'); } else { - setShippingMethods([]); + vi.mocked(getDraftOrderShippingMethods).mockResolvedValueOnce({ + checkoutSession: { + id: session.id, + storeId: session.storeId, + draftOrder: { id: 'order-1', calculatedShippingRates: null }, + }, + }); } - await applyCoupon(user, 'onedollar'); - await waitFor(() => { - expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1); + const retry = await screen.findByRole('button', { + name: enUs.shipping.retryMethods, }); - await flushPromises(); - await flushPromises(); - - expect(getOperations('DraftOrderShippingRates')).toHaveLength(1); + await waitFor(() => expect(retry).toBeEnabled()); + expect( + screen.getByText(enUs.shipping.failedToLoadMethods) + ).toBeInTheDocument(); expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength( 1 ); expect( getOperations('ApplyCheckoutSessionShippingMethod')[0].input ).toEqual([]); - expect(getOperations('ApplyCheckoutSessionDiscount')).toHaveLength(2); expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1); - expect(screen.queryByText('Standard')).not.toBeInTheDocument(); - expect(document.body).toHaveTextContent(/no shipping methods found/i); + expect( + queryClient.getQueryData(checkoutQueryKeys.draftOrder(session.id)) + ).toMatchObject({ + checkoutSession: { + draftOrder: { + shippingLines: [], + }, + }, + }); + const pay = screen.getByRole('button', { name: /complete your order/i }); + expect(pay).toBeEnabled(); + await user.click(pay); + await screen.findByText(enUs.apiErrors.MISSING_SHIPPING_INFO); + await waitFor(() => expect(retry).toBeEnabled()); + expect(getOperations('ConfirmCheckoutSession')).toHaveLength(0); + // Repeated failures keep the retry action usable without clearing twice. + setApiError('getDraftOrderShippingMethods', 'rates still failed'); + await user.click(retry); + await waitFor(() => expect(retry).toBeEnabled()); + expect(pay).toBeEnabled(); + expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength( + 1 + ); + expect( + getOperations('ApplyCheckoutSessionShippingMethod')[0].input + ).toEqual([]); + clearApiError('getDraftOrderShippingMethods'); + if (recovery === 'changed') { + setShippingMethods( + buildShippingRates([ + { + serviceCode: 'standard', + carrierCode: 'carrier', + displayName: 'Standard', + cost: { value: 0, currencyCode: 'USD' }, + }, + ]) + ); + } else if (recovery === 'empty') { + setShippingMethods([]); + } + await user.click(retry); + await waitFor(() => + expect( + screen.queryByRole('button', { name: enUs.shipping.retryMethods }) + ).not.toBeInTheDocument() + ); + if (recovery === 'empty') { + expect( + getOperations('ApplyCheckoutSessionShippingMethod') + ).toHaveLength(1); + expect( + screen.getByText(enUs.shipping.noShippingMethods) + ).toBeInTheDocument(); + } else { + await waitFor(() => + expect( + getOperations('ApplyCheckoutSessionShippingMethod') + ).toHaveLength(2) + ); + expect( + getOperations('ApplyCheckoutSessionShippingMethod')[1].input + ).toEqual([ + expect.objectContaining({ + requestedService: 'standard', + subTotal: { + value: recovery === 'changed' ? 0 : 1000, + currencyCode: 'USD', + }, + }), + ]); + await waitFor(() => expect(pay).toBeEnabled()); + expect(screen.getByText('Standard')).toBeInTheDocument(); + if (recovery === 'default') { + expect( + screen.getByRole('radio', { name: /standard/i }) + ).toBeChecked(); + expect( + screen.getByRole('radio', { name: /express/i }) + ).not.toBeChecked(); + } + expect( + screen.queryByText(enUs.apiErrors.MISSING_SHIPPING_INFO) + ).not.toBeInTheDocument(); + } } ); 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 16315215..48aa8d16 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-shipping.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-shipping.test.tsx @@ -1,3 +1,4 @@ +import { enUs } from '@godaddy/localizations'; import { act, fireEvent, screen, waitFor } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys'; @@ -8,6 +9,7 @@ import { buildDraftOrder, buildLineItem, buildShippingAddress, + clearApiError, clearOperations, flushPromises, getOperations, @@ -460,7 +462,7 @@ describe('Checkout shipping behavior', () => { ).toBeInTheDocument(); }); - it('records a shipping-method fetch failure when rates are refetched', async () => { + it('clears shipping after an address rate-fetch failure and reapplies the default on retry', async () => { const { user } = renderCheckout(); await waitForCheckoutReady(); clearOperations(); @@ -476,6 +478,29 @@ describe('Checkout shipping behavior', () => { ).toMatchObject({ destination: expect.objectContaining({ postalCode: '94016' }), }); + const retry = await screen.findByRole('button', { + name: enUs.shipping.retryMethods, + }); + await waitFor(() => expect(retry).toBeEnabled()); + expect( + getOperations('ApplyCheckoutSessionShippingMethod').at(-1)?.input + ).toEqual([]); + clearOperations(); + clearApiError('getDraftOrderShippingMethods'); + await user.click(retry); + await waitFor(() => + expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength( + 1 + ) + ); + expect( + getOperations('ApplyCheckoutSessionShippingMethod')[0].input + ).toEqual([expect.objectContaining({ requestedService: 'free-shipping' })]); + await waitFor(() => + expect( + screen.queryByRole('button', { name: enUs.shipping.retryMethods }) + ).not.toBeInTheDocument() + ); }); it.each([ diff --git a/packages/react/src/components/checkout/payment/utils/use-flush-checkout-sync.test.tsx b/packages/react/src/components/checkout/payment/utils/use-flush-checkout-sync.test.tsx index bbf5ea1b..06acf34d 100644 --- a/packages/react/src/components/checkout/payment/utils/use-flush-checkout-sync.test.tsx +++ b/packages/react/src/components/checkout/payment/utils/use-flush-checkout-sync.test.tsx @@ -1,3 +1,4 @@ +import { QueryObserver } from '@tanstack/react-query'; import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; @@ -151,6 +152,36 @@ async function clickFlush(user: ReturnType) { } describe('useFlushCheckoutSync', () => { + it('waits for a failed shipping refresh without validating checkout state', async () => { + const onCheckoutErrors = vi.fn(); + const { user, queryClient } = renderHookHost({ + onCheckoutErrors, + timeoutMs: 1000, + }); + const request = deferred(); + const observer = new QueryObserver(queryClient, { + queryKey: + checkoutQueryKeys.draftOrderShippingMethods('checkout-session-1'), + queryFn: () => request.promise, + retry: false, + }); + const unsubscribe = observer.subscribe(() => undefined); + try { + await clickFlush(user); + await act(async () => { + request.reject(new Error('Provider unavailable')); + await flushPromises(); + await vi.advanceTimersByTimeAsync(60); + }); + await waitFor(() => + expect(screen.getByTestId('result')).toHaveTextContent('resolved') + ); + expect(onCheckoutErrors).not.toHaveBeenCalled(); + } finally { + unsubscribe(); + } + }); + it('excludes tax mutation waits when tax collection is disabled', async () => { const { user } = renderHookHost({ enableTaxCollection: false, diff --git a/packages/react/src/components/checkout/shipping/shipping-method.tsx b/packages/react/src/components/checkout/shipping/shipping-method.tsx index 4182a8d7..5d72a27a 100644 --- a/packages/react/src/components/checkout/shipping/shipping-method.tsx +++ b/packages/react/src/components/checkout/shipping/shipping-method.tsx @@ -25,6 +25,7 @@ import { useApplyShippingMethod } from '@/components/checkout/shipping/utils/use import { useDraftOrderShippingMethods } from '@/components/checkout/shipping/utils/use-draft-order-shipping-methods'; import { useFormatCurrency } from '@/components/checkout/utils/format-currency'; import { checkoutMutationKeys } from '@/components/checkout/utils/query-keys'; +import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { useGoDaddyContext } from '@/godaddy-provider'; @@ -44,6 +45,8 @@ export function ShippingMethodForm() { data: shippingMethodsData, isError: isShippingMethodsError, isLoading: isShippingMethodsLoading, + isFetching: isShippingMethodsFetching, + refetch: refetchShippingMethods, } = useDraftOrderShippingMethods(); const { data: shippingAddress, isLoading: isShippingAddressLoading } = useDraftOrderShippingAddress(); @@ -91,19 +94,22 @@ export function ShippingMethodForm() { useEffect(() => { if (isApplyingDiscount) { wasApplyingDiscountRef.current = true; - lastShippingMethodsKeyRef.current = - getShippingMethodsKey(shippingMethods); - lastProcessedStateRef.current = { - ...lastProcessedStateRef.current, - serviceCode: shippingLines?.requestedService ?? null, - cost: shippingLines?.amount?.value ?? null, - hadShippingMethods: shippingMethods.length > 0, - }; + if (!isShippingMethodsFetching) { + lastShippingMethodsKeyRef.current = + getShippingMethodsKey(shippingMethods); + lastProcessedStateRef.current = { + ...lastProcessedStateRef.current, + serviceCode: shippingLines?.requestedService ?? null, + cost: shippingLines?.amount?.value ?? null, + hadShippingMethods: shippingMethods.length > 0, + }; + } return; } if ( isShippingMethodsLoading || + isShippingMethodsFetching || isDraftOrderLoading || isConfirmingCheckout || applyShippingMethod.isPending @@ -258,6 +264,8 @@ export function ShippingMethodForm() { shippingLines, hasShippingAddress, isShippingMethodsLoading, + isShippingMethodsFetching, + isShippingMethodsError, form, applyShippingMethod, updateTaxes.mutate, @@ -284,6 +292,26 @@ export function ShippingMethodForm() { ); } + if (hasShippingAddress && isShippingMethodsError) { + return ( +
+

+ {t.shipping.failedToLoadMethods} +

+ +
+ ); + } + if ( hasShippingAddress && !isShippingMethodsLoading && diff --git a/packages/react/src/components/checkout/shipping/utils/use-draft-order-shipping-methods.ts b/packages/react/src/components/checkout/shipping/utils/use-draft-order-shipping-methods.ts index dcb2f349..89c191cc 100644 --- a/packages/react/src/components/checkout/shipping/utils/use-draft-order-shipping-methods.ts +++ b/packages/react/src/components/checkout/shipping/utils/use-draft-order-shipping-methods.ts @@ -58,14 +58,25 @@ export function useDraftOrderShippingMethods() { countryCode: shippingAddress?.countryCode, }, ], - queryFn: () => - jwt - ? getDraftOrderShippingMethods( + queryFn: async () => { + const result = jwt + ? await getDraftOrderShippingMethods( { accessToken: jwt }, destination, apiHost ) - : getDraftOrderShippingMethods(session, destination, apiHost), + : await getDraftOrderShippingMethods(session, destination, apiHost); + // A null response can represent an upstream provider failure. Only an + // actual array (including []) is a validated shipping-rate result. + if ( + !Array.isArray( + result?.checkoutSession?.draftOrder?.calculatedShippingRates?.rates + ) + ) { + throw new Error('Shipping rates are unavailable'); + } + return result; + }, enabled: !!session?.id && hasShippingAddress, select: data => data?.checkoutSession?.draftOrder?.calculatedShippingRates?.rates, From d1fe6079ae82b9ade27a532dfdfdfdcf8c50fd11 Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Tue, 29 Sep 2026 10:54:08 -0500 Subject: [PATCH 30/32] fix shiping application failed fallback scenarios --- .../__tests__/checkout-discount.test.tsx | 130 +++++++++++++++++- .../discount/utils/use-apply-discount-core.ts | 6 +- .../utils/use-reconcile-after-discount.ts | 49 ++++++- .../checkout-buttons/express/godaddy.tsx | 24 ++++ .../checkout-buttons/express/stripe.tsx | 50 +++++-- .../utils/use-remove-shipping-method.ts | 9 +- .../react/src/lib/godaddy/checkout-env.ts | 22 ++- .../src/lib/godaddy/checkout-mutations.ts | 1 + 8 files changed, 264 insertions(+), 27 deletions(-) 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 2539704a..4ec31445 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx @@ -496,17 +496,135 @@ describe('Checkout discounts', () => { await applyCoupon(user, 'onedollar'); await waitFor(() => { + // 1: initial standard apply on mount, 2: failed reapplication attempt, + // 3: the failed attempt's own clear-shipping fallback (which also fails + // here since setApiError blanket-fails every applyShippingMethod call). expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength( - 2 + 3 ); }); await flushPromises(); await flushPromises(); - expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength(2); + expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength(3); expect(screen.getByRole('radio', { name: /standard/i })).toBeChecked(); expect(screen.getByRole('radio', { name: /free/i })).not.toBeChecked(); expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(0); + + await screen.findByText(enUs.apiErrors.SHIPPING_METHOD_APPLICATION_FAILED); + + // The failed reapplication invalidates the draft order and shipping-rate + // queries so the next render re-evaluates reconciliation against fresh data. + await waitFor(() => { + expect(getOperations('DraftOrder').length).toBeGreaterThan(0); + expect(getOperations('DraftOrderShippingRates').length).toBeGreaterThan( + 1 + ); + }); + }); + + it('clears SHIPPING_METHOD_APPLICATION_FAILED once reconciliation succeeds again', async () => { + const paidShipping = buildShippingRates([ + { + serviceCode: 'standard', + carrierCode: 'carrier', + displayName: 'Standard', + cost: { value: 1000, currencyCode: 'USD' }, + }, + ]); + const { user } = renderCheckout({ + apiOverrides: { shippingMethods: paidShipping }, + draftOrderOverrides: { + shippingLines: [ + { + requestedService: 'standard', + requestedProvider: 'carrier', + name: 'Standard', + amount: { value: 1000, currencyCode: 'USD' }, + }, + ], + }, + }); + await waitForCheckoutReady(); + clearOperations(); + setApiError('applyShippingMethod', 'apply failed'); + setShippingMethods([ + ...paidShipping, + ...buildShippingRates([ + { + serviceCode: 'free', + carrierCode: 'carrier', + displayName: 'Free', + cost: { value: 0, currencyCode: 'USD' }, + }, + ]), + ]); + + await applyCoupon(user, 'onedollar'); + await screen.findByText(enUs.apiErrors.SHIPPING_METHOD_APPLICATION_FAILED); + + clearApiError('applyShippingMethod'); + await user.click(screen.getByRole('radio', { name: /free/i })); + + await waitFor(() => { + expect( + screen.queryByText(enUs.apiErrors.SHIPPING_METHOD_APPLICATION_FAILED) + ).not.toBeInTheDocument(); + }); + expect(screen.getByRole('radio', { name: /free/i })).toBeChecked(); + }); + + it('surfaces specific error codes from a failed shipping reapplication', async () => { + const paidShipping = buildShippingRates([ + { + serviceCode: 'standard', + carrierCode: 'carrier', + displayName: 'Standard', + cost: { value: 1000, currencyCode: 'USD' }, + }, + ]); + const { user } = renderCheckout({ + apiOverrides: { shippingMethods: paidShipping }, + draftOrderOverrides: { + shippingLines: [ + { + requestedService: 'standard', + requestedProvider: 'carrier', + name: 'Standard', + amount: { value: 1000, currencyCode: 'USD' }, + }, + ], + }, + }); + await waitForCheckoutReady(); + clearOperations(); + setApiError( + 'applyShippingMethod', + new GraphQLErrorWithCodes([ + { + message: 'shipping method not found', + code: 'SHIPPING_METHOD_NOT_FOUND', + }, + ]) + ); + setShippingMethods([ + ...paidShipping, + ...buildShippingRates([ + { + serviceCode: 'free', + carrierCode: 'carrier', + displayName: 'Free', + cost: { value: 0, currencyCode: 'USD' }, + }, + ]), + ]); + + await applyCoupon(user, 'onedollar'); + + await screen.findByText(enUs.apiErrors.SHIPPING_METHOD_NOT_FOUND); + expect( + screen.queryByText(enUs.apiErrors.SHIPPING_METHOD_APPLICATION_FAILED) + ).not.toBeInTheDocument(); }); it.each(['empty', 'replacement'] as const)( @@ -557,9 +675,15 @@ describe('Checkout discounts', () => { await applyCoupon(user, 'onedollar'); await waitFor(() => { + // 1: initial standard apply on mount, 2: failed reapplication attempt. + // 'empty' has no method to select, so the reapplication attempt was + // already a clear ([]); the fallback clear is skipped as redundant. + // 'replacement' selects a method, so the failure triggers a distinct + // clear-shipping fallback call (3), which also fails here since + // setApiError blanket-fails every applyShippingMethod call. expect( getOperations('ApplyCheckoutSessionShippingMethod') - ).toHaveLength(2); + ).toHaveLength(result === 'empty' ? 2 : 3); }); await flushPromises(); await flushPromises(); diff --git a/packages/react/src/components/checkout/discount/utils/use-apply-discount-core.ts b/packages/react/src/components/checkout/discount/utils/use-apply-discount-core.ts index 486cf2fe..490fd1d7 100644 --- a/packages/react/src/components/checkout/discount/utils/use-apply-discount-core.ts +++ b/packages/react/src/components/checkout/discount/utils/use-apply-discount-core.ts @@ -81,8 +81,10 @@ export function updateDiscountCache( }; }), shippingLines: - currentOrder.shippingLines?.map((currentShippingLine, index) => { - const updatedShippingLine = updatedOrder.shippingLines?.[index]; + currentOrder.shippingLines?.map(currentShippingLine => { + const updatedShippingLine = updatedOrder.shippingLines?.find( + shippingLine => shippingLine.id === currentShippingLine.id + ); if (!updatedShippingLine) { return discountCodes?.length diff --git a/packages/react/src/components/checkout/discount/utils/use-reconcile-after-discount.ts b/packages/react/src/components/checkout/discount/utils/use-reconcile-after-discount.ts index b4d0d8f4..42881584 100644 --- a/packages/react/src/components/checkout/discount/utils/use-reconcile-after-discount.ts +++ b/packages/react/src/components/checkout/discount/utils/use-reconcile-after-discount.ts @@ -13,13 +13,14 @@ import { import { useApplyShippingMethodCore } from '@/components/checkout/shipping/utils/use-apply-shipping-method-core'; import { useDraftOrderShippingMethods } from '@/components/checkout/shipping/utils/use-draft-order-shipping-methods'; import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys'; +import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors'; import { type ApplyDiscountVariables, useApplyDiscountCore, } from './use-apply-discount-core'; export function useReconcileAfterDiscount() { - const { session } = useCheckoutContext(); + const { session, setCheckoutErrors } = useCheckoutContext(); const form = useFormContext(); const queryClient = useQueryClient(); const updateTaxes = useUpdateTaxes(); @@ -60,9 +61,49 @@ export function useReconcileAfterDiscount() { previousMethodsKey: getShippingMethodsKey(previousShippingMethods), }); - await applyShippingMethod.mutateAsync( - selectedMethod ? buildShippingPayload(selectedMethod) : [] - ); + try { + await applyShippingMethod.mutateAsync( + selectedMethod ? buildShippingPayload(selectedMethod) : [] + ); + } catch (error) { + if ( + error instanceof GraphQLErrorWithCodes && + error.codes.length > 0 + ) { + setCheckoutErrors(error.codes); + } else { + setCheckoutErrors(['SHIPPING_METHOD_APPLICATION_FAILED']); + } + + // The failed reapplication left the order's shipping line stale + // (possibly no longer valid, e.g. a coupon that granted it is gone). + // Clear it server-side so the order itself is no longer stale, + // rather than just invalidating caches around unchanged data. If the + // failed attempt was already a clear (no selectedMethod), retrying + // the identical call would just duplicate the request for no gain. + if (selectedMethod) { + try { + await applyShippingMethod.mutateAsync([]); + form.setValue('shippingMethod', '', { shouldDirty: false }); + } catch { + // Clearing failed too; fall through to invalidation below so + // mounted views still refresh against whatever the server has. + } + } + + // Invalidate after the clear settles so the refetch it triggers + // picks up the now-actually-empty shippingLines instead of racing + // ahead of the clear and re-caching the stale line. + await queryClient.invalidateQueries({ + queryKey: checkoutQueryKeys.draftOrder(session.id), + }); + await queryClient.invalidateQueries({ + queryKey: checkoutQueryKeys.draftOrderShippingMethods(session.id), + }); + return; + } + + setCheckoutErrors(undefined); form.setValue('shippingMethod', selectedMethod?.serviceCode ?? '', { shouldDirty: false, }); diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx index f19986ed..f09a7474 100644 --- a/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx +++ b/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx @@ -542,6 +542,20 @@ export function ExpressCheckoutButton() { // Start with the base line items const baseLineItems = [...poyntExpressRequest.lineItems]; + // Refetch shipping methods so rates reflect the coupon change (e.g. free-shipping discounts) + let refreshedShippingMethods: Awaited< + ReturnType + > = []; + if (shippingAddress) { + try { + refreshedShippingMethods = await getSortedShippingMethods({ + shippingAddress, + }); + } catch (_shippingError) { + refreshedShippingMethods = []; + } + } + if (!couponCode) { // User removed the coupon code - clear refs appliedCouponCodeRef.current = null; @@ -830,6 +844,16 @@ export function ExpressCheckoutButton() { } } + // Include the refreshed shipping methods so the wallet reflects rates for the current coupon state + if (!updatedOrder.error && refreshedShippingMethods.length) { + updatedOrder.shippingMethods = refreshedShippingMethods.map(method => ({ + id: method.id || '', + label: method.label, + detail: method.detail, + amount: method.displayAmount, + })); + } + // Update the wallet with the new order information // console.log("[poynt collect] Updating order with", { updatedOrder }); e.updateWith(updatedOrder); diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.tsx index 742602c4..1d5f12ea 100644 --- a/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.tsx +++ b/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.tsx @@ -97,23 +97,55 @@ export function StripeExpressCheckoutForm() { if (!couponCode) { appliedCouponCodeRef.current = null; calculatedAdjustmentsRef.current = null; - return; + } else { + try { + const result = await getPriceAdjustments.mutateAsync({ + discountCodes: [couponCode], + }); + + if (requestId !== couponSyncRequestRef.current) return; + + appliedCouponCodeRef.current = result ? couponCode : null; + calculatedAdjustmentsRef.current = result ?? null; + } catch { + if (requestId !== couponSyncRequestRef.current) return; + + appliedCouponCodeRef.current = null; + calculatedAdjustmentsRef.current = null; + } } + // Refetch shipping methods so rates reflect the coupon change (add or + // remove) once a shipping address is known. + if (!shippingAddress) return; + try { - const result = await getPriceAdjustments.mutateAsync({ - discountCodes: [couponCode], - }); + const shippingMethodsData = + await getShippingMethodsByAddress.mutateAsync({ + countryCode: shippingAddress.country || 'US', + postalCode: shippingAddress.postal_code || '', + adminArea2: shippingAddress.city || '', + adminArea1: shippingAddress.state || '', + }); if (requestId !== couponSyncRequestRef.current) return; - appliedCouponCodeRef.current = result ? couponCode : null; - calculatedAdjustmentsRef.current = result ?? null; + setShippingMethods(shippingMethodsData || null); + + const defaultMethod = sortShippingMethods(shippingMethodsData || [])[0]; + + if (defaultMethod) { + setSelectedShippingRate({ + id: + defaultMethod.displayName?.replace(/\s+/g, '-')?.toLowerCase() || + 'shipping', + amount: defaultMethod.cost?.value || 0, + displayName: defaultMethod.displayName || t.totals.shipping, + deliveryEstimate: defaultMethod.description || undefined, + }); + } } catch { if (requestId !== couponSyncRequestRef.current) return; - - appliedCouponCodeRef.current = null; - calculatedAdjustmentsRef.current = null; } }; diff --git a/packages/react/src/components/checkout/shipping/utils/use-remove-shipping-method.ts b/packages/react/src/components/checkout/shipping/utils/use-remove-shipping-method.ts index f1450dff..cf1c48d2 100644 --- a/packages/react/src/components/checkout/shipping/utils/use-remove-shipping-method.ts +++ b/packages/react/src/components/checkout/shipping/utils/use-remove-shipping-method.ts @@ -49,14 +49,7 @@ export function useRemoveShippingMethod() { ...old.checkoutSession, draftOrder: { ...old?.checkoutSession?.draftOrder, - shippingLines: [ - { - ...old?.checkoutSession?.draftOrder?.shippingLines?.[0], - amount: { - ...shippingTotal, - }, - }, - ], + shippingLines: [], totals: { ...old?.checkoutSession?.draftOrder?.totals, shippingTotal: { diff --git a/packages/react/src/lib/godaddy/checkout-env.ts b/packages/react/src/lib/godaddy/checkout-env.ts index 1c5bd8b4..903e0478 100644 --- a/packages/react/src/lib/godaddy/checkout-env.ts +++ b/packages/react/src/lib/godaddy/checkout-env.ts @@ -2020,6 +2020,18 @@ const introspection = { "args": [], "isDeprecated": false }, + { + "name": "orderId", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "String" + } + }, + "args": [], + "isDeprecated": false + }, { "name": "paymentMethods", "type": { @@ -9752,7 +9764,15 @@ const introspection = { "kind": "OBJECT", "name": "CheckoutSession" }, - "args": [], + "args": [ + { + "name": "id", + "type": { + "kind": "SCALAR", + "name": "ID" + } + } + ], "isDeprecated": false }, { diff --git a/packages/react/src/lib/godaddy/checkout-mutations.ts b/packages/react/src/lib/godaddy/checkout-mutations.ts index 04abfdf7..77e087d6 100644 --- a/packages/react/src/lib/godaddy/checkout-mutations.ts +++ b/packages/react/src/lib/godaddy/checkout-mutations.ts @@ -365,6 +365,7 @@ export const ApplyCheckoutSessionDiscountMutation = graphql(` } } shippingLines { + id discounts { id amount { From 7f34996c86296f3533611bebafbc95e4821fd4eb Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Tue, 29 Sep 2026 16:45:52 -0500 Subject: [PATCH 31/32] consolidate changesets --- .changeset/calm-coupons-ship.md | 9 ++++++++- .changeset/clever-clubs-crash.md | 10 ---------- .changeset/retry-shipping-rates.md | 6 ------ 3 files changed, 8 insertions(+), 17 deletions(-) delete mode 100644 .changeset/clever-clubs-crash.md delete mode 100644 .changeset/retry-shipping-rates.md diff --git a/.changeset/calm-coupons-ship.md b/.changeset/calm-coupons-ship.md index 5adcce0d..45e44255 100644 --- a/.changeset/calm-coupons-ship.md +++ b/.changeset/calm-coupons-ship.md @@ -1,5 +1,12 @@ --- "@godaddy/react": patch +"@godaddy/localizations": patch --- -Keep shipping rates, discounts, taxes, and express checkout in sync when coupons change. +Fix billing collection, shipping reconciliation, and discount/coupon sync across checkout flows. + +- 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 shipping rates, discounts, taxes, and express checkout in sync when coupons change. +- Show a localized retry action when shipping rates cannot be loaded. Clear applied shipping when rates cannot be loaded, and apply the refreshed default method after a successful retry. diff --git a/.changeset/clever-clubs-crash.md b/.changeset/clever-clubs-crash.md deleted file mode 100644 index 336fb607..00000000 --- a/.changeset/clever-clubs-crash.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"@godaddy/react": patch ---- - -Fix billing collection across checkout flows. - -- 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. diff --git a/.changeset/retry-shipping-rates.md b/.changeset/retry-shipping-rates.md deleted file mode 100644 index 0b51e816..00000000 --- a/.changeset/retry-shipping-rates.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@godaddy/react": patch -"@godaddy/localizations": patch ---- - -Show a localized retry action when shipping rates cannot be loaded. Clear applied shipping when rates cannot be loaded, and apply the refreshed default method after a successful retry. From 92a0191070e79950293aadcb32d5427fcc3f5dd9 Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Fri, 2 Oct 2026 16:57:09 -0500 Subject: [PATCH 32/32] Address PR review feedback on shipping selection and express coupons Keep a saved or customer-chosen shipping method while it is still offered, including on load, and only fall back to the cheapest rate when it is gone, was picked automatically and rates changed, or free shipping newly appears. Re-apply line-item discount codes on shipping changes, stop the Stripe express sheet from refetching and reselecting shipping on coupon changes, use the highest-value code for express wallets, and recompute cached coupon adjustments when the subtotal changes. Keep the unused remove-shipping hook from triggering reconciliation, disable automatic retries on the shipping rates query, trim the changeset, and use formal German copy. Add tests for the selection rules, failed-reapply recovery, discount cache matching, and the GoDaddy and Stripe express coupon sync. Co-Authored-By: Claude Opus 5.5 --- .changeset/calm-coupons-ship.md | 8 +- packages/localizations/src/deDe.ts | 4 +- .../__tests__/checkout-discount.test.tsx | 194 +++++++++++++++++ .../__tests__/checkout-free-order.test.tsx | 10 +- .../__tests__/checkout-shipping.test.tsx | 25 +++ .../src/components/checkout/checkout.tsx | 2 + .../get-draft-order-discount-codes.test.ts | 42 +++- .../utils/get-draft-order-discount-codes.ts | 40 ++++ .../utils/use-apply-discount-core.test.ts | 96 ++++++++ .../utils/use-reconcile-after-discount.ts | 13 +- .../checkout-buttons/express/godaddy.test.tsx | 205 ++++++++++++++++++ .../checkout-buttons/express/godaddy.tsx | 18 +- .../checkout-buttons/express/stripe.test.tsx | 167 ++++++++++++++ .../checkout-buttons/express/stripe.tsx | 66 ++---- .../checkout/shipping/shipping-method.tsx | 38 +++- .../requires-shipping-reconciliation.test.ts | 86 +++++++- .../utils/requires-shipping-reconciliation.ts | 53 ++++- .../utils/use-apply-shipping-method.ts | 27 +-- .../utils/use-draft-order-shipping-methods.ts | 2 + .../utils/use-remove-shipping-method.ts | 59 +---- .../checkout/utils/checkout-transformers.ts | 1 + 21 files changed, 994 insertions(+), 162 deletions(-) create mode 100644 packages/react/src/components/checkout/discount/utils/use-apply-discount-core.test.ts create mode 100644 packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.test.tsx create mode 100644 packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.test.tsx diff --git a/.changeset/calm-coupons-ship.md b/.changeset/calm-coupons-ship.md index 45e44255..dfb1d08e 100644 --- a/.changeset/calm-coupons-ship.md +++ b/.changeset/calm-coupons-ship.md @@ -3,10 +3,8 @@ "@godaddy/localizations": patch --- -Fix billing collection, shipping reconciliation, and discount/coupon sync across checkout flows. +Keep shipping rates, discounts, taxes, and express checkout in sync when coupons change. -- 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 shipping rates, discounts, taxes, and express checkout in sync when coupons change. +- Refetch shipping rates after a coupon is applied or removed, and keep the customer's chosen shipping method while it is still offered. - Show a localized retry action when shipping rates cannot be loaded. Clear applied shipping when rates cannot be loaded, and apply the refreshed default method after a successful retry. +- Remove the unused `experimental_rules.freeShipping` session field. diff --git a/packages/localizations/src/deDe.ts b/packages/localizations/src/deDe.ts index af9669e4..ed07fbde 100644 --- a/packages/localizations/src/deDe.ts +++ b/packages/localizations/src/deDe.ts @@ -71,7 +71,7 @@ export const deDe = { 'Geben Sie Ihre Adresse ein, um verfügbare Versandmethoden zu sehen.', noShippingMethods: 'Keine Versandmethoden gefunden.', failedToLoadMethods: - 'Versandarten konnten nicht geladen werden. Bitte versuche es erneut.', + 'Versandarten konnten nicht geladen werden. Bitte versuchen Sie es erneut.', retryMethods: 'Erneut versuchen', phone: 'Telefonnummer', country: 'Land', @@ -125,7 +125,7 @@ export const deDe = { paze: '', offline: '', mercadopago: - 'Verwende das MercadoPago-Formular unten, um deinen Kauf sicher abzuschließen.', + 'Verwenden Sie das MercadoPago-Formular unten, um Ihren Kauf sicher abzuschließen.', ach: '', ccavenue: '', }, 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 4ec31445..b6c64de9 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx @@ -14,6 +14,7 @@ import { getOperations, renderCheckout, setApiError, + setApiErrorOnce, setShippingMethods, waitForCheckoutReady, waitForOperation, @@ -704,6 +705,199 @@ describe('Checkout discounts', () => { } ); + it("keeps the customer's chosen method when a coupon reprices the rates", async () => { + const rates = (standardCost: number, expressCost: number) => + buildShippingRates([ + { + serviceCode: 'standard', + carrierCode: 'carrier', + displayName: 'Standard', + cost: { value: standardCost, currencyCode: 'USD' }, + }, + { + serviceCode: 'express', + carrierCode: 'carrier', + displayName: 'Express', + cost: { value: expressCost, currencyCode: 'USD' }, + }, + ]); + const { user } = renderCheckout({ + apiOverrides: { shippingMethods: rates(500, 2000) }, + draftOrderOverrides: { + shippingLines: [ + { + requestedService: 'standard', + requestedProvider: 'carrier', + name: 'Standard', + amount: { value: 500, currencyCode: 'USD' }, + }, + ], + }, + }); + await waitForCheckoutReady(); + + await user.click(screen.getByRole('radio', { name: /express/i })); + await waitForOperation('ApplyCheckoutSessionShippingMethod'); + await flushPromises(); + clearOperations(); + + setShippingMethods(rates(400, 1900)); + await applyCoupon(user, 'onedollar'); + await waitForOperation('ApplyCheckoutSessionShippingMethod'); + await flushPromises(); + + const applied = getOperations('ApplyCheckoutSessionShippingMethod'); + expect(applied.at(-1)?.input).toEqual([ + expect.objectContaining({ + requestedService: 'express', + subTotal: { value: 1900, currencyCode: 'USD' }, + }), + ]); + expect(screen.getByRole('radio', { name: /express/i })).toBeChecked(); + }); + + it('clears the stale shipping line when reapplication fails, then reapplies on the next render', async () => { + const paidShipping = buildShippingRates([ + { + serviceCode: 'standard', + carrierCode: 'carrier', + displayName: 'Standard', + cost: { value: 1000, currencyCode: 'USD' }, + }, + ]); + const { user } = renderCheckout({ + apiOverrides: { shippingMethods: paidShipping }, + draftOrderOverrides: { + shippingLines: [ + { + requestedService: 'standard', + requestedProvider: 'carrier', + name: 'Standard', + amount: { value: 1000, currencyCode: 'USD' }, + }, + ], + }, + }); + await waitForCheckoutReady(); + clearOperations(); + setApiErrorOnce('applyShippingMethod', 'apply failed'); + setShippingMethods([ + ...paidShipping, + ...buildShippingRates([ + { + serviceCode: 'free', + carrierCode: 'carrier', + displayName: 'Free', + cost: { value: 0, currencyCode: 'USD' }, + }, + ]), + ]); + + await applyCoupon(user, 'onedollar'); + + await waitFor(() => { + expect( + getOperations('ApplyCheckoutSessionShippingMethod').map(operation => + (operation.input as Array<{ requestedService: string }>).map( + line => line.requestedService + ) + ) + ).toEqual([['free'], [], ['free']]); + }); + await flushPromises(); + + expect(screen.getByRole('radio', { name: /free/i })).toBeChecked(); + expect( + screen.queryByText(enUs.apiErrors.SHIPPING_METHOD_APPLICATION_FAILED) + ).not.toBeInTheDocument(); + }); + + it('reapplies line-item discount codes when the shipping method changes', async () => { + const shippingMethods = buildShippingRates([ + { + serviceCode: 'flat-rate', + carrierCode: 'carrier', + displayName: 'Flat Rate', + cost: { value: 10, currencyCode: 'USD' }, + }, + { + serviceCode: 'premium-rate', + carrierCode: 'carrier', + displayName: 'Premium Rate', + cost: { value: 100, currencyCode: 'USD' }, + }, + ]); + const { user } = renderCheckout({ + apiOverrides: { shippingMethods }, + draftOrderOverrides: { + discounts: [{ code: 'order10' }], + lineItems: [{ id: 'line-item-1', discounts: [{ code: 'lineitem10' }] }], + shippingLines: [ + { + requestedService: 'flat-rate', + requestedProvider: 'carrier', + name: 'Flat Rate', + amount: { value: 10, currencyCode: 'USD' }, + }, + ], + }, + }); + await waitForCheckoutReady(); + await flushPromises(); + clearOperations(); + + await user.click(screen.getByRole('radio', { name: /premium rate/i })); + await waitForOperation('ApplyCheckoutSessionDiscount'); + + expect(getOperations('ApplyCheckoutSessionDiscount')[0].input).toEqual({ + discountCodes: expect.arrayContaining(['order10', 'lineitem10']), + }); + }); + + it('keeps treating a method as automatic after a failed customer change', async () => { + const rates = (cheapCost: number, otherCost: number) => + buildShippingRates([ + { + serviceCode: 'cheap', + carrierCode: 'carrier', + displayName: 'Cheap', + cost: { value: cheapCost, currencyCode: 'USD' }, + }, + { + serviceCode: 'other', + carrierCode: 'carrier', + displayName: 'Other', + cost: { value: otherCost, currencyCode: 'USD' }, + }, + ]); + const { user } = renderCheckout({ + apiOverrides: { shippingMethods: rates(100, 500) }, + draftOrderOverrides: { shippingLines: [] }, + }); + await waitForCheckoutReady(); + await waitForOperation('ApplyCheckoutSessionShippingMethod'); + await flushPromises(); + expect(screen.getByRole('radio', { name: /cheap/i })).toBeChecked(); + + setApiErrorOnce('applyShippingMethod', 'apply failed'); + await user.click(screen.getByRole('radio', { name: /other/i })); + await waitFor(() => { + expect(screen.getByRole('radio', { name: /cheap/i })).toBeChecked(); + }); + await flushPromises(); + clearOperations(); + + // "Cheap" is still an automatic pick, so a repricing moves it to the + // new cheapest method instead of keeping it like a customer choice. + setShippingMethods(rates(600, 500)); + await applyCoupon(user, 'onedollar'); + await waitForOperation('ApplyCheckoutSessionShippingMethod'); + + expect( + getOperations('ApplyCheckoutSessionShippingMethod').at(-1)?.input + ).toEqual([expect.objectContaining({ requestedService: 'other' })]); + }); + it('applies a newly available free method before calculating taxes', async () => { const paidShipping = buildShippingRates([ { 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 788ae2cf..080b0948 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 @@ -473,7 +473,7 @@ describe('Checkout free / offline orders', () => { ).not.toBeInTheDocument(); }); - it('switches to FreePaymentForm when the cheapest shipping rate makes the total zero', async () => { + it('switches to FreePaymentForm when selecting a free shipping rate makes the total zero', async () => { const draftOrder = buildDraftOrder({ totals: { subTotal: { value: 0, currencyCode: 'USD' }, @@ -512,12 +512,18 @@ describe('Checkout free / offline orders', () => { enableTaxCollection: false, }); - renderCheckout({ + const { user } = renderCheckout({ session, draftOrder, apiOverrides: { shippingMethods: buildShippingRates() }, }); await waitForCheckoutReady(); + expect( + await screen.findByRole('button', { name: /pay now/i }) + ).toBeInTheDocument(); + + clearOperations(); + await user.click(screen.getByRole('radio', { name: /free/i })); await waitForOperation('ApplyCheckoutSessionShippingMethod'); expect( 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 48aa8d16..6c9e66e5 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-shipping.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-shipping.test.tsx @@ -54,6 +54,31 @@ describe('Checkout shipping behavior', () => { } ); + it('keeps a saved shipping method on load when a cheaper rate is offered', async () => { + const { queryClient } = renderCheckout({ + draftOrderOverrides: { + shippingLines: [ + { + requestedService: 'weight-based', + requestedProvider: 'unknown', + name: 'Weight Based', + amount: { value: 100, currencyCode: 'USD' }, + discounts: [], + }, + ], + }, + }); + await waitForCheckoutReady(); + await waitFor(() => { + expect(queryClient.isMutating()).toBe(0); + expect(queryClient.isFetching()).toBe(0); + }); + + expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength(0); + expect(screen.getByRole('radio', { name: /weight based/i })).toBeChecked(); + expect(screen.getByRole('radio', { name: /free/i })).not.toBeChecked(); + }); + it('shows the no-origin-address message when shipping origin is missing', async () => { renderCheckout({ sessionOverrides: { shipping: { originAddress: null } }, diff --git a/packages/react/src/components/checkout/checkout.tsx b/packages/react/src/components/checkout/checkout.tsx index 812d0a74..7b326b54 100644 --- a/packages/react/src/components/checkout/checkout.tsx +++ b/packages/react/src/components/checkout/checkout.tsx @@ -156,6 +156,8 @@ export const baseCheckoutSchema = z.object({ shippingPostalCode: z.string().max(60), shippingCountryCode: z.string().max(2), shippingMethod: z.string().optional(), + // Client-only: true when checkout picked the method rather than the customer. + shippingMethodAutoSelected: z.boolean().optional(), billingFirstName: z.string().max(60), billingLastName: z.string().max(60), billingPhone: z.string().max(15, 'Phone number too long').optional(), diff --git a/packages/react/src/components/checkout/discount/utils/get-draft-order-discount-codes.test.ts b/packages/react/src/components/checkout/discount/utils/get-draft-order-discount-codes.test.ts index 4b980309..5e59dcf3 100644 --- a/packages/react/src/components/checkout/discount/utils/get-draft-order-discount-codes.test.ts +++ b/packages/react/src/components/checkout/discount/utils/get-draft-order-discount-codes.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest'; import type { DraftOrder } from '@/types'; -import { getDraftOrderDiscountCodes } from './get-draft-order-discount-codes'; +import { + getDraftOrderDiscountCodes, + getHighestValueDraftOrderDiscountCode, +} from './get-draft-order-discount-codes'; describe('getDraftOrderDiscountCodes', () => { it('collects unique order, line-item, and shipping-line discount codes', () => { @@ -24,3 +27,40 @@ describe('getDraftOrderDiscountCodes', () => { expect(getDraftOrderDiscountCodes()).toEqual([]); }); }); + +describe('getHighestValueDraftOrderDiscountCode', () => { + it('returns the code with the largest combined discount across all levels', () => { + const draftOrder = { + discounts: [{ code: 'SAVE10', amount: { value: 500 } }], + lineItems: [ + { discounts: [{ code: 'BIGLINE', amount: { value: 400 } }] }, + { discounts: [{ code: 'BIGLINE', amount: { value: 400 } }] }, + ], + shippingLines: [ + { discounts: [{ code: 'FREESHIP', amount: { value: 700 } }] }, + ], + } as DraftOrder; + + expect(getHighestValueDraftOrderDiscountCode(draftOrder)).toBe('BIGLINE'); + }); + + it('prefers the first code seen, order-level first, on a tie', () => { + const draftOrder = { + discounts: [{ code: 'SAVE10', amount: { value: 500 } }], + shippingLines: [ + { discounts: [{ code: 'FREESHIP', amount: { value: 500 } }] }, + ], + } as DraftOrder; + + expect(getHighestValueDraftOrderDiscountCode(draftOrder)).toBe('SAVE10'); + }); + + it('returns undefined when no discount codes are applied', () => { + expect(getHighestValueDraftOrderDiscountCode()).toBeUndefined(); + expect( + getHighestValueDraftOrderDiscountCode({ + discounts: [], + } as unknown as DraftOrder) + ).toBeUndefined(); + }); +}); diff --git a/packages/react/src/components/checkout/discount/utils/get-draft-order-discount-codes.ts b/packages/react/src/components/checkout/discount/utils/get-draft-order-discount-codes.ts index 800d1c9b..373bedeb 100644 --- a/packages/react/src/components/checkout/discount/utils/get-draft-order-discount-codes.ts +++ b/packages/react/src/components/checkout/discount/utils/get-draft-order-discount-codes.ts @@ -23,3 +23,43 @@ export function getDraftOrderDiscountCodes( return Array.from(codes).sort(); } + +// Express wallets accept a single coupon, so use the one worth the most. +export function getHighestValueDraftOrderDiscountCode( + draftOrder?: DraftOrder | null +): string | undefined { + const totalsByCode = new Map(); + const addDiscounts = ( + discounts?: ReadonlyArray<{ + code?: string | null; + amount?: { value?: number | null } | null; + }> | null + ) => { + for (const discount of discounts ?? []) { + if (!discount.code) continue; + totalsByCode.set( + discount.code, + (totalsByCode.get(discount.code) ?? 0) + (discount.amount?.value ?? 0) + ); + } + }; + + addDiscounts(draftOrder?.discounts); + for (const lineItem of draftOrder?.lineItems ?? []) { + addDiscounts(lineItem.discounts); + } + for (const shippingLine of draftOrder?.shippingLines ?? []) { + addDiscounts(shippingLine.discounts); + } + + let highestCode: string | undefined; + let highestTotal = Number.NEGATIVE_INFINITY; + for (const [code, total] of totalsByCode) { + if (total > highestTotal) { + highestCode = code; + highestTotal = total; + } + } + + return highestCode; +} diff --git a/packages/react/src/components/checkout/discount/utils/use-apply-discount-core.test.ts b/packages/react/src/components/checkout/discount/utils/use-apply-discount-core.test.ts new file mode 100644 index 00000000..59035122 --- /dev/null +++ b/packages/react/src/components/checkout/discount/utils/use-apply-discount-core.test.ts @@ -0,0 +1,96 @@ +import { QueryClient } from '@tanstack/react-query'; +import { describe, expect, it } from 'vitest'; +import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys'; +import { updateDiscountCache } from './use-apply-discount-core'; + +type UpdatedOrder = Parameters[2]; + +const freeShippingDiscount = { + id: 'discount-freeship', + code: 'freeship', + amount: { value: 500, currencyCode: 'USD' }, +}; + +function seedDraftOrder(queryClient: QueryClient) { + queryClient.setQueryData(checkoutQueryKeys.draftOrder('session-1'), { + checkoutSession: { + id: 'session-1', + draftOrder: { + id: 'draft-order-1', + totals: {}, + discounts: [], + lineItems: [], + shippingLines: [ + { id: 'shipping-a', name: 'A', discounts: [] }, + { id: 'shipping-b', name: 'B', discounts: [] }, + ], + }, + }, + }); +} + +function cachedShippingLines(queryClient: QueryClient) { + return ( + queryClient.getQueryData(checkoutQueryKeys.draftOrder('session-1')) as { + checkoutSession: { + draftOrder: { + shippingLines: Array<{ id: string; discounts: unknown[] }>; + }; + }; + } + ).checkoutSession.draftOrder.shippingLines; +} + +describe('updateDiscountCache', () => { + it('matches shipping-line discounts by id, not position', () => { + const queryClient = new QueryClient(); + seedDraftOrder(queryClient); + + updateDiscountCache( + queryClient, + 'session-1', + { + shippingLines: [ + { id: 'shipping-b', discounts: [freeShippingDiscount] }, + { id: 'shipping-a', discounts: [] }, + ], + } as unknown as UpdatedOrder, + ['freeship'] + ); + + expect(cachedShippingLines(queryClient)).toEqual([ + expect.objectContaining({ id: 'shipping-a', discounts: [] }), + expect.objectContaining({ + id: 'shipping-b', + discounts: [freeShippingDiscount], + }), + ]); + }); + + it('clears shipping-line discounts that are missing from the response when codes are removed', () => { + const queryClient = new QueryClient(); + seedDraftOrder(queryClient); + updateDiscountCache( + queryClient, + 'session-1', + { + shippingLines: [ + { id: 'shipping-a', discounts: [freeShippingDiscount] }, + ], + } as unknown as UpdatedOrder, + ['freeship'] + ); + + updateDiscountCache( + queryClient, + 'session-1', + { shippingLines: [] } as unknown as UpdatedOrder, + [] + ); + + expect(cachedShippingLines(queryClient)).toEqual([ + expect.objectContaining({ id: 'shipping-a', discounts: [] }), + expect.objectContaining({ id: 'shipping-b', discounts: [] }), + ]); + }); +}); diff --git a/packages/react/src/components/checkout/discount/utils/use-reconcile-after-discount.ts b/packages/react/src/components/checkout/discount/utils/use-reconcile-after-discount.ts index 42881584..14bcc20f 100644 --- a/packages/react/src/components/checkout/discount/utils/use-reconcile-after-discount.ts +++ b/packages/react/src/components/checkout/discount/utils/use-reconcile-after-discount.ts @@ -6,7 +6,6 @@ import { useDraftOrder } from '@/components/checkout/order/use-draft-order'; import { useUpdateTaxes } from '@/components/checkout/order/use-update-taxes'; import { buildShippingPayload } from '@/components/checkout/shipping/utils/build-shipping-payload'; import { - getShippingMethodsKey, requiresShippingReconciliation, selectShippingMethod, } from '@/components/checkout/shipping/utils/requires-shipping-reconciliation'; @@ -44,21 +43,26 @@ export function useReconcileAfterDiscount() { const previousShippingMethods = shippingMethodsQuery.data ?? []; const { data, isError } = await shippingMethodsQuery.refetch(); const refreshedMethods = isError ? [] : (data ?? []); + const isAutoSelected = Boolean( + form.getValues('shippingMethodAutoSelected') + ); const shippingRequiresReconciliation = requiresShippingReconciliation({ shippingMethods: refreshedMethods, previousShippingMethods, currentShippingLine: draftOrder?.shippingLines?.[0], selectedServiceCode: form.getValues('shippingMethod'), + isAutoSelected, }); if (shippingRequiresReconciliation) { const currentServiceCode = form.getValues('shippingMethod') || draftOrder?.shippingLines?.[0]?.requestedService; - const { selectedMethod } = selectShippingMethod({ + const { selectedMethod, autoSelected } = selectShippingMethod({ shippingMethods: refreshedMethods, currentServiceCode, - previousMethodsKey: getShippingMethodsKey(previousShippingMethods), + previousShippingMethods, + isAutoSelected, }); try { @@ -107,6 +111,9 @@ export function useReconcileAfterDiscount() { form.setValue('shippingMethod', selectedMethod?.serviceCode ?? '', { shouldDirty: false, }); + form.setValue('shippingMethodAutoSelected', autoSelected, { + shouldDirty: false, + }); if (session.enablePromotionCodes && variables.discountCodes?.length) { await reapplyDiscount.mutateAsync(variables); diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.test.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.test.tsx new file mode 100644 index 00000000..8c4d5c0f --- /dev/null +++ b/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.test.tsx @@ -0,0 +1,205 @@ +import { enUs } from '@godaddy/localizations'; +import { act, render, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { DraftOrder } from '@/types'; +import { ExpressCheckoutButton } from './godaddy'; + +type Handler = (event: Record) => Promise | void; + +const mocks = vi.hoisted(() => ({ + draftOrder: null as unknown, + getPriceAdjustments: vi.fn(), + getShippingMethods: vi.fn(), + getTaxes: vi.fn(), + tokenizeOptions: [] as Array>, + handlers: {} as Record, +})); + +vi.mock('@/components/checkout/checkout', () => ({ + useCheckoutContext: () => ({ + session: { + id: 'session-1', + storeId: 'store-1', + channelId: 'channel-1', + enablePromotionCodes: true, + enableShippingAddressCollection: true, + enableTaxCollection: false, + }, + godaddyPaymentsConfig: { appId: 'app-1', businessId: 'business-1' }, + setCheckoutErrors: vi.fn(), + isConfirmingCheckout: false, + }), +})); +vi.mock('@/components/checkout/order/use-draft-order', () => ({ + useDraftOrder: () => ({ data: mocks.draftOrder }), + useDraftOrderTotals: () => ({ + data: (mocks.draftOrder as DraftOrder | null)?.totals, + }), +})); +vi.mock( + '@/components/checkout/discount/utils/use-get-price-adjustments', + () => ({ + useGetPriceAdjustments: () => ({ mutateAsync: mocks.getPriceAdjustments }), + }) +); +vi.mock( + '@/components/checkout/shipping/utils/use-get-shipping-methods', + () => ({ + useGetShippingMethodByAddress: () => ({ + mutateAsync: mocks.getShippingMethods, + }), + }) +); +vi.mock('@/components/checkout/taxes/utils/use-get-taxes', () => ({ + useGetTaxes: () => ({ mutateAsync: mocks.getTaxes }), +})); +vi.mock('@/components/checkout/payment/utils/use-load-poynt-collect', () => ({ + useLoadPoyntCollect: () => ({ isPoyntLoaded: true }), +})); +vi.mock( + '@/components/checkout/payment/utils/use-build-payment-request', + () => ({ + useBuildPaymentRequest: () => ({ + poyntExpressRequest: { + lineItems: [], + total: { label: 'Total', amount: '25.00' }, + }, + }), + }) +); +vi.mock( + '@/components/checkout/payment/utils/use-confirm-express-checkout', + () => ({ useConfirmExpressCheckout: () => ({ mutateAsync: vi.fn() }) }) +); +vi.mock('@/components/checkout/payment/utils/use-is-payment-disabled', () => ({ + useIsPaymentDisabled: () => false, +})); +vi.mock('@/godaddy-provider', () => ({ + useGoDaddyContext: () => ({ t: enUs, locale: 'en-US' }), +})); +vi.mock('@/tracking/track', async importOriginal => ({ + ...(await importOriginal()), + track: vi.fn(), +})); + +class FakeTokenizeJs { + constructor(_config: unknown, options: Record) { + mocks.tokenizeOptions.push(options); + } + supportWalletPayments() { + return Promise.resolve({ applePay: true, googlePay: false }); + } + mount() { + return undefined; + } + on(event: string, handler: Handler) { + mocks.handlers[event] = handler; + } +} + +function draftOrder({ + subTotal = 2500, + id = 'draft-order-1', +}: { + subTotal?: number; + id?: string; +} = {}) { + return { + id, + totals: { + subTotal: { value: subTotal, currencyCode: 'USD' }, + total: { value: subTotal, currencyCode: 'USD' }, + }, + discounts: [{ code: 'SAVE5', amount: { value: 500 } }], + lineItems: [ + { id: 'line-1', discounts: [{ code: 'BIG', amount: { value: 400 } }] }, + { id: 'line-2', discounts: [{ code: 'BIG', amount: { value: 400 } }] }, + ], + shippingLines: [], + } as unknown as DraftOrder; +} + +function rate(serviceCode: string, value: number) { + return { + serviceCode, + displayName: serviceCode, + carrierCode: 'carrier', + description: null, + cost: { value, currencyCode: 'USD' }, + }; +} + +describe('ExpressCheckoutButton coupon sync', () => { + beforeEach(() => { + mocks.draftOrder = draftOrder(); + mocks.tokenizeOptions = []; + mocks.handlers = {}; + mocks.getPriceAdjustments.mockReset().mockResolvedValue({ + totalDiscountAmount: { value: 800, currencyCode: 'USD' }, + }); + mocks.getShippingMethods.mockReset(); + mocks.getTaxes.mockReset(); + (window as unknown as { TokenizeJs: unknown }).TokenizeJs = FakeTokenizeJs; + }); + + it('sends the highest-value coupon code to the wallet', async () => { + render(); + + await waitFor(() => expect(mocks.tokenizeOptions).toHaveLength(1)); + expect(mocks.getPriceAdjustments).toHaveBeenCalledWith({ + discountCodes: ['BIG'], + }); + expect(mocks.tokenizeOptions[0].couponCode).toMatchObject({ code: 'BIG' }); + }); + + it('recomputes adjustments when the subtotal changes, not on unrelated order updates', async () => { + const { rerender } = render(); + await waitFor(() => expect(mocks.getPriceAdjustments).toHaveBeenCalled()); + + mocks.draftOrder = draftOrder({ id: 'draft-order-1' }); + rerender(); + await act(() => Promise.resolve()); + expect(mocks.getPriceAdjustments).toHaveBeenCalledTimes(1); + + mocks.draftOrder = draftOrder({ subTotal: 5000 }); + rerender(); + await waitFor(() => + expect(mocks.getPriceAdjustments).toHaveBeenCalledTimes(2) + ); + }); + + it('refreshes the wallet shipping methods when a coupon is removed', async () => { + render(); + await waitFor(() => + expect(mocks.handlers.shipping_address_change).toBeDefined() + ); + + mocks.getShippingMethods.mockResolvedValueOnce([rate('standard', 1000)]); + await act(async () => { + await mocks.handlers.shipping_address_change({ + shippingAddress: { countryCode: 'US', postalCode: '85001' }, + updateWith: vi.fn(), + }); + }); + expect(mocks.getShippingMethods).toHaveBeenCalledTimes(1); + + mocks.getShippingMethods.mockResolvedValueOnce([ + rate('standard', 1000), + rate('express', 2000), + ]); + const updateWith = vi.fn(); + await act(async () => { + await mocks.handlers.coupon_code_change({ couponCode: '', updateWith }); + }); + + expect(mocks.getShippingMethods).toHaveBeenCalledTimes(2); + expect(updateWith).toHaveBeenCalledWith( + expect.objectContaining({ + shippingMethods: [ + expect.objectContaining({ id: 'standard', amount: '10.00' }), + expect.objectContaining({ id: 'express', amount: '20.00' }), + ], + }) + ); + }); +}); diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx index f09a7474..b8da8651 100644 --- a/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx +++ b/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCheckoutContext } from '@/components/checkout/checkout'; -import { getDraftOrderDiscountCodes } from '@/components/checkout/discount/utils/get-draft-order-discount-codes'; +import { getHighestValueDraftOrderDiscountCode } from '@/components/checkout/discount/utils/get-draft-order-discount-codes'; import { useGetPriceAdjustments } from '@/components/checkout/discount/utils/use-get-price-adjustments'; import { useDraftOrder, @@ -304,20 +304,24 @@ export function ExpressCheckoutButton() { >('idle'); const [couponSyncRevision, setCouponSyncRevision] = useState(0); - const draftOrderDiscountCodes = useMemo( - () => getDraftOrderDiscountCodes(draftOrder), + const primaryDiscountCode = useMemo( + () => getHighestValueDraftOrderDiscountCode(draftOrder), [draftOrder] ); - const discountCodesKey = JSON.stringify(draftOrderDiscountCodes); + // Adjustments depend on the subtotal too, e.g. a percentage discount. + const couponSyncKey = JSON.stringify([ + primaryDiscountCode ?? null, + totals?.subTotal?.value ?? null, + ]); const hasDraftOrder = Boolean(draftOrder); const areCouponAdjustmentsReady = - draftOrderDiscountCodes.length === 0 || couponFetchStatus === 'done'; + !primaryDiscountCode || couponFetchStatus === 'done'; useEffect(() => { if (!hasDraftOrder) return; const requestId = ++couponSyncRequestRef.current; - const couponCode = draftOrderDiscountCodes[0]; + const couponCode = primaryDiscountCode; setCouponFetchStatus('fetching'); const syncPriceAdjustments = async () => { @@ -350,7 +354,7 @@ export function ExpressCheckoutButton() { syncPriceAdjustments(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [hasDraftOrder, discountCodesKey, couponSyncRevision]); + }, [hasDraftOrder, couponSyncKey, couponSyncRevision]); // Initialize the TokenizeJs instance when the component mounts // But only after price adjustments have been fetched diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.test.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.test.tsx new file mode 100644 index 00000000..29d50e67 --- /dev/null +++ b/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.test.tsx @@ -0,0 +1,167 @@ +import { enUs } from '@godaddy/localizations'; +import { act, render, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { DraftOrder } from '@/types'; +import { StripeExpressCheckoutForm } from './stripe'; + +const mocks = vi.hoisted(() => ({ + draftOrder: null as unknown, + getPriceAdjustments: vi.fn(), + getShippingMethods: vi.fn(), + elementProps: {} as Record unknown>, +})); + +vi.mock('@stripe/react-stripe-js', () => ({ + ExpressCheckoutElement: ( + props: Record unknown> + ) => { + mocks.elementProps = props; + return null; + }, + useElements: () => ({ update: vi.fn() }), +})); +vi.mock('@/components/checkout/checkout', () => ({ + useCheckoutContext: () => ({ + session: { + id: 'session-1', + enablePromotionCodes: true, + enableShippingAddressCollection: true, + enableTaxCollection: false, + }, + setCheckoutErrors: vi.fn(), + isConfirmingCheckout: false, + }), +})); +vi.mock('@/components/checkout/order/use-draft-order', () => ({ + useDraftOrder: () => ({ data: mocks.draftOrder }), + useDraftOrderTotals: () => ({ + data: (mocks.draftOrder as DraftOrder | null)?.totals, + }), +})); +vi.mock( + '@/components/checkout/discount/utils/use-get-price-adjustments', + () => ({ + useGetPriceAdjustments: () => ({ mutateAsync: mocks.getPriceAdjustments }), + }) +); +vi.mock( + '@/components/checkout/shipping/utils/use-get-shipping-methods', + () => ({ + useGetShippingMethodByAddress: () => ({ + mutateAsync: mocks.getShippingMethods, + }), + }) +); +vi.mock('@/components/checkout/taxes/utils/use-get-taxes', () => ({ + useGetTaxes: () => ({ mutateAsync: vi.fn() }), +})); +vi.mock('@/components/checkout/payment/utils/use-stripe-checkout', () => ({ + useStripeCheckout: () => ({ handleSubmit: vi.fn() }), +})); +vi.mock('@/components/checkout/payment/utils/use-is-payment-disabled', () => ({ + useIsPaymentDisabled: () => false, +})); +vi.mock('@/godaddy-provider', () => ({ + useGoDaddyContext: () => ({ t: enUs, locale: 'en-US' }), +})); +vi.mock('@/tracking/track', async importOriginal => ({ + ...(await importOriginal()), + track: vi.fn(), +})); + +function draftOrder({ + subTotal = 2500, + lineCode = 'BIG', +}: { + subTotal?: number; + lineCode?: string; +} = {}) { + return { + id: 'draft-order-1', + totals: { + subTotal: { value: subTotal, currencyCode: 'USD' }, + total: { value: subTotal, currencyCode: 'USD' }, + }, + discounts: [{ code: 'SAVE5', amount: { value: 500 } }], + lineItems: [ + { id: 'line-1', discounts: [{ code: lineCode, amount: { value: 800 } }] }, + ], + shippingLines: [], + } as unknown as DraftOrder; +} + +describe('StripeExpressCheckoutForm coupon sync', () => { + beforeEach(() => { + mocks.draftOrder = draftOrder(); + mocks.elementProps = {}; + mocks.getPriceAdjustments.mockReset().mockResolvedValue({ + totalDiscountAmount: { value: 800, currencyCode: 'USD' }, + }); + mocks.getShippingMethods.mockReset().mockResolvedValue([ + { + serviceCode: 'standard', + displayName: 'Standard', + carrierCode: 'carrier', + description: null, + cost: { value: 1000, currencyCode: 'USD' }, + }, + ]); + }); + + it('uses the highest-value coupon code', async () => { + render(); + + await waitFor(() => + expect(mocks.getPriceAdjustments).toHaveBeenCalledWith({ + discountCodes: ['BIG'], + }) + ); + }); + + it('recomputes adjustments when the subtotal changes, not on unrelated order updates', async () => { + const { rerender } = render(); + await waitFor(() => expect(mocks.getPriceAdjustments).toHaveBeenCalled()); + + mocks.draftOrder = draftOrder(); + rerender(); + await act(() => Promise.resolve()); + expect(mocks.getPriceAdjustments).toHaveBeenCalledTimes(1); + + mocks.draftOrder = draftOrder({ subTotal: 5000 }); + rerender(); + await waitFor(() => + expect(mocks.getPriceAdjustments).toHaveBeenCalledTimes(2) + ); + }); + + it('does not refetch or reselect shipping when the coupon changes while the sheet is open', async () => { + const { rerender } = render(); + await waitFor(() => expect(mocks.getPriceAdjustments).toHaveBeenCalled()); + + await act(async () => { + await mocks.elementProps.onShippingAddressChange({ + address: { + city: 'Phoenix', + state: 'AZ', + postal_code: '85001', + country: 'US', + }, + resolve: vi.fn(), + reject: vi.fn(), + }); + }); + expect(mocks.getShippingMethods).toHaveBeenCalledTimes(1); + const priceAdjustmentCalls = mocks.getPriceAdjustments.mock.calls.length; + + mocks.draftOrder = draftOrder({ lineCode: 'BIGGER' }); + rerender(); + await waitFor(() => + expect(mocks.getPriceAdjustments).toHaveBeenCalledTimes( + priceAdjustmentCalls + 1 + ) + ); + await act(() => Promise.resolve()); + + expect(mocks.getShippingMethods).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.tsx index 1d5f12ea..a32c8f36 100644 --- a/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.tsx +++ b/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.tsx @@ -10,7 +10,7 @@ import type { } from '@stripe/stripe-js'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCheckoutContext } from '@/components/checkout/checkout'; -import { getDraftOrderDiscountCodes } from '@/components/checkout/discount/utils/get-draft-order-discount-codes'; +import { getHighestValueDraftOrderDiscountCode } from '@/components/checkout/discount/utils/get-draft-order-discount-codes'; import { useGetPriceAdjustments } from '@/components/checkout/discount/utils/use-get-price-adjustments'; import { useDraftOrder, @@ -80,78 +80,50 @@ export function StripeExpressCheckoutForm() { const calculatedAdjustmentsRef = useRef(null); const couponSyncRequestRef = useRef(0); - const draftOrderDiscountCodes = useMemo( - () => getDraftOrderDiscountCodes(draftOrder), + const primaryDiscountCode = useMemo( + () => getHighestValueDraftOrderDiscountCode(draftOrder), [draftOrder] ); - const discountCodesKey = JSON.stringify(draftOrderDiscountCodes); + // Adjustments depend on the subtotal too, e.g. a percentage discount. + const couponSyncKey = JSON.stringify([ + primaryDiscountCode ?? null, + totals?.subTotal?.value ?? null, + ]); const hasDraftOrder = Boolean(draftOrder); useEffect(() => { if (!hasDraftOrder) return; const requestId = ++couponSyncRequestRef.current; - const couponCode = draftOrderDiscountCodes[0]; + const couponCode = primaryDiscountCode; const syncPriceAdjustments = async () => { if (!couponCode) { appliedCouponCodeRef.current = null; calculatedAdjustmentsRef.current = null; - } else { - try { - const result = await getPriceAdjustments.mutateAsync({ - discountCodes: [couponCode], - }); - - if (requestId !== couponSyncRequestRef.current) return; - - appliedCouponCodeRef.current = result ? couponCode : null; - calculatedAdjustmentsRef.current = result ?? null; - } catch { - if (requestId !== couponSyncRequestRef.current) return; - - appliedCouponCodeRef.current = null; - calculatedAdjustmentsRef.current = null; - } + return; } - // Refetch shipping methods so rates reflect the coupon change (add or - // remove) once a shipping address is known. - if (!shippingAddress) return; - try { - const shippingMethodsData = - await getShippingMethodsByAddress.mutateAsync({ - countryCode: shippingAddress.country || 'US', - postalCode: shippingAddress.postal_code || '', - adminArea2: shippingAddress.city || '', - adminArea1: shippingAddress.state || '', - }); + const result = await getPriceAdjustments.mutateAsync({ + discountCodes: [couponCode], + }); if (requestId !== couponSyncRequestRef.current) return; - setShippingMethods(shippingMethodsData || null); - - const defaultMethod = sortShippingMethods(shippingMethodsData || [])[0]; - - if (defaultMethod) { - setSelectedShippingRate({ - id: - defaultMethod.displayName?.replace(/\s+/g, '-')?.toLowerCase() || - 'shipping', - amount: defaultMethod.cost?.value || 0, - displayName: defaultMethod.displayName || t.totals.shipping, - deliveryEstimate: defaultMethod.description || undefined, - }); - } + appliedCouponCodeRef.current = result ? couponCode : null; + calculatedAdjustmentsRef.current = result ?? null; } catch { if (requestId !== couponSyncRequestRef.current) return; + + appliedCouponCodeRef.current = null; + calculatedAdjustmentsRef.current = null; } }; syncPriceAdjustments(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [hasDraftOrder, discountCodesKey]); + }, [hasDraftOrder, couponSyncKey]); // Calculate taxes for express checkout const calculateExpressTaxes = useCallback( diff --git a/packages/react/src/components/checkout/shipping/shipping-method.tsx b/packages/react/src/components/checkout/shipping/shipping-method.tsx index 5d72a27a..da826ef2 100644 --- a/packages/react/src/components/checkout/shipping/shipping-method.tsx +++ b/packages/react/src/components/checkout/shipping/shipping-method.tsx @@ -12,10 +12,7 @@ import { useUpdateTaxes } from '@/components/checkout/order/use-update-taxes'; import { useIsPaymentDisabled } from '@/components/checkout/payment/utils/use-is-payment-disabled'; import { ShippingMethodSkeleton } from '@/components/checkout/shipping/shipping-method-skeleton'; import { buildShippingPayload } from '@/components/checkout/shipping/utils/build-shipping-payload'; -import { - getShippingMethodsKey, - selectShippingMethod, -} from '@/components/checkout/shipping/utils/requires-shipping-reconciliation'; +import { selectShippingMethod } from '@/components/checkout/shipping/utils/requires-shipping-reconciliation'; import { getShippingFulfillmentSyncKey, shouldApplyShippingMethod, @@ -32,6 +29,7 @@ import { useGoDaddyContext } from '@/godaddy-provider'; import { cn } from '@/lib/utils'; import { eventIds } from '@/tracking/events'; import { TrackingEventType, track } from '@/tracking/track'; +import type { ShippingMethod } from '@/types'; export function ShippingMethodForm() { const formatCurrency = useFormatCurrency(); @@ -71,7 +69,7 @@ export function ShippingMethodForm() { useIsMutating({ mutationKey: checkoutMutationKeys.applyDiscount(session?.id), }) > 0; - const lastShippingMethodsKeyRef = useRef(null); + const lastShippingMethodsRef = useRef(null); const wasApplyingDiscountRef = useRef(false); // Track the last processed state to avoid duplicate API calls @@ -95,8 +93,7 @@ export function ShippingMethodForm() { if (isApplyingDiscount) { wasApplyingDiscountRef.current = true; if (!isShippingMethodsFetching) { - lastShippingMethodsKeyRef.current = - getShippingMethodsKey(shippingMethods); + lastShippingMethodsRef.current = shippingMethods; lastProcessedStateRef.current = { ...lastProcessedStateRef.current, serviceCode: shippingLines?.requestedService ?? null, @@ -134,7 +131,7 @@ export function ShippingMethodForm() { // Case 1: No shipping methods available - clear shipping and set fulfillment to SHIP if (!hasShippingMethods && hasShippingAddress) { - lastShippingMethodsKeyRef.current = getShippingMethodsKey([]); + lastShippingMethodsRef.current = []; if (discountJustSettled && !currentServiceCode) { lastProcessedStateRef.current = { @@ -190,14 +187,18 @@ export function ShippingMethodForm() { if (hasShippingMethods) { const currentFormMethod = form.getValues('shippingMethod'); const existingMethod = currentFormMethod || currentServiceCode; - const isInitialSelection = lastShippingMethodsKeyRef.current === null; - const { selectedMethod: methodToApply, methodsKey } = + const isInitialSelection = lastShippingMethodsRef.current === null; + const previousAutoSelected = Boolean( + form.getValues('shippingMethodAutoSelected') + ); + const { selectedMethod: methodToApply, autoSelected } = selectShippingMethod({ shippingMethods, currentServiceCode: existingMethod, - previousMethodsKey: lastShippingMethodsKeyRef.current, + previousShippingMethods: lastShippingMethodsRef.current, + isAutoSelected: previousAutoSelected, }); - lastShippingMethodsKeyRef.current = methodsKey; + lastShippingMethodsRef.current = shippingMethods; if (!methodToApply) return; // Check if we've already processed this exact state. If cart contents @@ -217,6 +218,9 @@ export function ShippingMethodForm() { form.setValue('shippingMethod', methodToApply.serviceCode, { shouldDirty: false, }); + form.setValue('shippingMethodAutoSelected', autoSelected, { + shouldDirty: false, + }); if (needsMutation) { const isFulfillmentSync = Boolean( @@ -237,6 +241,13 @@ export function ShippingMethodForm() { form.setValue('shippingMethod', previousShippingMethod, { shouldDirty: false, }); + form.setValue( + 'shippingMethodAutoSelected', + previousAutoSelected, + { + shouldDirty: false, + } + ); }, }); } else if (session?.enableTaxCollection && isInitialSelection) { @@ -339,11 +350,13 @@ export function ShippingMethodForm() { } const previousValue = form.getValues('shippingMethod'); + const previousAutoSelected = form.getValues('shippingMethodAutoSelected'); const previousProcessedState = lastProcessedStateRef.current; form.setValue('shippingMethod', value, { shouldDirty: true, shouldValidate: true, }); + form.setValue('shippingMethodAutoSelected', false); const method = shippingMethods?.find(m => m.serviceCode === value); @@ -378,6 +391,7 @@ export function ShippingMethodForm() { .mutateAsync(buildShippingPayload(method)) .catch(() => { form.setValue('shippingMethod', previousValue); + form.setValue('shippingMethodAutoSelected', previousAutoSelected); lastProcessedStateRef.current = previousProcessedState; }); } diff --git a/packages/react/src/components/checkout/shipping/utils/requires-shipping-reconciliation.test.ts b/packages/react/src/components/checkout/shipping/utils/requires-shipping-reconciliation.test.ts index ab8764f6..bcc715ed 100644 --- a/packages/react/src/components/checkout/shipping/utils/requires-shipping-reconciliation.test.ts +++ b/packages/react/src/components/checkout/shipping/utils/requires-shipping-reconciliation.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest'; import type { ShippingLines, ShippingMethod } from '@/types'; -import { requiresShippingReconciliation } from './requires-shipping-reconciliation'; +import { + requiresShippingReconciliation, + selectShippingMethod, +} from './requires-shipping-reconciliation'; function shippingMethod(serviceCode: string, cost: number): ShippingMethod { return { @@ -106,3 +109,84 @@ describe('requiresShippingReconciliation', () => { ).toBe(false); }); }); + +describe('selectShippingMethod', () => { + const standard = shippingMethod('standard', 500); + const express = shippingMethod('express', 2000); + + it('keeps an offered method on first load even when it is not the cheapest', () => { + expect( + selectShippingMethod({ + shippingMethods: [standard, express], + currentServiceCode: 'express', + previousShippingMethods: null, + }) + ).toMatchObject({ selectedMethod: express, autoSelected: false }); + }); + + it("keeps the customer's choice when rates are repriced", () => { + expect( + selectShippingMethod({ + shippingMethods: [ + shippingMethod('standard', 600), + shippingMethod('express', 2100), + ], + currentServiceCode: 'express', + previousShippingMethods: [standard, express], + isAutoSelected: false, + }) + ).toMatchObject({ + selectedMethod: { serviceCode: 'express' }, + autoSelected: false, + }); + }); + + it('moves an automatic selection to the cheapest method when rates change', () => { + expect( + selectShippingMethod({ + shippingMethods: [shippingMethod('standard', 2500), express], + currentServiceCode: 'standard', + previousShippingMethods: [standard, express], + isAutoSelected: true, + }) + ).toMatchObject({ + selectedMethod: { serviceCode: 'express' }, + autoSelected: true, + }); + }); + + it('keeps an automatic selection while the rates are unchanged', () => { + expect( + selectShippingMethod({ + shippingMethods: [standard, express], + currentServiceCode: 'express', + previousShippingMethods: [standard, express], + isAutoSelected: true, + }) + ).toMatchObject({ selectedMethod: express, autoSelected: true }); + }); + + it("switches a customer's choice to free shipping when it newly appears", () => { + const free = shippingMethod('free', 0); + + expect( + selectShippingMethod({ + shippingMethods: [standard, express, free], + currentServiceCode: 'express', + previousShippingMethods: [standard, express], + isAutoSelected: false, + }) + ).toMatchObject({ selectedMethod: free, autoSelected: true }); + }); + + it('falls back to the cheapest method when the current one is gone', () => { + expect( + selectShippingMethod({ + shippingMethods: [standard], + currentServiceCode: 'express', + previousShippingMethods: [standard, express], + isAutoSelected: false, + }) + ).toMatchObject({ selectedMethod: standard, autoSelected: true }); + }); +}); diff --git a/packages/react/src/components/checkout/shipping/utils/requires-shipping-reconciliation.ts b/packages/react/src/components/checkout/shipping/utils/requires-shipping-reconciliation.ts index a68ae6a7..08ae06a3 100644 --- a/packages/react/src/components/checkout/shipping/utils/requires-shipping-reconciliation.ts +++ b/packages/react/src/components/checkout/shipping/utils/requires-shipping-reconciliation.ts @@ -4,7 +4,9 @@ import { sortShippingMethods } from './sort-shipping-methods'; interface SelectShippingMethodParams { shippingMethods: ShippingMethod[]; currentServiceCode?: string | null; - previousMethodsKey?: string | null; + // null means there is no earlier rate set to compare against (first load). + previousShippingMethods?: ShippingMethod[] | null; + isAutoSelected?: boolean; } interface RequiresShippingReconciliationParams { @@ -12,6 +14,11 @@ interface RequiresShippingReconciliationParams { previousShippingMethods?: ShippingMethod[]; currentShippingLine?: ShippingLines | null; selectedServiceCode?: string | null; + isAutoSelected?: boolean; +} + +function isFreeShippingMethod(method: ShippingMethod) { + return method.cost?.value === 0; } export function getShippingMethodsKey(shippingMethods: ShippingMethod[]) { @@ -27,18 +34,40 @@ export function getShippingMethodsKey(shippingMethods: ShippingMethod[]) { export function selectShippingMethod({ shippingMethods, currentServiceCode, - previousMethodsKey, + previousShippingMethods = null, + isAutoSelected = false, }: SelectShippingMethodParams) { const availableMethods = sortShippingMethods(shippingMethods); const methodsKey = getShippingMethodsKey(availableMethods); - const methodsChanged = methodsKey !== previousMethodsKey; - const selectedMethod = methodsChanged - ? availableMethods[0] - : availableMethods.find( - method => method.serviceCode === currentServiceCode - ) || availableMethods[0]; - - return { selectedMethod, methodsKey }; + const cheapestMethod = availableMethods[0]; + const currentMethod = currentServiceCode + ? availableMethods.find(method => method.serviceCode === currentServiceCode) + : undefined; + + if (!currentMethod) { + return { selectedMethod: cheapestMethod, methodsKey, autoSelected: true }; + } + + if (previousShippingMethods) { + const freeShippingNewlyAvailable = + !previousShippingMethods.some(isFreeShippingMethod) && + availableMethods.some(isFreeShippingMethod); + const methodsChanged = + methodsKey !== getShippingMethodsKey(previousShippingMethods); + + if ( + (freeShippingNewlyAvailable && !isFreeShippingMethod(currentMethod)) || + (isAutoSelected && methodsChanged) + ) { + return { selectedMethod: cheapestMethod, methodsKey, autoSelected: true }; + } + } + + return { + selectedMethod: currentMethod, + methodsKey, + autoSelected: isAutoSelected, + }; } export function requiresShippingReconciliation({ @@ -46,13 +75,15 @@ export function requiresShippingReconciliation({ previousShippingMethods = [], currentShippingLine, selectedServiceCode, + isAutoSelected, }: RequiresShippingReconciliationParams) { const currentServiceCode = selectedServiceCode || currentShippingLine?.requestedService; const { selectedMethod } = selectShippingMethod({ shippingMethods, currentServiceCode, - previousMethodsKey: getShippingMethodsKey(previousShippingMethods), + previousShippingMethods, + isAutoSelected, }); return selectedMethod diff --git a/packages/react/src/components/checkout/shipping/utils/use-apply-shipping-method.ts b/packages/react/src/components/checkout/shipping/utils/use-apply-shipping-method.ts index 896f6ae2..2b50fab7 100644 --- a/packages/react/src/components/checkout/shipping/utils/use-apply-shipping-method.ts +++ b/packages/react/src/components/checkout/shipping/utils/use-apply-shipping-method.ts @@ -1,5 +1,6 @@ import { useQueryClient } from '@tanstack/react-query'; import { useCheckoutContext } from '@/components/checkout/checkout'; +import { getDraftOrderDiscountCodes } from '@/components/checkout/discount/utils/get-draft-order-discount-codes'; import { useApplyDiscountCore } from '@/components/checkout/discount/utils/use-apply-discount-core'; import { useDraftOrder } from '@/components/checkout/order/use-draft-order'; import { useUpdateTaxes } from '@/components/checkout/order/use-update-taxes'; @@ -19,29 +20,9 @@ export function useApplyShippingMethod() { setCheckoutErrors(undefined); if (!session) return; - const allCodes = new Set(); - - if (order?.discounts) { - for (const discount of order.discounts) { - if (discount.code) { - allCodes.add(discount.code); - } - } - } - - if (order?.shippingLines) { - for (const shippingLine of order.shippingLines) { - if (shippingLine.discounts) { - for (const discount of shippingLine.discounts) { - if (discount.code) { - allCodes.add(discount.code); - } - } - } - } - } - - const discountCodes = Array.from(allCodes); + // Applying discounts replaces the order's full code list, so include + // line-item codes too or they are dropped. + const discountCodes = getDraftOrderDiscountCodes(order); if (session.enablePromotionCodes && discountCodes.length) { await applyDiscount.mutateAsync({ discountCodes }); diff --git a/packages/react/src/components/checkout/shipping/utils/use-draft-order-shipping-methods.ts b/packages/react/src/components/checkout/shipping/utils/use-draft-order-shipping-methods.ts index 89c191cc..90fd2424 100644 --- a/packages/react/src/components/checkout/shipping/utils/use-draft-order-shipping-methods.ts +++ b/packages/react/src/components/checkout/shipping/utils/use-draft-order-shipping-methods.ts @@ -78,6 +78,8 @@ export function useDraftOrderShippingMethods() { return result; }, enabled: !!session?.id && hasShippingAddress, + // The failure UI offers a manual retry; don't stack automatic retries on it. + retry: false, select: data => data?.checkoutSession?.draftOrder?.calculatedShippingRates?.rates, }); diff --git a/packages/react/src/components/checkout/shipping/utils/use-remove-shipping-method.ts b/packages/react/src/components/checkout/shipping/utils/use-remove-shipping-method.ts index cf1c48d2..1b4807d6 100644 --- a/packages/react/src/components/checkout/shipping/utils/use-remove-shipping-method.ts +++ b/packages/react/src/components/checkout/shipping/utils/use-remove-shipping-method.ts @@ -1,7 +1,8 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import type { ResultOf } from 'gql.tada'; import { useCheckoutContext } from '@/components/checkout/checkout'; -import { useDiscountApply } from '@/components/checkout/discount'; +import { getDraftOrderDiscountCodes } from '@/components/checkout/discount/utils/get-draft-order-discount-codes'; +import { useApplyDiscountCore } from '@/components/checkout/discount/utils/use-apply-discount-core'; import { useDraftOrder } from '@/components/checkout/order/use-draft-order'; import { checkoutMutationKeys, @@ -17,7 +18,9 @@ export function useRemoveShippingMethod() { const { apiHost } = useGoDaddyContext(); const queryClient = useQueryClient(); const { data: order } = useDraftOrder(); - const applyDiscount = useDiscountApply(); + // The core mutation skips shipping reconciliation, which could otherwise + // re-apply a shipping method right after it is removed. + const applyDiscount = useApplyDiscountCore(); return useMutation({ mutationKey: checkoutMutationKeys.removeShippingMethod(session?.id), @@ -63,55 +66,15 @@ export function useRemoveShippingMethod() { ); } - const allCodes = new Set(); + const discountCodes = getDraftOrderDiscountCodes(order); - // Add order-level discount codes - if (order?.discounts) { - for (const discount of order.discounts) { - if (discount.code) { - allCodes.add(discount.code); - } - } + if (session.enablePromotionCodes && discountCodes.length) { + await applyDiscount.mutateAsync({ discountCodes }); } - // Add line item-level discount codes - if (order?.lineItems) { - for (const lineItem of order.lineItems) { - if (lineItem?.discounts) { - for (const discount of lineItem.discounts) { - if (discount.code) { - allCodes.add(discount.code); - } - } - } - } - } - - // Add shipping line-level discount codes - if (order?.shippingLines) { - for (const shippingLine of order.shippingLines) { - if (shippingLine.discounts) { - for (const discount of shippingLine.discounts) { - if (discount.code) { - allCodes.add(discount.code); - } - } - } - } - } - - const discountCodes = Array.from(allCodes); - - if (session?.enablePromotionCodes && discountCodes?.length) { - /* should re-apply discounts if they were previously applied */ - await applyDiscount.mutateAsync({ - discountCodes, - }); - } else { - await queryClient.invalidateQueries({ - queryKey: checkoutQueryKeys.draftOrder(session.id), - }); - } + await queryClient.invalidateQueries({ + queryKey: checkoutQueryKeys.draftOrder(session.id), + }); }, }); } diff --git a/packages/react/src/components/checkout/utils/checkout-transformers.ts b/packages/react/src/components/checkout/utils/checkout-transformers.ts index 7b51b3ca..1ee398ba 100644 --- a/packages/react/src/components/checkout/utils/checkout-transformers.ts +++ b/packages/react/src/components/checkout/utils/checkout-transformers.ts @@ -228,6 +228,7 @@ export function mapOrderToFormValues({ // shippingMethod shippingMethod: order?.shippingLines?.[0]?.requestedService ?? '', + shippingMethodAutoSelected: false, }; }