
===== src/jarvis_finance/services/raiffeisen_manual_snapshot.py =====
     1	from __future__ import annotations
     2	
     3	import hashlib
     4	import json
     5	from dataclasses import dataclass
     6	from datetime import date, datetime, timezone
     7	from decimal import Decimal
     8	from sqlite3 import Connection
     9	from typing import Any
    10	
    11	from jarvis_finance.audit.log import record_audit_event
    12	from jarvis_finance.imports.common import stable_id
    13	from jarvis_finance.services.modelled_wealth import (
    14	    build_modelled_wealth_development,
    15	    effective_cash_evidence,
    16	)
    17	
    18	ZERO = Decimal("0")
    19	SOURCE_KIND = "dated_manual_screenshot"
    20	SOURCE_DB = "manual_screenshot_snapshot"
    21	PRIVATE_SUFFIX = "5632"
    22	SAVINGS_SUFFIX = "5031"
    23	
    24	
    25	def _safe_label(account_name: str) -> str:
    26	    compact = account_name.replace(" ", "")
    27	    for suffix in (PRIVATE_SUFFIX, SAVINGS_SUFFIX):
    28	        if compact.endswith(suffix):
    29	            return f"Bankkonto ••••{suffix}"
    30	    return "Bankkonto"
    31	
    32	
    33	@dataclass(frozen=True)
    34	class _Target:
    35	    role: str
    36	    account_id: str | None
    37	    label: str
    38	    asset_kind: str
    39	    previous: Decimal | None
    40	    previous_status: str
    41	    new_value: Decimal
    42	    platform_id: str
    43	
    44	
    45	def _now() -> datetime:
    46	    return datetime.now(timezone.utc)
    47	
    48	
    49	def _fmt(value: Decimal) -> str:
    50	    return str(value.quantize(Decimal("0.01")))
    51	
    52	
    53	def _decimal(value: object) -> Decimal:
    54	    try:
    55	        return Decimal(str(value or "0"))
    56	    except Exception:
    57	        return ZERO
    58	
    59	
    60	def _hash(payload: object) -> str:
    61	    return hashlib.sha256(
    62	        json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str).encode("utf-8")
    63	    ).hexdigest()
    64	
    65	
    66	def _platform_id(conn: Connection) -> str:
    67	    rows = conn.execute(
    68	        "SELECT platform_id FROM platforms WHERE lower(name) LIKE '%raiffeisen%' ORDER BY platform_id"
    69	    ).fetchall()
    70	    if len(rows) != 1:
    71	        raise ValueError("raiffeisen_platform_not_uniquely_mapped")
    72	    return str(rows[0]["platform_id"])
    73	
    74	
    75	def _cash_account_for_suffix(conn: Connection, platform_id: str, suffix: str) -> Any:
    76	    rows = conn.execute(
    77	        """SELECT account_id,account_name FROM accounts
    78	             WHERE platform_id=? AND account_type='cash' AND is_active=1
    79	             ORDER BY account_id""",
    80	        (platform_id,),
    81	    ).fetchall()
    82	    matched = [row for row in rows if str(row["account_name"] or "").replace(" ", "").endswith(suffix)]
    83	    if len(matched) != 1:
    84	        raise ValueError(f"raiffeisen_cash_target_{suffix}_not_uniquely_mapped")
    85	    return matched[0]
    86	
    87	
    88	def _membership_account(conn: Connection, platform_id: str) -> Any | None:
    89	    rows = conn.execute(
    90	        """SELECT account_id,account_name FROM accounts
    91	             WHERE platform_id=? AND is_active=1
    92	               AND (account_type IN ('other_asset','membership') OR portfolio_bucket='other')
    93	               AND lower(account_name) LIKE '%genossenschaft%'
    94	             ORDER BY account_id""",
    95	        (platform_id,),
    96	    ).fetchall()
    97	    if len(rows) > 1:
    98	        raise ValueError("raiffeisen_membership_target_not_uniquely_mapped")
    99	    return rows[0] if rows else None
   100	
   101	
   102	def _latest_cash_value(conn: Connection, account_id: str, as_of: str) -> Decimal | None:
   103	    evidence = effective_cash_evidence(conn, account_id=account_id, as_of=as_of)
   104	    value = evidence.get("value_chf")
   105	    return _decimal(value) if value is not None else None
   106	
   107	
   108	def _latest_asset_value(conn: Connection, account_id: str, as_of: str) -> Decimal | None:
   109	    row = conn.execute(
   110	        """SELECT total_value_chf FROM account_value_snapshots
   111	             WHERE account_id=? AND valuation_date<=? AND is_active=1
   112	             ORDER BY valuation_date DESC,created_at DESC,snapshot_id DESC LIMIT 1""",
   113	        (account_id, as_of),
   114	    ).fetchone()
   115	    return _decimal(row["total_value_chf"]) if row else None
   116	
   117	
   118	def _input_values(payload: dict[str, Any]) -> dict[str, Decimal]:
   119	    return {
   120	        "private": _decimal(payload["private_account_value_chf"]),
   121	        "savings": _decimal(payload["savings_account_value_chf"]),
   122	        "membership": _decimal(payload["membership_value_chf"]),
   123	    }
   124	
   125	
   126	def _targets(conn: Connection, payload: dict[str, Any]) -> list[_Target]:
   127	    snapshot_date = str(payload["snapshot_date"])
   128	    platform_id = _platform_id(conn)
   129	    private = _cash_account_for_suffix(conn, platform_id, PRIVATE_SUFFIX)
   130	    savings = _cash_account_for_suffix(conn, platform_id, SAVINGS_SUFFIX)
   131	    membership = _membership_account(conn, platform_id)
   132	    values = _input_values(payload)
   133	    targets = [
   134	        _Target(
   135	            role="private",
   136	            account_id=str(private["account_id"]),
   137	            label=_safe_label(str(private["account_name"])),
   138	            asset_kind="bank_cash",
   139	            previous=_latest_cash_value(conn, str(private["account_id"]), snapshot_date),
   140	            previous_status="confirmed" if _latest_cash_value(conn, str(private["account_id"]), snapshot_date) is not None else "unknown",
   141	            new_value=values["private"],
   142	            platform_id=platform_id,
   143	        ),
   144	        _Target(
   145	            role="savings",
   146	            account_id=str(savings["account_id"]),
   147	            label=_safe_label(str(savings["account_name"])),
   148	            asset_kind="bank_cash",
   149	            previous=_latest_cash_value(conn, str(savings["account_id"]), snapshot_date),
   150	            previous_status="confirmed" if _latest_cash_value(conn, str(savings["account_id"]), snapshot_date) is not None else "unknown",
   151	            new_value=values["savings"],
   152	            platform_id=platform_id,
   153	        ),
   154	        _Target(
   155	            role="membership",
   156	            account_id=str(membership["account_id"]) if membership else None,
   157	            label="Raiffeisen Genossenschaftsanteil",
   158	            asset_kind="membership_asset",
   159	            previous=_latest_asset_value(conn, str(membership["account_id"]), snapshot_date) if membership else None,
   160	            previous_status=("confirmed" if membership and _latest_asset_value(conn, str(membership["account_id"]), snapshot_date) is not None else "unknown" if membership else "not_created"),
   161	            new_value=values["membership"],
   162	            platform_id=platform_id,
   163	        ),
   164	    ]
   165	    if len({target.account_id for target in targets if target.account_id}) != len([target for target in targets if target.account_id]):
   166	        raise ValueError("raiffeisen_targets_not_distinct")
   167	    return targets
   168	
   169	
   170	def _baseline(conn: Connection, payload: dict[str, Any], targets: list[_Target]) -> str:
   171	    details: list[dict[str, object]] = []
   172	    snapshot_date = str(payload["snapshot_date"])
   173	    for target in targets:
   174	        latest = None
   175	        if target.account_id:
   176	            table = "cash_account_snapshots" if target.asset_kind == "bank_cash" else "account_value_snapshots"
   177	            date_column = "balance_date" if target.asset_kind == "bank_cash" else "valuation_date"
   178	            value_column = "amount_chf" if target.asset_kind == "bank_cash" else "total_value_chf"
   179	            latest = conn.execute(
   180	                f"SELECT snapshot_id,{date_column} AS at,{value_column} AS value FROM {table} "
   181	                f"WHERE account_id=? AND {date_column}<=? "
   182	                + ("AND is_active=1 " if table == "account_value_snapshots" else "")
   183	                + f"ORDER BY {date_column} DESC,created_at DESC,snapshot_id DESC LIMIT 1",
   184	                (target.account_id, snapshot_date),
   185	            ).fetchone()
   186	        details.append(
   187	            {
   188	                "role": target.role,
   189	                "account_id": target.account_id,
   190	                "latest": dict(latest) if latest else None,
   191	                "new_value": _fmt(target.new_value),
   192	            }
   193	        )
   194	    return _hash({"snapshot_date": snapshot_date, "targets": details})
   195	
   196	
   197	def _wealth_model(conn: Connection, as_of: str) -> dict[str, Any]:
   198	    return build_modelled_wealth_development(conn, as_of=as_of, period="all")
   199	
   200	
   201	def _known_total(conn: Connection, as_of: str) -> Decimal:
   202	    model = _wealth_model(conn, as_of)
   203	    # A component correction must not promote a mixed-date/modelled point to a
   204	    # confirmed household anchor. Current is only a sparse-fixture fallback.
   205	    baseline = model.get("anchor") or model.get("current")
   206	    return _decimal(baseline.get("value_chf")) if isinstance(baseline, dict) else ZERO
   207	
   208	
   209	def _bank_total(conn: Connection, as_of: str) -> Decimal:
   210	    model = _wealth_model(conn, as_of)
   211	    for component in model.get("components") or []:
   212	        if component.get("key") == "bank_cash":
   213	            return _decimal(component.get("current_value_chf"))
   214	    return ZERO
   215	
   216	
   217	def _preview_payload(conn: Connection, payload: dict[str, Any]) -> tuple[dict[str, Any], list[_Target]]:
   218	    snapshot_date = str(payload["snapshot_date"])
   219	    parsed_date = date.fromisoformat(snapshot_date)
   220	    if parsed_date > _now().date():
   221	        raise ValueError("manual_snapshot_date_in_future")
   222	    targets = _targets(conn, payload)
   223	    input_fingerprint = _baseline(conn, payload, targets)
   224	    known_before = _known_total(conn, snapshot_date)
   225	    delta = sum((target.new_value - (target.previous or ZERO) for target in targets), ZERO)
   226	    cash_delta = sum(
   227	        (
   228	            target.new_value - (target.previous or ZERO)
   229	            for target in targets
   230	            if target.asset_kind == "bank_cash"
   231	        ),
   232	        ZERO,
   233	    )
   234	    bank_after = _bank_total(conn, snapshot_date) + cash_delta
   235	    membership_after = sum((target.new_value for target in targets if target.asset_kind == "membership_asset"), ZERO)
   236	    token = _hash(
   237	        {
   238	            "contract": "raiffeisen_manual_snapshot_preview_v1",
   239	            "input_fingerprint": input_fingerprint,
   240	        }
   241	    )[:32]
   242	    preview = {
   243	        "preview_id": f"raiffeisen-preview-{token}",
   244	        "confirmation_id": f"raiffeisen-confirm-{token}",
   245	        "input_fingerprint": input_fingerprint,
   246	        "source_kind": SOURCE_KIND,
   247	        "snapshot_date": snapshot_date,
   248	        "affected_accounts": [
   249	            {
   250	                "account_label": target.label,
   251	                "asset_kind": target.asset_kind,
   252	                "previous_value_chf": _fmt(target.previous) if target.previous is not None else None,
   253	                "new_value_chf": _fmt(target.new_value),
   254	                "change_chf": _fmt(target.new_value - (target.previous or ZERO)),
   255	                "previous_status": target.previous_status,
   256	            }
   257	            for target in targets
   258	        ],
   259	        "bank_cash_after_chf": _fmt(bank_after),
   260	        "separate_membership_asset_after_chf": _fmt(membership_after),
   261	        "known_wealth_before_chf": _fmt(known_before),
   262	        "expected_known_wealth_after_chf": _fmt(known_before + delta),
   263	        "expected_total_wealth_change_chf": _fmt(delta),
   264	        "creates_transactions": False,
   265	        "append_only": True,
   266	    }
   267	    return preview, targets
   268	
   269	
   270	def preview_raiffeisen_manual_snapshot(conn: Connection, **payload: Any) -> dict[str, Any]:
   271	    """Pure preview over stored canonical baselines; this function never writes."""
   272	    preview, _ = _preview_payload(conn, payload)
   273	    return preview
   274	
   275	
   276	def _existing_confirmation(
   277	    conn: Connection,
   278	    confirmation_id: str,
   279	    input_fingerprint: str,
   280	    payload_hash: str,
   281	) -> dict[str, Any] | None:
   282	    row = conn.execute(
   283	        "SELECT * FROM manual_snapshot_confirmations WHERE confirmation_id=?",
   284	        (confirmation_id,),
   285	    ).fetchone()
   286	    if not row:
   287	        return None
   288	    if (
   289	        str(row["input_fingerprint"]) != input_fingerprint
   290	        or str(row["payload_hash"]) != payload_hash
   291	    ):
   292	        raise ValueError("confirmation_id_reused_with_different_input")
   293	    return {
   294	        "status": "already_applied",
   295	        "confirmation_id": confirmation_id,
   296	        "snapshot_date": str(row["snapshot_date"]),
   297	        "created_snapshot_count": int(row["created_snapshot_count"]),
   298	        "created_transaction_count": 0,
   299	        "bank_cash_after_chf": str(row["bank_cash_after_chf"]),
   300	        "separate_membership_asset_after_chf": str(row["separate_membership_asset_after_chf"]),
   301	        "known_wealth_after_chf": str(row["known_wealth_after_chf"]),
   302	        "audit_recorded": True,
   303	    }
   304	
   305	
   306	def _confirm_raiffeisen_manual_snapshot_locked(
   307	    conn: Connection,
   308	    *,
   309	    preview_id: str,
   310	    confirmation_id: str,
   311	    input_fingerprint: str,
   312	    snapshot_date: date,
   313	    private_account_value_chf: Decimal,
   314	    savings_account_value_chf: Decimal,
   315	    membership_value_chf: Decimal,
   316	) -> dict[str, Any]:
   317	    payload = {
   318	        "snapshot_date": snapshot_date.isoformat(),
   319	        "private_account_value_chf": private_account_value_chf,
   320	        "savings_account_value_chf": savings_account_value_chf,
   321	        "membership_value_chf": membership_value_chf,
   322	    }
   323	    payload_hash = _hash(payload)
   324	    existing = _existing_confirmation(
   325	        conn,
   326	        confirmation_id,
   327	        input_fingerprint,
   328	        payload_hash,
   329	    )
   330	    if existing:
   331	        return existing
   332	    preview, targets = _preview_payload(conn, payload)
   333	    if _baseline(conn, payload, targets) != input_fingerprint:
   334	        raise ValueError("manual_snapshot_baseline_changed")
   335	    if (
   336	        preview_id != preview["preview_id"]
   337	        or confirmation_id != preview["confirmation_id"]
   338	    ):
   339	        raise ValueError("manual_snapshot_confirmation_token_mismatch")
   340	
   341	    now = _now().isoformat()
   342	    membership = next(target for target in targets if target.asset_kind == "membership_asset")
   343	    membership_account_id = membership.account_id or stable_id("account", "raiffeisen", "membership-share")
   344	    created = 0
   345	    with conn:
   346	        if membership.account_id is None:
   347	            conn.execute(
   348	                """INSERT INTO accounts(
   349	                       account_id,platform_id,account_name,account_type,currency,performance_included,
   350	                       is_active,notes,created_at,updated_at,balance_mode,portfolio_bucket
   351	                   ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""",
   352	                (
   353	                    membership_account_id,
   354	                    membership.platform_id,
   355	                    "Raiffeisen Genossenschaftsanteil",
   356	                    "other_asset",
   357	                    "CHF",
   358	                    0,
   359	                    1,
   360	                    "Separates Mitgliedschaftsvermögen; kein frei verfügbares Bankguthaben.",
   361	                    now,
   362	                    now,
   363	                    "snapshot",
   364	                    "other",
   365	                ),
   366	            )
   367	        audit_id = record_audit_event(
   368	            conn,
   369	            source=SOURCE_DB,
   370	            action="confirm_manual_source_snapshot",
   371	            entity_type="manual_source_snapshot",
   372	            entity_id=confirmation_id,
   373	            old_values={"input_fingerprint": input_fingerprint},
   374	            new_values={
   375	                "snapshot_date": snapshot_date.isoformat(),
   376	                "source_kind": SOURCE_KIND,
   377	                "snapshot_count": 3,
   378	                "transaction_count": 0,
   379	            },
   380	            created_by="user",
   381	        )
   382	        for target in targets:
   383	            account_id = membership_account_id if target.asset_kind == "membership_asset" else str(target.account_id)
   384	            if target.asset_kind == "bank_cash":
   385	                snapshot_id = stable_id("cash-snapshot", confirmation_id, target.role)
   386	                conn.execute(
   387	                    """INSERT INTO cash_account_snapshots(
   388	                           snapshot_id,account_id,snapshot_type,balance_date,amount_original,currency,
   389	                           amount_chf,source,note,created_at,created_by,audit_id,semantic_identity
   390	                       ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""",
   391	                    (
   392	                        snapshot_id,
   393	                        account_id,
   394	                        "manual_balance",
   395	                        snapshot_date.isoformat(),
   396	                        _fmt(target.new_value),
   397	                        "CHF",
   398	                        _fmt(target.new_value),
   399	                        SOURCE_DB,
   400	                        "Datierter manueller Quellensnapshot; keine Transaktionsrekonstruktion.",
   401	                        now,
   402	                        "user",
   403	                        audit_id,
   404	                        stable_id("manual-source-snapshot", confirmation_id, target.role),
   405	                    ),
   406	                )
   407	            else:
   408	                snapshot_id = stable_id("account-value-snapshot", confirmation_id, target.role)
   409	                conn.execute(
   410	                    """INSERT INTO account_value_snapshots(
   411	                           snapshot_id,account_id,valuation_date,total_value_chf,currency,source_type,
   412	                           quality_status,notes,created_at,updated_at,valuation_at,source_reference,is_active
   413	                       ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,1)""",
   414	                    (
   415	                        snapshot_id,
   416	                        account_id,
   417	                        snapshot_date.isoformat(),
   418	                        _fmt(target.new_value),
   419	                        "CHF",
   420	                        SOURCE_DB,
   421	                        "confirmed",
   422	                        "Separates Mitgliedschaftsvermögen; kein frei verfügbares Bankguthaben.",
   423	                        now,
   424	                        None,
   425	                        snapshot_date.isoformat(),
   426	                        confirmation_id,
   427	                    ),
   428	                )
   429	            created += 1
   430	        known_after = _decimal(preview["expected_known_wealth_after_chf"])
   431	        bank_after = _decimal(preview["bank_cash_after_chf"])
   432	        conn.execute(
   433	            """INSERT INTO manual_snapshot_confirmations(
   434	                   confirmation_id,preview_id,input_fingerprint,payload_hash,snapshot_date,source_kind,
   435	                   known_wealth_after_chf,bank_cash_after_chf,separate_membership_asset_after_chf,
   436	                   created_snapshot_count,created_at,audit_id
   437	               ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""",
   438	            (
   439	                confirmation_id,
   440	                preview_id,
   441	                input_fingerprint,
   442	                payload_hash,
   443	                snapshot_date.isoformat(),
   444	                SOURCE_KIND,
   445	                _fmt(known_after),
   446	                _fmt(bank_after),
   447	                _fmt(membership_value_chf),
   448	                created,
   449	                now,
   450	                audit_id,
   451	            ),
   452	        )
   453	    return {
   454	        "status": "confirmed",
   455	        "confirmation_id": confirmation_id,
   456	        "snapshot_date": snapshot_date.isoformat(),
   457	        "created_snapshot_count": created,
   458	        "created_transaction_count": 0,
   459	        "bank_cash_after_chf": _fmt(bank_after),
   460	        "separate_membership_asset_after_chf": _fmt(membership_value_chf),
   461	        "known_wealth_after_chf": _fmt(known_after),
   462	        "audit_recorded": True,
   463	    }
   464	
   465	
   466	def confirm_raiffeisen_manual_snapshot(
   467	    conn: Connection,
   468	    *,
   469	    preview_id: str,
   470	    confirmation_id: str,
   471	    input_fingerprint: str,
   472	    snapshot_date: date,
   473	    private_account_value_chf: Decimal,
   474	    savings_account_value_chf: Decimal,
   475	    membership_value_chf: Decimal,
   476	) -> dict[str, Any]:
   477	    """Serialize stale-check, idempotency lookup, version allocation, and writes."""
   478	    if conn.in_transaction:
   479	        raise ValueError("manual_snapshot_requires_clean_transaction")
   480	    conn.execute("BEGIN IMMEDIATE")
   481	    try:
   482	        result = _confirm_raiffeisen_manual_snapshot_locked(
   483	            conn,
   484	            preview_id=preview_id,
   485	            confirmation_id=confirmation_id,
   486	            input_fingerprint=input_fingerprint,
   487	            snapshot_date=snapshot_date,
   488	            private_account_value_chf=private_account_value_chf,
   489	            savings_account_value_chf=savings_account_value_chf,
   490	            membership_value_chf=membership_value_chf,
   491	        )
   492	        if conn.in_transaction:
   493	            conn.commit()
   494	        return result
   495	    except Exception:
   496	        if conn.in_transaction:
   497	            conn.rollback()
   498	        raise

