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…
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.
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 / 12unconditionally. 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
helpKeydocumentation 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:
- Add
helpKeydocumentation 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.
- Align
net_return_percentageswithliability_month's model gate. Only accrue interest forfrenchandrevolvingliabilities that have an active payment plan, matching the projection engine exactly. Set the monthly interest contribution to0for all otherrepayment_modelvalues.
- Align
liabilitiesApproxMonthlyInterestSumwith 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.
- Fix
derive_principal_from_payment_planforfixed_payments. ReplaceΣ nominal installmentswith 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_paymentsIf 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.
- Extract a single
liability_accrues_interest(model, has_active_plan) -> boolhelper (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.
- Update API contract documentation for
GET /v1/liabilities/{id}/scheduleto state thattotal_interest_remainingis"0.0000"forfixed_paymentsliabilities 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-fastRun the API handler tests:
cargo test -p api handlers::liabilities --no-fail-fastConfiguration 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 liabilities2. 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_interesthelper 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 ifapr_percentis read directly outsideliability_interest.rs. - Do not allow TypeScript to reimplement financial logic that the API can compute server-side. The
liabilitiesApproxMonthlyInterestSumfunction 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_planany time the nominal-sum fallback path is taken; alert on that log pattern in production.
Documentation
- Complete the
helpKeyaddition 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.mdand link to it from bothnet_return.rsandledger.ts.