diff --git a/app/assets/tailwind/sure-design-system/_generated.css b/app/assets/tailwind/sure-design-system/_generated.css index d0c781722..0172e1583 100644 --- a/app/assets/tailwind/sure-design-system/_generated.css +++ b/app/assets/tailwind/sure-design-system/_generated.css @@ -402,6 +402,14 @@ } } +@utility divide-subdued { + @apply divide-alpha-black-50; + + @variant theme-dark { + @apply divide-alpha-white-200; + } +} + @utility button-bg-primary { @apply bg-gray-900; @@ -490,6 +498,22 @@ } } +@utility button-bg-accent { + @apply bg-blue-tint-5; + + @variant theme-dark { + @apply bg-blue-tint-5; + } +} + +@utility button-bg-accent-hover { + @apply bg-blue-tint-10; + + @variant theme-dark { + @apply bg-blue-tint-10; + } +} + @utility tab-item-active { @apply bg-white; diff --git a/app/components/DS/buttonish.rb b/app/components/DS/buttonish.rb index 967874fba..fe4742483 100644 --- a/app/components/DS/buttonish.rb +++ b/app/components/DS/buttonish.rb @@ -1,3 +1,6 @@ +# Bills subsystem: adds the `accent` and `outline_muted` variants, with their +# colours declared in design/tokens/sure.tokens.json alongside every other +# button colour. class DS::Buttonish < DesignSystemComponent VARIANTS = { primary: { @@ -8,6 +11,13 @@ class DS::Buttonish < DesignSystemComponent container_classes: "text-primary bg-gray-200 theme-dark:bg-gray-700 hover:bg-gray-300 theme-dark:hover:bg-gray-600 disabled:bg-gray-200 theme-dark:disabled:bg-gray-600", icon_classes: "text-primary" }, + # A tinted call to action for navigation that leaves the app. Backed by the + # button-bg-accent utilities in design/tokens/sure.tokens.json, so the colour + # lives with every other button colour rather than in this file. + accent: { + container_classes: "text-link button-bg-accent hover:button-bg-accent-hover disabled:button-bg-disabled", + icon_classes: "text-link" + }, destructive: { container_classes: "text-inverse button-bg-destructive hover:button-bg-destructive-hover disabled:bg-red-200 theme-dark:disabled:bg-red-600", icon_classes: "text-inverse" @@ -16,6 +26,13 @@ class DS::Buttonish < DesignSystemComponent container_classes: "text-primary border border-secondary bg-transparent hover:bg-surface-hover", icon_classes: "text-secondary" }, + # A quieter outline for "there is nothing here yet" affordances, which should sit + # behind the content they sit beside rather than compete with it. Pair with + # `border-dashed` for the empty-slot cue the split editor uses. + outline_muted: { + container_classes: "text-secondary hover:text-primary border border-secondary hover:border-primary bg-transparent", + icon_classes: "text-secondary" + }, outline_destructive: { container_classes: "text-destructive border border-secondary bg-transparent hover:bg-container-inset-hover", icon_classes: "text-secondary" @@ -112,6 +129,10 @@ class DS::Buttonish < DesignSystemComponent :white when :destructive, :outline_destructive :destructive + when :accent, :outline_muted + # Inherit the container's own text color so the glyph always matches its + # label, including on hover, instead of tracking a second colour by hand. + :current else :default end diff --git a/app/components/DS/link.rb b/app/components/DS/link.rb index 0d5c8501a..9f454271c 100644 --- a/app/components/DS/link.rb +++ b/app/components/DS/link.rb @@ -21,6 +21,13 @@ class DS::Link < DS::Buttonish data = data.merge(turbo_frame: frame) end + # `link_to method:` has been inert since the UJS removal -- Turbo drives + # non-GET links via `data-turbo-method` instead. Translate so a caller + # writing the natural `method: :post` gets a real POST, not a silent GET. + if (http_method = merged_opts.delete(:method)) + data = data.merge(turbo_method: http_method) + end + # External link hardening: `target="_blank"` without `rel="noopener"` # exposes window.opener to the new tab (reverse-tabnabbing). Always # set `noopener noreferrer` when we send the user off-tab. Authors diff --git a/app/components/DS/sparkline.html.erb b/app/components/DS/sparkline.html.erb new file mode 100644 index 000000000..ac0287b98 --- /dev/null +++ b/app/components/DS/sparkline.html.erb @@ -0,0 +1,13 @@ + + + <% points.each do |x, y, value| %> + " + stroke="currentColor" stroke-width="1.5" /> + <% end %> + <% labels.each do |x, text| %> + <%= text %> + <% end %> + diff --git a/app/components/DS/sparkline.rb b/app/components/DS/sparkline.rb new file mode 100644 index 000000000..dac08de77 --- /dev/null +++ b/app/components/DS/sparkline.rb @@ -0,0 +1,55 @@ +# A small labeled line-with-dots chart: one polyline over evenly spaced +# points, a dot per point (hollow when the value is zero), and a label under +# every other point. +# +# Extracted from the bill detail's twelve-month payment history so views stop +# hand-rolling SVG (raw SVG belongs in DS primitives). The component owns the +# geometry; the caller passes dated values and sets the color via a text-* +# class on `css`, which the strokes and fills pick up through currentColor. +class DS::Sparkline < DesignSystemComponent + WIDTH = 336 + HEIGHT = 84 + LEFT_PAD = 10 + SPAN = 316.0 + BASELINE_Y = 62 + VALUE_HEIGHT = 48 + LABEL_Y = 80 + + attr_reader :series, :aria_label, :css, :label_format + + # series: [ [Date, Numeric], ... ] in display order. + def initialize(series:, aria_label:, css: "w-full text-success", label_format: "%b") + @series = series + @aria_label = aria_label + @css = css + @label_format = label_format + end + + def points + @points ||= series.each_with_index.map do |(_label, value), index| + [ x_at(index), BASELINE_Y - (value.to_f / peak) * VALUE_HEIGHT, value ] + end + end + + def polyline_points + points.map { |x, y, _| "#{x.round(1)},#{y.round(1)}" }.join(" ") + end + + # Every other label keeps the axis readable at sparkline width. + def labels + series.each_with_index.filter_map do |(label, _value), index| + next unless index.even? + + [ x_at(index).round(1), I18n.l(label, format: label_format) ] + end + end + + private + def x_at(index) + LEFT_PAD + index * (SPAN / [ series.size - 1, 1 ].max) + end + + def peak + @peak ||= [ series.map(&:last).max, 1 ].max.to_f + end +end diff --git a/app/controllers/bills/ai_reviews_controller.rb b/app/controllers/bills/ai_reviews_controller.rb new file mode 100644 index 000000000..9d4a8f9fb --- /dev/null +++ b/app/controllers/bills/ai_reviews_controller.rb @@ -0,0 +1,26 @@ +class Bills::AiReviewsController < ApplicationController + include BillsHelper + include RecurringFeatureGuardable + + guard_feature unless: -> { bills_one_shot_ai_available? } + before_action :ensure_recurring_enabled + + # Server-owned on purpose: the button must not become a vector for + # client-supplied prompts, and the text appears in the chat as the user's + # own message so it stays short and legible. It reads like something a + # person would type: no internal tool names, because the tool descriptions + # already route the model without the prompt naming them. + REVIEW_PROMPT = <<~PROMPT.freeze + Review my bills and subscriptions. Look for duplicate bills, price increases, anything overdue or abandoned, trials about to convert, and recurring charges I have not declared yet. Propose specific fixes and ask me before changing anything. + PROMPT + + # Seeds a chat rather than generating a report: the audit tool grounds the + # findings deterministically, and a conversation can act on them ("fix the + # second one") through the bills write tools. + def create + chat = Current.user.chats.start!(REVIEW_PROMPT.strip, model: helpers.default_ai_model) + Current.user.update!(last_viewed_chat: chat) + + redirect_to chat_path(chat, thinking: true) + end +end diff --git a/app/controllers/bills/smart_configurations_controller.rb b/app/controllers/bills/smart_configurations_controller.rb new file mode 100644 index 000000000..baa7d2b40 --- /dev/null +++ b/app/controllers/bills/smart_configurations_controller.rb @@ -0,0 +1,32 @@ +class Bills::SmartConfigurationsController < ApplicationController + include BillsHelper + include RecurringFeatureGuardable + + guard_feature unless: -> { bills_one_shot_ai_available? } + before_action :ensure_recurring_enabled + + # Proposes configuration corrections for one bill from its own charge + # history (configure mode: only fields the history contradicts come back). + # The dialog's form PATCHes the ordinary recurring_transactions#update, so + # applying inherits every rule that path already enforces -- sign and + # ownership handling, preset application, schedule pinning, occurrence + # regeneration. Nothing applies without the user checking it. + def show + @series = Current.family.recurring_transactions + .accessible_by(Current.user) + .includes(:merchant, :category) + .find(params[:id]) + + begin + @suggestion = RecurringTransaction::AiSetupSuggester + .new(Current.family, user: Current.user) + .suggest_configuration(@series) + @detection = RecurringTransaction::FrequencyPreset.detect(@series) + rescue RecurringTransaction::AiSetupSuggester::Error => e + Rails.logger.warn("Smart configure failed for series #{@series.id}: #{e.class}: #{e.message}") + @error = t(".failed") + end + + render layout: dialog_layout + end +end diff --git a/app/controllers/bills_controller.rb b/app/controllers/bills_controller.rb new file mode 100644 index 000000000..b04df218c --- /dev/null +++ b/app/controllers/bills_controller.rb @@ -0,0 +1,647 @@ +class BillsController < ApplicationController + include RecurringFeatureGuardable + + # What the All-bills status filter offers: payment state, plus the two + # lifecycle values people actually use. suggested and inactive are detection + # plumbing and stay out. + PAYMENT_FILTERS = %w[overdue due partial paid].freeze + # Pause stores `inactive`, so the filter has to accept both. `paused` arrives + # only by import or the v1 API; `ended` only by dismissing a suggestion. + LIFECYCLE_STATUSES = { "paused" => %w[inactive paused], "ended" => %w[ended] }.freeze + LIFECYCLE_FILTERS = LIFECYCLE_STATUSES.keys.freeze + STATUS_FILTERS = (PAYMENT_FILTERS + LIFECYCLE_FILTERS).freeze + # Enough to answer "what happens next" without becoming a second bill list. + NEXT_UP_LIMIT = 4 + # Six covers a month of weekly paydays with room for a leading bridge. + PAY_PERIOD_LIMIT = 6 + before_action :ensure_recurring_enabled + + # The pay-run workspace, built on occurrence rows rather than series + # projections, so every row has a real due date and payment state. + def index + if params[:view] == "subscriptions" + redirect_to bills_path(view: "all", q: { bill_type: "subscription" }) + return + end + + @view = %w[all calendar paycheck].include?(params[:view]) ? params[:view] : "overview" + + # An upgraded instance can arrive with series but no occurrence rows, + # because nothing under the old build ever materialized them. One inline, + # idempotent generation covers every view. The cache is a cost gate, not + # correctness -- the none? probe stays authoritative; the guard only stops + # an all-ended-series family from re-running generation on every GET. + cache_key = "bills:materialized:#{Current.family.id}" + if Current.family.recurring_occurrences.none? && !Rails.cache.read(cache_key) + materialize_missing_occurrences + Rails.cache.write(cache_key, true, expires_in: 12.hours) + end + + case @view + when "all" + load_all_series + render :all + return + when "calendar" + load_calendar + render :calendar + return + when "paycheck" + load_paycheck_plan + render :paycheck + return + end + + occurrences = payable_occurrences + preload_allocation_sums(occurrences) + + today = Date.current + month_end = today.end_of_month + + open_occurrences, closed = occurrences.partition(&:scheduled?) + active_open, @dormant = open_occurrences.partition { |occurrence| occurrence.recurring_transaction.active? } + + @overdue, upcoming = active_open.partition { |occurrence| occurrence.derived_state == :overdue } + this_month, later = upcoming.partition { |occurrence| occurrence.due_on <= month_end } + @this_month = this_month.sort_by(&:due_on) + @overdue = @overdue.sort_by(&:due_on) + @dormant = @dormant.sort_by(&:due_on) + + # Beyond this month, one row per series: a weekly bill's next six + # occurrences are not six separate things to think about yet. + @later = later.group_by(&:recurring_transaction_id) + .values + .map { |group| group.min_by(&:due_on) } + .sort_by(&:due_on) + + @paid_this_month = closed.select { |occurrence| occurrence.paid? && occurrence.due_on >= today.beginning_of_month } + .sort_by(&:due_on) + + compute_kpis(today, month_end) + + @month_pay_periods = month_pay_periods(today, month_end) + + @detected_awaiting_review = detected_awaiting_review + # Fresh detections wait here for confirm/dismiss. Reviewing them is bill + # work, so the strip lives on this page as well as in Settings. + # Loaded once: the view asks any?/none? and the partial counts and + # iterates, which would otherwise be separate queries. + @suggested_series = accessible_suggested_series.includes(:merchant).order(next_expected_date: :asc).load + @has_transaction_history = Current.family.entries.where(entryable_type: "Transaction").exists? + @suggested_allocations = suggested_allocations + # A row waiting on a match decision offers Review rather than Find. + # Already loaded for the queue above, so indexing is free. + @suggestions_by_occurrence = @suggested_allocations.index_by(&:recurring_occurrence_id) + @notices = collect_notices + + # The month as one chronological list, paid rows in place under a check. + # Overdue rows are excluded: they get their own section. + @month_rows = (@this_month + @paid_this_month).sort_by(&:due_on) + + # Next up filters on the DATE, not derived_state: a bill two days late is + # still :due within its grace period, and nothing already past its due date + # belongs under "what happens next". + @month_bill_count = @overdue.size + @month_rows.size + @next_up = (@this_month + @later) + .select { |occurrence| occurrence.effective_due_on >= today } + .sort_by(&:effective_due_on) + .first(NEXT_UP_LIMIT) + end + + # One-click detection for a page with nothing on it: run the full pipeline + # and land back here, where the review strip presents anything found. The + # flash counts only rows this run created and this user can see -- the + # pattern total would count refreshes of series that already exist. + def detect + before_ids = accessible_suggested_series.pluck(:id) + # backfill: user-triggered detection always reconstructs history (the + # backfiller is idempotent). nil means another run holds the family lock. + result = RecurringTransaction::Pipeline.new(Current.family).run_with_lock!(backfill: true) + + flash[:notice] = + if result.nil? + t(".already_running") + else + found = accessible_suggested_series.where.not(id: before_ids).count + found.positive? ? t(".found", count: found) : t(".none_found") + end + + redirect_to bills_path + end + + # Revocation for the iCal feed: every previously shared URL stops working. + def reset_feed_token + Current.family.reset_bills_feed_token! + flash[:notice] = t(".done") + redirect_to bills_path(view: "calendar") + end + + # One bill's complete story: current state, history, what is coming, cost. + def show + @series = Current.family.recurring_transactions + .accessible_by(Current.user) + .includes(:merchant) + .find(params[:id]) + + # A row expansion names the cycle it was opened from; the bill's own page + # has no cycle in mind and asks the series. Looked up through the series, so + # an id from another bill resolves to nothing rather than to someone else's + # occurrence. + @current_occurrence = + if params[:occurrence].present? + @series.recurring_occurrences.find_by(id: params[:occurrence]) || @series.current_occurrence + else + @series.current_occurrence + end + + @history = @series.recurring_occurrences.closed.order(due_on: :desc).limit(12).includes(:allocations) + @upcoming = @series.schedule.occurrences_between(Date.current + 1, Date.current + 400).first(3) + + # What each settled cycle actually cost. The frozen `expected_amount` is an + # estimate, so reading it here would report averages of estimates beside the + # per-year totals below, which are sums of real payments. + paid_amounts = RecurringAllocation.confirmed + .joins(:recurring_occurrence) + .where(recurring_occurrences: { + recurring_transaction_id: @series.id, + status: "paid" + }) + .group(:recurring_occurrence_id) + .sum(:allocated_amount) + .values + @analytics = if paid_amounts.any? + { + average: Money.new(paid_amounts.sum / paid_amounts.size, @series.currency), + lowest: Money.new(paid_amounts.min, @series.currency), + highest: Money.new(paid_amounts.max, @series.currency), + annualized: @series.monthly_equivalent_amount * 12, + ytd: Money.new(ytd_paid_total, @series.currency) + } + end + + if params[:display] == "pane" + # The expansion renders into whichever row frame asked for it; the id + # is reflected back sanitized. close returns the empty frame, which + # collapses the row. + @pane_frame_id = params[:frame].to_s.gsub(/[^a-zA-Z0-9_-]/, "").presence || "bill_detail" + if params[:close].present? + render :pane_close, layout: false + return + end + end + + load_summary_extras + + if params[:display] == "pane" + # A pending suggestion is the one thing that changes what the expansion + # should offer, so it is worth the one query. + @pane_suggestion = @current_occurrence && RecurringAllocation.suggested + .where(recurring_occurrence_id: @current_occurrence.id).first + render :pane, layout: false + return + end + + # Only the bill's own page carries the deep material, so only it pays for + # the aggregates behind it. + load_deep_extras + render + end + + private + # The plan plus the income facts the page states alongside it. One planner + # instance answers both, so the income list and the periods always agree. + def load_paycheck_plan + planner = RecurringTransaction::PaycheckPlanner.new(Current.family, user: Current.user) + # An empty plan (no periods to spread anything across) renders as no plan. + @plan = planner.plan.presence + @plan_unconvertible = planner.unconvertible_count + + @income_series = Current.family.recurring_transactions + .accessible_by(Current.user) + .where(bill_type: :income) + .where.not(status: %i[suggested ended]) + .order(:name) + .to_a + @next_income_by_series = planner.next_income_by_series + + # The next income EVENT, which is not the same fact as any one series' + # next payday: two sources can land on the same day. + arrivals = @next_income_by_series.values + first_arrival = arrivals.min_by(&:due_on) + if first_arrival + same_day = arrivals.select { |occurrence| occurrence.due_on == first_arrival.due_on } + total, unconvertible = total_of(same_day) { |occurrence| occurrence.resolved_expected_amount_money } + @next_income = { date: first_arrival.due_on, occurrences: same_day, total: total, unconvertible: unconvertible } + end + + @income_needs_attention = @income_series.any? { |series| !paycheck_income_plans?(series) } + end + + # Only active, manually declared income defines paydays. + def paycheck_income_plans?(series) + series.active? && series.manual? + end + helper_method :paycheck_income_plans? + + # What the expansion needs: the handful of payments that actually settled + # this bill lately. Cheap enough to run on every row someone opens. + def load_summary_extras + @recent_allocations = confirmed_allocations.includes(:entry).order(paid_on: :desc, created_at: :desc).limit(6) + end + + # The bill's financial story: a year of payments by month, per-year totals, + # and where the money last came from. Three grouped aggregates, which is + # why they no longer run every time a row is expanded. + def load_deep_extras + confirmed = confirmed_allocations + + window_start = 11.months.ago.beginning_of_month.to_date + by_month = confirmed + .where("recurring_occurrences.due_on >= ?", window_start) + .group(Arel.sql("date_trunc('month', recurring_occurrences.due_on)")) + .sum(:allocated_amount) + .transform_keys(&:to_date) + + @payment_history = (0..11).map do |offset| + month = (window_start + offset.months) + [ month, by_month.fetch(month, 0) ] + end + + totals = confirmed.group(Arel.sql("date_trunc('year', recurring_occurrences.due_on)")).sum(:allocated_amount) + counts = confirmed.group(Arel.sql("date_trunc('year', recurring_occurrences.due_on)")).count + @yearly_metrics = totals.map do |year, total| + count = counts.fetch(year, 1) + { year: year.to_date.year, total: Money.new(total, @series.currency), average: Money.new(total / count, @series.currency) } + end.sort_by { |row| -row[:year] }.first(4) + + last_allocation = confirmed.where.not(entry_id: nil).includes(entry: :account).order(paid_on: :desc, created_at: :desc).first + @last_account = last_allocation&.entry&.account + end + + def confirmed_allocations + RecurringAllocation.confirmed + .joins(:recurring_occurrence) + .where(recurring_occurrences: { recurring_transaction_id: @series.id }) + end + + # The management table: every series of every type and status, filterable + # and sortable. This is the power-user surface; the overview stays a + # worklist. + def load_all_series + scope = Current.family.recurring_transactions + .accessible_by(Current.user) + .includes(:merchant) + + if (search = params.dig(:q, :search)).present? + pattern = "%#{ActiveRecord::Base.sanitize_sql_like(search)}%" + scope = scope.left_joins(:merchant) + .where("recurring_transactions.name ILIKE :p OR merchants.name ILIKE :p", p: pattern) + end + + # "Status" used to mean the SERIES lifecycle -- suggested, active, paused, + # inactive, ended -- so there was no way to ask the question people + # actually ask here, which is what is late and what is still owed. The + # filter now speaks payment state, with the lifecycle values that still + # matter (paused, ended) kept alongside. + status = params.dig(:q, :status) + + if status.presence_in(LIFECYCLE_FILTERS) + scope = scope.where(status: LIFECYCLE_STATUSES.fetch(status)) + end + + if (bill_type = params.dig(:q, :bill_type)).presence_in(RecurringTransaction.bill_types.keys) + scope = scope.where(bill_type: bill_type) + end + + scope = scope.includes(:recurring_occurrences) if status.presence_in(PAYMENT_FILTERS) + + @all_series = case params.dig(:q, :sort) + when "name" then scope.order(:name, :amount) + when "amount" then scope.order(amount: :desc) + else scope.order(status: :asc, next_expected_date: :asc) + end + + @all_series = filter_by_payment_state(@all_series, status) if status.presence_in(PAYMENT_FILTERS) + + load_subscription_rollup if bill_type == "subscription" + end + + # Payment state lives on the occurrence and is derived from dates and + # allocation sums, so it cannot be a WHERE clause. Occurrences are preloaded + # above, and this table is a management surface for a few hundred bills. + def filter_by_payment_state(series_list, status) + series_list.to_a.select do |series| + occurrence = series.current_occurrence + next false if occurrence.nil? + + case status + when "overdue" then occurrence.overdue? + when "due" then occurrence.derived_state == :due + when "partial" then occurrence.partially_paid? + when "paid" then occurrence.paid? + else false + end + end + end + + # What the Subscriptions tab existed to answer. It was a filter promoted to + # navigation -- bill_type: subscription, which All bills already offered -- + # so the rollup now rides the filter instead of a destination of its own. + def load_subscription_rollup + subscriptions = @all_series.select { |series| series.bill_type == "subscription" } + active = subscriptions.select(&:active?) + monthly, unconvertible = total_of_series(active) { |series| series.monthly_equivalent_amount.abs } + + @subscription_rollup = { + monthly: monthly, + annual: monthly ? monthly * 12 : nil, + active_count: active.size, + unconvertible: unconvertible + } + + @recent_price_changes = RecurringPriceChange + .joins(:recurring_transaction) + .merge(RecurringTransaction.accessible_by(Current.user)) + .where(recurring_transactions: { family_id: Current.family.id }) + .where("effective_on >= ?", 1.year.ago.to_date) + .includes(:recurring_transaction) + .order(effective_on: :desc) + .limit(10) + end + + def total_of_series(series_list, &value_of) + return [ nil, 0 ] if series_list.empty? + + target = Current.family.currency + unconvertible = 0 + + total = series_list.reduce(Money.new(0, target)) do |sum, series| + begin + sum + value_of.call(series).exchange_to(target) + rescue Money::ConversionError + unconvertible += 1 + sum + end + end + + [ total, unconvertible ] + end + + # Months are materialized on demand up to 13 months out (idempotent + # upserts, so navigation is free to re-visit); navigation caps there, + # which keeps every rendered chip a real, clickable occurrence. + CALENDAR_FORWARD_LIMIT_MONTHS = 13 + + def load_calendar + today = Date.current + @month = begin + Date.strptime(params[:month].to_s, "%Y-%m").beginning_of_month + rescue ArgumentError + today.beginning_of_month + end + + limit = (today + CALENDAR_FORWARD_LIMIT_MONTHS.months).beginning_of_month + @month = limit if @month > limit + @at_forward_limit = @month >= limit + + @grid_start = @month.beginning_of_week(:sunday) + @grid_end = @month.end_of_month.end_of_week(:sunday) + + materialize_for_calendar(@grid_end) if @grid_end > today + 89 + + occurrences = Current.family.recurring_occurrences + .where(recurring_transaction_id: payable_series_ids) + .due_between(@grid_start, @grid_end) + .includes(recurring_transaction: :merchant) + .to_a + preload_allocation_sums(occurrences) + + @by_day = occurrences.group_by(&:due_on) + month_occurrences = occurrences.select { |occurrence| occurrence.due_on.between?(@month, @month.end_of_month) } + @month_expected, @month_unconvertible = total_of(month_occurrences) { |occurrence| occurrence.resolved_expected_amount_money } + @month_paid, _ = total_of(month_occurrences) { |occurrence| occurrence.confirmed_allocated_money } + end + + def materialize_for_calendar(through) + Current.family.recurring_transactions + .active + .where(id: payable_series_ids) + .find_each do |series| + RecurringTransaction::OccurrenceGenerator.new(series).generate!(through: through) + end + end + + def ytd_paid_total + RecurringAllocation.confirmed + .joins(:recurring_occurrence) + .where(recurring_occurrences: { recurring_transaction_id: @series.id }) + .where("recurring_allocations.paid_on >= ?", Date.current.beginning_of_year) + .sum(:allocated_amount) + end + + # Open occurrences through the horizon plus everything closed this + # month, for every payable series (bills, subscriptions, and debt + # payments alike). Inactive series ride along so their leftover open + # occurrences can render as Dormant instead of haunting Past Due. + def payable_series_ids + debt_accounts = Account.where(accountable_type: %w[CreditCard Loan]).select(:id) + + Current.family.recurring_transactions + .where(status: %w[active inactive]) + .where("amount > 0") + .merge( + RecurringTransaction.where(destination_account_id: nil) + .or(RecurringTransaction.where(destination_account_id: debt_accounts)) + ) + .accessible_by(Current.user) + .select(:id) + end + + def payable_occurrences + # Price changes ride along because bills_attention_reason asks every + # row whether its amount changed recently. + Current.family.recurring_occurrences + .where(recurring_transaction_id: payable_series_ids) + .where("due_on >= ? OR status = 'scheduled'", Date.current.beginning_of_month) + .where("due_on <= ?", Date.current + 90) + .includes(recurring_transaction: [ :merchant, :recurring_price_changes ]) + .to_a + end + + def preload_allocation_sums(occurrences) + sums = RecurringAllocation.confirmed + .where(recurring_occurrence_id: occurrences.map(&:id)) + .group(:recurring_occurrence_id) + .sum(:allocated_amount) + + occurrences.each do |occurrence| + occurrence.cached_confirmed_allocated = sums.fetch(occurrence.id, 0) + end + end + + # The month is the right container for planning and the wrong unit for + # anyone whose income does not arrive monthly. Paid weekly, "this month" + # collapses four paychecks and four rent payments into one list. + # + # These are markers inside the month, not a regrouping of it. Only returned + # when income actually subdivides the month: monthly income yields a single + # overlapping period and undeclared income yields none, and in both cases + # the list renders exactly as it did before. + def month_pay_periods(today, month_end) + periods = RecurringTransaction::PaycheckPlanner + .new(Current.family, user: Current.user) + .plan(periods_limit: PAY_PERIOD_LIMIT) + return [] if periods.blank? + + overlapping = periods.select do |period| + period.starts_on <= month_end && period.ends_on >= today + end + + overlapping.size > 1 ? overlapping : [] + end + + def compute_kpis(today, month_end) + owed_now = @overdue + @this_month + + @remaining_this_month, @unconvertible_count = total_of(owed_now) { |occurrence| occurrence.remaining_amount_money } + @paid_this_month_total, _ = total_of(@paid_this_month) { |occurrence| occurrence.confirmed_allocated_money } + @due_next_seven, _ = total_of(owed_now.select { |occurrence| occurrence.effective_due_on <= today + 7 }) { |occurrence| occurrence.remaining_amount_money } + @past_due_total, _ = total_of(@overdue) { |occurrence| occurrence.remaining_amount_money } + @owed_count = owed_now.size + @needs_action_count = owed_now.count { |occurrence| !occurrence.recurring_transaction.autopay? } + end + + # A trial converting tomorrow and a month-old one-dollar price rise are not + # the same news. Notices used to sort by date ascending, which put the + # oldest and smallest first and buried the one thing you could still act on. + TRIAL_URGENT_DAYS = 3 + MATERIAL_PRICE_SHIFT = 0.10 + + Notice = Data.define(:kind, :series, :date, :detail) do + def urgent? + case kind + when :trial then date <= Date.current + TRIAL_URGENT_DAYS + when :price then price_shift >= MATERIAL_PRICE_SHIFT + else false + end + end + + # How far a price moved, as a fraction of what it was. A dollar on a + # ten-dollar subscription is worth saying; a dollar on the rent is not. + def price_shift + return 0 unless kind == :price && detail&.previous_amount.to_d.positive? + + ((detail.new_amount - detail.previous_amount).abs / detail.previous_amount).to_f + end + + def price_percent + return 0 unless kind == :price && detail&.previous_amount.to_d.positive? + + ((detail.new_amount - detail.previous_amount) / detail.previous_amount * 100).round + end + + # Nearness to today in either direction: a change three days ago and a + # renewal in three days are both current news. + def distance + (date - Date.current).to_i.abs + end + end + + # Lightweight, page-native reminders: the Insights pipeline is + # preview-gated, so anything that must reach EVERY user renders here. + def collect_notices + today = Date.current + window = today..(today + 14) + series_scope = Current.family.recurring_transactions.accessible_by(Current.user).active + + notices = [] + series_scope.where(trial_ends_on: window).find_each do |series| + notices << Notice.new(kind: :trial, series: series, date: series.trial_ends_on, detail: nil) + end + series_scope.where(renews_on: window).find_each do |series| + notices << Notice.new(kind: :renewal, series: series, date: series.renews_on, detail: nil) + end + RecurringPriceChange.joins(:recurring_transaction) + .merge(RecurringTransaction.accessible_by(Current.user)) + .where(recurring_transactions: { family_id: Current.family.id }) + .where("effective_on >= ?", today - 30) + .includes(:recurring_transaction) + .find_each do |change| + notices << Notice.new(kind: :price, series: change.recurring_transaction, date: change.effective_on, detail: change) + end + + notices.sort_by { |notice| [ notice.urgent? ? 0 : 1, notice.distance ] } + end + + # Detection has been creating recurring rows from bank data since long + # before this page existed, so a family arriving here for the first time + # meets bills nobody ever confirmed. Counts them, and returns zero the + # moment there is any sign the user has worked with Bills at all -- a + # declared bill, a dismissed suggestion, or a payment they recorded + # themselves -- so the prompt clears itself and needs no stored state. + def detected_awaiting_review + series = Current.family.recurring_transactions.accessible_by(Current.user) + return 0 if series.where(manual: true).exists? + return 0 if series.where(status: :ended).exists? + + user_touched = RecurringAllocation.where.not(source: :auto_matched) + .joins(:recurring_occurrence) + .where(recurring_occurrences: { family_id: Current.family.id }) + return 0 if user_touched.exists? + + series.where(manual: false, status: :active).count + end + + def accessible_suggested_series + Current.family.recurring_transactions + .accessible_by(Current.user) + .suggested + end + + # Family-wide, not user-scoped: occurrence materialization is the same + # machinery the sync job runs, and a partial per-user generation would + # leave the family half-materialized forever. + def materialize_missing_occurrences + Current.family.recurring_transactions.active.find_each do |series| + RecurringTransaction::OccurrenceGenerator.new(series).generate! + end + end + + def suggested_allocations + RecurringAllocation + .suggested + .joins(recurring_occurrence: :recurring_transaction) + .where(recurring_occurrences: { family_id: Current.family.id }) + .merge(RecurringTransaction.accessible_by(Current.user)) + # Income never reviews here: the matcher no longer suggests it, and + # this filter also retires any suggestion written before that rule. + .merge(RecurringTransaction.where.not(bill_type: "income")) + .includes(:entry, recurring_occurrence: { recurring_transaction: :merchant }) + # The confidence the matcher scored these with was sitting unused on + # the row while the queue ordered itself by when the job happened to + # run. Most-certain question first. + .order(match_confidence: :desc, created_at: :asc) + end + + # Converted into the family currency because the headline answers "how + # much do I owe", which is one number. A pair with no rate is left out + # and counted rather than silently understating the total. Returns + # [total, unconvertible_count] so each caller keeps its own count. + def total_of(occurrences, &value_of) + return [ nil, 0 ] if occurrences.empty? + + target = Current.family.currency + unconvertible = 0 + + total = occurrences.reduce(Money.new(0, target)) do |sum, occurrence| + begin + sum + value_of.call(occurrence).exchange_to(target) + rescue Money::ConversionError + unconvertible += 1 + sum + end + end + + [ total, unconvertible ] + end +end diff --git a/app/controllers/bills_feeds_controller.rb b/app/controllers/bills_feeds_controller.rb new file mode 100644 index 000000000..d6f638b13 --- /dev/null +++ b/app/controllers/bills_feeds_controller.rb @@ -0,0 +1,75 @@ +# Read-only iCal feed of upcoming bill occurrences, so calendar apps can +# subscribe (an entire third-party product exists to do this for a +# competitor). Token-authenticated and sessionless, and deliberately +# obligations only -- no balances, no accounts. +# +# The token is signed and names the MEMBER, not the family: sharing is per +# account, so each member's feed carries only the bills they can reach in +# the app. The signature binds a digest of the family's stored feed secret, +# which is how resetting that secret still revokes every previously shared +# URL in one stroke. +class BillsFeedsController < ApplicationController + skip_authentication + + HORIZON_DAYS = 90 + + def show + payload = Family.bills_feed_verifier.verified(params[:token].to_s) + user_id, stamp = payload if payload.is_a?(Array) + user = User.find_by(id: user_id) + family = user&.family + raise ActiveRecord::RecordNotFound unless family && stamp.present? && stamp == family.bills_feed_stamp + + # Preview-gated like every other bills surface. Sessionless, so the gate + # reads the member the token names: opting out of preview features (or the + # family switching recurring off) kills retained URLs immediately, not + # only after a token reset. + raise ActiveRecord::RecordNotFound unless user.preview_features_enabled? && !family.recurring_transactions_disabled? + + occurrences = family.recurring_occurrences + .open_status + .joins(:recurring_transaction) + .merge(RecurringTransaction.accessible_by(user)) + .where(recurring_transactions: { status: :active }) + .where("recurring_transactions.amount > 0") + .where(due_on: Date.current..(Date.current + HORIZON_DAYS)) + .includes(:recurring_transaction) + .order(:due_on) + + I18n.with_locale(family.locale.presence || I18n.default_locale) do + render plain: to_ical(occurrences), content_type: "text/calendar" + end + rescue ActiveRecord::RecordNotFound + head :not_found + end + + private + def to_ical(occurrences) + events = occurrences.map do |occurrence| + series = occurrence.recurring_transaction + amount = Money.new(occurrence.resolved_expected_amount, occurrence.currency).format + + <<~EVENT + BEGIN:VEVENT + UID:#{occurrence.id}@sure-bills + DTSTAMP:#{Time.current.utc.strftime("%Y%m%dT%H%M%SZ")} + DTSTART;VALUE=DATE:#{occurrence.effective_due_on.strftime("%Y%m%d")} + SUMMARY:#{escape_ical(series.display_name)} (#{escape_ical(amount)}) + END:VEVENT + EVENT + end + + <<~ICAL + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Sure//Bills//EN + X-WR-CALNAME:#{escape_ical(I18n.t("bills.feed.calendar_name"))} + #{events.join} + END:VCALENDAR + ICAL + end + + def escape_ical(text) + text.to_s.gsub("\\", "\\\\\\\\").gsub(",", "\\,").gsub(";", "\\;").gsub("\n", " ") + end +end diff --git a/app/controllers/concerns/recurring_feature_guardable.rb b/app/controllers/concerns/recurring_feature_guardable.rb new file mode 100644 index 000000000..9961c9797 --- /dev/null +++ b/app/controllers/concerns/recurring_feature_guardable.rb @@ -0,0 +1,36 @@ +# Shared guards for the Bills / recurring-transactions surfaces: every +# controller in the subsystem bails to the home page when the user hasn't +# opted into preview features or the family has switched the feature off, +# and the dialog surfaces drop the layout for turbo-frame requests so the +# shared modal frame stays unique in the response. +module RecurringFeatureGuardable + extend ActiveSupport::Concern + + private + # Bills ships as a preview feature, so the per-user gate runs first and + # carries the flash that points at Settings -> Preferences. The family's + # recurring toggle still applies to opted-in users. + def ensure_recurring_enabled + require_preview_features! + return if performed? + + redirect_to root_path if Current.family.recurring_transactions_disabled? + end + + def dialog_layout + turbo_frame_request? ? false : "settings" + end + + # Turbo-stream redirects take a raw URL, so the referer has to be validated + # the way redirect_back_or_to already validates it for HTML: same host, or + # the caller's fallback. + def safe_return_path(fallback:) + referer = request.referer + return fallback if referer.blank? + + uri = URI.parse(referer) + uri.host.nil? || uri.host == request.host ? referer : fallback + rescue URI::InvalidURIError + fallback + end +end diff --git a/app/controllers/recurring_allocations_controller.rb b/app/controllers/recurring_allocations_controller.rb new file mode 100644 index 000000000..01473017f --- /dev/null +++ b/app/controllers/recurring_allocations_controller.rb @@ -0,0 +1,138 @@ +class RecurringAllocationsController < ApplicationController + include RecurringFeatureGuardable + + before_action :ensure_recurring_enabled + + def create + occurrence = find_occurrence(params[:recurring_occurrence_id]) + ensure_series_writable(occurrence) + entry = find_entry(occurrence, params[:entry_id]) + + RecurringTransaction::Allocator.new(occurrence).allocate!( + entry: entry, + amount: params[:amount].presence, + # Defaults to today via RecurringAllocation's callback; accepting a date + # lets someone record last Tuesday's payment as last Tuesday. + paid_on: parse_paid_on(params[:paid_on]) + ) + + redirect_with notice: t(".success") + rescue RecurringTransaction::Allocator::OverAllocationError, + RecurringTransaction::Allocator::MissingRateError, + ActiveRecord::RecordInvalid, + ActiveRecord::RecordNotUnique, + ArgumentError => e + redirect_with alert: allocation_error_message(e) + end + + def destroy + allocation = find_allocation + occurrence = allocation.recurring_occurrence + ensure_series_writable(occurrence) + + RecurringTransaction::Allocator.new(occurrence).unallocate!(allocation) + + redirect_with notice: t(".success") + end + + def confirm + allocation = find_allocation + occurrence = allocation.recurring_occurrence + ensure_series_writable(occurrence) + + RecurringTransaction::Allocator.new(occurrence).confirm_suggestion!(allocation) + + redirect_with_return notice: t(".success") + end + + def reject + allocation = find_allocation + occurrence = allocation.recurring_occurrence + ensure_series_writable(occurrence) + + RecurringTransaction::Allocator.new(occurrence).reject_suggestion!(allocation) + + redirect_with_return notice: t(".success") + end + + private + # Active Record casts an unparseable date to nil, and a nil paid_on records + # the payment as today. Parsing here raises Date::Error (an ArgumentError), + # which the create rescue turns into the invalid-allocation message. + def parse_paid_on(raw) + return nil if raw.blank? + + Date.iso8601(raw.to_s) + end + + # Reading a shared bill is fine; changing its payment state is not. Sharing + # is per account, so a read-only account share must not mutate. Accountless + # series carry no account gate. + def ensure_series_writable(occurrence) + series = occurrence.recurring_transaction + return if series.account_id.nil? + return if Account.writable_by(Current.user).where(id: series.account_id).exists? + + raise ActiveRecord::RecordNotFound + end + + def find_allocation + RecurringAllocation + .joins(recurring_occurrence: :recurring_transaction) + .where(recurring_occurrences: { family_id: Current.family.id }) + .merge(RecurringTransaction.accessible_by(Current.user)) + .find(params[:id]) + end + + # Queue actions come from the Bills page and should land back there. The + # same-host referer check lives in RecurringFeatureGuardable#safe_return_path. + def redirect_with_return(notice:) + flash[:notice] = notice + target = safe_return_path(fallback: bills_path) + + respond_to do |format| + format.html { redirect_to target } + format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, target) } + end + end + + def find_occurrence(id) + Current.family.recurring_occurrences + .joins(:recurring_transaction) + .merge(RecurringTransaction.accessible_by(Current.user)) + .find(id) + end + + # Scoped to what this user can see: sharing is per account, so a family + # scope alone would let a member pay with another account's transaction. + def find_entry(occurrence, entry_id) + return nil if entry_id.blank? + + Current.accessible_entries.find(entry_id) + end + + def allocation_error_message(error) + case error + when RecurringTransaction::Allocator::OverAllocationError then t("recurring_allocations.over_allocation") + when RecurringTransaction::Allocator::MissingRateError then t("recurring_allocations.missing_rate") + when ActiveRecord::RecordNotUnique then t("recurring_allocations.already_allocated") + else t("recurring_allocations.invalid") + end + end + + # Back to the worklist, not the occurrence: a plain GET of + # recurring_occurrence_path renders the settings layout, which already emits + # an empty , so the page would carry two frames + # sharing one id. See the two-frames trap in + # RecurringTransactionsController#edit. + def redirect_with(notice: nil, alert: nil) + flash[:notice] = notice if notice + flash[:alert] = alert if alert + target = bills_path + + respond_to do |format| + format.html { redirect_to target } + format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, target) } + end + end +end diff --git a/app/controllers/recurring_occurrences_controller.rb b/app/controllers/recurring_occurrences_controller.rb new file mode 100644 index 000000000..b56f13c65 --- /dev/null +++ b/app/controllers/recurring_occurrences_controller.rb @@ -0,0 +1,204 @@ +class RecurringOccurrencesController < ApplicationController + include RecurringFeatureGuardable + + layout "settings" + + before_action :ensure_recurring_enabled + before_action :set_occurrence + before_action :ensure_series_writable, only: %i[mark_paid skip reopen snooze override_amount] + + # The dialog is delivered into the shared (see + # RecurringTransactionsController#edit for the two-frames trap). + def show + @candidate_query = params[:q].to_s.strip + @pending_suggestion = @occurrence.allocations.suggested.includes(:entry).first + @ranked_candidates = ranked_candidates + ranked_ids = @ranked_candidates.map { |entry, _| entry.id } + # The amount-nearness list is the fallback, so it must not repeat what the + # ranked list promoted. The extra fetch keeps it full after subtraction. + @other_entries = candidate_entries.reject { |entry| ranked_ids.include?(entry.id) }.first(FALLBACK_SHOWN) + + render layout: dialog_layout + end + + def mark_paid + allocator.mark_paid! + redirect_after_action t(".success") + end + + def skip + @occurrence.skip! + redirect_after_action t(".success") + end + + def reopen + @occurrence.reopen! + redirect_after_action t(".success") + end + + def snooze + until_date = Date.parse(params.require(:until)) + @occurrence.snooze!(until_date) + redirect_after_action t(".success", date: l(until_date, format: :long)) + # TypeError covers non-scalar params (until[]=...), which Date.parse raises + # on before ArgumentError gets a chance; both are the same user mistake. + rescue ArgumentError, TypeError + redirect_after_action t(".invalid_date"), alert: true + end + + def override_amount + @occurrence.override_amount!(params[:amount]) + redirect_after_action t(".success") + end + + private + def set_occurrence + @occurrence = Current.family.recurring_occurrences + .joins(:recurring_transaction) + .merge(RecurringTransaction.accessible_by(Current.user)) + .find(params[:id]) + end + + # Reading a shared bill is fine; changing its payment state is not. Sharing + # is per account, so a read-only account share must not mutate. Accountless + # series carry no account gate. + def ensure_series_writable + series = @occurrence.recurring_transaction + return if series.account_id.nil? + return if Account.writable_by(Current.user).where(id: series.account_id).exists? + + raise ActiveRecord::RecordNotFound + end + + def allocator + RecurringTransaction::Allocator.new(@occurrence) + end + + def redirect_after_action(message, alert: false) + flash[alert ? :alert : :notice] = message + target = bills_path + + respond_to do |format| + format.html { redirect_to target } + format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, target) } + end + end + + # How many entries the ranked pass will score in Ruby, and how many of each + # list survives to the view. + RANKED_SCAN_LIMIT = 200 + RANKED_SHOWN = 6 + FALLBACK_SHOWN = 15 + + # Hard filters both lists share: right account, sign, currency, a real + # transaction, not already allocated to THIS occurrence. Cross-currency + # attaches go through an explicit amount instead. + # + # The raw join is load-bearing: every `where(transactions: {...})` below is + # only legal because it puts that table in the query. + def candidate_scope + series = @occurrence.recurring_transaction + # Accountless bills fall back to the accounts this user can see, never + # the whole family: sharing is per account, and the drawer would + # otherwise print entries from accounts that were never shared. + base = if series.account.present? + series.account.entries + else + Current.family.entries.where(account_id: Account.accessible_by(Current.user).select(:id)) + end + sign = series.amount.negative? ? "entries.amount < 0" : "entries.amount > 0" + + scope = base + .where(entryable_type: "Transaction") + .where(currency: @occurrence.currency) + .where(sign) + .where.not(id: @occurrence.allocations.where.not(entry_id: nil).select(:entry_id)) + .joins("INNER JOIN transactions ON transactions.id = entries.entryable_id") + + return scope if series.transfer? + + scope.where.not(transactions: { kind: Transaction::TRANSFER_KINDS }) + end + + # Shared by both lists so they cannot disagree about which dates exist. + def candidate_window + (@occurrence.due_on - 40)..[ @occurrence.due_on + 40, Date.current ].min + end + + # Ranked by the same engine that decides auto-links, so the picker and the + # pipeline agree on what a match is, and the rejection table is honoured. + # + # Entries already allocated to another occurrence are deliberately kept: one + # charge can legitimately pay two bills, which is why + # Allocator#guard_entry_capacity! exists. Over-allocation is refused at + # write time, never hidden at read time. + def ranked_candidates + series = @occurrence.recurring_transaction + matcher = RecurringTransaction::Matcher.new(Current.family) + + scope = candidate_scope + .where(date: candidate_window) + .where(excluded: false) + .where.not(id: RecurringMatchRejection.where(recurring_transaction: series).select(:entry_id)) + # preload, not includes: entryable is polymorphic so it can never be + # joined, and naming it keeps the raw transactions join above out of + # Rails' eager-load machinery. + .preload(:entryable) + .limit(RANKED_SCAN_LIMIT) + + scope = if series.merchant_id.present? + scope.where(transactions: { merchant_id: series.merchant_id }) + else + patterns = matcher_name_patterns(series) + return [] if patterns.empty? + + scope.where("entries.name ILIKE ANY (ARRAY[?])", patterns) + end + + scored = scope.filter_map do |entry| + explanation = matcher.explain(@occurrence, entry) + [ entry, explanation ] if explanation + end + + scored.sort_by { |_entry, explanation| -explanation.confidence }.first(RANKED_SHOWN) + end + + # The names the matcher itself would recognize: the series' own name plus + # any alias a manual attach has taught it. + def matcher_name_patterns(series) + ([ series.name ] + Array(series.matcher_hints["name_aliases"])) + .compact_blank + .map { |name| "%#{ActiveRecord::Base.sanitize_sql_like(name)}%" } + end + + # Entries a user would plausibly attach by hand, ordered by how close the + # amount is rather than by how recent the transaction is. A bill is nearly + # always settled by a charge for its own amount, so date order buried the + # right answer: on a $6.44 bill with 77 candidates in the window, all four + # near-amount matches sat outside the fifteen shown. + # + # This is the fallback now -- what you browse when the ranked list did not + # have it. A search looks past the date window entirely, because the + # payment being hunted for is usually the one the window already excluded. + def candidate_entries + scope = if @candidate_query.present? + candidate_scope.where("entries.name ILIKE :q", q: "%#{ActiveRecord::Base.sanitize_sql_like(@candidate_query)}%") + else + candidate_scope.where(date: candidate_window) + end + + scope.order(candidate_relevance_sql).limit(FALLBACK_SHOWN + RANKED_SHOWN) + end + + def candidate_relevance_sql + remaining = @occurrence.remaining_amount + target = remaining.positive? ? remaining : @occurrence.resolved_expected_amount + + Arel.sql( + ActiveRecord::Base.sanitize_sql_array([ + "ABS(ABS(entries.amount) - ?) ASC, ABS(entries.date - ?) ASC, entries.date DESC", + target, @occurrence.due_on + ]) + ) + end +end diff --git a/app/controllers/recurring_transactions/smart_fills_controller.rb b/app/controllers/recurring_transactions/smart_fills_controller.rb new file mode 100644 index 000000000..3a6d3359a --- /dev/null +++ b/app/controllers/recurring_transactions/smart_fills_controller.rb @@ -0,0 +1,95 @@ +# Inherits RecurringTransactionsController so the re-rendered "new" template +# resolves its relative partials ("form") against recurring_transactions/. +class RecurringTransactions::SmartFillsController < RecurringTransactionsController + include BillsHelper + include RecurringFeatureGuardable + + guard_feature unless: -> { bills_one_shot_ai_available? } + before_action :ensure_recurring_enabled + + # Smart-fill re-renders the add-bill dialog with AI-proposed values. It only + # exists on the entry-prefilled variant: the picked transaction anchors the + # evidence (its charge history), so the model infers rather than guesses -- + # cadence from date gaps, the due day, autopay markers. Synchronous: one + # small LLM call, the same latency class as the identify action (slow local + # models may want a generous request timeout). + def create + income = params[:income].present? + @recurring_transaction = Current.family.recurring_transactions.new( + frequency_preset: income ? "biweekly" : "monthly", + first_due_on: Date.current + ) + @recurring_transaction.is_income = income + + entry = Current.accessible_entries.find_by(id: params[:entry_id]) + + if entry.nil? + @smart_fill_error = t(".failed") + return render "recurring_transactions/new", layout: dialog_layout + end + + prefill_recurring_from_entry(entry) + + begin + suggestion = RecurringTransaction::AiSetupSuggester + .new(Current.family, user: Current.user) + .suggest_from_entries(evidence_entries(entry, income: income)) + apply_suggestion(suggestion) + @smart_fill = suggestion + rescue RecurringTransaction::AiSetupSuggester::Error => e + Rails.logger.warn("Smart fill failed for entry #{entry.id}: #{e.class}: #{e.message}") + @smart_fill_error = t(".failed") + end + + render "recurring_transactions/new", layout: dialog_layout + end + + private + # The picked entry's own history: same account, same sign, same name + # shape. Recency-ordered so drifting amounts weight toward the present. + def evidence_entries(entry, income:) + pattern = "%#{ActiveRecord::Base.sanitize_sql_like(entry.name.to_s)}%" + + entry.account.entries + .where(entryable_type: "Transaction") + .where(income || entry.amount.negative? ? "entries.amount < 0" : "entries.amount > 0") + .where("entries.name ILIKE ?", pattern) + .order(date: :desc) + .limit(RecurringTransaction::AiSetupSuggester::MAX_CHARGES) + .to_a + .presence || [ entry ] + end + + # Only fields the add form actually carries; anything else the suggestion + # knows (category, kind) has no field to land in here and is dropped. + # The due date follows the cadence's own anchor: weekday for weekly-style + # presets, month for annual, day-of-month for the monthly-style rest. + def apply_suggestion(suggestion) + @recurring_transaction.name = suggestion.name if suggestion.name.present? + @recurring_transaction.amount = suggestion.amount if suggestion.amount.present? + @recurring_transaction.frequency_preset = suggestion.frequency if suggestion.frequency.present? + @recurring_transaction.autopay = suggestion.autopay unless suggestion.autopay.nil? + + today = Date.current + if %w[weekly biweekly].include?(suggestion.frequency) && suggestion.weekday.present? + @recurring_transaction.first_due_on = today + ((suggestion.weekday - today.wday) % 7) + elsif suggestion.frequency == "annual" && suggestion.month_of_year.present? + @recurring_transaction.first_due_on = next_annual_occurrence( + today, suggestion.month_of_year, + suggestion.day_of_month || @recurring_transaction.first_due_on.day + ) + elsif suggestion.day_of_month.present? + @recurring_transaction.first_due_on = + RecurringTransaction::Schedule.new(expected_day_of_month: suggestion.day_of_month).next_occurrence_from_today + end + end + + # The next date landing on (month, day), day clamped to the month's + # length, rolled a year forward once this year's is past. + def next_annual_occurrence(today, month, day) + candidate = Date.new(today.year, month, [ day, Date.new(today.year, month, -1).day ].min) + return candidate if candidate >= today + + Date.new(today.year + 1, month, [ day, Date.new(today.year + 1, month, -1).day ].min) + end +end diff --git a/app/controllers/recurring_transactions_controller.rb b/app/controllers/recurring_transactions_controller.rb index 723e166d8..914b742b0 100644 --- a/app/controllers/recurring_transactions_controller.rb +++ b/app/controllers/recurring_transactions_controller.rb @@ -1,14 +1,71 @@ +# Bills subsystem: this controller gained the declared-bill and declared-income +# create paths, editable identity and frequency, suggestion confirm/dismiss, and +# schedule pinning on a hand-set cadence. class RecurringTransactionsController < ApplicationController + include RecurringFeatureGuardable + layout "settings" + # Small on purpose: the picker narrows by searching, not by paging, and a + # fixed cap keeps the dialog free of pagination (whose shared partial + # targets _top and cannot live inside a turbo frame). + PICKER_SHOWN = 20 + + # The declare, edit and suggestion paths shipped with Bills and sit behind + # its preview gate like every other Bills surface. The actions that predate + # Bills (index, toggle_status, destroy, update_settings, identify, cleanup) + # keep their historical reach. + before_action :ensure_recurring_enabled, only: %i[new create edit update confirm dismiss] + before_action :set_recurring_transaction, only: %i[edit update toggle_status destroy confirm dismiss] + before_action :ensure_series_writable, only: %i[update toggle_status destroy confirm dismiss] + def index - @recurring_transactions = Current.family.recurring_transactions - .accessible_by(Current.user) - .includes(:merchant) - .order(status: :asc, next_expected_date: :asc) + scope = Current.family.recurring_transactions + .accessible_by(Current.user) + .includes(:merchant) + + # Fresh detections wait in their own review strip until confirmed; they + # are not real bills yet and would only be noise inside the main table. + @suggested = scope.suggested.order(next_expected_date: :asc) + @recurring_transactions = scope.where.not(status: :suggested) + .order(status: :asc, next_expected_date: :asc) @family = Current.family end + # Detection proposes, the user disposes: confirming makes the suggestion a + # real, active bill; dismissing tombstones it as `ended`, which the + # Identifier treats as "never suggest this again". + def confirm + first_confirmation = @recurring_transaction.suggested? + @recurring_transaction.update!(status: "active") + + # A just-confirmed bill shows its lived history rather than starting + # blank. Guarded so a replayed POST does not re-run the backfill; the + # matcher pass inside is family-wide on purpose (exact-tier, idempotent, + # and scoping it would need a parallel Matcher entry point). Taken under + # the family lock so it cannot interleave with a running pipeline; when + # the lock is held the backfill is skipped and the confirm still succeeds. + if first_confirmation + RecurringTransaction::Pipeline.with_family_lock(Current.family.id) do + RecurringTransaction::HistoryBackfiller.new( + Current.family, + months: RecurringTransaction::Pipeline::FIRST_RUN_BACKFILL_MONTHS, + series_scope: Current.family.recurring_transactions.where(id: @recurring_transaction.id) + ).run! + end + end + + flash[:notice] = t("recurring_transactions.confirmed") + redirect_back_or_to recurring_transactions_path + end + + def dismiss + @recurring_transaction.update!(status: "ended") + + flash[:notice] = t("recurring_transactions.dismissed") + redirect_back_or_to recurring_transactions_path + end + def update_settings Current.family.update!(recurring_settings_params) @@ -21,11 +78,18 @@ class RecurringTransactionsController < ApplicationController end def identify - count = RecurringTransaction.identify_patterns_for!(Current.family) + # User-triggered detection always reconstructs history; the backfiller is + # idempotent. nil means another run already holds the family lock. + result = RecurringTransaction::Pipeline.new(Current.family).run_with_lock!(backfill: true) respond_to do |format| format.html do - flash[:notice] = t("recurring_transactions.identified", count: count) + flash[:notice] = + if result.nil? + t("recurring_transactions.identify_already_running") + else + t("recurring_transactions.identified", count: result) + end redirect_to recurring_transactions_path end end @@ -42,9 +106,110 @@ class RecurringTransactionsController < ApplicationController end end - def toggle_status - @recurring_transaction = Current.family.recurring_transactions.accessible_by(Current.user).find(params[:id]) + # Optionally pre-filled from an existing transaction (entry_id param): the + # fastest declare path for a bill that already hits the ledger -- name, + # amount, account and a projected next-due all come from the entry. + def new + income = params[:income].present? + # "Not seeing what you're looking for?": a picker over every transaction, + # for when the detected candidates don't include the charge on the + # statement in the user's hand. Same URL and frame as the dialog it + # replaces, and a result row is just the entry_id prefill link the + # candidate strip already uses. + if params[:picker].present? + @is_income = income + @picker_query = params[:q].to_s.strip + @picker_entries = picker_entries(income: income) + @picker_capped = @picker_entries.size == PICKER_SHOWN + @claimed_by = claimed_series_names(@picker_entries) + return render :pick_entry, layout: dialog_layout + end + + @recurring_transaction = Current.family.recurring_transactions.new( + # Paychecks default to the most common pay cadence; bills to monthly. + frequency_preset: income ? "biweekly" : "monthly", + first_due_on: Date.current + ) + @recurring_transaction.is_income = income + + # Accessible, not merely same-family: prefilling reads the entry's name, + # amount and account straight back to the user. + if (entry = Current.accessible_entries.find_by(id: params[:entry_id])) + prefill_recurring_from_entry(entry) + else + # Fresh dialog: offer detected-but-undeclared recurring shapes as + # optional starting points. Picking one reloads the dialog prefilled + # through the entry_id path above; it never replaces manual entry. + @candidates = declare_candidates(income: income) + end + + render layout: dialog_layout + end + + # Declared bills are the manual-first path: Name, Amount, Due date, + # Frequency, done. The due date carries the day-of-month / weekday detail + # the frequency needs, so the form never asks twice. + def create + @recurring_transaction = build_declared_bill + + if @recurring_transaction.errors.none? && save_declared_bill + flash[:notice] = @recurring_transaction.typed_income? ? t(".success_income") : t(".success") + + respond_to do |format| + format.html { redirect_to bills_path } + format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, bills_path) } + end + else + render :new, status: :unprocessable_entity, layout: dialog_layout + end + end + + # The dialog is delivered into the shared that every page + # layout already renders empty. Responding with the full "settings" layout would put + # two frames with that id in one response, and Turbo matches the empty one first, so + # the dialog never opens. Drop the layout for frame requests, as `categories#merge` + # does, and keep it for a direct visit to the URL. + def edit + assign_frequency_form_state + @sibling_count = sibling_scope.count + + render layout: dialog_layout + end + + def update + @recurring_transaction.assign_attributes(recurring_transaction_params) + apply_editable_identity + apply_frequency_preset + + if @recurring_transaction.typed_installment? && @recurring_transaction.end_after_count.present? + @recurring_transaction.end_mode = "after_count" + @recurring_transaction.anchor_date ||= @recurring_transaction.last_occurrence_date + end + + # apply_editable_identity flags a bad account id; save would wipe that + # error while validating, so it is checked first. + if @recurring_transaction.errors.none? && @recurring_transaction.save + applied = apply_payment_url_to_siblings + + flash[:notice] = if applied.positive? + t(".success_with_siblings", count: applied) + else + t(".success") + end + + respond_to do |format| + format.html { redirect_back_or_to recurring_transactions_path } + format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, safe_return_path(fallback: recurring_transactions_path)) } + end + else + @sibling_count = sibling_scope.count + + render :edit, status: :unprocessable_entity, layout: dialog_layout + end + end + + def toggle_status if @recurring_transaction.active? @recurring_transaction.mark_inactive! message = t("recurring_transactions.marked_inactive") @@ -62,15 +227,277 @@ class RecurringTransactionsController < ApplicationController end def destroy - @recurring_transaction = Current.family.recurring_transactions.accessible_by(Current.user).find(params[:id]) - @recurring_transaction.destroy! + income = @recurring_transaction.typed_income? - flash[:notice] = t("recurring_transactions.deleted") - redirect_to recurring_transactions_path + # A detected row is removed by tombstoning it rather than deleting it: the + # pattern is still in the bank data, so a hard delete lasts only until the + # next sync rebuilds it. `ended` is the marker dismissing a suggestion + # already leaves, and the Identifier will not claim or recreate one. + # + # A hand-declared bill has no pattern behind it, so nothing would bring it + # back and it is deleted outright. + if @recurring_transaction.manual? + @recurring_transaction.destroy! + else + @recurring_transaction.update!(status: "ended") + end + + flash[:notice] = t(income ? "recurring_transactions.deleted_income" : "recurring_transactions.deleted") + redirect_back_or_to bills_path end + protected + + # Seeds the dialog's model from an existing transaction. Shared with the + # smart-fill path, so a failed suggestion still leaves the user exactly + # where the plain prefill would have. + def prefill_recurring_from_entry(entry) + @recurring_transaction.name = entry.entryable.try(:merchant)&.name.presence || entry.name + @recurring_transaction.amount = entry.amount.abs + @recurring_transaction.account_id = entry.account_id + # A negative entry is an inflow: pre-fill as income, not as a bill. + @recurring_transaction.is_income = true if entry.amount.negative? + @recurring_transaction.first_due_on = + RecurringTransaction::Schedule.new(expected_day_of_month: entry.date.day).next_occurrence_from_today + end + private + # Sign-filtered detected patterns not yet covered by any series, mapped + # to what the picker renders. Each candidate carries its latest entry's + # id so selection can ride the existing entry_id prefill path. Patterns + # are family-wide, so they are filtered to the accounts this user can + # actually reach. + def declare_candidates(income:) + identifier = RecurringTransaction::Identifier.new(Current.family) + accessible_ids = Current.user.accessible_accounts.pluck(:id) + + patterns = if income + # Already sorted heaviest-source-first, so the paycheck leads. + identifier.income_source_candidates + else + identifier.candidate_patterns(sign: :outflow) + .sort_by { |pattern| pattern[:last_occurrence_date] } + .reverse + end + + patterns + .select { |pattern| accessible_ids.include?(pattern[:account_id]) } + .first(8) + .map do |pattern| + latest = pattern[:entries].max_by(&:date) + + { + name: pattern[:name].presence || latest.entryable.try(:merchant)&.name.presence || latest.name, + amount: pattern[:expected_amount_avg].abs, + currency: pattern[:currency], + last_date: pattern[:last_occurrence_date], + count: pattern[:occurrence_count], + entry_id: latest.id + } + end + end + + # Every transaction a bill could start from: accessible (not merely + # same-family), the right sign for the mode, transfers and excluded rows + # out. No target amount exists yet, so recency is the only honest order. + # Merchant names are matched because detected entry names are often bank + # blobs; the search stays picker-local so the app-wide EntrySearch + # semantics (and the transactions index query plan) are untouched. + def picker_entries(income:) + scope = Current.accessible_entries + .where(entryable_type: "Transaction") + .where(excluded: false) + .where(income ? "entries.amount < 0" : "entries.amount > 0") + .merge(Entry.excluding_split_parents) + .joins("INNER JOIN transactions ON transactions.id = entries.entryable_id") + .where.not(transactions: { kind: Transaction::TRANSFER_KINDS }) + + if @picker_query.present? + pattern = "%#{ActiveRecord::Base.sanitize_sql_like(@picker_query)}%" + scope = scope + .joins("LEFT JOIN merchants ON merchants.id = transactions.merchant_id") + .where("entries.name ILIKE :q OR entries.notes ILIKE :q OR merchants.name ILIKE :q", q: pattern) + end + + scope.order(date: :desc, created_at: :desc) + .limit(PICKER_SHOWN) + .preload(:account) + end + + # Entries already backing a bill get a "Part of X" chip rather than being + # hidden: hiding lies, but starting a new bill from a claimed charge + # usually means a duplicate in the making. + def claimed_series_names(entries) + RecurringAllocation.confirmed + .joins(:recurring_occurrence) + .where(entry_id: entries.map(&:id)) + .includes(recurring_occurrence: { recurring_transaction: :merchant }) + .to_h { |a| [ a.entry_id, a.recurring_occurrence.recurring_transaction.display_name ] } + end + + def set_recurring_transaction + @recurring_transaction = Current.family.recurring_transactions + .accessible_by(Current.user) + .find(params[:id]) + end + + # Reading a shared bill is fine; changing it is not. Sharing is per + # account, so a read-only account share must not mutate the series. + # Accountless series carry no account gate. Same contract as + # RecurringOccurrencesController#ensure_series_writable. + def ensure_series_writable + return if @recurring_transaction.account_id.nil? + return if Account.writable_by(Current.user).where(id: @recurring_transaction.account_id).exists? + + raise ActiveRecord::RecordNotFound + end + + # name, amount and account are handled by apply_editable_identity rather + # than listed here: the account has to be one this user can actually reach, + # and the amount carries the series' sign convention. Neither survives a + # raw permit. + def recurring_transaction_params + params.require(:recurring_transaction).permit( + :payment_url, :autopay, :notes, :bill_type, :category_id, + :renews_on, :trial_ends_on, :cancelled_on, :end_after_count, + :frequency_preset, :frequency_day_of_month, :frequency_second_day_of_month, + :frequency_weekday, :frequency_month_of_year + ) + end + + def new_recurring_transaction_params + params.require(:recurring_transaction).permit( + :name, :amount, :account_id, :first_due_on, :frequency_preset, + :payment_url, :autopay, :notes, :is_income + ) + end + + def build_declared_bill + RecurringTransaction::DeclaredBill.new( + family: Current.family, + user: Current.user, + attrs: new_recurring_transaction_params + ).build + end + + def save_declared_bill + RecurringTransaction::DeclaredBill.save(@recurring_transaction) + end + + # Pre-fills the frequency picker's virtual attributes from the series' + # rules so the form shows the current cadence. + def assign_frequency_form_state + detection = RecurringTransaction::FrequencyPreset.detect(@recurring_transaction) + + @recurring_transaction.frequency_preset = detection.key + @recurring_transaction.frequency_day_of_month = detection.day_of_month + @recurring_transaction.frequency_second_day_of_month = detection.second_day_of_month + @recurring_transaction.frequency_weekday = detection.weekday + @recurring_transaction.frequency_month_of_year = detection.month_of_year + end + + # A bill outliving its own price is the normal case, and the only way to + # record a rise used to be delete-and-recreate, which takes the occurrences + # and allocations with it. So name, amount and account are editable, but + # resolved rather than mass-assigned: + # + # account must be one this user can reach, or a crafted account_id + # would point a bill at another family's account + # amount is stored negative for income, so assigning the raw field + # would flip a paycheck into a bill + # + # first_due_on stays create-only. It seeds the schedule and does nothing on + # a persisted series, so offering it would be a field that silently fails. + # The day a bill falls due is edited through the frequency picker. + # + # Currency deliberately does not follow the account: it shapes the schedule + # and is tied to existing allocations, and the allocator already converts + # cross-currency payments. + def apply_editable_identity + attrs = params.require(:recurring_transaction) + + @recurring_transaction.name = attrs[:name] if attrs.key?(:name) + + if attrs[:amount].present? + magnitude = attrs[:amount].to_d.abs + @recurring_transaction.amount = + @recurring_transaction.typed_income? ? -magnitude : magnitude + end + + if attrs.key?(:account_id) + # Blank means "any account"; a present id that does not resolve must + # not silently detach the bill from its account. Writable, not merely + # visible: attaching a series to an account changes what that + # account's owners see, so a read-only share cannot be a destination. + if attrs[:account_id].blank? + @recurring_transaction.account = nil + elsif (account = Account.writable_by(Current.user).find_by(id: attrs[:account_id])) + @recurring_transaction.account = account + else + @recurring_transaction.errors.add(:account, :invalid) + end + end + end + + def apply_frequency_preset + changed = RecurringTransaction::FrequencyPreset.apply( + @recurring_transaction, + preset: @recurring_transaction.frequency_preset, + day_of_month: @recurring_transaction.frequency_day_of_month, + second_day_of_month: @recurring_transaction.frequency_second_day_of_month, + weekday: @recurring_transaction.frequency_weekday, + month_of_year: @recurring_transaction.frequency_month_of_year + ) + + # A hand-set cadence is intent, not a guess for detection to correct. + @recurring_transaction.pin_schedule if changed + end + + # One merchant routinely owns several bills (three separate Twitch subscriptions, + # for example), and they all pay at the same portal. Opting in copies the link to + # the caller's other bills for that merchant so the user types it once. + # + # Scoped to what this user can WRITE, not merely see: a series on an account + # shared read-only must not be rewritten by the copy, and accountless series + # carry no account gate. Merchant-less rows are skipped entirely, because + # their only identity is a free-text name that says nothing about where to pay. + # `update_all` is deliberate: the value being copied was already normalized and + # validated on the source record, and a row-by-row save would let an unrelated + # pre-existing validation failure on a legacy sibling abort the whole copy. + def apply_payment_url_to_siblings + return 0 unless params[:apply_to_siblings] == "1" + # Clearing the link is a statement about this bill only: copying a blank + # over the siblings would erase links that were never wrong. + return 0 if @recurring_transaction.payment_url.blank? + + sibling_scope.update_all( + payment_url: @recurring_transaction.payment_url, + updated_at: Time.current + ) + end + + # One biller commonly owns several bills that all pay at the same portal (three + # separate subscriptions to one service, say). Siblings are found the same way + # `RecurringTransaction::Identifier` groups patterns in the first place: by + # merchant when there is one, and by exact name otherwise. Matching on merchant + # alone would miss most rows, because auto-detection leaves `merchant_id` null + # whenever the provider feed gave it nothing to match against. + def sibling_scope + scope = Current.family.recurring_transactions + .accessible_by(Current.user) + .where(account_id: Account.writable_by(Current.user).pluck(:id) + [ nil ]) + .where.not(id: @recurring_transaction.id) + + if @recurring_transaction.merchant_id.present? + scope.where(merchant_id: @recurring_transaction.merchant_id) + elsif @recurring_transaction.name.present? + scope.where(merchant_id: nil, name: @recurring_transaction.name) + else + RecurringTransaction.none + end + end + def recurring_settings_params { recurring_transactions_disabled: params[:recurring_transactions_disabled] == "true" } end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index eff64a7af..7cf1aba05 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -87,6 +87,25 @@ module ApplicationHelper item.merge(preview: true) end + # Bills is only meaningful while recurring detection is on, since a family that has + # turned it off has nothing to list. Returns nil so the entry drops out of the + # `Array#compact` nav list entirely rather than leading to an empty page. The + # subsystem also ships as a preview feature, so the entry is preview-gated on + # top: hidden without the flag, violet-dotted with it. + def bills_nav_item + return nil if Current.family.nil? || Current.family.recurring_transactions_disabled? + + preview_gated_nav_item( + { + name: t("layouts.application.nav.bills"), + path: bills_path, + icon: "receipt", + icon_custom: false, + active: page_active?(bills_path) + } + ) + end + # Budgets and Goals share one nav slot. Preview users get the "Plan" hub # entry fronting both (it stays lit while browsing either subpage, since # page_active? is a path-prefix match and /budgets ยท /goals don't share diff --git a/app/helpers/bills_helper.rb b/app/helpers/bills_helper.rb new file mode 100644 index 000000000..33ec6a0e5 --- /dev/null +++ b/app/helpers/bills_helper.rb @@ -0,0 +1,365 @@ +module BillsHelper + # One-shot AI features (smart-fill, smart-configure) need both the user's + # consent AND a resolvable LLM provider -- an unconfigured self-hosted + # install renders no AI affordances at all, following the Rules registry's + # conditional-executor precedent. + def bills_one_shot_ai_available? + Current.user&.ai_enabled? && Provider::Registry.preferred_llm_provider.present? + end + + # Two bills can be genuinely indistinguishable on a row -- same merchant, + # same amount, three tiers of one subscription. The keys returned here mark + # exactly those collisions, so only the rows that need a second fact get one. + def bills_ambiguous_row_keys(occurrences) + occurrences.group_by { |o| [ o.recurring_transaction.display_name, o.resolved_expected_amount ] } + .select { |_, group| group.size > 1 } + .keys.to_set + end + + # Pay-period markers keyed by the id of the FIRST occurrence inside each + # period, so the section template can drop a marker between groups without + # pre-bucketing the rows. Each marker carries its period and the summed + # obligations due inside it. + def bills_pay_period_markers(occurrences, pay_periods) + return {} if pay_periods.blank? + + seen = Set.new + occurrences.each_with_object({}) do |occurrence, markers| + index = pay_periods.index { |p| occurrence.due_on.between?(p.starts_on, p.ends_on) } + next unless index && seen.add?(index) + + period = pay_periods[index] + markers[occurrence.id] = { + period: period, + due_total: occurrences.select { |o| o.due_on.between?(period.starts_on, period.ends_on) } + .sum { |o| o.resolved_expected_amount.abs } + } + end + end + + # The month bar reads paid | overdue | still to come. Overdue money is a + # subset of what remains, so the slices are derived rather than three + # independent totals. + def bills_month_progress(paid:, remaining:, overdue:) + paid = paid.to_f + remaining = remaining.to_f + total = paid + remaining + + paid_pct = total.positive? ? (paid / total * 100) : 0 + overdue_pct = total.positive? ? ([ overdue.to_f, remaining ].min / total * 100) : 0 + + { + total: total, + paid_pct: paid_pct, + overdue_pct: overdue_pct, + upcoming_pct: [ 100 - paid_pct - overdue_pct, 0 ].max + } + end + + # What the matcher has learned from manual corrections, prepared for display: + # Allocator#learn_from_manual_attach! writes both values and until they were + # surfaced the bill quietly widened what it would match without saying so. + def bills_matcher_hints(series) + { + aliases: Array(series.matcher_hints["name_aliases"]).compact_blank, + learned_pct: series.matcher_hints["learned_tolerance_pct"].to_f + } + end + + # The paycheck plan split into what the page renders: the leading no-income + # bridge window (reported above the timeline, never inside it), the real + # periods, and which of the two bridge states applies -- short earns the + # warning, covered-with-items earns the quiet strip. + def paycheck_plan_sections(plan) + return {} if plan.blank? + + bridge = plan.find(&:bridge?) + + { + bridge: bridge, + periods: plan.reject(&:bridge?), + shortfall: bridge&.short? ? bridge : nil, + bridge_note: bridge && !bridge.short? && bridge.items.any? ? bridge : nil + } + end + + # Which bills a transaction paid, prepared for the transaction drawer. + # Preview-gated with the rest of the bills surface: bill links would + # dead-end for users without the flag. + def entry_bill_allocations(entry) + return [] unless preview_features_enabled? + + entry.recurring_allocations + .includes(recurring_occurrence: :recurring_transaction) + .reject { |allocation| allocation.recurring_occurrence.nil? } + end + + # The paycheck split into `[key, percent]` pairs; the caller owns the colours. + # A short period gets two segments (covered / short) rather than three, since + # there is no safe slice to draw. + def paycheck_allocation_segments(period) + return [] unless period.income.positive? + + if period.short? + normalize_segments([ + [ :covered, period.income ], + [ :short, period.shortfall ] + ], period.obligation_total) + else + normalize_segments([ + [ :due, period.due_total ], + [ :reserved, period.reserved_total ], + [ :safe, period.remaining ] + ], period.income) + end + end + + # Percentages summing to exactly 100; the last segment absorbs the remainder + # so a fully allocated bar never leaves a rounding sliver. + def normalize_segments(parts, total) + return [] unless total.positive? + + present = parts.reject { |_key, amount| amount.round(2).zero? } + return [] if present.empty? + + running = 0 + present.each_with_index.map do |(key, amount), index| + percent = if index == present.size - 1 + (100 - running).round(2) + else + ((amount / total) * 100).round(2).tap { |value| running += value } + end + + [ key, percent ] + end + end + + # The bar in words, in the same terms the card states in text. + def paycheck_allocation_aria(period, currency) + if period.short? + t("bills.paycheck.allocation_aria_short", + income: format_money(Money.new(period.income, currency)), + short: format_money(Money.new(period.shortfall, currency))) + else + t("bills.paycheck.allocation_aria", + income: format_money(Money.new(period.income, currency)), + due: format_money(Money.new(period.due_total, currency)), + reserved: format_money(Money.new(period.reserved_total, currency)), + safe: format_money(Money.new(period.remaining, currency))) + end + end + + # Named by the income that opens it: a declared series can be a pension or an + # invoice, so "paycheck" would be an assumption. + def paycheck_period_heading(period) + date = l(period.starts_on, format: :short) + + return t("bills.paycheck.before_next_paycheck", date: l(period.ends_on + 1, format: :short)) if period.bridge? + + "#{date}#{paycheck_period_source(period)}" + end + + # The trailing half of the heading, so the date carries the visual weight. + def paycheck_period_source(period) + return "" if period.bridge? + + case period.income_sources.size + when 0 then "" + when 1 then t("bills.paycheck.period_source", source: period.income_sources.first) + else t("bills.paycheck.period_source_multiple", count: period.income_sources.size) + end + end + + # The pay schedule in one line: who pays, how often, when next, how much. Two + # sources landing on one day are counted, never summed under one name. + def paycheck_income_headline(next_income) + occurrences = next_income[:occurrences] + single = occurrences.one? ? occurrences.first.recurring_transaction : nil + + parts = [ + single ? single.display_name : t("bills.paycheck.income_source_count", count: occurrences.size), + single ? frequency_label(single) : nil, + t("bills.paycheck.next_on", date: l(next_income[:date], format: :short)), + next_income[:total] ? format_money(next_income[:total]) : nil + ] + + parts.compact.join(" ยท ") + end + + # Why an income series is listed but not planned around. + def paycheck_income_excluded_reason(series) + return t("bills.paycheck.income_paused") unless series.active? + + t("bills.paycheck.income_detected") + end + + # Leads with the relative distance, which is what tells you whether to act, and + # keeps the absolute date alongside it for anything further out than a few days. + # + # Relative wording is also the safer default: the app runs in UTC while users do + # not, so a bare calendar date can read as off-by-one for part of every day. + def bills_due_label(bill) + days = (bill.next_due_date - Date.current).to_i + date = l(bill.next_due_date, format: :short) + + if days.negative? + t("bills.due_label.overdue", count: days.abs, date: date) + elsif days.zero? + t("bills.due_label.today") + else + t("bills.due_label.upcoming", count: days, date: date) + end + end + + # Which account the charge lands on. Worth showing only when it tells the rows + # apart: on a single-account family it repeated the same name down every line, + # which is nineteen copies of a fact carrying no information. The bill's + # expansion names the account regardless, so nothing is lost when it is quiet + # here. + def bills_paid_from_label(bill) + return "" if bill.account.blank? + return "" unless bills_span_multiple_accounts? + + " ยท #{t('bills.paid_from', account: bill.account.name)}" + end + + # Autopay is a state, not a task, so it reads on the bill's own line rather + # than in the slot where the row keeps its verb. + def bills_autopay_label(bill) + return "" unless bill.autopay? + + " ยท #{t('recurring_transactions.pay_action.autopay')}" + end + + # Memoized so this costs one query per request rather than one per row. + def bills_span_multiple_accounts? + return @bills_span_multiple_accounts if defined?(@bills_span_multiple_accounts) + + @bills_span_multiple_accounts = + Current.family.recurring_transactions.where.not(account_id: nil) + .distinct.count(:account_id) > 1 + end + + # The occurrence-level twin of bills_due_label: relative-first, snooze-aware. + def occurrence_due_label(occurrence) + due = occurrence.effective_due_on + days = (due - Date.current).to_i + date = l(due, format: :short) + + # A settled cycle is not late. This label only ever looked at dates, so a + # bill paid three weeks after its due date reported "Overdue by 20 days" + # directly beside its own "$11.99 of $11.99 paid" total. Once a cycle is + # closed the only useful fact left is when it had been due. + return t("bills.due_label.settled", date: date) unless occurrence.scheduled? + + # Overdue is the occurrence's own judgement, not a sign test on the date. + # RecurringOccurrence#derived_state only calls a cycle overdue once its + # grace period has run out, and every other surface honours that: the + # overview files a bill inside its grace under This month, and get_bills + # reports state "due". This label read the raw date and printed "Overdue by + # 1 day" on the same bill, in the secondary colour, because the surrounding + # tone check asks overdue? and got false. The screen contradicted itself + # and the assistant at once. + if occurrence.snoozed_until.present? && occurrence.snoozed_until > occurrence.due_on && days.positive? + t("bills.due_label.snoozed", date: date) + elsif occurrence.overdue? + t("bills.due_label.overdue", count: days.abs, date: date) + elsif days.zero? + t("bills.due_label.today") + elsif days.negative? + # Past its date but still inside the grace the bill was given. + t("bills.due_label.due_since", date: date) + else + t("bills.due_label.upcoming", count: days, date: date) + end + end + + # Short enough for a column in the Next up strip: relative while that still + # means something, absolute once it does not. + # + # There is deliberately no "late" case. The strip only ever holds bills due + # today or later, because something already past its due date is not part of + # what is coming up -- it is the thing the list below is for. + def bills_next_up_date(occurrence) + case (occurrence.effective_due_on - Date.current).to_i + when 0 then t("bills.month_pulse.date_today") + when 1 then t("bills.month_pulse.date_tomorrow") + else l(occurrence.effective_due_on, format: "%b %-d") + end + end + + # Why this row is in the Needs attention section. + # + # The section used to say "Overdue" against every row, which is alarming + # without being actionable: it names the symptom every row already shares + # instead of the thing that differs. First true wins, most specific first. + def bills_attention_reason(occurrence, suggestion: nil) + return t("bills.attention.needs_review") if suggestion.present? + + if occurrence.partially_paid? + return t("bills.attention.partial", amount: format_money(occurrence.remaining_amount_money)) + end + + if occurrence.recurring_transaction.recurring_price_changes.any? { |change| change.effective_on >= 30.days.ago.to_date } + return t("bills.attention.amount_changed") + end + + return nil unless occurrence.derived_state == :overdue + + t("bills.attention.overdue", count: (Date.current - occurrence.effective_due_on).to_i) + end + + # The match score's own components, said in words. + # + # Deterministic: every phrase here corresponds to a key the matcher actually + # wrote, so nothing is inferred and nothing is invented. Works for both + # callers -- a live candidate scored by Matcher#explain (symbol keys) and a + # persisted allocation's match_signals (string keys out of jsonb). + # + # The account signal is deliberately never rendered. It is a constant 0.10 on + # every candidate, because identity_matches? has already rejected everything + # on another account, so "same account" is a reason that never once + # distinguishes one candidate from another. + def bills_match_reasons(signals, currency:, expected: nil, actual: nil, due_on: nil, paid_on: nil) + signals = (signals || {}).symbolize_keys + reasons = [] + + reasons << t("bills.match.same_merchant") if signals[:merchant] + reasons << t("bills.match.name_matches") if signals[:name] + + # Guarded: the review queue can hold an allocation whose entry has been + # nullified out from under it, so neither figure is guaranteed. + if signals[:amount] && expected.present? && actual.present? + difference = (actual - expected).abs + + reasons << if difference < BigDecimal("0.01") + t("bills.match.exact_amount") + else + t("bills.match.amount_off", amount: format_money(Money.new(difference, currency))) + end + end + + if signals[:date] && due_on.present? && paid_on.present? + days = (paid_on - due_on).to_i + + reasons << if days.zero? + t("bills.match.due_date") + elsif days.negative? + t("bills.match.days_before", count: days.abs) + else + t("bills.match.days_after", count: days) + end + end + + reasons + end + + # An amount whose expectation is derived (average strategy, or an observed + # variance band) is shown as approximate; a fixed declared amount never is. + def occurrence_amount_estimated?(occurrence) + return false if occurrence.expected_amount.present? + + series = occurrence.recurring_transaction + !series.amount_fixed? || series.has_amount_variance? + end +end diff --git a/app/helpers/recurring_transactions_helper.rb b/app/helpers/recurring_transactions_helper.rb new file mode 100644 index 000000000..d1f94d9c7 --- /dev/null +++ b/app/helpers/recurring_transactions_helper.rb @@ -0,0 +1,42 @@ +module RecurringTransactionsHelper + def frequency_label(recurring_transaction) + RecurringTransaction::FrequencyPreset.label(recurring_transaction) + end + + # Status is domain state; the tone is how the design system says it. The + # mapping lives here so every surface badges a status the same way. + def recurring_status_pill_tone(status) + case status.to_s + when "active" then :success + when "suggested" then :warning + else :neutral + end + end + + def frequency_preset_options(recurring_transaction) + options = RecurringTransaction::FrequencyPreset::PRESETS.map do |preset| + [ t("recurring_transactions.frequency_presets.#{preset}"), preset ] + end + + if RecurringTransaction::FrequencyPreset.detect(recurring_transaction).key == RecurringTransaction::FrequencyPreset::CUSTOM + options.unshift([ t("recurring_transactions.frequency_presets.custom"), RecurringTransaction::FrequencyPreset::CUSTOM ]) + end + + options + end + + def frequency_day_options + # localized_ordinal, not ordinalize: the bare Rails helper always emits + # English suffixes regardless of the active locale. + (1..31).map { |day| [ localized_ordinal(day), day ] } + + [ [ t("recurring_transactions.frequency.last_day"), RecurrenceRule::LAST ] ] + end + + def frequency_weekday_options + t("date.day_names").each_with_index.map { |name, index| [ name, index ] } + end + + def frequency_month_options + t("date.month_names").compact.each_with_index.map { |name, index| [ name, index + 1 ] } + end +end diff --git a/app/javascript/controllers/app_layout_controller.js b/app/javascript/controllers/app_layout_controller.js index ceeec0f33..f31c7b923 100644 --- a/app/javascript/controllers/app_layout_controller.js +++ b/app/javascript/controllers/app_layout_controller.js @@ -31,6 +31,16 @@ export default class extends Controller { this.#toggleSidebarWidth(this.rightSidebarTarget, isOpen, "right"); } + // For actions that send content into the chat sidebar (quick prompts, + // AI review): make sure it is visible, and never close it. + openRightSidebar() { + const isOpen = this.rightSidebarTarget.classList.contains("w-full"); + if (isOpen) return; + + this.#updateUserPreference("show_ai_sidebar", true); + this.#toggleSidebarWidth(this.rightSidebarTarget, false, "right"); + } + #toggleSidebarWidth(el, isCurrentlyOpen, side) { const expandedClasses = side === "left" ? [...this.expandedSidebarClasses, "border-r"] : [...this.expandedSidebarClasses, "border-l"]; const collapsedClasses = side === "left" ? [...this.collapsedSidebarClasses, "border-r-0"] : [...this.collapsedSidebarClasses, "border-l-0"]; diff --git a/app/javascript/controllers/frequency_fields_controller.js b/app/javascript/controllers/frequency_fields_controller.js new file mode 100644 index 000000000..f2cdf1c47 --- /dev/null +++ b/app/javascript/controllers/frequency_fields_controller.js @@ -0,0 +1,20 @@ +import { Controller } from "@hotwired/stimulus"; + +// Shows the frequency-picker field groups relevant to the selected preset. +// Display logic only; the preset-to-rules translation is server-side. +export default class extends Controller { + static targets = ["preset", "group"]; + + connect() { + this.update(); + } + + update() { + const preset = this.presetTarget.value; + + this.groupTargets.forEach((group) => { + const presets = (group.dataset.presets || "").split(","); + group.classList.toggle("hidden", !presets.includes(preset)); + }); + } +} diff --git a/app/javascript/controllers/persisted_disclosure_controller.js b/app/javascript/controllers/persisted_disclosure_controller.js new file mode 100755 index 000000000..f3dd97616 --- /dev/null +++ b/app/javascript/controllers/persisted_disclosure_controller.js @@ -0,0 +1,30 @@ +import { Controller } from "@hotwired/stimulus"; + +// Remembers whether a
section is open, per device, so a panel the +// user has collapsed stays collapsed instead of reappearing on every render. +// Same storage approach as privacy mode and the sidebar width. +// +// The element keeps its server-rendered `open` state until connect() runs, so +// a collapsed section flashes open for a frame on a cold load. Reading storage +// in connect() rather than waiting for a turbo event keeps that to one frame. +export default class extends Controller { + static values = { key: String }; + + connect() { + const stored = localStorage.getItem(this.storageKey); + if (stored !== null) this.element.open = stored === "true"; + + this.toggleHandler = () => { + localStorage.setItem(this.storageKey, String(this.element.open)); + }; + this.element.addEventListener("toggle", this.toggleHandler); + } + + disconnect() { + this.element.removeEventListener("toggle", this.toggleHandler); + } + + get storageKey() { + return `disclosure:${this.keyValue}`; + } +} diff --git a/app/models/family.rb b/app/models/family.rb index 387dd3eff..7a4d336eb 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -543,6 +543,46 @@ class Family < ApplicationRecord Rails.application.config.app_mode.self_hosted? end + # Lazy so existing families get a token on first render, and resetting is + # revocation. + def bills_feed_token! + return bills_feed_token if bills_feed_token.present? + + update!(bills_feed_token: SecureRandom.urlsafe_base64(24)) + bills_feed_token + end + + def reset_bills_feed_token! + update!(bills_feed_token: SecureRandom.urlsafe_base64(24)) + bills_feed_token + end + + # The URL a member subscribes to carries the MEMBER's identity, because the + # feed must honor per-account sharing: a member who can reach a subset of + # accounts must not receive the whole family's obligations. The family + # secret never appears in the URL; only a digest of it does, so rotating + # `bills_feed_token` still revokes every previously shared URL at once. + def bills_feed_token_for(user) + self.class.bills_feed_verifier.generate([ user.id, bills_feed_stamp! ]) + end + + def bills_feed_stamp! + Digest::SHA256.hexdigest(bills_feed_token!).first(16) + end + + # Non-minting read for the verification side: a family that never rendered + # a feed link has no token, and a signed URL from some earlier life must + # not conjure one into existence to match against. + def bills_feed_stamp + return nil if bills_feed_token.blank? + + Digest::SHA256.hexdigest(bills_feed_token).first(16) + end + + def self.bills_feed_verifier + Rails.application.message_verifier("bills-user-feed") + end + private # Mirrors the inline `investment_ids` / `crypto_ids` SQL blocks in # `tax_advantaged_account_ids`. Joins `depositories` and filters by diff --git a/app/models/provider/anthropic.rb b/app/models/provider/anthropic.rb index 9328266ff..af9035680 100644 --- a/app/models/provider/anthropic.rb +++ b/app/models/provider/anthropic.rb @@ -97,6 +97,31 @@ class Provider::Anthropic < Provider end end + def suggest_bill_setup(charges: [], categories: [], current_config: nil, model: "", family: nil) + with_provider_response do + effective_model = model.presence || @default_model + + trace = create_langfuse_trace( + name: "anthropic.suggest_bill_setup", + input: { charges: charges, configure_mode: current_config.present? } + ) + + result = BillSetupSuggester.new( + client, + model: effective_model, + charges: charges, + categories: categories, + current_config: current_config, + langfuse_trace: trace, + family: family + ).suggest + + upsert_langfuse_trace(trace: trace, output: result.to_h) + + result + end + end + def auto_detect_merchants(transactions: [], user_merchants: [], model: "", family: nil, json_mode: nil) with_provider_response do raise Error, "Too many transactions to auto-detect merchants. Max is 25 per request." if transactions.size > 25 diff --git a/app/models/provider/anthropic/bill_setup_suggester.rb b/app/models/provider/anthropic/bill_setup_suggester.rb new file mode 100644 index 000000000..801f09429 --- /dev/null +++ b/app/models/provider/anthropic/bill_setup_suggester.rb @@ -0,0 +1,178 @@ +class Provider::Anthropic::BillSetupSuggester + include Provider::Anthropic::Concerns::UsageRecorder + + TOOL_NAME = "report_bill_setup".freeze + + attr_reader :client, :model, :charges, :categories, :current_config, :langfuse_trace, :family + + def initialize(client, model:, charges: [], categories: [], current_config: nil, langfuse_trace: nil, family: nil) + @client = client + @model = model + @charges = charges + @categories = categories + @current_config = current_config + @langfuse_trace = langfuse_trace + @family = family + end + + def suggest + span = langfuse_trace&.span(name: "suggest_bill_setup_api_call", input: { + model: model, + charges: charges, + configure_mode: current_config.present? + }) + + response = client.messages.create( + model: model, + max_tokens: max_tokens, + system_: instructions, + messages: [ { role: "user", content: user_message } ], + tools: [ output_tool ], + tool_choice: { type: "tool", name: TOOL_NAME, disable_parallel_tool_use: true } + ) + + result = build_suggestion(extract_input(response)) + + record_usage(model, response.usage, operation: "suggest_bill_setup", metadata: { + charge_count: charges.size, + configure_mode: current_config.present? + }) + + span&.end(output: result.to_h, usage: usage_hash(response.usage)) + result + rescue => e + span&.end(output: { error: e.message }, level: "ERROR") + record_usage_error(model, operation: "suggest_bill_setup", error: e, metadata: { + charge_count: charges.size + }) + raise + end + + private + Suggestion = Provider::LlmConcept::BillSetupSuggestion + + def max_tokens + ENV.fetch("ANTHROPIC_MAX_TOKENS", 4096).to_i + end + + def output_tool + nullable_string = { type: [ "string", "null" ] } + nullable_integer = { type: [ "integer", "null" ] } + + { + name: TOOL_NAME, + description: "Report the proposed recurring-bill configuration.", + input_schema: { + type: "object", + properties: { + name: nullable_string, + amount: { type: [ "number", "null" ], description: "Typical recent charge, positive." }, + frequency: { + type: [ "string", "null" ], + enum: RecurringTransaction::FrequencyPreset::PRESETS + [ nil ] + }, + day_of_month: nullable_integer, + weekday: nullable_integer.merge(description: "0 = Sunday; weekly cadences only."), + month_of_year: nullable_integer, + category_name: { + type: [ "string", "null" ], + description: "Exact match from the provided categories, or null.", + enum: categories + [ nil ] + }, + bill_type: { type: [ "string", "null" ], enum: %w[bill subscription installment] + [ nil ] }, + autopay: { type: [ "boolean", "null" ] }, + confidence: { type: [ "number", "null" ] }, + rationale: nullable_string + }, + required: %w[name amount frequency day_of_month weekday month_of_year category_name bill_type autopay confidence rationale], + additionalProperties: false + } + } + end + + def instructions + base = <<~INSTRUCTIONS.strip_heredoc + You configure recurring-bill records for a personal finance app. Given the dated + charge history for one obligation, propose the bill's configuration via the + #{TOOL_NAME} tool. + + Rules: + - Infer the cadence from the gaps between the dates, never from the row count. + - amount is the typical recent charge as a positive number; when amounts drift, + prefer the most recent ones. + - day_of_month is the modal charge day (monthly-style cadences only); weekday + only for weekly/biweekly; month_of_year only for annual. + - bill_type: "subscription" for digital services and memberships, "installment" + for finite payment plans, otherwise "bill". + - autopay true only when the history shows automatic-payment markers. + - Set any field the history cannot support to null. Never guess. + - confidence is 0 to 1 for the proposal overall; rationale is one short sentence. + INSTRUCTIONS + + return base if current_config.blank? + + base + <<~CONFIGURE.strip_heredoc + + A current configuration is provided. Propose ONLY fields where the charge history + contradicts it; set every field that is already right to null. + CONFIGURE + end + + def user_message + message = +"CHARGE HISTORY (date amount description):\n" + message << charges.map { |charge| "- #{charge[:date]} #{charge[:amount]} #{charge[:name]}" }.join("\n") + message << "\n\nAVAILABLE CATEGORIES: #{categories.join(", ")}" + message << "\n\nCURRENT CONFIGURATION:\n#{current_config.to_json}" if current_config.present? + message + end + + def extract_input(response) + tool_use = Array(response.content).find { |block| block_type(block) == :tool_use } + raise Provider::Anthropic::Error, "Model did not invoke #{TOOL_NAME}" unless tool_use + + input = block_input(tool_use) + input = JSON.parse(input) if input.is_a?(String) + input.is_a?(Hash) ? input.stringify_keys : {} + end + + def build_suggestion(parsed) + Suggestion.new( + name: presence_string(parsed["name"]), + amount: parsed["amount"].is_a?(Numeric) ? parsed["amount"].to_f : nil, + frequency: presence_string(parsed["frequency"]), + day_of_month: parsed["day_of_month"].is_a?(Integer) ? parsed["day_of_month"] : nil, + weekday: parsed["weekday"].is_a?(Integer) ? parsed["weekday"] : nil, + month_of_year: parsed["month_of_year"].is_a?(Integer) ? parsed["month_of_year"] : nil, + category_name: presence_string(parsed["category_name"]), + bill_type: presence_string(parsed["bill_type"]), + autopay: [ true, false ].include?(parsed["autopay"]) ? parsed["autopay"] : nil, + confidence: parsed["confidence"].is_a?(Numeric) ? parsed["confidence"].to_f : nil, + rationale: presence_string(parsed["rationale"]) + ) + end + + def presence_string(value) + normalized = value.to_s.strip + return nil if normalized.empty? || normalized.casecmp("null").zero? + + normalized + end + + def block_type(block) + raw = block.respond_to?(:type) ? block.type : block[:type] || block["type"] + raw.to_s.to_sym + end + + def block_input(block) + block.respond_to?(:input) ? block.input : (block[:input] || block["input"]) + end + + def usage_hash(raw_usage) + return {} unless raw_usage + { + "input_tokens" => raw_usage.input_tokens.to_i, + "output_tokens" => raw_usage.output_tokens.to_i, + "total_tokens" => raw_usage.input_tokens.to_i + raw_usage.output_tokens.to_i + } + end +end diff --git a/app/models/provider/llm_concept.rb b/app/models/provider/llm_concept.rb index 698bf13ee..1caad8174 100644 --- a/app/models/provider/llm_concept.rb +++ b/app/models/provider/llm_concept.rb @@ -19,6 +19,18 @@ module Provider::LlmConcept raise NotImplementedError, "Subclasses must implement #enhance_provider_merchants" end + # One proposed recurring-bill configuration, inferred from charge history. + # Every field is nullable: null means the history cannot support a value + # (or, in configure mode, that the current configuration is already right). + BillSetupSuggestion = Data.define( + :name, :amount, :frequency, :day_of_month, :weekday, :month_of_year, + :category_name, :bill_type, :autopay, :confidence, :rationale + ) + + def suggest_bill_setup(charges:, categories: [], current_config: nil, model: "", family: nil) + raise NotImplementedError, "Subclasses must implement #suggest_bill_setup" + end + PdfProcessingResult = Data.define(:summary, :document_type, :extracted_data) def supports_pdf_processing? diff --git a/app/models/provider/openai.rb b/app/models/provider/openai.rb index 1c6f5d86b..af44ee3b1 100644 --- a/app/models/provider/openai.rb +++ b/app/models/provider/openai.rb @@ -165,6 +165,31 @@ class Provider::Openai < Provider end end + def suggest_bill_setup(charges: [], categories: [], current_config: nil, model: "", family: nil) + with_provider_response do + effective_model = model.presence || @default_model + + trace = create_langfuse_trace( + name: "openai.suggest_bill_setup", + input: { charges: charges, configure_mode: current_config.present? } + ) + + result = BillSetupSuggester.new( + client, + model: effective_model, + charges: charges, + categories: categories, + current_config: current_config, + langfuse_trace: trace, + family: family + ).suggest + + upsert_langfuse_trace(trace: trace, output: result.to_h) + + result + end + end + def auto_detect_merchants(transactions: [], user_merchants: [], model: "", family: nil, json_mode: nil) with_provider_response do effective_model = model.presence || @default_model diff --git a/app/models/provider/openai/bill_setup_suggester.rb b/app/models/provider/openai/bill_setup_suggester.rb new file mode 100644 index 000000000..23d37dd38 --- /dev/null +++ b/app/models/provider/openai/bill_setup_suggester.rb @@ -0,0 +1,169 @@ +class Provider::Openai::BillSetupSuggester + include Provider::Openai::Concerns::UsageRecorder + + attr_reader :client, :model, :charges, :categories, :current_config, :langfuse_trace, :family + + def initialize(client, model: "", charges: [], categories: [], current_config: nil, langfuse_trace: nil, family: nil) + @client = client + @model = model + @charges = charges + @categories = categories + @current_config = current_config + @langfuse_trace = langfuse_trace + @family = family + end + + # One chat-completions call with a json_object response format, falling back + # to no constraint for providers that reject it (the AutoCategorizer's + # lesson: strict formats break some OpenAI-compatible hosts and local LLMs). + def suggest + suggest_with_format({ type: "json_object" }) + rescue Faraday::BadRequestError => e + Rails.logger.warn("json_object mode failed for bill setup suggestion, retrying without: #{e.message}") + suggest_with_format(nil) + end + + private + Suggestion = Provider::LlmConcept::BillSetupSuggestion + + def suggest_with_format(response_format) + span = langfuse_trace&.span(name: "suggest_bill_setup_api_call", input: { + model: model, + charges: charges, + configure_mode: current_config.present? + }) + + params = { + model: model, + messages: [ + { role: "system", content: instructions }, + { role: "user", content: user_message } + ] + } + params[:response_format] = response_format if response_format + + response = client.chat(parameters: params) + + result = build_suggestion(parse_json_flexibly(response.dig("choices", 0, "message", "content"))) + + record_usage(model, response.dig("usage"), operation: "suggest_bill_setup", metadata: { + charge_count: charges.size, + configure_mode: current_config.present? + }) + + span&.end(output: result.to_h, usage: response.dig("usage")) + result + rescue => e + span&.end(output: { error: e.message }, level: "ERROR") + raise + end + + def instructions + base = <<~INSTRUCTIONS.strip_heredoc + You configure recurring-bill records for a personal finance app. Given the dated + charge history for one obligation, propose the bill's configuration as JSON only. + + Rules: + - Infer the cadence from the gaps between the dates, never from the row count. + frequency is one of: monthly, weekly, biweekly, semimonthly, quarterly, semiannual, annual. + - amount is the typical recent charge as a positive number; when amounts drift, + prefer the most recent ones. + - day_of_month is the modal charge day (monthly-style cadences only); weekday + (0=Sunday) only for weekly/biweekly; month_of_year only for annual. + - category_name must EXACTLY match one of the provided categories, or null. + - bill_type: "subscription" for digital services and memberships, "installment" + for finite payment plans, otherwise "bill". + - autopay true only when the history shows automatic-payment markers (ACH, AUTOPAY). + - Set any field the history cannot support to null. Never guess. + - confidence is 0 to 1 for the proposal overall; rationale is one short sentence. + + Output JSON only, exactly this shape (no markdown, no explanation): + {"name": ..., "amount": ..., "frequency": ..., "day_of_month": ..., "weekday": ..., + "month_of_year": ..., "category_name": ..., "bill_type": ..., "autopay": ..., + "confidence": ..., "rationale": ...} + INSTRUCTIONS + + return base if current_config.blank? + + base + <<~CONFIGURE.strip_heredoc + + A current configuration is provided. Propose ONLY fields where the charge history + contradicts it; set every field that is already right to null. + CONFIGURE + end + + def user_message + message = +"CHARGE HISTORY (date amount description):\n" + message << charges.map { |charge| "- #{charge[:date]} #{charge[:amount]} #{charge[:name]}" }.join("\n") + message << "\n\nAVAILABLE CATEGORIES: #{categories.join(", ")}" + message << "\n\nCURRENT CONFIGURATION:\n#{current_config.to_json}" if current_config.present? + message + end + + def build_suggestion(parsed) + Suggestion.new( + name: string_or_nil(parsed["name"]), + amount: numeric_or_nil(parsed["amount"]), + frequency: string_or_nil(parsed["frequency"]), + day_of_month: integer_or_nil(parsed["day_of_month"]), + weekday: integer_or_nil(parsed["weekday"]), + month_of_year: integer_or_nil(parsed["month_of_year"]), + category_name: string_or_nil(parsed["category_name"]), + bill_type: string_or_nil(parsed["bill_type"]), + autopay: [ true, false ].include?(parsed["autopay"]) ? parsed["autopay"] : nil, + confidence: numeric_or_nil(parsed["confidence"]), + rationale: string_or_nil(parsed["rationale"]) + ) + end + + def string_or_nil(value) + normalized = value.to_s.strip + return nil if normalized.empty? || normalized.casecmp("null").zero? + + normalized + end + + def numeric_or_nil(value) + Float(value) + rescue TypeError, ArgumentError + nil + end + + def integer_or_nil(value) + Integer(value) + rescue TypeError, ArgumentError + nil + end + + # Same flexible parsing the sibling one-shot classes carry: LLM output may + # wrap JSON in markdown fences or thinking tags. + def parse_json_flexibly(raw) + raise Provider::Openai::Error, "No message content in response" if raw.blank? + + cleaned = strip_thinking_tags(raw) + + begin + JSON.parse(cleaned) + rescue JSON::ParserError + if cleaned =~ /```(?:json)?\s*(\{[\s\S]*?\})\s*```/m + JSON.parse(Regexp.last_match(1)) + elsif cleaned =~ /(\{[\s\S]*\})/m + JSON.parse(Regexp.last_match(1)) + else + raise Provider::Openai::Error, "Could not parse JSON from response: #{raw.truncate(200)}" + end + end + end + + def strip_thinking_tags(raw) + return raw unless raw.include?("") + + if raw =~ /<\/think>\s*([\s\S]*)/m && Regexp.last_match(1).strip.present? + Regexp.last_match(1) + elsif raw =~ /([\s\S]*)/m + Regexp.last_match(1) + else + raw + end + end +end diff --git a/app/models/recurring_transaction/ai_setup_suggester.rb b/app/models/recurring_transaction/ai_setup_suggester.rb new file mode 100644 index 000000000..23cb9499d --- /dev/null +++ b/app/models/recurring_transaction/ai_setup_suggester.rb @@ -0,0 +1,128 @@ +class RecurringTransaction + # Turns charge history into a reviewed bill-configuration proposal via the + # family's configured LLM provider. Two modes: + # + # suggest_from_entries -- from candidate entries (the add dialog's + # smart-fill; nothing exists yet) + # suggest_configuration -- from a series' own charge history against its + # current settings (per-bill smart-configure; + # only contradicted fields come back non-null) + # + # Every provider value is normalized here -- clamped to real presets and + # ranges, category resolved to this family's own id -- so callers can trust + # the shape without re-validating LLM output. + class AiSetupSuggester + Error = Class.new(StandardError) + MAX_CHARGES = 40 + + Suggestion = Data.define( + :name, :amount, :frequency, :day_of_month, :weekday, :month_of_year, + :category_id, :category_name, :bill_type, :autopay, :confidence, :rationale + ) do + # nil means "no proposal for this field"; false is a real proposal + # (turn autopay off), so presence is non-nil rather than truthy. + def any_proposal? + [ name, amount, frequency, day_of_month, weekday, month_of_year, category_id, bill_type, autopay ].any? { |value| !value.nil? } + end + end + + attr_reader :family, :user + + def initialize(family, user:) + @family = family + @user = user + end + + def suggest_from_entries(entries) + run(charges: charges_from(entries), current_config: nil) + end + + def suggest_configuration(series) + run( + charges: charges_from(series.matching_transactions), + current_config: current_config_for(series) + ) + end + + private + def run(charges:, current_config:) + raise Error, "No LLM provider configured" unless llm_provider + raise Error, "No charge history to analyze" if charges.empty? + + result = llm_provider.suggest_bill_setup( + charges: charges, + categories: family.categories.pluck(:name), + current_config: current_config, + family: family + ) + + raise Error, "Provider failed: #{result.error&.message}" unless result.success? + + normalize(result.data) + end + + def llm_provider + Provider::Registry.preferred_llm_provider + end + + def charges_from(entries) + entries.first(MAX_CHARGES).map do |entry| + { date: entry.date.iso8601, amount: entry.amount.abs.to_s, name: entry.name } + end + end + + def current_config_for(series) + detection = FrequencyPreset.detect(series) + + { + name: series.display_name, + amount: series.amount.abs.to_s, + frequency: detection.key, + day_of_month: detection.day_of_month, + weekday: detection.weekday, + month_of_year: detection.month_of_year, + category: series.category&.name, + bill_type: series.bill_type, + autopay: series.autopay + }.compact + end + + def normalize(raw) + category = resolve_category(raw.category_name) + + Suggestion.new( + name: raw.name, + amount: positive_or_nil(raw.amount), + frequency: raw.frequency.presence_in(FrequencyPreset::PRESETS), + day_of_month: in_range(raw.day_of_month, 1..31), + weekday: in_range(raw.weekday, 0..6), + month_of_year: in_range(raw.month_of_year, 1..12), + category_id: category&.id, + category_name: category&.name, + bill_type: raw.bill_type.presence_in(%w[bill subscription installment]), + autopay: [ true, false ].include?(raw.autopay) ? raw.autopay : nil, + confidence: raw.confidence.is_a?(Numeric) ? raw.confidence.to_f.clamp(0.0, 1.0) : nil, + rationale: raw.rationale + ) + end + + # This family's category or nothing: an LLM-invented name must never + # become an id, and another family's category can never resolve here. + def resolve_category(name) + return nil if name.blank? + + family.categories.find_by(name: name) || + family.categories.find_by(Category.arel_table[:name].lower.eq(name.downcase)) + end + + def positive_or_nil(value) + return nil unless value.is_a?(Numeric) + + value.positive? ? BigDecimal(value.to_s).abs : nil + end + + def in_range(value, range) + value.is_a?(Integer) && range.cover?(value) ? value : nil + end + end +end diff --git a/app/models/recurring_transaction/declared_bill.rb b/app/models/recurring_transaction/declared_bill.rb index 03df89dd3..16ccb027f 100644 --- a/app/models/recurring_transaction/declared_bill.rb +++ b/app/models/recurring_transaction/declared_bill.rb @@ -1,8 +1,10 @@ class RecurringTransaction # The one declared-bill build path, shared by the add-bill form and the AI # create tool so the two can never drift on the rules that matter here: - # the account must be one the user can actually reach, and the amount - # carries the sign convention (income is stored negative). + # the account must be one the user can actually write (attaching a bill + # changes what that account's owners see, so a read-only share is not a + # destination), and the amount carries the sign convention (income is + # stored negative). class DeclaredBill attr_reader :family, :user, :attrs @@ -13,7 +15,7 @@ class RecurringTransaction end def build - account = user.accessible_accounts.find_by(id: attrs[:account_id]) + account = Account.writable_by(user).find_by(id: attrs[:account_id]) due = begin Date.parse(attrs[:first_due_on].to_s) rescue Date::Error @@ -51,6 +53,14 @@ class RecurringTransaction recurring.frequency_preset = attrs[:frequency_preset] recurring.first_due_on = attrs[:first_due_on] + # A chosen account that does not resolve to something writable is said + # out loud, not silently dropped: a read-only share or a foreign id + # would otherwise become an accountless bill in the family currency. + if attrs[:account_id].present? && account.nil? + recurring.errors.add(:base, I18n.t("recurring_transactions.create.account_invalid")) + return recurring + end + if amount.nil? recurring.errors.add(:base, I18n.t("recurring_transactions.create.amount_invalid")) return recurring diff --git a/app/views/bills/_ai_prompts.html.erb b/app/views/bills/_ai_prompts.html.erb new file mode 100644 index 000000000..5286bf55d --- /dev/null +++ b/app/views/bills/_ai_prompts.html.erb @@ -0,0 +1,21 @@ +<%# locals: (prompts:) %> +<%# Quick prompts that seed the chat sidebar with a bills question. Each chip + POSTs chats#create into the sidebar frame (the same mechanism the chat's + own sample questions use) and makes sure the sidebar is visible. Hidden + entirely without AI consent + a configured provider. %> +<% if bills_one_shot_ai_available? %> +
+ <% prompts.each do |prompt| %> + <%= render DS::Button.new( + variant: :outline, + text: t(".#{prompt}"), + icon: "sparkles", + href: chats_path, + class: "rounded-full", + params: { chat: { content: t(".#{prompt}"), ai_model: default_ai_model } }, + form: { data: { turbo_frame: chat_frame } }, + data: { action: "app-layout#openRightSidebar" } + ) %> + <% end %> +
+<% end %> diff --git a/app/views/bills/_calendar_chip.html.erb b/app/views/bills/_calendar_chip.html.erb new file mode 100644 index 000000000..0414660cd --- /dev/null +++ b/app/views/bills/_calendar_chip.html.erb @@ -0,0 +1,28 @@ +<%# locals: (occurrence:) %> +<% series = occurrence.recurring_transaction %> +<% + # Monarch-style state vocabulary: paid as expected (green), paid at a + # different amount than expected (amber), skipped (muted), overdue + # (destructive), otherwise upcoming (neutral). + chip_classes = + if occurrence.paid? + if (occurrence.confirmed_allocated - occurrence.resolved_expected_amount).abs > RecurringOccurrence::CLOSE_EPSILON + "bg-warning/10 text-warning" + else + "bg-success/10 text-success" + end + elsif occurrence.skipped? || occurrence.missed? + "bg-surface-inset text-subdued line-through" + elsif occurrence.derived_state == :overdue + "bg-destructive/10 text-destructive" + else + "bg-surface-inset text-primary" + end +%> +<%= link_to recurring_occurrence_path(occurrence), + data: { turbo_frame: :drawer }, + class: "block rounded px-1.5 py-0.5 text-xs truncate hover:opacity-80 #{chip_classes}", + title: series.display_name do %> + <%= format_money(occurrence.resolved_expected_amount_money) %> + <%= series.display_name %> +<% end %> diff --git a/app/views/bills/_cancelled_notice.html.erb b/app/views/bills/_cancelled_notice.html.erb new file mode 100644 index 000000000..5209b72da --- /dev/null +++ b/app/views/bills/_cancelled_notice.html.erb @@ -0,0 +1,18 @@ +<%# A subscription can carry a cancellation date while its schedule keeps + running, so the app ends up calling the same bill "Cancelled" on one + surface and "Overdue" on another. Say so plainly, and put the action + that actually stops it next to the sentence. %> +<% if series.cancelled_on.present? && series.active? %> +
+

+ <%= t("bills.cancelled_still_scheduled", date: l(series.cancelled_on, format: :short)) %> +

+ <%= render DS::Link.new( + text: t("recurring_transactions.actions.pause"), + variant: "outline", + href: toggle_status_recurring_transaction_path(series), + method: :post, + data: { turbo_frame: "_top" } + ) %> +
+<% end %> diff --git a/app/views/bills/_detail.html.erb b/app/views/bills/_detail.html.erb new file mode 100644 index 000000000..f5957f0a2 --- /dev/null +++ b/app/views/bills/_detail.html.erb @@ -0,0 +1,222 @@ +<%# locals: (series:, current_occurrence:, history:, upcoming:, analytics:, + payment_history:, yearly_metrics:, last_account:, + recent_allocations:, dense: false) %> + +<%# The single definition of what a bill's detail *is*. + + The inline expansion and the drawer were two templates over one controller + action, so they drifted: open a bill from the Overview and you got the + matching rules, the twelve-month chart and its recent payments; open the + same bill from All bills and you got upcoming dates, notes and the + averages instead. Same bill, different facts, decided by which list you + happened to click from. + + They stay two presentations, because an inline row and a drawer want + different widths, but only one of them decides what a bill consists of. + `dense` picks the layout; it never picks the content. %> + +<% columns = dense ? "" : "md:grid-cols-2 md:divide-x md:divide-y-0" %> + +
+ <%= render "bills/cancelled_notice", series: series %> + + <%# What is owed right now leads the page above this partial. With no open + cycle there is nothing for that card to say, so the expectation is + stated here instead. %> + <% unless current_occurrence %> +
+

<%= t("bills.detail.next_payment") %>

+

+ <%= "~" if series.has_amount_variance? %><%= format_money(Money.new(series.amount.abs, series.currency)) %> +

+ <% if series.next_expected_date.present? %> +

<%= t("bills.detail.around", date: l(series.next_expected_date, format: :short)) %>

+ <% end %> +
+ <% end %> + + <%# Subscription state: a trial about to convert, a renewal date, a + cancellation already recorded. These lived only on the Subscriptions + tab, which meant they were invisible from every other route to the + same bill. %> + <%= render "bills/state_chips", series: series %> + + <%# Why this bill matches what it matches, in words rather than engine terms. %> +
+

<%= t("bills.detail.rules") %>

+
+ <%= render DS::Pill.new(label: t("bills.detail.rule_named", name: series.merchant&.name.presence || series.name), tone: :neutral, marker: false) %> + <% amount_label = series.has_amount_variance? ? + t("bills.detail.rule_amount_range", min: format_money(series.expected_amount_min_money), max: format_money(series.expected_amount_max_money)) : + format_money(Money.new(series.amount.abs, series.currency)) %> + <%= render DS::Pill.new(label: amount_label, tone: :neutral, marker: false) %> + <%= render DS::Pill.new(label: frequency_label(series), tone: :neutral, marker: false) %> + <% if series.account %> + <%= render DS::Pill.new(label: series.account.name, tone: :neutral, marker: false) %> + <% end %> +
+ + <%# What the app has learned from corrections, prepared by + BillsHelper#bills_matcher_hints. %> + <% hints = bills_matcher_hints(series) %> + <% if hints[:aliases].any? %> +

+ <%= t("bills.detail.rule_aliases", names: hints[:aliases].to_sentence) %> +

+ <% end %> + <% if hints[:learned_pct].positive? %> +

+ <%= t("bills.detail.rule_learned_tolerance", percent: number_to_percentage(hints[:learned_pct], precision: 1, format: "%n%")) %> +

+ <% end %> +
+ + <%# What it has actually cost: the shape over twelve months, then the figures. %> +
+
+

<%= t("bills.detail.history_title") %>

+ <%= render DS::Sparkline.new(series: payment_history, aria_label: t("bills.detail.history_aria")) %> +
+ +
+ <% if analytics %> +
+
+

<%= t("bills.detail.average") %>

+

<%= format_money(analytics[:average]) %>

+

+ <%= t("bills.detail.range", min: format_money(analytics[:lowest]), max: format_money(analytics[:highest])) %> +

+
+
+

<%= t("bills.detail.annualized") %>

+

<%= format_money(analytics[:annualized]) %>

+
+
+

<%= t("bills.detail.ytd") %>

+

<%= format_money(analytics[:ytd]) %>

+
+
+ <% end %> + + <%# Per-year totals are reference material rather than something you read + every time, so they sit one click away instead of adding a table to + every bill you open. %> + <% if yearly_metrics.any? %> + <%= render DS::Disclosure.new(title: t("bills.detail.key_metrics"), align: "left") do %> +
+
+

<%= t("bills.detail.year") %>

+

<%= t("bills.detail.spent_per_year") %>

+

<%= t("bills.detail.avg_payment") %>

+
+ <% yearly_metrics.each do |row| %> +
+

<%= row[:year] %>

+

<%= format_money(row[:total]) %>

+

<%= format_money(row[:average]) %>

+
+ <% end %> +
+ <% end %> + <% end %> + + <% if last_account %> +
+

<%= t("bills.detail.last_account") %>

+

<%= last_account.name %>

+
+ <% end %> +
+
+ + <%# What it used to cost. The twelve-month chart above shows the shape; this + names the moment it changed and by how much. %> + <% changes = series.recent_price_changes %> + <% if changes.any? %> +
+

<%= t("bills.detail.price_changes") %>

+
+ <% changes.each do |change| %> +
+

<%= l(change.effective_on, format: :long) %>

+

"> + <%= t("bills.detail.price_change_line", + from: format_money(change.previous_amount_money), + to: format_money(change.new_amount_money)) %> +

+
+ <% end %> +
+
+ <% end %> + + <% if upcoming.any? %> +
+

<%= t("bills.detail.upcoming") %>

+
+ <% upcoming.each do |date| %> +
+

<%= l(date, format: :long) %>

+

+ ~<%= format_money(Money.new(series.amount.abs, series.currency)) %> +

+
+ <% end %> +
+
+ <% end %> + + <%# Individual payments, which is the answer to "what actually paid this". %> + <% if recent_allocations.any? %> +
+

<%= t("bills.detail.recent_payments") %>

+
+ <% recent_allocations.each do |allocation| %> +
+
+

<%= allocation.entry&.name.presence || t("bills.detail.manual_payment") %>

+ <% if allocation.paid_on %> +

<%= l(allocation.paid_on, format: :short) %>

+ <% end %> +
+

<%= format_money(allocation.allocated_amount_money) %>

+
+ <% end %> +
+
+ <% end %> + + <%# Settled cycles, which answers "did I pay it that month" rather than + "which transaction paid it". %> + <% if history.any? %> +
+

<%= t("bills.detail.history") %>

+
+ <% history.each do |occurrence| %> +
+
+

<%= l(occurrence.due_on, format: :long) %>

+

+ <%= t("recurring_occurrences.history_status.#{occurrence.status}") %> + <% if occurrence.allocations.size > 1 %> + · <%= t("bills.detail.payment_count", count: occurrence.allocations.size) %> + <% end %> +

+
+

+ <%= format_money(occurrence.confirmed_allocated_money) %> +

+
+ <% end %> +
+
+ <% end %> + + <% if series.notes.present? %> +
+

<%= t("bills.detail.notes") %>

+

<%= series.notes %>

+
+ <% end %> +
diff --git a/app/views/bills/_month_pulse.html.erb b/app/views/bills/_month_pulse.html.erb new file mode 100644 index 000000000..951b0c30d --- /dev/null +++ b/app/views/bills/_month_pulse.html.erb @@ -0,0 +1,112 @@ +<%# The month as one surface rather than a row of statistics. + + It used to be a big number, two small ones, and a ring reading 0% -- a + generic KPI card that answered "how far along am I" (which nobody asked) + more loudly than "what do I owe" (which everybody does). + + It now reads as one sentence: this month, this much left, this much late, + this much due soon, and here is what happens next. %> + +<%# Slice math lives with BillsHelper#bills_month_progress: the bar reads + paid | overdue | still to come, overdue being a subset of what remains. %> +<% progress = bills_month_progress( + paid: @paid_this_month_total&.amount, + remaining: @remaining_this_month&.amount, + overdue: @past_due_total&.amount + ) %> + +
+
+

<%= l(Date.current, format: "%B") %>

+

<%= t(".bill_count", count: @month_bill_count) %>

+
+ + <%# The one figure the page exists to state. %> +
+

+ <%= @remaining_this_month ? format_money(@remaining_this_month) : "โ€“" %> +

+

<%= t(".left_to_pay") %>

+
+ + <%# Supporting, not competing: one line of three, each a figure and a word. %> +
+

+ <%= format_money(@paid_this_month_total || Money.new(0, Current.family.currency)) %> + <%= t(".pulse_paid") %> +

+ <% if @past_due_total && @past_due_total.amount.positive? %> +

+ <%= format_money(@past_due_total) %> + <%= t(".pulse_overdue") %> +

+ <% end %> + <% if @due_next_seven && @due_next_seven.amount.positive? %> +

+ <%= format_money(@due_next_seven) %> + <%= t(".pulse_next_seven") %> +

+ <% end %> +
+ + <%# Progress reports, it does not prompt: a 6px rule, not a centrepiece. %> + <% if progress[:total].positive? %> + + <% end %> + + <% if @unconvertible_count.positive? %> +

<%= t(".unconvertible", count: @unconvertible_count) %>

+ <% end %> + + <%# What happens next, which is what makes this header operational rather + than statistical. Items open the bill's page: the row expansion belongs + to a row, and expanding one further down the page while you are reading + the top of it would be a jump with no explanation. %> + <% if @next_up.any? %> +
+
+

<%= t(".next_up") %>

+ <%= link_to t(".view_upcoming"), bills_path(view: "calendar"), + class: "text-xs text-link hover:underline shrink-0" %> +
+ + <%# A horizontal strip where there is room, a scrollable one where there + is not. Four columns at 375px would be four unreadable slivers. %> +
+ <% @next_up.each do |occurrence| %> + <% series = occurrence.recurring_transaction %> + <%= link_to bill_path(series), + class: "shrink-0 w-36 sm:w-auto rounded-lg px-2 py-1.5 -mx-2 hover:bg-surface-hover group" do %> +

+ <%= bills_next_up_date(occurrence) %> + <% if series.autopay? %> + · <%= t("recurring_transactions.pay_action.autopay") %> + <% end %> +

+

<%= series.display_name %>

+

+ <% if occurrence.partially_paid? %> + <%= format_money(occurrence.remaining_amount_money) %> + <%= t("bills.remaining_label") %> + <% else %> + <%= "~" if occurrence_amount_estimated?(occurrence) %><%= format_money(occurrence.resolved_expected_amount_money) %> + <% end %> +

+ <% end %> + <% end %> +
+
+ <% end %> +
diff --git a/app/views/bills/_notice.html.erb b/app/views/bills/_notice.html.erb new file mode 100644 index 000000000..34eed76ec --- /dev/null +++ b/app/views/bills/_notice.html.erb @@ -0,0 +1,25 @@ +<%# locals: (notice:, urgent: false) %> + +
"> +

"> + <% case notice.kind %> + <% when :trial %> + <%= t("bills.index.notice_trial", name: notice.series.display_name, date: l(notice.date, format: :long)) %> + <% when :renewal %> + <%= t("bills.index.notice_renewal", name: notice.series.display_name, date: l(notice.date, format: :long)) %> + <% when :price %> + + <%# The percentage is the part that tells you whether to care. A dollar + on a ten-dollar subscription reads very differently from a dollar + on the rent, and from to alone never said which this was. %> + <%= t("bills.index.notice_price", + name: notice.series.display_name, + from: format_money(notice.detail.previous_amount_money), + to: format_money(notice.detail.new_amount_money), + percent: "#{"+" if notice.price_percent.positive?}#{number_to_percentage(notice.price_percent, precision: 0, format: "%n%")}") %> + + <% end %> +

+ <%= link_to t("bills.manage"), bill_path(notice.series), + class: "text-xs text-link hover:underline shrink-0" %> +
diff --git a/app/views/bills/_occurrence.html.erb b/app/views/bills/_occurrence.html.erb new file mode 100644 index 000000000..5b2524d73 --- /dev/null +++ b/app/views/bills/_occurrence.html.erb @@ -0,0 +1,138 @@ +<%# locals: (occurrence:, date_label: false, suggestion: nil, disambiguate: false) %> +<% series = occurrence.recurring_transaction %> +<% expected = occurrence.resolved_expected_amount_money %> +<% paid = occurrence.confirmed_allocated_money %> +<% pane_frame = dom_id(occurrence, :pane) %> + +<%# SCAN. What is this, and does it need me? + Everything past that question lives one tap deeper, in the expansion. + + An autopaying bill is still worth seeing and still counts toward the total, + but it recedes so the rows that want something from you carry the weight. %> +
+<%# The row reads as interactive without dressing up as a button: a hover tint + and the name underlining, the same cues the transaction list uses. + + The tint hangs off this element rather than a group on the wrapper. The + wrapper also holds the expansion, so a group hover kept the row lit while + the pointer was down in the expanded pane, and the lit band clipped against + the pane's own fill. Hover still propagates from every child of the row, so + the trailing action tints it exactly as before. %> +
"> + <%# The bulk of the row is the inspect control. Only the trailing verb sits + outside it, so there are no anchors inside anchors. %> + <%# Names the cycle it was opened from. Without it the expansion falls back to + the series' current occurrence, so expanding a settled row described the + NEXT one and reported it unpaid. %> + <%= link_to bill_path(series, display: "pane", frame: pane_frame, occurrence: occurrence.id), + data: { turbo_frame: pane_frame, turbo_prefetch: false }, + class: "flex items-center gap-3 lg:gap-4 min-w-0 flex-1 group" do %> + <%# A date rail, and only a date. It used to print "Overdue" beside a + subline already reading "14 days overdue", which spent the row's one + piece of temporal context saying the same word twice. The colour still + carries the state; the rail now carries the date the state is about. + + Desktop-only: on a phone the subline says it in words instead. %> + <% if date_label %> + + <% end %> + + <% if series.merchant&.logo_url.present? %> + <%= image_tag Setting.transform_brand_fetch_url(series.merchant.logo_url), + class: "w-9 h-9 rounded-full shrink-0", + loading: "lazy" %> + <% else %> + <%= render DS::FilledIcon.new( + variant: :text, + text: series.display_name, + size: "lg", + rounded: true + ) %> + <% end %> + + <% reason = date_label ? bills_attention_reason(occurrence, suggestion: suggestion) : nil %> +
+

+ <%= series.display_name %> + <% if series.transfer? %> + <%= t("bills.debt_payment") %> + <% end %> +

+ <%# In a dated section the subline says WHY this row needs attention, + which is the whole point of pulling those rows out of the run. %> +

"> + <% if reason %> + <%= reason %> + <%# Two rows reading the same name and the same amount need telling + apart, and the schedule is what actually differs between + subscription tiers. %> + <% if disambiguate %> + · <%= frequency_label(series) %> + <% end %> + <% else %> + <%= date_label ? frequency_label(series) : occurrence_due_label(occurrence) %><%= bills_paid_from_label(series) %><%= bills_autopay_label(series) %> + <% if disambiguate && !date_label %> + · <%= frequency_label(series) %> + <% end %> + <% end %> + <% if (progress = series.installment_progress) %> + · <%= t("bills.installment_progress", done_plus_one: progress.first + 1, total: progress.last) %> + <% end %> +

+ <% if series.notes.present? %> +

<%= series.notes %>

+ <% end %> +
+ + <%# One amount, and it is the one the next decision turns on. Payment state + used to be printed twice: a coloured subline on the left and a figure + on the right. %> +
+

+ <% if occurrence.paid? %> + <%= format_money(paid) %> + <% elsif occurrence.partially_paid? %> + <%= format_money(occurrence.remaining_amount_money) %> + <% else %> + <%= "~" if occurrence_amount_estimated?(occurrence) %><%= format_money(expected) %> + <% end %> +

+ <% if occurrence.paid? %> +

<%= occurrence.overpaid? ? t("bills.paid_over_short") : t("bills.paid_label") %>

+ <% elsif occurrence.partially_paid? %> + <%# Only when the left-hand subline has not already said it. In a dated + section the attention reason reads "Partial ยท $1,612.50 remaining", + and printing the same arithmetic again on the right is what was + squeezing the bill's name out of the row. %> + <% if reason.present? %> +

<%= t("bills.remaining_label") %>

+ <% else %> +

+ <%= t("bills.partial_progress", paid: format_money(paid), expected: format_money(expected)) %> +

+ <% end %> + <% elsif occurrence_amount_estimated?(occurrence) && series.has_amount_variance? %> +

+ <%= t("bills.amount_range", + min: format_money(series.expected_amount_min_money), + max: format_money(series.expected_amount_max_money)) %> +

+ <% end %> +
+ <% end %> + +
+ <%= render "bills/row_action", occurrence: occurrence, suggestion: suggestion %> +
+
+ +<%# The row's expansion: empty until the row is clicked, then the bill's + current state slides in under the line item. %> +<%= turbo_frame_tag pane_frame %> +
diff --git a/app/views/bills/_occurrence_section.html.erb b/app/views/bills/_occurrence_section.html.erb new file mode 100644 index 000000000..978e67259 --- /dev/null +++ b/app/views/bills/_occurrence_section.html.erb @@ -0,0 +1,46 @@ +<%# locals: (title:, occurrences:, tone: :default, date_labels: false, suggestions: {}, meta: nil, pay_periods: []) %> +<% return if occurrences.empty? %> + +<%# Where two rows would read identically, and only there, the row earns a + second fact to tell them apart (BillsHelper#bills_ambiguous_row_keys). + Adding the schedule to every row instead would be nineteen copies of + something nobody was confused about. %> +<% ambiguous = bills_ambiguous_row_keys(occurrences) %> +<% markers = bills_pay_period_markers(occurrences, pay_periods) %> + +
+
+
"> +

<%= title %>

+ · +

<%= occurrences.size %>

+
+ <% if meta %> +

<%= meta %>

+ <% end %> +
+ + <%# The divider is half its old weight: the rows are separated by a hairline + and by the hover state, not by a rule competing with the content. %> + <%# A container query, not a media query. The app shell has two sidebars, so + main is ~420px wide at a 1280px viewport -- "desktop" by any breakpoint + and phone-width in practice. The row has to size itself against the space + it actually has. %> +
+ <% occurrences.each do |occurrence| %> + <%# The marker belongs to the first row of its period, so it lands + between groups without the section having to pre-bucket the rows. %> + <% if (marker = markers[occurrence.id]) %> + <%= render "bills/pay_period_marker", + period: marker[:period], + currency: Current.family.currency, + due_total: marker[:due_total] %> + <% end %> + <%= render "bills/occurrence", + occurrence: occurrence, + date_label: date_labels, + suggestion: suggestions[occurrence.id], + disambiguate: ambiguous.include?([ occurrence.recurring_transaction.display_name, occurrence.resolved_expected_amount ]) %> + <% end %> +
+
diff --git a/app/views/bills/_pay_period_marker.html.erb b/app/views/bills/_pay_period_marker.html.erb new file mode 100755 index 000000000..b0e5e28e8 --- /dev/null +++ b/app/views/bills/_pay_period_marker.html.erb @@ -0,0 +1,47 @@ +<%# locals: (period:, due_total:, currency:) %> +<%# A payday, drawn inside the month rather than beside it. + + The month is the right container for planning, but it is the wrong unit for + anyone whose money does not arrive monthly: paid weekly, "this month" is + four paychecks and four rent payments in one undifferentiated list, and the + question that actually matters, whether the next few days are covered, is + the one the list cannot answer. + + So this stays a marker, not a section header. Same card, same rhythm, one + tinted band naming the money that arrives and the date it has to stretch to. + + Both halves name a date on purpose. "Paid Aug 26" read as past tense for a + date four days out, and "due before next" never said before what, so the + band described a pay period without ever saying where it ended. %> +<% next_payday = period.ends_on + 1 %> + +
+ <%# A leading window with nothing arriving is not a payday. Labelling it + "Paid $0.00" would read as a missed cheque rather than the stretch before + the next one, so it says what it is, the same wording the income plan uses. %> + <% if period.bridge? %> + <%= icon "wallet", size: "sm" %> + +

+ <%= t("bills.pay_period.before_next_paycheck") %> +

+ <% else %> + <%= icon "arrow-down-circle", size: "sm", color: "success" %> + +

+ <%= t("bills.pay_period.paycheck_on", date: l(period.starts_on, format: :short)) %> + · + + +<%= format_money(Money.new(period.income, currency)) %> + +

+ <% end %> + + <% if due_total.positive? %> +

+ <%= t("bills.pay_period.due_before_date", + amount: format_money(Money.new(due_total, currency)), + date: l(next_payday, format: :short)) %> +

+ <% end %> +
diff --git a/app/views/bills/_paycheck_bridge.html.erb b/app/views/bills/_paycheck_bridge.html.erb new file mode 100755 index 000000000..4c30a5d69 --- /dev/null +++ b/app/views/bills/_paycheck_bridge.html.erb @@ -0,0 +1,40 @@ +<%# locals: (period:, currency:) %> +<%# The gap before the next paycheck, when the cash does cover it. + + Its twin next door reports the same window when the cash falls short. This + one exists because the covered case used to be dropped from the page + outright: the bridge is filtered out of the timeline, and only a shortfall + earned a banner, so a bill due in five days appeared nowhere on the plan + that is supposed to answer what is due before payday. + + Deliberately quiet. Nothing is wrong here, and the window still has to be + visible. %> +
+
+

+ <%= t("bills.paycheck.bridge_label") %> + <%= t("bills.paycheck.bridge_amount", amount: format_money(Money.new(period.obligation_total, currency))) %> +

+ <%= link_to t("bills.paycheck.review_plan"), bills_path, + class: "text-xs text-secondary hover:text-primary shrink-0" %> +
+ +

+ <%= t("bills.paycheck.bridge_split", + date: l(period.ends_on + 1, format: :short), + cash: format_money(Money.new(period.cash_on_hand, currency))) %> +

+ +
+ <% period.items_due.each do |item| %> +
+ <%= link_to item.occurrence.recurring_transaction.display_name, + bill_path(item.occurrence.recurring_transaction), + class: "text-secondary hover:text-primary truncate" %> + + <%= format_money(Money.new(item.share, currency)) %> + +
+ <% end %> +
+
diff --git a/app/views/bills/_paycheck_income.html.erb b/app/views/bills/_paycheck_income.html.erb new file mode 100644 index 000000000..a9558e3c6 --- /dev/null +++ b/app/views/bills/_paycheck_income.html.erb @@ -0,0 +1,57 @@ +<%# The pay schedule as one line, with the full income list and its edit + controls behind Manage. Dates come from occurrences, never from the stored + next_expected_date column, so this and the plan always agree. %> + +<%= render DS::Disclosure.new(variant: :bare) do |disclosure| %> + <% disclosure.with_summary_content do %> +
+
+

+ <%= t("bills.paycheck.income_section_title") %> +

+ <% if @next_income %> +

+ <%= paycheck_income_headline(@next_income) %> +

+ <% else %> +

<%= t("bills.paycheck.no_upcoming_income") %>

+ <% end %> +
+ + <%= t("bills.paycheck.manage") %> + <%= icon "chevron-down", size: "sm", class: "group-open:rotate-180 motion-safe:transition-transform motion-safe:duration-150" %> + +
+ <% end %> + +
+ <% @income_series.each do |series| %> + <% occurrence = @next_income_by_series[series.id] %> +
+
+

+ <%= link_to series.display_name, bill_path(series), class: "hover:underline" %> +

+

+ <%= frequency_label(series) %> + <% if occurrence %> + · <%= t("bills.paycheck.income_next_payday", date: l(occurrence.due_on, format: :short)) %> + <% end %> + <% unless paycheck_income_plans?(series) %> + · <%= paycheck_income_excluded_reason(series) %> + <% end %> +

+
+
+

<%= format_money(occurrence&.resolved_expected_amount_money || Money.new(series.amount.abs, series.currency)) %>

+ <%= render DS::Link.new( + icon: "pencil", + variant: "icon", + href: edit_recurring_transaction_path(series), + frame: :modal + ) %> +
+
+ <% end %> +
+<% end %> diff --git a/app/views/bills/_paycheck_item.html.erb b/app/views/bills/_paycheck_item.html.erb new file mode 100644 index 000000000..16475e3be --- /dev/null +++ b/app/views/bills/_paycheck_item.html.erb @@ -0,0 +1,25 @@ +<%# One bill as a ledger line: due date in a fixed column, name, amount. A + reserved line also names the obligation it is a slice of, since the amount + on the right is a share rather than the whole bill. %> + +<% series = item.occurrence.recurring_transaction %> + +
+ + <%= l(item.occurrence.due_on, format: :short) %> + + + + <%= link_to series.display_name, recurring_occurrence_path(item.occurrence), + data: { turbo_frame: :drawer }, class: "text-sm text-primary hover:underline" %> + <% if reserved && item.remaining_total > item.share %> + + <%= t("bills.paycheck.of_total", amount: format_money(Money.new(item.remaining_total, currency))) %> + + <% end %> + + + + <%= format_money(Money.new(item.share, currency)) %> + +
diff --git a/app/views/bills/_paycheck_period.html.erb b/app/views/bills/_paycheck_period.html.erb new file mode 100644 index 000000000..40976f6de --- /dev/null +++ b/app/views/bills/_paycheck_period.html.erb @@ -0,0 +1,109 @@ +<%# One period in the paycheck timeline: when money arrives and what is left + of it, the due / reserved / safe split with its allocation bar, the bills + due in the window, and an expandable footer for what is held for later. %> + +<% + segments = paycheck_allocation_segments(period) + segment_class = { + due: "bg-inverse", + reserved: "bg-subdued", + safe: "bg-success", + covered: "bg-subdued", + short: "bg-destructive" + } + safe_tone = period.short? ? "text-destructive" : "text-primary" + safe_amount = format_money(Money.new(period.short? ? period.shortfall : period.remaining, currency)) + safe_label = period.short? ? t("bills.paycheck.short_after_bills") : t("bills.paycheck.safe_after_bills") +%> + +
+
" aria-hidden="true"> + + "> + + <% unless last %> + + <% end %> +
+ +
"> +
+
+

+ <%= l(period.starts_on, format: :short) %><%= paycheck_period_source(period) %> +

+

+ <% if period.bridge? %> + <%= t("bills.paycheck.no_income_arriving") %> + <% else %> + <%= t("bills.paycheck.income_amount", amount: format_money(Money.new(period.income, currency))) %> + <% end %> +

+
+ +
+

<%= safe_amount %>

+

<%= safe_label %>

+
+
+ +
+
+
+

<%= t("bills.paycheck.due_this_period") %>

+

<%= format_money(Money.new(period.due_total, currency)) %>

+
+
+

<%= t("bills.paycheck.reserved_ahead") %>

+

<%= format_money(Money.new(period.reserved_total, currency)) %>

+
+
+

<%= period.short? ? t("bills.paycheck.short_short") : t("bills.paycheck.safe_short") %>

+

<%= safe_amount %>

+
+
+ + <% if segments.any? %> + + <% end %> +
+ + <% if period.items_due.any? %> +
+

+ <% if period.bridge? %> + <%= t("bills.paycheck.bills_before_payday", count: period.items_due.size) %> + <% else %> + <%= t("bills.paycheck.bills_this_period", count: period.items_due.size) %> + <% end %> +

+
+ <% period.items_due.each do |item| %> + <%= render "bills/paycheck_item", item: item, currency: currency, reserved: false %> + <% end %> +
+
+ <% end %> + + <% if period.items_reserved.any? %> + <%= render DS::Disclosure.new(variant: :inline, body_class: "mt-1") do |disclosure| %> + <% disclosure.with_summary_content do %> + + <%= t("bills.paycheck.reserved_footer", count: period.items_reserved.size) %> + <%= icon "chevron-right", size: "sm", class: "group-open:rotate-90 motion-safe:transition-transform motion-safe:duration-150" %> + + <% end %> + + <% period.items_reserved.each do |item| %> + <%= render "bills/paycheck_item", item: item, currency: currency, reserved: true %> + <% end %> + <% end %> + <% end %> +
+
diff --git a/app/views/bills/_paycheck_shortfall.html.erb b/app/views/bills/_paycheck_shortfall.html.erb new file mode 100644 index 000000000..bdf1a7557 --- /dev/null +++ b/app/views/bills/_paycheck_shortfall.html.erb @@ -0,0 +1,37 @@ +<%# The gap between today and the next payday, when what is owed in it exceeds + what is on hand. + + This used to say "no income arriving before then", which was true of every + bridge window ever built and therefore explained nothing. The window earns + nothing by definition; the fact worth reporting is that the cash does not + reach, so the banner now shows both numbers it compared. %> + +<% largest = period.largest_obligation %> + +
+
+

+ <%= t("bills.paycheck.shortfall_label") %> + <%= t("bills.paycheck.shortfall_amount", amount: format_money(Money.new(period.shortfall, currency))) %> +

+ <%= link_to t("bills.paycheck.review_plan"), bills_path, + class: "text-xs text-secondary hover:text-primary shrink-0" %> +
+ +

+ <%= t("bills.paycheck.shortfall_split", + obligations: format_money(Money.new(period.obligation_total, currency)), + date: l(period.ends_on + 1, format: :short), + cash: format_money(Money.new(period.cash_on_hand, currency))) %> +

+ + <% if largest.present? %> +

+ <%= t("bills.paycheck.shortfall_largest") %> + <%= link_to bill_path(largest.occurrence.recurring_transaction), class: "text-primary hover:underline" do %> + <%= largest.occurrence.recurring_transaction.display_name %> + · <%= format_money(Money.new(largest.remaining_total, currency)) %> + <% end %> +

+ <% end %> +
diff --git a/app/views/bills/_row_action.html.erb b/app/views/bills/_row_action.html.erb new file mode 100644 index 000000000..b434e00be --- /dev/null +++ b/app/views/bills/_row_action.html.erb @@ -0,0 +1,79 @@ +<%# locals: (occurrence:, suggestion: nil, labelled: false) %> +<% series = occurrence.recurring_transaction %> + +<%# The row's one verb, chosen by what the bill actually needs. + + Every row used to carry the same "Details" button plus a pay action plus a + category badge, which at 375px added up to more than the row was wide: the + bill's own name collapsed to nothing and the page scrolled sideways. A row + that needs nothing from you now shows nothing. + + Below md the verb is an icon, which DS::Buttonish already gives a 44px + touch target via pointer-coarse. The labelled version lives in the row + expansion, which is one tap away and has room for words. + + `labelled: true` is what the expansion passes to get the full-width, + spelled-out version of the same decision. %> + +<% + action = + if suggestion.present? + { key: "review_match", icon: "git-compare" } + elsif occurrence.partially_paid? + { key: "add_payment", icon: "plus" } + elsif !occurrence.scheduled? + # Settled, skipped or missed. Nothing to chase, but unlinking a payment + # and reopening a cycle live behind this door and nowhere else. + { key: "manage_payments", icon: "receipt-text" } + elsif series.autopay? + nil + elsif occurrence.derived_state.in?(%i[overdue due]) + # A due bill with a payment portal wants paying, not reconciling. + RecurringTransaction.valid_payment_url?(series.payment_url) ? nil : { key: "find_payment", icon: "search" } + end + + # A row that needs nothing stays silent; the expansion never does, or + # unlinking, reopening and manual payment would be stranded. + action ||= { key: "find_payment", icon: "search" } if labelled && occurrence.scheduled? +%> + +<% if action.nil? %> + <%# Nothing to resolve here, so the only thing worth offering is the portal + when there is one. show_add stays false: a dashed "Add link" on every + row is exactly the chrome this pass exists to remove. %> + <%= render "recurring_transactions/pay_action", + recurring_transaction: series, show_add: false, show_state: false %> +<% elsif labelled %> + <%= render DS::Link.new( + text: t("bills.#{action[:key]}"), + icon: action[:icon], + variant: "primary", + href: recurring_occurrence_path(occurrence), + frame: :drawer + ) %> +<% else %> + <%# Container-relative, not viewport-relative: the row's own list is the + @container, because the app shell's sidebars mean a 1280px viewport can + still leave this row about 420px to work with. A labelled verb needs + roughly 500px of row before it stops eating the bill's name. %> + + + <%= render DS::Link.new( + icon: action[:icon], + variant: "icon", + href: recurring_occurrence_path(occurrence), + frame: :drawer, + title: t("bills.#{action[:key]}"), + aria: { label: t("bills.#{action[:key]}") } + ) %> + +<% end %> diff --git a/app/views/bills/_state_chips.html.erb b/app/views/bills/_state_chips.html.erb new file mode 100644 index 000000000..836f31b4f --- /dev/null +++ b/app/views/bills/_state_chips.html.erb @@ -0,0 +1,19 @@ +<%# locals: (series:) %> +<%# Trial, renewal and cancellation state, shared by the bill drawer and the + summary tab so the two never drift apart. Renders nothing when the series + carries none of the three. %> +<% trial = series.trial_ends_on.present? && series.trial_ends_on >= Date.current %> +<% renews = series.renews_on.present? && series.renews_on >= Date.current %> +<% if trial || renews || series.cancelled_on.present? %> +
+ <% if trial %> + <%= render DS::Pill.new(label: t("bills.detail.trial_chip", date: l(series.trial_ends_on, format: :short)), tone: :warning, marker: false) %> + <% end %> + <% if renews %> + <%= render DS::Pill.new(label: t("bills.detail.renews_chip", date: l(series.renews_on, format: :short)), tone: :neutral, marker: false) %> + <% end %> + <% if series.cancelled_on.present? %> + <%= render DS::Pill.new(label: t("bills.detail.cancelled_chip", date: l(series.cancelled_on, format: :short)), tone: :neutral, marker: false) %> + <% end %> +
+<% end %> diff --git a/app/views/bills/_summary.html.erb b/app/views/bills/_summary.html.erb new file mode 100644 index 000000000..6bb09a9ff --- /dev/null +++ b/app/views/bills/_summary.html.erb @@ -0,0 +1,137 @@ +<%# locals: (series:, current_occurrence:, analytics:, recent_allocations:, suggestion: nil) %> + +<%# INSPECT. What is going on with this bill? + + Not "everything about this bill" -- that is the bill's own page, and this + partial exists because the expansion and the drawer had become two + renderings of one enormous detail view. The twelve-month chart, the + per-year table, the price history, the upcoming dates and the settled + cycles all moved to the page. What is left is the answer to one question, + plus the way to act on it. %> + +
+ <%= render "bills/cancelled_notice", series: series %> + + <%# Current state. %> + <% if current_occurrence %> + <% expected = current_occurrence.resolved_expected_amount_money %> + <% paid = current_occurrence.confirmed_allocated_money %> +
+

+ <% if current_occurrence.paid? %> + <%= t("bills.summary.paid_headline", amount: format_money(paid)) %> + <% else %> + <%= t("bills.summary.remaining", amount: format_money(current_occurrence.remaining_amount_money)) %> + <% end %> +

+

"> + <%= t("bills.partial_progress", paid: format_money(paid), expected: format_money(expected)) %> + · <%= occurrence_due_label(current_occurrence) %> +

+
+ <% else %> +
+

+ <%= "~" if series.has_amount_variance? %><%= format_money(Money.new(series.amount.abs, series.currency)) %> +

+

+ <%= series.next_expected_date.present? ? t("bills.detail.around", date: l(series.next_expected_date, format: :short)) : t("bills.detail.next_payment") %> +

+
+ <% end %> + + <%# Schedule, on one line rather than as a stack of labelled fields. %> +

+ <%= frequency_label(series) %> + <% if series.account %>· <%= series.account.name %><% end %> + <% if series.autopay? %>· <%= t("recurring_transactions.pay_action.autopay") %><% end %> +

+ + <%# Subscription state is state, not depth, so it stays here. %> + <%= render "bills/state_chips", series: series %> + + <%# What has actually paid this lately. %> + <% if recent_allocations.any? %> +
+

<%= t("bills.detail.recent_payments") %>

+
+ <% recent_allocations.first(3).each do |allocation| %> +
+
+

<%= allocation.entry&.name.presence || t("bills.detail.manual_payment") %>

+ <% if allocation.paid_on %> +

<%= l(allocation.paid_on, format: :short) %>

+ <% end %> +
+

<%= format_money(allocation.allocated_amount_money) %>

+
+ <% end %> +
+
+ <% end %> + + <%# Two figures, not a dashboard. The rest of the cost story is on the page. %> + <% if analytics %> +

+ <%= t("bills.summary.cost_line", + average: format_money(analytics[:average]), + annualized: format_money(analytics[:annualized])) %> +

+ <% end %> + + <%# The next step, spelled out. The row shows this as an icon on a phone; + here there is room for the words. It is present on a settled occurrence + too, because the drawer is the only route in the app to unlinking a + payment or reopening a cycle. %> +
+ <% if current_occurrence %> + <%= render "bills/row_action", occurrence: current_occurrence, suggestion: suggestion, labelled: true %> + <% end %> + + <%# Pay and Add link keep a home here, so the portal never leaves Bills + just because the row got quieter. %> + <%= render "recurring_transactions/pay_action", recurring_transaction: series %> + +
+ <%= render DS::Link.new( + text: t("bills.view_full_bill"), + variant: "default", + href: bill_path(series), + data: { turbo_frame: "_top" } + ) %> + + <%# Edit, pause and remove stayed two clicks from the Overview rather + than moving to the page and becoming four. %> + <%= render DS::Menu.new do |menu| %> + <% menu.with_item( + variant: "link", + text: t("bills.show.edit"), + icon: "pencil", + href: edit_recurring_transaction_path(series), + data: { turbo_frame: :modal }) %> + <% if bills_one_shot_ai_available? %> + <% menu.with_item( + variant: "link", + text: t("bills.smart_configurations.show.trigger"), + icon: "sparkles", + href: smart_configuration_bill_path(series), + data: { turbo_frame: :modal }) %> + <% end %> + <% menu.with_item( + variant: "button", + text: series.active? ? t("recurring_transactions.actions.pause") : t("recurring_transactions.actions.resume"), + icon: series.active? ? "pause" : "play", + href: toggle_status_recurring_transaction_path(series), + method: :post) %> + <% menu.with_item( + variant: "button", + text: t("recurring_transactions.actions.delete"), + icon: "trash-2", + href: recurring_transaction_path(series), + method: :delete, + destructive: true, + confirm: t(series.typed_income? ? "recurring_transactions.confirm_delete_income" : "recurring_transactions.confirm_delete", name: series.display_name)) %> + <% end %> +
+
+
diff --git a/app/views/bills/_view_switcher.html.erb b/app/views/bills/_view_switcher.html.erb new file mode 100644 index 000000000..12444d804 --- /dev/null +++ b/app/views/bills/_view_switcher.html.erb @@ -0,0 +1,50 @@ +<%# locals: (active:) %> +<%# Page title and primary action in the header, view switcher beneath, matching + transactions/index. Bills was the only top-level destination in the app with + no heading at all, which also left screen readers with no outline to navigate. %> +<%# Both halves are addable from every view, but only the one this view is + about earns a header button: Add income on the Income plan, Add bill + everywhere else. The other half and the AI review live in the overflow + menu, so the header carries one action instead of a toolbar of three. %> +<% income_first = active == "paycheck" %> +
+

<%= t("bills.index.title") %>

+ +
+ <%= render DS::Link.new( + text: income_first ? t("bills.index.add_income") : t("bills.index.add_bill"), + icon: "plus", + variant: "primary", + href: income_first ? new_recurring_transaction_path(income: true) : new_recurring_transaction_path, + frame: :modal + ) %> + <%= render DS::Menu.new do |menu| %> + <% menu.with_item( + variant: "link", + text: income_first ? t("bills.index.add_bill") : t("bills.index.add_income"), + icon: "plus", + href: income_first ? new_recurring_transaction_path : new_recurring_transaction_path(income: true), + data: { turbo_frame: :modal }) %> + <%# Seeds a chat with the server-owned review prompt into the sidebar + frame; the audit tool grounds the findings. Needs AI consent plus a + configured provider, or the item leads to a dead chat. %> + <% if bills_one_shot_ai_available? %> + <% menu.with_item( + variant: "button", + text: t("bills.index.review_with_ai"), + icon: "sparkles", + href: ai_review_bills_path, + method: :post, + frame: chat_frame, + data: { action: "app-layout#openRightSidebar" }) %> + <% end %> + <% end %> +
+
+ +<%= render DS::SegmentedControl.new(aria_label: t("bills.views.aria_label")) do |control| %> + <% control.with_segment(t("bills.views.overview"), active: active == "overview", href: bills_path) %> + <% control.with_segment(t("bills.views.calendar"), active: active == "calendar", href: bills_path(view: "calendar")) %> + <% control.with_segment(t("bills.views.paycheck"), active: active == "paycheck", href: bills_path(view: "paycheck")) %> + <% control.with_segment(t("bills.views.all"), active: active == "all", href: bills_path(view: "all")) %> +<% end %> diff --git a/app/views/bills/all.html.erb b/app/views/bills/all.html.erb new file mode 100644 index 000000000..83979339b --- /dev/null +++ b/app/views/bills/all.html.erb @@ -0,0 +1,233 @@ +<%= content_for :page_title, t("bills.index.title") %> + +
+ <%= render "bills/view_switcher", active: "all" %> + + <%= form_with url: bills_path, method: :get, scope: :q, + data: { controller: "auto-submit-form" }, + class: "flex flex-wrap items-center gap-2" do |form| %> + <%= hidden_field_tag :view, "all" %> + <%= render DS::SearchInput.new( + name: "q[search]", + value: params.dig(:q, :search), + placeholder: t(".search_placeholder"), + class: "grow max-w-xs", + data: { "auto-submit-form-target": "auto" }) %> + <%= form.select :status, + options_for_select( + [ [ t(".any_status"), "" ] ] + BillsController::STATUS_FILTERS.map { |status| [ t(".status_filters.#{status}"), status ] }, + params.dig(:q, :status) + ), + {}, + { class: "form-field__input w-auto", "data-auto-submit-form-target": "auto" } %> + <%= form.select :bill_type, + options_for_select( + [ [ t(".any_type"), "" ] ] + RecurringTransaction.bill_types.keys.map { |type| [ t(".types.#{type}"), type ] }, + params.dig(:q, :bill_type) + ), + {}, + { class: "form-field__input w-auto", "data-auto-submit-form-target": "auto" } %> + <%= form.select :sort, + options_for_select( + [ [ t(".sort_due"), "" ], [ t(".sort_name"), "name" ], [ t(".sort_amount"), "amount" ] ], + params.dig(:q, :sort) + ), + {}, + { class: "form-field__input w-auto", "data-auto-submit-form-target": "auto" } %> + <% end %> + + <%# What the Subscriptions tab existed to answer. It was a whole destination + for a filter All bills already had, so the rollup now appears when that + filter is on rather than living behind its own tab. %> + <% if @subscription_rollup %> + <%# A text-xl currency figure does not fit an 85px cell, which is what three + columns leaves at 375px. %> +
+
+

<%= t(".subscription_monthly") %>

+

+ <%= @subscription_rollup[:monthly] ? format_money(@subscription_rollup[:monthly]) : "โ€“" %> +

+ <% if @subscription_rollup[:unconvertible].positive? %> +

<%= t("bills.index.unconvertible", count: @subscription_rollup[:unconvertible]) %>

+ <% end %> +
+
+

<%= t(".subscription_annual") %>

+

+ <%= @subscription_rollup[:annual] ? format_money(@subscription_rollup[:annual]) : "โ€“" %> +

+
+
+

<%= t(".subscription_active") %>

+

<%= @subscription_rollup[:active_count] %>

+
+
+ <% end %> + + <% if @all_series.empty? %> +
+ <%= render DS::EmptyState.new( + icon: "receipt", + title: t(".no_matches_title"), + description: t(".no_matches_description") + ) %> +
+ <% else %> + <%# A seven-column table on a phone is a table you scroll sideways to read. + The same rows, the same filters, laid out the way the Calendar already + does it: a purpose-built small-screen list rather than a squeezed + desktop one. Switched on the container, not the viewport: the app + shell's sidebars can squeeze a desktop window to phone-list widths, + and a viewport breakpoint cannot see that. %> +
+
+ <% @all_series.each do |series| %> +
+ <%= link_to bill_path(series), class: "flex items-center gap-3 min-w-0 flex-1 group" do %> +
+

<%= series.display_name %>

+

+ <%= frequency_label(series) %> + <% if series.next_expected_date %> + · <%= t(".next_short", date: l(series.next_expected_date, format: :short)) %> + <% end %> +

+
+
+

+ <%= format_money(Money.new(series.amount.abs, series.currency)) %> +

+

+ <%= render DS::Pill.new(label: t("recurring_transactions.status.#{series.status}"), tone: recurring_status_pill_tone(series.status), marker: false) %> +

+
+ <% end %> + +
+ <%= render DS::Menu.new do |menu| %> + <% menu.with_item( + variant: "link", + text: t("recurring_transactions.edit.trigger_label"), + icon: "pencil", + href: edit_recurring_transaction_path(series), + data: { turbo_frame: :modal }) %> + <% menu.with_item( + variant: "button", + text: series.active? ? t("recurring_transactions.actions.pause") : t("recurring_transactions.actions.resume"), + icon: series.active? ? "pause" : "play", + href: toggle_status_recurring_transaction_path(series), + method: :post) %> + <% menu.with_item( + variant: "button", + text: t("recurring_transactions.actions.delete"), + icon: "trash-2", + href: recurring_transaction_path(series), + method: :delete, + destructive: true, + confirm: t(series.typed_income? ? "recurring_transactions.confirm_delete_income" : "recurring_transactions.confirm_delete", name: series.display_name)) %> + <% end %> +
+
+ <% end %> +
+ + +
+ <% end %> + + <%# Across every bill, not just the one you have open. Per-bill history lives + in the bill's own detail. %> + <% if @recent_price_changes&.any? %> +
+
+

<%= t(".subscription_price_changes") %>

+
+
+ <% @recent_price_changes.each do |change| %> +
+
+

<%= change.recurring_transaction.display_name %>

+

<%= l(change.effective_on, format: :long) %>

+
+

"> + <%= t(".subscription_price_change_line", + from: format_money(change.previous_amount_money), + to: format_money(change.new_amount_money), + percent: change.previous_amount.positive? ? ((change.new_amount - change.previous_amount) / change.previous_amount * 100).round(1) : 0) %> +

+
+ <% end %> +
+
+ <% end %> +
diff --git a/app/views/bills/calendar.html.erb b/app/views/bills/calendar.html.erb new file mode 100644 index 000000000..4a8a9680e --- /dev/null +++ b/app/views/bills/calendar.html.erb @@ -0,0 +1,96 @@ +<%= content_for :page_title, t("bills.index.title") %> + +
+ <%= render "bills/view_switcher", active: "calendar" %> + + <%# The nav group and a totals line carrying two money values do not share + 375px, and neither had anywhere to wrap. %> +
+
+ <%= render DS::Link.new( + icon: "chevron-left", + variant: "ghost", + href: bills_path(view: "calendar", month: (@month - 1.month).strftime("%Y-%m")), + "aria-label": t(".previous_month") + ) %> + <% unless @at_forward_limit %> + <%= render DS::Link.new( + icon: "chevron-right", + variant: "ghost", + href: bills_path(view: "calendar", month: (@month + 1.month).strftime("%Y-%m")), + "aria-label": t(".next_month") + ) %> + <% end %> +

<%= l(@month, format: "%B %Y") %>

+ <% unless @month == Date.current.beginning_of_month %> + <%= render DS::Link.new(text: t(".today"), variant: "ghost", href: bills_path(view: "calendar")) %> + <% end %> +
+
+

+ <%= t(".month_totals", + expected: @month_expected ? format_money(@month_expected) : format_money(Money.new(0, Current.family.currency)), + paid: @month_paid ? format_money(@month_paid) : format_money(Money.new(0, Current.family.currency))) %> +

+ <% if @month_unconvertible.positive? %> +

<%= t("bills.index.unconvertible", count: @month_unconvertible) %>

+ <% end %> +
+
+ + <%# Month grid: desktop and tablet. %> + + +
+ <%# Minted for THIS member: the feed applies per-account sharing, so each + member's URL serves only the bills they can reach in the app. %> + <%= link_to t(".subscribe_ical"), + bills_feed_path(token: Current.family.bills_feed_token_for(Current.user)), + class: "text-xs text-link hover:underline" %> + <%= button_to t(".reset_feed"), + reset_feed_token_bills_path, + class: "text-xs text-secondary hover:underline", + data: { turbo_confirm: t("bills.reset_feed_token.confirm") } %> +
+ + <%# Mobile: an agenda list of the month's days that actually have bills, + not a shrunken grid. %> +
+ <% month_days = (@month..@month.end_of_month).select { |day| @by_day[day].present? } %> + <% if month_days.empty? %> +
+

<%= t(".empty_month") %>

+
+ <% else %> + <% month_days.each do |day| %> +
+

uppercase"> + <%= l(day, format: :long) %> +

+ <% @by_day[day].sort_by { |occurrence| -occurrence.resolved_expected_amount }.each do |occurrence| %> + <%= render "bills/calendar_chip", occurrence: occurrence %> + <% end %> +
+ <% end %> + <% end %> +
+
diff --git a/app/views/bills/index.html.erb b/app/views/bills/index.html.erb new file mode 100644 index 000000000..6c9e15086 --- /dev/null +++ b/app/views/bills/index.html.erb @@ -0,0 +1,181 @@ +<%= content_for :page_title, t(".title") %> + +
+ <%= render "bills/view_switcher", active: "overview" %> + +
+ <%# Nothing here was confirmed by anyone: detection built it from bank data. + Says so once, and stops as soon as the user works with any of it. %> + <% if @detected_awaiting_review.positive? %> +
+

+ <%= t(".detected_review", count: @detected_awaiting_review) %> +

+ <%= link_to t(".detected_review_action"), bills_path(view: "all"), + class: "text-xs text-secondary hover:text-primary shrink-0" %> +
+ <% end %> + + <%= render "bills/month_pulse" %> + + <%= render "bills/ai_prompts", prompts: %w[due_before_paycheck subscriptions_up monthly_subscriptions] %> + + <%# Changes worth knowing about, ranked by whether you can still do anything. + Everything stays on the page: the quieter half collapses rather than + being capped, so nothing becomes a dead end and nothing pushes the + worklist off screen. %> + <% if @notices.any? %> + <% urgent, routine = @notices.partition(&:urgent?) %> +
+ <% urgent.each do |notice| %> + <%= render "bills/notice", notice: notice, urgent: true %> + <% end %> + + <% if routine.any? %> +
+ <% if urgent.any? %> + <%= render DS::Disclosure.new(title: t(".notices_routine", count: routine.size), align: "left") do %> +
+ <% routine.each do |notice| %> + <%= render "bills/notice", notice: notice, urgent: false %> + <% end %> +
+ <% end %> + <% else %> +
+ <% routine.each do |notice| %> + <%= render "bills/notice", notice: notice, urgent: false %> + <% end %> +
+ <% end %> +
+ <% end %> +
+ <% end %> + + <% if @suggested_allocations.any? %> +
+
+

<%= t(".needs_review") %>

+ · +

<%= @suggested_allocations.size %>

+
+ +
+ <% @suggested_allocations.each do |suggestion| %> + <% series = suggestion.recurring_occurrence.recurring_transaction %> +
+
+

+ <%= t(".suggestion_line", + entry: suggestion.entry&.name.presence || t(".suggestion_unknown_entry"), + bill: series.display_name) %> +

+ <%# The matcher stores WHY it proposed each of these. This line + used to show a bare percentage of that reasoning instead of + the reasoning, which told nobody anything they could judge. %> + <% reasons = bills_match_reasons( + suggestion.match_signals, + currency: suggestion.recurring_occurrence.currency, + expected: suggestion.recurring_occurrence.resolved_expected_amount, + actual: suggestion.entry&.amount&.abs, + due_on: suggestion.recurring_occurrence.effective_due_on, + paid_on: suggestion.paid_on + ) %> +

+ <%= format_money(suggestion.allocated_amount_money) %> + · <%= l(suggestion.paid_on, format: :short) %><%= " ยท #{reasons.join(" ยท ")}" if reasons.any? %> +

+
+
+ <%= render DS::Link.new( + text: t("recurring_occurrences.show.link_payment"), + variant: "primary", + href: confirm_recurring_allocation_path(suggestion), + method: :post + ) %> + <%= render DS::Link.new( + text: t("recurring_occurrences.show.not_this_one"), + variant: "ghost", + href: reject_recurring_allocation_path(suggestion), + method: :post + ) %> +
+
+ <% end %> +
+
+ <% end %> + + <%# Series-level review: detection found these, nobody confirmed them yet. + Distinct from the payment-match queue above. %> + <% if @suggested_series.any? %> + <%= render "recurring_transactions/suggested_series", suggested: @suggested_series %> + <% end %> + + <% if [ @overdue, @month_rows, @later, @dormant ].all?(&:empty?) %> + <% if @suggested_series.none? %> +
+ <%= render DS::EmptyState.new( + icon: "receipt", + title: t(".empty.title"), + description: @has_transaction_history ? t(".empty.description") : t(".empty.no_history_description") + ) do |empty| %> + <% empty.with_action do %> +
+ <% if @has_transaction_history %> + <%= render DS::Link.new( + text: t(".empty.action"), + icon: "search", + variant: "primary", + href: detect_bills_path, + method: :post + ) %> + <%= render DS::Link.new( + text: t(".add_bill"), + icon: "plus", + variant: "outline", + href: new_recurring_transaction_path, + frame: :modal + ) %> + <% else %> + <%# Detection over zero transactions finds nothing; offering it + would be a button that silently does nothing. %> + <%= render DS::Link.new( + text: t(".add_bill"), + icon: "plus", + variant: "primary", + href: new_recurring_transaction_path, + frame: :modal + ) %> + <% end %> +
+ <% end %> + <% end %> +
+ <% end %> + <% else %> + <%# Anything late is pulled out of the chronological run. Inside it, an + overdue bill was distinguishable only by a word in the date column, so + the most urgent rows were the easiest ones to scroll past. %> + <% if @overdue.any? %> +
+ <%# The section says how much is at stake, not just how many rows: a + count alone is alarming without being informative. %> + <%= render "bills/occurrence_section", + title: t(".needs_attention"), + occurrences: @overdue, + date_labels: true, + suggestions: @suggestions_by_occurrence, + meta: (@past_due_total && @past_due_total.amount.positive? ? t(".attention_overdue_total", amount: format_money(@past_due_total)) : nil) %> +
+ <% end %> + +
+ <%= render "bills/occurrence_section", title: t(".this_month"), occurrences: @month_rows, date_labels: true, suggestions: @suggestions_by_occurrence, pay_periods: @month_pay_periods %> + <%= render "bills/occurrence_section", title: t(".later"), occurrences: @later, suggestions: @suggestions_by_occurrence %> + <%= render "bills/occurrence_section", title: t(".dormant"), occurrences: @dormant, suggestions: @suggestions_by_occurrence %> +
+ <% end %> +
+
diff --git a/app/views/bills/pane.html.erb b/app/views/bills/pane.html.erb new file mode 100644 index 000000000..725b3b307 --- /dev/null +++ b/app/views/bills/pane.html.erb @@ -0,0 +1,30 @@ +<%= turbo_frame_tag @pane_frame_id do %> +
+
+
+
+ <% if @series.category %> + <%= render "categories/badge", category: @series.category %> + <% end %> +

<%= @series.display_name %>

+
+ <%= link_to bill_path(@series, display: "pane", frame: @pane_frame_id, close: 1), + data: { turbo_frame: @pane_frame_id }, + class: "text-secondary hover:text-primary shrink-0", + "aria-label": t(".close") do %> + <%= icon "x", size: "sm" %> + <% end %> +
+ + <%# What is going on with this bill, and what to do about it. Everything + deeper -- the chart, the per-year totals, the price history, the + upcoming dates, the settled cycles -- lives on the bill's page. %> + <%= render "bills/summary", + series: @series, + current_occurrence: @current_occurrence, + analytics: @analytics, + recent_allocations: @recent_allocations, + suggestion: @pane_suggestion %> +
+
+<% end %> diff --git a/app/views/bills/pane_close.html.erb b/app/views/bills/pane_close.html.erb new file mode 100644 index 000000000..506ebff01 --- /dev/null +++ b/app/views/bills/pane_close.html.erb @@ -0,0 +1 @@ +<%= turbo_frame_tag @pane_frame_id %> diff --git a/app/views/bills/paycheck.html.erb b/app/views/bills/paycheck.html.erb new file mode 100644 index 000000000..bf7b203c1 --- /dev/null +++ b/app/views/bills/paycheck.html.erb @@ -0,0 +1,74 @@ +<%# The Income plan page: the income schedule, a warning if there is a gap + before the next payday, then one surface holding every upcoming pay + period in turn. %> + +<%= content_for :page_title, t("bills.index.title") %> + +<%# The bridge/period split and why the bridge never becomes a timeline row + live with BillsHelper#paycheck_plan_sections; this template only renders + the prepared sections. %> +<% + currency = Current.family.currency + sections = paycheck_plan_sections(@plan) + periods = sections[:periods] || [] + shortfall = sections[:shortfall] + bridge_note = sections[:bridge_note] +%> + +
+ <%= render "bills/view_switcher", active: "paycheck" %> + + <%= render "bills/ai_prompts", prompts: %w[due_before_paycheck safe_to_spend] %> + + <% if @income_series.any? %> + <%= render "bills/paycheck_income" %> + <% end %> + + <% if @plan.nil? %> +
+ <%= render DS::EmptyState.new( + icon: "calendar-clock", + title: t(".empty.title"), + description: t(".empty.description") + ) do |empty| %> + <% empty.with_action do %> + <%= render DS::Link.new( + text: t(".empty.action"), + icon: "plus", + variant: "primary", + href: new_recurring_transaction_path(income: true), + frame: :modal + ) %> + <% end %> + <% end %> +
+ <% elsif @plan.none? { |period| period.items.any? } %> +
+ <%= render DS::EmptyState.new( + icon: "circle-check", + title: t(".all_clear.title"), + description: t(".all_clear.description", date: l(@plan.last.ends_on, format: :long)) + ) %> +
+ <% else %> + <% if shortfall %> + <%= render "bills/paycheck_shortfall", period: shortfall, currency: currency %> + <% elsif bridge_note %> + <%= render "bills/paycheck_bridge", period: bridge_note, currency: currency %> + <% end %> + +
+ <% periods.each_with_index do |period, index| %> + <%= render "bills/paycheck_period", + period: period, + currency: currency, + first: index.zero?, + last: index == periods.size - 1 %> + <% end %> +
+ + <% if @plan_unconvertible.to_i.positive? %> +

<%= t(".unconvertible", count: @plan_unconvertible) %>

+ <% end %> + <% end %> +
diff --git a/app/views/bills/show.html.erb b/app/views/bills/show.html.erb new file mode 100644 index 000000000..ef2885f8e --- /dev/null +++ b/app/views/bills/show.html.erb @@ -0,0 +1,107 @@ +<%= content_for :page_title, @series.display_name %> + +<%# DEEP DIVE. Show me everything, and let me configure it. + + This used to be a drawer dialog floating over an empty settings layout, + which meant the app had three renderings of a bill's detail and no page. + It is a page now, so the chart, the history and the configuration have + somewhere to live that is not the row expansion. %> + +
+
+
+ <% if @series.category %> + <%= render "categories/badge", category: @series.category %> + <% end %> +

<%= @series.display_name %>

+

+ <%= frequency_label(@series) %> + <% if (progress = @series.installment_progress) %> + · <%= t("bills.installment_progress", done_plus_one: [ progress.first + 1, progress.last ].min, total: progress.last) %> + <% end %> + · <%= t("recurring_transactions.status.#{@series.status}") %> +

+
+ +
+ <%= render "recurring_transactions/pay_action", recurring_transaction: @series %> + + <%= render DS::Menu.new do |menu| %> + <% menu.with_item( + variant: "link", + text: t("bills.show.edit"), + icon: "pencil", + href: edit_recurring_transaction_path(@series), + data: { turbo_frame: :modal }) %> + <% if bills_one_shot_ai_available? %> + <% menu.with_item( + variant: "link", + text: t("bills.smart_configurations.show.trigger"), + icon: "sparkles", + href: smart_configuration_bill_path(@series), + data: { turbo_frame: :modal }) %> + <% end %> + <% menu.with_item( + variant: "button", + text: @series.active? ? t("recurring_transactions.actions.pause") : t("recurring_transactions.actions.resume"), + icon: @series.active? ? "pause" : "play", + href: toggle_status_recurring_transaction_path(@series), + method: :post) %> + <% menu.with_item( + variant: "button", + text: t("recurring_transactions.actions.delete"), + icon: "trash-2", + href: recurring_transaction_path(@series), + method: :delete, + destructive: true, + confirm: t(@series.typed_income? ? "recurring_transactions.confirm_delete_income" : "recurring_transactions.confirm_delete", name: @series.display_name)) %> + <% end %> +
+
+ + <%# The current obligation, and the way to resolve it. Unconditional: a + settled cycle still needs a route to unlinking a payment or reopening it, + and the drawer is the only place in the app that offers either. %> + <% if @current_occurrence %> +
+
+

+ <% if @current_occurrence.paid? %> + <%= t("bills.summary.paid_headline", amount: format_money(@current_occurrence.confirmed_allocated_money)) %> + <% else %> + <%= t("bills.summary.remaining", amount: format_money(@current_occurrence.remaining_amount_money)) %> + <% end %> +

+

"> + + <%= t("bills.partial_progress", + paid: format_money(@current_occurrence.confirmed_allocated_money), + expected: format_money(@current_occurrence.resolved_expected_amount_money)) %> + + · <%= occurrence_due_label(@current_occurrence) %> +

+
+ + <%= render DS::Link.new( + text: @current_occurrence.scheduled? ? t("bills.resolve") : t("bills.manage_payments"), + variant: "primary", + href: recurring_occurrence_path(@current_occurrence), + frame: :drawer + ) %> +
+ <% end %> + +
+ <%= render "bills/detail", + series: @series, + current_occurrence: @current_occurrence, + history: @history, + upcoming: @upcoming, + analytics: @analytics, + payment_history: @payment_history, + yearly_metrics: @yearly_metrics, + last_account: @last_account, + recent_allocations: @recent_allocations, + dense: false %> +
+
diff --git a/app/views/bills/smart_configurations/_proposal.html.erb b/app/views/bills/smart_configurations/_proposal.html.erb new file mode 100644 index 000000000..974c28ebd --- /dev/null +++ b/app/views/bills/smart_configurations/_proposal.html.erb @@ -0,0 +1,12 @@ +<%# locals: (name:, value:, label:, current:, proposed:) %> +<%# The checkbox IS the form field: its value is the proposed change, so an + unchecked row submits nothing at all. %> + diff --git a/app/views/bills/smart_configurations/show.html.erb b/app/views/bills/smart_configurations/show.html.erb new file mode 100644 index 000000000..a3761f35b --- /dev/null +++ b/app/views/bills/smart_configurations/show.html.erb @@ -0,0 +1,93 @@ +<%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".title", name: @series.display_name)) %> + <% dialog.with_body do %> + <% if @error %> +

<%= @error %>

+ <% elsif !@suggestion.any_proposal? %> +
+

<%= t(".no_changes") %>

+ <% if @suggestion.rationale.present? %> +

<%= @suggestion.rationale %>

+ <% end %> +
+ <% else %> + <%# Each proposal is a checkbox CARRYING the proposed value as the real + form param: unchecked rows submit nothing, so only accepted changes + reach recurring_transactions#update. The schedule's companion day + fields ride as hidden inputs -- harmless when the preset checkbox is + unchecked, because FrequencyPreset.apply no-ops without a preset. %> + <%= form_with url: recurring_transaction_path(@series), method: :patch, + data: { turbo_frame: :_top } do %> + <%# Keeps an all-unchecked submit a harmless no-op: params.require is + satisfied, and this unpermitted key is dropped on the other side. %> + <%= hidden_field_tag "recurring_transaction[_ai_review]", "1" %> +
+

<%= t(".intro") %>

+ + <% if @suggestion.name.present? && @suggestion.name != @series.display_name %> + <%= render "bills/smart_configurations/proposal", + name: "recurring_transaction[name]", value: @suggestion.name, + label: t(".field_name"), + current: @series.display_name, proposed: @suggestion.name %> + <% end %> + + <% if @suggestion.amount.present? && @suggestion.amount != @series.amount.abs %> + <%= render "bills/smart_configurations/proposal", + name: "recurring_transaction[amount]", value: @suggestion.amount.to_s, + label: t(".field_amount"), + current: format_money(@series.amount_money.abs), + proposed: format_money(Money.new(@suggestion.amount, @series.currency)) %> + <% end %> + + <% if @suggestion.frequency.present? %> + <%= render "bills/smart_configurations/proposal", + name: "recurring_transaction[frequency_preset]", value: @suggestion.frequency, + label: t(".field_schedule"), + current: frequency_label(@series), + proposed: t("recurring_transactions.frequency_presets.#{@suggestion.frequency}") %> + <%= hidden_field_tag "recurring_transaction[frequency_day_of_month]", + @suggestion.day_of_month || @detection.day_of_month %> + <%= hidden_field_tag "recurring_transaction[frequency_weekday]", + @suggestion.weekday || @detection.weekday %> + <%= hidden_field_tag "recurring_transaction[frequency_month_of_year]", + @suggestion.month_of_year || @detection.month_of_year %> + <% end %> + + <% if @suggestion.category_id.present? && @suggestion.category_id != @series.category_id %> + <%= render "bills/smart_configurations/proposal", + name: "recurring_transaction[category_id]", value: @suggestion.category_id, + label: t(".field_category"), + current: @series.category&.name || t(".uncategorized"), + proposed: @suggestion.category_name %> + <% end %> + + <% if @suggestion.bill_type.present? && @suggestion.bill_type != @series.bill_type %> + <%= render "bills/smart_configurations/proposal", + name: "recurring_transaction[bill_type]", value: @suggestion.bill_type, + label: t(".field_kind"), + current: t("bills.all.types.#{@series.bill_type}"), + proposed: t("bills.all.types.#{@suggestion.bill_type}") %> + <% end %> + + <%# false is a real proposal (turn autopay off), so the gate is + non-nil and different, not truthy. %> + <% unless @suggestion.autopay.nil? || @suggestion.autopay == @series.autopay %> + <%= render "bills/smart_configurations/proposal", + name: "recurring_transaction[autopay]", value: @suggestion.autopay.to_s, + label: t(".field_autopay"), + current: @series.autopay ? t(".autopay_on") : t(".autopay_off"), + proposed: @suggestion.autopay ? t(".autopay_on") : t(".autopay_off") %> + <% end %> +
+ + <% if @suggestion.rationale.present? %> +

<%= @suggestion.rationale %>

+ <% end %> + +
+ <%= render DS::Button.new(text: t(".apply"), variant: "primary") %> +
+ <% end %> + <% end %> + <% end %> +<% end %> diff --git a/app/views/budget_categories/_budget_category.html.erb b/app/views/budget_categories/_budget_category.html.erb index 8dae19f45..89178aa4a 100644 --- a/app/views/budget_categories/_budget_category.html.erb +++ b/app/views/budget_categories/_budget_category.html.erb @@ -77,6 +77,18 @@ days: daily_info[:days_remaining]) %> <% end %> + <%# Bills reservations are metadata like the budgeted figure above, so + compact callers that opt out of the meta row stay compact. %> + <% if (reserved = budget_category.bills_reserved).amount.positive? %> +
+ <%= t("budget_categories.bills_reserved", amount: format_money(reserved)) %> +
+ <% end %> + <% if (unconvertible = budget_category.bills_reserved_unconvertible_count).positive? %> +
+ <%= t("budget_categories.bills_reserved_unconvertible", count: unconvertible) %> +
+ <% end %> <% end %>
<% if budget_category.available_to_spend >= 0 %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 06592517e..e9967775f 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -9,6 +9,7 @@ else [ { name: t(".nav.home"), path: root_path, icon: "pie-chart", icon_custom: false, active: page_active?(root_path) }, { name: t(".nav.transactions"), path: transactions_path, icon: "credit-card", icon_custom: false, active: page_active?(transactions_path) }, + bills_nav_item, { name: t(".nav.reports"), path: reports_path, icon: "chart-bar", icon_custom: false, active: page_active?(reports_path) }, plan_nav_item, { name: t(".nav.assistant"), path: chats_path, icon: "icon-assistant", icon_custom: true, active: page_active?(chats_path), mobile_only: true } diff --git a/app/views/recurring_occurrences/_candidate.html.erb b/app/views/recurring_occurrences/_candidate.html.erb new file mode 100644 index 000000000..5fcec48e8 --- /dev/null +++ b/app/views/recurring_occurrences/_candidate.html.erb @@ -0,0 +1,63 @@ +<%# locals: (occurrence:, entry:, reasons: [], promoted: false) %> + +<%# One transaction offered as the payment for this bill. + + The list used to put an identical bordered "Use this" button on all fifteen + rows, which made a reconciliation screen read as a database dump. Now the + strongest candidate gets a card with the single real button on it, and + every other row IS the button -- the whole row posts, so there is one tap + target per transaction instead of a small one sitting beside the text. + + DS::Button cannot be used for the row form: its template renders its own + icon-and-text body and never renders a caller's block, so the row markup + would silently vanish. Hence a raw button_to, with form_class -- button_to + puts `class` on the
diff --git a/app/views/recurring_transactions/_suggested_series.html.erb b/app/views/recurring_transactions/_suggested_series.html.erb new file mode 100644 index 000000000..a8c8fb880 --- /dev/null +++ b/app/views/recurring_transactions/_suggested_series.html.erb @@ -0,0 +1,61 @@ +<%# locals: (suggested:) %> +<%# Detection proposes, the user disposes. Rendered from both the Bills + overview and Settings -> Recurring, so every key is absolutely scoped. + + Collapsible because this sits above the bills the user actually came to + see. Someone who is not ready to triage nine guesses should be able to fold + them away and still find their rent, and have that stick. %> +<%= render DS::Disclosure.new( + variant: :card_inset, + open: true, + data: { + controller: "persisted-disclosure", + persisted_disclosure_key_value: "bills-suggested" + } +) do |disclosure| %> + <% disclosure.with_summary_content do %> +
+ <%= icon "chevron-right", size: "sm", + class: "transition-transform group-open:rotate-90" %> + +

+ <%= t("recurring_transactions.suggested.title") %> +

+ + <%= render DS::Pill.new(label: suggested.size.to_s, tone: :neutral, marker: false) %> + +

+ <%= t("recurring_transactions.suggested.collapsed_hint") %> +

+
+ <% end %> + +
+ <% suggested.each do |suggestion| %> +
+
+

<%= suggestion.display_name %>

+

+ <%= format_money(suggestion.amount_money.abs) %> + · <%= frequency_label(suggestion) %> + · <%= t("recurring_transactions.suggested.seen_count", count: suggestion.occurrence_count) %> +

+
+
+ <%= render DS::Link.new( + text: t("recurring_transactions.suggested.confirm"), + variant: "primary", + href: confirm_recurring_transaction_path(suggestion), + method: :post + ) %> + <%= render DS::Link.new( + text: t("recurring_transactions.suggested.dismiss"), + variant: "ghost", + href: dismiss_recurring_transaction_path(suggestion), + method: :post + ) %> +
+
+ <% end %> +
+<% end %> diff --git a/app/views/recurring_transactions/edit.html.erb b/app/views/recurring_transactions/edit.html.erb new file mode 100644 index 000000000..fb0da0b9e --- /dev/null +++ b/app/views/recurring_transactions/edit.html.erb @@ -0,0 +1,9 @@ +<%= render DS::Dialog.new do |dialog| %> + <% title_key = @recurring_transaction.typed_income? ? ".income_title" : ".title" %> + <% dialog.with_header(title: t(title_key, name: @recurring_transaction.display_name)) %> + <% dialog.with_body do %> + <%= render "form", + recurring_transaction: @recurring_transaction, + sibling_count: @sibling_count.to_i %> + <% end %> +<% end %> diff --git a/app/views/recurring_transactions/index.html.erb b/app/views/recurring_transactions/index.html.erb index 03384a141..882f61aae 100644 --- a/app/views/recurring_transactions/index.html.erb +++ b/app/views/recurring_transactions/index.html.erb @@ -56,136 +56,25 @@ -
- <% if @recurring_transactions.empty? %> - <%= render DS::EmptyState.new( - icon: "repeat", - title: t("recurring_transactions.empty.title"), - description: t("recurring_transactions.empty.description") - ) do |empty| %> - <% empty.with_action do %> - <%= render DS::Link.new( - text: t("recurring_transactions.identify_patterns"), - icon: "search", - variant: "primary", - href: identify_recurring_transactions_path, - method: :post - ) %> - <% end %> - <% end %> - <% else %> -
-
-

<%= t("recurring_transactions.title") %>

- · -

<%= @recurring_transactions.count %>

-
+ <% if @suggested.any? %> + <%= render "recurring_transactions/suggested_series", suggested: @suggested %> + <% end %> -
- - - - - - - - - - - - - - <% @recurring_transactions.each do |recurring_transaction| %> - "> - - <% if recurring_transaction.transfer? %> - - <% else %> - - <% end %> - - - - - - - <% end %> - -
<%= t("recurring_transactions.table.merchant") %><%= t("recurring_transactions.table.amount") %><%= t("recurring_transactions.table.expected_day") %><%= t("recurring_transactions.table.next_date") %><%= t("recurring_transactions.table.last_occurrence") %><%= t("recurring_transactions.table.status") %><%= t("recurring_transactions.table.actions") %>
-
- <% if recurring_transaction.merchant.present? %> - <% if recurring_transaction.merchant.logo_url.present? %> - <%= image_tag recurring_transaction.merchant.logo_url, - class: "w-6 h-6 rounded-full", - loading: "lazy" %> - <% else %> - <%= render DS::FilledIcon.new( - variant: :text, - text: recurring_transaction.merchant.name, - size: "sm", - rounded: true - ) %> - <% end %> - <%= recurring_transaction.merchant.name %> - <% else %> - <%= render DS::FilledIcon.new( - variant: :text, - text: recurring_transaction.name, - size: "sm", - rounded: true - ) %> - <%= recurring_transaction.name %> - <% end %> - <% if recurring_transaction.manual? %> - - <%= t("recurring_transactions.badges.manual") %> - - <% end %> -
-
- <%= format_money(recurring_transaction.amount_money.abs) %> - "> - <% if recurring_transaction.manual? && recurring_transaction.has_amount_variance? %> -
"> - ~ - <%= format_money(-recurring_transaction.expected_amount_avg_money) %> -
- <% else %> - <%= format_money(-recurring_transaction.amount_money) %> - <% end %> -
- <%= t("recurring_transactions.day_of_month", day: recurring_transaction.expected_day_of_month) %> - - <%= l(recurring_transaction.next_expected_date, format: :short) %> - - <%= l(recurring_transaction.last_occurrence_date, format: :short) %> - - <% if recurring_transaction.active? %> - - <%= t("recurring_transactions.status.active") %> - - <% else %> - - <%= t("recurring_transactions.status.inactive") %> - - <% end %> - -
- <%= link_to toggle_status_recurring_transaction_path(recurring_transaction), - data: { turbo_method: :post }, - class: "text-secondary hover:text-primary" do %> - <%= icon recurring_transaction.active? ? "pause" : "play", size: "sm" %> - <% end %> - <%= link_to recurring_transaction_path(recurring_transaction), - data: { turbo_method: :delete, turbo_confirm: t("recurring_transactions.confirm_delete") }, - class: "text-secondary hover:text-destructive" do %> - <%= icon "trash-2", size: "sm" %> - <% end %> -
-
+ <%# The Bills workspace is preview-gated, so the card that leads into it + only renders for opted-in users. %> + <% if preview_features_enabled? %> +
+
+

<%= t("recurring_transactions.manage.title") %>

+

<%= t("recurring_transactions.manage.description") %>

+
+ <%= render DS::Link.new( + text: t("recurring_transactions.manage.cta"), + icon: "receipt", + variant: "outline", + href: bills_path(view: "all") + ) %>
-
- <% end %> -
+ <% end %> <% end %>
diff --git a/app/views/recurring_transactions/new.html.erb b/app/views/recurring_transactions/new.html.erb new file mode 100644 index 000000000..5af751c67 --- /dev/null +++ b/app/views/recurring_transactions/new.html.erb @@ -0,0 +1,77 @@ +<%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: @recurring_transaction.is_income ? t(".income_title") : t(".title")) %> + <% dialog.with_body do %> + <% if @candidates.present? %> +
+

