Files
sure/test/models/goal_test.rb
T
Guillem Arias FausteandJuan José Mata acf4cb2010 fix(goals): allow deleting a goal without archiving it first (#2963)
* fix(goals): allow deleting a goal without archiving it first

Goals could only be deleted after being archived. `GoalsController#destroy`
redirected with "Archive the goal before deleting it." unless the goal was
already archived, and the Delete item in the show-page kebab was wrapped in
`if @goal.archived?`. Nothing in the archive confirm copy hinted that
archiving was the prerequisite, so in practice an active goal had no delete
affordance anywhere in the UI.

The gate bought no safety. Destroying a goal cascades only to its own
`goal_accounts` and `goal_pledges`, and `GoalPledge#clear_matched_transaction_extra`
unstamps `extra["goal"]["pledge_id"]` from any transaction a matched pledge
claimed. No account, balance, entry or transaction is touched. Every other
resource in Sure (accounts, categories, rules, family merchants) deletes in
one step.

Drop the gate, render Delete unconditionally, and shorten the label from
"Delete permanently" to "Delete" now that it no longer needs to contrast
with an archive-first step.

The confirm copy moves to `Goal#deletion_confirm` and spells out what
survives. The generic `CustomConfirm.for_resource_deletion` only says "This
is not reversible", which overstates it for a goal.

Index cards deliberately keep no actions — the card stays a single click
target, and the show-page kebab is one click away.

* fix(goals): escape the goal name in the delete confirmation

`confirm_dialog_controller` assigns the confirm `body` to `innerHTML` — bodies
such as the accounts' `confirm_body_html` legitimately carry markup — so a goal
named "<img src=x onerror=…>" ran as soon as a family member opened the delete
confirmation. Verified in a browser: parsing the rendered `data-turbo-confirm`
and assigning its body produced a live `<img>` element with a working `onerror`
handler.

Escape the interpolated name. Only `body` needs it; the dialog sets its title
and button label with `textContent`.

`CustomConfirm.for_resource_deletion` interpolates a record name into the same
HTML-rendered body and was already reachable from accounts, categories, rules
and family merchants, so it is escaped here too rather than left as a known
hole next to the fixed one.

Also add the three `confirm_delete_*` keys to every locale that ships goal
translations. Fallbacks meant these silently rendered English rather than
breaking, so this is untranslated copy rather than a fault — ru is included,
which the review list omitted.

* i18n(confirm): move the resource-deletion copy to locale keys

`for_resource_deletion` built its title, body and button label as English
string interpolation, against the project's rule that user-facing strings go
through `t()`. It backs ~39 call sites — accounts, rules, tags, chats, every
provider item — so all of them were English-only.

Moved to `shared.custom_confirm.resource_deletion_*`, alongside the
`default_*` keys the same class already used.

`titleize` / `downcase` stay applied to the record name so the English output
is byte-identical to what the hardcoded strings produced; a locale needing
different casing can absorb it in its own string. Pinned by a test, along with
the escaping of the one field the dialog renders as HTML.

* i18n(confirm): translate the resource-deletion copy

The keys added when this copy moved out of hardcoded English only landed in
en.yml, leaving ~40 call sites falling back to English in every other locale.

Added to the eight other shared locale files that already carry the sibling
`custom_confirm.default_*` strings: ca, fr, hu, it, ru, tr, vi, zh-CN. Each
body reuses that locale's own "this is not reversible" sentence, so the
generic and resource-specific confirmations read the same, and each follows
the register its `default_title` already set (vous / siz / Вы, tu for ca).

The remaining shared locale files (de, es, nb, nl, pl, pt-BR, ro, zh-TW) have
no `custom_confirm` block at all, so they are left alone — adding one would
invent structure they have not adopted, and fallbacks already cover them. The
test derives its locale list from which files define the sibling key rather
than hardcoding it, so it follows that set as it grows.

* test(goals): restore the active-goal destroy test lost in the merge

Merging main into this branch hit a conflict in
`test/controllers/goals_controller_test.rb`: main had added two tests
immediately above the destroy block, and the resolution took main's side
wholesale for that hunk. That resurrected `destroy on non-archived is
rejected` — the test this PR replaces — and dropped its replacement.

The resurrected test failed against the new controller, since destroy no
longer gates on `archived?`:

    GoalsControllerTest#test_destroy_on_non-archived_is_rejected
    `Goal.count` didn't change by 0, but by -1.

Swap it back for `destroy deletes an active goal and cascades to its
links and pledges`. Main's two new tests stay.

* i18n(goals): finish the delete copy in de and zh-TW

Nine locales ship goals translations, not seven. `de.yml` and `zh-TW.yml`
were left behind: both still carried the dead `goals.destroy.archive_first`
key, still labelled the kebab item "Delete permanently" (Endgültig löschen
/ 永久刪除) after it was shortened elsewhere, and had none of the
`confirm_delete_*` keys, so a German or Traditional Chinese family saw the
new delete dialog in English.

Add the three confirm keys using each file's existing vocabulary — Zusagen
for pledges in German (informal du, matching the rest of the file), 投入 in
Traditional Chinese — drop `archive_first`, and shorten the label.

`confirm_delete copy resolves in every locale that ships goal translations`
could not have caught this. It hardcoded the seven locales, and its
assertions went through plain `I18n.t`: the backend has
I18n::Backend::Fallbacks mixed in, so a missing German key resolved to the
English string and `.present?` passed anyway. Verified — deleting
`confirm_delete_title` from `de.yml` left the test green.

Derive the locale list from the goals YAMLs and look the keys up with
`fallback: false, default: nil`. The same deletion now fails with
"de is missing goals.show.confirm_delete_title".

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-08-16 08:12:45 +02:00

547 lines
22 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
# The confirm dialog assigns `body` to innerHTML, so an unescaped goal name
# would execute when a family member opens the delete confirmation.
test "deletion_confirm escapes the goal name in the dialog body" do
@goal.name = "<img src=x onerror=alert(1)>"
body = @goal.deletion_confirm.to_data_attribute[:body]
assert_includes body, "&lt;img src=x onerror=alert(1)&gt;"
assert_no_match(/<img/, body)
end
# Derived, not a hardcoded list: a locale added later ships a goals YAML
# without these keys otherwise. `fallback: false` carries as much weight —
# the backend has I18n::Backend::Fallbacks mixed in, so a missing German key
# resolves to the English string and a plain `.present?` assertion passes
# while a German family reads English in the dialog.
test "confirm_delete copy resolves in every locale that ships goal translations" do
locales = Rails.root.glob("config/locales/views/goals/*.yml").map { |f| f.basename(".yml").to_s.to_sym }
assert_operator locales.size, :>=, 7, "goal locale files disappeared — check the glob"
locales.each do |locale|
body = I18n.t("goals.show.confirm_delete_body", name: "Wedding", locale: locale, fallback: false, default: nil)
assert body, "#{locale} is missing goals.show.confirm_delete_body"
assert_includes body, "Wedding", "#{locale} dropped the %{name} interpolation"
%w[confirm_delete_title confirm_delete_cta].each do |key|
assert I18n.t("goals.show.#{key}", locale: locale, fallback: false, default: nil).present?,
"#{locale} is missing goals.show.#{key}"
end
end
end
# The days-left segment is returned separately so the view can keep it
# unbroken; joined into one string it wrapped after "days" on a phone.
test "header_summary_parts keeps the days-left phrase in its own segment" do
# Pinned: the count is `target_date - Date.current`, so a suite that crossed
# midnight between setting the date and reading it would report 183.
travel_to Date.new(2026, 6, 1) do
# Target well above the linked account's balance, or the goal reads as
# :reached and drops the days-left segment on purpose.
@goal.target_amount = 1_000_000
@goal.target_date = Date.current + 184
parts = @goal.header_summary_parts
assert_equal 2, parts.size
assert_match(/184 days left/, parts.last)
assert_no_match(/days left/, parts.first)
end
end
test "header_summary_parts omits days left once the target is reached" do
@goal.target_date = 184.days.from_now.to_date
@goal.complete!
assert_equal 1, @goal.header_summary_parts.size
end
test "header_summary_parts is a single segment without a target date" do
@goal.target_date = nil
assert_equal 1, @goal.header_summary_parts.size
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