Commit graph

12 commits

Author SHA1 Message Date
Marcel Enguehard
8d00f6a936 Add LoanOffer::monthly_repayment
Closed form of the constant-instalment recurrence, C*i / (1 - (1+i)^-n), with
i the taux mensuel proportionnel (taux nominal / 12) rather than the taux
actuariel equivalent, which is what French lenders use for the schedule.

A zero rate is a real case, not a degenerate one: the PTZ is a 0 % loan and the
denominator collapses to zero, which rust_decimal's Div panics on rather than
erroring. The zero branch computes C/n, the limit of the formula as i tends to
zero, and offre_c_ptz covers it.

Both known-answer tests compare against generated fixtures whose values came
from a real amortisation table, not from this implementation.

The expect() is justified by the bounds from the previous commit: capital and
rate cap the value well below i64::MAX cents, and rescale(2) makes the scale
exact, so both EurosError arms are unreachable. CLAUDE.md is amended in the
same commit to permit expect() only in this provable case, since the rule
change exists for this call site.

Requires rust_decimal's maths feature for powu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 16:26:55 +02:00
Marcel Enguehard
a66e13d5db Bound LoanOffer inputs so the arithmetic stays in range
An offer with no upper bound on capital, rate or duration can be constructed
and then overflow downstream. Capping all three at construction turns those
overflows into a typed error at the boundary instead of a panic in the middle
of a computation.

The bounds are structural sanity limits chosen to leave several orders of
magnitude of headroom, not domain figures. In particular MAX_ANNUAL_NOMINAL_RATE
is not the taux d'usure, which applies to the TAEG, is published quarterly by
the Banque de France, and belongs in a separate business rule with its own
source and date.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 15:37:10 +02:00
Marcel Enguehard
bf5c463e93 Add generated loan offer fixtures for known-answer tests
Four reference offers with their full amortisation schedules, 960 instalment
lines in total, emitted by scripts/loan_schedule.py --rust. The generator was
verified against a real 2020 Caisse d'Epargne amortisation table and
cross-checked against the ANIL simulator, so the expected values come from
outside this crate rather than from its own output.

Do not hand-edit loan_offer_fixture.rs; regenerate it.

The module is #[cfg(test)] so the fixtures never reach the wasm build. Nothing
consumes them yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 15:18:29 +02:00
Marcel Enguehard
70086389f6 Add impl Add for Euros
Needed by the input-bound tests in the next commit, which construct an amount
one cent above the maximum.

Deliberately untested and deliberately not checked: this addition wraps in
release builds. That is acceptable while the only caller is a test, but it has
to be settled before the amortisation schedule starts summing instalments,
since a wrapped total is a wrong number that looks plausible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 15:18:08 +02:00
Marcel Enguehard
3ab4a4064a Add Euros::from_euros_as_decimal with a typed error
Converting a Decimal amount into integer cents can fail two ways: the value
carries sub-cent precision, or it does not fit in an i64 once scaled. Both are
reported through EurosError rather than silently truncating, since a money type
that rounds without being asked is the wrong kind of convenient.

The scale check demands exactly 2 rather than at most 2, so callers have to be
explicit about the precision they are handing over.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 15:17:47 +02:00
Marcel Enguehard
0cf311bdf4 Store the annual nominal rate as a fraction, not a percentage
The field was called annual_rate_in_percent but nothing multiplied it by 100,
so a 3,41 % loan would have been stored as 3.41 and read as 341 %. Rename it
to annual_nominal_rate, which is what it holds, and add an explicit
annual_nominal_rate_in_percent() accessor for callers that want the display
form. Drop the trailing % from the error message, which was making the same
claim.

The nominal qualifier is there because TAEG will join this struct later and
the two must never be confused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 15:17:15 +02:00
Marcel Enguehard
4506231f02 Rename Euros::from_cents to from_cents_as_i64
The constructor takes a raw i64 count of cents. Naming the unit at the call
site keeps it distinct from the Decimal-based constructor added next, where
the argument is an amount in euros rather than a cent count.

Also fixes a typo in a test name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 15:16:37 +02:00
Marcel Enguehard
91f96e7b57 Add LoanOffer with validating constructor
LoanOffer::new is the only way in: fields are private and the constructor
rejects a negative rate, a zero duration, and a non-positive capital. A zero
rate is accepted on purpose — the PTZ (prêt à taux zéro) is a real French
instrument.

Add thiserror for the typed error, as required for immo-core. Rate and
duration take no upper bound for now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 15:09:49 +02:00
Marcel Enguehard
f3d428dcbe Add Display for Euros
Renders as "123.45 €" — dot separator and a plain space, i.e. American
style. French formatting (narrow no-break space, comma separator) will need
a separate path since Display takes no parameters; deferred until there is
a page to render.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 15:09:34 +02:00
Marcel Enguehard
d812434827 Add zero known-answer test for Euros
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:26:04 +02:00
Marcel Enguehard
68a9088ec4 Add Euros money type backed by integer cents
Store amounts as i64 cents rather than a Decimal, so the "scale is 2"
invariant is structural instead of maintained by hand. Both accessors are
total: as_cents is a field read, as_decimal derives the Decimal on demand.
i64 rather than u64 because differences between amounts can be negative.

Add rust_decimal, verified to build for wasm32-unknown-unknown with default
features. Allow clippy::inconsistent_digit_grouping in immo-core so cent
literals can be grouped as euros-then-cents.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:22:41 +02:00
Marcel Enguehard
e5400c8992 Set up cargo workspace backbone
Two-crate workspace: immo-core for pure financial logic (wasm-compatible,
no async/IO deps) and immo-web for HTTP and rendering. Both crates are
still cargo-new skeletons; no domain code yet.

Pins the toolchain to 1.97 with rustfmt, clippy and the
wasm32-unknown-unknown target so the core crate's wasm constraint is
checkable locally. Cargo.lock is committed since the workspace ships a
binary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 20:30:55 +02:00