Show inflow/outflow totals for transfer filter (#1134)

* Show inflow/outflow totals when filtering by transfers

When filtering transactions by "Transfer" type, the summary bar previously
showed $0 for both Income and Expenses because transfers were excluded from
those sums. Now computes transfer inflow/outflow in the same SQL pass and
switches labels to "Inflow"/"Outflow" when transfer amounts are non-zero.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add mixed filter comment and transfer-only test coverage

Document the intentional mixed filter behavior where transfer amounts
are excluded from the summary bar when non-transfer types are present.
Add test exercising Inflow/Outflow label switching for transfer-only results.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Juan Manuel Reyes
2026-03-26 13:12:01 -07:00
committed by GitHub
parent f1991eaefe
commit f42b593b9e
4 changed files with 79 additions and 14 deletions

View File

@@ -52,7 +52,7 @@ class Transaction::Search
# because those transactions are retirement savings, not daily income/expenses.
def totals
@totals ||= begin
Rails.cache.fetch("transaction_search_totals/#{cache_key_base}") do
Rails.cache.fetch("transaction_search_totals/v2/#{cache_key_base}") do
scope = transactions_scope
# Exclude tax-advantaged accounts from totals calculation
@@ -69,6 +69,14 @@ class Transaction::Search
"COALESCE(SUM(CASE WHEN entries.amount < 0 AND transactions.kind NOT IN (?) THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as income_total",
Transaction::TRANSFER_KINDS
]),
ActiveRecord::Base.sanitize_sql_array([
"COALESCE(SUM(CASE WHEN entries.amount < 0 AND transactions.kind IN (?) THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as transfer_inflow_total",
Transaction::TRANSFER_KINDS
]),
ActiveRecord::Base.sanitize_sql_array([
"COALESCE(SUM(CASE WHEN entries.amount >= 0 AND transactions.kind IN (?) THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as transfer_outflow_total",
Transaction::TRANSFER_KINDS
]),
"COUNT(entries.id) as transactions_count"
)
.joins(
@@ -82,7 +90,9 @@ class Transaction::Search
Totals.new(
count: result.transactions_count.to_i,
income_money: Money.new(result.income_total, family.currency),
expense_money: Money.new(result.expense_total, family.currency)
expense_money: Money.new(result.expense_total, family.currency),
transfer_inflow_money: Money.new(result.transfer_inflow_total, family.currency),
transfer_outflow_money: Money.new(result.transfer_outflow_total, family.currency)
)
end
end
@@ -99,7 +109,7 @@ class Transaction::Search
end
private
Totals = Data.define(:count, :income_money, :expense_money)
Totals = Data.define(:count, :income_money, :expense_money, :transfer_inflow_money, :transfer_outflow_money)
def apply_active_accounts_filter(query, active_accounts_only_filter)
if active_accounts_only_filter

View File

@@ -1,19 +1,39 @@
<%# locals: (totals:) %>
<%# Show Inflow/Outflow labels only when the result set contains exclusively transfers
(income and expense are both $0). For mixed filters (e.g. Expense+Transfer),
we keep Income/Expenses labels — transfer amounts aren't included in the summary
bar in that case, though the transaction list still shows both types. %>
<% show_transfers = totals.income_money.zero? && totals.expense_money.zero? &&
(totals.transfer_inflow_money.amount > 0 || totals.transfer_outflow_money.amount > 0) %>
<div class="grid grid-cols-1 md:grid-cols-3 bg-container rounded-xl shadow-border-xs md:divide-x divide-y md:divide-y-0 divide-alpha-black-100 theme-dark:divide-alpha-white-200">
<div class="p-4 space-y-2">
<p class="text-sm text-secondary">Total transactions</p>
<p class="text-sm text-secondary"><%= t("transactions.summary.total_transactions") %></p>
<p class="text-primary font-medium text-xl privacy-sensitive" id="total-transactions"><%= totals.count.round(0) %></p>
</div>
<div class="p-4 space-y-2">
<p class="text-sm text-secondary">Income</p>
<p class="text-primary font-medium text-xl privacy-sensitive" id="total-income">
<%= totals.income_money.format %>
</p>
<% if show_transfers %>
<p class="text-sm text-secondary"><%= t("transactions.summary.inflow") %></p>
<p class="text-primary font-medium text-xl privacy-sensitive" id="total-income">
<%= (totals.income_money + totals.transfer_inflow_money).format %>
</p>
<% else %>
<p class="text-sm text-secondary"><%= t("transactions.summary.income") %></p>
<p class="text-primary font-medium text-xl privacy-sensitive" id="total-income">
<%= totals.income_money.format %>
</p>
<% end %>
</div>
<div class="p-4 space-y-2">
<p class="text-sm text-secondary">Expenses</p>
<p class="text-primary font-medium text-xl privacy-sensitive" id="total-expense">
<%= totals.expense_money.format %>
</p>
<% if show_transfers %>
<p class="text-sm text-secondary"><%= t("transactions.summary.outflow") %></p>
<p class="text-primary font-medium text-xl privacy-sensitive" id="total-expense">
<%= (totals.expense_money + totals.transfer_outflow_money).format %>
</p>
<% else %>
<p class="text-sm text-secondary"><%= t("transactions.summary.expenses") %></p>
<p class="text-primary font-medium text-xl privacy-sensitive" id="total-expense">
<%= totals.expense_money.format %>
</p>
<% end %>
</div>
</div>

View File

@@ -108,6 +108,12 @@ en:
review_recommended_short: Rev
confirm_title: "Merge with posted transaction (%{posted_amount})"
reject_title: Keep as separate transactions
summary:
total_transactions: Total transactions
income: Income
expenses: Expenses
inflow: Inflow
outflow: Outflow
header:
edit_categories: Edit categories
edit_imports: Edit imports

View File

@@ -159,7 +159,9 @@ end
totals = OpenStruct.new(
count: 1,
expense_money: Money.new(10000, "USD"),
income_money: Money.new(0, "USD")
income_money: Money.new(0, "USD"),
transfer_inflow_money: Money.new(0, "USD"),
transfer_outflow_money: Money.new(0, "USD")
)
Transaction::Search.expects(:new).with(family, filters: {}, accessible_account_ids: [ account.id ]).returns(search)
@@ -181,7 +183,9 @@ end
totals = OpenStruct.new(
count: 1,
expense_money: Money.new(10000, "USD"),
income_money: Money.new(0, "USD")
income_money: Money.new(0, "USD"),
transfer_inflow_money: Money.new(0, "USD"),
transfer_outflow_money: Money.new(0, "USD")
)
Transaction::Search.expects(:new).with(family, filters: { "categories" => [ "Food" ], "types" => [ "expense" ] }, accessible_account_ids: [ account.id ]).returns(search)
@@ -191,6 +195,31 @@ end
assert_response :success
end
test "shows inflow/outflow labels when filtering by transfers only" do
family = families(:empty)
sign_in users(:empty)
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
create_transaction(account: account, amount: 100)
search = Transaction::Search.new(family, filters: { "types" => [ "transfer" ] })
totals = OpenStruct.new(
count: 2,
expense_money: Money.new(0, "USD"),
income_money: Money.new(0, "USD"),
transfer_inflow_money: Money.new(5000, "USD"),
transfer_outflow_money: Money.new(3000, "USD")
)
Transaction::Search.expects(:new).with(family, filters: { "types" => [ "transfer" ] }, accessible_account_ids: [ account.id ]).returns(search)
search.expects(:totals).once.returns(totals)
get transactions_url(q: { types: [ "transfer" ] })
assert_response :success
assert_select "#total-income", text: totals.transfer_inflow_money.format
assert_select "#total-expense", text: totals.transfer_outflow_money.format
end
test "mark_as_recurring creates a manual recurring transaction" do
family = families(:empty)
sign_in users(:empty)