Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
7d6ddec
fix: adjust billing collection logic
pbennett1-godaddy Aug 19, 2026
2caa59f
add changeset
pbennett1-godaddy Aug 19, 2026
4db2dc6
feat(react): add billing policy resolver
pbennett1-godaddy Aug 19, 2026
a5c1135
test(react): lock billing policy contracts
pbennett1-godaddy Aug 19, 2026
188cf87
feat(react): migrate billing renderers to policy
pbennett1-godaddy Aug 19, 2026
933221c
feat(react): refactor checkout trigger field filtering
pbennett1-godaddy Aug 19, 2026
33207a6
feat(react): add billing policy transition controller
pbennett1-godaddy Aug 19, 2026
111fe18
feat(react): add policy-aware checkout validation adapter
pbennett1-godaddy Aug 19, 2026
8f77713
feat(react): finalize billing policy validation
pbennett1-godaddy Aug 19, 2026
48012b4
biome fix
pbennett1-godaddy Aug 19, 2026
89ab144
do not prevent tax call on zero order after discount
pbennett1-godaddy Aug 19, 2026
222590a
update billing policy for shipping scenarios
pbennett1-godaddy Aug 19, 2026
29664cb
add tax fix for discounts on purchase and digital orders
pbennett1-godaddy Aug 19, 2026
bfb13f8
update billing policy for shipping flags
pbennett1-godaddy Aug 20, 2026
6a7e6c7
adjust changeset
pbennett1-godaddy Aug 20, 2026
3af3471
use consts for billing mode and locations
pbennett1-godaddy Aug 20, 2026
b10a141
add additional billing tests and do not treat null total as free
pbennett1-godaddy Aug 21, 2026
c7c5885
add billing address to stripe payments
pbennett1-godaddy Aug 21, 2026
d845174
adjust sku and order hydration and refetches
pbennett1-godaddy Aug 21, 2026
4057110
always use flushed updated order on checkout
pbennett1-godaddy Aug 21, 2026
f46f8f0
checkout payment flush test for e2e with latest order
pbennett1-godaddy Aug 21, 2026
29c4f33
hydrate contact email from shipping or billing data
pbennett1-godaddy Aug 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/clever-clubs-crash.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Loading