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,