mirror of
https://github.com/we-promise/sure.git
synced 2026-05-29 23:39:03 +00:00
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:
@@ -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>
|
||||
@@ -1,4 +1,4 @@
|
||||
class Savings::AccountStackComponent < ApplicationComponent
|
||||
class Goals::AccountStackComponent < ApplicationComponent
|
||||
def initialize(accounts:, max: 3)
|
||||
@accounts = accounts
|
||||
@max = max
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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 %>
|
||||
@@ -1,4 +1,4 @@
|
||||
class Savings::FundingAccountsBreakdownComponent < ApplicationComponent
|
||||
class Goals::FundingAccountsBreakdownComponent < ApplicationComponent
|
||||
def initialize(goal:, rows:)
|
||||
@goal = goal
|
||||
@rows = rows
|
||||
@@ -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>
|
||||
@@ -1,4 +1,4 @@
|
||||
class Savings::ProgressRingComponent < ApplicationComponent
|
||||
class Goals::ProgressRingComponent < ApplicationComponent
|
||||
def initialize(goal:, size: 180)
|
||||
@goal = goal
|
||||
@size = size
|
||||
@@ -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
|
||||
58
app/controllers/goal_contributions_controller.rb
Normal file
58
app/controllers/goal_contributions_controller.rb
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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");
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ module Assistant
|
||||
Function::GetIncomeStatement,
|
||||
Function::ImportBankStatement,
|
||||
Function::SearchFamilyFiles,
|
||||
Function::CreateSavingsGoal
|
||||
Function::CreateGoal
|
||||
]
|
||||
end
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
6
app/models/goal_account.rb
Normal file
6
app/models/goal_account.rb
Normal file
@@ -0,0 +1,6 @@
|
||||
class GoalAccount < ApplicationRecord
|
||||
belongs_to :goal
|
||||
belongs_to :account
|
||||
|
||||
validates :account_id, uniqueness: { scope: :goal_id }
|
||||
end
|
||||
@@ -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
|
||||
@@ -1,6 +0,0 @@
|
||||
class SavingsGoalAccount < ApplicationRecord
|
||||
belongs_to :savings_goal
|
||||
belongs_to :account
|
||||
|
||||
validates :account_id, uniqueness: { scope: :savings_goal_id }
|
||||
end
|
||||
@@ -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,
|
||||
@@ -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 %>
|
||||
@@ -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 %>
|
||||
@@ -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 %>
|
||||
188
app/views/goals/_form_stepper.html.erb
Normal file
188
app/views/goals/_form_stepper.html.erb
Normal 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>
|
||||
@@ -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 %>
|
||||
@@ -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>
|
||||
@@ -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 %>
|
||||
@@ -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>
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
en:
|
||||
savings_contributions:
|
||||
goal_contributions:
|
||||
new:
|
||||
heading: Add contribution
|
||||
amount: Amount
|
||||
@@ -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.
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
10
db/migrate/20260511100003_rename_savings_to_goals.rb
Normal file
10
db/migrate/20260511100003_rename_savings_to_goals.rb
Normal 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
102
db/schema.rb
generated
@@ -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"
|
||||
|
||||
58
test/controllers/goal_contributions_controller_test.rb
Normal file
58
test/controllers/goal_contributions_controller_test.rb
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
46
test/models/goal_contribution_test.rb
Normal file
46
test/models/goal_contribution_test.rb
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user