Last active
September 23, 2025 05:17
-
-
Save shotasenga/c461a672d9c9f927ce213a0c3e9e1895 to your computer and use it in GitHub Desktop.
Export transactions from Wealthsimple to a CSV file for YNAB import
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name Export Wealthsimple transactions to CSV for YNAB | |
// @namespace https://shotasenga.com/ | |
// @version 2025080400 | |
// @description Export transactions from Wealthsimple to a CSV file for YNAB import | |
// @author Shota Senga | |
// @match https://my.wealthsimple.com/app/* | |
// @icon https://www.google.com/s2/favicons?sz=64&domain=wealthsimple.com | |
// @grant none | |
// ==/UserScript== | |
/* | |
* DISCLAIMER: | |
* This script extracts sensitive financial information (transaction data) from Wealthsimple. | |
* Ensure that you use this script in a secure environment and handle the extracted data responsibly. | |
* The developer of this script is not responsible for any issues or troubles that arise from its use. | |
*/ | |
(function () { | |
"use strict"; | |
waitUntilElementExists("//h1[contains(., 'Activity')]", (element) => { | |
const button = document.createElement("button"); | |
button.innerText = "Export transactions"; | |
button.onclick = exportTransactions; | |
element.parentElement.appendChild(button); | |
}); | |
async function exportTransactions() { | |
const transactions = []; | |
for (const button of x( | |
`//button[contains(., 'Chequing')][contains(., '$')]` | |
)) { | |
const payee = button.querySelector("p").innerText; | |
const amount = x(`.//p[contains(., '$')]`, button).next().value | |
.innerText; | |
button.click(); | |
await nextTick(); | |
const [date, _] = Array.from( | |
x( | |
`.//p[contains(., 'Date')]/following-sibling::*//p`, | |
button.parentElement.parentElement | |
) | |
).map((el) => el.innerText); | |
transactions.push({ | |
payee, | |
amount, | |
date: formatDateForYNAB(date), | |
}); | |
} | |
const csv = []; | |
csv.push("Date, Payee, Amount"); | |
for (const transaction of transactions) { | |
csv.push( | |
[transaction.date, transaction.payee, transaction.amount] | |
.map(escapeCsvField) | |
.join(",") | |
); | |
} | |
// save as a file | |
const blob = new Blob([csv.join("\n")], { type: "text/csv" }); | |
const url = URL.createObjectURL(blob); | |
const a = document.createElement("a"); | |
a.href = url; | |
a.download = "transactions.csv"; | |
a.click(); | |
} | |
function* x(xpath, root = document) { | |
const xpathResult = document.evaluate( | |
xpath, | |
root, | |
null, | |
XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, | |
null | |
); | |
for (let i = 0; i < xpathResult.snapshotLength; i++) { | |
yield xpathResult.snapshotItem(i); | |
} | |
} | |
function nextTick() { | |
return new Promise((resolve) => setTimeout(resolve, 0)); | |
} | |
function waitUntilElementExists(xpath, callback) { | |
const observer = new MutationObserver(() => { | |
const element = x(xpath).next().value; | |
if (element) { | |
observer.disconnect(); | |
callback(element); | |
} | |
}); | |
observer.observe(document.documentElement, { | |
childList: true, | |
subtree: true, | |
}); | |
} | |
function escapeCsvField(field) { | |
return `"${field}"`; | |
} | |
function formatDateForYNAB(str) { | |
// "August 19, 2024" to "2024-08-19" using RegExp | |
const [, month_s, day_s, year] = str.match(/(\w+) (\d+), (\d+)/); | |
const month = (new Date(Date.parse(`${month_s} 1, 2020`)).getMonth() + 1) | |
.toString() | |
.padStart(2, "0"); | |
const day = day_s.padStart(2, "0"); | |
return `${year}-${month}-${day}`; | |
} | |
})(); |
I've created a bookmarklet version of this so you can run it on any browsers without installing an extension.
Update the script for the wording changes ('CAD' to '$')
Changed the @match
URL to match all Welthsimple app pages to support page transitions.
It would be great if - $
was replaced by -$
I made some improvements:
- clean up the code
- use
-$
instead of- $
(using Pocketsmith, this is a problem) - added a spinner for better UX
- updated the button
- fixed the date selector (now includes "Scheduled date" also)
- automatically clicks "Load more" when the list is large
- batches large list
- exports CSV named as per the single account
// ==UserScript==
// @name Export Wealthsimple transactions to CSV for YNAB
// @namespace https://shotasenga.com/
// @version 2025090100
// @description Export transactions from Wealthsimple to a CSV file for YNAB import
// @author Shota Senga
// @match https://my.wealthsimple.com/app/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=wealthsimple.com
// @grant none
// ==/UserScript==
/*
* DISCLAIMER:
* This script extracts sensitive financial information (transaction data) from Wealthsimple.
* Ensure that you use this script in a secure environment and handle the extracted data responsibly.
* The developer of this script is not responsible for any issues or troubles that arise from its use.
*/
(function () {
"use strict";
// Constants
const SELECTORS = {
activityHeader: "//h1[contains(., 'Activity')]",
transactionButtons: "//button[contains(., 'Chequing')][contains(., '$')]",
amountElement: ".//p[contains(., '$')]",
dateElement: ".//p[contains(., 'Date') or contains(., 'Scheduled date')]/following-sibling::*//p",
loadMoreButton: "//button[contains(text(), 'Load more')]"
};
const CSV_HEADERS = ["Date", "Payee", "Amount"];
const DATE_REGEX = /(\w+) (\d+), (\d+)/;
// Initialize the script
init();
function init() {
waitUntilElementExists(SELECTORS.activityHeader, addExportButton);
}
function addExportButton(activityHeader) {
const exportButton = createExportButton();
activityHeader.parentElement.appendChild(exportButton);
}
function createExportButton() {
const button = document.createElement("button");
button.innerText = "Export transactions";
button.onclick = handleExportClick;
button.style.cssText = `
margin-left: 10px;
padding: 8px 16px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
`;
return button;
}
async function handleExportClick(event) {
const exportButton = event.target;
let spinner = null;
try {
console.log('Starting transaction export...');
// Show spinner and disable button
spinner = showSpinner(exportButton);
exportButton.disabled = true;
exportButton.style.opacity = '0.6';
// First, load all available transactions
updateSpinnerText(spinner, 'Loading all transactions...');
await loadAllTransactions();
// Then extract all transaction data
updateSpinnerText(spinner, 'Extracting transaction data...');
const transactions = await extractAllTransactions();
updateSpinnerText(spinner, 'Generating CSV file...');
const csvContent = generateCsvContent(transactions);
downloadCsvFile(csvContent);
updateSpinnerText(spinner, `β
Export complete! ${transactions.length} transactions exported`);
console.log(`Exported ${transactions.length} transactions successfully`);
// Keep success message for 2 seconds
setTimeout(() => {
hideSpinner(spinner);
resetExportButton(exportButton);
}, 2000);
} catch (error) {
console.error('Export failed:', error);
if (spinner) {
updateSpinnerText(spinner, 'β Export failed - check console');
setTimeout(() => {
hideSpinner(spinner);
resetExportButton(exportButton);
}, 3000);
}
alert('Failed to export transactions. Please check the console for details.');
}
}
async function loadAllTransactions() {
let loadMoreAttempts = 0;
const maxAttempts = 100; // Increased safety limit for large transaction histories
console.log('Loading all transactions...');
while (loadMoreAttempts < maxAttempts) {
const loadMoreButton = getElementsByXPath(SELECTORS.loadMoreButton).next().value;
if (!loadMoreButton) {
console.log(`β
All transactions loaded - "Load more" button disappeared after ${loadMoreAttempts} loads`);
break;
}
console.log(`π Loading more transactions... (batch ${loadMoreAttempts + 1})`);
// Update spinner with current progress
const spinner = document.getElementById('wealthsimple-export-spinner');
if (spinner) {
updateSpinnerText(spinner, `Loading transactions... (batch ${loadMoreAttempts + 1})`);
}
loadMoreButton.click();
// Wait for new content to load and DOM to update
await new Promise(resolve => setTimeout(resolve, 1500));
loadMoreAttempts++;
// Log progress every 10 loads for large datasets
if (loadMoreAttempts % 10 === 0) {
console.log(`π Progress: Loaded ${loadMoreAttempts} batches so far...`);
}
}
if (loadMoreAttempts >= maxAttempts) {
console.warn(`β οΈ Reached maximum load attempts (${maxAttempts}). This may indicate an issue or extremely large transaction history.`);
}
console.log(`π Transaction loading complete! Total batches loaded: ${loadMoreAttempts}`);
}
function showSpinner(exportButton) {
const spinner = document.createElement('div');
spinner.id = 'wealthsimple-export-spinner';
spinner.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(255, 255, 255, 0.95);
border: 2px solid #007bff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 10000;
display: flex;
align-items: center;
gap: 12px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
color: #333;
min-width: 250px;
`;
const spinnerIcon = document.createElement('div');
spinnerIcon.style.cssText = `
width: 20px;
height: 20px;
border: 2px solid #f3f3f3;
border-top: 2px solid #007bff;
border-radius: 50%;
animation: spin 1s linear infinite;
`;
const spinnerText = document.createElement('span');
spinnerText.id = 'spinner-text';
spinnerText.textContent = 'Initializing export...';
spinner.appendChild(spinnerIcon);
spinner.appendChild(spinnerText);
// Add CSS animation
if (!document.getElementById('spinner-styles')) {
const style = document.createElement('style');
style.id = 'spinner-styles';
style.textContent = `
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`;
document.head.appendChild(style);
}
document.body.appendChild(spinner);
return spinner;
}
function updateSpinnerText(spinner, text) {
if (spinner) {
const textElement = spinner.querySelector('#spinner-text');
if (textElement) {
textElement.textContent = text;
}
}
}
function hideSpinner(spinner) {
if (spinner && spinner.parentNode) {
spinner.parentNode.removeChild(spinner);
}
}
function resetExportButton(exportButton) {
exportButton.disabled = false;
exportButton.style.opacity = '1';
}
async function extractAllTransactions() {
const transactions = [];
const transactionButtons = Array.from(getElementsByXPath(SELECTORS.transactionButtons));
let accountName = null;
for (const button of transactionButtons) {
try {
const transaction = await extractTransactionData(button);
if (transaction) {
transactions.push(transaction);
// Capture account name from first transaction if not already set
if (!accountName && transaction.accountName) {
accountName = transaction.accountName;
}
}
} catch (error) {
console.error('Failed to extract transaction from button:', button, error);
}
}
// Store account name globally for filename generation
window.wealthsimpleAccountName = accountName;
return transactions;
}
async function extractTransactionData(button) {
const payee = getPayeeName(button);
const amount = getTransactionAmount(button);
const accountName = getAccountName(button);
// Click to reveal transaction details
button.click();
await nextTick();
const date = getTransactionDate(button);
return {
payee,
amount: normalizeAmount(amount),
date: formatDateForYNAB(date),
accountName
};
}
function getPayeeName(button) {
const payeeElement = button.querySelector("p");
return payeeElement?.innerText || "UNKNOWN_PAYEE";
}
function getTransactionAmount(button) {
const amountElement = getElementsByXPath(SELECTORS.amountElement, button).next().value;
return amountElement?.innerText || "0.00";
}
function getAccountName(button) {
// Look for account name in the transaction button structure
const accountElements = button.querySelectorAll('p');
for (const element of accountElements) {
const text = element.innerText;
// Look for text containing bullet point (β’) which indicates account info
if (text.includes('β’')) {
return text;
}
}
return null;
}
function getTransactionDate(button) {
const dateElements = Array.from(
getElementsByXPath(SELECTORS.dateElement, button.parentElement.parentElement)
);
return dateElements[0]?.innerText || null;
}
function normalizeAmount(amount) {
if (!amount) return "0.00";
return amount
.replace(/β/g, '-') // Unicode minus to ASCII hyphen
.replace(/\u2212/g, '-') // Mathematical minus to ASCII hyphen
.replace(/\s*[β\u2212-]\s*\$/g, '-$') // Clean spacing around minus and $
.trim();
}
function formatDateForYNAB(dateString) {
if (!dateString) {
console.warn('formatDateForYNAB received null/undefined date');
return getCurrentDateFormatted();
}
const match = dateString.match(DATE_REGEX);
if (!match) {
console.error('formatDateForYNAB could not parse date string:', dateString);
return getCurrentDateFormatted();
}
const [, monthName, day, year] = match;
const month = getMonthNumber(monthName);
const paddedDay = day.padStart(2, "0");
const paddedMonth = month.toString().padStart(2, "0");
return `${year}-${paddedMonth}-${paddedDay}`;
}
function getMonthNumber(monthName) {
const monthDate = new Date(Date.parse(`${monthName} 1, 2020`));
return monthDate.getMonth() + 1;
}
function getCurrentDateFormatted() {
const today = new Date();
const year = today.getFullYear();
const month = (today.getMonth() + 1).toString().padStart(2, "0");
const day = today.getDate().toString().padStart(2, "0");
return `${year}-${month}-${day}`;
}
function generateCsvContent(transactions) {
const csvRows = [CSV_HEADERS.join(",")];
transactions.forEach(transaction => {
const row = [
escapeCsvField(transaction.date),
escapeCsvField(transaction.payee),
escapeCsvField(transaction.amount)
].join(",");
csvRows.push(row);
});
return csvRows.join("\n");
}
function downloadCsvFile(csvContent) {
const blob = new Blob([csvContent], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const downloadLink = document.createElement("a");
const filename = generateFilename();
downloadLink.href = url;
downloadLink.download = filename;
downloadLink.click();
// Clean up the object URL
setTimeout(() => URL.revokeObjectURL(url), 100);
}
function generateFilename() {
const baseDate = getCurrentDateFormatted();
const accountName = window.wealthsimpleAccountName;
if (accountName) {
const sanitizedAccount = sanitizeFilename(accountName);
return `wealthsimple-${sanitizedAccount}-${baseDate}.csv`;
}
return `wealthsimple-transactions-${baseDate}.csv`;
}
function sanitizeFilename(filename) {
return filename
.replace(/β’/g, '-') // Replace bullet points with hyphens
.replace(/[<>:"/\\|?*]/g, '') // Remove invalid filename characters
.replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/-+/g, '-') // Replace multiple consecutive hyphens with single hyphen
.replace(/^-|-$/g, '') // Remove leading/trailing hyphens
.toLowerCase(); // Convert to lowercase for consistency
}
function escapeCsvField(field) {
if (!field) return '""';
const escaped = field.toString().replace(/"/g, '""');
return `"${escaped}"`;
}
// Utility Functions
function* getElementsByXPath(xpath, root = document) {
const xpathResult = document.evaluate(
xpath,
root,
null,
XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
null
);
for (let i = 0; i < xpathResult.snapshotLength; i++) {
yield xpathResult.snapshotItem(i);
}
}
function nextTick() {
return new Promise(resolve => setTimeout(resolve, 100));
}
function waitUntilElementExists(xpath, callback) {
// Check if element already exists
const existingElement = getElementsByXPath(xpath).next().value;
if (existingElement) {
callback(existingElement);
return;
}
// Wait for element to appear
const observer = new MutationObserver(() => {
const element = getElementsByXPath(xpath).next().value;
if (element) {
observer.disconnect();
callback(element);
}
});
observer.observe(document.documentElement, {
childList: true,
subtree: true,
});
}
// Legacy function alias for backward compatibility
const x = getElementsByXPath;
})();
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Instruction
Once you install the script, open the "Activity" page in your browser. The "Export Transaction" button should appear on top-right.
DISCLAIMER