mirror of
https://github.com/we-promise/sure.git
synced 2026-07-11 20:35:17 +00:00
feat(goals): earmark a portion of an account toward a goal (Phase 1) (#2490)
* feat(goals): earmark a portion of an account toward a goal Goals currently count each linked account's whole balance, so an account shared across goals double-counts and one account can't fund several goals in distinct slices. Add a per-account earmark — the "GoalBacking" the v1 model already foreshadowed (goal.rb). - goal_accounts.allocated_amount (nullable). NULL = "dedicate the whole balance" (the v1 default: no backfill, existing goals unchanged); a set amount reserves a fixed slice. - Goal#current_balance is now the single chokepoint computing each account's backing under a family-wide shared pool: fixed earmarks take their slice, an unallocated link takes the remainder, and when fixed earmarks exceed the balance every slice is scaled down pro-rata so the goals' shares can never sum past the account balance (no double-counting). - Account#free_to_earmark / #goal_earmarked_total (mirror Budget's available_to_allocate) back a soft, non-blocking over-allocation hint. - GoalsController threads a goal[allocations] hash through create/update. Phase 1 of the goals earmarking work; investment-backed goals follow. * feat(goals): earmark UI on the goal form + backing-aware funding breakdown - Goal form: a per-account "earmark amount" input (blank = whole balance) next to each funding-account checkbox, prefilled from the saved allocation on edit. - Goal#account_backing exposes a single linked account's share so the funding-accounts breakdown shows each account's earmarked contribution and percent instead of its whole balance — keeping the show page consistent with the (now allocation-aware) progress ring. - English strings for the earmark controls and the "earmarked of balance" breakdown line. * fix(goals): address review on the earmark shared-pool math - Overdrawn (<= 0 balance) accounts now back nothing on both the fixed and whole-balance paths. The fixed path previously produced negative backing and let a goal claim money the account doesn't hold. - An archived goal reads its OWN earmark from its own goal_accounts instead of the shared pool (which excludes archived goals), so it no longer mis-reports the whole account balance for itself. - goals#index injects one family-wide earmark pool into every card (Goal.pooled_allocations_for) instead of querying once per goal (N+1), and preloads goal_accounts. - The projection chart scales its whole-account historical series by the backing ratio so the saved line meets current_balance at "today" rather than dropping off a cliff for earmarked goals. - Honest comments: free_to_earmark no longer claims a form warning that doesn't exist yet; pace documents its deliberate whole-account basis. * fix(goals): widen the earmark input so the 'Whole balance' placeholder isn't clipped * fix(goals): address review on #2490 - autosave: true on goal_accounts so earmark edits to already-linked accounts persist through goal.save! (Rails only auto-saves newly built children, so changing/clearing an existing earmark was silently dropped). + test. - Reset the balance/progress memos on AASM transitions, not just the status memos, so a same-instance render after complete!/archive! isn't stale. + test. - backing_ratio is 0 (not 1) when the linked-account total is non-positive, so the projection saved series ends at 0 to match the forced-zero current_balance. - Localize the funding-row subtype label via goals.form.subtypes.*. - Add the earmark strings to zh-CN (the maintained second locale; goals has no ca locale, so Catalan keeps falling back to en like the rest of goals). --------- Signed-off-by: Juan José Mata <juanjo.mata@gmail.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
committed by
GitHub
parent
401cd6ab08
commit
1b403d64e5
@@ -13,8 +13,8 @@
|
||||
<% if rows.size > 1 && total.positive? %>
|
||||
<div class="flex gap-1">
|
||||
<% rows.each do |row| %>
|
||||
<% next if row[:balance].to_d.zero? %>
|
||||
<div class="h-1.5 rounded-sm" style="width: <%= percent_for(row[:balance]) %>%; background-color: <%= color_for(row[:account]) %>;"></div>
|
||||
<% next if row[:backing].to_d.zero? %>
|
||||
<div class="h-1.5 rounded-sm" style="width: <%= percent_for(row[:backing]) %>%; background-color: <%= color_for(row[:account]) %>;"></div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -31,13 +31,17 @@
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-primary truncate"><%= account.name %></p>
|
||||
<p class="text-sm text-subdued tabular-nums privacy-sensitive">
|
||||
<%= 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 %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<% if rows.size > 1 %>
|
||||
<p class="hidden sm:block text-sm text-secondary tabular-nums text-right privacy-sensitive">
|
||||
<%= percent_for(row[:balance]) %>%
|
||||
<%= percent_for(row[:backing]) %>%
|
||||
</p>
|
||||
<% else %>
|
||||
<span class="hidden sm:block"></span>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -44,30 +44,42 @@
|
||||
<div class="mb-2">
|
||||
<span class="block text-sm font-medium text-primary"><%= t("goals.form.fields.funding_accounts") %></span>
|
||||
<p class="text-xs text-secondary mt-0.5"><%= t("goals.form.fields.funding_accounts_hint") %></p>
|
||||
<p class="text-xs text-subdued mt-0.5"><%= t("goals.form.fields.earmark_hint") %></p>
|
||||
</div>
|
||||
<div class="bg-container-inset rounded-lg p-1">
|
||||
<% linked_allocation_by_account = goal.goal_accounts.index_by(&:account_id) %>
|
||||
<% grouped = linkable_accounts.group_by { |a| a.subtype.to_s.presence || "other" } %>
|
||||
<% grouped.each_with_index do |(subtype, accts), group_idx| %>
|
||||
<div class="px-3 py-2 text-[11px] font-medium uppercase tracking-wide text-secondary"><%= t("goals.form.subtypes.#{subtype}", default: subtype.titleize) %></div>
|
||||
<div class="bg-container rounded-md <%= "mb-1" if group_idx < grouped.size - 1 %>">
|
||||
<% accts.each_with_index do |account, idx| %>
|
||||
<label class="flex items-center gap-3 px-3 py-2.5 cursor-pointer hover:bg-surface-hover <%= "border-t border-subdued" if idx > 0 %>">
|
||||
<%= check_box_tag "goal[account_ids][]",
|
||||
account.id,
|
||||
currently_linked_account_ids.include?(account.id.to_s),
|
||||
id: "goal_account_ids_#{account.id}",
|
||||
class: "checkbox checkbox--light shrink-0",
|
||||
data: {
|
||||
goal_form_target: "linkedAccountCheckbox",
|
||||
action: "change->goal-form#linkedAccountChanged"
|
||||
} %>
|
||||
<%= render Goals::AvatarComponent.new(name: account.name, color: Goals::AvatarComponent.color_for(account.name), size: "md") %>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-primary truncate"><%= account.name %></p>
|
||||
<p class="text-xs text-secondary"><%= (account.subtype || subtype).titleize %></p>
|
||||
</div>
|
||||
<span class="text-sm text-primary tabular-nums privacy-sensitive"><%= Money.new(account.balance, account.currency).format %></span>
|
||||
</label>
|
||||
<% linked_ga = linked_allocation_by_account[account.id] %>
|
||||
<div class="flex items-center gap-3 px-3 py-2.5 hover:bg-surface-hover <%= "border-t border-subdued" if idx > 0 %>">
|
||||
<label class="flex items-center gap-3 flex-1 min-w-0 cursor-pointer">
|
||||
<%= check_box_tag "goal[account_ids][]",
|
||||
account.id,
|
||||
currently_linked_account_ids.include?(account.id.to_s),
|
||||
id: "goal_account_ids_#{account.id}",
|
||||
class: "checkbox checkbox--light shrink-0",
|
||||
data: {
|
||||
goal_form_target: "linkedAccountCheckbox",
|
||||
action: "change->goal-form#linkedAccountChanged"
|
||||
} %>
|
||||
<%= render Goals::AvatarComponent.new(name: account.name, color: Goals::AvatarComponent.color_for(account.name), size: "md") %>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-primary truncate"><%= account.name %></p>
|
||||
<% row_subtype = account.subtype.to_s.presence || subtype %>
|
||||
<p class="text-xs text-secondary tabular-nums privacy-sensitive"><%= t("goals.form.subtypes.#{row_subtype}", default: row_subtype.titleize) %> · <%= Money.new(account.balance, account.currency).format %></p>
|
||||
</div>
|
||||
</label>
|
||||
<%= text_field_tag "goal[allocations][#{account.id}]",
|
||||
linked_ga&.allocated_amount&.to_s("F"),
|
||||
placeholder: t("goals.form.fields.whole_balance"),
|
||||
inputmode: "decimal",
|
||||
autocomplete: "off",
|
||||
aria: { label: t("goals.form.fields.earmark_for", account: account.name) },
|
||||
class: "shrink-0 w-36 rounded-md border border-primary bg-container px-2.5 py-1.5 text-sm text-right tabular-nums text-primary placeholder:text-subdued focus-ring privacy-sensitive" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -105,6 +105,7 @@ en:
|
||||
pledge_just_saved: Log money you set aside
|
||||
funding_accounts_heading: Funding accounts
|
||||
funding_accounts:
|
||||
earmarked_of: "%{earmarked} earmarked of %{balance}"
|
||||
empty:
|
||||
heading: No funding accounts linked yet
|
||||
body: Edit the goal to link the depository accounts you save into.
|
||||
@@ -258,7 +259,10 @@ en:
|
||||
notes: Notes (optional)
|
||||
notes_placeholder: A reminder for future you…
|
||||
funding_accounts: Funding accounts
|
||||
funding_accounts_hint: This goal's balance is the balance of these accounts.
|
||||
funding_accounts_hint: This goal's balance is the balance (or earmarked portion) of these accounts.
|
||||
whole_balance: Whole balance
|
||||
earmark_for: Earmark amount for %{account}
|
||||
earmark_hint: Leave an amount blank to dedicate that account's whole balance.
|
||||
subtypes:
|
||||
checking: Checking
|
||||
savings: Savings
|
||||
|
||||
@@ -105,6 +105,7 @@ zh-CN:
|
||||
pledge_just_saved: 记录你留出的资金
|
||||
funding_accounts_heading: 资金账户
|
||||
funding_accounts:
|
||||
earmarked_of: 已预留 %{earmarked}(共 %{balance})
|
||||
empty:
|
||||
heading: 尚未关联资金账户
|
||||
body: 编辑目标以关联用于储蓄的存款账户。
|
||||
@@ -258,7 +259,10 @@ zh-CN:
|
||||
notes: 备注(可选)
|
||||
notes_placeholder: 给未来自己的提醒…
|
||||
funding_accounts: 资金账户
|
||||
funding_accounts_hint: 此目标余额等于这些账户的余额。
|
||||
funding_accounts_hint: 此目标余额等于这些账户的余额(或预留的部分)。
|
||||
whole_balance: 全部余额
|
||||
earmark_for: 为「%{account}」预留的金额
|
||||
earmark_hint: 留空则使用该账户的全部余额。
|
||||
subtypes:
|
||||
checking: 支票账户
|
||||
savings: 储蓄账户
|
||||
|
||||
14
db/migrate/20260625120000_add_allocation_to_goal_accounts.rb
Normal file
14
db/migrate/20260625120000_add_allocation_to_goal_accounts.rb
Normal file
@@ -0,0 +1,14 @@
|
||||
class AddAllocationToGoalAccounts < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
# Per-account earmark toward a goal. NULL = "dedicate the whole account
|
||||
# balance" (the v1 behaviour), so every existing goal_accounts row keeps
|
||||
# its current semantics with no backfill. A set amount reserves a fixed
|
||||
# slice of the account, letting one account fund several goals without
|
||||
# double-counting (Goal#current_balance applies the shared-pool math).
|
||||
add_column :goal_accounts, :allocated_amount, :decimal, precision: 19, scale: 4, null: true
|
||||
|
||||
add_check_constraint :goal_accounts,
|
||||
"allocated_amount IS NULL OR allocated_amount >= 0",
|
||||
name: "chk_goal_accounts_allocation_non_negative"
|
||||
end
|
||||
end
|
||||
2
db/schema.rb
generated
2
db/schema.rb
generated
@@ -813,9 +813,11 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do
|
||||
t.uuid "account_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.decimal "allocated_amount", precision: 19, scale: 4
|
||||
t.index ["account_id"], name: "index_goal_accounts_on_account_id"
|
||||
t.index ["goal_id", "account_id"], name: "index_savings_goal_accounts_on_goal_and_account", unique: true
|
||||
t.index ["goal_id"], name: "index_goal_accounts_on_goal_id"
|
||||
t.check_constraint "allocated_amount IS NULL OR allocated_amount >= 0::numeric", name: "chk_goal_accounts_allocation_non_negative"
|
||||
end
|
||||
|
||||
create_table "goal_pledges", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
|
||||
32
test/models/goal_account_test.rb
Normal file
32
test/models/goal_account_test.rb
Normal file
@@ -0,0 +1,32 @@
|
||||
require "test_helper"
|
||||
|
||||
class GoalAccountTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@goal = goals(:emergency_fund)
|
||||
@account = Account.create!(
|
||||
family: families(:dylan_family),
|
||||
accountable: Depository.new,
|
||||
name: "Allocation Test",
|
||||
currency: "USD",
|
||||
balance: 1_000
|
||||
)
|
||||
end
|
||||
|
||||
test "allocated_amount may be nil, meaning dedicate the whole balance" do
|
||||
ga = GoalAccount.new(goal: @goal, account: @account, allocated_amount: nil)
|
||||
assert ga.valid?, ga.errors.full_messages.to_sentence
|
||||
assert ga.whole_account?
|
||||
end
|
||||
|
||||
test "a set allocated_amount is not a whole-account link" do
|
||||
ga = GoalAccount.new(goal: @goal, account: @account, allocated_amount: 250)
|
||||
assert ga.valid?, ga.errors.full_messages.to_sentence
|
||||
assert_not ga.whole_account?
|
||||
end
|
||||
|
||||
test "allocated_amount must be non-negative" do
|
||||
ga = GoalAccount.new(goal: @goal, account: @account, allocated_amount: -1)
|
||||
assert_not ga.valid?
|
||||
assert_includes ga.errors[:allocated_amount], "must be greater than or equal to 0"
|
||||
end
|
||||
end
|
||||
@@ -290,4 +290,116 @@ class GoalTest < ActiveSupport::TestCase
|
||||
reloaded = Goal.find(@goal.id)
|
||||
assert_equal "goals.show.pledge_just_saved", reloaded.pledge_action_label_key
|
||||
end
|
||||
|
||||
test "explicit allocation backs only the earmarked slice" do
|
||||
account = Account.create!(family: @family, accountable: Depository.new, name: "Split Savings", currency: "USD", balance: 5_000)
|
||||
goal = @family.goals.create!(name: "Earmarked", target_amount: 10_000, currency: "USD") do |g|
|
||||
g.goal_accounts.build(account: account, allocated_amount: 1_000)
|
||||
end
|
||||
assert_equal BigDecimal("1000"), goal.current_balance.to_d
|
||||
end
|
||||
|
||||
test "explicit allocation is capped at the account balance via the haircut" do
|
||||
account = Account.create!(family: @family, accountable: Depository.new, name: "Over Earmark", currency: "USD", balance: 800)
|
||||
goal = @family.goals.create!(name: "Over", target_amount: 10_000, currency: "USD") do |g|
|
||||
g.goal_accounts.build(account: account, allocated_amount: 5_000)
|
||||
end
|
||||
assert_equal BigDecimal("800"), goal.current_balance.to_d
|
||||
end
|
||||
|
||||
test "unallocated link claims the balance left after another goal's earmark" do
|
||||
account = Account.create!(family: @family, accountable: Depository.new, name: "Shared Savings", currency: "USD", balance: 5_000)
|
||||
earmarked = @family.goals.create!(name: "Earmarked", target_amount: 10_000, currency: "USD") do |g|
|
||||
g.goal_accounts.build(account: account, allocated_amount: 2_000)
|
||||
end
|
||||
whole = @family.goals.create!(name: "Whole", target_amount: 10_000, currency: "USD") do |g|
|
||||
g.goal_accounts.build(account: account) # NULL = whole-balance remainder
|
||||
end
|
||||
assert_equal BigDecimal("2000"), earmarked.current_balance.to_d
|
||||
assert_equal BigDecimal("3000"), whole.current_balance.to_d
|
||||
# The two goals' shares of the shared account never exceed its balance.
|
||||
assert_equal account.balance.to_d, earmarked.current_balance.to_d + whole.current_balance.to_d
|
||||
end
|
||||
|
||||
test "over-earmarked account scales fixed slices pro-rata" do
|
||||
account = Account.create!(family: @family, accountable: Depository.new, name: "Contested Savings", currency: "USD", balance: 5_000)
|
||||
a = @family.goals.create!(name: "Goal A", target_amount: 10_000, currency: "USD") do |g|
|
||||
g.goal_accounts.build(account: account, allocated_amount: 4_000)
|
||||
end
|
||||
b = @family.goals.create!(name: "Goal B", target_amount: 10_000, currency: "USD") do |g|
|
||||
g.goal_accounts.build(account: account, allocated_amount: 4_000)
|
||||
end
|
||||
# sum_fixed 8000 > balance 5000 -> each scaled by 5000/8000 -> 2500.
|
||||
assert_equal BigDecimal("2500"), a.current_balance.to_d
|
||||
assert_equal BigDecimal("2500"), b.current_balance.to_d
|
||||
assert_equal account.balance.to_d, a.current_balance.to_d + b.current_balance.to_d
|
||||
end
|
||||
|
||||
test "archived goals release their earmark from the shared pool" do
|
||||
account = Account.create!(family: @family, accountable: Depository.new, name: "Release Savings", currency: "USD", balance: 5_000)
|
||||
whole = @family.goals.create!(name: "Whole", target_amount: 10_000, currency: "USD") do |g|
|
||||
g.goal_accounts.build(account: account)
|
||||
end
|
||||
earmarked = @family.goals.create!(name: "Earmarked", target_amount: 10_000, currency: "USD") do |g|
|
||||
g.goal_accounts.build(account: account, allocated_amount: 2_000)
|
||||
end
|
||||
assert_equal BigDecimal("3000"), Goal.find(whole.id).current_balance.to_d
|
||||
earmarked.archive!
|
||||
# Archived goal no longer reserves its slice -> whole reclaims it.
|
||||
assert_equal BigDecimal("5000"), Goal.find(whole.id).current_balance.to_d
|
||||
end
|
||||
|
||||
test "account free_to_earmark subtracts non-archived fixed earmarks" do
|
||||
account = Account.create!(family: @family, accountable: Depository.new, name: "Headroom Savings", currency: "USD", balance: 5_000)
|
||||
@family.goals.create!(name: "Earmarker", target_amount: 10_000, currency: "USD") do |g|
|
||||
g.goal_accounts.build(account: account, allocated_amount: 1_500)
|
||||
end
|
||||
assert_equal BigDecimal("1500"), account.goal_earmarked_total
|
||||
assert_equal BigDecimal("3500"), account.free_to_earmark
|
||||
end
|
||||
|
||||
test "an overdrawn account backs nothing for fixed or whole-balance links" do
|
||||
account = Account.create!(family: @family, accountable: Depository.new, name: "Overdrawn", currency: "USD", balance: BigDecimal("-100"))
|
||||
fixed = @family.goals.create!(name: "Fixed OD", target_amount: 1_000, currency: "USD") do |g|
|
||||
g.goal_accounts.build(account: account, allocated_amount: 50)
|
||||
end
|
||||
whole = @family.goals.create!(name: "Whole OD", target_amount: 1_000, currency: "USD") do |g|
|
||||
g.goal_accounts.build(account: account)
|
||||
end
|
||||
assert_equal 0.to_d, fixed.current_balance.to_d
|
||||
assert_equal 0.to_d, whole.current_balance.to_d
|
||||
end
|
||||
|
||||
test "an archived goal still shows its own earmark, not the whole balance" do
|
||||
account = Account.create!(family: @family, accountable: Depository.new, name: "Archived Earmark", currency: "USD", balance: 5_000)
|
||||
earmarked = @family.goals.create!(name: "Archived Fixed", target_amount: 10_000, currency: "USD") do |g|
|
||||
g.goal_accounts.build(account: account, allocated_amount: 2_000)
|
||||
end
|
||||
earmarked.archive!
|
||||
# Excluded from the shared pool, but its own earmark is read from its own
|
||||
# goal_accounts — so it still reports 2,000, not the whole 5,000.
|
||||
assert_equal BigDecimal("2000"), Goal.find(earmarked.id).current_balance.to_d
|
||||
end
|
||||
|
||||
test "earmark edits to an existing linked account persist via goal.save!" do
|
||||
account = Account.create!(family: @family, accountable: Depository.new, name: "Autosave Savings", currency: "USD", balance: 5_000)
|
||||
goal = @family.goals.create!(name: "Autosave goal", target_amount: 10_000, currency: "USD") do |g|
|
||||
g.goal_accounts.build(account: account) # NULL = whole balance
|
||||
end
|
||||
ga = goal.goal_accounts.first
|
||||
assert_nil ga.allocated_amount
|
||||
ga.allocated_amount = 1_500
|
||||
goal.save! # autosave: true must persist the dirty existing child
|
||||
assert_equal BigDecimal("1500"), goal.goal_accounts.first.reload.allocated_amount
|
||||
end
|
||||
|
||||
test "progress_percent memo resets after complete! on the same instance" do
|
||||
account = Account.create!(family: @family, accountable: Depository.new, name: "Memo Savings", currency: "USD", balance: 100)
|
||||
goal = @family.goals.create!(name: "Memo goal", target_amount: 1_000, currency: "USD") do |g|
|
||||
g.goal_accounts.build(account: account)
|
||||
end
|
||||
assert_operator goal.progress_percent, :<, 100 # memoize the underfunded value
|
||||
goal.complete!
|
||||
assert_equal 100, goal.progress_percent, "stale memo would still report the pre-complete percent"
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user