mirror of
https://github.com/we-promise/sure.git
synced 2026-08-12 11:10:20 +00:00
Correctness: - GoalPledge#matches? rejects outflows on transfer pledges so a +$200 purchase no longer satisfies a $200 deposit pledge after .abs - GoalsController#sync_linked_accounts! saves through the goal so currency/depository/family validations actually run on update - AlreadyClaimedError replaces empty RecordInvalid in resolve_with! and reconciler rescues the dedicated class - SweepExpiredGoalPledgesJob wraps each expire! in a per-record rescue - Assistant::Function::CreateGoal disambiguates duplicate account names and returns an absolute URL via mailer host config - Family#savings_inflow_velocity defensively scopes from the family's accounts (was Account.joins(:goal_accounts).where(goal_id: ...)) - GoalPledgesController#set_goal preloads linked_accounts + providers to drop the N+1 on any_connected_account? - Stepper subtitle update walks to the enclosing dialog before querySelector so two stepper instances don't fight over one header - categories/_form.html.erb data-action targets color-icon-picker, not the non-existent "category" controller UX / visual: - Projection chart drops preserveAspectRatio="none" and pins endDate at today for past-due goals so the today marker stays in-domain - _color_picker / categories form swap non-standard border-1 for border - Goals index search input uses ring-alpha-black-100 (was raw gray-500) Refactors: - Goal#header_summary extracts the multi-line ERB header block - Goal#catch_up_delta_money sums open_pledges in SQL - Goal#projection_summary uses I18n.l for the on-track month label - Account#default_pledge_kind moves the manual/transfer decision out of GoalPledgesController - GoalPledge::Reconciler iterates ordered (created_at, id) so first-claim wins is deterministic under non-sequential PKs - Goals::FundingAccountsBreakdownComponent + Goals::AccountStackComponent use clamp(0..) instead of Float::INFINITY / [x, 0].max - Goals::StatusPillComponent#label provides a titleize fallback - Goal projection chart skips the redundant initial _draw and reuses the snapped point in the past branch (no double-bisect) - Goal pledge preview drops maximumFractionDigits: 0 so USD/EUR show cents while JPY/KRW stay whole-unit - Demo generator captures the Wedding fund goal in the seed loop instead of looking it up by hardcoded name Tests: - GoalPledgeTest: outflow rejection - GoalsControllerTest: cross-currency attachment rejected on update - SweepExpiredGoalPledgesJobTest: cancelled coverage + per-record rescue - GoalTest: pledge_action_label_key flips to manual_save without an unconditional guard
90 lines
3.1 KiB
Ruby
90 lines
3.1 KiB
Ruby
class Goals::FundingAccountsBreakdownComponent < ApplicationComponent
|
|
COLUMN_WINDOW_DAYS = 30
|
|
TREND_WINDOW_DAYS = 90
|
|
|
|
def initialize(goal:)
|
|
@goal = goal
|
|
end
|
|
|
|
attr_reader :goal
|
|
|
|
def rows
|
|
@rows ||= goal.linked_accounts.sort_by { |a| -a.balance.to_d }.map do |account|
|
|
totals = inflow_totals_for(account)
|
|
{
|
|
account: account,
|
|
balance: account.balance.to_d,
|
|
balance_money: Money.new(account.balance.to_d, goal.currency),
|
|
last_30_money: Money.new(totals[:last_30], goal.currency),
|
|
last_90_money: Money.new(totals[:last_90], goal.currency)
|
|
}
|
|
end
|
|
end
|
|
|
|
def total
|
|
@total ||= rows.sum { |r| r[:balance].to_d }
|
|
end
|
|
|
|
def percent_for(balance)
|
|
return 0 if total.zero?
|
|
((balance.to_d / total) * 100).round
|
|
end
|
|
|
|
# Pull from the goal's per-goal account color map so the colors here
|
|
# (distribution bar, row avatars) match the AccountStackComponent on the
|
|
# index card. Stable + collision-free within the goal up to PALETTE size.
|
|
def color_for(account)
|
|
goal.account_color_map[account.id] || Goals::AvatarComponent.color_for(account.name)
|
|
end
|
|
|
|
# Label shown beneath the account name. Prefers the depository subtype
|
|
# ("Savings", "HSA"…) over the bare accountable_type ("Depository") so the
|
|
# subline carries useful signal. Falls back to the accountable type's i18n
|
|
# entry (`accounts.types.*`), and finally to a `titleize` so the row is
|
|
# never blank if a string is missing.
|
|
def accountable_label(account)
|
|
if account.subtype.present?
|
|
I18n.t("goals.form_stepper.step1.subtypes.#{account.subtype}", default: account.subtype.titleize)
|
|
else
|
|
type = account.accountable_type.to_s
|
|
I18n.t("accounts.types.#{type.underscore}", default: type.titleize)
|
|
end
|
|
end
|
|
|
|
private
|
|
# Per-account net inflow for both windows in one pass over the 90-day
|
|
# entries set. Entry amount sign in Sure: inflow is negative; flip and
|
|
# clamp ≥ 0.
|
|
def inflow_totals_for(account)
|
|
inflow_totals_map[account.id] || { last_30: 0.to_d, last_90: 0.to_d }
|
|
end
|
|
|
|
def inflow_totals_map
|
|
@inflow_totals_map ||= begin
|
|
account_ids = goal.linked_accounts.map(&:id)
|
|
return {} if account_ids.empty?
|
|
|
|
cutoff_30 = COLUMN_WINDOW_DAYS.days.ago.to_date
|
|
|
|
rows = Entry
|
|
.joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'")
|
|
.where(account_id: account_ids, date: TREND_WINDOW_DAYS.days.ago.to_date..Date.current)
|
|
.where(excluded: false)
|
|
.merge(Transaction.excluding_pending)
|
|
.pluck(:account_id, :date, :amount)
|
|
|
|
result = Hash.new { |h, k| h[k] = { last_30: 0.to_d, last_90: 0.to_d } }
|
|
rows.each do |aid, date, amount|
|
|
inflow = (-amount.to_d).clamp(0..)
|
|
result[aid][:last_90] += inflow
|
|
result[aid][:last_30] += inflow if date >= cutoff_30
|
|
end
|
|
result
|
|
end
|
|
rescue StandardError => e
|
|
Rails.logger.error("Inflow totals map for goal #{goal.id} failed: #{e.class}: #{e.message}")
|
|
Sentry.capture_exception(e) if defined?(Sentry)
|
|
{}
|
|
end
|
|
end
|