From 6da79fbbc1b2108132a76c9f49342c6ac81ce68b Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Wed, 26 Aug 2026 11:43:18 -0500 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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;