feat: show counterpart account in transfer transaction list row (#2643)

* Show counterpart account in transfer transaction list row

* feat: show transfer counterpart account with access and nil guards

* - Gate counterpart name behind accessible_accounts check.
- Add nested transfer includes to TransactionsController and
AccountsController to prevent N+1 queries.
- Use precomputed @accessible_account_ids Set for O(1) lookups.

* test: add view tests for transfer counterpart rendering

Cover outflow arrow, inflow arrow, and unmatched transfer fallback
using ActionView::TestCase following existing merged_badge pattern.

* Fix transfer eager loading for polymorphic entryables

* Keep accessible_account_ids as Array to fix mock test expectations
This commit is contained in:
Bishal Shrestha
2026-07-30 09:11:56 +10:00
committed by GitHub
parent 3c1c87a9d9
commit 5ee3275f98
4 changed files with 120 additions and 7 deletions

View File

@@ -57,8 +57,10 @@ class AccountsController < ApplicationController
def show
@chart_view = params[:chart_view] || "balance"
@tab = params[:tab]
@accessible_account_ids = Current.user.accessible_accounts.pluck(:id).to_set
@q = params.fetch(:q, {}).permit(:search, status: [])
entries = @account.entries.where(excluded: false).search(@q).reverse_chronological.includes(:entryable)
if statement_tab_active?
build_statement_tab_data
return render_statement_tab_frame if statement_tab_frame_request?
@@ -69,6 +71,17 @@ class AccountsController < ApplicationController
limit: safe_per_page,
params: request.query_parameters.except("tab").merge("tab" => "activity")
)
# Preload transfer associations only for Transaction entries
txn_entryables = @entries.filter_map { |e| e.entryable if e.entryable_type == "Transaction" }
ActiveRecord::Associations::Preloader.new(
records: txn_entryables,
associations: {
transfer_as_outflow: { inflow_transaction: { entry: :account } },
transfer_as_inflow: { outflow_transaction: { entry: :account } }
}
).call
Transaction::ActivitySecurityPreloader.new(@entries).preload
@activity_feed_data = Account::ActivityFeedData.new(@account, @entries)

View File

@@ -14,16 +14,17 @@ class TransactionsController < ApplicationController
def index
@q = search_params
accessible_account_ids = Current.user.accessible_accounts.pluck(:id)
@search = Transaction::Search.new(Current.family, filters: @q, accessible_account_ids: accessible_account_ids)
@accessible_account_ids = Current.user.accessible_accounts.pluck(:id)
@search = Transaction::Search.new(Current.family, filters: @q, accessible_account_ids: @accessible_account_ids)
base_scope = @search.transactions_scope
.reverse_chronological
.includes(
{ entry: :account },
:category, :merchant, :tags,
:transfer_as_inflow, :transfer_as_outflow
)
{ entry: :account },
:category, :merchant, :tags,
transfer_as_outflow: { inflow_transaction: { entry: :account } },
transfer_as_inflow: { outflow_transaction: { entry: :account } }
)
@pagy, @transactions = pagy(base_scope, limit: safe_per_page)
Transaction::ActivitySecurityPreloader.new(@transactions).preload

View File

@@ -153,7 +153,21 @@
<div class="text-secondary text-xs font-normal">
<% if transaction.transfer? %>
<span class="text-secondary">
<%= transaction.loan_payment? ? t("transactions.show.loan_payment") : t("transactions.show.transfer") %> • <%= entry.account.name %>
<%= transaction.loan_payment? ? t("transactions.show.loan_payment") : t("transactions.show.transfer") %> •
<% if transaction.transfer.present? %>
<% counterpart = transaction.transfer_as_outflow.present? ? transaction.transfer.to_account : transaction.transfer.from_account %>
<% if counterpart.present? && @accessible_account_ids&.include?(counterpart.id) %>
<% if transaction.transfer_as_outflow.present? %>
<%= entry.account.name %> → <%= counterpart.name %>
<% else %>
<%= entry.account.name %> ← <%= counterpart.name %>
<% end %>
<% else %>
<%= entry.account.name %>
<% end %>
<% else %>
<%= entry.account.name %>
<% end %>
</span>
<% else %>
<% if transaction.merchant&.present? %>

View File

@@ -0,0 +1,85 @@
require "test_helper"
class Transactions::TransferCounterpartViewTest < ActionView::TestCase
setup do
@family = families(:dylan_family)
@user = users(:family_admin)
Current.session = Session.create!(user: @user)
@checking = accounts(:depository) # "from" account
@savings = accounts(:credit_card) # "to" account
@accessible_account_ids = @user.accessible_accounts.pluck(:id).to_set
@split_parent_entry_ids = Set.new
end
test "renders outflow transfer with arrow to destination account" do
outflow_tx = Transaction.create!(kind: "funds_movement")
outflow_entry = Entry.create!(
account: @checking, entryable: outflow_tx,
name: "Transfer to Savings", amount: 100, currency: "USD", date: Date.today
)
inflow_tx = Transaction.create!(kind: "funds_movement")
inflow_entry = Entry.create!(
account: @savings, entryable: inflow_tx,
name: "Transfer from Checking", amount: -100, currency: "USD", date: Date.today
)
Transfer.create!(
inflow_transaction: inflow_tx,
outflow_transaction: outflow_tx,
status: "confirmed"
)
html = render(partial: "transactions/transaction", locals: {
entry: outflow_entry, balance_trend: nil, view_ctx: "global"
})
assert_includes html, ""
assert_includes html, @savings.name
end
test "renders inflow transfer with arrow from source account" do
outflow_tx = Transaction.create!(kind: "funds_movement")
outflow_entry = Entry.create!(
account: @checking, entryable: outflow_tx,
name: "Transfer to Savings", amount: 100, currency: "USD", date: Date.today
)
inflow_tx = Transaction.create!(kind: "funds_movement")
inflow_entry = Entry.create!(
account: @savings, entryable: inflow_tx,
name: "Transfer from Checking", amount: -100, currency: "USD", date: Date.today
)
Transfer.create!(
inflow_transaction: inflow_tx,
outflow_transaction: outflow_tx,
status: "confirmed"
)
html = render(partial: "transactions/transaction", locals: {
entry: inflow_entry, balance_trend: nil, view_ctx: "global"
})
assert_includes html, ""
assert_includes html, @checking.name
end
test "falls back to account name when transfer has no counterpart" do
tx = Transaction.create!(kind: "funds_movement")
entry = Entry.create!(
account: @checking, entryable: tx,
name: "Unmatched Transfer", amount: 100, currency: "USD", date: Date.today
)
html = render(partial: "transactions/transaction", locals: {
entry: entry, balance_trend: nil, view_ctx: "global"
})
assert_includes html, @checking.name
assert_not_includes html, ""
assert_not_includes html, ""
end
end