Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
144 changes: 144 additions & 0 deletions src/processout/actionhandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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) { }
Expand Down Expand Up @@ -511,20 +600,47 @@ 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
if (timer) { clearInterval(timer); timer = null; }
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;

Expand Down Expand Up @@ -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<string, any>} data
* @return {void}
*/
protected debugLog(message: string, data?: Record<string, any>): 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 */ }
}

}

}
59 changes: 57 additions & 2 deletions src/processout/processout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -1880,7 +1929,7 @@ module ProcessOut {
)
}.bind(this),
error,
new ActionHandlerOptions(opts),
urlActionOptions,
resourceID,
)
break
Expand Down Expand Up @@ -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
Expand Down
Loading