# Review guidelines:

You are acting as a reviewer for a proposed code change made by another engineer.

Below are some default guidelines for determining whether the original author would appreciate the issue being flagged.

These are not the final word in determining whether an issue is a bug. In many cases, you will encounter other, more specific guidelines. These may be present elsewhere in a developer message, a user message, a file, or even elsewhere in this system message.
Those guidelines should be considered to override these general instructions.

Here are the general guidelines for determining whether something is a bug and should be flagged.

1. It meaningfully impacts the accuracy, performance, security, or maintainability of the code.
2. The bug is discrete and actionable (i.e. not a general issue with the codebase or a combination of multiple issues).
3. Fixing the bug does not demand a level of rigor that is not present in the rest of the codebase (e.g. one doesn't need very detailed comments and input validation in a repository of one-off scripts in personal projects)
4. The bug was introduced in the commit (pre-existing bugs should not be flagged).
5. The author of the original PR would likely fix the issue if they were made aware of it.
6. The bug does not rely on unstated assumptions about the codebase or author's intent.
7. It is not enough to speculate that a change may disrupt another part of the codebase, to be considered a bug, one must identify the other parts of the code that are provably affected.
8. The bug is clearly not just an intentional change by the original author.

When flagging a bug, you will also provide an accompanying comment. Once again, these guidelines are not the final word on how to construct a comment -- defer to any subsequent guidelines that you encounter.

1. The comment should be clear about why the issue is a bug.
2. The comment should appropriately communicate the severity of the issue. It should not claim that an issue is more severe than it actually is.
3. The comment should be brief. The body should be at most 1 paragraph. It should not introduce line breaks within the natural language flow unless it is necessary for the code fragment.
4. The comment should not include any chunks of code longer than 3 lines. Any code chunks should be wrapped in markdown inline code tags or a code block.
5. The comment should clearly and explicitly communicate the scenarios, environments, or inputs that are necessary for the bug to arise. The comment should immediately indicate that the issue's severity depends on these factors.
6. The comment's tone should be matter-of-fact and not accusatory or overly positive. It should read as a helpful AI assistant suggestion without sounding too much like a human reviewer.
7. The comment should be written such that the original author can immediately grasp the idea without close reading.
8. The comment should avoid excessive flattery and comments that are not helpful to the original author. The comment should avoid phrasing like "Great job ...", "Thanks for ...".

Below are some more detailed guidelines that you should apply to this specific review.

HOW MANY FINDINGS TO RETURN:

Output all findings that the original author would fix if they knew about it. If there is no finding that a person would definitely love to see and fix, prefer outputting no findings. Do not stop at the first qualifying finding. Continue until you've listed every qualifying finding.

GUIDELINES:

- Ignore trivial style unless it obscures meaning or violates documented standards.
- Use one comment per distinct issue (or a multi-line range if necessary).
- Use ```suggestion blocks ONLY for concrete replacement code (minimal lines; no commentary inside the block).
- In every ```suggestion block, preserve the exact leading whitespace of the replaced lines (spaces vs tabs, number of spaces).
- Do NOT introduce or remove outer indentation levels unless that is the actual fix.

The comments will be presented in the code review as inline comments. You should avoid providing unnecessary location details in the comment body. Always keep the line range as short as possible for interpreting the issue. Avoid ranges longer than 5–10 lines; instead, choose the most suitable subrange that pinpoints the problem.

At the beginning of the finding title, tag the bug with priority level. For example "[P1] Un-padding slices along wrong tensor dimensions". [P0] – Drop everything to fix.  Blocking release, operations, or major usage. Only use for universal issues that do not depend on any assumptions about the inputs. · [P1] – Urgent. Should be addressed in the next cycle · [P2] – Normal. To be fixed eventually · [P3] – Low. Nice to have.

Additionally, include a numeric priority field in the JSON output for each finding: set "priority" to 0 for P0, 1 for P1, 2 for P2, or 3 for P3. If a priority cannot be determined, omit the field or use null.

At the end of your findings, output an "overall correctness" verdict of whether or not the patch should be considered "correct".
Correct implies that existing code and tests will not break, and the patch is free of bugs and other blocking issues.
Ignore non-blocking issues such as style, formatting, typos, documentation, and other nits.

FORMATTING GUIDELINES:
The finding description should be one paragraph.

OUTPUT FORMAT:

## Output schema  — MUST MATCH *exactly*

```json
{
  "findings": [
    {
      "title": "<≤ 80 chars, imperative>",
      "body": "<valid Markdown explaining *why* this is a problem; cite files/lines/functions>",
      "confidence_score": <float 0.0-1.0>,
      "priority": <int 0-3, optional>,
      "code_location": {
        "absolute_file_path": "<file path>",
        "line_range": {"start": <int>, "end": <int>}
      }
    }
  ],
  "overall_correctness": "patch is correct" | "patch is incorrect",
  "overall_explanation": "<1-3 sentence explanation justifying the overall_correctness verdict>",
  "overall_confidence_score": <float 0.0-1.0>
}
```

* **Do not** wrap the JSON in markdown fences or extra prose.
* The code_location field is required and must include absolute_file_path and line_range.
* Line ranges must be as short as possible for interpreting the issue (avoid ranges over 5–10 lines; pick the most suitable subrange).
* The code_location should overlap with the diff.
* Do not generate a PR fix.


<permissions instructions>
Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `read-only`: The sandbox only permits reading files. Network access is restricted.
Approval policy is currently never. Do not provide the `sandbox_permissions` for any reason, commands will be rejected.
</permissions instructions>

<apps_instructions>
## Apps (Connectors)
Apps (Connectors) can be explicitly triggered in user messages in the format `[$app-name](app://{connector_id})`. Apps can also be implicitly triggered as long as the context suggests usage of available apps.
An app is equivalent to a set of MCP tools within the `codex_apps` MCP.
An installed app's MCP tools are either provided to you already, or can be lazy-loaded through the `tool_search` tool. If `tool_search` is available, the apps that are searchable by `tools_search` will be listed by it.
Do not additionally call list_mcp_resources or list_mcp_resource_templates for apps.
</apps_instructions>

<skills_instructions>
## Skills
A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and file path so you can open the source for full instructions when using a specific skill.
### Available skills
- imagegen: Generate or edit raster images when the task benefits from AI-created bitmap visuals such as photos, illustrations, textures, sprites, mockups, or transparent-background cutouts. Use when Codex should create a brand-new image, transform an existing image, or derive visual variants from references, and the output should be a bitmap asset rather than repo-native code or vector. Do not use when the task is better handled by editing existing SVG/vector/code-native assets, extending an established icon or logo system, or building the visual directly in HTML/CSS/canvas. (file: /home/agent/.codex/skills/.system/imagegen/SKILL.md)
- openai-docs: Use when the user asks how to build with OpenAI products or APIs, asks about Codex itself or choosing Codex surfaces, needs up-to-date official documentation with citations, help choosing the latest model for a use case, or model upgrade and prompt-upgrade guidance; use OpenAI docs MCP tools for non-Codex docs questions, use the Codex manual helper first for broad Codex self-knowledge, and restrict fallback browsing to official OpenAI domains. (file: /home/agent/.codex/skills/.system/openai-docs/SKILL.md)
- plugin-creator: Create and scaffold plugin directories for Codex with a required `.codex-plugin/plugin.json`, optional plugin folders/files, valid manifest defaults, and personal-marketplace entries by default. Use when Codex needs to create a new personal plugin, add optional plugin structure, generate or update marketplace entries for plugin ordering and availability metadata, or update an existing local plugin during development with the CLI-driven cachebuster and reinstall flow. (file: /home/agent/.codex/skills/.system/plugin-creator/SKILL.md)
- skill-creator: Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Codex's capabilities with specialized knowledge, workflows, or tool integrations. (file: /home/agent/.codex/skills/.system/skill-creator/SKILL.md)
- skill-installer: Install Codex skills into $CODEX_HOME/skills from a curated list or a GitHub repo path. Use when a user asks to list installable skills, install a curated skill, or install a skill from another repo (including private repos). (file: /home/agent/.codex/skills/.system/skill-installer/SKILL.md)
### How to use skills
- Discovery: The list above is the skills available in this session (name + description + file path). Skill bodies live on disk at the listed paths.
- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.
- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.
- How to use a skill (progressive disclosure):
  1) After deciding to use a skill, the main agent must open and read its `SKILL.md` completely before taking task actions. If a read is truncated or paginated, continue until EOF.
  2) When `SKILL.md` references relative paths (e.g., `scripts/foo.py`), resolve them relative to the skill directory listed above first, and only consider other paths if needed.
  3) If `SKILL.md` points to extra folders such as `references/`, use its routing instructions to identify the files required for the task. The main agent must read each required instruction or reference file itself before acting on it. Do not delegate reading, summarizing, or interpreting skill instructions to a subagent. Subagents may still perform task work when the selected skill allows it.
  4) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.
  5) If `assets/` or templates exist, reuse them instead of recreating from scratch.
- Coordination and sequencing:
  - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.
  - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.
- Context hygiene:
  - Progressive disclosure applies to selecting relevant files, not partially reading a selected instruction file. Do not load unrelated references, scripts, or assets.
  - Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked.
  - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.
- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue.
</skills_instructions>

<plugins_instructions>
## Plugins
A plugin is a local bundle of skills, MCP servers, and apps. Below is the list of plugins that are enabled and available in this session.
### Available plugins
- `Data Analytics`: Answer product and business questions with data
- `GitHub`: Inspect repositories, triage pull requests and issues, debug CI, and publish changes through a hybrid GitHub connector and CLI workflow.
- `Gmail`: Work with Gmail using the configured Gmail app connector.
- `Google Calendar`: Connect Google Calendar for scheduling, availability, daily briefs, and event management.
- `Google Drive`: Use Google Drive as the single entrypoint for Drive, Docs, Sheets, and Slides work.
- `Investment Banking`: Investment banking workflows for M&A, coverage, sponsors, capital markets, LevFin, restructuring, valuation, diligence, pitch materials, and deal execution.
- `Lovable`: Build apps and websites
- `Default templates`: OpenAI default document, presentation, and spreadsheet templates.
- `Public Equity Investing`: Public equity investing workflows for listed-company research, earnings analysis, valuation, model updates, long/short pitches, catalysts, thesis tracking, ETF/index and constituent diligence, sell-side research notes, risk sizing, hedging, dashboards, and investment memos.
### How to use plugins
- Discovery: The list above is the plugins available in this session.
- Skill naming: If a plugin contributes skills, those skill entries are prefixed with `plugin_name:` in the Skills list.
- Trigger rules: If the user explicitly names a plugin, prefer capabilities associated with that plugin for that turn.
- Relationship to capabilities: Plugins are not invoked directly. Use their underlying skills, MCP tools, and app tools to help solve the task.
- Preference: When a relevant plugin is available, prefer using capabilities associated with that plugin over standalone capabilities that provide similar functionality.
- Missing/blocked: If the user requests a plugin that is not listed above, or the plugin does not have relevant callable capabilities for the task, say so briefly and continue with the best fallback.
</plugins_instructions>


# AGENTS.md instructions for /home/agent/.hermes/worktrees/FinanceManager-sprint12-truewealth-verified-import

<INSTRUCTIONS>
# AGENTS.md

## FinanceManager-2.0-Regeln

- Arbeite in kleinen vertikalen PRs; keine Big-Bang-Rewrites.
- Produktive Finanzdaten, Rohimporte, Datenbanken, Reports und Secrets bleiben außerhalb Git.
- Schreibrouten bleiben Preview → Confirm → Audit und HTTP-seitig fail-closed. Keine Orderausführung.
- Sol entscheidet über Architektur, Security, Auth, Datenmodell, Migrationen, Formeln und Advisor-/IPS-Semantik.
- Spark nur sequenziell für ein kleines Ergebnis (ca. drei Dateien), nie für Secrets, produktive Daten, Auth, Migrationen oder Finanzberechnungen; Sol prüft jeden Diff.
- Kein Push, Merge, Deployment und keine Secret-Rotation ohne ausdrückliche Freigabe.
- Vor Abschluss: `make PYTHON=.venv/bin/python verify` und `git diff --check` tatsächlich ausführen.
- Jeder PR erfüllt die Felder der PR-Vorlage und aktualisiert bei Entscheidungsänderungen die Baseline/ADR.

