From 22ccdf69b236cd375f5c12cc25a4d0fbc805a873 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Jasi=C5=84ski?= Date: Thu, 12 Nov 2026 09:30:38 +0100 Subject: [PATCH 1/2] fix: dispatch apple pay session events in dc --- src/dynamic-checkout/clients/apple-pay.ts | 44 +++--- src/dynamic-checkout/utils/events.ts | 4 +- src/processout/applepay-wrapper.ts | 6 +- src/processout/applepay.ts | 51 ++++++- test/dynamic-checkout/wallet-events.test.ts | 65 +++++++++ test/processout/applepay.test.ts | 144 ++++++++++++++++++++ test/support/loadNamespace.ts | 24 ++++ 7 files changed, 312 insertions(+), 26 deletions(-) create mode 100644 test/processout/applepay.test.ts diff --git a/src/dynamic-checkout/clients/apple-pay.ts b/src/dynamic-checkout/clients/apple-pay.ts index 0240b435..2dc86348 100644 --- a/src/dynamic-checkout/clients/apple-pay.ts +++ b/src/dynamic-checkout/clients/apple-pay.ts @@ -68,8 +68,18 @@ module ProcessOut { const applePayPaymentMethodData = this.getApplePayPaymentMethodData(invoiceData) const supportedNetworks = this.getSupportedNetworks(invoiceData) - const session = this.processOutInstance.applePay.newSession( - { + const dispatchSessionError = (err: any) => + DynamicCheckoutEventsUtils.dispatchApplePaySessionError( + this.paymentConfig.invoiceId, + err, + this.getApplePayPaymentMethodName(invoiceData), + this.paymentConfig.invoiceDetails.return_url || null, + ) + + let session: ApplePay + + try { + session = this.processOutInstance.applePay.newSession({ total: { label: invoiceData.name, amount: invoiceData.amount, @@ -80,21 +90,21 @@ module ProcessOut { merchantCapabilities: applePayPaymentMethodData.merchant_capabilities, merchantApplePayCertificateId: applePayPaymentMethodData.merchant_id, applePaySessionVersion: 14, - }, - () => - DynamicCheckoutEventsUtils.dispatchApplePayNewSessionEvent( - this.paymentConfig.invoiceId, - this.getApplePayPaymentMethodName(invoiceData), - this.paymentConfig.invoiceDetails.return_url || null, - ), - err => - DynamicCheckoutEventsUtils.dispatchApplePaySessionError( - this.paymentConfig.invoiceId, - err, - this.getApplePayPaymentMethodName(invoiceData), - this.paymentConfig.invoiceDetails.return_url || null, - ), - ) + }) + } catch (err) { + dispatchSessionError(err) + + throw err + } + + session.onmerchantvalidated = () => + DynamicCheckoutEventsUtils.dispatchApplePayNewSessionEvent( + this.paymentConfig.invoiceId, + this.getApplePayPaymentMethodName(invoiceData), + this.paymentConfig.invoiceDetails.return_url || null, + ) + + session.onmerchantvalidationerror = dispatchSessionError session.onpaymentauthorizedPostprocess = () => { DynamicCheckoutEventsUtils.dispatchPaymentSubmittedEvent({ diff --git a/src/dynamic-checkout/utils/events.ts b/src/dynamic-checkout/utils/events.ts index faa85c7a..3ba83d1c 100644 --- a/src/dynamic-checkout/utils/events.ts +++ b/src/dynamic-checkout/utils/events.ts @@ -202,7 +202,9 @@ module ProcessOut { const event = DynamicCheckoutEventsUtils.createEvent( DYNAMIC_CHECKOUT_EVENTS.APPLE_PAY_SESSION_ERROR, { - ...err, + // ProcessOut exceptions carry a string `code`; errors thrown by the Apple Pay SDK only a `name` + error_type: (err && (typeof err.code === "string" ? err.code : err.name)) || null, + error_message: (err && err.message) || null, invoice_id: invoiceId, payment_method_name: "apple_pay", payment_method_display_name: paymentMethodDisplayName, diff --git a/src/processout/applepay-wrapper.ts b/src/processout/applepay-wrapper.ts index 98ec52eb..72691d91 100644 --- a/src/processout/applepay-wrapper.ts +++ b/src/processout/applepay-wrapper.ts @@ -54,11 +54,11 @@ module ProcessOut { /** * newSession creates a new Apple Pay session * @param {ApplePayPaymentRequest} req - * @param {callback} onsuccess - * @param {callback} onerror + * @param {callback} onsuccess unused, kept for backward compatibility + * @param {callback} onerror unused, kept for backward compatibility */ public newSession(req: ApplePayPaymentRequest, - onsuccess: (e: any) => void, onerror?: (e: any) => void): ApplePay { + onsuccess?: (e: any) => void, onerror?: (e: any) => void): ApplePay { return new ApplePay(this.instance, req); } diff --git a/src/processout/applepay.ts b/src/processout/applepay.ts index b438094e..c273edb0 100644 --- a/src/processout/applepay.ts +++ b/src/processout/applepay.ts @@ -83,6 +83,21 @@ module ProcessOut { */ public onpaymentauthorizedPostprocess: (event: any) => void; + /** + * onmerchantvalidated can be set to handle the event when the merchant + * was validated and the Apple Pay session is ready to be used + * @type {() => void} + */ + public onmerchantvalidated: () => void; + + /** + * onmerchantvalidationerror can be set to handle the event when the + * merchant could not be validated. It is called in addition to (and + * right before) the error handler given to tokenize + * @type {(err: Exception) => void} + */ + public onmerchantvalidationerror: (err: Exception) => void; + /** * ApplePay constructor * @param {ProcessOut} instance @@ -121,17 +136,25 @@ module ProcessOut { }, function (data: any, req: XMLHttpRequest, e: Event): void { if (!data.success) { - t.onerror(new Exception(data.error_code, data.message)); - t.session.abort(); - } else t.session.completeMerchantValidation(data.session_payload); + t.handleMerchantValidationError( + new Exception(data.error_code, data.message) + ); + + return; + } + + t.session.completeMerchantValidation(data.session_payload); + + if (t.onmerchantvalidated) { + t.onmerchantvalidated(); + } }, function ( req: XMLHttpRequest, e: Event, errorCode: ApiRequestError ): void { - t.onerror(new Exception(errorCode)); - t.session.abort(); + t.handleMerchantValidationError(new Exception(errorCode)); } ); }; @@ -182,6 +205,24 @@ module ProcessOut { this.onShippingMethodSelectedHandler.bind(this); } + /** + * handleMerchantValidationError notifies the optional + * onmerchantvalidationerror handler, then reports the error and aborts + * the session exactly as before, even if that handler throws + * @param {Exception} err + * @return {void} + */ + protected handleMerchantValidationError(err: Exception): void { + try { + if (this.onmerchantvalidationerror) { + this.onmerchantvalidationerror(err); + } + } finally { + this.onerror(err); + this.session.abort(); + } + } + /** * SetHandlers sets the success and error handlers * @param {(e: any) => void} onsuccess diff --git a/test/dynamic-checkout/wallet-events.test.ts b/test/dynamic-checkout/wallet-events.test.ts index 778ab18b..9cc9dbb8 100644 --- a/test/dynamic-checkout/wallet-events.test.ts +++ b/test/dynamic-checkout/wallet-events.test.ts @@ -115,4 +115,69 @@ describe("Apple Pay", () => { return_url: "https://merchant.test", }) }) + + function createClient(newSession: () => unknown) { + const client = new ProcessOut.ApplePayClient( + { applePay: { newSession } }, + createPaymentConfig(), + ) + client.getSupportedNetworks = () => [] + + return client + } + + function createSessionCallbacks() { + const session = createClient(() => ({})).createApplePaySession(invoiceData) + + return { onSuccess: session.onmerchantvalidated, onError: session.onmerchantvalidationerror } + } + + it("does not pass callbacks to the public newSession API", () => { + const newSession = vi.fn((..._args: unknown[]) => ({})) + + createClient(newSession).createApplePaySession(invoiceData) + + expect(newSession.mock.calls[0]).toHaveLength(1) + expect(dispatchedEvents).toHaveLength(0) + }) + + it("dispatches apple_pay_session_error and rethrows when the session cannot be constructed", () => { + const sdkError = new TypeError("Invalid version") + const client = createClient(() => { + throw sdkError + }) + + expect(() => client.createApplePaySession(invoiceData)).toThrow(sdkError) + expect(getEventTypes()).toEqual(["processout_dynamic_checkout_apple_pay_session_error"]) + expect(dispatchedEvents[0].detail).toMatchObject({ + error_type: "TypeError", + error_message: "Invalid version", + }) + }) + + it("dispatches apple_pay_new_session when the session is created", () => { + createSessionCallbacks().onSuccess() + + expect(getEventTypes()).toEqual(["processout_dynamic_checkout_apple_pay_new_session"]) + expect(dispatchedEvents[0].detail).toEqual({ + payment_method_name: "apple_pay", + payment_method_display_name: "Apple Pay (CAT)", + invoice_id: "iv_test", + return_url: "https://merchant.test", + }) + }) + + it("dispatches apple_pay_session_error with the error code and message", () => { + createSessionCallbacks().onError({ code: "request.validation.error", message: "Bad domain" }) + + expect(getEventTypes()).toEqual(["processout_dynamic_checkout_apple_pay_session_error"]) + expect(dispatchedEvents[0].detail).toEqual({ + error_type: "request.validation.error", + error_message: "Bad domain", + payment_method_name: "apple_pay", + payment_method_display_name: "Apple Pay (CAT)", + invoice_id: "iv_test", + return_url: "https://merchant.test", + }) + }) }) diff --git a/test/processout/applepay.test.ts b/test/processout/applepay.test.ts new file mode 100644 index 00000000..f21f4a9c --- /dev/null +++ b/test/processout/applepay.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it, vi } from "vitest" +import { loadApplePay } from "../support/loadNamespace" + +class FakeApplePaySession { + onvalidatemerchant: (event: { validationURL: string }) => void + completeMerchantValidation = vi.fn() + abort = vi.fn() + begin = vi.fn() +} + +type Respond = (onSuccess: Function, onError: Function) => void + +const merchantValidated: Respond = onSuccess => + onSuccess({ success: true, session_payload: { id: "merchant_session" } }) +const merchantRejected: Respond = onSuccess => + onSuccess({ success: false, error_code: "bad.domain", message: "Bad domain" }) +const requestFailed: Respond = (_onSuccess, onError) => + onError({}, {}, "processout-js.network-issue") + +function setup(respond: Respond, configure: (applePay: any, calls: string[]) => void = () => {}) { + const ProcessOut = loadApplePay(FakeApplePaySession) + const instance = { + endpoint: () => "https://api.test/applepay/sessions", + apiRequest: ( + _method: string, + _url: string, + _body: unknown, + onSuccess: Function, + onError: Function, + ) => respond(onSuccess, onError), + } + const calls: string[] = [] + const newSessionOnSuccess = vi.fn() + const newSessionOnError = vi.fn() + const onTokenizeError = vi.fn(() => calls.push("tokenize_error")) + + const applePay = new ProcessOut.ApplePayWrapper(instance).newSession( + { total: { label: "Order", amount: "10.00" } }, + newSessionOnSuccess, + newSessionOnError, + ) + configure(applePay, calls) + applePay.tokenize({}, vi.fn(), onTokenizeError) + + const session: FakeApplePaySession = applePay.getSession() + const validateMerchant = () => + session.onvalidatemerchant({ validationURL: "https://apple.test/validate" }) + + return { + calls, + session, + validateMerchant, + newSessionOnSuccess, + newSessionOnError, + onTokenizeError, + } +} + +describe("existing behaviour (no session handlers set)", () => { + it("completes merchant validation without calling any handler", () => { + const { calls, session, validateMerchant } = setup(merchantValidated) + + validateMerchant() + + expect(session.completeMerchantValidation).toHaveBeenCalledWith({ id: "merchant_session" }) + expect(session.abort).not.toHaveBeenCalled() + expect(calls).toEqual([]) + }) + + it.each([ + ["the API rejects the merchant", merchantRejected, "bad.domain"], + ["the request fails", requestFailed, "processout-js.network-issue"], + ])("reports to the tokenize error handler and aborts when %s", (_name, respond, code) => { + const { session, validateMerchant, onTokenizeError } = setup(respond) + + validateMerchant() + + expect(onTokenizeError).toHaveBeenCalledOnce() + expect(onTokenizeError.mock.calls[0][0].code).toBe(code) + expect(session.completeMerchantValidation).not.toHaveBeenCalled() + expect(session.abort).toHaveBeenCalledOnce() + }) + + it.each([ + ["succeeds", merchantValidated], + ["fails", merchantRejected], + ])("never calls the callbacks given to newSession when validation %s", (_name, respond) => { + const { validateMerchant, newSessionOnSuccess, newSessionOnError } = setup(respond) + + validateMerchant() + + expect(newSessionOnSuccess).not.toHaveBeenCalled() + expect(newSessionOnError).not.toHaveBeenCalled() + }) +}) + +describe("session handlers", () => { + it("calls onmerchantvalidated after the merchant validation is completed", () => { + const { calls, session, validateMerchant } = setup(merchantValidated, (applePay, calls) => { + applePay.onmerchantvalidated = () => { + expect(session.completeMerchantValidation).toHaveBeenCalledOnce() + calls.push("merchant_validated") + } + }) + + validateMerchant() + + expect(calls).toEqual(["merchant_validated"]) + }) + + it.each([ + ["the API rejects the merchant", merchantRejected, "bad.domain"], + ["the request fails", requestFailed, "processout-js.network-issue"], + ])( + "calls onmerchantvalidationerror before the tokenize error handler when %s", + (_name, respond, code) => { + const onMerchantValidationError = vi.fn() + const { calls, session, validateMerchant } = setup(respond, (applePay, calls) => { + applePay.onmerchantvalidationerror = (err: unknown) => { + onMerchantValidationError(err) + calls.push("merchant_validation_error") + } + }) + + validateMerchant() + + expect(calls).toEqual(["merchant_validation_error", "tokenize_error"]) + expect(onMerchantValidationError.mock.calls[0][0].code).toBe(code) + expect(session.abort).toHaveBeenCalledOnce() + }, + ) + + it("still reports the error and aborts when onmerchantvalidationerror throws", () => { + const { session, validateMerchant, onTokenizeError } = setup(merchantRejected, applePay => { + applePay.onmerchantvalidationerror = () => { + throw new Error("listener blew up") + } + }) + + expect(validateMerchant).toThrow("listener blew up") + expect(onTokenizeError).toHaveBeenCalledOnce() + expect(session.abort).toHaveBeenCalledOnce() + }) +}) diff --git a/test/support/loadNamespace.ts b/test/support/loadNamespace.ts index b028f53b..48792f71 100644 --- a/test/support/loadNamespace.ts +++ b/test/support/loadNamespace.ts @@ -157,6 +157,30 @@ export function loadDynamicCheckout(): DynamicCheckoutNamespace { return { namespace: moduleShim.exports, dispatchedEvents } } +/** + * Load the core Apple Pay session class and its wrapper and return their `ProcessOut` + * namespace. `ApplePaySession` is the browser global the SDK wraps, so tests inject a fake. + */ +export function loadApplePay(ApplePaySession: unknown): Record { + const js = [ + // Core SDK exception; the real one pulls in the translator. + `function Exception(code, message) { this.code = code; this.message = message; }`, + compile("src/processout/applepay.ts"), + `const { ApplePay } = ProcessOut;`, + compile("src/processout/applepay-wrapper.ts"), + ].join("\n") + const moduleShim = { exports: {} as Record } + const run = new Function( + "module", + "exports", + "window", + "ApplePaySession", + `${js}\nmodule.exports = ProcessOut;`, + ) + run(moduleShim, moduleShim.exports, { location: { hostname: "merchant.test" } }, ApplePaySession) + return moduleShim.exports +} + /** * Load `src/processout/telemetry.ts` and return its `ProcessOut` namespace. */ From 0bc585a3a5f31e5fecd4c0c5c60b1ea093ec4ad2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Jasi=C5=84ski?= Date: Thu, 12 Nov 2026 09:30:39 +0100 Subject: [PATCH 2/2] chore: bump version to 1.9.21 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a20fd093..68c44727 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "processout.js", - "version": "1.9.20", + "version": "1.9.21", "description": "ProcessOut.js is a JavaScript library for ProcessOut's payment processing API.", "scripts": { "build:processout": "tsc -p src/processout && uglifyjs --compress --keep-fnames --ie8 dist/processout.js -o dist/processout.js",