Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<!-- Badge section -->
[![CI](https://github.com/meseer/monarch-uploader/actions/workflows/ci.yml/badge.svg)](https://github.com/meseer/monarch-uploader/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/meseer/monarch-uploader/branch/main/graph/badge.svg)](https://codecov.io/gh/meseer/monarch-uploader)
[![Version](https://img.shields.io/badge/version-6.10.1-blue)](https://github.com/meseer/monarch-uploader)
[![Version](https://img.shields.io/badge/version-6.11.0-blue)](https://github.com/meseer/monarch-uploader)
[![License: CC BY-NC-SA 4.0](https://img.shields.io/badge/License-CC%20BY--NC--SA%204.0-lightgrey.svg)](https://creativecommons.org/licenses/by-nc-sa/4.0/)

A userscript that automatically syncs balance history, transactions, holdings, and more from Canadian financial institutions to [Monarch Money](https://www.monarchmoney.com/).
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "monarch-uploader",
"version": "6.10.1",
"version": "6.11.0",
"description": "Violentmonkey userscript for uploading Questrade, Wealthsimple, Canada Life, and Rogers Bank data to Monarch Money",
"main": "dist/monarch-uploader.user.js",
"scripts": {
Expand Down
Binary file modified src/api/questrade.ts
Binary file not shown.
2 changes: 1 addition & 1 deletion src/scriptInfo.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"version": "6.10.1",
"version": "6.11.0",
"gistUrl": "https://gist.github.com/meseer/f00fb552c96efeb3eb4e4e1fd520d4e7/raw/monarch-uploader.user.js"
}
9 changes: 7 additions & 2 deletions src/services/questrade/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,8 @@ async function syncAccountToMonarch(accountId, accountName, fromDate, toDate, pr
// Get Monarch account mapping for transaction uploads
const monarchAccountForTx = accountService.getMonarchAccountMapping(INTEGRATIONS.QUESTRADE, accountId);

// Step 3: Sync orders (trades) - gracefully handle failures
// Step 3: Sync orders (trades) - also produces signatures for cross-source dedup
let orderSignatures = null;
if (progressDialog) {
progressDialog.updateStepStatus(accountId, 'orders', 'processing', 'Syncing orders...');
}
Expand All @@ -167,6 +168,9 @@ async function syncAccountToMonarch(accountId, accountName, fromDate, toDate, pr
null, // Don't pass progressDialog to avoid double-updates
);

// Extract order signatures for activity trade deduplication
orderSignatures = ordersResult?.orderSignatures || null;

if (ordersResult.success) {
const ordersCount = ordersResult.ordersProcessed || 0;
let ordersMessage;
Expand Down Expand Up @@ -197,7 +201,7 @@ async function syncAccountToMonarch(accountId, accountName, fromDate, toDate, pr
}
}

// Step 4: Sync activity (contributions, dividends, fees, etc.) - gracefully handle failures
// Step 4: Sync activity (all types, with cross-source trade dedup against orders)
if (progressDialog) {
progressDialog.updateStepStatus(accountId, 'activity', 'processing', 'Syncing activity...');
}
Expand All @@ -215,6 +219,7 @@ async function syncAccountToMonarch(accountId, accountName, fromDate, toDate, pr
fromDate,
monarchAccountForTx.id,
null, // Don't pass progressDialog to avoid double-updates
orderSignatures, // Pass order signatures for cross-source trade dedup
);

if (activityResult.success) {
Expand Down
115 changes: 108 additions & 7 deletions src/services/questrade/transactionRules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,69 @@ function formatQuantityNotes(normalized) {
return lines.join('\n');
}

/**
* Format notes for trade transactions (buy/sell from Activity API)
* Produces notes similar to Orders API format:
* Line 1: Description (e.g., "VANGUARD GROWTH ETF PORTFOLIO ETF UNIT WE ACTED AS AGENT")
* Line 2: Filled quantity @ price per share, commission
* Line 3: Total amount with currency
* Line 4: Settlement date (only if different from transaction date)
*
* Data sources:
* - quantity: from details or activity list (with &fields=Quantity)
* - price: from details or activity list (with &fields=Price) — { currencyCode, amount }
* - commission: from details only (details.commission)
* - net: from details or activity list — { currencyCode, amount }
*
* @param {Object} normalized - Normalized transaction data
* @returns {string} Formatted trade notes
*/
export function formatTradeNotes(normalized) {
const lines = [];

// Line 1: Description
if (normalized.description) {
lines.push(normalized.description);
}

// Line 2: Filled quantity @ price, commission
const qty = normalized.quantity !== undefined && normalized.quantity !== null
? formatNumber(normalized.quantity) : '';
const priceAmount = normalized.price?.amount !== undefined && normalized.price?.amount !== null
? formatNumber(normalized.price.amount) : '';
const priceCurrency = cleanString(normalized.price?.currencyCode) || cleanString(normalized.net?.currencyCode) || 'CAD';

// Commission comes from details endpoint only (details.commission)
// eslint-disable-next-line no-underscore-dangle
const commission = normalized._details?.commission;
const commissionStr = commission !== undefined && commission !== null
? formatNumber(commission) : '';

if (qty && priceAmount) {
let filledLine = `Filled ${qty} @ ${priceAmount}`;
if (commissionStr) {
filledLine += `, fees: ${commissionStr} ${priceCurrency}`;
}
lines.push(filledLine);
}

// Line 3: Total amount with currency
const netAmount = normalized.net?.amount;
if (netAmount !== undefined && netAmount !== null) {
const totalStr = formatNumber(Math.abs(parseFloat(netAmount) || 0));
if (totalStr) {
lines.push(`Total: ${totalStr} ${priceCurrency}`);
}
}

// Line 4: Settlement Date - only if different from transaction date
if (normalized.settlementDate && normalized.settlementDate !== normalized.transactionDate) {
lines.push(`Settlement Date: ${normalized.settlementDate}`);
}

return lines.join('\n');
}

/**
* Format FX conversion notes with exchange rate details
* Uses correct API structure:
Expand Down Expand Up @@ -696,6 +759,46 @@ export const QUESTRADE_TRANSACTION_RULES = [
}),
},

// ============================================
// TRADES (from Activity API)
// Trades are also available from the Orders API (v1), but that only returns
// recent orders. Activity API trades are kept and deduplicated against orders
// to ensure historic trades are not lost.
// ============================================
{
id: 'trades-buy',
description: 'Trades - Buy order',
match: (n) => n.transactionType === 'Trades' && n.action === 'Buy',
process: (n) => ({
category: 'Buy',
merchant: n.symbol || 'Unknown Security',
originalStatement: formatOriginalStatement(n.transactionType, n.action, n.symbol),
notes: formatTradeNotes(n),
}),
},
{
id: 'trades-sell',
description: 'Trades - Sell order',
match: (n) => n.transactionType === 'Trades' && n.action === 'Sell',
process: (n) => ({
category: 'Sell',
merchant: n.symbol || 'Unknown Security',
originalStatement: formatOriginalStatement(n.transactionType, n.action, n.symbol),
notes: formatTradeNotes(n),
}),
},
{
id: 'trades-fallback',
description: 'Trades - Other trade actions (catch-all)',
match: (n) => n.transactionType === 'Trades',
process: (n) => ({
category: 'Investment',
merchant: n.symbol || 'Unknown Security',
originalStatement: formatOriginalStatement(n.transactionType, n.action, n.symbol),
notes: formatTradeNotes(n),
}),
},

// ============================================
// FALLBACK - Unknown Type/Action
// ============================================
Expand Down Expand Up @@ -754,17 +857,15 @@ export function applyTransactionRule(transaction, details = null) {

/**
* Check if a transaction should be filtered out
* Trades are handled by the orders API, not the activity API
* Previously filtered trades (handled by orders API), but now trades are kept
* and deduplicated against orders instead.
* @param {Object} transaction - Transaction object
* @returns {boolean} True if transaction should be excluded
*/
export function shouldFilterTransaction(transaction) {
// Filter out Trades - they're handled by the orders API
if (transaction.transactionType === 'Trades') {
return true;
}

return false;
// Trades are no longer filtered — they are deduplicated against orders
// in the transaction processing pipeline instead
return !transaction && false; // always returns false; uses parameter to avoid unused warning
}

/**
Expand Down
Loading