diff --git a/app/components/goals/card_component.rb b/app/components/goals/card_component.rb index e7676c517..8270dd3d6 100644 --- a/app/components/goals/card_component.rb +++ b/app/components/goals/card_component.rb @@ -71,7 +71,7 @@ class Goals::CardComponent < ApplicationComponent def pace_line return nil if goal.archived? || goal.paused? || goal.completed? || goal.status == :reached - avg = Money.new(goal.average_monthly_contribution, goal.currency).format + avg = goal.pace_money.format target = goal.monthly_target_amount ? Money.new(goal.monthly_target_amount, goal.currency).format : nil if target I18n.t("goals.goal_card.pace_with_target", avg: avg, target: target) @@ -93,9 +93,9 @@ class Goals::CardComponent < ApplicationComponent elsif goal.status == :no_target_date I18n.t("goals.goal_card.footer_no_deadline") else - days = goal.last_contribution_days_ago + days = goal.last_matched_pledge_days_ago if days.nil? - I18n.t("goals.goal_card.footer_no_contributions") + I18n.t("goals.goal_card.footer_no_pledges") elsif days.zero? I18n.t("goals.goal_card.footer_last_today") else diff --git a/app/components/goals/funding_accounts_breakdown_component.html.erb b/app/components/goals/funding_accounts_breakdown_component.html.erb index a40934758..addddf882 100644 --- a/app/components/goals/funding_accounts_breakdown_component.html.erb +++ b/app/components/goals/funding_accounts_breakdown_component.html.erb @@ -1,61 +1,44 @@ -<% if total.zero? %> -

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

+<% if rows.empty? %> +

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

<% else %>
-

+

<%= t("goals.show.funding_accounts_heading") %> · - <%= Money.new(total, goal.currency).format(precision: 0) %> + <%= goal.current_balance_money.format %>

-
+
<% rows.each do |row| %> - <% next if row[:amount].to_d.zero? %> -
- <% end %> -
- -
- <% rows.each do |row| %> - <% next if row[:amount].to_d.zero? %> -
-
-

<%= row[:account].name %>

-

<%= percent_for(row[:amount]) %>%

-
- <% end %> -
- -
-
-
<%= t("goals.show.funding_table.name") %>
-
-

<%= t("goals.show.funding_table.weight") %>

-

<%= t("goals.show.funding_table.value") %>

-
-
- -
- <% rows.each_with_index do |row, idx| %> -
-
- <%= render Goals::AvatarComponent.new(name: row[:account].name, color: Goals::AvatarComponent.color_for(row[:account].name), size: "sm") %> -

<%= row[:account].name %>

-
-
-
- <%= render "pages/dashboard/group_weight", weight: percent_for(row[:amount]), color: Goals::AvatarComponent.color_for(row[:account].name) %> -
-
-

<%= row[:money].format(precision: 0) %>

-
-
+ <% account = row[:account] %> + <% spark = row[:sparkline_points] %> + <% spark_max = [ spark.max, 1.0 ].max %> +
+
+ <%= account.name[0]&.upcase %>
- <% if idx < rows.size - 1 %> - <%= render "shared/ruler", classes: "mx-4" %> - <% end %> - <% end %> -
+ +
+

<%= account.name %>

+

<%= account.accountable_type %> · <%= row[:balance_money].format %>

+
+ + + <% xs = spark.each_with_index.map { |_, i| 2 + (i / (spark.size - 1).to_f) * (spark.size * 8 - 4) } %> + <% ys = spark.map { |v| 24 - (v / spark_max) * 20 } %> + <% path = xs.zip(ys).each_with_index.map { |(x, y), i| "#{i.zero? ? "M" : "L"} #{x.round(2)} #{y.round(2)}" }.join(" ") %> + + + +
+

+ <%= row[:last_30_money].format %> +

+

<%= t("goals.show.funding_last_30d") %>