===== src/jarvis_finance/api/schemas/manual_snapshot.py =====
     1	from __future__ import annotations
     2	
     3	from datetime import date
     4	from decimal import Decimal
     5	from typing import Literal
     6	
     7	from pydantic import BaseModel, ConfigDict, Field, field_validator
     8	
     9	
    10	class RaiffeisenManualSnapshotInput(BaseModel):
    11	    """Dated source facts only; no transaction reconstruction and no full identifiers."""
    12	
    13	    model_config = ConfigDict(extra="forbid")
    14	    snapshot_date: date
    15	    private_account_value_chf: Decimal = Field(ge=0, max_digits=18, decimal_places=2)
    16	    savings_account_value_chf: Decimal = Field(ge=0, max_digits=18, decimal_places=2)
    17	    membership_value_chf: Decimal = Field(ge=0, max_digits=18, decimal_places=2)
    18	
    19	    @field_validator(
    20	        "private_account_value_chf",
    21	        "savings_account_value_chf",
    22	        "membership_value_chf",
    23	    )
    24	    @classmethod
    25	    def two_decimal_places(cls, value: Decimal) -> Decimal:
    26	        return value.quantize(Decimal("0.01"))
    27	
    28	
    29	class ManualSnapshotAffectedAccount(BaseModel):
    30	    model_config = ConfigDict(extra="forbid")
    31	    account_label: str
    32	    asset_kind: Literal["bank_cash", "membership_asset"]
    33	    previous_value_chf: str | None
    34	    new_value_chf: str
    35	    change_chf: str
    36	    previous_status: Literal["confirmed", "unknown", "not_created"]
    37	
    38	
    39	class ManualSnapshotPreviewRequest(RaiffeisenManualSnapshotInput):
    40	    pass
    41	
    42	
    43	class ManualSnapshotPreviewResponse(BaseModel):
    44	    model_config = ConfigDict(extra="forbid")
    45	    preview_id: str
    46	    confirmation_id: str
    47	    input_fingerprint: str
    48	    source_kind: Literal["dated_manual_screenshot"]
    49	    snapshot_date: str
    50	    affected_accounts: list[ManualSnapshotAffectedAccount]
    51	    bank_cash_after_chf: str
    52	    separate_membership_asset_after_chf: str
    53	    known_wealth_before_chf: str
    54	    expected_known_wealth_after_chf: str
    55	    expected_total_wealth_change_chf: str
    56	    creates_transactions: Literal[False]
    57	    append_only: Literal[True]
    58	
    59	
    60	class ManualSnapshotConfirmRequest(RaiffeisenManualSnapshotInput):
    61	    preview_id: str = Field(min_length=8, max_length=128)
    62	    confirmation_id: str = Field(min_length=8, max_length=128)
    63	    input_fingerprint: str = Field(min_length=32, max_length=128)
    64	
    65	
    66	class ManualSnapshotConfirmResponse(BaseModel):
    67	    model_config = ConfigDict(extra="forbid")
    68	    status: Literal["confirmed", "already_applied"]
    69	    confirmation_id: str
    70	    snapshot_date: str
    71	    created_snapshot_count: int
    72	    created_transaction_count: Literal[0]
    73	    bank_cash_after_chf: str
    74	    separate_membership_asset_after_chf: str
    75	    known_wealth_after_chf: str
    76	    audit_recorded: bool

