Files
sure/test/models/assistant/function/create_bill_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

171 lines
6.7 KiB
Ruby

require "test_helper"
class Assistant::Function::CreateBillTest < ActiveSupport::TestCase
setup do
@user = users(:family_admin)
@family = @user.family
@family.recurring_transactions.destroy_all
end
test "creates a declared bill with schedule and upcoming dates" do
due = Date.current.beginning_of_month.next_month + 8.days
result = call_tool(
"name" => "City Water", "amount" => 80, "first_due_on" => due.iso8601,
"account_name" => accounts(:depository).name, "bill_type" => "subscription"
)
assert result[:created]
assert_equal "City Water", result[:bill][:name]
assert_equal "subscription", result[:bill][:bill_type]
assert_equal 3, result[:upcoming_due_dates].size
series = @family.recurring_transactions.find_by!(name: "City Water")
assert series.manual?, "an AI-created bill is a declared bill"
assert_equal accounts(:depository).id, series.account_id
assert_operator series.recurring_occurrences.count, :>, 0
end
test "income flips the stored sign, never the caller's" do
result = call_tool(
"name" => "Paycheck", "amount" => 1200,
"first_due_on" => (Date.current + 3).iso8601, "is_income" => true
)
assert result[:created]
series = @family.recurring_transactions.find_by!(name: "Paycheck")
assert series.amount.negative?, "income is stored negative"
assert_equal "income", series.bill_type
end
test "an unknown account name returns a hint instead of guessing" do
result = call_tool(
"name" => "Bill", "amount" => 10, "first_due_on" => Date.current.iso8601,
"account_name" => "No Such Account"
)
assert_match(/No account named/, result[:error])
assert_includes result[:hint], "get_accounts"
assert_equal 0, @family.recurring_transactions.count
end
test "another family's category can never be attached" do
foreign = families(:empty).categories.create!(name: "Foreign category", color: "#ff0000")
result = call_tool(
"name" => "Bill", "amount" => 10, "first_due_on" => Date.current.iso8601,
"category_name" => foreign.name
)
assert_match(/No category named/, result[:error],
"a category outside the family must resolve to nothing")
assert_equal 0, @family.recurring_transactions.count
end
test "a duplicate identity lands as a second tier via the dedup retry" do
2.times do |i|
result = call_tool(
"name" => "Streaming Co", "amount" => 15.99 + i,
"first_due_on" => Date.current.iso8601,
"account_name" => accounts(:depository).name
)
assert result[:created], "attempt #{i + 1} must save"
end
assert_equal 2, @family.recurring_transactions.where(name: "Streaming Co").count
end
test "an invalid date returns the validation message as an error" do
result = call_tool("name" => "Bill", "amount" => 10, "first_due_on" => "not-a-date")
assert result[:error].present?
assert result[:hint].present?
end
test "a non-numeric amount returns an error instead of raising" do
result = call_tool("name" => "Bill", "amount" => "abc", "first_due_on" => (Date.current + 5).iso8601)
assert result[:error].present?
assert result[:hint].present?
assert_not @family.recurring_transactions.exists?(name: "Bill")
end
# Every dedup index is keyed on account_id, and Postgres treats NULLs as
# distinct, so an account-less bill collided with nothing and an LLM retry
# doubled the family's recurring commitments.
test "an account-less bill is not duplicated by a retry" do
args = { "name" => "Netflix", "amount" => 15.99, "first_due_on" => (Date.current + 3).iso8601 }
first = call_tool(args)
second = call_tool(args)
assert first[:created]
assert second[:error].present?, "the retry must be recognized as the same bill"
assert_equal 1, @family.recurring_transactions.where(name: "Netflix").count
end
test "a different amount is still a distinct account-less bill" do
call_tool("name" => "Netflix", "amount" => 15.99, "first_due_on" => (Date.current + 3).iso8601)
second = call_tool("name" => "Netflix", "amount" => 24.99, "first_due_on" => (Date.current + 3).iso8601)
assert second[:created], "a price tier is a real second row, not a duplicate"
assert_equal 2, @family.recurring_transactions.where(name: "Netflix").count
end
# "annual" is the enum word; "yearly" is the equally natural thing a model
# says. It used to fall back to monthly, turning a $600 premium into a $600
# monthly obligation.
test "an unrecognized frequency is refused rather than made monthly" do
result = call_tool("name" => "Insurance", "amount" => 600, "first_due_on" => (Date.current + 3).iso8601, "frequency" => "yearly")
assert result[:error].present?
assert_match(/not a frequency/, result[:error])
assert_match(/annual/, result[:hint], "the hint has to name the word that works")
assert_equal 0, @family.recurring_transactions.where(name: "Insurance").count
end
test "a recognized frequency still applies" do
result = call_tool("name" => "Insurance", "amount" => 600, "first_due_on" => (Date.current + 3).iso8601, "frequency" => "annual")
assert result[:created]
series = @family.recurring_transactions.find_by(name: "Insurance")
assert_equal "annual", RecurringTransaction::FrequencyPreset.detect(series).key
end
# The tool caller does not enforce params_schema: a loose MCP client can
# send booleans as strings, and "true" == true is false in Ruby, which used
# to silently declare a paycheck as a bill.
test "boolean-ish strings cast and garbage booleans are refused" do
result = call_tool("name" => "Paycheck", "amount" => 1200,
"first_due_on" => (Date.current + 3).iso8601, "is_income" => "true")
assert result[:created]
assert @family.recurring_transactions.find_by(name: "Paycheck").amount.negative?,
"a string true must still declare income, stored negative"
garbage = call_tool("name" => "Maybe", "amount" => 10,
"first_due_on" => (Date.current + 3).iso8601, "is_income" => "yeah")
assert_match(/must be true or false/, garbage[:error])
assert_nil @family.recurring_transactions.find_by(name: "Maybe")
end
test "a read-only shared account is not a creation destination" do
member = users(:family_member)
result = Assistant::Function::CreateBill.new(member).call(
"name" => "Sneaky", "amount" => 10, "first_due_on" => (Date.current + 3).iso8601,
"account_name" => accounts(:credit_card).name
)
assert_match(/add bills to/, result[:error])
assert_nil @family.recurring_transactions.find_by(name: "Sneaky")
end
private
def call_tool(params)
Assistant::Function::CreateBill.new(@user).call(params)
end
end