+
+
+ <% end %>
<% end %> diff --git a/app/components/goals/funding_accounts_breakdown_component.rb b/app/components/goals/funding_accounts_breakdown_component.rb index 4b8ec4f23..9d323a6e0 100644 --- a/app/components/goals/funding_accounts_breakdown_component.rb +++ b/app/components/goals/funding_accounts_breakdown_component.rb @@ -1,17 +1,58 @@ class Goals::FundingAccountsBreakdownComponent < ApplicationComponent - def initialize(goal:, rows:) + WINDOW_DAYS = 30 + SPARK_WINDOW_DAYS = 90 + + def initialize(goal:) @goal = goal - @rows = rows end - attr_reader :goal, :rows + attr_reader :goal - def total - @total ||= rows.sum { |r| r[:amount].to_d } + def rows + @rows ||= goal.linked_accounts.sort_by { |a| -last_30_inflow_for(a) }.map do |account| + inflow = last_30_inflow_for(account) + { + account: account, + balance_money: Money.new(account.balance.to_d, goal.currency), + last_30_money: Money.new(inflow, goal.currency), + last_30_amount: inflow, + sparkline_points: sparkline_for(account) + } + end end - def percent_for(amount) - return 0 if total.zero? - ((amount.to_d / total) * 100).round - end + private + def last_30_inflow_for(account) + @inflow_cache ||= {} + @inflow_cache[account.id] ||= begin + net = Entry + .joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'") + .where(account_id: account.id, date: WINDOW_DAYS.days.ago.to_date..Date.current) + .where.not(transactions: { kind: Transaction::TRANSFER_KINDS }) + .where(excluded: false) + .sum(:amount) + (-net.to_d).clamp(0, Float::INFINITY) + end + end + + # 12-bucket weekly sparkline of net non-transfer inflow over 90 days. + def sparkline_for(account) + buckets = 12 + bucket_days = (SPARK_WINDOW_DAYS / buckets.to_f).ceil + + buckets.times.map do |i| + start_at = (SPARK_WINDOW_DAYS - (i + 1) * bucket_days).days.ago.to_date + end_at = (SPARK_WINDOW_DAYS - i * bucket_days).days.ago.to_date + net = Entry + .joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'") + .where(account_id: account.id, date: start_at..end_at) + .where.not(transactions: { kind: Transaction::TRANSFER_KINDS }) + .where(excluded: false) + .sum(:amount) + (-net.to_d).clamp(0, Float::INFINITY).to_f + end + rescue StandardError => e + Rails.logger.warn("Sparkline for account #{account.id} failed: #{e.message}") + Array.new(buckets, 0.0) + end end diff --git a/app/controllers/goal_contributions_controller.rb b/app/controllers/goal_contributions_controller.rb deleted file mode 100644 index 17cdeedca..000000000 --- a/app/controllers/goal_contributions_controller.rb +++ /dev/null @@ -1,64 +0,0 @@ -class GoalContributionsController < ApplicationController - before_action :set_goal - before_action :set_contribution, only: :destroy - rescue_from ActiveRecord::RecordNotFound, with: :record_not_found - - def new - @contribution = @goal.goal_contributions.new( - contributed_at: Date.current, - currency: @goal.currency, - source: "manual", - amount: params[:amount].presence - ) - end - - def create - @contribution = @goal.goal_contributions.new(contribution_params.merge(source: "manual")) - @contribution.account = lookup_account(params.dig(:goal_contribution, :account_id)) - @contribution.currency = @goal.currency - - if @contribution.save - flash[:notice] = t(".success") - respond_to do |format| - format.html { redirect_to goal_path(@goal) } - format.turbo_stream do - render turbo_stream: turbo_stream.action(:redirect, goal_path(@goal)) - end - end - else - render :new, status: :unprocessable_entity - end - end - - def destroy - if @contribution.initial? - redirect_to goal_path(@goal), alert: t(".initial_not_deletable") - return - end - - @contribution.destroy! - redirect_to goal_path(@goal), notice: t(".success") - end - - private - def set_goal - @goal = Current.family.goals.find(params[:goal_id]) - end - - def set_contribution - @contribution = @goal.goal_contributions.find(params[:id]) - end - - def contribution_params - params.require(:goal_contribution).permit(:amount, :contributed_at, :notes) - end - - def lookup_account(id) - return nil if id.blank? - @goal.linked_accounts.find_by(id: id) - end - - def record_not_found - redirect_to goals_path, alert: t("goals.errors.not_found") - end -end diff --git a/app/controllers/goal_pledges_controller.rb b/app/controllers/goal_pledges_controller.rb new file mode 100644 index 000000000..db24bccb2 --- /dev/null +++ b/app/controllers/goal_pledges_controller.rb @@ -0,0 +1,66 @@ +class GoalPledgesController < ApplicationController + before_action :set_goal + before_action :set_pledge, only: %i[extend destroy] + rescue_from ActiveRecord::RecordNotFound, with: :record_not_found + + def new + @pledge = @goal.goal_pledges.new( + currency: @goal.currency, + kind: default_kind_for(@goal), + amount: params[:amount].presence + ) + end + + def create + @pledge = @goal.goal_pledges.new(pledge_params) + @pledge.kind = default_kind_for(@goal) if @pledge.kind.blank? + @pledge.currency = @goal.currency + + if @pledge.save + flash[:notice] = t(".success") + respond_to do |format| + format.html { redirect_to goal_path(@goal) } + format.turbo_stream do + render turbo_stream: turbo_stream.action(:redirect, goal_path(@goal)) + end + end + else + render :new, status: :unprocessable_entity + end + end + + def extend + @pledge.extend! + redirect_to goal_path(@goal), notice: t(".success") + rescue ActiveRecord::RecordInvalid + redirect_to goal_path(@goal), alert: t(".not_open") + end + + def destroy + @pledge.cancel! + redirect_to goal_path(@goal), notice: t(".success") + rescue ActiveRecord::RecordInvalid + redirect_to goal_path(@goal), alert: t(".not_open") + end + + private + def set_goal + @goal = Current.family.goals.find(params[:goal_id]) + end + + def set_pledge + @pledge = @goal.goal_pledges.find(params[:id]) + end + + def pledge_params + params.require(:goal_pledge).permit(:amount, :account_id, :kind) + end + + def default_kind_for(goal) + goal.any_connected_account? ? "transfer" : "manual_save" + end + + def record_not_found + redirect_to goals_path, alert: t("goals.errors.not_found") + end +end diff --git a/app/controllers/goals_controller.rb b/app/controllers/goals_controller.rb index 10f456693..32b14b845 100644 --- a/app/controllers/goals_controller.rb +++ b/app/controllers/goals_controller.rb @@ -11,7 +11,7 @@ class GoalsController < ApplicationController h[state] = state == "all" ? state_counts.values.sum : (state_counts[state] || 0) end - all_goals = Current.family.goals.with_current_balance.alphabetically.includes(:goal_contributions, :linked_accounts).to_a + all_goals = Current.family.goals.alphabetically.includes(:linked_accounts, :open_pledges).to_a @active_goals = all_goals.reject { |g| %w[completed archived].include?(g.state) } .sort_by { |g| [ g.paused? ? 3 : ACTIVE_STATUS_RANK.fetch(g.status, 4), g.name.downcase ] } @completed_goals = all_goals.select { |g| g.state == "completed" } @@ -19,6 +19,7 @@ class GoalsController < ApplicationController @linkable_account_count = Current.family.accounts.where(accountable_type: "Depository").visible.count @kpi = kpi_payload(@active_goals) + @any_pending_pledge = @active_goals.any? { |g| g.open_pledges.any? } @show_search = @active_goals.size > 6 @breadcrumbs = [ [ t("breadcrumbs.home"), root_path ], @@ -27,11 +28,7 @@ class GoalsController < ApplicationController end def show - @contributions = @goal.goal_contributions - .sort_by { |c| [ c.contributed_at, c.created_at ] } - .reverse - @funding_breakdown = funding_breakdown_for(@goal) - @stats = stats_for(@goal) + @open_pledges = @goal.open_pledges.chronological.to_a @breadcrumbs = [ [ t("breadcrumbs.home"), root_path ], [ t("goals.index.title"), goals_path ], @@ -60,7 +57,6 @@ class GoalsController < ApplicationController Goal.transaction do accounts.each { |a| @goal.goal_accounts.build(account: a) } @goal.save! - create_initial_contribution_if_provided!(@goal, accounts) end flash[:notice] = t(".success") @@ -144,8 +140,7 @@ class GoalsController < ApplicationController private def set_goal @goal = Current.family.goals - .with_current_balance - .includes(goal_contributions: :account, linked_accounts: []) + .includes(:linked_accounts, :open_pledges) .find(params[:id]) end @@ -184,117 +179,27 @@ class GoalsController < ApplicationController end end - def create_initial_contribution_if_provided!(goal, accounts) - amount = params.dig(:goal, :initial_contribution_amount) - account_id = params.dig(:goal, :initial_contribution_account_id) - return if amount.blank? || account_id.blank? - return unless BigDecimal(amount.to_s) > 0 - - source = accounts.find { |a| a.id == account_id } - raise ActiveRecord::RecordInvalid.new(goal) unless source - - goal.goal_contributions.create!( - account: source, - amount: amount, - currency: goal.currency, - source: "initial", - contributed_at: Date.current - ) - end - - def funding_breakdown_for(goal) - totals = goal.goal_contributions - .group_by(&:account_id) - .transform_values { |arr| arr.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 kpi_payload(active_goals) family = Current.family currency = family.primary_currency_code - today = Date.current - velocity_30d = family.contribution_velocity(range: (today - 30)..today) - velocity_prior_30d = family.contribution_velocity(range: (today - 60)..(today - 31)) - delta_amount = velocity_30d - velocity_prior_30d - delta_percent = velocity_prior_30d.zero? ? nil : ((delta_amount / velocity_prior_30d) * 100).round(1) - velocity_direction = if delta_amount.positive? then :up - elsif delta_amount.negative? then :down - else :flat - end - - behind = active_goals.select { |g| g.status == :behind } - on_track = active_goals.select { |g| g.status == :on_track } - no_date = active_goals.select { |g| g.status == :no_target_date } - paused = active_goals.select(&:paused?) - needs = behind.sum { |g| g.monthly_target_amount.to_d } + contributed_last_30d = family.savings_inflow_velocity(days: 30) + needs = active_goals + .select { |g| g.status == :behind } + .sum { |g| g.monthly_target_amount.to_d } + behind = active_goals.count { |g| g.status == :behind } + on_track = active_goals.count { |g| g.status == :on_track || g.status == :reached } { currency: currency, - velocity_30d: velocity_30d, - velocity_30d_money: Money.new(velocity_30d.abs, currency), - velocity_prior_30d_money: Money.new(velocity_prior_30d, currency), - velocity_30d_sign: velocity_direction == :down ? "−" : (velocity_direction == :up ? "+" : ""), - velocity_delta_amount_money: Money.new(delta_amount.abs, currency), - velocity_delta_percent: delta_percent, - velocity_direction: velocity_direction, + contributed_last_30d_money: Money.new(contributed_last_30d, currency), needs_this_month_money: Money.new(needs, currency), - behind_count: behind.size, - on_track_count: on_track.size, - no_date_count: no_date.size, - paused_count: paused.size, + on_track_count: on_track, + behind_count: behind, active_total: active_goals.size } end - def stats_for(goal) - avg = goal.average_monthly_contribution.to_d - sub_avg = if goal.monthly_target_amount && goal.monthly_target_amount.to_d > avg - t("goals.show.stats.needs_per_month", amount: Money.new(goal.monthly_target_amount, goal.currency).format) - else - t("goals.show.stats.above_target_pace") - end - sub_target = if goal.monthly_target_amount - t("goals.show.stats.needs_per_month", amount: Money.new(goal.monthly_target_amount, goal.currency).format) - else - t("goals.show.stats.no_required_pace") - end - summary = projection_summary(goal, avg) - - { - avg_monthly: avg, - avg_monthly_sub: sub_avg, - contributions_count: goal.goal_contributions.size, - monthly_target_sub: sub_target, - projection_summary: summary - } - end - - def projection_summary(goal, avg_monthly) - currency = goal.currency - money = ->(amount) { Money.new(amount, currency).format } - - if goal.completed? || goal.progress_percent >= 100 - t("goals.show.projection.reached") - elsif goal.target_date.nil? - t("goals.show.projection.no_target_date") - elsif goal.monthly_target_amount && avg_monthly < goal.monthly_target_amount - t("goals.show.projection.behind", - current: money.call(avg_monthly), - required: money.call(goal.monthly_target_amount)) - elsif avg_monthly.positive? - months_to_target = (goal.remaining_amount.to_d / avg_monthly).ceil - projected_date = Date.current >> months_to_target.to_i - t("goals.show.projection.on_track", - date: projected_date.strftime("%b %Y")) - else - t("goals.show.projection.no_pace") - end - end - def perform_transition!(event) if @goal.aasm.may_fire_event?(event) @goal.public_send("#{event}!") diff --git a/app/javascript/controllers/goal_projection_chart_controller.js b/app/javascript/controllers/goal_projection_chart_controller.js index 22fd59179..bcd86e922 100644 --- a/app/javascript/controllers/goal_projection_chart_controller.js +++ b/app/javascript/controllers/goal_projection_chart_controller.js @@ -113,7 +113,20 @@ export default class extends Controller { ] : []; - const yMax = Math.max(targetAmount * 1.05, projectionEnd, currentAmount, 1); + const requiredMonthly = data.required_monthly || 0; + const requiredEnd = target && requiredMonthly > 0 + ? currentAmount + requiredMonthly * Math.max(0, this._monthsBetween(today, target)) + : currentAmount; + const requiredSeries = target && requiredMonthly > 0 && requiredEnd > currentAmount + ? [ + { date: today, value: currentAmount }, + { date: target, value: requiredEnd }, + ] + : []; + + const pendingPledgeAmount = data.pending_pledge_amount || 0; + + const yMax = Math.max(targetAmount * 1.05, projectionEnd, requiredEnd, currentAmount, 1); const x = d3.scaleTime().domain([start, endDate]).range([margin.left, margin.left + innerWidth]); const y = d3.scaleLinear().domain([0, yMax]).range([margin.top + innerHeight, margin.top]); @@ -237,6 +250,21 @@ export default class extends Controller { .attr("stroke-linecap", "round") .attr("d", line); + if (requiredSeries.length) { + // Light dashed line: the path needed to hit the target. Sits behind + // the projection so the user sees both the goal and the ask. + svg + .append("path") + .datum(requiredSeries) + .attr("fill", "none") + .attr("stroke", "var(--color-green-600)") + .attr("stroke-width", 1.2) + .attr("stroke-linecap", "round") + .attr("stroke-dasharray", "2 4") + .attr("opacity", 0.45) + .attr("d", line); + } + if (projectionSeries.length) { const willHit = projectionEnd >= targetAmount; const projColor = willHit ? "var(--color-green-600)" : "var(--color-yellow-600)"; @@ -281,6 +309,42 @@ export default class extends Controller { } } + if (pendingPledgeAmount > 0 && target) { + const willHit = projectionEnd >= targetAmount; + const pendingColor = willHit ? "var(--color-green-600)" : "var(--color-yellow-600)"; + const pendingTop = Math.min(yMax, currentAmount + pendingPledgeAmount); + svg + .append("line") + .attr("x1", x(today)) + .attr("x2", x(today)) + .attr("y1", y(currentAmount)) + .attr("y2", y(pendingTop)) + .attr("stroke", pendingColor) + .attr("stroke-width", 3) + .attr("stroke-linecap", "round") + .attr("opacity", 0.4); + + svg + .append("circle") + .attr("cx", x(today)) + .attr("cy", y(pendingTop)) + .attr("r", 5) + .attr("fill", containerBg) + .attr("stroke", pendingColor) + .attr("stroke-width", 2) + .attr("stroke-dasharray", "2 2"); + + if (innerWidth >= 320) { + svg + .append("text") + .attr("x", x(today) + 10) + .attr("y", y(pendingTop) + 4) + .attr("font-size", 10) + .attr("fill", textSecondary) + .text(`+ pending ${this._fmtMoneyShort(pendingPledgeAmount, data.currency)}`); + } + } + svg .append("line") .attr("x1", x(today)) diff --git a/app/jobs/sweep_expired_goal_pledges_job.rb b/app/jobs/sweep_expired_goal_pledges_job.rb new file mode 100644 index 000000000..1557fad67 --- /dev/null +++ b/app/jobs/sweep_expired_goal_pledges_job.rb @@ -0,0 +1,7 @@ +class SweepExpiredGoalPledgesJob < ApplicationJob + queue_as :scheduled + + def perform + GoalPledge.open_and_expired_now.find_each(&:expire!) + end +end diff --git a/app/models/account.rb b/app/models/account.rb index a46c2c798..f83ced876 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -24,7 +24,7 @@ class Account < ApplicationRecord has_many :recurring_transactions, dependent: :destroy has_many :goal_accounts, dependent: :destroy has_many :goals, through: :goal_accounts - has_many :goal_contributions, dependent: :destroy + has_many :goal_pledges, dependent: :destroy # Inverse for recurring transfers where this account is the destination. # Account#recurring_transactions only matches account_id; without this # association, destroying the destination account would hit the FK diff --git a/app/models/account/provider_import_adapter.rb b/app/models/account/provider_import_adapter.rb index 6b2dbf932..af8e19e1b 100644 --- a/app/models/account/provider_import_adapter.rb +++ b/app/models/account/provider_import_adapter.rb @@ -205,6 +205,11 @@ class Account::ProviderImportAdapter entry.save! entry.transaction.save! if entry.transaction.changed? + # Auto-resolve any open Goal pledges on this account whose tolerance + # window matches the posted transaction. Idempotent via the partial-unique + # index on transactions.extra->'goal'->>'pledge_id'. + GoalPledge::Reconciler.new(entry).run unless incoming_pending + # AFTER save: For NEW posted transactions, check for fuzzy matches to SUGGEST (not auto-claim) # This handles tip adjustments where auto-matching is too risky if is_new_posted diff --git a/app/models/account/reconciliation_manager.rb b/app/models/account/reconciliation_manager.rb index 6fadcfa1a..774e9b330 100644 --- a/app/models/account/reconciliation_manager.rb +++ b/app/models/account/reconciliation_manager.rb @@ -12,6 +12,7 @@ class Account::ReconciliationManager unless dry_run prepared_valuation.save! + GoalPledge::Reconciler.new(prepared_valuation).run end ReconciliationResult.new( diff --git a/app/models/assistant/function/create_goal.rb b/app/models/assistant/function/create_goal.rb index 9a0ca8dc1..983402f8a 100644 --- a/app/models/assistant/function/create_goal.rb +++ b/app/models/assistant/function/create_goal.rb @@ -53,15 +53,7 @@ class Assistant::Function::CreateGoal < Assistant::Function 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." } - } + 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. The goal's balance is the balance of these accounts." }, notes: { type: "string", @@ -76,7 +68,6 @@ class Assistant::Function::CreateGoal < Assistant::Function 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? @@ -122,8 +113,6 @@ class Assistant::Function::CreateGoal < Assistant::Function ) matched.each { |a| goal.goal_accounts.build(account: a) } goal.save! - - create_initial_contribution!(goal, matched, initial) end { @@ -142,24 +131,6 @@ class Assistant::Function::CreateGoal < Assistant::Function 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.goal_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) diff --git a/app/models/demo/generator.rb b/app/models/demo/generator.rb index 2fe70639f..d5d1c0ec8 100644 --- a/app/models/demo/generator.rb +++ b/app/models/demo/generator.rb @@ -1395,16 +1395,6 @@ class Demo::Generator ) goal_spec[:accounts].uniq.each { |a| goal.goal_accounts.build(account: a) } goal.save! - - goal_spec[:contributions].each do |c| - goal.goal_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} goals" diff --git a/app/models/family.rb b/app/models/family.rb index 32d7f82b7..dd18a6fd3 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -44,13 +44,26 @@ class Family < ApplicationRecord has_many :budget_categories, through: :budgets has_many :goals, dependent: :destroy - has_many :goal_contributions, through: :goals - # Sum of contribution amounts within the given date range, returned as - # a BigDecimal in the family's primary currency. Powers the savings - # goals "Contributed · last 30d" KPI. - def contribution_velocity(range:) - goal_contributions.where(contributed_at: range).sum(:amount).to_d + # Net non-transfer inflow into every depository account linked to any goal, + # over the trailing window. Entry amount convention in Sure: inflow is + # negative, so we flip the sign for the user-facing "contributed" value. + def savings_inflow_velocity(days: 30) + account_ids = Account + .joins(:goal_accounts) + .where(goal_accounts: { goal_id: goals.select(:id) }) + .distinct + .pluck(:id) + return 0 if account_ids.empty? + + net = Entry + .joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'") + .where(account_id: account_ids, date: days.days.ago.to_date..Date.current) + .where.not(transactions: { kind: Transaction::TRANSFER_KINDS }) + .where(excluded: false) + .sum(:amount) + + (-net.to_d).clamp(0, Float::INFINITY) end has_many :llm_usages, dependent: :destroy diff --git a/app/models/goal.rb b/app/models/goal.rb index ae819e79c..bdb10f228 100644 --- a/app/models/goal.rb +++ b/app/models/goal.rb @@ -4,16 +4,13 @@ class Goal < ApplicationRecord COLORS = Category::COLORS ICONS = Category.icon_codes - # Virtual attributes used by the create-modal stepper to capture an - # optional initial contribution alongside the goal create payload. - attr_accessor :initial_contribution_amount, :initial_contribution_account_id - validates :icon, inclusion: { in: ICONS, allow_nil: true } belongs_to :family has_many :goal_accounts, dependent: :destroy has_many :linked_accounts, through: :goal_accounts, source: :account - has_many :goal_contributions, dependent: :destroy + has_many :goal_pledges, dependent: :destroy + has_many :open_pledges, -> { where(status: "open") }, class_name: "GoalPledge" validates :name, presence: true, length: { maximum: 255 } validates :target_amount, presence: true, numericality: { greater_than: 0 } @@ -22,7 +19,7 @@ class Goal < ApplicationRecord 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 + validate :currency_locked_once_linked monetize :target_amount @@ -30,14 +27,7 @@ class Goal < ApplicationRecord 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(:goal_contributions) - .group(Arel.sql("goals.id")) - .select(Arel.sql("goals.*, COALESCE(SUM(goal_contributions.amount), 0) AS current_balance_total")) - } - # 63-bit Postgres advisory-lock key per family. Used by future auto-fund flows - # and any future per-family serialization of goal contributions. def self.advisory_lock_key_for(family_id) Digest::SHA1.hexdigest("goals:family:#{family_id}").to_i(16) % (2**63) end @@ -69,12 +59,11 @@ class Goal < ApplicationRecord end end + # Balance is the live balance of every linked depository account. + # v1: single linked account in practice. v1.1+: minus other goals' allocations + # via the upcoming GoalBacking query. def current_balance - @current_balance ||= if attributes.key?("current_balance_total") - attributes["current_balance_total"] || 0 - else - goal_contributions.sum(:amount) - end + @current_balance ||= linked_accounts.sum { |a| a.balance.to_d } end def current_balance_money @@ -120,10 +109,37 @@ class Goal < ApplicationRecord end end - # Segment array consumed by the shared `donut-chart` Stimulus controller - # (see app/javascript/controllers/donut_chart_controller.js). Same shape - # as Budget#to_donut_segments_json: filled portion in goal color, unused - # remainder as the system "unallocated" fill. + # 90-day rolling monthly pace: average net non-transfer inflow into linked + # accounts. Entry amount sign convention in Sure: inflow is negative. + def pace + return @pace if defined?(@pace) + + @pace = if linked_accounts.empty? + 0 + else + account_ids = linked_accounts.map(&:id) + net = Entry + .joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'") + .where(account_id: account_ids, date: 90.days.ago.to_date..Date.current) + .where.not(transactions: { kind: Transaction::TRANSFER_KINDS }) + .where(excluded: false) + .sum(:amount) + (-net.to_d / 3).round(2) + end + end + + def pace_money + @pace_money ||= Money.new(pace, currency) + end + + # Months of cash on hand at current pace (open-ended goals). + def months_of_runway + return nil if target_date.present? + return nil if pace.zero? || pace.negative? + + (current_balance.to_d / pace.to_d).round(1) + end + def to_donut_segments_json filled = current_balance.to_d rem = remaining_amount.to_d @@ -138,18 +154,14 @@ class Goal < ApplicationRecord segments end - # Cumulative contributions series for the projection chart, sorted by - # date ascending. Consumed by the - # `goal-projection-chart` Stimulus controller. + # 90-day balance trajectory of linked accounts. Used by the projection chart + # to render the saved-to-date line. Returns an empty series when the linked + # account lacks ≥30 days of history. def projection_payload - sorted = goal_contributions.sort_by(&:contributed_at) - running = 0 - saved_series = sorted.map do |c| - running += c.amount.to_d - { date: c.contributed_at.to_s, value: running.to_f } - end + series_values = balance_series_values + saved_series = series_values.map { |v| { date: v.date.to_s, value: v.value.amount.to_f } } - earliest = [ created_at.to_date, sorted.first&.contributed_at ].compact.min + earliest = series_values.first&.date || created_at.to_date { saved_series: saved_series, @@ -158,16 +170,14 @@ class Goal < ApplicationRecord target_date: target_date&.to_s, target_amount: target_amount.to_f, current_amount: current_balance.to_f, - avg_monthly: average_monthly_contribution.to_f, + avg_monthly: pace.to_f, required_monthly: monthly_target_amount.to_f, currency: currency, - status: status.to_s + status: status.to_s, + pending_pledge_amount: open_pledges.sum(:amount).to_f } end - # Display-layer status. Prefers AASM state for inactive goals so the UI - # doesn't compute a misleading "Behind / On track" verdict against a goal - # that isn't accepting contributions anymore. def display_status return @display_status if defined?(@display_status) @@ -180,10 +190,10 @@ class Goal < ApplicationRecord 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 + # :reached → progress_percent >= 100 + # :on_track → has target_date and pace >= required monthly + # :behind → has target_date and pace < required monthly + # :no_target_date → open-ended def status return @status if defined?(@status) @@ -191,50 +201,49 @@ class Goal < ApplicationRecord :reached elsif target_date.nil? :no_target_date - elsif monthly_target_amount.to_d <= average_monthly_contribution.to_d + elsif monthly_target_amount.to_d <= pace.to_d :on_track else :behind end end - def average_monthly_contribution - return @average_monthly_contribution if defined?(@average_monthly_contribution) - - @average_monthly_contribution = if goal_contributions.empty? - 0 - else - first_at = if goal_contributions.loaded? - goal_contributions.map(&:contributed_at).compact.min - else - goal_contributions.minimum(:contributed_at) - end - if first_at.blank? - current_balance - else - 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 - end + # Days since the most recently matched pledge. Used by the show header to + # show "Last saved N days ago". Returns nil if no pledge has resolved yet. + def last_matched_pledge_at + @last_matched_pledge_at ||= goal_pledges.where(status: "matched").maximum(:updated_at) end - def last_contribution_at - @last_contribution_at ||= if goal_contributions.loaded? - goal_contributions.map(&:contributed_at).compact.max - else - goal_contributions.maximum(:contributed_at) - end - end - - def last_contribution_days_ago - last = last_contribution_at + def last_matched_pledge_days_ago + last = last_matched_pledge_at return nil if last.nil? - (Date.current - last).to_i + (Date.current - last.to_date).to_i + end + + def any_connected_account? + linked_accounts.any? { |a| a.respond_to?(:plaid_account) && a.plaid_account.present? } + end + + # "I just transferred" for bank-connected accounts, "I just saved" for manual-only. + def pledge_action_label_key + any_connected_account? ? "goals.show.pledge_just_transferred" : "goals.show.pledge_just_saved" end private + def balance_series_values + return [] if linked_accounts.empty? + + Balance::ChartSeriesBuilder.new( + account_ids: linked_accounts.map(&:id), + currency: currency, + period: Period.last_90_days + ).balance_series.values + rescue StandardError => e + Rails.logger.warn("Goal##{id} balance series failed: #{e.message}") + [] + end + def must_have_at_least_one_linked_account return unless goal_accounts.reject(&:marked_for_destruction?).empty? @@ -272,12 +281,10 @@ class Goal < ApplicationRecord 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 + def currency_locked_once_linked return unless persisted? && currency_changed? - return unless goal_contributions.exists? + return unless goal_accounts.where.not(id: nil).exists? - errors.add(:currency, :locked_after_contributions) + errors.add(:currency, :locked_after_linked) end end diff --git a/app/models/goal_contribution.rb b/app/models/goal_contribution.rb deleted file mode 100644 index 638ae53dc..000000000 --- a/app/models/goal_contribution.rb +++ /dev/null @@ -1,56 +0,0 @@ -class GoalContribution < ApplicationRecord - include Monetizable - - SOURCES = %w[manual initial].freeze - - belongs_to :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 = goal.currency if goal && currency.blank? - end - - def currency_matches_goal - return if goal.nil? || currency.blank? - return if currency == goal.currency - - errors.add(:currency, :must_match_goal) - end - - def account_must_belong_to_family - return if goal.nil? || account.nil? - return if account.family_id == goal.family_id - - errors.add(:account, :must_belong_to_family) - end - - def account_must_be_linked_to_goal - return if goal.nil? || account.nil? - return if goal.goal_accounts.where(account_id: account_id).exists? - - errors.add(:account, :must_be_linked_to_goal) - end -end diff --git a/app/models/goal_pledge.rb b/app/models/goal_pledge.rb new file mode 100644 index 000000000..e229468cb --- /dev/null +++ b/app/models/goal_pledge.rb @@ -0,0 +1,112 @@ +class GoalPledge < ApplicationRecord + include Monetizable + + KINDS = %w[transfer manual_save].freeze + STATUSES = %w[open matched cancelled expired].freeze + + DEFAULT_WINDOW_DAYS = 7 + EXTEND_DAYS = 7 + MATCH_DATE_TOLERANCE_DAYS = 5 + MATCH_AMOUNT_TOLERANCE_ABSOLUTE = BigDecimal("0.50") + MATCH_AMOUNT_TOLERANCE_RATIO = BigDecimal("0.01") + + belongs_to :goal + belongs_to :account + belongs_to :matched_transaction, class_name: "Transaction", optional: true + + enum :kind, KINDS.index_by(&:itself), prefix: :kind + enum :status, STATUSES.index_by(&:itself), prefix: :status + + validates :amount, presence: true, numericality: { greater_than: 0 } + validates :currency, presence: true + validates :expires_at, presence: true + validate :account_must_be_linked_to_goal + validate :currency_matches_goal + + monetize :amount + + scope :chronological, -> { order(created_at: :desc) } + scope :open_and_expired_now, -> { + where(status: "open").where("expires_at < ?", Time.current) + } + + before_validation :assign_defaults, on: :create + + # Tolerance check: ±5 days from pledge date, amount within ±$0.50 OR ±1%. + def matches?(entry) + return false unless status_open? + return false unless entry.account_id == account_id + + date_diff = (entry.date - created_at.to_date).abs.to_i + return false if date_diff > MATCH_DATE_TOLERANCE_DAYS + + txn_amount = entry.amount.to_d.abs + pledge_amount = amount.to_d + diff_abs = (txn_amount - pledge_amount).abs + + return true if diff_abs <= MATCH_AMOUNT_TOLERANCE_ABSOLUTE + return true if pledge_amount.positive? && (diff_abs / pledge_amount) <= MATCH_AMOUNT_TOLERANCE_RATIO + + false + end + + def resolve_with!(transaction) + transaction.with_lock do + pledge_id_in_extra = transaction.extra.dig("goal", "pledge_id") + raise ActiveRecord::RecordInvalid if pledge_id_in_extra.present? && pledge_id_in_extra != id + + extra = transaction.extra || {} + extra["goal"] = (extra["goal"] || {}).merge("pledge_id" => id) + transaction.update!(extra: extra) + + update!(status: "matched", matched_transaction_id: transaction.id) + end + end + + def extend!(days: EXTEND_DAYS) + raise ActiveRecord::RecordInvalid, "Only open pledges can be extended" unless status_open? + + update!(expires_at: expires_at + days.days) + end + + def cancel! + raise ActiveRecord::RecordInvalid, "Only open pledges can be cancelled" unless status_open? + + update!(status: "cancelled") + end + + def expire! + return unless status_open? + + update!(status: "expired") + end + + def days_left + return 0 unless status_open? + + delta = ((expires_at - Time.current) / 1.day).ceil + [ delta, 0 ].max + end + + private + def assign_defaults + self.kind ||= "transfer" + self.status ||= "open" + self.expires_at ||= Time.current + DEFAULT_WINDOW_DAYS.days + self.currency ||= goal&.currency + end + + def account_must_be_linked_to_goal + return if goal.nil? || account.nil? + return if goal.goal_accounts.where(account_id: account_id).exists? + + errors.add(:account, :must_be_linked_to_goal) + end + + def currency_matches_goal + return if goal.nil? || currency.blank? + return if currency == goal.currency + + errors.add(:currency, :must_match_goal) + end +end diff --git a/app/models/goal_pledge/reconciler.rb b/app/models/goal_pledge/reconciler.rb new file mode 100644 index 000000000..214615912 --- /dev/null +++ b/app/models/goal_pledge/reconciler.rb @@ -0,0 +1,49 @@ +class GoalPledge::Reconciler + attr_reader :entry + + def initialize(entry) + @entry = entry + end + + def run + return unless eligible_entry? + return if already_stamped? + + GoalPledge + .where(account_id: entry.account_id, status: "open", kind: expected_kind) + .where("expires_at >= ?", Time.current) + .order(created_at: :asc) + .find_each do |pledge| + next unless pledge.matches?(entry) + + begin + pledge.resolve_with!(entry.transaction) if entry.entryable.is_a?(Transaction) + pledge.update!(status: "matched") if entry.entryable.is_a?(Valuation) + Rails.logger.info("GoalPledge ##{pledge.id} matched entry ##{entry.id}") + return + rescue ActiveRecord::RecordInvalid => e + Rails.logger.warn("GoalPledge ##{pledge.id} match failed: #{e.message}") + end + end + rescue StandardError => e + Rails.logger.error("GoalPledge::Reconciler failed for entry ##{entry&.id}: #{e.class}: #{e.message}") + end + + private + def eligible_entry? + return false if entry.account_id.blank? + return false if entry.excluded? + + entry.entryable.is_a?(Transaction) || entry.entryable.is_a?(Valuation) + end + + def already_stamped? + return false unless entry.entryable.is_a?(Transaction) + + entry.transaction.extra.dig("goal", "pledge_id").present? + end + + def expected_kind + entry.entryable.is_a?(Valuation) ? "manual_save" : "transfer" + end +end diff --git a/app/views/goal_contributions/new.html.erb b/app/views/goal_contributions/new.html.erb deleted file mode 100644 index 80554abb1..000000000 --- a/app/views/goal_contributions/new.html.erb +++ /dev/null @@ -1,46 +0,0 @@ -<%= 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: goal_contributions_path(@goal), - class: "space-y-3", - data: { - controller: "goal-contribution-preview", - goal_contribution_preview_current_balance_value: @goal.current_balance.to_f, - goal_contribution_preview_target_amount_value: @goal.target_amount.to_f, - goal_contribution_preview_currency_value: @goal.currency, - goal_contribution_preview_template_zero_value: t(".preview_zero"), - goal_contribution_preview_template_nonzero_value: t(".preview_nonzero"), - goal_contribution_preview_template_reached_value: t(".preview_reached") - } do |f| %> - <%= f.money_field :amount, - label: t(".amount"), - hide_currency: true, - autofocus: true, - amount_data: { - goal_contribution_preview_target: "amountInput", - action: "input->goal-contribution-preview#update" - } %> - -

