feat(savings): add savings goals

Adds a standalone Savings goals feature: a piggy-bank style tracker that
lets a family set a target, link one or more Depository accounts as
funding sources, and log manual contributions over time. Supersedes #1569
(closed) — same intent, redesigned per reviewer + Discord feedback.

What this adds:

- New `/savings_goals` sidebar entry (piggy-bank icon) with index, show,
  state-filtered tabs (all/active/paused/completed/archived), and a
  2-step modal stepper for creation (Identity → Review).
- Multi-account funding via a `SavingsGoalAccount` join: a goal requires
  ≥1 linked Depository account (checking/savings/HSA/CD/money-market),
  and all linked accounts must share the goal's currency.
- Tracker balance model: goal balance = SUM(contributions.amount). No
  auto-flow from account balances. Contributions are pure logical
  records and don't move money between accounts.
- Manual contributions modal scoped to the goal's linked accounts.
  Initial contributions seeded at creation can't be deleted; manual
  ones can.
- AASM lifecycle: active / paused / completed / archived.
  Hard-delete only after archive.
- Status pills (On track / Behind / Reached / No date) derived from
  pace vs target_date.
- AI Assistant tool `create_savings_goal` lets the sidebar chat create
  a goal end-to-end from a natural-language prompt; soft errors carry
  the available-accounts list back to the LLM (mirrors the existing
  `import_bank_statement` pattern).
- Family-scoped throughout (`Current.family`-only access, account
  family-scoping enforced both in controllers and the AI tool).
- Demo data seed wires up 4 sample goals across the Depository accounts.

Intentionally out of scope (separate PRs / v1.1):

- Auto-fund from budget surplus + Sidekiq cron + budget-show card.
- Dashboard "Savings goals" widget.
- "Behind pace" projection chart on the detail page.
- `evaluate_savings_goal_feasibility` LLM tool (level-setting before
  create_savings_goal).
- Spend-less goals inside Budgets.
- Family-member-private goals (deferred investigation).
This commit is contained in:
Guillem Arias
2026-05-11 11:20:37 +02:00
parent 36960fe058
commit 77660d2ee4
49 changed files with 2419 additions and 5 deletions

View File

@@ -0,0 +1,25 @@
<section class="bg-container rounded-xl shadow-border-xs p-4">
<h3 class="text-sm font-medium text-primary mb-3"><%= t("savings_goals.show.funding_accounts_heading") %></h3>
<% if total.zero? %>
<p class="text-sm text-secondary"><%= t("savings_goals.show.no_contributions_yet") %></p>
<% else %>
<div class="space-y-3">
<% rows.each do |row| %>
<div class="flex items-center gap-3">
<%= render Savings::GoalAvatarComponent.new(name: row[:account].name, color: goal.color, size: "sm") %>
<div class="min-w-0 flex-1">
<div class="flex items-center justify-between gap-2 text-sm">
<span class="text-primary truncate"><%= row[:account].name %></span>
<span class="text-secondary tabular-nums"><%= row[:money].format %></span>
</div>
<div class="mt-1 h-1.5 w-full rounded-full bg-container-inset overflow-hidden">
<div class="h-full bg-blue-500" style="width: <%= percent_for(row[:amount]) %>%"></div>
</div>
</div>
<span class="text-xs text-secondary tabular-nums w-10 text-right"><%= percent_for(row[:amount]) %>%</span>
</div>
<% end %>
</div>
<% end %>
</section>

View File

@@ -0,0 +1,17 @@
class Savings::FundingAccountsBreakdownComponent < ApplicationComponent
def initialize(goal:, rows:)
@goal = goal
@rows = rows
end
attr_reader :goal, :rows
def total
@total ||= rows.sum { |r| r[:amount].to_d }
end
def percent_for(amount)
return 0 if total.zero?
((amount.to_d / total) * 100).round
end
end

View File

@@ -0,0 +1,5 @@
<span class="inline-flex items-center justify-center rounded-full text-inverse font-medium <%= box_classes %> <%= text_classes %>"
style="background-color: <%= color %>;"
data-testid="savings-goal-avatar">
<%= initial %>
</span>

View File

@@ -0,0 +1,29 @@
class Savings::GoalAvatarComponent < ApplicationComponent
SIZES = {
"sm" => { box: "w-6 h-6", text: "text-xs" },
"md" => { box: "w-8 h-8", text: "text-sm" },
"lg" => { box: "w-12 h-12", text: "text-lg" }
}.freeze
def initialize(goal: nil, name: nil, color: nil, size: "md")
@goal = goal
@name = name || goal&.name
@color = color || goal&.color || SavingsGoal::COLORS.first
@size = SIZES.key?(size) ? size : "md"
end
attr_reader :color
def initial
return "?" if @name.blank?
@name.strip.first&.upcase || "?"
end
def box_classes
SIZES[@size][:box]
end
def text_classes
SIZES[@size][:text]
end
end

View File

@@ -0,0 +1,28 @@
<%= link_to savings_goal_path(goal), class: "block bg-container rounded-xl shadow-border-xs hover:shadow-border-sm p-4 transition-shadow" do %>
<div class="flex items-start gap-3">
<%= render Savings::GoalAvatarComponent.new(goal: goal, size: "md") %>
<div class="min-w-0 flex-1">
<div class="flex items-center justify-between gap-2">
<p class="text-sm font-medium text-primary truncate"><%= goal.name %></p>
<%= render Savings::StatusPillComponent.new(goal: goal) %>
</div>
<p class="text-xs text-secondary truncate mt-0.5"><%= linked_accounts_label %></p>
</div>
</div>
<div class="mt-4">
<div class="flex items-center justify-between gap-2 text-xs tabular-nums">
<span class="text-secondary"><%= goal.current_balance_money.format %></span>
<span class="text-secondary">/ <%= goal.target_amount_money.format %></span>
</div>
<div class="mt-2 h-2 w-full rounded-full bg-container-inset overflow-hidden">
<div class="h-full <%= bar_color_class %>" style="width: <%= progress_percent %>%"></div>
</div>
<div class="mt-1 flex items-center justify-between text-xs text-secondary tabular-nums">
<span><%= progress_percent %>%</span>
<% if goal.target_date %>
<span><%= I18n.l(goal.target_date, format: :long) %></span>
<% end %>
</div>
</div>
<% end %>

View File

@@ -0,0 +1,30 @@
class Savings::GoalCardComponent < ApplicationComponent
def initialize(goal:)
@goal = goal
end
attr_reader :goal
def linked_accounts_label
names = goal.linked_accounts.pluck(:name)
case names.size
when 0 then I18n.t("savings_goals.goal_card.no_accounts")
when 1 then names.first
when 2 then names.join(", ")
else
I18n.t("savings_goals.goal_card.n_accounts", first: names.first, count: names.size - 1)
end
end
def progress_percent
goal.progress_percent
end
def bar_color_class
case progress_percent
when 0...25 then "bg-gray-400"
when 25...75 then "bg-blue-500"
else "bg-green-600"
end
end
end

View File

@@ -0,0 +1,27 @@
<div class="relative inline-flex items-center justify-center" style="width: <%= Savings::ProgressRingComponent::SIZE %>px; height: <%= Savings::ProgressRingComponent::SIZE %>px;">
<svg width="<%= Savings::ProgressRingComponent::SIZE %>"
height="<%= Savings::ProgressRingComponent::SIZE %>"
viewBox="0 0 <%= Savings::ProgressRingComponent::SIZE %> <%= Savings::ProgressRingComponent::SIZE %>">
<circle cx="<%= Savings::ProgressRingComponent::SIZE / 2.0 %>"
cy="<%= Savings::ProgressRingComponent::SIZE / 2.0 %>"
r="<%= Savings::ProgressRingComponent::RADIUS %>"
fill="none"
stroke="var(--color-gray-200)"
stroke-width="<%= Savings::ProgressRingComponent::STROKE %>" />
<circle cx="<%= Savings::ProgressRingComponent::SIZE / 2.0 %>"
cy="<%= Savings::ProgressRingComponent::SIZE / 2.0 %>"
r="<%= Savings::ProgressRingComponent::RADIUS %>"
fill="none"
stroke="<%= stroke_color %>"
stroke-width="<%= Savings::ProgressRingComponent::STROKE %>"
stroke-linecap="round"
stroke-dasharray="<%= Savings::ProgressRingComponent::CIRCUMFERENCE %>"
stroke-dashoffset="<%= offset %>"
transform="rotate(-90 <%= Savings::ProgressRingComponent::SIZE / 2.0 %> <%= Savings::ProgressRingComponent::SIZE / 2.0 %>)" />
</svg>
<div class="absolute inset-0 flex flex-col items-center justify-center text-center">
<span class="text-2xl font-semibold text-primary tabular-nums"><%= percent %>%</span>
<span class="text-xs text-secondary tabular-nums mt-1"><%= current_label %></span>
<span class="text-xs text-secondary tabular-nums">of <%= target_label %></span>
</div>
</div>

View File

@@ -0,0 +1,36 @@
class Savings::ProgressRingComponent < ApplicationComponent
SIZE = 180
STROKE = 14
RADIUS = (SIZE - STROKE) / 2.0
CIRCUMFERENCE = 2 * Math::PI * RADIUS
def initialize(goal:)
@goal = goal
end
attr_reader :goal
def percent
[ [ goal.progress_percent.to_i, 0 ].max, 100 ].min
end
def offset
CIRCUMFERENCE * (1 - percent / 100.0)
end
def stroke_color
case percent
when 0...25 then "var(--color-gray-400)"
when 25...75 then "var(--color-blue-500)"
else "var(--color-green-600)"
end
end
def current_label
goal.current_balance_money.format
end
def target_label
goal.target_amount_money.format
end
end

View File

@@ -0,0 +1,4 @@
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium <%= classes %>">
<%= helpers.icon(icon_name, size: "xs", color: "current") %>
<%= label %>
</span>

View File