===== src/jarvis_finance/api/routers/overview.py =====
     1	from __future__ import annotations
     2	
     3	from sqlite3 import Connection
     4	
     5	from fastapi import APIRouter, Depends, HTTPException, Query
     6	
     7	from jarvis_finance.api.dependencies import get_db
     8	from jarvis_finance.api.schemas.overview import PortfolioSummary
     9	from jarvis_finance.api.schemas.manual_snapshot import (
    10	    ManualSnapshotConfirmRequest,
    11	    ManualSnapshotConfirmResponse,
    12	    ManualSnapshotPreviewRequest,
    13	    ManualSnapshotPreviewResponse,
    14	)
    15	from jarvis_finance.api.schemas.performance_activation import (
    16	    DailyValuationJobStatus,
    17	    PerformanceBackfillConfirmRequest,
    18	    PerformanceBackfillConfirmResponse,
    19	    PerformanceBackfillPreviewRequest,
    20	    PerformanceBackfillPreviewResponse,
    21	    PerformanceReclassificationPreviewRequest,
    22	    PerformanceReclassificationPreviewResponse,
    23	    PerformanceSetupResponse,
    24	    PerformanceSourceActivationConfirmRequest,
    25	    PerformanceSourceActivationConfirmResponse,
    26	    PerformanceSourceActivationPreviewRequest,
    27	    PerformanceSourceActivationPreviewResponse,
    28	    TrueWealthActivationPackagePreviewRequest,
    29	    TrueWealthActivationPackagePreviewResponse,
    30	    TrueWealthBankPaymentConfirmRequest,
    31	    TrueWealthBankPaymentConfirmResponse,
    32	    TrueWealthBankPaymentPreviewRequest,
    33	    TrueWealthBankPaymentPreviewResponse,
    34	    TrueWealthCashflowConfirmRequest,
    35	    TrueWealthCashflowConfirmResponse,
    36	    TrueWealthCashflowPreviewRequest,
    37	    TrueWealthCashflowPreviewResponse,
    38	    TrueWealthModelPreviewResponse,
    39	    TrueWealthPerformanceViewResponse,
    40	    TrueWealthRecipientRuleActivationRequest,
    41	    TrueWealthRecipientRuleActivationResponse,
    42	)
    43	from jarvis_finance.api.schemas.portfolio_advisor import PortfolioAdvisorSnapshot
    44	from jarvis_finance.api.schemas.portfolio_data import (
    45	    DataSourcesResponse,
    46	    IngestionConfirmRequest,
    47	    IngestionConfirmResponse,
    48	    IngestionHistoryResponse,
    49	    IngestionPreviewRequest,
    50	    IngestionPreviewResponse,
    51	    ReconciliationResponse,
    52	)
    53	from jarvis_finance.api.schemas.portfolio_performance import (
    54	    PerformanceCoverageResponse,
    55	    PortfolioPerformanceResponse,
    56	)
    57	from jarvis_finance.api.schemas.portfolio_policy import (
    58	    ActivePortfolioPolicyResponse,
    59	    PolicyConfirmRequest,
    60	    PolicyConfirmResponse,
    61	    PolicyEvaluationResponse,
    62	    PolicyHistoryItem,
    63	    PolicyPreviewRequest,
    64	    PolicyPreviewResponse,
    65	    PortfolioPolicyResponse,
    66	)
    67	from jarvis_finance.api.schemas.reconciliation import ReconciliationSnapshotResponse
    68	from jarvis_finance.api.schemas.wealth_cockpit import WealthCockpitResponse
    69	from jarvis_finance.services.finance_command_center import build_finance_command_center
    70	from jarvis_finance.services.performance_activation import (
    71	    build_daily_valuation_job_status,
    72	    confirm_performance_backfill,
    73	    confirm_performance_source_activation,
    74	    preview_performance_backfill,
    75	    preview_performance_source_activation,
    76	)
    77	from jarvis_finance.services.performance_hardening import (
    78	    build_activation_setup_overview,
    79	    build_postfinance_component_preview,
    80	    confirm_truewealth_cashflow_period,
    81	    preview_performance_reclassification,
    82	    preview_truewealth_cashflow_period,
    83	)
    84	from jarvis_finance.services.portfolio_advisor import get_portfolio_advisor_snapshot
    85	from jarvis_finance.services.portfolio_analytics import build_portfolio_analytics
    86	from jarvis_finance.services.portfolio_data import (
    87	    build_portfolio_reconciliation,
    88	    confirm_ingestion,
    89	    ingestion_history,
    90	    list_data_sources,
    91	    preview_ingestion,
    92	)
    93	from jarvis_finance.services.portfolio_performance import (
    94	    build_performance_coverage,
    95	    build_portfolio_performance,
    96	)
    97	from jarvis_finance.services.portfolio_policy import (
    98	    active_policy,
    99	    confirm_policy,
   100	    evaluate_policy,
   101	    policy_detail,
   102	    policy_history,
   103	    preview_policy,
   104	)
   105	from jarvis_finance.services.portfolio_service import get_overview
   106	from jarvis_finance.services.reconciliation_snapshot import build_reconciliation_snapshot
   107	from jarvis_finance.services.truewealth_productization import (
   108	    activate_truewealth_recipient_rule,
   109	    confirm_truewealth_bank_payments,
   110	    preview_truewealth_bank_payments,
   111	)
   112	from jarvis_finance.services.truewealth_valuation import (
   113	    build_truewealth_activation_package_preview,
   114	    build_truewealth_model_preview,
   115	    build_truewealth_performance_view,
   116	    public_truewealth_model_preview,
   117	)
   118	from jarvis_finance.services.wealth_cockpit import build_wealth_cockpit
   119	from jarvis_finance.services.raiffeisen_manual_snapshot import (
   120	    confirm_raiffeisen_manual_snapshot,
   121	    preview_raiffeisen_manual_snapshot,
   122	)
   123	
   124	router = APIRouter(tags=["overview"])
   125	
   126	
   127	@router.post("/portfolio/manual-snapshot/raiffeisen/preview", response_model=ManualSnapshotPreviewResponse)
   128	def raiffeisen_manual_snapshot_preview(
   129	    request: ManualSnapshotPreviewRequest, conn: Connection = Depends(get_db)
   130	) -> dict:
   131	    """Read-only preview over the dated source facts supplied by the user."""
   132	    try:
   133	        return preview_raiffeisen_manual_snapshot(conn, **request.model_dump())
   134	    except ValueError as exc:
   135	        raise HTTPException(status_code=409, detail=str(exc)) from exc
   136	
   137	
   138	@router.post(
   139	    "/portfolio/manual-snapshot/raiffeisen/confirm",
   140	    response_model=ManualSnapshotConfirmResponse,
   141	    responses={409: {"description": "Preview baseline changed or confirmation id reused"}},
   142	)
   143	def raiffeisen_manual_snapshot_confirm(
   144	    request: ManualSnapshotConfirmRequest, conn: Connection = Depends(get_db)
   145	) -> dict:
   146	    try:
   147	        return confirm_raiffeisen_manual_snapshot(conn, **request.model_dump())
   148	    except ValueError as exc:
   149	        raise HTTPException(status_code=409, detail=str(exc)) from exc
   150	
   151	
   152	@router.get("/overview/finance-command-center")
   153	def finance_command_center(month: str | None = None, conn: Connection = Depends(get_db)) -> dict:
   154	    try:
   155	        return build_finance_command_center(conn, month=month)
   156	    except ValueError as exc:
   157	        raise HTTPException(status_code=400, detail=str(exc)) from exc
   158	
   159	
   160	@router.get("/overview", response_model=PortfolioSummary)
   161	def overview(conn: Connection = Depends(get_db)) -> PortfolioSummary:
   162	    return get_overview(conn)
   163	
   164	
   165	@router.get("/portfolio/wealth-cockpit", response_model=WealthCockpitResponse)
   166	def wealth_cockpit(
   167	    period: str = Query(
   168	        default="1m",
   169	        pattern="^(since_anchor|1m|3m|1y|ytd|previous_year|12m|all)$",
   170	    ),
   171	    as_of: str | None = None,
   172	    data_cutoff: str | None = None,
   173	    conn: Connection = Depends(get_db),
   174	) -> dict:
   175	    """Read-only household wealth view composed exclusively from stored canonical data."""
   176	    try:
   177	        return build_wealth_cockpit(
   178	            conn,
   179	            period=period,
   180	            as_of=as_of,
   181	            data_cutoff=data_cutoff,
   182	        )
   183	    except ValueError as exc:
   184	        raise HTTPException(status_code=400, detail=str(exc)) from exc
   185	
   186	
   187	@router.get("/portfolio/reconciliation-snapshot", response_model=ReconciliationSnapshotResponse)
   188	def reconciliation_snapshot(conn: Connection = Depends(get_db)) -> ReconciliationSnapshotResponse:
   189	    """Read-only local quality view; it never imports, corrects, or confirms data."""
   190	    return ReconciliationSnapshotResponse.model_validate(build_reconciliation_snapshot(conn))
   191	
   192	
   193	@router.get("/portfolio/data-sources", response_model=DataSourcesResponse)
   194	def portfolio_data_sources(conn: Connection = Depends(get_db)) -> dict:
   195	    """Read source availability and redacted ingestion status without fetching providers."""
   196	    return list_data_sources(conn)
   197	
   198	
   199	@router.post("/portfolio/ingestion/preview", response_model=IngestionPreviewResponse)
   200	def portfolio_ingestion_preview(request: IngestionPreviewRequest, conn: Connection = Depends(get_db)) -> dict:
   201	    """Pure preview over already-normalized local records; no source is fetched or mutated."""
   202	    try:
   203	        return preview_ingestion(conn, request.model_dump())
   204	    except ValueError as exc:
   205	        raise HTTPException(status_code=400, detail=str(exc)) from exc
   206	
   207	
   208	@router.post(
   209	    "/portfolio/ingestion/confirm",
   210	    response_model=IngestionConfirmResponse,
   211	    responses={409: {"description": "Abgelaufene oder veränderte Preview"}},
   212	)
   213	def portfolio_ingestion_confirm(request: IngestionConfirmRequest, conn: Connection = Depends(get_db)) -> dict:
   214	    """The only portfolio-ingestion write path: atomic, idempotent and audited."""
   215	    try:
   216	        return confirm_ingestion(conn, request.model_dump())
   217	    except ValueError as exc:
   218	        message = str(exc)
   219	        status = 409 if any(word in message.lower() for word in ("abgelaufen", "veraltet", "verändert", "ausgangsrevision", "wiederverwendet")) else 400
   220	        raise HTTPException(status_code=status, detail=message) from exc
   221	
   222	
   223	@router.get("/portfolio/ingestion/history", response_model=IngestionHistoryResponse)
   224	def portfolio_ingestion_history(
   225	    limit: int = Query(default=50, ge=1, le=100),
   226	    offset: int = Query(default=0, ge=0),
   227	    conn: Connection = Depends(get_db),
   228	) -> dict:
   229	    return ingestion_history(conn, limit=limit, offset=offset)
   230	
   231	
   232	@router.get("/portfolio/reconciliation", response_model=ReconciliationResponse)
   233	def portfolio_reconciliation(
   234	    as_of: str,
   235	    data_cutoff: str | None = None,
   236	    account_id: str | None = None,
   237	    base_currency: str = "CHF",
   238	    absolute_tolerance: str = "0.01",
   239	    relative_tolerance: str = "0.001",
   240	    limit: int = Query(default=200, ge=1, le=500),
   241	    offset: int = Query(default=0, ge=0),
   242	    conn: Connection = Depends(get_db),
   243	) -> dict:
   244	    """Read-only reproducible quantity, valuation, account-total and coverage reconciliation."""
   245	    try:
   246	        return build_portfolio_reconciliation(
   247	            conn, as_of=as_of, data_cutoff=data_cutoff, account_id=account_id,
   248	            base_currency=base_currency, absolute_tolerance=absolute_tolerance,
   249	            relative_tolerance=relative_tolerance, limit=limit, offset=offset,
   250	        )
   251	    except ValueError as exc:
   252	        raise HTTPException(status_code=400, detail=str(exc)) from exc
   253	
   254	
   255	@router.get("/portfolio/performance/coverage", response_model=PerformanceCoverageResponse)
   256	def portfolio_performance_coverage(
   257	    from_date: str | None = Query(default=None, alias="from"),
   258	    to_date: str | None = Query(default=None, alias="to"),
   259	    conn: Connection = Depends(get_db),
   260	) -> dict:
   261	    """Role-bound coverage matrix; never estimates unavailable performance."""
   262	    if (from_date is None) != (to_date is None):
   263	        raise HTTPException(status_code=400, detail="Performance coverage requires both from and to")
   264	    try:
   265	        return build_performance_coverage(conn, from_date=from_date, to_date=to_date)
   266	    except ValueError as exc:
   267	        raise HTTPException(status_code=400, detail=str(exc)) from exc
   268	
   269	
   270	@router.get("/portfolio/performance/daily-job", response_model=DailyValuationJobStatus)
   271	def portfolio_daily_valuation_job(conn: Connection = Depends(get_db)) -> dict:
   272	    """Read-only scheduler/run diagnosis; never invokes a provider or writes a snapshot."""
   273	    return build_daily_valuation_job_status(conn)
   274	
   275	
   276	@router.get("/portfolio/performance/setup", response_model=PerformanceSetupResponse)
   277	def portfolio_performance_setup(conn: Connection = Depends(get_db)) -> dict:
   278	    """Single compact read-only activation setup projection."""
   279	    return build_activation_setup_overview(conn)
   280	
   281	
   282	@router.get("/portfolio/performance/postfinance-components")
   283	def portfolio_postfinance_component_preview(
   284	    day: str,
   285	    conn: Connection = Depends(get_db),
   286	) -> dict:
   287	    try:
   288	        return build_postfinance_component_preview(conn, day=day)
   289	    except ValueError as exc:
   290	        raise HTTPException(status_code=400, detail=str(exc)) from exc
   291	
   292	
   293	@router.post(
   294	    "/portfolio/performance/reclassification/preview",
   295	    response_model=PerformanceReclassificationPreviewResponse,
   296	)
   297	def portfolio_performance_reclassification_preview(
   298	    request: PerformanceReclassificationPreviewRequest,
   299	    conn: Connection = Depends(get_db),
   300	) -> dict:
   301	    try:
   302	        return preview_performance_reclassification(conn, **request.model_dump())
   303	    except ValueError as exc:
   304	        raise HTTPException(status_code=400, detail=str(exc)) from exc
   305	
   306	
   307	@router.post(
   308	    "/portfolio/performance/truewealth-cashflows/preview",
   309	    response_model=TrueWealthCashflowPreviewResponse,
   310	)
   311	def truewealth_cashflow_period_preview(
   312	    request: TrueWealthCashflowPreviewRequest,
   313	    conn: Connection = Depends(get_db),
   314	) -> dict:
   315	    try:
   316	        return preview_truewealth_cashflow_period(conn, **request.model_dump())
   317	    except ValueError as exc:
   318	        raise HTTPException(status_code=400, detail=str(exc)) from exc
   319	
   320	
   321	@router.post(
   322	    "/portfolio/performance/truewealth-cashflows/confirm",
   323	    response_model=TrueWealthCashflowConfirmResponse,
   324	    responses={409: {"description": "Preview changed or confirmation id reused"}},
   325	)
   326	def truewealth_cashflow_period_confirm(
   327	    request: TrueWealthCashflowConfirmRequest,
   328	    conn: Connection = Depends(get_db),
   329	) -> dict:
   330	    try:
   331	        return confirm_truewealth_cashflow_period(conn, request.model_dump())
   332	    except ValueError as exc:
   333	        message = str(exc)
   334	        status = 409 if any(
   335	            word in message.lower()
   336	            for word in ("stale", "different payload", "already used")
   337	        ) else 400
   338	        raise HTTPException(status_code=status, detail=message) from exc
   339	
   340	
   341	@router.get(
   342	    "/portfolio/performance/truewealth/view",
   343	    response_model=TrueWealthPerformanceViewResponse,
   344	)
   345	def truewealth_performance_view(
   346	    requested_from: str,
   347	    requested_to: str,
   348	    conn: Connection = Depends(get_db),
   349	) -> dict:
   350	    try:
   351	        return build_truewealth_performance_view(
   352	            conn, requested_from=requested_from, requested_to=requested_to
   353	        )
   354	    except ValueError as exc:
   355	        raise HTTPException(status_code=400, detail=str(exc)) from exc
   356	
   357	
   358	@router.get(
   359	    "/portfolio/performance/truewealth/model-preview",
   360	    response_model=TrueWealthModelPreviewResponse,
   361	)
   362	def truewealth_model_preview(as_of: str, conn: Connection = Depends(get_db)) -> dict:
   363	    try:
   364	        return public_truewealth_model_preview(build_truewealth_model_preview(conn, as_of=as_of))
   365	    except ValueError as exc:
   366	        raise HTTPException(status_code=400, detail=str(exc)) from exc
   367	
   368	
   369	@router.post(
   370	    "/portfolio/performance/truewealth/activation-package-preview",
   371	    response_model=TrueWealthActivationPackagePreviewResponse,
   372	)
   373	def truewealth_activation_package_preview(
   374	    request: TrueWealthActivationPackagePreviewRequest,
   375	    conn: Connection = Depends(get_db),
   376	) -> dict:
   377	    try:
   378	        payload = request.model_dump()
   379	        bank = preview_truewealth_bank_payments(
   380	            conn,
   381	            **{
   382	                key: payload[key]
   383	                for key in (
   384	                    "period_from", "period_to", "recipient_name",
   385	                    "recipient_account_identity", "counterparty_hashes",
   386	                    "reference_tokens", "collision_decisions", "attestation",
   387	                )
   388	            },
   389	        )
   390	        return build_truewealth_activation_package_preview(
   391	            conn,
   392	            bank_preview=bank,
   393	            performance_from=payload["performance_from"],
   394	            performance_to=payload["performance_to"],
   395	            model_as_of=payload["model_as_of"],
   396	        )
   397	    except ValueError as exc:
   398	        raise HTTPException(status_code=400, detail=str(exc)) from exc
   399	
   400	
   401	@router.post(
   402	    "/portfolio/performance/truewealth-bank-payments/preview",
   403	    response_model=TrueWealthBankPaymentPreviewResponse,
   404	)
   405	def truewealth_bank_payment_preview(
   406	    request: TrueWealthBankPaymentPreviewRequest,
   407	    conn: Connection = Depends(get_db),
   408	) -> dict:
   409	    """Read only: scan already imported Raiffeisen rows; never fetch or mutate."""
   410	    try:
   411	        return preview_truewealth_bank_payments(conn, **request.model_dump())
   412	    except ValueError as exc:
   413	        raise HTTPException(status_code=400, detail=str(exc)) from exc
   414	
   415	
   416	@router.post(
   417	    "/portfolio/performance/truewealth-bank-payments/confirm",
   418	    response_model=TrueWealthBankPaymentConfirmResponse,
   419	    responses={409: {"description": "Preview changed or confirmation id reused"}},
   420	)
   421	def truewealth_bank_payment_confirm(
   422	    request: TrueWealthBankPaymentConfirmRequest,
   423	    conn: Connection = Depends(get_db),
   424	) -> dict:
   425	    try:
   426	        return confirm_truewealth_bank_payments(conn, request.model_dump())
   427	    except ValueError as exc:
   428	        message = str(exc)
   429	        status = 409 if any(word in message.lower() for word in ("stale", "already used", "became stale")) else 400
   430	        raise HTTPException(status_code=status, detail=message) from exc
   431	
   432	
   433	@router.post(
   434	    "/portfolio/performance/truewealth-recipient-rule/activate",
   435	    response_model=TrueWealthRecipientRuleActivationResponse,
   436	)
   437	def truewealth_recipient_rule_activate(
   438	    request: TrueWealthRecipientRuleActivationRequest,
   439	    conn: Connection = Depends(get_db),
   440	) -> dict:
   441	    """Separate rule activation boundary; historical confirmation never calls it."""
   442	    try:
   443	        return activate_truewealth_recipient_rule(conn, request.model_dump())
   444	    except ValueError as exc:
   445	        raise HTTPException(status_code=400, detail=str(exc)) from exc
   446	
   447	
   448	@router.post(
   449	    "/portfolio/performance/activation/preview",
   450	    response_model=PerformanceSourceActivationPreviewResponse,
   451	)
   452	def portfolio_performance_activation_preview(
   453	    request: PerformanceSourceActivationPreviewRequest,
   454	    conn: Connection = Depends(get_db),
   455	) -> dict:
   456	    """Read-only source-contract preview; it never creates scopes, coverage or values."""
   457	    try:
   458	        return preview_performance_source_activation(conn, **request.model_dump())
   459	    except ValueError as exc:
   460	        raise HTTPException(status_code=400, detail=str(exc)) from exc
   461	
   462	
   463	@router.post(
   464	    "/portfolio/performance/activation/confirm",
   465	    response_model=PerformanceSourceActivationConfirmResponse,
   466	    responses={409: {"description": "Preview changed or expired"}},
   467	)
   468	def portfolio_performance_activation_confirm(
   469	    request: PerformanceSourceActivationConfirmRequest,
   470	    conn: Connection = Depends(get_db),
   471	) -> dict:
   472	    """Explicit, fingerprint-bound source activation without financial snapshot writes."""
   473	    try:
   474	        return confirm_performance_source_activation(conn, request.model_dump())
   475	    except ValueError as exc:
   476	        status = 409 if "stale" in str(exc).lower() else 400
   477	        raise HTTPException(status_code=status, detail=str(exc)) from exc
   478	
   479	
   480	@router.post(
   481	    "/portfolio/performance/backfill/preview",
   482	    response_model=PerformanceBackfillPreviewResponse,
   483	)
   484	def portfolio_performance_backfill_preview(
   485	    request: PerformanceBackfillPreviewRequest,
   486	    conn: Connection = Depends(get_db),
   487	) -> dict:
   488	    """Pure local preview; provider calls and financial writes are forbidden here."""
   489	    try:
   490	        return preview_performance_backfill(conn, **request.model_dump())
   491	    except ValueError as exc:
   492	        raise HTTPException(status_code=400, detail=str(exc)) from exc
   493	
   494	
   495	@router.post(
   496	    "/portfolio/performance/backfill/confirm",
   497	    response_model=PerformanceBackfillConfirmResponse,
   498	    responses={409: {"description": "Preview changed or expired"}},
   499	)
   500	def portfolio_performance_backfill_confirm(
   501	    request: PerformanceBackfillConfirmRequest,
   502	    conn: Connection = Depends(get_db),
   503	) -> dict:
   504	    """Explicit, fingerprint-bound, audited and idempotent backfill write boundary."""
   505	    try:
   506	        return confirm_performance_backfill(conn, request.model_dump())
   507	    except ValueError as exc:
   508	        status = 409 if "stale" in str(exc).lower() else 400
   509	        raise HTTPException(status_code=status, detail=str(exc)) from exc
   510	
   511	
   512	@router.get(
   513	    "/portfolio/performance",
   514	    response_model=PortfolioPerformanceResponse,
   515	    responses={400: {"description": "Ungültiger Performance-Zeitraum oder Parameter"}},
   516	)
   517	def portfolio_performance(
   518	    from_date: str = Query(alias="from"),
   519	    to_date: str = Query(alias="to"),
   520	    method: str = Query(default="both"),
   521	    account_id: str | None = None,
   522	    scope: str = Query(default="portfolio", pattern="^(portfolio|postfinance|truewealth|crypto)$"),
   523	    base_currency: str = Query(default="CHF"),
   524	    data_cutoff: str | None = None,
   525	    conn: Connection = Depends(get_db),
   526	) -> dict:
   527	    """Pure read-only calculation from stored activities and versioned snapshots."""
   528	
   529	    try:
   530	        if account_id is not None and scope != "portfolio":
   531	            raise ValueError("Konto und fachlicher Scope dürfen nicht gleichzeitig gewählt werden")
   532	        role_map = {
   533	            "postfinance": ("postfinance_etrading_depot", "postfinance_etrading_cash"),
   534	            "truewealth": ("canonical_truewealth_total_value",),
   535	            "crypto": ("crypto_portfolio",),
   536	        }
   537	        selected_account_ids: list[str] | None = None
   538	        if scope != "portfolio":
   539	            roles = role_map[scope]
   540	            placeholders = ",".join("?" for _ in roles)
   541	            selected_account_ids = [
   542	                str(row[0])
   543	                for row in conn.execute(
   544	                    f"""SELECT account_id FROM performance_scope_classifications
   545	                        WHERE included=1 AND decision_version='investment_performance_scope_v1'
   546	                          AND classification_role IN ({placeholders})
   547	                        ORDER BY account_id""",
   548	                    roles,
   549	                ).fetchall()
   550	            ]
   551	        return build_portfolio_performance(
   552	            conn,
   553	            from_date=from_date,
   554	            to_date=to_date,
   555	            method=method,
   556	            account_id=account_id,
   557	            selected_account_ids=selected_account_ids,
   558	            base_currency=base_currency,
   559	            data_cutoff=data_cutoff,
   560	        )
   561	    except ValueError as exc:
   562	        raise HTTPException(status_code=400, detail=str(exc)) from exc
   563	
   564	
   565	@router.get("/portfolio/analytics")
   566	def portfolio_analytics(
   567	    period: str = Query(default="1y", pattern="^(1m|3m|6m|1y|2y)$"),
   568	    conn: Connection = Depends(get_db),
   569	) -> dict:
   570	    """Read-only market/FX, benchmark and risk projection from stored confirmed snapshots."""
   571	    return build_portfolio_analytics(conn, period=period)
   572	
   573	
   574	@router.get("/portfolio/policy", response_model=ActivePortfolioPolicyResponse)
   575	def portfolio_policy(conn: Connection = Depends(get_db)) -> dict:
   576	    return active_policy(conn)
   577	
   578	
   579	@router.get("/portfolio/policy/history", response_model=list[PolicyHistoryItem])
   580	def portfolio_policy_history(conn: Connection = Depends(get_db)) -> list[dict]:
   581	    return policy_history(conn)
   582	
   583	
   584	@router.post("/portfolio/policy/preview", response_model=PolicyPreviewResponse)
   585	def portfolio_policy_preview(request: PolicyPreviewRequest, conn: Connection = Depends(get_db)) -> dict:
   586	    return preview_policy(conn, request.model_dump())
   587	
   588	
   589	@router.post("/portfolio/policy/confirm", response_model=PolicyConfirmResponse)
   590	def portfolio_policy_confirm(request: PolicyConfirmRequest, conn: Connection = Depends(get_db)) -> dict:
   591	    try:
   592	        return confirm_policy(conn, request.model_dump())
   593	    except ValueError as exc:
   594	        raise HTTPException(status_code=400, detail=str(exc)) from exc
   595	
   596	
   597	@router.get("/portfolio/policy/evaluation", response_model=PolicyEvaluationResponse)
   598	def portfolio_policy_evaluation(conn: Connection = Depends(get_db)) -> dict:
   599	    return evaluate_policy(conn)
   600	
   601	
   602	@router.get(
   603	    "/portfolio/policy/{policy_id}",
   604	    response_model=PortfolioPolicyResponse,
   605	    responses={404: {"description": "Policy-Version nicht gefunden"}},
   606	)
   607	def portfolio_policy_detail(policy_id: str, conn: Connection = Depends(get_db)) -> dict:
   608	    policy = policy_detail(conn, policy_id)
   609	    if policy is None:
   610	        raise HTTPException(status_code=404, detail="Policy-Version nicht gefunden")
   611	    return policy
   612	
   613	
   614	@router.get("/portfolio/advisor", response_model=PortfolioAdvisorSnapshot)
   615	def portfolio_advisor(conn: Connection = Depends(get_db)) -> PortfolioAdvisorSnapshot:
   616	    return get_portfolio_advisor_snapshot(conn)