+ <%= @recurring_transaction.is_income ? t(".start_from_income_title") : t(".start_from_title") %> +

+
+ <% @candidates.each do |candidate| %> + <%= link_to new_recurring_transaction_path(entry_id: candidate[:entry_id], income: params[:income].presence), + class: "flex items-center justify-between gap-3 px-3 py-2 hover:bg-surface-inset" do %> +
+

<%= candidate[:name] %>

+

+ <%= t(".candidate_meta", count: candidate[:count], date: l(candidate[:last_date], format: :short)) %> +

+
+

+ <%= format_money(Money.new(candidate[:amount], candidate[:currency])) %> +

+ <% end %> + <% end %> +
+

<%= t(".start_from_hint") %>

+
+ <% end %> + + <%# Reachable whether or not detection found anything, and before the form + so a search does not throw away typed fields. %> +
+ <% if params[:entry_id].present? %> + <%= render DS::Link.new( + text: t(".pick_different"), + variant: :default, + href: new_recurring_transaction_path(picker: 1, income: params[:income].presence) + ) %> + <%# Smart-fill needs the picked transaction as its evidence anchor, so + it only exists on this variant: with no anchor it would guess. + DS::Link translates method: into data-turbo-method, so this is a + real POST; the anchor rides the query string. %> + <% if bills_one_shot_ai_available? %> + <%= render DS::Link.new( + text: t(".smart_fill"), + icon: "sparkles", + variant: :default, + href: smart_fill_recurring_transactions_path(entry_id: params[:entry_id], income: params[:income].presence), + method: :post, + frame: :modal + ) %> + <% end %> + <% else %> + <%= t(".search_all_prompt") %> + <%= render DS::Link.new( + text: t(".search_all_cta"), + variant: :default, + href: new_recurring_transaction_path(picker: 1, income: params[:income].presence) + ) %> + <% end %> +
+ + <% if @smart_fill %> +
+