Details: `docs/finance-manager-2.0/sprint-0-baseline.md`.

</INSTRUCTIONS>

<environment_context>
  <cwd>/home/agent/.hermes/worktrees/FinanceManager-sprint12-truewealth-verified-import</cwd>
  <shell>bash</shell>
  <current_date>2026-07-27</current_date>
  <timezone>Europe/Zurich</timezone>
  <filesystem><workspace_roots><root>/home/agent/.hermes/worktrees/FinanceManager-sprint12-truewealth-verified-import</root></workspace_roots><permission_profile type="managed"><file_system type="restricted"><entry access="read"><special>:root</special></entry></file_system></permission_profile></filesystem>
</environment_context>


Summary: auto


Review the current code changes (staged, unstaged, and untracked files) and provide prioritized findings.


git status --short && git diff --stat && git diff --cached --stat


Output:
Chunk ID: 07b481
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 732
Output:
 M .github/workflows/portfolio-phase3-integration.yml
 M docs/sprint12-truewealth-source-gate.md
 M docs/sprint12-truewealth-verified-import.md
 M frontend/src/pages/CashTrueWealthManagement.test.ts
 M frontend/src/pages/TrueWealthPage.vue
 M pyproject.toml
 M requirements-ci.txt
 M scripts/ci_portfolio_phase3_gate.py
 M src/jarvis_finance/api/main.py
 M src/jarvis_finance/services/portfolio_data.py
 M src/jarvis_finance/services/portfolio_performance.py
 M src/jarvis_finance/services/portfolio_service.py
 M src/jarvis_finance/services/reconciliation_snapshot.py
 M src/jarvis_finance/storage/migrations.py
 M tests/unit/test_budget_monthly_import_rule_learning_v1.py
 M tests/unit/test_budget_phase1.py
 M tests/unit/test_budget_phase11.py
 M tests/unit/test_budget_phase12_seed_review.py
 M tests/unit/test_budget_phase16_user_rules.py
 M tests/unit/test_fixed_costs_subscriptions_v1.py
 M tests/unit/test_grocery_matching_learning_v2.py
 M tests/unit/test_grocery_price_providers_v1.py
 M tests/unit/test_portfolio_data_ingestion_reconciliation.py
 M tests/unit/test_schema.py
?? frontend/src/api/truewealth.ts
?? src/jarvis_finance/api/routers/truewealth.py
?? src/jarvis_finance/api/schemas/truewealth.py
?? src/jarvis_finance/imports/truewealth_tax_statement.py
?? src/jarvis_finance/services/truewealth_service.py
?? tests/test_truewealth_sprint12.py
 .github/workflows/portfolio-phase3-integration.yml |   5 +
 docs/sprint12-truewealth-source-gate.md            |  95 +++++-----
 docs/sprint12-truewealth-verified-import.md        |  52 ++++--
 .../src/pages/CashTrueWealthManagement.test.ts     |  30 +--
 frontend/src/pages/TrueWealthPage.vue              | 208 +++++++++++++++++++--
 pyproject.toml                                     |   2 +-
 requirements-ci.txt                                |   1 +
 scripts/ci_portfolio_phase3_gate.py                |   6 +-
 src/jarvis_finance/api/main.py                     |  11 +-
 src/jarvis_finance/services/portfolio_data.py      |   7 +-
 .../services/portfolio_performance.py              |   6 +-
 src/jarvis_finance/services/portfolio_service.py   |   5 +-
 .../services/reconciliation_snapshot.py            |   5 +-
 src/jarvis_finance/storage/migrations.py           | 172 ++++++++++++++++-
 .../test_budget_monthly_import_rule_learning_v1.py |   2 +-
 tests/unit/test_budget_phase1.py                   |   2 +-
 tests/unit/test_budget_phase11.py                  |   2 +-
 tests/unit/test_budget_phase12_seed_review.py      |   2 +-
 tests/unit/test_budget_phase16_user_rules.py       |   2 +-
 tests/unit/test_fixed_costs_subscriptions_v1.py    |   2 +-
 tests/unit/test_grocery_matching_learning_v2.py    |   2 +-
 tests/unit/test_grocery_price_providers_v1.py      |   2 +-
 ...test_portfolio_data_ingestion_reconciliation.py |   4 +-
 tests/unit/test_schema.py                          |   2 +-
 24 files changed, 509 insertions(+), 118 deletions(-)


git diff -- . ':(exclude)docs/*' && for f in frontend/src/api/truewealth.ts src/jarvis_finance/api/routers/truewealth.py src/jarvis_finance/api/schemas/truewealth.py src/jarvis_finance/imports/truewealth_tax_statement.py src/jarvis_finance/services/truewealth_service.py tests/test_truewealth_sprint12.py; do echo '--- ' $f; sed -n '1,240p' $f; done


Output:
Chunk ID: 087668
Wall time: 0.0003 seconds
Process exited with code 0
Original token count: 23464
Output:
Total output lines: 1721

diff --git a/.github/workflows/portfolio-phase3-integration.yml b/.github/workflows/portfolio-phase3-integration.yml
index 7b317f6..32341ae 100644
--- a/.github/workflows/portfolio-phase3-integration.yml
+++ b/.github/workflows/portfolio-phase3-integration.yml
@@ -100,7 +100,9 @@ jobs:
             scripts/ci_portfolio_phase3_gate.py \
             src/jarvis_finance/api/main.py \
             src/jarvis_finance/api/routers/overview.py \
+            src/jarvis_finance/api/routers/truewealth.py \
             src/jarvis_finance/api/schemas/portfolio_data.py \
+            src/jarvis_finance/api/schemas/truewealth.py \
             src/jarvis_finance/api/schemas/portfolio_performance.py \
             src/jarvis_finance/api/schemas/portfolio_policy.py \
             src/jarvis_finance/api/schemas/reconciliation.py \
@@ -108,6 +110,7 @@ jobs:
             src/jarvis_finance/config/settings.py \
             src/jarvis_finance/ledger/cost_basis.py \
             src/jarvis_finance/ledger/performance.py \
+            src/jarvis_finance/imports/truewealth_tax_statement.py \
             src/jarvis_finance/quality/freshness.py \
             src/jarvis_finance/quality/git_safety.py \
             src/jarvis_finance/services/portfolio_data.py \
@@ -115,8 +118,10 @@ jobs:
             src/jarvis_finance/services/portfolio_policy.py \
             src/jarvis_finance/services/reconciliation_snapshot.py \
             src/jarvis_finance/services/transfer_pairing.py \
+            src/jarvis_finance/services/truewealth_service.py \
             src/jarvis_finance/storage/migrations.py \
             tests/conftest.py \
+            tests/test_truewealth_sprint12.py \
             tests/unit/test_api_write_security.py \
             tests/unit/test_git_safety.py \
             tests/unit/test_portfolio_data_ingestion_reconciliation.py \
diff --git a/frontend/src/pages/CashTrueWealthManagement.test.ts b/frontend/src/pages/CashTrueWealthManagement.test.ts
index aec0ab5..f85febe 100644
--- a/frontend/src/pages/CashTrueWealthManagement.test.ts
+++ b/frontend/src/pages/CashTrueWealthManagement.test.ts
@@ -3,13 +3,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
 import CashPage from './CashPage.vue'
 import TrueWealthPage from './TrueWealthPage.vue'
 import { getCashSummary, previewCashSnapshot, confirmCashSnapshot, getCashPosition } from '@/api/cash'
-import { getOverview } from '@/api/portfolio'
-import { getReferenceAccounts, previewAccountValue, confirmAccountValue } from '@/api/positions'
+import { confirmManualTrueWealthValue, getTrueWealthSummary, previewManualTrueWealthValue } from '@/api/truewealth'
 import { getHealth, getRuntimeStatus } from '@/api/runtime'

 vi.mock('@/api/cash', () => ({ getCashSummary: vi.fn(), getCashPosition: vi.fn(), previewCashSnapshot: vi.fn(), confirmCashSnapshot: vi.fn() }))
 vi.mock('@/api/portfolio', () => ({ getOverview: vi.fn() }))
 vi.mock('@/api/positions', () => ({ getReferenceAccounts: vi.fn(), previewAccountValue: vi.fn(), confirmAccountValue: vi.fn() }))
