Files
sure/app/views/recurring_transactions/_form.html.erb
T
Brandon fe0d27471d feat(bills): the bills pages, calendar feed and in-page AI helpers (#3202)
* feat(bills): the bills pages, calendar feed and in-page AI helpers

Second of three chunks carved out of #3083, stacked on the schema and domain
core. This is everything a user sees and clicks. The whole surface sits behind
the preview flag, so it is unreachable until someone opts in.

Pages, all under one nav entry:

- the pay run, a month calendar, the full bills table, and the paycheck planner
- a detail drawer per bill, with payment history, price changes and cost
  analytics
- create and edit flows for bills, subscriptions, installment plans and income

The overview marks pay periods inside the month, so a weekly paycheck no longer
reads as one undifferentiated month of bills. Markers appear only when income
actually subdivides the month, which means monthly and undeclared income render
exactly as before and there is no new setting to configure.

Navigation and design system:

- one preview-gated nav item shared by the desktop rail and the mobile bar
- DS::Sparkline for payment-history charts, replacing raw SVG in views
- status badges render through DS::Pill rather than hand-rolled spans
- the suggestions panel is a disclosure that remembers being collapsed, per
  device, the way privacy mode and the sidebar width already do
- every surface reflows to phone widths without horizontal scroll

Calendar feed: a signed ICS feed per family, served sessionless by token, with a
reset that revokes previously shared URLs.

In-page AI helpers: smart fill on the bill form and a smart configuration
proposal on an existing bill, each reading a bounded slice of charge history.
Provider-side prompt assembly sits behind the existing LlmConcept interface,
with an implementation for each of the two providers. These belong here rather
than with the assistant tools because they are buttons on these pages and lean
on the provider suggester, not on the tool registry.

Suite 7,776 runs green apart from the pre-existing passkey-session flake, which passes standalone. Rubocop clean, eager loading verified. The hosting guide for the feature ships here rather than with the schema, since its instructions walk pages this PR introduces.

* Render the suggested strip through DS::Disclosure

The hand-rolled details pair predates the component. The card_inset
variant is the same shape, so the strip now inherits the design system
chrome, and the persisted-disclosure controller rides along unchanged.

* Route the remaining hand-rolled chips through the design system

The subscription-state chips, rule-match chips and match-reason chips
become DS::Pill, with the state chips extracted to one shared partial so
the drawer and the summary tab stop carrying copy-pasted markup. The AI
prompt chips become DS::Button and the bills-index filter becomes
DS::SearchInput, both of which this PR already uses elsewhere for the
same shapes.

* Fix erb_lint whitespace offenses in bills views

* Address the post-ready review round

* Require a writable destination account and gate the feed on preview

* Reject an unresolvable declared account out loud
2026-09-02 02:08:58 +02:00

179 lines
8.6 KiB
ERB

<%# locals: (recurring_transaction:, sibling_count: 0) %>
<%# Income mode reshapes the dialog: a paycheck has a source and a payday,
not a biller portal and an autopay toggle. %>
<% income = recurring_transaction.is_income.present? || recurring_transaction.typed_income? %>
<%= styled_form_with model: recurring_transaction, class: "space-y-4" do |f| %>
<section class="space-y-4">
<% if recurring_transaction.errors.any? %>
<%= render "shared/form_errors", model: recurring_transaction %>
<% end %>
<%# What a bill is called, what it costs and where it is paid from all
outlive their first version, so they are editable. They used to be
create-only, which left delete-and-recreate as the only way to record
a price rise, and that takes the payment history with it. %>
<%# Detected bills carry a merchant and no name of their own, so the field
is seeded from whatever the bill is currently called. Left as the bare
attribute it renders empty on most bills, and being required, browsers
then refuse to submit the form at all. %>
<%= f.text_field :name,
label: income ? t(".income_name_label") : t(".name_label"),
placeholder: income ? t(".income_name_placeholder") : t(".name_placeholder"),
value: recurring_transaction.display_name,
autofocus: true,
required: true %>
<% if recurring_transaction.new_record? %>
<div class="grid grid-cols-2 gap-3">
<%= f.number_field :amount,
label: income ? t(".income_amount_label") : t(".amount_label"),
value: recurring_transaction.amount&.abs,
min: 0,
step: 0.01,
required: true %>
<%= f.date_field :first_due_on,
label: income ? t(".income_due_on_label") : t(".first_due_on_label"),
value: recurring_transaction.first_due_on,
required: true %>
</div>
<% else %>
<%# first_due_on seeds the schedule and does nothing once the series
exists, so editing when a bill falls due happens in the frequency
picker below rather than here. %>
<div class="space-y-2">
<%# Income stores its amount negative; the field edits the magnitude
and the update path re-applies the sign. %>
<%= f.number_field :amount,
label: income ? t(".income_amount_label") : t(".amount_label"),
value: recurring_transaction.amount&.abs,
min: 0,
step: 0.01,
required: true %>
<p class="text-xs text-secondary"><%= income ? t(".income_amount_edit_hint") : t(".amount_edit_hint") %></p>
</div>
<% end %>
<%= f.collection_select :account_id,
Current.user.accessible_accounts.visible.alphabetically,
:id, :name,
{ label: income ? t(".income_account_label") : t(".account_label"), include_blank: t(".account_blank") } %>
<%# Which kind this is was decided by the entry point that opened the
dialog, so the form only has to carry the answer. Offering it as a
checkbox here asked a question the dialog had already answered in its
own title, and ticking it did not reshape anything: you filled in
bill-shaped labels, pressed Save bill, and got income. %>
<% if recurring_transaction.new_record? && income %>
<%= hidden_field_tag "recurring_transaction[is_income]", "1" %>
<% end %>
<%# Name, amount, due date and how often are the whole job for most
bills. How often used to render below the payment link and the
autopay toggle, so the fourth thing anyone needs sat under two
things most people never set. %>
<div class="space-y-2" data-controller="frequency-fields">
<%= f.select :frequency_preset,
frequency_preset_options(recurring_transaction),
{ label: income ? t(".income_frequency_label") : t(".frequency_label") },
{ data: { frequency_fields_target: "preset", action: "change->frequency-fields#update" } } %>
<% if recurring_transaction.persisted? %>
<div class="grid grid-cols-2 gap-3">
<div data-frequency-fields-target="group" data-presets="monthly,semimonthly,quarterly,semiannual,annual" class="hidden">
<%= f.select :frequency_day_of_month, frequency_day_options, { label: t(".frequency_day_label") } %>
</div>
<div data-frequency-fields-target="group" data-presets="semimonthly" class="hidden">
<%= f.select :frequency_second_day_of_month, frequency_day_options, { label: t(".frequency_second_day_label") } %>
</div>
<div data-frequency-fields-target="group" data-presets="weekly,biweekly" class="hidden">
<%= f.select :frequency_weekday, frequency_weekday_options, { label: t(".frequency_weekday_label") } %>
</div>
<div data-frequency-fields-target="group" data-presets="annual" class="hidden">
<%= f.select :frequency_month_of_year, frequency_month_options, { label: t(".frequency_month_label") } %>
</div>
</div>
<p class="text-xs text-secondary"><%= income ? t(".income_frequency_hint") : t(".frequency_hint") %></p>
<% else %>
<%# A new series' day details derive from its first date. %>
<p class="text-xs text-secondary"><%= income ? t(".income_frequency_new_hint") : t(".frequency_new_hint") %></p>
<% end %>
</div>
<% if recurring_transaction.persisted? && %w[bill subscription installment other].include?(recurring_transaction.bill_type) %>
<div class="grid grid-cols-2 gap-3" data-controller="frequency-fields">
<%= f.select :bill_type,
%w[bill subscription installment other].map { |type| [ t("bills.all.types.#{type}"), type ] },
{ label: t(".bill_type_label") },
{ data: { frequency_fields_target: "preset", action: "change->frequency-fields#update" } } %>
<%= f.collection_select :category_id,
Current.family.categories.alphabetically,
:id, :name,
{ label: t(".category_label"), include_blank: t(".category_blank") } %>
<div data-frequency-fields-target="group" data-presets="installment" class="hidden col-span-2">
<%= f.number_field :end_after_count,
label: t(".installment_count_label"),
min: 2, step: 1 %>
<p class="text-xs text-secondary mt-1"><%= t(".installment_count_hint") %></p>
</div>
</div>
<% if recurring_transaction.typed_subscription? %>
<div class="space-y-2">
<div class="grid grid-cols-3 gap-3">
<%= f.date_field :renews_on, label: t(".renews_on_label") %>
<%= f.date_field :trial_ends_on, label: t(".trial_ends_on_label") %>
<%= f.date_field :cancelled_on, label: t(".cancelled_on_label") %>
</div>
<p class="text-xs text-secondary"><%= t(".cancelled_on_hint") %></p>
</div>
<% end %>
<% end %>
<%= render DS::Disclosure.new(title: t(".more_options"), align: "left") do %>
<div class="space-y-4 pt-2">
<% unless income %>
<div class="space-y-2">
<%= f.text_field :payment_url,
label: t(".payment_url_label"),
placeholder: t(".payment_url_placeholder"),
inputmode: "url" %>
<p class="text-xs text-secondary"><%= t(".payment_url_hint") %></p>
</div>
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-sm font-medium text-primary"><%= t(".autopay_label") %></p>
<p class="text-xs text-secondary"><%= t(".autopay_hint") %></p>
</div>
<%= f.toggle :autopay %>
</div>
<% end %>
<div class="space-y-2">
<%= f.text_area :notes,
label: t(".notes_label"),
placeholder: income ? t(".income_notes_placeholder") : t(".notes_placeholder"),
rows: 2 %>
<p class="text-xs text-secondary"><%= income ? t(".income_notes_hint") : t(".notes_hint") %></p>
</div>
</div>
<% end %>
<% if sibling_count.positive? %>
<label class="flex items-start gap-2 cursor-pointer">
<%= check_box_tag :apply_to_siblings, "1", false, class: "checkbox checkbox--light mt-0.5" %>
<span class="text-sm text-primary">
<%= t(".apply_to_siblings", count: sibling_count, name: recurring_transaction.display_name) %>
</span>
</label>
<% end %>
</section>
<section>
<%= f.submit income ? t(".submit_income") : t(".submit") %>
</section>
<% end %>