Skip to content

Commit 3116d55

Browse files
committed
Enhance transaction handling by adding parent_id support and filtering logic
- Updated the SQL query in getTransactions to include parent_id for split transactions. - Modified the Transaction interface to accommodate parent_id, allowing for better handling of split transactions. - Implemented filtering logic in transformToWrappedData to exclude parent split transactions while including child splits and regular transactions. - Added comprehensive tests to validate the correct behavior of split transaction filtering, ensuring accurate expense calculations and category representation.
1 parent 41c1708 commit 3116d55

File tree

5 files changed

+219
-2
lines changed

5 files changed

+219
-2
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file.
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to date-based versioning with entries grouped by date.
66

7+
## 2026-01-05
8+
9+
### Added
10+
11+
- Split transaction support: Added `parent_id` field to Transaction interface to track split transaction relationships
12+
- Parent split transaction filtering: Automatically excludes parent split transactions (transactions with no category and child splits pointing to them) while including child split transactions in the wrapped data
13+
- Comprehensive unit tests for split transaction filtering covering parent splits, child splits, regular transactions, and mixed scenarios
14+
715
## 2026-01-04
816

917
### Added

src/services/fileApi.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,7 @@ async function getTransactions(
288288
params.push(endDateInt);
289289
}
290290

291-
let sql = `SELECT id, acct as account, date, amount, category, notes, description, cleared, reconciled FROM transactions WHERE acct = ? AND tombstone = 0${dateFilter}`;
291+
let sql = `SELECT id, acct as account, date, amount, category, notes, description, cleared, reconciled, parent_id FROM transactions WHERE acct = ? AND tombstone = 0${dateFilter}`;
292292
const transactions = query(sql, params);
293293

294294
// Get payee information - payees are linked via payee_mapping table
@@ -387,6 +387,7 @@ async function getTransactions(
387387
category_tombstone: categoryTombstone,
388388
cleared: t.cleared === 1 || false,
389389
reconciled: t.reconciled === 1 || false,
390+
parent_id: t.parent_id ? String(t.parent_id) : undefined,
390391
});
391392
}
392393

src/types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export interface Transaction {
1212
category_tombstone?: boolean; // Whether the category is deleted
1313
cleared?: boolean;
1414
reconciled?: boolean;
15+
parent_id?: string; // ID of parent transaction for split transactions
1516
}
1617

1718
export interface Category {

src/utils/dataTransform.test.ts

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1335,6 +1335,193 @@ describe('transformToWrappedData', () => {
13351335
});
13361336
});
13371337

