diff --git a/.changeset/fruity-dots-jog.md b/.changeset/fruity-dots-jog.md new file mode 100644 index 00000000..e9a4fbd5 --- /dev/null +++ b/.changeset/fruity-dots-jog.md @@ -0,0 +1,5 @@ +--- +"@godaddy/react": patch +--- + +Support tips in unified checkout diff --git a/examples/nextjs/app/page.tsx b/examples/nextjs/app/page.tsx index 12ff2de4..17134c62 100644 --- a/examples/nextjs/app/page.tsx +++ b/examples/nextjs/app/page.tsx @@ -21,6 +21,19 @@ export default async function Home() { enableTaxCollection: true, enableNotesCollection: true, enablePromotionCodes: true, + enableTips: true, + tips: { + default: { + percentages: [ 20, 40, 60 ] + }, + thresholds: [ + { + minSubtotal: 0, + maxSubtotal: 1000, + amounts: [ 300, 500, 700 ] + } + ] + }, shipping: { fulfillmentLocationId: 'default-location', originAddress: { diff --git a/packages/react/README.md b/packages/react/README.md index 25641b21..e297559d 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -41,6 +41,7 @@ The first parameter accepts all checkout session configuration options from the - **`enableSurcharge`** (boolean): Enable surcharge fees - **`enableTaxCollection`** (boolean): Enable tax collection - **`enableTips`** (boolean): Enable tip/gratuity options +- **`tips`** (CheckoutSessionTipsInput): Tip option configuration (see [Tips](#tips)) - **`enabledLocales`** ([String!]): List of enabled locales - **`enabledPaymentProviders`** ([String!]): List of enabled payment providers - **`environment`** (enum): Environment - `ote`, `prod` @@ -135,6 +136,59 @@ operatingHours: { - **Timezone handling** — All date/time logic uses the store's `timeZone`, not the customer's browser timezone. A store in Phoenix shows Phoenix hours regardless of where the customer is browsing from. - **No available slots** — In `dateAndTime` mode, when leadTime exceeds the entire pickup window, no days are enabled, or no selectable slots exist, a "No available time slots" banner is shown. +### Tips + +The `tips` field configures preset tip options shown to the customer when `enableTips` is `true`. Tips supports a `default` preset and optional `thresholds` that activate based on the order subtotal. + +Every option list — `default` and each threshold — must supply **exactly one** of `amounts` or `percentages`, with **exactly three** values. The API rejects sessions that provide both, neither, or a different number of values. Three values is also what the tip selector is laid out for. + +```typescript +tips: { + default: { + percentages: [15, 18, 20], + }, + thresholds: [ + { + minSubtotal: 0, + maxSubtotal: 999, + amounts: [100, 200, 500], + }, + { + minSubtotal: 1000, + maxSubtotal: 4999, + amounts: [200, 400, 700], + }, + ], +} +``` + +#### `tips.default` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `amounts` | number[] | Conditional | Fixed tip amounts in the smallest currency unit (e.g. cents). Exactly three values. Mutually exclusive with `percentages`. | +| `percentages` | number[] | Conditional | Tip percentage options (integers between 0 and 100). Exactly three values. Mutually exclusive with `amounts`. | + +#### `tips.thresholds` + +An array of threshold objects that override the default tips when the order subtotal falls within the specified range. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `minSubtotal` | number | Yes | Minimum order subtotal (inclusive) in the smallest currency unit for this threshold to apply. Required by the API — omitting it fails with `INVALID_TIP_THRESHOLD`. | +| `maxSubtotal` | number | Yes | Maximum order subtotal (inclusive) in the smallest currency unit for this threshold to apply. Required by the API — omitting it fails with `INVALID_TIP_THRESHOLD`. | +| `amounts` | number[] | Conditional | Fixed tip amounts in the smallest currency unit (e.g. cents). Exactly three values. Mutually exclusive with `percentages`. | +| `percentages` | number[] | Conditional | Tip percentage options (integers between 0 and 100). Exactly three values. Mutually exclusive with `amounts`. | + +#### Behavior Notes + +- **Threshold matching** — Checkout uses the **first** threshold whose range contains the order subtotal. Both bounds are inclusive, so a subtotal equal to `minSubtotal` or `maxSubtotal` matches. +- **Overlaps are not validated** — The API checks neither overlap nor full coverage of the subtotal range. Adjacent thresholds that share a boundary (e.g. `0–1000` and `1000–2000`) are accepted and resolve silently to whichever comes first in the array. Make ranges contiguous but non-overlapping (e.g. `0–999` then `1000–1999`) so the applied threshold is unambiguous. +- **Gaps fall back to `default`** — A subtotal outside every threshold range uses `tips.default`. +- **No `tips` configured** — When `enableTips` is `true` but `tips` is omitted, checkout shows `15%`, `18%`, and `20%`. +- **Both lists on one threshold** — Should a threshold reach checkout with both `amounts` and `percentages` (the API rejects this), `amounts` wins and the percentages are ignored. +- **Customer overrides** — The presets are suggestions. The tip selector also offers "No tip" and "Custom amount", so the confirmed `tipAmount` need not match any preset. + ### Appearance The `appearance` field customizes the checkout's look and feel. diff --git a/packages/react/src/components/checkout/__tests__/checkout-ccavenue-tips.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-ccavenue-tips.test.tsx new file mode 100644 index 00000000..fecb7966 --- /dev/null +++ b/packages/react/src/components/checkout/__tests__/checkout-ccavenue-tips.test.tsx @@ -0,0 +1,223 @@ +import { screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + clearRedirectTipAmount, + getRedirectTipAmount, + setRedirectTipAmount, +} from '@/lib/redirect-tip-storage'; +import { CheckoutType, PaymentMethodType, PaymentProvider } from '@/types'; +import { + clearOperations, + getOperations, + renderCheckout, + setCheckoutUrl, + waitForCheckoutReady, + waitForOperation, +} from './checkout-test-env'; +import { getLastConfirmInput } from './checkout-test-fixtures'; + +const CCAVENUE_SESSION = { + enableTips: true, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + paymentMethods: { + card: null, + ccavenue: { + type: PaymentMethodType.CCAVENUE, + processor: PaymentProvider.CCAVENUE, + checkoutTypes: [CheckoutType.STANDARD], + }, + }, +}; + +const CCAVENUE_PROPS = { + ccavenueConfig: { accessCodeId: 'access-code-1' }, +}; + +function renderCCAvenueCheckout() { + return renderCheckout({ + checkoutProps: CCAVENUE_PROPS, + sessionOverrides: CCAVENUE_SESSION, + }); +} + +function getAuthorizeInput() { + return getOperations('AuthorizeCheckoutSession').at(-1)?.input as + | Record + | undefined; +} + +describe('Checkout CCAvenue tips', () => { + beforeEach(() => { + clearRedirectTipAmount(); + setCheckoutUrl(); + }); + + describe('redirect to the gateway', () => { + it('persists the tip the redirect authorized', async () => { + const submit = vi + .spyOn(HTMLFormElement.prototype, 'submit') + .mockImplementation(() => undefined); + const { user, session } = renderCCAvenueCheckout(); + await waitForCheckoutReady(); + clearOperations(); + + await user.click(await screen.findByRole('button', { name: /20%/ })); + await waitFor(() => { + expect(screen.getAllByText('$5.00').length).toBeGreaterThan(0); + }); + + await user.click( + await screen.findByRole('button', { name: /pay with ccavenue/i }) + ); + await waitForOperation('AuthorizeCheckoutSession'); + + expect(getAuthorizeInput()).toMatchObject({ tipAmount: 500 }); + expect(getRedirectTipAmount(session.id)).toBe(500); + await waitFor(() => { + expect(submit).toHaveBeenCalled(); + }); + }); + + it('persists a zero tip when no tip is selected', async () => { + vi.spyOn(HTMLFormElement.prototype, 'submit').mockImplementation( + () => undefined + ); + const { user, session } = renderCCAvenueCheckout(); + await waitForCheckoutReady(); + clearOperations(); + + await user.click( + await screen.findByRole('button', { name: /pay with ccavenue/i }) + ); + await waitForOperation('AuthorizeCheckoutSession'); + + expect(getRedirectTipAmount(session.id)).toBe(0); + }); + + it('does not persist a tip when tips are disabled', async () => { + vi.spyOn(HTMLFormElement.prototype, 'submit').mockImplementation( + () => undefined + ); + const { user, session } = renderCheckout({ + checkoutProps: CCAVENUE_PROPS, + sessionOverrides: { ...CCAVENUE_SESSION, enableTips: false }, + }); + await waitForCheckoutReady(); + clearOperations(); + + await user.click( + await screen.findByRole('button', { name: /pay with ccavenue/i }) + ); + await waitForOperation('AuthorizeCheckoutSession'); + + expect(getRedirectTipAmount(session.id)).toBeNull(); + }); + + it('does not persist a tip when the authorization fails', async () => { + vi.spyOn(HTMLFormElement.prototype, 'submit').mockImplementation( + () => undefined + ); + const { user, session } = renderCheckout({ + checkoutProps: CCAVENUE_PROPS, + sessionOverrides: CCAVENUE_SESSION, + apiOverrides: { + errors: { authorizeCheckoutSession: new Error('authorize failed') }, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + await user.click(await screen.findByRole('button', { name: /20%/ })); + await waitFor(() => { + expect(screen.getAllByText('$5.00').length).toBeGreaterThan(0); + }); + + await user.click( + await screen.findByRole('button', { name: /pay with ccavenue/i }) + ); + await waitForOperation('AuthorizeCheckoutSession'); + + expect(getRedirectTipAmount(session.id)).toBeNull(); + }); + }); + + describe('return from the gateway', () => { + it('confirms with the tip the redirect authorized', async () => { + setRedirectTipAmount('checkout-session-1', 500); + setCheckoutUrl({ + pathname: '/checkout/checkout-session-1', + search: 'encResp=enc-resp-1', + }); + + renderCCAvenueCheckout(); + await waitForOperation('ConfirmCheckoutSession'); + + expect(getLastConfirmInput()).toMatchObject({ + paymentToken: 'enc-resp-1', + paymentType: 'ccavenue', + tipAmount: 500, + }); + }); + + it('clears the persisted tip once the confirmation succeeds', async () => { + setRedirectTipAmount('checkout-session-1', 500); + setCheckoutUrl({ + pathname: '/checkout/checkout-session-1', + search: 'encResp=enc-resp-1', + }); + + const { session } = renderCCAvenueCheckout(); + await waitForOperation('ConfirmCheckoutSession'); + + await waitFor(() => { + expect(getRedirectTipAmount(session.id)).toBeNull(); + }); + }); + + it('keeps the persisted tip when the confirmation fails', async () => { + setRedirectTipAmount('checkout-session-1', 500); + setCheckoutUrl({ + pathname: '/checkout/checkout-session-1', + search: 'encResp=enc-resp-1', + }); + + const { session } = renderCheckout({ + checkoutProps: CCAVENUE_PROPS, + sessionOverrides: CCAVENUE_SESSION, + apiOverrides: { + errors: { confirmCheckout: new Error('confirm failed') }, + }, + }); + await waitForOperation('ConfirmCheckoutSession'); + + expect(getRedirectTipAmount(session.id)).toBe(500); + }); + + it('ignores a tip persisted for a different checkout session', async () => { + setRedirectTipAmount('checkout-session-other', 500); + setCheckoutUrl({ + pathname: '/checkout/checkout-session-1', + search: 'encResp=enc-resp-1', + }); + + renderCCAvenueCheckout(); + await waitForOperation('ConfirmCheckoutSession'); + + expect(getLastConfirmInput()).toMatchObject({ tipAmount: 0 }); + }); + + it('confirms with a zero tip when nothing was persisted', async () => { + setCheckoutUrl({ + pathname: '/checkout/checkout-session-1', + search: 'encResp=enc-resp-1', + }); + + renderCCAvenueCheckout(); + await waitForOperation('ConfirmCheckoutSession'); + + expect(getLastConfirmInput()).toMatchObject({ tipAmount: 0 }); + }); + }); +}); diff --git a/packages/react/src/components/checkout/__tests__/checkout-free-order.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-free-order.test.tsx index ea014b76..49d2b60f 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-free-order.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-free-order.test.tsx @@ -87,6 +87,34 @@ async function applyCoupon( await user.click(apply as HTMLButtonElement); } +// A fully-discounted order: nothing is owed on the order itself, but the +// subtotal the tip percentages are calculated from is still non-zero. +function buildFullyDiscountedDraftOrder() { + return buildDraftOrder({ + totals: { + subTotal: { value: 5000, currencyCode: 'USD' }, + discountTotal: { value: 5000, currencyCode: 'USD' }, + shippingTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + total: { value: 0, currencyCode: 'USD' }, + }, + shippingLines: [], + lineItems: [ + { + unitAmount: { value: 5000, currencyCode: 'USD' }, + fulfillmentMode: 'PURCHASE', + totals: { + subTotal: { value: 5000, currencyCode: 'USD' }, + discountTotal: { value: 5000, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + }, + }, + ], + }); +} + function buildPaidPurchaseDraftOrder() { return buildDraftOrder({ totals: { @@ -449,6 +477,90 @@ describe('Checkout free / offline orders', () => { ).not.toBeInTheDocument(); }); + it('collects payment on a zero-total order once a tip is added', async () => { + const draftOrder = buildFullyDiscountedDraftOrder(); + const session = buildCheckoutSession({ + draftOrder, + enableTips: true, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + }); + + const { user } = renderCheckout({ session, draftOrder }); + await waitForCheckoutReady(); + + // Nothing owed yet, so the free path is correct. + expect( + await screen.findByRole('button', { name: /complete your free order/i }) + ).toBeInTheDocument(); + + // 15% of the $50.00 subtotal is owed even though the order total is zero. + await user.click(await screen.findByRole('button', { name: /15%/ })); + + expect( + await screen.findByRole('button', { name: /pay now/i }) + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /complete your free order/i }) + ).not.toBeInTheDocument(); + }); + + it('returns to the free form when the tip on a zero-total order is cleared', async () => { + const draftOrder = buildFullyDiscountedDraftOrder(); + const session = buildCheckoutSession({ + draftOrder, + enableTips: true, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + }); + + const { user } = renderCheckout({ session, draftOrder }); + await waitForCheckoutReady(); + + await user.click(await screen.findByRole('button', { name: /15%/ })); + expect( + await screen.findByRole('button', { name: /pay now/i }) + ).toBeInTheDocument(); + + await user.click(await screen.findByRole('button', { name: /no tip/i })); + + expect( + await screen.findByRole('button', { name: /complete your free order/i }) + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /pay now/i }) + ).not.toBeInTheDocument(); + }); + + it('does not send a tip on a zero-total order confirmed through the free form', async () => { + const draftOrder = buildFullyDiscountedDraftOrder(); + const session = buildCheckoutSession({ + draftOrder, + enableTips: true, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + enableBillingAddressCollection: false, + }); + + const { user } = renderCheckout({ session, draftOrder }); + await waitForCheckoutReady(); + clearOperations(); + + await user.click( + await screen.findByRole('button', { name: /complete your free order/i }) + ); + + await waitForOperation('ConfirmCheckoutSession'); + expect(getLastConfirmInput()).toMatchObject({ + paymentType: 'offline', + paymentProvider: 'OFFLINE', + tipAmount: 0, + }); + }); + it('keeps the paid form visible when coupon application fails', async () => { const draftOrder = buildPaidPurchaseDraftOrder(); const session = buildCheckoutSession({ diff --git a/packages/react/src/components/checkout/__tests__/checkout-mercadopago-tips.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-mercadopago-tips.test.tsx new file mode 100644 index 00000000..e8e4f12b --- /dev/null +++ b/packages/react/src/components/checkout/__tests__/checkout-mercadopago-tips.test.tsx @@ -0,0 +1,210 @@ +import { screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { CheckoutType, PaymentMethodType, PaymentProvider } from '@/types'; +import { + clearOperations, + getOperations, + renderCheckout, + waitForOperation, +} from './checkout-test-env'; +import { getLastConfirmInput } from './checkout-test-fixtures'; + +vi.mock('@/components/checkout/payment/utils/use-load-mercadopago', () => ({ + useLoadMercadoPago: () => ({ isMercadoPagoLoaded: true }), +})); + +interface BrickCall { + amount: number; + preferenceId: string; +} + +const brickCalls: BrickCall[] = []; +let createGate: { promise: Promise; release: () => void } | null = null; + +// Gate the next brick creation so the pending-rebuild window is observable. +function gateNextCreate() { + let release = () => undefined as void; + const promise = new Promise(resolve => { + release = () => resolve(); + }); + createGate = { promise, release }; + return createGate; +} + +class MercadoPagoStub { + bricks() { + return { + create: async (_brick: string, elementId: string, settings: any) => { + const gate = createGate; + if (gate) { + createGate = null; + await gate.promise; + } + + brickCalls.push({ + amount: settings?.initialization?.amount, + preferenceId: settings?.initialization?.preferenceId, + }); + settings?.callbacks?.onReady?.(); + + return { + unmount: () => undefined, + getFormData: async () => ({ formData: { token: 'mp-token-1' } }), + }; + }, + }; + } +} + +(window as any).MercadoPago = MercadoPagoStub; + +const MERCADOPAGO_SESSION = { + enableTips: true, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + paymentMethods: { + card: null, + mercadopago: { + type: PaymentMethodType.MERCADOPAGO, + processor: PaymentProvider.MERCADOPAGO, + checkoutTypes: [CheckoutType.STANDARD], + }, + }, +}; + +const MERCADOPAGO_PROPS = { + mercadoPagoConfig: { publicKey: 'public-key-1', country: 'BR' as const }, +}; + +function renderMercadoPagoCheckout( + overrides: Parameters[0] = {} +) { + return renderCheckout({ + checkoutProps: MERCADOPAGO_PROPS, + sessionOverrides: MERCADOPAGO_SESSION, + ...overrides, + }); +} + +function getAuthorizeInputs() { + return getOperations('AuthorizeCheckoutSession').map( + operation => operation.input as Record + ); +} + +async function waitForBrickCalls(count: number) { + await waitFor(() => { + expect(brickCalls.length).toBeGreaterThanOrEqual(count); + }); +} + +describe('Checkout MercadoPago tips', () => { + beforeEach(() => { + brickCalls.length = 0; + createGate = null; + }); + + it('builds the brick and preference for the tip-inclusive total', async () => { + const { user } = renderMercadoPagoCheckout(); + await waitForBrickCalls(1); + + expect(brickCalls[0]).toMatchObject({ + amount: 25, + preferenceId: 'transaction-ref-1', + }); + expect(getAuthorizeInputs().at(-1)).toMatchObject({ tipAmount: 0 }); + + await user.click(await screen.findByRole('button', { name: /20%/ })); + + await waitForBrickCalls(2); + expect(brickCalls.at(-1)).toMatchObject({ amount: 30 }); + expect(getAuthorizeInputs().at(-1)).toMatchObject({ tipAmount: 500 }); + }); + + it('rebuilds the brick each time the tip changes', async () => { + const { user } = renderMercadoPagoCheckout(); + await waitForBrickCalls(1); + + await user.click(await screen.findByRole('button', { name: /20%/ })); + await waitForBrickCalls(2); + + await user.click(await screen.findByRole('button', { name: /15%/ })); + await waitForBrickCalls(3); + + expect(brickCalls.map(call => call.amount)).toEqual([25, 30, 28.75]); + expect(getAuthorizeInputs().map(input => input.tipAmount)).toEqual([ + 0, 500, 375, + ]); + }); + + it('does not rebuild the brick when the tip-inclusive total is unchanged', async () => { + const { user } = renderMercadoPagoCheckout(); + await waitForBrickCalls(1); + clearOperations(); + + await user.click(await screen.findByRole('button', { name: /no tip/i })); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /no tip/i })).toHaveAttribute( + 'aria-checked', + 'true' + ); + }); + expect(brickCalls).toHaveLength(1); + expect(getOperations('AuthorizeCheckoutSession')).toHaveLength(0); + }); + + it('does not let the customer pay while the brick is rebuilt for a new tip', async () => { + const firstCreate = gateNextCreate(); + const { user } = renderMercadoPagoCheckout(); + + const payNow = await screen.findByRole('button', { name: /pay now/i }); + expect(payNow).toBeDisabled(); + + firstCreate.release(); + await waitForBrickCalls(1); + await waitFor(() => { + expect(payNow).not.toBeDisabled(); + }); + + const rebuild = gateNextCreate(); + await user.click(await screen.findByRole('button', { name: /20%/ })); + + await waitFor(() => { + expect(payNow).toBeDisabled(); + }); + + rebuild.release(); + await waitForBrickCalls(2); + await waitFor(() => { + expect(payNow).not.toBeDisabled(); + }); + expect(brickCalls.at(-1)).toMatchObject({ amount: 30 }); + }); + + it('confirms with the tip the brick and preference were built for', async () => { + // confirmCheckout is made to fail so the module-level submit latch resets + // and the brick can be torn down for the next test. + const { user } = renderMercadoPagoCheckout({ + apiOverrides: { + errors: { confirmCheckout: new Error('confirm failed') }, + }, + }); + await waitForBrickCalls(1); + + await user.click(await screen.findByRole('button', { name: /20%/ })); + await waitForBrickCalls(2); + + await user.click(await screen.findByRole('button', { name: /pay now/i })); + await waitForOperation('ConfirmCheckoutSession'); + + expect(getLastConfirmInput()).toMatchObject({ + paymentToken: 'mp-token-1', + paymentType: 'mercadopago', + tipAmount: 500, + }); + expect(brickCalls.at(-1)).toMatchObject({ amount: 30 }); + expect(getAuthorizeInputs().at(-1)).toMatchObject({ tipAmount: 500 }); + }); +}); diff --git a/packages/react/src/components/checkout/__tests__/checkout-test-utils.tsx b/packages/react/src/components/checkout/__tests__/checkout-test-utils.tsx index 5faecca1..9a72aec6 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-test-utils.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-test-utils.tsx @@ -38,6 +38,7 @@ export type OperationName = | 'ApplyCheckoutSessionDiscount' | 'ApplyCheckoutSessionFulfillmentLocation' | 'ConfirmCheckoutSession' + | 'AuthorizeCheckoutSession' | 'TokenizeJs.getNonce' | 'ExchangeCheckoutToken' | 'RefreshCheckoutToken' @@ -70,6 +71,7 @@ export type MockGodaddyApiErrorKey = | 'applyDiscount' | 'applyFulfillmentLocation' | 'confirmCheckout' + | 'authorizeCheckoutSession' | 'getDraftOrderShippingMethods' | 'getProductsFromOrderSkus'; @@ -927,6 +929,17 @@ export function mockGodaddyApi(options: MockGodaddyApiOptions) { }; }); + mockedGodaddyApi.authorizeCheckoutSession.mockImplementation(async input => { + record('AuthorizeCheckoutSession', input); + await maybeDelay(); + maybeThrow('authorizeCheckoutSession'); + return { + authorizeCheckoutSession: { + transactionRefNum: 'transaction-ref-1', + }, + }; + }); + return state; } diff --git a/packages/react/src/components/checkout/__tests__/checkout-tips.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-tips.test.tsx index d634fea9..16e8f5d4 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-tips.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-tips.test.tsx @@ -8,6 +8,7 @@ import { waitForCheckoutReady, waitForOperation, } from './checkout-test-env'; +import { getLastConfirmInput } from './checkout-test-fixtures'; vi.mock('@/tracking/track', async importOriginal => { const actual = await importOriginal(); @@ -346,4 +347,535 @@ describe('Checkout tips', () => { expect(screen.queryByPlaceholderText('0')).not.toBeInTheDocument(); }); }); + + it('includes tipAmount in the ConfirmCheckoutSession mutation payload', async () => { + const { user } = renderCheckout({ + sessionOverrides: { + enableTips: true, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + paymentMethods: { + card: { + processor: 'godaddy', + checkoutTypes: ['standard'], + }, + }, + }, + draftOrderOverrides: { + totals: { + subTotal: { value: 2500, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + shippingTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + total: { value: 2500, currencyCode: 'USD' }, + }, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + await user.click(await screen.findByRole('button', { name: /20%/ })); + await waitFor(() => { + expect(screen.getAllByText('$5.00').length).toBeGreaterThan(0); + }); + + await user.click(await screen.findByRole('button', { name: /pay now/i })); + await waitForOperation('ConfirmCheckoutSession'); + + expect(getLastConfirmInput()).toMatchObject({ + tipAmount: 500, + }); + }); + + it('includes a custom tipAmount when entering a custom tip before confirming', async () => { + const { user } = renderCheckout({ + sessionOverrides: { + enableTips: true, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + paymentMethods: { + card: { + processor: 'godaddy', + checkoutTypes: ['standard'], + }, + }, + }, + draftOrderOverrides: { + totals: { + subTotal: { value: 2500, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + shippingTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + total: { value: 2500, currencyCode: 'USD' }, + }, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + await user.click( + await screen.findByRole('button', { name: /custom amount/i }) + ); + const input = await screen.findByPlaceholderText('0.00'); + await user.click(input); + await user.type(input, '7.50'); + await user.tab(); + + await waitFor(() => { + expect(screen.getAllByText('$7.50').length).toBeGreaterThan(0); + }); + + await user.click(await screen.findByRole('button', { name: /pay now/i })); + await waitForOperation('ConfirmCheckoutSession'); + + expect(getLastConfirmInput()).toMatchObject({ + tipAmount: 750, + }); + }); + + it('sends tipAmount as 0 when no tip is selected', async () => { + const { user } = renderCheckout({ + sessionOverrides: { + enableTips: true, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + paymentMethods: { + card: { + processor: 'godaddy', + checkoutTypes: ['standard'], + }, + }, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + await user.click(await screen.findByRole('button', { name: /pay now/i })); + await waitForOperation('ConfirmCheckoutSession'); + + expect(getLastConfirmInput()).toMatchObject({ + tipAmount: 0, + }); + }); + + describe('options.thresholds', () => { + it('uses default percentages when no thresholds match the subtotal', async () => { + renderCheckout({ + sessionOverrides: { + enableTips: true, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + tips: { + default: { percentages: [10, 15, 20], amounts: null }, + thresholds: [ + { + minSubtotal: 100000, + maxSubtotal: 200000, + percentages: [5, 8, 12], + amounts: null, + }, + ], + }, + }, + draftOrderOverrides: { + totals: { + subTotal: { value: 2500, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + shippingTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + total: { value: 2500, currencyCode: 'USD' }, + }, + }, + }); + await waitForCheckoutReady(); + + expect(await screen.findByRole('button', { name: /10%/ })).toBeVisible(); + expect(await screen.findByRole('button', { name: /15%/ })).toBeVisible(); + expect(await screen.findByRole('button', { name: /20%/ })).toBeVisible(); + expect( + screen.queryByRole('button', { name: /\b5%/ }) + ).not.toBeInTheDocument(); + }); + + it('uses threshold percentages when subtotal falls within a threshold range', async () => { + renderCheckout({ + sessionOverrides: { + enableTips: true, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + tips: { + default: { percentages: [10, 15, 20], amounts: null }, + thresholds: [ + { + minSubtotal: 2000, + maxSubtotal: 5000, + percentages: [5, 8, 12], + amounts: null, + }, + ], + }, + }, + draftOrderOverrides: { + totals: { + subTotal: { value: 2500, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + shippingTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + total: { value: 2500, currencyCode: 'USD' }, + }, + }, + }); + await waitForCheckoutReady(); + + expect(await screen.findByRole('button', { name: /5%/ })).toBeVisible(); + expect(await screen.findByRole('button', { name: /8%/ })).toBeVisible(); + expect(await screen.findByRole('button', { name: /12%/ })).toBeVisible(); + expect( + screen.queryByRole('button', { name: /10%/ }) + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /15%/ }) + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /20%/ }) + ).not.toBeInTheDocument(); + }); + + it('uses threshold amounts (flat values) when a matching threshold specifies amounts', async () => { + renderCheckout({ + sessionOverrides: { + enableTips: true, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + tips: { + default: { percentages: [10, 15, 20], amounts: null }, + thresholds: [ + { + minSubtotal: 2000, + maxSubtotal: 5000, + amounts: [100, 200, 500], + percentages: null, + }, + ], + }, + }, + draftOrderOverrides: { + totals: { + subTotal: { value: 2500, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + shippingTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + total: { value: 2500, currencyCode: 'USD' }, + }, + }, + }); + await waitForCheckoutReady(); + + expect( + await screen.findByRole('button', { name: /\$1\.00/ }) + ).toBeVisible(); + expect( + await screen.findByRole('button', { name: /\$2\.00/ }) + ).toBeVisible(); + expect( + await screen.findByRole('button', { name: /\$5\.00/ }) + ).toBeVisible(); + expect( + screen.queryByRole('button', { name: /10%/ }) + ).not.toBeInTheDocument(); + }); + + it('threshold amounts take priority over threshold percentages when both are provided', async () => { + renderCheckout({ + sessionOverrides: { + enableTips: true, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + tips: { + default: { percentages: [10, 15, 20], amounts: null }, + thresholds: [ + { + minSubtotal: 2000, + maxSubtotal: 5000, + amounts: [100, 200, 500], + percentages: [5, 8, 12], + }, + ], + }, + }, + draftOrderOverrides: { + totals: { + subTotal: { value: 2500, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + shippingTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + total: { value: 2500, currencyCode: 'USD' }, + }, + }, + }); + await waitForCheckoutReady(); + + expect( + await screen.findByRole('button', { name: /\$1\.00/ }) + ).toBeVisible(); + expect( + await screen.findByRole('button', { name: /\$2\.00/ }) + ).toBeVisible(); + expect( + await screen.findByRole('button', { name: /\$5\.00/ }) + ).toBeVisible(); + expect( + screen.queryByRole('button', { name: /5%/ }) + ).not.toBeInTheDocument(); + }); + + it('matches the correct threshold when multiple thresholds are defined', async () => { + renderCheckout({ + sessionOverrides: { + enableTips: true, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + tips: { + default: { percentages: [15, 18, 20], amounts: null }, + thresholds: [ + { + minSubtotal: 1000, + maxSubtotal: 3000, + percentages: [5, 8, 10], + amounts: null, + }, + { + minSubtotal: 3001, + maxSubtotal: 10000, + percentages: [3, 5, 7], + amounts: null, + }, + ], + }, + }, + draftOrderOverrides: { + totals: { + subTotal: { value: 5000, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + shippingTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + total: { value: 5000, currencyCode: 'USD' }, + }, + }, + }); + await waitForCheckoutReady(); + + expect(await screen.findByRole('button', { name: /3%/ })).toBeVisible(); + expect(await screen.findByRole('button', { name: /5%/ })).toBeVisible(); + expect(await screen.findByRole('button', { name: /7%/ })).toBeVisible(); + expect( + screen.queryByRole('button', { name: /15%/ }) + ).not.toBeInTheDocument(); + }); + + it('applies threshold at boundary: subtotal equals minSubtotal', async () => { + renderCheckout({ + sessionOverrides: { + enableTips: true, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + tips: { + default: { percentages: [15, 18, 20], amounts: null }, + thresholds: [ + { + minSubtotal: 2500, + maxSubtotal: 5000, + percentages: [5, 8, 12], + amounts: null, + }, + ], + }, + }, + draftOrderOverrides: { + totals: { + subTotal: { value: 2500, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + shippingTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + total: { value: 2500, currencyCode: 'USD' }, + }, + }, + }); + await waitForCheckoutReady(); + + expect(await screen.findByRole('button', { name: /5%/ })).toBeVisible(); + expect(await screen.findByRole('button', { name: /8%/ })).toBeVisible(); + expect(await screen.findByRole('button', { name: /12%/ })).toBeVisible(); + }); + + it('applies threshold at boundary: subtotal equals maxSubtotal', async () => { + renderCheckout({ + sessionOverrides: { + enableTips: true, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + tips: { + default: { percentages: [15, 18, 20], amounts: null }, + thresholds: [ + { + minSubtotal: 2000, + maxSubtotal: 2500, + percentages: [5, 8, 12], + amounts: null, + }, + ], + }, + }, + draftOrderOverrides: { + totals: { + subTotal: { value: 2500, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + shippingTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + total: { value: 2500, currencyCode: 'USD' }, + }, + }, + }); + await waitForCheckoutReady(); + + expect(await screen.findByRole('button', { name: /5%/ })).toBeVisible(); + expect(await screen.findByRole('button', { name: /8%/ })).toBeVisible(); + expect(await screen.findByRole('button', { name: /12%/ })).toBeVisible(); + }); + + it('clicking a threshold amount button selects it and updates the total', async () => { + const { user } = renderCheckout({ + sessionOverrides: { + enableTips: true, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + tips: { + default: { percentages: null, amounts: null }, + thresholds: [ + { + minSubtotal: 2000, + maxSubtotal: 5000, + amounts: [200, 500, 1000], + percentages: null, + }, + ], + }, + }, + draftOrderOverrides: { + totals: { + subTotal: { value: 2500, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + shippingTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + total: { value: 2500, currencyCode: 'USD' }, + }, + }, + }); + await waitForCheckoutReady(); + + const fiveDollarBtn = await screen.findByRole('button', { + name: /\$5\.00/, + }); + await user.click(fiveDollarBtn); + + await waitFor(() => { + expect(fiveDollarBtn).toHaveAttribute('aria-checked', 'true'); + expect(screen.getAllByText('$30.00').length).toBeGreaterThan(0); + }); + }); + + it('uses default amounts when options.default.amounts is provided and no threshold matches', async () => { + renderCheckout({ + sessionOverrides: { + enableTips: true, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + tips: { + default: { percentages: null, amounts: [100, 300, 500] }, + thresholds: [ + { + minSubtotal: 100000, + maxSubtotal: 200000, + percentages: [1, 2, 3], + amounts: null, + }, + ], + }, + }, + draftOrderOverrides: { + totals: { + subTotal: { value: 2500, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + shippingTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + total: { value: 2500, currencyCode: 'USD' }, + }, + }, + }); + await waitForCheckoutReady(); + + expect( + await screen.findByRole('button', { name: /\$1\.00/ }) + ).toBeVisible(); + expect( + await screen.findByRole('button', { name: /\$3\.00/ }) + ).toBeVisible(); + expect( + await screen.findByRole('button', { name: /\$5\.00/ }) + ).toBeVisible(); + expect( + screen.queryByRole('button', { name: /15%/ }) + ).not.toBeInTheDocument(); + }); + + it('falls back to DEFAULT_TIP_PERCENTAGES when no options are provided', async () => { + renderCheckout({ + sessionOverrides: { + enableTips: true, + enableShipping: false, + enableLocalPickup: false, + enableTaxCollection: false, + tips: null, + }, + draftOrderOverrides: { + totals: { + subTotal: { value: 2500, currencyCode: 'USD' }, + discountTotal: { value: 0, currencyCode: 'USD' }, + shippingTotal: { value: 0, currencyCode: 'USD' }, + taxTotal: { value: 0, currencyCode: 'USD' }, + feeTotal: { value: 0, currencyCode: 'USD' }, + total: { value: 2500, currencyCode: 'USD' }, + }, + }, + }); + await waitForCheckoutReady(); + + expect(await screen.findByRole('button', { name: /15%/ })).toBeVisible(); + expect(await screen.findByRole('button', { name: /18%/ })).toBeVisible(); + expect(await screen.findByRole('button', { name: /20%/ })).toBeVisible(); + }); + }); }); diff --git a/packages/react/src/components/checkout/checkout.tsx b/packages/react/src/components/checkout/checkout.tsx index e3efb6ff..cecfb7fd 100644 --- a/packages/react/src/components/checkout/checkout.tsx +++ b/packages/react/src/components/checkout/checkout.tsx @@ -188,7 +188,7 @@ export const baseCheckoutSchema = z.object({ pickupLeadTime: z.number().nullish(), pickupTimezone: z.string().nullish(), tipAmount: z.number().optional(), - tipPercentage: z.number().optional(), + tipPercentage: z.number().nullish(), paymentMethod: z.string().min(1, 'Select a payment method'), stripePaymentIntent: z.string().optional(), stripePaymentIntentId: z.string().optional(), diff --git a/packages/react/src/components/checkout/form/checkout-form.tsx b/packages/react/src/components/checkout/form/checkout-form.tsx index c6045576..20342e96 100644 --- a/packages/react/src/components/checkout/form/checkout-form.tsx +++ b/packages/react/src/components/checkout/form/checkout-form.tsx @@ -199,11 +199,11 @@ export function CheckoutForm({ const taxTotal = totals?.taxTotal?.value || 0; const feeTotal = totals?.feeTotal?.value || 0; const orderTotal = totals?.total?.value || 0; - const tipTotal = tipAmount || 0; + const tipTotal = session?.enableTips ? tipAmount || 0 : 0; const currencyCode = totals?.total?.currencyCode || 'USD'; const itemCount = items.reduce((sum, item) => sum + (item?.quantity || 0), 0); - const isFree = orderTotal <= 0; + const isFree = orderTotal + tipTotal <= 0; const showExpressButtons = subtotal > 0; const enableDelivery = Boolean( session?.enableShipping || session?.enableLocalPickup @@ -434,7 +434,8 @@ export function CheckoutForm({ diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/ccavenue/ccavenue.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/ccavenue/ccavenue.tsx index 8df40d33..109f2243 100644 --- a/packages/react/src/components/checkout/payment/checkout-buttons/ccavenue/ccavenue.tsx +++ b/packages/react/src/components/checkout/payment/checkout-buttons/ccavenue/ccavenue.tsx @@ -12,6 +12,7 @@ import { useDraftOrderShippingMethods } from '@/components/checkout/shipping/uti import { Button } from '@/components/ui/button'; import { useGoDaddyContext } from '@/godaddy-provider'; import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors'; +import { setRedirectTipAmount } from '@/lib/redirect-tip-storage'; import { cn } from '@/lib/utils'; import { PaymentMethodType } from '@/types'; @@ -22,7 +23,7 @@ const CCAVENUE_TEST_URL = export function CCAvenueCheckoutButton() { const { t, apiHost } = useGoDaddyContext(); - const { setCheckoutErrors, isConfirmingCheckout, ccavenueConfig } = + const { session, setCheckoutErrors, isConfirmingCheckout, ccavenueConfig } = useCheckoutContext(); const isPaymentDisabled = useIsPaymentDisabled(); const form = useFormContext(); @@ -75,6 +76,10 @@ export function CCAvenueCheckoutButton() { return; } + if (session?.enableTips && session?.id) { + setRedirectTipAmount(session.id, form.getValues('tipAmount') ?? 0); + } + const formEl = document.createElement('form'); formEl.method = 'POST'; formEl.action = redirectUrl; @@ -108,6 +113,8 @@ export function CCAvenueCheckoutButton() { setCheckoutErrors, ccavenueConfig?.accessCodeId, redirectUrl, + session?.enableTips, + session?.id, ]); const isBusy = isConfirmingCheckout || isPaymentDisabled; diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx index 5d2ddd19..60bfb81c 100644 --- a/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx +++ b/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useFormContext } from 'react-hook-form'; import { useCheckoutContext } from '@/components/checkout/checkout'; import { useGetPriceAdjustments } from '@/components/checkout/discount/utils/use-get-price-adjustments'; import { @@ -68,6 +69,10 @@ export function ExpressCheckoutButton() { const { data: totals } = useDraftOrderTotals(); const { data: draftOrder } = useDraftOrder(); const currencyCode = totals?.total?.currencyCode || 'USD'; + const form = useFormContext(); + // The tip is charged at confirmation, so every total we recompute for the + // wallet sheet has to stay tip-inclusive. + const tipMinorUnits = session?.enableTips ? form?.watch('tipAmount') || 0 : 0; const [godaddyTotals, setGoDaddyTotals] = useState<{ taxes: { currencyCode: string; value: number }; @@ -219,7 +224,8 @@ export function ExpressCheckoutButton() { // Calculate the correct total in minor units const totalInMinorUnits = - (totals?.subTotal?.value || 0) - + (totals?.subTotal?.value || 0) + + tipMinorUnits - (currentAdjustments?.totalDiscountAmount?.value || 0); const totalAmount = formatCurrency({ @@ -296,6 +302,7 @@ export function ExpressCheckoutButton() { totals, formatCurrency, isDisabled, + tipMinorUnits, ] ); @@ -656,6 +663,7 @@ export function ExpressCheckoutButton() { // Calculate the total in minor units then format with proper currency precision const totalInMinorUnits = (totals?.subTotal?.value || 0) + + tipMinorUnits + godaddyTotals.shipping.value + updatedTaxValue; @@ -803,6 +811,7 @@ export function ExpressCheckoutButton() { // Calculate the total in minor units then format with proper currency precision const totalInMinorUnits = (totals?.subTotal?.value || 0) + + tipMinorUnits + godaddyTotals.shipping.value + updatedTaxValue - adjustmentValue; @@ -1469,6 +1478,7 @@ export function ExpressCheckoutButton() { session, walletSource, setCheckoutErrors, + tipMinorUnits, ]); return ( diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.tsx index 03fb32fc..793a4984 100644 --- a/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.tsx +++ b/packages/react/src/components/checkout/payment/checkout-buttons/express/stripe.tsx @@ -9,6 +9,7 @@ import type { StripeExpressCheckoutElementShippingRateChangeEvent, } from '@stripe/stripe-js'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useFormContext } from 'react-hook-form'; import { useCheckoutContext } from '@/components/checkout/checkout'; import { useGetPriceAdjustments } from '@/components/checkout/discount/utils/use-get-price-adjustments'; import { @@ -63,6 +64,11 @@ export function StripeExpressCheckoutForm() { // Currency configuration const currencyCode = totals?.total?.currencyCode || 'USD'; + const form = useFormContext(); + // The tip is charged at confirmation and is already part of the Elements + // amount, so keep it in every total and line item list we recompute here. + const tipMinorUnits = session?.enableTips ? form?.watch('tipAmount') || 0 : 0; + // State for tracking calculated values during express checkout flow const [calculatedTaxes, setCalculatedTaxes] = useState(null); @@ -273,6 +279,14 @@ export function StripeExpressCheckoutForm() { amount: subtotal, }); + // Add tip if present + if (tipMinorUnits > 0) { + items.push({ + name: t.totals.tip, + amount: tipMinorUnits, + }); + } + // Add shipping if present if (shippingAmount > 0) { items.push({ @@ -299,7 +313,7 @@ export function StripeExpressCheckoutForm() { return items; }, - [totals?.subTotal?.value, t.totals] + [totals?.subTotal?.value, t.totals, tipMinorUnits] ); // Recalculate adjustments when shipping changes @@ -461,7 +475,11 @@ export function StripeExpressCheckoutForm() { // Calculate new total and update Elements amount before resolving // This ensures Stripe's lineItems validation passes const newTotal = - subtotal + defaultRate.amount + taxAmount - discountAmount; + subtotal + + tipMinorUnits + + defaultRate.amount + + taxAmount - + discountAmount; elements?.update({ amount: newTotal }); event.resolve({ @@ -487,6 +505,7 @@ export function StripeExpressCheckoutForm() { elements, totals?.subTotal?.value, t.apiErrors, + tipMinorUnits, ] ); @@ -535,7 +554,11 @@ export function StripeExpressCheckoutForm() { // Calculate new total and update Elements amount before resolving // This ensures Stripe's lineItems validation passes const newTotal = - subtotal + selectedRate.amount + taxAmount - discountAmount; + subtotal + + tipMinorUnits + + selectedRate.amount + + taxAmount - + discountAmount; elements?.update({ amount: newTotal }); event.resolve({ @@ -558,6 +581,7 @@ export function StripeExpressCheckoutForm() { calculatedTaxes, elements, totals?.subTotal?.value, + tipMinorUnits, ] ); diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/mercadopago/mercadopago.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/mercadopago/mercadopago.tsx index 4bdcb036..fc0930ee 100644 --- a/packages/react/src/components/checkout/payment/checkout-buttons/mercadopago/mercadopago.tsx +++ b/packages/react/src/components/checkout/payment/checkout-buttons/mercadopago/mercadopago.tsx @@ -1,5 +1,5 @@ import { LoaderCircle } from 'lucide-react'; -import React, { useCallback, useLayoutEffect, useState } from 'react'; +import React, { useCallback, useLayoutEffect, useRef, useState } from 'react'; import { useFormContext } from 'react-hook-form'; import { useCheckoutContext } from '@/components/checkout/checkout'; import { useDraftOrderTotals } from '@/components/checkout/order/use-draft-order'; @@ -22,6 +22,7 @@ let mpInstance: any = null; let bricksBuilderInstance: any = null; let brickController: any = null; let brickCreationPromise: Promise | null = null; +let brickAmount: number | null = null; let isSubmitting = false; function getMercadoPagoInstance(publicKey: string) { @@ -32,10 +33,26 @@ function getMercadoPagoInstance(publicKey: string) { return { mpInstance, bricksBuilderInstance }; } +function unmountBrick() { + if (!brickController) return; + + try { + brickController.unmount(); + } catch (_e) { + // Ignore unmount errors + } + brickController = null; + brickAmount = null; +} + export function MercadoPagoCheckoutButton() { const { t } = useGoDaddyContext(); - const { mercadoPagoConfig, setCheckoutErrors, isConfirmingCheckout } = - useCheckoutContext(); + const { + mercadoPagoConfig, + setCheckoutErrors, + isConfirmingCheckout, + session, + } = useCheckoutContext(); const isPaymentDisabled = useIsPaymentDisabled(); const { data: totals } = useDraftOrderTotals(); const form = useFormContext(); @@ -46,8 +63,25 @@ export function MercadoPagoCheckoutButton() { const [error, setError] = useState(''); const [isBrickReady, setIsBrickReady] = useState(false); + const [brickRevision, setBrickRevision] = useState(0); const elementId = 'mercadopago-brick-container'; + const tipAmount = form.watch('tipAmount'); + const rawAmount = parseFloat( + formatCurrency({ + amount: + (totals?.total?.value || 0) + + (session?.enableTips ? tipAmount || 0 : 0), + currencyCode: totals?.total?.currencyCode || 'USD', + inputInMinorUnits: true, + returnRaw: true, + }) + ); + const amount = Number.isFinite(rawAmount) ? rawAmount : 0; + + const amountRef = useRef(amount); + amountRef.current = amount; + const getPreferenceId = async () => { const response = await authorizeCheckout.mutateAsync({ paymentToken: '', @@ -108,23 +142,18 @@ export function MercadoPagoCheckoutButton() { const canInitialize = isMercadoPagoLoaded && mercadoPagoConfig?.publicKey; if (canInitialize) { - if (brickController) { - // Brick already exists, onReady callback will mark as ready - setIsBrickReady(true); - } else if (brickCreationPromise) { + if (brickCreationPromise) { // Brick creation in progress, onReady/onError callbacks will handle state + } else if (brickController && brickAmount === amount) { + // Brick already exists for this amount, onReady callback will mark as ready + setIsBrickReady(true); } else { + setIsBrickReady(false); + unmountBrick(); + // Create new brick const renderBrick = async () => { - // Convert from minor units (cents) to major units - const total = parseFloat( - formatCurrency({ - amount: totals?.total?.value || 0, - currencyCode: totals?.total?.currencyCode || 'USD', - inputInMinorUnits: true, - returnRaw: true, - }) - ); + const total = amount; try { const container = document.getElementById(elementId); @@ -137,47 +166,55 @@ export function MercadoPagoCheckoutButton() { const mercadoPagoPreferenceId = await getPreferenceId(); - brickController = await bricksBuilder.create('payment', elementId, { - initialization: { - amount: total, - preferenceId: mercadoPagoPreferenceId, - payer: { email: 'dummy@testuser.com' }, - }, - customization: { - visual: { - hideFormTitle: true, - hidePaymentButton: true, - style: { theme: 'default' }, + const controller = await bricksBuilder.create( + 'payment', + elementId, + { + initialization: { + amount: total, + preferenceId: mercadoPagoPreferenceId, + payer: { email: 'dummy@testuser.com' }, }, - paymentMethods: { - creditCard: 'all', - debitCard: 'all', - maxInstallments: 1, + customization: { + visual: { + hideFormTitle: true, + hidePaymentButton: true, + style: { theme: 'default' }, + }, + paymentMethods: { + creditCard: 'all', + debitCard: 'all', + maxInstallments: 1, + }, }, - }, - callbacks: { - onReady: () => { - setIsBrickReady(true); - const brickContainer = document.getElementById(elementId); - const formElement = brickContainer?.querySelector('form'); - if (formElement) { - formElement.style.padding = '0'; - const childDiv = formElement.querySelector(':scope > div'); - if (childDiv instanceof HTMLElement) { - childDiv.style.margin = '0'; + callbacks: { + onReady: () => { + setIsBrickReady(true); + const brickContainer = document.getElementById(elementId); + const formElement = brickContainer?.querySelector('form'); + if (formElement) { + formElement.style.padding = '0'; + const childDiv = + formElement.querySelector(':scope > div'); + if (childDiv instanceof HTMLElement) { + childDiv.style.margin = '0'; + } } - } - }, - onError: () => { - // Only treat as initialization failure if the brick never became ready. - // Card validation errors are handled by the brick's own UI. - if (!brickController) { - setError(t.errors.failedToInitializePayment); - setIsBrickReady(false); - } + }, + onError: () => { + // Only treat as initialization failure if the brick never became ready. + // Card validation errors are handled by the brick's own UI. + if (!brickController) { + setError(t.errors.failedToInitializePayment); + setIsBrickReady(false); + } + }, }, - }, - }); + } + ); + + brickController = controller; + brickAmount = total; } catch (_err) { setError(t.errors.failedToInitializePayment); setIsBrickReady(false); @@ -188,6 +225,9 @@ export function MercadoPagoCheckoutButton() { brickCreationPromise = renderBrick(); brickCreationPromise.finally(() => { brickCreationPromise = null; + if (brickController && brickAmount !== amountRef.current) { + setBrickRevision(revision => revision + 1); + } }); } } @@ -196,18 +236,15 @@ export function MercadoPagoCheckoutButton() { // Don't unmount if submitting (parent replaces component with loading button) // or if creation is in progress (React Strict Mode double-invocation) if (brickController && !brickCreationPromise && !isSubmitting) { - try { - brickController.unmount(); - } catch (_e) { - // Ignore unmount errors - } - brickController = null; + unmountBrick(); } }; }, [ isMercadoPagoLoaded, mercadoPagoConfig?.publicKey, elementId, + amount, + brickRevision, t.errors.failedToInitializePayment, ]); @@ -223,9 +260,11 @@ export function MercadoPagoCheckoutButton() { await flushCheckoutSync(); - if (brickController) { + if (brickController && brickAmount === amountRef.current) { const { formData } = await brickController.getFormData(); await handleSubmit({ formData }); + } else { + setIsBrickReady(false); } }; 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..a054dfb9 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,10 @@ 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'; export function CCAvenueReturnProvider({ children, @@ -31,21 +35,31 @@ export function CCAvenueReturnProvider({ hasRun.current = true; + const authorizedTipAmount = getRedirectTipAmount(session.id); + 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.', - ]); - } - }); + confirmCheckout + .mutateAsync(confirmInput) + .then(() => { + clearRedirectTipAmount(); + }) + .catch(err => { + if (err instanceof GraphQLErrorWithCodes) { + setCheckoutErrors(err.codes); + } else { + setCheckoutErrors([ + err instanceof Error ? err.message : 'Payment confirmation failed.', + ]); + } + }); }, [session?.token, session?.id, setCheckoutErrors]); return <>{children}; 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..99196ec7 100644 --- a/packages/react/src/components/checkout/payment/utils/stripe-provider.tsx +++ b/packages/react/src/components/checkout/payment/utils/stripe-provider.tsx @@ -1,20 +1,18 @@ 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 } @@ -22,13 +20,13 @@ function StripeElementsUpdater() { export function StripeProvider({ children }: { children: React.ReactNode }) { const { stripeConfig } = useCheckoutContext(); + const { stripePromise, currency, clientSecret, isLoading, amount } = + useStripePaymentIntent(); + if (!stripeConfig?.publishableKey?.trim()) { return <>{children}; } - const { stripePromise, currency, clientSecret, isLoading, amount } = - useStripePaymentIntent(); - if (isLoading || !stripePromise || amount <= 0) { return null; } @@ -46,7 +44,7 @@ export function StripeProvider({ children }: { children: React.ReactNode }) { payment_method_types: ['card'], }} > - + {children} ); @@ -54,7 +52,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..2f05338b --- /dev/null +++ b/packages/react/src/components/checkout/payment/utils/use-authorize-checkout.test.tsx @@ -0,0 +1,131 @@ +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 () => { + const { result } = renderHook(() => useAuthorizeCheckout(), { + wrapper: wrapper({ enableTips: true, tipAmount: 500 }), + }); + + await result.current.mutateAsync({ ...cardFieldsInput, tipAmount: 999 }); + + expect((await authorizedInput())?.tipAmount).toBe(500); + }); + + 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..bedd4fbe 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,4 +1,5 @@ import { useMutation } from '@tanstack/react-query'; +import { useFormContext } from 'react-hook-form'; import { useCheckoutContext } from '@/components/checkout/checkout'; import { useFlushCheckoutSync } from '@/components/checkout/payment/utils/use-flush-checkout-sync'; import { useGoDaddyContext } from '@/godaddy-provider'; @@ -8,15 +9,25 @@ import type { AuthorizeCheckoutSessionInput } from '@/types'; export function useAuthorizeCheckout() { const { session, jwt } = useCheckoutContext(); const { apiHost } = useGoDaddyContext(); + const form = useFormContext(); const flushCheckoutSync = useFlushCheckoutSync(); return useMutation({ mutationFn: async (input: AuthorizeCheckoutSessionInput['input']) => { await flushCheckoutSync(); + // Authorize for the same amount confirmCheckout later captures. Read the + // tip after the sync flush so pending form state is settled. + 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; }, 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 e539b9f4..ee26d99a 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,12 @@ async function renderUseBuildPaymentRequest({ draftOrderOverrides, sessionOverrides, products = [productNode()], + formDefaultValues, }: { draftOrderOverrides?: DeepPartial; sessionOverrides?: DeepPartial; products?: SKUProduct[]; + formDefaultValues?: Partial; } = {}) { const queryClient = createTestQueryClient(); const draftOrder = buildDraftOrder(draftOrderOverrides); @@ -104,7 +123,9 @@ async function renderUseBuildPaymentRequest({ setCheckoutErrors: () => undefined, }} > - + + + ); @@ -377,4 +398,196 @@ 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 total includes tip + expect(requests.poyntExpressRequest.total.amount).toBe('25.00'); + + // Poynt Express includes tip line item so recomputed wallet totals keep it + expect(requests.poyntExpressRequest.lineItems).toEqual( + expect.arrayContaining([ + expect.objectContaining({ label: 'Tip', amount: '5.00' }), + ]) + ); + + // Poynt Standard includes tip line item + expect(requests.poyntStandardRequest.lineItems).toEqual( + expect.arrayContaining([ + expect.objectContaining({ label: 'Tip', amount: '5.00' }), + ]) + ); + }); + + it('includes tax and tip in poyntExpressRequest.total.amount when applicable', 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 }, + }); + + // total is $22.00 (subtotal $20 + tax $2) + tip $3 = $25.00 + expect(requests.poyntExpressRequest.total.amount).toBe('25.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' })]) + ); + }); }); 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 3cd0c5f4..e1df4611 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, @@ -180,6 +181,7 @@ export function useBuildPaymentRequest(): { } { const formatCurrency = useFormatCurrency(); const { session } = useCheckoutContext(); + const form = useFormContext(); const draftOrderTotalsQuery = useDraftOrderTotals(); const draftOrderQuery = useDraftOrder(); @@ -206,7 +208,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', @@ -259,7 +263,7 @@ export function useBuildPaymentRequest(): { total: { label: 'Order Total', amount: formatCurrency({ - amount: totals?.total?.value || 0, + amount: session?.enableTips ? totalWithTipMinorUnits : totalMinorUnits, currencyCode, inputInMinorUnits: true, }), @@ -316,6 +320,19 @@ export function useBuildPaymentRequest(): { }), type: 'final', }, + ...(session?.enableTips + ? [ + { + label: 'Tip', + amount: formatCurrency({ + amount: tipAmount, + currencyCode, + inputInMinorUnits: true, + }), + type: 'final', + }, + ] + : []), ].filter(item => Number.parseFloat(item.amount) !== 0), }; @@ -353,7 +370,7 @@ export function useBuildPaymentRequest(): { transactionInfo: { totalPriceStatus: 'FINAL', totalPrice: formatCurrency({ - amount: totals?.total?.value || 0, + amount: session?.enableTips ? totalWithTipMinorUnits : totalMinorUnits, currencyCode, inputInMinorUnits: true, }), @@ -418,6 +435,23 @@ export function useBuildPaymentRequest(): { type: 'LINE_ITEM', status: 'FINAL', }, + ...(session?.enableTips + ? [ + { + label: 'Tip', + price: Number.parseFloat( + formatCurrency({ + amount: tipAmount, + currencyCode, + inputInMinorUnits: true, + returnRaw: true, + }) + ), + type: 'LINE_ITEM', + status: 'FINAL', + }, + ] + : []), ].filter(item => item?.price !== 0), }, }; @@ -427,7 +461,8 @@ export function useBuildPaymentRequest(): { subtotalMinorUnits + taxMinorUnits + shippingMinorUnits - - discountMinorUnits; + discountMinorUnits + + (session?.enableTips ? tipAmount : 0); const payPalRequest: PayPalRequest = { purchase_units: [ @@ -444,7 +479,9 @@ export function useBuildPaymentRequest(): { item_total: { currency_code: currencyCode, value: formatCurrency({ - amount: subtotalMinorUnits, + amount: session?.enableTips + ? subtotalMinorUnits + tipAmount + : subtotalMinorUnits, currencyCode, inputInMinorUnits: true, returnRaw: true, @@ -479,19 +516,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, }, @@ -541,7 +598,7 @@ export function useBuildPaymentRequest(): { const squarePaymentRequest: SquarePaymentRequest = { amount: formatCurrency({ - amount: totals?.total?.value || 0, + amount: session?.enableTips ? totalWithTipMinorUnits : totalMinorUnits, currencyCode, inputInMinorUnits: true, returnRaw: true, @@ -570,7 +627,7 @@ export function useBuildPaymentRequest(): { total: { label: 'Order Total', amount: formatCurrency({ - amount: subtotalMinorUnits, + amount: session?.enableTips ? totalWithTipMinorUnits : totalMinorUnits, currencyCode, inputInMinorUnits: true, returnRaw: true, @@ -588,6 +645,21 @@ export function useBuildPaymentRequest(): { }), }; }), + // Keep the tip in the line items so the wallet sheet itemizes it and the + // totals recomputed from these line items stay tip-inclusive. + ...(session?.enableTips && tipAmount + ? [ + { + label: 'Tip', + amount: formatCurrency({ + amount: tipAmount, + currencyCode, + inputInMinorUnits: true, + returnRaw: true, + }), + }, + ] + : []), ], }; @@ -595,7 +667,7 @@ export function useBuildPaymentRequest(): { total: { label: 'Order Total', amount: formatCurrency({ - amount: totalMinorUnits, + amount: session?.enableTips ? totalWithTipMinorUnits : totalMinorUnits, currencyCode, inputInMinorUnits: true, returnRaw: true, @@ -640,6 +712,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 8f54baff..ac83ee60 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 @@ -179,6 +179,14 @@ export function useConfirmCheckout() { : undefined, }) : {}; + const tipAmount = session.enableTips + ? (confirmCheckoutInput.tipAmount ?? form.getValues('tipAmount') ?? 0) + : undefined; + const payload = { + ...confirmCheckoutInput, + ...pickUpData, + tipAmount, + }; // keep for debugging // console.log({ @@ -205,21 +213,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'); 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..e6ce322d 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,55 @@ describe('useConfirmExpressCheckout', () => { expect(getOperations('CalculateCheckoutSessionTaxes')).toHaveLength(0); }); + it('sends the tip the wallet sheet authorized when tips are enabled', async () => { + const session = buildCheckoutSession({ enableTips: true }); + 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); + }); + expect(getOperations('ConfirmCheckoutSession')[0]?.input).toMatchObject({ + tipAmount: 1234, + }); + }); + + it('omits the tip when tips are disabled for the session', async () => { + const session = buildCheckoutSession({ enableTips: false }); + 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-confirm-express-checkout.ts b/packages/react/src/components/checkout/payment/utils/use-confirm-express-checkout.ts index 1b7bd2a3..dd2c2509 100644 --- a/packages/react/src/components/checkout/payment/utils/use-confirm-express-checkout.ts +++ b/packages/react/src/components/checkout/payment/utils/use-confirm-express-checkout.ts @@ -1,5 +1,6 @@ import { useMutation } from '@tanstack/react-query'; import { useRef } from 'react'; +import { useFormContext } from 'react-hook-form'; import { redirectToSuccessUrl, useCheckoutContext, @@ -30,6 +31,8 @@ export function useConfirmExpressCheckout() { } = useCheckoutContext(); const { apiHost } = useGoDaddyContext(); const isPaymentDisabled = useIsPaymentDisabled(); + // Express buttons can render outside a form provider, so this may be null. + const form = useFormContext(); const isPendingRef = useRef(false); return useMutation({ @@ -66,6 +69,18 @@ export function useConfirmExpressCheckout() { try { const { isExpress: _isExpress, ...confirmCheckoutInput } = input; + // The wallet sheet and the Stripe Elements amount are tip-inclusive, so + // capture the same tip the customer authorized. Callers may pass their + // own tipAmount; otherwise fall back to the tips section's form value. + const payload = { + ...confirmCheckoutInput, + tipAmount: session.enableTips + ? (confirmCheckoutInput.tipAmount ?? + form?.getValues('tipAmount') ?? + 0) + : undefined, + }; + setCheckoutErrors(undefined); setIsConfirmingCheckout(true); @@ -81,11 +96,11 @@ export function useConfirmExpressCheckout() { const data = jwt ? await confirmCheckout( - confirmCheckoutInput, + payload, { accessToken: jwt, sessionId: session?.id || '' }, apiHost ) - : await confirmCheckout(confirmCheckoutInput, session, apiHost); + : await confirmCheckout(payload, session, apiHost); if (!data) { throw new Error('Express checkout confirmation failed'); 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..b1f9c353 --- /dev/null +++ b/packages/react/src/components/checkout/payment/utils/use-stripe-payment-intent.test.tsx @@ -0,0 +1,246 @@ +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, +}: { + enableClientSecret?: boolean; + updateIntent?: boolean; +}) { + const form = useFormContext(); + const { clientSecret, amount } = useStripePaymentIntent({ + enableClientSecret, + updateIntent, + }); + + return ( + <> +
{clientSecret ?? 'none'}
+
{amount}
+ + + + ); +} + +function Host({ + hostIntent = false, + enableClientSecret = true, + updateIntent = true, +}: { + hostIntent?: boolean; + enableClientSecret?: boolean; + updateIntent?: 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('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..5bee8506 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 @@ -33,15 +33,25 @@ 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; + const amount = session?.enableTips ? 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 +93,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 +126,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 +153,7 @@ export function useStripePaymentIntent({ amount, currency, updateIntent, - intentId, + intentId: existingIntentId ?? intentId, }); }, [ amount, @@ -134,7 +161,8 @@ export function useStripePaymentIntent({ updateIntent, intentId, isLoading, - form, + existingClientSecret, + existingIntentId, paymentIntentMutation.mutate, enableClientSecret, ]); @@ -142,11 +170,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.tsx b/packages/react/src/components/checkout/tips/tips-form.tsx index e9523110..12150376 100644 --- a/packages/react/src/components/checkout/tips/tips-form.tsx +++ b/packages/react/src/components/checkout/tips/tips-form.tsx @@ -21,13 +21,17 @@ 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; } -export function TipsForm({ total, currencyCode }: TipsFormProps) { +const DEFAULT_TIP_PERCENTAGES = [15, 18, 20]; + +export function TipsForm({ subtotal, options, currencyCode }: TipsFormProps) { const { t } = useGoDaddyContext(); const form = useFormContext(); const formatCurrency = useFormatCurrency(); @@ -35,7 +39,25 @@ export function TipsForm({ total, currencyCode }: TipsFormProps) { const calculateTipAmount = (percentage: number): number => { // total is in minor units, so calculate percentage and return in minor units - return Math.round((total * percentage) / 100); + return Math.round((subtotal * percentage) / 100); + }; + + const handleAmountSelect = (amount: number) => { + form.setValue('tipAmount', amount); + form.setValue('tipPercentage', null); + 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) => { @@ -51,7 +73,7 @@ export function TipsForm({ total, currencyCode }: TipsFormProps) { properties: { tipPercentage: percentage, tipAmount: tipAmount, - totalBeforeTip: total, + totalBeforeTip: subtotal, currencyCode, }, }); @@ -69,14 +91,17 @@ 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); + form.setValue('tipAmount', currentTipAmount); form.setValue('tipPercentage', null); // Track custom tip selection @@ -84,14 +109,32 @@ 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 threshold = options?.thresholds?.find( + thres => + (thres?.minSubtotal == null || subtotal >= thres.minSubtotal) && + (thres?.maxSubtotal == null || subtotal <= thres.maxSubtotal) + ); + if (threshold) { + if (threshold.amounts) { + tipAmounts = threshold.amounts; + tipPercentages = undefined; + } else if (threshold.percentages) { + tipPercentages = threshold.percentages; + tipAmounts = undefined; + } + } return (
@@ -100,30 +143,56 @@ export function TipsForm({ total, currencyCode }: TipsFormProps) { role='radiogroup' aria-label={t.tips?.title || 'Tip amount'} > - {tipPercentages.map(percentage => ( - - ))} + {tipAmounts?.length + ? tipAmounts.map(amount => ( + + )) + : (tipPercentages || DEFAULT_TIP_PERCENTAGES).map(percentage => ( + + ))}
- {showCustomTip && ( + {showCustomTip ? ( - )} + ) : null}
); } @@ -181,7 +253,7 @@ export function TipsForm({ total, currencyCode }: TipsFormProps) { */ interface CustomTipInputProps { currencyCode?: string; - total: number; + subtotal: number; formatCurrency: (options: FormatCurrencyOptions) => string; } @@ -216,7 +288,7 @@ function symbolPadding(symbol: string, position: 'prefix' | 'suffix') { function CustomTipInput({ currencyCode, - total, + subtotal, formatCurrency, }: CustomTipInputProps) { const { t } = useGoDaddyContext(); @@ -278,6 +350,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 +361,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/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..f9b1b1a2 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": { @@ -3999,6 +4008,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 +6579,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" @@ -7680,6 +7965,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 +8004,13 @@ const introspection = { "name": "String" } } + }, + { + "name": "tipAmount", + "type": { + "kind": "SCALAR", + "name": "Int" + } } ], "isOneOf": false @@ -7735,6 +8040,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 +8137,13 @@ const introspection = { "kind": "INPUT_OBJECT", "name": "MoneyInput" } + }, + { + "name": "tipAmount", + "type": { + "kind": "SCALAR", + "name": "Int" + } } ], "isOneOf": false @@ -8091,6 +8416,13 @@ const introspection = { "name": "CheckoutSessionTaxesOptionsInput" } }, + { + "name": "tips", + "type": { + "kind": "INPUT_OBJECT", + "name": "CheckoutSessionTipsInput" + } + }, { "name": "url", "type": { @@ -8523,6 +8855,13 @@ const introspection = { "name": "String" } }, + { + "name": "tips", + "type": { + "kind": "INPUT_OBJECT", + "name": "CheckoutSessionTipsInput" + } + }, { "name": "url", "type": { @@ -10844,6 +11183,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/redirect-tip-storage.test.ts b/packages/react/src/lib/redirect-tip-storage.test.ts new file mode 100644 index 00000000..f02c0a7b --- /dev/null +++ b/packages/react/src/lib/redirect-tip-storage.test.ts @@ -0,0 +1,89 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + clearRedirectTipAmount, + getRedirectTipAmount, + setRedirectTipAmount, +} from './redirect-tip-storage'; + +const KEY = 'godaddy-checkout-redirect-tip'; + +describe('redirect tip storage', () => { + beforeEach(() => { + window.sessionStorage.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + window.sessionStorage.clear(); + }); + + it('round-trips a tip for the session it was saved for', () => { + setRedirectTipAmount('session-1', 500); + + 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('ignores a request without a session id', () => { + setRedirectTipAmount('', 500); + + expect(window.sessionStorage.getItem(KEY)).toBeNull(); + expect(getRedirectTipAmount('')).toBeNull(); + }); + + it('returns null for unparsable stored data', () => { + window.sessionStorage.setItem(KEY, 'not-json'); + + expect(getRedirectTipAmount('session-1')).toBeNull(); + }); + + it('returns null when the stored tip is not a number', () => { + window.sessionStorage.setItem( + KEY, + JSON.stringify({ sessionId: 'session-1', tipAmount: '500' }) + ); + + expect(getRedirectTipAmount('session-1')).toBeNull(); + }); + + it('clears the saved tip', () => { + setRedirectTipAmount('session-1', 500); + clearRedirectTipAmount(); + + expect(getRedirectTipAmount('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); + }); + + it('does not throw when storage is unavailable', () => { + 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)).not.toThrow(); + expect(getRedirectTipAmount('session-1')).toBeNull(); + expect(() => clearRedirectTipAmount()).not.toThrow(); + }); +}); 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..1df0c667 --- /dev/null +++ b/packages/react/src/lib/redirect-tip-storage.ts @@ -0,0 +1,83 @@ +const REDIRECT_TIP_KEY = 'godaddy-checkout-redirect-tip'; + +type StoredRedirectTip = { + sessionId: string; + tipAmount: number; +}; + +/** + * 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 authorized amount + * is tip-inclusive, so the tip has to outlive the page or the order is recorded + * for less than the customer was charged. + * + * Session storage is where the checkout JWT already lives + * (`godaddy-checkout-jwt`), and the return-side confirmation cannot run without + * that token, so the tip is exactly as durable as the rest of the return flow. + */ +export function setRedirectTipAmount( + sessionId: string, + tipAmount: number +): void { + if (typeof window === 'undefined' || !sessionId) { + // SSR safety + return; + } + + try { + window.sessionStorage.setItem( + REDIRECT_TIP_KEY, + JSON.stringify({ sessionId, tipAmount } satisfies StoredRedirectTip) + ); + } catch { + // Storage can be unavailable (private browsing, disabled storage). + } +} + +/** + * Read the tip saved for `sessionId`. + * + * Returns null when nothing was saved, the saved tip belongs to a different + * checkout session, or storage is unreadable. + */ +export function getRedirectTipAmount(sessionId: string): number | null { + if (typeof window === 'undefined' || !sessionId) { + // SSR safety + return null; + } + + try { + const raw = window.sessionStorage.getItem(REDIRECT_TIP_KEY); + if (!raw) { + return null; + } + + const stored = JSON.parse(raw) as Partial | null; + if (stored?.sessionId !== sessionId) { + return null; + } + + return typeof stored.tipAmount === 'number' ? stored.tipAmount : null; + } catch { + return null; + } +} + +/** + * Remove the saved redirect tip. + */ +export function clearRedirectTipAmount(): void { + if (typeof window === 'undefined') { + // SSR safety + return; + } + + try { + window.sessionStorage.removeItem(REDIRECT_TIP_KEY); + } catch { + // Storage can be unavailable (private browsing, disabled storage). + } +}