diff --git a/resources/js/components/transactions/import-transactions-drawer.tsx b/resources/js/components/transactions/import-transactions-drawer.tsx index 24e88384..0014568f 100644 --- a/resources/js/components/transactions/import-transactions-drawer.tsx +++ b/resources/js/components/transactions/import-transactions-drawer.tsx @@ -15,6 +15,7 @@ import { importKey } from '@/lib/crypto'; import { autoDetectColumns, calculateBalancesFromTransactions, + collectBalancesToImport, convertRowsToTransactions, parseFile, } from '@/lib/file-parser'; @@ -628,18 +629,7 @@ export function ImportTransactionsDrawer({ setImportProgress(processedCount); } - const balancesToImport = new Map(); - for (const transaction of newTransactions) { - if ( - transaction.balance !== null && - transaction.balance !== undefined - ) { - balancesToImport.set( - transaction.transaction_date, - transaction.balance, - ); - } - } + const balancesToImport = collectBalancesToImport(newTransactions); if (balancesToImport.size > 0) { try { diff --git a/resources/js/lib/file-parser.test.ts b/resources/js/lib/file-parser.test.ts index 4fcfa45b..dd45e206 100644 --- a/resources/js/lib/file-parser.test.ts +++ b/resources/js/lib/file-parser.test.ts @@ -5,6 +5,7 @@ import { autoDetectColumns, autoDetectDateFormat, calculateBalancesFromTransactions, + collectBalancesToImport, convertRowsToTransactions, getLatestTransactionDate, getLocaleDateFormat, @@ -73,6 +74,85 @@ describe('convertRowsToTransactions', () => { }); }); +describe('convertRowsToTransactions balance column', () => { + const mapping: ColumnMapping = { + transaction_date: 'date', + description: 'description', + amount: 'amount', + balance: 'balance', + creditor_name: null, + debtor_name: null, + }; + + it('keeps a zero balance instead of dropping it', () => { + const transactions = convertRowsToTransactions( + [ + { + date: '2026-05-04', + description: 'Drained account', + amount: '-10.00', + balance: 0, + }, + ], + mapping, + DateFormat.YearMonthDay, + ); + + expect(transactions[0].balance).toBe(0); + }); + + it('keeps a zero balance provided as a string', () => { + const transactions = convertRowsToTransactions( + [ + { + date: '2026-05-04', + description: 'Drained account', + amount: '-10.00', + balance: '0', + }, + ], + mapping, + DateFormat.YearMonthDay, + ); + + expect(transactions[0].balance).toBe(0); + }); + + it('keeps a negative balance', () => { + const transactions = convertRowsToTransactions( + [ + { + date: '2026-05-04', + description: 'Overdrawn', + amount: '-10.00', + balance: '-25.50', + }, + ], + mapping, + DateFormat.YearMonthDay, + ); + + expect(transactions[0].balance).toBe(-2550); + }); + + it('leaves balance null when the cell is empty', () => { + const transactions = convertRowsToTransactions( + [ + { + date: '2026-05-04', + description: 'No balance', + amount: '-10.00', + balance: '', + }, + ], + mapping, + DateFormat.YearMonthDay, + ); + + expect(transactions[0].balance).toBeNull(); + }); +}); + describe('autoDetectColumns', () => { it('detects creditor and debtor name columns', () => { const mapping = autoDetectColumns([ @@ -290,3 +370,61 @@ describe('calculateBalancesFromTransactions', () => { expect(balances.get('2024-01-05')).toBe(5000); }); }); + +describe('collectBalancesToImport', () => { + function txn(date: string, balance?: number | null): ParsedTransaction { + return { + transaction_date: date, + description: 'x', + amount: 0, + balance, + }; + } + + it('uses the first (newest) balance when a date repeats', () => { + // Rows are newest-on-top; the first one holds the correct balance. + const transactions = [ + txn('2024-01-15', 10000), + txn('2024-01-15', 8000), + txn('2024-01-15', 5000), + ]; + + const balances = collectBalancesToImport(transactions); + + expect(balances.get('2024-01-15')).toBe(10000); + }); + + it('keeps the first balance per date across multiple days', () => { + const transactions = [ + txn('2024-01-16', 12000), + txn('2024-01-15', 10000), + txn('2024-01-15', 8000), + txn('2024-01-14', 4000), + ]; + + const balances = collectBalancesToImport(transactions); + + expect(balances.get('2024-01-16')).toBe(12000); + expect(balances.get('2024-01-15')).toBe(10000); + expect(balances.get('2024-01-14')).toBe(4000); + }); + + it('keeps the first valid balance even when it is zero', () => { + const transactions = [txn('2024-01-15', 0), txn('2024-01-15', 9000)]; + + const balances = collectBalancesToImport(transactions); + + expect(balances.get('2024-01-15')).toBe(0); + }); + + it('skips transactions without a balance', () => { + const transactions = [ + txn('2024-01-15', null), + txn('2024-01-14', undefined), + ]; + + const balances = collectBalancesToImport(transactions); + + expect(balances.size).toBe(0); + }); +}); diff --git a/resources/js/lib/file-parser.ts b/resources/js/lib/file-parser.ts index c294698c..6b500bff 100644 --- a/resources/js/lib/file-parser.ts +++ b/resources/js/lib/file-parser.ts @@ -502,6 +502,8 @@ export function parseAmount(amountStr: string | number): number | null { let str = String(amountStr).trim(); + const isNegative = /^-/.test(str) || /^\(.*\)$/.test(str); + const dotPos = str.lastIndexOf('.'); const commaPos = str.lastIndexOf(','); @@ -526,7 +528,7 @@ export function parseAmount(amountStr: string | number): number | null { return null; } - return amount; + return isNegative ? -Math.abs(amount) : amount; } function getDescriptionFromRow(row: ParsedRow, mapping: ColumnMapping): string { @@ -634,12 +636,19 @@ export function convertRowsToTransactions( const debtorName = getOptionalTextFromRow(row, mapping.debtor_name); let balance: number | null = null; - if (mapping.balance && row[mapping.balance]) { - const parsedBalance = parseAmount( - row[mapping.balance] as string | number, - ); - if (parsedBalance !== null) { - balance = Math.round(parsedBalance * 100); + if (mapping.balance) { + const rawBalance = row[mapping.balance]; + if ( + rawBalance !== null && + rawBalance !== undefined && + String(rawBalance).trim() !== '' + ) { + const parsedBalance = parseAmount( + rawBalance as string | number, + ); + if (parsedBalance !== null) { + balance = Math.round(parsedBalance * 100); + } } } @@ -737,3 +746,29 @@ export function calculateBalancesFromTransactions( return balances; } + +/** + * Build the map of balances to store per date from imported transactions. + * + * CSV rows are newest-on-top and imported top-to-bottom, so when several + * transactions share a date the first one encountered is the newest and holds + * the correct end-of-day balance. A balance of 0 (or negative) is valid and + * kept; only null/undefined balances are skipped. + */ +export function collectBalancesToImport( + transactions: ParsedTransaction[], +): Map { + const balances = new Map(); + + for (const transaction of transactions) { + if ( + transaction.balance !== null && + transaction.balance !== undefined && + !balances.has(transaction.transaction_date) + ) { + balances.set(transaction.transaction_date, transaction.balance); + } + } + + return balances; +}