From da533aa4c47487517599271aec079f5a67f76dd6 Mon Sep 17 00:00:00 2001 From: Alexander Reyes-Wainwright Date: Tue, 8 Sep 2026 11:04:57 +0100 Subject: [PATCH 1/8] Surface a failed Apple Pay credentials exchange on the error event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CARD-1247. #exchangeApplePaymentData returned res.json() without checking res.ok, so a 500 from /frontend/apple-pay/credentials was handed back as if it were a successful exchange. It passed the tryCatch that guards the call and the handler then died on encrypted.card.displayName — thrown outside the caught region, so no error event fired and response.complete() was never called, leaving the sheet spinning until Apple timed it out. Check the status and the body shape, and complete the sheet as failed on the error path so a credentials failure reaches the merchant's error handler. The message is built from the documented API Error schema (detail, falling back to title) plus the HTTP status. Found while investigating the Upgrow outage, where this masked a five-day server-side failure across ~30 real customer authorizations. --- .../apple-pay-credentials-error-handling.md | 5 + packages/browser/lib/ui/ApplePay/index.ts | 26 +++- packages/browser/test/applePay.test.ts | 142 ++++++++++++++++++ 3 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 .changeset/apple-pay-credentials-error-handling.md diff --git a/.changeset/apple-pay-credentials-error-handling.md b/.changeset/apple-pay-credentials-error-handling.md new file mode 100644 index 00000000..5c691732 --- /dev/null +++ b/.changeset/apple-pay-credentials-error-handling.md @@ -0,0 +1,5 @@ +--- +"@evervault/browser": patch +--- + +Surface a failed Apple Pay credentials exchange on the `error` event instead of throwing an uncaught `TypeError` out of the click handler. `POST /frontend/apple-pay/credentials` responses are now checked for a non-2xx status and for missing card credentials, so an API failure reaches the merchant's error handler with the API's detail and status, and the Apple Pay sheet is completed as failed rather than left spinning until Apple times it out. Previously the error body was returned as if it were a successful exchange and the handler died on `card.displayName`, so no error event fired at all. diff --git a/packages/browser/lib/ui/ApplePay/index.ts b/packages/browser/lib/ui/ApplePay/index.ts index 5191bf6e..7c551079 100644 --- a/packages/browser/lib/ui/ApplePay/index.ts +++ b/packages/browser/lib/ui/ApplePay/index.ts @@ -31,6 +31,17 @@ import { Transaction } from "../../resources/transaction"; const APPLE_PAY_SCRIPT_URL = "https://applepay.cdn-apple.com/jsapi/1.latest/apple-pay-sdk.js"; +type ApiErrorBody = { detail?: string; title?: string }; + +async function credentialsFailureMessage(res: Response): Promise { + const [body] = await tryCatch(res.json()); + const detail = body?.detail ?? body?.title; + + return detail + ? `Apple Pay credentials exchange failed (${res.status}): ${detail}` + : `Apple Pay credentials exchange failed (${res.status})`; +} + export type ApplePayButtonOptions = { type?: ApplePayButtonType; style?: ApplePayButtonStyle; @@ -269,6 +280,7 @@ export default class ApplePayButton { if (encryptedError) { this.#events.dispatch("error", encryptedError.message); + await response.complete("fail"); return; } @@ -385,7 +397,19 @@ export default class ApplePayButton { body: JSON.stringify(requestBody), }); - return res.json(); + if (!res.ok) { + throw new Error(await credentialsFailureMessage(res)); + } + + const [encrypted] = await tryCatch(res.json()); + + if (!encrypted?.card) { + throw new Error( + "Apple Pay credentials exchange returned no card credentials" + ); + } + + return encrypted; } on( diff --git a/packages/browser/test/applePay.test.ts b/packages/browser/test/applePay.test.ts index cdb4ee38..4e92f276 100644 --- a/packages/browser/test/applePay.test.ts +++ b/packages/browser/test/applePay.test.ts @@ -1744,6 +1744,148 @@ describe("ApplePayButton process() payload", () => { }); }); +describe("ApplePayButton credentials exchange", () => { + function createSessionWithResponse() { + const response = { + details: { + token: { + paymentData: {}, + paymentMethod: { displayName: "Visa 1234", type: "credit" }, + }, + }, + complete: vi.fn().mockResolvedValue(undefined), + }; + + return { + response, + session: { show: vi.fn().mockResolvedValue(response), abort: vi.fn() }, + }; + } + + function mountButton() { + const { response, session } = createSessionWithResponse(); + buildSessionMock.mockResolvedValue(session); + + const error = vi.fn(); + const process = vi.fn().mockResolvedValue(undefined); + const apple = new ApplePayButton(createMockClient(), createTransaction(), { + process, + }); + apple.on("error", error); + + return { apple, error, process, response }; + } + + beforeEach(() => { + buildSessionMock.mockReset(); + vi.spyOn(applePayUtilities, "buildSession").mockImplementation( + buildSessionMock + ); + + vi.stubGlobal("PaymentRequest", class PaymentRequest {}); + + vi.stubGlobal("ApplePaySession", { + applePayCapabilities: vi.fn().mockResolvedValue({ + paymentCredentialStatus: "paymentCredentialsAvailable", + }), + }); + + const script = document.createElement("script"); + script.src = + "https://applepay.cdn-apple.com/jsapi/1.latest/apple-pay-sdk.js"; + document.body.appendChild(script); + }); + + afterEach(() => { + document.body.innerHTML = ""; + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("dispatches error and fails the sheet when the exchange returns a non-2xx", async () => { + server.use( + http.post(`${apiUrl}/frontend/apple-pay/credentials`, () => + HttpResponse.json( + { + code: "internal-error", + title: "Internal Error", + detail: "Unable to decrypt the payment token", + }, + { status: 500 } + ) + ) + ); + + const { apple, error, process, response } = mountButton(); + + await clickApplePayButton(apple); + + await vi.waitFor(() => expect(error).toHaveBeenCalledOnce()); + expect(error).toHaveBeenCalledWith( + "Apple Pay credentials exchange failed (500): Unable to decrypt the payment token" + ); + expect(process).not.toHaveBeenCalled(); + expect(response.complete).toHaveBeenCalledOnce(); + expect(response.complete).toHaveBeenCalledWith("fail"); + }); + + it("falls back to the status when the error body carries no detail", async () => { + server.use( + http.post( + `${apiUrl}/frontend/apple-pay/credentials`, + () => new HttpResponse(null, { status: 502 }) + ) + ); + + const { apple, error, process } = mountButton(); + + await clickApplePayButton(apple); + + await vi.waitFor(() => expect(error).toHaveBeenCalledOnce()); + expect(error).toHaveBeenCalledWith( + "Apple Pay credentials exchange failed (502)" + ); + expect(process).not.toHaveBeenCalled(); + }); + + it("dispatches error when a 200 response carries no card credentials", async () => { + server.use( + http.post(`${apiUrl}/frontend/apple-pay/credentials`, () => + HttpResponse.json({}) + ) + ); + + const { apple, error, process, response } = mountButton(); + + await clickApplePayButton(apple); + + await vi.waitFor(() => expect(error).toHaveBeenCalledOnce()); + expect(error).toHaveBeenCalledWith( + "Apple Pay credentials exchange returned no card credentials" + ); + expect(process).not.toHaveBeenCalled(); + expect(response.complete).toHaveBeenCalledOnce(); + expect(response.complete).toHaveBeenCalledWith("fail"); + }); + + it("completes the sheet successfully when the exchange succeeds", async () => { + server.use( + http.post(`${apiUrl}/frontend/apple-pay/credentials`, () => + HttpResponse.json({ card: {} }) + ) + ); + + const { apple, error, process, response } = mountButton(); + + await clickApplePayButton(apple); + + await vi.waitFor(() => expect(process).toHaveBeenCalledOnce()); + expect(error).not.toHaveBeenCalled(); + expect(response.complete).toHaveBeenCalledOnce(); + expect(response.complete).toHaveBeenCalledWith("success"); + }); +}); + describe("ApplePayButton.availability", () => { function stubApplePaySession( capabilities: From 913d1468917b57c96bd4fc5f701a29eea283b37c Mon Sep 17 00:00:00 2001 From: Alexander Reyes-Wainwright Date: Tue, 8 Sep 2026 10:55:56 +0100 Subject: [PATCH 2/8] Share the Apple Pay SDK load across ApplePayButton instances A second ApplePayButton on the same page found the first instance's script tag before Apple's SDK had executed, resolved its script wait immediately, and cached the resulting "unsupported" for the life of the instance, so the button never mounted. Move script loading to a single module-level promise and stop memoizing an "unsupported" availability result. --- .changeset/apple-pay-shared-sdk-load.md | 5 + packages/browser/lib/ui/ApplePay/index.ts | 131 +++++++++++++--------- packages/browser/test/applePay.test.ts | 59 +++++++++- 3 files changed, 142 insertions(+), 53 deletions(-) create mode 100644 .changeset/apple-pay-shared-sdk-load.md diff --git a/.changeset/apple-pay-shared-sdk-load.md b/.changeset/apple-pay-shared-sdk-load.md new file mode 100644 index 00000000..3e2c3765 --- /dev/null +++ b/.changeset/apple-pay-shared-sdk-load.md @@ -0,0 +1,5 @@ +--- +"@evervault/browser": patch +--- + +Fix Apple Pay `availability()` permanently returning `"unsupported"` on every `ApplePayButton` after the first one on a page. The SDK script load is now shared across instances, so a later instance waits for Apple's script to execute instead of treating the first instance's `