Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 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
02bfd2e
Merge remote-tracking branch 'upstream'
cshing-godaddy Aug 7, 2026
5485aa0
claude feedback
cshing-godaddy Aug 7, 2026
e700f60
claude feedback
cshing-godaddy Aug 7, 2026
60214ae
claude feedback
cshing-godaddy Aug 7, 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
7 changes: 7 additions & 0 deletions .changeset/fruity-dots-jog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@godaddy/react": minor
---

Support tips in unified checkout

Adds the `tips` session config surface (`default` and threshold-based `amounts`/`percentages` presets) alongside `enableTips`, and includes the selected tip in wallet sheet totals and the authorized/confirmed amount.
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
59 changes: 59 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,64 @@ 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.

Throughout this section, "subtotal" means the order's **item subtotal** (`totals.subTotal`) — the sum of item prices **before** discounts, shipping, fees and tax. It is not the order total the customer pays. See [Subtotal basis](#behavior-notes) below.

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's item subtotal falls within the specified range.

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `minSubtotal` | number | Yes | Minimum item 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 item subtotal (inclusive) in the smallest currency unit for this threshold to apply. Required by the API — omitting it fails with `INVALID_TIP_THRESHOLD`. Must be greater than `minSubtotal`. |
| `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

- **Subtotal basis** — `minSubtotal`, `maxSubtotal` and every `percentages` calculation use the order's item subtotal (`totals.subTotal`), which is the sum of item prices **before discounts, shipping, fees and tax**. A $50 cart with a $20 discount, $6 shipping and $2 tax has a subtotal of `5000`, not the `3800` the customer pays, so it matches a `0–5000` threshold and `20%` offers `1000`. Configure ranges against the pre-discount cart value, not the amount charged.
- **Threshold matching is client-side** — Checkout selects the preset list. The API stores `tips` and validates its shape, but never re-derives which threshold applied.
- **Tip ceiling is measured against the order total** — Independent of the presets, the API rejects a `tipAmount` above 100% of the **order total** (post-discount, tax and shipping included) or `2000` minor units, whichever is greater, with `TIP_EXCEEDS_LIMIT`. Note the asymmetry: thresholds bucket on the subtotal, this bound uses the total. Large fixed `amounts` can therefore be rejected on a heavily discounted order — e.g. `amounts: [2500, 5000, 10000]` on an order totalling `1000` allows at most `2000`.
- **Threshold matching** — Checkout uses the **first** threshold whose range contains the item 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", and the API does not check `tipAmount` against the configured options, so the confirmed tip 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