Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions app/controllers/splits_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ def create
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 }
{ name: s[:name], amount: s[:amount].to_d * -1, category_id: s[:category_id].presence, excluded: s[:excluded] }
end

@entry.split!(splits)
Expand Down Expand Up @@ -51,7 +51,7 @@ def update
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 }
{ name: s[:name], amount: s[:amount].to_d * -1, category_id: s[:category_id].presence, excluded: s[:excluded] }
end

Entry.transaction do
Expand Down Expand Up @@ -95,6 +95,6 @@ def resolve_to_parent!
end

def split_params
params.require(:split).permit(splits: [ :name, :amount, :category_id ])
params.require(:split).permit(splits: [ :name, :amount, :category_id, :excluded ])
end
end
5 changes: 3 additions & 2 deletions app/controllers/transaction_categories_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,20 @@ def update
transaction.lock_saved_attributes!
@entry.lock_saved_attributes!

in_split_group = helpers.in_split_group?(@entry, params[:grouped])
respond_to do |format|
format.html { redirect_back_or_to transaction_path(@entry) }
format.turbo_stream do
render turbo_stream: [
turbo_stream.replace(
dom_id(transaction, "category_menu_mobile"),
partial: "transactions/transaction_category",
locals: { transaction: transaction, variant: "mobile" }
locals: { transaction: transaction, variant: "mobile", in_split_group: in_split_group }
),
turbo_stream.replace(
dom_id(transaction, "category_menu_desktop"),
partial: "transactions/transaction_category",
locals: { transaction: transaction, variant: "desktop" }
locals: { transaction: transaction, variant: "desktop", in_split_group: in_split_group }
),
turbo_stream.replace(
"category_name_mobile_#{transaction.id}",
Expand Down
7 changes: 6 additions & 1 deletion app/controllers/transactions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ def update
respond_to do |format|
format.html { redirect_back_or_to account_path(@entry.account), notice: "Transaction updated" }
format.turbo_stream do
in_split_group = helpers.in_split_group?(@entry, params[:grouped])
render turbo_stream: [
turbo_stream.replace(
dom_id(@entry, :header),
Expand All @@ -158,7 +159,11 @@ def update
partial: "transactions/notes",
locals: { entry: @entry, can_annotate: can_annotate_entry? }
) if params[:entry]&.key?(:notes) && notes_changed),
turbo_stream.replace(@entry),
turbo_stream.replace(
dom_id(@entry),
partial: "entries/entry",
locals: { entry: @entry, in_split_group: in_split_group }
),
*flash_notification_stream_items
].compact
end
Expand Down
4 changes: 4 additions & 0 deletions app/helpers/transactions_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ def get_default_transaction_search_filter
transaction_search_filters[0]
end

def in_split_group?(entry, params_grouped)
entry.split_child? && Current.user.show_split_grouped? && params_grouped == "true"
end

# ---- Transaction extra details helpers ----
# Returns a structured hash describing extra details for a transaction.
# Input can be a Transaction or an Entry (responds_to :transaction).
Expand Down
6 changes: 5 additions & 1 deletion app/models/entry.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
class Entry < ApplicationRecord
include Monetizable, Enrichable

TRUTHY_VALUES = [ true, "true", "1", 1 ].freeze
private_constant :TRUTHY_VALUES

attr_accessor :unsplitting

monetize :amount
Expand Down Expand Up @@ -370,7 +373,7 @@ def split_child?

# Splits this entry into child entries. Marks parent as excluded.
#
# @param splits [Array<Hash>] array of { name:, amount:, category_id: } hashes
# @param splits [Array<Hash>] array of { name:, amount:, category_id:, excluded: } hashes
# @return [Array<Entry>] the created child entries
def split!(splits)
total = splits.sum { |s| s[:amount].to_d }
Expand All @@ -392,6 +395,7 @@ def split!(splits)
name: split_attrs[:name],
amount: split_attrs[:amount],
currency: currency,
excluded: TRUTHY_VALUES.include?(split_attrs[:excluded]),
entryable: child_transaction
)
end
Expand Down
4 changes: 2 additions & 2 deletions app/views/categories/_menu.html.erb
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<%# locals: (transaction:) %>
<%# locals: (transaction:, in_split_group: false) %>

<%= render DS::Menu.new(variant: "button") do |menu| %>
<% menu.with_button(class: "block w-full overflow-hidden") do %>
Expand All @@ -11,7 +11,7 @@
<% end %>

