Goals and Savings Tracking#
A Goal in Sure tracks savings progress by reading live balances from linked bank or investment accounts — there is no parallel contribution ledger. The central Goal model belongs to a Family, links accounts via the GoalAccount join model, and manages pledge records via GoalPledge.
Goals progress through four AASM states: active, paused, completed, and archived . The complete transition is guarded to one_off goals only—maintained/reserve goals cannot be completed. A reopen transition (completed → active) was added in Phase 2 to handle investment goals whose value dips after manual completion; reopening or unarchiving thaws the completed_amount and returns the goal to live balance calculation. When a goal is reopened, consumed_amount is reset to 0 only if the goal previously completed (if completed_amount is present). A goal archived straight from active never froze a figure, so unarchiving it is picking the same goal back up rather than restarting it—wiping what it had recorded as spent would delete history nothing replaced, and drop its progress for no reason the user can see. Only Depository and Investment accounts can be linked to a goal ; the goal's currency is locked once any account is attached .
Each goal has a kind field that controls fundamental lifecycle differences:
one_off— A goal reached once and then closed (e.g., saving for a vacation). At 100%, the user closes it, releasing the earmark.maintained— A reserve to hold at a level (e.g., an emergency fund). At 100%, it stays active in the:fundedstatus; there is no target to "finish". Every withdrawal creates a shortfall to make good. Thetarget_dateis cleared on save because reserves have no deadline.
Target modes for reserves: Maintained goals can set their target in two ways via the target_mode field:
fixed— A statictarget_amountthat does not change over time (the original behavior). Use when the reserve floor is a specific sum (e.g., "$5,000 emergency fund").months_of_expenses— A dynamic target based on N months of the family's median monthly spending. The goal stores how many months to cover intarget_months(e.g., 6 for "six months of expenses"), and the system recomputestarget_amountmonthly from the family's spending patterns. Use when the reserve should track living costs—a raise, a move, or a child changes what coverage the reserve needs, and this mode adjusts automatically. The UI shows hint text: "Recomputed on the 1st of each month from your median monthly spending."
target_amount remains the single source of truth—the background job rewrites this column rather than creating a separate effective target, so existing aggregates (remaining_amount, progress_percent, Goal.summary_for, the ring, the card) all continue working without modification.
How months-of-expenses recomputation works: When a reserve uses the months_of_expenses mode:
RefreshMaintainedGoalTargetsJobruns on the 1st of each month, querying all maintained goals inmonths_of_expensesmode that are not in released states.- The target is computed by
Goal#months_of_expenses_amount: it calculates the family's median monthly expense from visible, report-included accounts, multiplies bytarget_months, and converts from family currency to goal currency if needed. - The computation also runs on creation and whenever
target_monthsortarget_modechanges (via theapply_months_of_expenses_targetbefore-save callback), so a reserve created mid-month shows the correct figure immediately rather than a placeholder until the 1st. - Safe failure modes: If the median cannot be computed (no spending history, zero median, or currency conversion unavailable), the previous
target_amountstands untouched—a stale floor is safer than a wrong one, and a reserve silently frozen at zero would declare itself complete. - Failures during the job are logged via
DebugLogEntry.capturewithcategory: "goals", surfacing them in the support UI where a user can see the reserve stopped updating.
Form behavior: The target mode field is shown only for maintained goals, not one-off goals. When in months_of_expenses mode, the months field appears and the target amount field becomes read-only (it displays the computed value but prevents edits, since the figure is derived). The Stimulus controller (goal_kind_controller.js) toggles field visibility and resets target_mode to fixed when switching from maintained back to one-off, ensuring a one-off goal cannot be saved carrying a months mode nothing would refresh.
Validation: target_months requires the goal to be maintained and in months_of_expenses mode—it cannot be set for fixed-mode or one-off goals. The model validates target_months is a positive integer when present and enforces consistency through the months_target_requires_a_reserve validation.
Key source files:
| File | Role |
|---|---|
app/models/goal.rb | Core model — balance, pace, status, projections |
app/models/goal_pledge.rb | Pledge lifecycle and matching logic |
app/models/goal_pledge/reconciler.rb | Orchestrates pledge resolution from imports and reconciliations |
app/models/goal_account.rb | Join model; holds optional allocated_amount earmark |
app/models/account/reconciliation_manager.rb | Fires GoalPledge::Reconciler on balance writes |
app/models/goal/withdrawal_detector.rb | Detects unattributed outflows from goal accounts |
app/views/goals/_unattributed_outflows.html.erb | UI panel displaying unattributed outflows |
docs/llm-guides/goals.md | Developer guide (added in PR #1798) |
Balance Calculation and Earmarking#
Goal#current_balance sums each linked account's contribution through account_amount_for, which applies the active progress basis and earmark logic. All linked accounts must share the goal's currency; mismatches are logged and reported to Sentry.
Completion behavior: When a goal is marked complete, current_balance returns the frozen completed_amount value (set at completion time) rather than summing linked account contributions. This prevents a finished goal's reported amount from changing as the underlying accounts are spent. The guard is completed_amount.present?, not completed? — a goal archived directly from active or paused has no frozen amount and keeps the live calculation. The frozen completed_amount records the backing alone; consumed_amount is tracked separately to avoid double-counting money that was both spent and then backed at closure.
Earmarking (shared-pool): A GoalAccount row can store an allocated_amount earmark . When set, the goal claims only that fixed slice; an unallocated link (NULL amount) claims the account's full remaining balance after other goals' fixed earmarks. If total fixed earmarks across all goals exceed the account balance, each earmark is scaled down pro-rata so no double-counting occurs . When a goal records consumption (see Goal Consumption below), the earmark on that link shrinks by the consumed amount, releasing the spent money back to the shared pool. Whole-account links have no fixed slice to shrink, so they remain unaffected by consumption.
Whole-account exclusivity: A whole-account link (allocated_amount: NULL) claims the account's entire balance, so only one goal still holding its money may hold such a link to any given account at a time. The system enforces this at the door: GoalAccount validates that no other goal already has a whole-account claim on the same account when creating or updating a link to be unallocated [PR #3160]. The pro-rata haircut in Goal#backing_share_for applies only to fixed earmarks and cannot catch overlapping whole-account links, since an unallocated link contributes zero to the others_fixed sum used by the haircut logic.
Goals in Goal::RELEASED_STATES (['archived', 'completed']) release their whole-account claims. When a goal is marked complete, it releases its earmark from the shared pool (stops competing with sibling goals on the same account), freezes its amount at the completion value, and transitions to the completed state. This is distinct from pausing — a paused goal keeps its reservation, since pausing means "I have stopped feeding this", not "I have released it". Four places filter on RELEASED_STATES and must agree: Goal.pooled_allocations_for, Account#goal_earmarked_total, GoalAccount whole-account exclusivity validation, and the restore guard. These all check RELEASED_STATES to ensure the shared pool calculations stay consistent.
To avoid N+1 queries on the index page, the controller injects two pre-fetched maps:
Goal.pooled_allocations_for(family)— all earmarks from goals not inRELEASED_STATES, grouped byaccount_idGoal.market_flows_for(family)— cumulativenet_market_flowsper account (needed for contributions-basis goals)
Progress Basis: Balance vs. Contributions#
The progress_basis column controls what counts as "saved" .
| Basis | current_balance source | Use case |
|---|---|---|
balance (default) | Live account balance (or frozen completed_amount if completed) | Depository/cash goals |
contributions | Current value − cumulative market gain (Σ net_market_flows), or frozen completed_amount if completed | Investment-backed goals |
Goals backed by an investment account automatically default to contributions — this is enforced both on create and on update (e.g., when adding a brokerage to an existing goal) via the default_progress_basis_for_investment before-save callback. Depository accounts always have zero net_market_flows, so their behavior is identical on both bases .
Goal#market_value_money is always the live market value of the goal's backing, regardless of basis or completion status — shown as a secondary figure on the goal ring for contributions-basis goals. The market_value_money is not frozen at completion; it explicitly reflects "what this is worth today".
Progress Calculation#
Progress percentage (Goal#progress_percent) is calculated as:
((current_balance + consumed_amount) / target_amount) * 100
Money already spent on the goal's purpose counts toward the target. Without this, completing a goal (e.g., taking the vacation it paid for) would drop it from 100% to a lower percentage, making success look like failure.
Remaining amount (Goal#remaining_amount) is calculated as:
target_amount - current_balance - consumed_amount
Money already spent counts as "found" since it did its job. This prevents goals from appearing to fall behind after spending the saved money.
Goal Consumption#
Users can record when they spend money saved for a goal using Goal#consume!(amount, account: nil, transaction: nil). Consumed amounts count toward goal progress but reduce the earmarked allocation on the account. This prevents a goal from looking like it fell behind when the money was spent exactly as intended.
Behavior: The consume! method accepts an amount and optionally an account (required if the goal has multiple linked accounts). Two things happen: the goal's consumed_amount increments by the specified amount, and the earmark on the specified account link shrinks by the same amount. This releases the spent money back to the shared pool, preventing double-counting—money already spent should not stay reserved against sibling goals.
The transaction: parameter anchors the consumption record on the outflow it came from. Without a transaction, this is a bare declaration and nothing stops it being made twice. With a transaction, the transaction is stamped in extra["goal"]["consumed_goal_id"] and a second attempt on the same outflow is refused. This uses the same extra["goal"] namespace the pledges already write into, so the two halves of a goal's money—in and out—are stamped alike.
Restrictions: Consumption is only allowed for one_off goals, not maintained (reserve) goals. A reserve is drawn down and refilled, not consumed—recording a withdrawal as consumption would erase the shortfall the reserve exists to report. The goal must be in the active state, and the consumed amount cannot exceed the target or the earmarked amount on the chosen account link.
UI access: The UI provides a "consume" dialog accessible from the goal show page, offered only on one-off goals that are active and have a positive balance.
Validation rules:
amountmust be positiveconsumed_amountcannot exceedtarget_amount- For fixed earmark links,
amountcannot exceed the link'sallocated_amount - Cannot consume from a goal with no linked accounts
- Must specify which account when the goal has multiple links
kind_locked_once_consumed: Cannot convert a goal tomaintainedtype ifconsumed_amountis positivetarget_must_cover_what_was_consumed: Target amount cannot be lowered below the consumed amount
The consume! method raises Goal::ConsumptionRefused with a reason symbol when validation fails, allowing the UI to display specific actionable error messages rather than generic failures. New error reasons include :transaction_already_claimed (if another goal has claimed the outflow, or if replaying the same attribution) and :transaction_not_found (if the transaction is not in the list of unattributed outflows).
Unattributed Outflow Detection#
Goals never read transactions directly—current_balance is a stock summed from account balances. An outflow reaches the goal only as a smaller number with no idea which goal it belonged to. consume! closes that gap but only if the user thinks to declare it, which is the same problem the goal page had before the reserve insight: the app knows and does not say.
Goal::WithdrawalDetector finds money that has left a goal's accounts without anyone saying where it went, surfacing outflows the user can attribute. 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 the user attributes it—or does not.
Detection scope:
- Minimum threshold: 100 (in family currency units, tuned for dollar/euro scale). Below this an attribution is more friction than it is worth—a goal-backed savings account sees card-sized outflows nobody wants to file.
- Lookback window: 90 days (not all history). An outflow nobody attributed in three months is one nobody is going to, and asking forever turns the panel into a chore.
- One-off goals only: Reserves are drawn down and refilled rather than spent. Asking someone to attribute a withdrawal from one would invite them to erase the shortfall it exists to report.
- Active goals only: A released goal has handed its accounts back, so an outflow on one of them is no longer evidence about this goal. Offering it would invite the user to write spending into a history that is closed.
- Excludes attributed outflows: Transactions already stamped with
extra["goal"]["consumed_goal_id"]are filtered out. - Excludes provisional/pending transactions: A provisional charge can still be reversed or replaced by its posted form. Attributing one leaves the goal consumed for a transaction that no longer exists, while the posted twin arrives unstamped and is offered all over again.
UI presentation: The goal show page displays an "Outflows" panel when unattributed outflows exist. The panel shows up to 3 recent unattributed outflows from the goal's linked accounts, newest first. Users can attribute these outflows directly from the panel using an "Attribute" button.
Account visibility: Detection respects account visibility—only shows outflows from accounts the viewer can see. A goal can be backed by a private account, and surfacing its outflows here would name the account, its spending, and roughly its size to someone with no access to it. The controller narrows the detector's scope to eligible_consumption_accounts before surfacing any outflows.
Savings Velocity and Status#
Pace (Goal#pace) is a 90-day rolling monthly average: it sums net entry inflows across all linked accounts over the last 90 days and divides by 3. Pending and excluded entries are omitted. A known limitation: pace is whole-account inflow, not earmark-scoped — a goal covering only a slice of a shared account will see inflated pace.
Status (Goal#status) resolves to one of six values:
| Status | Condition |
|---|---|
:reached | (One-off only) Completed, or remaining_amount is zero |
:funded | (Maintained only) Reserve at its target level (100% funded). Green tone, shield-check icon. Steady state, not a celebration. |
:depleted | (Maintained only) Reserve below its target level. Amber tone, shield-alert icon; same urgency as :behind. |
:on_track | Has target date and pace ≥ monthly_target_amount |
:behind | Has target date and pace < monthly_target_amount |
:no_target_date | No deadline set |
Maintained goals never show :reached; they show :funded (at 100%) or :depleted (below 100%). Maintained goals are excluded from behind_pace? because they have no pace—no target_date means no monthly_target_amount.
UI behavior: When a one_off goal reaches 100% but is not yet completed, users see a "Close goal" call-to-action on the celebration panel. Closing releases the earmarked money for other goals on the same account. Once closed/completed, the celebration panel shows when and at what amount the goal was frozen (completed_at and completed_amount). Maintained goals at 100% stay active in the :funded status with no close action—completing them would release the earmark, which defeats their purpose. Closing a reserve is impossible; archiving is the only way to release one.
monthly_target_amount uses day-precision months remaining (not calendar-month rounding) to avoid a cliff effect near deadlines. catch_up_delta_money gives the extra monthly saving needed beyond current pace and any open pledges — so a user who has already pledged enough sees a zero catch-up demand. months_of_runway is available for open-ended goals without a target date.
The projection_payload method bundles the 90-day balance series, pace, required monthly amount, and projected end value into a single hash for the Stimulus chart controller.
Display order: ACTIVE_DISPLAY_STATUS_RANK defines how active goals sort: behind: 0, depleted: 0 (both need attention), on_track: 1, no_target_date: 2, funded: 3. Paused goals have explicit rank 5 — they used to share rank 3 with funded, causing paused goals to jump ahead of whole reserves when sorted alphabetically. Depleted reserves sort alongside behind goals because both signal "needs attention".
Lifecycle panels: Goals show different panels based on lifecycle state, componentized via Goals::LifecyclePanelComponent. The panel order is load-bearing: inactive (archived/paused), celebration (completed/reached/funded), reserve_shortfall (maintained goals below target), empty (no balance/pace yet), and projection (default). Reserve shortfall comes before empty to avoid asking a reserve for a pace it doesn't have. Depleted reserves display a callout showing the shortfall amount: "{amount} short of its level — top it up to bring the reserve back". Reserves show a special projection summary: "A reserve holds a level, so there is no finish line to project."
Pledges and Automatic Reconciliation#
When a user moves money toward a goal they record a GoalPledge . The system then watches for a matching entry and resolves the pledge automatically.
Pledge kinds :
transfer— for synced accounts and all investment accounts. Resolves on incomingTransactionentries (cash inflows only).manual_save— for manual cash-only accounts. Resolves on valuation deltas (balance change), not raw balance.
Investment accounts always use transfer because a positive valuation delta is usually a market move, not a deposit .
Matching tolerance : a pledge matches an entry when:
- Entry date is within ±5 days of the pledge's created/expires window
- Amount is within $0.50 or ±1% of the pledged amount
GoalPledge::Reconciler is the integration point:
- Invoked from
Account::ProviderImportAdapteron every imported transaction - Invoked from
Account::ReconciliationManageron every balance write, passing thevaluation_delta(new balance − prior balance) - Queries open pledges for the account ordered by
created_at, id— first eligible match wins resolve_with!(transaction)stampstransaction.extra["goal"]["pledge_id"]and marks pledgematchedresolve_with_valuation!marks matched without a transaction linkAlreadyClaimedErrorrace conditions are caught and handled gracefully
Expiry: SweepExpiredGoalPledgesJob runs every 15 minutes and expires any pledge past its expires_at (default 7 days) .