Files
sure/test/models/goal_consumption_test.rb
T
buzzromainandClaude Opus 5 f0333c026e feat(goals): surface money that left a goal's accounts unexplained (#3177)
* feat(goals): surface money that left a goal's accounts unexplained

Goals never read transactions. `current_balance` is a stock summed from account
balances, so an outflow reaches a goal only as a smaller number, with nothing
saying which goal it belonged to. `consume!` closes that gap, but only for a
user who thinks to declare it — and the whole difficulty is that they have no
reason to think of it.

`Goal::WithdrawalDetector` surfaces the outflows nothing has claimed, and the
goal page offers them: *if any of this was spent on Trip, say so*. One click
records it with the transaction as evidence.

This is the pull half of what `GoalPledge` does for money coming in. A pledge
asks first and matches later; here there is nothing to promise, so the outflow
is surfaced after the fact and attributed — or not.

**Anchored on the transaction, not declared.** `consume!` now takes one and
stamps `extra["goal"]["consumed_goal_id"]`, the same namespace the pledges
write into. That is what makes attribution idempotent: replaying it cannot
credit a goal twice for one spend, and the stamp happens inside the
consumption's own transaction so a refusal rolls the whole thing back.

**Sign matters more than it reads.** In Sure an inflow carries a NEGATIVE
amount, so the detector selects the positive side. Reading it the other way
round would have offered to attribute the user's deposits as spending, and the
mistake would look right in a diff. A test pins it.

**A reserve is excluded.** It is drawn down and refilled, not spent, and asking
someone to attribute a withdrawal from one invites them to erase the very
shortfall it exists to report.

Known limitation, unchanged by this: `GoalPledge::Reconciler` only runs on
provider imports, never on a hand-entered transaction. This detector reads
entries directly and so has no such gap, but the two halves are not symmetric
and that is worth knowing.

bin/rails test: 7100 runs, 28540 assertions, 0 failures. RuboCop, erb_lint and
Brakeman clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* fix(goals): only offer an outflow the goal could still have spent

Review on #3177.

The panel offered outflows for completed and archived goals. Those have
handed their accounts back, so a later transaction on one is not evidence
about this goal — attributing it writes spending into a history that is
already closed. The detector now returns nothing for a released goal;
`consume!` refuses these too, but the panel should not ask in the first
place.

Provisional transactions were offered as well. A pending charge can be
reversed or replaced by its posted form, leaving the goal consumed for a
transaction that no longer exists while the posted twin arrives unstamped
and gets offered again. Filtered through the pending-provider SQL the rest
of the app already uses.

`thaw_completed_amount!` wiped `consumed_amount` unconditionally, so a goal
that recorded a spend and was then archived straight from active lost that
history on unarchive — and dropped its progress with it. Restarting is what
clears the figure, and a direct archive never closed a lifecycle to restart
from. Cleared now only when a frozen figure exists.

The attribution button was a hand-rolled `button_to` with raw `btn`
classes; it is `DS::Button` now, the same primitive the consumption dialog
uses, so the two ways of recording a spend do not read as two features.

Carried down from #3176 by rebase: the goal-level lock, the `:not_active`
guard, and the success notice, which was blank on this path because the
form posts only `transaction_id`. The resolved amount is formatted through
`Money` and the account behind an attributed outflow now resolves through
`accessible_accounts` like the named one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* fix(goals): keep a private backing account out of the outflow panel

The same leak #3176 closed on the dialog, through a third door. A goal can
be backed by an account private to another family member, and the panel
listed its outflows — naming the account, what was spent on it and roughly
its size to someone with no access to it.

`WithdrawalDetector` takes `accounts:` now, and the controller passes the
links narrowed to what the viewer may see. It defaults to every linked
account for callers with no viewer to speak for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* fix(goals): use the shared separator key in the outflow panel

DS Drift Patrol on #3177. The `·` between an outflow's date and its
account was a bare literal. `shared.dot_separator` already exists and is
already used three times in the goals views, so this was drift rather than
a missing mechanism.

Wrapped in `aria-hidden` like the existing uses: the separator is
decorative, and a screen reader was reading it out between the two values.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 21:15:31 +02:00

248 lines
9.5 KiB
Ruby

