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*=, 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*=, 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