15 0 .github/workflows/portfolio-phase3-integration.yml 19 0 docs/status/jarvis-finance-status-roadmap-v0.3.md 72 0 docs/status/sprint23-professional-portfolio-cockpit.md 28 0 frontend/src/api/marketRefresh.ts 30 12 frontend/src/api/portfolio.ts 43 0 frontend/src/components/wealth/AssetRefreshControl.test.ts 39 0 frontend/src/components/wealth/AssetRefreshControl.vue 44 0 frontend/src/components/wealth/PortfolioAnalysisPanel.test.ts 44 0 frontend/src/components/wealth/PortfolioAnalysisPanel.vue 5 2 frontend/src/components/wealth/WealthCockpitPanel.test.ts 12 45 frontend/src/components/wealth/WealthCockpitPanel.vue 60 0 frontend/src/components/wealth/WealthDevelopmentChart.test.ts 100 0 frontend/src/components/wealth/WealthDevelopmentChart.vue 4 4 frontend/src/pages/PortfolioPage.test.ts 1 1 scripts/ci_portfolio_phase3_gate.py 27 2 src/jarvis_finance/api/routers/market.py 35 0 src/jarvis_finance/api/routers/overview.py 15 3 src/jarvis_finance/api/routers/system.py 76 0 src/jarvis_finance/api/schemas/manual_snapshot.py 33 1 src/jarvis_finance/api/schemas/market.py 56 4 src/jarvis_finance/api/schemas/wealth_cockpit.py 328 0 src/jarvis_finance/services/asset_price_refresh.py 70 17 src/jarvis_finance/services/market_service.py 101 20 src/jarvis_finance/services/modelled_wealth.py 241 0 src/jarvis_finance/services/portfolio_analysis_v1.py 499 0 src/jarvis_finance/services/raiffeisen_manual_snapshot.py 33 9 src/jarvis_finance/services/system_ops.py 5 0 src/jarvis_finance/services/wealth_cockpit.py 97 2 src/jarvis_finance/storage/migrations.py 105 0 tests/unit/test_asset_price_refresh.py 1 1 tests/unit/test_budget_monthly_import_rule_learning_v1.py 1 1 tests/unit/test_budget_phase1.py 1 1 tests/unit/test_budget_phase11.py 1 1 tests/unit/test_budget_phase12_seed_review.py 1 1 tests/unit/test_budget_phase16_user_rules.py 1 1 tests/unit/test_fixed_costs_subscriptions_v1.py 1 1 tests/unit/test_grocery_matching_learning_v2.py 1 1 tests/unit/test_grocery_price_providers_v1.py 1 1 tests/unit/test_household_import_v1_golden.py 1 1 tests/unit/test_household_review_corrections_v1.py 86 0 tests/unit/test_portfolio_analysis_v1.py 1 1 tests/unit/test_portfolio_data_ingestion_reconciliation.py 283 0 tests/unit/test_raiffeisen_manual_snapshot.py 1 1 tests/unit/test_schema.py 2 2 tests/unit/test_schema49_fk_safe_phase18.py 2 2 tests/unit/test_sprint14_performance_contract.py 1 1 tests/unit/test_sprint17d_annual_budget.py 1 1 tests/unit/test_sprint20b_performance_activation_daily_valuations.py 1 1 tests/unit/test_sprint20c_performance_activation_hardening.py 1 1 tests/unit/test_sprint20e_crypto_market_recovery.py 1 1 tests/unit/test_sprint20g1_crypto_reconciliation.py diff --git a/tests/unit/test_asset_price_refresh.py b/tests/unit/test_asset_price_refresh.py new file mode 100644 index 0000000..b7cabf8 --- /dev/null +++ b/tests/unit/test_asset_price_refresh.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from pathlib import Path +import threading + +from jarvis_finance.services.asset_price_refresh import ( + asset_price_refresh_status, + create_asset_price_refresh_job, + run_asset_price_refresh, +) +from jarvis_finance.storage.database import connect +from jarvis_finance.storage.migrations import apply_migrations + + +def database(path: Path): + conn = connect(path) + apply_migrations(conn) + return conn + + +def test_refresh_job_is_queued_then_isolates_source_failure_and_creates_one_snapshot(tmp_path): + path = tmp_path / "finance.sqlite3" + conn = database(path) + queued, db_path = create_asset_price_refresh_job(conn, stale_hours=12) + assert queued["status"] == "queued" + assert all(row["status"] == "pending" for row in queued["sources"]) + assert queued["provider_calls_on_read"] is False + job_id = queued["job_id"] + conn.close() + + calls: list[str] = [] + + def success(source: str): + def runner(_conn): + calls.append(source) + return 2, 1 + return runner + + def failure(_conn): + calls.append("crypto") + raise RuntimeError("provider_unavailable") + + run_asset_price_refresh( + db_path, + job_id, + runners={"equity": success("equity"), "crypto": failure, "fx": success("fx")}, + ) + + conn = connect(path) + status = asset_price_refresh_status(conn, job_id) + assert calls == ["equity", "crypto", "fx"] + assert status["status"] == "partial" + assert status["progress"] == {"completed": 3, "total": 3} + assert status["wealth_snapshot_created"] is True + assert status["audit_recorded"] is True + assert [row["status"] for row in status["sources"]] == ["complete", "failed", "complete"] + assert conn.execute("SELECT COUNT(*) FROM aggregated_wealth_refresh_snapshots WHERE job_id=?", (job_id,)).fetchone()[0] == 1 + assert conn.execute("SELECT COUNT(*) FROM transactions").fetchone()[0] == 0 + conn.close() + + +def test_status_read_does_not_write_or_call_runner(tmp_path): + path = tmp_path / "finance.sqlite3" + conn = database(path) + queued, _ = create_asset_price_refresh_job(conn) + before = conn.total_changes + first = asset_price_refresh_status(conn, queued["job_id"]) + second = asset_price_refresh_status(conn, queued["job_id"]) + assert conn.total_changes == before + assert first == second + assert first["provider_calls_on_read"] is False + conn.close() + + +def test_concurrent_starts_create_exactly_one_active_job(tmp_path): + path = tmp_path / "finance.sqlite3" + database(path).close() + barrier = threading.Barrier(2) + outcomes: list[str] = [] + + def worker() -> None: + conn = connect(path) + conn.execute("PRAGMA busy_timeout=5000") + barrier.wait() + try: + create_asset_price_refresh_job(conn) + outcomes.append("created") + except ValueError as exc: + outcomes.append(str(exc)) + finally: + conn.close() + + first = threading.Thread(target=worker) + second = threading.Thread(target=worker) + first.start() + second.start() + first.join() + second.join() + + assert sorted(outcomes) == ["asset_price_refresh_job_already_running", "created"] + conn = connect(path) + assert conn.execute( + "SELECT COUNT(*) FROM asset_price_refresh_jobs WHERE status IN ('queued','running')" + ).fetchone()[0] == 1 + conn.close() diff --git a/tests/unit/test_budget_monthly_import_rule_learning_v1.py b/tests/unit/test_budget_monthly_import_rule_learning_v1.py index 67bd04c..45835d9 100644 --- a/tests/unit/test_budget_monthly_import_rule_learning_v1.py +++ b/tests/unit/test_budget_monthly_import_rule_learning_v1.py @@ -109,6 +109,6 @@ def test_rule_learning_suggests_rule_from_manual_category_change_and_applies_wit def test_schema_version_32_grocery_optimizer_tables_exist() -> None: conn = db() - assert get_schema_version(conn) == 51 + assert get_schema_version(conn) == 52 for table in ["budget_import_sessions", "budget_rule_suggestions"]: assert conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table,)).fetchone() diff --git a/tests/unit/test_budget_phase1.py b/tests/unit/test_budget_phase1.py index 6a87e7d..c987ee9 100644 --- a/tests/unit/test_budget_phase1.py +++ b/tests/unit/test_budget_phase1.py @@ -29,7 +29,7 @@ def db(): def test_budget_phase1_tables_seeds_and_text_decimal_columns_are_created_idempotently() -> None: conn = db() apply_migrations(conn) - assert get_schema_version(conn) == 51 + assert get_schema_version(conn) == 52 tables = {row["name"] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} assert { "budget_accounts", diff --git a/tests/unit/test_budget_phase11.py b/tests/unit/test_budget_phase11.py index 72c2d32..711308e 100644 --- a/tests/unit/test_budget_phase11.py +++ b/tests/unit/test_budget_phase11.py @@ -19,7 +19,7 @@ def db(): def test_budget_phase11_tables_tags_and_plan_items_are_created() -> None: conn = db() - assert get_schema_version(conn) == 51 + assert get_schema_version(conn) == 52 tables = {row["name"] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} assert {"budget_plan_items", "budget_excel_seed_dry_runs", "budget_excel_seed_candidates"}.issubset(tables) cols = {row["name"]: row["type"] for row in conn.execute("PRAGMA table_info(budget_plan_items)")} diff --git a/tests/unit/test_budget_phase12_seed_review.py b/tests/unit/test_budget_phase12_seed_review.py index 6092dfe..a5f2d71 100644 --- a/tests/unit/test_budget_phase12_seed_review.py +++ b/tests/unit/test_budget_phase12_seed_review.py @@ -35,7 +35,7 @@ def synthetic_workbook() -> dict[str, list[list[str]]]: def test_phase12_schema_has_requested_budget_seed_candidates_table() -> None: conn = db() - assert get_schema_version(conn) == 51 + assert get_schema_version(conn) == 52 cols = {row["name"]: row["type"] for row in conn.execute("PRAGMA table_info(budget_seed_candidates)")} assert { "seed_candidate_id", diff --git a/tests/unit/test_budget_phase16_user_rules.py b/tests/unit/test_budget_phase16_user_rules.py index 305a10f..988da26 100644 --- a/tests/unit/test_budget_phase16_user_rules.py +++ b/tests/unit/test_budget_phase16_user_rules.py @@ -49,7 +49,7 @@ def candidate(conn, description: str): def test_phase16_schema_adds_rule_name_and_applies_card_category_rules() -> None: conn = db() - assert get_schema_version(conn) == 51 + assert get_schema_version(conn) == 52 cats = add_categories(conn) seed_credit_card_candidates_from_rows( conn, diff --git a/tests/unit/test_fixed_costs_subscriptions_v1.py b/tests/unit/test_fixed_costs_subscriptions_v1.py index 756f018..c24155f 100644 --- a/tests/unit/test_fixed_costs_subscriptions_v1.py +++ b/tests/unit/test_fixed_costs_subscriptions_v1.py @@ -48,7 +48,7 @@ def candidate(conn, category_id, merchant, amount, day, status="pending", classi def test_schema_38_creates_decimal_text_recurring_table_with_user_facing_fields() -> None: conn = db() - assert get_schema_version(conn) == 51 + assert get_schema_version(conn) == 52 cols = {r["name"]: r["type"] for r in conn.execute("PRAGMA table_info(budget_recurring_payments)").fetchall()} assert cols["expected_amount_text"].upper() == "TEXT" assert cols["amount_tolerance_pct"].upper() == "TEXT" diff --git a/tests/unit/test_grocery_matching_learning_v2.py b/tests/unit/test_grocery_matching_learning_v2.py index 2800a70..9156218 100644 --- a/tests/unit/test_grocery_matching_learning_v2.py +++ b/tests/unit/test_grocery_matching_learning_v2.py @@ -27,7 +27,7 @@ def db() -> sqlite3.Connection: conn = sqlite3.connect(':memory:') conn.row_factory = sqlite3.Row apply_migrations(conn) - assert get_schema_version(conn) == 51 + assert get_schema_version(conn) == 52 return conn diff --git a/tests/unit/test_grocery_price_providers_v1.py b/tests/unit/test_grocery_price_providers_v1.py index 49cf7c2..3ed44e3 100644 --- a/tests/unit/test_grocery_price_providers_v1.py +++ b/tests/unit/test_grocery_price_providers_v1.py @@ -29,7 +29,7 @@ def db() -> sqlite3.Connection: def test_schema_version_33_provider_cache_columns_exist() -> None: conn = db() - assert get_schema_version(conn) == 51 + assert get_schema_version(conn) == 52 cols = {row['name'] for row in conn.execute('PRAGMA table_info(grocery_product_details_cache)').fetchall()} assert {'brand', 'image_url', 'price_decimal_text', 'currency', 'unit', 'unit_price_decimal_text', 'availability_status', 'promotion_text', 'source', 'confidence', 'quality_flags_json', 'raw_result_json'}.issubset(cols) diff --git a/tests/unit/test_household_import_v1_golden.py b/tests/unit/test_household_import_v1_golden.py index 23afc18..284a250 100644 --- a/tests/unit/test_household_import_v1_golden.py +++ b/tests/unit/test_household_import_v1_golden.py @@ -94,7 +94,7 @@ def business_ready_preview(conn: Connection, source: dict) -> tuple[dict, dict]: def test_schema_48_has_household_contract_and_three_unique_layers() -> None: conn = database() - assert get_schema_version(conn) == 51 + assert get_schema_version(conn) == 52 tables = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} assert {"household_account_source_mappings", "household_import_batches", "household_import_files", "household_import_items", "household_migros_links"}.issubset(tables) diff --git a/tests/unit/test_household_review_corrections_v1.py b/tests/unit/test_household_review_corrections_v1.py index ef2175d..8d2d77b 100644 --- a/tests/unit/test_household_review_corrections_v1.py +++ b/tests/unit/test_household_review_corrections_v1.py @@ -195,7 +195,7 @@ def assert_http_error(status: int, callable_: object, *args: object) -> None: def test_schema_48_adds_versioned_immutable_settlement_contract() -> None: conn = database() - assert get_schema_version(conn) == 51 + assert get_schema_version(conn) == 52 tables = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} assert "household_credit_card_settlements" in tables columns = {row[1] for row in conn.execute("PRAGMA table_info(budget_transaction_candidates)")} diff --git a/tests/unit/test_portfolio_analysis_v1.py b/tests/unit/test_portfolio_analysis_v1.py new file mode 100644 index 0000000..b430eb6 --- /dev/null +++ b/tests/unit/test_portfolio_analysis_v1.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import json + +from jarvis_finance.services.portfolio_analysis_v1 import build_portfolio_analysis_v1 +from jarvis_finance.services.portfolio_policy import confirm_policy, preview_policy +from jarvis_finance.storage.database import connect_memory +from jarvis_finance.storage.migrations import apply_migrations + +NOW = "2026-08-27T10:00:00Z" + + +def policy_payload(): + return { + "base_currency": "CHF", "effective_from": "2026-08-01", "horizon": "long", + "objective": "Kapitalerhalt und Wachstum", "liquidity_reserve": None, + "monthly_contribution": "0", "max_crypto_pct": "10", + "allocations": [ + {"asset_class": "cash", "target_pct": "40", "lower_pct": "35", "upper_pct": "45"}, + {"asset_class": "equity", "target_pct": "50", "lower_pct": "45", "upper_pct": "55"}, + {"asset_class": "crypto", "target_pct": "5", "lower_pct": "0", "upper_pct": "10"}, + {"asset_class": "other", "target_pct": "5", "lower_pct": "0", "upper_pct": "10"}, + ], + } + + +def modelled(): + components = [ + {"key": "postfinance", "label": "PostFinance", "current_value_chf": "350", "change_chf": "20"}, + {"key": "truewealth", "label": "True Wealth", "current_value_chf": "100", "change_chf": "10"}, + {"key": "crypto", "label": "Krypto", "current_value_chf": "50", "change_chf": "-5"}, + {"key": "bank_cash", "label": "Bankguthaben", "current_value_chf": "400", "change_chf": "0"}, + {"key": "other_assets", "label": "Weitere Anlagen", "current_value_chf": "20", "change_chf": "0"}, + ] + return {"components": components} + + +def test_analysis_uses_requested_buckets_active_policy_and_metadata_without_buy_sell_advice(): + conn = connect_memory() + apply_migrations(conn) + payload = policy_payload() + preview = preview_policy(conn, payload) + confirm_policy(conn, {**payload, **preview, "confirm": True}) + conn.execute( + """INSERT INTO instruments( + instrument_id,asset_class,name,currency,country,sector,is_active,created_at + ) VALUES('stock','stock','Aktie','CHF','CH','Industrie',1,?), + ('etf','etf','ETF','USD','US','Breit',1,?)""", + (NOW, NOW), + ) + conn.execute( + """INSERT INTO market_data_runs( + run_id,as_of,input_fingerprint,status,started_at,completed_at + ) VALUES('run','2026-08-27','fingerprint','complete',?,?)""", + (NOW, NOW), + ) + summary = { + "positions": [ + {"instrument_id": "stock", "name": "Aktie", "asset_class": "stock", "currency": "CHF", "value_chf": "100"}, + {"instrument_id": "etf", "name": "ETF", "asset_class": "etf", "currency": "USD", "value_chf": "200"}, + ] + } + conn.execute( + """INSERT INTO portfolio_analysis_snapshots( + analysis_snapshot_id,run_id,as_of,base_currency,total_value_chf,price_coverage_pct, + fx_coverage_pct,benchmark_coverage_pct,quality_status,reason_codes_json,summary_json,created_at + ) VALUES('analysis','run','2026-08-27','CHF','300','100','100','100','complete','[]',?,?)""", + (json.dumps(summary), NOW), + ) + conn.commit() + + result = build_portfolio_analysis_v1(conn, as_of="2026-08-27", modelled=modelled()) + + rows = {row["key"]: row for row in result["allocation"]} + assert {"cash", "stocks", "etf", "truewealth", "crypto", "other", "equity_policy_group"} <= set(rows) + assert rows["cash"]["status"] == "above_corridor" + assert rows["equity_policy_group"]["status"] == "below_corridor" + assert result["concentrations"]["top1_pct"] is not None + assert result["dimensions"]["currency"]["rows"] + assert result["dimensions"]["region"]["status"] == "partial" + assert 1 <= len(result["hints"]) <= 5 + hint_text = " ".join(item["text"] for item in result["hints"]) + assert "Reduktion prüfen" in hint_text + assert "Erhöhung prüfen" in hint_text + assert "Buy" not in hint_text and "Sell" not in hint_text + assert [row["value_chf"] for row in result["contributions"]] == ["20.00", "10.00", "-5.00"] diff --git a/tests/unit/test_portfolio_data_ingestion_reconciliation.py b/tests/unit/test_portfolio_data_ingestion_reconciliation.py index c1fb913..9bf0c93 100644 --- a/tests/unit/test_portfolio_data_ingestion_reconciliation.py +++ b/tests/unit/test_portfolio_data_ingestion_reconciliation.py @@ -138,7 +138,7 @@ def test_migrations_41_to_43_are_additive_and_ingestion_history_is_immutable( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: conn = database() - assert MIGRATION_VERSION == 51 + assert MIGRATION_VERSION == 52 assert conn.execute("SELECT MAX(version) FROM schema_migrations").fetchone()[0] == MIGRATION_VERSION assert {row[1] for row in conn.execute("PRAGMA table_info(instrument_price_mappings)")} >= { "source_symbol", "source_venue", "source_currency", diff --git a/tests/unit/test_raiffeisen_manual_snapshot.py b/tests/unit/test_raiffeisen_manual_snapshot.py new file mode 100644 index 0000000..5637eb0 --- /dev/null +++ b/tests/unit/test_raiffeisen_manual_snapshot.py @@ -0,0 +1,283 @@ +from __future__ import annotations + +from datetime import date +from decimal import Decimal +from pathlib import Path +import sqlite3 +import threading + +import pytest + +from jarvis_finance.services.modelled_wealth import build_modelled_wealth_development +from jarvis_finance.services.performance_scope import set_performance_scope_classification +from jarvis_finance.services.raiffeisen_manual_snapshot import ( + confirm_raiffeisen_manual_snapshot, + preview_raiffeisen_manual_snapshot, +) +from jarvis_finance.storage.database import connect, connect_memory +from jarvis_finance.storage.migrations import apply_migrations + +NOW = "2026-08-20T12:00:00+00:00" + + +def database(): + conn = connect_memory() + apply_migrations(conn) + conn.execute( + "INSERT INTO platforms(platform_id,name,platform_type,created_at) VALUES('bank','Raiffeisen','bank',?)", + (NOW,), + ) + for account_id, name in ( + ("private", "Privatkonto ••••5632"), + ("savings", "Sparkonto ••••5031"), + ): + conn.execute( + """INSERT INTO accounts( + account_id,platform_id,account_name,account_type,currency,performance_included, + is_active,created_at,balance_mode,portfolio_bucket + ) VALUES(?, 'bank', ?, 'cash','CHF',0,1,?,'snapshot','cash')""", + (account_id, name, NOW), + ) + for snapshot_id, account_id, value in ( + ("old-private", "private", "100.00"), + ("old-savings", "savings", "10.00"), + ): + conn.execute( + """INSERT INTO cash_account_snapshots( + snapshot_id,account_id,snapshot_type,balance_date,amount_original,currency, + amount_chf,source,created_at,created_by,semantic_identity + ) VALUES(?,?, 'manual_balance','2026-08-20',?,'CHF',?,'test',?,'test',?)""", + (snapshot_id, account_id, value, value, NOW, snapshot_id), + ) + conn.commit() + return conn + + +def source_facts(): + return { + "snapshot_date": date(2026, 8, 21), + "private_account_value_chf": Decimal("90.00"), + "savings_account_value_chf": Decimal("20.00"), + "membership_value_chf": Decimal("5.00"), + } + + +def test_preview_is_read_only_and_keeps_targets_separate(): + conn = database() + before = conn.total_changes + + preview = preview_raiffeisen_manual_snapshot(conn, **source_facts()) + + assert conn.total_changes == before + assert preview["bank_cash_after_chf"] == "110.00" + assert preview["separate_membership_asset_after_chf"] == "5.00" + assert preview["known_wealth_before_chf"] == "110.00" + assert preview["expected_known_wealth_after_chf"] == "115.00" + assert preview["expected_total_wealth_change_chf"] == "5.00" + assert preview["creates_transactions"] is False + assert [row["account_label"] for row in preview["affected_accounts"]] == [ + "Bankkonto ••••5632", + "Bankkonto ••••5031", + "Raiffeisen Genossenschaftsanteil", + ] + assert preview["affected_accounts"][2]["previous_status"] == "not_created" + + +def test_confirm_is_append_only_audited_and_idempotent(): + conn = database() + preview = preview_raiffeisen_manual_snapshot(conn, **source_facts()) + with pytest.raises(ValueError, match="confirmation_token_mismatch"): + confirm_raiffeisen_manual_snapshot( + conn, + **source_facts(), + preview_id=f"raiffeisen-preview-{'0' * 32}", + confirmation_id=f"raiffeisen-confirm-{'0' * 32}", + input_fingerprint=preview["input_fingerprint"], + ) + request = { + **source_facts(), + "preview_id": preview["preview_id"], + "confirmation_id": preview["confirmation_id"], + "input_fingerprint": preview["input_fingerprint"], + } + + result = confirm_raiffeisen_manual_snapshot(conn, **request) + replay = confirm_raiffeisen_manual_snapshot(conn, **request) + + assert result["status"] == "confirmed" + assert result["created_snapshot_count"] == 3 + assert result["created_transaction_count"] == 0 + assert result["known_wealth_after_chf"] == "115.00" + assert replay["status"] == "already_applied" + assert conn.execute("SELECT COUNT(*) FROM transactions").fetchone()[0] == 0 + assert conn.execute("SELECT COUNT(*) FROM cash_account_snapshots WHERE source='manual_screenshot_snapshot'").fetchone()[0] == 2 + assert conn.execute("SELECT COUNT(*) FROM account_value_snapshots WHERE source_type='manual_screenshot_snapshot'").fetchone()[0] == 1 + membership = conn.execute( + "SELECT account_type,portfolio_bucket FROM accounts WHERE lower(account_name) LIKE '%genossenschaft%'" + ).fetchone() + assert dict(membership) == {"account_type": "other_asset", "portfolio_bucket": "other"} + assert conn.execute("SELECT COUNT(*) FROM audit_log WHERE entity_id=?", (preview["confirmation_id"],)).fetchone()[0] == 1 + with pytest.raises(ValueError, match="confirmation_id_reused_with_different_input"): + confirm_raiffeisen_manual_snapshot( + conn, + **{**request, "membership_value_chf": Decimal("6.00")}, + ) + with pytest.raises(sqlite3.IntegrityError, match="manual cash snapshots are immutable"): + conn.execute( + "UPDATE cash_account_snapshots SET amount_chf='999.00' WHERE source='manual_screenshot_snapshot'" + ) + conn.rollback() + with pytest.raises(sqlite3.IntegrityError, match="manual asset snapshots cannot be deleted"): + conn.execute( + "DELETE FROM account_value_snapshots WHERE source_type='manual_screenshot_snapshot'" + ) + conn.rollback() + + +def test_confirm_fails_closed_when_baseline_changed(): + conn = database() + preview = preview_raiffeisen_manual_snapshot(conn, **source_facts()) + conn.execute( + """INSERT INTO cash_account_snapshots( + snapshot_id,account_id,snapshot_type,balance_date,amount_original,currency,amount_chf, + source,created_at,created_by,semantic_identity + ) VALUES('changed','private','manual_balance','2026-08-21','95','CHF','95','test',?,'test','changed')""", + (NOW,), + ) + conn.commit() + with pytest.raises(ValueError, match="baseline_changed"): + confirm_raiffeisen_manual_snapshot( + conn, + **source_facts(), + preview_id=preview["preview_id"], + confirmation_id=preview["confirmation_id"], + input_fingerprint=preview["input_fingerprint"], + ) + + +def test_acceptance_sums_include_unchanged_bank_cash_and_keep_component_correction_after_anchor(): + conn = database() + conn.execute( + "UPDATE cash_account_snapshots SET amount_original='29042.53',amount_chf='29042.53' WHERE account_id='private'" + ) + conn.execute( + "UPDATE cash_account_snapshots SET amount_original='19.84',amount_chf='19.84' WHERE account_id='savings'" + ) + conn.execute( + """INSERT INTO accounts( + account_id,platform_id,account_name,account_type,currency,performance_included, + is_active,created_at,balance_mode,portfolio_bucket + ) VALUES('unchanged-bank','bank','Unchanged bank account','cash','CHF',0,1,?,'snapshot','cash')""", + (NOW,), + ) + conn.execute( + """INSERT INTO cash_account_snapshots( + snapshot_id,account_id,snapshot_type,balance_date,amount_original,currency, + amount_chf,source,created_at,created_by,semantic_identity + ) VALUES('unchanged-bank-value','unchanged-bank','manual_balance','2026-08-20', + '74900.12','CHF','74900.12','test',?,'test','unchanged-bank-value')""", + (NOW,), + ) + conn.execute( + "INSERT INTO platforms(platform_id,name,platform_type,created_at) VALUES('pf','PostFinance','broker',?)", + (NOW,), + ) + conn.execute( + """INSERT INTO accounts( + account_id,platform_id,account_name,account_type,currency,performance_included, + is_active,created_at,balance_mode,portfolio_bucket + ) VALUES('pf-depot','pf','PostFinance Depot','brokerage','CHF',0,1,?,'snapshot','equity')""", + (NOW,), + ) + set_performance_scope_classification( + conn, + account_id="pf-depot", + included=True, + classification_role="postfinance_etrading_depot", + source="test", + note="acceptance anchor", + classified_at=NOW, + ) + conn.execute( + """INSERT INTO account_value_snapshots( + snapshot_id,account_id,valuation_date,total_value_chf,currency,source_type, + quality_status,created_at,valuation_at,is_active + ) VALUES('pf-anchor','pf-depot','2026-08-26','523788.47','CHF', + 'postfinance_official_import','ok',?,'2026-08-26T12:00:00+00:00',1)""", + (NOW,), + ) + conn.commit() + facts = { + "snapshot_date": date(2026, 8, 27), + "private_account_value_chf": Decimal("29059.44"), + "savings_account_value_chf": Decimal("19.84"), + "membership_value_chf": Decimal("200.00"), + } + + preview = preview_raiffeisen_manual_snapshot(conn, **facts) + + assert preview["bank_cash_after_chf"] == "103979.40" + assert preview["separate_membership_asset_after_chf"] == "200.00" + assert preview["known_wealth_before_chf"] == "627750.96" + assert preview["expected_total_wealth_change_chf"] == "216.91" + assert preview["expected_known_wealth_after_chf"] == "627967.87" + confirmed = confirm_raiffeisen_manual_snapshot( + conn, + **facts, + preview_id=preview["preview_id"], + confirmation_id=preview["confirmation_id"], + input_fingerprint=preview["input_fingerprint"], + ) + assert confirmed["bank_cash_after_chf"] == "103979.40" + assert confirmed["known_wealth_after_chf"] == "627967.87" + model = build_modelled_wealth_development(conn, as_of="2026-08-27", period="all") + assert model["last_confirmed_anchor_date"] == "2026-08-26" + + +def test_concurrent_identical_confirm_is_one_write_and_one_truthful_replay(tmp_path: Path): + source = database() + db_path = tmp_path / "concurrent.sqlite3" + target = connect(db_path) + source.backup(target) + source.close() + preview = preview_raiffeisen_manual_snapshot(target, **source_facts()) + target.close() + request = { + **source_facts(), + "preview_id": preview["preview_id"], + "confirmation_id": preview["confirmation_id"], + "input_fingerprint": preview["input_fingerprint"], + } + barrier = threading.Barrier(2) + statuses: list[str] = [] + failures: list[Exception] = [] + + def worker() -> None: + conn = connect(db_path) + conn.execute("PRAGMA busy_timeout=5000") + barrier.wait() + try: + statuses.append(confirm_raiffeisen_manual_snapshot(conn, **request)["status"]) + except Exception as exc: # pragma: no cover - asserted empty below + failures.append(exc) + finally: + conn.close() + + first = threading.Thread(target=worker) + second = threading.Thread(target=worker) + first.start() + second.start() + first.join() + second.join() + + assert failures == [] + assert sorted(statuses) == ["already_applied", "confirmed"] + conn = connect(db_path) + assert conn.execute("SELECT COUNT(*) FROM manual_snapshot_confirmations").fetchone()[0] == 1 + assert conn.execute( + "SELECT COUNT(*) FROM cash_account_snapshots WHERE source='manual_screenshot_snapshot'" + ).fetchone()[0] == 2 + assert conn.execute( + "SELECT COUNT(*) FROM account_value_snapshots WHERE source_type='manual_screenshot_snapshot'" + ).fetchone()[0] == 1 + conn.close() diff --git a/tests/unit/test_schema.py b/tests/unit/test_schema.py index 7e3a951..f576932 100644 --- a/tests/unit/test_schema.py +++ b/tests/unit/test_schema.py @@ -18,7 +18,7 @@ def test_db_schema_can_be_created() -> None: def test_schema_version_recorded() -> None: conn = connect_memory() apply_migrations(conn) - assert get_schema_version(conn) == 51 + assert get_schema_version(conn) == 52 def test_decimal_sensitive_columns_use_text_affinity() -> None: diff --git a/tests/unit/test_schema49_fk_safe_phase18.py b/tests/unit/test_schema49_fk_safe_phase18.py index 914da69..647b2db 100644 --- a/tests/unit/test_schema49_fk_safe_phase18.py +++ b/tests/unit/test_schema49_fk_safe_phase18.py @@ -76,13 +76,13 @@ def test_phase18_compatibility_is_idempotent_with_confirmed_transfer_child() -> assert conn.execute("PRAGMA foreign_keys").fetchone()[0] == 1 assert list(conn.execute("PRAGMA foreign_key_check")) == [] assert conn.execute("PRAGMA integrity_check").fetchone()[0] == "ok" - assert get_schema_version(conn) == 51 + assert get_schema_version(conn) == 52 def test_fresh_database_reaches_schema_49_with_foreign_keys_enabled() -> None: conn = connect_memory() apply_migrations(conn) - assert get_schema_version(conn) == 51 + assert get_schema_version(conn) == 52 assert conn.execute("PRAGMA foreign_keys").fetchone()[0] == 1 assert list(conn.execute("PRAGMA foreign_key_check")) == [] assert conn.execute("PRAGMA integrity_check").fetchone()[0] == "ok" diff --git a/tests/unit/test_sprint14_performance_contract.py b/tests/unit/test_sprint14_performance_contract.py index 9496615..634db75 100644 --- a/tests/unit/test_sprint14_performance_contract.py +++ b/tests/unit/test_sprint14_performance_contract.py @@ -200,7 +200,7 @@ def test_schema_46_defaults_new_accounts_out_of_performance_and_repeats_as_noop( "SELECT COUNT(*) FROM performance_scope_classifications" ).fetchone()[0] apply_migrations(conn) - assert get_schema_version(conn) == 51 + assert get_schema_version(conn) == 52 assert conn.execute("SELECT COUNT(*) FROM audit_log").fetchone()[0] == audit_before assert ( conn.execute("SELECT COUNT(*) FROM performance_scope_classifications").fetchone()[0] @@ -262,7 +262,7 @@ def test_performance_scope_migration_defaults_new_accounts_to_excluded_and_repea assert ( list(conn.execute("SELECT version,name FROM schema_migrations ORDER BY version")) == before ) - assert get_schema_version(conn) == 51 + assert get_schema_version(conn) == 52 assert conn.execute("PRAGMA integrity_check").fetchone()[0] == "ok" diff --git a/tests/unit/test_sprint17d_annual_budget.py b/tests/unit/test_sprint17d_annual_budget.py index 0057557..946c696 100644 --- a/tests/unit/test_sprint17d_annual_budget.py +++ b/tests/unit/test_sprint17d_annual_budget.py @@ -39,7 +39,7 @@ def book(conn, account: str, category: str, payee: str, amount: str, tx_type: st def test_migration_49_is_additive_and_supports_immutable_budget_versions() -> None: conn = db() - assert get_schema_version(conn) == 51 + assert get_schema_version(conn) == 52 recurring_columns = {row["name"] for row in conn.execute("PRAGMA table_info(budget_recurring_payments)")} plan_columns = {row["name"] for row in conn.execute("PRAGMA table_info(budget_plan_items)")} assert {"planning_cadence", "planning_type", "due_months_json", "data_version"} <= recurring_columns diff --git a/tests/unit/test_sprint20b_performance_activation_daily_valuations.py b/tests/unit/test_sprint20b_performance_activation_daily_valuations.py index e748cbd..cdaf662 100644 --- a/tests/unit/test_sprint20b_performance_activation_daily_valuations.py +++ b/tests/unit/test_sprint20b_performance_activation_daily_valuations.py @@ -404,4 +404,4 @@ def test_source_activation_rejects_changed_confirmed_holdings_after_preview() -> def test_schema_remains_49() -> None: - assert MIGRATION_VERSION == 51 + assert MIGRATION_VERSION == 52 diff --git a/tests/unit/test_sprint20c_performance_activation_hardening.py b/tests/unit/test_sprint20c_performance_activation_hardening.py index aaae80e..1872694 100644 --- a/tests/unit/test_sprint20c_performance_activation_hardening.py +++ b/tests/unit/test_sprint20c_performance_activation_hardening.py @@ -712,7 +712,7 @@ def test_truewealth_coverage_extensions_preserve_prior_complete_period_and_rejec def test_schema_stays_49(): - assert MIGRATION_VERSION == 51 + assert MIGRATION_VERSION == 52 def test_non_postfinance_source_variants_remain_single_canonical_account_value(): diff --git a/tests/unit/test_sprint20e_crypto_market_recovery.py b/tests/unit/test_sprint20e_crypto_market_recovery.py index 1409ee5..d8d82b9 100644 --- a/tests/unit/test_sprint20e_crypto_market_recovery.py +++ b/tests/unit/test_sprint20e_crypto_market_recovery.py @@ -493,4 +493,4 @@ def test_daily_dispatcher_selects_crypto_and_truewealth_sources( def test_schema_remains_49() -> None: - assert MIGRATION_VERSION == 51 + assert MIGRATION_VERSION == 52 diff --git a/tests/unit/test_sprint20g1_crypto_reconciliation.py b/tests/unit/test_sprint20g1_crypto_reconciliation.py index ce4cfa6..2edb25e 100644 --- a/tests/unit/test_sprint20g1_crypto_reconciliation.py +++ b/tests/unit/test_sprint20g1_crypto_reconciliation.py @@ -51,7 +51,7 @@ def _payload(): def test_schema_50_and_snapshot_preview_confirm_replay_are_append_only(tmp_path: Path) -> None: conn = _db(tmp_path) - assert get_schema_version(conn) == 51 + assert get_schema_version(conn) == 52 request = CryptoSnapshotPreviewRequest(**_payload()) before = {table: conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] for table in ("crypto_balance_snapshots", "crypto_balance_snapshot_items", "audit_log")} preview = preview_crypto_snapshot(conn, request) __HERMES_CWD_8d46a20096ed__/home/agent/.hermes/worktrees/FinanceManager-sprint23__HERMES_CWD_8d46a20096ed__