@@ -0,0 +1,32 @@
class Savings::StatusPillComponent < ApplicationComponent
VARIANTS = {
on_track: { classes: "bg-green-600/10 text-green-700", icon: "check" },
behind: { classes: "bg-yellow-500/10 text-yellow-700", icon: "alert-triangle" },
reached: { classes: "bg-green-600/10 text-green-700", icon: "circle-check-big" },
no_target_date: { classes: "bg-container-inset text-secondary", icon: "calendar-off" }
}.freeze
def initialize(goal:)
@goal = goal
end
def status
@goal.status
end
def variant
VARIANTS.fetch(status, VARIANTS[:no_target_date])
end
def label
I18n.t("savings_goals.status.#{status}")
end
def icon_name
variant[:icon]
end
def classes
variant[:classes]
end
end

View File

@@ -0,0 +1,58 @@
class SavingsContributionsController < ApplicationController
before_action :set_savings_goal
before_action :set_contribution, only: :destroy
def new
@contribution = @savings_goal.savings_contributions.new(
contributed_at: Date.current,
currency: @savings_goal.currency,
source: "manual"
)
end
def create
@contribution = @savings_goal.savings_contributions.new(contribution_params.merge(source: "manual"))
@contribution.account = lookup_account(params.dig(:savings_contribution, :account_id))
@contribution.currency = @savings_goal.currency
if @contribution.save
flash[:notice] = t(".success")
respond_to do |format|
format.html { redirect_to savings_goal_path(@savings_goal) }
format.turbo_stream do
render turbo_stream: turbo_stream.action(:redirect, savings_goal_path(@savings_goal))
end
end
else
render :new, status: :unprocessable_entity
end
end
def destroy
if @contribution.initial?
redirect_to savings_goal_path(@savings_goal), alert: t(".initial_not_deletable")
return
end
@contribution.destroy!
redirect_to savings_goal_path(@savings_goal), notice: t(".success")
end
private
def set_savings_goal
@savings_goal = Current.family.savings_goals.find(params[:savings_goal_id])
end
def set_contribution
@contribution = @savings_goal.savings_contributions.find(params[:id])
end
def contribution_params
params.require(:savings_contribution).permit(:amount, :contributed_at, :notes)
end
def lookup_account(id)
return nil if id.blank?
@savings_goal.linked_accounts.find_by(id: id)
end
end

View File

@@ -0,0 +1,167 @@
class SavingsGoalsController < ApplicationController
before_action :set_savings_goal, only: %i[show edit update destroy pause resume complete archive unarchive]
STATE_FILTERS = %w[all active paused completed archived].freeze
def index
@state_filter = STATE_FILTERS.include?(params[:state]) ? params[:state] : "active"
scope = Current.family.savings_goals.with_current_balance.alphabetically
scope = scope.where(state: @state_filter) unless @state_filter == "all"
@savings_goals = scope.to_a
@counts = STATE_FILTERS.each_with_object({}) do |state, h|
h[state] = state == "all" ? Current.family.savings_goals.count : Current.family.savings_goals.where(state: state).count
end
@linkable_account_count = Current.family.accounts.where(accountable_type: "Depository").visible.count
end
def show
@contributions = @savings_goal.savings_contributions.includes(:account).chronological.limit(50)
@funding_breakdown = funding_breakdown_for(@savings_goal)
end
def new
@savings_goal = Current.family.savings_goals.new(
color: SavingsGoal::COLORS.sample,
currency: Current.family.primary_currency_code
)
@linkable_accounts = linkable_accounts_for_new
end
def create
@savings_goal = Current.family.savings_goals.new(savings_goal_params)
accounts = lookup_accounts(params.dig(:savings_goal, :account_ids))
@savings_goal.currency = accounts.first.currency if accounts.any? && @savings_goal.currency.blank?
SavingsGoal.transaction do
accounts.each { |a| @savings_goal.savings_goal_accounts.build(account: a) }
@savings_goal.save!
create_initial_contribution_if_provided!(@savings_goal, accounts)
end
flash[:notice] = t(".success")
respond_to do |format|
format.html { redirect_to savings_goal_path(@savings_goal) }
format.turbo_stream do
render turbo_stream: turbo_stream.action(:redirect, savings_goal_path(@savings_goal))
end
end
rescue ActiveRecord::RecordInvalid
@linkable_accounts = linkable_accounts_for_new
render :new, status: :unprocessable_entity
end
def edit
end
def update
if @savings_goal.update(savings_goal_update_params)
flash[:notice] = t(".success")
respond_to do |format|
format.html { redirect_to savings_goal_path(@savings_goal) }
format.turbo_stream do
render turbo_stream: turbo_stream.action(:redirect, savings_goal_path(@savings_goal))
end
end
else
render :edit, status: :unprocessable_entity
end
end
def destroy
unless @savings_goal.archived?
redirect_to savings_goal_path(@savings_goal), alert: t(".archive_first")
return
end
@savings_goal.destroy!
redirect_to savings_goals_path, notice: t(".success")
end
def pause
perform_transition!(:pause)
end
def resume
perform_transition!(:resume)
end
def complete
perform_transition!(:complete)
end
def archive
perform_transition!(:archive)
end
def unarchive
perform_transition!(:unarchive)
end
private
def set_savings_goal
@savings_goal = Current.family.savings_goals.find(params[:id])
end
def savings_goal_params
params.require(:savings_goal).permit(:name, :target_amount, :target_date, :color, :notes)
end
def savings_goal_update_params
params.require(:savings_goal).permit(:name, :target_amount, :target_date, :color, :notes)
end
def lookup_accounts(ids)
return [] if ids.blank?
ids = Array(ids).reject(&:blank?)
Current.family.accounts.where(accountable_type: "Depository").visible.where(id: ids).to_a
end
def linkable_accounts_for_new
Current.family.accounts.where(accountable_type: "Depository").visible.alphabetically.to_a
end
def create_initial_contribution_if_provided!(goal, accounts)
amount = params.dig(:savings_goal, :initial_contribution_amount)
account_id = params.dig(:savings_goal, :initial_contribution_account_id)
return if amount.blank? || account_id.blank?
return unless BigDecimal(amount.to_s) > 0
source = accounts.find { |a| a.id == account_id }
raise ActiveRecord::RecordInvalid.new(goal) unless source
goal.savings_contributions.create!(
account: source,
amount: amount,
currency: goal.currency,
source: "initial",
contributed_at: Date.current
)
end
def funding_breakdown_for(goal)
totals = goal.savings_contributions
.group(:account_id)
.sum(:amount)
goal.linked_accounts.map do |account|
amount = totals[account.id] || 0
{ account: account, amount: amount, money: Money.new(amount, goal.currency) }
end
end
def perform_transition!(event)
if @savings_goal.aasm.may_fire_event?(event)
@savings_goal.public_send("#{event}!")
respond_to do |format|
format.html { redirect_to savings_goal_path(@savings_goal), notice: t(".success") }
format.turbo_stream do
render turbo_stream: turbo_stream.action(:redirect, savings_goal_path(@savings_goal))
end
end
else
redirect_to savings_goal_path(@savings_goal), alert: t(".invalid_transition")
end
end
end

View File

@@ -0,0 +1,124 @@
import { Controller } from "@hotwired/stimulus";
// 2-step modal stepper for creating a savings goal.
//
// Single <form> with two panels. Step 1 collects identity (name, amount,
// date, color, notes). Step 2 collects ≥1 linked depository accounts and
// optionally an initial contribution. Submit button stays disabled until at
// least one linked account is selected. Step state lives entirely in the
// DOM — no half-records.
export default class extends Controller {
static targets = [
"step1Panel",
"step2Panel",
"step1Indicator",
"step2Indicator",
"step1Field",
"nameField",
"targetAmountField",
"linkedAccountCheckbox",
"initialContributionAmount",
"initialContributionAccountSelect",
"reviewPanel",
"reviewName",
"reviewAmount",
"reviewDate",
"reviewAccounts",
"submitButton",
];
next(event) {
event?.preventDefault?.();
if (!this.validateStep1()) return;
this.step1PanelTarget.classList.add("hidden");
this.step2PanelTarget.classList.remove("hidden");
this.markStepActive(2);
this.updateReview();
this.refreshSubmitState();
}
back(event) {
event?.preventDefault?.();
this.step2PanelTarget.classList.add("hidden");
this.step1PanelTarget.classList.remove("hidden");
this.markStepActive(1);
}
linkedAccountChanged() {
this.refreshAccountSelect();
this.refreshSubmitState();
this.updateReview();
}
validateStep1() {
let ok = true;
this.step1FieldTargets.forEach((field) => {
if (!field.checkValidity()) {
field.reportValidity();
ok = false;
}
});
return ok;
}
refreshSubmitState() {
const anyChecked = this.linkedAccountCheckboxTargets.some((cb) => cb.checked);
this.submitButtonTarget.disabled = !anyChecked;
}
refreshAccountSelect() {
if (!this.hasInitialContributionAccountSelectTarget) return;
const select = this.initialContributionAccountSelectTarget;
const previous = select.value;
select.innerHTML = "";
const blank = document.createElement("option");
blank.value = "";
blank.textContent = select.dataset.blankLabel || "—";
select.appendChild(blank);
this.linkedAccountCheckboxTargets
.filter((cb) => cb.checked)
.forEach((cb) => {
const opt = document.createElement("option");
opt.value = cb.value;
opt.textContent = cb.dataset.accountName || cb.value;
select.appendChild(opt);
});
if ([...select.options].some((o) => o.value === previous)) {
select.value = previous;
}
}
updateReview() {
if (!this.hasReviewPanelTarget) return;
if (this.hasReviewNameTarget && this.hasNameFieldTarget) {
this.reviewNameTarget.textContent = this.nameFieldTarget.value || "—";
}
if (this.hasReviewAmountTarget && this.hasTargetAmountFieldTarget) {
this.reviewAmountTarget.textContent = this.targetAmountFieldTarget.value || "—";
}
if (this.hasReviewDateTarget) {
const dateInput = this.element.querySelector('input[type="date"][name="savings_goal[target_date]"]');
this.reviewDateTarget.textContent = dateInput?.value || "—";
}
if (this.hasReviewAccountsTarget) {
const names = this.linkedAccountCheckboxTargets
.filter((cb) => cb.checked)
.map((cb) => cb.dataset.accountName || cb.value);
this.reviewAccountsTarget.textContent = names.length ? names.join(", ") : "—";
}
}
markStepActive(stepNumber) {
if (this.hasStep1IndicatorTarget) {
this.step1IndicatorTarget.classList.toggle("text-primary", stepNumber === 1);
}
if (this.hasStep2IndicatorTarget) {
this.step2IndicatorTarget.classList.toggle("text-primary", stepNumber === 2);
}
}
}

