From 77660d2ee4d33f602a114ec8bd8c943df3ea8049 Mon Sep 17 00:00:00 2001 From: Guillem Arias Date: Mon, 11 May 2026 11:20:37 +0200 Subject: [PATCH] feat(savings): add savings goals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- ...ding_accounts_breakdown_component.html.erb | 25 +++ .../funding_accounts_breakdown_component.rb | 17 ++ .../savings/goal_avatar_component.html.erb | 5 + .../savings/goal_avatar_component.rb | 29 +++ .../savings/goal_card_component.html.erb | 28 +++ app/components/savings/goal_card_component.rb | 30 +++ .../savings/progress_ring_component.html.erb | 27 +++ .../savings/progress_ring_component.rb | 36 ++++ .../savings/status_pill_component.html.erb | 4 + .../savings/status_pill_component.rb | 32 +++ .../savings_contributions_controller.rb | 58 ++++++ app/controllers/savings_goals_controller.rb | 167 ++++++++++++++++ .../savings_goal_stepper_controller.js | 124 ++++++++++++ app/models/account.rb | 3 + app/models/assistant.rb | 3 +- .../assistant/function/create_savings_goal.rb | 184 +++++++++++++++++ app/models/demo/generator.rb | 83 ++++++++ app/models/family.rb | 3 + app/models/savings_contribution.rb | 56 ++++++ app/models/savings_goal.rb | 185 ++++++++++++++++++ app/models/savings_goal_account.rb | 6 + app/views/layouts/application.html.erb | 1 + app/views/savings_contributions/new.html.erb | 33 ++++ .../_contributions_list.html.erb | 30 +++ app/views/savings_goals/_empty_state.html.erb | 29 +++ app/views/savings_goals/_form_edit.html.erb | 43 ++++ .../savings_goals/_form_stepper.html.erb | 134 +++++++++++++ app/views/savings_goals/edit.html.erb | 6 + app/views/savings_goals/index.html.erb | 43 ++++ app/views/savings_goals/new.html.erb | 6 + app/views/savings_goals/show.html.erb | 139 +++++++++++++ .../models/savings_contribution/en.yml | 20 ++ config/locales/models/savings_goal/en.yml | 25 +++ config/locales/views/layout/en.yml | 1 + .../views/savings_contributions/en.yml | 16 ++ config/locales/views/savings_goals/en.yml | 107 ++++++++++ config/routes.rb | 12 ++ .../20260511100000_create_savings_goals.rb | 27 +++ ...0511100001_create_savings_goal_accounts.rb | 15 ++ ...0511100002_create_savings_contributions.rb | 23 +++ db/schema.rb | 58 +++++- .../savings_contributions_controller_test.rb | 58 ++++++ .../savings_goals_controller_test.rb | 146 ++++++++++++++ test/fixtures/savings_contributions.yml | 23 +++ test/fixtures/savings_goal_accounts.yml | 15 ++ test/fixtures/savings_goals.yml | 25 +++ .../function/create_savings_goal_test.rb | 103 ++++++++++ test/models/savings_contribution_test.rb | 46 +++++ test/models/savings_goal_test.rb | 135 +++++++++++++ 49 files changed, 2419 insertions(+), 5 deletions(-) create mode 100644 app/components/savings/funding_accounts_breakdown_component.html.erb create mode 100644 app/components/savings/funding_accounts_breakdown_component.rb create mode 100644 app/components/savings/goal_avatar_component.html.erb create mode 100644 app/components/savings/goal_avatar_component.rb create mode 100644 app/components/savings/goal_card_component.html.erb create mode 100644 app/components/savings/goal_card_component.rb create mode 100644 app/components/savings/progress_ring_component.html.erb create mode 100644 app/components/savings/progress_ring_component.rb create mode 100644 app/components/savings/status_pill_component.html.erb create mode 100644 app/components/savings/status_pill_component.rb create mode 100644 app/controllers/savings_contributions_controller.rb create mode 100644 app/controllers/savings_goals_controller.rb create mode 100644 app/javascript/controllers/savings_goal_stepper_controller.js create mode 100644 app/models/assistant/function/create_savings_goal.rb create mode 100644 app/models/savings_contribution.rb create mode 100644 app/models/savings_goal.rb create mode 100644 app/models/savings_goal_account.rb create mode 100644 app/views/savings_contributions/new.html.erb create mode 100644 app/views/savings_goals/_contributions_list.html.erb create mode 100644 app/views/savings_goals/_empty_state.html.erb create mode 100644 app/views/savings_goals/_form_edit.html.erb create mode 100644 app/views/savings_goals/_form_stepper.html.erb create mode 100644 app/views/savings_goals/edit.html.erb create mode 100644 app/views/savings_goals/index.html.erb create mode 100644 app/views/savings_goals/new.html.erb create mode 100644 app/views/savings_goals/show.html.erb create mode 100644 config/locales/models/savings_contribution/en.yml create mode 100644 config/locales/models/savings_goal/en.yml create mode 100644 config/locales/views/savings_contributions/en.yml create mode 100644 config/locales/views/savings_goals/en.yml create mode 100644 db/migrate/20260511100000_create_savings_goals.rb create mode 100644 db/migrate/20260511100001_create_savings_goal_accounts.rb create mode 100644 db/migrate/20260511100002_create_savings_contributions.rb create mode 100644 test/controllers/savings_contributions_controller_test.rb create mode 100644 test/controllers/savings_goals_controller_test.rb create mode 100644 test/fixtures/savings_contributions.yml create mode 100644 test/fixtures/savings_goal_accounts.yml create mode 100644 test/fixtures/savings_goals.yml create mode 100644 test/models/assistant/function/create_savings_goal_test.rb create mode 100644 test/models/savings_contribution_test.rb create mode 100644 test/models/savings_goal_test.rb diff --git a/app/components/savings/funding_accounts_breakdown_component.html.erb b/app/components/savings/funding_accounts_breakdown_component.html.erb new file mode 100644 index 000000000..d44c3e2f7 --- /dev/null +++ b/app/components/savings/funding_accounts_breakdown_component.html.erb @@ -0,0 +1,25 @@ +
+

