updated_WSExporter

Created Diff never expires
118 removals
Words removed206
Total words2355
Words removed (%)8.75
841 lines
140 additions
Words added256
Total words2405
Words added (%)10.64
860 lines
// ==UserScript==
// ==UserScript==
// @name Wealthsimple export transactions as CSV
// @name Wealthsimple export transactions as CSV
// @namespace Violentmonkey Scripts
// @namespace Violentmonkey Scripts
// @match https://my.wealthsimple.com/*
// @match https://my.wealthsimple.com/*
// @grant GM.xmlHttpRequest
// @grant GM.xmlHttpRequest
// @version 1.3
// @version 1.3
// @license MIT
// @license MIT
// @author eaglesemanation
// @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.
// @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.
// @downloadURL https://update.greasyfork.org/scripts/500403/Wealthsimple%20export%20transactions%20as%20CSV.user.js
// @updateURL https://update.greasyfork.org/scripts/500403/Wealthsimple%20export%20transactions%20as%20CSV.meta.js
// ==/UserScript==
// ==/UserScript==

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

/**
/**
* @typedef {Object} PageInfo
* @typedef {Object} PageInfo
* @property {"account-details" | "activity" | null} pageType
* @property {"account-details" | "activity" | null} pageType
* @property {HTMLElement?} anchor - Element to which buttons will be "attached". Buttons should be inserted before it.
* @property {HTMLElement?} anchor - Element to which buttons will be "attached". Buttons should be inserted before it.
* @property {ReadyPredicate?} readyPredicate - Verifies if ready to insert
* @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,
* 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.
* because it gets spammed executed by MutationObserver.
*
*
* @returns {PageInfo}
* @returns {PageInfo}
*/
*/
function getPageInfo() {
function getPageInfo() {
/**
/**
* @type PageInfo
* @type PageInfo
*/
*/
let emptyInfo = {
let emptyInfo = {
pageType: null,
pageType: null,
anchor: null,
anchor: null,
readyPredicate: null,
readyPredicate: null,
accountsInfo: null,
accountsInfo: null,
};
};
let info = structuredClone(emptyInfo);
let info = structuredClone(emptyInfo);

let pathParts = window.location.pathname.split("/");
let pathParts = window.location.pathname.split("/");
if (pathParts.length === 4 && pathParts[2] === "account-details") {
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.
// 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 =
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";
"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}"])`;
const threeDotsButtonContainerQuery = `div:has(> div > button svg > path[d="${threeDotsSvgPath}"])`;

info.pageType = "account-details";
info.pageType = "account-details";
let anchor = document.querySelectorAll(threeDotsButtonContainerQuery);
let anchor = document.querySelectorAll(threeDotsButtonContainerQuery);
if (anchor.length !== 1) {
if (anchor.length !== 1) {
return emptyInfo;
return emptyInfo;
}
}
info.anchor = anchor[0];
info.anchor = anchor[0];
info.readyPredicate = () => info.anchor.parentNode.children.length >= 1;
info.readyPredicate = () => info.anchor.parentNode.children.length >= 1;
} else if (pathParts.length === 3 && pathParts[2] === "activity") {
} else if (pathParts.length === 3 && pathParts[2] === "activity") {
const threeLinesSvgPath =
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";
"M2 4h12M4.667 8h6.666m-4.666 4h2.666"; // UPDATED SVG PATH
const threeLinesButtonContainerQuery = `div:has(> button svg > path[d="${threeLinesSvgPath}"])`;
// updated css selector
const threeLinesButtonContainerQuery = `button svg > path[d="${threeLinesSvgPath}"]`;
info.pageType = "activity";
info.pageType = "activity";
let anchor = document.querySelectorAll(threeLinesButtonContainerQuery);
let anchor = document.querySelectorAll(threeLinesButtonContainerQuery);

if (anchor.length !== 1) {
if (anchor.length !== 1) {
console.log(
"[csv-export] Incorrect amount of anchors were found. Expected 1 but got: ",
anchor.length,
);
return emptyInfo;
return emptyInfo;
}
}
info.anchor = anchor[0];
// This has to be parent element of button element, not path element.
info.anchor = anchor[0].closest('div[class*="dVMmbH"]');
info.readyPredicate = () => info.anchor.parentNode.children.length >= 1;
info.readyPredicate = () => info.anchor.parentNode.children.length >= 1;
} else {
} else {
// Didn't match any expected page
// Didn't match any expected page
return emptyInfo;
return emptyInfo;
}
}

return info;
return info;
}
}

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

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

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

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

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

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

/**
/**
* Stub, just forcing neovim to corectly highlight CSS syntax in literal
* Stub, just forcing neovim to corectly highlight CSS syntax in literal
*/
*/
function css(str) {
function css(str) {
return str;
return str;
}
}

const stylesheet = new CSSStyleSheet();
const stylesheet = new CSSStyleSheet();
stylesheet.insertRule(css`
stylesheet.insertRule(css`
.export-csv-button:hover {
.export-csv-button:hover {
color: rgb(50, 48, 47);
color: rgb(50, 48, 47);
background-image: linear-gradient(
background-image: linear-gradient(
0deg,
0deg,
rgba(0, 0, 0, 0.04) 0%,
rgba(0, 0, 0, 0.04) 0%,
rgba(0, 0, 0, 0.04) 100%
rgba(0, 0, 0, 0.04) 100%
);
);
}
}
`);
`);
stylesheet.insertRule(css`
stylesheet.insertRule(css`
.export-csv-button {
.export-csv-button {
display: inline-flex;
display: inline-flex;
background: rgb(255, 255, 255);
background: rgb(255, 255, 255);
border: 1px solid rgb(228, 226, 225);
border: 1px solid rgb(228, 226, 225);
border-radius: 4.5em;
border-radius: 4.5em;
font-size: 16px;
font-size: 16px;
padding-left: 1em;
padding-left: 1em;
padding-right: 1em;
padding-right: 1em;
font-family: "FuturaPT-Demi";
font-family: "FuturaPT-Demi";
font-weight: unset;
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?
* 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
* @param {PageInfo} pageInfo
* @returns {void}
* @returns {void}
*/
*/
function addButtons(pageInfo) {
function addButtons(pageInfo) {
document.adoptedStyleSheets = [stylesheet];
document.adoptedStyleSheets = [stylesheet];

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

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

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

for (const button of buttons) {
for (const button of buttons) {
let exportButton = document.createElement("button");
let exportButton = document.createElement("button");
exportButton.innerText = button.text;
exportButton.innerText = button.text;
exportButton.className = "export-csv-button";
exportButton.className = "export-csv-button";
exportButton.onclick = async () => {
exportButton.onclick = async () => {
console.log("[csv-export] Fetching account details");
console.log("[csv-export] Fetching account details");
let accountsInfo = await accountFinancials();
let accountsInfo = await accountFinancials();
let accountNicknames = accountsInfo.reduce((acc, v) => {
let accountNicknames = accountsInfo.reduce((acc, v) => {
acc[v.id] = v.nickname;
acc[v.id] = v.nickname;
return acc;
return acc;
}, {});
}, {});

let transactions = [];
let transactions = [];

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

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

buttonRow.appendChild(exportButton);
buttonRow.appendChild(exportButton);
}
}

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

let currencyToggle = anchorParent.querySelector(
let currencyToggle = anchorParent.querySelector(
`div:has(> ul > li > button)`,
`div:has(> ul > li > button)`,
);
);
if (currencyToggle) {
if (currencyToggle) {
// NOTE: Patch to currency toggle, for some reason it sets width="100%", and it's ugly
// NOTE: Patch to currency toggle, for some reason it sets width="100%", and it's ugly
for (const s of document.styleSheets) {
for (const s of document.styleSheets) {
for (const r of s.rules) {
for (const r of s.rules) {
if (
if (
currencyToggle.matches(r.selectorText) &&
currencyToggle.matches(r.selectorText) &&
r.style.width === "100%"
r.style.width === "100%"
) {
) {
currencyToggle.classList.remove(r.selectorText.substring(1));
currencyToggle.classList.remove(r.selectorText.substring(1));
}
}
}
}
}
}
// NOTE: Swap with currency toggle, just looks nicer
// NOTE: Swap with currency toggle, just looks nicer
buttonRow.parentNode.insertBefore(buttonRow, currencyToggle);
buttonRow.parentNode.insertBefore(buttonRow, currencyToggle);
}
}
}
}

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

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

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

const activityFeedItemFragment = `
const activityFeedItemFragment = `
fragment Activity on ActivityFeedItem {
fragment Activity on ActivityFeedItem {
accountId
accountId
externalCanonicalId
externalCanonicalId
amount
amount
amountSign
amountSign
occurredAt
occurredAt
type
type
subType
subType
eTransferEmail
eTransferEmail
eTransferName
eTransferName
assetSymbol
assetSymbol
assetQuantity
assetQuantity
aftOriginatorName
aftOriginatorName
aftTransactionCategory
aftTransactionCategory
aftTransactionType
aftTransactionType
canonicalId
canonicalId
currency
currency
identityId
identityId
institutionName
institutionName
p2pHandle
p2pHandle
p2pMessage
p2pMessage
spendMerchant
spendMerchant
securityId
securityId
billPayCompanyName
billPayCompanyName
billPayPayeeNickname
billPayPayeeNickname
redactedExternalAccountNumber
redactedExternalAccountNumber
opposingAccountId
opposingAccountId
status
status
strikePrice
strikePrice
contractType
contractType
expiryDate
expiryDate
chequeNumber
chequeNumber
provisionalCreditAmount
provisionalCreditAmount
primaryBlocker
primaryBlocker
interestRate
interestRate
frequency
frequency
counterAssetSymbol
counterAssetSymbol
rewardProgram
rewardProgram
counterPartyCurrency
counterPartyCurrency
counterPartyCurrencyAmount
counterPartyCurrencyAmount
counterPartyName
counterPartyName
fxRate
fxRate
fees
fees
reference
reference
}
}
`;
`;

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

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

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

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

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

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

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

fragment Account on Account {
fragment Account on Account {
id
id
unifiedAccountType
unifiedAccountType
nickname
nickname
}
}
`;
`;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

switch (type) {
switch (type) {
case "INTEREST": {
case "INTEREST": {
payee = "Wealthsimple";
payee = "Wealthsimple";
notes = "Interest";
notes = "Interest";
break;
break;
}
}
case "INTEREST/FPL_INTEREST": {
payee = "Wealthsimple";
notes = `Interest`;
break;
}
case "REIMBURSEMENT/ATM": {
payee = "Wealthsimple";
notes = `ATM Reimbursement`;
break;
}
case "P2P_PAYMENT/SEND": {
payee = transaction.p2pHandle;
notes = `ATM Reimbursement`;
break;
}

case "DEPOSIT/E_TRANSFER": {
case "DEPOSIT/E_TRANSFER": {
payee = transaction.eTransferEmail;
payee = transaction.eTransferEmail;
notes = `INTERAC e-Transfer from ${transaction.eTransferName}`;
notes = `INTERAC e-Transfer from ${transaction.eTransferName}`;
break;
break;
}
}
case "WITHDRAWAL/E_TRANSFER": {
case "WITHDRAWAL/E_TRANSFER": {
payee = transaction.eTransferEmail;
payee = transaction.eTransferEmail;
notes = `INTERAC e-Transfer to ${transaction.eTransferName}`;
notes = `INTERAC e-Transfer to ${transaction.eTransferName}`;
break;
break;
}
}
case "DIVIDEND/DIY_DIVIDEND": {
case "DIVIDEND/DIY_DIVIDEND": {
payee = transaction.assetSymbol;
payee = transaction.assetSymbol;
notes = `Received dividend from ${transaction.assetSymbol}`;
notes = `Received dividend from ${transaction.assetSymbol}`;
break;
break;
}
}
case "DIY_BUY/DIVIDEND_REINVESTMENT": {
case "DIY_BUY/DIVIDEND_REINVESTMENT": {
payee = transaction.assetSymbol;
payee = transaction.assetSymbol;
notes = `Reinvested dividend into ${transaction.assetQuantity} ${transaction.assetSymbol}`;
notes = `Reinvested dividend into ${transaction.assetQuantity} ${transaction.assetSymbol}`;
break;
break;
}
}
case "DIY_BUY/MARKET_ORDER": {
case "DIY_BUY/MARKET_ORDER": {
payee = transaction.assetSymbol;
payee = transaction.assetSymbol;
notes = `Bought ${transaction.assetQuantity} ${transaction.assetSymbol}`;
notes = `Bought ${transaction.assetQuantity} ${transaction.assetSymbol}`;
break;
break;
}
}
case "DIY_BUY/RECURRING_ORDER": {
payee = transaction.assetSymbol;
notes = `Bought ${transaction.assetQuantity} ${transaction.assetSymbol} using recurring order`;
break;
}
case "DIY_BUY/LIMIT_ORDER": {
payee = transaction.assetSymbol;
notes = `Bought ${transaction.assetQuantity} ${transaction.assetSymbol} using limit order`;
break;
}
case "DIY_BUY/FRACTIONAL_ORDER": {
payee = transaction.assetSymbol;
notes = `Bought ${transaction.assetQuantity} ${transaction.assetSymbol} using fractional order`;
break;
}
case "DIY_SELL/LIMIT_ORDER": {
payee = transaction.assetSymbol;
notes = `Sold ${transaction.assetQuantity} ${transaction.assetSymbol} using limit order`;
break;
}
case "DIY_SELL/FRACTIONAL_ORDER": {
payee = transaction.assetSymbol;
notes = `Sold ${transaction.assetQuantity} ${transaction.assetSymbol} using fractional order`;
break;
}
case "DIY_SELL/MARKET_ORDER": {
payee = transaction.assetSymbol;
notes = `Sold ${transaction.assetQuantity} ${transaction.assetSymbol} using market order`;
break;
}
case "CRYPTO_SELL/MARKET_ORDER": {
payee = transaction.assetSymbol;
notes = `Sold ${transaction.assetQuantity} ${transaction.assetSymbol} using market order`;
break;
}

case "DEPOSIT/AFT": {
case "DEPOSIT/AFT": {
payee = transaction.aftOriginatorName;
payee = transaction.aftOriginatorName;
notes = `Direct deposit from ${transaction.aftOriginatorName}`;
notes = `Direct deposit from ${transaction.aftOriginatorName}`;
category = transaction.aftTransactionCategory;
category = transaction.aftTransactionCategory;
break;
break;
}
}
case "WITHDRAWAL/AFT": {
case "WITHDRAWAL/AFT": {
payee = transaction.aftOriginatorName;
payee = transaction.aftOriginatorName;
notes = `Direct deposit to ${transaction.aftOriginatorName}`;
notes = `Direct deposit to ${transaction.aftOriginatorName}`;
category = transaction.aftTransactionCategory;
category = transaction.aftTransactionCategory;
break;
break;
}
}
case "FUNDS_CONVERSION": {
payee = "Wealthsimple";
notes = `Funds converted to ${transaction.currency}`;
category = transaction.aftTransactionCategory;
break;
}
case "DEPOSIT/EFT": {
case "DEPOSIT/EFT": {
let info = await fundsTransfer(transaction.externalCanonicalId);
let info = await fundsTransfer(transaction.externalCanonicalId);
let bankInfo = info.source.bankAccount;
// console.log("[csv-export] EFT deposit info: ", info);
let bankInfo = info?.source?.bankAccount;
if (!bankInfo) {
console.error("[csv-export] bankInfo was undefined in EFT deposit:", transaction)
continue;
}
payee = `${bankInfo.institutionName} ${bankInfo.nickname || bankInfo.accountName} ${bankInfo.accountNumber || ""}`;
payee = `${bankInfo.institutionName} ${bankInfo.nickname || bankInfo.accountName} ${bankInfo.accountNumber || ""}`;
notes = `Direct deposit from ${payee}`;
notes = `Direct deposit from ${payee}`;
break;
break;
}
}
case "WITHDRAWAL/EFT": {
case "WITHDRAWAL/EFT": {
let info = await fundsTransfer(transaction.externalCanonicalId);
let info = await fundsTransfer(transaction.externalCanonicalId);
let bankInfo = info.source.bankAccount;
// console.log("[csv-export] EFT withdraw info: ", info);
let bankInfo = info?.source?.bankAccount;
if (!bankInfo) {
console.error("[csv-export] bankInfo was undefined in EFT withdraw:", transaction)
continue;
}
payee = `${bankInfo.institutionName} ${bankInfo.nickname || bankInfo.accountName} ${bankInfo.accountNumber || ""}`;
payee = `${bankInfo.institutionName} ${bankInfo.nickname || bankInfo.accountName} ${bankInfo.accountNumber || ""}`;
notes = `Direct deposit to ${payee}`;
notes = `Direct deposit to ${payee}`;
break;
break;
}
}
case "INTERNAL_TRANSFER/SOURCE": {
case "INTERNAL_TRANSFER/SOURCE": {
payee = accountNicknames[transaction.opposingAccountId];
payee = accountNicknames[transaction.opposingAccountId];
notes = `Internal transfer to ${payee}`;
notes = `Internal transfer to ${payee}`;
break;
break;
}
}
case "INTERNAL_TRANSFER/DESTINATION": {
case "INTERNAL_TRANSFER/DESTINATION": {
payee = accountNicknames[transaction.opposingAccountId];
payee = accountNicknames[transaction.opposingAccountId];
notes = `Internal transfer from ${payee}`;
notes = `Internal transfer from ${payee}`;
break;
break;
}
}
case "SPEND/PREPAID": {
case "SPEND/PREPAID": {
payee = transaction.spendMerchant;
payee = transaction.spendMerchant;
notes = `Prepaid to ${payee}`;
notes = `Prepaid to ${payee}`;
break;
break;
}
}
case "WITHDRAWAL/BILL_PAY": {
case "WITHDRAWAL/BILL_PAY": {
payee = transaction.billPayPayeeNickname;
payee = transaction.billPayPayeeNickname;
notes = `Bill payment to ${transaction.billPayCompanyName}`;
notes = `Bill payment to ${transaction.billPayCompanyName}`;
category = "bill";
c
break;
}
default: {
console.log(
`[csv-export] ${dateStr} transaction [${type}] has unexpected type, skipping it. Please report on greasyfork.org for assistanse.`,
);
console.log(transaction);
continue;
}
}
let amount = transaction.amount;
if (transaction.amountSign === "negative") {
amount = `-${amount}`;
}
let entry = `"${dateStr}","${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 {{[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);
}
}