Split Transactions#
Split transactions let users allocate a single transaction across multiple categories (e.g., a grocery run that includes household items and a gift). The feature is implemented via a parent-child Entry relationship: the original entry becomes the parent (marked excluded: true) and each allocation becomes a child entry with its own name, amount, and category.
Data Model#
The relationship is defined in Entry:
belongs_to :parent_entry, class_name: "Entry", optional: true— present on child entries.has_many :child_entries, class_name: "Entry", foreign_key: :parent_entry_id, dependent: :destroy— present on the parent.
Two predicates on Entry identify role:
split_parent?—child_entries.exists?split_child?—parent_entry_id.present?
When created, the parent entry is set to excluded: true; that flag cannot be toggled off while children exist.
Eligibility — Transaction::Splittable#
Transaction::Splittable#splittable? returns true only when the transaction is not a transfer, not already a split child or split parent, not pending, and not excluded. This check is enforced in the controller before creating splits .
split! and unsplit!#
Entry#split!(splits) takes an array of { name:, amount:, category_id:, excluded: } hashes. It validates that split amounts sum exactly to the parent amount, then creates child entries in a single database transaction and calls mark_user_modified! on the parent.
Entry#unsplit! destroys all child entries (setting unsplitting = true to bypass the guard) and restores excluded: false on the parent.
Child entries cannot be individually deleted outside of unsplit! — prevent_individual_child_deletion throws :abort before destroy unless the unsplitting flag or destroyed_by_association is set.
Analytics Exclusion#
The excluding_split_parents scope filters parents out of transaction lists by checking for the absence of child entries via a NOT EXISTS subquery. The Transaction Rule Engine also explicitly excludes split parents from its resource scope so rules only apply to the split children.
Controller — SplitsController#
SplitsController provides five actions scoped to an entry via params[:transaction_id]:
| Action | Route | Description |
|---|---|---|
new | GET /transactions/:transaction_id/split/new | Renders the split form |
create | POST /transactions/:transaction_id/split | Calls entry.split!(splits) |
edit | GET /transactions/:transaction_id/split/edit | Loads existing children for editing |
update | PATCH /transactions/:transaction_id/split | Calls unsplit! then split! in a transaction |
destroy | DELETE /transactions/:transaction_id/split | Calls unsplit! |
The update and destroy actions call resolve_to_parent! first — if the entry passed in is itself a split child, it navigates up to the parent before proceeding. Mutations require account write permission (require_account_permission!).
Amount signs are inverted on the way in: s[:amount].to_d * -1 converts user-entered positive dollar amounts to the internal negative convention used for expenses.
UI — Views and Stimulus Controller#
Both new and edit modals mount the split-transaction Stimulus controller via data: { controller: "split-transaction" }. The total and currency are passed as Stimulus values directly from the entry.
The split_transaction_controller.js manages:
addRow— dynamically creates a new split row by cloning the existing category select dropdown, resetting its state, and appending it to the container. Inputnameattributes use indexed param keys (split[splits][N][field]).removeRow— removes a row (minimum 1 row enforced) and re-indexes remaining rows.reindexRows— updatesnameattributes after a row is removed to maintain consecutive indices.updateRemaining— sums allamountInputtargets and computes the gap againsttotalValue. If the absolute difference is <0.005, the balance is considered "balanced": the remaining display turns green and the submit button is enabled; otherwise it turns red and the submit button is disabled.
The edit form pre-fills rows from @children, which the controller loads as @entry.child_entries.includes(:entryable) .
Key Constraints#
- Split children inherit the parent's
date,currency,account, andmerchant_id; a validation prevents date drift on children. - Split children inherit the parent's
kind, so their budget classification matches the original transaction. - The
edit/update/destroyactions accept a child entry's ID (viaresolve_to_parent!) — callers do not need to look up the parent themselves.