Files
sure/app/views/transfers/show.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

108 lines
5.0 KiB
Plaintext

<%= render DS::Dialog.new(frame: "drawer", responsive: true) do |dialog| %>
<% dialog.with_header(custom_header: true) do %>
<div class="flex items-start justify-between gap-4">
<div>
<h3 class="font-medium flex items-center gap-2">
<span class="text-2xl text-primary privacy-sensitive">
<%= format_money @transfer.amount_abs %>
</span>
<span class="text-lg text-secondary">
<%= @transfer.amount_abs.currency.iso_code %>
</span>
<%= icon "arrow-left-right", size: "sm", class: "text-secondary" %>
</h3>
<span class="text-sm text-secondary">
<%= @transfer.name %>
</span>
</div>
<%= dialog.close_button %>
</div>
<% end %>
<% dialog.with_body do %>
<% dialog.with_section(title: t(".overview"), open: true) do %>
<div class="pb-4 px-3 pt-2 text-sm space-y-3 text-primary">
<div class="space-y-3">
<dl class="flex items-center gap-2 justify-between">
<dt class="text-secondary">From</dt>
<dd class="flex items-center gap-2 font-medium">
<%= render "accounts/logo", account: @transfer.from_account, size: "sm" %>
<%= link_to @transfer.from_account.name, account_path(@transfer.from_account), data: { turbo_frame: "_top" } %>
</dd>
</dl>
<dl class="flex items-center gap-2 justify-between">
<dt class="text-secondary">Date</dt>
<dd class="font-medium"><%= l(@transfer.outflow_transaction.entry.date, format: :long) %></dd>
</dl>
<dl class="flex items-center gap-2 justify-between">
<dt class="text-secondary">Amount</dt>
<dd class="font-medium text-red-500 privacy-sensitive"><%= format_money @transfer.outflow_transaction.entry.amount_money * -1 %></dd>
</dl>
</div>
<%= render "shared/ruler", classes: "my-2" %>
<div class="space-y-3">
<dl class="flex items-center gap-2 justify-between">
<dt class="text-secondary">To</dt>
<dd class="flex items-center gap-2 font-medium">
<%= render "accounts/logo", account: @transfer.to_account, size: "sm" %>
<%= link_to @transfer.to_account.name, account_path(@transfer.to_account), data: { turbo_frame: "_top" } %>
</dd>
</dl>
<dl class="flex items-center gap-2 justify-between">
<dt class="text-secondary">Date</dt>
<dd class="font-medium"><%= l(@transfer.inflow_transaction.entry.date, format: :long) %></dd>
</dl>
<dl class="flex items-center gap-2 justify-between">
<dt class="text-secondary">Amount</dt>
<dd class="font-medium text-green-500 privacy-sensitive">+<%= format_money @transfer.inflow_transaction.entry.amount_money * -1 %></dd>
</dl>
</div>
</div>
<% end %>
<% dialog.with_section(title: t(".details")) do %>
<%= styled_form_with model: @transfer,
data: { controller: "auto-submit-form" }, class: "space-y-2" do |f| %>
<% if @transfer.categorizable? %>
<%= f.collection_select :category_id, @categories.alphabetically, :id, :name, { label: "Category", include_blank: "Uncategorized", selected: @transfer.outflow_transaction.category&.id }, "data-auto-submit-form-target": "auto" %>
<% end %>
<%= f.text_area :notes,
label: t(".note_label"),
placeholder: t(".note_placeholder"),
rows: 5,
"data-auto-submit-form-target": "auto" %>
<% end %>
<% end %>
<% dialog.with_section(title: t(".settings")) do %>
<div class="pb-4">
<% if @can_mark_as_recurring_transfer %>
<div class="flex items-center justify-between gap-2 p-3">
<div class="text-sm space-y-1">
<h4 class="text-primary"><%= t(".mark_recurring_title") %></h4>
<p class="text-secondary"><%= t(".mark_recurring_subtitle") %></p>
</div>
<%= render DS::Button.new(
text: t(".mark_recurring"),
variant: "outline",
icon: "repeat",
href: mark_as_recurring_transfer_path(@transfer),
method: :post,
frame: "_top"
) %>
</div>
<% end %>
<div class="flex items-center justify-between gap-2 p-3">
<div class="text-sm space-y-1">
<h4 class="text-primary"><%= t(".delete_title") %></h4>
<p class="text-secondary"><%= t(".delete_subtitle") %></p>
</div>
<%= button_to t(".delete"),
transfer_path(@transfer),
method: :delete,
class: "rounded-lg px-3 py-2 whitespace-nowrap text-red-500 text-sm
font-medium border border-secondary",
data: { turbo_confirm: true, turbo_frame: "_top" } %>
</div>
</div>
<% end %>
<% end %>
<% end %>