1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-10 16:05:22 +02:00

Separate exclude and one-time transaction handling

- Split transaction "exclude" and "one-time" toggles into separate controls in transaction detail view
- Updated Transaction::Search to show excluded transactions with grayed-out styling instead of filtering them out
- Modified IncomeStatement calculations to exclude both excluded and one_time transactions from totals
- Added migration to convert existing excluded transactions to also be one_time for backward compatibility
- Updated transaction list view to show asterisk for one_time transactions and gray out excluded ones
- Added controller support for kind parameter in transaction updates

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Zach Gollwitzer 2025-06-20 16:53:20 -04:00
parent c003e8c6ed
commit b61e56d696
11 changed files with 80 additions and 48 deletions

View file

@ -133,7 +133,7 @@ class TransactionsController < ApplicationController
def entry_params
entry_params = params.require(:entry).permit(
:name, :date, :amount, :currency, :excluded, :notes, :nature, :entryable_type,
entryable_attributes: [ :id, :category_id, :merchant_id, { tag_ids: [] } ]
entryable_attributes: [ :id, :category_id, :merchant_id, :kind, { tag_ids: [] } ]
)
nature = entry_params.delete(:nature)
@ -150,7 +150,7 @@ class TransactionsController < ApplicationController
cleaned_params = params.fetch(:q, {})
.permit(
:start_date, :end_date, :search, :amount,
:amount_operator, :active_accounts_only, :excluded_transactions,
:amount_operator, :active_accounts_only,
accounts: [], account_ids: [],
categories: [], merchants: [], types: [], tags: []
)

View file

@ -48,6 +48,7 @@ class IncomeStatement::CategoryStats
)
WHERE a.family_id = :family_id
AND t.kind NOT IN ('funds_movement', 'one_time', 'cc_payment')
AND ae.excluded = false
GROUP BY c.id, period, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END
)
SELECT

View file

@ -44,7 +44,8 @@ class IncomeStatement::FamilyStats
er.to_currency = :target_currency
)
WHERE a.family_id = :family_id
AND t.kind NOT IN ('funds_movement', 'one_time', 'cc_payment')
AND t.kind NOT IN ('funds_movement', 'one_time', 'cc_payment')
AND ae.excluded = false
GROUP BY period, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END
)
SELECT

View file

@ -45,6 +45,7 @@ class IncomeStatement::Totals
er.to_currency = :target_currency
)
WHERE at.kind NOT IN ('funds_movement', 'one_time', 'cc_payment')
AND ae.excluded = false
GROUP BY c.id, c.parent_id, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END;
SQL
end

View file

@ -14,7 +14,6 @@ class Transaction::Search
attribute :merchants, array: true
attribute :tags, array: true
attribute :active_accounts_only, :boolean, default: true
attribute :excluded_transactions, :boolean, default: false
attr_reader :family
@ -29,7 +28,6 @@ class Transaction::Search
query = family.transactions
query = apply_active_accounts_filter(query, active_accounts_only)
query = apply_excluded_transactions_filter(query, excluded_transactions)
query = apply_category_filter(query, categories)
query = apply_type_filter(query, types)
query = apply_merchant_filter(query, merchants)
@ -89,13 +87,6 @@ class Transaction::Search
end
end
def apply_excluded_transactions_filter(query, excluded_transactions_filter)
unless excluded_transactions_filter
query.where(entries: { excluded: false })
else
query
end
end
def apply_category_filter(query, categories)
return query unless categories.present?

View file

@ -6,7 +6,8 @@
<%= turbo_frame_tag dom_id(transaction) do %>
<div class="grid grid-cols-12 items-center text-primary text-sm font-medium p-4 lg:p-4
<%= @focused_record == entry || @focused_record == transaction ?
"border border-gray-900 rounded-lg" : "" %>">
"border border-gray-900 rounded-lg" : "" %>
<%= entry.excluded ? "opacity-50 text-gray-400" : "" %>">
<div class="pr-4 lg:pr-10 flex items-center gap-3 lg:gap-4 col-span-8 <%= view_ctx == 'global' ? 'lg:col-span-8' : 'lg:col-span-6' %>">
<%= check_box_tag dom_id(entry, "selection"),
@ -61,7 +62,7 @@
</div>
<div class="flex items-center gap-1 flex-shrink-0">
<% if entry.excluded %>
<% if transaction.one_time? %>
<span class="text-orange-500" title="One-time <%= entry.amount.negative? ? "income" : "expense" %> (excluded from averages)">
<%= icon "asterisk", size: "sm", color: "current" %>
</span>

