Wealthsimple export transactions as CSV

Adds export buttons to Activity feed and to Account specific activity. 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.

Od 12.07.2024.. Pogledajte najnovija verzija.

// ==UserScript==
// @name        Wealthsimple export transactions as CSV
// @namespace   Violentmonkey Scripts
// @match       https://my.wealthsimple.com/*
// @grant       GM.xmlHttpRequest
// @version     1.0
// @license     MIT
// @author      eaglesemanation
// @description Adds export buttons to Activity feed and to Account specific activity. 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==

/**
 * @callback ReadyPredicate
 * @returns {boolean}
 */

/**
 * @typedef {Object} PageInfo
 * @property {"account-details" | "activity" | null} pageType
 * @property {HTMLElement?} anchor - Element to which buttons will be "attached". Buttons should be inserted before it.
 * @property {ReadyPredicate?} readyPredicate - Verifies if ready to insert
 */

/**
 * 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,
    readyPredicate: null,
    accountsInfo: null,
  };
  let info = structuredClone(emptyInfo);

  let pathParts = window.location.pathname.split("/");
  if (pathParts.length === 4 && pathParts[2] === "account-details") {
    // All classes within HTML have been obfuscated/minified, using icons as a starting point, in hope that they don't change that much.
    const threeDotsSvgPath =
      "M5.333 11.997c0 1.466-1.2 2.666-2.666 2.666A2.675 2.675 0 0 1 0 11.997C0 10.53 1.2 9.33 2.667 9.33c1.466 0 2.666 1.2 2.666 2.667Zm16-2.667a2.675 2.675 0 0 0-2.666 2.667c0 1.466 1.2 2.666 2.666 2.666 1.467 0 2.667-1.2 2.667-2.666 0-1.467-1.2-2.667-2.667-2.667ZM12 9.33a2.675 2.675 0 0 0-2.667 2.667c0 1.466 1.2 2.666 2.667 2.666 1.467 0 2.667-1.2 2.667-2.666 0-1.467-1.2-2.667-2.667-2.667Z";
    const threeDotsButtonContainerQuery = `div:has(> div > button svg > path[d="${threeDotsSvgPath}"])`;

    info.pageType = "account-details";
    let anchor = document.querySelectorAll(threeDotsButtonContainerQuery);
    if (anchor.length !== 1) {
      return emptyInfo;
    }
    info.anchor = anchor[0];
    info.readyPredicate = () => info.anchor.parentNode.children.length >= 2;
  } else if (pathParts.length === 3 && pathParts[2] === "activity") {
    const threeLinesSvgPath =
      "M14 8c0 .6-.4 1-1 1H3c-.6 0-1-.4-1-1s.4-1 1-1h10c.6 0 1 .4 1 1Zm1-6H1c-.6 0-1 .4-1 1s.4 1 1 1h14c.6 0 1-.4 1-1s-.4-1-1-1Zm-4 10H5c-.6 0-1 .4-1 1s.4 1 1 1h6c.6 0 1-.4 1-1s-.4-1-1-1Z";
    const threeLinesButtonContainerQuery = `div:has(> button svg > path[d="${threeLinesSvgPath}"])`;

    info.pageType = "activity";
    let anchor = document.querySelectorAll(threeLinesButtonContainerQuery);
    if (anchor.length !== 1) {
      return emptyInfo;
    }
    info.anchor = anchor[0];
    info.readyPredicate = () => info.anchor.parentNode.children.length >= 2;
  } 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;
  }
  if (!(pageInfo.readyPredicate && pageInfo.readyPredicate())) {
    return;
  }

  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 CSS syntax in literal
 */
function css(str) {
  return str;
}

