Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
c4c5f02
add button cursor-pointer
cshing-godaddy Jul 1, 2026
1785322
improve tip btn styling, fix custom tip input
cshing-godaddy Jul 1, 2026
949b03c
enable tips in nextjs example
cshing-godaddy Jul 1, 2026
cfed7a4
fix tipPercentage schema
cshing-godaddy Jul 1, 2026
0d559e5
pass tipAmount in confirmCheckout
cshing-godaddy Jul 1, 2026
ac650af
add tipAmount definition
cshing-godaddy Jul 1, 2026
813e2a3
formatting
cshing-godaddy Jul 1, 2026
3878b05
add tests
cshing-godaddy Jul 1, 2026
d1d3bb3
changeset
cshing-godaddy Jul 1, 2026
b229861
Merge remote-tracking branch 'upstream/main'
cshing-godaddy Jul 6, 2026
5f148d9
Merge branch 'main' into main
pbennett1-godaddy Jul 8, 2026
9f4fc31
calculate tips from subtotal instead of total
cshing-godaddy Jul 9, 2026
112c161
add tips definition
cshing-godaddy Jul 9, 2026
8197e34
handle tip amounts
cshing-godaddy Jul 9, 2026
2eb2f64
fix tip amount selection
cshing-godaddy Jul 9, 2026
0e5649f
lint
cshing-godaddy Jul 10, 2026
8962669
handle tip thresholds
cshing-godaddy Jul 10, 2026
6afe9d4
regenerate from schema
cshing-godaddy Jul 10, 2026
c74a86b
renegerate checkout-env for MutationAuthorizeCheckoutSessionInput.tip…
cshing-godaddy Jul 10, 2026
db41364
revert add tipAmount
cshing-godaddy Jul 10, 2026
6db8aff
add tipAmount to provider payment request total
cshing-godaddy Jul 10, 2026
773b1b8
lint
cshing-godaddy Jul 10, 2026
72ac855
check session.enableTips before adding tipAmount
cshing-godaddy Jul 10, 2026
daac42e
update tests
cshing-godaddy Jul 10, 2026
4261978
remove feeProgramId
cshing-godaddy Jul 13, 2026
af81b66
pr feedback
cshing-godaddy Jul 13, 2026
539443e
update express request to use total instead of subtotal
cshing-godaddy Jul 15, 2026
c14a32d
update tests
cshing-godaddy Jul 15, 2026
fefe8cc
Merge branch 'main' into main
cshing-godaddy Jul 24, 2026
026d2b4
claude feedback
cshing-godaddy Jul 27, 2026
dd11982
claude feedback
cshing-godaddy Jul 27, 2026
53e5be1
add fallback
cshing-godaddy Jul 27, 2026
3d597b2
include tip in useAuthorizeCheckout
cshing-godaddy Jul 30, 2026
e30bb0a
thread tip into useConfirmExpressCheckout
cshing-godaddy Jul 30, 2026
2c5913c
update README
cshing-godaddy Jul 30, 2026
8bb0b66
gate tips in checkout-form
cshing-godaddy Jul 30, 2026
f59c38b
remove dead code
cshing-godaddy Jul 30, 2026
0f13a58
handle tip for zero-total orders
cshing-godaddy Jul 30, 2026
752d60e
preserve ccavenue tip
cshing-godaddy Jul 30, 2026
d21e30d
rebuild mercado brick on amount change
cshing-godaddy Jul 30, 2026
e1e8292
fix stripe intent tip
cshing-godaddy Jul 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fruity-dots-jog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@godaddy/react": patch
---

Support tips in unified checkout
13 changes: 13 additions & 0 deletions examples/nextjs/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
54 changes: 54 additions & 0 deletions packages/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
| 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 });
});
});
});
Loading