Initial split transaction support (#1230)

* Initial split transaction support

* Add support to unsplit and edit split

* Update show.html.erb

* FIX address reviews

* Improve UX

* Update show.html.erb

* Reviews

* Update edit.html.erb

* Add parent category to dialog

* Update en.yml

* Add UI indication to totals

* FIX ui update

* Add category select like rest of app

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
soky srm
2026-03-20 21:19:30 +01:00
committed by GitHub
parent 6d22514c01
commit ae5b23fe67
24 changed files with 1284 additions and 35 deletions

View File

@@ -0,0 +1,95 @@
class SplitsController < ApplicationController
before_action :set_entry
def new
@categories = Current.family.categories.alphabetically
end
def create
unless @entry.transaction.splittable?
redirect_back_or_to transactions_path, alert: t("splits.create.not_splittable")
return
end
raw_splits = split_params[:splits]
raw_splits = raw_splits.values if raw_splits.respond_to?(:values)
splits = raw_splits.map do |s|
{ name: s[:name], amount: s[:amount].to_d * -1, category_id: s[:category_id].presence }
end
@entry.split!(splits)
@entry.sync_account_later
redirect_back_or_to transactions_path, notice: t("splits.create.success")
rescue ActiveRecord::RecordInvalid => e
redirect_back_or_to transactions_path, alert: e.message
end
def edit
resolve_to_parent!
unless @entry.split_parent?
redirect_to transactions_path, alert: t("splits.edit.not_split")
return
end
@categories = Current.family.categories.alphabetically
@children = @entry.child_entries.includes(:entryable)
end
def update
resolve_to_parent!
unless @entry.split_parent?
redirect_to transactions_path, alert: t("splits.edit.not_split")
return
end
raw_splits = split_params[:splits]
raw_splits = raw_splits.values if raw_splits.respond_to?(:values)
splits = raw_splits.map do |s|
{ name: s[:name], amount: s[:amount].to_d * -1, category_id: s[:category_id].presence }
end
Entry.transaction do
@entry.unsplit!
@entry.split!(splits)
end
@entry.sync_account_later
redirect_to transactions_path, notice: t("splits.update.success")
rescue ActiveRecord::RecordInvalid => e
redirect_to transactions_path, alert: e.message
end
def destroy
resolve_to_parent!
unless @entry.split_parent?
redirect_to transactions_path, alert: t("splits.edit.not_split")
return
end
@entry.unsplit!
@entry.sync_account_later
redirect_to transactions_path, notice: t("splits.destroy.success")
end
private
def set_entry
@entry = Current.family.entries.find(params[:transaction_id])
end
def resolve_to_parent!
@entry = @entry.parent_entry if @entry.split_child?
end
def split_params
params.require(:split).permit(splits: [ :name, :amount, :category_id ])
end
end

View File

@@ -1,6 +1,8 @@
class Transactions::BulkDeletionsController < ApplicationController
def create
destroyed = Current.family.entries.destroy_by(id: bulk_delete_params[:entry_ids])
# Exclude split children from bulk delete - they must be deleted via unsplit on parent
entries_scope = Current.family.entries.where(parent_entry_id: nil)
destroyed = entries_scope.destroy_by(id: bulk_delete_params[:entry_ids])
destroyed.map(&:account).uniq.each(&:sync_later)
redirect_back_or_to transactions_url, notice: "#{destroyed.count} transaction#{destroyed.count == 1 ? "" : "s"} deleted"
end

View File

@@ -3,8 +3,10 @@ class Transactions::BulkUpdatesController < ApplicationController
end
def create
# Skip split parents from bulk update - update children instead
updated = Current.family
.entries
.excluding_split_parents
.where(id: bulk_update_params[:entry_ids])
.bulk_update!(bulk_update_params, update_tags: tags_provided?)

View File

@@ -0,0 +1,18 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["button"]
updateButton(event) {
const { value } = event.detail
const option = this.element.querySelector(`[role="option"][data-value="${CSS.escape(value)}"]`)
if (!option) return
const badge = option.querySelector("span.flex.items-center")
if (badge) {
this.buttonTarget.innerHTML = badge.outerHTML
} else {
this.buttonTarget.textContent = option.dataset.filterName || option.textContent.trim()
}
}
}

View File

