mirror of
https://github.com/we-promise/sure.git
synced 2026-08-05 16:42:18 +00:00
* feat(plan): unify Budgets and Goals under a single Plan tab
Preview users get one "Plan" nav entry (compass icon) in place of the
separate Budgets and preview-gated Goals items. It fronts a new /plan
hub with two summary cards — this month's budget (spent vs budgeted,
days left, top categories) and active goals (total saved vs targets,
behind/pending counts, per-goal rows) — each drilling into the existing
/budgets and /goals pages, whose breadcrumbs now start Home > Plan.
The two features share a home, not a model: no schema changes, no URL
changes. Users without preview features keep exactly the pre-Plan nav
(Budgets entry, Goals hidden), and /plan falls through to /budgets for
them.
Supporting changes:
- Goal.active_prepared_for: index-style sorted active goals with the
family-wide pooled-allocations + market-flows injection reused
- Goal::FUNDABLE_ACCOUNT_TYPES and Goal::ACTIVE_DISPLAY_STATUS_RANK
extracted from GoalsController
- Budget#days_remaining (same day math as suggested_daily_spending)
- Breadcrumbable#plan_breadcrumb_prefix for the conditional Plan crumb
- New BudgetsController web tests (previously untested) + Plans tests
* fix(plan): address review feedback on the Plan hub
- Keep the "All goals" footer link rendered when the family has only
completed/archived goals — the hub is a preview user's only route to
the goals index now that the Goals nav entry is gone (Codex P2)
- Replace the two hand-rolled footer button-links with DS::Link
(variant secondary, full_width, right icon) per DS Drift Patrol
- Fix DS::Link template comparing icon_position against the string
"right" — the initializer symbolizes it, so right-positioned icons
never rendered on links (DS::Button already compared symbols);
existing callers passing icon_position now get the layout they asked for
- Clamp progress-bar percentages to 0..100 instead of capping only the
upper bound (CodeRabbit)
* refactor(plan): single source of truth for goal loading, sorting, and counts
Addresses jjmata's draft review notes:
- GoalsController#index now builds on the shared Goal loaders instead
of hand-rolling its own copy: Goal.prepared_for (preloads + family-
wide backing-math injection, scope-able) and Goal.active_display_sort
carry the algorithm once; active_prepared_for composes them for the
hub. The controller-side constant alias is gone
- One definition of "behind pace": Goal#behind_pace? (excludes paused —
pausing stops the pace clock on purpose). Both the Plan hub summary
and GoalsController#kpi_payload's behind/needs-this-month figures use
it, so adjacent pages can't disagree. While there, the kpi on-track
numerator also excludes paused goals — it was counted against a
paused-excluding denominator, so the "X of Y" fraction could exceed
its own total
- BudgetCategory#suggested_daily_spending calls Budget#days_remaining
instead of keeping an inline copy of the day math
- Per the fat-model convention, the hub's aggregation moved off the
controller: Budget#top_spending_categories(limit:) and
Goal.summary_for(goals, currency:)
* fix(plan): move Edit budget/New goal into their own cards
Both actions lived in the hub's shared page header, unlinked to either
card and, on mobile, wrapping above all content before any real data
appeared. Each now lives in its own card's header instead: Edit budget
as a compact icon-only control next to the status pill (only when a
budget exists — the uninitialized state already has its own "Set up"
CTA), New goal as a small outline button next to the goals count (only
once there's a goal to sit beside; the empty state keeps its own CTA).
Also swaps the edit icon from "pencil" to "square-pen" — at the sizes
these header controls render, lucide's plain pencil is a thin diagonal
stroke that reads noticeably smaller than a neighboring bold glyph like
"plus", even in the same size box. square-pen carries more visual mass
and reads clearly at the same footprint.
* fix(plan): match established DS precedent for the card header actions
Edit budget was a bare icon-only button; verified against the app's
own precedent for this exact action (app/views/budgets/_budget_donut.html.erb,
the budget card already shipped on /budgets) and it's a labeled
secondary link with a trailing pencil, not icon-only and not a
three-dot menu. Matched that: DS::Link, variant secondary, size sm,
icon right. New goal gets the same treatment for consistency between
the two cards' header actions, rather than the full-page-scoped
"primary" weight goals/index.html.erb uses for its own create button —
that's calibrated for a whole page's sole CTA, not a compact card.
Adding a labeled button (wider than the bare icon this replaces)
crowded the header row on mobile enough to wrap "This month" onto two
lines and truncate the "· July 2026" meta away entirely. Header rows
now wrap as a whole (flex-wrap) with the title pinned (shrink-0) so
the action cluster drops to its own line instead of squeezing the
title and meta text.
Also drops the hub's footer note ("Budgets cap your spend; goals track
what you're saving toward...") — redundant with the subtitle right
above the cards.
* fix(plan): lead the budget card header with status, not the edit action
On Track/Over/Warning is what a glance at the card wants first; Edit
budget is the secondary action. Swapped their order so status leads
and the edit control trails, gap-2 unchanged.
* fix(plan): put the status pill on the left, next to the title
Meant the left side of the card, not just left of the edit button. On
Track/Over/Warning now sits beside "This month · July 2026" in normal
flow; ml-auto carries only the Edit budget link, alone on the right —
matching the goals card's own left-meta/right-action split ("· 7
active" left, "New goal" right).
* fix(plan): lead Edit budget with its icon, matching same-shape precedent
Wrong axis on the earlier match: _budget_donut's trailing pencil labels
the VALUE itself ("$12,850 ✎"), not a static action. Our button's label
is a static "Edit budget", and that shape takes a leading icon
everywhere else it appears — the categories "Edit" on budgets/show.html.erb
(icon: settings-2) and "Edit split" in transactions/show.html.erb both
lead with their icon. Drops icon_position: :right so it defaults to
left, matching New goal's shape in the sibling card.
* fix(plan): use the divider token for row separators, not border-primary
Traced against the dashboard outflows list (pages/dashboard/_outflows_donut.html.erb),
which renders its row separators via shared/_ruler → border-divider
(border-tertiary: black/8%, white/10%). Our category and goal rows used
border-b border-primary instead (black/15%, white/30%) — 2-3x heavier
than the established row-separator weight elsewhere in the app. Swapped
both to border-divider.
* fix(plan): lift the duplicated card shell into DS::Card
Codex P1: _budget_card.html.erb and _goals_card.html.erb hand-rolled
the identical "bg-container rounded-xl shadow-border-xs p-5 flex
flex-col" shell twice, with no DS:: card primitive to reach for
instead. Extracted a minimal wrapper — content-only, no header/footer
slots — matching what both cards actually need right now; the roadmap
cards (envelopes #2153, retirement #2044) can adopt it too instead of
copying the class string a third time.
Verified pixel-identical in a browser: same classes, same DOM shape,
just rendered through the component.
* fix(plan): batch pace queries before sorting goals
Codex P2: active_display_sort calls goal.status per goal to build the
sort key; Goal#status reaches Goal#pace for any goal with a
target_date, which fired its own Entry.sum(:amount) query per goal.
The /plan hub renders only the first 5 of active_prepared_for's list,
but paid the full O(N) query cost sorting all of them.
Adds Goal.pace_for(family) (account_id => 90-day net inflow), grouped
in one query and injected via inject_backing_math! alongside the
existing pooled_allocations/market_flows pattern. #pace now sums from
that shared map instead of firing its own query — same math, same
90-day window, same exclusions, just computed once per family instead
of once per goal.
483 lines
19 KiB
Ruby
483 lines
19 KiB
Ruby
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
|
||
|
||
test "behind_pace? excludes paused goals even when their raw status is behind" do
|
||
goal = goals(:vacation_italy)
|
||
goal.stubs(:status).returns(:behind)
|
||
|
||
assert goal.behind_pace?
|
||
|
||
goal.update!(state: "paused")
|
||
|
||
assert goal.paused?
|
||
assert_not goal.behind_pace?
|
||
end
|
||
|
||
test "summary_for counts behind goals via behind_pace? and sums one currency" do
|
||
behind = goals(:vacation_italy)
|
||
behind.stubs(:status).returns(:behind)
|
||
paused_behind = goals(:emergency_fund)
|
||
paused_behind.update!(state: "paused")
|
||
paused_behind.stubs(:status).returns(:behind)
|
||
|
||
summary = Goal.summary_for([ behind, paused_behind ], currency: "USD")
|
||
|
||
assert_equal 1, summary[:behind_count]
|
||
assert_kind_of Money, summary[:saved_money]
|
||
end
|
||
end
|