-
<%= t(".exclude") %>
-
<%= t(".exclude_description") %>
+ <%# Split children list for split parent %>
+ <% if @entry.split_parent? %>
+ <% dialog.with_section(title: t("splits.show.title"), open: true) do %>
+
+
<%= t("splits.show.description") %>
+ <% @entry.child_entries.includes(:entryable).each do |child| %>
+
+
+
<%= child.name %>
+
<%= child.entryable.try(:category)&.name || t("splits.new.uncategorized") %>
+
+
">
+ <%= format_money(-child.amount_money) %>
+
- <%= f.toggle :excluded, { data: { auto_submit_form_target: "auto" } } %>
+ <% end %>
+
+ <%= render DS::Link.new(
+ text: t("splits.child.edit_split"),
+ icon: "pencil",
+ variant: "ghost",
+ size: :sm,
+ href: edit_transaction_split_path(@entry),
+ frame: :modal
+ ) %>
+ <%= render DS::Button.new(
+ text: t("splits.show.unsplit_button"),
+ icon: "undo-2",
+ variant: "ghost",
+ size: :sm,
+ class: "text-destructive",
+ href: transaction_split_path(@entry),
+ method: :delete,
+ confirm: CustomConfirm.new(title: t("splits.show.unsplit_title"), body: t("splits.show.unsplit_confirm"), btn_text: t("splits.show.unsplit_button"), destructive: true),
+ frame: "_top"
+ ) %>
- <% end %>
-
+
+ <% end %>
+ <% end %>
+
+ <%# For split child, show parent info and actions %>
+ <% if @entry.split_child? %>
+ <% dialog.with_section(title: t("splits.child.title"), open: true) do %>
+
+ <% end %>
+ <% end %>
+
+ <% dialog.with_section(title: t(".settings")) do %>
+ <% unless @entry.split_parent? %>
+
+ <% end %>
<% if @entry.account.investment? || @entry.account.crypto? %>
<%= styled_form_with model: @entry,
@@ -233,6 +319,22 @@
<% end %>
<% end %>
+ <%# Split Transaction %>
+ <% if @entry.transaction.splittable? %>
+
Transfer or Debt Payment?
@@ -294,21 +396,23 @@
frame: "_top"
) %>
-
-
-
-
<%= t(".delete_title") %>
-
<%= t(".delete_subtitle") %>
+ <%# Delete Transaction Form - hidden for split children %>
+ <% unless @entry.split_child? %>
+
+
+
<%= t(".delete_title") %>
+
<%= t(".delete_subtitle") %>
+
+ <%= render DS::Button.new(
+ text: t(".delete"),
+ variant: "outline-destructive",
+ href: entry_path(@entry),
+ method: :delete,
+ confirm: CustomConfirm.for_resource_deletion("transaction"),
+ frame: "_top"
+ ) %>
- <%= render DS::Button.new(
- text: t(".delete"),
- variant: "outline-destructive",
- href: entry_path(@entry),
- method: :delete,
- confirm: CustomConfirm.for_resource_deletion("transaction"),
- frame: "_top"
- ) %>
-
+ <% end %>
<% end %>
<% end %>
diff --git a/config/locales/views/splits/en.yml b/config/locales/views/splits/en.yml
new file mode 100644
index 000000000..db9a5e037
--- /dev/null
+++ b/config/locales/views/splits/en.yml
@@ -0,0 +1,47 @@
+---
+en:
+ splits:
+ new:
+ title: Split Transaction
+ description: Split this transaction into multiple entries with different categories and amounts.
+ submit: Split Transaction
+ cancel: Cancel
+ add_row: Add split
+ remove_row: Remove
+ remaining: Remaining
+ amounts_must_match: Split amounts must equal the original transaction amount.
+ name_label: Name
+ name_placeholder: Split name
+ amount_label: Amount
+ category_label: Category
+ uncategorized: "(uncategorized)"
+ original_name: "Name:"
+ original_date: "Date:"
+ original_amount: "Amount"
+ split_number: "Split #%{number}"
+ create:
+ success: Transaction split successfully
+ not_splittable: This transaction cannot be split.
+ destroy:
+ success: Transaction unsplit successfully
+ show:
+ title: Split Entries
+ description: This transaction has been split into the following entries.
+ button_title: Split Transaction
+ button_description: Split this transaction into multiple entries with different categories and amounts.
+ button: Split
+ unsplit_title: Unsplit Transaction
+ unsplit_button: Unsplit
+ unsplit_confirm: This will remove all split entries and restore the original transaction.
+ edit:
+ title: Edit Split
+ description: Modify the split entries for this transaction.
+ submit: Update Split
+ not_split: This transaction is not split.
+ update:
+ success: Split updated successfully
+ child:
+ title: Part of Split
+ description: This entry is part of a split transaction.
+ edit_split: Edit Split
+ unsplit: Unsplit
diff --git a/config/locales/views/transactions/en.yml b/config/locales/views/transactions/en.yml
index f91410d9a..bd91c1c16 100644
--- a/config/locales/views/transactions/en.yml
+++ b/config/locales/views/transactions/en.yml
@@ -90,6 +90,9 @@ en:
potential_duplicate_tooltip: This may be a duplicate of another transaction
review_recommended: Review
review_recommended_tooltip: Large amount difference — review recommended to check if this is a duplicate
+ split: Split
+ split_tooltip: This transaction has been split into multiple entries
+ split_child_tooltip: Part of a split transaction
merge_duplicate:
success: Transactions merged successfully
failure: Could not merge transactions
diff --git a/config/routes.rb b/config/routes.rb
index b4576b907..017c15897 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -269,6 +269,7 @@ Rails.application.routes.draw do
end
resources :transactions, only: %i[index new create show update destroy] do
+ resource :split, only: %i[new create edit update destroy]
resource :transfer_match, only: %i[new create]
resource :pending_duplicate_merges, only: %i[new create]
resource :category, only: :update, controller: :transaction_categories
diff --git a/db/migrate/20260320080659_add_parent_entry_id_to_entries.rb b/db/migrate/20260320080659_add_parent_entry_id_to_entries.rb
new file mode 100644
index 000000000..164b52567
--- /dev/null
+++ b/db/migrate/20260320080659_add_parent_entry_id_to_entries.rb
@@ -0,0 +1,6 @@
+class AddParentEntryIdToEntries < ActiveRecord::Migration[7.2]
+ def change
+ add_reference :entries, :parent_entry, type: :uuid, null: true,
+ foreign_key: { to_table: :entries, on_delete: :cascade }
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index c357744ec..8355c6564 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.2].define(version: 2026_03_16_120000) do
+ActiveRecord::Schema[7.2].define(version: 2026_03_20_080659) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -397,6 +397,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_03_16_120000) do
t.string "source"
t.boolean "user_modified", default: false, null: false
t.boolean "import_locked", default: false, null: false
+ t.uuid "parent_entry_id"
t.index "lower((name)::text)", name: "index_entries_on_lower_name"
t.index ["account_id", "date"], name: "index_entries_on_account_id_and_date"
t.index ["account_id", "source", "external_id"], name: "index_entries_on_account_source_and_external_id", unique: true, where: "((external_id IS NOT NULL) AND (source IS NOT NULL))"
@@ -405,6 +406,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_03_16_120000) do
t.index ["entryable_type"], name: "index_entries_on_entryable_type"
t.index ["import_id"], name: "index_entries_on_import_id"
t.index ["import_locked"], name: "index_entries_on_import_locked_true", where: "(import_locked = true)"
+ t.index ["parent_entry_id"], name: "index_entries_on_parent_entry_id"
t.index ["user_modified"], name: "index_entries_on_user_modified_true", where: "(user_modified = true)"
end
@@ -1515,6 +1517,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_03_16_120000) do
add_foreign_key "enable_banking_accounts", "enable_banking_items"
add_foreign_key "enable_banking_items", "families"
add_foreign_key "entries", "accounts", on_delete: :cascade
+ add_foreign_key "entries", "entries", column: "parent_entry_id", on_delete: :cascade
add_foreign_key "entries", "imports"
add_foreign_key "eval_results", "eval_runs"
add_foreign_key "eval_results", "eval_samples"
diff --git a/test/controllers/splits_controller_test.rb b/test/controllers/splits_controller_test.rb
new file mode 100644
index 000000000..6a0cb3dad
--- /dev/null
+++ b/test/controllers/splits_controller_test.rb
@@ -0,0 +1,212 @@
+require "test_helper"
+
+class SplitsControllerTest < ActionDispatch::IntegrationTest
+ include EntriesTestHelper
+
+ setup do
+ sign_in @user = users(:family_admin)
+ @entry = create_transaction(
+ amount: 100,
+ name: "Grocery Store",
+ account: accounts(:depository)
+ )
+ end
+
+ test "new renders split editor" do
+ get new_transaction_split_path(@entry)
+ assert_response :success
+ end
+
+ test "create with valid params splits transaction" do
+ assert_difference "Entry.count", 2 do
+ post transaction_split_path(@entry), params: {
+ split: {
+ splits: [
+ { name: "Groceries", amount: "-70", category_id: categories(:food_and_drink).id },
+ { name: "Household", amount: "-30", category_id: "" }
+ ]
+ }
+ }
+ end
+
+ assert_redirected_to transactions_url
+ assert_equal I18n.t("splits.create.success"), flash[:notice]
+ assert @entry.reload.excluded?
+ assert @entry.split_parent?
+ end
+
+ test "create with mismatched amounts rejects" do
+ assert_no_difference "Entry.count" do
+ post transaction_split_path(@entry), params: {
+ split: {
+ splits: [
+ { name: "Part 1", amount: "-60", category_id: "" },
+ { name: "Part 2", amount: "-20", category_id: "" }
+ ]
+ }
+ }
+ end
+
+ assert_redirected_to transactions_url
+ assert flash[:alert].present?
+ end
+
+ test "destroy unsplits transaction" do
+ @entry.split!([
+ { name: "Part 1", amount: 50, category_id: nil },
+ { name: "Part 2", amount: 50, category_id: nil }
+ ])
+
+ assert_difference "Entry.count", -2 do
+ delete transaction_split_path(@entry)
+ end
+
+ assert_redirected_to transactions_url
+ assert_equal I18n.t("splits.destroy.success"), flash[:notice]
+ refute @entry.reload.excluded?
+ end
+
+ test "create with income transaction applies correct sign" do
+ income_entry = create_transaction(
+ amount: -400,
+ name: "Reimbursement",
+ account: accounts(:depository)
+ )
+
+ assert_difference "Entry.count", 2 do
+ post transaction_split_path(income_entry), params: {
+ split: {
+ splits: [
+ { name: "Part 1", amount: "200", category_id: "" },
+ { name: "Part 2", amount: "200", category_id: "" }
+ ]
+ }
+ }
+ end
+
+ assert income_entry.reload.excluded?
+ children = income_entry.child_entries
+ assert_equal(-200, children.first.amount.to_i)
+ assert_equal(-200, children.last.amount.to_i)
+ end
+
+ test "create with mixed sign amounts on expense" do
+ assert_difference "Entry.count", 2 do
+ post transaction_split_path(@entry), params: {
+ split: {
+ splits: [
+ { name: "Main expense", amount: "-130", category_id: "" },
+ { name: "Refund", amount: "30", category_id: "" }
+ ]
+ }
+ }
+ end
+
+ assert @entry.reload.excluded?
+ children = @entry.child_entries.order(:amount)
+ assert_equal(-30, children.first.amount.to_i)
+ assert_equal 130, children.last.amount.to_i
+ end
+
+ test "only family members can access splits" do
+ other_family_entry = create_transaction(
+ amount: 100,
+ name: "Other",
+ account: accounts(:depository)
+ )
+
+ # This should work since both belong to same family
+ get new_transaction_split_path(other_family_entry)
+ assert_response :success
+ end
+
+ # Edit action tests
+ test "edit renders with existing children pre-filled" do
+ @entry.split!([
+ { name: "Part 1", amount: 60, category_id: nil },
+ { name: "Part 2", amount: 40, category_id: nil }
+ ])
+
+ get edit_transaction_split_path(@entry)
+ assert_response :success
+ end
+
+ test "edit on a child redirects to parent edit" do
+ @entry.split!([
+ { name: "Part 1", amount: 60, category_id: nil },
+ { name: "Part 2", amount: 40, category_id: nil }
+ ])
+ child = @entry.child_entries.first
+
+ get edit_transaction_split_path(child)
+ assert_response :success
+ end
+
+ test "edit on a non-split entry redirects with alert" do
+ get edit_transaction_split_path(@entry)
+ assert_redirected_to transactions_url
+ assert_equal I18n.t("splits.edit.not_split"), flash[:alert]
+ end
+
+ # Update action tests
+ test "update modifies split entries" do
+ @entry.split!([
+ { name: "Part 1", amount: 60, category_id: nil },
+ { name: "Part 2", amount: 40, category_id: nil }
+ ])
+
+ patch transaction_split_path(@entry), params: {
+ split: {
+ splits: [
+ { name: "Food", amount: "-50", category_id: categories(:food_and_drink).id },
+ { name: "Transport", amount: "-30", category_id: "" },
+ { name: "Other", amount: "-20", category_id: "" }
+ ]
+ }
+ }
+
+ assert_redirected_to transactions_url
+ assert_equal I18n.t("splits.update.success"), flash[:notice]
+ @entry.reload
+ assert @entry.split_parent?
+ assert_equal 3, @entry.child_entries.count
+ end
+
+ test "update with mismatched amounts rejects" do
+ @entry.split!([
+ { name: "Part 1", amount: 60, category_id: nil },
+ { name: "Part 2", amount: 40, category_id: nil }
+ ])
+
+ patch transaction_split_path(@entry), params: {
+ split: {
+ splits: [
+ { name: "Part 1", amount: "-70", category_id: "" },
+ { name: "Part 2", amount: "-20", category_id: "" }
+ ]
+ }
+ }
+
+ assert_redirected_to transactions_url
+ assert flash[:alert].present?
+ # Original splits should remain intact
+ assert_equal 2, @entry.reload.child_entries.count
+ end
+
+ # Destroy from child tests
+ test "destroy from child resolves to parent and unsplits" do
+ @entry.split!([
+ { name: "Part 1", amount: 60, category_id: nil },
+ { name: "Part 2", amount: 40, category_id: nil }
+ ])
+ child = @entry.child_entries.first
+
+ assert_difference "Entry.count", -2 do
+ delete transaction_split_path(child)
+ end
+
+ assert_redirected_to transactions_url
+ assert_equal I18n.t("splits.destroy.success"), flash[:notice]
+ refute @entry.reload.excluded?
+ end
+end
diff --git a/test/models/entry_split_test.rb b/test/models/entry_split_test.rb
new file mode 100644
index 000000000..c9869210d
--- /dev/null
+++ b/test/models/entry_split_test.rb
@@ -0,0 +1,177 @@
+require "test_helper"
+
+class EntrySplitTest < ActiveSupport::TestCase
+ include EntriesTestHelper
+
+ setup do
+ @entry = create_transaction(
+ amount: 100,
+ name: "Grocery Store",
+ account: accounts(:depository),
+ category: categories(:food_and_drink)
+ )
+ end
+
+ test "split! creates child entries with correct amounts and marks parent excluded" do
+ splits = [
+ { name: "Groceries", amount: 70, category_id: categories(:food_and_drink).id },
+ { name: "Household", amount: 30, category_id: nil }
+ ]
+
+ children = @entry.split!(splits)
+
+ assert_equal 2, children.size
+ assert_equal 70, children.first.amount
+ assert_equal 30, children.last.amount
+ assert @entry.reload.excluded?
+ assert @entry.split_parent?
+ end
+
+ test "split! rejects when amounts don't sum to parent" do
+ splits = [
+ { name: "Part 1", amount: 60, category_id: nil },
+ { name: "Part 2", amount: 30, category_id: nil }
+ ]
+
+ assert_raises(ActiveRecord::RecordInvalid) do
+ @entry.split!(splits)
+ end
+ end
+
+ test "split! allows mixed positive and negative amounts that sum to parent" do
+ splits = [
+ { name: "Main expense", amount: 130, category_id: nil },
+ { name: "Refund", amount: -30, category_id: nil }
+ ]
+
+ children = @entry.split!(splits)
+
+ assert_equal 2, children.size
+ assert_equal 130, children.first.amount
+ assert_equal(-30, children.last.amount)
+ end
+
+ test "cannot split transfers" do
+ transfer = create_transfer(
+ from_account: accounts(:depository),
+ to_account: accounts(:credit_card),
+ amount: 100
+ )
+ outflow_transaction = transfer.outflow_transaction
+
+ refute outflow_transaction.splittable?
+ end
+
+ test "cannot split already-split parent" do
+ @entry.split!([
+ { name: "Part 1", amount: 50, category_id: nil },
+ { name: "Part 2", amount: 50, category_id: nil }
+ ])
+
+ refute @entry.entryable.splittable?
+ end
+
+ test "cannot split child entry" do
+ children = @entry.split!([
+ { name: "Part 1", amount: 50, category_id: nil },
+ { name: "Part 2", amount: 50, category_id: nil }
+ ])
+
+ refute children.first.entryable.splittable?
+ end
+
+ test "unsplit! removes children and restores parent" do
+ @entry.split!([
+ { name: "Part 1", amount: 50, category_id: nil },
+ { name: "Part 2", amount: 50, category_id: nil }
+ ])
+
+ assert @entry.reload.excluded?
+ assert_equal 2, @entry.child_entries.count
+
+ @entry.unsplit!
+
+ refute @entry.reload.excluded?
+ assert_equal 0, @entry.child_entries.count
+ end
+
+ test "parent deletion cascades to children" do
+ @entry.split!([
+ { name: "Part 1", amount: 50, category_id: nil },
+ { name: "Part 2", amount: 50, category_id: nil }
+ ])
+
+ child_ids = @entry.child_entries.pluck(:id)
+
+ @entry.destroy!
+
+ assert_empty Entry.where(id: child_ids)
+ end
+
+ test "individual child deletion is blocked" do
+ children = @entry.split!([
+ { name: "Part 1", amount: 50, category_id: nil },
+ { name: "Part 2", amount: 50, category_id: nil }
+ ])
+
+ refute children.first.destroy
+ assert children.first.persisted?
+ end
+
+ test "split parent cannot be un-excluded" do
+ @entry.split!([
+ { name: "Part 1", amount: 50, category_id: nil },
+ { name: "Part 2", amount: 50, category_id: nil }
+ ])
+
+ @entry.reload
+ @entry.excluded = false
+ refute @entry.valid?
+ assert_includes @entry.errors[:excluded], "cannot be toggled off for a split transaction"
+ end
+
+ test "excluding_split_parents scope excludes parents with children" do
+ @entry.split!([
+ { name: "Part 1", amount: 50, category_id: nil },
+ { name: "Part 2", amount: 50, category_id: nil }
+ ])
+
+ scope = Entry.excluding_split_parents.where(account: accounts(:depository))
+ refute_includes scope.pluck(:id), @entry.id
+ assert_includes scope.pluck(:id), @entry.child_entries.first.id
+ end
+
+ test "children inherit parent's account, date, and currency" do
+ children = @entry.split!([
+ { name: "Part 1", amount: 50, category_id: nil },
+ { name: "Part 2", amount: 50, category_id: nil }
+ ])
+
+ children.each do |child|
+ assert_equal @entry.account_id, child.account_id
+ assert_equal @entry.date, child.date
+ assert_equal @entry.currency, child.currency
+ end
+ end
+
+ test "split_parent? returns true when entry has children" do
+ refute @entry.split_parent?
+
+ @entry.split!([
+ { name: "Part 1", amount: 50, category_id: nil },
+ { name: "Part 2", amount: 50, category_id: nil }
+ ])
+
+ assert @entry.split_parent?
+ end
+
+ test "split_child? returns true for child entries" do
+ children = @entry.split!([
+ { name: "Part 1", amount: 50, category_id: nil },
+ { name: "Part 2", amount: 50, category_id: nil }
+ ])
+
+ assert children.first.split_child?
+ refute @entry.split_child?
+ end
+end