<%= t(".smart_fill_applied") %>

+ <% if @smart_fill.rationale.present? %> +

<%= @smart_fill.rationale %>

+ <% end %> +
+ <% elsif @smart_fill_error %> +
+ <%= @smart_fill_error %> +
+ <% end %> + + <%= render "form", recurring_transaction: @recurring_transaction, sibling_count: 0 %> + <% end %> +<% end %> diff --git a/app/views/recurring_transactions/pick_entry.html.erb b/app/views/recurring_transactions/pick_entry.html.erb new file mode 100644 index 000000000..a12dc4a4b --- /dev/null +++ b/app/views/recurring_transactions/pick_entry.html.erb @@ -0,0 +1,37 @@ +<%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: @is_income ? t(".income_title") : t(".title"), subtitle: t(".hint")) %> + <% dialog.with_body do %> +
+ <%# GETs this same dialog URL with ?q= and re-renders in the frame, + the same shape as the payment picker's search. Submit-on-Enter on + purpose: a frame replacement mid-typing destroys input focus. %> + <%= form_with url: new_recurring_transaction_path, method: :get, data: { turbo_frame: :modal } do %> + <%= hidden_field_tag :picker, "1" %> + <%= hidden_field_tag(:income, "1") if @is_income %> + <%= render DS::SearchInput.new( + name: "q", + value: @picker_query, + placeholder: t(".search_placeholder"), + autofocus: true + ) %> + <% end %> + + <% if @picker_entries.any? %> +
+ <% @picker_entries.each do |entry| %> + <%= render "recurring_transactions/picker_row", entry: entry, claimed_by: @claimed_by[entry.id] %> + <% end %> +
+ <% if @picker_capped %> +

