Files
sure/app/models/assistant/function/get_bills.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

220 lines
8.6 KiB
Ruby

class Assistant::Function::GetBills < Assistant::Function
include Assistant::Function::BillsSupport
MAX_RESULTS = 100
class << self
def name
"get_bills"
end
def description
<<~INSTRUCTIONS
Get the user's bills, subscriptions and other recurring obligations, each with its
current occurrence's payment state.
Key concepts:
- status is the series lifecycle: "active" (default), "suggested" (detected from
bank data, awaiting the user's confirmation -- not yet a real bill),
"paused" (set aside by the user), "ended" (dismissed or finished), "all".
- payment_state filters on the CURRENT occurrence instead: "overdue", "due",
"upcoming", "partial" (partly paid), "paid".
- bill_type "income" is a declared income schedule (paycheck), not an obligation.
Amounts are always positive magnitudes; bill_type carries the direction.
- totals exclude income and transfers (a transfer moves money, it is not spend),
and normalize each bill to a monthly equivalent whatever its cadence.
Use get_bill_details for one bill's full history and configuration.
INSTRUCTIONS
end
end
def strict_mode?
false
end
def params_schema
build_schema(
required: [],
properties: {
status: {
type: "string",
enum: %w[active suggested paused ended all],
description: "Series lifecycle filter. Defaults to active."
},
payment_state: {
type: "string",
enum: %w[overdue due upcoming partial paid],
description: "Filter by the current occurrence's payment state (applied after the status filter)."
},
bill_type: {
type: "string",
enum: RecurringTransaction.bill_types.keys,
description: "Filter by kind of recurring obligation."
},
search: { type: "string", description: "Substring match on the bill or merchant name." },
due_within_days: {
type: "integer", minimum: 1, maximum: 365,
description: "Only bills whose next due date falls within this many days."
}
}
)
end
def call(params = {})
return recurring_disabled_result if recurring_disabled?
# recurrence_rules included because serialization reads the schedule
# (frequency, next due date); without it every series costs one query.
scope = accessible_series.includes(:merchant, :account, :destination_account, :category,
:recurring_occurrences, :recurrence_rules)
invalid = reject_unknown_filters(params)
return invalid if invalid
scope = apply_status_filter(scope, params["status"])
if (bill_type = params["bill_type"]).present?
scope = scope.where(bill_type: bill_type)
end
if (search = params["search"].to_s).present?
pattern = "%#{ActiveRecord::Base.sanitize_sql_like(search)}%"
scope = scope.left_joins(:merchant)
.where("recurring_transactions.name ILIKE :p OR merchants.name ILIKE :p", p: pattern)
end
rows = scope.order(next_expected_date: :asc).to_a
currents = rows.index_with { |series| current_occurrence_from_loaded(series) }
preload_allocation_sums(currents.values)
# Integer(..., exception: false): MCP clients send whatever they like. A
# filter that silently answers a different question than it was asked is
# worse than an error, so out-of-range values are rejected, not adjusted.
if params["due_within_days"].present?
days = Integer(params["due_within_days"].to_s, exception: false)
unless days&.between?(1, 365)
return {
error: "due_within_days must be a whole number between 1 and 365",
hint: "Retry once with a value in that range, or omit it."
}
end
horizon = Date.current + days
rows = rows.select { |series| series.next_due_date.present? && series.next_due_date <= horizon }
end
if (state = params["payment_state"]).present?
rows = rows.select { |series| payment_state_matches?(currents[series], state) }
end
shown = rows.first(MAX_RESULTS)
{
as_of_date: Date.current.iso8601,
total_results: rows.size,
truncated: rows.size > shown.size,
family_currency: family.currency,
bills: shown.map { |series| serialize_series(series).merge(current_occurrence: serialize_occurrence(currents[series])) },
totals: totals_over(rows, currents)
}.merge(rows.empty? ? { hint: other_status_hint(params) }.compact : {})
end
private
PAYMENT_STATES = %w[overdue due upcoming partial paid].freeze
# strict_mode? is false and MCP clients bypass provider validation
# entirely, so out-of-schema values arrive here routinely. Each one used to
# fail differently and silently: an unknown status was coerced to active,
# an unknown bill_type dropped its filter so "subscriptions" (plural)
# answered with rent and a car loan totalled as subscriptions, and an
# unknown payment_state matched nothing and read as an empty family.
#
# A value the caller believes in is worth an error. Guessing at it produces
# a confident answer about the wrong bills.
def reject_unknown_filters(params)
unknown_value(params["status"], STATUS_VOCABULARY.keys + [ "all" ], "status") ||
unknown_value(params["bill_type"], RecurringTransaction.bill_types.keys, "bill_type") ||
unknown_value(params["payment_state"], PAYMENT_STATES, "payment_state")
end
def unknown_value(value, allowed, field)
return nil if value.blank? || value.to_s.in?(allowed)
{
error: "#{value} is not a valid #{field}",
hint: "Valid values: #{allowed.join(', ')}."
}
end
def apply_status_filter(scope, status)
return scope if status == "all"
scope.where(status: STATUS_VOCABULARY.fetch(status.presence || "active", STATUS_VOCABULARY.fetch("active")))
end
# An empty answer is ambiguous: it reads as "you have none of these" when it
# usually means "every one you have sits under a status this call filtered
# out". Detection parks new series in `suggested`, so asking about
# subscriptions on a family that has confirmed none answers zero results and
# a $0 total, which is worse than no answer at all. Only runs when nothing
# matched, so the normal path costs no extra query.
def other_status_hint(params)
# Only the status filter can be pointed elsewhere. When something else
# emptied the result, saying "nothing matched status active" is simply
# false, and the retry it prescribes returns empty again with no hint at
# all, because this method early-returns on status: all.
narrowing = params.values_at("payment_state", "search", "due_within_days").compact_blank
return if narrowing.any?
requested = params["status"].presence || "active"
return if requested == "all"
known = STATUS_VOCABULARY.fetch(requested, STATUS_VOCABULARY.fetch("active"))
scope = accessible_series.where.not(status: known)
if (bill_type = params["bill_type"]).presence_in(RecurringTransaction.bill_types.keys)
scope = scope.where(bill_type: bill_type)
end
elsewhere = scope.group(:status).count
return if elsewhere.empty?
summary = elsewhere.sort_by { |_status, count| -count }
.map { |status, count| "#{count} #{status}" }
.join(", ")
"Nothing matched status #{requested}, but this family has #{summary}. Call get_bills " \
"again with that status, or status: all, before telling the user they have none."
end
def payment_state_matches?(occurrence, state)
return false if occurrence.nil?
case state
when "overdue" then occurrence.overdue?
when "due" then occurrence.derived_state == :due
when "upcoming" then occurrence.derived_state == :upcoming
when "partial" then occurrence.partially_paid?
when "paid" then occurrence.paid?
else false
end
end
# Over the FULL filtered set, not the shown page, so a truncated list
# still reports honest totals.
def totals_over(rows, currents)
spend = rows.select { |series| series.active? && spend_series?(series) }
monthly = spend.group_by(&:currency).to_h do |currency, group|
total = group.sum(Money.new(0, currency)) { |series| series.monthly_equivalent_amount.abs }
[ currency, total.format ]
end
{
active_count: rows.count(&:active?),
overdue_count: rows.count { |series| currents[series]&.overdue? },
active_monthly_equivalent_by_currency: monthly
}
end
end