+vi.mock('@/api/truewealth', () => ({ getTrueWealthSummary: vi.fn(), previewManualTrueWealthValue: vi.fn(), confirmManualTrueWealthValue: vi.fn(), previewTrueWealthImport: vi.fn(), confirmTrueWealthImport: vi.fn(), setManualTrueWealthValueStatus: vi.fn() }))
 vi.mock('@/api/runtime', () => ({ getHealth: vi.fn(), getRuntimeStatus: vi.fn() }))

 const cashSummary = {
@@ -72,28 +72,32 @@ describe('TrueWealthPage management', () => {
     vi.clearAllMocks()
     vi.mocked(getHealth).mockResolvedValue({ status: 'ok', app: 'jarvis-finance-api', api_version: '0', mode: 'read-only' })
     vi.mocked(getRuntimeStatus).mockResolvedValue({ db_available: true, db_path: '/runtime/finance.sqlite3', base_currency: 'CHF', runtime_outside_repo: true, write_mode: 'preview-confirm' })
-    vi.mocked(getOverview).mockResolvedValue({ total_value_chf: '9000.00', crypto_value_chf: '0.00', equity_value_chf: '0.00', postfinance_equity_value_chf: '0.00', truewealth_value_chf: '9000.00', cash_value_chf: '0.00', unpriced_positions_count: 0, critical_alerts_count: 0, last_price_update: null, data_quality_status: 'ok' })
-    vi.mocked(getReferenceAccounts).mockResolvedValue([{ account_id: 'acct_tw', label: 'TrueWealth · TrueWealth Gesamtwert', account_type: 'managed_portfolio', currency: 'CHF', valuation_mode: 'manual_total_value', portfolio_bucket: 'truewealth', allow_position_adds: false }])
-    vi.mocked(previewAccountValue).mockResolvedValue({ preview_id: 'preview_tw', summary: 'Gesamtwert', amount_chf: '9999.00', fx_status: 'not_needed', warnings: [] })
-    vi.mocked(confirmAccountValue).mockResolvedValue({ status: 'confirmed', entity_id: 'acctval_1', audit_id: 'audit_tw', message: 'gespeichert' })
+    vi.mocked(getTrueWealthSummary).mockResolvedValue({
+      portfolio_label: 'Freie Anlagen', current_value_chf: '12000.00', current_valuation_at: '2026-07-27T23:59:59Z', current_source_type: 'truewealth_official_import', current_is_manual_provisional: false, current_label: 'Gesamtwert gemäss offiziellem Import', positions_snapshot_date: '2026-07-27', positions_label: 'Positionsdetails gemäss letztem Import vom 2026-07-27', mixed_as_of: false, allocation_performance_usable: true,
+      positions: [{ snapshot_position_id: 'hidden_position_id', instrument_name: 'Synthetic ETF', isin: 'CH0000000001', asset_type: null, quantity: '100', price_currency: 'CHF', source_price: '50.00', source_value_chf: '5000.00' }], cash: [], history: [], imports: [], latest_official: {},
+    })
+    vi.mocked(previewManualTrueWealthValue).mockResolvedValue({ preview_id: 'preview_tw', confirmation_id: 'confirm_tw', total_value_chf: '12500.00', valuation_at: '2026-07-28T08:00:00Z', valuation_date: '2026-07-28', note: '', source_type: 'truewealth_manual_provisional', status: 'manual_provisional', warnings: [] })
+    vi.mocked(confirmManualTrueWealthValue).mockResolvedValue({ status: 'confirmed', snapshot_id: 'acctval_1', audit_id: 'audit_tw' })
   })

-  it('shows truewealth manual update workflow and forbids individual positions wording', async () => {
+  it('shows official positions and runs manual provisional preview confirm', async () => {
     const wrapper = mount(TrueWealthPage)
     await flushPromises()

-    expect(wrapper.text()).toContain('aktueller TrueWealth-Gesamtwert CHF')
-    expect(wrapper.text()).toContain('keine Einzelpositionen')
+    expect(wrapper.text()).toContain('Aktueller TrueWealth-Gesamtwert CHF')
+    expect(wrapper.text()).toContain('Synthetic ETF')
+    expect(wrapper.text()).not.toContain('hidden_position_id')
     await wrapper.get('[data-testid="truewealth-update-button"]').trigger('click')
-    await wrapper.get('[data-testid="truewealth-amount-input"]').setValue('9999.00')
-    await wrapper.get('[data-testid="truewealth-date-input"]').setValue('2026-05-21')
+    await wrapper.get('[data-testid="truewealth-amount-input"]').setValue('12500.00')
+    await wrapper.get('[data-testid="truewealth-date-input"]').setValue('2026-07-28T10:00')
     await wrapper.get('[data-testid="truewealth-preview-button"]').trigger('click')
     await flushPromises()
+    expect(wrapper.text()).toContain('Manuell / provisorisch')
     await wrapper.get('[data-testid="truewealth-confirm-button"]').trigger('click')
     await flushPromises()

-    expect(previewAccountValue).toHaveBeenCalledWith('acct_tw', { valuation_date: '2026-05-21', total_value_chf: '9999.00', note: '' })
-    expect(confirmAccountValue).toHaveBeenCalledWith('acct_tw', { valuation_date: '2026-05-21', total_value_chf: '9999.00', note: '', preview_id: 'preview_tw', confirm: true })
+    expect(previewManualTrueWealthValue).toHaveBeenCalledWith({ total_value_chf: '12500.00', valuation_at: expect.any(String), note: '' })
+    expect(confirmManualTrueWealthValue).toHaveBeenCalledWith({ total_value_chf: '12500.00', valuation_at: expect.any(String), note: '', preview_id: 'preview_tw', confirmation_id: 'confirm_tw', confirm: true })
     expect(wrapper.text()).toContain('Audit')
   })
 })
diff --git a/frontend/src/pages/TrueWealthPage.vue b/frontend/src/pages/TrueWealthPage.vue
index 0e8af9b..657ad9c 100644
--- a/frontend/src/pages/TrueWealthPage.vue
+++ b/frontend/src/pages/TrueWealthPage.vue
@@ -1,29 +1,205 @@
 <template>

-    <LoadActionBar testid="refresh-truewealth" title="TrueWealth Roboadvisor" description="Managed Portfolio als eigener Anlagenbereich. Keine Einzelinstrument-Sammlung." :loading="loading" @load="loadData(true)" />
+    <LoadActionBar
+      testid="refresh-truewealth"
+      title="TrueWealth · Freie Anlagen"
+      description="Offizielle Portfolio-Snapshots und provisorische manuelle Gesamtwerte mit klar getrenntem Datenstand."
+      :loading="loading"
+      @load="loadData(true)"
+    />
     <LocalDataStatus :backend-ok="backendOk" :runtime-ok="runtimeOk" :loading="loading" :error="error" :last-loaded-at="lastLoadedAt" />
-

+
+

-

Anlagen

aktueller TrueWealth-Gesamtwert CHF

Manuell gepflegter Gesamtwert. keine Einzelpositionen, keine Aktien-/ETF-Zuordnung, kein PostFinance-Mix.

-        <MoneyValue :value="overview?.truewealth_value_chf ?? '0.00'" />
+

+
Freie Anlagen

+
Aktueller TrueWealth-Gesamtwert CHF

+
{{ summary?.current_label ?? 'Noch kein Wert verfügbar' }}

+

+        <MoneyValue :value="summary?.current_value_chf ?? '0.00'" />
+

+

+        Manuell / provisorisch. {{ summary.positions_label }}. Die Differenz wird weder Positionen noch Cash zugerechnet.
+

+

+        Gesamtwert und Positionsdetails haben unterschiedliche Stichtage. Allokation, Gewinn und Rendite sind für diesen Mischstand deaktiviert.
+

+

+        <button data-testid="truewealth-update-button" type="button" class="rounded-lg border border-cyan-300 bg-cyan-50 px-3 py-2 font-semibold text-cyan-800" @click="showManualForm = !showManualForm">Gesamtwert manuell aktualisieren</button>
+        <button data-testid="truewealth-import-button" type="button" class="rounded-lg border border-slate-300 bg-white px-3 py-2 font-semibold text-slate-800" @click="showImportForm = !showImportForm">Offiziellen Auszug importieren</button>
+        Zur Portfolio-Übersicht

-
<button data-testid="truewealth-update-button" type="button" class="rounded-lg border border-cyan-300 bg-cyan-50 px-3 py-2 font-semibold text-cyan-800" @click="showForm = true">TrueWealth-Wert aktualisieren</button>Zur Portfolio-Übersicht

-
Keine Provider-Abfrage beim Rendern. Keine Positionen unter TrueWealth erfassen.

-

TrueWealth-Wert aktualisieren

<input data-testid="truewealth-amount-input" v-model="form.total_value_chf" class="rounded border p-2" placeholder="Gesamtwert CHF" /><input data-testid="truewealth-date-input" v-model="form.valuation_date" class="rounded border p-2" type="date" /><input data-testid="truewealth-note-input" v-model="form.note" class="rounded border p-2" placeholder="Notiz optional" />

<button data-testid="truewealth-preview-button" class="rounded bg-slate-900 px-3 py-2 text-white" @click="doPreview">Preview</button><button v-if="preview" data-testid="truewealth-confirm-button" class="rounded bg-emerald-600 px-3 py-2 text-white" @click="doConfirm">Confirm</button>

Preview: {{ preview.amount_chf }}

{{ message }}

+

+
Gesamtwert manuell aktualisieren

+
Erstellt einen neuen historischen, provisorischen Datensatz. Offizielle Positionen bleiben unverändert.

+

+        Gesamtwert CHF<input data-testid="truewealth-amount-input" v-model="manualForm.total_value_chf" class="mt-1 w-full rounded-lg border border-slate-300 bg-white p-2" inputmode="decimal" placeholder="Betrag in CHF" @input="manualPreview = null" />
+        Bewertungszeitpunkt<input data-testid="truewealth-date-input" v-model="manualForm.valuation_at" class="mt-1 w-full rounded-lg border border-slate-300 bg-white p-2" type="datetime-local" @input="manualPreview = null" />
+        Notiz optional<input data-testid="truewealth-note-input" v-model="manualForm.note" class="mt-1 w-full rounded-lg border border-slate-300 bg-white p-2" maxlength="240" placeholder="z. B. Stand im Kundenportal" @input="manualPreview = null" />
+

+

+        <button data-testid="truewealth-preview-button" class="rounded-lg bg-slate-900 px-3 py-2 font-semibold text-white disabled:opacity-50" :disabled="actionLoading || !manualForm.total_value_chf || !manualForm.valuation_at" @click="doManualPreview">Preview</button>
+        <button v-if="manualPreview" data-testid="truewealth-confirm-button" class="rounded-lg bg-emerald-600 px-3 py-2 font-semibold text-white disabled:opacity-50" :disabled="actionLoading" @click="doManualConfirm">Confirm</button>
+

+

+
Preview: CHF {{ money(manualPreview.total_value_chf) }} · {{ formatDateTime(manualPreview.valuation_at) }}

+
Manuell / provisorisch. Positionsdetails werden nicht verändert.

+

+

-

Verlauf der manuellen Gesamtwert-Snapshots

Historie wird aus bestätigten Snapshots geführt; Audit-Hinweis je Confirm vorhanden. Keine Roh-IDs im User Mode.

+

+
Offiziellen TrueWealth-Auszug importieren

+
Nur unveränderte TrueWealth-Steuerauszüge als PDF. Der Import wird zuerst vollständig geprüft und angezeigt.

+      <input data-testid="truewealth-file-input" class="mt-3 block w-full rounded-lg border border-slate-300 bg-white p-2 text-sm" type="file" accept="application/pdf,.pdf" @change="onFileSelected" />
+

+        <button data-testid="truewealth-import-preview-button" class="rounded-lg bg-slate-900 px-3 py-2 font-semibold text-white disabled:opacity-50" :disabled="actionLoading || !importPayload" @click="doImportPreview">Import-Preview</button>
+        <button v-if="importPreview" data-testid="truewealth-import-confirm-button" class="rounded-lg bg-emerald-600 px-3 py-2 font-semibold text-white disabled:opacity-50" :disabled="actionLoading || importPreview.reconciliation_status === 'mismatch' || importPreview.conflict" @click="doImportConfirm">Import bestätigen</button>
+

+

+

Stichtag

{{ formatDate(importPreview.snapshot_date) }}

+

Gesamtwert

CHF {{ money(importPreview.source_total_chf) }}

+

Inhalt

{{ importPreview.positions.length }} Positionen · {{ importPreview.cash.length }} Cashkonten

+

Reconciliation

{{ importPreview.reconciliation_status }} · CHF {{ money(importPreview.reconciliation_difference_chf) }}

+
Identische Quelle bereits bestätigt. Ein erneuter Confirm ist idempotent.

+
Konflikt: Für diesen Stichtag existiert bereits eine andere offizielle Quelle. Confirm ist gesperrt.

+

+

+
+
{{ message }}

+
{{ actionError }}

+
+

+

Positionsdetails

{{ summary?.positions_label }}

{{ summary?.positions.length ?? 0 }} Positionen

+

+

+
{{ position.instrument_name }}

{{ position.isin }}

+
Menge
{{ position.quantity }}Quellwert
CHF {{ money(position.source_value_chf) }}

+

+

+
Noch keine offiziellen Positionen importiert.

+
 str:
    return format(value.quantize(Decimal(places)), "f")

def _decode_pdf(content_base64: str) -> bytes:
    try:
        return base64.b64decode(content_base64, validate=True)
    except (binascii.Error, ValueError) as exc:
        raise ValueError("TrueWealth PDF is not valid base64") from exc

def _resolve_account_id(conn: Connection, requested: str | None = None) -> str:
    mapped = conn.execute(
        "SELECT account_id FROM truewealth_portfolios WHERE portfolio_id=? AND is_active=1",
        (TRUEWEALTH_PORTFOLIO_ID,),
    ).fetchone()
    if mapped:
        return str(mapped["account_id"])
    configured = os.environ.get("JARVIS_TRUEWEALTH_ACCOUNT_ID", "").strip()
    if configured:
        return configured
    if requested:
        return requested
    candidates = conn.execute(
        """SELECT DISTINCT a.account_id
           FROM accounts a
           JOIN platforms p ON p.platform_id=a.platform_id
           WHERE a.is_active=1
             AND replace(replace(lower(p.name),' ',''),'-','') LIKE '%truewealth%'
             AND EXISTS (
                 SELECT 1 FROM account_value_snapshots v
                 WHERE v.account_id=a.account_id AND COALESCE(v.is_active,1)=1
             )
           ORDER BY a.account_id"""
    ).fetchall()
    if not candidates:
        candidates = conn.execute(
            """SELECT DISTINCT a.account_id
               FROM accounts a
               JOIN platforms p ON p.platform_id=a.platform_id
               WHERE a.is_active=1
                 AND replace(replace(lower(p.name),' ',''),'-','') LIKE '%truewealth%'
               ORDER BY a.account_id"""
        ).fetchall()
    if len(candidates) != 1:
        raise ValueError("Confirmed TrueWealth target account cannot be resolved unambiguously")
    return str(candidates[0]["account_id"])

def _validate_account(conn: Connection, account_id: str | None = None) -> str:
    resolved = _resolve_account_id(conn, account_id)
    if account_id and account_id != resolved:
        raise ValueError("TrueWealth portfolio account does not match the confirmed mapping")
    row = conn.execute(
        """SELECT a.account_id,a.is_active,p.name AS platform
           FROM accounts a JOIN platforms p ON p.platform_id=a.platform_id
           WHERE a.account_id=?""",
        (resolved,),
    ).fetchone()
    platform_key = str(row["platform"] if row else "").lower().replace(" ", "").replace("-", "")
    if not row or not row["is_active"] or "truewealth" not in platform_key:
        raise ValueError("Confirmed TrueWealth target account is unavailable")
    return resolved

def _revision(conn: Connection, account_id: str) -> str:
    row = conn.execute(
        """SELECT COALESCE(MAX(created_at),'') AS latest,COUNT(*) AS count
           FROM account_value_snapshots WHERE account_id=?""",
        (account_id,),
    ).fetchone()
    imported = conn.execute("SELECT COUNT(*) FROM truewealth_import_batches").fetchone()[0]
    return _json_hash([row["latest"], row["count"], imported])

def _preview_identity(kind: str, payload: dict[str, Any], revision: str) -> tuple[str, str]:
    fingerprint = _json_hash([kind, payload, revision])
    return f"twpreview_{fingerprint[:24]}", f"twconfirm_{fingerprint[24:48]}"

def _statement_preview_payload(statement: TrueWealthStatement, filename: str) -> dict[str, Any]:
    difference = statement.source_total_chf - statement.components_total_chf
    return {
        "portfolio_label": TRUEWEALTH_LABEL,
        "snapshot_date": statement.statement_date,
        "period_from": statement.period_from,
        "period_to": statement.period_to,
        "file_sha256": statement.file_sha256,
        "filename_sha256": sha256(filename.encode()).hexdigest(),
        "page_count": statement.page_count,
        "source_total_chf": _decimal_text(statement.source_total_chf),
        "securities_total_chf": _decimal_text(statement.securities_total_chf),
        "cash_total_chf": _decimal_text(statement.cash_total_chf),
        "components_total_chf": _decimal_text(statement.components_total_chf),
        "reconciliation_difference_chf": _decimal_text(difference),
        "reconciliation_status": "matched" if difference == 0 else "within_tolerance",
        "positions": [
            {
                "name": row.name,
                "isin": row.isin,
                "quantity": format(row.quantity, "f"),
                "currency": row.currency,
                "source_price": format(row.source_price, "f"),
                "source_value_chf": _decimal_text(row.source_value_chf),
            }
            for row in statement.positions
        ],
        "cash": [
            {
                "currency": row.currency,
                "amount_original": format(row.amount_original, "f"),
                "fx_rate_to_chf": format(row.fx_rate_to_chf, "f") if row.fx_rate_to_chf is not None else None,
                "source_value_chf": _decimal_text(row.value_chf),
            }
            for row in statement.cash
        ],
        "parser_id": PARSER_ID,
        "parser_version": PARSER_VERSION,
    }

def preview_truewealth_import(conn: Connection, request: dict[str, Any]) -> dict[str, Any]:
    requested = str(request.get("account_id") or "").strip() or None
    account_id = _validate_account(conn, requested)
    raw = _decode_pdf(str(request.get("content_base64") or ""))
    filename = str(request.get("file_name") or "source.pdf")
    statement = parse_truewealth_tax_statement(raw)
    payload = _statement_preview_payload(statement, filename)
    preview_id, confirmation_id = _preview_identity("official_import", payload, _revision(conn, account_id))
    duplicate = conn.execute(
        "SELECT batch_id FROM truewealth_import_batches WHERE file_sha256=?",
        (statement.file_sha256,),
    ).fetchone()
    conflict = conn.execute(
        """SELECT batch_id FROM truewealth_import_batches
           WHERE account_id=? AND snapshot_date=? AND file_sha256<>? LIMIT 1""",
        (account_id, statement.statement_date, statement.file_sha256),
    ).fetchone()
    warnings = []
    if statement.source_total_chf != statement.components_total_chf:
        warnings.append("Source total is rounded to whole CHF; components differ within CHF 1.00")
    if conflict:
        warnings.append("A different official source already exists for this snapshot date")
    return {
        **payload,
        "preview_id": preview_id,
        "confirmation_id": confirmation_id,
        "duplicate": bool(duplicate),
        "existing_batch_id": str(duplicate["batch_id"]) if duplicate else None,
        "conflict": bool(conflict),
        "conflicting_batch_id": str(conflict["batch_id"]) if conflict else None,
        "warnings": warnings,
    }

def _archive_pdf(raw: bytes, digest: str) -> tuple[str, bool]:
    root = Path(os.environ.get("JARVIS_FINANCE_RUNTIME_DIR", str(DEFAULT_RUNTIME_DIR))).expanduser()
    folder = root / "imports" / "truewealth" / "archive"
    folder.mkdir(mode=0o700, parents=True, exist_ok=True)
    try:
        folder.chmod(0o700)
    except OSError:
        pass
    target = folder / f"{digest}.pdf"
    created = False
    if target.exists():
        if sha256(target.read_bytes()).hexdigest() != digest:
            raise ValueError("Archived TrueWealth source hash mismatch")
    else:
        tmp = folder / f".{digest}.{uuid4().hex}.tmp"
        try:
            fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
            with os.fdopen(fd, "wb") as handle:
                handle.write(raw)
                handle.flush()
                os.fsync(handle.fileno())
            os.replace(tmp, target)
            target.chmod(0o600)
            created = True
        finally:
            if tmp.exists():
                tmp.unlink()
    return f"truewealth/archive/{digest}.pdf", created

def _remove_new_archive(reference: str) -> None:
    root = Path(os.environ.get("JARVIS_FINANCE_RUNTIME_DIR", str(DEFAULT_RUNTIME_DIR))).expanduser()
    target = root / "imports" / reference
    try:
        target.unlink(missing_ok=True)
    except OSError:
        pass

def _insert_audit(
    conn: Connection,
    *,
    action: str,
    entity_type: str,
    entity_id: str,
    details: dict[str, Any],
    note: str = "",
---  tests/test_truewealth_sprint12.py
from __future__ import annotations

import base64
from decimal import Decimal
from hashlib import sha256

import pytest

from jarvis_finance.imports.truewealth_tax_statement import (
    TrueWealthCash,
    TrueWealthPosition,
    TrueWealthStatement,
    parse_truewealth_text,
)
from jarvis_finance.services import truewealth_service as service
from jarvis_finance.storage.database import connect_memory
from jarvis_finance.storage.migrations import apply_migrations

TEST_ACCOUNT_ID = "account_truewealth_test"

def make_conn():
    conn = connect_memory()
    apply_migrations(conn)
    conn.execute(
        "INSERT INTO platforms(platform_id,name,platform_type,country,default_currency,is_active,created_at) VALUES('p','True Wealth','robo','CH','CHF',1,'2026-01-01')"
    )
    conn.execute(
        """INSERT INTO accounts(account_id,platform_id,account_name,account_type,currency,performance_included,is_active,created_at,portfolio_bucket)
           VALUES(?,'p','Manual Portfolio','managed_portfolio','CHF',1,1,'2026-01-01','truewealth')""",
        (TEST_ACCOUNT_ID,),
    )
    conn.execute(
        """INSERT INTO account_value_snapshots(snapshot_id,account_id,valuation_date,total_value_chf,currency,source_type,quality_status,notes,created_at)
           VALUES('legacy_manual',?,'2026-06-30','10000','CHF','manual_total_value','ok','historical','2026-06-30T12:00:00Z')""",
        (TEST_ACCOUNT_ID,),
    )
    conn.commit()
    return conn

def statement(raw: bytes, date: str = "2026-07-27", total: str = "12000") -> TrueWealthStatement:
    return TrueWealthStatement(
        statement_date=date,
        period_from="2026-01-01",
        period_to=date,
        source_total_chf=Decimal(total),
        positions=(
            TrueWealthPosition("position:CH0000000001", "Synthetic ETF A", "CH0000000001", Decimal("100"), "CHF", Decimal("50"), Decimal("5000")),
            TrueWealthPosition("position:US0000000002", "Synthetic ETF B", "US0000000002", Decimal("20"), "CHF", Decimal("200"), Decimal("4000")),
        ),
        cash=(TrueWealthCash("cash:CHF", "CHF", Decimal("3000"), None, Decimal("3000")),),
        file_sha256=sha256(raw).hexdigest(),
        page_count=7,
    )

def import_request(raw: bytes) -> dict[str, str]:
    return {
        "account_id": TEST_ACCOUNT_ID,
        "file_name": "official.pdf",
        "content_base64": base64.b64encode(raw).decode(),
    }

def confirm_import(conn, raw: bytes):
    request = import_request(raw)
    preview = service.preview_truewealth_import(conn, request)
    return service.confirm_truewealth_import(
        conn,
        {**request, "preview_id": preview["preview_id"], "confirmation_id": preview["confirmation_id"], "confirm": True},
    )

def test_text_parser_extracts_official_rows_without_inventing_values():
    text = """
True Wealth AG
Periode 01.01.2026 - 27.07.2026
Steuerauszug in CHF 27.07.2026
Total Steuerwert der A, B, DA-1 und USA-Werte am 27.07.2026
12’000 0 0 0
CHF (Synthetic Custodian AG)
27.07.2026 Steuerwert / Ertrag CHF 7’000.00 7’000.00 0.00
0000000 CH0000000001 - Synthetic ETF A, CH
27.07.2026 Bestand / Steuerwert / Ertrag 100.00 CHF 50.00 5’000.00 0.00
True Wealth AG
"""
    parsed = parse_truewealth_text(text, file_sha256="synthetic", page_count=1)
    assert parsed.statement_date == "2026-07-27"
    assert parsed.source_total_chf == Decimal("12000")
    assert parsed.positions[0].isin == "CH0000000001"
    assert parsed.cash[0].amount_original == Decimal("7000.00")
    assert parsed.components_total_chf == Decimal("12000.00")

def test_platform_spelling_without_space_and_empty_account_bootstrap(monkeypatch):
    conn = make_conn()
    conn.execute("UPDATE platforms SET name='TrueWealth' WHERE platform_id='p'")
    conn.execute("DELETE FROM account_value_snapshots")
    conn.commit()
    raw = b"%PDF-synthetic-bootstrap"
    monkeypatch.setattr(service, "parse_truewealth_tax_statement", lambda value: statement(value))
    preview = service.preview_truewealth_import(conn, import_request(raw))
    assert preview["reconciliation_status"] == "matched"
    assert service.get_truewealth_summary(conn)["current_value_chf"] is None

def test_official_confirm_is_idempotent_preserves_legacy_and_archives(monkeypatch, tmp_path):
    conn = make_conn()
    raw = b"%PDF-1.7 official-a"
    monkeypatch.setenv("JARVIS_FINANCE_RUNTIME_DIR", str(tmp_path))
    monkeypatch.setattr(service, "parse_truewealth_tax_statement", lambda value: statement(value))

    first = confirm_import(conn, raw)
    second = confirm_import(conn, raw)
    summary = service.get_truewealth_summary(conn)

    assert first["idempotent"] is False
    assert second["idempotent"] is True
    assert second["batch_id"] == first["batch_id"]
    assert summary["current_value_chf"] == "12000.00"
    assert summary["current_source_type"] == "truewealth_official_import"
    assert summary["allocation_performance_usable"] is True
    assert len(summary["positions"]) == 2
    assert conn.execute("SELECT COUNT(*) FROM account_value_snapshots WHERE snapshot_id='legacy_manual'").fetchone()[0] == 1
    assert conn.execute("SELECT COUNT(*) FROM truewealth_import_batches").fetchone()[0] == 1
    archived = tmp_path / "imports" / "truewealth" / "archive" / f"{sha256(raw).hexdigest()}.pdf"
    assert archived.read_bytes() == raw
    assert archived.stat().st_mode & 0o777 == 0o600

def test_newer_manual_value_wins_without_changing_positions_and_can_be_reversed(monkeypatch, tmp_path):
    conn = make_conn()
    raw = b"%PDF-1.7 official-b"
    monkeypatch.setenv("JARVIS_FINANCE_RUNTIME_DIR", str(tmp_path))
    monkeypatch.setattr(service, "parse_truewealth_tax_statement", lambda value: statement(value))
    confirm_import(conn, raw)

    payload = {"total_value_chf": "12500", "valuation_at": "2026-07-28T08:00:00Z", "note": "portal view"}
    preview = service.preview_manual_truewealth_value(conn, payload)
    result = service.confirm_manual_truewealth_value(
        conn,
        {**payload, "preview_id": preview["preview_id"], "confirmation_id": preview["confirmation_id"], "confirm": True},
    )
    summary = service.get_truewealth_summary(conn)
    assert summary["current_value_chf"] == "12500.00"
    assert summary["current_is_manual_provisional"] is True
    assert summary["positions_snapshot_date"] == "2026-07-27"
    assert summary["mixed_as_of"] is True
    assert summary["allocation_performance_usable"] is False
    assert len(summary["positions"]) == 2

    service.set_manual_truewealth_value_active(conn, result["snapshot_id"], {"active": False, "note": "revert", "confirm": True})
    restored = service.get_truewealth_summary(conn)
    assert restored["current_value_chf"] == "12000.00"
    assert restored["current_source_type"] == "truewealth_official_import"
    assert any(row["snapshot_id"] == result["snapshot_id"] and row["is_active"] == 0 for row in restored["history"])
    service.set_manual_truewealth_value_active(conn, result["snapshot_id"], {"active": True, "note": "reactivate", "confirm": True})
    assert service.get_truewealth_summary(conn)["current_value_chf"] == "12500.00"

def test_older_official_import_cannot_displace_newer_manual(monkeypatch, tmp_path):
    conn = make_conn()
    monkeypatch.setenv("JARVIS_FINANCE_RUNTIME_DIR", str(tmp_path))
    newest_raw = b"%PDF-1.7 official-new"
    old_raw = b"%PDF-1.7 official-old"
    monkeypatch.setattr(service, "parse_truewealth_tax_statement", lambda value: statement(value, "2026-07-27" if value == newest_raw else "2026-07-20"))
    confirm_import(conn, newest_raw)
    payload = {"total_value_chf": "12500", "valuation_at": "2026-07-28T08:00:00Z", "note": "newer manual"}
    preview = service.preview_manual_truewealth_value(conn, payload)
    service.confirm_manual_truewealth_value(conn, {**payload, "preview_id": preview["preview_id"], "confirmation_id": preview["confirmation_id"], "confirm": True})
    confirm_import(conn, old_raw)
    summary = service.get_truewealth_summary(conn)
    assert summary["current_value_chf"] == "12500.00"
    assert summary["positions_snapshot_date"] == "2026-07-27"

def test_same_day_official_has_priority_over_manual(monkeypatch, tmp_path):
    conn = make_conn()
    monkeypatch.setenv("JARVIS_FINANCE_RUNTIME_DIR", str(tmp_path))
    payload = {"total_value_chf": "11500", "valuation_at": "2026-07-27T22:00:00Z", "note": "same day"}
    preview = service.preview_manual_truewealth_value(conn, payload)
    service.confirm_manual_truewealth_value(conn, {**payload, "preview_id": preview["preview_id"], "confirmation_id": preview["confirmation_id"], "confirm": True})
    raw = b"%PDF-1.7 same-day"
    monkeypatch.setattr(service, "parse_truewealth_tax_statement", lambda value: statement(value))
    confirm_import(conn, raw)
    assert service.get_truewealth_summary(conn)["current_source_type"] == "truewealth_official_import"

def test_preview_and_get_are_side_effect_free(monkeypatch):
    conn = make_conn()
    raw = b"%PDF-1.7 preview-only"
    monkeypatch.setattr(service, "parse_truewealth_tax_statement", lambda value: statement(value))
    before = conn.total_changes
    service.preview_truewealth_import(conn, import_request(raw))
    service.get_truewealth_summary(conn)
    assert conn.total_changes == before

def test_immutable_source_tables_reject_update_and_delete(monkeypatch, tmp_path):
    conn = make_conn()
    raw = b"%PDF-1.7 immutable"
    monkeypatch.setenv("JARVIS_FINANCE_RUNTIME_DIR", str(tmp_path))
    monkeypatch.setattr(service, "parse_truewealth_tax_statement", lambda value: statement(value))
    result = confirm_import(conn, raw)
    with pytest.raises(Exception, match="immutable"):
        conn.execute("UPDATE truewealth_snapshots SET source_total_chf='0' WHERE snapshot_id=?", (result["snapshot_id"],))
    with pytest.raises(Exception, match="cannot be deleted"):
        conn.execute("DELETE FROM truewealth_snapshots WHERE snapshot_id=?", (result["snapshot_id"],))

def test_confirm_rollback_removes_new_archive(monkeypatch, tmp_path):
    conn = make_conn()
    monkeypatch.setenv("JARVIS_FINANCE_RUNTIME_DIR", str(tmp_path))
    raw = b"%PDF-synthetic-rollback"
    monkeypatch.setattr(service, "parse_truewealth_tax_statement", lambda _raw: statement(raw))
    request = import_request(raw)
    preview = service.preview_truewealth_import(conn, request)
    conn.execute(
        "CREATE TRIGGER force_truewealth_failure BEFORE INSERT ON truewealth_import_batches "
        "BEGIN SELECT RAISE(ABORT, 'forced'); END"
    )
    with pytest.raises(ValueError, match="conflicted"):
        service.confirm_truewealth_import(
            conn,
            {**request, "preview_id": preview["preview_id"], "confirmation_id": preview["confirmation_id"], "confirm": True},
        )
    assert not list((tmp_path / "imports" / "truewealth" / "archive").glob("*.pdf"))
    assert conn.execute("SELECT COUNT(*) FROM truewealth_import_batches").fetchone()[0] == 0

def test_same_date_different_official_source_is_blocked(monkeypatch, tmp_path):
    conn = make_conn()
    monkeypatch.setenv("JARVIS_FINANCE_RUNTIME_DIR", str(tmp_path))
    monkeypatch.setattr(service, "parse_truewealth_tax_statement", lambda value: statement(value))
    confirm_import(conn, b"%PDF-1.7 source-one")
    second = import_request(b"%PDF-1.7 source-two")
    preview = service.preview_truewealth_import(conn, second)
    assert preview["conflict"] is True
    with pytest.raises(ValueError, match="different official TrueWealth source"):


sed -n '240,520p' tests/test_truewealth_sprint12.py && sed -n '1,260p' src/jarvis_finance/services/truewealth_service.py && sed -n '260,620p' src/jarvis_finance/services/truewealth_service.py


Output:
Chunk ID: 33792f
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 7099
Output:
    with pytest.raises(ValueError, match="different official TrueWealth source"):
        service.confirm_truewealth_import(
            conn,
            {**second, "preview_id": preview["preview_id"], "confirmation_id": preview["confirmation_id"], "confirm": True},
        )
    assert conn.execute("SELECT COUNT(*) FROM truewealth_import_batches").fetchone()[0] == 1

def test_manual_value_is_not_hard_deleted_or_overwritten():
    conn = make_conn()
    payload = {"total_value_chf": "12500", "valuation_at": "2026-07-28T08:00:00Z", "note": "immutable value"}
    preview = service.preview_manual_truewealth_value(conn, payload)
    result = service.confirm_manual_truewealth_value(
        conn,
        {**payload, "preview_id": preview["preview_id"], "confirmation_id": preview["confirmation_id"], "confirm": True},
    )
    with pytest.raises(Exception, match="fields are immutable"):
        conn.execute("UPDATE account_value_snapshots SET total_value_chf='1' WHERE snapshot_id=?", (result["snapshot_id"],))
    with pytest.raises(Exception, match="cannot be deleted"):
        conn.execute("DELETE FROM account_value_snapshots WHERE snapshot_id=?", (result["snapshot_id"],))
from __future__ import annotations

import base64
import binascii
from datetime import UTC, datetime
from decimal import Decimal, InvalidOperation
from hashlib import sha256
import json
import os
from pathlib import Path
from sqlite3 import Connection, IntegrityError
from typing import Any
from uuid import uuid4

from jarvis_finance.config.paths import DEFAULT_RUNTIME_DIR
from jarvis_finance.imports.truewealth_tax_statement import (
    PARSER_ID,
    PARSER_VERSION,
    TrueWealthStatement,
    parse_truewealth_tax_statement,
)

TRUEWEALTH_PORTFOLIO_ID = "truewealth-free-assets"
TRUEWEALTH_LABEL = "Freie Anlagen"

def _now() -> str:
    return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")

def _id(prefix: str) -> str:
    return f"{prefix}_{uuid4().hex[:20]}"

def _json_hash(value: Any) -> str:
    return sha256(json.dumps(value, sort_keys=True, separators=(",", ":"), default=str).encode()).hexdigest()

def _decimal_text(value: Decimal, places: str = "0.01") -> str:
    return format(value.quantize(Decimal(places)), "f")

def _decode_pdf(content_base64: str) -> bytes:
    try:
        return base64.b64decode(content_base64, validate=True)
    except (binascii.Error, ValueError) as exc:
        raise ValueError("TrueWealth PDF is not valid base64") from exc

def _resolve_account_id(conn: Connection, requested: str | None = None) -> str:
    mapped = conn.execute(
        "SELECT account_id FROM truewealth_portfolios WHERE portfolio_id=? AND is_active=1",
        (TRUEWEALTH_PORTFOLIO_ID,),
    ).fetchone()
    if mapped:
        return str(mapped["account_id"])
    configured = os.environ.get("JARVIS_TRUEWEALTH_ACCOUNT_ID", "").strip()
    if configured:
        return configured
    if requested:
        return requested
    candidates = conn.execute(
        """SELECT DISTINCT a.account_id
           FROM accounts a
           JOIN platforms p ON p.platform_id=a.platform_id
           WHERE a.is_active=1
             AND replace(replace(lower(p.name),' ',''),'-','') LIKE '%truewealth%'
             AND EXISTS (
                 SELECT 1 FROM account_value_snapshots v
                 WHERE v.account_id=a.account_id AND COALESCE(v.is_active,1)=1
             )
           ORDER BY a.account_id"""
    ).fetchall()
    if not candidates:
        candidates = conn.execute(
            """SELECT DISTINCT a.account_id
               FROM accounts a
               JOIN platforms p ON p.platform_id=a.platform_id
               WHERE a.is_active=1
                 AND replace(replace(lower(p.name),' ',''),'-','') LIKE '%truewealth%'
               ORDER BY a.account_id"""
        ).fetchall()
    if len(candidates) != 1:
        raise ValueError("Confirmed TrueWealth target account cannot be resolved unambiguously")
    return str(candidates[0]["account_id"])

def _validate_account(conn: Connection, account_id: str | None = None) -> str:
    resolved = _resolve_account_id(conn, account_id)
    if account_id and account_id != resolved:
        raise ValueError("TrueWealth portfolio account does not match the confirmed mapping")
    row = conn.execute(
        """SELECT a.account_id,a.is_active,p.name AS platform
           FROM accounts a JOIN platforms p ON p.platform_id=a.platform_id
           WHERE a.account_id=?""",
        (resolved,),
    ).fetchone()
    platform_key = str(row["platform"] if row else "").lower().replace(" ", "").replace("-", "")
    if not row or not row["is_active"] or "truewealth" not in platform_key:
        raise ValueError("Confirmed TrueWealth target account is unavailable")
    return resolved

def _revision(conn: Connection, account_id: str) -> str:
    row = conn.execute(
        """SELECT COALESCE(MAX(created_at),'') AS latest,COUNT(*) AS count
           FROM account_value_snapshots WHERE account_id=?""",
        (account_id,),
    ).fetchone()
    imported = conn.execute("SELECT COUNT(*) FROM truewealth_import_batches").fetchone()[0]
    return _json_hash([row["latest"], row["count"], imported])

def _preview_identity(kind: str, payload: dict[str, Any], revision: str) -> tuple[str, str]:
    fingerprint = _json_hash([kind, payload, revision])
    return f"twpreview_{fingerprint[:24]}", f"twconfirm_{fingerprint[24:48]}"

def _statement_preview_payload(statement: TrueWealthStatement, filename: str) -> dict[str, Any]:
    difference = statement.source_total_chf - statement.components_total_chf
    return {
        "portfolio_label": TRUEWEALTH_LABEL,
        "snapshot_date": statement.statement_date,
        "period_from": statement.period_from,
        "period_to": statement.period_to,
        "file_sha256": statement.file_sha256,
        "filename_sha256": sha256(filename.encode()).hexdigest(),
        "page_count": statement.page_count,
        "source_total_chf": _decimal_text(statement.source_total_chf),
        "securities_total_chf": _decimal_text(statement.securities_total_chf),
        "cash_total_chf": _decimal_text(statement.cash_total_chf),
        "components_total_chf": _decimal_text(statement.components_total_chf),
        "reconciliation_difference_chf": _decimal_text(difference),
        "reconciliation_status": "matched" if difference == 0 else "within_tolerance",
        "positions": [
            {
                "name": row.name,
                "isin": row.isin,
                "quantity": format(row.quantity, "f"),
                "currency": row.currency,
                "source_price": format(row.source_price, "f"),
                "source_value_chf": _decimal_text(row.source_value_chf),
            }
            for row in statement.positions
        ],
        "cash": [
            {
                "currency": row.currency,
                "amount_original": format(row.amount_original, "f"),
                "fx_rate_to_chf": format(row.fx_rate_to_chf, "f") if row.fx_rate_to_chf is not None else None,
                "source_value_chf": _decimal_text(row.value_chf),
            }
            for row in statement.cash
        ],
        "parser_id": PARSER_ID,
        "parser_version": PARSER_VERSION,
    }

def preview_truewealth_import(conn: Connection, request: dict[str, Any]) -> dict[str, Any]:
    requested = str(request.get("account_id") or "").strip() or None
    account_id = _validate_account(conn, requested)
    raw = _decode_pdf(str(request.get("content_base64") or ""))
    filename = str(request.get("file_name") or "source.pdf")
    statement = parse_truewealth_tax_statement(raw)
    payload = _statement_preview_payload(statement, filename)
    preview_id, confirmation_id = _preview_identity("official_import", payload, _revision(conn, account_id))
    duplicate = conn.execute(
        "SELECT batch_id FROM truewealth_import_batches WHERE file_sha256=?",
        (statement.file_sha256,),
    ).fetchone()
    conflict = conn.execute(
        """SELECT batch_id FROM truewealth_import_batches
           WHERE account_id=? AND snapshot_date=? AND file_sha256<>? LIMIT 1""",
        (account_id, statement.statement_date, statement.file_sha256),
    ).fetchone()
    warnings = []
    if statement.source_total_chf != statement.components_total_chf:
        warnings.append("Source total is rounded to whole CHF; components differ within CHF 1.00")
    if conflict:
        warnings.append("A different official source already exists for this snapshot date")
    return {
        **payload,
        "preview_id": preview_id,
        "confirmation_id": confirmation_id,
        "duplicate": bool(duplicate),
        "existing_batch_id": str(duplicate["batch_id"]) if duplicate else None,
        "conflict": bool(conflict),
        "conflicting_batch_id": str(conflict["batch_id"]) if conflict else None,
        "warnings": warnings,
    }

def _archive_pdf(raw: bytes, digest: str) -> tuple[str, bool]:
    root = Path(os.environ.get("JARVIS_FINANCE_RUNTIME_DIR", str(DEFAULT_RUNTIME_DIR))).expanduser()
    folder = root / "imports" / "truewealth" / "archive"
    folder.mkdir(mode=0o700, parents=True, exist_ok=True)
    try:
        folder.chmod(0o700)
    except OSError:
        pass
    target = folder / f"{digest}.pdf"
    created = False
    if target.exists():
        if sha256(target.read_bytes()).hexdigest() != digest:
            raise ValueError("Archived TrueWealth source hash mismatch")
    else:
        tmp = folder / f".{digest}.{uuid4().hex}.tmp"
        try:
            fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
            with os.fdopen(fd, "wb") as handle:
                handle.write(raw)
                handle.flush()
                os.fsync(handle.fileno())
            os.replace(tmp, target)
            target.chmod(0o600)
            created = True
        finally:
            if tmp.exists():
                tmp.unlink()
    return f"truewealth/archive/{digest}.pdf", created

def _remove_new_archive(reference: str) -> None:
    root = Path(os.environ.get("JARVIS_FINANCE_RUNTIME_DIR", str(DEFAULT_RUNTIME_DIR))).expanduser()
    target = root / "imports" / reference
    try:
        target.unlink(missing_ok=True)
    except OSError:
        pass

def _insert_audit(
    conn: Connection,
    *,
    action: str,
    entity_type: str,
    entity_id: str,
    details: dict[str, Any],
    note: str = "",
    quality: str = "ok",
) -> str:
    audit_id = _id("audit")
    now = _now()
    conn.execute(
        """INSERT INTO audit_log(
             audit_id,timestamp,source,action,entity_type,entity_id,old_values_json,new_values_json,
             user_text_note,confirmed,confirmation_timestamp,auto_parsed,parse_confidence,created_by,
             quality_status,created_at)
           VALUES(?,?,'truewealth_sprint12',?,?,?,NULL,?,?,1,?,0,NULL,'user',?,?)""",
        (audit_id, now, action, entity_type, entity_id, json.dumps(details, sort_keys=True), note, now, quality, now),
    )
    return audit_id

def confirm_truewealth_import(conn: Connection, request: dict[str, Any]) -> dict[str, Any]:
    if request.get("confirm") is not True:
        raise ValueError("Explicit TrueWealth import confirmation is required")
    preview = preview_truewealth_import(conn, request)
    if request.get("preview_id") != preview["preview_id"] or request.get("confirmation_id") != preview["confirmation_id"]:
    if request.get("preview_id") != preview["preview_id"] or request.get("confirmation_id") != preview["confirmation_id"]:
        raise ValueError("TrueWealth import preview is stale or changed")
    if preview["duplicate"]:
        row = conn.execute(
            "SELECT batch_id,audit_id FROM truewealth_import_batches WHERE file_sha256=?",
            (preview["file_sha256"],),
        ).fetchone()
        return {"status": "confirmed", "batch_id": row["batch_id"], "audit_id": row["audit_id"], "idempotent": True}
    if preview["conflict"]:
        raise ValueError("A different official TrueWealth source already exists for this snapshot date")
    account_id = _validate_account(conn, str(request.get("account_id") or "").strip() or None)

    raw = _decode_pdf(str(request.get("content_base64") or ""))
    statement = parse_truewealth_tax_statement(raw)
    archive_reference, archive_created = _archive_pdf(raw, statement.file_sha256)
    now = _now()
    batch_id = _id("twbatch")
    snapshot_id = _id("twsnapshot")
    account_snapshot_id = _id("acctval")
    difference = statement.source_total_chf - statement.components_total_chf
    try:
        conn.execute("BEGIN IMMEDIATE")
        _validate_account(conn, account_id)
        existing_portfolios = conn.execute(
            "SELECT portfolio_id,account_id FROM truewealth_portfolios WHERE is_active=1"
        ).fetchall()
        if existing_portfolios and any(
            row["portfolio_id"] != TRUEWEALTH_PORTFOLIO_ID or row["account_id"] != account_id
            for row in existing_portfolios
        ):
            raise ValueError("TrueWealth portfolio mapping is ambiguous")
        conn.execute(
            """INSERT OR IGNORE INTO truewealth_portfolios(
                 portfolio_id,account_id,source_reference_hash,label,portfolio_kind,base_currency,is_active,created_at)
               VALUES(?,?,?,'Freie Anlagen','free_assets','CHF',1,?)""",
            (TRUEWEALTH_PORTFOLIO_ID, account_id, _json_hash(["TrueWealth", "Freie Anlagen"]), now),
        )
        audit_id = _insert_audit(
            conn,
            action="truewealth_import_confirm",
            entity_type="truewealth_import_batch",
            entity_id=batch_id,
            details={
                "snapshot_date": statement.statement_date,
                "file_sha256": statement.file_sha256,
                "position_count": len(statement.positions),
                "cash_count": len(statement.cash),
                "parser_id": PARSER_ID,
                "parser_version": PARSER_VERSION,
            },
        )
        conn.execute(
            """INSERT INTO truewealth_import_batches(
                 batch_id,portfolio_id,account_id,file_sha256,filename_sha256,source_file_type,parser_id,
                 parser_version,provenance,snapshot_date,page_count,archive_reference,status,audit_id,confirmed_at,confirmed_by)
               VALUES(?,?,?,?,?,'application/pdf',?,?,'truewealth_customer_export',?,?,?,'confirmed',?,?,'user')""",
            (
                batch_id,
                TRUEWEALTH_PORTFOLIO_ID,
                account_id,
                statement.file_sha256,
                preview["filename_sha256"],
                PARSER_ID,
                PARSER_VERSION,
                statement.statement_date,
                statement.page_count,
                archive_reference,
                audit_id,
                now,
            ),
        )
        conn.execute(
            """INSERT INTO truewealth_snapshots(
                 snapshot_id,portfolio_id,account_id,batch_id,snapshot_date,source_total_chf,securities_total_chf,
                 cash_total_chf,components_total_chf,reconciliation_difference_chf,reconciliation_tolerance_chf,
                 reconciliation_status,position_count,cash_count,completeness_status,reason_codes_json,created_at)
               VALUES(?,?,?,?,?,?,?,?,?,?,'1.00',?,?,?,'complete',?,?)""",
            (
                snapshot_id,
                TRUEWEALTH_PORTFOLIO_ID,
                account_id,
                batch_id,
                statement.statement_date,
                _decimal_text(statement.source_total_chf),
                _decimal_text(statement.securities_total_chf),
                _decimal_text(statement.cash_total_chf),
                _decimal_text(statement.components_total_chf),
                _decimal_text(difference),
                "matched" if difference == 0 else "within_tolerance",
                len(statement.positions),
                len(statement.cash),
                json.dumps(["source_total_rounded"] if difference else []),
                now,
            ),
        )
        for row in statement.positions:
            conn.execute(
                """INSERT INTO truewealth_snapshot_positions(
                     snapshot_position_id,snapshot_id,source_row_reference,instrument_name,isin,asset_type,quantity,
                     price_currency,source_price,source_value_chf,source_evidence_json,created_at)
                   VALUES(?,?,?,?,?,NULL,?,?,?,?,?,?)""",
                (
                    _id("twposition"),
                    snapshot_id,
                    row.source_ref,
                    row.name,
                    row.isin,
                    format(row.quantity, "f"),
                    row.currency,
                    format(row.source_price, "f"),
                    _decimal_text(row.source_value_chf),
                    json.dumps({"source": "official_tax_statement", "estimated": False}),
                    now,
                ),
            )
        for row in statement.cash:
            conn.execute(
                """INSERT INTO truewealth_snapshot_cash(
                     snapshot_cash_id,snapshot_id,source_row_reference,currency,amount_original,fx_rate_to_chf,
                     source_value_chf,source_evidence_json,created_at)
                   VALUES(?,?,?,?,?,?,?,?,?)""",
                (
                    _id("twcash"),
                    snapshot_id,
                    row.source_ref,
                    row.currency,
                    format(row.amount_original, "f"),
                    format(row.fx_rate_to_chf, "f") if row.fx_rate_to_chf is not None else None,
                    _decimal_text(row.value_chf),
                    json.dumps({"source": "official_tax_statement", "estimated": False}),
                    now,
                ),
            )
        conn.execute(
            """INSERT INTO account_value_snapshots(
                 snapshot_id,account_id,valuation_date,total_value_chf,currency,source_type,quality_status,notes,
                 created_at,valuation_at,source_reference,is_active)
               VALUES(?,?,?,?,'CHF','truewealth_official_import','ok','Official TrueWealth source total',?,?,?,1)""",
            (
                account_snapshot_id,
                account_id,
                statement.statement_date,
                _decimal_text(statement.source_total_chf),
                now,
                f"{statement.statement_date}T23:59:59Z",
                snapshot_id,
            ),
        )
        conn.commit()
    except IntegrityError as exc:
        conn.rollback()
        duplicate = conn.execute(
            "SELECT batch_id,audit_id FROM truewealth_import_batches WHERE file_sha256=?",
            (statement.file_sha256,),
        ).fetchone()
        if duplicate:
            return {"status": "confirmed", "batch_id": duplicate["batch_id"], "audit_id": duplicate["audit_id"], "idempotent": True}
        if archive_created:
            _remove_new_archive(archive_reference)
        raise ValueError("TrueWealth import conflicted with existing immutable data") from exc
    except Exception:
        conn.rollback()
        if archive_created:
            _remove_new_archive(archive_reference)
        raise
    return {"status": "confirmed", "batch_id": batch_id, "snapshot_id": snapshot_id, "audit_id": audit_id, "idempotent": False}

def _parse_valuation_at(value: str) -> datetime:
    try:
        parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
    except ValueError as exc:
        raise ValueError("Manual valuation timestamp is invalid") from exc
    if parsed.tzinfo is None:
        parsed = parsed.replace(tzinfo=UTC)
    return parsed.astimezone(UTC)

def _manual_payload(request: dict[str, Any]) -> dict[str, Any]:
    try:
        value = Decimal(str(request.get("total_value_chf") or ""))
    except InvalidOperation as exc:
        raise ValueError("Manual TrueWealth total is invalid") from exc
    if value <= 0:
        raise ValueError("Manual TrueWealth total must be positive")
    valuation = _parse_valuation_at(str(request.get("valuation_at") or ""))
    note = str(request.get("note") or "").strip()
    if len(note) > 240:
        raise ValueError("Manual TrueWealth note is too long")
    return {
        "total_value_chf": _decimal_text(value),
        "valuation_at": valuation.isoformat().replace("+00:00", "Z"),
        "valuation_date": valuation.date().isoformat(),
        "note": note,
        "source_type": "truewealth_manual_provisional",
    }

def preview_manual_truewealth_value(conn: Connection, request: dict[str, Any]) -> dict[str, Any]:
    account_id = _validate_account(conn)
    payload = _manual_payload(request)
    preview_id, confirmation_id = _preview_identity("manual_value", payload, _revision(conn, account_id))
    return {
        **payload,
        "preview_id": preview_id,
        "confirmation_id": confirmation_id,
        "status": "manual_provisional",
        "warnings": [
            "Position details remain at the latest official import date",
            "No difference is distributed to positions or invented as cash",
        ],
    }

def confirm_manual_truewealth_value(conn: Connection, request: dict[str, Any]) -> dict[str, Any]:
    if request.get("confirm") is not True:
        raise ValueError("Explicit manual TrueWealth confirmation is required")
    preview = preview_manual_truewealth_value(conn, request)
    if request.get("preview_id") != preview["preview_id"] or request.get("confirmation_id") != preview["confirmation_id"]:
        raise ValueError("Manual TrueWealth preview is stale or changed")
    account_id = _validate_account(conn, str(request.get("account_id") or "").strip() or None)
    snapshot_id = _id("acctval")
    now = _now()
    try:
        conn.execute("BEGIN IMMEDIATE")
        conn.execute(
            """INSERT INTO account_value_snapshots(
                 snapshot_id,account_id,valuation_date,total_value_chf,currency,source_type,quality_status,notes,
                 created_at,valuation_at,source_reference,is_active)
               VALUES(?,?,?,?,'CHF','truewealth_manual_provisional','warning',?,?,?,?,1)""",
            (
                snapshot_id,
                account_id,
                preview["valuation_date"],
                preview["total_value_chf"],
                preview["note"],
                now,
                preview["valuation_at"],
                snapshot_id,
            ),
        )
        audit_id = _insert_audit(
            conn,
            action="truewealth_manual_value_confirm",
            entity_type="account_value_snapshot",
            entity_id=snapshot_id,
            details={"valuation_at": preview["valuation_at"], "source_type": "manual_provisional"},
            note=preview["note"],
            quality="warning",
        )
        conn.commit()
    except Exception:
        conn.rollback()
        raise
    return {"status": "confirmed", "snapshot_id": snapshot_id, "audit_id": audit_id}

def set_manual_truewealth_value_active(conn: Connection, snapshot_id: str, request: dict[str, Any]) -> dict[str, Any]:
    if request.get("confirm") is not True:
        raise ValueError("Explicit manual valuation status confirmation is required")
    active = bool(request.get("active"))
    account_id = _validate_account(conn)
    row = conn.execute(
        """SELECT snapshot_id,is_active FROM account_value_snapshots
           WHERE snapshot_id=? AND account_id=? AND source_type='truewealth_manual_provisional'""",
        (snapshot_id, account_id),
    ).fetchone()
    if not row:
        raise ValueError("Manual TrueWealth valuation was not found")
    if bool(row["is_active"]) == active:
        audits = conn.execute(
            "SELECT audit_id FROM audit_log WHERE entity_type='account_value_snapshot' AND entity_id=? ORDER BY timestamp DESC LIMIT 1",
            (snapshot_id,),
        ).fetchone()
        return {"status": "confirmed", "snapshot_id": snapshot_id, "audit_id": audits["audit_id"] if audits else "", "idempotent": True}
    now = _now()
    note = str(request.get("note") or "").strip()
    try:
        conn.execute("BEGIN IMMEDIATE")
        conn.execute(
            """UPDATE account_value_snapshots SET is_active=?,deactivated_at=?,deactivation_reason=?,updated_at=?
               WHERE snapshot_id=?""",
            (1 if active else 0, None if active else now, None if active else note, now, snapshot_id),
        )
        audit_id = _insert_audit(
            conn,
            action="truewealth_manual_value_reactivate" if active else "truewealth_manual_value_deactivate",
            entity_type="account_value_snapshot",
            entity_id=snapshot_id,
            details={"active": active},
            note=note,
            quality="warning",
        )
        conn.commit()
    except Exception:
        conn.rollback()
        raise
    return {"status": "confirmed", "snapshot_id": snapshot_id, "audit_id": audit_id, "idempotent": False}

def _current_value_row(conn: Connection, account_id: str):
    return conn.execute(
        """SELECT snapshot_id,total_value_chf,valuation_date,
                  COALESCE(valuation_at,valuation_date || 'T23:59:58Z') AS valuation_at,
                  source_type,quality_status,notes,is_active
           FROM account_value_snapshots
           WHERE account_id=? AND COALESCE(is_active,1)=1
           ORDER BY valuation_date DESC,
                    CASE source_type WHEN 'truewealth_official_import' THEN 3
                         WHEN 'truewealth_manual_provisional' THEN 2 ELSE 1 END DESC,
                    COALESCE(valuation_at,created_at) DESC,created_at DESC
           LIMIT 1""",
        (account_id,),
    ).fetchone()

def get_current_truewealth_value(conn: Connection) -> Decimal:
    account_id = _validate_account(conn)
    row = _current_value_row(conn, account_id)
    return Decimal(str(row["total_value_chf"])) if row else Decimal("0")

def get_truewealth_summary(conn: Connection) -> dict[str, Any]:
    account_id = _validate_account(conn)
    current = _current_value_row(conn, account_id)
    official = conn.execute(
        """SELECT * FROM truewealth_snapshots
           WHERE account_id=? ORDER BY snapshot_date DESC,created_at DESC LIMIT 1""",
        (account_id,),
    ).fetchone()
    positions: list[dict[str, Any]] = []
    cash: list[dict[str, Any]] = []
    if official:
        positions = [dict(row) for row in conn.execute(
            """SELECT snapshot_position_id,instrument_name,isin,asset_type,quantity,price_currency,
                      source_price,source_value_chf
               FROM truewealth_snapshot_positions WHERE snapshot_id=? ORDER BY instrument_name""",
            (official["snapshot_id"],),
        ).fetchall()]
        cash = [dict(row) for row in conn.execute(
            """SELECT snapshot_cash_id,currency,amount_original,fx_rate_to_chf,source_value_chf
               FROM truewealth_snapshot_cash WHERE snapshot_id=? ORDER BY currency""",
            (official["snapshot_id"],),
        ).fetchall()]
    history = [dict(row) for row in conn.execute(
        """SELECT snapshot_id,total_value_chf,valuation_date,
                  COALESCE(valuation_at,valuation_date || 'T23:59:58Z') AS valuation_at,
                  source_type,quality_status,notes,COALESCE(is_active,1) AS is_active,deactivated_at,deactivation_reason
           FROM account_value_snapshots WHERE account_id=?
           ORDER BY valuation_date DESC,COALESCE(valuation_at,created_at) DESC,created_at DESC""",
        (account_id,),
    ).fetchall()]
    imports = [dict(row) for row in conn.execute(
        """SELECT b.batch_id,b.snapshot_date,b.parser_id,b.parser_version,b.confirmed_at,b.status,
                  s.snapshot_id,s.position_count,s.cash_count,s.source_total_chf,s.securities_total_chf,
                  s.cash_total_chf,s.components_total_chf,s.reconciliation_difference_chf,s.reconciliation_status,
                  s.completeness_status
           FROM truewealth_import_batches b JOIN truewealth_snapshots s ON s.batch_id=b.batch_id
           WHERE b.account_id=? ORDER BY b.snapshot_date DESC,b.confirmed_at DESC""",
        (account_id,),
    ).fetchall()]


sed -n '620,980p' src/jarvis_finance/services/truewealth_service.py && sed -n '1,260p' src/jarvis_finance/storage/migrations.py


Output:
Chunk ID: d15d53
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 2976
Output:
    ).fetchall()]
    current_type = str(current["source_type"]) if current else "unavailable"
    current_is_manual = current_type == "truewealth_manual_provisional"
    return {
        "portfolio_label": TRUEWEALTH_LABEL,
        "current_value_chf": str(current["total_value_chf"]) if current else None,
        "current_valuation_at": str(current["valuation_at"]) if current else None,
        "current_source_type": current_type,
        "current_is_manual_provisional": current_is_manual,
        "current_label": f"Gesamtwert manuell aktualisiert am {current['valuation_at']}" if current_is_manual else "Gesamtwert gemäss offiziellem Import",
        "positions_snapshot_date": str(official["snapshot_date"]) if official else None,
        "positions_label": f"Positionsdetails gemäss letztem Import vom {official['snapshot_date']}" if official else "Noch keine offiziellen Positionsdetails importiert",
        "mixed_as_of": bool(current_is_manual and official and str(current["valuation_date"]) > str(official["snapshot_date"])),
        "allocation_performance_usable": bool(current and official and not current_is_manual and str(current["valuation_date"]) == str(official["snapshot_date"])),
        "positions": positions,
        "cash": cash,
        "history": history,
        "imports": imports,
        "latest_official": dict(official) if official else None,
    }
