diff --git a/.changeset/calm-coupons-ship.md b/.changeset/calm-coupons-ship.md new file mode 100644 index 00000000..dfb1d08e --- /dev/null +++ b/.changeset/calm-coupons-ship.md @@ -0,0 +1,10 @@ +--- +"@godaddy/react": patch +"@godaddy/localizations": patch +--- + +Keep shipping rates, discounts, taxes, and express checkout in sync when coupons change. + +- Refetch shipping rates after a coupon is applied or removed, and keep the customer's chosen shipping method while it is still offered. +- Show a localized retry action when shipping rates cannot be loaded. Clear applied shipping when rates cannot be loaded, and apply the refreshed default method after a successful retry. +- Remove the unused `experimental_rules.freeShipping` session field. diff --git a/packages/localizations/src/deDe.ts b/packages/localizations/src/deDe.ts index 33dfc622..ed07fbde 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 59815771..39d3bbd3 100644 --- a/packages/localizations/src/enAu.ts +++ b/packages/localizations/src/enAu.ts @@ -70,6 +70,8 @@ export const enAu = { noShippingMethodAddress: 'Enter your address to see available shipping methods.', noShippingMethods: 'No shipping methods found.', + failedToLoadMethods: 'Unable to load shipping methods. Please try again.', + retryMethods: 'Try again', phone: 'Phone Number', country: 'Country', selectCountry: 'Select country', diff --git a/packages/localizations/src/enIe.ts b/packages/localizations/src/enIe.ts index 89470301..2cb6f8d5 100644 --- a/packages/localizations/src/enIe.ts +++ b/packages/localizations/src/enIe.ts @@ -70,6 +70,8 @@ export const enIe = { noShippingMethodAddress: 'Enter your address to see available shipping methods.', noShippingMethods: 'No shipping methods found.', + failedToLoadMethods: 'Unable to load shipping methods. Please try again.', + retryMethods: 'Try again', phone: 'Phone Number', country: 'Country', selectCountry: 'Select country', diff --git a/packages/localizations/src/enUs.ts b/packages/localizations/src/enUs.ts index 95a556da..9a1852f4 100644 --- a/packages/localizations/src/enUs.ts +++ b/packages/localizations/src/enUs.ts @@ -70,6 +70,8 @@ export const enUs = { noShippingMethodAddress: 'Enter your address to see available shipping methods.', noShippingMethods: 'No shipping methods found.', + failedToLoadMethods: 'Unable to load shipping methods. Please try again.', + retryMethods: 'Try again', phone: 'Phone Number', country: 'Country', selectCountry: 'Select country', diff --git a/packages/localizations/src/esAr.ts b/packages/localizations/src/esAr.ts index 8128fee0..0eebdfc4 100644 --- a/packages/localizations/src/esAr.ts +++ b/packages/localizations/src/esAr.ts @@ -70,6 +70,9 @@ export const esAr = { noShippingMethodAddress: 'Ingresá tu dirección para ver los métodos de envío disponibles.', noShippingMethods: 'No se encontraron métodos de envío.', + failedToLoadMethods: + 'No se pudieron cargar los métodos de envío. Inténtalo de nuevo.', + retryMethods: 'Intentar de nuevo', phone: 'Número de Teléfono', country: 'País', selectCountry: 'Seleccionar país', diff --git a/packages/localizations/src/esCl.ts b/packages/localizations/src/esCl.ts index ccb43c9a..81446be8 100644 --- a/packages/localizations/src/esCl.ts +++ b/packages/localizations/src/esCl.ts @@ -70,6 +70,9 @@ export const esCl = { noShippingMethodAddress: 'Ingresa tu dirección para ver los métodos de envío disponibles.', noShippingMethods: 'No se encontraron métodos de envío.', + failedToLoadMethods: + 'No se pudieron cargar los métodos de envío. Inténtalo de nuevo.', + retryMethods: 'Intentar de nuevo', phone: 'Número de Teléfono', country: 'País', selectCountry: 'Seleccionar país', diff --git a/packages/localizations/src/esCo.ts b/packages/localizations/src/esCo.ts index b80b2f9c..31ea0b86 100644 --- a/packages/localizations/src/esCo.ts +++ b/packages/localizations/src/esCo.ts @@ -70,6 +70,9 @@ export const esCo = { noShippingMethodAddress: 'Ingresa tu dirección para ver los métodos de envío disponibles.', noShippingMethods: 'No se encontraron métodos de envío.', + failedToLoadMethods: + 'No se pudieron cargar los métodos de envío. Inténtalo de nuevo.', + retryMethods: 'Intentar de nuevo', phone: 'Número de teléfono', country: 'País', selectCountry: 'Seleccionar país', diff --git a/packages/localizations/src/esEs.ts b/packages/localizations/src/esEs.ts index 013cf3a6..76167844 100644 --- a/packages/localizations/src/esEs.ts +++ b/packages/localizations/src/esEs.ts @@ -70,6 +70,9 @@ export const esEs = { noShippingMethodAddress: 'Introduce tu dirección para ver los métodos de envío disponibles.', noShippingMethods: 'No se encontraron métodos de envío.', + failedToLoadMethods: + 'No se pudieron cargar los métodos de envío. Inténtalo de nuevo.', + retryMethods: 'Intentar de nuevo', phone: 'Número de teléfono', country: 'País', selectCountry: 'Seleccionar país', diff --git a/packages/localizations/src/esMx.ts b/packages/localizations/src/esMx.ts index 171dc3c8..a0907b11 100644 --- a/packages/localizations/src/esMx.ts +++ b/packages/localizations/src/esMx.ts @@ -70,6 +70,9 @@ export const esMx = { noShippingMethodAddress: 'Ingrese su dirección para ver los métodos de envío disponibles.', noShippingMethods: 'No se encontraron métodos de envío.', + failedToLoadMethods: + 'No se pudieron cargar los métodos de envío. Inténtalo de nuevo.', + retryMethods: 'Intentar de nuevo', phone: 'Número de Teléfono', country: 'País', selectCountry: 'Seleccionar país', diff --git a/packages/localizations/src/esPe.ts b/packages/localizations/src/esPe.ts index 3d1f4f36..ea086c41 100644 --- a/packages/localizations/src/esPe.ts +++ b/packages/localizations/src/esPe.ts @@ -70,6 +70,9 @@ export const esPe = { noShippingMethodAddress: 'Ingrese su dirección para ver los métodos de envío disponibles.', noShippingMethods: 'No se encontraron métodos de envío.', + failedToLoadMethods: + 'No se pudieron cargar los métodos de envío. Inténtalo de nuevo.', + retryMethods: 'Intentar de nuevo', phone: 'Número de Teléfono', country: 'País', selectCountry: 'Seleccionar país', diff --git a/packages/localizations/src/esUs.ts b/packages/localizations/src/esUs.ts index fc77429b..5be7c6a4 100644 --- a/packages/localizations/src/esUs.ts +++ b/packages/localizations/src/esUs.ts @@ -70,6 +70,9 @@ export const esUs = { noShippingMethodAddress: 'Ingrese su dirección para ver los métodos de envío disponibles.', noShippingMethods: 'No se encontraron métodos de envío.', + failedToLoadMethods: + 'No se pudieron cargar los métodos de envío. Inténtalo de nuevo.', + retryMethods: 'Intentar de nuevo', phone: 'Número de Teléfono', country: 'País', selectCountry: 'Seleccionar país', diff --git a/packages/localizations/src/frCa.ts b/packages/localizations/src/frCa.ts index 4f4eed86..e65d2dcc 100644 --- a/packages/localizations/src/frCa.ts +++ b/packages/localizations/src/frCa.ts @@ -70,6 +70,9 @@ export const frCa = { noShippingMethodAddress: "Entrez votre adresse pour voir les méthodes d'expédition disponibles.", noShippingMethods: "Aucune méthode d'expédition trouvée.", + failedToLoadMethods: + 'Impossible de charger les modes de livraison. Veuillez réessayer.', + retryMethods: 'Réessayer', phone: 'Numéro de téléphone', country: 'Pays', selectCountry: 'Sélectionner un pays', diff --git a/packages/localizations/src/frFr.ts b/packages/localizations/src/frFr.ts index 09d4c91a..e786253c 100644 --- a/packages/localizations/src/frFr.ts +++ b/packages/localizations/src/frFr.ts @@ -70,6 +70,9 @@ export const frFr = { noShippingMethodAddress: 'Entrez votre adresse pour voir les méthodes de livraison disponibles.', noShippingMethods: "Aucune méthode d'expédition trouvée.", + failedToLoadMethods: + 'Impossible de charger les modes de livraison. Veuillez réessayer.', + retryMethods: 'Réessayer', phone: 'Numéro de téléphone', country: 'Pays', selectCountry: 'Sélectionner un pays', diff --git a/packages/localizations/src/idId.ts b/packages/localizations/src/idId.ts index 4bdc412e..be48d21a 100644 --- a/packages/localizations/src/idId.ts +++ b/packages/localizations/src/idId.ts @@ -70,6 +70,9 @@ export const idId = { noShippingMethodAddress: 'Masukkan alamat Anda untuk melihat metode pengiriman yang tersedia.', noShippingMethods: 'Tidak ada metode pengiriman ditemukan.', + failedToLoadMethods: + 'Tidak dapat memuat metode pengiriman. Silakan coba lagi.', + retryMethods: 'Coba lagi', phone: 'Nomor Telepon', country: 'Negara', selectCountry: 'Pilih negara', diff --git a/packages/localizations/src/itIt.ts b/packages/localizations/src/itIt.ts index de2515f5..e11df876 100644 --- a/packages/localizations/src/itIt.ts +++ b/packages/localizations/src/itIt.ts @@ -70,6 +70,9 @@ export const itIt = { noShippingMethodAddress: 'Inserisci il tuo indirizzo per vedere i metodi di spedizione disponibili.', noShippingMethods: 'Nessun metodo di spedizione trovato.', + failedToLoadMethods: + 'Impossibile caricare i metodi di spedizione. Riprova.', + retryMethods: 'Riprova', phone: 'Numero di Telefono', country: 'Paese', selectCountry: 'Seleziona paese', diff --git a/packages/localizations/src/ptBr.ts b/packages/localizations/src/ptBr.ts index d47a9bba..f0856279 100644 --- a/packages/localizations/src/ptBr.ts +++ b/packages/localizations/src/ptBr.ts @@ -70,6 +70,9 @@ export const ptBr = { noShippingMethodAddress: 'Digite seu endereço para ver os métodos de envio disponíveis.', noShippingMethods: 'Nenhum método de envio encontrado.', + failedToLoadMethods: + 'Não foi possível carregar as formas de envio. Tente novamente.', + retryMethods: 'Tentar novamente', phone: 'Número de Telefone', country: 'País', selectCountry: 'Selecionar país', diff --git a/packages/localizations/src/qaPs.ts b/packages/localizations/src/qaPs.ts index 208d5935..7c97ec6e 100644 --- a/packages/localizations/src/qaPs.ts +++ b/packages/localizations/src/qaPs.ts @@ -70,6 +70,8 @@ export const qaPs = { noShippingMethodAddress: '[Ëñťër ÿöür âddrëšš ťö šëë âvâîlâblë šhîþþîñg mëťhödš fôr ÿöür löçâťîöñ.]', noShippingMethods: '[Ñö šhîþþîñg mëťhödš föüñd fôr ţhîš löçâţîöñ.]', + failedToLoadMethods: '[Üñâblë ťö löâd šhîþþîñg mëťhödš. Þléâšé ťrÿ âgâîñ.]', + retryMethods: '[Ťrÿ âgâîñ]', phone: '[Þhöñë Ñümkër Îñförmâťîöñ]', country: '[Çöüñţrÿ Šëlëçťîöñ]', selectCountry: '[Šëlëçť çöüñţrÿ frôm lîšť]', diff --git a/packages/localizations/src/trTr.ts b/packages/localizations/src/trTr.ts index daa63b5e..4ea7f7d0 100644 --- a/packages/localizations/src/trTr.ts +++ b/packages/localizations/src/trTr.ts @@ -70,6 +70,9 @@ export const trTr = { noShippingMethodAddress: 'Mevcut kargo yöntemlerini görmek için adresinizi girin.', noShippingMethods: 'Kargo yöntemi bulunamadı.', + failedToLoadMethods: + 'Gönderim yöntemleri yüklenemedi. Lütfen tekrar deneyin.', + retryMethods: 'Tekrar dene', phone: 'Telefon Numarası', country: 'Ülke', selectCountry: 'Ülke seçin', diff --git a/packages/localizations/src/viVn.ts b/packages/localizations/src/viVn.ts index 37ff3b91..5ae72923 100644 --- a/packages/localizations/src/viVn.ts +++ b/packages/localizations/src/viVn.ts @@ -70,6 +70,9 @@ export const viVn = { noShippingMethodAddress: 'Nhập địa chỉ của bạn để xem các phương thức vận chuyển có sẵn.', noShippingMethods: 'Không tìm thấy phương thức vận chuyển nào.', + failedToLoadMethods: + 'Không thể tải phương thức vận chuyển. Vui lòng thử lại.', + retryMethods: 'Thử lại', phone: 'Số điện thoại', country: 'Quốc gia', selectCountry: 'Chọn quốc gia', diff --git a/packages/localizations/src/zhCn.ts b/packages/localizations/src/zhCn.ts index 19eb5bf9..4dbb9c3e 100644 --- a/packages/localizations/src/zhCn.ts +++ b/packages/localizations/src/zhCn.ts @@ -66,6 +66,8 @@ export const zhCn = { method: '配送方式', noShippingMethodAddress: '请输入您的地址以查看可用的配送方式。', noShippingMethods: '未找到配送方式。', + failedToLoadMethods: '无法加载配送方式。请重试。', + retryMethods: '重试', phone: '手机号码', country: '国家', selectCountry: '选择国家', diff --git a/packages/localizations/src/zhSg.ts b/packages/localizations/src/zhSg.ts index 52f1938b..a480ebec 100644 --- a/packages/localizations/src/zhSg.ts +++ b/packages/localizations/src/zhSg.ts @@ -66,6 +66,8 @@ export const zhSg = { method: '运送方式', noShippingMethodAddress: '输入您的地址以查看可用的运送方式。', noShippingMethods: '未找到运送方式。', + failedToLoadMethods: '无法加载配送方式。请重试。', + retryMethods: '重试', phone: '电话号码', country: '国家', selectCountry: '选择国家', diff --git a/packages/react/src/components/checkout/__tests__/checkout-confirm-errors.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-confirm-errors.test.tsx index 1724ab89..e6c57367 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-confirm-errors.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-confirm-errors.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, screen, waitFor } from '@testing-library/react'; import { useState } from 'react'; import { useFormContext } from 'react-hook-form'; import { describe, expect, it } from 'vitest'; @@ -9,6 +9,7 @@ import { PaymentProvider, useConfirmCheckout, } from '@/components/checkout/payment/utils/use-confirm-checkout'; +import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys'; import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors'; import { buildCheckoutSession, @@ -508,7 +509,7 @@ describe('Checkout confirm errors', () => { const draftOrder = buildDraftOrder({ shippingLines: [] }); const session = buildCheckoutSession({ draftOrder }); - const { user } = renderCheckout({ + const { user, queryClient } = renderCheckout({ session, draftOrder, checkoutProps: { @@ -521,6 +522,20 @@ describe('Checkout confirm errors', () => { }); await waitForCheckoutReady(); clearOperations(); + setApiError('getDraftOrderShippingMethods', 'rates failed'); + await act(async () => { + await queryClient.refetchQueries({ + queryKey: checkoutQueryKeys.draftOrderShippingMethods(session.id), + }); + }); + expect( + queryClient + .getQueryCache() + .findAll({ + queryKey: checkoutQueryKeys.draftOrderShippingMethods(session.id), + }) + .some(query => query.state.status === 'error') + ).toBe(true); await user.click( await screen.findByRole('button', { name: /express confirm seam/i }) diff --git a/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx index b49196d8..b6c64de9 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx @@ -1,14 +1,21 @@ import { enUs } from '@godaddy/localizations'; import { fireEvent, screen, waitFor } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys'; +import { getDraftOrderShippingMethods } from '@/lib/godaddy/godaddy'; import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors'; +import { CheckoutType, PaymentProvider } from '@/types'; import { buildBillingAddress, + buildShippingRates, + clearApiError, clearOperations, flushPromises, getOperations, renderCheckout, setApiError, + setApiErrorOnce, + setShippingMethods, waitForCheckoutReady, waitForOperation, } from './checkout-test-env'; @@ -148,6 +155,968 @@ describe('Checkout discounts', () => { expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(0); }); + it('refetches shipping methods when a coupon is applied', async () => { + const { user } = renderCheckout({ + sessionOverrides: { enableTaxCollection: false }, + }); + await waitForCheckoutReady(); + clearOperations(); + + await applyCoupon(user, 'onedollar'); + await waitForOperation('DraftOrderShippingRates'); + + expect(getOperations('DraftOrderShippingRates')).toHaveLength(1); + }); + + it('calculates taxes once after a discount changes the selected shipping cost', async () => { + const paidShipping = buildShippingRates([ + { + serviceCode: 'standard', + carrierCode: 'carrier', + displayName: 'Standard', + cost: { value: 1000, currencyCode: 'USD' }, + }, + ]); + const { user } = renderCheckout({ + apiOverrides: { shippingMethods: paidShipping }, + draftOrderOverrides: { + shippingLines: [ + { + requestedService: 'standard', + requestedProvider: 'carrier', + name: 'Standard', + amount: { value: 1000, currencyCode: 'USD' }, + }, + ], + totals: { + shippingTotal: { value: 1000, currencyCode: 'USD' }, + total: { value: 3500, currencyCode: 'USD' }, + }, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + setShippingMethods( + buildShippingRates([ + { + serviceCode: 'standard', + carrierCode: 'carrier', + displayName: 'Standard', + cost: { value: 0, currencyCode: 'USD' }, + }, + ]) + ); + + await applyCoupon(user, 'onedollar'); + + await waitFor(() => { + expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength( + 1 + ); + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1); + }); + + await flushPromises(); + + const operations = getOperations(); + const shippingIndex = operations.findIndex( + operation => operation.op === 'ApplyCheckoutSessionShippingMethod' + ); + const taxIndex = operations.findIndex( + operation => operation.op === 'CalculateCheckoutSessionTaxes' + ); + const lastDiscountIndex = operations + .map(operation => operation.op) + .lastIndexOf('ApplyCheckoutSessionDiscount'); + + expect(getOperations('DraftOrderShippingRates')).toHaveLength(1); + expect(getOperations('ApplyCheckoutSessionDiscount')).toHaveLength(2); + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1); + expect(taxIndex).toBeGreaterThan(shippingIndex); + expect(taxIndex).toBeGreaterThan(lastDiscountIndex); + }); + + it('clears applied shipping once when the discount rate refresh returns an empty array', async () => { + const paidShipping = buildShippingRates([ + { + serviceCode: 'standard', + carrierCode: 'carrier', + displayName: 'Standard', + cost: { value: 1000, currencyCode: 'USD' }, + }, + ]); + const { user } = renderCheckout({ + apiOverrides: { shippingMethods: paidShipping }, + draftOrderOverrides: { + shippingLines: [ + { + requestedService: 'standard', + requestedProvider: 'carrier', + name: 'Standard', + amount: { value: 1000, currencyCode: 'USD' }, + }, + ], + }, + }); + await waitForCheckoutReady(); + clearOperations(); + setShippingMethods([]); + + await applyCoupon(user, 'onedollar'); + await waitFor(() => { + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1); + }); + await flushPromises(); + await flushPromises(); + + expect(getOperations('DraftOrderShippingRates')).toHaveLength(1); + expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength(1); + expect( + getOperations('ApplyCheckoutSessionShippingMethod')[0].input + ).toEqual([]); + expect(getOperations('ApplyCheckoutSessionDiscount')).toHaveLength(2); + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1); + expect(screen.queryByText('Standard')).not.toBeInTheDocument(); + expect(document.body).toHaveTextContent(/no shipping methods found/i); + expect( + screen.queryByRole('button', { name: enUs.shipping.retryMethods }) + ).not.toBeInTheDocument(); + }); + + it.each([ + ['error', 'same'], + ['null', 'same'], + ['error', 'changed'], + ['error', 'default'], + ['error', 'empty'], + ] as const)( + 'clears shipping after a %s rate failure and recovers with %s rates', + async (failure, recovery) => { + const paidShipping = buildShippingRates([ + { + serviceCode: 'standard', + 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(); + if (failure === 'error') { + setApiError('getDraftOrderShippingMethods', 'rates failed'); + } else { + vi.mocked(getDraftOrderShippingMethods).mockResolvedValueOnce({ + checkoutSession: { + id: session.id, + storeId: session.storeId, + draftOrder: { id: 'order-1', calculatedShippingRates: null }, + }, + }); + } + 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(); + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(0); + + await screen.findByText(enUs.apiErrors.SHIPPING_METHOD_APPLICATION_FAILED); + + // The failed reapplication invalidates the draft order and shipping-rate + // queries so the next render re-evaluates reconciliation against fresh data. + await waitFor(() => { + expect(getOperations('DraftOrder').length).toBeGreaterThan(0); + expect(getOperations('DraftOrderShippingRates').length).toBeGreaterThan( + 1 + ); + }); + }); + + it('clears SHIPPING_METHOD_APPLICATION_FAILED once reconciliation succeeds again', async () => { + const paidShipping = buildShippingRates([ + { + serviceCode: 'standard', + carrierCode: 'carrier', + displayName: 'Standard', + cost: { value: 1000, currencyCode: 'USD' }, + }, + ]); + const { user } = renderCheckout({ + apiOverrides: { shippingMethods: paidShipping }, + draftOrderOverrides: { + shippingLines: [ + { + requestedService: 'standard', + requestedProvider: 'carrier', + name: 'Standard', + amount: { value: 1000, currencyCode: 'USD' }, + }, + ], + }, + }); + await waitForCheckoutReady(); + clearOperations(); + setApiError('applyShippingMethod', 'apply failed'); + setShippingMethods([ + ...paidShipping, + ...buildShippingRates([ + { + serviceCode: 'free', + carrierCode: 'carrier', + displayName: 'Free', + cost: { value: 0, currencyCode: 'USD' }, + }, + ]), + ]); + + await applyCoupon(user, 'onedollar'); + await screen.findByText(enUs.apiErrors.SHIPPING_METHOD_APPLICATION_FAILED); + + clearApiError('applyShippingMethod'); + await user.click(screen.getByRole('radio', { name: /free/i })); + + await waitFor(() => { + expect( + screen.queryByText(enUs.apiErrors.SHIPPING_METHOD_APPLICATION_FAILED) + ).not.toBeInTheDocument(); + }); + expect(screen.getByRole('radio', { name: /free/i })).toBeChecked(); + }); + + it('surfaces specific error codes from a failed shipping reapplication', async () => { + const paidShipping = buildShippingRates([ + { + serviceCode: 'standard', + carrierCode: 'carrier', + displayName: 'Standard', + cost: { value: 1000, currencyCode: 'USD' }, + }, + ]); + const { user } = renderCheckout({ + apiOverrides: { shippingMethods: paidShipping }, + draftOrderOverrides: { + shippingLines: [ + { + requestedService: 'standard', + requestedProvider: 'carrier', + name: 'Standard', + amount: { value: 1000, currencyCode: 'USD' }, + }, + ], + }, + }); + await waitForCheckoutReady(); + clearOperations(); + setApiError( + 'applyShippingMethod', + new GraphQLErrorWithCodes([ + { + message: 'shipping method not found', + code: 'SHIPPING_METHOD_NOT_FOUND', + }, + ]) + ); + setShippingMethods([ + ...paidShipping, + ...buildShippingRates([ + { + serviceCode: 'free', + carrierCode: 'carrier', + displayName: 'Free', + cost: { value: 0, currencyCode: 'USD' }, + }, + ]), + ]); + + await applyCoupon(user, 'onedollar'); + + await screen.findByText(enUs.apiErrors.SHIPPING_METHOD_NOT_FOUND); + expect( + screen.queryByText(enUs.apiErrors.SHIPPING_METHOD_APPLICATION_FAILED) + ).not.toBeInTheDocument(); + }); + + it.each(['empty', 'replacement'] as const)( + '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); + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(0); + + if (result === 'empty') { + expect(document.body).toHaveTextContent(/no shipping methods found/i); + } else { + expect( + screen.getByRole('radio', { name: /express/i }) + ).not.toBeChecked(); + expect( + screen.getByRole('radio', { name: /overnight/i }) + ).not.toBeChecked(); + } + } + ); + + it("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'); + + await waitFor(() => { + expect(getOperations('DraftOrderShippingRates')).toHaveLength(1); + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1); + }); + expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength(0); + expect( + screen.getAllByRole('button', { name: /remove freeship/i }).length + ).toBeGreaterThan(0); + + await flushPromises(); + clearOperations(); + await user.click(screen.getByRole('radio', { name: /premium rate/i })); + + await waitFor(() => { + expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength( + 1 + ); + expect(getOperations('ApplyCheckoutSessionDiscount')).toHaveLength(1); + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1); + }); + + const operationNames = getOperations().map(operation => operation.op); + expect( + operationNames.indexOf('ApplyCheckoutSessionDiscount') + ).toBeGreaterThan( + operationNames.indexOf('ApplyCheckoutSessionShippingMethod') + ); + expect( + operationNames.indexOf('CalculateCheckoutSessionTaxes') + ).toBeGreaterThan(operationNames.indexOf('ApplyCheckoutSessionDiscount')); + expect(getOperations('ApplyCheckoutSessionDiscount')[0].input).toEqual({ + discountCodes: ['freeship'], + }); + + await flushPromises(); + clearOperations(); + await user.click( + screen + .getAllByRole('button', { name: /remove freeship/i }) + .at(-1) as HTMLButtonElement + ); + + await waitFor(() => { + expect(getOperations('DraftOrderShippingRates')).toHaveLength(1); + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1); + }); + expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength(0); + expect(getOperations('ApplyCheckoutSessionDiscount')[0].input).toEqual({ + discountCodes: [], + }); + }); + + it('does not fetch shipping or taxes when a coupon is applied without a shipping address', async () => { + const { user } = renderCheckout({ + draftOrderOverrides: { + shipping: null, + billing: null, + shippingLines: null, + lineItems: [{ fulfillmentMode: 'PURCHASE' }], + }, + sessionOverrides: { + enableShipping: true, + enableLocalPickup: false, + enableTaxCollection: true, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + await applyCoupon(user, 'freeship'); + await waitForOperation('ApplyCheckoutSessionDiscount'); + await waitForOperation('DraftOrder'); + + expect(getOperations('DraftOrderShippingRates')).toHaveLength(0); + expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength(0); + expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(0); + }); + it('refetches the draft order when taxes cannot be recalculated without a billing address', async () => { const { user } = renderCheckout({ draftOrderOverrides: { diff --git a/packages/react/src/components/checkout/__tests__/checkout-free-order.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-free-order.test.tsx index 6d3a8538..080b0948 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-free-order.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-free-order.test.tsx @@ -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-shipping.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-shipping.test.tsx index aca516b9..6c9e66e5 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-shipping.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-shipping.test.tsx @@ -1,3 +1,4 @@ +import { enUs } from '@godaddy/localizations'; import { act, fireEvent, screen, waitFor } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys'; @@ -8,6 +9,7 @@ import { buildDraftOrder, buildLineItem, buildShippingAddress, + clearApiError, clearOperations, flushPromises, getOperations, @@ -20,6 +22,63 @@ import { } from './checkout-test-env'; 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('shows the no-origin-address message when shipping origin is missing', async () => { renderCheckout({ sessionOverrides: { shipping: { originAddress: null } }, @@ -97,7 +156,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 +179,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 +487,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 +503,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 dc86dd3f..1cbc2420 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-test-utils.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-test-utils.tsx @@ -631,21 +631,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, @@ -1011,6 +1035,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 812d0a74..7b326b54 100644 --- a/packages/react/src/components/checkout/checkout.tsx +++ b/packages/react/src/components/checkout/checkout.tsx @@ -156,6 +156,8 @@ export const baseCheckoutSchema = z.object({ shippingPostalCode: z.string().max(60), shippingCountryCode: z.string().max(2), shippingMethod: z.string().optional(), + // Client-only: true when checkout picked the method rather than the customer. + shippingMethodAutoSelected: z.boolean().optional(), billingFirstName: z.string().max(60), billingLastName: z.string().max(60), billingPhone: z.string().max(15, 'Phone number too long').optional(), diff --git a/packages/react/src/components/checkout/discount/utils/get-draft-order-discount-codes.test.ts b/packages/react/src/components/checkout/discount/utils/get-draft-order-discount-codes.test.ts new file mode 100644 index 00000000..5e59dcf3 --- /dev/null +++ b/packages/react/src/components/checkout/discount/utils/get-draft-order-discount-codes.test.ts @@ -0,0 +1,66 @@ +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([ + 'line', + 'order', + '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 } }], + shippingLines: [ + { discounts: [{ code: 'FREESHIP', amount: { value: 500 } }] }, + ], + } as DraftOrder; + + expect(getHighestValueDraftOrderDiscountCode(draftOrder)).toBe('SAVE10'); + }); + + it('returns undefined when no discount codes are applied', () => { + expect(getHighestValueDraftOrderDiscountCode()).toBeUndefined(); + expect( + getHighestValueDraftOrderDiscountCode({ + discounts: [], + } as unknown as DraftOrder) + ).toBeUndefined(); + }); +}); diff --git a/packages/react/src/components/checkout/discount/utils/get-draft-order-discount-codes.ts b/packages/react/src/components/checkout/discount/utils/get-draft-order-discount-codes.ts new file mode 100644 index 00000000..373bedeb --- /dev/null +++ b/packages/react/src/components/checkout/discount/utils/get-draft-order-discount-codes.ts @@ -0,0 +1,65 @@ +import type { DraftOrder } from '@/types'; + +export function getDraftOrderDiscountCodes( + draftOrder?: DraftOrder | null +): string[] { + const codes = new Set(); + + for (const discount of draftOrder?.discounts ?? []) { + if (discount.code) codes.add(discount.code); + } + + for (const lineItem of draftOrder?.lineItems ?? []) { + for (const discount of lineItem.discounts ?? []) { + if (discount.code) codes.add(discount.code); + } + } + + for (const shippingLine of draftOrder?.shippingLines ?? []) { + for (const discount of shippingLine.discounts ?? []) { + if (discount.code) codes.add(discount.code); + } + } + + return Array.from(codes).sort(); +} + +// Express wallets accept a single coupon, so use the one worth the most. +export function getHighestValueDraftOrderDiscountCode( + draftOrder?: DraftOrder | null +): string | undefined { + const totalsByCode = new Map(); + const addDiscounts = ( + discounts?: ReadonlyArray<{ + code?: string | null; + amount?: { value?: number | null } | null; + }> | null + ) => { + for (const discount of discounts ?? []) { + if (!discount.code) continue; + totalsByCode.set( + discount.code, + (totalsByCode.get(discount.code) ?? 0) + (discount.amount?.value ?? 0) + ); + } + }; + + addDiscounts(draftOrder?.discounts); + for (const lineItem of draftOrder?.lineItems ?? []) { + addDiscounts(lineItem.discounts); + } + for (const shippingLine of draftOrder?.shippingLines ?? []) { + addDiscounts(shippingLine.discounts); + } + + let highestCode: string | undefined; + let highestTotal = Number.NEGATIVE_INFINITY; + for (const [code, total] of totalsByCode) { + if (total > highestTotal) { + highestCode = code; + highestTotal = total; + } + } + + return highestCode; +} diff --git a/packages/react/src/components/checkout/discount/utils/use-apply-discount-core.test.ts b/packages/react/src/components/checkout/discount/utils/use-apply-discount-core.test.ts new file mode 100644 index 00000000..59035122 --- /dev/null +++ b/packages/react/src/components/checkout/discount/utils/use-apply-discount-core.test.ts @@ -0,0 +1,96 @@ +import { QueryClient } from '@tanstack/react-query'; +import { describe, expect, it } from 'vitest'; +import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys'; +import { updateDiscountCache } from './use-apply-discount-core'; + +type UpdatedOrder = Parameters[2]; + +const freeShippingDiscount = { + id: 'discount-freeship', + code: 'freeship', + amount: { value: 500, currencyCode: 'USD' }, +}; + +function seedDraftOrder(queryClient: QueryClient) { + queryClient.setQueryData(checkoutQueryKeys.draftOrder('session-1'), { + checkoutSession: { + id: 'session-1', + draftOrder: { + id: 'draft-order-1', + totals: {}, + discounts: [], + lineItems: [], + shippingLines: [ + { id: 'shipping-a', name: 'A', discounts: [] }, + { id: 'shipping-b', name: 'B', discounts: [] }, + ], + }, + }, + }); +} + +function cachedShippingLines(queryClient: QueryClient) { + return ( + queryClient.getQueryData(checkoutQueryKeys.draftOrder('session-1')) as { + checkoutSession: { + draftOrder: { + shippingLines: Array<{ id: string; discounts: unknown[] }>; + }; + }; + } + ).checkoutSession.draftOrder.shippingLines; +} + +describe('updateDiscountCache', () => { + it('matches shipping-line discounts by id, not position', () => { + const queryClient = new QueryClient(); + seedDraftOrder(queryClient); + + updateDiscountCache( + queryClient, + 'session-1', + { + shippingLines: [ + { id: 'shipping-b', discounts: [freeShippingDiscount] }, + { id: 'shipping-a', discounts: [] }, + ], + } as unknown as UpdatedOrder, + ['freeship'] + ); + + expect(cachedShippingLines(queryClient)).toEqual([ + expect.objectContaining({ id: 'shipping-a', discounts: [] }), + expect.objectContaining({ + id: 'shipping-b', + discounts: [freeShippingDiscount], + }), + ]); + }); + + it('clears shipping-line discounts that are missing from the response when codes are removed', () => { + const queryClient = new QueryClient(); + seedDraftOrder(queryClient); + updateDiscountCache( + queryClient, + 'session-1', + { + shippingLines: [ + { id: 'shipping-a', discounts: [freeShippingDiscount] }, + ], + } as unknown as UpdatedOrder, + ['freeship'] + ); + + updateDiscountCache( + queryClient, + 'session-1', + { shippingLines: [] } as unknown as UpdatedOrder, + [] + ); + + expect(cachedShippingLines(queryClient)).toEqual([ + expect.objectContaining({ id: 'shipping-a', discounts: [] }), + expect.objectContaining({ id: 'shipping-b', discounts: [] }), + ]); + }); +}); diff --git a/packages/react/src/components/checkout/discount/utils/use-apply-discount-core.ts b/packages/react/src/components/checkout/discount/utils/use-apply-discount-core.ts new file mode 100644 index 00000000..490fd1d7 --- /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 +>; +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 440cf1d8..5470a350 100644 --- a/packages/react/src/components/checkout/discount/utils/use-discount-apply.ts +++ b/packages/react/src/components/checkout/discount/utils/use-discount-apply.ts @@ -1,181 +1,12 @@ -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(); + const reconcileAfterDiscount = useReconcileAfterDiscount(); - 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), - }); + return useApplyDiscountCore({ + onSuccess: async (_data, variables) => { + await reconcileAfterDiscount(variables); }, }); } diff --git a/packages/react/src/components/checkout/discount/utils/use-reconcile-after-discount.ts b/packages/react/src/components/checkout/discount/utils/use-reconcile-after-discount.ts new file mode 100644 index 00000000..14bcc20f --- /dev/null +++ b/packages/react/src/components/checkout/discount/utils/use-reconcile-after-discount.ts @@ -0,0 +1,166 @@ +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 { + 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 { + type ApplyDiscountVariables, + 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) => { + if (!session) return; + + const deliveryMethod = form.getValues('deliveryMethod'); + const shippingAddress = draftOrder?.shipping?.address; + const hasShippingDestination = Boolean( + shippingAddress?.addressLine1 && + shippingAddress.postalCode && + shippingAddress.countryCode + ); + + if (deliveryMethod === DeliveryMethods.SHIP && hasShippingDestination) { + const previousShippingMethods = shippingMethodsQuery.data ?? []; + const { data, isError } = await shippingMethodsQuery.refetch(); + const refreshedMethods = isError ? [] : (data ?? []); + const isAutoSelected = Boolean( + form.getValues('shippingMethodAutoSelected') + ); + const shippingRequiresReconciliation = requiresShippingReconciliation({ + shippingMethods: refreshedMethods, + previousShippingMethods, + currentShippingLine: draftOrder?.shippingLines?.[0], + selectedServiceCode: form.getValues('shippingMethod'), + isAutoSelected, + }); + + if (shippingRequiresReconciliation) { + const currentServiceCode = + form.getValues('shippingMethod') || + draftOrder?.shippingLines?.[0]?.requestedService; + const { selectedMethod, autoSelected } = selectShippingMethod({ + shippingMethods: refreshedMethods, + currentServiceCode, + previousShippingMethods, + isAutoSelected, + }); + + try { + await applyShippingMethod.mutateAsync( + selectedMethod ? buildShippingPayload(selectedMethod) : [] + ); + } catch (error) { + if ( + error instanceof GraphQLErrorWithCodes && + error.codes.length > 0 + ) { + setCheckoutErrors(error.codes); + } else { + setCheckoutErrors(['SHIPPING_METHOD_APPLICATION_FAILED']); + } + + // The failed reapplication left the order's shipping line stale + // (possibly no longer valid, e.g. a coupon that granted it is gone). + // Clear it server-side so the order itself is no longer stale, + // rather than just invalidating caches around unchanged data. If the + // failed attempt was already a clear (no selectedMethod), retrying + // the identical call would just duplicate the request for no gain. + if (selectedMethod) { + try { + await applyShippingMethod.mutateAsync([]); + form.setValue('shippingMethod', '', { shouldDirty: false }); + } catch { + // Clearing failed too; fall through to invalidation below so + // mounted views still refresh against whatever the server has. + } + } + + // Invalidate after the clear settles so the refetch it triggers + // picks up the now-actually-empty shippingLines instead of racing + // ahead of the clear and re-caching the stale line. + await queryClient.invalidateQueries({ + queryKey: checkoutQueryKeys.draftOrder(session.id), + }); + await queryClient.invalidateQueries({ + queryKey: checkoutQueryKeys.draftOrderShippingMethods(session.id), + }); + return; + } + + setCheckoutErrors(undefined); + form.setValue('shippingMethod', selectedMethod?.serviceCode ?? '', { + shouldDirty: false, + }); + form.setValue('shippingMethodAutoSelected', autoSelected, { + shouldDirty: false, + }); + + if (session.enablePromotionCodes && variables.discountCodes?.length) { + await reapplyDiscount.mutateAsync(variables); + } + + if (session.enableTaxCollection) { + await updateTaxes.mutateAsync(undefined); + } else { + await invalidateDraftOrder(); + } + return; + } + } + + if (session.enableTaxCollection) { + if (deliveryMethod === DeliveryMethods.PICKUP) { + const pickupLocationId = form.getValues('pickupLocationId'); + const locationAddress = session.locations?.find( + location => location.id === pickupLocationId + )?.address; + + if (locationAddress) { + await updateTaxes.mutateAsync(locationAddress); + return; + } + } else if ( + deliveryMethod === DeliveryMethods.PURCHASE || + deliveryMethod === DeliveryMethods.DIGITAL + ) { + const billingAddress = draftOrder?.billing?.address; + + if (billingAddress?.postalCode && billingAddress?.countryCode) { + await updateTaxes.mutateAsync(billingAddress); + return; + } + } else if (shippingAddress?.postalCode && shippingAddress?.countryCode) { + await updateTaxes.mutateAsync(undefined); + return; + } + } + + await invalidateDraftOrder(); + }; + + function invalidateDraftOrder() { + return queryClient.invalidateQueries({ + queryKey: checkoutQueryKeys.draftOrder(session?.id), + }); + } +} diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.test.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.test.tsx new file mode 100644 index 00000000..8c4d5c0f --- /dev/null +++ b/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.test.tsx @@ -0,0 +1,205 @@ +import { enUs } from '@godaddy/localizations'; +import { act, render, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { DraftOrder } from '@/types'; +import { ExpressCheckoutButton } from './godaddy'; + +type Handler = (event: Record) => Promise | void; + +const mocks = vi.hoisted(() => ({ + draftOrder: null as unknown, + getPriceAdjustments: vi.fn(), + getShippingMethods: vi.fn(), + getTaxes: vi.fn(), + tokenizeOptions: [] as Array>, + handlers: {} as Record, +})); + +vi.mock('@/components/checkout/checkout', () => ({ + useCheckoutContext: () => ({ + session: { + id: 'session-1', + storeId: 'store-1', + channelId: 'channel-1', + enablePromotionCodes: true, + enableShippingAddressCollection: true, + enableTaxCollection: false, + }, + godaddyPaymentsConfig: { appId: 'app-1', businessId: 'business-1' }, + setCheckoutErrors: vi.fn(), + isConfirmingCheckout: false, + }), +})); +vi.mock('@/components/checkout/order/use-draft-order', () => ({ + useDraftOrder: () => ({ data: mocks.draftOrder }), + useDraftOrderTotals: () => ({ + data: (mocks.draftOrder as DraftOrder | null)?.totals, + }), +})); +vi.mock( + '@/components/checkout/discount/utils/use-get-price-adjustments', + () => ({ + useGetPriceAdjustments: () => ({ mutateAsync: mocks.getPriceAdjustments }), + }) +); +vi.mock( + '@/components/checkout/shipping/utils/use-get-shipping-methods', + () => ({ + useGetShippingMethodByAddress: () => ({ + mutateAsync: mocks.getShippingMethods, + }), + }) +); +vi.mock('@/components/checkout/taxes/utils/use-get-taxes', () => ({ + useGetTaxes: () => ({ mutateAsync: mocks.getTaxes }), +})); +vi.mock('@/components/checkout/payment/utils/use-load-poynt-collect', () => ({ + useLoadPoyntCollect: () => ({ isPoyntLoaded: true }), +})); +vi.mock( + '@/components/checkout/payment/utils/use-build-payment-request', + () => ({ + useBuildPaymentRequest: () => ({ + poyntExpressRequest: { + lineItems: [], + total: { label: 'Total', amount: '25.00' }, + }, + }), + }) +); +vi.mock( + '@/components/checkout/payment/utils/use-confirm-express-checkout', + () => ({ useConfirmExpressCheckout: () => ({ mutateAsync: vi.fn() }) }) +); +vi.mock('@/components/checkout/payment/utils/use-is-payment-disabled', () => ({ + useIsPaymentDisabled: () => false, +})); +vi.mock('@/godaddy-provider', () => ({ + useGoDaddyContext: () => ({ t: enUs, locale: 'en-US' }), +})); +vi.mock('@/tracking/track', async importOriginal => ({ + ...(await importOriginal()), + track: vi.fn(), +})); + +class FakeTokenizeJs { + constructor(_config: unknown, options: Record) { + mocks.tokenizeOptions.push(options); + } + supportWalletPayments() { + return Promise.resolve({ applePay: true, googlePay: false }); + } + mount() { + return undefined; + } + on(event: string, handler: Handler) { + mocks.handlers[event] = handler; + } +} + +function draftOrder({ + subTotal = 2500, + id = 'draft-order-1', +}: { + subTotal?: number; + id?: string; +} = {}) { + return { + id, + totals: { + subTotal: { value: subTotal, currencyCode: 'USD' }, + total: { value: subTotal, currencyCode: 'USD' }, + }, + discounts: [{ code: 'SAVE5', amount: { value: 500 } }], + lineItems: [ + { id: 'line-1', discounts: [{ code: 'BIG', amount: { value: 400 } }] }, + { id: 'line-2', discounts: [{ code: 'BIG', amount: { value: 400 } }] }, + ], + shippingLines: [], + } as unknown as DraftOrder; +} + +function rate(serviceCode: string, value: number) { + return { + serviceCode, + displayName: serviceCode, + carrierCode: 'carrier', + description: null, + cost: { value, currencyCode: 'USD' }, + }; +} + +describe('ExpressCheckoutButton coupon sync', () => { + beforeEach(() => { + mocks.draftOrder = draftOrder(); + mocks.tokenizeOptions = []; + mocks.handlers = {}; + mocks.getPriceAdjustments.mockReset().mockResolvedValue({ + totalDiscountAmount: { value: 800, currencyCode: 'USD' }, + }); + mocks.getShippingMethods.mockReset(); + mocks.getTaxes.mockReset(); + (window as unknown as { TokenizeJs: unknown }).TokenizeJs = FakeTokenizeJs; + }); + + it('sends the highest-value coupon code to the wallet', async () => { + render(); + + await waitFor(() => expect(mocks.tokenizeOptions).toHaveLength(1)); + expect(mocks.getPriceAdjustments).toHaveBeenCalledWith({ + discountCodes: ['BIG'], + }); + expect(mocks.tokenizeOptions[0].couponCode).toMatchObject({ code: 'BIG' }); + }); + + it('recomputes adjustments when the subtotal changes, not on unrelated order updates', async () => { + const { rerender } = render(); + await waitFor(() => expect(mocks.getPriceAdjustments).toHaveBeenCalled()); + + mocks.draftOrder = draftOrder({ id: 'draft-order-1' }); + rerender(); + await act(() => Promise.resolve()); + expect(mocks.getPriceAdjustments).toHaveBeenCalledTimes(1); + + mocks.draftOrder = draftOrder({ subTotal: 5000 }); + rerender(); + await waitFor(() => + expect(mocks.getPriceAdjustments).toHaveBeenCalledTimes(2) + ); + }); + + it('refreshes the wallet shipping methods when a coupon is removed', async () => { + render(); + await waitFor(() => + expect(mocks.handlers.shipping_address_change).toBeDefined() + ); + + mocks.getShippingMethods.mockResolvedValueOnce([rate('standard', 1000)]); + await act(async () => { + await mocks.handlers.shipping_address_change({ + shippingAddress: { countryCode: 'US', postalCode: '85001' }, + updateWith: vi.fn(), + }); + }); + expect(mocks.getShippingMethods).toHaveBeenCalledTimes(1); + + mocks.getShippingMethods.mockResolvedValueOnce([ + rate('standard', 1000), + rate('express', 2000), + ]); + const updateWith = vi.fn(); + await act(async () => { + await mocks.handlers.coupon_code_change({ couponCode: '', updateWith }); + }); + + expect(mocks.getShippingMethods).toHaveBeenCalledTimes(2); + expect(updateWith).toHaveBeenCalledWith( + expect.objectContaining({ + shippingMethods: [ + expect.objectContaining({ id: 'standard', amount: '10.00' }), + expect.objectContaining({ id: 'express', amount: '20.00' }), + ], + }) + ); + }); +}); diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx index c71f6c9c..b8da8651 100644 --- a/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx +++ b/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx @@ -1,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 @@ -585,6 +546,20 @@ export function ExpressCheckoutButton() { // Start with the base line items const baseLineItems = [...poyntExpressRequest.lineItems]; + // Refetch shipping methods so rates reflect the coupon change (e.g. free-shipping discounts) + let refreshedShippingMethods: Awaited< + ReturnType + > = []; + if (shippingAddress) { + try { + refreshedShippingMethods = await getSortedShippingMethods({ + shippingAddress, + }); + } catch (_shippingError) { + refreshedShippingMethods = []; + } + } + if (!couponCode) { // User removed the coupon code - clear refs appliedCouponCodeRef.current = null; @@ -873,6 +848,16 @@ export function ExpressCheckoutButton() { } } + // Include the refreshed shipping methods so the wallet reflects rates for the current coupon state + if (!updatedOrder.error && refreshedShippingMethods.length) { + updatedOrder.shippingMethods = refreshedShippingMethods.map(method => ({ + id: method.id || '', + label: method.label, + detail: method.detail, + amount: method.displayAmount, + })); + } + // Update the wallet with the new order information // console.log("[poynt collect] Updating order with", { updatedOrder }); e.updateWith(updatedOrder); diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.test.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.test.tsx new file mode 100644 index 00000000..29d50e67 --- /dev/null +++ b/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.test.tsx @@ -0,0 +1,167 @@ +import { enUs } from '@godaddy/localizations'; +import { act, render, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { DraftOrder } from '@/types'; +import { StripeExpressCheckoutForm } from './stripe'; + +const mocks = vi.hoisted(() => ({ + draftOrder: null as unknown, + getPriceAdjustments: vi.fn(), + getShippingMethods: vi.fn(), + elementProps: {} as Record unknown>, +})); + +vi.mock('@stripe/react-stripe-js', () => ({ + ExpressCheckoutElement: ( + props: Record unknown> + ) => { + mocks.elementProps = props; + return null; + }, + useElements: () => ({ update: vi.fn() }), +})); +vi.mock('@/components/checkout/checkout', () => ({ + useCheckoutContext: () => ({ + session: { + id: 'session-1', + enablePromotionCodes: true, + enableShippingAddressCollection: true, + enableTaxCollection: false, + }, + setCheckoutErrors: vi.fn(), + isConfirmingCheckout: false, + }), +})); +vi.mock('@/components/checkout/order/use-draft-order', () => ({ + useDraftOrder: () => ({ data: mocks.draftOrder }), + useDraftOrderTotals: () => ({ + data: (mocks.draftOrder as DraftOrder | null)?.totals, + }), +})); +vi.mock( + '@/components/checkout/discount/utils/use-get-price-adjustments', + () => ({ + useGetPriceAdjustments: () => ({ mutateAsync: mocks.getPriceAdjustments }), + }) +); +vi.mock( + '@/components/checkout/shipping/utils/use-get-shipping-methods', + () => ({ + useGetShippingMethodByAddress: () => ({ + mutateAsync: mocks.getShippingMethods, + }), + }) +); +vi.mock('@/components/checkout/taxes/utils/use-get-taxes', () => ({ + useGetTaxes: () => ({ mutateAsync: vi.fn() }), +})); +vi.mock('@/components/checkout/payment/utils/use-stripe-checkout', () => ({ + useStripeCheckout: () => ({ handleSubmit: vi.fn() }), +})); +vi.mock('@/components/checkout/payment/utils/use-is-payment-disabled', () => ({ + useIsPaymentDisabled: () => false, +})); +vi.mock('@/godaddy-provider', () => ({ + useGoDaddyContext: () => ({ t: enUs, locale: 'en-US' }), +})); +vi.mock('@/tracking/track', async importOriginal => ({ + ...(await importOriginal()), + track: vi.fn(), +})); + +function draftOrder({ + subTotal = 2500, + lineCode = 'BIG', +}: { + subTotal?: number; + lineCode?: string; +} = {}) { + return { + id: 'draft-order-1', + totals: { + subTotal: { value: subTotal, currencyCode: 'USD' }, + total: { value: subTotal, currencyCode: 'USD' }, + }, + discounts: [{ code: 'SAVE5', amount: { value: 500 } }], + lineItems: [ + { id: 'line-1', discounts: [{ code: lineCode, amount: { value: 800 } }] }, + ], + shippingLines: [], + } as unknown as DraftOrder; +} + +describe('StripeExpressCheckoutForm coupon sync', () => { + beforeEach(() => { + mocks.draftOrder = draftOrder(); + mocks.elementProps = {}; + mocks.getPriceAdjustments.mockReset().mockResolvedValue({ + totalDiscountAmount: { value: 800, currencyCode: 'USD' }, + }); + mocks.getShippingMethods.mockReset().mockResolvedValue([ + { + serviceCode: 'standard', + displayName: 'Standard', + carrierCode: 'carrier', + description: null, + cost: { value: 1000, currencyCode: 'USD' }, + }, + ]); + }); + + it('uses the highest-value coupon code', async () => { + render(); + + await waitFor(() => + expect(mocks.getPriceAdjustments).toHaveBeenCalledWith({ + discountCodes: ['BIG'], + }) + ); + }); + + it('recomputes adjustments when the subtotal changes, not on unrelated order updates', async () => { + const { rerender } = render(); + await waitFor(() => expect(mocks.getPriceAdjustments).toHaveBeenCalled()); + + mocks.draftOrder = draftOrder(); + rerender(); + await act(() => Promise.resolve()); + expect(mocks.getPriceAdjustments).toHaveBeenCalledTimes(1); + + mocks.draftOrder = draftOrder({ subTotal: 5000 }); + rerender(); + await waitFor(() => + expect(mocks.getPriceAdjustments).toHaveBeenCalledTimes(2) + ); + }); + + it('does not refetch or reselect shipping when the coupon changes while the sheet is open', async () => { + const { rerender } = render(); + await waitFor(() => expect(mocks.getPriceAdjustments).toHaveBeenCalled()); + + await act(async () => { + await mocks.elementProps.onShippingAddressChange({ + address: { + city: 'Phoenix', + state: 'AZ', + postal_code: '85001', + country: 'US', + }, + resolve: vi.fn(), + reject: vi.fn(), + }); + }); + expect(mocks.getShippingMethods).toHaveBeenCalledTimes(1); + const priceAdjustmentCalls = mocks.getPriceAdjustments.mock.calls.length; + + mocks.draftOrder = draftOrder({ lineCode: 'BIGGER' }); + rerender(); + await waitFor(() => + expect(mocks.getPriceAdjustments).toHaveBeenCalledTimes( + priceAdjustmentCalls + 1 + ) + ); + await act(() => Promise.resolve()); + + expect(mocks.getShippingMethods).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.tsx index 42107715..a32c8f36 100644 --- a/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.tsx +++ b/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.tsx @@ -10,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 bbf5ea1b..06acf34d 100644 --- a/packages/react/src/components/checkout/payment/utils/use-flush-checkout-sync.test.tsx +++ b/packages/react/src/components/checkout/payment/utils/use-flush-checkout-sync.test.tsx @@ -1,3 +1,4 @@ +import { QueryObserver } from '@tanstack/react-query'; import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; @@ -151,6 +152,36 @@ async function clickFlush(user: ReturnType) { } describe('useFlushCheckoutSync', () => { + it('waits for a failed shipping refresh without validating checkout state', async () => { + const onCheckoutErrors = vi.fn(); + const { user, queryClient } = renderHookHost({ + onCheckoutErrors, + timeoutMs: 1000, + }); + const request = deferred(); + const observer = new QueryObserver(queryClient, { + queryKey: + checkoutQueryKeys.draftOrderShippingMethods('checkout-session-1'), + queryFn: () => request.promise, + retry: false, + }); + const unsubscribe = observer.subscribe(() => undefined); + try { + await clickFlush(user); + await act(async () => { + request.reject(new Error('Provider unavailable')); + await flushPromises(); + await vi.advanceTimersByTimeAsync(60); + }); + await waitFor(() => + expect(screen.getByTestId('result')).toHaveTextContent('resolved') + ); + expect(onCheckoutErrors).not.toHaveBeenCalled(); + } finally { + unsubscribe(); + } + }); + it('excludes tax mutation waits when tax collection is disabled', async () => { const { user } = renderHookHost({ enableTaxCollection: false, diff --git a/packages/react/src/components/checkout/shipping/shipping-method.tsx b/packages/react/src/components/checkout/shipping/shipping-method.tsx index 4a8a8172..da826ef2 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,22 @@ 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 { 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 +31,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 +39,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 +60,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<{ @@ -99,14 +90,31 @@ export function ShippingMethodForm() { }); 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; @@ -123,6 +131,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) @@ -131,8 +153,16 @@ export function ShippingMethodForm() { : lastState.hadShippingMethods || !lastState.clearedShippingMethod; if (shouldClearShipping) { + const previousShippingMethod = + form.getValues('shippingMethod') || currentServiceCode || ''; form.setValue('shippingMethod', '', { shouldDirty: false }); - applyShippingMethod.mutate([]); + applyShippingMethod.mutate([], { + onError: () => { + form.setValue('shippingMethod', previousShippingMethod, { + shouldDirty: false, + }); + }, + }); lastProcessedStateRef.current = { serviceCode: null, cost: null, @@ -155,19 +185,22 @@ export function ShippingMethodForm() { // Case 2: Shipping methods available - apply or re-apply as needed if (hasShippingMethods) { - const firstMethod = shippingMethods[0]; const currentFormMethod = form.getValues('shippingMethod'); const existingMethod = currentFormMethod || currentServiceCode; + const isInitialSelection = lastShippingMethodsRef.current === null; + const previousAutoSelected = Boolean( + form.getValues('shippingMethodAutoSelected') + ); + const { selectedMethod: methodToApply, autoSelected } = + selectShippingMethod({ + shippingMethods, + currentServiceCode: existingMethod, + previousShippingMethods: lastShippingMethodsRef.current, + isAutoSelected: previousAutoSelected, + }); + lastShippingMethodsRef.current = shippingMethods; - // Try to find the existing method in available methods. Prefer the - // current form selection so an in-flight explicit user click is not - // overwritten by the stale draft-order shipping line while the mutation - // and refetch settle. - const matchedMethod = existingMethod - ? shippingMethods.find(m => m.serviceCode === existingMethod) - : null; - - const methodToApply = matchedMethod || firstMethod; + if (!methodToApply) return; // Check if we've already processed this exact state. If cart contents // changed after a shipping method was selected, shippingLines can still // match the selected rate while new line items are NONE. In that case we @@ -185,6 +218,9 @@ export function ShippingMethodForm() { form.setValue('shippingMethod', methodToApply.serviceCode, { shouldDirty: false, }); + form.setValue('shippingMethodAutoSelected', autoSelected, { + shouldDirty: false, + }); if (needsMutation) { const isFulfillmentSync = Boolean( @@ -198,8 +234,25 @@ export function ShippingMethodForm() { }; } - applyShippingMethod.mutate(buildShippingPayload(methodToApply)); - } else if (session?.enableTaxCollection) { + const previousShippingMethod = + currentFormMethod || currentServiceCode || ''; + applyShippingMethod.mutate(buildShippingPayload(methodToApply), { + onError: () => { + form.setValue('shippingMethod', previousShippingMethod, { + shouldDirty: false, + }); + form.setValue( + 'shippingMethodAutoSelected', + previousAutoSelected, + { + shouldDirty: false, + } + ); + }, + }); + } 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); } @@ -217,10 +270,13 @@ export function ShippingMethodForm() { } }, [ isConfirmingCheckout, + isApplyingDiscount, shippingMethods, shippingLines, hasShippingAddress, isShippingMethodsLoading, + isShippingMethodsFetching, + isShippingMethodsError, form, applyShippingMethod, updateTaxes.mutate, @@ -247,6 +303,26 @@ export function ShippingMethodForm() { ); } + if (hasShippingAddress && isShippingMethodsError) { + return ( +
+

+ {t.shipping.failedToLoadMethods} +

+ +
+ ); + } + if ( hasShippingAddress && !isShippingMethodsLoading && @@ -274,11 +350,13 @@ export function ShippingMethodForm() { } const previousValue = form.getValues('shippingMethod'); + const previousAutoSelected = form.getValues('shippingMethodAutoSelected'); const previousProcessedState = lastProcessedStateRef.current; form.setValue('shippingMethod', value, { shouldDirty: true, shouldValidate: true, }); + form.setValue('shippingMethodAutoSelected', false); const method = shippingMethods?.find(m => m.serviceCode === value); @@ -313,6 +391,7 @@ export function ShippingMethodForm() { .mutateAsync(buildShippingPayload(method)) .catch(() => { form.setValue('shippingMethod', previousValue); + form.setValue('shippingMethodAutoSelected', previousAutoSelected); lastProcessedStateRef.current = previousProcessedState; }); } diff --git a/packages/react/src/components/checkout/shipping/utils/build-shipping-payload.ts b/packages/react/src/components/checkout/shipping/utils/build-shipping-payload.ts new file mode 100644 index 00000000..f393d5b5 --- /dev/null +++ b/packages/react/src/components/checkout/shipping/utils/build-shipping-payload.ts @@ -0,0 +1,18 @@ +import type { ShippingMethod } from '@/types'; + +export function buildShippingPayload(method: ShippingMethod) { + const currencyCode = method.cost?.currencyCode || 'USD'; + + return [ + { + taxTotal: { value: 0, currencyCode }, + subTotal: { + value: method.cost?.value || 0, + currencyCode, + }, + requestedService: method.serviceCode, + requestedProvider: method.carrierCode, + name: method.displayName || '', + }, + ]; +} diff --git a/packages/react/src/components/checkout/shipping/utils/filter-shipping-methods.ts b/packages/react/src/components/checkout/shipping/utils/filter-shipping-methods.ts deleted file mode 100644 index 677fed18..00000000 --- a/packages/react/src/components/checkout/shipping/utils/filter-shipping-methods.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { CheckoutSession, ShippingMethod } from '@/types'; - -interface FilterShippingMethodsParams { - shippingMethods: ShippingMethod[]; - orderSubTotal: number; - experimentalRules?: CheckoutSession['experimental_rules']; -} - -export function filterAndSortShippingMethods({ - shippingMethods, - orderSubTotal, - experimentalRules, -}: FilterShippingMethodsParams): ShippingMethod[] { - const enableFreeShippingRule = experimentalRules?.freeShipping?.enabled; - const freeShippingMinimumOrderTotal = - experimentalRules?.freeShipping?.minimumOrderTotal || 0; - - return shippingMethods - .filter( - method => - !( - enableFreeShippingRule && - method?.cost?.value === 0 && - orderSubTotal < freeShippingMinimumOrderTotal - ) - ) - .sort((a, b) => { - const costA = a?.cost?.value || 0; - const costB = b?.cost?.value || 0; - - // First sort by cost - if (costA !== costB) { - return costA - costB; - } - - // If costs are equal, sort by name - const nameA = a?.displayName || ''; - const nameB = b?.displayName || ''; - return nameA.localeCompare(nameB); - }); -} diff --git a/packages/react/src/components/checkout/shipping/utils/requires-shipping-reconciliation.test.ts b/packages/react/src/components/checkout/shipping/utils/requires-shipping-reconciliation.test.ts new file mode 100644 index 00000000..bcc715ed --- /dev/null +++ b/packages/react/src/components/checkout/shipping/utils/requires-shipping-reconciliation.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it } from 'vitest'; +import type { ShippingLines, ShippingMethod } from '@/types'; +import { + 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('falls back to the cheapest method when the current one is gone', () => { + expect( + selectShippingMethod({ + shippingMethods: [standard], + currentServiceCode: 'express', + previousShippingMethods: [standard, express], + isAutoSelected: false, + }) + ).toMatchObject({ selectedMethod: standard, autoSelected: true }); + }); +}); diff --git a/packages/react/src/components/checkout/shipping/utils/requires-shipping-reconciliation.ts b/packages/react/src/components/checkout/shipping/utils/requires-shipping-reconciliation.ts new file mode 100644 index 00000000..08ae06a3 --- /dev/null +++ b/packages/react/src/components/checkout/shipping/utils/requires-shipping-reconciliation.ts @@ -0,0 +1,94 @@ +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; +} + +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 00000000..7aafc090 --- /dev/null +++ b/packages/react/src/components/checkout/shipping/utils/sort-shipping-methods.ts @@ -0,0 +1,18 @@ +import type { ShippingMethod } from '@/types'; + +export function sortShippingMethods( + shippingMethods: ShippingMethod[] +): ShippingMethod[] { + return [...shippingMethods].sort((a, b) => { + const costA = a?.cost?.value || 0; + const costB = b?.cost?.value || 0; + + if (costA !== costB) { + return costA - costB; + } + + const nameA = a?.displayName || ''; + const nameB = b?.displayName || ''; + return nameA.localeCompare(nameB); + }); +} diff --git a/packages/react/src/components/checkout/shipping/utils/use-apply-shipping-method-core.ts b/packages/react/src/components/checkout/shipping/utils/use-apply-shipping-method-core.ts new file mode 100644 index 00000000..9181d0ef --- /dev/null +++ b/packages/react/src/components/checkout/shipping/utils/use-apply-shipping-method-core.ts @@ -0,0 +1,97 @@ +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, + shippingLines: shippingMethods[0] + ? [ + { + ...cached.checkoutSession?.draftOrder?.shippingLines?.[0], + name: shippingMethods[0].name, + requestedProvider: + shippingMethods[0].requestedProvider ?? null, + requestedService: + shippingMethods[0].requestedService ?? null, + amount: { ...shippingTotal }, + }, + ] + : [], + totals: { + ...cached.checkoutSession?.draftOrder?.totals, + shippingTotal: { ...shippingTotal }, + }, + }, + }, + }; + } + ); +} + +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 331fedee..2b50fab7 100644 --- a/packages/react/src/components/checkout/shipping/utils/use-apply-shipping-method.ts +++ b/packages/react/src/components/checkout/shipping/utils/use-apply-shipping-method.ts @@ -1,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 dcb2f349..90fd2424 100644 --- a/packages/react/src/components/checkout/shipping/utils/use-draft-order-shipping-methods.ts +++ b/packages/react/src/components/checkout/shipping/utils/use-draft-order-shipping-methods.ts @@ -58,15 +58,28 @@ export function useDraftOrderShippingMethods() { countryCode: shippingAddress?.countryCode, }, ], - queryFn: () => - jwt - ? getDraftOrderShippingMethods( + queryFn: async () => { + const result = jwt + ? await getDraftOrderShippingMethods( { accessToken: jwt }, destination, apiHost ) - : getDraftOrderShippingMethods(session, destination, apiHost), + : await getDraftOrderShippingMethods(session, destination, apiHost); + // A null response can represent an upstream provider failure. Only an + // actual array (including []) is a validated shipping-rate result. + if ( + !Array.isArray( + result?.checkoutSession?.draftOrder?.calculatedShippingRates?.rates + ) + ) { + throw new Error('Shipping rates are unavailable'); + } + return result; + }, enabled: !!session?.id && hasShippingAddress, + // The failure UI offers a manual retry; don't stack automatic retries on it. + retry: false, select: data => data?.checkoutSession?.draftOrder?.calculatedShippingRates?.rates, }); diff --git a/packages/react/src/components/checkout/shipping/utils/use-remove-shipping-method.ts b/packages/react/src/components/checkout/shipping/utils/use-remove-shipping-method.ts index f1450dff..1b4807d6 100644 --- a/packages/react/src/components/checkout/shipping/utils/use-remove-shipping-method.ts +++ b/packages/react/src/components/checkout/shipping/utils/use-remove-shipping-method.ts @@ -1,7 +1,8 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import type { ResultOf } from 'gql.tada'; import { useCheckoutContext } from '@/components/checkout/checkout'; -import { useDiscountApply } from '@/components/checkout/discount'; +import { getDraftOrderDiscountCodes } from '@/components/checkout/discount/utils/get-draft-order-discount-codes'; +import { useApplyDiscountCore } from '@/components/checkout/discount/utils/use-apply-discount-core'; import { useDraftOrder } from '@/components/checkout/order/use-draft-order'; import { checkoutMutationKeys, @@ -17,7 +18,9 @@ export function useRemoveShippingMethod() { const { apiHost } = useGoDaddyContext(); const queryClient = useQueryClient(); const { data: order } = useDraftOrder(); - const applyDiscount = useDiscountApply(); + // The core mutation skips shipping reconciliation, which could otherwise + // re-apply a shipping method right after it is removed. + const applyDiscount = useApplyDiscountCore(); return useMutation({ mutationKey: checkoutMutationKeys.removeShippingMethod(session?.id), @@ -49,14 +52,7 @@ export function useRemoveShippingMethod() { ...old.checkoutSession, draftOrder: { ...old?.checkoutSession?.draftOrder, - shippingLines: [ - { - ...old?.checkoutSession?.draftOrder?.shippingLines?.[0], - amount: { - ...shippingTotal, - }, - }, - ], + shippingLines: [], totals: { ...old?.checkoutSession?.draftOrder?.totals, shippingTotal: { @@ -70,55 +66,15 @@ export function useRemoveShippingMethod() { ); } - 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); - } - } - } - } - } + const discountCodes = getDraftOrderDiscountCodes(order); - // 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); - } - } - } - } + if (session.enablePromotionCodes && discountCodes.length) { + await applyDiscount.mutateAsync({ discountCodes }); } - const discountCodes = Array.from(allCodes); - - if (session?.enablePromotionCodes && discountCodes?.length) { - /* should re-apply discounts if they were previously applied */ - await applyDiscount.mutateAsync({ - discountCodes, - }); - } else { - await queryClient.invalidateQueries({ - queryKey: checkoutQueryKeys.draftOrder(session.id), - }); - } + await queryClient.invalidateQueries({ + queryKey: checkoutQueryKeys.draftOrder(session.id), + }); }, }); } diff --git a/packages/react/src/components/checkout/utils/checkout-transformers.ts b/packages/react/src/components/checkout/utils/checkout-transformers.ts index 7b51b3ca..1ee398ba 100644 --- a/packages/react/src/components/checkout/utils/checkout-transformers.ts +++ b/packages/react/src/components/checkout/utils/checkout-transformers.ts @@ -228,6 +228,7 @@ export function mapOrderToFormValues({ // shippingMethod shippingMethod: order?.shippingLines?.[0]?.requestedService ?? '', + shippingMethodAutoSelected: false, }; } diff --git a/packages/react/src/lib/godaddy/checkout-env.ts b/packages/react/src/lib/godaddy/checkout-env.ts index 1c5bd8b4..903e0478 100644 --- a/packages/react/src/lib/godaddy/checkout-env.ts +++ b/packages/react/src/lib/godaddy/checkout-env.ts @@ -2020,6 +2020,18 @@ const introspection = { "args": [], "isDeprecated": false }, + { + "name": "orderId", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "String" + } + }, + "args": [], + "isDeprecated": false + }, { "name": "paymentMethods", "type": { @@ -9752,7 +9764,15 @@ const introspection = { "kind": "OBJECT", "name": "CheckoutSession" }, - "args": [], + "args": [ + { + "name": "id", + "type": { + "kind": "SCALAR", + "name": "ID" + } + } + ], "isDeprecated": false }, { diff --git a/packages/react/src/lib/godaddy/checkout-mutations.ts b/packages/react/src/lib/godaddy/checkout-mutations.ts index 89c49081..77e087d6 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 { diff --git a/packages/react/src/lib/godaddy/checkout-queries.ts b/packages/react/src/lib/godaddy/checkout-queries.ts index 42e342f8..0da8f029 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