A bare Decimal rate is ambiguous between the fraction 0,0341 and the percentage
3,41, and nothing in the type system separated them. from_fraction and
from_percentage force the call site to say which one it means.
Migrating the call sites showed the ambiguity was already there: several tests
passed Decimal::new(45, 2), which the old code consumed as the fraction 0,45,
that is 45 %, while it reads naturally as 0,45 %. Those tests exercise capital
and duration rather than the rate, so their outcome is unchanged, but the
reading was a coin flip before and is now explicit.
The type deliberately validates nothing. Comparing buying against investing the
same cash needs rates for expected return and inflation, where a negative value
is meaningful and a 99 % ceiling is nonsense. Range stays a LoanOffer rule,
which is where the domain knows what a plausible loan rate is.
LoanOffer::annual_nominal_rate_in_percent is replaced by InterestRate::
as_percentage, so the conversion stays in immo-core instead of immo-web having
to multiply by 100 itself. It normalises: dividing by 100 and multiplying back
does not restore the original scale, so 3,41 % round-tripped through Display as
"3.4100 %".
This fixes the fraction versus percentage axis only. InterestRate carries no
period, so proportional_monthly_interest_rate still returns a bare Decimal and
naming alone separates annual from monthly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
At high rate and long duration the rounded instalment converges to C*i from
above and lands exactly on the first month's interest, leaving zero principal:
at 99 % the frontier is 294 months for the maximum capital. The schedule would
never terminate.
Checking the first month suffices: once principal is positive the outstanding
capital decreases, so interest decreases and principal grows. The check must
stay after the bounds checks, since it calls monthly_repayment() and that
function's expect() is justified by them.
creating_loan_offer_with_limit_inputs_works asserted that the three maxima
combined were valid, which this invariant makes false. The valid domain is no
longer a box. It is replaced by the corner that actually maximises the
instalment, duration 1, at 1 082 499 999,99 EUR against 82 500 000,00 EUR for
the corner it tested before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>