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

1137 lines
46 KiB
Ruby

require "test_helper"
class RecurringTransactionsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in @user = users(:family_admin)
# The declare/edit/suggestion paths sit behind the Bills preview gate.
@user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => true))
@family = @user.family
@recurring_transaction = recurring_transactions(:netflix_subscription)
ensure_tailwind_build
end
# How often is one of the four things anyone needs to add a bill, and it used
# to render below the payment link and the autopay toggle: the fourth
# essential field sat under two most people never set.
test "the add form leads with the essentials and tucks the rest away" do
get new_recurring_transaction_url, headers: { "Turbo-Frame" => "modal" }
assert_response :success
body = response.body
name_at = body.index("recurring_transaction[name]")
amount_at = body.index("recurring_transaction[amount]")
due_at = body.index("recurring_transaction[first_due_on]")
often_at = body.index("recurring_transaction[frequency_preset]")
url_at = body.index("recurring_transaction[payment_url]")
assert name_at && amount_at && due_at && often_at && url_at
assert_operator name_at, :<, amount_at
assert_operator amount_at, :<, due_at
assert_operator due_at, :<, often_at, "how often belongs with the essentials"
assert_operator often_at, :<, url_at, "and above the advanced fields, not below them"
# Tucked away is not the same as gone.
assert_match "recurring_transaction[autopay]", body
assert_match "recurring_transaction[notes]", body
assert_match I18n.t("recurring_transactions.form.more_options"), body
end
test "edit renders the form" do
get edit_recurring_transaction_url(@recurring_transaction)
assert_response :success
end
# These three mutate: identify runs the whole detection and matching
# pipeline, cleanup destroys stale series, and toggle_status pauses a bill,
# which deletes its future occurrences. A GET route puts all of that behind a
# plain URL, outside CSRF protection, where an image tag on any page a signed
# in user visits is enough to fire it.
test "the mutating actions refuse GET" do
paths = {
"/recurring_transactions/identify" => :get,
"/recurring_transactions/cleanup" => :get,
"/recurring_transactions/#{@recurring_transaction.id}/toggle_status" => :get
}
paths.each do |path, verb|
assert_raises(ActionController::RoutingError, "#{path} must not answer #{verb.to_s.upcase}") do
Rails.application.routes.recognize_path(path, method: verb)
end
end
end
test "identify runs the pipeline over POST" do
post identify_recurring_transactions_url
assert_redirected_to recurring_transactions_url
end
test "cleanup retires stale series over POST" do
post cleanup_recurring_transactions_url
assert_redirected_to recurring_transactions_url
end
test "toggle_status pauses and resumes over POST" do
assert @recurring_transaction.active?
post toggle_status_recurring_transaction_url(@recurring_transaction)
assert_not @recurring_transaction.reload.active?
post toggle_status_recurring_transaction_url(@recurring_transaction)
assert @recurring_transaction.reload.active?
end
# The dialog is delivered into the shared <turbo-frame id="modal"> that every page
# layout already renders empty. If this action responds with a full page layout,
# the response carries two frames with that id, Turbo matches the empty one first,
# and the pencil icon silently does nothing. Assert there is exactly one.
test "edit responds to a turbo frame request with a single modal frame" do
get edit_recurring_transaction_url(@recurring_transaction),
headers: { "Turbo-Frame" => "modal" }
assert_response :success
assert_equal 1, response.body.scan(/<turbo-frame[^>]*id="modal"/).size
end
test "a failed update still renders a single modal frame" do
patch recurring_transaction_url(@recurring_transaction),
params: { recurring_transaction: { payment_url: "javascript:alert(1)" } },
headers: { "Turbo-Frame" => "modal" }
assert_response :unprocessable_entity
assert_equal 1, response.body.scan(/<turbo-frame[^>]*id="modal"/).size
end
test "update saves a payment link" do
patch recurring_transaction_url(@recurring_transaction),
params: { recurring_transaction: { payment_url: "pay.example.com/bill" } }
assert_redirected_to recurring_transactions_url
assert_equal "https://pay.example.com/bill", @recurring_transaction.reload.payment_url
end
test "new renders the create dialog in a single modal frame" do
get new_recurring_transaction_url, headers: { "Turbo-Frame" => "modal" }
assert_response :success
assert_equal 1, response.body.scan(/<turbo-frame[^>]*id="modal"/).size
end
test "add income opens an income dialog, not a bill dialog" do
get new_recurring_transaction_url(income: true), headers: { "Turbo-Frame" => "modal" }
assert_response :success
assert_match I18n.t("recurring_transactions.new.income_title"), response.body
assert_match I18n.t("recurring_transactions.form.income_name_label"), response.body
assert_match I18n.t("recurring_transactions.form.submit_income"), response.body
# Nothing bill-shaped survives in income mode.
assert_no_match I18n.t("recurring_transactions.form.autopay_hint"), response.body
assert_no_match I18n.t("recurring_transactions.form.payment_url_label"), response.body
assert_no_match I18n.t("recurring_transactions.form.submit"), response.body
end
test "fresh bill dialog offers detected recurring charges as starting points" do
account = accounts(:depository)
2.times do |i|
account.entries.create!(
date: Date.current - ((i + 1) * 30).days,
amount: 45.00, currency: "USD", name: "City Water",
entryable: Transaction.new
)
end
get new_recurring_transaction_url, headers: { "Turbo-Frame" => "modal" }
assert_response :success
assert_match I18n.t("recurring_transactions.new.start_from_title"), response.body
assert_match "City Water", response.body
end
test "the candidate strip never offers a pattern on an account the member cannot reach" do
3.times do |i|
accounts(:investment).entries.create!(
date: Date.current - ((i + 1) * 30).days,
amount: 45.00, currency: "USD", name: "PRIVATE BROKERAGE SUB",
entryable: Transaction.new
)
end
member = users(:family_member)
member.update!(preferences: (member.preferences || {}).merge("preview_features_enabled" => true))
sign_in member
get new_recurring_transaction_url, headers: { "Turbo-Frame" => "modal" }
assert_response :success
assert_no_match "PRIVATE BROKERAGE SUB", response.body
end
test "income dialog offers only detected deposits" do
account = accounts(:depository)
2.times do |i|
account.entries.create!(
date: (i + 1).months.ago.beginning_of_month + 2.days,
amount: -1840.00, currency: "USD", name: "ACME PAYROLL",
entryable: Transaction.new
)
account.entries.create!(
date: Date.current - ((i + 1) * 30).days,
amount: 45.00, currency: "USD", name: "City Water",
entryable: Transaction.new
)
end
get new_recurring_transaction_url(income: true), headers: { "Turbo-Frame" => "modal" }
assert_response :success
assert_match I18n.t("recurring_transactions.new.start_from_income_title"), response.body
assert_match "ACME PAYROLL", response.body
assert_no_match "City Water", response.body
end
test "prefilled dialog hides the picker" do
account = accounts(:depository)
entries = 2.times.map do |i|
account.entries.create!(
date: Date.current - ((i + 1) * 30).days,
amount: 45.00, currency: "USD", name: "City Water",
entryable: Transaction.new
)
end
get new_recurring_transaction_url(entry_id: entries.last.id), headers: { "Turbo-Frame" => "modal" }
assert_response :success
assert_no_match I18n.t("recurring_transactions.new.start_from_title"), response.body
end
test "new prefills from a transaction" do
entry = accounts(:depository).entries.create!(
date: Date.current - 20, amount: 184.37, currency: "USD", name: "PG&E WEB PAYMENT",
entryable: Transaction.new
)
get new_recurring_transaction_url(entry_id: entry.id), headers: { "Turbo-Frame" => "modal" }
assert_response :success
assert_match "PG&amp;E WEB PAYMENT", response.body
assert_match "184.37", response.body
end
# Sharing is per account, so a family scope is not an access check. Prefilling
# reads the entry's name, amount and account straight back into the form.
test "new ignores a transaction from an account the user was never given" do
hidden = accounts(:investment).entries.create!(
date: Date.current - 3, amount: 622.41, currency: "USD", name: "PRIVATE BROKERAGE FEE",
entryable: Transaction.new
)
member = users(:family_member)
member.update!(preferences: (member.preferences || {}).merge("preview_features_enabled" => true))
sign_in member
get new_recurring_transaction_url(entry_id: hidden.id), headers: { "Turbo-Frame" => "modal" }
assert_response :success
assert_no_match "PRIVATE BROKERAGE FEE", response.body
assert_no_match "622.41", response.body
end
test "prefilling from an inflow pre-selects income" do
entry = accounts(:depository).entries.create!(
date: Date.current - 10, amount: -1840, currency: "USD", name: "ACME PAYROLL",
entryable: Transaction.new
)
get new_recurring_transaction_url(entry_id: entry.id), headers: { "Turbo-Frame" => "modal" }
assert_response :success
assert_match I18n.t("recurring_transactions.new.income_title"), response.body
end
test "editing income keeps bill wording out of the dialog" do
payday = Date.current + 3
income = @family.recurring_transactions.create!(
name: "Paycheck", account: accounts(:depository), amount: -1840, currency: "USD",
bill_type: "income", expected_day_of_month: payday.day, anchor_date: payday,
last_occurrence_date: payday, next_expected_date: payday, status: "active", manual: true
)
get edit_recurring_transaction_url(income), headers: { "Turbo-Frame" => "modal" }
assert_response :success
assert_match I18n.t("recurring_transactions.edit.income_title", name: "Paycheck"), response.body
# Apostrophes HTML-escape in the body, so match on stable fragments.
assert_match "match your payday", response.body
assert_no_match(/comes due/, response.body)
end
test "removing a bill never touches the ledger and lands back on bills" do
due = Date.current + 5
bill = @family.recurring_transactions.create!(
name: "City Water", account: accounts(:depository), amount: 45, currency: "USD",
expected_day_of_month: due.day, anchor_date: due, last_occurrence_date: due,
next_expected_date: due, status: "active", manual: true
)
RecurringTransaction::OccurrenceGenerator.new(bill).generate!
entry = accounts(:depository).entries.create!(
date: Date.current, amount: 45, currency: "USD", name: "CITY WATER",
entryable: Transaction.new
)
occurrence = bill.recurring_occurrences.order(:due_on).first
RecurringTransaction::Allocator.new(occurrence).allocate!(amount: "45", entry: entry)
delete recurring_transaction_url(bill), headers: { "HTTP_REFERER" => bills_url }
assert_redirected_to bills_url
assert_equal I18n.t("recurring_transactions.deleted"), flash[:notice]
assert Entry.exists?(entry.id), "removing a bill must never delete ledger entries"
end
# Which kind this is was settled by the entry point that opened the dialog.
# The checkbox asked it again, and ticking it reshaped nothing: you filled in
# bill-shaped labels, pressed Save bill, and got an income record.
test "the add-bill dialog does not offer to make it income" do
get new_recurring_transaction_url, headers: { "Turbo-Frame" => "modal" }
assert_response :success
assert_no_match(/name="recurring_transaction\[is_income\]"/, response.body)
end
test "the add-income dialog carries the answer without asking" do
get new_recurring_transaction_url(income: true), headers: { "Turbo-Frame" => "modal" }
assert_response :success
assert_match(/type="hidden"[^>]*name="recurring_transaction\[is_income\]"/, response.body)
assert_no_match(/type="checkbox"[^>]*name="recurring_transaction\[is_income\]"/, response.body)
end
# Reported upstream: a deleted auto-detected recurring transaction comes back
# on the next detection run, so users delete the same row over and over. The
# pattern is still in the bank data, so a hard delete only lasts until the
# next sync. Removing it leaves the same `ended` tombstone that dismissing a
# suggestion does, and the Identifier refuses to claim or recreate one.
test "a deleted detected bill does not come back on the next detection run" do
account = accounts(:depository)
anchor_day = Date.current.beginning_of_month + 8
3.times do |i|
account.entries.create!(
date: anchor_day - i.months, amount: 42.00, currency: "USD",
name: "City Water", entryable: Transaction.create!(category: categories(:food_and_drink))
)
end
RecurringTransaction::Identifier.new(@family).identify_recurring_patterns
detected = @family.recurring_transactions.find_by(name: "City Water")
assert_not_nil detected
assert_not detected.manual?
delete recurring_transaction_url(detected)
RecurringTransaction::Identifier.new(Family.find(@family.id)).identify_recurring_patterns
rows = @family.recurring_transactions.where(name: "City Water")
assert_equal 1, rows.count, "detection must not build a second row for a pattern the user removed"
assert_equal "ended", rows.first.status, "and the one that remains is a tombstone, not a live bill"
assert_empty @family.recurring_transactions.where(name: "City Water").where.not(status: "ended")
end
# A hand-declared bill has no pattern behind it, so nothing would bring it
# back and it is deleted outright rather than left lying around as ended.
test "a declared bill is deleted outright" do
bill = @family.recurring_transactions.create!(
name: "Typo Bill", account: accounts(:depository), amount: 10, currency: "USD",
dedup_scope: "typo", bill_type: "bill", expected_day_of_month: Date.current.day,
anchor_date: Date.current, last_occurrence_date: Date.current,
next_expected_date: Date.current, status: "active", manual: true
)
assert_difference "@family.recurring_transactions.count", -1 do
delete recurring_transaction_url(bill)
end
end
test "creating income says income, not bill" do
post recurring_transactions_url, params: {
recurring_transaction: {
name: "Paycheck",
amount: "1840",
account_id: accounts(:depository).id,
first_due_on: (Date.current + 3).iso8601,
frequency_preset: "biweekly",
is_income: "1"
}
}
assert_equal I18n.t("recurring_transactions.create.success_income"), flash[:notice]
end
test "create declares a manual bill and materializes its occurrences" do
due = Date.current + 16
assert_difference "@family.recurring_transactions.count", 1 do
post recurring_transactions_url, params: {
recurring_transaction: {
name: "Watson Property",
amount: "2150",
account_id: accounts(:depository).id,
first_due_on: due.iso8601,
frequency_preset: "monthly"
}
}
end
bill = @family.recurring_transactions.order(:created_at).last
assert bill.manual?
assert_equal "active", bill.status
assert_equal 2150, bill.amount
assert_equal due.day, bill.expected_day_of_month
assert_equal due, bill.anchor_date
# Monthly on the derived day IS the zero-rule implicit shape, so no
# redundant rule row is written; the detection reads it back correctly.
detection = RecurringTransaction::FrequencyPreset.detect(bill)
assert_equal "monthly", detection.key
assert_equal due.day, detection.day_of_month
assert bill.recurring_occurrences.reload.exists?(due_on: due),
"the declared bill's occurrence must materialize immediately"
end
test "create with a non-monthly preset writes explicit rules" do
due = Date.current + 4
post recurring_transactions_url, params: {
recurring_transaction: {
name: "Cleaning service", amount: "80", account_id: accounts(:depository).id,
first_due_on: due.iso8601, frequency_preset: "biweekly"
}
}
bill = @family.recurring_transactions.order(:created_at).last
rule = bill.recurrence_rules.sole
assert_equal [ "weekly", 2, due.wday ], [ rule.frequency, rule.interval, rule.weekday ]
assert_equal due, bill.anchor_date
end
test "create without a due date re-renders with an error" do
assert_no_difference "@family.recurring_transactions.count" do
post recurring_transactions_url, params: {
recurring_transaction: { name: "No date", amount: "10", frequency_preset: "monthly", first_due_on: "" }
}
end
assert_response :unprocessable_entity
end
test "create with a currency-formatted amount re-renders with an error instead of crashing" do
assert_no_difference "@family.recurring_transactions.count" do
post recurring_transactions_url, params: {
recurring_transaction: { name: "Trash Pickup", amount: "$40.00", account_id: accounts(:depository).id,
first_due_on: (Date.current + 5).iso8601, frequency_preset: "monthly" }
}
end
assert_response :unprocessable_entity
assert_match I18n.t("recurring_transactions.create.amount_invalid"), response.body
end
test "update with an unresolvable account keeps the current account and reports the error" do
original_account_id = @recurring_transaction.account_id
patch recurring_transaction_url(@recurring_transaction),
params: { recurring_transaction: { account_id: SecureRandom.uuid } }
assert_response :unprocessable_entity
assert_equal original_account_id, @recurring_transaction.reload.account_id,
"a present-but-unresolvable id must not silently detach the account"
end
test "create stamps dedup_scope up front so tiers fork and true duplicates collide" do
post recurring_transactions_url, params: {
recurring_transaction: { name: "STREAMCO", amount: "5.99", account_id: accounts(:depository).id,
first_due_on: (Date.current + 3).iso8601, frequency_preset: "monthly" }
}
post recurring_transactions_url, params: {
recurring_transaction: { name: "STREAMCO", amount: "24.99", account_id: accounts(:depository).id,
first_due_on: (Date.current + 9).iso8601, frequency_preset: "monthly" }
}
tiers = @family.recurring_transactions.where(name: "STREAMCO").order(:amount)
assert_equal 2, tiers.count
assert_equal [ "5.99", "24.99" ], tiers.map(&:dedup_scope)
# The stamp makes the very first identical duplicate collide on insert.
post recurring_transactions_url, params: {
recurring_transaction: { name: "STREAMCO", amount: "5.99", account_id: accounts(:depository).id,
first_due_on: (Date.current + 3).iso8601, frequency_preset: "monthly" }
}
assert_equal 2, @family.recurring_transactions.where(name: "STREAMCO").count
end
test "marking a bill as an installment plan caps its occurrences and tracks progress" do
due = Date.current + 5
post recurring_transactions_url, params: {
recurring_transaction: { name: "Klarna sofa", amount: "120", first_due_on: due.iso8601, frequency_preset: "monthly" }
}
bill = @family.recurring_transactions.find_by!(name: "Klarna sofa")
patch recurring_transaction_url(bill), params: {
recurring_transaction: { bill_type: "installment", end_after_count: "4" }
}
bill.reload
assert bill.typed_installment?
assert bill.ends_after_count?
assert_equal 4, bill.recurring_occurrences.reload.count, "the plan materializes exactly its four payments"
assert_equal [ 0, 4 ], bill.installment_progress
occurrence = bill.recurring_occurrences.order(:due_on).first
RecurringTransaction::Allocator.new(occurrence).mark_paid!
assert_equal [ 1, 4 ], bill.reload.installment_progress
end
test "update applies a frequency preset as recurrence rules" do
patch recurring_transaction_url(@recurring_transaction),
params: { recurring_transaction: { frequency_preset: "biweekly", frequency_weekday: "5" } }
assert_redirected_to recurring_transactions_url
rules = @recurring_transaction.reload.recurrence_rules
assert_equal [ [ "weekly", 2, 5 ] ], rules.map { |rule| [ rule.frequency, rule.interval, rule.weekday ] }
assert_not_nil @recurring_transaction.anchor_date
end
test "update with an unchanged frequency does not rewrite the rules" do
patch recurring_transaction_url(@recurring_transaction),
params: { recurring_transaction: { frequency_preset: "weekly", frequency_weekday: "3" } }
original_ids = @recurring_transaction.reload.recurrence_rules.map(&:id)
patch recurring_transaction_url(@recurring_transaction),
params: { recurring_transaction: { notes: "edited", frequency_preset: "weekly", frequency_weekday: "3" } }
assert_equal original_ids, @recurring_transaction.reload.recurrence_rules.map(&:id)
assert_equal "edited", @recurring_transaction.notes
end
test "update with an incomplete frequency re-renders the form" do
patch recurring_transaction_url(@recurring_transaction),
params: { recurring_transaction: { frequency_preset: "weekly" } },
headers: { "Turbo-Frame" => "modal" }
assert_response :unprocessable_entity
assert_empty @recurring_transaction.reload.recurrence_rules
end
test "update rejects a non-http scheme instead of storing it" do
patch recurring_transaction_url(@recurring_transaction),
params: { recurring_transaction: { payment_url: "javascript:alert(1)" } }
assert_response :unprocessable_entity
assert_nil @recurring_transaction.reload.payment_url
end
test "update saves autopay and notes" do
patch recurring_transaction_url(@recurring_transaction),
params: { recurring_transaction: { autopay: "1", notes: "Account 4821" } }
@recurring_transaction.reload
assert @recurring_transaction.autopay?
assert_equal "Account 4821", @recurring_transaction.notes
end
test "update can turn autopay back off" do
@recurring_transaction.update!(autopay: true)
patch recurring_transaction_url(@recurring_transaction),
params: { recurring_transaction: { autopay: "0" } }
assert_not @recurring_transaction.reload.autopay?
end
test "update clears the payment link when submitted blank" do
@recurring_transaction.update!(payment_url: "https://pay.example.com")
patch recurring_transaction_url(@recurring_transaction),
params: { recurring_transaction: { payment_url: "" } }
assert_nil @recurring_transaction.reload.payment_url
end
# One biller routinely owns several bills that all pay at one portal, so the link
# can be fanned out on request. It must never reach a row outside the family.
test "update copies the payment link to sibling bills of the same merchant when asked" do
sibling = @family.recurring_transactions.create!(
account: accounts(:depository),
merchant: @recurring_transaction.merchant,
amount: 4.99,
dedup_scope: "4.99",
currency: "USD",
expected_day_of_month: 20,
last_occurrence_date: Date.current,
next_expected_date: 1.month.from_now.to_date,
status: "active"
)
other_merchant_bill = @family.recurring_transactions.create!(
account: accounts(:depository),
merchant: merchants(:amazon),
amount: 7.99,
currency: "USD",
expected_day_of_month: 21,
last_occurrence_date: Date.current,
next_expected_date: 1.month.from_now.to_date,
status: "active"
)
patch recurring_transaction_url(@recurring_transaction),
params: {
recurring_transaction: { payment_url: "https://pay.example.com" },
apply_to_siblings: "1"
}
assert_equal "https://pay.example.com", sibling.reload.payment_url
assert_nil other_merchant_bill.reload.payment_url
end
# Auto-detection leaves merchant_id null whenever the provider feed gave it nothing
# to match on, so most real bills are identified by name alone. Matching siblings on
# merchant only would skip them entirely.
test "update copies the payment link to name-matched siblings when there is no merchant" do
named = @family.recurring_transactions.create!(
account: accounts(:depository),
name: "TWITCH",
amount: 24.99,
currency: "USD",
expected_day_of_month: 21,
last_occurrence_date: Date.current,
next_expected_date: 1.month.from_now.to_date,
status: "active"
)
same_name = @family.recurring_transactions.create!(
account: accounts(:depository),
name: "TWITCH",
amount: 5.99,
dedup_scope: "5.99",
currency: "USD",
expected_day_of_month: 8,
last_occurrence_date: Date.current,
next_expected_date: 1.month.from_now.to_date,
status: "active"
)
different_name = @family.recurring_transactions.create!(
account: accounts(:depository),
name: "HUNTR.CO",
amount: 40,
currency: "USD",
expected_day_of_month: 28,
last_occurrence_date: Date.current,
next_expected_date: 1.month.from_now.to_date,
status: "active"
)
patch recurring_transaction_url(named),
params: {
recurring_transaction: { payment_url: "https://twitch.tv/subscriptions" },
apply_to_siblings: "1"
}
assert_equal "https://twitch.tv/subscriptions", same_name.reload.payment_url
assert_nil different_name.reload.payment_url
# A merchant-backed row must not be swept up by a name match.
assert_nil @recurring_transaction.reload.payment_url
end
test "update does not touch siblings unless asked" do
sibling = @family.recurring_transactions.create!(
account: accounts(:depository),
merchant: @recurring_transaction.merchant,
amount: 4.99,
dedup_scope: "4.99",
currency: "USD",
expected_day_of_month: 20,
last_occurrence_date: Date.current,
next_expected_date: 1.month.from_now.to_date,
status: "active"
)
patch recurring_transaction_url(@recurring_transaction),
params: { recurring_transaction: { payment_url: "https://pay.example.com" } }
assert_nil sibling.reload.payment_url
end
test "update cannot reach another family's recurring transaction" do
other_family_recurring = families(:empty).recurring_transactions.create!(
name: "Someone else's bill",
amount: 10,
currency: "USD",
expected_day_of_month: 3,
last_occurrence_date: Date.current,
next_expected_date: 1.month.from_now.to_date,
status: "active"
)
patch recurring_transaction_url(other_family_recurring),
params: { recurring_transaction: { payment_url: "https://evil.example.com" } }
assert_response :not_found
assert_nil other_family_recurring.reload.payment_url
end
# A bill outliving its own price is the normal case. These used to be
# create-only, so the only way to record a rise was delete-and-recreate,
# which takes the occurrences and allocations with it.
test "the edit dialog exposes name, amount and account" do
series = recurring_transactions(:netflix_subscription)
get edit_recurring_transaction_url(series)
assert_response :success
fields = response.body.scan(/name="recurring_transaction\[([a-z_]+)\]"/).flatten.uniq
%w[name amount account_id].each do |field|
assert_includes fields, field, "#{field} should be editable after creation"
end
refute_includes fields, "first_due_on",
"first_due_on is inert on a persisted series; the frequency picker owns the schedule"
end
test "updating name, amount and account persists all three" do
series = recurring_transactions(:netflix_subscription)
other = accounts(:credit_card)
patch recurring_transaction_url(series), params: {
recurring_transaction: { name: "Netflix Premium", amount: 24.99, account_id: other.id }
}
series.reload
assert_equal "Netflix Premium", series.name
assert_equal 24.99, series.amount.to_f
assert_equal other.id, series.account_id
end
test "a bill cannot be pointed at an account the user cannot reach" do
series = recurring_transactions(:netflix_subscription)
foreign = families(:empty).accounts.create!(
name: "Someone else's checking", balance: 0, currency: "USD",
accountable: Depository.new
)
refute_equal series.family_id, foreign.family_id
patch recurring_transaction_url(series), params: {
recurring_transaction: { account_id: foreign.id }
}
refute_equal foreign.id, series.reload.account_id,
"a crafted account_id must not reach another family's account"
end
test "editing an income series keeps its negative sign" do
income = recurring_transactions(:netflix_subscription)
income.update!(bill_type: "income", amount: -2000)
patch recurring_transaction_url(income), params: {
recurring_transaction: { amount: 2500 }
}
assert_equal(-2500, income.reload.amount.to_f,
"income is stored negative; a raw assignment would flip it into a bill")
end
test "the edit form shows an income amount as a positive magnitude" do
income = recurring_transactions(:netflix_subscription)
income.update!(bill_type: "income", amount: -2000)
get edit_recurring_transaction_url(income)
assert_response :success
# The stored sign is bookkeeping; the form edits what the paycheck pays.
assert_select "input[name=?][value=?]", "recurring_transaction[amount]", "2000.0"
end
test "the edit form shows a bill amount as it is stored" do
get edit_recurring_transaction_url(recurring_transactions(:netflix_subscription))
assert_response :success
assert_select "input[name=?][value=?]", "recurring_transaction[amount]", "15.99"
end
# Detected bills carry a merchant and no name of their own. The field has to
# arrive seeded, or it renders empty and, being required, browsers refuse to
# submit the whole form; and the rename has to actually show, or it is a
# control that silently does nothing.
test "renaming a detected bill seeds the field and takes effect" do
series = recurring_transactions(:netflix_subscription)
assert series.name.blank?, "premise: this bill is named by its merchant"
assert series.merchant.present?
get edit_recurring_transaction_url(series)
assert_select "input[name=?][value=?]", "recurring_transaction[name]", series.display_name
patch recurring_transaction_url(series), params: {
recurring_transaction: { name: "Netflix Premium" }
}
assert_equal "Netflix Premium", series.reload.display_name,
"a name the user typed should win over the detected merchant"
end
# --- Suggested-series review: confirm/dismiss from either page ---
test "confirming from the Bills page returns there and reconstructs the bill's history" do
last_month_ninth = Date.current.beginning_of_month + 8.days - 1.month
suggestion = @family.recurring_transactions.create!(
name: "CITY WATER", account: accounts(:depository), amount: 80, currency: "USD",
expected_day_of_month: 9, last_occurrence_date: last_month_ninth,
next_expected_date: last_month_ninth + 1.month, status: "suggested", manual: false
)
accounts(:depository).entries.create!(
date: last_month_ninth, amount: 80, currency: "USD", name: "CITY WATER",
entryable: Transaction.new
)
post confirm_recurring_transaction_url(suggestion), headers: { "HTTP_REFERER" => bills_url }
assert_redirected_to bills_url
assert suggestion.reload.active?
assert_operator suggestion.recurring_occurrences.count, :>, 0,
"confirming must materialize the schedule"
assert suggestion.recurring_occurrences.paid.where(due_on: last_month_ninth).exists?,
"confirming must close history a real entry anchors"
end
test "confirming twice does not double anything" do
suggestion = @family.recurring_transactions.create!(
name: "CITY GAS", account: accounts(:depository), amount: 55, currency: "USD",
expected_day_of_month: 9,
last_occurrence_date: Date.current.beginning_of_month + 8.days - 1.month,
next_expected_date: Date.current.beginning_of_month + 8.days,
status: "suggested", manual: false
)
post confirm_recurring_transaction_url(suggestion)
state = suggestion.recurring_occurrences.order(:due_on).pluck(:due_on, :status)
post confirm_recurring_transaction_url(suggestion)
assert_equal state, suggestion.recurring_occurrences.order(:due_on).pluck(:due_on, :status)
end
test "dismissing from the Bills page tombstones and returns there" do
suggestion = @family.recurring_transactions.create!(
name: "PHANTOM SUB", account: accounts(:depository), amount: 12, currency: "USD",
expected_day_of_month: 5, last_occurrence_date: 1.month.ago.to_date,
next_expected_date: Date.current, status: "suggested", manual: false
)
post dismiss_recurring_transaction_url(suggestion), headers: { "HTTP_REFERER" => bills_url }
assert_redirected_to bills_url
assert suggestion.reload.ended?
end
# --- "Search all your transactions" picker inside the add dialog ---
test "the add dialog links to the picker whether or not detection found anything" do
get new_recurring_transaction_url, headers: { "Turbo-Frame" => "modal" }
assert_response :success
assert_match I18n.t("recurring_transactions.new.search_all_cta"), response.body
assert_select "a[href=?]", new_recurring_transaction_path(picker: 1)
end
test "picker lists recent outflows as prefill links" do
entry = picker_entry(name: "ACME POWER", amount: 120)
get new_recurring_transaction_url(picker: 1), headers: { "Turbo-Frame" => "modal" }
assert_response :success
assert_match "ACME POWER", response.body
assert_select "a[href=?]", new_recurring_transaction_path(entry_id: entry.id)
end
test "picker filters by sign in each mode" do
picker_entry(name: "PAYCHECK DEPOSIT", amount: -900)
picker_entry(name: "ACME POWER", amount: 120)
get new_recurring_transaction_url(picker: 1), headers: { "Turbo-Frame" => "modal" }
assert_match "ACME POWER", response.body
assert_no_match "PAYCHECK DEPOSIT", response.body
get new_recurring_transaction_url(picker: 1, income: 1), headers: { "Turbo-Frame" => "modal" }
assert_match "PAYCHECK DEPOSIT", response.body
assert_no_match "ACME POWER", response.body
end
test "picker search matches the merchant behind a bank-blob entry name" do
picker_entry(name: "ACH WEB PMT 0042", amount: 15.49, merchant: merchants(:netflix))
picker_entry(name: "UNRELATED CHARGE", amount: 8)
get new_recurring_transaction_url(picker: 1, q: merchants(:netflix).name),
headers: { "Turbo-Frame" => "modal" }
assert_match "ACH WEB PMT 0042", response.body
assert_no_match "UNRELATED CHARGE", response.body
end
test "picker search matches notes" do
picker_entry(name: "CHECK 1042", amount: 300, notes: "quarterly water bill")
picker_entry(name: "CHECK 1043", amount: 300)
get new_recurring_transaction_url(picker: 1, q: "quarterly water"),
headers: { "Turbo-Frame" => "modal" }
assert_match "CHECK 1042", response.body
assert_no_match "CHECK 1043", response.body
end
test "picker search treats LIKE metacharacters as literals" do
picker_entry(name: "100% Juice Co", amount: 6)
picker_entry(name: "1003 Deli", amount: 9)
get new_recurring_transaction_url(picker: 1, q: "100%"), headers: { "Turbo-Frame" => "modal" }
assert_response :success
assert_match "100% Juice Co", response.body
assert_no_match "1003 Deli", response.body
end
test "picker hides transfers and excluded entries" do
picker_entry(name: "CARD PAYMENT", amount: 200, kind: "cc_payment")
picker_entry(name: "HIDDEN CHARGE", amount: 25, excluded: true)
picker_entry(name: "REAL CHARGE", amount: 25)
get new_recurring_transaction_url(picker: 1), headers: { "Turbo-Frame" => "modal" }
assert_match "REAL CHARGE", response.body
assert_no_match "CARD PAYMENT", response.body
assert_no_match "HIDDEN CHARGE", response.body
end
test "picker never shows an account the member was not given, even on exact match" do
hidden = picker_entry(name: "PRIVATE BROKERAGE FEE", amount: 30, account: accounts(:investment))
member = users(:family_member)
member.update!(preferences: (member.preferences || {}).merge("preview_features_enabled" => true))
sign_in member
get new_recurring_transaction_url(picker: 1, q: "PRIVATE BROKERAGE FEE"),
headers: { "Turbo-Frame" => "modal" }
assert_response :success
# The no-results copy echoes the query, so assert on the row link itself.
assert_select "a[href=?]", new_recurring_transaction_path(entry_id: hidden.id), count: 0
assert_match I18n.t("recurring_transactions.pick_entry.back"), response.body
end
test "an entry already backing a bill carries a chip instead of being hidden" do
claimed = picker_entry(name: "NETFLIX.COM", amount: 15.99)
series = @family.recurring_transactions.create!(
name: "Netflix", account: accounts(:depository), amount: 15.99, currency: "USD",
dedup_scope: "chip", expected_day_of_month: Date.current.day,
last_occurrence_date: 1.month.ago.to_date, next_expected_date: Date.current,
status: "active", manual: true
)
occurrence = series.recurring_occurrences.order(:due_on).first
occurrence.allocations.create!(
entry: claimed, allocated_amount: 15.99, currency: "USD", source: "user_created"
)
picker_entry(name: "UNCLAIMED CHARGE", amount: 12)
get new_recurring_transaction_url(picker: 1), headers: { "Turbo-Frame" => "modal" }
assert_match I18n.t("recurring_transactions.picker_row.claimed", name: "Netflix"), response.body
# The chip names its bill once, on the claimed row only.
assert_equal 1, response.body.scan(
I18n.t("recurring_transactions.picker_row.claimed", name: "Netflix")
).size
end
test "picker caps at twenty rows and says so" do
25.times { |i| picker_entry(name: "CHARGE #{format('%02d', i)}", amount: 5 + i) }
get new_recurring_transaction_url(picker: 1), headers: { "Turbo-Frame" => "modal" }
assert_equal RecurringTransactionsController::PICKER_SHOWN,
response.body.scan(/CHARGE \d\d/).uniq.size
assert_match I18n.t("recurring_transactions.pick_entry.showing_recent",
count: RecurringTransactionsController::PICKER_SHOWN), response.body
end
test "picker with no results explains and offers the way back" do
get new_recurring_transaction_url(picker: 1, q: "zzz-nothing-matches"),
headers: { "Turbo-Frame" => "modal" }
assert_response :success
assert_match CGI.escapeHTML("zzz-nothing-matches"), response.body
assert_match I18n.t("recurring_transactions.pick_entry.back"), response.body
end
# The declare, edit and suggestion paths shipped with Bills, so they honor
# the same preview gate as every other Bills surface. Direct URLs included:
# the gate is a before_action, not a matter of which buttons render.
test "the bills-era actions sit behind the preview gate" do
@user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => false))
get new_recurring_transaction_url
assert_redirected_to root_path
assert_no_difference "RecurringTransaction.count" do
post recurring_transactions_url, params: { recurring_transaction: {
name: "Gated", amount: 10, first_due_on: Date.current.iso8601, frequency_preset: "monthly"
} }
end
assert_redirected_to root_path
original_name = @recurring_transaction.name
patch recurring_transaction_url(@recurring_transaction), params: { recurring_transaction: { name: "Renamed" } }
assert_redirected_to root_path
assert_equal original_name, @recurring_transaction.reload.name
suggestion = create_series(name: "Maybe A Bill", status: "suggested")
post confirm_recurring_transaction_url(suggestion)
assert_redirected_to root_path
assert suggestion.reload.suggested?
end
test "the pre-bills settings actions stay reachable without the preview flag" do
@user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => false))
get recurring_transactions_url
assert_response :success
post toggle_status_recurring_transaction_url(@recurring_transaction)
assert_redirected_to recurring_transactions_url
end
# Sharing is per account: a read-only share may SEE the series everywhere the
# app lists it, and must not be able to change or remove it. Mirrors
# RecurringOccurrencesController#ensure_series_writable.
test "a read-only account share can see but not mutate a series" do
member = users(:family_member)
member.update!(preferences: (member.preferences || {}).merge("preview_features_enabled" => true))
# The credit card fixture is shared with the member read_only.
series = create_series(name: "Shared Read Only", account: accounts(:credit_card))
suggestion = create_series(name: "Shared Suggestion", account: accounts(:credit_card), status: "suggested")
sign_in member
# Visible: the read dialog opens. The write guard bites only on mutation.
get edit_recurring_transaction_url(series), headers: { "Turbo-Frame" => "modal" }
assert_response :success
assert_match "Shared Read Only", response.body
patch recurring_transaction_url(series), params: { recurring_transaction: { name: "Hijacked" } }
assert_response :not_found
assert_equal "Shared Read Only", series.reload.name
post toggle_status_recurring_transaction_url(series)
assert_response :not_found
assert series.reload.active?
post confirm_recurring_transaction_url(suggestion)
assert_response :not_found
assert suggestion.reload.suggested?
post dismiss_recurring_transaction_url(suggestion)
assert_response :not_found
assert suggestion.reload.suggested?
delete recurring_transaction_url(series)
assert_response :not_found
assert series.reload.persisted?
end
test "an accountless series carries no account write gate" do
series = create_series(name: "No Account", account: nil)
patch recurring_transaction_url(series), params: { recurring_transaction: { name: "Renamed Fine" } }
assert_response :redirect
assert_equal "Renamed Fine", series.reload.name
end
# The destination is a write too: attaching a series to an account changes
# what that account's owners see, so a read-only share cannot receive one,
# whether by edit or at declaration.
test "a read-only account cannot become a series' destination" do
member = users(:family_member)
member.update!(preferences: (member.preferences || {}).merge("preview_features_enabled" => true))
series = create_series(name: "Wandering Bill", account: nil)
sign_in member
patch recurring_transaction_url(series), params: { recurring_transaction: { account_id: accounts(:credit_card).id } }
assert_response :unprocessable_entity
assert_nil series.reload.account_id
assert_no_difference "RecurringTransaction.count" do
post recurring_transactions_url, params: { recurring_transaction: {
name: "Declared On Read Only", amount: 12, first_due_on: Date.current.iso8601,
frequency_preset: "monthly", account_id: accounts(:credit_card).id
} }
end
assert_response :unprocessable_entity
assert_match I18n.t("recurring_transactions.create.account_invalid"), response.body
end
# Clearing a payment link is a statement about one bill; the opt-in copy
# must not blank the siblings' own links on the way through.
test "clearing the payment link never blanks the siblings" do
source = create_series(name: "Twitch Tier 1", merchant: merchants(:netflix), payment_url: "https://pay.example/1")
sibling = create_series(name: "Twitch Tier 2", merchant: merchants(:netflix), payment_url: "https://pay.example/keep")
patch recurring_transaction_url(source), params: {
apply_to_siblings: "1",
recurring_transaction: { payment_url: "" }
}
assert_response :redirect
assert_nil source.reload.payment_url.presence
assert_equal "https://pay.example/keep", sibling.reload.payment_url
end
test "the sibling copy skips series on accounts the user cannot write" do
member = users(:family_member)
member.update!(preferences: (member.preferences || {}).merge("preview_features_enabled" => true))
source = create_series(name: "Portal Bill", account: nil, merchant: merchants(:netflix))
# The credit card fixture is shared with the member read_only: visible,
# therefore inside accessible_by, and exactly what the copy must skip.
read_only_sibling = create_series(name: "Portal Bill RO", account: accounts(:credit_card),
merchant: merchants(:netflix), payment_url: "https://pay.example/theirs")
sign_in member
patch recurring_transaction_url(source), params: {
apply_to_siblings: "1",
recurring_transaction: { payment_url: "https://pay.example/mine" }
}
assert_response :redirect
assert_equal "https://pay.example/mine", source.reload.payment_url
assert_equal "https://pay.example/theirs", read_only_sibling.reload.payment_url
end
private
def create_series(name:, account: accounts(:depository), merchant: nil, status: "active", payment_url: nil)
@family.recurring_transactions.create!(
account: account,
merchant: merchant,
name: name,
amount: 25,
dedup_scope: name,
currency: "USD",
expected_day_of_month: 5,
last_occurrence_date: 1.month.ago.to_date,
next_expected_date: 5.days.from_now.to_date,
status: status,
payment_url: payment_url
)
end
def picker_entry(name:, amount:, account: accounts(:depository), merchant: nil, notes: nil, kind: nil, excluded: false)
transaction_attrs = { merchant: merchant }
transaction_attrs[:kind] = kind if kind
account.entries.create!(
date: Date.current, amount: amount, currency: "USD", name: name,
notes: notes, excluded: excluded,
entryable: Transaction.new(**transaction_attrs)
)
end
end