+
<% 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? %>
+
+ <% 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 %>
+
+
+
+
+
+
+ <%= 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 %>
-
- <% contributions.each do |contribution| %>
-
- <%= render Goals::AvatarComponent.new(
- name: contribution.account.name,
- color: Goals::AvatarComponent.color_for(contribution.account.name),
- size: "sm"
- ) %>
-
-
<%= contribution.account.name %>
-
- <%= I18n.l(contribution.contributed_at, format: :long) %> ·
- <%= t("goals.show.source.#{contribution.source}") %>
-
-
- +<%= contribution.amount_money.format %>
- <% if contribution.manual? %>
- <%= render DS::Menu.new do |menu| %>
- <% menu.with_item(
- variant: "button",
- text: t("goals.show.delete_contribution"),
- icon: "trash-2",
- destructive: true,
- href: goal_contribution_path(@goal, contribution),
- method: :delete,
- confirm: CustomConfirm.new(
- destructive: true,
- title: t("goals.show.confirm_delete_contribution_title"),
- body: t("goals.show.confirm_delete_contribution_body", amount: contribution.amount_money.format),
- btn_text: t("goals.show.confirm_delete_contribution_cta")
- )
- ) %>
- <% end %>
- <% else %>
-
- <% end %>
-
- <% end %>
-
-<% 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 @@
—
-
-