mirror of
https://github.com/we-promise/sure.git
synced 2026-09-05 06:41:08 +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
1580 lines
68 KiB
Ruby
1580 lines
68 KiB
Ruby
require "test_helper"
|
|
|
|
class BillsControllerTest < ActionDispatch::IntegrationTest
|
|
teardown do
|
|
travel_back
|
|
end
|
|
|
|
setup do
|
|
sign_in @user = users(:family_admin)
|
|
@user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => true))
|
|
@family = @user.family
|
|
@family.recurring_transactions.destroy_all
|
|
ensure_tailwind_build
|
|
end
|
|
|
|
test "redirects users without preview access" do
|
|
@user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => false))
|
|
|
|
get bills_url
|
|
|
|
assert_redirected_to root_path
|
|
assert_match(/preview/i, flash[:alert])
|
|
end
|
|
|
|
# Bills was the only top-level destination with no heading, so screen readers
|
|
# got no outline and it broke the page-header pattern every other page follows.
|
|
test "every bills view has a page heading" do
|
|
create_bill(name: "Rent", amount: 1200)
|
|
|
|
%w[overview calendar paycheck all].each do |view|
|
|
get view == "overview" ? bills_url : bills_url(view: view)
|
|
|
|
assert_response :success
|
|
assert_select "main h1", text: I18n.t("bills.index.title"),
|
|
message: "the #{view} view is missing its page heading"
|
|
end
|
|
end
|
|
|
|
test "index lists a bill" do
|
|
bill = create_bill(name: "Rent", amount: 1200)
|
|
|
|
get bills_url
|
|
|
|
assert_response :success
|
|
assert_match "Rent", response.body
|
|
end
|
|
|
|
# A bill is something you owe. Income is not owed, an internal transfer is not owed,
|
|
# and a paused row was explicitly set aside, so none of them belong on the list.
|
|
test "index excludes income and inactive rows but shows debt payments" do
|
|
create_bill(name: "Real bill", amount: 50)
|
|
create_bill(name: "Salary deposit", amount: -2000)
|
|
create_bill(name: "Paused bill", amount: 30, status: "inactive")
|
|
# A recurring transfer into a credit card is a real obligation with a
|
|
# real due date -- it belongs on the pay-run page, marked as what it is.
|
|
create_bill(name: "Card payment", amount: 100,
|
|
destination_account_id: accounts(:credit_card).id)
|
|
# A transfer into an asset account is just moving money; not a bill.
|
|
create_bill(name: "Moved to savings", amount: 100,
|
|
destination_account_id: accounts(:investment).id)
|
|
|
|
get bills_url
|
|
|
|
assert_response :success
|
|
assert_match "Real bill", response.body
|
|
assert_match "Card payment", response.body
|
|
assert_match I18n.t("bills.debt_payment"), response.body
|
|
assert_no_match "Salary deposit", response.body
|
|
assert_no_match "Paused bill", response.body
|
|
assert_no_match "Moved to savings", response.body
|
|
end
|
|
|
|
test "index shows an overdue occurrence in the overdue section" do
|
|
overdue_day = 10.days.ago.to_date
|
|
create_bill(name: "Late bill", amount: 75,
|
|
expected_day_of_month: overdue_day.day,
|
|
last_occurrence_date: 2.months.ago.to_date,
|
|
next_expected_date: overdue_day)
|
|
|
|
get bills_url
|
|
|
|
assert_response :success
|
|
assert_match "Late bill", response.body
|
|
|
|
# The row says HOW late, once. It used to print "Overdue" in the date rail
|
|
# beside a subline already reading "10 days overdue", spending its one
|
|
# piece of temporal context on saying the same word twice.
|
|
assert_match I18n.t("bills.attention.overdue", count: 10), response.body
|
|
assert_no_match(/>\s*#{I18n.t("bills.row_overdue")}\s*</, response.body,
|
|
"the date rail carries the date, not the word the subline already said")
|
|
|
|
# And the summary states what is at stake.
|
|
assert_match I18n.t("bills.month_pulse.pulse_overdue"), response.body
|
|
end
|
|
|
|
# The summary answers one question in order: where am I this month, what is
|
|
# late, what is due soon, what happens next. It used to be a big number, two
|
|
# small ones and a ring reading 0%.
|
|
test "the month summary reads as one story, not a row of statistics" do
|
|
overdue_day = 10.days.ago.to_date
|
|
create_bill(name: "Late Co", amount: 49.41,
|
|
expected_day_of_month: overdue_day.day,
|
|
last_occurrence_date: 2.months.ago.to_date,
|
|
next_expected_date: overdue_day)
|
|
soon = 3.days.from_now.to_date
|
|
create_bill(name: "Amazon Prime", amount: 16.23, manual: true,
|
|
anchor_date: soon, expected_day_of_month: soon.day,
|
|
last_occurrence_date: Date.current, next_expected_date: soon)
|
|
|
|
get bills_url
|
|
assert_response :success
|
|
body = response.body
|
|
|
|
# The month frames it, and the count says how much month there is.
|
|
assert_match I18n.l(Date.current, format: "%B"), body
|
|
assert_match I18n.t("bills.month_pulse.left_to_pay"), body
|
|
|
|
# Paid / overdue / next-7 support the headline rather than becoming three
|
|
# KPI cards of their own.
|
|
assert_match I18n.t("bills.month_pulse.pulse_paid"), body
|
|
assert_match I18n.t("bills.month_pulse.pulse_overdue"), body
|
|
assert_match I18n.t("bills.month_pulse.pulse_next_seven"), body
|
|
|
|
# And it ends by saying what actually happens next.
|
|
assert_match I18n.t("bills.month_pulse.next_up"), body
|
|
assert_match "Amazon Prime", body
|
|
|
|
assert_match I18n.t("bills.month_pulse.left_to_pay"), body
|
|
assert_no_match(/ProgressRing|rounded-full[^"]*stroke/, body,
|
|
"the donut is gone; progress is a rule, not a centrepiece")
|
|
end
|
|
|
|
# Something already past its due date is not "coming up" -- it is the thing
|
|
# the list below is for, and putting it in Next up makes the summary argue
|
|
# with the worklist under it.
|
|
#
|
|
# The filter is on the DATE, not on derived_state, and this test asserts the
|
|
# same. An earlier version checked `derived_state == :overdue` and passed
|
|
# while the bug was plainly visible on screen: derived_state only turns
|
|
# :overdue after the 3-day grace period, so a bill two days late was still
|
|
# :due, sailed into the strip, and the date column rendered it "2 days late".
|
|
test "next up holds nothing that is already past due, grace period included" do
|
|
# Two days late is INSIDE the grace period, so derived_state still reads
|
|
# :due. That is the case that slipped through.
|
|
just_late = 2.days.ago.to_date
|
|
create_bill(name: "Barely Late Co", amount: 12, manual: true, dedup_scope: "bl",
|
|
anchor_date: just_late, expected_day_of_month: just_late.day,
|
|
last_occurrence_date: just_late, next_expected_date: just_late)
|
|
|
|
long_late = 10.days.ago.to_date
|
|
create_bill(name: "Very Late Co", amount: 49.41, manual: true, dedup_scope: "vl",
|
|
anchor_date: long_late, expected_day_of_month: long_late.day,
|
|
last_occurrence_date: long_late, next_expected_date: long_late)
|
|
|
|
soon = 4.days.from_now.to_date
|
|
create_bill(name: "Soon Co", amount: 30, manual: true, dedup_scope: "sc",
|
|
anchor_date: soon, expected_day_of_month: soon.day,
|
|
last_occurrence_date: Date.current, next_expected_date: soon)
|
|
|
|
get bills_url
|
|
assert_response :success
|
|
|
|
next_up = @controller.view_assigns["next_up"]
|
|
assert next_up.any?, "the fixture must actually produce a Next up strip"
|
|
|
|
past = next_up.select { |occurrence| occurrence.effective_due_on < Date.current }
|
|
assert_empty past.map { |occurrence| occurrence.recurring_transaction.display_name },
|
|
"a bill inside its grace period is still late, and late is not next"
|
|
|
|
assert_includes next_up.map { |o| o.recurring_transaction.display_name }, "Soon Co"
|
|
end
|
|
|
|
# Three tiers of one subscription are three real bills that render as three
|
|
# identical rows. Only then does a row earn a second fact.
|
|
test "bills that would render identically gain something that tells them apart" do
|
|
day = 12.days.from_now.to_date
|
|
2.times do |i|
|
|
create_bill(name: "TWITCH", amount: 11.99, manual: true, dedup_scope: "tier#{i}",
|
|
anchor_date: day, expected_day_of_month: day.day,
|
|
last_occurrence_date: Date.current, next_expected_date: day)
|
|
end
|
|
create_bill(name: "Distinct Co", amount: 30, manual: true,
|
|
anchor_date: day, expected_day_of_month: day.day,
|
|
last_occurrence_date: Date.current, next_expected_date: day)
|
|
|
|
get bills_url
|
|
assert_response :success
|
|
|
|
# The pair carries its schedule; the bill nobody could confuse does not
|
|
# pay for their ambiguity.
|
|
twitch_rows = response.body.scan(/TWITCH/).size
|
|
assert_operator twitch_rows, :>=, 2
|
|
end
|
|
|
|
# Overdue rows used to sit inside the chronological month list, marked only by
|
|
# a word where their date would be, which made the most urgent rows the
|
|
# easiest to scroll past.
|
|
test "overdue bills are lifted out of the month list into their own section" do
|
|
overdue_day = 10.days.ago.to_date
|
|
create_bill(name: "Late bill", amount: 75,
|
|
expected_day_of_month: overdue_day.day,
|
|
last_occurrence_date: 2.months.ago.to_date,
|
|
next_expected_date: overdue_day)
|
|
|
|
get bills_url
|
|
|
|
assert_response :success
|
|
assert_match I18n.t("bills.index.needs_attention"), response.body
|
|
|
|
body = response.body
|
|
attention_at = body.index(I18n.t("bills.index.needs_attention"))
|
|
month_at = body.index(I18n.t("bills.index.this_month"))
|
|
# Measured from the section headings down, not from the top of the page:
|
|
# this series' NEXT cycle legitimately appears in the Next up strip above,
|
|
# which is a different occurrence of the same bill.
|
|
late_at = body.index("Late bill", attention_at)
|
|
|
|
assert_not_nil attention_at
|
|
assert_operator attention_at, :<, late_at, "the late bill belongs under Needs attention"
|
|
assert_operator late_at, :<, month_at, "and above This month, not inside it" if month_at
|
|
end
|
|
|
|
# "Status" used to mean the series lifecycle, so the one question people
|
|
# actually bring to this table -- what is late -- could not be asked.
|
|
test "the all view filters by payment state, not just lifecycle" do
|
|
overdue_day = 10.days.ago.to_date
|
|
late = create_bill(name: "Late Co", amount: 75,
|
|
expected_day_of_month: overdue_day.day,
|
|
last_occurrence_date: 2.months.ago.to_date,
|
|
next_expected_date: overdue_day)
|
|
upcoming = create_bill(name: "Future Co", amount: 40, manual: true,
|
|
anchor_date: 20.days.from_now.to_date,
|
|
expected_day_of_month: 20.days.from_now.to_date.day,
|
|
last_occurrence_date: Date.current,
|
|
next_expected_date: 20.days.from_now.to_date)
|
|
|
|
# The filter is only meaningful if the fixtures are in the states it sorts by.
|
|
assert late.current_occurrence.overdue?, "Late Co must actually be overdue"
|
|
assert_not upcoming.current_occurrence.overdue?, "Future Co must not be"
|
|
|
|
get bills_url(view: "all", q: { status: "overdue" })
|
|
|
|
assert_response :success
|
|
assert_match "Late Co", response.body
|
|
assert_no_match "Future Co", response.body
|
|
|
|
get bills_url(view: "all", q: { status: "paused" })
|
|
assert_response :success
|
|
assert_no_match "Late Co", response.body, "lifecycle filtering still works"
|
|
end
|
|
|
|
test "index cannot see another family's bills" do
|
|
families(:empty).recurring_transactions.create!(
|
|
name: "Someone else's rent",
|
|
amount: 999,
|
|
currency: "USD",
|
|
expected_day_of_month: 1,
|
|
last_occurrence_date: Date.current,
|
|
next_expected_date: 1.month.from_now.to_date,
|
|
status: "active"
|
|
)
|
|
|
|
get bills_url
|
|
|
|
assert_response :success
|
|
assert_no_match "Someone else's rent", response.body
|
|
end
|
|
|
|
test "index redirects when the family has turned recurring transactions off" do
|
|
@family.update!(recurring_transactions_disabled: true)
|
|
|
|
get bills_url
|
|
|
|
assert_redirected_to root_path
|
|
end
|
|
|
|
# The headline answers "how much do I owe", which is one number, so foreign-currency
|
|
# bills are converted rather than suppressing the total the way this first did.
|
|
test "index totals bills in the family currency" do
|
|
create_bill(name: "Domestic bill", amount: 100)
|
|
create_bill(name: "Foreign bill", amount: 50, currency: "EUR")
|
|
ExchangeRate.create!(from_currency: "EUR", to_currency: @family.currency, date: Date.current, rate: 2)
|
|
|
|
get bills_url
|
|
|
|
assert_response :success
|
|
assert_match "200", response.body
|
|
end
|
|
|
|
test "index still totals what it can when a rate is missing" do
|
|
create_bill(name: "Domestic bill", amount: 100)
|
|
create_bill(name: "Unconvertible bill", amount: 50, currency: "JPY")
|
|
|
|
get bills_url
|
|
|
|
assert_response :success
|
|
assert_match "Unconvertible bill", response.body
|
|
assert_match I18n.t("bills.index.unconvertible", count: 1), response.body
|
|
end
|
|
|
|
# Autopay is information, not a task. The bill stays listed and still counts toward
|
|
# the total, but it must not read as something demanding to be clicked.
|
|
test "index shows an autopaying bill without a pay call to action" do
|
|
create_bill(name: "Handled bill", amount: 30, autopay: true,
|
|
payment_url: "https://pay.example.com")
|
|
|
|
get bills_url
|
|
|
|
assert_response :success
|
|
assert_match "Handled bill", response.body
|
|
assert_match I18n.t("recurring_transactions.pay_action.autopay"), response.body
|
|
assert_no_match(/>\s*#{I18n.t("recurring_transactions.pay_action.pay")}\s*</, response.body)
|
|
end
|
|
|
|
# The paid-from account still shows; it just waits until there is more than
|
|
# one account for it to distinguish between. The single-account case is
|
|
# covered below, where showing it is nineteen copies of one fact.
|
|
test "index shows the account a bill is paid from and its notes" do
|
|
create_bill(name: "Annotated bill", amount: 60, notes: "Account 4821, on the Amex")
|
|
create_bill(name: "Card bill", amount: 25, account: accounts(:credit_card))
|
|
|
|
get bills_url
|
|
|
|
assert_response :success
|
|
assert_match "Account 4821, on the Amex", response.body
|
|
assert_match I18n.t("bills.paid_from", account: accounts(:depository).name), response.body
|
|
end
|
|
|
|
# Three concurrent subscriptions to one merchant, at different prices on
|
|
# different days, are three real bills and render as three rows.
|
|
test "index shows separate subscription tiers as separate rows" do
|
|
create_bill(name: "TWITCH", amount: 5.99, expected_day_of_month: 8)
|
|
create_bill(name: "TWITCH", amount: 11.99, expected_day_of_month: 2)
|
|
create_bill(name: "TWITCH", amount: 24.99, expected_day_of_month: 21)
|
|
|
|
get bills_url
|
|
|
|
assert_response :success
|
|
assert_operator response.body.scan("TWITCH").size, :>=, 3
|
|
end
|
|
|
|
test "index sums the remaining KPI from open occurrences" do
|
|
create_bill(name: "One", amount: 100)
|
|
create_bill(name: "Two", amount: 50)
|
|
|
|
get bills_url
|
|
|
|
assert_response :success
|
|
assert_match I18n.t("bills.index.left_to_pay"), response.body
|
|
assert_match "$150", response.body
|
|
end
|
|
|
|
test "partial payment moves the remaining KPI and shows progress" do
|
|
bill = create_bill(name: "Rent", amount: 2000)
|
|
occurrence = bill.recurring_occurrences.order(:due_on).first
|
|
RecurringTransaction::Allocator.new(occurrence).allocate!(amount: "750")
|
|
|
|
get bills_url
|
|
|
|
assert_response :success
|
|
# What is left leads, and the row says it once. The subline carries the
|
|
# state and the figure; printing "$750.00 of $2,000.00 paid" again on the
|
|
# right was the same arithmetic twice, and it was squeezing the bill's own
|
|
# name out of the row.
|
|
assert_match I18n.t("bills.attention.partial", amount: "$1,250.00"), response.body
|
|
assert_match "$1,250", response.body
|
|
assert_no_match I18n.t("bills.partial_progress", paid: "$750.00", expected: "$2,000.00"), response.body
|
|
end
|
|
|
|
# The row expansion described the SERIES definition while the drawer described
|
|
# the current occurrence, so an overdue bill greeted you with "Next payment"
|
|
# in one surface and "Overdue" in the other, at the same moment.
|
|
test "the row expansion and the drawer tell the same story about an overdue bill" do
|
|
overdue_day = 6.days.ago.to_date
|
|
bill = create_bill(name: "Late Co", amount: 5.99,
|
|
expected_day_of_month: overdue_day.day,
|
|
last_occurrence_date: 2.months.ago.to_date,
|
|
next_expected_date: overdue_day)
|
|
occurrence = bill.recurring_occurrences.order(:due_on).detect(&:overdue?)
|
|
assert occurrence, "the fixture must actually be overdue"
|
|
|
|
days = (Date.current - occurrence.effective_due_on).to_i
|
|
overdue_phrase = I18n.t("bills.due_label.overdue", count: days,
|
|
date: I18n.l(occurrence.effective_due_on, format: :short))
|
|
|
|
get bill_url(bill), headers: { "Turbo-Frame" => "drawer" }
|
|
assert_response :success
|
|
assert_match overdue_phrase, response.body, "the drawer states the status"
|
|
|
|
get bill_url(bill, display: "pane", frame: "x"), headers: { "Turbo-Frame" => "x" }
|
|
assert_response :success
|
|
assert_match overdue_phrase, response.body, "and the expansion must state the same one"
|
|
assert_no_match I18n.t("bills.detail.next_payment"), response.body,
|
|
"an overdue bill is not a next payment"
|
|
end
|
|
|
|
# A bill's own page is where the depth lives now. It used to be a drawer
|
|
# dialog rendered over an empty settings layout, which is how the app ended
|
|
# up with three renderings of a bill's detail and no page at all.
|
|
test "show renders the bill page with history and analytics" do
|
|
bill = create_bill(name: "Power Co", amount: 80)
|
|
past = bill.recurring_occurrences.create!(
|
|
family: @family, original_due_on: 2.months.ago.to_date, due_on: 2.months.ago.to_date,
|
|
currency: "USD"
|
|
)
|
|
RecurringTransaction::Allocator.new(past).allocate!(amount: "78.50")
|
|
past.reload
|
|
|
|
get bill_url(bill)
|
|
|
|
assert_response :success
|
|
assert_select "main h1", text: "Power Co"
|
|
assert_match I18n.t("bills.detail.history"), response.body
|
|
assert_match I18n.t("bills.detail.ytd"), response.body
|
|
assert_match "$78.50", response.body
|
|
end
|
|
|
|
# The drawer slot belongs to resolving a payment. If a bill's page claimed it
|
|
# too, the page and the payment surface would compete for one frame id and
|
|
# whichever lost would render nothing at all.
|
|
test "the bill page leaves the drawer frame to the payment surface" do
|
|
bill = create_bill(name: "Power Co", amount: 80)
|
|
|
|
get bill_url(bill)
|
|
|
|
assert_response :success
|
|
assert_equal 1, response.body.scan(/<turbo-frame[^>]*id="drawer"/).size,
|
|
"only the layout's own empty drawer frame"
|
|
assert_match recurring_occurrence_path(bill.recurring_occurrences.order(:due_on).first), response.body,
|
|
"and the page still offers the way in to it"
|
|
end
|
|
|
|
# The average sat beside per-year totals that are sums of real payments, so
|
|
# reading estimates here put two disagreeing numbers about the same money in
|
|
# one panel. Expected $80 twice, really charged $76 and $78: the average is
|
|
# $77, a figure that appears nowhere if the estimates are averaged instead.
|
|
test "drawer analytics average what was charged, not what was expected" do
|
|
bill = create_bill(name: "Power Co", amount: 80)
|
|
|
|
[ [ 3, 76 ], [ 2, 78 ] ].each do |months_ago, charged|
|
|
due = months_ago.months.ago.to_date
|
|
occurrence = bill.recurring_occurrences.create!(
|
|
family: @family, original_due_on: due, due_on: due, currency: "USD")
|
|
RecurringTransaction::Allocator.new(occurrence).allocate!(amount: charged.to_s)
|
|
assert occurrence.reload.paid?, "each charge is inside tolerance and should settle the cycle"
|
|
end
|
|
|
|
get bill_url(bill), headers: { "Turbo-Frame" => "drawer" }
|
|
|
|
assert_response :success
|
|
assert_match "$77.00", response.body
|
|
end
|
|
|
|
test "the all view lists every series and filters compose" do
|
|
create_bill(name: "Alpha bill", amount: 10)
|
|
create_bill(name: "Beta paused", amount: 20, status: "paused")
|
|
create_bill(name: "Gamma income", amount: -500)
|
|
|
|
get bills_url(view: "all")
|
|
assert_response :success
|
|
assert_match "Alpha bill", response.body
|
|
assert_match "Beta paused", response.body
|
|
assert_match "Gamma income", response.body
|
|
|
|
get bills_url(view: "all", q: { status: "paused" })
|
|
assert_match "Beta paused", response.body
|
|
assert_no_match "Alpha bill", response.body
|
|
|
|
get bills_url(view: "all", q: { search: "gamma" })
|
|
assert_match "Gamma income", response.body
|
|
assert_no_match "Beta paused", response.body
|
|
end
|
|
|
|
test "the calendar renders the month grid with paid and overdue states" do
|
|
paid_bill = create_bill(name: "Paid on time", amount: 40, expected_day_of_month: 5)
|
|
paid_occurrence = paid_bill.recurring_occurrences.find_by!(due_on: Date.current.beginning_of_month + 4)
|
|
RecurringTransaction::Allocator.new(paid_occurrence).allocate!(amount: "40")
|
|
|
|
overdue_day = [ Date.current - 6, Date.current.beginning_of_month ].max
|
|
create_bill(name: "Still owed", amount: 60,
|
|
expected_day_of_month: overdue_day.day,
|
|
last_occurrence_date: 2.months.ago.to_date,
|
|
next_expected_date: overdue_day)
|
|
|
|
get bills_url(view: "calendar")
|
|
|
|
assert_response :success
|
|
assert_match "Paid on time", response.body
|
|
assert_match "Still owed", response.body
|
|
assert_match Date.current.strftime("%B %Y"), response.body
|
|
end
|
|
|
|
test "the calendar materializes a far-future month on demand" do
|
|
create_bill(name: "Forward bill", amount: 25)
|
|
target = (Date.current + 6.months).beginning_of_month
|
|
|
|
get bills_url(view: "calendar", month: target.strftime("%Y-%m"))
|
|
|
|
assert_response :success
|
|
assert_match "Forward bill", response.body
|
|
end
|
|
|
|
test "the calendar caps forward navigation" do
|
|
create_bill(name: "Some bill", amount: 25)
|
|
beyond = (Date.current + 30.months).strftime("%Y-%m")
|
|
|
|
get bills_url(view: "calendar", month: beyond)
|
|
|
|
assert_response :success
|
|
limit_month = (Date.current + 13.months).beginning_of_month
|
|
assert_match limit_month.strftime("%B %Y"), response.body
|
|
end
|
|
|
|
test "the paycheck view prompts for income, then plans around it" do
|
|
create_bill(name: "Rent", amount: 2150)
|
|
|
|
get bills_url(view: "paycheck")
|
|
assert_response :success
|
|
assert_match I18n.t("bills.paycheck.empty.title"), response.body
|
|
|
|
payday = Date.current + 3
|
|
@family.recurring_transactions.create!(
|
|
name: "Paycheck", account: accounts(:depository), amount: -1840, currency: "USD",
|
|
bill_type: "income", expected_day_of_month: payday.day, anchor_date: payday,
|
|
last_occurrence_date: payday, next_expected_date: payday, status: "active", manual: true
|
|
)
|
|
|
|
get bills_url(view: "paycheck")
|
|
assert_response :success
|
|
assert_match I18n.l(payday, format: :short), response.body
|
|
assert_match I18n.t("bills.paycheck.period_source", source: "Paycheck"), response.body
|
|
assert_match "Rent", response.body
|
|
assert_match I18n.t("bills.paycheck.safe_after_bills"), response.body
|
|
end
|
|
|
|
# A lone materialized paycheck landing today collapses the planner's
|
|
# boundary list to a single date, which yields an empty plan; the page must
|
|
# treat that like no plan instead of crashing on plan.last.
|
|
test "the paycheck view survives a lone paycheck landing today" do
|
|
series = @family.recurring_transactions.create!(
|
|
name: "Paycheck", account: accounts(:depository), amount: -1840, currency: "USD",
|
|
bill_type: "income", expected_day_of_month: Date.current.day, anchor_date: Date.current,
|
|
last_occurrence_date: Date.current, next_expected_date: Date.current,
|
|
status: "active", manual: true
|
|
)
|
|
series.recurring_occurrences.where("due_on > ?", Date.current).delete_all
|
|
|
|
get bills_url(view: "paycheck")
|
|
|
|
assert_response :success
|
|
assert_match I18n.t("bills.paycheck.empty.title"), response.body
|
|
end
|
|
|
|
# Income was addable only from inside the Income plan tab, so a family that
|
|
# had declared none had no way to discover the planning half of Bills
|
|
# existed. Both halves are addable from every view, but only the half the
|
|
# view is about earns the header button; the other waits in the menu.
|
|
test "every bills view offers both add actions, and income opens an income dialog" do
|
|
%w[overview calendar paycheck all].each do |view|
|
|
get view == "overview" ? bills_url : bills_url(view: view)
|
|
|
|
header_action = view == "paycheck" ? new_recurring_transaction_path(income: true) : new_recurring_transaction_path
|
|
menu_action = view == "paycheck" ? new_recurring_transaction_path : new_recurring_transaction_path(income: true)
|
|
|
|
assert_response :success
|
|
assert_select "header" do
|
|
assert_select "a[href=?]:not([role=menuitem])", header_action
|
|
assert_select "a[href=?][role=menuitem]", menu_action
|
|
assert_select "a[href=?]:not([role=menuitem])", menu_action, count: 0
|
|
end
|
|
end
|
|
|
|
# A CTA that says income has to deliver an income form, not a bill form
|
|
# wearing a different title.
|
|
get new_recurring_transaction_url(income: true), headers: { "Turbo-Frame" => "modal" }
|
|
|
|
assert_response :success
|
|
assert_match I18n.t("recurring_transactions.new.income_title"), response.body
|
|
end
|
|
|
|
# The expansion is opened from one row, so it has to describe that row's
|
|
# cycle. It used to ask the series for its current occurrence, which is the
|
|
# earliest still-open one, so expanding a settled row reported the NEXT cycle
|
|
# as unpaid directly underneath a row marked Paid.
|
|
test "expanding a row describes that row's cycle, not the series' next one" do
|
|
bill = create_bill(name: "Streaming Plus", amount: 15.99)
|
|
settled = bill.recurring_occurrences.order(:due_on).first
|
|
entry = accounts(:depository).entries.create!(
|
|
date: settled.due_on, amount: 15.99, currency: "USD",
|
|
name: "STREAMING PLUS", entryable: Transaction.new
|
|
)
|
|
RecurringTransaction::Allocator.new(settled).allocate!(amount: "15.99", entry: entry)
|
|
assert settled.reload.paid?
|
|
|
|
later = bill.recurring_occurrences.open_status.order(:due_on).first
|
|
assert_not_nil later, "the series has a later, unpaid cycle to be confused with"
|
|
assert_not_equal settled.id, later.id
|
|
|
|
get bill_url(bill, display: "pane", frame: "pane_x", occurrence: settled.id)
|
|
|
|
assert_response :success
|
|
assert_match I18n.t("bills.summary.paid_headline", amount: "$15.99"), response.body
|
|
assert_no_match I18n.t("bills.summary.remaining", amount: "$15.99"), response.body,
|
|
"the settled row must not report itself as still owing"
|
|
end
|
|
|
|
# Without an occurrence the page has no cycle in mind, so the series answers.
|
|
test "the bill page with no occurrence falls back to the series" do
|
|
bill = create_bill(name: "Streaming Plus", amount: 15.99)
|
|
|
|
get bill_url(bill, display: "pane", frame: "pane_x")
|
|
|
|
assert_response :success
|
|
assert_match I18n.t("bills.summary.remaining", amount: "$15.99"), response.body
|
|
end
|
|
|
|
# The id is resolved through the series, so one from another bill cannot be
|
|
# borrowed to render someone else's cycle.
|
|
test "an occurrence id from another bill is ignored" do
|
|
mine = create_bill(name: "Streaming Plus", amount: 15.99)
|
|
other = create_bill(name: "Gym", amount: 40)
|
|
stranger = other.recurring_occurrences.order(:due_on).first
|
|
|
|
get bill_url(mine, display: "pane", frame: "pane_x", occurrence: stranger.id)
|
|
|
|
assert_response :success
|
|
assert_match I18n.t("bills.summary.remaining", amount: "$15.99"), response.body
|
|
assert_no_match(/\$40\.00/, response.body)
|
|
end
|
|
|
|
# Detection has been creating recurring rows from bank data since long before
|
|
# Bills existed, so anyone upgrading meets a page of bills nobody confirmed.
|
|
test "a family that has never worked with Bills is told where its bills came from" do
|
|
create_bill(name: "Rent", amount: 2150)
|
|
create_bill(name: "Streaming", amount: 20)
|
|
|
|
get bills_url
|
|
|
|
assert_response :success
|
|
assert_match I18n.t("bills.index.detected_review", count: 2), response.body
|
|
end
|
|
|
|
# The prompt carries no stored state, so it has to clear itself off evidence
|
|
# that the user has worked with Bills. Each of these is sufficient on its own.
|
|
test "the prompt clears itself once the user has worked with Bills" do
|
|
detected = create_bill(name: "Rent", amount: 2150)
|
|
|
|
get bills_url
|
|
assert_match I18n.t("bills.index.detected_review", count: 1), response.body
|
|
|
|
# Declaring a bill by hand.
|
|
declared = declare_bill(name: "Water", amount: 45, due: Date.current + 3)
|
|
get bills_url
|
|
assert_no_match I18n.t("bills.index.detected_review", count: 1), response.body
|
|
assert_no_match I18n.t("bills.index.detected_review", count: 2), response.body
|
|
declared.destroy!
|
|
|
|
# Dismissing a suggestion.
|
|
detected.update!(status: :ended)
|
|
get bills_url
|
|
assert_no_match I18n.t("bills.index.detected_review", count: 1), response.body
|
|
detected.update!(status: :active)
|
|
|
|
# Recording a payment themselves.
|
|
occurrence = detected.recurring_occurrences.order(:due_on).first
|
|
RecurringTransaction::Allocator.new(occurrence).allocate!(amount: "10")
|
|
get bills_url
|
|
|
|
assert_response :success
|
|
assert_no_match I18n.t("bills.index.detected_review", count: 1), response.body
|
|
end
|
|
|
|
# The page's whole job. A single "Bills $695.60" against $357.48 of visible
|
|
# rows is a number nothing on screen can account for, so due and reserved
|
|
# are stated apart and their sum is never shown at all.
|
|
test "the paycheck view states due and reserved separately and never their sum" do
|
|
payday = Date.current + 3
|
|
declare_income(name: "Frito Lay", amount: -1200, payday: payday)
|
|
declare_bill(name: "Streaming", amount: 20, due: Date.current + 5)
|
|
# Bigger than one paycheck and due in the next one, so its overflow is
|
|
# genuinely reserved out of the first.
|
|
declare_bill(name: "Insurance", amount: 1500, due: payday + 31)
|
|
|
|
get bills_url(view: "paycheck")
|
|
|
|
assert_response :success
|
|
assert_match I18n.t("bills.paycheck.due_this_period"), response.body
|
|
assert_match I18n.t("bills.paycheck.reserved_ahead"), response.body
|
|
assert_match I18n.t("bills.paycheck.safe_after_bills"), response.body
|
|
|
|
paycheck = RecurringTransaction::PaycheckPlanner.new(@family, user: @user).plan
|
|
.find { |period| period.income.positive? }
|
|
assert paycheck.due_total.positive?
|
|
assert paycheck.reserved_total.positive?
|
|
assert_match money_string(paycheck.due_total), response.body
|
|
assert_match money_string(paycheck.reserved_total), response.body
|
|
|
|
# Asserting the combined figure is simply ABSENT does not work: period
|
|
# totals can collide across periods by arithmetic, so the test would pass
|
|
# or fail on a coincidence. What is actually being
|
|
# pinned is that no label survives for a combined bills figure to render
|
|
# under -- which fails the moment one is reintroduced.
|
|
assert_nil I18n.t("bills.paycheck.bills_that_period", default: nil)
|
|
assert_nil I18n.t("bills.paycheck.obligations_line", default: nil)
|
|
end
|
|
|
|
# The window before the first payday has no income to allocate, so it is
|
|
# reported above the timeline as a warning rather than drawn as a pay period
|
|
# with an empty paycheck. The shortfall names the obligation, not the slice
|
|
# the planner parked in this window, and never asks the user to operate on
|
|
# the allocation itself.
|
|
test "the gap before the first payday is a banner, not a period in the timeline" do
|
|
# The banner reports a shortfall, and a shortfall now means the cash cannot
|
|
# reach, not merely that the window earns nothing. Pin the balance under the
|
|
# bill so the condition this test is about actually holds.
|
|
@family.accounts.where(accountable_type: "Depository").update_all(balance: 100)
|
|
declare_income(name: "Frito Lay", amount: -1200, payday: Date.current + 4)
|
|
declare_bill(name: "Watson Property", amount: 2150, due: Date.current + 3)
|
|
|
|
get bills_url(view: "paycheck")
|
|
|
|
assert_response :success
|
|
plan = RecurringTransaction::PaycheckPlanner.new(@family, user: @user).plan
|
|
bridge = plan.find(&:bridge?)
|
|
|
|
assert_match I18n.t("bills.paycheck.shortfall_label"), response.body
|
|
assert_match I18n.t("bills.paycheck.shortfall_amount", amount: money_string(bridge.shortfall)), response.body
|
|
assert_match I18n.t("bills.paycheck.shortfall_largest"), response.body
|
|
assert_match "Watson Property", response.body
|
|
assert_match I18n.t("bills.paycheck.review_plan"), response.body
|
|
|
|
# The banner is the whole report on that window, so the timeline holds one
|
|
# entry per real paycheck and no empty-paycheck row.
|
|
assert plan.count { |period| !period.bridge? }.positive?
|
|
assert_no_match I18n.t("bills.paycheck.before_next_paycheck", date: I18n.l(bridge.ends_on + 1, format: :short)),
|
|
response.body, "the leading window is reported by the banner, not drawn as a pay period"
|
|
|
|
assert_no_match(/set-aside|set aside/i, response.body,
|
|
"nothing on this page asks the user to perform the planner's own bookkeeping")
|
|
end
|
|
|
|
# The strip read the stored next_expected_date column while the plan read
|
|
# occurrences, so one series could name two different next paydays on one
|
|
# screen.
|
|
test "the income strip names the same payday the plan does" do
|
|
payday = Date.current + 6
|
|
income = declare_income(name: "Frito Lay", amount: -1200, payday: payday)
|
|
income.update_columns(next_expected_date: Date.current - 1)
|
|
|
|
get bills_url(view: "paycheck")
|
|
|
|
assert_response :success
|
|
assert_match I18n.t("bills.paycheck.income_next_payday", date: I18n.l(payday, format: :short)), response.body
|
|
assert_no_match(/#{Regexp.escape(I18n.l(Date.current - 1, format: :short))}/, response.body,
|
|
"the stale column date must not appear anywhere on the page")
|
|
end
|
|
|
|
# An auto-detected inflow sat in the list looking exactly like a real payday
|
|
# source while moving no number on the page.
|
|
test "income the planner cannot use says so" do
|
|
declare_income(name: "Frito Lay", amount: -1200, payday: Date.current + 3)
|
|
detected = declare_income(name: "To Car Vault", amount: -0.01, payday: Date.current + 2)
|
|
detected.update!(manual: false)
|
|
# Something to cover, or the page is the all-clear state and no period
|
|
# renders a heading at all.
|
|
declare_bill(name: "Streaming", amount: 20, due: Date.current + 5)
|
|
|
|
get bills_url(view: "paycheck")
|
|
|
|
assert_response :success
|
|
assert_match I18n.t("bills.paycheck.income_detected"), response.body
|
|
assert_match I18n.l(Date.current + 3, format: :short), response.body
|
|
assert_match I18n.t("bills.paycheck.period_source", source: "Frito Lay"), response.body,
|
|
"the declared source heads the period, so the detected one two days earlier cannot have sliced it"
|
|
end
|
|
|
|
# Four cards each saying "nothing due" is not a better way to say that
|
|
# everything is covered.
|
|
test "an income schedule with nothing to cover renders one state, not empty cards" do
|
|
declare_income(name: "Frito Lay", amount: -1200, payday: Date.current + 3)
|
|
|
|
get bills_url(view: "paycheck")
|
|
|
|
assert_response :success
|
|
assert_match I18n.t("bills.paycheck.all_clear.title"), response.body
|
|
assert_no_match I18n.t("bills.paycheck.reserved_ahead"), response.body
|
|
end
|
|
|
|
test "every overview row carries its own empty expansion frame" do
|
|
create_bill(name: "Rent", amount: 2150)
|
|
|
|
get bills_url
|
|
|
|
assert_response :success
|
|
assert_match(/<turbo-frame[^>]*id="pane_recurring_occurrence_/, response.body)
|
|
assert_match(/data-turbo-frame="pane_recurring_occurrence_/, response.body)
|
|
end
|
|
|
|
test "the expansion renders into the requesting row frame and can collapse" do
|
|
bill = create_bill(name: "Rent", amount: 2150)
|
|
|
|
get bill_url(bill, display: "pane", frame: "pane_recurring_occurrence_abc123")
|
|
assert_response :success
|
|
assert_match(/<turbo-frame[^>]*id="pane_recurring_occurrence_abc123"/, response.body)
|
|
|
|
get bill_url(bill, display: "pane", frame: "pane_recurring_occurrence_abc123", close: 1)
|
|
assert_response :success
|
|
assert_match(/<turbo-frame[^>]*id="pane_recurring_occurrence_abc123"><\/turbo-frame>/, response.body)
|
|
assert_no_match I18n.t("bills.detail.rules"), response.body
|
|
end
|
|
|
|
test "the detail pane tells the bill's story inside its frame" do
|
|
bill = create_bill(name: "Rent", amount: 2150)
|
|
occurrence = bill.recurring_occurrences.order(:due_on).first
|
|
entry = accounts(:depository).entries.create!(
|
|
date: Date.current, amount: 2150, currency: "USD", name: "WATSON PROPERTY",
|
|
entryable: Transaction.new
|
|
)
|
|
RecurringTransaction::Allocator.new(occurrence).allocate!(amount: "2150", entry: entry)
|
|
|
|
get bill_url(bill, display: "pane")
|
|
|
|
assert_response :success
|
|
assert_match(/<turbo-frame[^>]*id="bill_detail"/, response.body, "no frame param falls back to a stable id")
|
|
assert_match I18n.t("bills.detail.recent_payments"), response.body
|
|
assert_match "WATSON PROPERTY", response.body
|
|
|
|
# The expansion answers "what is going on with this bill" and stops there.
|
|
# The matching rules and the per-year table are configuration and
|
|
# reference material, and they belong to the bill's page.
|
|
assert_no_match I18n.t("bills.detail.rules"), response.body,
|
|
"the expansion is not a second detail view"
|
|
assert_no_match I18n.t("bills.detail.key_metrics"), response.body
|
|
assert_no_match(/<html/, response.body, "the pane renders frame-only, no layout")
|
|
end
|
|
|
|
test "the paycheck view lists declared income with an edit affordance" do
|
|
payday = Date.current + 3
|
|
income = @family.recurring_transactions.create!(
|
|
name: "Paycheck", account: accounts(:depository), amount: -1840, currency: "USD",
|
|
bill_type: "income", expected_day_of_month: payday.day, anchor_date: payday,
|
|
last_occurrence_date: payday, next_expected_date: payday, status: "active", manual: true
|
|
)
|
|
|
|
get bills_url(view: "paycheck")
|
|
|
|
assert_response :success
|
|
assert_match I18n.t("bills.paycheck.income_section_title"), response.body
|
|
assert_match edit_recurring_transaction_path(income), response.body
|
|
assert_match bill_path(income), response.body, "income opens the same drawer as bills for pause and delete"
|
|
end
|
|
|
|
# Subscriptions was a filter promoted to navigation. Everything it answered
|
|
# still has to be answerable, and its old links still have to work.
|
|
test "the old subscriptions link lands on All bills, filtered" do
|
|
get bills_url(view: "subscriptions")
|
|
assert_redirected_to bills_path(view: "all", q: { bill_type: "subscription" })
|
|
end
|
|
|
|
test "filtering All bills by subscription rolls up cost and lists price changes" do
|
|
sub = create_bill(name: "STREAMFLIX", amount: 24.99)
|
|
sub.update!(bill_type: "subscription", trial_ends_on: Date.current + 5)
|
|
sub.recurring_price_changes.create!(
|
|
effective_on: 2.months.ago.to_date, previous_amount: 19.99, new_amount: 24.99,
|
|
currency: "USD", source: "detected"
|
|
)
|
|
|
|
get bills_url(view: "all", q: { bill_type: "subscription" })
|
|
|
|
assert_response :success
|
|
assert_match "STREAMFLIX", response.body
|
|
assert_match I18n.t("bills.all.subscription_monthly"), response.body
|
|
assert_match I18n.t("bills.all.subscription_annual"), response.body
|
|
assert_match I18n.t("bills.all.subscription_price_changes"), response.body
|
|
end
|
|
|
|
test "the rollup stays out of the way when the filter is not on subscriptions" do
|
|
sub = create_bill(name: "STREAMFLIX", amount: 24.99)
|
|
sub.update!(bill_type: "subscription")
|
|
|
|
get bills_url(view: "all")
|
|
|
|
assert_response :success
|
|
assert_match "STREAMFLIX", response.body
|
|
assert_no_match I18n.t("bills.all.subscription_monthly"), response.body,
|
|
"a subscription rollup on an unfiltered list would be answering a question nobody asked"
|
|
end
|
|
|
|
test "trial, renewal and price history follow the bill into its detail" do
|
|
sub = create_bill(name: "STREAMFLIX", amount: 24.99)
|
|
sub.update!(bill_type: "subscription", trial_ends_on: Date.current + 5,
|
|
renews_on: Date.current + 30)
|
|
sub.recurring_price_changes.create!(
|
|
effective_on: 2.months.ago.to_date, previous_amount: 19.99, new_amount: 24.99,
|
|
currency: "USD", source: "detected"
|
|
)
|
|
|
|
# Trial and renewal are STATE: they change what you might do about the bill
|
|
# today, so both routes to it must say so. Price history is the record of
|
|
# how it got here, which is the page's job.
|
|
{ "page" => bill_url(sub),
|
|
"expansion" => bill_url(sub, display: "pane", frame: "x") }.each do |label, url|
|
|
get url
|
|
assert_response :success
|
|
assert_match I18n.t("bills.detail.trial_chip", date: I18n.l(Date.current + 5, format: :short)),
|
|
response.body, "the #{label} lost the trial chip"
|
|
assert_match I18n.t("bills.detail.renews_chip", date: I18n.l(Date.current + 30, format: :short)),
|
|
response.body, "the #{label} lost the renewal date"
|
|
end
|
|
|
|
get bill_url(sub)
|
|
assert_match I18n.t("bills.detail.price_changes"), response.body,
|
|
"the bill's page keeps the price history"
|
|
end
|
|
|
|
test "notices surface trials, renewals and price changes" do
|
|
sub = create_bill(name: "STREAMFLIX", amount: 24.99)
|
|
sub.update!(bill_type: "subscription", trial_ends_on: Date.current + 3)
|
|
sub.recurring_price_changes.create!(
|
|
effective_on: Date.current - 5, previous_amount: 19.99, new_amount: 24.99,
|
|
currency: "USD", source: "detected"
|
|
)
|
|
|
|
get bills_url
|
|
|
|
assert_response :success
|
|
assert_match "trial ends #{I18n.l(Date.current + 3, format: :long)}", response.body
|
|
assert_match "changed price", response.body
|
|
end
|
|
|
|
test "the ical feed serves upcoming occurrences with a member token and rejects garbage" do
|
|
create_bill(name: "Rent", amount: 2150)
|
|
|
|
get bills_feed_url(token: @family.bills_feed_token_for(@user))
|
|
assert_response :success
|
|
assert_match "BEGIN:VCALENDAR", response.body
|
|
assert_match "Rent", response.body
|
|
|
|
get bills_feed_url(token: "tampered")
|
|
assert_response :not_found
|
|
end
|
|
|
|
test "reset_feed_token rotates the family token and returns to the calendar" do
|
|
old_token = @family.bills_feed_token!
|
|
|
|
post reset_feed_token_bills_url
|
|
|
|
assert_redirected_to bills_path(view: "calendar")
|
|
assert_equal I18n.t("bills.reset_feed_token.done"), flash[:notice]
|
|
assert_not_equal old_token, @family.reload.bills_feed_token
|
|
end
|
|
|
|
test "reset_feed_token refuses GET" do
|
|
# GET /bills/reset_feed_token falls through to bills#show (id:
|
|
# "reset_feed_token"), which 404s on lookup; the point is that it can
|
|
# never reach the reset action.
|
|
route = Rails.application.routes.recognize_path("/bills/reset_feed_token", method: :get)
|
|
assert_equal "show", route[:action], "GET must never reach the reset action"
|
|
|
|
get "/bills/reset_feed_token"
|
|
assert_response :not_found
|
|
end
|
|
|
|
test "index renders an empty state with no bills" do
|
|
get bills_url
|
|
|
|
assert_response :success
|
|
assert_match I18n.t("bills.index.empty.title"), response.body
|
|
end
|
|
|
|
# A cancellation date does not stop the schedule, so the same bill can read
|
|
# "Cancelled" on one surface and "Overdue" on another. The detail surfaces
|
|
# have to admit that rather than let the two claims sit apart.
|
|
test "a cancelled but still-scheduled bill says so, with the action that stops it" do
|
|
bill = create_bill(name: "Streamly", amount: 15, bill_type: "subscription",
|
|
cancelled_on: 3.days.ago.to_date)
|
|
assert bill.cancelled_on.present? && bill.active?, "premise: cancelled yet still running"
|
|
|
|
get bill_url(bill)
|
|
assert_response :success
|
|
assert_includes response.body,
|
|
I18n.t("bills.cancelled_still_scheduled", date: I18n.l(bill.cancelled_on, format: :short))
|
|
assert_includes response.body, toggle_status_recurring_transaction_path(bill)
|
|
end
|
|
|
|
test "a paused bill does not repeat the cancellation notice" do
|
|
bill = create_bill(name: "Streamly", amount: 15, bill_type: "subscription",
|
|
cancelled_on: 3.days.ago.to_date, status: "paused")
|
|
get bill_url(bill)
|
|
assert_response :success
|
|
refute_includes response.body,
|
|
I18n.t("bills.cancelled_still_scheduled", date: I18n.l(bill.cancelled_on, format: :short))
|
|
end
|
|
|
|
# The row carries one line of context, so anything on it has to earn the
|
|
# space. These pin the three things that were not earning it.
|
|
test "the paid-from account is quiet when every bill uses the same one" do
|
|
create_bill(name: "Netflix", amount: 15.99)
|
|
create_bill(name: "Spotify", amount: 11.99)
|
|
assert_equal 1, @family.recurring_transactions.distinct.count(:account_id),
|
|
"premise: a single account across all bills"
|
|
|
|
get bills_url
|
|
refute_includes response.body, I18n.t("bills.paid_from", account: accounts(:depository).name),
|
|
"repeating one account name down every row says nothing"
|
|
end
|
|
|
|
test "the paid-from account returns as soon as it tells rows apart" do
|
|
create_bill(name: "Netflix", amount: 15.99)
|
|
create_bill(name: "Amex bill", amount: 40, account: accounts(:credit_card))
|
|
assert_operator @family.recurring_transactions.distinct.count(:account_id), :>, 1
|
|
|
|
get bills_url
|
|
assert_includes response.body, I18n.t("bills.paid_from", account: accounts(:depository).name)
|
|
assert_includes response.body, I18n.t("bills.paid_from", account: accounts(:credit_card).name)
|
|
end
|
|
|
|
test "autopay reads on the bill's line rather than in the action slot" do
|
|
create_bill(name: "Netflix", amount: 15.99, autopay: true,
|
|
payment_url: "https://example.com/pay")
|
|
|
|
get bills_url
|
|
assert_includes response.body, I18n.t("recurring_transactions.pay_action.autopay")
|
|
refute_includes response.body, "refresh-cw",
|
|
"autopay is a state; the row's one action position belongs to a verb"
|
|
assert_includes response.body, "https://example.com/pay",
|
|
"the portal stays reachable, just not as the row's headline action"
|
|
end
|
|
|
|
# Pause, inactive and paused were three words for one thing, and the filter
|
|
# asked for the one the button never writes.
|
|
test "a bill you paused is findable under Paused" do
|
|
bill = create_bill(name: "Gym", amount: 40)
|
|
post toggle_status_recurring_transaction_path(bill)
|
|
assert_equal "inactive", bill.reload.status,
|
|
"premise: the Pause button stores inactive, not paused"
|
|
|
|
get bills_url(view: "all", q: { status: "paused" })
|
|
assert_includes response.body, "Gym",
|
|
"the filter has to ask for what the button actually writes"
|
|
end
|
|
|
|
test "the word for a paused bill is the same everywhere the user sees it" do
|
|
bill = create_bill(name: "Gym", amount: 40)
|
|
post toggle_status_recurring_transaction_path(bill)
|
|
|
|
# The badge, the filter option and the confirmation all have to agree.
|
|
# "Pause" stays the verb on the button; "Paused" is the state.
|
|
state = I18n.t("recurring_transactions.status.#{bill.reload.status}")
|
|
assert_equal "Paused", state
|
|
assert_equal state, I18n.t("bills.all.status_filters.paused")
|
|
assert_match(/#{state}/, I18n.t("recurring_transactions.marked_inactive"))
|
|
end
|
|
|
|
# toggle_status is shared with Settings > Recurring, which manages income and
|
|
# transfers too, so its confirmation must not talk about bills.
|
|
test "the pause confirmation does not assume the record is a bill" do
|
|
[ "recurring_transactions.marked_inactive", "recurring_transactions.marked_active" ].each do |key|
|
|
refute_match(/bills?/i, I18n.t(key),
|
|
"#{key} is shown on the shared Recurring surface as well as Bills")
|
|
end
|
|
end
|
|
|
|
test "the status filter does not offer words for states nobody can reach" do
|
|
# `ended` only ever comes from dismissing a suggestion, so it is labelled
|
|
# for what produced it rather than as a bill lifecycle.
|
|
assert_equal "Dismissed", I18n.t("bills.all.status_filters.ended")
|
|
assert_equal "Dismissed", I18n.t("recurring_transactions.status.ended")
|
|
end
|
|
|
|
# The expansion and the drawer used to be two templates over one action, so
|
|
# they drifted, and the fix made them render the SAME partial -- which traded
|
|
# a disagreement for a duplication: two surfaces answering one question.
|
|
#
|
|
# They now answer different ones. What has to stay true is that nothing was
|
|
# lost on the way, and that the shallower surface never quietly grows into
|
|
# the deeper one again. So: the expansion is a strict subset of the page, and
|
|
# every section the old shared partial rendered still exists somewhere.
|
|
test "the expansion is a subset of the bill's page, and nothing was dropped" do
|
|
bill = create_bill(name: "Power Co", amount: 80, notes: "Account 4821")
|
|
past = bill.recurring_occurrences.create!(
|
|
family: @family, original_due_on: 2.months.ago.to_date,
|
|
due_on: 2.months.ago.to_date, currency: "USD"
|
|
)
|
|
entry = accounts(:depository).entries.create!(
|
|
date: 2.months.ago.to_date, amount: 78.50, currency: "USD",
|
|
name: "POWER CO AUTOPAY", entryable: Transaction.new
|
|
)
|
|
RecurringTransaction::Allocator.new(past).allocate!(amount: "78.50", entry: entry)
|
|
bill.recurring_price_changes.create!(
|
|
effective_on: 3.months.ago.to_date, previous_amount: 70, new_amount: 80,
|
|
currency: "USD", source: "detected"
|
|
)
|
|
|
|
get bill_url(bill)
|
|
assert_response :success
|
|
page = response.body
|
|
|
|
get bill_url(bill, display: "pane", frame: "x"), headers: { "Turbo-Frame" => "x" }
|
|
assert_response :success
|
|
pane = response.body
|
|
|
|
# Every section the shared partial used to render still has a home.
|
|
everything = %w[rules history_title average annualized ytd upcoming
|
|
recent_payments history notes last_account key_metrics
|
|
price_changes]
|
|
homeless = everything.reject { |key| page.include?(I18n.t("bills.detail.#{key}")) }
|
|
assert_empty homeless, "relocating the detail must not delete any of it"
|
|
|
|
# And the expansion adds nothing of its own that the page lacks.
|
|
shown_in_pane = everything.select { |key| pane.include?(I18n.t("bills.detail.#{key}")) }
|
|
assert_equal shown_in_pane, shown_in_pane & everything.select { |key| page.include?(I18n.t("bills.detail.#{key}")) },
|
|
"the expansion must stay a subset, never a second detail view"
|
|
|
|
[ "POWER CO AUTOPAY", "$78.50" ].each do |fact|
|
|
assert_includes page, fact, "the page is missing #{fact}"
|
|
assert_includes pane, fact, "the expansion is missing #{fact}"
|
|
end
|
|
assert_includes page, "Account 4821", "notes belong to the page"
|
|
end
|
|
|
|
# "Something changed" is only useful if the thing you can still act on is
|
|
# the thing you see first. Notices used to sort by date ascending, so a
|
|
# month-old one-dollar rise outranked a trial converting tomorrow.
|
|
test "notices lead with what is still actionable, not with what is oldest" do
|
|
trial = create_bill(name: "Streamflix", amount: 20)
|
|
trial.update!(bill_type: "subscription", trial_ends_on: Date.current + 1)
|
|
|
|
big = create_bill(name: "Gym", amount: 90)
|
|
big.recurring_price_changes.create!(effective_on: 20.days.ago.to_date,
|
|
previous_amount: 90, new_amount: 200, currency: "USD", source: "detected")
|
|
|
|
small = create_bill(name: "Power", amount: 60)
|
|
small.recurring_price_changes.create!(effective_on: 30.days.ago.to_date,
|
|
previous_amount: 60, new_amount: 61, currency: "USD", source: "detected")
|
|
|
|
get bills_url
|
|
assert_response :success
|
|
body = response.body
|
|
|
|
trial_at = body.index("Streamflix")
|
|
big_at = body.index("Gym changed price")
|
|
small_at = body.index("Power changed price")
|
|
|
|
assert trial_at < small_at, "a trial converting tomorrow must outrank a month-old $1 rise"
|
|
assert big_at < small_at, "a 122% rise must outrank a 2% one"
|
|
end
|
|
|
|
test "small changes collapse rather than pushing the worklist down" do
|
|
urgent = create_bill(name: "Streamflix", amount: 20)
|
|
urgent.update!(bill_type: "subscription", trial_ends_on: Date.current + 1)
|
|
|
|
3.times do |i|
|
|
quiet = create_bill(name: "Utility #{i}", amount: 60 + i)
|
|
quiet.recurring_price_changes.create!(effective_on: (20 + i).days.ago.to_date,
|
|
previous_amount: 60 + i, new_amount: 61 + i, currency: "USD", source: "detected")
|
|
end
|
|
|
|
get bills_url
|
|
assert_response :success
|
|
assert_match I18n.t("bills.index.notices_routine", count: 3), response.body,
|
|
"the quiet ones collapse behind a count"
|
|
# Collapsed, not dropped: a hidden notice is still a dead end.
|
|
3.times { |i| assert_match "Utility #{i}", response.body }
|
|
end
|
|
|
|
test "a price notice says how big the change was" do
|
|
bill = create_bill(name: "Gym", amount: 90)
|
|
bill.recurring_price_changes.create!(effective_on: 5.days.ago.to_date,
|
|
previous_amount: 90, new_amount: 200, currency: "USD", source: "detected")
|
|
|
|
get bills_url
|
|
assert_response :success
|
|
assert_match "+122%", response.body,
|
|
"from-and-to alone never said whether a change was worth caring about"
|
|
end
|
|
|
|
# Within a group the order still has to mean something: the partition
|
|
# separates urgent from routine, but only the sort decides what leads
|
|
# inside each half.
|
|
test "among changes of equal weight the most recent leads" do
|
|
[ [ "Oldest", 30 ], [ "Middle", 20 ], [ "Newest", 5 ] ].each do |name, days|
|
|
bill = create_bill(name: name, amount: 60)
|
|
bill.recurring_price_changes.create!(effective_on: days.days.ago.to_date,
|
|
previous_amount: 60, new_amount: 61, currency: "USD", source: "detected")
|
|
end
|
|
|
|
get bills_url
|
|
assert_response :success
|
|
positions = %w[Newest Middle Oldest].map { |n| response.body.index("#{n} changed price") }
|
|
assert_equal positions.sort, positions,
|
|
"equally small changes should read newest first, not oldest first"
|
|
end
|
|
|
|
# --- Onboarding: detection from the Bills page ---
|
|
|
|
test "empty page with transaction history offers detection beside Add bill" do
|
|
create_transaction_entry(name: "Coffee", amount: 20, date: Date.current)
|
|
|
|
get bills_url
|
|
|
|
assert_response :success
|
|
assert_select "a[href=?][data-turbo-method=post]", detect_bills_path
|
|
assert_match I18n.t("bills.index.empty.action"), response.body
|
|
end
|
|
|
|
test "empty page with no transactions hides detection and explains why" do
|
|
Entry.where(account: @family.accounts).delete_all
|
|
|
|
get bills_url
|
|
|
|
assert_response :success
|
|
assert_select "a[href=?]", detect_bills_path, count: 0
|
|
assert_match I18n.t("bills.index.empty.no_history_description"), response.body
|
|
end
|
|
|
|
test "detect creates suggestions, counts only them, and the strip offers review" do
|
|
3.times do |i|
|
|
create_transaction_entry(name: "GYM MEMBERSHIP", amount: 40, date: Date.current - i.months)
|
|
end
|
|
|
|
post detect_bills_url
|
|
|
|
assert_redirected_to bills_path
|
|
assert_equal I18n.t("bills.detect.found", count: 1), flash[:notice]
|
|
|
|
follow_redirect!
|
|
assert_match "GYM MEMBERSHIP", response.body
|
|
assert_match I18n.t("recurring_transactions.suggested.confirm"), response.body
|
|
end
|
|
|
|
test "detect does not resurrect a dismissed pattern" do
|
|
# Same identity as the entries below (name, currency, account, blank
|
|
# dedup scope): the ended tombstone claims the pattern and blocks it.
|
|
create_bill(name: "GYM MEMBERSHIP", amount: 40, status: "ended", dedup_scope: "", manual: false)
|
|
3.times do |i|
|
|
create_transaction_entry(name: "GYM MEMBERSHIP", amount: 40, date: Date.current - i.months)
|
|
end
|
|
|
|
post detect_bills_url
|
|
|
|
assert_equal I18n.t("bills.detect.none_found"), flash[:notice]
|
|
assert_equal 0, @family.recurring_transactions.suggested.count
|
|
end
|
|
|
|
test "detect refuses GET" do
|
|
# GET /bills/detect falls through to bills#show (id: "detect"), which
|
|
# 404s on lookup; the point is that it can never reach the detect action.
|
|
route = Rails.application.routes.recognize_path("/bills/detect", method: :get)
|
|
assert_equal "show", route[:action], "GET must never reach the detect action"
|
|
|
|
get "/bills/detect"
|
|
assert_response :not_found
|
|
end
|
|
|
|
test "detect on an upgraded instance reconstructs paid history" do
|
|
last_month_ninth = Date.current.beginning_of_month + 8.days - 1.month
|
|
bill = create_bill(name: "CITY WATER", amount: 80, dedup_scope: "",
|
|
expected_day_of_month: 9,
|
|
last_occurrence_date: last_month_ninth,
|
|
next_expected_date: last_month_ninth + 1.month)
|
|
create_transaction_entry(name: "CITY WATER", amount: 80, date: last_month_ninth)
|
|
RecurringOccurrence.where(recurring_transaction: bill).delete_all
|
|
|
|
post detect_bills_url
|
|
|
|
assert bill.recurring_occurrences.paid.where(due_on: last_month_ninth).exists?,
|
|
"the first-run backfill closes history a real entry anchors"
|
|
end
|
|
|
|
test "index materializes occurrences for an upgraded instance" do
|
|
bill = create_bill(name: "Rent", amount: 1200)
|
|
RecurringOccurrence.where(recurring_transaction: bill).delete_all
|
|
|
|
get bills_url
|
|
|
|
assert_response :success
|
|
assert_operator bill.recurring_occurrences.count, :>, 0
|
|
assert_match "Rent", response.body
|
|
end
|
|
|
|
test "index creates no occurrences when the family has no active series" do
|
|
get bills_url
|
|
|
|
assert_response :success
|
|
assert_equal 0, @family.recurring_occurrences.count
|
|
end
|
|
|
|
test "an overdue-only family sees its overdue bill, not the empty state" do
|
|
overdue_day = 10.days.ago.to_date
|
|
bill = create_bill(name: "Late bill", amount: 75,
|
|
expected_day_of_month: overdue_day.day,
|
|
last_occurrence_date: 2.months.ago.to_date,
|
|
next_expected_date: overdue_day)
|
|
# Strip the future so only the overdue occurrence remains: the empty-state
|
|
# condition used to ignore @overdue and rendered both at once.
|
|
bill.recurring_occurrences.where("due_on > ?", Date.current).delete_all
|
|
|
|
get bills_url
|
|
|
|
assert_response :success
|
|
assert_match "Late bill", response.body
|
|
assert_no_match I18n.t("bills.index.empty.title"), response.body
|
|
end
|
|
|
|
test "AI chips and the review button need both consent and a provider" do
|
|
Provider::Registry.stubs(:preferred_llm_provider).returns(Object.new)
|
|
get bills_url
|
|
assert_response :success
|
|
# Apostrophe-free fragment: response bodies HTML-escape apostrophes.
|
|
assert_match "due before my next paycheck", response.body
|
|
assert_match I18n.t("bills.index.review_with_ai"), response.body
|
|
# A menu item, not a third header button: it still has to POST into the
|
|
# sidebar chat frame and open the sidebar, or it seeds a chat nobody sees.
|
|
assert_select "header div[role=menu] form[action=?]", ai_review_bills_path do
|
|
assert_select "button[data-turbo-frame=?][data-action=?]", "sidebar_chat", "app-layout#openRightSidebar"
|
|
end
|
|
|
|
@user.update!(ai_enabled: false)
|
|
get bills_url
|
|
assert_response :success
|
|
assert_no_match "due before my next paycheck", response.body
|
|
assert_no_match I18n.t("bills.index.review_with_ai"), response.body
|
|
|
|
# Consent without a configured provider is a button to a dead chat.
|
|
@user.update!(ai_enabled: true)
|
|
Provider::Registry.stubs(:preferred_llm_provider).returns(nil)
|
|
get bills_url
|
|
assert_response :success
|
|
assert_no_match "due before my next paycheck", response.body
|
|
assert_no_match I18n.t("bills.index.review_with_ai"), response.body
|
|
end
|
|
|
|
test "the bill page offers smart configure only when AI is available" do
|
|
bill = create_bill(name: "Power Co", amount: 80)
|
|
|
|
Provider::Registry.stubs(:preferred_llm_provider).returns(Object.new)
|
|
get bill_url(bill)
|
|
assert_response :success
|
|
assert_match smart_configuration_bill_path(bill), response.body
|
|
|
|
Provider::Registry.stubs(:preferred_llm_provider).returns(nil)
|
|
get bill_url(bill)
|
|
assert_response :success
|
|
assert_no_match smart_configuration_bill_path(bill), response.body
|
|
end
|
|
|
|
test "price changes on accounts the member cannot reach stay out of notices and the rollup" do
|
|
hidden = create_bill(name: "Hidden brokerage sub", amount: 24.99, account: accounts(:investment))
|
|
hidden.update!(bill_type: "subscription")
|
|
hidden.recurring_price_changes.create!(
|
|
effective_on: Date.current - 5, previous_amount: 19.99, new_amount: 24.99,
|
|
currency: "USD", source: "detected"
|
|
)
|
|
visible = create_bill(name: "Visible sub", amount: 9.99)
|
|
visible.update!(bill_type: "subscription")
|
|
visible.recurring_price_changes.create!(
|
|
effective_on: Date.current - 5, previous_amount: 7.99, new_amount: 9.99,
|
|
currency: "USD", source: "detected"
|
|
)
|
|
|
|
member = users(:family_member)
|
|
member.update!(preferences: (member.preferences || {}).merge("preview_features_enabled" => true))
|
|
sign_in member
|
|
|
|
get bills_url
|
|
assert_response :success
|
|
assert_match "Visible sub", response.body
|
|
assert_no_match "Hidden brokerage sub", response.body
|
|
|
|
get bills_url(view: "all", q: { bill_type: "subscription" })
|
|
assert_response :success
|
|
assert_no_match "Hidden brokerage sub", response.body
|
|
end
|
|
|
|
test "the suggested strip only shows series on accounts the member can reach" do
|
|
create_suggested(name: "Hidden brokerage sub", account: accounts(:investment))
|
|
create_suggested(name: "Visible sub", account: accounts(:depository))
|
|
|
|
member = users(:family_member)
|
|
member.update!(preferences: (member.preferences || {}).merge("preview_features_enabled" => true))
|
|
sign_in member
|
|
get bills_url
|
|
|
|
assert_response :success
|
|
assert_match "Visible sub", response.body
|
|
assert_no_match "Hidden brokerage sub", response.body
|
|
end
|
|
|
|
# The matcher no longer suggests income, but a suggestion written before
|
|
# that rule can still sit on the row. The queue must not render it; the
|
|
# same pending state on a bill must.
|
|
test "a pre-existing income suggestion stays out of the payment review queue" do
|
|
payday = Date.current - 3
|
|
income = declare_income(name: "ACME PAYROLL", amount: -1840, payday: payday)
|
|
bill = declare_bill(name: "CITY WATER", amount: 80, due: payday)
|
|
deposit = create_transaction_entry(name: "ACME PAYROLL", amount: -1900, date: payday)
|
|
charge = create_transaction_entry(name: "CITY WATER", amount: 85.50, date: payday)
|
|
|
|
[ [ income, deposit ], [ bill, charge ] ].each do |series, entry|
|
|
occurrence = series.recurring_occurrences.order(:due_on).first
|
|
RecurringTransaction::Allocator.new(occurrence).allocate_matched!(
|
|
entry: entry, state: "suggested", confidence: 0.7, signals: { name: 0.35 }
|
|
)
|
|
end
|
|
|
|
get bills_url
|
|
|
|
assert_response :success
|
|
assert_match I18n.t("bills.index.suggestion_line", entry: "CITY WATER", bill: "CITY WATER"),
|
|
response.body
|
|
assert_no_match I18n.t("bills.index.suggestion_line", entry: "ACME PAYROLL", bill: "ACME PAYROLL"),
|
|
response.body
|
|
end
|
|
|
|
|
|
# The overview groups by calendar month, which is the wrong unit for anyone
|
|
# paid weekly: four paychecks and four rent payments land in one list. The
|
|
# markers only earn their place when income actually subdivides the month.
|
|
test "weekly income marks pay periods inside the month" do
|
|
travel_to Date.current.beginning_of_month + 9.days
|
|
|
|
declare_scheduled_income(frequency: "weekly", weekday: Date.current.wday)
|
|
declare_weekly_bill
|
|
|
|
get bills_url
|
|
|
|
assert_response :success
|
|
assert_match(/due before [A-Z][a-z]{2} \d+/, response.body,
|
|
"a weekly paycheck should mark its period inside the month")
|
|
end
|
|
|
|
test "monthly income leaves the month undivided" do
|
|
travel_to Date.current.beginning_of_month + 9.days
|
|
|
|
declare_scheduled_income(frequency: "monthly", day_of_month: Date.current.day)
|
|
declare_weekly_bill
|
|
|
|
get bills_url
|
|
|
|
assert_response :success
|
|
assert_no_match(/due before [A-Z][a-z]{2} \d+/, response.body,
|
|
"one paycheck a month does not subdivide the month, so nothing should be marked")
|
|
end
|
|
|
|
test "no declared income leaves the month undivided" do
|
|
travel_to Date.current.beginning_of_month + 9.days
|
|
|
|
declare_weekly_bill
|
|
|
|
get bills_url
|
|
|
|
assert_response :success
|
|
assert_no_match(/due before [A-Z][a-z]{2} \d+/, response.body)
|
|
end
|
|
|
|
|
|
# The bridge is filtered out of the timeline, and only a shortfall earned a
|
|
# banner, so a bill due before payday that the cash comfortably covered
|
|
# appeared nowhere on the page built to answer what is due before payday.
|
|
test "a covered bridge window still shows what is due before payday" do
|
|
@family.accounts.where(accountable_type: "Depository").update_all(balance: 5_000)
|
|
declare_income(name: "Frito Lay", amount: -1200, payday: Date.current + 5)
|
|
declare_bill(name: "Curbside Cuts", amount: 150, due: Date.current + 2)
|
|
|
|
get bills_url(view: "paycheck")
|
|
|
|
assert_response :success
|
|
assert_match I18n.t("bills.paycheck.bridge_label"), response.body
|
|
assert_match "Curbside Cuts", response.body
|
|
assert_no_match(/#{Regexp.escape(I18n.t("bills.paycheck.shortfall_label"))}/, response.body,
|
|
"the cash covers it, so nothing is short")
|
|
end
|
|
|
|
test "a covered bridge is never rendered as a timeline row" do
|
|
@family.accounts.where(accountable_type: "Depository").update_all(balance: 5_000)
|
|
declare_income(name: "Frito Lay", amount: -1200, payday: Date.current + 5)
|
|
declare_bill(name: "Curbside Cuts", amount: 150, due: Date.current + 2)
|
|
|
|
get bills_url(view: "paycheck")
|
|
|
|
assert_response :success
|
|
assert_no_match(/-\$150\.00/, response.body,
|
|
"a timeline row prints income minus obligations, which is a deficit on a window that earns nothing")
|
|
end
|
|
|
|
private
|
|
|
|
def declare_scheduled_income(frequency:, weekday: nil, day_of_month: nil)
|
|
series = @family.recurring_transactions.create!(
|
|
name: "Payday", account: accounts(:depository), amount: -1200,
|
|
currency: "USD", status: "active", bill_type: "income", manual: true,
|
|
dedup_scope: "payday--1200", last_occurrence_date: 1.week.ago.to_date,
|
|
next_expected_date: Date.current,
|
|
expected_day_of_month: day_of_month || Date.current.day
|
|
)
|
|
series.recurrence_rules.create!(frequency: frequency, interval: 1,
|
|
weekday: weekday, day_of_month: day_of_month)
|
|
# Occurrences generate on create, before the rule exists, so a series
|
|
# built rule-last starts out on the fallback monthly cadence.
|
|
series.recurring_occurrences.destroy_all
|
|
RecurringTransaction::OccurrenceGenerator.new(series.reload).generate!
|
|
series
|
|
end
|
|
|
|
def declare_weekly_bill
|
|
series = @family.recurring_transactions.create!(
|
|
name: "Rent", account: accounts(:depository), amount: 400,
|
|
currency: "USD", status: "active", bill_type: "bill", manual: true,
|
|
dedup_scope: "rent-400", last_occurrence_date: 1.week.ago.to_date,
|
|
next_expected_date: Date.current, expected_day_of_month: Date.current.day
|
|
)
|
|
series.recurrence_rules.create!(frequency: "weekly", interval: 1,
|
|
weekday: Date.current.wday)
|
|
series.recurring_occurrences.destroy_all
|
|
RecurringTransaction::OccurrenceGenerator.new(series.reload).generate!
|
|
series
|
|
end
|
|
|
|
# A declared income series anchored to a specific payday, which is what the
|
|
# planner slices periods by.
|
|
def declare_income(name:, amount:, payday:)
|
|
@family.recurring_transactions.create!(
|
|
name: name, account: accounts(:depository), amount: amount, currency: "USD",
|
|
bill_type: "income", expected_day_of_month: payday.day, anchor_date: payday,
|
|
last_occurrence_date: payday, next_expected_date: payday, status: "active", manual: true
|
|
)
|
|
end
|
|
|
|
# A declared bill anchored to a specific due date. create_bill defaults to
|
|
# today, which is fine for the overview but puts every bill in the leading
|
|
# window here, where the whole point is which period a bill lands in.
|
|
def declare_bill(name:, amount:, due:)
|
|
@family.recurring_transactions.create!(
|
|
name: name, account: accounts(:depository), amount: amount, currency: "USD",
|
|
dedup_scope: amount.to_s, bill_type: "bill",
|
|
expected_day_of_month: due.day, anchor_date: due,
|
|
last_occurrence_date: due, next_expected_date: due, status: "active", manual: true
|
|
)
|
|
end
|
|
|
|
def money_string(amount)
|
|
ApplicationController.helpers.format_money(Money.new(amount, @family.currency))
|
|
end
|
|
|
|
def create_transaction_entry(name:, amount:, date:, account: accounts(:depository))
|
|
account.entries.create!(
|
|
date: date, amount: amount, currency: "USD", name: name,
|
|
entryable: Transaction.new
|
|
)
|
|
end
|
|
|
|
def create_suggested(name:, account:)
|
|
@family.recurring_transactions.create!(
|
|
name: name, account: account, amount: 15, currency: "USD",
|
|
dedup_scope: name, expected_day_of_month: 5,
|
|
last_occurrence_date: 1.month.ago.to_date,
|
|
next_expected_date: Date.current,
|
|
status: "suggested", manual: false
|
|
)
|
|
end
|
|
|
|
def create_bill(name:, amount:, **overrides)
|
|
@family.recurring_transactions.create!({
|
|
account: accounts(:depository),
|
|
name: name,
|
|
amount: amount,
|
|
# Defaults to the amount so same-name test bills (separate
|
|
# subscription tiers) coexist under the amount-free identity indexes,
|
|
# the same way the detector stamps a second series for one identifier.
|
|
dedup_scope: amount.to_s,
|
|
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
|