Files
sure/test/system/declare_and_pay_bill_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

125 lines
5.8 KiB
Ruby

require "application_system_test_case"
class DeclareAndPayBillTest < ApplicationSystemTestCase
teardown do
travel_back
end
setup do
sign_in @user = users(:family_admin)
@user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => true))
@family = @user.family
@account = accounts(:depository)
end
test "declare rent, allocate a real payment, watch it stay partial, settle it" do
# due lands ten days out. Late in a month that crosses into the next one,
# which files the row under a different section with a different subline,
# so the clock is pinned where the test's premise holds.
travel_to Date.current.beginning_of_month + 9.days
due = Date.current + 10
payment = @account.entries.create!(
date: Date.current - 1, amount: 537.50, currency: "USD", name: "WATSON PROPERTY LLC",
entryable: Transaction.new
)
visit bills_url
# The switcher and the empty state both offer Add bill; either works.
click_on I18n.t("bills.index.add_bill"), match: :first
fill_in I18n.t("recurring_transactions.form.name_label"), with: "Watson Property"
fill_in I18n.t("recurring_transactions.form.amount_label"), with: "2150"
fill_in I18n.t("recurring_transactions.form.first_due_on_label"), with: due.strftime("%m/%d/%Y")
# Account is optional (DS::Select is a custom combobox; the family
# fallback covers candidates), so the bill is declared without one.
click_button I18n.t("recurring_transactions.form.submit")
assert_text "Watson Property"
# Scan, then inspect: the row itself opens the expansion. It is due in ten
# days, so the row carries no call to action -- there is nothing to chase
# yet -- and the verb lives in the expansion, spelled out.
#
# Targeted by the frame it loads rather than by bare name: the bill also
# appears in the summary's Next up strip, which goes to its page instead.
# And by name within that: the index materializes the fixture family's
# series on first visit now, so "first row" is no longer this bill.
find("a[data-turbo-frame^='pane_recurring_occurrence_']", text: "Watson Property", match: :first).click
within(find("turbo-frame[id^='pane_recurring_occurrence_']", match: :first)) do
click_on I18n.t("bills.find_payment")
end
# Act: the drawer leads with what is owed.
assert_text I18n.t("recurring_occurrences.show.remaining", amount: "$2,150.00")
# This bill was declared a moment ago, so the matcher knows it only by the
# name that was typed. "WATSON PROPERTY LLC" is not yet one of its names,
# so there is honestly nothing to suggest -- and the wider list is open
# rather than collapsed, because otherwise that would be a dead end.
assert_text I18n.t("recurring_occurrences.show.no_ranked_candidates")
assert_text payment.name
# Attach the real $537.50 payment. Every candidate row IS its own button,
# so there is one tap target per transaction rather than a small one beside
# the text.
within(find("form", text: payment.name, match: :first)) do
find("button").click
end
# Linking lands back on the worklist, and the row must say the bill is
# partly paid rather than settled: $537.50 against $2,150 is not rent.
assert_text I18n.t("bills.attention.partial", amount: "$1,612.50")
# Journey C picks up exactly where that leaves off: the row's verb has
# become Add payment, and the rest is settled from the drawer.
click_on I18n.t("bills.add_payment"), match: :first
assert_text I18n.t("recurring_occurrences.show.remaining", amount: "$1,612.50")
click_on I18n.t("recurring_occurrences.show.mark_paid")
# Synchronize on durable page state, not the toast: toasts auto-dismiss on
# their own clock and have burned CI runs before (TradesTest). The drawer's
# remaining-amount line vanishing proves the settle round-tripped.
assert_no_text I18n.t("recurring_occurrences.show.remaining", amount: "$1,612.50")
bill = @family.recurring_transactions.find_by!(name: "Watson Property")
occurrence = bill.recurring_occurrences.find_by!(due_on: due)
assert occurrence.paid?
assert_equal 2, occurrence.allocations.count
assert_equal 2150, occurrence.allocations.sum(:allocated_amount)
end
test "declare a bill by searching every transaction and picking one" do
charge = @account.entries.create!(
date: Date.current - 3, amount: 537.50, currency: "USD", name: "WATSON PROPERTY LLC",
entryable: Transaction.new
)
visit bills_url
click_on I18n.t("bills.index.add_bill"), match: :first
# A dead-end search first: nothing matches, and the way back works.
click_on I18n.t("recurring_transactions.new.search_all_cta")
fill_in I18n.t("recurring_transactions.pick_entry.search_placeholder"), with: "zzz-nothing"
find("input[name='q']").send_keys(:enter)
assert_text I18n.t("recurring_transactions.pick_entry.no_results", query: "zzz-nothing")
click_on I18n.t("recurring_transactions.pick_entry.back")
assert_field I18n.t("recurring_transactions.form.name_label"), with: ""
# Now the real search: find the charge, pick it, land in a prefilled form.
click_on I18n.t("recurring_transactions.new.search_all_cta")
fill_in I18n.t("recurring_transactions.pick_entry.search_placeholder"), with: "WATSON"
find("input[name='q']").send_keys(:enter)
click_on "WATSON PROPERTY LLC"
assert_field I18n.t("recurring_transactions.form.name_label"), with: "WATSON PROPERTY LLC"
assert_field I18n.t("recurring_transactions.form.amount_label"), with: "537.5"
click_button I18n.t("recurring_transactions.form.submit")
assert_text "WATSON PROPERTY LLC"
bill = @family.recurring_transactions.find_by!(name: "WATSON PROPERTY LLC")
assert_equal charge.account_id, bill.account_id, "the picked entry's account rides the prefill"
end
end