-
Notifications
You must be signed in to change notification settings - Fork 7
feat(savings): add savings goals #1569
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
gariasf
wants to merge
33
commits into
we-promise:main
Choose a base branch
from
gariasf:feature/savings-goals
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 20 commits
Commits
Show all changes
33 commits
Select commit
Hold shift + click to select a range
7d21a06
feat(savings): add savings_goals and savings_contributions tables
gariasf 334c580
feat(savings): add SavingsGoal and SavingsContribution models
gariasf c04c9df
feat(savings): wire Budget#monthly_surplus and Family#savings_summary…
gariasf 199ba99
feat(savings): add AutoFundJob and monthly cron orchestrator
gariasf 13c11bd
feat(savings): link SavingsGoal to a backing asset Account
gariasf 92e499b
feat(savings): add controllers, routes, and placeholder views
gariasf f9a23ba
feat(savings): real UI — progress ring, goal cards, forms, budget sub…
gariasf 284d172
fix(savings): UX polish and demo seed data
gariasf ac682b2
fix(savings): surface "New goal" on the budget page itself
gariasf 67bcd4a
fix(savings): IA cleanup, fix dead nav buttons, add breadcrumbs
gariasf 9800512
fix(savings): drop "Apply to budget month" field from contribution form
gariasf 8abeae1
fix(savings): label icon-only buttons + reorder index tabs
gariasf 4f069fc
fix(savings): align with Sure design tokens and idiomatic patterns
gariasf e34e1ea
fix(savings): render new/edit/contribution forms as modals
gariasf eab5cdf
fix(savings): redesign with Sure conventions, dropping PR #833 guidance
gariasf 2e80ce5
test(savings): cover gaps + harden mass-assignment / advisory-lock SQL
gariasf e15d2bd
fix(savings): render ring labels via HTML overlay, matching budget donut
gariasf 37d4f9a
fix(savings): plug multi-currency + account-swap edge cases, add edge…
gariasf b5ea5fe
fix(savings): honor family month_start_day in budget resolution
gariasf d9e51a1
fix(savings): address coderabbit review (real bugs + cleanup)
gariasf 5efffc5
fix(savings): savepoint each AutoFundJob create + drop dead || 0
gariasf 3cfc687
i18n(savings): route all user-facing strings through t()
gariasf 69c8112
fix(savings): correct breadcrumbs when re-rendering :new / :edit
gariasf 657aeb2
fix(savings): cascade budget delete + gate auto-fund button on fundab…
gariasf 4a584c7
chore(savings): align form input types with Sure conventions + CLAUDE…
gariasf 4e19261
perf(savings): memoize per-instance derived values + eager-load goal.…
gariasf 2efa44a
perf(savings): grouped balance prefetch + bulk-enqueue the auto-fund …
gariasf e40341a
fix(savings): unify advisory-lock key so manual writes serialize with…
gariasf 254d668
fix(savings): handle narrow-viewport overflow on tab row and budget-c…
gariasf 8ddc496
Merge remote-tracking branch 'upstream/main' into feature/savings-goals
gariasf 8f790af
Merge branch 'main' into feature/savings-goals
gariasf 602615e
Merge branch 'main' into feature/savings-goals
gariasf 44811fc
Merge branch 'main' into feature/savings-goals
gariasf File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| <%= link_to savings_goal_path(goal), | ||
| class: "block bg-container rounded-xl shadow-border-xs p-5 hover:shadow-border-sm transition" do %> | ||
| <div class="flex items-start gap-4"> | ||
| <%= render Savings::ProgressRingComponent.new(percent: goal.progress_percent, size: 72, stroke: 6, color: goal.color) %> | ||
| <div class="grow min-w-0"> | ||
| <div class="flex items-center justify-between gap-2"> | ||
| <h3 class="text-primary font-medium truncate"><%= goal.name %></h3> | ||
| <span class="text-xs px-2 py-0.5 rounded-full font-medium <%= state_badge_classes %>"> | ||
| <%= state_label %> | ||
| </span> | ||
| </div> | ||
| <p class="text-secondary text-sm mt-1 truncate"><%= target_summary %></p> | ||
| <p class="text-primary text-sm mt-2 font-medium"> | ||
| <%= helpers.format_money goal.current_balance_money %> | ||
| <span class="text-secondary font-normal">/ <%= helpers.format_money target_amount_money %></span> | ||
| </p> | ||
| </div> | ||
| </div> | ||
| <% end %> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| class Savings::GoalCardComponent < ApplicationComponent | ||
| attr_reader :goal | ||
|
|
||
| def initialize(goal:) | ||
| @goal = goal | ||
| end | ||
|
|
||
| def state_badge_classes | ||
| case goal.state | ||
| when "active" then "bg-success/10 text-success" | ||
| when "paused" then "bg-warning/10 text-warning" | ||
| when "completed" then "bg-success/20 text-success" | ||
| when "archived" then "bg-container-inset text-secondary" | ||
| else "bg-container-inset text-secondary" | ||
| end | ||
| end | ||
|
|
||
| def target_summary | ||
| return goal.account.name if goal.target_date.nil? | ||
| "#{goal.target_date.strftime('%b %Y')} · #{goal.account.name}" | ||
| end | ||
|
|
||
| def state_label | ||
| goal.state.titleize | ||
| end | ||
|
|
||
| def target_amount_money | ||
| Money.new(goal.target_amount, goal.currency) | ||
| end | ||
| end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| <%# Wrapper sets ring footprint; SVG is absolutely positioned so text overlay | ||
| can flex-centre regardless of ring size. Same overlay pattern Sure uses | ||
| for the budget donut (`app/views/budgets/_budget_donut.html.erb`). %> | ||
| <%= tag.div class: "relative shrink-0 inline-flex items-center justify-center", | ||
| style: "width: #{size}px; height: #{size}px;" do %> | ||
| <%= tag.svg width: size, height: size, viewBox: "0 0 #{size} #{size}", | ||
| role: "img", "aria-label": "#{percent}% complete", | ||
| class: "absolute inset-0" do %> | ||
| <%= tag.circle cx: size / 2.0, cy: size / 2.0, r: radius, | ||
| fill: "transparent", | ||
| class: "stroke-current text-gray-300", | ||
| "stroke-width": stroke %> | ||
| <%= tag.circle cx: size / 2.0, cy: size / 2.0, r: radius, | ||
| fill: "transparent", | ||
| style: "stroke: #{fill_stroke_color}", | ||
| "stroke-width": stroke, | ||
| "stroke-linecap": "round", | ||
| "stroke-dasharray": circumference, | ||
| "stroke-dashoffset": offset, | ||
| transform: "rotate(-90 #{size / 2.0} #{size / 2.0})" %> | ||
| <% end %> | ||
|
|
||
| <%# HTML text overlay — Tailwind typography, easy to control across | ||
| ring sizes. First line is the primary value; subsequent lines are | ||
| small secondary captions. %> | ||
| <div class="relative flex flex-col items-center justify-center text-center leading-tight px-2"> | ||
| <% label_lines.each_with_index do |line, idx| %> | ||
| <span class="<%= idx.zero? ? "text-primary font-medium #{primary_size_class}" : "text-secondary text-xs" %>"> | ||
| <%= line %> | ||
| </span> | ||
| <% end %> | ||
| </div> | ||
| <% end %> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| class Savings::ProgressRingComponent < ApplicationComponent | ||
| attr_reader :percent, :size, :stroke, :color, :label_lines | ||
|
|
||
| # `label_lines` is an array of strings rendered as <tspan> rows inside the | ||
| # ring. Defaults to `["#{percent}%"]` so existing call sites (goal cards, | ||
| # budget summary card) keep their compact percent display. The goal show | ||
| # page passes a richer ["$1,250", "of $6,000", "21%"] for the larger ring. | ||
| def initialize(percent:, size: 80, stroke: 6, color: nil, label_lines: nil) | ||
| @percent = percent.to_i.clamp(0, 100) | ||
| @size = size | ||
| @stroke = stroke | ||
| @color = color | ||
| @label_lines = Array(label_lines || [ "#{@percent}%" ]) | ||
| end | ||
|
|
||
| def radius | ||
| (size - stroke) / 2.0 | ||
| end | ||
|
|
||
| def circumference | ||
| 2 * Math::PI * radius | ||
| end | ||
|
|
||
| def offset | ||
| circumference - (circumference * percent / 100.0) | ||
| end | ||
|
|
||
| # CSS vars resolve fine inside an inline `style=` attribute (Sure's | ||
| # shared/_progress_circle uses the same trick), unlike raw SVG `stroke=` | ||
| # attribute values where the spec is fussier. | ||
| def fill_stroke_color | ||
| return color if color.present? | ||
| case percent | ||
| when 0..24 then "var(--color-gray-400)" # barely started | ||
| when 25..74 then "var(--color-blue-500)" # in progress | ||
| else "var(--color-success)" # near or at target | ||
| end | ||
| end | ||
|
|
||
| # Scales the primary line's font size with the ring size so a 72px goal | ||
| # card shows a compact "21%" while the 180px show-page ring shows a | ||
| # full "$1,250" without spilling out of the inner area. | ||
| def primary_size_class | ||
| case size | ||
| when 0..80 then "text-xs" | ||
| when 81..120 then "text-sm" | ||
| when 121..160 then "text-base" | ||
| else "text-lg" | ||
| end | ||
| end | ||
| end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| module Budgets | ||
| class SavingsController < ApplicationController | ||
| before_action :set_budget | ||
|
|
||
| def auto_fund | ||
| SavingsGoals::AutoFundJob.perform_later(Current.family.id, @budget.id) | ||
| redirect_to budget_path(Budget.date_to_param(@budget.start_date)), | ||
| notice: "Auto-funding has been queued." | ||
| end | ||
|
|
||
| private | ||
| # Pass `family:` so families with a non-default `month_start_day` | ||
| # parse the route param to their own boundary (e.g. the 15th) | ||
| # instead of falling through to `beginning_of_month`. Mirrors the | ||
| # upstream BudgetsController#set_budget signature. | ||
| def set_budget | ||
| start_date = Budget.param_to_date(params[:budget_month_year], family: Current.family) | ||
| @budget = Budget.find_or_bootstrap(Current.family, start_date: start_date, user: Current.user) | ||
| raise ActiveRecord::RecordNotFound unless @budget | ||
| end | ||
| end | ||
| end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| class SavingsContributionsController < ApplicationController | ||
| before_action :set_savings_goal | ||
| before_action :set_contribution, only: :destroy | ||
| before_action :set_breadcrumbs | ||
|
|
||
|
|
||
| def new | ||
| @contribution = @savings_goal.savings_contributions.new(contributed_at: Date.current) | ||
| end | ||
|
|
||
| def create | ||
| @contribution = @savings_goal.savings_contributions.new(contribution_params) | ||
| @contribution.source = "manual" | ||
| @contribution.contributed_at ||= Date.current | ||
|
|
||
| if save_with_advisory_lock(@contribution) | ||
| flash[:notice] = "Contribution added." | ||
| respond_to do |format| | ||
| format.html { redirect_to savings_goal_path(@savings_goal) } | ||
| format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, savings_goal_path(@savings_goal)) } | ||
| end | ||
| else | ||
| render :new, status: :unprocessable_entity | ||
| end | ||
| end | ||
|
|
||
| def destroy | ||
| @contribution.destroy | ||
| redirect_to savings_goal_path(@savings_goal), notice: "Contribution removed." | ||
| end | ||
|
|
||
| private | ||
| def set_breadcrumbs | ||
| @breadcrumbs = [ | ||
| [ "Home", root_path ], | ||
| [ "Savings goals", savings_goals_path ], | ||
| [ @savings_goal.name, savings_goal_path(@savings_goal) ], | ||
| [ "Add contribution", nil ] | ||
| ] | ||
| end | ||
|
|
||
| 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, :notes, :contributed_at) | ||
| end | ||
|
|
||
| # Wraps the create in a Postgres advisory xact lock so concurrent | ||
| # contribution attempts on the same family serialize cleanly. The | ||
| # partial unique index on (savings_goal_id, budget_id) for source=auto | ||
| # handles auto-vs-auto races at the DB level; this lock keeps manual | ||
| # contributions tidy too. | ||
| def save_with_advisory_lock(contribution) | ||
| key = Digest::SHA1.hexdigest("savings_contribution:#{Current.family.id}").to_i(16) % (2**63) | ||
| saved = false | ||
| Family.transaction do | ||
| ActiveRecord::Base.connection.execute( | ||
| ActiveRecord::Base.sanitize_sql_array([ "SELECT pg_advisory_xact_lock(?)", key ]) | ||
| ) | ||
| saved = contribution.save | ||
| end | ||
| saved | ||
| end | ||
| end | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,150 @@ | ||
| class SavingsGoalsController < ApplicationController | ||
| before_action :set_savings_goal, only: %i[show edit update destroy pause resume complete archive unarchive] | ||
| before_action :set_backing_accounts, only: %i[new create edit update] | ||
| before_action :set_breadcrumbs | ||
|
|
||
|
|
||
| def index | ||
| state = params[:state].presence_in(%w[all active paused completed archived]) || "all" | ||
| scope = Current.family.savings_goals.alphabetically | ||
| @savings_goals = state == "all" ? scope : scope.where(state: state) | ||
| @state = state | ||
| end | ||
|
|
||
| def show | ||
| end | ||
|
|
||
| def new | ||
| @savings_goal = Current.family.savings_goals.new(state: "active") | ||
| end | ||
|
|
||
| def create | ||
| @savings_goal = Current.family.savings_goals.new(savings_goal_params) | ||
| @savings_goal.account = lookup_account(params.dig(:savings_goal, :account_id)) | ||
|
|
||
| begin | ||
| ActiveRecord::Base.transaction do | ||
| @savings_goal.save! | ||
| handle_initial_contribution(@savings_goal) | ||
| end | ||
| rescue ActiveRecord::RecordInvalid | ||
| return render :new, status: :unprocessable_entity | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| end | ||
|
|
||
| flash[:notice] = "Savings goal created." | ||
| respond_to do |format| | ||
| format.html { redirect_to savings_goal_path(@savings_goal) } | ||
| format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, savings_goal_path(@savings_goal)) } | ||
| end | ||
| end | ||
|
|
||
| def edit | ||
| end | ||
|
|
||
| def update | ||
| submitted_account_id = params.dig(:savings_goal, :account_id) | ||
| if submitted_account_id.present? | ||
| candidate = lookup_account(submitted_account_id) | ||
| # Only swap to a non-nil family-scoped account. A foreign account_id | ||
| # returns nil from `lookup_account`; assigning nil would null the | ||
| # `belongs_to :account` association and block unrelated attribute | ||
| # changes (e.g. a name edit) in the same request. Silently ignoring | ||
| # the foreign id keeps the rest of the update flowing through. | ||
| @savings_goal.account = candidate if candidate | ||
| end | ||
|
gariasf marked this conversation as resolved.
|
||
|
|
||
| if @savings_goal.update(savings_goal_params) | ||
| flash[:notice] = "Savings goal updated." | ||
| respond_to do |format| | ||
| format.html { redirect_to savings_goal_path(@savings_goal) } | ||
| format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, savings_goal_path(@savings_goal)) } | ||
| end | ||
| else | ||
| render :edit, status: :unprocessable_entity | ||
| end | ||
| end | ||
|
|
||
| def destroy | ||
| @savings_goal.destroy | ||
| redirect_to savings_goals_path, notice: "Savings goal deleted." | ||
| end | ||
|
|
||
| def pause | ||
| transition!(:pause!, "Goal paused.") | ||
| end | ||
|
|
||
| def resume | ||
| transition!(:resume!, "Goal resumed.") | ||
| end | ||
|
|
||
| def complete | ||
| transition!(:complete!, "Goal marked as completed.") | ||
| end | ||
|
|
||
| def archive | ||
| transition!(:archive!, "Goal archived.") | ||
| end | ||
|
|
||
| def unarchive | ||
| transition!(:unarchive!, "Goal restored to active.") | ||
| end | ||
|
|
||
| private | ||
| def set_breadcrumbs | ||
| crumbs = [ [ "Home", root_path ], [ "Savings goals", savings_goals_path ] ] | ||
| if @savings_goal&.persisted? | ||
| crumbs << [ @savings_goal.name, savings_goal_path(@savings_goal) ] if action_name != "show" | ||
| crumbs << [ @savings_goal.name, nil ] if action_name == "show" | ||
| crumbs << [ "Edit", nil ] if action_name == "edit" | ||
| elsif action_name == "new" | ||
| crumbs << [ "New goal", nil ] | ||
| else | ||
| crumbs.last[1] = nil | ||
| end | ||
| @breadcrumbs = crumbs | ||
| end | ||
|
|
||
| def set_savings_goal | ||
| @savings_goal = Current.family.savings_goals.find(params[:id]) | ||
| end | ||
|
|
||
| def set_backing_accounts | ||
| @backing_accounts = Current.family.accounts | ||
| .where(classification: "asset", | ||
| accountable_type: %w[Depository Investment OtherAsset]) | ||
| .alphabetically | ||
| end | ||
|
|
||
| def savings_goal_params | ||
| # `account_id` is intentionally omitted from the permit list. We | ||
| # assign `@savings_goal.account` manually via `lookup_account`, which | ||
| # scopes the lookup to `Current.family.accounts`. Permitting account_id | ||
| # here would let mass-assignment bypass that check. | ||
| params.require(:savings_goal).permit( | ||
| :name, :target_amount, :target_date, :color, :icon, :notes | ||
| ) | ||
| end | ||
|
|
||
| # Scopes the lookup so a foreign account_id never silently associates. | ||
| def lookup_account(account_id) | ||
| return nil if account_id.blank? | ||
| Current.family.accounts.find_by(id: account_id) | ||
| end | ||
|
|
||
| def handle_initial_contribution(goal) | ||
| amount = params.dig(:savings_goal, :initial_contribution).to_d | ||
| return unless amount.positive? | ||
| goal.savings_contributions.create!( | ||
| amount: amount, | ||
| source: "initial", | ||
| contributed_at: Date.current | ||
| ) | ||
| end | ||
|
|
||
| def transition!(event, message) | ||
| @savings_goal.public_send(event) | ||
| redirect_to savings_goal_path(@savings_goal), notice: message | ||
| rescue AASM::InvalidTransition => e | ||
| redirect_to savings_goal_path(@savings_goal), alert: e.message | ||
| end | ||
| end | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.