mirror of
https://github.com/we-promise/sure.git
synced 2026-09-08 16:14:23 +00:00
* 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>
248 lines
9.5 KiB
Ruby
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
|