mirror of
https://github.com/we-promise/sure.git
synced 2026-05-29 15:34:58 +00:00
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:
95
app/controllers/splits_controller.rb
Normal file
95
app/controllers/splits_controller.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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?)
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
158
app/javascript/controllers/split_transaction_controller.js
Normal file
158
app/javascript/controllers/split_transaction_controller.js
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
7
app/models/transaction/splittable.rb
Normal file
7
app/models/transaction/splittable.rb
Normal 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
|
||||
87
app/views/splits/_category_select.html.erb
Normal file
87
app/views/splits/_category_select.html.erb
Normal 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>
|
||||
118
app/views/splits/edit.html.erb
Normal file
118
app/views/splits/edit.html.erb
Normal 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">·</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 %>
|
||||
109
app/views/splits/new.html.erb
Normal file
109
app/views/splits/new.html.erb
Normal 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">·</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 %>
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
47
config/locales/views/splits/en.yml
Normal file
47
config/locales/views/splits/en.yml
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
5
db/schema.rb
generated
@@ -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"
|
||||
|
||||
212
test/controllers/splits_controller_test.rb
Normal file
212
test/controllers/splits_controller_test.rb
Normal 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
|
||||
177
test/models/entry_split_test.rb
Normal file
177
test/models/entry_split_test.rb
Normal 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
|
||||
Reference in New Issue
Block a user