const stylesheet = new CSSStyleSheet();
stylesheet.insertRule(css`
  .export-csv-button:hover {
    color: rgb(50, 48, 47);
    background-image: linear-gradient(
      0deg,
      rgba(0, 0, 0, 0.04) 0%,
      rgba(0, 0, 0, 0.04) 100%
    );
  }
`);
stylesheet.insertRule(css`
  .export-csv-button {
    display: inline-flex;
    background: rgb(255, 255, 255);
    border: 1px solid rgb(228, 226, 225);
    border-radius: 4.5em;
    font-size: 16px;
    padding-left: 1em;
    padding-right: 1em;
    font-family: "FuturaPT-Demi";
    font-weight: unset;
  }
`);

/**
 * Attaches button row to anchor element. Should be syncronous to avoid attaching row twice, because Mutex is not cool enough for JS?
 *
 * @param {PageInfo} pageInfo
 * @returns {void}
 */
function addButtons(pageInfo) {
  document.adoptedStyleSheets = [stylesheet];

  let buttonRow = document.createElement("div");
  buttonRow.id = exportCsvId;
  buttonRow.style.display = "flex";
  buttonRow.style.alignItems = "baseline";
  buttonRow.style.gap = "1em";
  buttonRow.style.marginLeft = "auto";

  let buttonRowText = document.createElement("span");
  buttonRowText.innerText = "Export Transactions as CSV:";
  buttonRow.appendChild(buttonRowText);

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

  for (const button of buttons) {
    let exportButton = document.createElement("button");
    exportButton.innerText = button.text;
    exportButton.className = "export-csv-button";
    exportButton.onclick = async () => {
      console.log("Fetching account details");
      let accountsInfo = await accountFinancials();

      let transactions = [];

      console.log("Fetching transactions");
      if (pageInfo.pageType === "account-details") {
        let pathParts = window.location.pathname.split("/");
        accountIds = [pathParts[3]];
        transactions = await activityList(accountIds, button.fromDate);
      } else if (pageInfo.pageType === "activity") {
        let params = new URLSearchParams(window.location.search);
        let ids_param = params.get("account_ids");
        if (ids_param) {
          accountIds = ids_param.split(",");
        } else {
          accountIds = accountsInfo.map((acc) => acc.id);
        }
        transactions = await activityFeedItems(accountIds, button.fromDate);
      }

      let blobs = await transactionsToCsvBlobs(transactions);
      saveBlobsToFiles(blobs, accountsInfo, button.fromDate);
    };

    buttonRow.appendChild(exportButton);
  }

  let anchorParent = pageInfo.anchor.parentNode;
  anchorParent.insertBefore(buttonRow, pageInfo.anchor);
  anchorParent.style.gap = "1em";
  pageInfo.anchor.style.marginLeft = "0";
}

/**
 * @typedef {Object} OauthCookie
 * @property {string} access_token
 * @property {string} identity_canonical_id
 */

/**
 * @returns {OauthCookie}
 */
function getOauthCookie() {
  let decodedCookie = decodeURIComponent(document.cookie).split(";");
  for (let cookieKV of decodedCookie) {
    if (cookieKV.indexOf("_oauth2_access_v2") !== -1) {
      let [_, val] = cookieKV.split("=");
      return JSON.parse(val);
    }
  }
  return null;
}

/**
 * Subset of ActivityFeedItem type in GraphQL API
 *
 * @typedef {Object} Transaction
 * @property {string} accountId
 * @property {string} externalCanonicalId
 * @property {string} amount
 * @property {string} amountSign
 * @property {string} occurredAt
 * @property {string} type
 * @property {string} subType
 * @property {string} eTransferEmail
 * @property {string} eTransferName
 * @property {string} assetSymbol
 * @property {string} assetQuantity
 * @property {string} aftOriginatorName
 * @property {string} aftTransactionCategory
 */

const activityFeedItemFragment = `
  fragment Activity on ActivityFeedItem {
    accountId
    externalCanonicalId
    amount
    amountSign
    occurredAt
    type
    subType
    eTransferEmail
    eTransferName
    assetSymbol
    assetQuantity
    aftOriginatorName
    aftTransactionCategory
  }
`;

