Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions .changeset/google-pay-web-offers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@evervault/browser": minor
"@evervault/js": minor
---

Add Google Pay offers on web. `offers` declares the promotions the sheet shows, `onOfferChange` updates the total, line items or offers when the buyer applies or removes one, and the applied codes are surfaced on the `process()` payload as `redemptionCodes`.
5 changes: 5 additions & 0 deletions packages/browser/lib/ui/googlePay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,10 @@ export default class GooglePay {
#runDataChangeCallback(
payload: GooglePayDataChangeRequest
): Promise<GooglePayDataChangeUpdate | void> | undefined {
if (payload.trigger === "OFFER") {
return this.#options.onOfferChange?.(payload.redemptionCodes ?? []);
}

if (payload.trigger === "SHIPPING_OPTION") {
if (!payload.shippingOptionId) return undefined;
return this.#options.onShippingOptionChange?.(payload.shippingOptionId);
Expand All @@ -170,6 +174,7 @@ export default class GooglePay {
billingAddress: this.#options.billingAddress,
shippingAddress: this.#options.shippingAddress,
shippingOptions: this.#options.shippingOptions,
offers: this.#options.offers,
emailRequired: this.#options.emailRequired,
},
};
Expand Down
55 changes: 55 additions & 0 deletions packages/browser/test/googlePay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ const SHIPPING_OPTIONS = {
options: [{ id: "standard", label: "Standard" }],
};

const OFFERS = [
{ redemptionCode: "SAVE10", description: "10% off your order" },
];

const ADDRESS = {
countryCode: "US",
postalCode: "94043",
Expand Down Expand Up @@ -191,6 +195,57 @@ describe("GooglePay data change callbacks", () => {
expect(reply?.payload).toEqual({ id: "gpay-data-change-1" });
});

it("calls onOfferChange with every applied redemption code", async () => {
const onOfferChange = vi.fn().mockResolvedValue({ amount: 900 });
mount({ offers: OFFERS, onOfferChange });

const reply = await raiseDataChange({
trigger: "OFFER",
redemptionCodes: ["SAVE10"],
});

expect(onOfferChange).toHaveBeenCalledOnce();
expect(onOfferChange).toHaveBeenCalledWith(["SAVE10"]);
expect(reply?.payload).toEqual({ id: "gpay-data-change-1", amount: 900 });
});

it("calls onOfferChange with an empty list when the buyer removes an offer", async () => {
const onOfferChange = vi.fn().mockResolvedValue({ amount: 1000 });
mount({ offers: OFFERS, onOfferChange });

await raiseDataChange({ trigger: "OFFER", redemptionCodes: [] });

expect(onOfferChange).toHaveBeenCalledWith([]);
});

it("does not call the shipping callbacks for an offer change", async () => {
const onShippingAddressChange = vi.fn().mockResolvedValue({});
mount({
offers: OFFERS,
shippingAddress: true,
onShippingAddressChange,
onOfferChange: vi.fn().mockResolvedValue({}),
});

await raiseDataChange({ trigger: "OFFER", redemptionCodes: ["SAVE10"] });

expect(onShippingAddressChange).not.toHaveBeenCalled();
});

it("returns new offers so the sheet can drop one that no longer applies", async () => {
mount({
offers: OFFERS,
onOfferChange: vi.fn().mockResolvedValue({ offers: [] }),
});

const reply = await raiseDataChange({
trigger: "OFFER",
redemptionCodes: ["SAVE10"],
});

expect(reply?.payload).toEqual({ id: "gpay-data-change-1", offers: [] });
});

it("turns a thrown callback into an inline sheet error", async () => {
mount({
shippingAddress: true,
Expand Down
27 changes: 27 additions & 0 deletions packages/types/uiComponents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,11 @@ export type EncryptedGooglePayData = (
* when `shippingOptions` were configured on the Google Pay button.
*/
shippingOptionId?: string | null;
/**
* Redemption codes of the offers the buyer applied in the sheet. Present only
* when `offers` were configured on the Google Pay button.
*/
redemptionCodes?: string[] | null;
};

export interface GooglePayErrorMessage {
Expand All @@ -497,6 +502,13 @@ export interface GooglePayErrorMessage {
intent?: google.payments.api.CallbackIntent;
}

export interface GooglePayOffer {
/** Identifies the offer when the buyer applies it. */
redemptionCode: string;
/** Shown to the buyer in the sheet. Google truncates past 60 characters. */
description: string;
}

export interface GooglePayShippingAddressParameters {
/** ISO 3166-1 alpha-2 codes the buyer may ship to, e.g. `["US", "CA"]`. */
allowedCountryCodes?: string[];
Expand Down Expand Up @@ -531,6 +543,8 @@ export interface GooglePayDataChangeUpdate {
amount?: number;
lineItems?: TransactionLineItem[];
shippingOptions?: GooglePayShippingOptionsConfig;
/** Replaces the offers the sheet shows, e.g. to drop one that no longer applies. */
offers?: GooglePayOffer[];
/** Rejects the buyer's selection and shows this error inside the sheet. */
error?: GooglePayErrorMessage;
}
Expand All @@ -545,6 +559,7 @@ export interface GooglePayDataChangeRequest {
trigger: google.payments.api.CallbackTrigger;
shippingAddress?: google.payments.api.IntermediateAddress | null;
shippingOptionId?: string | null;
redemptionCodes?: string[] | null;
}

export interface GooglePayDataChangeResponse extends GooglePayDataChangeUpdate {
Expand Down Expand Up @@ -596,6 +611,18 @@ export interface GooglePayOptions {
onShippingOptionChange?: (
optionId: string
) => Promise<GooglePayDataChangeUpdate | void>;
/**
* Offers the buyer can apply in the sheet. Google renders a promotion entry
* once at least one offer is declared.
*/
offers?: GooglePayOffer[];
/**
* Called when the buyer applies or removes an offer, with every redemption
* code currently applied. Return updated totals, line items or offers.
*/
onOfferChange?: (
redemptionCodes: string[]
) => Promise<GooglePayDataChangeUpdate | void>;
theme?: ThemeDefinition;
}

Expand Down
29 changes: 24 additions & 5 deletions packages/ui-components/src/GooglePay/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
buildPaymentRequest,
buildTransactionInfo,
exchangePaymentData,
offerInfo,
shippingOptionParameters,
} from "./utilities";
import { setSize } from "../utilities/resize";
Expand Down Expand Up @@ -49,9 +50,19 @@ function isPaymentError(
function errorIntent(
data: google.payments.api.IntermediatePaymentData
): google.payments.api.CallbackIntent {
return data.callbackTrigger === "SHIPPING_OPTION"
? "SHIPPING_OPTION"
: "SHIPPING_ADDRESS";
if (data.callbackTrigger === "SHIPPING_OPTION") return "SHIPPING_OPTION";
if (data.callbackTrigger === "OFFER") return "OFFER";
return "SHIPPING_ADDRESS";
}

/** Google shows a reason it does not recognise for the trigger as a generic error. */
function errorReason(
data: google.payments.api.IntermediatePaymentData
): google.payments.api.ErrorReason {
if (data.callbackTrigger === "SHIPPING_OPTION")
return "SHIPPING_OPTION_INVALID";
if (data.callbackTrigger === "OFFER") return "OFFER_INVALID";
return "SHIPPING_ADDRESS_UNSERVICEABLE";
}

let dataChangeSequence = 0;
Expand Down Expand Up @@ -115,15 +126,15 @@ export function GooglePay({ config }: GooglePayProps) {
trigger: data.callbackTrigger,
shippingAddress: data.shippingAddress ?? null,
shippingOptionId: data.shippingOptionData?.id ?? null,
redemptionCodes: data.offerData?.redemptionCodes ?? null,
});
}
);

if (update.error) {
return {
error: {
reason:
update.error.reason || "SHIPPING_ADDRESS_UNSERVICEABLE",
reason: update.error.reason || errorReason(data),
intent: update.error.intent || errorIntent(data),
message: update.error.message,
},
Expand All @@ -147,6 +158,10 @@ export function GooglePay({ config }: GooglePayProps) {
);
}

if (update.offers) {
result.newOfferInfo = offerInfo(update.offers);
}

return result;
},
onPaymentAuthorized: async (data) => {
Expand Down Expand Up @@ -185,6 +200,10 @@ export function GooglePay({ config }: GooglePayProps) {
payload.shippingOptionId = data.shippingOptionData.id;
}

if (data.offerData) {
payload.redemptionCodes = data.offerData.redemptionCodes;
}

const cardDetails = paymentMethodInfo?.cardDetails;
if (cardDetails) {
const fourDigitRegex = /(\d{4})$/;
Expand Down
2 changes: 2 additions & 0 deletions packages/ui-components/src/GooglePay/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
GooglePayBillingAddressConfig,
GooglePayButtonColor,
GooglePayButtonType,
GooglePayOffer,
GooglePayShippingAddressConfig,
GooglePayShippingOptionsConfig,
TransactionDetailsWithDomain,
Expand All @@ -18,5 +19,6 @@ export interface GooglePayConfig {
billingAddress?: GooglePayBillingAddressConfig;
shippingAddress?: GooglePayShippingAddressConfig;
shippingOptions?: GooglePayShippingOptionsConfig;
offers?: GooglePayOffer[];
emailRequired?: boolean;
}
14 changes: 14 additions & 0 deletions packages/ui-components/src/GooglePay/utilities.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
EncryptedGooglePayData,
GooglePayOffer,
GooglePayShippingOptionsConfig,
MerchantDetail,
TransactionLineItem,
Expand Down Expand Up @@ -75,6 +76,7 @@ export function buildPaymentRequest(
),
}
: {}),
...(config.offers?.length ? { offerInfo: offerInfo(config.offers) } : {}),
transactionInfo: buildTransactionInfo(config, merchant.name),
callbackIntents: callbackIntents(config),
};
Expand All @@ -90,10 +92,22 @@ export function callbackIntents(
const intents: google.payments.api.CallbackIntent[] = [];
if (isShippingRequired(config)) intents.push("SHIPPING_ADDRESS");
if (config.shippingOptions) intents.push("SHIPPING_OPTION");
if (config.offers?.length) intents.push("OFFER");
intents.push("PAYMENT_AUTHORIZATION");
return intents;
}

export function offerInfo(
offers: GooglePayOffer[]
): google.payments.api.OfferInfo {
return {
offers: offers.map((offer) => ({
redemptionCode: offer.redemptionCode,
description: offer.description,
})),
};
}

export function buildTransactionInfo(
config: GooglePayConfig,
merchantName: string,
Expand Down
34 changes: 34 additions & 0 deletions packages/ui-components/test/GooglePay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,40 @@ describe("GooglePay shipping data changes", () => {
);
});

it("sends applied redemption codes and applies new offers", async () => {
await mountWithShipping();
replyToDataChange({
amount: 900,
offers: [{ redemptionCode: "SAVE20", description: "20% off" }],
});

const result = await callbacks.onPaymentDataChanged!({
callbackTrigger: "OFFER",
offerData: { redemptionCodes: ["SAVE10"] },
} as google.payments.api.IntermediatePaymentData);

expect(result.newOfferInfo).toEqual({
offers: [{ redemptionCode: "SAVE20", description: "20% off" }],
});
expect(result.newTransactionInfo?.totalPrice).toBe("9.00");
});

it("defaults an offer error to the reason and intent Google expects", async () => {
await mountWithShipping();
replyToDataChange({ error: { message: "That code has expired" } });

const result = await callbacks.onPaymentDataChanged!({
callbackTrigger: "OFFER",
offerData: { redemptionCodes: ["SAVE10"] },
} as google.payments.api.IntermediatePaymentData);

expect(result.error).toEqual({
reason: "OFFER_INVALID",
intent: "OFFER",
message: "That code has expired",
});
});

it("ignores a reply meant for a different data change", async () => {
await mountWithShipping();
vi.spyOn(window.parent, "postMessage").mockImplementation(() => {
Expand Down
43 changes: 43 additions & 0 deletions packages/ui-components/test/GooglePayRequest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
buildPaymentRequest,
callbackIntents,
offerInfo,
shippingOptionParameters,
} from "../src/GooglePay/utilities";
import type { GooglePayConfig } from "../src/GooglePay/types";
Expand Down Expand Up @@ -101,6 +102,35 @@ describe("buildPaymentRequest shipping fields", () => {
});
});

const OFFERS = [
{ redemptionCode: "SAVE10", description: "10% off your order" },
];

describe("buildPaymentRequest offers", () => {
it("omits offerInfo when no offers are configured", () => {
expect(build()).not.toHaveProperty("offerInfo");
});

it("omits offerInfo for an empty offer list", () => {
expect(build({ offers: [] })).not.toHaveProperty("offerInfo");
});

it("declares the configured offers", () => {
expect(build({ offers: OFFERS }).offerInfo).toEqual({ offers: OFFERS });
});

it("keeps only the fields Google accepts per offer", () => {
expect(
offerInfo([
{
redemptionCode: "SAVE10",
description: "10% off your order",
},
]).offers[0]
).toEqual({ redemptionCode: "SAVE10", description: "10% off your order" });
});
});

describe("callbackIntents", () => {
it("asks only for authorization when shipping is not configured", () => {
expect(callbackIntents(BASE_CONFIG)).toEqual(["PAYMENT_AUTHORIZATION"]);
Expand All @@ -113,6 +143,19 @@ describe("callbackIntents", () => {
]);
});

it("adds OFFER when offers are configured", () => {
expect(callbackIntents({ ...BASE_CONFIG, offers: OFFERS })).toEqual([
"OFFER",
"PAYMENT_AUTHORIZATION",
]);
});

it("does not add OFFER for an empty offer list", () => {
expect(callbackIntents({ ...BASE_CONFIG, offers: [] })).toEqual([
"PAYMENT_AUTHORIZATION",
]);
});

it("adds SHIPPING_OPTION when options are offered", () => {
expect(
callbackIntents({
Expand Down