mirror of
https://github.com/we-promise/sure.git
synced 2026-09-06 23:24:21 +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
159 lines
5.8 KiB
Ruby
159 lines
5.8 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
# Shared plumbing for the Bills tools. The Bills pages sit behind a per-family
|
|
# feature gate and a per-user account-access scope; tool calls never pass
|
|
# through those controllers, so every tool re-checks both here.
|
|
#
|
|
# Status vocabulary: these tools speak the UI's lifecycle words, not raw
|
|
# storage. Nothing in the app writes the stored value "paused" (the Pause
|
|
# button stores "inactive"), so "paused" here means the inactive+paused set,
|
|
# exactly as the All-bills filter treats it.
|
|
module Assistant::Function::BillsSupport
|
|
# Mirrors BillsController::LIFECYCLE_STATUSES with the two review states.
|
|
STATUS_VOCABULARY = {
|
|
"active" => %w[active],
|
|
"suggested" => %w[suggested],
|
|
"paused" => %w[inactive paused],
|
|
"ended" => %w[ended]
|
|
}.freeze
|
|
|
|
private
|
|
def recurring_disabled?
|
|
family.recurring_transactions_disabled?
|
|
end
|
|
|
|
def recurring_disabled_result
|
|
{
|
|
error: "Bills & recurring transactions are disabled for this family",
|
|
hint: "Do not retry. Tell the user this feature is switched off under Settings -> Recurring transactions, and answer from transaction data instead."
|
|
}
|
|
end
|
|
|
|
def accessible_series
|
|
family.recurring_transactions.accessible_by(user)
|
|
end
|
|
|
|
def find_series(id)
|
|
unless valid_uuid?(id)
|
|
return [ nil, {
|
|
error: "bill_id is not a valid id",
|
|
hint: "Pass the exact id returned by get_bills."
|
|
} ]
|
|
end
|
|
|
|
# find (not find_by): a missing or foreign id raises RecordNotFound,
|
|
# which the tool caller converts into an error+hint result.
|
|
[ accessible_series.find(id), nil ]
|
|
end
|
|
|
|
# The write tools' lookup. Reading a shared bill is fine; changing it is
|
|
# not: sharing is per account, so a read-only share must not mutate the
|
|
# series, exactly as RecurringTransactionsController#ensure_series_writable
|
|
# enforces for the pages. Accountless series carry no account gate. The
|
|
# bill is already visible to this user, so naming the reason leaks nothing.
|
|
def find_writable_series(id)
|
|
series, error = find_series(id)
|
|
return [ nil, error ] if error
|
|
|
|
if series.account_id.present? && !Account.writable_by(user).where(id: series.account_id).exists?
|
|
return [ nil, {
|
|
error: "#{series.display_name} is on an account shared with you read-only",
|
|
hint: "You can read this bill but not change it. Do not retry."
|
|
} ]
|
|
end
|
|
|
|
[ series, nil ]
|
|
end
|
|
|
|
def display_status(series)
|
|
case series.status
|
|
when "inactive", "paused" then "paused"
|
|
else series.status
|
|
end
|
|
end
|
|
|
|
def serialize_series(series)
|
|
detection = RecurringTransaction::FrequencyPreset.detect(series)
|
|
|
|
{
|
|
id: series.id,
|
|
name: series.display_name,
|
|
bill_type: series.bill_type,
|
|
status: display_status(series),
|
|
amount: series.amount_money.abs.format,
|
|
currency: series.currency,
|
|
frequency: detection.key || "custom",
|
|
next_due_date: series.next_due_date&.iso8601,
|
|
autopay: series.autopay,
|
|
detected_automatically: !series.manual,
|
|
category: series.category&.name,
|
|
account: account_ref(series.account),
|
|
destination_account: account_ref(series.destination_account),
|
|
monthly_equivalent: series.monthly_equivalent_amount&.abs&.format,
|
|
payment_url: series.payment_url
|
|
}.compact
|
|
end
|
|
|
|
def serialize_occurrence(occurrence)
|
|
return nil if occurrence.nil?
|
|
|
|
# Read once, derive locally: resolved_expected_amount is unmemoized and
|
|
# can cost two queries per call under the `last` amount strategy.
|
|
expected = occurrence.resolved_expected_amount
|
|
paid = occurrence.confirmed_allocated
|
|
|
|
{
|
|
due_on: occurrence.due_on.iso8601,
|
|
effective_due_on: occurrence.effective_due_on.iso8601,
|
|
state: occurrence.derived_state.to_s,
|
|
expected: Money.new(expected, occurrence.currency).format,
|
|
paid: Money.new(paid, occurrence.currency).format,
|
|
remaining: Money.new([ expected - paid, 0 ].max, occurrence.currency).format,
|
|
partially_paid: occurrence.scheduled? && paid.positive? && paid < expected
|
|
}
|
|
end
|
|
|
|
def account_ref(account)
|
|
return nil if account.nil?
|
|
|
|
{ id: account.id, name: account.name }
|
|
end
|
|
|
|
# One grouped SUM for the given occurrences, injected through the same
|
|
# cache the list views use, so serialization issues no per-row queries.
|
|
def preload_allocation_sums(occurrences)
|
|
rows = occurrences.compact
|
|
return if rows.empty?
|
|
|
|
sums = RecurringAllocation.confirmed
|
|
.where(recurring_occurrence_id: rows.map(&:id))
|
|
.group(:recurring_occurrence_id)
|
|
.sum(:allocated_amount)
|
|
|
|
rows.each { |occurrence| occurrence.cached_confirmed_allocated = sums[occurrence.id] || 0 }
|
|
end
|
|
|
|
# current_occurrence resolved from the preloaded association instead of
|
|
# the model's per-series queries (100 series would mean 100 queries).
|
|
def current_occurrence_from_loaded(series)
|
|
occurrences = series.recurring_occurrences
|
|
occurrences.select(&:scheduled?).min_by(&:due_on) ||
|
|
occurrences.max_by(&:due_on)
|
|
end
|
|
|
|
# Spend commitments only: income is not spend and a transfer moves money
|
|
# rather than spending it, so both stay out of any totals row.
|
|
def spend_series?(series)
|
|
!%w[income transfer].include?(series.bill_type)
|
|
end
|
|
|
|
# How far a price moved, as a signed percent of what it was. nil when
|
|
# there is no previous amount to compare against.
|
|
def percent_change(previous_amount, new_amount)
|
|
previous = previous_amount.abs
|
|
return nil if previous.zero?
|
|
|
|
(((new_amount.abs - previous) / previous) * 100).round(1).to_f
|
|
end
|
|
end
|