Skip to content

Instantly share code, notes, and snippets.

@shotasenga
Last active September 23, 2025 05:17
Show Gist options
  • Save shotasenga/c461a672d9c9f927ce213a0c3e9e1895 to your computer and use it in GitHub Desktop.
Save shotasenga/c461a672d9c9f927ce213a0c3e9e1895 to your computer and use it in GitHub Desktop.
Export transactions from Wealthsimple to a CSV file for YNAB import
// ==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}`;
}
})();
@shotasenga
Copy link
Author

Instruction

  1. Install Tampermonkey https://www.tampermonkey.net/
  2. Open the raw script link to install

Once you install the script, open the "Activity" page in your browser. The "Export Transaction" button should appear on top-right.

Screenshot 2024-08-31 at 3 51 22β€―PM

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.

@shotasenga
Copy link
Author

I've created a bookmarklet version of this so you can run it on any browsers without installing an extension.

@shotasenga
Copy link
Author

Update the script for the wording changes ('CAD' to '$')

@shotasenga
Copy link
Author

Changed the @match URL to match all Welthsimple app pages to support page transitions.

@kaipee
Copy link

kaipee commented Sep 1, 2025

It would be great if - $ was replaced by -$

@kaipee
Copy link

kaipee commented Sep 1, 2025

I made some improvements:

  1. clean up the code
  2. use -$ instead of - $ (using Pocketsmith, this is a problem)
  3. added a spinner for better UX
  4. updated the button
  5. fixed the date selector (now includes "Scheduled date" also)
  6. automatically clicks "Load more" when the list is large
  7. batches large list
  8. 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