1338+
describe('Split Transaction Filtering', () => {
1339+
it('excludes parent split transactions (no category, no parent_id)', () => {
1340+
const transactions: Transaction[] = [
1341+
createMockTransaction({
1342+
id: 'parent1',
1343+
date: '2025-01-15',
1344+
category: undefined,
1345+
parent_id: undefined,
1346+
amount: -10000, // Parent split - should be excluded
1347+
}),
1348+
createMockTransaction({
1349+
id: 'child1',
1350+
date: '2025-01-15',
1351+
category: 'cat1',
1352+
parent_id: 'parent1',
1353+
amount: -5000, // Child split - should be included
1354+
}),
1355+
createMockTransaction({
1356+
id: 'child2',
1357+
date: '2025-01-15',
1358+
category: 'cat2',
1359+
parent_id: 'parent1',
1360+
amount: -5000, // Child split - should be included
1361+
}),
1362+
];
1363+
1364+
const categories: Category[] = [
1365+
createMockCategory({ id: 'cat1', name: 'Groceries' }),
1366+
createMockCategory({ id: 'cat2', name: 'Gas' }),
1367+
];
1368+
1369+
const result = transformToWrappedData(transactions, categories, [], []);
1370+
1371+
// Parent split should be excluded, only child splits included
1372+
expect(result.transactionStats.totalCount).toBe(2);
1373+
expect(result.totalExpenses).toBe(100); // $50 + $50 from child splits
1374+
expect(result.topCategories).toHaveLength(2);
1375+
});
1376+
1377+
it('includes child split transactions (has category, has parent_id)', () => {
1378+
const transactions: Transaction[] = [
1379+
createMockTransaction({
1380+
id: 'child1',
1381+
date: '2025-01-15',
1382+
category: 'cat1',
1383+
parent_id: 'parent1',
1384+
amount: -10000,
1385+
}),
1386+
createMockTransaction({
1387+
id: 'child2',
1388+
date: '2025-01-15',
1389+
category: 'cat1',
1390+
parent_id: 'parent1',
1391+
amount: -20000,
1392+
}),
1393+
];
1394+
1395+
const categories: Category[] = [createMockCategory({ id: 'cat1', name: 'Groceries' })];
1396+
1397+
const result = transformToWrappedData(transactions, categories, [], []);
1398+
1399+
expect(result.transactionStats.totalCount).toBe(2);
1400+
expect(result.totalExpenses).toBe(300); // $100 + $200
1401+
expect(result.topCategories).toHaveLength(1);
1402+
expect(result.topCategories[0].amount).toBe(300);
1403+
});
1404+
1405+
it('includes regular transactions (has category, no parent_id)', () => {
1406+
const transactions: Transaction[] = [
1407+
createMockTransaction({
1408+
id: 'regular1',
1409+
date: '2025-01-15',
1410+
category: 'cat1',
1411+
parent_id: undefined,
1412+
amount: -10000,
1413+
}),
1414+
createMockTransaction({
1415+
id: 'regular2',
1416+
date: '2025-01-15',
1417+
category: 'cat2',
1418+
parent_id: undefined,
1419+
amount: -20000,
1420+
}),
1421+
];
1422+
1423+
const categories: Category[] = [
1424+
createMockCategory({ id: 'cat1', name: 'Groceries' }),
1425+
createMockCategory({ id: 'cat2', name: 'Gas' }),
1426+
];
1427+
1428+
const result = transformToWrappedData(transactions, categories, [], []);
1429+
1430+
expect(result.transactionStats.totalCount).toBe(2);
1431+
expect(result.totalExpenses).toBe(300); // $100 + $200
1432+
expect(result.topCategories).toHaveLength(2);
1433+
});
1434+
1435+
it('handles mixed split and regular transactions correctly', () => {
1436+
const transactions: Transaction[] = [
1437+
// Parent split - should be excluded
1438+
createMockTransaction({
1439+
id: 'parent1',
1440+
date: '2025-01-15',
1441+
category: undefined,
1442+
parent_id: undefined,
1443+
amount: -30000,
1444+
}),
1445+
// Child splits - should be included
1446+
createMockTransaction({
1447+
id: 'child1',
1448+
date: '2025-01-15',
1449+
category: 'cat1',
1450+
parent_id: 'parent1',
1451+
amount: -10000,
1452+
}),
1453+
createMockTransaction({
1454+
id: 'child2',
1455+
date: '2025-01-15',
1456+
category: 'cat2',
1457+
parent_id: 'parent1',
1458+
amount: -20000,
1459+
}),
1460+
// Regular transaction - should be included
1461+
createMockTransaction({
1462+
id: 'regular1',
1463+
date: '2025-01-15',
1464+
category: 'cat1',
1465+
parent_id: undefined,
1466+
amount: -15000,
1467+
}),
1468+
];
1469+
1470+
const categories: Category[] = [
1471+
createMockCategory({ id: 'cat1', name: 'Groceries' }),
1472+
createMockCategory({ id: 'cat2', name: 'Gas' }),
1473+
];
1474+
1475+
const result = transformToWrappedData(transactions, categories, [], []);
1476+
1477+
// Parent split excluded, 2 child splits + 1 regular = 3 transactions
1478+
expect(result.transactionStats.totalCount).toBe(3);
1479+
expect(result.totalExpenses).toBe(450); // $100 + $200 + $150
1480+
expect(result.topCategories).toHaveLength(2);
1481+
// cat1 should have $100 (child) + $150 (regular) = $250
1482+
const cat1 = result.topCategories.find(c => c.categoryId === 'cat1');
1483+
expect(cat1?.amount).toBe(250);
1484+
});
1485+
1486+
it('handles split transactions with income categories', () => {
1487+
const transactions: Transaction[] = [
1488+
// Parent split - should be excluded
1489+
createMockTransaction({
1490+
id: 'parent1',
1491+
date: '2025-01-15',
1492+
category: undefined,
1493+
parent_id: undefined,
1494+
amount: 50000,
1495+
}),
1496+
// Child split income - should be included
1497+
createMockTransaction({
1498+
id: 'child1',
1499+
date: '2025-01-15',
1500+
category: 'cat1',
1501+
parent_id: 'parent1',
1502+
amount: 30000,
1503+
}),
1504+
createMockTransaction({
1505+
id: 'child2',
1506+
date: '2025-01-15',
1507+
category: 'cat2',
1508+
parent_id: 'parent1',
1509+
amount: 20000,
1510+
}),
1511+
];
1512+
1513+
const categories: Category[] = [
1514+
createMockCategory({ id: 'cat1', name: 'Salary', is_income: true }),
1515+
createMockCategory({ id: 'cat2', name: 'Freelance', is_income: true }),
1516+
];
1517+
1518+
const result = transformToWrappedData(transactions, categories, [], []);
1519+
1520+
expect(result.transactionStats.totalCount).toBe(2);
1521+
expect(result.totalIncome).toBe(500); // $300 + $200 from child splits
1522+
});
1523+
});
1524+
13381525
describe('Combined Filtering', () => {
13391526
it('excludes transfers, off-budget, and starting balance together', () => {
13401527
const transactions: Transaction[] = [

src/utils/dataTransform.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,14 +339,34 @@ export function transformToWrappedData(
339339
// Collect transfer transactions separately for budget comparison
340340
const transferTransactions: Transaction[] = [];
341341

342+
// Build a set of parent transaction IDs that have child splits
343+
// A parent split is identified by having child transactions pointing to it
344+
const parentSplitIds = new Set<string>();
345+
transactions.forEach(t => {
346+
if (t.parent_id) {
347+
parentSplitIds.add(t.parent_id);
348+
}
349+
});
350+
342351
// Filter transactions for 2025 and exclude transfers, off-budget (conditionally), and starting balance
343352
const yearTransactions = transactions.filter(t => {
344353
const date = parseISO(t.date);
345354
if (date < yearStart || date > yearEnd) {
346355
return false;
347356
}
348-
// Collect transfer transactions (payees with transfer_acct field)
357+
// Collect transfer transactions (payees with transfer_acct field) - check early for parent split logic
349358
const isTransfer = t.payee && payeeIdToTransferAcct.has(t.payee);
359+
360+
// Exclude parent split transactions (no category AND no parent_id AND has child splits AND not a transfer)
361+
// Parent split transactions represent the total but don't have categories themselves
362+
// We identify them by checking if any child transactions point to this transaction's ID
363+
// Child split transactions have parent_id and should be included
364+
// Regular transactions have category and no parent_id and should be included
365+
// Uncategorized transactions (no category, no parent_id, no children) should be included
366+
// Transfers don't have categories but should be included (if toggles allow)
367+
if (!t.parent_id && parentSplitIds.has(t.id) && !isTransfer) {
368+
return false;
369+
}
350370
if (isTransfer) {
351371
// Check off-budget status of source account (needed for transferTransactions collection)
352372
const sourceIsOffBudget = accountOffbudgetMap.get(t.account) || false;

0 commit comments

Comments
 (0)