diff --git a/.changeset/calm-coupons-ship.md b/.changeset/calm-coupons-ship.md
new file mode 100644
index 000000000..b7f1ca415
--- /dev/null
+++ b/.changeset/calm-coupons-ship.md
@@ -0,0 +1,6 @@
+---
+"@godaddy/react": patch
+"@godaddy/localizations": patch
+---
+
+Keep shipping rates, discounts, and taxes in sync when coupons change, and show a retry action when shipping rates fail to load.
diff --git a/packages/localizations/src/deDe.ts b/packages/localizations/src/deDe.ts
index 33dfc6223..ed07fbdea 100644
--- a/packages/localizations/src/deDe.ts
+++ b/packages/localizations/src/deDe.ts
@@ -70,6 +70,9 @@ export const deDe = {
noShippingMethodAddress:
'Geben Sie Ihre Adresse ein, um verfügbare Versandmethoden zu sehen.',
noShippingMethods: 'Keine Versandmethoden gefunden.',
+ failedToLoadMethods:
+ 'Versandarten konnten nicht geladen werden. Bitte versuchen Sie es erneut.',
+ retryMethods: 'Erneut versuchen',
phone: 'Telefonnummer',
country: 'Land',
selectCountry: 'Land auswählen',
@@ -122,7 +125,7 @@ export const deDe = {
paze: '',
offline: '',
mercadopago:
- 'Verwende das MercadoPago-Formular unten, um deinen Kauf sicher abzuschließen.',
+ 'Verwenden Sie das MercadoPago-Formular unten, um Ihren Kauf sicher abzuschließen.',
ach: '',
ccavenue: '',
},
diff --git a/packages/localizations/src/enAu.ts b/packages/localizations/src/enAu.ts
index 598157713..39d3bbd3a 100644
--- a/packages/localizations/src/enAu.ts
+++ b/packages/localizations/src/enAu.ts
@@ -70,6 +70,8 @@ export const enAu = {
noShippingMethodAddress:
'Enter your address to see available shipping methods.',
noShippingMethods: 'No shipping methods found.',
+ failedToLoadMethods: 'Unable to load shipping methods. Please try again.',
+ retryMethods: 'Try again',
phone: 'Phone Number',
country: 'Country',
selectCountry: 'Select country',
diff --git a/packages/localizations/src/enIe.ts b/packages/localizations/src/enIe.ts
index 894703013..2cb6f8d5e 100644
--- a/packages/localizations/src/enIe.ts
+++ b/packages/localizations/src/enIe.ts
@@ -70,6 +70,8 @@ export const enIe = {
noShippingMethodAddress:
'Enter your address to see available shipping methods.',
noShippingMethods: 'No shipping methods found.',
+ failedToLoadMethods: 'Unable to load shipping methods. Please try again.',
+ retryMethods: 'Try again',
phone: 'Phone Number',
country: 'Country',
selectCountry: 'Select country',
diff --git a/packages/localizations/src/enUs.ts b/packages/localizations/src/enUs.ts
index 95a556dac..9a1852f4f 100644
--- a/packages/localizations/src/enUs.ts
+++ b/packages/localizations/src/enUs.ts
@@ -70,6 +70,8 @@ export const enUs = {
noShippingMethodAddress:
'Enter your address to see available shipping methods.',
noShippingMethods: 'No shipping methods found.',
+ failedToLoadMethods: 'Unable to load shipping methods. Please try again.',
+ retryMethods: 'Try again',
phone: 'Phone Number',
country: 'Country',
selectCountry: 'Select country',
diff --git a/packages/localizations/src/esAr.ts b/packages/localizations/src/esAr.ts
index 8128fee07..0eebdfc49 100644
--- a/packages/localizations/src/esAr.ts
+++ b/packages/localizations/src/esAr.ts
@@ -70,6 +70,9 @@ export const esAr = {
noShippingMethodAddress:
'Ingresá tu dirección para ver los métodos de envío disponibles.',
noShippingMethods: 'No se encontraron métodos de envío.',
+ failedToLoadMethods:
+ 'No se pudieron cargar los métodos de envío. Inténtalo de nuevo.',
+ retryMethods: 'Intentar de nuevo',
phone: 'Número de Teléfono',
country: 'País',
selectCountry: 'Seleccionar país',
diff --git a/packages/localizations/src/esCl.ts b/packages/localizations/src/esCl.ts
index ccb43c9a2..81446be8e 100644
--- a/packages/localizations/src/esCl.ts
+++ b/packages/localizations/src/esCl.ts
@@ -70,6 +70,9 @@ export const esCl = {
noShippingMethodAddress:
'Ingresa tu dirección para ver los métodos de envío disponibles.',
noShippingMethods: 'No se encontraron métodos de envío.',
+ failedToLoadMethods:
+ 'No se pudieron cargar los métodos de envío. Inténtalo de nuevo.',
+ retryMethods: 'Intentar de nuevo',
phone: 'Número de Teléfono',
country: 'País',
selectCountry: 'Seleccionar país',
diff --git a/packages/localizations/src/esCo.ts b/packages/localizations/src/esCo.ts
index b80b2f9ce..31ea0b864 100644
--- a/packages/localizations/src/esCo.ts
+++ b/packages/localizations/src/esCo.ts
@@ -70,6 +70,9 @@ export const esCo = {
noShippingMethodAddress:
'Ingresa tu dirección para ver los métodos de envío disponibles.',
noShippingMethods: 'No se encontraron métodos de envío.',
+ failedToLoadMethods:
+ 'No se pudieron cargar los métodos de envío. Inténtalo de nuevo.',
+ retryMethods: 'Intentar de nuevo',
phone: 'Número de teléfono',
country: 'País',
selectCountry: 'Seleccionar país',
diff --git a/packages/localizations/src/esEs.ts b/packages/localizations/src/esEs.ts
index 013cf3a60..761678444 100644
--- a/packages/localizations/src/esEs.ts
+++ b/packages/localizations/src/esEs.ts
@@ -70,6 +70,9 @@ export const esEs = {
noShippingMethodAddress:
'Introduce tu dirección para ver los métodos de envío disponibles.',
noShippingMethods: 'No se encontraron métodos de envío.',
+ failedToLoadMethods:
+ 'No se pudieron cargar los métodos de envío. Inténtalo de nuevo.',
+ retryMethods: 'Intentar de nuevo',
phone: 'Número de teléfono',
country: 'País',
selectCountry: 'Seleccionar país',
diff --git a/packages/localizations/src/esMx.ts b/packages/localizations/src/esMx.ts
index 171dc3c84..a0907b117 100644
--- a/packages/localizations/src/esMx.ts
+++ b/packages/localizations/src/esMx.ts
@@ -70,6 +70,9 @@ export const esMx = {
noShippingMethodAddress:
'Ingrese su dirección para ver los métodos de envío disponibles.',
noShippingMethods: 'No se encontraron métodos de envío.',
+ failedToLoadMethods:
+ 'No se pudieron cargar los métodos de envío. Inténtalo de nuevo.',
+ retryMethods: 'Intentar de nuevo',
phone: 'Número de Teléfono',
country: 'País',
selectCountry: 'Seleccionar país',
diff --git a/packages/localizations/src/esPe.ts b/packages/localizations/src/esPe.ts
index 3d1f4f36e..ea086c418 100644
--- a/packages/localizations/src/esPe.ts
+++ b/packages/localizations/src/esPe.ts
@@ -70,6 +70,9 @@ export const esPe = {
noShippingMethodAddress:
'Ingrese su dirección para ver los métodos de envío disponibles.',
noShippingMethods: 'No se encontraron métodos de envío.',
+ failedToLoadMethods:
+ 'No se pudieron cargar los métodos de envío. Inténtalo de nuevo.',
+ retryMethods: 'Intentar de nuevo',
phone: 'Número de Teléfono',
country: 'País',
selectCountry: 'Seleccionar país',
diff --git a/packages/localizations/src/esUs.ts b/packages/localizations/src/esUs.ts
index fc77429b7..5be7c6a48 100644
--- a/packages/localizations/src/esUs.ts
+++ b/packages/localizations/src/esUs.ts
@@ -70,6 +70,9 @@ export const esUs = {
noShippingMethodAddress:
'Ingrese su dirección para ver los métodos de envío disponibles.',
noShippingMethods: 'No se encontraron métodos de envío.',
+ failedToLoadMethods:
+ 'No se pudieron cargar los métodos de envío. Inténtalo de nuevo.',
+ retryMethods: 'Intentar de nuevo',
phone: 'Número de Teléfono',
country: 'País',
selectCountry: 'Seleccionar país',
diff --git a/packages/localizations/src/frCa.ts b/packages/localizations/src/frCa.ts
index 4f4eed863..e65d2dcc5 100644
--- a/packages/localizations/src/frCa.ts
+++ b/packages/localizations/src/frCa.ts
@@ -70,6 +70,9 @@ export const frCa = {
noShippingMethodAddress:
"Entrez votre adresse pour voir les méthodes d'expédition disponibles.",
noShippingMethods: "Aucune méthode d'expédition trouvée.",
+ failedToLoadMethods:
+ 'Impossible de charger les modes de livraison. Veuillez réessayer.',
+ retryMethods: 'Réessayer',
phone: 'Numéro de téléphone',
country: 'Pays',
selectCountry: 'Sélectionner un pays',
diff --git a/packages/localizations/src/frFr.ts b/packages/localizations/src/frFr.ts
index 09d4c91a9..e786253cf 100644
--- a/packages/localizations/src/frFr.ts
+++ b/packages/localizations/src/frFr.ts
@@ -70,6 +70,9 @@ export const frFr = {
noShippingMethodAddress:
'Entrez votre adresse pour voir les méthodes de livraison disponibles.',
noShippingMethods: "Aucune méthode d'expédition trouvée.",
+ failedToLoadMethods:
+ 'Impossible de charger les modes de livraison. Veuillez réessayer.',
+ retryMethods: 'Réessayer',
phone: 'Numéro de téléphone',
country: 'Pays',
selectCountry: 'Sélectionner un pays',
diff --git a/packages/localizations/src/idId.ts b/packages/localizations/src/idId.ts
index 4bdc412ef..be48d21a2 100644
--- a/packages/localizations/src/idId.ts
+++ b/packages/localizations/src/idId.ts
@@ -70,6 +70,9 @@ export const idId = {
noShippingMethodAddress:
'Masukkan alamat Anda untuk melihat metode pengiriman yang tersedia.',
noShippingMethods: 'Tidak ada metode pengiriman ditemukan.',
+ failedToLoadMethods:
+ 'Tidak dapat memuat metode pengiriman. Silakan coba lagi.',
+ retryMethods: 'Coba lagi',
phone: 'Nomor Telepon',
country: 'Negara',
selectCountry: 'Pilih negara',
diff --git a/packages/localizations/src/itIt.ts b/packages/localizations/src/itIt.ts
index de2515f50..e11df876e 100644
--- a/packages/localizations/src/itIt.ts
+++ b/packages/localizations/src/itIt.ts
@@ -70,6 +70,9 @@ export const itIt = {
noShippingMethodAddress:
'Inserisci il tuo indirizzo per vedere i metodi di spedizione disponibili.',
noShippingMethods: 'Nessun metodo di spedizione trovato.',
+ failedToLoadMethods:
+ 'Impossibile caricare i metodi di spedizione. Riprova.',
+ retryMethods: 'Riprova',
phone: 'Numero di Telefono',
country: 'Paese',
selectCountry: 'Seleziona paese',
diff --git a/packages/localizations/src/ptBr.ts b/packages/localizations/src/ptBr.ts
index d47a9bba4..f08562798 100644
--- a/packages/localizations/src/ptBr.ts
+++ b/packages/localizations/src/ptBr.ts
@@ -70,6 +70,9 @@ export const ptBr = {
noShippingMethodAddress:
'Digite seu endereço para ver os métodos de envio disponíveis.',
noShippingMethods: 'Nenhum método de envio encontrado.',
+ failedToLoadMethods:
+ 'Não foi possível carregar as formas de envio. Tente novamente.',
+ retryMethods: 'Tentar novamente',
phone: 'Número de Telefone',
country: 'País',
selectCountry: 'Selecionar país',
diff --git a/packages/localizations/src/qaPs.ts b/packages/localizations/src/qaPs.ts
index 208d5935b..7c97ec6e2 100644
--- a/packages/localizations/src/qaPs.ts
+++ b/packages/localizations/src/qaPs.ts
@@ -70,6 +70,8 @@ export const qaPs = {
noShippingMethodAddress:
'[Ëñťër ÿöür âddrëšš ťö šëë âvâîlâblë šhîþþîñg mëťhödš fôr ÿöür löçâťîöñ.]',
noShippingMethods: '[Ñö šhîþþîñg mëťhödš föüñd fôr ţhîš löçâţîöñ.]',
+ failedToLoadMethods: '[Üñâblë ťö löâd šhîþþîñg mëťhödš. Þléâšé ťrÿ âgâîñ.]',
+ retryMethods: '[Ťrÿ âgâîñ]',
phone: '[Þhöñë Ñümkër Îñförmâťîöñ]',
country: '[Çöüñţrÿ Šëlëçťîöñ]',
selectCountry: '[Šëlëçť çöüñţrÿ frôm lîšť]',
diff --git a/packages/localizations/src/trTr.ts b/packages/localizations/src/trTr.ts
index daa63b5e1..4ea7f7d02 100644
--- a/packages/localizations/src/trTr.ts
+++ b/packages/localizations/src/trTr.ts
@@ -70,6 +70,9 @@ export const trTr = {
noShippingMethodAddress:
'Mevcut kargo yöntemlerini görmek için adresinizi girin.',
noShippingMethods: 'Kargo yöntemi bulunamadı.',
+ failedToLoadMethods:
+ 'Gönderim yöntemleri yüklenemedi. Lütfen tekrar deneyin.',
+ retryMethods: 'Tekrar dene',
phone: 'Telefon Numarası',
country: 'Ülke',
selectCountry: 'Ülke seçin',
diff --git a/packages/localizations/src/viVn.ts b/packages/localizations/src/viVn.ts
index 37ff3b91f..5ae729230 100644
--- a/packages/localizations/src/viVn.ts
+++ b/packages/localizations/src/viVn.ts
@@ -70,6 +70,9 @@ export const viVn = {
noShippingMethodAddress:
'Nhập địa chỉ của bạn để xem các phương thức vận chuyển có sẵn.',
noShippingMethods: 'Không tìm thấy phương thức vận chuyển nào.',
+ failedToLoadMethods:
+ 'Không thể tải phương thức vận chuyển. Vui lòng thử lại.',
+ retryMethods: 'Thử lại',
phone: 'Số điện thoại',
country: 'Quốc gia',
selectCountry: 'Chọn quốc gia',
diff --git a/packages/localizations/src/zhCn.ts b/packages/localizations/src/zhCn.ts
index 19eb5bf9d..4dbb9c3ec 100644
--- a/packages/localizations/src/zhCn.ts
+++ b/packages/localizations/src/zhCn.ts
@@ -66,6 +66,8 @@ export const zhCn = {
method: '配送方式',
noShippingMethodAddress: '请输入您的地址以查看可用的配送方式。',
noShippingMethods: '未找到配送方式。',
+ failedToLoadMethods: '无法加载配送方式。请重试。',
+ retryMethods: '重试',
phone: '手机号码',
country: '国家',
selectCountry: '选择国家',
diff --git a/packages/localizations/src/zhSg.ts b/packages/localizations/src/zhSg.ts
index 52f1938b2..a480ebecb 100644
--- a/packages/localizations/src/zhSg.ts
+++ b/packages/localizations/src/zhSg.ts
@@ -66,6 +66,8 @@ export const zhSg = {
method: '运送方式',
noShippingMethodAddress: '输入您的地址以查看可用的运送方式。',
noShippingMethods: '未找到运送方式。',
+ failedToLoadMethods: '无法加载配送方式。请重试。',
+ retryMethods: '重试',
phone: '电话号码',
country: '国家',
selectCountry: '选择国家',
diff --git a/packages/react/src/components/checkout/__tests__/checkout-confirm-errors.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-confirm-errors.test.tsx
index 1724ab89b..e6c573677 100644
--- a/packages/react/src/components/checkout/__tests__/checkout-confirm-errors.test.tsx
+++ b/packages/react/src/components/checkout/__tests__/checkout-confirm-errors.test.tsx
@@ -1,4 +1,4 @@
-import { fireEvent, screen, waitFor } from '@testing-library/react';
+import { act, fireEvent, screen, waitFor } from '@testing-library/react';
import { useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { describe, expect, it } from 'vitest';
@@ -9,6 +9,7 @@ import {
PaymentProvider,
useConfirmCheckout,
} from '@/components/checkout/payment/utils/use-confirm-checkout';
+import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys';
import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors';
import {
buildCheckoutSession,
@@ -508,7 +509,7 @@ describe('Checkout confirm errors', () => {
const draftOrder = buildDraftOrder({ shippingLines: [] });
const session = buildCheckoutSession({ draftOrder });
- const { user } = renderCheckout({
+ const { user, queryClient } = renderCheckout({
session,
draftOrder,
checkoutProps: {
@@ -521,6 +522,20 @@ describe('Checkout confirm errors', () => {
});
await waitForCheckoutReady();
clearOperations();
+ setApiError('getDraftOrderShippingMethods', 'rates failed');
+ await act(async () => {
+ await queryClient.refetchQueries({
+ queryKey: checkoutQueryKeys.draftOrderShippingMethods(session.id),
+ });
+ });
+ expect(
+ queryClient
+ .getQueryCache()
+ .findAll({
+ queryKey: checkoutQueryKeys.draftOrderShippingMethods(session.id),
+ })
+ .some(query => query.state.status === 'error')
+ ).toBe(true);
await user.click(
await screen.findByRole('button', { name: /express confirm seam/i })
diff --git a/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx
index b49196d8b..b5a7c4f43 100644
--- a/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx
+++ b/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx
@@ -1,18 +1,42 @@
import { enUs } from '@godaddy/localizations';
-import { fireEvent, screen, waitFor } from '@testing-library/react';
-import { describe, expect, it } from 'vitest';
+import { act, fireEvent, screen, waitFor } from '@testing-library/react';
+import { useFormContext } from 'react-hook-form';
+import { describe, expect, it, vi } from 'vitest';
+import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys';
+import {
+ applyShippingMethod,
+ getDraftOrderShippingMethods,
+} from '@/lib/godaddy/godaddy';
import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors';
+import { CheckoutType, PaymentProvider } from '@/types';
import {
buildBillingAddress,
+ buildShippingRates,
+ clearApiError,
clearOperations,
flushPromises,
getOperations,
renderCheckout,
setApiError,
+ setApiErrorOnce,
+ setShippingMethods,
waitForCheckoutReady,
waitForOperation,
} from './checkout-test-env';
+function AutoSelectedProbe() {
+ const form = useFormContext();
+ return (
+
+ {String(form.watch('shippingMethodAutoSelected'))}
+
+ );
+}
+
+const autoSelectedProbe = {
+ targets: { 'checkout.form.payment.after': () => },
+};
+
async function applyCoupon(
user: ReturnType,
code: string
@@ -148,6 +172,1260 @@ 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('does not refetch shipping methods when the product discount is unchanged', async () => {
+ const { user } = renderCheckout({
+ sessionOverrides: { enableTaxCollection: false },
+ });
+ await waitForCheckoutReady();
+ clearOperations();
+
+ // A shipping-only code changes the shipping line's discount, not the product
+ // discount, so the rates cannot change.
+ await applyCoupon(user, 'freeship');
+ await waitForOperation('ApplyCheckoutSessionDiscount');
+ await waitForOperation('DraftOrder');
+ await flushPromises();
+
+ expect(getOperations('DraftOrderShippingRates')).toHaveLength(0);
+ });
+
+ it('refetches shipping methods on a coupon change after a rates failure even when the product discount is unchanged', async () => {
+ const { user, queryClient, session } = renderCheckout({
+ sessionOverrides: { enableTaxCollection: false },
+ });
+ await waitForCheckoutReady();
+ setApiError('getDraftOrderShippingMethods', 'rates failed');
+ await act(async () => {
+ await queryClient.refetchQueries({
+ queryKey: checkoutQueryKeys.draftOrderShippingMethods(session.id),
+ });
+ });
+ await screen.findByRole('button', { name: enUs.shipping.retryMethods });
+ clearApiError('getDraftOrderShippingMethods');
+ clearOperations();
+
+ await applyCoupon(user, 'freeship');
+ await waitForOperation('DraftOrderShippingRates');
+
+ expect(getOperations('DraftOrderShippingRates')).toHaveLength(1);
+ expect(
+ screen.queryByRole('button', { name: enUs.shipping.retryMethods })
+ ).not.toBeInTheDocument();
+ });
+
+ 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('keeps the coupon and reconciles taxes when the post-shipping discount reapply 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();
+ setShippingMethods(
+ buildShippingRates([
+ {
+ serviceCode: 'standard',
+ carrierCode: 'carrier',
+ displayName: 'Standard',
+ cost: { value: 0, currencyCode: 'USD' },
+ },
+ ])
+ );
+ // Fail only the discount reapply that follows the shipping reapply.
+ const applyShippingMock = vi.mocked(applyShippingMethod);
+ const applyShippingImpl = applyShippingMock.getMockImplementation();
+ applyShippingMock.mockImplementationOnce(async (...args) => {
+ const result = await applyShippingImpl?.(...args);
+ setApiErrorOnce('applyDiscount', 'reapply failed');
+ return result as Awaited>;
+ });
+
+ await applyCoupon(user, 'onedollar');
+
+ await waitFor(() => {
+ expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1);
+ });
+ await flushPromises();
+
+ const operations = getOperations().map(operation => operation.op);
+ expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength(1);
+ expect(getOperations('ApplyCheckoutSessionDiscount')).toHaveLength(2);
+ expect(operations.indexOf('CalculateCheckoutSessionTaxes')).toBeGreaterThan(
+ operations.lastIndexOf('ApplyCheckoutSessionDiscount')
+ );
+ // The original apply succeeded and applying shipping keeps product
+ // (order/line-item) discounts server-side, so the coupon stays applied.
+ expect(
+ screen.getAllByRole('button', { name: /remove onedollar/i }).length
+ ).toBeGreaterThan(0);
+ await screen.findByText(enUs.apiErrors.DISCOUNT_APPLICATION_FAILED);
+ });
+
+ it('drops a shipping-line coupon when the post-shipping discount reapply 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' },
+ discounts: [
+ {
+ id: 'freeship-discount',
+ name: 'freeship',
+ code: 'freeship',
+ amount: { value: 1000, currencyCode: 'USD' },
+ ratePercentage: null,
+ appliedBeforeTax: false,
+ metafields: [],
+ },
+ ],
+ },
+ ],
+ },
+ });
+ await waitForCheckoutReady();
+ clearOperations();
+ setShippingMethods(
+ buildShippingRates([
+ {
+ serviceCode: 'standard',
+ carrierCode: 'carrier',
+ displayName: 'Standard',
+ cost: { value: 500, currencyCode: 'USD' },
+ },
+ ])
+ );
+ // Applying shipping replaces the line, dropping its discounts server-side;
+ // then the reapply that would restore them fails.
+ const applyShippingMock = vi.mocked(applyShippingMethod);
+ const applyShippingImpl = applyShippingMock.getMockImplementation();
+ applyShippingMock.mockImplementationOnce(async (...args) => {
+ const result = await applyShippingImpl?.(...args);
+ setApiErrorOnce('applyDiscount', 'reapply failed');
+ return result as Awaited>;
+ });
+
+ await applyCoupon(user, 'onedollar');
+
+ await waitFor(() => {
+ expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1);
+ });
+ await flushPromises();
+
+ expect(getOperations('ApplyCheckoutSessionDiscount')[0].input).toEqual({
+ discountCodes: ['freeship', 'onedollar'],
+ });
+ expect(getOperations('ApplyCheckoutSessionDiscount')).toHaveLength(2);
+ await screen.findByText(enUs.apiErrors.DISCOUNT_APPLICATION_FAILED);
+ // The refreshed order shows what the server kept: the product coupon,
+ // not the shipping coupon the new shipping line no longer carries.
+ await waitFor(() => {
+ expect(
+ screen.queryAllByRole('button', { name: /remove freeship/i })
+ ).toHaveLength(0);
+ });
+ expect(
+ screen.getAllByRole('button', { name: /remove onedollar/i }).length
+ ).toBeGreaterThan(0);
+ });
+
+ it('clears applied shipping once when the discount rate refresh returns an empty array', async () => {
+ const paidShipping = buildShippingRates([
+ {
+ serviceCode: 'standard',
+ carrierCode: 'carrier',
+ displayName: 'Standard',
+ cost: { value: 1000, currencyCode: 'USD' },
+ },
+ ]);
+ const { user } = renderCheckout({
+ apiOverrides: { shippingMethods: paidShipping },
+ draftOrderOverrides: {
+ shippingLines: [
+ {
+ requestedService: 'standard',
+ requestedProvider: 'carrier',
+ name: 'Standard',
+ amount: { value: 1000, currencyCode: 'USD' },
+ },
+ ],
+ },
+ });
+ await waitForCheckoutReady();
+ clearOperations();
+ setShippingMethods([]);
+
+ await applyCoupon(user, 'onedollar');
+ await waitFor(() => {
+ expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1);
+ });
+ await flushPromises();
+ await flushPromises();
+
+ expect(getOperations('DraftOrderShippingRates')).toHaveLength(1);
+ expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength(1);
+ expect(
+ getOperations('ApplyCheckoutSessionShippingMethod')[0].input
+ ).toEqual([]);
+ expect(getOperations('ApplyCheckoutSessionDiscount')).toHaveLength(2);
+ expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1);
+ expect(screen.queryByText('Standard')).not.toBeInTheDocument();
+ expect(document.body).toHaveTextContent(/no shipping methods found/i);
+ expect(
+ screen.queryByRole('button', { name: enUs.shipping.retryMethods })
+ ).not.toBeInTheDocument();
+ });
+
+ it('treats null shipping rates as no rates instead of a failure', async () => {
+ const paidShipping = buildShippingRates([
+ {
+ serviceCode: 'standard',
+ carrierCode: 'carrier',
+ displayName: 'Standard',
+ cost: { value: 1000, currencyCode: 'USD' },
+ },
+ ]);
+ const { user, session } = renderCheckout({
+ apiOverrides: { shippingMethods: paidShipping },
+ draftOrderOverrides: {
+ shippingLines: [
+ {
+ requestedService: 'standard',
+ requestedProvider: 'carrier',
+ name: 'Standard',
+ amount: { value: 1000, currencyCode: 'USD' },
+ },
+ ],
+ },
+ });
+ await waitForCheckoutReady();
+ clearOperations();
+ // checkout-api returns null rates on purpose, e.g. for an order with no
+ // SKU-backed line items.
+ vi.mocked(getDraftOrderShippingMethods).mockResolvedValue({
+ checkoutSession: {
+ id: session.id,
+ storeId: session.storeId,
+ draftOrder: { id: 'order-1', calculatedShippingRates: null },
+ },
+ });
+
+ await applyCoupon(user, 'onedollar');
+ await waitFor(() => {
+ expect(document.body).toHaveTextContent(/no shipping methods found/i);
+ });
+ await flushPromises();
+
+ expect(
+ screen.queryByRole('button', { name: enUs.shipping.retryMethods })
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByText(enUs.shipping.failedToLoadMethods)
+ ).not.toBeInTheDocument();
+ expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength(1);
+ expect(
+ getOperations('ApplyCheckoutSessionShippingMethod')[0].input
+ ).toEqual([]);
+
+ // The query is not left in an error state, so an unrelated coupon change
+ // does not keep refetching rates.
+ clearOperations();
+ await applyCoupon(user, 'freeship');
+ await waitForOperation('ApplyCheckoutSessionDiscount');
+ await flushPromises();
+ expect(getOperations('DraftOrderShippingRates')).toHaveLength(0);
+ });
+
+ it.each(['same', 'changed', 'default', 'empty'] as const)(
+ 'clears shipping after a rate failure and recovers with %s rates',
+ async recovery => {
+ const paidShipping = buildShippingRates([
+ {
+ serviceCode: 'standard',
+ carrierCode: 'carrier',
+ displayName: 'Standard',
+ cost: { value: 1000, currencyCode: 'USD' },
+ },
+ ]);
+ if (recovery === 'default') {
+ paidShipping.push(
+ ...buildShippingRates([
+ {
+ serviceCode: 'express',
+ carrierCode: 'carrier',
+ displayName: 'Express',
+ cost: { value: 2000, currencyCode: 'USD' },
+ },
+ ])
+ );
+ }
+ const { user, queryClient, session } = renderCheckout({
+ apiOverrides: { shippingMethods: paidShipping },
+ sessionOverrides: {
+ paymentMethods: {
+ card: null as never,
+ offline: {
+ processor: PaymentProvider.OFFLINE,
+ checkoutTypes: [CheckoutType.STANDARD],
+ },
+ },
+ },
+ draftOrderOverrides: {
+ shippingLines: [
+ {
+ requestedService: 'standard',
+ requestedProvider: 'carrier',
+ name: 'Standard',
+ amount: { value: 1000, currencyCode: 'USD' },
+ },
+ ],
+ },
+ });
+ await waitForCheckoutReady();
+ if (recovery === 'default') {
+ await user.click(screen.getByRole('radio', { name: /express/i }));
+ await waitFor(() => {
+ expect(queryClient.isMutating()).toBe(0);
+ expect(screen.getByRole('radio', { name: /express/i })).toBeChecked();
+ });
+ }
+ clearOperations();
+ setApiError('getDraftOrderShippingMethods', 'rates failed');
+ await applyCoupon(user, 'onedollar');
+ const retry = await screen.findByRole('button', {
+ name: enUs.shipping.retryMethods,
+ });
+ await waitFor(() => expect(retry).toBeEnabled());
+ expect(
+ screen.getByText(enUs.shipping.failedToLoadMethods)
+ ).toBeInTheDocument();
+ expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength(
+ 1
+ );
+ expect(
+ getOperations('ApplyCheckoutSessionShippingMethod')[0].input
+ ).toEqual([]);
+ expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1);
+ expect(
+ queryClient.getQueryData(checkoutQueryKeys.draftOrder(session.id))
+ ).toMatchObject({
+ checkoutSession: {
+ draftOrder: {
+ shippingLines: [],
+ },
+ },
+ });
+ const pay = screen.getByRole('button', { name: /complete your order/i });
+ expect(pay).toBeEnabled();
+ await user.click(pay);
+ await screen.findByText(enUs.apiErrors.MISSING_SHIPPING_INFO);
+ await waitFor(() => expect(retry).toBeEnabled());
+ expect(getOperations('ConfirmCheckoutSession')).toHaveLength(0);
+ // Repeated failures keep the retry action usable without clearing twice.
+ setApiError('getDraftOrderShippingMethods', 'rates still failed');
+ await user.click(retry);
+ await waitFor(() => expect(retry).toBeEnabled());
+ expect(pay).toBeEnabled();
+ expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength(
+ 1
+ );
+ expect(
+ getOperations('ApplyCheckoutSessionShippingMethod')[0].input
+ ).toEqual([]);
+ clearApiError('getDraftOrderShippingMethods');
+ if (recovery === 'changed') {
+ setShippingMethods(
+ buildShippingRates([
+ {
+ serviceCode: 'standard',
+ carrierCode: 'carrier',
+ displayName: 'Standard',
+ cost: { value: 0, currencyCode: 'USD' },
+ },
+ ])
+ );
+ } else if (recovery === 'empty') {
+ setShippingMethods([]);
+ }
+ await user.click(retry);
+ await waitFor(() =>
+ expect(
+ screen.queryByRole('button', { name: enUs.shipping.retryMethods })
+ ).not.toBeInTheDocument()
+ );
+ if (recovery === 'empty') {
+ expect(
+ getOperations('ApplyCheckoutSessionShippingMethod')
+ ).toHaveLength(1);
+ expect(
+ screen.getByText(enUs.shipping.noShippingMethods)
+ ).toBeInTheDocument();
+ } else {
+ await waitFor(() =>
+ expect(
+ getOperations('ApplyCheckoutSessionShippingMethod')
+ ).toHaveLength(2)
+ );
+ expect(
+ getOperations('ApplyCheckoutSessionShippingMethod')[1].input
+ ).toEqual([
+ expect.objectContaining({
+ requestedService: 'standard',
+ subTotal: {
+ value: recovery === 'changed' ? 0 : 1000,
+ currencyCode: 'USD',
+ },
+ }),
+ ]);
+ await waitFor(() => expect(pay).toBeEnabled());
+ expect(screen.getByText('Standard')).toBeInTheDocument();
+ if (recovery === 'default') {
+ expect(
+ screen.getByRole('radio', { name: /standard/i })
+ ).toBeChecked();
+ expect(
+ screen.getByRole('radio', { name: /express/i })
+ ).not.toBeChecked();
+ }
+ expect(
+ screen.queryByText(enUs.apiErrors.MISSING_SHIPPING_INFO)
+ ).not.toBeInTheDocument();
+ }
+ }
+ );
+
+ 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(() => {
+ // 1: initial standard apply on mount, 2: failed reapplication attempt,
+ // 3: the failed attempt's own clear-shipping fallback (which also fails
+ // here since setApiError blanket-fails every applyShippingMethod call).
+ expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength(
+ 3
+ );
+ });
+ await flushPromises();
+ await flushPromises();
+
+ expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength(3);
+ expect(screen.getByRole('radio', { name: /standard/i })).toBeChecked();
+ expect(screen.getByRole('radio', { name: /free/i })).not.toBeChecked();
+ // Shipping changed or was cleared, so taxes are still reconciled.
+ expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1);
+
+ await screen.findByText(enUs.apiErrors.SHIPPING_METHOD_APPLICATION_FAILED);
+
+ // The failed reapplication invalidates the draft order and shipping-rate
+ // queries so the next render re-evaluates reconciliation against fresh data.
+ await waitFor(() => {
+ expect(getOperations('DraftOrder').length).toBeGreaterThan(0);
+ expect(getOperations('DraftOrderShippingRates').length).toBeGreaterThan(
+ 1
+ );
+ });
+ });
+
+ it('clears SHIPPING_METHOD_APPLICATION_FAILED once reconciliation succeeds again', async () => {
+ const paidShipping = buildShippingRates([
+ {
+ serviceCode: 'standard',
+ carrierCode: 'carrier',
+ displayName: 'Standard',
+ cost: { value: 1000, currencyCode: 'USD' },
+ },
+ ]);
+ const { user } = renderCheckout({
+ apiOverrides: { shippingMethods: paidShipping },
+ draftOrderOverrides: {
+ shippingLines: [
+ {
+ requestedService: 'standard',
+ requestedProvider: 'carrier',
+ name: 'Standard',
+ amount: { value: 1000, currencyCode: 'USD' },
+ },
+ ],
+ },
+ });
+ await waitForCheckoutReady();
+ clearOperations();
+ setApiError('applyShippingMethod', 'apply failed');
+ setShippingMethods([
+ ...paidShipping,
+ ...buildShippingRates([
+ {
+ serviceCode: 'free',
+ carrierCode: 'carrier',
+ displayName: 'Free',
+ cost: { value: 0, currencyCode: 'USD' },
+ },
+ ]),
+ ]);
+
+ await applyCoupon(user, 'onedollar');
+ await screen.findByText(enUs.apiErrors.SHIPPING_METHOD_APPLICATION_FAILED);
+
+ clearApiError('applyShippingMethod');
+ await user.click(screen.getByRole('radio', { name: /free/i }));
+
+ await waitFor(() => {
+ expect(
+ screen.queryByText(enUs.apiErrors.SHIPPING_METHOD_APPLICATION_FAILED)
+ ).not.toBeInTheDocument();
+ });
+ expect(screen.getByRole('radio', { name: /free/i })).toBeChecked();
+ });
+
+ it('surfaces specific error codes from a failed shipping reapplication', async () => {
+ const paidShipping = buildShippingRates([
+ {
+ serviceCode: 'standard',
+ carrierCode: 'carrier',
+ displayName: 'Standard',
+ cost: { value: 1000, currencyCode: 'USD' },
+ },
+ ]);
+ const { user } = renderCheckout({
+ apiOverrides: { shippingMethods: paidShipping },
+ draftOrderOverrides: {
+ shippingLines: [
+ {
+ requestedService: 'standard',
+ requestedProvider: 'carrier',
+ name: 'Standard',
+ amount: { value: 1000, currencyCode: 'USD' },
+ },
+ ],
+ },
+ });
+ await waitForCheckoutReady();
+ clearOperations();
+ setApiError(
+ 'applyShippingMethod',
+ new GraphQLErrorWithCodes([
+ {
+ message: 'shipping method not found',
+ code: 'SHIPPING_METHOD_NOT_FOUND',
+ },
+ ])
+ );
+ setShippingMethods([
+ ...paidShipping,
+ ...buildShippingRates([
+ {
+ serviceCode: 'free',
+ carrierCode: 'carrier',
+ displayName: 'Free',
+ cost: { value: 0, currencyCode: 'USD' },
+ },
+ ]),
+ ]);
+
+ await applyCoupon(user, 'onedollar');
+
+ await screen.findByText(enUs.apiErrors.SHIPPING_METHOD_NOT_FOUND);
+ expect(
+ screen.queryByText(enUs.apiErrors.SHIPPING_METHOD_APPLICATION_FAILED)
+ ).not.toBeInTheDocument();
+ });
+
+ it.each(['empty', 'replacement'] as const)(
+ '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(() => {
+ // 1: initial standard apply on mount, 2: failed reapplication attempt.
+ // 'empty' has no method to select, so the reapplication attempt was
+ // already a clear ([]); the fallback clear is skipped as redundant.
+ // 'replacement' selects a method, so the failure triggers a distinct
+ // clear-shipping fallback call (3), which also fails here since
+ // setApiError blanket-fails every applyShippingMethod call.
+ expect(
+ getOperations('ApplyCheckoutSessionShippingMethod')
+ ).toHaveLength(result === 'empty' ? 2 : 3);
+ });
+ await flushPromises();
+ await flushPromises();
+
+ expect(getOperations('ApplyCheckoutSessionDiscount')).toHaveLength(1);
+ // Shipping changed or was cleared, so taxes are still reconciled.
+ expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1);
+
+ 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('clears the automatic selection flag when a failed reapplication falls back to clearing shipping', async () => {
+ const paidShipping = buildShippingRates([
+ {
+ serviceCode: 'standard',
+ carrierCode: 'carrier',
+ displayName: 'Standard',
+ cost: { value: 1000, currencyCode: 'USD' },
+ },
+ ]);
+ const { user, queryClient } = renderCheckout({
+ apiOverrides: { shippingMethods: paidShipping },
+ draftOrderOverrides: { shippingLines: [] },
+ checkoutProps: autoSelectedProbe,
+ });
+ await waitForCheckoutReady();
+ await waitFor(() => expect(queryClient.isMutating()).toBe(0));
+ expect(screen.getByTestId('auto-selected')).toHaveTextContent('true');
+ clearOperations();
+ setShippingMethods(
+ buildShippingRates([
+ {
+ serviceCode: 'express',
+ carrierCode: 'carrier',
+ displayName: 'Express',
+ cost: { value: 1500, currencyCode: 'USD' },
+ },
+ ])
+ );
+ // The replacement fails and the fallback clear succeeds. Later applies
+ // fail too, so the form's automatic re-selection can't overwrite the
+ // flag the clear reset (its rollback restores the cleared value).
+ const applyShippingMock = vi.mocked(applyShippingMethod);
+ const applyShippingImpl = applyShippingMock.getMockImplementation();
+ applyShippingMock
+ .mockImplementationOnce(async () => {
+ throw new Error('apply failed');
+ })
+ .mockImplementationOnce(async (...args) => {
+ const result = await applyShippingImpl?.(...args);
+ setApiError('applyShippingMethod', 'apply failed');
+ return result as Awaited>;
+ });
+
+ await applyCoupon(user, 'onedollar');
+ await waitFor(() => {
+ expect(
+ getOperations('ApplyCheckoutSessionShippingMethod').length
+ ).toBeGreaterThanOrEqual(1);
+ });
+ await waitFor(() => expect(queryClient.isMutating()).toBe(0));
+ await flushPromises();
+
+ // The failing replacement throws before the mock records it, so the
+ // fallback clear is the first recorded apply.
+ expect(
+ getOperations('ApplyCheckoutSessionShippingMethod')[0].input
+ ).toEqual([]);
+ expect(screen.getByTestId('auto-selected')).toHaveTextContent('false');
+ });
+
+ it("keeps the customer's chosen method when a coupon reprices the rates", async () => {
+ const rates = (standardCost: number, expressCost: number) =>
+ buildShippingRates([
+ {
+ serviceCode: 'standard',
+ carrierCode: 'carrier',
+ displayName: 'Standard',
+ cost: { value: standardCost, currencyCode: 'USD' },
+ },
+ {
+ serviceCode: 'express',
+ carrierCode: 'carrier',
+ displayName: 'Express',
+ cost: { value: expressCost, currencyCode: 'USD' },
+ },
+ ]);
+ const { user } = renderCheckout({
+ apiOverrides: { shippingMethods: rates(500, 2000) },
+ draftOrderOverrides: {
+ shippingLines: [
+ {
+ requestedService: 'standard',
+ requestedProvider: 'carrier',
+ name: 'Standard',
+ amount: { value: 500, currencyCode: 'USD' },
+ },
+ ],
+ },
+ });
+ await waitForCheckoutReady();
+
+ await user.click(screen.getByRole('radio', { name: /express/i }));
+ await waitForOperation('ApplyCheckoutSessionShippingMethod');
+ await flushPromises();
+ clearOperations();
+
+ setShippingMethods(rates(400, 1900));
+ await applyCoupon(user, 'onedollar');
+ await waitForOperation('ApplyCheckoutSessionShippingMethod');
+ await flushPromises();
+
+ const applied = getOperations('ApplyCheckoutSessionShippingMethod');
+ expect(applied.at(-1)?.input).toEqual([
+ expect.objectContaining({
+ requestedService: 'express',
+ subTotal: { value: 1900, currencyCode: 'USD' },
+ }),
+ ]);
+ expect(screen.getByRole('radio', { name: /express/i })).toBeChecked();
+ });
+
+ it('clears the stale shipping line when reapplication fails, then reapplies on the next render', async () => {
+ const paidShipping = buildShippingRates([
+ {
+ serviceCode: 'standard',
+ carrierCode: 'carrier',
+ displayName: 'Standard',
+ cost: { value: 1000, currencyCode: 'USD' },
+ },
+ ]);
+ const { user } = renderCheckout({
+ apiOverrides: { shippingMethods: paidShipping },
+ draftOrderOverrides: {
+ shippingLines: [
+ {
+ requestedService: 'standard',
+ requestedProvider: 'carrier',
+ name: 'Standard',
+ amount: { value: 1000, currencyCode: 'USD' },
+ },
+ ],
+ },
+ });
+ await waitForCheckoutReady();
+ clearOperations();
+ setApiErrorOnce('applyShippingMethod', 'apply failed');
+ setShippingMethods([
+ ...paidShipping,
+ ...buildShippingRates([
+ {
+ serviceCode: 'free',
+ carrierCode: 'carrier',
+ displayName: 'Free',
+ cost: { value: 0, currencyCode: 'USD' },
+ },
+ ]),
+ ]);
+
+ await applyCoupon(user, 'onedollar');
+
+ await waitFor(() => {
+ expect(
+ getOperations('ApplyCheckoutSessionShippingMethod').map(operation =>
+ (operation.input as Array<{ requestedService: string }>).map(
+ line => line.requestedService
+ )
+ )
+ ).toEqual([['free'], [], ['free']]);
+ });
+ await flushPromises();
+
+ expect(screen.getByRole('radio', { name: /free/i })).toBeChecked();
+ expect(
+ screen.queryByText(enUs.apiErrors.SHIPPING_METHOD_APPLICATION_FAILED)
+ ).not.toBeInTheDocument();
+ });
+
+ it('reapplies line-item discount codes when the shipping method changes', async () => {
+ const shippingMethods = buildShippingRates([
+ {
+ serviceCode: 'flat-rate',
+ carrierCode: 'carrier',
+ displayName: 'Flat Rate',
+ cost: { value: 10, currencyCode: 'USD' },
+ },
+ {
+ serviceCode: 'premium-rate',
+ carrierCode: 'carrier',
+ displayName: 'Premium Rate',
+ cost: { value: 100, currencyCode: 'USD' },
+ },
+ ]);
+ const { user } = renderCheckout({
+ apiOverrides: { shippingMethods },
+ draftOrderOverrides: {
+ discounts: [{ code: 'order10' }],
+ lineItems: [{ id: 'line-item-1', discounts: [{ code: 'lineitem10' }] }],
+ shippingLines: [
+ {
+ requestedService: 'flat-rate',
+ requestedProvider: 'carrier',
+ name: 'Flat Rate',
+ amount: { value: 10, currencyCode: 'USD' },
+ },
+ ],
+ },
+ });
+ await waitForCheckoutReady();
+ await flushPromises();
+ clearOperations();
+
+ await user.click(screen.getByRole('radio', { name: /premium rate/i }));
+ await waitForOperation('ApplyCheckoutSessionDiscount');
+
+ expect(getOperations('ApplyCheckoutSessionDiscount')[0].input).toEqual({
+ discountCodes: expect.arrayContaining(['order10', 'lineitem10']),
+ });
+ });
+
+ it('keeps treating a method as automatic after a failed customer change', async () => {
+ const rates = (cheapCost: number, otherCost: number) =>
+ buildShippingRates([
+ {
+ serviceCode: 'cheap',
+ carrierCode: 'carrier',
+ displayName: 'Cheap',
+ cost: { value: cheapCost, currencyCode: 'USD' },
+ },
+ {
+ serviceCode: 'other',
+ carrierCode: 'carrier',
+ displayName: 'Other',
+ cost: { value: otherCost, currencyCode: 'USD' },
+ },
+ ]);
+ const { user } = renderCheckout({
+ apiOverrides: { shippingMethods: rates(100, 500) },
+ draftOrderOverrides: { shippingLines: [] },
+ });
+ await waitForCheckoutReady();
+ await waitForOperation('ApplyCheckoutSessionShippingMethod');
+ await flushPromises();
+ expect(screen.getByRole('radio', { name: /cheap/i })).toBeChecked();
+
+ setApiErrorOnce('applyShippingMethod', 'apply failed');
+ await user.click(screen.getByRole('radio', { name: /other/i }));
+ await waitFor(() => {
+ expect(screen.getByRole('radio', { name: /cheap/i })).toBeChecked();
+ });
+ await flushPromises();
+ clearOperations();
+
+ // "Cheap" is still an automatic pick, so a repricing moves it to the
+ // new cheapest method instead of keeping it like a customer choice.
+ setShippingMethods(rates(600, 500));
+ await applyCoupon(user, 'onedollar');
+ await waitForOperation('ApplyCheckoutSessionShippingMethod');
+
+ expect(
+ getOperations('ApplyCheckoutSessionShippingMethod').at(-1)?.input
+ ).toEqual([expect.objectContaining({ requestedService: 'other' })]);
+ });
+
+ it('applies a newly available free method before calculating taxes', async () => {
+ const paidShipping = buildShippingRates([
+ {
+ 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');
+
+ // A shipping-only coupon leaves the product discount unchanged, so the
+ // rates cannot change and are not refetched.
+ await waitFor(() => {
+ expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1);
+ });
+ expect(getOperations('DraftOrderShippingRates')).toHaveLength(0);
+ 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('CalculateCheckoutSessionTaxes')).toHaveLength(1);
+ });
+ expect(getOperations('DraftOrderShippingRates')).toHaveLength(0);
+ 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-free-order.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-free-order.test.tsx
index ea06205c5..4e5cdab74 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
@@ -510,9 +510,6 @@ describe('Checkout free / offline orders', () => {
enableShipping: true,
enableLocalPickup: false,
enableTaxCollection: false,
- experimental_rules: {
- freeShipping: { enabled: true, minimumOrderTotal: 0 },
- },
});
const { user } = renderCheckout({
diff --git a/packages/react/src/components/checkout/__tests__/checkout-free-shipping-default.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-free-shipping-default.test.tsx
index 91963e795..893c4f3c2 100644
--- a/packages/react/src/components/checkout/__tests__/checkout-free-shipping-default.test.tsx
+++ b/packages/react/src/components/checkout/__tests__/checkout-free-shipping-default.test.tsx
@@ -1,3 +1,4 @@
+import type { QueryClient } from '@tanstack/react-query';
import { screen, waitFor } from '@testing-library/react';
import { act } from 'react';
import { describe, expect, it } from 'vitest';
@@ -8,10 +9,12 @@ import {
buildDraftOrder,
buildShippingAddress,
buildShippingRates,
+ clearApiError,
clearOperations,
flushPromises,
getOperations,
renderCheckout,
+ setApiError,
waitForCheckoutReady,
waitForOperation,
} from './checkout-test-env';
@@ -45,6 +48,31 @@ function shippingLine(service: string, value: number) {
};
}
+function offerRates(
+ queryClient: QueryClient,
+ sessionId: string,
+ rates: ShippingMethod[]
+) {
+ const address = buildDraftOrder().shipping?.address ?? buildShippingAddress();
+ act(() => {
+ queryClient.setQueryData(
+ [
+ ...checkoutQueryKeys.draftOrderShippingMethods(sessionId),
+ {
+ addressLine1: address.addressLine1,
+ adminArea1: address.adminArea1,
+ adminArea2: address.adminArea2,
+ postalCode: address.postalCode,
+ countryCode: address.countryCode,
+ },
+ ],
+ {
+ checkoutSession: { draftOrder: { calculatedShippingRates: { rates } } },
+ }
+ );
+ });
+}
+
function appliedInputs(): unknown[] {
return getOperations('ApplyCheckoutSessionShippingMethod').map(
operation => operation.input
@@ -52,7 +80,9 @@ function appliedInputs(): unknown[] {
}
describe('Checkout free shipping default selection', () => {
- it('selects and applies the free rate when the draft order already has a paid shipping line', async () => {
+ it('keeps a paid shipping line already on the draft order on load', async () => {
+ // The saved line may be the shopper's earlier choice (reload, payment
+ // redirect), so a $0 rate that was already offered does not replace it.
renderCheckout({
draftOrderOverrides: {
shippingLines: [shippingLine('weight-based', 100)],
@@ -61,10 +91,8 @@ describe('Checkout free shipping default selection', () => {
await waitForCheckoutReady();
await advanceCheckoutDebounce(2500);
- expect(checkedShippingRadios()).toEqual(['Free FREE']);
- expect(appliedInputs()).toEqual([
- [expect.objectContaining({ requestedService: 'free-shipping' })],
- ]);
+ expect(checkedShippingRadios()).toEqual(['Weight Based $1.00']);
+ expect(appliedInputs()).toEqual([]);
});
it('keeps a free rate already on the draft order when another free rate sorts first', async () => {
@@ -89,27 +117,6 @@ describe('Checkout free shipping default selection', () => {
expect(appliedInputs()).toEqual([]);
});
- it('shows the paid rate still on the draft order when applying the free rate fails', async () => {
- renderCheckout({
- draftOrderOverrides: {
- shippingLines: [shippingLine('weight-based', 100)],
- },
- apiOverrides: {
- errors: { applyShippingMethod: new Error('apply failed') },
- },
- });
- await waitForCheckoutReady();
- await waitForOperation('ApplyCheckoutSessionShippingMethod');
- await advanceCheckoutDebounce(2500);
-
- await waitFor(() => {
- expect(checkedShippingRadios()).toEqual(['Weight Based $1.00']);
- });
- expect(appliedInputs()).toEqual([
- [expect.objectContaining({ requestedService: 'free-shipping' })],
- ]);
- });
-
it('applies the free rate when the draft order has no shipping line', async () => {
renderCheckout();
await waitForCheckoutReady();
@@ -132,31 +139,7 @@ describe('Checkout free shipping default selection', () => {
expect(appliedInputs()).toHaveLength(1);
clearOperations();
- const address =
- buildDraftOrder().shipping?.address ?? buildShippingAddress();
- act(() => {
- queryClient.setQueryData(
- [
- ...checkoutQueryKeys.draftOrderShippingMethods(session.id),
- {
- addressLine1: address.addressLine1,
- adminArea1: address.adminArea1,
- adminArea2: address.adminArea2,
- postalCode: address.postalCode,
- countryCode: address.countryCode,
- },
- ],
- {
- checkoutSession: {
- draftOrder: {
- calculatedShippingRates: {
- rates: [freeRate, ...paidOnlyRates],
- },
- },
- },
- }
- );
- });
+ offerRates(queryClient, session.id, [freeRate, ...paidOnlyRates]);
await flushPromises();
await advanceCheckoutDebounce(2500);
@@ -211,10 +194,54 @@ describe('Checkout free shipping default selection', () => {
expect(appliedInputs()).toHaveLength(0);
});
- it('re-applies the order discount code after switching an auto-applied paid rate to free', async () => {
- renderCheckout({
+ it('shows the rate still on the draft order and does not retry when switching to a newly offered free rate fails', async () => {
+ const { queryClient, session } = renderCheckout({
+ apiOverrides: { shippingMethods: paidOnlyRates },
+ });
+ await waitForCheckoutReady();
+ await advanceCheckoutDebounce(2500);
+ expect(checkedShippingRadios()).toEqual(['Weight Based $1.00']);
+ clearOperations();
+
+ setApiError('applyShippingMethod', new Error('apply failed'));
+ offerRates(queryClient, session.id, [freeRate, ...paidOnlyRates]);
+ await waitForOperation('ApplyCheckoutSessionShippingMethod');
+ await waitFor(() => {
+ expect(queryClient.isMutating()).toBe(0);
+ expect(queryClient.isFetching()).toBe(0);
+ });
+ await flushPromises();
+ await advanceCheckoutDebounce(2500);
+ await flushPromises();
+
+ expect(checkedShippingRadios()).toEqual(['Weight Based $1.00']);
+ // One attempt only: the failed free rate is not retried in a loop.
+ expect(appliedInputs()).toEqual([
+ [expect.objectContaining({ requestedService: 'free-shipping' })],
+ ]);
+
+ // The paid rate is not stuck: once applies work again, the free rate that is
+ // still offered is applied after the next rate change.
+ clearApiError('applyShippingMethod');
+ clearOperations();
+ offerRates(queryClient, session.id, [
+ freeRate,
+ { ...weightRate, cost: { value: 150, currencyCode: 'USD' } },
+ expressRate,
+ ]);
+ await waitForOperation('ApplyCheckoutSessionShippingMethod');
+ await flushPromises();
+
+ expect(appliedInputs()).toEqual([
+ [expect.objectContaining({ requestedService: 'free-shipping' })],
+ ]);
+ expect(checkedShippingRadios()).toEqual(['Free FREE']);
+ });
+
+ it('re-applies the order discount code after switching an automatic paid rate to a newly offered free rate', async () => {
+ const { queryClient, session } = renderCheckout({
+ apiOverrides: { shippingMethods: paidOnlyRates },
draftOrderOverrides: {
- shippingLines: [shippingLine('weight-based', 100)],
discounts: [
{
code: 'SAVE',
@@ -225,13 +252,18 @@ describe('Checkout free shipping default selection', () => {
},
});
await waitForCheckoutReady();
+ await advanceCheckoutDebounce(2500);
+ expect(checkedShippingRadios()).toEqual(['Weight Based $1.00']);
+ await flushPromises();
+ clearOperations();
+
+ offerRates(queryClient, session.id, [freeRate, ...paidOnlyRates]);
await waitForOperation('ApplyCheckoutSessionShippingMethod');
await waitForOperation('ApplyCheckoutSessionDiscount');
expect(appliedInputs()).toEqual([
[expect.objectContaining({ requestedService: 'free-shipping' })],
]);
- expect(getOperations('ApplyCheckoutSessionDiscount')).toHaveLength(1);
expect(
getOperations('ApplyCheckoutSessionDiscount')[0].input
).toMatchObject({ discountCodes: ['SAVE'] });
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 aca516b9c..8b8db022c 100644
--- a/packages/react/src/components/checkout/__tests__/checkout-shipping.test.tsx
+++ b/packages/react/src/components/checkout/__tests__/checkout-shipping.test.tsx
@@ -1,4 +1,6 @@
+import { enUs } from '@godaddy/localizations';
import { act, fireEvent, screen, waitFor } from '@testing-library/react';
+import { useFormContext } from 'react-hook-form';
import { describe, expect, it } from 'vitest';
import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys';
import * as godaddyApi from '@/lib/godaddy/godaddy';
@@ -8,18 +10,269 @@ import {
buildDraftOrder,
buildLineItem,
buildShippingAddress,
+ buildShippingRates,
+ clearApiError,
clearOperations,
flushPromises,
getOperations,
renderCheckout,
setApiError,
+ setApiErrorOnce,
setCurrentDraftOrder,
+ setShippingMethods,
typeIntoNamedField,
waitForCheckoutReady,
waitForOperation,
} from './checkout-test-env';
+function AutoSelectedProbe() {
+ const form = useFormContext();
+ return (
+
+ {String(form.watch('shippingMethodAutoSelected'))}
+
+ );
+}
+
+function autoSelected() {
+ return screen.getByTestId('auto-selected').textContent;
+}
+
+const autoSelectedProbe = {
+ targets: { 'checkout.form.payment.after': () => },
+};
+
describe('Checkout shipping behavior', () => {
+ it.each([true, false])(
+ 'preserves an existing rate and refreshes initial taxes only when enabled (%s)',
+ async enableTaxCollection => {
+ const { queryClient } = renderCheckout({
+ sessionOverrides: { enableTaxCollection },
+ draftOrderOverrides: {
+ shippingLines: [
+ {
+ requestedService: 'free-shipping',
+ requestedProvider: 'unknown',
+ name: 'Free',
+ amount: { value: 0, currencyCode: 'USD' },
+ discounts: [],
+ },
+ ],
+ },
+ });
+ await waitForCheckoutReady();
+ await waitFor(() => {
+ expect(queryClient.isMutating()).toBe(0);
+ expect(queryClient.isFetching()).toBe(0);
+ });
+
+ expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength(
+ 0
+ );
+ expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(
+ enableTaxCollection ? 1 : 0
+ );
+ }
+ );
+
+ it('keeps a saved shipping method on load when a cheaper rate is offered', async () => {
+ const { queryClient } = renderCheckout({
+ draftOrderOverrides: {
+ shippingLines: [
+ {
+ requestedService: 'weight-based',
+ requestedProvider: 'unknown',
+ name: 'Weight Based',
+ amount: { value: 100, currencyCode: 'USD' },
+ discounts: [],
+ },
+ ],
+ },
+ });
+ await waitForCheckoutReady();
+ await waitFor(() => {
+ expect(queryClient.isMutating()).toBe(0);
+ expect(queryClient.isFetching()).toBe(0);
+ });
+
+ expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength(0);
+ expect(screen.getByRole('radio', { name: /weight based/i })).toBeChecked();
+ expect(screen.getByRole('radio', { name: /free/i })).not.toBeChecked();
+ });
+
+ it('moves an automatic selection to the new cheapest rate when the address reprices it', async () => {
+ const rates = (standard: number, express: number) =>
+ buildShippingRates([
+ {
+ serviceCode: 'standard',
+ carrierCode: 'carrier',
+ displayName: 'Standard',
+ cost: { value: standard, currencyCode: 'USD' },
+ },
+ {
+ serviceCode: 'express',
+ carrierCode: 'carrier',
+ displayName: 'Express',
+ cost: { value: express, currencyCode: 'USD' },
+ },
+ ]);
+ const { user, queryClient } = renderCheckout({
+ apiOverrides: { shippingMethods: rates(500, 2000) },
+ draftOrderOverrides: { shippingLines: [] },
+ });
+ await waitForCheckoutReady();
+ await waitFor(() => expect(queryClient.isMutating()).toBe(0));
+ expect(screen.getByRole('radio', { name: /standard/i })).toBeChecked();
+ clearOperations();
+
+ // Express is now the cheapest rate for the new address.
+ setShippingMethods(rates(2500, 2000));
+ const postal = document.querySelector(
+ 'input[name="shippingPostalCode"]'
+ ) as HTMLInputElement;
+ await user.clear(postal);
+ await user.type(postal, '94016');
+ await advanceCheckoutDebounce();
+ await waitForOperation('UpdateCheckoutSessionDraftOrder');
+ await waitForOperation('DraftOrderShippingRates', 1, 6000);
+ await waitFor(() => {
+ expect(queryClient.isMutating()).toBe(0);
+ expect(queryClient.isFetching()).toBe(0);
+ });
+ await flushPromises();
+
+ const applied = getOperations('ApplyCheckoutSessionShippingMethod');
+ expect(applied.at(-1)?.input).toEqual([
+ expect.objectContaining({ requestedService: 'express' }),
+ ]);
+ expect(applied.length).toBeLessThanOrEqual(2);
+ expect(screen.getByRole('radio', { name: /express/i })).toBeChecked();
+ });
+
+ it('switches a saved method to newly available free shipping after an address edit without looping', async () => {
+ const paidRates = buildShippingRates([
+ {
+ serviceCode: 'standard',
+ carrierCode: 'carrier',
+ displayName: 'Standard',
+ cost: { value: 500, currencyCode: 'USD' },
+ },
+ ]);
+ const { user, queryClient } = renderCheckout({
+ apiOverrides: { shippingMethods: paidRates },
+ draftOrderOverrides: {
+ shippingLines: [
+ {
+ requestedService: 'standard',
+ requestedProvider: 'carrier',
+ name: 'Standard',
+ amount: { value: 500, currencyCode: 'USD' },
+ discounts: [],
+ },
+ ],
+ },
+ });
+ await waitForCheckoutReady();
+ await waitFor(() => expect(queryClient.isMutating()).toBe(0));
+ clearOperations();
+
+ setShippingMethods([
+ ...paidRates,
+ ...buildShippingRates([
+ {
+ serviceCode: 'free',
+ carrierCode: 'carrier',
+ displayName: 'Free',
+ cost: { value: 0, currencyCode: 'USD' },
+ },
+ ]),
+ ]);
+ const postal = document.querySelector(
+ 'input[name="shippingPostalCode"]'
+ ) as HTMLInputElement;
+ await user.clear(postal);
+ await user.type(postal, '94016');
+ await advanceCheckoutDebounce();
+ await waitForOperation('UpdateCheckoutSessionDraftOrder');
+ await waitForOperation('DraftOrderShippingRates', 1, 6000);
+ await waitFor(() => {
+ expect(queryClient.isMutating()).toBe(0);
+ expect(queryClient.isFetching()).toBe(0);
+ });
+ await flushPromises();
+
+ const applied = getOperations('ApplyCheckoutSessionShippingMethod');
+ expect(applied.at(-1)?.input).toEqual([
+ expect.objectContaining({ requestedService: 'free' }),
+ ]);
+ expect(applied.length).toBeLessThanOrEqual(2);
+ expect(screen.getByRole('radio', { name: /free/i })).toBeChecked();
+ });
+
+ it('clears the automatic selection flag when switching to local pickup', async () => {
+ const { user, queryClient } = renderCheckout({
+ draftOrderOverrides: { shippingLines: [] },
+ checkoutProps: autoSelectedProbe,
+ });
+ await waitForCheckoutReady();
+ await waitFor(() => expect(queryClient.isMutating()).toBe(0));
+ expect(autoSelected()).toBe('true');
+
+ await user.click(screen.getByRole('radio', { name: /local pickup/i }));
+
+ expect(autoSelected()).toBe('false');
+ });
+
+ it('clears the automatic selection flag when rates come back empty', async () => {
+ const { queryClient } = renderCheckout({
+ draftOrderOverrides: { shippingLines: [] },
+ checkoutProps: autoSelectedProbe,
+ });
+ await waitForCheckoutReady();
+ await waitFor(() => expect(queryClient.isMutating()).toBe(0));
+ expect(autoSelected()).toBe('true');
+ clearOperations();
+
+ setShippingMethods([]);
+ await act(async () => {
+ await queryClient.refetchQueries({
+ queryKey: ['draft-order-shipping-methods'],
+ });
+ });
+ await waitForOperation('ApplyCheckoutSessionShippingMethod');
+
+ expect(
+ getOperations('ApplyCheckoutSessionShippingMethod').at(-1)?.input
+ ).toEqual([]);
+ expect(autoSelected()).toBe('false');
+ });
+
+ it('restores the automatic selection flag when clearing shipping fails', async () => {
+ const { queryClient } = renderCheckout({
+ draftOrderOverrides: { shippingLines: [] },
+ checkoutProps: autoSelectedProbe,
+ });
+ await waitForCheckoutReady();
+ await waitFor(() => expect(queryClient.isMutating()).toBe(0));
+ expect(autoSelected()).toBe('true');
+ clearOperations();
+
+ setApiErrorOnce('applyShippingMethod', 'clear failed');
+ setShippingMethods([]);
+ await act(async () => {
+ await queryClient.refetchQueries({
+ queryKey: ['draft-order-shipping-methods'],
+ });
+ });
+ await waitForOperation('ApplyCheckoutSessionShippingMethod');
+ await waitFor(() => expect(queryClient.isMutating()).toBe(0));
+
+ expect(
+ getOperations('ApplyCheckoutSessionShippingMethod').at(-1)?.input
+ ).toEqual([]);
+ expect(autoSelected()).toBe('true');
+ });
+
it('shows the no-origin-address message when shipping origin is missing', async () => {
renderCheckout({
sessionOverrides: { shipping: { originAddress: null } },
@@ -97,7 +350,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',
@@ -120,35 +373,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 () => {
@@ -451,7 +681,7 @@ describe('Checkout shipping behavior', () => {
).toBeInTheDocument();
});
- it('records a shipping-method fetch failure when rates are refetched', async () => {
+ it('clears shipping after an address rate-fetch failure and reapplies the default on retry', async () => {
const { user } = renderCheckout();
await waitForCheckoutReady();
clearOperations();
@@ -467,6 +697,29 @@ describe('Checkout shipping behavior', () => {
).toMatchObject({
destination: expect.objectContaining({ postalCode: '94016' }),
});
+ const retry = await screen.findByRole('button', {
+ name: enUs.shipping.retryMethods,
+ });
+ await waitFor(() => expect(retry).toBeEnabled());
+ expect(
+ getOperations('ApplyCheckoutSessionShippingMethod').at(-1)?.input
+ ).toEqual([]);
+ clearOperations();
+ clearApiError('getDraftOrderShippingMethods');
+ await user.click(retry);
+ await waitFor(() =>
+ expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength(
+ 1
+ )
+ );
+ expect(
+ getOperations('ApplyCheckoutSessionShippingMethod')[0].input
+ ).toEqual([expect.objectContaining({ requestedService: 'free-shipping' })]);
+ await waitFor(() =>
+ expect(
+ screen.queryByRole('button', { name: enUs.shipping.retryMethods })
+ ).not.toBeInTheDocument()
+ );
});
it.each([
diff --git a/packages/react/src/components/checkout/__tests__/checkout-test-utils.tsx b/packages/react/src/components/checkout/__tests__/checkout-test-utils.tsx
index dc86dd3f0..b299d2569 100644
--- a/packages/react/src/components/checkout/__tests__/checkout-test-utils.tsx
+++ b/packages/react/src/components/checkout/__tests__/checkout-test-utils.tsx
@@ -34,7 +34,6 @@ export type OperationName =
| 'UpdateCheckoutSessionDraftOrder'
| 'CalculateCheckoutSessionTaxes'
| 'ApplyCheckoutSessionShippingMethod'
- | 'RemoveAppliedCheckoutSessionShippingMethod'
| 'ApplyCheckoutSessionDiscount'
| 'ApplyCheckoutSessionFulfillmentLocation'
| 'ConfirmCheckoutSession'
@@ -67,7 +66,6 @@ export type MockGodaddyApiErrorKey =
| 'updateDraftOrder'
| 'updateDraftOrderTaxes'
| 'applyShippingMethod'
- | 'removeShippingMethod'
| 'applyDiscount'
| 'applyFulfillmentLocation'
| 'confirmCheckout'
@@ -631,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,
@@ -861,28 +883,6 @@ export function mockGodaddyApi(options: MockGodaddyApiOptions) {
}
);
- mockedGodaddyApi.removeShippingMethod.mockImplementation(async input => {
- record('RemoveAppliedCheckoutSessionShippingMethod', input);
- await maybeDelay();
- maybeThrow('removeShippingMethod');
- if (state) {
- state.draftOrder = recalculateTotal({
- ...state.draftOrder,
- shippingLines: [],
- totals: {
- ...(state.draftOrder.totals ?? defaultTotals()),
- shippingTotal: money(0),
- },
- });
- state.session = { ...state.session, draftOrder: state.draftOrder };
- }
- return {
- removeAppliedCheckoutSessionShippingMethod: {
- draftOrder: state?.draftOrder,
- },
- };
- });
-
mockedGodaddyApi.applyDiscount.mockImplementation(async discountCodes => {
record('ApplyCheckoutSessionDiscount', { discountCodes });
await maybeDelay();
@@ -1011,6 +1011,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/checkout.tsx b/packages/react/src/components/checkout/checkout.tsx
index 812d0a74d..7b326b541 100644
--- a/packages/react/src/components/checkout/checkout.tsx
+++ b/packages/react/src/components/checkout/checkout.tsx
@@ -156,6 +156,8 @@ export const baseCheckoutSchema = z.object({
shippingPostalCode: z.string().max(60),
shippingCountryCode: z.string().max(2),
shippingMethod: z.string().optional(),
+ // Client-only: true when checkout picked the method rather than the customer.
+ shippingMethodAutoSelected: z.boolean().optional(),
billingFirstName: z.string().max(60),
billingLastName: z.string().max(60),
billingPhone: z.string().max(15, 'Phone number too long').optional(),
diff --git a/packages/react/src/components/checkout/delivery/delivery-method.tsx b/packages/react/src/components/checkout/delivery/delivery-method.tsx
index 57514e53c..fd4e35894 100644
--- a/packages/react/src/components/checkout/delivery/delivery-method.tsx
+++ b/packages/react/src/components/checkout/delivery/delivery-method.tsx
@@ -55,6 +55,7 @@ export function DeliveryMethodForm() {
});
if (value === DeliveryMethods.PICKUP) {
form.setValue('shippingMethod', undefined, { shouldDirty: true });
+ form.setValue('shippingMethodAutoSelected', false);
}
track({
eventId: eventIds.changeDeliveryMethod,
diff --git a/packages/react/src/components/checkout/discount/discount-standalone.tsx b/packages/react/src/components/checkout/discount/discount-standalone.tsx
index 1ecc4f332..fbe8832d2 100644
--- a/packages/react/src/components/checkout/discount/discount-standalone.tsx
+++ b/packages/react/src/components/checkout/discount/discount-standalone.tsx
@@ -4,6 +4,7 @@ import React, { useState } from 'react';
import { DiscountApplyButton } from '@/components/checkout/discount/discount-apply-button';
import { DiscountErrorList } from '@/components/checkout/discount/discount-error-list';
import { DiscountInput } from '@/components/checkout/discount/discount-input';
+import { getDraftOrderDiscountCodes } from '@/components/checkout/discount/utils/get-draft-order-discount-codes';
import { useDiscountApply } from '@/components/checkout/discount/utils/use-discount-apply';
import { useDraftOrder } from '@/components/checkout/order/use-draft-order';
import { useIsPaymentDisabled } from '@/components/checkout/payment/utils/use-is-payment-disabled';
@@ -23,49 +24,10 @@ export function DiscountStandalone({
const isPaymentDisabled = useIsPaymentDisabled();
const { data: draftOrder } = useDraftOrder();
- // Get current discount codes from order-level, line item-level, and shipping line-level discounts
- const currentDiscountCodes = React.useMemo(() => {
- if (!draftOrder) return [];
-
- 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);
- }
- }
- }
- }
- }
-
- // Add shipping line-level discount codes
- if (draftOrder.shippingLines) {
- for (const shippingLine of draftOrder.shippingLines) {
- if (shippingLine.discounts) {
- for (const discount of shippingLine.discounts) {
- if (discount.code) {
- allCodes.add(discount.code);
- }
- }
- }
- }
- }
-
- return Array.from(allCodes);
- }, [draftOrder]);
+ const currentDiscountCodes = React.useMemo(
+ () => getDraftOrderDiscountCodes(draftOrder),
+ [draftOrder]
+ );
const [discountCode, setDiscountCode] = useState('');
const [formErrors, setFormErrors] = useState(undefined);
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 000000000..8fab82125
--- /dev/null
+++ b/packages/react/src/components/checkout/discount/utils/get-draft-order-discount-codes.test.ts
@@ -0,0 +1,98 @@
+import { describe, expect, it } from 'vitest';
+import type { DraftOrder } from '@/types';
+import {
+ getDraftOrderDiscountCodes,
+ getHighestValueDraftOrderDiscountCode,
+} 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([
+ 'order',
+ 'line',
+ 'shared',
+ 'shipping',
+ ]);
+ });
+
+ it('returns an empty list without a draft order', () => {
+ expect(getDraftOrderDiscountCodes()).toEqual([]);
+ });
+});
+
+describe('getHighestValueDraftOrderDiscountCode', () => {
+ it('returns the code with the largest combined discount across all levels', () => {
+ const draftOrder = {
+ discounts: [{ code: 'SAVE10', amount: { value: 500 } }],
+ lineItems: [
+ { discounts: [{ code: 'BIGLINE', amount: { value: 400 } }] },
+ { discounts: [{ code: 'BIGLINE', amount: { value: 400 } }] },
+ ],
+ shippingLines: [
+ { discounts: [{ code: 'FREESHIP', amount: { value: 700 } }] },
+ ],
+ } as DraftOrder;
+
+ expect(getHighestValueDraftOrderDiscountCode(draftOrder)).toBe('BIGLINE');
+ });
+
+ it('prefers the first code seen, order-level first, on a tie', () => {
+ const draftOrder = {
+ discounts: [{ code: 'SAVE10', amount: { value: 500 } }],
+ lineItems: [{ discounts: [{ code: 'LINE5', amount: { value: 500 } }] }],
+ } as DraftOrder;
+
+ expect(getHighestValueDraftOrderDiscountCode(draftOrder)).toBe('SAVE10');
+ });
+
+ it('ranks by product discount, ignoring shipping-only codes', () => {
+ const draftOrder = {
+ lineItems: [
+ { discounts: [{ code: 'PRODUCT10', amount: { value: 500 } }] },
+ ],
+ shippingLines: [
+ { discounts: [{ code: 'FREESHIP', amount: { value: 800 } }] },
+ ],
+ } as DraftOrder;
+
+ expect(getHighestValueDraftOrderDiscountCode(draftOrder)).toBe('PRODUCT10');
+ });
+
+ it('skips shipping-only codes, which the wallet prices after an address', () => {
+ const draftOrder = {
+ shippingLines: [
+ { discounts: [{ code: 'FREESHIP', amount: { value: 800 } }] },
+ ],
+ } as DraftOrder;
+
+ expect(getHighestValueDraftOrderDiscountCode(draftOrder)).toBeUndefined();
+ });
+
+ it('still uses a code that discounts products as well as shipping', () => {
+ const draftOrder = {
+ lineItems: [{ discounts: [{ code: 'COMBO', amount: { value: 200 } }] }],
+ shippingLines: [
+ { discounts: [{ code: 'COMBO', amount: { value: 800 } }] },
+ ],
+ } as DraftOrder;
+
+ expect(getHighestValueDraftOrderDiscountCode(draftOrder)).toBe('COMBO');
+ });
+
+ it('returns undefined when no discount codes are applied', () => {
+ expect(getHighestValueDraftOrderDiscountCode()).toBeUndefined();
+ expect(
+ getHighestValueDraftOrderDiscountCode({
+ discounts: [],
+ } as unknown as DraftOrder)
+ ).toBeUndefined();
+ });
+});
diff --git a/packages/react/src/components/checkout/discount/utils/get-draft-order-discount-codes.ts b/packages/react/src/components/checkout/discount/utils/get-draft-order-discount-codes.ts
new file mode 100644
index 000000000..139cf21d1
--- /dev/null
+++ b/packages/react/src/components/checkout/discount/utils/get-draft-order-discount-codes.ts
@@ -0,0 +1,81 @@
+type Discount = {
+ code?: string | null;
+ amount?: { value?: number | null } | null;
+};
+type Discounts = ReadonlyArray | null | undefined;
+
+export interface OrderWithDiscounts {
+ discounts?: Discounts;
+ lineItems?: ReadonlyArray<{ discounts?: Discounts }> | null;
+ shippingLines?: ReadonlyArray<{ discounts?: Discounts }> | null;
+}
+
+interface OrderDiscount {
+ code?: string | null;
+ amount: number;
+ // Shipping-line discounts are not product discounts: they don't affect the
+ // subtotal checkout-api sends for shipping rates.
+ isShipping: boolean;
+}
+
+// Single walk over every discount level of an order, order-level first.
+export function getOrderDiscounts(
+ order?: OrderWithDiscounts | null
+): OrderDiscount[] {
+ const toOrderDiscounts = (discounts: Discounts, isShipping: boolean) =>
+ (discounts ?? []).map(discount => ({
+ code: discount.code,
+ amount: discount.amount?.value ?? 0,
+ isShipping,
+ }));
+
+ return [
+ ...toOrderDiscounts(order?.discounts, false),
+ ...(order?.lineItems ?? []).flatMap(lineItem =>
+ toOrderDiscounts(lineItem.discounts, false)
+ ),
+ ...(order?.shippingLines ?? []).flatMap(shippingLine =>
+ toOrderDiscounts(shippingLine.discounts, true)
+ ),
+ ];
+}
+
+export function getDraftOrderDiscountCodes(
+ draftOrder?: OrderWithDiscounts | null
+): string[] {
+ const codes = new Set();
+
+ for (const { code } of getOrderDiscounts(draftOrder)) {
+ if (code) codes.add(code);
+ }
+
+ return Array.from(codes);
+}
+
+// Express wallets accept a single coupon, so prefill the product code worth
+// the most. The wallet is an isolated cart whose shipping is chosen in the
+// wallet, so shipping-only codes are skipped: they can't be priced until the
+// wallet supplies an address and rate, and checkout-api would otherwise price
+// them against the main order's shipping line. Shoppers can still enter one
+// in the wallet, where it's priced with the wallet's shipping.
+export function getHighestValueDraftOrderDiscountCode(
+ draftOrder?: OrderWithDiscounts | null
+): string | undefined {
+ const totalsByCode = new Map();
+
+ for (const { code, amount, isShipping } of getOrderDiscounts(draftOrder)) {
+ if (!code || isShipping) continue;
+ totalsByCode.set(code, (totalsByCode.get(code) ?? 0) + amount);
+ }
+
+ let highestCode: string | undefined;
+ let highestTotal = Number.NEGATIVE_INFINITY;
+ for (const [code, total] of totalsByCode) {
+ if (total > highestTotal) {
+ highestCode = code;
+ highestTotal = total;
+ }
+ }
+
+ return highestCode;
+}
diff --git a/packages/react/src/components/checkout/discount/utils/get-product-discount-total.test.ts b/packages/react/src/components/checkout/discount/utils/get-product-discount-total.test.ts
new file mode 100644
index 000000000..c41f14fdd
--- /dev/null
+++ b/packages/react/src/components/checkout/discount/utils/get-product-discount-total.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it } from 'vitest';
+import { getProductDiscountTotal } from './get-product-discount-total';
+
+describe('getProductDiscountTotal', () => {
+ it('sums order-level and line-item discounts but not shipping-line discounts', () => {
+ expect(
+ getProductDiscountTotal({
+ discounts: [{ amount: { value: 500 } }],
+ lineItems: [
+ {
+ discounts: [{ amount: { value: 300 } }, { amount: { value: 200 } }],
+ },
+ { discounts: null },
+ ],
+ shippingLines: [{ discounts: [{ amount: { value: 800 } }] }],
+ } as Parameters[0])
+ ).toBe(1000);
+ });
+
+ it('treats missing orders, lists, and amounts as zero', () => {
+ expect(getProductDiscountTotal()).toBe(0);
+ expect(getProductDiscountTotal(null)).toBe(0);
+ expect(
+ getProductDiscountTotal({
+ discounts: [{ amount: null }, { amount: { value: null } }],
+ lineItems: null,
+ })
+ ).toBe(0);
+ });
+});
diff --git a/packages/react/src/components/checkout/discount/utils/get-product-discount-total.ts b/packages/react/src/components/checkout/discount/utils/get-product-discount-total.ts
new file mode 100644
index 000000000..4a48b90aa
--- /dev/null
+++ b/packages/react/src/components/checkout/discount/utils/get-product-discount-total.ts
@@ -0,0 +1,14 @@
+import {
+ getOrderDiscounts,
+ type OrderWithDiscounts,
+} from './get-draft-order-discount-codes';
+
+// Mirrors the product discount checkout-api subtracts from the subtotal for
+// shipping rate requests: order-level and line-item discounts, not shipping.
+export function getProductDiscountTotal(order?: OrderWithDiscounts | null) {
+ return getOrderDiscounts(order).reduce(
+ (total, discount) =>
+ discount.isShipping ? total : total + discount.amount,
+ 0
+ );
+}
diff --git a/packages/react/src/components/checkout/discount/utils/use-apply-discount-core.test.ts b/packages/react/src/components/checkout/discount/utils/use-apply-discount-core.test.ts
new file mode 100644
index 000000000..590351225
--- /dev/null
+++ b/packages/react/src/components/checkout/discount/utils/use-apply-discount-core.test.ts
@@ -0,0 +1,96 @@
+import { QueryClient } from '@tanstack/react-query';
+import { describe, expect, it } from 'vitest';
+import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys';
+import { updateDiscountCache } from './use-apply-discount-core';
+
+type UpdatedOrder = Parameters[2];
+
+const freeShippingDiscount = {
+ id: 'discount-freeship',
+ code: 'freeship',
+ amount: { value: 500, currencyCode: 'USD' },
+};
+
+function seedDraftOrder(queryClient: QueryClient) {
+ queryClient.setQueryData(checkoutQueryKeys.draftOrder('session-1'), {
+ checkoutSession: {
+ id: 'session-1',
+ draftOrder: {
+ id: 'draft-order-1',
+ totals: {},
+ discounts: [],
+ lineItems: [],
+ shippingLines: [
+ { id: 'shipping-a', name: 'A', discounts: [] },
+ { id: 'shipping-b', name: 'B', discounts: [] },
+ ],
+ },
+ },
+ });
+}
+
+function cachedShippingLines(queryClient: QueryClient) {
+ return (
+ queryClient.getQueryData(checkoutQueryKeys.draftOrder('session-1')) as {
+ checkoutSession: {
+ draftOrder: {
+ shippingLines: Array<{ id: string; discounts: unknown[] }>;
+ };
+ };
+ }
+ ).checkoutSession.draftOrder.shippingLines;
+}
+
+describe('updateDiscountCache', () => {
+ it('matches shipping-line discounts by id, not position', () => {
+ const queryClient = new QueryClient();
+ seedDraftOrder(queryClient);
+
+ updateDiscountCache(
+ queryClient,
+ 'session-1',
+ {
+ shippingLines: [
+ { id: 'shipping-b', discounts: [freeShippingDiscount] },
+ { id: 'shipping-a', discounts: [] },
+ ],
+ } as unknown as UpdatedOrder,
+ ['freeship']
+ );
+
+ expect(cachedShippingLines(queryClient)).toEqual([
+ expect.objectContaining({ id: 'shipping-a', discounts: [] }),
+ expect.objectContaining({
+ id: 'shipping-b',
+ discounts: [freeShippingDiscount],
+ }),
+ ]);
+ });
+
+ it('clears shipping-line discounts that are missing from the response when codes are removed', () => {
+ const queryClient = new QueryClient();
+ seedDraftOrder(queryClient);
+ updateDiscountCache(
+ queryClient,
+ 'session-1',
+ {
+ shippingLines: [
+ { id: 'shipping-a', discounts: [freeShippingDiscount] },
+ ],
+ } as unknown as UpdatedOrder,
+ ['freeship']
+ );
+
+ updateDiscountCache(
+ queryClient,
+ 'session-1',
+ { shippingLines: [] } as unknown as UpdatedOrder,
+ []
+ );
+
+ expect(cachedShippingLines(queryClient)).toEqual([
+ expect.objectContaining({ id: 'shipping-a', discounts: [] }),
+ expect.objectContaining({ id: 'shipping-b', discounts: [] }),
+ ]);
+ });
+});
diff --git a/packages/react/src/components/checkout/discount/utils/use-apply-discount-core.ts b/packages/react/src/components/checkout/discount/utils/use-apply-discount-core.ts
new file mode 100644
index 000000000..26567e121
--- /dev/null
+++ b/packages/react/src/components/checkout/discount/utils/use-apply-discount-core.ts
@@ -0,0 +1,136 @@
+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
+>;
+export 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 => {
+ const updatedShippingLine = updatedOrder.shippingLines?.find(
+ shippingLine => shippingLine.id === currentShippingLine.id
+ );
+
+ 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 440cf1d89..924961980 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,181 +1,15 @@
-import { useMutation, useQueryClient } from '@tanstack/react-query';
-import type { ResultOf } from 'gql.tada';
-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 { useApplyDiscountCore } from './use-apply-discount-core';
+import { useReconcileAfterDiscount } from './use-reconcile-after-discount';
export function useDiscountApply() {
- const { session, jwt } = useCheckoutContext();
- const { apiHost } = useGoDaddyContext();
- const form = useFormContext();
- const queryClient = useQueryClient();
- const updateTaxes = useUpdateTaxes();
- const { data: draftOrder } = useDraftOrder();
-
- 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 }) => {
- if (!session) return;
-
- const discountTotal =
- data?.applyCheckoutSessionDiscount?.totals?.discountTotal;
- const responseData = data?.applyCheckoutSessionDiscount;
- // Update the cached draft-order query (includes totals)
-
- 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,
- },
- },
- };
- }
- );
- }
-
- 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 (session.enableTaxCollection) {
- // TODO: Move this to API layer
- const deliveryMethod = form.getValues('deliveryMethod');
-
- 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 {
- const shippingAddress = draftOrder?.shipping?.address;
-
- if (shippingAddress?.postalCode && shippingAddress?.countryCode) {
- await updateTaxes.mutateAsync(undefined);
- return;
- }
- }
- }
-
- await queryClient.invalidateQueries({
- queryKey: checkoutQueryKeys.draftOrder(session.id),
- });
+ const reconcileAfterDiscount = useReconcileAfterDiscount();
+
+ return useApplyDiscountCore({
+ onSuccess: async (data, variables) => {
+ await reconcileAfterDiscount(
+ variables,
+ data.applyCheckoutSessionDiscount
+ );
},
});
}
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 000000000..2a6573487
--- /dev/null
+++ b/packages/react/src/components/checkout/discount/utils/use-reconcile-after-discount.ts
@@ -0,0 +1,210 @@
+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 {
+ getCurrentShippingServiceCode,
+ 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 { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors';
+import { getProductDiscountTotal } from './get-product-discount-total';
+import {
+ type ApplyDiscountVariables,
+ type DiscountOrder,
+ useApplyDiscountCore,
+} from './use-apply-discount-core';
+
+export function useReconcileAfterDiscount() {
+ const { session, setCheckoutErrors } = 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,
+ updatedOrder?: DiscountOrder | null
+ ) => {
+ if (!session) return;
+
+ const deliveryMethod = form.getValues('deliveryMethod');
+ const shippingAddress = draftOrder?.shipping?.address;
+ const hasShippingDestination = Boolean(
+ shippingAddress?.addressLine1 &&
+ shippingAddress.postalCode &&
+ shippingAddress.countryCode
+ );
+
+ // A coupon can only change shipping rates through the product discount,
+ // the one coupon input to checkout-api's rate request (adjustedOrderTotal).
+ // A failed rates query is refetched regardless so the coupon acts as a retry.
+ const productDiscountChanged =
+ !updatedOrder ||
+ getProductDiscountTotal(updatedOrder) !==
+ getProductDiscountTotal(draftOrder);
+
+ if (
+ deliveryMethod === DeliveryMethods.SHIP &&
+ hasShippingDestination &&
+ (productDiscountChanged || shippingMethodsQuery.isError)
+ ) {
+ const previousShippingMethods = shippingMethodsQuery.data ?? [];
+ const { data, isError } = await shippingMethodsQuery.refetch();
+ // A failed refresh is treated as "no rates": the coupon may have changed
+ // what the saved line should cost, so the line is cleared rather than kept
+ // unverified. Confirm's shipping guard then blocks payment from the real
+ // order state until the customer retries and a method is re-applied.
+ const refreshedMethods = isError ? [] : (data ?? []);
+ const isAutoSelected = Boolean(
+ form.getValues('shippingMethodAutoSelected')
+ );
+ const currentServiceCode = getCurrentShippingServiceCode({
+ formServiceCode: form.getValues('shippingMethod'),
+ shippingLineServiceCode:
+ draftOrder?.shippingLines?.[0]?.requestedService,
+ isAutoSelected,
+ });
+ const shippingRequiresReconciliation = requiresShippingReconciliation({
+ shippingMethods: refreshedMethods,
+ previousShippingMethods,
+ currentShippingLine: draftOrder?.shippingLines?.[0],
+ selectedServiceCode: currentServiceCode,
+ isAutoSelected,
+ });
+
+ if (shippingRequiresReconciliation) {
+ const { selectedMethod, autoSelected } = selectShippingMethod({
+ shippingMethods: refreshedMethods,
+ currentServiceCode,
+ previousShippingMethods,
+ isAutoSelected,
+ });
+
+ try {
+ await applyShippingMethod.mutateAsync(
+ selectedMethod ? buildShippingPayload(selectedMethod) : []
+ );
+ setCheckoutErrors(undefined);
+ setShippingSelection(selectedMethod?.serviceCode, autoSelected);
+ } catch (error) {
+ setCheckoutErrors(
+ getErrorCodes(error, 'SHIPPING_METHOD_APPLICATION_FAILED')
+ );
+
+ // The failed reapplication left the order's shipping line stale
+ // (possibly no longer valid, e.g. a coupon that granted it is gone).
+ // Clear it server-side so the order itself is no longer stale,
+ // rather than just invalidating caches around unchanged data. If the
+ // failed attempt was already a clear (no selectedMethod), retrying
+ // the identical call would just duplicate the request for no gain.
+ if (selectedMethod) {
+ try {
+ await applyShippingMethod.mutateAsync([]);
+ setShippingSelection(undefined, false);
+ } catch {
+ // Clearing failed too; the refresh below still re-syncs mounted
+ // views against whatever the server has.
+ }
+ }
+
+ await queryClient.invalidateQueries({
+ queryKey: checkoutQueryKeys.draftOrderShippingMethods(session.id),
+ });
+ // Shipping may have changed or been cleared, so totals and taxes
+ // still need reconciling against the order as it now stands.
+ await refreshTotals();
+ return;
+ }
+
+ if (session.enablePromotionCodes && variables.discountCodes?.length) {
+ try {
+ await reapplyDiscount.mutateAsync(variables);
+ } catch (error) {
+ // The coupon was applied; only the post-shipping re-apply failed
+ // (e.g. it no longer qualifies at the new shipping price). Surface
+ // it without failing the original apply, and still reconcile totals.
+ setCheckoutErrors(
+ getErrorCodes(error, 'DISCOUNT_APPLICATION_FAILED')
+ );
+ }
+ }
+
+ await refreshTotals();
+ 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),
+ });
+ }
+
+ // Taxes are recalculated from the order's saved shipping line and address.
+ function refreshTotals() {
+ return session?.enableTaxCollection
+ ? updateTaxes.mutateAsync(undefined)
+ : invalidateDraftOrder();
+ }
+
+ function setShippingSelection(
+ serviceCode: string | null | undefined,
+ autoSelected: boolean
+ ) {
+ form.setValue('shippingMethod', serviceCode ?? '', { shouldDirty: false });
+ // A cleared selection has no origin, so it's never "auto selected".
+ form.setValue(
+ 'shippingMethodAutoSelected',
+ Boolean(serviceCode) && autoSelected,
+ {
+ shouldDirty: false,
+ }
+ );
+ }
+}
+
+function getErrorCodes(error: unknown, fallback: string) {
+ return error instanceof GraphQLErrorWithCodes && error.codes.length > 0
+ ? error.codes
+ : [fallback];
+}
diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.test.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.test.tsx
new file mode 100644
index 000000000..b8aaba614
--- /dev/null
+++ b/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.test.tsx
@@ -0,0 +1,297 @@
+import { enUs } from '@godaddy/localizations';
+import { act, render, waitFor } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import type { DraftOrder } from '@/types';
+import { ExpressCheckoutButton } from './godaddy';
+
+type Handler = (event: Record) => Promise | void;
+
+const mocks = vi.hoisted(() => ({
+ draftOrder: null as unknown,
+ getPriceAdjustments: vi.fn(),
+ getShippingMethods: vi.fn(),
+ getTaxes: vi.fn(),
+ setCheckoutErrors: vi.fn(),
+ mountOptions: null as null | {
+ buttonOptions: { onClick: (args: { source?: string }) => unknown };
+ },
+ abortApplePaySession: vi.fn(),
+ tokenizeOptions: [] as Array>,
+ handlers: {} as Record,
+}));
+
+vi.mock('@/components/checkout/checkout', () => ({
+ useCheckoutContext: () => ({
+ session: {
+ id: 'session-1',
+ storeId: 'store-1',
+ channelId: 'channel-1',
+ enablePromotionCodes: true,
+ enableShippingAddressCollection: true,
+ enableTaxCollection: false,
+ },
+ godaddyPaymentsConfig: { appId: 'app-1', businessId: 'business-1' },
+ setCheckoutErrors: mocks.setCheckoutErrors,
+ isConfirmingCheckout: false,
+ }),
+}));
+vi.mock('@/components/checkout/order/use-draft-order', () => ({
+ useDraftOrder: () => ({ data: mocks.draftOrder }),
+ useDraftOrderTotals: () => ({
+ data: (mocks.draftOrder as DraftOrder | null)?.totals,
+ }),
+}));
+vi.mock(
+ '@/components/checkout/discount/utils/use-get-price-adjustments',
+ () => ({
+ useGetPriceAdjustments: () => ({ mutateAsync: mocks.getPriceAdjustments }),
+ })
+);
+vi.mock(
+ '@/components/checkout/shipping/utils/use-get-shipping-methods',
+ () => ({
+ useGetShippingMethodByAddress: () => ({
+ mutateAsync: mocks.getShippingMethods,
+ }),
+ })
+);
+vi.mock('@/components/checkout/taxes/utils/use-get-taxes', () => ({
+ useGetTaxes: () => ({ mutateAsync: mocks.getTaxes }),
+}));
+vi.mock('@/components/checkout/payment/utils/use-load-poynt-collect', () => ({
+ useLoadPoyntCollect: () => ({ isPoyntLoaded: true }),
+}));
+vi.mock(
+ '@/components/checkout/payment/utils/use-build-payment-request',
+ () => ({
+ useBuildPaymentRequest: () => ({
+ poyntExpressRequest: {
+ lineItems: [],
+ total: { label: 'Total', amount: '25.00' },
+ },
+ }),
+ })
+);
+vi.mock(
+ '@/components/checkout/payment/utils/use-confirm-express-checkout',
+ () => ({ useConfirmExpressCheckout: () => ({ mutateAsync: vi.fn() }) })
+);
+vi.mock('@/components/checkout/payment/utils/use-is-payment-disabled', () => ({
+ useIsPaymentDisabled: () => false,
+}));
+vi.mock('@/godaddy-provider', () => ({
+ useGoDaddyContext: () => ({ t: enUs, locale: 'en-US' }),
+}));
+vi.mock('@/tracking/track', async importOriginal => ({
+ ...(await importOriginal()),
+ track: vi.fn(),
+}));
+
+class FakeTokenizeJs {
+ constructor(_config: unknown, options: Record) {
+ mocks.tokenizeOptions.push(options);
+ }
+ supportWalletPayments() {
+ return Promise.resolve({ applePay: true, googlePay: false });
+ }
+ mount(_id: string, _document: Document, options: typeof mocks.mountOptions) {
+ mocks.mountOptions = options;
+ }
+ startApplePaySession() {
+ return undefined;
+ }
+ startGooglePaySession() {
+ return undefined;
+ }
+ abortApplePaySession() {
+ mocks.abortApplePaySession();
+ }
+ on(event: string, handler: Handler) {
+ mocks.handlers[event] = handler;
+ }
+}
+
+function draftOrder({
+ subTotal = 2500,
+ id = 'draft-order-1',
+ shippingOnly = false,
+}: {
+ subTotal?: number;
+ id?: string;
+ shippingOnly?: boolean;
+} = {}) {
+ if (shippingOnly) {
+ return {
+ id,
+ totals: {
+ subTotal: { value: subTotal, currencyCode: 'USD' },
+ total: { value: subTotal, currencyCode: 'USD' },
+ },
+ discounts: [],
+ lineItems: [{ id: 'line-1', discounts: [] }],
+ shippingLines: [
+ { discounts: [{ code: 'FREESHIP', amount: { value: 1000 } }] },
+ ],
+ } as unknown as DraftOrder;
+ }
+
+ return {
+ id,
+ totals: {
+ subTotal: { value: subTotal, currencyCode: 'USD' },
+ total: { value: subTotal, currencyCode: 'USD' },
+ },
+ discounts: [{ code: 'SAVE5', amount: { value: 500 } }],
+ lineItems: [
+ { id: 'line-1', discounts: [{ code: 'BIG', amount: { value: 400 } }] },
+ { id: 'line-2', discounts: [{ code: 'BIG', amount: { value: 400 } }] },
+ ],
+ shippingLines: [],
+ } as unknown as DraftOrder;
+}
+
+function rate(serviceCode: string, value: number) {
+ return {
+ serviceCode,
+ displayName: serviceCode,
+ carrierCode: 'carrier',
+ description: null,
+ cost: { value, currencyCode: 'USD' },
+ };
+}
+
+describe('ExpressCheckoutButton coupon sync', () => {
+ beforeEach(() => {
+ mocks.draftOrder = draftOrder();
+ mocks.tokenizeOptions = [];
+ mocks.handlers = {};
+ mocks.getPriceAdjustments.mockReset().mockResolvedValue({
+ totalDiscountAmount: { value: 800, currencyCode: 'USD' },
+ });
+ mocks.getShippingMethods.mockReset();
+ mocks.setCheckoutErrors.mockReset();
+ mocks.abortApplePaySession.mockReset();
+ mocks.mountOptions = null;
+ mocks.getTaxes.mockReset();
+ (window as unknown as { TokenizeJs: unknown }).TokenizeJs = FakeTokenizeJs;
+ });
+
+ it('sends the highest-value coupon code to the wallet', async () => {
+ render();
+
+ await waitFor(() => expect(mocks.tokenizeOptions).toHaveLength(1));
+ expect(mocks.getPriceAdjustments).toHaveBeenCalledWith({
+ discountCodes: ['BIG'],
+ });
+ expect(mocks.tokenizeOptions[0].couponCode).toMatchObject({ code: 'BIG' });
+ });
+
+ it('does not prefill a shipping-only coupon before the wallet has shipping', async () => {
+ mocks.draftOrder = draftOrder({ shippingOnly: true });
+ render();
+
+ await waitFor(() => expect(mocks.tokenizeOptions).toHaveLength(1));
+ expect(mocks.getPriceAdjustments).not.toHaveBeenCalled();
+ expect(mocks.tokenizeOptions[0].couponCode).toBeUndefined();
+ });
+
+ it('recomputes adjustments when the subtotal changes, not on unrelated order updates', async () => {
+ const { rerender } = render();
+ await waitFor(() => expect(mocks.getPriceAdjustments).toHaveBeenCalled());
+
+ mocks.draftOrder = draftOrder({ id: 'draft-order-1' });
+ rerender();
+ await act(() => Promise.resolve());
+ expect(mocks.getPriceAdjustments).toHaveBeenCalledTimes(1);
+
+ mocks.draftOrder = draftOrder({ subTotal: 5000 });
+ rerender();
+ await waitFor(() =>
+ expect(mocks.getPriceAdjustments).toHaveBeenCalledTimes(2)
+ );
+ });
+
+ it('keeps the wallet shipping methods when a coupon changes', async () => {
+ render();
+ await waitFor(() =>
+ expect(mocks.handlers.shipping_address_change).toBeDefined()
+ );
+
+ mocks.getShippingMethods.mockResolvedValueOnce([rate('standard', 1000)]);
+ await act(async () => {
+ await mocks.handlers.shipping_address_change({
+ shippingAddress: { countryCode: 'US', postalCode: '85001' },
+ updateWith: vi.fn(),
+ });
+ });
+ expect(mocks.getShippingMethods).toHaveBeenCalledTimes(1);
+
+ const updateWith = vi.fn();
+ await act(async () => {
+ await mocks.handlers.coupon_code_change({ couponCode: '', updateWith });
+ });
+
+ expect(mocks.getShippingMethods).toHaveBeenCalledTimes(1);
+ expect(updateWith).toHaveBeenCalledTimes(1);
+ expect(updateWith.mock.calls[0][0]).not.toHaveProperty('shippingMethods');
+ });
+
+ async function openWallet(source: 'apple_pay' | 'google_pay') {
+ render();
+ await waitFor(() => expect(mocks.mountOptions).not.toBeNull());
+ await act(async () => {
+ await mocks.mountOptions?.buttonOptions.onClick({ source });
+ });
+ }
+
+ async function changeAddress() {
+ const updateWith = vi.fn();
+ await act(async () => {
+ await mocks.handlers.shipping_address_change({
+ shippingAddress: { countryCode: 'US', postalCode: '85001' },
+ updateWith,
+ });
+ });
+ return updateWith;
+ }
+
+ it('tells the wallet no shipping methods were found when there are no rates', async () => {
+ await openWallet('google_pay');
+ mocks.getShippingMethods.mockResolvedValueOnce([]);
+
+ const updateWith = await changeAddress();
+
+ expect(updateWith).toHaveBeenCalledWith({
+ error: {
+ code: 'invalid_shipping_address',
+ message: enUs.shipping.noShippingMethods,
+ },
+ });
+ });
+
+ it('tells the wallet shipping methods failed to load when the request fails', async () => {
+ await openWallet('google_pay');
+ mocks.getShippingMethods.mockRejectedValueOnce(new Error('rates failed'));
+
+ const updateWith = await changeAddress();
+
+ expect(updateWith).toHaveBeenCalledOnce();
+ expect(updateWith).toHaveBeenCalledWith({
+ error: { code: 'unknown', message: enUs.shipping.failedToLoadMethods },
+ });
+ expect(mocks.abortApplePaySession).not.toHaveBeenCalled();
+ });
+
+ it('closes the Apple Pay sheet and shows the error when the rates request fails', async () => {
+ await openWallet('apple_pay');
+ mocks.getShippingMethods.mockRejectedValueOnce(new Error('rates failed'));
+
+ const updateWith = await changeAddress();
+
+ expect(updateWith).not.toHaveBeenCalled();
+ expect(mocks.abortApplePaySession).toHaveBeenCalledOnce();
+ expect(mocks.setCheckoutErrors).toHaveBeenLastCalledWith([
+ enUs.shipping.failedToLoadMethods,
+ ]);
+ });
+});
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 c71f6c9ce..b22c82f1f 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 { getHighestValueDraftOrderDiscountCode } 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 {
@@ -94,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 ({
@@ -144,13 +146,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 +173,7 @@ export function ExpressCheckoutButton() {
return methods;
},
- [getShippingMethodsByAddress.mutateAsync, session, totals]
+ [getShippingMethodsByAddress.mutateAsync, currencyCode, formatCurrency]
);
const handleExpressPayClick = useCallback(
@@ -306,95 +302,59 @@ export function ExpressCheckoutButton() {
const [couponFetchStatus, setCouponFetchStatus] = useState<
'idle' | 'fetching' | 'done'
>('idle');
+ const [couponSyncRevision, setCouponSyncRevision] = useState(0);
- // 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 primaryDiscountCode = useMemo(
+ () => getHighestValueDraftOrderDiscountCode(draftOrder),
+ [draftOrder]
+ );
+ // Adjustments depend on the subtotal too, e.g. a percentage discount.
+ const couponSyncKey = JSON.stringify([
+ primaryDiscountCode ?? null,
+ totals?.subTotal?.value ?? null,
+ ]);
+ const hasDraftOrder = Boolean(draftOrder);
+ const areCouponAdjustmentsReady =
+ !primaryDiscountCode || couponFetchStatus === 'done';
useEffect(() => {
- if (!draftOrder) return;
- // Prevent concurrent fetches (but allow new fetches when draft order changes)
- if (couponFetchStatus === 'fetching') return;
+ if (!hasDraftOrder) return;
- const fetchPriceAdjustments = async () => {
- setCouponFetchStatus('fetching');
+ const requestId = ++couponSyncRequestRef.current;
+ const couponCode = primaryDiscountCode;
+ setCouponFetchStatus('fetching');
+ const syncPriceAdjustments = async () => {
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);
- }
- }
+ if (!couponCode) {
+ appliedCouponCodeRef.current = null;
+ calculatedAdjustmentsRef.current = null;
+ return;
}
- // 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 result = await getPriceAdjustments.mutateAsync({
+ discountCodes: [couponCode],
+ });
- const discountCodes = Array.from(allCodes);
+ if (requestId !== couponSyncRequestRef.current) return;
- // Update refs based on what's in the draft order
- if (discountCodes?.length && discountCodes?.[0]) {
- const result = await getPriceAdjustments.mutateAsync({
- discountCodes: [discountCodes?.[0]],
- });
+ appliedCouponCodeRef.current = result ? couponCode : null;
+ calculatedAdjustmentsRef.current = result ?? null;
+ } catch {
+ if (requestId !== couponSyncRequestRef.current) return;
- if (result) {
- // Update refs with current coupon state
- appliedCouponCodeRef.current = discountCodes?.[0];
- calculatedAdjustmentsRef.current = result;
- }
- } else {
- // No coupons in draft order - clear refs
- appliedCouponCodeRef.current = null;
- calculatedAdjustmentsRef.current = null;
- }
+ 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
- }, [draftOrder, draftOrderDiscountCodes]);
+ }, [hasDraftOrder, couponSyncKey, couponSyncRevision]);
// Initialize the TokenizeJs instance when the component mounts
// But only after price adjustments have been fetched
@@ -406,7 +366,7 @@ export function ExpressCheckoutButton() {
!isCollectLoading ||
!draftOrder ||
hasMounted.current ||
- couponFetchStatus !== 'done'
+ !areCouponAdjustmentsReady
)
return;
@@ -501,7 +461,7 @@ export function ExpressCheckoutButton() {
businessId,
isCollectLoading,
draftOrder,
- couponFetchStatus,
+ areCouponAdjustmentsReady,
countryCode,
currencyCode,
session?.storeId,
@@ -546,6 +506,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
@@ -1241,9 +1202,31 @@ export function ExpressCheckoutButton() {
// });
setShippingAddress(e.shippingAddress);
- const methods = await getSortedShippingMethods({
- shippingAddress: e.shippingAddress,
- });
+ let methods: Awaited>;
+ try {
+ methods = await getSortedShippingMethods({
+ shippingAddress: e.shippingAddress,
+ });
+ } catch {
+ // Empty or null rates mean "no methods"; only a failed request lands
+ // here, so the wallet must still get a response or the sheet hangs.
+ setGoDaddyTotals(value => ({
+ ...value,
+ shipping: { currencyCode, value: 0 },
+ }));
+ if (walletSource !== 'apple_pay') {
+ e.updateWith({
+ error: {
+ code: 'unknown',
+ message: t.shipping.failedToLoadMethods,
+ },
+ });
+ return;
+ }
+ setCheckoutErrors([t.shipping.failedToLoadMethods]);
+ collect?.current?.abortApplePaySession();
+ return;
+ }
if (methods[0]) {
setShippingMethod(methods[0]?.label || null);
diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.test.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.test.tsx
new file mode 100644
index 000000000..29d50e676
--- /dev/null
+++ b/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.test.tsx
@@ -0,0 +1,167 @@
+import { enUs } from '@godaddy/localizations';
+import { act, render, waitFor } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import type { DraftOrder } from '@/types';
+import { StripeExpressCheckoutForm } from './stripe';
+
+const mocks = vi.hoisted(() => ({
+ draftOrder: null as unknown,
+ getPriceAdjustments: vi.fn(),
+ getShippingMethods: vi.fn(),
+ elementProps: {} as Record unknown>,
+}));
+
+vi.mock('@stripe/react-stripe-js', () => ({
+ ExpressCheckoutElement: (
+ props: Record unknown>
+ ) => {
+ mocks.elementProps = props;
+ return null;
+ },
+ useElements: () => ({ update: vi.fn() }),
+}));
+vi.mock('@/components/checkout/checkout', () => ({
+ useCheckoutContext: () => ({
+ session: {
+ id: 'session-1',
+ enablePromotionCodes: true,
+ enableShippingAddressCollection: true,
+ enableTaxCollection: false,
+ },
+ setCheckoutErrors: vi.fn(),
+ isConfirmingCheckout: false,
+ }),
+}));
+vi.mock('@/components/checkout/order/use-draft-order', () => ({
+ useDraftOrder: () => ({ data: mocks.draftOrder }),
+ useDraftOrderTotals: () => ({
+ data: (mocks.draftOrder as DraftOrder | null)?.totals,
+ }),
+}));
+vi.mock(
+ '@/components/checkout/discount/utils/use-get-price-adjustments',
+ () => ({
+ useGetPriceAdjustments: () => ({ mutateAsync: mocks.getPriceAdjustments }),
+ })
+);
+vi.mock(
+ '@/components/checkout/shipping/utils/use-get-shipping-methods',
+ () => ({
+ useGetShippingMethodByAddress: () => ({
+ mutateAsync: mocks.getShippingMethods,
+ }),
+ })
+);
+vi.mock('@/components/checkout/taxes/utils/use-get-taxes', () => ({
+ useGetTaxes: () => ({ mutateAsync: vi.fn() }),
+}));
+vi.mock('@/components/checkout/payment/utils/use-stripe-checkout', () => ({
+ useStripeCheckout: () => ({ handleSubmit: vi.fn() }),
+}));
+vi.mock('@/components/checkout/payment/utils/use-is-payment-disabled', () => ({
+ useIsPaymentDisabled: () => false,
+}));
+vi.mock('@/godaddy-provider', () => ({
+ useGoDaddyContext: () => ({ t: enUs, locale: 'en-US' }),
+}));
+vi.mock('@/tracking/track', async importOriginal => ({
+ ...(await importOriginal()),
+ track: vi.fn(),
+}));
+
+function draftOrder({
+ subTotal = 2500,
+ lineCode = 'BIG',
+}: {
+ subTotal?: number;
+ lineCode?: string;
+} = {}) {
+ return {
+ id: 'draft-order-1',
+ totals: {
+ subTotal: { value: subTotal, currencyCode: 'USD' },
+ total: { value: subTotal, currencyCode: 'USD' },
+ },
+ discounts: [{ code: 'SAVE5', amount: { value: 500 } }],
+ lineItems: [
+ { id: 'line-1', discounts: [{ code: lineCode, amount: { value: 800 } }] },
+ ],
+ shippingLines: [],
+ } as unknown as DraftOrder;
+}
+
+describe('StripeExpressCheckoutForm coupon sync', () => {
+ beforeEach(() => {
+ mocks.draftOrder = draftOrder();
+ mocks.elementProps = {};
+ mocks.getPriceAdjustments.mockReset().mockResolvedValue({
+ totalDiscountAmount: { value: 800, currencyCode: 'USD' },
+ });
+ mocks.getShippingMethods.mockReset().mockResolvedValue([
+ {
+ serviceCode: 'standard',
+ displayName: 'Standard',
+ carrierCode: 'carrier',
+ description: null,
+ cost: { value: 1000, currencyCode: 'USD' },
+ },
+ ]);
+ });
+
+ it('uses the highest-value coupon code', async () => {
+ render();
+
+ await waitFor(() =>
+ expect(mocks.getPriceAdjustments).toHaveBeenCalledWith({
+ discountCodes: ['BIG'],
+ })
+ );
+ });
+
+ it('recomputes adjustments when the subtotal changes, not on unrelated order updates', async () => {
+ const { rerender } = render();
+ await waitFor(() => expect(mocks.getPriceAdjustments).toHaveBeenCalled());
+
+ mocks.draftOrder = draftOrder();
+ rerender();
+ await act(() => Promise.resolve());
+ expect(mocks.getPriceAdjustments).toHaveBeenCalledTimes(1);
+
+ mocks.draftOrder = draftOrder({ subTotal: 5000 });
+ rerender();
+ await waitFor(() =>
+ expect(mocks.getPriceAdjustments).toHaveBeenCalledTimes(2)
+ );
+ });
+
+ it('does not refetch or reselect shipping when the coupon changes while the sheet is open', async () => {
+ const { rerender } = render();
+ await waitFor(() => expect(mocks.getPriceAdjustments).toHaveBeenCalled());
+
+ await act(async () => {
+ await mocks.elementProps.onShippingAddressChange({
+ address: {
+ city: 'Phoenix',
+ state: 'AZ',
+ postal_code: '85001',
+ country: 'US',
+ },
+ resolve: vi.fn(),
+ reject: vi.fn(),
+ });
+ });
+ expect(mocks.getShippingMethods).toHaveBeenCalledTimes(1);
+ const priceAdjustmentCalls = mocks.getPriceAdjustments.mock.calls.length;
+
+ mocks.draftOrder = draftOrder({ lineCode: 'BIGGER' });
+ rerender();
+ await waitFor(() =>
+ expect(mocks.getPriceAdjustments).toHaveBeenCalledTimes(
+ priceAdjustmentCalls + 1
+ )
+ );
+ await act(() => Promise.resolve());
+
+ expect(mocks.getShippingMethods).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.tsx
index 421077159..a32c8f367 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 { getHighestValueDraftOrderDiscountCode } 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';
@@ -74,104 +75,55 @@ 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);
- // 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 primaryDiscountCode = useMemo(
+ () => getHighestValueDraftOrderDiscountCode(draftOrder),
+ [draftOrder]
+ );
+ // Adjustments depend on the subtotal too, e.g. a percentage discount.
+ const couponSyncKey = JSON.stringify([
+ primaryDiscountCode ?? null,
+ totals?.subTotal?.value ?? null,
+ ]);
+ const hasDraftOrder = Boolean(draftOrder);
- // 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;
-
- const fetchPriceAdjustments = async () => {
- setCouponFetchStatus('fetching');
+ if (!hasDraftOrder) return;
- try {
- const allCodes = new Set();
+ const requestId = ++couponSyncRequestRef.current;
+ const couponCode = primaryDiscountCode;
- // Add order-level discount codes
- if (draftOrder?.discounts) {
- for (const discount of draftOrder.discounts) {
- if (discount.code) {
- allCodes.add(discount.code);
- }
- }
- }
+ const syncPriceAdjustments = async () => {
+ if (!couponCode) {
+ appliedCouponCodeRef.current = null;
+ calculatedAdjustmentsRef.current = null;
+ return;
+ }
- // 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);
- }
- }
- }
- }
- }
+ try {
+ const result = await getPriceAdjustments.mutateAsync({
+ discountCodes: [couponCode],
+ });
- const discountCodes = Array.from(allCodes);
+ if (requestId !== couponSyncRequestRef.current) return;
- // Update refs based on what's in the draft order
- if (discountCodes?.length && discountCodes?.[0]) {
- const result = await getPriceAdjustments.mutateAsync({
- discountCodes: [discountCodes[0]],
- });
+ appliedCouponCodeRef.current = result ? couponCode : null;
+ calculatedAdjustmentsRef.current = result ?? null;
+ } catch {
+ if (requestId !== couponSyncRequestRef.current) return;
- if (result) {
- // Update refs with current coupon state
- appliedCouponCodeRef.current = discountCodes[0];
- calculatedAdjustmentsRef.current = result;
- }
- } else {
- // No coupons in draft order - clear refs
- appliedCouponCodeRef.current = null;
- calculatedAdjustmentsRef.current = null;
- }
- } finally {
- setCouponFetchStatus('done');
+ appliedCouponCodeRef.current = null;
+ calculatedAdjustmentsRef.current = null;
}
};
- fetchPriceAdjustments();
+ syncPriceAdjustments();
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [draftOrder, draftOrderDiscountCodes]);
+ }, [hasDraftOrder, couponSyncKey]);
// Calculate taxes for express checkout
const calculateExpressTaxes = useCallback(
@@ -224,19 +176,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/payment/utils/use-flush-checkout-sync.test.tsx b/packages/react/src/components/checkout/payment/utils/use-flush-checkout-sync.test.tsx
index bbf5ea1bc..06acf34d8 100644
--- a/packages/react/src/components/checkout/payment/utils/use-flush-checkout-sync.test.tsx
+++ b/packages/react/src/components/checkout/payment/utils/use-flush-checkout-sync.test.tsx
@@ -1,3 +1,4 @@
+import { QueryObserver } from '@tanstack/react-query';
import { act, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
@@ -151,6 +152,36 @@ async function clickFlush(user: ReturnType) {
}
describe('useFlushCheckoutSync', () => {
+ it('waits for a failed shipping refresh without validating checkout state', async () => {
+ const onCheckoutErrors = vi.fn();
+ const { user, queryClient } = renderHookHost({
+ onCheckoutErrors,
+ timeoutMs: 1000,
+ });
+ const request = deferred();
+ const observer = new QueryObserver(queryClient, {
+ queryKey:
+ checkoutQueryKeys.draftOrderShippingMethods('checkout-session-1'),
+ queryFn: () => request.promise,
+ retry: false,
+ });
+ const unsubscribe = observer.subscribe(() => undefined);
+ try {
+ await clickFlush(user);
+ await act(async () => {
+ request.reject(new Error('Provider unavailable'));
+ await flushPromises();
+ await vi.advanceTimersByTimeAsync(60);
+ });
+ await waitFor(() =>
+ expect(screen.getByTestId('result')).toHaveTextContent('resolved')
+ );
+ expect(onCheckoutErrors).not.toHaveBeenCalled();
+ } finally {
+ unsubscribe();
+ }
+ });
+
it('excludes tax mutation waits when tax collection is disabled', async () => {
const { user } = renderHookHost({
enableTaxCollection: false,
diff --git a/packages/react/src/components/checkout/payment/utils/use-flush-checkout-sync.ts b/packages/react/src/components/checkout/payment/utils/use-flush-checkout-sync.ts
index 634d5de25..c5615d1c0 100644
--- a/packages/react/src/components/checkout/payment/utils/use-flush-checkout-sync.ts
+++ b/packages/react/src/components/checkout/payment/utils/use-flush-checkout-sync.ts
@@ -43,10 +43,7 @@ export function useFlushCheckoutSync() {
? [checkoutMutationKeys.updateDraftOrderTaxes(sessionId)]
: []),
...(session?.enableShipping
- ? [
- checkoutMutationKeys.applyShippingMethod(sessionId),
- checkoutMutationKeys.removeShippingMethod(sessionId),
- ]
+ ? [checkoutMutationKeys.applyShippingMethod(sessionId)]
: []),
];
diff --git a/packages/react/src/components/checkout/shipping/shipping-method.tsx b/packages/react/src/components/checkout/shipping/shipping-method.tsx
index 29fbd2dd7..90ca4e2d3 100644
--- a/packages/react/src/components/checkout/shipping/shipping-method.tsx
+++ b/packages/react/src/components/checkout/shipping/shipping-method.tsx
@@ -1,3 +1,4 @@
+import { useIsMutating } from '@tanstack/react-query';
import { useEffect, useRef } from 'react';
import { useFormContext } from 'react-hook-form';
import { useCheckoutContext } from '@/components/checkout/checkout';
@@ -6,19 +7,25 @@ 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 { buildShippingPayload } from '@/components/checkout/shipping/utils/build-shipping-payload';
+import {
+ getCurrentShippingServiceCode,
+ 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';
+import { checkoutMutationKeys } from '@/components/checkout/utils/query-keys';
+import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { useGoDaddyContext } from '@/godaddy-provider';
@@ -27,25 +34,6 @@ 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();
@@ -54,11 +42,15 @@ export function ShippingMethodForm() {
const updateTaxes = useUpdateTaxes();
const isPaymentDisabled = useIsPaymentDisabled();
- const { data: shippingMethodsData, isLoading: isShippingMethodsLoading } =
- useDraftOrderShippingMethods();
+ const {
+ data: shippingMethodsData,
+ isError: isShippingMethodsError,
+ isLoading: isShippingMethodsLoading,
+ isFetching: isShippingMethodsFetching,
+ refetch: refetchShippingMethods,
+ } = useDraftOrderShippingMethods();
const { data: shippingAddress, isLoading: isShippingAddressLoading } =
useDraftOrderShippingAddress();
- const { data: totals } = useDraftOrderTotals();
const { data: order, isLoading: isDraftOrderLoading } = useDraftOrder();
const { data: shippingLines } = useDraftOrderShipping();
@@ -71,15 +63,17 @@ 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(
+ isShippingMethodsError ? [] : shippingMethodsData || []
+ );
const applyShippingMethod = useApplyShippingMethod();
+ const isApplyingDiscount =
+ useIsMutating({
+ mutationKey: checkoutMutationKeys.applyDiscount(session?.id),
+ }) > 0;
+ const lastShippingMethodsRef = useRef(null);
+ const wasApplyingDiscountRef = useRef(false);
// Track the last processed state to avoid duplicate API calls
const lastProcessedStateRef = useRef<{
@@ -98,20 +92,32 @@ export function ShippingMethodForm() {
blockedFulfillmentKey: null,
});
- // Only an explicit click in this mount counts as the shopper's choice. A
- // shipping line already on the draft order may have been auto-applied, so
- // it must not keep a free rate from being selected.
- const userSelectedServiceCodeRef = useRef(null);
-
useEffect(() => {
+ if (isApplyingDiscount) {
+ wasApplyingDiscountRef.current = true;
+ if (!isShippingMethodsFetching) {
+ lastShippingMethodsRef.current = shippingMethods;
+ lastProcessedStateRef.current = {
+ ...lastProcessedStateRef.current,
+ serviceCode: shippingLines?.requestedService ?? null,
+ cost: shippingLines?.amount?.value ?? null,
+ hadShippingMethods: shippingMethods.length > 0,
+ };
+ }
+ return;
+ }
+
if (
isShippingMethodsLoading ||
+ isShippingMethodsFetching ||
isDraftOrderLoading ||
isConfirmingCheckout ||
applyShippingMethod.isPending
)
return;
+ const discountJustSettled = wasApplyingDiscountRef.current;
+ wasApplyingDiscountRef.current = false;
const hasShippingMethods = (shippingMethods?.length ?? 0) > 0;
const currentServiceCode = shippingLines?.requestedService || null;
const lastState = lastProcessedStateRef.current;
@@ -128,6 +134,20 @@ export function ShippingMethodForm() {
// Case 1: No shipping methods available - clear shipping and set fulfillment to SHIP
if (!hasShippingMethods && hasShippingAddress) {
+ lastShippingMethodsRef.current = [];
+
+ 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)
@@ -136,8 +156,25 @@ export function ShippingMethodForm() {
: lastState.hadShippingMethods || !lastState.clearedShippingMethod;
if (shouldClearShipping) {
+ const previousShippingMethod =
+ form.getValues('shippingMethod') || currentServiceCode || '';
+ const previousAutoSelected = form.getValues(
+ 'shippingMethodAutoSelected'
+ );
form.setValue('shippingMethod', '', { shouldDirty: false });
- applyShippingMethod.mutate([]);
+ form.setValue('shippingMethodAutoSelected', false, {
+ shouldDirty: false,
+ });
+ applyShippingMethod.mutate([], {
+ onError: () => {
+ form.setValue('shippingMethod', previousShippingMethod, {
+ shouldDirty: false,
+ });
+ form.setValue('shippingMethodAutoSelected', previousAutoSelected, {
+ shouldDirty: false,
+ });
+ },
+ });
lastProcessedStateRef.current = {
serviceCode: null,
cost: null,
@@ -160,31 +197,26 @@ 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;
-
- // 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 userSelectedServiceCode: string | null =
- userSelectedServiceCodeRef.current;
- const userSelectedMethod: ShippingMethod | undefined =
- userSelectedServiceCode
- ? shippingMethods.find(m => m.serviceCode === userSelectedServiceCode)
- : undefined;
- const freeMethod: ShippingMethod | undefined =
- matchedMethod?.cost?.value === 0
- ? matchedMethod
- : shippingMethods.find(m => m.cost?.value === 0);
-
- const methodToApply =
- userSelectedMethod || freeMethod || matchedMethod || firstMethod;
+ const previousAutoSelected = Boolean(
+ form.getValues('shippingMethodAutoSelected')
+ );
+ const existingMethod = getCurrentShippingServiceCode({
+ formServiceCode: currentFormMethod,
+ shippingLineServiceCode: currentServiceCode,
+ isAutoSelected: previousAutoSelected,
+ });
+ const isInitialSelection = lastShippingMethodsRef.current === null;
+ const { selectedMethod: methodToApply, autoSelected } =
+ selectShippingMethod({
+ shippingMethods,
+ currentServiceCode: existingMethod,
+ previousShippingMethods: lastShippingMethodsRef.current,
+ isAutoSelected: previousAutoSelected,
+ });
+ lastShippingMethodsRef.current = shippingMethods;
+
+ 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
@@ -202,6 +234,9 @@ export function ShippingMethodForm() {
form.setValue('shippingMethod', methodToApply.serviceCode, {
shouldDirty: false,
});
+ form.setValue('shippingMethodAutoSelected', autoSelected, {
+ shouldDirty: false,
+ });
if (needsMutation) {
const isFulfillmentSync = Boolean(
@@ -215,16 +250,25 @@ export function ShippingMethodForm() {
};
}
- // On failure, show the rate the draft order still has. The processed
- // state is left as is so the failed rate is not retried in a loop.
+ const previousShippingMethod =
+ currentFormMethod || currentServiceCode || '';
applyShippingMethod.mutate(buildShippingPayload(methodToApply), {
onError: () => {
- form.setValue('shippingMethod', currentServiceCode ?? '', {
+ form.setValue('shippingMethod', previousShippingMethod, {
shouldDirty: false,
});
+ form.setValue(
+ 'shippingMethodAutoSelected',
+ previousAutoSelected,
+ {
+ shouldDirty: false,
+ }
+ );
},
});
- } else if (session?.enableTaxCollection) {
+ } else if (session?.enableTaxCollection && isInitialSelection) {
+ // Refresh taxes for an existing shipping selection on load. Discount
+ // reconciliation already owns the tax refresh when a coupon changes.
updateTaxes.mutate(undefined);
}
@@ -242,10 +286,13 @@ export function ShippingMethodForm() {
}
}, [
isConfirmingCheckout,
+ isApplyingDiscount,
shippingMethods,
shippingLines,
hasShippingAddress,
isShippingMethodsLoading,
+ isShippingMethodsFetching,
+ isShippingMethodsError,
form,
applyShippingMethod,
updateTaxes.mutate,
@@ -272,6 +319,26 @@ export function ShippingMethodForm() {
);
}
+ if (hasShippingAddress && isShippingMethodsError) {
+ return (
+
+
+ {t.shipping.failedToLoadMethods}
+
+
+
+ );
+ }
+
if (
hasShippingAddress &&
!isShippingMethodsLoading &&
@@ -299,19 +366,17 @@ export function ShippingMethodForm() {
}
const previousValue = form.getValues('shippingMethod');
+ const previousAutoSelected = form.getValues('shippingMethodAutoSelected');
const previousProcessedState = lastProcessedStateRef.current;
- const previousUserSelectedServiceCode: string | null =
- userSelectedServiceCodeRef.current;
form.setValue('shippingMethod', value, {
shouldDirty: true,
shouldValidate: true,
});
+ form.setValue('shippingMethodAutoSelected', false);
const method = shippingMethods?.find(m => m.serviceCode === value);
if (method) {
- userSelectedServiceCodeRef.current = method.serviceCode || value;
-
// The mutation success path already recalculates taxes. Optimistically
// record the selected method as processed so the post-mutation draft-order
// refetch does not look like a new unprocessed shipping state and trigger
@@ -342,8 +407,8 @@ export function ShippingMethodForm() {
.mutateAsync(buildShippingPayload(method))
.catch(() => {
form.setValue('shippingMethod', previousValue);
+ form.setValue('shippingMethodAutoSelected', previousAutoSelected);
lastProcessedStateRef.current = previousProcessedState;
- userSelectedServiceCodeRef.current = previousUserSelectedServiceCode;
});
}
};
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 000000000..f393d5b59
--- /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/filter-shipping-methods.ts b/packages/react/src/components/checkout/shipping/utils/filter-shipping-methods.ts
deleted file mode 100644
index 677fed184..000000000
--- 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 000000000..c09825a43
--- /dev/null
+++ b/packages/react/src/components/checkout/shipping/utils/requires-shipping-reconciliation.test.ts
@@ -0,0 +1,262 @@
+import { describe, expect, it } from 'vitest';
+import type { ShippingLines, ShippingMethod } from '@/types';
+import {
+ getCurrentShippingServiceCode,
+ requiresShippingReconciliation,
+ selectShippingMethod,
+} 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);
+ });
+});
+
+describe('selectShippingMethod', () => {
+ const standard = shippingMethod('standard', 500);
+ const express = shippingMethod('express', 2000);
+
+ it('keeps an offered method on first load even when it is not the cheapest', () => {
+ expect(
+ selectShippingMethod({
+ shippingMethods: [standard, express],
+ currentServiceCode: 'express',
+ previousShippingMethods: null,
+ })
+ ).toMatchObject({ selectedMethod: express, autoSelected: false });
+ });
+
+ it("keeps the customer's choice when rates are repriced", () => {
+ expect(
+ selectShippingMethod({
+ shippingMethods: [
+ shippingMethod('standard', 600),
+ shippingMethod('express', 2100),
+ ],
+ currentServiceCode: 'express',
+ previousShippingMethods: [standard, express],
+ isAutoSelected: false,
+ })
+ ).toMatchObject({
+ selectedMethod: { serviceCode: 'express' },
+ autoSelected: false,
+ });
+ });
+
+ it('moves an automatic selection to the cheapest method when rates change', () => {
+ expect(
+ selectShippingMethod({
+ shippingMethods: [shippingMethod('standard', 2500), express],
+ currentServiceCode: 'standard',
+ previousShippingMethods: [standard, express],
+ isAutoSelected: true,
+ })
+ ).toMatchObject({
+ selectedMethod: { serviceCode: 'express' },
+ autoSelected: true,
+ });
+ });
+
+ it('keeps an automatic selection while the rates are unchanged', () => {
+ expect(
+ selectShippingMethod({
+ shippingMethods: [standard, express],
+ currentServiceCode: 'express',
+ previousShippingMethods: [standard, express],
+ isAutoSelected: true,
+ })
+ ).toMatchObject({ selectedMethod: express, autoSelected: true });
+ });
+
+ it("switches a customer's choice to free shipping when it newly appears", () => {
+ const free = shippingMethod('free', 0);
+
+ expect(
+ selectShippingMethod({
+ shippingMethods: [standard, express, free],
+ currentServiceCode: 'express',
+ previousShippingMethods: [standard, express],
+ isAutoSelected: false,
+ })
+ ).toMatchObject({ selectedMethod: free, autoSelected: true });
+ });
+
+ it('detects free shipping by price, not by name or provider', () => {
+ const misleadinglyNamed = {
+ ...shippingMethod('free-shipping', 700),
+ displayName: 'Free Shipping',
+ carrierCode: 'free',
+ };
+ const zeroCostGround = shippingMethod('ground', 0);
+
+ // A paid method named "free" is not free shipping.
+ expect(
+ selectShippingMethod({
+ shippingMethods: [standard, express, misleadinglyNamed],
+ currentServiceCode: 'express',
+ previousShippingMethods: [standard, express],
+ isAutoSelected: false,
+ })
+ ).toMatchObject({ selectedMethod: express, autoSelected: false });
+
+ // A $0 method with an ordinary name is.
+ expect(
+ selectShippingMethod({
+ shippingMethods: [standard, express, zeroCostGround],
+ currentServiceCode: 'express',
+ previousShippingMethods: [standard, express],
+ isAutoSelected: false,
+ })
+ ).toMatchObject({ selectedMethod: zeroCostGround, autoSelected: true });
+ });
+
+ it('falls back to the cheapest method when the current one is gone', () => {
+ expect(
+ selectShippingMethod({
+ shippingMethods: [standard],
+ currentServiceCode: 'express',
+ previousShippingMethods: [standard, express],
+ isAutoSelected: false,
+ })
+ ).toMatchObject({ selectedMethod: standard, autoSelected: true });
+ });
+});
+
+describe('getCurrentShippingServiceCode', () => {
+ it("prefers the form value for a customer's choice", () => {
+ expect(
+ getCurrentShippingServiceCode({
+ formServiceCode: 'express',
+ shippingLineServiceCode: 'standard',
+ isAutoSelected: false,
+ })
+ ).toBe('express');
+ });
+
+ it("prefers the order's shipping line for an automatic pick", () => {
+ expect(
+ getCurrentShippingServiceCode({
+ formServiceCode: 'standard',
+ shippingLineServiceCode: 'express',
+ isAutoSelected: true,
+ })
+ ).toBe('express');
+ });
+
+ it('falls back to whichever value is set', () => {
+ expect(
+ getCurrentShippingServiceCode({
+ formServiceCode: '',
+ shippingLineServiceCode: 'standard',
+ isAutoSelected: false,
+ })
+ ).toBe('standard');
+ expect(
+ getCurrentShippingServiceCode({
+ formServiceCode: 'standard',
+ shippingLineServiceCode: null,
+ isAutoSelected: true,
+ })
+ ).toBe('standard');
+ expect(getCurrentShippingServiceCode({})).toBeNull();
+ });
+});
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 000000000..1a76c7ade
--- /dev/null
+++ b/packages/react/src/components/checkout/shipping/utils/requires-shipping-reconciliation.ts
@@ -0,0 +1,112 @@
+import type { ShippingLines, ShippingMethod } from '@/types';
+import { sortShippingMethods } from './sort-shipping-methods';
+
+interface SelectShippingMethodParams {
+ shippingMethods: ShippingMethod[];
+ currentServiceCode?: string | null;
+ // null means there is no earlier rate set to compare against (first load).
+ previousShippingMethods?: ShippingMethod[] | null;
+ isAutoSelected?: boolean;
+}
+
+interface RequiresShippingReconciliationParams {
+ shippingMethods: ShippingMethod[];
+ previousShippingMethods?: ShippingMethod[];
+ currentShippingLine?: ShippingLines | null;
+ selectedServiceCode?: string | null;
+ isAutoSelected?: boolean;
+}
+
+function isFreeShippingMethod(method: ShippingMethod) {
+ return method.cost?.value === 0;
+}
+
+// An automatic pick is never customer-edited, so form hydration can briefly
+// reset it to a stale order value; the order's line is the source of truth.
+export function getCurrentShippingServiceCode({
+ formServiceCode,
+ shippingLineServiceCode,
+ isAutoSelected,
+}: {
+ formServiceCode?: string | null;
+ shippingLineServiceCode?: string | null;
+ isAutoSelected?: boolean;
+}) {
+ return (
+ (isAutoSelected
+ ? shippingLineServiceCode || formServiceCode
+ : formServiceCode || shippingLineServiceCode) || 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,
+ previousShippingMethods = null,
+ isAutoSelected = false,
+}: SelectShippingMethodParams) {
+ const availableMethods = sortShippingMethods(shippingMethods);
+ const methodsKey = getShippingMethodsKey(availableMethods);
+ const cheapestMethod = availableMethods[0];
+ const currentMethod = currentServiceCode
+ ? availableMethods.find(method => method.serviceCode === currentServiceCode)
+ : undefined;
+
+ if (!currentMethod) {
+ return { selectedMethod: cheapestMethod, methodsKey, autoSelected: true };
+ }
+
+ if (previousShippingMethods) {
+ const freeShippingNewlyAvailable =
+ !previousShippingMethods.some(isFreeShippingMethod) &&
+ availableMethods.some(isFreeShippingMethod);
+ const methodsChanged =
+ methodsKey !== getShippingMethodsKey(previousShippingMethods);
+
+ if (
+ (freeShippingNewlyAvailable && !isFreeShippingMethod(currentMethod)) ||
+ (isAutoSelected && methodsChanged)
+ ) {
+ return { selectedMethod: cheapestMethod, methodsKey, autoSelected: true };
+ }
+ }
+
+ return {
+ selectedMethod: currentMethod,
+ methodsKey,
+ autoSelected: isAutoSelected,
+ };
+}
+
+export function requiresShippingReconciliation({
+ shippingMethods,
+ previousShippingMethods = [],
+ currentShippingLine,
+ selectedServiceCode,
+ isAutoSelected,
+}: RequiresShippingReconciliationParams) {
+ const currentServiceCode =
+ selectedServiceCode || currentShippingLine?.requestedService;
+ const { selectedMethod } = selectShippingMethod({
+ shippingMethods,
+ currentServiceCode,
+ previousShippingMethods,
+ isAutoSelected,
+ });
+
+ 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 000000000..7aafc0903
--- /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.test.ts b/packages/react/src/components/checkout/shipping/utils/use-apply-shipping-method-core.test.ts
new file mode 100644
index 000000000..5064cdc85
--- /dev/null
+++ b/packages/react/src/components/checkout/shipping/utils/use-apply-shipping-method-core.test.ts
@@ -0,0 +1,109 @@
+import { QueryClient } from '@tanstack/react-query';
+import { describe, expect, it } from 'vitest';
+import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys';
+import { updateShippingMethodCache } from './use-apply-shipping-method-core';
+
+type CacheArgs = Parameters;
+
+function seedDraftOrder(queryClient: QueryClient) {
+ queryClient.setQueryData(checkoutQueryKeys.draftOrder('session-1'), {
+ checkoutSession: {
+ id: 'session-1',
+ draftOrder: {
+ id: 'draft-order-1',
+ totals: { shippingTotal: { value: 0, currencyCode: 'USD' } },
+ shippingLines: [
+ {
+ id: 'shipping-line-1',
+ name: 'Standard',
+ requestedProvider: 'carrier',
+ requestedService: 'standard',
+ amount: { value: 0, currencyCode: 'USD' },
+ discounts: [
+ {
+ id: 'discount-freeship',
+ code: 'FREESHIP',
+ amount: { value: 1000, currencyCode: 'USD' },
+ },
+ ],
+ },
+ ],
+ },
+ },
+ });
+}
+
+function cachedDraftOrder(queryClient: QueryClient) {
+ return (
+ queryClient.getQueryData(checkoutQueryKeys.draftOrder('session-1')) as {
+ checkoutSession: {
+ draftOrder: {
+ shippingLines: Array>;
+ totals: { shippingTotal: unknown };
+ };
+ };
+ }
+ ).checkoutSession.draftOrder;
+}
+
+describe('updateShippingMethodCache', () => {
+ it("replaces the shipping line without the previous rate's discounts", () => {
+ const queryClient = new QueryClient();
+ seedDraftOrder(queryClient);
+
+ updateShippingMethodCache(
+ queryClient,
+ 'session-1',
+ {
+ applyCheckoutSessionShippingMethod: {
+ draftOrder: {
+ totals: { shippingTotal: { value: 2000, currencyCode: 'USD' } },
+ },
+ },
+ } as CacheArgs[2],
+ [
+ {
+ name: 'Express',
+ requestedProvider: 'carrier',
+ requestedService: 'express',
+ },
+ ] as CacheArgs[3]
+ );
+
+ const draftOrder = cachedDraftOrder(queryClient);
+ expect(draftOrder.shippingLines).toEqual([
+ {
+ id: 'shipping-line-1',
+ discounts: [],
+ name: 'Express',
+ requestedProvider: 'carrier',
+ requestedService: 'express',
+ amount: { value: 2000, currencyCode: 'USD' },
+ },
+ ]);
+ expect(draftOrder.totals.shippingTotal).toEqual({
+ value: 2000,
+ currencyCode: 'USD',
+ });
+ });
+
+ it('clears the shipping lines when no method is applied', () => {
+ const queryClient = new QueryClient();
+ seedDraftOrder(queryClient);
+
+ updateShippingMethodCache(
+ queryClient,
+ 'session-1',
+ {
+ applyCheckoutSessionShippingMethod: {
+ draftOrder: {
+ totals: { shippingTotal: { value: 0, currencyCode: 'USD' } },
+ },
+ },
+ } as CacheArgs[2],
+ [] as CacheArgs[3]
+ );
+
+ expect(cachedDraftOrder(queryClient).shippingLines).toEqual([]);
+ });
+});
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 000000000..3ce57b092
--- /dev/null
+++ b/packages/react/src/components/checkout/shipping/utils/use-apply-shipping-method-core.ts
@@ -0,0 +1,105 @@
+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,
+ shippingMethods: ShippingMethods
+) {
+ 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,
+ // checkout-api replaces the line and drops its discounts on apply,
+ // so build a fresh line instead of carrying the previous rate's
+ // discounts; the follow-up discount re-apply restores valid ones.
+ // The new line's id isn't returned, so keep the old one until the
+ // draft order refetches.
+ shippingLines: shippingMethods[0]
+ ? [
+ {
+ id:
+ cached.checkoutSession?.draftOrder?.shippingLines?.[0]
+ ?.id ?? null,
+ discounts: [],
+ name: shippingMethods[0].name,
+ requestedProvider:
+ shippingMethods[0].requestedProvider ?? null,
+ requestedService:
+ shippingMethods[0].requestedService ?? null,
+ 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, shippingMethods);
+ 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 331fedeed..2b50fab7f 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,119 +1,34 @@
-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 { getDraftOrderDiscountCodes } from '@/components/checkout/discount/utils/get-draft-order-discount-codes';
+import { useApplyDiscountCore } from '@/components/checkout/discount/utils/use-apply-discount-core';
import { useDraftOrder } from '@/components/checkout/order/use-draft-order';
import { useUpdateTaxes } from '@/components/checkout/order/use-update-taxes';
-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();
+ // Applying discounts replaces the order's full code list, so include
+ // line-item codes too or they are dropped.
+ const discountCodes = getDraftOrderDiscountCodes(order);
- // Add order-level discount codes
- if (order?.discounts) {
- for (const discount of order.discounts) {
- if (discount.code) {
- allCodes.add(discount.code);
- }
- }
+ if (session.enablePromotionCodes && discountCodes.length) {
+ await applyDiscount.mutateAsync({ discountCodes });
}
- // 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) {
- for (const discount of shippingLine.discounts) {
- if (discount.code) {
- allCodes.add(discount.code);
- }
- }
- }
- }
- }
-
- const discountCodes = Array.from(allCodes);
-
- if (session?.enablePromotionCodes && discountCodes?.length) {
- /* should re-apply discounts if they were previously applied */
- await applyDiscount.mutateAsync({
- discountCodes,
- });
- } else if (session?.enableTaxCollection) {
+ if (session.enableTaxCollection) {
await updateTaxes.mutateAsync(undefined);
} else {
await queryClient.invalidateQueries({
diff --git a/packages/react/src/components/checkout/shipping/utils/use-draft-order-shipping-methods.ts b/packages/react/src/components/checkout/shipping/utils/use-draft-order-shipping-methods.ts
index dcb2f349a..2d1967426 100644
--- a/packages/react/src/components/checkout/shipping/utils/use-draft-order-shipping-methods.ts
+++ b/packages/react/src/components/checkout/shipping/utils/use-draft-order-shipping-methods.ts
@@ -67,6 +67,11 @@ export function useDraftOrderShippingMethods() {
)
: getDraftOrderShippingMethods(session, destination, apiHost),
enabled: !!session?.id && hasShippingAddress,
+ // The failure UI offers a manual retry. Set explicitly because hosts can
+ // supply their own QueryClient with automatic retries enabled.
+ retry: false,
+ // Null rates are a normal "no rates" result from checkout-api (e.g. no
+ // SKU-backed items), not a failure; only thrown request errors show retry.
select: data =>
data?.checkoutSession?.draftOrder?.calculatedShippingRates?.rates,
});
diff --git a/packages/react/src/components/checkout/shipping/utils/use-remove-shipping-method.ts b/packages/react/src/components/checkout/shipping/utils/use-remove-shipping-method.ts
deleted file mode 100644
index f1450dffb..000000000
--- a/packages/react/src/components/checkout/shipping/utils/use-remove-shipping-method.ts
+++ /dev/null
@@ -1,124 +0,0 @@
-import { useMutation, useQueryClient } from '@tanstack/react-query';
-import type { ResultOf } from 'gql.tada';
-import { useCheckoutContext } from '@/components/checkout/checkout';
-import { useDiscountApply } from '@/components/checkout/discount';
-import { useDraftOrder } from '@/components/checkout/order/use-draft-order';
-import {
- checkoutMutationKeys,
- checkoutQueryKeys,
-} from '@/components/checkout/utils/query-keys';
-import { useGoDaddyContext } from '@/godaddy-provider';
-import type { DraftOrderQuery } from '@/lib/godaddy/checkout-queries.ts';
-import { removeShippingMethod } from '@/lib/godaddy/godaddy';
-import type { RemoveAppliedCheckoutSessionShippingMethodInput } from '@/types';
-
-export function useRemoveShippingMethod() {
- const { session, jwt } = useCheckoutContext();
- const { apiHost } = useGoDaddyContext();
- const queryClient = useQueryClient();
- const { data: order } = useDraftOrder();
- const applyDiscount = useDiscountApply();
-
- return useMutation({
- mutationKey: checkoutMutationKeys.removeShippingMethod(session?.id),
- mutationFn: async (
- input: RemoveAppliedCheckoutSessionShippingMethodInput['input']
- ) => {
- const data = jwt
- ? await removeShippingMethod(input, { accessToken: jwt }, apiHost)
- : await removeShippingMethod(input, session, apiHost);
- return data;
- },
- onSuccess: async data => {
- if (!session) return;
-
- // Extract shippingTotal from mutation response
- const shippingTotal =
- data?.removeAppliedCheckoutSessionShippingMethod?.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) {
- allCodes.add(discount.code);
- }
- }
- }
-
- // Add line item-level discount codes
- if (order?.lineItems) {
- for (const lineItem of order.lineItems) {
- if (lineItem?.discounts) {
- for (const discount of lineItem.discounts) {
- if (discount.code) {
- allCodes.add(discount.code);
- }
- }
- }
- }
- }
-
- // Add shipping line-level discount codes
- if (order?.shippingLines) {
- for (const shippingLine of order.shippingLines) {
- if (shippingLine.discounts) {
- for (const discount of shippingLine.discounts) {
- if (discount.code) {
- allCodes.add(discount.code);
- }
- }
- }
- }
- }
-
- const discountCodes = Array.from(allCodes);
-
- if (session?.enablePromotionCodes && discountCodes?.length) {
- /* should re-apply discounts if they were previously applied */
- await applyDiscount.mutateAsync({
- discountCodes,
- });
- } else {
- await queryClient.invalidateQueries({
- queryKey: checkoutQueryKeys.draftOrder(session.id),
- });
- }
- },
- });
-}
diff --git a/packages/react/src/components/checkout/utils/checkout-transformers.ts b/packages/react/src/components/checkout/utils/checkout-transformers.ts
index 7b51b3cad..1ee398ba8 100644
--- a/packages/react/src/components/checkout/utils/checkout-transformers.ts
+++ b/packages/react/src/components/checkout/utils/checkout-transformers.ts
@@ -228,6 +228,7 @@ export function mapOrderToFormValues({
// shippingMethod
shippingMethod: order?.shippingLines?.[0]?.requestedService ?? '',
+ shippingMethodAutoSelected: false,
};
}
diff --git a/packages/react/src/components/checkout/utils/query-keys.ts b/packages/react/src/components/checkout/utils/query-keys.ts
index cf2c8d2d8..7324a8f11 100644
--- a/packages/react/src/components/checkout/utils/query-keys.ts
+++ b/packages/react/src/components/checkout/utils/query-keys.ts
@@ -7,8 +7,6 @@ export const checkoutMutationKeys = {
['update-draft-order-fees', { sessionId }] as const,
applyShippingMethod: (sessionId?: string | null) =>
['apply-shipping-method', { sessionId }] as const,
- removeShippingMethod: (sessionId?: string | null) =>
- ['remove-shipping-method', { sessionId }] as const,
applyDiscount: (sessionId?: string | null) =>
['apply-discount', { sessionId }] as const,
applyDeliveryMethod: (sessionId?: string | null) =>
diff --git a/packages/react/src/lib/godaddy/checkout-env.ts b/packages/react/src/lib/godaddy/checkout-env.ts
index 1c5bd8b43..903e04786 100644
--- a/packages/react/src/lib/godaddy/checkout-env.ts
+++ b/packages/react/src/lib/godaddy/checkout-env.ts
@@ -2020,6 +2020,18 @@ const introspection = {
"args": [],
"isDeprecated": false
},
+ {
+ "name": "orderId",
+ "type": {
+ "kind": "NON_NULL",
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "String"
+ }
+ },
+ "args": [],
+ "isDeprecated": false
+ },
{
"name": "paymentMethods",
"type": {
@@ -9752,7 +9764,15 @@ const introspection = {
"kind": "OBJECT",
"name": "CheckoutSession"
},
- "args": [],
+ "args": [
+ {
+ "name": "id",
+ "type": {
+ "kind": "SCALAR",
+ "name": "ID"
+ }
+ }
+ ],
"isDeprecated": false
},
{
diff --git a/packages/react/src/lib/godaddy/checkout-mutations.ts b/packages/react/src/lib/godaddy/checkout-mutations.ts
index 89c49081b..c1488a1cf 100644
--- a/packages/react/src/lib/godaddy/checkout-mutations.ts
+++ b/packages/react/src/lib/godaddy/checkout-mutations.ts
@@ -70,10 +70,6 @@ export const CreateCheckoutSessionMutation = graphql(`
}
}
experimental_rules {
- freeShipping {
- enabled
- minimumOrderTotal
- }
gopay_override {
enabled
goPayAppId
@@ -369,6 +365,7 @@ export const ApplyCheckoutSessionDiscountMutation = graphql(`
}
}
shippingLines {
+ id
discounts {
id
amount {
@@ -436,22 +433,6 @@ export const ApplyCheckoutSessionDeliveryMethodMutation = graphql(`
}
`);
-export const RemoveAppliedCheckoutSessionShippingMethodMutation = graphql(`
- mutation RemoveAppliedCheckoutSessionShippingMethod($input: RemoveShippingMethodInput!) {
- removeAppliedCheckoutSessionShippingMethod(input: $input) {
- status
- draftOrder {
- totals {
- shippingTotal {
- currencyCode
- value
- }
- }
- }
- }
- }
-`);
-
export const ApplyCheckoutSessionFulfillmentLocationMutation = graphql(`
mutation ApplyCheckoutSessionFulfillmentLocation($input: MutationApplyCheckoutSessionFulfillmentLocationInput!) {
applyCheckoutSessionFulfillmentLocation(input: $input) {
diff --git a/packages/react/src/lib/godaddy/checkout-queries.ts b/packages/react/src/lib/godaddy/checkout-queries.ts
index 42e342f85..0da8f0294 100644
--- a/packages/react/src/lib/godaddy/checkout-queries.ts
+++ b/packages/react/src/lib/godaddy/checkout-queries.ts
@@ -70,10 +70,6 @@ export const GetCheckoutSessionQuery = graphql(`
}
}
experimental_rules {
- freeShipping {
- enabled
- minimumOrderTotal
- }
gopay_override {
enabled
goPayAppId
diff --git a/packages/react/src/lib/godaddy/godaddy.ts b/packages/react/src/lib/godaddy/godaddy.ts
index dc2dfc468..3ed2fd131 100644
--- a/packages/react/src/lib/godaddy/godaddy.ts
+++ b/packages/react/src/lib/godaddy/godaddy.ts
@@ -32,7 +32,6 @@ import type {
DraftOrderPriceAdjustmentsQueryInput,
GetCheckoutSessionShippingRatesInput,
GetCheckoutSessionTaxesInput,
- RemoveAppliedCheckoutSessionShippingMethodInput,
SkuGroupInput,
SkuGroupsInput,
SkuInput,
@@ -51,7 +50,6 @@ import {
CreateCheckoutSessionMutation,
ExchangeCheckoutTokenMutation,
RefreshCheckoutTokenMutation,
- RemoveAppliedCheckoutSessionShippingMethodMutation,
UpdateCheckoutSessionDraftOrderMutation,
VerifyCheckoutSessionAddressMutation,
} from './checkout-mutations.ts';
@@ -769,64 +767,6 @@ export function applyShippingMethod(
);
}
-export function removeShippingMethod(
- input: RemoveAppliedCheckoutSessionShippingMethodInput['input'],
- session: CheckoutSession | undefined | null,
- apiHost?: string
-): Promise>;
-export function removeShippingMethod(
- input: RemoveAppliedCheckoutSessionShippingMethodInput['input'],
- auth: { accessToken: string | undefined },
- apiHost?: string
-): Promise>;
-export function removeShippingMethod(
- input: RemoveAppliedCheckoutSessionShippingMethodInput['input'],
- sessionOrAuth:
- | CheckoutSession
- | undefined
- | null
- | { accessToken: string | undefined },
- apiHost?: string
-): Promise<
- ResultOf
-> {
- const GODADDY_HOST = getHostByEnvironment(apiHost);
-
- if (sessionOrAuth && 'accessToken' in sessionOrAuth) {
- if (!sessionOrAuth.accessToken) {
- throw new Error('No access token provided');
- }
- return graphqlRequestWithErrors<
- ResultOf
- >(
- GODADDY_HOST,
- RemoveAppliedCheckoutSessionShippingMethodMutation,
- { input },
- {
- Authorization: `Bearer ${sessionOrAuth.accessToken}`,
- }
- );
- }
-
- const session = sessionOrAuth;
- if (!session?.token || !session?.id) {
- throw new Error('No session token or ID provided');
- }
-
- return graphqlRequestWithErrors<
- ResultOf
- >(
- GODADDY_HOST,
- RemoveAppliedCheckoutSessionShippingMethodMutation,
- { input },
- {
- 'x-session-token': `${session.token}`,
- 'x-session-id': session.id,
- 'x-store-id': session.storeId,
- }
- );
-}
-
export function confirmCheckout(
input: ConfirmCheckoutMutationInput['input'],
session: CheckoutSession | undefined | null,
diff --git a/packages/react/src/tests/setup.ts b/packages/react/src/tests/setup.ts
index 835bc6789..64c559d12 100644
--- a/packages/react/src/tests/setup.ts
+++ b/packages/react/src/tests/setup.ts
@@ -14,7 +14,6 @@ vi.mock('@/lib/godaddy/godaddy', () => ({
updateDraftOrderTaxes: vi.fn(),
applyDiscount: vi.fn(),
applyShippingMethod: vi.fn(),
- removeShippingMethod: vi.fn(),
confirmCheckout: vi.fn(),
authorizeCheckoutSession: vi.fn(),
getDraftOrderShippingMethods: vi.fn(),
diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts
index 66f1331ba..95af88d8d 100644
--- a/packages/react/src/types.ts
+++ b/packages/react/src/types.ts
@@ -13,7 +13,6 @@ import type {
CalculateCheckoutSessionTaxesMutation,
ConfirmCheckoutSessionMutation,
CreateCheckoutSessionMutation,
- RemoveAppliedCheckoutSessionShippingMethodMutation,
UpdateCheckoutSessionDraftOrderMutation,
VerifyCheckoutSessionAddressMutation,
} from '@/lib/godaddy/checkout-mutations.ts';
@@ -159,10 +158,6 @@ export type ApplyCheckoutSessionFulfillmentLocationInput = VariablesOf<
typeof ApplyCheckoutSessionFulfillmentLocationMutation
>;
-export type RemoveAppliedCheckoutSessionShippingMethodInput = VariablesOf<
- typeof RemoveAppliedCheckoutSessionShippingMethodMutation
->;
-
export type CalculateCheckoutSessionTaxesInput = VariablesOf<
typeof CalculateCheckoutSessionTaxesMutation
>;