View file

@ -106,13 +106,34 @@
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">One-time <%= @entry.amount.negative? ? "Income" : "Expense" %></h4>
<p class="text-secondary">One-time transactions will be excluded from certain budgeting calculations and reports to help you see what's really important.</p>
<h4 class="text-primary">Exclude</h4>
<p class="text-secondary">Excluded transactions will be removed from budgeting calculations and reports.</p>
</div>
<%= f.toggle :excluded, { data: { auto_submit_form_target: "auto" } } %>
</div>
<% end %>
</div>
<div class="pb-4">
<%= styled_form_with model: @entry,
url: transaction_path(@entry),
class: "p-3",
data: { controller: "auto-submit-form" } do |f| %>
<%= f.fields_for :entryable do |ef| %>
<div class="flex cursor-pointer items-center gap-4 justify-between">
<div class="text-sm space-y-1">
<h4 class="text-primary">One-time <%= @entry.amount.negative? ? "Income" : "Expense" %></h4>
<p class="text-secondary">One-time transactions will be excluded from certain budgeting calculations and reports to help you see what's really important.</p>
</div>
<%= ef.toggle :kind, {
checked: @entry.transaction.one_time?,
data: { auto_submit_form_target: "auto" }
}, "one_time", "standard" %>
</div>
<% end %>
<% end %>
<div class="flex items-center justify-between gap-4 p-3">
<div class="text-sm space-y-1">

View file

@ -0,0 +1,33 @@
class UpdateExcludedTransactionsToOneTime < ActiveRecord::Migration[7.2]
def change
reversible do |dir|
dir.up do
# Update all transactions that have excluded entries to be one_time
# They remain excluded as well since users were using excluded as "one time" before
execute <<~SQL
UPDATE transactions
SET kind = 'one_time'
FROM entries
WHERE entries.entryable_id = transactions.id
AND entries.entryable_type = 'Transaction'
AND entries.excluded = true
AND transactions.kind = 'standard'
SQL
end
dir.down do
# Revert one_time transactions back to standard if their entry is excluded
# This assumes these were the ones we migrated in the up method
execute <<~SQL
UPDATE transactions
SET kind = 'standard'
FROM entries
WHERE entries.entryable_id = transactions.id
AND entries.entryable_type = 'Transaction'
AND entries.excluded = true
AND transactions.kind = 'one_time'
SQL
end
end
end
end

2
db/schema.rb generated
View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2025_06_18_120703) do
ActiveRecord::Schema[7.2].define(version: 2025_06_20_204550) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"

View file

@ -204,6 +204,20 @@ class IncomeStatementTest < ActiveSupport::TestCase
assert_equal Money.new(900, @family.currency), totals.expense_money
end
test "excludes excluded transactions from income statement calculations" do
# Create an excluded transaction
excluded_transaction_entry = create_transaction(account: @checking_account, amount: 250, category: @groceries_category)
excluded_transaction_entry.update!(excluded: true)
income_statement = IncomeStatement.new(@family)
totals = income_statement.totals
# Should exclude excluded transactions
assert_equal 4, totals.transactions_count # Only original 4 transactions
assert_equal Money.new(1000, @family.currency), totals.income_money
assert_equal Money.new(900, @family.currency), totals.expense_money
end
# NEW TESTS: Interval-Based Calculations
test "different intervals return different statistical results with multi-period data" do
# Clear existing transactions

View file

@ -298,35 +298,4 @@ class Transaction::SearchTest < ActiveSupport::TestCase
assert_equal Money.new(0, "USD"), totals.expense_money
assert_equal Money.new(0, "USD"), totals.income_money
end
test "totals respects excluded transactions filter from search" do
# Create an excluded transaction (should be excluded by default)
excluded_entry = create_transaction(
account: @checking_account,
amount: 100,
kind: "standard"
)
excluded_entry.update!(excluded: true) # Marks it as excluded
# Create a normal transaction
normal_entry = create_transaction(
account: @checking_account,
amount: 50,
kind: "standard"
)
# Default behavior should exclude excluded transactions
search = Transaction::Search.new(@family)
totals = search.totals
assert_equal 1, totals.count
assert_equal Money.new(50, "USD"), totals.expense_money # Only non-excluded transaction
# Explicitly include excluded transactions
search_with_excluded = Transaction::Search.new(@family, filters: { excluded_transactions: true })
totals_with_excluded = search_with_excluded.totals
assert_equal 2, totals_with_excluded.count
assert_equal Money.new(150, "USD"), totals_with_excluded.expense_money # Both transactions
end
end