mirror of
https://github.com/we-promise/sure.git
synced 2026-05-29 23:39:03 +00:00
feat(savings): add savings goals
Adds a standalone Savings goals feature: a piggy-bank style tracker that lets a family set a target, link one or more Depository accounts as funding sources, and log manual contributions over time. Supersedes #1569 (closed) — same intent, redesigned per reviewer + Discord feedback. What this adds: - New `/savings_goals` sidebar entry (piggy-bank icon) with index, show, state-filtered tabs (all/active/paused/completed/archived), and a 2-step modal stepper for creation (Identity → Review). - Multi-account funding via a `SavingsGoalAccount` join: a goal requires ≥1 linked Depository account (checking/savings/HSA/CD/money-market), and all linked accounts must share the goal's currency. - Tracker balance model: goal balance = SUM(contributions.amount). No auto-flow from account balances. Contributions are pure logical records and don't move money between accounts. - Manual contributions modal scoped to the goal's linked accounts. Initial contributions seeded at creation can't be deleted; manual ones can. - AASM lifecycle: active / paused / completed / archived. Hard-delete only after archive. - Status pills (On track / Behind / Reached / No date) derived from pace vs target_date. - AI Assistant tool `create_savings_goal` lets the sidebar chat create a goal end-to-end from a natural-language prompt; soft errors carry the available-accounts list back to the LLM (mirrors the existing `import_bank_statement` pattern). - Family-scoped throughout (`Current.family`-only access, account family-scoping enforced both in controllers and the AI tool). - Demo data seed wires up 4 sample goals across the Depository accounts. Intentionally out of scope (separate PRs / v1.1): - Auto-fund from budget surplus + Sidekiq cron + budget-show card. - Dashboard "Savings goals" widget. - "Behind pace" projection chart on the detail page. - `evaluate_savings_goal_feasibility` LLM tool (level-setting before create_savings_goal). - Spend-less goals inside Budgets. - Family-member-private goals (deferred investigation).
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
<section class="bg-container rounded-xl shadow-border-xs p-4">
|
||||
<h3 class="text-sm font-medium text-primary mb-3"><%= t("savings_goals.show.funding_accounts_heading") %></h3>
|
||||
|
||||
<% if total.zero? %>
|
||||
<p class="text-sm text-secondary"><%= t("savings_goals.show.no_contributions_yet") %></p>
|
||||
<% else %>
|
||||
<div class="space-y-3">
|
||||
<% rows.each do |row| %>
|
||||
<div class="flex items-center gap-3">
|
||||
<%= render Savings::GoalAvatarComponent.new(name: row[:account].name, color: goal.color, size: "sm") %>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center justify-between gap-2 text-sm">
|
||||
<span class="text-primary truncate"><%= row[:account].name %></span>
|
||||
<span class="text-secondary tabular-nums"><%= row[:money].format %></span>
|
||||
</div>
|
||||
<div class="mt-1 h-1.5 w-full rounded-full bg-container-inset overflow-hidden">
|
||||
<div class="h-full bg-blue-500" style="width: <%= percent_for(row[:amount]) %>%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-xs text-secondary tabular-nums w-10 text-right"><%= percent_for(row[:amount]) %>%</span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</section>
|
||||
@@ -0,0 +1,17 @@
|
||||
class Savings::FundingAccountsBreakdownComponent < ApplicationComponent
|
||||
def initialize(goal:, rows:)
|
||||
@goal = goal
|
||||
@rows = rows
|
||||
end
|
||||
|
||||
attr_reader :goal, :rows
|
||||
|
||||
def total
|
||||
@total ||= rows.sum { |r| r[:amount].to_d }
|
||||
end
|
||||
|
||||
def percent_for(amount)
|
||||
return 0 if total.zero?
|
||||
((amount.to_d / total) * 100).round
|
||||
end
|
||||
end
|
||||
5
app/components/savings/goal_avatar_component.html.erb
Normal file
5
app/components/savings/goal_avatar_component.html.erb
Normal file
@@ -0,0 +1,5 @@
|
||||
<span class="inline-flex items-center justify-center rounded-full text-inverse font-medium <%= box_classes %> <%= text_classes %>"
|
||||
style="background-color: <%= color %>;"
|
||||
data-testid="savings-goal-avatar">
|
||||
<%= initial %>
|
||||
</span>
|
||||
29
app/components/savings/goal_avatar_component.rb
Normal file
29
app/components/savings/goal_avatar_component.rb
Normal file
@@ -0,0 +1,29 @@
|
||||
class Savings::GoalAvatarComponent < ApplicationComponent
|
||||
SIZES = {
|
||||
"sm" => { box: "w-6 h-6", text: "text-xs" },
|
||||
"md" => { box: "w-8 h-8", text: "text-sm" },
|
||||
"lg" => { box: "w-12 h-12", text: "text-lg" }
|
||||
}.freeze
|
||||
|
||||
def initialize(goal: nil, name: nil, color: nil, size: "md")
|
||||
@goal = goal
|
||||
@name = name || goal&.name
|
||||
@color = color || goal&.color || SavingsGoal::COLORS.first
|
||||
@size = SIZES.key?(size) ? size : "md"
|
||||
end
|
||||
|
||||
attr_reader :color
|
||||
|
||||
def initial
|
||||
return "?" if @name.blank?
|
||||
@name.strip.first&.upcase || "?"
|
||||
end
|
||||
|
||||
def box_classes
|
||||
SIZES[@size][:box]
|
||||
end
|
||||
|
||||
def text_classes
|
||||
SIZES[@size][:text]
|
||||
end
|
||||
end
|
||||
28
app/components/savings/goal_card_component.html.erb
Normal file
28
app/components/savings/goal_card_component.html.erb
Normal file
@@ -0,0 +1,28 @@
|
||||
<%= link_to savings_goal_path(goal), class: "block bg-container rounded-xl shadow-border-xs hover:shadow-border-sm p-4 transition-shadow" do %>
|
||||
<div class="flex items-start gap-3">
|
||||
<%= render Savings::GoalAvatarComponent.new(goal: goal, size: "md") %>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<p class="text-sm font-medium text-primary truncate"><%= goal.name %></p>
|
||||
<%= render Savings::StatusPillComponent.new(goal: goal) %>
|
||||
</div>
|
||||
<p class="text-xs text-secondary truncate mt-0.5"><%= linked_accounts_label %></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="flex items-center justify-between gap-2 text-xs tabular-nums">
|
||||
<span class="text-secondary"><%= goal.current_balance_money.format %></span>
|
||||
<span class="text-secondary">/ <%= goal.target_amount_money.format %></span>
|
||||
</div>
|
||||
<div class="mt-2 h-2 w-full rounded-full bg-container-inset overflow-hidden">
|
||||
<div class="h-full <%= bar_color_class %>" style="width: <%= progress_percent %>%"></div>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center justify-between text-xs text-secondary tabular-nums">
|
||||
<span><%= progress_percent %>%</span>
|
||||
<% if goal.target_date %>
|
||||
<span><%= I18n.l(goal.target_date, format: :long) %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
30
app/components/savings/goal_card_component.rb
Normal file
30
app/components/savings/goal_card_component.rb
Normal file
@@ -0,0 +1,30 @@
|
||||
class Savings::GoalCardComponent < ApplicationComponent
|
||||
def initialize(goal:)
|
||||
@goal = goal
|
||||
end
|
||||
|
||||
attr_reader :goal
|
||||
|
||||
def linked_accounts_label
|
||||
names = goal.linked_accounts.pluck(:name)
|
||||
case names.size
|
||||
when 0 then I18n.t("savings_goals.goal_card.no_accounts")
|
||||
when 1 then names.first
|
||||
when 2 then names.join(", ")
|
||||
else
|
||||
I18n.t("savings_goals.goal_card.n_accounts", first: names.first, count: names.size - 1)
|
||||
end
|
||||
end
|
||||
|
||||
def progress_percent
|
||||
goal.progress_percent
|
||||
end
|
||||
|
||||
def bar_color_class
|
||||
case progress_percent
|
||||
when 0...25 then "bg-gray-400"
|
||||
when 25...75 then "bg-blue-500"
|
||||
else "bg-green-600"
|
||||
end
|
||||
end
|
||||
end
|
||||
27
app/components/savings/progress_ring_component.html.erb
Normal file
27
app/components/savings/progress_ring_component.html.erb
Normal file
@@ -0,0 +1,27 @@
|
||||
<div class="relative inline-flex items-center justify-center" style="width: <%= Savings::ProgressRingComponent::SIZE %>px; height: <%= Savings::ProgressRingComponent::SIZE %>px;">
|
||||
<svg width="<%= Savings::ProgressRingComponent::SIZE %>"
|
||||
height="<%= Savings::ProgressRingComponent::SIZE %>"
|
||||
viewBox="0 0 <%= Savings::ProgressRingComponent::SIZE %> <%= Savings::ProgressRingComponent::SIZE %>">
|
||||
<circle cx="<%= Savings::ProgressRingComponent::SIZE / 2.0 %>"
|
||||
cy="<%= Savings::ProgressRingComponent::SIZE / 2.0 %>"
|
||||
r="<%= Savings::ProgressRingComponent::RADIUS %>"
|
||||
fill="none"
|
||||
stroke="var(--color-gray-200)"
|
||||
stroke-width="<%= Savings::ProgressRingComponent::STROKE %>" />
|
||||
<circle cx="<%= Savings::ProgressRingComponent::SIZE / 2.0 %>"
|
||||
cy="<%= Savings::ProgressRingComponent::SIZE / 2.0 %>"
|
||||
r="<%= Savings::ProgressRingComponent::RADIUS %>"
|
||||
fill="none"
|
||||
stroke="<%= stroke_color %>"
|
||||
stroke-width="<%= Savings::ProgressRingComponent::STROKE %>"
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray="<%= Savings::ProgressRingComponent::CIRCUMFERENCE %>"
|
||||
stroke-dashoffset="<%= offset %>"
|
||||
transform="rotate(-90 <%= Savings::ProgressRingComponent::SIZE / 2.0 %> <%= Savings::ProgressRingComponent::SIZE / 2.0 %>)" />
|
||||
</svg>
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center text-center">
|
||||
<span class="text-2xl font-semibold text-primary tabular-nums"><%= percent %>%</span>
|
||||
<span class="text-xs text-secondary tabular-nums mt-1"><%= current_label %></span>
|
||||
<span class="text-xs text-secondary tabular-nums">of <%= target_label %></span>
|
||||
</div>
|
||||
</div>
|
||||
36
app/components/savings/progress_ring_component.rb
Normal file
36
app/components/savings/progress_ring_component.rb
Normal file
@@ -0,0 +1,36 @@
|
||||
class Savings::ProgressRingComponent < ApplicationComponent
|
||||
SIZE = 180
|
||||
STROKE = 14
|
||||
RADIUS = (SIZE - STROKE) / 2.0
|
||||
CIRCUMFERENCE = 2 * Math::PI * RADIUS
|
||||
|
||||
def initialize(goal:)
|
||||
@goal = goal
|
||||
end
|
||||
|
||||
attr_reader :goal
|
||||
|
||||
def percent
|
||||
[ [ goal.progress_percent.to_i, 0 ].max, 100 ].min
|
||||
end
|
||||
|
||||
def offset
|
||||
CIRCUMFERENCE * (1 - percent / 100.0)
|
||||
end
|
||||
|
||||
def stroke_color
|
||||
case percent
|
||||
when 0...25 then "var(--color-gray-400)"
|
||||
when 25...75 then "var(--color-blue-500)"
|
||||
else "var(--color-green-600)"
|
||||
end
|
||||
end
|
||||
|
||||
def current_label
|
||||
goal.current_balance_money.format
|
||||
end
|
||||
|
||||
def target_label
|
||||
goal.target_amount_money.format
|
||||
end
|
||||
end
|
||||
4
app/components/savings/status_pill_component.html.erb
Normal file
4
app/components/savings/status_pill_component.html.erb
Normal file
@@ -0,0 +1,4 @@
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium <%= classes %>">
|
||||
<%= helpers.icon(icon_name, size: "xs", color: "current") %>
|
||||
<%= label %>
|
||||
</span>
|
||||
32
app/components/savings/status_pill_component.rb
Normal file
32
app/components/savings/status_pill_component.rb
Normal file
@@ -0,0 +1,32 @@
|
||||
class Savings::StatusPillComponent < ApplicationComponent
|
||||
VARIANTS = {
|
||||
on_track: { classes: "bg-green-600/10 text-green-700", icon: "check" },
|
||||
behind: { classes: "bg-yellow-500/10 text-yellow-700", icon: "alert-triangle" },
|
||||
reached: { classes: "bg-green-600/10 text-green-700", icon: "circle-check-big" },
|
||||
no_target_date: { classes: "bg-container-inset text-secondary", icon: "calendar-off" }
|
||||
}.freeze
|
||||
|
||||
def initialize(goal:)
|
||||
@goal = goal
|
||||
end
|
||||
|
||||
def status
|
||||
@goal.status
|
||||
end
|
||||
|
||||
def variant
|
||||
VARIANTS.fetch(status, VARIANTS[:no_target_date])
|
||||
end
|
||||
|
||||
def label
|
||||
I18n.t("savings_goals.status.#{status}")
|
||||
end
|
||||
|
||||
def icon_name
|
||||
variant[:icon]
|
||||
end
|
||||
|
||||
def classes
|
||||
variant[:classes]
|
||||
end
|
||||
end
|
||||
58
app/controllers/savings_contributions_controller.rb
Normal file
58
app/controllers/savings_contributions_controller.rb
Normal file
@@ -0,0 +1,58 @@
|
||||
class SavingsContributionsController < ApplicationController
|
||||
before_action :set_savings_goal
|
||||
before_action :set_contribution, only: :destroy
|
||||
|
||||
def new
|
||||
@contribution = @savings_goal.savings_contributions.new(
|
||||
contributed_at: Date.current,
|
||||
currency: @savings_goal.currency,
|
||||
source: "manual"
|
||||
)
|
||||
end
|
||||
|
||||
def create
|
||||
@contribution = @savings_goal.savings_contributions.new(contribution_params.merge(source: "manual"))
|
||||
@contribution.account = lookup_account(params.dig(:savings_contribution, :account_id))
|
||||
@contribution.currency = @savings_goal.currency
|
||||
|
||||
if @contribution.save
|
||||
flash[:notice] = t(".success")
|
||||
respond_to do |format|
|
||||
format.html { redirect_to savings_goal_path(@savings_goal) }
|
||||
format.turbo_stream do
|
||||
render turbo_stream: turbo_stream.action(:redirect, savings_goal_path(@savings_goal))
|
||||
end
|
||||
end
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
if @contribution.initial?
|
||||
redirect_to savings_goal_path(@savings_goal), alert: t(".initial_not_deletable")
|
||||
return
|
||||
end
|
||||
|
||||
@contribution.destroy!
|
||||
redirect_to savings_goal_path(@savings_goal), notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
def set_savings_goal
|
||||
@savings_goal = Current.family.savings_goals.find(params[:savings_goal_id])
|
||||
end
|
||||
|
||||
def set_contribution
|
||||
@contribution = @savings_goal.savings_contributions.find(params[:id])
|
||||
end
|
||||
|
||||
def contribution_params
|
||||
params.require(:savings_contribution).permit(:amount, :contributed_at, :notes)
|
||||
end
|
||||
|
||||
def lookup_account(id)
|
||||
return nil if id.blank?
|
||||
@savings_goal.linked_accounts.find_by(id: id)
|
||||
end
|
||||
end
|
||||
167
app/controllers/savings_goals_controller.rb
Normal file
167
app/controllers/savings_goals_controller.rb
Normal file
@@ -0,0 +1,167 @@
|
||||
class SavingsGoalsController < ApplicationController
|
||||
before_action :set_savings_goal, only: %i[show edit update destroy pause resume complete archive unarchive]
|
||||
|
||||
STATE_FILTERS = %w[all active paused completed archived].freeze
|
||||
|
||||
def index
|
||||
@state_filter = STATE_FILTERS.include?(params[:state]) ? params[:state] : "active"
|
||||
scope = Current.family.savings_goals.with_current_balance.alphabetically
|
||||
scope = scope.where(state: @state_filter) unless @state_filter == "all"
|
||||
@savings_goals = scope.to_a
|
||||
|
||||
@counts = STATE_FILTERS.each_with_object({}) do |state, h|
|
||||
h[state] = state == "all" ? Current.family.savings_goals.count : Current.family.savings_goals.where(state: state).count
|
||||
end
|
||||
|
||||
@linkable_account_count = Current.family.accounts.where(accountable_type: "Depository").visible.count
|
||||
end
|
||||
|
||||
def show
|
||||
@contributions = @savings_goal.savings_contributions.includes(:account).chronological.limit(50)
|
||||
@funding_breakdown = funding_breakdown_for(@savings_goal)
|
||||
end
|
||||
|
||||
def new
|
||||
@savings_goal = Current.family.savings_goals.new(
|
||||
color: SavingsGoal::COLORS.sample,
|
||||
currency: Current.family.primary_currency_code
|
||||
)
|
||||
@linkable_accounts = linkable_accounts_for_new
|
||||
end
|
||||
|
||||
def create
|
||||
@savings_goal = Current.family.savings_goals.new(savings_goal_params)
|
||||
accounts = lookup_accounts(params.dig(:savings_goal, :account_ids))
|
||||
@savings_goal.currency = accounts.first.currency if accounts.any? && @savings_goal.currency.blank?
|
||||
|
||||
SavingsGoal.transaction do
|
||||
accounts.each { |a| @savings_goal.savings_goal_accounts.build(account: a) }
|
||||
@savings_goal.save!
|
||||
create_initial_contribution_if_provided!(@savings_goal, accounts)
|
||||
end
|
||||
|
||||
flash[:notice] = t(".success")
|
||||
respond_to do |format|
|
||||
format.html { redirect_to savings_goal_path(@savings_goal) }
|
||||
format.turbo_stream do
|
||||
render turbo_stream: turbo_stream.action(:redirect, savings_goal_path(@savings_goal))
|
||||
end
|
||||
end
|
||||
rescue ActiveRecord::RecordInvalid
|
||||
@linkable_accounts = linkable_accounts_for_new
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
if @savings_goal.update(savings_goal_update_params)
|
||||
flash[:notice] = t(".success")
|
||||
respond_to do |format|
|
||||
format.html { redirect_to savings_goal_path(@savings_goal) }
|
||||
format.turbo_stream do
|
||||
render turbo_stream: turbo_stream.action(:redirect, savings_goal_path(@savings_goal))
|
||||
end
|
||||
end
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
unless @savings_goal.archived?
|
||||
redirect_to savings_goal_path(@savings_goal), alert: t(".archive_first")
|
||||
return
|
||||
end
|
||||
|
||||
@savings_goal.destroy!
|
||||
redirect_to savings_goals_path, notice: t(".success")
|
||||
end
|
||||
|
||||
def pause
|
||||
perform_transition!(:pause)
|
||||
end
|
||||
|
||||
def resume
|
||||
perform_transition!(:resume)
|
||||
end
|
||||
|
||||
def complete
|
||||
perform_transition!(:complete)
|
||||
end
|
||||
|
||||
def archive
|
||||
perform_transition!(:archive)
|
||||
end
|
||||
|
||||
def unarchive
|
||||
perform_transition!(:unarchive)
|
||||
end
|
||||
|
||||
private
|
||||
def set_savings_goal
|
||||
@savings_goal = Current.family.savings_goals.find(params[:id])
|
||||
end
|
||||
|
||||
def savings_goal_params
|
||||
params.require(:savings_goal).permit(:name, :target_amount, :target_date, :color, :notes)
|
||||
end
|
||||
|
||||
def savings_goal_update_params
|
||||
params.require(:savings_goal).permit(:name, :target_amount, :target_date, :color, :notes)
|
||||
end
|
||||
|
||||
def lookup_accounts(ids)
|
||||
return [] if ids.blank?
|
||||
|
||||
ids = Array(ids).reject(&:blank?)
|
||||
Current.family.accounts.where(accountable_type: "Depository").visible.where(id: ids).to_a
|
||||
end
|
||||
|
||||
def linkable_accounts_for_new
|
||||
Current.family.accounts.where(accountable_type: "Depository").visible.alphabetically.to_a
|
||||
end
|
||||
|
||||
def create_initial_contribution_if_provided!(goal, accounts)
|
||||
amount = params.dig(:savings_goal, :initial_contribution_amount)
|
||||
account_id = params.dig(:savings_goal, :initial_contribution_account_id)
|
||||
return if amount.blank? || account_id.blank?
|
||||
return unless BigDecimal(amount.to_s) > 0
|
||||
|
||||
source = accounts.find { |a| a.id == account_id }
|
||||
raise ActiveRecord::RecordInvalid.new(goal) unless source
|
||||
|
||||
goal.savings_contributions.create!(
|
||||
account: source,
|
||||
amount: amount,
|
||||
currency: goal.currency,
|
||||
source: "initial",
|
||||
contributed_at: Date.current
|
||||
)
|
||||
end
|
||||
|
||||
def funding_breakdown_for(goal)
|
||||
totals = goal.savings_contributions
|
||||
.group(:account_id)
|
||||
.sum(:amount)
|
||||
goal.linked_accounts.map do |account|
|
||||
amount = totals[account.id] || 0
|
||||
{ account: account, amount: amount, money: Money.new(amount, goal.currency) }
|
||||
end
|
||||
end
|
||||
|
||||
def perform_transition!(event)
|
||||
if @savings_goal.aasm.may_fire_event?(event)
|
||||
@savings_goal.public_send("#{event}!")
|
||||
respond_to do |format|
|
||||
format.html { redirect_to savings_goal_path(@savings_goal), notice: t(".success") }
|
||||
format.turbo_stream do
|
||||
render turbo_stream: turbo_stream.action(:redirect, savings_goal_path(@savings_goal))
|
||||
end
|
||||
end
|
||||
else
|
||||
redirect_to savings_goal_path(@savings_goal), alert: t(".invalid_transition")
|
||||
end
|
||||
end
|
||||
end
|
||||
124
app/javascript/controllers/savings_goal_stepper_controller.js
Normal file
124
app/javascript/controllers/savings_goal_stepper_controller.js
Normal file
@@ -0,0 +1,124 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
// 2-step modal stepper for creating a savings goal.
|
||||
//
|
||||
// Single <form> with two panels. Step 1 collects identity (name, amount,
|
||||
// date, color, notes). Step 2 collects ≥1 linked depository accounts and
|
||||
// optionally an initial contribution. Submit button stays disabled until at
|
||||
// least one linked account is selected. Step state lives entirely in the
|
||||
// DOM — no half-records.
|
||||
export default class extends Controller {
|
||||
static targets = [
|
||||
"step1Panel",
|
||||
"step2Panel",
|
||||
"step1Indicator",
|
||||
"step2Indicator",
|
||||
"step1Field",
|
||||
"nameField",
|
||||
"targetAmountField",
|
||||
"linkedAccountCheckbox",
|
||||
"initialContributionAmount",
|
||||
"initialContributionAccountSelect",
|
||||
"reviewPanel",
|
||||
"reviewName",
|
||||
"reviewAmount",
|
||||
"reviewDate",
|
||||
"reviewAccounts",
|
||||
"submitButton",
|
||||
];
|
||||
|
||||
next(event) {
|
||||
event?.preventDefault?.();
|
||||
if (!this.validateStep1()) return;
|
||||
|
||||
this.step1PanelTarget.classList.add("hidden");
|
||||
this.step2PanelTarget.classList.remove("hidden");
|
||||
this.markStepActive(2);
|
||||
this.updateReview();
|
||||
this.refreshSubmitState();
|
||||
}
|
||||
|
||||
back(event) {
|
||||
event?.preventDefault?.();
|
||||
this.step2PanelTarget.classList.add("hidden");
|
||||
this.step1PanelTarget.classList.remove("hidden");
|
||||
this.markStepActive(1);
|
||||
}
|
||||
|
||||
linkedAccountChanged() {
|
||||
this.refreshAccountSelect();
|
||||
this.refreshSubmitState();
|
||||
this.updateReview();
|
||||
}
|
||||
|
||||
validateStep1() {
|
||||
let ok = true;
|
||||
this.step1FieldTargets.forEach((field) => {
|
||||
if (!field.checkValidity()) {
|
||||
field.reportValidity();
|
||||
ok = false;
|
||||
}
|
||||
});
|
||||
return ok;
|
||||
}
|
||||
|
||||
refreshSubmitState() {
|
||||
const anyChecked = this.linkedAccountCheckboxTargets.some((cb) => cb.checked);
|
||||
this.submitButtonTarget.disabled = !anyChecked;
|
||||
}
|
||||
|
||||
refreshAccountSelect() {
|
||||
if (!this.hasInitialContributionAccountSelectTarget) return;
|
||||
|
||||
const select = this.initialContributionAccountSelectTarget;
|
||||
const previous = select.value;
|
||||
select.innerHTML = "";
|
||||
const blank = document.createElement("option");
|
||||
blank.value = "";
|
||||
blank.textContent = select.dataset.blankLabel || "—";
|
||||
select.appendChild(blank);
|
||||
|
||||
this.linkedAccountCheckboxTargets
|
||||
.filter((cb) => cb.checked)
|
||||
.forEach((cb) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = cb.value;
|
||||
opt.textContent = cb.dataset.accountName || cb.value;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
|
||||
if ([...select.options].some((o) => o.value === previous)) {
|
||||
select.value = previous;
|
||||
}
|
||||
}
|
||||
|
||||
updateReview() {
|
||||
if (!this.hasReviewPanelTarget) return;
|
||||
|
||||
if (this.hasReviewNameTarget && this.hasNameFieldTarget) {
|
||||
this.reviewNameTarget.textContent = this.nameFieldTarget.value || "—";
|
||||
}
|
||||
if (this.hasReviewAmountTarget && this.hasTargetAmountFieldTarget) {
|
||||
this.reviewAmountTarget.textContent = this.targetAmountFieldTarget.value || "—";
|
||||
}
|
||||
if (this.hasReviewDateTarget) {
|
||||
const dateInput = this.element.querySelector('input[type="date"][name="savings_goal[target_date]"]');
|
||||
this.reviewDateTarget.textContent = dateInput?.value || "—";
|
||||
}
|
||||
if (this.hasReviewAccountsTarget) {
|
||||
const names = this.linkedAccountCheckboxTargets
|
||||
.filter((cb) => cb.checked)
|
||||
.map((cb) => cb.dataset.accountName || cb.value);
|
||||
this.reviewAccountsTarget.textContent = names.length ? names.join(", ") : "—";
|
||||
}
|
||||
}
|
||||
|
||||
markStepActive(stepNumber) {
|
||||
if (this.hasStep1IndicatorTarget) {
|
||||
this.step1IndicatorTarget.classList.toggle("text-primary", stepNumber === 1);
|
||||
}
|
||||
if (this.hasStep2IndicatorTarget) {
|
||||
this.step2IndicatorTarget.classList.toggle("text-primary", stepNumber === 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,9 @@ class Account < ApplicationRecord
|
||||
has_many :holdings, dependent: :destroy
|
||||
has_many :balances, dependent: :destroy
|
||||
has_many :recurring_transactions, dependent: :destroy
|
||||
has_many :savings_goal_accounts, dependent: :destroy
|
||||
has_many :savings_goals, through: :savings_goal_accounts
|
||||
has_many :savings_contributions, dependent: :destroy
|
||||
|
||||
monetize :balance, :cash_balance
|
||||
|
||||
|
||||
@@ -28,7 +28,8 @@ module Assistant
|
||||
Function::GetBalanceSheet,
|
||||
Function::GetIncomeStatement,
|
||||
Function::ImportBankStatement,
|
||||
Function::SearchFamilyFiles
|
||||
Function::SearchFamilyFiles,
|
||||
Function::CreateSavingsGoal
|
||||
]
|
||||
end
|
||||
|
||||
|
||||
184
app/models/assistant/function/create_savings_goal.rb
Normal file
184
app/models/assistant/function/create_savings_goal.rb
Normal file
@@ -0,0 +1,184 @@
|
||||
class Assistant::Function::CreateSavingsGoal < Assistant::Function
|
||||
class << self
|
||||
def name
|
||||
"create_savings_goal"
|
||||
end
|
||||
|
||||
def description
|
||||
<<~INSTRUCTIONS
|
||||
Creates a savings goal for the user's family.
|
||||
|
||||
Use when the user describes a target they want to save toward — e.g.
|
||||
"vacation in 4 months for $5000", "downpayment for a car next year",
|
||||
"build an emergency fund of $10k".
|
||||
|
||||
Before calling, confirm the key details by paraphrasing back to the
|
||||
user: the name, target amount, target date (if mentioned), and which
|
||||
of their accounts will fund it. Only call once they've confirmed.
|
||||
|
||||
Constraints:
|
||||
- The goal must link to at least one of the user's Depository
|
||||
accounts (checking, savings, HSA, CD, money-market).
|
||||
- All linked accounts must share the same currency.
|
||||
- Use account names exactly as listed in the user's Depository
|
||||
accounts.
|
||||
|
||||
On success returns the new goal's URL so you can point the user to
|
||||
it. On a soft failure (e.g. account name doesn't match), the
|
||||
response includes the available account list so you can re-ask.
|
||||
INSTRUCTIONS
|
||||
end
|
||||
end
|
||||
|
||||
def strict_mode?
|
||||
false
|
||||
end
|
||||
|
||||
def params_schema
|
||||
build_schema(
|
||||
required: %w[name target_amount linked_account_names],
|
||||
properties: {
|
||||
name: {
|
||||
type: "string",
|
||||
description: "Short goal name, e.g. 'Vacation in Italy'."
|
||||
},
|
||||
target_amount: {
|
||||
type: "number",
|
||||
description: "Total amount to save, in the linked accounts' currency."
|
||||
},
|
||||
target_date: {
|
||||
type: "string",
|
||||
description: "Optional ISO 8601 date (YYYY-MM-DD) for when the user wants to reach the target."
|
||||
},
|
||||
linked_account_names: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Names of the user's Depository accounts to link. Must contain at least one. Use names exactly as they appear in the available accounts list."
|
||||
},
|
||||
initial_contribution: {
|
||||
type: "object",
|
||||
description: "Optional starting contribution at creation time.",
|
||||
properties: {
|
||||
amount: { type: "number" },
|
||||
source_account_name: { type: "string", description: "Must be one of the linked_account_names." }
|
||||
}
|
||||
},
|
||||
notes: {
|
||||
type: "string",
|
||||
description: "Optional freeform notes."
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def call(params = {})
|
||||
name = params["name"].to_s.strip
|
||||
target_amount = parse_decimal(params["target_amount"])
|
||||
target_date = parse_date(params["target_date"])
|
||||
linked_account_names = Array(params["linked_account_names"]).map { |n| n.to_s.strip }.reject(&:blank?)
|
||||
initial = params["initial_contribution"]
|
||||
notes = params["notes"].to_s.strip
|
||||
|
||||
return error("name_required", "Please provide a name for the goal.") if name.blank?
|
||||
|
||||
return error("target_amount_invalid", "Target amount must be greater than zero.") unless target_amount && target_amount > 0
|
||||
|
||||
if linked_account_names.empty?
|
||||
return error(
|
||||
"no_linked_accounts",
|
||||
"Please specify at least one Depository account to link to this goal.",
|
||||
available_accounts: depository_account_payload
|
||||
)
|
||||
end
|
||||
|
||||
matched = family.accounts.where(accountable_type: "Depository").visible.where(name: linked_account_names).to_a
|
||||
missing = linked_account_names - matched.map(&:name)
|
||||
if missing.any?
|
||||
return error(
|
||||
"unknown_accounts",
|
||||
"Some account names didn't match the user's Depository accounts.",
|
||||
unknown_names: missing,
|
||||
available_accounts: depository_account_payload
|
||||
)
|
||||
end
|
||||
|
||||
currencies = matched.map(&:currency).uniq
|
||||
if currencies.size > 1
|
||||
return error(
|
||||
"currency_mismatch",
|
||||
"All linked accounts must share the same currency. Found: #{currencies.join(', ')}."
|
||||
)
|
||||
end
|
||||
|
||||
goal = nil
|
||||
SavingsGoal.transaction do
|
||||
goal = family.savings_goals.new(
|
||||
name: name,
|
||||
target_amount: target_amount,
|
||||
target_date: target_date,
|
||||
currency: currencies.first,
|
||||
notes: notes.presence,
|
||||
color: SavingsGoal::COLORS.sample
|
||||
)
|
||||
matched.each { |a| goal.savings_goal_accounts.build(account: a) }
|
||||
goal.save!
|
||||
|
||||
create_initial_contribution!(goal, matched, initial)
|
||||
end
|
||||
|
||||
{
|
||||
success: true,
|
||||
goal_id: goal.id,
|
||||
name: goal.name,
|
||||
target_amount_formatted: goal.target_amount_money.format,
|
||||
currency: goal.currency,
|
||||
target_date: goal.target_date&.iso8601,
|
||||
url: Rails.application.routes.url_helpers.savings_goal_path(goal),
|
||||
linked_account_names: matched.map(&:name),
|
||||
message: "Created savings goal '#{goal.name}' (target #{goal.target_amount_money.format}). View it at #{Rails.application.routes.url_helpers.savings_goal_path(goal)}."
|
||||
}
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
error("validation_failed", e.record.errors.full_messages.join("; "))
|
||||
end
|
||||
|
||||
private
|
||||
def create_initial_contribution!(goal, matched_accounts, initial)
|
||||
return unless initial.is_a?(Hash)
|
||||
|
||||
amount = parse_decimal(initial["amount"])
|
||||
return unless amount && amount > 0
|
||||
|
||||
source = matched_accounts.find { |a| a.name == initial["source_account_name"].to_s }
|
||||
raise ActiveRecord::RecordInvalid.new(goal) unless source
|
||||
|
||||
goal.savings_contributions.create!(
|
||||
account: source,
|
||||
amount: amount,
|
||||
currency: goal.currency,
|
||||
source: "initial",
|
||||
contributed_at: Date.current
|
||||
)
|
||||
end
|
||||
|
||||
def parse_decimal(value)
|
||||
return nil if value.nil?
|
||||
BigDecimal(value.to_s)
|
||||
rescue ArgumentError, TypeError
|
||||
nil
|
||||
end
|
||||
|
||||
def parse_date(value)
|
||||
return nil if value.blank?
|
||||
Date.iso8601(value.to_s)
|
||||
rescue Date::Error
|
||||
nil
|
||||
end
|
||||
|
||||
def depository_account_payload
|
||||
family.accounts.where(accountable_type: "Depository").visible.pluck(:name, :currency).map { |n, c| { name: n, currency: c } }
|
||||
end
|
||||
|
||||
def error(key, message, extras = {})
|
||||
{ success: false, error: key, message: message }.merge(extras)
|
||||
end
|
||||
end
|
||||
@@ -103,6 +103,9 @@ class Demo::Generator
|
||||
# Auto-fill current-month budget based on recent spending averages
|
||||
generate_budget_auto_fill!(family)
|
||||
|
||||
puts "🎯 Seeding savings goals..."
|
||||
generate_savings_goals!(family)
|
||||
|
||||
puts "✅ Realistic demo data loaded successfully!"
|
||||
end
|
||||
end
|
||||
@@ -1274,4 +1277,84 @@ class Demo::Generator
|
||||
|
||||
puts " ✅ Set property and vehicle valuations"
|
||||
end
|
||||
|
||||
def generate_savings_goals!(family)
|
||||
depository_accounts = family.accounts.depository.visible.to_a
|
||||
return if depository_accounts.empty?
|
||||
|
||||
currency = depository_accounts.first.currency
|
||||
eligible = depository_accounts.select { |a| a.currency == currency }
|
||||
primary = eligible.first
|
||||
secondary = eligible[1] || primary
|
||||
|
||||
goals = [
|
||||
{
|
||||
name: "Vacation in Italy",
|
||||
target: 5_000,
|
||||
target_date: 4.months.from_now.to_date,
|
||||
accounts: eligible.first(2),
|
||||
contributions: [
|
||||
{ amount: 500, source: "initial", days_ago: 90, account: primary },
|
||||
{ amount: 250, source: "manual", days_ago: 60, account: primary },
|
||||
{ amount: 250, source: "manual", days_ago: 30, account: secondary }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Emergency fund",
|
||||
target: 10_000,
|
||||
target_date: nil,
|
||||
accounts: [ primary ],
|
||||
contributions: [
|
||||
{ amount: 1_000, source: "initial", days_ago: 180, account: primary }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "House downpayment",
|
||||
target: 50_000,
|
||||
target_date: 24.months.from_now.to_date,
|
||||
accounts: eligible.first(2),
|
||||
contributions: [
|
||||
{ amount: 5_000, source: "initial", days_ago: 365, account: primary }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Paid-off car",
|
||||
target: 8_000,
|
||||
target_date: 6.months.ago.to_date,
|
||||
state: "completed",
|
||||
accounts: [ primary ],
|
||||
contributions: [
|
||||
{ amount: 2_000, source: "initial", days_ago: 730, account: primary },
|
||||
{ amount: 2_000, source: "manual", days_ago: 600, account: primary },
|
||||
{ amount: 2_000, source: "manual", days_ago: 450, account: primary },
|
||||
{ amount: 2_000, source: "manual", days_ago: 300, account: primary }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
goals.each do |goal_spec|
|
||||
goal = family.savings_goals.new(
|
||||
name: goal_spec[:name],
|
||||
target_amount: goal_spec[:target],
|
||||
target_date: goal_spec[:target_date],
|
||||
currency: currency,
|
||||
color: SavingsGoal::COLORS.sample,
|
||||
state: goal_spec[:state] || "active"
|
||||
)
|
||||
goal_spec[:accounts].uniq.each { |a| goal.savings_goal_accounts.build(account: a) }
|
||||
goal.save!
|
||||
|
||||
goal_spec[:contributions].each do |c|
|
||||
goal.savings_contributions.create!(
|
||||
account: c[:account],
|
||||
amount: c[:amount],
|
||||
currency: currency,
|
||||
source: c[:source],
|
||||
contributed_at: c[:days_ago].days.ago.to_date
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
puts " ✅ Seeded #{goals.size} savings goals"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -42,6 +42,9 @@ class Family < ApplicationRecord
|
||||
has_many :budgets, dependent: :destroy
|
||||
has_many :budget_categories, through: :budgets
|
||||
|
||||
has_many :savings_goals, dependent: :destroy
|
||||
has_many :savings_contributions, through: :savings_goals
|
||||
|
||||
has_many :llm_usages, dependent: :destroy
|
||||
has_many :recurring_transactions, dependent: :destroy
|
||||
|
||||
|
||||
56
app/models/savings_contribution.rb
Normal file
56
app/models/savings_contribution.rb
Normal file
@@ -0,0 +1,56 @@
|
||||
class SavingsContribution < ApplicationRecord
|
||||
include Monetizable
|
||||
|
||||
SOURCES = %w[manual initial].freeze
|
||||
|
||||
belongs_to :savings_goal
|
||||
belongs_to :account
|
||||
|
||||
validates :amount, presence: true, numericality: { greater_than: 0 }
|
||||
validates :currency, presence: true
|
||||
validates :contributed_at, presence: true
|
||||
validates :source, inclusion: { in: SOURCES }
|
||||
validate :currency_matches_goal
|
||||
validate :account_must_belong_to_family
|
||||
validate :account_must_be_linked_to_goal
|
||||
|
||||
before_validation :sync_currency_from_goal
|
||||
|
||||
monetize :amount
|
||||
|
||||
scope :chronological, -> { order(contributed_at: :desc, created_at: :desc) }
|
||||
|
||||
def manual?
|
||||
source == "manual"
|
||||
end
|
||||
|
||||
def initial?
|
||||
source == "initial"
|
||||
end
|
||||
|
||||
private
|
||||
def sync_currency_from_goal
|
||||
self.currency = savings_goal.currency if savings_goal && currency.blank?
|
||||
end
|
||||
|
||||
def currency_matches_goal
|
||||
return if savings_goal.nil? || currency.blank?
|
||||
return if currency == savings_goal.currency
|
||||
|
||||
errors.add(:currency, :must_match_goal)
|
||||
end
|
||||
|
||||
def account_must_belong_to_family
|
||||
return if savings_goal.nil? || account.nil?
|
||||
return if account.family_id == savings_goal.family_id
|
||||
|
||||
errors.add(:account, :must_belong_to_family)
|
||||
end
|
||||
|
||||
def account_must_be_linked_to_goal
|
||||
return if savings_goal.nil? || account.nil?
|
||||
return if savings_goal.savings_goal_accounts.where(account_id: account_id).exists?
|
||||
|
||||
errors.add(:account, :must_be_linked_to_goal)
|
||||
end
|
||||
end
|
||||
185
app/models/savings_goal.rb
Normal file
185
app/models/savings_goal.rb
Normal file
@@ -0,0 +1,185 @@
|
||||
class SavingsGoal < ApplicationRecord
|
||||
include AASM, Monetizable
|
||||
|
||||
COLORS = Category::COLORS
|
||||
|
||||
belongs_to :family
|
||||
has_many :savings_goal_accounts, dependent: :destroy
|
||||
has_many :linked_accounts, through: :savings_goal_accounts, source: :account
|
||||
has_many :savings_contributions, dependent: :destroy
|
||||
|
||||
validates :name, presence: true, length: { maximum: 255 }
|
||||
validates :target_amount, presence: true, numericality: { greater_than: 0 }
|
||||
validates :currency, presence: true
|
||||
validate :must_have_at_least_one_linked_account
|
||||
validate :linked_accounts_must_be_depository
|
||||
validate :linked_accounts_must_match_goal_currency
|
||||
validate :linked_accounts_must_belong_to_family
|
||||
validate :currency_locked_once_contributions_exist
|
||||
|
||||
monetize :target_amount
|
||||
|
||||
scope :alphabetically, -> { order(Arel.sql("LOWER(name) ASC")) }
|
||||
scope :active_first, lambda {
|
||||
order(Arel.sql("CASE state WHEN 'active' THEN 0 WHEN 'paused' THEN 1 WHEN 'completed' THEN 2 ELSE 3 END"))
|
||||
}
|
||||
scope :with_current_balance, lambda {
|
||||
left_outer_joins(:savings_contributions)
|
||||
.group(Arel.sql("savings_goals.id"))
|
||||
.select(Arel.sql("savings_goals.*, COALESCE(SUM(savings_contributions.amount), 0) AS current_balance_total"))
|
||||
}
|
||||
|
||||
# 63-bit Postgres advisory-lock key per family. Used by future auto-fund flows
|
||||
# and any future per-family serialization of goal contributions.
|
||||
def self.advisory_lock_key_for(family_id)
|
||||
Digest::SHA1.hexdigest("savings_goals:family:#{family_id}").to_i(16) % (2**63)
|
||||
end
|
||||
|
||||
aasm column: :state do
|
||||
state :active, initial: true
|
||||
state :paused
|
||||
state :completed
|
||||
state :archived
|
||||
|
||||
event :pause do
|
||||
transitions from: :active, to: :paused
|
||||
end
|
||||
|
||||
event :resume do
|
||||
transitions from: :paused, to: :active
|
||||
end
|
||||
|
||||
event :complete do
|
||||
transitions from: [ :active, :paused ], to: :completed
|
||||
end
|
||||
|
||||
event :archive do
|
||||
transitions from: [ :active, :paused, :completed ], to: :archived
|
||||
end
|
||||
|
||||
event :unarchive do
|
||||
transitions from: :archived, to: :active
|
||||
end
|
||||
end
|
||||
|
||||
def current_balance
|
||||
@current_balance ||= if attributes.key?("current_balance_total")
|
||||
attributes["current_balance_total"] || 0
|
||||
else
|
||||
savings_contributions.sum(:amount)
|
||||
end
|
||||
end
|
||||
|
||||
def current_balance_money
|
||||
@current_balance_money ||= Money.new(current_balance, currency)
|
||||
end
|
||||
|
||||
def remaining_amount
|
||||
@remaining_amount ||= [ target_amount - current_balance, 0 ].max
|
||||
end
|
||||
|
||||
def remaining_amount_money
|
||||
@remaining_amount_money ||= Money.new(remaining_amount, currency)
|
||||
end
|
||||
|
||||
def progress_percent
|
||||
return @progress_percent if defined?(@progress_percent)
|
||||
|
||||
@progress_percent = if completed?
|
||||
100
|
||||
elsif target_amount.to_d.zero?
|
||||
0
|
||||
else
|
||||
[ ((current_balance.to_d / target_amount.to_d) * 100).round, 100 ].min
|
||||
end
|
||||
end
|
||||
|
||||
def months_remaining
|
||||
return nil unless target_date
|
||||
|
||||
months = (target_date.year - Date.current.year) * 12 + (target_date.month - Date.current.month)
|
||||
[ months, 0 ].max
|
||||
end
|
||||
|
||||
def monthly_target_amount
|
||||
return @monthly_target_amount if defined?(@monthly_target_amount)
|
||||
|
||||
@monthly_target_amount = if target_date.nil?
|
||||
nil
|
||||
elsif months_remaining.zero?
|
||||
remaining_amount
|
||||
else
|
||||
(remaining_amount.to_d / months_remaining).ceil(2)
|
||||
end
|
||||
end
|
||||
|
||||
# :reached → progress_percent >= 100
|
||||
# :on_track → has target_date and current pace >= required monthly pace
|
||||
# :behind → has target_date and current pace < required monthly pace
|
||||
# :no_target_date → progress < 100 and target_date is nil
|
||||
def status
|
||||
return :reached if progress_percent >= 100
|
||||
return :no_target_date if target_date.nil?
|
||||
return :on_track if monthly_target_amount.to_d <= average_monthly_contribution.to_d
|
||||
|
||||
:behind
|
||||
end
|
||||
|
||||
def average_monthly_contribution
|
||||
return 0 if savings_contributions.empty?
|
||||
|
||||
first_at = savings_contributions.minimum(:contributed_at)
|
||||
return current_balance if first_at.blank?
|
||||
|
||||
months = ((Date.current.year - first_at.year) * 12 + (Date.current.month - first_at.month)) + 1
|
||||
months = 1 if months < 1
|
||||
(current_balance.to_d / months).round(2)
|
||||
end
|
||||
|
||||
private
|
||||
def must_have_at_least_one_linked_account
|
||||
return unless savings_goal_accounts.reject(&:marked_for_destruction?).empty?
|
||||
|
||||
errors.add(:base, :at_least_one_linked_account_required)
|
||||
end
|
||||
|
||||
def linked_accounts_must_be_depository
|
||||
offending = savings_goal_accounts.reject(&:marked_for_destruction?).reject do |sga|
|
||||
sga.account&.depository?
|
||||
end
|
||||
return if offending.empty?
|
||||
|
||||
errors.add(:linked_accounts, :must_be_depository)
|
||||
end
|
||||
|
||||
def linked_accounts_must_match_goal_currency
|
||||
return if currency.blank?
|
||||
|
||||
mismatched = savings_goal_accounts.reject(&:marked_for_destruction?).reject do |sga|
|
||||
sga.account.nil? || sga.account.currency == currency
|
||||
end
|
||||
return if mismatched.empty?
|
||||
|
||||
errors.add(:linked_accounts, :currency_mismatch)
|
||||
end
|
||||
|
||||
def linked_accounts_must_belong_to_family
|
||||
return if family.nil?
|
||||
|
||||
foreign = savings_goal_accounts.reject(&:marked_for_destruction?).reject do |sga|
|
||||
sga.account.nil? || sga.account.family_id == family_id
|
||||
end
|
||||
return if foreign.empty?
|
||||
|
||||
errors.add(:linked_accounts, :must_belong_to_family)
|
||||
end
|
||||
|
||||
# Once a goal has contributions, changing currency would orphan amounts
|
||||
# in the old currency. Lock it.
|
||||
def currency_locked_once_contributions_exist
|
||||
return unless persisted? && currency_changed?
|
||||
return unless savings_contributions.exists?
|
||||
|
||||
errors.add(:currency, :locked_after_contributions)
|
||||
end
|
||||
end
|
||||
6
app/models/savings_goal_account.rb
Normal file
6
app/models/savings_goal_account.rb
Normal file
@@ -0,0 +1,6 @@
|
||||
class SavingsGoalAccount < ApplicationRecord
|
||||
belongs_to :savings_goal
|
||||
belongs_to :account
|
||||
|
||||
validates :account_id, uniqueness: { scope: :savings_goal_id }
|
||||
end
|
||||
@@ -11,6 +11,7 @@ else
|
||||
{ name: t(".nav.transactions"), path: transactions_path, icon: "credit-card", icon_custom: false, active: page_active?(transactions_path) },
|
||||
{ name: t(".nav.reports"), path: reports_path, icon: "chart-bar", icon_custom: false, active: page_active?(reports_path) },
|
||||
{ name: t(".nav.budgets"), path: budgets_path, icon: "map", icon_custom: false, active: page_active?(budgets_path) },
|
||||
{ name: t(".nav.savings_goals"), path: savings_goals_path, icon: "piggy-bank", icon_custom: false, active: page_active?(savings_goals_path) },
|
||||
{ name: t(".nav.assistant"), path: chats_path, icon: "icon-assistant", icon_custom: true, active: page_active?(chats_path), mobile_only: true }
|
||||
]
|
||||
end %>
|
||||
|
||||
33
app/views/savings_contributions/new.html.erb
Normal file
33
app/views/savings_contributions/new.html.erb
Normal file
@@ -0,0 +1,33 @@
|
||||
<%= render DS::Dialog.new do |dialog| %>
|
||||
<% dialog.with_header(title: t(".heading")) %>
|
||||
<% dialog.with_body do %>
|
||||
<% if @contribution.errors.any? %>
|
||||
<%= render "shared/form_errors", model: @contribution %>
|
||||
<% end %>
|
||||
|
||||
<%= styled_form_with model: @contribution,
|
||||
url: savings_goal_contributions_path(@savings_goal),
|
||||
class: "space-y-3" do |f| %>
|
||||
<%= f.money_field :amount,
|
||||
label: t(".amount"),
|
||||
required: true,
|
||||
autofocus: true %>
|
||||
|
||||
<%= label_tag "savings_contribution[account_id]", t(".source_account"), class: "block text-sm text-secondary" %>
|
||||
<%= select_tag "savings_contribution[account_id]",
|
||||
options_from_collection_for_select(@savings_goal.linked_accounts, :id, :name),
|
||||
class: "w-full",
|
||||
include_blank: t(".select_account") %>
|
||||
|
||||
<%= f.date_field :contributed_at,
|
||||
label: t(".contributed_at"),
|
||||
required: true %>
|
||||
|
||||
<%= f.text_area :notes, label: t(".notes"), rows: 2 %>
|
||||
|
||||
<div class="flex justify-end pt-2">
|
||||
<%= f.submit t(".submit") %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
30
app/views/savings_goals/_contributions_list.html.erb
Normal file
30
app/views/savings_goals/_contributions_list.html.erb
Normal file
@@ -0,0 +1,30 @@
|
||||
<%# locals: (contributions:) %>
|
||||
|
||||
<% if contributions.empty? %>
|
||||
<p class="text-sm text-secondary"><%= t("savings_goals.show.no_contributions_yet") %></p>
|
||||
<% else %>
|
||||
<ul class="divide-y divide-divider">
|
||||
<% contributions.each do |contribution| %>
|
||||
<li class="flex items-center gap-3 py-2">
|
||||
<%= render Savings::GoalAvatarComponent.new(
|
||||
name: contribution.account.name,
|
||||
color: @savings_goal.color,
|
||||
size: "sm"
|
||||
) %>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm text-primary truncate"><%= contribution.account.name %></p>
|
||||
<p class="text-xs text-secondary"><%= I18n.l(contribution.contributed_at, format: :long) %> · <%= t("savings_goals.show.source.#{contribution.source}") %></p>
|
||||
</div>
|
||||
<span class="text-sm text-primary tabular-nums"><%= contribution.amount_money.format %></span>
|
||||
<% if contribution.manual? %>
|
||||
<%= button_to savings_goal_contribution_path(@savings_goal, contribution),
|
||||
method: :delete,
|
||||
class: "text-secondary hover:text-destructive",
|
||||
form: { data: { turbo_confirm: t("savings_goals.show.confirm_delete_contribution") } } do %>
|
||||
<%= icon("x", size: "sm") %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% end %>
|
||||
29
app/views/savings_goals/_empty_state.html.erb
Normal file
29
app/views/savings_goals/_empty_state.html.erb
Normal file
@@ -0,0 +1,29 @@
|
||||
<%# locals: (linkable_account_count:) %>
|
||||
|
||||
<div class="bg-container rounded-xl shadow-border-xs py-16">
|
||||
<div class="flex flex-col items-center text-center max-w-md mx-auto">
|
||||
<div class="w-12 h-12 rounded-full bg-container-inset flex items-center justify-center mb-3">
|
||||
<%= icon("piggy-bank", size: "lg") %>
|
||||
</div>
|
||||
<h2 class="text-base font-medium text-primary mb-1"><%= t("savings_goals.empty_state.heading") %></h2>
|
||||
<p class="text-sm text-secondary mb-4"><%= t("savings_goals.empty_state.subtitle") %></p>
|
||||
|
||||
<% if linkable_account_count > 0 %>
|
||||
<%= render DS::Link.new(
|
||||
text: t("savings_goals.empty_state.new_goal"),
|
||||
variant: "primary",
|
||||
href: new_savings_goal_path,
|
||||
icon: "plus",
|
||||
frame: :modal
|
||||
) %>
|
||||
<% else %>
|
||||
<p class="text-sm text-secondary mb-3"><%= t("savings_goals.empty_state.no_depository_accounts") %></p>
|
||||
<%= render DS::Link.new(
|
||||
text: t("savings_goals.empty_state.add_account"),
|
||||
variant: "primary",
|
||||
href: new_account_path,
|
||||
icon: "plus"
|
||||
) %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
43
app/views/savings_goals/_form_edit.html.erb
Normal file
43
app/views/savings_goals/_form_edit.html.erb
Normal file
@@ -0,0 +1,43 @@
|
||||
<%# locals: (savings_goal:) %>
|
||||
|
||||
<% if savings_goal.errors.any? %>
|
||||
<%= render "shared/form_errors", model: savings_goal %>
|
||||
<% end %>
|
||||
|
||||
<%= styled_form_with model: savings_goal,
|
||||
url: savings_goal_path(savings_goal),
|
||||
method: :patch,
|
||||
class: "space-y-3" do |f| %>
|
||||
<%= f.text_field :name,
|
||||
label: t("savings_goals.form_stepper.step1.fields.name"),
|
||||
required: true,
|
||||
autofocus: true %>
|
||||
|
||||
<%= f.money_field :target_amount,
|
||||
label: t("savings_goals.form_stepper.step1.fields.target_amount"),
|
||||
required: true %>
|
||||
|
||||
<%= f.date_field :target_date,
|
||||
label: t("savings_goals.form_stepper.step1.fields.target_date") %>
|
||||
|
||||
<div>
|
||||
<span class="block text-sm text-secondary mb-2"><%= t("savings_goals.form_stepper.step1.fields.color") %></span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<% SavingsGoal::COLORS.each do |c| %>
|
||||
<label class="relative">
|
||||
<%= f.radio_button :color, c, class: "sr-only peer" %>
|
||||
<div class="w-6 h-6 rounded-full cursor-pointer peer-checked:ring-2 peer-checked:ring-offset-2 peer-checked:ring-gray-500"
|
||||
style="background-color: <%= c %>"></div>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= f.text_area :notes,
|
||||
label: t("savings_goals.form_stepper.step1.fields.notes"),
|
||||
rows: 2 %>
|
||||
|
||||
<div class="flex justify-end pt-2">
|
||||
<%= f.submit t("savings_goals.edit.save") %>
|
||||
</div>
|
||||
<% end %>
|
||||
134
app/views/savings_goals/_form_stepper.html.erb
Normal file
134
app/views/savings_goals/_form_stepper.html.erb
Normal file
@@ -0,0 +1,134 @@
|
||||
<%# locals: (savings_goal:, linkable_accounts:) %>
|
||||
|
||||
<div data-controller="savings-goal-stepper">
|
||||
<% if savings_goal.errors.any? %>
|
||||
<%= render "shared/form_errors", model: savings_goal %>
|
||||
<% end %>
|
||||
|
||||
<ol class="flex items-center gap-2 mb-4 text-xs font-medium text-secondary">
|
||||
<li class="inline-flex items-center gap-1.5"
|
||||
data-savings-goal-stepper-target="step1Indicator">
|
||||
<span class="w-5 h-5 rounded-full inline-flex items-center justify-center bg-inverse text-primary">1</span>
|
||||
<span class="text-primary"><%= t("savings_goals.form_stepper.step1.heading") %></span>
|
||||
</li>
|
||||
<span class="text-subdued">→</span>
|
||||
<li class="inline-flex items-center gap-1.5"
|
||||
data-savings-goal-stepper-target="step2Indicator">
|
||||
<span class="w-5 h-5 rounded-full inline-flex items-center justify-center bg-container-inset">2</span>
|
||||
<span><%= t("savings_goals.form_stepper.step2.heading") %></span>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<%= styled_form_with model: savings_goal, url: savings_goals_path, class: "space-y-4" do |f| %>
|
||||
<section data-savings-goal-stepper-target="step1Panel" class="space-y-3">
|
||||
<%= f.text_field :name,
|
||||
label: t("savings_goals.form_stepper.step1.fields.name"),
|
||||
required: true,
|
||||
autofocus: true,
|
||||
data: { savings_goal_stepper_target: "step1Field nameField" } %>
|
||||
|
||||
<%= f.money_field :target_amount,
|
||||
label: t("savings_goals.form_stepper.step1.fields.target_amount"),
|
||||
required: true,
|
||||
data: { savings_goal_stepper_target: "step1Field targetAmountField" } %>
|
||||
|
||||
<%= f.date_field :target_date,
|
||||
label: t("savings_goals.form_stepper.step1.fields.target_date") %>
|
||||
|
||||
<div>
|
||||
<span class="block text-sm text-secondary mb-2"><%= t("savings_goals.form_stepper.step1.fields.color") %></span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<% SavingsGoal::COLORS.each do |c| %>
|
||||
<label class="relative">
|
||||
<%= f.radio_button :color, c, class: "sr-only peer" %>
|
||||
<div class="w-6 h-6 rounded-full cursor-pointer peer-checked:ring-2 peer-checked:ring-offset-2 peer-checked:ring-gray-500"
|
||||
style="background-color: <%= c %>"></div>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= f.text_area :notes,
|
||||
label: t("savings_goals.form_stepper.step1.fields.notes"),
|
||||
rows: 2 %>
|
||||
|
||||
<div class="flex justify-end pt-2">
|
||||
<%= render DS::Button.new(
|
||||
text: t("savings_goals.form_stepper.continue"),
|
||||
variant: "primary",
|
||||
data: { action: "click->savings-goal-stepper#next" }
|
||||
) %>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section data-savings-goal-stepper-target="step2Panel" class="space-y-4 hidden">
|
||||
<div>
|
||||
<span class="block text-sm font-medium text-primary"><%= t("savings_goals.form_stepper.step2.linked_accounts_heading") %></span>
|
||||
<p class="text-xs text-secondary mb-2"><%= t("savings_goals.form_stepper.step2.linked_accounts_hint") %></p>
|
||||
|
||||
<div class="space-y-2 max-h-56 overflow-y-auto pr-1">
|
||||
<% linkable_accounts.each do |account| %>
|
||||
<label class="flex items-center gap-3 px-3 py-2 rounded-md bg-container-inset hover:bg-container-inset-hover cursor-pointer">
|
||||
<%= check_box_tag "savings_goal[account_ids][]",
|
||||
account.id,
|
||||
false,
|
||||
data: {
|
||||
savings_goal_stepper_target: "linkedAccountCheckbox",
|
||||
action: "change->savings-goal-stepper#linkedAccountChanged",
|
||||
account_currency: account.currency,
|
||||
account_name: account.name
|
||||
} %>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm text-primary truncate"><%= account.name %></p>
|
||||
<p class="text-xs text-secondary"><%= account.subtype&.titleize %> · <%= Money.new(account.balance, account.currency).format %></p>
|
||||
</div>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details data-savings-goal-stepper-target="initialContributionToggle">
|
||||
<summary class="cursor-pointer text-sm text-primary"><%= t("savings_goals.form_stepper.step2.add_initial_contribution") %></summary>
|
||||
<div class="mt-3 space-y-2">
|
||||
<%= label_tag "savings_goal[initial_contribution_amount]",
|
||||
t("savings_goals.form_stepper.step2.initial_amount"),
|
||||
class: "block text-sm text-secondary" %>
|
||||
<%= number_field_tag "savings_goal[initial_contribution_amount]",
|
||||
nil,
|
||||
step: "0.01", min: "0",
|
||||
autocomplete: "off",
|
||||
class: "w-full",
|
||||
data: { savings_goal_stepper_target: "initialContributionAmount" } %>
|
||||
|
||||
<%= label_tag "savings_goal[initial_contribution_account_id]",
|
||||
t("savings_goals.form_stepper.step2.initial_account"),
|
||||
class: "block text-sm text-secondary" %>
|
||||
<%= select_tag "savings_goal[initial_contribution_account_id]",
|
||||
options_for_select([]),
|
||||
include_blank: t("savings_goals.form_stepper.step2.select_account"),
|
||||
data: { savings_goal_stepper_target: "initialContributionAccountSelect" },
|
||||
class: "w-full" %>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="bg-container-inset rounded-md p-3 text-sm space-y-1" data-savings-goal-stepper-target="reviewPanel">
|
||||
<p class="text-secondary"><%= t("savings_goals.form_stepper.step2.review_heading") %></p>
|
||||
<p><strong data-savings-goal-stepper-target="reviewName">—</strong></p>
|
||||
<p><span class="text-secondary"><%= t("savings_goals.form_stepper.step2.review_target") %>:</span> <span data-savings-goal-stepper-target="reviewAmount">—</span></p>
|
||||
<p><span class="text-secondary"><%= t("savings_goals.form_stepper.step2.review_date") %>:</span> <span data-savings-goal-stepper-target="reviewDate">—</span></p>
|
||||
<p><span class="text-secondary"><%= t("savings_goals.form_stepper.step2.review_accounts") %>:</span> <span data-savings-goal-stepper-target="reviewAccounts">—</span></p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between pt-2">
|
||||
<%= render DS::Button.new(
|
||||
text: t("savings_goals.form_stepper.back"),
|
||||
variant: "secondary",
|
||||
data: { action: "click->savings-goal-stepper#back" }
|
||||
) %>
|
||||
<%= f.submit t("savings_goals.form_stepper.submit"),
|
||||
data: { savings_goal_stepper_target: "submitButton" },
|
||||
disabled: true %>
|
||||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
</div>
|
||||
6
app/views/savings_goals/edit.html.erb
Normal file
6
app/views/savings_goals/edit.html.erb
Normal file
@@ -0,0 +1,6 @@
|
||||
<%= render DS::Dialog.new do |dialog| %>
|
||||
<% dialog.with_header(title: t(".heading")) %>
|
||||
<% dialog.with_body do %>
|
||||
<%= render "form_edit", savings_goal: @savings_goal %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
43
app/views/savings_goals/index.html.erb
Normal file
43
app/views/savings_goals/index.html.erb
Normal file
@@ -0,0 +1,43 @@
|
||||
<%= content_for :page_title, t(".title") %>
|
||||
<%= content_for :page_actions do %>
|
||||
<% if @linkable_account_count > 0 %>
|
||||
<%= render DS::Link.new(
|
||||
text: t(".new_goal"),
|
||||
variant: "primary",
|
||||
href: new_savings_goal_path,
|
||||
icon: "plus",
|
||||
frame: :modal
|
||||
) %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% if @savings_goals.empty? && @counts["all"].zero? %>
|
||||
<%= render "empty_state", linkable_account_count: @linkable_account_count %>
|
||||
<% else %>
|
||||
<div class="space-y-4">
|
||||
<nav class="flex items-center gap-1 overflow-x-auto" role="tablist">
|
||||
<% SavingsGoalsController::STATE_FILTERS.each do |state| %>
|
||||
<% active = state == @state_filter %>
|
||||
<%= link_to savings_goals_path(state: state),
|
||||
role: "tab",
|
||||
"aria-selected": active,
|
||||
class: "inline-flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium #{active ? 'bg-container shadow-border-xs text-primary' : 'text-secondary hover:text-primary'}" do %>
|
||||
<span><%= t(".tabs.#{state}") %></span>
|
||||
<span class="text-xs text-subdued tabular-nums"><%= @counts[state] %></span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</nav>
|
||||
|
||||
<% if @savings_goals.any? %>
|
||||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<% @savings_goals.each do |goal| %>
|
||||
<%= render Savings::GoalCardComponent.new(goal: goal) %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="bg-container rounded-xl shadow-border-xs py-12 text-center">
|
||||
<p class="text-sm text-secondary"><%= t(".empty_filtered", state: t(".tabs.#{@state_filter}").downcase) %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
6
app/views/savings_goals/new.html.erb
Normal file
6
app/views/savings_goals/new.html.erb
Normal file
@@ -0,0 +1,6 @@
|
||||
<%= render DS::Dialog.new do |dialog| %>
|
||||
<% dialog.with_header(title: t(".heading")) %>
|
||||
<% dialog.with_body do %>
|
||||
<%= render "form_stepper", savings_goal: @savings_goal, linkable_accounts: @linkable_accounts %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
139
app/views/savings_goals/show.html.erb
Normal file
139
app/views/savings_goals/show.html.erb
Normal file
@@ -0,0 +1,139 @@
|
||||
<%= content_for :page_title, @savings_goal.name %>
|
||||
<%= content_for :page_actions do %>
|
||||
<%= render DS::Menu.new do |menu| %>
|
||||
<% menu.with_item(
|
||||
variant: "link",
|
||||
text: t(".edit"),
|
||||
icon: "pencil",
|
||||
href: edit_savings_goal_path(@savings_goal),
|
||||
data: { turbo_frame: :modal }
|
||||
) %>
|
||||
<% if @savings_goal.may_pause? %>
|
||||
<% menu.with_item(
|
||||
variant: "button",
|
||||
text: t(".pause"),
|
||||
icon: "pause",
|
||||
href: pause_savings_goal_path(@savings_goal),
|
||||
method: :patch
|
||||
) %>
|
||||
<% end %>
|
||||
<% if @savings_goal.may_resume? %>
|
||||
<% menu.with_item(
|
||||
variant: "button",
|
||||
text: t(".resume"),
|
||||
icon: "play",
|
||||
href: resume_savings_goal_path(@savings_goal),
|
||||
method: :patch
|
||||
) %>
|
||||
<% end %>
|
||||
<% if @savings_goal.may_complete? %>
|
||||
<% menu.with_item(
|
||||
variant: "button",
|
||||
text: t(".complete"),
|
||||
icon: "circle-check-big",
|
||||
href: complete_savings_goal_path(@savings_goal),
|
||||
method: :patch
|
||||
) %>
|
||||
<% end %>
|
||||
<% if @savings_goal.may_archive? %>
|
||||
<% menu.with_item(
|
||||
variant: "button",
|
||||
text: t(".archive"),
|
||||
icon: "archive",
|
||||
href: archive_savings_goal_path(@savings_goal),
|
||||
method: :patch
|
||||
) %>
|
||||
<% end %>
|
||||
<% if @savings_goal.may_unarchive? %>
|
||||
<% menu.with_item(
|
||||
variant: "button",
|
||||
text: t(".unarchive"),
|
||||
icon: "archive-restore",
|
||||
href: unarchive_savings_goal_path(@savings_goal),
|
||||
method: :patch
|
||||
) %>
|
||||
<% end %>
|
||||
<% if @savings_goal.archived? %>
|
||||
<% menu.with_item(
|
||||
variant: "button",
|
||||
text: t(".delete"),
|
||||
icon: "trash-2",
|
||||
href: savings_goal_path(@savings_goal),
|
||||
method: :delete,
|
||||
destructive: true,
|
||||
confirm: CustomConfirm.for_resource_deletion(@savings_goal.name, high_severity: true)
|
||||
) %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<div class="space-y-4">
|
||||
<section class="bg-container rounded-xl shadow-border-xs p-6">
|
||||
<div class="flex flex-col md:flex-row items-start md:items-center gap-6">
|
||||
<div>
|
||||
<%= render Savings::ProgressRingComponent.new(goal: @savings_goal) %>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 space-y-3 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= render Savings::GoalAvatarComponent.new(goal: @savings_goal, size: "lg") %>
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-lg font-semibold text-primary truncate"><%= @savings_goal.name %></h1>
|
||||
<p class="text-xs text-secondary"><%= t("savings_goals.states.#{@savings_goal.state}") %></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3 text-sm">
|
||||
<div>
|
||||
<p class="text-xs text-secondary"><%= t(".stats.current") %></p>
|
||||
<p class="text-primary tabular-nums"><%= @savings_goal.current_balance_money.format %></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-secondary"><%= t(".stats.target") %></p>
|
||||
<p class="text-primary tabular-nums"><%= @savings_goal.target_amount_money.format %></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-secondary"><%= t(".stats.remaining") %></p>
|
||||
<p class="text-primary tabular-nums"><%= @savings_goal.remaining_amount_money.format %></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-secondary"><%= t(".stats.target_date") %></p>
|
||||
<p class="text-primary"><%= @savings_goal.target_date ? I18n.l(@savings_goal.target_date, format: :long) : t(".stats.no_target_date") %></p>
|
||||
</div>
|
||||
<% if @savings_goal.monthly_target_amount %>
|
||||
<div>
|
||||
<p class="text-xs text-secondary"><%= t(".stats.monthly_target") %></p>
|
||||
<p class="text-primary tabular-nums"><%= Money.new(@savings_goal.monthly_target_amount, @savings_goal.currency).format %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
<div>
|
||||
<p class="text-xs text-secondary"><%= t(".stats.status") %></p>
|
||||
<%= render Savings::StatusPillComponent.new(goal: @savings_goal) %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if @savings_goal.notes.present? %>
|
||||
<p class="text-sm text-secondary"><%= simple_format(@savings_goal.notes) %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%= render Savings::FundingAccountsBreakdownComponent.new(goal: @savings_goal, rows: @funding_breakdown) %>
|
||||
|
||||
<section class="bg-container rounded-xl shadow-border-xs p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-sm font-medium text-primary"><%= t(".contributions_heading") %></h3>
|
||||
<%= render DS::Link.new(
|
||||
text: t(".add_contribution"),
|
||||
variant: "primary",
|
||||
size: "sm",
|
||||
href: new_savings_goal_contribution_path(@savings_goal),
|
||||
icon: "plus",
|
||||
frame: :modal
|
||||
) %>
|
||||
</div>
|
||||
|
||||
<%= render "contributions_list", contributions: @contributions %>
|
||||
</section>
|
||||
</div>
|
||||
20
config/locales/models/savings_contribution/en.yml
Normal file
20
config/locales/models/savings_contribution/en.yml
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
en:
|
||||
activerecord:
|
||||
attributes:
|
||||
savings_contribution:
|
||||
amount: Amount
|
||||
currency: Currency
|
||||
contributed_at: Date
|
||||
source: Source
|
||||
notes: Notes
|
||||
account: Account
|
||||
errors:
|
||||
models:
|
||||
savings_contribution:
|
||||
attributes:
|
||||
currency:
|
||||
must_match_goal: Currency must match the goal's currency.
|
||||
account:
|
||||
must_belong_to_family: Account must belong to the same family as the goal.
|
||||
must_be_linked_to_goal: Account must be one of the goal's linked accounts.
|
||||
25
config/locales/models/savings_goal/en.yml
Normal file
25
config/locales/models/savings_goal/en.yml
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
en:
|
||||
activerecord:
|
||||
attributes:
|
||||
savings_goal:
|
||||
name: Name
|
||||
target_amount: Target amount
|
||||
currency: Currency
|
||||
target_date: Target date
|
||||
color: Color
|
||||
notes: Notes
|
||||
state: State
|
||||
linked_accounts: Linked accounts
|
||||
errors:
|
||||
models:
|
||||
savings_goal:
|
||||
attributes:
|
||||
base:
|
||||
at_least_one_linked_account_required: Pick at least one Depository account to fund this goal.
|
||||
linked_accounts:
|
||||
must_be_depository: All linked accounts must be Depository (checking, savings, HSA, CD, money-market).
|
||||
currency_mismatch: All linked accounts must share the same currency.
|
||||
must_belong_to_family: Linked accounts must belong to the same family as the goal.
|
||||
currency:
|
||||
locked_after_contributions: Can't change the currency after a goal has contributions.
|
||||
@@ -8,6 +8,7 @@ en:
|
||||
budgets: Budgets
|
||||
home: Home
|
||||
reports: Reports
|
||||
savings_goals: Savings goals
|
||||
transactions: Transactions
|
||||
auth:
|
||||
existing_account: Already have an account?
|
||||
|
||||
16
config/locales/views/savings_contributions/en.yml
Normal file
16
config/locales/views/savings_contributions/en.yml
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
en:
|
||||
savings_contributions:
|
||||
new:
|
||||
heading: Add contribution
|
||||
amount: Amount
|
||||
source_account: From account
|
||||
select_account: Select an account
|
||||
contributed_at: Date
|
||||
notes: Notes (optional)
|
||||
submit: Save contribution
|
||||
create:
|
||||
success: Contribution saved.
|
||||
destroy:
|
||||
success: Contribution deleted.
|
||||
initial_not_deletable: The initial contribution can't be deleted.
|
||||
107
config/locales/views/savings_goals/en.yml
Normal file
107
config/locales/views/savings_goals/en.yml
Normal file
@@ -0,0 +1,107 @@
|
||||
---
|
||||
en:
|
||||
savings_goals:
|
||||
index:
|
||||
title: Savings goals
|
||||
new_goal: New goal
|
||||
empty_filtered: No %{state} goals.
|
||||
tabs:
|
||||
all: All
|
||||
active: Active
|
||||
paused: Paused
|
||||
completed: Completed
|
||||
archived: Archived
|
||||
new:
|
||||
heading: New savings goal
|
||||
edit:
|
||||
heading: Edit savings goal
|
||||
save: Save changes
|
||||
create:
|
||||
success: Savings goal created.
|
||||
update:
|
||||
success: Savings goal updated.
|
||||
destroy:
|
||||
success: Savings goal deleted.
|
||||
archive_first: Archive the goal before deleting it.
|
||||
pause:
|
||||
success: Goal paused.
|
||||
invalid_transition: Goal can't be paused from its current state.
|
||||
resume:
|
||||
success: Goal resumed.
|
||||
invalid_transition: Goal can't be resumed from its current state.
|
||||
complete:
|
||||
success: Goal marked complete.
|
||||
invalid_transition: Goal can't be completed from its current state.
|
||||
archive:
|
||||
success: Goal archived.
|
||||
invalid_transition: Goal can't be archived from its current state.
|
||||
unarchive:
|
||||
success: Goal restored.
|
||||
invalid_transition: Goal can't be restored from its current state.
|
||||
show:
|
||||
edit: Edit
|
||||
pause: Pause
|
||||
resume: Resume
|
||||
complete: Mark complete
|
||||
archive: Archive
|
||||
unarchive: Restore
|
||||
delete: Delete permanently
|
||||
contributions_heading: Contributions
|
||||
add_contribution: Add contribution
|
||||
funding_accounts_heading: Funding accounts
|
||||
no_contributions_yet: No contributions yet.
|
||||
confirm_delete_contribution: Delete this contribution?
|
||||
source:
|
||||
initial: Initial
|
||||
manual: Manual
|
||||
stats:
|
||||
current: Current
|
||||
target: Target
|
||||
remaining: Remaining
|
||||
target_date: Target date
|
||||
no_target_date: No target date
|
||||
monthly_target: Per month
|
||||
status: Status
|
||||
states:
|
||||
active: Active
|
||||
paused: Paused
|
||||
completed: Completed
|
||||
archived: Archived
|
||||
status:
|
||||
on_track: On track
|
||||
behind: Behind
|
||||
reached: Reached
|
||||
no_target_date: No date
|
||||
empty_state:
|
||||
heading: No savings goals yet
|
||||
subtitle: Set a target and start saving toward it.
|
||||
new_goal: Create your first goal
|
||||
no_depository_accounts: You need at least one Depository account (checking, savings, HSA, CD, money-market) before creating a goal.
|
||||
add_account: Add an account
|
||||
goal_card:
|
||||
no_accounts: No linked accounts
|
||||
n_accounts: "%{first} +%{count}"
|
||||
form_stepper:
|
||||
continue: Continue
|
||||
back: Back
|
||||
submit: Create goal
|
||||
step1:
|
||||
heading: Identity
|
||||
fields:
|
||||
name: Name
|
||||
target_amount: Target amount
|
||||
target_date: Target date (optional)
|
||||
color: Color
|
||||
notes: Notes (optional)
|
||||
step2:
|
||||
heading: Review
|
||||
linked_accounts_heading: Linked accounts
|
||||
linked_accounts_hint: Pick at least one Depository account that funds this goal.
|
||||
add_initial_contribution: Add an initial contribution (optional)
|
||||
initial_amount: Amount
|
||||
initial_account: From account
|
||||
select_account: Select an account
|
||||
review_heading: Review
|
||||
review_target: Target
|
||||
review_date: Date
|
||||
review_accounts: Accounts
|
||||
@@ -252,6 +252,18 @@ Rails.application.routes.draw do
|
||||
resources :budget_categories, only: %i[index show update]
|
||||
end
|
||||
|
||||
resources :savings_goals do
|
||||
member do
|
||||
patch :pause
|
||||
patch :resume
|
||||
patch :complete
|
||||
patch :archive
|
||||
patch :unarchive
|
||||
end
|
||||
|
||||
resources :contributions, only: %i[new create destroy], controller: "savings_contributions"
|
||||
end
|
||||
|
||||
resources :family_merchants, only: %i[index new create edit update destroy] do
|
||||
collection do
|
||||
get :merge
|
||||
|
||||
27
db/migrate/20260511100000_create_savings_goals.rb
Normal file
27
db/migrate/20260511100000_create_savings_goals.rb
Normal file
@@ -0,0 +1,27 @@
|
||||
class CreateSavingsGoals < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
create_table :savings_goals, id: :uuid do |t|
|
||||
t.references :family, null: false, foreign_key: { on_delete: :cascade }, type: :uuid
|
||||
t.string :name, null: false
|
||||
t.decimal :target_amount, precision: 19, scale: 4, null: false
|
||||
t.string :currency, null: false
|
||||
t.date :target_date
|
||||
t.string :color
|
||||
t.text :notes
|
||||
t.string :state, null: false, default: "active"
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :savings_goals, [ :family_id, :state ]
|
||||
add_check_constraint :savings_goals,
|
||||
"char_length(name) <= 255",
|
||||
name: "chk_savings_goals_name_length"
|
||||
add_check_constraint :savings_goals,
|
||||
"target_amount > 0",
|
||||
name: "chk_savings_goals_target_amount_positive"
|
||||
add_check_constraint :savings_goals,
|
||||
"state IN ('active','paused','completed','archived')",
|
||||
name: "chk_savings_goals_state_enum"
|
||||
end
|
||||
end
|
||||
15
db/migrate/20260511100001_create_savings_goal_accounts.rb
Normal file
15
db/migrate/20260511100001_create_savings_goal_accounts.rb
Normal file
@@ -0,0 +1,15 @@
|
||||
class CreateSavingsGoalAccounts < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
create_table :savings_goal_accounts, id: :uuid do |t|
|
||||
t.references :savings_goal, null: false, foreign_key: { on_delete: :cascade }, type: :uuid
|
||||
t.references :account, null: false, foreign_key: { on_delete: :cascade }, type: :uuid
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :savings_goal_accounts,
|
||||
[ :savings_goal_id, :account_id ],
|
||||
unique: true,
|
||||
name: "index_savings_goal_accounts_on_goal_and_account"
|
||||
end
|
||||
end
|
||||
23
db/migrate/20260511100002_create_savings_contributions.rb
Normal file
23
db/migrate/20260511100002_create_savings_contributions.rb
Normal file
@@ -0,0 +1,23 @@
|
||||
class CreateSavingsContributions < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
create_table :savings_contributions, id: :uuid do |t|
|
||||
t.references :savings_goal, null: false, foreign_key: { on_delete: :cascade }, type: :uuid
|
||||
t.references :account, null: false, foreign_key: { on_delete: :cascade }, type: :uuid
|
||||
t.decimal :amount, precision: 19, scale: 4, null: false
|
||||
t.string :currency, null: false
|
||||
t.string :source, null: false, default: "manual"
|
||||
t.date :contributed_at, null: false
|
||||
t.text :notes
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :savings_contributions, [ :savings_goal_id, :contributed_at ]
|
||||
add_check_constraint :savings_contributions,
|
||||
"amount > 0",
|
||||
name: "chk_savings_contributions_amount_positive"
|
||||
add_check_constraint :savings_contributions,
|
||||
"source IN ('manual','initial')",
|
||||
name: "chk_savings_contributions_source_enum"
|
||||
end
|
||||
end
|
||||
58
db/schema.rb
generated
58
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_10_120000) do
|
||||
ActiveRecord::Schema[7.2].define(version: 2026_05_11_100002) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pgcrypto"
|
||||
enable_extension "plpgsql"
|
||||
@@ -1224,6 +1224,51 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_10_120000) do
|
||||
t.index ["family_id"], name: "index_rules_on_family_id"
|
||||
end
|
||||
|
||||
create_table "savings_contributions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.uuid "savings_goal_id", null: false
|
||||
t.uuid "account_id", null: false
|
||||
t.decimal "amount", precision: 19, scale: 4, null: false
|
||||
t.string "currency", null: false
|
||||
t.string "source", default: "manual", null: false
|
||||
t.date "contributed_at", null: false
|
||||
t.text "notes"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id"], name: "index_savings_contributions_on_account_id"
|
||||
t.index ["savings_goal_id", "contributed_at"], name: "idx_on_savings_goal_id_contributed_at_da0bf9e1fc"
|
||||
t.index ["savings_goal_id"], name: "index_savings_contributions_on_savings_goal_id"
|
||||
t.check_constraint "amount > 0::numeric", name: "chk_savings_contributions_amount_positive"
|
||||
t.check_constraint "source::text = ANY (ARRAY['manual'::character varying, 'initial'::character varying]::text[])", name: "chk_savings_contributions_source_enum"
|
||||
end
|
||||
|
||||
create_table "savings_goal_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.uuid "savings_goal_id", null: false
|
||||
t.uuid "account_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id"], name: "index_savings_goal_accounts_on_account_id"
|
||||
t.index ["savings_goal_id", "account_id"], name: "index_savings_goal_accounts_on_goal_and_account", unique: true
|
||||
t.index ["savings_goal_id"], name: "index_savings_goal_accounts_on_savings_goal_id"
|
||||
end
|
||||
|
||||
create_table "savings_goals", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.uuid "family_id", null: false
|
||||
t.string "name", null: false
|
||||
t.decimal "target_amount", precision: 19, scale: 4, null: false
|
||||
t.string "currency", null: false
|
||||
t.date "target_date"
|
||||
t.string "color"
|
||||
t.text "notes"
|
||||
t.string "state", default: "active", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["family_id", "state"], name: "index_savings_goals_on_family_id_and_state"
|
||||
t.index ["family_id"], name: "index_savings_goals_on_family_id"
|
||||
t.check_constraint "char_length(name::text) <= 255", name: "chk_savings_goals_name_length"
|
||||
t.check_constraint "state::text = ANY (ARRAY['active'::character varying, 'paused'::character varying, 'completed'::character varying, 'archived'::character varying]::text[])", name: "chk_savings_goals_state_enum"
|
||||
t.check_constraint "target_amount > 0::numeric", name: "chk_savings_goals_target_amount_positive"
|
||||
end
|
||||
|
||||
create_table "securities", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.string "ticker", null: false
|
||||
t.string "name"
|
||||
@@ -1249,7 +1294,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_10_120000) do
|
||||
t.index ["kind"], name: "index_securities_on_kind"
|
||||
t.index ["price_provider", "offline_reason"], name: "index_securities_on_price_provider_and_offline_reason"
|
||||
t.index ["price_provider"], name: "index_securities_on_price_provider"
|
||||
t.check_constraint "kind::text = ANY (ARRAY['standard'::character varying, 'cash'::character varying]::text[])", name: "chk_securities_kind"
|
||||
t.check_constraint "kind::text = ANY (ARRAY['standard'::character varying::text, 'cash'::character varying::text])", name: "chk_securities_kind"
|
||||
end
|
||||
|
||||
create_table "security_prices", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
@@ -1408,8 +1453,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_10_120000) do
|
||||
t.datetime "updated_at", null: false
|
||||
t.boolean "manual_sync", default: false, null: false
|
||||
t.index ["account_id"], name: "index_sophtron_accounts_on_account_id"
|
||||
t.index ["sophtron_item_id"], name: "index_sophtron_accounts_on_sophtron_item_id"
|
||||
t.index ["sophtron_item_id", "account_id"], name: "idx_unique_sophtron_accounts_per_item", unique: true
|
||||
t.index ["sophtron_item_id"], name: "index_sophtron_accounts_on_sophtron_item_id"
|
||||
end
|
||||
|
||||
create_table "sophtron_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
@@ -1664,9 +1709,9 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_10_120000) do
|
||||
t.datetime "last_used_at"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.check_constraint "sign_count >= 0", name: "chk_webauthn_credentials_sign_count_non_negative"
|
||||
t.index ["credential_id"], name: "index_webauthn_credentials_on_credential_id", unique: true
|
||||
t.index ["user_id"], name: "index_webauthn_credentials_on_user_id"
|
||||
t.check_constraint "sign_count >= 0", name: "chk_webauthn_credentials_sign_count_non_negative"
|
||||
end
|
||||
|
||||
add_foreign_key "account_providers", "accounts", on_delete: :cascade
|
||||
@@ -1741,6 +1786,11 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_10_120000) do
|
||||
add_foreign_key "rule_conditions", "rules"
|
||||
add_foreign_key "rule_runs", "rules"
|
||||
add_foreign_key "rules", "families"
|
||||
add_foreign_key "savings_contributions", "accounts", on_delete: :cascade
|
||||
add_foreign_key "savings_contributions", "savings_goals", on_delete: :cascade
|
||||
add_foreign_key "savings_goal_accounts", "accounts", on_delete: :cascade
|
||||
add_foreign_key "savings_goal_accounts", "savings_goals", on_delete: :cascade
|
||||
add_foreign_key "savings_goals", "families", on_delete: :cascade
|
||||
add_foreign_key "security_prices", "securities"
|
||||
add_foreign_key "sessions", "impersonation_sessions", column: "active_impersonator_session_id"
|
||||
add_foreign_key "sessions", "users"
|
||||
|
||||
58
test/controllers/savings_contributions_controller_test.rb
Normal file
58
test/controllers/savings_contributions_controller_test.rb
Normal file
@@ -0,0 +1,58 @@
|
||||
require "test_helper"
|
||||
|
||||
class SavingsContributionsControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
sign_in users(:family_admin)
|
||||
@goal = savings_goals(:vacation_italy)
|
||||
@depository = accounts(:depository)
|
||||
ensure_tailwind_build
|
||||
end
|
||||
|
||||
test "new renders the modal form" do
|
||||
get new_savings_goal_contribution_url(@goal)
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "create saves a manual contribution" do
|
||||
assert_difference -> { @goal.savings_contributions.count } => 1 do
|
||||
post savings_goal_contributions_url(@goal), params: {
|
||||
savings_contribution: {
|
||||
amount: "100",
|
||||
contributed_at: Date.current.iso8601,
|
||||
notes: ""
|
||||
},
|
||||
savings_contribution_account_id: @depository.id
|
||||
}.merge(savings_contribution: { account_id: @depository.id, amount: "100", contributed_at: Date.current.iso8601 })
|
||||
end
|
||||
|
||||
assert_redirected_to savings_goal_path(@goal)
|
||||
contribution = @goal.savings_contributions.order(created_at: :desc).first
|
||||
assert_equal "manual", contribution.source
|
||||
assert_equal @depository, contribution.account
|
||||
end
|
||||
|
||||
test "create rejects contribution from non-linked account" do
|
||||
unlinked = Account.create!(family: @goal.family, accountable: Depository.new, name: "Unlinked", currency: "USD", balance: 100)
|
||||
assert_no_difference "@goal.savings_contributions.count" do
|
||||
post savings_goal_contributions_url(@goal), params: {
|
||||
savings_contribution: { amount: "10", contributed_at: Date.current.iso8601, account_id: unlinked.id }
|
||||
}
|
||||
end
|
||||
assert_response :unprocessable_entity
|
||||
end
|
||||
|
||||
test "destroy manual contribution removes it" do
|
||||
manual = savings_contributions(:vacation_italy_manual)
|
||||
assert_difference "SavingsContribution.count", -1 do
|
||||
delete savings_goal_contribution_url(@goal, manual)
|
||||
end
|
||||
end
|
||||
|
||||
test "destroy initial contribution is blocked" do
|
||||
initial = savings_contributions(:vacation_italy_initial)
|
||||
assert_no_difference "SavingsContribution.count" do
|
||||
delete savings_goal_contribution_url(@goal, initial)
|
||||
end
|
||||
assert_redirected_to savings_goal_path(@goal)
|
||||
end
|
||||
end
|
||||
146
test/controllers/savings_goals_controller_test.rb
Normal file
146
test/controllers/savings_goals_controller_test.rb
Normal file
@@ -0,0 +1,146 @@
|
||||
require "test_helper"
|
||||
|
||||
class SavingsGoalsControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
sign_in users(:family_admin)
|
||||
@goal = savings_goals(:vacation_italy)
|
||||
@depository = accounts(:depository)
|
||||
@connected = accounts(:connected)
|
||||
ensure_tailwind_build
|
||||
end
|
||||
|
||||
test "index renders with active filter by default" do
|
||||
get savings_goals_url
|
||||
assert_response :success
|
||||
assert_match(/Savings goals/i, response.body)
|
||||
end
|
||||
|
||||
test "index honors state filter" do
|
||||
get savings_goals_url(state: "paused")
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "show renders the goal" do
|
||||
get savings_goal_url(@goal)
|
||||
assert_response :success
|
||||
assert_match(@goal.name, response.body)
|
||||
end
|
||||
|
||||
test "new renders the modal form" do
|
||||
get new_savings_goal_url
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "create persists a goal with linked accounts" do
|
||||
assert_difference -> { SavingsGoal.count } => 1,
|
||||
-> { SavingsGoalAccount.count } => 2 do
|
||||
post savings_goals_url, params: {
|
||||
savings_goal: {
|
||||
name: "New goal",
|
||||
target_amount: "1000",
|
||||
target_date: 3.months.from_now.to_date.iso8601,
|
||||
color: "#4da568",
|
||||
account_ids: [ @depository.id, @connected.id ]
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
goal = SavingsGoal.order(created_at: :desc).first
|
||||
assert_redirected_to savings_goal_path(goal)
|
||||
end
|
||||
|
||||
test "create with initial contribution writes the contribution" do
|
||||
assert_difference -> { SavingsContribution.count } => 1 do
|
||||
post savings_goals_url, params: {
|
||||
savings_goal: {
|
||||
name: "Goal with initial",
|
||||
target_amount: "1000",
|
||||
color: "#4da568",
|
||||
account_ids: [ @depository.id ],
|
||||
initial_contribution_amount: "50",
|
||||
initial_contribution_account_id: @depository.id
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
contribution = SavingsContribution.order(created_at: :desc).first
|
||||
assert_equal "initial", contribution.source
|
||||
assert_equal 50, contribution.amount.to_i
|
||||
end
|
||||
|
||||
test "create rejects missing account_ids" do
|
||||
assert_no_difference "SavingsGoal.count" do
|
||||
post savings_goals_url, params: {
|
||||
savings_goal: {
|
||||
name: "Bad goal",
|
||||
target_amount: "1000",
|
||||
color: "#4da568"
|
||||
}
|
||||
}
|
||||
end
|
||||
assert_response :unprocessable_entity
|
||||
end
|
||||
|
||||
test "create rejects foreign accounts" do
|
||||
other_family = Family.create!(name: "Other", currency: "USD", locale: "en", country: "US", timezone: "UTC")
|
||||
foreign = Account.create!(family: other_family, accountable: Depository.new, name: "Foreign", currency: "USD", balance: 100)
|
||||
|
||||
assert_no_difference "SavingsGoal.count" do
|
||||
post savings_goals_url, params: {
|
||||
savings_goal: {
|
||||
name: "Foreign goal",
|
||||
target_amount: "1000",
|
||||
color: "#4da568",
|
||||
account_ids: [ foreign.id ]
|
||||
}
|
||||
}
|
||||
end
|
||||
assert_response :unprocessable_entity
|
||||
end
|
||||
|
||||
test "update modifies identity fields" do
|
||||
patch savings_goal_url(@goal), params: { savings_goal: { name: "Renamed" } }
|
||||
assert_redirected_to savings_goal_path(@goal)
|
||||
assert_equal "Renamed", @goal.reload.name
|
||||
end
|
||||
|
||||
test "pause/resume/complete/archive/unarchive flow" do
|
||||
fresh = savings_goals(:emergency_fund)
|
||||
patch pause_savings_goal_url(fresh)
|
||||
assert fresh.reload.paused?
|
||||
patch resume_savings_goal_url(fresh)
|
||||
assert fresh.reload.active?
|
||||
patch complete_savings_goal_url(fresh)
|
||||
assert fresh.reload.completed?
|
||||
patch archive_savings_goal_url(fresh)
|
||||
assert fresh.reload.archived?
|
||||
patch unarchive_savings_goal_url(fresh)
|
||||
assert fresh.reload.active?
|
||||
end
|
||||
|
||||
test "destroy on non-archived is rejected" do
|
||||
assert_no_difference "SavingsGoal.count" do
|
||||
delete savings_goal_url(@goal)
|
||||
end
|
||||
assert_redirected_to savings_goal_path(@goal)
|
||||
end
|
||||
|
||||
test "destroy on archived deletes" do
|
||||
@goal.archive!
|
||||
assert_difference "SavingsGoal.count", -1 do
|
||||
delete savings_goal_url(@goal)
|
||||
end
|
||||
assert_redirected_to savings_goals_path
|
||||
end
|
||||
|
||||
test "another family's goal returns 404" do
|
||||
other_family = Family.create!(name: "Other", currency: "USD", locale: "en", country: "US", timezone: "UTC")
|
||||
other_account = Account.create!(family: other_family, accountable: Depository.new, name: "Foreign", currency: "USD", balance: 100)
|
||||
other_goal = other_family.savings_goals.new(name: "Foreign goal", target_amount: 100, currency: "USD")
|
||||
other_goal.savings_goal_accounts.build(account: other_account)
|
||||
other_goal.save!
|
||||
|
||||
get savings_goal_url(other_goal)
|
||||
assert_response :not_found
|
||||
end
|
||||
end
|
||||
23
test/fixtures/savings_contributions.yml
vendored
Normal file
23
test/fixtures/savings_contributions.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
vacation_italy_initial:
|
||||
savings_goal: vacation_italy
|
||||
account: depository
|
||||
amount: 500
|
||||
currency: USD
|
||||
source: initial
|
||||
contributed_at: <%= 90.days.ago.to_date %>
|
||||
|
||||
vacation_italy_manual:
|
||||
savings_goal: vacation_italy
|
||||
account: connected
|
||||
amount: 250
|
||||
currency: USD
|
||||
source: manual
|
||||
contributed_at: <%= 30.days.ago.to_date %>
|
||||
|
||||
emergency_fund_initial:
|
||||
savings_goal: emergency_fund
|
||||
account: depository
|
||||
amount: 1000
|
||||
currency: USD
|
||||
source: initial
|
||||
contributed_at: <%= 180.days.ago.to_date %>
|
||||
15
test/fixtures/savings_goal_accounts.yml
vendored
Normal file
15
test/fixtures/savings_goal_accounts.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
vacation_italy_depository:
|
||||
savings_goal: vacation_italy
|
||||
account: depository
|
||||
|
||||
vacation_italy_connected:
|
||||
savings_goal: vacation_italy
|
||||
account: connected
|
||||
|
||||
emergency_fund_depository:
|
||||
savings_goal: emergency_fund
|
||||
account: depository
|
||||
|
||||
car_paydown_depository:
|
||||
savings_goal: car_paydown
|
||||
account: depository
|
||||
25
test/fixtures/savings_goals.yml
vendored
Normal file
25
test/fixtures/savings_goals.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
vacation_italy:
|
||||
family: dylan_family
|
||||
name: Vacation in Italy
|
||||
target_amount: 5000
|
||||
currency: USD
|
||||
target_date: <%= 4.months.from_now.to_date %>
|
||||
color: "#4da568"
|
||||
state: active
|
||||
|
||||
emergency_fund:
|
||||
family: dylan_family
|
||||
name: Emergency fund
|
||||
target_amount: 10000
|
||||
currency: USD
|
||||
color: "#6471eb"
|
||||
state: active
|
||||
|
||||
car_paydown:
|
||||
family: dylan_family
|
||||
name: Paid-off car
|
||||
target_amount: 8000
|
||||
currency: USD
|
||||
target_date: <%= 12.months.from_now.to_date %>
|
||||
color: "#e99537"
|
||||
state: paused
|
||||
103
test/models/assistant/function/create_savings_goal_test.rb
Normal file
103
test/models/assistant/function/create_savings_goal_test.rb
Normal file
@@ -0,0 +1,103 @@
|
||||
require "test_helper"
|
||||
|
||||
class Assistant::Function::CreateSavingsGoalTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@user = users(:family_admin)
|
||||
@family = @user.family
|
||||
@depository = accounts(:depository)
|
||||
@fn = Assistant::Function::CreateSavingsGoal.new(@user)
|
||||
end
|
||||
|
||||
test "to_definition returns valid JSON shape" do
|
||||
definition = @fn.to_definition
|
||||
assert_equal "create_savings_goal", definition[:name]
|
||||
assert_kind_of String, definition[:description]
|
||||
assert_equal "object", definition[:params_schema][:type]
|
||||
assert_includes definition[:params_schema][:required], "name"
|
||||
assert_includes definition[:params_schema][:required], "target_amount"
|
||||
assert_includes definition[:params_schema][:required], "linked_account_names"
|
||||
end
|
||||
|
||||
test "creates a goal with linked accounts" do
|
||||
assert_difference -> { SavingsGoal.count } => 1,
|
||||
-> { SavingsGoalAccount.count } => 1 do
|
||||
result = @fn.call(
|
||||
"name" => "Vacation",
|
||||
"target_amount" => 1500,
|
||||
"target_date" => 3.months.from_now.to_date.iso8601,
|
||||
"linked_account_names" => [ @depository.name ]
|
||||
)
|
||||
|
||||
assert result[:success]
|
||||
assert_match(/Vacation/, result[:message])
|
||||
assert result[:url].present?
|
||||
assert_equal "USD", result[:currency]
|
||||
end
|
||||
end
|
||||
|
||||
test "creates a goal with initial contribution" do
|
||||
assert_difference -> { SavingsContribution.count } => 1 do
|
||||
@fn.call(
|
||||
"name" => "Laptop fund",
|
||||
"target_amount" => 2000,
|
||||
"linked_account_names" => [ @depository.name ],
|
||||
"initial_contribution" => { "amount" => 200, "source_account_name" => @depository.name }
|
||||
)
|
||||
end
|
||||
|
||||
contribution = SavingsContribution.order(created_at: :desc).first
|
||||
assert_equal "initial", contribution.source
|
||||
assert_equal 200, contribution.amount.to_i
|
||||
end
|
||||
|
||||
test "soft error when name is missing" do
|
||||
result = @fn.call("target_amount" => 100, "linked_account_names" => [ @depository.name ])
|
||||
assert_equal false, result[:success]
|
||||
assert_equal "name_required", result[:error]
|
||||
end
|
||||
|
||||
test "soft error when target_amount is zero" do
|
||||
result = @fn.call("name" => "X", "target_amount" => 0, "linked_account_names" => [ @depository.name ])
|
||||
assert_equal false, result[:success]
|
||||
assert_equal "target_amount_invalid", result[:error]
|
||||
end
|
||||
|
||||
test "soft error when no linked accounts" do
|
||||
result = @fn.call("name" => "X", "target_amount" => 100, "linked_account_names" => [])
|
||||
assert_equal false, result[:success]
|
||||
assert_equal "no_linked_accounts", result[:error]
|
||||
assert_kind_of Array, result[:available_accounts]
|
||||
assert(result[:available_accounts].all? { |a| a.is_a?(Hash) && a.key?(:name) })
|
||||
end
|
||||
|
||||
test "soft error when account name doesn't match" do
|
||||
result = @fn.call("name" => "X", "target_amount" => 100, "linked_account_names" => [ "Nonexistent Account" ])
|
||||
assert_equal false, result[:success]
|
||||
assert_equal "unknown_accounts", result[:error]
|
||||
assert_includes result[:unknown_names], "Nonexistent Account"
|
||||
end
|
||||
|
||||
test "soft error when currencies differ across linked accounts" do
|
||||
eur = Account.create!(family: @family, accountable: Depository.new, name: "EUR Account", currency: "EUR", balance: 100)
|
||||
result = @fn.call(
|
||||
"name" => "Mixed",
|
||||
"target_amount" => 100,
|
||||
"linked_account_names" => [ @depository.name, eur.name ]
|
||||
)
|
||||
assert_equal false, result[:success]
|
||||
assert_equal "currency_mismatch", result[:error]
|
||||
end
|
||||
|
||||
test "scopes to the user's family" do
|
||||
other_family = Family.create!(name: "Other", currency: "USD", locale: "en", country: "US", timezone: "UTC")
|
||||
Account.create!(family: other_family, accountable: Depository.new, name: "Foreign Checking", currency: "USD", balance: 100)
|
||||
|
||||
result = @fn.call(
|
||||
"name" => "X",
|
||||
"target_amount" => 100,
|
||||
"linked_account_names" => [ "Foreign Checking" ]
|
||||
)
|
||||
assert_equal false, result[:success]
|
||||
assert_equal "unknown_accounts", result[:error]
|
||||
end
|
||||
end
|
||||
46
test/models/savings_contribution_test.rb
Normal file
46
test/models/savings_contribution_test.rb
Normal file
@@ -0,0 +1,46 @@
|
||||
require "test_helper"
|
||||
|
||||
class SavingsContributionTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@goal = savings_goals(:vacation_italy)
|
||||
@depository = accounts(:depository)
|
||||
end
|
||||
|
||||
test "valid fixture contribution saves" do
|
||||
assert savings_contributions(:vacation_italy_initial).valid?
|
||||
end
|
||||
|
||||
test "amount must be positive" do
|
||||
c = @goal.savings_contributions.new(account: @depository, amount: 0, currency: "USD", source: "manual", contributed_at: Date.current)
|
||||
assert_not c.valid?
|
||||
end
|
||||
|
||||
test "source must be manual or initial" do
|
||||
c = @goal.savings_contributions.new(account: @depository, amount: 10, currency: "USD", source: "auto", contributed_at: Date.current)
|
||||
assert_not c.valid?
|
||||
end
|
||||
|
||||
test "currency syncs from goal when blank" do
|
||||
c = @goal.savings_contributions.new(account: @depository, amount: 10, source: "manual", contributed_at: Date.current)
|
||||
c.valid?
|
||||
assert_equal @goal.currency, c.currency
|
||||
end
|
||||
|
||||
test "account must be linked to goal" do
|
||||
other_depository = Account.create!(
|
||||
family: @goal.family,
|
||||
accountable: Depository.new,
|
||||
name: "Unlinked Depository",
|
||||
currency: "USD",
|
||||
balance: 100
|
||||
)
|
||||
c = @goal.savings_contributions.new(account: other_depository, amount: 10, currency: "USD", source: "manual", contributed_at: Date.current)
|
||||
assert_not c.valid?
|
||||
assert_includes c.errors[:account], "Account must be one of the goal's linked accounts."
|
||||
end
|
||||
|
||||
test "manual? and initial? predicates" do
|
||||
assert savings_contributions(:vacation_italy_initial).initial?
|
||||
assert savings_contributions(:vacation_italy_manual).manual?
|
||||
end
|
||||
end
|
||||
135
test/models/savings_goal_test.rb
Normal file
135
test/models/savings_goal_test.rb
Normal file
@@ -0,0 +1,135 @@
|
||||
require "test_helper"
|
||||
|
||||
class SavingsGoalTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@family = families(:dylan_family)
|
||||
@depository = accounts(:depository)
|
||||
@connected = accounts(:connected)
|
||||
@goal = savings_goals(:vacation_italy)
|
||||
end
|
||||
|
||||
test "valid fixture goal saves" do
|
||||
assert @goal.valid?
|
||||
end
|
||||
|
||||
test "name is required" do
|
||||
@goal.name = ""
|
||||
assert_not @goal.valid?
|
||||
assert_includes @goal.errors[:name], "can't be blank"
|
||||
end
|
||||
|
||||
test "target_amount must be positive" do
|
||||
@goal.target_amount = 0
|
||||
assert_not @goal.valid?
|
||||
end
|
||||
|
||||
test "must have at least one linked account on create" do
|
||||
new_goal = @family.savings_goals.new(name: "Test", target_amount: 100, currency: "USD")
|
||||
assert_not new_goal.valid?
|
||||
assert_match(/at least one/i, new_goal.errors[:base].join)
|
||||
end
|
||||
|
||||
test "linked accounts must be depository" do
|
||||
investment = accounts(:investment)
|
||||
new_goal = @family.savings_goals.new(name: "Test", target_amount: 100, currency: "USD")
|
||||
new_goal.savings_goal_accounts.build(account: investment)
|
||||
assert_not new_goal.valid?
|
||||
assert_includes new_goal.errors[:linked_accounts], "All linked accounts must be Depository (checking, savings, HSA, CD, money-market)."
|
||||
end
|
||||
|
||||
test "linked accounts must belong to family" do
|
||||
other_family = Family.create!(name: "Other", currency: "USD", locale: "en", country: "US", timezone: "UTC")
|
||||
foreign_account = Account.create!(
|
||||
family: other_family,
|
||||
accountable: Depository.new,
|
||||
name: "Foreign",
|
||||
currency: "USD",
|
||||
balance: 100
|
||||
)
|
||||
new_goal = @family.savings_goals.new(name: "T", target_amount: 100, currency: "USD")
|
||||
new_goal.savings_goal_accounts.build(account: foreign_account)
|
||||
assert_not new_goal.valid?
|
||||
assert_includes new_goal.errors[:linked_accounts], "Linked accounts must belong to the same family as the goal."
|
||||
end
|
||||
|
||||
test "linked accounts must share currency with goal" do
|
||||
eur_account = Account.create!(
|
||||
family: @family,
|
||||
accountable: Depository.new,
|
||||
name: "Euro Cash",
|
||||
currency: "EUR",
|
||||
balance: 100
|
||||
)
|
||||
new_goal = @family.savings_goals.new(name: "T", target_amount: 100, currency: "USD")
|
||||
new_goal.savings_goal_accounts.build(account: eur_account)
|
||||
assert_not new_goal.valid?
|
||||
assert_includes new_goal.errors[:linked_accounts], "All linked accounts must share the same currency."
|
||||
end
|
||||
|
||||
test "currency can't change after contributions exist" do
|
||||
assert @goal.savings_contributions.exists?
|
||||
@goal.currency = "EUR"
|
||||
assert_not @goal.valid?
|
||||
assert_includes @goal.errors[:currency], "Can't change the currency after a goal has contributions."
|
||||
end
|
||||
|
||||
test "current_balance sums contributions" do
|
||||
expected = @goal.savings_contributions.sum(:amount)
|
||||
assert_equal expected, @goal.current_balance
|
||||
end
|
||||
|
||||
test "with_current_balance scope precomputes balance" do
|
||||
loaded = @family.savings_goals.with_current_balance.find(@goal.id)
|
||||
expected = @goal.savings_contributions.sum(:amount)
|
||||
assert_equal expected.to_f, loaded.current_balance.to_f
|
||||
end
|
||||
|
||||
test "progress_percent caps at 100" do
|
||||
@goal.target_amount = 1
|
||||
assert_equal 100, @goal.progress_percent
|
||||
end
|
||||
|
||||
test "progress_percent is 0 for empty active goal" do
|
||||
fresh = savings_goals(:car_paydown)
|
||||
fresh.target_amount = 10000
|
||||
assert_equal 0, fresh.progress_percent
|
||||
end
|
||||
|
||||
test "remaining_amount is non-negative" do
|
||||
@goal.target_amount = 1
|
||||
assert_equal 0, @goal.remaining_amount
|
||||
end
|
||||
|
||||
test "AASM transitions" do
|
||||
fresh = savings_goals(:emergency_fund)
|
||||
assert fresh.active?
|
||||
fresh.pause!
|
||||
assert fresh.paused?
|
||||
fresh.resume!
|
||||
assert fresh.active?
|
||||
fresh.complete!
|
||||
assert fresh.completed?
|
||||
fresh.archive!
|
||||
assert fresh.archived?
|
||||
fresh.unarchive!
|
||||
assert fresh.active?
|
||||
end
|
||||
|
||||
test "status: reached when balance >= target" do
|
||||
@goal.target_amount = 1
|
||||
assert_equal :reached, @goal.status
|
||||
end
|
||||
|
||||
test "status: no_target_date when target_date is nil" do
|
||||
@goal.target_date = nil
|
||||
@goal.target_amount = 10_000
|
||||
assert_equal :no_target_date, @goal.status
|
||||
end
|
||||
|
||||
test "advisory_lock_key_for is stable per family" do
|
||||
k1 = SavingsGoal.advisory_lock_key_for(@family.id)
|
||||
k2 = SavingsGoal.advisory_lock_key_for(@family.id)
|
||||
assert_equal k1, k2
|
||||
assert_kind_of Integer, k1
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user