Files
sure/test/models/assistant/function/get_bills_test.rb
T
Brandon ce92b36351 feat(bills): assistant and MCP tools for bills (#3203)
* feat(bills): assistant and MCP tools for bills

Last of three chunks carved out of #3083, stacked on the UI bundle. Exposes
bills to the builtin assistant and to MCP clients. Everything here is gated
behind preview features, so the tools are absent from tools/list until a user
opts in.

Seven tools:

- get_bills, get_bill_details and get_paycheck_plan for reads
- get_bill_audit, a deterministic review that surfaces likely duplicates, price
  changes, trials about to convert, upcoming renewals and long-overdue bills
- create_bill, update_bill and record_bill_payment for writes

Shared argument parsing, permission checks and error shapes live in
BillsSupport, so every tool answers with the same {error, hint} contract the
existing tools use, and a bad argument never aborts the turn.

The write tools mutate financial records on a model's say-so, so they refuse
rather than guess: a payment cannot exceed what its cycle still owes, a repeated
settle will not quietly close next month, an unrecognized frequency is an error
instead of a silent monthly default, and non-finite or negative amounts are
rejected before they reach the database.

The read tools say what they filtered. An empty result names the statuses that
do hold matches, the paycheck plan discloses the unconfirmed series it excluded
from spending headroom, and history and price-change windows report their real
totals rather than letting a caller sum a truncated list.

A not-found no longer returns the scoped relation's SQL, which handed any MCP
client the access-control schema for the cost of a guessed id.

The in-page AI helpers are not here. Smart fill and smart configuration are
buttons on the bills pages, so they ship with the UI bundle along with the
provider-side suggester they call.

Suite 7,854 runs, 0 failures. Rubocop clean, eager loading verified.

* Address the ready-review round

* Reject an out-of-range audit lookback out loud

* Speak the cycle remainder guard through the allocator locale
2026-09-02 07:06:13 +02:00

241 lines
9.3 KiB
Ruby

require "test_helper"
class Assistant::Function::GetBillsTest < ActiveSupport::TestCase
setup do
@user = users(:family_admin)
@family = @user.family
@family.recurring_transactions.destroy_all
end
test "defaults to active bills and hides review states" do
create_series(name: "Active bill", amount: 50)
create_series(name: "Suggested detection", amount: 20, status: "suggested", manual: false)
create_series(name: "Dismissed", amount: 10, status: "ended")
create_series(name: "Paused bill", amount: 30, status: "inactive")
result = call_tool
names = result[:bills].map { |bill| bill[:name] }
assert_includes names, "Active bill"
assert_not_includes names, "Suggested detection"
assert_not_includes names, "Dismissed"
assert_not_includes names, "Paused bill"
end
test "the paused filter speaks the UI vocabulary over the stored value" do
create_series(name: "Paused bill", amount: 30, status: "inactive")
result = call_tool("status" => "paused")
row = result[:bills].sole
assert_equal "Paused bill", row[:name]
assert_equal "paused", row[:status], "stored 'inactive' must serialize as the UI's word"
end
test "payment_state filters on the current occurrence" do
travel_to Date.current do
overdue_day = 10.days.ago.to_date
create_series(name: "Late bill", amount: 75,
expected_day_of_month: overdue_day.day,
anchor_date: overdue_day,
last_occurrence_date: 2.months.ago.to_date,
next_expected_date: overdue_day)
# Anchored at its own future due date: the generator floors at anchor,
# so no past-cycle row exists to read as overdue.
future_day = Date.current + 10
create_series(name: "Future bill", amount: 20,
expected_day_of_month: future_day.day,
anchor_date: future_day,
last_occurrence_date: future_day - 1.month,
next_expected_date: future_day)
result = call_tool("payment_state" => "overdue")
assert_equal [ "Late bill" ], result[:bills].map { |bill| bill[:name] }
end
end
test "search matches the merchant behind a nameless series" do
create_series(name: nil, merchant: merchants(:netflix), amount: 15.99)
create_series(name: "Water", amount: 80)
result = call_tool("search" => merchants(:netflix).name)
assert_equal [ merchants(:netflix).name ], result[:bills].map { |bill| bill[:name] }
end
test "a member only sees bills on accounts they were given" do
create_series(name: "Visible bill", amount: 10)
create_series(name: "Hidden brokerage bill", amount: 99, account: accounts(:investment))
result = Assistant::Function::GetBills.new(users(:family_member)).call({})
names = result[:bills].map { |bill| bill[:name] }
assert_includes names, "Visible bill"
assert_not_includes names, "Hidden brokerage bill"
end
test "totals exclude income and transfers and count the overdue" do
create_series(name: "Real bill", amount: 100)
create_series(name: "Paycheck", amount: -2000, bill_type: "income")
create_series(name: "Card payment", amount: 300, bill_type: "transfer",
destination_account_id: accounts(:credit_card).id)
result = call_tool("status" => "all")
monthly = result[:totals][:active_monthly_equivalent_by_currency].fetch("USD")
assert_equal "$100.00", monthly, "income and transfers must not inflate the spend total"
end
test "a disabled family gets an error with a hint, not a raise" do
@family.update!(recurring_transactions_disabled: true)
result = call_tool
assert_match(/disabled/, result[:error])
assert result[:hint].present?
end
# Reported from live use: "What am I paying monthly for subscriptions?" answered
# "No active subscriptions found, total monthly equivalent $0" while five
# detected subscriptions sat in `suggested`. The status filter was right; the
# silence about what it filtered out was the bug.
test "an empty status-filtered result says where the matches actually are" do
create_series(name: "Crunchyroll", amount: 12, status: "suggested", bill_type: "subscription", manual: false)
create_series(name: "Huntr", amount: 40, status: "suggested", bill_type: "subscription", manual: false)
result = call_tool("bill_type" => "subscription")
assert_equal 0, result[:total_results]
assert result[:hint].present?, "an empty result must say what other statuses hold"
assert_match(/2 suggested/, result[:hint])
assert_match(/status: all/, result[:hint])
end
test "the hint counts only the requested bill type" do
create_series(name: "Crunchyroll", amount: 12, status: "suggested", bill_type: "subscription", manual: false)
create_series(name: "Rent", amount: 2000, status: "suggested", bill_type: "bill", manual: false)
result = call_tool("bill_type" => "subscription")
assert_equal 0, result[:total_results]
assert_match(/1 suggested/, result[:hint])
end
test "no hint when results are found" do
create_series(name: "Crunchyroll", amount: 12, bill_type: "subscription")
result = call_tool("bill_type" => "subscription")
assert_operator result[:total_results], :>, 0
assert_nil result[:hint]
end
test "no hint when the caller already asked for every status" do
create_series(name: "Rent", amount: 2000, status: "suggested", bill_type: "bill", manual: false)
result = call_tool("status" => "all", "bill_type" => "subscription")
assert_equal 0, result[:total_results]
assert_nil result[:hint], "status: all already saw everything, so there is nowhere else to point"
end
# strict_mode? is false and MCP bypasses provider validation, so out-of-schema
# values arrive routinely. Each one used to fail silently and differently.
test "an unknown bill_type is refused rather than dropping the filter" do
create_series(name: "Rent", amount: 2000)
create_series(name: "Netflix", amount: 12, bill_type: "subscription")
result = call_tool("bill_type" => "subscriptions")
assert_match(/not a valid bill_type/, result[:error],
"plural used to drop the filter and total rent as a subscription")
assert_nil result[:bills]
end
test "an unknown status is refused rather than silently meaning active" do
create_series(name: "Rent", amount: 2000)
result = call_tool("status" => "inactive")
assert_match(/not a valid status/, result[:error])
assert_match(/paused/, result[:hint], "the hint has to name the word that works")
end
test "an unknown payment_state is refused rather than matching nothing" do
create_series(name: "Rent", amount: 2000)
result = call_tool("payment_state" => "unpaid")
assert_match(/not a valid payment_state/, result[:error])
end
test "valid filters still work" do
create_series(name: "Netflix", amount: 12, bill_type: "subscription")
result = call_tool("bill_type" => "subscription")
assert_equal 1, result[:total_results]
end
# The hint can only redirect a status filter. Blaming status for a result
# emptied by payment_state sent the model to the suggestion queue while it
# was asking about overdue bills, and the retry it prescribed returned empty
# again with no hint at all.
test "the empty-result hint stays quiet when another filter did the emptying" do
create_series(name: "Rent", amount: 2000)
create_series(name: "Detected", amount: 9, status: "suggested", manual: false)
result = call_tool("payment_state" => "overdue")
assert_equal 0, result[:total_results]
assert_nil result[:hint], "nothing about the suggested series explains an overdue query"
end
test "the hint still fires when status alone emptied the result" do
create_series(name: "Detected", amount: 9, status: "suggested", manual: false)
result = call_tool({})
assert_equal 0, result[:total_results]
assert_match(/suggested/, result[:hint])
end
# A filter that silently answers a different question than it was asked is
# worse than an error: due_within_days: 0 used to clamp to 1.
test "an out-of-range due_within_days is rejected, not silently adjusted" do
create_series(name: "Rent", amount: 2150)
assert_match(/between 1 and 365/, call_tool("due_within_days" => 0)[:error])
assert_match(/between 1 and 365/, call_tool("due_within_days" => "soon")[:error])
assert_match(/between 1 and 365/, call_tool("due_within_days" => 400)[:error])
assert_nil call_tool("due_within_days" => 30)[:error]
end
private
def call_tool(params = {})
Assistant::Function::GetBills.new(@user).call(params)
end
def create_series(name:, amount:, merchant: nil, account: accounts(:depository), **overrides)
@family.recurring_transactions.create!({
name: name,
merchant: merchant,
account: account,
amount: amount,
dedup_scope: "#{name}-#{amount}",
currency: "USD",
expected_day_of_month: Date.current.day,
last_occurrence_date: 1.month.ago.to_date,
next_expected_date: Date.current,
status: "active",
# The generator's anchor floor (no fabricated past debt) applies to
# declared series, which is what these test rows stand in for.
manual: true,
bill_type: amount.to_d.negative? ? "income" : "bill"
}.merge(overrides))
end
end