from __future__ import annotations

import hashlib
from datetime import datetime, timezone
from sqlite3 import Connection

from .schema import INITIAL_SCHEMA_SQL

MIGRATION_VERSION = 44
MIGRATION_NAME = "044_truewealth_verified_snapshot_v1"

INSTRUMENT_OPTIONAL_COLUMNS = {
    "position_category": "TEXT",
    "ter": "TEXT",
    "distribution_policy": "TEXT",
    "index_name": "TEXT",
    "fund_domicile": "TEXT",
    "benchmark": "TEXT",
    "is_currency_hedged": "INTEGER NOT NULL DEFAULT 0",
    "hedged_to_currency": "TEXT",
    "hedge_status": "TEXT NOT NULL DEFAULT 'unknown'",
    "base_exposure_currency": "TEXT",
    "trading_currency": "TEXT",
    "instrument_status": "TEXT NOT NULL DEFAULT 'unknown'",
    "valuation_policy": "TEXT NOT NULL DEFAULT 'live_price'",
    "corporate_action_status": "TEXT NOT NULL DEFAULT 'not_checked'",
    "split_or_corporate_action_review_required": "INTEGER NOT NULL DEFAULT 0",
}

CATALOG_OPTIONAL_COLUMNS = {
    "last_price": "TEXT",
    "price_currency": "TEXT",
    "price_date": "TEXT",
    "price_source": "TEXT",
    "exchange_name": "TEXT",
    "mic": "TEXT",
    "security_type": "TEXT",
}

