Files
sure/test/models/goal_test.rb
Guillem Arias Fauste c5ca0431c9 feat(goals): investment-backed goals (Phase 2) (#2491)
* 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).

* feat(goals): investment-backed goals (Phase 2)

Goals can now be funded by investment accounts, not just depository.

- Relax linked_accounts_must_be_depository -> _must_be_fundable
  (depository || investment); the funding picker + counts include investment
  accounts.
- Add goals.progress_basis ('balance' | 'contributions', default 'balance').
  Investment-backed goals default to 'contributions' so a market swing doesn't
  move the goal: current_balance = value - cumulative market gain
  (Sum of balances.net_market_flows); depository accounts have zero
  net_market_flows, so they're unchanged. Goal#market_value_money shows what
  it's worth today next to the contributed figure on the show page.
- Pledge false-match guard: investment accounts never use manual_save /
  valuation-delta matching (a market move isn't a deposit) - they resolve on
  transfer (cash-inflow) entries only. Guarded in both
  Account#default_pledge_kind and GoalPledge#matches?.
- Add a `reopen` AASM event (completed -> active) + route/action/menu item so a
  manually-completed goal whose value later dips can be reopened.

Stacked on the earmarking branch (#2490). Full suite green; +6 goal tests.

* fix(goals): address Phase 2 review — allocation-aware contributions, N+1, basis-on-update

- Contributions basis now goes through the same earmark/shared-pool logic as
  the balance basis: backing_balance_for -> backing_share_for(account, base),
  where base is the live balance (balance basis) or net contributions
  (contributions basis). Earmarks are respected and shared accounts no longer
  double-count on contributions goals; market_value_money stays consistent.
- Batch the per-account net_market_flows sum (Goal.market_flows_for) and inject
  it on index like pooled_allocations, killing the N+1 for contributions goals.
- Default the basis on update too (not just create), so adding an investment
  account to an existing depository goal flips it to contributions instead of
  silently tracking market value.
- Fix the stale reconciliation_manager comment (renamed validation) and the
  orphaned zh-CN must_be_depository key.

* fix(goals): address review on #2491

- before_save (not before_validation) for the progress_basis default, so a goal
  can be inspected via valid? without its basis flipping as a side effect (jjmata).
- Pledge copy keys off default_pledge_kind, not manual?, so a manual investment
  account — which pledges via transfer — shows the transfer prompt instead of the
  "update your manual balance" flow (codex). pledge_action_label_key and the
  pledge modal's per-account helper flag both use it.
- Add the Phase 2 strings (reopen success/invalid_transition, show.reopen,
  ring.market_value) to zh-CN.

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-06-30 07:26:23 +02:00

458 lines
18 KiB
Ruby
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
require "test_helper"
class GoalTest < ActiveSupport::TestCase
include EntriesTestHelper
setup do
@family = families(:dylan_family)
@depository = accounts(:depository)
@connected = accounts(:connected)
@goal = goals(:vacation_italy)
end
test "valid fixture goal saves" do
assert @goal.valid?
end
test "name is required" do
@goal.name = ""
assert_not @goal.valid?
assert_includes @goal.errors[:name], "can't be blank"
end
test "target_amount must be positive" do
@goal.target_amount = 0
assert_not @goal.valid?
end
test "color must match hex format" do
@goal.color = "red; cursor: pointer"
assert_not @goal.valid?
assert_includes @goal.errors[:color], "is invalid"
end
test "color accepts standard 6-digit hex" do
@goal.color = "#abcdef"
assert @goal.valid?, @goal.errors.full_messages.to_sentence
end
test "display_status follows AASM state after pause! on the same instance" do
@goal.update!(color: "#4da568") if @goal.color.blank?
initial = @goal.display_status
@goal.pause!
assert_equal :paused, @goal.display_status, "stale memo would have returned #{initial.inspect}"
end
test "must have at least one linked account on create" do
new_goal = @family.goals.new(name: "Test", target_amount: 100, currency: "USD")
assert_not new_goal.valid?
assert_match(/at least one/i, new_goal.errors[:base].join)
end
test "investment accounts are fundable and default to the contributions basis" do
investment = accounts(:investment)
new_goal = @family.goals.new(name: "Inv", target_amount: 100, currency: "USD")
new_goal.goal_accounts.build(account: investment)
assert new_goal.valid?, new_goal.errors.full_messages.to_sentence
new_goal.save! # basis is set on save (before_save), not on valid?
assert_equal "contributions", new_goal.progress_basis
end
test "non-fundable account types are rejected" do
credit = accounts(:credit_card)
new_goal = @family.goals.new(name: "Test", target_amount: 100, currency: "USD")
new_goal.goal_accounts.build(account: credit)
assert_not new_goal.valid?
assert_includes new_goal.errors[:linked_accounts], "All linked accounts must be cash or investment accounts."
end
test "linked accounts must belong to family" do
other_family = Family.create!(name: "Other", currency: "USD", locale: "en", country: "US", timezone: "UTC")
foreign_account = Account.create!(
family: other_family,
accountable: Depository.new,
name: "Foreign",
currency: "USD",
balance: 100
)
new_goal = @family.goals.new(name: "T", target_amount: 100, currency: "USD")
new_goal.goal_accounts.build(account: foreign_account)
assert_not new_goal.valid?
assert_includes new_goal.errors[:linked_accounts], "Linked accounts must belong to the same family as the goal."
end
test "linked accounts must share currency with goal" do
eur_account = Account.create!(
family: @family,
accountable: Depository.new,
name: "Euro Cash",
currency: "EUR",
balance: 100
)
new_goal = @family.goals.new(name: "T", target_amount: 100, currency: "USD")
new_goal.goal_accounts.build(account: eur_account)
assert_not new_goal.valid?
assert_includes new_goal.errors[:linked_accounts], "All linked accounts must share the same currency."
end
test "currency can't change once linked accounts exist" do
assert @goal.linked_accounts.exists?
@goal.currency = "EUR"
assert_not @goal.valid?
assert_includes @goal.errors[:currency], "Can't change the currency after the goal is linked to accounts."
end
test "current_balance sums linked account balances" do
expected = @goal.linked_accounts.sum(&:balance).to_d
assert_equal expected, @goal.current_balance.to_d
end
test "progress_percent caps at 100" do
@goal.target_amount = 1
assert_equal 100, @goal.progress_percent
end
test "progress_percent stays below 100 while remaining amount is positive" do
account = Account.create!(
family: @family,
accountable: Depository.new,
name: "Almost There Savings",
currency: "USD",
balance: BigDecimal("999.50")
)
goal = @family.goals.create!(
name: "Almost There",
target_amount: BigDecimal("1000"),
currency: "USD"
) { |new_goal| new_goal.goal_accounts.build(account: account) }
assert_equal BigDecimal("0.5"), goal.remaining_amount
assert_equal 99, goal.progress_percent
assert_equal :no_target_date, goal.status
end
test "status stays reached for a goal completed while underfunded" do
account = Account.create!(
family: @family,
accountable: Depository.new,
name: "Completed Underfunded Savings",
currency: "USD",
balance: BigDecimal("999.50")
)
goal = @family.goals.create!(
name: "Completed Underfunded",
target_amount: BigDecimal("1000"),
currency: "USD"
) { |new_goal| new_goal.goal_accounts.build(account: account) }
goal.complete!
assert_equal BigDecimal("0.5"), goal.remaining_amount
assert_equal 100, goal.progress_percent
assert_equal :reached, Goal.find(goal.id).status
end
test "progress_percent is 0 for empty active goal" do
fresh = goals(:car_paydown)
fresh.update!(target_amount: 10_000)
fresh.linked_accounts.update_all(balance: 0)
# Refetch instead of poking @current_balance directly so the test
# exercises the real memo lifecycle (a request reads progress_percent
# on a freshly-loaded record after the underlying balances changed).
reloaded = Goal.find(fresh.id)
assert_equal 0, reloaded.progress_percent
end
test "remaining_amount is non-negative" do
@goal.target_amount = 1
assert_equal 0, @goal.remaining_amount
end
test "pace is zero on a goal whose linked accounts have no transactions" do
fresh_account = Account.create!(
family: @family,
accountable: Depository.new,
name: "Empty Savings",
currency: "USD",
balance: 0
)
fresh = @family.goals.create!(
name: "Fresh goal",
target_amount: 100,
currency: "USD"
) { |g| g.goal_accounts.build(account: fresh_account) }
assert_equal 0, fresh.pace.to_d
end
test "pace averages 90-day net inflow, excluding pending and excluded entries" do
account = Account.create!(
family: @family,
accountable: Depository.new,
name: "Pace Savings",
currency: "USD",
balance: 0
)
goal = @family.goals.create!(
name: "Pace goal",
target_amount: 10_000,
currency: "USD"
) { |g| g.goal_accounts.build(account: account) }
# Three inflows over the 90-day window. Sure convention: inflows are
# negative. Net = -900 → pace = 900 / 3 = 300.
create_transaction(account: account, amount: -300, date: 80.days.ago.to_date)
create_transaction(account: account, amount: -300, date: 40.days.ago.to_date)
create_transaction(account: account, amount: -300, date: 5.days.ago.to_date)
# Pending inflow that must be excluded by `Transaction.excluding_pending`.
pending_entry = create_transaction(account: account, amount: -1_000, date: 10.days.ago.to_date)
pending_entry.transaction.update!(extra: { "plaid" => { "pending" => true } })
# User-excluded outflow that must be excluded by `entries.excluded = false`.
excluded_entry = create_transaction(account: account, amount: 500, date: 20.days.ago.to_date)
excluded_entry.update!(excluded: true)
# Entry outside the 90-day window — must be ignored.
create_transaction(account: account, amount: -10_000, date: 200.days.ago.to_date)
assert_equal 300, goal.pace.to_d
end
test "months_of_runway is nil when goal has a target date" do
assert_not_nil @goal.target_date
assert_nil @goal.months_of_runway
end
test "months_of_runway is nil when pace is zero" do
fresh = goals(:emergency_fund)
assert_nil fresh.months_of_runway
end
test "AASM transitions" do
fresh = goals(:emergency_fund)
assert fresh.active?
fresh.pause!
assert fresh.paused?
fresh.resume!
assert fresh.active?
fresh.complete!
assert fresh.completed?
fresh.archive!
assert fresh.archived?
fresh.unarchive!
assert fresh.active?
end
test "status: reached when balance >= target" do
@goal.target_amount = 1
assert_equal :reached, @goal.status
end
test "status: no_target_date when target_date is nil" do
@goal.target_date = nil
@goal.target_amount = 10_000
@goal.linked_accounts.update_all(balance: 100)
assert_equal :no_target_date, @goal.status
end
test "display_status returns :archived for archived goal regardless of progress" do
@goal.save!
@goal.archive!
assert_equal :archived, @goal.display_status
end
test "display_status returns :paused for paused goal regardless of progress" do
@goal.save!
@goal.pause!
assert_equal :paused, @goal.display_status
end
test "display_status falls through to status for active goals" do
@goal.target_amount = 1
assert_equal :reached, @goal.display_status
end
test "advisory_lock_key_for is stable per family" do
k1 = Goal.advisory_lock_key_for(@family.id)
k2 = Goal.advisory_lock_key_for(@family.id)
assert_equal k1, k2
assert_kind_of Integer, k1
end
test "any_connected_account? reflects plaid_account presence" do
assert @goal.any_connected_account?
only_manual = goals(:emergency_fund)
only_manual.goal_accounts.where(account_id: @connected.id).destroy_all
assert_not only_manual.reload.any_connected_account?
end
test "pledge_action_label_key flips on manual-only goals" do
assert_equal "goals.show.pledge_just_transferred", @goal.pledge_action_label_key
@goal.goal_accounts.where(account_id: @connected.id).destroy_all
# After removing the only connected account, the goal is manual-only;
# the copy must flip to "pledge_just_saved" so users aren't told to
# wait for a sync that won't run. Refetch to exercise the real
# request lifecycle rather than poking a memo on the same instance.
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
test "contributions basis excludes market gains" do
account = Account.create!(family: @family, accountable: Investment.new, name: "Brokerage", currency: "USD", balance: 10_000)
account.balances.create!(date: 10.days.ago.to_date, balance: 10_000, currency: "USD", net_market_flows: 3_000)
goal = @family.goals.create!(name: "Invest goal", target_amount: 20_000, currency: "USD") do |g|
g.goal_accounts.build(account: account)
end
assert_equal "contributions", goal.progress_basis
# 10,000 value 3,000 market gain = 7,000 contributed.
assert_equal BigDecimal("7000"), goal.current_balance.to_d
assert_equal BigDecimal("10000"), goal.market_value_money.amount
end
test "reopen transitions a completed goal back to active" do
fresh = goals(:emergency_fund)
fresh.complete!
assert fresh.completed?
fresh.reopen!
assert fresh.active?
end
test "investment accounts default to transfer pledge kind, never manual_save" do
assert_equal "transfer", accounts(:investment).default_pledge_kind
end
test "adding an investment account via update flips a depository goal to contributions" do
goal = goals(:emergency_fund)
assert_equal "balance", goal.progress_basis
goal.goal_accounts.build(account: accounts(:investment))
goal.save!
assert_equal "contributions", goal.reload.progress_basis
end
test "earmark is respected on a contributions-basis goal" do
account = Account.create!(family: @family, accountable: Investment.new, name: "Brokerage2", currency: "USD", balance: 10_000)
account.balances.create!(date: 5.days.ago.to_date, balance: 10_000, currency: "USD", net_market_flows: 2_000)
goal = @family.goals.create!(name: "Earmarked invest", target_amount: 20_000, currency: "USD") do |g|
g.goal_accounts.build(account: account, allocated_amount: 1_000)
end
assert_equal "contributions", goal.progress_basis
# net contributed = 10,000 2,000 = 8,000; earmark 1,000 ≤ 8,000 → 1,000.
assert_equal BigDecimal("1000"), goal.current_balance.to_d
end
end