View File

@@ -20,6 +20,9 @@ class Account < ApplicationRecord
has_many :holdings, dependent: :destroy
has_many :balances, dependent: :destroy
has_many :recurring_transactions, dependent: :destroy
has_many :savings_goal_accounts, dependent: :destroy
has_many :savings_goals, through: :savings_goal_accounts
has_many :savings_contributions, dependent: :destroy
monetize :balance, :cash_balance

View File

@@ -28,7 +28,8 @@ module Assistant
Function::GetBalanceSheet,
Function::GetIncomeStatement,
Function::ImportBankStatement,
Function::SearchFamilyFiles
Function::SearchFamilyFiles,
Function::CreateSavingsGoal
]
end

View File

@@ -0,0 +1,184 @@
class Assistant::Function::CreateSavingsGoal < Assistant::Function
class << self
def name
"create_savings_goal"
end
def description
<<~INSTRUCTIONS
Creates a savings goal for the user's family.
Use when the user describes a target they want to save toward — e.g.
"vacation in 4 months for $5000", "downpayment for a car next year",
"build an emergency fund of $10k".
Before calling, confirm the key details by paraphrasing back to the
user: the name, target amount, target date (if mentioned), and which
of their accounts will fund it. Only call once they've confirmed.
Constraints:
- The goal must link to at least one of the user's Depository
accounts (checking, savings, HSA, CD, money-market).
- All linked accounts must share the same currency.
- Use account names exactly as listed in the user's Depository
accounts.
On success returns the new goal's URL so you can point the user to
it. On a soft failure (e.g. account name doesn't match), the
response includes the available account list so you can re-ask.
INSTRUCTIONS
end
end
def strict_mode?
false
end
def params_schema
build_schema(
required: %w[name target_amount linked_account_names],
properties: {
name: {
type: "string",
description: "Short goal name, e.g. 'Vacation in Italy'."
},
target_amount: {
type: "number",
description: "Total amount to save, in the linked accounts' currency."
},
target_date: {
type: "string",
description: "Optional ISO 8601 date (YYYY-MM-DD) for when the user wants to reach the target."
},
linked_account_names: {
type: "array",
items: { type: "string" },
description: "Names of the user's Depository accounts to link. Must contain at least one. Use names exactly as they appear in the available accounts list."
},
initial_contribution: {
type: "object",
description: "Optional starting contribution at creation time.",
properties: {
amount: { type: "number" },
source_account_name: { type: "string", description: "Must be one of the linked_account_names." }
}
},
notes: {
type: "string",
description: "Optional freeform notes."
}
}
)
end
def call(params = {})
name = params["name"].to_s.strip
target_amount = parse_decimal(params["target_amount"])
target_date = parse_date(params["target_date"])
linked_account_names = Array(params["linked_account_names"]).map { |n| n.to_s.strip }.reject(&:blank?)
initial = params["initial_contribution"]
notes = params["notes"].to_s.strip
return error("name_required", "Please provide a name for the goal.") if name.blank?
return error("target_amount_invalid", "Target amount must be greater than zero.") unless target_amount && target_amount > 0
if linked_account_names.empty?
return error(
"no_linked_accounts",
"Please specify at least one Depository account to link to this goal.",
available_accounts: depository_account_payload
)
end
matched = family.accounts.where(accountable_type: "Depository").visible.where(name: linked_account_names).to_a
missing = linked_account_names - matched.map(&:name)
if missing.any?
return error(
"unknown_accounts",
"Some account names didn't match the user's Depository accounts.",
unknown_names: missing,
available_accounts: depository_account_payload
)
end
currencies = matched.map(&:currency).uniq
if currencies.size > 1
return error(
"currency_mismatch",
"All linked accounts must share the same currency. Found: #{currencies.join(', ')}."
)
end
goal = nil
SavingsGoal.transaction do
goal = family.savings_goals.new(
name: name,
target_amount: target_amount,
target_date: target_date,
currency: currencies.first,
notes: notes.presence,
color: SavingsGoal::COLORS.sample
)
matched.each { |a| goal.savings_goal_accounts.build(account: a) }
goal.save!
create_initial_contribution!(goal, matched, initial)
end
{
success: true,
goal_id: goal.id,
name: goal.name,
target_amount_formatted: goal.target_amount_money.format,
currency: goal.currency,
target_date: goal.target_date&.iso8601,
url: Rails.application.routes.url_helpers.savings_goal_path(goal),
linked_account_names: matched.map(&:name),
message: "Created savings goal '#{goal.name}' (target #{goal.target_amount_money.format}). View it at #{Rails.application.routes.url_helpers.savings_goal_path(goal)}."
}
rescue ActiveRecord::RecordInvalid => e
error("validation_failed", e.record.errors.full_messages.join("; "))
end
private
def create_initial_contribution!(goal, matched_accounts, initial)
return unless initial.is_a?(Hash)
amount = parse_decimal(initial["amount"])
return unless amount && amount > 0
source = matched_accounts.find { |a| a.name == initial["source_account_name"].to_s }
raise ActiveRecord::RecordInvalid.new(goal) unless source
goal.savings_contributions.create!(
account: source,
amount: amount,
currency: goal.currency,
source: "initial",
contributed_at: Date.current
)
end
def parse_decimal(value)
return nil if value.nil?
BigDecimal(value.to_s)
rescue ArgumentError, TypeError
nil
end
def parse_date(value)
return nil if value.blank?
Date.iso8601(value.to_s)
rescue Date::Error
nil
end
def depository_account_payload
family.accounts.where(accountable_type: "Depository").visible.pluck(:name, :currency).map { |n, c| { name: n, currency: c } }
end
def error(key, message, extras = {})
{ success: false, error: key, message: message }.merge(extras)
end
end

View File

@@ -103,6 +103,9 @@ class Demo::Generator
# Auto-fill current-month budget based on recent spending averages
generate_budget_auto_fill!(family)
puts "🎯 Seeding savings goals..."
generate_savings_goals!(family)
puts "✅ Realistic demo data loaded successfully!"
end
end
@@ -1274,4 +1277,84 @@ class Demo::Generator
puts " ✅ Set property and vehicle valuations"
end
def generate_savings_goals!(family)
depository_accounts = family.accounts.depository.visible.to_a
return if depository_accounts.empty?
currency = depository_accounts.first.currency
eligible = depository_accounts.select { |a| a.currency == currency }
primary = eligible.first
secondary = eligible[1] || primary
goals = [
{
name: "Vacation in Italy",
target: 5_000,
target_date: 4.months.from_now.to_date,
accounts: eligible.first(2),
contributions: [
{ amount: 500, source: "initial", days_ago: 90, account: primary },
{ amount: 250, source: "manual", days_ago: 60, account: primary },
{ amount: 250, source: "manual", days_ago: 30, account: secondary }
]
},
{
name: "Emergency fund",
target: 10_000,
target_date: nil,
accounts: [ primary ],
contributions: [
{ amount: 1_000, source: "initial", days_ago: 180, account: primary }
]
},
{
name: "House downpayment",
target: 50_000,
target_date: 24.months.from_now.to_date,
accounts: eligible.first(2),
contributions: [
{ amount: 5_000, source: "initial", days_ago: 365, account: primary }
]
},
{
name: "Paid-off car",
target: 8_000,
target_date: 6.months.ago.to_date,
state: "completed",
accounts: [ primary ],
contributions: [
{ amount: 2_000, source: "initial", days_ago: 730, account: primary },
{ amount: 2_000, source: "manual", days_ago: 600, account: primary },
{ amount: 2_000, source: "manual", days_ago: 450, account: primary },
{ amount: 2_000, source: "manual", days_ago: 300, account: primary }
]
}
]
goals.each do |goal_spec|
goal = family.savings_goals.new(
name: goal_spec[:name],
target_amount: goal_spec[:target],
target_date: goal_spec[:target_date],
currency: currency,
color: SavingsGoal::COLORS.sample,
state: goal_spec[:state] || "active"
)
goal_spec[:accounts].uniq.each { |a| goal.savings_goal_accounts.build(account: a) }
goal.save!
goal_spec[:contributions].each do |c|
goal.savings_contributions.create!(
account: c[:account],
amount: c[:amount],
currency: currency,
source: c[:source],
contributed_at: c[:days_ago].days.ago.to_date
)
end
end
puts " ✅ Seeded #{goals.size} savings goals"
end
end

View File

@@ -42,6 +42,9 @@ class Family < ApplicationRecord
has_many :budgets, dependent: :destroy
has_many :budget_categories, through: :budgets
has_many :savings_goals, dependent: :destroy
has_many :savings_contributions, through: :savings_goals
has_many :llm_usages, dependent: :destroy
has_many :recurring_transactions, dependent: :destroy

View File

@@ -0,0 +1,56 @@
class SavingsContribution < ApplicationRecord
include Monetizable
SOURCES = %w[manual initial].freeze
belongs_to :savings_goal
belongs_to :account
validates :amount, presence: true, numericality: { greater_than: 0 }
validates :currency, presence: true
validates :contributed_at, presence: true
validates :source, inclusion: { in: SOURCES }
validate :currency_matches_goal
validate :account_must_belong_to_family
validate :account_must_be_linked_to_goal
before_validation :sync_currency_from_goal
monetize :amount
scope :chronological, -> { order(contributed_at: :desc, created_at: :desc) }
def manual?
source == "manual"
end
def initial?
source == "initial"
end
private
def sync_currency_from_goal
self.currency = savings_goal.currency if savings_goal && currency.blank?
end
def currency_matches_goal
return if savings_goal.nil? || currency.blank?
return if currency == savings_goal.currency
errors.add(:currency, :must_match_goal)
end
def account_must_belong_to_family
return if savings_goal.nil? || account.nil?
return if account.family_id == savings_goal.family_id
errors.add(:account, :must_belong_to_family)
end
def account_must_be_linked_to_goal
return if savings_goal.nil? || account.nil?
return if savings_goal.savings_goal_accounts.where(account_id: account_id).exists?
errors.add(:account, :must_be_linked_to_goal)
end
end

185
app/models/savings_goal.rb Normal file
View File

@@ -0,0 +1,185 @@
class SavingsGoal < ApplicationRecord
include AASM, Monetizable
COLORS = Category::COLORS
belongs_to :family
has_many :savings_goal_accounts, dependent: :destroy
has_many :linked_accounts, through: :savings_goal_accounts, source: :account
has_many :savings_contributions, dependent: :destroy
validates :name, presence: true, length: { maximum: 255 }
validates :target_amount, presence: true, numericality: { greater_than: 0 }
validates :currency, presence: true
validate :must_have_at_least_one_linked_account
validate :linked_accounts_must_be_depository
validate :linked_accounts_must_match_goal_currency
validate :linked_accounts_must_belong_to_family
validate :currency_locked_once_contributions_exist
monetize :target_amount
scope :alphabetically, -> { order(Arel.sql("LOWER(name) ASC")) }
scope :active_first, lambda {
order(Arel.sql("CASE state WHEN 'active' THEN 0 WHEN 'paused' THEN 1 WHEN 'completed' THEN 2 ELSE 3 END"))
}
scope :with_current_balance, lambda {
left_outer_joins(:savings_contributions)
.group(Arel.sql("savings_goals.id"))
.select(Arel.sql("savings_goals.*, COALESCE(SUM(savings_contributions.amount), 0) AS current_balance_total"))
}
# 63-bit Postgres advisory-lock key per family. Used by future auto-fund flows
# and any future per-family serialization of goal contributions.
def self.advisory_lock_key_for(family_id)
Digest::SHA1.hexdigest("savings_goals:family:#{family_id}").to_i(16) % (2**63)
end
aasm column: :state do
state :active, initial: true
state :paused
state :completed
state :archived
event :pause do
transitions from: :active, to: :paused
end
event :resume do
transitions from: :paused, to: :active
end
event :complete do
transitions from: [ :active, :paused ], to: :completed
end
event :archive do
transitions from: [ :active, :paused, :completed ], to: :archived
end
event :unarchive do
transitions from: :archived, to: :active
end
end
def current_balance
@current_balance ||= if attributes.key?("current_balance_total")
attributes["current_balance_total"] || 0
else
savings_contributions.sum(:amount)
end
end
def current_balance_money
@current_balance_money ||= Money.new(current_balance, currency)
end
def remaining_amount
@remaining_amount ||= [ target_amount - current_balance, 0 ].max
end
def remaining_amount_money
@remaining_amount_money ||= Money.new(remaining_amount, currency)
end
def progress_percent
return @progress_percent if defined?(@progress_percent)
@progress_percent = if completed?
100
elsif target_amount.to_d.zero?
0
else
[ ((current_balance.to_d / target_amount.to_d) * 100).round, 100 ].min
end
end
def months_remaining
return nil unless target_date
months = (target_date.year - Date.current.year) * 12 + (target_date.month - Date.current.month)
[ months, 0 ].max
end
def monthly_target_amount
return @monthly_target_amount if defined?(@monthly_target_amount)
@monthly_target_amount = if target_date.nil?
nil
elsif months_remaining.zero?
remaining_amount
else
(remaining_amount.to_d / months_remaining).ceil(2)
end
end
# :reached → progress_percent >= 100
# :on_track → has target_date and current pace >= required monthly pace
# :behind → has target_date and current pace < required monthly pace
# :no_target_date → progress < 100 and target_date is nil
def status
return :reached if progress_percent >= 100
return :no_target_date if target_date.nil?
return :on_track if monthly_target_amount.to_d <= average_monthly_contribution.to_d
:behind
end
def average_monthly_contribution
return 0 if savings_contributions.empty?
first_at = savings_contributions.minimum(:contributed_at)
return current_balance if first_at.blank?
months = ((Date.current.year - first_at.year) * 12 + (Date.current.month - first_at.month)) + 1
months = 1 if months < 1
(current_balance.to_d / months).round(2)
end
private
def must_have_at_least_one_linked_account
return unless savings_goal_accounts.reject(&:marked_for_destruction?).empty?
errors.add(:base, :at_least_one_linked_account_required)
end
def linked_accounts_must_be_depository
offending = savings_goal_accounts.reject(&:marked_for_destruction?).reject do |sga|
sga.account&.depository?
end
return if offending.empty?
errors.add(:linked_accounts, :must_be_depository)
end
def linked_accounts_must_match_goal_currency
return if currency.blank?
mismatched = savings_goal_accounts.reject(&:marked_for_destruction?).reject do |sga|
sga.account.nil? || sga.account.currency == currency
end
return if mismatched.empty?
errors.add(:linked_accounts, :currency_mismatch)
end
def linked_accounts_must_belong_to_family
return if family.nil?
foreign = savings_goal_accounts.reject(&:marked_for_destruction?).reject do |sga|
sga.account.nil? || sga.account.family_id == family_id
end
return if foreign.empty?
errors.add(:linked_accounts, :must_belong_to_family)
end
# Once a goal has contributions, changing currency would orphan amounts
# in the old currency. Lock it.
def currency_locked_once_contributions_exist
return unless persisted? && currency_changed?
return unless savings_contributions.exists?
errors.add(:currency, :locked_after_contributions)
end
end

View File

@@ -0,0 +1,6 @@
class SavingsGoalAccount < ApplicationRecord
belongs_to :savings_goal
belongs_to :account
validates :account_id, uniqueness: { scope: :savings_goal_id }
end

View File

@@ -11,6 +11,7 @@ else
{ name: t(".nav.transactions"), path: transactions_path, icon: "credit-card", icon_custom: false, active: page_active?(transactions_path) },
{ name: t(".nav.reports"), path: reports_path, icon: "chart-bar", icon_custom: false, active: page_active?(reports_path) },
{ name: t(".nav.budgets"), path: budgets_path, icon: "map", icon_custom: false, active: page_active?(budgets_path) },
{ name: t(".nav.savings_goals"), path: savings_goals_path, icon: "piggy-bank", icon_custom: false, active: page_active?(savings_goals_path) },
{ name: t(".nav.assistant"), path: chats_path, icon: "icon-assistant", icon_custom: true, active: page_active?(chats_path), mobile_only: true }
]
end %>

