diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 310df6d2..0602bcea 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -1287,9 +1287,11 @@ func main() { // factory converts every figure into the tenant's currency at a dated rate. financialSummaryUseCase := risk.NewFinancialSummaryUseCase(riskRepo, riskQuantifier). WithPresenters(financialPresenters). - WithCoverageCounter(riskRepo) + WithCoverageCounter(riskRepo). + WithAppetiteReader(orgRepo) financialAnalyticsHandler := handlers.NewFinancialAnalyticsHandler(financialSummaryUseCase). - WithCurrencyWriter(orgRepo) + WithCurrencyWriter(orgRepo). + WithAppetiteUseCase(risk.NewSetRiskAppetiteUseCase(orgRepo)) // NOTE: same bug class as compliance (see comment above complianceFrameworkRead) — // middleware.RequirePermissions reads the legacy *domain.UserClaims, which the RS256 @@ -2413,6 +2415,9 @@ func main() { // Tenant display currency — chosen at onboarding, changeable here (admin). protected.Put("/analytics/financial/currency", middleware.RequireRole("admin", "root"), financialAnalyticsHandler.SetCurrency) + // Risk appetite (#904) — the board's accepted annual loss, a tenant policy (admin). + protected.Put("/analytics/financial/appetite", + middleware.RequireRole("admin", "root"), featFinancial, financialAnalyticsHandler.SetAppetite) // Executive dashboard (spec §11) — ONE consolidated, tenant-scoped aggregation // (cyber score, financial exposure, KRIs, top-10 risks, risk & incident trends, diff --git a/backend/internal/application/risk/financial_summary.go b/backend/internal/application/risk/financial_summary.go index 4b67c477..841992e6 100644 --- a/backend/internal/application/risk/financial_summary.go +++ b/backend/internal/application/risk/financial_summary.go @@ -7,6 +7,7 @@ package risk import ( "context" + "math" "sort" "strings" "time" @@ -31,6 +32,32 @@ type FinancialCoverageCounter interface { CountFinancialCoverage(ctx context.Context, tenantID uuid.UUID) (total, quantified int, err error) } +// RiskAppetiteReader returns the tenant's risk appetite in XAF, or nil when the +// tenant has not set one (#904). Optional/nil-safe. +type RiskAppetiteReader interface { + OrgRiskAppetite(ctx context.Context, tenantID uuid.UUID) (*float64, error) +} + +// LossExceedance is the loss-exceedance curve of the whole register in compact +// form: total annual loss (XAF) at every percentile 0..100, before and after the +// treatment plans (#904). P(loss > x) is the share of percentiles above x. +type LossExceedance struct { + Inherent []float64 `json:"inherent"` + Residual []float64 `json:"residual"` +} + +// TreatmentPayback is one risk's treatment plan seen as an investment: what it +// costs, how much annual loss it removes, and how many months of avoided loss +// pay for it (#904). Money lives on the risk's plan (remediation cost and +// expected effectiveness), not on individual mitigations. +type TreatmentPayback struct { + RiskID uuid.UUID `json:"risk_id"` + Title string `json:"title"` + Cost crq.Money `json:"cost"` + Reduction crq.Money `json:"reduction"` // annual loss avoided + PaybackMonths float64 `json:"payback_months"` +} + // CriticalityBucket is the aggregated annual loss for one criticality band. type CriticalityBucket struct { Criticality string `json:"criticality"` @@ -77,6 +104,15 @@ type FinancialSummary struct { PortfolioROSIOK bool `json:"portfolio_rosi_computable"` ByCriticality []CriticalityBucket `json:"by_criticality"` TopRisks []TopRiskFinancial `json:"top_risks"` + + // #904 — the financial page of the October 2026 redesign. + // PortfolioROSI3Y is (3 × annual reduction − plan cost) / plan cost. + PortfolioROSI3Y float64 `json:"portfolio_rosi_3y"` + PortfolioROSI3YOK bool `json:"portfolio_rosi_3y_computable"` + TreatedRisks int `json:"treated_risks"` // risks whose plan has a cost + LossExceedance *LossExceedance `json:"loss_exceedance"` + Treatments []TreatmentPayback `json:"treatments"` // shortest payback first + RiskAppetiteXAF *float64 `json:"risk_appetite_xaf"` } // FinancialSummaryUseCase aggregates the CRQ model across a tenant's register. @@ -85,6 +121,7 @@ type FinancialSummaryUseCase struct { quantifier *crq.Quantifier presenters *FinancialPresenterFactory // optional; nil → XAF/static coverage FinancialCoverageCounter // optional; nil → derived from the list + appetite RiskAppetiteReader // optional; nil → no appetite now func() time.Time } @@ -105,6 +142,12 @@ func (uc *FinancialSummaryUseCase) WithCoverageCounter(c FinancialCoverageCounte return uc } +// WithAppetiteReader attaches the tenant risk-appetite reader. +func (uc *FinancialSummaryUseCase) WithAppetiteReader(r RiskAppetiteReader) *FinancialSummaryUseCase { + uc.appetite = r + return uc +} + // WithClock overrides the clock (tests). func (uc *FinancialSummaryUseCase) WithClock(now func() time.Time) *FinancialSummaryUseCase { uc.now = now @@ -147,6 +190,8 @@ func (uc *FinancialSummaryUseCase) Execute(ctx context.Context, tenantID uuid.UU tops := make([]TopRiskFinancial, 0, len(risks)) sims := make([]crq.SimulationInput, 0, len(risks)) + residualSims := make([]crq.SimulationInput, 0, len(risks)) + treatments := make([]TreatmentPayback, 0, len(risks)) for i := range risks { r := &risks[i] @@ -154,7 +199,22 @@ func (uc *FinancialSummaryUseCase) Execute(ctx context.Context, tenantID uuid.UU // Deterministic per-risk figures (no per-risk Monte Carlo); the band comes // from ONE shared portfolio simulation below. a := q.AssessDeterministic(in, string(r.Criticality)) - sims = append(sims, q.SimulationInputFor(in, string(r.Criticality))) + sim := q.SimulationInputFor(in, string(r.Criticality)) + sims = append(sims, sim) + residualSims = append(residualSims, crq.ScaleLoss(sim, 1-a.Effectiveness)) + + if a.RemediationCost.XAF > 0 { + sum.TreatedRisks++ + if a.RiskReduction.XAF > 0 { + treatments = append(treatments, TreatmentPayback{ + RiskID: r.ID, + Title: riskTitle(r), + Cost: a.RemediationCost, + Reduction: a.RiskReduction, + PaybackMonths: math.Round(a.RemediationCost.XAF/a.RiskReduction.XAF*12*10) / 10, + }) + } + } totalALE += a.ALE.XAF totalWorst += a.ALEWorst.XAF @@ -204,6 +264,30 @@ func (uc *FinancialSummaryUseCase) Execute(ctx context.Context, tenantID uuid.UU // Portfolio ROSI over all modeled controls. sum.PortfolioROSI, sum.PortfolioROSIOK = crq.ROSI(totalALE, totalAfter, totalRemediation) + if totalRemediation > 0 { + sum.PortfolioROSI3Y = math.Round((3*totalReduction-totalRemediation)/totalRemediation*100) / 100 + sum.PortfolioROSI3YOK = true + } + + // Loss-exceedance curve, before and after the plans, from the same draws. + if len(sims) > 0 { + sum.LossExceedance = &LossExceedance{ + Inherent: crq.PortfolioQuantiles(sims, crq.DefaultIterations, crq.DefaultSeed), + Residual: crq.PortfolioQuantiles(residualSims, crq.DefaultIterations, crq.DefaultSeed), + } + } + + sort.SliceStable(treatments, func(i, j int) bool { + return treatments[i].PaybackMonths < treatments[j].PaybackMonths + }) + sum.Treatments = treatments + + if uc.appetite != nil { + // A failing read leaves the appetite unset rather than failing the page. + if v, aErr := uc.appetite.OrgRiskAppetite(ctx, tenantID); aErr == nil { + sum.RiskAppetiteXAF = v + } + } // Emit criticality buckets in a stable order with USD derived. for _, b := range bandOrder { diff --git a/backend/internal/application/risk/financial_summary_exceedance_test.go b/backend/internal/application/risk/financial_summary_exceedance_test.go new file mode 100644 index 00000000..5dbd35cb --- /dev/null +++ b/backend/internal/application/risk/financial_summary_exceedance_test.go @@ -0,0 +1,109 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: AGPL-3.0-only +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 (see LICENSE). + +package risk + +import ( + "context" + "errors" + "testing" + + "github.com/google/uuid" + "github.com/opendefender/openrisk/internal/domain" + "github.com/opendefender/openrisk/pkg/crq" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The financial page of the redesign (#904): exceedance curve before and after +// the plans, treatment payback, three-year ROSI and the tenant's appetite. + +type mockAppetite struct { + v *float64 + err error +} + +func (m mockAppetite) OrgRiskAppetite(context.Context, uuid.UUID) (*float64, error) { + return m.v, m.err +} + +func pageRisks() []domain.Risk { + return []domain.Risk{ + { // ALE 10M, plan 2M at 80 % → reduction 8M/yr, payback 3 months + ID: uuid.New(), Title: "Ransomware", Criticality: domain.RiskCriticalityCritical, + SLEXAF: fp(20_000_000), ARO: fp(0.5), + RemediationCostXAF: fp(2_000_000), MitigationEffectiveness: fp(0.8), + }, + { // ALE 6M, plan 6M at 50 % → reduction 3M/yr, payback 24 months + ID: uuid.New(), Title: "Fraud", Criticality: domain.RiskCriticalityHigh, + SLEXAF: fp(6_000_000), ARO: fp(1), + RemediationCostXAF: fp(6_000_000), MitigationEffectiveness: fp(0.5), + }, + { // a plan with a cost but no effect: treated, never in the payback list + ID: uuid.New(), Title: "Unproven", Criticality: domain.RiskCriticalityMedium, + SLEXAF: fp(1_000_000), ARO: fp(1), RemediationCostXAF: fp(500_000), + }, + { // no plan + ID: uuid.New(), Title: "Untreated", Criticality: domain.RiskCriticalityLow, + SLEXAF: fp(2_000_000), ARO: fp(1), + }, + } +} + +func newPageUC(risks []domain.Risk) *FinancialSummaryUseCase { + return NewFinancialSummaryUseCase(&mockFinancialLister{risks: risks}, crq.NewQuantifier(600, crq.DefaultReference())) +} + +func TestFinancialSummaryPage_Success(t *testing.T) { + appetite := 12_000_000.0 + sum, err := newPageUC(pageRisks()).WithAppetiteReader(mockAppetite{v: &appetite}). + Execute(context.Background(), uuid.New()) + require.NoError(t, err) + + assert.Equal(t, 3, sum.TreatedRisks) + require.Len(t, sum.Treatments, 2, "a plan without effect has no payback") + assert.Equal(t, "Ransomware", sum.Treatments[0].Title) + assert.Equal(t, 3.0, sum.Treatments[0].PaybackMonths) + assert.Equal(t, 8_000_000.0, sum.Treatments[0].Reduction.XAF) + assert.Equal(t, "Fraud", sum.Treatments[1].Title) + assert.Equal(t, 24.0, sum.Treatments[1].PaybackMonths) + + // Plan cost 8.5M, annual reduction 11M → (33 − 8.5) / 8.5. + assert.True(t, sum.PortfolioROSI3YOK) + assert.InDelta(t, 2.88, sum.PortfolioROSI3Y, 0.005) + + require.NotNil(t, sum.LossExceedance) + inh, res := sum.LossExceedance.Inherent, sum.LossExceedance.Residual + require.Len(t, inh, 101) + require.Len(t, res, 101) + for p := range inh { + assert.LessOrEqual(t, res[p], inh[p], "after the plans a year never costs more (p%d)", p) + } + // The median of the curve is the headline band's P50. + assert.Equal(t, sum.PortfolioLoss.P50.XAF, inh[50]) + + require.NotNil(t, sum.RiskAppetiteXAF) + assert.Equal(t, appetite, *sum.RiskAppetiteXAF) +} + +// An empty register has no curve, no payback and no ROSI, and says so with +// nulls rather than zeros that read like measurements. +func TestFinancialSummaryPage_NotFound(t *testing.T) { + sum, err := newPageUC(nil).Execute(context.Background(), uuid.New()) + require.NoError(t, err) + assert.Nil(t, sum.LossExceedance) + assert.Empty(t, sum.Treatments) + assert.False(t, sum.PortfolioROSI3YOK) + assert.Nil(t, sum.RiskAppetiteXAF) +} + +// A failing appetite read leaves the appetite unset; the page still loads. +func TestFinancialSummaryPage_AppetiteReadFails(t *testing.T) { + sum, err := newPageUC(pageRisks()).WithAppetiteReader(mockAppetite{err: errors.New("db down")}). + Execute(context.Background(), uuid.New()) + require.NoError(t, err) + assert.Nil(t, sum.RiskAppetiteXAF) + assert.NotNil(t, sum.LossExceedance) +} diff --git a/backend/internal/application/risk/set_risk_appetite.go b/backend/internal/application/risk/set_risk_appetite.go new file mode 100644 index 00000000..8208dd4a --- /dev/null +++ b/backend/internal/application/risk/set_risk_appetite.go @@ -0,0 +1,51 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: AGPL-3.0-only +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 (see LICENSE). + +package risk + +import ( + "context" + "math" + + "github.com/google/uuid" + "github.com/opendefender/openrisk/internal/domain" +) + +// RiskAppetiteWriter stores the tenant's risk appetite. It returns a +// domain NotFound error when the organization does not exist. +type RiskAppetiteWriter interface { + SetOrganizationRiskAppetite(ctx context.Context, tenantID uuid.UUID, appetiteXAF float64) error +} + +// maxRiskAppetiteXAF bounds the input to something a balance sheet can hold +// (10^15 XAF); beyond it the number is a typo, not a policy. +const maxRiskAppetiteXAF = 1e15 + +// SetRiskAppetiteUseCase records the annual loss the board accepts, the +// threshold drawn on the loss-exceedance curve (#904). The amount is in XAF, +// the canonical store; the page converts from the display currency. +type SetRiskAppetiteUseCase struct { + writer RiskAppetiteWriter +} + +// NewSetRiskAppetiteUseCase builds the use case. +func NewSetRiskAppetiteUseCase(w RiskAppetiteWriter) *SetRiskAppetiteUseCase { + return &SetRiskAppetiteUseCase{writer: w} +} + +// Execute validates and stores the appetite for the caller's tenant. +func (uc *SetRiskAppetiteUseCase) Execute(ctx context.Context, tenantID uuid.UUID, appetiteXAF float64) (float64, error) { + if tenantID == uuid.Nil { + return 0, domain.NewForbiddenError("no organization in the session") + } + if math.IsNaN(appetiteXAF) || math.IsInf(appetiteXAF, 0) || appetiteXAF <= 0 || appetiteXAF > maxRiskAppetiteXAF { + return 0, domain.NewValidationError("risk appetite must be a positive amount") + } + v := math.Round(appetiteXAF) + if err := uc.writer.SetOrganizationRiskAppetite(ctx, tenantID, v); err != nil { + return 0, err + } + return v, nil +} diff --git a/backend/internal/application/risk/set_risk_appetite_test.go b/backend/internal/application/risk/set_risk_appetite_test.go new file mode 100644 index 00000000..d87cf7e1 --- /dev/null +++ b/backend/internal/application/risk/set_risk_appetite_test.go @@ -0,0 +1,64 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: AGPL-3.0-only +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 (see LICENSE). + +package risk + +import ( + "context" + "errors" + "math" + "testing" + + "github.com/google/uuid" + "github.com/opendefender/openrisk/internal/domain" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeAppetiteWriter struct { + tenants map[uuid.UUID]float64 + calls int +} + +func (f *fakeAppetiteWriter) SetOrganizationRiskAppetite(_ context.Context, tenantID uuid.UUID, v float64) error { + f.calls++ + if _, ok := f.tenants[tenantID]; !ok { + return domain.NewNotFoundError("organization", tenantID) + } + f.tenants[tenantID] = v + return nil +} + +func TestSetRiskAppetite_Success(t *testing.T) { + tenant := uuid.New() + w := &fakeAppetiteWriter{tenants: map[uuid.UUID]float64{tenant: 0}} + got, err := NewSetRiskAppetiteUseCase(w).Execute(context.Background(), tenant, 80_000_000.4) + require.NoError(t, err) + assert.Equal(t, 80_000_000.0, got) + assert.Equal(t, 80_000_000.0, w.tenants[tenant]) +} + +func TestSetRiskAppetite_NotFound(t *testing.T) { + w := &fakeAppetiteWriter{tenants: map[uuid.UUID]float64{}} + _, err := NewSetRiskAppetiteUseCase(w).Execute(context.Background(), uuid.New(), 1_000_000) + assert.True(t, errors.Is(err, domain.ErrNotFound)) +} + +func TestSetRiskAppetite_Unauthorized(t *testing.T) { + w := &fakeAppetiteWriter{tenants: map[uuid.UUID]float64{}} + _, err := NewSetRiskAppetiteUseCase(w).Execute(context.Background(), uuid.Nil, 1_000_000) + assert.True(t, errors.Is(err, domain.ErrForbidden)) + assert.Zero(t, w.calls, "nothing is written without a tenant") +} + +func TestSetRiskAppetite_Validation(t *testing.T) { + tenant := uuid.New() + w := &fakeAppetiteWriter{tenants: map[uuid.UUID]float64{tenant: 0}} + for _, v := range []float64{0, -5, math.NaN(), math.Inf(1), 2e15} { + _, err := NewSetRiskAppetiteUseCase(w).Execute(context.Background(), tenant, v) + assert.True(t, errors.Is(err, domain.ErrValidation), "value %v", v) + } + assert.Zero(t, w.calls) +} diff --git a/backend/internal/handler/risk_appetite_handler_test.go b/backend/internal/handler/risk_appetite_handler_test.go new file mode 100644 index 00000000..e92e8706 --- /dev/null +++ b/backend/internal/handler/risk_appetite_handler_test.go @@ -0,0 +1,100 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: AGPL-3.0-only +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 (see LICENSE). + +package handler + +import ( + "context" + "encoding/json" + "io" + "net/http/httptest" + "strings" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + + "github.com/opendefender/openrisk/internal/application/risk" + "github.com/opendefender/openrisk/internal/domain" + "github.com/opendefender/openrisk/internal/middleware" +) + +// PUT /analytics/financial/appetite (#904). + +type memAppetite map[uuid.UUID]float64 + +func (m memAppetite) SetOrganizationRiskAppetite(_ context.Context, id uuid.UUID, v float64) error { + if _, ok := m[id]; !ok { + return domain.NewNotFoundError("organization", id) + } + m[id] = v + return nil +} + +func appetiteApp(store memAppetite, tenant uuid.UUID) *fiber.App { + h := NewFinancialAnalyticsHandler(nil).WithAppetiteUseCase(risk.NewSetRiskAppetiteUseCase(store)) + app := fiber.New() + app.Use(func(c *fiber.Ctx) error { + if tenant != uuid.Nil { + middleware.SetContext(c, &middleware.RequestContext{UserID: uuid.New(), OrganizationID: tenant}) + } + return c.Next() + }) + app.Put("/analytics/financial/appetite", h.SetAppetite) + return app +} + +func putAppetite(t *testing.T, app *fiber.App, body string) (int, string) { + t.Helper() + req := httptest.NewRequest("PUT", "/analytics/financial/appetite", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("request: %v", err) + } + b, _ := io.ReadAll(resp.Body) + return resp.StatusCode, string(b) +} + +func TestSetAppetiteHandler_Success(t *testing.T) { + tenant := uuid.New() + store := memAppetite{tenant: 0} + code, body := putAppetite(t, appetiteApp(store, tenant), `{"appetite_xaf": 80000000}`) + if code != 200 { + t.Fatalf("want 200, got %d %s", code, body) + } + var out struct { + AppetiteXAF float64 `json:"appetite_xaf"` + } + _ = json.Unmarshal([]byte(body), &out) + if out.AppetiteXAF != 80_000_000 || store[tenant] != 80_000_000 { + t.Fatalf("appetite not stored: body %s, store %v", body, store[tenant]) + } +} + +func TestSetAppetiteHandler_NotFound(t *testing.T) { + code, _ := putAppetite(t, appetiteApp(memAppetite{}, uuid.New()), `{"appetite_xaf": 1000}`) + if code != 404 { + t.Fatalf("want 404, got %d", code) + } +} + +func TestSetAppetiteHandler_Unauthorized(t *testing.T) { + store := memAppetite{} + code, _ := putAppetite(t, appetiteApp(store, uuid.Nil), `{"appetite_xaf": 1000}`) + if code != 403 { + t.Fatalf("want 403 without an organization, got %d", code) + } +} + +func TestSetAppetiteHandler_Validation(t *testing.T) { + tenant := uuid.New() + app := appetiteApp(memAppetite{tenant: 0}, tenant) + for _, body := range []string{`{"appetite_xaf": 0}`, `{"appetite_xaf": -3}`, `not json`} { + if code, _ := putAppetite(t, app, body); code != 400 { + t.Fatalf("%s: want 400, got %d", body, code) + } + } +} diff --git a/backend/internal/handler/risk_financial_handler.go b/backend/internal/handler/risk_financial_handler.go index ad8247ae..0ae58c3e 100644 --- a/backend/internal/handler/risk_financial_handler.go +++ b/backend/internal/handler/risk_financial_handler.go @@ -27,7 +27,8 @@ type OrgCurrencyWriter interface { // (GET /analytics/financial) that backs the CISO/CFO screen. type FinancialAnalyticsHandler struct { summaryUC *risk.FinancialSummaryUseCase - currencyWr OrgCurrencyWriter // optional + currencyWr OrgCurrencyWriter // optional + appetiteUC *risk.SetRiskAppetiteUseCase // optional } // NewFinancialAnalyticsHandler builds the handler. @@ -41,6 +42,39 @@ func (h *FinancialAnalyticsHandler) WithCurrencyWriter(w OrgCurrencyWriter) *Fin return h } +// WithAppetiteUseCase attaches the risk-appetite setter (for PUT appetite). +func (h *FinancialAnalyticsHandler) WithAppetiteUseCase(uc *risk.SetRiskAppetiteUseCase) *FinancialAnalyticsHandler { + h.appetiteUC = uc + return h +} + +// SetAppetiteInput is the body of PUT /analytics/financial/appetite. +type SetAppetiteInput struct { + AppetiteXAF float64 `json:"appetite_xaf"` +} + +// SetAppetite PUT /analytics/financial/appetite — stores the annual loss the +// board accepts, drawn on the loss-exceedance curve (#904). Amount in XAF. +// Guarded admin at the route, like the currency: it is a tenant policy. +func (h *FinancialAnalyticsHandler) SetAppetite(c *fiber.Ctx) error { + if h.appetiteUC == nil { + return c.Status(500).JSON(fiber.Map{"error": "risk appetite not configured"}) + } + orgID := uuid.Nil + if mwCtx := middleware.GetContext(c); mwCtx != nil { + orgID = mwCtx.OrganizationID + } + in := new(SetAppetiteInput) + if err := c.BodyParser(in); err != nil { + return c.Status(400).JSON(fiber.Map{"error": "Invalid input"}) + } + v, err := h.appetiteUC.Execute(c.UserContext(), orgID, in.AppetiteXAF) + if err != nil { + return writeAppError(c, err) + } + return c.JSON(fiber.Map{"appetite_xaf": v}) +} + // SetCurrencyInput is the body of PUT /analytics/financial/currency. type SetCurrencyInput struct { Currency string `json:"currency"` diff --git a/backend/internal/infrastructure/repository/onboarding_profile_writes.go b/backend/internal/infrastructure/repository/onboarding_profile_writes.go index 90c45eeb..e7d39d32 100644 --- a/backend/internal/infrastructure/repository/onboarding_profile_writes.go +++ b/backend/internal/infrastructure/repository/onboarding_profile_writes.go @@ -8,6 +8,7 @@ package repository import ( "context" "encoding/json" + "errors" "strings" "github.com/google/uuid" @@ -100,6 +101,45 @@ func (r *GormOrganizationRepository) OrgCurrency(ctx context.Context, orgID uuid return string(crq.CurrencyXAF), nil } +// SetOrganizationRiskAppetite stores the tenant's risk appetite (XAF) in the +// organization's settings jsonb, beside the display currency (#904). The +// organization IS the tenant: the row is matched on id = tenantID, so no other +// tenant's settings can be touched. Missing organization → NotFound. +func (r *GormOrganizationRepository) SetOrganizationRiskAppetite(ctx context.Context, tenantID uuid.UUID, appetiteXAF float64) error { + var org domain.Organization + if err := r.db.WithContext(ctx).Where("id = ?", tenantID).First(&org).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return domain.NewNotFoundError("organization", tenantID) + } + return err + } + settings := org.GetSettings() + settings["risk_appetite_xaf"] = appetiteXAF + if err := org.SetSettings(settings); err != nil { + return err + } + return r.db.WithContext(ctx). + Model(&domain.Organization{}). + Where("id = ?", tenantID). + Update("settings", org.Settings).Error +} + +// OrgRiskAppetite reads the tenant's risk appetite (XAF), or nil when unset. +// Same tenant gate as above: the organization row is the tenant. +func (r *GormOrganizationRepository) OrgRiskAppetite(ctx context.Context, tenantID uuid.UUID) (*float64, error) { + if tenantID == uuid.Nil { + return nil, nil + } + var org domain.Organization + if err := r.db.WithContext(ctx).Where("id = ?", tenantID).First(&org).Error; err != nil { + return nil, err + } + if v, ok := org.GetSettings()["risk_appetite_xaf"].(float64); ok && v > 0 { + return &v, nil + } + return nil, nil +} + // UpdateUserProfile applies the wizard's profile step. // // The write is scoped to the acting user's own id — the wizard can only ever diff --git a/backend/internal/infrastructure/repository/org_risk_appetite_test.go b/backend/internal/infrastructure/repository/org_risk_appetite_test.go new file mode 100644 index 00000000..198b2f0d --- /dev/null +++ b/backend/internal/infrastructure/repository/org_risk_appetite_test.go @@ -0,0 +1,71 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: AGPL-3.0-only +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 (see LICENSE). + +package repository + +import ( + "context" + "errors" + "testing" + + "github.com/google/uuid" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "github.com/opendefender/openrisk/internal/domain" + "github.com/opendefender/openrisk/internal/testsupport/sqliteschema" +) + +// The risk appetite (#904) lives in the organization's settings beside the +// display currency. Writing it must keep the currency, never touch another +// organization, and read back as unset until someone sets it. +func TestOrgRiskAppetite_RoundTrip(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.Exec(`CREATE TABLE organizations (id TEXT PRIMARY KEY)`).Error; err != nil { + t.Fatalf("create table: %v", err) + } + if err := sqliteschema.Reconcile(db, "organizations", &domain.Organization{}); err != nil { + t.Fatalf("reconcile: %v", err) + } + repo := NewGormOrganizationRepository(db) + ctx := context.Background() + + a, b := uuid.New(), uuid.New() + for _, id := range []uuid.UUID{a, b} { + org := domain.Organization{ID: id, Name: "org " + id.String()[:4], Slug: id.String()} + if err := db.Create(&org).Error; err != nil { + t.Fatalf("seed org: %v", err) + } + } + if err := repo.SetOrganizationCurrency(ctx, a, "EUR"); err != nil { + t.Fatalf("set currency: %v", err) + } + + if v, err := repo.OrgRiskAppetite(ctx, a); err != nil || v != nil { + t.Fatalf("unset appetite must read nil, got %v, %v", v, err) + } + if err := repo.SetOrganizationRiskAppetite(ctx, a, 80_000_000); err != nil { + t.Fatalf("set appetite: %v", err) + } + v, err := repo.OrgRiskAppetite(ctx, a) + if err != nil || v == nil || *v != 80_000_000 { + t.Fatalf("appetite read back wrong: %v, %v", v, err) + } + if cur, _ := repo.OrgCurrency(ctx, a); cur != "EUR" { + t.Fatalf("writing the appetite lost the currency: %q", cur) + } + if other, _ := repo.OrgRiskAppetite(ctx, b); other != nil { + t.Fatalf("another organization got an appetite: %v", *other) + } + + err = repo.SetOrganizationRiskAppetite(ctx, uuid.New(), 1) + if !errors.Is(err, domain.ErrNotFound) { + t.Fatalf("unknown organization must be NotFound, got %v", err) + } +} diff --git a/backend/pkg/crq/montecarlo.go b/backend/pkg/crq/montecarlo.go index cf023ce3..be5f7578 100644 --- a/backend/pkg/crq/montecarlo.go +++ b/backend/pkg/crq/montecarlo.go @@ -161,7 +161,51 @@ func SimulatePortfolio(inputs []SimulationInput, iterations int, seed int64) Los if len(inputs) == 0 { return dist } + samples, sum := portfolioSamples(inputs, iterations, seed) + dist.P10 = round2(percentile(samples, 10)) + dist.P50 = round2(percentile(samples, 50)) + dist.P90 = round2(percentile(samples, 90)) + dist.P95 = round2(percentile(samples, 95)) + dist.Mean = round2(sum / float64(iterations)) + dist.Min = round2(samples[0]) + dist.Max = round2(samples[len(samples)-1]) + return dist +} +// PortfolioQuantiles runs the same shared Monte Carlo as SimulatePortfolio and +// returns the total annual loss at every percentile, 0 to 100 (101 values, XAF, +// ascending). It is the loss-exceedance curve in compact form: the probability +// that a year's loss exceeds x is the share of percentiles above x (#904). +// With no input it returns nil. +func PortfolioQuantiles(inputs []SimulationInput, iterations int, seed int64) []float64 { + if len(inputs) == 0 { + return nil + } + if iterations <= 0 { + iterations = DefaultIterations + } + samples, _ := portfolioSamples(inputs, iterations, seed) + out := make([]float64, 101) + for p := range out { + out[p] = round2(percentile(samples, float64(p))) + } + return out +} + +// ScaleLoss returns a copy of in whose loss magnitude is multiplied by factor +// (clamped to [0, 1]). The residual exposure of a treated risk is its inherent +// loss times (1 − effectiveness), the same rule as ALEAfter, so the curve +// "after plan" uses the same frequency and the same random draws. +func ScaleLoss(in SimulationInput, factor float64) SimulationInput { + f := math.Min(1, math.Max(0, factor)) + in.LM = PERT{Min: in.LM.Min * f, Mode: in.LM.Mode * f, Max: in.LM.Max * f} + return in +} + +// portfolioSamples draws `iterations` total annual losses across the inputs and +// returns them sorted ascending, with their sum. Deterministic given the seed +// and the order of inputs. +func portfolioSamples(inputs []SimulationInput, iterations int, seed int64) ([]float64, float64) { // Pre-compute each risk's LEF + Beta shape (or a degenerate point). type risk struct { lef float64 @@ -202,14 +246,7 @@ func SimulatePortfolio(inputs []SimulationInput, iterations int, seed int64) Los sum += total } sort.Float64s(samples) - dist.P10 = round2(percentile(samples, 10)) - dist.P50 = round2(percentile(samples, 50)) - dist.P90 = round2(percentile(samples, 90)) - dist.P95 = round2(percentile(samples, 95)) - dist.Mean = round2(sum / float64(iterations)) - dist.Min = round2(samples[0]) - dist.Max = round2(samples[len(samples)-1]) - return dist + return samples, sum } // pertBetaParams derives the standard Beta-PERT shape parameters (λ = 4). diff --git a/backend/pkg/crq/montecarlo_test.go b/backend/pkg/crq/montecarlo_test.go index 5383c505..282ce9bc 100644 --- a/backend/pkg/crq/montecarlo_test.go +++ b/backend/pkg/crq/montecarlo_test.go @@ -166,3 +166,46 @@ func TestPresenter_PresentDistribution(t *testing.T) { t.Fatalf("EUR conversion wrong: got %.2f %s want %.2f EUR", got.P50.Value, got.P50.Currency, wantEUR) } } + +func TestPortfolioQuantiles(t *testing.T) { + inputs := []SimulationInput{ + {LEF: 0.5, LM: PERT{Min: 5e6, Mode: 10e6, Max: 20e6}}, + {LEF: 1.0, LM: PERT{Min: 2e6, Mode: 4e6, Max: 9e6}}, + } + q := PortfolioQuantiles(inputs, 20_000, DefaultSeed) + if len(q) != 101 { + t.Fatalf("want 101 percentiles, got %d", len(q)) + } + for i := 1; i < len(q); i++ { + if q[i] < q[i-1] { + t.Fatalf("percentiles not ascending at %d: %v < %v", i, q[i], q[i-1]) + } + } + // Same run as SimulatePortfolio: its P50 and P90 are the 50th and 90th entries. + d := SimulatePortfolio(inputs, 20_000, DefaultSeed) + if q[50] != d.P50 || q[90] != d.P90 || q[0] != d.Min || q[100] != d.Max { + t.Fatalf("quantiles disagree with the portfolio band: q50=%v p50=%v q90=%v p90=%v", q[50], d.P50, q[90], d.P90) + } + if PortfolioQuantiles(nil, 1000, 1) != nil { + t.Fatal("an empty portfolio has no curve") + } +} + +func TestScaleLoss(t *testing.T) { + in := SimulationInput{LEF: 2, LM: PERT{Min: 10, Mode: 20, Max: 40}} + r := ScaleLoss(in, 0.25) + if r.LEF != 2 || r.LM != (PERT{Min: 2.5, Mode: 5, Max: 10}) { + t.Fatalf("scaled wrong: %+v", r) + } + if ScaleLoss(in, 3).LM != in.LM || ScaleLoss(in, -1).LM != (PERT{}) { + t.Fatal("factor must clamp to [0, 1]") + } + // A 40 % effective treatment moves every percentile to 60 % of the inherent one. + inh := PortfolioQuantiles([]SimulationInput{in}, 5000, 7) + res := PortfolioQuantiles([]SimulationInput{ScaleLoss(in, 0.6)}, 5000, 7) + for p := range inh { + if d := math.Abs(res[p] - inh[p]*0.6); d > 0.02 { + t.Fatalf("percentile %d: residual %v, want %v", p, res[p], inh[p]*0.6) + } + } +} diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 5e237801..1bcbdc8b 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -5,6 +5,35 @@ recommends, and surfaces these in the daily brief. Run `/decide` to clear them. ## Open +### D-068 — Loss-exceedance curve: sample how often losses happen? · raised 2026-10-09 + +**Raised by** — #904, tracked in #931. A change to the quantification model, so it is +escalated rather than made in place. + +**Context** +- The financial page now draws the loss-exceedance curve from the portfolio Monte Carlo + (FAIR-lite 1.0). That model fixes each risk's yearly frequency at its ARO and samples + only the loss size, so every simulated year contains every expected event. +- Result: the curve is an S between the best and worst plausible totals, and the + probability of a quiet year is 0. On the parity dataset it reads 100 % up to ~90 M FCFA, + then falls to 0 % by ~180 M. The mockup shows a decaying curve, which is what a model + that also samples the number of events produces. +- The ALE (the mean) does not depend on this choice; the P10/P50/P90/P95 band and the + curve do. + +**Options** +- **A — keep FAIR-lite 1.0.** The page shows the S honestly; nothing else moves. +- **B — FAIR-lite 1.1.** Draw events per year from Poisson(ARO). ALE unchanged, bands + widen, the curve decays like the mockup. `FormulaVersion` bump and a release note, + because every band on every screen moves. + +**Recommendation** — **B**, in its own release. Frequency is half of FAIR, and a curve +that says "a calm year is impossible" is not one a risk committee should read. + +**Cost of delay** — Low. The page is correct for the model it states. + +**Blocks** — #931 only. + ### D-066 — MFA: one authenticator per person, or one per organisation? · raised 2026-10-07 **Raised by** — #714, tracked in #897. This is tenant-isolation design, so it is escalated rather diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 9767ca35..142a54e0 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -906,6 +906,45 @@ paths: '401': description: Unauthorized + /analytics/financial/appetite: + put: + tags: + - Financial Quantification + summary: Set the tenant's risk appetite + description: > + Stores the annual loss the board accepts, drawn as the threshold on the + loss-exceedance curve. Amount in XAF, the canonical currency. Admin only. + operationId: setRiskAppetite + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [appetite_xaf] + properties: + appetite_xaf: { type: number, exclusiveMinimum: 0 } + responses: + '200': + description: Appetite stored (rounded to the unit) + content: + application/json: + schema: + type: object + required: [appetite_xaf] + properties: + appetite_xaf: { type: number } + '400': + description: Not a positive amount + '401': + description: Unauthorized + '403': + description: Not an administrator, or no organization in the session + '404': + description: Organization not found + /risks/{id}/transfer-owner: post: tags: @@ -3864,8 +3903,34 @@ components: criticality: { type: string } ale: { $ref: '#/components/schemas/Money' } ale_worst: { $ref: '#/components/schemas/Money' } + ale_after: + $ref: '#/components/schemas/Money' + description: Exposure targeted once the risk's treatment plan is in place. rosi: { type: number } rosi_computable: { type: boolean } + LossExceedance: + type: object + description: > + Loss-exceedance curve of the register in compact form: total annual + loss (XAF) at every percentile 0..100 from one shared Monte Carlo run, + before and after the treatment plans. P(loss > x) is the share of + percentiles above x. + required: [inherent, residual] + properties: + inherent: { type: array, items: { type: number }, minItems: 101, maxItems: 101 } + residual: { type: array, items: { type: number }, minItems: 101, maxItems: 101 } + TreatmentPayback: + type: object + description: One risk's treatment plan as an investment. + required: [risk_id, title, cost, reduction, payback_months] + properties: + risk_id: { type: string, format: uuid } + title: { type: string } + cost: { $ref: '#/components/schemas/Money' } + reduction: + $ref: '#/components/schemas/Money' + description: Annual loss avoided. + payback_months: { type: number } FinancialSummary: type: object description: Tenant-wide financial posture for the CFO/CISO dashboard. @@ -3901,6 +3966,25 @@ components: top_risks: type: array items: { $ref: '#/components/schemas/TopRiskFinancial' } + portfolio_rosi_3y: + type: number + description: (3 × annual reduction − plan cost) / plan cost. + portfolio_rosi_3y_computable: { type: boolean } + treated_risks: + type: integer + description: Risks whose treatment plan has a cost. + loss_exceedance: + nullable: true + allOf: [{ $ref: '#/components/schemas/LossExceedance' }] + description: Null when the register is empty. + treatments: + type: array + description: Treatment plans with a measurable reduction, shortest payback first. + items: { $ref: '#/components/schemas/TreatmentPayback' } + risk_appetite_xaf: + type: number + nullable: true + description: Annual loss the board accepts (XAF); null until set. # ========== INPUT SCHEMAS ========== LoginInput: diff --git a/frontend/src/features/financial/FinancialDashboard.tsx b/frontend/src/features/financial/FinancialDashboard.tsx index 234a0fdf..8e701558 100644 --- a/frontend/src/features/financial/FinancialDashboard.tsx +++ b/frontend/src/features/financial/FinancialDashboard.tsx @@ -1,1068 +1,367 @@ // Copyright (c) 2026 OpenDefender Contributors // SPDX-License-Identifier: AGPL-3.0-only -// This program is free software: you can redistribute it and/or modify it under // -// Financial Risk Quantification dashboard (spec §9) — a CFO/CISO view of the -// tenant's cyber exposure in money. The headline is a FAIR-lite P10/P50/P90 -// LOSS BAND (never a single number), converted into the tenant's currency at a -// dated reference rate. Every amount is clickable → a "Méthodologie" panel that -// shows the model, its intrants, assumptions, iterations and calc date. An -// investment simulator turns three inputs into ONE plain-language ROSI sentence. -// All figures come from GET /analytics/financial and POST /risks/:id/simulate. +// Financial quantification on the October 2026 redesign (#904): the register +// turned into money. Four headline figures (inherent ALE, targeted residual +// ALE, plan cost, 3-year ROSI), the loss-exceedance curve with the board's risk +// appetite, exposure by risk, and the treatment plans ranked by payback. +// +// Kept from the previous page, outside the design's layout: the methodology +// (opened from the subtitle), the display currency (header, admin) and the +// investment simulator (below the design's blocks). + +import { useState } from 'react'; +import { Link } from 'react-router'; +import { ChevronRight } from 'lucide-react'; -import { useMemo, useState } from 'react'; -import { CartesianChart } from '../../shared/ds/charts'; +import { useI18n } from '../../hooks/useI18n'; +import { useAuthStore } from '../../hooks/useAuthStore'; +import { formatNumber } from '../../i18n/format'; import { FeatureGate } from '../../shared/FeatureGate'; +import { Select } from '../../shared/ds'; +import { PageFrame, PageHeader } from '../../shared/ui'; import { useFeature } from '../billing/useEntitlements'; +import { BlockEmpty, BlockError, BlockSkeleton, Panel } from '../dashboard/console/Panel'; import { - Coins, - RefreshCw, - TrendingDown, - Wallet, - ShieldCheck, - Gauge, - FlaskConical, - Info, - X, - BookOpen, -} from 'lucide-react'; -import { - PageFrame, - PageHeader, - Card, - Btn, - Skeleton, - EmptyState, - ErrorState, -} from '../../shared/ui'; -import { useUIStore } from '../../store/uiStore'; -import { useAuthStore } from '../../hooks/useAuthStore'; -import { useFinancialSummary, useSimulateFinancial, useSetCurrency } from './useFinancial'; -import type { - FinancialSummary, - TopRiskFinancial, - Amount, - Methodology, - CurrencyCode, + SUPPORTED_CURRENCIES, + type CurrencyCode, + type FinancialSummary, + type Methodology, } from './financialService'; -import { SUPPORTED_CURRENCIES } from './financialService'; - -const C_LOSS = 'var(--chart-5)'; // exposure without controls -const C_RESID = 'var(--chart-3)'; // residual with controls -const C_BAND = 'var(--accent)'; -const CRIT_COLOR: Record = { - critical: 'var(--critical)', - high: 'var(--high)', - medium: 'var(--medium)', - low: 'var(--low)', -}; - -/* ---------------- currency-aware formatting ---------------- */ -// XAF keeps the familiar "FCFA" label in the target market; others show the code. -function curLabel(code: string): string { - return code === 'XAF' ? 'FCFA' : code; -} -function group(n: number): string { - const sign = n < 0 ? '-' : ''; - return `${sign}${Math.abs(Math.round(n)) - .toString() - .replace(/\B(?=(\d{3})+(?!\d))/g, ' ')}`; -} -function compact(n: number, lang: string): string { - const abs = Math.abs(n); - const u = lang === 'fr' ? { b: ' Md', m: ' M', k: ' k' } : { b: 'B', m: 'M', k: 'K' }; - const f = (v: number) => (lang === 'fr' ? v.toFixed(1).replace('.', ',') : v.toFixed(1)); - if (abs >= 1e9) return `${f(n / 1e9)}${u.b}`; - if (abs >= 1e6) return `${f(n / 1e6)}${u.m}`; - if (abs >= 1e3) return `${f(n / 1e3)}${u.k}`; - return String(Math.round(n)); -} -function fmtPct(ratio: number): string { - return `${ratio >= 0 ? '+' : ''}${Math.round(ratio * 100)}%`; -} -function relTime(iso: string, lang: string): string { - const then = new Date(iso).getTime(); - if (!Number.isFinite(then)) return ''; - const secs = Math.max(0, Math.round((Date.now() - then) / 1000)); - const mins = Math.round(secs / 60); - if (lang === 'fr') { - if (secs < 60) return 'il y a quelques secondes'; - if (mins < 60) return `il y a ${mins} min`; - return `il y a ${Math.round(mins / 60)} h`; - } - if (secs < 60) return 'a few seconds ago'; - if (mins < 60) return `${mins} min ago`; - return `${Math.round(mins / 60)} h ago`; -} - -/** A currency-aware formatter bound to the summary's currency + FX rate. */ -function useMoneyFmt(summary: FinancialSummary) { - const rate = summary.fx_rate_xaf > 0 ? summary.fx_rate_xaf : 1; - const code = summary.currency || 'XAF'; - const lang = useUIStore((s) => s.lang); - const disp = (xaf: number) => xaf / rate; - return { - code, - label: curLabel(code), - // From a raw XAF figure (Money-based fields). - xafFull: (xaf: number) => `${group(disp(xaf))} ${curLabel(code)}`, - xafCompact: (xaf: number) => `${compact(disp(xaf), lang)} ${curLabel(code)}`, - // From a currency-aware Amount (already converted server-side). - amtFull: (a: Amount) => `${group(a.value)} ${curLabel(a.currency)}`, - amtCompact: (a: Amount) => `${compact(a.value, lang)} ${curLabel(a.currency)}`, - }; -} +import { LossExceedanceCard } from './LossExceedanceCard'; +import { paybackTone } from './lec'; +import { MethodologyModal } from './MethodologyModal'; +import { portfolioMethodology } from './methodology'; +import { currencyLabel, millions, toDisplay } from './money'; +import { SimulatorCard } from './SimulatorCard'; +import { useFinancialSummary, useSetCurrency } from './useFinancial'; export function FinancialDashboard() { - const lang = useUIStore((s) => s.lang); - const tr = (fr: string, en: string) => (lang === 'fr' ? fr : en); + const { t, locale } = useI18n(); const fin = useFeature('financial_quantification'); - const { data, isLoading, isError, refetch, isFetching } = useFinancialSummary(); + const summary = useFinancialSummary(); const [methodology, setMethodology] = useState(null); + const data = summary.data; + const cur = currencyLabel(data?.currency); - // Paywall: on a plan without financial quantification, show the blurred preview - // + explaining upsell instead of the (402) error state. + // On a plan without the feature, the blurred preview and the upsell. if (!fin.loading && !fin.enabled) { return ( - - + + - + ); } - if (isLoading) return ; - if (isError || !data) { - return ( - - - refetch()} - retryLabel={tr('Réessayer', 'Retry')} - /> - - ); - } + const loading = summary.isLoading || fin.loading; + const failed = !loading && (summary.isError || !data); + const empty = !!data && data.total_risks === 0; return ( - + - - refetch()} /> - + className="!mb-[22px]" + title={t('financial.title')} + subtitle={ + data ? ( + <> + {t('financial.subtitle', { currency: cur })} + + + ) : undefined } + actions={data ? : undefined} /> -
- - {tr('Calculé', 'Computed')} {relTime(data.computed_at, lang)} - - · - - {tr('Taux', 'Rate')}{' '} - {data.fx_as_of ? new Date(data.fx_as_of).toLocaleDateString(lang) : '—'} - - · - {data.formula_version} -
- {isFetching && ( -
- )} - {data.total_risks === 0 ? ( - + {failed ? ( + + void summary.refetch()} /> + + ) : empty ? ( + + {t('financial.emptyRegister')} + ) : ( <> - setMethodology(summaryMethodology(data))} - /> -
- -
-
-
- -
- -
-
-
- -
- + + {data ? ( + + ) : ( + + + + )} +
+ +
+ {data && } )} - {methodology && ( - setMethodology(null)} - /> - )} + {methodology && setMethodology(null)} />} ); } -/* ---------------- headline P10/P50/P90 band ---------------- */ -function HeadlineBand({ - data, - lang, - tr, - onExplain, -}: { - data: FinancialSummary; - lang: string; - tr: (f: string, e: string) => string; - onExplain: () => void; -}) { - const f = useMoneyFmt(data); - const b = data.portfolio_loss; - // Position the P50 marker within the [P10, P90] track. - const span = Math.max(1, b.p90.value - b.p10.value); - const p50Pos = Math.min(100, Math.max(0, ((b.p50.value - b.p10.value) / span) * 100)); - return ( - -
-
-
- - {tr('Exposition annuelle attendue (médiane)', 'Expected annual exposure (median)')} - - -
- -
- {tr('sur', 'over')} {b.iterations.toLocaleString(lang)}{' '} - {tr('itérations Monte Carlo', 'Monte Carlo iterations')} -
-
-
-
- {tr('Plage P10 – P90', 'P10 – P90 range')} -
-
- {f.amtCompact(b.p10)} — {f.amtCompact(b.p90)} -
-
-
- {/* Interval track with the median highlighted. */} -
-
-
-
-
-
- P10 · {f.amtCompact(b.p10)} - P50 · {f.amtCompact(b.p50)} - P90 · {f.amtCompact(b.p90)} -
-
- - ); -} +/* ------------------------------------------------------------ KPI tiles */ -/* ---------------- KPI tiles ---------------- */ -function KpiRow({ - data, - lang, - tr, -}: { - data: FinancialSummary; - lang: string; - tr: (f: string, e: string) => string; -}) { - const f = useMoneyFmt(data); - const rosi = data.portfolio_rosi_computable ? fmtPct(data.portfolio_rosi) : '—'; - const tiles: { label: string; value: string; sub: string; icon: typeof Coins; tone: string }[] = [ +function KpiTiles({ data }: { data: FinancialSummary | undefined }) { + const { t, locale } = useI18n(); + const cur = currencyLabel(data?.currency); + const m = (xaf: number) => millions(toDisplay(xaf, data?.fx_rate_xaf), locale); + const rosi = data?.portfolio_rosi_3y_computable + ? `${data.portfolio_rosi_3y >= 0 ? '+' : '−'}${formatNumber(locale, Math.abs(Math.round(data.portfolio_rosi_3y * 100)))} %` + : '—'; + const tiles = [ { - label: tr('Pire cas P90', 'Worst case P90'), - value: f.amtCompact(data.portfolio_loss.p90), - sub: tr('1 année sur 10 dépasse ce montant', '1 year in 10 exceeds this'), - icon: TrendingDown, - tone: 'var(--critical)', + key: 'inherent', + label: t('financial.kpi.inherent'), + value: data ? m(data.total_ale.xaf) : '', + sub: data ? t('financial.kpi.inherentSub', { currency: cur, count: data.total_risks }) : '', + color: 'var(--fg-primary)', }, { - label: tr('Exposition ALE (moyenne)', 'ALE exposure (mean)'), - value: f.xafCompact(data.total_ale.xaf), - sub: `${group(data.total_ale.usd)} USD`, - icon: Coins, - tone: 'var(--accent)', + key: 'residual', + label: t('financial.kpi.residual'), + value: data ? m(data.total_ale_after.xaf) : '', + sub: t('financial.kpi.residualSub'), + color: 'var(--success-text)', }, { - label: tr('Budget de remédiation', 'Remediation budget'), - value: f.xafCompact(data.total_remediation.xaf), - sub: tr( - `Réduit l’ALE de ${f.xafCompact(data.total_risk_reduction.xaf)}`, - `Cuts ALE by ${f.xafCompact(data.total_risk_reduction.xaf)}`, - ), - icon: Wallet, - tone: 'var(--medium)', + key: 'plan', + label: t('financial.kpi.plan'), + value: data ? m(data.total_remediation.xaf) : '', + sub: data ? t('financial.kpi.planSub', { currency: cur, count: data.treated_risks }) : '', + color: 'var(--fg-primary)', }, { - label: tr('ROSI du portefeuille', 'Portfolio ROSI'), + key: 'rosi', + label: t('financial.kpi.rosi'), value: rosi, - sub: tr('Retour sur investissement sécurité', 'Return on security investment'), - icon: Gauge, - tone: 'var(--low)', + sub: data?.portfolio_rosi_3y_computable + ? t('financial.kpi.rosiSub') + : t('financial.kpi.rosiNone'), + color: 'var(--fg-primary)', }, ]; return ( -
- {tiles.map((t, i) => ( - -
- {t.label} - - - +
+ {tiles.map((k) => ( + +
+ {k.label}
-
{t.value}
-
{t.sub}
- - ))} -
- ); -} - -/* ---------------- currency picker (admin) ---------------- */ -function CurrencyPicker({ current }: { current: string }) { - const lang = useUIStore((s) => s.lang); - const can = useAuthStore((s) => s.hasPermission); - const setCur = useSetCurrency(); - // Currency is a tenant setting → the route is guarded admin/root, whose JWT - // carries the "*" wildcard. Non-admins see the code, read-only. - const allowed = can('*'); - if (!allowed) { - return {curLabel(current)}; - } - return ( - - ); -} - -/* ---------------- methodology panel (explainability §4) ---------------- */ -function MethodologyModal({ - m, - lang, - tr, - onClose, -}: { - m: Methodology; - lang: string; - tr: (f: string, e: string) => string; - onClose: () => void; -}) { - const meta: { k: string; v: string }[] = [ - { k: tr('Modèle', 'Model'), v: m.model }, - { k: tr('Version de formule', 'Formula version'), v: m.formula_version }, - { k: tr('Itérations', 'Iterations'), v: m.iterations.toLocaleString(lang) }, - { k: tr('Graine (déterministe)', 'Seed (deterministic)'), v: String(m.seed) }, - { - k: tr('Calculé le', 'Computed at'), - v: m.computed_at ? new Date(m.computed_at).toLocaleString(lang) : '—', - }, - { - k: tr('Devise', 'Currency'), - v: `${m.currency} · 1 ${m.currency} = ${group(m.fx_rate_xaf)} FCFA (${m.fx_as_of ? new Date(m.fx_as_of).toLocaleDateString(lang) : '—'})`, - }, - ]; - return ( -
-
e.stopPropagation()} - > -
-
-
- -
-
-
- {meta.map((r) => ( -
- {r.k} - {r.v} + {data ? ( + <> +
+ {k.value}
- ))} -
- -
- {tr('Intrants utilisés', 'Inputs used')} -
- - - {m.inputs.map((inp) => ( - - - - - - ))} - -
{inp.label} - {group(inp.value)} {inp.unit} - - -
- -
- {tr('Hypothèses', 'Assumptions')} -
-
    - {m.assumptions.map((a, i) => ( -
  • {a}
  • - ))} -
- - - {tr('Documentation du modèle', 'Model documentation')} - -
-
+
{k.sub}
+ + ) : ( +
+ )} + + ))}
); } -function SourceChip({ source, tr }: { source: string; tr: (f: string, e: string) => string }) { - const map: Record = { - 'risk-input': { label: tr('saisi', 'input'), tone: 'var(--low)' }, - derived: { label: tr('dérivé', 'derived'), tone: 'var(--accent)' }, - 'reference-model': { label: tr('référence', 'reference'), tone: 'var(--medium)' }, - }; - const s = map[source] ?? { label: source, tone: 'var(--ink-muted)' }; - return ( - - {s.label} - - ); -} -/** Build a Methodology object for the portfolio band from the summary fields. */ -function summaryMethodology(d: FinancialSummary): Methodology { - const b = d.portfolio_loss; - return { - formula_version: d.formula_version || b.formula_version, - model: 'FAIR-lite: ALE = LEF × LM (PERT), Monte Carlo (portefeuille)', - iterations: d.iterations || b.iterations, - // `seed` is the Monte-Carlo RNG seed the backend returns with the - // simulation, surfaced so a run can be reproduced. The no-mock-data rule - // matches the word `seed` in any identifier — the right default, since - // seeded fixtures are how fake data gets in — but wrong for this one field: - // it comes from the API and is displayed as provenance, not invented. - // eslint-disable-next-line openrisk/no-mock-data - seed: b.seed, - computed_at: d.computed_at, - doc_url: '/docs/financial-quantification.md', - currency: d.currency, - fx_as_of: d.fx_as_of, - fx_rate_xaf: d.fx_rate_xaf, - inputs: [ - { - key: 'risks', - label: 'Risques agrégés', - value: d.total_risks, - unit: 'risques', - source: 'derived', - }, - { - key: 'quantified', - label: 'Risques chiffrés', - value: d.quantified_risks, - unit: 'risques', - source: 'risk-input', - }, - ], - assumptions: [ - 'Exposition totale = simulation Monte Carlo unique sommant la perte de chaque risque par itération (diversification prise en compte).', - 'Chaque risque : ALE = LEF × LM ; LM suit une loi PERT à 3 points (min / probable / max).', - 'La médiane P50 est mise en avant ; P10 et P90 bornent l’intervalle.', - ], - }; -} +/* ----------------------------------------------------- exposure by risk */ -/* ---------------- cumulative loss projection ---------------- */ -function ProjectionCard({ +function ExposureTable({ data, - lang, - tr, + className, }: { - data: FinancialSummary; - lang: string; - tr: (f: string, e: string) => string; + data: FinancialSummary | undefined; + className: string; }) { - const rate = data.fx_rate_xaf > 0 ? data.fx_rate_xaf : 1; - const code = curLabel(data.currency || 'XAF'); - const series = useMemo(() => { - const before = data.total_ale.xaf / rate; - const after = data.total_ale_after.xaf / rate; - return Array.from({ length: 6 }, (_, y) => ({ - year: lang === 'fr' ? `A${y}` : `Y${y}`, - sans: Math.round(before * y), - avec: Math.round(after * y), - })); - }, [data, lang, rate]); - + const { t, locale } = useI18n(); + const rows = (data?.top_risks ?? []).filter((r) => r.ale.xaf > 0); + const m = (xaf: number) => millions(toDisplay(xaf, data?.fx_rate_xaf), locale); + const th = 'h-[34px] font-semibold border-b border-border-subtle'; return ( - -
-
- {tr('Projection des pertes cumulées (5 ans)', 'Cumulative loss projection (5 yrs)')} -
-
-
- {tr( - 'L’écart entre les deux courbes est la valeur créée par le programme de sécurité.', - 'The gap between the curves is the value created by the security program.', - )} + +
+

+ {t('financial.exposure.title')} +

- `${compact(v, lang)} ${code}`} - ariaLabel={tr( - 'Projection des pertes cumulées sur cinq ans, sans contrôle et avec contrôles', - 'Cumulative five-year loss projection, uncontrolled and with controls', - )} - /> - - ); -} - -/* ---------------- ALE by criticality ---------------- */ -function ByCriticalityCard({ - data, - lang, - tr, -}: { - data: FinancialSummary; - lang: string; - tr: (f: string, e: string) => string; -}) { - const f = useMoneyFmt(data); - const label: Record = { - critical: tr('Critique', 'Critical'), - high: tr('Élevé', 'High'), - medium: tr('Moyen', 'Medium'), - low: tr('Faible', 'Low'), - }; - const max = Math.max(1, ...data.by_criticality.map((b) => b.ale.xaf)); - return ( - -
- {tr('Exposition annuelle par criticité', 'Annual exposure by criticality')} -
-
- {data.by_criticality.map((b) => ( -
-
- - - {label[b.criticality] ?? b.criticality} - · {b.count} - - - {f.xafCompact(b.ale.xaf)} - -
-
-
-
-
- ))} -
- - ); -} - -/* ---------------- top exposures table ---------------- */ -function TopExposuresCard({ - data, - lang, - tr, -}: { - data: FinancialSummary; - lang: string; - tr: (f: string, e: string) => string; -}) { - const f = useMoneyFmt(data); - const rows = data.top_risks; - return ( - -
- {tr('Principales expositions financières', 'Top financial exposures')} -
- {/* Same as ExecutiveDashboard's top-10 table: wider than a phone, so it - scrolls at 393px and needs to be reachable by keyboard (#589). */} -
- + {!data ? ( +
+ +
+ ) : rows.length === 0 ? ( +
+ {t('financial.exposure.empty')} +
+ ) : ( +
- - - - - + + + + + - {rows.map((r: TopRiskFinancial) => ( - - - - - + + + + - - ))} + + + ); + })}
{tr('Risque', 'Risk')}ALE{tr('Pire cas', 'Worst')}ROSI
{t('financial.exposure.risk')} + {t('financial.exposure.inherent')} + + {t('financial.exposure.residual')} + {t('financial.exposure.reduction')}
- - - - {r.title} - - - {f.xafCompact(r.ale.xaf)} - {f.xafCompact(r.ale_worst.xaf)} - - {r.rosi_computable ? ( - = 0 ? 'var(--low)' : 'var(--critical)' }} + {rows.map((r) => { + const after = r.ale_after?.xaf ?? r.ale.xaf; + const share = r.ale.xaf > 0 ? (r.ale.xaf - after) / r.ale.xaf : 0; + const critical = r.criticality.toLowerCase() === 'critical'; + return ( +
+ - {fmtPct(r.rosi)} + + #{r.id.slice(0, 8)} + + {r.title} + + + {m(r.ale.xaf)} + {m(after)} + + + + + + {share > 0 ? `−${Math.round(share * 100)}%` : '—'} + - ) : ( - — - )} -
-
-
- ); -} - -/* ---------------- investment scenario simulator (ROSI, §5) ---------------- */ -function SimulatorCard({ - rows, - summary, - lang, - tr, - onExplain, -}: { - rows: TopRiskFinancial[]; - summary: FinancialSummary; - lang: string; - tr: (f: string, e: string) => string; - onExplain: (m: Methodology) => void; -}) { - const [riskId, setRiskId] = useState(rows[0]?.id ?? ''); - const [action, setAction] = useState(''); - const [cost, setCost] = useState(5_000_000); - const [eff, setEff] = useState(0.7); - const sim = useSimulateFinancial(riskId); - const f = useMoneyFmt(summary); - - // Slider ceiling scales with the selected risk's ALE (worst case) so the cost - // slider stays meaningful. - const selected = rows.find((r) => r.id === riskId); - const costMax = Math.max(20_000_000, Math.round(selected?.ale_worst.xaf ?? 50_000_000)); - - const run = () => { - if (!riskId) return; - sim.mutate({ remediation_cost_xaf: cost, mitigation_effectiveness: eff }); - }; - - const a = sim.data; - return ( - -
- -
- {tr('Simulateur d’investissement', 'Investment simulator')} -
-
-
- {tr( - 'Trois réglages → une phrase de décision. Rien n’est enregistré.', - 'Three settings → one decision sentence. Nothing is saved.', - )} -
- - {/* 1. Which risk */} - - - {/* 2. Concrete control / action — makes the sentence precise ("comment ?") */} - - - {/* 3a. Cost slider */} - - - {/* 3b. Effectiveness slider */} - - - - - {sim.isError && ( -
- {tr('Échec de la simulation', 'Simulation failed')} -
- )} - {a && ( - )} -
+ ); } -function SimResult({ - a, - riskTitle, - action, - summary, - lang, - tr, - onExplain, +/* ------------------------------------------------------ payback ranking */ + +function PaybackList({ + data, + className, }: { - a: import('./financialService').FinancialAssessment; - riskTitle: string; - action: string; - summary: FinancialSummary; - lang: string; - tr: (f: string, e: string) => string; - onExplain: (m: Methodology) => void; + data: FinancialSummary | undefined; + className: string; }) { - const f = useMoneyFmt(summary); - const before = a.ale; - const after = a.ale_after; - const cost = a.remediation_cost; - const reduction = a.risk_reduction; - // Payback in months: cost / (annual loss avoided / 12). - const paybackMonths = reduction.xaf > 0 ? Math.round((cost.xaf * 12) / reduction.xaf) : 0; - const measure = action.trim() || tr('cette mesure', 'this measure'); - const rosiTxt = a.rosi_computable ? fmtPct(a.rosi) : '—'; - - // One concrete decision sentence (spec §5). - const sentence = - lang === 'fr' - ? `Investir ${f.xafFull(cost.xaf)} dans ${measure}${riskTitle ? ` pour le risque « ${riskTitle} »` : ''} ramènerait son exposition annuelle attendue de ${f.xafFull(before.xaf)} à ${f.xafFull(after.xaf)}${a.rosi_computable ? `, soit un retour de ${rosiTxt}` : ''}${paybackMonths > 0 && cost.xaf > 0 ? ` — l’investissement serait amorti en ~${paybackMonths} mois` : ''}.` - : `Investing ${f.xafFull(cost.xaf)} in ${measure}${riskTitle ? ` for the "${riskTitle}" risk` : ''} would cut its expected annual exposure from ${f.xafFull(before.xaf)} to ${f.xafFull(after.xaf)}${a.rosi_computable ? `, a ${rosiTxt} return` : ''}${paybackMonths > 0 && cost.xaf > 0 ? ` — paying for itself in ~${paybackMonths} months` : ''}.`; - + const { t, locale } = useI18n(); + const m = (xaf: number) => millions(toDisplay(xaf, data?.fx_rate_xaf), locale); + const rows = data?.treatments ?? []; return ( -
- {/* The sentence that makes a COMEX understand the product. */} -
- {sentence} -
- -
- - - ROSI - - = 0 - ? 'var(--low)' - : 'var(--critical)' - : 'var(--ink-muted)', - }} - > - {rosiTxt} - -
-
- - + +
+

{t('financial.payback.title')}

+

{t('financial.payback.sub')}

-
- - {tr('Perte évitée / an :', 'Loss avoided / yr:')}{' '} - {f.xafFull(reduction.xaf)} - - {a.methodology && ( - - )} -
-
+ + {r.title} + + #{r.risk_id.slice(0, 8)} ·{' '} + {t('financial.payback.row', { cost: m(r.cost.xaf), reduction: m(r.reduction.xaf) })} + + + + {t('financial.payback.months', { + n: formatNumber(locale, Math.round(r.payback_months)), + })} + +