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.12.0-blue)](https://github.com/meseer/monarch-uploader)
[![Version](https://img.shields.io/badge/version-6.12.1-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.12.0",
"version": "6.12.1",
"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
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.12.0",
"version": "6.12.1",
"gistUrl": "https://gist.github.com/meseer/f00fb552c96efeb3eb4e4e1fd520d4e7/raw/monarch-uploader.user.js"
}
9 changes: 9 additions & 0 deletions src/services/wealthsimple/transactionRulesInvestment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,15 @@ export function formatDividendNotes(tx: WealthsimpleTransaction): string {

if (tx.amount === null || tx.amount === undefined) {
noteLines.push(`Upcoming dividend on ${symbol}`);
// Calculate expected dividend amount from holdings and rate
if (tx.assetQuantity !== null && tx.assetQuantity !== undefined && tx.grossDividendRate !== null && tx.grossDividendRate !== undefined) {
const qty = parseFloat(String(tx.assetQuantity));
const rate = parseFloat(String(tx.grossDividendRate));
if (!isNaN(qty) && !isNaN(rate) && qty > 0 && rate > 0) {
const expectedAmount = Math.round(qty * rate * 100) / 100;
noteLines.push(`Expected dividends: ${currency}$${formatAmount(expectedAmount)}`);
}
}
} else if (tx.subType === 'MANUFACTURED_DIVIDEND') {
noteLines.push(`Dividend on lended ${symbol} shares: ${currency}$${amount}`);
} else {
Expand Down
8 changes: 6 additions & 2 deletions src/services/wealthsimple/transactionsReconciliation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,14 @@ function cleanSystemNotesFromNotes(notes: string | null | undefined): string {
/**
* Update dividend notes when a pending dividend settles.
* Replaces "Upcoming dividend on {symbol}" with "Dividend on {symbol}"
* so the notes reflect the settled state.
* and removes the "Expected dividends: ..." line (no longer needed once settled).
*/
function updateSettledDividendNotes(notes: string): string {
return notes.replace(/^Upcoming dividend on /m, 'Dividend on ');
let updated = notes.replace(/^Upcoming dividend on /m, 'Dividend on ');
updated = updated.replace(/^Expected dividends: .+\n?/m, '');
// Clean up any resulting double newlines
updated = updated.replace(/\n{2,}/g, '\n');
return updated.trim();
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ describe('Wealthsimple Transaction Rules Engine - Investment Dividends & Deposit
const result = rule.process(transaction);

expect(result).not.toBeNull();
// amount:null means pending (not yet paid out) "Upcoming dividend on {symbol}"
// amount:null means pending (not yet paid out) "Upcoming dividend on {symbol}"
expect(result.notes).toBe('Upcoming dividend on VFV');
});

Expand All @@ -264,7 +264,7 @@ describe('Wealthsimple Transaction Rules Engine - Investment Dividends & Deposit
const result = rule.process(transaction);

expect(result).not.toBeNull();
// amount:undefined means pending (not yet paid out) "Upcoming dividend on {symbol}"
// amount:undefined means pending (not yet paid out) "Upcoming dividend on {symbol}"
expect(result.notes).toBe('Upcoming dividend on AAPL');
});

Expand Down Expand Up @@ -299,7 +299,7 @@ describe('Wealthsimple Transaction Rules Engine - Investment Dividends & Deposit
expect(result.category).toBe('Dividends & Capital Gains');
expect(result.merchant).toBe('Unknown');
expect(result.originalStatement).toBe('DIVIDEND::Unknown');
// No amount field (undefined) treated as pending "Upcoming dividend on Unknown"
// No amount field (undefined) treated as pending "Upcoming dividend on Unknown"
expect(result.notes).toBe('Upcoming dividend on Unknown');
expect(result.technicalDetails).toBe('');
});
Expand All @@ -318,7 +318,7 @@ describe('Wealthsimple Transaction Rules Engine - Investment Dividends & Deposit
expect(result.category).toBe('Dividends & Capital Gains');
expect(result.merchant).toBe('Unknown');
expect(result.originalStatement).toBe('DIVIDEND:MANUFACTURED_DIVIDEND:Unknown');
// No amount field (undefined) treated as pending "Upcoming dividend on Unknown"
// No amount field (undefined) treated as pending "Upcoming dividend on Unknown"
expect(result.notes).toBe('Upcoming dividend on Unknown');
});

Expand Down Expand Up @@ -394,13 +394,126 @@ describe('Wealthsimple Transaction Rules Engine - Investment Dividends & Deposit
const rule = INVESTMENT_DIVIDEND_TRANSACTION_RULES.find((r) => r.id === 'dividend');
const result = rule.process(pendingDividendTx);

expect(result.notes).toContain('Expected dividends: CAD$2.06');
expect(result.notes).toContain('Holdings on record date: 34.3537 shares');
expect(result.notes).toContain('Gross dividend rate: CAD$0.06 per share');
expect(result.notes).toContain('Announcement date: Feb 19, 2026');
expect(result.notes).toContain('Record date: Feb 26, 2026');
expect(result.notes).toContain('Payable date: Mar 3, 2026');
});

it('should include expected dividends line calculated from holdings × rate', () => {
const rule = INVESTMENT_DIVIDEND_TRANSACTION_RULES.find((r) => r.id === 'dividend');
const tx = {
type: 'DIVIDEND',
subType: null,
assetSymbol: 'VFV',
amount: null,
currency: 'CAD',
assetQuantity: 100,
grossDividendRate: 0.25,
};
const result = rule.process(tx);

expect(result.notes).toContain('Expected dividends: CAD$25');
});

it('should include expected dividends with USD currency', () => {
const rule = INVESTMENT_DIVIDEND_TRANSACTION_RULES.find((r) => r.id === 'dividend');
const tx = {
type: 'DIVIDEND',
subType: 'DIY_DIVIDEND',
assetSymbol: 'AAPL',
amount: null,
currency: 'USD',
assetQuantity: 50,
grossDividendRate: 0.96,
};
const result = rule.process(tx);

expect(result.notes).toContain('Expected dividends: USD$48');
});

it('should NOT include expected dividends when assetQuantity is null', () => {
const rule = INVESTMENT_DIVIDEND_TRANSACTION_RULES.find((r) => r.id === 'dividend');
const tx = {
type: 'DIVIDEND',
subType: null,
assetSymbol: 'VFV',
amount: null,
currency: 'CAD',
assetQuantity: null,
grossDividendRate: 0.25,
};
const result = rule.process(tx);

expect(result.notes).not.toContain('Expected dividends');
});

it('should NOT include expected dividends when grossDividendRate is null', () => {
const rule = INVESTMENT_DIVIDEND_TRANSACTION_RULES.find((r) => r.id === 'dividend');
const tx = {
type: 'DIVIDEND',
subType: null,
assetSymbol: 'VFV',
amount: null,
currency: 'CAD',
assetQuantity: 100,
grossDividendRate: null,
};
const result = rule.process(tx);

expect(result.notes).not.toContain('Expected dividends');
});

it('should NOT include expected dividends when both fields are missing', () => {
const rule = INVESTMENT_DIVIDEND_TRANSACTION_RULES.find((r) => r.id === 'dividend');
const tx = {
type: 'DIVIDEND',
subType: null,
assetSymbol: 'VFV',
amount: null,
currency: 'CAD',
};
const result = rule.process(tx);

expect(result.notes).not.toContain('Expected dividends');
});

it('should NOT include expected dividends for settled dividends (amount present)', () => {
const rule = INVESTMENT_DIVIDEND_TRANSACTION_RULES.find((r) => r.id === 'dividend');
const tx = {
type: 'DIVIDEND',
subType: null,
assetSymbol: 'VFV',
amount: 25,
currency: 'CAD',
assetQuantity: 100,
grossDividendRate: 0.25,
};
const result = rule.process(tx);

expect(result.notes).not.toContain('Expected dividends');
expect(result.notes).toContain('Dividend on VFV: CAD$25');
});

it('should handle string values for assetQuantity and grossDividendRate', () => {
const rule = INVESTMENT_DIVIDEND_TRANSACTION_RULES.find((r) => r.id === 'dividend');
const tx = {
type: 'DIVIDEND',
subType: null,
assetSymbol: 'ZHY',
amount: null,
currency: 'CAD',
assetQuantity: '34.3537',
grossDividendRate: '0.060000',
};
const result = rule.process(tx);

// 34.3537 * 0.06 = 2.061222
expect(result.notes).toContain('Expected dividends: CAD$2.06');
});

it('should NOT include withholding tax line when withholdingTaxAmount is 0', () => {
const rule = INVESTMENT_DIVIDEND_TRANSACTION_RULES.find((r) => r.id === 'dividend');
const result = rule.process(pendingDividendTx);
Expand Down
43 changes: 43 additions & 0 deletions test/services/wealthsimple/transactionsReconciliation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,49 @@ describe('reconcileWealthsimpleFetchedPending', () => {
expect(updatedNotes).toContain('CAD$12.5');
});

it('removes "Expected dividends" line and replaces "Upcoming" with "Dividend" on settle', async () => {
const pendingNotes = 'Upcoming dividend on ZHY\nExpected dividends: CAD$2.06\nHoldings on record date: 34.3537 shares\nGross dividend rate: CAD$0.06 per share\nws-tx:div-456';
const monarchTx = makeMonarchTx(
'mtx-div',
pendingNotes,
{ amount: 0 },
);

mockMonarchApi.updateTransaction.mockResolvedValue({});
mockMonarchApi.setTransactionTags.mockResolvedValue({});

const wsTx = makeWsTx('div-456', {
type: 'DIVIDEND',
subType: 'CASH_DIVIDEND',
status: null,
unifiedStatus: 'COMPLETED',
amount: 2.06,
currency: 'CAD',
assetSymbol: 'ZHY',
assetQuantity: 34.3537,
grossDividendRate: 0.06,
amountSign: 'positive',
});

const result = await reconcileWealthsimpleFetchedPending(
pendingTag,
[monarchTx],
[wsTx],
'MANAGED_RESP',
);

expect(result.settled).toBe(1);

const notesUpdateCall = mockMonarchApi.updateTransaction.mock.calls[0];
const updatedNotes = notesUpdateCall[1].notes;
// "Upcoming" should be replaced with "Dividend on"
expect(updatedNotes).not.toContain('Upcoming');
// "Expected dividends" line should be removed
expect(updatedNotes).not.toContain('Expected dividends');
// ws-tx ID should be removed
expect(updatedNotes).not.toContain('ws-tx:');
});

it('handles notes regeneration failure gracefully', async () => {
const monarchTx = makeMonarchTx(
'mtx-3',
Expand Down