<NME> max.ts
<BEF> import buildUrl from 'build-url';
import moment from 'moment';
import { fetchGetWithinPage } from '../helpers/fetch';
import { BaseScraperWithBrowser, LoginResults } from './base-scraper-with-browser';
import { waitForRedirect } from '../helpers/navigation';
import { waitUntilElementFound, elementPresentOnPage, clickButton } from '../helpers/elements-interactions';
import getAllMonthMoments from '../helpers/dates';
import { fixInstallments, sortTransactionsByDate, filterOldTransactions } from '../helpers/transactions';
import { TransactionStatuses, TransactionTypes } from '../types';

const BASE_ACTIONS_URL = 'https://online.max.co.il';
const BASE_API_ACTIONS_URL = 'https://onlinelcapi.max.co.il';
    arn: string;
  };
}

const BASE_ACTIONS_URL = 'https://online.max.co.il';
const BASE_API_ACTIONS_URL = 'https://onlinelcapi.max.co.il';
const BASE_WELCOME_URL = 'https://www.max.co.il';

const LOGIN_URL = `${BASE_WELCOME_URL}/homepage/welcome`;
const PASSWORD_EXPIRED_URL = `${BASE_ACTIONS_URL}/Anonymous/Login/PasswordExpired.aspx`;
const SUCCESS_URL = `${BASE_WELCOME_URL}/homepage/personal`;

const NORMAL_TYPE_NAME = 'רגילה';
const ATM_TYPE_NAME = 'חיוב עסקות מיידי';
const INTERNET_SHOPPING_TYPE_NAME = 'אינטרנט/חו"ל';
const INSTALLMENTS_TYPE_NAME = 'תשלומים';
const INVALID_DETAILS_SELECTOR = '#popupWrongDetails';
const LOGIN_ERROR_SELECTOR = '#popupCardHoldersLoginError';

function redirectOrDialog(page) {
  return Promise.race([
    waitForRedirect(page, 20000, false, [BASE_WELCOME_URL, `${BASE_WELCOME_URL}/`]),
    waitUntilElementFound(page, INVALID_DETAILS_SELECTOR, true),
const TWO_MONTHS_POSTPONED_TYPE_NAME = 'דחוי חודשיים';
  ]);
}