@@ -0,0 +1,158 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["rowsContainer", "row", "amountInput", "remaining", "remainingContainer", "error", "submitButton", "nameInput"]
static values = { total: Number, currency: String }
connect() {
this.updateRemaining()
}
get rowCount() {
return this.rowTargets.length
}
addRow() {
const index = this.rowCount
const container = this.rowsContainerTarget
const row = document.createElement("div")
row.classList.add("p-3", "rounded-lg", "border", "border-secondary", "bg-container")
row.dataset.splitTransactionTarget = "row"
// Clone category select from the first row
const existingCategorySelect = container.querySelector(".category-select-container")
let categorySelectHTML = ""
if (existingCategorySelect) {
const cloned = existingCategorySelect.cloneNode(true)
// Reset hidden input value and update name
const hiddenInput = cloned.querySelector("input[type='hidden']")
if (hiddenInput) {
hiddenInput.value = ""
hiddenInput.name = `split[splits][${index}][category_id]`
}
// Reset button to show placeholder text (uncategorized)
const button = cloned.querySelector("[data-select-target='button']")
if (button) {
// Find the uncategorized option text from the menu
const uncategorizedOption = cloned.querySelector("[data-value='']")
const placeholderText = uncategorizedOption ? uncategorizedOption.dataset.filterName : "(uncategorized)"
button.innerHTML = placeholderText
button.setAttribute("aria-expanded", "false")
}
// Reset selected states in menu
cloned.querySelectorAll("[role='option']").forEach(option => {
option.setAttribute("aria-selected", "false")
option.classList.remove("bg-container-inset")
const checkIcon = option.querySelector(".check-icon")
if (checkIcon) checkIcon.classList.add("hidden")
})
// Select the blank/uncategorized option
const blankOption = cloned.querySelector("[data-value='']")
if (blankOption) {
blankOption.setAttribute("aria-selected", "true")
blankOption.classList.add("bg-container-inset")
const checkIcon = blankOption.querySelector(".check-icon")
if (checkIcon) checkIcon.classList.remove("hidden")
}
// Ensure menu is hidden
const menu = cloned.querySelector("[data-select-target='menu']")
if (menu && !menu.classList.contains("hidden")) {
menu.classList.add("hidden")
}
categorySelectHTML = cloned.outerHTML
}
row.innerHTML = `
<div class="flex items-end gap-2">
<div class="flex-1 min-w-0">
<label class="text-xs font-medium text-secondary uppercase tracking-wide block mb-1">Name</label>
<input type="text"
name="split[splits][${index}][name]"
placeholder="Split name"
class="form-field__input border border-secondary rounded-md px-2.5 py-1.5 w-full text-sm text-primary bg-container"
required
autocomplete="off"
data-split-transaction-target="nameInput">
</div>
<div class="w-28 shrink-0">
<label class="text-xs font-medium text-secondary uppercase tracking-wide block mb-1">Amount</label>
<input type="number"
name="split[splits][${index}][amount]"
placeholder="0.00"
step="0.01"
class="form-field__input border border-secondary rounded-md px-2.5 py-1.5 w-full text-sm text-primary bg-container"
required
autocomplete="off"
data-split-transaction-target="amountInput"
data-action="input->split-transaction#updateRemaining">
</div>
${categorySelectHTML}
<button type="button"
class="w-8 h-8 shrink-0 flex items-center justify-center rounded-md text-secondary hover:text-primary hover:bg-surface-hover transition-colors"
data-action="click->split-transaction#removeRow">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
</div>
`
container.appendChild(row)
this.updateRemaining()
}
removeRow(event) {
event.stopPropagation()
const row = event.target.closest("[data-split-transaction-target='row']")
if (row && this.rowCount > 1) {
row.remove()
this.reindexRows()
this.updateRemaining()
}
}
reindexRows() {
this.rowTargets.forEach((row, index) => {
// Update input names (including hidden inputs inside category select)
row.querySelectorAll("[name]").forEach(input => {
input.name = input.name.replace(/splits\[\d+\]/, `splits[${index}]`)
})
})
}
updateRemaining() {
const total = this.totalValue
const sum = this.amountInputTargets.reduce((acc, input) => {
return acc + (Number.parseFloat(input.value) || 0)
}, 0)
const remaining = total - sum
const absRemaining = Math.abs(remaining)
const balanced = absRemaining < 0.005
this.remainingTarget.textContent = balanced ? "0.00" : remaining.toFixed(2)
// Visual feedback on remaining balance
const container = this.remainingContainerTarget
if (balanced) {
this.remainingTarget.classList.remove("text-destructive")
this.remainingTarget.classList.add("text-success")
container.classList.remove("border-destructive", "bg-red-25")
container.classList.add("border-green-200", "bg-green-25")
} else {
this.remainingTarget.classList.remove("text-success")
this.remainingTarget.classList.add("text-destructive")
container.classList.remove("border-green-200", "bg-green-25")
container.classList.add("border-destructive", "bg-red-25")
}
this.errorTarget.classList.toggle("hidden", balanced)
this.submitButtonTarget.disabled = !balanced
}
}

