mirror of
https://github.com/we-promise/sure.git
synced 2026-09-09 00:24:15 +00:00
* fix(i18n): localize recurring occurrence feedback * Address PR review feedback (#3382) - Localize every due-label state rendered by the occurrence drawer - Cover the German due labels with fallback-disabled and rendered UI assertions --------- Co-authored-by: Johns <19662585+Rowdy@users.noreply.github.com>
480 lines
21 KiB
Ruby
480 lines
21 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
|
|
|
|
test "recurring occurrence feedback is localized in German" do
|
|
@user.update!(locale: "de")
|
|
|
|
get recurring_occurrence_url(@occurrence), headers: { "Turbo-Frame" => "drawer" }
|
|
|
|
assert_response :success
|
|
|
|
translations = {
|
|
"history_status.paid" => "Bezahlt",
|
|
"history_status.skipped" => "Übersprungen",
|
|
"history_status.missed" => "Verpasst",
|
|
"history_status.scheduled" => "Offen",
|
|
"show.paid_of" => "%{paid} von %{expected} bezahlt",
|
|
"show.paid_headline" => "%{amount} bezahlt",
|
|
"show.remaining" => "%{amount} ausstehend",
|
|
"show.overpaid" => "mehr als erwartet",
|
|
"show.skipped" => "Übersprungen",
|
|
"show.missed" => "Als verpasst markiert",
|
|
"show.payments" => "Zahlungen",
|
|
"show.manual_payment" => "Manuelle Zahlung",
|
|
"show.unlink" => "Verknüpfung aufheben",
|
|
"show.review_heading" => "Zahlung prüfen",
|
|
"show.find_heading" => "Zahlung finden",
|
|
"show.add_heading" => "Weitere Zahlung hinzufügen",
|
|
"show.other_matches" => "Weitere mögliche Treffer",
|
|
"show.no_ranked_candidates" => "Derzeit sieht hier keine Transaktion nach einer Zahlung für diese Rechnung aus.",
|
|
"show.search_all" => "Alle Transaktionen durchsuchen",
|
|
"show.search_placeholder" => "Transaktionen suchen",
|
|
"show.no_candidates" => "Keine passenden Transaktionen in zeitlicher Nähe gefunden.",
|
|
"show.no_search_results" => "Keine Treffer für „%{query}“.",
|
|
"show.link_payment" => "Zahlung verknüpfen",
|
|
"show.not_this_one" => "Nicht diese Transaktion",
|
|
"show.cant_find" => "Transaktion nicht gefunden?",
|
|
"show.manual_explainer" => "Erfasse eine tatsächlich erfolgte Zahlung, wenn hier keine passende Transaktion dabei ist.",
|
|
"show.manual_amount_label" => "Betrag",
|
|
"show.manual_date_label" => "Datum",
|
|
"show.record_payment" => "Zahlung erfassen",
|
|
"show.mark_paid" => "Als bezahlt markieren",
|
|
"show.mark_paid_hint" => "Markiert den Restbetrag als bezahlt, ohne eine Transaktion zu erfassen.",
|
|
"show.skip" => "Überspringen",
|
|
"show.snooze_week" => "Um eine Woche verschieben",
|
|
"show.reopen" => "Wieder öffnen",
|
|
"show.view_bill" => "Vollständige Rechnung anzeigen",
|
|
"mark_paid.success" => "Rechnung als bezahlt markiert",
|
|
"skip.success" => "Rechnung übersprungen",
|
|
"reopen.success" => "Rechnung wieder geöffnet",
|
|
"snooze.success" => "Bis %{date} verschoben",
|
|
"snooze.invalid_date" => "Das Datum konnte nicht erkannt werden",
|
|
"override_amount.success" => "Erwarteter Betrag aktualisiert"
|
|
}
|
|
|
|
translations.each do |key, text|
|
|
assert_equal text, I18n.t("recurring_occurrences.#{key}", locale: :de, fallback: false)
|
|
end
|
|
|
|
due_label_translations = {
|
|
"overdue.one" => "Seit 1 Tag überfällig · fällig am %{date}",
|
|
"overdue.other" => "Seit %{count} Tagen überfällig · fällig am %{date}",
|
|
"today" => "Heute fällig",
|
|
"settled" => "War am %{date} fällig",
|
|
"due_since" => "Fällig am %{date}",
|
|
"snoozed" => "Verschoben bis %{date}",
|
|
"upcoming.one" => "Morgen fällig · %{date}",
|
|
"upcoming.other" => "In %{count} Tagen fällig · %{date}"
|
|
}
|
|
|
|
due_label_translations.each do |key, text|
|
|
assert_equal text, I18n.t("bills.due_label.#{key}", locale: :de, fallback: false)
|
|
end
|
|
|
|
days_until_due = (@occurrence.effective_due_on - Date.current).to_i
|
|
localized_due_date = I18n.l(@occurrence.effective_due_on, locale: :de, format: :short).strip
|
|
german_due_label = I18n.t("bills.due_label.upcoming", count: days_until_due,
|
|
date: localized_due_date, locale: :de, fallback: false)
|
|
english_due_label = I18n.t("bills.due_label.upcoming", count: days_until_due,
|
|
date: localized_due_date, locale: :en)
|
|
|
|
assert_select "h2 + p", text: german_due_label
|
|
assert_select "h2 + p", text: english_due_label, count: 0
|
|
|
|
assert_select "p", text: /ausstehend/
|
|
assert_select "p", text: translations.fetch("show.find_heading")
|
|
assert_select "summary", text: translations.fetch("show.search_all")
|
|
assert_select "input[placeholder=?]", translations.fetch("show.search_placeholder")
|
|
assert_select "summary", text: translations.fetch("show.cant_find")
|
|
assert_select "a", text: translations.fetch("show.mark_paid")
|
|
assert_select "p", text: translations.fetch("show.mark_paid_hint")
|
|
assert_select "a", text: translations.fetch("show.view_bill")
|
|
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
|