View File

@@ -0,0 +1,33 @@
<%= render DS::Dialog.new do |dialog| %>
<% dialog.with_header(title: t(".heading")) %>
<% dialog.with_body do %>
<% if @contribution.errors.any? %>
<%= render "shared/form_errors", model: @contribution %>
<% end %>
<%= styled_form_with model: @contribution,
url: savings_goal_contributions_path(@savings_goal),
class: "space-y-3" do |f| %>
<%= f.money_field :amount,
label: t(".amount"),
required: true,
autofocus: true %>
<%= label_tag "savings_contribution[account_id]", t(".source_account"), class: "block text-sm text-secondary" %>
<%= select_tag "savings_contribution[account_id]",
options_from_collection_for_select(@savings_goal.linked_accounts, :id, :name),
class: "w-full",
include_blank: t(".select_account") %>
<%= f.date_field :contributed_at,
label: t(".contributed_at"),
required: true %>
<%= f.text_area :notes, label: t(".notes"), rows: 2 %>
<div class="flex justify-end pt-2">
<%= f.submit t(".submit") %>
</div>
<% end %>
<% end %>
<% end %>

View File

@@ -0,0 +1,30 @@
<%# locals: (contributions:) %>
<% if contributions.empty? %>
<p class="text-sm text-secondary"><%= t("savings_goals.show.no_contributions_yet") %></p>
<% else %>
<ul class="divide-y divide-divider">
<% contributions.each do |contribution| %>
<li class="flex items-center gap-3 py-2">
<%= render Savings::GoalAvatarComponent.new(
name: contribution.account.name,
color: @savings_goal.color,
size: "sm"
) %>
<div class="flex-1 min-w-0">
<p class="text-sm text-primary truncate"><%= contribution.account.name %></p>
<p class="text-xs text-secondary"><%= I18n.l(contribution.contributed_at, format: :long) %> · <%= t("savings_goals.show.source.#{contribution.source}") %></p>
</div>
<span class="text-sm text-primary tabular-nums"><%= contribution.amount_money.format %></span>
<% if contribution.manual? %>
<%= button_to savings_goal_contribution_path(@savings_goal, contribution),
method: :delete,
class: "text-secondary hover:text-destructive",
form: { data: { turbo_confirm: t("savings_goals.show.confirm_delete_contribution") } } do %>
<%= icon("x", size: "sm") %>
<% end %>
<% end %>
</li>
<% end %>
</ul>
<% end %>

View File

@@ -0,0 +1,29 @@
<%# locals: (linkable_account_count:) %>
<div class="bg-container rounded-xl shadow-border-xs py-16">
<div class="flex flex-col items-center text-center max-w-md mx-auto">
<div class="w-12 h-12 rounded-full bg-container-inset flex items-center justify-center mb-3">
<%= icon("piggy-bank", size: "lg") %>
</div>
<h2 class="text-base font-medium text-primary mb-1"><%= t("savings_goals.empty_state.heading") %></h2>
<p class="text-sm text-secondary mb-4"><%= t("savings_goals.empty_state.subtitle") %></p>
<% if linkable_account_count > 0 %>
<%= render DS::Link.new(
text: t("savings_goals.empty_state.new_goal"),
variant: "primary",
href: new_savings_goal_path,
icon: "plus",
frame: :modal
) %>
<% else %>
<p class="text-sm text-secondary mb-3"><%= t("savings_goals.empty_state.no_depository_accounts") %></p>
<%= render DS::Link.new(
text: t("savings_goals.empty_state.add_account"),
variant: "primary",
href: new_account_path,
icon: "plus"
) %>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,43 @@
<%# locals: (savings_goal:) %>
<% if savings_goal.errors.any? %>
<%= render "shared/form_errors", model: savings_goal %>
<% end %>
<%= styled_form_with model: savings_goal,
url: savings_goal_path(savings_goal),
method: :patch,
class: "space-y-3" do |f| %>
<%= f.text_field :name,
label: t("savings_goals.form_stepper.step1.fields.name"),
required: true,
autofocus: true %>
<%= f.money_field :target_amount,
label: t("savings_goals.form_stepper.step1.fields.target_amount"),
required: true %>
<%= f.date_field :target_date,
label: t("savings_goals.form_stepper.step1.fields.target_date") %>
<div>
<span class="block text-sm text-secondary mb-2"><%= t("savings_goals.form_stepper.step1.fields.color") %></span>
<div class="flex flex-wrap gap-2">
<% SavingsGoal::COLORS.each do |c| %>
<label class="relative">
<%= f.radio_button :color, c, class: "sr-only peer" %>
<div class="w-6 h-6 rounded-full cursor-pointer peer-checked:ring-2 peer-checked:ring-offset-2 peer-checked:ring-gray-500"
style="background-color: <%= c %>"></div>
</label>
<% end %>
</div>
</div>
<%= f.text_area :notes,
label: t("savings_goals.form_stepper.step1.fields.notes"),
rows: 2 %>
<div class="flex justify-end pt-2">
<%= f.submit t("savings_goals.edit.save") %>
</div>
<% end %>

