diff --git a/README.md b/README.md index 84dcb09..46c8f96 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![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/). diff --git a/package.json b/package.json index 950424f..cd5ec89 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/scriptInfo.json b/src/scriptInfo.json index 6eebbe1..39963b8 100644 --- a/src/scriptInfo.json +++ b/src/scriptInfo.json @@ -1,4 +1,4 @@ { - "version": "6.12.0", + "version": "6.12.1", "gistUrl": "https://gist.github.com/meseer/f00fb552c96efeb3eb4e4e1fd520d4e7/raw/monarch-uploader.user.js" } diff --git a/src/services/wealthsimple/transactionRulesInvestment.ts b/src/services/wealthsimple/transactionRulesInvestment.ts index 07838e6..1decb84 100644 --- a/src/services/wealthsimple/transactionRulesInvestment.ts +++ b/src/services/wealthsimple/transactionRulesInvestment.ts @@ -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 { diff --git a/src/services/wealthsimple/transactionsReconciliation.ts b/src/services/wealthsimple/transactionsReconciliation.ts index 30795d0..08e240e 100644 --- a/src/services/wealthsimple/transactionsReconciliation.ts +++ b/src/services/wealthsimple/transactionsReconciliation.ts @@ -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(); } /** diff --git a/test/services/wealthsimple/transactionRules.investment-dividends-deposits.test.js b/test/services/wealthsimple/transactionRules.investment-dividends-deposits.test.js index 978d78b..32981ee 100644 --- a/test/services/wealthsimple/transactionRules.investment-dividends-deposits.test.js +++ b/test/services/wealthsimple/transactionRules.investment-dividends-deposits.test.js @@ -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'); }); @@ -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'); }); @@ -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(''); }); @@ -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'); }); @@ -394,6 +394,7 @@ 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'); @@ -401,6 +402,118 @@ describe('Wealthsimple Transaction Rules Engine - Investment Dividends & Deposit 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); diff --git a/test/services/wealthsimple/transactionsReconciliation.test.js b/test/services/wealthsimple/transactionsReconciliation.test.js index 425aa54..4e77c60 100644 --- a/test/services/wealthsimple/transactionsReconciliation.test.js +++ b/test/services/wealthsimple/transactionsReconciliation.test.js @@ -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',