Bug 1427939 - Use total and any additionalDisplayItems when a modifier matches the payment method. r=jaws draft
authorSam Foster <sfoster@mozilla.com>
Wed, 18 Apr 2018 00:43:15 -0700
changeset 786368 e3a3d7ffbcb6602ec14d0bc27b20b0d7804f1694
parent 786367 fa3119fe5f5d9df1e4ea2628dc9a6ab2dac735bd
push id107441
push usermozilla@noorenberghe.ca
push dateMon, 23 Apr 2018 03:13:24 +0000
reviewersjaws
bugs1427939
milestone61.0a1
Bug 1427939 - Use total and any additionalDisplayItems when a modifier matches the payment method. r=jaws * getModifierForPaymentMethod helper * Use selectedPaymentMethod from state store. * Update and add tests for the modifier case for the main total in PaymentDialog * Update and add tests for handling modifiers and additionalDisplayItems in the OrderDetails component MozReview-Commit-ID: FmlovZjP0t1
toolkit/components/payments/res/containers/order-details.js
toolkit/components/payments/res/containers/payment-dialog.js
toolkit/components/payments/res/debugging.js
toolkit/components/payments/res/paymentRequest.js
toolkit/components/payments/test/PaymentTestUtils.jsm
toolkit/components/payments/test/browser/browser_total.js
toolkit/components/payments/test/mochitest/test_order_details.html
--- a/toolkit/components/payments/res/containers/order-details.js
+++ b/toolkit/components/payments/res/containers/order-details.js
@@ -1,15 +1,16 @@
 /* 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/. */
 
 // <currency-amount> is used in the <template>
 import "../components/currency-amount.js";
 import PaymentDetailsItem from "../components/payment-details-item.js";
+import paymentRequest from "../paymentRequest.js";
 import PaymentStateSubscriberMixin from "../mixins/PaymentStateSubscriberMixin.js";
 
 /**
  * <order-details></order-details>
  */
 
 export default class OrderDetails extends PaymentStateSubscriberMixin(HTMLElement) {
   connectedCallback() {
@@ -52,29 +53,37 @@ export default class OrderDetails extend
       row.amountValue = item.amount.value;
       row.amountCurrency = item.amount.currency;
       fragment.appendChild(row);
     }
     listEl.appendChild(fragment);
     return listEl;
   }
 
+  _getAdditionalDisplayItems(state) {
+    let methodId = state.selectedPaymentCard;
+    let modifier = paymentRequest.getModifierForPaymentMethod(state, methodId);
+    if (modifier && modifier.additionalDisplayItems) {
+      return modifier.additionalDisplayItems;
+    }
+    return [];
+  }
+
   render(state) {
-    let request = state.request;
-    let totalItem = request.paymentDetails.totalItem;
+    let totalItem = paymentRequest.getTotalItem(state);
 
     OrderDetails._emptyList(this.mainItemsList);
     OrderDetails._emptyList(this.footerItemsList);
 
-    let mainItems = OrderDetails._getMainListItems(request);
+    let mainItems = OrderDetails._getMainListItems(state);
     if (mainItems.length) {
       OrderDetails._populateList(this.mainItemsList, mainItems);
     }
 
-    let footerItems = OrderDetails._getFooterListItems(request);
+    let footerItems = OrderDetails._getFooterListItems(state);
     if (footerItems.length) {
       OrderDetails._populateList(this.footerItemsList, footerItems);
     }
 
     this.totalAmountElem.value = totalItem.amount.value;
     this.totalAmountElem.currency = totalItem.amount.currency;
   }
 
@@ -85,28 +94,38 @@ export default class OrderDetails extend
    *
    * @param {object} item - Data representing a PaymentItem
    * @returns {boolean}
    */
   static isFooterItem(item) {
     return item.type == "tax";
   }
 
