## Outcome Schema 49 can support a bounded Sprint 20D implementation **without migration**: detect existing Raiffeisen outflows read-only, preview selected True-Wealth deposits, confirm them into the existing canonical `transactions` table, update cashflow coverage only with explicit attestation, and audit all decisions. Do **not** invoke production performance activation. ## Recommended implementation map ### 1. Read-only detector Add a focused service, preferably: - `src/jarvis_finance/services/truewealth_payment_detection.py` - `detect_raiffeisen_truewealth_payments(conn, coverage_from, coverage_to)` Read from `budget_transaction_candidates`, optionally joining: - `budget_transactions` through `source_candidate_id` - `household_import_batches` through `household_batch_id` - `budget_accounts` through `account_source` Conservative eligibility: - `source_type = 'raiffeisen_bank'` - CHF only - negative `signed_amount_original`; never infer direction from unsigned `amount_original` - bounded ISO date range - deterministic normalized marker for `TRUE WEALTH` / `TRUEWEALTH` - stable source identity available via `source_row_fingerprint` or legacy `raw_fingerprint` - exclude pending, ignored, superseded, duplicate, and already-consumed rows - distinguish: - `eligible`: confirmed household batch or confirmed linked budget transaction - `review_required`: legacy candidate-only evidence - `blocked`: missing sign, identity, currency, date, or ambiguous destination A Raiffeisen outflow becomes a True-Wealth `external_deposit`. Positive Raiffeisen rows must not be silently interpreted as withdrawals in this contract. Do not reuse `propose_transfer_pairs()` for this. It requires two mapped household budget accounts and opposite legs; the True-Wealth performance account is deliberately outside that household-account model. ### 2. Detection preview contract Suggested endpoint: - `POST /api/portfolio/performance/truewealth-detected-payments/preview` Add it to `READ_ONLY_POST_PATHS`. Request should contain only: - `coverage_from`, `coverage_to` - selected opaque `detection_ids` - explicit per-collision dedupe decisions, if any - completeness attestation fields The browser must not submit authoritative dates or amounts. Reconstruct those server-side from the existing imported rows. Response: - `preview_id` - `input_fingerprint` - opaque candidate rows: detection ID, date, deposit direction, amount/currency, evidence status, duplicate status, reason codes - `expected_changes`: - canonical cashflows - dedupe links/no-ops - coverage record change - valuation snapshots = `0` - blockers and `can_confirm` Fingerprint the complete relevant source rows, current matching canonical cashflows, True-Wealth account mapping, current coverage, selected IDs, and dedupe decisions. ### 3. Confirm contract Suggested endpoint: - `POST /api/portfolio/performance/truewealth-detected-payments/confirm` Require: - `confirm: true` - `confirmation_id` - `preview_id` - `input_fingerprint` - same selection and dedupe decisions as preview Confirm sequence: 1. Check prior audit for the `confirmation_id`; reject reuse with a different request fingerprint. 2. `BEGIN IMMEDIATE`. 3. Re-run detection and fingerprint under the lock. 4. Resolve the True-Wealth account through the existing role: - `performance_scope_classifications.classification_role = 'canonical_truewealth_total_value'` 5. Materialize only approved, non-duplicate entries in `transactions`. 6. Apply explicit manual-collision decisions. 7. Update `performance_cashflow_coverage` only according to the attested scope. 8. Write a redacted audit. 9. Commit atomically. Keep this separate from `/portfolio/performance/activation/confirm`; Sprint 20D should prepare evidence, not activate production performance. ## Exact reusable schema ### Imported bank evidence `budget_transaction_candidates`: - identity/provenance: `transaction_candidate_id`, `source_type`, `source_row_fingerprint`, `raw_fingerprint`, `logical_fingerprint`, `household_batch_id` - economics: `transaction_date`, `value_date`, `amount_original`, `signed_amount_original`, `currency_original` - private matching input: `description`, `merchant` - state: `status`, `classification`, `requires_review` - links: `confirmed_transaction_id`, `account_source` Existing uniqueness: - `ux_budget_candidates_household_source_row` - `ux_budget_candidates_household_logical` `household_import_batches`: - `batch_id`, `contract_version`, `preview_fingerprint`, `baseline_fingerprint`, `input_fingerprint`, `status`, `audit_id`, `confirmed_at` `budget_transactions`: - `budget_transaction_id`, `source_candidate_id`, `account_id` - `transaction_type`, `transaction_date`, `booking_date` - `amount_original`, `currency_original`, `amount_chf`, `fx_status` - `status`, `source_type` ### Canonical performance cashflows `transactions`: - `transaction_id`, `account_id` - `transaction_type`, `activity_kind` - `trade_date`, `booking_date`, `event_timestamp` - `gross_amount_original`, `net_amount_original`, `currency_original` - `fx_rate_to_chf`, `fx_source`, `fx_status` - `gross_amount_chf`, `net_amount_chf` - `source_type`, `source_id`, `external_transaction_id`, `row_hash`, `source_reference` - `is_confirmed`, `is_voided`, `quality_status` Relevant uniqueness already exists on: - `row_hash` - `external_transaction_id` `performance_cashflow_coverage`: - `account_id` primary key - `coverage_from`, `coverage_to`, `status`, `source`, `audit_id`, `recorded_at` `audit_log`: - `source`, `action`, `entity_type`, `entity_id` - `old_values_json`, `new_values_json` - `user_text_note`, `confirmed`, `created_by`, timestamps ## Exact reusable functions From household/budget imports: - `household_import._normal_row()` — Raiffeisen normalization and signed amount - household source-row/logical fingerprint conventions - `household_import._baseline()` pattern — complete stale-preview protection - `household_import.preview_household_import()` / `confirm_household_import()` — redaction, Preview→Confirm, locked re-preview - `budget_imports._bank_candidate_decision()` — already recognizes True Wealth as `investment_transfer` - `budget_imports.seed_bank_transfer_candidates_from_rows()` — legacy candidate shape - Do not directly reuse private helpers across modules; extract small shared normalization/identity helpers if implementation starts. From Sprint 20C: - `_truewealth_account()` - `_normalise_truewealth_entries()` - `preview_truewealth_cashflow_period()` - `confirm_truewealth_cashflow_period()` - `set_performance_cashflow_coverage()` - `record_audit_event()` Best refactor: extract the locked canonical cashflow writer from `confirm_truewealth_cashflow_period()` so manual/CSV and detected-payment confirms share validation and persistence while retaining distinct request contracts. ## Idempotency and manual dedupe Use two identities: 1. **Source identity** Stable hash of contract version + imported source-row fingerprint. Store as `external_transaction_id` and `row_hash`. Exact replay is automatically idempotent. 2. **Economic collision key** True-Wealth account + date + direction + original amount/currency + CHF amount. Do **not** include `confirmation_id`, evidence reference, or source label. If the economic key matches an existing manually confirmed True-Wealth cashflow but source identity differs: - return `possible_manual_duplicate` - block confirmation by default - require an explicit per-row decision: - `link_existing`: no new cashflow; audit the detected ID → existing transaction ID relationship - `record_distinct`: insert because the owner confirms it is a separate same-day payment Audit those decisions under a stable detected entity ID so a later preview does not repeatedly ask about an already-linked source row. The current Sprint 20C `_truewealth_entry_identity()` includes FX/evidence provenance, so it is insufficient by itself for cross-channel manual-vs-detected dedupe. ## Coverage and activation safety Detection of Raiffeisen payments does **not** prove all True-Wealth external flows are complete. Other banks or withdrawals may exist. Therefore: - without a full-period owner attestation: record cashflows but leave coverage `partial` or unchanged - only mark `complete` when the user explicitly attests that all external deposits and withdrawals from every source are covered - preserve/merge existing contiguous complete coverage; never shrink it or bridge a gap silently - write exactly zero valuations - do not call `confirm_performance_source_activation()` - leave `JARVIS_FINANCE_DAILY_VALUATION_ENABLED` untouched ## Privacy and safety pitfalls - Never return or audit raw `description`, merchant text, IBAN/account references, source filenames, CSV contents, or raw fingerprints. - Return opaque detection tokens and masked source labels only. - Do not use account names or the word “Raiffeisen” alone as destination evidence; require source type plus explicit True-Wealth marker. - Never classify unsigned or non-CHF rows automatically. - Use decimal strings, not SQL/Python floats. - Do not trust client-returned amounts or dates. - Recheck source rows, existing cashflows, account role, coverage, and dedupe decisions inside the write lock. - Avoid `SELECT *` in public projections and fingerprints if future unrelated schema columns could destabilize the contract. - The existing generic transfer matcher is inappropriate because merchant text is intentionally non-decisive and a True-Wealth household counter-leg may not exist. ## Recommended tests 1. Exact Raiffeisen negative CHF row with `True Wealth` is detected as a deposit. 2. Case, hyphen, and whitespace variants are deterministic. 3. Positive, unsigned, EUR, malformed-date, and non-Raiffeisen rows are blocked. 4. “True Wealth” in unrelated metadata does not match. 5. Household-batch and legacy candidate shapes produce the same public contract but different evidence status. 6. Preview performs zero writes and is byte-deterministic. 7. Preview/API/audit contain no IBAN, description, filename, raw fingerprint, or CSV data. 8. Exact source replay writes once. 9. Reusing `confirmation_id` with changed payload fails. 10. Source row or canonical cashflow changing after preview causes stale-preview rejection. 11. Manual economic collision blocks without a decision. 12. `link_existing` writes no cashflow and remains resolved on later previews. 13. `record_distinct` allows two legitimate same-day equal payments while source IDs remain unique. 14. Confirm rollback leaves no transaction, coverage, or audit residue on failure. 15. Coverage is not marked complete without full external-flow attestation. 16. Existing complete coverage is not shortened and gaps are rejected. 17. Confirm writes zero valuation snapshots. 18. Confirm is HTTP 403 in disabled write mode; preview remains available. 19. Existing Sprint 20C manual/CSV/null-flow tests remain green. 20. `MIGRATION_VERSION == 49`. ## Verification and workspace state - Verified exact base: `107de783afac36bf48c95a996fc920aec2a46270`. - Inspected source/schema/tests only; no productive database rows were opened. - No files created or modified. - `git diff --check` passed. - Existing untracked `.venv` was already present and appears broken; targeted pytest execution was blocked because the available Python environment has no `pytest` module.