diff --git a/.changeset/fruity-dots-jog.md b/.changeset/fruity-dots-jog.md new file mode 100644 index 00000000..bd001e74 --- /dev/null +++ b/.changeset/fruity-dots-jog.md @@ -0,0 +1,13 @@ +--- +"@godaddy/react": patch +--- + +Support tips in unified checkout + +Adds the `tips` session config surface (`default` and threshold-based `amounts`/`percentages` presets) alongside `enableTips`, and includes the selected tip in wallet sheet totals and the authorized/confirmed amount. + +For redirect gateways (CCAvenue), the authorized tip is persisted across the redirect so the confirmation on the return leg records the tip the customer was actually charged. Checkout refuses to redirect when a non-zero tip cannot be persisted, rather than sending the customer to pay a tip the order would not include. + +Express checkout stays tip-free: its wallet sheets open on the item subtotal and add the shipping and taxes they calculate in their own event flows, and its confirmation records no tip. + +Also gives every `Button` a `cursor-pointer`, so buttons rendered as `; + }, +})); + +import { + type CheckoutFormData, + checkoutContext, +} from '@/components/checkout/checkout'; +import { DraftOrderSyncProvider } from '@/components/checkout/order/draft-order-sync-provider'; +import { PayPalCheckoutButton } from '@/components/checkout/payment/checkout-buttons/paypal/paypal'; +import { GoDaddyProvider } from '@/godaddy-provider'; +import { + buildCheckoutSession, + buildDraftOrder, + createTestQueryClient, + getOperations, + mockGodaddyApi, + restoreWindowLocation, + setupCheckoutTestGlobals, +} from '../../../__tests__/checkout-test-utils'; + +interface PayPalOrderActions { + order: { + create: (order: Record) => Promise; + get: () => Promise<{ id: string; payer: { payer_id: string } }>; + }; +} + +interface PayPalButtonsMockProps { + disabled?: boolean; + createOrder?: (data: unknown, actions: PayPalOrderActions) => Promise; + onApprove?: (data: unknown, actions: PayPalOrderActions) => Promise; +} + +let payPalButtonsProps: PayPalButtonsMockProps | undefined; + +function getPayPalButtonsProps() { + if (!payPalButtonsProps) { + throw new Error('PayPalButtons has not rendered'); + } + return payPalButtonsProps; +} + +const noop = () => undefined; + +const PAYPAL_ORDER_ID = 'paypal-order-1'; + +let form: UseFormReturn | undefined; + +function renderPayPalButton({ enableTips = true, tipAmount = 0 } = {}) { + const session = buildCheckoutSession({ enableTips }); + const draftOrder = buildDraftOrder(); + mockGodaddyApi({ session, draftOrder }); + const queryClient = createTestQueryClient(); + + function Harness() { + const methods = useForm({ + defaultValues: { tipAmount } as CheckoutFormData, + }); + form = methods; + + return ( + + + + + + + + + + ); + } + + return render(); +} + +// PayPal calls `createOrder` when the buyer opens the popup and `onApprove` +// after they approve inside it — two separate round trips, with the page still +// live in between. +function payPalActions(createdOrders: Array>) { + return { + order: { + create: async (order: Record) => { + createdOrders.push(order); + return PAYPAL_ORDER_ID; + }, + get: async () => ({ + id: PAYPAL_ORDER_ID, + payer: { payer_id: 'payer-1' }, + }), + }, + } satisfies PayPalOrderActions; +} + +function tipMinorUnitsInOrder(order: Record) { + const purchaseUnit = ( + order.purchase_units as Array<{ + items?: Array<{ name: string; unit_amount: { value: string } }>; + }> + )[0]; + const tipItem = purchaseUnit?.items?.find(item => item.name === 'Tip'); + return tipItem ? Math.round(Number(tipItem.unit_amount.value) * 100) : 0; +} + +function confirmInput() { + return getOperations('ConfirmCheckoutSession').at(-1)?.input as + | Record + | undefined; +} + +describe('PayPalCheckoutButton', () => { + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + setupCheckoutTestGlobals(); + form = undefined; + payPalButtonsProps = undefined; + }); + + afterEach(() => { + act(() => { + vi.runOnlyPendingTimers(); + }); + vi.useRealTimers(); + vi.restoreAllMocks(); + restoreWindowLocation(); + }); + + it('confirms with the tip PayPal authorized, not a tip chosen afterwards', async () => { + // The popup stays open across `createOrder` → `onApprove`, so the tip + // control can move underneath it. Confirming with the current form value + // would charge the authorized amount but record a different tip. + renderPayPalButton({ enableTips: true, tipAmount: 500 }); + const createdOrders: Array> = []; + + await act(async () => { + await getPayPalButtonsProps().createOrder?.( + {}, + payPalActions(createdOrders) + ); + }); + + expect(tipMinorUnitsInOrder(createdOrders[0])).toBe(500); + + act(() => { + form?.setValue('tipAmount', 100); + }); + + await act(async () => { + await getPayPalButtonsProps().onApprove?.( + {}, + payPalActions(createdOrders) + ); + }); + + await waitFor(() => { + expect(confirmInput()).toBeDefined(); + }); + expect(confirmInput()).toMatchObject({ + paymentToken: `${PAYPAL_ORDER_ID}:payer-1`, + paymentType: 'paypal', + tipAmount: 500, + }); + }); + + it('confirms with the selected tip when it does not change', async () => { + renderPayPalButton({ enableTips: true, tipAmount: 500 }); + const createdOrders: Array> = []; + + await act(async () => { + await getPayPalButtonsProps().createOrder?.( + {}, + payPalActions(createdOrders) + ); + await getPayPalButtonsProps().onApprove?.({}, payPalActions([])); + }); + + await waitFor(() => { + expect(confirmInput()).toMatchObject({ tipAmount: 500 }); + }); + }); + + it('sends no tip when the session has tips disabled', async () => { + // A stale tipAmount can linger in form state after tips are turned off. + renderPayPalButton({ enableTips: false, tipAmount: 500 }); + const createdOrders: Array> = []; + + await act(async () => { + await getPayPalButtonsProps().createOrder?.( + {}, + payPalActions(createdOrders) + ); + await getPayPalButtonsProps().onApprove?.({}, payPalActions([])); + }); + + await waitFor(() => { + expect(confirmInput()).toBeDefined(); + }); + expect(tipMinorUnitsInOrder(createdOrders[0])).toBe(0); + expect(confirmInput()?.tipAmount).toBeUndefined(); + }); +}); 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..c3805191 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 @@ -3,7 +3,7 @@ import { PayPalButtons, usePayPalScriptReducer, } from '@paypal/react-paypal-js'; -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { useFormContext } from 'react-hook-form'; import { useCheckoutContext } from '@/components/checkout/checkout'; import { DeliveryMethods } from '@/components/checkout/delivery/delivery-methods'; @@ -19,7 +19,7 @@ import { useGoDaddyContext } from '@/godaddy-provider'; import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors'; function PayPalButtonsWrapper() { - const { setCheckoutErrors } = useCheckoutContext(); + const { session, setCheckoutErrors } = useCheckoutContext(); const isPaymentDisabled = useIsPaymentDisabled(); const form = useFormContext(); const { payPalRequest } = useBuildPaymentRequest(); @@ -29,6 +29,10 @@ function PayPalButtonsWrapper() { const deliveryMethod = form.watch('deliveryMethod'); const isPickup = deliveryMethod === DeliveryMethods.PICKUP; const [{ isResolved, isPending }] = usePayPalScriptReducer(); + const tipAmount = form.watch('tipAmount') || 0; + // PayPal's popup can stay open while the tip changes underneath, so confirm + // sends the tip `createOrder` submitted rather than the current form value. + const authorizedTipAmount = useRef(null); // PayPal onClick handler that returns Promise const handleClick = async (_data, actions) => { @@ -54,6 +58,7 @@ function PayPalButtonsWrapper() { }; const createOrder = async (_data, actions) => { + authorizedTipAmount.current = session?.enableTips ? tipAmount : null; const order = { ...payPalRequest, purchase_units: payPalRequest.purchase_units @@ -80,6 +85,9 @@ function PayPalButtonsWrapper() { paymentToken: `${details.id}:${details.payer.payer_id}`, paymentType: 'paypal', paymentProvider: PaymentProvider.PAYPAL, + ...(authorizedTipAmount.current === null + ? {} + : { tipAmount: authorizedTipAmount.current }), }); } catch (err: unknown) { if (err instanceof GraphQLErrorWithCodes) { diff --git a/packages/react/src/components/checkout/payment/utils/ccavenue-return-provider.test.tsx b/packages/react/src/components/checkout/payment/utils/ccavenue-return-provider.test.tsx new file mode 100644 index 00000000..d8a27a95 --- /dev/null +++ b/packages/react/src/components/checkout/payment/utils/ccavenue-return-provider.test.tsx @@ -0,0 +1,136 @@ +import { render, waitFor } from '@testing-library/react'; +import type React from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { + type CheckoutFormData, + checkoutContext, +} from '@/components/checkout/checkout'; +import { DraftOrderSyncProvider } from '@/components/checkout/order/draft-order-sync-provider'; +import { CCAvenueReturnProvider } from '@/components/checkout/payment/utils/ccavenue-return-provider'; +import { GoDaddyProvider } from '@/godaddy-provider'; +import { setRedirectTipAmount } from '@/lib/redirect-tip-storage'; +import type { CheckoutSession } from '@/types'; +import { + buildCheckoutSession, + buildDraftOrder, + createTestQueryClient, + getOperations, + mockGodaddyApi, + setCheckoutUrl, +} from '../../__tests__/checkout-test-env'; + +// Stable across renders, like the state setters the real provider supplies — +// an inline callback would re-run the effect on every render and mask whether +// the declared dependencies are complete. +const noop = () => undefined; + +// The session cookie is absent in the token-exchange path, so `jwt` is the only +// credential the return leg can authenticate with. +function buildCookielessSession(): CheckoutSession { + return { + ...buildCheckoutSession({ enableTips: true }), + token: null, + }; +} + +function renderReturnProvider(session: CheckoutSession) { + const queryClient = createTestQueryClient(); + + function Harness({ jwt }: { jwt?: string }) { + const methods = useForm(); + + return ( + + + + + +
checkout
+
+
+
+
+
+ ); + } + + const view = render(); + return { + setJwt: (jwt: string) => view.rerender(), + }; +} + +function confirmOperations() { + return getOperations('ConfirmCheckoutSession'); +} + +describe('CCAvenueReturnProvider', () => { + beforeEach(() => { + window.sessionStorage.clear(); + window.localStorage.clear(); + mockGodaddyApi({ + session: buildCookielessSession(), + draftOrder: buildDraftOrder(), + }); + setCheckoutUrl({ + pathname: '/checkout/checkout-session-1', + search: 'encResp=enc-resp-1', + }); + }); + + it('confirms with the authorized tip once the jwt arrives after the first render', async () => { + // The effect reads `jwt`, so it has to re-run when the token lands or the + // customer is left paid at the gateway with no order. + setRedirectTipAmount('checkout-session-1', 500); + + const { setJwt } = renderReturnProvider(buildCookielessSession()); + expect(confirmOperations()).toHaveLength(0); + + setJwt('jwt-1'); + + await waitFor(() => { + expect(confirmOperations()).toHaveLength(1); + }); + expect(confirmOperations()[0]?.input).toMatchObject({ + paymentToken: 'enc-resp-1', + paymentType: 'ccavenue', + tipAmount: 500, + }); + }); + + it('confirms only once when the context changes again after the confirmation', async () => { + setRedirectTipAmount('checkout-session-1', 500); + + const { setJwt } = renderReturnProvider(buildCookielessSession()); + setJwt('jwt-1'); + await waitFor(() => { + expect(confirmOperations()).toHaveLength(1); + }); + + setJwt('jwt-2'); + + await waitFor(() => { + expect(confirmOperations()).toHaveLength(1); + }); + }); + + it('does not confirm while no credential is available', async () => { + setRedirectTipAmount('checkout-session-1', 500); + + renderReturnProvider(buildCookielessSession()); + + await waitFor(() => { + expect(confirmOperations()).toHaveLength(0); + }); + }); +}); diff --git a/packages/react/src/components/checkout/payment/utils/ccavenue-return-provider.tsx b/packages/react/src/components/checkout/payment/utils/ccavenue-return-provider.tsx index 52c38169..278ab3a9 100644 --- a/packages/react/src/components/checkout/payment/utils/ccavenue-return-provider.tsx +++ b/packages/react/src/components/checkout/payment/utils/ccavenue-return-provider.tsx @@ -7,6 +7,12 @@ import { useConfirmCheckout, } from '@/components/checkout/payment/utils/use-confirm-checkout'; import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors'; +import { + clearRedirectTipAmount, + getRedirectTipAmount, +} from '@/lib/redirect-tip-storage'; +import { eventIds } from '@/tracking/events'; +import { TrackingEventType, track } from '@/tracking/track'; export function CCAvenueReturnProvider({ children, @@ -31,22 +37,63 @@ export function CCAvenueReturnProvider({ hasRun.current = true; + const sessionId = session.id; + const authorizedTipAmount = getRedirectTipAmount(sessionId); + + // The gateway has already collected a tip-inclusive amount by this point, so + // the confirmation still has to go through even when the tip cannot be + // recovered — refusing would leave the customer paid with no order. The + // redirect leg refuses to send a customer whose tip could not be persisted, + // so reaching here means storage was cleared mid-redirect: report it, since + // the order is about to be recorded for less than was charged. + if (session.enableTips && authorizedTipAmount === null) { + track({ + eventId: eventIds.redirectTipUnrecoverable, + type: TrackingEventType.EVENT, + properties: { + provider: PaymentProvider.CCAVENUE, + draftOrderId: session.draftOrder?.id || 'unknown', + }, + }); + } + const confirmInput = { paymentToken: encResp, paymentType: 'ccavenue' as const, paymentProvider: PaymentProvider.CCAVENUE, + ...(authorizedTipAmount === null + ? {} + : { tipAmount: authorizedTipAmount }), }; - confirmCheckout.mutateAsync(confirmInput).catch(err => { - if (err instanceof GraphQLErrorWithCodes) { - setCheckoutErrors(err.codes); - } else { - setCheckoutErrors([ - err instanceof Error ? err.message : 'Payment confirmation failed.', - ]); - } - }); - }, [session?.token, session?.id, setCheckoutErrors]); + confirmCheckout + .mutateAsync(confirmInput) + .then(() => { + clearRedirectTipAmount(sessionId); + }) + .catch(err => { + if (err instanceof GraphQLErrorWithCodes) { + setCheckoutErrors(err.codes); + } else { + setCheckoutErrors([ + err instanceof Error ? err.message : 'Payment confirmation failed.', + ]); + } + }); + // Every value the effect reads is a dependency, `jwt` included: in the + // token-exchange path the session cookie is absent, so `jwt` is the only + // credential that unblocks the gate above, and it can arrive after the + // first run. Re-running is safe — `hasRun` makes the confirmation + // fire-once regardless of how many times the effect is re-invoked. + }, [ + session?.token, + session?.id, + session?.enableTips, + session?.draftOrder?.id, + jwt, + confirmCheckout.mutateAsync, + setCheckoutErrors, + ]); return <>{children}; } diff --git a/packages/react/src/components/checkout/payment/utils/conditional-providers.tsx b/packages/react/src/components/checkout/payment/utils/conditional-providers.tsx index 8d6c9292..0aee72fb 100644 --- a/packages/react/src/components/checkout/payment/utils/conditional-providers.tsx +++ b/packages/react/src/components/checkout/payment/utils/conditional-providers.tsx @@ -117,7 +117,9 @@ export function ConditionalExpressProviders({ // Only wrap with StripeProvider if Stripe is configured if (stripeConfig?.publishableKey?.trim()) { - wrappedChildren = {wrappedChildren}; + wrappedChildren = ( + {wrappedChildren} + ); } return <>{wrappedChildren}; diff --git a/packages/react/src/components/checkout/payment/utils/stripe-provider.tsx b/packages/react/src/components/checkout/payment/utils/stripe-provider.tsx index 81f58f6d..d29d3eda 100644 --- a/packages/react/src/components/checkout/payment/utils/stripe-provider.tsx +++ b/packages/react/src/components/checkout/payment/utils/stripe-provider.tsx @@ -1,34 +1,38 @@ import { Elements, useElements } from '@stripe/react-stripe-js'; import { useEffect } from 'react'; import { useCheckoutContext } from '@/components/checkout/checkout'; -import { useDraftOrderTotals } from '@/components/checkout/order/use-draft-order'; import { useStripePaymentIntent } from '@/components/checkout/payment/utils/use-stripe-payment-intent'; -function StripeElementsUpdater() { +function StripeElementsUpdater({ amount = 0 }: { amount?: number }) { const elements = useElements(); - const { data: totals, isLoading: totalsLoading } = useDraftOrderTotals(); useEffect(() => { - if (!totalsLoading && elements && (totals?.total?.value || 0) > 0) { + if (elements && amount > 0) { elements.update({ - amount: totals?.total?.value || 0, + amount, }); } - }, [elements, totalsLoading, totals?.total?.value]); + }, [elements, amount]); return null; // This component only updates Elements } -export function StripeProvider({ children }: { children: React.ReactNode }) { +export function StripeProvider({ + children, + isExpress = false, +}: { + children: React.ReactNode; + isExpress?: boolean; +}) { const { stripeConfig } = useCheckoutContext(); + const { stripePromise, currency, clientSecret, isLoading, amount } = + useStripePaymentIntent({ isExpress }); + if (!stripeConfig?.publishableKey?.trim()) { return <>{children}; } - const { stripePromise, currency, clientSecret, isLoading, amount } = - useStripePaymentIntent(); - if (isLoading || !stripePromise || amount <= 0) { return null; } @@ -46,7 +50,7 @@ export function StripeProvider({ children }: { children: React.ReactNode }) { payment_method_types: ['card'], }} > - + {children} ); @@ -54,7 +58,11 @@ export function StripeProvider({ children }: { children: React.ReactNode }) { if (stripePromise && clientSecret) { return ( - + {children} ); diff --git a/packages/react/src/components/checkout/payment/utils/use-authorize-checkout.test.tsx b/packages/react/src/components/checkout/payment/utils/use-authorize-checkout.test.tsx new file mode 100644 index 00000000..90c9693a --- /dev/null +++ b/packages/react/src/components/checkout/payment/utils/use-authorize-checkout.test.tsx @@ -0,0 +1,144 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import type React from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { describe, expect, it } from 'vitest'; +import { + type CheckoutFormData, + checkoutContext, +} from '@/components/checkout/checkout'; +import { DraftOrderSyncProvider } from '@/components/checkout/order/draft-order-sync-provider'; +import { useAuthorizeCheckout } from '@/components/checkout/payment/utils/use-authorize-checkout'; +import { PaymentProvider } from '@/components/checkout/payment/utils/use-confirm-checkout'; +import { GoDaddyProvider } from '@/godaddy-provider'; +import { PaymentMethodType } from '@/types'; +import { + buildCheckoutSession, + buildDraftOrder, + createTestQueryClient, + getOperations, + mockGodaddyApi, +} from '../../__tests__/checkout-test-env'; + +function wrapper({ + enableTips = false, + tipAmount, +}: { + enableTips?: boolean; + tipAmount?: number; +} = {}) { + const session = buildCheckoutSession({ enableTips }); + const draftOrder = buildDraftOrder(); + mockGodaddyApi({ session, draftOrder }); + const queryClient = createTestQueryClient(); + + return function Wrapper({ children }: { children: React.ReactNode }) { + const methods = useForm({ + defaultValues: tipAmount === undefined ? {} : { tipAmount }, + }); + + return ( + + undefined, + checkoutErrors: undefined, + setCheckoutErrors: () => undefined, + }} + > + + {children} + + + + ); + }; +} + +const cardFieldsInput = { + paymentType: PaymentMethodType.CREDIT_CARD, + paymentProvider: PaymentProvider.PAYPAL, + paymentToken: '', +}; + +async function authorizedInput() { + await waitFor(() => { + expect(getOperations('AuthorizeCheckoutSession')).toHaveLength(1); + }); + return getOperations('AuthorizeCheckoutSession')[0]?.input as + | Record + | undefined; +} + +describe('useAuthorizeCheckout', () => { + it('authorizes for the tip-inclusive amount when tips are enabled', async () => { + const { result } = renderHook(() => useAuthorizeCheckout(), { + wrapper: wrapper({ enableTips: true, tipAmount: 500 }), + }); + + await result.current.mutateAsync(cardFieldsInput); + + expect(await authorizedInput()).toMatchObject({ + paymentType: PaymentMethodType.CREDIT_CARD, + paymentProvider: 'PAYPAL', + paymentToken: '', + tipAmount: 500, + }); + }); + + it('authorizes with a zero tip when tips are enabled but none was chosen', async () => { + const { result } = renderHook(() => useAuthorizeCheckout(), { + wrapper: wrapper({ enableTips: true }), + }); + + await result.current.mutateAsync(cardFieldsInput); + + expect((await authorizedInput())?.tipAmount).toBe(0); + }); + + it('sends no tip when the session has tips disabled', async () => { + // A stale tipAmount can linger in form state after tips are turned off, so + // the session flag — not the form value — decides whether a tip is sent. + const { result } = renderHook(() => useAuthorizeCheckout(), { + wrapper: wrapper({ enableTips: false, tipAmount: 500 }), + }); + + await result.current.mutateAsync(cardFieldsInput); + + expect((await authorizedInput())?.tipAmount).toBeUndefined(); + }); + + it('ignores a caller-supplied tip so the authorized amount cannot drift', async () => { + // The form is the single source of truth: confirmCheckout captures the form + // value, so authorizing a caller-supplied amount instead would let the + // authorized and captured amounts diverge. + const { result } = renderHook(() => useAuthorizeCheckout(), { + wrapper: wrapper({ enableTips: true, tipAmount: 500 }), + }); + + await result.current.mutateAsync({ ...cardFieldsInput, tipAmount: 999 }); + + expect((await authorizedInput())?.tipAmount).toBe(500); + }); + + it('sends no tip when tips are disabled even if the caller supplies one', async () => { + const { result } = renderHook(() => useAuthorizeCheckout(), { + wrapper: wrapper({ enableTips: false, tipAmount: 500 }), + }); + + await result.current.mutateAsync({ ...cardFieldsInput, tipAmount: 250 }); + + expect((await authorizedInput())?.tipAmount).toBeUndefined(); + }); + + it('returns the transaction used as the provider order reference', async () => { + const { result } = renderHook(() => useAuthorizeCheckout(), { + wrapper: wrapper({ enableTips: true, tipAmount: 500 }), + }); + + const authorized = await result.current.mutateAsync(cardFieldsInput); + + expect(authorized?.transactionRefNum).toBe('transaction-ref-1'); + }); +}); diff --git a/packages/react/src/components/checkout/payment/utils/use-authorize-checkout.ts b/packages/react/src/components/checkout/payment/utils/use-authorize-checkout.ts index 2eeeff4b..5c11f8fb 100644 --- a/packages/react/src/components/checkout/payment/utils/use-authorize-checkout.ts +++ b/packages/react/src/components/checkout/payment/utils/use-authorize-checkout.ts @@ -1,24 +1,60 @@ import { useMutation } from '@tanstack/react-query'; +import { useFormContext } from 'react-hook-form'; import { useCheckoutContext } from '@/components/checkout/checkout'; +import { useDraftOrderTotals } from '@/components/checkout/order/use-draft-order'; import { useFlushCheckoutSync } from '@/components/checkout/payment/utils/use-flush-checkout-sync'; +import { + applyTipFieldError, + applyTipOnlyChargeError, +} from '@/components/checkout/tips/utils/tip-field-errors'; import { useGoDaddyContext } from '@/godaddy-provider'; import { authorizeCheckoutSession } from '@/lib/godaddy/godaddy'; import type { AuthorizeCheckoutSessionInput } from '@/types'; export function useAuthorizeCheckout() { const { session, jwt } = useCheckoutContext(); - const { apiHost } = useGoDaddyContext(); + const { apiHost, t } = useGoDaddyContext(); + const form = useFormContext(); + const { data: totals } = useDraftOrderTotals(); const flushCheckoutSync = useFlushCheckoutSync(); return useMutation({ mutationFn: async (input: AuthorizeCheckoutSessionInput['input']) => { await flushCheckoutSync(); + // The form is the single source of truth for the tip, deliberately + // overriding any `tipAmount` the caller passed: the authorized amount must + // match what confirmCheckout later captures, so it cannot drift to a value + // a provider captured earlier. Read after the sync flush, once pending + // form state has settled. + // + // Note the precedence is the opposite of `useConfirmCheckout`, which + // prefers a caller-supplied tip. Confirming has to accept one — express + // wallets and the CCAvenue return leg know a tip the form no longer holds + // — whereas nothing authorizes on their behalf, so there is no such tip to + // honour here. + const payload = { + ...input, + tipAmount: session?.enableTips + ? (form?.getValues('tipAmount') ?? 0) + : undefined, + }; + const result = jwt - ? await authorizeCheckoutSession(input, { accessToken: jwt }, apiHost) - : await authorizeCheckoutSession(input, session, apiHost); + ? await authorizeCheckoutSession(payload, { accessToken: jwt }, apiHost) + : await authorizeCheckoutSession(payload, session, apiHost); return result.authorizeCheckoutSession; }, + onError: (error: unknown) => { + const translate = (code: string) => + t.apiErrors?.[code as keyof typeof t.apiErrors]; + + // An unattributed rejection still belongs on the tip field when the tip is + // the only thing being charged. + if (!applyTipFieldError(form, error, translate) && session?.enableTips) { + applyTipOnlyChargeError(form, totals?.total?.value || 0, translate); + } + }, }); } 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..34a2ff0d 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 @@ -1,7 +1,11 @@ import { render, waitFor } from '@testing-library/react'; import React from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; import { describe, expect, it, vi } from 'vitest'; -import { checkoutContext } from '@/components/checkout/checkout'; +import { + type CheckoutFormData, + checkoutContext, +} from '@/components/checkout/checkout'; import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys'; import { GoDaddyProvider } from '@/godaddy-provider'; import type { CheckoutSession, DraftOrder, SKUProduct } from '@/types'; @@ -48,6 +52,19 @@ function productNode(overrides: Partial = {}): SKUProduct { } as SKUProduct; } +function FormWrapper({ + defaultValues, + children, +}: { + defaultValues?: Partial; + children: React.ReactNode; +}) { + const methods = useForm({ + defaultValues: defaultValues ?? {}, + }); + return {children}; +} + function PaymentRequestProbe({ onRequests, }: { @@ -66,10 +83,15 @@ async function renderUseBuildPaymentRequest({ draftOrderOverrides, sessionOverrides, products = [productNode()], + formDefaultValues, + withoutForm = false, }: { draftOrderOverrides?: DeepPartial; sessionOverrides?: DeepPartial; products?: SKUProduct[]; + formDefaultValues?: Partial; + /** Render outside any FormProvider, so `useFormContext()` returns null. */ + withoutForm?: boolean; } = {}) { const queryClient = createTestQueryClient(); const draftOrder = buildDraftOrder(draftOrderOverrides); @@ -108,7 +130,13 @@ async function renderUseBuildPaymentRequest({ setCheckoutErrors: () => undefined, }} > - + {withoutForm ? ( + + ) : ( + + + + )} ); @@ -382,4 +410,353 @@ describe('useBuildPaymentRequest', () => { '1.234' ); }); + + it('includes tipAmount in payment request totals when enableTips is true', async () => { + const { requests } = await renderUseBuildPaymentRequest({ + sessionOverrides: { + enableTips: true, + }, + draftOrderOverrides: { + lineItems: [ + buildLineItem({ + name: 'Coffee Mug', + quantity: 1, + details: { sku: 'mug-sku' }, + totals: { + subTotal: money(2000), + discountTotal: money(0), + feeTotal: money(0), + taxTotal: money(0), + }, + unitAmount: money(2000), + }), + ], + shippingLines: [], + totals: { + subTotal: money(2000), + discountTotal: money(0), + shippingTotal: money(0), + taxTotal: money(0), + feeTotal: money(0), + total: money(2000), + }, + }, + products: [productNode({ code: 'mug-sku', label: 'Coffee Mug' })], + formDefaultValues: { tipAmount: 500 }, + }); + + // Apple Pay total includes tip + expect(requests.applePayRequest.total.amount).toBe('$25.00'); + expect(requests.applePayRequest.lineItems).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + label: 'Tip', + amount: '$5.00', + type: 'final', + }), + ]) + ); + + // Google Pay total includes tip + expect(requests.googlePayRequest.transactionInfo.totalPrice).toBe('$25.00'); + expect(requests.googlePayRequest.transactionInfo.displayItems).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + label: 'Tip', + price: 5, + type: 'LINE_ITEM', + status: 'FINAL', + }), + ]) + ); + + // PayPal total includes tip in breakdown and items + expect(requests.payPalRequest.purchase_units[0].amount.value).toBe('25.00'); + expect( + requests.payPalRequest.purchase_units[0].amount.breakdown.item_total.value + ).toBe('25.00'); + expect(requests.payPalRequest.purchase_units[0].items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'Tip', + unit_amount: { currency_code: 'USD', value: '5.00' }, + quantity: '1', + }), + ]) + ); + + // Square total includes tip + expect(requests.squarePaymentRequest.amount).toBe('25.00'); + + // Poynt Express never charges a tip, so it stays on the bare subtotal + expect(requests.poyntExpressRequest.total.amount).toBe('20.00'); + expect(requests.poyntExpressRequest.lineItems).not.toEqual( + expect.arrayContaining([expect.objectContaining({ label: 'Tip' })]) + ); + + // Poynt Standard includes tip line item + expect(requests.poyntStandardRequest.lineItems).toEqual( + expect.arrayContaining([ + expect.objectContaining({ label: 'Tip', amount: '5.00' }), + ]) + ); + }); + + it('keeps tax and tip out of poyntExpressRequest.total.amount', async () => { + const { requests } = await renderUseBuildPaymentRequest({ + sessionOverrides: { + enableTips: true, + }, + draftOrderOverrides: { + lineItems: [ + buildLineItem({ + name: 'Coffee Mug', + quantity: 1, + details: { sku: 'mug-sku' }, + totals: { + subTotal: money(2000), + discountTotal: money(0), + feeTotal: money(0), + taxTotal: money(200), + }, + unitAmount: money(2000), + }), + ], + shippingLines: [], + totals: { + subTotal: money(2000), + discountTotal: money(0), + shippingTotal: money(0), + taxTotal: money(200), + feeTotal: money(0), + total: money(2200), + }, + }, + products: [productNode({ code: 'mug-sku', label: 'Coffee Mug' })], + formDefaultValues: { tipAmount: 300 }, + }); + + // Express opens on the $20.00 subtotal: the wallet adds the $2.00 tax in its + // own event flow, and it never charges the $3.00 tip. + expect(requests.poyntExpressRequest.total.amount).toBe('20.00'); + }); + + it('opens express on the subtotal while the standard wallet charges the full total', async () => { + // Express recalculates shipping and taxes in the wallet's event handlers and + // applies them at confirmation, so its sheet starts from the subtotal. The + // standard wallet requests have no such flow and must charge the full total. + // Keep subtotal and total distinct so neither can hide behind equal fixtures. + const { requests } = await renderUseBuildPaymentRequest({ + sessionOverrides: { + enableTips: false, + }, + draftOrderOverrides: { + lineItems: [ + buildLineItem({ + name: 'Coffee Mug', + quantity: 1, + details: { sku: 'mug-sku' }, + totals: { + subTotal: money(2000), + discountTotal: money(500), + feeTotal: money(0), + taxTotal: money(200), + }, + unitAmount: money(2000), + }), + ], + shippingLines: [ + { + id: 'shipping-line-1', + requestedService: 'ground', + requestedProvider: 'shippo', + name: 'Ground', + amount: money(1000), + discounts: [], + }, + ], + totals: { + subTotal: money(2000), + discountTotal: money(500), + shippingTotal: money(1000), + taxTotal: money(200), + feeTotal: money(0), + total: money(2700), + }, + }, + products: [productNode({ code: 'mug-sku', label: 'Coffee Mug' })], + formDefaultValues: { tipAmount: 500 }, + }); + + expect(requests.poyntExpressRequest.total.amount).toBe('20.00'); + + // subtotal $20.00 - discount $5.00 + shipping $10.00 + tax $2.00 = $27.00 + expect(requests.poyntStandardRequest.total.amount).toBe('27.00'); + expect(requests.applePayRequest.total.amount).toBe('$27.00'); + expect(requests.squarePaymentRequest.amount).toBe('27.00'); + }); + + it('excludes tipAmount from payment requests when enableTips is false', async () => { + const { requests } = await renderUseBuildPaymentRequest({ + sessionOverrides: { + enableTips: false, + }, + draftOrderOverrides: { + lineItems: [ + buildLineItem({ + name: 'Coffee Mug', + quantity: 1, + details: { sku: 'mug-sku' }, + totals: { + subTotal: money(2000), + discountTotal: money(0), + feeTotal: money(0), + taxTotal: money(0), + }, + unitAmount: money(2000), + }), + ], + shippingLines: [], + totals: { + subTotal: money(2000), + discountTotal: money(0), + shippingTotal: money(0), + taxTotal: money(0), + feeTotal: money(0), + total: money(2000), + }, + }, + products: [productNode({ code: 'mug-sku', label: 'Coffee Mug' })], + formDefaultValues: { tipAmount: 500 }, + }); + + // Totals should NOT include tip when enableTips is false + expect(requests.applePayRequest.total.amount).toBe('$20.00'); + expect(requests.googlePayRequest.transactionInfo.totalPrice).toBe('$20.00'); + expect(requests.payPalRequest.purchase_units[0].amount.value).toBe('20.00'); + expect(requests.squarePaymentRequest.amount).toBe('20.00'); + expect(requests.poyntExpressRequest.total.amount).toBe('20.00'); + + // No Tip line item in any request + expect(requests.applePayRequest.lineItems).not.toEqual( + expect.arrayContaining([expect.objectContaining({ label: 'Tip' })]) + ); + expect(requests.googlePayRequest.transactionInfo.displayItems).not.toEqual( + expect.arrayContaining([expect.objectContaining({ label: 'Tip' })]) + ); + expect(requests.payPalRequest.purchase_units[0].items).not.toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'Tip' })]) + ); + expect(requests.poyntStandardRequest.lineItems).not.toEqual( + expect.arrayContaining([expect.objectContaining({ label: 'Tip' })]) + ); + expect(requests.poyntExpressRequest.lineItems).not.toEqual( + expect.arrayContaining([expect.objectContaining({ label: 'Tip' })]) + ); + }); + + it.each([ + { scenario: 'the tip is explicitly zero', tipAmount: 0 }, + { scenario: 'no tip has been selected yet', tipAmount: undefined }, + ])( + 'omits the Tip line item when enableTips is true and $scenario', + async ({ tipAmount }) => { + const { requests } = await renderUseBuildPaymentRequest({ + sessionOverrides: { + enableTips: true, + }, + draftOrderOverrides: { + lineItems: [ + buildLineItem({ + name: 'Coffee Mug', + quantity: 1, + details: { sku: 'mug-sku' }, + totals: { + subTotal: money(2000), + discountTotal: money(0), + feeTotal: money(0), + taxTotal: money(0), + }, + unitAmount: money(2000), + }), + ], + shippingLines: [], + totals: { + subTotal: money(2000), + discountTotal: money(0), + shippingTotal: money(0), + taxTotal: money(0), + feeTotal: money(0), + total: money(2000), + }, + }, + products: [productNode({ code: 'mug-sku', label: 'Coffee Mug' })], + formDefaultValues: { tipAmount }, + }); + + // A zero tip must not reach the wallet sheets as a "$0.00 Tip" row. + expect(requests.applePayRequest.lineItems).not.toEqual( + expect.arrayContaining([expect.objectContaining({ label: 'Tip' })]) + ); + expect( + requests.googlePayRequest.transactionInfo.displayItems + ).not.toEqual( + expect.arrayContaining([expect.objectContaining({ label: 'Tip' })]) + ); + expect(requests.payPalRequest.purchase_units[0].items).not.toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'Tip' })]) + ); + expect(requests.poyntStandardRequest.lineItems).not.toEqual( + expect.arrayContaining([expect.objectContaining({ label: 'Tip' })]) + ); + expect(requests.poyntExpressRequest.lineItems).not.toEqual( + expect.arrayContaining([expect.objectContaining({ label: 'Tip' })]) + ); + + expect(requests.applePayRequest.total.amount).toBe('$20.00'); + expect(requests.poyntExpressRequest.total.amount).toBe('20.00'); + } + ); + + it('builds requests outside a form provider without a tip', async () => { + // Every shipping caller sits inside CustomFormProvider, so this guards the + // hook's own contract rather than a reachable path: reading the tip must not + // require a form context the way the rest of the hook does not. + const { requests } = await renderUseBuildPaymentRequest({ + withoutForm: true, + sessionOverrides: { enableTips: true }, + draftOrderOverrides: { + lineItems: [ + buildLineItem({ + name: 'Coffee Mug', + quantity: 1, + details: { sku: 'mug-sku' }, + totals: { + subTotal: money(2000), + discountTotal: money(0), + feeTotal: money(0), + taxTotal: money(0), + }, + unitAmount: money(2000), + }), + ], + shippingLines: [], + totals: { + subTotal: money(2000), + discountTotal: money(0), + shippingTotal: money(0), + taxTotal: money(0), + feeTotal: money(0), + total: money(2000), + }, + }, + products: [productNode({ code: 'mug-sku', label: 'Coffee Mug' })], + }); + + expect(requests.applePayRequest.total.amount).toBe('$20.00'); + expect(requests.applePayRequest.lineItems).not.toEqual( + expect.arrayContaining([expect.objectContaining({ label: 'Tip' })]) + ); + expect(requests.poyntExpressRequest.total.amount).toBe('20.00'); + }); }); 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..b1967e41 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 @@ -3,6 +3,7 @@ import type { PaymentMethodCreateParams, } from '@stripe/stripe-js'; import { useMemo } from 'react'; +import { useFormContext } from 'react-hook-form'; import { useCheckoutContext } from '@/components/checkout/checkout'; import { useDraftOrder, @@ -183,6 +184,7 @@ export function useBuildPaymentRequest(): { } { const formatCurrency = useFormatCurrency(); const { paypalConfig, session } = useCheckoutContext(); + const form = useFormContext(); const draftOrderTotalsQuery = useDraftOrderTotals(); const draftOrderQuery = useDraftOrder(); @@ -209,7 +211,9 @@ export function useBuildPaymentRequest(): { 0 ) || 0; const discountMinorUnits = totals?.discountTotal?.value || 0; + const tipAmount = form?.watch('tipAmount') || 0; const totalMinorUnits = totals?.total?.value || 0; + const totalWithTipMinorUnits = totalMinorUnits + tipAmount; const countryCode = useMemo( () => session?.shipping?.originAddress?.countryCode || 'US', @@ -262,7 +266,7 @@ export function useBuildPaymentRequest(): { total: { label: 'Order Total', amount: formatCurrency({ - amount: totals?.total?.value || 0, + amount: session?.enableTips ? totalWithTipMinorUnits : totalMinorUnits, currencyCode, inputInMinorUnits: true, }), @@ -319,6 +323,19 @@ export function useBuildPaymentRequest(): { }), type: 'final', }, + ...(session?.enableTips && tipAmount + ? [ + { + label: 'Tip', + amount: formatCurrency({ + amount: tipAmount, + currencyCode, + inputInMinorUnits: true, + }), + type: 'final', + }, + ] + : []), ].filter(item => Number.parseFloat(item.amount) !== 0), }; @@ -356,7 +373,7 @@ export function useBuildPaymentRequest(): { transactionInfo: { totalPriceStatus: 'FINAL', totalPrice: formatCurrency({ - amount: totals?.total?.value || 0, + amount: session?.enableTips ? totalWithTipMinorUnits : totalMinorUnits, currencyCode, inputInMinorUnits: true, }), @@ -421,6 +438,23 @@ export function useBuildPaymentRequest(): { type: 'LINE_ITEM', status: 'FINAL', }, + ...(session?.enableTips && tipAmount + ? [ + { + label: 'Tip', + price: Number.parseFloat( + formatCurrency({ + amount: tipAmount, + currencyCode, + inputInMinorUnits: true, + returnRaw: true, + }) + ), + type: 'LINE_ITEM', + status: 'FINAL', + }, + ] + : []), ].filter(item => item?.price !== 0), }, }; @@ -430,7 +464,8 @@ export function useBuildPaymentRequest(): { subtotalMinorUnits + taxMinorUnits + shippingMinorUnits - - discountMinorUnits; + discountMinorUnits + + (session?.enableTips ? tipAmount : 0); const payPalMerchantId = paypalConfig?.merchantId?.trim(); const payPalRequest: PayPalRequest = { @@ -451,7 +486,9 @@ export function useBuildPaymentRequest(): { item_total: { currency_code: currencyCode, value: formatCurrency({ - amount: subtotalMinorUnits, + amount: session?.enableTips + ? subtotalMinorUnits + tipAmount + : subtotalMinorUnits, currencyCode, inputInMinorUnits: true, returnRaw: true, @@ -486,19 +523,39 @@ export function useBuildPaymentRequest(): { }, }, }, - items: items.map(lineItem => ({ - name: lineItem?.name || '', - unit_amount: { - currency_code: currencyCode, - value: formatCurrency({ - amount: lineItem?.originalPrice || 0, - currencyCode, - inputInMinorUnits: true, - returnRaw: true, - }), - }, - quantity: (lineItem?.quantity || 1).toString(), - })), + items: items + .map(lineItem => ({ + name: lineItem?.name || '', + unit_amount: { + currency_code: currencyCode, + value: formatCurrency({ + amount: lineItem?.originalPrice || 0, + currencyCode, + inputInMinorUnits: true, + returnRaw: true, + }), + }, + quantity: (lineItem?.quantity || 1).toString(), + })) + .concat( + session?.enableTips && tipAmount + ? [ + { + name: 'Tip', + unit_amount: { + currency_code: currencyCode, + value: formatCurrency({ + amount: tipAmount, + currencyCode, + inputInMinorUnits: true, + returnRaw: true, + }), + }, + quantity: '1', + }, + ] + : [] + ), shipping: shippingAddress, billing: billingAddress, }, @@ -548,7 +605,7 @@ export function useBuildPaymentRequest(): { const squarePaymentRequest: SquarePaymentRequest = { amount: formatCurrency({ - amount: totals?.total?.value || 0, + amount: session?.enableTips ? totalWithTipMinorUnits : totalMinorUnits, currencyCode, inputInMinorUnits: true, returnRaw: true, @@ -573,6 +630,9 @@ export function useBuildPaymentRequest(): { sellerKeyedIn: false, }; + // Express starts from the item subtotal on purpose: the wallet's own event + // flows add shipping and taxes as the customer picks an address and method, + // and express never charges a tip. const poyntExpressRequest: PoyntExpressRequest = { total: { label: 'Order Total', @@ -583,26 +643,24 @@ export function useBuildPaymentRequest(): { returnRaw: true, }), }, - lineItems: [ - ...(items || []).map(lineItem => { - return { - label: lineItem?.name || '', - amount: formatCurrency({ - amount: (lineItem?.originalPrice || 0) * (lineItem?.quantity || 1), - currencyCode, - inputInMinorUnits: true, - returnRaw: true, - }), - }; - }), - ], + lineItems: (items || []).map(lineItem => { + return { + label: lineItem?.name || '', + amount: formatCurrency({ + amount: (lineItem?.originalPrice || 0) * (lineItem?.quantity || 1), + currencyCode, + inputInMinorUnits: true, + returnRaw: true, + }), + }; + }), }; const poyntStandardRequest: PoyntStandardRequest = { total: { label: 'Order Total', amount: formatCurrency({ - amount: totalMinorUnits, + amount: session?.enableTips ? totalWithTipMinorUnits : totalMinorUnits, currencyCode, inputInMinorUnits: true, returnRaw: true, @@ -647,6 +705,19 @@ export function useBuildPaymentRequest(): { returnRaw: true, }), }, + ...(session?.enableTips && tipAmount + ? [ + { + label: 'Tip', + amount: formatCurrency({ + amount: tipAmount, + currencyCode, + inputInMinorUnits: true, + returnRaw: true, + }), + }, + ] + : []), ], }; diff --git a/packages/react/src/components/checkout/payment/utils/use-confirm-checkout.ts b/packages/react/src/components/checkout/payment/utils/use-confirm-checkout.ts index 72e06442..a4f48010 100644 --- a/packages/react/src/components/checkout/payment/utils/use-confirm-checkout.ts +++ b/packages/react/src/components/checkout/payment/utils/use-confirm-checkout.ts @@ -11,6 +11,10 @@ import { useFlushCheckoutSync } from '@/components/checkout/payment/utils/use-fl import { buildPickupPayload } from '@/components/checkout/pickup/utils/build-pickup-payload'; import { getPickupMode } from '@/components/checkout/pickup/utils/generate-pickup-time-slots'; import { getShippingFulfillmentSyncKey } from '@/components/checkout/shipping/utils/should-apply-shipping-method'; +import { + applyTipFieldError, + applyTipOnlyChargeError, +} from '@/components/checkout/tips/utils/tip-field-errors'; import { isDigitalLineItem } from '@/components/checkout/utils/fulfillment'; import { useGoDaddyContext } from '@/godaddy-provider'; import { confirmCheckout } from '@/lib/godaddy/godaddy'; @@ -92,7 +96,7 @@ export enum PaymentProvider { export function useConfirmCheckout() { const { session, jwt, setIsConfirmingCheckout, setCheckoutErrors } = useCheckoutContext(); - const { apiHost } = useGoDaddyContext(); + const { apiHost, t } = useGoDaddyContext(); const form = useFormContext(); const { data: order } = useDraftOrder(); const flushCheckoutSync = useFlushCheckoutSync(); @@ -172,6 +176,27 @@ export function useConfirmCheckout() { : undefined, }) : {}; + // Destructured out so the key is omitted entirely when tips are off, + // rather than sent as `tipAmount: undefined` — and so a caller-supplied + // tip cannot ride along on the spread past that gate. + // + // A caller-supplied tip wins over form state here, unlike in + // `useAuthorizeCheckout` where the form overrides it. Express wallets + // captured their tip inside the sheet, and the CCAvenue return leg reads + // it from storage on a fresh document — in both cases the form is either + // stale or empty, so the caller is the better source. + const { tipAmount: suppliedTipAmount, ...inputWithoutTip } = + confirmCheckoutInput; + const payload = { + ...inputWithoutTip, + ...pickUpData, + ...(session.enableTips + ? { + tipAmount: + suppliedTipAmount ?? form.getValues('tipAmount') ?? 0, + } + : {}), + }; // keep for debugging // console.log({ @@ -195,21 +220,11 @@ export function useConfirmCheckout() { const data = jwt ? await confirmCheckout( - { - ...confirmCheckoutInput, - ...(isPickup ? pickUpData : {}), - }, + payload, { accessToken: jwt, sessionId: session?.id || '' }, apiHost ) - : await confirmCheckout( - { - ...confirmCheckoutInput, - ...(isPickup ? pickUpData : {}), - }, - session, - apiHost - ); + : await confirmCheckout(payload, session, apiHost); if (!data) { throw new Error('Checkout confirmation failed'); @@ -268,6 +283,19 @@ export function useConfirmCheckout() { onError: (error: unknown, data) => { if (isCheckoutConfirmationBlockedError(error)) return; + const translate = (code: string) => + t.apiErrors?.[code as keyof typeof t.apiErrors]; + + // An unattributed rejection still belongs on the tip field when the tip is + // the only thing being charged. + if (!applyTipFieldError(form, error, translate) && session?.enableTips) { + applyTipOnlyChargeError( + form, + order?.totals?.total?.value || 0, + translate + ); + } + // Track checkout error event track({ eventId: eventIds.checkoutError, diff --git a/packages/react/src/components/checkout/payment/utils/use-confirm-express-checkout.test.tsx b/packages/react/src/components/checkout/payment/utils/use-confirm-express-checkout.test.tsx index cf5f4a64..3bb8fec8 100644 --- a/packages/react/src/components/checkout/payment/utils/use-confirm-express-checkout.test.tsx +++ b/packages/react/src/components/checkout/payment/utils/use-confirm-express-checkout.test.tsx @@ -1,5 +1,6 @@ import { renderHook, waitFor } from '@testing-library/react'; import React from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; import { describe, expect, it } from 'vitest'; import { checkoutContext } from '@/components/checkout/checkout'; import { PaymentProvider } from '@/components/checkout/payment/utils/use-confirm-checkout'; @@ -14,9 +15,17 @@ import { mockGodaddyApi, } from '../../__tests__/checkout-test-env'; -function wrapper(session = buildCheckoutSession()) { +function wrapper( + session = buildCheckoutSession(), + formValues?: { tipAmount?: number } +) { const queryClient = createTestQueryClient(); + function MaybeForm({ children }: { children: React.ReactNode }) { + const form = useForm({ defaultValues: formValues }); + return {children}; + } + return function Wrapper({ children }: { children: React.ReactNode }) { const [isConfirmingCheckout, setIsConfirmingCheckout] = React.useState(false); @@ -35,7 +44,7 @@ function wrapper(session = buildCheckoutSession()) { setCheckoutErrors, }} > - {children} + {formValues ? {children} : children} ); @@ -84,6 +93,37 @@ describe('useConfirmExpressCheckout', () => { expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(0); }); + it.each([ + { scenario: 'tips are enabled for the session', enableTips: true }, + { scenario: 'tips are disabled for the session', enableTips: false }, + ])('omits the tip when $scenario', async ({ enableTips }) => { + // Express never charges a tip: its wallet sheet is built from the subtotal + // plus the shipping and taxes it calculates in its own event flows. A tip + // the customer typed into the standard form must not ride along. + const session = buildCheckoutSession({ enableTips }); + const draftOrder = buildDraftOrder(); + mockGodaddyApi({ session, draftOrder }); + + const { result } = renderHook(() => useConfirmExpressCheckout(), { + wrapper: wrapper(session, { tipAmount: 1234 }), + }); + + await result.current.mutateAsync({ + paymentToken: 'wallet-nonce', + paymentType: 'apple_pay', + paymentProvider: PaymentProvider.POYNT, + isExpress: true, + }); + + await waitFor(() => { + expect(getOperations('ConfirmCheckoutSession')).toHaveLength(1); + }); + const confirmInput = getOperations('ConfirmCheckoutSession')[0]?.input as + | { tipAmount?: number } + | undefined; + expect(confirmInput?.tipAmount).toBeUndefined(); + }); + it('rejects without confirming while checkout is already confirming', async () => { const session = buildCheckoutSession(); const draftOrder = buildDraftOrder(); diff --git a/packages/react/src/components/checkout/payment/utils/use-stripe-payment-intent.test.tsx b/packages/react/src/components/checkout/payment/utils/use-stripe-payment-intent.test.tsx new file mode 100644 index 00000000..c149b120 --- /dev/null +++ b/packages/react/src/components/checkout/payment/utils/use-stripe-payment-intent.test.tsx @@ -0,0 +1,268 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { FormProvider, useForm, useFormContext } from 'react-hook-form'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + type CheckoutFormData, + checkoutContext, +} from '@/components/checkout/checkout'; +import { useStripePaymentIntent } from '@/components/checkout/payment/utils/use-stripe-payment-intent'; +import { GoDaddyProvider } from '@/godaddy-provider'; +import { + buildCheckoutSession, + createTestQueryClient, +} from '../../__tests__/checkout-test-env'; + +vi.mock('@stripe/stripe-js', () => ({ + loadStripe: vi.fn(() => Promise.resolve({})), +})); + +let totalValue = 2500; + +vi.mock('@/components/checkout/order/use-draft-order', async importOriginal => { + const actual = + await importOriginal< + typeof import('@/components/checkout/order/use-draft-order') + >(); + return { + ...actual, + useDraftOrderTotals: () => ({ + data: { total: { value: totalValue, currencyCode: 'USD' } }, + isLoading: false, + }), + }; +}); + +interface IntentRequest { + url: string; + amount: number; + id?: string; +} + +let requests: IntentRequest[] = []; + +function stubIntentApi() { + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string, init: { body: string }) => { + const body = JSON.parse(init.body); + requests.push({ url: String(url), amount: body.amount, id: body.id }); + + const id = body.id ?? `pi_${requests.length}`; + return { + ok: true, + json: async () => ({ clientSecret: `${id}_secret`, id }), + }; + }) + ); +} + +function Probe({ + enableClientSecret = true, + updateIntent = true, + isExpress = false, +}: { + enableClientSecret?: boolean; + updateIntent?: boolean; + isExpress?: boolean; +}) { + const form = useFormContext(); + const { clientSecret, amount } = useStripePaymentIntent({ + enableClientSecret, + updateIntent, + isExpress, + }); + + return ( + <> +
{clientSecret ?? 'none'}
+
{amount}
+ + + + ); +} + +function Host({ + hostIntent = false, + enableClientSecret = true, + updateIntent = true, + isExpress = false, +}: { + hostIntent?: boolean; + enableClientSecret?: boolean; + updateIntent?: boolean; + isExpress?: boolean; +}) { + const methods = useForm({ + defaultValues: { + tipAmount: 0, + ...(hostIntent + ? { + stripePaymentIntent: 'pi_host_secret', + stripePaymentIntentId: 'pi_host', + } + : {}), + } as Partial, + }); + + return ( + undefined, + checkoutErrors: undefined, + setCheckoutErrors: () => undefined, + }} + > + + + + + ); +} + +function renderProbe(props: Parameters[0] = {}) { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render( + + + + ); + return { user }; +} + +async function waitForClientSecret(value: string) { + await waitFor(() => { + expect(screen.getByTestId('client-secret')).toHaveTextContent(value); + }); +} + +describe('useStripePaymentIntent', () => { + beforeEach(() => { + requests = []; + totalValue = 2500; + stubIntentApi(); + }); + + it('creates the intent for the tip-inclusive amount', async () => { + renderProbe(); + + await waitForClientSecret('pi_1_secret'); + expect(requests).toEqual([ + { url: '/api/create-payment-intent', amount: 2500, id: undefined }, + ]); + }); + + it('leaves the tip out of the express amount', async () => { + // The express wallet sheet is built from the subtotal plus the shipping and + // taxes it calculates itself, and its confirmation records no tip. Charging + // the tip here would take money the order never accounts for. + const { user } = renderProbe({ isExpress: true }); + await waitForClientSecret('pi_1_secret'); + + await user.click(screen.getByTestId('add-tip')); + await waitForClientSecret('pi_1_secret'); + + expect(screen.getByTestId('amount')).toHaveTextContent('2500'); + expect(requests).toEqual([ + { url: '/api/create-payment-intent', amount: 2500, id: undefined }, + ]); + }); + + it('updates the intent when a tip is added after it was created', async () => { + const { user } = renderProbe(); + await waitForClientSecret('pi_1_secret'); + + await user.click(screen.getByTestId('add-tip')); + + await waitFor(() => { + expect(requests).toHaveLength(2); + }); + expect(requests[1]).toEqual({ + url: '/api/update-payment-intent', + amount: 3000, + id: 'pi_1', + }); + expect(screen.getByTestId('amount')).toHaveTextContent('3000'); + }); + + it('recreates the intent for the new amount when updates are disabled', async () => { + const { user } = renderProbe({ updateIntent: false }); + await waitForClientSecret('pi_1_secret'); + + await user.click(screen.getByTestId('add-tip')); + + await waitFor(() => { + expect(requests).toHaveLength(2); + }); + expect(requests[1]).toMatchObject({ + url: '/api/create-payment-intent', + amount: 3000, + }); + await waitForClientSecret('pi_2_secret'); + }); + + it('updates a host-supplied intent when a tip is added', async () => { + const { user } = renderProbe({ hostIntent: true }); + await waitForClientSecret('pi_host_secret'); + expect(requests).toHaveLength(0); + + await user.click(screen.getByTestId('add-tip')); + + await waitFor(() => { + expect(requests).toHaveLength(1); + }); + expect(requests[0]).toEqual({ + url: '/api/update-payment-intent', + amount: 3000, + id: 'pi_host', + }); + }); + + it('adopts a replacement intent supplied by the host', async () => { + const { user } = renderProbe({ hostIntent: true }); + await waitForClientSecret('pi_host_secret'); + + await user.click(screen.getByTestId('replace-host-intent')); + + await waitForClientSecret('pi_host_2_secret'); + expect(requests).toHaveLength(0); + }); + + it('does not touch the intent while the amount is unchanged', async () => { + const { user } = renderProbe(); + await waitForClientSecret('pi_1_secret'); + + await user.click(screen.getByTestId('add-tip')); + await waitFor(() => { + expect(requests).toHaveLength(2); + }); + + await user.click(screen.getByTestId('add-tip')); + await waitForClientSecret('pi_1_secret'); + + expect(requests).toHaveLength(2); + }); +}); diff --git a/packages/react/src/components/checkout/payment/utils/use-stripe-payment-intent.ts b/packages/react/src/components/checkout/payment/utils/use-stripe-payment-intent.ts index 2ec647bc..9a4ccbb2 100644 --- a/packages/react/src/components/checkout/payment/utils/use-stripe-payment-intent.ts +++ b/packages/react/src/components/checkout/payment/utils/use-stripe-payment-intent.ts @@ -21,11 +21,13 @@ function getStripe(publishableKey: string): Promise { type UseStripePaymentIntentOptions = { updateIntent?: boolean; enableClientSecret?: boolean; + isExpress?: boolean; }; export function useStripePaymentIntent({ updateIntent = false, enableClientSecret = false, + isExpress = false, }: UseStripePaymentIntentOptions = {}) { const { session, stripeConfig } = useCheckoutContext(); const form = @@ -33,15 +35,28 @@ export function useStripePaymentIntent({ const draftOrderTotalsQuery = useDraftOrderTotals(); const { data: totals, isLoading: isLoadingTotals } = draftOrderTotalsQuery; - const amount = totals?.total?.value || 0; + const total = totals?.total?.value || 0; + const tipAmount = form?.watch('tipAmount') || 0; + // Express never charges a tip — it builds its own totals in the wallet's + // event handlers and applies them at confirmation — so its amount stays + // tip-free even when the session collects tips for the standard form. + const amount = session?.enableTips && !isExpress ? total + tipAmount : total; const currency = totals?.total?.currencyCode?.toLowerCase() || 'usd'; + const existingClientSecret = form?.watch('stripePaymentIntent'); + const existingIntentId = form?.watch('stripePaymentIntentId'); + const [stripePromise, setStripePromise] = useState | null>(null); const [clientSecret, setClientSecret] = useState(null); const [intentId, setIntentId] = useState(null); const [error, setError] = useState(null); + const syncedIntentRef = useRef<{ + clientSecret: string; + amount: number; + } | null>(null); + useEffect(() => { if (stripeConfig?.publishableKey?.trim()) { setStripePromise(getStripe(stripeConfig.publishableKey)); @@ -83,13 +98,21 @@ export function useStripePaymentIntent({ return res.json(); }, onMutate: () => { + syncedIntentRef.current = null; setClientSecret(null); setIntentId(null); form?.setValue('stripePaymentIntent', undefined); form?.setValue('stripePaymentIntentId', undefined); setError(null); }, - onSuccess: ({ clientSecret: responseClientSecret, id: responseId }) => { + onSuccess: ( + { clientSecret: responseClientSecret, id: responseId }, + variables + ) => { + syncedIntentRef.current = { + clientSecret: responseClientSecret, + amount: variables.amount, + }; setClientSecret(responseClientSecret); setIntentId(responseId); form?.setValue('stripePaymentIntent', responseClientSecret); @@ -108,14 +131,23 @@ export function useStripePaymentIntent({ isCreatingPaymentIntent; const initializePaymentIntent = useCallback(() => { - const existingClientSecret = form?.getValues('stripePaymentIntent'); - const existingIntentId = form?.getValues('stripePaymentIntentId'); - if (existingClientSecret && existingIntentId) { - setClientSecret(existingClientSecret); - setIntentId(existingIntentId); - setError(null); - return; + // An intent we haven't seen yet: adopt it for the current amount. + if (syncedIntentRef.current?.clientSecret !== existingClientSecret) { + syncedIntentRef.current = { + clientSecret: existingClientSecret, + amount, + }; + setClientSecret(existingClientSecret); + setIntentId(existingIntentId); + setError(null); + return; + } + + // The intent already covers this amount. + if (syncedIntentRef.current.amount === amount) { + return; + } } if (isLoading || !enableClientSecret) { @@ -126,7 +158,7 @@ export function useStripePaymentIntent({ amount, currency, updateIntent, - intentId, + intentId: existingIntentId ?? intentId, }); }, [ amount, @@ -134,7 +166,8 @@ export function useStripePaymentIntent({ updateIntent, intentId, isLoading, - form, + existingClientSecret, + existingIntentId, paymentIntentMutation.mutate, enableClientSecret, ]); @@ -142,11 +175,18 @@ export function useStripePaymentIntent({ const amountRef = useRef(null); useEffect(() => { - if (amountRef.current !== amount && !isLoading) { + if (isLoading) { + return; + } + + const isIntentStale = + syncedIntentRef.current?.clientSecret !== existingClientSecret; + + if (amountRef.current !== amount || isIntentStale) { initializePaymentIntent(); amountRef.current = amount; } - }, [initializePaymentIntent, amount, isLoading]); + }, [initializePaymentIntent, amount, isLoading, existingClientSecret]); return { stripePromise, diff --git a/packages/react/src/components/checkout/tips/tips-form.test.tsx b/packages/react/src/components/checkout/tips/tips-form.test.tsx new file mode 100644 index 00000000..7d7a43e2 --- /dev/null +++ b/packages/react/src/components/checkout/tips/tips-form.test.tsx @@ -0,0 +1,235 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { type ComponentProps, useState } from 'react'; +import { FormProvider, useForm, useFormContext } from 'react-hook-form'; +import { describe, expect, it, vi } from 'vitest'; +import { checkoutContext } from '@/components/checkout/checkout'; +import { TipsForm } from '@/components/checkout/tips/tips-form'; +import { GoDaddyProvider } from '@/godaddy-provider'; +import { + buildCheckoutSession, + createTestQueryClient, +} from '../__tests__/checkout-test-env'; + +type TipsOptions = ComponentProps['options']; + +/** + * The subtotal is owned by the harness rather than by a fixture, because these + * tests are about what happens to a selection when the subtotal moves under it — + * routine, since the tips section renders before the draft-order totals resolve. + */ +function Harness({ + initialSubtotal, + nextSubtotal, + options, + isTotalsLoading = false, +}: { + initialSubtotal: number; + nextSubtotal: number; + options?: TipsOptions; + /** The draft order landing is what moves the subtotal, so it ends the load. */ + isTotalsLoading?: boolean; +}) { + const [subtotal, setSubtotal] = useState(initialSubtotal); + const [totalsLoading, setTotalsLoading] = useState(isTotalsLoading); + const form = useForm({ defaultValues: { tipAmount: 0 } }); + + return ( + undefined, + checkoutErrors: undefined, + setCheckoutErrors: () => undefined, + }} + > + + + + + + + ); +} + +/** Exposes the values that actually get charged. */ +function TipState() { + const form = useFormContext(); + return ( + <> +
{String(form.watch('tipAmount'))}
+
+ {String(form.watch('tipPercentage'))} +
+ + ); +} + +function renderTipsForm(props: ComponentProps) { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render( + + + + ); + return { user }; +} + +describe('TipsForm when the subtotal moves under a selection', () => { + it('re-derives what a percentage preset is worth', async () => { + // The totals had not arrived when the customer picked a tip, so 20% of the + // subtotal was 20% of nothing. + const { user } = renderTipsForm({ + initialSubtotal: 0, + nextSubtotal: 2500, + isTotalsLoading: true, + }); + + await user.click(screen.getByRole('radio', { name: /20%/ })); + expect(screen.getByTestId('tip-amount')).toHaveTextContent('0'); + + await user.click(screen.getByTestId('move-subtotal')); + + // What the button reads is what gets charged. + const preset = screen.getByRole('radio', { name: /20%/ }); + expect(preset).toHaveTextContent('$5.00'); + expect(preset).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByTestId('tip-amount')).toHaveTextContent('500'); + }); + + it('leaves a fixed-amount preset alone', async () => { + const { user } = renderTipsForm({ + initialSubtotal: 2500, + nextSubtotal: 5000, + options: { + default: { amounts: [300, 500, 700], percentages: null }, + thresholds: null, + }, + }); + + await user.click(screen.getByRole('radio', { name: /\$5\.00/ })); + expect(screen.getByTestId('tip-amount')).toHaveTextContent('500'); + + await user.click(screen.getByTestId('move-subtotal')); + + // A fixed amount is not a proportion of anything, so it does not move. + expect(screen.getByTestId('tip-amount')).toHaveTextContent('500'); + expect(screen.getByRole('radio', { name: /\$5\.00/ })).toHaveAttribute( + 'aria-checked', + 'true' + ); + }); + + it('keeps the selected percentage checked when a threshold swaps the presets', async () => { + const { user } = renderTipsForm({ + initialSubtotal: 2500, + nextSubtotal: 5000, + options: { + default: { percentages: [15, 18, 20], amounts: null }, + thresholds: [ + { + minSubtotal: 5000, + maxSubtotal: null, + percentages: [20, 25, 30], + amounts: null, + }, + ], + }, + }); + + // 20% is the last preset before the threshold and the first one after it, so + // the index the customer clicked no longer points at their choice. + await user.click(screen.getByRole('radio', { name: /20%/ })); + + await user.click(screen.getByTestId('move-subtotal')); + + expect(screen.getByTestId('tip-percentage')).toHaveTextContent('20'); + expect(screen.getByRole('radio', { name: /20%/ })).toHaveAttribute( + 'aria-checked', + 'true' + ); + expect(screen.getByRole('radio', { name: /25%/ })).toHaveAttribute( + 'aria-checked', + 'false' + ); + expect(screen.getByTestId('tip-amount')).toHaveTextContent('1000'); + }); +}); + +describe('TipsForm presets on a zero subtotal', () => { + it('drops the percentage presets and still offers a custom amount', async () => { + // Every percentage of nothing is nothing, so the presets would be $0.00 + // buttons that leave the tip at zero when picked. + const { user } = renderTipsForm({ initialSubtotal: 0, nextSubtotal: 0 }); + + expect( + screen.queryByRole('radio', { name: /15%/ }) + ).not.toBeInTheDocument(); + expect(screen.queryByRole('radio', { name: /%/ })).not.toBeInTheDocument(); + + // A tip is still possible, it just cannot be a proportion of the subtotal. + await user.click(screen.getByRole('radio', { name: /custom amount/i })); + const input = await screen.findByPlaceholderText('0.00'); + await user.type(input, '5'); + await user.tab(); + + expect(screen.getByTestId('tip-amount')).toHaveTextContent('500'); + }); + + it('keeps the percentage presets while the totals load', async () => { + renderTipsForm({ + initialSubtotal: 0, + nextSubtotal: 2500, + isTotalsLoading: true, + }); + + // Hiding them here would flash them in once the draft order lands. + expect(screen.getByRole('radio', { name: /15%/ })).toHaveTextContent( + '$0.00' + ); + }); + + it('drops the percentage presets when the totals land on a zero subtotal', async () => { + const { user } = renderTipsForm({ + initialSubtotal: 0, + nextSubtotal: 0, + isTotalsLoading: true, + }); + + expect(screen.getByRole('radio', { name: /15%/ })).toBeInTheDocument(); + + await user.click(screen.getByTestId('move-subtotal')); + + expect( + screen.queryByRole('radio', { name: /15%/ }) + ).not.toBeInTheDocument(); + }); + + it('keeps fixed-amount presets, which are worth what they say', async () => { + renderTipsForm({ + initialSubtotal: 0, + nextSubtotal: 0, + options: { + default: { amounts: [300, 500, 700], percentages: null }, + thresholds: null, + }, + }); + + expect(screen.getByRole('radio', { name: /\$5\.00/ })).toBeInTheDocument(); + }); +}); diff --git a/packages/react/src/components/checkout/tips/tips-form.tsx b/packages/react/src/components/checkout/tips/tips-form.tsx index e9523110..3cc2f29c 100644 --- a/packages/react/src/components/checkout/tips/tips-form.tsx +++ b/packages/react/src/components/checkout/tips/tips-form.tsx @@ -2,6 +2,7 @@ import { useDebouncedValue } from '@tanstack/react-pacer'; import { useEffect, useRef, useState } from 'react'; import { useFormContext } from 'react-hook-form'; import { useCheckoutContext } from '@/components/checkout/checkout'; +import { TIP_SERVER_ERROR_TYPE } from '@/components/checkout/tips/utils/tip-field-errors'; import { convertMajorToMinorUnits, currencyConfigs, @@ -21,27 +22,91 @@ import { useGoDaddyContext } from '@/godaddy-provider'; import { cn } from '@/lib/utils'; import { eventIds } from '@/tracking/events'; import { TrackingEventType, track } from '@/tracking/track'; +import { type CheckoutSession } from '@/types'; interface TipsFormProps { - total: number; + subtotal: number; + options?: CheckoutSession['tips']; currencyCode?: string; + /** The subtotal arrives with the draft order, so it reads as 0 until then. */ + isTotalsLoading?: boolean; } -export function TipsForm({ total, currencyCode }: TipsFormProps) { +const DEFAULT_TIP_PERCENTAGES = [15, 18, 20]; + +/** `subtotal` is in minor units, so the tip is too. */ +function percentageToAmount(subtotal: number, percentage: number): number { + return Math.round((subtotal * percentage) / 100); +} + +/** + * Which preset index counts as selected. + * + * The clicked index wins, since that is what tells two presets of the same value + * apart — but only while it still holds the selected value. It stops doing so + * when the subtotal crosses a threshold and swaps the list out from under it, + * and it was never set at all for a tip the host app preselected. Both fall back + * to matching by value. + */ +function resolveActiveIndex( + clickedIndex: number | null, + presets: readonly (number | null | undefined)[] | null | undefined, + value: unknown +): number { + if (!presets) return -1; + if (clickedIndex != null && presets[clickedIndex] === value) { + return clickedIndex; + } + return presets.indexOf(value as number); +} + +// A library cannot assume `process` exists, and bundlers replace this expression +// at build time, so the warning below is compiled out of production apps. +const IS_DEV = + typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production'; + +export function TipsForm({ + subtotal, + options, + currencyCode, + isTotalsLoading = false, +}: TipsFormProps) { const { t } = useGoDaddyContext(); const form = useFormContext(); const formatCurrency = useFormatCurrency(); const [showCustomTip, setShowCustomTip] = useState(false); + // Which preset the customer picked. Selection is matched by index as well as + // by value so a merchant that lists the same amount twice does not light up + // both buttons; the form value stays authoritative. + const [selectedIndex, setSelectedIndex] = useState(null); + + const calculateTipAmount = (percentage: number): number => + percentageToAmount(subtotal, percentage); - const calculateTipAmount = (percentage: number): number => { - // total is in minor units, so calculate percentage and return in minor units - return Math.round((total * percentage) / 100); + const handleAmountSelect = (amount: number, index: number) => { + form.setValue('tipAmount', amount); + form.setValue('tipPercentage', null); + setSelectedIndex(index); + setShowCustomTip(false); + + // Track tip amount selection + track({ + eventId: eventIds.selectTipAmount, + type: TrackingEventType.CLICK, + properties: { + tipPercentage: null, + tipAmount: amount, + totalBeforeTip: subtotal, + currencyCode, + }, + }); }; - const handlePercentageSelect = (percentage: number) => { + const handlePercentageSelect = (percentage: number, index: number) => { const tipAmount = calculateTipAmount(percentage); form.setValue('tipAmount', tipAmount); form.setValue('tipPercentage', percentage); + setSelectedIndex(index); setShowCustomTip(false); // Track tip percentage selection @@ -51,7 +116,7 @@ export function TipsForm({ total, currencyCode }: TipsFormProps) { properties: { tipPercentage: percentage, tipAmount: tipAmount, - totalBeforeTip: total, + totalBeforeTip: subtotal, currencyCode, }, }); @@ -60,6 +125,7 @@ export function TipsForm({ total, currencyCode }: TipsFormProps) { const handleNoTip = () => { form.setValue('tipAmount', 0); form.setValue('tipPercentage', 0); + setSelectedIndex(null); setShowCustomTip(false); // Track no tip selection @@ -69,14 +135,18 @@ export function TipsForm({ total, currencyCode }: TipsFormProps) { properties: { tipPercentage: 0, tipAmount: 0, - totalBeforeTip: total, + totalBeforeTip: subtotal, currencyCode, }, }); }; const handleCustomTip = () => { + const currentTipAmount = form.getValues('tipAmount') || 0; + setShowCustomTip(true); + setSelectedIndex(null); + form.setValue('tipAmount', currentTipAmount); form.setValue('tipPercentage', null); // Track custom tip selection @@ -84,47 +154,182 @@ export function TipsForm({ total, currencyCode }: TipsFormProps) { eventId: eventIds.enterCustomTip, type: TrackingEventType.CLICK, properties: { - totalBeforeTip: total, + totalBeforeTip: subtotal, currencyCode, }, }); }; - const tipPercentages = [15, 18, 20]; const tipPercentage = form.watch('tipPercentage'); + let tipPercentages = options?.default?.percentages; + + const tipAmount = form.watch('tipAmount'); + let tipAmounts = options?.default?.amounts; + + const matchingThresholds = + options?.thresholds?.filter( + thres => + (thres?.minSubtotal == null || subtotal >= thres.minSubtotal) && + (thres?.maxSubtotal == null || subtotal <= thres.maxSubtotal) + ) ?? []; + const threshold = matchingThresholds[0]; + const matchCount = matchingThresholds.length; + + // Overlapping ranges make the order of `thresholds` load-bearing, which is + // never what a merchant intends and is invisible at runtime — the first match + // simply wins. Warned about in development so the config gets fixed rather + // than the array quietly reordered later. + useEffect(() => { + if (!IS_DEV || matchCount <= 1) return; + + // biome-ignore lint/suspicious/noConsole: a misconfiguration only the developer integrating the SDK can fix, and only reachable in development + console.warn( + `[godaddy-checkout] tips.thresholds has ${matchCount} entries matching a subtotal of ${subtotal}. The first match is used; overlapping ranges make the array order significant.` + ); + }, [matchCount, subtotal]); + + if (threshold) { + if (threshold.amounts?.length) { + tipAmounts = threshold.amounts; + tipPercentages = undefined; + } else if (threshold.percentages?.length) { + tipPercentages = threshold.percentages; + tipAmounts = undefined; + } + } + + const percentagePresets = tipPercentages?.length + ? tipPercentages + : DEFAULT_TIP_PERCENTAGES; + + // Percentages of a zero subtotal are all worth nothing, so the presets would be + // $0.00 buttons that do nothing when picked; Custom Amount still tips. Fixed + // amounts are worth what they say. A subtotal still loading keeps the presets + // rather than flashing them in once the draft order lands. + const showAmountPresets = Boolean(tipAmounts?.length); + const showPercentagePresets = + !showAmountPresets && (isTotalsLoading || subtotal > 0); + + const activeAmountIndex = resolveActiveIndex( + selectedIndex, + tipAmounts, + tipAmount + ); + const activePercentageIndex = resolveActiveIndex( + selectedIndex, + percentagePresets, + tipPercentage + ); + + // A rejection the API attributed to `tipAmount` (TIP_EXCEEDS_LIMIT and + // friends) is shown here rather than only in the checkout-wide error list, so + // the customer can see which field to fix. + const tipFieldError = form.formState.errors.tipAmount; + + // Ref to avoid `form` (unstable reference) in the dependency array. + const formRef = useRef(form); + formRef.current = form; + + // A percentage preset is worth whatever it is worth now. The amount shown under + // the button is recomputed from the current subtotal on every render, so form + // state has to follow it — otherwise a preset picked before the draft-order + // totals arrived stays worth a percentage of nothing while displaying, and + // reporting as selected, the amount it would be worth today. + useEffect(() => { + const percentage = formRef.current.getValues('tipPercentage'); + if (!percentage) return; + + const nextTipAmount = percentageToAmount(subtotal, percentage); + if (formRef.current.getValues('tipAmount') !== nextTipAmount) { + formRef.current.setValue('tipAmount', nextTipAmount); + } + }, [subtotal]); + + // That rejection goes stale as soon as the customer picks a different amount, + // and react-hook-form leaves manually-set errors in place on its own. + useEffect(() => { + if ( + formRef.current.formState.errors.tipAmount?.type === TIP_SERVER_ERROR_TYPE + ) { + formRef.current.clearErrors('tipAmount'); + } + }, [tipAmount]); return (
-
- {tipPercentages.map(percentage => ( - + ); + }) + : percentagePresets.map((percentage, index) => { + const isSelected = + tipPercentage === percentage && + index === activePercentageIndex; + + return ( + + ); })} - - - ))} -
+ + ) : null}
- {showCustomTip && ( + {showCustomTip ? ( + ) : ( + // When the custom input is open its own FormMessage renders this, wired + // to the input via aria-describedby. + tipFieldError?.message && ( +

+ {String(tipFieldError.message)} +

+ ) )}
); @@ -181,7 +404,7 @@ export function TipsForm({ total, currencyCode }: TipsFormProps) { */ interface CustomTipInputProps { currencyCode?: string; - total: number; + subtotal: number; formatCurrency: (options: FormatCurrencyOptions) => string; } @@ -216,7 +439,7 @@ function symbolPadding(symbol: string, position: 'prefix' | 'suffix') { function CustomTipInput({ currencyCode, - total, + subtotal, formatCurrency, }: CustomTipInputProps) { const { t } = useGoDaddyContext(); @@ -278,6 +501,10 @@ function CustomTipInput({ }); }; + // Ref to avoid `form` (unstable reference) in the dependency array. + const formRef = useRef(form); + formRef.current = form; + // When the debounced value settles and the input is still focused, // sync to form state and format the display — the same effect as blur // but triggered by 1.5s of inactivity. This keeps the order summary @@ -285,11 +512,11 @@ function CustomTipInput({ useEffect(() => { if (!isFocused.current || debouncedLocal === null) return; const tipAmount = convertMajorToMinorUnits(debouncedLocal ?? '', code); - form.setValue('tipAmount', tipAmount); + formRef.current.setValue('tipAmount', tipAmount); // Clear local state so the display derives from the formatted form // value (e.g. "10.5" → "10.50"), same as the blur handler. setLocalValue(null); - }, [debouncedLocal, code, form]); + }, [debouncedLocal, code]); const symbolEl = ( 0 - ? Number(((tipAmount / total) * 100).toFixed(2)) + subtotal > 0 + ? Number(((tipAmount / subtotal) * 100).toFixed(2)) : 0, currencyCode, }, diff --git a/packages/react/src/components/checkout/tips/utils/tip-field-errors.ts b/packages/react/src/components/checkout/tips/utils/tip-field-errors.ts new file mode 100644 index 00000000..efac2ad3 --- /dev/null +++ b/packages/react/src/components/checkout/tips/utils/tip-field-errors.ts @@ -0,0 +1,77 @@ +import type { UseFormReturn } from 'react-hook-form'; +import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors'; + +/** + * Marks a tip error as set from a server response rather than by the form + * resolver, so `TipsForm` can clear it once the customer changes the amount — + * react-hook-form only clears the errors its own resolver produced. + */ +export const TIP_SERVER_ERROR_TYPE = 'server'; + +/** Copy for a rejection the API did not attribute, where the tip is the charge. */ +export const TIP_CHARGE_FAILED_CODE = 'TIP_CHARGE_FAILED'; + +/** + * Attach a tip rejection to the tip field. + * + * The API tags its tip errors (`TIP_EXCEEDS_LIMIT`, `INVALID_TIP_AMOUNT`, + * `TIPS_NOT_ENABLED`) with `extensions.path: ['tipAmount']`, so the field is + * taken from the response rather than an allow-list of codes that would have to + * be kept in step with the API. + * + * The error also stays in the checkout-wide list, which scrolls itself into view + * and covers the case where the tip section is not rendered at all. + * + * @param translate resolves an error code to localized copy + * @returns true when the error was attributed to the tip field + */ +export function applyTipFieldError( + form: Pick | null | undefined, + error: unknown, + translate: (code: string) => string | undefined +): boolean { + if (!form || !(error instanceof GraphQLErrorWithCodes)) return false; + + const tipError = error.errors.find(item => item.path?.[0] === 'tipAmount'); + if (!tipError) return false; + + form.setError('tipAmount', { + type: TIP_SERVER_ERROR_TYPE, + // The API message is developer-facing and untranslated, so prefer localized + // copy for the code and fall back to the bare code, matching what + // CheckoutErrorList renders for an unmapped code. + message: (tipError.code && translate(tipError.code)) || tipError.code, + }); + + return true; +} + +/** + * Blame the tip field for a rejection the API did not attribute itself. + * + * Only when the tip is the whole charge. Nothing is owed on a zero-total order, + * so the tip is both the only amount being charged and the only one the customer + * can change — a processor minimum (Stripe's is around $0.50) rejects a small + * one, and so would any ordinary decline. Which of those it was stays in the + * checkout-wide error list; this only points at the field to change, and the copy + * claims no cause it cannot know. + * + * @param orderTotal the live order total, tip excluded + * @param translate resolves an error code to localized copy + * @returns true when the error was attributed to the tip field + */ +export function applyTipOnlyChargeError( + form: Pick | null | undefined, + orderTotal: number, + translate: (code: string) => string | undefined +): boolean { + if (!form || orderTotal > 0) return false; + if ((form.getValues('tipAmount') || 0) <= 0) return false; + + form.setError('tipAmount', { + type: TIP_SERVER_ERROR_TYPE, + message: translate(TIP_CHARGE_FAILED_CODE) || TIP_CHARGE_FAILED_CODE, + }); + + return true; +} diff --git a/packages/react/src/components/checkout/totals/utils/use-is-order-free.ts b/packages/react/src/components/checkout/totals/utils/use-is-order-free.ts deleted file mode 100644 index 9c103eca..00000000 --- a/packages/react/src/components/checkout/totals/utils/use-is-order-free.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { useDraftOrderTotals } from '@/components/checkout/order/use-draft-order'; - -export function useCheckIsOrderFree() { - const { data: totals, isLoading } = useDraftOrderTotals(); - - /* TODO: Will need logic for handling tips */ - return { - isFree: totals?.total?.value === 0, - isLoading, - }; -} diff --git a/packages/react/src/components/ui/button.tsx b/packages/react/src/components/ui/button.tsx index 25d70383..ae374c7a 100644 --- a/packages/react/src/components/ui/button.tsx +++ b/packages/react/src/components/ui/button.tsx @@ -6,7 +6,7 @@ import { useCheckoutContext } from '@/components/checkout/checkout'; import { cn } from '@/lib/utils'; const buttonVariants = cva( - 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', + 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', { variants: { variant: { diff --git a/packages/react/src/lib/godaddy/checkout-env.ts b/packages/react/src/lib/godaddy/checkout-env.ts index c9ab79e9..65038eb1 100644 --- a/packages/react/src/lib/godaddy/checkout-env.ts +++ b/packages/react/src/lib/godaddy/checkout-env.ts @@ -2139,6 +2139,15 @@ const introspection = { "args": [], "isDeprecated": false }, + { + "name": "tips", + "type": { + "kind": "OBJECT", + "name": "CheckoutSessionTips" + }, + "args": [], + "isDeprecated": false + }, { "name": "token", "type": { @@ -2970,6 +2979,80 @@ const introspection = { ], "isOneOf": false }, + { + "kind": "OBJECT", + "name": "CheckoutSessionFee", + "fields": [ + { + "name": "amount", + "type": { + "kind": "SCALAR", + "name": "Int" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "feeProgramId", + "type": { + "kind": "SCALAR", + "name": "String" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "feeProgramType", + "type": { + "kind": "SCALAR", + "name": "String" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "feeType", + "type": { + "kind": "SCALAR", + "name": "String" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "signature", + "type": { + "kind": "SCALAR", + "name": "String" + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, + { + "kind": "OBJECT", + "name": "CheckoutSessionFeesResult", + "fields": [ + { + "name": "fees", + "type": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "CheckoutSessionFee" + } + } + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, { "kind": "OBJECT", "name": "CheckoutSessionFreeShippingRule", @@ -3999,6 +4082,242 @@ const introspection = { ], "isOneOf": false }, + { + "kind": "OBJECT", + "name": "CheckoutSessionTips", + "fields": [ + { + "name": "default", + "type": { + "kind": "OBJECT", + "name": "CheckoutSessionTipsDefault" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "thresholds", + "type": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "CheckoutSessionTipsThreshold" + } + } + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, + { + "kind": "OBJECT", + "name": "CheckoutSessionTipsDefault", + "fields": [ + { + "name": "amounts", + "type": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Int" + } + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "percentages", + "type": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Int" + } + } + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, + { + "kind": "INPUT_OBJECT", + "name": "CheckoutSessionTipsDefaultInput", + "inputFields": [ + { + "name": "amounts", + "type": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Int" + } + } + } + }, + { + "name": "percentages", + "type": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Int" + } + } + } + } + ], + "isOneOf": false + }, + { + "kind": "INPUT_OBJECT", + "name": "CheckoutSessionTipsInput", + "inputFields": [ + { + "name": "default", + "type": { + "kind": "INPUT_OBJECT", + "name": "CheckoutSessionTipsDefaultInput" + } + }, + { + "name": "thresholds", + "type": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CheckoutSessionTipsThresholdInput" + } + } + } + } + ], + "isOneOf": false + }, + { + "kind": "OBJECT", + "name": "CheckoutSessionTipsThreshold", + "fields": [ + { + "name": "amounts", + "type": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Int" + } + } + }, + "args": [], + "isDeprecated": false + }, + { + "name": "maxSubtotal", + "type": { + "kind": "SCALAR", + "name": "Int" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "minSubtotal", + "type": { + "kind": "SCALAR", + "name": "Int" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "percentages", + "type": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Int" + } + } + }, + "args": [], + "isDeprecated": false + } + ], + "interfaces": [] + }, + { + "kind": "INPUT_OBJECT", + "name": "CheckoutSessionTipsThresholdInput", + "inputFields": [ + { + "name": "amounts", + "type": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Int" + } + } + } + }, + { + "name": "maxSubtotal", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Int" + } + } + }, + { + "name": "minSubtotal", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Int" + } + } + }, + { + "name": "percentages", + "type": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Int" + } + } + } + } + ], + "isOneOf": false + }, { "kind": "OBJECT", "name": "CheckoutSessionTotalTaxAmount", @@ -6334,6 +6653,46 @@ const introspection = { ], "isOneOf": false }, + { + "kind": "ENUM", + "name": "FeeProgramType", + "enumValues": [ + { + "name": "CASH_DISCOUNT", + "isDeprecated": false + }, + { + "name": "CONVENIENCE_FEE", + "isDeprecated": false + }, + { + "name": "SERVICE_FEE", + "isDeprecated": false + }, + { + "name": "SURCHARGE", + "isDeprecated": false + } + ] + }, + { + "kind": "ENUM", + "name": "FeeType", + "enumValues": [ + { + "name": "FIXED", + "isDeprecated": false + }, + { + "name": "HYBRID", + "isDeprecated": false + }, + { + "name": "PERCENTAGE", + "isDeprecated": false + } + ] + }, { "kind": "SCALAR", "name": "Float" @@ -6408,6 +6767,24 @@ const introspection = { } ] }, + { + "kind": "ENUM", + "name": "FundingSourceType", + "enumValues": [ + { + "name": "CREDIT", + "isDeprecated": false + }, + { + "name": "DEBIT", + "isDeprecated": false + }, + { + "name": "PREPAID", + "isDeprecated": false + } + ] + }, { "kind": "OBJECT", "name": "GeoCoordinates", @@ -7397,6 +7774,26 @@ const introspection = { ], "isDeprecated": false }, + { + "name": "calculateCheckoutSessionFees", + "type": { + "kind": "OBJECT", + "name": "CheckoutSessionFeesResult" + }, + "args": [ + { + "name": "fundingSourceType", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "ENUM", + "name": "FundingSourceType" + } + } + } + ], + "isDeprecated": false + }, { "name": "calculateCheckoutSessionTaxes", "type": { @@ -7680,6 +8077,19 @@ const introspection = { "kind": "INPUT_OBJECT", "name": "MutationAuthorizeCheckoutSessionInput", "inputFields": [ + { + "name": "fees", + "type": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "INPUT_OBJECT", + "name": "TransactionFeeInput" + } + } + } + }, { "name": "paymentProvider", "type": { @@ -7706,6 +8116,13 @@ const introspection = { "name": "String" } } + }, + { + "name": "tipAmount", + "type": { + "kind": "SCALAR", + "name": "Int" + } } ], "isOneOf": false @@ -7735,6 +8152,19 @@ const introspection = { "name": "CalculatedTaxesInput" } }, + { + "name": "fees", + "type": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "INPUT_OBJECT", + "name": "TransactionFeeInput" + } + } + } + }, { "name": "fulfillmentEndAt", "type": { @@ -7819,6 +8249,13 @@ const introspection = { "kind": "INPUT_OBJECT", "name": "MoneyInput" } + }, + { + "name": "tipAmount", + "type": { + "kind": "SCALAR", + "name": "Int" + } } ], "isOneOf": false @@ -8091,6 +8528,13 @@ const introspection = { "name": "CheckoutSessionTaxesOptionsInput" } }, + { + "name": "tips", + "type": { + "kind": "INPUT_OBJECT", + "name": "CheckoutSessionTipsInput" + } + }, { "name": "url", "type": { @@ -8523,6 +8967,13 @@ const introspection = { "name": "String" } }, + { + "name": "tips", + "type": { + "kind": "INPUT_OBJECT", + "name": "CheckoutSessionTipsInput" + } + }, { "name": "url", "type": { @@ -10844,6 +11295,57 @@ const introspection = { ], "interfaces": [] }, + { + "kind": "INPUT_OBJECT", + "name": "TransactionFeeInput", + "inputFields": [ + { + "name": "amount", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Int" + } + } + }, + { + "name": "feeProgramType", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "ENUM", + "name": "FeeProgramType" + } + } + }, + { + "name": "feeType", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "ENUM", + "name": "FeeType" + } + } + }, + { + "name": "required", + "type": { + "kind": "SCALAR", + "name": "Boolean" + } + }, + { + "name": "signature", + "type": { + "kind": "SCALAR", + "name": "String" + } + } + ], + "isOneOf": false + }, { "kind": "OBJECT", "name": "TransactionFundingSource", diff --git a/packages/react/src/lib/godaddy/checkout-mutations.ts b/packages/react/src/lib/godaddy/checkout-mutations.ts index 39f4780a..89c49081 100644 --- a/packages/react/src/lib/godaddy/checkout-mutations.ts +++ b/packages/react/src/lib/godaddy/checkout-mutations.ts @@ -16,6 +16,18 @@ export const CreateCheckoutSessionMutation = graphql(` storeName environment enableTips + tips { + default { + amounts + percentages + } + thresholds { + minSubtotal + maxSubtotal + amounts + percentages + } + } enabledLocales enableSurcharge enableLocalPickup @@ -394,10 +406,10 @@ export const ApplyCheckoutSessionDiscountMutation = graphql(` export const ConfirmCheckoutSessionMutation = graphql(` mutation ConfirmCheckoutSession($input: MutationConfirmCheckoutSessionInput!, $sessionId: String!) { - confirmCheckoutSession(input: $input, sessionId: $sessionId) { - status - } + confirmCheckoutSession(input: $input, sessionId: $sessionId) { + status } + } `); export const ApplyCheckoutSessionShippingMethodMutation = graphql(` diff --git a/packages/react/src/lib/godaddy/checkout-queries.ts b/packages/react/src/lib/godaddy/checkout-queries.ts index 4e3e6185..5c3315b0 100644 --- a/packages/react/src/lib/godaddy/checkout-queries.ts +++ b/packages/react/src/lib/godaddy/checkout-queries.ts @@ -16,6 +16,18 @@ export const GetCheckoutSessionQuery = graphql(` storeName environment enableTips + tips { + default { + amounts + percentages + } + thresholds { + minSubtotal + maxSubtotal + amounts + percentages + } + } enabledLocales enableSurcharge enableLocalPickup diff --git a/packages/react/src/lib/graphql-with-errors.ts b/packages/react/src/lib/graphql-with-errors.ts index 94ffb4b1..78a078b1 100644 --- a/packages/react/src/lib/graphql-with-errors.ts +++ b/packages/react/src/lib/graphql-with-errors.ts @@ -7,9 +7,10 @@ import { // Define the shape of GraphQL errors explicitly export class GraphQLErrorWithCodes< - T extends { message?: string; code?: string } = { + T extends { message?: string; code?: string; path?: string[] } = { message?: string; code?: string; + path?: string[]; }, > extends Error { constructor(public errors: T[]) { @@ -47,6 +48,11 @@ export async function graphqlRequestWithErrors( const parsedErrors = err.response.errors.map(e => ({ message: e.message as string, code: e.extensions?.code as string, + // The input path the API blamed, e.g. `['tipAmount']`. Read from + // `extensions` rather than the GraphQL `path`, which points at the + // response field. Lets a caller attach the error to that form field + // instead of only the checkout-wide error list. + path: e.extensions?.path as string[] | undefined, })); throw new GraphQLErrorWithCodes(parsedErrors); } diff --git a/packages/react/src/lib/redirect-tip-storage.test.ts b/packages/react/src/lib/redirect-tip-storage.test.ts new file mode 100644 index 00000000..40a7043d --- /dev/null +++ b/packages/react/src/lib/redirect-tip-storage.test.ts @@ -0,0 +1,182 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + clearRedirectTipAmount, + getRedirectTipAmount, + setRedirectTipAmount, +} from './redirect-tip-storage'; + +const KEY_PREFIX = 'godaddy-checkout-redirect-tip'; +const keyFor = (sessionId: string) => `${KEY_PREFIX}:${sessionId}`; + +function clearAll() { + window.sessionStorage.clear(); + window.localStorage.clear(); +} + +describe('redirect tip storage', () => { + beforeEach(clearAll); + + afterEach(() => { + vi.restoreAllMocks(); + clearAll(); + }); + + it('round-trips a tip for the session it was saved for', () => { + expect(setRedirectTipAmount('session-1', 500)).toBe(true); + + expect(getRedirectTipAmount('session-1')).toBe(500); + }); + + it('saves a zero tip distinctly from nothing saved', () => { + setRedirectTipAmount('session-1', 0); + + expect(getRedirectTipAmount('session-1')).toBe(0); + }); + + it('returns null when nothing was saved', () => { + expect(getRedirectTipAmount('session-1')).toBeNull(); + }); + + it('returns null for a different session id', () => { + setRedirectTipAmount('session-1', 500); + + expect(getRedirectTipAmount('session-2')).toBeNull(); + }); + + it('keeps a tip a later session in the same tab saved alongside it', () => { + setRedirectTipAmount('session-1', 500); + setRedirectTipAmount('session-2', 750); + + expect(getRedirectTipAmount('session-1')).toBe(500); + expect(getRedirectTipAmount('session-2')).toBe(750); + }); + + it('ignores a request without a session id', () => { + expect(setRedirectTipAmount('', 500)).toBe(false); + + expect(getRedirectTipAmount('')).toBeNull(); + }); + + it('returns null for unparsable stored data', () => { + window.sessionStorage.setItem(keyFor('session-1'), 'not-json'); + + expect(getRedirectTipAmount('session-1')).toBeNull(); + }); + + it('returns null when the stored tip is not a number', () => { + window.sessionStorage.setItem( + keyFor('session-1'), + JSON.stringify({ tipAmount: '500', savedAt: Date.now() }) + ); + + expect(getRedirectTipAmount('session-1')).toBeNull(); + }); + + it('clears the saved tip', () => { + setRedirectTipAmount('session-1', 500); + clearRedirectTipAmount('session-1'); + + expect(getRedirectTipAmount('session-1')).toBeNull(); + }); + + it('clears the tip from every store it was mirrored to', () => { + setRedirectTipAmount('session-1', 500); + clearRedirectTipAmount('session-1'); + + expect(window.sessionStorage.getItem(keyFor('session-1'))).toBeNull(); + expect(window.localStorage.getItem(keyFor('session-1'))).toBeNull(); + }); + + it('overwrites the tip saved for an earlier redirect', () => { + setRedirectTipAmount('session-1', 500); + setRedirectTipAmount('session-1', 750); + + expect(getRedirectTipAmount('session-1')).toBe(750); + }); + + describe('durability across tabs', () => { + it('mirrors the tip to localStorage so a return in a new tab can read it', () => { + setRedirectTipAmount('session-1', 500); + + // sessionStorage is per-tab; a gateway returning to a different tab sees + // only localStorage. + window.sessionStorage.clear(); + + expect(getRedirectTipAmount('session-1')).toBe(500); + }); + + it('reports success when only one store accepted the write', () => { + vi.spyOn(window.localStorage, 'setItem').mockImplementation(() => { + throw new Error('storage full'); + }); + + expect(setRedirectTipAmount('session-1', 500)).toBe(true); + expect(getRedirectTipAmount('session-1')).toBe(500); + }); + }); + + describe('staleness', () => { + it('ignores a tip older than the maximum age', () => { + const twoDaysAgo = Date.now() - 2 * 24 * 60 * 60 * 1000; + window.sessionStorage.setItem( + keyFor('session-1'), + JSON.stringify({ tipAmount: 500, savedAt: twoDaysAgo }) + ); + + expect(getRedirectTipAmount('session-1')).toBeNull(); + }); + + it('ignores a tip with no saved timestamp', () => { + window.sessionStorage.setItem( + keyFor('session-1'), + JSON.stringify({ tipAmount: 500 }) + ); + + expect(getRedirectTipAmount('session-1')).toBeNull(); + }); + + it('sweeps expired entries on the next write', () => { + const twoDaysAgo = Date.now() - 2 * 24 * 60 * 60 * 1000; + window.localStorage.setItem( + keyFor('abandoned'), + JSON.stringify({ tipAmount: 500, savedAt: twoDaysAgo }) + ); + + setRedirectTipAmount('session-1', 750); + + expect(window.localStorage.getItem(keyFor('abandoned'))).toBeNull(); + expect(getRedirectTipAmount('session-1')).toBe(750); + }); + + it('leaves unrelated keys alone when sweeping', () => { + window.localStorage.setItem('some-other-app-key', 'keep me'); + + setRedirectTipAmount('session-1', 500); + + expect(window.localStorage.getItem('some-other-app-key')).toBe('keep me'); + }); + }); + + describe('when storage is unavailable', () => { + it('reports failure rather than throwing', () => { + for (const method of ['setItem', 'getItem', 'removeItem'] as const) { + vi.spyOn(Storage.prototype, method).mockImplementation(() => { + throw new Error('storage disabled'); + }); + } + + expect(setRedirectTipAmount('session-1', 500)).toBe(false); + expect(getRedirectTipAmount('session-1')).toBeNull(); + expect(() => clearRedirectTipAmount('session-1')).not.toThrow(); + }); + + it('reports failure when a write is accepted but not readable back', () => { + // Safari with storage blocked accepts setItem and then returns null. + vi.spyOn(Storage.prototype, 'setItem').mockImplementation( + () => undefined + ); + + expect(setRedirectTipAmount('session-1', 500)).toBe(false); + }); + }); +}); diff --git a/packages/react/src/lib/redirect-tip-storage.ts b/packages/react/src/lib/redirect-tip-storage.ts new file mode 100644 index 00000000..022792f1 --- /dev/null +++ b/packages/react/src/lib/redirect-tip-storage.ts @@ -0,0 +1,194 @@ +const REDIRECT_TIP_KEY_PREFIX = 'godaddy-checkout-redirect-tip'; + +// A gateway round-trip takes minutes. An older entry belongs to a checkout the +// customer abandoned at the gateway, so it is ignored on read and swept up on +// the next write rather than accumulating in localStorage. +const REDIRECT_TIP_MAX_AGE_MS = 24 * 60 * 60 * 1000; + +type StoredRedirectTip = { + tipAmount: number; + savedAt: number; +}; + +/** + * Entries are keyed per checkout session so a later session started in the same + * tab cannot overwrite a tip an earlier one is still waiting to confirm. + */ +function keyFor(sessionId: string): string { + return `${REDIRECT_TIP_KEY_PREFIX}:${sessionId}`; +} + +/** + * The stores the tip is mirrored across. + * + * `sessionStorage` is scoped to a single tab, so a gateway that returns the + * customer to a different one — routine in mobile in-app browsers — cannot see + * it. `localStorage` survives that. Both are written and either can satisfy a + * read, so losing the tip takes both being unavailable. + */ +function getStores(): Storage[] { + if (typeof window === 'undefined') { + // SSR safety + return []; + } + + const stores: Storage[] = []; + for (const read of [() => window.sessionStorage, () => window.localStorage]) { + try { + const store = read(); + if (store) { + stores.push(store); + } + } catch { + // Touching the property itself throws when storage is blocked outright. + } + } + + return stores; +} + +/** + * Drop expired entries, and any this version cannot read, before writing a new + * one. Keeps abandoned checkouts from accumulating in localStorage, which — + * unlike sessionStorage — outlives the tab. + */ +function pruneExpired(store: Storage): void { + const now = Date.now(); + const stale: string[] = []; + + for (let index = 0; index < store.length; index++) { + const key = store.key(index); + if (!key?.startsWith(`${REDIRECT_TIP_KEY_PREFIX}:`)) { + continue; + } + + try { + const raw = store.getItem(key); + const savedAt = raw + ? (JSON.parse(raw) as Partial | null)?.savedAt + : undefined; + if ( + typeof savedAt !== 'number' || + now - savedAt > REDIRECT_TIP_MAX_AGE_MS + ) { + stale.push(key); + } + } catch { + // Unparsable, so it can never be read back either way. + stale.push(key); + } + } + + for (const key of stale) { + try { + store.removeItem(key); + } catch { + // Storage can become unwritable between the read and the remove. + } + } +} + +/** + * Save the tip a gateway redirect was authorized for. + * + * Redirect providers (CCAvenue) authorize on one page load and confirm on + * another: the customer leaves for the gateway and comes back to a fresh + * document where react-hook-form state no longer exists. The gateway collects + * the tip-inclusive amount, and `confirmCheckoutSession` records whatever tip + * the client sends — the API defaults a missing `tipAmount` to `0` rather than + * inheriting the authorized one. So if this value does not survive the + * redirect, the order is recorded for less than the customer paid. + * + * @returns true when the tip was written somewhere it can be read back. A false + * return means the tip cannot survive the redirect, so the caller must not send + * the customer to a gateway that will charge it. + */ +export function setRedirectTipAmount( + sessionId: string, + tipAmount: number +): boolean { + if (!sessionId) { + return false; + } + + const key = keyFor(sessionId); + const payload = JSON.stringify({ + tipAmount, + savedAt: Date.now(), + } satisfies StoredRedirectTip); + let saved = false; + + for (const store of getStores()) { + try { + pruneExpired(store); + store.setItem(key, payload); + // Read back rather than trusting setItem: with storage blocked, Safari + // accepts the write and then hands back null, and a quota failure can + // evict the entry immediately after it is accepted. + if (store.getItem(key) === payload) { + saved = true; + } + } catch { + // Storage can be unavailable (private browsing, disabled storage) or full. + } + } + + return saved; +} + +/** + * Read the tip saved for `sessionId`. + * + * Returns null when nothing was saved for this session, the entry is too old to + * belong to the redirect in progress, or every store is unreadable. + */ +export function getRedirectTipAmount(sessionId: string): number | null { + if (!sessionId) { + return null; + } + + const key = keyFor(sessionId); + + for (const store of getStores()) { + try { + const raw = store.getItem(key); + if (!raw) { + continue; + } + + const stored = JSON.parse(raw) as Partial | null; + if (typeof stored?.tipAmount !== 'number') { + continue; + } + if ( + typeof stored.savedAt !== 'number' || + Date.now() - stored.savedAt > REDIRECT_TIP_MAX_AGE_MS + ) { + continue; + } + + return stored.tipAmount; + } catch { + // Unreadable or unparsable — try the next store. + } + } + + return null; +} + +/** + * Remove the tip saved for `sessionId`. + */ +export function clearRedirectTipAmount(sessionId: string): void { + if (!sessionId) { + return; + } + + for (const store of getStores()) { + try { + store.removeItem(keyFor(sessionId)); + } catch { + // Storage can be unavailable (private browsing, disabled storage). + } + } +} diff --git a/packages/react/src/tracking/events.ts b/packages/react/src/tracking/events.ts index eafe52df..8c6f3f31 100644 --- a/packages/react/src/tracking/events.ts +++ b/packages/react/src/tracking/events.ts @@ -60,6 +60,9 @@ export const eventIds = { // Tips events selectTipAmount: 'select_tip_amount.click', enterCustomTip: 'enter_custom_tip.click', + // A redirect gateway charged a tip-inclusive amount but the tip could not be + // recovered on the return leg, so the order is recorded without it. + redirectTipUnrecoverable: 'redirect_tip_unrecoverable.event', // Notes events addOrderNote: 'add_order_note.click',