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>