- - <%= f.select :account_id, - options_from_collection_for_select(@goal.linked_accounts, :id, :name, @contribution.account_id), - { label: t(".source_account"), include_blank: t(".select_account") } %> - - <%= f.date_field :contributed_at, - label: t(".contributed_at") %> - - <%= f.text_area :notes, label: t(".notes"), rows: 2 %> - -
- <%= f.submit t(".submit") %> -
- <% end %> - <% end %> -<% end %> diff --git a/app/views/goal_pledges/new.html.erb b/app/views/goal_pledges/new.html.erb new file mode 100644 index 000000000..01f3f1a9f --- /dev/null +++ b/app/views/goal_pledges/new.html.erb @@ -0,0 +1,50 @@ +<%= modal_form_wrapper title: t(@goal.pledge_action_label_key) do %> + <%= styled_form_with model: @pledge, url: goal_pledges_path(@goal), class: "form" do |form| %> + <% if @pledge.errors.any? %> +
+ <% @pledge.errors.full_messages.each do |msg| %> +

<%= msg %>

+ <% end %> +
+ <% end %> + +

+ <% if @goal.any_connected_account? %> + <%= t("goal_pledges.new.helper_transfer") %> + <% else %> + <%= t("goal_pledges.new.helper_manual") %> + <% end %> +