<%= t("savings_goals.show.funding_accounts_heading") %>

+ + <% if total.zero? %> +

<%= t("savings_goals.show.no_contributions_yet") %>

+ <% else %> +
+ <% rows.each do |row| %> +
+ <%= render Savings::GoalAvatarComponent.new(name: row[:account].name, color: goal.color, size: "sm") %> +
+
+ <%= row[:account].name %> + <%= row[:money].format %> +
+
+
+
+
+ <%= percent_for(row[:amount]) %>% +
+ <% end %> +
+ <% end %> +
diff --git a/app/components/savings/funding_accounts_breakdown_component.rb b/app/components/savings/funding_accounts_breakdown_component.rb new file mode 100644 index 000000000..93843cad4 --- /dev/null +++ b/app/components/savings/funding_accounts_breakdown_component.rb @@ -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 diff --git a/app/components/savings/goal_avatar_component.html.erb b/app/components/savings/goal_avatar_component.html.erb new file mode 100644 index 000000000..da1ec91f9 --- /dev/null +++ b/app/components/savings/goal_avatar_component.html.erb @@ -0,0 +1,5 @@ + + <%= initial %> + diff --git a/app/components/savings/goal_avatar_component.rb b/app/components/savings/goal_avatar_component.rb new file mode 100644 index 000000000..afdee63fd --- /dev/null +++ b/app/components/savings/goal_avatar_component.rb @@ -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 diff --git a/app/components/savings/goal_card_component.html.erb b/app/components/savings/goal_card_component.html.erb new file mode 100644 index 000000000..23b292140 --- /dev/null +++ b/app/components/savings/goal_card_component.html.erb @@ -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 %> +
+ <%= render Savings::GoalAvatarComponent.new(goal: goal, size: "md") %> +
+
+

<%= goal.name %>

+ <%= render Savings::StatusPillComponent.new(goal: goal) %> +
+

