Files
sure/app/views/recurring_transactions/index.html.erb
Guillem Arias Fauste 7c06fe6296 feat(recurring): allow marking transfers as recurring (#895) (#1589)
Refs #895, discussion #1224.

Adds a "Mark as recurring" entry point on the transfer detail drawer
that creates a `RecurringTransaction` carrying both source and
destination accounts. The recurring index, settings toggle
(`recurring_transactions_disabled`), and projected upcoming feed all
light up automatically once the data shape is there.

Schema:

* `destination_account_id` nullable FK to accounts. `on_delete: :cascade`
  matches #20251030172500's precedent for accounts FKs. The existing
  `account_id` FK is widened to cascade in the same migration so
  Family destruction with a recurring transfer doesn't FK-violate.
* Two predicate-partitioned partial unique indexes per shape:
  non-transfer rows (`destination_account_id IS NULL`, original
  5-column shape preserved) and transfer rows (6-column shape
  including the destination). Postgres treats NULLs as distinct in
  unique indexes, so widening would have broken non-transfer dedupe.
* Two CHECK constraints enforcing transfer invariants in PostgreSQL:
  `chk_recurring_txns_transfer_requires_source` (destination implies
  source) and `chk_recurring_txns_transfer_distinct_accounts`
  (destination cannot equal source). Per CLAUDE.md "Enforce null
  checks, unique indexes, and simple validations in the database
  schema for PostgreSQL".
* `Account` gains an `inbound_recurring_transfers` inverse so the
  destroy chain reaches both ends.

Controller / behaviour:

* `transfers#mark_as_recurring` mirrors `transactions#mark_as_recurring`:
  i18n flashes (4 new keys: transfer_marked_as_recurring,
  transfer_already_exists, transfer_creation_failed,
  transfer_feature_disabled), `respond_to format.html`,
  `redirect_back_or_to transactions_path`, server-side gate on
  `recurring_transactions_disabled?`, and rescue both `RecordInvalid`
  and `RecordNotUnique` for the race window between the dedupe
  `find_by` and `create_from_transfer`. The `StandardError` rescue
  now logs the exception (class, message, transfer/family/user ids)
  before surfacing the generic flash so production failures aren't
  context-less.
* `RecurringTransaction.accessible_by(user)` now requires
  destination_account_id (when present) to be in the user's
  accessible set, so a recurring transfer never leaks to a user
  without access to BOTH endpoints.
* Model validation gains a `destination_account.blank?` branch in
  `transfer_endpoints_consistent` so a dangling
  `destination_account_id` (referenced row destroyed) surfaces as a
  normal validation error instead of an FK exception on save.
* `Identifier` filter for transfer-kind transactions moved into SQL.

UI:

* Recurring index table and projected feed render transfer rows with
  the existing letter-avatar and the row's `name` field
  ("Transfer to {destination}"). No special pill or icon -- every row
  in `/recurring_transactions` is recurring by definition. Amount
  column on transfers uses `text-secondary` (muted-but-live) instead
  of the income/expense colour, since transfers are zero-net for the
  family.

Out of scope (called out in the PR body):

* Auto-creation of future Transfer rows on a schedule
  (discussion #1224's primary ask). Behaviour change vs the
  current projection-only model.
* Auto-identification of recurring transfer pairs in `Identifier`.
* Frequency model richer than `expected_day_of_month`.
* `Cleaner` for recurring transfers (issue #1590 tracks this).

Tests:

* `RecurringTransaction#transfer?` predicate (with / without
  destination).
* `transfer_endpoints_consistent`: rejects same source and
  destination, rejects dangling destination_account_id, rejects
  cross-family destination.
* `RecurringTransaction.create_from_transfer` happy path;
  multi-currency variant stores source-side currency.
* `projected_entry` exposes source / destination on transfer rows.
* `Identifier` skips transfer-kind transactions; creates a pattern
  from expense halves while ignoring co-resident transfer halves.
* Destroying the destination account cascades to inbound recurring
  transfers (FK + AR association).
* Unique partial index still de-duplicates non-transfer rows after
  the destination_account_id widening.
* `transfers#mark_as_recurring` happy path, idempotent on second
  call, rejected when `recurring_transactions_disabled`.

Suite: 3261 / 0 / 0 / 24 on the latest upstream/main. Lint clean.
Brakeman clean.

Signed-off-by: Guillem Arias Fauste <gariasf@proton.me>
2026-05-12 00:37:47 +02:00

191 lines
9.9 KiB
Plaintext

<%= content_for :page_title, t("recurring_transactions.title") %>
<%= content_for :page_actions do %>
<% unless @family.recurring_transactions_disabled? %>
<%= render DS::Menu.new do |menu| %>
<% menu.with_item(
variant: "button",
text: t("recurring_transactions.cleanup_stale"),
href: cleanup_recurring_transactions_path,
method: :post,
icon: "trash-2") %>
<% end %>
<%= render DS::Link.new(
text: t("recurring_transactions.identify_patterns"),
icon: "search",
variant: "outline",
href: identify_recurring_transactions_path,
method: :post
) %>
<% end %>
<% end %>
<div class="space-y-4 flex flex-col">
<div class="bg-container rounded-xl shadow-border-xs p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-primary"><%= t("recurring_transactions.settings.enable_label") %></p>
<p class="text-xs text-secondary"><%= t("recurring_transactions.settings.enable_description") %></p>
</div>
<%= form_with url: update_settings_recurring_transactions_path, method: :patch, data: { turbo_frame: "_top", controller: "auto-submit-form" } do |f| %>
<%= f.hidden_field :recurring_transactions_disabled, value: @family.recurring_transactions_disabled? ? "false" : "true" %>
<%= render DS::Toggle.new(
id: "recurring_transactions_enabled",
name: "toggle_display",
checked: !@family.recurring_transactions_disabled?,
data: { auto_submit_form_target: "auto" }
) %>
<% end %>
</div>
</div>
<% unless @family.recurring_transactions_disabled? %>
<div class="p-4 bg-container-inset border border-secondary rounded-lg">
<div class="flex items-start gap-2">
<%= icon "info", class: "w-5 h-5 text-link mt-0.5 flex-shrink-0" %>
<div>
<p class="text-sm font-medium text-primary mb-2"><%= t("recurring_transactions.info.title") %></p>
<p class="text-xs text-secondary mb-2"><%= t("recurring_transactions.info.manual_description") %></p>
<p class="text-xs text-secondary mb-1"><%= t("recurring_transactions.info.automatic_description") %></p>
<ul class="list-disc list-inside text-xs text-secondary space-y-0.5 ml-2">
<% t("recurring_transactions.info.triggers").each do |trigger| %>
<li><%= trigger %></li>
<% end %>
</ul>
</div>
</div>
</div>
<div class="bg-container rounded-xl shadow-border-xs p-4">
<% if @recurring_transactions.empty? %>
<div class="text-center py-12">
<div class="flex justify-center text-secondary mb-4">
<%= icon "repeat", size: "xl" %>
</div>
<p class="text-primary font-medium mb-2"><%= t("recurring_transactions.empty.title") %></p>
<p class="text-secondary text-sm mb-4"><%= t("recurring_transactions.empty.description") %></p>
<%= render DS::Link.new(
text: t("recurring_transactions.identify_patterns"),
icon: "search",
variant: "primary",
href: identify_recurring_transactions_path,
method: :post
) %>
</div>
<% else %>
<div class="rounded-xl bg-container-inset space-y-1 p-1">
<div class="flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-secondary uppercase">
<p><%= t("recurring_transactions.title") %></p>
<span class="text-subdued">&middot;</span>
<p><%= @recurring_transactions.count %></p>
</div>
<div class="bg-container rounded-lg shadow-border-xs overflow-x-auto">
<table class="w-full">
<thead>
<tr class="text-xs uppercase font-medium text-secondary border-b border-divider">
<th class="text-left py-3 px-2"><%= t("recurring_transactions.table.merchant") %></th>
<th class="text-left py-3 px-2"><%= t("recurring_transactions.table.amount") %></th>
<th class="text-left py-3 px-2"><%= t("recurring_transactions.table.expected_day") %></th>
<th class="text-left py-3 px-2"><%= t("recurring_transactions.table.next_date") %></th>
<th class="text-left py-3 px-2"><%= t("recurring_transactions.table.last_occurrence") %></th>
<th class="text-left py-3 px-2"><%= t("recurring_transactions.table.status") %></th>
<th class="text-right py-3 px-2"><%= t("recurring_transactions.table.actions") %></th>
</tr>
</thead>
<tbody>
<% @recurring_transactions.each do |recurring_transaction| %>
<tr class="border-b border-subdued hover:bg-surface-hover <%= "opacity-60" unless recurring_transaction.active? %>">
<td class="py-3 px-2 text-sm">
<div class="flex items-center gap-2">
<% 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 %>
<span class="text-primary font-medium"><%= recurring_transaction.merchant.name %></span>
<% else %>
<%= render DS::FilledIcon.new(
variant: :text,
text: recurring_transaction.name,
size: "sm",
rounded: true
) %>
<span class="text-primary font-medium"><%= recurring_transaction.name %></span>
<% end %>
<% if recurring_transaction.manual? %>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-tint-10 text-link">
<%= t("recurring_transactions.badges.manual") %>
</span>
<% end %>
</div>
</td>
<% if recurring_transaction.transfer? %>
<td class="py-3 px-2 text-sm font-medium privacy-sensitive text-secondary">
<%= format_money(recurring_transaction.amount_money.abs) %>
</td>
<% else %>
<td class="py-3 px-2 text-sm font-medium privacy-sensitive <%= recurring_transaction.amount.negative? ? "text-success" : "text-primary" %>">
<% if recurring_transaction.manual? && recurring_transaction.has_amount_variance? %>
<div class="inline-flex items-center gap-1 cursor-help group" title="<%= t("recurring_transactions.amount_range", min: format_money(-recurring_transaction.expected_amount_min_money), max: format_money(-recurring_transaction.expected_amount_max_money)) %>">
<span class="text-xs text-secondary group-hover:text-primary transition-colors">~</span>
<span class="border-b border-dashed border-subdued group-hover:border-primary transition-colors privacy-sensitive"><%= format_money(-recurring_transaction.expected_amount_avg_money) %></span>
</div>
<% else %>
<%= format_money(-recurring_transaction.amount_money) %>
<% end %>
</td>
<% end %>
<td class="py-3 px-2 text-sm text-secondary">
<%= t("recurring_transactions.day_of_month", day: recurring_transaction.expected_day_of_month) %>
</td>
<td class="py-3 px-2 text-sm text-secondary">
<%= l(recurring_transaction.next_expected_date, format: :short) %>
</td>
<td class="py-3 px-2 text-sm text-secondary">
<%= l(recurring_transaction.last_occurrence_date, format: :short) %>
</td>
<td class="py-3 px-2 text-sm">
<% if recurring_transaction.active? %>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-50 text-success">
<%= t("recurring_transactions.status.active") %>
</span>
<% else %>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-surface-inset text-primary">
<%= t("recurring_transactions.status.inactive") %>
</span>
<% end %>
</td>
<td class="py-3 px-2 text-sm text-right">
<div class="flex items-center justify-end gap-2">
<%= 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 %>
</div>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
<% end %>
</div>
<% end %>
</div>