RBC export transactions as CSV

Adds export buttons to Summary and Details pages. They will export transactions within certain timeframe into CSV, options are "This Month", "Last 3 Month", "All". This should provide better transaction description than what is provided by preexisting CSV export feature.

// ==UserScript==
// @name        RBC export transactions as CSV
// @namespace   Violentmonkey Scripts
// @match       https://www1.royalbank.com/*
// @grant       GM.xmlHttpRequest
// @version     1.0.1
// @license     MIT
// @author      eaglesemanation
// @description Adds export buttons to Summary and Details pages. They will export transactions within certain timeframe into CSV, options are "This Month", "Last 3 Month", "All". This should provide better transaction description than what is provided by preexisting CSV export feature.
// ==/UserScript==

/**
 * @typedef {Object} PageInfo
 * @property {"summary" | "details" | null} pageType
 * @property {HTMLElement?} anchor - Element to which buttons will be "attached". Buttons should be inserted before it.
 * @property {string?} accountId
 */

/**
 * Figures out which paget we're currently on and where to attach buttons. Should not do any queries,
 * because it gets spammed executed by MutationObserver.
 *
 * @returns {PageInfo}
 */
function getPageInfo() {
  /**
   * @type PageInfo
   */
  let emptyInfo = {
    pageType: null,
    anchor: null,
    accountId: null,
  };
  let info = structuredClone(emptyInfo);

  let hashParts = window.location.hash.split("/");
  if (hashParts.length === 2 && hashParts[1] === "summary") {
    info.pageType = "summary";
    let anchor = document.querySelectorAll("#ribbon-statements");
    if (anchor.length !== 1) {
      return emptyInfo;
    }
    info.anchor = anchor[0];
  } else if (hashParts.length === 2 && hashParts[1].startsWith("details")) {
    info.pageType = "details";
    const hashKV = hashParts[1].split(";");
    for (const kv of hashKV) {
      const kvParts = kv.split("=");
      if (kvParts.length === 2 && kvParts[0] === "selectedAccount") {
        info.accountId = kvParts[1];
        break;
      }
    }
    let downloadButtons = document.querySelectorAll(
      `a[rbcportalsubmit="DownloadTransactions"], a[rbcportalsubmit="CC_Posted_Transactions_Download"]`,
    );
    if (downloadButtons.length !== 2) {
      return emptyInfo;
    }
    for (let button of downloadButtons) {
      for (let attr of button.attributes) {
        if (attr.name.startsWith("_ngcontent")) {
          info.anchor = button;
        }
      }
    }
    if (info.anchor === null) {
      return emptyInfo;
    }
  } else {
    // Didn't match any expected page
    return emptyInfo;
  }

  return info;
}

// ID for quickly verifying if buttons were already injected
const exportCsvId = "export-transactions-csv";

/**
 * Keeps button shown after rerenders and href changes
 *
 * @returns {Promise<void>}
 */
async function keepButtonShown() {
  // Early exit, to avoid unnecessary requests if already injected
  if (document.querySelector(`div#${exportCsvId}`)) {
    return;
  }

  const pageInfo = getPageInfo();
  if (!pageInfo.pageType) {
    return;
  }

  console.log("[csv-export] Adding buttons");
  addButtons(pageInfo);
}

(async function () {
  const observer = new MutationObserver(async (mutations) => {
    for (const _ of mutations) {
      await keepButtonShown();
    }
  });
  observer.observe(document.documentElement, {
    childList: true,
    subtree: true,
  });

  // Try running on load if there are no mutations for some reason
  window.addEventListener("load", async () => {
    await keepButtonShown();
  });
})();

/**
 * Stub, just forcing neovim to corectly highlight HTML syntax in literal
 */
function html(strings, ...values) {
  return strings.reduce((result, string, i) => {
    return result + string + (values[i] || "");
  }, "");
}

const downloadIcon = function (ngcontentAttr) {
  return html`
    <rbc-icon
      ${ngcontentAttr}=""
      name="download"
      size="s"
      class="download s rbc-icon"
      aria-hidden="true"
    >
      <svg
        viewBox="0 0 16 16"
        fit=""
        height="100%"
        width="100%"
        preserveAspectRatio="xMidYMid meet"
        focusable="false"
      >
        <path
          fill-rule="evenodd"
          d="m11.905 9.356-3.555 3.5a.498.498 0 0 1-.7 0h-.001l-3.554-3.5a.5.5 0 1 1 .701-.712L7.5 11.306V.5a.5.5 0 0 1 1 0v10.806l2.704-2.662a.5.5 0 0 1 .701.712ZM15 9.5a.5.5 0 0 1 1 0V15a1 1 0 0 1-1 1H1a1 1 0 0 1-1-1V9.5a.5.5 0 0 1 1 0V15h14V9.5Z"
          clip-rule="evenodd"
        ></path>
      </svg>
    </rbc-icon>
  `;
};

