BugInfrastructure

Three Incompatible Interest Bases Between the Net-Return KPI, Liabilities Card, and Projection Engine

The dashboard KPI surface (net_return_percentages), the client-side Liabilities card (liabilitiesApproxMonthlyInterestSum), and the payment-plan principal derivation (derive_principal_from_payment_plan) each use a different — and incorrect — basis for debt cost, while the…

Rootlock SRE Engine 8 min read
Diagnostic brief

At a Glance

The dashboard KPI surface (net_return_percentages), the client-side Liabilities card (liabilitiesApproxMonthlyInterestSum), and the payment-plan principal derivation (derive_principal_from_payment_plan) each use a different — and incorrect — basis for debt cost, while the canonical projection engine (liability_month) correctly accrues interest only for french/revolving liabilities with an active plan.

Severity Not rated
Confidence Medium
Frequency Unknown
Impact See analysis

Summary

The dashboard KPI surface (net_return_percentages), the client-side Liabilities card (liabilitiesApproxMonthlyInterestSum), and the payment-plan principal derivation (derive_principal_from_payment_plan) each use a different — and incorrect — basis for debt cost, while the canonical projection engine (liability_month) correctly accrues interest only for french/revolving liabilities with an active plan. The divergence produces up to 3 percentage-point errors in the published net-return figure, up to €83,000 in phantom interest charges on the summary page, and up to ~€59,686 in overcounted principal when deriving from a payment plan. The correct logic already exists in liability_month; the fix is to align the two stale copies and the derivation function to that same gate.

Root-Cause Analysis

Confirmed Evidence

| Surface | File | Behaviour | |—|—|—| | net_return_percentages | crates/engine/src/net_return.rs | Applies apr_percent to all non-matured liabilities regardless of repayment_model or active-plan status | | liabilitiesApproxMonthlyInterestSum | apps/web/src/lib/ledger.ts | Client-side copy of the same unconditional formula | | derive_principal_from_payment_plan | apps/api/src/handlers/liabilities.rs | Stores principal as Σ nominal installments for fixed_payments; uses present-value-at-TIN only for french | | liability_month | crates/engine/src/projection.rs | Canonical: accrues real interest only for french/revolving with an active payment plan |

Mechanism

net_return_percentages and liabilitiesApproxMonthlyInterestSum

Both functions apply the formula:

annual_interest = principal × (apr_percent / 100)
monthly_interest = annual_interest / 12

unconditionally. For a fixed_payments liability with principal €100,000 and apr_percent = 5 this yields €416.67/month, which is subtracted from net return. However, liability_month accrues zero interest for fixed_payments (no interest-accrual branch exists for that model), so the projection shows €0/month and extinguishes the debt through nominal repayments over ~200 months. The KPI and the projection are looking at fundamentally different loan semantics.

derive_principal_from_payment_plan

For fixed_payments, principal is stored as payment_amount × num_payments (nominal sum), not as the present value of those cash flows discounted at the stated TIN. For a 240-month plan at €1,000/month and TIN 3%, the stored value is €240,000 versus the economically correct present value of ~€180,314 — an overcount of ~€59,686.

Reasonable Inference

The fixed_payments model was implemented as a simple amortisation shortcut that does not accrue interest; the intent was that the payment itself covers both principal and interest implicitly. Neither net_return_percentages nor liabilitiesApproxMonthlyInterestSum was updated when liability_month introduced the model-gated accrual logic, leaving two stale copies with conflicting semantics.

Alternative / Contributing Causes

  • Lack of a shared, single-source-of-truth function for "does this liability accrue interest?" has allowed duplicated logic to diverge silently.
  • The absence of helpKey documentation on the four Liability KPIs means end users cannot detect the inconsistency without reading source code.

Resolution Steps