<% menu.with_custom_content do %>
<%= turbo_frame_tag "category_dropdown", src: category_dropdown_path(category_id: transaction.category_id, transaction_id: transaction.id), loading: :lazy do %>
<%= turbo_frame_tag "category_dropdown", src: category_dropdown_path(category_id: transaction.category_id, transaction_id: transaction.id, grouped: in_split_group), loading: :lazy do %>
<div class="p-6 flex items-center justify-center">
<p class="text-sm text-secondary animate-pulse"><%= t(".loading") %></p>
</div>
Expand Down
1 change: 1 addition & 0 deletions app/views/category/dropdowns/_row.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
data: { filter_name: category.name } do %>
<%= button_to transaction_category_path(
@transaction.entry,
grouped: params[:grouped],
entry: {
entryable_type: "Transaction",
entryable_attributes: { id: @transaction.id, category_id: category.id }
Expand Down
3 changes: 2 additions & 1 deletion app/views/category/dropdowns/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
<%= button_to transaction_path(@transaction.entry),
method: :patch,
data: { turbo_frame: dom_id(@transaction.entry) },
params: { entry: { entryable_type: "Transaction", entryable_attributes: { id: @transaction.id, category_id: nil } } },
params: { grouped: params[:grouped], entry: { entryable_type: "Transaction", entryable_attributes: { id: @transaction.id, category_id: nil } } },
class: "flex text-sm font-medium items-center gap-2 text-secondary w-full rounded-lg p-2 hover:bg-container-inset-hover" do %>
<%= icon("minus") %>

Expand Down Expand Up @@ -85,6 +85,7 @@
method: :patch,
data: { controller: "auto-submit-form" } do |f| %>
<%= f.hidden_field "entry[excluded]", value: !@transaction.entry.excluded %>
<%= hidden_field_tag :grouped, "true" if params[:grouped] == "true" %>
<%= f.check_box "entry[excluded]",
checked: @transaction.entry.excluded,
class: "checkbox checkbox--light",
Expand Down
4 changes: 2 additions & 2 deletions app/views/entries/_entry.html.erb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<%# locals: (entry:, balance_trend: nil, view_ctx: "global") %>
<%# locals: (entry:, balance_trend: nil, view_ctx: "global", in_split_group: false) %>

<% if entry.entryable.present? %>
<%= render partial: entry.entryable.to_partial_path,
locals: { entry: entry, balance_trend: balance_trend, view_ctx: view_ctx } %>
locals: { entry: entry, balance_trend: balance_trend, view_ctx: view_ctx, in_split_group: in_split_group } %>
<% end %>
1 change: 1 addition & 0 deletions app/views/transactions/_notes.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<%= styled_form_with model: entry,
url: transaction_path(entry),
data: { controller: "auto-submit-form" } do |f| %>
<%= hidden_field_tag :grouped, "true" if params[:grouped] == "true" %>
<%= f.text_area :notes,
label: t("transactions.show.note_label"),
placeholder: t("transactions.show.note_placeholder"),
Expand Down
6 changes: 3 additions & 3 deletions app/views/transactions/_transaction.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
} %>

<div class="flex md:hidden items-center gap-1 col-span-2 relative shrink-0">
<%= render "transactions/transaction_category", transaction: transaction, variant: "mobile" %>
<%= render "transactions/transaction_category", transaction: transaction, variant: "mobile", in_split_group: in_split_group %>
<% if transaction.merchant&.logo_url.present? %>
<%= image_tag Setting.transform_brand_fetch_url(transaction.merchant.logo_url),
class: "w-5 h-5 rounded-full absolute -bottom-1 -right-1 border border-secondary pointer-events-none",
Expand Down Expand Up @@ -70,7 +70,7 @@
<% else %>
<%= link_to(
entry.name,
entry_path(entry),
in_split_group ? entry_path(entry, grouped: true) : entry_path(entry),
data: {
turbo_frame: "drawer",
turbo_prefetch: false
Expand Down Expand Up @@ -163,7 +163,7 @@
<%# For investment accounts, show activity label instead of category %>
<%= render "investment_activity/quick_edit_badge", entry: entry, entryable: transaction %>
<% else %>
<%= render "transactions/transaction_category", transaction: transaction, variant: "desktop" %>
<%= render "transactions/transaction_category", transaction: transaction, variant: "desktop", in_split_group: in_split_group %>
<% end %>
</div>

Expand Down
4 changes: 2 additions & 2 deletions app/views/transactions/_transaction_category.html.erb
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
<%# locals: (transaction:, variant:) %>
<%# locals: (transaction:, variant:, in_split_group: false) %>

<div id="<%= dom_id(transaction, "category_menu_#{variant}") %>" class="min-w-0 overflow-hidden">
<% if transaction.transfer&.categorizable? || transaction.transfer.nil? %>
<%= render "categories/menu", transaction: transaction %>
<%= render "categories/menu", transaction: transaction, in_split_group: in_split_group %>
<% else %>
<div class="hidden lg:flex">
<%= render "categories/badge", category: transaction.transfer&.payment? ? payment_category : transfer_category %>
Expand Down
7 changes: 6 additions & 1 deletion app/views/transactions/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
url: transaction_path(@entry),
class: "space-y-2",
data: { controller: "auto-submit-form" } do |f| %>
<%= hidden_field_tag :grouped, "true" if params[:grouped] == "true" %>
<%= f.text_field :name,
label: t(".name_label"),
disabled: @entry.split_child? || edit_locked,
Expand Down Expand Up @@ -95,6 +96,7 @@
url: transaction_path(@entry),
class: "space-y-2",
data: { controller: "auto-submit-form" } do |f| %>
<%= hidden_field_tag :grouped, "true" if params[:grouped] == "true" %>
<% unless @entry.transaction.transfer? %>
<%= f.select :account,
options_for_select(
Expand Down Expand Up @@ -262,12 +264,13 @@

<% if can_edit_entry? %>
<% dialog.with_section(title: t(".settings")) do %>
<% unless @entry.split_parent? || @entry.split_child? %>
<% 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| %>
<%= hidden_field_tag :grouped, "true" if params[:grouped] == "true" %>
<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>
Expand All @@ -284,6 +287,7 @@
url: transaction_path(@entry),
class: "p-3",
data: { controller: "auto-submit-form" } do |f| %>
<%= hidden_field_tag :grouped, "true" if params[:grouped] == "true" %>
<%= f.fields_for :entryable do |ef| %>
<div class="flex cursor-pointer items-center gap-4 justify-between">
<div class="text-sm space-y-1">
Expand All @@ -309,6 +313,7 @@
url: transaction_path(@entry),
class: "p-3",
data: { controller: "auto-submit-form" } do |f| %>
<%= hidden_field_tag :grouped, "true" if params[:grouped] == "true" %>
<%= f.fields_for :entryable do |ef| %>
<div class="flex cursor-pointer items-center gap-4 justify-between">
<div class="text-sm space-y-1">
Expand Down
41 changes: 41 additions & 0 deletions test/controllers/splits_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,26 @@ class SplitsControllerTest < ActionDispatch::IntegrationTest
assert_response :success
end

test "create with excluded parameter sets child as excluded" do
assert_difference "Entry.count", 2 do
post transaction_split_path(@entry), params: {
split: {
splits: [
{ name: "Groceries", amount: "-70", category_id: categories(:food_and_drink).id, excluded: "true" },
{ name: "Household", amount: "-30", category_id: "", excluded: "false" }
]
}
}
end

assert_redirected_to transactions_url
children = @entry.child_entries.order(:amount)
# Household has amount 30 (smaller), Groceries has amount 70 (larger)
# Household is NOT excluded, Groceries IS excluded
refute children.first.excluded?
assert children.last.excluded?
end

# Edit action tests
test "edit renders with existing children pre-filled" do
@entry.split!([
Expand Down Expand Up @@ -193,6 +213,27 @@ class SplitsControllerTest < ActionDispatch::IntegrationTest
assert_equal 2, @entry.reload.child_entries.count
end

test "update with excluded parameter sets child as excluded" do
@entry.split!([
{ name: "Groceries", amount: 70, category_id: nil },
{ name: "Household", amount: 30, category_id: nil }
])

patch transaction_split_path(@entry), params: {
split: {
splits: [
{ name: "Groceries", amount: "-70", category_id: "", excluded: "true" },
{ name: "Household", amount: "-30", category_id: "", excluded: "false" }
]
}
}

assert_redirected_to transactions_url
children = @entry.child_entries.order(:amount)
refute children.first.excluded?
assert children.last.excluded?
end

# Destroy from child tests
test "destroy from child resolves to parent and unsplits" do
@entry.split!([
Expand Down
47 changes: 47 additions & 0 deletions test/models/entry_split_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -174,4 +174,51 @@ class EntrySplitTest < ActiveSupport::TestCase
assert children.first.split_child?
refute @entry.split_child?
end

test "split! creates child entries with excluded: true when specified" do
splits = [
{ name: "Part 1", amount: 50, category_id: nil, excluded: true },
{ name: "Part 2", amount: 50, category_id: nil, excluded: false }
]

children = @entry.split!(splits)

assert_equal 2, children.size
assert children.first.excluded?
refute children.last.excluded?
end

test "split! properly casts excluded from string values" do
splits = [
{ name: "Part 1", amount: 50, category_id: nil, excluded: "true" },
{ name: "Part 2", amount: 50, category_id: nil, excluded: "false" }
]

children = @entry.split!(splits)

assert children.first.excluded?
refute children.last.excluded?
end

test "excluded split children are excluded from balance calculations" do
@entry.split!([
{ name: "Part 1", amount: 50, category_id: nil, excluded: true },
{ name: "Part 2", amount: 50, category_id: nil, excluded: false }
])

# Parent is always excluded for splits
assert @entry.reload.excluded?

# Excluded child should be filtered out by where(excluded: false)
excluded_child = @entry.child_entries.find { |c| c.name == "Part 1" }
non_excluded_child = @entry.child_entries.find { |c| c.name == "Part 2" }

assert excluded_child.excluded?
refute non_excluded_child.excluded?

# where(excluded: false) should only include the non-excluded child
visible_entries = Entry.where(id: @entry.child_entries.map(&:id)).where(excluded: false)
assert_includes visible_entries.pluck(:id), non_excluded_child.id
refute_includes visible_entries.pluck(:id), excluded_child.id
end
end
Loading