/**
 * Attaches button row to anchor element. Should be synchronous to avoid attaching row twice, because Mutex is not cool enough for JS?
 *
 * @param {PageInfo} pageInfo
 * @returns {void}
 */
function addButtons(pageInfo) {
  let linkRow = document.createElement("div");
  linkRow.id = exportCsvId;
  linkRow.style.display = "flex";
  linkRow.style.alignItems = "baseline";
  linkRow.style.gap = "1em";
  linkRow.style.marginLeft = "auto";

  // Seems to be some sort of component CSS system, just copy attribute from adjasent node
  let ngcontentAttrName = "";
  for (let attr of pageInfo.anchor.attributes) {
    if (attr.name.startsWith("_ngcontent")) {
      ngcontentAttrName = attr.name;
    }
  }

  const now = new Date();
  const links = [
    {
      text: "This Month",
      fromDate: new Date(now.getFullYear(), now.getMonth(), 1),
    },
    {
      text: "3 Months",
      fromDate: new Date(now.getFullYear(), now.getMonth() - 3, 1),
    },
    {
      text: "All",
      fromDate: null,
    },
  ];

  for (const link of links) {
    let exportLink = document.createElement("a");
    exportLink.href = "JavaScript:void(0);"; // Makes browser believe that it's interactive, what a silly thing to do
    exportLink.className = "export-csv-button";
    exportLink.innerHTML = html`${downloadIcon(ngcontentAttrName)}
      <span
        ${pageInfo.pageType === "summary"
          ? `class="label label--shadowed"`
          : null}
        ${pageInfo.pageType === "details" ? `style="margin-left: 8px;"` : null}
        ${ngcontentAttrName}=""
      >
        ${link.text}
      </span>`;
    exportLink.setAttribute(ngcontentAttrName, "");
    exportLink.onclick = async () => {
      console.log("[csv-export] Fetching account details");
      let accountsInfo = await accountListSummary();
      /**
       * @type {Record<string, AccountSummary>}
       */
      let idToAccount = accountsInfo.reduce((acc, v) => {
        acc[v.accountId] = v;
        return acc;
      }, {});
      /**
       * @type {Record<string, Blob>}
       */
      let blobs = {};
      if (pageInfo.pageType === "summary") {
        for (let acc of accountsInfo) {
          let transactions = await accountTransactions(acc, link.fromDate);
          blobs[acc.accountId] = await transactionsToCsvBlob(transactions);
        }
      } else if (pageInfo.pageType === "details") {
        if (pageInfo.accountId === null) {
          throw "details page is missing selectedAccount argument";
        }
        let transactions = await accountTransactions(
          idToAccount[pageInfo.accountId],
          link.fromDate,
        );
        blobs[pageInfo.accountId] = await transactionsToCsvBlob(transactions);
      }
      saveBlobsToFiles(blobs, idToAccount, link.fromDate);
    };

    linkRow.appendChild(exportLink);
  }

  let anchorParent = pageInfo.anchor.parentNode;

  if (pageInfo.pageType === "details") {
    // Remove already existing download button
    pageInfo.anchor.innerHTML = "";
    anchorParent.insertBefore(linkRow, pageInfo.anchor);
  } else {
    let divider = document.createElement("div");
    divider.className = "mat-divider left-divider";
    divider.setAttribute(ngcontentAttrName, "");
    anchorParent.insertBefore(divider, pageInfo.anchor);
    anchorParent.insertBefore(linkRow, divider);
  }

  anchorParent.style.gap = "1em";
  pageInfo.anchor.style.marginLeft = "0";
}

/**
 * @typedef {Object} AccountListSummary
 *
 * @property {{accounts: [AccountSummary]}} depositAccounts
 * @property {{accounts: [AccountSummary]}} creditCards
 */

/**
 * @typedef {Object} AccountSummary
 *
 * @property {string} accountId
 * @property {string} accountNumber
 * @property {string} encryptedAccountNumber
 * @property {string} nickName
 * @property {{productName: string}} product
 * @property {"CREDIT" | "DEBIT"} type
 */

/**
 * Gets list of all types of accounts and their details
 *
 * @returns {Promise<[AccountSummary]>}
 */