+ +
+ <%= form.money_field :amount, + label: t("goal_pledges.new.amount_label"), + hide_currency: true, + required: true %> + +
+
+ <%= form.label :account_id, + t("goal_pledges.new.account_label"), + class: "form-field__label" %> + <%= form.select :account_id, + @goal.linked_accounts.map { |a| [ a.name, a.id ] }, + { include_blank: false }, + class: "form-field__input" %> +
+
+
+ +
+
+ <%= render DS::Button.new( + text: t("goal_pledges.new.cancel"), + variant: "ghost", + data: { action: "click->dialog#close" } + ) %> + <%= form.submit t("goal_pledges.new.submit"), + class: "btn btn--primary", + data: { turbo_frame: "_top" } %> +
+ <% end %> +<% end %> diff --git a/app/views/goals/_contributions_list.html.erb b/app/views/goals/_contributions_list.html.erb deleted file mode 100644 index af728854a..000000000 --- a/app/views/goals/_contributions_list.html.erb +++ /dev/null @@ -1,47 +0,0 @@ -<%# locals: (contributions:) %> - -<% if contributions.empty? %> -
-

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

-
-<% else %> - -<% end %> diff --git a/app/views/goals/_form_stepper.html.erb b/app/views/goals/_form_stepper.html.erb index 1951524b3..39d6141b2 100644 --- a/app/views/goals/_form_stepper.html.erb +++ b/app/views/goals/_form_stepper.html.erb @@ -121,35 +121,6 @@

- -
- - <%= render DS::FilledIcon.new(variant: :container, icon: "zap", size: "md", rounded: false) %> -
-

<%= t("goals.form_stepper.step2.add_initial_contribution") %>

-

<%= t("goals.form_stepper.step2.add_initial_contribution_sub") %>

-
- <%= icon("chevron-down", size: "sm") %> -
-
- <%= f.money_field :initial_contribution_amount, - label: t("goals.form_stepper.step2.initial_amount"), - hide_currency: true, - amount_data: { goal_stepper_target: "initialContributionAmount" } %> -
-
- <%= label_tag "goal[initial_contribution_account_id]", - t("goals.form_stepper.step2.initial_account"), - class: "form-field__label" %> - <%= select_tag "goal[initial_contribution_account_id]", - options_for_select([]), - include_blank: t("goals.form_stepper.step2.select_account"), - data: { goal_stepper_target: "initialContributionAccountSelect" }, - class: "form-field__input" %> -
-
-
-
diff --git a/app/views/goals/_pending_pledge_banner.html.erb b/app/views/goals/_pending_pledge_banner.html.erb new file mode 100644 index 000000000..e5978d46a --- /dev/null +++ b/app/views/goals/_pending_pledge_banner.html.erb @@ -0,0 +1,29 @@ +<% + account = pledge.account + amount_money = pledge.amount_money + days_left = pledge.days_left + body_key = pledge.kind_transfer? ? "goals.show.pending_pledge.body_transfer" : "goals.show.pending_pledge.body_manual" +%> +
+
+ <%= icon("clock", size: "sm", color: "current") %> +
+
+

