From 6aacebf8803274cbde584e480265074e2be7bb88 Mon Sep 17 00:00:00 2001 From: Max Harrison Date: Mon, 7 Sep 2026 09:50:25 +0100 Subject: [PATCH] Add Google Pay offers to the web sheet Google Pay web declares no offerInfo and no OFFER callback intent, so a buyer cannot apply a promotion in the sheet. Declare the merchant's offers, add OFFER to the derived callbackIntents, and route the OFFER trigger to a merchant onOfferChange callback that can update the total, line items or the offers themselves. Reuses the correlated data-change handshake added for shipping. --- .changeset/google-pay-web-offers.md | 6 ++ packages/browser/lib/ui/googlePay.ts | 5 ++ packages/browser/test/googlePay.test.ts | 55 +++++++++++++++++++ packages/types/uiComponents.ts | 27 +++++++++ .../ui-components/src/GooglePay/index.tsx | 29 ++++++++-- packages/ui-components/src/GooglePay/types.ts | 2 + .../ui-components/src/GooglePay/utilities.ts | 14 +++++ .../ui-components/test/GooglePay.test.tsx | 34 ++++++++++++ .../test/GooglePayRequest.test.ts | 43 +++++++++++++++ 9 files changed, 210 insertions(+), 5 deletions(-) create mode 100644 .changeset/google-pay-web-offers.md diff --git a/.changeset/google-pay-web-offers.md b/.changeset/google-pay-web-offers.md new file mode 100644 index 00000000..8f48cbe5 --- /dev/null +++ b/.changeset/google-pay-web-offers.md @@ -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`. diff --git a/packages/browser/lib/ui/googlePay.ts b/packages/browser/lib/ui/googlePay.ts index 6bd02c2f..1c0dd00a 100644 --- a/packages/browser/lib/ui/googlePay.ts +++ b/packages/browser/lib/ui/googlePay.ts @@ -148,6 +148,10 @@ export default class GooglePay { #runDataChangeCallback( payload: GooglePayDataChangeRequest ): Promise | 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); @@ -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, }, }; diff --git a/packages/browser/test/googlePay.test.ts b/packages/browser/test/googlePay.test.ts index 32bccdd4..d13246b0 100644 --- a/packages/browser/test/googlePay.test.ts +++ b/packages/browser/test/googlePay.test.ts @@ -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", @@ -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, diff --git a/packages/types/uiComponents.ts b/packages/types/uiComponents.ts index 17391545..5493d5e3 100644 --- a/packages/types/uiComponents.ts +++ b/packages/types/uiComponents.ts @@ -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 { @@ -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[]; @@ -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; } @@ -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 { @@ -596,6 +611,18 @@ export interface GooglePayOptions { onShippingOptionChange?: ( optionId: string ) => Promise; + /** + * 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; theme?: ThemeDefinition; } diff --git a/packages/ui-components/src/GooglePay/index.tsx b/packages/ui-components/src/GooglePay/index.tsx index 9cd74ca9..a2504e67 100644 --- a/packages/ui-components/src/GooglePay/index.tsx +++ b/packages/ui-components/src/GooglePay/index.tsx @@ -4,6 +4,7 @@ import { buildPaymentRequest, buildTransactionInfo, exchangePaymentData, + offerInfo, shippingOptionParameters, } from "./utilities"; import { setSize } from "../utilities/resize"; @@ -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; @@ -115,6 +126,7 @@ export function GooglePay({ config }: GooglePayProps) { trigger: data.callbackTrigger, shippingAddress: data.shippingAddress ?? null, shippingOptionId: data.shippingOptionData?.id ?? null, + redemptionCodes: data.offerData?.redemptionCodes ?? null, }); } ); @@ -122,8 +134,7 @@ export function GooglePay({ config }: GooglePayProps) { 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, }, @@ -147,6 +158,10 @@ export function GooglePay({ config }: GooglePayProps) { ); } + if (update.offers) { + result.newOfferInfo = offerInfo(update.offers); + } + return result; }, onPaymentAuthorized: async (data) => { @@ -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})$/; diff --git a/packages/ui-components/src/GooglePay/types.ts b/packages/ui-components/src/GooglePay/types.ts index 6e65c0a9..e7caf61a 100644 --- a/packages/ui-components/src/GooglePay/types.ts +++ b/packages/ui-components/src/GooglePay/types.ts @@ -2,6 +2,7 @@ import { GooglePayBillingAddressConfig, GooglePayButtonColor, GooglePayButtonType, + GooglePayOffer, GooglePayShippingAddressConfig, GooglePayShippingOptionsConfig, TransactionDetailsWithDomain, @@ -18,5 +19,6 @@ export interface GooglePayConfig { billingAddress?: GooglePayBillingAddressConfig; shippingAddress?: GooglePayShippingAddressConfig; shippingOptions?: GooglePayShippingOptionsConfig; + offers?: GooglePayOffer[]; emailRequired?: boolean; } diff --git a/packages/ui-components/src/GooglePay/utilities.ts b/packages/ui-components/src/GooglePay/utilities.ts index cd3c2784..5c8b6f95 100644 --- a/packages/ui-components/src/GooglePay/utilities.ts +++ b/packages/ui-components/src/GooglePay/utilities.ts @@ -1,5 +1,6 @@ import { EncryptedGooglePayData, + GooglePayOffer, GooglePayShippingOptionsConfig, MerchantDetail, TransactionLineItem, @@ -75,6 +76,7 @@ export function buildPaymentRequest( ), } : {}), + ...(config.offers?.length ? { offerInfo: offerInfo(config.offers) } : {}), transactionInfo: buildTransactionInfo(config, merchant.name), callbackIntents: callbackIntents(config), }; @@ -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, diff --git a/packages/ui-components/test/GooglePay.test.tsx b/packages/ui-components/test/GooglePay.test.tsx index 468746e8..0898e8f6 100644 --- a/packages/ui-components/test/GooglePay.test.tsx +++ b/packages/ui-components/test/GooglePay.test.tsx @@ -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(() => { diff --git a/packages/ui-components/test/GooglePayRequest.test.ts b/packages/ui-components/test/GooglePayRequest.test.ts index e9d0ca7c..52b24bee 100644 --- a/packages/ui-components/test/GooglePayRequest.test.ts +++ b/packages/ui-components/test/GooglePayRequest.test.ts @@ -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"; @@ -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"]); @@ -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({