const fetchActivityListQuery = `
  query FetchActivityList(
    $first: Int!
    $cursor: Cursor
    $accountIds: [String!]
    $types: [ActivityFeedItemType!]
    $subTypes: [ActivityFeedItemSubType!]
    $endDate: Datetime
    $securityIds: [String]
    $startDate: Datetime
    $legacyStatuses: [String]
  ) {
    activities(
      first: $first
      after: $cursor
      accountIds: $accountIds
      types: $types
      subTypes: $subTypes
      endDate: $endDate
      securityIds: $securityIds
      startDate: $startDate
      legacyStatuses: $legacyStatuses
    ) {
      edges {
        node {
          ...Activity
        }
      }
      pageInfo {
        hasNextPage
        endCursor
      }
    }
  }
`;

/**
 * API used by account specific activity view.
 * Seems like it's just outdated API, will use it just as safetyguard
 *
 * @returns {Promise<[Transaction]>}
 */
async function activityList(accountIds, startDate) {
  let transactions = [];
  let hasNextPage = true;
  let cursor = undefined;
  while (hasNextPage) {
    let respJson = await GM.xmlHttpRequest({
      url: "https://my.wealthsimple.com/graphql",
      method: "POST",
      responseType: "json",
      headers: {
        "content-type": "application/json",
        authorization: `Bearer ${getOauthCookie().access_token}`,
      },
      data: JSON.stringify({
        operationName: "FetchActivityList",
        query: `
            ${fetchActivityListQuery}
            ${activityFeedItemFragment}
        `,
        variables: {
          first: 100,
          cursor,
          startDate,
          endDate: new Date().toISOString(),
          accountIds,
        },
      }),
    });

    if (respJson.status !== 200) {
      throw `Failed to fetch transactions: ${respJson.responseText}`;
    }
    let resp = JSON.parse(respJson.responseText);
    let activities = resp.data.activities;
    hasNextPage = activities.pageInfo.hasNextPage;
    cursor = activities.pageInfo.endCursor;
    transactions = transactions.concat(activities.edges.map((e) => e.node));
  }
  return transactions;
}

const fetchActivityFeedItemsQuery = `
  query FetchActivityFeedItems(
    $first: Int
    $cursor: Cursor
    $condition: ActivityCondition
    $orderBy: [ActivitiesOrderBy!] = OCCURRED_AT_DESC
  ) {
    activityFeedItems(
      first: $first
      after: $cursor
      condition: $condition
      orderBy: $orderBy
    ) {
      edges {
        node {
          ...Activity
        }
      }
      pageInfo {
        hasNextPage
        endCursor
      }
    }
  }
`;

/**
 * API used by activity feed page.
 * @returns {Promise<[Transaction]>}
 */
async function activityFeedItems(accountIds, startDate) {
  let transactions = [];
  let hasNextPage = true;
  let cursor = undefined;
  while (hasNextPage) {
    let respJson = await GM.xmlHttpRequest({
      url: "https://my.wealthsimple.com/graphql",
      method: "POST",
      responseType: "json",
      headers: {
        "content-type": "application/json",
        authorization: `Bearer ${getOauthCookie().access_token}`,
      },
      data: JSON.stringify({
        operationName: "FetchActivityFeedItems",
        query: `
            ${fetchActivityFeedItemsQuery}
            ${activityFeedItemFragment}
        `,
        variables: {
          first: 100,
          cursor,
          condition: {
            startDate,
            accountIds,
            unifiedStatuses: ["COMPLETED"],
          },
        },
      }),
    });

    if (respJson.status !== 200) {
      throw `Failed to fetch transactions: ${respJson.responseText}`;
    }
    let resp = JSON.parse(respJson.responseText);
    let activities = resp.data.activityFeedItems;
    hasNextPage = activities.pageInfo.hasNextPage;
    cursor = activities.pageInfo.endCursor;
    transactions = transactions.concat(activities.edges.map((e) => e.node));
  }
  return transactions;
}

