mirror of
https://github.com/we-promise/sure.git
synced 2026-09-07 07:34:14 +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
134 lines
5.2 KiB
Ruby
134 lines
5.2 KiB
Ruby
require "test_helper"
|
|
|
|
class Assistant::Function::GetBillAuditTest < ActiveSupport::TestCase
|
|
setup do
|
|
@user = users(:family_admin)
|
|
@family = @user.family
|
|
@family.recurring_transactions.destroy_all
|
|
end
|
|
|
|
test "flags exact duplicates but never subscription tiers" do
|
|
create_series(name: "Streaming Co", amount: 15.99, dedup_scope: "a")
|
|
create_series(name: "Streaming Co", amount: 15.99, dedup_scope: "b")
|
|
# A different tier to the same merchant: same name, different amount.
|
|
create_series(name: "Streaming Co", amount: 24.99, dedup_scope: "c")
|
|
|
|
result = call_tool
|
|
|
|
duplicates = result[:possible_duplicates][:items]
|
|
assert_equal 1, duplicates.size
|
|
assert_equal 2, duplicates.sole[:bills].size,
|
|
"the 24.99 tier must not be flagged as a duplicate of the 15.99 pair"
|
|
end
|
|
|
|
test "reports price changes inside the lookback and trials about to convert" do
|
|
series = create_series(name: "Stream Co", amount: 12)
|
|
series.recurring_price_changes.create!(
|
|
effective_on: Date.current - 20, previous_amount: 10, new_amount: 12,
|
|
currency: "USD", source: "detected"
|
|
)
|
|
create_series(name: "Trial service", amount: 9, trial_ends_on: Date.current + 5)
|
|
|
|
result = call_tool
|
|
|
|
assert_equal 1, result[:price_changes][:count]
|
|
assert_in_delta 20.0, result[:price_changes][:items].sole[:percent_change]
|
|
assert_equal "Trial service", result[:upcoming_trials][:items].sole[:name]
|
|
end
|
|
|
|
test "suggested detections wait in awaiting_confirmation" do
|
|
create_series(name: "Detected sub", amount: 8, status: "suggested", manual: false)
|
|
|
|
result = call_tool
|
|
|
|
assert_equal 1, result[:awaiting_confirmation][:count]
|
|
assert_equal "Detected sub", result[:awaiting_confirmation][:items].sole[:name]
|
|
end
|
|
|
|
test "surfaces recurring charge patterns no series covers" do
|
|
3.times do |i|
|
|
accounts(:depository).entries.create!(
|
|
date: Date.current - i.months, amount: 40, currency: "USD",
|
|
name: "GYM MEMBERSHIP", entryable: Transaction.new
|
|
)
|
|
end
|
|
|
|
result = call_tool
|
|
|
|
candidate = result[:undeclared_candidates][:items].find { |item| item[:name] == "GYM MEMBERSHIP" }
|
|
assert candidate.present?, "the undeclared gym pattern must surface"
|
|
assert_operator candidate[:occurrence_count], :>=, 2
|
|
end
|
|
|
|
test "undeclared candidates never include accounts the user cannot reach" do
|
|
3.times do |i|
|
|
accounts(:investment).entries.create!(
|
|
date: Date.current - i.months, amount: 25, currency: "USD",
|
|
name: "BROKERAGE FEE", entryable: Transaction.new
|
|
)
|
|
end
|
|
|
|
admin_names = call_tool[:undeclared_candidates][:items].map { |item| item[:name] }
|
|
assert_includes admin_names, "BROKERAGE FEE",
|
|
"positive control: the admin can see the investment-account pattern"
|
|
|
|
member_result = Assistant::Function::GetBillAudit.new(users(:family_member)).call
|
|
member_names = member_result[:undeclared_candidates][:items].map { |item| item[:name] }
|
|
assert_not_includes member_names, "BROKERAGE FEE",
|
|
"a pattern on an account the member was never given must not leak"
|
|
end
|
|
|
|
test "long_overdue measures in the bill's own cycles" do
|
|
overdue_day = 45.days.ago.to_date
|
|
series = create_series(name: "Forgotten bill", amount: 60,
|
|
expected_day_of_month: overdue_day.day,
|
|
last_occurrence_date: overdue_day << 1,
|
|
next_expected_date: overdue_day)
|
|
|
|
# The cycle actually left unpaid is what makes a bill overdue. Declared
|
|
# series do not fabricate past occurrences, so the one that was forgotten
|
|
# has to exist for there to be anything to forget.
|
|
series.recurring_occurrences.destroy_all
|
|
series.recurring_occurrences.create!(
|
|
family: @family, original_due_on: overdue_day, due_on: overdue_day,
|
|
currency: "USD", expected_amount: 60, status: "scheduled"
|
|
)
|
|
|
|
result = call_tool
|
|
|
|
row = result[:long_overdue][:items].find { |item| item[:name] == "Forgotten bill" }
|
|
assert row.present?
|
|
assert_operator row[:cycles_overdue], :>=, 1
|
|
end
|
|
|
|
# Same contract as get_bills' due_within_days: out-of-range values are
|
|
# rejected, not silently clamped onto a different question.
|
|
test "an out-of-range lookback_months is rejected, not silently adjusted" do
|
|
assert_match(/between 1 and 24/, call_tool("lookback_months" => 0)[:error])
|
|
assert_match(/between 1 and 24/, call_tool("lookback_months" => "forever")[:error])
|
|
assert_match(/between 1 and 24/, call_tool("lookback_months" => 36)[:error])
|
|
assert_nil call_tool("lookback_months" => 6)[:error]
|
|
assert_nil call_tool({})[:error]
|
|
end
|
|
|
|
private
|
|
|
|
def call_tool(params = {})
|
|
Assistant::Function::GetBillAudit.new(@user).call(params)
|
|
end
|
|
|
|
def create_series(name:, amount:, dedup_scope: nil, **overrides)
|
|
@family.recurring_transactions.create!({
|
|
name: name,
|
|
account: accounts(:depository),
|
|
amount: amount,
|
|
dedup_scope: dedup_scope || "#{name}-#{amount}",
|
|
currency: "USD",
|
|
expected_day_of_month: Date.current.day,
|
|
last_occurrence_date: 1.month.ago.to_date,
|
|
next_expected_date: Date.current,
|
|
status: "active"
|
|
}.merge(overrides))
|
|
end
|
|
end
|