require "test_helper"
# Lot B4: recording that a goal's money was spent on what it was for.
class GoalConsumptionTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
end
test "spending a goal's money does not look like falling behind" do
goal = goal_with(earmark: 5_000, balance: 5_000, target: 5_000)
assert_equal 100, goal.progress_percent
goal.consume!(3_000)
assert_equal 3_000, goal.consumed_amount
assert_equal 100, goal.progress_percent, "spent money still counts toward the target"
assert_equal 0, goal.remaining_amount
end
# The part that is easy to miss: money already spent must stop being
# reserved, or it keeps its share of the account away from sibling goals.
test "consuming shrinks the earmark by the same amount" do
goal = goal_with(earmark: 5_000, balance: 5_000, target: 5_000)
goal.consume!(2_000)
assert_equal 3_000, goal.goal_accounts.first.reload.allocated_amount
end
# Deliberately over-earmarked, which is the normal state of a shared account
# mid-saving: 10,000 claimed on 6,000 held, so the pro-rata haircut is in
# play and both goals read 3,000. Releasing spent money relieves the haircut
# and the sibling's share grows — where two plain fixed earmarks inside the
# balance would each just take their own amount and nothing would move.
test "the freed share relieves the haircut on a sibling goal" do
account = fresh_account(6_000)
mine = goal_on(account, earmark: 5_000, target: 5_000, name: "Trip")
sibling = goal_on(account, earmark: 5_000, target: 5_000, name: "Sibling")
before = Goal.find(sibling.id).current_balance.to_d
assert_equal 3_000, before, "the fixture should start out haircut"
mine.consume!(2_000)
assert_equal 3_750, Goal.find(sibling.id).current_balance.to_d
end
# A whole-account link reserves no fixed slice, so there is nothing to shrink.
test "a whole-account link records the consumption and keeps its shape" do
account = fresh_account(5_000)
goal = @family.goals.create!(name: "Whole", target_amount: 5_000, currency: "USD") do |g|
g.goal_accounts.build(account: account)
end
goal.consume!(1_000)
assert_equal 1_000, goal.consumed_amount
assert_nil goal.goal_accounts.first.reload.allocated_amount
end
test "with several linked accounts the caller has to say which one" do
goal = goal_with(earmark: 3_000, balance: 3_000, target: 6_000)
goal.goal_accounts.create!(account: fresh_account(3_000), allocated_amount: 3_000)
error = assert_raises(Goal::ConsumptionRefused) { goal.consume!(1_000) }
assert_equal :account_required, error.reason
end
test "naming one of the linked accounts is enough" do
goal = goal_with(earmark: 3_000, balance: 3_000, target: 6_000)
second = fresh_account(3_000)
goal.goal_accounts.create!(account: second, allocated_amount: 3_000)
goal.consume!(1_000, account: second)
assert_equal 2_000, goal.goal_accounts.find_by(account_id: second.id).reload.allocated_amount
end
test "an account the goal is not linked to is refused" do
goal = goal_with(earmark: 3_000, balance: 3_000, target: 5_000)
error = assert_raises(Goal::ConsumptionRefused) { goal.consume!(500, account: fresh_account(100)) }
assert_equal :account_not_linked, error.reason
end
test "consuming more than the goal ever set out to save is refused" do
goal = goal_with(earmark: 5_000, balance: 5_000, target: 5_000)
error = assert_raises(Goal::ConsumptionRefused) { goal.consume!(5_001) }
assert_equal :exceeds_target, error.reason
assert_equal 0, goal.reload.consumed_amount
end
test "a non-positive amount is refused" do
goal = goal_with(earmark: 5_000, balance: 5_000, target: 5_000)
assert_equal :non_positive, assert_raises(Goal::ConsumptionRefused) { goal.consume!(0) }.reason
end
# A reserve is drawn down and refilled, not consumed. Recording a withdrawal
# as consumption would erase the shortfall the reserve exists to report.
test "a reserve refuses consumption outright" do
account = fresh_account(4_000)
reserve = @family.goals.create!(
name: "Precaution", target_amount: 6_000, currency: "USD", kind: "maintained"
) { |g| g.goal_accounts.build(account: account) }
error = assert_raises(Goal::ConsumptionRefused) { reserve.consume!(1_000) }
assert_equal :maintained, error.reason
end
test "reopening a goal clears what was spent under its previous life" do
goal = goal_with(earmark: 5_000, balance: 5_000, target: 5_000)
goal.consume!(2_000)
goal.complete!
goal.reload.reopen!
assert_equal 0, goal.reload.consumed_amount
end
# `completed_amount` freezes the BACKING; consumption is counted separately.
# Folding one into the other would count the same money twice on a goal that
# was partly spent and then closed.
test "closing after a partial spend counts each side once" do
goal = goal_with(earmark: 5_000, balance: 5_000, target: 5_000)
goal.consume!(2_000)
goal.complete!
reloaded = Goal.find(goal.id)
assert_equal 3_000, reloaded.completed_amount, "the frozen figure is the backing alone"
assert_equal 2_000, reloaded.consumed_amount
assert_equal 0, reloaded.remaining_amount
end
# --- Review follow-ups ---
# Clamping released only what the link held while `consumed_amount` took the
# full figure, so the two sides silently disagreed: money counted as spent
# that was never released, and still reserved against every sibling.
test "consuming more than the chosen link holds is refused, not clamped" do
goal = goal_with(earmark: 3_000, balance: 6_000, target: 9_000)
second = fresh_account(6_000)
goal.goal_accounts.create!(account: second, allocated_amount: 1_000)
error = assert_raises(Goal::ConsumptionRefused) { goal.consume!(2_000, account: second) }
assert_equal :exceeds_earmark, error.reason
assert_equal 0, goal.reload.consumed_amount
assert_equal 1_000, goal.goal_accounts.find_by(account_id: second.id).reload.allocated_amount
end
# A dialog left open in another tab, or a client posting directly.
test "a goal that is no longer active records nothing" do
goal = goal_with(earmark: 5_000, balance: 5_000, target: 5_000)
goal.complete!
error = assert_raises(Goal::ConsumptionRefused) { goal.reload.consume!(1_000) }
assert_equal :not_active, error.reason
assert_equal 0, goal.reload.consumed_amount
end
# A reserve refuses consumption, so a converted goal would carry a figure
# counting toward progress on an object whose model says spending is a
# shortfall to refill. The two readings cannot both be true.
test "a goal that has recorded a spend cannot become a reserve" do
goal = goal_with(earmark: 5_000, balance: 5_000, target: 5_000)
goal.consume!(1_000)
goal.kind = "maintained"
assert_not goal.valid?
assert_includes goal.errors[:kind],
"This goal has already recorded money as spent, so it cannot become a reserve."
end
# The consumed total was only checked while consuming, leaving the ordinary
# edit form free to lower the target underneath it.
test "the target cannot be lowered below what was already spent" do
goal = goal_with(earmark: 5_000, balance: 5_000, target: 5_000)
goal.consume!(3_000)
assert_not goal.update(target_amount: 2_000)
assert_equal 5_000, goal.reload.target_amount
end
# `reload` refreshes columns and leaves memos standing, so an instance that
# had already read its backing kept reporting the figure from before the
# spend — while progress, which nets the two, looked unchanged and hid it.
#
# Progress holding at 50% is not the bug, it is the feature: the earmark
# shrinks by exactly what consumption grows by, so spending the money does
# not move the bar. The backing underneath it does move, and must say so.
test "the figures an instance already read are refreshed by consuming" do
goal = goal_with(earmark: 5_000, balance: 5_000, target: 10_000)
assert_equal 5_000, goal.current_balance.to_d
assert_equal 50, goal.progress_percent
goal.consume!(2_000)
assert_equal 3_000, goal.current_balance.to_d, "the memo survived the spend"
assert_equal 50, goal.progress_percent, "and progress is preserved, which is the point"
end
# Restarting a goal drops what it spent under its previous life, along with
# the frozen figure. A goal archived straight from active never froze one —
# unarchiving it is picking the same goal back up, not restarting it.
test "unarchiving a goal that never completed keeps what it spent" do
goal = goal_with(earmark: 5_000, balance: 5_000, target: 5_000)
goal.consume!(1_000)
goal.archive!
goal.unarchive!
assert_equal 1_000, goal.reload.consumed_amount
end
test "reopening a completed goal starts it over" do
goal = goal_with(earmark: 5_000, balance: 5_000, target: 5_000)
goal.consume!(1_000)
goal.complete!
goal.reopen!
assert_equal 0, goal.reload.consumed_amount
end
private
def fresh_account(balance)
Account.create!(
family: @family, accountable: Depository.new,
name: "Pot #{SecureRandom.hex(4)}", currency: "USD", balance: balance
)
end
def goal_on(account, earmark:, target:, name:)
@family.goals.create!(name: name, target_amount: target, currency: "USD") do |g|
g.goal_accounts.build(account: account, allocated_amount: earmark)
end
end
def goal_with(earmark:, balance:, target:)
goal_on(fresh_account(balance), earmark: earmark, target: target, name: "Trip #{SecureRandom.hex(4)}")
end
end