mirror of
https://github.com/we-promise/sure.git
synced 2026-09-05 14:51:15 +00:00
* 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
220 lines
8.6 KiB
Ruby
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
|