Files
sure/test/controllers/recurring_occurrences_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

390 lines
17 KiB
Ruby

require "test_helper"
class RecurringOccurrencesControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in @user = users(:family_admin)
@user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => true))
@family = @user.family
@series = recurring_transactions(:netflix_subscription)
@occurrence = @series.recurring_occurrences.create!(
family: @family,
original_due_on: Date.current + 5,
due_on: Date.current + 5,
currency: "USD"
)
ensure_tailwind_build
end
# Resolving a payment is the ACT surface, so it owns the drawer slot -- the
# same one transactions, trades and transfers use. The bill's own story moved
# to its own page, so nothing competes for it.
test "show renders the occurrence dialog in a single drawer frame" do
get recurring_occurrence_url(@occurrence), headers: { "Turbo-Frame" => "drawer" }
assert_response :success
assert_equal 1, response.body.scan(/<turbo-frame[^>]*id="drawer"/).size
end
# The dialog used to list the fifteen most RECENT transactions in the window.
# On a real bill that hid every plausible match behind unrelated larger
# charges, so the exact payment was invisible.
test "the transaction list leads with the closest amount, not the most recent" do
# Older than every distractor, so date order pushes it past the fifteen-row
# cut. Everything sits in the past, because the window is clipped at today.
exact = accounts(:depository).entries.create!(
date: @occurrence.due_on - 35, amount: 15.99, currency: "USD",
name: "Netflix charge", entryable: Transaction.new
)
20.times do |i|
accounts(:depository).entries.create!(
date: @occurrence.due_on - 6 - i, amount: 500 + i, currency: "USD",
name: "Unrelated big charge #{i}", entryable: Transaction.new
)
end
get recurring_occurrence_url(@occurrence), headers: { "Turbo-Frame" => "drawer" }
assert_response :success
assert_match exact.name, response.body,
"the transaction matching the bill amount must survive the fifteen-row cut"
end
# The mission's own example. The picker ranked by amount distance and nothing
# else, so a $6.44 Twitch charge scored exactly as well as the $6.44 7-Eleven
# charge for a 7-Eleven bill. It now asks the matcher, whose identity filter
# rules the others out entirely rather than merely ranking them lower.
test "the suggested payment is the one the matcher would have picked" do
seven_eleven = merchants(:netflix)
series = @family.recurring_transactions.create!(
name: "7-Eleven Gold Pass", merchant: seven_eleven, account: accounts(:depository),
amount: 6.44, currency: "USD", expected_day_of_month: Date.current.day,
anchor_date: Date.current, last_occurrence_date: Date.current,
next_expected_date: Date.current, status: "active", manual: true,
dedup_scope: "gold-pass"
)
occurrence = series.recurring_occurrences.order(:due_on).first
match = accounts(:depository).entries.create!(
date: occurrence.due_on, amount: 6.44, currency: "USD",
name: "7-ELEVEN GOLD PASS TM", entryable: Transaction.new(merchant: seven_eleven)
)
decoys = [ "Twitch", "Grok", "Steam" ].map do |name|
accounts(:depository).entries.create!(
date: occurrence.due_on, amount: 6.44, currency: "USD",
name: "#{name} subscription", entryable: Transaction.new
)
end
get recurring_occurrence_url(occurrence), headers: { "Turbo-Frame" => "drawer" }
assert_response :success
ranked = @controller.view_assigns["ranked_candidates"]
assert_equal [ match.id ], ranked.map { |entry, _| entry.id },
"only the 7-Eleven charge belongs to a 7-Eleven bill"
fallback = @controller.view_assigns["other_entries"].map(&:id)
decoys.each { |decoy| assert_includes fallback, decoy.id, "unrelated charges stay browsable, just not suggested" }
# And the reasons are the matcher's own, not a percentage.
assert_match I18n.t("bills.match.same_merchant"), response.body
assert_match I18n.t("bills.match.exact_amount"), response.body
end
# Corrections are permanently sticky in the matcher. The picker never checked
# the rejection table, so a transaction the user had already dismissed could
# come straight back to the top of the list.
test "a rejected pairing never returns as a suggestion" do
series = @family.recurring_transactions.create!(
name: "CITY WATER", account: accounts(:depository), amount: 80, currency: "USD",
expected_day_of_month: Date.current.day, anchor_date: Date.current,
last_occurrence_date: Date.current, next_expected_date: Date.current,
status: "active", manual: true
)
occurrence = series.recurring_occurrences.order(:due_on).first
entry = accounts(:depository).entries.create!(
date: occurrence.due_on, amount: 80, currency: "USD",
name: "CITY WATER", entryable: Transaction.new
)
get recurring_occurrence_url(occurrence), headers: { "Turbo-Frame" => "drawer" }
assert_equal [ entry.id ], @controller.view_assigns["ranked_candidates"].map { |candidate, _| candidate.id }
RecurringMatchRejection.create!(recurring_transaction: series, entry: entry)
get recurring_occurrence_url(occurrence), headers: { "Turbo-Frame" => "drawer" }
assert_empty @controller.view_assigns["ranked_candidates"]
end
# One transaction can legitimately pay more than one bill, which is why the
# Allocator guards capacity at write time rather than at read time. Excluding
# every entry that already has a confirmed allocation would have quietly
# deleted split payments from the picker.
test "an entry already allocated to another bill is still offered here" do
first, second = [ "a", "b" ].map do |scope|
@family.recurring_transactions.create!(
name: "WATSON PROPERTY", account: accounts(:depository), amount: 500, currency: "USD",
expected_day_of_month: Date.current.day, anchor_date: Date.current,
last_occurrence_date: Date.current, next_expected_date: Date.current,
status: "active", manual: true, dedup_scope: scope
).recurring_occurrences.order(:due_on).first
end
entry = accounts(:depository).entries.create!(
date: first.due_on, amount: 500, currency: "USD",
name: "WATSON PROPERTY", entryable: Transaction.new
)
RecurringTransaction::Allocator.new(first).allocate!(entry: entry, amount: 200)
get recurring_occurrence_url(second), headers: { "Turbo-Frame" => "drawer" }
assert_includes @controller.view_assigns["ranked_candidates"].map { |candidate, _| candidate.id }, entry.id,
"the rest of that charge can still pay another bill; over-allocation is refused when it is written"
end
test "searching looks past the date window" do
old = accounts(:depository).entries.create!(
date: @occurrence.due_on - 300, amount: 15.99, currency: "USD",
name: "Ancient Netflix charge", entryable: Transaction.new
)
get recurring_occurrence_url(@occurrence), headers: { "Turbo-Frame" => "drawer" }
assert_no_match old.name, response.body, "outside the window it is not offered by default"
get recurring_occurrence_url(@occurrence, q: "Ancient"), headers: { "Turbo-Frame" => "drawer" }
assert_match old.name, response.body, "a search must be able to reach it"
end
test "mark_paid settles the occurrence" do
post mark_paid_recurring_occurrence_url(@occurrence)
assert_redirected_to bills_url
@occurrence.reload
assert @occurrence.paid?
assert_equal "user", @occurrence.closed_source
assert_equal @occurrence.expected_amount, @occurrence.allocations.sum(:allocated_amount)
end
test "skip and reopen round trip" do
post skip_recurring_occurrence_url(@occurrence)
assert @occurrence.reload.skipped?
post reopen_recurring_occurrence_url(@occurrence)
assert @occurrence.reload.scheduled?
end
test "snooze postpones the effective due date" do
patch snooze_recurring_occurrence_url(@occurrence, until: (Date.current + 12).iso8601)
assert_equal Date.current + 12, @occurrence.reload.snoozed_until
end
# until[]=... makes params.require(:until) an Array, which Date.parse
# rejects with TypeError rather than ArgumentError. Same user mistake, same
# invalid-date redirect; never a 500.
test "a non-scalar until parameter is an invalid date, not a 500" do
patch snooze_recurring_occurrence_url(@occurrence), params: { until: [ (Date.current + 12).iso8601 ] }
assert_response :redirect
assert_nil @occurrence.reload.snoozed_until
end
test "override amount sets and clears the per-occurrence expectation" do
patch override_amount_recurring_occurrence_url(@occurrence, amount: "42.50")
assert_equal 42.50, @occurrence.reload.expected_amount
patch override_amount_recurring_occurrence_url(@occurrence, amount: "")
assert_nil @occurrence.reload.expected_amount
end
test "another family's occurrence is unreachable" do
other_family = families(:empty)
other_series = other_family.recurring_transactions.create!(
name: "Foreign bill", amount: 10, currency: "USD", expected_day_of_month: 1,
last_occurrence_date: Date.current, next_expected_date: 1.month.from_now.to_date,
status: "active", manual: true
)
foreign = other_series.recurring_occurrences.order(:due_on).first ||
other_series.recurring_occurrences.create!(
family: other_family, original_due_on: Date.current,
due_on: Date.current, currency: "USD"
)
get recurring_occurrence_url(foreign)
assert_response :not_found
end
test "allocating an entry applies its amount toward the occurrence" do
entry = accounts(:depository).entries.create!(
date: Date.current, amount: 10, currency: "USD", name: "Netflix charge",
entryable: Transaction.new(merchant: merchants(:netflix))
)
post recurring_occurrence_allocations_url(@occurrence, entry_id: entry.id)
allocation = @occurrence.allocations.sole
assert_equal entry.id, allocation.entry_id
assert_equal 10, allocation.allocated_amount
assert @occurrence.reload.partially_paid?
end
test "a custom amount records an entry-less payment" do
post recurring_occurrence_allocations_url(@occurrence), params: { amount: "5.00" }
allocation = @occurrence.allocations.sole
assert_nil allocation.entry_id
assert allocation.from_user_created?
assert_equal 5, allocation.allocated_amount
end
test "over-allocating an entry is refused with an explanation" do
entry = accounts(:depository).entries.create!(
date: Date.current, amount: 10, currency: "USD", name: "Netflix charge",
entryable: Transaction.new(merchant: merchants(:netflix))
)
post recurring_occurrence_allocations_url(@occurrence, entry_id: entry.id), params: { amount: "25" }
assert_equal 0, @occurrence.allocations.count
assert_equal I18n.t("recurring_allocations.over_allocation"), flash[:alert]
end
# Sharing is per account. A member with no share on the brokerage must not be
# able to settle a bill with a transaction from it, which would both spend an
# obligation against money they cannot see and echo the charge back to them.
test "a member cannot pay a bill with a transaction from an account they were never given" do
hidden = accounts(:investment).entries.create!(
date: Date.current, amount: 15.99, currency: "USD", name: "PRIVATE BROKERAGE FEE",
entryable: Transaction.new
)
member = users(:family_member)
member.update!(preferences: (member.preferences || {}).merge("preview_features_enabled" => true))
sign_in member
post recurring_occurrence_allocations_url(@occurrence, entry_id: hidden.id), params: { amount: "15.99" }
assert_response :not_found
assert_equal 0, @occurrence.reload.allocations.count
end
test "confirming and rejecting suggestions from the queue" do
entry = accounts(:depository).entries.create!(
date: Date.current, amount: 15.99, currency: "USD", name: "Netflix charge",
entryable: Transaction.new(merchant: merchants(:netflix))
)
suggestion = RecurringTransaction::Allocator.new(@occurrence).allocate_matched!(
entry: entry, state: "suggested", confidence: 0.7, signals: { name: 0.35 }
)
post confirm_recurring_allocation_url(suggestion)
assert suggestion.reload.allocation_confirmed?
assert @occurrence.reload.paid?
delete recurring_allocation_url(suggestion)
other = RecurringTransaction::Allocator.new(@occurrence.reload).allocate_matched!(
entry: entry, state: "suggested", confidence: 0.7, signals: { name: 0.35 }
)
post reject_recurring_allocation_url(other)
assert_not RecurringAllocation.exists?(other.id)
assert RecurringMatchRejection.exists?(recurring_transaction: @series, entry: entry)
end
test "unlinking a payment reopens an auto-closed occurrence" do
entry = accounts(:depository).entries.create!(
date: Date.current, amount: 15.99, currency: "USD", name: "Netflix charge",
entryable: Transaction.new(merchant: merchants(:netflix))
)
post recurring_occurrence_allocations_url(@occurrence, entry_id: entry.id)
assert @occurrence.reload.paid?
delete recurring_allocation_url(@occurrence.allocations.sole)
assert @occurrence.reload.scheduled?
assert_equal 0, @occurrence.allocations.count
end
test "the drawer redirects when the family has turned recurring transactions off" do
@family.update!(recurring_transactions_disabled: true)
get recurring_occurrence_url(@occurrence)
assert_redirected_to root_path
end
# Sharing is per account. An accountless bill is visible family-wide, but
# its candidate list must still show only entries from accounts the viewer
# can reach, or the drawer leaks names and amounts from unshared accounts.
test "an accountless bill's candidates exclude entries from unshared accounts" do
accountless = @family.recurring_transactions.create!(
name: "Water Utility", amount: 60, currency: "USD",
expected_day_of_month: Date.current.day, anchor_date: Date.current,
last_occurrence_date: Date.current, next_expected_date: Date.current,
status: "active", manual: true
)
occurrence = accountless.recurring_occurrences.order(:due_on).first
hidden = accounts(:investment).entries.create!(
date: occurrence.due_on, amount: 60, currency: "USD",
name: "Broker service fee", entryable: Transaction.new
)
visible = accounts(:depository).entries.create!(
date: occurrence.due_on, amount: 60, currency: "USD",
name: "Shared checking charge", entryable: Transaction.new
)
member = users(:family_member)
member.update!(preferences: (member.preferences || {}).merge("preview_features_enabled" => true))
sign_in member
get recurring_occurrence_url(occurrence), headers: { "Turbo-Frame" => "drawer" }
assert_response :success
assert_no_match hidden.name, response.body,
"an entry on an account never shared with the viewer must not render"
assert_match visible.name, response.body,
"positive control: the same-shaped entry on a shared account must render"
end
# credit_card is shared read-only with family_member in the fixtures; a
# read-only share can look at the bill but never move its payment state.
test "a read-only account share cannot mutate an occurrence" do
occurrence = credit_card_occurrence
member = users(:family_member)
member.update!(preferences: (member.preferences || {}).merge("preview_features_enabled" => true))
sign_in member
post mark_paid_recurring_occurrence_url(occurrence)
assert_response :not_found
assert occurrence.reload.scheduled?, "a read-only share must not settle the bill"
post skip_recurring_occurrence_url(occurrence)
assert_response :not_found
patch override_amount_recurring_occurrence_url(occurrence, amount: "1")
assert_response :not_found
assert_nil occurrence.reload.expected_amount
get recurring_occurrence_url(occurrence), headers: { "Turbo-Frame" => "drawer" }
assert_response :success, "reading the shared bill stays allowed"
end
test "the account owner still settles the same occurrence" do
occurrence = credit_card_occurrence
post mark_paid_recurring_occurrence_url(occurrence)
assert occurrence.reload.paid?
end
private
def credit_card_occurrence
series = @family.recurring_transactions.create!(
name: "Card Annual Fee", account: accounts(:credit_card), amount: 95,
currency: "USD", expected_day_of_month: Date.current.day,
anchor_date: Date.current, last_occurrence_date: Date.current,
next_expected_date: Date.current, status: "active", manual: true
)
series.recurring_occurrences.order(:due_on).first
end
end