const fetchAllAccountFinancialsQuery = `
  query FetchAllAccountFinancials(
    $identityId: ID!
    $pageSize: Int = 25
    $cursor: String
  ) {
    identity(id: $identityId) {
      id
      accounts(filter: {}, first: $pageSize, after: $cursor) {
        pageInfo {
          hasNextPage
          endCursor
        }
        edges {
          cursor
          node {
            ...Account
          }
        }
      }
    }
  }

  fragment Account on Account {
    id
    unifiedAccountType
    nickname
  }
`;

/**
 * @typedef {Object} AccountInfo
 * @property {string} id
 * @property {string} nickname
 */

/**
 * Query all accounts
 * @returns {Promise<[AccountInfo]>}
 */
async function accountFinancials() {
  let oauthCookie = getOauthCookie();
  let respJson = await GM.xmlHttpRequest({
    url: "https://my.wealthsimple.com/graphql",
    method: "POST",
    responseType: "json",
    headers: {
      "content-type": "application/json",
      authorization: `Bearer ${oauthCookie.access_token}`,
    },
    data: JSON.stringify({
      operationName: "FetchAllAccountFinancials",
      query: fetchAllAccountFinancialsQuery,
      variables: {
        identityId: oauthCookie.identity_canonical_id,
        pageSize: 25,
      },
    }),
  });

  if (respJson.status !== 200) {
    throw `Failed to fetch account info: ${respJson.responseText}`;
  }
  let resp = JSON.parse(respJson.responseText);
  const self_directed_re = /^SELF_DIRECTED_(?<name>.*)/;
  let accounts = resp.data.identity.accounts.edges.map((e) => {
    let nickname = e.node.nickname;
    if (!nickname) {
      if (e.node.unifiedAccountType === "CASH") {
        nickname = "Cash";
      } else if (self_directed_re.test(e.node.unifiedAccountType)) {
        let found = e.node.unifiedAccountType.match(self_directed_re);
        nickname = found.groups.name;
        if (nickname === "CRYPTO") {
          nickname = "Crypto";
        } else if (nickname === "NON_REGISTERED") {
          nickname = "Non-registered";
        }
      } else {
        nickname = "Unknown";
      }
    }
    return {
      id: e.node.id,
      nickname,
    };
  });
  return accounts;
}

/**
 * @typedef {Object} TransferInfo
 * @property {string} id
 * @property {string} status
 * @property {{"bankAccount": BankInfo}} source
 * @property {{"bankAccount": BankInfo}} destination
 */

/**
 * @typedef {Object} BankInfo
 * @property {string} accountName
 * @property {string} accountNumber
 * @property {string} institutionName
 * @property {string} nickname
 */

const fetchFundsTransferQuery = `
  query FetchFundsTransfer($id: ID!) {
    fundsTransfer: funds_transfer(id: $id, include_cancelled: true) {
      id
      status
      source {
        ...BankAccountOwner
      }
      destination {
        ...BankAccountOwner
      }
    }
  }

  fragment BankAccountOwner on BankAccountOwner {
    bankAccount: bank_account {
      id
      institutionName: institution_name
      nickname
      ...CaBankAccount
      ...UsBankAccount
    }
  }

  fragment CaBankAccount on CaBankAccount {
    accountName: account_name
    accountNumber: account_number
  }

  fragment UsBankAccount on UsBankAccount {
    accountName: account_name
    accountNumber: account_number
  }
`;

/**
 * @param {string} transferId
 * @returns {Promise<TransferInfo>}
 */
async function fundsTransfer(transferId) {
  let respJson = await GM.xmlHttpRequest({
    url: "https://my.wealthsimple.com/graphql",
    method: "POST",
    responseType: "json",
    headers: {
      "content-type": "application/json",
      authorization: `Bearer ${getOauthCookie().access_token}`,
    },
    data: JSON.stringify({
      operationName: "FetchFundsTransfer",
      query: fetchFundsTransferQuery,
      variables: {
        id: transferId,
      },
    }),
  });

  if (respJson.status !== 200) {
    throw `Failed to fetch transfer info: ${respJson.responseText}`;
  }
  let resp = JSON.parse(respJson.responseText);
  return resp.data.fundsTransfer;
}

