diff --git a/frontend/src/api/portfolio.ts b/frontend/src/api/portfolio.ts index 86e7071..0d7408a 100644 --- a/frontend/src/api/portfolio.ts +++ b/frontend/src/api/portfolio.ts @@ -133,11 +133,43 @@ export type WealthDiagnostic = { reason_code: string prominent: boolean } +export type ModelledValueQuality = 'confirmed' | 'modelled' | 'carried' | 'incomplete' | 'unavailable' +export type ModelledWealthDevelopment = { + status: 'available' | 'unavailable' + period: { preset: 'since_anchor' | '1m' | '3m' | '1y' | 'all'; from: string; to: string } + anchor: { date: string; value_chf: string; quality: ModelledValueQuality } | null + current: { date: string; value_chf: string; quality: ModelledValueQuality } | null + change_chf: string | null + change_pct: string | null + chart_visible: boolean + points: Array<{ + date: string + value_chf: string + quality: ModelledValueQuality + excluded_account_count: number + components: Array<{ key: 'postfinance' | 'truewealth' | 'crypto' | 'bank_cash'; label: string; value_chf: string | null; quality: ModelledValueQuality; source_date: string | null }> + }> + components: Array<{ key: 'postfinance' | 'truewealth' | 'crypto' | 'bank_cash'; label: string; current_value_chf: string | null; change_chf: string | null; change_pct: string | null; quality: ModelledValueQuality; as_of: string | null; unknown_account_count: number }> + correction_markers: Array<{ date: string; source_key: 'postfinance' | 'truewealth'; confirmed_value_chf: string; predecessor_model_value_chf: string; difference_chf: string }> + unknown_accounts: Array<{ key: string; label: string; reason_code: string }> + method: 'modelled_wealth_daily_v1' + disclaimer: string +} +export type VerifiedPerformanceSummary = { + status: 'verified' | 'not_verified' + label: string + ttwror_status: ReadinessStatus + xirr_status: ReadinessStatus + ttwror_pct: string | null + xirr_pct: string | null +} export type WealthCockpit = { scope_label: string not_net_worth: boolean - period: { preset: 'ytd' | 'previous_year' | '12m' | 'all'; from: string; to: string } + period: { preset: 'since_anchor' | '1m' | '3m' | '1y' | 'ytd' | 'previous_year' | '12m' | 'all'; from: string; to: string } data_cutoff: string + modelled_development: ModelledWealthDevelopment + verified_performance: VerifiedPerformanceSummary kpis: Array<{ key: string; label: string; value_chf?: string | null; value_pct?: string | null; value_date?: string | null; status: string }> totals: { captured_wealth_chf: string; investments_chf: string; bank_cash_chf: string; complete: boolean } history: { status: string; points: Array<{ at: string; value_chf: string }>; household_cashflow_events: Array<{ at: string; kind: string; amount_chf: string }>; investment_cashflow_events: Array<{ at: string; kind: string; amount: string }>; reason: string } @@ -178,7 +210,7 @@ export type CoverageMetric = { covered: number; total: number; ratio: string | n export type PortfolioReconciliation = { as_of: string; data_cutoff: string; base_currency: string; status: 'matched' | 'within_tolerance' | 'mismatch' | 'not_comparable' | 'unavailable'; reason_codes: string[]; tolerance: { version: string; absolute: string; relative: string }; coverage: Record; differences: { difference_ref: string; account_ref: string; account_label: string; instrument_ref: string; instrument_label: string; quantity: { ledger: string | null; reported: string | null; difference: string | null; status: string }; valuation: { derived: string | null; reported: string | null; difference: string | null; relative_difference: string | null; status: string; position_as_of: string | null; price_as_of: string | null; fx_as_of: string | null; price_source: string | null; fx_source: string | null }; reason_codes: string[] }[]; account_totals: { account_ref: string; account_label: string; total_value_only: boolean; positions_value: string | null; cash_value: string | null; derived_total: string | null; reported_total: string | null; difference: string | null; status: string; as_of: string | null; reason_codes: string[] }[]; input_fingerprint: string; sources: string[]; limit: number; offset: number; total: number } export const getOverview = (refresh = false) => apiGet('/api/overview', { refresh }) -export const getWealthCockpit = (period: WealthCockpit['period']['preset'] = 'ytd', refresh = false) => apiGet(`/api/portfolio/wealth-cockpit?period=${encodeURIComponent(period)}`, { refresh }) +export const getWealthCockpit = (period: WealthCockpit['period']['preset'] = '1m', refresh = false) => apiGet(`/api/portfolio/wealth-cockpit?period=${encodeURIComponent(period)}`, { refresh }) export const getReconciliationSnapshot = (refresh = false) => apiGet('/api/portfolio/reconciliation-snapshot', { refresh }) export const getFinanceCommandCenter = (month = currentLocalMonth(), refresh = false) => apiGet(`/api/overview/finance-command-center?month=${encodeURIComponent(month)}`, { refresh }) export const getPortfolioAdvisor = (refresh = false) => apiGet('/api/portfolio/advisor', { refresh }) diff --git a/frontend/src/components/wealth/WealthCockpitPanel.test.ts b/frontend/src/components/wealth/WealthCockpitPanel.test.ts index 1a03900..ca365ec 100644 --- a/frontend/src/components/wealth/WealthCockpitPanel.test.ts +++ b/frontend/src/components/wealth/WealthCockpitPanel.test.ts @@ -6,10 +6,10 @@ import WealthCockpitPanel from './WealthCockpitPanel.vue' vi.mock('@/api/portfolio', () => ({ getWealthCockpit: vi.fn() })) const source = (overrides: Partial): WealthCockpit['sources'][number] => ({ - key: 'postfinance', label: 'PostFinance', kind: 'Anlagequelle', current_value_chf: '318886.56', current_value_status: 'ready', as_of: '2026-08-26', + key: 'postfinance', label: 'PostFinance', kind: 'Anlagequelle', current_value_chf: '1030.00', current_value_status: 'ready', as_of: '2026-08-27', change_chf: null, net_contributions_chf: null, return_pct: null, freshness_status: 'fresh', reconciliation_status: 'reconciled', performance_status: 'not_ready', - value_basis: 'confirmed', last_activity_day: '2026-07-31', last_confirmed_snapshot: '2026-08-26', imported_at: '2026-08-26T18:00:00Z', + value_basis: 'modelled', last_activity_day: '2026-07-31', last_confirmed_snapshot: '2026-08-26', imported_at: '2026-08-26T18:00:00Z', coverage_from: '2026-07-01', coverage_to: '2026-07-31', coverage_status: 'partial', new_rows: 5, duplicate_rows: 0, review_rows: 0, next_action: 'August-Aktivitätsauszug ergänzen.', performance_blocker: 'August-Aktivitäten fehlen.', @@ -17,38 +17,74 @@ const source = (overrides: Partial): WealthCoc }) const cockpit = { - period: { preset: 'ytd', from: '2026-01-01', to: '2026-08-26' }, - totals: { captured_wealth_chf: '475683.28', investments_chf: '475683.28', bank_cash_chf: '0', complete: true }, - kpis: [], history: { points: [], status: 'unavailable', reason_codes: [], household_cashflow_events: [], investment_cashflow_events: [], reason: 'Keine Historie' }, - distribution: [], + scope_label: 'Erfasstes Vermögen', not_net_worth: true, + period: { preset: '1m', from: '2026-07-27', to: '2026-08-27' }, data_cutoff: '2026-08-27T12:00:00Z', + modelled_development: { + status: 'available', period: { preset: '1m', from: '2026-07-27', to: '2026-08-27' }, + anchor: { date: '2026-08-26', value_chf: '1600.00', quality: 'confirmed' }, + current: { date: '2026-08-27', value_chf: '1640.00', quality: 'incomplete' }, + change_chf: '40.00', change_pct: '2.5000', chart_visible: true, + points: [ + { date: '2026-08-26', value_chf: '1600.00', quality: 'confirmed', excluded_account_count: 1, components: [ + { key: 'postfinance', label: 'PostFinance', value_chf: '1000.00', quality: 'confirmed', source_date: '2026-08-26' }, + { key: 'truewealth', label: 'True Wealth', value_chf: '500.00', quality: 'confirmed', source_date: '2026-08-26' }, + { key: 'crypto', label: 'Krypto', value_chf: null, quality: 'unavailable', source_date: null }, + { key: 'bank_cash', label: 'Bankguthaben', value_chf: '100.00', quality: 'confirmed', source_date: '2026-08-26' }, + ] }, + { date: '2026-08-27', value_chf: '1640.00', quality: 'incomplete', excluded_account_count: 1, components: [ + { key: 'postfinance', label: 'PostFinance', value_chf: '1030.00', quality: 'modelled', source_date: '2026-08-27' }, + { key: 'truewealth', label: 'True Wealth', value_chf: '510.00', quality: 'modelled', source_date: '2026-08-27' }, + { key: 'crypto', label: 'Krypto', value_chf: null, quality: 'unavailable', source_date: null }, + { key: 'bank_cash', label: 'Bankguthaben', value_chf: '100.00', quality: 'carried', source_date: '2026-08-26' }, + ] }, + ], + components: [ + { key: 'postfinance', label: 'PostFinance', current_value_chf: '1030.00', change_chf: '30.00', change_pct: '3.0000', quality: 'modelled', as_of: '2026-08-27', unknown_account_count: 0 }, + { key: 'truewealth', label: 'True Wealth', current_value_chf: '510.00', change_chf: '10.00', change_pct: '2.0000', quality: 'modelled', as_of: '2026-08-27', unknown_account_count: 0 }, + { key: 'crypto', label: 'Krypto', current_value_chf: null, change_chf: null, change_pct: null, quality: 'unavailable', as_of: null, unknown_account_count: 0 }, + { key: 'bank_cash', label: 'Bankguthaben', current_value_chf: '100.00', change_chf: '0.00', change_pct: '0.0000', quality: 'incomplete', as_of: '2026-08-26', unknown_account_count: 1 }, + ], + correction_markers: [{ date: '2026-08-27', source_key: 'truewealth', confirmed_value_chf: '510.00', predecessor_model_value_chf: '508.00', difference_chf: '2.00' }], + unknown_accounts: [{ key: 'cash-missing', label: 'Sparkonto ••••5031', reason_code: 'confirmed_cash_evidence_missing' }], + method: 'modelled_wealth_daily_v1', disclaimer: 'Geschätzte Entwicklung', + }, + verified_performance: { status: 'not_verified', label: 'Noch nicht verifiziert', ttwror_status: 'not_ready', xirr_status: 'not_ready', ttwror_pct: null, xirr_pct: null }, + totals: { captured_wealth_chf: '1640.00', investments_chf: '1540.00', bank_cash_chf: '100.00', complete: false }, + kpis: [], history: { points: [], status: 'unavailable', household_cashflow_events: [], investment_cashflow_events: [], reason: 'Nicht relevant' }, distribution: [], policy: { configured: false, version: null, rows: [], contribution: null }, - sources: [ - source({}), - source({ key: 'truewealth', label: 'True Wealth', current_value_chf: '156796.72', as_of: '2026-08-25', last_activity_day: '2026-08-03', last_confirmed_snapshot: '2026-08-26', coverage_from: '2026-01-05', coverage_to: '2026-08-03', new_rows: 54, performance_blocker: 'Externe Cashflows unvollständig.' }), - ], - planning: { available: false, free_plannable_chf: null, link: '/budget' }, - diagnostics: [], data_quality: { missing_areas: [] }, + sources: [source({}), source({ key: 'truewealth', label: 'True Wealth', current_value_chf: '510.00', performance_blocker: 'Externe Cashflows unvollständig.' })], + planning: { available: false, free_plannable_chf: null, link: '/budget', included_in_wealth: false }, + diagnostics: [{ dimension: 'performance', affected_sources: ['PostFinance'], message: 'Cashflow-Coverage fehlt.', action: 'Diagnose prüfen.', reason_code: 'cashflow_coverage_missing', prominent: false }], + data_quality: { freshness_status: 'partial', reconciliation_status: 'not_assessable', performance_status: 'unavailable', performance_reasons: [], missing_areas: [], unassigned: [] }, method: { wealth_change: 'Stichtagsvergleich', investment_result: 'Coverage-gesteuert', return: 'Coverage-gesteuert' }, - readiness: { - dimensions: { - current_value: { status: 'ready', reason_codes: [] }, freshness: { status: 'partial', reason_codes: [] }, - reconciliation: { status: 'ready', reason_codes: [] }, performance: { status: 'not_ready', reason_codes: [] }, policy: { status: 'not_ready', reason_codes: [] }, - }, - metrics: [], - }, -} as unknown as WealthCockpit + readiness: { dimensions: { current_value: { status: 'partial', reason_code: null }, freshness: { status: 'partial', reason_code: null }, reconciliation: { status: 'partial', reason_code: null }, performance: { status: 'not_ready', reason_code: 'coverage' }, policy: { status: 'not_applicable', reason_code: null } }, metrics: [] }, + performance_coverage: { decision_version: 'investment_performance_scope_v1', expected_scopes: ['postfinance', 'truewealth', 'crypto'], status: 'partial', rows: [] }, + hints: [], +} as WealthCockpit -describe('WealthCockpitPanel source coverage', () => { - it('qualifies mixed-date totals and separates snapshots, activity coverage and performance blockers', async () => { +describe('WealthCockpitPanel modelled development', () => { + it('shows the model chart with two points while verified performance remains blocked', async () => { vi.mocked(getWealthCockpit).mockResolvedValue(cockpit) const wrapper = mount(WealthCockpitPanel) await flushPromises() - expect(wrapper.get('[data-testid="mixed-source-dates"]').text()).toContain('gemischten Stichtagen') - expect(wrapper.text()).toContain('Wertbasis') - expect(wrapper.text()).toContain('Bestätigt') - expect(wrapper.text()).toContain('Coverage') - expect(wrapper.text()).toContain('01.07.2026 – 31.07.2026') - expect(wrapper.text()).toContain('August-Aktivitätsauszug ergänzen') - expect(wrapper.text()).toContain('Externe Cashflows unvollständig') + + expect(getWealthCockpit).toHaveBeenCalledWith('1m', false) + expect(wrapper.find('[data-testid="modelled-chart"]').exists()).toBe(true) + expect(wrapper.get('[data-testid="modelled-current-badge"]').text()).toBe('Unvollständig') + expect(wrapper.get('[data-testid="verified-performance"]').attributes('open')).toBeUndefined() + expect(wrapper.get('[data-testid="verified-performance"]').text()).toContain('Noch nicht verifiziert') + }) + + it('consolidates PostFinance, uses bank wording and keeps blockers in collapsed diagnostics', async () => { + vi.mocked(getWealthCockpit).mockResolvedValue(cockpit) + const wrapper = mount(WealthCockpitPanel) + await flushPromises() + + expect(wrapper.findAll('[data-testid="modelled-component-postfinance"]')).toHaveLength(1) + expect(wrapper.get('[data-testid="modelled-component-bank_cash"]').text()).toContain('Kontostandentwicklung') + expect(wrapper.get('[data-testid="modelled-unknown-accounts"]').text()).toContain('übrige Entwicklung bleibt sichtbar') + expect(wrapper.get('[data-testid="wealth-diagnostics"]').attributes('open')).toBeUndefined() + expect(wrapper.get('[data-testid="wealth-diagnostics"]').text()).toContain('August-Aktivitäten fehlen') + expect(wrapper.get('[data-testid="correction-markers"]').text()).toContain('Importkorrektur') }) }) diff --git a/frontend/src/components/wealth/WealthCockpitPanel.vue b/frontend/src/components/wealth/WealthCockpitPanel.vue index 077fcca..e1be52b 100644 --- a/frontend/src/components/wealth/WealthCockpitPanel.vue +++ b/frontend/src/components/wealth/WealthCockpitPanel.vue @@ -1,157 +1,100 @@