<%= linked_accounts_label %>

+
+
+ +
+
+ <%= goal.current_balance_money.format %> + / <%= goal.target_amount_money.format %> +
+
+
+
+
+ <%= progress_percent %>% + <% if goal.target_date %> + <%= I18n.l(goal.target_date, format: :long) %> + <% end %> +
+
+<% end %> diff --git a/app/components/savings/goal_card_component.rb b/app/components/savings/goal_card_component.rb new file mode 100644 index 000000000..738f5b6a3 --- /dev/null +++ b/app/components/savings/goal_card_component.rb @@ -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 diff --git a/app/components/savings/progress_ring_component.html.erb b/app/components/savings/progress_ring_component.html.erb new file mode 100644 index 000000000..871fab614 --- /dev/null +++ b/app/components/savings/progress_ring_component.html.erb @@ -0,0 +1,27 @@ +
+ + + + +
+ <%= percent %>% + <%= current_label %> + of <%= target_label %> +
+
diff --git a/app/components/savings/progress_ring_component.rb b/app/components/savings/progress_ring_component.rb new file mode 100644 index 000000000..b67f4b9ae --- /dev/null +++ b/app/components/savings/progress_ring_component.rb @@ -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 diff --git a/app/components/savings/status_pill_component.html.erb b/app/components/savings/status_pill_component.html.erb new file mode 100644 index 000000000..8db06d50a --- /dev/null +++ b/app/components/savings/status_pill_component.html.erb @@ -0,0 +1,4 @@ + + <%= helpers.icon(icon_name, size: "xs", color: "current") %> + <%= label %> + diff --git a/app/components/savings/status_pill_component.rb b/app/components/savings/status_pill_component.rb new file mode 100644 index 000000000..fa26f5575 --- /dev/null +++ b/app/components/savings/status_pill_component.rb @@ -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 diff --git a/app/controllers/savings_contributions_controller.rb b/app/controllers/savings_contributions_controller.rb new file mode 100644 index 000000000..ee61d78a8 --- /dev/null +++ b/app/controllers/savings_contributions_controller.rb @@ -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 diff --git a/app/controllers/savings_goals_controller.rb b/app/controllers/savings_goals_controller.rb new file mode 100644 index 000000000..6e432fa6b --- /dev/null +++ b/app/controllers/savings_goals_controller.rb @@ -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 diff --git a/app/javascript/controllers/savings_goal_stepper_controller.js b/app/javascript/controllers/savings_goal_stepper_controller.js new file mode 100644 index 000000000..913d5be69 --- /dev/null +++ b/app/javascript/controllers/savings_goal_stepper_controller.js @@ -0,0 +1,124 @@ +import { Controller } from "@hotwired/stimulus"; + +// 2-step modal stepper for creating a savings goal. +// +// Single
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); + } + } +} diff --git a/app/models/account.rb b/app/models/account.rb index 8a31a8d5e..40802311b 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -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 diff --git a/app/models/assistant.rb b/app/models/assistant.rb index b07009396..370f25d29 100644 --- a/app/models/assistant.rb +++ b/app/models/assistant.rb @@ -28,7 +28,8 @@ module Assistant Function::GetBalanceSheet, Function::GetIncomeStatement, Function::ImportBankStatement, - Function::SearchFamilyFiles + Function::SearchFamilyFiles, + Function::CreateSavingsGoal ] end diff --git a/app/models/assistant/function/create_savings_goal.rb b/app/models/assistant/function/create_savings_goal.rb new file mode 100644 index 000000000..2a053ad83 --- /dev/null +++ b/app/models/assistant/function/create_savings_goal.rb @@ -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 diff --git a/app/models/demo/generator.rb b/app/models/demo/generator.rb index 474a1095c..0b35b9a40 100644 --- a/app/models/demo/generator.rb +++ b/app/models/demo/generator.rb @@ -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 diff --git a/app/models/family.rb b/app/models/family.rb index 3a3547b58..3ccdc380e 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -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 diff --git a/app/models/savings_contribution.rb b/app/models/savings_contribution.rb new file mode 100644 index 000000000..c74b9219c --- /dev/null +++ b/app/models/savings_contribution.rb @@ -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 diff --git a/app/models/savings_goal.rb b/app/models/savings_goal.rb new file mode 100644 index 000000000..85626a745 --- /dev/null +++ b/app/models/savings_goal.rb @@ -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 diff --git a/app/models/savings_goal_account.rb b/app/models/savings_goal_account.rb new file mode 100644 index 000000000..6ade6ff66 --- /dev/null +++ b/app/models/savings_goal_account.rb @@ -0,0 +1,6 @@ +class SavingsGoalAccount < ApplicationRecord + belongs_to :savings_goal + belongs_to :account + + validates :account_id, uniqueness: { scope: :savings_goal_id } +end diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 50174161d..3b8364ed3 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -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 %> diff --git a/app/views/savings_contributions/new.html.erb b/app/views/savings_contributions/new.html.erb new file mode 100644 index 000000000..2b5a9b702 --- /dev/null +++ b/app/views/savings_contributions/new.html.erb @@ -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 %> + +
+ <%= f.submit t(".submit") %> +
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/savings_goals/_contributions_list.html.erb b/app/views/savings_goals/_contributions_list.html.erb new file mode 100644 index 000000000..4bf162596 --- /dev/null +++ b/app/views/savings_goals/_contributions_list.html.erb @@ -0,0 +1,30 @@ +<%# locals: (contributions:) %> + +<% if contributions.empty? %> +