/**
 * @param {[Transaction]} transactions
 * @returns {Promise<{[string]: Blob}>}
 */
async function transactionsToCsvBlobs(transactions) {
  let accTransactions = transactions.reduce((acc, transaction) => {
    const id = transaction.accountId;
    (acc[id] = acc[id] || []).push(transaction);
    return acc;
  }, {});
  let accBlobs = {};
  for (let acc in accTransactions) {
    accBlobs[acc] = await accountTransactionsToCsvBlob(accTransactions[acc]);
  }
  return accBlobs;
}

/**
 * @param {[Transaction]} transactions
 * @returns {Promise<Blob>}
 */
async function accountTransactionsToCsvBlob(transactions) {
  let csv = `"Date","Payee","Notes","Category","Amount"\n`;
  for (const transaction of transactions) {
    let date = new Date(transaction.occurredAt);
    // JS Date type is absolutly horible, I hope Temporal API will be better
    let dateStr = `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`;

    let payee = null;
    let notes = null;
    let type = transaction.type;
    if (transaction.subType) {
      type = `${type}/${transaction.subType}`;
    }

    // Most transactions in Wealthsimple don't have category, skipping
    let category = "";

    switch (type) {
      case "INTEREST":
        payee = "Wealthsimple";
        notes = "Interest";
        break;
      case "WITHDRAWAL/E_TRANSFER":
        payee = transaction.eTransferEmail;
        notes = `INTERAC e-Transfer to ${transaction.eTransferName}`;
        break;
      case "DIVIDEND/DIY_DIVIDEND":
        payee = transaction.assetSymbol;
        notes = `Received dividend from ${transaction.assetSymbol}`;
        break;
      case "DIY_BUY/DIVIDEND_REINVESTMENT":
        payee = transaction.assetSymbol;
        notes = `Reinvested dividend into ${transaction.assetQuantity} ${transaction.assetSymbol}`;
        break;
      case "DIY_BUY/MARKET_ORDER":
        payee = transaction.assetSymbol;
        notes = `Bought ${transaction.assetQuantity} ${transaction.assetSymbol}`;
        break;
      case "DEPOSIT/AFT":
        payee = transaction.aftOriginatorName;
        notes = `Direct deposit from ${transaction.aftOriginatorName}`;
        category = transaction.aftTransactionCategory;
        break;
      case "DEPOSIT/EFT":
        let info = await fundsTransfer(transaction.externalCanonicalId);
        let bankInfo = info.source.bankAccount;
        payee = `${bankInfo.institutionName} ${bankInfo.nickname || bankInfo.accountName} ${bankInfo.accountNumber || ""}`;
        notes = `Direct deposit from ${payee}`;
        break;
      default:
        console.log(
          `${dateStr} transaction [${type}] has unexpected type. Object logged below. Skipping`,
        );
        console.log(transaction);
        continue;
    }

    let amount = transaction.amount;
    if (transaction.amountSign === "negative") {
      amount = `-${amount}`;
    }

    let entry = `"${dateStr}","${payee}","${notes}","${category}","${amount}"`;
    csv += `${entry}\n`;
  }
  return new Blob([csv], { type: "text/csv" });
}

/**
 * @param {{[string]: Blob}} accountBlobs
 * @param {[AccountInfo]} accountsInfo
 * @param {Date?} fromDate
 */
function saveBlobsToFiles(accountBlobs, accountsInfo, fromDate) {
  let accToName = accountsInfo.reduce((accum, info) => {
    accum[info.id] = info.nickname;
    return accum;
  }, {});

  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 = `Wealthsimple ${accToName[acc]} Transactions ${timeFrame}.csv`;
    link.style.display = "none";
    document.body.appendChild(link);
    link.click();
    document.body.removeChild(link);
    URL.revokeObjectURL(blobUrl);
  }
}
长期地址
遇到问题?请前往 GitHub 提 Issues。