diff --git a/app/controllers/splits_controller.rb b/app/controllers/splits_controller.rb new file mode 100644 index 000000000..3fab45a1b --- /dev/null +++ b/app/controllers/splits_controller.rb @@ -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 diff --git a/app/controllers/transactions/bulk_deletions_controller.rb b/app/controllers/transactions/bulk_deletions_controller.rb index fefaf389f..99951d26d 100644 --- a/app/controllers/transactions/bulk_deletions_controller.rb +++ b/app/controllers/transactions/bulk_deletions_controller.rb @@ -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 diff --git a/app/controllers/transactions/bulk_updates_controller.rb b/app/controllers/transactions/bulk_updates_controller.rb index 8a115e58d..82d4c6ddf 100644 --- a/app/controllers/transactions/bulk_updates_controller.rb +++ b/app/controllers/transactions/bulk_updates_controller.rb @@ -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?) diff --git a/app/javascript/controllers/category_badge_select_controller.js b/app/javascript/controllers/category_badge_select_controller.js new file mode 100644 index 000000000..f9484f6e8 --- /dev/null +++ b/app/javascript/controllers/category_badge_select_controller.js @@ -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() + } + } +} diff --git a/app/javascript/controllers/split_transaction_controller.js b/app/javascript/controllers/split_transaction_controller.js new file mode 100644 index 000000000..e338368af --- /dev/null +++ b/app/javascript/controllers/split_transaction_controller.js @@ -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 = ` +
+
+ + +
+
+ + +
+ ${categorySelectHTML} + +
+ ` + + 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 + } +} diff --git a/app/models/balance/sync_cache.rb b/app/models/balance/sync_cache.rb index aed2b64e7..e13be7b96 100644 --- a/app/models/balance/sync_cache.rb +++ b/app/models/balance/sync_cache.rb @@ -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, diff --git a/app/models/entry.rb b/app/models/entry.rb index 3812e3d58..2ebb7d3d1 100644 --- a/app/models/entry.rb +++ b/app/models/entry.rb @@ -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] array of { name:, amount:, category_id: } hashes + # @return [Array] 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 diff --git a/app/models/family/data_exporter.rb b/app/models/family/data_exporter.rb index 3eacd9fd2..dc0ffae13 100644 --- a/app/models/family/data_exporter.rb +++ b/app/models/family/data_exporter.rb @@ -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: { diff --git a/app/models/rule/registry/transaction_resource.rb b/app/models/rule/registry/transaction_resource.rb index fb4847728..ee697927c 100644 --- a/app/models/rule/registry/transaction_resource.rb +++ b/app/models/rule/registry/transaction_resource.rb @@ -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 diff --git a/app/models/transaction.rb b/app/models/transaction.rb index cabe4cede..75e36bbdc 100644 --- a/app/models/transaction.rb +++ b/app/models/transaction.rb @@ -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 diff --git a/app/models/transaction/search.rb b/app/models/transaction/search.rb index 3ea2a2391..5a9cf441e 100644 --- a/app/models/transaction/search.rb +++ b/app/models/transaction/search.rb @@ -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) diff --git a/app/models/transaction/splittable.rb b/app/models/transaction/splittable.rb new file mode 100644 index 000000000..032fd6624 --- /dev/null +++ b/app/models/transaction/splittable.rb @@ -0,0 +1,7 @@ +module Transaction::Splittable + extend ActiveSupport::Concern + + def splittable? + !transfer? && !entry.split_child? && !entry.split_parent? && !pending? && !entry.excluded? + end +end diff --git a/app/views/splits/_category_select.html.erb b/app/views/splits/_category_select.html.erb new file mode 100644 index 000000000..17e1ea75c --- /dev/null +++ b/app/views/splits/_category_select.html.erb @@ -0,0 +1,87 @@ +<%# locals: (name:, categories:, selected_id: nil) %> +<% + selected_category = categories.find { |c| c.id == selected_id } + default_color = "#737373" +%> + +
+ + + + +
diff --git a/app/views/splits/edit.html.erb b/app/views/splits/edit.html.erb new file mode 100644 index 000000000..a762df288 --- /dev/null +++ b/app/views/splits/edit.html.erb @@ -0,0 +1,118 @@ +<%= render DS::Dialog.new(variant: "modal") do |dialog| %> + <% dialog.with_header do %> +
+
+

<%= @entry.name %>

+

+ <%= @entry.date.strftime("%b %d, %Y") %> + <% if (category = @entry.entryable.try(:category)) %> + · + <%= icon category.lucide_icon, size: "xs", color: "current" %> + <%= category.name %> + <% end %> +