<%= t("savings_goals.show.no_contributions_yet") %>

+<% else %> + +<% end %> diff --git a/app/views/savings_goals/_empty_state.html.erb b/app/views/savings_goals/_empty_state.html.erb new file mode 100644 index 000000000..c4f116d73 --- /dev/null +++ b/app/views/savings_goals/_empty_state.html.erb @@ -0,0 +1,29 @@ +<%# locals: (linkable_account_count:) %> + +
+
+
+ <%= icon("piggy-bank", size: "lg") %> +
+

<%= t("savings_goals.empty_state.heading") %>

+

<%= t("savings_goals.empty_state.subtitle") %>

+ + <% 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 %> +

<%= t("savings_goals.empty_state.no_depository_accounts") %>

+ <%= render DS::Link.new( + text: t("savings_goals.empty_state.add_account"), + variant: "primary", + href: new_account_path, + icon: "plus" + ) %> + <% end %> +
+
diff --git a/app/views/savings_goals/_form_edit.html.erb b/app/views/savings_goals/_form_edit.html.erb new file mode 100644 index 000000000..3bf4fc25c --- /dev/null +++ b/app/views/savings_goals/_form_edit.html.erb @@ -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") %> + +
+ <%= t("savings_goals.form_stepper.step1.fields.color") %> +
+ <% SavingsGoal::COLORS.each do |c| %> + + <% end %> +
+
+ + <%= f.text_area :notes, + label: t("savings_goals.form_stepper.step1.fields.notes"), + rows: 2 %> + +
+ <%= f.submit t("savings_goals.edit.save") %> +
+<% end %> diff --git a/app/views/savings_goals/_form_stepper.html.erb b/app/views/savings_goals/_form_stepper.html.erb new file mode 100644 index 000000000..05105d51c --- /dev/null +++ b/app/views/savings_goals/_form_stepper.html.erb @@ -0,0 +1,134 @@ +<%# locals: (savings_goal:, linkable_accounts:) %> + +
+ <% if savings_goal.errors.any? %> + <%= render "shared/form_errors", model: savings_goal %> + <% end %> + +
    +
  1. + 1 + <%= t("savings_goals.form_stepper.step1.heading") %> +
  2. + +
  3. + 2 + <%= t("savings_goals.form_stepper.step2.heading") %> +
  4. +
