Files
sure/app/controllers/bills_controller.rb
T
Brandon fe0d27471d feat(bills): the bills pages, calendar feed and in-page AI helpers (#3202)
* feat(bills): the bills pages, calendar feed and in-page AI helpers

Second of three chunks carved out of #3083, stacked on the schema and domain
core. This is everything a user sees and clicks. The whole surface sits behind
the preview flag, so it is unreachable until someone opts in.

Pages, all under one nav entry:

- the pay run, a month calendar, the full bills table, and the paycheck planner
- a detail drawer per bill, with payment history, price changes and cost
  analytics
- create and edit flows for bills, subscriptions, installment plans and income

The overview marks pay periods inside the month, so a weekly paycheck no longer
reads as one undifferentiated month of bills. Markers appear only when income
actually subdivides the month, which means monthly and undeclared income render
exactly as before and there is no new setting to configure.

Navigation and design system:

- one preview-gated nav item shared by the desktop rail and the mobile bar
- DS::Sparkline for payment-history charts, replacing raw SVG in views
- status badges render through DS::Pill rather than hand-rolled spans
- the suggestions panel is a disclosure that remembers being collapsed, per
  device, the way privacy mode and the sidebar width already do
- every surface reflows to phone widths without horizontal scroll

Calendar feed: a signed ICS feed per family, served sessionless by token, with a
reset that revokes previously shared URLs.

In-page AI helpers: smart fill on the bill form and a smart configuration
proposal on an existing bill, each reading a bounded slice of charge history.
Provider-side prompt assembly sits behind the existing LlmConcept interface,
with an implementation for each of the two providers. These belong here rather
than with the assistant tools because they are buttons on these pages and lean
on the provider suggester, not on the tool registry.

Suite 7,776 runs green apart from the pre-existing passkey-session flake, which passes standalone. Rubocop clean, eager loading verified. The hosting guide for the feature ships here rather than with the schema, since its instructions walk pages this PR introduces.

* Render the suggested strip through DS::Disclosure

The hand-rolled details pair predates the component. The card_inset
variant is the same shape, so the strip now inherits the design system
chrome, and the persisted-disclosure controller rides along unchanged.

* Route the remaining hand-rolled chips through the design system

The subscription-state chips, rule-match chips and match-reason chips
become DS::Pill, with the state chips extracted to one shared partial so
the drawer and the summary tab stop carrying copy-pasted markup. The AI
prompt chips become DS::Button and the bills-index filter becomes
DS::SearchInput, both of which this PR already uses elsewhere for the
same shapes.

* Fix erb_lint whitespace offenses in bills views

* Address the post-ready review round

* Require a writable destination account and gate the feed on preview

* Reject an unresolvable declared account out loud
2026-09-02 02:08:58 +02:00

648 lines
28 KiB
Ruby

class BillsController < ApplicationController
include RecurringFeatureGuardable
# What the All-bills status filter offers: payment state, plus the two
# lifecycle values people actually use. suggested and inactive are detection
# plumbing and stay out.
PAYMENT_FILTERS = %w[overdue due partial paid].freeze
# Pause stores `inactive`, so the filter has to accept both. `paused` arrives
# only by import or the v1 API; `ended` only by dismissing a suggestion.
LIFECYCLE_STATUSES = { "paused" => %w[inactive paused], "ended" => %w[ended] }.freeze
LIFECYCLE_FILTERS = LIFECYCLE_STATUSES.keys.freeze
STATUS_FILTERS = (PAYMENT_FILTERS + LIFECYCLE_FILTERS).freeze
# Enough to answer "what happens next" without becoming a second bill list.
NEXT_UP_LIMIT = 4
# Six covers a month of weekly paydays with room for a leading bridge.
PAY_PERIOD_LIMIT = 6
before_action :ensure_recurring_enabled
# The pay-run workspace, built on occurrence rows rather than series
# projections, so every row has a real due date and payment state.
def index
if params[:view] == "subscriptions"
redirect_to bills_path(view: "all", q: { bill_type: "subscription" })
return
end
@view = %w[all calendar paycheck].include?(params[:view]) ? params[:view] : "overview"
# An upgraded instance can arrive with series but no occurrence rows,
# because nothing under the old build ever materialized them. One inline,
# idempotent generation covers every view. The cache is a cost gate, not
# correctness -- the none? probe stays authoritative; the guard only stops
# an all-ended-series family from re-running generation on every GET.
cache_key = "bills:materialized:#{Current.family.id}"
if Current.family.recurring_occurrences.none? && !Rails.cache.read(cache_key)
materialize_missing_occurrences
Rails.cache.write(cache_key, true, expires_in: 12.hours)
end
case @view
when "all"
load_all_series
render :all
return
when "calendar"
load_calendar
render :calendar
return
when "paycheck"
load_paycheck_plan
render :paycheck
return
end
occurrences = payable_occurrences
preload_allocation_sums(occurrences)
today = Date.current
month_end = today.end_of_month
open_occurrences, closed = occurrences.partition(&:scheduled?)
active_open, @dormant = open_occurrences.partition { |occurrence| occurrence.recurring_transaction.active? }
@overdue, upcoming = active_open.partition { |occurrence| occurrence.derived_state == :overdue }
this_month, later = upcoming.partition { |occurrence| occurrence.due_on <= month_end }
@this_month = this_month.sort_by(&:due_on)
@overdue = @overdue.sort_by(&:due_on)
@dormant = @dormant.sort_by(&:due_on)
# Beyond this month, one row per series: a weekly bill's next six
# occurrences are not six separate things to think about yet.
@later = later.group_by(&:recurring_transaction_id)
.values
.map { |group| group.min_by(&:due_on) }
.sort_by(&:due_on)
@paid_this_month = closed.select { |occurrence| occurrence.paid? && occurrence.due_on >= today.beginning_of_month }
.sort_by(&:due_on)
compute_kpis(today, month_end)
@month_pay_periods = month_pay_periods(today, month_end)
@detected_awaiting_review = detected_awaiting_review
# Fresh detections wait here for confirm/dismiss. Reviewing them is bill
# work, so the strip lives on this page as well as in Settings.
# Loaded once: the view asks any?/none? and the partial counts and
# iterates, which would otherwise be separate queries.
@suggested_series = accessible_suggested_series.includes(:merchant).order(next_expected_date: :asc).load
@has_transaction_history = Current.family.entries.where(entryable_type: "Transaction").exists?
@suggested_allocations = suggested_allocations
# A row waiting on a match decision offers Review rather than Find.
# Already loaded for the queue above, so indexing is free.
@suggestions_by_occurrence = @suggested_allocations.index_by(&:recurring_occurrence_id)
@notices = collect_notices
# The month as one chronological list, paid rows in place under a check.
# Overdue rows are excluded: they get their own section.
@month_rows = (@this_month + @paid_this_month).sort_by(&:due_on)
# Next up filters on the DATE, not derived_state: a bill two days late is
# still :due within its grace period, and nothing already past its due date
# belongs under "what happens next".
@month_bill_count = @overdue.size + @month_rows.size
@next_up = (@this_month + @later)
.select { |occurrence| occurrence.effective_due_on >= today }
.sort_by(&:effective_due_on)
.first(NEXT_UP_LIMIT)
end
# One-click detection for a page with nothing on it: run the full pipeline
# and land back here, where the review strip presents anything found. The
# flash counts only rows this run created and this user can see -- the
# pattern total would count refreshes of series that already exist.
def detect
before_ids = accessible_suggested_series.pluck(:id)
# backfill: user-triggered detection always reconstructs history (the
# backfiller is idempotent). nil means another run holds the family lock.
result = RecurringTransaction::Pipeline.new(Current.family).run_with_lock!(backfill: true)
flash[:notice] =
if result.nil?
t(".already_running")
else
found = accessible_suggested_series.where.not(id: before_ids).count
found.positive? ? t(".found", count: found) : t(".none_found")
end
redirect_to bills_path
end
# Revocation for the iCal feed: every previously shared URL stops working.
def reset_feed_token
Current.family.reset_bills_feed_token!
flash[:notice] = t(".done")
redirect_to bills_path(view: "calendar")
end
# One bill's complete story: current state, history, what is coming, cost.
def show
@series = Current.family.recurring_transactions
.accessible_by(Current.user)
.includes(:merchant)
.find(params[:id])
# A row expansion names the cycle it was opened from; the bill's own page
# has no cycle in mind and asks the series. Looked up through the series, so
# an id from another bill resolves to nothing rather than to someone else's
# occurrence.
@current_occurrence =
if params[:occurrence].present?
@series.recurring_occurrences.find_by(id: params[:occurrence]) || @series.current_occurrence
else
@series.current_occurrence
end
@history = @series.recurring_occurrences.closed.order(due_on: :desc).limit(12).includes(:allocations)
@upcoming = @series.schedule.occurrences_between(Date.current + 1, Date.current + 400).first(3)
# What each settled cycle actually cost. The frozen `expected_amount` is an
# estimate, so reading it here would report averages of estimates beside the
# per-year totals below, which are sums of real payments.
paid_amounts = RecurringAllocation.confirmed
.joins(:recurring_occurrence)
.where(recurring_occurrences: {
recurring_transaction_id: @series.id,
status: "paid"
})
.group(:recurring_occurrence_id)
.sum(:allocated_amount)
.values
@analytics = if paid_amounts.any?
{
average: Money.new(paid_amounts.sum / paid_amounts.size, @series.currency),
lowest: Money.new(paid_amounts.min, @series.currency),
highest: Money.new(paid_amounts.max, @series.currency),
annualized: @series.monthly_equivalent_amount * 12,
ytd: Money.new(ytd_paid_total, @series.currency)
}
end
if params[:display] == "pane"
# The expansion renders into whichever row frame asked for it; the id
# is reflected back sanitized. close returns the empty frame, which
# collapses the row.
@pane_frame_id = params[:frame].to_s.gsub(/[^a-zA-Z0-9_-]/, "").presence || "bill_detail"
if params[:close].present?
render :pane_close, layout: false
return
end
end
load_summary_extras
if params[:display] == "pane"
# A pending suggestion is the one thing that changes what the expansion
# should offer, so it is worth the one query.
@pane_suggestion = @current_occurrence && RecurringAllocation.suggested
.where(recurring_occurrence_id: @current_occurrence.id).first
render :pane, layout: false
return
end
# Only the bill's own page carries the deep material, so only it pays for
# the aggregates behind it.
load_deep_extras
render
end
private
# The plan plus the income facts the page states alongside it. One planner
# instance answers both, so the income list and the periods always agree.
def load_paycheck_plan
planner = RecurringTransaction::PaycheckPlanner.new(Current.family, user: Current.user)
# An empty plan (no periods to spread anything across) renders as no plan.
@plan = planner.plan.presence
@plan_unconvertible = planner.unconvertible_count
@income_series = Current.family.recurring_transactions
.accessible_by(Current.user)
.where(bill_type: :income)
.where.not(status: %i[suggested ended])
.order(:name)
.to_a
@next_income_by_series = planner.next_income_by_series
# The next income EVENT, which is not the same fact as any one series'
# next payday: two sources can land on the same day.
arrivals = @next_income_by_series.values
first_arrival = arrivals.min_by(&:due_on)
if first_arrival
same_day = arrivals.select { |occurrence| occurrence.due_on == first_arrival.due_on }
total, unconvertible = total_of(same_day) { |occurrence| occurrence.resolved_expected_amount_money }
@next_income = { date: first_arrival.due_on, occurrences: same_day, total: total, unconvertible: unconvertible }
end
@income_needs_attention = @income_series.any? { |series| !paycheck_income_plans?(series) }
end
# Only active, manually declared income defines paydays.
def paycheck_income_plans?(series)
series.active? && series.manual?
end
helper_method :paycheck_income_plans?
# What the expansion needs: the handful of payments that actually settled
# this bill lately. Cheap enough to run on every row someone opens.
def load_summary_extras
@recent_allocations = confirmed_allocations.includes(:entry).order(paid_on: :desc, created_at: :desc).limit(6)
end
# The bill's financial story: a year of payments by month, per-year totals,
# and where the money last came from. Three grouped aggregates, which is
# why they no longer run every time a row is expanded.
def load_deep_extras
confirmed = confirmed_allocations
window_start = 11.months.ago.beginning_of_month.to_date
by_month = confirmed
.where("recurring_occurrences.due_on >= ?", window_start)
.group(Arel.sql("date_trunc('month', recurring_occurrences.due_on)"))
.sum(:allocated_amount)
.transform_keys(&:to_date)
@payment_history = (0..11).map do |offset|
month = (window_start + offset.months)
[ month, by_month.fetch(month, 0) ]
end
totals = confirmed.group(Arel.sql("date_trunc('year', recurring_occurrences.due_on)")).sum(:allocated_amount)
counts = confirmed.group(Arel.sql("date_trunc('year', recurring_occurrences.due_on)")).count
@yearly_metrics = totals.map do |year, total|
count = counts.fetch(year, 1)
{ year: year.to_date.year, total: Money.new(total, @series.currency), average: Money.new(total / count, @series.currency) }
end.sort_by { |row| -row[:year] }.first(4)
last_allocation = confirmed.where.not(entry_id: nil).includes(entry: :account).order(paid_on: :desc, created_at: :desc).first
@last_account = last_allocation&.entry&.account
end
def confirmed_allocations
RecurringAllocation.confirmed
.joins(:recurring_occurrence)
.where(recurring_occurrences: { recurring_transaction_id: @series.id })
end
# The management table: every series of every type and status, filterable
# and sortable. This is the power-user surface; the overview stays a
# worklist.
def load_all_series
scope = Current.family.recurring_transactions
.accessible_by(Current.user)
.includes(:merchant)
if (search = params.dig(:q, :search)).present?
pattern = "%#{ActiveRecord::Base.sanitize_sql_like(search)}%"
scope = scope.left_joins(:merchant)
.where("recurring_transactions.name ILIKE :p OR merchants.name ILIKE :p", p: pattern)
end
# "Status" used to mean the SERIES lifecycle -- suggested, active, paused,
# inactive, ended -- so there was no way to ask the question people
# actually ask here, which is what is late and what is still owed. The
# filter now speaks payment state, with the lifecycle values that still
# matter (paused, ended) kept alongside.
status = params.dig(:q, :status)
if status.presence_in(LIFECYCLE_FILTERS)
scope = scope.where(status: LIFECYCLE_STATUSES.fetch(status))
end
if (bill_type = params.dig(:q, :bill_type)).presence_in(RecurringTransaction.bill_types.keys)
scope = scope.where(bill_type: bill_type)
end
scope = scope.includes(:recurring_occurrences) if status.presence_in(PAYMENT_FILTERS)
@all_series = case params.dig(:q, :sort)
when "name" then scope.order(:name, :amount)
when "amount" then scope.order(amount: :desc)
else scope.order(status: :asc, next_expected_date: :asc)
end
@all_series = filter_by_payment_state(@all_series, status) if status.presence_in(PAYMENT_FILTERS)
load_subscription_rollup if bill_type == "subscription"
end
# Payment state lives on the occurrence and is derived from dates and
# allocation sums, so it cannot be a WHERE clause. Occurrences are preloaded
# above, and this table is a management surface for a few hundred bills.
def filter_by_payment_state(series_list, status)
series_list.to_a.select do |series|
occurrence = series.current_occurrence
next false if occurrence.nil?
case status
when "overdue" then occurrence.overdue?
when "due" then occurrence.derived_state == :due
when "partial" then occurrence.partially_paid?
when "paid" then occurrence.paid?
else false
end
end
end
# What the Subscriptions tab existed to answer. It was a filter promoted to
# navigation -- bill_type: subscription, which All bills already offered --
# so the rollup now rides the filter instead of a destination of its own.
def load_subscription_rollup
subscriptions = @all_series.select { |series| series.bill_type == "subscription" }
active = subscriptions.select(&:active?)
monthly, unconvertible = total_of_series(active) { |series| series.monthly_equivalent_amount.abs }
@subscription_rollup = {
monthly: monthly,
annual: monthly ? monthly * 12 : nil,
active_count: active.size,
unconvertible: unconvertible
}
@recent_price_changes = RecurringPriceChange
.joins(:recurring_transaction)
.merge(RecurringTransaction.accessible_by(Current.user))
.where(recurring_transactions: { family_id: Current.family.id })
.where("effective_on >= ?", 1.year.ago.to_date)
.includes(:recurring_transaction)
.order(effective_on: :desc)
.limit(10)
end
def total_of_series(series_list, &value_of)
return [ nil, 0 ] if series_list.empty?
target = Current.family.currency
unconvertible = 0
total = series_list.reduce(Money.new(0, target)) do |sum, series|
begin
sum + value_of.call(series).exchange_to(target)
rescue Money::ConversionError
unconvertible += 1
sum
end
end
[ total, unconvertible ]
end
# Months are materialized on demand up to 13 months out (idempotent
# upserts, so navigation is free to re-visit); navigation caps there,
# which keeps every rendered chip a real, clickable occurrence.
CALENDAR_FORWARD_LIMIT_MONTHS = 13
def load_calendar
today = Date.current
@month = begin
Date.strptime(params[:month].to_s, "%Y-%m").beginning_of_month
rescue ArgumentError
today.beginning_of_month
end
limit = (today + CALENDAR_FORWARD_LIMIT_MONTHS.months).beginning_of_month
@month = limit if @month > limit
@at_forward_limit = @month >= limit
@grid_start = @month.beginning_of_week(:sunday)
@grid_end = @month.end_of_month.end_of_week(:sunday)
materialize_for_calendar(@grid_end) if @grid_end > today + 89
occurrences = Current.family.recurring_occurrences
.where(recurring_transaction_id: payable_series_ids)
.due_between(@grid_start, @grid_end)
.includes(recurring_transaction: :merchant)
.to_a
preload_allocation_sums(occurrences)
@by_day = occurrences.group_by(&:due_on)
month_occurrences = occurrences.select { |occurrence| occurrence.due_on.between?(@month, @month.end_of_month) }
@month_expected, @month_unconvertible = total_of(month_occurrences) { |occurrence| occurrence.resolved_expected_amount_money }
@month_paid, _ = total_of(month_occurrences) { |occurrence| occurrence.confirmed_allocated_money }
end
def materialize_for_calendar(through)
Current.family.recurring_transactions
.active
.where(id: payable_series_ids)
.find_each do |series|
RecurringTransaction::OccurrenceGenerator.new(series).generate!(through: through)
end
end
def ytd_paid_total
RecurringAllocation.confirmed
.joins(:recurring_occurrence)
.where(recurring_occurrences: { recurring_transaction_id: @series.id })
.where("recurring_allocations.paid_on >= ?", Date.current.beginning_of_year)
.sum(:allocated_amount)
end
# Open occurrences through the horizon plus everything closed this
# month, for every payable series (bills, subscriptions, and debt
# payments alike). Inactive series ride along so their leftover open
# occurrences can render as Dormant instead of haunting Past Due.
def payable_series_ids
debt_accounts = Account.where(accountable_type: %w[CreditCard Loan]).select(:id)
Current.family.recurring_transactions
.where(status: %w[active inactive])
.where("amount > 0")
.merge(
RecurringTransaction.where(destination_account_id: nil)
.or(RecurringTransaction.where(destination_account_id: debt_accounts))
)
.accessible_by(Current.user)
.select(:id)
end
def payable_occurrences
# Price changes ride along because bills_attention_reason asks every
# row whether its amount changed recently.
Current.family.recurring_occurrences
.where(recurring_transaction_id: payable_series_ids)
.where("due_on >= ? OR status = 'scheduled'", Date.current.beginning_of_month)
.where("due_on <= ?", Date.current + 90)
.includes(recurring_transaction: [ :merchant, :recurring_price_changes ])
.to_a
end
def preload_allocation_sums(occurrences)
sums = RecurringAllocation.confirmed
.where(recurring_occurrence_id: occurrences.map(&:id))
.group(:recurring_occurrence_id)
.sum(:allocated_amount)
occurrences.each do |occurrence|
occurrence.cached_confirmed_allocated = sums.fetch(occurrence.id, 0)
end
end
# The month is the right container for planning and the wrong unit for
# anyone whose income does not arrive monthly. Paid weekly, "this month"
# collapses four paychecks and four rent payments into one list.
#
# These are markers inside the month, not a regrouping of it. Only returned
# when income actually subdivides the month: monthly income yields a single
# overlapping period and undeclared income yields none, and in both cases
# the list renders exactly as it did before.
def month_pay_periods(today, month_end)
periods = RecurringTransaction::PaycheckPlanner
.new(Current.family, user: Current.user)
.plan(periods_limit: PAY_PERIOD_LIMIT)
return [] if periods.blank?
overlapping = periods.select do |period|
period.starts_on <= month_end && period.ends_on >= today
end
overlapping.size > 1 ? overlapping : []
end
def compute_kpis(today, month_end)
owed_now = @overdue + @this_month
@remaining_this_month, @unconvertible_count = total_of(owed_now) { |occurrence| occurrence.remaining_amount_money }
@paid_this_month_total, _ = total_of(@paid_this_month) { |occurrence| occurrence.confirmed_allocated_money }
@due_next_seven, _ = total_of(owed_now.select { |occurrence| occurrence.effective_due_on <= today + 7 }) { |occurrence| occurrence.remaining_amount_money }
@past_due_total, _ = total_of(@overdue) { |occurrence| occurrence.remaining_amount_money }
@owed_count = owed_now.size
@needs_action_count = owed_now.count { |occurrence| !occurrence.recurring_transaction.autopay? }
end
# A trial converting tomorrow and a month-old one-dollar price rise are not
# the same news. Notices used to sort by date ascending, which put the
# oldest and smallest first and buried the one thing you could still act on.
TRIAL_URGENT_DAYS = 3
MATERIAL_PRICE_SHIFT = 0.10
Notice = Data.define(:kind, :series, :date, :detail) do
def urgent?
case kind
when :trial then date <= Date.current + TRIAL_URGENT_DAYS
when :price then price_shift >= MATERIAL_PRICE_SHIFT
else false
end
end
# How far a price moved, as a fraction of what it was. A dollar on a
# ten-dollar subscription is worth saying; a dollar on the rent is not.
def price_shift
return 0 unless kind == :price && detail&.previous_amount.to_d.positive?
((detail.new_amount - detail.previous_amount).abs / detail.previous_amount).to_f
end
def price_percent
return 0 unless kind == :price && detail&.previous_amount.to_d.positive?
((detail.new_amount - detail.previous_amount) / detail.previous_amount * 100).round
end
# Nearness to today in either direction: a change three days ago and a
# renewal in three days are both current news.
def distance
(date - Date.current).to_i.abs
end
end
# Lightweight, page-native reminders: the Insights pipeline is
# preview-gated, so anything that must reach EVERY user renders here.
def collect_notices
today = Date.current
window = today..(today + 14)
series_scope = Current.family.recurring_transactions.accessible_by(Current.user).active
notices = []
series_scope.where(trial_ends_on: window).find_each do |series|
notices << Notice.new(kind: :trial, series: series, date: series.trial_ends_on, detail: nil)
end
series_scope.where(renews_on: window).find_each do |series|
notices << Notice.new(kind: :renewal, series: series, date: series.renews_on, detail: nil)
end
RecurringPriceChange.joins(:recurring_transaction)
.merge(RecurringTransaction.accessible_by(Current.user))
.where(recurring_transactions: { family_id: Current.family.id })
.where("effective_on >= ?", today - 30)
.includes(:recurring_transaction)
.find_each do |change|
notices << Notice.new(kind: :price, series: change.recurring_transaction, date: change.effective_on, detail: change)
end
notices.sort_by { |notice| [ notice.urgent? ? 0 : 1, notice.distance ] }
end
# Detection has been creating recurring rows from bank data since long
# before this page existed, so a family arriving here for the first time
# meets bills nobody ever confirmed. Counts them, and returns zero the
# moment there is any sign the user has worked with Bills at all -- a
# declared bill, a dismissed suggestion, or a payment they recorded
# themselves -- so the prompt clears itself and needs no stored state.
def detected_awaiting_review
series = Current.family.recurring_transactions.accessible_by(Current.user)
return 0 if series.where(manual: true).exists?
return 0 if series.where(status: :ended).exists?
user_touched = RecurringAllocation.where.not(source: :auto_matched)
.joins(:recurring_occurrence)
.where(recurring_occurrences: { family_id: Current.family.id })
return 0 if user_touched.exists?
series.where(manual: false, status: :active).count
end
def accessible_suggested_series
Current.family.recurring_transactions
.accessible_by(Current.user)
.suggested
end
# Family-wide, not user-scoped: occurrence materialization is the same
# machinery the sync job runs, and a partial per-user generation would
# leave the family half-materialized forever.
def materialize_missing_occurrences
Current.family.recurring_transactions.active.find_each do |series|
RecurringTransaction::OccurrenceGenerator.new(series).generate!
end
end
def suggested_allocations
RecurringAllocation
.suggested
.joins(recurring_occurrence: :recurring_transaction)
.where(recurring_occurrences: { family_id: Current.family.id })
.merge(RecurringTransaction.accessible_by(Current.user))
# Income never reviews here: the matcher no longer suggests it, and
# this filter also retires any suggestion written before that rule.
.merge(RecurringTransaction.where.not(bill_type: "income"))
.includes(:entry, recurring_occurrence: { recurring_transaction: :merchant })
# The confidence the matcher scored these with was sitting unused on
# the row while the queue ordered itself by when the job happened to
# run. Most-certain question first.
.order(match_confidence: :desc, created_at: :asc)
end
# Converted into the family currency because the headline answers "how
# much do I owe", which is one number. A pair with no rate is left out
# and counted rather than silently understating the total. Returns
# [total, unconvertible_count] so each caller keeps its own count.
def total_of(occurrences, &value_of)
return [ nil, 0 ] if occurrences.empty?
target = Current.family.currency
unconvertible = 0
total = occurrences.reduce(Money.new(0, target)) do |sum, occurrence|
begin
sum + value_of.call(occurrence).exchange_to(target)
rescue Money::ConversionError
unconvertible += 1
sum
end
end
[ total, unconvertible ]
end
end