+
+
+

<%= t("splits.new.original_amount") %>

+

<%= format_money(-@entry.amount_money) %>

+
+
+ <% 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 %> +
+ <% @children.each_with_index do |child, index| %> +
+
+
+ + " + 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"> +
+
+ + +
+ <%= render "splits/category_select", + name: "split[splits][#{index}][category_id]", + categories: @categories, + selected_id: child.entryable.try(:category_id) %> + +
+
+ <% end %> +
+ + <%# Add split button %> + + + <%# Remaining balance indicator %> +
+
+ <%= t("splits.new.remaining") %> + + <%= (-@entry.amount).to_f %> + +
+ +
+ + <%# Actions %> +
+ <%= 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" } + ) %> +
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/splits/new.html.erb b/app/views/splits/new.html.erb new file mode 100644 index 000000000..8aae475b1 --- /dev/null +++ b/app/views/splits/new.html.erb @@ -0,0 +1,109 @@ +<%= render DS::Dialog.new(variant: "modal") do |dialog| %> + <% dialog.with_header do %> +
+
+

<%= @entry.name %>

+

+ <%= @entry.date.strftime("%b %d, %Y") %> + <% if (category = @entry.entryable.try(:category)) %> + · + <%= icon category.lucide_icon, size: "xs", color: "current" %> + <%= category.name %> + <% end %> +

+
+
+

<%= t("splits.new.original_amount") %>

+

<%= format_money(-@entry.amount_money) %>

+
+
+ <% 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 %> +
+
+
+
+ + " + 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"> +
+
+ + +
+ <%= render "splits/category_select", + name: "split[splits][0][category_id]", + categories: @categories, + selected_id: nil %> +
+
+
+
+ + <%# Add split button %> + + + <%# Remaining balance indicator %> +
+
+ <%= t("splits.new.remaining") %> + + <%= (-@entry.amount).to_f %> + +
+

+ <%= t("splits.new.amounts_must_match") %> +

+
+ + <%# Actions %> +
+ <%= 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" } + ) %> +
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/transactions/_transaction.html.erb b/app/views/transactions/_transaction.html.erb index d7993c669..62a68ca9c 100644 --- a/app/views/transactions/_transaction.html.erb +++ b/app/views/transactions/_transaction.html.erb @@ -101,6 +101,19 @@ <% end %> <% end %> + <%# Split indicator %> + <% if entry.split_parent? %> + "> + <%= icon "split", size: "sm", color: "current" %> + <%= t("transactions.transaction.split") %> + + <% end %> + <% if entry.split_child? %> + "> + <%= icon "corner-down-right", size: "sm", color: "current" %> + + <% end %> + <% if transaction.transfer.present? %> <%= render "transactions/transfer_match", transaction: transaction %> <% end %> diff --git a/app/views/transactions/show.html.erb b/app/views/transactions/show.html.erb index 4f520f976..a42486d87 100644 --- a/app/views/transactions/show.html.erb +++ b/app/views/transactions/show.html.erb @@ -175,21 +175,107 @@ <% end %> <% end %> - <% dialog.with_section(title: t(".settings")) do %> -
- <%= styled_form_with model: @entry, - url: transaction_path(@entry), - class: "p-3", - data: { controller: "auto-submit-form" } do |f| %> -
-
-

<%= 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 %> +
+ <% parent = @entry.parent_entry %> + <% if parent %> +
+
+
+

<%= parent.name %>

+

<%= parent.date.strftime("%b %d, %Y") %>

+
+

+ <%= format_money(-parent.amount_money) %> +

+
+
+ <%= 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" + ) %> +
+
+ <% end %> +
+ <% end %> + <% end %> + + <% dialog.with_section(title: t(".settings")) do %> + <% unless @entry.split_parent? %> +
+ <%= styled_form_with model: @entry, + url: transaction_path(@entry), + class: "p-3", + data: { controller: "auto-submit-form" } do |f| %> +
+
+

<%= t(".exclude") %>

+

<%= t(".exclude_description") %>

+
+ <%= f.toggle :excluded, { data: { auto_submit_form_target: "auto" } } %> +
+ <% end %> +
+ <% 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? %> +
+
+

<%= t("splits.show.button_title") %>

+

<%= t("splits.show.button_description") %>

+
+ <%= render DS::Link.new( + text: t("splits.show.button"), + icon: "split", + variant: "outline", + href: new_transaction_split_path(@entry), + frame: :modal + ) %> +
+ <% end %>

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