Bug 1447777 - Add completion fail and timeout error pages. r?MattN draft
authorSam Foster <sfoster@mozilla.com>
Fri, 20 Jul 2018 15:51:52 -0700
changeset 821153 a98fff8a950e64ccb2c0a0b4c6ca66aa1e621e7a
parent 821152 4bd5ccd360f0ba8eeae30a92c7e7d2cc51836cde
push id117018
push userbmo:sfoster@mozilla.com
push dateSat, 21 Jul 2018 04:05:10 +0000
reviewersMattN
bugs1447777
milestone63.0a1
Bug 1447777 - Add completion fail and timeout error pages. r?MattN * A new CompletionErrorPage / completion-error-page element which represents the content of the completion error * Leave the dialog open when complete() results in a 'fail' or 'timeout'. * The 'done' button on the fail & timeout error page closes the dialog by sending a message up to the paymentDialogWrapper. * Rewrite the pay button rendering logic to ensure it is disabled when it should be * Retry handling and UI not addressed here. Will need a new bug when the DOM support has landed. * Extend completeStatus support in debugging.html and group like actions to tidy up a bit MozReview-Commit-ID: GDhJqrj14uT * Add tests to verify that the dialog stays open when completion fails or times out * Add tests to verify that complete() throws after the timeout * Rework completeStatus mochitest for PaymentDialog MozReview-Commit-ID: 4ZNVEYMp7h5
browser/components/payments/content/paymentDialogWrapper.js
browser/components/payments/jar.mn
browser/components/payments/paymentUIService.js
browser/components/payments/res/containers/completion-error-page.js
browser/components/payments/res/containers/error-page.css
browser/components/payments/res/containers/payment-dialog.js
browser/components/payments/res/containers/placeholder.svg
browser/components/payments/res/debugging.css
browser/components/payments/res/debugging.html
browser/components/payments/res/debugging.js
browser/components/payments/res/paymentRequest.js
browser/components/payments/res/paymentRequest.xhtml
browser/components/payments/test/PaymentTestUtils.jsm
browser/components/payments/test/browser/browser.ini
browser/components/payments/test/browser/browser_payment_completion.js
browser/components/payments/test/browser/head.js
browser/components/payments/test/mochitest/test_payment_dialog.html
--- a/browser/components/payments/content/paymentDialogWrapper.js
+++ b/browser/components/payments/content/paymentDialogWrapper.js
@@ -548,16 +548,21 @@ var paymentDialogWrapper = {
   onChangeShippingOption({optionID}) {
     // Note, failing here on browser_host_name.js because the test closes
     // the dialog before the onChangeShippingOption is called, thus
     // deleting the request and making the requestId invalid. Unclear
     // why we aren't seeing the same issue with onChangeShippingAddress.
     paymentSrv.changeShippingOption(this.request.requestId, optionID);
   },
 
+  onCloseDialogMessage() {
+    // The PR is complete(), just close the dialog
+    window.close();
+  },
+
   async onUpdateAutofillRecord(collectionName, record, guid, {
     errorStateChange,
     preserveOldProperties,
     selectedStateKey,
     successStateChange,
   }) {
     if (collectionName == "creditCards" && !guid && !record.isTemporary) {
       // We need to be logged in so we can encrypt the credit card number and
@@ -652,16 +657,20 @@ var paymentDialogWrapper = {
       case "changeShippingAddress": {
         this.onChangeShippingAddress(data);
         break;
       }
       case "changeShippingOption": {
         this.onChangeShippingOption(data);
         break;
       }
+      case "closeDialog": {
+        this.onCloseDialogMessage();
+        break;
+      }
       case "paymentCancel": {
         this.onPaymentCancel();
         break;
       }
       case "pay": {
         this.onPay(data);
         break;
       }
--- a/browser/components/payments/jar.mn
+++ b/browser/components/payments/jar.mn
@@ -10,16 +10,17 @@ browser.jar:
     content/payments/paymentDialogWrapper.xul         (content/paymentDialogWrapper.xul)
 
 %   resource payments %res/payments/
     res/payments                                      (res/paymentRequest.*)
     res/payments/components/                          (res/components/*.css)
     res/payments/components/                          (res/components/*.js)
     res/payments/containers/                          (res/containers/*.js)
     res/payments/containers/                          (res/containers/*.css)
+    res/payments/containers/                          (res/containers/*.svg)
     res/payments/debugging.css                        (res/debugging.css)
     res/payments/debugging.html                       (res/debugging.html)
     res/payments/debugging.js                         (res/debugging.js)
     res/payments/formautofill/autofillEditForms.js    (../../../browser/extensions/formautofill/content/autofillEditForms.js)
     res/payments/formautofill/editAddress.xhtml       (../../../browser/extensions/formautofill/content/editAddress.xhtml)
     res/payments/formautofill/editCreditCard.xhtml    (../../../browser/extensions/formautofill/content/editCreditCard.xhtml)
     res/payments/unprivileged-fallbacks.js            (res/unprivileged-fallbacks.js)
     res/payments/mixins/                              (res/mixins/*.js)
--- a/browser/components/payments/paymentUIService.js
+++ b/browser/components/payments/paymentUIService.js
@@ -63,25 +63,45 @@ PaymentUIService.prototype = {
       Ci.nsIPaymentActionResponse.ABORT_SUCCEEDED :
       Ci.nsIPaymentActionResponse.ABORT_FAILED;
 
     abortResponse.init(requestId, response);
     paymentSrv.respondPayment(abortResponse);
   },
 
   completePayment(requestId) {
-    this.log.debug("completePayment:", requestId);
-    let closed = this.closeDialog(requestId);
+    // completeStatus should be one of "timeout", "success", "fail", ""
+    let {completeStatus} = paymentSrv.getPaymentRequestById(requestId);
+    this.log.debug(`completePayment: requestId: ${requestId}, completeStatus: ${completeStatus}`);
+
+    let closed;
+    switch (completeStatus) {
+      case "fail":
+      case "timeout":
+        break;
+      default:
+        closed = this.closeDialog(requestId);
+        break;
+    }
     let responseCode = closed ?
         Ci.nsIPaymentActionResponse.COMPLETE_SUCCEEDED :
         Ci.nsIPaymentActionResponse.COMPLETE_FAILED;
     let completeResponse = Cc["@mozilla.org/dom/payments/payment-complete-action-response;1"]
                              .createInstance(Ci.nsIPaymentCompleteActionResponse);
     completeResponse.init(requestId, responseCode);
     paymentSrv.respondPayment(completeResponse.QueryInterface(Ci.nsIPaymentActionResponse));
+
+    if (!closed) {
+      let dialog = this.findDialog(requestId);
+      if (!dialog) {
+        this.log.error("completePayment: no dialog found");
+        return;
+      }
+      dialog.paymentDialogWrapper.updateRequest();
+    }
   },
 
   updatePayment(requestId) {
     let dialog = this.findDialog(requestId);
     this.log.debug("updatePayment:", requestId);
     if (!dialog) {
       this.log.error("updatePayment: no dialog found");
       return;
new file mode 100644
--- /dev/null
+++ b/browser/components/payments/res/containers/completion-error-page.js
@@ -0,0 +1,78 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import PaymentRequestPage from "../components/payment-request-page.js";
+import PaymentStateSubscriberMixin from "../mixins/PaymentStateSubscriberMixin.js";
+import paymentRequest from "../paymentRequest.js";
+
+/* import-globals-from ../unprivileged-fallbacks.js */
+
+/**
+ * <completion-error-page></completion-error-page>
+ *
+ * XXX: Bug 1473772 - This page isn't fully localized when used via this custom element
+ * as it will be much easier to implement and share the logic once we switch to Fluent.
+ */
+
+export default class CompletionErrorPage extends PaymentStateSubscriberMixin(PaymentRequestPage) {
+  constructor() {
+    super();
+
+    this.classList.add("error-page");
+    this.suggestionsList = document.createElement("ul");
+    this.suggestions = [];
+    this.body.append(this.suggestionsList);
+
+    this.doneButton = document.createElement("button");
+    this.doneButton.classList.add("done-button", "primary");
+    this.doneButton.addEventListener("click", this);
+
+    this.footer.appendChild(this.doneButton);
+  }
+
+  render(state) {
+    let { page } = state;
+
+    if (this.id && page && page.id !== this.id) {
+      log.debug(`CompletionErrorPage: no need to further render inactive page: ${page.id}`);
+      return;
+    }
+
+    this.pageTitleHeading.textContent = this.dataset.pageTitle;
+    this.doneButton.textContent = this.dataset.doneButtonLabel;
+
+    this.suggestionsList.textContent = "";
+
+     // FIXME: should come from this.dataset.suggestionN when those strings are created
+    this.suggestions[0] = "First suggestion";
+
+    let suggestionsFragment = document.createDocumentFragment();
+    for (let suggestionText of this.suggestions) {
+      let listNode = document.createElement("li");
+      listNode.textContent = suggestionText;
+      suggestionsFragment.appendChild(listNode);
+    }
+    this.suggestionsList.appendChild(suggestionsFragment);
+  }
+
+  handleEvent(event) {
+    if (event.type == "click") {
+      switch (event.target) {
+        case this.doneButton: {
+          this.onDoneButtonClick(event);
+          break;
+        }
+        default: {
+          throw new Error("Unexpected click target");
+        }
+      }
+    }
+  }
+
+  onDoneButtonClick(event) {
+    paymentRequest.closeDialog();
+  }
+}
+
+customElements.define("completion-error-page", CompletionErrorPage);
new file mode 100644
--- /dev/null
+++ b/browser/components/payments/res/containers/error-page.css
@@ -0,0 +1,22 @@
+.error-page.illustrated > .page-body {
+  min-height: 300px;
+  background-position: left center;
+  background-repeat: no-repeat;
+  background-size: 38%;
+  padding-inline-start: 38%;
+}
+
+.error-page.illustrated > .page-body:dir(rtl) {
+  background-position: right center;
+}
+
+.error-page.illustrated > .page-body > h2 {
+  background: none;
+  padding-inline-start: 0;
+  margin-inline-start: 0;
+}
+
+.error-page#completion-timeout-error > .page-body,
+.error-page#completion-fail-error > .page-body {
+  background-image: url("./placeholder.svg");
+}
--- a/browser/components/payments/res/containers/payment-dialog.js
+++ b/browser/components/payments/res/containers/payment-dialog.js
@@ -7,16 +7,17 @@ import "../vendor/custom-elements.min.js
 import PaymentStateSubscriberMixin from "../mixins/PaymentStateSubscriberMixin.js";
 import paymentRequest from "../paymentRequest.js";
 
 import "../components/currency-amount.js";
 import "../components/payment-request-page.js";
 import "./address-picker.js";
 import "./address-form.js";
 import "./basic-card-form.js";
+import "./completion-error-page.js";
 import "./order-details.js";
 import "./payment-method-picker.js";
 import "./shipping-option-picker.js";
 
 /* import-globals-from ../unprivileged-fallbacks.js */
 
 /**
  * <payment-dialog></payment-dialog>
@@ -116,25 +117,43 @@ export default class PaymentDialog exten
     let methodId = state.selectedPaymentCard;
     let modifier = paymentRequest.getModifierForPaymentMethod(state, methodId);
     if (modifier && modifier.additionalDisplayItems) {
       return modifier.additionalDisplayItems;
     }
     return [];
   }
 
+  _updateCompleteStatus(state) {
+    let {completeStatus} = state.request;
+    switch (completeStatus) {
+      case "fail":
+      case "timeout":
+      case "unknown":
+        state.page = {
+          id: `completion-${completeStatus}-error`,
+        };
+        state.changesPrevented = false;
+        break;
+    }
+    return state;
+  }
+
   /**
    * Set some state from the privileged parent process.
    * Other elements that need to set state should use their own `this.requestStore.setState`
    * method provided by the `PaymentStateSubscriberMixin`.
    *
    * @param {object} state - See `PaymentsStore.setState`
    */
   setStateFromParent(state) {
     let oldAddresses = paymentRequest.getAddresses(this.requestStore.getState());
+    if (state.request) {
+      state = this._updateCompleteStatus(state);
+    }
     this.requestStore.setState(state);
 
     // Check if any foreign-key constraints were invalidated.
     state = this.requestStore.getState();
     let {
       selectedPayerAddress,
       selectedPaymentCard,
       selectedShippingAddress,
@@ -198,32 +217,41 @@ export default class PaymentDialog exten
     if (!addresses[selectedPayerAddress]) {
       this.requestStore.setState({
         selectedPayerAddress: Object.keys(addresses)[0] || null,
       });
     }
   }
 
   _renderPayButton(state) {
-    this._payButton.disabled = state.changesPrevented;
     let completeStatus = state.request.completeStatus;
     switch (completeStatus) {
       case "initial":
       case "processing":
       case "success":
+      case "unknown": {
+        this._payButton.disabled = state.changesPrevented;
+        this._payButton.textContent = this._payButton.dataset[completeStatus + "Label"];
+        break;
+      }
       case "fail":
-      case "unknown":
+      case "timeout": {
+        // pay button is hidden in these states. Reset its label and disable it
+        this._payButton.textContent = this._payButton.dataset.initialLabel;
+        this._payButton.disabled = true;
         break;
-      case "":
+      }
+      case "": {
         completeStatus = "initial";
         break;
-      default:
+      }
+      default: {
         throw new Error(`Invalid completeStatus: ${completeStatus}`);
+      }
     }
-
     this._payButton.textContent = this._payButton.dataset[completeStatus + "Label"];
   }
 
   stateChangeCallback(state) {
     super.stateChangeCallback(state);
 
     // Don't dispatch change events for initial selectedShipping* changes at initialization
     // if requestShipping is false.
new file mode 100644
--- /dev/null
+++ b/browser/components/payments/res/containers/placeholder.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="300" height="300" viewBox="0 0 300 300">
+  <circle cx="150" cy="150" r="100" stroke="#0a84ff" fill="#c9e4ff"/>
+</svg>
--- a/browser/components/payments/res/debugging.css
+++ b/browser/components/payments/res/debugging.css
@@ -11,8 +11,21 @@ html {
 
 h1 {
   font-size: 1em;
 }
 
 fieldset > label {
   white-space: nowrap;
 }
+
+.group {
+  margin: 0.5em 0;
+}
+
+label.block {
+  display: block;
+  margin: 0.3em 0;
+}
+
+button.wide {
+  width: 100%;
+}
--- a/browser/components/payments/res/debugging.html
+++ b/browser/components/payments/res/debugging.html
@@ -6,42 +6,65 @@
   <head>
     <meta charset="utf-8">
     <meta http-equiv="Content-Security-Policy" content="default-src 'self'">
     <link rel="stylesheet" href="debugging.css"/>
     <script src="debugging.js"></script>
   </head>
   <body>
     <div>
-      <button id="refresh">Refresh</button>
-      <button id="rerender">Re-render</button>
-      <button id="logState">Log state</button>
-      <button id="debugFrame" hidden>Debug frame</button>
-      <h1>Requests</h1>
-      <button id="setRequest1">Request 1</button>
-      <button id="setRequest2">Request 2</button>
-      <fieldset id="paymentOptions">
-        <legend>Payment Options</legend>
-        <label><input type="checkbox" autocomplete="off" name="requestPayerName" id="setRequestPayerName">requestPayerName</label>
-        <label><input type="checkbox" autocomplete="off" name="requestPayerEmail" id="setRequestPayerEmail">requestPayerEmail</label>
-        <label><input type="checkbox" autocomplete="off" name="requestPayerPhone" id="setRequestPayerPhone">requestPayerPhone</label>
-        <label><input type="checkbox" autocomplete="off" name="requestShipping" id="setRequestShipping">requestShipping</label>
-      </fieldset>
-      <h1>Addresses</h1>
-      <button id="setAddresses1">Set Addreses 1</button>
-      <button id="setDupesAddresses">Set Duped Addresses</button>
-      <button id="delete1Address">Delete 1 Address</button>
-      <h1>Payment Methods</h1>
-      <button id="setBasicCards1">Set Basic Cards 1</button>
-      <button id="delete1Card">Delete 1 Card</button>
-      <h1>States</h1>
-      <button id="setChangesPrevented">Prevent changes</button>
-      <button id="setChangesAllowed">Allow changes</button>
-      <button id="setShippingError">Shipping Error</button>
-      <button id="setAddressErrors">Address Errors</button>
-      <button id="setStateDefault">Default</button>
-      <button id="setStateProcessing">Processing</button>
-      <button id="setStateSuccess">Success</button>
-      <button id="setStateFail">Fail</button>
-      <button id="setStateUnknown">Unknown</button>
+      <section class="group">
+        <button id="refresh">Refresh</button>
+        <button id="rerender">Re-render</button>
+        <button id="logState">Log state</button>
+        <button id="debugFrame" hidden>Debug frame</button>
+      </section>
+      <section class="group">
+        <h1>Requests</h1>
+        <button id="setRequest1">Request 1</button>
+        <button id="setRequest2">Request 2</button>
+        <fieldset id="paymentOptions">
+          <legend>Payment Options</legend>
+          <label><input type="checkbox" autocomplete="off" name="requestPayerName" id="setRequestPayerName">requestPayerName</label>
+          <label><input type="checkbox" autocomplete="off" name="requestPayerEmail" id="setRequestPayerEmail">requestPayerEmail</label>
+          <label><input type="checkbox" autocomplete="off" name="requestPayerPhone" id="setRequestPayerPhone">requestPayerPhone</label>
+          <label><input type="checkbox" autocomplete="off" name="requestShipping" id="setRequestShipping">requestShipping</label>
+        </fieldset>
+      </section>
+
+      <section class="group">
+        <h1>Addresses</h1>
+        <button id="setAddresses1">Set Addreses 1</button>
+        <button id="setDupesAddresses">Set Duped Addresses</button>
+        <button id="delete1Address">Delete 1 Address</button>
+      </section>
+
+      <section class="group">
+        <h1>Payment Methods</h1>
+        <button id="setBasicCards1">Set Basic Cards 1</button>
+        <button id="delete1Card">Delete 1 Card</button>
+      </section>
+
+      <section class="group">
+        <h1>States</h1>
+        <fieldset>
+          <legend>Complete Status</legend>
+          <label class="block"><input type="radio" name="completeStatus" value="initial" checked="checked">Initial (default)</label>
+          <label class="block"><input type="radio" name="completeStatus" value="processing">Processing</label>
+          <label class="block"><input type="radio" name="completeStatus" value="success">Success</label>
+          <label class="block"><input type="radio" name="completeStatus" value="fail">Fail</label>
+          <label class="block"><input type="radio" name="completeStatus" value="unknown">Unknown</label>
+          <label class="block"><input type="radio" name="completeStatus" value="timeout">Timeout</label>
+        </fieldset>
+        <label class="block"><input type="checkbox" id="setChangesPrevented">Prevent changes</label>
+        <button id="setCompleteStatus" class="wide">Set Complete Status</button>
+
+        <section class="group">
+          <fieldset>
+            <legend>User Data Errors</legend>
+            <button id="setShippingError">Shipping Error</button>
+            <button id="setAddressErrors">Address Errors</button>
+          </fieldset>
+        </section>
+      </section>
     </div>
   </body>
 </html>
--- a/browser/components/payments/res/debugging.js
+++ b/browser/components/payments/res/debugging.js
@@ -401,49 +401,23 @@ let buttonActions = {
       recipient: "Can only ship to names that start with J",
       region: "Can only ship to regions that start with M",
     };
     requestStore.setState({
       request,
     });
   },
 
-  setStateDefault() {
-    let request = Object.assign({}, requestStore.getState().request, {
-      completeStatus: "initial",
-    });
-    requestStore.setState({ request });
-  },
-
-  setStateProcessing() {
-    let request = Object.assign({}, requestStore.getState().request, {
-      completeStatus: "processing",
+  setCompleteStatus(e) {
+    let input = document.querySelector("[name='completionState']:checked");
+    let completeStatus = input.value;
+    let request = requestStore.getState().request;
+    requestStore.setStateFromParent({
+      request: Object.assign({}, request, { completeStatus }),
     });
-    requestStore.setState({ request });
-  },
-
-  setStateSuccess() {
-    let request = Object.assign({}, requestStore.getState().request, {
-      completeStatus: "success",
-    });
-    requestStore.setState({ request });
-  },
-
-  setStateFail() {
-    let request = Object.assign({}, requestStore.getState().request, {
-      completeStatus: "fail",
-    });
-    requestStore.setState({ request });
-  },
-
-  setStateUnknown() {
-    let request = Object.assign({}, requestStore.getState().request, {
-      completeStatus: "unknown",
-    });
-    requestStore.setState({ request });
   },
 };
 
 window.addEventListener("click", function onButtonClick(evt) {
   let id = evt.target.id;
   if (!id || typeof(buttonActions[id]) != "function") {
     return;
   }
--- a/browser/components/payments/res/paymentRequest.js
+++ b/browser/components/payments/res/paymentRequest.js
@@ -165,16 +165,20 @@ var paymentRequest = {
   cancel() {
     this.sendMessageToChrome("paymentCancel");
   },
 
   pay(data) {
     this.sendMessageToChrome("pay", data);
   },
 
+  closeDialog() {
+    this.sendMessageToChrome("closeDialog");
+  },
+
   changeShippingAddress(data) {
     this.sendMessageToChrome("changeShippingAddress", data);
   },
 
   changeShippingOption(data) {
     this.sendMessageToChrome("changeShippingOption", data);
   },
 
--- a/browser/components/payments/res/paymentRequest.xhtml
+++ b/browser/components/payments/res/paymentRequest.xhtml
@@ -35,31 +35,40 @@
   <!ENTITY basicCard.editPage.title   "Edit Credit Card">
   <!ENTITY payer.addPage.title        "Add Payer Contact">
   <!ENTITY payer.editPage.title       "Edit Payer Contact">
   <!ENTITY payerLabel                 "Contact Information">
   <!ENTITY cancelPaymentButton.label   "Cancel">
   <!ENTITY approvePaymentButton.label  "Pay">
   <!ENTITY processingPaymentButton.label "Processing">
   <!ENTITY successPaymentButton.label    "Done">
-  <!ENTITY failPaymentButton.label       "Fail">
   <!ENTITY unknownPaymentButton.label    "Unknown">
   <!ENTITY orderDetailsLabel          "Order Details">
   <!ENTITY orderTotalLabel            "Total">
   <!ENTITY basicCardPage.error.genericSave    "There was an error saving the payment card.">
   <!ENTITY basicCardPage.addressAddLink.label "Add">
   <!ENTITY basicCardPage.addressEditLink.label "Edit">
   <!ENTITY basicCardPage.backButton.label     "Back">
   <!ENTITY basicCardPage.saveButton.label     "Save">
   <!ENTITY basicCardPage.persistCheckbox.label     "Save credit card to &brandShortName; (Security code will not be saved)">
   <!ENTITY addressPage.error.genericSave      "There was an error saving the address.">
   <!ENTITY addressPage.cancelButton.label     "Cancel">
   <!ENTITY addressPage.backButton.label       "Back">
   <!ENTITY addressPage.saveButton.label       "Save">
   <!ENTITY addressPage.persistCheckbox.label  "Save address to &brandShortName;">
+  <!ENTITY failErrorPage.title  "Sorry! Something went wrong with the payment process.">
+  <!ENTITY failErrorPage.suggestion1  "Check your credit card has not expired.">
+  <!ENTITY failErrorPage.suggestion2  "Make sure your credit card information is accurate.">
+  <!ENTITY failErrorPage.suggestion3  "If no other solutions work, check with shopping.com.">
+  <!ENTITY failErrorPage.doneButton.label     "OK">
+  <!ENTITY timeoutErrorPage.title  "Whoops! Shopping.com took too long to respond.">
+  <!ENTITY timeoutErrorPage.suggestion1  "Try again later.">
+  <!ENTITY timeoutErrorPage.suggestion2  "Check your network connection." >
+  <!ENTITY timeoutErrorPage.suggestion3  "If no other solutions work, check with shopping.com.">
+  <!ENTITY timeoutErrorPage.doneButton.label     "OK">
 ]>
 <html xmlns="http://www.w3.org/1999/xhtml">
 <head>
   <title>&paymentSummaryTitle;</title>
 
   <!-- chrome: is needed for global.dtd -->
   <meta http-equiv="Content-Security-Policy" content="default-src 'self' chrome:"/>
 
@@ -69,16 +78,17 @@
   <link rel="stylesheet" href="components/address-option.css"/>
   <link rel="stylesheet" href="components/basic-card-option.css"/>
   <link rel="stylesheet" href="components/shipping-option.css"/>
   <link rel="stylesheet" href="components/payment-details-item.css"/>
   <link rel="stylesheet" href="containers/address-form.css"/>
   <link rel="stylesheet" href="containers/basic-card-form.css"/>
   <link rel="stylesheet" href="containers/order-details.css"/>
   <link rel="stylesheet" href="containers/rich-picker.css"/>
+  <link rel="stylesheet" href="containers/error-page.css"/>
 
   <script src="unprivileged-fallbacks.js"></script>
 
   <script src="formautofill/autofillEditForms.js"></script>
 
   <script type="module" src="containers/payment-dialog.js"></script>
   <script type="module" src="paymentRequest.js"></script>
 
@@ -122,17 +132,16 @@
         </div>
 
         <footer>
           <button id="cancel">&cancelPaymentButton.label;</button>
           <button id="pay"
                   class="primary"
                   data-initial-label="&approvePaymentButton.label;"
                   data-processing-label="&processingPaymentButton.label;"
-                  data-fail-label="&failPaymentButton.label;"
                   data-unknown-label="&unknownPaymentButton.label;"
                   data-success-label="&successPaymentButton.label;"></button>
         </footer>
       </payment-request-page>
       <section id="order-details-overlay" hidden="hidden">
         <h2>&orderDetailsLabel;</h2>
         <order-details></order-details>
       </section>
@@ -153,16 +162,25 @@
 
       <address-form id="address-page"
                     data-error-generic-save="&addressPage.error.genericSave;"
                     data-cancel-button-label="&addressPage.cancelButton.label;"
                     data-back-button-label="&addressPage.backButton.label;"
                     data-save-button-label="&addressPage.saveButton.label;"
                     data-persist-checkbox-label="&addressPage.persistCheckbox.label;"
                     hidden="hidden"></address-form>
+
+      <completion-error-page id="completion-timeout-error" class="illustrated"
+                  data-page-title="&timeoutErrorPage.title;"
+                  data-done-button-label="&timeoutErrorPage.doneButton.label;"
+                  hidden="hidden"></completion-error-page>
+      <completion-error-page id="completion-fail-error" class="illustrated"
+                  data-page-title="&failErrorPage.title;"
+                  data-done-button-label="&failErrorPage.doneButton.label;"
+                  hidden="hidden"></completion-error-page>
     </div>
 
     <div id="disabled-overlay" hidden="hidden">
       <!-- overlay to prevent changes while waiting for a response from the merchant -->
     </div>
   </template>
 
   <template id="order-details-template">
--- a/browser/components/payments/test/PaymentTestUtils.jsm
+++ b/browser/components/payments/test/PaymentTestUtils.jsm
@@ -7,20 +7,33 @@ var PaymentTestUtils = {
    * Common content tasks functions to be used with ContentTask.spawn.
    */
   ContentTasks: {
     /* eslint-env mozilla/frame-script */
     /**
      * Add a completion handler to the existing `showPromise` to call .complete().
      * @returns {Object} representing the PaymentResponse
      */
-    addCompletionHandler: async () => {
+    addCompletionHandler: async ({result, delayMs = 0}) => {
       let response = await content.showPromise;
-      response.complete();
+      let completeException;
+
+      // delay the given # milliseconds
+      await new Promise(resolve => content.setTimeout(resolve, delayMs));
+
+      try {
+        await response.complete(result);
+      } catch (ex) {
+        completeException = {
+          name: ex.name,
+          message: ex.message,
+        };
+      }
       return {
+        completeException,
         response: response.toJSON(),
         // XXX: Bug NNN: workaround for `details` not being included in `toJSON`.
         methodDetails: response.details,
       };
     },
 
     ensureNoPaymentRequestEvent: ({eventName}) => {
       content.rq.addEventListener(eventName, (event) => {
@@ -146,16 +159,30 @@ var PaymentTestUtils = {
       let select = Cu.waiveXrays(optionPicker).dropdown.popupBox;
       let option = select.querySelector(`[value="${value}"]`);
       select.focus();
       // eslint-disable-next-line no-undef
       EventUtils.synthesizeKey(option.textContent, {}, content.window);
     },
 
     /**
+     * Click the primary button for the current page
+     *
+     * Don't await on this method from a ContentTask when expecting the dialog to close
+     *
+     * @returns {undefined}
+     */
+    clickPrimaryButton: () => {
+      let {requestStore} = Cu.waiveXrays(content.document.querySelector("payment-dialog"));
+      let {page} = requestStore.getState();
+      let button = content.document.querySelector(`#${page.id} button.primary`);
+      button.click();
+    },
+
+    /**
      * Click the cancel button
      *
      * Don't await on this task since the cancel can close the dialog before
      * ContentTask can resolve the promise.
      *
      * @returns {undefined}
      */
     manuallyClickCancel: () => {
--- a/browser/components/payments/test/browser/browser.ini
+++ b/browser/components/payments/test/browser/browser.ini
@@ -7,16 +7,17 @@ support-files =
   blank_page.html
 
 [browser_address_edit.js]
 skip-if = verify && debug && os == 'mac'
 [browser_card_edit.js]
 [browser_change_shipping.js]
 [browser_dropdowns.js]
 [browser_host_name.js]
+[browser_payment_completion.js]
 [browser_payments_onboarding_wizard.js]
 [browser_profile_storage.js]
 [browser_request_serialization.js]
 [browser_request_shipping.js]
 [browser_shippingaddresschange_error.js]
 [browser_show_dialog.js]
 skip-if = os == 'win' && debug # bug 1418385
 [browser_total.js]
new file mode 100644
--- /dev/null
+++ b/browser/components/payments/test/browser/browser_payment_completion.js
@@ -0,0 +1,107 @@
+"use strict";
+
+/*
+  Test the permutations of calling complete() on the payment response and handling the case
+  where the timeout is exceeded before it is called
+*/
+
+async function setup() {
+  await setupFormAutofillStorage();
+  await cleanupFormAutofillStorage();
+  let billingAddressGUID = await addAddressRecord(PTU.Addresses.TimBL);
+  let card = Object.assign({}, PTU.BasicCards.JohnDoe,
+                           { billingAddressGUID });
+  await addCardRecord(card);
+}
+
+add_task(async function test_complete_success() {
+  await setup();
+  await BrowserTestUtils.withNewTab({
+    gBrowser,
+    url: BLANK_PAGE_URL,
+  }, async browser => {
+    let {win, frame} =
+      await setupPaymentDialog(browser, {
+        methodData: [PTU.MethodData.basicCard],
+        details: Object.assign({}, PTU.Details.total60USD),
+        merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+      }
+    );
+
+    spawnPaymentDialogTask(frame, PTU.DialogContentTasks.completePayment);
+
+    // Add a handler to complete the payment above.
+    info("acknowledging the completion from the merchant page");
+    let {completeException} = await ContentTask.spawn(browser,
+                                                      { result: "success" },
+                                                      PTU.ContentTasks.addCompletionHandler);
+
+    ok(!completeException, "Expect no exception to be thrown when calling complete()");
+
+    await BrowserTestUtils.waitForCondition(() => win.closed, "dialog should be closed");
+  });
+});
+
+add_task(async function test_complete_fail() {
+  await setup();
+  await BrowserTestUtils.withNewTab({
+    gBrowser,
+    url: BLANK_PAGE_URL,
+  }, async browser => {
+    let {win, frame} =
+      await setupPaymentDialog(browser, {
+        methodData: [PTU.MethodData.basicCard],
+        details: Object.assign({}, PTU.Details.total60USD),
+        merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+      }
+    );
+
+    info("clicking pay");
+    spawnPaymentDialogTask(frame, PTU.DialogContentTasks.completePayment);
+
+    info("acknowledging the completion from the merchant page");
+    let {completeException} = await ContentTask.spawn(browser,
+                                                      { result: "fail" },
+                                                      PTU.ContentTasks.addCompletionHandler);
+    ok(!completeException, "Expect no exception to be thrown when calling complete()");
+
+    ok(!win.closed, "dialog shouldn't be closed yet");
+
+    spawnPaymentDialogTask(frame, PTU.DialogContentTasks.clickPrimaryButton);
+    await BrowserTestUtils.waitForCondition(() => win.closed, "dialog should be closed");
+  });
+});
+
+add_task(async function test_complete_timeout() {
+  await setup();
+  await BrowserTestUtils.withNewTab({
+    gBrowser,
+    url: BLANK_PAGE_URL,
+  }, async browser => {
+    // timeout the response asap
+    Services.prefs.setIntPref(RESPONSE_TIMEOUT_PREF, 60);
+
+    let {win, frame} =
+      await setupPaymentDialog(browser, {
+        methodData: [PTU.MethodData.basicCard],
+        details: Object.assign({}, PTU.Details.total60USD),
+        merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+      }
+    );
+
+    info("clicking pay");
+    await spawnPaymentDialogTask(frame, PTU.DialogContentTasks.completePayment);
+
+    info("acknowledging the completion from the merchant page after a delay");
+    let {completeException} = await ContentTask.spawn(browser,
+                                                      { result: "fail", delayMs: 1000 },
+                                                      PTU.ContentTasks.addCompletionHandler);
+    ok(completeException,
+       "Expect an exception to be thrown when calling complete() too late");
+
+    ok(!win.closed, "dialog shouldn't be closed");
+
+    spawnPaymentDialogTask(frame, PTU.DialogContentTasks.clickPrimaryButton);
+    await BrowserTestUtils.waitForCondition(() => win.closed, "dialog should be closed");
+  });
+});
--- a/browser/components/payments/test/browser/head.js
+++ b/browser/components/payments/test/browser/head.js
@@ -5,16 +5,17 @@
     vars: "local",
     args: "none",
   }],
 */
 
 
 const BLANK_PAGE_PATH = "/browser/browser/components/payments/test/browser/blank_page.html";
 const BLANK_PAGE_URL = "https://example.com" + BLANK_PAGE_PATH;
+const RESPONSE_TIMEOUT_PREF = "dom.payments.response.timeout";
 
 const paymentSrv = Cc["@mozilla.org/dom/payments/payment-request-service;1"]
                      .getService(Ci.nsIPaymentRequestService);
 const paymentUISrv = Cc["@mozilla.org/dom/payments/payment-ui-service;1"]
                      .getService().wrappedJSObject;
 const {formAutofillStorage} = ChromeUtils.import(
   "resource://formautofill/FormAutofillStorage.jsm", {});
 const {PaymentTestUtils: PTU} = ChromeUtils.import(
@@ -311,16 +312,17 @@ function cleanupFormAutofillStorage() {
   formAutofillStorage.creditCards.removeAll();
 }
 
 add_task(async function setup_head() {
   await setupFormAutofillStorage();
   registerCleanupFunction(function cleanup() {
     paymentSrv.cleanup();
     cleanupFormAutofillStorage();
+    Services.prefs.clearUserPref(RESPONSE_TIMEOUT_PREF);
   });
 });
 
 function deepClone(obj) {
   return JSON.parse(JSON.stringify(obj));
 }
 
 async function selectPaymentDialogShippingAddressByCountry(frame, country) {
--- a/browser/components/payments/test/mochitest/test_payment_dialog.html
+++ b/browser/components/payments/test/mochitest/test_payment_dialog.html
@@ -31,31 +31,16 @@ Test the payment-dialog custom element
 /** Test the payment-dialog element **/
 
 /* global sinon */
 
 import PaymentDialog from "../../res/containers/payment-dialog.js";
 
 let el1;
 
-let completeStatuses = [
-    ["processing", "Processing"],
-    ["success", "Done"],
-    ["fail", "Fail"],
-    ["unknown", "Unknown"],
-];
-
-/* test that:
-  the view-all-items button exists
-  that clicking it changes the state on the store
-  that clicking it causes render to be called
-
-  that order details element's hidden state matches the state on the store
-*/
-
 add_task(async function setup_once() {
   let templateFrame = document.getElementById("templateFrame");
   await SimpleTest.promiseFocus(templateFrame.contentWindow);
 
   let displayEl = document.getElementById("display");
   // Import the templates from the real shipping dialog to avoid duplication.
   for (let template of templateFrame.contentDocument.querySelectorAll("template")) {
     let imported = document.importNode(template, true);
@@ -70,16 +55,19 @@ add_task(async function setup_once() {
 });
 
 async function setup() {
   let {request} = el1.requestStore.getState();
   await el1.requestStore.setState({
     changesPrevented: false,
     request: Object.assign({}, request, {completeStatus: "initial"}),
     orderDetailsShowing: false,
+    page: {
+      id: "payment-summary",
+    },
   });
 
   el1.render.reset();
   el1.stateChangeCallback.reset();
 }
 
 add_task(async function test_initialState() {
   await setup();
@@ -111,17 +99,16 @@ add_task(async function test_viewAllButt
   ];
   await el1.requestStore.setState({ request });
   await asyncElementRendered();
 
   // Check if the "View all items" button is visible.
   ok(!button.hidden, "Button is visible");
 });
 
-
 add_task(async function test_viewAllButton() {
   await setup();
 
   let elDetails = el1._orderDetailsOverlay;
   let button = el1._viewAllButton;
 
   button.click();
   await asyncElementRendered();
@@ -140,53 +127,102 @@ add_task(async function test_changesPrev
   is(state.changesPrevented, false, "changesPrevented is initially false");
   let disabledOverlay = document.getElementById("disabled-overlay");
   ok(disabledOverlay.hidden, "Overlay should initially be hidden");
   await el1.requestStore.setState({changesPrevented: true});
   await asyncElementRendered();
   ok(!disabledOverlay.hidden, "Overlay should prevent changes");
 });
 
-add_task(async function test_completeStatus() {
+add_task(async function test_initial_completeStatus() {
   await setup();
-  let {request} = el1.requestStore.getState();
+  let {request, page} = el1.requestStore.getState();
   is(request.completeStatus, "initial", "completeStatus is initially initial");
+
   let payButton = document.getElementById("pay");
+  is(payButton, document.querySelector(`#${page.id} button.primary`),
+     "Primary button is the pay button in the initial state");
   is(payButton.textContent, "Pay", "Check default label");
   ok(!payButton.disabled, "Button is enabled");
-  for (let [completeStatus, label] of completeStatuses) {
-    request.completeStatus = completeStatus;
-    await el1.requestStore.setState({request});
+});
+
+add_task(async function test_processing_completeStatus() {
+  // "processing": has overlay. Check button visibility
+  await setup();
+  let {request} = el1.requestStore.getState();
+  // this a transition state, set when waiting for a response from the merchant page
+  el1.requestStore.setState({
+    changesPrevented: true,
+    request: Object.assign({}, request, {completeStatus: "processing"}),
+  });
+  await asyncElementRendered();
+
+  let primaryButtons = document.querySelectorAll("footer button.primary");
+  ok(Array.from(primaryButtons).every(el => isHidden(el) || el.disabled),
+     "all primary footer buttons are hidden or disabled");
+});
+
+add_task(async function test_success_unknown_completeStatus() {
+  // in the "success" and "unknown" completion states the dialog would normally be closed
+  // so just ensure it is left in a good state
+  for (let completeStatus of ["success", "unknown"]) {
+    await setup();
+    let {request} = el1.requestStore.getState();
+    el1.requestStore.setState({
+      request: Object.assign({}, request, {completeStatus}),
+    });
     await asyncElementRendered();
-    is(payButton.textContent, label, "Check payButton label");
-    ok(!payButton.disabled, "Button is still enabled");
+
+    let {page} = el1.requestStore.getState();
+
+    // this status doesnt change page
+    let payButton = document.getElementById("pay");
+    is(payButton, document.querySelector(`#${page.id} button.primary`),
+       `Primary button is the pay button in the ${completeStatus} state`);
+
+    if (completeStatus == "success") {
+      is(payButton.textContent, "Done", "Check button label");
+    }
+    if (completeStatus == "unknown") {
+      is(payButton.textContent, "Unknown", "Check button label");
+    }
+    ok(!payButton.disabled, "Button is enabled");
   }
 });
 
-add_task(async function test_completeStatusChangesPrevented() {
-  await setup();
-  let state = el1.requestStore.getState();
-  is(state.request.completeStatus, "initial", "completeStatus is initially initial");
-  is(state.changesPrevented, false, "changesPrevented is initially false");
-  let payButton = document.getElementById("pay");
-  is(payButton.textContent, "Pay", "Check default label");
-  ok(!payButton.disabled, "Button is enabled");
-
-  for (let [status, label] of completeStatuses) {
-    await el1.requestStore.setState({
-      changesPrevented: true,
-      request: Object.assign(state.request, { completeStatus: status }),
+add_task(async function test_timeout_fail_completeStatus() {
+  // in these states the dialog stays open and presents a single
+  // button for acknowledgement
+  for (let completeStatus of ["fail", "timeout"]) {
+    await setup();
+    let {request} = el1.requestStore.getState();
+    el1.requestStore.setState({
+      request: Object.assign({}, request, {completeStatus}),
+      page: {
+        id: `completion-${completeStatus}-error`,
+      },
     });
     await asyncElementRendered();
-    is(payButton.textContent, label, "Check payButton label");
-    ok(payButton.disabled, "Button is disabled");
-    let rect = payButton.getBoundingClientRect();
+
+    let {page} = el1.requestStore.getState();
+    let pageElem = document.querySelector(`#${page.id}`);
+    let payButton = document.getElementById("pay");
+    let primaryButton = pageElem.querySelector("button.primary");
+
+    ok(pageElem && !isHidden(pageElem, `page element for ${page.id} exists and is visible`));
+    ok(!isHidden(primaryButton), "Primary button is visible");
+    ok(payButton != primaryButton,
+       `Primary button is the not pay button in the ${completeStatus} state`);
+    ok(isHidden(payButton), "Pay button is not visible");
+    is(primaryButton.textContent, "OK", "Check button label");
+
+    let rect = primaryButton.getBoundingClientRect();
     let visibleElement =
       document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2);
-    ok(payButton === visibleElement, "Pay button is on top of the overlay");
+    ok(primaryButton === visibleElement, "Primary button is on top of the overlay");
   }
 });
 
 add_task(async function test_scrollPaymentRequestPage() {
   await setup();
   info("making the payment-dialog container small to require scrolling");
   el1.parentElement.style.height = "100px";
   let summaryPageBody = document.querySelector("#payment-summary .page-body");