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
390 lines
17 KiB
Ruby
390 lines
17 KiB
Ruby
require "test_helper"
|
|
|
|
class RecurringOccurrencesControllerTest < ActionDispatch::IntegrationTest
|
|
setup do
|
|
sign_in @user = users(:family_admin)
|
|
@user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => true))
|
|
@family = @user.family
|
|
@series = recurring_transactions(:netflix_subscription)
|
|
@occurrence = @series.recurring_occurrences.create!(
|
|
family: @family,
|
|
original_due_on: Date.current + 5,
|
|
due_on: Date.current + 5,
|
|
currency: "USD"
|
|
)
|
|
ensure_tailwind_build
|
|
end
|
|
|
|
# Resolving a payment is the ACT surface, so it owns the drawer slot -- the
|
|
# same one transactions, trades and transfers use. The bill's own story moved
|
|
# to its own page, so nothing competes for it.
|
|
test "show renders the occurrence dialog in a single drawer frame" do
|
|
get recurring_occurrence_url(@occurrence), headers: { "Turbo-Frame" => "drawer" }
|
|
|
|
assert_response :success
|
|
assert_equal 1, response.body.scan(/<turbo-frame[^>]*id="drawer"/).size
|
|
end
|
|
|
|
# The dialog used to list the fifteen most RECENT transactions in the window.
|
|
# On a real bill that hid every plausible match behind unrelated larger
|
|
# charges, so the exact payment was invisible.
|
|
test "the transaction list leads with the closest amount, not the most recent" do
|
|
# Older than every distractor, so date order pushes it past the fifteen-row
|
|
# cut. Everything sits in the past, because the window is clipped at today.
|
|
exact = accounts(:depository).entries.create!(
|
|
date: @occurrence.due_on - 35, amount: 15.99, currency: "USD",
|
|
name: "Netflix charge", entryable: Transaction.new
|
|
)
|
|
20.times do |i|
|
|
accounts(:depository).entries.create!(
|
|
date: @occurrence.due_on - 6 - i, amount: 500 + i, currency: "USD",
|
|
name: "Unrelated big charge #{i}", entryable: Transaction.new
|
|
)
|
|
end
|
|
|
|
get recurring_occurrence_url(@occurrence), headers: { "Turbo-Frame" => "drawer" }
|
|
|
|
assert_response :success
|
|
assert_match exact.name, response.body,
|
|
"the transaction matching the bill amount must survive the fifteen-row cut"
|
|
end
|
|
|
|
# The mission's own example. The picker ranked by amount distance and nothing
|
|
# else, so a $6.44 Twitch charge scored exactly as well as the $6.44 7-Eleven
|
|
# charge for a 7-Eleven bill. It now asks the matcher, whose identity filter
|
|
# rules the others out entirely rather than merely ranking them lower.
|
|
test "the suggested payment is the one the matcher would have picked" do
|
|
seven_eleven = merchants(:netflix)
|
|
series = @family.recurring_transactions.create!(
|
|
name: "7-Eleven Gold Pass", merchant: seven_eleven, account: accounts(:depository),
|
|
amount: 6.44, currency: "USD", 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,
|
|
dedup_scope: "gold-pass"
|
|
)
|
|
occurrence = series.recurring_occurrences.order(:due_on).first
|
|
|
|
match = accounts(:depository).entries.create!(
|
|
date: occurrence.due_on, amount: 6.44, currency: "USD",
|
|
name: "7-ELEVEN GOLD PASS TM", entryable: Transaction.new(merchant: seven_eleven)
|
|
)
|
|
decoys = [ "Twitch", "Grok", "Steam" ].map do |name|
|
|
accounts(:depository).entries.create!(
|
|
date: occurrence.due_on, amount: 6.44, currency: "USD",
|
|
name: "#{name} subscription", entryable: Transaction.new
|
|
)
|
|
end
|
|
|
|
get recurring_occurrence_url(occurrence), headers: { "Turbo-Frame" => "drawer" }
|
|
assert_response :success
|
|
|
|
ranked = @controller.view_assigns["ranked_candidates"]
|
|
assert_equal [ match.id ], ranked.map { |entry, _| entry.id },
|
|
"only the 7-Eleven charge belongs to a 7-Eleven bill"
|
|
|
|
fallback = @controller.view_assigns["other_entries"].map(&:id)
|
|
decoys.each { |decoy| assert_includes fallback, decoy.id, "unrelated charges stay browsable, just not suggested" }
|
|
|
|
# And the reasons are the matcher's own, not a percentage.
|
|
assert_match I18n.t("bills.match.same_merchant"), response.body
|
|
assert_match I18n.t("bills.match.exact_amount"), response.body
|
|
end
|
|
|
|
# Corrections are permanently sticky in the matcher. The picker never checked
|
|
# the rejection table, so a transaction the user had already dismissed could
|
|
# come straight back to the top of the list.
|
|
test "a rejected pairing never returns as a suggestion" do
|
|
series = @family.recurring_transactions.create!(
|
|
name: "CITY WATER", account: accounts(:depository), amount: 80, currency: "USD",
|
|
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
|
|
)
|
|
occurrence = series.recurring_occurrences.order(:due_on).first
|
|
entry = accounts(:depository).entries.create!(
|
|
date: occurrence.due_on, amount: 80, currency: "USD",
|
|
name: "CITY WATER", entryable: Transaction.new
|
|
)
|
|
|
|
get recurring_occurrence_url(occurrence), headers: { "Turbo-Frame" => "drawer" }
|
|
assert_equal [ entry.id ], @controller.view_assigns["ranked_candidates"].map { |candidate, _| candidate.id }
|
|
|
|
RecurringMatchRejection.create!(recurring_transaction: series, entry: entry)
|
|
|
|
get recurring_occurrence_url(occurrence), headers: { "Turbo-Frame" => "drawer" }
|
|
assert_empty @controller.view_assigns["ranked_candidates"]
|
|
end
|
|
|
|
# One transaction can legitimately pay more than one bill, which is why the
|
|
# Allocator guards capacity at write time rather than at read time. Excluding
|
|
# every entry that already has a confirmed allocation would have quietly
|
|
# deleted split payments from the picker.
|
|
test "an entry already allocated to another bill is still offered here" do
|
|
first, second = [ "a", "b" ].map do |scope|
|
|
@family.recurring_transactions.create!(
|
|
name: "WATSON PROPERTY", account: accounts(:depository), amount: 500, currency: "USD",
|
|
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, dedup_scope: scope
|
|
).recurring_occurrences.order(:due_on).first
|
|
end
|
|
|
|
entry = accounts(:depository).entries.create!(
|
|
date: first.due_on, amount: 500, currency: "USD",
|
|
name: "WATSON PROPERTY", entryable: Transaction.new
|
|
)
|
|
RecurringTransaction::Allocator.new(first).allocate!(entry: entry, amount: 200)
|
|
|
|
get recurring_occurrence_url(second), headers: { "Turbo-Frame" => "drawer" }
|
|
|
|
assert_includes @controller.view_assigns["ranked_candidates"].map { |candidate, _| candidate.id }, entry.id,
|
|
"the rest of that charge can still pay another bill; over-allocation is refused when it is written"
|
|
end
|
|
|
|
test "searching looks past the date window" do
|
|
old = accounts(:depository).entries.create!(
|
|
date: @occurrence.due_on - 300, amount: 15.99, currency: "USD",
|
|
name: "Ancient Netflix charge", entryable: Transaction.new
|
|
)
|
|
|
|
get recurring_occurrence_url(@occurrence), headers: { "Turbo-Frame" => "drawer" }
|
|
assert_no_match old.name, response.body, "outside the window it is not offered by default"
|
|
|
|
get recurring_occurrence_url(@occurrence, q: "Ancient"), headers: { "Turbo-Frame" => "drawer" }
|
|
assert_match old.name, response.body, "a search must be able to reach it"
|
|
end
|
|
|
|
test "mark_paid settles the occurrence" do
|
|
post mark_paid_recurring_occurrence_url(@occurrence)
|
|
|
|
assert_redirected_to bills_url
|
|
@occurrence.reload
|
|
assert @occurrence.paid?
|
|
assert_equal "user", @occurrence.closed_source
|
|
assert_equal @occurrence.expected_amount, @occurrence.allocations.sum(:allocated_amount)
|
|
end
|
|
|
|
test "skip and reopen round trip" do
|
|
post skip_recurring_occurrence_url(@occurrence)
|
|
assert @occurrence.reload.skipped?
|
|
|
|
post reopen_recurring_occurrence_url(@occurrence)
|
|
assert @occurrence.reload.scheduled?
|
|
end
|
|
|
|
test "snooze postpones the effective due date" do
|
|
patch snooze_recurring_occurrence_url(@occurrence, until: (Date.current + 12).iso8601)
|
|
|
|
assert_equal Date.current + 12, @occurrence.reload.snoozed_until
|
|
end
|
|
|
|
# until[]=... makes params.require(:until) an Array, which Date.parse
|
|
# rejects with TypeError rather than ArgumentError. Same user mistake, same
|
|
# invalid-date redirect; never a 500.
|
|
test "a non-scalar until parameter is an invalid date, not a 500" do
|
|
patch snooze_recurring_occurrence_url(@occurrence), params: { until: [ (Date.current + 12).iso8601 ] }
|
|
|
|
assert_response :redirect
|
|
assert_nil @occurrence.reload.snoozed_until
|
|
end
|
|
|
|
test "override amount sets and clears the per-occurrence expectation" do
|
|
patch override_amount_recurring_occurrence_url(@occurrence, amount: "42.50")
|
|
assert_equal 42.50, @occurrence.reload.expected_amount
|
|
|
|
patch override_amount_recurring_occurrence_url(@occurrence, amount: "")
|
|
assert_nil @occurrence.reload.expected_amount
|
|
end
|
|
|
|
test "another family's occurrence is unreachable" do
|
|
other_family = families(:empty)
|
|
other_series = other_family.recurring_transactions.create!(
|
|
name: "Foreign bill", amount: 10, currency: "USD", expected_day_of_month: 1,
|
|
last_occurrence_date: Date.current, next_expected_date: 1.month.from_now.to_date,
|
|
status: "active", manual: true
|
|
)
|
|
foreign = other_series.recurring_occurrences.order(:due_on).first ||
|
|
other_series.recurring_occurrences.create!(
|
|
family: other_family, original_due_on: Date.current,
|
|
due_on: Date.current, currency: "USD"
|
|
)
|
|
|
|
get recurring_occurrence_url(foreign)
|
|
assert_response :not_found
|
|
end
|
|
|
|
test "allocating an entry applies its amount toward the occurrence" do
|
|
entry = accounts(:depository).entries.create!(
|
|
date: Date.current, amount: 10, currency: "USD", name: "Netflix charge",
|
|
entryable: Transaction.new(merchant: merchants(:netflix))
|
|
)
|
|
|
|
post recurring_occurrence_allocations_url(@occurrence, entry_id: entry.id)
|
|
|
|
allocation = @occurrence.allocations.sole
|
|
assert_equal entry.id, allocation.entry_id
|
|
assert_equal 10, allocation.allocated_amount
|
|
assert @occurrence.reload.partially_paid?
|
|
end
|
|
|
|
test "a custom amount records an entry-less payment" do
|
|
post recurring_occurrence_allocations_url(@occurrence), params: { amount: "5.00" }
|
|
|
|
allocation = @occurrence.allocations.sole
|
|
assert_nil allocation.entry_id
|
|
assert allocation.from_user_created?
|
|
assert_equal 5, allocation.allocated_amount
|
|
end
|
|
|
|
test "over-allocating an entry is refused with an explanation" do
|
|
entry = accounts(:depository).entries.create!(
|
|
date: Date.current, amount: 10, currency: "USD", name: "Netflix charge",
|
|
entryable: Transaction.new(merchant: merchants(:netflix))
|
|
)
|
|
|
|
post recurring_occurrence_allocations_url(@occurrence, entry_id: entry.id), params: { amount: "25" }
|
|
|
|
assert_equal 0, @occurrence.allocations.count
|
|
assert_equal I18n.t("recurring_allocations.over_allocation"), flash[:alert]
|
|
end
|
|
|
|
# Sharing is per account. A member with no share on the brokerage must not be
|
|
# able to settle a bill with a transaction from it, which would both spend an
|
|
# obligation against money they cannot see and echo the charge back to them.
|
|
test "a member cannot pay a bill with a transaction from an account they were never given" do
|
|
hidden = accounts(:investment).entries.create!(
|
|
date: Date.current, amount: 15.99, currency: "USD", name: "PRIVATE BROKERAGE FEE",
|
|
entryable: Transaction.new
|
|
)
|
|
member = users(:family_member)
|
|
member.update!(preferences: (member.preferences || {}).merge("preview_features_enabled" => true))
|
|
sign_in member
|
|
|
|
post recurring_occurrence_allocations_url(@occurrence, entry_id: hidden.id), params: { amount: "15.99" }
|
|
|
|
assert_response :not_found
|
|
assert_equal 0, @occurrence.reload.allocations.count
|
|
end
|
|
|
|
test "confirming and rejecting suggestions from the queue" do
|
|
entry = accounts(:depository).entries.create!(
|
|
date: Date.current, amount: 15.99, currency: "USD", name: "Netflix charge",
|
|
entryable: Transaction.new(merchant: merchants(:netflix))
|
|
)
|
|
suggestion = RecurringTransaction::Allocator.new(@occurrence).allocate_matched!(
|
|
entry: entry, state: "suggested", confidence: 0.7, signals: { name: 0.35 }
|
|
)
|
|
|
|
post confirm_recurring_allocation_url(suggestion)
|
|
assert suggestion.reload.allocation_confirmed?
|
|
assert @occurrence.reload.paid?
|
|
|
|
delete recurring_allocation_url(suggestion)
|
|
other = RecurringTransaction::Allocator.new(@occurrence.reload).allocate_matched!(
|
|
entry: entry, state: "suggested", confidence: 0.7, signals: { name: 0.35 }
|
|
)
|
|
post reject_recurring_allocation_url(other)
|
|
|
|
assert_not RecurringAllocation.exists?(other.id)
|
|
assert RecurringMatchRejection.exists?(recurring_transaction: @series, entry: entry)
|
|
end
|
|
|
|
test "unlinking a payment reopens an auto-closed occurrence" do
|
|
entry = accounts(:depository).entries.create!(
|
|
date: Date.current, amount: 15.99, currency: "USD", name: "Netflix charge",
|
|
entryable: Transaction.new(merchant: merchants(:netflix))
|
|
)
|
|
post recurring_occurrence_allocations_url(@occurrence, entry_id: entry.id)
|
|
assert @occurrence.reload.paid?
|
|
|
|
delete recurring_allocation_url(@occurrence.allocations.sole)
|
|
|
|
assert @occurrence.reload.scheduled?
|
|
assert_equal 0, @occurrence.allocations.count
|
|
end
|
|
|
|
test "the drawer redirects when the family has turned recurring transactions off" do
|
|
@family.update!(recurring_transactions_disabled: true)
|
|
|
|
get recurring_occurrence_url(@occurrence)
|
|
|
|
assert_redirected_to root_path
|
|
end
|
|
|
|
# Sharing is per account. An accountless bill is visible family-wide, but
|
|
# its candidate list must still show only entries from accounts the viewer
|
|
# can reach, or the drawer leaks names and amounts from unshared accounts.
|
|
test "an accountless bill's candidates exclude entries from unshared accounts" do
|
|
accountless = @family.recurring_transactions.create!(
|
|
name: "Water Utility", amount: 60, currency: "USD",
|
|
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
|
|
)
|
|
occurrence = accountless.recurring_occurrences.order(:due_on).first
|
|
|
|
hidden = accounts(:investment).entries.create!(
|
|
date: occurrence.due_on, amount: 60, currency: "USD",
|
|
name: "Broker service fee", entryable: Transaction.new
|
|
)
|
|
visible = accounts(:depository).entries.create!(
|
|
date: occurrence.due_on, amount: 60, currency: "USD",
|
|
name: "Shared checking charge", entryable: Transaction.new
|
|
)
|
|
|
|
member = users(:family_member)
|
|
member.update!(preferences: (member.preferences || {}).merge("preview_features_enabled" => true))
|
|
sign_in member
|
|
get recurring_occurrence_url(occurrence), headers: { "Turbo-Frame" => "drawer" }
|
|
|
|
assert_response :success
|
|
assert_no_match hidden.name, response.body,
|
|
"an entry on an account never shared with the viewer must not render"
|
|
assert_match visible.name, response.body,
|
|
"positive control: the same-shaped entry on a shared account must render"
|
|
end
|
|
|
|
# credit_card is shared read-only with family_member in the fixtures; a
|
|
# read-only share can look at the bill but never move its payment state.
|
|
test "a read-only account share cannot mutate an occurrence" do
|
|
occurrence = credit_card_occurrence
|
|
|
|
member = users(:family_member)
|
|
member.update!(preferences: (member.preferences || {}).merge("preview_features_enabled" => true))
|
|
sign_in member
|
|
|
|
post mark_paid_recurring_occurrence_url(occurrence)
|
|
assert_response :not_found
|
|
assert occurrence.reload.scheduled?, "a read-only share must not settle the bill"
|
|
|
|
post skip_recurring_occurrence_url(occurrence)
|
|
assert_response :not_found
|
|
|
|
patch override_amount_recurring_occurrence_url(occurrence, amount: "1")
|
|
assert_response :not_found
|
|
assert_nil occurrence.reload.expected_amount
|
|
|
|
get recurring_occurrence_url(occurrence), headers: { "Turbo-Frame" => "drawer" }
|
|
assert_response :success, "reading the shared bill stays allowed"
|
|
end
|
|
|
|
test "the account owner still settles the same occurrence" do
|
|
occurrence = credit_card_occurrence
|
|
|
|
post mark_paid_recurring_occurrence_url(occurrence)
|
|
|
|
assert occurrence.reload.paid?
|
|
end
|
|
|
|
private
|
|
def credit_card_occurrence
|
|
series = @family.recurring_transactions.create!(
|
|
name: "Card Annual Fee", account: accounts(:credit_card), amount: 95,
|
|
currency: "USD", 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.order(:due_on).first
|
|
end
|
|
end
|