mirror of
https://github.com/we-promise/sure.git
synced 2026-09-09 08:34:26 +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
125 lines
5.8 KiB
Ruby
125 lines
5.8 KiB
Ruby
require "application_system_test_case"
|
|
|
|
class DeclareAndPayBillTest < ApplicationSystemTestCase
|
|
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
|
|
@account = accounts(:depository)
|
|
end
|
|
|
|
test "declare rent, allocate a real payment, watch it stay partial, settle it" do
|
|
# due lands ten days out. Late in a month that crosses into the next one,
|
|
# which files the row under a different section with a different subline,
|
|
# so the clock is pinned where the test's premise holds.
|
|
travel_to Date.current.beginning_of_month + 9.days
|
|
|
|
due = Date.current + 10
|
|
payment = @account.entries.create!(
|
|
date: Date.current - 1, amount: 537.50, currency: "USD", name: "WATSON PROPERTY LLC",
|
|
entryable: Transaction.new
|
|
)
|
|
|
|
visit bills_url
|
|
# The switcher and the empty state both offer Add bill; either works.
|
|
click_on I18n.t("bills.index.add_bill"), match: :first
|
|
fill_in I18n.t("recurring_transactions.form.name_label"), with: "Watson Property"
|
|
fill_in I18n.t("recurring_transactions.form.amount_label"), with: "2150"
|
|
fill_in I18n.t("recurring_transactions.form.first_due_on_label"), with: due.strftime("%m/%d/%Y")
|
|
# Account is optional (DS::Select is a custom combobox; the family
|
|
# fallback covers candidates), so the bill is declared without one.
|
|
click_button I18n.t("recurring_transactions.form.submit")
|
|
|
|
assert_text "Watson Property"
|
|
|
|
# Scan, then inspect: the row itself opens the expansion. It is due in ten
|
|
# days, so the row carries no call to action -- there is nothing to chase
|
|
# yet -- and the verb lives in the expansion, spelled out.
|
|
#
|
|
# Targeted by the frame it loads rather than by bare name: the bill also
|
|
# appears in the summary's Next up strip, which goes to its page instead.
|
|
# And by name within that: the index materializes the fixture family's
|
|
# series on first visit now, so "first row" is no longer this bill.
|
|
find("a[data-turbo-frame^='pane_recurring_occurrence_']", text: "Watson Property", match: :first).click
|
|
within(find("turbo-frame[id^='pane_recurring_occurrence_']", match: :first)) do
|
|
click_on I18n.t("bills.find_payment")
|
|
end
|
|
|
|
# Act: the drawer leads with what is owed.
|
|
assert_text I18n.t("recurring_occurrences.show.remaining", amount: "$2,150.00")
|
|
|
|
# This bill was declared a moment ago, so the matcher knows it only by the
|
|
# name that was typed. "WATSON PROPERTY LLC" is not yet one of its names,
|
|
# so there is honestly nothing to suggest -- and the wider list is open
|
|
# rather than collapsed, because otherwise that would be a dead end.
|
|
assert_text I18n.t("recurring_occurrences.show.no_ranked_candidates")
|
|
assert_text payment.name
|
|
|
|
# Attach the real $537.50 payment. Every candidate row IS its own button,
|
|
# so there is one tap target per transaction rather than a small one beside
|
|
# the text.
|
|
within(find("form", text: payment.name, match: :first)) do
|
|
find("button").click
|
|
end
|
|
|
|
# Linking lands back on the worklist, and the row must say the bill is
|
|
# partly paid rather than settled: $537.50 against $2,150 is not rent.
|
|
assert_text I18n.t("bills.attention.partial", amount: "$1,612.50")
|
|
|
|
# Journey C picks up exactly where that leaves off: the row's verb has
|
|
# become Add payment, and the rest is settled from the drawer.
|
|
click_on I18n.t("bills.add_payment"), match: :first
|
|
assert_text I18n.t("recurring_occurrences.show.remaining", amount: "$1,612.50")
|
|
|
|
click_on I18n.t("recurring_occurrences.show.mark_paid")
|
|
# Synchronize on durable page state, not the toast: toasts auto-dismiss on
|
|
# their own clock and have burned CI runs before (TradesTest). The drawer's
|
|
# remaining-amount line vanishing proves the settle round-tripped.
|
|
assert_no_text I18n.t("recurring_occurrences.show.remaining", amount: "$1,612.50")
|
|
|
|
bill = @family.recurring_transactions.find_by!(name: "Watson Property")
|
|
occurrence = bill.recurring_occurrences.find_by!(due_on: due)
|
|
assert occurrence.paid?
|
|
assert_equal 2, occurrence.allocations.count
|
|
assert_equal 2150, occurrence.allocations.sum(:allocated_amount)
|
|
end
|
|
|
|
test "declare a bill by searching every transaction and picking one" do
|
|
charge = @account.entries.create!(
|
|
date: Date.current - 3, amount: 537.50, currency: "USD", name: "WATSON PROPERTY LLC",
|
|
entryable: Transaction.new
|
|
)
|
|
|
|
visit bills_url
|
|
click_on I18n.t("bills.index.add_bill"), match: :first
|
|
|
|
# A dead-end search first: nothing matches, and the way back works.
|
|
click_on I18n.t("recurring_transactions.new.search_all_cta")
|
|
fill_in I18n.t("recurring_transactions.pick_entry.search_placeholder"), with: "zzz-nothing"
|
|
find("input[name='q']").send_keys(:enter)
|
|
assert_text I18n.t("recurring_transactions.pick_entry.no_results", query: "zzz-nothing")
|
|
|
|
click_on I18n.t("recurring_transactions.pick_entry.back")
|
|
assert_field I18n.t("recurring_transactions.form.name_label"), with: ""
|
|
|
|
# Now the real search: find the charge, pick it, land in a prefilled form.
|
|
click_on I18n.t("recurring_transactions.new.search_all_cta")
|
|
fill_in I18n.t("recurring_transactions.pick_entry.search_placeholder"), with: "WATSON"
|
|
find("input[name='q']").send_keys(:enter)
|
|
|
|
click_on "WATSON PROPERTY LLC"
|
|
|
|
assert_field I18n.t("recurring_transactions.form.name_label"), with: "WATSON PROPERTY LLC"
|
|
assert_field I18n.t("recurring_transactions.form.amount_label"), with: "537.5"
|
|
click_button I18n.t("recurring_transactions.form.submit")
|
|
|
|
assert_text "WATSON PROPERTY LLC"
|
|
bill = @family.recurring_transactions.find_by!(name: "WATSON PROPERTY LLC")
|
|
assert_equal charge.account_id, bill.account_id, "the picked entry's account rides the prefill"
|
|
end
|
|
end
|