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

1424 lines
51 KiB
Ruby

require "test_helper"
class TransactionsControllerTest < ActionDispatch::IntegrationTest
include EntryableResourceInterfaceTest, EntriesTestHelper
setup do
sign_in @user = users(:family_admin)
@entry = entries(:transaction)
end
# Bills has always linked out to transactions. Until now nothing linked back,
# so a transaction that settled a bill was a dead end. The link-back is part
# of the preview-gated bills surface, so the viewer needs the flag.
test "a transaction shows the bill it paid, and links to it" do
@user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => true))
series = @user.family.recurring_transactions.create!(
account: accounts(:depository), name: "Watson Property", amount: 2000,
currency: "USD", expected_day_of_month: 9, status: "active", manual: true,
bill_type: "bill", last_occurrence_date: Date.current,
next_expected_date: Date.current
)
series.recurring_occurrences.destroy_all
due = Date.current.beginning_of_month + 8
occurrence = series.recurring_occurrences.create!(
family: @user.family, original_due_on: due, due_on: due,
currency: "USD", expected_amount: 2000, status: "scheduled"
)
RecurringTransaction::Allocator.new(occurrence).allocate!(entry: @entry)
get transaction_url(@entry), headers: { "Turbo-Frame" => "drawer" }
assert_response :success
assert_match "Watson Property", response.body
assert_match bill_path(series), response.body, "the bill must be reachable from the transaction"
end
test "the bill link-back stays hidden without preview access" do
series = @user.family.recurring_transactions.create!(
account: accounts(:depository), name: "Watson Property", amount: 2000,
currency: "USD", expected_day_of_month: 9, status: "active", manual: true,
bill_type: "bill", last_occurrence_date: Date.current,
next_expected_date: Date.current
)
series.recurring_occurrences.destroy_all
due = Date.current.beginning_of_month + 8
occurrence = series.recurring_occurrences.create!(
family: @user.family, original_due_on: due, due_on: due,
currency: "USD", expected_amount: 2000, status: "scheduled"
)
RecurringTransaction::Allocator.new(occurrence).allocate!(entry: @entry)
get transaction_url(@entry), headers: { "Turbo-Frame" => "drawer" }
assert_response :success
assert_no_match bill_path(series), response.body,
"the preview-gated bill link must not render for a user without the flag"
end
test "index groups subcategories immediately after their parent in the category filter" do
get transactions_url
assert_response :success
doc = Nokogiri::HTML::Document.parse(response.body)
checkbox_values = doc.css("input[name='q[categories][]']").map { |node| node["value"] }
parent_index = checkbox_values.index(categories(:food_and_drink).name)
child_index = checkbox_values.index(categories(:subcategory).name)
assert_not_nil parent_index
assert_not_nil child_index
assert_equal parent_index + 1, child_index
end
test "creates with transaction details" do
assert_difference [ "Entry.count", "Transaction.count" ], 1 do
post transactions_url, params: {
entry: {
account_id: @entry.account_id,
name: "New transaction",
date: Date.current,
currency: "USD",
amount: 100,
nature: "inflow",
entryable_type: @entry.entryable_type,
entryable_attributes: {
tag_ids: [ tags(:one).id, tags(:two).id ],
category_id: Category.first.id,
merchant_id: Merchant.first.id
}
}
}
end
created_entry = Entry.order(:created_at).last
assert_redirected_to account_url(created_entry.account)
assert_equal "Transaction created", flash[:notice]
assert_enqueued_with(job: SyncJob)
end
test "create without an account re-renders the form instead of raising" do
assert_no_difference [ "Entry.count", "Transaction.count" ] do
post transactions_url, params: {
entry: {
account_id: "",
name: "New transaction",
date: Date.current,
currency: "USD",
amount: 100,
nature: "inflow",
entryable_type: "Transaction",
entryable_attributes: {
category_id: Category.first.id
}
}
}
end
assert_response :unprocessable_entity
end
test "updates with transaction details" do
assert_no_difference [ "Entry.count", "Transaction.count" ] do
patch transaction_url(@entry), params: {
entry: {
name: "Updated name",
date: Date.current,
currency: "USD",
amount: 100,
nature: "inflow",
entryable_type: @entry.entryable_type,
notes: "test notes",
excluded: false,
entryable_attributes: {
id: @entry.entryable_id,
tag_ids: [ tags(:one).id, tags(:two).id ],
category_id: Category.first.id,
merchant_id: Merchant.first.id
}
}
}
end
@entry.reload
assert_equal "Updated name", @entry.name
assert_equal Date.current, @entry.date
assert_equal "USD", @entry.currency
assert_equal -100, @entry.amount
assert_equal [ tags(:one).id, tags(:two).id ].sort, @entry.entryable.tag_ids.sort
assert_equal Category.first.id, @entry.entryable.category_id
assert_equal Merchant.first.id, @entry.entryable.merchant_id
assert_equal "test notes", @entry.notes
assert_equal false, @entry.excluded
assert_equal "Transaction updated", flash[:notice]
assert_redirected_to account_url(@entry.account)
assert_enqueued_with(job: SyncJob)
end
test "re-renders show with mark-recurring state when update fails validation" do
family = families(:empty)
sign_in users(:empty)
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
merchant = family.merchants.create! name: "Test Merchant"
entry = create_transaction(account: account, amount: 100, merchant: merchant)
family.recurring_transactions.create!(
account: account,
merchant: merchant,
amount: entry.amount,
currency: entry.currency,
expected_day_of_month: entry.date.day,
last_occurrence_date: entry.date,
next_expected_date: 1.month.from_now,
status: "active",
manual: true,
occurrence_count: 1
)
patch transaction_url(entry), params: {
entry: {
name: "",
date: entry.date,
currency: entry.currency,
amount: entry.amount.abs,
nature: "outflow",
entryable_type: entry.entryable_type,
entryable_attributes: { id: entry.entryable_id }
}
}
assert_response :unprocessable_entity
assert_includes response.body, "A manual recurring transaction already exists for this pattern"
assert_select "button[disabled]", text: /Mark as Recurring/
end
test "turbo_stream update refreshes mark-recurring state when it newly matches" do
family = families(:empty)
sign_in users(:empty)
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
merchant = family.merchants.create! name: "Test Merchant"
entry = create_transaction(account: account, amount: 100, name: "Other Name")
family.recurring_transactions.create!(
account: account,
merchant: merchant,
amount: entry.amount,
currency: entry.currency,
expected_day_of_month: entry.date.day,
last_occurrence_date: entry.date,
next_expected_date: 1.month.from_now,
status: "active",
manual: true,
occurrence_count: 1
)
patch transaction_url(entry), params: {
entry: {
date: entry.date,
currency: entry.currency,
amount: entry.amount.abs,
nature: "outflow",
entryable_type: entry.entryable_type,
entryable_attributes: { id: entry.entryable_id, merchant_id: merchant.id }
}
}, as: :turbo_stream
assert_response :success
assert_select "turbo-stream[target='#{dom_id(entry, :mark_recurring)}'] button[disabled]", text: /Mark as Recurring/
end
test "transaction count represents filtered total" do
family = families(:empty)
sign_in users(:empty)
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
3.times do
create_transaction(account: account)
end
get transactions_url(per_page: 10)
assert_dom "#total-transactions", count: 1, text: family.entries.transactions.size.to_s
searchable_transaction = create_transaction(account: account, name: "Unique test name")
get transactions_url(q: { search: searchable_transaction.name })
# Only finds 1 transaction that matches filter
assert_dom "#" + dom_id(searchable_transaction), count: 1
assert_dom "#total-transactions", count: 1, text: "1"
end
test "can update notes on split child transaction" do
parent = create_transaction(account: accounts(:depository), amount: 100)
parent.split!([ { name: "Part 1", amount: 60, category_id: nil }, { name: "Part 2", amount: 40, category_id: nil } ])
child = parent.child_entries.first
patch transaction_url(child), params: {
entry: { notes: "split child note", entryable_attributes: { id: child.entryable_id } }
}
assert_response :redirect
assert_equal "split child note", child.reload.notes
end
test "can update tags on split child transaction" do
parent = create_transaction(account: accounts(:depository), amount: 100)
parent.split!([ { name: "Part 1", amount: 60, category_id: nil }, { name: "Part 2", amount: 40, category_id: nil } ])
child = parent.child_entries.first
tag = tags(:one)
patch transaction_url(child), params: {
entry: { entryable_attributes: { id: child.entryable_id, tag_ids: [ tag.id ] } }
}
assert_response :redirect
assert_equal [ tag.id ], child.reload.entryable.tag_ids
end
test "can update tags through tag-only endpoint" do
patch tags_transaction_url(@entry, format: :json), params: {
tag_ids: [ tags(:one).id, tags(:two).id ]
}
assert_response :success
assert_equal [ tags(:one).id, tags(:two).id ].sort, @entry.reload.entryable.tag_ids.sort
assert_equal @entry.entryable.tag_ids.sort, JSON.parse(response.body)["tag_ids"].sort
end
test "tag-only endpoint ignores tags from another family" do
other_tag = users(:empty).family.tags.create!(name: "Other family")
patch tags_transaction_url(@entry, format: :json), params: {
tag_ids: [ tags(:one).id, other_tag.id ]
}
assert_response :success
assert_equal [ tags(:one).id ], @entry.reload.entryable.tag_ids
end
test "tag-only endpoint locks tags when clearing all tags" do
@entry.entryable.update!(tag_ids: [ tags(:one).id ], locked_attributes: {})
patch tags_transaction_url(@entry, format: :json), params: {
tag_ids: []
}, as: :json
assert_response :success
assert_empty @entry.reload.entryable.tag_ids
assert @entry.entryable.locked?(:tag_ids)
end
test "tag-only endpoint returns forbidden json for read-only users" do
sign_in users(:family_member)
read_only_entry = entries(:transfer_in)
original_tag_ids = read_only_entry.entryable.tag_ids
patch tags_transaction_url(read_only_entry), params: {
tag_ids: [ tags(:one).id ]
}, headers: {
"Accept" => "application/json"
}
assert_response :forbidden
assert_equal "application/json", response.media_type
assert_equal I18n.t("accounts.not_authorized"), JSON.parse(response.body)["error"]
assert_equal original_tag_ids, read_only_entry.reload.entryable.tag_ids
end
test "split parent rows mark amount as privacy-sensitive" do
entry = create_transaction(account: accounts(:depository), amount: 100, name: "Split parent")
entry.split!([
{ name: "Part 1", amount: 60, category_id: nil },
{ name: "Part 2", amount: 40, category_id: nil }
])
get transactions_url
assert_response :success
assert_select ".split-group > div.opacity-50 p.privacy-sensitive", count: 1
end
test "can paginate" do
family = families(:empty)
sign_in users(:empty)
# Clean up any existing entries to ensure clean test
family.accounts.each { |account| account.entries.delete_all }
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
# Create multiple transactions for pagination
25.times do |i|
create_transaction(
account: account,
name: "Transaction #{i + 1}",
amount: 100 + i, # Different amounts to prevent transfer matching
date: Date.current - i.days # Different dates
)
end
total_transactions = family.entries.transactions.count
assert_operator total_transactions, :>=, 20, "Should have at least 20 transactions for testing"
# Test page 1 - should show limited transactions
get transactions_url(page: 1, per_page: 10)
assert_response :success
page_1_count = css_select("turbo-frame[id^='entry_']").count
assert_equal 10, page_1_count, "Page 1 should respect per_page limit"
# Test page 2 - should show different transactions
get transactions_url(page: 2, per_page: 10)
assert_response :success
page_2_count = css_select("turbo-frame[id^='entry_']").count
assert_operator page_2_count, :>, 0, "Page 2 should show some transactions"
assert_operator page_2_count, :<=, 10, "Page 2 should not exceed per_page limit"
# Test Pagy overflow handling - should redirect or handle gracefully
get transactions_url(page: 9999999, per_page: 10)
# Either success (if Pagy shows last page) or redirect (if Pagy redirects)
assert_includes [ 200, 302 ], response.status, "Pagy should handle overflow gracefully"
if response.status == 302
follow_redirect!
assert_response :success
end
overflow_count = css_select("turbo-frame[id^='entry_']").count
assert_operator overflow_count, :>, 0, "Overflow should show some transactions"
end
test "filtered requests without per_page keep the stored page size" do
family = families(:empty)
sign_in users(:empty)
family.accounts.each { |account| account.entries.delete_all }
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
25.times do |i|
create_transaction(
account: account,
name: "Transaction #{i + 1}",
amount: 100 + i,
date: Date.current - i.days
)
end
get transactions_url(per_page: 20)
assert_response :success
# A filtered request that omits per_page has query params present, so it
# is not eligible for the "restore stored params" redirect - it must fall
# back to the previously stored per_page instead of the hardcoded default.
get transactions_url(q: { search: "Transaction" })
assert_response :success
assert_select "select[name='per_page'] option[value='20'][selected]"
assert_equal 20, css_select("turbo-frame[id^='entry_']").count
end
test "pagination does not duplicate or skip transactions with same date and timestamp" do
family = families(:empty)
user = users(:empty)
sign_in user
family.accounts.each { |account| account.entries.delete_all }
account = family.accounts.create! name: "Same day", balance: 0, currency: "USD", accountable: Depository.new
timestamp = Time.zone.parse("2026-05-05 12:00:00")
entries = 13.times.map do |index|
create_transaction(
account: account,
name: "May 05 Transaction #{index + 1}",
amount: 100 + index,
date: Date.new(2026, 5, 5),
created_at: timestamp,
updated_at: timestamp
)
end
expected_entry_ids = Entry.where(id: entries.map(&:id)).reverse_chronological.pluck(:id).map(&:to_s)
get transactions_url(page: 1, per_page: 10)
assert_response :success
page_1_entry_ids = rendered_entry_ids
get transactions_url(page: 2, per_page: 10)
assert_response :success
page_2_entry_ids = rendered_entry_ids
assert_equal expected_entry_ids.first(10), page_1_entry_ids
assert_equal expected_entry_ids.drop(10), page_2_entry_ids
assert_empty page_1_entry_ids & page_2_entry_ids
assert_equal expected_entry_ids, page_1_entry_ids + page_2_entry_ids
end
test "calls Transaction::Search totals method with correct search parameters" do
family = families(:empty)
sign_in users(:empty)
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
create_transaction(account: account, amount: 100)
search = Transaction::Search.new(family)
totals = OpenStruct.new(
count: 1,
expense_money: Money.new(10000, "USD"),
income_money: Money.new(0, "USD"),
transfer_inflow_money: Money.new(0, "USD"),
transfer_outflow_money: Money.new(0, "USD")
)
Transaction::Search.expects(:new).with(family, filters: {}, accessible_account_ids: [ account.id ]).returns(search)
search.expects(:totals).once.returns(totals)
get transactions_url
assert_response :success
end
test "calls Transaction::Search totals method with filtered search parameters" do
family = families(:empty)
sign_in users(:empty)
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
category = family.categories.create! name: "Food", color: "#ff0000"
create_transaction(account: account, amount: 100, category: category)
search = Transaction::Search.new(family, filters: { "categories" => [ "Food" ], "types" => [ "expense" ] })
totals = OpenStruct.new(
count: 1,
expense_money: Money.new(10000, "USD"),
income_money: Money.new(0, "USD"),
transfer_inflow_money: Money.new(0, "USD"),
transfer_outflow_money: Money.new(0, "USD")
)
Transaction::Search.expects(:new).with(family, filters: { "categories" => [ "Food" ], "types" => [ "expense" ] }, accessible_account_ids: [ account.id ]).returns(search)
search.expects(:totals).once.returns(totals)
get transactions_url(q: { categories: [ "Food" ], types: [ "expense" ] })
assert_response :success
end
test "shows inflow/outflow labels when filtering by transfers only" do
family = families(:empty)
sign_in users(:empty)
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
create_transaction(account: account, amount: 100)
search = Transaction::Search.new(family, filters: { "types" => [ "transfer" ] })
totals = OpenStruct.new(
count: 2,
expense_money: Money.new(0, "USD"),
income_money: Money.new(0, "USD"),
transfer_inflow_money: Money.new(5000, "USD"),
transfer_outflow_money: Money.new(3000, "USD")
)
Transaction::Search.expects(:new).with(family, filters: { "types" => [ "transfer" ] }, accessible_account_ids: [ account.id ]).returns(search)
search.expects(:totals).once.returns(totals)
get transactions_url(q: { types: [ "transfer" ] })
assert_response :success
assert_select "#total-income", text: totals.transfer_inflow_money.format
assert_select "#total-expense", text: totals.transfer_outflow_money.format
end
test "mark_as_recurring creates a manual recurring transaction" do
family = families(:empty)
sign_in users(:empty)
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
merchant = family.merchants.create! name: "Test Merchant"
entry = create_transaction(account: account, amount: 100, merchant: merchant)
transaction = entry.entryable
assert_difference "family.recurring_transactions.count", 1 do
post mark_as_recurring_transaction_path(transaction)
end
assert_redirected_to transactions_path
assert_equal "Transaction marked as recurring", flash[:notice]
recurring = family.recurring_transactions.last
assert_equal true, recurring.manual, "Expected recurring transaction to be manual"
assert_equal merchant.id, recurring.merchant_id
assert_equal entry.currency, recurring.currency
assert_equal entry.date.day, recurring.expected_day_of_month
end
test "mark_as_recurring shows alert if recurring transaction already exists" do
family = families(:empty)
sign_in users(:empty)
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
merchant = family.merchants.create! name: "Test Merchant"
entry = create_transaction(account: account, amount: 100, merchant: merchant)
transaction = entry.entryable
# Create existing recurring transaction
family.recurring_transactions.create!(
account: account,
merchant: merchant,
amount: entry.amount,
currency: entry.currency,
expected_day_of_month: entry.date.day,
last_occurrence_date: entry.date,
next_expected_date: 1.month.from_now,
status: "active",
manual: true,
occurrence_count: 1
)
assert_no_difference "RecurringTransaction.count" do
post mark_as_recurring_transaction_path(transaction)
end
assert_redirected_to transactions_path
assert_equal "A manual recurring transaction already exists for this pattern", flash[:alert]
end
test "mark_as_recurring allows a second manual recurring transaction with same merchant but different amount" do
family = families(:empty)
sign_in users(:empty)
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
merchant = family.merchants.create! name: "Test Merchant"
entry = create_transaction(account: account, amount: 34, merchant: merchant)
transaction = entry.entryable
# Existing manual recurring row for the same merchant, but a different amount
family.recurring_transactions.create!(
account: account,
merchant: merchant,
amount: 12,
currency: entry.currency,
expected_day_of_month: entry.date.day,
last_occurrence_date: entry.date,
next_expected_date: 1.month.from_now,
status: "active",
manual: true,
occurrence_count: 1
)
assert_difference "family.recurring_transactions.count", 1 do
post mark_as_recurring_transaction_path(transaction)
end
assert_redirected_to transactions_path
assert_equal "Transaction marked as recurring", flash[:notice]
end
test "mark_as_recurring allows a second manual recurring transaction with same name but different amount" do
family = families(:empty)
sign_in users(:empty)
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
entry = create_transaction(account: account, name: "Example Payee", amount: 34)
transaction = entry.entryable
# Existing manual recurring row for the same payee name, but a different amount
family.recurring_transactions.create!(
account: account,
name: "Example Payee",
amount: 12,
currency: entry.currency,
expected_day_of_month: entry.date.day,
last_occurrence_date: entry.date,
next_expected_date: 1.month.from_now,
status: "active",
manual: true,
occurrence_count: 1
)
assert_difference "family.recurring_transactions.count", 1 do
post mark_as_recurring_transaction_path(transaction)
end
assert_redirected_to transactions_path
assert_equal "Transaction marked as recurring", flash[:notice]
end
test "mark_as_recurring shows alert if recurring transaction with same name and amount already exists" do
family = families(:empty)
sign_in users(:empty)
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
entry = create_transaction(account: account, name: "Example Payee", amount: 100)
transaction = entry.entryable
family.recurring_transactions.create!(
account: account,
name: "Example Payee",
amount: entry.amount,
currency: entry.currency,
expected_day_of_month: entry.date.day,
last_occurrence_date: entry.date,
next_expected_date: 1.month.from_now,
status: "active",
manual: true,
occurrence_count: 1
)
assert_no_difference "RecurringTransaction.count" do
post mark_as_recurring_transaction_path(transaction)
end
assert_redirected_to transactions_path
assert_equal "A manual recurring transaction already exists for this pattern", flash[:alert]
end
test "mark_as_recurring shows already-exists alert when a concurrent request wins the race" do
family = families(:empty)
sign_in users(:empty)
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
merchant = family.merchants.create! name: "Test Merchant"
entry = create_transaction(account: account, amount: 100, merchant: merchant)
transaction = entry.entryable
# Simulate another request creating the identical pattern between our
# pre-check and our create call.
RecurringTransaction.expects(:create_from_transaction).raises(
ActiveRecord::RecordNotUnique.new("duplicate key value violates unique constraint")
)
assert_no_difference "RecurringTransaction.count" do
post mark_as_recurring_transaction_path(transaction)
end
assert_redirected_to transactions_path
assert_equal "A manual recurring transaction already exists for this pattern", flash[:alert]
end
test "mark_as_recurring handles validation errors gracefully" do
family = families(:empty)
sign_in users(:empty)
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
merchant = family.merchants.create! name: "Test Merchant"
entry = create_transaction(account: account, amount: 100, merchant: merchant)
transaction = entry.entryable
# Stub create_from_transaction to raise a validation error
RecurringTransaction.expects(:create_from_transaction).raises(
ActiveRecord::RecordInvalid.new(
RecurringTransaction.new.tap { |rt| rt.errors.add(:base, "Test validation error") }
)
)
assert_no_difference "RecurringTransaction.count" do
post mark_as_recurring_transaction_path(transaction)
end
assert_redirected_to transactions_path
assert_equal "Failed to create recurring transaction. Please check the transaction details and try again.", flash[:alert]
end
test "mark_as_recurring handles unexpected errors gracefully" do
family = families(:empty)
sign_in users(:empty)
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
merchant = family.merchants.create! name: "Test Merchant"
entry = create_transaction(account: account, amount: 100, merchant: merchant)
transaction = entry.entryable
# Stub create_from_transaction to raise an unexpected error
RecurringTransaction.expects(:create_from_transaction).raises(StandardError.new("Unexpected error"))
assert_no_difference "RecurringTransaction.count" do
post mark_as_recurring_transaction_path(transaction)
end
assert_redirected_to transactions_path
assert_equal "An unexpected error occurred while creating the recurring transaction", flash[:alert]
end
test "unlock clears protection flags on user-modified entry" do
family = families(:empty)
sign_in users(:empty)
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
entry = create_transaction(account: account, amount: 100)
transaction = entry.entryable
# Mark as protected with locked_attributes on both entry and entryable
entry.update!(user_modified: true, locked_attributes: { "date" => Time.current.iso8601 })
transaction.update!(locked_attributes: { "category_id" => Time.current.iso8601 })
assert entry.reload.protected_from_sync?
post unlock_transaction_path(transaction)
assert_redirected_to transactions_path
assert_equal "Entry unlocked. It may be updated on next sync.", flash[:notice]
entry.reload
assert_not entry.user_modified?
assert_empty entry.locked_attributes, "Entry locked_attributes should be cleared"
assert_empty entry.entryable.locked_attributes, "Transaction locked_attributes should be cleared"
assert_not entry.protected_from_sync?
end
test "new renders category and merchant selectors in German" do
get new_transaction_url(locale: "de")
assert_response :success
assert_select "[data-controller='category-select']" do
assert_select "input[type='search'][placeholder=?]", "Kategorien suchen"
assert_select "[data-category-select-create-label-value=?]", "„__CATEGORY_NAME__“ erstellen"
assert_select "[data-category-select-create-error-message-value=?]", "Kategorie konnte nicht erstellt werden"
end
assert_select "[data-controller='merchant-select']" do
assert_select "input[type='search'][placeholder=?]", "Händler suchen oder erstellen"
assert_select "[data-merchant-select-error-message-value=?]", "Händler konnte nicht erstellt werden"
assert_select "[data-merchant-select-target='createForm']", text: /Erstellen/
end
end
test "new groups subcategories immediately after their parent in the category select" do
get new_transaction_url
assert_response :success
doc = Nokogiri::HTML::Document.parse(response.body)
trigger = doc.at_css("#category_id_trigger")
assert_not_nil trigger, "expected the category select trigger button to render"
wrapper = trigger.ancestors(".relative").first
category_values = wrapper.css("[data-value]").map { |node| node["data-value"] }
parent_index = category_values.index(categories(:food_and_drink).id)
child_index = category_values.index(categories(:subcategory).id)
assert_not_nil parent_index
assert_not_nil child_index
assert_equal parent_index + 1, child_index
child_option = wrapper.at_css("[data-category-id='#{categories(:subcategory).id}']")
assert_not_nil child_option, "expected the subcategory option to render"
assert child_option.at_css("[data-testid='category-select-subcategory-indicator']"),
"expected the subcategory option to show the hierarchy indicator used in Settings"
end
test "new renders a search box for account selection" do
get new_transaction_url
assert_response :success
doc = Nokogiri::HTML::Document.parse(response.body)
trigger = doc.at_css("#account_id_trigger")
assert_not_nil trigger, "expected the account select trigger button to render"
wrapper = trigger.ancestors(".relative").first
assert_not_nil wrapper.at_css("input[type='search']"), "expected a search input inside the account select"
end
test "new with duplicate_entry_id pre-fills form from source transaction" do
@entry.reload
get new_transaction_url(duplicate_entry_id: @entry.id)
assert_response :success
assert_select "input[name='entry[name]'][value=?]", @entry.name
assert_select "input[type='number'][name='entry[amount]']" do |elements|
assert_equal sprintf("%.2f", @entry.amount.abs), elements.first["value"]
end
assert_select "input[type='hidden'][name='entry[entryable_attributes][merchant_id]']"
end
test "new with invalid duplicate_entry_id renders empty form" do
get new_transaction_url(duplicate_entry_id: -1)
assert_response :success
assert_select "input[name='entry[name]']" do |elements|
assert_nil elements.first["value"]
end
end
test "new with duplicate_entry_id from another family does not prefill form" do
other_family = families(:empty)
other_account = other_family.accounts.create!(name: "Other", balance: 0, currency: "USD", accountable: Depository.new)
other_entry = create_transaction(account: other_account, name: "Should not leak", amount: 50)
get new_transaction_url(duplicate_entry_id: other_entry.id)
assert_response :success
assert_select "input[name='entry[name]']" do |elements|
assert_nil elements.first["value"]
end
end
test "new preloads transaction form option data" do
family = families(:empty)
user = users(:empty)
sign_in user
manual_account_ids = []
4.times do |idx|
account = family.accounts.create!(
name: "Manual Account #{idx}",
balance: 0,
currency: "USD",
accountable: Depository.new
)
assert Account.manual.active.exists?(id: account.id), "Account should be included in the manual active scope"
manual_account_ids << account.id
family.categories.create!(
name: "Category #{idx}",
color: "#000000",
lucide_icon: "shapes"
)
family.merchants.create!(name: "Merchant #{idx}")
family.tags.create!(name: "Tag #{idx}")
end
inaccessible_account = families(:dylan_family).accounts.create!(
name: "Other Family Account",
balance: 0,
currency: "EUR",
accountable: Depository.new
)
queries = capture_sql_queries { get new_transaction_url }
assert_response :success
assert_select "input[name='entry[account_id]']"
assert_select "input[name='entry[entryable_attributes][category_id]']"
assert_select "input[name='entry[entryable_attributes][merchant_id]']"
assert_select "form[data-transaction-form-account-currencies-value]" do |forms|
account_currencies = JSON.parse(forms.first["data-transaction-form-account-currencies-value"])
manual_account_ids.each do |account_id|
assert_equal "USD", account_currencies[account_id.to_s]
end
assert_nil account_currencies[inaccessible_account.id.to_s]
end
assert_empty queries.grep(/FROM "account_providers" WHERE "account_providers"\."account_id" =/)
assert_operator queries.grep(/FROM "active_storage_attachments" WHERE "active_storage_attachments"\."record_id" =/).size, :<=, 1
assert_operator queries.grep(/SELECT "categories"\.\* FROM "categories" WHERE "categories"\."family_id" =/).size, :<=, 1
end
test "unlock clears import_locked flag" do
family = families(:empty)
sign_in users(:empty)
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
entry = create_transaction(account: account, amount: 100)
transaction = entry.entryable
# Mark as import locked
entry.update!(import_locked: true)
assert entry.reload.protected_from_sync?
post unlock_transaction_path(transaction)
assert_redirected_to transactions_path
entry.reload
assert_not entry.import_locked?
assert_not entry.protected_from_sync?
end
test "exchange_rate endpoint returns rate for different currencies" do
ExchangeRate.expects(:find_or_fetch_rate)
.with(from: "EUR", to: "USD", date: Date.current)
.returns(1.2)
get exchange_rate_url, params: {
from: "EUR",
to: "USD",
date: Date.current
}
assert_response :success
json_response = JSON.parse(response.body)
assert_equal 1.2, json_response["rate"]
end
test "exchange_rate endpoint returns same_currency for matching currencies" do
get exchange_rate_url, params: {
from: "USD",
to: "USD"
}
assert_response :success
json_response = JSON.parse(response.body)
assert json_response["same_currency"]
assert_equal 1.0, json_response["rate"]
end
test "exchange_rate endpoint uses provided date" do
custom_date = 3.days.ago.to_date
ExchangeRate.expects(:find_or_fetch_rate)
.with(from: "EUR", to: "USD", date: custom_date)
.returns(1.25)
get exchange_rate_url, params: {
from: "EUR",
to: "USD",
date: custom_date
}
assert_response :success
json_response = JSON.parse(response.body)
assert_equal 1.25, json_response["rate"]
end
test "exchange_rate endpoint returns 400 when from currency is missing" do
get exchange_rate_url, params: {
to: "USD"
}
assert_response :bad_request
json_response = JSON.parse(response.body)
assert_equal "from and to currencies are required", json_response["error"]
end
test "exchange_rate endpoint returns 400 when to currency is missing" do
get exchange_rate_url, params: {
from: "EUR"
}
assert_response :bad_request
json_response = JSON.parse(response.body)
assert_equal "from and to currencies are required", json_response["error"]
end
test "exchange_rate endpoint returns 400 on invalid date format" do
get exchange_rate_url, params: {
from: "EUR",
to: "USD",
date: "not-a-date"
}
assert_response :bad_request
json_response = JSON.parse(response.body)
assert_equal "Invalid date format", json_response["error"]
end
test "exchange_rate endpoint returns 404 when rate not found" do
ExchangeRate.expects(:find_or_fetch_rate)
.with(from: "EUR", to: "USD", date: Date.current)
.returns(nil)
get exchange_rate_url, params: {
from: "EUR",
to: "USD"
}
assert_response :not_found
json_response = JSON.parse(response.body)
assert_equal "Exchange rate not found", json_response["error"]
end
test "creates transaction with custom exchange rate" do
account = @user.family.accounts.create!(
name: "USD Account",
currency: "USD",
balance: 1000,
accountable: Depository.new
)
assert_difference [ "Entry.count", "Transaction.count" ], 1 do
post transactions_url, params: {
entry: {
account_id: account.id,
name: "EUR transaction with custom rate",
date: Date.current,
currency: "EUR",
amount: 100,
nature: "outflow",
entryable_type: "Transaction",
entryable_attributes: {
category_id: Category.first.id,
exchange_rate: "1.5"
}
}
}
end
created_entry = Entry.order(:created_at).last
assert_equal "EUR", created_entry.currency
assert_equal 100, created_entry.amount
assert_equal 1.5, created_entry.transaction.extra["exchange_rate"]
end
test "creates transaction without custom exchange rate" do
account = @user.family.accounts.create!(
name: "USD Account",
currency: "USD",
balance: 1000,
accountable: Depository.new
)
assert_difference [ "Entry.count", "Transaction.count" ], 1 do
post transactions_url, params: {
entry: {
account_id: account.id,
name: "EUR transaction without custom rate",
date: Date.current,
currency: "EUR",
amount: 100,
nature: "outflow",
entryable_type: "Transaction",
entryable_attributes: {
category_id: Category.first.id
}
}
}
end
created_entry = Entry.order(:created_at).last
assert_nil created_entry.transaction.extra["exchange_rate"]
end
test "index preloads transfer counterparty entry and account to avoid N+1" do
family = @user.family
from_account = family.accounts.visible.first
to_account = family.accounts.create!(
name: "Transfer Counterparty",
currency: family.currency,
balance: 0,
accountable: Depository.new
)
transfers = 6.times.map do |i|
create_transfer(
from_account: from_account,
to_account: to_account,
amount: 25 + i,
date: Date.current - i.days
)
end
queries = capture_sql_queries do
# per_page must fit all transfer legs + fixtures so assertions below
# actually exercise the transfer render path this preload protects.
get transactions_url(per_page: 50)
end
assert_response :success
# Index dedupes transfers to the outflow side; assert those rows rendered so
# the SQL assertions below actually exercise Transfer#to_account.
rendered_ids = rendered_entry_ids
transfers.each do |transfer|
assert_includes rendered_ids, transfer.outflow_transaction.entry.id.to_s,
"Expected transfer outflow entry to render on the index"
end
# Transfer#categorizable? / #payment? walk to_account via
# transfer.inflow_transaction.entry.account. Without nested includes those
# become one lookup triad per transfer row during list render.
normalized_queries = queries.map { |sql| normalize_sql_query(sql) }
assert_empty single_record_lookups(normalized_queries, table: "transactions", column: "id"),
"Expected transfer counterparty transactions to be preloaded"
assert_empty single_record_lookups(normalized_queries, table: "entries", column: "entryable_id"),
"Expected transfer counterparty entries to be preloaded"
assert_empty single_record_lookups(normalized_queries, table: "accounts", column: "id"),
"Expected transfer counterparty accounts to be preloaded"
end
test "index caches uncategorized_count and projected_recurring across requests" do
# Test environment uses null_store; swap in a memory store so the cache
# actually persists between the two requests below.
original_cache = Rails.cache
Rails.cache = ActiveSupport::Cache::MemoryStore.new
get transactions_url
assert_response :success
queries = capture_sql_queries { get transactions_url }
assert_response :success
# uncategorized_transactions is a scope (joins/wheres), so its name never
# appears in the generated SQL -- match the COUNT query it produces instead.
assert_empty queries.select { |q| q =~ /SELECT COUNT/i && q =~ /category_id.*IS NULL/i },
"second request with unchanged data should reuse the cached uncategorized count"
# Building the cache key itself still runs small COUNT/MAX queries against
# recurring_transactions (Family#recurring_transactions_version and
# #recurring_transaction_merchants_version), so match the projection query
# the cached block itself would run instead of the whole table name.
assert_empty queries.grep(/next_expected_date/i),
"second request with unchanged data should reuse the cached projected recurring lookup"
ensure
Rails.cache = original_cache
end
test "index uncategorized_count cache reflects new transactions immediately" do
original_cache = Rails.cache
Rails.cache = ActiveSupport::Cache::MemoryStore.new
get transactions_url
initial_count = rendered_uncategorized_count
account = @user.family.accounts.visible.first
account.entries.create!(
name: "New uncategorized transaction",
amount: 42,
currency: "USD",
date: Date.current,
entryable: Transaction.new
)
get transactions_url
updated_count = rendered_uncategorized_count
assert_equal initial_count + 1, updated_count,
"a new uncategorized transaction must be reflected without a stale cache read"
ensure
Rails.cache = original_cache
end
test "index uncategorized_count cache reflects categorizing a transaction immediately" do
original_cache = Rails.cache
Rails.cache = ActiveSupport::Cache::MemoryStore.new
entry = @user.family.accounts.visible.first.entries.create!(
name: "Needs a category",
amount: 42,
currency: "USD",
date: Date.current,
entryable: Transaction.new
)
get transactions_url
initial_count = rendered_uncategorized_count
entry.entryable.update!(category: categories(:food_and_drink))
get transactions_url
updated_count = rendered_uncategorized_count
assert_equal initial_count - 1, updated_count,
"categorizing a transaction must be reflected without a stale cache read"
ensure
Rails.cache = original_cache
end
test "index uncategorized_count cache is invalidated when account-share access is revoked" do
original_cache = Rails.cache
Rails.cache = ActiveSupport::Cache::MemoryStore.new
member = users(:family_member)
share = account_shares(:depository_shared_with_member)
share.account.entries.create!(
name: "Uncategorized on shared account",
amount: 42,
currency: "USD",
date: Date.current,
entryable: Transaction.new
)
sign_in member
get transactions_url
count_with_access = rendered_uncategorized_count
assert_operator count_with_access, :>, 0,
"the member should see the shared account's uncategorized transaction before revocation"
share.destroy!
get transactions_url
count_after_revocation = rendered_uncategorized_count
assert_operator count_after_revocation, :<, count_with_access,
"revoking account-share access must not leave a stale cached count that still includes the now-inaccessible account"
ensure
Rails.cache = original_cache
end
test "index projected_recurring cache is invalidated when account-share access is revoked" do
original_cache = Rails.cache
Rails.cache = ActiveSupport::Cache::MemoryStore.new
member = users(:family_member)
share = account_shares(:depository_shared_with_member)
recurring = recurring_transactions(:netflix_subscription)
assert_equal share.account_id, recurring.account_id,
"fixture assumption: the shared depository account has the netflix recurring charge"
merchant_name_pattern = /#{Regexp.escape(recurring.merchant.name)}/
sign_in member
get transactions_url
assert_match merchant_name_pattern, response.body,
"the member should see the shared account's recurring transaction before revocation"
share.destroy!
get transactions_url
assert_no_match merchant_name_pattern, response.body,
"revoking account-share access must not leave a stale cached projected-recurring list"
ensure
Rails.cache = original_cache
end
test "index projected_recurring cache reflects a merchant rename immediately" do
original_cache = Rails.cache
Rails.cache = ActiveSupport::Cache::MemoryStore.new
recurring = recurring_transactions(:netflix_subscription)
merchant = recurring.merchant
get transactions_url
assert_match(/#{Regexp.escape(merchant.name)}/, response.body,
"the recurring transaction should render with the merchant's original name")
merchant.update!(name: "Netflix Renamed")
get transactions_url
assert_match(/Netflix Renamed/, response.body,
"renaming the merchant must not leave a stale cached projected-recurring list")
ensure
Rails.cache = original_cache
end
test "index projected_recurring cache reflects a provider merchant update immediately" do
original_cache = Rails.cache
Rails.cache = ActiveSupport::Cache::MemoryStore.new
# Recurring detection copies transaction.merchant_id, which can point at a
# shared ProviderMerchant (not just a family-owned FamilyMerchant) -- e.g.
# ProviderMerchant::Enhancer updates these after the recurring row exists.
provider_merchant = ProviderMerchant.create!(name: "Provider Merchant Original", source: "enable_banking")
recurring = recurring_transactions(:netflix_subscription)
recurring.update!(merchant: provider_merchant, name: nil)
get transactions_url
assert_match(/Provider Merchant Original/, response.body,
"the recurring transaction should render with the provider merchant's original name")
provider_merchant.update!(name: "Provider Merchant Renamed")
get transactions_url
assert_match(/Provider Merchant Renamed/, response.body,
"updating the provider merchant must not leave a stale cached projected-recurring list")
ensure
Rails.cache = original_cache
end
test "index uncategorized_count cache reflects deleting an uncategorized transaction immediately" do
original_cache = Rails.cache
Rails.cache = ActiveSupport::Cache::MemoryStore.new
entry = @user.family.accounts.visible.first.entries.create!(
name: "Deleted while uncategorized",
amount: 42,
currency: "USD",
date: Date.current,
entryable: Transaction.new
)
get transactions_url
initial_count = rendered_uncategorized_count
entry.destroy!
get transactions_url
updated_count = rendered_uncategorized_count
assert_equal initial_count - 1, updated_count,
"deleting an uncategorized transaction must not leave a stale cached count"
ensure
Rails.cache = original_cache
end
test "index uncategorized_count cache reflects disabling an account immediately" do
original_cache = Rails.cache
Rails.cache = ActiveSupport::Cache::MemoryStore.new
account = @user.family.accounts.visible.first
account.entries.create!(
name: "Uncategorized on account about to be disabled",
amount: 42,
currency: "USD",
date: Date.current,
entryable: Transaction.new
)
get transactions_url
initial_count = rendered_uncategorized_count
assert_operator initial_count, :>, 0
account.disable!
get transactions_url
updated_count = rendered_uncategorized_count
assert_operator updated_count, :<, initial_count,
"disabling an account must not leave a stale cached count that still includes its transactions"
ensure
Rails.cache = original_cache
end
test "index uncategorized_count cache is scoped per user, not just per family" do
original_cache = Rails.cache
Rails.cache = ActiveSupport::Cache::MemoryStore.new
# family_member has no share on this account (see test/fixtures/account_shares.yml),
# so only the admin can see its uncategorized transaction.
accounts(:other_asset).entries.create!(
name: "Admin-only uncategorized transaction",
amount: 42,
currency: "USD",
date: Date.current,
entryable: Transaction.new
)
get transactions_url
admin_count = rendered_uncategorized_count
assert_operator admin_count, :>, 0
sign_in users(:family_member)
get transactions_url
member_count = rendered_uncategorized_count
assert_not_equal admin_count, member_count,
"a member without access to the admin-only account must not reuse the admin's cached uncategorized count"
ensure
Rails.cache = original_cache
end
private
def rendered_entry_ids
css_select("turbo-frame[id^='entry_']").map { |node| node["id"].delete_prefix("entry_") }
end
def normalize_sql_query(sql)
sql.to_s.squish.gsub(/[`"]/, "").downcase
end
# The "Categorize (N)" menu item only renders when @uncategorized_count
# is positive, so an absent match means the count is 0.
def rendered_uncategorized_count
match = response.body.match(/Categorize \((\d+)\)/)
match ? match[1].to_i : 0
end
# Per-row lazy loads use `column = ?`. Do not treat `IN (...)` as lazy
# loads — ActiveRecord nested preloads also use single-value IN when only
# one associated record is needed (e.g. one counterparty account).
def single_record_lookups(normalized_queries, table:, column:)
pattern = /
from\s+#{Regexp.escape(table)}\s+
where\s+#{Regexp.escape(table)}\.#{Regexp.escape(column)}\s*=
/x
normalized_queries.grep(pattern)
end
def capture_sql_queries
queries = []
callback = lambda do |_name, _started, _finished, _unique_id, payload|
next if payload[:cached]
next if %w[SCHEMA TRANSACTION].include?(payload[:name])
queries << payload[:sql].squish
end
ActiveSupport::Notifications.subscribed(callback, "sql.active_record") do
yield
end
queries
end
end