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:
parent
65175e184a
commit
144d919c0b
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue