refactor: rename Savings Goals feature to Goals

User-facing rename + structural rename. Feature is now called just
"Goals" everywhere — page title, sidebar nav, modal headings, flash
messages, AI assistant tool. Code identifiers follow:

- Models: SavingsGoal → Goal, SavingsContribution → GoalContribution,
  SavingsGoalAccount → GoalAccount.
- Tables: savings_goals → goals, savings_contributions → goal_contributions,
  savings_goal_accounts → goal_accounts. FK columns savings_goal_id →
  goal_id. New migration db/migrate/20260511100003_rename_savings_to_goals.rb
  uses rename_table + rename_column; PG handles index renaming and FK
  redirection automatically.
- Controllers: SavingsGoalsController → GoalsController,
  SavingsContributionsController → GoalContributionsController.
- Routes: /savings_goals → /goals, nested /goals/:id/contributions
  (resource name shifts; old route name aliases dropped).
- ViewComponent namespace: Savings::* → Goals::*. Component class
  names drop their redundant "Goal" prefix where the namespace already
  carries it: Savings::GoalCardComponent → Goals::CardComponent,
  Savings::GoalAvatarComponent → Goals::AvatarComponent. Others keep
  their names (Goals::ProgressRingComponent, Goals::StatusPillComponent,
  Goals::AccountStackComponent, Goals::FundingAccountsBreakdownComponent).
- Stimulus controllers: savings_goal_* → goal_*, savings_goals_filter
  → goals_filter. Stimulus identifiers in data-controller / data-*
  attributes follow.
- Locale keys: savings_goals: → goals: (top level), savings_contributions:
  → goal_contributions: (top level). All t() callers updated.
- AI assistant tool: Assistant::Function::CreateSavingsGoal →
  Assistant::Function::CreateGoal, tool name "create_savings_goal" →
  "create_goal", description / response text updated.
- Sidebar nav label "Savings" → "Goals". Goals/show + index page title
  "Savings" → "Goals". Empty goals_section heading/subtitle dropped
  (duplicated the page title post-rename).

Original migrations create_savings_goals / create_savings_goal_accounts /
create_savings_contributions remain untouched so historical replay
still works; the rename migration runs on top.
This commit is contained in:
Guillem Arias
2026-05-11 20:08:32 +02:00
parent 560bff87d2
commit 9b61e4a41b
56 changed files with 810 additions and 804 deletions

View File

@@ -1,7 +1,7 @@
<span class="inline-flex items-center" aria-hidden="true">
<% shown.each_with_index do |account, i| %>
<span class="inline-flex items-center justify-center w-5 h-5 rounded-full text-inverse text-[9px] font-semibold ring-2 ring-container"
style="background-color: <%= Savings::GoalAvatarComponent.color_for(account.name) %>; <%= "margin-left: -6px;" if i > 0 %>"
style="background-color: <%= Goals::AvatarComponent.color_for(account.name) %>; <%= "margin-left: -6px;" if i > 0 %>"
title="<%= account.name %>">
<%= initial_for(account) %>
</span>

View File

@@ -1,4 +1,4 @@
class Savings::AccountStackComponent < ApplicationComponent
class Goals::AccountStackComponent < ApplicationComponent
def initialize(accounts:, max: 3)
@accounts = accounts
@max = max

View File

@@ -1,6 +1,6 @@
<span class="inline-flex items-center justify-center text-inverse font-semibold <%= box_classes %> <%= text_classes %> <%= radius_classes %>"
style="background-color: <%= color %>;"
aria-hidden="true"
data-testid="savings-goal-avatar">
data-testid="goal-avatar">
<%= initial %>
</span>

View File

@@ -1,4 +1,4 @@
class Savings::GoalAvatarComponent < ApplicationComponent
class Goals::AvatarComponent < ApplicationComponent
SIZES = {
"sm" => { box: "w-6 h-6", text: "text-[10px]", radius: "rounded-md" },
"md" => { box: "w-9 h-9", text: "text-sm", radius: "rounded-lg" },
@@ -6,7 +6,7 @@ class Savings::GoalAvatarComponent < ApplicationComponent
"xl" => { box: "w-16 h-16", text: "text-2xl", radius: "rounded-2xl" }
}.freeze
PALETTE = SavingsGoal::COLORS
PALETTE = Goal::COLORS
# Deterministic color pick from the palette so the same string maps to
# the same color across processes (Ruby's String#hash is randomized per
@@ -19,7 +19,7 @@ class Savings::GoalAvatarComponent < ApplicationComponent
def initialize(goal: nil, name: nil, color: nil, size: "md")
@goal = goal
@name = name || goal&.name
@color = color || goal&.color || SavingsGoal::COLORS.first
@color = color || goal&.color || Goal::COLORS.first
@size = SIZES.key?(size) ? size : "md"
end

View File

@@ -1,38 +1,38 @@
<%= link_to savings_goal_path(goal),
<%= link_to goal_path(goal),
class: "group block bg-container rounded-xl shadow-border-xs hover:bg-surface-hover transition-colors p-6 #{"opacity-75" if goal.paused? || goal.archived?}",
data: {
savings_goals_filter_target: "card",
goals_filter_target: "card",
goal_name: goal.name,
goal_status: goal.display_status
} do %>
<div class="flex items-start gap-3">
<%= render Savings::GoalAvatarComponent.new(goal: goal, size: "lg") %>
<%= render Goals::AvatarComponent.new(goal: goal, size: "lg") %>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 mb-0.5">
<p class="text-sm font-medium text-primary truncate"><%= goal.name %></p>
<%= render Savings::StatusPillComponent.new(goal: goal) %>
<%= render Goals::StatusPillComponent.new(goal: goal) %>
</div>
<p class="text-[11px] text-subdued truncate"><%= secondary_line %></p>
</div>
<div class="shrink-0 relative" style="width: <%= Savings::GoalCardComponent::RING_SIZE %>px; height: <%= Savings::GoalCardComponent::RING_SIZE %>px;">
<svg width="<%= Savings::GoalCardComponent::RING_SIZE %>" height="<%= Savings::GoalCardComponent::RING_SIZE %>" viewBox="0 0 <%= Savings::GoalCardComponent::RING_SIZE %> <%= Savings::GoalCardComponent::RING_SIZE %>">
<circle cx="<%= Savings::GoalCardComponent::RING_SIZE / 2.0 %>"
cy="<%= Savings::GoalCardComponent::RING_SIZE / 2.0 %>"
<div class="shrink-0 relative" style="width: <%= Goals::CardComponent::RING_SIZE %>px; height: <%= Goals::CardComponent::RING_SIZE %>px;">
<svg width="<%= Goals::CardComponent::RING_SIZE %>" height="<%= Goals::CardComponent::RING_SIZE %>" viewBox="0 0 <%= Goals::CardComponent::RING_SIZE %> <%= Goals::CardComponent::RING_SIZE %>">
<circle cx="<%= Goals::CardComponent::RING_SIZE / 2.0 %>"
cy="<%= Goals::CardComponent::RING_SIZE / 2.0 %>"
r="<%= ring_radius %>"
fill="none"
stroke="var(--budget-unallocated-fill)"
stroke-width="<%= Savings::GoalCardComponent::RING_STROKE %>" />
<circle cx="<%= Savings::GoalCardComponent::RING_SIZE / 2.0 %>"
cy="<%= Savings::GoalCardComponent::RING_SIZE / 2.0 %>"
stroke-width="<%= Goals::CardComponent::RING_STROKE %>" />
<circle cx="<%= Goals::CardComponent::RING_SIZE / 2.0 %>"
cy="<%= Goals::CardComponent::RING_SIZE / 2.0 %>"
r="<%= ring_radius %>"
fill="none"
stroke="<%= ring_color %>"
stroke-width="<%= Savings::GoalCardComponent::RING_STROKE %>"
stroke-width="<%= Goals::CardComponent::RING_STROKE %>"
stroke-linecap="round"
stroke-dasharray="<%= ring_circumference %>"
stroke-dashoffset="<%= ring_offset %>"
transform="rotate(-90 <%= Savings::GoalCardComponent::RING_SIZE / 2.0 %> <%= Savings::GoalCardComponent::RING_SIZE / 2.0 %>)" />
transform="rotate(-90 <%= Goals::CardComponent::RING_SIZE / 2.0 %> <%= Goals::CardComponent::RING_SIZE / 2.0 %>)" />
</svg>
<div class="absolute inset-0 flex items-center justify-center text-[11px] font-medium text-primary tabular-nums">
<%= progress_percent %>%
@@ -52,7 +52,7 @@
<div class="mt-4 flex items-center justify-between">
<div class="flex items-center gap-2">
<%= render Savings::AccountStackComponent.new(accounts: linked_accounts) %>
<%= render Goals::AccountStackComponent.new(accounts: linked_accounts) %>
<span class="text-[11px] text-subdued"><%= linked_accounts_count_label %></span>
</div>
<span class="text-[11px] text-subdued tabular-nums"><%= footer_line %></span>

View File

@@ -1,4 +1,4 @@
class Savings::GoalCardComponent < ApplicationComponent
class Goals::CardComponent < ApplicationComponent
RING_SIZE = 64
RING_STROKE = 6
@@ -26,20 +26,20 @@ class Savings::GoalCardComponent < ApplicationComponent
end
def linked_accounts_count_label
I18n.t("savings_goals.goal_card.accounts", count: linked_accounts.size)
I18n.t("goals.goal_card.accounts", count: linked_accounts.size)
end
def secondary_line
if goal.completed?
I18n.t("savings_goals.goal_card.completed")
I18n.t("goals.goal_card.completed")
elsif goal.target_date.nil?
I18n.t("savings_goals.goal_card.no_target_date")
I18n.t("goals.goal_card.no_target_date")
else
days = (goal.target_date - Date.current).to_i
if days >= 0
I18n.t("savings_goals.goal_card.days_left_by", count: days, date: I18n.l(goal.target_date, format: :long))
I18n.t("goals.goal_card.days_left_by", count: days, date: I18n.l(goal.target_date, format: :long))
else
I18n.t("savings_goals.goal_card.past_due")
I18n.t("goals.goal_card.past_due")
end
end
end
@@ -63,32 +63,32 @@ class Savings::GoalCardComponent < ApplicationComponent
avg = Money.new(goal.average_monthly_contribution, goal.currency).format
target = goal.monthly_target_amount ? Money.new(goal.monthly_target_amount, goal.currency).format : nil
if target
I18n.t("savings_goals.goal_card.pace_with_target", avg: avg, target: target)
I18n.t("goals.goal_card.pace_with_target", avg: avg, target: target)
else
I18n.t("savings_goals.goal_card.pace_no_target", avg: avg)
I18n.t("goals.goal_card.pace_no_target", avg: avg)
end
end
def footer_line
if goal.archived?
I18n.t("savings_goals.goal_card.footer_archived")
I18n.t("goals.goal_card.footer_archived")
elsif goal.paused?
I18n.t("savings_goals.goal_card.footer_paused")
I18n.t("goals.goal_card.footer_paused")
elsif goal.completed? || goal.status == :reached
I18n.t("savings_goals.goal_card.footer_reached")
I18n.t("goals.goal_card.footer_reached")
elsif goal.status == :behind && goal.monthly_target_amount
catch_up = Money.new(goal.monthly_target_amount, goal.currency).format
I18n.t("savings_goals.goal_card.footer_catch_up", amount: catch_up)
I18n.t("goals.goal_card.footer_catch_up", amount: catch_up)
elsif goal.status == :no_target_date
I18n.t("savings_goals.goal_card.footer_no_deadline")
I18n.t("goals.goal_card.footer_no_deadline")
else
days = goal.last_contribution_days_ago
if days.nil?
I18n.t("savings_goals.goal_card.footer_no_contributions")
I18n.t("goals.goal_card.footer_no_contributions")
elsif days.zero?
I18n.t("savings_goals.goal_card.footer_last_today")
I18n.t("goals.goal_card.footer_last_today")
else
I18n.t("savings_goals.goal_card.footer_last_days", count: days)
I18n.t("goals.goal_card.footer_last_days", count: days)
end
end
end

View File

@@ -1,10 +1,10 @@
<% if total.zero? %>
<p class="text-sm text-secondary"><%= t("savings_goals.show.no_contributions_yet") %></p>
<p class="text-sm text-secondary"><%= t("goals.show.no_contributions_yet") %></p>
<% else %>
<div class="flex h-2 rounded-full overflow-hidden mb-4">
<% rows.each do |row| %>
<% next if row[:amount].to_d.zero? %>
<div style="width: <%= percent_for(row[:amount]) %>%; background-color: <%= Savings::GoalAvatarComponent.color_for(row[:account].name) %>;"
<div style="width: <%= percent_for(row[:amount]) %>%; background-color: <%= Goals::AvatarComponent.color_for(row[:account].name) %>;"
title="<%= row[:account].name %>"></div>
<% end %>
</div>
@@ -12,14 +12,14 @@
<ul class="space-y-3">
<% rows.each do |row| %>
<li class="flex items-center gap-3">
<%= render Savings::GoalAvatarComponent.new(name: row[:account].name, color: Savings::GoalAvatarComponent.color_for(row[:account].name), size: "sm") %>
<%= render Goals::AvatarComponent.new(name: row[:account].name, color: Goals::AvatarComponent.color_for(row[:account].name), size: "sm") %>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-primary truncate"><%= row[:account].name %></p>
<p class="text-[11px] text-subdued"><%= row[:account].subtype&.titleize || row[:account].accountable_type %> · <%= t("savings_goals.show.funding_balance", amount: Money.new(row[:account].balance, row[:account].currency).format) %></p>
<p class="text-[11px] text-subdued"><%= row[:account].subtype&.titleize || row[:account].accountable_type %> · <%= t("goals.show.funding_balance", amount: Money.new(row[:account].balance, row[:account].currency).format) %></p>
</div>
<div class="text-right">
<p class="text-sm font-medium text-primary tabular-nums"><%= row[:money].format %></p>
<p class="text-[10px] text-subdued tabular-nums"><%= percent_for(row[:amount]) %>% <%= t("savings_goals.show.of_saved") %></p>
<p class="text-[10px] text-subdued tabular-nums"><%= percent_for(row[:amount]) %>% <%= t("goals.show.of_saved") %></p>
</div>
</li>
<% end %>

View File

@@ -1,4 +1,4 @@
class Savings::FundingAccountsBreakdownComponent < ApplicationComponent
class Goals::FundingAccountsBreakdownComponent < ApplicationComponent
def initialize(goal:, rows:)
@goal = goal
@rows = rows

View File

@@ -5,13 +5,13 @@
aria-valuenow="<%= percent %>"
aria-valuemin="0"
aria-valuemax="100"
aria-label="<%= t("savings_goals.show.ring.aria_label", percent: percent, amount: amount_label, target: target_label) %>"
aria-label="<%= t("goals.show.ring.aria_label", percent: percent, amount: amount_label, target: target_label) %>"
class="relative mx-auto"
style="width: <%= size %>px; height: <%= size %>px;">
<div data-donut-chart-target="chartContainer" class="absolute inset-0 pointer-events-none"></div>
<div data-donut-chart-target="contentContainer" class="flex items-center justify-center h-full">
<div data-donut-chart-target="defaultContent" class="flex flex-col items-center text-center">
<span class="text-secondary text-xs mb-1"><%= t("savings_goals.show.ring.saved") %></span>
<span class="text-secondary text-xs mb-1"><%= t("goals.show.ring.saved") %></span>
<span class="text-3xl font-medium tabular-nums privacy-sensitive <%= percent_text_class %>"><%= percent %>%</span>
</div>
</div>

View File

@@ -1,4 +1,4 @@
class Savings::ProgressRingComponent < ApplicationComponent
class Goals::ProgressRingComponent < ApplicationComponent
def initialize(goal:, size: 180)
@goal = goal
@size = size

View File

@@ -1,4 +1,4 @@
class Savings::StatusPillComponent < ApplicationComponent
class Goals::StatusPillComponent < ApplicationComponent
# Text colors here intentionally use palette steps (green-700 / yellow-700 /
# gray-700) rather than `text-success` / `text-warning` / `text-secondary`
# tokens because the functional tokens drop below WCAG 1.4.3 4.5:1 on tinted
@@ -26,7 +26,7 @@ class Savings::StatusPillComponent < ApplicationComponent
end
def label
I18n.t("savings_goals.status.#{status_key}")
I18n.t("goals.status.#{status_key}")
end
def classes

View File

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

View File

@@ -1,15 +1,15 @@
class SavingsGoalsController < ApplicationController
before_action :set_savings_goal, only: %i[show edit update destroy pause resume complete archive unarchive]
class GoalsController < ApplicationController
before_action :set_goal, only: %i[show edit update destroy pause resume complete archive unarchive]
STATE_FILTERS = %w[all active paused completed archived].freeze
ACTIVE_STATUS_RANK = { behind: 0, on_track: 1, no_target_date: 2 }.freeze
def index
@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
h[state] = state == "all" ? Current.family.goals.count : Current.family.goals.where(state: state).count
end
all_goals = Current.family.savings_goals.with_current_balance.alphabetically.includes(:savings_contributions, :linked_accounts).to_a
all_goals = Current.family.goals.with_current_balance.alphabetically.includes(:goal_contributions, :linked_accounts).to_a
@active_goals = all_goals.reject { |g| %w[completed archived].include?(g.state) }
.sort_by { |g| [ g.paused? ? 3 : ACTIVE_STATUS_RANK.fetch(g.status, 4), g.name.downcase ] }
@completed_goals = all_goals.select { |g| g.state == "completed" }
@@ -20,47 +20,47 @@ class SavingsGoalsController < ApplicationController
@show_search = @active_goals.size > 6
@breadcrumbs = [
[ t("breadcrumbs.home"), root_path ],
[ t("savings_goals.index.title"), nil ]
[ t("goals.index.title"), nil ]
]
end
def show
@contributions = @savings_goal.savings_contributions
@contributions = @goal.goal_contributions
.sort_by { |c| [ c.contributed_at, c.created_at ] }
.reverse
@funding_breakdown = funding_breakdown_for(@savings_goal)
@stats = stats_for(@savings_goal)
@funding_breakdown = funding_breakdown_for(@goal)
@stats = stats_for(@goal)
@breadcrumbs = [
[ t("breadcrumbs.home"), root_path ],
[ t("savings_goals.index.title"), savings_goals_path ],
[ @savings_goal.name, nil ]
[ t("goals.index.title"), goals_path ],
[ @goal.name, nil ]
]
end
def new
@savings_goal = Current.family.savings_goals.new(
color: SavingsGoal::COLORS.sample,
@goal = Current.family.goals.new(
color: Goal::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?
@goal = Current.family.goals.new(goal_params)
accounts = lookup_accounts(params.dig(:goal, :account_ids))
@goal.currency = accounts.first.currency if accounts.any? && @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)
Goal.transaction do
accounts.each { |a| @goal.goal_accounts.build(account: a) }
@goal.save!
create_initial_contribution_if_provided!(@goal, accounts)
end
flash[:notice] = t(".success")
respond_to do |format|
format.html { redirect_to savings_goal_path(@savings_goal) }
format.html { redirect_to goal_path(@goal) }
format.turbo_stream do
render turbo_stream: turbo_stream.action(:redirect, savings_goal_path(@savings_goal))
render turbo_stream: turbo_stream.action(:redirect, goal_path(@goal))
end
end
rescue ActiveRecord::RecordInvalid
@@ -72,12 +72,12 @@ class SavingsGoalsController < ApplicationController
end
def update
if @savings_goal.update(savings_goal_update_params)
if @goal.update(goal_update_params)
flash[:notice] = t(".success")
respond_to do |format|
format.html { redirect_to savings_goal_path(@savings_goal) }
format.html { redirect_to goal_path(@goal) }
format.turbo_stream do
render turbo_stream: turbo_stream.action(:redirect, savings_goal_path(@savings_goal))
render turbo_stream: turbo_stream.action(:redirect, goal_path(@goal))
end
end
else
@@ -86,13 +86,13 @@ class SavingsGoalsController < ApplicationController
end
def destroy
unless @savings_goal.archived?
redirect_to savings_goal_path(@savings_goal), alert: t(".archive_first")
unless @goal.archived?
redirect_to goal_path(@goal), alert: t(".archive_first")
return
end
@savings_goal.destroy!
redirect_to savings_goals_path, notice: t(".success")
@goal.destroy!
redirect_to goals_path, notice: t(".success")
end
def pause
@@ -116,19 +116,19 @@ class SavingsGoalsController < ApplicationController
end
private
def set_savings_goal
@savings_goal = Current.family.savings_goals
def set_goal
@goal = Current.family.goals
.with_current_balance
.includes(savings_contributions: :account, linked_accounts: [])
.includes(goal_contributions: :account, linked_accounts: [])
.find(params[:id])
end
def savings_goal_params
params.require(:savings_goal).permit(:name, :target_amount, :target_date, :color, :notes)
def goal_params
params.require(: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)
def goal_update_params
params.require(:goal).permit(:name, :target_amount, :target_date, :color, :notes)
end
def lookup_accounts(ids)
@@ -143,15 +143,15 @@ class SavingsGoalsController < ApplicationController
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)
amount = params.dig(:goal, :initial_contribution_amount)
account_id = params.dig(: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!(
goal.goal_contributions.create!(
account: source,
amount: amount,
currency: goal.currency,
@@ -161,7 +161,7 @@ class SavingsGoalsController < ApplicationController
end
def funding_breakdown_for(goal)
totals = goal.savings_contributions
totals = goal.goal_contributions
.group_by(&:account_id)
.transform_values { |arr| arr.sum(&:amount) }
goal.linked_accounts.map do |account|
@@ -211,21 +211,21 @@ class SavingsGoalsController < ApplicationController
def stats_for(goal)
avg = goal.average_monthly_contribution.to_d
sub_avg = if goal.monthly_target_amount && goal.monthly_target_amount.to_d > avg
t("savings_goals.show.stats.needs_per_month", amount: Money.new(goal.monthly_target_amount, goal.currency).format)
t("goals.show.stats.needs_per_month", amount: Money.new(goal.monthly_target_amount, goal.currency).format)
else
t("savings_goals.show.stats.above_target_pace")
t("goals.show.stats.above_target_pace")
end
sub_target = if goal.monthly_target_amount
t("savings_goals.show.stats.needs_per_month", amount: Money.new(goal.monthly_target_amount, goal.currency).format)
t("goals.show.stats.needs_per_month", amount: Money.new(goal.monthly_target_amount, goal.currency).format)
else
t("savings_goals.show.stats.no_required_pace")
t("goals.show.stats.no_required_pace")
end
summary = projection_summary(goal, avg)
{
avg_monthly: avg,
avg_monthly_sub: sub_avg,
contributions_count: goal.savings_contributions.size,
contributions_count: goal.goal_contributions.size,
monthly_target_sub: sub_target,
projection_summary: summary
}
@@ -236,34 +236,34 @@ class SavingsGoalsController < ApplicationController
money = ->(amount) { Money.new(amount, currency).format }
if goal.completed? || goal.progress_percent >= 100
t("savings_goals.show.projection.reached")
t("goals.show.projection.reached")
elsif goal.target_date.nil?
t("savings_goals.show.projection.no_target_date")
t("goals.show.projection.no_target_date")
elsif goal.monthly_target_amount && avg_monthly < goal.monthly_target_amount
t("savings_goals.show.projection.behind",
t("goals.show.projection.behind",
current: money.call(avg_monthly),
required: money.call(goal.monthly_target_amount))
elsif avg_monthly.positive?
months_to_target = (goal.remaining_amount.to_d / avg_monthly).ceil
projected_date = Date.current >> months_to_target.to_i
t("savings_goals.show.projection.on_track",
t("goals.show.projection.on_track",
date: projected_date.strftime("%b %Y"))
else
t("savings_goals.show.projection.no_pace")
t("goals.show.projection.no_pace")
end
end
def perform_transition!(event)
if @savings_goal.aasm.may_fire_event?(event)
@savings_goal.public_send("#{event}!")
if @goal.aasm.may_fire_event?(event)
@goal.public_send("#{event}!")
respond_to do |format|
format.html { redirect_to savings_goal_path(@savings_goal), notice: t(".success") }
format.html { redirect_to goal_path(@goal), notice: t(".success") }
format.turbo_stream do
render turbo_stream: turbo_stream.action(:redirect, savings_goal_path(@savings_goal))
render turbo_stream: turbo_stream.action(:redirect, goal_path(@goal))
end
end
else
redirect_to savings_goal_path(@savings_goal), alert: t(".invalid_transition")
redirect_to goal_path(@goal), alert: t(".invalid_transition")
end
end
end

View File

@@ -1,58 +0,0 @@
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

@@ -1,15 +1,15 @@
import { Controller } from "@hotwired/stimulus";
import * as d3 from "d3";
// Projection chart for a savings goal. Renders:
// Projection chart for a goal. Renders:
// - Saved area + line from goal creation → today (solid)
// - Dashed projection line from today → target date (yellow if behind,
// green if on track)
// - Horizontal dashed target line with label
// - Today marker (vertical line + dot)
//
// Data shape passed via `data-savings-goal-projection-chart-data-value`
// matches SavingsGoal#projection_payload.
// Data shape passed via `data-goal-projection-chart-data-value`
// matches Goal#projection_payload.
export default class extends Controller {
static values = { data: Object, ariaLabel: String, ariaDescription: String };
@@ -112,7 +112,7 @@ export default class extends Controller {
const titleId = `chart-title-${this._id()}`;
const descId = `chart-desc-${this._id()}`;
svg.attr("role", "img").attr("aria-labelledby", titleId).attr("aria-describedby", descId);
svg.append("title").attr("id", titleId).text(this.ariaLabelValue || "Savings goal projection");
svg.append("title").attr("id", titleId).text(this.ariaLabelValue || "Goal projection");
svg.append("desc").attr("id", descId).text(this.ariaDescriptionValue || "");
const defs = svg.append("defs");

View File

@@ -1,6 +1,6 @@
import { Controller } from "@hotwired/stimulus";
// 2-step modal stepper for creating a savings goal.
// 2-step modal stepper for creating a goal.
//
// Single <form> with two panels. Step 1 collects identity (name, amount,
// date, color, notes, linked accounts). Step 2 reviews + optional initial
@@ -108,7 +108,7 @@ export default class extends Controller {
if (!this.hasAvatarPreviewTarget || !this.hasNameInputTarget) return;
const name = this.nameInputTarget.value.trim();
const initial = name ? name.charAt(0).toUpperCase() : "?";
const inner = this.avatarPreviewTarget.querySelector('[data-testid="savings-goal-avatar"]');
const inner = this.avatarPreviewTarget.querySelector('[data-testid="goal-avatar"]');
if (inner) inner.textContent = initial;
}
@@ -208,7 +208,7 @@ export default class extends Controller {
}
// Modal subtitle lives in the dialog header, outside this controller's
// DOM scope. Locate it by attribute and update directly.
const subtitle = document.querySelector('[data-savings-goal-stepper-modal-subtitle]');
const subtitle = document.querySelector('[data-goal-stepper-modal-subtitle]');
if (subtitle) {
subtitle.textContent =
this.currentStep === 1 ? this.step1SubtitleValue : this.step2SubtitleValue;
@@ -232,10 +232,10 @@ export default class extends Controller {
updateReview() {
if (!this.hasReviewNameTarget) return;
const name = this.element.querySelector('input[name="savings_goal[name]"]')?.value || "—";
const amountInput = this.element.querySelector('input[name="savings_goal[target_amount]"]');
const name = this.element.querySelector('input[name="goal[name]"]')?.value || "—";
const amountInput = this.element.querySelector('input[name="goal[target_amount]"]');
const amount = amountInput?.value ? Number.parseFloat(amountInput.value) : 0;
const dateInput = this.element.querySelector('input[type="date"][name="savings_goal[target_date]"]');
const dateInput = this.element.querySelector('input[type="date"][name="goal[target_date]"]');
const dateValue = dateInput?.value;
this.reviewNameTarget.textContent = name;

View File

@@ -1,6 +1,6 @@
import { Controller } from "@hotwired/stimulus";
// Free-text + status-chip filter for the savings-goals index grid.
// Free-text + status-chip filter for the goals index grid.
// Mirrors the providers-filter pattern. Each card has data-goal-name
// and data-goal-status; the controller toggles `.hidden` on cards
// based on the active query/chip.

View File

@@ -20,9 +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
has_many :goal_accounts, dependent: :destroy
has_many :goals, through: :goal_accounts
has_many :goal_contributions, dependent: :destroy
monetize :balance, :cash_balance

View File

@@ -29,7 +29,7 @@ module Assistant
Function::GetIncomeStatement,
Function::ImportBankStatement,
Function::SearchFamilyFiles,
Function::CreateSavingsGoal
Function::CreateGoal
]
end

View File

@@ -1,12 +1,12 @@
class Assistant::Function::CreateSavingsGoal < Assistant::Function
class Assistant::Function::CreateGoal < Assistant::Function
class << self
def name
"create_savings_goal"
"create_goal"
end
def description
<<~INSTRUCTIONS
Creates a savings goal for the user's family.
Creates a 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",
@@ -111,16 +111,16 @@ class Assistant::Function::CreateSavingsGoal < Assistant::Function
end
goal = nil
SavingsGoal.transaction do
goal = family.savings_goals.new(
Goal.transaction do
goal = family.goals.new(
name: name,
target_amount: target_amount,
target_date: target_date,
currency: currencies.first,
notes: notes.presence,
color: SavingsGoal::COLORS.sample
color: Goal::COLORS.sample
)
matched.each { |a| goal.savings_goal_accounts.build(account: a) }
matched.each { |a| goal.goal_accounts.build(account: a) }
goal.save!
create_initial_contribution!(goal, matched, initial)
@@ -133,9 +133,9 @@ class Assistant::Function::CreateSavingsGoal < Assistant::Function
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),
url: Rails.application.routes.url_helpers.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)}."
message: "Created goal '#{goal.name}' (target #{goal.target_amount_money.format}). View it at #{Rails.application.routes.url_helpers.goal_path(goal)}."
}
rescue ActiveRecord::RecordInvalid => e
error("validation_failed", e.record.errors.full_messages.join("; "))
@@ -151,7 +151,7 @@ class Assistant::Function::CreateSavingsGoal < Assistant::Function
source = matched_accounts.find { |a| a.name == initial["source_account_name"].to_s }
raise ActiveRecord::RecordInvalid.new(goal) unless source
goal.savings_contributions.create!(
goal.goal_contributions.create!(
account: source,
amount: amount,
currency: goal.currency,

View File

@@ -103,8 +103,8 @@ 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 "🎯 Seeding goals..."
generate_goals!(family)
puts "✅ Realistic demo data loaded successfully!"
end
@@ -1278,7 +1278,7 @@ class Demo::Generator
puts " ✅ Set property and vehicle valuations"
end
def generate_savings_goals!(family)
def generate_goals!(family)
depository_accounts = family.accounts.where(accountable_type: "Depository").visible.to_a
return if depository_accounts.empty?
@@ -1385,19 +1385,19 @@ class Demo::Generator
]
goals.each do |goal_spec|
goal = family.savings_goals.new(
goal = family.goals.new(
name: goal_spec[:name],
target_amount: goal_spec[:target],
target_date: goal_spec[:target_date],
currency: currency,
color: SavingsGoal::COLORS.sample,
color: Goal::COLORS.sample,
state: goal_spec[:state] || "active"
)
goal_spec[:accounts].uniq.each { |a| goal.savings_goal_accounts.build(account: a) }
goal_spec[:accounts].uniq.each { |a| goal.goal_accounts.build(account: a) }
goal.save!
goal_spec[:contributions].each do |c|
goal.savings_contributions.create!(
goal.goal_contributions.create!(
account: c[:account],
amount: c[:amount],
currency: currency,
@@ -1407,6 +1407,6 @@ class Demo::Generator
end
end
puts " ✅ Seeded #{goals.size} savings goals"
puts " ✅ Seeded #{goals.size} goals"
end
end

View File

@@ -42,14 +42,14 @@ 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 :goals, dependent: :destroy
has_many :goal_contributions, through: :goals
# Sum of contribution amounts within the given date range, returned as
# a BigDecimal in the family's primary currency. Powers the savings
# goals "Contributed · last 30d" KPI.
def contribution_velocity(range:)
savings_contributions.where(contributed_at: range).sum(:amount).to_d
goal_contributions.where(contributed_at: range).sum(:amount).to_d
end
has_many :llm_usages, dependent: :destroy

View File

@@ -1,4 +1,4 @@
class SavingsGoal < ApplicationRecord
class Goal < ApplicationRecord
include AASM, Monetizable
COLORS = Category::COLORS
@@ -8,9 +8,9 @@ class SavingsGoal < ApplicationRecord
attr_accessor :initial_contribution_amount, :initial_contribution_account_id
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
has_many :goal_accounts, dependent: :destroy
has_many :linked_accounts, through: :goal_accounts, source: :account
has_many :goal_contributions, dependent: :destroy
validates :name, presence: true, length: { maximum: 255 }
validates :target_amount, presence: true, numericality: { greater_than: 0 }
@@ -28,15 +28,15 @@ class SavingsGoal < ApplicationRecord
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"))
left_outer_joins(:goal_contributions)
.group(Arel.sql("goals.id"))
.select(Arel.sql("goals.*, COALESCE(SUM(goal_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)
Digest::SHA1.hexdigest("goals:family:#{family_id}").to_i(16) % (2**63)
end
aasm column: :state do
@@ -70,7 +70,7 @@ class SavingsGoal < ApplicationRecord
@current_balance ||= if attributes.key?("current_balance_total")
attributes["current_balance_total"] || 0
else
savings_contributions.sum(:amount)
goal_contributions.sum(:amount)
end
end
@@ -137,9 +137,9 @@ class SavingsGoal < ApplicationRecord
# Cumulative contributions series for the projection chart, sorted by
# date ascending. Consumed by the
# `savings-goal-projection-chart` Stimulus controller.
# `goal-projection-chart` Stimulus controller.
def projection_payload
sorted = savings_contributions.sort_by(&:contributed_at)
sorted = goal_contributions.sort_by(&:contributed_at)
running = 0
saved_series = sorted.map do |c|
running += c.amount.to_d
@@ -198,13 +198,13 @@ class SavingsGoal < ApplicationRecord
def average_monthly_contribution
return @average_monthly_contribution if defined?(@average_monthly_contribution)
@average_monthly_contribution = if savings_contributions.empty?
@average_monthly_contribution = if goal_contributions.empty?
0
else
first_at = if savings_contributions.loaded?
savings_contributions.map(&:contributed_at).compact.min
first_at = if goal_contributions.loaded?
goal_contributions.map(&:contributed_at).compact.min
else
savings_contributions.minimum(:contributed_at)
goal_contributions.minimum(:contributed_at)
end
if first_at.blank?
current_balance
@@ -217,10 +217,10 @@ class SavingsGoal < ApplicationRecord
end
def last_contribution_at
@last_contribution_at ||= if savings_contributions.loaded?
savings_contributions.map(&:contributed_at).compact.max
@last_contribution_at ||= if goal_contributions.loaded?
goal_contributions.map(&:contributed_at).compact.max
else
savings_contributions.maximum(:contributed_at)
goal_contributions.maximum(:contributed_at)
end
end
@@ -233,13 +233,13 @@ class SavingsGoal < ApplicationRecord
private
def must_have_at_least_one_linked_account
return unless savings_goal_accounts.reject(&:marked_for_destruction?).empty?
return unless 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|
offending = goal_accounts.reject(&:marked_for_destruction?).reject do |sga|
sga.account&.depository?
end
return if offending.empty?
@@ -250,7 +250,7 @@ class SavingsGoal < ApplicationRecord
def linked_accounts_must_match_goal_currency
return if currency.blank?
mismatched = savings_goal_accounts.reject(&:marked_for_destruction?).reject do |sga|
mismatched = goal_accounts.reject(&:marked_for_destruction?).reject do |sga|
sga.account.nil? || sga.account.currency == currency
end
return if mismatched.empty?
@@ -261,7 +261,7 @@ class SavingsGoal < ApplicationRecord
def linked_accounts_must_belong_to_family
return if family.nil?
foreign = savings_goal_accounts.reject(&:marked_for_destruction?).reject do |sga|
foreign = goal_accounts.reject(&:marked_for_destruction?).reject do |sga|
sga.account.nil? || sga.account.family_id == family_id
end
return if foreign.empty?
@@ -273,7 +273,7 @@ class SavingsGoal < ApplicationRecord
# in the old currency. Lock it.
def currency_locked_once_contributions_exist
return unless persisted? && currency_changed?
return unless savings_contributions.exists?
return unless goal_contributions.exists?
errors.add(:currency, :locked_after_contributions)
end

View File

@@ -0,0 +1,6 @@
class GoalAccount < ApplicationRecord
belongs_to :goal
belongs_to :account
validates :account_id, uniqueness: { scope: :goal_id }
end

View File

@@ -1,9 +1,9 @@
class SavingsContribution < ApplicationRecord
class GoalContribution < ApplicationRecord
include Monetizable
SOURCES = %w[manual initial].freeze
belongs_to :savings_goal
belongs_to :goal
belongs_to :account
validates :amount, presence: true, numericality: { greater_than: 0 }
@@ -30,26 +30,26 @@ class SavingsContribution < ApplicationRecord
private
def sync_currency_from_goal
self.currency = savings_goal.currency if savings_goal && currency.blank?
self.currency = goal.currency if goal && currency.blank?
end
def currency_matches_goal
return if savings_goal.nil? || currency.blank?
return if currency == savings_goal.currency
return if goal.nil? || currency.blank?
return if currency == 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
return if goal.nil? || account.nil?
return if account.family_id == 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?
return if goal.nil? || account.nil?
return if goal.goal_accounts.where(account_id: account_id).exists?
errors.add(:account, :must_be_linked_to_goal)
end

View File

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

View File

@@ -6,7 +6,7 @@
<% end %>
<%= styled_form_with model: @contribution,
url: savings_goal_contributions_path(@savings_goal),
url: goal_contributions_path(@goal),
class: "space-y-3" do |f| %>
<%= f.money_field :amount,
label: t(".amount"),
@@ -14,7 +14,7 @@
autofocus: true %>
<%= f.select :account_id,
options_from_collection_for_select(@savings_goal.linked_accounts, :id, :name, @contribution.account_id),
options_from_collection_for_select(@goal.linked_accounts, :id, :name, @contribution.account_id),
{ label: t(".source_account"), include_blank: t(".select_account") } %>
<%= f.date_field :contributed_at,

View File

@@ -2,22 +2,22 @@
<% if contributions.empty? %>
<div class="px-5 py-8 text-center">
<p class="text-sm text-secondary"><%= t("savings_goals.show.no_contributions_yet") %></p>
<p class="text-sm text-secondary"><%= t("goals.show.no_contributions_yet") %></p>
</div>
<% else %>
<ul>
<% contributions.each do |contribution| %>
<li class="flex items-center gap-3 px-2 py-2 rounded-lg">
<%= render Savings::GoalAvatarComponent.new(
<%= render Goals::AvatarComponent.new(
name: contribution.account.name,
color: Savings::GoalAvatarComponent.color_for(contribution.account.name),
color: Goals::AvatarComponent.color_for(contribution.account.name),
size: "sm"
) %>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-primary truncate"><%= contribution.account.name %></p>
<p class="text-[11px] text-subdued">
<%= I18n.l(contribution.contributed_at, format: :long) %> ·
<%= t("savings_goals.show.source.#{contribution.source}") %>
<%= t("goals.show.source.#{contribution.source}") %>
</p>
</div>
<span class="text-sm font-medium text-success tabular-nums">+<%= contribution.amount_money.format %></span>
@@ -25,16 +25,16 @@
<%= render DS::Menu.new do |menu| %>
<% menu.with_item(
variant: "button",
text: t("savings_goals.show.delete_contribution"),
text: t("goals.show.delete_contribution"),
icon: "trash-2",
destructive: true,
href: savings_goal_contribution_path(@savings_goal, contribution),
href: goal_contribution_path(@goal, contribution),
method: :delete,
confirm: CustomConfirm.new(
destructive: true,
title: t("savings_goals.show.confirm_delete_contribution_title"),
body: t("savings_goals.show.confirm_delete_contribution_body", amount: contribution.amount_money.format),
btn_text: t("savings_goals.show.confirm_delete_contribution_cta")
title: t("goals.show.confirm_delete_contribution_title"),
body: t("goals.show.confirm_delete_contribution_body", amount: contribution.amount_money.format),
btn_text: t("goals.show.confirm_delete_contribution_cta")
)
) %>
<% end %>

View File

@@ -5,23 +5,23 @@
<div class="w-24 h-24 rounded-full bg-surface-inset flex items-center justify-center mb-5 text-secondary">
<%= icon("target", size: "2xl") %>
</div>
<h2 class="text-lg font-medium text-primary mb-2"><%= t("savings_goals.empty_state.heading") %></h2>
<p class="text-sm text-secondary leading-relaxed mb-5"><%= t("savings_goals.empty_state.body") %></p>
<h2 class="text-lg font-medium text-primary mb-2"><%= t("goals.empty_state.heading") %></h2>
<p class="text-sm text-secondary leading-relaxed mb-5"><%= t("goals.empty_state.body") %></p>
<% if linkable_account_count > 0 %>
<%= render DS::Link.new(
text: t("savings_goals.empty_state.new_goal"),
text: t("goals.empty_state.new_goal"),
variant: "primary",
href: new_savings_goal_path,
href: new_goal_path,
icon: "plus",
frame: :modal
) %>
<% else %>
<p class="text-sm text-secondary mb-3"><%= t("savings_goals.empty_state.no_depository_accounts") %></p>
<p class="text-sm text-secondary mb-3"><%= t("goals.empty_state.no_depository_accounts") %></p>
<%= render DS::Link.new(
text: t("savings_goals.empty_state.add_account"),
text: t("goals.empty_state.add_account"),
variant: "primary",
href: new_account_path(return_to: savings_goals_path),
href: new_account_path(return_to: goals_path),
icon: "plus"
) %>
<% end %>

View File

@@ -1,29 +1,29 @@
<%# locals: (savings_goal:) %>
<%# locals: (goal:) %>
<% if savings_goal.errors.any? %>
<%= render "shared/form_errors", model: savings_goal %>
<% if goal.errors.any? %>
<%= render "shared/form_errors", model: goal %>
<% end %>
<%= styled_form_with model: savings_goal,
url: savings_goal_path(savings_goal),
<%= styled_form_with model: goal,
url: goal_path(goal),
method: :patch,
class: "space-y-3" do |f| %>
<%= f.text_field :name,
label: t("savings_goals.form_stepper.step1.fields.name"),
label: t("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"),
label: t("goals.form_stepper.step1.fields.target_amount"),
required: true %>
<%= f.date_field :target_date,
label: t("savings_goals.form_stepper.step1.fields.target_date") %>
label: t("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>
<span class="block text-sm text-secondary mb-2"><%= t("goals.form_stepper.step1.fields.color") %></span>
<div class="flex flex-wrap gap-2">
<% SavingsGoal::COLORS.each do |c| %>
<% Goal::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"
@@ -34,10 +34,10 @@
</div>
<%= f.text_area :notes,
label: t("savings_goals.form_stepper.step1.fields.notes"),
label: t("goals.form_stepper.step1.fields.notes"),
rows: 2 %>
<div class="flex justify-end pt-2">
<%= f.submit t("savings_goals.edit.save") %>
<%= f.submit t("goals.edit.save") %>
</div>
<% end %>

View File

@@ -0,0 +1,188 @@
<%# locals: (goal:, linkable_accounts:) %>
<div data-controller="goal-stepper">
<% if goal.errors[:base].any? %>
<%= render "shared/form_errors", model: goal %>
<% end %>
<%# Connected stepper %>
<div class="flex items-center gap-3 mb-6">
<div class="flex items-center gap-2" data-goal-stepper-target="step1Indicator">
<span data-goal-stepper-target="step1Circle" class="w-7 h-7 rounded-full inline-flex items-center justify-center bg-inverse text-inverse text-xs font-medium">1</span>
<span class="text-sm font-medium text-primary"><%= t("goals.form_stepper.step1.label") %></span>
</div>
<div class="flex-1 border-t-2 border-secondary" data-goal-stepper-target="stepperLine"></div>
<div class="flex items-center gap-2" data-goal-stepper-target="step2Indicator">
<span data-goal-stepper-target="step2Circle" class="w-7 h-7 rounded-full inline-flex items-center justify-center border border-secondary text-secondary text-xs font-medium">2</span>
<span class="text-sm font-medium text-secondary"><%= t("goals.form_stepper.step2.label") %></span>
</div>
</div>
<%= styled_form_with model: goal, url: goals_path, class: "space-y-4", data: { action: "keydown.enter->goal-stepper#blockEnter" } do |f| %>
<section data-goal-stepper-target="step1Panel" class="space-y-5">
<div>
<h3 class="text-lg font-semibold text-primary mb-1"><%= t("goals.form_stepper.step1.heading") %></h3>
<p class="text-sm text-secondary"><%= t("goals.form_stepper.step1.subheading") %></p>
</div>
<div>
<div class="flex items-center gap-2">
<span class="shrink-0" data-goal-stepper-target="avatarPreview">
<%= render Goals::AvatarComponent.new(name: goal.name, color: goal.color, size: "md") %>
</span>
<%= f.text_field :name,
placeholder: t("goals.form_stepper.step1.fields.name_placeholder"),
autofocus: true,
label: t("goals.form_stepper.step1.fields.name"),
container_class: "flex-1",
data: { goal_stepper_target: "nameInput", action: "input->goal-stepper#nameChanged" } %>
</div>
<p class="hidden mt-1.5 text-xs text-destructive" data-goal-stepper-target="nameError"><%= t("goals.form_stepper.errors.name_required") %></p>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<%= f.money_field :target_amount,
label: t("goals.form_stepper.step1.fields.target_amount"),
hide_currency: true,
amount_data: { goal_stepper_target: "amountInput", action: "input->goal-stepper#amountChanged" } %>
<p class="hidden mt-1.5 text-xs text-destructive" data-goal-stepper-target="amountError"><%= t("goals.form_stepper.errors.amount_required") %></p>
</div>
<%= f.date_field :target_date,
label: t("goals.form_stepper.step1.fields.target_date") %>
</div>
<div>
<div class="mb-2">
<span class="block text-sm font-medium text-primary"><%= t("goals.form_stepper.step1.fields.funding_accounts") %></span>
<p class="text-xs text-secondary mt-0.5"><%= t("goals.form_stepper.step1.fields.funding_accounts_hint") %></p>
</div>
<div class="bg-container-inset rounded-lg p-1">
<% grouped = linkable_accounts.group_by { |a| a.subtype.to_s.presence || "other" } %>
<% grouped.each_with_index do |(subtype, accts), group_idx| %>
<div class="px-3 py-2 text-[11px] font-medium uppercase tracking-wide text-secondary"><%= t("goals.form_stepper.step1.subtypes.#{subtype}", default: subtype.titleize) %></div>
<div class="bg-container rounded-md <%= "mb-1" if group_idx < grouped.size - 1 %>">
<% accts.each_with_index do |account, idx| %>
<label class="flex items-center gap-3 px-3 py-2.5 cursor-pointer hover:bg-surface-hover <%= "border-t border-subdued" if idx > 0 %>">
<%= check_box_tag "goal[account_ids][]",
account.id,
false,
id: "goal_account_ids_#{account.id}",
class: "checkbox checkbox--light shrink-0",
data: {
goal_stepper_target: "linkedAccountCheckbox",
action: "change->goal-stepper#linkedAccountChanged",
account_name: account.name,
account_subtype: account.subtype || subtype,
account_balance: account.balance
} %>
<%= render Goals::AvatarComponent.new(name: account.name, color: Goals::AvatarComponent.color_for(account.name), size: "md") %>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-primary truncate"><%= account.name %></p>
<p class="text-xs text-secondary"><%= (account.subtype || subtype).titleize %></p>
</div>
<span class="text-sm text-primary tabular-nums"><%= Money.new(account.balance, account.currency).format %></span>
</label>
<% end %>
</div>
<% end %>
</div>
<p class="hidden mt-1.5 text-xs text-destructive" data-goal-stepper-target="accountsError"><%= t("goals.form_stepper.errors.accounts_required") %></p>
</div>
<%= render DS::Disclosure.new(title: t("goals.form_stepper.step1.fields.notes_summary"), align: "right") do %>
<%= f.text_area :notes,
label: t("goals.form_stepper.step1.fields.notes"),
rows: 3,
placeholder: t("goals.form_stepper.step1.fields.notes_placeholder") %>
<% end %>
<%= f.hidden_field :color %>
</section>
<section data-goal-stepper-target="step2Panel" class="space-y-5 hidden">
<div>
<h3 class="text-lg font-semibold text-primary mb-1"><%= t("goals.form_stepper.step2.heading") %></h3>
<p class="text-sm text-secondary"><%= t("goals.form_stepper.step2.subheading") %></p>
</div>
<div class="border border-subdued rounded-lg p-5 space-y-4">
<div class="flex items-center gap-3">
<%= render DS::FilledIcon.new(variant: :container, icon: "target", size: "lg", rounded: false) %>
<div class="min-w-0 flex-1">
<p class="text-base font-medium text-primary truncate" data-goal-stepper-target="reviewName">—</p>
<p class="text-sm text-secondary tabular-nums" data-goal-stepper-target="reviewSummary">—</p>
</div>
</div>
<div class="border-t border-subdued pt-3 flex items-center justify-between text-sm">
<span class="text-secondary"><%= t("goals.form_stepper.step2.funding_accounts") %></span>
<span class="text-primary tabular-nums" data-goal-stepper-target="reviewAccounts">—</span>
</div>
<div class="border-t border-subdued pt-3 flex items-center justify-between text-sm">
<span class="text-secondary"><%= t("goals.form_stepper.step2.suggested_monthly") %></span>
<span class="text-primary tabular-nums" data-goal-stepper-target="reviewSuggested">—</span>
</div>
</div>
<details class="border border-subdued rounded-lg group" data-goal-stepper-target="initialContributionToggle">
<summary class="flex items-center gap-3 p-4 cursor-pointer list-none">
<%= render DS::FilledIcon.new(variant: :container, icon: "zap", size: "md", rounded: false) %>
<div class="flex-1">
<p class="text-sm font-medium text-primary"><%= t("goals.form_stepper.step2.add_initial_contribution") %></p>
<p class="text-xs text-secondary"><%= t("goals.form_stepper.step2.add_initial_contribution_sub") %></p>
</div>
<%= icon("chevron-down", size: "sm") %>
</summary>
<div class="px-4 pb-4 space-y-3">
<%= f.money_field :initial_contribution_amount,
label: t("goals.form_stepper.step2.initial_amount"),
hide_currency: true,
amount_data: { goal_stepper_target: "initialContributionAmount" } %>
<div class="form-field">
<div class="form-field__body">
<%= label_tag "goal[initial_contribution_account_id]",
t("goals.form_stepper.step2.initial_account"),
class: "form-field__label" %>
<%= select_tag "goal[initial_contribution_account_id]",
options_for_select([]),
include_blank: t("goals.form_stepper.step2.select_account"),
data: { goal_stepper_target: "initialContributionAccountSelect" },
class: "form-field__input" %>
</div>
</div>
</div>
</details>
</section>
<div class="flex items-center justify-between pt-2">
<div class="hidden" data-goal-stepper-target="footerLeftButton">
<%= render DS::Button.new(
variant: "ghost",
text: t("goals.form_stepper.back"),
icon: "arrow-left",
icon_position: :left,
data: {
action: "click->goal-stepper#footerLeft"
}
) %>
</div>
<%= render DS::Button.new(
text: t("goals.form_stepper.continue"),
variant: "primary",
icon: "arrow-right",
icon_position: :right,
data: {
goal_stepper_target: "footerRightButton",
action: "click->goal-stepper#footerRight"
}
) %>
<button type="submit"
class="sr-only"
tabindex="-1"
aria-hidden="true"
data-goal-stepper-target="submitButton"><%= t("goals.form_stepper.submit") %></button>
</div>
<% end %>
</div>

View File

@@ -1,6 +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 %>
<%= render "form_edit", goal: @goal %>
<% end %>
<% end %>

View File

@@ -68,34 +68,30 @@
</section>
<%# Goals section %>
<section data-controller="savings-goals-filter"
data-savings-goals-filter-empty-query-value="<%= t(".search.empty_with_query", query: "__QUERY__") %>"
data-savings-goals-filter-empty-filter-value="<%= t(".search.empty_with_filter") %>"
data-savings-goals-filter-empty-both-value="<%= t(".search.empty_with_both", query: "__QUERY__") %>"
data-savings-goals-filter-empty-default-value="<%= t(".search.empty") %>">
<div class="flex items-start justify-between mb-3 gap-3">
<div>
<h2 class="text-base font-semibold text-primary"><%= t(".goals_section.heading") %></h2>
<p class="text-sm text-secondary"><%= t(".goals_section.subtitle") %></p>
</div>
<% if @linkable_account_count > 0 %>
<section data-controller="goals-filter"
data-goals-filter-empty-query-value="<%= t(".search.empty_with_query", query: "__QUERY__") %>"
data-goals-filter-empty-filter-value="<%= t(".search.empty_with_filter") %>"
data-goals-filter-empty-both-value="<%= t(".search.empty_with_both", query: "__QUERY__") %>"
data-goals-filter-empty-default-value="<%= t(".search.empty") %>">
<% if @linkable_account_count > 0 %>
<div class="flex items-center justify-end mb-3">
<%= render DS::Link.new(
text: t(".new_goal"),
variant: "primary",
href: new_savings_goal_path,
href: new_goal_path,
icon: "plus",
frame: :modal
) %>
<% end %>
</div>
</div>
<% end %>
<% if @show_search %>
<div class="flex flex-wrap items-center gap-2.5 mb-4">
<div class="relative flex-1 min-w-[200px]">
<input type="search"
autocomplete="off"
data-savings-goals-filter-target="input"
data-action="input->savings-goals-filter#filter"
data-goals-filter-target="input"
data-action="input->goals-filter#filter"
aria-label="<%= t(".search.aria_label") %>"
placeholder="<%= t(".search.placeholder") %>"
class="block w-full border border-secondary rounded-md py-2.5 pl-10 pr-3 bg-container focus:ring-gray-500 sm:text-sm">
@@ -107,8 +103,8 @@
<% %w[all on_track behind no_target_date paused].each do |status| %>
<% active = status == "all" %>
<button type="button"
data-savings-goals-filter-target="chip"
data-action="click->savings-goals-filter#selectChip"
data-goals-filter-target="chip"
data-action="click->goals-filter#selectChip"
data-status="<%= status %>"
aria-pressed="<%= active %>"
class="px-2.5 py-1 text-xs font-medium rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-alpha-black-100 <%= active ? "bg-container shadow-border-xs text-primary" : "text-secondary" %>">
@@ -123,26 +119,26 @@
<div class="flex items-center gap-1.5 mb-4 text-[11px] font-medium uppercase tracking-wide text-secondary">
<span><%= t(".ongoing_section.heading") %></span>
<span class="text-subdued">·</span>
<span class="tabular-nums" data-savings-goals-filter-target="count"><%= @active_goals.size %></span>
<span class="tabular-nums" data-goals-filter-target="count"><%= @active_goals.size %></span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3.5" data-savings-goals-filter-target="grid">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3.5" data-goals-filter-target="grid">
<% @active_goals.each do |goal| %>
<%= render Savings::GoalCardComponent.new(goal: goal) %>
<%= render Goals::CardComponent.new(goal: goal) %>
<% end %>
</div>
<div class="hidden bg-container rounded-xl shadow-border-xs py-10 text-center" data-savings-goals-filter-target="empty">
<p class="text-sm text-secondary" data-savings-goals-filter-target="emptyCopy"><%= t(".search.empty") %></p>
<div class="hidden bg-container rounded-xl shadow-border-xs py-10 text-center" data-goals-filter-target="empty">
<p class="text-sm text-secondary" data-goals-filter-target="emptyCopy"><%= t(".search.empty") %></p>
<div class="mt-3 flex items-center justify-center gap-2">
<button type="button"
class="hidden text-xs font-medium text-secondary underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-alpha-black-100"
data-savings-goals-filter-target="emptyClearSearch"
data-action="click->savings-goals-filter#clearSearch">
data-goals-filter-target="emptyClearSearch"
data-action="click->goals-filter#clearSearch">
<%= t(".search.clear_search") %>
</button>
<button type="button"
class="hidden text-xs font-medium text-secondary underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-alpha-black-100"
data-savings-goals-filter-target="emptyClearFilter"
data-action="click->savings-goals-filter#clearFilter">
data-goals-filter-target="emptyClearFilter"
data-action="click->goals-filter#clearFilter">
<%= t(".search.show_all") %>
</button>
</div>
@@ -163,7 +159,7 @@
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3.5">
<% @completed_goals.each do |goal| %>
<%= render Savings::GoalCardComponent.new(goal: goal) %>
<%= render Goals::CardComponent.new(goal: goal) %>
<% end %>
</div>
</section>
@@ -180,7 +176,7 @@
</summary>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3.5">
<% @archived_goals.each do |goal| %>
<%= render Savings::GoalCardComponent.new(goal: goal) %>
<%= render Goals::CardComponent.new(goal: goal) %>
<% end %>
</div>
</details>

View File

@@ -5,7 +5,7 @@
<%= render DS::FilledIcon.new(variant: :container, icon: "target", size: "md", rounded: true) %>
<div>
<h2 class="text-base font-medium text-primary"><%= t(".heading") %></h2>
<p class="text-sm text-secondary mt-0.5" data-savings-goal-stepper-modal-subtitle>
<p class="text-sm text-secondary mt-0.5" data-goal-stepper-modal-subtitle>
<%= t(".step1_subtitle") %>
</p>
</div>
@@ -14,6 +14,6 @@
</div>
<% end %>
<% dialog.with_body do %>
<%= render "form_stepper", savings_goal: @savings_goal, linkable_accounts: @linkable_accounts %>
<%= render "form_stepper", goal: @goal, linkable_accounts: @linkable_accounts %>
<% end %>
<% end %>

View File

@@ -1,33 +1,33 @@
<div class="space-y-4 pb-6 lg:pb-12">
<header class="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4">
<div class="flex items-start gap-3 min-w-0 flex-1">
<%= render Savings::GoalAvatarComponent.new(goal: @savings_goal, size: "xl") %>
<%= render Goals::AvatarComponent.new(goal: @goal, size: "xl") %>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 mb-1 flex-wrap">
<h1 class="text-2xl font-semibold text-primary min-w-0 break-words"><%= @savings_goal.name %></h1>
<%= render Savings::StatusPillComponent.new(goal: @savings_goal) %>
<h1 class="text-2xl font-semibold text-primary min-w-0 break-words"><%= @goal.name %></h1>
<%= render Goals::StatusPillComponent.new(goal: @goal) %>
</div>
<p class="text-sm text-secondary">
<%
primary_parts = []
if @savings_goal.target_date
primary_parts << t(".header.target_by", amount: @savings_goal.target_amount_money.format, date: I18n.l(@savings_goal.target_date, format: :long))
unless @savings_goal.completed? || @savings_goal.status == :reached
days = (@savings_goal.target_date - Date.current).to_i
if @goal.target_date
primary_parts << t(".header.target_by", amount: @goal.target_amount_money.format, date: I18n.l(@goal.target_date, format: :long))
unless @goal.completed? || @goal.status == :reached
days = (@goal.target_date - Date.current).to_i
if days > 0
primary_parts << t("savings_goals.goal_card.days_left", count: days, date: I18n.l(@savings_goal.target_date, format: :long)).split(" · ").first
primary_parts << t("goals.goal_card.days_left", count: days, date: I18n.l(@goal.target_date, format: :long)).split(" · ").first
end
end
else
primary_parts << t(".header.target", amount: @savings_goal.target_amount_money.format)
primary_parts << t(".header.target", amount: @goal.target_amount_money.format)
end
%>
<%= primary_parts.join(" · ") %>
</p>
<% last_days = @savings_goal.last_contribution_days_ago %>
<% last_days = @goal.last_contribution_days_ago %>
<% unless last_days.nil? %>
<p class="text-xs text-subdued mt-0.5">
<%= last_days.zero? ? t("savings_goals.goal_card.footer_last_today") : t("savings_goals.goal_card.footer_last_days", count: last_days) %>
<%= last_days.zero? ? t("goals.goal_card.footer_last_today") : t("goals.goal_card.footer_last_days", count: last_days) %>
</p>
<% end %>
</div>
@@ -36,73 +36,73 @@
<%= render DS::Link.new(
text: t(".edit"),
variant: "outline",
href: edit_savings_goal_path(@savings_goal),
href: edit_goal_path(@goal),
icon: "pencil",
frame: :modal
) %>
<% unless @savings_goal.completed? || @savings_goal.status == :reached %>
<% unless @goal.completed? || @goal.status == :reached %>
<%= render DS::Link.new(
text: t(".add_contribution"),
variant: "primary",
href: new_savings_goal_contribution_path(@savings_goal),
href: new_goal_contribution_path(@goal),
icon: "plus",
frame: :modal
) %>
<% end %>
<%= render DS::Menu.new do |menu| %>
<% if @savings_goal.may_pause? %>
<% menu.with_item(variant: "button", text: t(".pause"), icon: "pause", href: pause_savings_goal_path(@savings_goal), method: :patch) %>
<% if @goal.may_pause? %>
<% menu.with_item(variant: "button", text: t(".pause"), icon: "pause", href: pause_goal_path(@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) %>
<% if @goal.may_resume? %>
<% menu.with_item(variant: "button", text: t(".resume"), icon: "play", href: resume_goal_path(@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) %>
<% if @goal.may_complete? %>
<% menu.with_item(variant: "button", text: t(".complete"), icon: "circle-check-big", href: complete_goal_path(@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) %>
<% if @goal.may_archive? %>
<% menu.with_item(variant: "button", text: t(".archive"), icon: "archive", href: archive_goal_path(@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) %>
<% if @goal.may_unarchive? %>
<% menu.with_item(variant: "button", text: t(".unarchive"), icon: "archive-restore", href: unarchive_goal_path(@goal), method: :patch) %>
<% end %>
<% if @savings_goal.archived? %>
<% if @goal.archived? %>
<% menu.with_item(
variant: "button",
text: t(".delete"),
icon: "trash-2",
href: savings_goal_path(@savings_goal),
href: goal_path(@goal),
method: :delete,
destructive: true,
confirm: CustomConfirm.for_resource_deletion(@savings_goal.name, high_severity: true)
confirm: CustomConfirm.for_resource_deletion(@goal.name, high_severity: true)
) %>
<% end %>
<% end %>
</div>
</header>
<% if @savings_goal.paused? %>
<% if @goal.paused? %>
<%# Paused banner %>
<%= render DS::Alert.new(variant: "info", title: t("savings_goals.show.paused_banner.title")) do %>
<p class="text-secondary"><%= t("savings_goals.show.paused_banner.body") %></p>
<%= render DS::Alert.new(variant: "info", title: t("goals.show.paused_banner.title")) do %>
<p class="text-secondary"><%= t("goals.show.paused_banner.body") %></p>
<div class="mt-2">
<%= render DS::Button.new(
text: t("savings_goals.show.paused_banner.resume_cta"),
href: resume_savings_goal_path(@savings_goal),
text: t("goals.show.paused_banner.resume_cta"),
href: resume_goal_path(@goal),
variant: "primary",
size: "sm",
method: :patch
) %>
</div>
<% end %>
<% elsif @savings_goal.archived? %>
<% elsif @goal.archived? %>
<%# Archived banner %>
<%= render DS::Alert.new(variant: "info", title: t("savings_goals.show.archived_banner.title")) do %>
<p class="text-secondary"><%= t("savings_goals.show.archived_banner.body") %></p>
<% if @savings_goal.may_unarchive? %>
<%= render DS::Alert.new(variant: "info", title: t("goals.show.archived_banner.title")) do %>
<p class="text-secondary"><%= t("goals.show.archived_banner.body") %></p>
<% if @goal.may_unarchive? %>
<div class="mt-2">
<%= render DS::Button.new(
text: t("savings_goals.show.archived_banner.restore_cta"),
href: unarchive_savings_goal_path(@savings_goal),
text: t("goals.show.archived_banner.restore_cta"),
href: unarchive_goal_path(@goal),
variant: "primary",
size: "sm",
method: :patch
@@ -110,23 +110,23 @@
</div>
<% end %>
<% end %>
<% elsif @savings_goal.status == :behind && @savings_goal.monthly_target_amount %>
<% elsif @goal.status == :behind && @goal.monthly_target_amount %>
<%# Catch-up callout %>
<% catch_up_money = Money.new(@savings_goal.monthly_target_amount, @savings_goal.currency) %>
<%= render DS::Alert.new(variant: "warning", title: t("savings_goals.show.catch_up.title", amount: catch_up_money.format)) do %>
<% catch_up_money = Money.new(@goal.monthly_target_amount, @goal.currency) %>
<%= render DS::Alert.new(variant: "warning", title: t("goals.show.catch_up.title", amount: catch_up_money.format)) do %>
<p class="text-secondary">
<% if @savings_goal.target_date %>
<%= t("savings_goals.show.catch_up.body_with_date", date: I18n.l(@savings_goal.target_date, format: :long)) %>
<% if @goal.target_date %>
<%= t("goals.show.catch_up.body_with_date", date: I18n.l(@goal.target_date, format: :long)) %>
<% else %>
<%= t("savings_goals.show.catch_up.body") %>
<%= t("goals.show.catch_up.body") %>
<% end %>
</p>
<div class="mt-2">
<%= render DS::Link.new(
text: t("savings_goals.show.catch_up.cta", amount: catch_up_money.format),
text: t("goals.show.catch_up.cta", amount: catch_up_money.format),
variant: "primary",
size: "sm",
href: new_savings_goal_contribution_path(@savings_goal),
href: new_goal_contribution_path(@goal),
icon: "plus",
frame: :modal
) %>
@@ -137,42 +137,42 @@
<%# Top row: ring card + projection chart card %>
<section class="grid grid-cols-1 lg:grid-cols-[320px_minmax(0,1fr)] gap-3">
<div class="bg-container rounded-xl shadow-border-xs p-5 flex flex-col items-center justify-center text-center">
<%= render Savings::ProgressRingComponent.new(goal: @savings_goal, size: 180) %>
<p class="text-xl font-medium text-primary tabular-nums privacy-sensitive mt-4"><%= @savings_goal.current_balance_money.format %></p>
<%= render Goals::ProgressRingComponent.new(goal: @goal, size: 180) %>
<p class="text-xl font-medium text-primary tabular-nums privacy-sensitive mt-4"><%= @goal.current_balance_money.format %></p>
<p class="text-xs text-subdued tabular-nums mt-0.5">
<%= t(".ring.of", target: @savings_goal.target_amount_money.format) %>
<% unless @savings_goal.completed? %>
· <%= t(".ring.to_go", amount: @savings_goal.remaining_amount_money.format) %>
<%= t(".ring.of", target: @goal.target_amount_money.format) %>
<% unless @goal.completed? %>
· <%= t(".ring.to_go", amount: @goal.remaining_amount_money.format) %>
<% end %>
</p>
</div>
<% if @savings_goal.archived? || @savings_goal.paused? %>
<% if @goal.archived? || @goal.paused? %>
<%# Paused / archived: pace + projection are misleading. Show a static recap card. %>
<div class="bg-container rounded-xl shadow-border-xs p-5 flex flex-col items-center justify-center text-center">
<div class="w-16 h-16 rounded-full bg-surface-inset inline-flex items-center justify-center text-secondary mb-3">
<%= icon(@savings_goal.archived? ? "archive" : "pause", size: "2xl") %>
<%= icon(@goal.archived? ? "archive" : "pause", size: "2xl") %>
</div>
<h2 class="text-lg font-semibold text-primary">
<%= t(@savings_goal.archived? ? ".inactive.heading_archived" : ".inactive.heading_paused") %>
<%= t(@goal.archived? ? ".inactive.heading_archived" : ".inactive.heading_paused") %>
</h2>
<p class="text-sm text-secondary mt-1 max-w-md tabular-nums">
<%= t(".inactive.body", saved: @savings_goal.current_balance_money.format, target: @savings_goal.target_amount_money.format) %>
<%= t(".inactive.body", saved: @goal.current_balance_money.format, target: @goal.target_amount_money.format) %>
</p>
</div>
<% elsif @savings_goal.completed? || @savings_goal.status == :reached %>
<% elsif @goal.completed? || @goal.status == :reached %>
<%# Reached celebration card %>
<div class="bg-container rounded-xl shadow-border-xs p-5 flex flex-col items-center justify-center text-center">
<div class="w-16 h-16 rounded-full bg-green-500/10 inline-flex items-center justify-center text-success mb-3">
<%= icon("party-popper", size: "2xl", color: "success") %>
</div>
<h2 class="text-lg font-semibold text-primary"><%= t(".celebration.heading") %></h2>
<p class="text-sm text-secondary mt-1 max-w-md"><%= t(".celebration.body", amount: @savings_goal.target_amount_money.format) %></p>
<% if @savings_goal.may_archive? %>
<p class="text-sm text-secondary mt-1 max-w-md"><%= t(".celebration.body", amount: @goal.target_amount_money.format) %></p>
<% if @goal.may_archive? %>
<div class="mt-4">
<%= render DS::Button.new(
text: t(".celebration.archive_cta"),
href: archive_savings_goal_path(@savings_goal),
href: archive_goal_path(@goal),
variant: "outline",
size: "sm",
method: :patch
@@ -180,7 +180,7 @@
</div>
<% end %>
</div>
<% elsif @savings_goal.target_date.nil? %>
<% elsif @goal.target_date.nil? %>
<%# No-target-date prompt %>
<div class="bg-container rounded-xl shadow-border-xs p-5 flex flex-col items-center justify-center text-center">
<div class="w-16 h-16 rounded-full bg-surface-inset inline-flex items-center justify-center text-secondary mb-3">
@@ -193,7 +193,7 @@
text: t(".no_target_date.cta"),
variant: "outline",
size: "sm",
href: edit_savings_goal_path(@savings_goal),
href: edit_goal_path(@goal),
icon: "calendar-plus",
frame: :modal
) %>
@@ -206,7 +206,7 @@
<h2 class="text-sm font-medium text-primary"><%= t(".projection.heading") %></h2>
<p class="text-xs text-secondary mt-0.5"><%= @stats[:projection_summary].html_safe %></p>
</div>
<% projection_color = @savings_goal.status == :on_track ? "var(--color-green-600)" : "var(--color-yellow-600)" %>
<% projection_color = @goal.status == :on_track ? "var(--color-green-600)" : "var(--color-yellow-600)" %>
<div class="flex items-center gap-3 text-[11px] text-secondary shrink-0">
<span class="inline-flex items-center gap-1.5">
<svg width="18" height="6" class="text-primary"><line x1="0" y1="3" x2="18" y2="3" stroke="currentColor" stroke-width="2" /></svg>
@@ -219,10 +219,10 @@
</div>
</div>
<div class="flex-1 min-h-[200px]"
data-controller="savings-goal-projection-chart"
data-savings-goal-projection-chart-data-value="<%= @savings_goal.projection_payload.to_json %>"
data-savings-goal-projection-chart-aria-label-value="<%= t("savings_goals.show.projection.aria_label", name: @savings_goal.name) %>"
data-savings-goal-projection-chart-aria-description-value="<%= strip_tags(@stats[:projection_summary]) %>"></div>
data-controller="goal-projection-chart"
data-goal-projection-chart-data-value="<%= @goal.projection_payload.to_json %>"
data-goal-projection-chart-aria-label-value="<%= t("goals.show.projection.aria_label", name: @goal.name) %>"
data-goal-projection-chart-aria-description-value="<%= strip_tags(@stats[:projection_summary]) %>"></div>
</div>
<% end %>
</section>
@@ -230,24 +230,24 @@
<%# Stat row — combo pace card + contributions count. Reached, paused,
or archived goals hide the pace combo since the comparison is moot
or misleading. %>
<% goal_reached = @savings_goal.completed? || @savings_goal.status == :reached %>
<% hide_pace = goal_reached || @savings_goal.archived? || @savings_goal.paused? %>
<% goal_reached = @goal.completed? || @goal.status == :reached %>
<% hide_pace = goal_reached || @goal.archived? || @goal.paused? %>
<section class="grid grid-cols-1 <%= hide_pace ? "" : "md:grid-cols-3" %> gap-3">
<% unless hide_pace %>
<%# Combo: Avg vs Target pace %>
<div class="md:col-span-2 bg-container rounded-xl shadow-border-xs px-5 py-4">
<p class="text-[11px] text-secondary mb-2"><%= t(".stats.monthly_pace") %></p>
<div class="flex items-baseline gap-2">
<p class="text-2xl font-medium text-primary tabular-nums"><%= Money.new(@stats[:avg_monthly], @savings_goal.currency).format %></p>
<p class="text-2xl font-medium text-primary tabular-nums"><%= Money.new(@stats[:avg_monthly], @goal.currency).format %></p>
<p class="text-sm text-subdued tabular-nums">/mo</p>
<% if @savings_goal.monthly_target_amount && @savings_goal.monthly_target_amount.to_d.positive? %>
<p class="text-sm text-subdued tabular-nums">· <%= t(".stats.target_of", amount: Money.new(@savings_goal.monthly_target_amount, @savings_goal.currency).format) %></p>
<% if @goal.monthly_target_amount && @goal.monthly_target_amount.to_d.positive? %>
<p class="text-sm text-subdued tabular-nums">· <%= t(".stats.target_of", amount: Money.new(@goal.monthly_target_amount, @goal.currency).format) %></p>
<% end %>
</div>
<% if @savings_goal.monthly_target_amount && @savings_goal.monthly_target_amount.to_d.positive? %>
<% delta = @savings_goal.monthly_target_amount.to_d - @stats[:avg_monthly].to_d %>
<% if @goal.monthly_target_amount && @goal.monthly_target_amount.to_d.positive? %>
<% delta = @goal.monthly_target_amount.to_d - @stats[:avg_monthly].to_d %>
<% if delta.positive? %>
<p class="text-xs text-subdued mt-1 tabular-nums"><%= t(".stats.behind_by", amount: Money.new(delta, @savings_goal.currency).format) %></p>
<p class="text-xs text-subdued mt-1 tabular-nums"><%= t(".stats.behind_by", amount: Money.new(delta, @goal.currency).format) %></p>
<% else %>
<p class="text-xs text-subdued mt-1 tabular-nums"><%= t(".stats.above_target_pace") %></p>
<% end %>
@@ -279,14 +279,14 @@
<div class="bg-container rounded-xl shadow-border-xs p-5">
<h2 class="text-sm font-medium text-primary mb-3"><%= t(".funding_accounts_heading") %></h2>
<%= render Savings::FundingAccountsBreakdownComponent.new(goal: @savings_goal, rows: @funding_breakdown) %>
<%= render Goals::FundingAccountsBreakdownComponent.new(goal: @goal, rows: @funding_breakdown) %>
</div>
</section>
<% if @savings_goal.notes.present? %>
<% if @goal.notes.present? %>
<section class="bg-container rounded-xl shadow-border-xs p-5">
<h2 class="text-sm font-medium text-primary mb-2"><%= t(".notes") %></h2>
<p class="text-sm text-secondary whitespace-pre-line"><%= @savings_goal.notes %></p>
<p class="text-sm text-secondary whitespace-pre-line"><%= @goal.notes %></p>
</section>
<% end %>
</div>

View File

@@ -11,7 +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.goals"), path: goals_path, icon: "piggy-bank", icon_custom: false, active: page_active?(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

@@ -1,188 +0,0 @@
<%# locals: (savings_goal:, linkable_accounts:) %>
<div data-controller="savings-goal-stepper">
<% if savings_goal.errors[:base].any? %>
<%= render "shared/form_errors", model: savings_goal %>
<% end %>
<%# Connected stepper %>
<div class="flex items-center gap-3 mb-6">
<div class="flex items-center gap-2" data-savings-goal-stepper-target="step1Indicator">
<span data-savings-goal-stepper-target="step1Circle" class="w-7 h-7 rounded-full inline-flex items-center justify-center bg-inverse text-inverse text-xs font-medium">1</span>
<span class="text-sm font-medium text-primary"><%= t("savings_goals.form_stepper.step1.label") %></span>
</div>
<div class="flex-1 border-t-2 border-secondary" data-savings-goal-stepper-target="stepperLine"></div>
<div class="flex items-center gap-2" data-savings-goal-stepper-target="step2Indicator">
<span data-savings-goal-stepper-target="step2Circle" class="w-7 h-7 rounded-full inline-flex items-center justify-center border border-secondary text-secondary text-xs font-medium">2</span>
<span class="text-sm font-medium text-secondary"><%= t("savings_goals.form_stepper.step2.label") %></span>
</div>
</div>
<%= styled_form_with model: savings_goal, url: savings_goals_path, class: "space-y-4", data: { action: "keydown.enter->savings-goal-stepper#blockEnter" } do |f| %>
<section data-savings-goal-stepper-target="step1Panel" class="space-y-5">
<div>
<h3 class="text-lg font-semibold text-primary mb-1"><%= t("savings_goals.form_stepper.step1.heading") %></h3>
<p class="text-sm text-secondary"><%= t("savings_goals.form_stepper.step1.subheading") %></p>
</div>
<div>
<div class="flex items-center gap-2">
<span class="shrink-0" data-savings-goal-stepper-target="avatarPreview">
<%= render Savings::GoalAvatarComponent.new(name: savings_goal.name, color: savings_goal.color, size: "md") %>
</span>
<%= f.text_field :name,
placeholder: t("savings_goals.form_stepper.step1.fields.name_placeholder"),
autofocus: true,
label: t("savings_goals.form_stepper.step1.fields.name"),
container_class: "flex-1",
data: { savings_goal_stepper_target: "nameInput", action: "input->savings-goal-stepper#nameChanged" } %>
</div>
<p class="hidden mt-1.5 text-xs text-destructive" data-savings-goal-stepper-target="nameError"><%= t("savings_goals.form_stepper.errors.name_required") %></p>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<%= f.money_field :target_amount,
label: t("savings_goals.form_stepper.step1.fields.target_amount"),
hide_currency: true,
amount_data: { savings_goal_stepper_target: "amountInput", action: "input->savings-goal-stepper#amountChanged" } %>
<p class="hidden mt-1.5 text-xs text-destructive" data-savings-goal-stepper-target="amountError"><%= t("savings_goals.form_stepper.errors.amount_required") %></p>
</div>
<%= f.date_field :target_date,
label: t("savings_goals.form_stepper.step1.fields.target_date") %>
</div>
<div>
<div class="mb-2">
<span class="block text-sm font-medium text-primary"><%= t("savings_goals.form_stepper.step1.fields.funding_accounts") %></span>
<p class="text-xs text-secondary mt-0.5"><%= t("savings_goals.form_stepper.step1.fields.funding_accounts_hint") %></p>
</div>
<div class="bg-container-inset rounded-lg p-1">
<% grouped = linkable_accounts.group_by { |a| a.subtype.to_s.presence || "other" } %>
<% grouped.each_with_index do |(subtype, accts), group_idx| %>
<div class="px-3 py-2 text-[11px] font-medium uppercase tracking-wide text-secondary"><%= t("savings_goals.form_stepper.step1.subtypes.#{subtype}", default: subtype.titleize) %></div>
<div class="bg-container rounded-md <%= "mb-1" if group_idx < grouped.size - 1 %>">
<% accts.each_with_index do |account, idx| %>
<label class="flex items-center gap-3 px-3 py-2.5 cursor-pointer hover:bg-surface-hover <%= "border-t border-subdued" if idx > 0 %>">
<%= check_box_tag "savings_goal[account_ids][]",
account.id,
false,
id: "savings_goal_account_ids_#{account.id}",
class: "checkbox checkbox--light shrink-0",
data: {
savings_goal_stepper_target: "linkedAccountCheckbox",
action: "change->savings-goal-stepper#linkedAccountChanged",
account_name: account.name,
account_subtype: account.subtype || subtype,
account_balance: account.balance
} %>
<%= render Savings::GoalAvatarComponent.new(name: account.name, color: Savings::GoalAvatarComponent.color_for(account.name), size: "md") %>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-primary truncate"><%= account.name %></p>
<p class="text-xs text-secondary"><%= (account.subtype || subtype).titleize %></p>
</div>
<span class="text-sm text-primary tabular-nums"><%= Money.new(account.balance, account.currency).format %></span>
</label>
<% end %>
</div>
<% end %>
</div>
<p class="hidden mt-1.5 text-xs text-destructive" data-savings-goal-stepper-target="accountsError"><%= t("savings_goals.form_stepper.errors.accounts_required") %></p>
</div>
<%= render DS::Disclosure.new(title: t("savings_goals.form_stepper.step1.fields.notes_summary"), align: "right") do %>
<%= f.text_area :notes,
label: t("savings_goals.form_stepper.step1.fields.notes"),
rows: 3,
placeholder: t("savings_goals.form_stepper.step1.fields.notes_placeholder") %>
<% end %>
<%= f.hidden_field :color %>
</section>
<section data-savings-goal-stepper-target="step2Panel" class="space-y-5 hidden">
<div>
<h3 class="text-lg font-semibold text-primary mb-1"><%= t("savings_goals.form_stepper.step2.heading") %></h3>
<p class="text-sm text-secondary"><%= t("savings_goals.form_stepper.step2.subheading") %></p>
</div>
<div class="border border-subdued rounded-lg p-5 space-y-4">
<div class="flex items-center gap-3">
<%= render DS::FilledIcon.new(variant: :container, icon: "target", size: "lg", rounded: false) %>
<div class="min-w-0 flex-1">
<p class="text-base font-medium text-primary truncate" data-savings-goal-stepper-target="reviewName">—</p>
<p class="text-sm text-secondary tabular-nums" data-savings-goal-stepper-target="reviewSummary">—</p>
</div>
</div>
<div class="border-t border-subdued pt-3 flex items-center justify-between text-sm">
<span class="text-secondary"><%= t("savings_goals.form_stepper.step2.funding_accounts") %></span>
<span class="text-primary tabular-nums" data-savings-goal-stepper-target="reviewAccounts">—</span>
</div>
<div class="border-t border-subdued pt-3 flex items-center justify-between text-sm">
<span class="text-secondary"><%= t("savings_goals.form_stepper.step2.suggested_monthly") %></span>
<span class="text-primary tabular-nums" data-savings-goal-stepper-target="reviewSuggested">—</span>
</div>
</div>
<details class="border border-subdued rounded-lg group" data-savings-goal-stepper-target="initialContributionToggle">
<summary class="flex items-center gap-3 p-4 cursor-pointer list-none">
<%= render DS::FilledIcon.new(variant: :container, icon: "zap", size: "md", rounded: false) %>
<div class="flex-1">
<p class="text-sm font-medium text-primary"><%= t("savings_goals.form_stepper.step2.add_initial_contribution") %></p>
<p class="text-xs text-secondary"><%= t("savings_goals.form_stepper.step2.add_initial_contribution_sub") %></p>
</div>
<%= icon("chevron-down", size: "sm") %>
</summary>
<div class="px-4 pb-4 space-y-3">
<%= f.money_field :initial_contribution_amount,
label: t("savings_goals.form_stepper.step2.initial_amount"),
hide_currency: true,
amount_data: { savings_goal_stepper_target: "initialContributionAmount" } %>
<div class="form-field">
<div class="form-field__body">
<%= label_tag "savings_goal[initial_contribution_account_id]",
t("savings_goals.form_stepper.step2.initial_account"),
class: "form-field__label" %>
<%= 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: "form-field__input" %>
</div>
</div>
</div>
</details>
</section>
<div class="flex items-center justify-between pt-2">
<div class="hidden" data-savings-goal-stepper-target="footerLeftButton">
<%= render DS::Button.new(
variant: "ghost",
text: t("savings_goals.form_stepper.back"),
icon: "arrow-left",
icon_position: :left,
data: {
action: "click->savings-goal-stepper#footerLeft"
}
) %>
</div>
<%= render DS::Button.new(
text: t("savings_goals.form_stepper.continue"),
variant: "primary",
icon: "arrow-right",
icon_position: :right,
data: {
savings_goal_stepper_target: "footerRightButton",
action: "click->savings-goal-stepper#footerRight"
}
) %>
<button type="submit"
class="sr-only"
tabindex="-1"
aria-hidden="true"
data-savings-goal-stepper-target="submitButton"><%= t("savings_goals.form_stepper.submit") %></button>
</div>
<% end %>
</div>

View File

@@ -2,7 +2,7 @@
en:
activerecord:
attributes:
savings_goal:
goal:
name: Name
target_amount: Target amount
currency: Currency
@@ -13,7 +13,7 @@ en:
linked_accounts: Linked accounts
errors:
models:
savings_goal:
goal:
attributes:
base:
at_least_one_linked_account_required: Pick at least one Depository account to fund this goal.

View File

@@ -2,7 +2,7 @@
en:
activerecord:
attributes:
savings_contribution:
goal_contribution:
amount: Amount
currency: Currency
contributed_at: Date
@@ -11,7 +11,7 @@ en:
account: Account
errors:
models:
savings_contribution:
goal_contribution:
attributes:
currency:
must_match_goal: Currency must match the goal's currency.

View File

@@ -1,6 +1,6 @@
---
en:
savings_contributions:
goal_contributions:
new:
heading: Add contribution
amount: Amount

View File

@@ -1,9 +1,9 @@
---
en:
savings_goals:
goals:
index:
title: Savings
subtitle: Your savings accounts and the goals you're working toward.
title: Goals
subtitle: Save toward what matters.
new_goal: New goal
empty_filtered: No goals match.
kpi:
@@ -41,7 +41,7 @@ en:
heading: Archived
search:
placeholder: Search goals…
aria_label: Search savings goals
aria_label: Search goals
empty: No goals match.
empty_with_query: "No goals match \"%{query}\"."
empty_with_filter: No goals match this filter.
@@ -55,18 +55,18 @@ en:
no_target_date: Open-ended
paused: Paused
new:
heading: New savings goal
heading: New goal
step1_subtitle: Step 1 of 2 · Goal details
step2_subtitle: Step 2 of 2 · Review & start
edit:
heading: Edit savings goal
heading: Edit goal
save: Save changes
create:
success: Savings goal created.
success: Goal created.
update:
success: Savings goal updated.
success: Goal updated.
destroy:
success: Savings goal deleted.
success: Goal deleted.
archive_first: Archive the goal before deleting it.
pause:
success: Goal paused.

View File

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

View File

@@ -252,7 +252,7 @@ Rails.application.routes.draw do
resources :budget_categories, only: %i[index show update]
end
resources :savings_goals do
resources :goals do
member do
patch :pause
patch :resume
@@ -261,7 +261,7 @@ Rails.application.routes.draw do
patch :unarchive
end
resources :contributions, only: %i[new create destroy], controller: "savings_contributions"
resources :contributions, only: %i[new create destroy], controller: "goal_contributions"
end
resources :family_merchants, only: %i[index new create edit update destroy] do

View File

@@ -0,0 +1,10 @@
class RenameSavingsToGoals < ActiveRecord::Migration[7.2]
def change
rename_table :savings_goals, :goals
rename_table :savings_contributions, :goal_contributions
rename_table :savings_goal_accounts, :goal_accounts
rename_column :goal_contributions, :savings_goal_id, :goal_id
rename_column :goal_accounts, :savings_goal_id, :goal_id
end
end

102
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_11_100002) do
ActiveRecord::Schema[7.2].define(version: 2026_05_11_100003) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -634,6 +634,51 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_11_100002) do
t.index ["merchant_id"], name: "index_family_merchant_associations_on_merchant_id"
end
create_table "goal_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "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_goal_accounts_on_account_id"
t.index ["goal_id", "account_id"], name: "index_savings_goal_accounts_on_goal_and_account", unique: true
t.index ["goal_id"], name: "index_goal_accounts_on_goal_id"
end
create_table "goal_contributions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "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_goal_contributions_on_account_id"
t.index ["goal_id", "contributed_at"], name: "index_goal_contributions_on_goal_id_and_contributed_at"
t.index ["goal_id"], name: "index_goal_contributions_on_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 "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_goals_on_family_id_and_state"
t.index ["family_id"], name: "index_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::text, 'paused'::character varying::text, 'completed'::character varying::text, '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 "holdings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "account_id", null: false
t.uuid "security_id", null: false
@@ -1224,51 +1269,6 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_11_100002) 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"
@@ -1750,6 +1750,11 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_11_100002) do
add_foreign_key "family_exports", "families"
add_foreign_key "family_merchant_associations", "families"
add_foreign_key "family_merchant_associations", "merchants"
add_foreign_key "goal_accounts", "accounts", on_delete: :cascade
add_foreign_key "goal_accounts", "goals", on_delete: :cascade
add_foreign_key "goal_contributions", "accounts", on_delete: :cascade
add_foreign_key "goal_contributions", "goals", on_delete: :cascade
add_foreign_key "goals", "families", on_delete: :cascade
add_foreign_key "holdings", "account_providers"
add_foreign_key "holdings", "accounts", on_delete: :cascade
add_foreign_key "holdings", "securities"
@@ -1786,11 +1791,6 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_11_100002) 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 GoalContributionsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in users(:family_admin)
@goal = goals(:vacation_italy)
@depository = accounts(:depository)
ensure_tailwind_build
end
test "new renders the modal form" do
get new_goal_contribution_url(@goal)
assert_response :success
end
test "create saves a manual contribution" do
assert_difference -> { @goal.goal_contributions.count } => 1 do
post goal_contributions_url(@goal), params: {
goal_contribution: {
amount: "100",
contributed_at: Date.current.iso8601,
notes: ""
},
goal_contribution_account_id: @depository.id
}.merge(goal_contribution: { account_id: @depository.id, amount: "100", contributed_at: Date.current.iso8601 })
end
assert_redirected_to goal_path(@goal)
contribution = @goal.goal_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.goal_contributions.count" do
post goal_contributions_url(@goal), params: {
goal_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 = goal_contributions(:vacation_italy_manual)
assert_difference "GoalContribution.count", -1 do
delete goal_contribution_url(@goal, manual)
end
end
test "destroy initial contribution is blocked" do
initial = goal_contributions(:vacation_italy_initial)
assert_no_difference "GoalContribution.count" do
delete goal_contribution_url(@goal, initial)
end
assert_redirected_to goal_path(@goal)
end
end

View File

@@ -1,41 +1,41 @@
require "test_helper"
class SavingsGoalsControllerTest < ActionDispatch::IntegrationTest
class GoalsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in users(:family_admin)
@goal = savings_goals(:vacation_italy)
@goal = 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
get goals_url
assert_response :success
assert_match(/Savings/i, response.body)
assert_match(/Goals/i, response.body)
end
test "index honors state filter" do
get savings_goals_url(state: "paused")
get goals_url(state: "paused")
assert_response :success
end
test "show renders the goal" do
get savings_goal_url(@goal)
get 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
get new_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: {
assert_difference -> { Goal.count } => 1,
-> { GoalAccount.count } => 2 do
post goals_url, params: {
goal: {
name: "New goal",
target_amount: "1000",
target_date: 3.months.from_now.to_date.iso8601,
@@ -45,14 +45,14 @@ class SavingsGoalsControllerTest < ActionDispatch::IntegrationTest
}
end
goal = SavingsGoal.order(created_at: :desc).first
assert_redirected_to savings_goal_path(goal)
goal = Goal.order(created_at: :desc).first
assert_redirected_to 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: {
assert_difference -> { GoalContribution.count } => 1 do
post goals_url, params: {
goal: {
name: "Goal with initial",
target_amount: "1000",
color: "#4da568",
@@ -63,15 +63,15 @@ class SavingsGoalsControllerTest < ActionDispatch::IntegrationTest
}
end
contribution = SavingsContribution.order(created_at: :desc).first
contribution = GoalContribution.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: {
assert_no_difference "Goal.count" do
post goals_url, params: {
goal: {
name: "Bad goal",
target_amount: "1000",
color: "#4da568"
@@ -85,9 +85,9 @@ class SavingsGoalsControllerTest < ActionDispatch::IntegrationTest
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: {
assert_no_difference "Goal.count" do
post goals_url, params: {
goal: {
name: "Foreign goal",
target_amount: "1000",
color: "#4da568",
@@ -99,48 +99,48 @@ class SavingsGoalsControllerTest < ActionDispatch::IntegrationTest
end
test "update modifies identity fields" do
patch savings_goal_url(@goal), params: { savings_goal: { name: "Renamed" } }
assert_redirected_to savings_goal_path(@goal)
patch goal_url(@goal), params: { goal: { name: "Renamed" } }
assert_redirected_to 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)
fresh = goals(:emergency_fund)
patch pause_goal_url(fresh)
assert fresh.reload.paused?
patch resume_savings_goal_url(fresh)
patch resume_goal_url(fresh)
assert fresh.reload.active?
patch complete_savings_goal_url(fresh)
patch complete_goal_url(fresh)
assert fresh.reload.completed?
patch archive_savings_goal_url(fresh)
patch archive_goal_url(fresh)
assert fresh.reload.archived?
patch unarchive_savings_goal_url(fresh)
patch unarchive_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)
assert_no_difference "Goal.count" do
delete goal_url(@goal)
end
assert_redirected_to savings_goal_path(@goal)
assert_redirected_to goal_path(@goal)
end
test "destroy on archived deletes" do
@goal.archive!
assert_difference "SavingsGoal.count", -1 do
delete savings_goal_url(@goal)
assert_difference "Goal.count", -1 do
delete goal_url(@goal)
end
assert_redirected_to savings_goals_path
assert_redirected_to 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 = other_family.goals.new(name: "Foreign goal", target_amount: 100, currency: "USD")
other_goal.goal_accounts.build(account: other_account)
other_goal.save!
get savings_goal_url(other_goal)
get goal_url(other_goal)
assert_response :not_found
end
end

View File

@@ -1,58 +0,0 @@
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

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

View File

@@ -1,5 +1,5 @@
vacation_italy_initial:
savings_goal: vacation_italy
goal: vacation_italy
account: depository
amount: 500
currency: USD
@@ -7,7 +7,7 @@ vacation_italy_initial:
contributed_at: <%= 90.days.ago.to_date %>
vacation_italy_manual:
savings_goal: vacation_italy
goal: vacation_italy
account: connected
amount: 250
currency: USD
@@ -15,7 +15,7 @@ vacation_italy_manual:
contributed_at: <%= 30.days.ago.to_date %>
emergency_fund_initial:
savings_goal: emergency_fund
goal: emergency_fund
account: depository
amount: 1000
currency: USD

View File

@@ -1,16 +1,16 @@
require "test_helper"
class Assistant::Function::CreateSavingsGoalTest < ActiveSupport::TestCase
class Assistant::Function::CreateGoalTest < ActiveSupport::TestCase
setup do
@user = users(:family_admin)
@family = @user.family
@depository = accounts(:depository)
@fn = Assistant::Function::CreateSavingsGoal.new(@user)
@fn = Assistant::Function::CreateGoal.new(@user)
end
test "to_definition returns valid JSON shape" do
definition = @fn.to_definition
assert_equal "create_savings_goal", definition[:name]
assert_equal "create_goal", definition[:name]
assert_kind_of String, definition[:description]
assert_equal "object", definition[:params_schema][:type]
assert_includes definition[:params_schema][:required], "name"
@@ -19,8 +19,8 @@ class Assistant::Function::CreateSavingsGoalTest < ActiveSupport::TestCase
end
test "creates a goal with linked accounts" do
assert_difference -> { SavingsGoal.count } => 1,
-> { SavingsGoalAccount.count } => 1 do
assert_difference -> { Goal.count } => 1,
-> { GoalAccount.count } => 1 do
result = @fn.call(
"name" => "Vacation",
"target_amount" => 1500,
@@ -36,7 +36,7 @@ class Assistant::Function::CreateSavingsGoalTest < ActiveSupport::TestCase
end
test "creates a goal with initial contribution" do
assert_difference -> { SavingsContribution.count } => 1 do
assert_difference -> { GoalContribution.count } => 1 do
@fn.call(
"name" => "Laptop fund",
"target_amount" => 2000,
@@ -45,7 +45,7 @@ class Assistant::Function::CreateSavingsGoalTest < ActiveSupport::TestCase
)
end
contribution = SavingsContribution.order(created_at: :desc).first
contribution = GoalContribution.order(created_at: :desc).first
assert_equal "initial", contribution.source
assert_equal 200, contribution.amount.to_i
end

View File

@@ -0,0 +1,46 @@
require "test_helper"
class GoalContributionTest < ActiveSupport::TestCase
setup do
@goal = goals(:vacation_italy)
@depository = accounts(:depository)
end
test "valid fixture contribution saves" do
assert goal_contributions(:vacation_italy_initial).valid?
end
test "amount must be positive" do
c = @goal.goal_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.goal_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.goal_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.goal_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 goal_contributions(:vacation_italy_initial).initial?
assert goal_contributions(:vacation_italy_manual).manual?
end
end

View File

@@ -1,11 +1,11 @@
require "test_helper"
class SavingsGoalTest < ActiveSupport::TestCase
class GoalTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
@depository = accounts(:depository)
@connected = accounts(:connected)
@goal = savings_goals(:vacation_italy)
@goal = goals(:vacation_italy)
end
test "valid fixture goal saves" do
@@ -24,15 +24,15 @@ class SavingsGoalTest < ActiveSupport::TestCase
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")
new_goal = @family.goals.new(name: "Test", target_amount: 100, currency: "USD")
assert_not new_goal.valid?
assert_match(/at least one/i, new_goal.errors[:base].join)
end
test "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)
new_goal = @family.goals.new(name: "Test", target_amount: 100, currency: "USD")
new_goal.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
@@ -46,8 +46,8 @@ class SavingsGoalTest < ActiveSupport::TestCase
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)
new_goal = @family.goals.new(name: "T", target_amount: 100, currency: "USD")
new_goal.goal_accounts.build(account: foreign_account)
assert_not new_goal.valid?
assert_includes new_goal.errors[:linked_accounts], "Linked accounts must belong to the same family as the goal."
end
@@ -60,27 +60,27 @@ class SavingsGoalTest < ActiveSupport::TestCase
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)
new_goal = @family.goals.new(name: "T", target_amount: 100, currency: "USD")
new_goal.goal_accounts.build(account: eur_account)
assert_not new_goal.valid?
assert_includes new_goal.errors[:linked_accounts], "All linked accounts must share the same currency."
end
test "currency can't change after contributions exist" do
assert @goal.savings_contributions.exists?
assert @goal.goal_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)
expected = @goal.goal_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)
loaded = @family.goals.with_current_balance.find(@goal.id)
expected = @goal.goal_contributions.sum(:amount)
assert_equal expected.to_f, loaded.current_balance.to_f
end
@@ -90,7 +90,7 @@ class SavingsGoalTest < ActiveSupport::TestCase
end
test "progress_percent is 0 for empty active goal" do
fresh = savings_goals(:car_paydown)
fresh = goals(:car_paydown)
fresh.target_amount = 10000
assert_equal 0, fresh.progress_percent
end
@@ -101,7 +101,7 @@ class SavingsGoalTest < ActiveSupport::TestCase
end
test "AASM transitions" do
fresh = savings_goals(:emergency_fund)
fresh = goals(:emergency_fund)
assert fresh.active?
fresh.pause!
assert fresh.paused?
@@ -144,8 +144,8 @@ class SavingsGoalTest < ActiveSupport::TestCase
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)
k1 = Goal.advisory_lock_key_for(@family.id)
k2 = Goal.advisory_lock_key_for(@family.id)
assert_equal k1, k2
assert_kind_of Integer, k1
end

View File

@@ -1,46 +0,0 @@
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