TEXT_AFFINITY_COLUMNS = {
    "transactions": {"quantity", "price_original", "gross_amount_original", "fee_original", "tax_original", "net_amount_original", "fx_rate_to_chf", "gross_amount_chf", "fee_chf", "tax_chf", "net_amount_chf"},
    "crypto_holdings": {"quantity", "legacy_snapshot_value_original", "legacy_snapshot_value_chf"},
    "crypto_transactions": {"quantity", "price_original", "gross_amount_original", "fee_quantity", "fee_original", "fx_rate_to_chf", "amount_chf"},
    "crypto_prices": {"price", "market_cap", "volume_24h", "change_24h_pct"},
    "fx_rates": {"rate"},
    "market_prices": {"open", "high", "low", "close", "adjusted_close"},
    "equity_price_points": {"price"},
    "equity_intraday_candles": {"open", "close", "low", "high", "volume"},
    "crypto_price_points": {"price"},
}

def utc_now() -> str:
    return datetime.now(timezone.utc).isoformat()

def checksum_sql(sql: str) -> str:
    return hashlib.sha256(sql.encode("utf-8")).hexdigest()

def get_schema_version(conn: Connection) -> int:
    row = conn.execute("SELECT MAX(version) AS version FROM schema_migrations").fetchone()
    return int(row["version"] or 0) if row else 0