View File

@@ -0,0 +1,134 @@
<%# locals: (savings_goal:, linkable_accounts:) %>
<div data-controller="savings-goal-stepper">
<% if savings_goal.errors.any? %>
<%= render "shared/form_errors", model: savings_goal %>
<% end %>
<ol class="flex items-center gap-2 mb-4 text-xs font-medium text-secondary">
<li class="inline-flex items-center gap-1.5"
data-savings-goal-stepper-target="step1Indicator">
<span class="w-5 h-5 rounded-full inline-flex items-center justify-center bg-inverse text-primary">1</span>
<span class="text-primary"><%= t("savings_goals.form_stepper.step1.heading") %></span>
</li>
<span class="text-subdued">→</span>
<li class="inline-flex items-center gap-1.5"
data-savings-goal-stepper-target="step2Indicator">
<span class="w-5 h-5 rounded-full inline-flex items-center justify-center bg-container-inset">2</span>
<span><%= t("savings_goals.form_stepper.step2.heading") %></span>
</li>
</ol>
<%= styled_form_with model: savings_goal, url: savings_goals_path, class: "space-y-4" do |f| %>
<section data-savings-goal-stepper-target="step1Panel" class="space-y-3">
<%= f.text_field :name,
label: t("savings_goals.form_stepper.step1.fields.name"),
required: true,
autofocus: true,
data: { savings_goal_stepper_target: "step1Field nameField" } %>
<%= f.money_field :target_amount,
label: t("savings_goals.form_stepper.step1.fields.target_amount"),
required: true,
data: { savings_goal_stepper_target: "step1Field targetAmountField" } %>
<%= f.date_field :target_date,
label: t("savings_goals.form_stepper.step1.fields.target_date") %>
<div>
<span class="block text-sm text-secondary mb-2"><%= t("savings_goals.form_stepper.step1.fields.color") %></span>
<div class="flex flex-wrap gap-2">
<% SavingsGoal::COLORS.each do |c| %>
<label class="relative">
<%= f.radio_button :color, c, class: "sr-only peer" %>
<div class="w-6 h-6 rounded-full cursor-pointer peer-checked:ring-2 peer-checked:ring-offset-2 peer-checked:ring-gray-500"
style="background-color: <%= c %>"></div>
</label>
<% end %>
</div>
</div>
<%= f.text_area :notes,
label: t("savings_goals.form_stepper.step1.fields.notes"),
rows: 2 %>
<div class="flex justify-end pt-2">
<%= render DS::Button.new(
text: t("savings_goals.form_stepper.continue"),
variant: "primary",
data: { action: "click->savings-goal-stepper#next" }
) %>
</div>
</section>
<section data-savings-goal-stepper-target="step2Panel" class="space-y-4 hidden">
<div>
<span class="block text-sm font-medium text-primary"><%= t("savings_goals.form_stepper.step2.linked_accounts_heading") %></span>
<p class="text-xs text-secondary mb-2"><%= t("savings_goals.form_stepper.step2.linked_accounts_hint") %></p>
<div class="space-y-2 max-h-56 overflow-y-auto pr-1">
<% linkable_accounts.each do |account| %>
<label class="flex items-center gap-3 px-3 py-2 rounded-md bg-container-inset hover:bg-container-inset-hover cursor-pointer">
<%= check_box_tag "savings_goal[account_ids][]",
account.id,
false,
data: {
savings_goal_stepper_target: "linkedAccountCheckbox",
action: "change->savings-goal-stepper#linkedAccountChanged",
account_currency: account.currency,
account_name: account.name
} %>
<div class="flex-1 min-w-0">
<p class="text-sm text-primary truncate"><%= account.name %></p>
<p class="text-xs text-secondary"><%= account.subtype&.titleize %> · <%= Money.new(account.balance, account.currency).format %></p>
</div>
</label>
<% end %>
</div>
</div>
<details data-savings-goal-stepper-target="initialContributionToggle">
<summary class="cursor-pointer text-sm text-primary"><%= t("savings_goals.form_stepper.step2.add_initial_contribution") %></summary>
<div class="mt-3 space-y-2">
<%= label_tag "savings_goal[initial_contribution_amount]",
t("savings_goals.form_stepper.step2.initial_amount"),
class: "block text-sm text-secondary" %>
<%= number_field_tag "savings_goal[initial_contribution_amount]",
nil,
step: "0.01", min: "0",
autocomplete: "off",
class: "w-full",
data: { savings_goal_stepper_target: "initialContributionAmount" } %>
<%= label_tag "savings_goal[initial_contribution_account_id]",
t("savings_goals.form_stepper.step2.initial_account"),
class: "block text-sm text-secondary" %>
<%= select_tag "savings_goal[initial_contribution_account_id]",
options_for_select([]),
include_blank: t("savings_goals.form_stepper.step2.select_account"),
data: { savings_goal_stepper_target: "initialContributionAccountSelect" },
class: "w-full" %>
</div>
</details>
<div class="bg-container-inset rounded-md p-3 text-sm space-y-1" data-savings-goal-stepper-target="reviewPanel">
<p class="text-secondary"><%= t("savings_goals.form_stepper.step2.review_heading") %></p>
<p><strong data-savings-goal-stepper-target="reviewName">—</strong></p>
<p><span class="text-secondary"><%= t("savings_goals.form_stepper.step2.review_target") %>:</span> <span data-savings-goal-stepper-target="reviewAmount">—</span></p>
<p><span class="text-secondary"><%= t("savings_goals.form_stepper.step2.review_date") %>:</span> <span data-savings-goal-stepper-target="reviewDate">—</span></p>
<p><span class="text-secondary"><%= t("savings_goals.form_stepper.step2.review_accounts") %>:</span> <span data-savings-goal-stepper-target="reviewAccounts">—</span></p>
</div>
<div class="flex items-center justify-between pt-2">
<%= render DS::Button.new(
text: t("savings_goals.form_stepper.back"),
variant: "secondary",
data: { action: "click->savings-goal-stepper#back" }
) %>
<%= f.submit t("savings_goals.form_stepper.submit"),
data: { savings_goal_stepper_target: "submitButton" },
disabled: true %>
</div>
</section>
<% end %>
</div>

View File

@@ -0,0 +1,6 @@
<%= render DS::Dialog.new do |dialog| %>
<% dialog.with_header(title: t(".heading")) %>
<% dialog.with_body do %>
<%= render "form_edit", savings_goal: @savings_goal %>
<% end %>
<% end %>

View File

@@ -0,0 +1,43 @@
<%= content_for :page_title, t(".title") %>
<%= content_for :page_actions do %>
<% if @linkable_account_count > 0 %>
<%= render DS::Link.new(
text: t(".new_goal"),
variant: "primary",
href: new_savings_goal_path,
icon: "plus",
frame: :modal
) %>
<% end %>
<% end %>
<% if @savings_goals.empty? && @counts["all"].zero? %>
<%= render "empty_state", linkable_account_count: @linkable_account_count %>
<% else %>
<div class="space-y-4">
<nav class="flex items-center gap-1 overflow-x-auto" role="tablist">
<% SavingsGoalsController::STATE_FILTERS.each do |state| %>
<% active = state == @state_filter %>
<%= link_to savings_goals_path(state: state),
role: "tab",
"aria-selected": active,
class: "inline-flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium #{active ? 'bg-container shadow-border-xs text-primary' : 'text-secondary hover:text-primary'}" do %>
<span><%= t(".tabs.#{state}") %></span>
<span class="text-xs text-subdued tabular-nums"><%= @counts[state] %></span>
<% end %>
<% end %>
</nav>
<% if @savings_goals.any? %>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<% @savings_goals.each do |goal| %>
<%= render Savings::GoalCardComponent.new(goal: goal) %>
<% end %>
</div>
<% else %>
<div class="bg-container rounded-xl shadow-border-xs py-12 text-center">
<p class="text-sm text-secondary"><%= t(".empty_filtered", state: t(".tabs.#{@state_filter}").downcase) %></p>
</div>
<% end %>
</div>
<% end %>

View File

@@ -0,0 +1,6 @@
<%= render DS::Dialog.new do |dialog| %>
<% dialog.with_header(title: t(".heading")) %>
<% dialog.with_body do %>
<%= render "form_stepper", savings_goal: @savings_goal, linkable_accounts: @linkable_accounts %>
<% end %>
<% end %>

View File

@@ -0,0 +1,139 @@
<%= content_for :page_title, @savings_goal.name %>
<%= content_for :page_actions do %>
<%= render DS::Menu.new do |menu| %>
<% menu.with_item(
variant: "link",
text: t(".edit"),
icon: "pencil",
href: edit_savings_goal_path(@savings_goal),
data: { turbo_frame: :modal }
) %>
<% if @savings_goal.may_pause? %>
<% menu.with_item(
variant: "button",
text: t(".pause"),
icon: "pause",
href: pause_savings_goal_path(@savings_goal),
method: :patch
) %>
<% end %>
<% if @savings_goal.may_resume? %>
<% menu.with_item(
variant: "button",
text: t(".resume"),
icon: "play",
href: resume_savings_goal_path(@savings_goal),
method: :patch
) %>
<% end %>
<% if @savings_goal.may_complete? %>
<% menu.with_item(
variant: "button",
text: t(".complete"),
icon: "circle-check-big",
href: complete_savings_goal_path(@savings_goal),
method: :patch
) %>
<% end %>
<% if @savings_goal.may_archive? %>
<% menu.with_item(
variant: "button",
text: t(".archive"),
icon: "archive",
href: archive_savings_goal_path(@savings_goal),
method: :patch
) %>
<% end %>
<% if @savings_goal.may_unarchive? %>
<% menu.with_item(
variant: "button",
text: t(".unarchive"),
icon: "archive-restore",
href: unarchive_savings_goal_path(@savings_goal),
method: :patch
) %>
<% end %>
<% if @savings_goal.archived? %>
<% menu.with_item(
variant: "button",
text: t(".delete"),
icon: "trash-2",
href: savings_goal_path(@savings_goal),
method: :delete,
destructive: true,
confirm: CustomConfirm.for_resource_deletion(@savings_goal.name, high_severity: true)
) %>
<% end %>
<% end %>
<% end %>
<div class="space-y-4">
<section class="bg-container rounded-xl shadow-border-xs p-6">
<div class="flex flex-col md:flex-row items-start md:items-center gap-6">
<div>
<%= render Savings::ProgressRingComponent.new(goal: @savings_goal) %>
</div>
<div class="flex-1 space-y-3 min-w-0">
<div class="flex items-center gap-2">
<%= render Savings::GoalAvatarComponent.new(goal: @savings_goal, size: "lg") %>
<div class="min-w-0">
<h1 class="text-lg font-semibold text-primary truncate"><%= @savings_goal.name %></h1>
<p class="text-xs text-secondary"><%= t("savings_goals.states.#{@savings_goal.state}") %></p>
</div>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3 text-sm">
<div>
<p class="text-xs text-secondary"><%= t(".stats.current") %></p>
<p class="text-primary tabular-nums"><%= @savings_goal.current_balance_money.format %></p>
</div>
<div>
<p class="text-xs text-secondary"><%= t(".stats.target") %></p>
<p class="text-primary tabular-nums"><%= @savings_goal.target_amount_money.format %></p>
</div>
<div>
<p class="text-xs text-secondary"><%= t(".stats.remaining") %></p>
<p class="text-primary tabular-nums"><%= @savings_goal.remaining_amount_money.format %></p>
</div>
<div>
<p class="text-xs text-secondary"><%= t(".stats.target_date") %></p>
<p class="text-primary"><%= @savings_goal.target_date ? I18n.l(@savings_goal.target_date, format: :long) : t(".stats.no_target_date") %></p>
</div>
<% if @savings_goal.monthly_target_amount %>
<div>
<p class="text-xs text-secondary"><%= t(".stats.monthly_target") %></p>
<p class="text-primary tabular-nums"><%= Money.new(@savings_goal.monthly_target_amount, @savings_goal.currency).format %></p>
</div>
<% end %>
<div>
<p class="text-xs text-secondary"><%= t(".stats.status") %></p>
<%= render Savings::StatusPillComponent.new(goal: @savings_goal) %>
</div>
</div>
<% if @savings_goal.notes.present? %>
<p class="text-sm text-secondary"><%= simple_format(@savings_goal.notes) %></p>
<% end %>
</div>
</div>
</section>
<%= render Savings::FundingAccountsBreakdownComponent.new(goal: @savings_goal, rows: @funding_breakdown) %>
<section class="bg-container rounded-xl shadow-border-xs p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-medium text-primary"><%= t(".contributions_heading") %></h3>
<%= render DS::Link.new(
text: t(".add_contribution"),
variant: "primary",
size: "sm",
href: new_savings_goal_contribution_path(@savings_goal),
icon: "plus",
frame: :modal
) %>
</div>
<%= render "contributions_list", contributions: @contributions %>
</section>
</div>

View File

@@ -0,0 +1,20 @@
---
en:
activerecord:
attributes:
savings_contribution:
amount: Amount
currency: Currency
contributed_at: Date
source: Source
notes: Notes
account: Account
errors:
models:
savings_contribution:
attributes:
currency:
must_match_goal: Currency must match the goal's currency.
account:
must_belong_to_family: Account must belong to the same family as the goal.
must_be_linked_to_goal: Account must be one of the goal's linked accounts.

View File

@@ -0,0 +1,25 @@
---
en:
activerecord:
attributes:
savings_goal:
name: Name
target_amount: Target amount
currency: Currency
target_date: Target date
color: Color
notes: Notes
state: State
linked_accounts: Linked accounts
errors:
models:
savings_goal:
attributes:
base:
at_least_one_linked_account_required: Pick at least one Depository account to fund this goal.
linked_accounts:
must_be_depository: All linked accounts must be Depository (checking, savings, HSA, CD, money-market).
currency_mismatch: All linked accounts must share the same currency.
must_belong_to_family: Linked accounts must belong to the same family as the goal.
currency:
locked_after_contributions: Can't change the currency after a goal has contributions.

View File

@@ -8,6 +8,7 @@ en:
budgets: Budgets
home: Home
reports: Reports
savings_goals: Savings goals
transactions: Transactions
auth:
existing_account: Already have an account?

View File

@@ -0,0 +1,16 @@
---
en:
savings_contributions:
new:
heading: Add contribution
amount: Amount
source_account: From account
select_account: Select an account
contributed_at: Date
notes: Notes (optional)
submit: Save contribution
create:
success: Contribution saved.
destroy:
success: Contribution deleted.
initial_not_deletable: The initial contribution can't be deleted.

View File

@@ -0,0 +1,107 @@
---
en:
savings_goals:
index:
title: Savings goals
new_goal: New goal
empty_filtered: No %{state} goals.
tabs:
all: All
active: Active
paused: Paused
completed: Completed
archived: Archived
new:
heading: New savings goal
edit:
heading: Edit savings goal
save: Save changes
create:
success: Savings goal created.
update:
success: Savings goal updated.
destroy:
success: Savings goal deleted.
archive_first: Archive the goal before deleting it.
pause:
success: Goal paused.
invalid_transition: Goal can't be paused from its current state.
resume:
success: Goal resumed.
invalid_transition: Goal can't be resumed from its current state.
complete:
success: Goal marked complete.
invalid_transition: Goal can't be completed from its current state.
archive:
success: Goal archived.
invalid_transition: Goal can't be archived from its current state.
unarchive:
success: Goal restored.
invalid_transition: Goal can't be restored from its current state.
show:
edit: Edit
pause: Pause
resume: Resume
complete: Mark complete
archive: Archive
unarchive: Restore
delete: Delete permanently
contributions_heading: Contributions
add_contribution: Add contribution
funding_accounts_heading: Funding accounts
no_contributions_yet: No contributions yet.
confirm_delete_contribution: Delete this contribution?
source:
initial: Initial
manual: Manual
stats:
current: Current
target: Target
remaining: Remaining
target_date: Target date
no_target_date: No target date
monthly_target: Per month
status: Status
states:
active: Active
paused: Paused
completed: Completed
archived: Archived
status:
on_track: On track
behind: Behind
reached: Reached
no_target_date: No date
empty_state:
heading: No savings goals yet
subtitle: Set a target and start saving toward it.
new_goal: Create your first goal
no_depository_accounts: You need at least one Depository account (checking, savings, HSA, CD, money-market) before creating a goal.
add_account: Add an account
goal_card:
no_accounts: No linked accounts
n_accounts: "%{first} +%{count}"
form_stepper:
continue: Continue
back: Back
submit: Create goal
step1:
heading: Identity
fields:
name: Name
target_amount: Target amount
target_date: Target date (optional)
color: Color
notes: Notes (optional)
step2:
heading: Review
linked_accounts_heading: Linked accounts
linked_accounts_hint: Pick at least one Depository account that funds this goal.
add_initial_contribution: Add an initial contribution (optional)
initial_amount: Amount
initial_account: From account
select_account: Select an account
review_heading: Review
review_target: Target
review_date: Date
review_accounts: Accounts

View File

@@ -252,6 +252,18 @@ Rails.application.routes.draw do
resources :budget_categories, only: %i[index show update]
end
resources :savings_goals do
member do
patch :pause
patch :resume
patch :complete
patch :archive
patch :unarchive
end
resources :contributions, only: %i[new create destroy], controller: "savings_contributions"
end
resources :family_merchants, only: %i[index new create edit update destroy] do
collection do
get :merge

View File

@@ -0,0 +1,27 @@
class CreateSavingsGoals < ActiveRecord::Migration[7.2]
def change
create_table :savings_goals, id: :uuid do |t|
t.references :family, null: false, foreign_key: { on_delete: :cascade }, type: :uuid
t.string :name, null: false
t.decimal :target_amount, precision: 19, scale: 4, null: false
t.string :currency, null: false
t.date :target_date
t.string :color
t.text :notes
t.string :state, null: false, default: "active"
t.timestamps
end
add_index :savings_goals, [ :family_id, :state ]
add_check_constraint :savings_goals,
"char_length(name) <= 255",
name: "chk_savings_goals_name_length"
add_check_constraint :savings_goals,
"target_amount > 0",
name: "chk_savings_goals_target_amount_positive"
add_check_constraint :savings_goals,
"state IN ('active','paused','completed','archived')",
name: "chk_savings_goals_state_enum"
end
end

View File

@@ -0,0 +1,15 @@
class CreateSavingsGoalAccounts < ActiveRecord::Migration[7.2]
def change
create_table :savings_goal_accounts, id: :uuid do |t|
t.references :savings_goal, null: false, foreign_key: { on_delete: :cascade }, type: :uuid
t.references :account, null: false, foreign_key: { on_delete: :cascade }, type: :uuid
t.timestamps
end
add_index :savings_goal_accounts,
[ :savings_goal_id, :account_id ],
unique: true,
name: "index_savings_goal_accounts_on_goal_and_account"
end
end

View File

@@ -0,0 +1,23 @@
class CreateSavingsContributions < ActiveRecord::Migration[7.2]
def change
create_table :savings_contributions, id: :uuid do |t|
t.references :savings_goal, null: false, foreign_key: { on_delete: :cascade }, type: :uuid
t.references :account, null: false, foreign_key: { on_delete: :cascade }, type: :uuid
t.decimal :amount, precision: 19, scale: 4, null: false
t.string :currency, null: false
t.string :source, null: false, default: "manual"
t.date :contributed_at, null: false
t.text :notes
t.timestamps
end
add_index :savings_contributions, [ :savings_goal_id, :contributed_at ]
add_check_constraint :savings_contributions,
"amount > 0",
name: "chk_savings_contributions_amount_positive"
add_check_constraint :savings_contributions,
"source IN ('manual','initial')",
name: "chk_savings_contributions_source_enum"
end
end

58
db/schema.rb generated
View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2026_05_10_120000) do
ActiveRecord::Schema[7.2].define(version: 2026_05_11_100002) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -1224,6 +1224,51 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_10_120000) do
t.index ["family_id"], name: "index_rules_on_family_id"
end
create_table "savings_contributions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "savings_goal_id", null: false
t.uuid "account_id", null: false
t.decimal "amount", precision: 19, scale: 4, null: false
t.string "currency", null: false
t.string "source", default: "manual", null: false
t.date "contributed_at", null: false
t.text "notes"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_savings_contributions_on_account_id"
t.index ["savings_goal_id", "contributed_at"], name: "idx_on_savings_goal_id_contributed_at_da0bf9e1fc"
t.index ["savings_goal_id"], name: "index_savings_contributions_on_savings_goal_id"
t.check_constraint "amount > 0::numeric", name: "chk_savings_contributions_amount_positive"
t.check_constraint "source::text = ANY (ARRAY['manual'::character varying, 'initial'::character varying]::text[])", name: "chk_savings_contributions_source_enum"
end
create_table "savings_goal_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "savings_goal_id", null: false
t.uuid "account_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_savings_goal_accounts_on_account_id"
t.index ["savings_goal_id", "account_id"], name: "index_savings_goal_accounts_on_goal_and_account", unique: true
t.index ["savings_goal_id"], name: "index_savings_goal_accounts_on_savings_goal_id"
end
create_table "savings_goals", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "family_id", null: false
t.string "name", null: false
t.decimal "target_amount", precision: 19, scale: 4, null: false
t.string "currency", null: false
t.date "target_date"
t.string "color"
t.text "notes"
t.string "state", default: "active", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["family_id", "state"], name: "index_savings_goals_on_family_id_and_state"
t.index ["family_id"], name: "index_savings_goals_on_family_id"
t.check_constraint "char_length(name::text) <= 255", name: "chk_savings_goals_name_length"
t.check_constraint "state::text = ANY (ARRAY['active'::character varying, 'paused'::character varying, 'completed'::character varying, 'archived'::character varying]::text[])", name: "chk_savings_goals_state_enum"
t.check_constraint "target_amount > 0::numeric", name: "chk_savings_goals_target_amount_positive"
end
create_table "securities", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.string "ticker", null: false
t.string "name"
@@ -1249,7 +1294,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_10_120000) do
t.index ["kind"], name: "index_securities_on_kind"
t.index ["price_provider", "offline_reason"], name: "index_securities_on_price_provider_and_offline_reason"
t.index ["price_provider"], name: "index_securities_on_price_provider"
t.check_constraint "kind::text = ANY (ARRAY['standard'::character varying, 'cash'::character varying]::text[])", name: "chk_securities_kind"
t.check_constraint "kind::text = ANY (ARRAY['standard'::character varying::text, 'cash'::character varying::text])", name: "chk_securities_kind"
end
create_table "security_prices", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@@ -1408,8 +1453,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_10_120000) do
t.datetime "updated_at", null: false
t.boolean "manual_sync", default: false, null: false
t.index ["account_id"], name: "index_sophtron_accounts_on_account_id"
t.index ["sophtron_item_id"], name: "index_sophtron_accounts_on_sophtron_item_id"
t.index ["sophtron_item_id", "account_id"], name: "idx_unique_sophtron_accounts_per_item", unique: true
t.index ["sophtron_item_id"], name: "index_sophtron_accounts_on_sophtron_item_id"
end
create_table "sophtron_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@@ -1664,9 +1709,9 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_10_120000) do
t.datetime "last_used_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.check_constraint "sign_count >= 0", name: "chk_webauthn_credentials_sign_count_non_negative"
t.index ["credential_id"], name: "index_webauthn_credentials_on_credential_id", unique: true
t.index ["user_id"], name: "index_webauthn_credentials_on_user_id"
t.check_constraint "sign_count >= 0", name: "chk_webauthn_credentials_sign_count_non_negative"
end
add_foreign_key "account_providers", "accounts", on_delete: :cascade
@@ -1741,6 +1786,11 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_10_120000) do
add_foreign_key "rule_conditions", "rules"
add_foreign_key "rule_runs", "rules"
add_foreign_key "rules", "families"
add_foreign_key "savings_contributions", "accounts", on_delete: :cascade
add_foreign_key "savings_contributions", "savings_goals", on_delete: :cascade
add_foreign_key "savings_goal_accounts", "accounts", on_delete: :cascade
add_foreign_key "savings_goal_accounts", "savings_goals", on_delete: :cascade
add_foreign_key "savings_goals", "families", on_delete: :cascade
add_foreign_key "security_prices", "securities"
add_foreign_key "sessions", "impersonation_sessions", column: "active_impersonator_session_id"
add_foreign_key "sessions", "users"

View File

@@ -0,0 +1,58 @@
require "test_helper"
class SavingsContributionsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in users(:family_admin)
@goal = savings_goals(:vacation_italy)
@depository = accounts(:depository)
ensure_tailwind_build
end
test "new renders the modal form" do
get new_savings_goal_contribution_url(@goal)
assert_response :success
end
test "create saves a manual contribution" do
assert_difference -> { @goal.savings_contributions.count } => 1 do
post savings_goal_contributions_url(@goal), params: {
savings_contribution: {
amount: "100",
contributed_at: Date.current.iso8601,
notes: ""
},
savings_contribution_account_id: @depository.id
}.merge(savings_contribution: { account_id: @depository.id, amount: "100", contributed_at: Date.current.iso8601 })
end
assert_redirected_to savings_goal_path(@goal)
contribution = @goal.savings_contributions.order(created_at: :desc).first
assert_equal "manual", contribution.source
assert_equal @depository, contribution.account
end
test "create rejects contribution from non-linked account" do
unlinked = Account.create!(family: @goal.family, accountable: Depository.new, name: "Unlinked", currency: "USD", balance: 100)
assert_no_difference "@goal.savings_contributions.count" do
post savings_goal_contributions_url(@goal), params: {
savings_contribution: { amount: "10", contributed_at: Date.current.iso8601, account_id: unlinked.id }
}
end
assert_response :unprocessable_entity
end
test "destroy manual contribution removes it" do
manual = savings_contributions(:vacation_italy_manual)
assert_difference "SavingsContribution.count", -1 do
delete savings_goal_contribution_url(@goal, manual)
end
end
test "destroy initial contribution is blocked" do
initial = savings_contributions(:vacation_italy_initial)
assert_no_difference "SavingsContribution.count" do
delete savings_goal_contribution_url(@goal, initial)
end
assert_redirected_to savings_goal_path(@goal)
end
end

View File

@@ -0,0 +1,146 @@
require "test_helper"
class SavingsGoalsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in users(:family_admin)
@goal = savings_goals(:vacation_italy)
@depository = accounts(:depository)
@connected = accounts(:connected)
ensure_tailwind_build
end
test "index renders with active filter by default" do
get savings_goals_url
assert_response :success
assert_match(/Savings goals/i, response.body)
end
test "index honors state filter" do
get savings_goals_url(state: "paused")
assert_response :success
end
test "show renders the goal" do
get savings_goal_url(@goal)
assert_response :success
assert_match(@goal.name, response.body)
end
test "new renders the modal form" do
get new_savings_goal_url
assert_response :success
end
test "create persists a goal with linked accounts" do
assert_difference -> { SavingsGoal.count } => 1,
-> { SavingsGoalAccount.count } => 2 do
post savings_goals_url, params: {
savings_goal: {
name: "New goal",
target_amount: "1000",
target_date: 3.months.from_now.to_date.iso8601,
color: "#4da568",
account_ids: [ @depository.id, @connected.id ]
}
}
end
goal = SavingsGoal.order(created_at: :desc).first
assert_redirected_to savings_goal_path(goal)
end
test "create with initial contribution writes the contribution" do
assert_difference -> { SavingsContribution.count } => 1 do
post savings_goals_url, params: {
savings_goal: {
name: "Goal with initial",
target_amount: "1000",
color: "#4da568",
account_ids: [ @depository.id ],
initial_contribution_amount: "50",
initial_contribution_account_id: @depository.id
}
}
end
contribution = SavingsContribution.order(created_at: :desc).first
assert_equal "initial", contribution.source
assert_equal 50, contribution.amount.to_i
end
test "create rejects missing account_ids" do
assert_no_difference "SavingsGoal.count" do
post savings_goals_url, params: {
savings_goal: {
name: "Bad goal",
target_amount: "1000",
color: "#4da568"
}
}
end
assert_response :unprocessable_entity
end
test "create rejects foreign accounts" do
other_family = Family.create!(name: "Other", currency: "USD", locale: "en", country: "US", timezone: "UTC")
foreign = Account.create!(family: other_family, accountable: Depository.new, name: "Foreign", currency: "USD", balance: 100)
assert_no_difference "SavingsGoal.count" do
post savings_goals_url, params: {
savings_goal: {
name: "Foreign goal",
target_amount: "1000",
color: "#4da568",
account_ids: [ foreign.id ]
}
}
end
assert_response :unprocessable_entity
end
test "update modifies identity fields" do
patch savings_goal_url(@goal), params: { savings_goal: { name: "Renamed" } }
assert_redirected_to savings_goal_path(@goal)
assert_equal "Renamed", @goal.reload.name
end
test "pause/resume/complete/archive/unarchive flow" do
fresh = savings_goals(:emergency_fund)
patch pause_savings_goal_url(fresh)
assert fresh.reload.paused?
patch resume_savings_goal_url(fresh)
assert fresh.reload.active?
patch complete_savings_goal_url(fresh)
assert fresh.reload.completed?
patch archive_savings_goal_url(fresh)
assert fresh.reload.archived?
patch unarchive_savings_goal_url(fresh)
assert fresh.reload.active?
end
test "destroy on non-archived is rejected" do
assert_no_difference "SavingsGoal.count" do
delete savings_goal_url(@goal)
end
assert_redirected_to savings_goal_path(@goal)
end
test "destroy on archived deletes" do
@goal.archive!
assert_difference "SavingsGoal.count", -1 do
delete savings_goal_url(@goal)
end
assert_redirected_to savings_goals_path
end
test "another family's goal returns 404" do
other_family = Family.create!(name: "Other", currency: "USD", locale: "en", country: "US", timezone: "UTC")
other_account = Account.create!(family: other_family, accountable: Depository.new, name: "Foreign", currency: "USD", balance: 100)
other_goal = other_family.savings_goals.new(name: "Foreign goal", target_amount: 100, currency: "USD")
other_goal.savings_goal_accounts.build(account: other_account)
other_goal.save!
get savings_goal_url(other_goal)
assert_response :not_found
end
end