-  static _getMainListItems(request) {
+  static _getMainListItems(state) {
+    let request = state.request;
     let items = request.paymentDetails.displayItems;
     if (Array.isArray(items) && items.length) {
       let predicate = item => !OrderDetails.isFooterItem(item);
       return request.paymentDetails.displayItems.filter(predicate);
     }
     return [];
   }
 
-  static _getFooterListItems(request) {
+  static _getFooterListItems(state) {
+    let request = state.request;
     let items = request.paymentDetails.displayItems;
+    let footerItems = [];
+    let methodId = state.selectedPaymentCard;
+    if (methodId) {
+      let modifier = paymentRequest.getModifierForPaymentMethod(state, methodId);
+      if (modifier && Array.isArray(modifier.additionalDisplayItems)) {
+        footerItems.push(...modifier.additionalDisplayItems);
+      }
+    }
     if (Array.isArray(items) && items.length) {
       let predicate = OrderDetails.isFooterItem;
-      return request.paymentDetails.displayItems.filter(predicate);
+      footerItems.push(...request.paymentDetails.displayItems.filter(predicate));
     }
-    return [];
+    return footerItems;
   }
 }
 
 customElements.define("order-details", OrderDetails);
--- a/toolkit/components/payments/res/containers/payment-dialog.js
+++ b/toolkit/components/payments/res/containers/payment-dialog.js
@@ -228,17 +228,17 @@ export default class PaymentDialog exten
     this._cachedState.selectedShippingOption = state.selectedShippingOption;
   }
 
   render(state) {
     let request = state.request;
     let paymentDetails = request.paymentDetails;
     this._hostNameEl.textContent = request.topLevelPrincipal.URI.displayHost;
 
-    let totalItem = paymentDetails.totalItem;
+    let totalItem = paymentRequest.getTotalItem(state);
     let totalAmountEl = this.querySelector("#total > currency-amount");
     totalAmountEl.value = totalItem.amount.value;
     totalAmountEl.currency = totalItem.amount.currency;
 
     this._orderDetailsOverlay.hidden = !state.orderDetailsShowing;
     this._errorText.textContent = paymentDetails.error;
 
     let paymentOptions = request.paymentOptions;
--- a/toolkit/components/payments/res/debugging.js
+++ b/toolkit/components/payments/res/debugging.js
@@ -122,17 +122,41 @@ let REQUEST_2 = {
         label: "Slow",
         amount: {
           currency: "USD",
           value: 10,
         },
         selected: false,
       },
     ],
-    modifiers: null,
+    modifiers: [
+      {
+        supportedMethods: "basic-card",
+        total: {
+          label: "Total",
+          amount: {
+            currency: "CAD",
+            value: "28.75",
+          },
+          pending: false,
+        },
+        additionalDisplayItems: [
+          {
+            label: "Credit card fee",
+            amount: {
+              currency: "CAD",
+              value: "1.50",
+            },
+          },
+        ],
+        data: {
+          supportedTypes: "credit",
+        },
+      },
+    ],
     error: "",
   },
   paymentOptions: {
     requestPayerName: false,
     requestPayerEmail: false,
     requestPayerPhone: false,
     requestShipping: true,
     shippingType: "shipping",
--- a/toolkit/components/payments/res/paymentRequest.js
+++ b/toolkit/components/payments/res/paymentRequest.js
@@ -156,16 +156,54 @@ var paymentRequest = {
       record,
       errorStateChange,
       preserveOldProperties,
       selectedStateKey,
       successStateChange,
     });
   },
 
+  /**
+   * @param {object} state object representing the UI state
+   * @param {string} methodID (GUID) uniquely identifying the selected payment method
+   * @returns {object?} the applicable modifier for the payment method
+   */
+  getModifierForPaymentMethod(state, methodID) {
+    let method = state.savedBasicCards[methodID] || null;
+    if (method && method.methodName !== "basic-card") {
+      throw new Error(`${method.methodName} (${methodID}) is not a supported payment method`);
+    }
+    let modifiers = state.request.paymentDetails.modifiers;
+    if (!modifiers || !modifiers.length) {
+      return null;
+    }
+    let modifier = modifiers.find(m => {
+      // take the first matching modifier
+      // TODO (bug 1429198): match on supportedTypes and supportedNetworks
+      return m.supportedMethods == "basic-card";
+    });
+    return modifier || null;
+  },
+
+  /**
+   * @param {object} state object representing the UI state
+   * @returns {object} in the shape of `nsIPaymentItem` representing the total
+   *                   that applies to the selected payment method.
+   */
+  getTotalItem(state) {
+    let methodID = state.selectedPaymentCard;
+    if (methodID) {
+      let modifier = paymentRequest.getModifierForPaymentMethod(state, methodID);
+      if (modifier && modifier.hasOwnProperty("total")) {
+        return modifier.total;
+      }
+    }
+    return state.request.paymentDetails.totalItem;
+  },
+
   onPaymentRequestUnload() {
     // remove listeners that may be used multiple times here
     window.removeEventListener("paymentChromeToContent", this);
   },
 };
 
 paymentRequest.init();
 