def _table_columns(conn: Connection, table: str) -> dict[str, str]:
    return {row["name"]: (row["type"] or "") for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}

def _add_missing_instrument_columns(conn: Connection) -> None:
    existing = _table_columns(conn, "instruments")
    for name, col_type in INSTRUMENT_OPTIONAL_COLUMNS.items():
        if name not in existing:
            conn.execute(f"ALTER TABLE instruments ADD COLUMN {name} {col_type}")

def _rebuild_table_with_text_columns(conn: Connection, table: str, text_columns: set[str]) -> None:
    cols = conn.execute(f"PRAGMA table_info({table})").fetchall()
    if not cols:
        return
    needs_rebuild = any(row["name"] in text_columns and (row["type"] or "").upper() != "TEXT" for row in cols)
    if not needs_rebuild:
        return
    tmp = f"{table}__text_migration"
    col_defs: list[str] = []
    pk_cols = [row["name"] for row in cols if row["pk"]]
    for row in cols:
        name = row["name"]
        col_type = "TEXT" if name in text_columns else (row["type"] or "TEXT")
        parts = [name, col_type]
        if row["pk"] and len(pk_cols) == 1:
            parts.append("PRIMARY KEY")
        if row["notnull"]:
            parts.append("NOT NULL")
        if row["dflt_value"] is not None:
            parts.append(f"DEFAULT {row['dflt_value']}")
        col_defs.append(" ".join(parts))
    if len(pk_cols) > 1:
        col_defs.append("PRIMARY KEY (" + ", ".join(pk_cols) + ")")
    conn.execute(f"DROP TABLE IF EXISTS {tmp}")
    conn.execute(f"CREATE TABLE {tmp} (" + ", ".join(col_defs) + ")")
    names = [row["name"] for row in cols]
    select_exprs = [f"CAST({name} AS TEXT)" if name in text_columns else name for name in names]
    conn.execute(f"INSERT INTO {tmp} ({', '.join(names)}) SELECT {', '.join(select_exprs)} FROM {table}")
    conn.execute(f"DROP TABLE {table}")
    conn.execute(f"ALTER TABLE {tmp} RENAME TO {table}")
    if table == "crypto_holdings":
        conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_crypto_holdings_asset_wallet ON crypto_holdings(asset_id, wallet_id)")

