mirror of
https://github.com/we-promise/sure.git
synced 2026-07-16 06:45:21 +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
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