# Internal transfer pairing and paired-ledger contract

Use this reference when implementing internal transfers, counterbookings, paired imports, or any workflow where two imported records must become one financial event.

## Core invariants

- Reuse the existing import candidates, account registry, ledger, audit log, and confirm path. Do not create a parallel import or ledger engine.
- Preserve raw imports unchanged. Pairing is a derived proposal; confirmation is a separate audited decision.
- Store signed original amounts using `Decimal`/Money semantics. A magnitude-only candidate is insufficient for reliable matching.
- A confirmed internal transfer has zero budget effect and must not appear as either consumption expense or income.
- Auto-detection may prefill a proposal; auto-confirm stays off unless a separately approved, transparent learning policy exists.

## Minimal pair model

Persist a stable pair ID plus:

- source and target candidate IDs;
- non-sensitive source/target account identities;
- signed original amounts, currency, booking/value dates;
- status: `proposed`, `confirmed`, `rejected`, `superseded`, `unmatched`;
- quality, evidence, reason codes, creation/decision metadata;
- confirmed transfer ID, optional successor pair ID, and explicit zero budget effect.

Use deterministic IDs for the pair, its two ledger sides, and the resulting transfer. Add uniqueness guards for confirmed candidate use. Database indices covering only “source” and “target” separately are not enough: under the write lock, also exclude a candidate already confirmed in either role.

## Deterministic matcher

Required evidence:

1. both candidates map to known active own accounts;
2. accounts differ;
3. signs are opposite;
4. absolute Decimal amounts are exactly equal;
5. currencies match;
6. booking/value dates fall within a small explicit window.

Account/link identity may strengthen the match. Source, import run, reference, merchant, and memo are secondary evidence only. Text alone must never confirm or veto a transfer; a misleading merchant label must not defeat a strong counterbooking.

Search across banks, source types, and import runs. Match outflow-to-inflow, then verify uniqueness in both directions. If either side has multiple equally plausible counterparts, create an explainable ambiguous/unmatched review item and select none. When a later counterbooking creates a better deterministic proposal, supersede the earlier open derivation rather than mutating history invisibly.

## Atomic confirm and reject

For SQLite, acquire `BEGIN IMMEDIATE` when no outer transaction exists; otherwise use a savepoint. Inside the same transaction:

1. reload the pair and both candidates;
2. revalidate signs, amount, currency, dates, account mapping, open status, and competing confirmed pairs;
3. insert exactly two signed transfer ledger rows;
4. insert exactly one transfer relation;
5. consume/link both candidates with conditional updates;
6. mark the pair confirmed with budget effect zero;
7. write a redacted audit entry;
8. commit/release only after every step succeeds.

Return the same transfer on repeated confirm. Conditional state updates plus deterministic IDs and uniqueness constraints protect against double-clicks and competing requests.

Reject is also a concurrent write decision: lock and re-read before a conditional status update. It must create no ledger rows, clear active pair links, and return affected candidates to an explainable review state while retaining signed direction metadata. Otherwise rejected candidates become stranded and cannot be reclassified or paired later. Repeated reject is idempotent; confirmed pairs cannot be rejected.

## API, UI, and privacy

- Extend the existing review surface where possible.
- Render the pair as one review item with direction, amount/currency, account labels, evidence/uncertainty, zero budget effect, and explicit confirm/reject consequences.
- Never expose full IBANs, wallet addresses, raw import filenames, local paths, or internal diagnostics in normal pair responses or UI.
- Preserve fail-closed write security; adding a pair endpoint must not create anonymous or tailnet write access.
- A legacy single-candidate transfer-confirm path must delegate to a valid proposed pair and reject unmatched candidates.

## Required verification matrix

Cover synthetic cases for:

- misleading merchant text with a strong opposite counterbooking;
- separate banks and import runs;
- missing then later-arriving counterpart;
- ambiguity with no automatic choice;
- same sign/account, amount mismatch, currency mismatch, and unknown account;
- atomic confirm, zero budget effect, and exactly one transfer;
- repeat and competing confirm;
- reject, supersede, and reject/confirm race protection;
- no income/expense leakage;
- API/OpenAPI, write security, redacted audit/API/UI;
- one focused UI test proving both sides render as a single item.

Prefer an additive migration. Before productive use, rollback may be a commit revert; after use, require a runtime-DB backup and a deliberate data/schema rollback plan.