mirror of
https://github.com/we-promise/sure.git
synced 2026-09-07 15:44:21 +00:00
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
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
require "test_helper"
|
||||
|
||||
class Assistant::Function::BillsToolsSchemaTest < ActiveSupport::TestCase
|
||||
BILLS_TOOLS = [
|
||||
Assistant::Function::GetBills,
|
||||
Assistant::Function::GetBillDetails,
|
||||
Assistant::Function::GetPaycheckPlan,
|
||||
Assistant::Function::GetBillAudit,
|
||||
Assistant::Function::CreateBill,
|
||||
Assistant::Function::UpdateBill,
|
||||
Assistant::Function::RecordBillPayment
|
||||
].freeze
|
||||
|
||||
# Strict function calling requires every declared property in `required`;
|
||||
# a strict tool with an optional property is rejected wholesale by strict
|
||||
# providers. Scoped to the bills tools: the pre-existing tools' strictness
|
||||
# is reworked by the assistant-upgrade PR (#3064), not here.
|
||||
test "strict bills tools declare every property as required" do
|
||||
each_definition do |name, definition|
|
||||
next unless definition[:strict]
|
||||
|
||||
schema = definition[:params_schema]
|
||||
property_keys = schema[:properties].keys.map(&:to_s)
|
||||
required_keys = Array(schema[:required]).map(&:to_s)
|
||||
|
||||
assert_equal property_keys.sort, required_keys.sort,
|
||||
"#{name} is strict but properties #{property_keys - required_keys} are not required"
|
||||
end
|
||||
end
|
||||
|
||||
# Data-driven enums put per-family values into the schema, which breaks
|
||||
# strict validators the moment a family has none (the empty-enum incident)
|
||||
# and bloats every request. Bills tools use static enums only.
|
||||
test "bills tool enums are static and never empty" do
|
||||
each_definition do |name, definition|
|
||||
definition[:params_schema][:properties].each do |key, property|
|
||||
enums = [ property[:enum], property.dig(:items, :enum) ].compact
|
||||
enums.each do |values|
|
||||
assert values.any?, "#{name}.#{key} declares an empty enum"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# The registry is shared with the public /mcp endpoint: being listed makes a
|
||||
# tool callable by external agents. Bills is a preview feature, so its tools
|
||||
# ride the per-user preview flag -- present for an opted-in user, absent from
|
||||
# the default set. This test documents both halves.
|
||||
test "the bills tools are preview-gated in the registry" do
|
||||
user_with_preview = users(:family_admin)
|
||||
user_with_preview.update!(
|
||||
preferences: (user_with_preview.preferences || {}).merge("preview_features_enabled" => true)
|
||||
)
|
||||
|
||||
preview_classes = Assistant.function_classes(user_with_preview)
|
||||
default_classes = Assistant.function_classes(nil)
|
||||
|
||||
BILLS_TOOLS.each do |tool|
|
||||
assert_includes preview_classes, tool
|
||||
assert_not_includes default_classes, tool
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def each_definition
|
||||
user = users(:family_admin)
|
||||
|
||||
BILLS_TOOLS.each do |fn_class|
|
||||
fn = fn_class.new(user)
|
||||
yield fn.name, fn.to_definition
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,170 @@
|
||||
require "test_helper"
|
||||
|
||||
class Assistant::Function::CreateBillTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@user = users(:family_admin)
|
||||
@family = @user.family
|
||||
@family.recurring_transactions.destroy_all
|
||||
end
|
||||
|
||||
test "creates a declared bill with schedule and upcoming dates" do
|
||||
due = Date.current.beginning_of_month.next_month + 8.days
|
||||
|
||||
result = call_tool(
|
||||
"name" => "City Water", "amount" => 80, "first_due_on" => due.iso8601,
|
||||
"account_name" => accounts(:depository).name, "bill_type" => "subscription"
|
||||
)
|
||||
|
||||
assert result[:created]
|
||||
assert_equal "City Water", result[:bill][:name]
|
||||
assert_equal "subscription", result[:bill][:bill_type]
|
||||
assert_equal 3, result[:upcoming_due_dates].size
|
||||
|
||||
series = @family.recurring_transactions.find_by!(name: "City Water")
|
||||
assert series.manual?, "an AI-created bill is a declared bill"
|
||||
assert_equal accounts(:depository).id, series.account_id
|
||||
assert_operator series.recurring_occurrences.count, :>, 0
|
||||
end
|
||||
|
||||
test "income flips the stored sign, never the caller's" do
|
||||
result = call_tool(
|
||||
"name" => "Paycheck", "amount" => 1200,
|
||||
"first_due_on" => (Date.current + 3).iso8601, "is_income" => true
|
||||
)
|
||||
|
||||
assert result[:created]
|
||||
series = @family.recurring_transactions.find_by!(name: "Paycheck")
|
||||
assert series.amount.negative?, "income is stored negative"
|
||||
assert_equal "income", series.bill_type
|
||||
end
|
||||
|
||||
test "an unknown account name returns a hint instead of guessing" do
|
||||
result = call_tool(
|
||||
"name" => "Bill", "amount" => 10, "first_due_on" => Date.current.iso8601,
|
||||
"account_name" => "No Such Account"
|
||||
)
|
||||
|
||||
assert_match(/No account named/, result[:error])
|
||||
assert_includes result[:hint], "get_accounts"
|
||||
assert_equal 0, @family.recurring_transactions.count
|
||||
end
|
||||
|
||||
test "another family's category can never be attached" do
|
||||
foreign = families(:empty).categories.create!(name: "Foreign category", color: "#ff0000")
|
||||
|
||||
result = call_tool(
|
||||
"name" => "Bill", "amount" => 10, "first_due_on" => Date.current.iso8601,
|
||||
"category_name" => foreign.name
|
||||
)
|
||||
|
||||
assert_match(/No category named/, result[:error],
|
||||
"a category outside the family must resolve to nothing")
|
||||
assert_equal 0, @family.recurring_transactions.count
|
||||
end
|
||||
|
||||
test "a duplicate identity lands as a second tier via the dedup retry" do
|
||||
2.times do |i|
|
||||
result = call_tool(
|
||||
"name" => "Streaming Co", "amount" => 15.99 + i,
|
||||
"first_due_on" => Date.current.iso8601,
|
||||
"account_name" => accounts(:depository).name
|
||||
)
|
||||
assert result[:created], "attempt #{i + 1} must save"
|
||||
end
|
||||
|
||||
assert_equal 2, @family.recurring_transactions.where(name: "Streaming Co").count
|
||||
end
|
||||
|
||||
test "an invalid date returns the validation message as an error" do
|
||||
result = call_tool("name" => "Bill", "amount" => 10, "first_due_on" => "not-a-date")
|
||||
|
||||
assert result[:error].present?
|
||||
assert result[:hint].present?
|
||||
end
|
||||
|
||||
test "a non-numeric amount returns an error instead of raising" do
|
||||
result = call_tool("name" => "Bill", "amount" => "abc", "first_due_on" => (Date.current + 5).iso8601)
|
||||
|
||||
assert result[:error].present?
|
||||
assert result[:hint].present?
|
||||
assert_not @family.recurring_transactions.exists?(name: "Bill")
|
||||
end
|
||||
|
||||
|
||||
# Every dedup index is keyed on account_id, and Postgres treats NULLs as
|
||||
# distinct, so an account-less bill collided with nothing and an LLM retry
|
||||
# doubled the family's recurring commitments.
|
||||
test "an account-less bill is not duplicated by a retry" do
|
||||
args = { "name" => "Netflix", "amount" => 15.99, "first_due_on" => (Date.current + 3).iso8601 }
|
||||
|
||||
first = call_tool(args)
|
||||
second = call_tool(args)
|
||||
|
||||
assert first[:created]
|
||||
assert second[:error].present?, "the retry must be recognized as the same bill"
|
||||
assert_equal 1, @family.recurring_transactions.where(name: "Netflix").count
|
||||
end
|
||||
|
||||
test "a different amount is still a distinct account-less bill" do
|
||||
call_tool("name" => "Netflix", "amount" => 15.99, "first_due_on" => (Date.current + 3).iso8601)
|
||||
second = call_tool("name" => "Netflix", "amount" => 24.99, "first_due_on" => (Date.current + 3).iso8601)
|
||||
|
||||
assert second[:created], "a price tier is a real second row, not a duplicate"
|
||||
assert_equal 2, @family.recurring_transactions.where(name: "Netflix").count
|
||||
end
|
||||
|
||||
# "annual" is the enum word; "yearly" is the equally natural thing a model
|
||||
# says. It used to fall back to monthly, turning a $600 premium into a $600
|
||||
# monthly obligation.
|
||||
test "an unrecognized frequency is refused rather than made monthly" do
|
||||
result = call_tool("name" => "Insurance", "amount" => 600, "first_due_on" => (Date.current + 3).iso8601, "frequency" => "yearly")
|
||||
|
||||
assert result[:error].present?
|
||||
assert_match(/not a frequency/, result[:error])
|
||||
assert_match(/annual/, result[:hint], "the hint has to name the word that works")
|
||||
assert_equal 0, @family.recurring_transactions.where(name: "Insurance").count
|
||||
end
|
||||
|
||||
test "a recognized frequency still applies" do
|
||||
result = call_tool("name" => "Insurance", "amount" => 600, "first_due_on" => (Date.current + 3).iso8601, "frequency" => "annual")
|
||||
|
||||
assert result[:created]
|
||||
series = @family.recurring_transactions.find_by(name: "Insurance")
|
||||
assert_equal "annual", RecurringTransaction::FrequencyPreset.detect(series).key
|
||||
end
|
||||
|
||||
# The tool caller does not enforce params_schema: a loose MCP client can
|
||||
# send booleans as strings, and "true" == true is false in Ruby, which used
|
||||
# to silently declare a paycheck as a bill.
|
||||
test "boolean-ish strings cast and garbage booleans are refused" do
|
||||
result = call_tool("name" => "Paycheck", "amount" => 1200,
|
||||
"first_due_on" => (Date.current + 3).iso8601, "is_income" => "true")
|
||||
|
||||
assert result[:created]
|
||||
assert @family.recurring_transactions.find_by(name: "Paycheck").amount.negative?,
|
||||
"a string true must still declare income, stored negative"
|
||||
|
||||
garbage = call_tool("name" => "Maybe", "amount" => 10,
|
||||
"first_due_on" => (Date.current + 3).iso8601, "is_income" => "yeah")
|
||||
assert_match(/must be true or false/, garbage[:error])
|
||||
assert_nil @family.recurring_transactions.find_by(name: "Maybe")
|
||||
end
|
||||
|
||||
test "a read-only shared account is not a creation destination" do
|
||||
member = users(:family_member)
|
||||
|
||||
result = Assistant::Function::CreateBill.new(member).call(
|
||||
"name" => "Sneaky", "amount" => 10, "first_due_on" => (Date.current + 3).iso8601,
|
||||
"account_name" => accounts(:credit_card).name
|
||||
)
|
||||
|
||||
assert_match(/add bills to/, result[:error])
|
||||
assert_nil @family.recurring_transactions.find_by(name: "Sneaky")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def call_tool(params)
|
||||
Assistant::Function::CreateBill.new(@user).call(params)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,133 @@
|
||||
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
|
||||
@@ -0,0 +1,146 @@
|
||||
require "test_helper"
|
||||
|
||||
class Assistant::Function::GetBillDetailsTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@user = users(:family_admin)
|
||||
@family = @user.family
|
||||
@family.recurring_transactions.destroy_all
|
||||
end
|
||||
|
||||
test "analytics come from confirmed payments on settled occurrences, never estimates" do
|
||||
series = create_series(name: "CITY WATER", amount: 200)
|
||||
paid = series.recurring_occurrences.order(:due_on).first
|
||||
paid.allocations.create!(allocated_amount: 100, currency: "USD", source: "user_created")
|
||||
paid.close!("paid", source: "user")
|
||||
# An open occurrence still expecting the estimated 200 must not move the average.
|
||||
|
||||
result = call_tool(series.id)
|
||||
|
||||
assert_equal "$100.00", result[:analytics][:average_paid],
|
||||
"the open occurrence's 200 estimate must not be averaged in"
|
||||
assert_equal "$100.00", result[:analytics][:paid_this_year]
|
||||
end
|
||||
|
||||
test "analytics are null before anything was paid" do
|
||||
series = create_series(name: "Fresh bill", amount: 50)
|
||||
|
||||
result = call_tool(series.id)
|
||||
|
||||
assert_nil result[:analytics]
|
||||
end
|
||||
|
||||
test "history rows carry their payments with the transaction name" do
|
||||
series = create_series(name: "CITY WATER", amount: 80)
|
||||
entry = accounts(:depository).entries.create!(
|
||||
date: Date.current, amount: 80, currency: "USD", name: "CITY WATER PMT",
|
||||
entryable: Transaction.new
|
||||
)
|
||||
occurrence = series.recurring_occurrences.order(:due_on).first
|
||||
occurrence.allocations.create!(entry: entry, allocated_amount: 80, currency: "USD", source: "user_confirmed")
|
||||
occurrence.close!("paid", source: "user")
|
||||
|
||||
result = call_tool(series.id)
|
||||
|
||||
payment = result[:history].sole[:payments].sole
|
||||
assert_equal "CITY WATER PMT", payment[:transaction_name]
|
||||
assert_equal "user_confirmed", payment[:source]
|
||||
end
|
||||
|
||||
test "price changes serialize with the percent" do
|
||||
series = create_series(name: "Stream Co", amount: 12)
|
||||
series.recurring_price_changes.create!(
|
||||
effective_on: Date.current - 10, previous_amount: 10, new_amount: 12,
|
||||
currency: "USD", source: "detected"
|
||||
)
|
||||
|
||||
result = call_tool(series.id)
|
||||
|
||||
change = result[:price_changes].sole
|
||||
assert_equal "$10.00", change[:previous_amount]
|
||||
assert_equal "$12.00", change[:new_amount]
|
||||
assert_in_delta 20.0, change[:percent_change]
|
||||
end
|
||||
|
||||
test "an invalid uuid returns an error with a hint" do
|
||||
result = call_tool("not-a-uuid")
|
||||
|
||||
assert result[:error].present?
|
||||
assert_includes result[:hint], "get_bills"
|
||||
end
|
||||
|
||||
test "another family's bill raises RecordNotFound for the caller to convert" do
|
||||
other = families(:empty).recurring_transactions.create!(
|
||||
name: "Foreign bill", amount: 10, currency: "USD",
|
||||
expected_day_of_month: 1, last_occurrence_date: Date.current,
|
||||
next_expected_date: 1.month.from_now.to_date, status: "active"
|
||||
)
|
||||
|
||||
assert_raises(ActiveRecord::RecordNotFound) { call_tool(other.id) }
|
||||
end
|
||||
|
||||
|
||||
# annualized_cost was the declared amount times cadence, sitting inside a
|
||||
# block documented as payments-only, so a bill declared at 100 whose every
|
||||
# payment was 50 reported a 50 average and a 1,200 year in the same hash.
|
||||
test "the run rate follows the payments, and the declared figure says so" do
|
||||
series = paid_series(declared: 100, paid: 50, cycles: 3)
|
||||
|
||||
analytics = call_tool(series.id)[:analytics]
|
||||
|
||||
assert_equal "$50.00", analytics[:average_paid]
|
||||
assert_equal "$600.00", analytics[:annualized_cost], "twelve payments of what is actually paid"
|
||||
assert_equal "$1,200.00", analytics[:annualized_declared], "the declared figure keeps its own name"
|
||||
end
|
||||
|
||||
test "history discloses how much of it was withheld" do
|
||||
series = paid_series(declared: 10, paid: 10, cycles: 15)
|
||||
|
||||
result = call_tool(series.id)
|
||||
|
||||
assert_equal 12, result[:history].size
|
||||
assert_equal 15, result[:history_window][:count]
|
||||
assert result[:history_window][:truncated], "summing 12 rows is not the lifetime total"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def call_tool(bill_id)
|
||||
Assistant::Function::GetBillDetails.new(@user).call({ "bill_id" => bill_id.to_s })
|
||||
end
|
||||
|
||||
# A series whose declared amount and actual payments deliberately disagree,
|
||||
# with `cycles` settled cycles behind it.
|
||||
def paid_series(declared:, paid:, cycles:)
|
||||
series = create_series(name: "Gym #{declared}-#{paid}-#{cycles}", amount: declared)
|
||||
series.recurring_occurrences.destroy_all
|
||||
|
||||
cycles.times do |index|
|
||||
due = Date.current - ((index + 1) * 30)
|
||||
occurrence = series.recurring_occurrences.create!(
|
||||
family: @family, original_due_on: due, due_on: due,
|
||||
# The series declares one figure; each cycle actually costs another,
|
||||
# which is the whole point of separating run rate from declared.
|
||||
currency: "USD", expected_amount: paid, status: "scheduled"
|
||||
)
|
||||
RecurringTransaction::Allocator.new(occurrence).allocate!(
|
||||
amount: paid, paid_on: due, source: "user_created"
|
||||
)
|
||||
end
|
||||
|
||||
series.reload
|
||||
end
|
||||
|
||||
def create_series(name:, amount:, **overrides)
|
||||
@family.recurring_transactions.create!({
|
||||
name: name,
|
||||
account: accounts(:depository),
|
||||
amount: amount,
|
||||
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
|
||||
@@ -0,0 +1,240 @@
|
||||
require "test_helper"
|
||||
|
||||
class Assistant::Function::GetBillsTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@user = users(:family_admin)
|
||||
@family = @user.family
|
||||
@family.recurring_transactions.destroy_all
|
||||
end
|
||||
|
||||
test "defaults to active bills and hides review states" do
|
||||
create_series(name: "Active bill", amount: 50)
|
||||
create_series(name: "Suggested detection", amount: 20, status: "suggested", manual: false)
|
||||
create_series(name: "Dismissed", amount: 10, status: "ended")
|
||||
create_series(name: "Paused bill", amount: 30, status: "inactive")
|
||||
|
||||
result = call_tool
|
||||
|
||||
names = result[:bills].map { |bill| bill[:name] }
|
||||
assert_includes names, "Active bill"
|
||||
assert_not_includes names, "Suggested detection"
|
||||
assert_not_includes names, "Dismissed"
|
||||
assert_not_includes names, "Paused bill"
|
||||
end
|
||||
|
||||
test "the paused filter speaks the UI vocabulary over the stored value" do
|
||||
create_series(name: "Paused bill", amount: 30, status: "inactive")
|
||||
|
||||
result = call_tool("status" => "paused")
|
||||
|
||||
row = result[:bills].sole
|
||||
assert_equal "Paused bill", row[:name]
|
||||
assert_equal "paused", row[:status], "stored 'inactive' must serialize as the UI's word"
|
||||
end
|
||||
|
||||
test "payment_state filters on the current occurrence" do
|
||||
travel_to Date.current do
|
||||
overdue_day = 10.days.ago.to_date
|
||||
create_series(name: "Late bill", amount: 75,
|
||||
expected_day_of_month: overdue_day.day,
|
||||
anchor_date: overdue_day,
|
||||
last_occurrence_date: 2.months.ago.to_date,
|
||||
next_expected_date: overdue_day)
|
||||
# Anchored at its own future due date: the generator floors at anchor,
|
||||
# so no past-cycle row exists to read as overdue.
|
||||
future_day = Date.current + 10
|
||||
create_series(name: "Future bill", amount: 20,
|
||||
expected_day_of_month: future_day.day,
|
||||
anchor_date: future_day,
|
||||
last_occurrence_date: future_day - 1.month,
|
||||
next_expected_date: future_day)
|
||||
|
||||
result = call_tool("payment_state" => "overdue")
|
||||
|
||||
assert_equal [ "Late bill" ], result[:bills].map { |bill| bill[:name] }
|
||||
end
|
||||
end
|
||||
|
||||
test "search matches the merchant behind a nameless series" do
|
||||
create_series(name: nil, merchant: merchants(:netflix), amount: 15.99)
|
||||
create_series(name: "Water", amount: 80)
|
||||
|
||||
result = call_tool("search" => merchants(:netflix).name)
|
||||
|
||||
assert_equal [ merchants(:netflix).name ], result[:bills].map { |bill| bill[:name] }
|
||||
end
|
||||
|
||||
test "a member only sees bills on accounts they were given" do
|
||||
create_series(name: "Visible bill", amount: 10)
|
||||
create_series(name: "Hidden brokerage bill", amount: 99, account: accounts(:investment))
|
||||
|
||||
result = Assistant::Function::GetBills.new(users(:family_member)).call({})
|
||||
|
||||
names = result[:bills].map { |bill| bill[:name] }
|
||||
assert_includes names, "Visible bill"
|
||||
assert_not_includes names, "Hidden brokerage bill"
|
||||
end
|
||||
|
||||
test "totals exclude income and transfers and count the overdue" do
|
||||
create_series(name: "Real bill", amount: 100)
|
||||
create_series(name: "Paycheck", amount: -2000, bill_type: "income")
|
||||
create_series(name: "Card payment", amount: 300, bill_type: "transfer",
|
||||
destination_account_id: accounts(:credit_card).id)
|
||||
|
||||
result = call_tool("status" => "all")
|
||||
|
||||
monthly = result[:totals][:active_monthly_equivalent_by_currency].fetch("USD")
|
||||
assert_equal "$100.00", monthly, "income and transfers must not inflate the spend total"
|
||||
end
|
||||
|
||||
test "a disabled family gets an error with a hint, not a raise" do
|
||||
@family.update!(recurring_transactions_disabled: true)
|
||||
|
||||
result = call_tool
|
||||
|
||||
assert_match(/disabled/, result[:error])
|
||||
assert result[:hint].present?
|
||||
end
|
||||
|
||||
# Reported from live use: "What am I paying monthly for subscriptions?" answered
|
||||
# "No active subscriptions found, total monthly equivalent $0" while five
|
||||
# detected subscriptions sat in `suggested`. The status filter was right; the
|
||||
# silence about what it filtered out was the bug.
|
||||
test "an empty status-filtered result says where the matches actually are" do
|
||||
create_series(name: "Crunchyroll", amount: 12, status: "suggested", bill_type: "subscription", manual: false)
|
||||
create_series(name: "Huntr", amount: 40, status: "suggested", bill_type: "subscription", manual: false)
|
||||
|
||||
result = call_tool("bill_type" => "subscription")
|
||||
|
||||
assert_equal 0, result[:total_results]
|
||||
assert result[:hint].present?, "an empty result must say what other statuses hold"
|
||||
assert_match(/2 suggested/, result[:hint])
|
||||
assert_match(/status: all/, result[:hint])
|
||||
end
|
||||
|
||||
test "the hint counts only the requested bill type" do
|
||||
create_series(name: "Crunchyroll", amount: 12, status: "suggested", bill_type: "subscription", manual: false)
|
||||
create_series(name: "Rent", amount: 2000, status: "suggested", bill_type: "bill", manual: false)
|
||||
|
||||
result = call_tool("bill_type" => "subscription")
|
||||
|
||||
assert_equal 0, result[:total_results]
|
||||
assert_match(/1 suggested/, result[:hint])
|
||||
end
|
||||
|
||||
test "no hint when results are found" do
|
||||
create_series(name: "Crunchyroll", amount: 12, bill_type: "subscription")
|
||||
|
||||
result = call_tool("bill_type" => "subscription")
|
||||
|
||||
assert_operator result[:total_results], :>, 0
|
||||
assert_nil result[:hint]
|
||||
end
|
||||
|
||||
test "no hint when the caller already asked for every status" do
|
||||
create_series(name: "Rent", amount: 2000, status: "suggested", bill_type: "bill", manual: false)
|
||||
|
||||
result = call_tool("status" => "all", "bill_type" => "subscription")
|
||||
|
||||
assert_equal 0, result[:total_results]
|
||||
assert_nil result[:hint], "status: all already saw everything, so there is nowhere else to point"
|
||||
end
|
||||
|
||||
|
||||
# strict_mode? is false and MCP bypasses provider validation, so out-of-schema
|
||||
# values arrive routinely. Each one used to fail silently and differently.
|
||||
test "an unknown bill_type is refused rather than dropping the filter" do
|
||||
create_series(name: "Rent", amount: 2000)
|
||||
create_series(name: "Netflix", amount: 12, bill_type: "subscription")
|
||||
|
||||
result = call_tool("bill_type" => "subscriptions")
|
||||
|
||||
assert_match(/not a valid bill_type/, result[:error],
|
||||
"plural used to drop the filter and total rent as a subscription")
|
||||
assert_nil result[:bills]
|
||||
end
|
||||
|
||||
test "an unknown status is refused rather than silently meaning active" do
|
||||
create_series(name: "Rent", amount: 2000)
|
||||
|
||||
result = call_tool("status" => "inactive")
|
||||
|
||||
assert_match(/not a valid status/, result[:error])
|
||||
assert_match(/paused/, result[:hint], "the hint has to name the word that works")
|
||||
end
|
||||
|
||||
test "an unknown payment_state is refused rather than matching nothing" do
|
||||
create_series(name: "Rent", amount: 2000)
|
||||
|
||||
result = call_tool("payment_state" => "unpaid")
|
||||
|
||||
assert_match(/not a valid payment_state/, result[:error])
|
||||
end
|
||||
|
||||
test "valid filters still work" do
|
||||
create_series(name: "Netflix", amount: 12, bill_type: "subscription")
|
||||
|
||||
result = call_tool("bill_type" => "subscription")
|
||||
|
||||
assert_equal 1, result[:total_results]
|
||||
end
|
||||
|
||||
# The hint can only redirect a status filter. Blaming status for a result
|
||||
# emptied by payment_state sent the model to the suggestion queue while it
|
||||
# was asking about overdue bills, and the retry it prescribed returned empty
|
||||
# again with no hint at all.
|
||||
test "the empty-result hint stays quiet when another filter did the emptying" do
|
||||
create_series(name: "Rent", amount: 2000)
|
||||
create_series(name: "Detected", amount: 9, status: "suggested", manual: false)
|
||||
|
||||
result = call_tool("payment_state" => "overdue")
|
||||
|
||||
assert_equal 0, result[:total_results]
|
||||
assert_nil result[:hint], "nothing about the suggested series explains an overdue query"
|
||||
end
|
||||
|
||||
test "the hint still fires when status alone emptied the result" do
|
||||
create_series(name: "Detected", amount: 9, status: "suggested", manual: false)
|
||||
|
||||
result = call_tool({})
|
||||
|
||||
assert_equal 0, result[:total_results]
|
||||
assert_match(/suggested/, result[:hint])
|
||||
end
|
||||
|
||||
# A filter that silently answers a different question than it was asked is
|
||||
# worse than an error: due_within_days: 0 used to clamp to 1.
|
||||
test "an out-of-range due_within_days is rejected, not silently adjusted" do
|
||||
create_series(name: "Rent", amount: 2150)
|
||||
|
||||
assert_match(/between 1 and 365/, call_tool("due_within_days" => 0)[:error])
|
||||
assert_match(/between 1 and 365/, call_tool("due_within_days" => "soon")[:error])
|
||||
assert_match(/between 1 and 365/, call_tool("due_within_days" => 400)[:error])
|
||||
assert_nil call_tool("due_within_days" => 30)[:error]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def call_tool(params = {})
|
||||
Assistant::Function::GetBills.new(@user).call(params)
|
||||
end
|
||||
|
||||
def create_series(name:, amount:, merchant: nil, account: accounts(:depository), **overrides)
|
||||
@family.recurring_transactions.create!({
|
||||
name: name,
|
||||
merchant: merchant,
|
||||
account: account,
|
||||
amount: amount,
|
||||
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",
|
||||
# The generator's anchor floor (no fabricated past debt) applies to
|
||||
# declared series, which is what these test rows stand in for.
|
||||
manual: true,
|
||||
bill_type: amount.to_d.negative? ? "income" : "bill"
|
||||
}.merge(overrides))
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,194 @@
|
||||
require "test_helper"
|
||||
|
||||
class Assistant::Function::GetPaycheckPlanTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@user = users(:family_admin)
|
||||
@family = @user.family
|
||||
@family.recurring_transactions.destroy_all
|
||||
end
|
||||
|
||||
test "no declared income returns an error with a hint instead of a fabricated plan" do
|
||||
# A detected inflow is income by sign but declares no payday.
|
||||
@family.recurring_transactions.create!(
|
||||
name: "Deposit From Checking", account: accounts(:depository), amount: -0.01,
|
||||
currency: "USD", bill_type: "income", manual: false,
|
||||
expected_day_of_month: Date.current.day,
|
||||
last_occurrence_date: 1.month.ago.to_date, next_expected_date: Date.current,
|
||||
status: "active"
|
||||
)
|
||||
|
||||
result = call_tool
|
||||
|
||||
assert_equal "No declared income schedule", result[:error]
|
||||
assert_includes result[:hint], "declared"
|
||||
end
|
||||
|
||||
test "declared income produces periods with due, reserved and safe figures" do
|
||||
payday = Date.current + 3
|
||||
@family.recurring_transactions.create!(
|
||||
name: "Paycheck", account: accounts(:depository), amount: -1200,
|
||||
currency: "USD", bill_type: "income", manual: true,
|
||||
expected_day_of_month: payday.day, anchor_date: payday,
|
||||
last_occurrence_date: payday, next_expected_date: payday, status: "active"
|
||||
)
|
||||
due = payday + 4
|
||||
@family.recurring_transactions.create!(
|
||||
name: "Rent", account: accounts(:depository), amount: 500,
|
||||
currency: "USD", bill_type: "bill", manual: true,
|
||||
expected_day_of_month: due.day, anchor_date: due,
|
||||
last_occurrence_date: due, next_expected_date: due, status: "active"
|
||||
)
|
||||
|
||||
result = call_tool("periods_limit" => 2)
|
||||
|
||||
assert_equal @family.currency, result[:family_currency]
|
||||
assert_operator result[:periods].size, :>=, 1
|
||||
|
||||
period_with_rent = result[:periods].find { |period| period[:bills_due].any? { |bill| bill[:name] == "Rent" } }
|
||||
assert period_with_rent.present?, "the rent must land in a period as due"
|
||||
assert period_with_rent[:income].present?
|
||||
assert period_with_rent[:safe_after_bills].present?
|
||||
end
|
||||
|
||||
# A lone materialized paycheck landing today yields an empty plan; the tool
|
||||
# answers with its guidance instead of raising on the missing periods.
|
||||
test "a lone paycheck landing today returns guidance rather than raising" do
|
||||
series = @family.recurring_transactions.create!(
|
||||
name: "Paycheck", account: accounts(:depository), amount: -1840,
|
||||
currency: "USD", bill_type: "income", manual: true,
|
||||
expected_day_of_month: Date.current.day, anchor_date: Date.current,
|
||||
last_occurrence_date: Date.current, next_expected_date: Date.current, status: "active"
|
||||
)
|
||||
series.recurring_occurrences.where("due_on > ?", Date.current).delete_all
|
||||
|
||||
result = call_tool
|
||||
|
||||
assert_equal "No declared income schedule", result[:error]
|
||||
assert result[:hint].present?
|
||||
end
|
||||
|
||||
# Same class of bug as the get_bills one: the planner counts confirmed series
|
||||
# only, which is correct, but every figure it returns is spending headroom, so
|
||||
# dropping unconfirmed detections without a word makes the plan look safer
|
||||
# than it is.
|
||||
test "a plan that ignores unconfirmed detections says so" do
|
||||
declare_income
|
||||
@family.recurring_transactions.create!(
|
||||
name: "Detected subscription", account: accounts(:depository), amount: 40,
|
||||
currency: "USD", expected_day_of_month: Date.current.day, status: "suggested",
|
||||
bill_type: "subscription", manual: false, dedup_scope: "detected-40",
|
||||
last_occurrence_date: 1.month.ago.to_date, next_expected_date: Date.current
|
||||
)
|
||||
|
||||
result = call_tool
|
||||
|
||||
assert result[:unconfirmed_excluded].present?,
|
||||
"the plan must disclose obligations it left out"
|
||||
assert_equal 1, result[:unconfirmed_excluded][:count]
|
||||
assert_match(/upper bound/, result[:unconfirmed_excluded][:note])
|
||||
end
|
||||
|
||||
test "no exclusion notice when everything is confirmed" do
|
||||
declare_income
|
||||
|
||||
result = call_tool
|
||||
|
||||
assert result[:periods].present?
|
||||
assert_nil result[:unconfirmed_excluded]
|
||||
end
|
||||
|
||||
|
||||
# The assistant answered "Shortfall: $150.00, Safe after bills: -$150.00" for
|
||||
# a window funded entirely from money already in the bank. A bridge earns
|
||||
# nothing, so income minus obligations is a deficit by construction, and the
|
||||
# deficit sat right beside short: false.
|
||||
test "a bridge window reports headroom against cash, not against its zero income" do
|
||||
set_cash(900)
|
||||
declare_future_income
|
||||
declare_bridge_bill(amount: 150)
|
||||
|
||||
bridge = call_tool[:periods].find { |period| period[:bridge] }
|
||||
|
||||
assert_equal false, bridge[:short]
|
||||
assert_equal "$750.00", bridge[:safe_after_bills],
|
||||
"cash minus what is due out of it, never income minus obligations"
|
||||
assert_equal "$900.00", bridge[:cash_on_hand]
|
||||
end
|
||||
|
||||
test "a bridge the cash cannot cover still reports short" do
|
||||
set_cash(100)
|
||||
declare_future_income
|
||||
declare_bridge_bill(amount: 150)
|
||||
|
||||
bridge = call_tool[:periods].find { |period| period[:bridge] }
|
||||
|
||||
assert_equal true, bridge[:short]
|
||||
assert_equal "-$50.00", bridge[:safe_after_bills]
|
||||
end
|
||||
|
||||
test "an unreadable balance omits the figure rather than inventing one" do
|
||||
@family.accounts.update_all(status: "disabled")
|
||||
declare_future_income
|
||||
declare_bridge_bill(amount: 150)
|
||||
|
||||
bridge = call_tool[:periods].find { |period| period[:bridge] }
|
||||
|
||||
assert_equal false, bridge[:short]
|
||||
assert_not bridge.key?(:safe_after_bills),
|
||||
"no balance means no honest headroom figure, so the key goes rather than guessing"
|
||||
assert_not bridge.key?(:cash_on_hand)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# The shared declare_income pays today, so there is no gap before the next
|
||||
# payday and no bridge window at all. These tests need one.
|
||||
def declare_future_income
|
||||
payday = Date.current + 5
|
||||
series = @family.recurring_transactions.create!(
|
||||
name: "Payday", account: accounts(:depository), amount: -2000,
|
||||
currency: "USD", expected_day_of_month: payday.day, status: "active",
|
||||
bill_type: "income", manual: true, dedup_scope: "future-payday--2000",
|
||||
last_occurrence_date: 1.month.ago.to_date, next_expected_date: payday
|
||||
)
|
||||
series.recurring_occurrences.destroy_all
|
||||
series.recurring_occurrences.create!(
|
||||
family: @family, original_due_on: payday, due_on: payday,
|
||||
currency: "USD", expected_amount: 2000, status: "scheduled"
|
||||
)
|
||||
series
|
||||
end
|
||||
|
||||
def set_cash(amount)
|
||||
accounts = @family.accounts.where(accountable_type: "Depository")
|
||||
accounts.update_all(balance: amount / accounts.count.to_d)
|
||||
end
|
||||
|
||||
def declare_bridge_bill(amount:)
|
||||
series = @family.recurring_transactions.create!(
|
||||
name: "Haircut", account: accounts(:depository), amount: amount,
|
||||
currency: "USD", status: "active", bill_type: "bill", manual: true,
|
||||
dedup_scope: "haircut-#{amount}", expected_day_of_month: (Date.current + 1).day,
|
||||
last_occurrence_date: 1.month.ago.to_date, next_expected_date: Date.current + 1
|
||||
)
|
||||
series.recurring_occurrences.destroy_all
|
||||
series.recurring_occurrences.create!(
|
||||
family: @family, original_due_on: Date.current + 1, due_on: Date.current + 1,
|
||||
currency: "USD", expected_amount: amount, status: "scheduled"
|
||||
)
|
||||
series
|
||||
end
|
||||
|
||||
def call_tool(params = {})
|
||||
Assistant::Function::GetPaycheckPlan.new(@user).call(params)
|
||||
end
|
||||
|
||||
def declare_income
|
||||
@family.recurring_transactions.create!(
|
||||
name: "Payday", account: accounts(:depository), amount: -2000,
|
||||
currency: "USD", expected_day_of_month: Date.current.day, status: "active",
|
||||
bill_type: "income", manual: true, dedup_scope: "payday--2000",
|
||||
last_occurrence_date: 1.month.ago.to_date, next_expected_date: Date.current
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,194 @@
|
||||
require "test_helper"
|
||||
|
||||
class Assistant::Function::RecordBillPaymentTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@user = users(:family_admin)
|
||||
@family = @user.family
|
||||
@family.recurring_transactions.destroy_all
|
||||
@series = @family.recurring_transactions.create!(
|
||||
name: "Rent", account: accounts(:depository), amount: 2000, currency: "USD",
|
||||
expected_day_of_month: Date.current.day, anchor_date: Date.current,
|
||||
last_occurrence_date: 1.month.ago.to_date, next_expected_date: Date.current,
|
||||
status: "active", manual: true
|
||||
)
|
||||
@occurrence = @series.recurring_occurrences.open_status.order(:due_on).first
|
||||
end
|
||||
|
||||
test "omitting amount settles the occurrence in full" do
|
||||
result = call_tool({})
|
||||
|
||||
assert result[:recorded]
|
||||
assert_equal "paid", result[:occurrence][:status]
|
||||
assert @occurrence.reload.paid?
|
||||
assert_equal 2000, @occurrence.allocations.sum(:allocated_amount)
|
||||
end
|
||||
|
||||
test "a backdated full settlement carries its payment date into the allocation" do
|
||||
paid_on = Date.current - 6
|
||||
|
||||
result = call_tool("paid_on" => paid_on.iso8601)
|
||||
|
||||
assert result[:recorded]
|
||||
assert_equal paid_on, @occurrence.reload.allocations.sole.paid_on,
|
||||
"the settlement must record the stated payment date, not today"
|
||||
end
|
||||
|
||||
test "a partial payment leaves the occurrence open and partially paid" do
|
||||
result = call_tool("amount" => 500)
|
||||
|
||||
assert result[:recorded]
|
||||
occurrence = @occurrence.reload
|
||||
assert occurrence.scheduled?, "500 against 2000 is not rent"
|
||||
assert occurrence.partially_paid?
|
||||
assert_equal "$1,500.00", result[:occurrence][:remaining]
|
||||
end
|
||||
|
||||
test "an invalid payment amount is refused with a hint, not raised" do
|
||||
result = call_tool("amount" => 0)
|
||||
|
||||
assert result[:error].present?
|
||||
assert_includes result[:hint], "get_bill_details"
|
||||
assert_equal 0, @occurrence.reload.allocations.count
|
||||
end
|
||||
|
||||
test "after settling, the next open occurrence becomes the payable one" do
|
||||
call_tool({})
|
||||
|
||||
result = call_tool("amount" => 100)
|
||||
|
||||
assert result[:recorded], "the series' next open occurrence takes the payment"
|
||||
next_open = @series.recurring_occurrences.open_status.order(:due_on).first
|
||||
assert next_open.partially_paid?
|
||||
end
|
||||
|
||||
test "a closed or unknown due date lists the open ones" do
|
||||
result = call_tool("occurrence_due_on" => (Date.current - 3).iso8601)
|
||||
|
||||
assert_match(/No open occurrence/, result[:error])
|
||||
assert_includes result[:hint], @occurrence.due_on.iso8601
|
||||
end
|
||||
|
||||
test "payments go through the Allocator write path" do
|
||||
result = call_tool("amount" => 500, "paid_on" => (Date.current - 1).iso8601)
|
||||
|
||||
assert result[:recorded]
|
||||
allocation = @occurrence.allocations.sole
|
||||
assert_equal "user_created", allocation.source
|
||||
assert_equal Date.current - 1, allocation.paid_on
|
||||
end
|
||||
|
||||
|
||||
# An LLM retries on timeout. Every payment recorded through this tool was
|
||||
# capped by nothing, because the allocator only guarded payments attached to
|
||||
# a bank entry. Two identical calls settled a $2,000 bill at $3,000.
|
||||
test "a repeated partial payment cannot overfill the cycle" do
|
||||
series = declare_capped_bill(amount: 2000)
|
||||
args = { "bill_id" => series.id, "amount" => 1500 }
|
||||
|
||||
first = Assistant::Function::RecordBillPayment.new(@user).call(args)
|
||||
second = Assistant::Function::RecordBillPayment.new(@user).call(args)
|
||||
|
||||
assert first[:recorded]
|
||||
assert second[:error].present?, "the retry must be refused, not absorbed"
|
||||
assert_match(/more than the/, second[:error])
|
||||
|
||||
occurrence = series.recurring_occurrences.order(:due_on).first
|
||||
assert_equal 1500, occurrence.allocations.confirmed.sum(:allocated_amount)
|
||||
end
|
||||
|
||||
test "an amount larger than the cycle owes is refused" do
|
||||
series = declare_capped_bill(amount: 2000)
|
||||
|
||||
result = Assistant::Function::RecordBillPayment.new(@user).call("bill_id" => series.id, "amount" => 999_999)
|
||||
|
||||
assert result[:error].present?
|
||||
assert_equal 0, series.recurring_occurrences.order(:due_on).first.allocations.count
|
||||
end
|
||||
|
||||
# After a cycle settles, the next one becomes current. A retried settle used
|
||||
# to fall through and pay it, closing two cycles from two identical requests.
|
||||
test "a repeated full settle does not pay the following cycle" do
|
||||
series = declare_capped_bill(amount: 2000)
|
||||
|
||||
first = Assistant::Function::RecordBillPayment.new(@user).call("bill_id" => series.id)
|
||||
second = Assistant::Function::RecordBillPayment.new(@user).call("bill_id" => series.id)
|
||||
|
||||
assert first[:recorded]
|
||||
assert second[:error].present?, "nothing is owed once the due cycle is settled"
|
||||
assert_match(/pay ahead/, second[:hint])
|
||||
assert_equal 1, series.recurring_occurrences.where(status: "paid").count
|
||||
end
|
||||
|
||||
test "paying ahead still works when the cycle is named" do
|
||||
series = declare_capped_bill(amount: 2000)
|
||||
Assistant::Function::RecordBillPayment.new(@user).call("bill_id" => series.id)
|
||||
future = series.recurring_occurrences.open_status.order(:due_on).first
|
||||
|
||||
result = Assistant::Function::RecordBillPayment.new(@user).call("bill_id" => series.id, "occurrence_due_on" => future.due_on.iso8601)
|
||||
|
||||
assert result[:recorded], "naming the date is the deliberate act a retry never performs"
|
||||
end
|
||||
|
||||
test "non-finite and negative amounts are refused instead of coerced" do
|
||||
series = declare_capped_bill(amount: 2000)
|
||||
|
||||
%w[Infinity NaN].each do |value|
|
||||
result = Assistant::Function::RecordBillPayment.new(@user).call("bill_id" => series.id, "amount" => value)
|
||||
assert_equal "amount is not a number", result[:error]
|
||||
end
|
||||
|
||||
negative = Assistant::Function::RecordBillPayment.new(@user).call("bill_id" => series.id, "amount" => -500)
|
||||
assert_equal "amount must be greater than zero", negative[:error],
|
||||
"a negative used to be flipped with .abs and recorded as a payment"
|
||||
|
||||
assert_equal 0, series.recurring_occurrences.order(:due_on).first.allocations.count
|
||||
end
|
||||
|
||||
# "" is not "no amount": a present-but-blank value used to skip the amount
|
||||
# branch entirely and settle the whole occurrence.
|
||||
test "a blank amount is malformed input, not a settlement" do
|
||||
series = declare_capped_bill(amount: 100)
|
||||
|
||||
result = Assistant::Function::RecordBillPayment.new(@user).call("bill_id" => series.id, "amount" => "")
|
||||
|
||||
assert_match(/not a number/, result[:error])
|
||||
assert_equal 0, series.recurring_occurrences.order(:due_on).first.allocations.count
|
||||
end
|
||||
|
||||
test "a read-only account share cannot record payments" do
|
||||
series = declare_capped_bill(amount: 100)
|
||||
series.update!(account: accounts(:credit_card))
|
||||
member = users(:family_member)
|
||||
|
||||
result = Assistant::Function::RecordBillPayment.new(member).call("bill_id" => series.id)
|
||||
|
||||
assert_match(/read-only/, result[:error])
|
||||
assert_equal 0, series.recurring_occurrences.order(:due_on).first.allocations.count
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def declare_capped_bill(amount:)
|
||||
due = Date.current
|
||||
series = @family.recurring_transactions.create!(
|
||||
name: "Rent #{amount}", account: accounts(:depository), amount: amount,
|
||||
currency: "USD", status: "active", bill_type: "bill", manual: true,
|
||||
dedup_scope: "rent-#{amount}", expected_day_of_month: due.day,
|
||||
last_occurrence_date: 1.month.ago.to_date, next_expected_date: due
|
||||
)
|
||||
series.recurring_occurrences.destroy_all
|
||||
series.recurring_occurrences.create!(
|
||||
family: @family, original_due_on: due, due_on: due,
|
||||
currency: "USD", expected_amount: amount, status: "scheduled"
|
||||
)
|
||||
series.recurring_occurrences.create!(
|
||||
family: @family, original_due_on: due + 30, due_on: due + 30,
|
||||
currency: "USD", expected_amount: amount, status: "scheduled"
|
||||
)
|
||||
series.reload
|
||||
end
|
||||
|
||||
def call_tool(params)
|
||||
Assistant::Function::RecordBillPayment.new(@user).call({ "bill_id" => @series.id.to_s }.merge(params))
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,203 @@
|
||||
require "test_helper"
|
||||
|
||||
class Assistant::Function::UpdateBillTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@user = users(:family_admin)
|
||||
@family = @user.family
|
||||
@family.recurring_transactions.destroy_all
|
||||
end
|
||||
|
||||
test "amount arrives as magnitude and income keeps its sign" do
|
||||
income = create_series(name: "Paycheck", amount: -1200, bill_type: "income")
|
||||
|
||||
result = call_tool(income.id, "amount" => 1300)
|
||||
|
||||
assert result[:updated]
|
||||
assert_equal(-1300, income.reload.amount.to_f,
|
||||
"a raw assignment would have flipped the paycheck into a bill")
|
||||
end
|
||||
|
||||
test "a non-numeric amount is refused with a hint, not raised" do
|
||||
series = create_series(name: "Gym", amount: 40)
|
||||
|
||||
result = call_tool(series.id, "amount" => "twenty")
|
||||
|
||||
assert result[:error].present?
|
||||
assert result[:hint].present?
|
||||
assert_equal 40, series.reload.amount.to_f
|
||||
end
|
||||
|
||||
test "a non-finite amount is refused, not assigned" do
|
||||
# BigDecimal happily parses these strings, so the finite check is the
|
||||
# only thing between them and the ledger.
|
||||
series = create_series(name: "Gym", amount: 40)
|
||||
|
||||
%w[Infinity -Infinity NaN].each do |value|
|
||||
result = call_tool(series.id, "amount" => value)
|
||||
|
||||
assert result[:error].present?, "#{value} must be refused"
|
||||
assert_equal 40, series.reload.amount.to_f
|
||||
end
|
||||
end
|
||||
|
||||
test "a schedule change applies the preset and pins it against detection" do
|
||||
series = create_series(name: "Gym", amount: 40)
|
||||
refute series.schedule_pinned?
|
||||
|
||||
result = call_tool(series.id, "frequency" => "weekly", "weekday" => 5)
|
||||
|
||||
assert result[:updated]
|
||||
assert_includes result[:changed_fields], "schedule"
|
||||
assert series.reload.schedule_pinned?,
|
||||
"an AI-applied cadence is user intent by proxy; detection must not move it back"
|
||||
end
|
||||
|
||||
test "paused maps to the stored value the UI's own Pause writes" do
|
||||
series = create_series(name: "Gym", amount: 40)
|
||||
|
||||
result = call_tool(series.id, "status" => "paused")
|
||||
|
||||
assert result[:updated]
|
||||
assert_equal "inactive", series.reload.status
|
||||
assert_equal "paused", result[:bill][:status], "but the tool answers in the UI vocabulary"
|
||||
end
|
||||
|
||||
test "an amount change pins occurrences already due instead of restating them" do
|
||||
overdue_day = 10.days.ago.to_date
|
||||
series = create_series(name: "Rent", amount: 2000,
|
||||
expected_day_of_month: overdue_day.day,
|
||||
anchor_date: overdue_day,
|
||||
last_occurrence_date: overdue_day << 1,
|
||||
next_expected_date: overdue_day)
|
||||
past_due = series.recurring_occurrences.open_status.find_by!(due_on: overdue_day)
|
||||
|
||||
call_tool(series.id, "amount" => 2500)
|
||||
|
||||
assert_equal 2000, past_due.reload.resolved_expected_amount.to_f,
|
||||
"raising the rent must not restate what last month's unpaid rent claims"
|
||||
end
|
||||
|
||||
test "an invalid renews_on is rejected instead of silently clearing" do
|
||||
series = create_series(name: "Gym", amount: 40)
|
||||
|
||||
result = call_tool(series.id, "renews_on" => "not-a-date")
|
||||
|
||||
assert_equal "renews_on is not a valid date", result[:error]
|
||||
assert_equal "Use YYYY-MM-DD.", result[:hint]
|
||||
assert_nil series.reload.renews_on
|
||||
|
||||
result = call_tool(series.id, "renews_on" => "2027-03-01")
|
||||
|
||||
assert result[:updated]
|
||||
assert_equal Date.new(2027, 3, 1), series.reload.renews_on
|
||||
end
|
||||
|
||||
test "kind cannot leave the editable set" do
|
||||
income = create_series(name: "Paycheck", amount: -1200, bill_type: "income")
|
||||
|
||||
result = call_tool(income.id, "bill_type" => "bill")
|
||||
|
||||
assert result[:error].present?
|
||||
assert_equal "income", income.reload.bill_type
|
||||
end
|
||||
|
||||
test "an inaccessible account name returns a hint" do
|
||||
series = create_series(name: "Gym", amount: 40)
|
||||
|
||||
result = call_tool(series.id, "account_name" => "No Such Account")
|
||||
|
||||
assert_match(/No account named/, result[:error])
|
||||
assert_includes result[:hint], "get_accounts"
|
||||
end
|
||||
|
||||
test "no recognized fields is an error, not a silent no-op" do
|
||||
series = create_series(name: "Gym", amount: 40)
|
||||
|
||||
result = call_tool(series.id, "unknown_field" => "x")
|
||||
|
||||
assert_match(/No recognized fields/, result[:error])
|
||||
end
|
||||
|
||||
# Sharing is per account: a read-only share reads a bill everywhere the app
|
||||
# shows it and must not change it, mirroring the pages' write guard.
|
||||
test "a read-only account share cannot update the series" do
|
||||
series = create_series(name: "Shared Sub", amount: 12, account: accounts(:credit_card))
|
||||
member = users(:family_member)
|
||||
|
||||
result = Assistant::Function::UpdateBill.new(member).call("bill_id" => series.id.to_s, "name" => "Hijacked")
|
||||
|
||||
assert_match(/read-only/, result[:error])
|
||||
assert_equal "Shared Sub", series.reload.name
|
||||
end
|
||||
|
||||
test "the reassignment target must be writable" do
|
||||
series = create_series(name: "Wandering", amount: 9, account: nil)
|
||||
member = users(:family_member)
|
||||
|
||||
result = Assistant::Function::UpdateBill.new(member).call(
|
||||
"bill_id" => series.id.to_s, "account_name" => accounts(:credit_card).name
|
||||
)
|
||||
|
||||
assert_match(/add bills to/, result[:error])
|
||||
assert_nil series.reload.account_id
|
||||
end
|
||||
|
||||
test "namesake accounts are refused rather than picked between" do
|
||||
series = create_series(name: "Gym", amount: 40)
|
||||
@family.accounts.create!(
|
||||
name: accounts(:depository).name, balance: 0, currency: "USD",
|
||||
accountable: Depository.new, owner: @user
|
||||
)
|
||||
|
||||
result = call_tool(series.id, "account_name" => accounts(:depository).name)
|
||||
|
||||
assert_match(/More than one account/, result[:error])
|
||||
end
|
||||
|
||||
test "an unrecognized frequency is rejected before anything saves" do
|
||||
series = create_series(name: "Insurance", amount: 60)
|
||||
|
||||
result = call_tool(series.id, "frequency" => "yearly", "name" => "Renamed")
|
||||
|
||||
assert_match(/not a frequency/, result[:error])
|
||||
assert_equal "Insurance", series.reload.name, "companion edits must not save with a rejected cadence"
|
||||
end
|
||||
|
||||
test "day details without a frequency are refused, not silently dropped" do
|
||||
series = create_series(name: "Rent", amount: 2150)
|
||||
|
||||
result = call_tool(series.id, "due_day_of_month" => 5)
|
||||
|
||||
assert_match(/need frequency/, result[:error])
|
||||
end
|
||||
|
||||
test "unrecognized status and kind values error instead of no-opping" do
|
||||
series = create_series(name: "Gym", amount: 40)
|
||||
|
||||
assert_match(/not a status/, call_tool(series.id, "status" => "cancelled")[:error])
|
||||
assert_match(/not a kind/, call_tool(series.id, "bill_type" => "loan")[:error])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def call_tool(bill_id, params)
|
||||
Assistant::Function::UpdateBill.new(@user).call({ "bill_id" => bill_id.to_s }.merge(params))
|
||||
end
|
||||
|
||||
def create_series(name:, amount:, **overrides)
|
||||
@family.recurring_transactions.create!({
|
||||
name: name,
|
||||
account: accounts(:depository),
|
||||
amount: amount,
|
||||
dedup_scope: "#{name}-#{amount}",
|
||||
currency: "USD",
|
||||
expected_day_of_month: Date.current.day,
|
||||
anchor_date: Date.current,
|
||||
last_occurrence_date: 1.month.ago.to_date,
|
||||
next_expected_date: Date.current,
|
||||
status: "active",
|
||||
manual: true,
|
||||
bill_type: amount.to_d.negative? ? "income" : "bill"
|
||||
}.merge(overrides))
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user