diff --git a/package.json b/package.json index eac4bde..faa35f1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "processout.js", - "version": "1.9.9", + "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 4dab547..52caae6 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"; @@ -319,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; @@ -330,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", @@ -367,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", @@ -380,15 +437,47 @@ 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 // 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) { } @@ -511,13 +600,27 @@ module ProcessOut { var cur = ActionHandler.listenerCount; const telemetryClient = this.instance.telemetryClient 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 @@ -525,6 +628,19 @@ 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; + self.debugLog("window-close monitoring disabled by checkout tab"); + return; + } + if (alreadyDone) return; alreadyDone = true; @@ -614,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 2ae8095..27be34f 100644 --- a/src/processout/processout.ts +++ b/src/processout/processout.ts @@ -1853,6 +1853,51 @@ 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" + + // 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", + }), + "*", + ) + 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. + } + } + switch (data.customer_action.type) { case "url": var opts = ActionHandlerOptions.ThreeDSChallengeFlow @@ -1862,6 +1907,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 +1929,7 @@ module ProcessOut { ) }.bind(this), error, - new ActionHandlerOptions(opts), + urlActionOptions, resourceID, ) break @@ -1910,12 +1959,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