<%= t(".showing_recent", count: @picker_entries.size) %>

+ <% end %> + <% else %> +

<%= t(".no_results", query: @picker_query) %>

+ <% end %> + + <%= link_to t(".back"), + new_recurring_transaction_path(income: @is_income ? "1" : nil), + class: "block text-sm text-secondary hover:text-primary" %> +
+ <% end %> +<% end %> diff --git a/app/views/transactions/_mark_recurring.html.erb b/app/views/transactions/_mark_recurring.html.erb index 46e684312..4a738e5ff 100644 --- a/app/views/transactions/_mark_recurring.html.erb +++ b/app/views/transactions/_mark_recurring.html.erb @@ -6,15 +6,29 @@ <%= @mark_recurring_subtitle %>

- <%= render DS::Button.new( - text: t("transactions.show.mark_recurring"), - variant: "outline", - icon: "repeat", - href: @mark_recurring_href, - method: :post, - frame: "_top", - disabled: @mark_recurring_disabled, - title: @mark_recurring_title, - class: @mark_recurring_button_class - ) %> +
+ <%= render DS::Button.new( + text: t("transactions.show.mark_recurring"), + variant: "outline", + icon: "repeat", + href: @mark_recurring_href, + method: :post, + frame: "_top", + disabled: @mark_recurring_disabled, + title: @mark_recurring_title, + class: @mark_recurring_button_class + ) %> + + <%# The declare dialog is part of the preview-gated bills surface, so the + action only appears for someone who has opted into it. %> + <% if preview_features_enabled? %> + <%= render DS::Button.new( + text: t("transactions.show.create_bill"), + variant: "outline", + icon: "receipt", + href: new_recurring_transaction_path(entry_id: entry.id), + frame: :modal + ) %> + <% end %> +
diff --git a/app/views/transactions/show.html.erb b/app/views/transactions/show.html.erb index fe6459ed2..9ccdcd51a 100644 --- a/app/views/transactions/show.html.erb +++ b/app/views/transactions/show.html.erb @@ -401,6 +401,35 @@ ) %> <% end %> + <%# Which bills this transaction paid. Bills has always linked out to + transactions; without this the trip back was a dead end. Loading + and preview-gating live with BillsHelper#entry_bill_allocations. %> + <% applied_to = entry_bill_allocations(@entry) %> + <% if applied_to.any? %> +
+

