diff --git a/app/assets/tailwind/sure-design-system/_generated.css b/app/assets/tailwind/sure-design-system/_generated.css
index d0c781722..0172e1583 100644
--- a/app/assets/tailwind/sure-design-system/_generated.css
+++ b/app/assets/tailwind/sure-design-system/_generated.css
@@ -402,6 +402,14 @@
}
}
+@utility divide-subdued {
+ @apply divide-alpha-black-50;
+
+ @variant theme-dark {
+ @apply divide-alpha-white-200;
+ }
+}
+
@utility button-bg-primary {
@apply bg-gray-900;
@@ -490,6 +498,22 @@
}
}
+@utility button-bg-accent {
+ @apply bg-blue-tint-5;
+
+ @variant theme-dark {
+ @apply bg-blue-tint-5;
+ }
+}
+
+@utility button-bg-accent-hover {
+ @apply bg-blue-tint-10;
+
+ @variant theme-dark {
+ @apply bg-blue-tint-10;
+ }
+}
+
@utility tab-item-active {
@apply bg-white;
diff --git a/app/components/DS/buttonish.rb b/app/components/DS/buttonish.rb
index 967874fba..fe4742483 100644
--- a/app/components/DS/buttonish.rb
+++ b/app/components/DS/buttonish.rb
@@ -1,3 +1,6 @@
+# Bills subsystem: adds the `accent` and `outline_muted` variants, with their
+# colours declared in design/tokens/sure.tokens.json alongside every other
+# button colour.
class DS::Buttonish < DesignSystemComponent
VARIANTS = {
primary: {
@@ -8,6 +11,13 @@ class DS::Buttonish < DesignSystemComponent
container_classes: "text-primary bg-gray-200 theme-dark:bg-gray-700 hover:bg-gray-300 theme-dark:hover:bg-gray-600 disabled:bg-gray-200 theme-dark:disabled:bg-gray-600",
icon_classes: "text-primary"
},
+ # A tinted call to action for navigation that leaves the app. Backed by the
+ # button-bg-accent utilities in design/tokens/sure.tokens.json, so the colour
+ # lives with every other button colour rather than in this file.
+ accent: {
+ container_classes: "text-link button-bg-accent hover:button-bg-accent-hover disabled:button-bg-disabled",
+ icon_classes: "text-link"
+ },
destructive: {
container_classes: "text-inverse button-bg-destructive hover:button-bg-destructive-hover disabled:bg-red-200 theme-dark:disabled:bg-red-600",
icon_classes: "text-inverse"
@@ -16,6 +26,13 @@ class DS::Buttonish < DesignSystemComponent
container_classes: "text-primary border border-secondary bg-transparent hover:bg-surface-hover",
icon_classes: "text-secondary"
},
+ # A quieter outline for "there is nothing here yet" affordances, which should sit
+ # behind the content they sit beside rather than compete with it. Pair with
+ # `border-dashed` for the empty-slot cue the split editor uses.
+ outline_muted: {
+ container_classes: "text-secondary hover:text-primary border border-secondary hover:border-primary bg-transparent",
+ icon_classes: "text-secondary"
+ },
outline_destructive: {
container_classes: "text-destructive border border-secondary bg-transparent hover:bg-container-inset-hover",
icon_classes: "text-secondary"
@@ -112,6 +129,10 @@ class DS::Buttonish < DesignSystemComponent
:white
when :destructive, :outline_destructive
:destructive
+ when :accent, :outline_muted
+ # Inherit the container's own text color so the glyph always matches its
+ # label, including on hover, instead of tracking a second colour by hand.
+ :current
else
:default
end
diff --git a/app/components/DS/link.rb b/app/components/DS/link.rb
index 0d5c8501a..9f454271c 100644
--- a/app/components/DS/link.rb
+++ b/app/components/DS/link.rb
@@ -21,6 +21,13 @@ class DS::Link < DS::Buttonish
data = data.merge(turbo_frame: frame)
end
+ # `link_to method:` has been inert since the UJS removal -- Turbo drives
+ # non-GET links via `data-turbo-method` instead. Translate so a caller
+ # writing the natural `method: :post` gets a real POST, not a silent GET.
+ if (http_method = merged_opts.delete(:method))
+ data = data.merge(turbo_method: http_method)
+ end
+
# External link hardening: `target="_blank"` without `rel="noopener"`
# exposes window.opener to the new tab (reverse-tabnabbing). Always
# set `noopener noreferrer` when we send the user off-tab. Authors
diff --git a/app/components/DS/sparkline.html.erb b/app/components/DS/sparkline.html.erb
new file mode 100644
index 000000000..ac0287b98
--- /dev/null
+++ b/app/components/DS/sparkline.html.erb
@@ -0,0 +1,13 @@
+
diff --git a/app/components/DS/sparkline.rb b/app/components/DS/sparkline.rb
new file mode 100644
index 000000000..dac08de77
--- /dev/null
+++ b/app/components/DS/sparkline.rb
@@ -0,0 +1,55 @@
+# A small labeled line-with-dots chart: one polyline over evenly spaced
+# points, a dot per point (hollow when the value is zero), and a label under
+# every other point.
+#
+# Extracted from the bill detail's twelve-month payment history so views stop
+# hand-rolling SVG (raw SVG belongs in DS primitives). The component owns the
+# geometry; the caller passes dated values and sets the color via a text-*
+# class on `css`, which the strokes and fills pick up through currentColor.
+class DS::Sparkline < DesignSystemComponent
+ WIDTH = 336
+ HEIGHT = 84
+ LEFT_PAD = 10
+ SPAN = 316.0
+ BASELINE_Y = 62
+ VALUE_HEIGHT = 48
+ LABEL_Y = 80
+
+ attr_reader :series, :aria_label, :css, :label_format
+
+ # series: [ [Date, Numeric], ... ] in display order.
+ def initialize(series:, aria_label:, css: "w-full text-success", label_format: "%b")
+ @series = series
+ @aria_label = aria_label
+ @css = css
+ @label_format = label_format
+ end
+
+ def points
+ @points ||= series.each_with_index.map do |(_label, value), index|
+ [ x_at(index), BASELINE_Y - (value.to_f / peak) * VALUE_HEIGHT, value ]
+ end
+ end
+
+ def polyline_points
+ points.map { |x, y, _| "#{x.round(1)},#{y.round(1)}" }.join(" ")
+ end
+
+ # Every other label keeps the axis readable at sparkline width.
+ def labels
+ series.each_with_index.filter_map do |(label, _value), index|
+ next unless index.even?
+
+ [ x_at(index).round(1), I18n.l(label, format: label_format) ]
+ end
+ end
+
+ private
+ def x_at(index)
+ LEFT_PAD + index * (SPAN / [ series.size - 1, 1 ].max)
+ end
+
+ def peak
+ @peak ||= [ series.map(&:last).max, 1 ].max.to_f
+ end
+end
diff --git a/app/controllers/bills/ai_reviews_controller.rb b/app/controllers/bills/ai_reviews_controller.rb
new file mode 100644
index 000000000..9d4a8f9fb
--- /dev/null
+++ b/app/controllers/bills/ai_reviews_controller.rb
@@ -0,0 +1,26 @@
+class Bills::AiReviewsController < ApplicationController
+ include BillsHelper
+ include RecurringFeatureGuardable
+
+ guard_feature unless: -> { bills_one_shot_ai_available? }
+ before_action :ensure_recurring_enabled
+
+ # Server-owned on purpose: the button must not become a vector for
+ # client-supplied prompts, and the text appears in the chat as the user's
+ # own message so it stays short and legible. It reads like something a
+ # person would type: no internal tool names, because the tool descriptions
+ # already route the model without the prompt naming them.
+ REVIEW_PROMPT = <<~PROMPT.freeze
+ Review my bills and subscriptions. Look for duplicate bills, price increases, anything overdue or abandoned, trials about to convert, and recurring charges I have not declared yet. Propose specific fixes and ask me before changing anything.
+ PROMPT
+
+ # Seeds a chat rather than generating a report: the audit tool grounds the
+ # findings deterministically, and a conversation can act on them ("fix the
+ # second one") through the bills write tools.
+ def create
+ chat = Current.user.chats.start!(REVIEW_PROMPT.strip, model: helpers.default_ai_model)
+ Current.user.update!(last_viewed_chat: chat)
+
+ redirect_to chat_path(chat, thinking: true)
+ end
+end
diff --git a/app/controllers/bills/smart_configurations_controller.rb b/app/controllers/bills/smart_configurations_controller.rb
new file mode 100644
index 000000000..baa7d2b40
--- /dev/null
+++ b/app/controllers/bills/smart_configurations_controller.rb
@@ -0,0 +1,32 @@
+class Bills::SmartConfigurationsController < ApplicationController
+ include BillsHelper
+ include RecurringFeatureGuardable
+
+ guard_feature unless: -> { bills_one_shot_ai_available? }
+ before_action :ensure_recurring_enabled
+
+ # Proposes configuration corrections for one bill from its own charge
+ # history (configure mode: only fields the history contradicts come back).
+ # The dialog's form PATCHes the ordinary recurring_transactions#update, so
+ # applying inherits every rule that path already enforces -- sign and
+ # ownership handling, preset application, schedule pinning, occurrence
+ # regeneration. Nothing applies without the user checking it.
+ def show
+ @series = Current.family.recurring_transactions
+ .accessible_by(Current.user)
+ .includes(:merchant, :category)
+ .find(params[:id])
+
+ begin
+ @suggestion = RecurringTransaction::AiSetupSuggester
+ .new(Current.family, user: Current.user)
+ .suggest_configuration(@series)
+ @detection = RecurringTransaction::FrequencyPreset.detect(@series)
+ rescue RecurringTransaction::AiSetupSuggester::Error => e
+ Rails.logger.warn("Smart configure failed for series #{@series.id}: #{e.class}: #{e.message}")
+ @error = t(".failed")
+ end
+
+ render layout: dialog_layout
+ end
+end
diff --git a/app/controllers/bills_controller.rb b/app/controllers/bills_controller.rb
new file mode 100644
index 000000000..b04df218c
--- /dev/null
+++ b/app/controllers/bills_controller.rb
@@ -0,0 +1,647 @@
+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
diff --git a/app/controllers/bills_feeds_controller.rb b/app/controllers/bills_feeds_controller.rb
new file mode 100644
index 000000000..d6f638b13
--- /dev/null
+++ b/app/controllers/bills_feeds_controller.rb
@@ -0,0 +1,75 @@
+# Read-only iCal feed of upcoming bill occurrences, so calendar apps can
+# subscribe (an entire third-party product exists to do this for a
+# competitor). Token-authenticated and sessionless, and deliberately
+# obligations only -- no balances, no accounts.
+#
+# The token is signed and names the MEMBER, not the family: sharing is per
+# account, so each member's feed carries only the bills they can reach in
+# the app. The signature binds a digest of the family's stored feed secret,
+# which is how resetting that secret still revokes every previously shared
+# URL in one stroke.
+class BillsFeedsController < ApplicationController
+ skip_authentication
+
+ HORIZON_DAYS = 90
+
+ def show
+ payload = Family.bills_feed_verifier.verified(params[:token].to_s)
+ user_id, stamp = payload if payload.is_a?(Array)
+ user = User.find_by(id: user_id)
+ family = user&.family
+ raise ActiveRecord::RecordNotFound unless family && stamp.present? && stamp == family.bills_feed_stamp
+
+ # Preview-gated like every other bills surface. Sessionless, so the gate
+ # reads the member the token names: opting out of preview features (or the
+ # family switching recurring off) kills retained URLs immediately, not
+ # only after a token reset.
+ raise ActiveRecord::RecordNotFound unless user.preview_features_enabled? && !family.recurring_transactions_disabled?
+
+ occurrences = family.recurring_occurrences
+ .open_status
+ .joins(:recurring_transaction)
+ .merge(RecurringTransaction.accessible_by(user))
+ .where(recurring_transactions: { status: :active })
+ .where("recurring_transactions.amount > 0")
+ .where(due_on: Date.current..(Date.current + HORIZON_DAYS))
+ .includes(:recurring_transaction)
+ .order(:due_on)
+
+ I18n.with_locale(family.locale.presence || I18n.default_locale) do
+ render plain: to_ical(occurrences), content_type: "text/calendar"
+ end
+ rescue ActiveRecord::RecordNotFound
+ head :not_found
+ end
+
+ private
+ def to_ical(occurrences)
+ events = occurrences.map do |occurrence|
+ series = occurrence.recurring_transaction
+ amount = Money.new(occurrence.resolved_expected_amount, occurrence.currency).format
+
+ <<~EVENT
+ BEGIN:VEVENT
+ UID:#{occurrence.id}@sure-bills
+ DTSTAMP:#{Time.current.utc.strftime("%Y%m%dT%H%M%SZ")}
+ DTSTART;VALUE=DATE:#{occurrence.effective_due_on.strftime("%Y%m%d")}
+ SUMMARY:#{escape_ical(series.display_name)} (#{escape_ical(amount)})
+ END:VEVENT
+ EVENT
+ end
+
+ <<~ICAL
+ BEGIN:VCALENDAR
+ VERSION:2.0
+ PRODID:-//Sure//Bills//EN
+ X-WR-CALNAME:#{escape_ical(I18n.t("bills.feed.calendar_name"))}
+ #{events.join}
+ END:VCALENDAR
+ ICAL
+ end
+
+ def escape_ical(text)
+ text.to_s.gsub("\\", "\\\\\\\\").gsub(",", "\\,").gsub(";", "\\;").gsub("\n", " ")
+ end
+end
diff --git a/app/controllers/concerns/recurring_feature_guardable.rb b/app/controllers/concerns/recurring_feature_guardable.rb
new file mode 100644
index 000000000..9961c9797
--- /dev/null
+++ b/app/controllers/concerns/recurring_feature_guardable.rb
@@ -0,0 +1,36 @@
+# Shared guards for the Bills / recurring-transactions surfaces: every
+# controller in the subsystem bails to the home page when the user hasn't
+# opted into preview features or the family has switched the feature off,
+# and the dialog surfaces drop the layout for turbo-frame requests so the
+# shared modal frame stays unique in the response.
+module RecurringFeatureGuardable
+ extend ActiveSupport::Concern
+
+ private
+ # Bills ships as a preview feature, so the per-user gate runs first and
+ # carries the flash that points at Settings -> Preferences. The family's
+ # recurring toggle still applies to opted-in users.
+ def ensure_recurring_enabled
+ require_preview_features!
+ return if performed?
+
+ redirect_to root_path if Current.family.recurring_transactions_disabled?
+ end
+
+ def dialog_layout
+ turbo_frame_request? ? false : "settings"
+ end
+
+ # Turbo-stream redirects take a raw URL, so the referer has to be validated
+ # the way redirect_back_or_to already validates it for HTML: same host, or
+ # the caller's fallback.
+ def safe_return_path(fallback:)
+ referer = request.referer
+ return fallback if referer.blank?
+
+ uri = URI.parse(referer)
+ uri.host.nil? || uri.host == request.host ? referer : fallback
+ rescue URI::InvalidURIError
+ fallback
+ end
+end
diff --git a/app/controllers/recurring_allocations_controller.rb b/app/controllers/recurring_allocations_controller.rb
new file mode 100644
index 000000000..01473017f
--- /dev/null
+++ b/app/controllers/recurring_allocations_controller.rb
@@ -0,0 +1,138 @@
+class RecurringAllocationsController < ApplicationController
+ include RecurringFeatureGuardable
+
+ before_action :ensure_recurring_enabled
+
+ def create
+ occurrence = find_occurrence(params[:recurring_occurrence_id])
+ ensure_series_writable(occurrence)
+ entry = find_entry(occurrence, params[:entry_id])
+
+ RecurringTransaction::Allocator.new(occurrence).allocate!(
+ entry: entry,
+ amount: params[:amount].presence,
+ # Defaults to today via RecurringAllocation's callback; accepting a date
+ # lets someone record last Tuesday's payment as last Tuesday.
+ paid_on: parse_paid_on(params[:paid_on])
+ )
+
+ redirect_with notice: t(".success")
+ rescue RecurringTransaction::Allocator::OverAllocationError,
+ RecurringTransaction::Allocator::MissingRateError,
+ ActiveRecord::RecordInvalid,
+ ActiveRecord::RecordNotUnique,
+ ArgumentError => e
+ redirect_with alert: allocation_error_message(e)
+ end
+
+ def destroy
+ allocation = find_allocation
+ occurrence = allocation.recurring_occurrence
+ ensure_series_writable(occurrence)
+
+ RecurringTransaction::Allocator.new(occurrence).unallocate!(allocation)
+
+ redirect_with notice: t(".success")
+ end
+
+ def confirm
+ allocation = find_allocation
+ occurrence = allocation.recurring_occurrence
+ ensure_series_writable(occurrence)
+
+ RecurringTransaction::Allocator.new(occurrence).confirm_suggestion!(allocation)
+
+ redirect_with_return notice: t(".success")
+ end
+
+ def reject
+ allocation = find_allocation
+ occurrence = allocation.recurring_occurrence
+ ensure_series_writable(occurrence)
+
+ RecurringTransaction::Allocator.new(occurrence).reject_suggestion!(allocation)
+
+ redirect_with_return notice: t(".success")
+ end
+
+ private
+ # Active Record casts an unparseable date to nil, and a nil paid_on records
+ # the payment as today. Parsing here raises Date::Error (an ArgumentError),
+ # which the create rescue turns into the invalid-allocation message.
+ def parse_paid_on(raw)
+ return nil if raw.blank?
+
+ Date.iso8601(raw.to_s)
+ end
+
+ # Reading a shared bill is fine; changing its payment state is not. Sharing
+ # is per account, so a read-only account share must not mutate. Accountless
+ # series carry no account gate.
+ def ensure_series_writable(occurrence)
+ series = occurrence.recurring_transaction
+ return if series.account_id.nil?
+ return if Account.writable_by(Current.user).where(id: series.account_id).exists?
+
+ raise ActiveRecord::RecordNotFound
+ end
+
+ def find_allocation
+ RecurringAllocation
+ .joins(recurring_occurrence: :recurring_transaction)
+ .where(recurring_occurrences: { family_id: Current.family.id })
+ .merge(RecurringTransaction.accessible_by(Current.user))
+ .find(params[:id])
+ end
+
+ # Queue actions come from the Bills page and should land back there. The
+ # same-host referer check lives in RecurringFeatureGuardable#safe_return_path.
+ def redirect_with_return(notice:)
+ flash[:notice] = notice
+ target = safe_return_path(fallback: bills_path)
+
+ respond_to do |format|
+ format.html { redirect_to target }
+ format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, target) }
+ end
+ end
+
+ def find_occurrence(id)
+ Current.family.recurring_occurrences
+ .joins(:recurring_transaction)
+ .merge(RecurringTransaction.accessible_by(Current.user))
+ .find(id)
+ end
+
+ # Scoped to what this user can see: sharing is per account, so a family
+ # scope alone would let a member pay with another account's transaction.
+ def find_entry(occurrence, entry_id)
+ return nil if entry_id.blank?
+
+ Current.accessible_entries.find(entry_id)
+ end
+
+ def allocation_error_message(error)
+ case error
+ when RecurringTransaction::Allocator::OverAllocationError then t("recurring_allocations.over_allocation")
+ when RecurringTransaction::Allocator::MissingRateError then t("recurring_allocations.missing_rate")
+ when ActiveRecord::RecordNotUnique then t("recurring_allocations.already_allocated")
+ else t("recurring_allocations.invalid")
+ end
+ end
+
+ # Back to the worklist, not the occurrence: a plain GET of
+ # recurring_occurrence_path renders the settings layout, which already emits
+ # an empty , so the page would carry two frames
+ # sharing one id. See the two-frames trap in
+ # RecurringTransactionsController#edit.
+ def redirect_with(notice: nil, alert: nil)
+ flash[:notice] = notice if notice
+ flash[:alert] = alert if alert
+ target = bills_path
+
+ respond_to do |format|
+ format.html { redirect_to target }
+ format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, target) }
+ end
+ end
+end
diff --git a/app/controllers/recurring_occurrences_controller.rb b/app/controllers/recurring_occurrences_controller.rb
new file mode 100644
index 000000000..b56f13c65
--- /dev/null
+++ b/app/controllers/recurring_occurrences_controller.rb
@@ -0,0 +1,204 @@
+class RecurringOccurrencesController < ApplicationController
+ include RecurringFeatureGuardable
+
+ layout "settings"
+
+ before_action :ensure_recurring_enabled
+ before_action :set_occurrence
+ before_action :ensure_series_writable, only: %i[mark_paid skip reopen snooze override_amount]
+
+ # The dialog is delivered into the shared (see
+ # RecurringTransactionsController#edit for the two-frames trap).
+ def show
+ @candidate_query = params[:q].to_s.strip
+ @pending_suggestion = @occurrence.allocations.suggested.includes(:entry).first
+ @ranked_candidates = ranked_candidates
+ ranked_ids = @ranked_candidates.map { |entry, _| entry.id }
+ # The amount-nearness list is the fallback, so it must not repeat what the
+ # ranked list promoted. The extra fetch keeps it full after subtraction.
+ @other_entries = candidate_entries.reject { |entry| ranked_ids.include?(entry.id) }.first(FALLBACK_SHOWN)
+
+ render layout: dialog_layout
+ end
+
+ def mark_paid
+ allocator.mark_paid!
+ redirect_after_action t(".success")
+ end
+
+ def skip
+ @occurrence.skip!
+ redirect_after_action t(".success")
+ end
+
+ def reopen
+ @occurrence.reopen!
+ redirect_after_action t(".success")
+ end
+
+ def snooze
+ until_date = Date.parse(params.require(:until))
+ @occurrence.snooze!(until_date)
+ redirect_after_action t(".success", date: l(until_date, format: :long))
+ # TypeError covers non-scalar params (until[]=...), which Date.parse raises
+ # on before ArgumentError gets a chance; both are the same user mistake.
+ rescue ArgumentError, TypeError
+ redirect_after_action t(".invalid_date"), alert: true
+ end
+
+ def override_amount
+ @occurrence.override_amount!(params[:amount])
+ redirect_after_action t(".success")
+ end
+
+ private
+ def set_occurrence
+ @occurrence = Current.family.recurring_occurrences
+ .joins(:recurring_transaction)
+ .merge(RecurringTransaction.accessible_by(Current.user))
+ .find(params[:id])
+ end
+
+ # Reading a shared bill is fine; changing its payment state is not. Sharing
+ # is per account, so a read-only account share must not mutate. Accountless
+ # series carry no account gate.
+ def ensure_series_writable
+ series = @occurrence.recurring_transaction
+ return if series.account_id.nil?
+ return if Account.writable_by(Current.user).where(id: series.account_id).exists?
+
+ raise ActiveRecord::RecordNotFound
+ end
+
+ def allocator
+ RecurringTransaction::Allocator.new(@occurrence)
+ end
+
+ def redirect_after_action(message, alert: false)
+ flash[alert ? :alert : :notice] = message
+ target = bills_path
+
+ respond_to do |format|
+ format.html { redirect_to target }
+ format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, target) }
+ end
+ end
+
+ # How many entries the ranked pass will score in Ruby, and how many of each
+ # list survives to the view.
+ RANKED_SCAN_LIMIT = 200
+ RANKED_SHOWN = 6
+ FALLBACK_SHOWN = 15
+
+ # Hard filters both lists share: right account, sign, currency, a real
+ # transaction, not already allocated to THIS occurrence. Cross-currency
+ # attaches go through an explicit amount instead.
+ #
+ # The raw join is load-bearing: every `where(transactions: {...})` below is
+ # only legal because it puts that table in the query.
+ def candidate_scope
+ series = @occurrence.recurring_transaction
+ # Accountless bills fall back to the accounts this user can see, never
+ # the whole family: sharing is per account, and the drawer would
+ # otherwise print entries from accounts that were never shared.
+ base = if series.account.present?
+ series.account.entries
+ else
+ Current.family.entries.where(account_id: Account.accessible_by(Current.user).select(:id))
+ end
+ sign = series.amount.negative? ? "entries.amount < 0" : "entries.amount > 0"
+
+ scope = base
+ .where(entryable_type: "Transaction")
+ .where(currency: @occurrence.currency)
+ .where(sign)
+ .where.not(id: @occurrence.allocations.where.not(entry_id: nil).select(:entry_id))
+ .joins("INNER JOIN transactions ON transactions.id = entries.entryable_id")
+
+ return scope if series.transfer?
+
+ scope.where.not(transactions: { kind: Transaction::TRANSFER_KINDS })
+ end
+
+ # Shared by both lists so they cannot disagree about which dates exist.
+ def candidate_window
+ (@occurrence.due_on - 40)..[ @occurrence.due_on + 40, Date.current ].min
+ end
+
+ # Ranked by the same engine that decides auto-links, so the picker and the
+ # pipeline agree on what a match is, and the rejection table is honoured.
+ #
+ # Entries already allocated to another occurrence are deliberately kept: one
+ # charge can legitimately pay two bills, which is why
+ # Allocator#guard_entry_capacity! exists. Over-allocation is refused at
+ # write time, never hidden at read time.
+ def ranked_candidates
+ series = @occurrence.recurring_transaction
+ matcher = RecurringTransaction::Matcher.new(Current.family)
+
+ scope = candidate_scope
+ .where(date: candidate_window)
+ .where(excluded: false)
+ .where.not(id: RecurringMatchRejection.where(recurring_transaction: series).select(:entry_id))
+ # preload, not includes: entryable is polymorphic so it can never be
+ # joined, and naming it keeps the raw transactions join above out of
+ # Rails' eager-load machinery.
+ .preload(:entryable)
+ .limit(RANKED_SCAN_LIMIT)
+
+ scope = if series.merchant_id.present?
+ scope.where(transactions: { merchant_id: series.merchant_id })
+ else
+ patterns = matcher_name_patterns(series)
+ return [] if patterns.empty?
+
+ scope.where("entries.name ILIKE ANY (ARRAY[?])", patterns)
+ end
+
+ scored = scope.filter_map do |entry|
+ explanation = matcher.explain(@occurrence, entry)
+ [ entry, explanation ] if explanation
+ end
+
+ scored.sort_by { |_entry, explanation| -explanation.confidence }.first(RANKED_SHOWN)
+ end
+
+ # The names the matcher itself would recognize: the series' own name plus
+ # any alias a manual attach has taught it.
+ def matcher_name_patterns(series)
+ ([ series.name ] + Array(series.matcher_hints["name_aliases"]))
+ .compact_blank
+ .map { |name| "%#{ActiveRecord::Base.sanitize_sql_like(name)}%" }
+ end
+
+ # Entries a user would plausibly attach by hand, ordered by how close the
+ # amount is rather than by how recent the transaction is. A bill is nearly
+ # always settled by a charge for its own amount, so date order buried the
+ # right answer: on a $6.44 bill with 77 candidates in the window, all four
+ # near-amount matches sat outside the fifteen shown.
+ #
+ # This is the fallback now -- what you browse when the ranked list did not
+ # have it. A search looks past the date window entirely, because the
+ # payment being hunted for is usually the one the window already excluded.
+ def candidate_entries
+ scope = if @candidate_query.present?
+ candidate_scope.where("entries.name ILIKE :q", q: "%#{ActiveRecord::Base.sanitize_sql_like(@candidate_query)}%")
+ else
+ candidate_scope.where(date: candidate_window)
+ end
+
+ scope.order(candidate_relevance_sql).limit(FALLBACK_SHOWN + RANKED_SHOWN)
+ end
+
+ def candidate_relevance_sql
+ remaining = @occurrence.remaining_amount
+ target = remaining.positive? ? remaining : @occurrence.resolved_expected_amount
+
+ Arel.sql(
+ ActiveRecord::Base.sanitize_sql_array([
+ "ABS(ABS(entries.amount) - ?) ASC, ABS(entries.date - ?) ASC, entries.date DESC",
+ target, @occurrence.due_on
+ ])
+ )
+ end
+end
diff --git a/app/controllers/recurring_transactions/smart_fills_controller.rb b/app/controllers/recurring_transactions/smart_fills_controller.rb
new file mode 100644
index 000000000..3a6d3359a
--- /dev/null
+++ b/app/controllers/recurring_transactions/smart_fills_controller.rb
@@ -0,0 +1,95 @@
+# Inherits RecurringTransactionsController so the re-rendered "new" template
+# resolves its relative partials ("form") against recurring_transactions/.
+class RecurringTransactions::SmartFillsController < RecurringTransactionsController
+ include BillsHelper
+ include RecurringFeatureGuardable
+
+ guard_feature unless: -> { bills_one_shot_ai_available? }
+ before_action :ensure_recurring_enabled
+
+ # Smart-fill re-renders the add-bill dialog with AI-proposed values. It only
+ # exists on the entry-prefilled variant: the picked transaction anchors the
+ # evidence (its charge history), so the model infers rather than guesses --
+ # cadence from date gaps, the due day, autopay markers. Synchronous: one
+ # small LLM call, the same latency class as the identify action (slow local
+ # models may want a generous request timeout).
+ def create
+ income = params[:income].present?
+ @recurring_transaction = Current.family.recurring_transactions.new(
+ frequency_preset: income ? "biweekly" : "monthly",
+ first_due_on: Date.current
+ )
+ @recurring_transaction.is_income = income
+
+ entry = Current.accessible_entries.find_by(id: params[:entry_id])
+
+ if entry.nil?
+ @smart_fill_error = t(".failed")
+ return render "recurring_transactions/new", layout: dialog_layout
+ end
+
+ prefill_recurring_from_entry(entry)
+
+ begin
+ suggestion = RecurringTransaction::AiSetupSuggester
+ .new(Current.family, user: Current.user)
+ .suggest_from_entries(evidence_entries(entry, income: income))
+ apply_suggestion(suggestion)
+ @smart_fill = suggestion
+ rescue RecurringTransaction::AiSetupSuggester::Error => e
+ Rails.logger.warn("Smart fill failed for entry #{entry.id}: #{e.class}: #{e.message}")
+ @smart_fill_error = t(".failed")
+ end
+
+ render "recurring_transactions/new", layout: dialog_layout
+ end
+
+ private
+ # The picked entry's own history: same account, same sign, same name
+ # shape. Recency-ordered so drifting amounts weight toward the present.
+ def evidence_entries(entry, income:)
+ pattern = "%#{ActiveRecord::Base.sanitize_sql_like(entry.name.to_s)}%"
+
+ entry.account.entries
+ .where(entryable_type: "Transaction")
+ .where(income || entry.amount.negative? ? "entries.amount < 0" : "entries.amount > 0")
+ .where("entries.name ILIKE ?", pattern)
+ .order(date: :desc)
+ .limit(RecurringTransaction::AiSetupSuggester::MAX_CHARGES)
+ .to_a
+ .presence || [ entry ]
+ end
+
+ # Only fields the add form actually carries; anything else the suggestion
+ # knows (category, kind) has no field to land in here and is dropped.
+ # The due date follows the cadence's own anchor: weekday for weekly-style
+ # presets, month for annual, day-of-month for the monthly-style rest.
+ def apply_suggestion(suggestion)
+ @recurring_transaction.name = suggestion.name if suggestion.name.present?
+ @recurring_transaction.amount = suggestion.amount if suggestion.amount.present?
+ @recurring_transaction.frequency_preset = suggestion.frequency if suggestion.frequency.present?
+ @recurring_transaction.autopay = suggestion.autopay unless suggestion.autopay.nil?
+
+ today = Date.current
+ if %w[weekly biweekly].include?(suggestion.frequency) && suggestion.weekday.present?
+ @recurring_transaction.first_due_on = today + ((suggestion.weekday - today.wday) % 7)
+ elsif suggestion.frequency == "annual" && suggestion.month_of_year.present?
+ @recurring_transaction.first_due_on = next_annual_occurrence(
+ today, suggestion.month_of_year,
+ suggestion.day_of_month || @recurring_transaction.first_due_on.day
+ )
+ elsif suggestion.day_of_month.present?
+ @recurring_transaction.first_due_on =
+ RecurringTransaction::Schedule.new(expected_day_of_month: suggestion.day_of_month).next_occurrence_from_today
+ end
+ end
+
+ # The next date landing on (month, day), day clamped to the month's
+ # length, rolled a year forward once this year's is past.
+ def next_annual_occurrence(today, month, day)
+ candidate = Date.new(today.year, month, [ day, Date.new(today.year, month, -1).day ].min)
+ return candidate if candidate >= today
+
+ Date.new(today.year + 1, month, [ day, Date.new(today.year + 1, month, -1).day ].min)
+ end
+end
diff --git a/app/controllers/recurring_transactions_controller.rb b/app/controllers/recurring_transactions_controller.rb
index 723e166d8..914b742b0 100644
--- a/app/controllers/recurring_transactions_controller.rb
+++ b/app/controllers/recurring_transactions_controller.rb
@@ -1,14 +1,71 @@
+# Bills subsystem: this controller gained the declared-bill and declared-income
+# create paths, editable identity and frequency, suggestion confirm/dismiss, and
+# schedule pinning on a hand-set cadence.
class RecurringTransactionsController < ApplicationController
+ include RecurringFeatureGuardable
+
layout "settings"
+ # Small on purpose: the picker narrows by searching, not by paging, and a
+ # fixed cap keeps the dialog free of pagination (whose shared partial
+ # targets _top and cannot live inside a turbo frame).
+ PICKER_SHOWN = 20
+
+ # The declare, edit and suggestion paths shipped with Bills and sit behind
+ # its preview gate like every other Bills surface. The actions that predate
+ # Bills (index, toggle_status, destroy, update_settings, identify, cleanup)
+ # keep their historical reach.
+ before_action :ensure_recurring_enabled, only: %i[new create edit update confirm dismiss]
+ before_action :set_recurring_transaction, only: %i[edit update toggle_status destroy confirm dismiss]
+ before_action :ensure_series_writable, only: %i[update toggle_status destroy confirm dismiss]
+
def index
- @recurring_transactions = Current.family.recurring_transactions
- .accessible_by(Current.user)
- .includes(:merchant)
- .order(status: :asc, next_expected_date: :asc)
+ scope = Current.family.recurring_transactions
+ .accessible_by(Current.user)
+ .includes(:merchant)
+
+ # Fresh detections wait in their own review strip until confirmed; they
+ # are not real bills yet and would only be noise inside the main table.
+ @suggested = scope.suggested.order(next_expected_date: :asc)
+ @recurring_transactions = scope.where.not(status: :suggested)
+ .order(status: :asc, next_expected_date: :asc)
@family = Current.family
end
+ # Detection proposes, the user disposes: confirming makes the suggestion a
+ # real, active bill; dismissing tombstones it as `ended`, which the
+ # Identifier treats as "never suggest this again".
+ def confirm
+ first_confirmation = @recurring_transaction.suggested?
+ @recurring_transaction.update!(status: "active")
+
+ # A just-confirmed bill shows its lived history rather than starting
+ # blank. Guarded so a replayed POST does not re-run the backfill; the
+ # matcher pass inside is family-wide on purpose (exact-tier, idempotent,
+ # and scoping it would need a parallel Matcher entry point). Taken under
+ # the family lock so it cannot interleave with a running pipeline; when
+ # the lock is held the backfill is skipped and the confirm still succeeds.
+ if first_confirmation
+ RecurringTransaction::Pipeline.with_family_lock(Current.family.id) do
+ RecurringTransaction::HistoryBackfiller.new(
+ Current.family,
+ months: RecurringTransaction::Pipeline::FIRST_RUN_BACKFILL_MONTHS,
+ series_scope: Current.family.recurring_transactions.where(id: @recurring_transaction.id)
+ ).run!
+ end
+ end
+
+ flash[:notice] = t("recurring_transactions.confirmed")
+ redirect_back_or_to recurring_transactions_path
+ end
+
+ def dismiss
+ @recurring_transaction.update!(status: "ended")
+
+ flash[:notice] = t("recurring_transactions.dismissed")
+ redirect_back_or_to recurring_transactions_path
+ end
+
def update_settings
Current.family.update!(recurring_settings_params)
@@ -21,11 +78,18 @@ class RecurringTransactionsController < ApplicationController
end
def identify
- count = RecurringTransaction.identify_patterns_for!(Current.family)
+ # User-triggered detection always reconstructs history; the backfiller is
+ # idempotent. nil means another run already holds the family lock.
+ result = RecurringTransaction::Pipeline.new(Current.family).run_with_lock!(backfill: true)
respond_to do |format|
format.html do
- flash[:notice] = t("recurring_transactions.identified", count: count)
+ flash[:notice] =
+ if result.nil?
+ t("recurring_transactions.identify_already_running")
+ else
+ t("recurring_transactions.identified", count: result)
+ end
redirect_to recurring_transactions_path
end
end
@@ -42,9 +106,110 @@ class RecurringTransactionsController < ApplicationController
end
end
- def toggle_status
- @recurring_transaction = Current.family.recurring_transactions.accessible_by(Current.user).find(params[:id])
+ # Optionally pre-filled from an existing transaction (entry_id param): the
+ # fastest declare path for a bill that already hits the ledger -- name,
+ # amount, account and a projected next-due all come from the entry.
+ def new
+ income = params[:income].present?
+ # "Not seeing what you're looking for?": a picker over every transaction,
+ # for when the detected candidates don't include the charge on the
+ # statement in the user's hand. Same URL and frame as the dialog it
+ # replaces, and a result row is just the entry_id prefill link the
+ # candidate strip already uses.
+ if params[:picker].present?
+ @is_income = income
+ @picker_query = params[:q].to_s.strip
+ @picker_entries = picker_entries(income: income)
+ @picker_capped = @picker_entries.size == PICKER_SHOWN
+ @claimed_by = claimed_series_names(@picker_entries)
+ return render :pick_entry, layout: dialog_layout
+ end
+
+ @recurring_transaction = Current.family.recurring_transactions.new(
+ # Paychecks default to the most common pay cadence; bills to monthly.
+ frequency_preset: income ? "biweekly" : "monthly",
+ first_due_on: Date.current
+ )
+ @recurring_transaction.is_income = income
+
+ # Accessible, not merely same-family: prefilling reads the entry's name,
+ # amount and account straight back to the user.
+ if (entry = Current.accessible_entries.find_by(id: params[:entry_id]))
+ prefill_recurring_from_entry(entry)
+ else
+ # Fresh dialog: offer detected-but-undeclared recurring shapes as
+ # optional starting points. Picking one reloads the dialog prefilled
+ # through the entry_id path above; it never replaces manual entry.
+ @candidates = declare_candidates(income: income)
+ end
+
+ render layout: dialog_layout
+ end
+
+ # Declared bills are the manual-first path: Name, Amount, Due date,
+ # Frequency, done. The due date carries the day-of-month / weekday detail
+ # the frequency needs, so the form never asks twice.
+ def create
+ @recurring_transaction = build_declared_bill
+
+ if @recurring_transaction.errors.none? && save_declared_bill
+ flash[:notice] = @recurring_transaction.typed_income? ? t(".success_income") : t(".success")
+
+ respond_to do |format|
+ format.html { redirect_to bills_path }
+ format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, bills_path) }
+ end
+ else
+ render :new, status: :unprocessable_entity, layout: dialog_layout
+ end
+ end
+
+ # The dialog is delivered into the shared that every page
+ # layout already renders empty. Responding with the full "settings" layout would put
+ # two frames with that id in one response, and Turbo matches the empty one first, so
+ # the dialog never opens. Drop the layout for frame requests, as `categories#merge`
+ # does, and keep it for a direct visit to the URL.
+ def edit
+ assign_frequency_form_state
+ @sibling_count = sibling_scope.count
+
+ render layout: dialog_layout
+ end
+
+ def update
+ @recurring_transaction.assign_attributes(recurring_transaction_params)
+ apply_editable_identity
+ apply_frequency_preset
+
+ if @recurring_transaction.typed_installment? && @recurring_transaction.end_after_count.present?
+ @recurring_transaction.end_mode = "after_count"
+ @recurring_transaction.anchor_date ||= @recurring_transaction.last_occurrence_date
+ end
+
+ # apply_editable_identity flags a bad account id; save would wipe that
+ # error while validating, so it is checked first.
+ if @recurring_transaction.errors.none? && @recurring_transaction.save
+ applied = apply_payment_url_to_siblings
+
+ flash[:notice] = if applied.positive?
+ t(".success_with_siblings", count: applied)
+ else
+ t(".success")
+ end
+
+ respond_to do |format|
+ format.html { redirect_back_or_to recurring_transactions_path }
+ format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, safe_return_path(fallback: recurring_transactions_path)) }
+ end
+ else
+ @sibling_count = sibling_scope.count
+
+ render :edit, status: :unprocessable_entity, layout: dialog_layout
+ end
+ end
+
+ def toggle_status
if @recurring_transaction.active?
@recurring_transaction.mark_inactive!
message = t("recurring_transactions.marked_inactive")
@@ -62,15 +227,277 @@ class RecurringTransactionsController < ApplicationController
end
def destroy
- @recurring_transaction = Current.family.recurring_transactions.accessible_by(Current.user).find(params[:id])
- @recurring_transaction.destroy!
+ income = @recurring_transaction.typed_income?
- flash[:notice] = t("recurring_transactions.deleted")
- redirect_to recurring_transactions_path
+ # A detected row is removed by tombstoning it rather than deleting it: the
+ # pattern is still in the bank data, so a hard delete lasts only until the
+ # next sync rebuilds it. `ended` is the marker dismissing a suggestion
+ # already leaves, and the Identifier will not claim or recreate one.
+ #
+ # A hand-declared bill has no pattern behind it, so nothing would bring it
+ # back and it is deleted outright.
+ if @recurring_transaction.manual?
+ @recurring_transaction.destroy!
+ else
+ @recurring_transaction.update!(status: "ended")
+ end
+
+ flash[:notice] = t(income ? "recurring_transactions.deleted_income" : "recurring_transactions.deleted")
+ redirect_back_or_to bills_path
end
+ protected
+
+ # Seeds the dialog's model from an existing transaction. Shared with the
+ # smart-fill path, so a failed suggestion still leaves the user exactly
+ # where the plain prefill would have.
+ def prefill_recurring_from_entry(entry)
+ @recurring_transaction.name = entry.entryable.try(:merchant)&.name.presence || entry.name
+ @recurring_transaction.amount = entry.amount.abs
+ @recurring_transaction.account_id = entry.account_id
+ # A negative entry is an inflow: pre-fill as income, not as a bill.
+ @recurring_transaction.is_income = true if entry.amount.negative?
+ @recurring_transaction.first_due_on =
+ RecurringTransaction::Schedule.new(expected_day_of_month: entry.date.day).next_occurrence_from_today
+ end
+
private
+ # Sign-filtered detected patterns not yet covered by any series, mapped
+ # to what the picker renders. Each candidate carries its latest entry's
+ # id so selection can ride the existing entry_id prefill path. Patterns
+ # are family-wide, so they are filtered to the accounts this user can
+ # actually reach.
+ def declare_candidates(income:)
+ identifier = RecurringTransaction::Identifier.new(Current.family)
+ accessible_ids = Current.user.accessible_accounts.pluck(:id)
+
+ patterns = if income
+ # Already sorted heaviest-source-first, so the paycheck leads.
+ identifier.income_source_candidates
+ else
+ identifier.candidate_patterns(sign: :outflow)
+ .sort_by { |pattern| pattern[:last_occurrence_date] }
+ .reverse
+ end
+
+ patterns
+ .select { |pattern| accessible_ids.include?(pattern[:account_id]) }
+ .first(8)
+ .map do |pattern|
+ latest = pattern[:entries].max_by(&:date)
+
+ {
+ name: pattern[:name].presence || latest.entryable.try(:merchant)&.name.presence || latest.name,
+ amount: pattern[:expected_amount_avg].abs,
+ currency: pattern[:currency],
+ last_date: pattern[:last_occurrence_date],
+ count: pattern[:occurrence_count],
+ entry_id: latest.id
+ }
+ end
+ end
+
+ # Every transaction a bill could start from: accessible (not merely
+ # same-family), the right sign for the mode, transfers and excluded rows
+ # out. No target amount exists yet, so recency is the only honest order.
+ # Merchant names are matched because detected entry names are often bank
+ # blobs; the search stays picker-local so the app-wide EntrySearch
+ # semantics (and the transactions index query plan) are untouched.
+ def picker_entries(income:)
+ scope = Current.accessible_entries
+ .where(entryable_type: "Transaction")
+ .where(excluded: false)
+ .where(income ? "entries.amount < 0" : "entries.amount > 0")
+ .merge(Entry.excluding_split_parents)
+ .joins("INNER JOIN transactions ON transactions.id = entries.entryable_id")
+ .where.not(transactions: { kind: Transaction::TRANSFER_KINDS })
+
+ if @picker_query.present?
+ pattern = "%#{ActiveRecord::Base.sanitize_sql_like(@picker_query)}%"
+ scope = scope
+ .joins("LEFT JOIN merchants ON merchants.id = transactions.merchant_id")
+ .where("entries.name ILIKE :q OR entries.notes ILIKE :q OR merchants.name ILIKE :q", q: pattern)
+ end
+
+ scope.order(date: :desc, created_at: :desc)
+ .limit(PICKER_SHOWN)
+ .preload(:account)
+ end
+
+ # Entries already backing a bill get a "Part of X" chip rather than being
+ # hidden: hiding lies, but starting a new bill from a claimed charge
+ # usually means a duplicate in the making.
+ def claimed_series_names(entries)
+ RecurringAllocation.confirmed
+ .joins(:recurring_occurrence)
+ .where(entry_id: entries.map(&:id))
+ .includes(recurring_occurrence: { recurring_transaction: :merchant })
+ .to_h { |a| [ a.entry_id, a.recurring_occurrence.recurring_transaction.display_name ] }
+ end
+
+ def set_recurring_transaction
+ @recurring_transaction = Current.family.recurring_transactions
+ .accessible_by(Current.user)
+ .find(params[:id])
+ end
+
+ # Reading a shared bill is fine; changing it is not. Sharing is per
+ # account, so a read-only account share must not mutate the series.
+ # Accountless series carry no account gate. Same contract as
+ # RecurringOccurrencesController#ensure_series_writable.
+ def ensure_series_writable
+ return if @recurring_transaction.account_id.nil?
+ return if Account.writable_by(Current.user).where(id: @recurring_transaction.account_id).exists?
+
+ raise ActiveRecord::RecordNotFound
+ end
+
+ # name, amount and account are handled by apply_editable_identity rather
+ # than listed here: the account has to be one this user can actually reach,
+ # and the amount carries the series' sign convention. Neither survives a
+ # raw permit.
+ def recurring_transaction_params
+ params.require(:recurring_transaction).permit(
+ :payment_url, :autopay, :notes, :bill_type, :category_id,
+ :renews_on, :trial_ends_on, :cancelled_on, :end_after_count,
+ :frequency_preset, :frequency_day_of_month, :frequency_second_day_of_month,
+ :frequency_weekday, :frequency_month_of_year
+ )
+ end
+
+ def new_recurring_transaction_params
+ params.require(:recurring_transaction).permit(
+ :name, :amount, :account_id, :first_due_on, :frequency_preset,
+ :payment_url, :autopay, :notes, :is_income
+ )
+ end
+
+ def build_declared_bill
+ RecurringTransaction::DeclaredBill.new(
+ family: Current.family,
+ user: Current.user,
+ attrs: new_recurring_transaction_params
+ ).build
+ end
+
+ def save_declared_bill
+ RecurringTransaction::DeclaredBill.save(@recurring_transaction)
+ end
+
+ # Pre-fills the frequency picker's virtual attributes from the series'
+ # rules so the form shows the current cadence.
+ def assign_frequency_form_state
+ detection = RecurringTransaction::FrequencyPreset.detect(@recurring_transaction)
+
+ @recurring_transaction.frequency_preset = detection.key
+ @recurring_transaction.frequency_day_of_month = detection.day_of_month
+ @recurring_transaction.frequency_second_day_of_month = detection.second_day_of_month
+ @recurring_transaction.frequency_weekday = detection.weekday
+ @recurring_transaction.frequency_month_of_year = detection.month_of_year
+ end
+
+ # A bill outliving its own price is the normal case, and the only way to
+ # record a rise used to be delete-and-recreate, which takes the occurrences
+ # and allocations with it. So name, amount and account are editable, but
+ # resolved rather than mass-assigned:
+ #
+ # account must be one this user can reach, or a crafted account_id
+ # would point a bill at another family's account
+ # amount is stored negative for income, so assigning the raw field
+ # would flip a paycheck into a bill
+ #
+ # first_due_on stays create-only. It seeds the schedule and does nothing on
+ # a persisted series, so offering it would be a field that silently fails.
+ # The day a bill falls due is edited through the frequency picker.
+ #
+ # Currency deliberately does not follow the account: it shapes the schedule
+ # and is tied to existing allocations, and the allocator already converts
+ # cross-currency payments.
+ def apply_editable_identity
+ attrs = params.require(:recurring_transaction)
+
+ @recurring_transaction.name = attrs[:name] if attrs.key?(:name)
+
+ if attrs[:amount].present?
+ magnitude = attrs[:amount].to_d.abs
+ @recurring_transaction.amount =
+ @recurring_transaction.typed_income? ? -magnitude : magnitude
+ end
+
+ if attrs.key?(:account_id)
+ # Blank means "any account"; a present id that does not resolve must
+ # not silently detach the bill from its account. Writable, not merely
+ # visible: attaching a series to an account changes what that
+ # account's owners see, so a read-only share cannot be a destination.
+ if attrs[:account_id].blank?
+ @recurring_transaction.account = nil
+ elsif (account = Account.writable_by(Current.user).find_by(id: attrs[:account_id]))
+ @recurring_transaction.account = account
+ else
+ @recurring_transaction.errors.add(:account, :invalid)
+ end
+ end
+ end
+
+ def apply_frequency_preset
+ changed = RecurringTransaction::FrequencyPreset.apply(
+ @recurring_transaction,
+ preset: @recurring_transaction.frequency_preset,
+ day_of_month: @recurring_transaction.frequency_day_of_month,
+ second_day_of_month: @recurring_transaction.frequency_second_day_of_month,
+ weekday: @recurring_transaction.frequency_weekday,
+ month_of_year: @recurring_transaction.frequency_month_of_year
+ )
+
+ # A hand-set cadence is intent, not a guess for detection to correct.
+ @recurring_transaction.pin_schedule if changed
+ end
+
+ # One merchant routinely owns several bills (three separate Twitch subscriptions,
+ # for example), and they all pay at the same portal. Opting in copies the link to
+ # the caller's other bills for that merchant so the user types it once.
+ #
+ # Scoped to what this user can WRITE, not merely see: a series on an account
+ # shared read-only must not be rewritten by the copy, and accountless series
+ # carry no account gate. Merchant-less rows are skipped entirely, because
+ # their only identity is a free-text name that says nothing about where to pay.
+ # `update_all` is deliberate: the value being copied was already normalized and
+ # validated on the source record, and a row-by-row save would let an unrelated
+ # pre-existing validation failure on a legacy sibling abort the whole copy.
+ def apply_payment_url_to_siblings
+ return 0 unless params[:apply_to_siblings] == "1"
+ # Clearing the link is a statement about this bill only: copying a blank
+ # over the siblings would erase links that were never wrong.
+ return 0 if @recurring_transaction.payment_url.blank?
+
+ sibling_scope.update_all(
+ payment_url: @recurring_transaction.payment_url,
+ updated_at: Time.current
+ )
+ end
+
+ # One biller commonly owns several bills that all pay at the same portal (three
+ # separate subscriptions to one service, say). Siblings are found the same way
+ # `RecurringTransaction::Identifier` groups patterns in the first place: by
+ # merchant when there is one, and by exact name otherwise. Matching on merchant
+ # alone would miss most rows, because auto-detection leaves `merchant_id` null
+ # whenever the provider feed gave it nothing to match against.
+ def sibling_scope
+ scope = Current.family.recurring_transactions
+ .accessible_by(Current.user)
+ .where(account_id: Account.writable_by(Current.user).pluck(:id) + [ nil ])
+ .where.not(id: @recurring_transaction.id)
+
+ if @recurring_transaction.merchant_id.present?
+ scope.where(merchant_id: @recurring_transaction.merchant_id)
+ elsif @recurring_transaction.name.present?
+ scope.where(merchant_id: nil, name: @recurring_transaction.name)
+ else
+ RecurringTransaction.none
+ end
+ end
+
def recurring_settings_params
{ recurring_transactions_disabled: params[:recurring_transactions_disabled] == "true" }
end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index eff64a7af..7cf1aba05 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -87,6 +87,25 @@ module ApplicationHelper
item.merge(preview: true)
end
+ # Bills is only meaningful while recurring detection is on, since a family that has
+ # turned it off has nothing to list. Returns nil so the entry drops out of the
+ # `Array#compact` nav list entirely rather than leading to an empty page. The
+ # subsystem also ships as a preview feature, so the entry is preview-gated on
+ # top: hidden without the flag, violet-dotted with it.
+ def bills_nav_item
+ return nil if Current.family.nil? || Current.family.recurring_transactions_disabled?
+
+ preview_gated_nav_item(
+ {
+ name: t("layouts.application.nav.bills"),
+ path: bills_path,
+ icon: "receipt",
+ icon_custom: false,
+ active: page_active?(bills_path)
+ }
+ )
+ end
+
# Budgets and Goals share one nav slot. Preview users get the "Plan" hub
# entry fronting both (it stays lit while browsing either subpage, since
# page_active? is a path-prefix match and /budgets ยท /goals don't share
diff --git a/app/helpers/bills_helper.rb b/app/helpers/bills_helper.rb
new file mode 100644
index 000000000..33ec6a0e5
--- /dev/null
+++ b/app/helpers/bills_helper.rb
@@ -0,0 +1,365 @@
+module BillsHelper
+ # One-shot AI features (smart-fill, smart-configure) need both the user's
+ # consent AND a resolvable LLM provider -- an unconfigured self-hosted
+ # install renders no AI affordances at all, following the Rules registry's
+ # conditional-executor precedent.
+ def bills_one_shot_ai_available?
+ Current.user&.ai_enabled? && Provider::Registry.preferred_llm_provider.present?
+ end
+
+ # Two bills can be genuinely indistinguishable on a row -- same merchant,
+ # same amount, three tiers of one subscription. The keys returned here mark
+ # exactly those collisions, so only the rows that need a second fact get one.
+ def bills_ambiguous_row_keys(occurrences)
+ occurrences.group_by { |o| [ o.recurring_transaction.display_name, o.resolved_expected_amount ] }
+ .select { |_, group| group.size > 1 }
+ .keys.to_set
+ end
+
+ # Pay-period markers keyed by the id of the FIRST occurrence inside each
+ # period, so the section template can drop a marker between groups without
+ # pre-bucketing the rows. Each marker carries its period and the summed
+ # obligations due inside it.
+ def bills_pay_period_markers(occurrences, pay_periods)
+ return {} if pay_periods.blank?
+
+ seen = Set.new
+ occurrences.each_with_object({}) do |occurrence, markers|
+ index = pay_periods.index { |p| occurrence.due_on.between?(p.starts_on, p.ends_on) }
+ next unless index && seen.add?(index)
+
+ period = pay_periods[index]
+ markers[occurrence.id] = {
+ period: period,
+ due_total: occurrences.select { |o| o.due_on.between?(period.starts_on, period.ends_on) }
+ .sum { |o| o.resolved_expected_amount.abs }
+ }
+ end
+ end
+
+ # The month bar reads paid | overdue | still to come. Overdue money is a
+ # subset of what remains, so the slices are derived rather than three
+ # independent totals.
+ def bills_month_progress(paid:, remaining:, overdue:)
+ paid = paid.to_f
+ remaining = remaining.to_f
+ total = paid + remaining
+
+ paid_pct = total.positive? ? (paid / total * 100) : 0
+ overdue_pct = total.positive? ? ([ overdue.to_f, remaining ].min / total * 100) : 0
+
+ {
+ total: total,
+ paid_pct: paid_pct,
+ overdue_pct: overdue_pct,
+ upcoming_pct: [ 100 - paid_pct - overdue_pct, 0 ].max
+ }
+ end
+
+ # What the matcher has learned from manual corrections, prepared for display:
+ # Allocator#learn_from_manual_attach! writes both values and until they were
+ # surfaced the bill quietly widened what it would match without saying so.
+ def bills_matcher_hints(series)
+ {
+ aliases: Array(series.matcher_hints["name_aliases"]).compact_blank,
+ learned_pct: series.matcher_hints["learned_tolerance_pct"].to_f
+ }
+ end
+
+ # The paycheck plan split into what the page renders: the leading no-income
+ # bridge window (reported above the timeline, never inside it), the real
+ # periods, and which of the two bridge states applies -- short earns the
+ # warning, covered-with-items earns the quiet strip.
+ def paycheck_plan_sections(plan)
+ return {} if plan.blank?
+
+ bridge = plan.find(&:bridge?)
+
+ {
+ bridge: bridge,
+ periods: plan.reject(&:bridge?),
+ shortfall: bridge&.short? ? bridge : nil,
+ bridge_note: bridge && !bridge.short? && bridge.items.any? ? bridge : nil
+ }
+ end
+
+ # Which bills a transaction paid, prepared for the transaction drawer.
+ # Preview-gated with the rest of the bills surface: bill links would
+ # dead-end for users without the flag.
+ def entry_bill_allocations(entry)
+ return [] unless preview_features_enabled?
+
+ entry.recurring_allocations
+ .includes(recurring_occurrence: :recurring_transaction)
+ .reject { |allocation| allocation.recurring_occurrence.nil? }
+ end
+
+ # The paycheck split into `[key, percent]` pairs; the caller owns the colours.
+ # A short period gets two segments (covered / short) rather than three, since
+ # there is no safe slice to draw.
+ def paycheck_allocation_segments(period)
+ return [] unless period.income.positive?
+
+ if period.short?
+ normalize_segments([
+ [ :covered, period.income ],
+ [ :short, period.shortfall ]
+ ], period.obligation_total)
+ else
+ normalize_segments([
+ [ :due, period.due_total ],
+ [ :reserved, period.reserved_total ],
+ [ :safe, period.remaining ]
+ ], period.income)
+ end
+ end
+
+ # Percentages summing to exactly 100; the last segment absorbs the remainder
+ # so a fully allocated bar never leaves a rounding sliver.
+ def normalize_segments(parts, total)
+ return [] unless total.positive?
+
+ present = parts.reject { |_key, amount| amount.round(2).zero? }
+ return [] if present.empty?
+
+ running = 0
+ present.each_with_index.map do |(key, amount), index|
+ percent = if index == present.size - 1
+ (100 - running).round(2)
+ else
+ ((amount / total) * 100).round(2).tap { |value| running += value }
+ end
+
+ [ key, percent ]
+ end
+ end
+
+ # The bar in words, in the same terms the card states in text.
+ def paycheck_allocation_aria(period, currency)
+ if period.short?
+ t("bills.paycheck.allocation_aria_short",
+ income: format_money(Money.new(period.income, currency)),
+ short: format_money(Money.new(period.shortfall, currency)))
+ else
+ t("bills.paycheck.allocation_aria",
+ income: format_money(Money.new(period.income, currency)),
+ due: format_money(Money.new(period.due_total, currency)),
+ reserved: format_money(Money.new(period.reserved_total, currency)),
+ safe: format_money(Money.new(period.remaining, currency)))
+ end
+ end
+
+ # Named by the income that opens it: a declared series can be a pension or an
+ # invoice, so "paycheck" would be an assumption.
+ def paycheck_period_heading(period)
+ date = l(period.starts_on, format: :short)
+
+ return t("bills.paycheck.before_next_paycheck", date: l(period.ends_on + 1, format: :short)) if period.bridge?
+
+ "#{date}#{paycheck_period_source(period)}"
+ end
+
+ # The trailing half of the heading, so the date carries the visual weight.
+ def paycheck_period_source(period)
+ return "" if period.bridge?
+
+ case period.income_sources.size
+ when 0 then ""
+ when 1 then t("bills.paycheck.period_source", source: period.income_sources.first)
+ else t("bills.paycheck.period_source_multiple", count: period.income_sources.size)
+ end
+ end
+
+ # The pay schedule in one line: who pays, how often, when next, how much. Two
+ # sources landing on one day are counted, never summed under one name.
+ def paycheck_income_headline(next_income)
+ occurrences = next_income[:occurrences]
+ single = occurrences.one? ? occurrences.first.recurring_transaction : nil
+
+ parts = [
+ single ? single.display_name : t("bills.paycheck.income_source_count", count: occurrences.size),
+ single ? frequency_label(single) : nil,
+ t("bills.paycheck.next_on", date: l(next_income[:date], format: :short)),
+ next_income[:total] ? format_money(next_income[:total]) : nil
+ ]
+
+ parts.compact.join(" ยท ")
+ end
+
+ # Why an income series is listed but not planned around.
+ def paycheck_income_excluded_reason(series)
+ return t("bills.paycheck.income_paused") unless series.active?
+
+ t("bills.paycheck.income_detected")
+ end
+
+ # Leads with the relative distance, which is what tells you whether to act, and
+ # keeps the absolute date alongside it for anything further out than a few days.
+ #
+ # Relative wording is also the safer default: the app runs in UTC while users do
+ # not, so a bare calendar date can read as off-by-one for part of every day.
+ def bills_due_label(bill)
+ days = (bill.next_due_date - Date.current).to_i
+ date = l(bill.next_due_date, format: :short)
+
+ if days.negative?
+ t("bills.due_label.overdue", count: days.abs, date: date)
+ elsif days.zero?
+ t("bills.due_label.today")
+ else
+ t("bills.due_label.upcoming", count: days, date: date)
+ end
+ end
+
+ # Which account the charge lands on. Worth showing only when it tells the rows
+ # apart: on a single-account family it repeated the same name down every line,
+ # which is nineteen copies of a fact carrying no information. The bill's
+ # expansion names the account regardless, so nothing is lost when it is quiet
+ # here.
+ def bills_paid_from_label(bill)
+ return "" if bill.account.blank?
+ return "" unless bills_span_multiple_accounts?
+
+ " ยท #{t('bills.paid_from', account: bill.account.name)}"
+ end
+
+ # Autopay is a state, not a task, so it reads on the bill's own line rather
+ # than in the slot where the row keeps its verb.
+ def bills_autopay_label(bill)
+ return "" unless bill.autopay?
+
+ " ยท #{t('recurring_transactions.pay_action.autopay')}"
+ end
+
+ # Memoized so this costs one query per request rather than one per row.
+ def bills_span_multiple_accounts?
+ return @bills_span_multiple_accounts if defined?(@bills_span_multiple_accounts)
+
+ @bills_span_multiple_accounts =
+ Current.family.recurring_transactions.where.not(account_id: nil)
+ .distinct.count(:account_id) > 1
+ end
+
+ # The occurrence-level twin of bills_due_label: relative-first, snooze-aware.
+ def occurrence_due_label(occurrence)
+ due = occurrence.effective_due_on
+ days = (due - Date.current).to_i
+ date = l(due, format: :short)
+
+ # A settled cycle is not late. This label only ever looked at dates, so a
+ # bill paid three weeks after its due date reported "Overdue by 20 days"
+ # directly beside its own "$11.99 of $11.99 paid" total. Once a cycle is
+ # closed the only useful fact left is when it had been due.
+ return t("bills.due_label.settled", date: date) unless occurrence.scheduled?
+
+ # Overdue is the occurrence's own judgement, not a sign test on the date.
+ # RecurringOccurrence#derived_state only calls a cycle overdue once its
+ # grace period has run out, and every other surface honours that: the
+ # overview files a bill inside its grace under This month, and get_bills
+ # reports state "due". This label read the raw date and printed "Overdue by
+ # 1 day" on the same bill, in the secondary colour, because the surrounding
+ # tone check asks overdue? and got false. The screen contradicted itself
+ # and the assistant at once.
+ if occurrence.snoozed_until.present? && occurrence.snoozed_until > occurrence.due_on && days.positive?
+ t("bills.due_label.snoozed", date: date)
+ elsif occurrence.overdue?
+ t("bills.due_label.overdue", count: days.abs, date: date)
+ elsif days.zero?
+ t("bills.due_label.today")
+ elsif days.negative?
+ # Past its date but still inside the grace the bill was given.
+ t("bills.due_label.due_since", date: date)
+ else
+ t("bills.due_label.upcoming", count: days, date: date)
+ end
+ end
+
+ # Short enough for a column in the Next up strip: relative while that still
+ # means something, absolute once it does not.
+ #
+ # There is deliberately no "late" case. The strip only ever holds bills due
+ # today or later, because something already past its due date is not part of
+ # what is coming up -- it is the thing the list below is for.
+ def bills_next_up_date(occurrence)
+ case (occurrence.effective_due_on - Date.current).to_i
+ when 0 then t("bills.month_pulse.date_today")
+ when 1 then t("bills.month_pulse.date_tomorrow")
+ else l(occurrence.effective_due_on, format: "%b %-d")
+ end
+ end
+
+ # Why this row is in the Needs attention section.
+ #
+ # The section used to say "Overdue" against every row, which is alarming
+ # without being actionable: it names the symptom every row already shares
+ # instead of the thing that differs. First true wins, most specific first.
+ def bills_attention_reason(occurrence, suggestion: nil)
+ return t("bills.attention.needs_review") if suggestion.present?
+
+ if occurrence.partially_paid?
+ return t("bills.attention.partial", amount: format_money(occurrence.remaining_amount_money))
+ end
+
+ if occurrence.recurring_transaction.recurring_price_changes.any? { |change| change.effective_on >= 30.days.ago.to_date }
+ return t("bills.attention.amount_changed")
+ end
+
+ return nil unless occurrence.derived_state == :overdue
+
+ t("bills.attention.overdue", count: (Date.current - occurrence.effective_due_on).to_i)
+ end
+
+ # The match score's own components, said in words.
+ #
+ # Deterministic: every phrase here corresponds to a key the matcher actually
+ # wrote, so nothing is inferred and nothing is invented. Works for both
+ # callers -- a live candidate scored by Matcher#explain (symbol keys) and a
+ # persisted allocation's match_signals (string keys out of jsonb).
+ #
+ # The account signal is deliberately never rendered. It is a constant 0.10 on
+ # every candidate, because identity_matches? has already rejected everything
+ # on another account, so "same account" is a reason that never once
+ # distinguishes one candidate from another.
+ def bills_match_reasons(signals, currency:, expected: nil, actual: nil, due_on: nil, paid_on: nil)
+ signals = (signals || {}).symbolize_keys
+ reasons = []
+
+ reasons << t("bills.match.same_merchant") if signals[:merchant]
+ reasons << t("bills.match.name_matches") if signals[:name]
+
+ # Guarded: the review queue can hold an allocation whose entry has been
+ # nullified out from under it, so neither figure is guaranteed.
+ if signals[:amount] && expected.present? && actual.present?
+ difference = (actual - expected).abs
+
+ reasons << if difference < BigDecimal("0.01")
+ t("bills.match.exact_amount")
+ else
+ t("bills.match.amount_off", amount: format_money(Money.new(difference, currency)))
+ end
+ end
+
+ if signals[:date] && due_on.present? && paid_on.present?
+ days = (paid_on - due_on).to_i
+
+ reasons << if days.zero?
+ t("bills.match.due_date")
+ elsif days.negative?
+ t("bills.match.days_before", count: days.abs)
+ else
+ t("bills.match.days_after", count: days)
+ end
+ end
+
+ reasons
+ end
+
+ # An amount whose expectation is derived (average strategy, or an observed
+ # variance band) is shown as approximate; a fixed declared amount never is.
+ def occurrence_amount_estimated?(occurrence)
+ return false if occurrence.expected_amount.present?
+
+ series = occurrence.recurring_transaction
+ !series.amount_fixed? || series.has_amount_variance?
+ end
+end
diff --git a/app/helpers/recurring_transactions_helper.rb b/app/helpers/recurring_transactions_helper.rb
new file mode 100644
index 000000000..d1f94d9c7
--- /dev/null
+++ b/app/helpers/recurring_transactions_helper.rb
@@ -0,0 +1,42 @@
+module RecurringTransactionsHelper
+ def frequency_label(recurring_transaction)
+ RecurringTransaction::FrequencyPreset.label(recurring_transaction)
+ end
+
+ # Status is domain state; the tone is how the design system says it. The
+ # mapping lives here so every surface badges a status the same way.
+ def recurring_status_pill_tone(status)
+ case status.to_s
+ when "active" then :success
+ when "suggested" then :warning
+ else :neutral
+ end
+ end
+
+ def frequency_preset_options(recurring_transaction)
+ options = RecurringTransaction::FrequencyPreset::PRESETS.map do |preset|
+ [ t("recurring_transactions.frequency_presets.#{preset}"), preset ]
+ end
+
+ if RecurringTransaction::FrequencyPreset.detect(recurring_transaction).key == RecurringTransaction::FrequencyPreset::CUSTOM
+ options.unshift([ t("recurring_transactions.frequency_presets.custom"), RecurringTransaction::FrequencyPreset::CUSTOM ])
+ end
+
+ options
+ end
+
+ def frequency_day_options
+ # localized_ordinal, not ordinalize: the bare Rails helper always emits
+ # English suffixes regardless of the active locale.
+ (1..31).map { |day| [ localized_ordinal(day), day ] } +
+ [ [ t("recurring_transactions.frequency.last_day"), RecurrenceRule::LAST ] ]
+ end
+
+ def frequency_weekday_options
+ t("date.day_names").each_with_index.map { |name, index| [ name, index ] }
+ end
+
+ def frequency_month_options
+ t("date.month_names").compact.each_with_index.map { |name, index| [ name, index + 1 ] }
+ end
+end
diff --git a/app/javascript/controllers/app_layout_controller.js b/app/javascript/controllers/app_layout_controller.js
index ceeec0f33..f31c7b923 100644
--- a/app/javascript/controllers/app_layout_controller.js
+++ b/app/javascript/controllers/app_layout_controller.js
@@ -31,6 +31,16 @@ export default class extends Controller {
this.#toggleSidebarWidth(this.rightSidebarTarget, isOpen, "right");
}
+ // For actions that send content into the chat sidebar (quick prompts,
+ // AI review): make sure it is visible, and never close it.
+ openRightSidebar() {
+ const isOpen = this.rightSidebarTarget.classList.contains("w-full");
+ if (isOpen) return;
+
+ this.#updateUserPreference("show_ai_sidebar", true);
+ this.#toggleSidebarWidth(this.rightSidebarTarget, false, "right");
+ }
+
#toggleSidebarWidth(el, isCurrentlyOpen, side) {
const expandedClasses = side === "left" ? [...this.expandedSidebarClasses, "border-r"] : [...this.expandedSidebarClasses, "border-l"];
const collapsedClasses = side === "left" ? [...this.collapsedSidebarClasses, "border-r-0"] : [...this.collapsedSidebarClasses, "border-l-0"];
diff --git a/app/javascript/controllers/frequency_fields_controller.js b/app/javascript/controllers/frequency_fields_controller.js
new file mode 100644
index 000000000..f2cdf1c47
--- /dev/null
+++ b/app/javascript/controllers/frequency_fields_controller.js
@@ -0,0 +1,20 @@
+import { Controller } from "@hotwired/stimulus";
+
+// Shows the frequency-picker field groups relevant to the selected preset.
+// Display logic only; the preset-to-rules translation is server-side.
+export default class extends Controller {
+ static targets = ["preset", "group"];
+
+ connect() {
+ this.update();
+ }
+
+ update() {
+ const preset = this.presetTarget.value;
+
+ this.groupTargets.forEach((group) => {
+ const presets = (group.dataset.presets || "").split(",");
+ group.classList.toggle("hidden", !presets.includes(preset));
+ });
+ }
+}
diff --git a/app/javascript/controllers/persisted_disclosure_controller.js b/app/javascript/controllers/persisted_disclosure_controller.js
new file mode 100755
index 000000000..f3dd97616
--- /dev/null
+++ b/app/javascript/controllers/persisted_disclosure_controller.js
@@ -0,0 +1,30 @@
+import { Controller } from "@hotwired/stimulus";
+
+// Remembers whether a section is open, per device, so a panel the
+// user has collapsed stays collapsed instead of reappearing on every render.
+// Same storage approach as privacy mode and the sidebar width.
+//
+// The element keeps its server-rendered `open` state until connect() runs, so
+// a collapsed section flashes open for a frame on a cold load. Reading storage
+// in connect() rather than waiting for a turbo event keeps that to one frame.
+export default class extends Controller {
+ static values = { key: String };
+
+ connect() {
+ const stored = localStorage.getItem(this.storageKey);
+ if (stored !== null) this.element.open = stored === "true";
+
+ this.toggleHandler = () => {
+ localStorage.setItem(this.storageKey, String(this.element.open));
+ };
+ this.element.addEventListener("toggle", this.toggleHandler);
+ }
+
+ disconnect() {
+ this.element.removeEventListener("toggle", this.toggleHandler);
+ }
+
+ get storageKey() {
+ return `disclosure:${this.keyValue}`;
+ }
+}
diff --git a/app/models/family.rb b/app/models/family.rb
index 387dd3eff..7a4d336eb 100644
--- a/app/models/family.rb
+++ b/app/models/family.rb
@@ -543,6 +543,46 @@ class Family < ApplicationRecord
Rails.application.config.app_mode.self_hosted?
end
+ # Lazy so existing families get a token on first render, and resetting is
+ # revocation.
+ def bills_feed_token!
+ return bills_feed_token if bills_feed_token.present?
+
+ update!(bills_feed_token: SecureRandom.urlsafe_base64(24))
+ bills_feed_token
+ end
+
+ def reset_bills_feed_token!
+ update!(bills_feed_token: SecureRandom.urlsafe_base64(24))
+ bills_feed_token
+ end
+
+ # The URL a member subscribes to carries the MEMBER's identity, because the
+ # feed must honor per-account sharing: a member who can reach a subset of
+ # accounts must not receive the whole family's obligations. The family
+ # secret never appears in the URL; only a digest of it does, so rotating
+ # `bills_feed_token` still revokes every previously shared URL at once.
+ def bills_feed_token_for(user)
+ self.class.bills_feed_verifier.generate([ user.id, bills_feed_stamp! ])
+ end
+
+ def bills_feed_stamp!
+ Digest::SHA256.hexdigest(bills_feed_token!).first(16)
+ end
+
+ # Non-minting read for the verification side: a family that never rendered
+ # a feed link has no token, and a signed URL from some earlier life must
+ # not conjure one into existence to match against.
+ def bills_feed_stamp
+ return nil if bills_feed_token.blank?
+
+ Digest::SHA256.hexdigest(bills_feed_token).first(16)
+ end
+
+ def self.bills_feed_verifier
+ Rails.application.message_verifier("bills-user-feed")
+ end
+
private
# Mirrors the inline `investment_ids` / `crypto_ids` SQL blocks in
# `tax_advantaged_account_ids`. Joins `depositories` and filters by
diff --git a/app/models/provider/anthropic.rb b/app/models/provider/anthropic.rb
index 9328266ff..af9035680 100644
--- a/app/models/provider/anthropic.rb
+++ b/app/models/provider/anthropic.rb
@@ -97,6 +97,31 @@ class Provider::Anthropic < Provider
end
end
+ def suggest_bill_setup(charges: [], categories: [], current_config: nil, model: "", family: nil)
+ with_provider_response do
+ effective_model = model.presence || @default_model
+
+ trace = create_langfuse_trace(
+ name: "anthropic.suggest_bill_setup",
+ input: { charges: charges, configure_mode: current_config.present? }
+ )
+
+ result = BillSetupSuggester.new(
+ client,
+ model: effective_model,
+ charges: charges,
+ categories: categories,
+ current_config: current_config,
+ langfuse_trace: trace,
+ family: family
+ ).suggest
+
+ upsert_langfuse_trace(trace: trace, output: result.to_h)
+
+ result
+ end
+ end
+
def auto_detect_merchants(transactions: [], user_merchants: [], model: "", family: nil, json_mode: nil)
with_provider_response do
raise Error, "Too many transactions to auto-detect merchants. Max is 25 per request." if transactions.size > 25
diff --git a/app/models/provider/anthropic/bill_setup_suggester.rb b/app/models/provider/anthropic/bill_setup_suggester.rb
new file mode 100644
index 000000000..801f09429
--- /dev/null
+++ b/app/models/provider/anthropic/bill_setup_suggester.rb
@@ -0,0 +1,178 @@
+class Provider::Anthropic::BillSetupSuggester
+ include Provider::Anthropic::Concerns::UsageRecorder
+
+ TOOL_NAME = "report_bill_setup".freeze
+
+ attr_reader :client, :model, :charges, :categories, :current_config, :langfuse_trace, :family
+
+ def initialize(client, model:, charges: [], categories: [], current_config: nil, langfuse_trace: nil, family: nil)
+ @client = client
+ @model = model
+ @charges = charges
+ @categories = categories
+ @current_config = current_config
+ @langfuse_trace = langfuse_trace
+ @family = family
+ end
+
+ def suggest
+ span = langfuse_trace&.span(name: "suggest_bill_setup_api_call", input: {
+ model: model,
+ charges: charges,
+ configure_mode: current_config.present?
+ })
+
+ response = client.messages.create(
+ model: model,
+ max_tokens: max_tokens,
+ system_: instructions,
+ messages: [ { role: "user", content: user_message } ],
+ tools: [ output_tool ],
+ tool_choice: { type: "tool", name: TOOL_NAME, disable_parallel_tool_use: true }
+ )
+
+ result = build_suggestion(extract_input(response))
+
+ record_usage(model, response.usage, operation: "suggest_bill_setup", metadata: {
+ charge_count: charges.size,
+ configure_mode: current_config.present?
+ })
+
+ span&.end(output: result.to_h, usage: usage_hash(response.usage))
+ result
+ rescue => e
+ span&.end(output: { error: e.message }, level: "ERROR")
+ record_usage_error(model, operation: "suggest_bill_setup", error: e, metadata: {
+ charge_count: charges.size
+ })
+ raise
+ end
+
+ private
+ Suggestion = Provider::LlmConcept::BillSetupSuggestion
+
+ def max_tokens
+ ENV.fetch("ANTHROPIC_MAX_TOKENS", 4096).to_i
+ end
+
+ def output_tool
+ nullable_string = { type: [ "string", "null" ] }
+ nullable_integer = { type: [ "integer", "null" ] }
+
+ {
+ name: TOOL_NAME,
+ description: "Report the proposed recurring-bill configuration.",
+ input_schema: {
+ type: "object",
+ properties: {
+ name: nullable_string,
+ amount: { type: [ "number", "null" ], description: "Typical recent charge, positive." },
+ frequency: {
+ type: [ "string", "null" ],
+ enum: RecurringTransaction::FrequencyPreset::PRESETS + [ nil ]
+ },
+ day_of_month: nullable_integer,
+ weekday: nullable_integer.merge(description: "0 = Sunday; weekly cadences only."),
+ month_of_year: nullable_integer,
+ category_name: {
+ type: [ "string", "null" ],
+ description: "Exact match from the provided categories, or null.",
+ enum: categories + [ nil ]
+ },
+ bill_type: { type: [ "string", "null" ], enum: %w[bill subscription installment] + [ nil ] },
+ autopay: { type: [ "boolean", "null" ] },
+ confidence: { type: [ "number", "null" ] },
+ rationale: nullable_string
+ },
+ required: %w[name amount frequency day_of_month weekday month_of_year category_name bill_type autopay confidence rationale],
+ additionalProperties: false
+ }
+ }
+ end
+
+ def instructions
+ base = <<~INSTRUCTIONS.strip_heredoc
+ You configure recurring-bill records for a personal finance app. Given the dated
+ charge history for one obligation, propose the bill's configuration via the
+ #{TOOL_NAME} tool.
+
+ Rules:
+ - Infer the cadence from the gaps between the dates, never from the row count.
+ - amount is the typical recent charge as a positive number; when amounts drift,
+ prefer the most recent ones.
+ - day_of_month is the modal charge day (monthly-style cadences only); weekday
+ only for weekly/biweekly; month_of_year only for annual.
+ - bill_type: "subscription" for digital services and memberships, "installment"
+ for finite payment plans, otherwise "bill".
+ - autopay true only when the history shows automatic-payment markers.
+ - Set any field the history cannot support to null. Never guess.
+ - confidence is 0 to 1 for the proposal overall; rationale is one short sentence.
+ INSTRUCTIONS
+
+ return base if current_config.blank?
+
+ base + <<~CONFIGURE.strip_heredoc
+
+ A current configuration is provided. Propose ONLY fields where the charge history
+ contradicts it; set every field that is already right to null.
+ CONFIGURE
+ end
+
+ def user_message
+ message = +"CHARGE HISTORY (date amount description):\n"
+ message << charges.map { |charge| "- #{charge[:date]} #{charge[:amount]} #{charge[:name]}" }.join("\n")
+ message << "\n\nAVAILABLE CATEGORIES: #{categories.join(", ")}"
+ message << "\n\nCURRENT CONFIGURATION:\n#{current_config.to_json}" if current_config.present?
+ message
+ end
+
+ def extract_input(response)
+ tool_use = Array(response.content).find { |block| block_type(block) == :tool_use }
+ raise Provider::Anthropic::Error, "Model did not invoke #{TOOL_NAME}" unless tool_use
+
+ input = block_input(tool_use)
+ input = JSON.parse(input) if input.is_a?(String)
+ input.is_a?(Hash) ? input.stringify_keys : {}
+ end
+
+ def build_suggestion(parsed)
+ Suggestion.new(
+ name: presence_string(parsed["name"]),
+ amount: parsed["amount"].is_a?(Numeric) ? parsed["amount"].to_f : nil,
+ frequency: presence_string(parsed["frequency"]),
+ day_of_month: parsed["day_of_month"].is_a?(Integer) ? parsed["day_of_month"] : nil,
+ weekday: parsed["weekday"].is_a?(Integer) ? parsed["weekday"] : nil,
+ month_of_year: parsed["month_of_year"].is_a?(Integer) ? parsed["month_of_year"] : nil,
+ category_name: presence_string(parsed["category_name"]),
+ bill_type: presence_string(parsed["bill_type"]),
+ autopay: [ true, false ].include?(parsed["autopay"]) ? parsed["autopay"] : nil,
+ confidence: parsed["confidence"].is_a?(Numeric) ? parsed["confidence"].to_f : nil,
+ rationale: presence_string(parsed["rationale"])
+ )
+ end
+
+ def presence_string(value)
+ normalized = value.to_s.strip
+ return nil if normalized.empty? || normalized.casecmp("null").zero?
+
+ normalized
+ end
+
+ def block_type(block)
+ raw = block.respond_to?(:type) ? block.type : block[:type] || block["type"]
+ raw.to_s.to_sym
+ end
+
+ def block_input(block)
+ block.respond_to?(:input) ? block.input : (block[:input] || block["input"])
+ end
+
+ def usage_hash(raw_usage)
+ return {} unless raw_usage
+ {
+ "input_tokens" => raw_usage.input_tokens.to_i,
+ "output_tokens" => raw_usage.output_tokens.to_i,
+ "total_tokens" => raw_usage.input_tokens.to_i + raw_usage.output_tokens.to_i
+ }
+ end
+end
diff --git a/app/models/provider/llm_concept.rb b/app/models/provider/llm_concept.rb
index 698bf13ee..1caad8174 100644
--- a/app/models/provider/llm_concept.rb
+++ b/app/models/provider/llm_concept.rb
@@ -19,6 +19,18 @@ module Provider::LlmConcept
raise NotImplementedError, "Subclasses must implement #enhance_provider_merchants"
end
+ # One proposed recurring-bill configuration, inferred from charge history.
+ # Every field is nullable: null means the history cannot support a value
+ # (or, in configure mode, that the current configuration is already right).
+ BillSetupSuggestion = Data.define(
+ :name, :amount, :frequency, :day_of_month, :weekday, :month_of_year,
+ :category_name, :bill_type, :autopay, :confidence, :rationale
+ )
+
+ def suggest_bill_setup(charges:, categories: [], current_config: nil, model: "", family: nil)
+ raise NotImplementedError, "Subclasses must implement #suggest_bill_setup"
+ end
+
PdfProcessingResult = Data.define(:summary, :document_type, :extracted_data)
def supports_pdf_processing?
diff --git a/app/models/provider/openai.rb b/app/models/provider/openai.rb
index 1c6f5d86b..af44ee3b1 100644
--- a/app/models/provider/openai.rb
+++ b/app/models/provider/openai.rb
@@ -165,6 +165,31 @@ class Provider::Openai < Provider
end
end
+ def suggest_bill_setup(charges: [], categories: [], current_config: nil, model: "", family: nil)
+ with_provider_response do
+ effective_model = model.presence || @default_model
+
+ trace = create_langfuse_trace(
+ name: "openai.suggest_bill_setup",
+ input: { charges: charges, configure_mode: current_config.present? }
+ )
+
+ result = BillSetupSuggester.new(
+ client,
+ model: effective_model,
+ charges: charges,
+ categories: categories,
+ current_config: current_config,
+ langfuse_trace: trace,
+ family: family
+ ).suggest
+
+ upsert_langfuse_trace(trace: trace, output: result.to_h)
+
+ result
+ end
+ end
+
def auto_detect_merchants(transactions: [], user_merchants: [], model: "", family: nil, json_mode: nil)
with_provider_response do
effective_model = model.presence || @default_model
diff --git a/app/models/provider/openai/bill_setup_suggester.rb b/app/models/provider/openai/bill_setup_suggester.rb
new file mode 100644
index 000000000..23d37dd38
--- /dev/null
+++ b/app/models/provider/openai/bill_setup_suggester.rb
@@ -0,0 +1,169 @@
+class Provider::Openai::BillSetupSuggester
+ include Provider::Openai::Concerns::UsageRecorder
+
+ attr_reader :client, :model, :charges, :categories, :current_config, :langfuse_trace, :family
+
+ def initialize(client, model: "", charges: [], categories: [], current_config: nil, langfuse_trace: nil, family: nil)
+ @client = client
+ @model = model
+ @charges = charges
+ @categories = categories
+ @current_config = current_config
+ @langfuse_trace = langfuse_trace
+ @family = family
+ end
+
+ # One chat-completions call with a json_object response format, falling back
+ # to no constraint for providers that reject it (the AutoCategorizer's
+ # lesson: strict formats break some OpenAI-compatible hosts and local LLMs).
+ def suggest
+ suggest_with_format({ type: "json_object" })
+ rescue Faraday::BadRequestError => e
+ Rails.logger.warn("json_object mode failed for bill setup suggestion, retrying without: #{e.message}")
+ suggest_with_format(nil)
+ end
+
+ private
+ Suggestion = Provider::LlmConcept::BillSetupSuggestion
+
+ def suggest_with_format(response_format)
+ span = langfuse_trace&.span(name: "suggest_bill_setup_api_call", input: {
+ model: model,
+ charges: charges,
+ configure_mode: current_config.present?
+ })
+
+ params = {
+ model: model,
+ messages: [
+ { role: "system", content: instructions },
+ { role: "user", content: user_message }
+ ]
+ }
+ params[:response_format] = response_format if response_format
+
+ response = client.chat(parameters: params)
+
+ result = build_suggestion(parse_json_flexibly(response.dig("choices", 0, "message", "content")))
+
+ record_usage(model, response.dig("usage"), operation: "suggest_bill_setup", metadata: {
+ charge_count: charges.size,
+ configure_mode: current_config.present?
+ })
+
+ span&.end(output: result.to_h, usage: response.dig("usage"))
+ result
+ rescue => e
+ span&.end(output: { error: e.message }, level: "ERROR")
+ raise
+ end
+
+ def instructions
+ base = <<~INSTRUCTIONS.strip_heredoc
+ You configure recurring-bill records for a personal finance app. Given the dated
+ charge history for one obligation, propose the bill's configuration as JSON only.
+
+ Rules:
+ - Infer the cadence from the gaps between the dates, never from the row count.
+ frequency is one of: monthly, weekly, biweekly, semimonthly, quarterly, semiannual, annual.
+ - amount is the typical recent charge as a positive number; when amounts drift,
+ prefer the most recent ones.
+ - day_of_month is the modal charge day (monthly-style cadences only); weekday
+ (0=Sunday) only for weekly/biweekly; month_of_year only for annual.
+ - category_name must EXACTLY match one of the provided categories, or null.
+ - bill_type: "subscription" for digital services and memberships, "installment"
+ for finite payment plans, otherwise "bill".
+ - autopay true only when the history shows automatic-payment markers (ACH, AUTOPAY).
+ - Set any field the history cannot support to null. Never guess.
+ - confidence is 0 to 1 for the proposal overall; rationale is one short sentence.
+
+ Output JSON only, exactly this shape (no markdown, no explanation):
+ {"name": ..., "amount": ..., "frequency": ..., "day_of_month": ..., "weekday": ...,
+ "month_of_year": ..., "category_name": ..., "bill_type": ..., "autopay": ...,
+ "confidence": ..., "rationale": ...}
+ INSTRUCTIONS
+
+ return base if current_config.blank?
+
+ base + <<~CONFIGURE.strip_heredoc
+
+ A current configuration is provided. Propose ONLY fields where the charge history
+ contradicts it; set every field that is already right to null.
+ CONFIGURE
+ end
+
+ def user_message
+ message = +"CHARGE HISTORY (date amount description):\n"
+ message << charges.map { |charge| "- #{charge[:date]} #{charge[:amount]} #{charge[:name]}" }.join("\n")
+ message << "\n\nAVAILABLE CATEGORIES: #{categories.join(", ")}"
+ message << "\n\nCURRENT CONFIGURATION:\n#{current_config.to_json}" if current_config.present?
+ message
+ end
+
+ def build_suggestion(parsed)
+ Suggestion.new(
+ name: string_or_nil(parsed["name"]),
+ amount: numeric_or_nil(parsed["amount"]),
+ frequency: string_or_nil(parsed["frequency"]),
+ day_of_month: integer_or_nil(parsed["day_of_month"]),
+ weekday: integer_or_nil(parsed["weekday"]),
+ month_of_year: integer_or_nil(parsed["month_of_year"]),
+ category_name: string_or_nil(parsed["category_name"]),
+ bill_type: string_or_nil(parsed["bill_type"]),
+ autopay: [ true, false ].include?(parsed["autopay"]) ? parsed["autopay"] : nil,
+ confidence: numeric_or_nil(parsed["confidence"]),
+ rationale: string_or_nil(parsed["rationale"])
+ )
+ end
+
+ def string_or_nil(value)
+ normalized = value.to_s.strip
+ return nil if normalized.empty? || normalized.casecmp("null").zero?
+
+ normalized
+ end
+
+ def numeric_or_nil(value)
+ Float(value)
+ rescue TypeError, ArgumentError
+ nil
+ end
+
+ def integer_or_nil(value)
+ Integer(value)
+ rescue TypeError, ArgumentError
+ nil
+ end
+
+ # Same flexible parsing the sibling one-shot classes carry: LLM output may
+ # wrap JSON in markdown fences or thinking tags.
+ def parse_json_flexibly(raw)
+ raise Provider::Openai::Error, "No message content in response" if raw.blank?
+
+ cleaned = strip_thinking_tags(raw)
+
+ begin
+ JSON.parse(cleaned)
+ rescue JSON::ParserError
+ if cleaned =~ /```(?:json)?\s*(\{[\s\S]*?\})\s*```/m
+ JSON.parse(Regexp.last_match(1))
+ elsif cleaned =~ /(\{[\s\S]*\})/m
+ JSON.parse(Regexp.last_match(1))
+ else
+ raise Provider::Openai::Error, "Could not parse JSON from response: #{raw.truncate(200)}"
+ end
+ end
+ end
+
+ def strip_thinking_tags(raw)
+ return raw unless raw.include?("")
+
+ if raw =~ /<\/think>\s*([\s\S]*)/m && Regexp.last_match(1).strip.present?
+ Regexp.last_match(1)
+ elsif raw =~ /([\s\S]*)/m
+ Regexp.last_match(1)
+ else
+ raw
+ end
+ end
+end
diff --git a/app/models/recurring_transaction/ai_setup_suggester.rb b/app/models/recurring_transaction/ai_setup_suggester.rb
new file mode 100644
index 000000000..23cb9499d
--- /dev/null
+++ b/app/models/recurring_transaction/ai_setup_suggester.rb
@@ -0,0 +1,128 @@
+class RecurringTransaction
+ # Turns charge history into a reviewed bill-configuration proposal via the
+ # family's configured LLM provider. Two modes:
+ #
+ # suggest_from_entries -- from candidate entries (the add dialog's
+ # smart-fill; nothing exists yet)
+ # suggest_configuration -- from a series' own charge history against its
+ # current settings (per-bill smart-configure;
+ # only contradicted fields come back non-null)
+ #
+ # Every provider value is normalized here -- clamped to real presets and
+ # ranges, category resolved to this family's own id -- so callers can trust
+ # the shape without re-validating LLM output.
+ class AiSetupSuggester
+ Error = Class.new(StandardError)
+ MAX_CHARGES = 40
+
+ Suggestion = Data.define(
+ :name, :amount, :frequency, :day_of_month, :weekday, :month_of_year,
+ :category_id, :category_name, :bill_type, :autopay, :confidence, :rationale
+ ) do
+ # nil means "no proposal for this field"; false is a real proposal
+ # (turn autopay off), so presence is non-nil rather than truthy.
+ def any_proposal?
+ [ name, amount, frequency, day_of_month, weekday, month_of_year, category_id, bill_type, autopay ].any? { |value| !value.nil? }
+ end
+ end
+
+ attr_reader :family, :user
+
+ def initialize(family, user:)
+ @family = family
+ @user = user
+ end
+
+ def suggest_from_entries(entries)
+ run(charges: charges_from(entries), current_config: nil)
+ end
+
+ def suggest_configuration(series)
+ run(
+ charges: charges_from(series.matching_transactions),
+ current_config: current_config_for(series)
+ )
+ end
+
+ private
+ def run(charges:, current_config:)
+ raise Error, "No LLM provider configured" unless llm_provider
+ raise Error, "No charge history to analyze" if charges.empty?
+
+ result = llm_provider.suggest_bill_setup(
+ charges: charges,
+ categories: family.categories.pluck(:name),
+ current_config: current_config,
+ family: family
+ )
+
+ raise Error, "Provider failed: #{result.error&.message}" unless result.success?
+
+ normalize(result.data)
+ end
+
+ def llm_provider
+ Provider::Registry.preferred_llm_provider
+ end
+
+ def charges_from(entries)
+ entries.first(MAX_CHARGES).map do |entry|
+ { date: entry.date.iso8601, amount: entry.amount.abs.to_s, name: entry.name }
+ end
+ end
+
+ def current_config_for(series)
+ detection = FrequencyPreset.detect(series)
+
+ {
+ name: series.display_name,
+ amount: series.amount.abs.to_s,
+ frequency: detection.key,
+ day_of_month: detection.day_of_month,
+ weekday: detection.weekday,
+ month_of_year: detection.month_of_year,
+ category: series.category&.name,
+ bill_type: series.bill_type,
+ autopay: series.autopay
+ }.compact
+ end
+
+ def normalize(raw)
+ category = resolve_category(raw.category_name)
+
+ Suggestion.new(
+ name: raw.name,
+ amount: positive_or_nil(raw.amount),
+ frequency: raw.frequency.presence_in(FrequencyPreset::PRESETS),
+ day_of_month: in_range(raw.day_of_month, 1..31),
+ weekday: in_range(raw.weekday, 0..6),
+ month_of_year: in_range(raw.month_of_year, 1..12),
+ category_id: category&.id,
+ category_name: category&.name,
+ bill_type: raw.bill_type.presence_in(%w[bill subscription installment]),
+ autopay: [ true, false ].include?(raw.autopay) ? raw.autopay : nil,
+ confidence: raw.confidence.is_a?(Numeric) ? raw.confidence.to_f.clamp(0.0, 1.0) : nil,
+ rationale: raw.rationale
+ )
+ end
+
+ # This family's category or nothing: an LLM-invented name must never
+ # become an id, and another family's category can never resolve here.
+ def resolve_category(name)
+ return nil if name.blank?
+
+ family.categories.find_by(name: name) ||
+ family.categories.find_by(Category.arel_table[:name].lower.eq(name.downcase))
+ end
+
+ def positive_or_nil(value)
+ return nil unless value.is_a?(Numeric)
+
+ value.positive? ? BigDecimal(value.to_s).abs : nil
+ end
+
+ def in_range(value, range)
+ value.is_a?(Integer) && range.cover?(value) ? value : nil
+ end
+ end
+end
diff --git a/app/models/recurring_transaction/declared_bill.rb b/app/models/recurring_transaction/declared_bill.rb
index 03df89dd3..16ccb027f 100644
--- a/app/models/recurring_transaction/declared_bill.rb
+++ b/app/models/recurring_transaction/declared_bill.rb
@@ -1,8 +1,10 @@
class RecurringTransaction
# The one declared-bill build path, shared by the add-bill form and the AI
# create tool so the two can never drift on the rules that matter here:
- # the account must be one the user can actually reach, and the amount
- # carries the sign convention (income is stored negative).
+ # the account must be one the user can actually write (attaching a bill
+ # changes what that account's owners see, so a read-only share is not a
+ # destination), and the amount carries the sign convention (income is
+ # stored negative).
class DeclaredBill
attr_reader :family, :user, :attrs
@@ -13,7 +15,7 @@ class RecurringTransaction
end
def build
- account = user.accessible_accounts.find_by(id: attrs[:account_id])
+ account = Account.writable_by(user).find_by(id: attrs[:account_id])
due = begin
Date.parse(attrs[:first_due_on].to_s)
rescue Date::Error
@@ -51,6 +53,14 @@ class RecurringTransaction
recurring.frequency_preset = attrs[:frequency_preset]
recurring.first_due_on = attrs[:first_due_on]
+ # A chosen account that does not resolve to something writable is said
+ # out loud, not silently dropped: a read-only share or a foreign id
+ # would otherwise become an accountless bill in the family currency.
+ if attrs[:account_id].present? && account.nil?
+ recurring.errors.add(:base, I18n.t("recurring_transactions.create.account_invalid"))
+ return recurring
+ end
+
if amount.nil?
recurring.errors.add(:base, I18n.t("recurring_transactions.create.amount_invalid"))
return recurring
diff --git a/app/views/bills/_ai_prompts.html.erb b/app/views/bills/_ai_prompts.html.erb
new file mode 100644
index 000000000..5286bf55d
--- /dev/null
+++ b/app/views/bills/_ai_prompts.html.erb
@@ -0,0 +1,21 @@
+<%# locals: (prompts:) %>
+<%# Quick prompts that seed the chat sidebar with a bills question. Each chip
+ POSTs chats#create into the sidebar frame (the same mechanism the chat's
+ own sample questions use) and makes sure the sidebar is visible. Hidden
+ entirely without AI consent + a configured provider. %>
+<% if bills_one_shot_ai_available? %>
+
+<% end %>
diff --git a/app/views/bills/_calendar_chip.html.erb b/app/views/bills/_calendar_chip.html.erb
new file mode 100644
index 000000000..0414660cd
--- /dev/null
+++ b/app/views/bills/_calendar_chip.html.erb
@@ -0,0 +1,28 @@
+<%# locals: (occurrence:) %>
+<% series = occurrence.recurring_transaction %>
+<%
+ # Monarch-style state vocabulary: paid as expected (green), paid at a
+ # different amount than expected (amber), skipped (muted), overdue
+ # (destructive), otherwise upcoming (neutral).
+ chip_classes =
+ if occurrence.paid?
+ if (occurrence.confirmed_allocated - occurrence.resolved_expected_amount).abs > RecurringOccurrence::CLOSE_EPSILON
+ "bg-warning/10 text-warning"
+ else
+ "bg-success/10 text-success"
+ end
+ elsif occurrence.skipped? || occurrence.missed?
+ "bg-surface-inset text-subdued line-through"
+ elsif occurrence.derived_state == :overdue
+ "bg-destructive/10 text-destructive"
+ else
+ "bg-surface-inset text-primary"
+ end
+%>
+<%= link_to recurring_occurrence_path(occurrence),
+ data: { turbo_frame: :drawer },
+ class: "block rounded px-1.5 py-0.5 text-xs truncate hover:opacity-80 #{chip_classes}",
+ title: series.display_name do %>
+ <%= format_money(occurrence.resolved_expected_amount_money) %>
+ <%= series.display_name %>
+<% end %>
diff --git a/app/views/bills/_cancelled_notice.html.erb b/app/views/bills/_cancelled_notice.html.erb
new file mode 100644
index 000000000..5209b72da
--- /dev/null
+++ b/app/views/bills/_cancelled_notice.html.erb
@@ -0,0 +1,18 @@
+<%# A subscription can carry a cancellation date while its schedule keeps
+ running, so the app ends up calling the same bill "Cancelled" on one
+ surface and "Overdue" on another. Say so plainly, and put the action
+ that actually stops it next to the sentence. %>
+<% if series.cancelled_on.present? && series.active? %>
+
+<% end %>
diff --git a/app/views/bills/_detail.html.erb b/app/views/bills/_detail.html.erb
new file mode 100644
index 000000000..f5957f0a2
--- /dev/null
+++ b/app/views/bills/_detail.html.erb
@@ -0,0 +1,222 @@
+<%# locals: (series:, current_occurrence:, history:, upcoming:, analytics:,
+ payment_history:, yearly_metrics:, last_account:,
+ recent_allocations:, dense: false) %>
+
+<%# The single definition of what a bill's detail *is*.
+
+ The inline expansion and the drawer were two templates over one controller
+ action, so they drifted: open a bill from the Overview and you got the
+ matching rules, the twelve-month chart and its recent payments; open the
+ same bill from All bills and you got upcoming dates, notes and the
+ averages instead. Same bill, different facts, decided by which list you
+ happened to click from.
+
+ They stay two presentations, because an inline row and a drawer want
+ different widths, but only one of them decides what a bill consists of.
+ `dense` picks the layout; it never picks the content. %>
+
+<% columns = dense ? "" : "md:grid-cols-2 md:divide-x md:divide-y-0" %>
+
+
+ <%= render "bills/cancelled_notice", series: series %>
+
+ <%# What is owed right now leads the page above this partial. With no open
+ cycle there is nothing for that card to say, so the expectation is
+ stated here instead. %>
+ <% unless current_occurrence %>
+
+
<%= t("bills.detail.next_payment") %>
+
+ <%= "~" if series.has_amount_variance? %><%= format_money(Money.new(series.amount.abs, series.currency)) %>
+
+ <% end %>
+
+ <%# Subscription state: a trial about to convert, a renewal date, a
+ cancellation already recorded. These lived only on the Subscriptions
+ tab, which meant they were invisible from every other route to the
+ same bill. %>
+ <%= render "bills/state_chips", series: series %>
+
+ <%# Why this bill matches what it matches, in words rather than engine terms. %>
+
+
+ <%# What the app has learned from corrections, prepared by
+ BillsHelper#bills_matcher_hints. %>
+ <% hints = bills_matcher_hints(series) %>
+ <% if hints[:aliases].any? %>
+
+ <% end %>
+
+ <%# Per-year totals are reference material rather than something you read
+ every time, so they sit one click away instead of adding a table to
+ every bill you open. %>
+ <% if yearly_metrics.any? %>
+ <%= render DS::Disclosure.new(title: t("bills.detail.key_metrics"), align: "left") do %>
+
+
+
<%= t("bills.detail.year") %>
+
<%= t("bills.detail.spent_per_year") %>
+
<%= t("bills.detail.avg_payment") %>
+
+ <% yearly_metrics.each do |row| %>
+
+
<%= row[:year] %>
+
<%= format_money(row[:total]) %>
+
<%= format_money(row[:average]) %>
+
+ <% end %>
+
+ <% end %>
+ <% end %>
+
+ <% if last_account %>
+
+
<%= t("bills.detail.last_account") %>
+
<%= last_account.name %>
+
+ <% end %>
+
+
+
+ <%# What it used to cost. The twelve-month chart above shows the shape; this
+ names the moment it changed and by how much. %>
+ <% changes = series.recent_price_changes %>
+ <% if changes.any? %>
+
diff --git a/app/views/bills/_month_pulse.html.erb b/app/views/bills/_month_pulse.html.erb
new file mode 100644
index 000000000..951b0c30d
--- /dev/null
+++ b/app/views/bills/_month_pulse.html.erb
@@ -0,0 +1,112 @@
+<%# The month as one surface rather than a row of statistics.
+
+ It used to be a big number, two small ones, and a ring reading 0% -- a
+ generic KPI card that answered "how far along am I" (which nobody asked)
+ more loudly than "what do I owe" (which everybody does).
+
+ It now reads as one sentence: this month, this much left, this much late,
+ this much due soon, and here is what happens next. %>
+
+<%# Slice math lives with BillsHelper#bills_month_progress: the bar reads
+ paid | overdue | still to come, overdue being a subset of what remains. %>
+<% progress = bills_month_progress(
+ paid: @paid_this_month_total&.amount,
+ remaining: @remaining_this_month&.amount,
+ overdue: @past_due_total&.amount
+ ) %>
+
+
+
+
<%= l(Date.current, format: "%B") %>
+
<%= t(".bill_count", count: @month_bill_count) %>
+
+
+ <%# The one figure the page exists to state. %>
+
+ <% end %>
+
+ <%# What happens next, which is what makes this header operational rather
+ than statistical. Items open the bill's page: the row expansion belongs
+ to a row, and expanding one further down the page while you are reading
+ the top of it would be a jump with no explanation. %>
+ <% if @next_up.any? %>
+
">
+ <% case notice.kind %>
+ <% when :trial %>
+ <%= t("bills.index.notice_trial", name: notice.series.display_name, date: l(notice.date, format: :long)) %>
+ <% when :renewal %>
+ <%= t("bills.index.notice_renewal", name: notice.series.display_name, date: l(notice.date, format: :long)) %>
+ <% when :price %>
+
+ <%# The percentage is the part that tells you whether to care. A dollar
+ on a ten-dollar subscription reads very differently from a dollar
+ on the rent, and from to alone never said which this was. %>
+ <%= t("bills.index.notice_price",
+ name: notice.series.display_name,
+ from: format_money(notice.detail.previous_amount_money),
+ to: format_money(notice.detail.new_amount_money),
+ percent: "#{"+" if notice.price_percent.positive?}#{number_to_percentage(notice.price_percent, precision: 0, format: "%n%")}") %>
+
+ <% end %>
+
diff --git a/app/views/bills/_occurrence.html.erb b/app/views/bills/_occurrence.html.erb
new file mode 100644
index 000000000..5b2524d73
--- /dev/null
+++ b/app/views/bills/_occurrence.html.erb
@@ -0,0 +1,138 @@
+<%# locals: (occurrence:, date_label: false, suggestion: nil, disambiguate: false) %>
+<% series = occurrence.recurring_transaction %>
+<% expected = occurrence.resolved_expected_amount_money %>
+<% paid = occurrence.confirmed_allocated_money %>
+<% pane_frame = dom_id(occurrence, :pane) %>
+
+<%# SCAN. What is this, and does it need me?
+ Everything past that question lives one tap deeper, in the expansion.
+
+ An autopaying bill is still worth seeing and still counts toward the total,
+ but it recedes so the rows that want something from you carry the weight. %>
+
+<%# The row reads as interactive without dressing up as a button: a hover tint
+ and the name underlining, the same cues the transaction list uses.
+
+ The tint hangs off this element rather than a group on the wrapper. The
+ wrapper also holds the expansion, so a group hover kept the row lit while
+ the pointer was down in the expanded pane, and the lit band clipped against
+ the pane's own fill. Hover still propagates from every child of the row, so
+ the trailing action tints it exactly as before. %>
+
">
+ <%# The bulk of the row is the inspect control. Only the trailing verb sits
+ outside it, so there are no anchors inside anchors. %>
+ <%# Names the cycle it was opened from. Without it the expansion falls back to
+ the series' current occurrence, so expanding a settled row described the
+ NEXT one and reported it unpaid. %>
+ <%= link_to bill_path(series, display: "pane", frame: pane_frame, occurrence: occurrence.id),
+ data: { turbo_frame: pane_frame, turbo_prefetch: false },
+ class: "flex items-center gap-3 lg:gap-4 min-w-0 flex-1 group" do %>
+ <%# A date rail, and only a date. It used to print "Overdue" beside a
+ subline already reading "14 days overdue", which spent the row's one
+ piece of temporal context saying the same word twice. The colour still
+ carries the state; the rail now carries the date the state is about.
+
+ Desktop-only: on a phone the subline says it in words instead. %>
+ <% if date_label %>
+
+ <%= series.display_name %>
+ <% if series.transfer? %>
+ <%= t("bills.debt_payment") %>
+ <% end %>
+
+ <%# In a dated section the subline says WHY this row needs attention,
+ which is the whole point of pulling those rows out of the run. %>
+
">
+ <% if reason %>
+ <%= reason %>
+ <%# Two rows reading the same name and the same amount need telling
+ apart, and the schedule is what actually differs between
+ subscription tiers. %>
+ <% if disambiguate %>
+ · <%= frequency_label(series) %>
+ <% end %>
+ <% else %>
+ <%= date_label ? frequency_label(series) : occurrence_due_label(occurrence) %><%= bills_paid_from_label(series) %><%= bills_autopay_label(series) %>
+ <% if disambiguate && !date_label %>
+ · <%= frequency_label(series) %>
+ <% end %>
+ <% end %>
+ <% if (progress = series.installment_progress) %>
+ · <%= t("bills.installment_progress", done_plus_one: progress.first + 1, total: progress.last) %>
+ <% end %>
+
+ <% if series.notes.present? %>
+
<%= series.notes %>
+ <% end %>
+
+
+ <%# One amount, and it is the one the next decision turns on. Payment state
+ used to be printed twice: a coloured subline on the left and a figure
+ on the right. %>
+
+ <% elsif occurrence.partially_paid? %>
+ <%# Only when the left-hand subline has not already said it. In a dated
+ section the attention reason reads "Partial ยท $1,612.50 remaining",
+ and printing the same arithmetic again on the right is what was
+ squeezing the bill's name out of the row. %>
+ <% if reason.present? %>
+
+
+<%# The row's expansion: empty until the row is clicked, then the bill's
+ current state slides in under the line item. %>
+<%= turbo_frame_tag pane_frame %>
+
diff --git a/app/views/bills/_occurrence_section.html.erb b/app/views/bills/_occurrence_section.html.erb
new file mode 100644
index 000000000..978e67259
--- /dev/null
+++ b/app/views/bills/_occurrence_section.html.erb
@@ -0,0 +1,46 @@
+<%# locals: (title:, occurrences:, tone: :default, date_labels: false, suggestions: {}, meta: nil, pay_periods: []) %>
+<% return if occurrences.empty? %>
+
+<%# Where two rows would read identically, and only there, the row earns a
+ second fact to tell them apart (BillsHelper#bills_ambiguous_row_keys).
+ Adding the schedule to every row instead would be nineteen copies of
+ something nobody was confused about. %>
+<% ambiguous = bills_ambiguous_row_keys(occurrences) %>
+<% markers = bills_pay_period_markers(occurrences, pay_periods) %>
+
+
+
+
">
+
<%= title %>
+ ·
+
<%= occurrences.size %>
+
+ <% if meta %>
+
<%= meta %>
+ <% end %>
+
+
+ <%# The divider is half its old weight: the rows are separated by a hairline
+ and by the hover state, not by a rule competing with the content. %>
+ <%# A container query, not a media query. The app shell has two sidebars, so
+ main is ~420px wide at a 1280px viewport -- "desktop" by any breakpoint
+ and phone-width in practice. The row has to size itself against the space
+ it actually has. %>
+
+ <% occurrences.each do |occurrence| %>
+ <%# The marker belongs to the first row of its period, so it lands
+ between groups without the section having to pre-bucket the rows. %>
+ <% if (marker = markers[occurrence.id]) %>
+ <%= render "bills/pay_period_marker",
+ period: marker[:period],
+ currency: Current.family.currency,
+ due_total: marker[:due_total] %>
+ <% end %>
+ <%= render "bills/occurrence",
+ occurrence: occurrence,
+ date_label: date_labels,
+ suggestion: suggestions[occurrence.id],
+ disambiguate: ambiguous.include?([ occurrence.recurring_transaction.display_name, occurrence.resolved_expected_amount ]) %>
+ <% end %>
+
+
diff --git a/app/views/bills/_pay_period_marker.html.erb b/app/views/bills/_pay_period_marker.html.erb
new file mode 100755
index 000000000..b0e5e28e8
--- /dev/null
+++ b/app/views/bills/_pay_period_marker.html.erb
@@ -0,0 +1,47 @@
+<%# locals: (period:, due_total:, currency:) %>
+<%# A payday, drawn inside the month rather than beside it.
+
+ The month is the right container for planning, but it is the wrong unit for
+ anyone whose money does not arrive monthly: paid weekly, "this month" is
+ four paychecks and four rent payments in one undifferentiated list, and the
+ question that actually matters, whether the next few days are covered, is
+ the one the list cannot answer.
+
+ So this stays a marker, not a section header. Same card, same rhythm, one
+ tinted band naming the money that arrives and the date it has to stretch to.
+
+ Both halves name a date on purpose. "Paid Aug 26" read as past tense for a
+ date four days out, and "due before next" never said before what, so the
+ band described a pay period without ever saying where it ended. %>
+<% next_payday = period.ends_on + 1 %>
+
+
+ <%# A leading window with nothing arriving is not a payday. Labelling it
+ "Paid $0.00" would read as a missed cheque rather than the stretch before
+ the next one, so it says what it is, the same wording the income plan uses. %>
+ <% if period.bridge? %>
+ <%= icon "wallet", size: "sm" %>
+
+
diff --git a/app/views/bills/_paycheck_bridge.html.erb b/app/views/bills/_paycheck_bridge.html.erb
new file mode 100755
index 000000000..4c30a5d69
--- /dev/null
+++ b/app/views/bills/_paycheck_bridge.html.erb
@@ -0,0 +1,40 @@
+<%# locals: (period:, currency:) %>
+<%# The gap before the next paycheck, when the cash does cover it.
+
+ Its twin next door reports the same window when the cash falls short. This
+ one exists because the covered case used to be dropped from the page
+ outright: the bridge is filtered out of the timeline, and only a shortfall
+ earned a banner, so a bill due in five days appeared nowhere on the plan
+ that is supposed to answer what is due before payday.
+
+ Deliberately quiet. Nothing is wrong here, and the window still has to be
+ visible. %>
+
diff --git a/app/views/bills/_paycheck_income.html.erb b/app/views/bills/_paycheck_income.html.erb
new file mode 100644
index 000000000..a9558e3c6
--- /dev/null
+++ b/app/views/bills/_paycheck_income.html.erb
@@ -0,0 +1,57 @@
+<%# The pay schedule as one line, with the full income list and its edit
+ controls behind Manage. Dates come from occurrences, never from the stored
+ next_expected_date column, so this and the plan always agree. %>
+
+<%= render DS::Disclosure.new(variant: :bare) do |disclosure| %>
+ <% disclosure.with_summary_content do %>
+
+<% end %>
diff --git a/app/views/bills/_paycheck_item.html.erb b/app/views/bills/_paycheck_item.html.erb
new file mode 100644
index 000000000..16475e3be
--- /dev/null
+++ b/app/views/bills/_paycheck_item.html.erb
@@ -0,0 +1,25 @@
+<%# One bill as a ledger line: due date in a fixed column, name, amount. A
+ reserved line also names the obligation it is a slice of, since the amount
+ on the right is a share rather than the whole bill. %>
+
+<% series = item.occurrence.recurring_transaction %>
+
+
diff --git a/app/views/bills/_paycheck_period.html.erb b/app/views/bills/_paycheck_period.html.erb
new file mode 100644
index 000000000..40976f6de
--- /dev/null
+++ b/app/views/bills/_paycheck_period.html.erb
@@ -0,0 +1,109 @@
+<%# One period in the paycheck timeline: when money arrives and what is left
+ of it, the due / reserved / safe split with its allocation bar, the bills
+ due in the window, and an expandable footer for what is held for later. %>
+
+<%
+ segments = paycheck_allocation_segments(period)
+ segment_class = {
+ due: "bg-inverse",
+ reserved: "bg-subdued",
+ safe: "bg-success",
+ covered: "bg-subdued",
+ short: "bg-destructive"
+ }
+ safe_tone = period.short? ? "text-destructive" : "text-primary"
+ safe_amount = format_money(Money.new(period.short? ? period.shortfall : period.remaining, currency))
+ safe_label = period.short? ? t("bills.paycheck.short_after_bills") : t("bills.paycheck.safe_after_bills")
+%>
+
+
+
" aria-hidden="true">
+
+ ">
+
+ <% unless last %>
+
+ <% end %>
+
+ <% end %>
+
+ <% if period.items_reserved.any? %>
+ <%= render DS::Disclosure.new(variant: :inline, body_class: "mt-1") do |disclosure| %>
+ <% disclosure.with_summary_content do %>
+
+ <%= t("bills.paycheck.reserved_footer", count: period.items_reserved.size) %>
+ <%= icon "chevron-right", size: "sm", class: "group-open:rotate-90 motion-safe:transition-transform motion-safe:duration-150" %>
+
+ <% end %>
+
+ <% period.items_reserved.each do |item| %>
+ <%= render "bills/paycheck_item", item: item, currency: currency, reserved: true %>
+ <% end %>
+ <% end %>
+ <% end %>
+
+
diff --git a/app/views/bills/_paycheck_shortfall.html.erb b/app/views/bills/_paycheck_shortfall.html.erb
new file mode 100644
index 000000000..bdf1a7557
--- /dev/null
+++ b/app/views/bills/_paycheck_shortfall.html.erb
@@ -0,0 +1,37 @@
+<%# The gap between today and the next payday, when what is owed in it exceeds
+ what is on hand.
+
+ This used to say "no income arriving before then", which was true of every
+ bridge window ever built and therefore explained nothing. The window earns
+ nothing by definition; the fact worth reporting is that the cash does not
+ reach, so the banner now shows both numbers it compared. %>
+
+<% largest = period.largest_obligation %>
+
+
diff --git a/app/views/bills/_row_action.html.erb b/app/views/bills/_row_action.html.erb
new file mode 100644
index 000000000..b434e00be
--- /dev/null
+++ b/app/views/bills/_row_action.html.erb
@@ -0,0 +1,79 @@
+<%# locals: (occurrence:, suggestion: nil, labelled: false) %>
+<% series = occurrence.recurring_transaction %>
+
+<%# The row's one verb, chosen by what the bill actually needs.
+
+ Every row used to carry the same "Details" button plus a pay action plus a
+ category badge, which at 375px added up to more than the row was wide: the
+ bill's own name collapsed to nothing and the page scrolled sideways. A row
+ that needs nothing from you now shows nothing.
+
+ Below md the verb is an icon, which DS::Buttonish already gives a 44px
+ touch target via pointer-coarse. The labelled version lives in the row
+ expansion, which is one tap away and has room for words.
+
+ `labelled: true` is what the expansion passes to get the full-width,
+ spelled-out version of the same decision. %>
+
+<%
+ action =
+ if suggestion.present?
+ { key: "review_match", icon: "git-compare" }
+ elsif occurrence.partially_paid?
+ { key: "add_payment", icon: "plus" }
+ elsif !occurrence.scheduled?
+ # Settled, skipped or missed. Nothing to chase, but unlinking a payment
+ # and reopening a cycle live behind this door and nowhere else.
+ { key: "manage_payments", icon: "receipt-text" }
+ elsif series.autopay?
+ nil
+ elsif occurrence.derived_state.in?(%i[overdue due])
+ # A due bill with a payment portal wants paying, not reconciling.
+ RecurringTransaction.valid_payment_url?(series.payment_url) ? nil : { key: "find_payment", icon: "search" }
+ end
+
+ # A row that needs nothing stays silent; the expansion never does, or
+ # unlinking, reopening and manual payment would be stranded.
+ action ||= { key: "find_payment", icon: "search" } if labelled && occurrence.scheduled?
+%>
+
+<% if action.nil? %>
+ <%# Nothing to resolve here, so the only thing worth offering is the portal
+ when there is one. show_add stays false: a dashed "Add link" on every
+ row is exactly the chrome this pass exists to remove. %>
+ <%= render "recurring_transactions/pay_action",
+ recurring_transaction: series, show_add: false, show_state: false %>
+<% elsif labelled %>
+ <%= render DS::Link.new(
+ text: t("bills.#{action[:key]}"),
+ icon: action[:icon],
+ variant: "primary",
+ href: recurring_occurrence_path(occurrence),
+ frame: :drawer
+ ) %>
+<% else %>
+ <%# Container-relative, not viewport-relative: the row's own list is the
+ @container, because the app shell's sidebars mean a 1280px viewport can
+ still leave this row about 420px to work with. A labelled verb needs
+ roughly 500px of row before it stops eating the bill's name. %>
+
+ <%= render DS::Link.new(
+ text: t("bills.#{action[:key]}"),
+ icon: action[:icon],
+ variant: "outline",
+ size: "sm",
+ href: recurring_occurrence_path(occurrence),
+ frame: :drawer
+ ) %>
+
+
+ <%= render DS::Link.new(
+ icon: action[:icon],
+ variant: "icon",
+ href: recurring_occurrence_path(occurrence),
+ frame: :drawer,
+ title: t("bills.#{action[:key]}"),
+ aria: { label: t("bills.#{action[:key]}") }
+ ) %>
+
+<% end %>
diff --git a/app/views/bills/_state_chips.html.erb b/app/views/bills/_state_chips.html.erb
new file mode 100644
index 000000000..836f31b4f
--- /dev/null
+++ b/app/views/bills/_state_chips.html.erb
@@ -0,0 +1,19 @@
+<%# locals: (series:) %>
+<%# Trial, renewal and cancellation state, shared by the bill drawer and the
+ summary tab so the two never drift apart. Renders nothing when the series
+ carries none of the three. %>
+<% trial = series.trial_ends_on.present? && series.trial_ends_on >= Date.current %>
+<% renews = series.renews_on.present? && series.renews_on >= Date.current %>
+<% if trial || renews || series.cancelled_on.present? %>
+
+<% end %>
diff --git a/app/views/bills/_summary.html.erb b/app/views/bills/_summary.html.erb
new file mode 100644
index 000000000..6bb09a9ff
--- /dev/null
+++ b/app/views/bills/_summary.html.erb
@@ -0,0 +1,137 @@
+<%# locals: (series:, current_occurrence:, analytics:, recent_allocations:, suggestion: nil) %>
+
+<%# INSPECT. What is going on with this bill?
+
+ Not "everything about this bill" -- that is the bill's own page, and this
+ partial exists because the expansion and the drawer had become two
+ renderings of one enormous detail view. The twelve-month chart, the
+ per-year table, the price history, the upcoming dates and the settled
+ cycles all moved to the page. What is left is the answer to one question,
+ plus the way to act on it. %>
+
+
+ <% end %>
+
+ <%# Schedule, on one line rather than as a stack of labelled fields. %>
+
+ <%= frequency_label(series) %>
+ <% if series.account %>· <%= series.account.name %><% end %>
+ <% if series.autopay? %>· <%= t("recurring_transactions.pay_action.autopay") %><% end %>
+
+
+ <%# Subscription state is state, not depth, so it stays here. %>
+ <%= render "bills/state_chips", series: series %>
+
+ <%# What has actually paid this lately. %>
+ <% if recent_allocations.any? %>
+
+
<%= t("bills.detail.recent_payments") %>
+
+ <% recent_allocations.first(3).each do |allocation| %>
+
+ <% end %>
+
+ <%# The next step, spelled out. The row shows this as an icon on a phone;
+ here there is room for the words. It is present on a settled occurrence
+ too, because the drawer is the only route in the app to unlinking a
+ payment or reopening a cycle. %>
+
+ <% if current_occurrence %>
+ <%= render "bills/row_action", occurrence: current_occurrence, suggestion: suggestion, labelled: true %>
+ <% end %>
+
+ <%# Pay and Add link keep a home here, so the portal never leaves Bills
+ just because the row got quieter. %>
+ <%= render "recurring_transactions/pay_action", recurring_transaction: series %>
+
+
diff --git a/app/views/bills/_view_switcher.html.erb b/app/views/bills/_view_switcher.html.erb
new file mode 100644
index 000000000..12444d804
--- /dev/null
+++ b/app/views/bills/_view_switcher.html.erb
@@ -0,0 +1,50 @@
+<%# locals: (active:) %>
+<%# Page title and primary action in the header, view switcher beneath, matching
+ transactions/index. Bills was the only top-level destination in the app with
+ no heading at all, which also left screen readers with no outline to navigate. %>
+<%# Both halves are addable from every view, but only the one this view is
+ about earns a header button: Add income on the Income plan, Add bill
+ everywhere else. The other half and the AI review live in the overflow
+ menu, so the header carries one action instead of a toolbar of three. %>
+<% income_first = active == "paycheck" %>
+
+
<%= t("bills.index.title") %>
+
+
+ <%= render DS::Link.new(
+ text: income_first ? t("bills.index.add_income") : t("bills.index.add_bill"),
+ icon: "plus",
+ variant: "primary",
+ href: income_first ? new_recurring_transaction_path(income: true) : new_recurring_transaction_path,
+ frame: :modal
+ ) %>
+ <%= render DS::Menu.new do |menu| %>
+ <% menu.with_item(
+ variant: "link",
+ text: income_first ? t("bills.index.add_bill") : t("bills.index.add_income"),
+ icon: "plus",
+ href: income_first ? new_recurring_transaction_path : new_recurring_transaction_path(income: true),
+ data: { turbo_frame: :modal }) %>
+ <%# Seeds a chat with the server-owned review prompt into the sidebar
+ frame; the audit tool grounds the findings. Needs AI consent plus a
+ configured provider, or the item leads to a dead chat. %>
+ <% if bills_one_shot_ai_available? %>
+ <% menu.with_item(
+ variant: "button",
+ text: t("bills.index.review_with_ai"),
+ icon: "sparkles",
+ href: ai_review_bills_path,
+ method: :post,
+ frame: chat_frame,
+ data: { action: "app-layout#openRightSidebar" }) %>
+ <% end %>
+ <% end %>
+
+
+
+<%= render DS::SegmentedControl.new(aria_label: t("bills.views.aria_label")) do |control| %>
+ <% control.with_segment(t("bills.views.overview"), active: active == "overview", href: bills_path) %>
+ <% control.with_segment(t("bills.views.calendar"), active: active == "calendar", href: bills_path(view: "calendar")) %>
+ <% control.with_segment(t("bills.views.paycheck"), active: active == "paycheck", href: bills_path(view: "paycheck")) %>
+ <% control.with_segment(t("bills.views.all"), active: active == "all", href: bills_path(view: "all")) %>
+<% end %>
diff --git a/app/views/bills/all.html.erb b/app/views/bills/all.html.erb
new file mode 100644
index 000000000..83979339b
--- /dev/null
+++ b/app/views/bills/all.html.erb
@@ -0,0 +1,233 @@
+<%= content_for :page_title, t("bills.index.title") %>
+
+
+ <%= render "bills/view_switcher", active: "all" %>
+
+ <%= form_with url: bills_path, method: :get, scope: :q,
+ data: { controller: "auto-submit-form" },
+ class: "flex flex-wrap items-center gap-2" do |form| %>
+ <%= hidden_field_tag :view, "all" %>
+ <%= render DS::SearchInput.new(
+ name: "q[search]",
+ value: params.dig(:q, :search),
+ placeholder: t(".search_placeholder"),
+ class: "grow max-w-xs",
+ data: { "auto-submit-form-target": "auto" }) %>
+ <%= form.select :status,
+ options_for_select(
+ [ [ t(".any_status"), "" ] ] + BillsController::STATUS_FILTERS.map { |status| [ t(".status_filters.#{status}"), status ] },
+ params.dig(:q, :status)
+ ),
+ {},
+ { class: "form-field__input w-auto", "data-auto-submit-form-target": "auto" } %>
+ <%= form.select :bill_type,
+ options_for_select(
+ [ [ t(".any_type"), "" ] ] + RecurringTransaction.bill_types.keys.map { |type| [ t(".types.#{type}"), type ] },
+ params.dig(:q, :bill_type)
+ ),
+ {},
+ { class: "form-field__input w-auto", "data-auto-submit-form-target": "auto" } %>
+ <%= form.select :sort,
+ options_for_select(
+ [ [ t(".sort_due"), "" ], [ t(".sort_name"), "name" ], [ t(".sort_amount"), "amount" ] ],
+ params.dig(:q, :sort)
+ ),
+ {},
+ { class: "form-field__input w-auto", "data-auto-submit-form-target": "auto" } %>
+ <% end %>
+
+ <%# What the Subscriptions tab existed to answer. It was a whole destination
+ for a filter All bills already had, so the rollup now appears when that
+ filter is on rather than living behind its own tab. %>
+ <% if @subscription_rollup %>
+ <%# A text-xl currency figure does not fit an 85px cell, which is what three
+ columns leaves at 375px. %>
+
+ <% else %>
+ <%# A seven-column table on a phone is a table you scroll sideways to read.
+ The same rows, the same filters, laid out the way the Calendar already
+ does it: a purpose-built small-screen list rather than a squeezed
+ desktop one. Switched on the container, not the viewport: the app
+ shell's sidebars can squeeze a desktop window to phone-list widths,
+ and a viewport breakpoint cannot see that. %>
+
+
+ <%# The one rule on these pages that stays at full weight. Row
+ dividers dropped to `subdued` so the content carries the
+ structure, which leaves the header rule free to do the one job a
+ line is actually good at: separating the labels from the data. %>
+
+ <% amount = Money.new(series.amount.abs, series.currency) %>
+ <% monthly = series.monthly_equivalent_amount.abs %>
+ <%= format_money(amount) %>
+ <%# A monthly bill's monthly equivalent is its own amount, so the
+ second line said "$5.99" under "$5.99". It only carries
+ information for the other cadences. %>
+ <% if monthly != amount %>
+
+ <% end %>
+
+ <%# Across every bill, not just the one you have open. Per-bill history lives
+ in the bill's own detail. %>
+ <% if @recent_price_changes&.any? %>
+
+ <%= render "bills/view_switcher", active: "calendar" %>
+
+ <%# The nav group and a totals line carrying two money values do not share
+ 375px, and neither had anywhere to wrap. %>
+
+ <%# Nothing here was confirmed by anyone: detection built it from bank data.
+ Says so once, and stops as soon as the user works with any of it. %>
+ <% if @detected_awaiting_review.positive? %>
+
+ <% end %>
+
+ <%= render "bills/month_pulse" %>
+
+ <%= render "bills/ai_prompts", prompts: %w[due_before_paycheck subscriptions_up monthly_subscriptions] %>
+
+ <%# Changes worth knowing about, ranked by whether you can still do anything.
+ Everything stays on the page: the quieter half collapses rather than
+ being capped, so nothing becomes a dead end and nothing pushes the
+ worklist off screen. %>
+ <% if @notices.any? %>
+ <% urgent, routine = @notices.partition(&:urgent?) %>
+
+ <% urgent.each do |notice| %>
+ <%= render "bills/notice", notice: notice, urgent: true %>
+ <% end %>
+
+ <% if routine.any? %>
+
+ <% if urgent.any? %>
+ <%= render DS::Disclosure.new(title: t(".notices_routine", count: routine.size), align: "left") do %>
+
+ <% routine.each do |notice| %>
+ <%= render "bills/notice", notice: notice, urgent: false %>
+ <% end %>
+
+ <% end %>
+ <% else %>
+
+ <% routine.each do |notice| %>
+ <%= render "bills/notice", notice: notice, urgent: false %>
+ <% end %>
+
+ <% end %>
+
+ <% end %>
+
+ <% end %>
+
+ <% if @suggested_allocations.any? %>
+
+
+
<%= t(".needs_review") %>
+ ·
+
<%= @suggested_allocations.size %>
+
+
+
+ <% @suggested_allocations.each do |suggestion| %>
+ <% series = suggestion.recurring_occurrence.recurring_transaction %>
+
+ <%# The matcher stores WHY it proposed each of these. This line
+ used to show a bare percentage of that reasoning instead of
+ the reasoning, which told nobody anything they could judge. %>
+ <% reasons = bills_match_reasons(
+ suggestion.match_signals,
+ currency: suggestion.recurring_occurrence.currency,
+ expected: suggestion.recurring_occurrence.resolved_expected_amount,
+ actual: suggestion.entry&.amount&.abs,
+ due_on: suggestion.recurring_occurrence.effective_due_on,
+ paid_on: suggestion.paid_on
+ ) %>
+
+ <% if @has_transaction_history %>
+ <%= render DS::Link.new(
+ text: t(".empty.action"),
+ icon: "search",
+ variant: "primary",
+ href: detect_bills_path,
+ method: :post
+ ) %>
+ <%= render DS::Link.new(
+ text: t(".add_bill"),
+ icon: "plus",
+ variant: "outline",
+ href: new_recurring_transaction_path,
+ frame: :modal
+ ) %>
+ <% else %>
+ <%# Detection over zero transactions finds nothing; offering it
+ would be a button that silently does nothing. %>
+ <%= render DS::Link.new(
+ text: t(".add_bill"),
+ icon: "plus",
+ variant: "primary",
+ href: new_recurring_transaction_path,
+ frame: :modal
+ ) %>
+ <% end %>
+
+ <% end %>
+ <% end %>
+
+ <% end %>
+ <% else %>
+ <%# Anything late is pulled out of the chronological run. Inside it, an
+ overdue bill was distinguishable only by a word in the date column, so
+ the most urgent rows were the easiest ones to scroll past. %>
+ <% if @overdue.any? %>
+
+ <%# The section says how much is at stake, not just how many rows: a
+ count alone is alarming without being informative. %>
+ <%= render "bills/occurrence_section",
+ title: t(".needs_attention"),
+ occurrences: @overdue,
+ date_labels: true,
+ suggestions: @suggestions_by_occurrence,
+ meta: (@past_due_total && @past_due_total.amount.positive? ? t(".attention_overdue_total", amount: format_money(@past_due_total)) : nil) %>
+
+
+ <%# What is going on with this bill, and what to do about it. Everything
+ deeper -- the chart, the per-year totals, the price history, the
+ upcoming dates, the settled cycles -- lives on the bill's page. %>
+ <%= render "bills/summary",
+ series: @series,
+ current_occurrence: @current_occurrence,
+ analytics: @analytics,
+ recent_allocations: @recent_allocations,
+ suggestion: @pane_suggestion %>
+
+
+<% end %>
diff --git a/app/views/bills/pane_close.html.erb b/app/views/bills/pane_close.html.erb
new file mode 100644
index 000000000..506ebff01
--- /dev/null
+++ b/app/views/bills/pane_close.html.erb
@@ -0,0 +1 @@
+<%= turbo_frame_tag @pane_frame_id %>
diff --git a/app/views/bills/paycheck.html.erb b/app/views/bills/paycheck.html.erb
new file mode 100644
index 000000000..bf7b203c1
--- /dev/null
+++ b/app/views/bills/paycheck.html.erb
@@ -0,0 +1,74 @@
+<%# The Income plan page: the income schedule, a warning if there is a gap
+ before the next payday, then one surface holding every upcoming pay
+ period in turn. %>
+
+<%= content_for :page_title, t("bills.index.title") %>
+
+<%# The bridge/period split and why the bridge never becomes a timeline row
+ live with BillsHelper#paycheck_plan_sections; this template only renders
+ the prepared sections. %>
+<%
+ currency = Current.family.currency
+ sections = paycheck_plan_sections(@plan)
+ periods = sections[:periods] || []
+ shortfall = sections[:shortfall]
+ bridge_note = sections[:bridge_note]
+%>
+
+
diff --git a/app/views/bills/show.html.erb b/app/views/bills/show.html.erb
new file mode 100644
index 000000000..ef2885f8e
--- /dev/null
+++ b/app/views/bills/show.html.erb
@@ -0,0 +1,107 @@
+<%= content_for :page_title, @series.display_name %>
+
+<%# DEEP DIVE. Show me everything, and let me configure it.
+
+ This used to be a drawer dialog floating over an empty settings layout,
+ which meant the app had three renderings of a bill's detail and no page.
+ It is a page now, so the chart, the history and the configuration have
+ somewhere to live that is not the row expansion. %>
+
+
+
+
+ <% if @series.category %>
+ <%= render "categories/badge", category: @series.category %>
+ <% end %>
+
+
+
+ <%# The current obligation, and the way to resolve it. Unconditional: a
+ settled cycle still needs a route to unlinking a payment or reopening it,
+ and the drawer is the only place in the app that offers either. %>
+ <% if @current_occurrence %>
+
diff --git a/app/views/bills/smart_configurations/_proposal.html.erb b/app/views/bills/smart_configurations/_proposal.html.erb
new file mode 100644
index 000000000..974c28ebd
--- /dev/null
+++ b/app/views/bills/smart_configurations/_proposal.html.erb
@@ -0,0 +1,12 @@
+<%# locals: (name:, value:, label:, current:, proposed:) %>
+<%# The checkbox IS the form field: its value is the proposed change, so an
+ unchecked row submits nothing at all. %>
+
diff --git a/app/views/bills/smart_configurations/show.html.erb b/app/views/bills/smart_configurations/show.html.erb
new file mode 100644
index 000000000..a3761f35b
--- /dev/null
+++ b/app/views/bills/smart_configurations/show.html.erb
@@ -0,0 +1,93 @@
+<%= render DS::Dialog.new do |dialog| %>
+ <% dialog.with_header(title: t(".title", name: @series.display_name)) %>
+ <% dialog.with_body do %>
+ <% if @error %>
+
<%= @error %>
+ <% elsif !@suggestion.any_proposal? %>
+
+
<%= t(".no_changes") %>
+ <% if @suggestion.rationale.present? %>
+
<%= @suggestion.rationale %>
+ <% end %>
+
+ <% else %>
+ <%# Each proposal is a checkbox CARRYING the proposed value as the real
+ form param: unchecked rows submit nothing, so only accepted changes
+ reach recurring_transactions#update. The schedule's companion day
+ fields ride as hidden inputs -- harmless when the preset checkbox is
+ unchecked, because FrequencyPreset.apply no-ops without a preset. %>
+ <%= form_with url: recurring_transaction_path(@series), method: :patch,
+ data: { turbo_frame: :_top } do %>
+ <%# Keeps an all-unchecked submit a harmless no-op: params.require is
+ satisfied, and this unpermitted key is dropped on the other side. %>
+ <%= hidden_field_tag "recurring_transaction[_ai_review]", "1" %>
+
+ <% end %>
+ <% end %>
+ <% end %>
+<% end %>
diff --git a/app/views/budget_categories/_budget_category.html.erb b/app/views/budget_categories/_budget_category.html.erb
index 8dae19f45..89178aa4a 100644
--- a/app/views/budget_categories/_budget_category.html.erb
+++ b/app/views/budget_categories/_budget_category.html.erb
@@ -77,6 +77,18 @@
days: daily_info[:days_remaining]) %>
<% end %>
+ <%# Bills reservations are metadata like the budgeted figure above, so
+ compact callers that opt out of the meta row stay compact. %>
+ <% if (reserved = budget_category.bills_reserved).amount.positive? %>
+
<% if budget_category.available_to_spend >= 0 %>
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index 06592517e..e9967775f 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -9,6 +9,7 @@ else
[
{ name: t(".nav.home"), path: root_path, icon: "pie-chart", icon_custom: false, active: page_active?(root_path) },
{ name: t(".nav.transactions"), path: transactions_path, icon: "credit-card", icon_custom: false, active: page_active?(transactions_path) },
+ bills_nav_item,
{ name: t(".nav.reports"), path: reports_path, icon: "chart-bar", icon_custom: false, active: page_active?(reports_path) },
plan_nav_item,
{ name: t(".nav.assistant"), path: chats_path, icon: "icon-assistant", icon_custom: true, active: page_active?(chats_path), mobile_only: true }
diff --git a/app/views/recurring_occurrences/_candidate.html.erb b/app/views/recurring_occurrences/_candidate.html.erb
new file mode 100644
index 000000000..5fcec48e8
--- /dev/null
+++ b/app/views/recurring_occurrences/_candidate.html.erb
@@ -0,0 +1,63 @@
+<%# locals: (occurrence:, entry:, reasons: [], promoted: false) %>
+
+<%# One transaction offered as the payment for this bill.
+
+ The list used to put an identical bordered "Use this" button on all fifteen
+ rows, which made a reconciliation screen read as a database dump. Now the
+ strongest candidate gets a card with the single real button on it, and
+ every other row IS the button -- the whole row posts, so there is one tap
+ target per transaction instead of a small one sitting beside the text.
+
+ DS::Button cannot be used for the row form: its template renders its own
+ icon-and-text body and never renders a caller's block, so the row markup
+ would silently vanish. Hence a raw button_to, with form_class -- button_to
+ puts `class` on the
diff --git a/app/views/recurring_transactions/_suggested_series.html.erb b/app/views/recurring_transactions/_suggested_series.html.erb
new file mode 100644
index 000000000..a8c8fb880
--- /dev/null
+++ b/app/views/recurring_transactions/_suggested_series.html.erb
@@ -0,0 +1,61 @@
+<%# locals: (suggested:) %>
+<%# Detection proposes, the user disposes. Rendered from both the Bills
+ overview and Settings -> Recurring, so every key is absolutely scoped.
+
+ Collapsible because this sits above the bills the user actually came to
+ see. Someone who is not ready to triage nine guesses should be able to fold
+ them away and still find their rent, and have that stick. %>
+<%= render DS::Disclosure.new(
+ variant: :card_inset,
+ open: true,
+ data: {
+ controller: "persisted-disclosure",
+ persisted_disclosure_key_value: "bills-suggested"
+ }
+) do |disclosure| %>
+ <% disclosure.with_summary_content do %>
+
+ <%# The Bills workspace is preview-gated, so the card that leads into it
+ only renders for opted-in users. %>
+ <% if preview_features_enabled? %>
+
+ <%= render DS::Button.new(
+ text: t("transactions.show.mark_recurring"),
+ variant: "outline",
+ icon: "repeat",
+ href: @mark_recurring_href,
+ method: :post,
+ frame: "_top",
+ disabled: @mark_recurring_disabled,
+ title: @mark_recurring_title,
+ class: @mark_recurring_button_class
+ ) %>
+
+ <%# The declare dialog is part of the preview-gated bills surface, so the
+ action only appears for someone who has opted into it. %>
+ <% if preview_features_enabled? %>
+ <%= render DS::Button.new(
+ text: t("transactions.show.create_bill"),
+ variant: "outline",
+ icon: "receipt",
+ href: new_recurring_transaction_path(entry_id: entry.id),
+ frame: :modal
+ ) %>
+ <% end %>
+
diff --git a/app/views/transactions/show.html.erb b/app/views/transactions/show.html.erb
index fe6459ed2..9ccdcd51a 100644
--- a/app/views/transactions/show.html.erb
+++ b/app/views/transactions/show.html.erb
@@ -401,6 +401,35 @@
) %>
<% end %>
+ <%# Which bills this transaction paid. Bills has always linked out to
+ transactions; without this the trip back was a dead end. Loading
+ and preview-gating live with BillsHelper#entry_bill_allocations. %>
+ <% applied_to = entry_bill_allocations(@entry) %>
+ <% if applied_to.any? %>
+
+ <% if allocation.allocation_suggested? %>
+ <%= t(".applied_to_unreviewed") %>
+ <% end %>
+
+ <% end %>
+
+ <% end %>
+
<%= render "transactions/mark_recurring", entry: @entry %>
<% end %>
diff --git a/config/locales/views/bills/en.yml b/config/locales/views/bills/en.yml
new file mode 100644
index 000000000..787fbf97a
--- /dev/null
+++ b/config/locales/views/bills/en.yml
@@ -0,0 +1,327 @@
+---
+en:
+ bills:
+ row_overdue: Overdue
+ row_today: Today
+ detect:
+ found:
+ one: Found 1 possible bill. Review it below.
+ other: Found %{count} possible bills. Review them below.
+ none_found: No new recurring patterns found. You can add bills by hand.
+ already_running: Detection is already running. Check back in a moment.
+ feed:
+ calendar_name: Sure Bills
+ ai_prompts:
+ due_before_paycheck: What's due before my next paycheck?
+ subscriptions_up: Which subscriptions went up this year?
+ monthly_subscriptions: What am I paying monthly for subscriptions?
+ safe_to_spend: How much is safe to spend this period?
+ smart_configurations:
+ show:
+ trigger: Let AI configure
+ title: "AI suggestions for %{name}"
+ intro: Check the changes to apply. Unchecked ones are left exactly as they are.
+ no_changes: This bill already matches its payment history. Nothing to change.
+ failed: Could not analyze this bill right now. Its configuration is untouched.
+ field_name: Name
+ field_amount: Amount
+ field_schedule: Schedule
+ field_category: Category
+ field_kind: Kind
+ field_autopay: Autopay
+ uncategorized: Uncategorized
+ autopay_off: "Off"
+ autopay_on: "On"
+ apply: Apply selected
+ detail:
+ current: Current
+ next_payment: Next payment
+ around: "around %{date}"
+ rules: How this bill is matched
+ rule_named: "%{name}"
+ rule_amount_range: "from %{min} to %{max}"
+ rule_aliases: "Also matches %{names}"
+ rule_learned_tolerance: "Amounts within %{percent} of the usual amount match this bill"
+ history_title: Last 12 months
+ history_aria: Payments over the last twelve months
+ key_metrics: Per year
+ year: Year
+ spent_per_year: Spent
+ avg_payment: Avg payment
+ average: Average paid
+ range: "%{min} to %{max}"
+ annualized: A year
+ ytd: Paid this year
+ last_account: Last account used
+ upcoming: Coming up
+ recent_payments: Recent payments
+ manual_payment: Manual payment
+ history: History
+ payment_count:
+ one: "1 payment"
+ other: "%{count} payments"
+ notes: Notes
+ trial_chip: "Trial ends %{date}"
+ renews_chip: "Renews %{date}"
+ cancelled_chip: "Cancelled %{date}"
+ price_changes: Price changes
+ price_change_line: "%{from} โ %{to}"
+ month_pulse:
+ bill_count:
+ one: 1 bill
+ other: "%{count} bills"
+ left_to_pay: left to pay
+ pulse_paid: paid
+ pulse_overdue: overdue
+ pulse_next_seven: next 7 days
+ pulse_bar_aria: "%{percent}% of this month's bills paid"
+ next_up: Next up
+ view_upcoming: View upcoming
+ # The strip has a column to itself, so the date stands alone rather than
+ # reading as a sentence the way the row sublines do.
+ date_today: Today
+ date_tomorrow: Tomorrow
+ unconvertible:
+ one: "1 bill is not included, no exchange rate available"
+ other: "%{count} bills are not included, no exchange rate available"
+ summary:
+ remaining: "%{amount} remaining"
+ paid_headline: "%{amount} paid"
+ cost_line: "Averages %{average} a payment, about %{annualized} a year"
+ pane:
+ close: Collapse details
+ add_bill: Add bill
+ manage: Manage
+ find_payment: Find payment
+ add_payment: Add payment
+ review_match: Review match
+ resolve: Resolve
+ manage_payments: Manage payments
+ view_full_bill: View full bill
+ match:
+ same_merchant: Same merchant
+ name_matches: Name matches
+ exact_amount: Exact amount
+ amount_off: "%{amount} off"
+ due_date: Due-date match
+ days_before:
+ one: 1 day before due
+ other: "%{count} days before due"
+ days_after:
+ one: 1 day after due
+ other: "%{count} days after due"
+ attention:
+ needs_review: Match needs review
+ partial: "Partial ยท %{amount} remaining"
+ amount_changed: Amount changed
+ overdue:
+ one: 1 day overdue
+ other: "%{count} days overdue"
+ paid_label: Paid
+ paid_over_short: Paid, over
+ installment_progress: "Payment %{done_plus_one} of %{total}"
+ partial_progress: "%{paid} of %{expected} paid"
+ cancelled_still_scheduled: "You marked this cancelled on %{date}, but it is still scheduled, so future bills will keep appearing."
+ paid_over: "Paid %{paid}, more than expected"
+ remaining_label: remaining
+ debt_payment: "ยท debt payment"
+ paid_from: "from %{account}"
+ amount_range: "ranges %{min} to %{max}"
+ possible_duplicate: Possible duplicate
+ due_label:
+ overdue:
+ one: "Overdue by 1 day, was due %{date}"
+ other: "Overdue by %{count} days, was due %{date}"
+ today: "Due today"
+ settled: "Was due %{date}"
+ due_since: "Due %{date}"
+ snoozed: "Snoozed until %{date}"
+ upcoming:
+ one: "Due tomorrow, %{date}"
+ other: "Due in %{count} days, %{date}"
+ views:
+ aria_label: Bills views
+ overview: Overview
+ calendar: Calendar
+ # Named for income rather than for a paycheck: a declared income series
+ # can be a pension, an invoice or a benefit, and this is the only place
+ # in the app where income is added or managed. "Plan" keeps it from
+ # promising the earnings reporting that Reports already owns.
+ paycheck: Income plan
+ all: All bills
+ pay_period:
+ paycheck_on: "Paycheck %{date}"
+ before_next_paycheck: Before your next paycheck
+ due_before_date: "%{amount} due before %{date}"
+ paycheck:
+ unconvertible:
+ one: "1 obligation is not included, no exchange rate available"
+ other: "%{count} obligations are not included, no exchange rate available"
+ income_section_title: Income schedule
+ manage: Manage
+ add_income: Add income
+ income_paused: Paused, not planned
+ income_detected: Detected automatically, does not set paydays
+ income_next_payday: "next %{date}"
+ income_source_count:
+ one: "%{count} income source"
+ other: "%{count} income sources"
+ next_on: "Next %{date}"
+ no_upcoming_income: No income scheduled from here on.
+ empty:
+ title: Declare your income schedule
+ description: Add your income to see what each payday has to cover. Detected inflows never set your paydays on their own -- you do.
+ action: Add income
+ all_clear:
+ title: Nothing to cover
+ description: "No bills come due between now and %{date}, so nothing in that stretch is spoken for."
+ # The three words the page is built on: due now, reserved for later,
+ # safe after bills. Anything that adds two of them together needs a very
+ # good reason, because the whole job here is keeping them apart.
+ safe_after_bills: Safe after bills
+ short_after_bills: Short after bills
+ period_source: " ยท %{source}"
+ period_source_multiple: " ยท %{count} income sources"
+ before_next_paycheck: "Before %{date}"
+ no_income_arriving: No income arrives
+ income_amount: "%{amount} income"
+ due_this_period: Due this period
+ reserved_ahead: Reserved ahead
+ # The bar's key uses the short forms so three columns fit a phone; the
+ # header carries the full phrase.
+ safe_short: Safe
+ short_short: Short
+ reserved_footer:
+ one: Reserved ahead for 1 later bill
+ other: "Reserved ahead for %{count} later bills"
+ bills_this_period:
+ one: Bills this period ยท 1
+ other: "Bills this period ยท %{count}"
+ bills_before_payday:
+ one: Bills before your next payday ยท 1
+ other: "Bills before your next payday ยท %{count}"
+ of_total: "of %{amount}"
+ # The gap before the next payday, said as news rather than drawn as a
+ # pay period. Compact on purpose: it has to be noticeable without
+ # becoming the identity of the page.
+ bridge_label: Before your next paycheck
+ bridge_amount: "ยท %{amount} due"
+ bridge_split: "Due before %{date}, covered by the %{cash} in your accounts."
+ shortfall_label: Short before your next payday
+ shortfall_amount: "ยท %{amount}"
+ shortfall_split: "%{obligations} is due before %{date} and your accounts hold %{cash}."
+ shortfall_largest: "Largest upcoming obligation:"
+ review_plan: Review plan
+ allocation_aria: "Of %{income} income, %{due} is due now, %{reserved} is reserved for later, and %{safe} is safe to spend."
+ allocation_aria_short: "%{income} of income goes to what is committed here, and %{short} more is needed."
+ reset_feed_token:
+ done: Feed link reset. Calendar apps using the old link will stop updating; resubscribe with the new one.
+ confirm: Reset the calendar feed link? Every previously shared link stops working.
+ calendar:
+ subscribe_ical: Subscribe (iCal)
+ reset_feed: Reset link
+ previous_month: Previous month
+ next_month: Next month
+ today: Today
+ month_totals: "%{expected} expected ยท %{paid} paid"
+ empty_month: No bills due this month.
+ all:
+ search_placeholder: Search bills
+ any_status: Any status
+ status_filters:
+ overdue: Overdue
+ due: Due soon
+ partial: Partly paid
+ paid: Paid
+ paused: Paused
+ ended: Dismissed
+ subscription_monthly: A month
+ subscription_annual: A year
+ subscription_active: Active
+ subscription_price_changes: Price changes this year
+ subscription_price_change_line: "%{from} โ %{to} (%{percent}%)"
+ any_type: Any type
+ sort_due: By next due
+ sort_name: By name
+ sort_amount: By amount
+ no_matches_title: Nothing matches
+ no_matches_description: Try clearing a filter or the search.
+ col_name: Name
+ col_type: Type
+ col_frequency: Frequency
+ col_amount: Amount
+ col_next: Next due
+ next_short: "next %{date}"
+ col_status: Status
+ col_actions: Actions
+ monthly_equivalent: "%{amount}/mo"
+ types:
+ bill: Bill
+ subscription: Subscription
+ installment: Installment plan
+ income: Income
+ transfer: Transfer
+ other: Other
+ show:
+ edit: Edit bill
+ index:
+ left_to_pay: left to pay
+ paid_so_far: Paid so far
+ hero_past_due: "%{amount} past due"
+ hero_next_seven: "%{amount} due in the next 7 days"
+ add_bill: Add bill
+ add_income: Add income
+ review_with_ai: Review with AI
+ detected_review:
+ one: We found 1 recurring payment in your transactions. Check it over so the totals here match what you actually pay.
+ other: We found %{count} recurring payments in your transactions. Check them over so the totals here match what you actually pay.
+ detected_review_action: Review them
+ needs_review: Needs review
+ notice_trial: "%{name}'s trial ends %{date}"
+ notice_renewal: "%{name} renews %{date}"
+ notice_price: "%{name} changed price: %{from} โ %{to} (%{percent})"
+ notices_routine:
+ one: "1 smaller change"
+ other: "%{count} smaller changes"
+ suggestion_line: "%{entry} looks like a payment of %{bill}"
+ suggestion_unknown_entry: A transaction
+ confidence: "%{percent}% match"
+ accept: Apply
+ reject: Not this bill
+ title: Bills
+ summary_label: Due this month
+ summary_count:
+ one: "1 bill due"
+ other: "%{count} bills due"
+ summary_needs_action:
+ one: "1 needs you"
+ other: "%{count} need you"
+ all_automatic: "all on autopay"
+ recurring_label: Recurring commitment
+ recurring_monthly: "%{amount} a month"
+ recurring_annual: "%{amount} a year"
+ unconvertible:
+ one: "1 bill is not included, no exchange rate available"
+ other: "%{count} bills are not included, no exchange rate available"
+ overdue: Overdue
+ needs_attention: Needs attention
+ attention_overdue_total: "%{amount} overdue"
+ this_month: This month
+ later: After this month
+ paid_this_month: Paid this month
+ dormant: Dormant
+ kpi_remaining: Remaining this month
+ kpi_paid: Paid this month
+ kpi_next_seven: Due next 7 days
+ kpi_past_due: Past due
+ paid_count:
+ one: "1 bill settled"
+ other: "%{count} bills settled"
+ needs_action_count:
+ one: "1 needs you"
+ other: "%{count} need you"
+ empty:
+ title: No bills yet
+ description: Bills are your recurring expenses. Sure finds them automatically from your transaction history, and you can add a payment link to each one so you can pay straight from here.
+ action: Find recurring transactions
+ no_history_description: No transactions yet. Connect an account or import history and Sure will find your bills automatically, or add one by hand.
diff --git a/config/locales/views/budgets/en.yml b/config/locales/views/budgets/en.yml
index aaeb331b4..3fddcce35 100644
--- a/config/locales/views/budgets/en.yml
+++ b/config/locales/views/budgets/en.yml
@@ -74,6 +74,10 @@ en:
no_source: "No previous budget found to copy from"
already_initialized: "This budget has already been set up"
budget_categories:
+ bills_reserved: "%{amount} reserved by bills"
+ bills_reserved_unconvertible:
+ one: "1 bill is not reserved, no exchange rate available"
+ other: "%{count} bills are not reserved, no exchange rate available"
allocation_progress:
budget_exceeded_html: 'Budget exceeded by %{amount}'
left_to_allocate: left to allocate
diff --git a/config/locales/views/layout/en.yml b/config/locales/views/layout/en.yml
index 4b3e23a3b..8a7708f42 100644
--- a/config/locales/views/layout/en.yml
+++ b/config/locales/views/layout/en.yml
@@ -9,6 +9,7 @@ en:
skip_to_main: Skip to main content
nav:
assistant: Assistant
+ bills: Bills
budgets: Budgets
home: Home
plan: Plan
diff --git a/config/locales/views/recurring_allocations/en.yml b/config/locales/views/recurring_allocations/en.yml
new file mode 100644
index 000000000..409454936
--- /dev/null
+++ b/config/locales/views/recurring_allocations/en.yml
@@ -0,0 +1,15 @@
+---
+en:
+ recurring_allocations:
+ over_allocation: That would allocate more than the transaction's amount
+ missing_rate: No exchange rate available; enter the amount explicitly
+ already_allocated: That transaction is already applied to this bill
+ invalid: The payment could not be recorded
+ create:
+ success: Payment applied
+ destroy:
+ success: Payment unlinked
+ confirm:
+ success: Payment applied to the bill
+ reject:
+ success: "Dismissed. That transaction won't be suggested for this bill again."
diff --git a/config/locales/views/recurring_occurrences/en.yml b/config/locales/views/recurring_occurrences/en.yml
new file mode 100644
index 000000000..f9370189a
--- /dev/null
+++ b/config/locales/views/recurring_occurrences/en.yml
@@ -0,0 +1,53 @@
+---
+en:
+ recurring_occurrences:
+ history_status:
+ paid: Paid
+ skipped: Skipped
+ missed: Missed
+ scheduled: Open
+ show:
+ due_on: "Due %{date}"
+ paid_of: "%{paid} of %{expected} paid"
+ paid_headline: "%{amount} paid"
+ remaining: "%{amount} remaining"
+ settled: Paid in full
+ overpaid: more than expected
+ skipped: Skipped
+ missed: Marked missed
+ payments: Payments
+ manual_payment: Manual payment
+ unlink: Unlink
+ review_heading: Review payment
+ find_heading: Find a payment
+ add_heading: Add another payment
+ other_matches: Other likely matches
+ no_ranked_candidates: Nothing here looks like a payment for this bill yet.
+ search_all: Search all transactions
+ search_placeholder: Search transactions
+ no_candidates: No nearby transactions to link.
+ no_search_results: "Nothing matching %{query}."
+ link_payment: Link payment
+ not_this_one: Not this one
+ cant_find: Can't find the transaction?
+ manual_explainer: Record a payment you know happened when no transaction here matches it.
+ manual_amount_label: Amount
+ manual_date_label: Date
+ record_payment: Record payment
+ mark_paid: Mark paid
+ mark_paid_hint: Settles the rest without recording a transaction.
+ skip: Skip
+ snooze_week: Snooze a week
+ reopen: Reopen
+ view_bill: View full bill
+ mark_paid:
+ success: Bill marked paid
+ skip:
+ success: Bill skipped
+ reopen:
+ success: Bill reopened
+ snooze:
+ success: "Snoozed until %{date}"
+ invalid_date: That date could not be read
+ override_amount:
+ success: Expected amount updated
diff --git a/config/locales/views/recurring_transactions/en.yml b/config/locales/views/recurring_transactions/en.yml
index 88f43ce28..7e454ba6b 100644
--- a/config/locales/views/recurring_transactions/en.yml
+++ b/config/locales/views/recurring_transactions/en.yml
@@ -1,6 +1,15 @@
---
en:
recurring_transactions:
+ title: Recurring Transactions
+ upcoming: Upcoming Recurring Transactions
+ projected: Projected
+ recurring: Recurring
+ expected_today: "Expected today"
+ expected_in:
+ one: "Expected in %{count} day"
+ other: "Expected in %{count} days"
+ day_of_month: Day %{day} of month
frequency_presets:
monthly: Monthly
weekly: Weekly
@@ -20,17 +29,115 @@ en:
annual: "Yearly on %{month} %{day}"
custom: Custom schedule
last_day: last day
- title: Recurring Transactions
- upcoming: Upcoming Recurring Transactions
- projected: Projected
- recurring: Recurring
- expected_today: "Expected today"
- expected_in:
- one: "Expected in %{count} day"
- other: "Expected in %{count} days"
- day_of_month: Day %{day} of month
identify_patterns: Identify Patterns
cleanup_stale: Clean Up Stale
+ actions:
+ edit_payment_link: Edit payment link
+ pause: Pause
+ resume: Resume
+ delete: Remove
+ pay_action:
+ pay: Pay
+ pay_aria: "Pay %{name} (opens in new tab)"
+ add_link: Add link
+ add_link_aria: "Add a payment link for %{name}"
+ autopay: Autopay
+ autopay_aria: "%{name} pays automatically, open its portal in a new tab"
+ open_site: Open bill site
+ new:
+ title: Add a bill
+ income_title: Add income
+ start_from_title: Start from a recurring charge we spotted
+ start_from_income_title: Start from a recurring deposit we spotted
+ start_from_hint: Optional. Picking one just fills in the form; you can still change anything.
+ candidate_meta: "%{count}ร ยท last %{date}"
+ search_all_prompt: Not seeing what you're looking for?
+ search_all_cta: Search all your transactions
+ pick_different: Start from a different transaction
+ smart_fill: Smart-fill with AI
+ smart_fill_applied: AI suggested these values from the charge history. Review before saving.
+ smart_fills:
+ create:
+ failed: Could not analyze the charge history right now. The form kept the transaction's own values.
+ pick_entry:
+ title: Find a transaction to start from
+ income_title: Find a deposit to start from
+ hint: Picking one just fills in the form; you can still change anything.
+ search_placeholder: Search by name, notes or merchant
+ back: Back to the form
+ showing_recent: Showing your %{count} most recent. Search to narrow it down.
+ no_results: Nothing matches โ%{query}โ. Try fewer words, or go back and fill in the form yourself.
+ picker_row:
+ claimed: Part of %{name}
+ create:
+ success: Bill added
+ success_income: Income added
+ due_date_required: "Enter the bill's next due date"
+ amount_invalid: "Enter the amount as a plain number"
+ account_invalid: "Pick an account you can add bills to, or leave it blank"
+ already_exists: "This bill already exists"
+ edit:
+ trigger_label: Edit bill
+ title: "Edit %{name}"
+ income_title: "Edit income ยท %{name}"
+ form:
+ payment_url_label: Payment link
+ payment_url_placeholder: bank.example.com/pay
+ payment_url_hint: Where you go to pay this bill. Opens in a new tab from your recurring and upcoming lists.
+ autopay_label: Pays automatically
+ autopay_hint: Keep it on your bills list, but stop treating it as something to do.
+ more_options: More options
+ notes_label: Notes
+ notes_placeholder: Account 4821, charged to the Amex
+ notes_hint: Anything you want in front of you when paying. Shown on the bill.
+ apply_to_siblings:
+ one: "Also use this link for the other %{name} bill"
+ other: "Also use this link for the other %{count} %{name} bills"
+ submit: Save bill
+ submit_income: Save income
+ income_name_label: Source
+ income_name_placeholder: Employer, client, side gig
+ income_amount_label: Amount per paycheck
+ income_due_on_label: Next payday
+ income_account_label: Deposits into
+ income_frequency_hint: How often you're paid. Pick the day details to match your payday.
+ income_frequency_label: How often you're paid
+ income_frequency_new_hint: The payday details come from the date above. You can refine the schedule after saving.
+ income_notes_placeholder: Direct deposit, splits across two accounts
+ income_notes_hint: Anything worth remembering about this income.
+ name_label: Name
+ name_placeholder: Rent, PG&E, Netflix
+ amount_label: Amount
+ first_due_on_label: Next due
+ account_label: Paid from
+ account_blank: Any account
+ bill_type_label: Kind
+ installment_count_label: Number of payments
+ installment_count_hint: The plan ends after this many payments and shows its progress until then.
+ renews_on_label: Renews
+ trial_ends_on_label: Trial ends
+ amount_edit_hint: Applies to bills due from now on. Anything already due keeps the amount it was.
+ income_amount_edit_hint: Applies to paydays from now on. Anything already due keeps the amount it was.
+ cancelled_on_label: Cancelled
+ cancelled_on_hint: Recording the date is for your own reference. Pause the bill to stop future ones appearing.
+ category_label: Category
+ category_blank: No category
+ frequency_new_hint: The day details come from the due date above. You can refine the schedule after saving.
+ frequency_label: How often
+ frequency_hint: How often this bill comes due. Pick the day details to match your statement.
+ frequency_day_label: On day
+ frequency_second_day_label: And day
+ frequency_weekday_label: "On"
+ frequency_month_label: "In"
+ update:
+ success: Bill updated
+ success_with_siblings:
+ one: "Bill updated, and the payment link was copied to 1 other bill"
+ other: "Bill updated, and the payment link was copied to %{count} other bills"
+ manage:
+ title: Your bills live on the Bills page
+ description: Declare, edit, pause and pay everything from there. This page controls detection.
+ cta: Open all bills
settings:
enable_label: Enable Recurring Transactions
enable_description: Automatically detect recurring transaction patterns and show upcoming projected transactions.
@@ -43,11 +150,14 @@ en:
- CSV imports complete (transactions, trades, accounts, etc. )
- Any provider sync completes ( Plaid, SimpleFIN, etc. )
identified: Identified %{count} recurring transaction patterns
+ identify_already_running: Detection is already running. Check back in a moment.
cleaned_up: Cleaned up %{count} stale recurring transactions
- marked_inactive: Recurring transaction marked as inactive
- marked_active: Recurring transaction marked as active
- deleted: Recurring transaction deleted
- confirm_delete: Are you sure you want to delete this recurring transaction?
+ marked_inactive: Paused. It won't appear again until you resume it.
+ marked_active: Resumed.
+ deleted: Bill removed. Your transactions were not touched.
+ deleted_income: Income removed. Your transactions were not touched.
+ confirm_delete: "Remove %{name} from your bills? Your transactions stay exactly as they are. This only removes the bill and its payment schedule."
+ confirm_delete_income: "Remove %{name} as income? Your transactions stay exactly as they are. This only removes the income and its schedule."
marked_as_recurring: Transaction marked as recurring
already_exists: A manual recurring transaction already exists for this pattern
creation_failed: Failed to create recurring transaction. Please check the transaction details and try again.
@@ -59,14 +169,27 @@ en:
table:
merchant: Name
amount: Amount
- expected_day: Expected Day
+ frequency: Frequency
next_date: Next Date
last_occurrence: Last Occurrence
status: Status
actions: Actions
status:
active: Active
- inactive: Inactive
+ inactive: Paused
+ suggested: Possible bill
+ paused: Paused
+ ended: Dismissed
+ suggested:
+ title: Possible new bills
+ seen_count:
+ one: "seen once"
+ other: "seen %{count} times"
+ collapsed_hint: Tap to review
+ confirm: Add bill
+ dismiss: Not a bill
+ confirmed: Bill added to your recurring transactions
+ dismissed: "Dismissed. This pattern won't be suggested again."
badges:
manual: Manual
transfer_marked_as_recurring: Transfer marked as recurring
diff --git a/config/locales/views/transactions/en.yml b/config/locales/views/transactions/en.yml
index e46bf495d..36ae7ff50 100644
--- a/config/locales/views/transactions/en.yml
+++ b/config/locales/views/transactions/en.yml
@@ -62,8 +62,12 @@ en:
keep_both: No, keep both
loan_payment: Loan Payment
mark_recurring: Mark as Recurring
+ create_bill: Create a bill
mark_recurring_subtitle: Track this as a recurring transaction. Amount variance is automatically calculated from past 6 months of similar transactions.
mark_recurring_title: Recurring Transaction
+ applied_to_title: Bills this paid
+ applied_to_detail: "%{amount} toward the bill due %{date}"
+ applied_to_unreviewed: Needs review
merge_duplicate: Yes, merge them
potential_duplicate_description: This pending transaction may be the same as the posted transaction below. If so, merge them to avoid double-counting.
potential_duplicate_title: Possible duplicate detected
diff --git a/config/routes.rb b/config/routes.rb
index 3a9bc205a..dac230640 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -526,15 +526,55 @@ Rails.application.routes.draw do
end
end
- resources :recurring_transactions, only: %i[index destroy] do
+ resources :bills, only: %i[index show] do
+ # POST for the same reason recurring_transactions#identify is: detection
+ # mutates (creates suggested series and occurrences), so it stays behind
+ # CSRF protection rather than a plain URL.
collection do
- match :identify, via: [ :get, :post ]
- match :cleanup, via: [ :get, :post ]
+ post :detect
+ post :ai_review, to: "bills/ai_reviews#create"
+ post :reset_feed_token
+ end
+ member do
+ get :smart_configuration, to: "bills/smart_configurations#show"
+ end
+ end
+ get "bills_feed/:token", to: "bills_feeds#show", as: :bills_feed, defaults: { format: :ics }
+
+ resources :recurring_occurrences, only: %i[show] do
+ member do
+ post :mark_paid
+ post :skip
+ post :reopen
+ patch :snooze
+ patch :override_amount
+ end
+
+ resources :allocations, controller: :recurring_allocations, only: %i[create]
+ end
+
+ resources :recurring_allocations, only: %i[destroy] do
+ member do
+ post :confirm
+ post :reject
+ end
+ end
+
+ resources :recurring_transactions, only: %i[index new create edit update destroy] do
+ collection do
+ # POST only: all three mutate. They accepted GET while DS::Link's method
+ # option was inert, which left destructive work sitting behind a plain
+ # URL and outside CSRF protection. Every call site passes method: :post.
+ post :identify
+ post :cleanup
+ post :smart_fill, to: "recurring_transactions/smart_fills#create"
patch :update_settings
end
member do
- match :toggle_status, via: [ :get, :post ]
+ post :toggle_status
+ post :confirm
+ post :dismiss
end
end
diff --git a/design/tokens/sure.tokens.json b/design/tokens/sure.tokens.json
index f0c6366ee..ce43d45b1 100644
--- a/design/tokens/sure.tokens.json
+++ b/design/tokens/sure.tokens.json
@@ -342,6 +342,8 @@
"border-destructive": { "$type": "utility", "$value": "{color.red.500}", "$extensions": { "sure.utility": { "prefix": "border" }, "sure.dark": "{color.red.400}" } },
"border-inverse": { "$type": "utility", "$value": "{color.alpha-white.200}", "$extensions": { "sure.utility": { "prefix": "border" }, "sure.dark": "{color.alpha-black.300}" } },
+ "divide-subdued": { "$type": "utility", "$value": "{color.alpha-black.50}", "$extensions": { "sure.utility": { "prefix": "divide" }, "sure.dark": "{color.alpha-white.200}" } },
+
"button-bg-primary": { "$type": "utility", "$value": "{color.gray.900}", "$extensions": { "sure.utility": { "prefix": "bg" }, "sure.dark": "{color.white}" } },
"button-bg-primary-hover": { "$type": "utility", "$value": "{color.gray.800}", "$extensions": { "sure.utility": { "prefix": "bg" }, "sure.dark": "{color.gray.50}" } },
"button-bg-secondary": { "$type": "utility", "$value": "{color.gray.50}", "$extensions": { "sure.utility": { "prefix": "bg" }, "sure.dark": "{color.gray.700}" } },
@@ -353,6 +355,8 @@
"button-bg-destructive-hover": { "$type": "utility", "$value": "{color.red.700}", "$extensions": { "sure.utility": { "prefix": "bg" }, "sure.dark": "{color.red.500}" } },
"button-bg-ghost-hover": { "$type": "utility", "$value": "{color.gray.50}", "$extensions": { "sure.utility": { "prefix": "bg" }, "sure.dark": "bg-gray-800 text-inverse" } },
"button-bg-outline-hover": { "$type": "utility", "$value": "{color.gray.100}", "$extensions": { "sure.utility": { "prefix": "bg" }, "sure.dark": "{color.gray.700}" } },
+ "button-bg-accent": { "$type": "utility", "$value": "{color.blue.tint-5}", "$extensions": { "sure.utility": { "prefix": "bg" }, "sure.dark": "{color.blue.tint-5}" } },
+ "button-bg-accent-hover": { "$type": "utility", "$value": "{color.blue.tint-10}", "$extensions": { "sure.utility": { "prefix": "bg" }, "sure.dark": "{color.blue.tint-10}" } },
"tab-item-active": { "$type": "utility", "$value": "{color.white}", "$extensions": { "sure.utility": { "prefix": "bg" }, "sure.dark": "{color.gray.700}" } },
"tab-item-hover": { "$type": "utility", "$value": "{color.gray.200}", "$extensions": { "sure.utility": { "prefix": "bg" }, "sure.dark": "{color.gray.800}" } },
diff --git a/docs/hosting/recurring-bills.md b/docs/hosting/recurring-bills.md
new file mode 100644
index 000000000..7c846b22b
--- /dev/null
+++ b/docs/hosting/recurring-bills.md
@@ -0,0 +1,68 @@
+# Bills and Recurring Transactions
+
+This document explains how Sure detects recurring bills, when the detection
+pipeline runs, and the maintenance tasks available to self-hosters.
+
+## How detection works
+
+Sure clusters your transaction history into recurring patterns (same
+merchant or name, consistent amount within tolerance, consistent day). A
+pattern needs at least three consistent occurrences to become a series.
+New detections land with status `suggested` and wait in a review strip on
+the Bills page (and under Settings -> Recurring transactions) until you
+confirm or dismiss them. Dismissing leaves a tombstone, so a dismissed
+pattern is never suggested again.
+
+## When the pipeline runs
+
+The full pipeline (detect patterns, materialize upcoming occurrences,
+repair provider-replaced entries, match payments, detect price changes)
+runs automatically:
+
+- after every completed bank sync or import (debounced by 30 seconds)
+- nightly at 05:30 UTC (occurrence materialization only, for families
+ that never sync)
+
+And on demand:
+
+- the **Find recurring transactions** button on an empty Bills page
+- the **Identify Patterns** button under Settings -> Recurring transactions
+
+All triggers share one per-family lock, so concurrent runs never stack.
+
+## First run on existing data
+
+The user-triggered detection actions (the **Find recurring transactions**
+button on an empty Bills page and the **Identify Patterns** button under
+Settings -> Recurring transactions) backfill the last six months of
+history: past occurrences are generated and closed as paid where a real
+transaction anchors them. Past occurrences no transaction covers are
+deleted rather than shown as missed, so the backfill reconstructs what
+happened without fabricating debt. The backfill is idempotent, so
+re-running detection never duplicates history. Background syncs never
+backfill; on an instance upgraded from a build without the Bills
+subsystem, run either detection action once to reconstruct history.
+
+Confirming an individual suggestion likewise backfills that bill's own
+history, so a just-confirmed bill shows its lived past instead of
+starting blank.
+
+## Maintenance tasks
+
+Both tasks are safe to re-run; they only close history a real entry
+anchors and never touch existing payment records.
+
+```bash
+# Rebuild N months of occurrence history for every family (default 6)
+bin/rails "recurring:backfill_history[12]"
+
+# One-shot classification of auto-detected series still on defaults
+# (assigns bill/subscription/installment kind and a category)
+bin/rails recurring:classify_existing
+```
+
+## Disabling the feature
+
+Settings -> Recurring transactions has a per-family toggle. Disabling
+hides the Bills page and stops all detection and materialization for
+that family.
diff --git a/test/controllers/bills/ai_reviews_controller_test.rb b/test/controllers/bills/ai_reviews_controller_test.rb
new file mode 100644
index 000000000..f2ddc757f
--- /dev/null
+++ b/test/controllers/bills/ai_reviews_controller_test.rb
@@ -0,0 +1,63 @@
+require "test_helper"
+
+class Bills::AiReviewsControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ sign_in @user = users(:family_admin)
+ @user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => true))
+ end
+
+ test "seeds a chat with the server-owned review prompt" do
+ stub_provider
+
+ assert_difference "@user.chats.count", 1 do
+ post ai_review_bills_url
+ end
+
+ chat = @user.chats.order(created_at: :desc).first
+ assert_redirected_to chat_path(chat, thinking: true)
+ content = chat.messages.find_by!(type: "UserMessage").content
+ assert_includes content, "Review my bills and subscriptions"
+ assert_includes content, "ask me before changing anything"
+ # The prompt appears in the chat as the user's own words, so it must not
+ # leak internal tool names; the tool descriptions route the model.
+ assert_no_match(/get_bill/, content)
+ assert_equal chat, @user.reload.last_viewed_chat
+ end
+
+ test "forbidden when AI is disabled" do
+ stub_provider
+ @user.update!(ai_enabled: false)
+
+ post ai_review_bills_url
+
+ assert_response :forbidden
+ end
+
+ test "forbidden without an LLM provider" do
+ Provider::Registry.stubs(:preferred_llm_provider).returns(nil)
+
+ post ai_review_bills_url
+
+ assert_response :forbidden
+ end
+
+ test "redirects when the family has recurring transactions off" do
+ stub_provider
+ @user.family.update!(recurring_transactions_disabled: true)
+
+ post ai_review_bills_url
+
+ assert_redirected_to root_path
+ end
+
+ test "refuses GET" do
+ route = Rails.application.routes.recognize_path("/bills/ai_review", method: :get)
+ assert_equal "show", route[:action], "GET must never reach the review action"
+ end
+
+ private
+
+ def stub_provider
+ Provider::Registry.stubs(:preferred_llm_provider).returns(Object.new)
+ end
+end
diff --git a/test/controllers/bills/smart_configurations_controller_test.rb b/test/controllers/bills/smart_configurations_controller_test.rb
new file mode 100644
index 000000000..90a072db4
--- /dev/null
+++ b/test/controllers/bills/smart_configurations_controller_test.rb
@@ -0,0 +1,96 @@
+require "test_helper"
+
+class Bills::SmartConfigurationsControllerTest < ActionDispatch::IntegrationTest
+ RawSuggestion = Provider::LlmConcept::BillSetupSuggestion
+
+ setup do
+ sign_in @user = users(:family_admin)
+ @user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => true))
+ @family = @user.family
+ @family.recurring_transactions.destroy_all
+ @series = @family.recurring_transactions.create!(
+ name: "Gym", account: accounts(:depository), amount: 40, currency: "USD",
+ expected_day_of_month: 9, anchor_date: Date.current,
+ last_occurrence_date: Date.current.beginning_of_month + 8.days - 1.month,
+ next_expected_date: Date.current.beginning_of_month + 8.days,
+ status: "active", manual: true
+ )
+ @family.recurring_transactions.where.not(id: @series.id) # no-op, clarity
+ accounts(:depository).entries.create!(
+ date: Date.current.beginning_of_month + 8.days - 1.month, amount: 40,
+ currency: "USD", name: "Gym", entryable: Transaction.new
+ )
+ end
+
+ test "renders proposals as value-carrying checkboxes" do
+ stub_provider(raw(amount: 45.0, frequency: "monthly", day_of_month: 9,
+ rationale: "Recent charges are 45"))
+
+ get smart_configuration_bill_url(@series), headers: { "Turbo-Frame" => "modal" }
+
+ assert_response :success
+ # The checkbox IS the field: unchecked rows submit nothing.
+ assert_select "input[type=checkbox][name=?][value=?]", "recurring_transaction[amount]", "45.0"
+ assert_select "input[type=checkbox][name=?][value=?]", "recurring_transaction[frequency_preset]", "monthly"
+ assert_select "form[action=?]", recurring_transaction_path(@series)
+ assert_match "Recent charges are 45", response.body
+ end
+
+ test "an all-null suggestion means the bill is already right" do
+ stub_provider(raw(confidence: 0.9))
+
+ get smart_configuration_bill_url(@series), headers: { "Turbo-Frame" => "modal" }
+
+ assert_response :success
+ assert_match I18n.t("bills.smart_configurations.show.no_changes"), response.body
+ assert_select "input[type=checkbox]", count: 0
+ end
+
+ test "checked proposals apply through the ordinary update path" do
+ # Simulates submitting the dialog with only the amount box checked.
+ patch recurring_transaction_url(@series), params: {
+ recurring_transaction: { amount: "45.0" }
+ }
+
+ assert_equal 45.0, @series.reload.amount.to_f
+ end
+
+ test "forbidden without an LLM provider" do
+ Provider::Registry.stubs(:preferred_llm_provider).returns(nil)
+
+ get smart_configuration_bill_url(@series)
+
+ assert_response :forbidden
+ end
+
+ test "another family's bill is not found" do
+ stub_provider(raw)
+ other = families(:empty).recurring_transactions.create!(
+ name: "Foreign", amount: 10, currency: "USD", expected_day_of_month: 1,
+ last_occurrence_date: Date.current, next_expected_date: 1.month.from_now.to_date,
+ status: "active"
+ )
+
+ get smart_configuration_bill_url(other)
+
+ assert_response :not_found
+ end
+
+ private
+
+ def raw(**overrides)
+ RawSuggestion.new(**{
+ name: nil, amount: nil, frequency: nil, day_of_month: nil, weekday: nil,
+ month_of_year: nil, category_name: nil, bill_type: nil, autopay: nil,
+ confidence: nil, rationale: nil
+ }.merge(overrides))
+ end
+
+ def stub_provider(suggestion)
+ provider = Object.new
+ provider.define_singleton_method(:suggest_bill_setup) do |**|
+ Provider::Response.new(success?: true, data: suggestion, error: nil)
+ end
+ Provider::Registry.stubs(:preferred_llm_provider).returns(provider)
+ end
+end
diff --git a/test/controllers/bills_controller_test.rb b/test/controllers/bills_controller_test.rb
new file mode 100644
index 000000000..3f3bfd0ae
--- /dev/null
+++ b/test/controllers/bills_controller_test.rb
@@ -0,0 +1,1579 @@
+require "test_helper"
+
+class BillsControllerTest < ActionDispatch::IntegrationTest
+ teardown do
+ travel_back
+ end
+
+ setup do
+ sign_in @user = users(:family_admin)
+ @user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => true))
+ @family = @user.family
+ @family.recurring_transactions.destroy_all
+ ensure_tailwind_build
+ end
+
+ test "redirects users without preview access" do
+ @user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => false))
+
+ get bills_url
+
+ assert_redirected_to root_path
+ assert_match(/preview/i, flash[:alert])
+ end
+
+ # Bills was the only top-level destination with no heading, so screen readers
+ # got no outline and it broke the page-header pattern every other page follows.
+ test "every bills view has a page heading" do
+ create_bill(name: "Rent", amount: 1200)
+
+ %w[overview calendar paycheck all].each do |view|
+ get view == "overview" ? bills_url : bills_url(view: view)
+
+ assert_response :success
+ assert_select "main h1", text: I18n.t("bills.index.title"),
+ message: "the #{view} view is missing its page heading"
+ end
+ end
+
+ test "index lists a bill" do
+ bill = create_bill(name: "Rent", amount: 1200)
+
+ get bills_url
+
+ assert_response :success
+ assert_match "Rent", response.body
+ end
+
+ # A bill is something you owe. Income is not owed, an internal transfer is not owed,
+ # and a paused row was explicitly set aside, so none of them belong on the list.
+ test "index excludes income and inactive rows but shows debt payments" do
+ create_bill(name: "Real bill", amount: 50)
+ create_bill(name: "Salary deposit", amount: -2000)
+ create_bill(name: "Paused bill", amount: 30, status: "inactive")
+ # A recurring transfer into a credit card is a real obligation with a
+ # real due date -- it belongs on the pay-run page, marked as what it is.
+ create_bill(name: "Card payment", amount: 100,
+ destination_account_id: accounts(:credit_card).id)
+ # A transfer into an asset account is just moving money; not a bill.
+ create_bill(name: "Moved to savings", amount: 100,
+ destination_account_id: accounts(:investment).id)
+
+ get bills_url
+
+ assert_response :success
+ assert_match "Real bill", response.body
+ assert_match "Card payment", response.body
+ assert_match I18n.t("bills.debt_payment"), response.body
+ assert_no_match "Salary deposit", response.body
+ assert_no_match "Paused bill", response.body
+ assert_no_match "Moved to savings", response.body
+ end
+
+ test "index shows an overdue occurrence in the overdue section" do
+ overdue_day = 10.days.ago.to_date
+ create_bill(name: "Late bill", amount: 75,
+ expected_day_of_month: overdue_day.day,
+ last_occurrence_date: 2.months.ago.to_date,
+ next_expected_date: overdue_day)
+
+ get bills_url
+
+ assert_response :success
+ assert_match "Late bill", response.body
+
+ # The row says HOW late, once. It used to print "Overdue" in the date rail
+ # beside a subline already reading "10 days overdue", spending its one
+ # piece of temporal context on saying the same word twice.
+ assert_match I18n.t("bills.attention.overdue", count: 10), response.body
+ assert_no_match(/>\s*#{I18n.t("bills.row_overdue")}\s*, response.body,
+ "the date rail carries the date, not the word the subline already said")
+
+ # And the summary states what is at stake.
+ assert_match I18n.t("bills.month_pulse.pulse_overdue"), response.body
+ end
+
+ # The summary answers one question in order: where am I this month, what is
+ # late, what is due soon, what happens next. It used to be a big number, two
+ # small ones and a ring reading 0%.
+ test "the month summary reads as one story, not a row of statistics" do
+ overdue_day = 10.days.ago.to_date
+ create_bill(name: "Late Co", amount: 49.41,
+ expected_day_of_month: overdue_day.day,
+ last_occurrence_date: 2.months.ago.to_date,
+ next_expected_date: overdue_day)
+ soon = 3.days.from_now.to_date
+ create_bill(name: "Amazon Prime", amount: 16.23, manual: true,
+ anchor_date: soon, expected_day_of_month: soon.day,
+ last_occurrence_date: Date.current, next_expected_date: soon)
+
+ get bills_url
+ assert_response :success
+ body = response.body
+
+ # The month frames it, and the count says how much month there is.
+ assert_match I18n.l(Date.current, format: "%B"), body
+ assert_match I18n.t("bills.month_pulse.left_to_pay"), body
+
+ # Paid / overdue / next-7 support the headline rather than becoming three
+ # KPI cards of their own.
+ assert_match I18n.t("bills.month_pulse.pulse_paid"), body
+ assert_match I18n.t("bills.month_pulse.pulse_overdue"), body
+ assert_match I18n.t("bills.month_pulse.pulse_next_seven"), body
+
+ # And it ends by saying what actually happens next.
+ assert_match I18n.t("bills.month_pulse.next_up"), body
+ assert_match "Amazon Prime", body
+
+ assert_match I18n.t("bills.month_pulse.left_to_pay"), body
+ assert_no_match(/ProgressRing|rounded-full[^"]*stroke/, body,
+ "the donut is gone; progress is a rule, not a centrepiece")
+ end
+
+ # Something already past its due date is not "coming up" -- it is the thing
+ # the list below is for, and putting it in Next up makes the summary argue
+ # with the worklist under it.
+ #
+ # The filter is on the DATE, not on derived_state, and this test asserts the
+ # same. An earlier version checked `derived_state == :overdue` and passed
+ # while the bug was plainly visible on screen: derived_state only turns
+ # :overdue after the 3-day grace period, so a bill two days late was still
+ # :due, sailed into the strip, and the date column rendered it "2 days late".
+ test "next up holds nothing that is already past due, grace period included" do
+ # Two days late is INSIDE the grace period, so derived_state still reads
+ # :due. That is the case that slipped through.
+ just_late = 2.days.ago.to_date
+ create_bill(name: "Barely Late Co", amount: 12, manual: true, dedup_scope: "bl",
+ anchor_date: just_late, expected_day_of_month: just_late.day,
+ last_occurrence_date: just_late, next_expected_date: just_late)
+
+ long_late = 10.days.ago.to_date
+ create_bill(name: "Very Late Co", amount: 49.41, manual: true, dedup_scope: "vl",
+ anchor_date: long_late, expected_day_of_month: long_late.day,
+ last_occurrence_date: long_late, next_expected_date: long_late)
+
+ soon = 4.days.from_now.to_date
+ create_bill(name: "Soon Co", amount: 30, manual: true, dedup_scope: "sc",
+ anchor_date: soon, expected_day_of_month: soon.day,
+ last_occurrence_date: Date.current, next_expected_date: soon)
+
+ get bills_url
+ assert_response :success
+
+ next_up = @controller.view_assigns["next_up"]
+ assert next_up.any?, "the fixture must actually produce a Next up strip"
+
+ past = next_up.select { |occurrence| occurrence.effective_due_on < Date.current }
+ assert_empty past.map { |occurrence| occurrence.recurring_transaction.display_name },
+ "a bill inside its grace period is still late, and late is not next"
+
+ assert_includes next_up.map { |o| o.recurring_transaction.display_name }, "Soon Co"
+ end
+
+ # Three tiers of one subscription are three real bills that render as three
+ # identical rows. Only then does a row earn a second fact.
+ test "bills that would render identically gain something that tells them apart" do
+ day = 12.days.from_now.to_date
+ 2.times do |i|
+ create_bill(name: "TWITCH", amount: 11.99, manual: true, dedup_scope: "tier#{i}",
+ anchor_date: day, expected_day_of_month: day.day,
+ last_occurrence_date: Date.current, next_expected_date: day)
+ end
+ create_bill(name: "Distinct Co", amount: 30, manual: true,
+ anchor_date: day, expected_day_of_month: day.day,
+ last_occurrence_date: Date.current, next_expected_date: day)
+
+ get bills_url
+ assert_response :success
+
+ # The pair carries its schedule; the bill nobody could confuse does not
+ # pay for their ambiguity.
+ twitch_rows = response.body.scan(/TWITCH/).size
+ assert_operator twitch_rows, :>=, 2
+ end
+
+ # Overdue rows used to sit inside the chronological month list, marked only by
+ # a word where their date would be, which made the most urgent rows the
+ # easiest to scroll past.
+ test "overdue bills are lifted out of the month list into their own section" do
+ overdue_day = 10.days.ago.to_date
+ create_bill(name: "Late bill", amount: 75,
+ expected_day_of_month: overdue_day.day,
+ last_occurrence_date: 2.months.ago.to_date,
+ next_expected_date: overdue_day)
+
+ get bills_url
+
+ assert_response :success
+ assert_match I18n.t("bills.index.needs_attention"), response.body
+
+ body = response.body
+ attention_at = body.index(I18n.t("bills.index.needs_attention"))
+ month_at = body.index(I18n.t("bills.index.this_month"))
+ # Measured from the section headings down, not from the top of the page:
+ # this series' NEXT cycle legitimately appears in the Next up strip above,
+ # which is a different occurrence of the same bill.
+ late_at = body.index("Late bill", attention_at)
+
+ assert_not_nil attention_at
+ assert_operator attention_at, :<, late_at, "the late bill belongs under Needs attention"
+ assert_operator late_at, :<, month_at, "and above This month, not inside it" if month_at
+ end
+
+ # "Status" used to mean the series lifecycle, so the one question people
+ # actually bring to this table -- what is late -- could not be asked.
+ test "the all view filters by payment state, not just lifecycle" do
+ overdue_day = 10.days.ago.to_date
+ late = create_bill(name: "Late Co", amount: 75,
+ expected_day_of_month: overdue_day.day,
+ last_occurrence_date: 2.months.ago.to_date,
+ next_expected_date: overdue_day)
+ upcoming = create_bill(name: "Future Co", amount: 40, manual: true,
+ anchor_date: 20.days.from_now.to_date,
+ expected_day_of_month: 20.days.from_now.to_date.day,
+ last_occurrence_date: Date.current,
+ next_expected_date: 20.days.from_now.to_date)
+
+ # The filter is only meaningful if the fixtures are in the states it sorts by.
+ assert late.current_occurrence.overdue?, "Late Co must actually be overdue"
+ assert_not upcoming.current_occurrence.overdue?, "Future Co must not be"
+
+ get bills_url(view: "all", q: { status: "overdue" })
+
+ assert_response :success
+ assert_match "Late Co", response.body
+ assert_no_match "Future Co", response.body
+
+ get bills_url(view: "all", q: { status: "paused" })
+ assert_response :success
+ assert_no_match "Late Co", response.body, "lifecycle filtering still works"
+ end
+
+ test "index cannot see another family's bills" do
+ families(:empty).recurring_transactions.create!(
+ name: "Someone else's rent",
+ amount: 999,
+ currency: "USD",
+ expected_day_of_month: 1,
+ last_occurrence_date: Date.current,
+ next_expected_date: 1.month.from_now.to_date,
+ status: "active"
+ )
+
+ get bills_url
+
+ assert_response :success
+ assert_no_match "Someone else's rent", response.body
+ end
+
+ test "index redirects when the family has turned recurring transactions off" do
+ @family.update!(recurring_transactions_disabled: true)
+
+ get bills_url
+
+ assert_redirected_to root_path
+ end
+
+ # The headline answers "how much do I owe", which is one number, so foreign-currency
+ # bills are converted rather than suppressing the total the way this first did.
+ test "index totals bills in the family currency" do
+ create_bill(name: "Domestic bill", amount: 100)
+ create_bill(name: "Foreign bill", amount: 50, currency: "EUR")
+ ExchangeRate.create!(from_currency: "EUR", to_currency: @family.currency, date: Date.current, rate: 2)
+
+ get bills_url
+
+ assert_response :success
+ assert_match "200", response.body
+ end
+
+ test "index still totals what it can when a rate is missing" do
+ create_bill(name: "Domestic bill", amount: 100)
+ create_bill(name: "Unconvertible bill", amount: 50, currency: "JPY")
+
+ get bills_url
+
+ assert_response :success
+ assert_match "Unconvertible bill", response.body
+ assert_match I18n.t("bills.index.unconvertible", count: 1), response.body
+ end
+
+ # Autopay is information, not a task. The bill stays listed and still counts toward
+ # the total, but it must not read as something demanding to be clicked.
+ test "index shows an autopaying bill without a pay call to action" do
+ create_bill(name: "Handled bill", amount: 30, autopay: true,
+ payment_url: "https://pay.example.com")
+
+ get bills_url
+
+ assert_response :success
+ assert_match "Handled bill", response.body
+ assert_match I18n.t("recurring_transactions.pay_action.autopay"), response.body
+ assert_no_match(/>\s*#{I18n.t("recurring_transactions.pay_action.pay")}\s*, response.body)
+ end
+
+ # The paid-from account still shows; it just waits until there is more than
+ # one account for it to distinguish between. The single-account case is
+ # covered below, where showing it is nineteen copies of one fact.
+ test "index shows the account a bill is paid from and its notes" do
+ create_bill(name: "Annotated bill", amount: 60, notes: "Account 4821, on the Amex")
+ create_bill(name: "Card bill", amount: 25, account: accounts(:credit_card))
+
+ get bills_url
+
+ assert_response :success
+ assert_match "Account 4821, on the Amex", response.body
+ assert_match I18n.t("bills.paid_from", account: accounts(:depository).name), response.body
+ end
+
+ # Three concurrent subscriptions to one merchant, at different prices on
+ # different days, are three real bills and render as three rows.
+ test "index shows separate subscription tiers as separate rows" do
+ create_bill(name: "TWITCH", amount: 5.99, expected_day_of_month: 8)
+ create_bill(name: "TWITCH", amount: 11.99, expected_day_of_month: 2)
+ create_bill(name: "TWITCH", amount: 24.99, expected_day_of_month: 21)
+
+ get bills_url
+
+ assert_response :success
+ assert_operator response.body.scan("TWITCH").size, :>=, 3
+ end
+
+ test "index sums the remaining KPI from open occurrences" do
+ create_bill(name: "One", amount: 100)
+ create_bill(name: "Two", amount: 50)
+
+ get bills_url
+
+ assert_response :success
+ assert_match I18n.t("bills.index.left_to_pay"), response.body
+ assert_match "$150", response.body
+ end
+
+ test "partial payment moves the remaining KPI and shows progress" do
+ bill = create_bill(name: "Rent", amount: 2000)
+ occurrence = bill.recurring_occurrences.order(:due_on).first
+ RecurringTransaction::Allocator.new(occurrence).allocate!(amount: "750")
+
+ get bills_url
+
+ assert_response :success
+ # What is left leads, and the row says it once. The subline carries the
+ # state and the figure; printing "$750.00 of $2,000.00 paid" again on the
+ # right was the same arithmetic twice, and it was squeezing the bill's own
+ # name out of the row.
+ assert_match I18n.t("bills.attention.partial", amount: "$1,250.00"), response.body
+ assert_match "$1,250", response.body
+ assert_no_match I18n.t("bills.partial_progress", paid: "$750.00", expected: "$2,000.00"), response.body
+ end
+
+ # The row expansion described the SERIES definition while the drawer described
+ # the current occurrence, so an overdue bill greeted you with "Next payment"
+ # in one surface and "Overdue" in the other, at the same moment.
+ test "the row expansion and the drawer tell the same story about an overdue bill" do
+ overdue_day = 6.days.ago.to_date
+ bill = create_bill(name: "Late Co", amount: 5.99,
+ expected_day_of_month: overdue_day.day,
+ last_occurrence_date: 2.months.ago.to_date,
+ next_expected_date: overdue_day)
+ occurrence = bill.recurring_occurrences.order(:due_on).detect(&:overdue?)
+ assert occurrence, "the fixture must actually be overdue"
+
+ days = (Date.current - occurrence.effective_due_on).to_i
+ overdue_phrase = I18n.t("bills.due_label.overdue", count: days,
+ date: I18n.l(occurrence.effective_due_on, format: :short))
+
+ get bill_url(bill), headers: { "Turbo-Frame" => "drawer" }
+ assert_response :success
+ assert_match overdue_phrase, response.body, "the drawer states the status"
+
+ get bill_url(bill, display: "pane", frame: "x"), headers: { "Turbo-Frame" => "x" }
+ assert_response :success
+ assert_match overdue_phrase, response.body, "and the expansion must state the same one"
+ assert_no_match I18n.t("bills.detail.next_payment"), response.body,
+ "an overdue bill is not a next payment"
+ end
+
+ # A bill's own page is where the depth lives now. It used to be a drawer
+ # dialog rendered over an empty settings layout, which is how the app ended
+ # up with three renderings of a bill's detail and no page at all.
+ test "show renders the bill page with history and analytics" do
+ bill = create_bill(name: "Power Co", amount: 80)
+ past = bill.recurring_occurrences.create!(
+ family: @family, original_due_on: 2.months.ago.to_date, due_on: 2.months.ago.to_date,
+ currency: "USD"
+ )
+ RecurringTransaction::Allocator.new(past).allocate!(amount: "78.50")
+ past.reload
+
+ get bill_url(bill)
+
+ assert_response :success
+ assert_select "main h1", text: "Power Co"
+ assert_match I18n.t("bills.detail.history"), response.body
+ assert_match I18n.t("bills.detail.ytd"), response.body
+ assert_match "$78.50", response.body
+ end
+
+ # The drawer slot belongs to resolving a payment. If a bill's page claimed it
+ # too, the page and the payment surface would compete for one frame id and
+ # whichever lost would render nothing at all.
+ test "the bill page leaves the drawer frame to the payment surface" do
+ bill = create_bill(name: "Power Co", amount: 80)
+
+ get bill_url(bill)
+
+ assert_response :success
+ assert_equal 1, response.body.scan(/]*id="drawer"/).size,
+ "only the layout's own empty drawer frame"
+ assert_match recurring_occurrence_path(bill.recurring_occurrences.order(:due_on).first), response.body,
+ "and the page still offers the way in to it"
+ end
+
+ # The average sat beside per-year totals that are sums of real payments, so
+ # reading estimates here put two disagreeing numbers about the same money in
+ # one panel. Expected $80 twice, really charged $76 and $78: the average is
+ # $77, a figure that appears nowhere if the estimates are averaged instead.
+ test "drawer analytics average what was charged, not what was expected" do
+ bill = create_bill(name: "Power Co", amount: 80)
+
+ [ [ 3, 76 ], [ 2, 78 ] ].each do |months_ago, charged|
+ due = months_ago.months.ago.to_date
+ occurrence = bill.recurring_occurrences.create!(
+ family: @family, original_due_on: due, due_on: due, currency: "USD")
+ RecurringTransaction::Allocator.new(occurrence).allocate!(amount: charged.to_s)
+ assert occurrence.reload.paid?, "each charge is inside tolerance and should settle the cycle"
+ end
+
+ get bill_url(bill), headers: { "Turbo-Frame" => "drawer" }
+
+ assert_response :success
+ assert_match "$77.00", response.body
+ end
+
+ test "the all view lists every series and filters compose" do
+ create_bill(name: "Alpha bill", amount: 10)
+ create_bill(name: "Beta paused", amount: 20, status: "paused")
+ create_bill(name: "Gamma income", amount: -500)
+
+ get bills_url(view: "all")
+ assert_response :success
+ assert_match "Alpha bill", response.body
+ assert_match "Beta paused", response.body
+ assert_match "Gamma income", response.body
+
+ get bills_url(view: "all", q: { status: "paused" })
+ assert_match "Beta paused", response.body
+ assert_no_match "Alpha bill", response.body
+
+ get bills_url(view: "all", q: { search: "gamma" })
+ assert_match "Gamma income", response.body
+ assert_no_match "Beta paused", response.body
+ end
+
+ test "the calendar renders the month grid with paid and overdue states" do
+ paid_bill = create_bill(name: "Paid on time", amount: 40, expected_day_of_month: 5)
+ paid_occurrence = paid_bill.recurring_occurrences.find_by!(due_on: Date.current.beginning_of_month + 4)
+ RecurringTransaction::Allocator.new(paid_occurrence).allocate!(amount: "40")
+
+ overdue_day = [ Date.current - 6, Date.current.beginning_of_month ].max
+ create_bill(name: "Still owed", amount: 60,
+ expected_day_of_month: overdue_day.day,
+ last_occurrence_date: 2.months.ago.to_date,
+ next_expected_date: overdue_day)
+
+ get bills_url(view: "calendar")
+
+ assert_response :success
+ assert_match "Paid on time", response.body
+ assert_match "Still owed", response.body
+ assert_match Date.current.strftime("%B %Y"), response.body
+ end
+
+ test "the calendar materializes a far-future month on demand" do
+ create_bill(name: "Forward bill", amount: 25)
+ target = (Date.current + 6.months).beginning_of_month
+
+ get bills_url(view: "calendar", month: target.strftime("%Y-%m"))
+
+ assert_response :success
+ assert_match "Forward bill", response.body
+ end
+
+ test "the calendar caps forward navigation" do
+ create_bill(name: "Some bill", amount: 25)
+ beyond = (Date.current + 30.months).strftime("%Y-%m")
+
+ get bills_url(view: "calendar", month: beyond)
+
+ assert_response :success
+ limit_month = (Date.current + 13.months).beginning_of_month
+ assert_match limit_month.strftime("%B %Y"), response.body
+ end
+
+ test "the paycheck view prompts for income, then plans around it" do
+ create_bill(name: "Rent", amount: 2150)
+
+ get bills_url(view: "paycheck")
+ assert_response :success
+ assert_match I18n.t("bills.paycheck.empty.title"), response.body
+
+ payday = Date.current + 3
+ @family.recurring_transactions.create!(
+ name: "Paycheck", account: accounts(:depository), amount: -1840, currency: "USD",
+ bill_type: "income", expected_day_of_month: payday.day, anchor_date: payday,
+ last_occurrence_date: payday, next_expected_date: payday, status: "active", manual: true
+ )
+
+ get bills_url(view: "paycheck")
+ assert_response :success
+ assert_match I18n.l(payday, format: :short), response.body
+ assert_match I18n.t("bills.paycheck.period_source", source: "Paycheck"), response.body
+ assert_match "Rent", response.body
+ assert_match I18n.t("bills.paycheck.safe_after_bills"), response.body
+ end
+
+ # A lone materialized paycheck landing today collapses the planner's
+ # boundary list to a single date, which yields an empty plan; the page must
+ # treat that like no plan instead of crashing on plan.last.
+ test "the paycheck view survives a lone paycheck landing today" do
+ series = @family.recurring_transactions.create!(
+ name: "Paycheck", account: accounts(:depository), amount: -1840, currency: "USD",
+ bill_type: "income", expected_day_of_month: Date.current.day, anchor_date: Date.current,
+ last_occurrence_date: Date.current, next_expected_date: Date.current,
+ status: "active", manual: true
+ )
+ series.recurring_occurrences.where("due_on > ?", Date.current).delete_all
+
+ get bills_url(view: "paycheck")
+
+ assert_response :success
+ assert_match I18n.t("bills.paycheck.empty.title"), response.body
+ end
+
+ # Income was addable only from inside the Income plan tab, so a family that
+ # had declared none had no way to discover the planning half of Bills
+ # existed. Both halves are addable from every view, but only the half the
+ # view is about earns the header button; the other waits in the menu.
+ test "every bills view offers both add actions, and income opens an income dialog" do
+ %w[overview calendar paycheck all].each do |view|
+ get view == "overview" ? bills_url : bills_url(view: view)
+
+ header_action = view == "paycheck" ? new_recurring_transaction_path(income: true) : new_recurring_transaction_path
+ menu_action = view == "paycheck" ? new_recurring_transaction_path : new_recurring_transaction_path(income: true)
+
+ assert_response :success
+ assert_select "header" do
+ assert_select "a[href=?]:not([role=menuitem])", header_action
+ assert_select "a[href=?][role=menuitem]", menu_action
+ assert_select "a[href=?]:not([role=menuitem])", menu_action, count: 0
+ end
+ end
+
+ # A CTA that says income has to deliver an income form, not a bill form
+ # wearing a different title.
+ get new_recurring_transaction_url(income: true), headers: { "Turbo-Frame" => "modal" }
+
+ assert_response :success
+ assert_match I18n.t("recurring_transactions.new.income_title"), response.body
+ end
+
+ # The expansion is opened from one row, so it has to describe that row's
+ # cycle. It used to ask the series for its current occurrence, which is the
+ # earliest still-open one, so expanding a settled row reported the NEXT cycle
+ # as unpaid directly underneath a row marked Paid.
+ test "expanding a row describes that row's cycle, not the series' next one" do
+ bill = create_bill(name: "Streaming Plus", amount: 15.99)
+ settled = bill.recurring_occurrences.order(:due_on).first
+ entry = accounts(:depository).entries.create!(
+ date: settled.due_on, amount: 15.99, currency: "USD",
+ name: "STREAMING PLUS", entryable: Transaction.new
+ )
+ RecurringTransaction::Allocator.new(settled).allocate!(amount: "15.99", entry: entry)
+ assert settled.reload.paid?
+
+ later = bill.recurring_occurrences.open_status.order(:due_on).first
+ assert_not_nil later, "the series has a later, unpaid cycle to be confused with"
+ assert_not_equal settled.id, later.id
+
+ get bill_url(bill, display: "pane", frame: "pane_x", occurrence: settled.id)
+
+ assert_response :success
+ assert_match I18n.t("bills.summary.paid_headline", amount: "$15.99"), response.body
+ assert_no_match I18n.t("bills.summary.remaining", amount: "$15.99"), response.body,
+ "the settled row must not report itself as still owing"
+ end
+
+ # Without an occurrence the page has no cycle in mind, so the series answers.
+ test "the bill page with no occurrence falls back to the series" do
+ bill = create_bill(name: "Streaming Plus", amount: 15.99)
+
+ get bill_url(bill, display: "pane", frame: "pane_x")
+
+ assert_response :success
+ assert_match I18n.t("bills.summary.remaining", amount: "$15.99"), response.body
+ end
+
+ # The id is resolved through the series, so one from another bill cannot be
+ # borrowed to render someone else's cycle.
+ test "an occurrence id from another bill is ignored" do
+ mine = create_bill(name: "Streaming Plus", amount: 15.99)
+ other = create_bill(name: "Gym", amount: 40)
+ stranger = other.recurring_occurrences.order(:due_on).first
+
+ get bill_url(mine, display: "pane", frame: "pane_x", occurrence: stranger.id)
+
+ assert_response :success
+ assert_match I18n.t("bills.summary.remaining", amount: "$15.99"), response.body
+ assert_no_match(/\$40\.00/, response.body)
+ end
+
+ # Detection has been creating recurring rows from bank data since long before
+ # Bills existed, so anyone upgrading meets a page of bills nobody confirmed.
+ test "a family that has never worked with Bills is told where its bills came from" do
+ create_bill(name: "Rent", amount: 2150)
+ create_bill(name: "Streaming", amount: 20)
+
+ get bills_url
+
+ assert_response :success
+ assert_match I18n.t("bills.index.detected_review", count: 2), response.body
+ end
+
+ # The prompt carries no stored state, so it has to clear itself off evidence
+ # that the user has worked with Bills. Each of these is sufficient on its own.
+ test "the prompt clears itself once the user has worked with Bills" do
+ detected = create_bill(name: "Rent", amount: 2150)
+
+ get bills_url
+ assert_match I18n.t("bills.index.detected_review", count: 1), response.body
+
+ # Declaring a bill by hand.
+ declared = declare_bill(name: "Water", amount: 45, due: Date.current + 3)
+ get bills_url
+ assert_no_match I18n.t("bills.index.detected_review", count: 1), response.body
+ assert_no_match I18n.t("bills.index.detected_review", count: 2), response.body
+ declared.destroy!
+
+ # Dismissing a suggestion.
+ detected.update!(status: :ended)
+ get bills_url
+ assert_no_match I18n.t("bills.index.detected_review", count: 1), response.body
+ detected.update!(status: :active)
+
+ # Recording a payment themselves.
+ occurrence = detected.recurring_occurrences.order(:due_on).first
+ RecurringTransaction::Allocator.new(occurrence).allocate!(amount: "10")
+ get bills_url
+
+ assert_response :success
+ assert_no_match I18n.t("bills.index.detected_review", count: 1), response.body
+ end
+
+ # The page's whole job. A single "Bills $695.60" against $357.48 of visible
+ # rows is a number nothing on screen can account for, so due and reserved
+ # are stated apart and their sum is never shown at all.
+ test "the paycheck view states due and reserved separately and never their sum" do
+ payday = Date.current + 3
+ declare_income(name: "Frito Lay", amount: -1200, payday: payday)
+ declare_bill(name: "Streaming", amount: 20, due: Date.current + 5)
+ # Bigger than one paycheck and due in the next one, so its overflow is
+ # genuinely reserved out of the first.
+ declare_bill(name: "Insurance", amount: 1500, due: payday + 31)
+
+ get bills_url(view: "paycheck")
+
+ assert_response :success
+ assert_match I18n.t("bills.paycheck.due_this_period"), response.body
+ assert_match I18n.t("bills.paycheck.reserved_ahead"), response.body
+ assert_match I18n.t("bills.paycheck.safe_after_bills"), response.body
+
+ paycheck = RecurringTransaction::PaycheckPlanner.new(@family, user: @user).plan
+ .find { |period| period.income.positive? }
+ assert paycheck.due_total.positive?
+ assert paycheck.reserved_total.positive?
+ assert_match money_string(paycheck.due_total), response.body
+ assert_match money_string(paycheck.reserved_total), response.body
+
+ # Asserting the combined figure is simply ABSENT does not work: period
+ # totals can collide across periods by arithmetic, so the test would pass
+ # or fail on a coincidence. What is actually being
+ # pinned is that no label survives for a combined bills figure to render
+ # under -- which fails the moment one is reintroduced.
+ assert_nil I18n.t("bills.paycheck.bills_that_period", default: nil)
+ assert_nil I18n.t("bills.paycheck.obligations_line", default: nil)
+ end
+
+ # The window before the first payday has no income to allocate, so it is
+ # reported above the timeline as a warning rather than drawn as a pay period
+ # with an empty paycheck. The shortfall names the obligation, not the slice
+ # the planner parked in this window, and never asks the user to operate on
+ # the allocation itself.
+ test "the gap before the first payday is a banner, not a period in the timeline" do
+ # The banner reports a shortfall, and a shortfall now means the cash cannot
+ # reach, not merely that the window earns nothing. Pin the balance under the
+ # bill so the condition this test is about actually holds.
+ @family.accounts.where(accountable_type: "Depository").update_all(balance: 100)
+ declare_income(name: "Frito Lay", amount: -1200, payday: Date.current + 4)
+ declare_bill(name: "Watson Property", amount: 2150, due: Date.current + 3)
+
+ get bills_url(view: "paycheck")
+
+ assert_response :success
+ plan = RecurringTransaction::PaycheckPlanner.new(@family, user: @user).plan
+ bridge = plan.find(&:bridge?)
+
+ assert_match I18n.t("bills.paycheck.shortfall_label"), response.body
+ assert_match I18n.t("bills.paycheck.shortfall_amount", amount: money_string(bridge.shortfall)), response.body
+ assert_match I18n.t("bills.paycheck.shortfall_largest"), response.body
+ assert_match "Watson Property", response.body
+ assert_match I18n.t("bills.paycheck.review_plan"), response.body
+
+ # The banner is the whole report on that window, so the timeline holds one
+ # entry per real paycheck and no empty-paycheck row.
+ assert plan.count { |period| !period.bridge? }.positive?
+ assert_no_match I18n.t("bills.paycheck.before_next_paycheck", date: I18n.l(bridge.ends_on + 1, format: :short)),
+ response.body, "the leading window is reported by the banner, not drawn as a pay period"
+
+ assert_no_match(/set-aside|set aside/i, response.body,
+ "nothing on this page asks the user to perform the planner's own bookkeeping")
+ end
+
+ # The strip read the stored next_expected_date column while the plan read
+ # occurrences, so one series could name two different next paydays on one
+ # screen.
+ test "the income strip names the same payday the plan does" do
+ payday = Date.current + 6
+ income = declare_income(name: "Frito Lay", amount: -1200, payday: payday)
+ income.update_columns(next_expected_date: Date.current - 1)
+
+ get bills_url(view: "paycheck")
+
+ assert_response :success
+ assert_match I18n.t("bills.paycheck.income_next_payday", date: I18n.l(payday, format: :short)), response.body
+ assert_no_match(/#{Regexp.escape(I18n.l(Date.current - 1, format: :short))}/, response.body,
+ "the stale column date must not appear anywhere on the page")
+ end
+
+ # An auto-detected inflow sat in the list looking exactly like a real payday
+ # source while moving no number on the page.
+ test "income the planner cannot use says so" do
+ declare_income(name: "Frito Lay", amount: -1200, payday: Date.current + 3)
+ detected = declare_income(name: "To Car Vault", amount: -0.01, payday: Date.current + 2)
+ detected.update!(manual: false)
+ # Something to cover, or the page is the all-clear state and no period
+ # renders a heading at all.
+ declare_bill(name: "Streaming", amount: 20, due: Date.current + 5)
+
+ get bills_url(view: "paycheck")
+
+ assert_response :success
+ assert_match I18n.t("bills.paycheck.income_detected"), response.body
+ assert_match I18n.l(Date.current + 3, format: :short), response.body
+ assert_match I18n.t("bills.paycheck.period_source", source: "Frito Lay"), response.body,
+ "the declared source heads the period, so the detected one two days earlier cannot have sliced it"
+ end
+
+ # Four cards each saying "nothing due" is not a better way to say that
+ # everything is covered.
+ test "an income schedule with nothing to cover renders one state, not empty cards" do
+ declare_income(name: "Frito Lay", amount: -1200, payday: Date.current + 3)
+
+ get bills_url(view: "paycheck")
+
+ assert_response :success
+ assert_match I18n.t("bills.paycheck.all_clear.title"), response.body
+ assert_no_match I18n.t("bills.paycheck.reserved_ahead"), response.body
+ end
+
+ test "every overview row carries its own empty expansion frame" do
+ create_bill(name: "Rent", amount: 2150)
+
+ get bills_url
+
+ assert_response :success
+ assert_match(/]*id="pane_recurring_occurrence_/, response.body)
+ assert_match(/data-turbo-frame="pane_recurring_occurrence_/, response.body)
+ end
+
+ test "the expansion renders into the requesting row frame and can collapse" do
+ bill = create_bill(name: "Rent", amount: 2150)
+
+ get bill_url(bill, display: "pane", frame: "pane_recurring_occurrence_abc123")
+ assert_response :success
+ assert_match(/]*id="pane_recurring_occurrence_abc123"/, response.body)
+
+ get bill_url(bill, display: "pane", frame: "pane_recurring_occurrence_abc123", close: 1)
+ assert_response :success
+ assert_match(/]*id="pane_recurring_occurrence_abc123"><\/turbo-frame>/, response.body)
+ assert_no_match I18n.t("bills.detail.rules"), response.body
+ end
+
+ test "the detail pane tells the bill's story inside its frame" do
+ bill = create_bill(name: "Rent", amount: 2150)
+ occurrence = bill.recurring_occurrences.order(:due_on).first
+ entry = accounts(:depository).entries.create!(
+ date: Date.current, amount: 2150, currency: "USD", name: "WATSON PROPERTY",
+ entryable: Transaction.new
+ )
+ RecurringTransaction::Allocator.new(occurrence).allocate!(amount: "2150", entry: entry)
+
+ get bill_url(bill, display: "pane")
+
+ assert_response :success
+ assert_match(/]*id="bill_detail"/, response.body, "no frame param falls back to a stable id")
+ assert_match I18n.t("bills.detail.recent_payments"), response.body
+ assert_match "WATSON PROPERTY", response.body
+
+ # The expansion answers "what is going on with this bill" and stops there.
+ # The matching rules and the per-year table are configuration and
+ # reference material, and they belong to the bill's page.
+ assert_no_match I18n.t("bills.detail.rules"), response.body,
+ "the expansion is not a second detail view"
+ assert_no_match I18n.t("bills.detail.key_metrics"), response.body
+ assert_no_match(/ bill_url(sub),
+ "expansion" => bill_url(sub, display: "pane", frame: "x") }.each do |label, url|
+ get url
+ assert_response :success
+ assert_match I18n.t("bills.detail.trial_chip", date: I18n.l(Date.current + 5, format: :short)),
+ response.body, "the #{label} lost the trial chip"
+ assert_match I18n.t("bills.detail.renews_chip", date: I18n.l(Date.current + 30, format: :short)),
+ response.body, "the #{label} lost the renewal date"
+ end
+
+ get bill_url(sub)
+ assert_match I18n.t("bills.detail.price_changes"), response.body,
+ "the bill's page keeps the price history"
+ end
+
+ test "notices surface trials, renewals and price changes" do
+ sub = create_bill(name: "STREAMFLIX", amount: 24.99)
+ sub.update!(bill_type: "subscription", trial_ends_on: Date.current + 3)
+ sub.recurring_price_changes.create!(
+ effective_on: Date.current - 5, previous_amount: 19.99, new_amount: 24.99,
+ currency: "USD", source: "detected"
+ )
+
+ get bills_url
+
+ assert_response :success
+ assert_match "trial ends #{I18n.l(Date.current + 3, format: :long)}", response.body
+ assert_match "changed price", response.body
+ end
+
+ test "the ical feed serves upcoming occurrences with a member token and rejects garbage" do
+ create_bill(name: "Rent", amount: 2150)
+
+ get bills_feed_url(token: @family.bills_feed_token_for(@user))
+ assert_response :success
+ assert_match "BEGIN:VCALENDAR", response.body
+ assert_match "Rent", response.body
+
+ get bills_feed_url(token: "tampered")
+ assert_response :not_found
+ end
+
+ test "reset_feed_token rotates the family token and returns to the calendar" do
+ old_token = @family.bills_feed_token!
+
+ post reset_feed_token_bills_url
+
+ assert_redirected_to bills_path(view: "calendar")
+ assert_equal I18n.t("bills.reset_feed_token.done"), flash[:notice]
+ assert_not_equal old_token, @family.reload.bills_feed_token
+ end
+
+ test "reset_feed_token refuses GET" do
+ # GET /bills/reset_feed_token falls through to bills#show (id:
+ # "reset_feed_token"), which 404s on lookup; the point is that it can
+ # never reach the reset action.
+ route = Rails.application.routes.recognize_path("/bills/reset_feed_token", method: :get)
+ assert_equal "show", route[:action], "GET must never reach the reset action"
+
+ get "/bills/reset_feed_token"
+ assert_response :not_found
+ end
+
+ test "index renders an empty state with no bills" do
+ get bills_url
+
+ assert_response :success
+ assert_match I18n.t("bills.index.empty.title"), response.body
+ end
+
+ # A cancellation date does not stop the schedule, so the same bill can read
+ # "Cancelled" on one surface and "Overdue" on another. The detail surfaces
+ # have to admit that rather than let the two claims sit apart.
+ test "a cancelled but still-scheduled bill says so, with the action that stops it" do
+ bill = create_bill(name: "Streamly", amount: 15, bill_type: "subscription",
+ cancelled_on: 3.days.ago.to_date)
+ assert bill.cancelled_on.present? && bill.active?, "premise: cancelled yet still running"
+
+ get bill_url(bill)
+ assert_response :success
+ assert_includes response.body,
+ I18n.t("bills.cancelled_still_scheduled", date: I18n.l(bill.cancelled_on, format: :short))
+ assert_includes response.body, toggle_status_recurring_transaction_path(bill)
+ end
+
+ test "a paused bill does not repeat the cancellation notice" do
+ bill = create_bill(name: "Streamly", amount: 15, bill_type: "subscription",
+ cancelled_on: 3.days.ago.to_date, status: "paused")
+ get bill_url(bill)
+ assert_response :success
+ refute_includes response.body,
+ I18n.t("bills.cancelled_still_scheduled", date: I18n.l(bill.cancelled_on, format: :short))
+ end
+
+ # The row carries one line of context, so anything on it has to earn the
+ # space. These pin the three things that were not earning it.
+ test "the paid-from account is quiet when every bill uses the same one" do
+ create_bill(name: "Netflix", amount: 15.99)
+ create_bill(name: "Spotify", amount: 11.99)
+ assert_equal 1, @family.recurring_transactions.distinct.count(:account_id),
+ "premise: a single account across all bills"
+
+ get bills_url
+ refute_includes response.body, I18n.t("bills.paid_from", account: accounts(:depository).name),
+ "repeating one account name down every row says nothing"
+ end
+
+ test "the paid-from account returns as soon as it tells rows apart" do
+ create_bill(name: "Netflix", amount: 15.99)
+ create_bill(name: "Amex bill", amount: 40, account: accounts(:credit_card))
+ assert_operator @family.recurring_transactions.distinct.count(:account_id), :>, 1
+
+ get bills_url
+ assert_includes response.body, I18n.t("bills.paid_from", account: accounts(:depository).name)
+ assert_includes response.body, I18n.t("bills.paid_from", account: accounts(:credit_card).name)
+ end
+
+ test "autopay reads on the bill's line rather than in the action slot" do
+ create_bill(name: "Netflix", amount: 15.99, autopay: true,
+ payment_url: "https://example.com/pay")
+
+ get bills_url
+ assert_includes response.body, I18n.t("recurring_transactions.pay_action.autopay")
+ refute_includes response.body, "refresh-cw",
+ "autopay is a state; the row's one action position belongs to a verb"
+ assert_includes response.body, "https://example.com/pay",
+ "the portal stays reachable, just not as the row's headline action"
+ end
+
+ # Pause, inactive and paused were three words for one thing, and the filter
+ # asked for the one the button never writes.
+ test "a bill you paused is findable under Paused" do
+ bill = create_bill(name: "Gym", amount: 40)
+ post toggle_status_recurring_transaction_path(bill)
+ assert_equal "inactive", bill.reload.status,
+ "premise: the Pause button stores inactive, not paused"
+
+ get bills_url(view: "all", q: { status: "paused" })
+ assert_includes response.body, "Gym",
+ "the filter has to ask for what the button actually writes"
+ end
+
+ test "the word for a paused bill is the same everywhere the user sees it" do
+ bill = create_bill(name: "Gym", amount: 40)
+ post toggle_status_recurring_transaction_path(bill)
+
+ # The badge, the filter option and the confirmation all have to agree.
+ # "Pause" stays the verb on the button; "Paused" is the state.
+ state = I18n.t("recurring_transactions.status.#{bill.reload.status}")
+ assert_equal "Paused", state
+ assert_equal state, I18n.t("bills.all.status_filters.paused")
+ assert_match(/#{state}/, I18n.t("recurring_transactions.marked_inactive"))
+ end
+
+ # toggle_status is shared with Settings > Recurring, which manages income and
+ # transfers too, so its confirmation must not talk about bills.
+ test "the pause confirmation does not assume the record is a bill" do
+ [ "recurring_transactions.marked_inactive", "recurring_transactions.marked_active" ].each do |key|
+ refute_match(/bills?/i, I18n.t(key),
+ "#{key} is shown on the shared Recurring surface as well as Bills")
+ end
+ end
+
+ test "the status filter does not offer words for states nobody can reach" do
+ # `ended` only ever comes from dismissing a suggestion, so it is labelled
+ # for what produced it rather than as a bill lifecycle.
+ assert_equal "Dismissed", I18n.t("bills.all.status_filters.ended")
+ assert_equal "Dismissed", I18n.t("recurring_transactions.status.ended")
+ end
+
+ # The expansion and the drawer used to be two templates over one action, so
+ # they drifted, and the fix made them render the SAME partial -- which traded
+ # a disagreement for a duplication: two surfaces answering one question.
+ #
+ # They now answer different ones. What has to stay true is that nothing was
+ # lost on the way, and that the shallower surface never quietly grows into
+ # the deeper one again. So: the expansion is a strict subset of the page, and
+ # every section the old shared partial rendered still exists somewhere.
+ test "the expansion is a subset of the bill's page, and nothing was dropped" do
+ bill = create_bill(name: "Power Co", amount: 80, notes: "Account 4821")
+ past = bill.recurring_occurrences.create!(
+ family: @family, original_due_on: 2.months.ago.to_date,
+ due_on: 2.months.ago.to_date, currency: "USD"
+ )
+ entry = accounts(:depository).entries.create!(
+ date: 2.months.ago.to_date, amount: 78.50, currency: "USD",
+ name: "POWER CO AUTOPAY", entryable: Transaction.new
+ )
+ RecurringTransaction::Allocator.new(past).allocate!(amount: "78.50", entry: entry)
+ bill.recurring_price_changes.create!(
+ effective_on: 3.months.ago.to_date, previous_amount: 70, new_amount: 80,
+ currency: "USD", source: "detected"
+ )
+
+ get bill_url(bill)
+ assert_response :success
+ page = response.body
+
+ get bill_url(bill, display: "pane", frame: "x"), headers: { "Turbo-Frame" => "x" }
+ assert_response :success
+ pane = response.body
+
+ # Every section the shared partial used to render still has a home.
+ everything = %w[rules history_title average annualized ytd upcoming
+ recent_payments history notes last_account key_metrics
+ price_changes]
+ homeless = everything.reject { |key| page.include?(I18n.t("bills.detail.#{key}")) }
+ assert_empty homeless, "relocating the detail must not delete any of it"
+
+ # And the expansion adds nothing of its own that the page lacks.
+ shown_in_pane = everything.select { |key| pane.include?(I18n.t("bills.detail.#{key}")) }
+ assert_equal shown_in_pane, shown_in_pane & everything.select { |key| page.include?(I18n.t("bills.detail.#{key}")) },
+ "the expansion must stay a subset, never a second detail view"
+
+ [ "POWER CO AUTOPAY", "$78.50" ].each do |fact|
+ assert_includes page, fact, "the page is missing #{fact}"
+ assert_includes pane, fact, "the expansion is missing #{fact}"
+ end
+ assert_includes page, "Account 4821", "notes belong to the page"
+ end
+
+ # "Something changed" is only useful if the thing you can still act on is
+ # the thing you see first. Notices used to sort by date ascending, so a
+ # month-old one-dollar rise outranked a trial converting tomorrow.
+ test "notices lead with what is still actionable, not with what is oldest" do
+ trial = create_bill(name: "Streamflix", amount: 20)
+ trial.update!(bill_type: "subscription", trial_ends_on: Date.current + 1)
+
+ big = create_bill(name: "Gym", amount: 90)
+ big.recurring_price_changes.create!(effective_on: 20.days.ago.to_date,
+ previous_amount: 90, new_amount: 200, currency: "USD", source: "detected")
+
+ small = create_bill(name: "Power", amount: 60)
+ small.recurring_price_changes.create!(effective_on: 30.days.ago.to_date,
+ previous_amount: 60, new_amount: 61, currency: "USD", source: "detected")
+
+ get bills_url
+ assert_response :success
+ body = response.body
+
+ trial_at = body.index("Streamflix")
+ big_at = body.index("Gym changed price")
+ small_at = body.index("Power changed price")
+
+ assert trial_at < small_at, "a trial converting tomorrow must outrank a month-old $1 rise"
+ assert big_at < small_at, "a 122% rise must outrank a 2% one"
+ end
+
+ test "small changes collapse rather than pushing the worklist down" do
+ urgent = create_bill(name: "Streamflix", amount: 20)
+ urgent.update!(bill_type: "subscription", trial_ends_on: Date.current + 1)
+
+ 3.times do |i|
+ quiet = create_bill(name: "Utility #{i}", amount: 60 + i)
+ quiet.recurring_price_changes.create!(effective_on: (20 + i).days.ago.to_date,
+ previous_amount: 60 + i, new_amount: 61 + i, currency: "USD", source: "detected")
+ end
+
+ get bills_url
+ assert_response :success
+ assert_match I18n.t("bills.index.notices_routine", count: 3), response.body,
+ "the quiet ones collapse behind a count"
+ # Collapsed, not dropped: a hidden notice is still a dead end.
+ 3.times { |i| assert_match "Utility #{i}", response.body }
+ end
+
+ test "a price notice says how big the change was" do
+ bill = create_bill(name: "Gym", amount: 90)
+ bill.recurring_price_changes.create!(effective_on: 5.days.ago.to_date,
+ previous_amount: 90, new_amount: 200, currency: "USD", source: "detected")
+
+ get bills_url
+ assert_response :success
+ assert_match "+122%", response.body,
+ "from-and-to alone never said whether a change was worth caring about"
+ end
+
+ # Within a group the order still has to mean something: the partition
+ # separates urgent from routine, but only the sort decides what leads
+ # inside each half.
+ test "among changes of equal weight the most recent leads" do
+ [ [ "Oldest", 30 ], [ "Middle", 20 ], [ "Newest", 5 ] ].each do |name, days|
+ bill = create_bill(name: name, amount: 60)
+ bill.recurring_price_changes.create!(effective_on: days.days.ago.to_date,
+ previous_amount: 60, new_amount: 61, currency: "USD", source: "detected")
+ end
+
+ get bills_url
+ assert_response :success
+ positions = %w[Newest Middle Oldest].map { |n| response.body.index("#{n} changed price") }
+ assert_equal positions.sort, positions,
+ "equally small changes should read newest first, not oldest first"
+ end
+
+ # --- Onboarding: detection from the Bills page ---
+
+ test "empty page with transaction history offers detection beside Add bill" do
+ create_transaction_entry(name: "Coffee", amount: 20, date: Date.current)
+
+ get bills_url
+
+ assert_response :success
+ assert_select "a[href=?][data-turbo-method=post]", detect_bills_path
+ assert_match I18n.t("bills.index.empty.action"), response.body
+ end
+
+ test "empty page with no transactions hides detection and explains why" do
+ Entry.where(account: @family.accounts).delete_all
+
+ get bills_url
+
+ assert_response :success
+ assert_select "a[href=?]", detect_bills_path, count: 0
+ assert_match I18n.t("bills.index.empty.no_history_description"), response.body
+ end
+
+ test "detect creates suggestions, counts only them, and the strip offers review" do
+ 3.times do |i|
+ create_transaction_entry(name: "GYM MEMBERSHIP", amount: 40, date: Date.current - i.months)
+ end
+
+ post detect_bills_url
+
+ assert_redirected_to bills_path
+ assert_equal I18n.t("bills.detect.found", count: 1), flash[:notice]
+
+ follow_redirect!
+ assert_match "GYM MEMBERSHIP", response.body
+ assert_match I18n.t("recurring_transactions.suggested.confirm"), response.body
+ end
+
+ test "detect does not resurrect a dismissed pattern" do
+ # Same identity as the entries below (name, currency, account, blank
+ # dedup scope): the ended tombstone claims the pattern and blocks it.
+ create_bill(name: "GYM MEMBERSHIP", amount: 40, status: "ended", dedup_scope: "", manual: false)
+ 3.times do |i|
+ create_transaction_entry(name: "GYM MEMBERSHIP", amount: 40, date: Date.current - i.months)
+ end
+
+ post detect_bills_url
+
+ assert_equal I18n.t("bills.detect.none_found"), flash[:notice]
+ assert_equal 0, @family.recurring_transactions.suggested.count
+ end
+
+ test "detect refuses GET" do
+ # GET /bills/detect falls through to bills#show (id: "detect"), which
+ # 404s on lookup; the point is that it can never reach the detect action.
+ route = Rails.application.routes.recognize_path("/bills/detect", method: :get)
+ assert_equal "show", route[:action], "GET must never reach the detect action"
+
+ get "/bills/detect"
+ assert_response :not_found
+ end
+
+ test "detect on an upgraded instance reconstructs paid history" do
+ last_month_ninth = Date.current.beginning_of_month + 8.days - 1.month
+ bill = create_bill(name: "CITY WATER", amount: 80, dedup_scope: "",
+ expected_day_of_month: 9,
+ last_occurrence_date: last_month_ninth,
+ next_expected_date: last_month_ninth + 1.month)
+ create_transaction_entry(name: "CITY WATER", amount: 80, date: last_month_ninth)
+ RecurringOccurrence.where(recurring_transaction: bill).delete_all
+
+ post detect_bills_url
+
+ assert bill.recurring_occurrences.paid.where(due_on: last_month_ninth).exists?,
+ "the first-run backfill closes history a real entry anchors"
+ end
+
+ test "index materializes occurrences for an upgraded instance" do
+ bill = create_bill(name: "Rent", amount: 1200)
+ RecurringOccurrence.where(recurring_transaction: bill).delete_all
+
+ get bills_url
+
+ assert_response :success
+ assert_operator bill.recurring_occurrences.count, :>, 0
+ assert_match "Rent", response.body
+ end
+
+ test "index creates no occurrences when the family has no active series" do
+ get bills_url
+
+ assert_response :success
+ assert_equal 0, @family.recurring_occurrences.count
+ end
+
+ test "an overdue-only family sees its overdue bill, not the empty state" do
+ overdue_day = 10.days.ago.to_date
+ bill = create_bill(name: "Late bill", amount: 75,
+ expected_day_of_month: overdue_day.day,
+ last_occurrence_date: 2.months.ago.to_date,
+ next_expected_date: overdue_day)
+ # Strip the future so only the overdue occurrence remains: the empty-state
+ # condition used to ignore @overdue and rendered both at once.
+ bill.recurring_occurrences.where("due_on > ?", Date.current).delete_all
+
+ get bills_url
+
+ assert_response :success
+ assert_match "Late bill", response.body
+ assert_no_match I18n.t("bills.index.empty.title"), response.body
+ end
+
+ test "AI chips and the review button need both consent and a provider" do
+ Provider::Registry.stubs(:preferred_llm_provider).returns(Object.new)
+ get bills_url
+ assert_response :success
+ # Apostrophe-free fragment: response bodies HTML-escape apostrophes.
+ assert_match "due before my next paycheck", response.body
+ assert_match I18n.t("bills.index.review_with_ai"), response.body
+ # A menu item, not a third header button: it still has to POST into the
+ # sidebar chat frame and open the sidebar, or it seeds a chat nobody sees.
+ assert_select "header div[role=menu] form[action=?]", ai_review_bills_path do
+ assert_select "button[data-turbo-frame=?][data-action=?]", "sidebar_chat", "app-layout#openRightSidebar"
+ end
+
+ @user.update!(ai_enabled: false)
+ get bills_url
+ assert_response :success
+ assert_no_match "due before my next paycheck", response.body
+ assert_no_match I18n.t("bills.index.review_with_ai"), response.body
+
+ # Consent without a configured provider is a button to a dead chat.
+ @user.update!(ai_enabled: true)
+ Provider::Registry.stubs(:preferred_llm_provider).returns(nil)
+ get bills_url
+ assert_response :success
+ assert_no_match "due before my next paycheck", response.body
+ assert_no_match I18n.t("bills.index.review_with_ai"), response.body
+ end
+
+ test "the bill page offers smart configure only when AI is available" do
+ bill = create_bill(name: "Power Co", amount: 80)
+
+ Provider::Registry.stubs(:preferred_llm_provider).returns(Object.new)
+ get bill_url(bill)
+ assert_response :success
+ assert_match smart_configuration_bill_path(bill), response.body
+
+ Provider::Registry.stubs(:preferred_llm_provider).returns(nil)
+ get bill_url(bill)
+ assert_response :success
+ assert_no_match smart_configuration_bill_path(bill), response.body
+ end
+
+ test "price changes on accounts the member cannot reach stay out of notices and the rollup" do
+ hidden = create_bill(name: "Hidden brokerage sub", amount: 24.99, account: accounts(:investment))
+ hidden.update!(bill_type: "subscription")
+ hidden.recurring_price_changes.create!(
+ effective_on: Date.current - 5, previous_amount: 19.99, new_amount: 24.99,
+ currency: "USD", source: "detected"
+ )
+ visible = create_bill(name: "Visible sub", amount: 9.99)
+ visible.update!(bill_type: "subscription")
+ visible.recurring_price_changes.create!(
+ effective_on: Date.current - 5, previous_amount: 7.99, new_amount: 9.99,
+ currency: "USD", source: "detected"
+ )
+
+ member = users(:family_member)
+ member.update!(preferences: (member.preferences || {}).merge("preview_features_enabled" => true))
+ sign_in member
+
+ get bills_url
+ assert_response :success
+ assert_match "Visible sub", response.body
+ assert_no_match "Hidden brokerage sub", response.body
+
+ get bills_url(view: "all", q: { bill_type: "subscription" })
+ assert_response :success
+ assert_no_match "Hidden brokerage sub", response.body
+ end
+
+ test "the suggested strip only shows series on accounts the member can reach" do
+ create_suggested(name: "Hidden brokerage sub", account: accounts(:investment))
+ create_suggested(name: "Visible sub", account: accounts(:depository))
+
+ member = users(:family_member)
+ member.update!(preferences: (member.preferences || {}).merge("preview_features_enabled" => true))
+ sign_in member
+ get bills_url
+
+ assert_response :success
+ assert_match "Visible sub", response.body
+ assert_no_match "Hidden brokerage sub", response.body
+ end
+
+ # The matcher no longer suggests income, but a suggestion written before
+ # that rule can still sit on the row. The queue must not render it; the
+ # same pending state on a bill must.
+ test "a pre-existing income suggestion stays out of the payment review queue" do
+ payday = Date.current - 3
+ income = declare_income(name: "ACME PAYROLL", amount: -1840, payday: payday)
+ bill = declare_bill(name: "CITY WATER", amount: 80, due: payday)
+ deposit = create_transaction_entry(name: "ACME PAYROLL", amount: -1900, date: payday)
+ charge = create_transaction_entry(name: "CITY WATER", amount: 85.50, date: payday)
+
+ [ [ income, deposit ], [ bill, charge ] ].each do |series, entry|
+ occurrence = series.recurring_occurrences.order(:due_on).first
+ RecurringTransaction::Allocator.new(occurrence).allocate_matched!(
+ entry: entry, state: "suggested", confidence: 0.7, signals: { name: 0.35 }
+ )
+ end
+
+ get bills_url
+
+ assert_response :success
+ assert_match I18n.t("bills.index.suggestion_line", entry: "CITY WATER", bill: "CITY WATER"),
+ response.body
+ assert_no_match I18n.t("bills.index.suggestion_line", entry: "ACME PAYROLL", bill: "ACME PAYROLL"),
+ response.body
+ end
+
+
+ # The overview groups by calendar month, which is the wrong unit for anyone
+ # paid weekly: four paychecks and four rent payments land in one list. The
+ # markers only earn their place when income actually subdivides the month.
+ test "weekly income marks pay periods inside the month" do
+ travel_to Date.current.beginning_of_month + 9.days
+
+ declare_scheduled_income(frequency: "weekly", weekday: Date.current.wday)
+ declare_weekly_bill
+
+ get bills_url
+
+ assert_response :success
+ assert_match(/due before [A-Z][a-z]{2} \d+/, response.body,
+ "a weekly paycheck should mark its period inside the month")
+ end
+
+ test "monthly income leaves the month undivided" do
+ travel_to Date.current.beginning_of_month + 9.days
+
+ declare_scheduled_income(frequency: "monthly", day_of_month: Date.current.day)
+ declare_weekly_bill
+
+ get bills_url
+
+ assert_response :success
+ assert_no_match(/due before [A-Z][a-z]{2} \d+/, response.body,
+ "one paycheck a month does not subdivide the month, so nothing should be marked")
+ end
+
+ test "no declared income leaves the month undivided" do
+ travel_to Date.current.beginning_of_month + 9.days
+
+ declare_weekly_bill
+
+ get bills_url
+
+ assert_response :success
+ assert_no_match(/due before [A-Z][a-z]{2} \d+/, response.body)
+ end
+
+
+ # The bridge is filtered out of the timeline, and only a shortfall earned a
+ # banner, so a bill due before payday that the cash comfortably covered
+ # appeared nowhere on the page built to answer what is due before payday.
+ test "a covered bridge window still shows what is due before payday" do
+ @family.accounts.where(accountable_type: "Depository").update_all(balance: 5_000)
+ declare_income(name: "Frito Lay", amount: -1200, payday: Date.current + 5)
+ declare_bill(name: "Curbside Cuts", amount: 150, due: Date.current + 2)
+
+ get bills_url(view: "paycheck")
+
+ assert_response :success
+ assert_match I18n.t("bills.paycheck.bridge_label"), response.body
+ assert_match "Curbside Cuts", response.body
+ assert_no_match(/#{Regexp.escape(I18n.t("bills.paycheck.shortfall_label"))}/, response.body,
+ "the cash covers it, so nothing is short")
+ end
+
+ test "a covered bridge is never rendered as a timeline row" do
+ @family.accounts.where(accountable_type: "Depository").update_all(balance: 5_000)
+ declare_income(name: "Frito Lay", amount: -1200, payday: Date.current + 5)
+ declare_bill(name: "Curbside Cuts", amount: 150, due: Date.current + 2)
+
+ get bills_url(view: "paycheck")
+
+ assert_response :success
+ assert_no_match(/-\$150\.00/, response.body,
+ "a timeline row prints income minus obligations, which is a deficit on a window that earns nothing")
+ end
+
+ private
+
+ def declare_scheduled_income(frequency:, weekday: nil, day_of_month: nil)
+ series = @family.recurring_transactions.create!(
+ name: "Payday", account: accounts(:depository), amount: -1200,
+ currency: "USD", status: "active", bill_type: "income", manual: true,
+ dedup_scope: "payday--1200", last_occurrence_date: 1.week.ago.to_date,
+ next_expected_date: Date.current,
+ expected_day_of_month: day_of_month || Date.current.day
+ )
+ series.recurrence_rules.create!(frequency: frequency, interval: 1,
+ weekday: weekday, day_of_month: day_of_month)
+ # Occurrences generate on create, before the rule exists, so a series
+ # built rule-last starts out on the fallback monthly cadence.
+ series.recurring_occurrences.destroy_all
+ RecurringTransaction::OccurrenceGenerator.new(series.reload).generate!
+ series
+ end
+
+ def declare_weekly_bill
+ series = @family.recurring_transactions.create!(
+ name: "Rent", account: accounts(:depository), amount: 400,
+ currency: "USD", status: "active", bill_type: "bill", manual: true,
+ dedup_scope: "rent-400", last_occurrence_date: 1.week.ago.to_date,
+ next_expected_date: Date.current, expected_day_of_month: Date.current.day
+ )
+ series.recurrence_rules.create!(frequency: "weekly", interval: 1,
+ weekday: Date.current.wday)
+ series.recurring_occurrences.destroy_all
+ RecurringTransaction::OccurrenceGenerator.new(series.reload).generate!
+ series
+ end
+
+ # A declared income series anchored to a specific payday, which is what the
+ # planner slices periods by.
+ def declare_income(name:, amount:, payday:)
+ @family.recurring_transactions.create!(
+ name: name, account: accounts(:depository), amount: amount, currency: "USD",
+ bill_type: "income", expected_day_of_month: payday.day, anchor_date: payday,
+ last_occurrence_date: payday, next_expected_date: payday, status: "active", manual: true
+ )
+ end
+
+ # A declared bill anchored to a specific due date. create_bill defaults to
+ # today, which is fine for the overview but puts every bill in the leading
+ # window here, where the whole point is which period a bill lands in.
+ def declare_bill(name:, amount:, due:)
+ @family.recurring_transactions.create!(
+ name: name, account: accounts(:depository), amount: amount, currency: "USD",
+ dedup_scope: amount.to_s, bill_type: "bill",
+ expected_day_of_month: due.day, anchor_date: due,
+ last_occurrence_date: due, next_expected_date: due, status: "active", manual: true
+ )
+ end
+
+ def money_string(amount)
+ ApplicationController.helpers.format_money(Money.new(amount, @family.currency))
+ end
+
+ def create_transaction_entry(name:, amount:, date:, account: accounts(:depository))
+ account.entries.create!(
+ date: date, amount: amount, currency: "USD", name: name,
+ entryable: Transaction.new
+ )
+ end
+
+ def create_suggested(name:, account:)
+ @family.recurring_transactions.create!(
+ name: name, account: account, amount: 15, currency: "USD",
+ dedup_scope: name, expected_day_of_month: 5,
+ last_occurrence_date: 1.month.ago.to_date,
+ next_expected_date: Date.current,
+ status: "suggested", manual: false
+ )
+ end
+
+ def create_bill(name:, amount:, **overrides)
+ @family.recurring_transactions.create!({
+ account: accounts(:depository),
+ name: name,
+ amount: amount,
+ # Defaults to the amount so same-name test bills (separate
+ # subscription tiers) coexist under the amount-free identity indexes,
+ # the same way the detector stamps a second series for one identifier.
+ dedup_scope: amount.to_s,
+ currency: "USD",
+ expected_day_of_month: Date.current.day,
+ last_occurrence_date: 1.month.ago.to_date,
+ next_expected_date: Date.current,
+ status: "active"
+ }.merge(overrides))
+ end
+end
diff --git a/test/controllers/bills_feeds_controller_test.rb b/test/controllers/bills_feeds_controller_test.rb
new file mode 100644
index 000000000..fcdf30e99
--- /dev/null
+++ b/test/controllers/bills_feeds_controller_test.rb
@@ -0,0 +1,128 @@
+require "test_helper"
+
+class BillsFeedsControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ @family = families(:dylan_family)
+ @user = users(:family_admin)
+ # The feed is preview-gated on the member the token names.
+ @user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => true))
+ @family.recurring_transactions.destroy_all
+ create_bill(name: "Rent", amount: 2150)
+ end
+
+ test "a member token serves the feed without a session" do
+ get bills_feed_url(token: @family.bills_feed_token_for(@user))
+
+ assert_response :success
+ assert_match "BEGIN:VCALENDAR", response.body
+ assert_match "Rent", response.body
+ end
+
+ test "an unknown token is not found" do
+ get bills_feed_url(token: "nonsense")
+
+ assert_response :not_found
+ end
+
+ # The old URLs carried a deterministic signed family id with no expiry and
+ # no revocation. Breaking them is the point of the change: every URL minted
+ # under the old scheme stops working.
+ test "an old-style signed token no longer works" do
+ signed = Rails.application.message_verifier("bills-ical-feed").generate(@family.id)
+
+ get bills_feed_url(token: signed)
+
+ assert_response :not_found
+ end
+
+ # The stored family secret is the revocation root, not a credential: putting
+ # it in a URL would hand every member the whole family's obligations.
+ test "the raw family secret is not itself a feed token" do
+ get bills_feed_url(token: @family.bills_feed_token!)
+
+ assert_response :not_found
+ end
+
+ # Sharing is per account, so the feed has to honor it: a member who cannot
+ # reach an account in the app must not receive its bills by calendar.
+ test "a member's feed carries only the bills that member can reach" do
+ member = users(:family_member)
+ member.update!(preferences: (member.preferences || {}).merge("preview_features_enabled" => true))
+ # The investment account is the admin's and was never shared.
+ create_bill(name: "Private Brokerage Fee", amount: 95, account: accounts(:investment))
+ create_bill(name: "Gym", amount: 30, account: nil)
+
+ get bills_feed_url(token: @family.bills_feed_token_for(member))
+
+ assert_response :success
+ assert_match "Gym", response.body
+ assert_no_match(/Private Brokerage Fee/, response.body)
+
+ get bills_feed_url(token: @family.bills_feed_token_for(@user))
+
+ assert_match "Gym", response.body
+ assert_match "Private Brokerage Fee", response.body
+ end
+
+ test "resetting the token revokes every member URL and freshly minted ones work" do
+ old_token = @family.bills_feed_token_for(@user)
+ @family.reset_bills_feed_token!
+
+ get bills_feed_url(token: old_token)
+ assert_response :not_found
+
+ get bills_feed_url(token: @family.reload.bills_feed_token_for(@user))
+ assert_response :success
+ assert_match "BEGIN:VCALENDAR", response.body
+ end
+
+ # The feed is sessionless, so the preview gate has to travel with the token:
+ # a retained calendar URL must die the moment its member opts out, not only
+ # after an explicit token reset.
+ test "a retained URL stops working when the member opts out of preview" do
+ token = @family.bills_feed_token_for(@user)
+
+ @user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => false))
+ get bills_feed_url(token: token)
+ assert_response :not_found
+
+ @user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => true))
+ get bills_feed_url(token: token)
+ assert_response :success
+ end
+
+ test "the feed honors the family recurring switch" do
+ token = @family.bills_feed_token_for(@user)
+ @family.update!(recurring_transactions_disabled: true)
+
+ get bills_feed_url(token: token)
+
+ assert_response :not_found
+ end
+
+ test "the family secret generates lazily exactly once" do
+ assert_nil @family.bills_feed_token
+
+ first = @family.bills_feed_token!
+ second = @family.bills_feed_token!
+
+ assert first.present?
+ assert_equal first, second
+ end
+
+ private
+
+ def create_bill(name:, amount:, account: accounts(:depository))
+ @family.recurring_transactions.create!(
+ account: account,
+ name: name,
+ amount: amount,
+ dedup_scope: amount.to_s,
+ currency: "USD",
+ expected_day_of_month: Date.current.day,
+ last_occurrence_date: 1.month.ago.to_date,
+ next_expected_date: Date.current,
+ status: "active"
+ )
+ end
+end
diff --git a/test/controllers/recurring_allocations_controller_test.rb b/test/controllers/recurring_allocations_controller_test.rb
new file mode 100644
index 000000000..bdf723157
--- /dev/null
+++ b/test/controllers/recurring_allocations_controller_test.rb
@@ -0,0 +1,91 @@
+require "test_helper"
+
+class RecurringAllocationsControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ sign_in @user = users(:family_admin)
+ @user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => true))
+ @family = @user.family
+ # credit_card is shared read-only with family_member in the fixtures, so
+ # this series is visible to them but must never be mutable by them.
+ @series = @family.recurring_transactions.create!(
+ name: "Card Annual Fee", account: accounts(:credit_card), amount: 95,
+ currency: "USD", expected_day_of_month: Date.current.day,
+ anchor_date: Date.current, last_occurrence_date: Date.current,
+ next_expected_date: Date.current, status: "active", manual: true
+ )
+ @occurrence = @series.recurring_occurrences.order(:due_on).first
+ end
+
+ test "allocation writes redirect when the family has turned recurring transactions off" do
+ @family.update!(recurring_transactions_disabled: true)
+
+ post recurring_occurrence_allocations_url(@occurrence), params: { amount: "5.00" }
+
+ assert_redirected_to root_path
+ assert_equal 0, @occurrence.reload.allocations.count
+ end
+
+ test "a read-only account share cannot record a payment" do
+ member = users(:family_member)
+ member.update!(preferences: (member.preferences || {}).merge("preview_features_enabled" => true))
+ sign_in member
+
+ post recurring_occurrence_allocations_url(@occurrence), params: { amount: "5.00" }
+
+ assert_response :not_found
+ assert_equal 0, @occurrence.reload.allocations.count
+ end
+
+ test "a read-only account share cannot unlink, confirm or reject an allocation" do
+ entry = accounts(:credit_card).entries.create!(
+ date: Date.current, amount: 95, currency: "USD", name: "ANNUAL FEE",
+ entryable: Transaction.new
+ )
+ suggestion = RecurringTransaction::Allocator.new(@occurrence).allocate_matched!(
+ entry: entry, state: "suggested", confidence: 0.7, signals: { name: 0.35 }
+ )
+
+ member = users(:family_member)
+ member.update!(preferences: (member.preferences || {}).merge("preview_features_enabled" => true))
+ sign_in member
+
+ post confirm_recurring_allocation_url(suggestion)
+ assert_response :not_found
+ assert suggestion.reload.allocation_suggested?
+
+ post reject_recurring_allocation_url(suggestion)
+ assert_response :not_found
+ assert RecurringAllocation.exists?(suggestion.id)
+
+ delete recurring_allocation_url(suggestion)
+ assert_response :not_found
+ assert RecurringAllocation.exists?(suggestion.id)
+ end
+
+ test "the account owner records the same payment" do
+ post recurring_occurrence_allocations_url(@occurrence), params: { amount: "5.00" }
+
+ assert_redirected_to bills_url
+ assert_equal 5, @occurrence.reload.allocations.sole.allocated_amount
+ end
+
+ test "a valid paid_on records the payment on that date" do
+ paid = 3.days.ago.to_date
+
+ post recurring_occurrence_allocations_url(@occurrence),
+ params: { amount: "5.00", paid_on: paid.iso8601 }
+
+ assert_redirected_to bills_url
+ assert_equal paid, @occurrence.reload.allocations.sole.paid_on
+ end
+
+ test "a malformed paid_on is rejected instead of being recorded as today" do
+ post recurring_occurrence_allocations_url(@occurrence),
+ params: { amount: "5.00", paid_on: "not-a-date" }
+
+ assert_redirected_to bills_url
+ assert flash[:alert].present?
+ assert_equal 0, @occurrence.reload.allocations.count,
+ "a nil-cast date would have silently defaulted the payment to today"
+ end
+end
diff --git a/test/controllers/recurring_occurrences_controller_test.rb b/test/controllers/recurring_occurrences_controller_test.rb
new file mode 100644
index 000000000..75f8795b2
--- /dev/null
+++ b/test/controllers/recurring_occurrences_controller_test.rb
@@ -0,0 +1,389 @@
+require "test_helper"
+
+class RecurringOccurrencesControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ sign_in @user = users(:family_admin)
+ @user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => true))
+ @family = @user.family
+ @series = recurring_transactions(:netflix_subscription)
+ @occurrence = @series.recurring_occurrences.create!(
+ family: @family,
+ original_due_on: Date.current + 5,
+ due_on: Date.current + 5,
+ currency: "USD"
+ )
+ ensure_tailwind_build
+ end
+
+ # Resolving a payment is the ACT surface, so it owns the drawer slot -- the
+ # same one transactions, trades and transfers use. The bill's own story moved
+ # to its own page, so nothing competes for it.
+ test "show renders the occurrence dialog in a single drawer frame" do
+ get recurring_occurrence_url(@occurrence), headers: { "Turbo-Frame" => "drawer" }
+
+ assert_response :success
+ assert_equal 1, response.body.scan(/]*id="drawer"/).size
+ end
+
+ # The dialog used to list the fifteen most RECENT transactions in the window.
+ # On a real bill that hid every plausible match behind unrelated larger
+ # charges, so the exact payment was invisible.
+ test "the transaction list leads with the closest amount, not the most recent" do
+ # Older than every distractor, so date order pushes it past the fifteen-row
+ # cut. Everything sits in the past, because the window is clipped at today.
+ exact = accounts(:depository).entries.create!(
+ date: @occurrence.due_on - 35, amount: 15.99, currency: "USD",
+ name: "Netflix charge", entryable: Transaction.new
+ )
+ 20.times do |i|
+ accounts(:depository).entries.create!(
+ date: @occurrence.due_on - 6 - i, amount: 500 + i, currency: "USD",
+ name: "Unrelated big charge #{i}", entryable: Transaction.new
+ )
+ end
+
+ get recurring_occurrence_url(@occurrence), headers: { "Turbo-Frame" => "drawer" }
+
+ assert_response :success
+ assert_match exact.name, response.body,
+ "the transaction matching the bill amount must survive the fifteen-row cut"
+ end
+
+ # The mission's own example. The picker ranked by amount distance and nothing
+ # else, so a $6.44 Twitch charge scored exactly as well as the $6.44 7-Eleven
+ # charge for a 7-Eleven bill. It now asks the matcher, whose identity filter
+ # rules the others out entirely rather than merely ranking them lower.
+ test "the suggested payment is the one the matcher would have picked" do
+ seven_eleven = merchants(:netflix)
+ series = @family.recurring_transactions.create!(
+ name: "7-Eleven Gold Pass", merchant: seven_eleven, account: accounts(:depository),
+ amount: 6.44, currency: "USD", expected_day_of_month: Date.current.day,
+ anchor_date: Date.current, last_occurrence_date: Date.current,
+ next_expected_date: Date.current, status: "active", manual: true,
+ dedup_scope: "gold-pass"
+ )
+ occurrence = series.recurring_occurrences.order(:due_on).first
+
+ match = accounts(:depository).entries.create!(
+ date: occurrence.due_on, amount: 6.44, currency: "USD",
+ name: "7-ELEVEN GOLD PASS TM", entryable: Transaction.new(merchant: seven_eleven)
+ )
+ decoys = [ "Twitch", "Grok", "Steam" ].map do |name|
+ accounts(:depository).entries.create!(
+ date: occurrence.due_on, amount: 6.44, currency: "USD",
+ name: "#{name} subscription", entryable: Transaction.new
+ )
+ end
+
+ get recurring_occurrence_url(occurrence), headers: { "Turbo-Frame" => "drawer" }
+ assert_response :success
+
+ ranked = @controller.view_assigns["ranked_candidates"]
+ assert_equal [ match.id ], ranked.map { |entry, _| entry.id },
+ "only the 7-Eleven charge belongs to a 7-Eleven bill"
+
+ fallback = @controller.view_assigns["other_entries"].map(&:id)
+ decoys.each { |decoy| assert_includes fallback, decoy.id, "unrelated charges stay browsable, just not suggested" }
+
+ # And the reasons are the matcher's own, not a percentage.
+ assert_match I18n.t("bills.match.same_merchant"), response.body
+ assert_match I18n.t("bills.match.exact_amount"), response.body
+ end
+
+ # Corrections are permanently sticky in the matcher. The picker never checked
+ # the rejection table, so a transaction the user had already dismissed could
+ # come straight back to the top of the list.
+ test "a rejected pairing never returns as a suggestion" do
+ series = @family.recurring_transactions.create!(
+ name: "CITY WATER", account: accounts(:depository), amount: 80, currency: "USD",
+ expected_day_of_month: Date.current.day, anchor_date: Date.current,
+ last_occurrence_date: Date.current, next_expected_date: Date.current,
+ status: "active", manual: true
+ )
+ occurrence = series.recurring_occurrences.order(:due_on).first
+ entry = accounts(:depository).entries.create!(
+ date: occurrence.due_on, amount: 80, currency: "USD",
+ name: "CITY WATER", entryable: Transaction.new
+ )
+
+ get recurring_occurrence_url(occurrence), headers: { "Turbo-Frame" => "drawer" }
+ assert_equal [ entry.id ], @controller.view_assigns["ranked_candidates"].map { |candidate, _| candidate.id }
+
+ RecurringMatchRejection.create!(recurring_transaction: series, entry: entry)
+
+ get recurring_occurrence_url(occurrence), headers: { "Turbo-Frame" => "drawer" }
+ assert_empty @controller.view_assigns["ranked_candidates"]
+ end
+
+ # One transaction can legitimately pay more than one bill, which is why the
+ # Allocator guards capacity at write time rather than at read time. Excluding
+ # every entry that already has a confirmed allocation would have quietly
+ # deleted split payments from the picker.
+ test "an entry already allocated to another bill is still offered here" do
+ first, second = [ "a", "b" ].map do |scope|
+ @family.recurring_transactions.create!(
+ name: "WATSON PROPERTY", account: accounts(:depository), amount: 500, currency: "USD",
+ expected_day_of_month: Date.current.day, anchor_date: Date.current,
+ last_occurrence_date: Date.current, next_expected_date: Date.current,
+ status: "active", manual: true, dedup_scope: scope
+ ).recurring_occurrences.order(:due_on).first
+ end
+
+ entry = accounts(:depository).entries.create!(
+ date: first.due_on, amount: 500, currency: "USD",
+ name: "WATSON PROPERTY", entryable: Transaction.new
+ )
+ RecurringTransaction::Allocator.new(first).allocate!(entry: entry, amount: 200)
+
+ get recurring_occurrence_url(second), headers: { "Turbo-Frame" => "drawer" }
+
+ assert_includes @controller.view_assigns["ranked_candidates"].map { |candidate, _| candidate.id }, entry.id,
+ "the rest of that charge can still pay another bill; over-allocation is refused when it is written"
+ end
+
+ test "searching looks past the date window" do
+ old = accounts(:depository).entries.create!(
+ date: @occurrence.due_on - 300, amount: 15.99, currency: "USD",
+ name: "Ancient Netflix charge", entryable: Transaction.new
+ )
+
+ get recurring_occurrence_url(@occurrence), headers: { "Turbo-Frame" => "drawer" }
+ assert_no_match old.name, response.body, "outside the window it is not offered by default"
+
+ get recurring_occurrence_url(@occurrence, q: "Ancient"), headers: { "Turbo-Frame" => "drawer" }
+ assert_match old.name, response.body, "a search must be able to reach it"
+ end
+
+ test "mark_paid settles the occurrence" do
+ post mark_paid_recurring_occurrence_url(@occurrence)
+
+ assert_redirected_to bills_url
+ @occurrence.reload
+ assert @occurrence.paid?
+ assert_equal "user", @occurrence.closed_source
+ assert_equal @occurrence.expected_amount, @occurrence.allocations.sum(:allocated_amount)
+ end
+
+ test "skip and reopen round trip" do
+ post skip_recurring_occurrence_url(@occurrence)
+ assert @occurrence.reload.skipped?
+
+ post reopen_recurring_occurrence_url(@occurrence)
+ assert @occurrence.reload.scheduled?
+ end
+
+ test "snooze postpones the effective due date" do
+ patch snooze_recurring_occurrence_url(@occurrence, until: (Date.current + 12).iso8601)
+
+ assert_equal Date.current + 12, @occurrence.reload.snoozed_until
+ end
+
+ # until[]=... makes params.require(:until) an Array, which Date.parse
+ # rejects with TypeError rather than ArgumentError. Same user mistake, same
+ # invalid-date redirect; never a 500.
+ test "a non-scalar until parameter is an invalid date, not a 500" do
+ patch snooze_recurring_occurrence_url(@occurrence), params: { until: [ (Date.current + 12).iso8601 ] }
+
+ assert_response :redirect
+ assert_nil @occurrence.reload.snoozed_until
+ end
+
+ test "override amount sets and clears the per-occurrence expectation" do
+ patch override_amount_recurring_occurrence_url(@occurrence, amount: "42.50")
+ assert_equal 42.50, @occurrence.reload.expected_amount
+
+ patch override_amount_recurring_occurrence_url(@occurrence, amount: "")
+ assert_nil @occurrence.reload.expected_amount
+ end
+
+ test "another family's occurrence is unreachable" do
+ other_family = families(:empty)
+ other_series = other_family.recurring_transactions.create!(
+ name: "Foreign bill", amount: 10, currency: "USD", expected_day_of_month: 1,
+ last_occurrence_date: Date.current, next_expected_date: 1.month.from_now.to_date,
+ status: "active", manual: true
+ )
+ foreign = other_series.recurring_occurrences.order(:due_on).first ||
+ other_series.recurring_occurrences.create!(
+ family: other_family, original_due_on: Date.current,
+ due_on: Date.current, currency: "USD"
+ )
+
+ get recurring_occurrence_url(foreign)
+ assert_response :not_found
+ end
+
+ test "allocating an entry applies its amount toward the occurrence" do
+ entry = accounts(:depository).entries.create!(
+ date: Date.current, amount: 10, currency: "USD", name: "Netflix charge",
+ entryable: Transaction.new(merchant: merchants(:netflix))
+ )
+
+ post recurring_occurrence_allocations_url(@occurrence, entry_id: entry.id)
+
+ allocation = @occurrence.allocations.sole
+ assert_equal entry.id, allocation.entry_id
+ assert_equal 10, allocation.allocated_amount
+ assert @occurrence.reload.partially_paid?
+ end
+
+ test "a custom amount records an entry-less payment" do
+ post recurring_occurrence_allocations_url(@occurrence), params: { amount: "5.00" }
+
+ allocation = @occurrence.allocations.sole
+ assert_nil allocation.entry_id
+ assert allocation.from_user_created?
+ assert_equal 5, allocation.allocated_amount
+ end
+
+ test "over-allocating an entry is refused with an explanation" do
+ entry = accounts(:depository).entries.create!(
+ date: Date.current, amount: 10, currency: "USD", name: "Netflix charge",
+ entryable: Transaction.new(merchant: merchants(:netflix))
+ )
+
+ post recurring_occurrence_allocations_url(@occurrence, entry_id: entry.id), params: { amount: "25" }
+
+ assert_equal 0, @occurrence.allocations.count
+ assert_equal I18n.t("recurring_allocations.over_allocation"), flash[:alert]
+ end
+
+ # Sharing is per account. A member with no share on the brokerage must not be
+ # able to settle a bill with a transaction from it, which would both spend an
+ # obligation against money they cannot see and echo the charge back to them.
+ test "a member cannot pay a bill with a transaction from an account they were never given" do
+ hidden = accounts(:investment).entries.create!(
+ date: Date.current, amount: 15.99, currency: "USD", name: "PRIVATE BROKERAGE FEE",
+ entryable: Transaction.new
+ )
+ member = users(:family_member)
+ member.update!(preferences: (member.preferences || {}).merge("preview_features_enabled" => true))
+ sign_in member
+
+ post recurring_occurrence_allocations_url(@occurrence, entry_id: hidden.id), params: { amount: "15.99" }
+
+ assert_response :not_found
+ assert_equal 0, @occurrence.reload.allocations.count
+ end
+
+ test "confirming and rejecting suggestions from the queue" do
+ entry = accounts(:depository).entries.create!(
+ date: Date.current, amount: 15.99, currency: "USD", name: "Netflix charge",
+ entryable: Transaction.new(merchant: merchants(:netflix))
+ )
+ suggestion = RecurringTransaction::Allocator.new(@occurrence).allocate_matched!(
+ entry: entry, state: "suggested", confidence: 0.7, signals: { name: 0.35 }
+ )
+
+ post confirm_recurring_allocation_url(suggestion)
+ assert suggestion.reload.allocation_confirmed?
+ assert @occurrence.reload.paid?
+
+ delete recurring_allocation_url(suggestion)
+ other = RecurringTransaction::Allocator.new(@occurrence.reload).allocate_matched!(
+ entry: entry, state: "suggested", confidence: 0.7, signals: { name: 0.35 }
+ )
+ post reject_recurring_allocation_url(other)
+
+ assert_not RecurringAllocation.exists?(other.id)
+ assert RecurringMatchRejection.exists?(recurring_transaction: @series, entry: entry)
+ end
+
+ test "unlinking a payment reopens an auto-closed occurrence" do
+ entry = accounts(:depository).entries.create!(
+ date: Date.current, amount: 15.99, currency: "USD", name: "Netflix charge",
+ entryable: Transaction.new(merchant: merchants(:netflix))
+ )
+ post recurring_occurrence_allocations_url(@occurrence, entry_id: entry.id)
+ assert @occurrence.reload.paid?
+
+ delete recurring_allocation_url(@occurrence.allocations.sole)
+
+ assert @occurrence.reload.scheduled?
+ assert_equal 0, @occurrence.allocations.count
+ end
+
+ test "the drawer redirects when the family has turned recurring transactions off" do
+ @family.update!(recurring_transactions_disabled: true)
+
+ get recurring_occurrence_url(@occurrence)
+
+ assert_redirected_to root_path
+ end
+
+ # Sharing is per account. An accountless bill is visible family-wide, but
+ # its candidate list must still show only entries from accounts the viewer
+ # can reach, or the drawer leaks names and amounts from unshared accounts.
+ test "an accountless bill's candidates exclude entries from unshared accounts" do
+ accountless = @family.recurring_transactions.create!(
+ name: "Water Utility", amount: 60, currency: "USD",
+ expected_day_of_month: Date.current.day, anchor_date: Date.current,
+ last_occurrence_date: Date.current, next_expected_date: Date.current,
+ status: "active", manual: true
+ )
+ occurrence = accountless.recurring_occurrences.order(:due_on).first
+
+ hidden = accounts(:investment).entries.create!(
+ date: occurrence.due_on, amount: 60, currency: "USD",
+ name: "Broker service fee", entryable: Transaction.new
+ )
+ visible = accounts(:depository).entries.create!(
+ date: occurrence.due_on, amount: 60, currency: "USD",
+ name: "Shared checking charge", entryable: Transaction.new
+ )
+
+ member = users(:family_member)
+ member.update!(preferences: (member.preferences || {}).merge("preview_features_enabled" => true))
+ sign_in member
+ get recurring_occurrence_url(occurrence), headers: { "Turbo-Frame" => "drawer" }
+
+ assert_response :success
+ assert_no_match hidden.name, response.body,
+ "an entry on an account never shared with the viewer must not render"
+ assert_match visible.name, response.body,
+ "positive control: the same-shaped entry on a shared account must render"
+ end
+
+ # credit_card is shared read-only with family_member in the fixtures; a
+ # read-only share can look at the bill but never move its payment state.
+ test "a read-only account share cannot mutate an occurrence" do
+ occurrence = credit_card_occurrence
+
+ member = users(:family_member)
+ member.update!(preferences: (member.preferences || {}).merge("preview_features_enabled" => true))
+ sign_in member
+
+ post mark_paid_recurring_occurrence_url(occurrence)
+ assert_response :not_found
+ assert occurrence.reload.scheduled?, "a read-only share must not settle the bill"
+
+ post skip_recurring_occurrence_url(occurrence)
+ assert_response :not_found
+
+ patch override_amount_recurring_occurrence_url(occurrence, amount: "1")
+ assert_response :not_found
+ assert_nil occurrence.reload.expected_amount
+
+ get recurring_occurrence_url(occurrence), headers: { "Turbo-Frame" => "drawer" }
+ assert_response :success, "reading the shared bill stays allowed"
+ end
+
+ test "the account owner still settles the same occurrence" do
+ occurrence = credit_card_occurrence
+
+ post mark_paid_recurring_occurrence_url(occurrence)
+
+ assert occurrence.reload.paid?
+ end
+
+ private
+ def credit_card_occurrence
+ series = @family.recurring_transactions.create!(
+ name: "Card Annual Fee", account: accounts(:credit_card), amount: 95,
+ currency: "USD", expected_day_of_month: Date.current.day,
+ anchor_date: Date.current, last_occurrence_date: Date.current,
+ next_expected_date: Date.current, status: "active", manual: true
+ )
+ series.recurring_occurrences.order(:due_on).first
+ end
+end
diff --git a/test/controllers/recurring_transactions/smart_fills_controller_test.rb b/test/controllers/recurring_transactions/smart_fills_controller_test.rb
new file mode 100644
index 000000000..8735374e8
--- /dev/null
+++ b/test/controllers/recurring_transactions/smart_fills_controller_test.rb
@@ -0,0 +1,98 @@
+require "test_helper"
+
+class RecurringTransactions::SmartFillsControllerTest < ActionDispatch::IntegrationTest
+ RawSuggestion = Provider::LlmConcept::BillSetupSuggestion
+
+ setup do
+ sign_in @user = users(:family_admin)
+ @user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => true))
+ @family = @user.family
+ @entry = accounts(:depository).entries.create!(
+ date: Date.current, amount: 40, currency: "USD", name: "GYM MEMBERSHIP",
+ entryable: Transaction.new
+ )
+ end
+
+ test "applies suggested values to the form and says so" do
+ stub_provider(raw(name: "Gym Membership", amount: 42.0, frequency: "weekly", confidence: 0.9,
+ rationale: "Weekly gaps between charges"))
+
+ post smart_fill_recurring_transactions_url(entry_id: @entry.id),
+ headers: { "Turbo-Frame" => "modal" }
+
+ assert_response :success
+ assert_match I18n.t("recurring_transactions.new.smart_fill_applied"), response.body
+ assert_match "Weekly gaps between charges", response.body
+ assert_select "input[name=?][value=?]", "recurring_transaction[name]", "Gym Membership"
+ assert_select "input[name=?][value=?]", "recurring_transaction[amount]", "42.0"
+ end
+
+ test "a provider failure keeps the plain prefill and explains" do
+ provider = Object.new
+ provider.define_singleton_method(:suggest_bill_setup) do |**|
+ Provider::Response.new(success?: false, data: nil, error: StandardError.new("provider down"))
+ end
+ Provider::Registry.stubs(:preferred_llm_provider).returns(provider)
+
+ post smart_fill_recurring_transactions_url(entry_id: @entry.id),
+ headers: { "Turbo-Frame" => "modal" }
+
+ assert_response :success
+ assert_match "Could not analyze the charge history", response.body
+ assert_select "input[name=?][value=?]", "recurring_transaction[name]", "GYM MEMBERSHIP",
+ { count: 1 }, "the entry's own prefill must survive a failed suggestion"
+ end
+
+ test "forbidden without an LLM provider" do
+ Provider::Registry.stubs(:preferred_llm_provider).returns(nil)
+
+ post smart_fill_recurring_transactions_url(entry_id: @entry.id)
+
+ assert_response :forbidden
+ end
+
+ test "forbidden without AI consent" do
+ stub_provider(raw)
+ @user.update!(ai_enabled: false)
+
+ post smart_fill_recurring_transactions_url(entry_id: @entry.id)
+
+ assert_response :forbidden
+ end
+
+ test "an inaccessible entry never becomes evidence" do
+ stub_provider(raw(name: "Should not appear"))
+ hidden = accounts(:investment).entries.create!(
+ date: Date.current, amount: 30, currency: "USD", name: "PRIVATE FEE",
+ entryable: Transaction.new
+ )
+ member = users(:family_member)
+ member.update!(preferences: (member.preferences || {}).merge("preview_features_enabled" => true))
+ sign_in member
+
+ post smart_fill_recurring_transactions_url(entry_id: hidden.id),
+ headers: { "Turbo-Frame" => "modal" }
+
+ assert_response :success
+ assert_match "Could not analyze the charge history", response.body
+ assert_no_match "Should not appear", response.body
+ end
+
+ private
+
+ def raw(**overrides)
+ RawSuggestion.new(**{
+ name: nil, amount: nil, frequency: nil, day_of_month: nil, weekday: nil,
+ month_of_year: nil, category_name: nil, bill_type: nil, autopay: nil,
+ confidence: nil, rationale: nil
+ }.merge(overrides))
+ end
+
+ def stub_provider(suggestion)
+ provider = Object.new
+ provider.define_singleton_method(:suggest_bill_setup) do |**|
+ Provider::Response.new(success?: true, data: suggestion, error: nil)
+ end
+ Provider::Registry.stubs(:preferred_llm_provider).returns(provider)
+ end
+end
diff --git a/test/controllers/recurring_transactions_controller_test.rb b/test/controllers/recurring_transactions_controller_test.rb
new file mode 100644
index 000000000..048325da6
--- /dev/null
+++ b/test/controllers/recurring_transactions_controller_test.rb
@@ -0,0 +1,1136 @@
+require "test_helper"
+
+class RecurringTransactionsControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ sign_in @user = users(:family_admin)
+ # The declare/edit/suggestion paths sit behind the Bills preview gate.
+ @user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => true))
+ @family = @user.family
+ @recurring_transaction = recurring_transactions(:netflix_subscription)
+ ensure_tailwind_build
+ end
+
+ # How often is one of the four things anyone needs to add a bill, and it used
+ # to render below the payment link and the autopay toggle: the fourth
+ # essential field sat under two most people never set.
+ test "the add form leads with the essentials and tucks the rest away" do
+ get new_recurring_transaction_url, headers: { "Turbo-Frame" => "modal" }
+
+ assert_response :success
+ body = response.body
+
+ name_at = body.index("recurring_transaction[name]")
+ amount_at = body.index("recurring_transaction[amount]")
+ due_at = body.index("recurring_transaction[first_due_on]")
+ often_at = body.index("recurring_transaction[frequency_preset]")
+ url_at = body.index("recurring_transaction[payment_url]")
+
+ assert name_at && amount_at && due_at && often_at && url_at
+ assert_operator name_at, :<, amount_at
+ assert_operator amount_at, :<, due_at
+ assert_operator due_at, :<, often_at, "how often belongs with the essentials"
+ assert_operator often_at, :<, url_at, "and above the advanced fields, not below them"
+
+ # Tucked away is not the same as gone.
+ assert_match "recurring_transaction[autopay]", body
+ assert_match "recurring_transaction[notes]", body
+ assert_match I18n.t("recurring_transactions.form.more_options"), body
+ end
+
+ test "edit renders the form" do
+ get edit_recurring_transaction_url(@recurring_transaction)
+
+ assert_response :success
+ end
+
+ # These three mutate: identify runs the whole detection and matching
+ # pipeline, cleanup destroys stale series, and toggle_status pauses a bill,
+ # which deletes its future occurrences. A GET route puts all of that behind a
+ # plain URL, outside CSRF protection, where an image tag on any page a signed
+ # in user visits is enough to fire it.
+ test "the mutating actions refuse GET" do
+ paths = {
+ "/recurring_transactions/identify" => :get,
+ "/recurring_transactions/cleanup" => :get,
+ "/recurring_transactions/#{@recurring_transaction.id}/toggle_status" => :get
+ }
+
+ paths.each do |path, verb|
+ assert_raises(ActionController::RoutingError, "#{path} must not answer #{verb.to_s.upcase}") do
+ Rails.application.routes.recognize_path(path, method: verb)
+ end
+ end
+ end
+
+ test "identify runs the pipeline over POST" do
+ post identify_recurring_transactions_url
+
+ assert_redirected_to recurring_transactions_url
+ end
+
+ test "cleanup retires stale series over POST" do
+ post cleanup_recurring_transactions_url
+
+ assert_redirected_to recurring_transactions_url
+ end
+
+ test "toggle_status pauses and resumes over POST" do
+ assert @recurring_transaction.active?
+
+ post toggle_status_recurring_transaction_url(@recurring_transaction)
+ assert_not @recurring_transaction.reload.active?
+
+ post toggle_status_recurring_transaction_url(@recurring_transaction)
+ assert @recurring_transaction.reload.active?
+ end
+
+ # The dialog is delivered into the shared that every page
+ # layout already renders empty. If this action responds with a full page layout,
+ # the response carries two frames with that id, Turbo matches the empty one first,
+ # and the pencil icon silently does nothing. Assert there is exactly one.
+ test "edit responds to a turbo frame request with a single modal frame" do
+ get edit_recurring_transaction_url(@recurring_transaction),
+ headers: { "Turbo-Frame" => "modal" }
+
+ assert_response :success
+ assert_equal 1, response.body.scan(/]*id="modal"/).size
+ end
+
+ test "a failed update still renders a single modal frame" do
+ patch recurring_transaction_url(@recurring_transaction),
+ params: { recurring_transaction: { payment_url: "javascript:alert(1)" } },
+ headers: { "Turbo-Frame" => "modal" }
+
+ assert_response :unprocessable_entity
+ assert_equal 1, response.body.scan(/]*id="modal"/).size
+ end
+
+ test "update saves a payment link" do
+ patch recurring_transaction_url(@recurring_transaction),
+ params: { recurring_transaction: { payment_url: "pay.example.com/bill" } }
+
+ assert_redirected_to recurring_transactions_url
+ assert_equal "https://pay.example.com/bill", @recurring_transaction.reload.payment_url
+ end
+
+ test "new renders the create dialog in a single modal frame" do
+ get new_recurring_transaction_url, headers: { "Turbo-Frame" => "modal" }
+
+ assert_response :success
+ assert_equal 1, response.body.scan(/]*id="modal"/).size
+ end
+
+ test "add income opens an income dialog, not a bill dialog" do
+ get new_recurring_transaction_url(income: true), headers: { "Turbo-Frame" => "modal" }
+
+ assert_response :success
+ assert_match I18n.t("recurring_transactions.new.income_title"), response.body
+ assert_match I18n.t("recurring_transactions.form.income_name_label"), response.body
+ assert_match I18n.t("recurring_transactions.form.submit_income"), response.body
+ # Nothing bill-shaped survives in income mode.
+ assert_no_match I18n.t("recurring_transactions.form.autopay_hint"), response.body
+ assert_no_match I18n.t("recurring_transactions.form.payment_url_label"), response.body
+ assert_no_match I18n.t("recurring_transactions.form.submit"), response.body
+ end
+
+ test "fresh bill dialog offers detected recurring charges as starting points" do
+ account = accounts(:depository)
+ 2.times do |i|
+ account.entries.create!(
+ date: Date.current - ((i + 1) * 30).days,
+ amount: 45.00, currency: "USD", name: "City Water",
+ entryable: Transaction.new
+ )
+ end
+
+ get new_recurring_transaction_url, headers: { "Turbo-Frame" => "modal" }
+
+ assert_response :success
+ assert_match I18n.t("recurring_transactions.new.start_from_title"), response.body
+ assert_match "City Water", response.body
+ end
+
+ test "the candidate strip never offers a pattern on an account the member cannot reach" do
+ 3.times do |i|
+ accounts(:investment).entries.create!(
+ date: Date.current - ((i + 1) * 30).days,
+ amount: 45.00, currency: "USD", name: "PRIVATE BROKERAGE SUB",
+ entryable: Transaction.new
+ )
+ end
+ member = users(:family_member)
+ member.update!(preferences: (member.preferences || {}).merge("preview_features_enabled" => true))
+ sign_in member
+
+ get new_recurring_transaction_url, headers: { "Turbo-Frame" => "modal" }
+
+ assert_response :success
+ assert_no_match "PRIVATE BROKERAGE SUB", response.body
+ end
+
+ test "income dialog offers only detected deposits" do
+ account = accounts(:depository)
+ 2.times do |i|
+ account.entries.create!(
+ date: (i + 1).months.ago.beginning_of_month + 2.days,
+ amount: -1840.00, currency: "USD", name: "ACME PAYROLL",
+ entryable: Transaction.new
+ )
+ account.entries.create!(
+ date: Date.current - ((i + 1) * 30).days,
+ amount: 45.00, currency: "USD", name: "City Water",
+ entryable: Transaction.new
+ )
+ end
+
+ get new_recurring_transaction_url(income: true), headers: { "Turbo-Frame" => "modal" }
+
+ assert_response :success
+ assert_match I18n.t("recurring_transactions.new.start_from_income_title"), response.body
+ assert_match "ACME PAYROLL", response.body
+ assert_no_match "City Water", response.body
+ end
+
+ test "prefilled dialog hides the picker" do
+ account = accounts(:depository)
+ entries = 2.times.map do |i|
+ account.entries.create!(
+ date: Date.current - ((i + 1) * 30).days,
+ amount: 45.00, currency: "USD", name: "City Water",
+ entryable: Transaction.new
+ )
+ end
+
+ get new_recurring_transaction_url(entry_id: entries.last.id), headers: { "Turbo-Frame" => "modal" }
+
+ assert_response :success
+ assert_no_match I18n.t("recurring_transactions.new.start_from_title"), response.body
+ end
+
+ test "new prefills from a transaction" do
+ entry = accounts(:depository).entries.create!(
+ date: Date.current - 20, amount: 184.37, currency: "USD", name: "PG&E WEB PAYMENT",
+ entryable: Transaction.new
+ )
+
+ get new_recurring_transaction_url(entry_id: entry.id), headers: { "Turbo-Frame" => "modal" }
+
+ assert_response :success
+ assert_match "PG&E WEB PAYMENT", response.body
+ assert_match "184.37", response.body
+ end
+
+ # Sharing is per account, so a family scope is not an access check. Prefilling
+ # reads the entry's name, amount and account straight back into the form.
+ test "new ignores a transaction from an account the user was never given" do
+ hidden = accounts(:investment).entries.create!(
+ date: Date.current - 3, amount: 622.41, currency: "USD", name: "PRIVATE BROKERAGE FEE",
+ entryable: Transaction.new
+ )
+ member = users(:family_member)
+ member.update!(preferences: (member.preferences || {}).merge("preview_features_enabled" => true))
+ sign_in member
+
+ get new_recurring_transaction_url(entry_id: hidden.id), headers: { "Turbo-Frame" => "modal" }
+
+ assert_response :success
+ assert_no_match "PRIVATE BROKERAGE FEE", response.body
+ assert_no_match "622.41", response.body
+ end
+
+ test "prefilling from an inflow pre-selects income" do
+ entry = accounts(:depository).entries.create!(
+ date: Date.current - 10, amount: -1840, currency: "USD", name: "ACME PAYROLL",
+ entryable: Transaction.new
+ )
+
+ get new_recurring_transaction_url(entry_id: entry.id), headers: { "Turbo-Frame" => "modal" }
+
+ assert_response :success
+ assert_match I18n.t("recurring_transactions.new.income_title"), response.body
+ end
+
+ test "editing income keeps bill wording out of the dialog" do
+ payday = Date.current + 3
+ income = @family.recurring_transactions.create!(
+ name: "Paycheck", account: accounts(:depository), amount: -1840, currency: "USD",
+ bill_type: "income", expected_day_of_month: payday.day, anchor_date: payday,
+ last_occurrence_date: payday, next_expected_date: payday, status: "active", manual: true
+ )
+
+ get edit_recurring_transaction_url(income), headers: { "Turbo-Frame" => "modal" }
+
+ assert_response :success
+ assert_match I18n.t("recurring_transactions.edit.income_title", name: "Paycheck"), response.body
+ # Apostrophes HTML-escape in the body, so match on stable fragments.
+ assert_match "match your payday", response.body
+ assert_no_match(/comes due/, response.body)
+ end
+
+ test "removing a bill never touches the ledger and lands back on bills" do
+ due = Date.current + 5
+ bill = @family.recurring_transactions.create!(
+ name: "City Water", account: accounts(:depository), amount: 45, currency: "USD",
+ expected_day_of_month: due.day, anchor_date: due, last_occurrence_date: due,
+ next_expected_date: due, status: "active", manual: true
+ )
+ RecurringTransaction::OccurrenceGenerator.new(bill).generate!
+ entry = accounts(:depository).entries.create!(
+ date: Date.current, amount: 45, currency: "USD", name: "CITY WATER",
+ entryable: Transaction.new
+ )
+ occurrence = bill.recurring_occurrences.order(:due_on).first
+ RecurringTransaction::Allocator.new(occurrence).allocate!(amount: "45", entry: entry)
+
+ delete recurring_transaction_url(bill), headers: { "HTTP_REFERER" => bills_url }
+
+ assert_redirected_to bills_url
+ assert_equal I18n.t("recurring_transactions.deleted"), flash[:notice]
+ assert Entry.exists?(entry.id), "removing a bill must never delete ledger entries"
+ end
+
+ # Which kind this is was settled by the entry point that opened the dialog.
+ # The checkbox asked it again, and ticking it reshaped nothing: you filled in
+ # bill-shaped labels, pressed Save bill, and got an income record.
+ test "the add-bill dialog does not offer to make it income" do
+ get new_recurring_transaction_url, headers: { "Turbo-Frame" => "modal" }
+
+ assert_response :success
+ assert_no_match(/name="recurring_transaction\[is_income\]"/, response.body)
+ end
+
+ test "the add-income dialog carries the answer without asking" do
+ get new_recurring_transaction_url(income: true), headers: { "Turbo-Frame" => "modal" }
+
+ assert_response :success
+ assert_match(/type="hidden"[^>]*name="recurring_transaction\[is_income\]"/, response.body)
+ assert_no_match(/type="checkbox"[^>]*name="recurring_transaction\[is_income\]"/, response.body)
+ end
+
+ # Reported upstream: a deleted auto-detected recurring transaction comes back
+ # on the next detection run, so users delete the same row over and over. The
+ # pattern is still in the bank data, so a hard delete only lasts until the
+ # next sync. Removing it leaves the same `ended` tombstone that dismissing a
+ # suggestion does, and the Identifier refuses to claim or recreate one.
+ test "a deleted detected bill does not come back on the next detection run" do
+ account = accounts(:depository)
+ anchor_day = Date.current.beginning_of_month + 8
+ 3.times do |i|
+ account.entries.create!(
+ date: anchor_day - i.months, amount: 42.00, currency: "USD",
+ name: "City Water", entryable: Transaction.create!(category: categories(:food_and_drink))
+ )
+ end
+
+ RecurringTransaction::Identifier.new(@family).identify_recurring_patterns
+ detected = @family.recurring_transactions.find_by(name: "City Water")
+ assert_not_nil detected
+ assert_not detected.manual?
+
+ delete recurring_transaction_url(detected)
+
+ RecurringTransaction::Identifier.new(Family.find(@family.id)).identify_recurring_patterns
+
+ rows = @family.recurring_transactions.where(name: "City Water")
+ assert_equal 1, rows.count, "detection must not build a second row for a pattern the user removed"
+ assert_equal "ended", rows.first.status, "and the one that remains is a tombstone, not a live bill"
+ assert_empty @family.recurring_transactions.where(name: "City Water").where.not(status: "ended")
+ end
+
+ # A hand-declared bill has no pattern behind it, so nothing would bring it
+ # back and it is deleted outright rather than left lying around as ended.
+ test "a declared bill is deleted outright" do
+ bill = @family.recurring_transactions.create!(
+ name: "Typo Bill", account: accounts(:depository), amount: 10, currency: "USD",
+ dedup_scope: "typo", bill_type: "bill", expected_day_of_month: Date.current.day,
+ anchor_date: Date.current, last_occurrence_date: Date.current,
+ next_expected_date: Date.current, status: "active", manual: true
+ )
+
+ assert_difference "@family.recurring_transactions.count", -1 do
+ delete recurring_transaction_url(bill)
+ end
+ end
+
+ test "creating income says income, not bill" do
+ post recurring_transactions_url, params: {
+ recurring_transaction: {
+ name: "Paycheck",
+ amount: "1840",
+ account_id: accounts(:depository).id,
+ first_due_on: (Date.current + 3).iso8601,
+ frequency_preset: "biweekly",
+ is_income: "1"
+ }
+ }
+
+ assert_equal I18n.t("recurring_transactions.create.success_income"), flash[:notice]
+ end
+
+ test "create declares a manual bill and materializes its occurrences" do
+ due = Date.current + 16
+
+ assert_difference "@family.recurring_transactions.count", 1 do
+ post recurring_transactions_url, params: {
+ recurring_transaction: {
+ name: "Watson Property",
+ amount: "2150",
+ account_id: accounts(:depository).id,
+ first_due_on: due.iso8601,
+ frequency_preset: "monthly"
+ }
+ }
+ end
+
+ bill = @family.recurring_transactions.order(:created_at).last
+ assert bill.manual?
+ assert_equal "active", bill.status
+ assert_equal 2150, bill.amount
+ assert_equal due.day, bill.expected_day_of_month
+ assert_equal due, bill.anchor_date
+ # Monthly on the derived day IS the zero-rule implicit shape, so no
+ # redundant rule row is written; the detection reads it back correctly.
+ detection = RecurringTransaction::FrequencyPreset.detect(bill)
+ assert_equal "monthly", detection.key
+ assert_equal due.day, detection.day_of_month
+ assert bill.recurring_occurrences.reload.exists?(due_on: due),
+ "the declared bill's occurrence must materialize immediately"
+ end
+
+ test "create with a non-monthly preset writes explicit rules" do
+ due = Date.current + 4
+
+ post recurring_transactions_url, params: {
+ recurring_transaction: {
+ name: "Cleaning service", amount: "80", account_id: accounts(:depository).id,
+ first_due_on: due.iso8601, frequency_preset: "biweekly"
+ }
+ }
+
+ bill = @family.recurring_transactions.order(:created_at).last
+ rule = bill.recurrence_rules.sole
+ assert_equal [ "weekly", 2, due.wday ], [ rule.frequency, rule.interval, rule.weekday ]
+ assert_equal due, bill.anchor_date
+ end
+
+ test "create without a due date re-renders with an error" do
+ assert_no_difference "@family.recurring_transactions.count" do
+ post recurring_transactions_url, params: {
+ recurring_transaction: { name: "No date", amount: "10", frequency_preset: "monthly", first_due_on: "" }
+ }
+ end
+
+ assert_response :unprocessable_entity
+ end
+
+ test "create with a currency-formatted amount re-renders with an error instead of crashing" do
+ assert_no_difference "@family.recurring_transactions.count" do
+ post recurring_transactions_url, params: {
+ recurring_transaction: { name: "Trash Pickup", amount: "$40.00", account_id: accounts(:depository).id,
+ first_due_on: (Date.current + 5).iso8601, frequency_preset: "monthly" }
+ }
+ end
+
+ assert_response :unprocessable_entity
+ assert_match I18n.t("recurring_transactions.create.amount_invalid"), response.body
+ end
+
+ test "update with an unresolvable account keeps the current account and reports the error" do
+ original_account_id = @recurring_transaction.account_id
+
+ patch recurring_transaction_url(@recurring_transaction),
+ params: { recurring_transaction: { account_id: SecureRandom.uuid } }
+
+ assert_response :unprocessable_entity
+ assert_equal original_account_id, @recurring_transaction.reload.account_id,
+ "a present-but-unresolvable id must not silently detach the account"
+ end
+
+ test "create stamps dedup_scope up front so tiers fork and true duplicates collide" do
+ post recurring_transactions_url, params: {
+ recurring_transaction: { name: "STREAMCO", amount: "5.99", account_id: accounts(:depository).id,
+ first_due_on: (Date.current + 3).iso8601, frequency_preset: "monthly" }
+ }
+ post recurring_transactions_url, params: {
+ recurring_transaction: { name: "STREAMCO", amount: "24.99", account_id: accounts(:depository).id,
+ first_due_on: (Date.current + 9).iso8601, frequency_preset: "monthly" }
+ }
+
+ tiers = @family.recurring_transactions.where(name: "STREAMCO").order(:amount)
+ assert_equal 2, tiers.count
+ assert_equal [ "5.99", "24.99" ], tiers.map(&:dedup_scope)
+
+ # The stamp makes the very first identical duplicate collide on insert.
+ post recurring_transactions_url, params: {
+ recurring_transaction: { name: "STREAMCO", amount: "5.99", account_id: accounts(:depository).id,
+ first_due_on: (Date.current + 3).iso8601, frequency_preset: "monthly" }
+ }
+ assert_equal 2, @family.recurring_transactions.where(name: "STREAMCO").count
+ end
+
+ test "marking a bill as an installment plan caps its occurrences and tracks progress" do
+ due = Date.current + 5
+ post recurring_transactions_url, params: {
+ recurring_transaction: { name: "Klarna sofa", amount: "120", first_due_on: due.iso8601, frequency_preset: "monthly" }
+ }
+ bill = @family.recurring_transactions.find_by!(name: "Klarna sofa")
+
+ patch recurring_transaction_url(bill), params: {
+ recurring_transaction: { bill_type: "installment", end_after_count: "4" }
+ }
+
+ bill.reload
+ assert bill.typed_installment?
+ assert bill.ends_after_count?
+ assert_equal 4, bill.recurring_occurrences.reload.count, "the plan materializes exactly its four payments"
+ assert_equal [ 0, 4 ], bill.installment_progress
+
+ occurrence = bill.recurring_occurrences.order(:due_on).first
+ RecurringTransaction::Allocator.new(occurrence).mark_paid!
+ assert_equal [ 1, 4 ], bill.reload.installment_progress
+ end
+
+ test "update applies a frequency preset as recurrence rules" do
+ patch recurring_transaction_url(@recurring_transaction),
+ params: { recurring_transaction: { frequency_preset: "biweekly", frequency_weekday: "5" } }
+
+ assert_redirected_to recurring_transactions_url
+ rules = @recurring_transaction.reload.recurrence_rules
+ assert_equal [ [ "weekly", 2, 5 ] ], rules.map { |rule| [ rule.frequency, rule.interval, rule.weekday ] }
+ assert_not_nil @recurring_transaction.anchor_date
+ end
+
+ test "update with an unchanged frequency does not rewrite the rules" do
+ patch recurring_transaction_url(@recurring_transaction),
+ params: { recurring_transaction: { frequency_preset: "weekly", frequency_weekday: "3" } }
+ original_ids = @recurring_transaction.reload.recurrence_rules.map(&:id)
+
+ patch recurring_transaction_url(@recurring_transaction),
+ params: { recurring_transaction: { notes: "edited", frequency_preset: "weekly", frequency_weekday: "3" } }
+
+ assert_equal original_ids, @recurring_transaction.reload.recurrence_rules.map(&:id)
+ assert_equal "edited", @recurring_transaction.notes
+ end
+
+ test "update with an incomplete frequency re-renders the form" do
+ patch recurring_transaction_url(@recurring_transaction),
+ params: { recurring_transaction: { frequency_preset: "weekly" } },
+ headers: { "Turbo-Frame" => "modal" }
+
+ assert_response :unprocessable_entity
+ assert_empty @recurring_transaction.reload.recurrence_rules
+ end
+
+ test "update rejects a non-http scheme instead of storing it" do
+ patch recurring_transaction_url(@recurring_transaction),
+ params: { recurring_transaction: { payment_url: "javascript:alert(1)" } }
+
+ assert_response :unprocessable_entity
+ assert_nil @recurring_transaction.reload.payment_url
+ end
+
+ test "update saves autopay and notes" do
+ patch recurring_transaction_url(@recurring_transaction),
+ params: { recurring_transaction: { autopay: "1", notes: "Account 4821" } }
+
+ @recurring_transaction.reload
+ assert @recurring_transaction.autopay?
+ assert_equal "Account 4821", @recurring_transaction.notes
+ end
+
+ test "update can turn autopay back off" do
+ @recurring_transaction.update!(autopay: true)
+
+ patch recurring_transaction_url(@recurring_transaction),
+ params: { recurring_transaction: { autopay: "0" } }
+
+ assert_not @recurring_transaction.reload.autopay?
+ end
+
+ test "update clears the payment link when submitted blank" do
+ @recurring_transaction.update!(payment_url: "https://pay.example.com")
+
+ patch recurring_transaction_url(@recurring_transaction),
+ params: { recurring_transaction: { payment_url: "" } }
+
+ assert_nil @recurring_transaction.reload.payment_url
+ end
+
+ # One biller routinely owns several bills that all pay at one portal, so the link
+ # can be fanned out on request. It must never reach a row outside the family.
+ test "update copies the payment link to sibling bills of the same merchant when asked" do
+ sibling = @family.recurring_transactions.create!(
+ account: accounts(:depository),
+ merchant: @recurring_transaction.merchant,
+ amount: 4.99,
+ dedup_scope: "4.99",
+ currency: "USD",
+ expected_day_of_month: 20,
+ last_occurrence_date: Date.current,
+ next_expected_date: 1.month.from_now.to_date,
+ status: "active"
+ )
+ other_merchant_bill = @family.recurring_transactions.create!(
+ account: accounts(:depository),
+ merchant: merchants(:amazon),
+ amount: 7.99,
+ currency: "USD",
+ expected_day_of_month: 21,
+ last_occurrence_date: Date.current,
+ next_expected_date: 1.month.from_now.to_date,
+ status: "active"
+ )
+
+ patch recurring_transaction_url(@recurring_transaction),
+ params: {
+ recurring_transaction: { payment_url: "https://pay.example.com" },
+ apply_to_siblings: "1"
+ }
+
+ assert_equal "https://pay.example.com", sibling.reload.payment_url
+ assert_nil other_merchant_bill.reload.payment_url
+ end
+
+ # Auto-detection leaves merchant_id null whenever the provider feed gave it nothing
+ # to match on, so most real bills are identified by name alone. Matching siblings on
+ # merchant only would skip them entirely.
+ test "update copies the payment link to name-matched siblings when there is no merchant" do
+ named = @family.recurring_transactions.create!(
+ account: accounts(:depository),
+ name: "TWITCH",
+ amount: 24.99,
+ currency: "USD",
+ expected_day_of_month: 21,
+ last_occurrence_date: Date.current,
+ next_expected_date: 1.month.from_now.to_date,
+ status: "active"
+ )
+ same_name = @family.recurring_transactions.create!(
+ account: accounts(:depository),
+ name: "TWITCH",
+ amount: 5.99,
+ dedup_scope: "5.99",
+ currency: "USD",
+ expected_day_of_month: 8,
+ last_occurrence_date: Date.current,
+ next_expected_date: 1.month.from_now.to_date,
+ status: "active"
+ )
+ different_name = @family.recurring_transactions.create!(
+ account: accounts(:depository),
+ name: "HUNTR.CO",
+ amount: 40,
+ currency: "USD",
+ expected_day_of_month: 28,
+ last_occurrence_date: Date.current,
+ next_expected_date: 1.month.from_now.to_date,
+ status: "active"
+ )
+
+ patch recurring_transaction_url(named),
+ params: {
+ recurring_transaction: { payment_url: "https://twitch.tv/subscriptions" },
+ apply_to_siblings: "1"
+ }
+
+ assert_equal "https://twitch.tv/subscriptions", same_name.reload.payment_url
+ assert_nil different_name.reload.payment_url
+ # A merchant-backed row must not be swept up by a name match.
+ assert_nil @recurring_transaction.reload.payment_url
+ end
+
+ test "update does not touch siblings unless asked" do
+ sibling = @family.recurring_transactions.create!(
+ account: accounts(:depository),
+ merchant: @recurring_transaction.merchant,
+ amount: 4.99,
+ dedup_scope: "4.99",
+ currency: "USD",
+ expected_day_of_month: 20,
+ last_occurrence_date: Date.current,
+ next_expected_date: 1.month.from_now.to_date,
+ status: "active"
+ )
+
+ patch recurring_transaction_url(@recurring_transaction),
+ params: { recurring_transaction: { payment_url: "https://pay.example.com" } }
+
+ assert_nil sibling.reload.payment_url
+ end
+
+ test "update cannot reach another family's recurring transaction" do
+ other_family_recurring = families(:empty).recurring_transactions.create!(
+ name: "Someone else's bill",
+ amount: 10,
+ currency: "USD",
+ expected_day_of_month: 3,
+ last_occurrence_date: Date.current,
+ next_expected_date: 1.month.from_now.to_date,
+ status: "active"
+ )
+
+ patch recurring_transaction_url(other_family_recurring),
+ params: { recurring_transaction: { payment_url: "https://evil.example.com" } }
+
+ assert_response :not_found
+ assert_nil other_family_recurring.reload.payment_url
+ end
+
+ # A bill outliving its own price is the normal case. These used to be
+ # create-only, so the only way to record a rise was delete-and-recreate,
+ # which takes the occurrences and allocations with it.
+ test "the edit dialog exposes name, amount and account" do
+ series = recurring_transactions(:netflix_subscription)
+ get edit_recurring_transaction_url(series)
+ assert_response :success
+
+ fields = response.body.scan(/name="recurring_transaction\[([a-z_]+)\]"/).flatten.uniq
+ %w[name amount account_id].each do |field|
+ assert_includes fields, field, "#{field} should be editable after creation"
+ end
+ refute_includes fields, "first_due_on",
+ "first_due_on is inert on a persisted series; the frequency picker owns the schedule"
+ end
+
+ test "updating name, amount and account persists all three" do
+ series = recurring_transactions(:netflix_subscription)
+ other = accounts(:credit_card)
+
+ patch recurring_transaction_url(series), params: {
+ recurring_transaction: { name: "Netflix Premium", amount: 24.99, account_id: other.id }
+ }
+ series.reload
+
+ assert_equal "Netflix Premium", series.name
+ assert_equal 24.99, series.amount.to_f
+ assert_equal other.id, series.account_id
+ end
+ test "a bill cannot be pointed at an account the user cannot reach" do
+ series = recurring_transactions(:netflix_subscription)
+ foreign = families(:empty).accounts.create!(
+ name: "Someone else's checking", balance: 0, currency: "USD",
+ accountable: Depository.new
+ )
+ refute_equal series.family_id, foreign.family_id
+
+ patch recurring_transaction_url(series), params: {
+ recurring_transaction: { account_id: foreign.id }
+ }
+
+ refute_equal foreign.id, series.reload.account_id,
+ "a crafted account_id must not reach another family's account"
+ end
+
+ test "editing an income series keeps its negative sign" do
+ income = recurring_transactions(:netflix_subscription)
+ income.update!(bill_type: "income", amount: -2000)
+
+ patch recurring_transaction_url(income), params: {
+ recurring_transaction: { amount: 2500 }
+ }
+
+ assert_equal(-2500, income.reload.amount.to_f,
+ "income is stored negative; a raw assignment would flip it into a bill")
+ end
+
+ test "the edit form shows an income amount as a positive magnitude" do
+ income = recurring_transactions(:netflix_subscription)
+ income.update!(bill_type: "income", amount: -2000)
+
+ get edit_recurring_transaction_url(income)
+
+ assert_response :success
+ # The stored sign is bookkeeping; the form edits what the paycheck pays.
+ assert_select "input[name=?][value=?]", "recurring_transaction[amount]", "2000.0"
+ end
+
+ test "the edit form shows a bill amount as it is stored" do
+ get edit_recurring_transaction_url(recurring_transactions(:netflix_subscription))
+
+ assert_response :success
+ assert_select "input[name=?][value=?]", "recurring_transaction[amount]", "15.99"
+ end
+ # Detected bills carry a merchant and no name of their own. The field has to
+ # arrive seeded, or it renders empty and, being required, browsers refuse to
+ # submit the whole form; and the rename has to actually show, or it is a
+ # control that silently does nothing.
+ test "renaming a detected bill seeds the field and takes effect" do
+ series = recurring_transactions(:netflix_subscription)
+ assert series.name.blank?, "premise: this bill is named by its merchant"
+ assert series.merchant.present?
+
+ get edit_recurring_transaction_url(series)
+ assert_select "input[name=?][value=?]", "recurring_transaction[name]", series.display_name
+
+ patch recurring_transaction_url(series), params: {
+ recurring_transaction: { name: "Netflix Premium" }
+ }
+
+ assert_equal "Netflix Premium", series.reload.display_name,
+ "a name the user typed should win over the detected merchant"
+ end
+
+ # --- Suggested-series review: confirm/dismiss from either page ---
+
+ test "confirming from the Bills page returns there and reconstructs the bill's history" do
+ last_month_ninth = Date.current.beginning_of_month + 8.days - 1.month
+ suggestion = @family.recurring_transactions.create!(
+ name: "CITY WATER", account: accounts(:depository), amount: 80, currency: "USD",
+ expected_day_of_month: 9, last_occurrence_date: last_month_ninth,
+ next_expected_date: last_month_ninth + 1.month, status: "suggested", manual: false
+ )
+ accounts(:depository).entries.create!(
+ date: last_month_ninth, amount: 80, currency: "USD", name: "CITY WATER",
+ entryable: Transaction.new
+ )
+
+ post confirm_recurring_transaction_url(suggestion), headers: { "HTTP_REFERER" => bills_url }
+
+ assert_redirected_to bills_url
+ assert suggestion.reload.active?
+ assert_operator suggestion.recurring_occurrences.count, :>, 0,
+ "confirming must materialize the schedule"
+ assert suggestion.recurring_occurrences.paid.where(due_on: last_month_ninth).exists?,
+ "confirming must close history a real entry anchors"
+ end
+
+ test "confirming twice does not double anything" do
+ suggestion = @family.recurring_transactions.create!(
+ name: "CITY GAS", account: accounts(:depository), amount: 55, currency: "USD",
+ expected_day_of_month: 9,
+ last_occurrence_date: Date.current.beginning_of_month + 8.days - 1.month,
+ next_expected_date: Date.current.beginning_of_month + 8.days,
+ status: "suggested", manual: false
+ )
+
+ post confirm_recurring_transaction_url(suggestion)
+ state = suggestion.recurring_occurrences.order(:due_on).pluck(:due_on, :status)
+
+ post confirm_recurring_transaction_url(suggestion)
+
+ assert_equal state, suggestion.recurring_occurrences.order(:due_on).pluck(:due_on, :status)
+ end
+
+ test "dismissing from the Bills page tombstones and returns there" do
+ suggestion = @family.recurring_transactions.create!(
+ name: "PHANTOM SUB", account: accounts(:depository), amount: 12, currency: "USD",
+ expected_day_of_month: 5, last_occurrence_date: 1.month.ago.to_date,
+ next_expected_date: Date.current, status: "suggested", manual: false
+ )
+
+ post dismiss_recurring_transaction_url(suggestion), headers: { "HTTP_REFERER" => bills_url }
+
+ assert_redirected_to bills_url
+ assert suggestion.reload.ended?
+ end
+
+ # --- "Search all your transactions" picker inside the add dialog ---
+
+ test "the add dialog links to the picker whether or not detection found anything" do
+ get new_recurring_transaction_url, headers: { "Turbo-Frame" => "modal" }
+
+ assert_response :success
+ assert_match I18n.t("recurring_transactions.new.search_all_cta"), response.body
+ assert_select "a[href=?]", new_recurring_transaction_path(picker: 1)
+ end
+
+ test "picker lists recent outflows as prefill links" do
+ entry = picker_entry(name: "ACME POWER", amount: 120)
+
+ get new_recurring_transaction_url(picker: 1), headers: { "Turbo-Frame" => "modal" }
+
+ assert_response :success
+ assert_match "ACME POWER", response.body
+ assert_select "a[href=?]", new_recurring_transaction_path(entry_id: entry.id)
+ end
+
+ test "picker filters by sign in each mode" do
+ picker_entry(name: "PAYCHECK DEPOSIT", amount: -900)
+ picker_entry(name: "ACME POWER", amount: 120)
+
+ get new_recurring_transaction_url(picker: 1), headers: { "Turbo-Frame" => "modal" }
+ assert_match "ACME POWER", response.body
+ assert_no_match "PAYCHECK DEPOSIT", response.body
+
+ get new_recurring_transaction_url(picker: 1, income: 1), headers: { "Turbo-Frame" => "modal" }
+ assert_match "PAYCHECK DEPOSIT", response.body
+ assert_no_match "ACME POWER", response.body
+ end
+
+ test "picker search matches the merchant behind a bank-blob entry name" do
+ picker_entry(name: "ACH WEB PMT 0042", amount: 15.49, merchant: merchants(:netflix))
+ picker_entry(name: "UNRELATED CHARGE", amount: 8)
+
+ get new_recurring_transaction_url(picker: 1, q: merchants(:netflix).name),
+ headers: { "Turbo-Frame" => "modal" }
+
+ assert_match "ACH WEB PMT 0042", response.body
+ assert_no_match "UNRELATED CHARGE", response.body
+ end
+
+ test "picker search matches notes" do
+ picker_entry(name: "CHECK 1042", amount: 300, notes: "quarterly water bill")
+ picker_entry(name: "CHECK 1043", amount: 300)
+
+ get new_recurring_transaction_url(picker: 1, q: "quarterly water"),
+ headers: { "Turbo-Frame" => "modal" }
+
+ assert_match "CHECK 1042", response.body
+ assert_no_match "CHECK 1043", response.body
+ end
+
+ test "picker search treats LIKE metacharacters as literals" do
+ picker_entry(name: "100% Juice Co", amount: 6)
+ picker_entry(name: "1003 Deli", amount: 9)
+
+ get new_recurring_transaction_url(picker: 1, q: "100%"), headers: { "Turbo-Frame" => "modal" }
+
+ assert_response :success
+ assert_match "100% Juice Co", response.body
+ assert_no_match "1003 Deli", response.body
+ end
+
+ test "picker hides transfers and excluded entries" do
+ picker_entry(name: "CARD PAYMENT", amount: 200, kind: "cc_payment")
+ picker_entry(name: "HIDDEN CHARGE", amount: 25, excluded: true)
+ picker_entry(name: "REAL CHARGE", amount: 25)
+
+ get new_recurring_transaction_url(picker: 1), headers: { "Turbo-Frame" => "modal" }
+
+ assert_match "REAL CHARGE", response.body
+ assert_no_match "CARD PAYMENT", response.body
+ assert_no_match "HIDDEN CHARGE", response.body
+ end
+
+ test "picker never shows an account the member was not given, even on exact match" do
+ hidden = picker_entry(name: "PRIVATE BROKERAGE FEE", amount: 30, account: accounts(:investment))
+ member = users(:family_member)
+ member.update!(preferences: (member.preferences || {}).merge("preview_features_enabled" => true))
+ sign_in member
+
+ get new_recurring_transaction_url(picker: 1, q: "PRIVATE BROKERAGE FEE"),
+ headers: { "Turbo-Frame" => "modal" }
+
+ assert_response :success
+ # The no-results copy echoes the query, so assert on the row link itself.
+ assert_select "a[href=?]", new_recurring_transaction_path(entry_id: hidden.id), count: 0
+ assert_match I18n.t("recurring_transactions.pick_entry.back"), response.body
+ end
+
+ test "an entry already backing a bill carries a chip instead of being hidden" do
+ claimed = picker_entry(name: "NETFLIX.COM", amount: 15.99)
+ series = @family.recurring_transactions.create!(
+ name: "Netflix", account: accounts(:depository), amount: 15.99, currency: "USD",
+ dedup_scope: "chip", expected_day_of_month: Date.current.day,
+ last_occurrence_date: 1.month.ago.to_date, next_expected_date: Date.current,
+ status: "active", manual: true
+ )
+ occurrence = series.recurring_occurrences.order(:due_on).first
+ occurrence.allocations.create!(
+ entry: claimed, allocated_amount: 15.99, currency: "USD", source: "user_created"
+ )
+ picker_entry(name: "UNCLAIMED CHARGE", amount: 12)
+
+ get new_recurring_transaction_url(picker: 1), headers: { "Turbo-Frame" => "modal" }
+
+ assert_match I18n.t("recurring_transactions.picker_row.claimed", name: "Netflix"), response.body
+ # The chip names its bill once, on the claimed row only.
+ assert_equal 1, response.body.scan(
+ I18n.t("recurring_transactions.picker_row.claimed", name: "Netflix")
+ ).size
+ end
+
+ test "picker caps at twenty rows and says so" do
+ 25.times { |i| picker_entry(name: "CHARGE #{format('%02d', i)}", amount: 5 + i) }
+
+ get new_recurring_transaction_url(picker: 1), headers: { "Turbo-Frame" => "modal" }
+
+ assert_equal RecurringTransactionsController::PICKER_SHOWN,
+ response.body.scan(/CHARGE \d\d/).uniq.size
+ assert_match I18n.t("recurring_transactions.pick_entry.showing_recent",
+ count: RecurringTransactionsController::PICKER_SHOWN), response.body
+ end
+
+ test "picker with no results explains and offers the way back" do
+ get new_recurring_transaction_url(picker: 1, q: "zzz-nothing-matches"),
+ headers: { "Turbo-Frame" => "modal" }
+
+ assert_response :success
+ assert_match CGI.escapeHTML("zzz-nothing-matches"), response.body
+ assert_match I18n.t("recurring_transactions.pick_entry.back"), response.body
+ end
+
+ # The declare, edit and suggestion paths shipped with Bills, so they honor
+ # the same preview gate as every other Bills surface. Direct URLs included:
+ # the gate is a before_action, not a matter of which buttons render.
+ test "the bills-era actions sit behind the preview gate" do
+ @user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => false))
+
+ get new_recurring_transaction_url
+ assert_redirected_to root_path
+
+ assert_no_difference "RecurringTransaction.count" do
+ post recurring_transactions_url, params: { recurring_transaction: {
+ name: "Gated", amount: 10, first_due_on: Date.current.iso8601, frequency_preset: "monthly"
+ } }
+ end
+ assert_redirected_to root_path
+
+ original_name = @recurring_transaction.name
+ patch recurring_transaction_url(@recurring_transaction), params: { recurring_transaction: { name: "Renamed" } }
+ assert_redirected_to root_path
+ assert_equal original_name, @recurring_transaction.reload.name
+
+ suggestion = create_series(name: "Maybe A Bill", status: "suggested")
+ post confirm_recurring_transaction_url(suggestion)
+ assert_redirected_to root_path
+ assert suggestion.reload.suggested?
+ end
+
+ test "the pre-bills settings actions stay reachable without the preview flag" do
+ @user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => false))
+
+ get recurring_transactions_url
+ assert_response :success
+
+ post toggle_status_recurring_transaction_url(@recurring_transaction)
+ assert_redirected_to recurring_transactions_url
+ end
+
+ # Sharing is per account: a read-only share may SEE the series everywhere the
+ # app lists it, and must not be able to change or remove it. Mirrors
+ # RecurringOccurrencesController#ensure_series_writable.
+ test "a read-only account share can see but not mutate a series" do
+ member = users(:family_member)
+ member.update!(preferences: (member.preferences || {}).merge("preview_features_enabled" => true))
+ # The credit card fixture is shared with the member read_only.
+ series = create_series(name: "Shared Read Only", account: accounts(:credit_card))
+ suggestion = create_series(name: "Shared Suggestion", account: accounts(:credit_card), status: "suggested")
+
+ sign_in member
+
+ # Visible: the read dialog opens. The write guard bites only on mutation.
+ get edit_recurring_transaction_url(series), headers: { "Turbo-Frame" => "modal" }
+ assert_response :success
+ assert_match "Shared Read Only", response.body
+
+ patch recurring_transaction_url(series), params: { recurring_transaction: { name: "Hijacked" } }
+ assert_response :not_found
+ assert_equal "Shared Read Only", series.reload.name
+
+ post toggle_status_recurring_transaction_url(series)
+ assert_response :not_found
+ assert series.reload.active?
+
+ post confirm_recurring_transaction_url(suggestion)
+ assert_response :not_found
+ assert suggestion.reload.suggested?
+
+ post dismiss_recurring_transaction_url(suggestion)
+ assert_response :not_found
+ assert suggestion.reload.suggested?
+
+ delete recurring_transaction_url(series)
+ assert_response :not_found
+ assert series.reload.persisted?
+ end
+
+ test "an accountless series carries no account write gate" do
+ series = create_series(name: "No Account", account: nil)
+
+ patch recurring_transaction_url(series), params: { recurring_transaction: { name: "Renamed Fine" } }
+
+ assert_response :redirect
+ assert_equal "Renamed Fine", series.reload.name
+ end
+
+ # The destination is a write too: attaching a series to an account changes
+ # what that account's owners see, so a read-only share cannot receive one,
+ # whether by edit or at declaration.
+ test "a read-only account cannot become a series' destination" do
+ member = users(:family_member)
+ member.update!(preferences: (member.preferences || {}).merge("preview_features_enabled" => true))
+ series = create_series(name: "Wandering Bill", account: nil)
+
+ sign_in member
+
+ patch recurring_transaction_url(series), params: { recurring_transaction: { account_id: accounts(:credit_card).id } }
+ assert_response :unprocessable_entity
+ assert_nil series.reload.account_id
+
+ assert_no_difference "RecurringTransaction.count" do
+ post recurring_transactions_url, params: { recurring_transaction: {
+ name: "Declared On Read Only", amount: 12, first_due_on: Date.current.iso8601,
+ frequency_preset: "monthly", account_id: accounts(:credit_card).id
+ } }
+ end
+ assert_response :unprocessable_entity
+ assert_match I18n.t("recurring_transactions.create.account_invalid"), response.body
+ end
+
+ # Clearing a payment link is a statement about one bill; the opt-in copy
+ # must not blank the siblings' own links on the way through.
+ test "clearing the payment link never blanks the siblings" do
+ source = create_series(name: "Twitch Tier 1", merchant: merchants(:netflix), payment_url: "https://pay.example/1")
+ sibling = create_series(name: "Twitch Tier 2", merchant: merchants(:netflix), payment_url: "https://pay.example/keep")
+
+ patch recurring_transaction_url(source), params: {
+ apply_to_siblings: "1",
+ recurring_transaction: { payment_url: "" }
+ }
+
+ assert_response :redirect
+ assert_nil source.reload.payment_url.presence
+ assert_equal "https://pay.example/keep", sibling.reload.payment_url
+ end
+
+ test "the sibling copy skips series on accounts the user cannot write" do
+ member = users(:family_member)
+ member.update!(preferences: (member.preferences || {}).merge("preview_features_enabled" => true))
+
+ source = create_series(name: "Portal Bill", account: nil, merchant: merchants(:netflix))
+ # The credit card fixture is shared with the member read_only: visible,
+ # therefore inside accessible_by, and exactly what the copy must skip.
+ read_only_sibling = create_series(name: "Portal Bill RO", account: accounts(:credit_card),
+ merchant: merchants(:netflix), payment_url: "https://pay.example/theirs")
+
+ sign_in member
+ patch recurring_transaction_url(source), params: {
+ apply_to_siblings: "1",
+ recurring_transaction: { payment_url: "https://pay.example/mine" }
+ }
+
+ assert_response :redirect
+ assert_equal "https://pay.example/mine", source.reload.payment_url
+ assert_equal "https://pay.example/theirs", read_only_sibling.reload.payment_url
+ end
+
+ private
+
+ def create_series(name:, account: accounts(:depository), merchant: nil, status: "active", payment_url: nil)
+ @family.recurring_transactions.create!(
+ account: account,
+ merchant: merchant,
+ name: name,
+ amount: 25,
+ dedup_scope: name,
+ currency: "USD",
+ expected_day_of_month: 5,
+ last_occurrence_date: 1.month.ago.to_date,
+ next_expected_date: 5.days.from_now.to_date,
+ status: status,
+ payment_url: payment_url
+ )
+ end
+
+ def picker_entry(name:, amount:, account: accounts(:depository), merchant: nil, notes: nil, kind: nil, excluded: false)
+ transaction_attrs = { merchant: merchant }
+ transaction_attrs[:kind] = kind if kind
+
+ account.entries.create!(
+ date: Date.current, amount: amount, currency: "USD", name: name,
+ notes: notes, excluded: excluded,
+ entryable: Transaction.new(**transaction_attrs)
+ )
+ end
+end
diff --git a/test/controllers/transactions_controller_test.rb b/test/controllers/transactions_controller_test.rb
index 8b7becbae..23f7dbddb 100644
--- a/test/controllers/transactions_controller_test.rb
+++ b/test/controllers/transactions_controller_test.rb
@@ -8,6 +8,54 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
@entry = entries(:transaction)
end
+ # Bills has always linked out to transactions. Until now nothing linked back,
+ # so a transaction that settled a bill was a dead end. The link-back is part
+ # of the preview-gated bills surface, so the viewer needs the flag.
+ test "a transaction shows the bill it paid, and links to it" do
+ @user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => true))
+ series = @user.family.recurring_transactions.create!(
+ account: accounts(:depository), name: "Watson Property", amount: 2000,
+ currency: "USD", expected_day_of_month: 9, status: "active", manual: true,
+ bill_type: "bill", last_occurrence_date: Date.current,
+ next_expected_date: Date.current
+ )
+ series.recurring_occurrences.destroy_all
+ due = Date.current.beginning_of_month + 8
+ occurrence = series.recurring_occurrences.create!(
+ family: @user.family, original_due_on: due, due_on: due,
+ currency: "USD", expected_amount: 2000, status: "scheduled"
+ )
+ RecurringTransaction::Allocator.new(occurrence).allocate!(entry: @entry)
+
+ get transaction_url(@entry), headers: { "Turbo-Frame" => "drawer" }
+
+ assert_response :success
+ assert_match "Watson Property", response.body
+ assert_match bill_path(series), response.body, "the bill must be reachable from the transaction"
+ end
+
+ test "the bill link-back stays hidden without preview access" do
+ series = @user.family.recurring_transactions.create!(
+ account: accounts(:depository), name: "Watson Property", amount: 2000,
+ currency: "USD", expected_day_of_month: 9, status: "active", manual: true,
+ bill_type: "bill", last_occurrence_date: Date.current,
+ next_expected_date: Date.current
+ )
+ series.recurring_occurrences.destroy_all
+ due = Date.current.beginning_of_month + 8
+ occurrence = series.recurring_occurrences.create!(
+ family: @user.family, original_due_on: due, due_on: due,
+ currency: "USD", expected_amount: 2000, status: "scheduled"
+ )
+ RecurringTransaction::Allocator.new(occurrence).allocate!(entry: @entry)
+
+ get transaction_url(@entry), headers: { "Turbo-Frame" => "drawer" }
+
+ assert_response :success
+ assert_no_match bill_path(series), response.body,
+ "the preview-gated bill link must not render for a user without the flag"
+ end
+
test "index groups subcategories immediately after their parent in the category filter" do
get transactions_url
assert_response :success
diff --git a/test/helpers/bills_helper_test.rb b/test/helpers/bills_helper_test.rb
new file mode 100644
index 000000000..005e710e9
--- /dev/null
+++ b/test/helpers/bills_helper_test.rb
@@ -0,0 +1,340 @@
+require "test_helper"
+require "ostruct"
+
+class BillsHelperTest < ActionView::TestCase
+ # bills_match_reasons formats one money value, and format_money lives in
+ # ApplicationHelper rather than this module.
+ include ApplicationHelper
+
+ # The matcher has always stored WHY it matched something, in match_signals.
+ # Nothing rendered it, so the app showed a bare percentage instead of the
+ # facts the percentage is made of.
+ test "the signals behind an exact match read as plain reasons" do
+ reasons = bills_match_reasons(
+ { merchant: 0.40, amount: 0.30, date: 0.20, account: 0.10 },
+ currency: "USD",
+ expected: BigDecimal("6.44"),
+ actual: BigDecimal("6.44"),
+ due_on: Date.new(2026, 7, 31),
+ paid_on: Date.new(2026, 7, 31)
+ )
+
+ assert_equal [
+ I18n.t("bills.match.same_merchant"),
+ I18n.t("bills.match.exact_amount"),
+ I18n.t("bills.match.due_date")
+ ], reasons
+ end
+
+ # signals[:account] is a constant 0.10 on every candidate, because
+ # identity_matches? has already rejected everything on another account. A
+ # reason that never distinguishes anything is decoration.
+ test "the account signal is never rendered as a reason" do
+ reasons = bills_match_reasons(
+ { merchant: 0.40, amount: 0.30, date: 0.20, account: 0.10 },
+ currency: "USD", expected: 10, actual: 10,
+ due_on: Date.current, paid_on: Date.current
+ )
+
+ assert_no_match(/account/i, reasons.join(" "))
+ end
+
+ test "an inexact amount names the difference, and a nearby date names the gap" do
+ reasons = bills_match_reasons(
+ { name: 0.35, amount: 0.22, date: 0.14 },
+ currency: "USD",
+ expected: BigDecimal("14.99"),
+ actual: BigDecimal("13.27"),
+ due_on: Date.new(2026, 7, 31),
+ paid_on: Date.new(2026, 7, 30)
+ )
+
+ assert_includes reasons, I18n.t("bills.match.name_matches")
+ assert_includes reasons, I18n.t("bills.match.amount_off", amount: "$1.72")
+ assert_includes reasons, I18n.t("bills.match.days_before", count: 1)
+ end
+
+ test "a date after the due date reads as after" do
+ reasons = bills_match_reasons(
+ { date: 0.10 },
+ currency: "USD",
+ due_on: Date.new(2026, 7, 31),
+ paid_on: Date.new(2026, 8, 3)
+ )
+
+ assert_equal [ I18n.t("bills.match.days_after", count: 3) ], reasons
+ end
+
+ # A suggestion's entry FK nullifies rather than cascades, so the review queue
+ # can hold an allocation with no entry behind it. An unguarded subtraction
+ # would raise on the one screen this helper exists to improve.
+ test "a signal with no figures behind it is skipped rather than raising" do
+ assert_nothing_raised do
+ reasons = bills_match_reasons({ merchant: 0.40, amount: 0.30, date: 0.20 }, currency: "USD")
+
+ assert_equal [ I18n.t("bills.match.same_merchant") ], reasons
+ end
+ end
+
+ test "string keys out of jsonb work the same as symbols" do
+ reasons = bills_match_reasons(
+ { "merchant" => 0.40, "amount" => 0.30 },
+ currency: "USD", expected: 5, actual: 5
+ )
+
+ assert_equal [
+ I18n.t("bills.match.same_merchant"),
+ I18n.t("bills.match.exact_amount")
+ ], reasons
+ end
+
+ test "no signals at all yields no reasons" do
+ assert_empty bills_match_reasons(nil, currency: "USD")
+ assert_empty bills_match_reasons({}, currency: "USD")
+ end
+
+ # The bar exists to show a paycheck divided three ways. If the segments do
+ # not carry the three amounts the card states in words, it is decoration
+ # sitting where an explanation should be.
+ test "a healthy period divides into due, reserved and safe" do
+ period = build_period(income: 1200, due: 357.48, reserved: 338.12)
+
+ assert_equal [ :due, :reserved, :safe ], paycheck_allocation_segments(period).map(&:first)
+ assert_in_delta 29.79, paycheck_allocation_segments(period).first.last, 0.01
+ end
+
+ # Rounding three shares to two places can leave the track a hair short, and
+ # a fully allocated paycheck showing a sliver of empty bar is the one thing
+ # this bar must never say.
+ test "segments always add up to exactly 100" do
+ [ [ 1200, 357.48, 338.12 ], [ 1000, 333.33, 333.33 ], [ 999.99, 333.33, 0 ] ].each do |income, due, reserved|
+ segments = paycheck_allocation_segments(build_period(income: income, due: due, reserved: reserved))
+
+ assert_equal 100, segments.sum(&:last), "#{income}/#{due}/#{reserved} did not fill the track"
+ end
+ end
+
+ # Dividing a short period three ways would draw a safe slice out of money
+ # that is not there.
+ test "a short period reads as covered and short, never as safe" do
+ period = build_period(income: 500, due: 400, reserved: 300)
+
+ segments = paycheck_allocation_segments(period)
+
+ assert_equal [ :covered, :short ], segments.map(&:first)
+ assert_equal 100, segments.sum(&:last)
+ assert_in_delta 71.43, segments.first.last, 0.01
+ end
+
+ test "a window with no income has no bar at all" do
+ assert_empty paycheck_allocation_segments(build_period(income: 0, due: 28.71, reserved: 695.62))
+ end
+
+ test "a zero part is dropped rather than drawn as a hairline" do
+ segments = paycheck_allocation_segments(build_period(income: 1200, due: 0, reserved: 338.12))
+
+ assert_equal [ :reserved, :safe ], segments.map(&:first)
+ end
+
+ # "Paycheck" is an assumption. A declared income series can be a pension or
+ # an invoice, and the user's own setup already names it.
+ test "a period is headed by the income that opens it" do
+ period = build_period(income: 1200, due: 0, reserved: 0, sources: [ "Frito Lay" ])
+
+ assert_equal "#{I18n.l(period.starts_on, format: :short)} ยท Frito Lay", paycheck_period_heading(period),
+ "the date leads, because the timeline is read down its date anchors"
+ end
+
+ test "two sources on one day are counted, not merged into one name" do
+ period = build_period(income: 1400, due: 0, reserved: 0, sources: [ "Frito Lay", "Side work" ])
+
+ assert_match(/2 income sources/, paycheck_period_heading(period))
+ end
+
+
+ # Reported from live use: a Twitch charge showed "$11.99 of $11.99 paid" and
+ # "Overdue by 20 days" on the same line. The label only ever read dates, so a
+ # cycle settled after its due date stayed "overdue" forever.
+ test "a settled cycle is not overdue" do
+ occurrence = build_occurrence(due_on: 20.days.ago.to_date, status: "paid")
+
+ label = occurrence_due_label(occurrence)
+
+ assert_match(/was due/i, label)
+ assert_no_match(/overdue/i, label, "a paid cycle cannot also be late")
+ end
+
+ test "skipped and missed cycles read the same way" do
+ %w[skipped missed].each do |status|
+ occurrence = build_occurrence(due_on: 20.days.ago.to_date, status: status)
+
+ assert_no_match(/overdue/i, occurrence_due_label(occurrence),
+ "a #{status} cycle is closed, so it is not still running late")
+ end
+ end
+
+ test "an open cycle past its due date is still overdue" do
+ occurrence = build_occurrence(due_on: 20.days.ago.to_date, status: "scheduled")
+
+ assert_match(/overdue/i, occurrence_due_label(occurrence),
+ "the overdue case must survive: that is the one the label exists for")
+ end
+
+
+ # derived_state only calls a cycle overdue once its grace has run out, and
+ # the overview and get_bills both honour that. This label read the raw date,
+ # so the screen said Overdue by 1 day about a bill the assistant correctly
+ # called due.
+ test "a cycle inside its grace period is not labelled overdue" do
+ occurrence = build_occurrence(due_on: Date.current - 1, status: "scheduled")
+ assert_equal :due, occurrence.derived_state, "precondition: still inside grace"
+
+ label = occurrence_due_label(occurrence)
+
+ assert_no_match(/overdue/i, label)
+ assert_match(/due/i, label)
+ end
+
+ test "a cycle past its grace is still labelled overdue" do
+ occurrence = build_occurrence(due_on: Date.current - 30, status: "scheduled")
+ assert_equal :overdue, occurrence.derived_state, "precondition: grace exhausted"
+
+ assert_match(/overdue/i, occurrence_due_label(occurrence))
+ end
+
+ # --- Prepared-data helpers extracted from the templates, so the section,
+ # pulse, detail and paycheck views render precomputed values. ---
+
+ test "ambiguous row keys mark only genuine collisions" do
+ twin_a = stub_occurrence("Twitch", 5.99, id: "a1")
+ twin_b = stub_occurrence("Twitch", 5.99, id: "a2")
+ other_tier = stub_occurrence("Twitch", 11.99, id: "b")
+
+ keys = bills_ambiguous_row_keys([ twin_a, twin_b, other_tier ])
+
+ assert_includes keys, [ "Twitch", 5.99 ]
+ assert_not_includes keys, [ "Twitch", 11.99 ]
+ end
+
+ test "pay period markers land on the first row of each period with its summed total" do
+ period = OpenStruct.new(starts_on: Date.new(2026, 9, 1), ends_on: Date.new(2026, 9, 14))
+ first_inside = stub_occurrence("Rent", 2150, id: "one", due_on: Date.new(2026, 9, 2))
+ second_inside = stub_occurrence("Power", 80, id: "two", due_on: Date.new(2026, 9, 10))
+ outside = stub_occurrence("Later", 10, id: "three", due_on: Date.new(2026, 9, 20))
+
+ markers = bills_pay_period_markers([ first_inside, second_inside, outside ], [ period ])
+
+ assert_equal [ "one" ], markers.keys
+ assert_equal 2230, markers["one"][:due_total]
+ assert_equal period, markers["one"][:period]
+ end
+
+ test "no pay periods means no markers" do
+ occurrence = stub_occurrence("Rent", 1, id: "x", due_on: Date.current)
+
+ assert_empty bills_pay_period_markers([ occurrence ], [])
+ end
+
+ test "month progress divides paid, overdue and upcoming out of one total" do
+ progress = bills_month_progress(paid: 50, remaining: 50, overdue: 25)
+
+ assert_equal 100.0, progress[:total]
+ assert_in_delta 50.0, progress[:paid_pct]
+ assert_in_delta 25.0, progress[:overdue_pct]
+ assert_in_delta 25.0, progress[:upcoming_pct]
+ end
+
+ test "an empty month draws no bar" do
+ progress = bills_month_progress(paid: nil, remaining: nil, overdue: nil)
+
+ assert_equal 0.0, progress[:total]
+ assert_equal 0, progress[:paid_pct]
+ end
+
+ test "overdue money never claims more of the bar than what remains" do
+ progress = bills_month_progress(paid: 80, remaining: 20, overdue: 500)
+
+ assert_in_delta 20.0, progress[:overdue_pct]
+ assert_in_delta 0.0, progress[:upcoming_pct]
+ end
+
+ test "matcher hints strip blanks and cast the tolerance" do
+ series = OpenStruct.new(matcher_hints: { "name_aliases" => [ "PEPSICO", "" ], "learned_tolerance_pct" => "7.5" })
+
+ hints = bills_matcher_hints(series)
+
+ assert_equal [ "PEPSICO" ], hints[:aliases]
+ assert_equal 7.5, hints[:learned_pct]
+ end
+
+ test "plan sections split the bridge from the timeline and pick the warning state" do
+ short_bridge = build_period(income: 0, due: 400, reserved: 0, leading: true, cash_on_hand: BigDecimal("100"))
+ period = build_period(income: 1200, due: 300, reserved: 100)
+
+ sections = paycheck_plan_sections([ short_bridge, period ])
+
+ assert_equal [ period ], sections[:periods]
+ assert_equal short_bridge, sections[:shortfall]
+ assert_nil sections[:bridge_note]
+ end
+
+ test "a covered bridge with items becomes the quiet note, not the warning" do
+ covered = build_period(income: 0, due: 50, reserved: 0, leading: true,
+ cash_on_hand: BigDecimal("500"), items: [ :a_bill ])
+
+ sections = paycheck_plan_sections([ covered ])
+
+ assert_nil sections[:shortfall]
+ assert_equal covered, sections[:bridge_note]
+ end
+
+ test "no plan yields empty sections" do
+ assert_empty paycheck_plan_sections(nil)
+ end
+
+ private
+
+ def stub_occurrence(name, amount, id:, due_on: Date.current)
+ OpenStruct.new(
+ id: id,
+ due_on: due_on,
+ resolved_expected_amount: amount,
+ recurring_transaction: OpenStruct.new(display_name: name)
+ )
+ end
+
+ def build_occurrence(due_on:, status:)
+ family = users(:family_admin).family
+ series = family.recurring_transactions.create!(
+ name: "Twitch #{status} #{due_on}", account: accounts(:depository),
+ amount: 11.99, currency: "USD", expected_day_of_month: due_on.day,
+ status: "active", bill_type: "subscription", manual: true,
+ dedup_scope: "twitch-#{status}-#{due_on}",
+ last_occurrence_date: due_on, next_expected_date: due_on
+ )
+ series.recurring_occurrences.destroy_all
+ series.recurring_occurrences.create!(
+ family: family, original_due_on: due_on, due_on: due_on,
+ currency: "USD", expected_amount: 11.99, status: status,
+ closed_at: (status == "scheduled" ? nil : Time.current)
+ )
+ end
+ def build_period(income:, due:, reserved:, sources: [ "Payroll" ], leading: false, cash_on_hand: nil, items: [])
+ obligations = BigDecimal(due.to_s) + BigDecimal(reserved.to_s)
+
+ RecurringTransaction::PaycheckPlanner::Period.new(
+ starts_on: Date.new(2026, 8, 19),
+ ends_on: Date.new(2026, 8, 25),
+ income: BigDecimal(income.to_s),
+ income_sources: sources,
+ items: items,
+ due_total: BigDecimal(due.to_s),
+ reserved_total: BigDecimal(reserved.to_s),
+ obligation_total: obligations,
+ remaining: BigDecimal(income.to_s) - obligations,
+ leading: leading,
+ final: false,
+ cash_on_hand: cash_on_hand
+ )
+ end
+end
diff --git a/test/helpers/recurring_transactions_helper_test.rb b/test/helpers/recurring_transactions_helper_test.rb
new file mode 100644
index 000000000..eccd7143e
--- /dev/null
+++ b/test/helpers/recurring_transactions_helper_test.rb
@@ -0,0 +1,22 @@
+require "test_helper"
+
+class RecurringTransactionsHelperTest < ActionView::TestCase
+ include ApplicationHelper
+
+ # ordinalize always emits English suffixes; the day picker has to follow the
+ # active locale the way ApplicationHelper#localized_ordinal does.
+ test "day options follow the locale's ordinals" do
+ assert_equal "1st", frequency_day_options.first.first
+
+ I18n.with_locale(:ca) do
+ assert_equal "1r", frequency_day_options.first.first
+ end
+ end
+
+ test "day options end with the last-day sentinel" do
+ label, value = frequency_day_options.last
+
+ assert_equal RecurrenceRule::LAST, value
+ assert_equal I18n.t("recurring_transactions.frequency.last_day"), label
+ end
+end
diff --git a/test/models/recurring_transaction/ai_setup_suggester_test.rb b/test/models/recurring_transaction/ai_setup_suggester_test.rb
new file mode 100644
index 000000000..24fe54b75
--- /dev/null
+++ b/test/models/recurring_transaction/ai_setup_suggester_test.rb
@@ -0,0 +1,137 @@
+require "test_helper"
+
+class RecurringTransaction::AiSetupSuggesterTest < ActiveSupport::TestCase
+ Suggester = RecurringTransaction::AiSetupSuggester
+ RawSuggestion = Provider::LlmConcept::BillSetupSuggestion
+
+ setup do
+ @user = users(:family_admin)
+ @family = @user.family
+ @family.recurring_transactions.destroy_all
+ @entries = [ create_entry(name: "GYM MEMBERSHIP", amount: 40, date: Date.current) ]
+ end
+
+ test "raises when no LLM provider is configured" do
+ Provider::Registry.stubs(:preferred_llm_provider).returns(nil)
+
+ assert_raises(Suggester::Error) do
+ Suggester.new(@family, user: @user).suggest_from_entries(@entries)
+ end
+ end
+
+ test "raises on empty charge history instead of asking the model to guess" do
+ stub_provider(raw(name: "X"))
+
+ assert_raises(Suggester::Error) do
+ Suggester.new(@family, user: @user).suggest_from_entries([])
+ end
+ end
+
+ test "normalizes provider output: presets clamped, ranges enforced" do
+ stub_provider(raw(
+ name: "Gym", amount: 40.0, frequency: "fortnightly", day_of_month: 45,
+ weekday: 9, month_of_year: 0, bill_type: "loan", confidence: 3.5
+ ))
+
+ suggestion = Suggester.new(@family, user: @user).suggest_from_entries(@entries)
+
+ assert_nil suggestion.frequency, "an invented cadence must not survive"
+ assert_nil suggestion.day_of_month
+ assert_nil suggestion.weekday
+ assert_nil suggestion.month_of_year
+ assert_nil suggestion.bill_type
+ assert_equal 1.0, suggestion.confidence, "confidence clamps into 0..1"
+ assert_equal 40.0, suggestion.amount.to_f
+ end
+
+ test "an explicit autopay false survives normalization as a real proposal" do
+ stub_provider(raw(autopay: false))
+
+ suggestion = Suggester.new(@family, user: @user).suggest_from_entries(@entries)
+
+ assert_equal false, suggestion.autopay, "false proposes turning autopay off; only nil means no proposal"
+ assert suggestion.any_proposal?
+ end
+
+ test "a non-boolean autopay normalizes to no proposal" do
+ stub_provider(raw(autopay: "yes"))
+
+ suggestion = Suggester.new(@family, user: @user).suggest_from_entries(@entries)
+
+ assert_nil suggestion.autopay
+ assert_not suggestion.any_proposal?
+ end
+
+ test "resolves the category to this family's own id, case-insensitively" do
+ category = @family.categories.create!(name: "Utilities", color: "#0000ff")
+ stub_provider(raw(category_name: "utilities"))
+
+ suggestion = Suggester.new(@family, user: @user).suggest_from_entries(@entries)
+
+ assert_equal category.id, suggestion.category_id
+ assert_equal "Utilities", suggestion.category_name
+ end
+
+ test "an LLM-invented category resolves to nothing" do
+ stub_provider(raw(category_name: "Definitely Not A Real Category"))
+
+ suggestion = Suggester.new(@family, user: @user).suggest_from_entries(@entries)
+
+ assert_nil suggestion.category_id
+ assert_nil suggestion.category_name
+ end
+
+ test "configure mode sends the series' current configuration to the provider" do
+ series = @family.recurring_transactions.create!(
+ name: "Gym", account: accounts(:depository), amount: 40, currency: "USD",
+ expected_day_of_month: 9, anchor_date: Date.current,
+ last_occurrence_date: 1.month.ago.to_date, next_expected_date: Date.current,
+ status: "active", manual: true
+ )
+ # On the series' expected day: matching_transactions is day-of-month
+ # scoped, so a drifting date would give the suggester no history.
+ create_entry(name: "Gym", amount: 40, date: Date.current.beginning_of_month + 8.days - 1.month)
+
+ captured = nil
+ provider = Object.new
+ provider.define_singleton_method(:suggest_bill_setup) do |**kwargs|
+ captured = kwargs
+ Provider::Response.new(success?: true, data: RawSuggestion.new(
+ name: nil, amount: nil, frequency: nil, day_of_month: nil, weekday: nil,
+ month_of_year: nil, category_name: nil, bill_type: nil, autopay: nil,
+ confidence: 0.9, rationale: "already right"
+ ), error: nil)
+ end
+ Provider::Registry.stubs(:preferred_llm_provider).returns(provider)
+
+ suggestion = Suggester.new(@family, user: @user).suggest_configuration(series)
+
+ assert_equal "Gym", captured[:current_config][:name]
+ refute suggestion.any_proposal?, "all-null fields mean the configuration is already right"
+ end
+
+ private
+
+ def raw(**overrides)
+ RawSuggestion.new(**{
+ name: nil, amount: nil, frequency: nil, day_of_month: nil, weekday: nil,
+ month_of_year: nil, category_name: nil, bill_type: nil, autopay: nil,
+ confidence: nil, rationale: nil
+ }.merge(overrides))
+ end
+
+ def stub_provider(suggestion)
+ provider = Object.new
+ provider.define_singleton_method(:suggest_bill_setup) do |**|
+ Provider::Response.new(success?: true, data: suggestion, error: nil)
+ end
+ Provider::Registry.stubs(:preferred_llm_provider).returns(provider)
+ end
+
+ def create_entry(name:, amount:, date:)
+ accounts(:depository).entries.create!(
+ date: date, amount: amount, currency: "USD", name: name,
+ entryable: Transaction.new
+ )
+ end
+end
diff --git a/test/system/bills_mobile_test.rb b/test/system/bills_mobile_test.rb
new file mode 100644
index 000000000..432221a44
--- /dev/null
+++ b/test/system/bills_mobile_test.rb
@@ -0,0 +1,163 @@
+require "application_system_test_case"
+require "ostruct"
+
+# Bills is used on a phone, and the app is installable as a PWA, so "fits a
+# phone" is a correctness property rather than a polish one.
+#
+# The row used to carry a date column, an icon, an amount, a Details button and
+# a pay action, all shrink-0. At 375px those added up to more than the row was
+# wide, so the bill's own name collapsed to nothing AND the row still
+# overflowed. Because is `overflow-y-auto`, the CSS overflow spec
+# computes its overflow-x to `auto` too, which turned one wide row into a
+# whole page that scrolled sideways.
+class BillsMobileTest < ApplicationSystemTestCase
+ PHONE = [ 375, 812 ].freeze
+
+ setup do
+ sign_in @user = users(:family_admin)
+ @user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => true))
+ @family = @user.family
+ page.driver.browser.manage.window.resize_to(*PHONE)
+ end
+
+ teardown do
+ page.driver.browser.manage.window.resize_to(1400, 1400)
+ end
+
+ test "no Bills view scrolls sideways on a phone" do
+ # The AI chips render only with consent plus a provider, so without this
+ # the overview would be measured without a whole strip it can carry.
+ Provider::Registry.stubs(:preferred_llm_provider).returns(OpenStruct.new)
+
+ # A long name, a five-figure amount and a note: the row at its widest.
+ bill = @family.recurring_transactions.create!(
+ name: "Watson Property Management Company LLC",
+ account: accounts(:depository), amount: 12_450.75, currency: "USD",
+ notes: "Account 4821, on the Amex",
+ expected_day_of_month: Date.current.day, anchor_date: Date.current,
+ last_occurrence_date: Date.current, next_expected_date: Date.current,
+ status: "active", manual: true, payment_url: "https://example.com/pay"
+ )
+
+ # Without declared income the Paycheck view is an empty state, so the view
+ # nominally covered here was never the one that renders periods, heroes
+ # and allocation bars.
+ payday = Date.current + 3
+ @family.recurring_transactions.create!(
+ name: "Frito Lay Bakersfield Payroll", account: accounts(:depository),
+ amount: -1840, currency: "USD", bill_type: "income",
+ expected_day_of_month: payday.day, anchor_date: payday,
+ last_occurrence_date: payday, next_expected_date: payday,
+ status: "active", manual: true
+ )
+
+ # Detection's suggested strip: a long name fighting two buttons for a row.
+ @family.recurring_transactions.create!(
+ name: "Neighborhood Fitness and Racquet Club Membership",
+ account: accounts(:depository), amount: 89.99, currency: "USD",
+ expected_day_of_month: Date.current.day, anchor_date: Date.current,
+ last_occurrence_date: Date.current, next_expected_date: Date.current + 1.month,
+ status: "suggested", occurrence_count: 3
+ )
+
+ %w[overview calendar paycheck all].each do |view|
+ visit view == "overview" ? bills_url : bills_url(view: view)
+
+ # The widest optional strips have to actually be on the page for the
+ # measurement to mean anything.
+ if view == "overview"
+ assert_text I18n.t("bills.ai_prompts.due_before_paycheck")
+ assert_text "Neighborhood Fitness and Racquet Club Membership"
+ end
+
+ # The management table reflows into the stacked list on a narrow
+ # container; a table that merely scrolls sideways would pass the
+ # document measurement below while still hiding six of its columns.
+ assert_no_selector "table", visible: true if view == "all"
+
+ assert_no_horizontal_scroll("the #{view} view")
+ end
+
+ # The bill's own page: chart, history and configuration in one column.
+ visit bill_url(bill)
+ assert_text bill.display_name
+ assert_no_horizontal_scroll("the bill page")
+
+ # The control for the reflow above: given its width back, the container
+ # query must bring the table back, or the check proved only that a table
+ # never renders at all. 1920 and not 1400, because the switch reads the
+ # container: the app shell's sidebars eat ~885px before the bills column
+ # gets any, and 1400 leaves it narrower than the table deserves.
+ page.driver.browser.manage.window.resize_to(1920, 1400)
+ visit bills_url(view: "all")
+ assert_selector "table", visible: true
+
+ # The reserved list is behind a disclosure, so its rows are only ever
+ # measured with it open.
+ visit bills_url(view: "paycheck")
+ assert_text I18n.t("bills.paycheck.reserved_ahead")
+ all("summary", text: I18n.t("bills.paycheck.reserved_ahead")).each(&:click)
+ assert_no_horizontal_scroll("the paycheck view with reserved amounts open")
+
+ # And with a row expanded, which is the widest the page ever gets.
+ visit bills_url
+ find("a[data-turbo-frame^='pane_recurring_occurrence_']", match: :first).click
+ assert_text bill.display_name
+ assert_no_horizontal_scroll("the overview with a row expanded")
+ end
+
+ # A green overflow assertion proves nothing unless it can go red, and this
+ # one measures a property that is zero on most pages by accident. So: force
+ # an overflow and confirm the measurement sees it.
+ test "the overflow check actually detects overflow" do
+ visit bills_url
+ assert_no_horizontal_scroll("the overview")
+
+ page.execute_script(<<~JS)
+ const wide = document.createElement("div");
+ wide.style.width = "3000px";
+ wide.style.height = "1px";
+ document.querySelector("#main").appendChild(wide);
+ JS
+
+ assert_raises(Minitest::Assertion) { assert_no_horizontal_scroll("a deliberately wide element") }
+ end
+
+ test "the payment drawer is escapable on a phone" do
+ bill = @family.recurring_transactions.create!(
+ name: "CITY WATER", account: accounts(:depository), amount: 80, currency: "USD",
+ expected_day_of_month: Date.current.day, anchor_date: Date.current,
+ last_occurrence_date: Date.current, next_expected_date: Date.current,
+ status: "active", manual: true
+ )
+ occurrence = bill.recurring_occurrences.order(:due_on).first
+
+ visit recurring_occurrence_url(occurrence)
+
+ # DS::Dialog hides its own close button below lg when responsive, which
+ # leaves Esc and a 12px gutter tap as the only ways out. A phone has no Esc
+ # key, so this surface renders its own.
+ within("dialog") do
+ assert_selector "button[aria-label='#{I18n.t("ds.dialog.close")}']", visible: true
+ end
+ assert_no_horizontal_scroll("the payment drawer")
+ end
+
+ private
+ # The document must never be wider than the viewport, and neither must the
+ # scroll container inside it.
+ def assert_no_horizontal_scroll(label)
+ overflow = page.evaluate_script(<<~JS)
+ (() => {
+ const main = document.querySelector("#main");
+ return {
+ doc: document.documentElement.scrollWidth - document.documentElement.clientWidth,
+ main: main ? main.scrollWidth - main.clientWidth : 0
+ };
+ })()
+ JS
+
+ assert_operator overflow["doc"], :<=, 1, "#{label} scrolls the document sideways"
+ assert_operator overflow["main"], :<=, 1, "#{label} scrolls its main content sideways"
+ end
+end
diff --git a/test/system/declare_and_pay_bill_test.rb b/test/system/declare_and_pay_bill_test.rb
new file mode 100644
index 000000000..009751a79
--- /dev/null
+++ b/test/system/declare_and_pay_bill_test.rb
@@ -0,0 +1,124 @@
+require "application_system_test_case"
+
+class DeclareAndPayBillTest < ApplicationSystemTestCase
+ teardown do
+ travel_back
+ end
+
+ setup do
+ sign_in @user = users(:family_admin)
+ @user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => true))
+ @family = @user.family
+ @account = accounts(:depository)
+ end
+
+ test "declare rent, allocate a real payment, watch it stay partial, settle it" do
+ # due lands ten days out. Late in a month that crosses into the next one,
+ # which files the row under a different section with a different subline,
+ # so the clock is pinned where the test's premise holds.
+ travel_to Date.current.beginning_of_month + 9.days
+
+ due = Date.current + 10
+ payment = @account.entries.create!(
+ date: Date.current - 1, amount: 537.50, currency: "USD", name: "WATSON PROPERTY LLC",
+ entryable: Transaction.new
+ )
+
+ visit bills_url
+ # The switcher and the empty state both offer Add bill; either works.
+ click_on I18n.t("bills.index.add_bill"), match: :first
+ fill_in I18n.t("recurring_transactions.form.name_label"), with: "Watson Property"
+ fill_in I18n.t("recurring_transactions.form.amount_label"), with: "2150"
+ fill_in I18n.t("recurring_transactions.form.first_due_on_label"), with: due.strftime("%m/%d/%Y")
+ # Account is optional (DS::Select is a custom combobox; the family
+ # fallback covers candidates), so the bill is declared without one.
+ click_button I18n.t("recurring_transactions.form.submit")
+
+ assert_text "Watson Property"
+
+ # Scan, then inspect: the row itself opens the expansion. It is due in ten
+ # days, so the row carries no call to action -- there is nothing to chase
+ # yet -- and the verb lives in the expansion, spelled out.
+ #
+ # Targeted by the frame it loads rather than by bare name: the bill also
+ # appears in the summary's Next up strip, which goes to its page instead.
+ # And by name within that: the index materializes the fixture family's
+ # series on first visit now, so "first row" is no longer this bill.
+ find("a[data-turbo-frame^='pane_recurring_occurrence_']", text: "Watson Property", match: :first).click
+ within(find("turbo-frame[id^='pane_recurring_occurrence_']", match: :first)) do
+ click_on I18n.t("bills.find_payment")
+ end
+
+ # Act: the drawer leads with what is owed.
+ assert_text I18n.t("recurring_occurrences.show.remaining", amount: "$2,150.00")
+
+ # This bill was declared a moment ago, so the matcher knows it only by the
+ # name that was typed. "WATSON PROPERTY LLC" is not yet one of its names,
+ # so there is honestly nothing to suggest -- and the wider list is open
+ # rather than collapsed, because otherwise that would be a dead end.
+ assert_text I18n.t("recurring_occurrences.show.no_ranked_candidates")
+ assert_text payment.name
+
+ # Attach the real $537.50 payment. Every candidate row IS its own button,
+ # so there is one tap target per transaction rather than a small one beside
+ # the text.
+ within(find("form", text: payment.name, match: :first)) do
+ find("button").click
+ end
+
+ # Linking lands back on the worklist, and the row must say the bill is
+ # partly paid rather than settled: $537.50 against $2,150 is not rent.
+ assert_text I18n.t("bills.attention.partial", amount: "$1,612.50")
+
+ # Journey C picks up exactly where that leaves off: the row's verb has
+ # become Add payment, and the rest is settled from the drawer.
+ click_on I18n.t("bills.add_payment"), match: :first
+ assert_text I18n.t("recurring_occurrences.show.remaining", amount: "$1,612.50")
+
+ click_on I18n.t("recurring_occurrences.show.mark_paid")
+ # Synchronize on durable page state, not the toast: toasts auto-dismiss on
+ # their own clock and have burned CI runs before (TradesTest). The drawer's
+ # remaining-amount line vanishing proves the settle round-tripped.
+ assert_no_text I18n.t("recurring_occurrences.show.remaining", amount: "$1,612.50")
+
+ bill = @family.recurring_transactions.find_by!(name: "Watson Property")
+ occurrence = bill.recurring_occurrences.find_by!(due_on: due)
+ assert occurrence.paid?
+ assert_equal 2, occurrence.allocations.count
+ assert_equal 2150, occurrence.allocations.sum(:allocated_amount)
+ end
+
+ test "declare a bill by searching every transaction and picking one" do
+ charge = @account.entries.create!(
+ date: Date.current - 3, amount: 537.50, currency: "USD", name: "WATSON PROPERTY LLC",
+ entryable: Transaction.new
+ )
+
+ visit bills_url
+ click_on I18n.t("bills.index.add_bill"), match: :first
+
+ # A dead-end search first: nothing matches, and the way back works.
+ click_on I18n.t("recurring_transactions.new.search_all_cta")
+ fill_in I18n.t("recurring_transactions.pick_entry.search_placeholder"), with: "zzz-nothing"
+ find("input[name='q']").send_keys(:enter)
+ assert_text I18n.t("recurring_transactions.pick_entry.no_results", query: "zzz-nothing")
+
+ click_on I18n.t("recurring_transactions.pick_entry.back")
+ assert_field I18n.t("recurring_transactions.form.name_label"), with: ""
+
+ # Now the real search: find the charge, pick it, land in a prefilled form.
+ click_on I18n.t("recurring_transactions.new.search_all_cta")
+ fill_in I18n.t("recurring_transactions.pick_entry.search_placeholder"), with: "WATSON"
+ find("input[name='q']").send_keys(:enter)
+
+ click_on "WATSON PROPERTY LLC"
+
+ assert_field I18n.t("recurring_transactions.form.name_label"), with: "WATSON PROPERTY LLC"
+ assert_field I18n.t("recurring_transactions.form.amount_label"), with: "537.5"
+ click_button I18n.t("recurring_transactions.form.submit")
+
+ assert_text "WATSON PROPERTY LLC"
+ bill = @family.recurring_transactions.find_by!(name: "WATSON PROPERTY LLC")
+ assert_equal charge.account_id, bill.account_id, "the picked entry's account rides the prefill"
+ end
+end
diff --git a/test/system/find_my_bills_test.rb b/test/system/find_my_bills_test.rb
new file mode 100644
index 000000000..cb75f1fe2
--- /dev/null
+++ b/test/system/find_my_bills_test.rb
@@ -0,0 +1,45 @@
+require "application_system_test_case"
+
+class FindMyBillsTest < ApplicationSystemTestCase
+ setup do
+ sign_in @user = users(:family_admin)
+ @user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => true))
+ @family = @user.family
+ @family.recurring_transactions.destroy_all
+ @account = accounts(:depository)
+ end
+
+ test "an empty Bills page finds, reviews and confirms a detected bill" do
+ 3.times do |i|
+ @account.entries.create!(
+ date: Date.current - i.months, amount: 40, currency: "USD",
+ name: "GYM MEMBERSHIP", entryable: Transaction.new
+ )
+ end
+
+ visit bills_url
+ assert_text I18n.t("bills.index.empty.title")
+
+ click_on I18n.t("bills.index.empty.action")
+
+ # Detection ran synchronously; the review strip presents what it found.
+ # (Case-insensitive: the strip heading renders through CSS `uppercase`.)
+ assert_text(/#{Regexp.escape(I18n.t("recurring_transactions.suggested.title"))}/i)
+ assert_text "GYM MEMBERSHIP"
+
+ # Confirm inside the GYM row specifically: fixture entries can produce
+ # other suggestions, and this test must not depend on their order.
+ row = find(:xpath,
+ "//div[contains(@class,'justify-between')][.//p[contains(normalize-space(),'GYM MEMBERSHIP')]]",
+ match: :first)
+ within(row) { click_on I18n.t("recurring_transactions.suggested.confirm") }
+
+ # Confirmed on the page it was reviewed on: the bill is a worklist row now.
+ assert_text I18n.t("recurring_transactions.confirmed")
+ assert_current_path bills_path
+
+ bill = @family.recurring_transactions.find_by!(name: "GYM MEMBERSHIP")
+ assert bill.active?
+ assert_operator bill.recurring_occurrences.count, :>, 0
+ end
+end
diff --git a/test/system/recurring_transaction_frequency_test.rb b/test/system/recurring_transaction_frequency_test.rb
new file mode 100644
index 000000000..cbaa972a0
--- /dev/null
+++ b/test/system/recurring_transaction_frequency_test.rb
@@ -0,0 +1,37 @@
+require "application_system_test_case"
+
+class RecurringTransactionFrequencyTest < ApplicationSystemTestCase
+ setup do
+ sign_in @user = users(:family_admin)
+ @user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => true))
+ @recurring = recurring_transactions(:netflix_subscription)
+ end
+
+ test "the frequency picker reveals the fields for the chosen preset and saves" do
+ visit edit_recurring_transaction_url(@recurring)
+
+ # Monthly is the current cadence: the day group shows, the weekday group
+ # does not.
+ day_field = find("[data-presets*='monthly']", match: :first, visible: :all)
+ weekday_field = find("[data-presets='weekly,biweekly']", visible: :all)
+ assert day_field.visible?
+ assert_not weekday_field.visible?
+
+ select I18n.t("recurring_transactions.frequency_presets.biweekly"),
+ from: I18n.t("recurring_transactions.form.frequency_label")
+
+ assert weekday_field.visible?
+ assert_not day_field.visible?
+
+ select I18n.t("date.day_names")[5],
+ from: I18n.t("recurring_transactions.form.frequency_weekday_label")
+ click_button I18n.t("recurring_transactions.form.submit")
+
+ # The update redirects via the referer; the cadence label lives on the
+ # All bills management view now.
+ visit bills_url(view: "all")
+ assert_text I18n.t("recurring_transactions.frequency.biweekly", weekday: I18n.t("date.day_names")[5])
+ assert_equal [ [ "weekly", 2, 5 ] ],
+ @recurring.reload.recurrence_rules.map { |rule| [ rule.frequency, rule.interval, rule.weekday ] }
+ end
+end