<NME> leumi.js
<BEF> ADDFILE
<MSG> Merge pull request #90 from esakal/bank-leumi-scraper

add bank leumi scraper
<DFF> @@ -0,0 +1,173 @@
+import moment from 'moment';
+import { BaseScraperWithBrowser, LOGIN_RESULT } from './base-scraper-with-browser';
+import { dropdownSelect, fillInput, clickButton, waitUntilElementFound } from '../helpers/elements-interactions';
+import { waitForNavigation } from '../helpers/navigation';
+import { SHEKEL_CURRENCY, NORMAL_TXN_TYPE } from '../constants';
+
+const BASE_URL = 'https://hb2.bankleumi.co.il/';
+const DATE_FORMAT = 'DD/MM/YY';
+
+function getTransactionsUrl() {
+  return `${BASE_URL}/ebanking/Accounts/ExtendedActivity.aspx?WidgetPar=1#/`;
+}
+
+function getPossibleLoginResults() {
+  const urls = {};
+  urls[LOGIN_RESULT.SUCCESS] = /ebanking\/SO\/SPA.aspx/;
+  urls[LOGIN_RESULT.INVALID_PASSWORD] = /InternalSite\/CustomUpdate\/leumi\/LoginPage.ASP/;
+  // urls[LOGIN_RESULT.CHANGE_PASSWORD] = ``; // TODO should wait until my password expires
+  return urls;
+}
+
+function createLoginFields(credentials) {
+  return [
+    { selector: '#wtr_uid', value: credentials.username },
+    { selector: '#wtr_password', value: credentials.password },
+  ];
+}
+
+function getAmountData(amountStr) {
+  const amountStrCopy = amountStr.replace(',', '');
+  const amount = parseFloat(amountStrCopy);
+  const currency = SHEKEL_CURRENCY;
+
+  return {
+    amount,
+    currency,
+  };
+}
+
+function convertTransactions(txns) {
+  return txns.map((txn) => {
+    const txnDate = moment(txn.date, DATE_FORMAT).toISOString();
+
+    const credit = getAmountData(txn.credit).amount;
+    const debit = getAmountData(txn.debit).amount;
+    const amount = (Number.isNaN(credit) ? 0 : credit) - (Number.isNaN(debit) ? 0 : debit);
+    return {
+      type: NORMAL_TXN_TYPE,
+      identifier: txn.reference ? parseInt(txn.reference, 10) : null,
+      date: txnDate,
+      processedDate: txnDate,
+      originalAmount: amount,
+      originalCurrency: SHEKEL_CURRENCY,
+      chargedAmount: amount,
+      description: txn.description,
+      /* memo: txn.memo, TODO add this line to export transaction memo */
+    };
+  });
+}
+
+async function fetchTransactionsForAccount(page, startDate) {
+  await dropdownSelect(page, 'select#ddlTransactionPeriod', '004');
+  await waitUntilElementFound(page, 'select#ddlTransactionPeriod');
+  await fillInput(
+    page,
+    'input#dtFromDate_textBox',
+    startDate.format('DD/MM/YY'),
+  );
+  await clickButton(page, 'input#btnDisplayDates');
+  await waitForNavigation(page);
+  await waitUntilElementFound(page, 'table#WorkSpaceBox table#ctlActivityTable');
+  await clickButton(page, 'a#lnkCtlExpandAll');
+
+  const selectedSnifAccount = await page.$eval('#ddlAccounts_m_ddl option[selected="selected"]', (option) => {
+    return $(option).text(); // eslint-disable-line no-undef
+  });
+
+  const accountNumber = selectedSnifAccount.replace('/', '_');
+
+  const tdsValues = await page.$$eval('#WorkSpaceBox #ctlActivityTable tr td', (tds) => {
+    return tds.map(td =>
+      ({
+        classList: td.getAttribute('class'),
+        innerText: td.innerText,
+      }));
+  });
+
+  const txns = [];
+  for (const element of tdsValues) {
+    if (element.classList.includes('ExtendedActivityColumnDate')) {
+      const newTransaction = {};
+      newTransaction.date = (element.innerText || '').trim();
+      txns.push(newTransaction);
+    } else if (element.classList.includes('ActivityTableColumn1LTR')
+        || element.classList.includes('ActivityTableColumn1')) {
+      const changedTransaction = txns.pop();
+      changedTransaction.description = element.innerText;
+      txns.push(changedTransaction);
+    } else if (element.classList.includes('ReferenceNumberUniqeClass')) {
+      const changedTransaction = txns.pop();
+      changedTransaction.reference = element.innerText;
+      txns.push(changedTransaction);
+    } else if (element.classList.includes('AmountDebitUniqeClass')) {
+      const changedTransaction = txns.pop();
+      changedTransaction.debit = element.innerText;
+      txns.push(changedTransaction);
+    } else if (element.classList.includes('AmountCreditUniqeClass')) {
+      const changedTransaction = txns.pop();
+      changedTransaction.credit = element.innerText;
+      txns.push(changedTransaction);
+    } else if (element.classList.includes('number_column')) {
+      const changedTransaction = txns.pop();
+      changedTransaction.balance = element.innerText;
+      txns.push(changedTransaction);
+    } else if (element.classList.includes('tdDepositRowAdded')) {
+      const changedTransaction = txns.pop();
+      changedTransaction.memo = element.innerText;
+      txns.push(changedTransaction);
+    }
+  }
+
+  return {
+    accountNumber,
+    txns: convertTransactions(txns),
+  };
+}
+
+async function fetchTransactions(page, startDate) {
+  // TODO need to extend to support multiple accounts and foreign accounts
+  return [await fetchTransactionsForAccount(page, startDate)];
+}
+
+async function getAccountData(page, options) {
+  const defaultStartMoment = moment().subtract(1, 'years').add(1, 'day');
+  const startDate = options.startDate || defaultStartMoment.toDate();
+  const startMoment = moment.max(defaultStartMoment, moment(startDate));
+
+  const url = getTransactionsUrl();
+  await page.goto(url);
+
+  const accounts = await fetchTransactions(page, startMoment);
+
+  return {
+    success: true,
+    accounts,
+  };
+}
+
+async function waitForPostLogin(page) {
+  // TODO check for condition to provide new password
+  return Promise.race([
+    waitUntilElementFound(page, 'div.leumi-container', true),
+    waitUntilElementFound(page, '#loginErrMsg', true),
+  ]);
+}
+
+class LeumiScraper extends BaseScraperWithBrowser {
+  getLoginOptions(credentials) {
+    return {
+      loginUrl: `${BASE_URL}`,
+      fields: createLoginFields(credentials),
+      submitButtonSelector: '#enter',
+      postAction: async () => waitForPostLogin(this.page),
+      possibleResults: getPossibleLoginResults(),
+    };
+  }
+
+  async fetchData() {
+    return getAccountData(this.page, this.options);
+  }
+}
+
+export default LeumiScraper;

Merge pull request #90 from esakal/bank-leumi-scraper