Address issues in this order to avoid a regression window:

  1. Add helpKey documentation to the four Liability KPIs (Option 2 in the issue) before changing any numbers. This closes the observability gap and gives users a basis for understanding the upcoming figure change. No numerical regression is introduced.
  1. Align net_return_percentages with liability_month's model gate. Only accrue interest for french and revolving liabilities that have an active payment plan, matching the projection engine exactly. Set the monthly interest contribution to 0 for all other repayment_model values.
  1. Align liabilitiesApproxMonthlyInterestSum with the same gate. Apply identical filtering logic in the client-side function. Consider extracting a shared utility or calling the server-side computed value rather than reimplementing the formula in TypeScript.
  1. Fix derive_principal_from_payment_plan for fixed_payments. Replace Σ nominal installments with the present value of the payment stream discounted at TIN:
   PV = payment × [1 − (1 + r)^−n] / r
   where r = TIN / 12 (monthly rate), n = num_payments

If TIN is not available on a fixed_payments plan (because that model historically did not require it), gate the present-value calculation on TIN being present and non-zero; fall back to the nominal sum only when TIN is explicitly absent, and emit a deprecation warning.

  1. Extract a single liability_accrues_interest(model, has_active_plan) -> bool helper (or equivalent) in a shared crate/module that all three call sites import. This is the structural fix that prevents the three surfaces from diverging again.
  1. Update API contract documentation for GET /v1/liabilities/{id}/schedule to state that total_interest_remaining is "0.0000" for fixed_payments liabilities by design (assuming step 4 does not change this).

CLI Commands

Locate all sites that read apr_percent for interest accrual to find any additional undiscovered copies:

# Rust side
grep -rn "apr_percent" crates/ apps/api/src/ --include="*.rs" | grep -v "_test\|#\[cfg(test"

# TypeScript side
grep -rn "apr_percent\|aprPercent\|monthlyInterest" apps/web/src/ --include="*.ts" --include="*.tsx"

Verify the present-value formula against a known loan:

# Python one-liner: PV of 240 payments of €1000 at TIN 3% annually (0.25%/month)
python3 -c "
r = 0.03 / 12
n = 240
pmt = 1000
pv = pmt * (1 - (1 + r)**-n) / r
print(f'PV = {pv:.4f}')
# Expected: ~180314
"

Run the engine test suite targeting net-return and projection modules:

cargo test -p engine net_return --no-fail-fast
cargo test -p engine projection::liability_month --no-fail-fast

Run the API handler tests:

cargo test -p api handlers::liabilities --no-fail-fast

Configuration Snippets

No configuration file changes are required. The fix is entirely in application logic.

The shared helper (Rust example):

// crates/engine/src/liability_interest.rs  (new file)

#[derive(Debug, PartialEq)]
pub enum RepaymentModel {
    French,
    Revolving,
    FixedPayments,
    InterestOnly,
    // extend as Cube-4 / Q6 finalises the catalogue
}

/// Returns true when the liability accrues interest that should be
/// reflected in net-return calculations and the projection engine.
/// This is the single source of truth consulted by:
///   - net_return_percentages
///   - liability_month
///   - liabilitiesApproxMonthlyInterestSum (via API response)
pub fn liability_accrues_interest(model: &RepaymentModel, has_active_plan: bool) -> bool {
    match model {
        RepaymentModel::French | RepaymentModel::Revolving => has_active_plan,
        RepaymentModel::FixedPayments | RepaymentModel::InterestOnly => false,
    }
}

Present-value derivation patch (Rust, apps/api/src/handlers/liabilities.rs):

fn present_value_of_annuity(payment: Decimal, monthly_rate: Decimal, n: u32) -> Decimal {
    if monthly_rate.is_zero() {
        return payment * Decimal::from(n);
    }
    let one = Decimal::ONE;
    let factor = (one + monthly_rate).powi(-(n as i64));
    payment * (one - factor) / monthly_rate
}

// In derive_principal_from_payment_plan, replace the fixed_payments branch:
RepaymentModel::FixedPayments => {
    match plan.tin_annual_percent {
        Some(tin) if !tin.is_zero() => {
            let r = tin / Decimal::from(1200); // monthly rate
            present_value_of_annuity(plan.payment_amount, r, plan.num_payments)
        }
        _ => {
            // TIN unavailable: fall back to nominal sum and warn
            tracing::warn!(
                liability_id = %plan.liability_id,
                "derive_principal: TIN not set for fixed_payments plan; \
                 using nominal sum — result will be overstated"
            );
            plan.payment_amount * Decimal::from(plan.num_payments)
        }
    }
}