23
test/fixtures/savings_contributions.yml vendored Normal file
View File

@@ -0,0 +1,23 @@
vacation_italy_initial:
savings_goal: vacation_italy
account: depository
amount: 500
currency: USD
source: initial
contributed_at: <%= 90.days.ago.to_date %>
vacation_italy_manual:
savings_goal: vacation_italy
account: connected
amount: 250
currency: USD
source: manual
contributed_at: <%= 30.days.ago.to_date %>
emergency_fund_initial:
savings_goal: emergency_fund
account: depository
amount: 1000
currency: USD
source: initial
contributed_at: <%= 180.days.ago.to_date %>

15
test/fixtures/savings_goal_accounts.yml vendored Normal file
View File

@@ -0,0 +1,15 @@
vacation_italy_depository:
savings_goal: vacation_italy
account: depository
vacation_italy_connected:
savings_goal: vacation_italy
account: connected
emergency_fund_depository:
savings_goal: emergency_fund
account: depository
car_paydown_depository:
savings_goal: car_paydown
account: depository

25
test/fixtures/savings_goals.yml vendored Normal file
View File

@@ -0,0 +1,25 @@
vacation_italy:
family: dylan_family
name: Vacation in Italy
target_amount: 5000
currency: USD
target_date: <%= 4.months.from_now.to_date %>
color: "#4da568"
state: active
emergency_fund:
family: dylan_family
name: Emergency fund
target_amount: 10000
currency: USD
color: "#6471eb"
state: active
car_paydown:
family: dylan_family
name: Paid-off car
target_amount: 8000
currency: USD
target_date: <%= 12.months.from_now.to_date %>
color: "#e99537"
state: paused

View File

@@ -0,0 +1,103 @@
require "test_helper"
class Assistant::Function::CreateSavingsGoalTest < ActiveSupport::TestCase
setup do
@user = users(:family_admin)
@family = @user.family
@depository = accounts(:depository)
@fn = Assistant::Function::CreateSavingsGoal.new(@user)
end
test "to_definition returns valid JSON shape" do
definition = @fn.to_definition
assert_equal "create_savings_goal", definition[:name]
assert_kind_of String, definition[:description]
assert_equal "object", definition[:params_schema][:type]
assert_includes definition[:params_schema][:required], "name"
assert_includes definition[:params_schema][:required], "target_amount"
assert_includes definition[:params_schema][:required], "linked_account_names"
end
test "creates a goal with linked accounts" do
assert_difference -> { SavingsGoal.count } => 1,
-> { SavingsGoalAccount.count } => 1 do
result = @fn.call(
"name" => "Vacation",
"target_amount" => 1500,
"target_date" => 3.months.from_now.to_date.iso8601,
"linked_account_names" => [ @depository.name ]
)
assert result[:success]
assert_match(/Vacation/, result[:message])
assert result[:url].present?
assert_equal "USD", result[:currency]
end
end
test "creates a goal with initial contribution" do
assert_difference -> { SavingsContribution.count } => 1 do
@fn.call(
"name" => "Laptop fund",
"target_amount" => 2000,
"linked_account_names" => [ @depository.name ],
"initial_contribution" => { "amount" => 200, "source_account_name" => @depository.name }
)
end
contribution = SavingsContribution.order(created_at: :desc).first
assert_equal "initial", contribution.source
assert_equal 200, contribution.amount.to_i
end
test "soft error when name is missing" do
result = @fn.call("target_amount" => 100, "linked_account_names" => [ @depository.name ])
assert_equal false, result[:success]
assert_equal "name_required", result[:error]
end
test "soft error when target_amount is zero" do
result = @fn.call("name" => "X", "target_amount" => 0, "linked_account_names" => [ @depository.name ])
assert_equal false, result[:success]
assert_equal "target_amount_invalid", result[:error]
end
test "soft error when no linked accounts" do
result = @fn.call("name" => "X", "target_amount" => 100, "linked_account_names" => [])
assert_equal false, result[:success]
assert_equal "no_linked_accounts", result[:error]
assert_kind_of Array, result[:available_accounts]
assert(result[:available_accounts].all? { |a| a.is_a?(Hash) && a.key?(:name) })
end
test "soft error when account name doesn't match" do
result = @fn.call("name" => "X", "target_amount" => 100, "linked_account_names" => [ "Nonexistent Account" ])
assert_equal false, result[:success]
assert_equal "unknown_accounts", result[:error]
assert_includes result[:unknown_names], "Nonexistent Account"
end
test "soft error when currencies differ across linked accounts" do
eur = Account.create!(family: @family, accountable: Depository.new, name: "EUR Account", currency: "EUR", balance: 100)
result = @fn.call(
"name" => "Mixed",
"target_amount" => 100,
"linked_account_names" => [ @depository.name, eur.name ]
)
assert_equal false, result[:success]
assert_equal "currency_mismatch", result[:error]
end
test "scopes to the user's family" do
other_family = Family.create!(name: "Other", currency: "USD", locale: "en", country: "US", timezone: "UTC")
Account.create!(family: other_family, accountable: Depository.new, name: "Foreign Checking", currency: "USD", balance: 100)
result = @fn.call(
"name" => "X",
"target_amount" => 100,
"linked_account_names" => [ "Foreign Checking" ]
)
assert_equal false, result[:success]
assert_equal "unknown_accounts", result[:error]
end
end

View File

@@ -0,0 +1,46 @@
require "test_helper"
class SavingsContributionTest < ActiveSupport::TestCase
setup do
@goal = savings_goals(:vacation_italy)
@depository = accounts(:depository)
end
test "valid fixture contribution saves" do
assert savings_contributions(:vacation_italy_initial).valid?
end
test "amount must be positive" do
c = @goal.savings_contributions.new(account: @depository, amount: 0, currency: "USD", source: "manual", contributed_at: Date.current)
assert_not c.valid?
end
test "source must be manual or initial" do
c = @goal.savings_contributions.new(account: @depository, amount: 10, currency: "USD", source: "auto", contributed_at: Date.current)
assert_not c.valid?
end
test "currency syncs from goal when blank" do
c = @goal.savings_contributions.new(account: @depository, amount: 10, source: "manual", contributed_at: Date.current)
c.valid?
assert_equal @goal.currency, c.currency
end
test "account must be linked to goal" do
other_depository = Account.create!(
family: @goal.family,
accountable: Depository.new,
name: "Unlinked Depository",
currency: "USD",
balance: 100
)
c = @goal.savings_contributions.new(account: other_depository, amount: 10, currency: "USD", source: "manual", contributed_at: Date.current)
assert_not c.valid?
assert_includes c.errors[:account], "Account must be one of the goal's linked accounts."
end
test "manual? and initial? predicates" do
assert savings_contributions(:vacation_italy_initial).initial?
assert savings_contributions(:vacation_italy_manual).manual?
end
end

View File

@@ -0,0 +1,135 @@
require "test_helper"
class SavingsGoalTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
@depository = accounts(:depository)
@connected = accounts(:connected)
@goal = savings_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 "must have at least one linked account on create" do
new_goal = @family.savings_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 "linked accounts must be depository" do
investment = accounts(:investment)
new_goal = @family.savings_goals.new(name: "Test", target_amount: 100, currency: "USD")
new_goal.savings_goal_accounts.build(account: investment)
assert_not new_goal.valid?
assert_includes new_goal.errors[:linked_accounts], "All linked accounts must be Depository (checking, savings, HSA, CD, money-market)."
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.savings_goals.new(name: "T", target_amount: 100, currency: "USD")
new_goal.savings_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.savings_goals.new(name: "T", target_amount: 100, currency: "USD")
new_goal.savings_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 after contributions exist" do
assert @goal.savings_contributions.exists?
@goal.currency = "EUR"
assert_not @goal.valid?
assert_includes @goal.errors[:currency], "Can't change the currency after a goal has contributions."
end
test "current_balance sums contributions" do
expected = @goal.savings_contributions.sum(:amount)
assert_equal expected, @goal.current_balance
end
test "with_current_balance scope precomputes balance" do
loaded = @family.savings_goals.with_current_balance.find(@goal.id)
expected = @goal.savings_contributions.sum(:amount)
assert_equal expected.to_f, loaded.current_balance.to_f
end
test "progress_percent caps at 100" do
@goal.target_amount = 1
assert_equal 100, @goal.progress_percent
end
test "progress_percent is 0 for empty active goal" do
fresh = savings_goals(:car_paydown)
fresh.target_amount = 10000
assert_equal 0, fresh.progress_percent
end
test "remaining_amount is non-negative" do
@goal.target_amount = 1
assert_equal 0, @goal.remaining_amount
end
test "AASM transitions" do
fresh = savings_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
assert_equal :no_target_date, @goal.status
end
test "advisory_lock_key_for is stable per family" do
k1 = SavingsGoal.advisory_lock_key_for(@family.id)
k2 = SavingsGoal.advisory_lock_key_for(@family.id)
assert_equal k1, k2
assert_kind_of Integer, k1
end
end