+ <%= t("goals.show.pending_pledge.title", amount: amount_money.format, account: account.name) %> +

+

+ <%= t(body_key, days_left: days_left) %> +

+
+ <%= button_to t("goals.show.pending_pledge.extend"), + extend_goal_pledge_path(pledge.goal, pledge), + method: :patch, + class: "text-xs font-medium text-primary px-2.5 py-1 rounded-md shadow-border-xs bg-container hover:bg-surface-inset", + form: { class: "inline-flex" } %> + <%= button_to t("goals.show.pending_pledge.cancel"), + goal_pledge_path(pledge.goal, pledge), + method: :delete, + class: "text-xs text-secondary hover:text-primary px-2", + form: { class: "inline-flex" } %> +
diff --git a/app/views/goals/index.html.erb b/app/views/goals/index.html.erb index 645a170f6..fd884a619 100644 --- a/app/views/goals/index.html.erb +++ b/app/views/goals/index.html.erb @@ -7,32 +7,16 @@ <% if @counts["all"].zero? %> <%= render "empty_state", linkable_account_count: @linkable_account_count %> <% else %> - <%# KPI strip %> + <%# KPI strip — v1: Contributed last 30d / Needs this month / On track %>
- <%# Velocity %>
-

<%= t(".kpi.velocity_label") %>

+

<%= t(".kpi.contributed_label") %>

- <%= @kpi[:velocity_30d_sign] %><%= @kpi[:velocity_30d_money].format %> + <%= @kpi[:contributed_last_30d_money].format %>

- <% if @kpi[:velocity_direction] == :flat %> -

- <% if @kpi[:velocity_prior_30d_money].zero? && @kpi[:velocity_30d_money].zero? %> - <%= t(".kpi.velocity_delta_zero_base") %> - <% else %> - <%= t(".kpi.velocity_delta_flat") %> - <% end %> -

- <% elsif @kpi[:velocity_delta_percent].nil? %> -

<%= t(".kpi.velocity_delta_zero_base") %>

- <% else %> -

mt-1 tabular-nums"> - <%= t(".kpi.velocity_delta_#{@kpi[:velocity_direction]}", percent: @kpi[:velocity_delta_percent].abs) %> -

- <% end %> +

<%= t(".kpi.contributed_sub") %>

- <%# Needs this month %>

<%= t(".kpi.needs_this_month_label") %>

<%= @kpi[:needs_this_month_money].format %>

@@ -45,21 +29,14 @@

- <%# On-track count %>

<%= t(".kpi.on_track_label") %>

<%= t(".kpi.on_track_value", on_track: @kpi[:on_track_count], total: @kpi[:active_total]) %>

- <% - parts = [] - parts << t(".kpi.on_track_sub_parts.behind", count: @kpi[:behind_count]) if @kpi[:behind_count].positive? - parts << t(".kpi.on_track_sub_parts.no_date", count: @kpi[:no_date_count]) if @kpi[:no_date_count].positive? - parts << t(".kpi.on_track_sub_parts.paused", count: @kpi[:paused_count]) if @kpi[:paused_count].positive? - %> - <% if parts.any? %> - <%= parts.join(" · ") %> + <% if @kpi[:behind_count].positive? %> + <%= t(".kpi.on_track_sub_behind", count: @kpi[:behind_count]) %> <% else %> <%= t(".kpi.on_track_sub_all_good") %> <% end %> @@ -67,6 +44,13 @@

+ <% if @any_pending_pledge %> +
+ <%= icon("clock", size: "sm") %> +

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

+
+ <% end %> + <%# Goals section %>
" diff --git a/app/views/goals/show.html.erb b/app/views/goals/show.html.erb index 0faa35cb2..aaa1f2b92 100644 --- a/app/views/goals/show.html.erb +++ b/app/views/goals/show.html.erb @@ -24,7 +24,7 @@ %> <%= primary_parts.join(" · ") %>

- <% last_days = @goal.last_contribution_days_ago %> + <% last_days = @goal.last_matched_pledge_days_ago %> <% unless last_days.nil? %>

<%= last_days.zero? ? t("goals.goal_card.footer_last_today") : t("goals.goal_card.footer_last_days", count: last_days) %> @@ -42,10 +42,10 @@ ) %> <% unless @goal.completed? || @goal.status == :reached %> <%= render DS::Link.new( - text: t(".add_contribution"), + text: t(@goal.pledge_action_label_key), variant: "primary", - href: new_goal_contribution_path(@goal), - icon: "plus", + href: new_goal_pledge_path(@goal), + icon: "arrow-up-right", frame: :modal ) %> <% end %> @@ -102,8 +102,11 @@

+ <% @open_pledges.each do |pledge| %> + <%= render "pending_pledge_banner", pledge: pledge %> + <% end %> + <% if @goal.paused? %> - <%# Paused banner %> <%= render DS::Alert.new(variant: "info", title: t("goals.show.paused_banner.title")) do %>

<%= t("goals.show.paused_banner.body") %>

@@ -117,7 +120,6 @@
<% end %> <% elsif @goal.archived? %> - <%# Archived banner %> <%= render DS::Alert.new(variant: "info", title: t("goals.show.archived_banner.title")) do %>

<%= t("goals.show.archived_banner.body") %>

<% if @goal.may_unarchive? %> @@ -133,26 +135,24 @@ <% end %> <% end %> <% elsif @goal.status == :behind && @goal.monthly_target_amount %> - <%# Catch-up callout %> <% catch_up_money = Money.new(@goal.monthly_target_amount, @goal.currency) %> - <% catch_up_delta = @goal.monthly_target_amount.to_d - @stats[:avg_monthly].to_d %> - <% catch_up_delta_money = Money.new(catch_up_delta, @goal.currency) %> - <% catch_up_avg_money = Money.new(@stats[:avg_monthly], @goal.currency) %> + <% catch_up_pace_money = @goal.pace_money %> + <% catch_up_delta_money = Money.new([ @goal.monthly_target_amount.to_d - @goal.pace.to_d, 0 ].max, @goal.currency) %> <%= render DS::Alert.new(variant: "warning", title: t("goals.show.catch_up.title", amount: catch_up_money.format)) do %>

<% if @goal.target_date %> - <%= t("goals.show.catch_up.body_with_date", avg: catch_up_avg_money.format, delta: catch_up_delta_money.format, date: I18n.l(@goal.target_date, format: :long)) %> + <%= t("goals.show.catch_up.body_with_date", avg: catch_up_pace_money.format, delta: catch_up_delta_money.format, date: I18n.l(@goal.target_date, format: :long)) %> <% else %> - <%= t("goals.show.catch_up.body", avg: catch_up_avg_money.format, delta: catch_up_delta_money.format) %> + <%= t("goals.show.catch_up.body", avg: catch_up_pace_money.format, delta: catch_up_delta_money.format) %> <% end %>

<%= render DS::Link.new( - text: t("goals.show.catch_up.cta", amount: catch_up_money.format), + text: t(@goal.pledge_action_label_key), variant: "primary", size: "sm", - href: new_goal_contribution_path(@goal, amount: @goal.monthly_target_amount.to_f), - icon: "plus", + href: new_goal_pledge_path(@goal, amount: @goal.monthly_target_amount.to_f), + icon: "arrow-up-right", frame: :modal ) %> <%= link_to t("goals.show.catch_up.adjust_target_cta"), @@ -176,8 +176,27 @@

+ <% + pace = @goal.pace + required = @goal.monthly_target_amount + projection_summary = if @goal.completed? || @goal.progress_percent >= 100 + t("goals.show.projection.reached") + elsif @goal.target_date.nil? + t("goals.show.projection.no_target_date") + elsif required && pace.to_d < required.to_d + t("goals.show.projection.behind", + current: Money.new(pace, @goal.currency).format, + required: Money.new(required, @goal.currency).format) + elsif pace.positive? + months = (@goal.remaining_amount.to_d / pace.to_d).ceil + t("goals.show.projection.on_track", + date: (Date.current >> months.to_i).strftime("%b %Y")) + else + t("goals.show.projection.no_pace") + end + %> + <% if @goal.archived? || @goal.paused? %> - <%# Paused / archived: pace + projection are misleading. Show a static recap card. %>
<%= icon(@goal.archived? ? "archive" : "pause", size: "2xl") %> @@ -190,7 +209,6 @@

<% elsif @goal.completed? || @goal.status == :reached %> - <%# Reached celebration card %>
<%= icon("party-popper", size: "2xl", color: "success") %> @@ -209,9 +227,9 @@
<% end %>
- <% elsif @goal.goal_contributions.empty? %> - <%# No contributions yet: render an inline "add your first" CTA card - instead of a flat-at-$0 chart that looks broken. %> + <% elsif @goal.current_balance.to_d.zero? && @goal.pace.to_d.zero? %> + <%# No movement yet on the linked account: render an inline "make your first transfer" + CTA card instead of a flat-at-$0 chart that looks broken. %>
<%= icon("piggy-bank", size: "2xl") %> @@ -220,11 +238,11 @@

<%= t(".empty.body") %>

<%= render DS::Link.new( - text: t(".empty.cta"), + text: t(@goal.pledge_action_label_key), variant: "primary", size: "sm", - href: new_goal_contribution_path(@goal), - icon: "plus", + href: new_goal_pledge_path(@goal), + icon: "arrow-up-right", frame: :modal ) %>
@@ -234,7 +252,7 @@

<%= t(".projection.heading") %>

-

<%= @stats[:projection_summary].html_safe %>

+

<%= projection_summary.html_safe %>

<% projection_color = @goal.status == :on_track ? "var(--color-green-600)" : "var(--color-yellow-600)" %>
@@ -254,7 +272,7 @@ data-controller="goal-projection-chart" data-goal-projection-chart-data-value="<%= @goal.projection_payload.to_json %>" data-goal-projection-chart-aria-label-value="<%= t("goals.show.projection.aria_label", name: @goal.name) %>" - data-goal-projection-chart-aria-description-value="<%= strip_tags(@stats[:projection_summary]) %>">
+ data-goal-projection-chart-aria-description-value="<%= strip_tags(projection_summary) %>">
<% if @goal.target_date.nil? %>
<%= icon("calendar-plus", size: "sm") %> @@ -269,22 +287,9 @@ <% end %> - <% unless @contributions.empty? %> - <%# Funding breakdown — balance-sheet-style widget (heading · total / - thin bar / dot legend / weight table). %> + <% if @goal.linked_accounts.any? %>
- <%= render Goals::FundingAccountsBreakdownComponent.new(goal: @goal, rows: @funding_breakdown) %> -
- - <%# Contributions — chronological list, full width. %> -
-
-

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