Verification

After deploying the changes, verify each previously failing invariant:

1. KPI and projection interest agree for fixed_payments

# Call the API for the synthetic liability (principal 100k, apr 5%, fixed_payments)
curl -s https://<HOST>/v1/liabilities/<LIABILITY_ID>/schedule | jq .total_interest_remaining
# Expected: "0.0000"  (unchanged — fixed_payments accrues no interest)

curl -s https://<HOST>/v1/dashboard/summary | jq .net_return_nominal_pct
# Expected: reflects 0 debt-interest drag from fixed_payments liabilities

2. Approx monthly interest card shows €0 for fixed_payments

In the Liabilities UI, the "Approx Monthly Interest" card for a pure fixed_payments liability must read €0.00, not principal × apr / 12.

3. Principal derivation for the 240-month / 3% / €1,000 plan

curl -s -X POST https://<HOST>/v1/liabilities/<LIABILITY_ID>/derive-principal \
  -H 'Content-Type: application/json' \
  -d '{"payment_amount": 1000, "num_payments": 240, "tin_annual_percent": 3}' \
  | jq .principal
# Expected: approximately "180314.0000" (±1 cent rounding)
# Previous (broken) value was "240000.0000"

4. Net-return figure for the 300k asset / 100k liability scenario

Asset: €300,000 @ 4% → annual return €12,000
Liability (fixed_payments, 0 interest accrual after fix): €0/year drag
Expected net_return_nominal_pct ≈ 4.00%   (was incorrectly shown as 3.50%)

Rollback indicator: If the net-return figure drops further than expected or projection extinction months change dramatically, the liability_accrues_interest helper may have been applied with inverted logic. Revert the engine crate deploy and re-examine the boolean gate.

Prevention

Structural

  • Enforce the single liability_accrues_interest helper at the crate boundary. Add a compile-time or linting rule (e.g., a Clippy custom lint or a #[deprecated] shim) that fails the build if apr_percent is read directly outside liability_interest.rs.
  • Do not allow TypeScript to reimplement financial logic that the API can compute server-side. The liabilitiesApproxMonthlyInterestSum function should be replaced by a field returned from the API, not recalculated on the client.

Testing

Add golden-path integration tests that assert, for each repayment_model value, that net_return_percentages, liabilitiesApproxMonthlyInterestSum, and one full projection cycle all agree on the monthly interest contribution:

#[test]
fn net_return_and_projection_agree_for_fixed_payments() {
    // synthetic: 100k principal, apr 5%, fixed_payments
    let monthly_net_return_drag = compute_monthly_interest_drag(&liability);
    let monthly_projection_accrual = liability_month(&liability, &plan, month_0);
    assert_eq!(monthly_net_return_drag, monthly_projection_accrual.interest);
}

Monitoring and Alerting

  • Add a server-side consistency check (run nightly or on every liability write) that asserts abs(net_return_monthly_interest_drag − projection_month_1_interest) < ε per liability. Alert if any liability violates this invariant.
  • Emit a structured log line from derive_principal_from_payment_plan any time the nominal-sum fallback path is taken; alert on that log pattern in production.

Documentation

  • Complete the helpKey addition for all four Liability KPIs (resolution step 1) and wire them to in-app tooltips before the next public release, so end users and QA engineers can independently detect future semantic drift.
  • Record the canonical interest-accrual contract in the engine crate's README.md and link to it from both net_return.rs and ledger.ts.
Developer FirstBuilt for engineers solving real problems
Evidence DrivenTechnical claims tied to available evidence
Automation ReadyStructured for CLI, APIs, and workflows
Privacy FocusedNo unnecessary data collection in this article UI
STAY AHEAD OF ISSUES

Get new root-cause analyses in your inbox

Engineering-focused updates. No fake subscriber counts. Unsubscribe anytime.