def _add_missing_columns(conn: Connection, table: str, columns: dict[str, str]) -> None:
    existing = _table_columns(conn, table)
    for name, col_type in columns.items():
        if name not in existing:
            conn.execute(f"ALTER TABLE {table} ADD COLUMN {name} {col_type}")

def _create_broker_bank_mapping_tables(conn: Connection) -> None:
    conn.executescript(
        """
        CREATE TABLE IF NOT EXISTS instrument_mappings (
            mapping_id TEXT PRIMARY KEY,
            source_name TEXT,
            source_platform TEXT NOT NULL,
            source_account TEXT,
            source_label TEXT NOT NULL,
            normalized_name TEXT NOT NULL,
            isin TEXT,
            ticker TEXT,
            exchange TEXT,
            currency TEXT,
            asset_class TEXT NOT NULL DEFAULT 'other',
            source_instrument_name TEXT,
            source_isin TEXT,
            source_ticker TEXT,
            source_exchange TEXT,
            source_currency TEXT,
            instrument_id TEXT REFERENCES instruments(instrument_id),
            mapping_status TEXT NOT NULL DEFAULT 'needs_manual_review',
            confidence TEXT NOT NULL DEFAULT '0',
            quality_flags_json TEXT,
            notes TEXT,
            created_at TEXT NOT NULL,
            updated_at TEXT
        );

        CREATE TABLE IF NOT EXISTS platform_account_mappings (
            mapping_id TEXT PRIMARY KEY,
            source_name TEXT,
            source_platform TEXT NOT NULL,
            source_platform_label TEXT,
            source_account_label TEXT NOT NULL,
            normalized_platform TEXT NOT NULL,
            normalized_account_name TEXT NOT NULL,
            source_account_type TEXT,
            account_type TEXT NOT NULL DEFAULT 'other',
            source_currency TEXT,
            currency TEXT,
            platform_id TEXT REFERENCES platforms(platform_id),
            account_id TEXT REFERENCES accounts(account_id),
            internal_platform_id TEXT REFERENCES platforms(platform_id),
            internal_account_id TEXT REFERENCES accounts(account_id),
            mapping_status TEXT NOT NULL DEFAULT 'needs_manual_review',
            quality_flags_json TEXT,
            notes TEXT,
            created_at TEXT NOT NULL,
            updated_at TEXT
        );

        CREATE TABLE IF NOT EXISTS broker_import_dry_runs (
            dry_run_id TEXT PRIMARY KEY,
            source_name TEXT,
            source_platform TEXT NOT NULL,
            source_file_label TEXT,
            source_file_type TEXT NOT NULL,
            source_hash TEXT,
            source_filename_hash TEXT,
            detected_snapshot_date TEXT,
            snapshot_date_status TEXT NOT NULL DEFAULT 'missing',
            detected_sections_json TEXT NOT NULL,
            field_coverage_json TEXT,
            rows_total INTEGER NOT NULL DEFAULT 0,
            rows_position_candidates INTEGER NOT NULL DEFAULT 0,
            rows_cash_candidates INTEGER NOT NULL DEFAULT 0,
            candidate_positions INTEGER NOT NULL DEFAULT 0,
            candidate_cash_rows INTEGER NOT NULL DEFAULT 0,
            mapped_positions INTEGER NOT NULL DEFAULT 0,
            blocked_positions INTEGER NOT NULL DEFAULT 0,
            warnings_count INTEGER NOT NULL DEFAULT 0,
            errors_count INTEGER NOT NULL DEFAULT 0,
            quality_flags_json TEXT NOT NULL,
            summary_json TEXT NOT NULL DEFAULT '{}',
            created_at TEXT NOT NULL,
            notes TEXT
        );
        """
    )
    _add_missing_columns(conn, "instrument_mappings", {
        "source_name": "TEXT",
        "source_platform": "TEXT",
        "source_account": "TEXT",
        "source_label": "TEXT",
        "normalized_name": "TEXT",
        "isin": "TEXT",
        "ticker": "TEXT",
        "exchange": "TEXT",
        "currency": "TEXT",
        "asset_class": "TEXT DEFAULT 'other'",
        "confidence": "TEXT DEFAULT '0'",
    })
    _add_missing_columns(conn, "platform_account_mappings", {
        "source_platform": "TEXT",
        "normalized_platform": "TEXT",
        "normalized_account_name": "TEXT",
        "account_type": "TEXT DEFAULT 'other'",
        "currency": "TEXT",
        "internal_platform_id": "TEXT",
        "internal_account_id": "TEXT",
    })
    _add_missing_columns(conn, "broker_import_dry_runs", {
        "source_platform": "TEXT",
        "source_filename_hash": "TEXT",
        "detected_snapshot_date": "TEXT",
        "snapshot_date_status": "TEXT DEFAULT 'missing'",
        "candidate_positions": "INTEGER DEFAULT 0",
        "candidate_cash_rows": "INTEGER DEFAULT 0",
        "mapped_positions": "INTEGER DEFAULT 0",
        "blocked_positions": "INTEGER DEFAULT 0",
        "warnings_count": "INTEGER DEFAULT 0",
        "errors_count": "INTEGER DEFAULT 0",
        "summary_json": "TEXT DEFAULT '{}'",
        "session_status": "TEXT DEFAULT 'active'",
        "is_current": "INTEGER DEFAULT 0",
        "archived_at": "TEXT",
        "discarded_at": "TEXT",
    })

