Skip to content

Split transactions: one movement over several categories - #48

Merged
joaoguimaraespro merged 1 commit into
mainfrom
feat/split-transactions
Oct 6, 2026
Merged

joaoguimaraespro merged 1 commit into
mainfrom
feat/split-transactions

Conversation

@joaoguimaraespro

Copy link
Copy Markdown
Owner

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 €.

  • Quick-add/edit: Dividir por categorias turns the category field into lines (category + amount + optional note), add/remove lines, live "Faltam X / acima do total / Soma igual ao total", Preencher restante, a nature option Por categoria; refuses to save until the lines add up. Keyboard friendly, fits 390px.
  • List: a split row shows "2 categorias" and expands to its lines. Filtered by a category, the row shows its part in that category ("de 96,40 €").
  • Recurring items can be split too (form + list); proposals and confirmed transactions carry the lines.

Data model (and why)

  • New owned table 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.
  • A split transaction has category_id = NULL and 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.
  • Rules (domain + request validator): expense/income only; 2–20 lines; amount > 0 (≤4 decimals); each category once and of the transaction's type; lines sum exactly to the amount in the original currency; not with a single category at the same time; never on transfers, savings, investments, broker rows or estimated interest (those are already read-only).
  • EUR: each line = round(amount × fx, 4), remainder on the last line, so lines always sum to the transaction's base_amount.
  • Nature: the transaction's explicit nature applies to every line; otherwise each expense line takes its category's default nature (Variable if none), stored on the line at write time — same behaviour as single-category rows. Parent nature is NULL for a split unless explicit.
  • Audit: line changes are recorded on the transaction (Splits: "60.00 <cat>; 40.00 <cat>" from → to), including edits that only touch lines; re-saving identical lines adds no row.
  • Recurring: owned table 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.
  • Transaction list: category filter matches any line (parent includes children) with categoryAmount; nature filter matches lines.
  • Interest: an interest line of a split income counts as real interest (settles the month, shows in account interest).
  • Exports: CSV and Excel — one row per line with split ("1/2") and split_note columns appended at the end (columns sum by category to the report figures). JSON archive: splits on 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.
  • AI tools: summaries count lines (get_expense_summary total/count, get_income_summary, get_budget_status, get_transactions totals with a category count only matching lines); rows show splits: [{category, amount}] (get_transactions, get_expense_transactions, get_recurring). create_transaction/update_transaction accept optional splits as "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_recurring keeps 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): new Finance.Tests/SplitTransactionTests (validation, rounding/base amounts, nature, sources, recurring rescale) and Integration.Tests/SplitTransactionTests (create/edit/remove + audit, reports fixed/variable, category budget status, list category/nature filter with categoryAmount, 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.
  • Web build + ng test (113 passed) incl. split-lines.spec.ts.
  • Full Docker E2E locally (journey.spec.ts extended with a split expense: split, pick a category, fill remaining, save, expand in list).
  • Screenshots (mocked API): quick-add split at 1280 light / 390 dark, list at both.
  • Note: AiToolCoverageTests year breakdown moved from 2041 to 2042 — it raced with AiWriteToolsTests (2041 income) and failed depending on order.
  • Note: the initial bundle is ~803 kB on main already (over the 800 kB warning); this PR adds ~0.05 kB.

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).
@joaoguimaraespro
joaoguimaraespro merged commit eb74828 into main Oct 6, 2026
8 checks passed
@joaoguimaraespro
joaoguimaraespro deleted the feat/split-transactions branch October 6, 2026 18:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant