Repository navigation
Split transactions: one movement over several categories - #48
Merged
Merged
Conversation
A transaction (one bank line, one total) can now be split into category lines, e.g. one energy bill for electricity and gas. Lines live in transaction_splits, add up exactly to the amount (EUR amounts via the transaction's rate, remainder on the last line) and take the explicit nature or their category's default. A split transaction has no single category. Every per-category figure reads the lines: reports, budgets, dashboard, the list's category/nature filters, CSV/Excel (one row per line), the JSON archive and the AI tools (rows show splits; create/update_transaction accept splits). Recurring templates can be split and carry the lines into proposals and the confirmed transaction (rescaled when the amount changes).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
"Why can't a transaction have 2 categories?" — now it can. One movement (one bank line, one total) can be split into category lines, e.g. one energy bill: electricity 61,20 € + gas 35,20 €.
Data model (and why)
finance.transaction_splits(id, transaction_id, position, category_id → categories RESTRICT, original_amount, base_amount, nature, note ≤120); cascade-deleted with the transaction, hidden with it by the soft-delete filter.category_id = NULLand keeps its total/account/date/description. Alternatives considered: keeping a "main" category on the parent would double-count or need every query to special-case it; a separate "split" pseudo-category leaks into reports. With NULL, any category-grouped query must read the lines, and the parent stays the single movement for balances, interest and audit.base_amount.natureis NULL for a split unless explicit.Splits: "60.00 <cat>; 40.00 <cat>"from → to), including edits that only touch lines; re-saving identical lines adds no row.finance.recurring_transaction_splits; confirming with a different amount rescales lines proportionally (cents, remainder last). The expected list shows the scaled lines.Where splits are counted
LedgerAggregates.MonthTotalsAsync(the single source for monthly, annual, overview, trends, category breakdown, budgets/category limits, budget notifications, dashboard): unsplit rows grouped as before + split lines grouped by line category/nature + split transactions counted once.categoryAmount; nature filter matches lines.interestline of a split income counts as real interest (settles the month, shows in account interest).split("1/2") andsplit_notecolumns appended at the end (columns sum by category to the report figures). JSON archive:splitson transactions and recurring items; import re-derives EUR amounts and skips a record whose line category can't be mapped. Round-trip covered by test.get_expense_summarytotal/count,get_income_summary,get_budget_status,get_transactionstotals with a category count only matching lines); rows showsplits: [{category, amount}](get_transactions,get_expense_transactions,get_recurring).create_transaction/update_transactionaccept optionalsplitsas"electricity: 60.00; water-gas: 40"— a string so every tool argument stays scalar (the architecture test pinning "no arrays/objects" is unchanged); same validation, strictly parsed. Updating only the amount of a split row is refused (lines must add up);upsert_recurringkeeps a split template's lines and refuses changing its amount.Migration
20261005214742_TransactionSplits— creates the two tables + indexes; no data change, existing rows are unaffected. New tables get app grants through the existing default privileges.Test plan
dotnet build, all backend tests (330 passed): newFinance.Tests/SplitTransactionTests(validation, rounding/base amounts, nature, sources, recurring rescale) andIntegration.Tests/SplitTransactionTests(create/edit/remove + audit, reports fixed/variable, category budget status, list category/nature filter withcategoryAmount, refusals for transfer/investment/sum/duplicates/income category, CSV rows, recurring proposal + confirm, AI read counts and strict writes); JSON round-trip test now includes a split.ng test(113 passed) incl.split-lines.spec.ts.journey.spec.tsextended with a split expense: split, pick a category, fill remaining, save, expand in list).AiToolCoverageTestsyear breakdown moved from 2041 to 2042 — it raced withAiWriteToolsTests(2041 income) and failed depending on order.