From fe0d27471d5abe6367db461218e693e1967acc0e Mon Sep 17 00:00:00 2001
From: Brandon
Date: Tue, 1 Sep 2026 17:08:58 -0700
Subject: [PATCH] feat(bills): the bills pages, calendar feed and in-page AI
helpers (#3202)
* feat(bills): the bills pages, calendar feed and in-page AI helpers
Second of three chunks carved out of #3083, stacked on the schema and domain
core. This is everything a user sees and clicks. The whole surface sits behind
the preview flag, so it is unreachable until someone opts in.
Pages, all under one nav entry:
- the pay run, a month calendar, the full bills table, and the paycheck planner
- a detail drawer per bill, with payment history, price changes and cost
analytics
- create and edit flows for bills, subscriptions, installment plans and income
The overview marks pay periods inside the month, so a weekly paycheck no longer
reads as one undifferentiated month of bills. Markers appear only when income
actually subdivides the month, which means monthly and undeclared income render
exactly as before and there is no new setting to configure.
Navigation and design system:
- one preview-gated nav item shared by the desktop rail and the mobile bar
- DS::Sparkline for payment-history charts, replacing raw SVG in views
- status badges render through DS::Pill rather than hand-rolled spans
- the suggestions panel is a disclosure that remembers being collapsed, per
device, the way privacy mode and the sidebar width already do
- every surface reflows to phone widths without horizontal scroll
Calendar feed: a signed ICS feed per family, served sessionless by token, with a
reset that revokes previously shared URLs.
In-page AI helpers: smart fill on the bill form and a smart configuration
proposal on an existing bill, each reading a bounded slice of charge history.
Provider-side prompt assembly sits behind the existing LlmConcept interface,
with an implementation for each of the two providers. These belong here rather
than with the assistant tools because they are buttons on these pages and lean
on the provider suggester, not on the tool registry.
Suite 7,776 runs green apart from the pre-existing passkey-session flake, which passes standalone. Rubocop clean, eager loading verified. The hosting guide for the feature ships here rather than with the schema, since its instructions walk pages this PR introduces.
* Render the suggested strip through DS::Disclosure
The hand-rolled details pair predates the component. The card_inset
variant is the same shape, so the strip now inherits the design system
chrome, and the persisted-disclosure controller rides along unchanged.
* Route the remaining hand-rolled chips through the design system
The subscription-state chips, rule-match chips and match-reason chips
become DS::Pill, with the state chips extracted to one shared partial so
the drawer and the summary tab stop carrying copy-pasted markup. The AI
prompt chips become DS::Button and the bills-index filter becomes
DS::SearchInput, both of which this PR already uses elsewhere for the
same shapes.
* Fix erb_lint whitespace offenses in bills views
* Address the post-ready review round
* Require a writable destination account and gate the feed on preview
* Reject an unresolvable declared account out loud
---
.../sure-design-system/_generated.css | 24 +
app/components/DS/buttonish.rb | 21 +
app/components/DS/link.rb | 7 +
app/components/DS/sparkline.html.erb | 13 +
app/components/DS/sparkline.rb | 55 +
.../bills/ai_reviews_controller.rb | 26 +
.../bills/smart_configurations_controller.rb | 32 +
app/controllers/bills_controller.rb | 647 +++++++
app/controllers/bills_feeds_controller.rb | 75 +
.../concerns/recurring_feature_guardable.rb | 36 +
.../recurring_allocations_controller.rb | 138 ++
.../recurring_occurrences_controller.rb | 204 +++
.../smart_fills_controller.rb | 95 +
.../recurring_transactions_controller.rb | 451 ++++-
app/helpers/application_helper.rb | 19 +
app/helpers/bills_helper.rb | 365 ++++
app/helpers/recurring_transactions_helper.rb | 42 +
.../controllers/app_layout_controller.js | 10 +
.../frequency_fields_controller.js | 20 +
.../persisted_disclosure_controller.js | 30 +
app/models/family.rb | 40 +
app/models/provider/anthropic.rb | 25 +
.../anthropic/bill_setup_suggester.rb | 178 ++
app/models/provider/llm_concept.rb | 12 +
app/models/provider/openai.rb | 25 +
.../provider/openai/bill_setup_suggester.rb | 169 ++
.../ai_setup_suggester.rb | 128 ++
.../recurring_transaction/declared_bill.rb | 16 +-
app/views/bills/_ai_prompts.html.erb | 21 +
app/views/bills/_calendar_chip.html.erb | 28 +
app/views/bills/_cancelled_notice.html.erb | 18 +
app/views/bills/_detail.html.erb | 222 +++
app/views/bills/_month_pulse.html.erb | 112 ++
app/views/bills/_notice.html.erb | 25 +
app/views/bills/_occurrence.html.erb | 138 ++
app/views/bills/_occurrence_section.html.erb | 46 +
app/views/bills/_pay_period_marker.html.erb | 47 +
app/views/bills/_paycheck_bridge.html.erb | 40 +
app/views/bills/_paycheck_income.html.erb | 57 +
app/views/bills/_paycheck_item.html.erb | 25 +
app/views/bills/_paycheck_period.html.erb | 109 ++
app/views/bills/_paycheck_shortfall.html.erb | 37 +
app/views/bills/_row_action.html.erb | 79 +
app/views/bills/_state_chips.html.erb | 19 +
app/views/bills/_summary.html.erb | 137 ++
app/views/bills/_view_switcher.html.erb | 50 +
app/views/bills/all.html.erb | 233 +++
app/views/bills/calendar.html.erb | 96 +
app/views/bills/index.html.erb | 181 ++
app/views/bills/pane.html.erb | 30 +
app/views/bills/pane_close.html.erb | 1 +
app/views/bills/paycheck.html.erb | 74 +
app/views/bills/show.html.erb | 107 ++
.../smart_configurations/_proposal.html.erb | 12 +
.../bills/smart_configurations/show.html.erb | 93 +
.../_budget_category.html.erb | 12 +
app/views/layouts/application.html.erb | 1 +
.../recurring_occurrences/_candidate.html.erb | 63 +
app/views/recurring_occurrences/show.html.erb | 314 ++++
.../recurring_transactions/_form.html.erb | 178 ++
.../_pay_action.html.erb | 78 +
.../_picker_row.html.erb | 15 +
.../_projected_transaction.html.erb | 1 +
.../_suggested_series.html.erb | 61 +
.../recurring_transactions/edit.html.erb | 9 +
.../recurring_transactions/index.html.erb | 147 +-
app/views/recurring_transactions/new.html.erb | 77 +
.../pick_entry.html.erb | 37 +
.../transactions/_mark_recurring.html.erb | 36 +-
app/views/transactions/show.html.erb | 29 +
config/locales/views/bills/en.yml | 327 ++++
config/locales/views/budgets/en.yml | 4 +
config/locales/views/layout/en.yml | 1 +
.../views/recurring_allocations/en.yml | 15 +
.../views/recurring_occurrences/en.yml | 53 +
.../views/recurring_transactions/en.yml | 153 +-
config/locales/views/transactions/en.yml | 4 +
config/routes.rb | 48 +-
design/tokens/sure.tokens.json | 4 +
docs/hosting/recurring-bills.md | 68 +
.../bills/ai_reviews_controller_test.rb | 63 +
.../smart_configurations_controller_test.rb | 96 +
test/controllers/bills_controller_test.rb | 1579 +++++++++++++++++
.../bills_feeds_controller_test.rb | 128 ++
.../recurring_allocations_controller_test.rb | 91 +
.../recurring_occurrences_controller_test.rb | 389 ++++
.../smart_fills_controller_test.rb | 98 +
.../recurring_transactions_controller_test.rb | 1136 ++++++++++++
.../transactions_controller_test.rb | 48 +
test/helpers/bills_helper_test.rb | 340 ++++
.../recurring_transactions_helper_test.rb | 22 +
.../ai_setup_suggester_test.rb | 137 ++
test/system/bills_mobile_test.rb | 163 ++
test/system/declare_and_pay_bill_test.rb | 124 ++
test/system/find_my_bills_test.rb | 45 +
.../recurring_transaction_frequency_test.rb | 37 +
96 files changed, 10997 insertions(+), 174 deletions(-)
create mode 100644 app/components/DS/sparkline.html.erb
create mode 100644 app/components/DS/sparkline.rb
create mode 100644 app/controllers/bills/ai_reviews_controller.rb
create mode 100644 app/controllers/bills/smart_configurations_controller.rb
create mode 100644 app/controllers/bills_controller.rb
create mode 100644 app/controllers/bills_feeds_controller.rb
create mode 100644 app/controllers/concerns/recurring_feature_guardable.rb
create mode 100644 app/controllers/recurring_allocations_controller.rb
create mode 100644 app/controllers/recurring_occurrences_controller.rb
create mode 100644 app/controllers/recurring_transactions/smart_fills_controller.rb
create mode 100644 app/helpers/bills_helper.rb
create mode 100644 app/helpers/recurring_transactions_helper.rb
create mode 100644 app/javascript/controllers/frequency_fields_controller.js
create mode 100755 app/javascript/controllers/persisted_disclosure_controller.js
create mode 100644 app/models/provider/anthropic/bill_setup_suggester.rb
create mode 100644 app/models/provider/openai/bill_setup_suggester.rb
create mode 100644 app/models/recurring_transaction/ai_setup_suggester.rb
create mode 100644 app/views/bills/_ai_prompts.html.erb
create mode 100644 app/views/bills/_calendar_chip.html.erb
create mode 100644 app/views/bills/_cancelled_notice.html.erb
create mode 100644 app/views/bills/_detail.html.erb
create mode 100644 app/views/bills/_month_pulse.html.erb
create mode 100644 app/views/bills/_notice.html.erb
create mode 100644 app/views/bills/_occurrence.html.erb
create mode 100644 app/views/bills/_occurrence_section.html.erb
create mode 100755 app/views/bills/_pay_period_marker.html.erb
create mode 100755 app/views/bills/_paycheck_bridge.html.erb
create mode 100644 app/views/bills/_paycheck_income.html.erb
create mode 100644 app/views/bills/_paycheck_item.html.erb
create mode 100644 app/views/bills/_paycheck_period.html.erb
create mode 100644 app/views/bills/_paycheck_shortfall.html.erb
create mode 100644 app/views/bills/_row_action.html.erb
create mode 100644 app/views/bills/_state_chips.html.erb
create mode 100644 app/views/bills/_summary.html.erb
create mode 100644 app/views/bills/_view_switcher.html.erb
create mode 100644 app/views/bills/all.html.erb
create mode 100644 app/views/bills/calendar.html.erb
create mode 100644 app/views/bills/index.html.erb
create mode 100644 app/views/bills/pane.html.erb
create mode 100644 app/views/bills/pane_close.html.erb
create mode 100644 app/views/bills/paycheck.html.erb
create mode 100644 app/views/bills/show.html.erb
create mode 100644 app/views/bills/smart_configurations/_proposal.html.erb
create mode 100644 app/views/bills/smart_configurations/show.html.erb
create mode 100644 app/views/recurring_occurrences/_candidate.html.erb
create mode 100644 app/views/recurring_occurrences/show.html.erb
create mode 100644 app/views/recurring_transactions/_form.html.erb
create mode 100644 app/views/recurring_transactions/_pay_action.html.erb
create mode 100644 app/views/recurring_transactions/_picker_row.html.erb
create mode 100644 app/views/recurring_transactions/_suggested_series.html.erb
create mode 100644 app/views/recurring_transactions/edit.html.erb
create mode 100644 app/views/recurring_transactions/new.html.erb
create mode 100644 app/views/recurring_transactions/pick_entry.html.erb
create mode 100644 config/locales/views/bills/en.yml
create mode 100644 config/locales/views/recurring_allocations/en.yml
create mode 100644 config/locales/views/recurring_occurrences/en.yml
create mode 100644 docs/hosting/recurring-bills.md
create mode 100644 test/controllers/bills/ai_reviews_controller_test.rb
create mode 100644 test/controllers/bills/smart_configurations_controller_test.rb
create mode 100644 test/controllers/bills_controller_test.rb
create mode 100644 test/controllers/bills_feeds_controller_test.rb
create mode 100644 test/controllers/recurring_allocations_controller_test.rb
create mode 100644 test/controllers/recurring_occurrences_controller_test.rb
create mode 100644 test/controllers/recurring_transactions/smart_fills_controller_test.rb
create mode 100644 test/controllers/recurring_transactions_controller_test.rb
create mode 100644 test/helpers/bills_helper_test.rb
create mode 100644 test/helpers/recurring_transactions_helper_test.rb
create mode 100644 test/models/recurring_transaction/ai_setup_suggester_test.rb
create mode 100644 test/system/bills_mobile_test.rb
create mode 100644 test/system/declare_and_pay_bill_test.rb
create mode 100644 test/system/find_my_bills_test.rb
create mode 100644 test/system/recurring_transaction_frequency_test.rb
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