+ + <%= styled_form_with model: savings_goal, url: savings_goals_path, class: "space-y-4" do |f| %> +
+ <%= 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") %> + +
+ <%= t("savings_goals.form_stepper.step1.fields.color") %> +
+ <% SavingsGoal::COLORS.each do |c| %> + + <% end %> +
+
+ + <%= f.text_area :notes, + label: t("savings_goals.form_stepper.step1.fields.notes"), + rows: 2 %> + +
+ <%= render DS::Button.new( + text: t("savings_goals.form_stepper.continue"), + variant: "primary", + data: { action: "click->savings-goal-stepper#next" } + ) %> +
+
+ + + <% end %> +
diff --git a/app/views/savings_goals/edit.html.erb b/app/views/savings_goals/edit.html.erb new file mode 100644 index 000000000..8c553d687 --- /dev/null +++ b/app/views/savings_goals/edit.html.erb @@ -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 %> diff --git a/app/views/savings_goals/index.html.erb b/app/views/savings_goals/index.html.erb new file mode 100644 index 000000000..40489c9c0 --- /dev/null +++ b/app/views/savings_goals/index.html.erb @@ -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 %> +
+ + + <% if @savings_goals.any? %> +
+ <% @savings_goals.each do |goal| %> + <%= render Savings::GoalCardComponent.new(goal: goal) %> + <% end %> +
+ <% else %> +
+

<%= t(".empty_filtered", state: t(".tabs.#{@state_filter}").downcase) %>

+
+ <% end %> +
+<% end %> diff --git a/app/views/savings_goals/new.html.erb b/app/views/savings_goals/new.html.erb new file mode 100644 index 000000000..ac813f46a --- /dev/null +++ b/app/views/savings_goals/new.html.erb @@ -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 %> diff --git a/app/views/savings_goals/show.html.erb b/app/views/savings_goals/show.html.erb new file mode 100644 index 000000000..50b5e6298 --- /dev/null +++ b/app/views/savings_goals/show.html.erb @@ -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 %> + +
+
+
+
+ <%= render Savings::ProgressRingComponent.new(goal: @savings_goal) %> +
+ +
+
+ <%= render Savings::GoalAvatarComponent.new(goal: @savings_goal, size: "lg") %> +
+

<%= @savings_goal.name %>

+

<%= t("savings_goals.states.#{@savings_goal.state}") %>

+
+
+ +
+
+

<%= t(".stats.current") %>

+

<%= @savings_goal.current_balance_money.format %>

+
+
+

<%= t(".stats.target") %>

+

<%= @savings_goal.target_amount_money.format %>

+
+
+

<%= t(".stats.remaining") %>

+

<%= @savings_goal.remaining_amount_money.format %>

+
+
+

<%= t(".stats.target_date") %>

+

<%= @savings_goal.target_date ? I18n.l(@savings_goal.target_date, format: :long) : t(".stats.no_target_date") %>

+
+ <% if @savings_goal.monthly_target_amount %> +
+

<%= t(".stats.monthly_target") %>

+

<%= Money.new(@savings_goal.monthly_target_amount, @savings_goal.currency).format %>

+
+ <% end %> +
+

<%= t(".stats.status") %>

+ <%= render Savings::StatusPillComponent.new(goal: @savings_goal) %> +
+
+ + <% if @savings_goal.notes.present? %> +

<%= simple_format(@savings_goal.notes) %>

+ <% end %> +
+
+
+ + <%= render Savings::FundingAccountsBreakdownComponent.new(goal: @savings_goal, rows: @funding_breakdown) %> + +
+
+

<%= t(".contributions_heading") %>

