mirror of
https://github.com/we-promise/sure.git
synced 2026-09-09 00:24:15 +00:00
* feat(bills): the bills pages, calendar feed and in-page AI helpers Second of three chunks carved out of #3083, stacked on the schema and domain core. This is everything a user sees and clicks. The whole surface sits behind the preview flag, so it is unreachable until someone opts in. Pages, all under one nav entry: - the pay run, a month calendar, the full bills table, and the paycheck planner - a detail drawer per bill, with payment history, price changes and cost analytics - create and edit flows for bills, subscriptions, installment plans and income The overview marks pay periods inside the month, so a weekly paycheck no longer reads as one undifferentiated month of bills. Markers appear only when income actually subdivides the month, which means monthly and undeclared income render exactly as before and there is no new setting to configure. Navigation and design system: - one preview-gated nav item shared by the desktop rail and the mobile bar - DS::Sparkline for payment-history charts, replacing raw SVG in views - status badges render through DS::Pill rather than hand-rolled spans - the suggestions panel is a disclosure that remembers being collapsed, per device, the way privacy mode and the sidebar width already do - every surface reflows to phone widths without horizontal scroll Calendar feed: a signed ICS feed per family, served sessionless by token, with a reset that revokes previously shared URLs. In-page AI helpers: smart fill on the bill form and a smart configuration proposal on an existing bill, each reading a bounded slice of charge history. Provider-side prompt assembly sits behind the existing LlmConcept interface, with an implementation for each of the two providers. These belong here rather than with the assistant tools because they are buttons on these pages and lean on the provider suggester, not on the tool registry. Suite 7,776 runs green apart from the pre-existing passkey-session flake, which passes standalone. Rubocop clean, eager loading verified. The hosting guide for the feature ships here rather than with the schema, since its instructions walk pages this PR introduces. * Render the suggested strip through DS::Disclosure The hand-rolled details pair predates the component. The card_inset variant is the same shape, so the strip now inherits the design system chrome, and the persisted-disclosure controller rides along unchanged. * Route the remaining hand-rolled chips through the design system The subscription-state chips, rule-match chips and match-reason chips become DS::Pill, with the state chips extracted to one shared partial so the drawer and the summary tab stop carrying copy-pasted markup. The AI prompt chips become DS::Button and the bills-index filter becomes DS::SearchInput, both of which this PR already uses elsewhere for the same shapes. * Fix erb_lint whitespace offenses in bills views * Address the post-ready review round * Require a writable destination account and gate the feed on preview * Reject an unresolvable declared account out loud
341 lines
13 KiB
Ruby
341 lines
13 KiB
Ruby
require "test_helper"
|
|
require "ostruct"
|
|
|
|
class BillsHelperTest < ActionView::TestCase
|
|
# bills_match_reasons formats one money value, and format_money lives in
|
|
# ApplicationHelper rather than this module.
|
|
include ApplicationHelper
|
|
|
|
# The matcher has always stored WHY it matched something, in match_signals.
|
|
# Nothing rendered it, so the app showed a bare percentage instead of the
|
|
# facts the percentage is made of.
|
|
test "the signals behind an exact match read as plain reasons" do
|
|
reasons = bills_match_reasons(
|
|
{ merchant: 0.40, amount: 0.30, date: 0.20, account: 0.10 },
|
|
currency: "USD",
|
|
expected: BigDecimal("6.44"),
|
|
actual: BigDecimal("6.44"),
|
|
due_on: Date.new(2026, 7, 31),
|
|
paid_on: Date.new(2026, 7, 31)
|
|
)
|
|
|
|
assert_equal [
|
|
I18n.t("bills.match.same_merchant"),
|
|
I18n.t("bills.match.exact_amount"),
|
|
I18n.t("bills.match.due_date")
|
|
], reasons
|
|
end
|
|
|
|
# signals[:account] is a constant 0.10 on every candidate, because
|
|
# identity_matches? has already rejected everything on another account. A
|
|
# reason that never distinguishes anything is decoration.
|
|
test "the account signal is never rendered as a reason" do
|
|
reasons = bills_match_reasons(
|
|
{ merchant: 0.40, amount: 0.30, date: 0.20, account: 0.10 },
|
|
currency: "USD", expected: 10, actual: 10,
|
|
due_on: Date.current, paid_on: Date.current
|
|
)
|
|
|
|
assert_no_match(/account/i, reasons.join(" "))
|
|
end
|
|
|
|
test "an inexact amount names the difference, and a nearby date names the gap" do
|
|
reasons = bills_match_reasons(
|
|
{ name: 0.35, amount: 0.22, date: 0.14 },
|
|
currency: "USD",
|
|
expected: BigDecimal("14.99"),
|
|
actual: BigDecimal("13.27"),
|
|
due_on: Date.new(2026, 7, 31),
|
|
paid_on: Date.new(2026, 7, 30)
|
|
)
|
|
|
|
assert_includes reasons, I18n.t("bills.match.name_matches")
|
|
assert_includes reasons, I18n.t("bills.match.amount_off", amount: "$1.72")
|
|
assert_includes reasons, I18n.t("bills.match.days_before", count: 1)
|
|
end
|
|
|
|
test "a date after the due date reads as after" do
|
|
reasons = bills_match_reasons(
|
|
{ date: 0.10 },
|
|
currency: "USD",
|
|
due_on: Date.new(2026, 7, 31),
|
|
paid_on: Date.new(2026, 8, 3)
|
|
)
|
|
|
|
assert_equal [ I18n.t("bills.match.days_after", count: 3) ], reasons
|
|
end
|
|
|
|
# A suggestion's entry FK nullifies rather than cascades, so the review queue
|
|
# can hold an allocation with no entry behind it. An unguarded subtraction
|
|
# would raise on the one screen this helper exists to improve.
|
|
test "a signal with no figures behind it is skipped rather than raising" do
|
|
assert_nothing_raised do
|
|
reasons = bills_match_reasons({ merchant: 0.40, amount: 0.30, date: 0.20 }, currency: "USD")
|
|
|
|
assert_equal [ I18n.t("bills.match.same_merchant") ], reasons
|
|
end
|
|
end
|
|
|
|
test "string keys out of jsonb work the same as symbols" do
|
|
reasons = bills_match_reasons(
|
|
{ "merchant" => 0.40, "amount" => 0.30 },
|
|
currency: "USD", expected: 5, actual: 5
|
|
)
|
|
|
|
assert_equal [
|
|
I18n.t("bills.match.same_merchant"),
|
|
I18n.t("bills.match.exact_amount")
|
|
], reasons
|
|
end
|
|
|
|
test "no signals at all yields no reasons" do
|
|
assert_empty bills_match_reasons(nil, currency: "USD")
|
|
assert_empty bills_match_reasons({}, currency: "USD")
|
|
end
|
|
|
|
# The bar exists to show a paycheck divided three ways. If the segments do
|
|
# not carry the three amounts the card states in words, it is decoration
|
|
# sitting where an explanation should be.
|
|
test "a healthy period divides into due, reserved and safe" do
|
|
period = build_period(income: 1200, due: 357.48, reserved: 338.12)
|
|
|
|
assert_equal [ :due, :reserved, :safe ], paycheck_allocation_segments(period).map(&:first)
|
|
assert_in_delta 29.79, paycheck_allocation_segments(period).first.last, 0.01
|
|
end
|
|
|
|
# Rounding three shares to two places can leave the track a hair short, and
|
|
# a fully allocated paycheck showing a sliver of empty bar is the one thing
|
|
# this bar must never say.
|
|
test "segments always add up to exactly 100" do
|
|
[ [ 1200, 357.48, 338.12 ], [ 1000, 333.33, 333.33 ], [ 999.99, 333.33, 0 ] ].each do |income, due, reserved|
|
|
segments = paycheck_allocation_segments(build_period(income: income, due: due, reserved: reserved))
|
|
|
|
assert_equal 100, segments.sum(&:last), "#{income}/#{due}/#{reserved} did not fill the track"
|
|
end
|
|
end
|
|
|
|
# Dividing a short period three ways would draw a safe slice out of money
|
|
# that is not there.
|
|
test "a short period reads as covered and short, never as safe" do
|
|
period = build_period(income: 500, due: 400, reserved: 300)
|
|
|
|
segments = paycheck_allocation_segments(period)
|
|
|
|
assert_equal [ :covered, :short ], segments.map(&:first)
|
|
assert_equal 100, segments.sum(&:last)
|
|
assert_in_delta 71.43, segments.first.last, 0.01
|
|
end
|
|
|
|
test "a window with no income has no bar at all" do
|
|
assert_empty paycheck_allocation_segments(build_period(income: 0, due: 28.71, reserved: 695.62))
|
|
end
|
|
|
|
test "a zero part is dropped rather than drawn as a hairline" do
|
|
segments = paycheck_allocation_segments(build_period(income: 1200, due: 0, reserved: 338.12))
|
|
|
|
assert_equal [ :reserved, :safe ], segments.map(&:first)
|
|
end
|
|
|
|
# "Paycheck" is an assumption. A declared income series can be a pension or
|
|
# an invoice, and the user's own setup already names it.
|
|
test "a period is headed by the income that opens it" do
|
|
period = build_period(income: 1200, due: 0, reserved: 0, sources: [ "Frito Lay" ])
|
|
|
|
assert_equal "#{I18n.l(period.starts_on, format: :short)} · Frito Lay", paycheck_period_heading(period),
|
|
"the date leads, because the timeline is read down its date anchors"
|
|
end
|
|
|
|
test "two sources on one day are counted, not merged into one name" do
|
|
period = build_period(income: 1400, due: 0, reserved: 0, sources: [ "Frito Lay", "Side work" ])
|
|
|
|
assert_match(/2 income sources/, paycheck_period_heading(period))
|
|
end
|
|
|
|
|
|
# Reported from live use: a Twitch charge showed "$11.99 of $11.99 paid" and
|
|
# "Overdue by 20 days" on the same line. The label only ever read dates, so a
|
|
# cycle settled after its due date stayed "overdue" forever.
|
|
test "a settled cycle is not overdue" do
|
|
occurrence = build_occurrence(due_on: 20.days.ago.to_date, status: "paid")
|
|
|
|
label = occurrence_due_label(occurrence)
|
|
|
|
assert_match(/was due/i, label)
|
|
assert_no_match(/overdue/i, label, "a paid cycle cannot also be late")
|
|
end
|
|
|
|
test "skipped and missed cycles read the same way" do
|
|
%w[skipped missed].each do |status|
|
|
occurrence = build_occurrence(due_on: 20.days.ago.to_date, status: status)
|
|
|
|
assert_no_match(/overdue/i, occurrence_due_label(occurrence),
|
|
"a #{status} cycle is closed, so it is not still running late")
|
|
end
|
|
end
|
|
|
|
test "an open cycle past its due date is still overdue" do
|
|
occurrence = build_occurrence(due_on: 20.days.ago.to_date, status: "scheduled")
|
|
|
|
assert_match(/overdue/i, occurrence_due_label(occurrence),
|
|
"the overdue case must survive: that is the one the label exists for")
|
|
end
|
|
|
|
|
|
# derived_state only calls a cycle overdue once its grace has run out, and
|
|
# the overview and get_bills both honour that. This label read the raw date,
|
|
# so the screen said Overdue by 1 day about a bill the assistant correctly
|
|
# called due.
|
|
test "a cycle inside its grace period is not labelled overdue" do
|
|
occurrence = build_occurrence(due_on: Date.current - 1, status: "scheduled")
|
|
assert_equal :due, occurrence.derived_state, "precondition: still inside grace"
|
|
|
|
label = occurrence_due_label(occurrence)
|
|
|
|
assert_no_match(/overdue/i, label)
|
|
assert_match(/due/i, label)
|
|
end
|
|
|
|
test "a cycle past its grace is still labelled overdue" do
|
|
occurrence = build_occurrence(due_on: Date.current - 30, status: "scheduled")
|
|
assert_equal :overdue, occurrence.derived_state, "precondition: grace exhausted"
|
|
|
|
assert_match(/overdue/i, occurrence_due_label(occurrence))
|
|
end
|
|
|
|
# --- Prepared-data helpers extracted from the templates, so the section,
|
|
# pulse, detail and paycheck views render precomputed values. ---
|
|
|
|
test "ambiguous row keys mark only genuine collisions" do
|
|
twin_a = stub_occurrence("Twitch", 5.99, id: "a1")
|
|
twin_b = stub_occurrence("Twitch", 5.99, id: "a2")
|
|
other_tier = stub_occurrence("Twitch", 11.99, id: "b")
|
|
|
|
keys = bills_ambiguous_row_keys([ twin_a, twin_b, other_tier ])
|
|
|
|
assert_includes keys, [ "Twitch", 5.99 ]
|
|
assert_not_includes keys, [ "Twitch", 11.99 ]
|
|
end
|
|
|
|
test "pay period markers land on the first row of each period with its summed total" do
|
|
period = OpenStruct.new(starts_on: Date.new(2026, 9, 1), ends_on: Date.new(2026, 9, 14))
|
|
first_inside = stub_occurrence("Rent", 2150, id: "one", due_on: Date.new(2026, 9, 2))
|
|
second_inside = stub_occurrence("Power", 80, id: "two", due_on: Date.new(2026, 9, 10))
|
|
outside = stub_occurrence("Later", 10, id: "three", due_on: Date.new(2026, 9, 20))
|
|
|
|
markers = bills_pay_period_markers([ first_inside, second_inside, outside ], [ period ])
|
|
|
|
assert_equal [ "one" ], markers.keys
|
|
assert_equal 2230, markers["one"][:due_total]
|
|
assert_equal period, markers["one"][:period]
|
|
end
|
|
|
|
test "no pay periods means no markers" do
|
|
occurrence = stub_occurrence("Rent", 1, id: "x", due_on: Date.current)
|
|
|
|
assert_empty bills_pay_period_markers([ occurrence ], [])
|
|
end
|
|
|
|
test "month progress divides paid, overdue and upcoming out of one total" do
|
|
progress = bills_month_progress(paid: 50, remaining: 50, overdue: 25)
|
|
|
|
assert_equal 100.0, progress[:total]
|
|
assert_in_delta 50.0, progress[:paid_pct]
|
|
assert_in_delta 25.0, progress[:overdue_pct]
|
|
assert_in_delta 25.0, progress[:upcoming_pct]
|
|
end
|
|
|
|
test "an empty month draws no bar" do
|
|
progress = bills_month_progress(paid: nil, remaining: nil, overdue: nil)
|
|
|
|
assert_equal 0.0, progress[:total]
|
|
assert_equal 0, progress[:paid_pct]
|
|
end
|
|
|
|
test "overdue money never claims more of the bar than what remains" do
|
|
progress = bills_month_progress(paid: 80, remaining: 20, overdue: 500)
|
|
|
|
assert_in_delta 20.0, progress[:overdue_pct]
|
|
assert_in_delta 0.0, progress[:upcoming_pct]
|
|
end
|
|
|
|
test "matcher hints strip blanks and cast the tolerance" do
|
|
series = OpenStruct.new(matcher_hints: { "name_aliases" => [ "PEPSICO", "" ], "learned_tolerance_pct" => "7.5" })
|
|
|
|
hints = bills_matcher_hints(series)
|
|
|
|
assert_equal [ "PEPSICO" ], hints[:aliases]
|
|
assert_equal 7.5, hints[:learned_pct]
|
|
end
|
|
|
|
test "plan sections split the bridge from the timeline and pick the warning state" do
|
|
short_bridge = build_period(income: 0, due: 400, reserved: 0, leading: true, cash_on_hand: BigDecimal("100"))
|
|
period = build_period(income: 1200, due: 300, reserved: 100)
|
|
|
|
sections = paycheck_plan_sections([ short_bridge, period ])
|
|
|
|
assert_equal [ period ], sections[:periods]
|
|
assert_equal short_bridge, sections[:shortfall]
|
|
assert_nil sections[:bridge_note]
|
|
end
|
|
|
|
test "a covered bridge with items becomes the quiet note, not the warning" do
|
|
covered = build_period(income: 0, due: 50, reserved: 0, leading: true,
|
|
cash_on_hand: BigDecimal("500"), items: [ :a_bill ])
|
|
|
|
sections = paycheck_plan_sections([ covered ])
|
|
|
|
assert_nil sections[:shortfall]
|
|
assert_equal covered, sections[:bridge_note]
|
|
end
|
|
|
|
test "no plan yields empty sections" do
|
|
assert_empty paycheck_plan_sections(nil)
|
|
end
|
|
|
|
private
|
|
|
|
def stub_occurrence(name, amount, id:, due_on: Date.current)
|
|
OpenStruct.new(
|
|
id: id,
|
|
due_on: due_on,
|
|
resolved_expected_amount: amount,
|
|
recurring_transaction: OpenStruct.new(display_name: name)
|
|
)
|
|
end
|
|
|
|
def build_occurrence(due_on:, status:)
|
|
family = users(:family_admin).family
|
|
series = family.recurring_transactions.create!(
|
|
name: "Twitch #{status} #{due_on}", account: accounts(:depository),
|
|
amount: 11.99, currency: "USD", expected_day_of_month: due_on.day,
|
|
status: "active", bill_type: "subscription", manual: true,
|
|
dedup_scope: "twitch-#{status}-#{due_on}",
|
|
last_occurrence_date: due_on, next_expected_date: due_on
|
|
)
|
|
series.recurring_occurrences.destroy_all
|
|
series.recurring_occurrences.create!(
|
|
family: family, original_due_on: due_on, due_on: due_on,
|
|
currency: "USD", expected_amount: 11.99, status: status,
|
|
closed_at: (status == "scheduled" ? nil : Time.current)
|
|
)
|
|
end
|
|
def build_period(income:, due:, reserved:, sources: [ "Payroll" ], leading: false, cash_on_hand: nil, items: [])
|
|
obligations = BigDecimal(due.to_s) + BigDecimal(reserved.to_s)
|
|
|
|
RecurringTransaction::PaycheckPlanner::Period.new(
|
|
starts_on: Date.new(2026, 8, 19),
|
|
ends_on: Date.new(2026, 8, 25),
|
|
income: BigDecimal(income.to_s),
|
|
income_sources: sources,
|
|
items: items,
|
|
due_total: BigDecimal(due.to_s),
|
|
reserved_total: BigDecimal(reserved.to_s),
|
|
obligation_total: obligations,
|
|
remaining: BigDecimal(income.to_s) - obligations,
|
|
leading: leading,
|
|
final: false,
|
|
cash_on_hand: cash_on_hand
|
|
)
|
|
end
|
|
end
|