mirror of
https://github.com/we-promise/sure.git
synced 2026-07-13 13:25:22 +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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user