View File

@@ -19,7 +19,7 @@ class Balance::SyncCache
attr_reader :account
def converted_entries
@converted_entries ||= account.entries.order(:date).to_a.map do |e|
@converted_entries ||= account.entries.excluding_split_parents.order(:date).to_a.map do |e|
converted_entry = e.dup
converted_entry.amount = converted_entry.amount_money.exchange_to(
account.currency,

View File

@@ -1,11 +1,16 @@
class Entry < ApplicationRecord
include Monetizable, Enrichable
attr_accessor :unsplitting
monetize :amount
belongs_to :account
belongs_to :transfer, optional: true
belongs_to :import, optional: true
belongs_to :parent_entry, class_name: "Entry", optional: true
has_many :child_entries, class_name: "Entry", foreign_key: :parent_entry_id, dependent: :destroy
delegated_type :entryable, types: Entryable::TYPES, dependent: :destroy
accepts_nested_attributes_for :entryable
@@ -15,6 +20,10 @@ class Entry < ApplicationRecord
validates :date, comparison: { greater_than: -> { min_supported_date } }
validates :external_id, uniqueness: { scope: [ :account_id, :source ] }, if: -> { external_id.present? && source.present? }
validate :cannot_unexclude_split_parent
before_destroy :prevent_individual_child_deletion, if: :split_child?
scope :visible, -> {
joins(:account).where(accounts: { status: [ "draft", "active" ] })
}
@@ -63,6 +72,14 @@ class Entry < ApplicationRecord
SQL
}
scope :excluding_split_parents, -> {
where(<<~SQL.squish)
NOT EXISTS (
SELECT 1 FROM entries ce WHERE ce.parent_entry_id = entries.id
)
SQL
}
# Find stale pending transactions (pending for more than X days with no matching posted version)
scope :stale_pending, ->(days: 8) {
pending.where("entries.date < ?", days.days.ago.to_date)
@@ -313,6 +330,60 @@ class Entry < ApplicationRecord
end
end
def split_parent?
child_entries.exists?
end
def split_child?
parent_entry_id.present?
end
# Splits this entry into child entries. Marks parent as excluded.
#
# @param splits [Array<Hash>] array of { name:, amount:, category_id: } hashes
# @return [Array<Entry>] the created child entries
def split!(splits)
total = splits.sum { |s| s[:amount].to_d }
unless total == amount
raise ActiveRecord::RecordInvalid.new(self), "Split amounts must sum to parent amount (expected #{amount}, got #{total})"
end
self.class.transaction do
children = splits.map do |split_attrs|
child_transaction = Transaction.new(
category_id: split_attrs[:category_id],
merchant_id: entryable.try(:merchant_id),
kind: entryable.try(:kind)
)
child_entries.create!(
account: account,
date: date,
name: split_attrs[:name],
amount: split_attrs[:amount],
currency: currency,
entryable: child_transaction
)
end
update!(excluded: true)
mark_user_modified!
children
end
end
# Removes split children and restores parent entry.
def unsplit!
self.class.transaction do
child_entries.each do |child|
child.unsplitting = true
child.destroy!
end
update!(excluded: false)
end
end
class << self
def search(params)
EntrySearch.new(params).build_query(all)
@@ -373,4 +444,18 @@ class Entry < ApplicationRecord
all.size
end
end
private
def cannot_unexclude_split_parent
return unless excluded_changed?(from: true, to: false) && split_parent?
errors.add(:excluded, "cannot be toggled off for a split transaction")
end
def prevent_individual_child_deletion
return if destroyed_by_association || unsplitting
throw :abort
end
end

View File

@@ -65,8 +65,10 @@ class Family::DataExporter
csv << [ "date", "account_name", "amount", "name", "category", "tags", "notes", "currency" ]
# Only export transactions from accounts belonging to this family
# Exclude split parents (export children instead)
@family.transactions
.includes(:category, :tags, entry: :account)
.merge(Entry.excluding_split_parents)
.find_each do |transaction|
csv << [
transaction.entry.date.iso8601,
@@ -176,8 +178,8 @@ class Family::DataExporter
}.to_json
end
# Export transactions with full data
@family.transactions.includes(:category, :merchant, :tags, entry: :account).find_each do |transaction|
# Export transactions with full data (exclude split parents, export children instead)
@family.transactions.includes(:category, :merchant, :tags, entry: :account).merge(Entry.excluding_split_parents).find_each do |transaction|
lines << {
type: "Transaction",
data: {

View File

@@ -1,6 +1,6 @@
class Rule::Registry::TransactionResource < Rule::Registry
def resource_scope
family.transactions.visible.with_entry.where(entry: { date: rule.effective_date.. })
family.transactions.visible.with_entry.merge(Entry.excluding_split_parents).where(entry: { date: rule.effective_date.. })
end
def condition_filters

View File

@@ -1,5 +1,5 @@
class Transaction < ApplicationRecord
include Entryable, Transferable, Ruleable
include Entryable, Transferable, Ruleable, Splittable
belongs_to :category, optional: true
belongs_to :merchant, optional: true

View File

@@ -26,7 +26,7 @@ class Transaction::Search
def transactions_scope
@transactions_scope ||= begin
# This already joins entries + accounts. To avoid expensive double-joins, don't join them again (causes full table scan)
query = family.transactions
query = family.transactions.merge(Entry.excluding_split_parents)
query = apply_active_accounts_filter(query, active_accounts_only)
query = apply_category_filter(query, categories)

View File

@@ -0,0 +1,7 @@
module Transaction::Splittable
extend ActiveSupport::Concern
def splittable?
!transfer? && !entry.split_child? && !entry.split_parent? && !pending? && !entry.excluded?
end
end

View File

@@ -0,0 +1,87 @@
<%# locals: (name:, categories:, selected_id: nil) %>
<%
selected_category = categories.find { |c| c.id == selected_id }
default_color = "#737373"
%>
<div class="category-select-container relative w-44 shrink-0" data-controller="select list-filter form-dropdown category-badge-select" data-action="dropdown:select->form-dropdown#onSelect dropdown:select->category-badge-select#updateButton">
<label class="text-xs font-medium text-secondary uppercase tracking-wide block mb-1"><%= t("splits.new.category_label") %></label>
<input type="hidden"
name="<%= name %>"
value="<%= selected_id %>"
data-form-dropdown-target="input">
<button type="button"
class="form-field__input w-full overflow-hidden border border-secondary rounded-md pl-2.5 pr-7 py-1.5 text-sm text-primary bg-container"
data-select-target="button"
data-category-badge-select-target="button"
data-action="click->select#toggle"
aria-haspopup="listbox"
aria-expanded="false">
<% if selected_category %>
<% hex_color = selected_category.color.presence || default_color %>
<span class="flex items-center gap-2 text-sm font-medium rounded-full px-3 py-1 border truncate"
style="background-color: color-mix(in oklab, <%= hex_color %> 10%, transparent); border-color: color-mix(in oklab, <%= hex_color %> 20%, transparent); color: <%= hex_color %>;">
<% if selected_category.lucide_icon.present? %>
<%= icon selected_category.lucide_icon, size: "sm", color: "current" %>
<% else %>
<span class="size-1.5 rounded-full" style="background-color: <%= hex_color %>;"></span>
<% end %>
<%= selected_category.name %>
</span>
<% else %>
<%= t("splits.new.uncategorized") %>
<% end %>
</button>
<div class="absolute z-50 p-1.5 w-full min-w-48 rounded-lg shadow-lg shadow-border-xs bg-container mt-1.5 transition duration-150 ease-out -translate-y-1 opacity-0 hidden" data-select-target="menu">
<div class="relative flex items-center bg-container border border-secondary rounded-lg mb-1">
<input type="search"
placeholder="<%= t("helpers.select.search_placeholder") %>"
autocomplete="off"
class="bg-container text-sm placeholder:text-secondary font-normal h-10 pl-10 w-full border-none rounded-lg focus:outline-hidden focus:ring-0"
data-list-filter-target="input"
data-action="list-filter#filter">
<%= icon("search", class: "absolute inset-0 ml-2 transform top-1/2 -translate-y-1/2") %>
</div>
<div data-list-filter-target="list" data-select-target="content" class="flex flex-col gap-0.5 max-h-64 overflow-auto" role="listbox" tabindex="-1">
<%# Uncategorized option %>
<% is_blank_selected = selected_id.blank? %>
<div class="filterable-item text-sm cursor-pointer flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-container-inset-hover <%= "bg-container-inset" if is_blank_selected %>"
role="option"
tabindex="0"
aria-selected="<%= is_blank_selected %>"
data-action="click->select#select"
data-value=""
data-filter-name="<%= t("splits.new.uncategorized") %>">
<span class="check-icon <%= "hidden" unless is_blank_selected %>">
<%= icon("check") %>
</span>
<%= t("splits.new.uncategorized") %>
</div>
<% categories.each do |category| %>
<% is_selected = category.id == selected_id %>
<% hex_color = category.color.presence || default_color %>
<div class="filterable-item text-sm cursor-pointer flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-container-inset-hover <%= "bg-container-inset" if is_selected %>"
role="option"
tabindex="0"
aria-selected="<%= is_selected %>"
data-action="click->select#select"
data-value="<%= category.id %>"
data-filter-name="<%= category.name %>">
<span class="check-icon <%= "hidden" unless is_selected %>">
<%= icon("check") %>
</span>
<span class="flex items-center gap-2 text-sm font-medium rounded-full px-3 py-1 border truncate"
style="background-color: color-mix(in oklab, <%= hex_color %> 10%, transparent); border-color: color-mix(in oklab, <%= hex_color %> 20%, transparent); color: <%= hex_color %>;">
<% if category.lucide_icon.present? %>
<%= icon category.lucide_icon, size: "sm", color: "current" %>
<% else %>
<span class="size-1.5 rounded-full" style="background-color: <%= hex_color %>;"></span>
<% end %>
<%= category.name %>
</span>
</div>
<% end %>
</div>
</div>
</div>

View File

@@ -0,0 +1,118 @@
<%= render DS::Dialog.new(variant: "modal") do |dialog| %>
<% dialog.with_header do %>
<div class="flex items-start justify-between gap-4">
<div>
<h2 class="text-lg font-semibold text-primary"><%= @entry.name %></h2>
<p class="text-sm text-secondary flex items-center gap-1.5">
<span><%= @entry.date.strftime("%b %d, %Y") %></span>
<% if (category = @entry.entryable.try(:category)) %>
<span class="text-secondary">&middot;</span>
<span style="color: <%= category.color %>"><%= icon category.lucide_icon, size: "xs", color: "current" %></span>
<span><%= category.name %></span>
<% end %>
</p>
</div>
<div class="text-right shrink-0">
<p class="text-xs font-medium text-secondary uppercase tracking-wide"><%= t("splits.new.original_amount") %></p>
<p class="text-lg font-semibold text-primary"><%= format_money(-@entry.amount_money) %></p>
</div>
</div>
<% end %>
<% dialog.with_body do %>
<%= form_with(
url: transaction_split_path(@entry),
method: :patch,
scope: :split,
class: "space-y-3",
data: {
controller: "split-transaction",
split_transaction_total_value: (-@entry.amount).to_f,
split_transaction_currency_value: @entry.currency,
turbo_frame: :_top
}
) do %>
<%# Split rows pre-filled from existing children %>
<div data-split-transaction-target="rowsContainer" class="space-y-3">
<% @children.each_with_index do |child, index| %>
<div class="p-3 rounded-lg border border-secondary bg-container" data-split-transaction-target="row">
<div class="flex items-end gap-2">
<div class="flex-1 min-w-0">
<label class="text-xs font-medium text-secondary uppercase tracking-wide block mb-1"><%= t("splits.new.name_label") %></label>
<input type="text"
name="split[splits][<%= index %>][name]"
placeholder="<%= t("splits.new.name_placeholder") %>"
class="form-field__input border border-secondary rounded-md px-2.5 py-1.5 w-full text-sm text-primary bg-container"
required
autocomplete="off"
value="<%= child.name %>"
data-split-transaction-target="nameInput">
</div>
<div class="w-28 shrink-0">
<label class="text-xs font-medium text-secondary uppercase tracking-wide block mb-1"><%= t("splits.new.amount_label") %></label>
<input type="number"
name="split[splits][<%= index %>][amount]"
placeholder="0.00"
step="0.01"
class="form-field__input border border-secondary rounded-md px-2.5 py-1.5 w-full text-sm text-primary bg-container"
required
autocomplete="off"
value="<%= (-child.amount).to_f %>"
data-split-transaction-target="amountInput"
data-action="input->split-transaction#updateRemaining">
</div>
<%= render "splits/category_select",
name: "split[splits][#{index}][category_id]",
categories: @categories,
selected_id: child.entryable.try(:category_id) %>
<button type="button"
class="w-8 h-8 shrink-0 flex items-center justify-center rounded-md text-secondary hover:text-primary hover:bg-surface-hover transition-colors"
aria-label="<%= t("splits.new.remove_row") %>"
data-action="click->split-transaction#removeRow">
<%= icon "x", size: "sm" %>
</button>
</div>
</div>
<% end %>
</div>
<%# Add split button %>
<button type="button"
class="flex items-center justify-center gap-1.5 w-full py-2 text-sm font-medium text-secondary hover:text-primary border border-dashed border-secondary hover:border-primary rounded-lg transition-colors"
data-action="click->split-transaction#addRow">
<%= icon "plus", size: "sm" %>
<%= t("splits.new.add_row") %>
</button>
<%# Remaining balance indicator %>
<div data-split-transaction-target="remainingContainer" class="p-3 rounded-lg border border-secondary text-sm">
<div class="flex items-center justify-between">
<span class="text-secondary font-medium"><%= t("splits.new.remaining") %></span>
<span data-split-transaction-target="remaining" class="font-semibold text-primary">
<%= (-@entry.amount).to_f %>
</span>
</div>
<p data-split-transaction-target="error" class="text-destructive text-xs mt-1.5 hidden">
<%= t("splits.new.amounts_must_match") %>
</p>
</div>
<%# Actions %>
<div class="flex justify-end gap-3 pt-4 border-t border-primary">
<%= render DS::Button.new(
text: t("splits.new.cancel"),
variant: "outline",
href: "#",
data: { action: "click->ds--dialog#close" }
) %>
<%= render DS::Button.new(
text: t("splits.edit.submit"),
variant: "primary",
type: "submit",
data: { split_transaction_target: "submitButton" }
) %>
</div>
<% end %>
<% end %>
<% end %>

View File

@@ -0,0 +1,109 @@
<%= render DS::Dialog.new(variant: "modal") do |dialog| %>
<% dialog.with_header do %>
<div class="flex items-start justify-between gap-4">
<div>
<h2 class="text-lg font-semibold text-primary"><%= @entry.name %></h2>
<p class="text-sm text-secondary flex items-center gap-1.5">
<span><%= @entry.date.strftime("%b %d, %Y") %></span>
<% if (category = @entry.entryable.try(:category)) %>
<span class="text-secondary">&middot;</span>
<span style="color: <%= category.color %>"><%= icon category.lucide_icon, size: "xs", color: "current" %></span>
<span><%= category.name %></span>
<% end %>
</p>
</div>
<div class="text-right shrink-0">
<p class="text-xs font-medium text-secondary uppercase tracking-wide"><%= t("splits.new.original_amount") %></p>
<p class="text-lg font-semibold text-primary"><%= format_money(-@entry.amount_money) %></p>
</div>
</div>
<% end %>
<% dialog.with_body do %>
<%= form_with(
url: transaction_split_path(@entry),
scope: :split,
class: "space-y-3",
data: {
controller: "split-transaction",
split_transaction_total_value: (-@entry.amount).to_f,
split_transaction_currency_value: @entry.currency,
turbo_frame: :_top
}
) do %>
<%# Split rows %>
<div data-split-transaction-target="rowsContainer" class="space-y-3">
<div class="p-3 rounded-lg border border-secondary bg-container" data-split-transaction-target="row">
<div class="flex items-end gap-2">
<div class="flex-1 min-w-0">
<label class="text-xs font-medium text-secondary uppercase tracking-wide block mb-1"><%= t("splits.new.name_label") %></label>
<input type="text"
name="split[splits][0][name]"
placeholder="<%= t("splits.new.name_placeholder") %>"
class="form-field__input border border-secondary rounded-md px-2.5 py-1.5 w-full text-sm text-primary bg-container"
required
autocomplete="off"
value="<%= @entry.name %>"
data-split-transaction-target="nameInput">
</div>
<div class="w-28 shrink-0">
<label class="text-xs font-medium text-secondary uppercase tracking-wide block mb-1"><%= t("splits.new.amount_label") %></label>
<input type="number"
name="split[splits][0][amount]"
placeholder="0.00"
step="0.01"
class="form-field__input border border-secondary rounded-md px-2.5 py-1.5 w-full text-sm text-primary bg-container"
required
autocomplete="off"
data-split-transaction-target="amountInput"
data-action="input->split-transaction#updateRemaining">
</div>
<%= render "splits/category_select",
name: "split[splits][0][category_id]",
categories: @categories,
selected_id: nil %>
<div class="w-8 shrink-0"></div>
</div>
</div>
</div>
<%# Add split button %>
<button type="button"
class="flex items-center justify-center gap-1.5 w-full py-2 text-sm font-medium text-secondary hover:text-primary border border-dashed border-secondary hover:border-primary rounded-lg transition-colors"
data-action="click->split-transaction#addRow">
<%= icon "plus", size: "sm" %>
<%= t("splits.new.add_row") %>
</button>
<%# Remaining balance indicator %>
<div data-split-transaction-target="remainingContainer" class="p-3 rounded-lg border border-destructive bg-red-25 text-sm">
<div class="flex items-center justify-between">
<span class="text-secondary font-medium"><%= t("splits.new.remaining") %></span>
<span data-split-transaction-target="remaining" class="font-semibold text-destructive">
<%= (-@entry.amount).to_f %>
</span>
</div>
<p data-split-transaction-target="error" class="text-destructive text-xs mt-1.5">
<%= t("splits.new.amounts_must_match") %>
</p>
</div>
<%# Actions %>
<div class="flex justify-end gap-3 pt-4 border-t border-primary">
<%= render DS::Button.new(
text: t("splits.new.cancel"),
variant: "outline",
href: "#",
data: { action: "click->ds--dialog#close" }
) %>
<%= render DS::Button.new(
text: t("splits.new.submit"),
variant: "primary",
type: "submit",
data: { split_transaction_target: "submitButton" }
) %>
</div>
<% end %>
<% end %>
<% end %>

View File

@@ -101,6 +101,19 @@
<% end %>
<% end %>
<%# Split indicator %>
<% if entry.split_parent? %>
<span class="inline-flex items-center gap-1 text-xs font-medium rounded-full px-1.5 py-0.5 border border-accent bg-accent/10 text-accent" title="<%= t("transactions.transaction.split_tooltip") %>">
<%= icon "split", size: "sm", color: "current" %>
<%= t("transactions.transaction.split") %>
</span>
<% end %>
<% if entry.split_child? %>
<span class="text-secondary" title="<%= t("transactions.transaction.split_child_tooltip") %>">
<%= icon "corner-down-right", size: "sm", color: "current" %>
</span>
<% end %>
<% if transaction.transfer.present? %>
<%= render "transactions/transfer_match", transaction: transaction %>
<% end %>

View File

@@ -175,21 +175,107 @@
</div>
<% end %>
<% end %>
<% dialog.with_section(title: t(".settings")) do %>
<div class="pb-4">
<%= styled_form_with model: @entry,
url: transaction_path(@entry),
class: "p-3",
data: { controller: "auto-submit-form" } do |f| %>
<div class="flex cursor-pointer items-center gap-4 justify-between">
<div class="text-sm space-y-1">
<h4 class="text-primary"><%= t(".exclude") %></h4>
<p class="text-secondary"><%= t(".exclude_description") %></p>
<%# Split children list for split parent %>
<% if @entry.split_parent? %>
<% dialog.with_section(title: t("splits.show.title"), open: true) do %>
<div class="px-3 py-2 space-y-2">
<p class="text-xs text-secondary"><%= t("splits.show.description") %></p>
<% @entry.child_entries.includes(:entryable).each do |child| %>
<div class="flex items-center justify-between p-2 rounded-lg border border-primary">
<div>
<p class="text-sm font-medium text-primary"><%= child.name %></p>
<p class="text-xs text-secondary"><%= child.entryable.try(:category)&.name || t("splits.new.uncategorized") %></p>
</div>
<p class="text-sm font-medium <%= child.amount.negative? ? "text-green-600" : "text-primary" %>">
<%= format_money(-child.amount_money) %>
</p>
</div>
<%= f.toggle :excluded, { data: { auto_submit_form_target: "auto" } } %>
<% end %>
<div class="flex items-center gap-3 pt-2">
<%= 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"
) %>
</div>
<% end %>
</div>
</div>
<% 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 %>
<div class="px-3 py-2">
<% parent = @entry.parent_entry %>
<% if parent %>
<div class="p-3 rounded-lg border border-secondary space-y-3">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-primary"><%= parent.name %></p>
<p class="text-xs text-secondary"><%= parent.date.strftime("%b %d, %Y") %></p>
</div>
<p class="text-sm font-medium text-primary">
<%= format_money(-parent.amount_money) %>
</p>
</div>
<div class="flex items-center gap-3 border-t border-primary pt-2">
<%= render DS::Link.new(
text: t("splits.child.edit_split"),
icon: "pencil",
variant: "ghost",
size: :sm,
href: edit_transaction_split_path(parent),
frame: :modal
) %>
<%= render DS::Button.new(
text: t("splits.child.unsplit"),
icon: "undo-2",
variant: "ghost",
size: :sm,
class: "text-destructive",
href: transaction_split_path(parent),
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"
) %>
</div>
</div>
<% end %>
</div>
<% end %>
<% end %>
<% dialog.with_section(title: t(".settings")) do %>
<% unless @entry.split_parent? %>
<div class="pb-4">
<%= styled_form_with model: @entry,
url: transaction_path(@entry),
class: "p-3",
data: { controller: "auto-submit-form" } do |f| %>
<div class="flex cursor-pointer items-center gap-4 justify-between">
<div class="text-sm space-y-1">
<h4 class="text-primary"><%= t(".exclude") %></h4>
<p class="text-secondary"><%= t(".exclude_description") %></p>
</div>
<%= f.toggle :excluded, { data: { auto_submit_form_target: "auto" } } %>
</div>
<% end %>
</div>
<% end %>
<% if @entry.account.investment? || @entry.account.crypto? %>
<div class="pb-4">
<%= styled_form_with model: @entry,
@@ -233,6 +319,22 @@
</div>
<% end %>
<% end %>
<%# Split Transaction %>
<% if @entry.transaction.splittable? %>
<div class="flex items-center justify-between gap-4 p-3">
<div class="text-sm space-y-1">
<h4 class="text-primary"><%= t("splits.show.button_title") %></h4>
<p class="text-secondary"><%= t("splits.show.button_description") %></p>
</div>
<%= render DS::Link.new(
text: t("splits.show.button"),
icon: "split",
variant: "outline",
href: new_transaction_split_path(@entry),
frame: :modal
) %>
</div>
<% end %>
<div class="flex items-center justify-between gap-4 p-3">
<div class="text-sm space-y-1">
<h4 class="text-primary">Transfer or Debt Payment?</h4>
@@ -294,21 +396,23 @@
frame: "_top"
) %>
</div>
<!-- Delete Transaction Form -->
<div class="flex items-center justify-between gap-2 p-3">
<div class="text-sm space-y-1">
<h4 class="text-primary"><%= t(".delete_title") %></h4>
<p class="text-secondary"><%= t(".delete_subtitle") %></p>
<%# Delete Transaction Form - hidden for split children %>
<% unless @entry.split_child? %>
<div class="flex items-center justify-between gap-2 p-3">
<div class="text-sm space-y-1">
<h4 class="text-primary"><%= t(".delete_title") %></h4>
<p class="text-secondary"><%= t(".delete_subtitle") %></p>
</div>
<%= render DS::Button.new(
text: t(".delete"),
variant: "outline-destructive",
href: entry_path(@entry),
method: :delete,
confirm: CustomConfirm.for_resource_deletion("transaction"),
frame: "_top"
) %>
</div>
<%= render DS::Button.new(
text: t(".delete"),
variant: "outline-destructive",
href: entry_path(@entry),
method: :delete,
confirm: CustomConfirm.for_resource_deletion("transaction"),
frame: "_top"
) %>
</div>
<% end %>
</div>
<% end %>
<% end %>

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

5
db/schema.rb generated
View File

@@ -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"

View File

@@ -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

View File

@@ -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