From 62f8dc751483f51376f3aa36760855ba2ceddc57 Mon Sep 17 00:00:00 2001 From: Guillem Arias Date: Thu, 14 May 2026 21:59:48 +0200 Subject: [PATCH] fix(goals): current_balance guards against linked-account currency drift Ruby idiom audit edge case. `linked_accounts.sum { |a| a.balance.to_d }` trusted the model's validation that all linked accounts share the goal's currency. The invariant holds at write-time, but direct DB writes, an account-currency edit outside goal validation, or future code that bypasses the validation chain could drift it. The naive sum would silently add raw EUR + USD numbers and surface the result as goal.currency. Filter `linked_accounts.select { |a| a.currency == currency }` and log/report-to-Sentry when the filtered count differs. The sum stays correct (no FX, no mixing) and the operator gets visibility into the drift. Same pattern as `Family#savings_inflow_velocity` already uses for the family-level rollup. --- app/models/goal.rb | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/app/models/goal.rb b/app/models/goal.rb index 72b470cd9..1c230bd56 100644 --- a/app/models/goal.rb +++ b/app/models/goal.rb @@ -61,11 +61,22 @@ class Goal < ApplicationRecord end end - # Balance is the live balance of every linked depository account. - # v1: single linked account in practice. v1.1+: minus other goals' allocations - # via the upcoming GoalBacking query. + # 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. def current_balance - @current_balance ||= linked_accounts.sum { |a| a.balance.to_d } + @current_balance ||= begin + matching = linked_accounts.select { |a| a.currency == currency } + if matching.size != linked_accounts.size + 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 } + end end def current_balance_money