mirror of
https://github.com/we-promise/sure.git
synced 2026-09-05 06:41:08 +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
191 lines
6.9 KiB
Ruby
191 lines
6.9 KiB
Ruby
class Assistant::Function::CreateBill < Assistant::Function
|
|
include Assistant::Function::BillsSupport
|
|
|
|
class << self
|
|
def name
|
|
"create_bill"
|
|
end
|
|
|
|
def description
|
|
<<~INSTRUCTIONS
|
|
Create a bill, subscription, installment plan or income schedule for the user.
|
|
|
|
Rules:
|
|
- amount is always a positive magnitude; set is_income true for income and the
|
|
app derives the sign. Never pass a negative amount.
|
|
- account_name must exactly match a name returned by get_accounts. Omit it to
|
|
create the bill without an account.
|
|
- category_name must exactly match a name returned by get_categories.
|
|
- first_due_on seeds the schedule: its day of month (or weekday for weekly
|
|
cadences) becomes the recurring due day.
|
|
- Transfers between accounts cannot be created here.
|
|
|
|
Confirm the details with the user before calling this.
|
|
INSTRUCTIONS
|
|
end
|
|
end
|
|
|
|
def strict_mode?
|
|
false
|
|
end
|
|
|
|
def params_schema
|
|
build_schema(
|
|
required: %w[name amount first_due_on],
|
|
properties: {
|
|
name: { type: "string", description: "What the user calls this bill." },
|
|
amount: { type: "number", minimum: 0.01, description: "Positive magnitude per occurrence." },
|
|
first_due_on: { type: "string", description: "Next due date, YYYY-MM-DD." },
|
|
frequency: {
|
|
type: "string",
|
|
enum: RecurringTransaction::FrequencyPreset::PRESETS,
|
|
description: "Cadence (default monthly)."
|
|
},
|
|
is_income: { type: "boolean", description: "True for a paycheck/income schedule." },
|
|
bill_type: {
|
|
type: "string", enum: %w[bill subscription installment],
|
|
description: "Kind of obligation (ignored for income)."
|
|
},
|
|
account_name: { type: "string", description: "Exact account name from get_accounts." },
|
|
category_name: { type: "string", description: "Exact category name from get_categories." },
|
|
autopay: { type: "boolean" },
|
|
payment_url: { type: "string", description: "Where this bill gets paid." },
|
|
notes: { type: "string" }
|
|
}
|
|
)
|
|
end
|
|
|
|
def call(params = {})
|
|
return recurring_disabled_result if recurring_disabled?
|
|
|
|
is_income, income_error = resolve_boolean(params, "is_income")
|
|
return income_error if income_error
|
|
|
|
autopay, autopay_error = resolve_boolean(params, "autopay")
|
|
return autopay_error if autopay_error
|
|
|
|
account, account_error = resolve_account(params["account_name"])
|
|
return account_error if account_error
|
|
|
|
category, category_error = resolve_category(params["category_name"])
|
|
return category_error if category_error
|
|
|
|
frequency, frequency_error = resolve_frequency(params["frequency"])
|
|
return frequency_error if frequency_error
|
|
|
|
series = RecurringTransaction::DeclaredBill.new(
|
|
family: family,
|
|
user: user,
|
|
attrs: {
|
|
name: params["name"],
|
|
amount: params["amount"],
|
|
first_due_on: params["first_due_on"],
|
|
frequency_preset: frequency,
|
|
is_income: is_income,
|
|
account_id: account&.id,
|
|
payment_url: params["payment_url"],
|
|
autopay: autopay,
|
|
notes: params["notes"]
|
|
}
|
|
).build
|
|
|
|
if series.errors.none?
|
|
series.category = category if category
|
|
if !is_income && params["bill_type"].presence_in(%w[bill subscription installment])
|
|
series.bill_type = params["bill_type"]
|
|
end
|
|
end
|
|
|
|
unless series.errors.none? && RecurringTransaction::DeclaredBill.save(series)
|
|
return {
|
|
error: series.errors.full_messages.to_sentence,
|
|
hint: "Fix the named fields and retry once."
|
|
}
|
|
end
|
|
|
|
{
|
|
created: true,
|
|
bill: serialize_series(series),
|
|
upcoming_due_dates: series.schedule.occurrences_between(Date.current, Date.current + 400).first(3).map(&:iso8601)
|
|
}
|
|
end
|
|
|
|
private
|
|
# An unrecognized cadence used to fall back to monthly. The enum word for a
|
|
# once-a-year bill is "annual", so a model offering the equally natural
|
|
# "yearly" turned a $600 premium into a $600 monthly commitment, twelve
|
|
# times the real obligation, with no indication anything had been ignored.
|
|
# A financial write is the wrong place to guess.
|
|
def resolve_frequency(value)
|
|
return [ "monthly", nil ] if value.blank?
|
|
|
|
preset = value.to_s.presence_in(RecurringTransaction::FrequencyPreset::PRESETS)
|
|
return [ preset, nil ] if preset
|
|
|
|
[ nil, {
|
|
error: "#{value} is not a frequency this app recognizes",
|
|
hint: "Use one of: #{RecurringTransaction::FrequencyPreset::PRESETS.join(', ')}. " \
|
|
"Omit it entirely for monthly."
|
|
} ]
|
|
end
|
|
|
|
# Writable, not merely visible: attaching a bill to an account changes
|
|
# what that account's owners see, so a read-only share is not a
|
|
# destination. Namesakes are refused rather than picked between: a
|
|
# financial write is the wrong place to guess.
|
|
def resolve_account(name)
|
|
return [ nil, nil ] if name.blank?
|
|
|
|
matches = Account.writable_by(user).where(name: name).limit(2).to_a
|
|
return [ matches.first, nil ] if matches.size == 1
|
|
|
|
if matches.empty?
|
|
[ nil, {
|
|
error: "No account named #{name.inspect} that you can add bills to",
|
|
hint: "Call get_accounts and retry once with the exact name of a writable account."
|
|
} ]
|
|
else
|
|
[ nil, {
|
|
error: "More than one account is named #{name.inspect}",
|
|
hint: "Ask the user which one they mean; this tool cannot pick between namesakes."
|
|
} ]
|
|
end
|
|
end
|
|
|
|
# Category namesakes cannot exist: names are unique per family
|
|
# (index_categories_on_family_id_and_name), so find_by is unambiguous.
|
|
def resolve_category(name)
|
|
return [ nil, nil ] if name.blank?
|
|
|
|
category = family.categories.find_by(name: name)
|
|
return [ category, nil ] if category
|
|
|
|
[ nil, {
|
|
error: "No category named #{name.inspect}",
|
|
hint: "Call get_categories and retry once with the exact category name."
|
|
} ]
|
|
end
|
|
|
|
# The tool caller does not enforce params_schema, so a string "true" from
|
|
# a loose MCP client would otherwise compare unequal to true and silently
|
|
# flip a paycheck into a bill. An explicit allowlist, not
|
|
# ActiveModel::Type::Boolean, because that cast reads every unrecognized
|
|
# string as true, and a financial write is the wrong place to guess.
|
|
TRUTHY_INPUTS = [ true, "true", "t", "1", 1 ].freeze
|
|
FALSY_INPUTS = [ false, "false", "f", "0", 0 ].freeze
|
|
|
|
def resolve_boolean(params, key)
|
|
value = params[key]
|
|
return [ false, nil ] if value.nil?
|
|
|
|
normalized = value.is_a?(String) ? value.strip.downcase : value
|
|
return [ true, nil ] if TRUTHY_INPUTS.include?(normalized)
|
|
return [ false, nil ] if FALSY_INPUTS.include?(normalized)
|
|
|
|
[ nil, {
|
|
error: "#{key} must be true or false",
|
|
hint: "Pass a JSON boolean, not #{value.inspect}."
|
|
} ]
|
|
end
|
|
end
|