function getTransactionsUrl(monthMoment) {
  const month = monthMoment.month() + 1;
  const year = monthMoment.year();
  const date = `${year}-${month}-01`;

const INVALID_DETAILS_SELECTOR = '#popupWrongDetails';
const LOGIN_ERROR_SELECTOR = '#popupCardHoldersLoginError';

const categories = new Map<number, string>();

function redirectOrDialog(page: Page) {
  return Promise.race([
    waitForRedirect(page, 20000, false, [BASE_WELCOME_URL, `${BASE_WELCOME_URL}/`]),
  });
}

function getTransactionType(txnTypeStr) {
  const cleanedUpTxnTypeStr = txnTypeStr.replace('\t', ' ').trim();
  switch (cleanedUpTxnTypeStr) {
    case ATM_TYPE_NAME:
  const year = monthMoment.year();
  const date = `${year}-${month}-01`;

  /**
     * url explanation:
     * userIndex: -1 for all account owners
     * cardIndex: -1 for all cards under the account
     * all other query params are static, beside the date which changes for request per month
     */
  return buildUrl(BASE_API_ACTIONS_URL, {
    path: `/api/registered/transactionDetails/getTransactionsAndGraphs?filterData={"userIndex":-1,"cardIndex":-1,"monthView":true,"date":"${date}","dates":{"startDate":"0","endDate":"0"},"bankAccount":{"bankAccountIndex":-1,"cards":null}}&firstCallCardIndex=-1`,
  });
}

interface FetchCategoryResult {
  result? : Array<{
    id: number;
  }
}

function getInstallmentsInfo(comments) {
  if (!comments) {
    return null;
  }
  if (res && Array.isArray(res.result)) {
    debug(`${res.result.length} categories loaded`);
      res.result?.forEach(({ id, name }) => categories.set(id, name));
  }
}

function getTransactionType(txnTypeStr: string) {
    total: parseInt(matches[1], 10),
  };
}

function mapTransaction(rawTransaction) {
  const isPending = rawTransaction.paymentDate === null;
  const processedDate = moment(isPending ?
    rawTransaction.purchaseDate :
    case FUTURE_PURCHASE_FINANCING:
    case MONTHLY_PAYMENT_TYPE_NAME:
    case MONTHLY_POSTPONED_INSTALLMENTS_TYPE_NAME:
    case THIRTY_DAYS_PLUS_TYPE_NAME:
    case TWO_MONTHS_POSTPONED_TYPE_NAME:
    case ACCUMULATING_BASKET:
    case INTERNET_SHOPPING_TYPE_NAME:
    case MONTHLY_CHARGE_PLUS_INTEREST_TYPE_NAME:
    case POSTPONED_TRANSACTION_INSTALLMENTS:
    chargedAmount: -rawTransaction.actualPaymentAmount,
    description: rawTransaction.merchantName.trim(),
    memo: rawTransaction.comments,
    installments: getInstallmentsInfo(rawTransaction.comments),
    status,
  };
}

async function fetchTransactionsForMonth(page, monthMoment) {
  const url = getTransactionsUrl(monthMoment);

  const data = await fetchGetWithinPage(page, url);
  const transactionsByAccount = {};

  if (!data.result) return transactionsByAccount;

  data.result.transactions.forEach((transaction) => {
    if (!transactionsByAccount[transaction.shortCardNumber]) {
      transactionsByAccount[transaction.shortCardNumber] = [];
    }
}
function mapTransaction(rawTransaction: ScrapedTransaction): Transaction {
  const isPending = rawTransaction.paymentDate === null;
  const processedDate = moment(isPending ?
    rawTransaction.purchaseDate :
  return transactionsByAccount;
}

function addResult(allResults, result) {
  const clonedResults = { ...allResults };
  Object.keys(result).forEach((accountNumber) => {
    if (!clonedResults[accountNumber]) {
      clonedResults[accountNumber] = [];
  return {
    type: getTransactionType(rawTransaction.planName),
    date: moment(rawTransaction.purchaseDate).toISOString(),
  return clonedResults;
}

function prepareTransactions(txns, startMoment, combineInstallments) {
  let clonedTxns = Array.from(txns);
  if (!combineInstallments) {
    clonedTxns = fixInstallments(clonedTxns);
    installments,
    identifier,
    status,
  return clonedTxns;
}

async function fetchTransactions(page, options) {
  const defaultStartMoment = moment().subtract(1, 'years');
  const startDate = options.startDate || defaultStartMoment.toDate();
  const startMoment = moment.max(defaultStartMoment, moment(startDate));
  const allMonths = getAllMonthMoments(startMoment, true);

  let allResults = {};
  for (let i = 0; i < allMonths.length; i += 1) {
    const result = await fetchTransactionsForMonth(page, allMonths[i]);
    allResults = addResult(allResults, result);

  if (!data || !data.result) return transactionsByAccount;

  data.result.transactions
    // Filter out non-transactions without a plan type, e.g. summary rows
    .filter((transaction) => !!transaction.planName)
    .forEach((transaction: ScrapedTransaction) => {
      if (!transactionsByAccount[transaction.shortCardNumber]) {
  return allResults;
}

function getPossibleLoginResults(page) {
  const urls = {};
  urls[LoginResults.Success] = [`${BASE_WELCOME_URL}/homepage/personal`];
  urls[LoginResults.ChangePassword] = [`${BASE_ACTIONS_URL}/Anonymous/Login/PasswordExpired.aspx`];
  urls[LoginResults.InvalidPassword] = [async () => {
}

function addResult(allResults: Record<string, Transaction[]>, result: Record<string, Transaction[]>) {
  const clonedResults: Record<string, Transaction[]> = { ...allResults };
  Object.keys(result).forEach((accountNumber) => {
  return urls;
}

function createLoginFields(inputGroupName, credentials) {
  return [
    { selector: `#${inputGroupName}_txtUserName`, value: credentials.username },
    { selector: '#txtPassword', value: credentials.password },

}

class MaxScraper extends BaseScraperWithBrowser {
  getLoginOptions(credentials) {
    const inputGroupName = 'PlaceHolderMain_CardHoldersLogin1';
    return {
      loginUrl: `${BASE_ACTIONS_URL}/Anonymous/Login/CardholdersLogin.aspx`,
  return clonedTxns;
}

async function fetchTransactions(page: Page, options: ScraperOptions) {
  const futureMonthsToScrape = options.futureMonthsToScrape ?? 1;
  const defaultStartMoment = moment().subtract(1, 'years');
  const startDate = options.startDate || defaultStartMoment.toDate();
  const startMoment = moment.max(defaultStartMoment, moment(startDate));
  const allMonths = getAllMonthMoments(startMoment, futureMonthsToScrape);

  await loadCategories(page);

  let allResults: Record<string, Transaction[]> = {};
  for (let i = 0; i < allMonths.length; i += 1) {
    const result = await fetchTransactionsForMonth(page, allMonths[i]);
    allResults = addResult(allResults, result);
  }

  Object.keys(allResults).forEach((accountNumber) => {
    let txns = allResults[accountNumber];
    txns = prepareTransactions(txns, startMoment, options.combineInstallments || false);
    allResults[accountNumber] = txns;
  });

  return allResults;
}

function getPossibleLoginResults(page: Page): PossibleLoginResults {
  const urls: PossibleLoginResults = {};
  urls[LoginResults.Success] = [SUCCESS_URL];
  urls[LoginResults.ChangePassword] = [PASSWORD_EXPIRED_URL];
  urls[LoginResults.InvalidPassword] = [async () => {
    return elementPresentOnPage(page, INVALID_DETAILS_SELECTOR);
  }];
  urls[LoginResults.UnknownError] = [async () => {
    return elementPresentOnPage(page, LOGIN_ERROR_SELECTOR);
  }];
  return urls;
}

function createLoginFields(credentials: ScraperCredentials) {
  return [
    { selector: '#user-name', value: credentials.username },
    { selector: '#password', value: credentials.password },
  ];
}

class MaxScraper extends BaseScraperWithBrowser {
  getLoginOptions(credentials: ScraperCredentials) {
    return {
      loginUrl: LOGIN_URL,
      fields: createLoginFields(credentials),
      submitButtonSelector: '#login-password #send-code',
      preAction: async () => {
        if (await elementPresentOnPage(this.page, '#closePopup')) {
          await clickButton(this.page, '#closePopup');
        }
        await clickButton(this.page, '.personal-area > a.go-to-personal-area');
        await waitUntilElementFound(this.page, '#login-password-link', true);
        await clickButton(this.page, '#login-password-link');
        await waitUntilElementFound(this.page, '#login-password.tab-pane.active app-user-login-form', true);
      },
      checkReadiness: async () => {
        await waitUntilElementFound(this.page, '.personal-area > a.go-to-personal-area', true);
      },
      postAction: async () => redirectOrDialog(this.page),
      possibleResults: getPossibleLoginResults(this.page),
    };
  }

  async fetchData() {
    const results = await fetchTransactions(this.page, this.options);
    const accounts = Object.keys(results).map((accountNumber) => {
      return {
        accountNumber,
        txns: results[accountNumber],
      };
    });

    return {
      success: true,
      accounts,
    };
  }
}

export default MaxScraper;

<MSG> fix max types

<DFF> @@ -1,12 +1,27 @@
 import buildUrl from 'build-url';
-import moment from 'moment';
+import moment, { Moment } from 'moment';
+import { Page } from 'puppeteer';
 import { fetchGetWithinPage } from '../helpers/fetch';
-import { BaseScraperWithBrowser, LoginResults } from './base-scraper-with-browser';
+import { BaseScraperWithBrowser, LoginResults, PossibleLoginResults } from './base-scraper-with-browser';
 import { waitForRedirect } from '../helpers/navigation';
 import { waitUntilElementFound, elementPresentOnPage, clickButton } from '../helpers/elements-interactions';
 import getAllMonthMoments from '../helpers/dates';
 import { fixInstallments, sortTransactionsByDate, filterOldTransactions } from '../helpers/transactions';
-import { TransactionStatuses, TransactionTypes } from '../types';
+import { Transaction, TransactionStatuses, TransactionTypes } from '../types';
+import { BaseScraperOptions, ScraperCredentials } from './base-scraper';
+
+
+interface ScrapedTransaction {
+  shortCardNumber: string,
+  paymentDate?: string,
+  purchaseDate: string,
+  actualPaymentAmount: string,
+  originalCurrency: string,
+  originalAmount: number,
+  planName: string,
+  comments: string,
+  merchantName: string,
+}
 
 const BASE_ACTIONS_URL = 'https://online.max.co.il';
 const BASE_API_ACTIONS_URL = 'https://onlinelcapi.max.co.il';
@@ -29,7 +44,7 @@ const CREDIT_TYPE_NAME = 'קרדיט';
 const INVALID_DETAILS_SELECTOR = '#popupWrongDetails';
 const LOGIN_ERROR_SELECTOR = '#popupCardHoldersLoginError';
 
-function redirectOrDialog(page) {
+function redirectOrDialog(page: Page) {
   return Promise.race([
     waitForRedirect(page, 20000, false, [BASE_WELCOME_URL, `${BASE_WELCOME_URL}/`]),
     waitUntilElementFound(page, INVALID_DETAILS_SELECTOR, true),
@@ -37,7 +52,7 @@ function redirectOrDialog(page) {
   ]);
 }
 
-function getTransactionsUrl(monthMoment) {
+function getTransactionsUrl(monthMoment: Moment) {
   const month = monthMoment.month() + 1;
   const year = monthMoment.year();
   const date = `${year}-${month}-01`;
@@ -53,7 +68,7 @@ function getTransactionsUrl(monthMoment) {
   });
 }
 
-function getTransactionType(txnTypeStr) {
+function getTransactionType(txnTypeStr: string) {
   const cleanedUpTxnTypeStr = txnTypeStr.replace('\t', ' ').trim();
   switch (cleanedUpTxnTypeStr) {
     case ATM_TYPE_NAME:
@@ -77,7 +92,7 @@ function getTransactionType(txnTypeStr) {
   }
 }
 
-function getInstallmentsInfo(comments) {
+function getInstallmentsInfo(comments: string) {
   if (!comments) {
     return null;
   }
@@ -91,8 +106,7 @@ function getInstallmentsInfo(comments) {
     total: parseInt(matches[1], 10),
   };
 }
-
-function mapTransaction(rawTransaction) {
+function mapTransaction(rawTransaction: ScrapedTransaction): Transaction {
   const isPending = rawTransaction.paymentDate === null;
   const processedDate = moment(isPending ?
     rawTransaction.purchaseDate :
@@ -108,20 +122,25 @@ function mapTransaction(rawTransaction) {
     chargedAmount: -rawTransaction.actualPaymentAmount,
     description: rawTransaction.merchantName.trim(),
     memo: rawTransaction.comments,
-    installments: getInstallmentsInfo(rawTransaction.comments),
+    installments: getInstallmentsInfo(rawTransaction.comments) || undefined,
     status,
   };
 }
+interface FetchedTransactions{
+  result?: {
+    transactions: ScrapedTransaction[]
+  }
+}
 
-async function fetchTransactionsForMonth(page, monthMoment) {
+async function fetchTransactionsForMonth(page: Page, monthMoment: Moment) {
   const url = getTransactionsUrl(monthMoment);
 
-  const data = await fetchGetWithinPage(page, url);
-  const transactionsByAccount = {};
+  const data = await fetchGetWithinPage<FetchedTransactions>(page, url);
+  const transactionsByAccount: Record<string, Transaction[]> = {};
 
-  if (!data.result) return transactionsByAccount;
+  if (!data || !data.result) return transactionsByAccount;
 
-  data.result.transactions.forEach((transaction) => {
+  data.result.transactions.forEach((transaction: ScrapedTransaction) => {
     if (!transactionsByAccount[transaction.shortCardNumber]) {
       transactionsByAccount[transaction.shortCardNumber] = [];
     }
@@ -133,8 +152,8 @@ async function fetchTransactionsForMonth(page, monthMoment) {
   return transactionsByAccount;
 }
 
-function addResult(allResults, result) {
-  const clonedResults = { ...allResults };
+function addResult(allResults: Record<string, Transaction[]>, result: Record<string, Transaction[]>) {
+  const clonedResults: Record<string, Transaction[]> = { ...allResults };
   Object.keys(result).forEach((accountNumber) => {
     if (!clonedResults[accountNumber]) {
       clonedResults[accountNumber] = [];
@@ -144,7 +163,7 @@ function addResult(allResults, result) {
   return clonedResults;
 }
 
-function prepareTransactions(txns, startMoment, combineInstallments) {
+function prepareTransactions(txns: Transaction[], startMoment: moment.Moment, combineInstallments: boolean) {
   let clonedTxns = Array.from(txns);
   if (!combineInstallments) {
     clonedTxns = fixInstallments(clonedTxns);
@@ -154,13 +173,13 @@ function prepareTransactions(txns, startMoment, combineInstallments) {
   return clonedTxns;
 }
 
-async function fetchTransactions(page, options) {
+async function fetchTransactions(page: Page, options: BaseScraperOptions) {
   const defaultStartMoment = moment().subtract(1, 'years');
   const startDate = options.startDate || defaultStartMoment.toDate();
   const startMoment = moment.max(defaultStartMoment, moment(startDate));
   const allMonths = getAllMonthMoments(startMoment, true);
 
-  let allResults = {};
+  let allResults: Record<string, Transaction[]> = {};
   for (let i = 0; i < allMonths.length; i += 1) {
     const result = await fetchTransactionsForMonth(page, allMonths[i]);
     allResults = addResult(allResults, result);
@@ -175,8 +194,8 @@ async function fetchTransactions(page, options) {
   return allResults;
 }
 
-function getPossibleLoginResults(page) {
-  const urls = {};
+function getPossibleLoginResults(page: Page): PossibleLoginResults {
+  const urls: PossibleLoginResults = {};
   urls[LoginResults.Success] = [`${BASE_WELCOME_URL}/homepage/personal`];
   urls[LoginResults.ChangePassword] = [`${BASE_ACTIONS_URL}/Anonymous/Login/PasswordExpired.aspx`];
   urls[LoginResults.InvalidPassword] = [async () => {
@@ -188,7 +207,7 @@ function getPossibleLoginResults(page) {
   return urls;
 }
 
-function createLoginFields(inputGroupName, credentials) {
+function createLoginFields(inputGroupName: string, credentials: ScraperCredentials) {
   return [
     { selector: `#${inputGroupName}_txtUserName`, value: credentials.username },
     { selector: '#txtPassword', value: credentials.password },
@@ -196,7 +215,7 @@ function createLoginFields(inputGroupName, credentials) {
 }
 
 class MaxScraper extends BaseScraperWithBrowser {
-  getLoginOptions(credentials) {
+  getLoginOptions(credentials: ScraperCredentials) {
     const inputGroupName = 'PlaceHolderMain_CardHoldersLogin1';
     return {
       loginUrl: `${BASE_ACTIONS_URL}/Anonymous/Login/CardholdersLogin.aspx`,

fix max types