async function accountListSummary() {
  let respJson = await GM.xmlHttpRequest({
    url: "https://www1.royalbank.com/sgw5/digital/product-summary-presentation-service-v3/v3/accountListSummary",
    method: "GET",
    responseType: "json",
    headers: {
      "content-type": "application/json",
    },
  });

  if (respJson.status !== 200) {
    throw `Failed to fetch account list: ${respJson.responseText}`;
  }
  /**
   * @type {AccountListSummary}
   */
  let resp = JSON.parse(respJson.responseText);
  if (resp.errorState.hasError === true) {
    throw `Failed to fetch account list: ${resp.errorState}`;
  }
  return resp.creditCards.accounts
    .map((v) => ({ ...v, type: "CREDIT" }))
    .concat(
      resp.depositAccounts.accounts.map((v) => ({
        ...v,
        type: "DEBIT",
      })),
    );
}

/**
 * Given Date returns yyyy-mm-dd
 *
 * @param {Date} date
 * @returns {string}
 */
function toDateString(date) {
  const offset = date.getTimezoneOffset();
  const dateWithOffset = new Date(date.getTime() - offset * 60 * 1000);
  return dateWithOffset.toISOString().split("T")[0];
}

/**
 * @param {Date} date
 * @param {number} years
 * @returns {Date}
 */
function dateSubstractYears(date, years) {
  let olderDate = new Date(date);
  olderDate.setFullYear(date.getFullYear() - years);
  return olderDate;
}

function getXSRFCookie() {
  let match = decodeURIComponent(document.cookie)
    .split(";")
    .map((v) => v.trim().split("=", 2))
    .filter(([key, _]) => key === "XSRF-TOKEN");
  if (match.length === 1) {
    return match[0][1];
  }
  return undefined;
}

/**
 * @typedef {Object} Transaction
 *
 * @property {number} amount
 * @property {"CREDIT" | "DEBIT"} creditDebitIndicator
 * @property {[string]} description
 * @property {string} bookingDate
 * @property {string} merchantName
 * @property {string} merchantCity
 */

/**
 * @param {AccountSummary} account
 * @param {Date?} fromDate
 */
async function accountTransactions(account, fromDate) {
  if (account.type === "CREDIT") {
    // TODO: Figure out what is actual limit for export, it might be related to date of card issue
    const fromDateWithDefault = fromDate ?? dateSubstractYears(new Date(), 4);
    return await creditAccountTransactions(
      account.encryptedAccountNumber,
      fromDateWithDefault,
    );
  } else if (account.type === "DEBIT") {
    const fromDateWithDefault = fromDate ?? dateSubstractYears(new Date(), 7);
    return await debitAccountTransactions(
      account.encryptedAccountNumber,
      fromDateWithDefault,
    );
  }
}

/**
 * Gets transactions for debit account
 *
 * @param {string} encryptedAccountNumber
 * @param {Date} fromDate
 * @returns {Promise<[Transaction]>}
 */
async function debitAccountTransactions(encryptedAccountNumber, fromDate) {
  const transactionFromDate = toDateString(fromDate);
  let transactions = [];
  let hasNextPage = true;
  let offsetKey = undefined;
  while (hasNextPage) {
    let respJson = await GM.xmlHttpRequest({
      url: `https://www1.royalbank.com/sgw5/digital/transaction-presentation-service-v3-dbb/v3/search/pda/account/${encodeURIComponent(encryptedAccountNumber)}`,
      method: "POST",
      responseType: "json",
      headers: {
        "content-type": "application/json",
        "X-XSRF-TOKEN": getXSRFCookie(),
      },
      data: JSON.stringify({
        transactionFromDate,
        transactionToDate: toDateString(new Date()),
        limit: 200,
        offsetKey,
      }),
    });

    if (respJson.status !== 200) {
      throw `Failed to fetch transactions: ${respJson.responseText}`;
    }
    let resp = JSON.parse(respJson.responseText);
    if (resp.hasError === true) {
      throw `Failed to fetch transactions: [${resp.errorLevel}] ${resp.errorDescription}`;
    }
    transactions = transactions.concat(resp.transactionList);
    if (transactions.length === 0) {
      break;
    }
    offsetKey = transactions[transactions.length - 1].transactionOffsetKey;
    hasNextPage = resp.totalMatches > transactions.length;
  }
  return transactions;
}

/**
 * Gets transactions for credit card
 *
 * @param {string} encryptedAccountNumber
 * @param {Date} fromDate
 * @returns {Promise<[Transaction]>}
 */
