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

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

Pages, all under one nav entry:

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

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

Navigation and design system:

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

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

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

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

* Render the suggested strip through DS::Disclosure

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

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

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

* Fix erb_lint whitespace offenses in bills views

* Address the post-ready review round

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

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

1580 lines
68 KiB
Ruby

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(/<turbo-frame[^>]*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(/<turbo-frame[^>]*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(/<turbo-frame[^>]*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(/<turbo-frame[^>]*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(/<turbo-frame[^>]*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(/<html/, response.body, "the pane renders frame-only, no layout")
end
test "the paycheck view lists declared income with an edit affordance" 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 bills_url(view: "paycheck")
assert_response :success
assert_match I18n.t("bills.paycheck.income_section_title"), response.body
assert_match edit_recurring_transaction_path(income), response.body
assert_match bill_path(income), response.body, "income opens the same drawer as bills for pause and delete"
end
# Subscriptions was a filter promoted to navigation. Everything it answered
# still has to be answerable, and its old links still have to work.
test "the old subscriptions link lands on All bills, filtered" do
get bills_url(view: "subscriptions")
assert_redirected_to bills_path(view: "all", q: { bill_type: "subscription" })
end
test "filtering All bills by subscription rolls up cost and lists price changes" do
sub = create_bill(name: "STREAMFLIX", amount: 24.99)
sub.update!(bill_type: "subscription", trial_ends_on: Date.current + 5)
sub.recurring_price_changes.create!(
effective_on: 2.months.ago.to_date, previous_amount: 19.99, new_amount: 24.99,
currency: "USD", source: "detected"
)
get bills_url(view: "all", q: { bill_type: "subscription" })
assert_response :success
assert_match "STREAMFLIX", response.body
assert_match I18n.t("bills.all.subscription_monthly"), response.body
assert_match I18n.t("bills.all.subscription_annual"), response.body
assert_match I18n.t("bills.all.subscription_price_changes"), response.body
end
test "the rollup stays out of the way when the filter is not on subscriptions" do
sub = create_bill(name: "STREAMFLIX", amount: 24.99)
sub.update!(bill_type: "subscription")
get bills_url(view: "all")
assert_response :success
assert_match "STREAMFLIX", response.body
assert_no_match I18n.t("bills.all.subscription_monthly"), response.body,
"a subscription rollup on an unfiltered list would be answering a question nobody asked"
end
test "trial, renewal and price history follow the bill into its detail" do
sub = create_bill(name: "STREAMFLIX", amount: 24.99)
sub.update!(bill_type: "subscription", trial_ends_on: Date.current + 5,
renews_on: Date.current + 30)
sub.recurring_price_changes.create!(
effective_on: 2.months.ago.to_date, previous_amount: 19.99, new_amount: 24.99,
currency: "USD", source: "detected"
)
# Trial and renewal are STATE: they change what you might do about the bill
# today, so both routes to it must say so. Price history is the record of
# how it got here, which is the page's job.
{ "page" => 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