From 9629689afc0499c060b2302bc7450e4dc8fc2d7f Mon Sep 17 00:00:00 2001 From: alex-dembele Date: Fri, 9 Oct 2026 08:08:18 +0100 Subject: [PATCH 1/4] feat(crq): loss-exceedance curve and treatment payback in the summary (#904) The redesigned financial page draws the probability that a year's loss exceeds an amount, before and after the treatment plans, and lists the plans by payback. The summary now carries: - loss_exceedance: total annual loss at every percentile 0..100 from the same shared Monte Carlo as the P10/P50/P90 band, inherent and residual. The residual run scales each risk's loss by (1 - effectiveness), the rule ALE-after already uses, with the same draws. The model is unchanged; SimulatePortfolio shares its sampling loop and returns the same band. - treatments: each risk plan with a cost and a measurable reduction, its annual loss avoided and payback in months, shortest first. - portfolio_rosi_3y and treated_risks for the KPI tiles. - risk_appetite_xaf, read through an optional port (stored next commit). An empty register returns no curve and no ROSI instead of zeros. Signed-off-by: alex-dembele --- .../application/risk/financial_summary.go | 86 +++++++++++++- .../risk/financial_summary_exceedance_test.go | 109 ++++++++++++++++++ backend/pkg/crq/montecarlo.go | 53 +++++++-- backend/pkg/crq/montecarlo_test.go | 43 +++++++ 4 files changed, 282 insertions(+), 9 deletions(-) create mode 100644 backend/internal/application/risk/financial_summary_exceedance_test.go 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/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) + } + } +} From adf32c4d42076610c65d981ff42d98b1147ddcc4 Mon Sep 17 00:00:00 2001 From: alex-dembele Date: Fri, 9 Oct 2026 08:08:18 +0100 Subject: [PATCH 2/4] feat(crq): store the tenant's risk appetite (#904) The appetite slider on the loss-exceedance curve was a local value in the mockup. It is the board's accepted annual loss, so it is now a tenant setting: PUT /analytics/financial/appetite (admin, XAF) stores it in the organization settings beside the display currency, and the financial summary returns it. No migration: settings is jsonb. The use case rejects zero, negative, non-finite and absurd amounts, and a session without an organization; an unknown organization is not found. Tests cover the use case, the handler and a round trip on the organization table that keeps the currency and leaves other organizations alone. OpenAPI updated with the new fields of the summary (ale_after from #903 was missing too) and the TS types regenerated. Signed-off-by: alex-dembele --- backend/cmd/server/main.go | 9 +- .../application/risk/set_risk_appetite.go | 51 +++++++++ .../risk/set_risk_appetite_test.go | 64 +++++++++++ .../handler/risk_appetite_handler_test.go | 100 +++++++++++++++++ .../handler/risk_financial_handler.go | 36 +++++- .../repository/onboarding_profile_writes.go | 40 +++++++ .../repository/org_risk_appetite_test.go | 71 ++++++++++++ docs/openapi.yaml | 84 ++++++++++++++ frontend/src/types/openapi.generated.ts | 104 ++++++++++++++++++ 9 files changed, 556 insertions(+), 3 deletions(-) create mode 100644 backend/internal/application/risk/set_risk_appetite.go create mode 100644 backend/internal/application/risk/set_risk_appetite_test.go create mode 100644 backend/internal/handler/risk_appetite_handler_test.go create mode 100644 backend/internal/infrastructure/repository/org_risk_appetite_test.go 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/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/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/types/openapi.generated.ts b/frontend/src/types/openapi.generated.ts index 7d57b6cc..a23b93c2 100644 --- a/frontend/src/types/openapi.generated.ts +++ b/frontend/src/types/openapi.generated.ts @@ -367,6 +367,26 @@ export interface paths { patch?: never; trace?: never; }; + "/analytics/financial/appetite": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * 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. + */ + put: operations["setRiskAppetite"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/risks/{id}/transfer-owner": { parameters: { query?: never; @@ -2135,9 +2155,26 @@ export interface components { criticality: string; ale: components["schemas"]["Money"]; ale_worst: components["schemas"]["Money"]; + /** @description Exposure targeted once the risk's treatment plan is in place. */ + ale_after?: components["schemas"]["Money"]; rosi: number; rosi_computable: boolean; }; + /** @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. */ + LossExceedance: { + inherent: number[]; + residual: number[]; + }; + /** @description One risk's treatment plan as an investment. */ + TreatmentPayback: { + /** Format: uuid */ + risk_id: string; + title: string; + cost: components["schemas"]["Money"]; + /** @description Annual loss avoided. */ + reduction: components["schemas"]["Money"]; + payback_months: number; + }; /** @description Tenant-wide financial posture for the CFO/CISO dashboard. */ FinancialSummary: { currency: string; @@ -2153,6 +2190,17 @@ export interface components { portfolio_rosi_computable: boolean; by_criticality: components["schemas"]["CriticalityBucket"][]; top_risks: components["schemas"]["TopRiskFinancial"][]; + /** @description (3 × annual reduction − plan cost) / plan cost. */ + portfolio_rosi_3y?: number; + portfolio_rosi_3y_computable?: boolean; + /** @description Risks whose treatment plan has a cost. */ + treated_risks?: number; + /** @description Null when the register is empty. */ + loss_exceedance?: components["schemas"]["LossExceedance"] | null; + /** @description Treatment plans with a measurable reduction, shortest payback first. */ + treatments?: components["schemas"]["TreatmentPayback"][]; + /** @description Annual loss the board accepts (XAF); null until set. */ + risk_appetite_xaf?: number | null; }; LoginInput: { /** @@ -4642,6 +4690,62 @@ export interface operations { }; }; }; + setRiskAppetite: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + appetite_xaf: number; + }; + }; + }; + responses: { + /** @description Appetite stored (rounded to the unit) */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + appetite_xaf: number; + }; + }; + }; + /** @description Not a positive amount */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not an administrator, or no organization in the session */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Organization not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; transferRiskOwner: { parameters: { query?: never; From 79519b00a0b69d3265a31eb50f90d6db57f61fe3 Mon Sep 17 00:00:00 2001 From: alex-dembele Date: Fri, 9 Oct 2026 08:18:02 +0100 Subject: [PATCH 3/4] feat(financial): rebuild the financial page on the October 2026 redesign (#904) The register in money, laid out as the mockup: inherent ALE, targeted residual ALE, plan cost and 3-year ROSI; the loss-exceedance curve, inherent and after plan, with both exceedance probabilities read at the risk appetite; exposure by risk with its reduction bar; the treatment plans ranked by payback, each opening the risk's mitigations. The curve is drawn from the summary's percentiles and names both axes, which the mockup left out. The appetite slider saves the tenant's appetite after the pointer rests (optimistic, rolled back on refusal, no retry); members without admin rights see it read-only, and an unset appetite says so. The risk column of the exposure table gets the room the mockup's wrapped titles lacked. Kept from the previous page outside the mockup's layout: the methodology opens from the subtitle, the display currency sits in the header for admins, and the investment simulator follows the design's blocks. All copy moves to fr/en locale keys; the dialog uses the design-system modal. Signed-off-by: alex-dembele --- .../features/financial/FinancialDashboard.tsx | 1265 ++++------------- .../features/financial/LossExceedanceCard.tsx | 257 ++++ .../features/financial/MethodologyModal.tsx | 112 ++ .../src/features/financial/SimulatorCard.tsx | 272 ++++ .../__tests__/financialPage.test.tsx | 274 ++++ .../features/financial/__tests__/lec.test.ts | 56 + .../features/financial/financialService.ts | 35 + frontend/src/features/financial/lec.ts | 76 + .../src/features/financial/methodology.ts | 47 + .../src/features/financial/useFinancial.ts | 33 +- frontend/src/locales/en.json | 103 ++ frontend/src/locales/fr.json | 103 ++ 12 files changed, 1649 insertions(+), 984 deletions(-) create mode 100644 frontend/src/features/financial/LossExceedanceCard.tsx create mode 100644 frontend/src/features/financial/MethodologyModal.tsx create mode 100644 frontend/src/features/financial/SimulatorCard.tsx create mode 100644 frontend/src/features/financial/__tests__/financialPage.test.tsx create mode 100644 frontend/src/features/financial/__tests__/lec.test.ts create mode 100644 frontend/src/features/financial/lec.ts create mode 100644 frontend/src/features/financial/methodology.ts 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)), + })} + +