===== src/jarvis_finance/api/main.py =====
     1	from __future__ import annotations
     2	
     3	import os
     4	from urllib.parse import urlparse
     5	
     6	from fastapi import FastAPI, Request
     7	from fastapi.exception_handlers import request_validation_exception_handler
     8	from fastapi.exceptions import RequestValidationError
     9	from fastapi.middleware.cors import CORSMiddleware
    10	from fastapi.responses import JSONResponse
    11	
    12	from jarvis_finance.api.routers import budget, cash, crypto, crypto_trader, equity, health, market, overview, positions, postfinance, reports, system, truewealth
    13	from jarvis_finance.api.security import WRITE_METHODS, is_local_request, is_write_request_allowed, resolve_write_mode
    14	
    15	LOCAL_ORIGINS = [
    16	    "http://localhost:5173",
    17	    "http://127.0.0.1:5173",
    18	    "http://100.85.29.67:5173",
    19	    "http://agent.tailbd371b.ts.net:5173",
    20	    "http://localhost:8503",
    21	    "http://127.0.0.1:8503",
    22	]
    23	READ_ONLY_POST_PATHS = {
    24	    "/api/portfolio/ingestion/preview",
    25	    "/api/portfolio/policy/preview",
    26	    "/api/postfinance/imports/preview",
    27	    "/api/truewealth/imports/preview",
    28	    "/api/truewealth/manual-values/preview",
    29	    "/api/portfolio/performance/reclassification/preview",
    30	    "/api/portfolio/performance/truewealth-cashflows/preview",
    31	    "/api/portfolio/performance/truewealth-bank-payments/preview",
    32	    "/api/portfolio/performance/truewealth/activation-package-preview",
    33	    "/api/budget/household/review/items/preview",
    34	    "/api/budget/household/transactions/category/preview",
    35	    "/api/market/equity/update-quotes/dry-run",
    36	    "/api/crypto/reconciliation/snapshots/preview",
    37	    "/api/crypto/reconciliation/transfers/preview",
    38	    "/api/portfolio/manual-snapshot/raiffeisen/preview",
    39	}
    40	POSTFINANCE_UPLOAD_PATHS = {
    41	    "/api/postfinance/imports/preview",
    42	    "/api/postfinance/imports/confirm",
    43	}
    44	MAX_POSTFINANCE_REQUEST_BYTES = 84_000_000
    45	
    46	
    47	def _is_safe_local_origin(origin: str) -> bool:
    48	    parsed = urlparse(origin)
    49	    if parsed.scheme != "http" or not parsed.hostname or not parsed.port:
    50	        return False
    51	    hostname = parsed.hostname.lower()
    52	    if hostname in {"localhost", "127.0.0.1"}:
    53	        return True
    54	    if hostname.startswith("100."):
    55	        return True
    56	    if hostname.endswith(".ts.net"):
    57	        return True
    58	    return False
    59	
    60	
    61	def build_local_origins(environ: dict[str, str] | None = None) -> list[str]:
    62	    env = environ or os.environ
    63	    origins = list(LOCAL_ORIGINS)
    64	    for raw_origin in env.get("JARVIS_FINANCE_CORS_ORIGINS", "").split(","):
    65	        origin = raw_origin.strip().rstrip("/")
    66	        if origin and _is_safe_local_origin(origin) and origin not in origins:
    67	            origins.append(origin)
    68	    return origins
    69	
    70	
    71	def create_app(*, write_mode: str | None = None) -> FastAPI:
    72	    active_write_mode = resolve_write_mode(write_mode)
    73	    app = FastAPI(
    74	        title="JARVIS Finance API",
    75	        version="0.1.0",
    76	        description="Read-only FastAPI v0 skeleton for the future Vue User Dashboard.",
    77	    )
    78	    app.add_middleware(
    79	        CORSMiddleware,
    80	        allow_origins=build_local_origins(),
    81	        allow_credentials=False,
    82	        allow_methods=["GET", "POST"] if active_write_mode == "disabled" else ["GET", "POST", "PUT", "PATCH", "DELETE"],
    83	        allow_headers=["*"],
    84	    )
    85	
    86	    @app.exception_handler(RequestValidationError)
    87	    async def safe_upload_validation_error(request: Request, exc: RequestValidationError):
    88	        if request.url.path.startswith("/api/postfinance/imports/"):
    89	            fields = {str(error.get("loc", ("",))[-1]) for error in exc.errors()}
    90	            if fields & {"zip_file_name", "zip_mime_type"}:
    91	                detail = "Bitte ein unterstütztes PostFinance-ZIP auswählen."
    92	            elif fields & {"overview_file_name", "overview_mime_type"}:
    93	                detail = "Bitte eine offizielle Portfolioübersicht als PDF auswählen."
    94	            else:
    95	                detail = "Eine Datei ist leer, zu gross oder unvollständig übertragen worden."
    96	            return JSONResponse(status_code=422, content={"detail": detail})
    97	        return await request_validation_exception_handler(request, exc)
    98	
    99	    @app.middleware("http")
   100	    async def block_untrusted_writes(request: Request, call_next):
   101	        if request.method == "POST" and request.url.path in POSTFINANCE_UPLOAD_PATHS:
   102	            raw_length = request.headers.get("content-length", "")
   103	            if not raw_length.isdigit():
   104	                return JSONResponse(
   105	                    status_code=411,
   106	                    content={"detail": "Upload benötigt eine prüfbare Dateigrösse."},
   107	                )
   108	            if int(raw_length) > MAX_POSTFINANCE_REQUEST_BYTES:
   109	                return JSONResponse(
   110	                    status_code=413,
   111	                    content={"detail": "Upload ist grösser als das sichere Verarbeitungslimit."},
   112	                )
   113	        read_only_post = request.method == "POST" and request.url.path in READ_ONLY_POST_PATHS
   114	        if request.method == "POST" and request.url.path == "/api/market/equity/update-quotes/dry-run" and not is_local_request(request):
   115	            return JSONResponse(status_code=403, content={"detail": "local_request_required"})
   116	        if request.method in WRITE_METHODS and not read_only_post and not is_write_request_allowed(request, active_write_mode):
   117	            return JSONResponse(status_code=403, content={"detail": "write_operations_disabled"})
   118	        return await call_next(request)
   119	
   120	    app.state.write_mode = active_write_mode
   121	    for router in (health.router, overview.router, crypto.router, crypto_trader.router, equity.router, cash.router, reports.router, positions.router, budget.router, system.router, market.router, truewealth.router, postfinance.router):
   122	        app.include_router(router, prefix="/api")
   123	    return app
   124	
   125	
   126	app = create_app()

__HERMES_CWD_8d46a20096ed__/home/agent/.hermes/worktrees/FinanceManager-sprint23__HERMES_CWD_8d46a20096ed__
