* fix(transfers): prevent duplicate creation on double-submit
TransfersController#create -> Transfer::Creator had no protection
against a repeated form submission - a double-click, a browser retry,
or two near-simultaneous requests could each create a separate,
identical transfer (and its 2-4 underlying Entry/Transaction rows).
Adds a per-form idempotency key, the same approach already used for
TransactionsController#create: a UUID hidden field generated fresh on
page load, tagging the outflow/inflow (and fee, with a distinguishing
suffix since a fee leg shares its account with its primary leg) entries
via the existing entries(account_id, source, external_id) partial
unique index. A pre-check handles the sequential double-submit case;
rescue ActiveRecord::RecordNotUnique is the authoritative backstop for
genuine concurrent requests - the whole Transfer.transaction block
rolls back cleanly on conflict, so there's no risk of a half-created
transfer.
A same-day duplicate transfer can be legitimate (unlike a duplicate
valuation, see #3339/PR #3340), so this uses the same per-submission
token approach as #3334/PR #3338 rather than a natural-key DB
constraint.
Fixes#3341.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(transfers): store the idempotency key in its own column, isolate the retry with a savepoint
Same two review findings as PR #3338 (transactions) and #3340
(valuations), applied here since this branch shares the same
mechanism:
- Reusing external_id/source for the web-form idempotency token made
every leg of a manually-created transfer satisfy Entry#linked?,
incorrectly making it look provider-synced. Uses the same dedicated
entries.idempotency_key column added in
db/migrate/20260902180400_add_idempotency_key_to_entries.rb (cherry-picked
identically from PR #3338 - this branch depends on that migration;
please merge #3338 first, or merge this after it lands so the
duplicate migration file is a no-op).
- Transfer::Creator now wraps the actual save in
Transfer.transaction(requires_new: true) so a RecordNotUnique only
rolls back to a savepoint rather than aborting any transaction the
caller might already be in, keeping the rescue's retry lookup usable
(mirrors the fix already applied to Account::ReconciliationManager
in PR #3340).
Added a regression test asserting neither leg of a transfer created
via this path is linked? or has external_id/source set.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(transfers): distinct idempotency key per leg, rebuild invalid index on retry
Two more review findings:
- CodeRabbit: the form doesn't prevent selecting the same account as
both source and destination. The outflow and inflow legs shared the
bare idempotency key, so on that same-account path they'd collide
with each other under the same account-scoped unique index (as
would both fee legs, which shared a single "-fee" suffix). Every
leg now gets a distinct, role-specific suffix (outflow stays bare -
that's what find_existing_transfer looks up by - inflow/source_fee/
destination_fee each get their own).
- Codex (same finding already fixed once for entries.idempotency_key's
sibling migration, recurring here since this branch carries an
identical copy): index_exists? alone doesn't distinguish a valid
index from an INVALID one left behind by an interrupted CREATE INDEX
CONCURRENTLY, so a retry after a failed build would short-circuit
and record the migration as applied while the constraint was still
missing. Now checks pg_index.indisvalid directly before deciding to
skip.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(transfers): clear idempotency key on destroy so a retry doesn't 500
Codex flagged that Transfer#destroy! (used by reject!) preserves the
outflow/inflow entries but not the Transfer join row - a retried
create request with the same idempotency_key would find no Transfer
via find_existing_transfer, attempt another insert, hit the stale
entry's unique key, and re-raise RecordNotUnique instead of finding
a match. Clear the key on the surviving entries when a transfer is
destroyed.
Also adds a regression test for the CodeRabbit-flagged per-leg key
collision concern (already fixed by role-specific suffixes in the
prior commit) to lock in that fee legs never share a key with their
primary leg.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(transfers): verify idempotency key matches the request, fix stale doc comment
jjmata review on #3342:
- find_existing_transfer matched on idempotency_key + source_account only,
so a stale key from a cached form could silently return a different,
older transfer instead of creating the one actually requested. Now
verifies destination account, date, and amount before treating a key
match as the same request; a genuine mismatch surfaces as a new
StaleIdempotencyKeyError (422 + message) instead of a false success or
a raw 500.
- Removed a comment claiming parity with a
TransactionsController#new_transaction_idempotency_key method that
doesn't exist in the codebase.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(transfers): preserve from_account_id on error, match fees/exchange rate in idempotency check
coderabbitai review on #3342:
- All three create rescue blocks (exchange rate unavailable, invalid date,
stale idempotency key) failed to set @from_account_id, so the re-rendered
form lost the user's selected source account.
- matches_request? only compared accounts/date/outflow amount, so a retry
with the same key but a different exchange_rate or fee would be reported
as success while silently keeping the old inflow amount and fee entries.
Now recomputes the request's effective inflow amount and compares derived
fee totals too; a mismatch raises StaleIdempotencyKeyError like other
stale-key mismatches instead of silently returning the old transfer.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---------
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Gerald <248542187+gfr-free@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
* Add tags support for transfer transactions
Expose TagSelect on transfer create/edit so users can classify
fund movements; apply the same family-scoped tags to both sides.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Require annotate permission on both transfer sides for tags
Prevent tagging a read-only destination transaction when the user
only has write access on the outflow account.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Restore transfer tag selections on create form errors
* Localize transfer create validation error messages
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
- Show principal-only transfer amounts on both sides with separate fee and total lines (fixes inconsistent gross/net convention)
- Derive displayed fee amounts from fee_transactions entries (single source of truth) instead of stored columns
- Remove stored source_fee_amount/destination_fee_amount columns from transfers table
- Add foreign key for transactions.transfer_id -> transfers.id (replaces invalid CHECK subquery)
- Move destination fee line inside destination side div for consistent layout
- Remove orphaned view_fee_transaction locale keys from 7 locale files
- Rebuild schema.rb from origin/main to eliminate unrelated column reordering churn
Entries now hold principal only (no fee baked into amounts). Fee transactions created as standard kind with Fees category. Transfer#amount_abs returns principal from new amount column. Update handler recomputes entries and fee transactions on edit. Remove dead source_principal/destination_principal helpers. Schema regenerated cleanly with only transfer fee columns.
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>
* Transfer data model migration
* Transfers and payment modeling and UI improvements
* Fix CI
* Transfer matching flow
* Better UI for transfers
* Auto transfer matching, approve, reject flow
* Mark transfers created from form as confirmed
* Account filtering
* Excluded rejected transfers from calculations
* Calculation tweaks with transfer exclusions
* Clean up migration
* Move Transfer to Account namespace
* Fix partial resolution due to namespacing plurality
* Make category and tag controllers consistent with namespacing convention
* Update stale partial reference
* Add transfer model and clean up family snapshot fixtures
* Ignore transfers in income and expense snapshots
* Add transfer validations
* Implement basic transfer matching UI
* Fix merge conflicts
* Add missing translations
* Tweak selection states for transfer types
* Add missing i18n translation