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..970fa5cc 100644
--- a/packages/react/src/components/checkout/__tests__/checkout-digital-fulfillment.test.tsx
+++ b/packages/react/src/components/checkout/__tests__/checkout-digital-fulfillment.test.tsx
@@ -221,6 +221,24 @@ describe('Digital fulfillment checkout', () => {
expectBillingNamesOnlyWithPhone();
});
+ it('respects disabled billing address collection for taxable digital-only orders', async () => {
+ renderCheckout({
+ draftOrderOverrides: {
+ shipping: { address: null },
+ lineItems: [buildDigitalLineItem()],
+ },
+ sessionOverrides: {
+ enableBillingAddressCollection: false,
+ enableTaxCollection: true,
+ enableShipping: true,
+ enableLocalPickup: true,
+ },
+ });
+ await waitForCheckoutReady();
+
+ expectBillingNamesOnlyWithPhone();
+ });
+
it('shows billing names and phone for free digital-only orders when tax is disabled', async () => {
renderCheckout({
draftOrderOverrides: {
diff --git a/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx
index c20c8e12..b49196d8 100644
--- a/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx
+++ b/packages/react/src/components/checkout/__tests__/checkout-discount.test.tsx
@@ -3,6 +3,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors';
import {
+ buildBillingAddress,
clearOperations,
flushPromises,
getOperations,
@@ -147,6 +148,68 @@ describe('Checkout discounts', () => {
expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(0);
});
+ it('refetches the draft order when taxes cannot be recalculated without a billing address', async () => {
+ const { user } = renderCheckout({
+ draftOrderOverrides: {
+ billing: { address: null },
+ lineItems: [{ fulfillmentMode: 'PURCHASE' }],
+ },
+ sessionOverrides: {
+ enableShipping: false,
+ enableLocalPickup: false,
+ enableTaxCollection: true,
+ },
+ });
+ await waitForCheckoutReady();
+ clearOperations();
+
+ await applyCoupon(user, 'onedollar');
+ await waitForOperation('ApplyCheckoutSessionDiscount');
+ await waitForOperation('DraftOrder');
+
+ expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(0);
+ expect(getOperations('DraftOrder')).toHaveLength(1);
+ });
+
+ it.each(['PURCHASE', 'DIGITAL'] as const)(
+ 'recalculates taxes using the billing address when a coupon is applied to a %s order',
+ async fulfillmentMode => {
+ 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-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..0cbafd42 100644
--- a/packages/react/src/components/checkout/__tests__/checkout-free-order.test.tsx
+++ b/packages/react/src/components/checkout/__tests__/checkout-free-order.test.tsx
@@ -1,6 +1,8 @@
import { screen, waitFor } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import {
+ advanceCheckoutDebounce,
+ buildBillingAddress,
buildCheckoutSession,
buildDraftOrder,
buildShippingRates,
@@ -87,7 +89,7 @@ async function applyCoupon(
await user.click(apply as HTMLButtonElement);
}
-function buildPaidPurchaseDraftOrder() {
+function buildPaidOrder(fulfillmentMode: 'PICKUP' | 'PURCHASE') {
return buildDraftOrder({
totals: {
subTotal: { value: 100, currencyCode: 'USD' },
@@ -100,7 +102,7 @@ function buildPaidPurchaseDraftOrder() {
lineItems: [
{
unitAmount: { value: 100, currencyCode: 'USD' },
- fulfillmentMode: 'PURCHASE',
+ fulfillmentMode,
totals: {
subTotal: { value: 100, currencyCode: 'USD' },
discountTotal: { value: 0, currencyCode: 'USD' },
@@ -112,6 +114,14 @@ function buildPaidPurchaseDraftOrder() {
});
}
+function buildPaidPurchaseDraftOrder() {
+ return buildPaidOrder('PURCHASE');
+}
+
+function buildPaidPickupDraftOrder() {
+ return buildPaidOrder('PICKUP');
+}
+
describe('Checkout free / offline orders', () => {
it('renders a free pickup order with names-only billing and no paid payment form', async () => {
const draftOrder = buildFreeDraftOrder({
@@ -327,6 +337,56 @@ describe('Checkout free / offline orders', () => {
expect(getLastConfirmInput()).not.toHaveProperty('fulfillmentLocationId');
});
+ it('clears a paid card pickup billing address exactly once when a coupon makes the order free', async () => {
+ const draftOrder = buildPaidPickupDraftOrder();
+ draftOrder.billing = {
+ firstName: 'Card',
+ lastName: 'Pickup',
+ phone: '',
+ email: 'jane@example.com',
+ address: buildBillingAddress({ addressLine1: '500 Card St' }),
+ };
+ const session = buildCheckoutSession({
+ draftOrder,
+ enableShipping: false,
+ enableLocalPickup: true,
+ enableTaxCollection: true,
+ });
+
+ const { user } = renderCheckout({ session, draftOrder });
+ await waitForCheckoutReady();
+ expect(
+ document.querySelector('input[name="billingAddressLine1"]')
+ ).toBeInTheDocument();
+
+ clearOperations();
+ await applyCoupon(user, 'free100');
+ await waitForOperation('ApplyCheckoutSessionDiscount');
+ await waitForOperation('UpdateCheckoutSessionDraftOrder');
+ await advanceCheckoutDebounce(2500);
+
+ await waitFor(() => {
+ expect(
+ screen.getByRole('button', { name: /complete your free order/i })
+ ).toBeInTheDocument();
+ expect(
+ document.querySelector('input[name="billingAddressLine1"]')
+ ).not.toBeInTheDocument();
+ });
+
+ const nullAddressUpdates = getOperations(
+ 'UpdateCheckoutSessionDraftOrder'
+ ).filter(
+ operation =>
+ (operation.input as { billing?: { address?: unknown } }).billing
+ ?.address === null
+ );
+ expect(nullAddressUpdates).toHaveLength(1);
+ expect(nullAddressUpdates[0].input).toMatchObject({
+ billing: { firstName: 'Card', lastName: 'Pickup', address: null },
+ });
+ });
+
it('switches from paid payment methods to FreePaymentForm after a 100% coupon', async () => {
const draftOrder = buildPaidPurchaseDraftOrder();
const session = buildCheckoutSession({
diff --git a/packages/react/src/components/checkout/__tests__/checkout-free-payment-form.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-free-payment-form.test.tsx
index e418a910..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..87061b35 100644
--- a/packages/react/src/components/checkout/__tests__/checkout-shipping.test.tsx
+++ b/packages/react/src/components/checkout/__tests__/checkout-shipping.test.tsx
@@ -2,9 +2,11 @@ import { fireEvent, screen, waitFor } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys';
import * as godaddyApi from '@/lib/godaddy/godaddy';
+import { CheckoutType, PaymentProvider } from '@/types';
import {
advanceCheckoutDebounce,
buildDraftOrder,
+ buildShippingAddress,
clearOperations,
flushPromises,
getOperations,
@@ -363,6 +365,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..b2ae0c4c 100644
--- a/packages/react/src/components/checkout/__tests__/checkout-test-utils.tsx
+++ b/packages/react/src/components/checkout/__tests__/checkout-test-utils.tsx
@@ -630,7 +630,17 @@ function applyShippingLines(shippingMethods: unknown) {
function applyDiscountCodes(discountCodes: string[]) {
if (!state) return;
const discounts = discountCodes.map(code => discount(code));
- const discountTotal = money(discountCodes.length * 100);
+ const totals = state.draftOrder.totals ?? defaultTotals();
+ const freeOrderDiscount =
+ (totals.subTotal?.value ?? 0) +
+ (totals.shippingTotal?.value ?? 0) +
+ (totals.taxTotal?.value ?? 0) +
+ (totals.feeTotal?.value ?? 0);
+ const discountTotal = money(
+ discountCodes.some(code => code.toLowerCase() === 'free100')
+ ? freeOrderDiscount
+ : discountCodes.length * 100
+ );
state.draftOrder = recalculateTotal({
...state.draftOrder,
discounts,
diff --git a/packages/react/src/components/checkout/__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/use-discount-apply.ts b/packages/react/src/components/checkout/discount/utils/use-discount-apply.ts
index db2a6f4e..440cf1d8 100644
--- a/packages/react/src/components/checkout/discount/utils/use-discount-apply.ts
+++ b/packages/react/src/components/checkout/discount/utils/use-discount-apply.ts
@@ -139,37 +139,43 @@ export function useDiscountApply() {
);
}
- if (session?.enableTaxCollection) {
- // If the delivery method is pickup, we need to update taxes based on the pickup location
- // Otherwise, we can just update taxes without a specific address
+ if (session.enableTaxCollection) {
// TODO: Move this to API layer
const deliveryMethod = form.getValues('deliveryMethod');
- const isPickup = deliveryMethod === DeliveryMethods.PICKUP;
- if (isPickup) {
+ if (deliveryMethod === DeliveryMethods.PICKUP) {
const pickupLocationId = form.getValues('pickupLocationId');
- const locationAddress = session?.locations?.find(
+ const locationAddress = session.locations?.find(
loc => loc.id === pickupLocationId
)?.address;
if (locationAddress) {
await updateTaxes.mutateAsync(locationAddress);
+ return;
+ }
+ } else if (
+ deliveryMethod === DeliveryMethods.PURCHASE ||
+ deliveryMethod === DeliveryMethods.DIGITAL
+ ) {
+ const billingAddress = draftOrder?.billing?.address;
+
+ if (billingAddress?.postalCode && billingAddress?.countryCode) {
+ await updateTaxes.mutateAsync(billingAddress);
+ return;
}
} else {
- // Only update taxes if we have the required location data
- const hasRequiredLocationData =
- draftOrder?.shipping?.address?.postalCode &&
- draftOrder?.shipping?.address?.countryCode;
+ const shippingAddress = draftOrder?.shipping?.address;
- if (hasRequiredLocationData) {
+ if (shippingAddress?.postalCode && shippingAddress?.countryCode) {
await updateTaxes.mutateAsync(undefined);
+ return;
}
}
- } else {
- queryClient.invalidateQueries({
- queryKey: checkoutQueryKeys.draftOrder(session.id),
- });
}
+
+ await queryClient.invalidateQueries({
+ queryKey: checkoutQueryKeys.draftOrder(session.id),
+ });
},
});
}
diff --git a/packages/react/src/components/checkout/form/checkout-form-container.tsx b/packages/react/src/components/checkout/form/checkout-form-container.tsx
index 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(
@@ -402,7 +411,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/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/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,