From 8bf701e0108cf71c292a95a5cb0d0e1ff6e04c6c Mon Sep 17 00:00:00 2001 From: Marcel Enguehard Date: Tue, 25 Aug 2026 13:40:51 +0200 Subject: [PATCH] Expose the amortising base in the loan offer fixtures The fixture struct published borrowed_capital and total_duration_in_months, but monthly_repayment is not a function of those two. It is a function of the capital the amortisation actually runs on and the number of amortising instalments, and both differ from the published fields as soon as there is a deferral. On offre B the six months of total deferral capitalise 439 000 EUR into 444 405,27 EUR, amortised over 294 instalments rather than 300. Feeding the formula with the fields the fixture exposed gave 1 958,39 EUR against the table's 2 011,86 EUR. The two deferred fixtures were therefore unusable, and misleading about it: a 53 EUR monthly gap reads like a rounding bug in the implementation rather than a missing input. The generator already computed both values and wrote them to the JSON output. Only render_rust dropped them, so the Rust fixtures were strictly less informative than the JSON they were generated alongside. All four fixtures now reproduce their reference instalment to the cent when fed amortised_capital and amortisation_in_months, including the two deferred offers that could not be tested at all before. Co-Authored-By: Claude Opus 5 --- .../src/fixtures/loan_offer_fixture.rs | 22 +++++++++++++++++++ scripts/loan_schedule.py | 16 ++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/crates/immo-core/src/fixtures/loan_offer_fixture.rs b/crates/immo-core/src/fixtures/loan_offer_fixture.rs index 33f4bca..9b8db7b 100644 --- a/crates/immo-core/src/fixtures/loan_offer_fixture.rs +++ b/crates/immo-core/src/fixtures/loan_offer_fixture.rs @@ -40,6 +40,20 @@ pub struct LoanOfferFixture { pub annual_nominal_rate: InterestRate, pub total_duration_in_months: u64, pub deferral_in_months: u64, + /// Capital sur lequel court l'amortissement, après capitalisation des + /// intérêts du différé. Égal à `borrowed_capital` hors différé total. + /// + /// C'est **ce** capital, et non `borrowed_capital`, qui alimente la formule + /// de mensualité. Sur l'offre B, le différé total porte les 439 000 EUR + /// empruntés à 444 405,27 EUR amortissables. + pub amortised_capital: Euros, + /// Nombre d'échéances amortissantes, différé exclu. C'est le `n` de la + /// formule de mensualité, à ne pas confondre avec `total_duration_in_months`. + /// + /// Alimenter la formule avec `borrowed_capital` et `total_duration_in_months` + /// sur l'offre B donne 1 958,39 EUR au lieu des 2 011,86 EUR du tableau : + /// l'écart est un cumul des deux confusions, pas une dérive d'arrondi. + pub amortisation_in_months: u64, pub monthly_repayment: Euros, pub total_credit_cost: Euros, pub total_paid: Euros, @@ -5826,6 +5840,8 @@ pub fn offre_a() -> LoanOfferFixture { annual_nominal_rate: InterestRate::from_fraction(Decimal::new(341, 4)), total_duration_in_months: 240, deferral_in_months: 0, + amortised_capital: Euros::from_cents_as_i64(150_000_00), + amortisation_in_months: 240, monthly_repayment: Euros::from_cents_as_i64(863_02), total_credit_cost: Euros::from_cents_as_i64(57_124_30), total_paid: Euros::from_cents_as_i64(207_124_30), @@ -5841,6 +5857,8 @@ pub fn offre_b_differe_total() -> LoanOfferFixture { annual_nominal_rate: InterestRate::from_fraction(Decimal::new(245, 4)), total_duration_in_months: 300, deferral_in_months: 6, + amortised_capital: Euros::from_cents_as_i64(444_405_27), + amortisation_in_months: 294, monthly_repayment: Euros::from_cents_as_i64(2_011_86), total_credit_cost: Euros::from_cents_as_i64(152_486_18), total_paid: Euros::from_cents_as_i64(591_486_18), @@ -5856,6 +5874,8 @@ pub fn offre_b_differe_partiel() -> LoanOfferFixture { annual_nominal_rate: InterestRate::from_fraction(Decimal::new(245, 4)), total_duration_in_months: 300, deferral_in_months: 6, + amortised_capital: Euros::from_cents_as_i64(439_000_00), + amortisation_in_months: 294, monthly_repayment: Euros::from_cents_as_i64(1_987_39), total_credit_cost: Euros::from_cents_as_i64(150_669_69), total_paid: Euros::from_cents_as_i64(589_669_69), @@ -5871,6 +5891,8 @@ pub fn offre_c_ptz() -> LoanOfferFixture { annual_nominal_rate: InterestRate::from_fraction(Decimal::new(0, 0)), total_duration_in_months: 120, deferral_in_months: 0, + amortised_capital: Euros::from_cents_as_i64(95_000_00), + amortisation_in_months: 120, monthly_repayment: Euros::from_cents_as_i64(791_67), total_credit_cost: Euros::from_cents_as_i64(0_00), total_paid: Euros::from_cents_as_i64(95_000_00), diff --git a/scripts/loan_schedule.py b/scripts/loan_schedule.py index b3754cf..389ebc3 100644 --- a/scripts/loan_schedule.py +++ b/scripts/loan_schedule.py @@ -575,6 +575,20 @@ pub struct LoanOfferFixture {{ pub annual_nominal_rate: InterestRate, pub total_duration_in_months: u64, pub deferral_in_months: u64, + /// Capital sur lequel court l'amortissement, après capitalisation des + /// intérêts du différé. Égal à `borrowed_capital` hors différé total. + /// + /// C'est **ce** capital, et non `borrowed_capital`, qui alimente la formule + /// de mensualité. Sur l'offre B, le différé total porte les 439 000 EUR + /// empruntés à 444 405,27 EUR amortissables. + pub amortised_capital: Euros, + /// Nombre d'échéances amortissantes, différé exclu. C'est le `n` de la + /// formule de mensualité, à ne pas confondre avec `total_duration_in_months`. + /// + /// Alimenter la formule avec `borrowed_capital` et `total_duration_in_months` + /// sur l'offre B donne 1 958,39 EUR au lieu des 2 011,86 EUR du tableau : + /// l'écart est un cumul des deux confusions, pas une dérive d'arrondi. + pub amortisation_in_months: u64, pub monthly_repayment: Euros, pub total_credit_cost: Euros, pub total_paid: Euros, @@ -657,6 +671,8 @@ def render_rust(offers: list[LoanOffer], path: str) -> str: f" annual_nominal_rate: {rust_interest_rate(offer.annual_nominal_rate)},\n" f" total_duration_in_months: {offer.total_duration_in_months},\n" f" deferral_in_months: {offer.deferral_in_months},\n" + f" amortised_capital: {rust_euros(offer.amortised_capital)},\n" + f" amortisation_in_months: {offer.amortisation_in_months},\n" f" monthly_repayment: {rust_euros(offer.monthly_repayment)},\n" f" total_credit_cost: {rust_euros(offer.total_credit_cost(rows))},\n" f" total_paid: {rust_euros(offer.total_paid(rows))},\n"