diff --git a/app/components/goals/funding_accounts_breakdown_component.html.erb b/app/components/goals/funding_accounts_breakdown_component.html.erb index 587038a8d..f98118fb4 100644 --- a/app/components/goals/funding_accounts_breakdown_component.html.erb +++ b/app/components/goals/funding_accounts_breakdown_component.html.erb @@ -13,8 +13,8 @@ <% if rows.size > 1 && total.positive? %>
<%= account.name %>
- <%= accountable_label(account) %> · <%= row[:balance_money].format(precision: 0) %> + <% if row[:earmarked] %> + <%= t("goals.show.funding_accounts.earmarked_of", earmarked: row[:backing_money].format(precision: 0), balance: row[:balance_money].format(precision: 0)) %> + <% else %> + <%= accountable_label(account) %> · <%= row[:backing_money].format(precision: 0) %> + <% end %>
- <%= percent_for(row[:balance]) %>% + <%= percent_for(row[:backing]) %>%
<% else %> diff --git a/app/components/goals/funding_accounts_breakdown_component.rb b/app/components/goals/funding_accounts_breakdown_component.rb index 345d40988..6917bf47c 100644 --- a/app/components/goals/funding_accounts_breakdown_component.rb +++ b/app/components/goals/funding_accounts_breakdown_component.rb @@ -9,12 +9,16 @@ class Goals::FundingAccountsBreakdownComponent < ApplicationComponent attr_reader :goal def rows - @rows ||= goal.linked_accounts.sort_by { |a| -a.balance.to_d }.map do |account| + @rows ||= goal.linked_accounts.sort_by { |a| -goal.account_backing(a).amount.to_d }.map do |account| totals = inflow_totals_for(account) + backing = goal.account_backing(account).amount.to_d + goal_account = goal_account_by_id[account.id] { account: account, - balance: account.balance.to_d, + backing: backing, + backing_money: Money.new(backing, goal.currency), balance_money: Money.new(account.balance.to_d, goal.currency), + earmarked: goal_account&.allocated_amount.present?, last_30_money: Money.new(totals[:last_30], goal.currency), last_90_money: Money.new(totals[:last_90], goal.currency) } @@ -22,12 +26,12 @@ class Goals::FundingAccountsBreakdownComponent < ApplicationComponent end def total - @total ||= rows.sum { |r| r[:balance].to_d } + @total ||= rows.sum { |r| r[:backing].to_d } end - def percent_for(balance) + def percent_for(backing) return 0 if total.zero? - ((balance.to_d / total) * 100).round + ((backing.to_d / total) * 100).round end # Pull from the goal's per-goal account color map so the colors here @@ -52,6 +56,10 @@ class Goals::FundingAccountsBreakdownComponent < ApplicationComponent end private + def goal_account_by_id + @goal_account_by_id ||= goal.goal_accounts.index_by(&:account_id) + end + # Per-account net inflow for both windows in one pass over the 90-day # entries set. Entry amount sign in Sure: inflow is negative; flip and # clamp ≥ 0. diff --git a/app/controllers/goals_controller.rb b/app/controllers/goals_controller.rb index 1bb8f3c8a..e77db0367 100644 --- a/app/controllers/goals_controller.rb +++ b/app/controllers/goals_controller.rb @@ -14,7 +14,7 @@ class GoalsController < ApplicationController all_goals = Current.family.goals .alphabetically - .includes(:open_pledges, linked_accounts: :account_providers) + .includes(:open_pledges, :goal_accounts, linked_accounts: :account_providers) .to_a @active_goals = all_goals.reject { |g| %w[completed archived].include?(g.state) } .sort_by { |g| [ g.paused? ? 3 : ACTIVE_STATUS_RANK.fetch(g.status, 4), g.name.downcase ] } @@ -26,6 +26,11 @@ class GoalsController < ApplicationController # entirely (rendered with filterable: false). @grid_goals = @active_goals + @completed_goals + # One family-wide earmark-pool query injected into every rendered goal so + # the shared-pool backing math doesn't fire a query per card (N+1). + pooled = Goal.pooled_allocations_for(Current.family) + (@grid_goals + @archived_goals).each { |goal| goal.pooled_allocations = pooled } + @linkable_account_count = Current.user.accessible_accounts.where(accountable_type: "Depository").visible.count @kpi = kpi_payload(@active_goals) @any_pending_pledge = @active_goals.any? { |g| g.open_pledges.any? } @@ -63,8 +68,9 @@ class GoalsController < ApplicationController accounts = lookup_accounts(params.dig(:goal, :account_ids)) @goal.currency = (accounts.first&.currency || Current.family.primary_currency_code) if @goal.currency.blank? + allocations = submitted_allocations Goal.transaction do - accounts.each { |a| @goal.goal_accounts.build(account: a) } + accounts.each { |a| @goal.goal_accounts.build(account: a, allocated_amount: allocations[a.id.to_s]) } @goal.save! end @@ -100,7 +106,7 @@ class GoalsController < ApplicationController Goal.transaction do @goal.update!(goal_update_params) - sync_linked_accounts!(@goal, accounts) if accounts_supplied + sync_linked_accounts!(@goal, accounts, submitted_allocations) if accounts_supplied end flash[:notice] = t(".success") @@ -176,7 +182,7 @@ class GoalsController < ApplicationController Current.user.accessible_accounts.where(accountable_type: "Depository").visible.alphabetically.to_a end - def sync_linked_accounts!(goal, accounts) + def sync_linked_accounts!(goal, accounts, allocations = {}) desired_ids = accounts.map(&:id).to_set current_ids = goal.goal_accounts.pluck(:account_id).to_set @@ -189,14 +195,36 @@ class GoalsController < ApplicationController ((current_ids & removable_ids) - desired_ids).each do |id| goal.goal_accounts.where(account_id: id).destroy_all end - additions = accounts.reject { |a| current_ids.include?(a.id) } - additions.each { |a| goal.goal_accounts.build(account: a) } - # Save through the goal so currency / depository / family - # validations fire. `create!` on goal_accounts directly bypasses them - # and let cross-currency / non-depository attachments through. + goal.goal_accounts.reload + + # Add new links and refresh the earmark on kept links. Only touch the + # allocation when the form actually submitted a value for that account + # (allocations.key?), so a caller that omits the hash leaves earmarks + # untouched. Save through the goal so currency / depository / family + # validations fire — create! on goal_accounts bypasses them. + accounts.each do |account| + existing = goal.goal_accounts.find { |ga| ga.account_id == account.id } + if existing + existing.allocated_amount = allocations[account.id.to_s] if allocations.key?(account.id.to_s) + else + goal.goal_accounts.build(account: account, allocated_amount: allocations[account.id.to_s]) + end + end goal.save! end + # { account_id_string => amount_string_or_nil } from goal[allocations]. + # A blank amount means "dedicate the whole balance" (NULL allocated_amount). + def submitted_allocations + raw = params.dig(:goal, :allocations) + return {} if raw.blank? + + hash = raw.respond_to?(:to_unsafe_h) ? raw.to_unsafe_h : raw + hash.each_with_object({}) do |(account_id, amount), memo| + memo[account_id.to_s] = amount.to_s.strip.presence + end + end + def kpi_payload(active_goals) family = Current.family currency = family.primary_currency_code diff --git a/app/models/account.rb b/app/models/account.rb index 8ed07849d..d137831d8 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -410,6 +410,26 @@ class Account < ApplicationRecord manual? ? "manual_save" : "transfer" end + # Total fixed earmark this account currently has reserved across every + # non-archived goal (unallocated/whole-balance links reserve no fixed + # slice). Mirrors Budget#allocated_spending. + def goal_earmarked_total + GoalAccount.joins(:goal) + .where(account_id: id) + .where.not(allocated_amount: nil) + .where.not(goals: { state: "archived" }) + .sum(:allocated_amount) + .to_d + end + + # Headroom left to earmark toward goals before fixed allocations exceed the + # balance. Negative means the account is over-earmarked. Intended to back a + # non-blocking over-allocation warning (UI is a follow-up). Mirrors + # Budget#available_to_allocate. + def free_to_earmark + balance.to_d - goal_earmarked_total + end + def logo_url if institution_domain.present? && Setting.brand_fetch_client_id.present? logo_size = Setting.brand_fetch_logo_size diff --git a/app/models/goal.rb b/app/models/goal.rb index 67c4f6dae..3dbc78ac1 100644 --- a/app/models/goal.rb +++ b/app/models/goal.rb @@ -8,7 +8,10 @@ class Goal < ApplicationRecord validates :color, format: { with: /\A#[0-9A-Fa-f]{6}\z/ }, allow_nil: true belongs_to :family - has_many :goal_accounts, dependent: :destroy + # autosave so earmark (allocated_amount) edits on already-linked accounts + # persist through goal.save! — without it Rails only saves newly built + # children, silently dropping changes to existing goal_accounts. + has_many :goal_accounts, dependent: :destroy, autosave: true has_many :linked_accounts, through: :goal_accounts, source: :account has_many :goal_pledges, dependent: :destroy has_many :open_pledges, @@ -35,6 +38,24 @@ class Goal < ApplicationRecord Digest::SHA1.hexdigest("goals:family:#{family_id}").to_i(16) % (2**63) end + # Family-wide map of non-archived goal earmarks, grouped by account_id: + # { account_id => [{ goal_id:, allocated_amount: }, ...] }. The controller + # assigns this to each goal on index (goal.pooled_allocations = ...) so the + # shared-pool backing math runs ONE query for the whole page instead of one + # per goal. + def self.pooled_allocations_for(family) + GoalAccount.joins(:goal) + .where(goals: { family_id: family.id }) + .where.not(goals: { state: "archived" }) + .pluck(:account_id, :goal_id, :allocated_amount) + .group_by(&:first) + .transform_values do |triples| + triples.map { |(_, goal_id, amount)| { goal_id: goal_id, allocated_amount: amount } } + end + end + + attr_writer :pooled_allocations + aasm column: :state do after_all_transitions :reset_state_dependent_caches! @@ -64,13 +85,14 @@ class Goal < ApplicationRecord end end - # Balance is the live balance of every linked depository account that - # matches the goal's currency. The model validates this invariant at - # write time, but defensive filter + telemetry here guards against any - # drift caused by direct DB writes, account-currency edits outside - # goal validation, or future code that bypasses the validation chain. - # v1.1+: minus other goals' allocations via the upcoming GoalBacking - # query. + # Balance is this goal's backing across its linked depository accounts that + # match the goal's currency. Each linked account contributes either its + # earmarked slice (goal_accounts.allocated_amount) or — when unallocated — + # the whole balance left after other goals' earmarks (see + # #backing_balance_for). The model validates the currency invariant at write + # time, but the defensive filter + telemetry here guards against drift from + # direct DB writes, account-currency edits outside goal validation, or + # future code that bypasses the validation chain. def current_balance @current_balance ||= begin matching = linked_accounts.select { |a| a.currency == currency } @@ -78,7 +100,7 @@ class Goal < ApplicationRecord Rails.logger.warn("Goal##{id} linked-account currency drift: #{linked_accounts.size - matching.size} of #{linked_accounts.size} mismatched (expected #{currency})") Sentry.capture_message("Goal linked-account currency drift", level: :warning, extra: { goal_id: id, expected_currency: currency }) if defined?(Sentry) end - matching.sum { |a| a.balance.to_d } + matching.sum { |account| backing_balance_for(account) } end end @@ -86,6 +108,13 @@ class Goal < ApplicationRecord @current_balance_money ||= Money.new(current_balance, currency) end + # This goal's backing from a single linked account — the earmarked slice, or + # the whole-balance remainder when the link is unallocated — as Money. Used + # by the funding breakdown so the per-account rows reconcile with the ring. + def account_backing(account) + Money.new(backing_balance_for(account), currency) + end + def remaining_amount @remaining_amount ||= [ target_amount - current_balance, 0 ].max end @@ -139,6 +168,11 @@ class Goal < ApplicationRecord # user records a pledge, the transfer arrives, balance goes up, pace # goes up, status flips off "behind". Excludes user-flagged-excluded # entries. Entry amount sign convention in Sure: inflow is negative. + # + # NOTE: pace is whole-account inflow by design in this phase, even for an + # earmarked goal whose current_balance is only a slice — so runway/status + # mix a whole-account numerator with an earmark-scoped balance. Earmark-aware + # pace is a deliberate follow-up; don't "fix" the basis without that work. def pace return @pace if defined?(@pace) @@ -190,7 +224,16 @@ class Goal < ApplicationRecord # strings server-side rather than build them with its own Intl calls. def projection_payload series_values = balance_series_values - saved_series = series_values.map { |v| { date: v.date.to_s, value: v.value.amount.to_f } } + # The historical series tracks the whole linked-account balances. Scale it + # to this goal's backing so the saved line meets current_balance at "today" + # instead of dropping off a cliff for earmarked goals. Assumes the earmark + # ratio held over the window (an approximation); exact for unallocated + # goals, where ratio == 1 and the series is unchanged. + whole_total = linked_accounts.select { |a| a.currency == currency }.sum { |a| a.balance.to_d } + # 0 when the linked-account total is non-positive: current_balance is forced + # to 0 there, so the saved series must end at 0 too (no stray non-zero tail). + backing_ratio = whole_total.positive? ? (current_balance.to_d / whole_total) : 0.to_d + saved_series = series_values.map { |v| { date: v.date.to_s, value: (v.value.amount.to_d * backing_ratio).to_f } } earliest = series_values.first&.date || created_at.to_date target_amt = target_amount.to_d @@ -402,12 +445,67 @@ class Goal < ApplicationRecord end private + # This goal's share of `account`'s live balance under the family-wide + # shared pool. The goal's OWN earmark is read from its own goal_accounts + # (reliable even for an archived goal, which is excluded from the pool); + # OTHER non-archived goals' fixed earmarks come from the shared pool. A + # fixed earmark takes its slice; an unallocated link takes the balance left + # after others' fixed earmarks (so it keeps the v1 whole-balance behaviour + # when nothing else earmarks the account). When the fixed earmarks on an + # account exceed its balance every fixed slice is scaled down pro-rata (to + # within sub-cent rounding) so the goals' shares effectively never sum past + # the account's balance — no double-counting. An overdrawn (<= 0) account + # backs nothing. + def backing_balance_for(account) + balance = account.balance.to_d + return 0.to_d if balance <= 0 + + mine = own_allocation_for(account) + others_fixed = (pooled_allocations[account.id] || []) + .reject { |r| r[:goal_id] == id } + .sum { |r| r[:allocated_amount].to_d } + + if mine + total_fixed = others_fixed + mine + if total_fixed > balance && total_fixed.positive? + (mine * (balance / total_fixed)).round(4) # pro-rata haircut + else + mine + end + else + [ balance - others_fixed, 0 ].max # unallocated link: the remainder + end + end + + # This goal's own earmark on `account` (a BigDecimal, or nil for a + # whole-balance link). Read from the loaded goal_accounts association so it + # is correct even for archived goals, which are excluded from the pool. + def own_allocation_for(account) + goal_accounts.find { |ga| ga.account_id == account.id }&.allocated_amount + end + + # Family-wide map of non-archived goal earmarks. Injected once per request + # by the controller on index (one query for the whole page); falls back to + # a single query for the standalone (show) case. + def pooled_allocations + @pooled_allocations ||= self.class.pooled_allocations_for(family) + end + # Cleared after every AASM transition. The state column drives the # display_status / projection_summary memos; without this the same # instance keeps returning the pre-transition value if a controller # calls archive! / pause! and then renders without reload. def reset_state_dependent_caches! - %i[@display_status @projection_summary].each do |ivar| + # current_balance now depends on the goal's own archived state (an + # archived goal is excluded from the shared pool), so the balance-derived + # memos must be cleared on a transition too, not just the status memos. + %i[ + @display_status @projection_summary + @current_balance @current_balance_money + @remaining_amount @remaining_amount_money + @progress_percent @monthly_target_amount + @pace @pace_money @status @pooled_allocations + ].each do |ivar| remove_instance_variable(ivar) if instance_variable_defined?(ivar) end end diff --git a/app/models/goal_account.rb b/app/models/goal_account.rb index 4ab38bb68..57f906db6 100644 --- a/app/models/goal_account.rb +++ b/app/models/goal_account.rb @@ -3,4 +3,16 @@ class GoalAccount < ApplicationRecord belongs_to :account validates :account_id, uniqueness: { scope: :goal_id } + validates :allocated_amount, + numericality: { greater_than_or_equal_to: 0 }, + allow_nil: true + + # nil allocated_amount means "dedicate the whole account balance" (the v1 + # default). A set amount earmarks a fixed slice of the account toward this + # goal. The share that actually counts toward the goal — after sibling + # earmarks and the pro-rata over-allocation haircut — is computed by + # Goal#current_balance, which owns the shared-pool math. + def whole_account? + allocated_amount.nil? + end end diff --git a/app/views/goals/_form.html.erb b/app/views/goals/_form.html.erb index 252bc4259..6b53c230d 100644 --- a/app/views/goals/_form.html.erb +++ b/app/views/goals/_form.html.erb @@ -44,30 +44,42 @@<%= t("goals.form.fields.funding_accounts_hint") %>
+<%= t("goals.form.fields.earmark_hint") %>