<%= t(".applied_to_title") %>

+ <% applied_to.each do |allocation| %> + <% occurrence = allocation.recurring_occurrence %> +
+
+ <%= link_to occurrence.recurring_transaction.display_name, + bill_path(occurrence.recurring_transaction), + class: "text-primary font-medium hover:underline", + data: { turbo_frame: "_top" } %> +

+ <%= t(".applied_to_detail", + amount: format_money(allocation.allocated_amount_money), + date: l(occurrence.due_on, format: :long)) %> +

+
+ <% if allocation.allocation_suggested? %> + <%= t(".applied_to_unreviewed") %> + <% end %> +
+ <% end %> +
+ <% end %> + <%= render "transactions/mark_recurring", entry: @entry %> <% end %> diff --git a/config/locales/views/bills/en.yml b/config/locales/views/bills/en.yml new file mode 100644 index 000000000..787fbf97a --- /dev/null +++ b/config/locales/views/bills/en.yml @@ -0,0 +1,327 @@ +--- +en: + bills: + row_overdue: Overdue + row_today: Today + detect: + found: + one: Found 1 possible bill. Review it below. + other: Found %{count} possible bills. Review them below. + none_found: No new recurring patterns found. You can add bills by hand. + already_running: Detection is already running. Check back in a moment. + feed: + calendar_name: Sure Bills + ai_prompts: + due_before_paycheck: What's due before my next paycheck? + subscriptions_up: Which subscriptions went up this year? + monthly_subscriptions: What am I paying monthly for subscriptions? + safe_to_spend: How much is safe to spend this period? + smart_configurations: + show: + trigger: Let AI configure + title: "AI suggestions for %{name}" + intro: Check the changes to apply. Unchecked ones are left exactly as they are. + no_changes: This bill already matches its payment history. Nothing to change. + failed: Could not analyze this bill right now. Its configuration is untouched. + field_name: Name + field_amount: Amount + field_schedule: Schedule + field_category: Category + field_kind: Kind + field_autopay: Autopay + uncategorized: Uncategorized + autopay_off: "Off" + autopay_on: "On" + apply: Apply selected + detail: + current: Current + next_payment: Next payment + around: "around %{date}" + rules: How this bill is matched + rule_named: "%{name}" + rule_amount_range: "from %{min} to %{max}" + rule_aliases: "Also matches %{names}" + rule_learned_tolerance: "Amounts within %{percent} of the usual amount match this bill" + history_title: Last 12 months + history_aria: Payments over the last twelve months + key_metrics: Per year + year: Year + spent_per_year: Spent + avg_payment: Avg payment + average: Average paid + range: "%{min} to %{max}" + annualized: A year + ytd: Paid this year + last_account: Last account used + upcoming: Coming up + recent_payments: Recent payments + manual_payment: Manual payment + history: History + payment_count: + one: "1 payment" + other: "%{count} payments" + notes: Notes + trial_chip: "Trial ends %{date}" + renews_chip: "Renews %{date}" + cancelled_chip: "Cancelled %{date}" + price_changes: Price changes + price_change_line: "%{from} โ†’ %{to}" + month_pulse: + bill_count: + one: 1 bill + other: "%{count} bills" + left_to_pay: left to pay + pulse_paid: paid + pulse_overdue: overdue + pulse_next_seven: next 7 days + pulse_bar_aria: "%{percent}% of this month's bills paid" + next_up: Next up + view_upcoming: View upcoming + # The strip has a column to itself, so the date stands alone rather than + # reading as a sentence the way the row sublines do. + date_today: Today + date_tomorrow: Tomorrow + unconvertible: + one: "1 bill is not included, no exchange rate available" + other: "%{count} bills are not included, no exchange rate available" + summary: + remaining: "%{amount} remaining" + paid_headline: "%{amount} paid" + cost_line: "Averages %{average} a payment, about %{annualized} a year" + pane: + close: Collapse details + add_bill: Add bill + manage: Manage + find_payment: Find payment + add_payment: Add payment + review_match: Review match + resolve: Resolve + manage_payments: Manage payments + view_full_bill: View full bill + match: + same_merchant: Same merchant + name_matches: Name matches + exact_amount: Exact amount + amount_off: "%{amount} off" + due_date: Due-date match + days_before: + one: 1 day before due + other: "%{count} days before due" + days_after: + one: 1 day after due + other: "%{count} days after due" + attention: + needs_review: Match needs review + partial: "Partial ยท %{amount} remaining" + amount_changed: Amount changed + overdue: + one: 1 day overdue + other: "%{count} days overdue" + paid_label: Paid + paid_over_short: Paid, over + installment_progress: "Payment %{done_plus_one} of %{total}" + partial_progress: "%{paid} of %{expected} paid" + cancelled_still_scheduled: "You marked this cancelled on %{date}, but it is still scheduled, so future bills will keep appearing." + paid_over: "Paid %{paid}, more than expected" + remaining_label: remaining + debt_payment: "ยท debt payment" + paid_from: "from %{account}" + amount_range: "ranges %{min} to %{max}" + possible_duplicate: Possible duplicate + due_label: + overdue: + one: "Overdue by 1 day, was due %{date}" + other: "Overdue by %{count} days, was due %{date}" + today: "Due today" + settled: "Was due %{date}" + due_since: "Due %{date}" + snoozed: "Snoozed until %{date}" + upcoming: + one: "Due tomorrow, %{date}" + other: "Due in %{count} days, %{date}" + views: + aria_label: Bills views + overview: Overview + calendar: Calendar + # Named for income rather than for a paycheck: a declared income series + # can be a pension, an invoice or a benefit, and this is the only place + # in the app where income is added or managed. "Plan" keeps it from + # promising the earnings reporting that Reports already owns. + paycheck: Income plan + all: All bills + pay_period: + paycheck_on: "Paycheck %{date}" + before_next_paycheck: Before your next paycheck + due_before_date: "%{amount} due before %{date}" + paycheck: + unconvertible: + one: "1 obligation is not included, no exchange rate available" + other: "%{count} obligations are not included, no exchange rate available" + income_section_title: Income schedule + manage: Manage + add_income: Add income + income_paused: Paused, not planned + income_detected: Detected automatically, does not set paydays + income_next_payday: "next %{date}" + income_source_count: + one: "%{count} income source" + other: "%{count} income sources" + next_on: "Next %{date}" + no_upcoming_income: No income scheduled from here on. + empty: + title: Declare your income schedule + description: Add your income to see what each payday has to cover. Detected inflows never set your paydays on their own -- you do. + action: Add income + all_clear: + title: Nothing to cover + description: "No bills come due between now and %{date}, so nothing in that stretch is spoken for." + # The three words the page is built on: due now, reserved for later, + # safe after bills. Anything that adds two of them together needs a very + # good reason, because the whole job here is keeping them apart. + safe_after_bills: Safe after bills + short_after_bills: Short after bills + period_source: " ยท %{source}" + period_source_multiple: " ยท %{count} income sources" + before_next_paycheck: "Before %{date}" + no_income_arriving: No income arrives + income_amount: "%{amount} income" + due_this_period: Due this period + reserved_ahead: Reserved ahead + # The bar's key uses the short forms so three columns fit a phone; the + # header carries the full phrase. + safe_short: Safe + short_short: Short + reserved_footer: + one: Reserved ahead for 1 later bill + other: "Reserved ahead for %{count} later bills" + bills_this_period: + one: Bills this period ยท 1 + other: "Bills this period ยท %{count}" + bills_before_payday: + one: Bills before your next payday ยท 1 + other: "Bills before your next payday ยท %{count}" + of_total: "of %{amount}" + # The gap before the next payday, said as news rather than drawn as a + # pay period. Compact on purpose: it has to be noticeable without + # becoming the identity of the page. + bridge_label: Before your next paycheck + bridge_amount: "ยท %{amount} due" + bridge_split: "Due before %{date}, covered by the %{cash} in your accounts." + shortfall_label: Short before your next payday + shortfall_amount: "ยท %{amount}" + shortfall_split: "%{obligations} is due before %{date} and your accounts hold %{cash}." + shortfall_largest: "Largest upcoming obligation:" + review_plan: Review plan + allocation_aria: "Of %{income} income, %{due} is due now, %{reserved} is reserved for later, and %{safe} is safe to spend." + allocation_aria_short: "%{income} of income goes to what is committed here, and %{short} more is needed." + reset_feed_token: + done: Feed link reset. Calendar apps using the old link will stop updating; resubscribe with the new one. + confirm: Reset the calendar feed link? Every previously shared link stops working. + calendar: + subscribe_ical: Subscribe (iCal) + reset_feed: Reset link + previous_month: Previous month + next_month: Next month + today: Today + month_totals: "%{expected} expected ยท %{paid} paid" + empty_month: No bills due this month. + all: + search_placeholder: Search bills + any_status: Any status + status_filters: + overdue: Overdue + due: Due soon + partial: Partly paid + paid: Paid + paused: Paused + ended: Dismissed + subscription_monthly: A month + subscription_annual: A year + subscription_active: Active + subscription_price_changes: Price changes this year + subscription_price_change_line: "%{from} โ†’ %{to} (%{percent}%)" + any_type: Any type + sort_due: By next due + sort_name: By name + sort_amount: By amount + no_matches_title: Nothing matches + no_matches_description: Try clearing a filter or the search. + col_name: Name + col_type: Type + col_frequency: Frequency + col_amount: Amount + col_next: Next due + next_short: "next %{date}" + col_status: Status + col_actions: Actions + monthly_equivalent: "%{amount}/mo" + types: + bill: Bill + subscription: Subscription + installment: Installment plan + income: Income + transfer: Transfer + other: Other + show: + edit: Edit bill + index: + left_to_pay: left to pay + paid_so_far: Paid so far + hero_past_due: "%{amount} past due" + hero_next_seven: "%{amount} due in the next 7 days" + add_bill: Add bill + add_income: Add income + review_with_ai: Review with AI + detected_review: + one: We found 1 recurring payment in your transactions. Check it over so the totals here match what you actually pay. + other: We found %{count} recurring payments in your transactions. Check them over so the totals here match what you actually pay. + detected_review_action: Review them + needs_review: Needs review + notice_trial: "%{name}'s trial ends %{date}" + notice_renewal: "%{name} renews %{date}" + notice_price: "%{name} changed price: %{from} โ†’ %{to} (%{percent})" + notices_routine: + one: "1 smaller change" + other: "%{count} smaller changes" + suggestion_line: "%{entry} looks like a payment of %{bill}" + suggestion_unknown_entry: A transaction + confidence: "%{percent}% match" + accept: Apply + reject: Not this bill + title: Bills + summary_label: Due this month + summary_count: + one: "1 bill due" + other: "%{count} bills due" + summary_needs_action: + one: "1 needs you" + other: "%{count} need you" + all_automatic: "all on autopay" + recurring_label: Recurring commitment + recurring_monthly: "%{amount} a month" + recurring_annual: "%{amount} a year" + unconvertible: + one: "1 bill is not included, no exchange rate available" + other: "%{count} bills are not included, no exchange rate available" + overdue: Overdue + needs_attention: Needs attention + attention_overdue_total: "%{amount} overdue" + this_month: This month + later: After this month + paid_this_month: Paid this month + dormant: Dormant + kpi_remaining: Remaining this month + kpi_paid: Paid this month + kpi_next_seven: Due next 7 days + kpi_past_due: Past due + paid_count: + one: "1 bill settled" + other: "%{count} bills settled" + needs_action_count: + one: "1 needs you" + other: "%{count} need you" + empty: + title: No bills yet + description: Bills are your recurring expenses. Sure finds them automatically from your transaction history, and you can add a payment link to each one so you can pay straight from here. + action: Find recurring transactions + no_history_description: No transactions yet. Connect an account or import history and Sure will find your bills automatically, or add one by hand. diff --git a/config/locales/views/budgets/en.yml b/config/locales/views/budgets/en.yml index aaeb331b4..3fddcce35 100644 --- a/config/locales/views/budgets/en.yml +++ b/config/locales/views/budgets/en.yml @@ -74,6 +74,10 @@ en: no_source: "No previous budget found to copy from" already_initialized: "This budget has already been set up" budget_categories: + bills_reserved: "%{amount} reserved by bills" + bills_reserved_unconvertible: + one: "1 bill is not reserved, no exchange rate available" + other: "%{count} bills are not reserved, no exchange rate available" allocation_progress: budget_exceeded_html: 'Budget exceeded by %{amount}' left_to_allocate: left to allocate diff --git a/config/locales/views/layout/en.yml b/config/locales/views/layout/en.yml index 4b3e23a3b..8a7708f42 100644 --- a/config/locales/views/layout/en.yml +++ b/config/locales/views/layout/en.yml @@ -9,6 +9,7 @@ en: skip_to_main: Skip to main content nav: assistant: Assistant + bills: Bills budgets: Budgets home: Home plan: Plan diff --git a/config/locales/views/recurring_allocations/en.yml b/config/locales/views/recurring_allocations/en.yml new file mode 100644 index 000000000..409454936 --- /dev/null +++ b/config/locales/views/recurring_allocations/en.yml @@ -0,0 +1,15 @@ +--- +en: + recurring_allocations: + over_allocation: That would allocate more than the transaction's amount + missing_rate: No exchange rate available; enter the amount explicitly + already_allocated: That transaction is already applied to this bill + invalid: The payment could not be recorded + create: + success: Payment applied + destroy: + success: Payment unlinked + confirm: + success: Payment applied to the bill + reject: + success: "Dismissed. That transaction won't be suggested for this bill again." diff --git a/config/locales/views/recurring_occurrences/en.yml b/config/locales/views/recurring_occurrences/en.yml new file mode 100644 index 000000000..f9370189a --- /dev/null +++ b/config/locales/views/recurring_occurrences/en.yml @@ -0,0 +1,53 @@ +--- +en: + recurring_occurrences: + history_status: + paid: Paid + skipped: Skipped + missed: Missed + scheduled: Open + show: + due_on: "Due %{date}" + paid_of: "%{paid} of %{expected} paid" + paid_headline: "%{amount} paid" + remaining: "%{amount} remaining" + settled: Paid in full + overpaid: more than expected + skipped: Skipped + missed: Marked missed + payments: Payments + manual_payment: Manual payment + unlink: Unlink + review_heading: Review payment + find_heading: Find a payment + add_heading: Add another payment + other_matches: Other likely matches + no_ranked_candidates: Nothing here looks like a payment for this bill yet. + search_all: Search all transactions + search_placeholder: Search transactions + no_candidates: No nearby transactions to link. + no_search_results: "Nothing matching %{query}." + link_payment: Link payment + not_this_one: Not this one + cant_find: Can't find the transaction? + manual_explainer: Record a payment you know happened when no transaction here matches it. + manual_amount_label: Amount + manual_date_label: Date + record_payment: Record payment + mark_paid: Mark paid + mark_paid_hint: Settles the rest without recording a transaction. + skip: Skip + snooze_week: Snooze a week + reopen: Reopen + view_bill: View full bill + mark_paid: + success: Bill marked paid + skip: + success: Bill skipped + reopen: + success: Bill reopened + snooze: + success: "Snoozed until %{date}" + invalid_date: That date could not be read + override_amount: + success: Expected amount updated diff --git a/config/locales/views/recurring_transactions/en.yml b/config/locales/views/recurring_transactions/en.yml index 88f43ce28..7e454ba6b 100644 --- a/config/locales/views/recurring_transactions/en.yml +++ b/config/locales/views/recurring_transactions/en.yml @@ -1,6 +1,15 @@ --- en: recurring_transactions: + title: Recurring Transactions + upcoming: Upcoming Recurring Transactions + projected: Projected + recurring: Recurring + expected_today: "Expected today" + expected_in: + one: "Expected in %{count} day" + other: "Expected in %{count} days" + day_of_month: Day %{day} of month frequency_presets: monthly: Monthly weekly: Weekly @@ -20,17 +29,115 @@ en: annual: "Yearly on %{month} %{day}" custom: Custom schedule last_day: last day - title: Recurring Transactions - upcoming: Upcoming Recurring Transactions - projected: Projected - recurring: Recurring - expected_today: "Expected today" - expected_in: - one: "Expected in %{count} day" - other: "Expected in %{count} days" - day_of_month: Day %{day} of month identify_patterns: Identify Patterns cleanup_stale: Clean Up Stale + actions: + edit_payment_link: Edit payment link + pause: Pause + resume: Resume + delete: Remove + pay_action: + pay: Pay + pay_aria: "Pay %{name} (opens in new tab)" + add_link: Add link + add_link_aria: "Add a payment link for %{name}" + autopay: Autopay + autopay_aria: "%{name} pays automatically, open its portal in a new tab" + open_site: Open bill site + new: + title: Add a bill + income_title: Add income + start_from_title: Start from a recurring charge we spotted + start_from_income_title: Start from a recurring deposit we spotted + start_from_hint: Optional. Picking one just fills in the form; you can still change anything. + candidate_meta: "%{count}ร— ยท last %{date}" + search_all_prompt: Not seeing what you're looking for? + search_all_cta: Search all your transactions + pick_different: Start from a different transaction + smart_fill: Smart-fill with AI + smart_fill_applied: AI suggested these values from the charge history. Review before saving. + smart_fills: + create: + failed: Could not analyze the charge history right now. The form kept the transaction's own values. + pick_entry: + title: Find a transaction to start from + income_title: Find a deposit to start from + hint: Picking one just fills in the form; you can still change anything. + search_placeholder: Search by name, notes or merchant + back: Back to the form + showing_recent: Showing your %{count} most recent. Search to narrow it down. + no_results: Nothing matches โ€œ%{query}โ€. Try fewer words, or go back and fill in the form yourself. + picker_row: + claimed: Part of %{name} + create: + success: Bill added + success_income: Income added + due_date_required: "Enter the bill's next due date" + amount_invalid: "Enter the amount as a plain number" + account_invalid: "Pick an account you can add bills to, or leave it blank" + already_exists: "This bill already exists" + edit: + trigger_label: Edit bill + title: "Edit %{name}" + income_title: "Edit income ยท %{name}" + form: + payment_url_label: Payment link + payment_url_placeholder: bank.example.com/pay + payment_url_hint: Where you go to pay this bill. Opens in a new tab from your recurring and upcoming lists. + autopay_label: Pays automatically + autopay_hint: Keep it on your bills list, but stop treating it as something to do. + more_options: More options + notes_label: Notes + notes_placeholder: Account 4821, charged to the Amex + notes_hint: Anything you want in front of you when paying. Shown on the bill. + apply_to_siblings: + one: "Also use this link for the other %{name} bill" + other: "Also use this link for the other %{count} %{name} bills" + submit: Save bill + submit_income: Save income + income_name_label: Source + income_name_placeholder: Employer, client, side gig + income_amount_label: Amount per paycheck + income_due_on_label: Next payday + income_account_label: Deposits into + income_frequency_hint: How often you're paid. Pick the day details to match your payday. + income_frequency_label: How often you're paid + income_frequency_new_hint: The payday details come from the date above. You can refine the schedule after saving. + income_notes_placeholder: Direct deposit, splits across two accounts + income_notes_hint: Anything worth remembering about this income. + name_label: Name + name_placeholder: Rent, PG&E, Netflix + amount_label: Amount + first_due_on_label: Next due + account_label: Paid from + account_blank: Any account + bill_type_label: Kind + installment_count_label: Number of payments + installment_count_hint: The plan ends after this many payments and shows its progress until then. + renews_on_label: Renews + trial_ends_on_label: Trial ends + amount_edit_hint: Applies to bills due from now on. Anything already due keeps the amount it was. + income_amount_edit_hint: Applies to paydays from now on. Anything already due keeps the amount it was. + cancelled_on_label: Cancelled + cancelled_on_hint: Recording the date is for your own reference. Pause the bill to stop future ones appearing. + category_label: Category + category_blank: No category + frequency_new_hint: The day details come from the due date above. You can refine the schedule after saving. + frequency_label: How often + frequency_hint: How often this bill comes due. Pick the day details to match your statement. + frequency_day_label: On day + frequency_second_day_label: And day + frequency_weekday_label: "On" + frequency_month_label: "In" + update: + success: Bill updated + success_with_siblings: + one: "Bill updated, and the payment link was copied to 1 other bill" + other: "Bill updated, and the payment link was copied to %{count} other bills" + manage: + title: Your bills live on the Bills page + description: Declare, edit, pause and pay everything from there. This page controls detection. + cta: Open all bills settings: enable_label: Enable Recurring Transactions enable_description: Automatically detect recurring transaction patterns and show upcoming projected transactions. @@ -43,11 +150,14 @@ en: - CSV imports complete (transactions, trades, accounts, etc. ) - Any provider sync completes ( Plaid, SimpleFIN, etc. ) identified: Identified %{count} recurring transaction patterns + identify_already_running: Detection is already running. Check back in a moment. cleaned_up: Cleaned up %{count} stale recurring transactions - marked_inactive: Recurring transaction marked as inactive - marked_active: Recurring transaction marked as active - deleted: Recurring transaction deleted - confirm_delete: Are you sure you want to delete this recurring transaction? + marked_inactive: Paused. It won't appear again until you resume it. + marked_active: Resumed. + deleted: Bill removed. Your transactions were not touched. + deleted_income: Income removed. Your transactions were not touched. + confirm_delete: "Remove %{name} from your bills? Your transactions stay exactly as they are. This only removes the bill and its payment schedule." + confirm_delete_income: "Remove %{name} as income? Your transactions stay exactly as they are. This only removes the income and its schedule." marked_as_recurring: Transaction marked as recurring already_exists: A manual recurring transaction already exists for this pattern creation_failed: Failed to create recurring transaction. Please check the transaction details and try again. @@ -59,14 +169,27 @@ en: table: merchant: Name amount: Amount - expected_day: Expected Day + frequency: Frequency next_date: Next Date last_occurrence: Last Occurrence status: Status actions: Actions status: active: Active - inactive: Inactive + inactive: Paused + suggested: Possible bill + paused: Paused + ended: Dismissed + suggested: + title: Possible new bills + seen_count: + one: "seen once" + other: "seen %{count} times" + collapsed_hint: Tap to review + confirm: Add bill + dismiss: Not a bill + confirmed: Bill added to your recurring transactions + dismissed: "Dismissed. This pattern won't be suggested again." badges: manual: Manual transfer_marked_as_recurring: Transfer marked as recurring diff --git a/config/locales/views/transactions/en.yml b/config/locales/views/transactions/en.yml index e46bf495d..36ae7ff50 100644 --- a/config/locales/views/transactions/en.yml +++ b/config/locales/views/transactions/en.yml @@ -62,8 +62,12 @@ en: keep_both: No, keep both loan_payment: Loan Payment mark_recurring: Mark as Recurring + create_bill: Create a bill mark_recurring_subtitle: Track this as a recurring transaction. Amount variance is automatically calculated from past 6 months of similar transactions. mark_recurring_title: Recurring Transaction + applied_to_title: Bills this paid + applied_to_detail: "%{amount} toward the bill due %{date}" + applied_to_unreviewed: Needs review merge_duplicate: Yes, merge them potential_duplicate_description: This pending transaction may be the same as the posted transaction below. If so, merge them to avoid double-counting. potential_duplicate_title: Possible duplicate detected diff --git a/config/routes.rb b/config/routes.rb index 3a9bc205a..dac230640 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -526,15 +526,55 @@ Rails.application.routes.draw do end end - resources :recurring_transactions, only: %i[index destroy] do + resources :bills, only: %i[index show] do + # POST for the same reason recurring_transactions#identify is: detection + # mutates (creates suggested series and occurrences), so it stays behind + # CSRF protection rather than a plain URL. collection do - match :identify, via: [ :get, :post ] - match :cleanup, via: [ :get, :post ] + post :detect + post :ai_review, to: "bills/ai_reviews#create" + post :reset_feed_token + end + member do + get :smart_configuration, to: "bills/smart_configurations#show" + end + end + get "bills_feed/:token", to: "bills_feeds#show", as: :bills_feed, defaults: { format: :ics } + + resources :recurring_occurrences, only: %i[show] do + member do + post :mark_paid + post :skip + post :reopen + patch :snooze + patch :override_amount + end + + resources :allocations, controller: :recurring_allocations, only: %i[create] + end + + resources :recurring_allocations, only: %i[destroy] do + member do + post :confirm + post :reject + end + end + + resources :recurring_transactions, only: %i[index new create edit update destroy] do + collection do + # POST only: all three mutate. They accepted GET while DS::Link's method + # option was inert, which left destructive work sitting behind a plain + # URL and outside CSRF protection. Every call site passes method: :post. + post :identify + post :cleanup + post :smart_fill, to: "recurring_transactions/smart_fills#create" patch :update_settings end member do - match :toggle_status, via: [ :get, :post ] + post :toggle_status + post :confirm + post :dismiss end end diff --git a/design/tokens/sure.tokens.json b/design/tokens/sure.tokens.json index f0c6366ee..ce43d45b1 100644 --- a/design/tokens/sure.tokens.json +++ b/design/tokens/sure.tokens.json @@ -342,6 +342,8 @@ "border-destructive": { "$type": "utility", "$value": "{color.red.500}", "$extensions": { "sure.utility": { "prefix": "border" }, "sure.dark": "{color.red.400}" } }, "border-inverse": { "$type": "utility", "$value": "{color.alpha-white.200}", "$extensions": { "sure.utility": { "prefix": "border" }, "sure.dark": "{color.alpha-black.300}" } }, + "divide-subdued": { "$type": "utility", "$value": "{color.alpha-black.50}", "$extensions": { "sure.utility": { "prefix": "divide" }, "sure.dark": "{color.alpha-white.200}" } }, + "button-bg-primary": { "$type": "utility", "$value": "{color.gray.900}", "$extensions": { "sure.utility": { "prefix": "bg" }, "sure.dark": "{color.white}" } }, "button-bg-primary-hover": { "$type": "utility", "$value": "{color.gray.800}", "$extensions": { "sure.utility": { "prefix": "bg" }, "sure.dark": "{color.gray.50}" } }, "button-bg-secondary": { "$type": "utility", "$value": "{color.gray.50}", "$extensions": { "sure.utility": { "prefix": "bg" }, "sure.dark": "{color.gray.700}" } }, @@ -353,6 +355,8 @@ "button-bg-destructive-hover": { "$type": "utility", "$value": "{color.red.700}", "$extensions": { "sure.utility": { "prefix": "bg" }, "sure.dark": "{color.red.500}" } }, "button-bg-ghost-hover": { "$type": "utility", "$value": "{color.gray.50}", "$extensions": { "sure.utility": { "prefix": "bg" }, "sure.dark": "bg-gray-800 text-inverse" } }, "button-bg-outline-hover": { "$type": "utility", "$value": "{color.gray.100}", "$extensions": { "sure.utility": { "prefix": "bg" }, "sure.dark": "{color.gray.700}" } }, + "button-bg-accent": { "$type": "utility", "$value": "{color.blue.tint-5}", "$extensions": { "sure.utility": { "prefix": "bg" }, "sure.dark": "{color.blue.tint-5}" } }, + "button-bg-accent-hover": { "$type": "utility", "$value": "{color.blue.tint-10}", "$extensions": { "sure.utility": { "prefix": "bg" }, "sure.dark": "{color.blue.tint-10}" } }, "tab-item-active": { "$type": "utility", "$value": "{color.white}", "$extensions": { "sure.utility": { "prefix": "bg" }, "sure.dark": "{color.gray.700}" } }, "tab-item-hover": { "$type": "utility", "$value": "{color.gray.200}", "$extensions": { "sure.utility": { "prefix": "bg" }, "sure.dark": "{color.gray.800}" } }, diff --git a/docs/hosting/recurring-bills.md b/docs/hosting/recurring-bills.md new file mode 100644 index 000000000..7c846b22b --- /dev/null +++ b/docs/hosting/recurring-bills.md @@ -0,0 +1,68 @@ +# Bills and Recurring Transactions + +This document explains how Sure detects recurring bills, when the detection +pipeline runs, and the maintenance tasks available to self-hosters. + +## How detection works + +Sure clusters your transaction history into recurring patterns (same +merchant or name, consistent amount within tolerance, consistent day). A +pattern needs at least three consistent occurrences to become a series. +New detections land with status `suggested` and wait in a review strip on +the Bills page (and under Settings -> Recurring transactions) until you +confirm or dismiss them. Dismissing leaves a tombstone, so a dismissed +pattern is never suggested again. + +## When the pipeline runs + +The full pipeline (detect patterns, materialize upcoming occurrences, +repair provider-replaced entries, match payments, detect price changes) +runs automatically: + +- after every completed bank sync or import (debounced by 30 seconds) +- nightly at 05:30 UTC (occurrence materialization only, for families + that never sync) + +And on demand: + +- the **Find recurring transactions** button on an empty Bills page +- the **Identify Patterns** button under Settings -> Recurring transactions + +All triggers share one per-family lock, so concurrent runs never stack. + +## First run on existing data + +The user-triggered detection actions (the **Find recurring transactions** +button on an empty Bills page and the **Identify Patterns** button under +Settings -> Recurring transactions) backfill the last six months of +history: past occurrences are generated and closed as paid where a real +transaction anchors them. Past occurrences no transaction covers are +deleted rather than shown as missed, so the backfill reconstructs what +happened without fabricating debt. The backfill is idempotent, so +re-running detection never duplicates history. Background syncs never +backfill; on an instance upgraded from a build without the Bills +subsystem, run either detection action once to reconstruct history. + +Confirming an individual suggestion likewise backfills that bill's own +history, so a just-confirmed bill shows its lived past instead of +starting blank. + +## Maintenance tasks + +Both tasks are safe to re-run; they only close history a real entry +anchors and never touch existing payment records. + +```bash +# Rebuild N months of occurrence history for every family (default 6) +bin/rails "recurring:backfill_history[12]" + +# One-shot classification of auto-detected series still on defaults +# (assigns bill/subscription/installment kind and a category) +bin/rails recurring:classify_existing +``` + +## Disabling the feature + +Settings -> Recurring transactions has a per-family toggle. Disabling +hides the Bills page and stops all detection and materialization for +that family. diff --git a/test/controllers/bills/ai_reviews_controller_test.rb b/test/controllers/bills/ai_reviews_controller_test.rb new file mode 100644 index 000000000..f2ddc757f --- /dev/null +++ b/test/controllers/bills/ai_reviews_controller_test.rb @@ -0,0 +1,63 @@ +require "test_helper" + +class Bills::AiReviewsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in @user = users(:family_admin) + @user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => true)) + end + + test "seeds a chat with the server-owned review prompt" do + stub_provider + + assert_difference "@user.chats.count", 1 do + post ai_review_bills_url + end + + chat = @user.chats.order(created_at: :desc).first + assert_redirected_to chat_path(chat, thinking: true) + content = chat.messages.find_by!(type: "UserMessage").content + assert_includes content, "Review my bills and subscriptions" + assert_includes content, "ask me before changing anything" + # The prompt appears in the chat as the user's own words, so it must not + # leak internal tool names; the tool descriptions route the model. + assert_no_match(/get_bill/, content) + assert_equal chat, @user.reload.last_viewed_chat + end + + test "forbidden when AI is disabled" do + stub_provider + @user.update!(ai_enabled: false) + + post ai_review_bills_url + + assert_response :forbidden + end + + test "forbidden without an LLM provider" do + Provider::Registry.stubs(:preferred_llm_provider).returns(nil) + + post ai_review_bills_url + + assert_response :forbidden + end + + test "redirects when the family has recurring transactions off" do + stub_provider + @user.family.update!(recurring_transactions_disabled: true) + + post ai_review_bills_url + + assert_redirected_to root_path + end + + test "refuses GET" do + route = Rails.application.routes.recognize_path("/bills/ai_review", method: :get) + assert_equal "show", route[:action], "GET must never reach the review action" + end + + private + + def stub_provider + Provider::Registry.stubs(:preferred_llm_provider).returns(Object.new) + end +end diff --git a/test/controllers/bills/smart_configurations_controller_test.rb b/test/controllers/bills/smart_configurations_controller_test.rb new file mode 100644 index 000000000..90a072db4 --- /dev/null +++ b/test/controllers/bills/smart_configurations_controller_test.rb @@ -0,0 +1,96 @@ +require "test_helper" + +class Bills::SmartConfigurationsControllerTest < ActionDispatch::IntegrationTest + RawSuggestion = Provider::LlmConcept::BillSetupSuggestion + + 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 + @series = @family.recurring_transactions.create!( + name: "Gym", account: accounts(:depository), amount: 40, currency: "USD", + expected_day_of_month: 9, anchor_date: Date.current, + last_occurrence_date: Date.current.beginning_of_month + 8.days - 1.month, + next_expected_date: Date.current.beginning_of_month + 8.days, + status: "active", manual: true + ) + @family.recurring_transactions.where.not(id: @series.id) # no-op, clarity + accounts(:depository).entries.create!( + date: Date.current.beginning_of_month + 8.days - 1.month, amount: 40, + currency: "USD", name: "Gym", entryable: Transaction.new + ) + end + + test "renders proposals as value-carrying checkboxes" do + stub_provider(raw(amount: 45.0, frequency: "monthly", day_of_month: 9, + rationale: "Recent charges are 45")) + + get smart_configuration_bill_url(@series), headers: { "Turbo-Frame" => "modal" } + + assert_response :success + # The checkbox IS the field: unchecked rows submit nothing. + assert_select "input[type=checkbox][name=?][value=?]", "recurring_transaction[amount]", "45.0" + assert_select "input[type=checkbox][name=?][value=?]", "recurring_transaction[frequency_preset]", "monthly" + assert_select "form[action=?]", recurring_transaction_path(@series) + assert_match "Recent charges are 45", response.body + end + + test "an all-null suggestion means the bill is already right" do + stub_provider(raw(confidence: 0.9)) + + get smart_configuration_bill_url(@series), headers: { "Turbo-Frame" => "modal" } + + assert_response :success + assert_match I18n.t("bills.smart_configurations.show.no_changes"), response.body + assert_select "input[type=checkbox]", count: 0 + end + + test "checked proposals apply through the ordinary update path" do + # Simulates submitting the dialog with only the amount box checked. + patch recurring_transaction_url(@series), params: { + recurring_transaction: { amount: "45.0" } + } + + assert_equal 45.0, @series.reload.amount.to_f + end + + test "forbidden without an LLM provider" do + Provider::Registry.stubs(:preferred_llm_provider).returns(nil) + + get smart_configuration_bill_url(@series) + + assert_response :forbidden + end + + test "another family's bill is not found" do + stub_provider(raw) + other = families(:empty).recurring_transactions.create!( + name: "Foreign", 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" + ) + + get smart_configuration_bill_url(other) + + assert_response :not_found + end + + private + + def raw(**overrides) + RawSuggestion.new(**{ + name: nil, amount: nil, frequency: nil, day_of_month: nil, weekday: nil, + month_of_year: nil, category_name: nil, bill_type: nil, autopay: nil, + confidence: nil, rationale: nil + }.merge(overrides)) + end + + def stub_provider(suggestion) + provider = Object.new + provider.define_singleton_method(:suggest_bill_setup) do |**| + Provider::Response.new(success?: true, data: suggestion, error: nil) + end + Provider::Registry.stubs(:preferred_llm_provider).returns(provider) + end +end diff --git a/test/controllers/bills_controller_test.rb b/test/controllers/bills_controller_test.rb new file mode 100644 index 000000000..3f3bfd0ae --- /dev/null +++ b/test/controllers/bills_controller_test.rb @@ -0,0 +1,1579 @@ +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*=, 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*=, 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(/]*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(/]*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(/]*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(/]*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(/]*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(/ 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 diff --git a/test/controllers/bills_feeds_controller_test.rb b/test/controllers/bills_feeds_controller_test.rb new file mode 100644 index 000000000..fcdf30e99 --- /dev/null +++ b/test/controllers/bills_feeds_controller_test.rb @@ -0,0 +1,128 @@ +require "test_helper" + +class BillsFeedsControllerTest < ActionDispatch::IntegrationTest + setup do + @family = families(:dylan_family) + @user = users(:family_admin) + # The feed is preview-gated on the member the token names. + @user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => true)) + @family.recurring_transactions.destroy_all + create_bill(name: "Rent", amount: 2150) + end + + test "a member token serves the feed without a session" do + 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 + end + + test "an unknown token is not found" do + get bills_feed_url(token: "nonsense") + + assert_response :not_found + end + + # The old URLs carried a deterministic signed family id with no expiry and + # no revocation. Breaking them is the point of the change: every URL minted + # under the old scheme stops working. + test "an old-style signed token no longer works" do + signed = Rails.application.message_verifier("bills-ical-feed").generate(@family.id) + + get bills_feed_url(token: signed) + + assert_response :not_found + end + + # The stored family secret is the revocation root, not a credential: putting + # it in a URL would hand every member the whole family's obligations. + test "the raw family secret is not itself a feed token" do + get bills_feed_url(token: @family.bills_feed_token!) + + assert_response :not_found + end + + # Sharing is per account, so the feed has to honor it: a member who cannot + # reach an account in the app must not receive its bills by calendar. + test "a member's feed carries only the bills that member can reach" do + member = users(:family_member) + member.update!(preferences: (member.preferences || {}).merge("preview_features_enabled" => true)) + # The investment account is the admin's and was never shared. + create_bill(name: "Private Brokerage Fee", amount: 95, account: accounts(:investment)) + create_bill(name: "Gym", amount: 30, account: nil) + + get bills_feed_url(token: @family.bills_feed_token_for(member)) + + assert_response :success + assert_match "Gym", response.body + assert_no_match(/Private Brokerage Fee/, response.body) + + get bills_feed_url(token: @family.bills_feed_token_for(@user)) + + assert_match "Gym", response.body + assert_match "Private Brokerage Fee", response.body + end + + test "resetting the token revokes every member URL and freshly minted ones work" do + old_token = @family.bills_feed_token_for(@user) + @family.reset_bills_feed_token! + + get bills_feed_url(token: old_token) + assert_response :not_found + + get bills_feed_url(token: @family.reload.bills_feed_token_for(@user)) + assert_response :success + assert_match "BEGIN:VCALENDAR", response.body + end + + # The feed is sessionless, so the preview gate has to travel with the token: + # a retained calendar URL must die the moment its member opts out, not only + # after an explicit token reset. + test "a retained URL stops working when the member opts out of preview" do + token = @family.bills_feed_token_for(@user) + + @user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => false)) + get bills_feed_url(token: token) + assert_response :not_found + + @user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => true)) + get bills_feed_url(token: token) + assert_response :success + end + + test "the feed honors the family recurring switch" do + token = @family.bills_feed_token_for(@user) + @family.update!(recurring_transactions_disabled: true) + + get bills_feed_url(token: token) + + assert_response :not_found + end + + test "the family secret generates lazily exactly once" do + assert_nil @family.bills_feed_token + + first = @family.bills_feed_token! + second = @family.bills_feed_token! + + assert first.present? + assert_equal first, second + end + + private + + def create_bill(name:, amount:, account: accounts(:depository)) + @family.recurring_transactions.create!( + account: account, + name: name, + amount: amount, + 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" + ) + end +end diff --git a/test/controllers/recurring_allocations_controller_test.rb b/test/controllers/recurring_allocations_controller_test.rb new file mode 100644 index 000000000..bdf723157 --- /dev/null +++ b/test/controllers/recurring_allocations_controller_test.rb @@ -0,0 +1,91 @@ +require "test_helper" + +class RecurringAllocationsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in @user = users(:family_admin) + @user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => true)) + @family = @user.family + # credit_card is shared read-only with family_member in the fixtures, so + # this series is visible to them but must never be mutable by them. + @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 + ) + @occurrence = @series.recurring_occurrences.order(:due_on).first + end + + test "allocation writes redirect when the family has turned recurring transactions off" do + @family.update!(recurring_transactions_disabled: true) + + post recurring_occurrence_allocations_url(@occurrence), params: { amount: "5.00" } + + assert_redirected_to root_path + assert_equal 0, @occurrence.reload.allocations.count + end + + test "a read-only account share cannot record a payment" do + member = users(:family_member) + member.update!(preferences: (member.preferences || {}).merge("preview_features_enabled" => true)) + sign_in member + + post recurring_occurrence_allocations_url(@occurrence), params: { amount: "5.00" } + + assert_response :not_found + assert_equal 0, @occurrence.reload.allocations.count + end + + test "a read-only account share cannot unlink, confirm or reject an allocation" do + entry = accounts(:credit_card).entries.create!( + date: Date.current, amount: 95, currency: "USD", name: "ANNUAL FEE", + entryable: Transaction.new + ) + suggestion = RecurringTransaction::Allocator.new(@occurrence).allocate_matched!( + entry: entry, state: "suggested", confidence: 0.7, signals: { name: 0.35 } + ) + + member = users(:family_member) + member.update!(preferences: (member.preferences || {}).merge("preview_features_enabled" => true)) + sign_in member + + post confirm_recurring_allocation_url(suggestion) + assert_response :not_found + assert suggestion.reload.allocation_suggested? + + post reject_recurring_allocation_url(suggestion) + assert_response :not_found + assert RecurringAllocation.exists?(suggestion.id) + + delete recurring_allocation_url(suggestion) + assert_response :not_found + assert RecurringAllocation.exists?(suggestion.id) + end + + test "the account owner records the same payment" do + post recurring_occurrence_allocations_url(@occurrence), params: { amount: "5.00" } + + assert_redirected_to bills_url + assert_equal 5, @occurrence.reload.allocations.sole.allocated_amount + end + + test "a valid paid_on records the payment on that date" do + paid = 3.days.ago.to_date + + post recurring_occurrence_allocations_url(@occurrence), + params: { amount: "5.00", paid_on: paid.iso8601 } + + assert_redirected_to bills_url + assert_equal paid, @occurrence.reload.allocations.sole.paid_on + end + + test "a malformed paid_on is rejected instead of being recorded as today" do + post recurring_occurrence_allocations_url(@occurrence), + params: { amount: "5.00", paid_on: "not-a-date" } + + assert_redirected_to bills_url + assert flash[:alert].present? + assert_equal 0, @occurrence.reload.allocations.count, + "a nil-cast date would have silently defaulted the payment to today" + end +end diff --git a/test/controllers/recurring_occurrences_controller_test.rb b/test/controllers/recurring_occurrences_controller_test.rb new file mode 100644 index 000000000..75f8795b2 --- /dev/null +++ b/test/controllers/recurring_occurrences_controller_test.rb @@ -0,0 +1,389 @@ +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(/]*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 diff --git a/test/controllers/recurring_transactions/smart_fills_controller_test.rb b/test/controllers/recurring_transactions/smart_fills_controller_test.rb new file mode 100644 index 000000000..8735374e8 --- /dev/null +++ b/test/controllers/recurring_transactions/smart_fills_controller_test.rb @@ -0,0 +1,98 @@ +require "test_helper" + +class RecurringTransactions::SmartFillsControllerTest < ActionDispatch::IntegrationTest + RawSuggestion = Provider::LlmConcept::BillSetupSuggestion + + setup do + sign_in @user = users(:family_admin) + @user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => true)) + @family = @user.family + @entry = accounts(:depository).entries.create!( + date: Date.current, amount: 40, currency: "USD", name: "GYM MEMBERSHIP", + entryable: Transaction.new + ) + end + + test "applies suggested values to the form and says so" do + stub_provider(raw(name: "Gym Membership", amount: 42.0, frequency: "weekly", confidence: 0.9, + rationale: "Weekly gaps between charges")) + + post smart_fill_recurring_transactions_url(entry_id: @entry.id), + headers: { "Turbo-Frame" => "modal" } + + assert_response :success + assert_match I18n.t("recurring_transactions.new.smart_fill_applied"), response.body + assert_match "Weekly gaps between charges", response.body + assert_select "input[name=?][value=?]", "recurring_transaction[name]", "Gym Membership" + assert_select "input[name=?][value=?]", "recurring_transaction[amount]", "42.0" + end + + test "a provider failure keeps the plain prefill and explains" do + provider = Object.new + provider.define_singleton_method(:suggest_bill_setup) do |**| + Provider::Response.new(success?: false, data: nil, error: StandardError.new("provider down")) + end + Provider::Registry.stubs(:preferred_llm_provider).returns(provider) + + post smart_fill_recurring_transactions_url(entry_id: @entry.id), + headers: { "Turbo-Frame" => "modal" } + + assert_response :success + assert_match "Could not analyze the charge history", response.body + assert_select "input[name=?][value=?]", "recurring_transaction[name]", "GYM MEMBERSHIP", + { count: 1 }, "the entry's own prefill must survive a failed suggestion" + end + + test "forbidden without an LLM provider" do + Provider::Registry.stubs(:preferred_llm_provider).returns(nil) + + post smart_fill_recurring_transactions_url(entry_id: @entry.id) + + assert_response :forbidden + end + + test "forbidden without AI consent" do + stub_provider(raw) + @user.update!(ai_enabled: false) + + post smart_fill_recurring_transactions_url(entry_id: @entry.id) + + assert_response :forbidden + end + + test "an inaccessible entry never becomes evidence" do + stub_provider(raw(name: "Should not appear")) + hidden = accounts(:investment).entries.create!( + date: Date.current, amount: 30, currency: "USD", name: "PRIVATE FEE", + entryable: Transaction.new + ) + member = users(:family_member) + member.update!(preferences: (member.preferences || {}).merge("preview_features_enabled" => true)) + sign_in member + + post smart_fill_recurring_transactions_url(entry_id: hidden.id), + headers: { "Turbo-Frame" => "modal" } + + assert_response :success + assert_match "Could not analyze the charge history", response.body + assert_no_match "Should not appear", response.body + end + + private + + def raw(**overrides) + RawSuggestion.new(**{ + name: nil, amount: nil, frequency: nil, day_of_month: nil, weekday: nil, + month_of_year: nil, category_name: nil, bill_type: nil, autopay: nil, + confidence: nil, rationale: nil + }.merge(overrides)) + end + + def stub_provider(suggestion) + provider = Object.new + provider.define_singleton_method(:suggest_bill_setup) do |**| + Provider::Response.new(success?: true, data: suggestion, error: nil) + end + Provider::Registry.stubs(:preferred_llm_provider).returns(provider) + end +end diff --git a/test/controllers/recurring_transactions_controller_test.rb b/test/controllers/recurring_transactions_controller_test.rb new file mode 100644 index 000000000..048325da6 --- /dev/null +++ b/test/controllers/recurring_transactions_controller_test.rb @@ -0,0 +1,1136 @@ +require "test_helper" + +class RecurringTransactionsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in @user = users(:family_admin) + # The declare/edit/suggestion paths sit behind the Bills preview gate. + @user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => true)) + @family = @user.family + @recurring_transaction = recurring_transactions(:netflix_subscription) + ensure_tailwind_build + end + + # How often is one of the four things anyone needs to add a bill, and it used + # to render below the payment link and the autopay toggle: the fourth + # essential field sat under two most people never set. + test "the add form leads with the essentials and tucks the rest away" do + get new_recurring_transaction_url, headers: { "Turbo-Frame" => "modal" } + + assert_response :success + body = response.body + + name_at = body.index("recurring_transaction[name]") + amount_at = body.index("recurring_transaction[amount]") + due_at = body.index("recurring_transaction[first_due_on]") + often_at = body.index("recurring_transaction[frequency_preset]") + url_at = body.index("recurring_transaction[payment_url]") + + assert name_at && amount_at && due_at && often_at && url_at + assert_operator name_at, :<, amount_at + assert_operator amount_at, :<, due_at + assert_operator due_at, :<, often_at, "how often belongs with the essentials" + assert_operator often_at, :<, url_at, "and above the advanced fields, not below them" + + # Tucked away is not the same as gone. + assert_match "recurring_transaction[autopay]", body + assert_match "recurring_transaction[notes]", body + assert_match I18n.t("recurring_transactions.form.more_options"), body + end + + test "edit renders the form" do + get edit_recurring_transaction_url(@recurring_transaction) + + assert_response :success + end + + # These three mutate: identify runs the whole detection and matching + # pipeline, cleanup destroys stale series, and toggle_status pauses a bill, + # which deletes its future occurrences. A GET route puts all of that behind a + # plain URL, outside CSRF protection, where an image tag on any page a signed + # in user visits is enough to fire it. + test "the mutating actions refuse GET" do + paths = { + "/recurring_transactions/identify" => :get, + "/recurring_transactions/cleanup" => :get, + "/recurring_transactions/#{@recurring_transaction.id}/toggle_status" => :get + } + + paths.each do |path, verb| + assert_raises(ActionController::RoutingError, "#{path} must not answer #{verb.to_s.upcase}") do + Rails.application.routes.recognize_path(path, method: verb) + end + end + end + + test "identify runs the pipeline over POST" do + post identify_recurring_transactions_url + + assert_redirected_to recurring_transactions_url + end + + test "cleanup retires stale series over POST" do + post cleanup_recurring_transactions_url + + assert_redirected_to recurring_transactions_url + end + + test "toggle_status pauses and resumes over POST" do + assert @recurring_transaction.active? + + post toggle_status_recurring_transaction_url(@recurring_transaction) + assert_not @recurring_transaction.reload.active? + + post toggle_status_recurring_transaction_url(@recurring_transaction) + assert @recurring_transaction.reload.active? + end + + # The dialog is delivered into the shared that every page + # layout already renders empty. If this action responds with a full page layout, + # the response carries two frames with that id, Turbo matches the empty one first, + # and the pencil icon silently does nothing. Assert there is exactly one. + test "edit responds to a turbo frame request with a single modal frame" do + get edit_recurring_transaction_url(@recurring_transaction), + headers: { "Turbo-Frame" => "modal" } + + assert_response :success + assert_equal 1, response.body.scan(/]*id="modal"/).size + end + + test "a failed update still renders a single modal frame" do + patch recurring_transaction_url(@recurring_transaction), + params: { recurring_transaction: { payment_url: "javascript:alert(1)" } }, + headers: { "Turbo-Frame" => "modal" } + + assert_response :unprocessable_entity + assert_equal 1, response.body.scan(/]*id="modal"/).size + end + + test "update saves a payment link" do + patch recurring_transaction_url(@recurring_transaction), + params: { recurring_transaction: { payment_url: "pay.example.com/bill" } } + + assert_redirected_to recurring_transactions_url + assert_equal "https://pay.example.com/bill", @recurring_transaction.reload.payment_url + end + + test "new renders the create dialog in a single modal frame" do + get new_recurring_transaction_url, headers: { "Turbo-Frame" => "modal" } + + assert_response :success + assert_equal 1, response.body.scan(/]*id="modal"/).size + end + + test "add income opens an income dialog, not a bill dialog" do + 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 + assert_match I18n.t("recurring_transactions.form.income_name_label"), response.body + assert_match I18n.t("recurring_transactions.form.submit_income"), response.body + # Nothing bill-shaped survives in income mode. + assert_no_match I18n.t("recurring_transactions.form.autopay_hint"), response.body + assert_no_match I18n.t("recurring_transactions.form.payment_url_label"), response.body + assert_no_match I18n.t("recurring_transactions.form.submit"), response.body + end + + test "fresh bill dialog offers detected recurring charges as starting points" do + account = accounts(:depository) + 2.times do |i| + account.entries.create!( + date: Date.current - ((i + 1) * 30).days, + amount: 45.00, currency: "USD", name: "City Water", + entryable: Transaction.new + ) + end + + get new_recurring_transaction_url, headers: { "Turbo-Frame" => "modal" } + + assert_response :success + assert_match I18n.t("recurring_transactions.new.start_from_title"), response.body + assert_match "City Water", response.body + end + + test "the candidate strip never offers a pattern on an account the member cannot reach" do + 3.times do |i| + accounts(:investment).entries.create!( + date: Date.current - ((i + 1) * 30).days, + amount: 45.00, currency: "USD", name: "PRIVATE BROKERAGE SUB", + entryable: Transaction.new + ) + end + member = users(:family_member) + member.update!(preferences: (member.preferences || {}).merge("preview_features_enabled" => true)) + sign_in member + + get new_recurring_transaction_url, headers: { "Turbo-Frame" => "modal" } + + assert_response :success + assert_no_match "PRIVATE BROKERAGE SUB", response.body + end + + test "income dialog offers only detected deposits" do + account = accounts(:depository) + 2.times do |i| + account.entries.create!( + date: (i + 1).months.ago.beginning_of_month + 2.days, + amount: -1840.00, currency: "USD", name: "ACME PAYROLL", + entryable: Transaction.new + ) + account.entries.create!( + date: Date.current - ((i + 1) * 30).days, + amount: 45.00, currency: "USD", name: "City Water", + entryable: Transaction.new + ) + end + + get new_recurring_transaction_url(income: true), headers: { "Turbo-Frame" => "modal" } + + assert_response :success + assert_match I18n.t("recurring_transactions.new.start_from_income_title"), response.body + assert_match "ACME PAYROLL", response.body + assert_no_match "City Water", response.body + end + + test "prefilled dialog hides the picker" do + account = accounts(:depository) + entries = 2.times.map do |i| + account.entries.create!( + date: Date.current - ((i + 1) * 30).days, + amount: 45.00, currency: "USD", name: "City Water", + entryable: Transaction.new + ) + end + + get new_recurring_transaction_url(entry_id: entries.last.id), headers: { "Turbo-Frame" => "modal" } + + assert_response :success + assert_no_match I18n.t("recurring_transactions.new.start_from_title"), response.body + end + + test "new prefills from a transaction" do + entry = accounts(:depository).entries.create!( + date: Date.current - 20, amount: 184.37, currency: "USD", name: "PG&E WEB PAYMENT", + entryable: Transaction.new + ) + + get new_recurring_transaction_url(entry_id: entry.id), headers: { "Turbo-Frame" => "modal" } + + assert_response :success + assert_match "PG&E WEB PAYMENT", response.body + assert_match "184.37", response.body + end + + # Sharing is per account, so a family scope is not an access check. Prefilling + # reads the entry's name, amount and account straight back into the form. + test "new ignores a transaction from an account the user was never given" do + hidden = accounts(:investment).entries.create!( + date: Date.current - 3, amount: 622.41, 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 + + get new_recurring_transaction_url(entry_id: hidden.id), headers: { "Turbo-Frame" => "modal" } + + assert_response :success + assert_no_match "PRIVATE BROKERAGE FEE", response.body + assert_no_match "622.41", response.body + end + + test "prefilling from an inflow pre-selects income" do + entry = accounts(:depository).entries.create!( + date: Date.current - 10, amount: -1840, currency: "USD", name: "ACME PAYROLL", + entryable: Transaction.new + ) + + get new_recurring_transaction_url(entry_id: entry.id), headers: { "Turbo-Frame" => "modal" } + + assert_response :success + assert_match I18n.t("recurring_transactions.new.income_title"), response.body + end + + test "editing income keeps bill wording out of the dialog" 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 edit_recurring_transaction_url(income), headers: { "Turbo-Frame" => "modal" } + + assert_response :success + assert_match I18n.t("recurring_transactions.edit.income_title", name: "Paycheck"), response.body + # Apostrophes HTML-escape in the body, so match on stable fragments. + assert_match "match your payday", response.body + assert_no_match(/comes due/, response.body) + end + + test "removing a bill never touches the ledger and lands back on bills" do + due = Date.current + 5 + bill = @family.recurring_transactions.create!( + name: "City Water", account: accounts(:depository), amount: 45, currency: "USD", + expected_day_of_month: due.day, anchor_date: due, last_occurrence_date: due, + next_expected_date: due, status: "active", manual: true + ) + RecurringTransaction::OccurrenceGenerator.new(bill).generate! + entry = accounts(:depository).entries.create!( + date: Date.current, amount: 45, currency: "USD", name: "CITY WATER", + entryable: Transaction.new + ) + occurrence = bill.recurring_occurrences.order(:due_on).first + RecurringTransaction::Allocator.new(occurrence).allocate!(amount: "45", entry: entry) + + delete recurring_transaction_url(bill), headers: { "HTTP_REFERER" => bills_url } + + assert_redirected_to bills_url + assert_equal I18n.t("recurring_transactions.deleted"), flash[:notice] + assert Entry.exists?(entry.id), "removing a bill must never delete ledger entries" + end + + # Which kind this is was settled by the entry point that opened the dialog. + # The checkbox asked it again, and ticking it reshaped nothing: you filled in + # bill-shaped labels, pressed Save bill, and got an income record. + test "the add-bill dialog does not offer to make it income" do + get new_recurring_transaction_url, headers: { "Turbo-Frame" => "modal" } + + assert_response :success + assert_no_match(/name="recurring_transaction\[is_income\]"/, response.body) + end + + test "the add-income dialog carries the answer without asking" do + get new_recurring_transaction_url(income: true), headers: { "Turbo-Frame" => "modal" } + + assert_response :success + assert_match(/type="hidden"[^>]*name="recurring_transaction\[is_income\]"/, response.body) + assert_no_match(/type="checkbox"[^>]*name="recurring_transaction\[is_income\]"/, response.body) + end + + # Reported upstream: a deleted auto-detected recurring transaction comes back + # on the next detection run, so users delete the same row over and over. The + # pattern is still in the bank data, so a hard delete only lasts until the + # next sync. Removing it leaves the same `ended` tombstone that dismissing a + # suggestion does, and the Identifier refuses to claim or recreate one. + test "a deleted detected bill does not come back on the next detection run" do + account = accounts(:depository) + anchor_day = Date.current.beginning_of_month + 8 + 3.times do |i| + account.entries.create!( + date: anchor_day - i.months, amount: 42.00, currency: "USD", + name: "City Water", entryable: Transaction.create!(category: categories(:food_and_drink)) + ) + end + + RecurringTransaction::Identifier.new(@family).identify_recurring_patterns + detected = @family.recurring_transactions.find_by(name: "City Water") + assert_not_nil detected + assert_not detected.manual? + + delete recurring_transaction_url(detected) + + RecurringTransaction::Identifier.new(Family.find(@family.id)).identify_recurring_patterns + + rows = @family.recurring_transactions.where(name: "City Water") + assert_equal 1, rows.count, "detection must not build a second row for a pattern the user removed" + assert_equal "ended", rows.first.status, "and the one that remains is a tombstone, not a live bill" + assert_empty @family.recurring_transactions.where(name: "City Water").where.not(status: "ended") + end + + # A hand-declared bill has no pattern behind it, so nothing would bring it + # back and it is deleted outright rather than left lying around as ended. + test "a declared bill is deleted outright" do + bill = @family.recurring_transactions.create!( + name: "Typo Bill", account: accounts(:depository), amount: 10, currency: "USD", + dedup_scope: "typo", bill_type: "bill", 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 + ) + + assert_difference "@family.recurring_transactions.count", -1 do + delete recurring_transaction_url(bill) + end + end + + test "creating income says income, not bill" do + post recurring_transactions_url, params: { + recurring_transaction: { + name: "Paycheck", + amount: "1840", + account_id: accounts(:depository).id, + first_due_on: (Date.current + 3).iso8601, + frequency_preset: "biweekly", + is_income: "1" + } + } + + assert_equal I18n.t("recurring_transactions.create.success_income"), flash[:notice] + end + + test "create declares a manual bill and materializes its occurrences" do + due = Date.current + 16 + + assert_difference "@family.recurring_transactions.count", 1 do + post recurring_transactions_url, params: { + recurring_transaction: { + name: "Watson Property", + amount: "2150", + account_id: accounts(:depository).id, + first_due_on: due.iso8601, + frequency_preset: "monthly" + } + } + end + + bill = @family.recurring_transactions.order(:created_at).last + assert bill.manual? + assert_equal "active", bill.status + assert_equal 2150, bill.amount + assert_equal due.day, bill.expected_day_of_month + assert_equal due, bill.anchor_date + # Monthly on the derived day IS the zero-rule implicit shape, so no + # redundant rule row is written; the detection reads it back correctly. + detection = RecurringTransaction::FrequencyPreset.detect(bill) + assert_equal "monthly", detection.key + assert_equal due.day, detection.day_of_month + assert bill.recurring_occurrences.reload.exists?(due_on: due), + "the declared bill's occurrence must materialize immediately" + end + + test "create with a non-monthly preset writes explicit rules" do + due = Date.current + 4 + + post recurring_transactions_url, params: { + recurring_transaction: { + name: "Cleaning service", amount: "80", account_id: accounts(:depository).id, + first_due_on: due.iso8601, frequency_preset: "biweekly" + } + } + + bill = @family.recurring_transactions.order(:created_at).last + rule = bill.recurrence_rules.sole + assert_equal [ "weekly", 2, due.wday ], [ rule.frequency, rule.interval, rule.weekday ] + assert_equal due, bill.anchor_date + end + + test "create without a due date re-renders with an error" do + assert_no_difference "@family.recurring_transactions.count" do + post recurring_transactions_url, params: { + recurring_transaction: { name: "No date", amount: "10", frequency_preset: "monthly", first_due_on: "" } + } + end + + assert_response :unprocessable_entity + end + + test "create with a currency-formatted amount re-renders with an error instead of crashing" do + assert_no_difference "@family.recurring_transactions.count" do + post recurring_transactions_url, params: { + recurring_transaction: { name: "Trash Pickup", amount: "$40.00", account_id: accounts(:depository).id, + first_due_on: (Date.current + 5).iso8601, frequency_preset: "monthly" } + } + end + + assert_response :unprocessable_entity + assert_match I18n.t("recurring_transactions.create.amount_invalid"), response.body + end + + test "update with an unresolvable account keeps the current account and reports the error" do + original_account_id = @recurring_transaction.account_id + + patch recurring_transaction_url(@recurring_transaction), + params: { recurring_transaction: { account_id: SecureRandom.uuid } } + + assert_response :unprocessable_entity + assert_equal original_account_id, @recurring_transaction.reload.account_id, + "a present-but-unresolvable id must not silently detach the account" + end + + test "create stamps dedup_scope up front so tiers fork and true duplicates collide" do + post recurring_transactions_url, params: { + recurring_transaction: { name: "STREAMCO", amount: "5.99", account_id: accounts(:depository).id, + first_due_on: (Date.current + 3).iso8601, frequency_preset: "monthly" } + } + post recurring_transactions_url, params: { + recurring_transaction: { name: "STREAMCO", amount: "24.99", account_id: accounts(:depository).id, + first_due_on: (Date.current + 9).iso8601, frequency_preset: "monthly" } + } + + tiers = @family.recurring_transactions.where(name: "STREAMCO").order(:amount) + assert_equal 2, tiers.count + assert_equal [ "5.99", "24.99" ], tiers.map(&:dedup_scope) + + # The stamp makes the very first identical duplicate collide on insert. + post recurring_transactions_url, params: { + recurring_transaction: { name: "STREAMCO", amount: "5.99", account_id: accounts(:depository).id, + first_due_on: (Date.current + 3).iso8601, frequency_preset: "monthly" } + } + assert_equal 2, @family.recurring_transactions.where(name: "STREAMCO").count + end + + test "marking a bill as an installment plan caps its occurrences and tracks progress" do + due = Date.current + 5 + post recurring_transactions_url, params: { + recurring_transaction: { name: "Klarna sofa", amount: "120", first_due_on: due.iso8601, frequency_preset: "monthly" } + } + bill = @family.recurring_transactions.find_by!(name: "Klarna sofa") + + patch recurring_transaction_url(bill), params: { + recurring_transaction: { bill_type: "installment", end_after_count: "4" } + } + + bill.reload + assert bill.typed_installment? + assert bill.ends_after_count? + assert_equal 4, bill.recurring_occurrences.reload.count, "the plan materializes exactly its four payments" + assert_equal [ 0, 4 ], bill.installment_progress + + occurrence = bill.recurring_occurrences.order(:due_on).first + RecurringTransaction::Allocator.new(occurrence).mark_paid! + assert_equal [ 1, 4 ], bill.reload.installment_progress + end + + test "update applies a frequency preset as recurrence rules" do + patch recurring_transaction_url(@recurring_transaction), + params: { recurring_transaction: { frequency_preset: "biweekly", frequency_weekday: "5" } } + + assert_redirected_to recurring_transactions_url + rules = @recurring_transaction.reload.recurrence_rules + assert_equal [ [ "weekly", 2, 5 ] ], rules.map { |rule| [ rule.frequency, rule.interval, rule.weekday ] } + assert_not_nil @recurring_transaction.anchor_date + end + + test "update with an unchanged frequency does not rewrite the rules" do + patch recurring_transaction_url(@recurring_transaction), + params: { recurring_transaction: { frequency_preset: "weekly", frequency_weekday: "3" } } + original_ids = @recurring_transaction.reload.recurrence_rules.map(&:id) + + patch recurring_transaction_url(@recurring_transaction), + params: { recurring_transaction: { notes: "edited", frequency_preset: "weekly", frequency_weekday: "3" } } + + assert_equal original_ids, @recurring_transaction.reload.recurrence_rules.map(&:id) + assert_equal "edited", @recurring_transaction.notes + end + + test "update with an incomplete frequency re-renders the form" do + patch recurring_transaction_url(@recurring_transaction), + params: { recurring_transaction: { frequency_preset: "weekly" } }, + headers: { "Turbo-Frame" => "modal" } + + assert_response :unprocessable_entity + assert_empty @recurring_transaction.reload.recurrence_rules + end + + test "update rejects a non-http scheme instead of storing it" do + patch recurring_transaction_url(@recurring_transaction), + params: { recurring_transaction: { payment_url: "javascript:alert(1)" } } + + assert_response :unprocessable_entity + assert_nil @recurring_transaction.reload.payment_url + end + + test "update saves autopay and notes" do + patch recurring_transaction_url(@recurring_transaction), + params: { recurring_transaction: { autopay: "1", notes: "Account 4821" } } + + @recurring_transaction.reload + assert @recurring_transaction.autopay? + assert_equal "Account 4821", @recurring_transaction.notes + end + + test "update can turn autopay back off" do + @recurring_transaction.update!(autopay: true) + + patch recurring_transaction_url(@recurring_transaction), + params: { recurring_transaction: { autopay: "0" } } + + assert_not @recurring_transaction.reload.autopay? + end + + test "update clears the payment link when submitted blank" do + @recurring_transaction.update!(payment_url: "https://pay.example.com") + + patch recurring_transaction_url(@recurring_transaction), + params: { recurring_transaction: { payment_url: "" } } + + assert_nil @recurring_transaction.reload.payment_url + end + + # One biller routinely owns several bills that all pay at one portal, so the link + # can be fanned out on request. It must never reach a row outside the family. + test "update copies the payment link to sibling bills of the same merchant when asked" do + sibling = @family.recurring_transactions.create!( + account: accounts(:depository), + merchant: @recurring_transaction.merchant, + amount: 4.99, + dedup_scope: "4.99", + currency: "USD", + expected_day_of_month: 20, + last_occurrence_date: Date.current, + next_expected_date: 1.month.from_now.to_date, + status: "active" + ) + other_merchant_bill = @family.recurring_transactions.create!( + account: accounts(:depository), + merchant: merchants(:amazon), + amount: 7.99, + currency: "USD", + expected_day_of_month: 21, + last_occurrence_date: Date.current, + next_expected_date: 1.month.from_now.to_date, + status: "active" + ) + + patch recurring_transaction_url(@recurring_transaction), + params: { + recurring_transaction: { payment_url: "https://pay.example.com" }, + apply_to_siblings: "1" + } + + assert_equal "https://pay.example.com", sibling.reload.payment_url + assert_nil other_merchant_bill.reload.payment_url + end + + # Auto-detection leaves merchant_id null whenever the provider feed gave it nothing + # to match on, so most real bills are identified by name alone. Matching siblings on + # merchant only would skip them entirely. + test "update copies the payment link to name-matched siblings when there is no merchant" do + named = @family.recurring_transactions.create!( + account: accounts(:depository), + name: "TWITCH", + amount: 24.99, + currency: "USD", + expected_day_of_month: 21, + last_occurrence_date: Date.current, + next_expected_date: 1.month.from_now.to_date, + status: "active" + ) + same_name = @family.recurring_transactions.create!( + account: accounts(:depository), + name: "TWITCH", + amount: 5.99, + dedup_scope: "5.99", + currency: "USD", + expected_day_of_month: 8, + last_occurrence_date: Date.current, + next_expected_date: 1.month.from_now.to_date, + status: "active" + ) + different_name = @family.recurring_transactions.create!( + account: accounts(:depository), + name: "HUNTR.CO", + amount: 40, + currency: "USD", + expected_day_of_month: 28, + last_occurrence_date: Date.current, + next_expected_date: 1.month.from_now.to_date, + status: "active" + ) + + patch recurring_transaction_url(named), + params: { + recurring_transaction: { payment_url: "https://twitch.tv/subscriptions" }, + apply_to_siblings: "1" + } + + assert_equal "https://twitch.tv/subscriptions", same_name.reload.payment_url + assert_nil different_name.reload.payment_url + # A merchant-backed row must not be swept up by a name match. + assert_nil @recurring_transaction.reload.payment_url + end + + test "update does not touch siblings unless asked" do + sibling = @family.recurring_transactions.create!( + account: accounts(:depository), + merchant: @recurring_transaction.merchant, + amount: 4.99, + dedup_scope: "4.99", + currency: "USD", + expected_day_of_month: 20, + last_occurrence_date: Date.current, + next_expected_date: 1.month.from_now.to_date, + status: "active" + ) + + patch recurring_transaction_url(@recurring_transaction), + params: { recurring_transaction: { payment_url: "https://pay.example.com" } } + + assert_nil sibling.reload.payment_url + end + + test "update cannot reach another family's recurring transaction" do + other_family_recurring = families(:empty).recurring_transactions.create!( + name: "Someone else's bill", + amount: 10, + currency: "USD", + expected_day_of_month: 3, + last_occurrence_date: Date.current, + next_expected_date: 1.month.from_now.to_date, + status: "active" + ) + + patch recurring_transaction_url(other_family_recurring), + params: { recurring_transaction: { payment_url: "https://evil.example.com" } } + + assert_response :not_found + assert_nil other_family_recurring.reload.payment_url + end + + # A bill outliving its own price is the normal case. These used to be + # create-only, so the only way to record a rise was delete-and-recreate, + # which takes the occurrences and allocations with it. + test "the edit dialog exposes name, amount and account" do + series = recurring_transactions(:netflix_subscription) + get edit_recurring_transaction_url(series) + assert_response :success + + fields = response.body.scan(/name="recurring_transaction\[([a-z_]+)\]"/).flatten.uniq + %w[name amount account_id].each do |field| + assert_includes fields, field, "#{field} should be editable after creation" + end + refute_includes fields, "first_due_on", + "first_due_on is inert on a persisted series; the frequency picker owns the schedule" + end + + test "updating name, amount and account persists all three" do + series = recurring_transactions(:netflix_subscription) + other = accounts(:credit_card) + + patch recurring_transaction_url(series), params: { + recurring_transaction: { name: "Netflix Premium", amount: 24.99, account_id: other.id } + } + series.reload + + assert_equal "Netflix Premium", series.name + assert_equal 24.99, series.amount.to_f + assert_equal other.id, series.account_id + end + test "a bill cannot be pointed at an account the user cannot reach" do + series = recurring_transactions(:netflix_subscription) + foreign = families(:empty).accounts.create!( + name: "Someone else's checking", balance: 0, currency: "USD", + accountable: Depository.new + ) + refute_equal series.family_id, foreign.family_id + + patch recurring_transaction_url(series), params: { + recurring_transaction: { account_id: foreign.id } + } + + refute_equal foreign.id, series.reload.account_id, + "a crafted account_id must not reach another family's account" + end + + test "editing an income series keeps its negative sign" do + income = recurring_transactions(:netflix_subscription) + income.update!(bill_type: "income", amount: -2000) + + patch recurring_transaction_url(income), params: { + recurring_transaction: { amount: 2500 } + } + + assert_equal(-2500, income.reload.amount.to_f, + "income is stored negative; a raw assignment would flip it into a bill") + end + + test "the edit form shows an income amount as a positive magnitude" do + income = recurring_transactions(:netflix_subscription) + income.update!(bill_type: "income", amount: -2000) + + get edit_recurring_transaction_url(income) + + assert_response :success + # The stored sign is bookkeeping; the form edits what the paycheck pays. + assert_select "input[name=?][value=?]", "recurring_transaction[amount]", "2000.0" + end + + test "the edit form shows a bill amount as it is stored" do + get edit_recurring_transaction_url(recurring_transactions(:netflix_subscription)) + + assert_response :success + assert_select "input[name=?][value=?]", "recurring_transaction[amount]", "15.99" + end + # Detected bills carry a merchant and no name of their own. The field has to + # arrive seeded, or it renders empty and, being required, browsers refuse to + # submit the whole form; and the rename has to actually show, or it is a + # control that silently does nothing. + test "renaming a detected bill seeds the field and takes effect" do + series = recurring_transactions(:netflix_subscription) + assert series.name.blank?, "premise: this bill is named by its merchant" + assert series.merchant.present? + + get edit_recurring_transaction_url(series) + assert_select "input[name=?][value=?]", "recurring_transaction[name]", series.display_name + + patch recurring_transaction_url(series), params: { + recurring_transaction: { name: "Netflix Premium" } + } + + assert_equal "Netflix Premium", series.reload.display_name, + "a name the user typed should win over the detected merchant" + end + + # --- Suggested-series review: confirm/dismiss from either page --- + + test "confirming from the Bills page returns there and reconstructs the bill's history" do + last_month_ninth = Date.current.beginning_of_month + 8.days - 1.month + suggestion = @family.recurring_transactions.create!( + name: "CITY WATER", account: accounts(:depository), amount: 80, currency: "USD", + expected_day_of_month: 9, last_occurrence_date: last_month_ninth, + next_expected_date: last_month_ninth + 1.month, status: "suggested", manual: false + ) + accounts(:depository).entries.create!( + date: last_month_ninth, amount: 80, currency: "USD", name: "CITY WATER", + entryable: Transaction.new + ) + + post confirm_recurring_transaction_url(suggestion), headers: { "HTTP_REFERER" => bills_url } + + assert_redirected_to bills_url + assert suggestion.reload.active? + assert_operator suggestion.recurring_occurrences.count, :>, 0, + "confirming must materialize the schedule" + assert suggestion.recurring_occurrences.paid.where(due_on: last_month_ninth).exists?, + "confirming must close history a real entry anchors" + end + + test "confirming twice does not double anything" do + suggestion = @family.recurring_transactions.create!( + name: "CITY GAS", account: accounts(:depository), amount: 55, currency: "USD", + expected_day_of_month: 9, + last_occurrence_date: Date.current.beginning_of_month + 8.days - 1.month, + next_expected_date: Date.current.beginning_of_month + 8.days, + status: "suggested", manual: false + ) + + post confirm_recurring_transaction_url(suggestion) + state = suggestion.recurring_occurrences.order(:due_on).pluck(:due_on, :status) + + post confirm_recurring_transaction_url(suggestion) + + assert_equal state, suggestion.recurring_occurrences.order(:due_on).pluck(:due_on, :status) + end + + test "dismissing from the Bills page tombstones and returns there" do + suggestion = @family.recurring_transactions.create!( + name: "PHANTOM SUB", account: accounts(:depository), amount: 12, currency: "USD", + expected_day_of_month: 5, last_occurrence_date: 1.month.ago.to_date, + next_expected_date: Date.current, status: "suggested", manual: false + ) + + post dismiss_recurring_transaction_url(suggestion), headers: { "HTTP_REFERER" => bills_url } + + assert_redirected_to bills_url + assert suggestion.reload.ended? + end + + # --- "Search all your transactions" picker inside the add dialog --- + + test "the add dialog links to the picker whether or not detection found anything" do + get new_recurring_transaction_url, headers: { "Turbo-Frame" => "modal" } + + assert_response :success + assert_match I18n.t("recurring_transactions.new.search_all_cta"), response.body + assert_select "a[href=?]", new_recurring_transaction_path(picker: 1) + end + + test "picker lists recent outflows as prefill links" do + entry = picker_entry(name: "ACME POWER", amount: 120) + + get new_recurring_transaction_url(picker: 1), headers: { "Turbo-Frame" => "modal" } + + assert_response :success + assert_match "ACME POWER", response.body + assert_select "a[href=?]", new_recurring_transaction_path(entry_id: entry.id) + end + + test "picker filters by sign in each mode" do + picker_entry(name: "PAYCHECK DEPOSIT", amount: -900) + picker_entry(name: "ACME POWER", amount: 120) + + get new_recurring_transaction_url(picker: 1), headers: { "Turbo-Frame" => "modal" } + assert_match "ACME POWER", response.body + assert_no_match "PAYCHECK DEPOSIT", response.body + + get new_recurring_transaction_url(picker: 1, income: 1), headers: { "Turbo-Frame" => "modal" } + assert_match "PAYCHECK DEPOSIT", response.body + assert_no_match "ACME POWER", response.body + end + + test "picker search matches the merchant behind a bank-blob entry name" do + picker_entry(name: "ACH WEB PMT 0042", amount: 15.49, merchant: merchants(:netflix)) + picker_entry(name: "UNRELATED CHARGE", amount: 8) + + get new_recurring_transaction_url(picker: 1, q: merchants(:netflix).name), + headers: { "Turbo-Frame" => "modal" } + + assert_match "ACH WEB PMT 0042", response.body + assert_no_match "UNRELATED CHARGE", response.body + end + + test "picker search matches notes" do + picker_entry(name: "CHECK 1042", amount: 300, notes: "quarterly water bill") + picker_entry(name: "CHECK 1043", amount: 300) + + get new_recurring_transaction_url(picker: 1, q: "quarterly water"), + headers: { "Turbo-Frame" => "modal" } + + assert_match "CHECK 1042", response.body + assert_no_match "CHECK 1043", response.body + end + + test "picker search treats LIKE metacharacters as literals" do + picker_entry(name: "100% Juice Co", amount: 6) + picker_entry(name: "1003 Deli", amount: 9) + + get new_recurring_transaction_url(picker: 1, q: "100%"), headers: { "Turbo-Frame" => "modal" } + + assert_response :success + assert_match "100% Juice Co", response.body + assert_no_match "1003 Deli", response.body + end + + test "picker hides transfers and excluded entries" do + picker_entry(name: "CARD PAYMENT", amount: 200, kind: "cc_payment") + picker_entry(name: "HIDDEN CHARGE", amount: 25, excluded: true) + picker_entry(name: "REAL CHARGE", amount: 25) + + get new_recurring_transaction_url(picker: 1), headers: { "Turbo-Frame" => "modal" } + + assert_match "REAL CHARGE", response.body + assert_no_match "CARD PAYMENT", response.body + assert_no_match "HIDDEN CHARGE", response.body + end + + test "picker never shows an account the member was not given, even on exact match" do + hidden = picker_entry(name: "PRIVATE BROKERAGE FEE", amount: 30, account: accounts(:investment)) + member = users(:family_member) + member.update!(preferences: (member.preferences || {}).merge("preview_features_enabled" => true)) + sign_in member + + get new_recurring_transaction_url(picker: 1, q: "PRIVATE BROKERAGE FEE"), + headers: { "Turbo-Frame" => "modal" } + + assert_response :success + # The no-results copy echoes the query, so assert on the row link itself. + assert_select "a[href=?]", new_recurring_transaction_path(entry_id: hidden.id), count: 0 + assert_match I18n.t("recurring_transactions.pick_entry.back"), response.body + end + + test "an entry already backing a bill carries a chip instead of being hidden" do + claimed = picker_entry(name: "NETFLIX.COM", amount: 15.99) + series = @family.recurring_transactions.create!( + name: "Netflix", account: accounts(:depository), amount: 15.99, currency: "USD", + dedup_scope: "chip", expected_day_of_month: Date.current.day, + last_occurrence_date: 1.month.ago.to_date, next_expected_date: Date.current, + status: "active", manual: true + ) + occurrence = series.recurring_occurrences.order(:due_on).first + occurrence.allocations.create!( + entry: claimed, allocated_amount: 15.99, currency: "USD", source: "user_created" + ) + picker_entry(name: "UNCLAIMED CHARGE", amount: 12) + + get new_recurring_transaction_url(picker: 1), headers: { "Turbo-Frame" => "modal" } + + assert_match I18n.t("recurring_transactions.picker_row.claimed", name: "Netflix"), response.body + # The chip names its bill once, on the claimed row only. + assert_equal 1, response.body.scan( + I18n.t("recurring_transactions.picker_row.claimed", name: "Netflix") + ).size + end + + test "picker caps at twenty rows and says so" do + 25.times { |i| picker_entry(name: "CHARGE #{format('%02d', i)}", amount: 5 + i) } + + get new_recurring_transaction_url(picker: 1), headers: { "Turbo-Frame" => "modal" } + + assert_equal RecurringTransactionsController::PICKER_SHOWN, + response.body.scan(/CHARGE \d\d/).uniq.size + assert_match I18n.t("recurring_transactions.pick_entry.showing_recent", + count: RecurringTransactionsController::PICKER_SHOWN), response.body + end + + test "picker with no results explains and offers the way back" do + get new_recurring_transaction_url(picker: 1, q: "zzz-nothing-matches"), + headers: { "Turbo-Frame" => "modal" } + + assert_response :success + assert_match CGI.escapeHTML("zzz-nothing-matches"), response.body + assert_match I18n.t("recurring_transactions.pick_entry.back"), response.body + end + + # The declare, edit and suggestion paths shipped with Bills, so they honor + # the same preview gate as every other Bills surface. Direct URLs included: + # the gate is a before_action, not a matter of which buttons render. + test "the bills-era actions sit behind the preview gate" do + @user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => false)) + + get new_recurring_transaction_url + assert_redirected_to root_path + + assert_no_difference "RecurringTransaction.count" do + post recurring_transactions_url, params: { recurring_transaction: { + name: "Gated", amount: 10, first_due_on: Date.current.iso8601, frequency_preset: "monthly" + } } + end + assert_redirected_to root_path + + original_name = @recurring_transaction.name + patch recurring_transaction_url(@recurring_transaction), params: { recurring_transaction: { name: "Renamed" } } + assert_redirected_to root_path + assert_equal original_name, @recurring_transaction.reload.name + + suggestion = create_series(name: "Maybe A Bill", status: "suggested") + post confirm_recurring_transaction_url(suggestion) + assert_redirected_to root_path + assert suggestion.reload.suggested? + end + + test "the pre-bills settings actions stay reachable without the preview flag" do + @user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => false)) + + get recurring_transactions_url + assert_response :success + + post toggle_status_recurring_transaction_url(@recurring_transaction) + assert_redirected_to recurring_transactions_url + end + + # Sharing is per account: a read-only share may SEE the series everywhere the + # app lists it, and must not be able to change or remove it. Mirrors + # RecurringOccurrencesController#ensure_series_writable. + test "a read-only account share can see but not mutate a series" do + member = users(:family_member) + member.update!(preferences: (member.preferences || {}).merge("preview_features_enabled" => true)) + # The credit card fixture is shared with the member read_only. + series = create_series(name: "Shared Read Only", account: accounts(:credit_card)) + suggestion = create_series(name: "Shared Suggestion", account: accounts(:credit_card), status: "suggested") + + sign_in member + + # Visible: the read dialog opens. The write guard bites only on mutation. + get edit_recurring_transaction_url(series), headers: { "Turbo-Frame" => "modal" } + assert_response :success + assert_match "Shared Read Only", response.body + + patch recurring_transaction_url(series), params: { recurring_transaction: { name: "Hijacked" } } + assert_response :not_found + assert_equal "Shared Read Only", series.reload.name + + post toggle_status_recurring_transaction_url(series) + assert_response :not_found + assert series.reload.active? + + post confirm_recurring_transaction_url(suggestion) + assert_response :not_found + assert suggestion.reload.suggested? + + post dismiss_recurring_transaction_url(suggestion) + assert_response :not_found + assert suggestion.reload.suggested? + + delete recurring_transaction_url(series) + assert_response :not_found + assert series.reload.persisted? + end + + test "an accountless series carries no account write gate" do + series = create_series(name: "No Account", account: nil) + + patch recurring_transaction_url(series), params: { recurring_transaction: { name: "Renamed Fine" } } + + assert_response :redirect + assert_equal "Renamed Fine", series.reload.name + end + + # The destination is a write too: attaching a series to an account changes + # what that account's owners see, so a read-only share cannot receive one, + # whether by edit or at declaration. + test "a read-only account cannot become a series' destination" do + member = users(:family_member) + member.update!(preferences: (member.preferences || {}).merge("preview_features_enabled" => true)) + series = create_series(name: "Wandering Bill", account: nil) + + sign_in member + + patch recurring_transaction_url(series), params: { recurring_transaction: { account_id: accounts(:credit_card).id } } + assert_response :unprocessable_entity + assert_nil series.reload.account_id + + assert_no_difference "RecurringTransaction.count" do + post recurring_transactions_url, params: { recurring_transaction: { + name: "Declared On Read Only", amount: 12, first_due_on: Date.current.iso8601, + frequency_preset: "monthly", account_id: accounts(:credit_card).id + } } + end + assert_response :unprocessable_entity + assert_match I18n.t("recurring_transactions.create.account_invalid"), response.body + end + + # Clearing a payment link is a statement about one bill; the opt-in copy + # must not blank the siblings' own links on the way through. + test "clearing the payment link never blanks the siblings" do + source = create_series(name: "Twitch Tier 1", merchant: merchants(:netflix), payment_url: "https://pay.example/1") + sibling = create_series(name: "Twitch Tier 2", merchant: merchants(:netflix), payment_url: "https://pay.example/keep") + + patch recurring_transaction_url(source), params: { + apply_to_siblings: "1", + recurring_transaction: { payment_url: "" } + } + + assert_response :redirect + assert_nil source.reload.payment_url.presence + assert_equal "https://pay.example/keep", sibling.reload.payment_url + end + + test "the sibling copy skips series on accounts the user cannot write" do + member = users(:family_member) + member.update!(preferences: (member.preferences || {}).merge("preview_features_enabled" => true)) + + source = create_series(name: "Portal Bill", account: nil, merchant: merchants(:netflix)) + # The credit card fixture is shared with the member read_only: visible, + # therefore inside accessible_by, and exactly what the copy must skip. + read_only_sibling = create_series(name: "Portal Bill RO", account: accounts(:credit_card), + merchant: merchants(:netflix), payment_url: "https://pay.example/theirs") + + sign_in member + patch recurring_transaction_url(source), params: { + apply_to_siblings: "1", + recurring_transaction: { payment_url: "https://pay.example/mine" } + } + + assert_response :redirect + assert_equal "https://pay.example/mine", source.reload.payment_url + assert_equal "https://pay.example/theirs", read_only_sibling.reload.payment_url + end + + private + + def create_series(name:, account: accounts(:depository), merchant: nil, status: "active", payment_url: nil) + @family.recurring_transactions.create!( + account: account, + merchant: merchant, + name: name, + amount: 25, + dedup_scope: name, + currency: "USD", + expected_day_of_month: 5, + last_occurrence_date: 1.month.ago.to_date, + next_expected_date: 5.days.from_now.to_date, + status: status, + payment_url: payment_url + ) + end + + def picker_entry(name:, amount:, account: accounts(:depository), merchant: nil, notes: nil, kind: nil, excluded: false) + transaction_attrs = { merchant: merchant } + transaction_attrs[:kind] = kind if kind + + account.entries.create!( + date: Date.current, amount: amount, currency: "USD", name: name, + notes: notes, excluded: excluded, + entryable: Transaction.new(**transaction_attrs) + ) + end +end diff --git a/test/controllers/transactions_controller_test.rb b/test/controllers/transactions_controller_test.rb index 8b7becbae..23f7dbddb 100644 --- a/test/controllers/transactions_controller_test.rb +++ b/test/controllers/transactions_controller_test.rb @@ -8,6 +8,54 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest @entry = entries(:transaction) end + # Bills has always linked out to transactions. Until now nothing linked back, + # so a transaction that settled a bill was a dead end. The link-back is part + # of the preview-gated bills surface, so the viewer needs the flag. + test "a transaction shows the bill it paid, and links to it" do + @user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => true)) + series = @user.family.recurring_transactions.create!( + account: accounts(:depository), name: "Watson Property", amount: 2000, + currency: "USD", expected_day_of_month: 9, status: "active", manual: true, + bill_type: "bill", last_occurrence_date: Date.current, + next_expected_date: Date.current + ) + series.recurring_occurrences.destroy_all + due = Date.current.beginning_of_month + 8 + occurrence = series.recurring_occurrences.create!( + family: @user.family, original_due_on: due, due_on: due, + currency: "USD", expected_amount: 2000, status: "scheduled" + ) + RecurringTransaction::Allocator.new(occurrence).allocate!(entry: @entry) + + get transaction_url(@entry), headers: { "Turbo-Frame" => "drawer" } + + assert_response :success + assert_match "Watson Property", response.body + assert_match bill_path(series), response.body, "the bill must be reachable from the transaction" + end + + test "the bill link-back stays hidden without preview access" do + series = @user.family.recurring_transactions.create!( + account: accounts(:depository), name: "Watson Property", amount: 2000, + currency: "USD", expected_day_of_month: 9, status: "active", manual: true, + bill_type: "bill", last_occurrence_date: Date.current, + next_expected_date: Date.current + ) + series.recurring_occurrences.destroy_all + due = Date.current.beginning_of_month + 8 + occurrence = series.recurring_occurrences.create!( + family: @user.family, original_due_on: due, due_on: due, + currency: "USD", expected_amount: 2000, status: "scheduled" + ) + RecurringTransaction::Allocator.new(occurrence).allocate!(entry: @entry) + + get transaction_url(@entry), headers: { "Turbo-Frame" => "drawer" } + + assert_response :success + assert_no_match bill_path(series), response.body, + "the preview-gated bill link must not render for a user without the flag" + end + test "index groups subcategories immediately after their parent in the category filter" do get transactions_url assert_response :success diff --git a/test/helpers/bills_helper_test.rb b/test/helpers/bills_helper_test.rb new file mode 100644 index 000000000..005e710e9 --- /dev/null +++ b/test/helpers/bills_helper_test.rb @@ -0,0 +1,340 @@ +require "test_helper" +require "ostruct" + +class BillsHelperTest < ActionView::TestCase + # bills_match_reasons formats one money value, and format_money lives in + # ApplicationHelper rather than this module. + include ApplicationHelper + + # The matcher has always stored WHY it matched something, in match_signals. + # Nothing rendered it, so the app showed a bare percentage instead of the + # facts the percentage is made of. + test "the signals behind an exact match read as plain reasons" do + reasons = bills_match_reasons( + { merchant: 0.40, amount: 0.30, date: 0.20, account: 0.10 }, + currency: "USD", + expected: BigDecimal("6.44"), + actual: BigDecimal("6.44"), + due_on: Date.new(2026, 7, 31), + paid_on: Date.new(2026, 7, 31) + ) + + assert_equal [ + I18n.t("bills.match.same_merchant"), + I18n.t("bills.match.exact_amount"), + I18n.t("bills.match.due_date") + ], reasons + end + + # signals[:account] is a constant 0.10 on every candidate, because + # identity_matches? has already rejected everything on another account. A + # reason that never distinguishes anything is decoration. + test "the account signal is never rendered as a reason" do + reasons = bills_match_reasons( + { merchant: 0.40, amount: 0.30, date: 0.20, account: 0.10 }, + currency: "USD", expected: 10, actual: 10, + due_on: Date.current, paid_on: Date.current + ) + + assert_no_match(/account/i, reasons.join(" ")) + end + + test "an inexact amount names the difference, and a nearby date names the gap" do + reasons = bills_match_reasons( + { name: 0.35, amount: 0.22, date: 0.14 }, + currency: "USD", + expected: BigDecimal("14.99"), + actual: BigDecimal("13.27"), + due_on: Date.new(2026, 7, 31), + paid_on: Date.new(2026, 7, 30) + ) + + assert_includes reasons, I18n.t("bills.match.name_matches") + assert_includes reasons, I18n.t("bills.match.amount_off", amount: "$1.72") + assert_includes reasons, I18n.t("bills.match.days_before", count: 1) + end + + test "a date after the due date reads as after" do + reasons = bills_match_reasons( + { date: 0.10 }, + currency: "USD", + due_on: Date.new(2026, 7, 31), + paid_on: Date.new(2026, 8, 3) + ) + + assert_equal [ I18n.t("bills.match.days_after", count: 3) ], reasons + end + + # A suggestion's entry FK nullifies rather than cascades, so the review queue + # can hold an allocation with no entry behind it. An unguarded subtraction + # would raise on the one screen this helper exists to improve. + test "a signal with no figures behind it is skipped rather than raising" do + assert_nothing_raised do + reasons = bills_match_reasons({ merchant: 0.40, amount: 0.30, date: 0.20 }, currency: "USD") + + assert_equal [ I18n.t("bills.match.same_merchant") ], reasons + end + end + + test "string keys out of jsonb work the same as symbols" do + reasons = bills_match_reasons( + { "merchant" => 0.40, "amount" => 0.30 }, + currency: "USD", expected: 5, actual: 5 + ) + + assert_equal [ + I18n.t("bills.match.same_merchant"), + I18n.t("bills.match.exact_amount") + ], reasons + end + + test "no signals at all yields no reasons" do + assert_empty bills_match_reasons(nil, currency: "USD") + assert_empty bills_match_reasons({}, currency: "USD") + end + + # The bar exists to show a paycheck divided three ways. If the segments do + # not carry the three amounts the card states in words, it is decoration + # sitting where an explanation should be. + test "a healthy period divides into due, reserved and safe" do + period = build_period(income: 1200, due: 357.48, reserved: 338.12) + + assert_equal [ :due, :reserved, :safe ], paycheck_allocation_segments(period).map(&:first) + assert_in_delta 29.79, paycheck_allocation_segments(period).first.last, 0.01 + end + + # Rounding three shares to two places can leave the track a hair short, and + # a fully allocated paycheck showing a sliver of empty bar is the one thing + # this bar must never say. + test "segments always add up to exactly 100" do + [ [ 1200, 357.48, 338.12 ], [ 1000, 333.33, 333.33 ], [ 999.99, 333.33, 0 ] ].each do |income, due, reserved| + segments = paycheck_allocation_segments(build_period(income: income, due: due, reserved: reserved)) + + assert_equal 100, segments.sum(&:last), "#{income}/#{due}/#{reserved} did not fill the track" + end + end + + # Dividing a short period three ways would draw a safe slice out of money + # that is not there. + test "a short period reads as covered and short, never as safe" do + period = build_period(income: 500, due: 400, reserved: 300) + + segments = paycheck_allocation_segments(period) + + assert_equal [ :covered, :short ], segments.map(&:first) + assert_equal 100, segments.sum(&:last) + assert_in_delta 71.43, segments.first.last, 0.01 + end + + test "a window with no income has no bar at all" do + assert_empty paycheck_allocation_segments(build_period(income: 0, due: 28.71, reserved: 695.62)) + end + + test "a zero part is dropped rather than drawn as a hairline" do + segments = paycheck_allocation_segments(build_period(income: 1200, due: 0, reserved: 338.12)) + + assert_equal [ :reserved, :safe ], segments.map(&:first) + end + + # "Paycheck" is an assumption. A declared income series can be a pension or + # an invoice, and the user's own setup already names it. + test "a period is headed by the income that opens it" do + period = build_period(income: 1200, due: 0, reserved: 0, sources: [ "Frito Lay" ]) + + assert_equal "#{I18n.l(period.starts_on, format: :short)} ยท Frito Lay", paycheck_period_heading(period), + "the date leads, because the timeline is read down its date anchors" + end + + test "two sources on one day are counted, not merged into one name" do + period = build_period(income: 1400, due: 0, reserved: 0, sources: [ "Frito Lay", "Side work" ]) + + assert_match(/2 income sources/, paycheck_period_heading(period)) + end + + + # Reported from live use: a Twitch charge showed "$11.99 of $11.99 paid" and + # "Overdue by 20 days" on the same line. The label only ever read dates, so a + # cycle settled after its due date stayed "overdue" forever. + test "a settled cycle is not overdue" do + occurrence = build_occurrence(due_on: 20.days.ago.to_date, status: "paid") + + label = occurrence_due_label(occurrence) + + assert_match(/was due/i, label) + assert_no_match(/overdue/i, label, "a paid cycle cannot also be late") + end + + test "skipped and missed cycles read the same way" do + %w[skipped missed].each do |status| + occurrence = build_occurrence(due_on: 20.days.ago.to_date, status: status) + + assert_no_match(/overdue/i, occurrence_due_label(occurrence), + "a #{status} cycle is closed, so it is not still running late") + end + end + + test "an open cycle past its due date is still overdue" do + occurrence = build_occurrence(due_on: 20.days.ago.to_date, status: "scheduled") + + assert_match(/overdue/i, occurrence_due_label(occurrence), + "the overdue case must survive: that is the one the label exists for") + end + + + # derived_state only calls a cycle overdue once its grace has run out, and + # the overview and get_bills both honour that. This label read the raw date, + # so the screen said Overdue by 1 day about a bill the assistant correctly + # called due. + test "a cycle inside its grace period is not labelled overdue" do + occurrence = build_occurrence(due_on: Date.current - 1, status: "scheduled") + assert_equal :due, occurrence.derived_state, "precondition: still inside grace" + + label = occurrence_due_label(occurrence) + + assert_no_match(/overdue/i, label) + assert_match(/due/i, label) + end + + test "a cycle past its grace is still labelled overdue" do + occurrence = build_occurrence(due_on: Date.current - 30, status: "scheduled") + assert_equal :overdue, occurrence.derived_state, "precondition: grace exhausted" + + assert_match(/overdue/i, occurrence_due_label(occurrence)) + end + + # --- Prepared-data helpers extracted from the templates, so the section, + # pulse, detail and paycheck views render precomputed values. --- + + test "ambiguous row keys mark only genuine collisions" do + twin_a = stub_occurrence("Twitch", 5.99, id: "a1") + twin_b = stub_occurrence("Twitch", 5.99, id: "a2") + other_tier = stub_occurrence("Twitch", 11.99, id: "b") + + keys = bills_ambiguous_row_keys([ twin_a, twin_b, other_tier ]) + + assert_includes keys, [ "Twitch", 5.99 ] + assert_not_includes keys, [ "Twitch", 11.99 ] + end + + test "pay period markers land on the first row of each period with its summed total" do + period = OpenStruct.new(starts_on: Date.new(2026, 9, 1), ends_on: Date.new(2026, 9, 14)) + first_inside = stub_occurrence("Rent", 2150, id: "one", due_on: Date.new(2026, 9, 2)) + second_inside = stub_occurrence("Power", 80, id: "two", due_on: Date.new(2026, 9, 10)) + outside = stub_occurrence("Later", 10, id: "three", due_on: Date.new(2026, 9, 20)) + + markers = bills_pay_period_markers([ first_inside, second_inside, outside ], [ period ]) + + assert_equal [ "one" ], markers.keys + assert_equal 2230, markers["one"][:due_total] + assert_equal period, markers["one"][:period] + end + + test "no pay periods means no markers" do + occurrence = stub_occurrence("Rent", 1, id: "x", due_on: Date.current) + + assert_empty bills_pay_period_markers([ occurrence ], []) + end + + test "month progress divides paid, overdue and upcoming out of one total" do + progress = bills_month_progress(paid: 50, remaining: 50, overdue: 25) + + assert_equal 100.0, progress[:total] + assert_in_delta 50.0, progress[:paid_pct] + assert_in_delta 25.0, progress[:overdue_pct] + assert_in_delta 25.0, progress[:upcoming_pct] + end + + test "an empty month draws no bar" do + progress = bills_month_progress(paid: nil, remaining: nil, overdue: nil) + + assert_equal 0.0, progress[:total] + assert_equal 0, progress[:paid_pct] + end + + test "overdue money never claims more of the bar than what remains" do + progress = bills_month_progress(paid: 80, remaining: 20, overdue: 500) + + assert_in_delta 20.0, progress[:overdue_pct] + assert_in_delta 0.0, progress[:upcoming_pct] + end + + test "matcher hints strip blanks and cast the tolerance" do + series = OpenStruct.new(matcher_hints: { "name_aliases" => [ "PEPSICO", "" ], "learned_tolerance_pct" => "7.5" }) + + hints = bills_matcher_hints(series) + + assert_equal [ "PEPSICO" ], hints[:aliases] + assert_equal 7.5, hints[:learned_pct] + end + + test "plan sections split the bridge from the timeline and pick the warning state" do + short_bridge = build_period(income: 0, due: 400, reserved: 0, leading: true, cash_on_hand: BigDecimal("100")) + period = build_period(income: 1200, due: 300, reserved: 100) + + sections = paycheck_plan_sections([ short_bridge, period ]) + + assert_equal [ period ], sections[:periods] + assert_equal short_bridge, sections[:shortfall] + assert_nil sections[:bridge_note] + end + + test "a covered bridge with items becomes the quiet note, not the warning" do + covered = build_period(income: 0, due: 50, reserved: 0, leading: true, + cash_on_hand: BigDecimal("500"), items: [ :a_bill ]) + + sections = paycheck_plan_sections([ covered ]) + + assert_nil sections[:shortfall] + assert_equal covered, sections[:bridge_note] + end + + test "no plan yields empty sections" do + assert_empty paycheck_plan_sections(nil) + end + + private + + def stub_occurrence(name, amount, id:, due_on: Date.current) + OpenStruct.new( + id: id, + due_on: due_on, + resolved_expected_amount: amount, + recurring_transaction: OpenStruct.new(display_name: name) + ) + end + + def build_occurrence(due_on:, status:) + family = users(:family_admin).family + series = family.recurring_transactions.create!( + name: "Twitch #{status} #{due_on}", account: accounts(:depository), + amount: 11.99, currency: "USD", expected_day_of_month: due_on.day, + status: "active", bill_type: "subscription", manual: true, + dedup_scope: "twitch-#{status}-#{due_on}", + last_occurrence_date: due_on, next_expected_date: due_on + ) + series.recurring_occurrences.destroy_all + series.recurring_occurrences.create!( + family: family, original_due_on: due_on, due_on: due_on, + currency: "USD", expected_amount: 11.99, status: status, + closed_at: (status == "scheduled" ? nil : Time.current) + ) + end + def build_period(income:, due:, reserved:, sources: [ "Payroll" ], leading: false, cash_on_hand: nil, items: []) + obligations = BigDecimal(due.to_s) + BigDecimal(reserved.to_s) + + RecurringTransaction::PaycheckPlanner::Period.new( + starts_on: Date.new(2026, 8, 19), + ends_on: Date.new(2026, 8, 25), + income: BigDecimal(income.to_s), + income_sources: sources, + items: items, + due_total: BigDecimal(due.to_s), + reserved_total: BigDecimal(reserved.to_s), + obligation_total: obligations, + remaining: BigDecimal(income.to_s) - obligations, + leading: leading, + final: false, + cash_on_hand: cash_on_hand + ) + end +end diff --git a/test/helpers/recurring_transactions_helper_test.rb b/test/helpers/recurring_transactions_helper_test.rb new file mode 100644 index 000000000..eccd7143e --- /dev/null +++ b/test/helpers/recurring_transactions_helper_test.rb @@ -0,0 +1,22 @@ +require "test_helper" + +class RecurringTransactionsHelperTest < ActionView::TestCase + include ApplicationHelper + + # ordinalize always emits English suffixes; the day picker has to follow the + # active locale the way ApplicationHelper#localized_ordinal does. + test "day options follow the locale's ordinals" do + assert_equal "1st", frequency_day_options.first.first + + I18n.with_locale(:ca) do + assert_equal "1r", frequency_day_options.first.first + end + end + + test "day options end with the last-day sentinel" do + label, value = frequency_day_options.last + + assert_equal RecurrenceRule::LAST, value + assert_equal I18n.t("recurring_transactions.frequency.last_day"), label + end +end diff --git a/test/models/recurring_transaction/ai_setup_suggester_test.rb b/test/models/recurring_transaction/ai_setup_suggester_test.rb new file mode 100644 index 000000000..24fe54b75 --- /dev/null +++ b/test/models/recurring_transaction/ai_setup_suggester_test.rb @@ -0,0 +1,137 @@ +require "test_helper" + +class RecurringTransaction::AiSetupSuggesterTest < ActiveSupport::TestCase + Suggester = RecurringTransaction::AiSetupSuggester + RawSuggestion = Provider::LlmConcept::BillSetupSuggestion + + setup do + @user = users(:family_admin) + @family = @user.family + @family.recurring_transactions.destroy_all + @entries = [ create_entry(name: "GYM MEMBERSHIP", amount: 40, date: Date.current) ] + end + + test "raises when no LLM provider is configured" do + Provider::Registry.stubs(:preferred_llm_provider).returns(nil) + + assert_raises(Suggester::Error) do + Suggester.new(@family, user: @user).suggest_from_entries(@entries) + end + end + + test "raises on empty charge history instead of asking the model to guess" do + stub_provider(raw(name: "X")) + + assert_raises(Suggester::Error) do + Suggester.new(@family, user: @user).suggest_from_entries([]) + end + end + + test "normalizes provider output: presets clamped, ranges enforced" do + stub_provider(raw( + name: "Gym", amount: 40.0, frequency: "fortnightly", day_of_month: 45, + weekday: 9, month_of_year: 0, bill_type: "loan", confidence: 3.5 + )) + + suggestion = Suggester.new(@family, user: @user).suggest_from_entries(@entries) + + assert_nil suggestion.frequency, "an invented cadence must not survive" + assert_nil suggestion.day_of_month + assert_nil suggestion.weekday + assert_nil suggestion.month_of_year + assert_nil suggestion.bill_type + assert_equal 1.0, suggestion.confidence, "confidence clamps into 0..1" + assert_equal 40.0, suggestion.amount.to_f + end + + test "an explicit autopay false survives normalization as a real proposal" do + stub_provider(raw(autopay: false)) + + suggestion = Suggester.new(@family, user: @user).suggest_from_entries(@entries) + + assert_equal false, suggestion.autopay, "false proposes turning autopay off; only nil means no proposal" + assert suggestion.any_proposal? + end + + test "a non-boolean autopay normalizes to no proposal" do + stub_provider(raw(autopay: "yes")) + + suggestion = Suggester.new(@family, user: @user).suggest_from_entries(@entries) + + assert_nil suggestion.autopay + assert_not suggestion.any_proposal? + end + + test "resolves the category to this family's own id, case-insensitively" do + category = @family.categories.create!(name: "Utilities", color: "#0000ff") + stub_provider(raw(category_name: "utilities")) + + suggestion = Suggester.new(@family, user: @user).suggest_from_entries(@entries) + + assert_equal category.id, suggestion.category_id + assert_equal "Utilities", suggestion.category_name + end + + test "an LLM-invented category resolves to nothing" do + stub_provider(raw(category_name: "Definitely Not A Real Category")) + + suggestion = Suggester.new(@family, user: @user).suggest_from_entries(@entries) + + assert_nil suggestion.category_id + assert_nil suggestion.category_name + end + + test "configure mode sends the series' current configuration to the provider" do + series = @family.recurring_transactions.create!( + name: "Gym", account: accounts(:depository), amount: 40, currency: "USD", + expected_day_of_month: 9, anchor_date: Date.current, + last_occurrence_date: 1.month.ago.to_date, next_expected_date: Date.current, + status: "active", manual: true + ) + # On the series' expected day: matching_transactions is day-of-month + # scoped, so a drifting date would give the suggester no history. + create_entry(name: "Gym", amount: 40, date: Date.current.beginning_of_month + 8.days - 1.month) + + captured = nil + provider = Object.new + provider.define_singleton_method(:suggest_bill_setup) do |**kwargs| + captured = kwargs + Provider::Response.new(success?: true, data: RawSuggestion.new( + name: nil, amount: nil, frequency: nil, day_of_month: nil, weekday: nil, + month_of_year: nil, category_name: nil, bill_type: nil, autopay: nil, + confidence: 0.9, rationale: "already right" + ), error: nil) + end + Provider::Registry.stubs(:preferred_llm_provider).returns(provider) + + suggestion = Suggester.new(@family, user: @user).suggest_configuration(series) + + assert_equal "Gym", captured[:current_config][:name] + refute suggestion.any_proposal?, "all-null fields mean the configuration is already right" + end + + private + + def raw(**overrides) + RawSuggestion.new(**{ + name: nil, amount: nil, frequency: nil, day_of_month: nil, weekday: nil, + month_of_year: nil, category_name: nil, bill_type: nil, autopay: nil, + confidence: nil, rationale: nil + }.merge(overrides)) + end + + def stub_provider(suggestion) + provider = Object.new + provider.define_singleton_method(:suggest_bill_setup) do |**| + Provider::Response.new(success?: true, data: suggestion, error: nil) + end + Provider::Registry.stubs(:preferred_llm_provider).returns(provider) + end + + def create_entry(name:, amount:, date:) + accounts(:depository).entries.create!( + date: date, amount: amount, currency: "USD", name: name, + entryable: Transaction.new + ) + end +end diff --git a/test/system/bills_mobile_test.rb b/test/system/bills_mobile_test.rb new file mode 100644 index 000000000..432221a44 --- /dev/null +++ b/test/system/bills_mobile_test.rb @@ -0,0 +1,163 @@ +require "application_system_test_case" +require "ostruct" + +# Bills is used on a phone, and the app is installable as a PWA, so "fits a +# phone" is a correctness property rather than a polish one. +# +# The row used to carry a date column, an icon, an amount, a Details button and +# a pay action, all shrink-0. At 375px those added up to more than the row was +# wide, so the bill's own name collapsed to nothing AND the row still +# overflowed. Because
is `overflow-y-auto`, the CSS overflow spec +# computes its overflow-x to `auto` too, which turned one wide row into a +# whole page that scrolled sideways. +class BillsMobileTest < ApplicationSystemTestCase + PHONE = [ 375, 812 ].freeze + + setup do + sign_in @user = users(:family_admin) + @user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => true)) + @family = @user.family + page.driver.browser.manage.window.resize_to(*PHONE) + end + + teardown do + page.driver.browser.manage.window.resize_to(1400, 1400) + end + + test "no Bills view scrolls sideways on a phone" do + # The AI chips render only with consent plus a provider, so without this + # the overview would be measured without a whole strip it can carry. + Provider::Registry.stubs(:preferred_llm_provider).returns(OpenStruct.new) + + # A long name, a five-figure amount and a note: the row at its widest. + bill = @family.recurring_transactions.create!( + name: "Watson Property Management Company LLC", + account: accounts(:depository), amount: 12_450.75, currency: "USD", + notes: "Account 4821, on the Amex", + 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, payment_url: "https://example.com/pay" + ) + + # Without declared income the Paycheck view is an empty state, so the view + # nominally covered here was never the one that renders periods, heroes + # and allocation bars. + payday = Date.current + 3 + @family.recurring_transactions.create!( + name: "Frito Lay Bakersfield Payroll", 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 + ) + + # Detection's suggested strip: a long name fighting two buttons for a row. + @family.recurring_transactions.create!( + name: "Neighborhood Fitness and Racquet Club Membership", + account: accounts(:depository), amount: 89.99, currency: "USD", + expected_day_of_month: Date.current.day, anchor_date: Date.current, + last_occurrence_date: Date.current, next_expected_date: Date.current + 1.month, + status: "suggested", occurrence_count: 3 + ) + + %w[overview calendar paycheck all].each do |view| + visit view == "overview" ? bills_url : bills_url(view: view) + + # The widest optional strips have to actually be on the page for the + # measurement to mean anything. + if view == "overview" + assert_text I18n.t("bills.ai_prompts.due_before_paycheck") + assert_text "Neighborhood Fitness and Racquet Club Membership" + end + + # The management table reflows into the stacked list on a narrow + # container; a table that merely scrolls sideways would pass the + # document measurement below while still hiding six of its columns. + assert_no_selector "table", visible: true if view == "all" + + assert_no_horizontal_scroll("the #{view} view") + end + + # The bill's own page: chart, history and configuration in one column. + visit bill_url(bill) + assert_text bill.display_name + assert_no_horizontal_scroll("the bill page") + + # The control for the reflow above: given its width back, the container + # query must bring the table back, or the check proved only that a table + # never renders at all. 1920 and not 1400, because the switch reads the + # container: the app shell's sidebars eat ~885px before the bills column + # gets any, and 1400 leaves it narrower than the table deserves. + page.driver.browser.manage.window.resize_to(1920, 1400) + visit bills_url(view: "all") + assert_selector "table", visible: true + + # The reserved list is behind a disclosure, so its rows are only ever + # measured with it open. + visit bills_url(view: "paycheck") + assert_text I18n.t("bills.paycheck.reserved_ahead") + all("summary", text: I18n.t("bills.paycheck.reserved_ahead")).each(&:click) + assert_no_horizontal_scroll("the paycheck view with reserved amounts open") + + # And with a row expanded, which is the widest the page ever gets. + visit bills_url + find("a[data-turbo-frame^='pane_recurring_occurrence_']", match: :first).click + assert_text bill.display_name + assert_no_horizontal_scroll("the overview with a row expanded") + end + + # A green overflow assertion proves nothing unless it can go red, and this + # one measures a property that is zero on most pages by accident. So: force + # an overflow and confirm the measurement sees it. + test "the overflow check actually detects overflow" do + visit bills_url + assert_no_horizontal_scroll("the overview") + + page.execute_script(<<~JS) + const wide = document.createElement("div"); + wide.style.width = "3000px"; + wide.style.height = "1px"; + document.querySelector("#main").appendChild(wide); + JS + + assert_raises(Minitest::Assertion) { assert_no_horizontal_scroll("a deliberately wide element") } + end + + test "the payment drawer is escapable on a phone" do + bill = @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 = bill.recurring_occurrences.order(:due_on).first + + visit recurring_occurrence_url(occurrence) + + # DS::Dialog hides its own close button below lg when responsive, which + # leaves Esc and a 12px gutter tap as the only ways out. A phone has no Esc + # key, so this surface renders its own. + within("dialog") do + assert_selector "button[aria-label='#{I18n.t("ds.dialog.close")}']", visible: true + end + assert_no_horizontal_scroll("the payment drawer") + end + + private + # The document must never be wider than the viewport, and neither must the + # scroll container inside it. + def assert_no_horizontal_scroll(label) + overflow = page.evaluate_script(<<~JS) + (() => { + const main = document.querySelector("#main"); + return { + doc: document.documentElement.scrollWidth - document.documentElement.clientWidth, + main: main ? main.scrollWidth - main.clientWidth : 0 + }; + })() + JS + + assert_operator overflow["doc"], :<=, 1, "#{label} scrolls the document sideways" + assert_operator overflow["main"], :<=, 1, "#{label} scrolls its main content sideways" + end +end diff --git a/test/system/declare_and_pay_bill_test.rb b/test/system/declare_and_pay_bill_test.rb new file mode 100644 index 000000000..009751a79 --- /dev/null +++ b/test/system/declare_and_pay_bill_test.rb @@ -0,0 +1,124 @@ +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 diff --git a/test/system/find_my_bills_test.rb b/test/system/find_my_bills_test.rb new file mode 100644 index 000000000..cb75f1fe2 --- /dev/null +++ b/test/system/find_my_bills_test.rb @@ -0,0 +1,45 @@ +require "application_system_test_case" + +class FindMyBillsTest < ApplicationSystemTestCase + 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 + @account = accounts(:depository) + end + + test "an empty Bills page finds, reviews and confirms a detected bill" do + 3.times do |i| + @account.entries.create!( + date: Date.current - i.months, amount: 40, currency: "USD", + name: "GYM MEMBERSHIP", entryable: Transaction.new + ) + end + + visit bills_url + assert_text I18n.t("bills.index.empty.title") + + click_on I18n.t("bills.index.empty.action") + + # Detection ran synchronously; the review strip presents what it found. + # (Case-insensitive: the strip heading renders through CSS `uppercase`.) + assert_text(/#{Regexp.escape(I18n.t("recurring_transactions.suggested.title"))}/i) + assert_text "GYM MEMBERSHIP" + + # Confirm inside the GYM row specifically: fixture entries can produce + # other suggestions, and this test must not depend on their order. + row = find(:xpath, + "//div[contains(@class,'justify-between')][.//p[contains(normalize-space(),'GYM MEMBERSHIP')]]", + match: :first) + within(row) { click_on I18n.t("recurring_transactions.suggested.confirm") } + + # Confirmed on the page it was reviewed on: the bill is a worklist row now. + assert_text I18n.t("recurring_transactions.confirmed") + assert_current_path bills_path + + bill = @family.recurring_transactions.find_by!(name: "GYM MEMBERSHIP") + assert bill.active? + assert_operator bill.recurring_occurrences.count, :>, 0 + end +end diff --git a/test/system/recurring_transaction_frequency_test.rb b/test/system/recurring_transaction_frequency_test.rb new file mode 100644 index 000000000..cbaa972a0 --- /dev/null +++ b/test/system/recurring_transaction_frequency_test.rb @@ -0,0 +1,37 @@ +require "application_system_test_case" + +class RecurringTransactionFrequencyTest < ApplicationSystemTestCase + setup do + sign_in @user = users(:family_admin) + @user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => true)) + @recurring = recurring_transactions(:netflix_subscription) + end + + test "the frequency picker reveals the fields for the chosen preset and saves" do + visit edit_recurring_transaction_url(@recurring) + + # Monthly is the current cadence: the day group shows, the weekday group + # does not. + day_field = find("[data-presets*='monthly']", match: :first, visible: :all) + weekday_field = find("[data-presets='weekly,biweekly']", visible: :all) + assert day_field.visible? + assert_not weekday_field.visible? + + select I18n.t("recurring_transactions.frequency_presets.biweekly"), + from: I18n.t("recurring_transactions.form.frequency_label") + + assert weekday_field.visible? + assert_not day_field.visible? + + select I18n.t("date.day_names")[5], + from: I18n.t("recurring_transactions.form.frequency_weekday_label") + click_button I18n.t("recurring_transactions.form.submit") + + # The update redirects via the referer; the cadence label lives on the + # All bills management view now. + visit bills_url(view: "all") + assert_text I18n.t("recurring_transactions.frequency.biweekly", weekday: I18n.t("date.day_names")[5]) + assert_equal [ [ "weekly", 2, 5 ] ], + @recurring.reload.recurrence_rules.map { |rule| [ rule.frequency, rule.interval, rule.weekday ] } + end +end