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:
parent
c003e8c6ed
commit
b61e56d696
11 changed files with 80 additions and 48 deletions
|
@ -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: []
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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
2
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: 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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue