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

195 lines
7.6 KiB
Ruby

require "test_helper"
class Assistant::Function::GetPaycheckPlanTest < ActiveSupport::TestCase
setup do
@user = users(:family_admin)
@family = @user.family
@family.recurring_transactions.destroy_all
end
test "no declared income returns an error with a hint instead of a fabricated plan" do
# A detected inflow is income by sign but declares no payday.
@family.recurring_transactions.create!(
name: "Deposit From Checking", account: accounts(:depository), amount: -0.01,
currency: "USD", bill_type: "income", manual: false,
expected_day_of_month: Date.current.day,
last_occurrence_date: 1.month.ago.to_date, next_expected_date: Date.current,
status: "active"
)
result = call_tool
assert_equal "No declared income schedule", result[:error]
assert_includes result[:hint], "declared"
end
test "declared income produces periods with due, reserved and safe figures" do
payday = Date.current + 3
@family.recurring_transactions.create!(
name: "Paycheck", account: accounts(:depository), amount: -1200,
currency: "USD", bill_type: "income", manual: true,
expected_day_of_month: payday.day, anchor_date: payday,
last_occurrence_date: payday, next_expected_date: payday, status: "active"
)
due = payday + 4
@family.recurring_transactions.create!(
name: "Rent", account: accounts(:depository), amount: 500,
currency: "USD", bill_type: "bill", manual: true,
expected_day_of_month: due.day, anchor_date: due,
last_occurrence_date: due, next_expected_date: due, status: "active"
)
result = call_tool("periods_limit" => 2)
assert_equal @family.currency, result[:family_currency]
assert_operator result[:periods].size, :>=, 1
period_with_rent = result[:periods].find { |period| period[:bills_due].any? { |bill| bill[:name] == "Rent" } }
assert period_with_rent.present?, "the rent must land in a period as due"
assert period_with_rent[:income].present?
assert period_with_rent[:safe_after_bills].present?
end
# A lone materialized paycheck landing today yields an empty plan; the tool
# answers with its guidance instead of raising on the missing periods.
test "a lone paycheck landing today returns guidance rather than raising" do
series = @family.recurring_transactions.create!(
name: "Paycheck", account: accounts(:depository), amount: -1840,
currency: "USD", bill_type: "income", manual: true,
expected_day_of_month: Date.current.day, anchor_date: Date.current,
last_occurrence_date: Date.current, next_expected_date: Date.current, status: "active"
)
series.recurring_occurrences.where("due_on > ?", Date.current).delete_all
result = call_tool
assert_equal "No declared income schedule", result[:error]
assert result[:hint].present?
end
# Same class of bug as the get_bills one: the planner counts confirmed series
# only, which is correct, but every figure it returns is spending headroom, so
# dropping unconfirmed detections without a word makes the plan look safer
# than it is.
test "a plan that ignores unconfirmed detections says so" do
declare_income
@family.recurring_transactions.create!(
name: "Detected subscription", account: accounts(:depository), amount: 40,
currency: "USD", expected_day_of_month: Date.current.day, status: "suggested",
bill_type: "subscription", manual: false, dedup_scope: "detected-40",
last_occurrence_date: 1.month.ago.to_date, next_expected_date: Date.current
)
result = call_tool
assert result[:unconfirmed_excluded].present?,
"the plan must disclose obligations it left out"
assert_equal 1, result[:unconfirmed_excluded][:count]
assert_match(/upper bound/, result[:unconfirmed_excluded][:note])
end
test "no exclusion notice when everything is confirmed" do
declare_income
result = call_tool
assert result[:periods].present?
assert_nil result[:unconfirmed_excluded]
end
# The assistant answered "Shortfall: $150.00, Safe after bills: -$150.00" for
# a window funded entirely from money already in the bank. A bridge earns
# nothing, so income minus obligations is a deficit by construction, and the
# deficit sat right beside short: false.
test "a bridge window reports headroom against cash, not against its zero income" do
set_cash(900)
declare_future_income
declare_bridge_bill(amount: 150)
bridge = call_tool[:periods].find { |period| period[:bridge] }
assert_equal false, bridge[:short]
assert_equal "$750.00", bridge[:safe_after_bills],
"cash minus what is due out of it, never income minus obligations"
assert_equal "$900.00", bridge[:cash_on_hand]
end
test "a bridge the cash cannot cover still reports short" do
set_cash(100)
declare_future_income
declare_bridge_bill(amount: 150)
bridge = call_tool[:periods].find { |period| period[:bridge] }
assert_equal true, bridge[:short]
assert_equal "-$50.00", bridge[:safe_after_bills]
end
test "an unreadable balance omits the figure rather than inventing one" do
@family.accounts.update_all(status: "disabled")
declare_future_income
declare_bridge_bill(amount: 150)
bridge = call_tool[:periods].find { |period| period[:bridge] }
assert_equal false, bridge[:short]
assert_not bridge.key?(:safe_after_bills),
"no balance means no honest headroom figure, so the key goes rather than guessing"
assert_not bridge.key?(:cash_on_hand)
end
private
# The shared declare_income pays today, so there is no gap before the next
# payday and no bridge window at all. These tests need one.
def declare_future_income
payday = Date.current + 5
series = @family.recurring_transactions.create!(
name: "Payday", account: accounts(:depository), amount: -2000,
currency: "USD", expected_day_of_month: payday.day, status: "active",
bill_type: "income", manual: true, dedup_scope: "future-payday--2000",
last_occurrence_date: 1.month.ago.to_date, next_expected_date: payday
)
series.recurring_occurrences.destroy_all
series.recurring_occurrences.create!(
family: @family, original_due_on: payday, due_on: payday,
currency: "USD", expected_amount: 2000, status: "scheduled"
)
series
end
def set_cash(amount)
accounts = @family.accounts.where(accountable_type: "Depository")
accounts.update_all(balance: amount / accounts.count.to_d)
end
def declare_bridge_bill(amount:)
series = @family.recurring_transactions.create!(
name: "Haircut", account: accounts(:depository), amount: amount,
currency: "USD", status: "active", bill_type: "bill", manual: true,
dedup_scope: "haircut-#{amount}", expected_day_of_month: (Date.current + 1).day,
last_occurrence_date: 1.month.ago.to_date, next_expected_date: Date.current + 1
)
series.recurring_occurrences.destroy_all
series.recurring_occurrences.create!(
family: @family, original_due_on: Date.current + 1, due_on: Date.current + 1,
currency: "USD", expected_amount: amount, status: "scheduled"
)
series
end
def call_tool(params = {})
Assistant::Function::GetPaycheckPlan.new(@user).call(params)
end
def declare_income
@family.recurring_transactions.create!(
name: "Payday", account: accounts(:depository), amount: -2000,
currency: "USD", expected_day_of_month: Date.current.day, status: "active",
bill_type: "income", manual: true, dedup_scope: "payday--2000",
last_occurrence_date: 1.month.ago.to_date, next_expected_date: Date.current
)
end
end