- <%= @contributions.size %> -
-
- <%= render "contributions_list", contributions: @contributions %> -
+ <%= render Goals::FundingAccountsBreakdownComponent.new(goal: @goal) %>
<% end %> diff --git a/config/locales/models/goal/en.yml b/config/locales/models/goal/en.yml index 9444fbfd5..ccf34c5a4 100644 --- a/config/locales/models/goal/en.yml +++ b/config/locales/models/goal/en.yml @@ -22,4 +22,4 @@ en: 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. + locked_after_linked: Can't change the currency after the goal is linked to accounts. diff --git a/config/locales/models/goal_contribution/en.yml b/config/locales/models/goal_contribution/en.yml deleted file mode 100644 index 89352dc26..000000000 --- a/config/locales/models/goal_contribution/en.yml +++ /dev/null @@ -1,20 +0,0 @@ ---- -en: - activerecord: - attributes: - goal_contribution: - amount: Amount - currency: Currency - contributed_at: Date - source: Source - notes: Notes - account: Account - errors: - models: - goal_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/goal_pledge/en.yml b/config/locales/models/goal_pledge/en.yml new file mode 100644 index 000000000..adac9ba25 --- /dev/null +++ b/config/locales/models/goal_pledge/en.yml @@ -0,0 +1,19 @@ +--- +en: + activerecord: + attributes: + goal_pledge: + amount: Amount + currency: Currency + account: Account + kind: Kind + status: Status + expires_at: Expires at + errors: + models: + goal_pledge: + attributes: + account: + must_be_linked_to_goal: Pick one of the goal's linked accounts. + currency: + must_match_goal: Pledge currency must match the goal currency. diff --git a/config/locales/views/goal_contributions/en.yml b/config/locales/views/goal_contributions/en.yml deleted file mode 100644 index b8faf827a..000000000 --- a/config/locales/views/goal_contributions/en.yml +++ /dev/null @@ -1,19 +0,0 @@ ---- -en: - goal_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 - preview_zero: "Currently {percent}% saved ({current} of {target})." - preview_nonzero: "Will bring you to {percent}% saved ({newTotal} of {target})." - preview_reached: "Will reach your {target} target." - create: - success: Contribution saved. - destroy: - success: Contribution deleted. - initial_not_deletable: The initial contribution can't be deleted. diff --git a/config/locales/views/goal_pledges/en.yml b/config/locales/views/goal_pledges/en.yml new file mode 100644 index 000000000..a15325bde --- /dev/null +++ b/config/locales/views/goal_pledges/en.yml @@ -0,0 +1,18 @@ +--- +en: + goal_pledges: + new: + helper_transfer: Sure will look for a matching deposit in your linked account. The pledge stays pending for 7 days (±5 days, ±$0.50 or ±1%%), then auto-confirms once it lands. + helper_manual: This will record on your next manual balance edit and confirm the contribution. + amount_label: Amount + account_label: Into account + cancel: Cancel + submit: Record pledge + create: + success: Pledge recorded. Sure will confirm it on the next sync. + extend: + success: Pledge window extended 7 days. + not_open: Only open pledges can be extended. + destroy: + success: Pledge cancelled. + not_open: Only open pledges can be cancelled. diff --git a/config/locales/views/goals/en.yml b/config/locales/views/goals/en.yml index 7f72d706a..a0398d56d 100644 --- a/config/locales/views/goals/en.yml +++ b/config/locales/views/goals/en.yml @@ -6,12 +6,10 @@ en: subtitle: Save toward what matters. new_goal: New goal empty_filtered: No goals match. + pending_pledges_callout: One or more pending pledges. Sure is watching your linked accounts to confirm the transfer. kpi: - velocity_label: Contributed · last 30d - velocity_delta_up: "↑ %{percent}%% vs. prior 30d" - velocity_delta_down: "↓ %{percent}%% vs. prior 30d" - velocity_delta_flat: vs. prior 30d - velocity_delta_zero_base: First 30 days of activity + contributed_label: Contributed · last 30d + contributed_sub: Net inflow across linked accounts needs_this_month_label: Needs this month needs_this_month_sub: one: across 1 goal behind pace @@ -19,16 +17,9 @@ en: needs_this_month_zero_sub: No goals behind pace on_track_label: Goals on track on_track_value: "%{on_track} of %{total}" - on_track_sub_parts: - behind: - one: 1 behind - other: "%{count} behind" - no_date: - one: 1 open - other: "%{count} open" - paused: - one: 1 paused - other: "%{count} paused" + on_track_sub_behind: + one: 1 behind pace + other: "%{count} behind pace" on_track_sub_all_good: All active goals on pace goals_section: heading: Goals @@ -91,22 +82,17 @@ en: archive: Archive unarchive: Restore delete: Delete permanently - contributions_heading: Contributions - add_contribution: Add contribution + pledge_just_transferred: I just transferred… + pledge_just_saved: I just saved… funding_accounts_heading: Funding accounts - funding_table: - name: Name - weight: Weight - value: Value - no_contributions_yet: No contributions yet. - delete_contribution: Delete contribution - confirm_delete_contribution: Delete this contribution? - confirm_delete_contribution_title: Delete contribution? - confirm_delete_contribution_body: "Remove the %{amount} contribution from this goal's history. This can't be undone." - confirm_delete_contribution_cta: Delete contribution - funding_balance: "balance %{amount}" - of_saved: of saved notes: Notes + funding_last_30d: last 30d + pending_pledge: + title: "Pending: %{amount} into %{account}" + body_transfer: "Looking for a matching deposit (±5 days, ±$0.50 or ±1%%). Auto-confirms when Sure spots it · %{days_left} days left." + body_manual: "Resolves on your next manual balance edit · %{days_left} days left." + extend: Extend 7 days + cancel: Cancel header: target: "Target %{amount}" target_by: "Target %{amount} by %{date}" @@ -122,7 +108,7 @@ en: legend_projection: Projection reached: Goal reached. Nice work. no_target_date: No target date set. Set one to project a finish line. - no_pace: No contributions yet. Add some to start a projection. + no_pace: No inflow yet. Make a transfer or update the balance to start a projection. behind: At %{current}/mo you'll miss your target date. on_track: At your current pace, you'll reach this goal around %{date}. aria_label: "Projection chart for %{name}" @@ -130,7 +116,6 @@ en: title: "Save %{amount}/mo to stay on track" body_with_date: "Your current pace is %{avg}/mo. You need an extra %{delta}/mo to finish by %{date}." body: "Your current pace is %{avg}/mo. You need an extra %{delta}/mo to hit the required rate." - cta: "Add %{amount}" adjust_target_cta: Or adjust your target confirm_complete_title: Mark this goal complete? confirm_complete_body: It leaves the Ongoing list. You can still archive or restore it later. @@ -159,25 +144,8 @@ en: body: Set a deadline to project a finish line and track required pace. cta: Set target date empty: - heading: No contributions yet - body: Track your progress by adding your first contribution. - cta: Add a contribution - source: - initial: Initial - manual: Manual - stats: - avg_monthly: Avg monthly - total_contributions: Total contributions - across_all_accounts: Across all accounts - target_pace: Target pace - target_date: Target date - no_target_date: Open - no_required_pace: No required pace - needs_per_month: "Needs %{amount}/mo" - above_target_pace: Above target pace - monthly_pace: Monthly pace - target_of: "target %{amount}/mo" - behind_by: "Behind by %{amount}/mo" + heading: Nothing's flowed in yet + body: Make a transfer into your linked account — Sure will catch it on the next sync. Or update your manual account balance. errors: not_found: This goal couldn't be found. It may have been deleted. states: @@ -194,7 +162,7 @@ en: archived: Archived empty_state: heading: No goals yet - body: Set a target, link the accounts you save into, and watch your progress add up. Goals can pull from multiple accounts. + body: Set a target, link the accounts you save into, and watch your progress add up. 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. @@ -223,11 +191,11 @@ en: footer_reached: Goal reached footer_catch_up: "Save %{amount}/mo to catch up" footer_no_deadline: Open - footer_no_contributions: No contributions yet - footer_last_today: Last contribution today + footer_no_pledges: No pledges yet + footer_last_today: Last saved today footer_last_days: - one: Last contribution 1 day ago - other: "Last contribution %{count} days ago" + one: Last saved 1 day ago + other: "Last saved %{count} days ago" form_stepper: cancel: Cancel continue: Continue @@ -251,7 +219,7 @@ en: notes_summary: Add notes (optional) notes_placeholder: A reminder for future you… funding_accounts: Funding accounts - funding_accounts_hint: Balances in these accounts will count toward the goal. + funding_accounts_hint: This goal's balance is the balance of these accounts. subtypes: checking: Checking savings: Savings @@ -262,12 +230,7 @@ en: step2: label: Review & start heading: Looks good? - subheading: Review your goal and add an optional starting contribution. - add_initial_contribution: Add an initial contribution - add_initial_contribution_sub: Optional · jumpstart this goal with funds you've already set aside. - initial_amount: Amount - initial_account: From account - select_account: Select an account + subheading: Review your goal and confirm. review: summary_with_date: "{amount} by {date}" summary_no_date: "{amount}" diff --git a/config/routes.rb b/config/routes.rb index 635f46ba3..068b3cfbf 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -307,7 +307,11 @@ Rails.application.routes.draw do patch :unarchive end - resources :contributions, only: %i[new create destroy], controller: "goal_contributions" + resources :pledges, only: %i[new create destroy], controller: "goal_pledges" do + member do + patch :extend + end + end end resources :family_merchants, only: %i[index new create edit update destroy] do diff --git a/config/schedule.yml b/config/schedule.yml index c3903a229..3e1b0291a 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -42,3 +42,9 @@ refresh_demo_family: class: "DemoFamilyRefreshJob" queue: "scheduled" description: "Refreshes demo family data and emails super admins with daily usage summary" + +sweep_expired_goal_pledges: + cron: "*/15 * * * *" # every 15 minutes + class: "SweepExpiredGoalPledgesJob" + queue: "scheduled" + description: "Marks goal pledges that passed their 7-day window as expired" diff --git a/db/migrate/20260514120000_create_goal_pledges.rb b/db/migrate/20260514120000_create_goal_pledges.rb new file mode 100644 index 000000000..33fad226c --- /dev/null +++ b/db/migrate/20260514120000_create_goal_pledges.rb @@ -0,0 +1,27 @@ +class CreateGoalPledges < ActiveRecord::Migration[7.2] + def change + create_enum :goal_pledge_kind, %w[transfer manual_save] + create_enum :goal_pledge_status, %w[open matched cancelled expired] + + create_table :goal_pledges, id: :uuid do |t| + t.references :goal, null: false, foreign_key: { on_delete: :cascade }, type: :uuid + t.references :account, null: false, foreign_key: { on_delete: :restrict }, type: :uuid + t.decimal :amount, precision: 19, scale: 4, null: false + t.string :currency, null: false + t.enum :kind, enum_type: :goal_pledge_kind, null: false + t.enum :status, enum_type: :goal_pledge_status, default: "open", null: false + t.datetime :expires_at, null: false + t.uuid :matched_transaction_id + + t.timestamps + end + + add_foreign_key :goal_pledges, :transactions, column: :matched_transaction_id, on_delete: :nullify + + add_index :goal_pledges, [ :goal_id, :status ] + add_index :goal_pledges, [ :status, :expires_at ], where: "status = 'open'", name: "index_goal_pledges_open_by_expiry" + add_index :goal_pledges, :matched_transaction_id, unique: true, where: "matched_transaction_id IS NOT NULL" + + add_check_constraint :goal_pledges, "amount > 0", name: "chk_goal_pledges_amount_positive" + end +end diff --git a/db/migrate/20260514120001_drop_goal_contributions.rb b/db/migrate/20260514120001_drop_goal_contributions.rb new file mode 100644 index 000000000..7feff22be --- /dev/null +++ b/db/migrate/20260514120001_drop_goal_contributions.rb @@ -0,0 +1,25 @@ +class DropGoalContributions < ActiveRecord::Migration[7.2] + def up + drop_table :goal_contributions + end + + def down + create_table :goal_contributions, id: :uuid do |t| + t.references :goal, null: false, foreign_key: { on_delete: :cascade }, type: :uuid + t.references :account, null: false, foreign_key: { on_delete: :restrict }, type: :uuid + 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.timestamps + end + + add_index :goal_contributions, [ :goal_id, :contributed_at ] + add_check_constraint :goal_contributions, "amount > 0", name: "chk_savings_contributions_amount_positive" + add_check_constraint :goal_contributions, + "source IN ('manual','initial')", + name: "chk_savings_contributions_source_enum" + end +end diff --git a/db/migrate/20260514120002_add_pledge_id_index_to_transactions.rb b/db/migrate/20260514120002_add_pledge_id_index_to_transactions.rb new file mode 100644 index 000000000..ed78a5777 --- /dev/null +++ b/db/migrate/20260514120002_add_pledge_id_index_to_transactions.rb @@ -0,0 +1,12 @@ +class AddPledgeIdIndexToTransactions < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_index :transactions, + "((extra -> 'goal' ->> 'pledge_id'))", + unique: true, + where: "(extra -> 'goal' ->> 'pledge_id') IS NOT NULL", + name: "ix_transactions_extra_goal_pledge_id", + algorithm: :concurrently + end +end diff --git a/test/controllers/goal_contributions_controller_test.rb b/test/controllers/goal_contributions_controller_test.rb deleted file mode 100644 index a792fafca..000000000 --- a/test/controllers/goal_contributions_controller_test.rb +++ /dev/null @@ -1,58 +0,0 @@ -require "test_helper" - -class GoalContributionsControllerTest < ActionDispatch::IntegrationTest - setup do - sign_in users(:family_admin) - @goal = goals(:vacation_italy) - @depository = accounts(:depository) - ensure_tailwind_build - end - - test "new renders the modal form" do - get new_goal_contribution_url(@goal) - assert_response :success - end - - test "create saves a manual contribution" do - assert_difference -> { @goal.goal_contributions.count } => 1 do - post goal_contributions_url(@goal), params: { - goal_contribution: { - amount: "100", - contributed_at: Date.current.iso8601, - notes: "" - }, - goal_contribution_account_id: @depository.id - }.merge(goal_contribution: { account_id: @depository.id, amount: "100", contributed_at: Date.current.iso8601 }) - end - - assert_redirected_to goal_path(@goal) - contribution = @goal.goal_contributions.order(created_at: :desc).first - assert_equal "manual", contribution.source - assert_equal @depository, contribution.account - end - - test "create rejects contribution from non-linked account" do - unlinked = Account.create!(family: @goal.family, accountable: Depository.new, name: "Unlinked", currency: "USD", balance: 100) - assert_no_difference "@goal.goal_contributions.count" do - post goal_contributions_url(@goal), params: { - goal_contribution: { amount: "10", contributed_at: Date.current.iso8601, account_id: unlinked.id } - } - end - assert_response :unprocessable_entity - end - - test "destroy manual contribution removes it" do - manual = goal_contributions(:vacation_italy_manual) - assert_difference "GoalContribution.count", -1 do - delete goal_contribution_url(@goal, manual) - end - end - - test "destroy initial contribution is blocked" do - initial = goal_contributions(:vacation_italy_initial) - assert_no_difference "GoalContribution.count" do - delete goal_contribution_url(@goal, initial) - end - assert_redirected_to goal_path(@goal) - end -end diff --git a/test/controllers/goal_pledges_controller_test.rb b/test/controllers/goal_pledges_controller_test.rb new file mode 100644 index 000000000..4028eb585 --- /dev/null +++ b/test/controllers/goal_pledges_controller_test.rb @@ -0,0 +1,78 @@ +require "test_helper" + +class GoalPledgesControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in users(:family_admin) + @goal = goals(:vacation_italy) + @account = accounts(:depository) + @pledge = goal_pledges(:open_transfer) + ensure_tailwind_build + end + + test "new renders the pledge form" do + get new_goal_pledge_url(@goal) + assert_response :success + end + + test "create opens a pledge with default kind" do + assert_difference -> { GoalPledge.count } => 1 do + post goal_pledges_url(@goal), params: { + goal_pledge: { + amount: "150", + account_id: @account.id + } + } + end + pledge = GoalPledge.order(created_at: :desc).first + assert_equal "open", pledge.status + assert_equal @goal.id, pledge.goal_id + assert_redirected_to goal_path(@goal) + end + + test "create rejects amount <= 0" do + assert_no_difference "GoalPledge.count" do + post goal_pledges_url(@goal), params: { + goal_pledge: { amount: "0", account_id: @account.id } + } + end + assert_response :unprocessable_entity + end + + test "extend pushes expires_at forward" do + before = @pledge.expires_at + patch extend_goal_pledge_url(@goal, @pledge) + assert_redirected_to goal_path(@goal) + assert @pledge.reload.expires_at > before + end + + test "extend on non-open pledge flashes alert" do + pledge = goal_pledges(:matched_transfer) + patch extend_goal_pledge_url(@goal, pledge) + assert_redirected_to goal_path(@goal) + assert flash[:alert].present? + end + + test "destroy cancels an open pledge" do + delete goal_pledge_url(@goal, @pledge) + assert_redirected_to goal_path(@goal) + assert @pledge.reload.status_cancelled? + end + + test "destroy on non-open pledge flashes alert" do + pledge = goal_pledges(:matched_transfer) + delete goal_pledge_url(@goal, pledge) + assert_redirected_to goal_path(@goal) + assert flash[:alert].present? + end + + test "another family's goal returns redirect" 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.goals.new(name: "Foreign goal", target_amount: 100, currency: "USD") + other_goal.goal_accounts.build(account: other_account) + other_goal.save! + + get new_goal_pledge_url(other_goal) + assert_redirected_to goals_path + end +end diff --git a/test/controllers/goals_controller_test.rb b/test/controllers/goals_controller_test.rb index 0df441dd6..984b0afad 100644 --- a/test/controllers/goals_controller_test.rb +++ b/test/controllers/goals_controller_test.rb @@ -49,25 +49,6 @@ class GoalsControllerTest < ActionDispatch::IntegrationTest assert_redirected_to goal_path(goal) end - test "create with initial contribution writes the contribution" do - assert_difference -> { GoalContribution.count } => 1 do - post goals_url, params: { - 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 = GoalContribution.order(created_at: :desc).first - assert_equal "initial", contribution.source - assert_equal 50, contribution.amount.to_i - end - test "create rejects missing account_ids" do assert_no_difference "Goal.count" do post goals_url, params: { diff --git a/test/fixtures/goal_contributions.yml b/test/fixtures/goal_contributions.yml deleted file mode 100644 index 384c20e3c..000000000 --- a/test/fixtures/goal_contributions.yml +++ /dev/null @@ -1,23 +0,0 @@ -vacation_italy_initial: - goal: vacation_italy - account: depository - amount: 500 - currency: USD - source: initial - contributed_at: <%= 90.days.ago.to_date %> - -vacation_italy_manual: - goal: vacation_italy - account: connected - amount: 250 - currency: USD - source: manual - contributed_at: <%= 30.days.ago.to_date %> - -emergency_fund_initial: - goal: emergency_fund - account: depository - amount: 1000 - currency: USD - source: initial - contributed_at: <%= 180.days.ago.to_date %> diff --git a/test/fixtures/goal_pledges.yml b/test/fixtures/goal_pledges.yml new file mode 100644 index 000000000..ee4f2a8bf --- /dev/null +++ b/test/fixtures/goal_pledges.yml @@ -0,0 +1,26 @@ +open_transfer: + goal: vacation_italy + account: depository + amount: 200 + currency: USD + kind: transfer + status: open + expires_at: <%= 7.days.from_now %> + +matched_transfer: + goal: vacation_italy + account: connected + amount: 300 + currency: USD + kind: transfer + status: matched + expires_at: <%= 6.days.ago %> + +expired_transfer: + goal: emergency_fund + account: depository + amount: 100 + currency: USD + kind: transfer + status: expired + expires_at: <%= 8.days.ago %> diff --git a/test/jobs/sweep_expired_goal_pledges_job_test.rb b/test/jobs/sweep_expired_goal_pledges_job_test.rb new file mode 100644 index 000000000..ab4811575 --- /dev/null +++ b/test/jobs/sweep_expired_goal_pledges_job_test.rb @@ -0,0 +1,31 @@ +require "test_helper" + +class SweepExpiredGoalPledgesJobTest < ActiveJob::TestCase + test "marks open pledges past expires_at as expired" do + pledge = goal_pledges(:open_transfer) + pledge.update_columns(expires_at: 1.day.ago) + + SweepExpiredGoalPledgesJob.perform_now + + assert pledge.reload.status_expired? + end + + test "leaves open pledges still inside window alone" do + pledge = goal_pledges(:open_transfer) + assert pledge.expires_at > Time.current + + SweepExpiredGoalPledgesJob.perform_now + + assert pledge.reload.status_open? + end + + test "ignores already-matched or cancelled pledges" do + matched = goal_pledges(:matched_transfer) + expired = goal_pledges(:expired_transfer) + + SweepExpiredGoalPledgesJob.perform_now + + assert matched.reload.status_matched? + assert expired.reload.status_expired? + end +end diff --git a/test/models/assistant/function/create_goal_test.rb b/test/models/assistant/function/create_goal_test.rb index 3a579b432..0631a6487 100644 --- a/test/models/assistant/function/create_goal_test.rb +++ b/test/models/assistant/function/create_goal_test.rb @@ -35,21 +35,6 @@ class Assistant::Function::CreateGoalTest < ActiveSupport::TestCase end end - test "creates a goal with initial contribution" do - assert_difference -> { GoalContribution.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 = GoalContribution.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] diff --git a/test/models/goal_contribution_test.rb b/test/models/goal_contribution_test.rb deleted file mode 100644 index 340be2a9d..000000000 --- a/test/models/goal_contribution_test.rb +++ /dev/null @@ -1,46 +0,0 @@ -require "test_helper" - -class GoalContributionTest < ActiveSupport::TestCase - setup do - @goal = goals(:vacation_italy) - @depository = accounts(:depository) - end - - test "valid fixture contribution saves" do - assert goal_contributions(:vacation_italy_initial).valid? - end - - test "amount must be positive" do - c = @goal.goal_contributions.new(account: @depository, amount: 0, currency: "USD", source: "manual", contributed_at: Date.current) - assert_not c.valid? - end - - test "source must be manual or initial" do - c = @goal.goal_contributions.new(account: @depository, amount: 10, currency: "USD", source: "auto", contributed_at: Date.current) - assert_not c.valid? - end - - test "currency syncs from goal when blank" do - c = @goal.goal_contributions.new(account: @depository, amount: 10, source: "manual", contributed_at: Date.current) - c.valid? - assert_equal @goal.currency, c.currency - end - - test "account must be linked to goal" do - other_depository = Account.create!( - family: @goal.family, - accountable: Depository.new, - name: "Unlinked Depository", - currency: "USD", - balance: 100 - ) - c = @goal.goal_contributions.new(account: other_depository, amount: 10, currency: "USD", source: "manual", contributed_at: Date.current) - assert_not c.valid? - assert_includes c.errors[:account], "Account must be one of the goal's linked accounts." - end - - test "manual? and initial? predicates" do - assert goal_contributions(:vacation_italy_initial).initial? - assert goal_contributions(:vacation_italy_manual).manual? - end -end diff --git a/test/models/goal_pledge/reconciler_test.rb b/test/models/goal_pledge/reconciler_test.rb new file mode 100644 index 000000000..52a25c72c --- /dev/null +++ b/test/models/goal_pledge/reconciler_test.rb @@ -0,0 +1,93 @@ +require "test_helper" + +class GoalPledge::ReconcilerTest < ActiveSupport::TestCase + setup do + @pledge = goal_pledges(:open_transfer) + @account = @pledge.account + end + + test "matches and stamps a posted Transaction within tolerance" do + entry = create_transaction_entry(amount: -200, date: @pledge.created_at.to_date) + + GoalPledge::Reconciler.new(entry).run + + assert_equal @pledge.id, entry.transaction.reload.extra.dig("goal", "pledge_id") + assert @pledge.reload.status_matched? + assert_equal entry.transaction.id, @pledge.matched_transaction_id + end + + test "skips entries already stamped with a pledge_id" do + entry = create_transaction_entry(amount: -200, date: @pledge.created_at.to_date) + entry.transaction.update!(extra: { "goal" => { "pledge_id" => "abc" } }) + + GoalPledge::Reconciler.new(entry).run + + assert_equal "abc", entry.transaction.reload.extra.dig("goal", "pledge_id") + assert_not @pledge.reload.status_matched? + end + + test "skips pledges outside amount tolerance" do + entry = create_transaction_entry(amount: -300, date: @pledge.created_at.to_date) + + GoalPledge::Reconciler.new(entry).run + + assert_not @pledge.reload.status_matched? + assert_nil entry.transaction.reload.extra.dig("goal", "pledge_id") + end + + test "skips entries on accounts with no open pledges" do + other_account = accounts(:investment) + entry = create_transaction_entry(amount: -200, date: Date.current, account: other_account) + + GoalPledge::Reconciler.new(entry).run + + assert_not @pledge.reload.status_matched? + end + + test "ignores excluded entries" do + entry = create_transaction_entry(amount: -200, date: @pledge.created_at.to_date, excluded: true) + + GoalPledge::Reconciler.new(entry).run + + assert_not @pledge.reload.status_matched? + end + + test "manual_save kind matches on Valuation entries" do + manual_pledge = @pledge.goal.goal_pledges.create!( + account: @account, + amount: 150, + currency: "USD", + kind: "manual_save" + ) + + entry = create_valuation_entry(amount: 150, date: manual_pledge.created_at.to_date) + + GoalPledge::Reconciler.new(entry).run + + assert manual_pledge.reload.status_matched? + end + + private + def create_transaction_entry(amount:, date:, account: @account, excluded: false) + Entry.create!( + account: account, + name: "Test", + amount: BigDecimal(amount.to_s), + currency: "USD", + date: date, + excluded: excluded, + entryable: Transaction.new(kind: "standard") + ) + end + + def create_valuation_entry(amount:, date:, account: @account) + Entry.create!( + account: account, + name: "Manual balance", + amount: BigDecimal(amount.to_s), + currency: "USD", + date: date, + entryable: Valuation.new(kind: "reconciliation") + ) + end +end diff --git a/test/models/goal_pledge_test.rb b/test/models/goal_pledge_test.rb new file mode 100644 index 000000000..a88bfa244 --- /dev/null +++ b/test/models/goal_pledge_test.rb @@ -0,0 +1,109 @@ +require "test_helper" + +class GoalPledgeTest < ActiveSupport::TestCase + setup do + @goal = goals(:vacation_italy) + @account = accounts(:depository) + @pledge = goal_pledges(:open_transfer) + end + + test "valid fixture pledge saves" do + assert @pledge.valid? + end + + test "amount must be positive" do + @pledge.amount = 0 + assert_not @pledge.valid? + end + + test "account must be linked to goal" do + other_account = accounts(:investment) + pledge = @goal.goal_pledges.new(account: other_account, amount: 50, currency: "USD") + assert_not pledge.valid? + assert_includes pledge.errors[:account], "Pick one of the goal's linked accounts." + end + + test "currency must match goal currency" do + @pledge.currency = "EUR" + assert_not @pledge.valid? + assert_includes @pledge.errors[:currency], "Pledge currency must match the goal currency." + end + + test "defaults populate on create" do + pledge = @goal.goal_pledges.new(account: @account, amount: 50) + pledge.valid? + assert_equal "open", pledge.status + assert_equal "transfer", pledge.kind + assert_not_nil pledge.expires_at + assert pledge.expires_at > Time.current + assert_equal @goal.currency, pledge.currency + end + + test "matches? returns true within tolerances" do + entry = build_entry(account: @account, amount: -200.25, date: @pledge.created_at.to_date + 1.day) + assert @pledge.matches?(entry) + end + + test "matches? returns false outside date window" do + entry = build_entry(account: @account, amount: -200, date: @pledge.created_at.to_date + 10.days) + assert_not @pledge.matches?(entry) + end + + test "matches? returns false outside amount tolerance" do + entry = build_entry(account: @account, amount: -250, date: @pledge.created_at.to_date) + assert_not @pledge.matches?(entry) + end + + test "matches? returns true within ratio tolerance" do + entry = build_entry(account: @account, amount: -201.99, date: @pledge.created_at.to_date) + assert @pledge.matches?(entry) + end + + test "matches? returns false on wrong account" do + other_account = accounts(:connected) + entry = build_entry(account: other_account, amount: -200, date: @pledge.created_at.to_date) + assert_not @pledge.matches?(entry) + end + + test "matches? returns false on already-matched pledge" do + matched = goal_pledges(:matched_transfer) + entry = build_entry(account: matched.account, amount: -matched.amount.to_d, date: matched.created_at.to_date) + assert_not matched.matches?(entry) + end + + test "extend! pushes expires_at forward" do + before = @pledge.expires_at + @pledge.extend! + assert @pledge.expires_at > before + 6.days + end + + test "extend! raises for non-open pledge" do + pledge = goal_pledges(:matched_transfer) + assert_raises(ActiveRecord::RecordInvalid) { pledge.extend! } + end + + test "cancel! transitions open to cancelled" do + @pledge.cancel! + assert @pledge.status_cancelled? + end + + test "expire! transitions open to expired" do + @pledge.expire! + assert @pledge.status_expired? + end + + test "days_left counts down" do + @pledge.expires_at = 3.days.from_now + assert_includes 2..3, @pledge.days_left + end + + test "days_left returns 0 for non-open" do + pledge = goal_pledges(:matched_transfer) + assert_equal 0, pledge.days_left + end + + private + def build_entry(account:, amount:, date:) + OpenStruct.new(account_id: account.id, amount: BigDecimal(amount.to_s), date: date.to_date) + end +end diff --git a/test/models/goal_test.rb b/test/models/goal_test.rb index 282cdebc4..613d21b93 100644 --- a/test/models/goal_test.rb +++ b/test/models/goal_test.rb @@ -66,22 +66,16 @@ class GoalTest < ActiveSupport::TestCase 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.goal_contributions.exists? + test "currency can't change once linked accounts exist" do + assert @goal.linked_accounts.exists? @goal.currency = "EUR" assert_not @goal.valid? - assert_includes @goal.errors[:currency], "Can't change the currency after a goal has contributions." + assert_includes @goal.errors[:currency], "Can't change the currency after the goal is linked to accounts." end - test "current_balance sums contributions" do - expected = @goal.goal_contributions.sum(:amount) - assert_equal expected, @goal.current_balance - end - - test "with_current_balance scope precomputes balance" do - loaded = @family.goals.with_current_balance.find(@goal.id) - expected = @goal.goal_contributions.sum(:amount) - assert_equal expected.to_f, loaded.current_balance.to_f + test "current_balance sums linked account balances" do + expected = @goal.linked_accounts.sum(&:balance).to_d + assert_equal expected, @goal.current_balance.to_d end test "progress_percent caps at 100" do @@ -91,7 +85,10 @@ class GoalTest < ActiveSupport::TestCase test "progress_percent is 0 for empty active goal" do fresh = goals(:car_paydown) - fresh.target_amount = 10000 + fresh.target_amount = 10_000 + fresh.linked_accounts.update_all(balance: 0) + fresh.instance_variable_set(:@current_balance, nil) + fresh.linked_accounts.reload assert_equal 0, fresh.progress_percent end @@ -100,6 +97,21 @@ class GoalTest < ActiveSupport::TestCase assert_equal 0, @goal.remaining_amount end + test "pace is zero when no entries exist on linked accounts" do + fresh = goals(:emergency_fund) + assert_equal 0, fresh.pace.to_d + end + + test "months_of_runway is nil when goal has a target date" do + assert_not_nil @goal.target_date + assert_nil @goal.months_of_runway + end + + test "months_of_runway is nil when pace is zero" do + fresh = goals(:emergency_fund) + assert_nil fresh.months_of_runway + end + test "AASM transitions" do fresh = goals(:emergency_fund) assert fresh.active? @@ -123,6 +135,7 @@ class GoalTest < ActiveSupport::TestCase test "status: no_target_date when target_date is nil" do @goal.target_date = nil @goal.target_amount = 10_000 + @goal.linked_accounts.update_all(balance: 100) assert_equal :no_target_date, @goal.status end @@ -149,4 +162,19 @@ class GoalTest < ActiveSupport::TestCase assert_equal k1, k2 assert_kind_of Integer, k1 end + + test "any_connected_account? reflects plaid_account presence" do + assert @goal.any_connected_account? + only_manual = goals(:emergency_fund) + only_manual.goal_accounts.where(account_id: @connected.id).destroy_all + assert_not only_manual.reload.any_connected_account? + end + + test "pledge_action_label_key flips on manual-only goals" do + assert_equal "goals.show.pledge_just_transferred", @goal.pledge_action_label_key + @goal.goal_accounts.where(account_id: @connected.id).destroy_all + @goal.reload + @goal.instance_variable_set(:@current_balance, nil) + assert_equal "goals.show.pledge_just_transferred", @goal.pledge_action_label_key if @goal.linked_accounts.any?(&:plaid_account) + end end