def _create_broker_import_review_items(conn: Connection) -> None:
    conn.executescript(
        """
        CREATE TABLE IF NOT EXISTS broker_import_review_items (
            review_item_id TEXT PRIMARY KEY,
            dry_run_id TEXT NOT NULL REFERENCES broker_import_dry_runs(dry_run_id),
            source_platform TEXT NOT NULL,
            source_file_type TEXT NOT NULL,
            source_row_ref TEXT NOT NULL,
            row_hash TEXT NOT NULL,
            source_label TEXT,
            normalized_name TEXT,
            detected_asset_class TEXT,
            detected_currency TEXT,
            detected_quantity_present INTEGER NOT NULL DEFAULT 0,
            detected_market_value_present INTEGER NOT NULL DEFAULT 0,
            isin TEXT,
            ticker TEXT,
            exchange TEXT,
            mapped_instrument_id TEXT REFERENCES instruments(instrument_id),
            mapped_account_id TEXT REFERENCES accounts(account_id),
            quality_flags_json TEXT NOT NULL,


nl -ba src/jarvis_finance/services/truewealth_service.py | sed -n '210,250p'; grep -R "include_router.*truewealth\|truewealth" -n src/jarvis_finance/api/main.py src/jarvis_finance/api/routers/truewealth.py src/jarvis_finance/api/schemas/truewealth.py frontend/src/api/truewealth.ts | head -200