+ <% unless @entry.split_child? %>
+ <%= styled_form_with model: @entry,
+ url: transaction_path(@entry),
+ class: "p-3",
+ data: { controller: "auto-submit-form" } do |f| %>
+ <%= f.fields_for :entryable do |ef| %>
+
-
-
Transfer or Debt Payment?
-
Transfers and payments are special types of transactions that indicate money movement between 2 accounts.
+ <% if @entry.transaction.splittable? %>
+
+
+
Split Transaction
+
Divide this transaction into multiple categories or parts.
+
+ <%= render DS::Link.new(
+ text: "Split",
+ icon: "split",
+ variant: "outline",
+ href: new_transaction_split_path(@entry),
+ frame: :modal
+ ) %>
+ <% end %>
- <%= render DS::Link.new(
- text: "Open matcher",
- icon: "arrow-left-right",
- variant: "outline",
- href: new_transaction_transfer_match_path(@entry),
- frame: :modal
- ) %>
-
+ <% unless @entry.split_child? %>
+
+
+
Transfer or Debt Payment?
+
Transfers and payments are special types of transactions that indicate money movement between 2 accounts.
+
-
-
-
-
<%= t(".delete_title") %>
-
<%= t(".delete_subtitle") %>
+ <%= render DS::Link.new(
+ text: "Open matcher",
+ icon: "arrow-left-right",
+ variant: "outline",
+ href: new_transaction_transfer_match_path(@entry),
+ frame: :modal
+ ) %>
+ <% end %>
- <%= render DS::Button.new(
- text: t(".delete"),
- variant: "outline-destructive",
- href: entry_path(@entry),
- method: :delete,
- confirm: CustomConfirm.for_resource_deletion("transaction"),
- frame: "_top"
- ) %>
-
+
+ <% 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"
+ ) %>
+
+ <% end %>
<% end %>
<% end %>
diff --git a/config/environments/development.rb b/config/environments/development.rb
index 5096294d..eb8f1bce 100644
--- a/config/environments/development.rb
+++ b/config/environments/development.rb
@@ -93,6 +93,7 @@
config.hosts << "eminent-cuddly-shark.ngrok-free.app" if ENV["PORT"] == "3002"
config.hosts << ".ngrok-free.app" if ENV["PORT"] == "3002"
config.hosts << ".ngrok.io" if ENV["PORT"] == "3002"
+ config.hosts << "finance.permana.icu"
# Raise error when a before_action's only/except options reference missing actions.
config.action_controller.raise_on_missing_callback_actions = true
diff --git a/config/routes.rb b/config/routes.rb
index 55b013f4..94aca172 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -180,6 +180,7 @@
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 :category, only: :update, controller: :transaction_categories
diff --git a/db/migrate/20260524122331_add_parent_entry_id_to_entries.rb b/db/migrate/20260524122331_add_parent_entry_id_to_entries.rb
new file mode 100644
index 00000000..01ab9423
--- /dev/null
+++ b/db/migrate/20260524122331_add_parent_entry_id_to_entries.rb
@@ -0,0 +1,6 @@
+class AddParentEntryIdToEntries < ActiveRecord::Migration[8.1]
+ 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 d6d11745..37193499 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[8.1].define(version: 2026_01_25_141807) do
+ActiveRecord::Schema[8.1].define(version: 2026_05_24_122331) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
enable_extension "pgcrypto"
@@ -283,6 +283,7 @@
t.jsonb "locked_attributes", default: {}
t.string "name", null: false
t.text "notes"
+ t.uuid "parent_entry_id"
t.string "plaid_id"
t.string "source"
t.datetime "updated_at", null: false
@@ -294,6 +295,7 @@
t.index ["entryable_id"], name: "index_entries_on_entryable_id"
t.index ["entryable_type"], name: "index_entries_on_entryable_type"
t.index ["import_id"], name: "index_entries_on_import_id"
+ t.index ["parent_entry_id"], name: "index_entries_on_parent_entry_id"
t.index ["plaid_id"], name: "index_entries_on_plaid_id"
end
@@ -1426,6 +1428,7 @@
add_foreign_key "categories", "families"
add_foreign_key "chats", "users"
add_foreign_key "entries", "accounts"
+ 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 00000000..65da79bc
--- /dev/null
+++ b/test/controllers/splits_controller_test.rb
@@ -0,0 +1,253 @@
+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 "Transaction successfully split.", 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 "Transaction unsplit.", 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
+
+ test "create with excluded parameter sets child as excluded" 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, excluded: "true" },
+ { name: "Household", amount: "-30", category_id: "", excluded: "false" }
+ ]
+ }
+ }
+ end
+
+ assert_redirected_to transactions_url
+ children = @entry.child_entries.order(:amount)
+ # Household has amount 30 (smaller), Groceries has amount 70 (larger)
+ # Household is NOT excluded, Groceries IS excluded
+ refute children.first.excluded?
+ assert children.last.excluded?
+ 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 "This transaction is 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 "Split transaction updated.", 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
+
+ test "update with excluded parameter sets child as excluded" do
+ @entry.split!([
+ { name: "Groceries", amount: 70, category_id: nil },
+ { name: "Household", amount: 30, category_id: nil }
+ ])
+
+ patch transaction_split_path(@entry), params: {
+ split: {
+ splits: [
+ { name: "Groceries", amount: "-70", category_id: "", excluded: "true" },
+ { name: "Household", amount: "-30", category_id: "", excluded: "false" }
+ ]
+ }
+ }
+
+ assert_redirected_to transactions_url
+ children = @entry.child_entries.order(:amount)
+ refute children.first.excluded?
+ assert children.last.excluded?
+ 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 "Transaction unsplit.", 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 00000000..a7efa4d0
--- /dev/null
+++ b/test/models/entry_split_test.rb
@@ -0,0 +1,224 @@
+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
+
+ test "split! creates child entries with excluded: true when specified" do
+ splits = [
+ { name: "Part 1", amount: 50, category_id: nil, excluded: true },
+ { name: "Part 2", amount: 50, category_id: nil, excluded: false }
+ ]
+
+ children = @entry.split!(splits)
+
+ assert_equal 2, children.size
+ assert children.first.excluded?
+ refute children.last.excluded?
+ end
+
+ test "split! properly casts excluded from string values" do
+ splits = [
+ { name: "Part 1", amount: 50, category_id: nil, excluded: "true" },
+ { name: "Part 2", amount: 50, category_id: nil, excluded: "false" }
+ ]
+
+ children = @entry.split!(splits)
+
+ assert children.first.excluded?
+ refute children.last.excluded?
+ end
+
+ test "excluded split children are excluded from balance calculations" do
+ @entry.split!([
+ { name: "Part 1", amount: 50, category_id: nil, excluded: true },
+ { name: "Part 2", amount: 50, category_id: nil, excluded: false }
+ ])
+
+ # Parent is always excluded for splits
+ assert @entry.reload.excluded?
+
+ # Excluded child should be filtered out by where(excluded: false)
+ excluded_child = @entry.child_entries.find { |c| c.name == "Part 1" }
+ non_excluded_child = @entry.child_entries.find { |c| c.name == "Part 2" }
+
+ assert excluded_child.excluded?
+ refute non_excluded_child.excluded?
+
+ # where(excluded: false) should only include the non-excluded child
+ visible_entries = Entry.where(id: @entry.child_entries.map(&:id)).where(excluded: false)
+ assert_includes visible_entries.pluck(:id), non_excluded_child.id
+ refute_includes visible_entries.pluck(:id), excluded_child.id
+ end
+end
diff --git a/test/models/simplefin_account/processor_test.rb b/test/models/simplefin_account/processor_test.rb
index 35ce923a..b424c8c9 100644
--- a/test/models/simplefin_account/processor_test.rb
+++ b/test/models/simplefin_account/processor_test.rb
@@ -180,14 +180,13 @@ class SimplefinAccount::ProcessorTest < ActiveSupport::TestCase
account.update!(simplefin_account: simplefin_account)
- # Mock the balance calculator
- calculator = Minitest::Mock.new
- calculator.expect(:cash_balance, 1000.00)
+ # Mock the balance calculator using mocha
+ calculator = mock("BalanceCalculator")
+ calculator.stubs(cash_balance: 1000.00)
- SimplefinAccount::Investments::BalanceCalculator.stub(:new, calculator) do
- processor = SimplefinAccount::Processor.new(simplefin_account)
- processor.send(:process_account!)
- end
+ SimplefinAccount::Investments::BalanceCalculator.stubs(:new).returns(calculator)
+ processor = SimplefinAccount::Processor.new(simplefin_account)
+ processor.send(:process_account!)
account.reload
assert_equal 5000.00, account.balance, "Investment balance should be set"
diff --git a/test/test_helper.rb b/test/test_helper.rb
index 373ce558..11543a3c 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -62,7 +62,7 @@ def multi
ENV["PGGSSENCMODE"] = "disable"
require "rails/test_help"
-require "minitest/mock"
+# require "minitest/mock"
require "minitest/autorun"
require "mocha/minitest"
require "aasm/minitest"