fix(import): correct balance for same-day, zero and negative values (#456)

## Problem

CSV imports only carry a date (no timestamp). Rows are newest-on-top and
we import top-to-bottom. Three balance bugs:

1. **Same-day balance wrong.** The store loop did `Map.set(date,
balance)` per row, so when a day had 2+ transactions the **last**
(bottom = oldest) row overwrote the newest. The correct daily balance is
the **first** (newest) row.
2. **Zero balance dropped.** The parser guard `row[mapping.balance]`
treated numeric `0` as falsy, so a real `0` balance became `null`. `0`
is valid.
3. **Negative balance lost its sign.** `parseAmount` stripped `-` via
`replace(/[^\d]/g,'')`, so negative balances (and amounts) came out
positive.

## Fix

- `import-transactions-drawer.tsx`: keep the **first** occurrence per
date (`!balancesToImport.has(date)`).
- `file-parser.ts`: guard balance on null/undefined/empty-string only,
so `0` is kept.
- `file-parser.ts`: `parseAmount` now detects a leading `-` (or `(...)`)
and applies the sign — fixes negative balances and negative amounts.

## Tests

Added 4 cases to `file-parser.test.ts`: zero (number), zero (string),
negative, empty→null. Full suite: **144 passed**.

> Note: the same-day "keep first" logic lives in the React component and
relies on `newTransactions` staying in CSV order (top = newest), which
holds in current code.
This commit is contained in:
Víctor Falcón 2026-05-30 16:19:27 +02:00 committed by GitHub
parent 65175e184a
commit 144d919c0b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 182 additions and 19 deletions

View File

@ -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<string, number>();
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 {

View File

@ -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);
});
});

View File

@ -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<string, number> {
const balances = new Map<string, number>();
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;
}