diff --git a/.changeset/calm-coupons-ship.md b/.changeset/calm-coupons-ship.md
new file mode 100644
index 00000000..5adcce0d
--- /dev/null
+++ b/.changeset/calm-coupons-ship.md
@@ -0,0 +1,5 @@
+---
+"@godaddy/react": patch
+---
+
+Keep shipping rates, discounts, taxes, and express checkout in sync when coupons change.
diff --git a/.changeset/clever-clubs-crash.md b/.changeset/clever-clubs-crash.md
new file mode 100644
index 00000000..336fb607
--- /dev/null
+++ b/.changeset/clever-clubs-crash.md
@@ -0,0 +1,10 @@
+---
+"@godaddy/react": patch
+---
+
+Fix billing collection across checkout flows.
+
+- Align billing fields and validation for paid, free, pickup, shipping, purchase, and digital orders.
+- Respect billing, shipping, phone, and tax collection settings.
+- Clear hidden billing addresses when switching to a names-only flow.
+- Keep totals and taxes accurate when discounts are applied.
diff --git a/packages/react/src/components/checkout/__tests__/checkout-billing.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-billing.test.tsx
index 80ce4978..f8d61774 100644
--- a/packages/react/src/components/checkout/__tests__/checkout-billing.test.tsx
+++ b/packages/react/src/components/checkout/__tests__/checkout-billing.test.tsx
@@ -1,4 +1,4 @@
-import { screen, within } from '@testing-library/react';
+import { screen, waitFor, within } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import * as godaddyApi from '@/lib/godaddy/godaddy';
import {
@@ -89,6 +89,129 @@ describe('Checkout billing behavior', () => {
});
});
+ it('collects names only for paid offline purchase mode when tax collection is disabled', async () => {
+ renderCheckout({
+ draftOrderOverrides: {
+ billing: {
+ firstName: 'Pay',
+ lastName: 'In Person',
+ address: buildBillingAddress({ addressLine1: '' }),
+ },
+ lineItems: [{ fulfillmentMode: 'PURCHASE' }],
+ },
+ sessionOverrides: {
+ enableShipping: false,
+ enableLocalPickup: false,
+ enableBillingAddressCollection: true,
+ enableTaxCollection: false,
+ paymentMethods: {
+ card: null as never,
+ offline: {
+ processor: 'offline',
+ checkoutTypes: ['standard'],
+ },
+ },
+ },
+ });
+ await waitForCheckoutReady();
+
+ await waitFor(() => {
+ expect(
+ document.querySelector('input[name="billingFirstName"]')
+ ).toBeInTheDocument();
+ expect(
+ document.querySelector('input[name="billingLastName"]')
+ ).toBeInTheDocument();
+ expect(
+ document.querySelector('input[name="billingAddressLine1"]')
+ ).not.toBeInTheDocument();
+ expect(
+ document.querySelector('input[name="billingPostalCode"]')
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ it('respects disabled billing address collection in purchase mode even when tax collection is enabled', async () => {
+ renderCheckout({
+ draftOrderOverrides: {
+ billing: {
+ firstName: 'Names',
+ lastName: 'Only',
+ address: buildBillingAddress({ addressLine1: '' }),
+ },
+ lineItems: [{ fulfillmentMode: 'PURCHASE' }],
+ },
+ sessionOverrides: {
+ enableShipping: false,
+ enableLocalPickup: false,
+ enableBillingAddressCollection: false,
+ enableTaxCollection: true,
+ },
+ });
+ await waitForCheckoutReady();
+
+ await waitFor(() => {
+ expect(
+ document.querySelector('input[name="billingFirstName"]')
+ ).toBeInTheDocument();
+ expect(
+ document.querySelector('input[name="billingLastName"]')
+ ).toBeInTheDocument();
+ expect(
+ document.querySelector('input[name="billingAddressLine1"]')
+ ).not.toBeInTheDocument();
+ expect(
+ document.querySelector('input[name="billingPostalCode"]')
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ it('collects billing address for paid offline purchase mode when tax collection is enabled and uses it for taxes', async () => {
+ const { user } = renderCheckout({
+ draftOrderOverrides: {
+ billing: { address: buildBillingAddress({ addressLine1: '' }) },
+ lineItems: [{ fulfillmentMode: 'PURCHASE' }],
+ },
+ sessionOverrides: {
+ enableShipping: false,
+ enableLocalPickup: false,
+ enableBillingAddressCollection: true,
+ enableTaxCollection: true,
+ paymentMethods: {
+ card: null as never,
+ offline: {
+ processor: 'offline',
+ checkoutTypes: ['standard'],
+ },
+ },
+ },
+ });
+ await waitForCheckoutReady();
+
+ expect(
+ document.querySelector('input[name="billingAddressLine1"]')
+ ).toBeInTheDocument();
+
+ await typeIntoNamedField(user, 'billingFirstName', 'Offline');
+ await typeIntoNamedField(user, 'billingLastName', 'Buyer');
+ await typeIntoNamedField(user, 'billingAddressLine1', '456 Tax Lane');
+ await typeIntoNamedField(user, 'billingAdminArea2', 'Austin');
+ await typeIntoNamedField(user, 'billingPostalCode', '78701');
+ await advanceCheckoutDebounce();
+ await waitForOperation('CalculateCheckoutSessionTaxes');
+
+ expect(
+ getOperations('CalculateCheckoutSessionTaxes').at(-1)?.input
+ ).toMatchObject({
+ destination: expect.objectContaining({
+ addressLine1: '456 Tax Lane',
+ adminArea2: 'Austin',
+ postalCode: '78701',
+ countryCode: 'US',
+ }),
+ });
+ });
+
it('copies explicit shipping patches to billing while same-as-shipping is checked, then stops after unchecked', async () => {
const draftOrder = buildDraftOrder();
const session = buildCheckoutSession({ draftOrder });
@@ -185,4 +308,185 @@ describe('Checkout billing behavior', () => {
screen.getByLabelText(/use shipping address as billing/i)
).not.toBeChecked();
});
+
+ it('does not resync matching billing when switching to shipping', async () => {
+ const address = buildShippingAddress({ addressLine1: '10 Shared St' });
+ const contact = {
+ firstName: 'Same',
+ lastName: 'Buyer',
+ phone: '+12015550123',
+ address,
+ };
+ const draftOrder = buildDraftOrder({
+ lineItems: [{ fulfillmentMode: 'PICKUP' }],
+ shipping: contact,
+ billing: contact,
+ });
+ const { user } = renderCheckout({
+ draftOrder,
+ session: buildCheckoutSession({
+ draftOrder,
+ enableShipping: true,
+ enableLocalPickup: true,
+ }),
+ });
+ await waitForCheckoutReady();
+ clearOperations();
+
+ await user.click(screen.getByRole('radio', { name: /^shipping/i }));
+
+ expect(
+ await screen.findByLabelText(/use shipping address as billing/i)
+ ).toBeChecked();
+ await advanceCheckoutDebounce();
+ expect(
+ getOperations('UpdateCheckoutSessionDraftOrder').some(operation =>
+ Object.hasOwn(operation.input as object, 'billing')
+ )
+ ).toBe(false);
+ });
+
+ it('does not clear billing when switching to offline without a collected address', async () => {
+ const draftOrder = buildDraftOrder({
+ lineItems: [{ fulfillmentMode: 'PICKUP' }],
+ billing: {
+ firstName: '',
+ lastName: '',
+ address: null,
+ },
+ });
+ const { user } = renderCheckout({
+ draftOrder,
+ session: buildCheckoutSession({
+ draftOrder,
+ enableShipping: false,
+ enableLocalPickup: true,
+ enableTaxCollection: true,
+ paymentMethods: {
+ card: { processor: 'godaddy', checkoutTypes: ['standard'] } as never,
+ offline: { processor: 'offline', checkoutTypes: ['standard'] },
+ },
+ }),
+ });
+ await waitForCheckoutReady();
+ clearOperations();
+
+ await user.click(
+ await screen.findByRole('button', { name: /offline payments/i })
+ );
+ await advanceCheckoutDebounce();
+
+ expect(getOperations('UpdateCheckoutSessionDraftOrder')).toHaveLength(0);
+ });
+
+ it('clears a collected billing address when switching to offline pickup hides it', async () => {
+ const draftOrder = buildDraftOrder({
+ lineItems: [{ fulfillmentMode: 'PICKUP' }],
+ billing: {
+ firstName: 'Card',
+ lastName: 'Payer',
+ address: buildBillingAddress({ addressLine1: '500 Card St' }),
+ },
+ });
+ const { user } = renderCheckout({
+ draftOrder,
+ session: buildCheckoutSession({
+ draftOrder,
+ enableShipping: false,
+ enableLocalPickup: true,
+ enableTaxCollection: true,
+ paymentMethods: {
+ card: { processor: 'godaddy', checkoutTypes: ['standard'] } as never,
+ offline: { processor: 'offline', checkoutTypes: ['standard'] },
+ },
+ }),
+ });
+ await waitForCheckoutReady();
+ clearOperations();
+
+ // Offline pickup collects names only, so the address the card form had
+ // collected must not stay behind on the draft order where the customer can
+ // no longer see or correct it.
+ await user.click(
+ await screen.findByRole('button', { name: /offline payments/i })
+ );
+ await waitForOperation('UpdateCheckoutSessionDraftOrder');
+
+ expect(getLastUpdateInput()).toMatchObject({
+ billing: { firstName: 'Card', lastName: 'Payer', address: null },
+ });
+ expect(
+ document.querySelector('input[name="billingAddressLine1"]')
+ ).not.toBeInTheDocument();
+ });
+
+ it('clears a collected billing address when switching delivery to offline pickup hides it', async () => {
+ const draftOrder = buildDraftOrder({
+ lineItems: [{ fulfillmentMode: 'SHIP' }],
+ billing: {
+ firstName: 'Jane',
+ lastName: 'Buyer',
+ address: buildBillingAddress({ addressLine1: '77 Separate Way' }),
+ },
+ });
+ const { user } = renderCheckout({
+ draftOrder,
+ session: buildCheckoutSession({
+ draftOrder,
+ enableShipping: true,
+ enableLocalPickup: true,
+ enableTaxCollection: true,
+ paymentMethods: {
+ card: null as never,
+ offline: { processor: 'offline', checkoutTypes: ['standard'] },
+ },
+ }),
+ });
+ await waitForCheckoutReady();
+ expect(
+ document.querySelector('input[name="billingAddressLine1"]')
+ ).toHaveValue('77 Separate Way');
+ clearOperations();
+
+ await user.click(screen.getByRole('radio', { name: /local pickup/i }));
+ await waitForOperation('UpdateCheckoutSessionDraftOrder');
+
+ expect(getLastUpdateInput()).toMatchObject({
+ billing: { firstName: 'Jane', lastName: 'Buyer', address: null },
+ });
+ });
+
+ it('keeps a merchant-provided billing address that offline pickup never asks about', async () => {
+ const draftOrder = buildDraftOrder({
+ lineItems: [{ fulfillmentMode: 'PICKUP' }],
+ billing: {
+ firstName: 'Merchant',
+ lastName: 'Prefill',
+ address: buildBillingAddress({ addressLine1: '1 Prefilled Rd' }),
+ },
+ });
+ renderCheckout({
+ draftOrder,
+ session: buildCheckoutSession({
+ draftOrder,
+ enableShipping: false,
+ enableLocalPickup: true,
+ enableTaxCollection: true,
+ paymentMethods: {
+ card: null as never,
+ offline: { processor: 'offline', checkoutTypes: ['standard'] },
+ },
+ }),
+ });
+ await waitForCheckoutReady();
+ await advanceCheckoutDebounce();
+
+ // Only customer-driven changes clear the address; loading a checkout must
+ // never delete data the merchant put on the draft order.
+ expect(
+ getOperations('UpdateCheckoutSessionDraftOrder').filter(operation =>
+ Object.hasOwn(operation.input as object, 'billing')
+ )
+ ).toHaveLength(0);
+ });
});
diff --git a/packages/react/src/components/checkout/__tests__/checkout-digital-fulfillment.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-digital-fulfillment.test.tsx
index b2bdac7a..c22d9165 100644
--- a/packages/react/src/components/checkout/__tests__/checkout-digital-fulfillment.test.tsx
+++ b/packages/react/src/components/checkout/__tests__/checkout-digital-fulfillment.test.tsx
@@ -221,6 +221,24 @@ describe('Digital fulfillment checkout', () => {
expectBillingNamesOnlyWithPhone();
});
+ it('respects disabled billing address collection for taxable digital-only orders', async () => {
+ renderCheckout({
+ draftOrderOverrides: {
+ shipping: { address: null },
+ lineItems: [buildDigitalLineItem()],
+ },
+ sessionOverrides: {
+ enableBillingAddressCollection: false,
+ enableTaxCollection: true,
+ enableShipping: true,
+ enableLocalPickup: true,
+ },
+ });
+ await waitForCheckoutReady();
+
+ expectBillingNamesOnlyWithPhone();
+ });
+
it('shows billing names and phone for free digital-only orders when tax is disabled', async () => {
renderCheckout({
draftOrderOverrides: {
@@ -324,7 +342,7 @@ describe('Digital fulfillment checkout', () => {
).toBeVisible();
});
- it('hides express for mixed digital and pickup orders', async () => {
+ it('shows express for mixed digital and pickup orders when shipping is enabled', async () => {
renderCheckout({
draftOrderOverrides: {
lineItems: [
@@ -344,8 +362,8 @@ describe('Digital fulfillment checkout', () => {
await waitForCheckoutReady();
expect(
- screen.queryByTestId('mock-godaddy-express-button')
- ).not.toBeInTheDocument();
+ await screen.findByTestId('mock-godaddy-express-button')
+ ).toBeVisible();
});
it('does not let digital NONE lines trigger shipping fulfillment sync', async () => {
diff --git a/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx
index c20c8e12..1efa1c16 100644
--- a/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx
+++ b/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx
@@ -3,11 +3,14 @@ import { fireEvent, screen, waitFor } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors';
import {
+ buildBillingAddress,
+ buildShippingRates,
clearOperations,
flushPromises,
getOperations,
renderCheckout,
setApiError,
+ setShippingMethods,
waitForCheckoutReady,
waitForOperation,
} from './checkout-test-env';
@@ -147,6 +150,545 @@ 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.each(['empty', 'error'] as const)(
+ 'clears applied shipping once when the discount rate refresh result is %s',
+ async result => {
+ const paidShipping = buildShippingRates([
+ {
+ serviceCode: 'standard',
+ carrierCode: 'carrier',
+ displayName: 'Standard',
+ cost: { value: 1000, currencyCode: 'USD' },
+ },
+ ]);
+ const { user } = renderCheckout({
+ apiOverrides: { shippingMethods: paidShipping },
+ draftOrderOverrides: {
+ shippingLines: [
+ {
+ requestedService: 'standard',
+ requestedProvider: 'carrier',
+ name: 'Standard',
+ amount: { value: 1000, currencyCode: 'USD' },
+ },
+ ],
+ },
+ });
+ await waitForCheckoutReady();
+ clearOperations();
+ if (result === 'error') {
+ setApiError('getDraftOrderShippingMethods', 'rates failed');
+ } else {
+ setShippingMethods([]);
+ }
+
+ await applyCoupon(user, 'onedollar');
+ await waitFor(() => {
+ expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1);
+ });
+ await flushPromises();
+ await flushPromises();
+
+ expect(getOperations('DraftOrderShippingRates')).toHaveLength(1);
+ expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength(
+ 1
+ );
+ expect(
+ getOperations('ApplyCheckoutSessionShippingMethod')[0].input
+ ).toEqual([]);
+ expect(getOperations('ApplyCheckoutSessionDiscount')).toHaveLength(2);
+ expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(1);
+ expect(screen.queryByText('Standard')).not.toBeInTheDocument();
+ expect(document.body).toHaveTextContent(/no shipping methods found/i);
+ }
+ );
+
+ it('keeps the previous shipping selection when discount reconciliation fails', async () => {
+ const paidShipping = buildShippingRates([
+ {
+ serviceCode: 'standard',
+ carrierCode: 'carrier',
+ displayName: 'Standard',
+ cost: { value: 1000, currencyCode: 'USD' },
+ },
+ ]);
+ const { user } = renderCheckout({
+ apiOverrides: { shippingMethods: paidShipping },
+ draftOrderOverrides: {
+ shippingLines: [
+ {
+ requestedService: 'standard',
+ requestedProvider: 'carrier',
+ name: 'Standard',
+ amount: { value: 1000, currencyCode: 'USD' },
+ },
+ ],
+ },
+ });
+ await waitForCheckoutReady();
+ clearOperations();
+ setApiError('applyShippingMethod', 'apply failed');
+ setShippingMethods([
+ ...paidShipping,
+ ...buildShippingRates([
+ {
+ serviceCode: 'free',
+ carrierCode: 'carrier',
+ displayName: 'Free',
+ cost: { value: 0, currencyCode: 'USD' },
+ },
+ ]),
+ ]);
+
+ await applyCoupon(user, 'onedollar');
+ await waitFor(() => {
+ expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength(
+ 2
+ );
+ });
+ await flushPromises();
+ await flushPromises();
+
+ expect(getOperations('ApplyCheckoutSessionShippingMethod')).toHaveLength(2);
+ expect(screen.getByRole('radio', { name: /standard/i })).toBeChecked();
+ expect(screen.getByRole('radio', { name: /free/i })).not.toBeChecked();
+ expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(0);
+ });
+
+ it.each(['empty', 'replacement'] as const)(
+ 'does not display a failed %s automatic shipping selection',
+ async result => {
+ const paidShipping = buildShippingRates([
+ {
+ serviceCode: 'standard',
+ carrierCode: 'carrier',
+ displayName: 'Standard',
+ cost: { value: 1000, currencyCode: 'USD' },
+ },
+ ]);
+ const { user } = renderCheckout({
+ apiOverrides: { shippingMethods: paidShipping },
+ draftOrderOverrides: {
+ shippingLines: [
+ {
+ requestedService: 'standard',
+ requestedProvider: 'carrier',
+ name: 'Standard',
+ amount: { value: 1000, currencyCode: 'USD' },
+ },
+ ],
+ },
+ });
+ await waitForCheckoutReady();
+ clearOperations();
+ setApiError('applyShippingMethod', 'apply failed');
+ setShippingMethods(
+ result === 'empty'
+ ? []
+ : buildShippingRates([
+ {
+ serviceCode: 'express',
+ carrierCode: 'carrier',
+ displayName: 'Express',
+ cost: { value: 1500, currencyCode: 'USD' },
+ },
+ {
+ serviceCode: 'overnight',
+ carrierCode: 'carrier',
+ displayName: 'Overnight',
+ cost: { value: 2000, currencyCode: 'USD' },
+ },
+ ])
+ );
+
+ await applyCoupon(user, 'onedollar');
+ await waitFor(() => {
+ expect(
+ getOperations('ApplyCheckoutSessionShippingMethod')
+ ).toHaveLength(2);
+ });
+ await flushPromises();
+ await flushPromises();
+
+ expect(getOperations('ApplyCheckoutSessionDiscount')).toHaveLength(1);
+ expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(0);
+
+ if (result === 'empty') {
+ expect(document.body).toHaveTextContent(/no shipping methods found/i);
+ } else {
+ expect(
+ screen.getByRole('radio', { name: /express/i })
+ ).not.toBeChecked();
+ expect(
+ screen.getByRole('radio', { name: /overnight/i })
+ ).not.toBeChecked();
+ }
+ }
+ );
+
+ it('applies a newly available free method before calculating taxes', async () => {
+ const paidShipping = buildShippingRates([
+ {
+ 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: {
+ billing: { address: null },
+ lineItems: [{ fulfillmentMode: 'PURCHASE' }],
+ },
+ sessionOverrides: {
+ enableShipping: false,
+ enableLocalPickup: false,
+ enableTaxCollection: true,
+ },
+ });
+ await waitForCheckoutReady();
+ clearOperations();
+
+ await applyCoupon(user, 'onedollar');
+ await waitForOperation('ApplyCheckoutSessionDiscount');
+ await waitForOperation('DraftOrder');
+
+ expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(0);
+ expect(getOperations('DraftOrder')).toHaveLength(1);
+ });
+
+ it.each(['PURCHASE', 'DIGITAL'] as const)(
+ 'recalculates taxes using the billing address when a coupon is applied to a %s order',
+ async fulfillmentMode => {
+ const billingAddress = buildBillingAddress({
+ addressLine1: '123 Billing St',
+ adminArea2: 'Tempe',
+ adminArea1: 'AZ',
+ postalCode: '85281',
+ countryCode: 'US',
+ });
+ const { user } = renderCheckout({
+ draftOrderOverrides: {
+ billing: {
+ firstName: 'Bill',
+ lastName: 'Buyer',
+ address: billingAddress,
+ },
+ lineItems: [{ fulfillmentMode }],
+ },
+ sessionOverrides: {
+ enableShipping: false,
+ enableLocalPickup: false,
+ enableTaxCollection: true,
+ },
+ });
+ await waitForCheckoutReady();
+ clearOperations();
+
+ await applyCoupon(user, 'onedollar');
+ await waitForOperation('CalculateCheckoutSessionTaxes');
+
+ expect(getOperations('CalculateCheckoutSessionTaxes')).toContainEqual(
+ expect.objectContaining({
+ input: { destination: billingAddress },
+ })
+ );
+ }
+ );
+
it('shows duplicate coupon validation without issuing a duplicate mutation', async () => {
const { user } = renderCheckout({
draftOrderOverrides: { discounts: [{ code: 'onedollar' }] },
diff --git a/packages/react/src/components/checkout/__tests__/checkout-draft-order-sync.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-draft-order-sync.test.tsx
index 799a1054..3a7d56fe 100644
--- a/packages/react/src/components/checkout/__tests__/checkout-draft-order-sync.test.tsx
+++ b/packages/react/src/components/checkout/__tests__/checkout-draft-order-sync.test.tsx
@@ -570,6 +570,59 @@ describe('Checkout draft-order field sync', () => {
expect(getLastUpdateInput()).toMatchObject({ notes: null });
});
+ it('does not sync a billing phone value rejected by checkoutFormSchema', async () => {
+ const { user } = renderCheckout({
+ draftOrderOverrides: {
+ billing: {
+ firstName: 'Pat',
+ lastName: 'Pickup',
+ phone: '',
+ email: 'jane@example.com',
+ address: null,
+ },
+ lineItems: [{ fulfillmentMode: DeliveryMethods.PICKUP }],
+ totals: {
+ subTotal: { value: 0, currencyCode: 'USD' },
+ discountTotal: { value: 0, currencyCode: 'USD' },
+ shippingTotal: { value: 0, currencyCode: 'USD' },
+ taxTotal: { value: 0, currencyCode: 'USD' },
+ feeTotal: { value: 0, currencyCode: 'USD' },
+ total: { value: 0, currencyCode: 'USD' },
+ },
+ },
+ checkoutProps: {
+ checkoutFormSchema: {
+ billingPhone: z.string().min(12, 'full billing phone required'),
+ },
+ },
+ sessionOverrides: {
+ enableShipping: false,
+ enableLocalPickup: true,
+ enableTaxCollection: false,
+ enablePhoneCollection: true,
+ },
+ });
+ await waitForCheckoutReady();
+ clearOperations();
+
+ const phone = screen.getByLabelText(/phone/i);
+ await user.clear(phone);
+ await user.type(phone, '123');
+ await advanceCheckoutDebounce();
+ await flushPromises();
+
+ expect(getOperations('UpdateCheckoutSessionDraftOrder')).toHaveLength(0);
+
+ await user.clear(phone);
+ await user.type(phone, '+12015550123');
+ await advanceCheckoutDebounce();
+ await waitForOperation('UpdateCheckoutSessionDraftOrder');
+
+ expect(getLastUpdateInput()).toMatchObject({
+ billing: { phone: '+12015550123' },
+ });
+ });
+
it('does not clear order notes while a custom required notes field is empty', async () => {
const { user } = renderCheckout({
draftOrderOverrides: {
diff --git a/packages/react/src/components/checkout/__tests__/checkout-express.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-express.test.tsx
index 4a847df2..09236f2c 100644
--- a/packages/react/src/components/checkout/__tests__/checkout-express.test.tsx
+++ b/packages/react/src/components/checkout/__tests__/checkout-express.test.tsx
@@ -72,6 +72,90 @@ describe('Express checkout section visibility', () => {
expect(screen.getByRole('button', { name: /pay now/i })).toBeVisible();
});
+ it('renders express checkout for a shipping-enabled session with purchase fulfillment', async () => {
+ renderCheckout({
+ sessionOverrides: {
+ enableShipping: true,
+ paymentMethods: {
+ card: { processor: 'stripe', checkoutTypes: ['standard'] },
+ express: { processor: 'godaddy', checkoutTypes: ['express'] },
+ },
+ },
+ draftOrderOverrides: {
+ lineItems: [{ fulfillmentMode: 'PURCHASE' }],
+ },
+ });
+ await waitForCheckoutReady();
+
+ expect(
+ await screen.findByTestId('mock-godaddy-express-button')
+ ).toBeVisible();
+ });
+
+ it('does not render express checkout for a purchase-only session', async () => {
+ renderCheckout({
+ sessionOverrides: {
+ enableShipping: false,
+ enableLocalPickup: false,
+ paymentMethods: {
+ card: { processor: 'stripe', checkoutTypes: ['standard'] },
+ express: { processor: 'godaddy', checkoutTypes: ['express'] },
+ },
+ },
+ draftOrderOverrides: {
+ lineItems: [{ fulfillmentMode: 'PURCHASE' }],
+ },
+ });
+ await waitForCheckoutReady();
+
+ expect(
+ screen.queryByTestId('mock-godaddy-express-button')
+ ).not.toBeInTheDocument();
+ expect(screen.queryByText(/^OR$/)).not.toBeInTheDocument();
+ });
+
+ it('renders express checkout for pickup fulfillment when shipping is enabled', async () => {
+ renderCheckout({
+ sessionOverrides: {
+ enableShipping: true,
+ enableLocalPickup: true,
+ paymentMethods: {
+ card: { processor: 'stripe', checkoutTypes: ['standard'] },
+ express: { processor: 'godaddy', checkoutTypes: ['express'] },
+ },
+ },
+ draftOrderOverrides: {
+ lineItems: [{ fulfillmentMode: 'PICKUP' }],
+ },
+ });
+ await waitForCheckoutReady();
+
+ expect(
+ await screen.findByTestId('mock-godaddy-express-button')
+ ).toBeVisible();
+ });
+
+ it('does not render express checkout for a digital-only order', async () => {
+ renderCheckout({
+ sessionOverrides: {
+ enableShipping: true,
+ paymentMethods: {
+ card: { processor: 'stripe', checkoutTypes: ['standard'] },
+ express: { processor: 'godaddy', checkoutTypes: ['express'] },
+ },
+ },
+ draftOrderOverrides: {
+ lineItems: [{ type: 'DIGITAL', fulfillmentMode: 'DIGITAL' }],
+ },
+ });
+ await waitForCheckoutReady();
+
+ expect(
+ screen.queryByTestId('mock-godaddy-express-button')
+ ).not.toBeInTheDocument();
+ expect(screen.queryByText(/^OR$/)).not.toBeInTheDocument();
+ });
+
it('renders the Stripe express button when paymentMethods.express is configured for stripe', async () => {
renderCheckout({
sessionOverrides: {
diff --git a/packages/react/src/components/checkout/__tests__/checkout-form-validation.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-form-validation.test.tsx
index 455da15f..48bad97b 100644
--- a/packages/react/src/components/checkout/__tests__/checkout-form-validation.test.tsx
+++ b/packages/react/src/components/checkout/__tests__/checkout-form-validation.test.tsx
@@ -139,6 +139,22 @@ function BillingReuseProbe() {
);
}
+function ShippingPrefixedCustomFieldProbe() {
+ const form = useFormContext();
+ const error = form.formState.errors.shippingGiftMessage?.message;
+
+ return (
+
+
+
+ {typeof error === 'string' ?
{error}
: null}
+
+ );
+}
+
describe('Checkout form validation', () => {
it('requires only billing names for free pickup and does not require billing address fields', async () => {
const draftOrder = makeFreePickupOrder();
@@ -392,6 +408,45 @@ describe('Checkout form validation', () => {
expect(document.body).not.toHaveTextContent(customMessage);
});
+ it('does not enforce custom billing address rules when names-only billing hides the address', async () => {
+ const customMessage = 'Billing address is required';
+ const draftOrder = makeFreePickupOrder({
+ billing: {
+ firstName: 'Pat',
+ lastName: 'Pickup',
+ address: buildShippingAddress({ addressLine1: '' }),
+ },
+ });
+ const { user } = renderCheckout({
+ draftOrder,
+ checkoutProps: {
+ checkoutFormSchema: {
+ billingAddressLine1: z.string().min(1, customMessage),
+ },
+ },
+ sessionOverrides: {
+ draftOrder,
+ paymentMethods: stripeOnlyPaymentMethods(),
+ enableShipping: false,
+ enableLocalPickup: true,
+ enableTaxCollection: false,
+ },
+ });
+ await waitForCheckoutReady();
+ clearOperations();
+
+ expect(
+ document.querySelector('input[name="billingAddressLine1"]')
+ ).not.toBeInTheDocument();
+
+ await user.click(await clickSubmitButton(/complete your free order/i));
+
+ await waitFor(() => {
+ expect(getOperations('ConfirmCheckoutSession')).toHaveLength(1);
+ });
+ expect(document.body).not.toHaveTextContent(customMessage);
+ });
+
it('does not enforce custom shipping rules when pickup is selected', async () => {
const customMessage = 'Shipping field is required';
const draftOrder = makeFreePickupOrder({
@@ -431,6 +486,59 @@ describe('Checkout form validation', () => {
expect(document.body).not.toHaveTextContent(customMessage);
});
+ it('does not filter unrelated rendered custom fields that share a shipping prefix', async () => {
+ const customMessage = 'Shipping gift message is required';
+ const draftOrder = makeFreePickupOrder({
+ billing: {
+ firstName: 'Pat',
+ lastName: 'Pickup',
+ address: buildShippingAddress({ addressLine1: '' }),
+ },
+ });
+ const { user } = renderCheckout({
+ draftOrder,
+ checkoutProps: {
+ checkoutFormSchema: {
+ shippingGiftMessage: z.preprocess(
+ value => value ?? '',
+ z.string().min(1, customMessage)
+ ),
+ },
+ targets: {
+ 'checkout.form.payment.before': ShippingPrefixedCustomFieldProbe,
+ },
+ },
+ sessionOverrides: {
+ draftOrder,
+ paymentMethods: stripeOnlyPaymentMethods(),
+ enableShipping: true,
+ enableLocalPickup: true,
+ enableTaxCollection: false,
+ },
+ });
+ await waitForCheckoutReady();
+ clearOperations();
+
+ expect(screen.getByLabelText(/shipping gift message/i)).toBeInTheDocument();
+
+ await user.click(await clickSubmitButton(/complete your free order/i));
+
+ await waitFor(() => {
+ expect(document.body).toHaveTextContent(customMessage);
+ });
+ expect(getOperations('ConfirmCheckoutSession')).toHaveLength(0);
+
+ await user.type(
+ screen.getByLabelText(/shipping gift message/i),
+ 'Gift wrap'
+ );
+ await user.click(await clickSubmitButton(/complete your free order/i));
+
+ await waitFor(() => {
+ expect(getOperations('ConfirmCheckoutSession')).toHaveLength(1);
+ });
+ });
+
it('does not enforce custom billing address rules when shipping address is reused', async () => {
const customMessage = 'Billing address line 2 is required';
const sharedAddress = buildShippingAddress({ addressLine2: '' });
diff --git a/packages/react/src/components/checkout/__tests__/checkout-free-order.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-free-order.test.tsx
index ea014b76..449a0d93 100644
--- a/packages/react/src/components/checkout/__tests__/checkout-free-order.test.tsx
+++ b/packages/react/src/components/checkout/__tests__/checkout-free-order.test.tsx
@@ -1,6 +1,8 @@
import { screen, waitFor } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import {
+ advanceCheckoutDebounce,
+ buildBillingAddress,
buildCheckoutSession,
buildDraftOrder,
buildShippingRates,
@@ -87,7 +89,7 @@ async function applyCoupon(
await user.click(apply as HTMLButtonElement);
}
-function buildPaidPurchaseDraftOrder() {
+function buildPaidOrder(fulfillmentMode: 'PICKUP' | 'PURCHASE') {
return buildDraftOrder({
totals: {
subTotal: { value: 100, currencyCode: 'USD' },
@@ -100,7 +102,7 @@ function buildPaidPurchaseDraftOrder() {
lineItems: [
{
unitAmount: { value: 100, currencyCode: 'USD' },
- fulfillmentMode: 'PURCHASE',
+ fulfillmentMode,
totals: {
subTotal: { value: 100, currencyCode: 'USD' },
discountTotal: { value: 0, currencyCode: 'USD' },
@@ -112,6 +114,14 @@ function buildPaidPurchaseDraftOrder() {
});
}
+function buildPaidPurchaseDraftOrder() {
+ return buildPaidOrder('PURCHASE');
+}
+
+function buildPaidPickupDraftOrder() {
+ return buildPaidOrder('PICKUP');
+}
+
describe('Checkout free / offline orders', () => {
it('renders a free pickup order with names-only billing and no paid payment form', async () => {
const draftOrder = buildFreeDraftOrder({
@@ -327,6 +337,56 @@ describe('Checkout free / offline orders', () => {
expect(getLastConfirmInput()).not.toHaveProperty('fulfillmentLocationId');
});
+ it('clears a paid card pickup billing address exactly once when a coupon makes the order free', async () => {
+ const draftOrder = buildPaidPickupDraftOrder();
+ draftOrder.billing = {
+ firstName: 'Card',
+ lastName: 'Pickup',
+ phone: '',
+ email: 'jane@example.com',
+ address: buildBillingAddress({ addressLine1: '500 Card St' }),
+ };
+ const session = buildCheckoutSession({
+ draftOrder,
+ enableShipping: false,
+ enableLocalPickup: true,
+ enableTaxCollection: true,
+ });
+
+ const { user } = renderCheckout({ session, draftOrder });
+ await waitForCheckoutReady();
+ expect(
+ document.querySelector('input[name="billingAddressLine1"]')
+ ).toBeInTheDocument();
+
+ clearOperations();
+ await applyCoupon(user, 'free100');
+ await waitForOperation('ApplyCheckoutSessionDiscount');
+ await waitForOperation('UpdateCheckoutSessionDraftOrder');
+ await advanceCheckoutDebounce(2500);
+
+ await waitFor(() => {
+ expect(
+ screen.getByRole('button', { name: /complete your free order/i })
+ ).toBeInTheDocument();
+ expect(
+ document.querySelector('input[name="billingAddressLine1"]')
+ ).not.toBeInTheDocument();
+ });
+
+ const nullAddressUpdates = getOperations(
+ 'UpdateCheckoutSessionDraftOrder'
+ ).filter(
+ operation =>
+ (operation.input as { billing?: { address?: unknown } }).billing
+ ?.address === null
+ );
+ expect(nullAddressUpdates).toHaveLength(1);
+ expect(nullAddressUpdates[0].input).toMatchObject({
+ billing: { firstName: 'Card', lastName: 'Pickup', address: null },
+ });
+ });
+
it('switches from paid payment methods to FreePaymentForm after a 100% coupon', async () => {
const draftOrder = buildPaidPurchaseDraftOrder();
const session = buildCheckoutSession({
@@ -385,7 +445,7 @@ describe('Checkout free / offline orders', () => {
).not.toBeInTheDocument();
});
- it('switches to FreePaymentForm when selecting a free shipping rate makes the total zero', async () => {
+ it('switches to FreePaymentForm when the cheapest shipping rate makes the total zero', async () => {
const draftOrder = buildDraftOrder({
totals: {
subTotal: { value: 0, currencyCode: 'USD' },
@@ -422,23 +482,14 @@ describe('Checkout free / offline orders', () => {
enableShipping: true,
enableLocalPickup: false,
enableTaxCollection: false,
- experimental_rules: {
- freeShipping: { enabled: true, minimumOrderTotal: 0 },
- },
});
- const { user } = renderCheckout({
+ renderCheckout({
session,
draftOrder,
apiOverrides: { shippingMethods: buildShippingRates() },
});
await waitForCheckoutReady();
- expect(
- await screen.findByRole('button', { name: /pay now/i })
- ).toBeInTheDocument();
-
- clearOperations();
- await user.click(screen.getByRole('radio', { name: /free/i }));
await waitForOperation('ApplyCheckoutSessionShippingMethod');
expect(
diff --git a/packages/react/src/components/checkout/__tests__/checkout-free-payment-form.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-free-payment-form.test.tsx
index e418a910..0eb2eee6 100644
--- a/packages/react/src/components/checkout/__tests__/checkout-free-payment-form.test.tsx
+++ b/packages/react/src/components/checkout/__tests__/checkout-free-payment-form.test.tsx
@@ -1,7 +1,9 @@
+import { enUs } from '@godaddy/localizations';
import { screen, waitFor } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import {
advanceCheckoutDebounce,
+ buildBillingAddress,
buildCheckoutSession,
buildDraftOrder,
buildShippingRates,
@@ -41,6 +43,36 @@ function buildFreeDraftOrder(
});
}
+function buildFreeShippingDraftOrder(
+ overrides: Parameters[0] = {}
+) {
+ return buildFreeDraftOrder({
+ lineItems: [{ fulfillmentMode: 'SHIP' }],
+ shippingLines: [
+ {
+ id: 'shipping-line-free',
+ requestedService: 'free-shipping',
+ requestedProvider: 'unknown',
+ name: 'Free',
+ amount: { value: 0, currencyCode: 'USD' },
+ discounts: [],
+ },
+ ],
+ ...overrides,
+ });
+}
+
+function freeShippingRates() {
+ return buildShippingRates([
+ {
+ serviceCode: 'free-shipping',
+ displayName: 'Free',
+ description: 'Free',
+ cost: { value: 0, currencyCode: 'USD' },
+ },
+ ]);
+}
+
async function submitFreeOrder(
user: ReturnType
) {
@@ -152,7 +184,7 @@ describe('Checkout FreePaymentForm integration', () => {
expect(getLastConfirmInput()).not.toHaveProperty('fulfillmentLocationId');
});
- it('renders a free purchase order without collecting address fields', async () => {
+ it('collects billing names only for a free purchase order when tax collection is disabled', async () => {
const draftOrder = buildFreeDraftOrder({
lineItems: [{ fulfillmentMode: 'PURCHASE' }],
});
@@ -169,9 +201,12 @@ describe('Checkout FreePaymentForm integration', () => {
expect(
screen.getByRole('button', { name: /complete your free order/i })
).toBeInTheDocument();
- // Current FreePaymentForm renders the submit button only for PURCHASE
- // orders. PRD T-107/T-401 notes document that new billing collection
- // fields are not rendered for this case.
+ // A free purchase order follows the same rules as a paid offline one: names
+ // are collected, and the address is skipped because no tax destination is
+ // needed.
+ expect(
+ document.querySelector('input[name="billingFirstName"]')
+ ).toBeInTheDocument();
expect(
document.querySelector('input[name="billingAddressLine1"]')
).not.toBeInTheDocument();
@@ -179,4 +214,180 @@ describe('Checkout FreePaymentForm integration', () => {
document.querySelector('input[name="shippingAddressLine1"]')
).not.toBeInTheDocument();
});
+
+ it('collects a billing address for a free purchase order when tax collection is enabled', async () => {
+ const draftOrder = buildFreeDraftOrder({
+ lineItems: [{ fulfillmentMode: 'PURCHASE' }],
+ billing: { address: buildBillingAddress({ addressLine1: '' }) },
+ });
+ const session = buildCheckoutSession({
+ draftOrder,
+ enableShipping: false,
+ enableLocalPickup: false,
+ enableBillingAddressCollection: true,
+ enableTaxCollection: true,
+ });
+
+ renderCheckout({ session, draftOrder });
+ await waitForCheckoutReady();
+
+ expect(
+ document.querySelector('input[name="billingAddressLine1"]')
+ ).toBeInTheDocument();
+ expect(
+ document.querySelector('input[name="billingPostalCode"]')
+ ).toBeInTheDocument();
+ });
+
+ it('blocks a free purchase order confirm while a required billing field is empty', async () => {
+ const draftOrder = buildFreeDraftOrder({
+ lineItems: [{ fulfillmentMode: 'PURCHASE' }],
+ billing: { firstName: '', lastName: '' },
+ });
+ const session = buildCheckoutSession({
+ draftOrder,
+ enableShipping: false,
+ enableLocalPickup: false,
+ enableTaxCollection: false,
+ });
+
+ const { user } = renderCheckout({ session, draftOrder });
+ await waitForCheckoutReady();
+ clearOperations();
+
+ // The schema requires billing names here, so the fields must be rendered
+ // and validated instead of leaving the button silently inert.
+ await user.click(
+ await screen.findByRole('button', { name: /complete your free order/i })
+ );
+
+ await waitFor(() => {
+ expect(document.body).toHaveTextContent(enUs.validation.enterFirstName);
+ });
+ expect(getOperations('ConfirmCheckoutSession')).toHaveLength(0);
+ });
+
+ it('collects a billing address for a free shipping order whose billing differs from shipping', async () => {
+ const draftOrder = buildFreeDraftOrder({
+ lineItems: [{ fulfillmentMode: 'SHIP' }],
+ billing: {
+ address: buildBillingAddress({ addressLine1: '99 Billing Blvd' }),
+ },
+ });
+ const session = buildCheckoutSession({
+ draftOrder,
+ enableShipping: true,
+ enableLocalPickup: false,
+ enableBillingAddressCollection: true,
+ enableTaxCollection: false,
+ });
+
+ renderCheckout({ session, draftOrder });
+ await waitForCheckoutReady();
+
+ // Billing is not a copy of shipping, so the free form has to keep showing
+ // the billing address the schema still requires.
+ expect(
+ document.querySelector('input[name="billingAddressLine1"]')
+ ).toHaveValue('99 Billing Blvd');
+ });
+
+ it('lets a free shipping order reuse the shipping address for billing', async () => {
+ const draftOrder = buildFreeShippingDraftOrder({
+ billing: { firstName: '', lastName: '', phone: '', address: null },
+ });
+ const session = buildCheckoutSession({
+ draftOrder,
+ enableShipping: true,
+ enableLocalPickup: false,
+ enableTaxCollection: false,
+ });
+
+ const { user } = renderCheckout({
+ session,
+ draftOrder,
+ apiOverrides: { shippingMethods: freeShippingRates() },
+ });
+ await waitForCheckoutReady();
+
+ // An order that arrives with only a shipping address starts out asking for
+ // a separate billing address, so the customer needs the same opt-out the
+ // paid form offers instead of being forced to retype the address.
+ const toggle = screen.getByLabelText(/use shipping address as billing/i);
+ expect(toggle).not.toBeChecked();
+ expect(
+ document.querySelector('input[name="billingAddressLine1"]')
+ ).toBeInTheDocument();
+
+ await user.click(toggle);
+
+ expect(toggle).toBeChecked();
+ expect(
+ document.querySelector('input[name="billingAddressLine1"]')
+ ).not.toBeInTheDocument();
+ await submitFreeOrder(user);
+ expect(getLastConfirmInput()).toMatchObject({ paymentType: 'offline' });
+ });
+
+ it('lets a free shipping order opt into a separate billing address', async () => {
+ const draftOrder = buildFreeShippingDraftOrder();
+ const session = buildCheckoutSession({
+ draftOrder,
+ enableShipping: true,
+ enableLocalPickup: false,
+ enableTaxCollection: false,
+ });
+
+ const { user } = renderCheckout({
+ session,
+ draftOrder,
+ apiOverrides: { shippingMethods: freeShippingRates() },
+ });
+ await waitForCheckoutReady();
+
+ const toggle = screen.getByLabelText(/use shipping address as billing/i);
+ expect(toggle).toBeChecked();
+ expect(
+ document.querySelector('input[name="billingAddressLine1"]')
+ ).not.toBeInTheDocument();
+
+ await user.click(toggle);
+
+ // Opting out reveals the billing address form and, because unchecking
+ // clears the copied address, the order cannot confirm until it is filled.
+ expect(
+ document.querySelector('input[name="billingAddressLine1"]')
+ ).toBeInTheDocument();
+ clearOperations();
+ await user.click(
+ await screen.findByRole('button', { name: /complete your free order/i })
+ );
+ await waitFor(() => {
+ expect(document.body).toHaveTextContent(enUs.validation.enterAddress);
+ });
+ expect(getOperations('ConfirmCheckoutSession')).toHaveLength(0);
+ });
+
+ it('does not treat a missing order total as free', async () => {
+ const draftOrder = buildFreeDraftOrder({
+ lineItems: [{ fulfillmentMode: 'PURCHASE' }],
+ totals: { total: null },
+ });
+ const session = buildCheckoutSession({
+ draftOrder,
+ enableShipping: false,
+ enableLocalPickup: false,
+ enableTaxCollection: false,
+ });
+
+ renderCheckout({ session, draftOrder });
+ await waitForCheckoutReady();
+
+ expect(
+ screen.queryByRole('button', { name: /complete your free order/i })
+ ).not.toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: /pay now/i })
+ ).toBeInTheDocument();
+ });
});
diff --git a/packages/react/src/components/checkout/__tests__/checkout-payment-flush.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-payment-flush.test.tsx
index d500f9e6..fb8a7d6b 100644
--- a/packages/react/src/components/checkout/__tests__/checkout-payment-flush.test.tsx
+++ b/packages/react/src/components/checkout/__tests__/checkout-payment-flush.test.tsx
@@ -1,6 +1,17 @@
-import { describe, expect, it } from 'vitest';
+import { fireEvent, screen, waitFor } from '@testing-library/react';
+import { useFormContext } from 'react-hook-form';
+import { describe, expect, it, vi } from 'vitest';
+import { type CheckoutFormData } from '@/components/checkout/checkout';
+import { useBuildPaymentRequest } from '@/components/checkout/payment/utils/use-build-payment-request';
+import {
+ PaymentProvider,
+ useConfirmCheckout,
+} from '@/components/checkout/payment/utils/use-confirm-checkout';
+import { useFlushCheckoutSync } from '@/components/checkout/payment/utils/use-flush-checkout-sync';
import * as godaddyApi from '@/lib/godaddy/godaddy';
+import { PaymentMethodType } from '@/types';
import {
+ buildBillingAddress,
buildCheckoutSession,
buildDraftOrder,
buildDraftOrderUpdate,
@@ -9,12 +20,55 @@ import {
getOperations,
MockTokenizeJs,
mockGodaddyApi,
+ renderCheckout,
+ waitForCheckoutReady,
} from './checkout-test-env';
import {
getLastConfirmInput,
getLastUpdateInput,
} from './checkout-test-fixtures';
+const tokenizeLatestOrder = vi.fn(
+ async (_request: unknown) => 'resolved-payment-token'
+);
+let operationsAtTokenization: string[] = [];
+
+function PaymentRequestResolutionProbe() {
+ const form = useFormContext();
+ const flushCheckoutSync = useFlushCheckoutSync();
+ const { buildPaymentRequestsFromOrder } = useBuildPaymentRequest();
+ const confirmCheckout = useConfirmCheckout();
+
+ return (
+
+ );
+}
+
async function simulateCardPayment(
options: { notes?: string; pickup?: boolean; tokenError?: string } = {}
) {
@@ -72,6 +126,69 @@ async function simulateCardPayment(
}
describe('Checkout payment flushing and Poynt card flow', () => {
+ it('builds the SDK request from the order returned after flushing current form edits', async () => {
+ tokenizeLatestOrder.mockClear();
+ operationsAtTokenization = [];
+ const { user } = renderCheckout({
+ draftOrderOverrides: {
+ billing: {
+ firstName: 'Stale',
+ lastName: 'Buyer',
+ address: buildBillingAddress(),
+ },
+ lineItems: [{ fulfillmentMode: 'PURCHASE' }],
+ },
+ sessionOverrides: {
+ enableShipping: false,
+ enableLocalPickup: false,
+ enableTaxCollection: false,
+ },
+ checkoutProps: {
+ targets: {
+ 'checkout.form.submit.after': PaymentRequestResolutionProbe,
+ },
+ },
+ });
+ await waitForCheckoutReady();
+ clearOperations();
+
+ const billingFirstName = document.querySelector(
+ 'input[name="billingFirstName"]'
+ );
+ expect(billingFirstName).toBeInstanceOf(HTMLInputElement);
+ fireEvent.change(billingFirstName as HTMLInputElement, {
+ target: { value: 'Latest' },
+ });
+ await user.click(
+ screen.getByRole('button', { name: /resolve and tokenize payment/i })
+ );
+
+ await waitFor(() => {
+ expect(getOperations('ConfirmCheckoutSession')).toHaveLength(1);
+ });
+ expect(getLastUpdateInput()).toMatchObject({
+ billing: { firstName: 'Latest', lastName: 'Buyer' },
+ });
+ expect(tokenizeLatestOrder).toHaveBeenCalledWith(
+ expect.objectContaining({
+ firstName: 'Latest',
+ lastName: 'Buyer',
+ })
+ );
+ const updateIndex = operationsAtTokenization.indexOf(
+ 'UpdateCheckoutSessionDraftOrder'
+ );
+ const refetchIndex = operationsAtTokenization.indexOf('DraftOrder');
+ expect(updateIndex).toBeGreaterThanOrEqual(0);
+ expect(refetchIndex).toBeGreaterThan(updateIndex);
+ expect(operationsAtTokenization).not.toContain('ConfirmCheckoutSession');
+ expect(getLastConfirmInput()).toMatchObject({
+ paymentToken: 'resolved-payment-token',
+ paymentType: 'card',
+ paymentProvider: 'POYNT',
+ });
+ });
+
it('flushes pending notes sync before tokenization and confirms with the correct payload', async () => {
await simulateCardPayment({ notes: 'Leave at door' });
diff --git a/packages/react/src/components/checkout/__tests__/checkout-refetch-hydration.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-refetch-hydration.test.tsx
index 39b7e7ad..7c87a3f1 100644
--- a/packages/react/src/components/checkout/__tests__/checkout-refetch-hydration.test.tsx
+++ b/packages/react/src/components/checkout/__tests__/checkout-refetch-hydration.test.tsx
@@ -8,11 +8,15 @@ import {
buildDraftOrder,
buildPickupLocation,
buildShippingAddress,
+ clearOperations,
flushPromises,
getNamedInput,
+ getOperations,
renderCheckout,
+ setCurrentDraftOrder,
typeIntoNamedField,
waitForCheckoutReady,
+ waitForOperation,
} from './checkout-test-env';
function ClientStateProbe() {
@@ -102,6 +106,64 @@ function BillingToggleProbe() {
}
describe('Checkout refetch hydration', () => {
+ it('refetches SKUs when draft-order product identity changes', async () => {
+ const { queryClient, session } = renderCheckout();
+ await waitForCheckoutReady();
+ await waitFor(() => {
+ expect(
+ queryClient.getQueryState(
+ checkoutQueryKeys.draftOrderProducts(session.id)
+ )?.fetchStatus
+ ).toBe('idle');
+ });
+ clearOperations();
+
+ const updated = buildDraftOrder({
+ lineItems: [
+ {
+ id: 'line-item-1',
+ productId: 'product-2',
+ details: { sku: 'sku-2' },
+ },
+ ],
+ });
+ setCurrentDraftOrder(updated);
+ await act(async () => {
+ queryClient.setQueryData(checkoutQueryKeys.draftOrder(session.id), {
+ checkoutSession: { draftOrder: updated },
+ });
+ await flushPromises();
+ });
+ await waitForOperation('DraftOrderSkus');
+
+ expect(getOperations('DraftOrderSkus')).toHaveLength(1);
+ });
+
+ it('does not refetch SKUs for non-product draft-order updates', async () => {
+ const { queryClient, session } = renderCheckout();
+ await waitForCheckoutReady();
+ await waitFor(() => {
+ expect(
+ queryClient.getQueryState(
+ checkoutQueryKeys.draftOrderProducts(session.id)
+ )?.fetchStatus
+ ).toBe('idle');
+ });
+ clearOperations();
+
+ const updated = buildDraftOrder({
+ billing: { firstName: 'Updated' },
+ });
+ await act(async () => {
+ queryClient.setQueryData(checkoutQueryKeys.draftOrder(session.id), {
+ checkoutSession: { draftOrder: updated },
+ });
+ await flushPromises();
+ });
+
+ expect(getOperations('DraftOrderSkus')).toHaveLength(0);
+ });
+
it('hydrates pristine fields from draft-order refetch without clobbering dirty fields', async () => {
const { user, queryClient, session } = renderCheckout({
draftOrderOverrides: {
diff --git a/packages/react/src/components/checkout/__tests__/checkout-shipping.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-shipping.test.tsx
index 7ef65027..ad93332f 100644
--- a/packages/react/src/components/checkout/__tests__/checkout-shipping.test.tsx
+++ b/packages/react/src/components/checkout/__tests__/checkout-shipping.test.tsx
@@ -2,9 +2,11 @@ import { fireEvent, screen, waitFor } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys';
import * as godaddyApi from '@/lib/godaddy/godaddy';
+import { CheckoutType, PaymentProvider } from '@/types';
import {
advanceCheckoutDebounce,
buildDraftOrder,
+ buildShippingAddress,
clearOperations,
flushPromises,
getOperations,
@@ -93,7 +95,7 @@ describe('Checkout shipping behavior', () => {
).not.toBeInTheDocument();
});
- it('filters free shipping below the minimum order total and shows it once the subtotal qualifies', async () => {
+ it('shows free shipping returned by the API', async () => {
const shippingMethods = [
{
serviceCode: 'free-shipping',
@@ -116,35 +118,12 @@ describe('Checkout shipping behavior', () => {
cost: { value: 500, currencyCode: 'USD' },
},
];
- const experimental_rules = {
- freeShipping: { enabled: true, minimumOrderTotal: 5000 },
- };
- const { unmount } = renderCheckout({
- sessionOverrides: { experimental_rules },
- apiOverrides: { shippingMethods },
- });
- await waitForCheckoutReady();
-
- expect(
- screen.queryByRole('radio', { name: /free/i })
- ).not.toBeInTheDocument();
- expect(screen.getAllByText('Paid Rate').length).toBeGreaterThan(0);
-
- unmount();
- renderCheckout({
- sessionOverrides: { experimental_rules },
- draftOrderOverrides: {
- totals: {
- subTotal: { value: 5000, currencyCode: 'USD' },
- total: { value: 5000, currencyCode: 'USD' },
- },
- },
- apiOverrides: { shippingMethods },
- });
+ renderCheckout({ apiOverrides: { shippingMethods } });
await waitForCheckoutReady();
expect(screen.getByRole('radio', { name: /free/i })).toBeInTheDocument();
+ expect(screen.getAllByText('Paid Rate').length).toBeGreaterThan(0);
});
it('renders FREE for a single zero-cost shipping method', async () => {
@@ -363,6 +342,90 @@ describe('Checkout shipping behavior', () => {
).not.toBeInTheDocument();
});
+ // The trigger filter also skips these fields, so this passes with or without
+ // the matching schema rule; it guards the end-to-end guarantee that a hidden
+ // shipping form can never block checkout, whichever layer regresses.
+ it('completes checkout when enableShippingAddressCollection hides missing shipping names', async () => {
+ const { user } = renderCheckout({
+ draftOrderOverrides: {
+ // Address is intact so shipping rates still resolve; the names the
+ // hidden form would have collected are what the schema must not demand.
+ shipping: { firstName: '', lastName: '' },
+ },
+ sessionOverrides: {
+ enableShipping: true,
+ enableShippingAddressCollection: false,
+ enableLocalPickup: false,
+ enableTaxCollection: false,
+ paymentMethods: {
+ card: null as never,
+ offline: {
+ processor: PaymentProvider.OFFLINE,
+ checkoutTypes: [CheckoutType.STANDARD],
+ },
+ },
+ },
+ });
+ await waitForCheckoutReady();
+ clearOperations();
+
+ // The address form is hidden, so the schema must not require fields the
+ // customer has no way to fill in.
+ await user.click(
+ await screen.findByRole('button', { name: /complete your order/i })
+ );
+
+ await waitForOperation('ConfirmCheckoutSession');
+ await advanceCheckoutDebounce(0);
+ });
+
+ it('collects billing when shipping address collection is disabled even if shipping is prefilled', async () => {
+ const sharedAddress = buildShippingAddress({
+ addressLine1: '1 Hidden Way',
+ });
+ renderCheckout({
+ draftOrderOverrides: {
+ shipping: {
+ firstName: 'Ship',
+ lastName: 'Buyer',
+ phone: '',
+ address: sharedAddress,
+ },
+ billing: {
+ firstName: 'Bill',
+ lastName: 'Buyer',
+ phone: '',
+ address: sharedAddress,
+ },
+ },
+ sessionOverrides: {
+ enableShipping: true,
+ enableShippingAddressCollection: false,
+ enableBillingAddressCollection: true,
+ enableLocalPickup: false,
+ enableTaxCollection: false,
+ paymentMethods: {
+ card: null as never,
+ offline: {
+ processor: PaymentProvider.OFFLINE,
+ checkoutTypes: [CheckoutType.STANDARD],
+ },
+ },
+ },
+ });
+ await waitForCheckoutReady();
+
+ expect(
+ screen.queryByLabelText(/use shipping address as billing/i)
+ ).not.toBeInTheDocument();
+ expect(
+ document.querySelector('input[name="shippingAddressLine1"]')
+ ).not.toBeInTheDocument();
+ expect(
+ document.querySelector('input[name="billingAddressLine1"]')
+ ).toBeInTheDocument();
+ });
+
it('records a shipping-method fetch failure when rates are refetched', async () => {
const { user } = renderCheckout();
await waitForCheckoutReady();
diff --git a/packages/react/src/components/checkout/__tests__/checkout-test-utils.tsx b/packages/react/src/components/checkout/__tests__/checkout-test-utils.tsx
index 562d625f..765100bf 100644
--- a/packages/react/src/components/checkout/__tests__/checkout-test-utils.tsx
+++ b/packages/react/src/components/checkout/__tests__/checkout-test-utils.tsx
@@ -629,11 +629,45 @@ function applyShippingLines(shippingMethods: unknown) {
function applyDiscountCodes(discountCodes: string[]) {
if (!state) return;
- const discounts = discountCodes.map(code => discount(code));
- const discountTotal = money(discountCodes.length * 100);
+ const totals = state.draftOrder.totals ?? defaultTotals();
+ const 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
+ : 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,
@@ -988,6 +1022,11 @@ export function setPriceAdjustments(adjustments: unknown[]) {
state.priceAdjustments = adjustments;
}
+export function setShippingMethods(shippingMethods: ShippingMethod[]) {
+ if (!state) throw new Error('mockGodaddyApi must be called first');
+ state.shippingMethods = shippingMethods;
+}
+
export function getOperations(op?: OperationName) {
const operations = state?.operations ?? [];
return op ? operations.filter(operation => operation.op === op) : operations;
diff --git a/packages/react/src/components/checkout/__tests__/checkout-validation.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-validation.test.tsx
index 9a6edc5a..0185888c 100644
--- a/packages/react/src/components/checkout/__tests__/checkout-validation.test.tsx
+++ b/packages/react/src/components/checkout/__tests__/checkout-validation.test.tsx
@@ -6,6 +6,7 @@ import { GoDaddyProvider } from '@/godaddy-provider';
import { CheckoutType, PaymentProvider } from '@/types';
import {
advanceCheckoutDebounce,
+ buildBillingAddress,
buildCheckoutSession,
buildDraftOrder,
clearOperations,
@@ -65,6 +66,54 @@ describe('Checkout validation behaviors', () => {
expect(getOperations('TokenizeJs.getNonce')).toHaveLength(0);
});
+ it('shows full billing address for pickup card and names-only billing for offline pickup with tax enabled', async () => {
+ const { user } = renderCheckout({
+ draftOrderOverrides: {
+ lineItems: [{ fulfillmentMode: 'PICKUP' }],
+ },
+ sessionOverrides: {
+ enableShipping: false,
+ enableLocalPickup: true,
+ enableBillingAddressCollection: true,
+ enablePhoneCollection: true,
+ enableTaxCollection: true,
+ paymentMethods: {
+ card: {
+ processor: PaymentProvider.STRIPE,
+ checkoutTypes: [CheckoutType.STANDARD],
+ },
+ offline: {
+ processor: PaymentProvider.OFFLINE,
+ checkoutTypes: [CheckoutType.STANDARD],
+ },
+ },
+ },
+ });
+ await waitForCheckoutReady();
+
+ expect(
+ document.querySelector('input[name="billingAddressLine1"]')
+ ).toBeInTheDocument();
+
+ await user.click(
+ await screen.findByRole('button', { name: /offline payments/i })
+ );
+
+ expect(
+ document.querySelector('input[name="billingFirstName"]')
+ ).toBeInTheDocument();
+ expect(
+ document.querySelector('input[name="billingLastName"]')
+ ).toBeInTheDocument();
+ expect(screen.getByPlaceholderText(/201.*555/)).toBeInTheDocument();
+ expect(
+ document.querySelector('input[name="billingAddressLine1"]')
+ ).not.toBeInTheDocument();
+ expect(
+ document.querySelector('input[name="billingPostalCode"]')
+ ).not.toBeInTheDocument();
+ });
+
it('shows billing names and phone for offline pickup even when billing address collection is enabled', async () => {
const { user } = renderCheckout({
draftOrderOverrides: {
@@ -109,6 +158,125 @@ describe('Checkout validation behaviors', () => {
).not.toBeInTheDocument();
});
+ it('blocks a paid offline purchase-mode confirm while the required billing address is empty', async () => {
+ const { user } = renderCheckout({
+ draftOrderOverrides: {
+ billing: {
+ firstName: 'Pay',
+ lastName: 'In Person',
+ address: buildBillingAddress({
+ addressLine1: '',
+ adminArea2: '',
+ postalCode: '',
+ }),
+ },
+ lineItems: [{ fulfillmentMode: 'PURCHASE' }],
+ },
+ sessionOverrides: {
+ enableShipping: false,
+ enableLocalPickup: false,
+ enableBillingAddressCollection: true,
+ enableTaxCollection: true,
+ enablePhoneCollection: false,
+ paymentMethods: {
+ card: null as never,
+ offline: {
+ processor: PaymentProvider.OFFLINE,
+ checkoutTypes: [CheckoutType.STANDARD],
+ },
+ },
+ },
+ });
+ await waitForCheckoutReady();
+
+ // A paid offline order renders PaymentForm, so the billing address it shows
+ // must also be validated — the free-order rules must not leak in here.
+ expect(
+ document.querySelector('input[name="billingAddressLine1"]')
+ ).toBeInTheDocument();
+ clearOperations();
+
+ await user.click(
+ await screen.findByRole('button', { name: /complete your order/i })
+ );
+
+ await waitFor(() => {
+ expect(document.body).toHaveTextContent(enUs.validation.enterAddress);
+ });
+ expect(getOperations('ConfirmCheckoutSession')).toHaveLength(0);
+
+ // Filling the address it asked for lets the same click through.
+ await typeIntoNamedField(user, 'billingAddressLine1', '789 Billing Rd');
+ await typeIntoNamedField(user, 'billingAdminArea2', 'Atlanta');
+ await typeIntoNamedField(user, 'billingPostalCode', '30301');
+ await user.click(
+ screen.getByRole('button', { name: /complete your order/i })
+ );
+
+ await waitForOperation('ConfirmCheckoutSession');
+ await advanceCheckoutDebounce(0);
+ });
+
+ it('blocks a paid offline pickup confirm while the billing phone is invalid', async () => {
+ const { user } = renderCheckout({
+ draftOrderOverrides: {
+ billing: {
+ firstName: 'Pay',
+ lastName: 'In Person',
+ phone: '',
+ },
+ lineItems: [{ fulfillmentMode: 'PICKUP' }],
+ },
+ sessionOverrides: {
+ enableShipping: false,
+ enableLocalPickup: true,
+ enableBillingAddressCollection: true,
+ enablePhoneCollection: true,
+ enableTaxCollection: true,
+ paymentMethods: {
+ card: null as never,
+ offline: {
+ processor: PaymentProvider.OFFLINE,
+ checkoutTypes: [CheckoutType.STANDARD],
+ },
+ },
+ },
+ });
+ await waitForCheckoutReady();
+
+ // Offline pickup collects names + phone only; the phone it renders still
+ // has to be validated before confirming.
+ const phone = (
+ await screen.findAllByPlaceholderText(/201.*555/)
+ )[0] as HTMLInputElement;
+ await user.clear(phone);
+ await user.type(phone, '12');
+ clearOperations();
+
+ await user.click(
+ await screen.findByRole('button', { name: /complete your order/i })
+ );
+
+ await waitFor(() => {
+ expect(document.body).toHaveTextContent(
+ enUs.validation.enterValidBillingPhone
+ );
+ });
+ expect(getOperations('ConfirmCheckoutSession')).toHaveLength(0);
+
+ const rerenderedPhone = (
+ await screen.findAllByPlaceholderText(/201.*555/)
+ )[0] as HTMLInputElement;
+ await user.clear(rerenderedPhone);
+ await user.type(rerenderedPhone, '2015550123');
+ await user.click(
+ screen.getByRole('button', { name: /complete your order/i })
+ );
+
+ await waitForOperation('ConfirmCheckoutSession');
+ await advanceCheckoutDebounce(0);
+ });
+
it('shows billing names and phone when billing address collection is disabled but phone collection is enabled', async () => {
renderCheckout({
draftOrderOverrides: {
diff --git a/packages/react/src/components/checkout/address/address-form.tsx b/packages/react/src/components/checkout/address/address-form.tsx
index 3258b588..b0f28568 100644
--- a/packages/react/src/components/checkout/address/address-form.tsx
+++ b/packages/react/src/components/checkout/address/address-form.tsx
@@ -62,7 +62,6 @@ type SectionKey = 'shipping' | 'billing';
interface AddressFormProps {
sectionKey: SectionKey;
- /** When true, only show first name and last name fields (used for free pickup orders) */
onlyNames?: boolean;
}
@@ -239,6 +238,7 @@ export function AddressForm({
enabled: ({ values, draftOrder: currentDraftOrder }) =>
Boolean(
onlyNames &&
+ currentDraftOrder &&
sectionNameHasChanged(values, currentDraftOrder, sectionKey) &&
getFormString(values, `${sectionKey}FirstName`).trim() &&
getFormString(values, `${sectionKey}LastName`).trim()
diff --git a/packages/react/src/components/checkout/address/utils/use-clear-billing-address.ts b/packages/react/src/components/checkout/address/utils/use-clear-billing-address.ts
index ea3f8ac6..dcc6ec87 100644
--- a/packages/react/src/components/checkout/address/utils/use-clear-billing-address.ts
+++ b/packages/react/src/components/checkout/address/utils/use-clear-billing-address.ts
@@ -1,6 +1,42 @@
import { useFormContext } from 'react-hook-form';
import { useTryUpdateDraftOrder } from '@/components/checkout/order/use-try-update-draft-order';
+const BILLING_ADDRESS_FIELDS = [
+ 'billingAddressLine1',
+ 'billingAddressLine2',
+ 'billingAddressLine3',
+ 'billingAdminArea4',
+ 'billingAdminArea3',
+ 'billingAdminArea2',
+ 'billingAdminArea1',
+ 'billingPostalCode',
+ 'billingCountryCode',
+];
+
+/**
+ * Clears the billing address but keeps the name and phone, for switching into a
+ * names-only mode where the address inputs are no longer rendered. The names are
+ * resent with the patch so the partial billing input cannot drop them.
+ */
+export function useClearBillingAddressDetails() {
+ const form = useFormContext();
+ const tryUpdateDraftOrder = useTryUpdateDraftOrder();
+
+ return function clearBillingAddressDetails() {
+ tryUpdateDraftOrder({
+ billing: {
+ firstName: String(form.getValues('billingFirstName') ?? '').trim(),
+ lastName: String(form.getValues('billingLastName') ?? '').trim(),
+ address: null,
+ },
+ });
+
+ for (const fieldName of BILLING_ADDRESS_FIELDS) {
+ form.setValue(fieldName, '');
+ }
+ };
+}
+
export function useClearBillingAddress() {
const form = useFormContext();
const tryUpdateDraftOrder = useTryUpdateDraftOrder();
diff --git a/packages/react/src/components/checkout/checkout.tsx b/packages/react/src/components/checkout/checkout.tsx
index 6bae0493..bf532b01 100644
--- a/packages/react/src/components/checkout/checkout.tsx
+++ b/packages/react/src/components/checkout/checkout.tsx
@@ -3,8 +3,6 @@
import { CircleAlert } from 'lucide-react';
import React, { type ReactNode } from 'react';
import { z } from 'zod';
-import { hasRegionData } from '@/components/checkout/address';
-import { checkIsValidPhone } from '@/components/checkout/address/utils/check-is-valid-phone';
import { DeliveryMethods } from '@/components/checkout/delivery/delivery-methods';
import { getRequiredFieldsFromSchema } from '@/components/checkout/form/utils/get-required-fields-from-schema';
import { type GoDaddyVariables, useGoDaddyContext } from '@/godaddy-provider';
@@ -13,8 +11,12 @@ import { type Theme, useTheme } from '@/hooks/use-theme';
import { useVariables } from '@/hooks/use-variables';
import type { TrackingProperties } from '@/tracking/event-properties';
import { TrackingProvider } from '@/tracking/tracking-provider';
-import { type CheckoutSession, PaymentMethodType } from '@/types';
+import { type CheckoutSession } from '@/types';
import { CheckoutFormContainer } from './form/checkout-form-container';
+import {
+ type CheckoutValidationMessages,
+ createCheckoutValidationAdapter,
+} from './form/checkout-validation-adapter';
import type { Target } from './target/types';
// Utility function for redirecting to success URL after checkout
@@ -111,12 +113,6 @@ interface CheckoutContextValue {
checkoutErrors?: string[] | undefined;
setCheckoutErrors: (error?: string[] | undefined) => void;
requiredFields?: { [key: string]: boolean };
- /**
- * Field names supplied through the `checkoutFormSchema` prop. Consumer rules
- * must always be validated, even when the built-in conditional validation
- * would skip that field for the current delivery/payment combination.
- */
- customSchemaFields?: string[];
}
export const checkoutContext = React.createContext({
@@ -259,167 +255,35 @@ export function Checkout(props: CheckoutProps) {
useTheme(session?.appearance?.theme);
useVariables(session?.appearance?.variables || props?.appearance?.variables);
- const formSchema = React.useMemo(() => {
- const extendedSchema = checkoutFormSchema
- ? baseCheckoutSchema.extend(checkoutFormSchema)
- : baseCheckoutSchema;
-
- const enableBillingAddressCollection =
- session?.enableBillingAddressCollection !== false;
- const enableShipping = session?.enableShipping !== false;
-
- return extendedSchema.superRefine((data, ctx) => {
- if (data.billingPhone) {
- if (!checkIsValidPhone(String(data?.billingPhone))) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: t.validation.enterValidBillingPhone,
- path: ['billingPhone'],
- });
- }
- }
-
- if (data.shippingPhone) {
- if (!checkIsValidPhone(String(data?.shippingPhone))) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: t.validation.enterValidShippingPhone,
- path: ['shippingPhone'],
- });
- }
- }
-
- // Billing address validation - only required if not using shipping address OR pickup
- // BUT skip for free orders (paymentMethod === 'offline')
- const isOfflinePayment = data.paymentMethod === PaymentMethodType.OFFLINE;
- const isPickup = data.deliveryMethod === DeliveryMethods.PICKUP;
- const isShipping = data.deliveryMethod === DeliveryMethods.SHIP;
- const isDigital = data.deliveryMethod === DeliveryMethods.DIGITAL;
- const isFreePickup = isOfflinePayment && isPickup;
- const isDigitalTaxDisabledOffline =
- isDigital && isOfflinePayment && !session?.enableTaxCollection;
- // Billing is separate from shipping when there is no shipping address
- // to copy from. `mapOrderToFormValues` canonicalizes deliveryMethod
- // against session capabilities, so `!isShipping` already covers both
- // session.enableShipping=false and orders with no SHIP fulfillment.
- // The remaining case is the user opting out of "use shipping for billing".
- const billingIsSeparateFromShipping =
- !isShipping || !data.paymentUseShippingAddress;
-
- const requireBillingNamesOnly =
- (!enableBillingAddressCollection && billingIsSeparateFromShipping) ||
- isFreePickup ||
- isDigitalTaxDisabledOffline;
-
- if (requireBillingNamesOnly) {
- const nameFields = [
- { key: 'billingFirstName', message: t.validation.enterFirstName },
- { key: 'billingLastName', message: t.validation.enterLastName },
- ];
-
- for (const { key, message } of nameFields) {
- if (!data[key as keyof typeof data]) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message,
- path: [key],
- });
- }
- }
- }
-
- const requireBillingAddress =
- enableBillingAddressCollection &&
- !isFreePickup &&
- !isDigitalTaxDisabledOffline &&
- billingIsSeparateFromShipping;
-
- if (requireBillingAddress) {
- // Basic billing fields required for all countries
- const billingFields = [
- { key: 'billingFirstName', message: t.validation.enterFirstName },
- { key: 'billingLastName', message: t.validation.enterLastName },
- { key: 'billingAddressLine1', message: t.validation.enterAddress },
- { key: 'billingAdminArea2', message: t.validation.enterCity },
- {
- key: 'billingPostalCode',
- message: t.validation.enterZipPostalCode,
- },
- { key: 'billingCountryCode', message: t.validation.enterCountry },
- ];
-
- if (hasRegionData(String(data.billingCountryCode))) {
- billingFields.push({
- key: 'billingAdminArea1',
- message: t.validation.selectState,
- });
- }
-
- for (const { key, message } of billingFields) {
- if (!data[key as keyof typeof data]) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message,
- path: [key],
- });
- }
- }
- }
-
- // Shipping address validation - only required if delivery method is SHIP
- // AND shipping is enabled at the session level. This guards against the
- // contradictory case where line items declare SHIP fulfillment but the
- // session has enableShipping: false (the shipping form is not rendered
- // in that case, so requiring the fields would block the user).
- const requireShippingAddress = isShipping && enableShipping;
-
- if (requireShippingAddress) {
- // Basic shipping fields required for all countries
- const shippingFields = [
- { key: 'shippingFirstName', message: t.validation.enterFirstName },
- { key: 'shippingLastName', message: t.validation.enterLastName },
- { key: 'shippingAddressLine1', message: t.validation.enterAddress },
- { key: 'shippingAdminArea2', message: t.validation.enterCity },
- {
- key: 'shippingPostalCode',
- message: t.validation.enterZipPostalCode,
- },
- { key: 'shippingCountryCode', message: t.validation.enterCountry },
- ];
-
- if (hasRegionData(String(data.shippingCountryCode))) {
- shippingFields.push({
- key: 'shippingAdminArea1',
- message: t.validation.selectState,
- });
- }
+ const validationMessages = React.useMemo(
+ () => ({
+ enterValidBillingPhone: t.validation.enterValidBillingPhone,
+ enterValidShippingPhone: t.validation.enterValidShippingPhone,
+ enterFirstName: t.validation.enterFirstName,
+ enterLastName: t.validation.enterLastName,
+ enterAddress: t.validation.enterAddress,
+ enterCity: t.validation.enterCity,
+ enterZipPostalCode: t.validation.enterZipPostalCode,
+ enterCountry: t.validation.enterCountry,
+ selectState: t.validation.selectState,
+ }),
+ [t]
+ );
- for (const { key, message } of shippingFields) {
- if (!data[key as keyof typeof data]) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message,
- path: [key],
- });
- }
- }
- }
- });
- }, [
- checkoutFormSchema,
- session?.enableBillingAddressCollection,
- session?.enableShipping,
- session?.enableTaxCollection,
- t,
- ]);
+ const validationAdapter = React.useMemo(
+ () =>
+ createCheckoutValidationAdapter({
+ baseSchema: baseCheckoutSchema,
+ checkoutFormSchema,
+ messages: validationMessages,
+ getContext: () => ({ session }),
+ }),
+ [checkoutFormSchema, session, validationMessages]
+ );
const requiredFields = React.useMemo(() => {
- return getRequiredFieldsFromSchema(formSchema);
- }, [formSchema]);
-
- const customSchemaFields = React.useMemo(() => {
- return Object.keys(checkoutFormSchema ?? {});
- }, [checkoutFormSchema]);
+ return getRequiredFieldsFromSchema(validationAdapter.schema);
+ }, [validationAdapter]);
if (!props.isLoading && !isLoadingJWT && !session) {
return (
@@ -470,7 +334,6 @@ export function Checkout(props: CheckoutProps) {
paypalConfig,
ccavenueConfig,
requiredFields,
- customSchemaFields,
isConfirmingCheckout,
setIsConfirmingCheckout,
checkoutErrors,
@@ -480,7 +343,7 @@ export function Checkout(props: CheckoutProps) {
diff --git a/packages/react/src/components/checkout/discount/utils/get-draft-order-discount-codes.test.ts b/packages/react/src/components/checkout/discount/utils/get-draft-order-discount-codes.test.ts
new file mode 100644
index 00000000..4b980309
--- /dev/null
+++ b/packages/react/src/components/checkout/discount/utils/get-draft-order-discount-codes.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from 'vitest';
+import type { DraftOrder } from '@/types';
+import { getDraftOrderDiscountCodes } from './get-draft-order-discount-codes';
+
+describe('getDraftOrderDiscountCodes', () => {
+ it('collects unique order, line-item, and shipping-line discount codes', () => {
+ const draftOrder = {
+ discounts: [{ code: 'order' }],
+ lineItems: [{ discounts: [{ code: 'line' }, { code: 'shared' }] }],
+ shippingLines: [
+ { discounts: [{ code: 'shipping' }, { code: 'shared' }] },
+ ],
+ } as DraftOrder;
+
+ expect(getDraftOrderDiscountCodes(draftOrder)).toEqual([
+ 'line',
+ 'order',
+ 'shared',
+ 'shipping',
+ ]);
+ });
+
+ it('returns an empty list without a draft order', () => {
+ expect(getDraftOrderDiscountCodes()).toEqual([]);
+ });
+});
diff --git a/packages/react/src/components/checkout/discount/utils/get-draft-order-discount-codes.ts b/packages/react/src/components/checkout/discount/utils/get-draft-order-discount-codes.ts
new file mode 100644
index 00000000..800d1c9b
--- /dev/null
+++ b/packages/react/src/components/checkout/discount/utils/get-draft-order-discount-codes.ts
@@ -0,0 +1,25 @@
+import type { DraftOrder } from '@/types';
+
+export function getDraftOrderDiscountCodes(
+ draftOrder?: DraftOrder | null
+): string[] {
+ const codes = new Set();
+
+ for (const discount of draftOrder?.discounts ?? []) {
+ if (discount.code) codes.add(discount.code);
+ }
+
+ for (const lineItem of draftOrder?.lineItems ?? []) {
+ for (const discount of lineItem.discounts ?? []) {
+ if (discount.code) codes.add(discount.code);
+ }
+ }
+
+ for (const shippingLine of draftOrder?.shippingLines ?? []) {
+ for (const discount of shippingLine.discounts ?? []) {
+ if (discount.code) codes.add(discount.code);
+ }
+ }
+
+ return Array.from(codes).sort();
+}
diff --git a/packages/react/src/components/checkout/discount/utils/use-apply-discount-core.ts b/packages/react/src/components/checkout/discount/utils/use-apply-discount-core.ts
new file mode 100644
index 00000000..486cf2fe
--- /dev/null
+++ b/packages/react/src/components/checkout/discount/utils/use-apply-discount-core.ts
@@ -0,0 +1,134 @@
+import type { QueryClient } from '@tanstack/react-query';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import type { ResultOf } from 'gql.tada';
+import { useCheckoutContext } from '@/components/checkout/checkout';
+import {
+ checkoutMutationKeys,
+ checkoutQueryKeys,
+} from '@/components/checkout/utils/query-keys';
+import { useGoDaddyContext } from '@/godaddy-provider';
+import { ApplyCheckoutSessionDiscountMutation } from '@/lib/godaddy/checkout-mutations.ts';
+import { DraftOrderQuery } from '@/lib/godaddy/checkout-queries.ts';
+import { applyDiscount } from '@/lib/godaddy/godaddy';
+import type { ApplyCheckoutSessionDiscountInput } from '@/types';
+
+type DiscountMutationResult = ResultOf<
+ typeof ApplyCheckoutSessionDiscountMutation
+>;
+type DiscountOrder = NonNullable<
+ DiscountMutationResult['applyCheckoutSessionDiscount']
+>;
+
+export interface ApplyDiscountVariables {
+ discountCodes: ApplyCheckoutSessionDiscountInput['input']['discountCodes'];
+}
+
+interface UseApplyDiscountCoreOptions {
+ onSuccess?: (
+ data: DiscountMutationResult,
+ variables: ApplyDiscountVariables
+ ) => Promise | void;
+}
+
+export function updateDiscountCache(
+ queryClient: QueryClient,
+ sessionId: string,
+ updatedOrder: DiscountOrder,
+ discountCodes: ApplyDiscountVariables['discountCodes']
+) {
+ queryClient.setQueryData(
+ checkoutQueryKeys.draftOrder(sessionId),
+ (cached: ResultOf | undefined) => {
+ const currentOrder = cached?.checkoutSession?.draftOrder;
+ if (!cached || !currentOrder) return cached;
+
+ return {
+ ...cached,
+ checkoutSession: {
+ ...cached.checkoutSession,
+ draftOrder: {
+ ...currentOrder,
+ totals: {
+ ...currentOrder.totals,
+ discountTotal:
+ updatedOrder.totals?.discountTotal ??
+ currentOrder.totals?.discountTotal,
+ total: updatedOrder.totals?.total ?? currentOrder.totals?.total,
+ },
+ discounts:
+ updatedOrder.discounts ??
+ (discountCodes?.length ? currentOrder.discounts : []),
+ lineItems: currentOrder.lineItems?.map(currentLineItem => {
+ const updatedLineItem = updatedOrder.lineItems?.find(
+ lineItem => lineItem.id === currentLineItem.id
+ );
+
+ if (!updatedLineItem) {
+ return discountCodes?.length
+ ? currentLineItem
+ : { ...currentLineItem, discounts: [] };
+ }
+
+ return {
+ ...currentLineItem,
+ discounts: updatedLineItem.discounts ?? [],
+ totals: {
+ ...currentLineItem.totals,
+ discountTotal:
+ updatedLineItem.totals?.discountTotal ??
+ currentLineItem.totals?.discountTotal,
+ },
+ };
+ }),
+ shippingLines:
+ currentOrder.shippingLines?.map((currentShippingLine, index) => {
+ const updatedShippingLine = updatedOrder.shippingLines?.[index];
+
+ if (!updatedShippingLine) {
+ return discountCodes?.length
+ ? currentShippingLine
+ : { ...currentShippingLine, discounts: [] };
+ }
+
+ return {
+ ...currentShippingLine,
+ discounts: updatedShippingLine.discounts ?? [],
+ };
+ }) ?? null,
+ },
+ },
+ };
+ }
+ );
+}
+
+export function useApplyDiscountCore(
+ options: UseApplyDiscountCoreOptions = {}
+) {
+ const { session, jwt } = useCheckoutContext();
+ const { apiHost } = useGoDaddyContext();
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationKey: checkoutMutationKeys.applyDiscount(session?.id),
+ mutationFn: async ({ discountCodes }: ApplyDiscountVariables) =>
+ jwt
+ ? applyDiscount(discountCodes, { accessToken: jwt }, apiHost)
+ : applyDiscount(discountCodes, session, apiHost),
+ onSuccess: async (data, variables) => {
+ if (!session) return;
+
+ const updatedOrder = data.applyCheckoutSessionDiscount;
+ if (updatedOrder) {
+ updateDiscountCache(
+ queryClient,
+ session.id,
+ updatedOrder,
+ variables.discountCodes
+ );
+ }
+
+ await options.onSuccess?.(data, variables);
+ },
+ });
+}
diff --git a/packages/react/src/components/checkout/discount/utils/use-discount-apply.ts b/packages/react/src/components/checkout/discount/utils/use-discount-apply.ts
index db2a6f4e..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,175 +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) {
- // If the delivery method is pickup, we need to update taxes based on the pickup location
- // Otherwise, we can just update taxes without a specific address
- // TODO: Move this to API layer
- const deliveryMethod = form.getValues('deliveryMethod');
- const isPickup = deliveryMethod === DeliveryMethods.PICKUP;
-
- if (isPickup) {
- const pickupLocationId = form.getValues('pickupLocationId');
- const locationAddress = session?.locations?.find(
- loc => loc.id === pickupLocationId
- )?.address;
-
- if (locationAddress) {
- await updateTaxes.mutateAsync(locationAddress);
- }
- } else {
- // Only update taxes if we have the required location data
- const hasRequiredLocationData =
- draftOrder?.shipping?.address?.postalCode &&
- draftOrder?.shipping?.address?.countryCode;
-
- if (hasRequiredLocationData) {
- await updateTaxes.mutateAsync(undefined);
- }
- }
- } else {
- 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..b4d0d8f4
--- /dev/null
+++ b/packages/react/src/components/checkout/discount/utils/use-reconcile-after-discount.ts
@@ -0,0 +1,118 @@
+import { useQueryClient } from '@tanstack/react-query';
+import { useFormContext } from 'react-hook-form';
+import { useCheckoutContext } from '@/components/checkout/checkout';
+import { DeliveryMethods } from '@/components/checkout/delivery/delivery-methods';
+import { useDraftOrder } from '@/components/checkout/order/use-draft-order';
+import { useUpdateTaxes } from '@/components/checkout/order/use-update-taxes';
+import { buildShippingPayload } from '@/components/checkout/shipping/utils/build-shipping-payload';
+import {
+ getShippingMethodsKey,
+ requiresShippingReconciliation,
+ selectShippingMethod,
+} from '@/components/checkout/shipping/utils/requires-shipping-reconciliation';
+import { useApplyShippingMethodCore } from '@/components/checkout/shipping/utils/use-apply-shipping-method-core';
+import { useDraftOrderShippingMethods } from '@/components/checkout/shipping/utils/use-draft-order-shipping-methods';
+import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys';
+import {
+ type ApplyDiscountVariables,
+ useApplyDiscountCore,
+} from './use-apply-discount-core';
+
+export function useReconcileAfterDiscount() {
+ const { session } = useCheckoutContext();
+ const form = useFormContext();
+ const queryClient = useQueryClient();
+ const updateTaxes = useUpdateTaxes();
+ const { data: draftOrder } = useDraftOrder();
+ const shippingMethodsQuery = useDraftOrderShippingMethods();
+ const applyShippingMethod = useApplyShippingMethodCore();
+ const reapplyDiscount = useApplyDiscountCore();
+
+ return async (variables: ApplyDiscountVariables) => {
+ if (!session) return;
+
+ const deliveryMethod = form.getValues('deliveryMethod');
+ const shippingAddress = draftOrder?.shipping?.address;
+ const hasShippingDestination = Boolean(
+ shippingAddress?.addressLine1 &&
+ shippingAddress.postalCode &&
+ shippingAddress.countryCode
+ );
+
+ if (deliveryMethod === DeliveryMethods.SHIP && hasShippingDestination) {
+ const previousShippingMethods = shippingMethodsQuery.data ?? [];
+ const { data, isError } = await shippingMethodsQuery.refetch();
+ const refreshedMethods = isError ? [] : (data ?? []);
+ const shippingRequiresReconciliation = requiresShippingReconciliation({
+ shippingMethods: refreshedMethods,
+ previousShippingMethods,
+ currentShippingLine: draftOrder?.shippingLines?.[0],
+ selectedServiceCode: form.getValues('shippingMethod'),
+ });
+
+ if (shippingRequiresReconciliation) {
+ const currentServiceCode =
+ form.getValues('shippingMethod') ||
+ draftOrder?.shippingLines?.[0]?.requestedService;
+ const { selectedMethod } = selectShippingMethod({
+ shippingMethods: refreshedMethods,
+ currentServiceCode,
+ previousMethodsKey: getShippingMethodsKey(previousShippingMethods),
+ });
+
+ await applyShippingMethod.mutateAsync(
+ selectedMethod ? buildShippingPayload(selectedMethod) : []
+ );
+ form.setValue('shippingMethod', selectedMethod?.serviceCode ?? '', {
+ 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/form/checkout-form-container.tsx b/packages/react/src/components/checkout/form/checkout-form-container.tsx
index 8843ee13..2176dd76 100644
--- a/packages/react/src/components/checkout/form/checkout-form-container.tsx
+++ b/packages/react/src/components/checkout/form/checkout-form-container.tsx
@@ -1,16 +1,19 @@
import { useMemo } from 'react';
-import type { z } from 'zod';
import {
type CheckoutProps,
useCheckoutContext,
} from '@/components/checkout/checkout';
import { CheckoutSkeleton } from '@/components/checkout/checkout-skeleton';
import { CheckoutForm } from '@/components/checkout/form/checkout-form';
+import type { CheckoutValidationAdapter } from '@/components/checkout/form/checkout-validation-adapter';
import {
useDraftOrder,
useDraftOrderLineItems,
} from '@/components/checkout/order/use-draft-order';
-import { useDraftOrderProductsMap } from '@/components/checkout/order/use-draft-order-products';
+import {
+ useDraftOrderProductsMap,
+ useRefreshProductsWhenLineItemsChange,
+} from '@/components/checkout/order/use-draft-order-products';
import {
mapOrderToFormValues,
mapSkusToItemsDisplay,
@@ -18,12 +21,12 @@ import {
import { getFulfillmentSummary } from '@/components/checkout/utils/fulfillment';
interface CheckoutFormContainerProps extends Omit {
- schema: z.ZodObject | z.ZodEffects;
+ validationAdapter: CheckoutValidationAdapter;
isLoadingJWT?: boolean;
}
export function CheckoutFormContainer({
- schema,
+ validationAdapter,
isLoadingJWT,
...props
}: CheckoutFormContainerProps) {
@@ -35,6 +38,7 @@ export function CheckoutFormContainer({
const { data: order } = draftOrderQuery;
const { data: lineItems } = draftOrderLineItemsQuery;
+ useRefreshProductsWhenLineItemsChange(lineItems);
const items = useMemo(
() => mapSkusToItemsDisplay(lineItems, skusMap),
@@ -81,7 +85,7 @@ export function CheckoutFormContainer({
return (
= {
};
interface CheckoutFormProps extends Omit {
- schema: z.ZodObject | z.ZodEffects;
+ validationAdapter: CheckoutValidationAdapter;
defaultValues?: Pick;
items: Product[];
fulfillmentSummary: FulfillmentSummary;
@@ -117,7 +118,7 @@ function mergeOrderBackedFormValues(
}
export function CheckoutForm({
- schema,
+ validationAdapter,
defaultValues,
items,
fulfillmentSummary,
@@ -129,8 +130,15 @@ export function CheckoutForm({
useCheckoutContext();
const formValues = (defaultValues ?? {}) as DefaultValues;
+ const validationContextRef = useRef({
+ session,
+ totals: undefined as typeof totals | undefined,
+ });
+ validationContextRef.current.session = session;
+
const form = useForm({
- resolver: zodResolver(schema),
+ resolver: (values, _context, options) =>
+ validationAdapter.resolver(values, validationContextRef.current, options),
defaultValues: formValues,
reValidateMode: 'onBlur',
mode: 'onBlur',
@@ -194,6 +202,7 @@ export function CheckoutForm({
const draftOrderTotalsQuery = useDraftOrderTotals();
const { data: totals, isLoading: totalsLoading } = draftOrderTotalsQuery;
+ validationContextRef.current.totals = totals;
// Order summary calculations - keep all values in minor units
const subtotal = totals?.subTotal?.value || 0;
@@ -206,7 +215,7 @@ export function CheckoutForm({
const currencyCode = totals?.total?.currencyCode || 'USD';
const itemCount = items.reduce((sum, item) => sum + (item?.quantity || 0), 0);
- const isFree = orderTotal <= 0;
+ const isFree = isFreeOrderTotal(totals);
const hasExpressCheckoutPaymentMethod = Object.values(
session?.paymentMethods ?? {}
).some(
@@ -220,11 +229,7 @@ export function CheckoutForm({
subtotal > 0 &&
hasExpressCheckoutPaymentMethod &&
session?.enableShipping === true &&
- !fulfillmentSummary.isDigitalOnly &&
- deliveryMethod !== DeliveryMethods.PURCHASE &&
- deliveryMethod !== DeliveryMethods.DIGITAL &&
- !fulfillmentSummary.hasPickupLineItems &&
- !fulfillmentSummary.hasPurchaseLineItems
+ !fulfillmentSummary.isDigitalOnly
);
const enableDelivery = Boolean(
!fulfillmentSummary.isDigitalOnly &&
@@ -402,7 +407,8 @@ export function CheckoutForm({
return (
-
+
+
;
+ safeParseAsync: (
+ values: CheckoutFormData,
+ context?: CheckoutValidationContext
+ ) => Promise
>;
+};
+
+const SHIPPING_ADDRESS_FIELD_NAMES = new Set([
+ 'shippingFirstName',
+ 'shippingLastName',
+ 'shippingAddressLine1',
+ 'shippingAddressLine2',
+ 'shippingAddressLine3',
+ 'shippingAdminArea4',
+ 'shippingAdminArea3',
+ 'shippingAdminArea2',
+ 'shippingAdminArea1',
+ 'shippingPostalCode',
+ 'shippingCountryCode',
+]);
+
+const BILLING_ADDRESS_FIELD_NAMES = new Set([
+ 'billingAddressLine1',
+ 'billingAddressLine2',
+ 'billingAddressLine3',
+ 'billingAdminArea4',
+ 'billingAdminArea3',
+ 'billingAdminArea2',
+ 'billingAdminArea1',
+ 'billingPostalCode',
+ 'billingCountryCode',
+]);
+
+const BILLING_NAME_FIELD_NAMES = new Set([
+ 'billingFirstName',
+ 'billingLastName',
+]);
+
+function isBuiltInConditionalFieldHidden(
+ fieldName: string,
+ values: CheckoutFormData,
+ context?: CheckoutValidationContext
+) {
+ const session = context?.session;
+ const deliveryMethod = values.deliveryMethod;
+ const isShipping = deliveryMethod === DeliveryMethods.SHIP;
+ const shippingSectionIsCollectable = Boolean(
+ isShipping && session?.enableShipping
+ );
+ const shippingAddressIsCollectable = Boolean(
+ shippingSectionIsCollectable && session?.enableShippingAddressCollection
+ );
+ const policy = resolveBillingPolicyForCheckoutState({
+ values,
+ session,
+ totals: context?.totals,
+ });
+ const billingIsCollectable = policy.mode !== BillingCollectionModes.NONE;
+ const billingAddressIsCollectable =
+ policy.mode === BillingCollectionModes.ADDRESS;
+ const phoneIsCollectable = session?.enablePhoneCollection === true;
+ const notesAreCollectable = session?.enableNotesCollection === true;
+
+ if (fieldName === 'shippingPhone') {
+ return !shippingAddressIsCollectable || !phoneIsCollectable;
+ }
+ if (fieldName === 'billingPhone') {
+ return !billingIsCollectable || !phoneIsCollectable;
+ }
+ if (SHIPPING_ADDRESS_FIELD_NAMES.has(fieldName)) {
+ return !shippingAddressIsCollectable;
+ }
+ if (fieldName === 'shippingMethod') {
+ return !shippingSectionIsCollectable;
+ }
+ if (BILLING_NAME_FIELD_NAMES.has(fieldName)) {
+ return !billingIsCollectable;
+ }
+ if (BILLING_ADDRESS_FIELD_NAMES.has(fieldName)) {
+ return !billingAddressIsCollectable;
+ }
+ if (fieldName === 'notes') {
+ return !notesAreCollectable;
+ }
+ return false;
+}
+
+function filterIssuesForHiddenConditionalFields(
+ issues: z.ZodIssue[],
+ values: CheckoutFormData,
+ context?: CheckoutValidationContext
+) {
+ return issues.filter(issue => {
+ const [fieldName] = issue.path;
+ return !(
+ typeof fieldName === 'string' &&
+ isBuiltInConditionalFieldHidden(fieldName, values, context)
+ );
+ });
+}
+
+function addRequiredIssue(
+ ctx: z.RefinementCtx,
+ data: CheckoutFormData,
+ key: keyof CheckoutFormData,
+ message: string
+) {
+ if (data[key]) return;
+
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message,
+ path: [key],
+ });
+}
+
+export function createCheckoutSchema(
+ baseSchema: z.ZodObject,
+ checkoutFormSchema: CheckoutFormSchema | undefined,
+ messages: CheckoutValidationMessages,
+ context?: CheckoutValidationContext
+) {
+ const extendedSchema = checkoutFormSchema
+ ? baseSchema.extend(checkoutFormSchema)
+ : baseSchema;
+
+ return extendedSchema.superRefine((schemaData, ctx) => {
+ const data = schemaData as CheckoutFormData;
+ if (data.billingPhone) {
+ if (!checkIsValidPhone(String(data.billingPhone))) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: messages.enterValidBillingPhone,
+ path: ['billingPhone'],
+ });
+ }
+ }
+
+ if (data.shippingPhone) {
+ if (!checkIsValidPhone(String(data.shippingPhone))) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: messages.enterValidShippingPhone,
+ path: ['shippingPhone'],
+ });
+ }
+ }
+
+ const policy = resolveBillingPolicyForCheckoutState({
+ values: data,
+ session: context?.session,
+ totals: context?.totals,
+ });
+
+ if (
+ policy.mode === BillingCollectionModes.NAMES ||
+ policy.mode === BillingCollectionModes.ADDRESS
+ ) {
+ addRequiredIssue(ctx, data, 'billingFirstName', messages.enterFirstName);
+ addRequiredIssue(ctx, data, 'billingLastName', messages.enterLastName);
+ }
+
+ if (policy.mode === BillingCollectionModes.ADDRESS) {
+ addRequiredIssue(ctx, data, 'billingAddressLine1', messages.enterAddress);
+ addRequiredIssue(ctx, data, 'billingAdminArea2', messages.enterCity);
+ addRequiredIssue(
+ ctx,
+ data,
+ 'billingPostalCode',
+ messages.enterZipPostalCode
+ );
+ addRequiredIssue(ctx, data, 'billingCountryCode', messages.enterCountry);
+
+ if (hasRegionData(String(data.billingCountryCode))) {
+ addRequiredIssue(ctx, data, 'billingAdminArea1', messages.selectState);
+ }
+ }
+
+ const requireShippingAddress = Boolean(
+ data.deliveryMethod === DeliveryMethods.SHIP &&
+ context?.session?.enableShipping &&
+ context?.session?.enableShippingAddressCollection
+ );
+
+ if (requireShippingAddress) {
+ addRequiredIssue(ctx, data, 'shippingFirstName', messages.enterFirstName);
+ addRequiredIssue(ctx, data, 'shippingLastName', messages.enterLastName);
+ addRequiredIssue(
+ ctx,
+ data,
+ 'shippingAddressLine1',
+ messages.enterAddress
+ );
+ addRequiredIssue(ctx, data, 'shippingAdminArea2', messages.enterCity);
+ addRequiredIssue(
+ ctx,
+ data,
+ 'shippingPostalCode',
+ messages.enterZipPostalCode
+ );
+ addRequiredIssue(ctx, data, 'shippingCountryCode', messages.enterCountry);
+
+ if (hasRegionData(String(data.shippingCountryCode))) {
+ addRequiredIssue(ctx, data, 'shippingAdminArea1', messages.selectState);
+ }
+ }
+ });
+}
+
+function createZodError(issues: z.ZodIssue[]) {
+ return new z.ZodError(issues);
+}
+
+type ParsedFieldError = {
+ message: string;
+ type: string;
+ types?: Record;
+};
+
+function parseErrorSchema(
+ issues: z.ZodIssue[],
+ validateAllFieldCriteria: boolean
+) {
+ const errors: Record = {};
+
+ for (; issues.length; ) {
+ const issue = issues[0];
+ const path = issue.path.join('.');
+
+ if (!errors[path]) {
+ errors[path] = {
+ message: issue.message,
+ type: issue.code,
+ };
+ }
+
+ if (validateAllFieldCriteria) {
+ const types = errors[path]?.types;
+ const messages = types?.[issue.code];
+ errors[path] = {
+ ...errors[path],
+ types: {
+ ...types,
+ [issue.code]: messages
+ ? ([] as string[]).concat(messages as string[], issue.message)
+ : issue.message,
+ },
+ };
+ }
+
+ issues.shift();
+ }
+
+ return errors;
+}
+
+function createResolverResult(
+ error: z.ZodError,
+ options: ResolverOptions
+): ResolverResult {
+ return {
+ values: {},
+ errors: toNestErrors(
+ parseErrorSchema(
+ [...error.errors],
+ !options.shouldUseNativeValidation && options.criteriaMode === 'all'
+ ),
+ options
+ ),
+ };
+}
+
+export function createCheckoutValidationAdapter({
+ baseSchema,
+ checkoutFormSchema,
+ messages,
+ getContext,
+}: {
+ baseSchema: z.ZodObject;
+ checkoutFormSchema?: CheckoutFormSchema;
+ messages: CheckoutValidationMessages;
+ getContext?: () => CheckoutValidationContext;
+}): CheckoutValidationAdapter {
+ const getValidationContext = (context?: CheckoutValidationContext) => ({
+ ...(getContext?.() ?? {}),
+ ...(context ?? {}),
+ });
+
+ const safeParseAsync: CheckoutValidationAdapter['safeParseAsync'] = async (
+ values,
+ context
+ ) => {
+ const data = values as CheckoutFormData;
+ const validationContext = getValidationContext(context);
+ const schema = createCheckoutSchema(
+ baseSchema,
+ checkoutFormSchema,
+ messages,
+ validationContext
+ );
+ const result = await schema.safeParseAsync(data);
+
+ if (result.success) {
+ return { success: true as const, data: result.data as CheckoutFormData };
+ }
+
+ const issues = filterIssuesForHiddenConditionalFields(
+ result.error.issues,
+ data,
+ validationContext
+ );
+
+ return issues.length
+ ? { success: false as const, error: createZodError(issues) }
+ : { success: true as const, data };
+ };
+
+ return {
+ schema: createCheckoutSchema(baseSchema, checkoutFormSchema, messages),
+ safeParseAsync,
+ resolver: async (values, context, options) => {
+ const result = await safeParseAsync(values, context);
+
+ if (result.success) {
+ if (options.shouldUseNativeValidation) {
+ validateFieldsNatively({}, options);
+ }
+ return {
+ errors: {},
+ values: result.data,
+ };
+ }
+
+ return createResolverResult(result.error, options);
+ },
+ };
+}
diff --git a/packages/react/src/components/checkout/form/custom-form-provider.tsx b/packages/react/src/components/checkout/form/custom-form-provider.tsx
index 7dd7001c..aa7ee227 100644
--- a/packages/react/src/components/checkout/form/custom-form-provider.tsx
+++ b/packages/react/src/components/checkout/form/custom-form-provider.tsx
@@ -1,230 +1,13 @@
-import React, { useEffect, useMemo, useState } from 'react';
-import type { FieldPath, UseFormReturn, UseFormTrigger } from 'react-hook-form';
+import React from 'react';
+import type { UseFormReturn } from 'react-hook-form';
import { FormProvider } from 'react-hook-form';
-import {
- getBillingCollectionMode,
- hasInlineBillingForm,
-} from '@/components/checkout/payment/utils/billing-collection';
-import { PaymentMethodType } from '@/types';
-import { type CheckoutFormData, useCheckoutContext } from '../checkout';
-import { DeliveryMethods } from '../delivery/delivery-method';
+import type { CheckoutFormData } from '../checkout';
-/**
- * Custom FormProvider that extends React Hook Form's FormProvider
- * to add smart validation that respects unregistered fields
- */
export function CustomFormProvider<
TFormValues extends Record = CheckoutFormData,
>({
children,
...methods
}: { children: React.ReactNode } & UseFormReturn) {
- // Original methods reference to use in the enhancedTrigger
- const methodsRef = React.useRef(methods);
- // Use state to force re-render
- const [, setForceUpdate] = useState({});
- const { customSchemaFields, session } = useCheckoutContext();
- const customSchemaFieldsRef = React.useRef(customSchemaFields);
- const sessionRef = React.useRef(session);
-
- // Update the refs on every render
- useEffect(() => {
- methodsRef.current = methods;
- customSchemaFieldsRef.current = customSchemaFields;
- sessionRef.current = session;
- });
-
- const enhancedMethods = useMemo(() => {
- // Override the trigger function with a type-safe version that ensures error messages are displayed
- const enhancedTrigger: UseFormTrigger = async (
- name?:
- | FieldPath
- | ReadonlyArray>
- | Array>,
- options?: { shouldFocus?: boolean }
- ) => {
- try {
- const currentMethods = methodsRef.current;
-
- // Always enable shouldFocus by default unless explicitly disabled
- const triggerOptions = { shouldFocus: true, ...options };
-
- let result: boolean;
-
- // If specific fields are provided, use the original trigger
- if (name) {
- // Use original methods directly to ensure formState is properly updated
- result = await methods.trigger(name, triggerOptions);
- }
- // Get the current delivery method using type assertion for safety
- else {
- const values = currentMethods.getValues();
- const deliveryMethod = values.deliveryMethod as unknown as string;
- const paymentMethod = values.paymentMethod as unknown as string;
- const paymentUseShippingAddress =
- values.paymentUseShippingAddress as unknown as boolean;
- const isPickup = deliveryMethod === DeliveryMethods.PICKUP;
- const isShipping = deliveryMethod === DeliveryMethods.SHIP;
- const isFreeOrder = paymentMethod === PaymentMethodType.OFFLINE;
- const isFreePickup = isFreeOrder && isPickup;
- const currentSession = sessionRef.current;
- let billingContext:
- | 'top-level'
- | 'inline-payment-form'
- | 'free-payment-form' = 'top-level';
- if (hasInlineBillingForm(paymentMethod)) {
- billingContext = 'inline-payment-form';
- } else if (isFreeOrder) {
- billingContext = 'free-payment-form';
- }
- const billingMode = getBillingCollectionMode({
- context: billingContext,
- deliveryMethod,
- paymentMethod,
- paymentUseShippingAddress,
- enableBillingAddressCollection:
- currentSession?.enableBillingAddressCollection,
- enableTaxCollection: currentSession?.enableTaxCollection,
- });
-
- // Get all field names and filter based on conditions
- const allFieldNames = Object.keys(values);
- let fieldNames = [...allFieldNames] as Array>;
- const shippingAddressFieldNames = new Set([
- 'shippingFirstName',
- 'shippingLastName',
- 'shippingAddressLine1',
- 'shippingAddressLine2',
- 'shippingAddressLine3',
- 'shippingAdminArea4',
- 'shippingAdminArea3',
- 'shippingAdminArea2',
- 'shippingAdminArea1',
- 'shippingPostalCode',
- 'shippingCountryCode',
- ]);
- const billingAddressFieldNames = new Set([
- 'billingAddressLine1',
- 'billingAddressLine2',
- 'billingAddressLine3',
- 'billingAdminArea4',
- 'billingAdminArea3',
- 'billingAdminArea2',
- 'billingAdminArea1',
- 'billingPostalCode',
- 'billingCountryCode',
- ]);
- const billingNameFieldNames = new Set([
- 'billingFirstName',
- 'billingLastName',
- ]);
- const shippingSectionIsCollectable = Boolean(
- isShipping && currentSession?.enableShipping
- );
- const shippingAddressIsCollectable = Boolean(
- shippingSectionIsCollectable &&
- currentSession?.enableShippingAddressCollection
- );
- const billingNamesAreCollectable = billingMode !== 'none';
- const billingAddressIsCollectable = billingMode === 'address';
- const phoneIsCollectable =
- currentSession?.enablePhoneCollection === true;
- const notesAreCollectable =
- currentSession?.enableNotesCollection === true;
-
- const isCollectable = (fieldName: string) => {
- if (fieldName === 'shippingPhone') {
- return shippingAddressIsCollectable && phoneIsCollectable;
- }
- if (fieldName === 'billingPhone') {
- return billingNamesAreCollectable && phoneIsCollectable;
- }
- if (shippingAddressFieldNames.has(fieldName)) {
- return shippingAddressIsCollectable;
- }
- if (fieldName === 'shippingMethod') {
- return shippingSectionIsCollectable;
- }
- if (billingNameFieldNames.has(fieldName)) {
- return billingNamesAreCollectable;
- }
- if (billingAddressFieldNames.has(fieldName)) {
- return billingAddressIsCollectable;
- }
- if (fieldName.startsWith('shipping')) {
- return shippingSectionIsCollectable;
- }
- if (fieldName.startsWith('billing')) {
- return billingNamesAreCollectable;
- }
- if (fieldName === 'notes') {
- return notesAreCollectable;
- }
- return true;
- };
- fieldNames = fieldNames.filter(fieldName => isCollectable(fieldName));
-
- const customFieldNames = new Set(
- (customSchemaFieldsRef.current ?? []).filter(isCollectable)
- );
- const isSkippable = (fieldName: string) =>
- !customFieldNames.has(fieldName);
-
- /* For free pickup orders, only validate billingFirstName and billingLastName */
- if (isFreePickup) {
- fieldNames = fieldNames.filter(
- fieldName =>
- !fieldName.startsWith('billing') ||
- fieldName === 'billingFirstName' ||
- fieldName === 'billingLastName' ||
- !isSkippable(fieldName)
- );
- } else if (paymentUseShippingAddress && isShipping) {
- /* If using shipping address for billing, filter out billing-related field validations.
- * We require isShipping (not just !isPickup) so that PURCHASE / all-NONE
- * fulfillment orders, or sessions with enableShipping: false, still validate
- * billing fields — there's no shipping address to copy from in those cases. */
- fieldNames = fieldNames.filter(
- fieldName =>
- !fieldName.startsWith('billing') || !isSkippable(fieldName)
- );
- }
-
- /* If the delivery method is not shipping (i.e. pickup), filter out shipping-related field validations */
- if (!isShipping) {
- fieldNames = fieldNames.filter(
- fieldName =>
- !fieldName.startsWith('shipping') || !isSkippable(fieldName)
- );
- }
-
- result = await methods.trigger(fieldNames, triggerOptions);
- }
-
- // Force update to ensure error messages show immediately
- setTimeout(() => {
- setForceUpdate({});
- }, 0);
-
- return result;
- } catch {
- return false;
- }
- };
-
- // Return the enhanced methods object with properly typed trigger and original state
- const result = {
- ...methods,
- trigger: enhancedTrigger,
- } as UseFormReturn;
-
- // Make sure we're not losing formState reactivity
- Object.defineProperty(result, 'formState', {
- get: () => methodsRef.current.formState,
- });
-
- return result;
- }, []);
-
- return {children};
+ return {children};
}
diff --git a/packages/react/src/components/checkout/order/draft-order-sync-provider.tsx b/packages/react/src/components/checkout/order/draft-order-sync-provider.tsx
index adde7be6..aef58a38 100644
--- a/packages/react/src/components/checkout/order/draft-order-sync-provider.tsx
+++ b/packages/react/src/components/checkout/order/draft-order-sync-provider.tsx
@@ -2,11 +2,11 @@ import { useQueryClient } from '@tanstack/react-query';
import isEqual from 'fast-deep-equal';
import * as React from 'react';
import { type UseFormReturn, useFormContext } from 'react-hook-form';
-import type { z } from 'zod';
import {
type CheckoutFormData,
useCheckoutContext,
} from '@/components/checkout/checkout';
+import type { CheckoutValidationAdapter } from '@/components/checkout/form/checkout-validation-adapter';
import { useDraftOrder } from '@/components/checkout/order/use-draft-order';
import { useUpdateOrder } from '@/components/checkout/order/use-update-order';
import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys';
@@ -150,14 +150,22 @@ export function mergeDraftOrderPatch(
export function DraftOrderSyncProvider({
children,
+ validationAdapter,
schema,
}: {
children: React.ReactNode;
/**
- * The same schema the form resolver uses. Registrations are skipped while
- * their fields are invalid so rejected values never reach the draft order.
+ * The same policy-aware adapter the form resolver uses. Registrations are
+ * skipped while their fields are invalid so rejected values never reach the
+ * draft order.
*/
- schema?: z.ZodTypeAny;
+ validationAdapter?: Pick;
+ schema?: {
+ safeParseAsync: (values: CheckoutFormData) => Promise<{
+ success: boolean;
+ error?: { issues: Array<{ path: Array }> };
+ }>;
+ };
}) {
const updateDraftOrder = useUpdateOrder();
const queryClient = useQueryClient();
@@ -351,10 +359,14 @@ export function DraftOrderSyncProvider({
const getInvalidFieldNames = React.useCallback(
async (values: CheckoutFormData) => {
const invalidFieldNames = new Set();
- if (!schema) return invalidFieldNames;
-
- const result = await schema.safeParseAsync(values);
- if (result.success) return invalidFieldNames;
+ const result = validationAdapter
+ ? await validationAdapter.safeParseAsync(values, {
+ session,
+ totals: draftOrderQuery.data?.totals ?? null,
+ })
+ : await schema?.safeParseAsync(values);
+ if (!result) return invalidFieldNames;
+ if (result.success || !result.error) return invalidFieldNames;
for (const issue of result.error.issues) {
const [fieldName] = issue.path;
@@ -364,7 +376,7 @@ export function DraftOrderSyncProvider({
return invalidFieldNames;
},
- [schema]
+ [draftOrderQuery.data?.totals, schema, session, validationAdapter]
);
const buildPatchFromRegistrations = React.useCallback(
diff --git a/packages/react/src/components/checkout/order/is-free-order.ts b/packages/react/src/components/checkout/order/is-free-order.ts
new file mode 100644
index 00000000..28a77215
--- /dev/null
+++ b/packages/react/src/components/checkout/order/is-free-order.ts
@@ -0,0 +1,16 @@
+import { useDraftOrderTotals } from '@/components/checkout/order/use-draft-order';
+import type { Totals } from '@/types';
+
+/**
+ * Single definition of "free order" so the rendered payment form, the trigger
+ * field filter, and the schema all agree.
+ */
+export function isFreeOrderTotal(totals?: Totals | null): boolean {
+ const totalValue = totals?.total?.value;
+ return typeof totalValue === 'number' && totalValue <= 0;
+}
+
+export function useIsFreeOrder(): boolean {
+ const { data: totals } = useDraftOrderTotals();
+ return isFreeOrderTotal(totals);
+}
diff --git a/packages/react/src/components/checkout/order/use-draft-order-products.ts b/packages/react/src/components/checkout/order/use-draft-order-products.ts
index 26c5b47b..fad574c4 100644
--- a/packages/react/src/components/checkout/order/use-draft-order-products.ts
+++ b/packages/react/src/components/checkout/order/use-draft-order-products.ts
@@ -1,10 +1,10 @@
-import { useQuery } from '@tanstack/react-query';
-import { useMemo } from 'react';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+import { useEffect, useMemo, useRef } from 'react';
import { useCheckoutContext } from '@/components/checkout/checkout';
import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys';
import { useGoDaddyContext } from '@/godaddy-provider';
import { getProductsFromOrderSkus } from '@/lib/godaddy/godaddy';
-import type { SKUProduct } from '@/types';
+import type { DraftOrder, SKUProduct } from '@/types';
/**
* Hook to fetch products from SKUs in the draft order
@@ -21,10 +21,55 @@ export function useDraftOrderProducts() {
? getProductsFromOrderSkus({ accessToken: jwt }, apiHost)
: getProductsFromOrderSkus(session, apiHost),
enabled: !!session?.id,
+ staleTime: Number.POSITIVE_INFINITY,
+ refetchOnMount: false,
+ refetchOnWindowFocus: 'always',
select: data => data.checkoutSession?.skus?.edges,
});
}
+function getLineItemProductIdentity(
+ lineItems: DraftOrder['lineItems'] | null | undefined
+) {
+ if (!lineItems) return null;
+
+ const identities = lineItems.map(lineItem => {
+ if (lineItem.details?.sku) return `sku:${lineItem.details.sku}`;
+ if (lineItem.productId) return `product:${lineItem.productId}`;
+ return `line:${lineItem.id}`;
+ });
+
+ return JSON.stringify([...new Set(identities)].sort());
+}
+
+export function useRefreshProductsWhenLineItemsChange(
+ lineItems: DraftOrder['lineItems'] | null | undefined
+) {
+ const { session } = useCheckoutContext();
+ const queryClient = useQueryClient();
+ const identity = getLineItemProductIdentity(lineItems);
+ const previousRef = useRef<
+ | {
+ sessionId: string;
+ identity: string;
+ }
+ | undefined
+ >(undefined);
+
+ useEffect(() => {
+ if (!session?.id || identity === null) return;
+
+ const previous = previousRef.current;
+ previousRef.current = { sessionId: session.id, identity };
+
+ if (previous?.sessionId === session.id && previous.identity !== identity) {
+ void queryClient.invalidateQueries({
+ queryKey: checkoutQueryKeys.draftOrderProducts(session.id),
+ });
+ }
+ }, [identity, queryClient, session?.id]);
+}
+
/**
* Hook to get products from SKUs in the draft order as a map for easy lookup
* @returns Map of SKU ID to SKU product data
diff --git a/packages/react/src/components/checkout/order/use-draft-order.ts b/packages/react/src/components/checkout/order/use-draft-order.ts
index 95667e8a..8e97ada8 100644
--- a/packages/react/src/components/checkout/order/use-draft-order.ts
+++ b/packages/react/src/components/checkout/order/use-draft-order.ts
@@ -35,9 +35,10 @@ export function useDraftOrder(
? getDraftOrder({ accessToken: jwt }, apiHost)
: getDraftOrder(session, apiHost),
enabled: !!session?.id,
+ staleTime: 5_000,
select: select ?? (data => data.checkoutSession?.draftOrder as TData),
retry: 3,
- refetchOnWindowFocus: true,
+ refetchOnWindowFocus: 'always',
});
}
diff --git a/packages/react/src/components/checkout/payment/billing-policy-transition-controller.tsx b/packages/react/src/components/checkout/payment/billing-policy-transition-controller.tsx
new file mode 100644
index 00000000..240851d5
--- /dev/null
+++ b/packages/react/src/components/checkout/payment/billing-policy-transition-controller.tsx
@@ -0,0 +1,77 @@
+import React from 'react';
+import { useFormContext } from 'react-hook-form';
+import { useClearBillingAddressDetails } from '@/components/checkout/address/utils/use-clear-billing-address';
+import {
+ type CheckoutFormData,
+ useCheckoutContext,
+} from '@/components/checkout/checkout';
+import {
+ useDraftOrder,
+ useDraftOrderTotals,
+} from '@/components/checkout/order/use-draft-order';
+import { BillingCollectionModes } from '@/components/checkout/payment/utils/billing-collection';
+import { useBillingPolicy } from '@/components/checkout/payment/utils/use-billing-policy';
+
+export function BillingPolicyTransitionController(): null {
+ const form = useFormContext();
+ const { session } = useCheckoutContext();
+ const policy = useBillingPolicy();
+ const { data: draftOrder } = useDraftOrder();
+ const { data: totals } = useDraftOrderTotals();
+ const totalValue = totals?.total?.value ?? null;
+ const deliveryMethod = form.watch('deliveryMethod');
+ const paymentMethod = form.watch('paymentMethod');
+ const clearBillingAddressDetails = useClearBillingAddressDetails();
+ const previousStateRef = React.useRef({
+ mode: policy.mode,
+ paymentMethod,
+ draftOrderId: session?.draftOrder?.id,
+ totalValue,
+ });
+ const hydratedRef = React.useRef(false);
+
+ React.useEffect(() => {
+ if (!deliveryMethod || totals === undefined) return;
+
+ const previousState = previousStateRef.current;
+ previousStateRef.current = {
+ mode: policy.mode,
+ paymentMethod,
+ draftOrderId: session?.draftOrder?.id,
+ totalValue,
+ };
+
+ if (previousState.draftOrderId !== session?.draftOrder?.id) {
+ hydratedRef.current = false;
+ }
+
+ if (!hydratedRef.current) {
+ hydratedRef.current = true;
+ return;
+ }
+
+ if (
+ previousState.mode === BillingCollectionModes.ADDRESS &&
+ policy.mode === BillingCollectionModes.NAMES &&
+ draftOrder?.billing?.address != null &&
+ (Boolean(previousState.paymentMethod) ||
+ form.getFieldState('paymentMethod').isDirty ||
+ form.getFieldState('deliveryMethod').isDirty ||
+ previousState.totalValue !== totalValue)
+ ) {
+ clearBillingAddressDetails();
+ }
+ }, [
+ clearBillingAddressDetails,
+ deliveryMethod,
+ draftOrder?.billing?.address,
+ form,
+ paymentMethod,
+ policy.mode,
+ session?.draftOrder?.id,
+ totalValue,
+ totals,
+ ]);
+
+ return null;
+}
diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/applePay/godaddy.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/applePay/godaddy.tsx
index 77ade28c..898b25db 100644
--- a/packages/react/src/components/checkout/payment/checkout-buttons/applePay/godaddy.tsx
+++ b/packages/react/src/components/checkout/payment/checkout-buttons/applePay/godaddy.tsx
@@ -34,7 +34,8 @@ export function GoDaddyApplePayCheckoutButton() {
const [isCollectLoading, setIsCollectLoading] = useState(true);
const [error, setError] = useState('');
const { data: totals } = useDraftOrderTotals();
- const { poyntStandardRequest } = useBuildPaymentRequest();
+ const { poyntStandardRequest, buildPaymentRequestsFromOrder } =
+ useBuildPaymentRequest();
const currencyCode = totals?.total?.currencyCode || 'USD';
const countryCode = session?.shipping?.originAddress?.countryCode || 'US';
@@ -64,11 +65,16 @@ export function GoDaddyApplePayCheckoutButton() {
return;
}
- await flushCheckoutSync();
+ const { latestOrder } = await flushCheckoutSync({
+ includeCurrentFormDiff: true,
+ });
+ const request = latestOrder
+ ? buildPaymentRequestsFromOrder(latestOrder).poyntStandardRequest
+ : poyntStandardRequest;
setCheckoutErrors(undefined);
- collect?.current?.startApplePaySession(poyntStandardRequest);
+ collect?.current?.startApplePaySession(request);
track({
eventId: eventIds.applePayClick,
@@ -79,6 +85,7 @@ export function GoDaddyApplePayCheckoutButton() {
});
}, [
poyntStandardRequest,
+ buildPaymentRequestsFromOrder,
flushCheckoutSync,
setCheckoutErrors,
form,
diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/credit-card/godaddy.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/credit-card/godaddy.tsx
index d6767d87..f4caeca2 100644
--- a/packages/react/src/components/checkout/payment/checkout-buttons/credit-card/godaddy.tsx
+++ b/packages/react/src/components/checkout/payment/checkout-buttons/credit-card/godaddy.tsx
@@ -13,7 +13,8 @@ export function CreditCardCheckoutButton() {
const { isConfirmingCheckout, setCheckoutErrors } = useCheckoutContext();
const isPaymentDisabled = useIsPaymentDisabled();
const form = useFormContext();
- const { poyntCardRequest } = useBuildPaymentRequest();
+ const { poyntCardRequest, buildPaymentRequestsFromOrder } =
+ useBuildPaymentRequest();
const flushCheckoutSync = useFlushCheckoutSync();
const buttonRef = useRef(null);
const { t } = useGoDaddyContext();
@@ -34,16 +35,22 @@ export function CreditCardCheckoutButton() {
}
try {
- await flushCheckoutSync();
+ const { latestOrder } = await flushCheckoutSync({
+ includeCurrentFormDiff: true,
+ });
+ const request = latestOrder
+ ? buildPaymentRequestsFromOrder(latestOrder).poyntCardRequest
+ : poyntCardRequest;
setCheckoutErrors(undefined);
setIsLoadingNonce(true);
- collect.getNonce(poyntCardRequest);
+ collect.getNonce(request);
} catch (_error) {
setIsLoadingNonce(false);
setCheckoutErrors(['TRANSACTION_PROCESSING_FAILED']);
}
}, [
+ buildPaymentRequestsFromOrder,
collect,
flushCheckoutSync,
form,
diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/credit-card/square.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/credit-card/square.tsx
index 0309dba1..83bc299c 100644
--- a/packages/react/src/components/checkout/payment/checkout-buttons/credit-card/square.tsx
+++ b/packages/react/src/components/checkout/payment/checkout-buttons/credit-card/square.tsx
@@ -17,7 +17,8 @@ import { PaymentMethodType } from '@/types';
export function SquareCreditCardCheckoutButton() {
const { t } = useGoDaddyContext();
const { card, isLoading } = useSquare();
- const { squarePaymentRequest } = useBuildPaymentRequest();
+ const { squarePaymentRequest, buildPaymentRequestsFromOrder } =
+ useBuildPaymentRequest();
const confirmCheckout = useConfirmCheckout();
const { setCheckoutErrors, isConfirmingCheckout } = useCheckoutContext();
const isPaymentDisabled = useIsPaymentDisabled();
@@ -40,11 +41,16 @@ export function SquareCreditCardCheckoutButton() {
return;
}
- await flushCheckoutSync();
+ const { latestOrder } = await flushCheckoutSync({
+ includeCurrentFormDiff: true,
+ });
+ const request = latestOrder
+ ? buildPaymentRequestsFromOrder(latestOrder).squarePaymentRequest
+ : squarePaymentRequest;
try {
setIsSquareDisabled(true);
- const cardToken = await card.tokenize(squarePaymentRequest);
+ const cardToken = await card.tokenize(request);
if (cardToken.status === 'OK' && cardToken?.token) {
await confirmCheckout.mutateAsync({
@@ -61,6 +67,7 @@ export function SquareCreditCardCheckoutButton() {
setIsSquareDisabled(false);
}
}, [
+ buildPaymentRequestsFromOrder,
form,
flushCheckoutSync,
card,
diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/credit-card/stripe.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/credit-card/stripe.tsx
index e7e05682..18269a77 100644
--- a/packages/react/src/components/checkout/payment/checkout-buttons/credit-card/stripe.tsx
+++ b/packages/react/src/components/checkout/payment/checkout-buttons/credit-card/stripe.tsx
@@ -25,8 +25,10 @@ export function StripeCreditCardCheckoutButton() {
form.setFocus(firstError);
}
} else {
- await flushCheckoutSync();
- await handleSubmit();
+ const { latestOrder } = await flushCheckoutSync({
+ includeCurrentFormDiff: true,
+ });
+ await handleSubmit(undefined, latestOrder);
}
};
diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx
index 5d2ddd19..f7f2efa0 100644
--- a/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx
+++ b/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCheckoutContext } from '@/components/checkout/checkout';
+import { getDraftOrderDiscountCodes } from '@/components/checkout/discount/utils/get-draft-order-discount-codes';
import { useGetPriceAdjustments } from '@/components/checkout/discount/utils/use-get-price-adjustments';
import {
useDraftOrder,
@@ -24,7 +25,7 @@ import {
import { useConfirmExpressCheckout } from '@/components/checkout/payment/utils/use-confirm-express-checkout';
import { useIsPaymentDisabled } from '@/components/checkout/payment/utils/use-is-payment-disabled';
import { useLoadPoyntCollect } from '@/components/checkout/payment/utils/use-load-poynt-collect';
-import { filterAndSortShippingMethods } from '@/components/checkout/shipping/utils/filter-shipping-methods';
+import { sortShippingMethods } from '@/components/checkout/shipping/utils/sort-shipping-methods';
import { useGetShippingMethodByAddress } from '@/components/checkout/shipping/utils/use-get-shipping-methods';
import { useGetTaxes } from '@/components/checkout/taxes/utils/use-get-taxes';
import {
@@ -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,55 @@ 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 draftOrderDiscountCodes = useMemo(
+ () => getDraftOrderDiscountCodes(draftOrder),
+ [draftOrder]
+ );
+ const discountCodesKey = JSON.stringify(draftOrderDiscountCodes);
+ const hasDraftOrder = Boolean(draftOrder);
+ const areCouponAdjustmentsReady =
+ draftOrderDiscountCodes.length === 0 || couponFetchStatus === 'done';
useEffect(() => {
- if (!draftOrder) return;
- // Prevent concurrent fetches (but allow new fetches when draft order changes)
- if (couponFetchStatus === 'fetching') return;
+ if (!hasDraftOrder) return;
- const fetchPriceAdjustments = async () => {
- setCouponFetchStatus('fetching');
+ const requestId = ++couponSyncRequestRef.current;
+ const couponCode = draftOrderDiscountCodes[0];
+ 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, discountCodesKey, couponSyncRevision]);
// Initialize the TokenizeJs instance when the component mounts
// But only after price adjustments have been fetched
@@ -407,7 +363,7 @@ export function ExpressCheckoutButton() {
!isCollectLoading ||
!draftOrder ||
hasMounted.current ||
- couponFetchStatus !== 'done'
+ !areCouponAdjustmentsReady
)
return;
@@ -502,7 +458,7 @@ export function ExpressCheckoutButton() {
businessId,
isCollectLoading,
draftOrder,
- couponFetchStatus,
+ areCouponAdjustmentsReady,
countryCode,
currencyCode,
session?.storeId,
@@ -547,6 +503,7 @@ export function ExpressCheckoutButton() {
// Reset coupon fetch status to trigger re-sync with draft order on next open
// This ensures any coupon changes made inside the wallet (but not committed) are discarded
setCouponFetchStatus('idle');
+ setCouponSyncRevision(value => value + 1);
setCalculatedTaxes(null);
// Clear coupon refs - will be re-synced with draft order on next fetch
diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.tsx
index 03fb32fc..31f5a9e5 100644
--- a/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.tsx
+++ b/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.tsx
@@ -10,6 +10,7 @@ import type {
} from '@stripe/stripe-js';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCheckoutContext } from '@/components/checkout/checkout';
+import { getDraftOrderDiscountCodes } from '@/components/checkout/discount/utils/get-draft-order-discount-codes';
import { useGetPriceAdjustments } from '@/components/checkout/discount/utils/use-get-price-adjustments';
import {
useDraftOrder,
@@ -18,7 +19,7 @@ import {
import { useIsPaymentDisabled } from '@/components/checkout/payment/utils/use-is-payment-disabled';
import { useStripeCheckout } from '@/components/checkout/payment/utils/use-stripe-checkout';
import { useStripePaymentIntent } from '@/components/checkout/payment/utils/use-stripe-payment-intent';
-import { filterAndSortShippingMethods } from '@/components/checkout/shipping/utils/filter-shipping-methods';
+import { sortShippingMethods } from '@/components/checkout/shipping/utils/sort-shipping-methods';
import { useGetShippingMethodByAddress } from '@/components/checkout/shipping/utils/use-get-shipping-methods';
import { useGetTaxes } from '@/components/checkout/taxes/utils/use-get-taxes';
@@ -74,104 +75,51 @@ 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 draftOrderDiscountCodes = useMemo(
+ () => getDraftOrderDiscountCodes(draftOrder),
+ [draftOrder]
+ );
+ const discountCodesKey = JSON.stringify(draftOrderDiscountCodes);
+ const hasDraftOrder = Boolean(draftOrder);
- // Fetch and cache price adjustments for pre-applied coupons
useEffect(() => {
- if (!draftOrder) return;
- // Prevent concurrent fetches (but allow new fetches when draft order changes)
- if (couponFetchStatus === 'fetching') return;
-
- const fetchPriceAdjustments = async () => {
- setCouponFetchStatus('fetching');
+ if (!hasDraftOrder) return;
- try {
- const allCodes = new Set();
+ const requestId = ++couponSyncRequestRef.current;
+ const couponCode = draftOrderDiscountCodes[0];
- // 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, discountCodesKey]);
// Calculate taxes for express checkout
const calculateExpressTaxes = useCallback(
@@ -224,19 +172,9 @@ export function StripeExpressCheckoutForm() {
setShippingMethods(shippingMethodsData || null);
- const orderSubTotal = totals?.subTotal?.value || 0;
-
- return filterAndSortShippingMethods({
- shippingMethods: shippingMethodsData || [],
- orderSubTotal,
- experimentalRules: session?.experimental_rules,
- });
+ return sortShippingMethods(shippingMethodsData || []);
},
- [
- getShippingMethodsByAddress,
- session?.experimental_rules,
- totals?.subTotal?.value,
- ]
+ [getShippingMethodsByAddress]
);
// Convert shipping methods to Stripe ShippingRate format
diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/googlePay/godaddy.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/googlePay/godaddy.tsx
index 4d11a8b9..ca181f3f 100644
--- a/packages/react/src/components/checkout/payment/checkout-buttons/googlePay/godaddy.tsx
+++ b/packages/react/src/components/checkout/payment/checkout-buttons/googlePay/godaddy.tsx
@@ -34,7 +34,8 @@ export function GoDaddyGooglePayCheckoutButton() {
const [isCollectLoading, setIsCollectLoading] = useState(true);
const [error, setError] = useState('');
const { data: totals } = useDraftOrderTotals();
- const { poyntStandardRequest } = useBuildPaymentRequest();
+ const { poyntStandardRequest, buildPaymentRequestsFromOrder } =
+ useBuildPaymentRequest();
const currencyCode = totals?.total?.currencyCode || 'USD';
const countryCode = session?.shipping?.originAddress?.countryCode || 'US';
@@ -64,11 +65,16 @@ export function GoDaddyGooglePayCheckoutButton() {
return;
}
- await flushCheckoutSync();
+ const { latestOrder } = await flushCheckoutSync({
+ includeCurrentFormDiff: true,
+ });
+ const request = latestOrder
+ ? buildPaymentRequestsFromOrder(latestOrder).poyntStandardRequest
+ : poyntStandardRequest;
setCheckoutErrors(undefined);
- collect?.current?.startGooglePaySession(poyntStandardRequest);
+ collect?.current?.startGooglePaySession(request);
track({
eventId: eventIds.googlePayClick,
@@ -79,6 +85,7 @@ export function GoDaddyGooglePayCheckoutButton() {
});
}, [
poyntStandardRequest,
+ buildPaymentRequestsFromOrder,
flushCheckoutSync,
setCheckoutErrors,
form,
diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/payment-request-resolution.test.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/payment-request-resolution.test.tsx
new file mode 100644
index 00000000..80afeac0
--- /dev/null
+++ b/packages/react/src/components/checkout/payment/checkout-buttons/payment-request-resolution.test.tsx
@@ -0,0 +1,154 @@
+import { QueryClient } from '@tanstack/react-query';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import React from 'react';
+import { FormProvider, useForm } from 'react-hook-form';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ type CheckoutFormData,
+ checkoutContext,
+} from '@/components/checkout/checkout';
+import { GoDaddyProvider } from '@/godaddy-provider';
+import type { DraftOrder } from '@/types';
+import { CreditCardCheckoutButton } from './credit-card/godaddy';
+import { SquareCreditCardCheckoutButton } from './credit-card/square';
+
+const mocks = vi.hoisted(() => ({
+ latestOrder: { id: 'latest-order' } as DraftOrder,
+ flush: vi.fn(),
+ buildFromOrder: vi.fn(),
+ getNonce: vi.fn(),
+ tokenize: vi.fn(),
+ confirm: vi.fn(),
+}));
+
+vi.mock('@/components/checkout/payment/utils/use-flush-checkout-sync', () => ({
+ useFlushCheckoutSync: () => mocks.flush,
+}));
+
+vi.mock(
+ '@/components/checkout/payment/utils/use-build-payment-request',
+ () => ({
+ useBuildPaymentRequest: () => ({
+ poyntCardRequest: { firstName: 'Stale' },
+ squarePaymentRequest: { amount: '1.00' },
+ buildPaymentRequestsFromOrder: mocks.buildFromOrder,
+ }),
+ })
+);
+
+vi.mock('@/components/checkout/payment/utils/poynt-provider', () => ({
+ usePoyntCollect: () => ({
+ collect: { getNonce: mocks.getNonce },
+ isLoadingNonce: false,
+ setIsLoadingNonce: vi.fn(),
+ }),
+}));
+
+vi.mock('@/components/checkout/payment/utils/square-provider', () => ({
+ useSquare: () => ({
+ card: { tokenize: mocks.tokenize },
+ isLoading: false,
+ }),
+}));
+
+vi.mock('@/components/checkout/payment/utils/use-is-payment-disabled', () => ({
+ useIsPaymentDisabled: () => false,
+}));
+
+vi.mock(
+ '@/components/checkout/payment/utils/use-confirm-checkout',
+ async () => {
+ const actual = await vi.importActual<
+ typeof import('@/components/checkout/payment/utils/use-confirm-checkout')
+ >('@/components/checkout/payment/utils/use-confirm-checkout');
+ return {
+ ...actual,
+ useConfirmCheckout: () => ({ mutateAsync: mocks.confirm }),
+ };
+ }
+);
+
+function Wrapper({ children }: { children: React.ReactNode }) {
+ const form = useForm({
+ defaultValues: { paymentMethod: 'card' } as CheckoutFormData,
+ });
+ const queryClient = React.useMemo(
+ () =>
+ new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ }),
+ []
+ );
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+describe('payment request resolution from the flushed order', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.flush.mockResolvedValue({ latestOrder: mocks.latestOrder });
+ mocks.buildFromOrder.mockReturnValue({
+ poyntCardRequest: { firstName: 'Latest' },
+ squarePaymentRequest: {
+ amount: '43.21',
+ billingContact: { givenName: 'Latest' },
+ },
+ });
+ mocks.tokenize.mockResolvedValue({ status: 'OK', token: 'square-token' });
+ mocks.confirm.mockResolvedValue(undefined);
+ });
+
+ it('passes the latest order request to GoDaddy tokenization', async () => {
+ render(, { wrapper: Wrapper });
+
+ fireEvent.click(screen.getByRole('button', { name: /pay now/i }));
+
+ await waitFor(() => {
+ expect(mocks.getNonce).toHaveBeenCalledWith({ firstName: 'Latest' });
+ });
+ expect(mocks.flush).toHaveBeenCalledWith({
+ includeCurrentFormDiff: true,
+ });
+ expect(mocks.buildFromOrder).toHaveBeenCalledWith(mocks.latestOrder);
+ expect(mocks.getNonce.mock.invocationCallOrder[0]).toBeGreaterThan(
+ mocks.flush.mock.invocationCallOrder[0]
+ );
+ });
+
+ it('passes the latest order request to Square before confirmation', async () => {
+ render(, { wrapper: Wrapper });
+
+ fireEvent.click(screen.getByRole('button', { name: /pay now/i }));
+
+ await waitFor(() => {
+ expect(mocks.confirm).toHaveBeenCalledWith({
+ paymentToken: 'square-token',
+ paymentType: 'card',
+ paymentProvider: 'SQUARE',
+ });
+ });
+ expect(mocks.tokenize).toHaveBeenCalledWith({
+ amount: '43.21',
+ billingContact: { givenName: 'Latest' },
+ });
+ expect(mocks.tokenize.mock.invocationCallOrder[0]).toBeGreaterThan(
+ mocks.flush.mock.invocationCallOrder[0]
+ );
+ expect(mocks.confirm.mock.invocationCallOrder[0]).toBeGreaterThan(
+ mocks.tokenize.mock.invocationCallOrder[0]
+ );
+ });
+});
diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/paypal/payment-request-resolution.test.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/paypal/payment-request-resolution.test.tsx
new file mode 100644
index 00000000..46a4a6d2
--- /dev/null
+++ b/packages/react/src/components/checkout/payment/checkout-buttons/paypal/payment-request-resolution.test.tsx
@@ -0,0 +1,164 @@
+import { QueryClient } from '@tanstack/react-query';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import React from 'react';
+import { FormProvider, useForm } from 'react-hook-form';
+import { beforeEach, expect, it, vi } from 'vitest';
+import {
+ type CheckoutFormData,
+ checkoutContext,
+} from '@/components/checkout/checkout';
+import { GoDaddyProvider } from '@/godaddy-provider';
+import type { DraftOrder } from '@/types';
+import { PayPalCheckoutButton } from './paypal';
+
+const mocks = vi.hoisted(() => ({
+ latestOrder: { id: 'latest-order' } as DraftOrder,
+ flush: vi.fn(),
+ buildFromOrder: vi.fn(),
+ createOrder: vi.fn(),
+ confirm: vi.fn(),
+}));
+
+vi.mock('@paypal/react-paypal-js', () => ({
+ FUNDING: { PAYPAL: 'paypal' },
+ usePayPalScriptReducer: () => [{ isResolved: true, isPending: false }],
+ PayPalButtons: (props: {
+ onClick: (data: unknown, actions: unknown) => Promise;
+ createOrder: (data: unknown, actions: unknown) => Promise;
+ onApprove: (data: unknown, actions: unknown) => Promise;
+ }) => (
+
+ ),
+}));
+
+vi.mock('@/components/checkout/payment/utils/use-flush-checkout-sync', () => ({
+ useFlushCheckoutSync: () => mocks.flush,
+}));
+
+vi.mock(
+ '@/components/checkout/payment/utils/use-build-payment-request',
+ () => ({
+ useBuildPaymentRequest: () => ({
+ payPalRequest: { purchase_units: [{ amount: { value: '1.00' } }] },
+ buildPaymentRequestsFromOrder: mocks.buildFromOrder,
+ }),
+ })
+);
+
+vi.mock('@/components/checkout/payment/utils/use-is-payment-disabled', () => ({
+ useIsPaymentDisabled: () => false,
+}));
+
+vi.mock(
+ '@/components/checkout/payment/utils/use-confirm-checkout',
+ async () => {
+ const actual = await vi.importActual<
+ typeof import('@/components/checkout/payment/utils/use-confirm-checkout')
+ >('@/components/checkout/payment/utils/use-confirm-checkout');
+ return {
+ ...actual,
+ useConfirmCheckout: () => ({ mutateAsync: mocks.confirm }),
+ };
+ }
+);
+
+function Wrapper({ children }: { children: React.ReactNode }) {
+ const form = useForm({
+ defaultValues: { deliveryMethod: 'PURCHASE' } as CheckoutFormData,
+ });
+ const queryClient = React.useMemo(() => new QueryClient(), []);
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.flush.mockResolvedValue({ latestOrder: mocks.latestOrder });
+ mocks.buildFromOrder.mockReturnValue({
+ payPalRequest: {
+ purchase_units: [
+ {
+ amount: { currency_code: 'USD', value: '43.21' },
+ billing: { name: { full_name: 'Latest Buyer' } },
+ },
+ ],
+ },
+ });
+ mocks.createOrder.mockResolvedValue('paypal-order');
+ mocks.confirm.mockResolvedValue(undefined);
+});
+
+it('creates and confirms PayPal with the request from the flushed latest order', async () => {
+ render(, { wrapper: Wrapper });
+
+ fireEvent.click(screen.getByRole('button', { name: /paypal sdk button/i }));
+
+ await waitFor(() => {
+ expect(mocks.confirm).toHaveBeenCalledWith({
+ paymentToken: 'paypal-order:paypal-payer',
+ paymentType: 'paypal',
+ paymentProvider: 'PAYPAL',
+ });
+ });
+ expect(mocks.flush).toHaveBeenCalledWith({ includeCurrentFormDiff: true });
+ expect(mocks.buildFromOrder).toHaveBeenCalledWith(mocks.latestOrder);
+ expect(mocks.createOrder).toHaveBeenCalledWith({
+ purchase_units: [
+ {
+ amount: { currency_code: 'USD', value: '43.21' },
+ billing: { name: { full_name: 'Latest Buyer' } },
+ },
+ ],
+ application_context: { shipping_preference: 'SET_PROVIDED_ADDRESS' },
+ });
+ expect(mocks.createOrder.mock.invocationCallOrder[0]).toBeGreaterThan(
+ mocks.flush.mock.invocationCallOrder[0]
+ );
+ expect(mocks.confirm.mock.invocationCallOrder[0]).toBeGreaterThan(
+ mocks.createOrder.mock.invocationCallOrder[0]
+ );
+});
diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/paypal/paypal.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/paypal/paypal.tsx
index 125654ec..b97c0d17 100644
--- a/packages/react/src/components/checkout/payment/checkout-buttons/paypal/paypal.tsx
+++ b/packages/react/src/components/checkout/payment/checkout-buttons/paypal/paypal.tsx
@@ -22,7 +22,8 @@ function PayPalButtonsWrapper() {
const { setCheckoutErrors } = useCheckoutContext();
const isPaymentDisabled = useIsPaymentDisabled();
const form = useFormContext();
- const { payPalRequest } = useBuildPaymentRequest();
+ const { payPalRequest, buildPaymentRequestsFromOrder } =
+ useBuildPaymentRequest();
const confirmCheckout = useConfirmCheckout();
const flushCheckoutSync = useFlushCheckoutSync();
const [isPaypalDisabled, setIsPaypalDisabled] = useState(false);
@@ -47,19 +48,23 @@ function PayPalButtonsWrapper() {
return actions.reject();
}
- await flushCheckoutSync();
-
// Return true to continue flow, false to stop it
return actions.resolve();
};
const createOrder = async (_data, actions) => {
+ const { latestOrder } = await flushCheckoutSync({
+ includeCurrentFormDiff: true,
+ });
+ const request = latestOrder
+ ? buildPaymentRequestsFromOrder(latestOrder).payPalRequest
+ : payPalRequest;
const order = {
- ...payPalRequest,
- purchase_units: payPalRequest.purchase_units
+ ...request,
+ purchase_units: request.purchase_units
? [
{
- ...payPalRequest.purchase_units[0],
+ ...request.purchase_units[0],
...(isPickup ? { shipping: undefined } : {}), // Remove shipping if pickup
},
]
diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/paze/godaddy.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/paze/godaddy.tsx
index ee41410c..74c0c5c5 100644
--- a/packages/react/src/components/checkout/payment/checkout-buttons/paze/godaddy.tsx
+++ b/packages/react/src/components/checkout/payment/checkout-buttons/paze/godaddy.tsx
@@ -34,7 +34,8 @@ export function PazeCheckoutButton() {
const [isCollectLoading, setIsCollectLoading] = useState(true);
const [error, setError] = useState('');
const { data: totals } = useDraftOrderTotals();
- const { poyntStandardRequest } = useBuildPaymentRequest();
+ const { poyntStandardRequest, buildPaymentRequestsFromOrder } =
+ useBuildPaymentRequest();
const currencyCode = totals?.total?.currencyCode || 'USD';
const countryCode = session?.shipping?.originAddress?.countryCode || 'US';
@@ -62,11 +63,16 @@ export function PazeCheckoutButton() {
return;
}
- await flushCheckoutSync();
+ const { latestOrder } = await flushCheckoutSync({
+ includeCurrentFormDiff: true,
+ });
+ const request = latestOrder
+ ? buildPaymentRequestsFromOrder(latestOrder).poyntStandardRequest
+ : poyntStandardRequest;
setCheckoutErrors(undefined);
- collect?.current?.startPazeSession(poyntStandardRequest);
+ collect?.current?.startPazeSession(request);
// Track the Paze click
track({
@@ -78,6 +84,7 @@ export function PazeCheckoutButton() {
});
}, [
poyntStandardRequest,
+ buildPaymentRequestsFromOrder,
flushCheckoutSync,
setCheckoutErrors,
form,
diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/wallet-payment-request-resolution.test.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/wallet-payment-request-resolution.test.tsx
new file mode 100644
index 00000000..cd64f2d2
--- /dev/null
+++ b/packages/react/src/components/checkout/payment/checkout-buttons/wallet-payment-request-resolution.test.tsx
@@ -0,0 +1,178 @@
+import { QueryClient } from '@tanstack/react-query';
+import { act, render, waitFor } from '@testing-library/react';
+import React from 'react';
+import { FormProvider, useForm } from 'react-hook-form';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ type CheckoutFormData,
+ checkoutContext,
+} from '@/components/checkout/checkout';
+import { GoDaddyProvider } from '@/godaddy-provider';
+import type { DraftOrder } from '@/types';
+import { GoDaddyApplePayCheckoutButton } from './applePay/godaddy';
+import { GoDaddyGooglePayCheckoutButton } from './googlePay/godaddy';
+import { PazeCheckoutButton } from './paze/godaddy';
+
+const mocks = vi.hoisted(() => ({
+ latestOrder: { id: 'latest-order' } as DraftOrder,
+ flush: vi.fn(),
+ buildFromOrder: vi.fn(),
+ startApplePaySession: vi.fn(),
+ startGooglePaySession: vi.fn(),
+ startPazeSession: vi.fn(),
+ walletClickHandlers: new Map Promise>(),
+}));
+
+vi.mock('@/components/checkout/payment/utils/use-flush-checkout-sync', () => ({
+ useFlushCheckoutSync: () => mocks.flush,
+}));
+
+vi.mock(
+ '@/components/checkout/payment/utils/use-build-payment-request',
+ () => ({
+ useBuildPaymentRequest: () => ({
+ poyntStandardRequest: { total: { amount: '1.00' } },
+ buildPaymentRequestsFromOrder: mocks.buildFromOrder,
+ }),
+ })
+);
+
+vi.mock('@/components/checkout/payment/utils/use-load-poynt-collect', () => ({
+ useLoadPoyntCollect: () => ({ isPoyntLoaded: true }),
+}));
+
+vi.mock('@/components/checkout/order/use-draft-order', () => ({
+ useDraftOrderTotals: () => ({
+ data: { total: { value: 100, currencyCode: 'USD' } },
+ }),
+}));
+
+vi.mock('@/components/checkout/payment/utils/use-is-payment-disabled', () => ({
+ useIsPaymentDisabled: () => false,
+}));
+
+vi.mock(
+ '@/components/checkout/payment/utils/use-confirm-checkout',
+ async () => {
+ const actual = await vi.importActual<
+ typeof import('@/components/checkout/payment/utils/use-confirm-checkout')
+ >('@/components/checkout/payment/utils/use-confirm-checkout');
+ return {
+ ...actual,
+ useConfirmCheckout: () => ({ mutateAsync: vi.fn() }),
+ };
+ }
+);
+
+class MockTokenizeJs {
+ async supportWalletPayments() {
+ return { applePay: true, googlePay: true, paze: true };
+ }
+
+ mount(
+ id: string,
+ _document: Document,
+ options: { buttonOptions?: { onClick?: () => Promise } }
+ ) {
+ if (options.buttonOptions?.onClick) {
+ mocks.walletClickHandlers.set(id, options.buttonOptions.onClick);
+ }
+ }
+
+ on(_eventName: string, _handler: (event: unknown) => void) {
+ return undefined;
+ }
+
+ startApplePaySession(request: unknown) {
+ mocks.startApplePaySession(request);
+ }
+
+ startGooglePaySession(request: unknown) {
+ mocks.startGooglePaySession(request);
+ }
+
+ startPazeSession(request: unknown) {
+ mocks.startPazeSession(request);
+ }
+}
+
+function Wrapper({ children }: { children: React.ReactNode }) {
+ const form = useForm({
+ defaultValues: { paymentMethod: 'card' } as CheckoutFormData,
+ });
+ const queryClient = React.useMemo(() => new QueryClient(), []);
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.walletClickHandlers.clear();
+ mocks.flush.mockResolvedValue({ latestOrder: mocks.latestOrder });
+ mocks.buildFromOrder.mockReturnValue({
+ poyntStandardRequest: { total: { amount: '43.21' } },
+ });
+ window.TokenizeJs = MockTokenizeJs as never;
+});
+
+describe.each([
+ {
+ name: 'Apple Pay',
+ Component: GoDaddyApplePayCheckoutButton,
+ elementId: 'apple-pay-element',
+ start: mocks.startApplePaySession,
+ },
+ {
+ name: 'Google Pay',
+ Component: GoDaddyGooglePayCheckoutButton,
+ elementId: 'google-pay-element',
+ start: mocks.startGooglePaySession,
+ },
+ {
+ name: 'Paze',
+ Component: PazeCheckoutButton,
+ elementId: 'paze-pay-element',
+ start: mocks.startPazeSession,
+ },
+])('$name request resolution', ({ Component, elementId, start }) => {
+ it('starts the wallet with totals from the flushed latest order', async () => {
+ render(, { wrapper: Wrapper });
+
+ await waitFor(() => {
+ expect(mocks.walletClickHandlers.has(elementId)).toBe(true);
+ });
+ await act(async () => {
+ await mocks.walletClickHandlers.get(elementId)?.();
+ });
+
+ expect(mocks.flush).toHaveBeenCalledWith({
+ includeCurrentFormDiff: true,
+ });
+ expect(mocks.buildFromOrder).toHaveBeenCalledWith(mocks.latestOrder);
+ expect(start).toHaveBeenCalledWith({ total: { amount: '43.21' } });
+ expect(start.mock.invocationCallOrder[0]).toBeGreaterThan(
+ mocks.flush.mock.invocationCallOrder[0]
+ );
+ });
+});
diff --git a/packages/react/src/components/checkout/payment/free-payment-form.tsx b/packages/react/src/components/checkout/payment/free-payment-form.tsx
index 2e276800..b34ac9fc 100644
--- a/packages/react/src/components/checkout/payment/free-payment-form.tsx
+++ b/packages/react/src/components/checkout/payment/free-payment-form.tsx
@@ -3,7 +3,15 @@ import React from 'react';
import { useFormContext } from 'react-hook-form';
import { AddressForm } from '@/components/checkout/address/address-form';
import { useCheckoutContext } from '@/components/checkout/checkout';
-import { useBillingCollectionMode } from '@/components/checkout/payment/utils/billing-collection';
+import {
+ BillingCollectionLocations,
+ BillingCollectionModes,
+} from '@/components/checkout/payment/utils/billing-collection';
+import { PaymentAddressToggle } from '@/components/checkout/payment/utils/payment-address-toggle';
+import {
+ useBillingPolicy,
+ useCanOfferShippingAddressAsBilling,
+} from '@/components/checkout/payment/utils/use-billing-policy';
import {
PaymentProvider,
useConfirmCheckout,
@@ -22,9 +30,8 @@ export function FreePaymentForm() {
const form = useFormContext();
const confirmCheckout = useConfirmCheckout();
- const billingMode = useBillingCollectionMode({
- context: 'free-payment-form',
- });
+ const billingPolicy = useBillingPolicy();
+ const showAddressToggle = useCanOfferShippingAddressAsBilling();
const handleSubmit = React.useCallback(async () => {
const valid = await form.trigger();
@@ -70,10 +77,20 @@ export function FreePaymentForm() {
);
- if (billingMode !== 'none') {
+ const shouldShowBilling =
+ billingPolicy.location === BillingCollectionLocations.FREE_PAYMENT_FORM &&
+ billingPolicy.mode !== BillingCollectionModes.NONE;
+
+ if (showAddressToggle || shouldShowBilling) {
return (
-
+ {showAddressToggle ?
: null}
+ {shouldShowBilling ? (
+
+ ) : null}
{submitButton}
);
diff --git a/packages/react/src/components/checkout/payment/payment-form.tsx b/packages/react/src/components/checkout/payment/payment-form.tsx
index dd85d3d6..a38dc70d 100644
--- a/packages/react/src/components/checkout/payment/payment-form.tsx
+++ b/packages/react/src/components/checkout/payment/payment-form.tsx
@@ -35,11 +35,15 @@ import {
} from '@/components/checkout/payment/payment-method-renderer';
import type { TokenizeJs } from '@/components/checkout/payment/types';
import {
- hasInlineBillingForm,
- useBillingCollectionMode,
+ BillingCollectionLocations,
+ BillingCollectionModes,
} from '@/components/checkout/payment/utils/billing-collection';
import { getApplicationId } from '@/components/checkout/payment/utils/get-application-id';
import { PaymentAddressToggle } from '@/components/checkout/payment/utils/payment-address-toggle';
+import {
+ useBillingPolicy,
+ useCanOfferShippingAddressAsBilling,
+} from '@/components/checkout/payment/utils/use-billing-policy';
import { useGetSelectedPaymentMethod } from '@/components/checkout/payment/utils/use-get-selected-payment-method';
import { useLoadPoyntCollect } from '@/components/checkout/payment/utils/use-load-poynt-collect';
import { Target } from '@/components/checkout/target/target';
@@ -102,9 +106,13 @@ export function PaymentForm(
const paymentMethod = form.watch('paymentMethod');
const deliveryMethod = form.watch('deliveryMethod');
const isPickup = deliveryMethod === DeliveryMethods.PICKUP;
- const isShipping = deliveryMethod === DeliveryMethods.SHIP;
- const billingMode = useBillingCollectionMode({ context: 'top-level' });
- const isPaymentMethodWithInlineBilling = hasInlineBillingForm(paymentMethod);
+ const _isShipping = deliveryMethod === DeliveryMethods.SHIP;
+ const billingPolicy = useBillingPolicy();
+ const selectedMethodUsesInlineBilling =
+ paymentMethod === PaymentMethodType.CREDIT_CARD ||
+ paymentMethod === PaymentMethodType.ACH;
+ const canOfferShippingAddressAsBilling =
+ useCanOfferShippingAddressAsBilling();
const methodConfig = useGetSelectedPaymentMethod(
paymentMethod as PaymentMethodValue
);
@@ -294,11 +302,12 @@ export function PaymentForm(
googlePaySupported,
]);
- const shouldShowBillingNamesOnly = billingMode === 'names';
- const isBillingAddressRequired = billingMode !== 'none';
-
+ const shouldShowBilling =
+ billingPolicy.location === BillingCollectionLocations.TOP_LEVEL &&
+ billingPolicy.mode !== BillingCollectionModes.NONE;
const billingCopy =
- shouldShowBillingNamesOnly && t.payment.billingInformation
+ billingPolicy.mode === BillingCollectionModes.NAMES &&
+ t.payment.billingInformation
? t.payment.billingInformation
: t.payment.billingAddress;
@@ -534,12 +543,10 @@ export function PaymentForm(
/>
) : null}
- {isShipping &&
- session?.enableShipping &&
- !isPaymentMethodWithInlineBilling ? (
+ {canOfferShippingAddressAsBilling && !selectedMethodUsesInlineBilling ? (
) : null}
- {isBillingAddressRequired ? (
+ {shouldShowBilling ? (
) : null}
diff --git a/packages/react/src/components/checkout/payment/payment-methods/ach/godaddy.tsx b/packages/react/src/components/checkout/payment/payment-methods/ach/godaddy.tsx
index 53251a77..f62efa6c 100644
--- a/packages/react/src/components/checkout/payment/payment-methods/ach/godaddy.tsx
+++ b/packages/react/src/components/checkout/payment/payment-methods/ach/godaddy.tsx
@@ -4,14 +4,21 @@ import { AddressForm } from '@/components/checkout/address';
import { useCheckoutContext } from '@/components/checkout/checkout';
import { CheckoutSection } from '@/components/checkout/checkout-section';
import { CheckoutSectionHeader } from '@/components/checkout/checkout-section-header';
-import { DeliveryMethods } from '@/components/checkout/delivery/delivery-methods';
import type {
TokenizeJs,
TokenizeJsEvent,
} from '@/components/checkout/payment/types';
+import {
+ BillingCollectionLocations,
+ BillingCollectionModes,
+} from '@/components/checkout/payment/utils/billing-collection';
import { getApplicationId } from '@/components/checkout/payment/utils/get-application-id';
import { PaymentAddressToggle } from '@/components/checkout/payment/utils/payment-address-toggle';
import { usePoyntACHCollect } from '@/components/checkout/payment/utils/poynt-ach-provider';
+import {
+ useBillingPolicy,
+ useCanOfferShippingAddressAsBilling,
+} from '@/components/checkout/payment/utils/use-billing-policy';
import {
PaymentProvider,
useConfirmCheckout,
@@ -31,32 +38,17 @@ export function GoDaddyACHForm() {
const form = useFormContext();
const paymentMethod = form.watch('paymentMethod');
- const useShippingAddress = form.watch('paymentUseShippingAddress');
- const deliveryMethod = form.watch('deliveryMethod');
- const isShipping = deliveryMethod === DeliveryMethods.SHIP;
-
- // Billing is separate from shipping when there is no shipping address to
- // copy from. `mapOrderToFormValues` canonicalizes deliveryMethod against
- // session capabilities, so `!isShipping` already covers:
- // - session.enableShipping = false
- // - line items have no SHIP fulfillment (PICKUP / PURCHASE / all-NONE)
- // The remaining case is the user opting out of "use shipping for billing".
- const billingIsSeparateFromShipping = !isShipping || !useShippingAddress;
-
- const billingAddressEnabled =
- session?.enableBillingAddressCollection !== false;
- const shouldShowBillingNamesOnly =
- paymentMethod === PaymentMethodType.ACH &&
- !billingAddressEnabled &&
- billingIsSeparateFromShipping;
-
- const isBillingAddressRequired =
+ const billingPolicy = useBillingPolicy();
+ const canOfferShippingAddressAsBilling =
+ useCanOfferShippingAddressAsBilling();
+ const shouldShowBilling =
+ billingPolicy.location === BillingCollectionLocations.INLINE_PAYMENT_FORM &&
paymentMethod === PaymentMethodType.ACH &&
- billingIsSeparateFromShipping &&
- (shouldShowBillingNamesOnly || billingAddressEnabled);
+ billingPolicy.mode !== BillingCollectionModes.NONE;
const billingCopy =
- shouldShowBillingNamesOnly && t.payment.billingInformation
+ billingPolicy.mode === BillingCollectionModes.NAMES &&
+ t.payment.billingInformation
? t.payment.billingInformation
: t.payment.billingAddress;
@@ -263,12 +255,11 @@ export function GoDaddyACHForm() {
{error ? (
{error}
) : null}
- {session?.enableShipping &&
- isShipping &&
+ {canOfferShippingAddressAsBilling &&
paymentMethod === PaymentMethodType.ACH ? (
) : null}
- {isBillingAddressRequired ? (
+ {shouldShowBilling ? (
) : null}
diff --git a/packages/react/src/components/checkout/payment/payment-methods/credit-card/container.tsx b/packages/react/src/components/checkout/payment/payment-methods/credit-card/container.tsx
index b6563254..00d5dc58 100644
--- a/packages/react/src/components/checkout/payment/payment-methods/credit-card/container.tsx
+++ b/packages/react/src/components/checkout/payment/payment-methods/credit-card/container.tsx
@@ -2,45 +2,35 @@ import type { ReactNode } from 'react';
import { useCallback } from 'react';
import { useFormContext } from 'react-hook-form';
import { AddressForm } from '@/components/checkout/address';
-import { useCheckoutContext } from '@/components/checkout/checkout';
import { CheckoutSection } from '@/components/checkout/checkout-section';
import { CheckoutSectionHeader } from '@/components/checkout/checkout-section-header';
-import { DeliveryMethods } from '@/components/checkout/delivery/delivery-methods';
+import {
+ BillingCollectionLocations,
+ BillingCollectionModes,
+} from '@/components/checkout/payment/utils/billing-collection';
import { PaymentAddressToggle } from '@/components/checkout/payment/utils/payment-address-toggle';
+import {
+ useBillingPolicy,
+ useCanOfferShippingAddressAsBilling,
+} from '@/components/checkout/payment/utils/use-billing-policy';
import { useGoDaddyContext } from '@/godaddy-provider';
import { PaymentMethodType } from '@/types';
export function CreditCardContainer({ children }: { children?: ReactNode }) {
- const { session } = useCheckoutContext();
const form = useFormContext();
const { t } = useGoDaddyContext();
const paymentMethod = form.watch('paymentMethod');
- const useShippingAddress = form.watch('paymentUseShippingAddress');
- const deliveryMethod = form.watch('deliveryMethod');
- const isShipping = deliveryMethod === DeliveryMethods.SHIP;
-
- // Billing is separate from shipping when there is no shipping address to
- // copy from. `mapOrderToFormValues` canonicalizes deliveryMethod against
- // session capabilities, so `!isShipping` already covers:
- // - session.enableShipping = false
- // - line items have no SHIP fulfillment (PICKUP / PURCHASE / all-NONE)
- // The remaining case is the user opting out of "use shipping for billing".
- const billingIsSeparateFromShipping = !isShipping || !useShippingAddress;
-
- const billingAddressEnabled =
- session?.enableBillingAddressCollection !== false;
- const shouldShowBillingNamesOnly =
- paymentMethod === PaymentMethodType.CREDIT_CARD &&
- !billingAddressEnabled &&
- billingIsSeparateFromShipping;
-
- const isBillingAddressRequired =
+ const billingPolicy = useBillingPolicy();
+ const canOfferShippingAddressAsBilling =
+ useCanOfferShippingAddressAsBilling();
+ const shouldShowBilling =
+ billingPolicy.location === BillingCollectionLocations.INLINE_PAYMENT_FORM &&
paymentMethod === PaymentMethodType.CREDIT_CARD &&
- billingIsSeparateFromShipping &&
- (shouldShowBillingNamesOnly || billingAddressEnabled);
+ billingPolicy.mode !== BillingCollectionModes.NONE;
const billingCopy =
- shouldShowBillingNamesOnly && t.payment.billingInformation
+ billingPolicy.mode === BillingCollectionModes.NAMES &&
+ t.payment.billingInformation
? t.payment.billingInformation
: t.payment.billingAddress;
@@ -59,12 +49,11 @@ export function CreditCardContainer({ children }: { children?: ReactNode }) {
<>
{description && {description}
}
{children}
- {session?.enableShipping &&
- isShipping &&
+ {canOfferShippingAddressAsBilling &&
paymentMethod === PaymentMethodType.CREDIT_CARD && (
)}
- {isBillingAddressRequired ? (
+ {shouldShowBilling ? (
) : null}
diff --git a/packages/react/src/components/checkout/payment/utils/billing-collection.test.ts b/packages/react/src/components/checkout/payment/utils/billing-collection.test.ts
new file mode 100644
index 00000000..c9c8e1eb
--- /dev/null
+++ b/packages/react/src/components/checkout/payment/utils/billing-collection.test.ts
@@ -0,0 +1,338 @@
+import { describe, expect, it } from 'vitest';
+import { DeliveryMethods } from '@/components/checkout/delivery/delivery-methods';
+import { PaymentMethodType } from '@/types';
+import {
+ BillingCollectionLocations,
+ type BillingCollectionMode,
+ BillingCollectionModes,
+ type BillingPolicy,
+ type BillingPolicyInput,
+ getBillingPolicy,
+} from './billing-collection';
+
+const deliveryMethods = [
+ DeliveryMethods.SHIP,
+ DeliveryMethods.PICKUP,
+ DeliveryMethods.PURCHASE,
+ DeliveryMethods.DIGITAL,
+];
+const paymentMethods = Object.values(PaymentMethodType).filter(
+ paymentMethod => paymentMethod !== PaymentMethodType.EXPRESS
+);
+const flags = [true, false];
+
+function everyPolicyCombination(callback: (input: BillingPolicyInput) => void) {
+ for (const deliveryMethod of deliveryMethods) {
+ for (const paymentMethod of paymentMethods) {
+ for (const isFreeOrder of flags) {
+ for (const paymentUseShippingAddress of flags) {
+ for (const enableShipping of flags) {
+ for (const enableShippingAddressCollection of flags) {
+ for (const enableBillingAddressCollection of flags) {
+ for (const enableTaxCollection of flags) {
+ callback({
+ isFreeOrder,
+ paymentMethod,
+ deliveryMethod,
+ paymentUseShippingAddress,
+ enableShipping,
+ enableShippingAddressCollection,
+ enableBillingAddressCollection,
+ enableTaxCollection,
+ });
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+function getExpectedMode({
+ isFreeOrder,
+ paymentMethod,
+ deliveryMethod,
+ paymentUseShippingAddress,
+ enableShipping,
+ enableShippingAddressCollection,
+ enableBillingAddressCollection,
+ enableTaxCollection,
+}: BillingPolicyInput): BillingCollectionMode {
+ if (
+ deliveryMethod === DeliveryMethods.SHIP &&
+ enableShipping &&
+ enableShippingAddressCollection &&
+ paymentUseShippingAddress
+ ) {
+ return BillingCollectionModes.NONE;
+ }
+
+ const effectivePaymentMethod = isFreeOrder
+ ? PaymentMethodType.OFFLINE
+ : paymentMethod;
+ const isOffline = effectivePaymentMethod === PaymentMethodType.OFFLINE;
+
+ if (isOffline) {
+ if (deliveryMethod === DeliveryMethods.PICKUP) {
+ return BillingCollectionModes.NAMES;
+ }
+ if (
+ deliveryMethod === DeliveryMethods.PURCHASE ||
+ deliveryMethod === DeliveryMethods.DIGITAL
+ ) {
+ if (!enableTaxCollection) return BillingCollectionModes.NAMES;
+ return enableBillingAddressCollection
+ ? BillingCollectionModes.ADDRESS
+ : BillingCollectionModes.NAMES;
+ }
+ }
+
+ return enableBillingAddressCollection
+ ? BillingCollectionModes.ADDRESS
+ : BillingCollectionModes.NAMES;
+}
+
+function getExpectedPolicy(input: BillingPolicyInput): BillingPolicy {
+ const mode = getExpectedMode(input);
+ const usesShippingAddress = Boolean(
+ input.deliveryMethod === DeliveryMethods.SHIP &&
+ input.enableShipping &&
+ input.enableShippingAddressCollection &&
+ input.paymentUseShippingAddress
+ );
+
+ if (mode === BillingCollectionModes.NONE) {
+ return {
+ mode,
+ location: BillingCollectionLocations.NONE,
+ usesShippingAddress,
+ };
+ }
+
+ if (input.isFreeOrder) {
+ return {
+ mode,
+ location: BillingCollectionLocations.FREE_PAYMENT_FORM,
+ usesShippingAddress,
+ };
+ }
+
+ if (
+ input.paymentMethod === PaymentMethodType.CREDIT_CARD ||
+ input.paymentMethod === PaymentMethodType.ACH
+ ) {
+ return {
+ mode,
+ location: BillingCollectionLocations.INLINE_PAYMENT_FORM,
+ usesShippingAddress,
+ };
+ }
+
+ return {
+ mode,
+ location: BillingCollectionLocations.TOP_LEVEL,
+ usesShippingAddress,
+ };
+}
+
+describe('getBillingPolicy', () => {
+ it('implements the authoritative matrix for every supported input combination', () => {
+ everyPolicyCombination(input => {
+ expect({ input, policy: getBillingPolicy(input) }).toEqual({
+ input,
+ policy: getExpectedPolicy(input),
+ });
+ });
+ });
+
+ it('forces free orders through offline rules when a stale inline payment remains selected', () => {
+ for (const paymentMethod of [
+ PaymentMethodType.CREDIT_CARD,
+ PaymentMethodType.ACH,
+ ]) {
+ const input: BillingPolicyInput = {
+ isFreeOrder: true,
+ paymentMethod,
+ deliveryMethod: DeliveryMethods.PICKUP,
+ paymentUseShippingAddress: false,
+ enableShipping: true,
+ enableShippingAddressCollection: true,
+ enableBillingAddressCollection: true,
+ enableTaxCollection: true,
+ };
+
+ expect(getBillingPolicy(input)).toEqual(
+ getBillingPolicy({
+ ...input,
+ paymentMethod: PaymentMethodType.OFFLINE,
+ })
+ );
+ expect(getBillingPolicy(input)).toEqual({
+ mode: BillingCollectionModes.NAMES,
+ location: BillingCollectionLocations.FREE_PAYMENT_FORM,
+ usesShippingAddress: false,
+ });
+ }
+ });
+
+ it('uses the inline payment form for active paid card and ACH billing', () => {
+ for (const paymentMethod of [
+ PaymentMethodType.CREDIT_CARD,
+ PaymentMethodType.ACH,
+ ]) {
+ for (const deliveryMethod of [
+ DeliveryMethods.PICKUP,
+ DeliveryMethods.PURCHASE,
+ DeliveryMethods.DIGITAL,
+ DeliveryMethods.SHIP,
+ ]) {
+ const policy = getBillingPolicy({
+ isFreeOrder: false,
+ paymentMethod,
+ deliveryMethod,
+ paymentUseShippingAddress: false,
+ enableShipping: true,
+ enableShippingAddressCollection: true,
+ enableBillingAddressCollection: true,
+ enableTaxCollection: false,
+ });
+
+ expect(policy.location).toBe(
+ BillingCollectionLocations.INLINE_PAYMENT_FORM
+ );
+ expect(policy.mode).toBe(BillingCollectionModes.ADDRESS);
+ }
+ }
+ });
+
+ it('uses the top-level payment form for active paid non-inline methods', () => {
+ for (const paymentMethod of [
+ PaymentMethodType.OFFLINE,
+ PaymentMethodType.PAYPAL,
+ PaymentMethodType.APPLE_PAY,
+ ]) {
+ const policy = getBillingPolicy({
+ isFreeOrder: false,
+ paymentMethod,
+ deliveryMethod: DeliveryMethods.PICKUP,
+ paymentUseShippingAddress: false,
+ enableShipping: true,
+ enableShippingAddressCollection: true,
+ enableBillingAddressCollection: true,
+ enableTaxCollection: true,
+ });
+
+ expect(policy.location).toBe(BillingCollectionLocations.TOP_LEVEL);
+ expect(policy.mode).not.toBe(BillingCollectionModes.NONE);
+ }
+ });
+
+ it('uses the free payment form for active free-order billing', () => {
+ for (const deliveryMethod of [
+ DeliveryMethods.PICKUP,
+ DeliveryMethods.PURCHASE,
+ DeliveryMethods.DIGITAL,
+ DeliveryMethods.SHIP,
+ ]) {
+ const policy = getBillingPolicy({
+ isFreeOrder: true,
+ paymentMethod: PaymentMethodType.PAYPAL,
+ deliveryMethod,
+ paymentUseShippingAddress: false,
+ enableShipping: true,
+ enableShippingAddressCollection: true,
+ enableBillingAddressCollection: true,
+ enableTaxCollection: true,
+ });
+
+ expect(policy.location).toBe(
+ BillingCollectionLocations.FREE_PAYMENT_FORM
+ );
+ expect(policy.mode).not.toBe(BillingCollectionModes.NONE);
+ }
+ });
+
+ it('returns no billing location whenever mode is none', () => {
+ everyPolicyCombination(input => {
+ const policy = getBillingPolicy(input);
+
+ if (policy.mode === BillingCollectionModes.NONE) {
+ expect(policy.location).toBe(BillingCollectionLocations.NONE);
+ }
+ });
+ });
+
+ it('does not collect separate billing when shipping is reused as billing', () => {
+ for (const isFreeOrder of flags) {
+ for (const paymentMethod of paymentMethods) {
+ const policy = getBillingPolicy({
+ isFreeOrder,
+ paymentMethod,
+ deliveryMethod: DeliveryMethods.SHIP,
+ paymentUseShippingAddress: true,
+ enableShipping: true,
+ enableShippingAddressCollection: true,
+ enableBillingAddressCollection: true,
+ enableTaxCollection: true,
+ });
+
+ expect(policy).toEqual({
+ mode: BillingCollectionModes.NONE,
+ location: BillingCollectionLocations.NONE,
+ usesShippingAddress: true,
+ });
+ }
+ }
+ });
+
+ it('does not reuse shipping as billing when shipping address collection is disabled', () => {
+ expect(
+ getBillingPolicy({
+ isFreeOrder: false,
+ paymentMethod: PaymentMethodType.CREDIT_CARD,
+ deliveryMethod: DeliveryMethods.SHIP,
+ paymentUseShippingAddress: true,
+ enableShipping: true,
+ enableShippingAddressCollection: false,
+ enableBillingAddressCollection: true,
+ enableTaxCollection: true,
+ })
+ ).toEqual({
+ mode: BillingCollectionModes.ADDRESS,
+ location: BillingCollectionLocations.INLINE_PAYMENT_FORM,
+ usesShippingAddress: false,
+ });
+ });
+
+ it('does not reuse shipping as billing when shipping is disabled', () => {
+ expect(
+ getBillingPolicy({
+ isFreeOrder: false,
+ paymentMethod: PaymentMethodType.CREDIT_CARD,
+ deliveryMethod: DeliveryMethods.SHIP,
+ paymentUseShippingAddress: true,
+ enableShipping: false,
+ enableShippingAddressCollection: true,
+ enableBillingAddressCollection: true,
+ enableTaxCollection: true,
+ })
+ ).toEqual({
+ mode: BillingCollectionModes.ADDRESS,
+ location: BillingCollectionLocations.INLINE_PAYMENT_FORM,
+ usesShippingAddress: false,
+ });
+ });
+
+ it('never returns address mode when billing address collection is disabled', () => {
+ everyPolicyCombination(input => {
+ const policy = getBillingPolicy({
+ ...input,
+ enableBillingAddressCollection: false,
+ });
+
+ expect(policy.mode).not.toBe(BillingCollectionModes.ADDRESS);
+ });
+ });
+});
diff --git a/packages/react/src/components/checkout/payment/utils/billing-collection.ts b/packages/react/src/components/checkout/payment/utils/billing-collection.ts
index c0cb83b7..a990f27e 100644
--- a/packages/react/src/components/checkout/payment/utils/billing-collection.ts
+++ b/packages/react/src/components/checkout/payment/utils/billing-collection.ts
@@ -1,21 +1,121 @@
-import { useFormContext } from 'react-hook-form';
-import type { CheckoutFormData } from '@/components/checkout/checkout';
-import { useCheckoutContext } from '@/components/checkout/checkout';
import { DeliveryMethods } from '@/components/checkout/delivery/delivery-methods';
import { PaymentMethodType, type PaymentMethodValue } from '@/types';
-export type BillingCollectionMode = 'none' | 'names' | 'address';
-export type BillingCollectionContext =
- | 'top-level'
- | 'inline-payment-form'
- | 'free-payment-form';
+export const BillingCollectionModes = {
+ NONE: 'none',
+ NAMES: 'names',
+ ADDRESS: 'address',
+} as const;
+
+export type BillingCollectionMode =
+ (typeof BillingCollectionModes)[keyof typeof BillingCollectionModes];
+
+export const BillingCollectionLocations = {
+ NONE: 'none',
+ TOP_LEVEL: 'top-level',
+ INLINE_PAYMENT_FORM: 'inline-payment-form',
+ FREE_PAYMENT_FORM: 'free-payment-form',
+} as const;
+
+export type BillingCollectionLocation =
+ (typeof BillingCollectionLocations)[keyof typeof BillingCollectionLocations];
+
+export type BillingPolicyInput = {
+ isFreeOrder: boolean;
+ paymentMethod?: PaymentMethodValue | string | null;
+ deliveryMethod?: DeliveryMethods | string | null;
+ paymentUseShippingAddress: boolean;
+ enableShipping: boolean;
+ enableShippingAddressCollection: boolean;
+ enableBillingAddressCollection: boolean;
+ enableTaxCollection: boolean;
+};
+
+export type BillingPolicy = {
+ mode: BillingCollectionMode;
+ location: BillingCollectionLocation;
+ usesShippingAddress: boolean;
+};
const INLINE_BILLING_PAYMENT_METHODS: PaymentMethodValue[] = [
PaymentMethodType.CREDIT_CARD,
PaymentMethodType.ACH,
];
-export function hasInlineBillingForm(
+export function canOfferShippingAddressAsBilling({
+ deliveryMethod,
+ enableShipping,
+ enableShippingAddressCollection,
+}: Pick<
+ BillingPolicyInput,
+ 'deliveryMethod' | 'enableShipping' | 'enableShippingAddressCollection'
+>) {
+ return Boolean(
+ deliveryMethod === DeliveryMethods.SHIP &&
+ enableShipping &&
+ enableShippingAddressCollection
+ );
+}
+
+export function isUsingShippingAddressAsBilling({
+ paymentUseShippingAddress,
+ ...input
+}: Pick<
+ BillingPolicyInput,
+ | 'deliveryMethod'
+ | 'paymentUseShippingAddress'
+ | 'enableShipping'
+ | 'enableShippingAddressCollection'
+>) {
+ return canOfferShippingAddressAsBilling(input) && paymentUseShippingAddress;
+}
+
+function getOfflineBillingMode({
+ deliveryMethod,
+ usesShippingAddress,
+ enableBillingAddressCollection,
+ enableTaxCollection,
+}: Pick<
+ BillingPolicyInput,
+ 'deliveryMethod' | 'enableBillingAddressCollection' | 'enableTaxCollection'
+> & {
+ usesShippingAddress: boolean;
+}): BillingCollectionMode {
+ if (usesShippingAddress) return BillingCollectionModes.NONE;
+
+ if (deliveryMethod === DeliveryMethods.PICKUP) {
+ return BillingCollectionModes.NAMES;
+ }
+
+ if (
+ deliveryMethod === DeliveryMethods.PURCHASE ||
+ deliveryMethod === DeliveryMethods.DIGITAL
+ ) {
+ if (!enableTaxCollection) return BillingCollectionModes.NAMES;
+ return enableBillingAddressCollection
+ ? BillingCollectionModes.ADDRESS
+ : BillingCollectionModes.NAMES;
+ }
+
+ return enableBillingAddressCollection
+ ? BillingCollectionModes.ADDRESS
+ : BillingCollectionModes.NAMES;
+}
+
+function getPaidStandardBillingMode({
+ usesShippingAddress,
+ enableBillingAddressCollection,
+}: Pick & {
+ usesShippingAddress: boolean;
+}): BillingCollectionMode {
+ if (usesShippingAddress) return BillingCollectionModes.NONE;
+
+ return enableBillingAddressCollection
+ ? BillingCollectionModes.ADDRESS
+ : BillingCollectionModes.NAMES;
+}
+
+function isInlineBillingPaymentMethod(
paymentMethod?: PaymentMethodValue | string | null
) {
return Boolean(
@@ -26,75 +126,66 @@ export function hasInlineBillingForm(
);
}
-export function getBillingCollectionMode({
- context,
- deliveryMethod,
+export function getBillingPolicy({
+ isFreeOrder,
paymentMethod,
- paymentUseShippingAddress = true,
- enableBillingAddressCollection = true,
- enableTaxCollection = false,
-}: {
- context: BillingCollectionContext;
- deliveryMethod?: DeliveryMethods | string | null;
- paymentMethod?: PaymentMethodValue | string | null;
- paymentUseShippingAddress?: boolean | null;
- enableBillingAddressCollection?: boolean | null;
- enableTaxCollection?: boolean | null;
-}): BillingCollectionMode {
- const isDigital = deliveryMethod === DeliveryMethods.DIGITAL;
- const isPickup = deliveryMethod === DeliveryMethods.PICKUP;
- const isShipping = deliveryMethod === DeliveryMethods.SHIP;
- const isOffline = paymentMethod === PaymentMethodType.OFFLINE;
- const inlineBilling = hasInlineBillingForm(paymentMethod);
- const billingAddressEnabled = enableBillingAddressCollection !== false;
- const billingIsSeparateFromShipping =
- !isShipping || !paymentUseShippingAddress;
-
- if (context === 'top-level') {
- if (inlineBilling) return 'none';
-
- if (isDigital) {
- if (!billingAddressEnabled) return 'names';
- if (isOffline && !enableTaxCollection) return 'names';
- return 'address';
- }
-
- if (isPickup && isOffline && !enableTaxCollection) return 'names';
- if (!billingIsSeparateFromShipping) return 'none';
- return billingAddressEnabled ? 'address' : 'names';
- }
+ deliveryMethod,
+ paymentUseShippingAddress,
+ enableShipping,
+ enableShippingAddressCollection,
+ enableBillingAddressCollection,
+ enableTaxCollection,
+}: BillingPolicyInput): BillingPolicy {
+ const usesShippingAddress = isUsingShippingAddressAsBilling({
+ deliveryMethod,
+ paymentUseShippingAddress,
+ enableShipping,
+ enableShippingAddressCollection,
+ });
+ const effectivePaymentMethod = isFreeOrder
+ ? PaymentMethodType.OFFLINE
+ : paymentMethod;
+ const isOffline = effectivePaymentMethod === PaymentMethodType.OFFLINE;
+ const isInline = isInlineBillingPaymentMethod(effectivePaymentMethod);
+ const mode = isOffline
+ ? getOfflineBillingMode({
+ deliveryMethod,
+ usesShippingAddress,
+ enableBillingAddressCollection,
+ enableTaxCollection,
+ })
+ : getPaidStandardBillingMode({
+ usesShippingAddress,
+ enableBillingAddressCollection,
+ });
- if (context === 'inline-payment-form') {
- if (!inlineBilling) return 'none';
- if (isDigital) return billingAddressEnabled ? 'address' : 'names';
- if (!billingIsSeparateFromShipping) return 'none';
- return billingAddressEnabled ? 'address' : 'names';
+ if (mode === BillingCollectionModes.NONE) {
+ return {
+ mode,
+ location: BillingCollectionLocations.NONE,
+ usesShippingAddress,
+ };
}
- if (isDigital) {
- if (!enableTaxCollection) return 'names';
- return billingAddressEnabled ? 'address' : 'names';
+ if (isFreeOrder) {
+ return {
+ mode,
+ location: BillingCollectionLocations.FREE_PAYMENT_FORM,
+ usesShippingAddress,
+ };
}
- if (isPickup) return 'names';
-
- return 'none';
-}
+ if (isInline) {
+ return {
+ mode,
+ location: BillingCollectionLocations.INLINE_PAYMENT_FORM,
+ usesShippingAddress,
+ };
+ }
-export function useBillingCollectionMode({
- context,
-}: {
- context: BillingCollectionContext;
-}): BillingCollectionMode {
- const form = useFormContext();
- const { session } = useCheckoutContext();
-
- return getBillingCollectionMode({
- context,
- deliveryMethod: form.watch('deliveryMethod'),
- paymentMethod: form.watch('paymentMethod'),
- paymentUseShippingAddress: form.watch('paymentUseShippingAddress'),
- enableBillingAddressCollection: session?.enableBillingAddressCollection,
- enableTaxCollection: session?.enableTaxCollection,
- });
+ return {
+ mode,
+ location: BillingCollectionLocations.TOP_LEVEL,
+ usesShippingAddress,
+ };
}
diff --git a/packages/react/src/components/checkout/payment/utils/use-billing-policy.test.ts b/packages/react/src/components/checkout/payment/utils/use-billing-policy.test.ts
new file mode 100644
index 00000000..4aa396c1
--- /dev/null
+++ b/packages/react/src/components/checkout/payment/utils/use-billing-policy.test.ts
@@ -0,0 +1,146 @@
+import { describe, expect, it } from 'vitest';
+import { DeliveryMethods } from '@/components/checkout/delivery/delivery-methods';
+import {
+ BillingCollectionLocations,
+ BillingCollectionModes,
+} from '@/components/checkout/payment/utils/billing-collection';
+import { type CheckoutSession, PaymentMethodType, type Totals } from '@/types';
+import { resolveBillingPolicyForCheckoutState } from './use-billing-policy';
+
+const paidTotals = {
+ total: { value: 1000, currencyCode: 'USD' },
+} as Totals;
+
+const freeTotals = {
+ total: { value: 0, currencyCode: 'USD' },
+} as Totals;
+
+const values = {
+ deliveryMethod: DeliveryMethods.SHIP,
+ paymentMethod: PaymentMethodType.CREDIT_CARD,
+ paymentUseShippingAddress: true,
+};
+
+function buildSession(overrides: Partial = {}) {
+ return {
+ enableShipping: true,
+ enableShippingAddressCollection: true,
+ enableBillingAddressCollection: true,
+ enableTaxCollection: true,
+ ...overrides,
+ } as CheckoutSession;
+}
+
+describe('resolveBillingPolicyForCheckoutState', () => {
+ it.each([
+ {
+ enableShipping: null,
+ enableShippingAddressCollection: true,
+ },
+ {
+ enableShipping: true,
+ enableShippingAddressCollection: null,
+ },
+ {
+ enableShipping: undefined,
+ enableShippingAddressCollection: undefined,
+ },
+ ])(
+ 'does not reuse shipping when its collection flags are not explicitly enabled',
+ session => {
+ expect(
+ resolveBillingPolicyForCheckoutState({
+ values,
+ session: buildSession(session),
+ totals: paidTotals,
+ })
+ ).toEqual({
+ mode: BillingCollectionModes.ADDRESS,
+ location: BillingCollectionLocations.INLINE_PAYMENT_FORM,
+ usesShippingAddress: false,
+ });
+ }
+ );
+
+ it('reuses shipping when shipping and address collection are explicitly enabled', () => {
+ expect(
+ resolveBillingPolicyForCheckoutState({
+ values,
+ session: buildSession(),
+ totals: paidTotals,
+ })
+ ).toEqual({
+ mode: BillingCollectionModes.NONE,
+ location: BillingCollectionLocations.NONE,
+ usesShippingAddress: true,
+ });
+ });
+
+ it('collects separate billing when the customer opts out of shipping reuse', () => {
+ expect(
+ resolveBillingPolicyForCheckoutState({
+ values: { ...values, paymentUseShippingAddress: false },
+ session: buildSession(),
+ totals: paidTotals,
+ })
+ ).toEqual({
+ mode: BillingCollectionModes.ADDRESS,
+ location: BillingCollectionLocations.INLINE_PAYMENT_FORM,
+ usesShippingAddress: false,
+ });
+ });
+
+ it('uses free offline pickup rules when the total is zero', () => {
+ expect(
+ resolveBillingPolicyForCheckoutState({
+ values: {
+ ...values,
+ deliveryMethod: DeliveryMethods.PICKUP,
+ paymentUseShippingAddress: false,
+ },
+ session: buildSession(),
+ totals: freeTotals,
+ })
+ ).toEqual({
+ mode: BillingCollectionModes.NAMES,
+ location: BillingCollectionLocations.FREE_PAYMENT_FORM,
+ usesShippingAddress: false,
+ });
+ });
+
+ it('does not treat a missing total as a free order', () => {
+ expect(
+ resolveBillingPolicyForCheckoutState({
+ values: {
+ ...values,
+ deliveryMethod: DeliveryMethods.PURCHASE,
+ paymentUseShippingAddress: false,
+ },
+ session: buildSession({ enableTaxCollection: false }),
+ totals: undefined,
+ })
+ ).toEqual({
+ mode: BillingCollectionModes.ADDRESS,
+ location: BillingCollectionLocations.INLINE_PAYMENT_FORM,
+ usesShippingAddress: false,
+ });
+ });
+
+ it('keeps a positive-total card order in the paid inline flow', () => {
+ expect(
+ resolveBillingPolicyForCheckoutState({
+ values: {
+ ...values,
+ deliveryMethod: DeliveryMethods.PICKUP,
+ paymentUseShippingAddress: false,
+ },
+ session: buildSession(),
+ totals: paidTotals,
+ })
+ ).toEqual({
+ mode: BillingCollectionModes.ADDRESS,
+ location: BillingCollectionLocations.INLINE_PAYMENT_FORM,
+ usesShippingAddress: false,
+ });
+ });
+});
diff --git a/packages/react/src/components/checkout/payment/utils/use-billing-policy.ts b/packages/react/src/components/checkout/payment/utils/use-billing-policy.ts
new file mode 100644
index 00000000..380ae874
--- /dev/null
+++ b/packages/react/src/components/checkout/payment/utils/use-billing-policy.ts
@@ -0,0 +1,74 @@
+import { useMemo } from 'react';
+import { useFormContext } from 'react-hook-form';
+import type { CheckoutFormData } from '@/components/checkout/checkout';
+import { useCheckoutContext } from '@/components/checkout/checkout';
+import { isFreeOrderTotal } from '@/components/checkout/order/is-free-order';
+import { useDraftOrderTotals } from '@/components/checkout/order/use-draft-order';
+import {
+ type BillingPolicy,
+ canOfferShippingAddressAsBilling,
+ getBillingPolicy,
+} from '@/components/checkout/payment/utils/billing-collection';
+import type { CheckoutSession, Totals } from '@/types';
+
+export function resolveBillingPolicyForCheckoutState(input: {
+ values: Pick<
+ CheckoutFormData,
+ 'paymentMethod' | 'deliveryMethod' | 'paymentUseShippingAddress'
+ >;
+ session?: CheckoutSession | null;
+ totals?: Totals | null;
+}): BillingPolicy {
+ return getBillingPolicy({
+ isFreeOrder: isFreeOrderTotal(input.totals),
+ deliveryMethod: input.values.deliveryMethod,
+ paymentMethod: input.values.paymentMethod,
+ paymentUseShippingAddress: input.values.paymentUseShippingAddress !== false,
+ enableShipping: input.session?.enableShipping === true,
+ enableShippingAddressCollection:
+ input.session?.enableShippingAddressCollection === true,
+ enableBillingAddressCollection:
+ input.session?.enableBillingAddressCollection !== false,
+ enableTaxCollection: input.session?.enableTaxCollection === true,
+ });
+}
+
+export function useCanOfferShippingAddressAsBilling() {
+ const form = useFormContext();
+ const { session } = useCheckoutContext();
+ const deliveryMethod = form.watch('deliveryMethod');
+
+ return useMemo(
+ () =>
+ canOfferShippingAddressAsBilling({
+ deliveryMethod,
+ enableShipping: session?.enableShipping === true,
+ enableShippingAddressCollection:
+ session?.enableShippingAddressCollection === true,
+ }),
+ [deliveryMethod, session]
+ );
+}
+
+export function useBillingPolicy(): BillingPolicy {
+ const form = useFormContext();
+ const { session } = useCheckoutContext();
+ const { data: totals } = useDraftOrderTotals();
+ const paymentMethod = form.watch('paymentMethod');
+ const deliveryMethod = form.watch('deliveryMethod');
+ const paymentUseShippingAddress = form.watch('paymentUseShippingAddress');
+
+ return useMemo(
+ () =>
+ resolveBillingPolicyForCheckoutState({
+ values: {
+ paymentMethod,
+ deliveryMethod,
+ paymentUseShippingAddress,
+ },
+ session,
+ totals,
+ }),
+ [deliveryMethod, paymentMethod, paymentUseShippingAddress, session, totals]
+ );
+}
diff --git a/packages/react/src/components/checkout/payment/utils/use-build-payment-request.test.tsx b/packages/react/src/components/checkout/payment/utils/use-build-payment-request.test.tsx
index 6e7fdcd2..508e0d2c 100644
--- a/packages/react/src/components/checkout/payment/utils/use-build-payment-request.test.tsx
+++ b/packages/react/src/components/checkout/payment/utils/use-build-payment-request.test.tsx
@@ -124,7 +124,7 @@ async function renderUseBuildPaymentRequest({
}
describe('useBuildPaymentRequest', () => {
- it('builds Apple Pay, Google Pay, and PayPal request shapes from draft-order totals', async () => {
+ it('builds payment request shapes from draft-order totals and billing details', async () => {
const lineItem = buildLineItem({
name: 'Coffee Mug',
quantity: 2,
@@ -262,6 +262,21 @@ describe('useBuildPaymentRequest', () => {
])
);
+ expect(requests.stripePaymentMethodParams).toEqual({
+ billing_details: {
+ name: 'Bill Buyer',
+ email: 'bill@example.com',
+ phone: '+12015550124',
+ address: {
+ line1: '1 Billing Way',
+ line2: 'Suite 3',
+ city: 'Tempe',
+ state: 'AZ',
+ postal_code: '85284',
+ country: 'US',
+ },
+ },
+ });
expect(requests.payPalRequest.purchase_units[0]).toMatchObject({
payee: { merchant_id: 'MERCHANTID123' },
amount: {
@@ -330,6 +345,56 @@ describe('useBuildPaymentRequest', () => {
expect(requests.squarePaymentRequest.amount).toBe('0.00');
});
+ it('builds submission requests from an explicitly supplied latest order', async () => {
+ const { requests } = await renderUseBuildPaymentRequest();
+ const latestOrder = buildDraftOrder({
+ billing: {
+ firstName: 'Latest',
+ lastName: 'Buyer',
+ email: 'latest@example.com',
+ address: buildBillingAddress({ postalCode: '78701' }),
+ },
+ totals: {
+ total: money(4321),
+ subTotal: money(4321),
+ discountTotal: money(0),
+ shippingTotal: money(0),
+ taxTotal: money(0),
+ feeTotal: money(0),
+ },
+ });
+
+ const latestRequests = requests.buildPaymentRequestsFromOrder(latestOrder);
+
+ expect(
+ latestRequests.stripePaymentMethodParams.billing_details
+ ).toMatchObject({
+ name: 'Latest Buyer',
+ email: 'latest@example.com',
+ address: { postal_code: '78701' },
+ });
+ expect(latestRequests.poyntCardRequest).toMatchObject({
+ firstName: 'Latest',
+ lastName: 'Buyer',
+ emailAddress: 'latest@example.com',
+ zipCode: '78701',
+ });
+ expect(latestRequests.squarePaymentRequest).toMatchObject({
+ amount: '43.21',
+ billingContact: {
+ givenName: 'Latest',
+ familyName: 'Buyer',
+ email: 'latest@example.com',
+ postalCode: '78701',
+ },
+ });
+ expect(latestRequests.payPalRequest.purchase_units[0]).toMatchObject({
+ amount: { value: '43.21' },
+ billing: { name: { full_name: 'Latest Buyer' } },
+ });
+ expect(latestRequests.poyntStandardRequest.total.amount).toBe('43.21');
+ });
+
it('preserves three-decimal KWD precision for raw payment request amounts', async () => {
const { requests } = await renderUseBuildPaymentRequest({
sessionOverrides: {
diff --git a/packages/react/src/components/checkout/payment/utils/use-build-payment-request.ts b/packages/react/src/components/checkout/payment/utils/use-build-payment-request.ts
index ecae4af0..1096b5d5 100644
--- a/packages/react/src/components/checkout/payment/utils/use-build-payment-request.ts
+++ b/packages/react/src/components/checkout/payment/utils/use-build-payment-request.ts
@@ -1,16 +1,11 @@
-import type {
- CreateTokenCardData,
- PaymentMethodCreateParams,
-} from '@stripe/stripe-js';
-import { useMemo } from 'react';
+import type { PaymentMethodCreateParams } from '@stripe/stripe-js';
+import { useCallback, useMemo } from 'react';
import { useCheckoutContext } from '@/components/checkout/checkout';
-import {
- useDraftOrder,
- useDraftOrderTotals,
-} from '@/components/checkout/order/use-draft-order';
+import { useDraftOrder } from '@/components/checkout/order/use-draft-order';
import { useDraftOrderProductsMap } from '@/components/checkout/order/use-draft-order-products';
import { mapSkusToItemsDisplay } from '@/components/checkout/utils/checkout-transformers';
import { useFormatCurrency } from '@/components/checkout/utils/format-currency';
+import type { CheckoutSession, DraftOrder, SKUProduct } from '@/types';
// Apple Pay request interface
export interface ApplePayRequest {
@@ -170,35 +165,64 @@ export interface PoyntExpressRequest {
export interface PoyntStandardRequest extends PoyntExpressRequest {}
-export function useBuildPaymentRequest(): {
+export function buildStripePaymentMethodParams(
+ order?: DraftOrder | null
+): PaymentMethodCreateParams {
+ return {
+ billing_details: {
+ name:
+ `${order?.billing?.firstName || ''} ${order?.billing?.lastName || ''}`.trim() ||
+ undefined,
+ email: order?.billing?.email || undefined,
+ phone: order?.billing?.phone || undefined,
+ address: {
+ line1: order?.billing?.address?.addressLine1 || undefined,
+ line2: order?.billing?.address?.addressLine2 || undefined,
+ city: order?.billing?.address?.adminArea2 || undefined,
+ state: order?.billing?.address?.adminArea1 || undefined,
+ postal_code: order?.billing?.address?.postalCode || undefined,
+ country: order?.billing?.address?.countryCode || undefined,
+ },
+ },
+ };
+}
+
+export type PaymentRequests = {
applePayRequest: ApplePayRequest;
googlePayRequest: GooglePayRequest;
payPalRequest: PayPalRequest;
- stripePaymentCardRequest: CreateTokenCardData;
- stripePaymentExpressRequest: PaymentMethodCreateParams;
+ stripePaymentMethodParams: PaymentMethodCreateParams;
poyntCardRequest: PoyntCardRequest;
poyntExpressRequest: PoyntExpressRequest;
poyntStandardRequest: PoyntStandardRequest;
squarePaymentRequest: SquarePaymentRequest;
-} {
- const formatCurrency = useFormatCurrency();
- const { paypalConfig, session } = useCheckoutContext();
+};
- const draftOrderTotalsQuery = useDraftOrderTotals();
- const draftOrderQuery = useDraftOrder();
- const skusMap = useDraftOrderProductsMap();
+export type PaymentRequestBuilder = (
+ order?: DraftOrder | null
+) => PaymentRequests;
- const { data: totals } = draftOrderTotalsQuery;
- const { data: order } = draftOrderQuery;
+type BuildPaymentRequestsInput = {
+ order?: DraftOrder | null;
+ skusMap: Record;
+ formatCurrency: ReturnType;
+ session?: CheckoutSession | null;
+ paypalMerchantId?: string;
+ hostname: string;
+};
- // Extract totals information based on the data format
+export function buildPaymentRequests({
+ order,
+ skusMap,
+ formatCurrency,
+ session,
+ paypalMerchantId,
+ hostname,
+}: BuildPaymentRequestsInput): PaymentRequests {
+ const totals = order?.totals;
const currencyCode = totals?.total?.currencyCode || 'USD';
const lineItems = order?.lineItems || [];
-
- const items = useMemo(
- () => mapSkusToItemsDisplay(lineItems, skusMap),
- [lineItems, skusMap]
- );
+ const items = mapSkusToItemsDisplay(lineItems, skusMap);
// Extract amounts in minor units for use across payment requests
const subtotalMinorUnits = totals?.subTotal?.value || 0;
@@ -211,47 +235,35 @@ export function useBuildPaymentRequest(): {
const discountMinorUnits = totals?.discountTotal?.value || 0;
const totalMinorUnits = totals?.total?.value || 0;
- const countryCode = useMemo(
- () => session?.shipping?.originAddress?.countryCode || 'US',
- [session?.shipping?.originAddress?.countryCode]
- );
-
- // Memoize address information with null handling
- const shippingAddress = useMemo(
- () => ({
- name: {
- full_name:
- `${order?.shipping?.firstName || ''} ${order?.shipping?.lastName || ''}`.trim(),
- },
- address: {
- address_line_1: order?.shipping?.address?.addressLine1 || undefined,
- address_line_2: order?.shipping?.address?.addressLine2 || undefined,
- admin_area_2: order?.shipping?.address?.adminArea2 || undefined,
- admin_area_1: order?.shipping?.address?.adminArea1 || undefined,
- postal_code: order?.shipping?.address?.postalCode || undefined,
- country_code: order?.shipping?.address?.countryCode || countryCode,
- },
- }),
- [order?.shipping, countryCode]
- );
-
- const billingAddress = useMemo(
- () => ({
- name: {
- full_name:
- `${order?.billing?.firstName || ''} ${order?.billing?.lastName || ''}`.trim(),
- },
- address: {
- address_line_1: order?.billing?.address?.addressLine1 || undefined,
- address_line_2: order?.billing?.address?.addressLine2 || undefined,
- admin_area_2: order?.billing?.address?.adminArea2 || undefined,
- admin_area_1: order?.billing?.address?.adminArea1 || undefined,
- postal_code: order?.billing?.address?.postalCode || undefined,
- country_code: order?.billing?.address?.countryCode || countryCode,
- },
- }),
- [order?.billing, countryCode]
- );
+ const countryCode = session?.shipping?.originAddress?.countryCode || 'US';
+ const shippingAddress = {
+ name: {
+ full_name:
+ `${order?.shipping?.firstName || ''} ${order?.shipping?.lastName || ''}`.trim(),
+ },
+ address: {
+ address_line_1: order?.shipping?.address?.addressLine1 || undefined,
+ address_line_2: order?.shipping?.address?.addressLine2 || undefined,
+ admin_area_2: order?.shipping?.address?.adminArea2 || undefined,
+ admin_area_1: order?.shipping?.address?.adminArea1 || undefined,
+ postal_code: order?.shipping?.address?.postalCode || undefined,
+ country_code: order?.shipping?.address?.countryCode || countryCode,
+ },
+ };
+ const billingAddress = {
+ name: {
+ full_name:
+ `${order?.billing?.firstName || ''} ${order?.billing?.lastName || ''}`.trim(),
+ },
+ address: {
+ address_line_1: order?.billing?.address?.addressLine1 || undefined,
+ address_line_2: order?.billing?.address?.addressLine2 || undefined,
+ admin_area_2: order?.billing?.address?.adminArea2 || undefined,
+ admin_area_1: order?.billing?.address?.adminArea1 || undefined,
+ postal_code: order?.billing?.address?.postalCode || undefined,
+ country_code: order?.billing?.address?.countryCode || countryCode,
+ },
+ };
// Create Apple Pay request
const applePayRequest: ApplePayRequest = {
@@ -351,7 +363,7 @@ export function useBuildPaymentRequest(): {
merchantInfo: {
merchantId: session?.storeId || '',
merchantName: session?.storeName || '',
- merchantOrigin: document.location.hostname,
+ merchantOrigin: hostname,
},
transactionInfo: {
totalPriceStatus: 'FINAL',
@@ -432,7 +444,7 @@ export function useBuildPaymentRequest(): {
shippingMinorUnits -
discountMinorUnits;
- const payPalMerchantId = paypalConfig?.merchantId?.trim();
+ const payPalMerchantId = paypalMerchantId?.trim();
const payPalRequest: PayPalRequest = {
purchase_units: [
{
@@ -505,34 +517,7 @@ export function useBuildPaymentRequest(): {
],
};
- const stripePaymentCardRequest: CreateTokenCardData = {
- name:
- `${order?.billing?.firstName || ''} ${order?.billing?.lastName || ''}`.trim() ||
- undefined,
- address_line1: order?.billing?.address?.addressLine1 || undefined,
- address_line2: order?.billing?.address?.addressLine2 || undefined,
- address_city: order?.billing?.address?.adminArea2 || undefined,
- address_state: order?.billing?.address?.adminArea1 || undefined,
- address_zip: order?.billing?.address?.postalCode || undefined,
- address_country: order?.billing?.address?.countryCode || undefined,
- };
-
- const stripePaymentExpressRequest: PaymentMethodCreateParams = {
- billing_details: {
- name:
- `${order?.billing?.firstName || ''} ${order?.billing?.lastName || ''}`.trim() ||
- undefined,
- email: order?.billing?.email || undefined,
- address: {
- line1: order?.billing?.address?.addressLine1 || undefined,
- line2: order?.billing?.address?.addressLine2 || undefined,
- city: order?.billing?.address?.adminArea2 || undefined,
- state: order?.billing?.address?.adminArea1 || undefined,
- postal_code: order?.billing?.address?.postalCode || undefined,
- country: order?.billing?.address?.countryCode || undefined,
- },
- },
- };
+ const stripePaymentMethodParams = buildStripePaymentMethodParams(order);
const poyntCardRequest: PoyntCardRequest = {
emailAddress: order?.billing?.email || undefined,
@@ -654,11 +639,42 @@ export function useBuildPaymentRequest(): {
applePayRequest,
googlePayRequest,
payPalRequest,
- stripePaymentCardRequest,
- stripePaymentExpressRequest,
+ stripePaymentMethodParams,
poyntCardRequest,
poyntExpressRequest,
poyntStandardRequest,
squarePaymentRequest,
};
}
+
+export function useBuildPaymentRequest(): PaymentRequests & {
+ buildPaymentRequestsFromOrder: PaymentRequestBuilder;
+} {
+ const formatCurrency = useFormatCurrency();
+ const { paypalConfig, session } = useCheckoutContext();
+ const { data: order } = useDraftOrder();
+ const skusMap = useDraftOrderProductsMap();
+ const hostname =
+ typeof document === 'undefined' ? '' : document.location.hostname;
+ const paypalMerchantId = paypalConfig?.merchantId;
+
+ const buildPaymentRequestsFromOrder = useCallback(
+ orderOverride =>
+ buildPaymentRequests({
+ order: orderOverride === undefined ? order : orderOverride,
+ skusMap,
+ formatCurrency,
+ session,
+ paypalMerchantId,
+ hostname,
+ }),
+ [formatCurrency, hostname, order, paypalMerchantId, session, skusMap]
+ );
+
+ const requests = useMemo(
+ () => buildPaymentRequestsFromOrder(order),
+ [buildPaymentRequestsFromOrder, order]
+ );
+
+ return { ...requests, buildPaymentRequestsFromOrder };
+}
diff --git a/packages/react/src/components/checkout/payment/utils/use-stripe-checkout.integration.test.tsx b/packages/react/src/components/checkout/payment/utils/use-stripe-checkout.integration.test.tsx
new file mode 100644
index 00000000..488de166
--- /dev/null
+++ b/packages/react/src/components/checkout/payment/utils/use-stripe-checkout.integration.test.tsx
@@ -0,0 +1,177 @@
+import { act, renderHook } from '@testing-library/react';
+import React from 'react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { checkoutContext } from '@/components/checkout/checkout';
+import type { DraftOrder } from '@/types';
+import { useStripeCheckout } from './use-stripe-checkout';
+
+const mocks = vi.hoisted(() => ({
+ latestOrder: { id: 'latest-order' } as DraftOrder,
+ flush: vi.fn(),
+ buildFromOrder: vi.fn(),
+ createPaymentMethod: vi.fn(),
+ confirm: vi.fn(),
+ confirmExpress: vi.fn(),
+ cardElement: {},
+}));
+
+vi.mock('@stripe/react-stripe-js', () => ({
+ CardElement: function CardElement() {
+ return null;
+ },
+ useStripe: () => ({ createPaymentMethod: mocks.createPaymentMethod }),
+ useElements: () => ({
+ getElement: () => mocks.cardElement,
+ }),
+}));
+
+vi.mock(
+ '@/components/checkout/payment/utils/use-build-payment-request',
+ () => ({
+ useBuildPaymentRequest: () => ({
+ stripePaymentMethodParams: {
+ billing_details: { name: 'Stale Buyer' },
+ },
+ buildPaymentRequestsFromOrder: mocks.buildFromOrder,
+ }),
+ })
+);
+
+vi.mock('@/components/checkout/payment/utils/use-flush-checkout-sync', () => ({
+ useFlushCheckoutSync: () => mocks.flush,
+}));
+
+vi.mock(
+ '@/components/checkout/payment/utils/use-confirm-checkout',
+ async () => {
+ const actual = await vi.importActual<
+ typeof import('@/components/checkout/payment/utils/use-confirm-checkout')
+ >('@/components/checkout/payment/utils/use-confirm-checkout');
+ return {
+ ...actual,
+ useConfirmCheckout: () => ({ mutateAsync: mocks.confirm }),
+ };
+ }
+);
+
+vi.mock(
+ '@/components/checkout/payment/utils/use-confirm-express-checkout',
+ () => ({
+ useConfirmExpressCheckout: () => ({ mutateAsync: mocks.confirmExpress }),
+ })
+);
+
+function Wrapper({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+describe('useStripeCheckout payment request resolution', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.flush.mockResolvedValue({ latestOrder: mocks.latestOrder });
+ mocks.buildFromOrder.mockReturnValue({
+ stripePaymentMethodParams: {
+ billing_details: { name: 'Latest Buyer' },
+ },
+ });
+ mocks.createPaymentMethod.mockResolvedValue({
+ paymentMethod: { id: 'stripe-payment-method' },
+ });
+ mocks.confirm.mockResolvedValue(undefined);
+ mocks.confirmExpress.mockResolvedValue(undefined);
+ });
+
+ it('tokenizes card billing from the flushed latest order before confirmation', async () => {
+ const { result } = renderHook(() => useStripeCheckout({ mode: 'card' }), {
+ wrapper: Wrapper,
+ });
+
+ await act(async () => {
+ await result.current.handleSubmit();
+ });
+
+ expect(mocks.flush).toHaveBeenCalledWith({
+ includeCurrentFormDiff: true,
+ });
+ expect(mocks.buildFromOrder).toHaveBeenCalledWith(mocks.latestOrder);
+ expect(mocks.createPaymentMethod).toHaveBeenCalledWith({
+ billing_details: { name: 'Latest Buyer' },
+ card: mocks.cardElement,
+ type: 'card',
+ });
+ expect(mocks.confirm).toHaveBeenCalledWith({
+ paymentToken: 'stripe-payment-method',
+ paymentType: 'card',
+ paymentProvider: 'STRIPE',
+ });
+ expect(mocks.confirm.mock.invocationCallOrder[0]).toBeGreaterThan(
+ mocks.createPaymentMethod.mock.invocationCallOrder[0]
+ );
+ });
+
+ it('tokenizes express billing from the wallet event without flushing form data', async () => {
+ const { result } = renderHook(
+ () => useStripeCheckout({ mode: 'express' }),
+ { wrapper: Wrapper }
+ );
+ const event = {
+ expressPaymentType: 'apple_pay',
+ billingDetails: {
+ name: 'Wallet Buyer',
+ email: 'wallet@example.com',
+ phone: null,
+ address: {
+ line1: '789 Wallet Ave',
+ line2: null,
+ city: 'Phoenix',
+ state: 'AZ',
+ postal_code: '85001',
+ country: 'US',
+ },
+ },
+ } as never;
+
+ await act(async () => {
+ await result.current.handleSubmit({ event });
+ });
+
+ expect(mocks.flush).not.toHaveBeenCalled();
+ expect(mocks.createPaymentMethod).toHaveBeenCalledWith({
+ elements: expect.any(Object),
+ params: {
+ billing_details: {
+ name: 'Wallet Buyer',
+ email: 'wallet@example.com',
+ phone: undefined,
+ address: {
+ line1: '789 Wallet Ave',
+ line2: undefined,
+ city: 'Phoenix',
+ state: 'AZ',
+ postal_code: '85001',
+ country: 'US',
+ },
+ },
+ },
+ });
+ expect(mocks.confirmExpress).toHaveBeenCalledWith(
+ expect.objectContaining({
+ paymentToken: 'stripe-payment-method',
+ paymentType: 'apple_pay',
+ paymentProvider: 'STRIPE',
+ isExpress: true,
+ })
+ );
+ });
+});
diff --git a/packages/react/src/components/checkout/payment/utils/use-stripe-checkout.test.ts b/packages/react/src/components/checkout/payment/utils/use-stripe-checkout.test.ts
new file mode 100644
index 00000000..a64f14bd
--- /dev/null
+++ b/packages/react/src/components/checkout/payment/utils/use-stripe-checkout.test.ts
@@ -0,0 +1,71 @@
+import { expect, it } from 'vitest';
+import type { DraftOrder } from '@/types';
+import { buildStripePaymentMethodParams } from './use-build-payment-request';
+import { buildStripeExpressPaymentMethodParams } from './use-stripe-checkout';
+
+it('builds Stripe billing details from the supplied draft order', () => {
+ const order = {
+ billing: {
+ firstName: 'Latest',
+ lastName: 'Buyer',
+ email: 'latest@example.com',
+ phone: '+12015550123',
+ address: {
+ addressLine1: '123 Current St',
+ addressLine2: 'Suite 4',
+ adminArea2: 'Austin',
+ adminArea1: 'TX',
+ postalCode: '78701',
+ countryCode: 'US',
+ },
+ },
+ } as DraftOrder;
+
+ expect(buildStripePaymentMethodParams(order)).toEqual({
+ billing_details: {
+ name: 'Latest Buyer',
+ email: 'latest@example.com',
+ phone: '+12015550123',
+ address: {
+ line1: '123 Current St',
+ line2: 'Suite 4',
+ city: 'Austin',
+ state: 'TX',
+ postal_code: '78701',
+ country: 'US',
+ },
+ },
+ });
+});
+
+it('builds Stripe Express billing details from the wallet event', () => {
+ expect(
+ buildStripeExpressPaymentMethodParams({
+ name: 'Wallet Buyer',
+ email: 'wallet@example.com',
+ phone: '+12015550999',
+ address: {
+ line1: '789 Wallet Ave',
+ line2: null,
+ city: 'Phoenix',
+ state: 'AZ',
+ postal_code: '85001',
+ country: 'US',
+ },
+ })
+ ).toEqual({
+ billing_details: {
+ name: 'Wallet Buyer',
+ email: 'wallet@example.com',
+ phone: '+12015550999',
+ address: {
+ line1: '789 Wallet Ave',
+ line2: undefined,
+ city: 'Phoenix',
+ state: 'AZ',
+ postal_code: '85001',
+ country: 'US',
+ },
+ },
+ });
+});
diff --git a/packages/react/src/components/checkout/payment/utils/use-stripe-checkout.ts b/packages/react/src/components/checkout/payment/utils/use-stripe-checkout.ts
index d3bd358e..c9708a1e 100644
--- a/packages/react/src/components/checkout/payment/utils/use-stripe-checkout.ts
+++ b/packages/react/src/components/checkout/payment/utils/use-stripe-checkout.ts
@@ -1,5 +1,8 @@
import { CardElement, useElements, useStripe } from '@stripe/react-stripe-js';
-import type { StripeExpressCheckoutElementConfirmEvent } from '@stripe/stripe-js';
+import type {
+ PaymentMethodCreateParams,
+ StripeExpressCheckoutElementConfirmEvent,
+} from '@stripe/stripe-js';
import { useCallback, useState } from 'react';
import { useCheckoutContext } from '@/components/checkout/checkout';
import { useBuildPaymentRequest } from '@/components/checkout/payment/utils/use-build-payment-request';
@@ -8,10 +11,12 @@ import {
useConfirmCheckout,
} from '@/components/checkout/payment/utils/use-confirm-checkout';
import { useConfirmExpressCheckout } from '@/components/checkout/payment/utils/use-confirm-express-checkout';
+import { useFlushCheckoutSync } from '@/components/checkout/payment/utils/use-flush-checkout-sync';
import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors';
import type {
CalculatedAdjustments,
CalculatedTaxes,
+ DraftOrder,
ShippingMethod,
} from '@/types';
import { PaymentMethodType } from '@/types';
@@ -22,6 +27,29 @@ type UseStripeCheckoutOptions = {
};
// Express checkout data to pass to confirmCheckout
+export function buildStripeExpressPaymentMethodParams(
+ billingDetails:
+ | StripeExpressCheckoutElementConfirmEvent['billingDetails']
+ | null
+ | undefined
+): PaymentMethodCreateParams {
+ return {
+ billing_details: {
+ name: billingDetails?.name || undefined,
+ email: billingDetails?.email || undefined,
+ phone: billingDetails?.phone || undefined,
+ address: {
+ line1: billingDetails?.address?.line1 || undefined,
+ line2: billingDetails?.address?.line2 || undefined,
+ city: billingDetails?.address?.city || undefined,
+ state: billingDetails?.address?.state || undefined,
+ postal_code: billingDetails?.address?.postal_code || undefined,
+ country: billingDetails?.address?.country || undefined,
+ },
+ },
+ };
+}
+
export type StripeExpressCheckoutData = {
// Stripe confirm event data
event: StripeExpressCheckoutElementConfirmEvent;
@@ -42,11 +70,16 @@ export function useStripeCheckout({ mode }: UseStripeCheckoutOptions) {
const confirmCheckout = useConfirmCheckout();
const confirmExpressCheckout = useConfirmExpressCheckout();
const { setCheckoutErrors } = useCheckoutContext();
- const { stripePaymentExpressRequest } = useBuildPaymentRequest();
+ const { stripePaymentMethodParams, buildPaymentRequestsFromOrder } =
+ useBuildPaymentRequest();
+ const flushCheckoutSync = useFlushCheckoutSync();
const [isProcessingPayment, setIsProcessingPayment] = useState(false);
const handleSubmit = useCallback(
- async (expressData?: StripeExpressCheckoutData) => {
+ async (
+ expressData?: StripeExpressCheckoutData,
+ resolvedOrder?: DraftOrder | null
+ ) => {
setIsProcessingPayment(true);
try {
if (!stripe || !elements) {
@@ -60,7 +93,18 @@ export function useStripeCheckout({ mode }: UseStripeCheckoutOptions) {
return;
}
+ const latestOrder =
+ resolvedOrder ??
+ (
+ await flushCheckoutSync({
+ includeCurrentFormDiff: true,
+ })
+ ).latestOrder;
const { paymentMethod, error } = await stripe.createPaymentMethod({
+ ...(latestOrder
+ ? buildPaymentRequestsFromOrder(latestOrder)
+ .stripePaymentMethodParams
+ : stripePaymentMethodParams),
card: cardElement,
type: 'card',
});
@@ -91,7 +135,9 @@ export function useStripeCheckout({ mode }: UseStripeCheckoutOptions) {
if (mode === 'express') {
const { error, paymentMethod } = await stripe.createPaymentMethod({
elements,
- params: stripePaymentExpressRequest,
+ params: buildStripeExpressPaymentMethodParams(
+ expressData?.event.billingDetails
+ ),
});
if (error) {
@@ -230,8 +276,11 @@ export function useStripeCheckout({ mode }: UseStripeCheckoutOptions) {
stripe,
elements,
confirmCheckout.mutateAsync,
+ flushCheckoutSync,
+ buildPaymentRequestsFromOrder,
confirmExpressCheckout.mutateAsync,
setCheckoutErrors,
+ stripePaymentMethodParams,
]
);
diff --git a/packages/react/src/components/checkout/shipping/shipping-method.tsx b/packages/react/src/components/checkout/shipping/shipping-method.tsx
index d14bcab2..ef545db3 100644
--- a/packages/react/src/components/checkout/shipping/shipping-method.tsx
+++ b/packages/react/src/components/checkout/shipping/shipping-method.tsx
@@ -1,4 +1,4 @@
-import { useQueryClient } from '@tanstack/react-query';
+import { useIsMutating, useQueryClient } from '@tanstack/react-query';
import { useEffect, useRef } from 'react';
import { useFormContext } from 'react-hook-form';
import { useCheckoutContext } from '@/components/checkout/checkout';
@@ -7,61 +7,48 @@ 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 {
+ getShippingMethodsKey,
+ selectShippingMethod,
+} from '@/components/checkout/shipping/utils/requires-shipping-reconciliation';
import {
getShippingFulfillmentSyncKey,
shouldApplyShippingMethod,
} from '@/components/checkout/shipping/utils/should-apply-shipping-method';
+import { sortShippingMethods } from '@/components/checkout/shipping/utils/sort-shipping-methods';
import { useApplyShippingMethod } from '@/components/checkout/shipping/utils/use-apply-shipping-method';
import { useDraftOrderShippingMethods } from '@/components/checkout/shipping/utils/use-draft-order-shipping-methods';
import { useFormatCurrency } from '@/components/checkout/utils/format-currency';
-import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys';
+import {
+ checkoutMutationKeys,
+ checkoutQueryKeys,
+} from '@/components/checkout/utils/query-keys';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { useGoDaddyContext } from '@/godaddy-provider';
import { cn } from '@/lib/utils';
import { eventIds } from '@/tracking/events';
import { TrackingEventType, track } from '@/tracking/track';
-import type { ShippingMethod } from '@/types';
-
-// Helper function to build the shipping payload
-function buildShippingPayload(method: ShippingMethod) {
- return [
- {
- taxTotal: {
- value: 0,
- currencyCode: method?.cost?.currencyCode || 'USD',
- },
- subTotal: {
- value: method?.cost?.value || 0,
- currencyCode: method?.cost?.currencyCode || 'USD',
- },
- requestedService: method?.serviceCode,
- requestedProvider: method?.carrierCode,
- name: method?.displayName || '',
- },
- ];
-}
export function ShippingMethodForm() {
const formatCurrency = useFormatCurrency();
const form = useFormContext();
const { t } = useGoDaddyContext();
const { session, isConfirmingCheckout } = useCheckoutContext();
- const updateTaxes = useUpdateTaxes();
const queryClient = useQueryClient();
const isPaymentDisabled = useIsPaymentDisabled();
- const { data: shippingMethodsData, isLoading: isShippingMethodsLoading } =
- useDraftOrderShippingMethods();
+ const {
+ data: shippingMethodsData,
+ isError: isShippingMethodsError,
+ isLoading: isShippingMethodsLoading,
+ } = useDraftOrderShippingMethods();
const { data: shippingAddress, isLoading: isShippingAddressLoading } =
useDraftOrderShippingAddress();
- const { data: totals } = useDraftOrderTotals();
const { data: order, isLoading: isDraftOrderLoading } = useDraftOrder();
const { data: shippingLines } = useDraftOrderShipping();
@@ -74,15 +61,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 lastShippingMethodsKeyRef = useRef(null);
+ const wasApplyingDiscountRef = useRef(false);
// Track the last processed state to avoid duplicate API calls
const lastProcessedStateRef = useRef<{
@@ -102,6 +91,19 @@ export function ShippingMethodForm() {
});
useEffect(() => {
+ if (isApplyingDiscount) {
+ wasApplyingDiscountRef.current = true;
+ lastShippingMethodsKeyRef.current =
+ getShippingMethodsKey(shippingMethods);
+ lastProcessedStateRef.current = {
+ ...lastProcessedStateRef.current,
+ serviceCode: shippingLines?.requestedService ?? null,
+ cost: shippingLines?.amount?.value ?? null,
+ hadShippingMethods: shippingMethods.length > 0,
+ };
+ return;
+ }
+
if (
isShippingMethodsLoading ||
isDraftOrderLoading ||
@@ -110,6 +112,8 @@ export function ShippingMethodForm() {
)
return;
+ const discountJustSettled = wasApplyingDiscountRef.current;
+ wasApplyingDiscountRef.current = false;
const hasShippingMethods = (shippingMethods?.length ?? 0) > 0;
const currentServiceCode = shippingLines?.requestedService || null;
const lastState = lastProcessedStateRef.current;
@@ -126,6 +130,20 @@ export function ShippingMethodForm() {
// Case 1: No shipping methods available - clear shipping and set fulfillment to SHIP
if (!hasShippingMethods && hasShippingAddress) {
+ lastShippingMethodsKeyRef.current = getShippingMethodsKey([]);
+
+ if (discountJustSettled && !currentServiceCode) {
+ lastProcessedStateRef.current = {
+ serviceCode: null,
+ cost: null,
+ hadShippingMethods: false,
+ wasPickup: isPickup,
+ clearedShippingMethod: true,
+ blockedFulfillmentKey: null,
+ };
+ return;
+ }
+
// Apply empty shipping method if:
// - Pickup mode and has shipping code OR wasn't pickup before
// - Shipping mode and (had methods before OR haven't cleared yet)
@@ -134,8 +152,16 @@ export function ShippingMethodForm() {
: lastState.hadShippingMethods || !lastState.clearedShippingMethod;
if (shouldClearShipping) {
+ const previousShippingMethod =
+ form.getValues('shippingMethod') || currentServiceCode || '';
form.setValue('shippingMethod', '', { shouldDirty: false });
- applyShippingMethod.mutate([]);
+ applyShippingMethod.mutate([], {
+ onError: () => {
+ form.setValue('shippingMethod', previousShippingMethod, {
+ shouldDirty: false,
+ });
+ },
+ });
lastProcessedStateRef.current = {
serviceCode: null,
cost: null,
@@ -158,19 +184,17 @@ export function ShippingMethodForm() {
// Case 2: Shipping methods available - apply or re-apply as needed
if (hasShippingMethods) {
- const firstMethod = shippingMethods[0];
const currentFormMethod = form.getValues('shippingMethod');
const existingMethod = currentFormMethod || currentServiceCode;
+ const { selectedMethod: methodToApply, methodsKey } =
+ selectShippingMethod({
+ shippingMethods,
+ currentServiceCode: existingMethod,
+ previousMethodsKey: lastShippingMethodsKeyRef.current,
+ });
+ lastShippingMethodsKeyRef.current = methodsKey;
- // Try to find the existing method in available methods. Prefer the
- // current form selection so an in-flight explicit user click is not
- // overwritten by the stale draft-order shipping line while the mutation
- // and refetch settle.
- const matchedMethod = existingMethod
- ? shippingMethods.find(m => m.serviceCode === existingMethod)
- : null;
-
- const methodToApply = matchedMethod || firstMethod;
+ if (!methodToApply) return;
// Check if we've already processed this exact state. If cart contents
// changed after a shipping method was selected, shippingLines can still
// match the selected rate while new line items are NONE. In that case we
@@ -201,7 +225,14 @@ export function ShippingMethodForm() {
};
}
+ const previousShippingMethod =
+ currentFormMethod || currentServiceCode || '';
applyShippingMethod.mutate(buildShippingPayload(methodToApply), {
+ onError: () => {
+ form.setValue('shippingMethod', previousShippingMethod, {
+ shouldDirty: false,
+ });
+ },
onSuccess: () => {
if (!isFulfillmentSync || !session?.id) return;
@@ -210,8 +241,6 @@ export function ShippingMethodForm() {
});
},
});
- } else if (session?.enableTaxCollection) {
- updateTaxes.mutate(undefined);
}
lastProcessedStateRef.current = {
@@ -228,14 +257,13 @@ export function ShippingMethodForm() {
}
}, [
isConfirmingCheckout,
+ isApplyingDiscount,
shippingMethods,
shippingLines,
hasShippingAddress,
isShippingMethodsLoading,
form,
applyShippingMethod,
- updateTaxes.mutate,
- session?.enableTaxCollection,
queryClient,
session?.id,
isPickup,
diff --git a/packages/react/src/components/checkout/shipping/utils/build-shipping-payload.ts b/packages/react/src/components/checkout/shipping/utils/build-shipping-payload.ts
new file mode 100644
index 00000000..f393d5b5
--- /dev/null
+++ b/packages/react/src/components/checkout/shipping/utils/build-shipping-payload.ts
@@ -0,0 +1,18 @@
+import type { ShippingMethod } from '@/types';
+
+export function buildShippingPayload(method: ShippingMethod) {
+ const currencyCode = method.cost?.currencyCode || 'USD';
+
+ return [
+ {
+ taxTotal: { value: 0, currencyCode },
+ subTotal: {
+ value: method.cost?.value || 0,
+ currencyCode,
+ },
+ requestedService: method.serviceCode,
+ requestedProvider: method.carrierCode,
+ name: method.displayName || '',
+ },
+ ];
+}
diff --git a/packages/react/src/components/checkout/shipping/utils/filter-shipping-methods.ts b/packages/react/src/components/checkout/shipping/utils/filter-shipping-methods.ts
deleted file mode 100644
index 677fed18..00000000
--- a/packages/react/src/components/checkout/shipping/utils/filter-shipping-methods.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import type { CheckoutSession, ShippingMethod } from '@/types';
-
-interface FilterShippingMethodsParams {
- shippingMethods: ShippingMethod[];
- orderSubTotal: number;
- experimentalRules?: CheckoutSession['experimental_rules'];
-}
-
-export function filterAndSortShippingMethods({
- shippingMethods,
- orderSubTotal,
- experimentalRules,
-}: FilterShippingMethodsParams): ShippingMethod[] {
- const enableFreeShippingRule = experimentalRules?.freeShipping?.enabled;
- const freeShippingMinimumOrderTotal =
- experimentalRules?.freeShipping?.minimumOrderTotal || 0;
-
- return shippingMethods
- .filter(
- method =>
- !(
- enableFreeShippingRule &&
- method?.cost?.value === 0 &&
- orderSubTotal < freeShippingMinimumOrderTotal
- )
- )
- .sort((a, b) => {
- const costA = a?.cost?.value || 0;
- const costB = b?.cost?.value || 0;
-
- // First sort by cost
- if (costA !== costB) {
- return costA - costB;
- }
-
- // If costs are equal, sort by name
- const nameA = a?.displayName || '';
- const nameB = b?.displayName || '';
- return nameA.localeCompare(nameB);
- });
-}
diff --git a/packages/react/src/components/checkout/shipping/utils/requires-shipping-reconciliation.test.ts b/packages/react/src/components/checkout/shipping/utils/requires-shipping-reconciliation.test.ts
new file mode 100644
index 00000000..ab8764f6
--- /dev/null
+++ b/packages/react/src/components/checkout/shipping/utils/requires-shipping-reconciliation.test.ts
@@ -0,0 +1,108 @@
+import { describe, expect, it } from 'vitest';
+import type { ShippingLines, ShippingMethod } from '@/types';
+import { requiresShippingReconciliation } from './requires-shipping-reconciliation';
+
+function shippingMethod(serviceCode: string, cost: number): ShippingMethod {
+ return {
+ serviceCode,
+ carrierCode: 'carrier',
+ displayName: serviceCode,
+ description: null,
+ features: [],
+ minDeliveryDate: null,
+ maxDeliveryDate: null,
+ cost: { value: cost, currencyCode: 'USD' },
+ };
+}
+
+function shippingLine(serviceCode: string, cost: number): ShippingLines {
+ return {
+ id: `shipping-${serviceCode}`,
+ requestedService: serviceCode,
+ requestedProvider: 'carrier',
+ name: serviceCode,
+ amount: { value: cost, currencyCode: 'USD' },
+ discounts: [],
+ };
+}
+
+describe('requiresShippingReconciliation', () => {
+ it('returns false when the selected service and cost are unchanged', () => {
+ expect(
+ requiresShippingReconciliation({
+ shippingMethods: [shippingMethod('standard', 1000)],
+ currentShippingLine: shippingLine('standard', 1000),
+ selectedServiceCode: 'standard',
+ })
+ ).toBe(false);
+ });
+
+ it('returns true when a cheaper default method becomes available', () => {
+ expect(
+ requiresShippingReconciliation({
+ shippingMethods: [
+ shippingMethod('standard', 1000),
+ shippingMethod('free', 0),
+ ],
+ currentShippingLine: shippingLine('standard', 1000),
+ selectedServiceCode: 'standard',
+ })
+ ).toBe(true);
+ });
+
+ it('preserves the selected method when available methods are unchanged', () => {
+ const shippingMethods = [
+ shippingMethod('standard', 1000),
+ shippingMethod('free', 0),
+ ];
+
+ expect(
+ requiresShippingReconciliation({
+ shippingMethods,
+ previousShippingMethods: shippingMethods,
+ currentShippingLine: shippingLine('standard', 1000),
+ selectedServiceCode: 'standard',
+ })
+ ).toBe(false);
+ });
+
+ it('returns true when the selected service becomes free', () => {
+ expect(
+ requiresShippingReconciliation({
+ shippingMethods: [shippingMethod('standard', 0)],
+ currentShippingLine: shippingLine('standard', 1000),
+ selectedServiceCode: 'standard',
+ })
+ ).toBe(true);
+ });
+
+ it('returns true when the selected service is no longer available', () => {
+ expect(
+ requiresShippingReconciliation({
+ shippingMethods: [shippingMethod('express', 1500)],
+ currentShippingLine: shippingLine('standard', 1000),
+ selectedServiceCode: 'standard',
+ })
+ ).toBe(true);
+ });
+
+ it('returns true when no methods remain for an applied shipping line', () => {
+ expect(
+ requiresShippingReconciliation({
+ shippingMethods: [],
+ currentShippingLine: shippingLine('standard', 1000),
+ selectedServiceCode: 'standard',
+ })
+ ).toBe(true);
+ });
+
+ it('returns false when there are no methods and no applied shipping line', () => {
+ expect(
+ requiresShippingReconciliation({
+ shippingMethods: [],
+ currentShippingLine: null,
+ selectedServiceCode: null,
+ })
+ ).toBe(false);
+ });
+});
diff --git a/packages/react/src/components/checkout/shipping/utils/requires-shipping-reconciliation.ts b/packages/react/src/components/checkout/shipping/utils/requires-shipping-reconciliation.ts
new file mode 100644
index 00000000..a68ae6a7
--- /dev/null
+++ b/packages/react/src/components/checkout/shipping/utils/requires-shipping-reconciliation.ts
@@ -0,0 +1,63 @@
+import type { ShippingLines, ShippingMethod } from '@/types';
+import { sortShippingMethods } from './sort-shipping-methods';
+
+interface SelectShippingMethodParams {
+ shippingMethods: ShippingMethod[];
+ currentServiceCode?: string | null;
+ previousMethodsKey?: string | null;
+}
+
+interface RequiresShippingReconciliationParams {
+ shippingMethods: ShippingMethod[];
+ previousShippingMethods?: ShippingMethod[];
+ currentShippingLine?: ShippingLines | null;
+ selectedServiceCode?: string | null;
+}
+
+export function getShippingMethodsKey(shippingMethods: ShippingMethod[]) {
+ return JSON.stringify(
+ sortShippingMethods(shippingMethods).map(method => ({
+ serviceCode: method.serviceCode,
+ carrierCode: method.carrierCode,
+ cost: method.cost,
+ }))
+ );
+}
+
+export function selectShippingMethod({
+ shippingMethods,
+ currentServiceCode,
+ previousMethodsKey,
+}: SelectShippingMethodParams) {
+ const availableMethods = sortShippingMethods(shippingMethods);
+ const methodsKey = getShippingMethodsKey(availableMethods);
+ const methodsChanged = methodsKey !== previousMethodsKey;
+ const selectedMethod = methodsChanged
+ ? availableMethods[0]
+ : availableMethods.find(
+ method => method.serviceCode === currentServiceCode
+ ) || availableMethods[0];
+
+ return { selectedMethod, methodsKey };
+}
+
+export function requiresShippingReconciliation({
+ shippingMethods,
+ previousShippingMethods = [],
+ currentShippingLine,
+ selectedServiceCode,
+}: RequiresShippingReconciliationParams) {
+ const currentServiceCode =
+ selectedServiceCode || currentShippingLine?.requestedService;
+ const { selectedMethod } = selectShippingMethod({
+ shippingMethods,
+ currentServiceCode,
+ previousMethodsKey: getShippingMethodsKey(previousShippingMethods),
+ });
+
+ return selectedMethod
+ ? selectedMethod.serviceCode !== currentShippingLine?.requestedService ||
+ (selectedMethod.cost?.value ?? null) !==
+ (currentShippingLine?.amount?.value ?? null)
+ : Boolean(currentShippingLine?.requestedService);
+}
diff --git a/packages/react/src/components/checkout/shipping/utils/sort-shipping-methods.ts b/packages/react/src/components/checkout/shipping/utils/sort-shipping-methods.ts
new file mode 100644
index 00000000..7aafc090
--- /dev/null
+++ b/packages/react/src/components/checkout/shipping/utils/sort-shipping-methods.ts
@@ -0,0 +1,18 @@
+import type { ShippingMethod } from '@/types';
+
+export function sortShippingMethods(
+ shippingMethods: ShippingMethod[]
+): ShippingMethod[] {
+ return [...shippingMethods].sort((a, b) => {
+ const costA = a?.cost?.value || 0;
+ const costB = b?.cost?.value || 0;
+
+ if (costA !== costB) {
+ return costA - costB;
+ }
+
+ const nameA = a?.displayName || '';
+ const nameB = b?.displayName || '';
+ return nameA.localeCompare(nameB);
+ });
+}
diff --git a/packages/react/src/components/checkout/shipping/utils/use-apply-shipping-method-core.ts b/packages/react/src/components/checkout/shipping/utils/use-apply-shipping-method-core.ts
new file mode 100644
index 00000000..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..896f6ae2 100644
--- a/packages/react/src/components/checkout/shipping/utils/use-apply-shipping-method.ts
+++ b/packages/react/src/components/checkout/shipping/utils/use-apply-shipping-method.ts
@@ -1,88 +1,26 @@
-import { useMutation, useQueryClient } from '@tanstack/react-query';
-import type { ResultOf } from 'gql.tada';
+import { useQueryClient } from '@tanstack/react-query';
import { useCheckoutContext } from '@/components/checkout/checkout';
-import { useDiscountApply } from '@/components/checkout/discount';
+import { useApplyDiscountCore } from '@/components/checkout/discount/utils/use-apply-discount-core';
import { useDraftOrder } from '@/components/checkout/order/use-draft-order';
import { useUpdateTaxes } from '@/components/checkout/order/use-update-taxes';
-import {
- checkoutMutationKeys,
- checkoutQueryKeys,
-} from '@/components/checkout/utils/query-keys';
-import { useGoDaddyContext } from '@/godaddy-provider';
-import type { DraftOrderQuery } from '@/lib/godaddy/checkout-queries.ts';
-import { applyShippingMethod } from '@/lib/godaddy/godaddy';
+import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys';
import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors';
-import type { ApplyCheckoutSessionShippingMethodInput } from '@/types';
+import { useApplyShippingMethodCore } from './use-apply-shipping-method-core';
export function useApplyShippingMethod() {
- const { session, jwt, setCheckoutErrors } = useCheckoutContext();
- const { apiHost } = useGoDaddyContext();
+ const { session, setCheckoutErrors } = useCheckoutContext();
const { data: order } = useDraftOrder();
const updateTaxes = useUpdateTaxes();
- const applyDiscount = useDiscountApply();
+ const applyDiscount = useApplyDiscountCore();
const queryClient = useQueryClient();
- return useMutation({
- mutationKey: checkoutMutationKeys.applyShippingMethod(session?.id),
- mutationFn: async (
- shippingMethods: ApplyCheckoutSessionShippingMethodInput['input']
- ) => {
- if (!session) return;
- const data = jwt
- ? await applyShippingMethod(
- shippingMethods,
- { accessToken: jwt },
- apiHost
- )
- : await applyShippingMethod(shippingMethods, session, apiHost);
- return data;
- },
- onSuccess: async data => {
+ return useApplyShippingMethodCore({
+ onSuccess: async () => {
setCheckoutErrors(undefined);
if (!session) return;
- // Extract shippingTotal from mutation response
- const shippingTotal =
- data?.applyCheckoutSessionShippingMethod?.draftOrder?.totals
- ?.shippingTotal;
-
- // Update the cached draft-order query (includes totals)
- if (shippingTotal) {
- queryClient.setQueryData(
- checkoutQueryKeys.draftOrder(session.id),
- (old: ResultOf | undefined) => {
- if (!old) return old;
-
- return {
- ...old,
- checkoutSession: {
- ...old.checkoutSession,
- draftOrder: {
- ...old?.checkoutSession?.draftOrder,
- shippingLines: [
- {
- ...old?.checkoutSession?.draftOrder?.shippingLines?.[0],
- amount: {
- ...shippingTotal,
- },
- },
- ],
- totals: {
- ...old?.checkoutSession?.draftOrder?.totals,
- shippingTotal: {
- ...shippingTotal,
- },
- },
- },
- },
- };
- }
- );
- }
-
const allCodes = new Set();
- // Add order-level discount codes
if (order?.discounts) {
for (const discount of order.discounts) {
if (discount.code) {
@@ -91,9 +29,6 @@ export function useApplyShippingMethod() {
}
}
- // Line item-level discount codes do not need to be re-applied as they would not be affected by shipping method changes
-
- // Add shipping line-level discount codes
if (order?.shippingLines) {
for (const shippingLine of order.shippingLines) {
if (shippingLine.discounts) {
@@ -108,12 +43,11 @@ export function useApplyShippingMethod() {
const discountCodes = Array.from(allCodes);
- if (session?.enablePromotionCodes && discountCodes?.length) {
- /* should re-apply discounts if they were previously applied */
- await applyDiscount.mutateAsync({
- discountCodes,
- });
- } else if (session?.enableTaxCollection) {
+ if (session.enablePromotionCodes && discountCodes.length) {
+ await applyDiscount.mutateAsync({ discountCodes });
+ }
+
+ if (session.enableTaxCollection) {
await updateTaxes.mutateAsync(undefined);
} else {
await queryClient.invalidateQueries({
diff --git a/packages/react/src/components/checkout/utils/checkout-transformers.test.ts b/packages/react/src/components/checkout/utils/checkout-transformers.test.ts
index 2e212f3c..920ccf22 100644
--- a/packages/react/src/components/checkout/utils/checkout-transformers.test.ts
+++ b/packages/react/src/components/checkout/utils/checkout-transformers.test.ts
@@ -326,6 +326,18 @@ describe('mapOrderToFormValues', () => {
expect(values.notes).toBe('Leave by the gate');
});
+ it('hydrates contact email from billing when pickup has no shipping contact', () => {
+ const values = mapOrderToFormValues({
+ order: buildDraftOrder({
+ shipping: null,
+ billing: { email: 'pickup@example.com' },
+ lineItems: [{ fulfillmentMode: DeliveryMethods.PICKUP }],
+ }),
+ });
+
+ expect(values.contactEmail).toBe('pickup@example.com');
+ });
+
it('returns schema defaults for an empty draft order without throwing', () => {
const values = mapOrderToFormValues({ order: null });
diff --git a/packages/react/src/components/checkout/utils/checkout-transformers.ts b/packages/react/src/components/checkout/utils/checkout-transformers.ts
index 10bb204a..7b51b3ca 100644
--- a/packages/react/src/components/checkout/utils/checkout-transformers.ts
+++ b/packages/react/src/components/checkout/utils/checkout-transformers.ts
@@ -192,7 +192,11 @@ export function mapOrderToFormValues({
orderBillingAddress?.countryCode || defaultCountryCode || 'US',
// Contact information
- contactEmail: order?.shipping?.email || defaultValues?.contactEmail || '',
+ contactEmail:
+ order?.shipping?.email ||
+ order?.billing?.email ||
+ defaultValues?.contactEmail ||
+ '',
// Delivery Methods
deliveryMethod: deliveryMethod,
diff --git a/packages/react/src/lib/godaddy/checkout-mutations.ts b/packages/react/src/lib/godaddy/checkout-mutations.ts
index 39f4780a..0c6f4ff1 100644
--- a/packages/react/src/lib/godaddy/checkout-mutations.ts
+++ b/packages/react/src/lib/godaddy/checkout-mutations.ts
@@ -58,10 +58,6 @@ export const CreateCheckoutSessionMutation = graphql(`
}
}
experimental_rules {
- freeShipping {
- enabled
- minimumOrderTotal
- }
gopay_override {
enabled
goPayAppId
diff --git a/packages/react/src/lib/godaddy/checkout-queries.ts b/packages/react/src/lib/godaddy/checkout-queries.ts
index 4e3e6185..815e3e53 100644
--- a/packages/react/src/lib/godaddy/checkout-queries.ts
+++ b/packages/react/src/lib/godaddy/checkout-queries.ts
@@ -58,10 +58,6 @@ export const GetCheckoutSessionQuery = graphql(`
}
}
experimental_rules {
- freeShipping {
- enabled
- minimumOrderTotal
- }
gopay_override {
enabled
goPayAppId