From aab05426d619808106c6f92613befab04e42b501 Mon Sep 17 00:00:00 2001 From: Roshan Gorasia Date: Mon, 27 Jul 2026 15:46:19 +0100 Subject: [PATCH 1/4] feat(actionhandler): allow actions to opt out of window-closed monitoring Some gateways (e.g. MercadoPago cash) transiently close and reopen the redirect payment window/tab. The ActionHandler's 500ms monitor polls window.closed and treats a closed window as a cancellation, firing a spurious "customer.canceled" ({ reason: "tab_closed" }) that kills an otherwise-valid payment. Add a listenToWindowClosed option (default true) to ActionHandlerOptions. When false, the monitor skips all window.closed detection and relies on explicit cancellation / postMessage completion instead. Wire it from the no_listen_to_window_closed customer_action metadata flag in the url and redirect flows (same mechanism as the existing no_iframe flag). Co-Authored-By: Claude Opus 5 (1M context) --- src/processout/actionhandler.ts | 22 ++++++++++++++++++++++ src/processout/processout.ts | 24 ++++++++++++++++++++++-- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/processout/actionhandler.ts b/src/processout/actionhandler.ts index 4dab547..07377a3 100644 --- a/src/processout/actionhandler.ts +++ b/src/processout/actionhandler.ts @@ -85,6 +85,19 @@ module ProcessOut { /** When set, iframe modal and new-window overlay are appended here instead of document.body */ public overlayMountParent?: HTMLElement; + /** + * When false, the 500ms monitor will NOT treat a closed payment + * window/tab as a cancellation. Driven by the + * no_listen_to_window_closed action metadata flag. This is a + * workaround for gateways + * (e.g. MercadoPago) whose redirect flow transiently closes and + * reopens the window, which would otherwise fire a spurious + * "customer.canceled" ({ reason: "tab_closed" }). Explicit user + * cancellation and postMessage-driven completion are unaffected. + * @type {boolean} + */ + public listenToWindowClosed: boolean = true; + public static ThreeDSChallengeFlow = "three-d-s-challenge-flow"; public static ThreeDSChallengeFlowNoIframe = "three-d-s-challenge-flow-no-iframe"; public static ThreeDSFingerprintFlow = "three-d-s-fingerprint-flow"; @@ -380,6 +393,15 @@ module ProcessOut { }) refocus() } + // Some gateways (e.g. MercadoPago) transiently close/reopen + // the redirect window, which would make the checks below fire + // a false "customer.canceled". When the action opts out via + // metadata, we skip all window-closed detection entirely and + // rely on explicit cancellation / postMessage completion. + if (!t.options.listenToWindowClosed) { + return; + } + try { // We want to run the newWindow.closed condition in a try // catch as Chrome has a bug in which the access to the diff --git a/src/processout/processout.ts b/src/processout/processout.ts index 2ae8095..ad5693d 100644 --- a/src/processout/processout.ts +++ b/src/processout/processout.ts @@ -1853,6 +1853,16 @@ module ProcessOut { ) }.bind(this) + // Some gateways (e.g. MercadoPago) transiently close and reopen the + // redirect window/tab, which would make the ActionHandler raise a + // spurious "customer.canceled" ({ reason: "tab_closed" }). The action + // can opt out of window-closed monitoring via the + // no_listen_to_window_closed metadata flag (same mechanism as the + // no_iframe flag above). + var noListenToWindowClosed = + data.customer_action.metadata && + data.customer_action.metadata.no_listen_to_window_closed === "true" + switch (data.customer_action.type) { case "url": var opts = ActionHandlerOptions.ThreeDSChallengeFlow @@ -1862,6 +1872,10 @@ module ProcessOut { ) { opts = ActionHandlerOptions.ThreeDSChallengeFlowNoIframe } + var urlActionOptions = new ActionHandlerOptions(opts) + if (noListenToWindowClosed) { + urlActionOptions.listenToWindowClosed = false + } // This is for 3DS1 this.handleAction( data.customer_action.value, @@ -1880,7 +1894,7 @@ module ProcessOut { ) }.bind(this), error, - new ActionHandlerOptions(opts), + urlActionOptions, resourceID, ) break @@ -1910,12 +1924,18 @@ module ProcessOut { break case "redirect": + var redirectActionOptions = new ActionHandlerOptions( + ActionHandlerOptions.ThreeDSChallengeFlow, + ) + if (noListenToWindowClosed) { + redirectActionOptions.listenToWindowClosed = false + } // This is for 3DS2 this.handleAction( data.customer_action.value, nextStep, error, - new ActionHandlerOptions(ActionHandlerOptions.ThreeDSChallengeFlow), + redirectActionOptions, resourceID, ) break From c8e33a9e30a72e41c62265b0d64535b26c107ce6 Mon Sep 17 00:00:00 2001 From: Roshan Gorasia Date: Mon, 27 Jul 2026 16:03:17 +0100 Subject: [PATCH 2/4] chore: bump version to 1.9.10 Bumps the version so the active bundle is traceable via the ProcessOut-JS-Version request header during staging testing. Co-Authored-By: Claude Opus 5 (1M context) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index eac4bde..4b6112a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "processout.js", - "version": "1.9.9", + "version": "1.9.10", "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", From 13ff3909a812210b80ed32ea0d21f58f1cd9721b Mon Sep 17 00:00:00 2001 From: Roshan Gorasia Date: Mon, 27 Jul 2026 16:06:35 +0100 Subject: [PATCH 3/4] feat(apm): propagate no_listen_to_window_closed to the opener tab The vanilla APM redirect opens the hosted checkout page in a new tab; the ActionHandler polling window.closed lives on the *parent* page, while the no_listen_to_window_closed metadata is only seen by the checkout page in the *child* tab. So the previous handleCardActions-only wiring never reached the handler that raises the spurious "customer.canceled". - Child: when handleCardActions sees the flag and a window.opener exists, postMessage the opener with a new "disable-window-close-monitoring" checkout-namespace action. - Parent: listenEvents handles that action by flipping options.listenToWindowClosed to false on the live monitor, without terminating the flow, so it keeps waiting for the real outcome. Co-Authored-By: Claude Opus 5 (1M context) --- src/processout/actionhandler.ts | 13 +++++++++++++ src/processout/processout.ts | 19 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/processout/actionhandler.ts b/src/processout/actionhandler.ts index 07377a3..6d4a5f1 100644 --- a/src/processout/actionhandler.ts +++ b/src/processout/actionhandler.ts @@ -533,6 +533,7 @@ module ProcessOut { var cur = ActionHandler.listenerCount; const telemetryClient = this.instance.telemetryClient const resourceID = this.resourceID; + var self = this; var alreadyDone = false; var handler = function(event) { @@ -547,6 +548,18 @@ module ProcessOut { return; } + // Some gateways (e.g. MercadoPago cash) transiently close and + // reopen the payment window/tab. The checkout page running in + // that window tells us — via the no_listen_to_window_closed + // action metadata — to stop treating window.closed as a + // cancellation. This is NOT a terminal action, so we flip the + // flag the monitor reads and keep listening for the real + // success/cancel/error outcome. + if (data.action == "disable-window-close-monitoring") { + self.options.listenToWindowClosed = false; + return; + } + if (alreadyDone) return; alreadyDone = true; diff --git a/src/processout/processout.ts b/src/processout/processout.ts index ad5693d..a7bdd8b 100644 --- a/src/processout/processout.ts +++ b/src/processout/processout.ts @@ -1863,6 +1863,25 @@ module ProcessOut { data.customer_action.metadata && data.customer_action.metadata.no_listen_to_window_closed === "true" + // When we're running inside the hosted-checkout tab (i.e. we have a + // window.opener), tell the parent page's ActionHandler — which is + // the one polling window.closed on this tab — to stop treating a + // closed window as a cancellation. The local ActionHandlerOptions + // below cover the in-tab handler; this covers the opener. + if (noListenToWindowClosed && window.opener && window.opener !== window) { + try { + window.opener.postMessage( + JSON.stringify({ + namespace: Message.checkoutNamespace, + action: "disable-window-close-monitoring", + }), + "*", + ) + } catch (e) { + // Cross-origin opener access can throw; nothing else to do. + } + } + switch (data.customer_action.type) { case "url": var opts = ActionHandlerOptions.ThreeDSChallengeFlow From 41a6a682b73b04a050a15111875e77a1de32a55a Mon Sep 17 00:00:00 2001 From: Roshan Gorasia Date: Mon, 27 Jul 2026 16:14:59 +0100 Subject: [PATCH 4/4] feat(apm): add window-monitor tracing + startup grace window Diagnostics and mitigation for the "cancels as soon as the APM page opens" race, where the redirect tab navigates to the gateway login page before the checkout tab's disable-window-close-monitoring message is received. - debugLog helper: mirrors traces to console + telemetry (tagged apm-window-monitoring, carries SCRIPT_VERSION) at every decision point: window opened, popup-blocked, isCanceled cancel, closed-window cancel, every inbound checkout message (with elapsed timings), and the disable-monitoring send/receive. - 2s startup grace window: a window-closed report (or access error) within the first 2s is logged but does not cancel, giving a late disable message time to land. Explicit user cancellation is unaffected. - Bump version to 1.9.11. Co-Authored-By: Claude Opus 5 (1M context) --- package.json | 2 +- src/processout/actionhandler.ts | 109 ++++++++++++++++++++++++++++++++ src/processout/processout.ts | 16 +++++ 3 files changed, 126 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 4b6112a..faa35f1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "processout.js", - "version": "1.9.10", + "version": "1.9.11", "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", diff --git a/src/processout/actionhandler.ts b/src/processout/actionhandler.ts index 6d4a5f1..52caae6 100644 --- a/src/processout/actionhandler.ts +++ b/src/processout/actionhandler.ts @@ -332,7 +332,28 @@ module ProcessOut { ({topLayer, newWindow} = this.createNewTabOrWindow(url)); } + // Timestamp the moment the window/tab was opened so every + // subsequent log carries an elapsed-ms value. This is what lets us + // see the race between a closed-window cancel and a late + // disable-window-close-monitoring message from the checkout tab. + var openedAt = Date.now(); + + // Snapshot the state right after the window/tab was opened. If a + // cancellation fires "as soon as the page opened", this tells us + // whether the window came back already-closed, whether the action + // was pre-canceled, and whether window-closed monitoring is on. + this.debugLog("action window opened", { + flow: ActionFlow[this.options.flow], + hasNewWindow: !!newWindow, + newWindowClosed: newWindow ? newWindow.closed : null, + listenToWindowClosed: this.options.listenToWindowClosed, + isCanceled: this.isCanceled(), + hasOpener: !!(window.opener && window.opener !== window), + url: url, + }); + if (!newWindow) { + this.debugLog("no window returned -> popup-blocked"); error(new Exception("customer.popup-blocked")); refocus(); return null; @@ -343,21 +364,39 @@ module ProcessOut { // a misleading "customer.canceled" error. Only applies to real popup flows; // MockedIFrameWindow (IFrame/FingerprintIframe) initialises closed as undefined. if ((this.options.flow === ActionFlow.NewTab || this.options.flow === ActionFlow.NewWindow) && newWindow.closed) { + this.debugLog("window already closed right after open -> popup-blocked", { + flow: ActionFlow[this.options.flow], + }); error(new Exception("customer.popup-blocked")); refocus(); return null; } + // Grace window: an APM redirect tab often navigates straight to + // the gateway's login page the instant it opens, and the checkout + // tab's disable-window-close-monitoring message can arrive after + // the first 500ms tick. During this window we still LOG a + // would-be closed-window cancel but do not act on it, giving the + // message time to land. Explicit cancellation is unaffected. + var WINDOW_CLOSED_GRACE_MS = 2000; + // We now want to monitor the payment page var timer = setInterval(function() { if (!timer) return; + var elapsed = Date.now() - openedAt; + if (t.isCanceled()) { clearInterval(timer) timer = null newWindow.close() error(new Exception("customer.canceled")) + t.debugLog("cancel via isCanceled() in timer", { + elapsedMs: elapsed, + listenToWindowClosed: t.options.listenToWindowClosed, + }) + telemetryClient.reportWarning({ host: window && window.location ? window.location.host : "", fileName: "actionhandler.ts/ActionHandler.handle.timer", @@ -380,6 +419,11 @@ module ProcessOut { timer = null error(new Exception("customer.canceled", undefined, { reason: "tab_closed" })) + t.debugLog("cancel via window closed (cancelf)", { + elapsedMs: elapsed, + listenToWindowClosed: t.options.listenToWindowClosed, + }) + telemetryClient.reportWarning({ host: window && window.location ? window.location.host : "", fileName: "actionhandler.ts/ActionHandler.handle.cancelf", @@ -408,9 +452,32 @@ module ProcessOut { // window is lost when the user navigates back (ie clicks // on the back button) if (!newWindow || newWindow.closed) { + // Within the grace window, log but don't cancel yet — + // this is where the "cancels as soon as it opens" race + // shows up, and where a late disable message can still + // save the flow. + if (elapsed < WINDOW_CLOSED_GRACE_MS) { + t.debugLog("window reported closed within grace window; deferring cancel", { + elapsedMs: elapsed, + graceMs: WINDOW_CLOSED_GRACE_MS, + hasNewWindow: !!newWindow, + }); + return; + } cancelf(); } } catch (err) { + // Accessing newWindow.closed can throw (Chrome back-button + // bug). Honour the same grace window so a transient + // access error right after open doesn't cancel the flow. + if (elapsed < WINDOW_CLOSED_GRACE_MS) { + t.debugLog("error reading window.closed within grace window; deferring cancel", { + elapsedMs: elapsed, + graceMs: WINDOW_CLOSED_GRACE_MS, + errorMessage: err && err.message ? err.message : String(err), + }); + return; + } // Close the newWindow, just in case it didn't crash for // that reason try { newWindow.close(); } catch (err) { } @@ -535,12 +602,25 @@ module ProcessOut { const resourceID = this.resourceID; var self = this; + var listenStartedAt = Date.now(); var alreadyDone = false; var handler = function(event) { var data = Message.parseEvent(event); if (data.namespace != Message.checkoutNamespace) return; + // Trace every checkout-namespace message we receive, with the + // time since we started listening. Comparing this against the + // "cancel via window closed" elapsedMs shows whether the + // disable message simply arrived too late. + self.debugLog("checkout message received", { + action: data.action, + elapsedSinceListenMs: Date.now() - listenStartedAt, + isLatestListener: ActionHandler.listenerCount == cur, + alreadyDone: alreadyDone, + listenToWindowClosed: self.options.listenToWindowClosed, + }); + // Not the latest listener anymore if (ActionHandler.listenerCount != cur) { // Reset the timer if it hasn't been done already @@ -557,6 +637,7 @@ module ProcessOut { // success/cancel/error outcome. if (data.action == "disable-window-close-monitoring") { self.options.listenToWindowClosed = false; + self.debugLog("window-close monitoring disabled by checkout tab"); return; } @@ -649,6 +730,34 @@ module ProcessOut { return this.canceled; } + /** + * debugLog emits a trace both to the browser console and to the + * telemetry endpoint (so it shows up in server-side logs alongside the + * SCRIPT_VERSION). Used to diagnose spurious window-closed + * cancellations for APM redirects (e.g. MercadoPago). + * @param {string} message + * @param {Record} data + * @return {void} + */ + protected debugLog(message: string, data?: Record): void { + try { + console.log("[ProcessOut.ActionHandler] " + message, data || ""); + } catch (e) { /* console may be unavailable */ } + + try { + this.instance.telemetryClient.reportWarning({ + host: window && window.location ? window.location.host : "", + fileName: "actionhandler.ts/ActionHandler", + lineNumber: 0, + message: "[action-window-monitor] " + message, + stack: "apm-window-monitoring", + invoiceId: this.resourceID, + category: "apm-window-monitoring", + data: data, + }); + } catch (e) { /* never let logging break the flow */ } + } + } } diff --git a/src/processout/processout.ts b/src/processout/processout.ts index a7bdd8b..27be34f 100644 --- a/src/processout/processout.ts +++ b/src/processout/processout.ts @@ -1877,6 +1877,22 @@ module ProcessOut { }), "*", ) + try { + console.log( + "[ProcessOut] sent disable-window-close-monitoring to opener", + { invoiceId: resourceID }, + ) + } catch (e) {} + this.telemetryClient.reportWarning({ + host: window && window.location ? window.location.host : "", + fileName: "processout.ts/handleCardActions", + lineNumber: 0, + message: "[action-window-monitor] sent disable-window-close-monitoring to opener", + stack: "apm-window-monitoring", + invoiceId: resourceID, + category: "apm-window-monitoring", + data: { customerActionType: data.customer_action.type }, + }) } catch (e) { // Cross-origin opener access can throw; nothing else to do. }