La fiche du lot 15 annonçait « dernier loyer connu, arrêté après févr. 26 » alors qu'un locataire y est entré le 8 juillet — et la courbe juste dessous, où la barre de juillet est bien là, la contredisait. Le loyer en vigueur se lit sur les paliers, et un mois facturé au seul prorata n'en ouvre pas : celui d'avant s'arrête donc à la dernière mensualité pleine, quatre mois avant le nouveau bail. Ne rien lire après lui confond deux états que le compte rendu sépare : un lot qui n'est plus loué, et un lot entre deux locataires. La distinction tient à la frontière qui sert déjà à séparer loyer plein et prorata, la période portée par le compte rendu : - un prorata qui finit le dernier jour du mois sans partir du premier facture la fin du mois : quelqu'un est entré, le bail court encore après ; - un prorata qui part du premier sans l'atteindre facture le début : le bail s'arrête là ; - un montant négatif ne fait ni l'un ni l'autre. Un avoir porte parfois la période exacte de la ligne qu'il annule, et le lire comme une entrée inventerait une relocation là où rien n'a été loué. L'entrée l'emporte sur la sortie parce qu'elle vient après : ce lot voit son locataire partir le 10 mars et le suivant arriver le 8 juillet, et c'est le second qui décrit son état. Le montant affiché ne bouge pas — le nouveau bail n'a été facturé qu'au prorata, et en tirer un loyer mensuel l'inventerait. Seul ce qu'on en dit change : « dernier loyer plein · nouveau bail depuis juil. 26 ». Sans relocation, la sortie date enfin la fin de la location au mois où elle a lieu : « arrêté en cours de mars 26 » plutôt qu'« arrêté après févr. 26 », qui l'avançait d'un mois. Sur le parc : 31 lots loués, 1 relouté, 1 réellement arrêté. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
305 lines
11 KiB
Python
305 lines
11 KiB
Python
"""Tests de la mise en temps du loyer.
|
|
|
|
Ce que ces tests protègent, c'est la lecture d'une courbe : un mois vacant, un
|
|
mois de changement de locataire et un mois à loyer plein doivent rester trois
|
|
choses différentes. Les cas ne sont pas inventés — ils viennent tous du parc
|
|
réel (bail commercial trimestriel, relocation en cours de mois, avoir annulant
|
|
un loyer, régularisation rétroactive à cheval sur six mois).
|
|
"""
|
|
|
|
from datetime import date
|
|
|
|
import pytest
|
|
|
|
from plesna_gerance.services.loyers import (
|
|
loyer_au_m2,
|
|
mediane,
|
|
mois_entiers,
|
|
mois_suivant,
|
|
paliers,
|
|
repartir,
|
|
variation,
|
|
)
|
|
|
|
|
|
def ligne(debut: str, fin: str, loyers: float, provisions: float = 0.0):
|
|
"""Une ligne de loyer telle que la base la porte."""
|
|
return (date.fromisoformat(debut), date.fromisoformat(fin), loyers, provisions)
|
|
|
|
|
|
class TestMoisEntiers:
|
|
"""Ce qui fait qu'une période porte un loyer plutôt qu'un prorata."""
|
|
|
|
def test_un_mois_plein_couvre_son_mois(self):
|
|
assert mois_entiers(date(2026, 3, 1), date(2026, 3, 31)) == ["2026-03"]
|
|
|
|
def test_fevrier_se_termine_le_28_ou_le_29(self):
|
|
assert mois_entiers(date(2025, 2, 1), date(2025, 2, 28)) == ["2025-02"]
|
|
assert mois_entiers(date(2024, 2, 1), date(2024, 2, 29)) == ["2024-02"]
|
|
|
|
def test_un_trimestre_couvre_ses_trois_mois(self):
|
|
assert mois_entiers(date(2025, 10, 1), date(2025, 12, 31)) == [
|
|
"2025-10",
|
|
"2025-11",
|
|
"2025-12",
|
|
]
|
|
|
|
def test_une_periode_partielle_ne_couvre_aucun_mois(self):
|
|
assert mois_entiers(date(2024, 6, 22), date(2024, 6, 30)) == []
|
|
assert mois_entiers(date(2025, 3, 14), date(2025, 8, 31)) == []
|
|
|
|
def test_une_periode_absente_ou_inversee_ne_couvre_rien(self):
|
|
assert mois_entiers(None, date(2026, 3, 31)) == []
|
|
assert mois_entiers(date(2026, 3, 31), date(2026, 3, 1)) == []
|
|
|
|
def test_le_passage_a_l_annee_suivante(self):
|
|
assert mois_suivant("2025-12") == "2026-01"
|
|
|
|
|
|
class TestRepartition:
|
|
"""Comment les lignes se rangent sur l'axe des mois."""
|
|
|
|
def test_un_loyer_mensuel_va_dans_son_mois(self):
|
|
serie = repartir([ligne("2026-03-01", "2026-03-31", 640.0, 31.0)])
|
|
|
|
assert [point.mois for point in serie.mois] == ["2026-03"]
|
|
assert serie.mois[0].loyer == 640.0
|
|
assert serie.mois[0].charges == 31.0
|
|
assert serie.mois[0].reparti is False
|
|
|
|
def test_un_bail_trimestriel_se_repartit_sur_ses_mois(self):
|
|
"""Sans répartition, deux mois sur trois d'un bail commercial seraient
|
|
montrés vides alors que le local est loué."""
|
|
serie = repartir([ligne("2025-10-01", "2025-12-31", 3963.27)])
|
|
|
|
assert [point.mois for point in serie.mois] == [
|
|
"2025-10",
|
|
"2025-11",
|
|
"2025-12",
|
|
]
|
|
assert [point.loyer for point in serie.mois] == [1321.09, 1321.09, 1321.09]
|
|
assert all(point.reparti for point in serie.mois)
|
|
|
|
def test_un_mois_sans_ligne_reste_vide_et_non_a_zero(self):
|
|
"""Un loyer à zéro se lirait comme un logement prêté ; le trou dit la
|
|
vacance, qui est ce que les comptes rendus montrent."""
|
|
serie = repartir(
|
|
[
|
|
ligne("2024-01-01", "2024-01-31", 850.0),
|
|
ligne("2024-04-01", "2024-04-30", 900.0),
|
|
]
|
|
)
|
|
|
|
assert [point.mois for point in serie.mois] == [
|
|
"2024-01",
|
|
"2024-02",
|
|
"2024-03",
|
|
"2024-04",
|
|
]
|
|
assert [point.loyer for point in serie.mois] == [850.0, None, None, 900.0]
|
|
|
|
def test_un_prorata_se_range_a_part_du_loyer(self):
|
|
"""Le locataire entre le 22 : le mois est facturé, mais pas au prix
|
|
d'un mois plein. Additionner les deux inventerait une baisse de loyer."""
|
|
serie = repartir([ligne("2024-06-22", "2024-06-30", 417.0)])
|
|
|
|
point = serie.mois[0]
|
|
assert point.mois == "2024-06"
|
|
assert point.loyer is None
|
|
assert point.prorata == 417.0
|
|
assert point.en_transition is True
|
|
|
|
def test_un_mois_de_transition_n_est_pas_une_vacance(self):
|
|
"""Sortie le 3, entrée le 14 : le mois est loué deux fois en morceaux.
|
|
Le montrer vide le confondrait avec un mois sans locataire."""
|
|
serie = repartir(
|
|
[
|
|
ligne("2025-03-01", "2025-03-03", 109.68),
|
|
ligne("2025-03-14", "2025-03-31", 622.2),
|
|
]
|
|
)
|
|
|
|
point = serie.mois[0]
|
|
assert point.loyer is None
|
|
assert point.prorata == pytest.approx(731.88)
|
|
assert point.en_transition is True
|
|
|
|
def test_un_avoir_ne_deforme_pas_le_loyer_du_mois(self):
|
|
"""Le loyer d'octobre est annulé par un avoir. Le loyer contractuel
|
|
reste 1390 ; l'avoir se lit à côté, sans creuser la courbe."""
|
|
serie = repartir(
|
|
[
|
|
ligne("2024-10-01", "2024-10-31", 1390.0),
|
|
ligne("2024-10-18", "2024-10-18", -1390.0),
|
|
]
|
|
)
|
|
|
|
point = serie.mois[0]
|
|
assert point.loyer == 1390.0
|
|
assert point.prorata == -1390.0
|
|
assert point.en_transition is False
|
|
|
|
def test_un_prorata_qui_finit_le_mois_est_une_entree(self):
|
|
"""Lot 15 du parc : le locataire entre le 8 juillet. La location ne
|
|
s'arrête donc pas au dernier loyer plein, elle recommence."""
|
|
serie = repartir([ligne("2026-07-08", "2026-07-31", 1111.67)])
|
|
|
|
point = serie.mois[0]
|
|
assert point.entree is True
|
|
assert point.sortie is False
|
|
|
|
def test_un_prorata_qui_ouvre_le_mois_est_une_sortie(self):
|
|
"""Même lot, quatre mois plus tôt : le locataire part le 10 mars."""
|
|
serie = repartir([ligne("2026-03-01", "2026-03-10", 482.35)])
|
|
|
|
point = serie.mois[0]
|
|
assert point.sortie is True
|
|
assert point.entree is False
|
|
|
|
def test_un_mois_qui_change_de_locataire_porte_les_deux(self):
|
|
"""Sortie le 3, entrée le 14 : le mois ferme un bail et en ouvre un
|
|
autre, et c'est l'entrée qui dit que le lot reste loué."""
|
|
serie = repartir(
|
|
[
|
|
ligne("2025-03-01", "2025-03-03", 109.68),
|
|
ligne("2025-03-14", "2025-03-31", 622.2),
|
|
]
|
|
)
|
|
|
|
point = serie.mois[0]
|
|
assert point.entree is True
|
|
assert point.sortie is True
|
|
|
|
def test_un_avoir_n_ouvre_ni_ne_ferme_de_bail(self):
|
|
"""Un avoir porte la période qu'il annule : lu comme une entrée, il
|
|
ferait croire à une relocation là où rien n'a été loué."""
|
|
serie = repartir(
|
|
[
|
|
ligne("2024-10-15", "2024-10-31", -700.0),
|
|
ligne("2024-11-18", "2024-11-18", -1390.0),
|
|
]
|
|
)
|
|
|
|
assert [point.entree for point in serie.mois] == [False, False]
|
|
assert [point.sortie for point in serie.mois] == [False, False]
|
|
|
|
def test_un_mois_plein_n_est_ni_entree_ni_sortie(self):
|
|
serie = repartir([ligne("2026-01-01", "2026-01-31", 1447.05)])
|
|
|
|
point = serie.mois[0]
|
|
assert point.entree is False
|
|
assert point.sortie is False
|
|
|
|
def test_une_regularisation_a_cheval_reste_hors_de_la_courbe(self):
|
|
"""De mars à août sans couvrir un mois entier : l'étaler inventerait
|
|
six demi-mois de loyer."""
|
|
serie = repartir(
|
|
[
|
|
ligne("2025-09-01", "2025-09-30", 1023.0),
|
|
ligne("2025-03-14", "2025-08-31", -418.55),
|
|
]
|
|
)
|
|
|
|
assert [point.mois for point in serie.mois] == ["2025-09"]
|
|
assert len(serie.ecartees) == 1
|
|
assert serie.ecartees[0].loyers == -418.55
|
|
|
|
def test_sans_aucune_ligne_la_serie_est_vide(self):
|
|
assert repartir([]).mois == []
|
|
|
|
|
|
class TestPaliers:
|
|
"""Les révisions de loyer, lues dans la suite des mois."""
|
|
|
|
def test_un_loyer_stable_ne_fait_qu_un_palier(self):
|
|
serie = repartir(
|
|
[
|
|
ligne("2026-01-01", "2026-01-31", 900.0),
|
|
ligne("2026-02-01", "2026-02-28", 900.0),
|
|
ligne("2026-03-01", "2026-03-31", 900.0),
|
|
]
|
|
)
|
|
|
|
niveaux = paliers(serie.mois)
|
|
assert len(niveaux) == 1
|
|
assert (niveaux[0].mois_debut, niveaux[0].mois_fin) == ("2026-01", "2026-03")
|
|
|
|
def test_une_revision_ouvre_un_palier(self):
|
|
serie = repartir(
|
|
[
|
|
ligne("2025-12-01", "2025-12-31", 900.0),
|
|
ligne("2026-01-01", "2026-01-31", 907.85),
|
|
ligne("2026-02-01", "2026-02-28", 907.85),
|
|
]
|
|
)
|
|
|
|
niveaux = paliers(serie.mois)
|
|
assert [n.loyer for n in niveaux] == [900.0, 907.85]
|
|
assert niveaux[-1].mois_debut == "2026-01"
|
|
|
|
def test_une_vacance_coupe_le_palier_meme_a_loyer_egal(self):
|
|
"""Relouer au même prix après trois mois vides, c'est un nouveau bail :
|
|
dire que le loyer « n'a pas bougé depuis 2024 » serait faux."""
|
|
serie = repartir(
|
|
[
|
|
ligne("2024-01-01", "2024-01-31", 850.0),
|
|
ligne("2024-05-01", "2024-05-31", 850.0),
|
|
]
|
|
)
|
|
|
|
niveaux = paliers(serie.mois)
|
|
assert len(niveaux) == 2
|
|
assert niveaux[-1].mois_debut == "2024-05"
|
|
|
|
def test_les_centimes_d_un_trimestre_ne_creent_pas_de_faux_paliers(self):
|
|
"""3963,27 divisé par trois ne retombe pas juste ; l'égalité se juge au
|
|
centime, sinon chaque mois ouvrirait son propre palier."""
|
|
serie = repartir(
|
|
[
|
|
ligne("2025-01-01", "2025-03-31", 3963.27),
|
|
ligne("2025-04-01", "2025-06-30", 3963.27),
|
|
]
|
|
)
|
|
|
|
assert len(paliers(serie.mois)) == 1
|
|
|
|
|
|
class TestLoyerAuM2:
|
|
"""Le ratio, et ce qu'il refuse de calculer."""
|
|
|
|
def test_le_ratio_se_calcule_hors_charges(self):
|
|
assert loyer_au_m2(640.0, 47.84) == 13.38
|
|
|
|
def test_sans_surface_il_n_y_a_pas_de_ratio(self):
|
|
"""Zéro serait un loyer au m² nul ; `None` est un trou à combler."""
|
|
assert loyer_au_m2(640.0, None) is None
|
|
|
|
def test_une_surface_absurde_vaut_une_surface_absente(self):
|
|
assert loyer_au_m2(640.0, 0) is None
|
|
assert loyer_au_m2(640.0, -10) is None
|
|
|
|
def test_un_mois_sans_loyer_n_a_pas_de_ratio(self):
|
|
assert loyer_au_m2(None, 47.84) is None
|
|
|
|
|
|
class TestMedianeEtVariation:
|
|
"""Les deux agrégats qui situent un lot."""
|
|
|
|
def test_la_mediane_resiste_a_un_local_commercial(self):
|
|
"""Une moyenne serait tirée par la valeur extrême ; c'est tout
|
|
l'intérêt de la médiane sur un parc de trente lots."""
|
|
assert mediane([11.0, 12.0, 13.0, 14.0, 90.0]) == 13.0
|
|
|
|
def test_la_mediane_d_un_effectif_pair_prend_le_milieu(self):
|
|
assert mediane([10.0, 12.0, 14.0, 16.0]) == 13.0
|
|
|
|
def test_sans_lot_comparable_il_n_y_a_pas_de_mediane(self):
|
|
assert mediane([]) is None
|
|
|
|
def test_la_variation_se_lit_en_pourcentage(self):
|
|
assert variation(1023.0, 1031.06) == 0.79
|
|
|
|
def test_une_variation_sans_reference_n_existe_pas(self):
|
|
assert variation(None, 900.0) is None
|
|
assert variation(0.0, 900.0) is None
|