+ <%= render DS::Link.new( + text: t(".add_contribution"), + variant: "primary", + size: "sm", + href: new_savings_goal_contribution_path(@savings_goal), + icon: "plus", + frame: :modal + ) %> +
+ + <%= render "contributions_list", contributions: @contributions %> +
+
diff --git a/config/locales/models/savings_contribution/en.yml b/config/locales/models/savings_contribution/en.yml new file mode 100644 index 000000000..a64957db9 --- /dev/null +++ b/config/locales/models/savings_contribution/en.yml @@ -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. diff --git a/config/locales/models/savings_goal/en.yml b/config/locales/models/savings_goal/en.yml new file mode 100644 index 000000000..72304d0a4 --- /dev/null +++ b/config/locales/models/savings_goal/en.yml @@ -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. diff --git a/config/locales/views/layout/en.yml b/config/locales/views/layout/en.yml index 86ad78ace..5346c2cad 100644 --- a/config/locales/views/layout/en.yml +++ b/config/locales/views/layout/en.yml @@ -8,6 +8,7 @@ en: budgets: Budgets home: Home reports: Reports + savings_goals: Savings goals transactions: Transactions auth: existing_account: Already have an account? diff --git a/config/locales/views/savings_contributions/en.yml b/config/locales/views/savings_contributions/en.yml new file mode 100644 index 000000000..bae799294 --- /dev/null +++ b/config/locales/views/savings_contributions/en.yml @@ -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. diff --git a/config/locales/views/savings_goals/en.yml b/config/locales/views/savings_goals/en.yml new file mode 100644 index 000000000..db67484b4 --- /dev/null +++ b/config/locales/views/savings_goals/en.yml @@ -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 diff --git a/config/routes.rb b/config/routes.rb index 3a59f340b..bcf5ae7cb 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/db/migrate/20260511100000_create_savings_goals.rb b/db/migrate/20260511100000_create_savings_goals.rb new file mode 100644 index 000000000..23c5281b2 --- /dev/null +++ b/db/migrate/20260511100000_create_savings_goals.rb @@ -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 diff --git a/db/migrate/20260511100001_create_savings_goal_accounts.rb b/db/migrate/20260511100001_create_savings_goal_accounts.rb new file mode 100644 index 000000000..8794aaa58 --- /dev/null +++ b/db/migrate/20260511100001_create_savings_goal_accounts.rb @@ -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 diff --git a/db/migrate/20260511100002_create_savings_contributions.rb b/db/migrate/20260511100002_create_savings_contributions.rb new file mode 100644 index 000000000..3f0d1ca66 --- /dev/null +++ b/db/migrate/20260511100002_create_savings_contributions.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index b40605ab9..9f8cf9a7a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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" diff --git a/test/controllers/savings_contributions_controller_test.rb b/test/controllers/savings_contributions_controller_test.rb new file mode 100644 index 000000000..f07eadc7f --- /dev/null +++ b/test/controllers/savings_contributions_controller_test.rb @@ -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 diff --git a/test/controllers/savings_goals_controller_test.rb b/test/controllers/savings_goals_controller_test.rb new file mode 100644 index 000000000..c95bb565a --- /dev/null +++ b/test/controllers/savings_goals_controller_test.rb @@ -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 diff --git a/test/fixtures/savings_contributions.yml b/test/fixtures/savings_contributions.yml new file mode 100644 index 000000000..3f2ada0fc --- /dev/null +++ b/test/fixtures/savings_contributions.yml @@ -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 %> diff --git a/test/fixtures/savings_goal_accounts.yml b/test/fixtures/savings_goal_accounts.yml new file mode 100644 index 000000000..1982634e4 --- /dev/null +++ b/test/fixtures/savings_goal_accounts.yml @@ -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 diff --git a/test/fixtures/savings_goals.yml b/test/fixtures/savings_goals.yml new file mode 100644 index 000000000..6c0faafaa --- /dev/null +++ b/test/fixtures/savings_goals.yml @@ -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 diff --git a/test/models/assistant/function/create_savings_goal_test.rb b/test/models/assistant/function/create_savings_goal_test.rb new file mode 100644 index 000000000..0946103b6 --- /dev/null +++ b/test/models/assistant/function/create_savings_goal_test.rb @@ -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 diff --git a/test/models/savings_contribution_test.rb b/test/models/savings_contribution_test.rb new file mode 100644 index 000000000..0a920bd7b --- /dev/null +++ b/test/models/savings_contribution_test.rb @@ -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 diff --git a/test/models/savings_goal_test.rb b/test/models/savings_goal_test.rb new file mode 100644 index 000000000..e78eceace --- /dev/null +++ b/test/models/savings_goal_test.rb @@ -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