async function creditAccountTransactions(encryptedAccountNumber, fromDate) {
  const transactionFromDate = toDateString(fromDate);
  let transactions = [];
  let hasNextPage = true;
  let offsetKey = undefined;
  while (hasNextPage) {
    let respJson = await GM.xmlHttpRequest({
      url: `https://www1.royalbank.com/sgw5/digital/transaction-presentation-service-v3-dbb/v3/search/cc/posted/account/${encodeURIComponent(encryptedAccountNumber)}`,
      method: "POST",
      responseType: "json",
      headers: {
        "content-type": "application/json",
        "X-XSRF-TOKEN": getXSRFCookie(),
      },
      data: JSON.stringify({
        transactionFromDate,
        transactionToDate: toDateString(new Date()),
        limit: 200,
        offsetKey,
      }),
    });

    if (respJson.status !== 200) {
      throw `Failed to fetch transactions: ${respJson.responseText}`;
    }
    let resp = JSON.parse(respJson.responseText);
    if (resp.hasError === true) {
      throw `Failed to fetch transactions: [${resp.errorLevel}] ${resp.errorDescription}`;
    }
    transactions = transactions.concat(resp.transactionList);
    if (transactions.length === 0) {
      break;
    }
    offsetKey = transactions[transactions.length - 1].transactionOffsetKey;
    hasNextPage = resp.totalMatches > transactions.length;
  }
  return transactions;
}

/**
 * @param {[Transaction]} transactions
 * @returns {Promise<Blob>}
 */
async function transactionsToCsvBlob(transactions) {
  let csv = `"Date","Payee","Notes","Category","Amount"\n`;
  for (const transaction of transactions) {
    let payee = "unknown";
    let notes = transaction.description.join(" ");

    // Most transactions in RBC don't have category
    let category = "";

    const desc = transaction.description;
    if (transaction.merchantName !== null) {
      payee = transaction.merchantName;
      notes = transaction.merchantCity ?? transaction.merchantName;
    } else if (desc.length === 3 && desc[0].startsWith("e-Transfer")) {
      payee = desc[1];
      notes = `${desc[0]} [${desc[2]}]`;
    } else if (desc.length >= 1 && desc[0].toLowerCase().includes("fee")) {
      console.log(
        `[csv-export] ${transaction.bookingDate} transaction includes "fee" in its description: "${desc.join(" ")}"; setting payee to RBC. Please report on greasyfork.org in case of false positives.`,
      );
      payee = "RBC";
      notes = desc[0];
    } else if (
      desc.length === 1 &&
      desc[0].toLowerCase().endsWith("deposit interest")
    ) {
      payee = "RBC";
      notes = desc[0];
    } else if (desc.length === 2) {
      payee = desc[1];
      notes = desc[0];
    } else {
      console.log(
        `[csv-export] ${transaction.bookingDate} transaction has description that could not be parsed: "${desc.join(" ")}". If you beleive this description has identifiable payee - please report on greasyfork.org for assistanse.`,
      );
      console.log(transaction);
    }

    let amount = transaction.amount;
    if (transaction.creditDebitIndicator === "DEBIT") {
      amount = `-${amount}`;
    }

    // Transactions in RBC don't have category, skipping
    let entry = `"${transaction.bookingDate}","${payee}","${notes}","${category}","${amount}"`;
    csv += `${entry}\n`;
  }

  // Signals to some apps that file encoded with UTF-8
  const BOM = "\uFEFF";
  return new Blob([BOM, csv], { type: "text/csv;charset=utf-8" });
}

/**
 * @param {Record<string, Blob>}} accountBlobs
 * @param {Record<string, AccountInfo>} idToAccounts
 * @param {Date?} fromDate
 */
function saveBlobsToFiles(accountBlobs, idToAccounts, fromDate) {
  for (let acc in accountBlobs) {
    let blobUrl = URL.createObjectURL(accountBlobs[acc]);

    let now = new Date();
    let nowStr = `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()}`;
    let timeFrame = "";
    if (fromDate) {
      timeFrame += `From ${fromDate.getFullYear()}-${fromDate.getMonth() + 1}-${fromDate.getDate()} `;
    }
    timeFrame += `Up to ${nowStr}`;

    let link = document.createElement("a");
    link.href = blobUrl;
    link.download = `${idToAccounts[acc].product.productName} Transactions ${timeFrame}.csv`;
    link.style.display = "none";
    document.body.appendChild(link);
    link.click();
    document.body.removeChild(link);
    URL.revokeObjectURL(blobUrl);
  }
}