--- a/toolkit/components/payments/test/PaymentTestUtils.jsm
+++ b/toolkit/components/payments/test/PaymentTestUtils.jsm
@@ -289,17 +289,21 @@ var PaymentTestUtils = {
     bobPayPaymentModifier: {
       total: {
         label: "Total due",
         amount: { currency: "USD", value: "2.00" },
       },
       displayItems: [
         {
           label: "First",
-          amount: { currency: "USD", value: "1" },
+          amount: { currency: "USD", value: "1.75" },
+        },
+        {
+          label: "Second",
+          amount: { currency: "USD", value: "0.25" },
         },
       ],
       modifiers: [
         {
           additionalDisplayItems: [
             {
               label: "Credit card fee",
               amount: { currency: "USD", value: "0.50" },
--- a/toolkit/components/payments/test/browser/browser_total.js
+++ b/toolkit/components/payments/test/browser/browser_total.js
@@ -7,8 +7,40 @@ add_task(async function test_total() {
        "Check total currency amount");
   };
   const args = {
     methodData: [PTU.MethodData.basicCard],
     details: PTU.Details.total60USD,
   };
   await spawnInDialogForMerchantTask(PTU.ContentTasks.createRequest, testTask, args);
 });
+
+add_task(async function test_modifier_with_no_method_selected() {
+  const testTask = async ({methodData, details}) => {
+    // There are no payment methods installed/setup so we expect the original (unmodified) total.
+    is(content.document.querySelector("#total > currency-amount").textContent,
+       "$2.00",
+       "Check unmodified total currency amount");
+  };
+  const args = {
+    methodData: [PTU.MethodData.bobPay, PTU.MethodData.basicCard],
+    details: PTU.Details.bobPayPaymentModifier,
+  };
+  await spawnInDialogForMerchantTask(PTU.ContentTasks.createRequest, testTask, args);
+});
+
+add_task(async function test_modifier_with_no_method_selected() {
+  info("adding a basic-card");
+  await addSampleAddressesAndBasicCard();
+
+  const testTask = async ({methodData, details}) => {
+    // We expect the *only* payment method (the one basic-card) to be selected initially.
+    is(content.document.querySelector("#total > currency-amount").textContent,
+       "$2.50",
+       "Check modified total currency amount");
+  };
+  const args = {
+    methodData: [PTU.MethodData.bobPay, PTU.MethodData.basicCard],
+    details: PTU.Details.bobPayPaymentModifier,
+  };
+  await spawnInDialogForMerchantTask(PTU.ContentTasks.createRequest, testTask, args);
+  await cleanupFormAutofillStorage();
+});
--- a/toolkit/components/payments/test/mochitest/test_order_details.html
+++ b/toolkit/components/payments/test/mochitest/test_order_details.html
@@ -5,18 +5,20 @@
 -->
 <head>
   <meta charset="utf-8">
   <title>Test the order-details component</title>
   <script type="application/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
   <script type="application/javascript" src="/tests/SimpleTest/AddTask.js"></script>
   <script src="payments_common.js"></script>
   <script src="../../res/vendor/custom-elements.min.js"></script>
+  <script src="../../res/unprivileged-fallbacks.js"></script>
 
   <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+  <link rel="stylesheet" type="text/css" href="../../res/containers/order-details.css"/>
 
   <template id="order-details-template">
     <ul class="main-list"></ul>
     <ul class="footer-items-list"></ul>
 
     <div class="details-total">
       <h2 class="label">Total</h2>
       <currency-amount></currency-amount>
@@ -40,56 +42,69 @@
 import OrderDetails from "../../res/containers/order-details.js";
 import {requestStore} from "../../res/mixins/PaymentStateSubscriberMixin.js";
 
 let orderDetails = document.querySelector("order-details");
 let emptyState = requestStore.getState();
 
 function setup() {
   let initialState = deepClone(emptyState);
-  requestStore.setState(initialState);
+  let cardGUID = "john-doe";
+  let johnDoeCard = deepClone(PTU.BasicCards.JohnDoe);
+  johnDoeCard.methodName = "basic-card";
+  let savedBasicCards = {
+    [cardGUID]: johnDoeCard,
+  };
+  initialState.selectedPaymentCard = cardGUID;
+  requestStore.setState(Object.assign(initialState, {savedBasicCards}));
 }
 
 add_task(async function isFooterItem() {
   ok(OrderDetails.isFooterItem({
     label: "Levy",
     type: "tax",
     amount: { currency: "USD", value: "1" },
   }, "items with type of 'tax' are footer items"));
   ok(!OrderDetails.isFooterItem({
     label: "Levis",
     amount: { currency: "USD", value: "1" },
-  }, "items without type of 'tax' arent footer items"));
+  }, "items without type of 'tax' aren't footer items"));
 });
 
 add_task(async function test_initial_state() {
   setup();
   is(orderDetails.mainItemsList.childElementCount, 0, "main items list is initially empty");
   is(orderDetails.footerItemsList.childElementCount, 0, "footer items list is initially empty");
   is(orderDetails.totalAmountElem.value, "0", "total amount is 0");
 });
 
 add_task(async function test_list_population() {
   setup();
-  let paymentDetails = requestStore.getState().request.paymentDetails;
+  let state = requestStore.getState();
+  let request = state.request;
+  let paymentDetails = deepClone(request.paymentDetails);
   paymentDetails.displayItems = [
     {
       label: "One",
       amount: { currency: "USD", value: "5" },
     },
     {
       label: "Two",
       amount: { currency: "USD", value: "6" },
     },
     {
       label: "Three",
       amount: { currency: "USD", value: "7" },
     },
   ];
-  requestStore.setState({ paymentDetails });
+
+  requestStore.setState({
+    request: Object.assign(deepClone(request), { paymentDetails }),
+  });
+
   await asyncElementRendered();
   is(orderDetails.mainItemsList.childElementCount, 3, "main items list has correct # children");
   is(orderDetails.footerItemsList.childElementCount, 0, "footer items list has 0 children");
 
   paymentDetails.displayItems = [
     {
       label: "Levy",
       type: "tax",
@@ -99,29 +114,86 @@ add_task(async function test_list_popula
       label: "Item",
       amount: { currency: "USD", value: "6" },
     },
     {
       label: "Thing",
       amount: { currency: "USD", value: "7" },
     },
   ];
-  requestStore.setState({ paymentDetails });
+  Object.assign(request, { paymentDetails });
+  requestStore.setState({ request });
   await asyncElementRendered();
 
   is(orderDetails.mainItemsList.childElementCount, 2, "main list has correct # children");
   is(orderDetails.footerItemsList.childElementCount, 1, "footer list has correct # children");
 });
 
+add_task(async function test_additionalDisplayItems() {
+  setup();
+  let state = requestStore.getState();
+  let request = state.request;
+  let paymentDetails = request.paymentDetails;
+
+  paymentDetails.modifiers = [{
+    additionalDisplayItems: [
+      {
+        label: "Card fee",
+        amount: { currency: "USD", value: "1.50" },
+      },
+    ],
+    supportedMethods: "basic-card",
+    total: {
+      label: "Total due",
+      amount: { currency: "USD", value: "3.50" },
+    },
+  }];
+
+  Object.assign(request, { paymentDetails });
+  requestStore.setState(state);
+  await asyncElementRendered();
+
+  is(orderDetails.mainItemsList.childElementCount, 0,
+     "main list added 0 children from additionalDisplayItems");
+  is(orderDetails.footerItemsList.childElementCount, 1,
+     "footer list added children from additionalDisplayItems");
+});
+
+
 add_task(async function test_total() {
-  let paymentDetails = requestStore.getState().request.paymentDetails;
-  paymentDetails.totalItem = { label: "foo", amount: { currency: "JPY", value: 5 }};
-  requestStore.setState({ paymentDetails });
+  setup();
+  let request = requestStore.getState().request;
+  let paymentDetails = request.paymentDetails;
+  paymentDetails.totalItem = { label: "foo", amount: { currency: "JPY", value: "5" }};
+
+  Object.assign(request, { paymentDetails });
+  requestStore.setState({ request });
   await asyncElementRendered();
 
   is(orderDetails.totalAmountElem.value, "5", "total amount gets updated");
   is(orderDetails.totalAmountElem.currency, "JPY", "total currency gets updated");
 });
 
+add_task(async function test_modified_total() {
+  setup();
+  let state = requestStore.getState();
+  let request = state.request;
+  let paymentDetails = request.paymentDetails;
+  paymentDetails.totalItem = { label: "foo", amount: { currency: "JPY", value: "5" }};
+  paymentDetails.modifiers = [{
+    supportedMethods: "basic-card",
+    total: {
+      label: "Total due",
+      amount: { currency: "USD", value: "3.5" },
+    },
+  }];
+  Object.assign(request, { paymentDetails });
+  requestStore.setState(state);
+  await asyncElementRendered();
+
+  is(orderDetails.totalAmountElem.value, "3.5", "total amount uses modifier total");
+  is(orderDetails.totalAmountElem.currency, "USD", "total currency uses modifier currency");
+});
+
 </script>
 
 </body>
 </html>