feat: add lease end page and resolution forms with necessary services and components
- Implemented LeaseEndPage component for managing lease end processes. - Created LeaseEndResolutionForms component for handling agreement and closure submissions. - Added civil date formatting utilities for French date representation. - Developed styles for lease end resolution forms and rent due correction components. - Integrated HTTP service for lease end operations including notice recording, withdrawal acceptance, and closure. - Added unit tests for new components and services to ensure functionality.
This commit is contained in:
1 parent
3d42c9a256
commit
c1e6b905d5
114 files changed
+6582
-345
No files matched your search
+6
-6
@@ -37,15 +37,15 @@ Document requis seulement selon la situation déclarée par le bailleur, notamme
|
||||
_Avoid_: Détection géographique automatique, lien vers un document courant, obligation réglementaire codée comme invariant permanent
|
||||
|
||||
**Bail** (Code: `Lease`):
|
||||
Contrat de location meublée de résidence principale liant le propriétaire à un ou plusieurs locataires (co-signataires) pour un appartement donné, définissant les conditions de la location (loyer, charges, durée). La première version couvre la gestion directe par le bailleur, sans mandataire ni honoraires d'agence, ainsi que le bail d'un an reconductible, le bail étudiant de neuf mois non reconductible et la colocation à bail unique, mais pas le bail mobilité. Le bail ordinaire conserve un anniversaire annuel calculé mais aucune date de fin réelle tant qu'un congé n'est pas enregistré ; le bail étudiant possède une fin fixe calculée après neuf mois. Son brouillon combine une copie des caractéristiques de l'appartement avec les données propres à la location, notamment les parties, dates, montants, règles d'encadrement et de révision du loyer, modalités de paiement, travaux récents et clauses. Un brouillon peut être prévisualisé et supprimé ; après signature de toutes les parties, sa finalisation conserve le document signé, fige les faits contractuels, réserve la période de l'appartement et crée les échéances, tandis qu'un bail finalisé est archivé à sa clôture mais jamais supprimé.
|
||||
Contrat de location meublée de résidence principale liant le propriétaire à un ou plusieurs locataires (co-signataires) pour un appartement donné, définissant les conditions de la location (loyer, charges, durée). La première version couvre la gestion directe par le bailleur, sans mandataire ni honoraires d'agence, ainsi que le bail d'un an reconductible, le bail étudiant de neuf mois non reconductible et la colocation à bail unique, mais pas le bail mobilité. Le bail ordinaire conserve un anniversaire annuel calculé mais aucune date de fin réelle tant qu'un congé ou une fin anticipée convenue n'est pas enregistré ; le bail étudiant possède une fin fixe calculée après neuf mois. Son brouillon combine une copie des caractéristiques de l'appartement avec les données propres à la location, notamment les parties, dates, montants, règles d'encadrement et de révision du loyer, modalités de paiement, travaux récents et clauses. Un brouillon peut être prévisualisé et supprimé ; après signature de toutes les parties, sa finalisation conserve le document signé, fige les faits contractuels, réserve la période de l'appartement et crée les échéances, tandis qu'un bail finalisé est archivé à sa clôture mais jamais supprimé.
|
||||
_Avoid_: Contrat, convention de location
|
||||
|
||||
**Fin contractuelle du bail** (Code: `LeaseContractEnd`):
|
||||
Date à laquelle le bail cesse de donner au locataire un titre d'occupation, qu'elle résulte du terme fixe d'un bail étudiant ou d'un congé. Elle est distincte de la sortie matérielle du logement et de la clôture du dossier par le bailleur.
|
||||
Date à laquelle le bail cesse de donner au locataire un titre d'occupation, qu'elle résulte du terme fixe d'un bail étudiant, d'un congé ou d'une fin anticipée convenue. Elle est distincte de la sortie matérielle du logement et de la clôture du dossier par le bailleur.
|
||||
_Avoid_: Archivage, restitution des clés
|
||||
|
||||
**Congé du locataire** (Code: `TenantNotice`):
|
||||
Notification unilatérale par laquelle le locataire manifeste sa volonté de mettre fin au bail. Son préavis court à compter de la réception effective par le bailleur, indépendamment de la date d'envoi, et sa preuve documentaire peut être conservée sans constituer le congé lui-même.
|
||||
Notification unilatérale par laquelle le locataire manifeste sa volonté de mettre fin au bail. Son préavis court à compter de la réception effective par le bailleur, indépendamment de la date d'envoi, et sa preuve documentaire peut être conservée sans constituer le congé lui-même. Les dates saisies restent des dates civiles sans conversion artificielle en UTC et sont validées selon le fuseau IANA du navigateur ; les horodatages d'audit sont, eux, conservés comme des instants UTC.
|
||||
_Avoid_: Demande de départ, date d'envoi, clôture
|
||||
|
||||
**Justificatif du congé** (Code: `TenantNoticeEvidence`):
|
||||
@@ -61,7 +61,7 @@ Version de la règle juridique utilisée pour calculer la durée minimale et la
|
||||
_Avoid_: Règle courante appliquée rétroactivement, recalcul automatique
|
||||
|
||||
**Fin anticipée convenue** (Code: `AgreedEarlyLeaseEnd`):
|
||||
Accord explicite entre le bailleur et le locataire qui avance la fin contractuelle avant le terme du préavis initialement calculé. Elle conserve une date et un motif sans effacer le congé qui l'a précédée.
|
||||
Accord explicite entre le bailleur et le locataire qui avance la fin contractuelle existante ou, pour un bail ordinaire encore sans terme, en fixe directement une. Il conserve une date et un motif ; lorsqu'un congé existe déjà, celui-ci reste inchangé dans l'historique.
|
||||
_Avoid_: Correction du congé, clôture anticipée, date forcée
|
||||
|
||||
**Rétractation acceptée du congé** (Code: `AcceptedTenantNoticeWithdrawal`):
|
||||
@@ -121,7 +121,7 @@ Conditions mensuelles choisies dans le bail soit sous la forme d'un forfait sans
|
||||
_Avoid_: Charges mensuelles sans mode, dépense du bailleur
|
||||
|
||||
**Révision IRL** (Code: `RentRevisionTerms`):
|
||||
Clause facultative définissant la date annuelle de révision du loyer ainsi que le trimestre, l'année de référence et la série de l'indice. La série INSEE est présélectionnée lors du bail depuis le département de l'appartement entre la métropole hors Corse, la Corse et les territoires ultramarins concernés, puis reste un fait contractuel : l'application de la révision ne la remplace jamais silencieusement depuis l'adresse courante. À l'approche de la date, l'application récupère et met en cache les valeurs officielles de l'INSEE, puis propose le plafond calculé à partir du loyer courant et des indices du même trimestre. L'identifiant de série est affiché et le bailleur peut exceptionnellement le corriger parmi les trois séries prises en charge avec une confirmation auditée. En cas d'indisponibilité de la source officielle et du cache, il peut également saisir les valeurs avec une confirmation auditée. Lorsque le DPE est classé F ou G, l'application informe le bailleur de l'interdiction actuellement applicable sans en faire un invariant permanent : il peut poursuivre après une confirmation explicite, versionnée et auditée.
|
||||
Clause facultative définissant le jour et le mois récurrents de révision du loyer ainsi que le trimestre, l'année de référence et la série de l'indice. La première date de révision est la première occurrence strictement postérieure au début du bail ; un 29 Février devient le 28 Février les années non bissextiles, puis redevient le 29 Février les années bissextiles. La série INSEE est présélectionnée lors du bail depuis le département de l'appartement entre la métropole hors Corse, la Corse et les territoires ultramarins concernés, puis reste un fait contractuel : l'application de la révision ne la remplace jamais silencieusement depuis l'adresse courante. À l'approche de la date, l'application récupère et met en cache les valeurs officielles de l'INSEE, puis propose le plafond calculé à partir du loyer courant et des indices du même trimestre. L'identifiant de série est affiché et le bailleur peut exceptionnellement le corriger parmi les trois séries prises en charge avec une confirmation auditée. En cas d'indisponibilité de la source officielle et du cache, il peut également saisir les valeurs avec une confirmation auditée. Lorsque le DPE est classé F ou G, l'application informe le bailleur de l'interdiction actuellement applicable sans en faire un invariant permanent : il peut poursuivre après une confirmation explicite, versionnée et auditée.
|
||||
_Avoid_: Augmentation silencieuse du loyer, rattrapage rétroactif, interdiction technique permanente
|
||||
|
||||
**Application d'une révision IRL** (Code: `RentRevision`):
|
||||
@@ -149,7 +149,7 @@ Encaissement déjà reçu, donc jamais daté dans le futur selon le fuseau horai
|
||||
_Avoid_: Échéance, mensualité, marquage manuel comme payé
|
||||
|
||||
**Contrepassation de règlement** (Code: `RentPaymentReversal`):
|
||||
Écriture immuable, motivée et consultable qui annule les effets d'un règlement confirmé sans effacer le fait historique ni sa trace d'audit. Ses affectations sont annulées, ses remboursements déjà confirmés restent des sorties historiques immuables, et un éventuel règlement corrigé est enregistré séparément.
|
||||
Écriture immuable, motivée et consultable qui annule les effets d'un règlement confirmé sans effacer le fait historique ni sa trace d'audit. Sa date civile est celle du jour dans le fuseau IANA du navigateur. Ses affectations sont annulées, ses remboursements déjà confirmés restent des sorties historiques immuables, et un éventuel règlement corrigé est enregistré séparément.
|
||||
_Avoid_: Suppression, modification, correction silencieuse
|
||||
|
||||
**Quittance de loyer** (Code: `RentReceipt`):
|
||||
|
||||
+11
-5
@@ -1,11 +1,11 @@
|
||||
package fr.nessar.lmnporganizer.rental.adapter.in.config;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.ActivateLeaseService;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.ArchiveLeaseService;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.GenerateLeaseDocumentService;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.LeaseDraftService;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.LeaseClauseCatalog;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.LeaseFinalizationService;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.LeaseEndingService;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.AmendLeaseService;
|
||||
import fr.nessar.lmnporganizer.rental.application.landlord.LandlordAccountService;
|
||||
import fr.nessar.lmnporganizer.rental.application.apartment.ApartmentCatalogService;
|
||||
@@ -102,10 +102,11 @@ public class RentalApplicationConfiguration {
|
||||
|
||||
@Bean
|
||||
AmendLeaseService amendLeaseService(LeaseRepository leases, TenantRepository tenants,
|
||||
RentDueRepository rentDues, ApartmentOccupancyRepository occupancies,
|
||||
RentDueRepository rentDues, RentRevisionRepository revisions,
|
||||
ApartmentOccupancyRepository occupancies,
|
||||
TransactionRunner transactions, IdentifierGenerator identifiers,
|
||||
Clock rentalClock) {
|
||||
return new AmendLeaseService(leases, tenants, rentDues, occupancies, transactions, identifiers,
|
||||
return new AmendLeaseService(leases, tenants, rentDues, revisions, occupancies, transactions, identifiers,
|
||||
rentalClock);
|
||||
}
|
||||
|
||||
@@ -120,8 +121,13 @@ public class RentalApplicationConfiguration {
|
||||
}
|
||||
|
||||
@Bean
|
||||
ArchiveLeaseService archiveLeaseService(LeaseRepository leases) {
|
||||
return new ArchiveLeaseService(leases);
|
||||
LeaseEndingService leaseEndingService(LeaseRepository leases, ApartmentOccupancyRepository occupancies,
|
||||
RentDueRepository rentDues, RentRevisionRepository revisions,
|
||||
LeaseDocumentStorage storage,
|
||||
TransactionRunner transactions, IdentifierGenerator identifiers,
|
||||
Clock rentalClock, RentalAttachmentProperties properties) {
|
||||
return new LeaseEndingService(leases, occupancies, rentDues, revisions, storage, transactions, identifiers,
|
||||
rentalClock, properties.maxSize().toBytes());
|
||||
}
|
||||
|
||||
@Bean
|
||||
|
||||
+26
-8
@@ -11,6 +11,7 @@ import fr.nessar.lmnporganizer.rental.domain.lease.LeaseContractVersion;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentAnnex;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentWarning;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseLegalTerms;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRuleViolation;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseStatus;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSnapshot;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSpecialClause;
|
||||
@@ -27,8 +28,10 @@ import jakarta.validation.constraints.PositiveOrZero;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.DateTimeException;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.MonthDay;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -67,13 +70,25 @@ final class LeaseApi {
|
||||
}
|
||||
}
|
||||
|
||||
record RentRevisionRequest(Boolean enabled, LocalDate annualRevisionDate,
|
||||
record RentRevisionRequest(Boolean enabled, @Min(1) @Max(31) Integer annualRevisionDay,
|
||||
@Min(1) @Max(12) Integer annualRevisionMonth,
|
||||
@Min(1) @Max(4) Integer referenceQuarter,
|
||||
@Positive Integer referenceYear, @Size(max = 100) String seriesId) {
|
||||
LeaseLegalTerms.RentRevisionTerms toDomain() {
|
||||
return new LeaseLegalTerms.RentRevisionTerms(Boolean.TRUE.equals(enabled), annualRevisionDate,
|
||||
return new LeaseLegalTerms.RentRevisionTerms(Boolean.TRUE.equals(enabled), toMonthDay(),
|
||||
referenceQuarter, referenceYear, seriesId);
|
||||
}
|
||||
|
||||
private MonthDay toMonthDay() {
|
||||
if (annualRevisionDay == null || annualRevisionMonth == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return MonthDay.of(annualRevisionMonth, annualRevisionDay);
|
||||
} catch (DateTimeException exception) {
|
||||
throw new LeaseRuleViolation("Le jour annuel de révision IRL est invalide.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
record RecentWorkRequest(@Size(max = 4000) String description, @PositiveOrZero BigDecimal amount) {
|
||||
@@ -109,7 +124,8 @@ final class LeaseApi {
|
||||
@Valid LegalTermsRequest legalTerms) {
|
||||
}
|
||||
|
||||
record AmendmentRequest(@NotNull LocalDate effectiveDate, @NotEmpty List<@NotBlank String> tenantIds,
|
||||
record AmendmentRequest(@NotNull LocalDate effectiveDate, @NotBlank String timeZone,
|
||||
@NotEmpty List<@NotBlank String> tenantIds,
|
||||
List<@Valid GuarantorRequest> guarantors, @NotNull LocalDate endDate,
|
||||
@NotNull @Positive BigDecimal monthlyRent,
|
||||
@NotNull @PositiveOrZero BigDecimal monthlyCharges,
|
||||
@@ -150,11 +166,13 @@ final class LeaseApi {
|
||||
}
|
||||
}
|
||||
|
||||
record RentRevisionResponse(boolean enabled, LocalDate annualRevisionDate, Integer referenceQuarter,
|
||||
Integer referenceYear, String seriesId) {
|
||||
record RentRevisionResponse(boolean enabled, Integer annualRevisionDay, Integer annualRevisionMonth,
|
||||
Integer referenceQuarter, Integer referenceYear, String seriesId) {
|
||||
static RentRevisionResponse from(LeaseLegalTerms.RentRevisionTerms terms) {
|
||||
return new RentRevisionResponse(terms.enabled(), terms.annualRevisionDate(), terms.referenceQuarter(),
|
||||
terms.referenceYear(), terms.seriesId());
|
||||
MonthDay day = terms.annualRevisionDay();
|
||||
return new RentRevisionResponse(terms.enabled(), day == null ? null : day.getDayOfMonth(),
|
||||
day == null ? null : day.getMonthValue(), terms.referenceQuarter(), terms.referenceYear(),
|
||||
terms.seriesId());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+12
-10
@@ -2,7 +2,6 @@ package fr.nessar.lmnporganizer.rental.adapter.in.web.lease;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlord;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.AmendLeaseService;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.ArchiveLeaseService;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.GenerateLeaseDocumentService;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.LeaseDraftService;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.GeneratedDocument;
|
||||
@@ -32,7 +31,9 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.DateTimeException;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -40,17 +41,15 @@ import java.util.UUID;
|
||||
@RequestMapping("/api/leases")
|
||||
public class LeaseController {
|
||||
private final LeaseDraftService drafts;
|
||||
private final ArchiveLeaseService archiving;
|
||||
private final AmendLeaseService amendments;
|
||||
private final GenerateLeaseDocumentService documents;
|
||||
private final AuthenticatedLandlord authenticatedLandlord;
|
||||
private final Clock clock;
|
||||
|
||||
public LeaseController(LeaseDraftService drafts, ArchiveLeaseService archiving, AmendLeaseService amendments,
|
||||
public LeaseController(LeaseDraftService drafts, AmendLeaseService amendments,
|
||||
GenerateLeaseDocumentService documents,
|
||||
AuthenticatedLandlord authenticatedLandlord, Clock clock) {
|
||||
this.drafts = drafts;
|
||||
this.archiving = archiving;
|
||||
this.amendments = amendments;
|
||||
this.documents = documents;
|
||||
this.authenticatedLandlord = authenticatedLandlord;
|
||||
@@ -86,11 +85,6 @@ public class LeaseController {
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping("/{leaseId}/archive")
|
||||
LeaseApi.Response archive(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId) {
|
||||
return response(archiving.archive(authenticatedLandlord.id(principal), new LeaseId(leaseId)));
|
||||
}
|
||||
|
||||
@PostMapping("/{leaseId}/amendments")
|
||||
ResponseEntity<LeaseApi.Response> amend(@AuthenticationPrincipal OidcUser principal,
|
||||
@PathVariable String leaseId,
|
||||
@@ -99,7 +93,7 @@ public class LeaseController {
|
||||
request.endDate(), request.monthlyRent(), request.monthlyCharges(), request.dueDay(),
|
||||
request.chargeMethod(), request.indexFlatRate());
|
||||
var amended = amendments.amend(authenticatedLandlord.id(principal), new LeaseId(leaseId),
|
||||
request.effectiveDate(), change);
|
||||
request.effectiveDate(), zone(request.timeZone()), change);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(response(amended));
|
||||
}
|
||||
|
||||
@@ -160,6 +154,14 @@ public class LeaseController {
|
||||
return LeaseApi.Response.from(lease, LocalDate.now(clock));
|
||||
}
|
||||
|
||||
private static ZoneId zone(String value) {
|
||||
try {
|
||||
return ZoneId.of(value);
|
||||
} catch (DateTimeException failure) {
|
||||
throw new IllegalArgumentException("Le fuseau horaire IANA est invalide.", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<TenantId> tenantIds(List<String> ids) {
|
||||
return ids.stream().map(TenantId::new).toList();
|
||||
}
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
package fr.nessar.lmnporganizer.rental.adapter.in.web.lease;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.LeaseEndingState;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.AgreedEarlyLeaseEnd;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseClosure;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseEndPhase;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseEndingHistory;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseType;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.NoticeDeliveryMode;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.NoticeEvidenceVersion;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.NoticeInvalidation;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.NoticeWithdrawal;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.TenantNotice;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.TenantNoticeStatus;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
final class LeaseEndingApi {
|
||||
private LeaseEndingApi() {
|
||||
}
|
||||
|
||||
record StateResponse(String leaseId, boolean supported, String unsupportedReason, LeaseEndPhase phase,
|
||||
LeaseType leaseType, LocalDate fixedEndOn, LocalDate effectiveEndOn,
|
||||
boolean correctionRequired, List<NoticeResponse> notices,
|
||||
AgreedEarlyEndResponse agreedEarlyEnd, List<AgreedEarlyEndResponse> agreedEarlyEnds,
|
||||
ClosureResponse closure) {
|
||||
static StateResponse from(LeaseEndingState state) {
|
||||
LeaseEndingHistory history = state.history();
|
||||
return new StateResponse(state.leaseId().value(), state.supported(),
|
||||
state.unsupportedReason().orElse(null), state.phase(), state.leaseType(),
|
||||
state.fixedEndOn().orElse(null), state.effectiveEndOn().orElse(null),
|
||||
state.correctionRequired(), history.notices().stream()
|
||||
.map(notice -> NoticeResponse.from(notice, history)).toList(),
|
||||
history.latestAgreement().map(AgreedEarlyEndResponse::from).orElse(null),
|
||||
history.agreedEarlyEnds().stream().map(AgreedEarlyEndResponse::from).toList(),
|
||||
history.closure().map(ClosureResponse::from).orElse(null));
|
||||
}
|
||||
}
|
||||
|
||||
record NoticeResponse(String id, TenantNoticeStatus status, LocalDate receivedOn,
|
||||
LocalDate requestedDepartureOn, NoticeDeliveryMode deliveryMode,
|
||||
String otherDeliveryMode, int noticeMonths, LocalDate minimumEndOn,
|
||||
String ruleVersion, boolean recordedWithoutEvidenceConfirmed,
|
||||
String authorId, Instant recordedAt, List<EvidenceResponse> evidenceVersions,
|
||||
WithdrawalResponse withdrawal, InvalidationResponse invalidation) {
|
||||
static NoticeResponse from(TenantNotice notice, LeaseEndingHistory history) {
|
||||
NoticeWithdrawal withdrawal = history.withdrawals().stream()
|
||||
.filter(fact -> fact.noticeId().equals(notice.id())).findFirst().orElse(null);
|
||||
NoticeInvalidation invalidation = history.invalidations().stream()
|
||||
.filter(fact -> fact.noticeId().equals(notice.id())).findFirst().orElse(null);
|
||||
return new NoticeResponse(notice.id().value(), notice.status(), notice.receivedOn(),
|
||||
notice.requestedDepartureOn(), notice.deliveryMode(), notice.otherDeliveryMode(),
|
||||
notice.noticeMonths(), notice.minimumEndOn(), notice.ruleVersion(),
|
||||
notice.recordedWithoutEvidenceConfirmed(), notice.audit().authorId(),
|
||||
notice.audit().recordedAt(), history.evidenceVersions(notice.id()).stream()
|
||||
.map(EvidenceResponse::from).toList(),
|
||||
withdrawal == null ? null : WithdrawalResponse.from(withdrawal),
|
||||
invalidation == null ? null : InvalidationResponse.from(invalidation));
|
||||
}
|
||||
}
|
||||
|
||||
record EvidenceResponse(int version, String fileName, String contentType, long size, String sha256,
|
||||
String reason, String authorId, Instant recordedAt) {
|
||||
static EvidenceResponse from(NoticeEvidenceVersion evidence) {
|
||||
return new EvidenceResponse(evidence.version(), evidence.file().fileName(),
|
||||
evidence.file().contentType(), evidence.file().size(), evidence.file().sha256(),
|
||||
evidence.reason(), evidence.audit().authorId(), evidence.audit().recordedAt());
|
||||
}
|
||||
}
|
||||
|
||||
record WithdrawalResponse(LocalDate acceptedOn, String reason, String authorId, Instant recordedAt) {
|
||||
static WithdrawalResponse from(NoticeWithdrawal withdrawal) {
|
||||
return new WithdrawalResponse(withdrawal.acceptedOn(), withdrawal.reason(),
|
||||
withdrawal.audit().authorId(), withdrawal.audit().recordedAt());
|
||||
}
|
||||
}
|
||||
|
||||
record InvalidationResponse(LocalDate invalidatedOn, String reason, String authorId, Instant recordedAt) {
|
||||
static InvalidationResponse from(NoticeInvalidation invalidation) {
|
||||
return new InvalidationResponse(invalidation.invalidatedOn(), invalidation.reason(),
|
||||
invalidation.audit().authorId(), invalidation.audit().recordedAt());
|
||||
}
|
||||
}
|
||||
|
||||
record AgreedEarlyEndResponse(LocalDate endOn, String reason, String authorId, Instant recordedAt) {
|
||||
static AgreedEarlyEndResponse from(AgreedEarlyLeaseEnd agreement) {
|
||||
return new AgreedEarlyEndResponse(agreement.endOn(), agreement.reason(),
|
||||
agreement.audit().authorId(), agreement.audit().recordedAt());
|
||||
}
|
||||
}
|
||||
|
||||
record ClosureResponse(LocalDate keysReturnedOn, String authorId, Instant recordedAt) {
|
||||
static ClosureResponse from(LeaseClosure closure) {
|
||||
return new ClosureResponse(closure.keysReturnedOn(), closure.audit().authorId(),
|
||||
closure.audit().recordedAt());
|
||||
}
|
||||
}
|
||||
}
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
package fr.nessar.lmnporganizer.rental.adapter.in.web.lease;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlord;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.LeaseEndingService;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentStorageFailure;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.NoticeDeliveryMode;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.TenantNoticeId;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.time.DateTimeException;
|
||||
|
||||
@RestController("rentalLeaseEndingController")
|
||||
@RequestMapping("/api/leases/{leaseId}/end")
|
||||
public class LeaseEndingController {
|
||||
private final LeaseEndingService endings;
|
||||
private final AuthenticatedLandlord authenticatedLandlord;
|
||||
|
||||
public LeaseEndingController(LeaseEndingService endings, AuthenticatedLandlord authenticatedLandlord) {
|
||||
this.endings = endings;
|
||||
this.authenticatedLandlord = authenticatedLandlord;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
LeaseEndingApi.StateResponse state(@AuthenticationPrincipal OidcUser principal,
|
||||
@PathVariable String leaseId,
|
||||
@RequestParam String timeZone) {
|
||||
return LeaseEndingApi.StateResponse.from(endings.state(authenticatedLandlord.id(principal),
|
||||
new LeaseId(leaseId), zone(timeZone)));
|
||||
}
|
||||
|
||||
@PostMapping(path = "/notices", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
ResponseEntity<LeaseEndingApi.StateResponse> recordNotice(
|
||||
@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId,
|
||||
@RequestParam String timeZone, @RequestParam LocalDate receivedOn,
|
||||
@RequestParam LocalDate requestedDepartureOn, @RequestParam NoticeDeliveryMode deliveryMode,
|
||||
@RequestParam(required = false) String otherDeliveryMode,
|
||||
@RequestParam boolean lateRegistrationConfirmed,
|
||||
@RequestParam boolean nonStandardDeliveryConfirmed,
|
||||
@RequestParam boolean missingEvidenceConfirmed,
|
||||
@RequestPart(value = "evidence", required = false) MultipartFile evidence) {
|
||||
var state = endings.recordNotice(authenticatedLandlord.id(principal), new LeaseId(leaseId),
|
||||
principal.getSubject(), new LeaseEndingService.RecordNotice(zone(timeZone), receivedOn,
|
||||
requestedDepartureOn, deliveryMode, otherDeliveryMode, lateRegistrationConfirmed,
|
||||
nonStandardDeliveryConfirmed, missingEvidenceConfirmed), upload(evidence, timeZone, null));
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(LeaseEndingApi.StateResponse.from(state));
|
||||
}
|
||||
|
||||
@PostMapping(path = "/notices/{noticeId}/evidence", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
LeaseEndingApi.StateResponse appendEvidence(
|
||||
@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId,
|
||||
@PathVariable String noticeId, @RequestParam String timeZone,
|
||||
@RequestPart("evidence") MultipartFile evidence, @RequestParam(required = false) String reason) {
|
||||
return LeaseEndingApi.StateResponse.from(endings.appendEvidence(authenticatedLandlord.id(principal),
|
||||
new LeaseId(leaseId), new TenantNoticeId(noticeId), principal.getSubject(),
|
||||
upload(evidence, timeZone, reason)));
|
||||
}
|
||||
|
||||
@GetMapping("/notices/{noticeId}/evidence/{version}")
|
||||
ResponseEntity<byte[]> evidence(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId,
|
||||
@PathVariable String noticeId, @PathVariable int version) {
|
||||
var evidence = endings.evidence(authenticatedLandlord.id(principal), new LeaseId(leaseId),
|
||||
new TenantNoticeId(noticeId), version);
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_OCTET_STREAM_VALUE)
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
|
||||
.filename(safeFileName(evidence.fileName())).build().toString())
|
||||
.header("X-Content-Type-Options", "nosniff")
|
||||
.body(evidence.content());
|
||||
}
|
||||
|
||||
@PostMapping("/notices/{noticeId}/withdrawals")
|
||||
LeaseEndingApi.StateResponse withdraw(@AuthenticationPrincipal OidcUser principal,
|
||||
@PathVariable String leaseId, @PathVariable String noticeId,
|
||||
@Valid @RequestBody WithdrawalRequest request) {
|
||||
return LeaseEndingApi.StateResponse.from(endings.acceptWithdrawal(authenticatedLandlord.id(principal),
|
||||
new LeaseId(leaseId), new TenantNoticeId(noticeId), principal.getSubject(),
|
||||
new LeaseEndingService.AdministrativeFact(zone(request.timeZone()), request.acceptedOn(),
|
||||
request.reason())));
|
||||
}
|
||||
|
||||
@PostMapping("/notices/{noticeId}/invalidations")
|
||||
LeaseEndingApi.StateResponse invalidate(@AuthenticationPrincipal OidcUser principal,
|
||||
@PathVariable String leaseId, @PathVariable String noticeId,
|
||||
@Valid @RequestBody InvalidationRequest request) {
|
||||
return LeaseEndingApi.StateResponse.from(endings.invalidateNotice(authenticatedLandlord.id(principal),
|
||||
new LeaseId(leaseId), new TenantNoticeId(noticeId), principal.getSubject(),
|
||||
new LeaseEndingService.AdministrativeFact(zone(request.timeZone()), request.invalidatedOn(),
|
||||
request.reason())));
|
||||
}
|
||||
|
||||
@PostMapping("/agreed-early-end")
|
||||
LeaseEndingApi.StateResponse agree(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId,
|
||||
@Valid @RequestBody AgreedEndRequest request) {
|
||||
return LeaseEndingApi.StateResponse.from(endings.recordAgreedEarlyEnd(authenticatedLandlord.id(principal),
|
||||
new LeaseId(leaseId), principal.getSubject(), new LeaseEndingService.AgreedEnd(
|
||||
zone(request.timeZone()), request.endOn(), request.reason())));
|
||||
}
|
||||
|
||||
@PostMapping("/closure")
|
||||
LeaseEndingApi.StateResponse close(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId,
|
||||
@Valid @RequestBody ClosureRequest request) {
|
||||
return LeaseEndingApi.StateResponse.from(endings.close(authenticatedLandlord.id(principal),
|
||||
new LeaseId(leaseId), principal.getSubject(), new LeaseEndingService.Closure(
|
||||
zone(request.timeZone()), request.keysReturnedOn(), request.confirmed())));
|
||||
}
|
||||
|
||||
private static LeaseEndingService.EvidenceUpload upload(MultipartFile file, String timeZone, String reason) {
|
||||
if (file == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new LeaseEndingService.EvidenceUpload(zone(timeZone), file.getOriginalFilename(),
|
||||
file.getContentType(), file.getBytes(), reason);
|
||||
} catch (IOException failure) {
|
||||
throw new AttachmentStorageFailure("Evidence could not be opened.", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static ZoneId zone(String value) {
|
||||
try {
|
||||
return ZoneId.of(value);
|
||||
} catch (DateTimeException failure) {
|
||||
throw new IllegalArgumentException("Le fuseau horaire IANA est invalide.", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static String safeFileName(String value) {
|
||||
String normalized = value == null ? "justificatif" : value.replace('\\', '/');
|
||||
String leaf = normalized.substring(normalized.lastIndexOf('/') + 1).trim();
|
||||
StringBuilder safe = new StringBuilder();
|
||||
leaf.codePoints().filter(character -> !Character.isISOControl(character)).forEach(safe::appendCodePoint);
|
||||
return safe.isEmpty() || safe.toString().equals(".") || safe.toString().equals("..")
|
||||
? "justificatif" : safe.toString();
|
||||
}
|
||||
|
||||
record WithdrawalRequest(@NotBlank String timeZone, @NotNull LocalDate acceptedOn,
|
||||
@NotBlank String reason) {
|
||||
}
|
||||
|
||||
record InvalidationRequest(@NotBlank String timeZone, @NotNull LocalDate invalidatedOn,
|
||||
@NotBlank String reason) {
|
||||
}
|
||||
|
||||
record AgreedEndRequest(@NotBlank String timeZone, @NotNull LocalDate endOn, @NotBlank String reason) {
|
||||
}
|
||||
|
||||
record ClosureRequest(@NotBlank String timeZone, @NotNull LocalDate keysReturnedOn,
|
||||
boolean confirmed) {
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -11,11 +11,12 @@ public final class RentDueApi {
|
||||
|
||||
public record Response(String id, String leaseId, String month, LocalDate dueDate, BigDecimal rent,
|
||||
BigDecimal charges, BigDecimal total, BigDecimal allocatedAmount,
|
||||
BigDecimal remainingAmount, String settlement, String timing, long version) {
|
||||
BigDecimal remainingAmount, String settlement, String timing,
|
||||
boolean correctionRequired, long version) {
|
||||
public static Response from(RentDue due, LocalDate today) {
|
||||
return new Response(due.id().value(), due.leaseId().value(), due.month().toString(), due.dueDate(),
|
||||
due.rent(), due.charges(), due.total(), due.allocatedAmount(), due.remainingAmount(),
|
||||
due.settlement().name(), due.timingOn(today).name(), due.version());
|
||||
due.settlement().name(), due.timingOn(today).name(), due.correctionRequired(), due.version());
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -28,7 +28,8 @@ final class RentPaymentApi {
|
||||
@NotNull @Positive BigDecimal amount) {
|
||||
}
|
||||
|
||||
record ReversalRequest(@NotNull @PositiveOrZero Long paymentVersion, @NotBlank @Size(max = 500) String reason) {
|
||||
record ReversalRequest(@NotNull @PositiveOrZero Long paymentVersion, @NotBlank @Size(max = 500) String reason,
|
||||
@NotBlank String timeZone) {
|
||||
}
|
||||
|
||||
record RefundRequest(@NotBlank String operationId, @NotNull @PositiveOrZero Long paymentVersion,
|
||||
|
||||
+2
-1
@@ -96,7 +96,8 @@ public class RentPaymentController {
|
||||
@Valid @RequestBody RentPaymentApi.ReversalRequest request) {
|
||||
LandlordId landlordId = authenticatedLandlord.id(principal);
|
||||
RentPaymentId id = new RentPaymentId(paymentId);
|
||||
reversal.reverse(landlordId, id, request.paymentVersion(), request.reason());
|
||||
reversal.reverse(landlordId, id, request.paymentVersion(), request.reason(),
|
||||
timeZone(request.timeZone()));
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(RentPaymentApi.Response.from(history.get(landlordId,
|
||||
id)));
|
||||
}
|
||||
|
||||
+5
-3
@@ -6,7 +6,7 @@ import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.ApartmentOccupancy;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.ApartmentOccupancyRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.RentalPeriod;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.OccupancyPeriod;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.annotation.Version;
|
||||
import org.springframework.data.mongodb.core.MongoTemplate;
|
||||
@@ -47,7 +47,7 @@ public class MongoApartmentOccupancyRepository implements ApartmentOccupancyRepo
|
||||
occupancy.landlordId().value(), occupancy.apartmentId().value(),
|
||||
occupancy.reservations().stream().map(reservation -> new ReservationDocument(
|
||||
reservation.leaseId().value(), reservation.period().startDate(),
|
||||
reservation.period().endDate())).toList(),
|
||||
reservation.period().endOn().orElse(null))).toList(),
|
||||
occupancy.version() < 0 ? null : occupancy.version());
|
||||
}
|
||||
|
||||
@@ -56,7 +56,9 @@ public class MongoApartmentOccupancyRepository implements ApartmentOccupancyRepo
|
||||
new ApartmentId(document.apartmentId()),
|
||||
document.reservations().stream().map(reservation -> new ApartmentOccupancy.Reservation(
|
||||
new LeaseId(reservation.leaseId()),
|
||||
new RentalPeriod(reservation.startDate(), reservation.endDate()))).toList(),
|
||||
reservation.endDate() == null
|
||||
? OccupancyPeriod.open(reservation.startDate())
|
||||
: OccupancyPeriod.closed(reservation.startDate(), reservation.endDate()))).toList(),
|
||||
document.version() == null ? -1 : document.version());
|
||||
}
|
||||
|
||||
|
||||
+128
-5
@@ -6,21 +6,33 @@ import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile;
|
||||
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile.BuildingType;
|
||||
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile.ConstructionPeriod;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.ChargeMethod;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.AgreedEarlyLeaseEnd;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.Guarantor;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseActionAudit;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseAmendment;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseAmendmentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseContractVersion;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseClosure;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseEndingHistory;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseLegalTerms;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSnapshot;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseStatus;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSpecialClause;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseType;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.NoticeDeliveryMode;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.NoticeEvidenceFile;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.NoticeEvidenceVersion;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.NoticeInvalidation;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.NoticeWithdrawal;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.RentTerms;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.RentalPeriod;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.TenantNotice;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.TenantNoticeId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.TenantNoticeStatus;
|
||||
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.annotation.Version;
|
||||
@@ -35,6 +47,8 @@ import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.Instant;
|
||||
import java.time.MonthDay;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@@ -89,6 +103,7 @@ public class MongoLeaseRepository implements LeaseRepository {
|
||||
toDocument(lease.legalTerms()),
|
||||
lease.snapshot() == null ? null : toDocument(lease.snapshot()),
|
||||
lease.amendments().stream().map(MongoLeaseRepository::toDocument).toList(),
|
||||
toDocument(lease.endingHistory()),
|
||||
persistedVersion(lease.version()));
|
||||
}
|
||||
|
||||
@@ -107,9 +122,73 @@ public class MongoLeaseRepository implements LeaseRepository {
|
||||
document.snapshot() == null ? null : toDomain(document.snapshot()),
|
||||
document.amendments() == null ? List.of()
|
||||
: document.amendments().stream().map(MongoLeaseRepository::toDomain).toList(),
|
||||
toDomain(document.endingHistory()),
|
||||
domainVersion(document.version()));
|
||||
}
|
||||
|
||||
private static EndingHistoryDocument toDocument(LeaseEndingHistory history) {
|
||||
return new EndingHistoryDocument(
|
||||
history.notices().stream().map(notice -> new TenantNoticeDocument(
|
||||
notice.id().value(), notice.receivedOn(), notice.requestedDepartureOn(),
|
||||
notice.deliveryMode().name(), notice.otherDeliveryMode(), notice.noticeMonths(),
|
||||
notice.minimumEndOn(), notice.ruleVersion(), notice.recordedWithoutEvidenceConfirmed(),
|
||||
toDocument(notice.audit()))).toList(),
|
||||
history.withdrawals().stream().map(withdrawal -> new NoticeWithdrawalDocument(
|
||||
withdrawal.noticeId().value(), withdrawal.acceptedOn(), withdrawal.reason(),
|
||||
toDocument(withdrawal.audit()))).toList(),
|
||||
history.invalidations().stream().map(invalidation -> new NoticeInvalidationDocument(
|
||||
invalidation.noticeId().value(), invalidation.invalidatedOn(), invalidation.reason(),
|
||||
toDocument(invalidation.audit()))).toList(),
|
||||
history.agreedEarlyEnds().stream().map(agreement -> new AgreedEarlyEndDocument(
|
||||
agreement.endOn(), agreement.reason(), toDocument(agreement.audit()))).toList(),
|
||||
history.evidenceVersions().stream().map(evidence -> new NoticeEvidenceDocument(
|
||||
evidence.noticeId().value(), evidence.version(), evidence.file().storageKey(),
|
||||
evidence.file().fileName(), evidence.file().contentType(), evidence.file().size(),
|
||||
evidence.file().sha256(), evidence.reason(), toDocument(evidence.audit()))).toList(),
|
||||
history.closure().map(closure -> new LeaseClosureDocument(
|
||||
closure.keysReturnedOn(), toDocument(closure.audit()))).orElse(null));
|
||||
}
|
||||
|
||||
private static LeaseEndingHistory toDomain(EndingHistoryDocument history) {
|
||||
if (history == null) {
|
||||
return LeaseEndingHistory.empty();
|
||||
}
|
||||
return LeaseEndingHistory.reconstitute(
|
||||
values(history.notices()).stream().map(notice -> new TenantNotice(
|
||||
new TenantNoticeId(notice.id()), TenantNoticeStatus.ACTIVE, notice.receivedOn(),
|
||||
notice.requestedDepartureOn(), NoticeDeliveryMode.valueOf(notice.deliveryMode()),
|
||||
notice.otherDeliveryMode(), notice.noticeMonths(), notice.minimumEndOn(),
|
||||
notice.ruleVersion(), Boolean.TRUE.equals(notice.recordedWithoutEvidenceConfirmed()),
|
||||
toDomain(notice.audit()))).toList(),
|
||||
values(history.withdrawals()).stream().map(withdrawal -> new NoticeWithdrawal(
|
||||
new TenantNoticeId(withdrawal.noticeId()), withdrawal.acceptedOn(), withdrawal.reason(),
|
||||
toDomain(withdrawal.audit()))).toList(),
|
||||
values(history.invalidations()).stream().map(invalidation -> new NoticeInvalidation(
|
||||
new TenantNoticeId(invalidation.noticeId()), invalidation.invalidatedOn(),
|
||||
invalidation.reason(), toDomain(invalidation.audit()))).toList(),
|
||||
values(history.agreedEarlyEnds()).stream().map(agreement -> new AgreedEarlyLeaseEnd(
|
||||
agreement.endOn(), agreement.reason(), toDomain(agreement.audit()))).toList(),
|
||||
values(history.evidenceVersions()).stream().map(evidence -> new NoticeEvidenceVersion(
|
||||
new TenantNoticeId(evidence.noticeId()), evidence.version(),
|
||||
new NoticeEvidenceFile(evidence.storageKey(), evidence.fileName(), evidence.contentType(),
|
||||
evidence.size(), evidence.sha256()), evidence.reason(), toDomain(evidence.audit())))
|
||||
.toList(),
|
||||
history.closure() == null ? null
|
||||
: new LeaseClosure(history.closure().keysReturnedOn(), toDomain(history.closure().audit())));
|
||||
}
|
||||
|
||||
private static LeaseAuditDocument toDocument(LeaseActionAudit audit) {
|
||||
return new LeaseAuditDocument(audit.authorId(), audit.recordedAt());
|
||||
}
|
||||
|
||||
private static LeaseActionAudit toDomain(LeaseAuditDocument audit) {
|
||||
return new LeaseActionAudit(audit.authorId(), audit.recordedAt());
|
||||
}
|
||||
|
||||
private static <T> List<T> values(List<T> values) {
|
||||
return values == null ? List.of() : values;
|
||||
}
|
||||
|
||||
private static GuarantorDocument toDocument(Guarantor guarantor) {
|
||||
return new GuarantorDocument(guarantor.id(), guarantor.firstName(), guarantor.lastName(), guarantor.email(),
|
||||
guarantor.phone(), guarantor.address());
|
||||
@@ -225,7 +304,11 @@ public class MongoLeaseRepository implements LeaseRepository {
|
||||
new PreviousRentDocument(terms.previousRent().applicable(), terms.previousRent().amount(),
|
||||
terms.previousRent().lastRevisionDate(), terms.previousRent().lastPaymentDate(),
|
||||
terms.previousRent().tenantConfirmation()),
|
||||
new RentRevisionDocument(terms.rentRevision().enabled(), terms.rentRevision().annualRevisionDate(),
|
||||
new RentRevisionDocument(terms.rentRevision().enabled(),
|
||||
terms.rentRevision().annualRevisionDay() == null ? null
|
||||
: terms.rentRevision().annualRevisionDay().getDayOfMonth(),
|
||||
terms.rentRevision().annualRevisionDay() == null ? null
|
||||
: terms.rentRevision().annualRevisionDay().getMonthValue(),
|
||||
terms.rentRevision().referenceQuarter(), terms.rentRevision().referenceYear(),
|
||||
terms.rentRevision().seriesId()),
|
||||
new RecentWorkDocument(terms.recentWork().description(), terms.recentWork().amount()),
|
||||
@@ -252,7 +335,10 @@ public class MongoLeaseRepository implements LeaseRepository {
|
||||
previousRent.lastRevisionDate(), previousRent.lastPaymentDate(),
|
||||
Boolean.TRUE.equals(previousRent.tenantConfirmation())),
|
||||
rentRevision == null ? null : new LeaseLegalTerms.RentRevisionTerms(
|
||||
Boolean.TRUE.equals(rentRevision.enabled()), rentRevision.annualRevisionDate(),
|
||||
Boolean.TRUE.equals(rentRevision.enabled()),
|
||||
rentRevision.annualRevisionDay() == null || rentRevision.annualRevisionMonth() == null
|
||||
? null : MonthDay.of(rentRevision.annualRevisionMonth(),
|
||||
rentRevision.annualRevisionDay()),
|
||||
rentRevision.referenceQuarter(), rentRevision.referenceYear(), rentRevision.seriesId()),
|
||||
recentWork == null ? null : new LeaseLegalTerms.RecentWork(recentWork.description(),
|
||||
recentWork.amount()),
|
||||
@@ -318,11 +404,12 @@ public class MongoLeaseRepository implements LeaseRepository {
|
||||
String leaseType, List<SpecialClauseDocument> specialClauses,
|
||||
LegalTermsDocument legalTerms,
|
||||
LeaseSnapshotDocument snapshot, List<LeaseAmendmentDocument> amendments,
|
||||
EndingHistoryDocument endingHistory,
|
||||
@Version Long version) {
|
||||
LeaseDocument withVersion(Long version) {
|
||||
return new LeaseDocument(id, landlordId, apartmentId, tenantIds, guarantors, startDate, endDate,
|
||||
monthlyRent, monthlyCharges, securityDeposit, status, dueDay, chargeMethod, indexFlatRate,
|
||||
leaseType, specialClauses, legalTerms, snapshot, amendments, version);
|
||||
leaseType, specialClauses, legalTerms, snapshot, amendments, endingHistory, version);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,8 +441,8 @@ public class MongoLeaseRepository implements LeaseRepository {
|
||||
Boolean tenantConfirmation) {
|
||||
}
|
||||
|
||||
record RentRevisionDocument(Boolean enabled, LocalDate annualRevisionDate, Integer referenceQuarter,
|
||||
Integer referenceYear, String seriesId) {
|
||||
record RentRevisionDocument(Boolean enabled, Integer annualRevisionDay, Integer annualRevisionMonth,
|
||||
Integer referenceQuarter, Integer referenceYear, String seriesId) {
|
||||
}
|
||||
|
||||
record RecentWorkDocument(String description,
|
||||
@@ -400,6 +487,42 @@ public class MongoLeaseRepository implements LeaseRepository {
|
||||
LeaseContractVersionDocument afterVersion) {
|
||||
}
|
||||
|
||||
record EndingHistoryDocument(List<TenantNoticeDocument> notices,
|
||||
List<NoticeWithdrawalDocument> withdrawals,
|
||||
List<NoticeInvalidationDocument> invalidations,
|
||||
List<AgreedEarlyEndDocument> agreedEarlyEnds,
|
||||
List<NoticeEvidenceDocument> evidenceVersions,
|
||||
LeaseClosureDocument closure) {
|
||||
}
|
||||
|
||||
record TenantNoticeDocument(String id, LocalDate receivedOn, LocalDate requestedDepartureOn,
|
||||
String deliveryMode, String otherDeliveryMode, int noticeMonths,
|
||||
LocalDate minimumEndOn, String ruleVersion,
|
||||
Boolean recordedWithoutEvidenceConfirmed, LeaseAuditDocument audit) {
|
||||
}
|
||||
|
||||
record NoticeWithdrawalDocument(String noticeId, LocalDate acceptedOn, String reason,
|
||||
LeaseAuditDocument audit) {
|
||||
}
|
||||
|
||||
record NoticeInvalidationDocument(String noticeId, LocalDate invalidatedOn, String reason,
|
||||
LeaseAuditDocument audit) {
|
||||
}
|
||||
|
||||
record AgreedEarlyEndDocument(LocalDate endOn, String reason, LeaseAuditDocument audit) {
|
||||
}
|
||||
|
||||
record NoticeEvidenceDocument(String noticeId, int version, String storageKey, String fileName,
|
||||
String contentType, long size, String sha256, String reason,
|
||||
LeaseAuditDocument audit) {
|
||||
}
|
||||
|
||||
record LeaseClosureDocument(LocalDate keysReturnedOn, LeaseAuditDocument audit) {
|
||||
}
|
||||
|
||||
record LeaseAuditDocument(String authorId, Instant recordedAt) {
|
||||
}
|
||||
|
||||
record LeaseContractVersionDocument(LandlordSnapshotDocument landlord, ApartmentSnapshotDocument apartment,
|
||||
List<TenantSnapshotDocument> tenants, List<GuarantorDocument> guarantors,
|
||||
LocalDate startDate, LocalDate endDate,
|
||||
|
||||
+6
-2
@@ -78,13 +78,15 @@ public class MongoRentDueRepository implements RentDueRepository {
|
||||
private RentDueDocument toDocument(RentDue due) {
|
||||
return new RentDueDocument(due.id().value(), due.landlordId().value(), due.leaseId().value(),
|
||||
due.month().toString(), due.dueDate(), due.rent(), due.charges(), due.allocatedAmount(),
|
||||
due.rentBeforeLeaseEnd(), due.chargesBeforeLeaseEnd(),
|
||||
persistedVersion(due.version()));
|
||||
}
|
||||
|
||||
private RentDue toDomain(RentDueDocument document) {
|
||||
return RentDue.reconstitute(new RentDueId(document.id()), new LandlordId(document.landlordId()),
|
||||
new LeaseId(document.leaseId()), YearMonth.parse(document.month()), document.dueDate(),
|
||||
document.rent(), document.charges(), document.allocatedAmount(), domainVersion(document.version()));
|
||||
document.rent(), document.charges(), document.allocatedAmount(), document.rentBeforeLeaseEnd(),
|
||||
document.chargesBeforeLeaseEnd(), domainVersion(document.version()));
|
||||
}
|
||||
|
||||
private static Long persistedVersion(long version) {
|
||||
@@ -102,10 +104,12 @@ public class MongoRentDueRepository implements RentDueRepository {
|
||||
LocalDate dueDate, @Field(targetType = FieldType.DECIMAL128) BigDecimal rent,
|
||||
@Field(targetType = FieldType.DECIMAL128) BigDecimal charges,
|
||||
@Field(targetType = FieldType.DECIMAL128) BigDecimal allocatedAmount,
|
||||
@Field(targetType = FieldType.DECIMAL128) BigDecimal rentBeforeLeaseEnd,
|
||||
@Field(targetType = FieldType.DECIMAL128) BigDecimal chargesBeforeLeaseEnd,
|
||||
@Version Long version) {
|
||||
RentDueDocument withVersion(Long version) {
|
||||
return new RentDueDocument(id, landlordId, leaseId, month, dueDate, rent, charges, allocatedAmount,
|
||||
version);
|
||||
rentBeforeLeaseEnd, chargesBeforeLeaseEnd, version);
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
@@ -17,6 +17,7 @@ import java.math.BigDecimal;
|
||||
import java.text.NumberFormat;
|
||||
import java.time.LocalDate;
|
||||
import java.time.Month;
|
||||
import java.time.MonthDay;
|
||||
import java.time.YearMonth;
|
||||
import java.time.format.TextStyle;
|
||||
import java.time.temporal.TemporalAccessor;
|
||||
@@ -103,6 +104,10 @@ public class ThymeleafRentalDocumentRenderer implements RentalDocumentRenderer {
|
||||
return date.getDayOfMonth() + " " + monthName(date) + " " + date.getYear();
|
||||
}
|
||||
|
||||
public String monthDay(MonthDay date) {
|
||||
return date.getDayOfMonth() + " " + monthName(date);
|
||||
}
|
||||
|
||||
public String month(YearMonth month) {
|
||||
return monthName(month) + " " + month.getYear();
|
||||
}
|
||||
|
||||
+1
-9
@@ -16,7 +16,6 @@ import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRuleViolation;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSnapshot;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseStatus;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentSchedule;
|
||||
@@ -55,13 +54,6 @@ public final class ActivateLeaseService {
|
||||
|
||||
Lease activateInTransaction(LandlordId landlordId, LeaseId leaseId) {
|
||||
Lease lease = leases.find(landlordId, leaseId).orElseThrow(() -> new ResourceNotFound("Lease"));
|
||||
boolean alreadyLeased = leases.findAll(landlordId).stream()
|
||||
.anyMatch(existing -> !existing.id().equals(lease.id())
|
||||
&& existing.apartmentId().equals(lease.apartmentId())
|
||||
&& existing.status() == LeaseStatus.ACTIVE);
|
||||
if (alreadyLeased) {
|
||||
throw new LeaseRuleViolation("Un autre bail actif existe déjà pour ce logement.");
|
||||
}
|
||||
Landlord landlord = landlords.find(landlordId).orElseThrow(() -> new ResourceNotFound("Landlord"));
|
||||
if (!landlord.hasCompleteProfile()) {
|
||||
throw new LeaseRuleViolation("Le profil bailleur doit être complet avant l'activation du bail.");
|
||||
@@ -76,7 +68,7 @@ public final class ActivateLeaseService {
|
||||
|
||||
ApartmentOccupancy occupancy = occupancies.find(landlordId, lease.apartmentId())
|
||||
.orElseGet(() -> ApartmentOccupancy.empty(landlordId, lease.apartmentId()));
|
||||
occupancy.reserve(lease.id(), lease.period());
|
||||
occupancy.reserve(lease.id(), lease.occupancyPeriod());
|
||||
LeaseActivated event = lease.activate(snapshot(landlord, apartment, leaseTenants, lease));
|
||||
occupancies.save(occupancy);
|
||||
rentDues.saveAll(RentSchedule.from(event, month -> new RentDueId(identifiers.next())));
|
||||
|
||||
+33
-5
@@ -23,6 +23,9 @@ import fr.nessar.lmnporganizer.rental.domain.rent.RentDue;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentSchedule;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentRevision;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentRevisionRepository;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.RentTermsTimeline;
|
||||
import fr.nessar.lmnporganizer.rental.domain.tenant.Tenant;
|
||||
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantRepository;
|
||||
@@ -30,12 +33,16 @@ import fr.nessar.lmnporganizer.rental.domain.tenant.TenantRepository;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.List;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
|
||||
public final class AmendLeaseService {
|
||||
private final LeaseRepository leases;
|
||||
private final TenantRepository tenants;
|
||||
private final RentDueRepository rentDues;
|
||||
private final RentRevisionRepository revisions;
|
||||
private final ApartmentOccupancyRepository occupancies;
|
||||
private final TransactionRunner transactions;
|
||||
private final IdentifierGenerator identifiers;
|
||||
@@ -44,17 +51,25 @@ public final class AmendLeaseService {
|
||||
public AmendLeaseService(LeaseRepository leases, TenantRepository tenants, RentDueRepository rentDues,
|
||||
ApartmentOccupancyRepository occupancies, TransactionRunner transactions,
|
||||
IdentifierGenerator identifiers, Clock clock) {
|
||||
this(leases, tenants, rentDues, null, occupancies, transactions, identifiers, clock);
|
||||
}
|
||||
|
||||
public AmendLeaseService(LeaseRepository leases, TenantRepository tenants, RentDueRepository rentDues,
|
||||
RentRevisionRepository revisions, ApartmentOccupancyRepository occupancies,
|
||||
TransactionRunner transactions, IdentifierGenerator identifiers, Clock clock) {
|
||||
this.leases = leases;
|
||||
this.tenants = tenants;
|
||||
this.rentDues = rentDues;
|
||||
this.revisions = revisions;
|
||||
this.occupancies = occupancies;
|
||||
this.transactions = transactions;
|
||||
this.identifiers = identifiers;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public Lease amend(LandlordId landlordId, LeaseId leaseId, LocalDate effectiveDate, Change change) {
|
||||
if (effectiveDate.isBefore(LocalDate.now(clock))) {
|
||||
public Lease amend(LandlordId landlordId, LeaseId leaseId, LocalDate effectiveDate, ZoneId timeZone,
|
||||
Change change) {
|
||||
if (effectiveDate.isBefore(LocalDate.now(clock.withZone(timeZone)))) {
|
||||
throw new LeaseRuleViolation("Un avenant ne peut pas prendre effet rétroactivement.");
|
||||
}
|
||||
return transactions.run(() -> amendInTransaction(landlordId, leaseId, effectiveDate, change));
|
||||
@@ -82,9 +97,10 @@ public final class AmendLeaseService {
|
||||
|
||||
ApartmentOccupancy occupancy = occupancies.find(landlordId, lease.apartmentId())
|
||||
.orElseThrow(() -> new ResourceNotFound("Apartment occupancy"));
|
||||
occupancy.reviseReservation(lease.id(), lease.period());
|
||||
occupancy.reviseReservation(lease.id(), lease.occupancyPeriod());
|
||||
List<RentDue> existingDues = rentDues.findAll(landlordId, leaseId);
|
||||
RentSchedule.Reconciliation reconciliation = RentSchedule.reconcile(event,
|
||||
rentDues.findAll(landlordId, leaseId), month -> new RentDueId(identifiers.next()));
|
||||
existingDues, month -> new RentDueId(identifiers.next()));
|
||||
for (RentDue removed : reconciliation.toRemove()) {
|
||||
if (!rentDues.deleteUnallocated(removed)) {
|
||||
throw new ConcurrentChange("Rent due");
|
||||
@@ -92,7 +108,19 @@ public final class AmendLeaseService {
|
||||
}
|
||||
amendedTenants.forEach(tenant -> tenant.referenceBy(leaseId));
|
||||
amendedTenants.forEach(tenants::save);
|
||||
rentDues.saveAll(reconciliation.toSave());
|
||||
LinkedHashSet<RentDue> toSave = new LinkedHashSet<>(reconciliation.toSave());
|
||||
lease.contractEndOn().ifPresent(endOn -> {
|
||||
List<RentDue> boundedDues = new ArrayList<>(existingDues);
|
||||
boundedDues.removeAll(reconciliation.toRemove());
|
||||
reconciliation.toSave().stream().filter(due -> !boundedDues.contains(due)).forEach(boundedDues::add);
|
||||
List<RentRevision> applied = revisions == null
|
||||
? List.of() : revisions.findAll(landlordId, leaseId);
|
||||
toSave.addAll(RentSchedule.reconcileEnd(
|
||||
boundedDues, lease.scheduledOccupancyPeriod(), endOn,
|
||||
RentTermsTimeline.finalMonthAmounts(
|
||||
lease, applied, lease.scheduledOccupancyPeriod(), endOn)).toSave());
|
||||
});
|
||||
rentDues.saveAll(List.copyOf(toSave));
|
||||
occupancies.save(occupancy);
|
||||
return leases.save(lease);
|
||||
}
|
||||
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.lease;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
|
||||
|
||||
public final class ArchiveLeaseService {
|
||||
private final LeaseRepository leases;
|
||||
|
||||
public ArchiveLeaseService(LeaseRepository leases) {
|
||||
this.leases = leases;
|
||||
}
|
||||
|
||||
public Lease archive(LandlordId landlordId, LeaseId leaseId) {
|
||||
Lease lease = leases.find(landlordId, leaseId).orElseThrow(() -> new ResourceNotFound("Lease"));
|
||||
lease.archive();
|
||||
return leases.save(lease);
|
||||
}
|
||||
}
|
||||
+354
@@ -0,0 +1,354 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.lease;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.IdentifierGenerator;
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.LeaseDocumentStorage;
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentTooLarge;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.ApartmentOccupancy;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.ApartmentOccupancyRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseActionAudit;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRuleViolation;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseStatus;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.NoticeDeliveryMode;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.NoticeEvidenceFile;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.NoticeEvidenceVersion;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.TenantNoticeCommand;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.TenantNoticeId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDue;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentSchedule;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentRevision;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentRevisionRepository;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.RentTermsTimeline;
|
||||
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
public final class LeaseEndingService {
|
||||
private final LeaseRepository leases;
|
||||
private final ApartmentOccupancyRepository occupancies;
|
||||
private final RentDueRepository rentDues;
|
||||
private final RentRevisionRepository revisions;
|
||||
private final LeaseDocumentStorage storage;
|
||||
private final TransactionRunner transactions;
|
||||
private final IdentifierGenerator identifiers;
|
||||
private final Clock clock;
|
||||
private final long maxEvidenceBytes;
|
||||
|
||||
public LeaseEndingService(LeaseRepository leases, ApartmentOccupancyRepository occupancies,
|
||||
RentDueRepository rentDues, LeaseDocumentStorage storage,
|
||||
TransactionRunner transactions, IdentifierGenerator identifiers,
|
||||
Clock clock, long maxEvidenceBytes) {
|
||||
this(leases, occupancies, rentDues, null, storage, transactions, identifiers, clock, maxEvidenceBytes);
|
||||
}
|
||||
|
||||
public LeaseEndingService(LeaseRepository leases, ApartmentOccupancyRepository occupancies,
|
||||
RentDueRepository rentDues, RentRevisionRepository revisions,
|
||||
LeaseDocumentStorage storage, TransactionRunner transactions,
|
||||
IdentifierGenerator identifiers, Clock clock, long maxEvidenceBytes) {
|
||||
this.leases = leases;
|
||||
this.occupancies = occupancies;
|
||||
this.rentDues = rentDues;
|
||||
this.revisions = revisions;
|
||||
this.storage = storage;
|
||||
this.transactions = transactions;
|
||||
this.identifiers = identifiers;
|
||||
this.clock = clock;
|
||||
if (maxEvidenceBytes <= 0) {
|
||||
throw new IllegalArgumentException("Maximum evidence size must be positive.");
|
||||
}
|
||||
this.maxEvidenceBytes = maxEvidenceBytes;
|
||||
}
|
||||
|
||||
public LeaseEndingState state(LandlordId landlordId, LeaseId leaseId, ZoneId timeZone) {
|
||||
Lease lease = findLease(landlordId, leaseId);
|
||||
requireFinalized(lease);
|
||||
return state(lease, rentDues.findAll(landlordId, leaseId), today(timeZone));
|
||||
}
|
||||
|
||||
public LeaseEndingState recordNotice(LandlordId landlordId, LeaseId leaseId, String authorId,
|
||||
RecordNotice request, EvidenceUpload evidence) {
|
||||
Objects.requireNonNull(request);
|
||||
if (evidence == null && !request.missingEvidenceConfirmed()) {
|
||||
throw new LeaseRuleViolation("L'absence de justificatif doit être confirmée.");
|
||||
}
|
||||
TenantNoticeId noticeId = new TenantNoticeId(identifiers.next());
|
||||
StoredEvidence stored = evidence == null ? null : store(evidence);
|
||||
try {
|
||||
return transactions.run(() -> changeEnd(landlordId, leaseId, lease -> {
|
||||
lease.recordTenantNotice(new TenantNoticeCommand(noticeId, request.receivedOn(),
|
||||
request.requestedDepartureOn(), request.deliveryMode(), request.otherDeliveryMode(),
|
||||
request.lateRegistrationConfirmed(), request.nonStandardDeliveryConfirmed(),
|
||||
evidence == null, today(request.timeZone())), audit(authorId));
|
||||
if (stored != null) {
|
||||
lease.appendNoticeEvidence(noticeId, stored.file(), null, audit(authorId));
|
||||
}
|
||||
}, request.timeZone()));
|
||||
} catch (RuntimeException failure) {
|
||||
discard(stored, failure);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
public LeaseEndingState acceptWithdrawal(LandlordId landlordId, LeaseId leaseId, TenantNoticeId noticeId,
|
||||
String authorId, AdministrativeFact request) {
|
||||
return transactions.run(() -> changeEnd(landlordId, leaseId,
|
||||
lease -> lease.acceptNoticeWithdrawal(noticeId, request.on(), request.reason(),
|
||||
today(request.timeZone()), audit(authorId)), request.timeZone()));
|
||||
}
|
||||
|
||||
public LeaseEndingState invalidateNotice(LandlordId landlordId, LeaseId leaseId, TenantNoticeId noticeId,
|
||||
String authorId, AdministrativeFact request) {
|
||||
return transactions.run(() -> changeEnd(landlordId, leaseId,
|
||||
lease -> lease.invalidateNotice(noticeId, request.on(), request.reason(),
|
||||
today(request.timeZone()), audit(authorId)), request.timeZone()));
|
||||
}
|
||||
|
||||
public LeaseEndingState recordAgreedEarlyEnd(LandlordId landlordId, LeaseId leaseId, String authorId,
|
||||
AgreedEnd request) {
|
||||
return transactions.run(() -> changeEnd(landlordId, leaseId,
|
||||
lease -> lease.recordAgreedEarlyEnd(request.endOn(), request.reason(), audit(authorId)),
|
||||
request.timeZone()));
|
||||
}
|
||||
|
||||
public LeaseEndingState close(LandlordId landlordId, LeaseId leaseId, String authorId, Closure request) {
|
||||
return transactions.run(() -> {
|
||||
Lease lease = findLease(landlordId, leaseId);
|
||||
lease.close(request.keysReturnedOn(), request.confirmed(), today(request.timeZone()), audit(authorId));
|
||||
Lease saved = leases.save(lease);
|
||||
return state(saved, rentDues.findAll(landlordId, leaseId), today(request.timeZone()));
|
||||
});
|
||||
}
|
||||
|
||||
public LeaseEndingState appendEvidence(LandlordId landlordId, LeaseId leaseId, TenantNoticeId noticeId,
|
||||
String authorId, EvidenceUpload evidence) {
|
||||
StoredEvidence stored = store(evidence);
|
||||
try {
|
||||
return transactions.run(() -> {
|
||||
Lease lease = findLease(landlordId, leaseId);
|
||||
lease.appendNoticeEvidence(noticeId, stored.file(), evidence.reason(), audit(authorId));
|
||||
Lease saved = leases.save(lease);
|
||||
return state(saved, rentDues.findAll(landlordId, leaseId), today(evidence.timeZone()));
|
||||
});
|
||||
} catch (RuntimeException failure) {
|
||||
discard(stored, failure);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
public EvidenceDownload evidence(LandlordId landlordId, LeaseId leaseId, TenantNoticeId noticeId, int version) {
|
||||
Lease lease = findLease(landlordId, leaseId);
|
||||
NoticeEvidenceVersion evidence = lease.endingHistory().evidenceVersions(noticeId).stream()
|
||||
.filter(candidate -> candidate.version() == version)
|
||||
.findFirst().orElseThrow(() -> new ResourceNotFound("Lease ending evidence"));
|
||||
byte[] content = storage.read(evidence.file().storageKey());
|
||||
if (content.length != evidence.file().size() || !sha256(content).equals(evidence.file().sha256())) {
|
||||
throw new IllegalStateException("Lease ending evidence integrity check failed.");
|
||||
}
|
||||
return new EvidenceDownload(evidence.file().fileName(), content);
|
||||
}
|
||||
|
||||
private LeaseEndingState changeEnd(LandlordId landlordId, LeaseId leaseId, Consumer<Lease> change,
|
||||
ZoneId timeZone) {
|
||||
Lease lease = findLease(landlordId, leaseId);
|
||||
Optional<LocalDate> previousEnd = lease.contractEndOn();
|
||||
change.accept(lease);
|
||||
ApartmentOccupancy occupancy = occupancies.find(landlordId, lease.apartmentId())
|
||||
.orElseThrow(() -> new ResourceNotFound("Apartment occupancy"));
|
||||
occupancy.reviseReservation(lease.id(), lease.occupancyPeriod());
|
||||
List<RentDue> dues = rentDues.findAll(landlordId, leaseId);
|
||||
Optional<LocalDate> currentEnd = lease.contractEndOn();
|
||||
if (currentEnd.isPresent() && isEarlier(currentEnd.orElseThrow(), previousEnd)) {
|
||||
RentSchedule.EndReconciliation reconciliation = RentSchedule.reconcileEnd(
|
||||
dues, lease.scheduledOccupancyPeriod(), currentEnd.orElseThrow(),
|
||||
finalMonthAmounts(landlordId, lease, currentEnd.orElseThrow()));
|
||||
if (!reconciliation.toSave().isEmpty()) {
|
||||
rentDues.saveAll(reconciliation.toSave());
|
||||
}
|
||||
} else if (isExpanded(previousEnd, currentEnd)) {
|
||||
RentSchedule.EndReconciliation restoration = RentSchedule.restoreAfterEndExpansion(
|
||||
dues, lease.scheduledOccupancyPeriod(), currentEnd,
|
||||
currentEnd.map(endOn -> finalMonthAmounts(landlordId, lease, endOn)).orElse(null));
|
||||
if (!restoration.toSave().isEmpty()) {
|
||||
rentDues.saveAll(restoration.toSave());
|
||||
}
|
||||
}
|
||||
occupancies.save(occupancy);
|
||||
Lease saved = leases.save(lease);
|
||||
return state(saved, dues, today(timeZone));
|
||||
}
|
||||
|
||||
private LeaseEndingState state(Lease lease, List<RentDue> dues, LocalDate today) {
|
||||
boolean supported = lease.endingSupported();
|
||||
return new LeaseEndingState(lease.id(), supported,
|
||||
supported ? Optional.empty()
|
||||
: Optional.of("La fin de bail locataire n'est pas prise en charge pour une colocation."),
|
||||
lease.endPhaseOn(today), lease.leaseType(), lease.fixedEndOn(), lease.contractEndOn(),
|
||||
dues.stream().anyMatch(RentDue::correctionRequired), lease.endingHistory());
|
||||
}
|
||||
|
||||
private RentSchedule.EndAmounts finalMonthAmounts(LandlordId landlordId, Lease lease, LocalDate endOn) {
|
||||
List<RentRevision> applied = revisions == null ? List.of() : revisions.findAll(landlordId, lease.id());
|
||||
return RentTermsTimeline.finalMonthAmounts(lease, applied, lease.scheduledOccupancyPeriod(), endOn);
|
||||
}
|
||||
|
||||
private static void requireFinalized(Lease lease) {
|
||||
if (lease.status() == LeaseStatus.DRAFT) {
|
||||
throw new LeaseRuleViolation("La fin de bail n'est disponible que pour un bail finalisé.");
|
||||
}
|
||||
}
|
||||
|
||||
private Lease findLease(LandlordId landlordId, LeaseId leaseId) {
|
||||
return leases.find(landlordId, leaseId).orElseThrow(() -> new ResourceNotFound("Lease"));
|
||||
}
|
||||
|
||||
private LocalDate today(ZoneId timeZone) {
|
||||
return LocalDate.now(clock.withZone(Objects.requireNonNull(timeZone, "Time zone is required.")));
|
||||
}
|
||||
|
||||
private LeaseActionAudit audit(String authorId) {
|
||||
return new LeaseActionAudit(authorId, Instant.now(clock));
|
||||
}
|
||||
|
||||
private StoredEvidence store(EvidenceUpload upload) {
|
||||
Objects.requireNonNull(upload, "Evidence is required.");
|
||||
byte[] content = Objects.requireNonNull(upload.content(), "Evidence content is required.").clone();
|
||||
if (content.length == 0) {
|
||||
throw new LeaseRuleViolation("Le justificatif est vide.");
|
||||
}
|
||||
if (content.length > maxEvidenceBytes) {
|
||||
throw new AttachmentTooLarge(maxEvidenceBytes);
|
||||
}
|
||||
String key = storage.store(content);
|
||||
return new StoredEvidence(new NoticeEvidenceFile(key, safeFileName(upload.fileName()),
|
||||
contentType(upload.contentType()), content.length, sha256(content)));
|
||||
}
|
||||
|
||||
private void discard(StoredEvidence evidence, RuntimeException failure) {
|
||||
if (evidence == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
storage.discard(evidence.file().storageKey());
|
||||
} catch (RuntimeException cleanupFailure) {
|
||||
failure.addSuppressed(cleanupFailure);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isEarlier(LocalDate current, Optional<LocalDate> previous) {
|
||||
return previous.isEmpty() || current.isBefore(previous.orElseThrow());
|
||||
}
|
||||
|
||||
private static boolean isExpanded(Optional<LocalDate> previous, Optional<LocalDate> current) {
|
||||
return previous.isPresent()
|
||||
&& (current.isEmpty() || current.orElseThrow().isAfter(previous.orElseThrow()));
|
||||
}
|
||||
|
||||
private static String safeFileName(String fileName) {
|
||||
if (fileName == null || fileName.isBlank()) {
|
||||
return "justificatif";
|
||||
}
|
||||
String normalized = fileName.replace('\\', '/');
|
||||
String leaf = normalized.substring(normalized.lastIndexOf('/') + 1).trim();
|
||||
StringBuilder safe = new StringBuilder();
|
||||
leaf.codePoints().filter(character -> !Character.isISOControl(character))
|
||||
.forEach(safe::appendCodePoint);
|
||||
if (safe.isEmpty() || safe.toString().equals(".") || safe.toString().equals("..")) {
|
||||
return "justificatif";
|
||||
}
|
||||
return safe.length() > 255 ? safe.substring(0, 255) : safe.toString();
|
||||
}
|
||||
|
||||
private static String contentType(String contentType) {
|
||||
return contentType == null || contentType.isBlank() ? "application/octet-stream" : contentType.trim();
|
||||
}
|
||||
|
||||
private static String sha256(byte[] content) {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(content));
|
||||
} catch (NoSuchAlgorithmException impossible) {
|
||||
throw new IllegalStateException("SHA-256 is unavailable.", impossible);
|
||||
}
|
||||
}
|
||||
|
||||
public record RecordNotice(ZoneId timeZone, LocalDate receivedOn, LocalDate requestedDepartureOn,
|
||||
NoticeDeliveryMode deliveryMode, String otherDeliveryMode,
|
||||
boolean lateRegistrationConfirmed, boolean nonStandardDeliveryConfirmed,
|
||||
boolean missingEvidenceConfirmed) {
|
||||
public RecordNotice(ZoneId timeZone, LocalDate receivedOn, LocalDate requestedDepartureOn,
|
||||
NoticeDeliveryMode deliveryMode, String otherDeliveryMode,
|
||||
boolean lateRegistrationConfirmed, boolean nonStandardDeliveryConfirmed) {
|
||||
this(timeZone, receivedOn, requestedDepartureOn, deliveryMode, otherDeliveryMode,
|
||||
lateRegistrationConfirmed, nonStandardDeliveryConfirmed, false);
|
||||
}
|
||||
|
||||
public RecordNotice {
|
||||
Objects.requireNonNull(timeZone);
|
||||
Objects.requireNonNull(receivedOn);
|
||||
Objects.requireNonNull(requestedDepartureOn);
|
||||
Objects.requireNonNull(deliveryMode);
|
||||
}
|
||||
}
|
||||
|
||||
public record AdministrativeFact(ZoneId timeZone, LocalDate on, String reason) {
|
||||
public AdministrativeFact {
|
||||
Objects.requireNonNull(timeZone);
|
||||
Objects.requireNonNull(on);
|
||||
}
|
||||
}
|
||||
|
||||
public record AgreedEnd(ZoneId timeZone, LocalDate endOn, String reason) {
|
||||
public AgreedEnd {
|
||||
Objects.requireNonNull(timeZone);
|
||||
Objects.requireNonNull(endOn);
|
||||
}
|
||||
}
|
||||
|
||||
public record Closure(ZoneId timeZone, LocalDate keysReturnedOn, boolean confirmed) {
|
||||
public Closure {
|
||||
Objects.requireNonNull(timeZone);
|
||||
Objects.requireNonNull(keysReturnedOn);
|
||||
}
|
||||
}
|
||||
|
||||
public record EvidenceUpload(ZoneId timeZone, String fileName, String contentType, byte[] content,
|
||||
String reason) {
|
||||
public EvidenceUpload {
|
||||
Objects.requireNonNull(timeZone);
|
||||
content = content == null ? null : content.clone();
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] content() {
|
||||
return content == null ? null : content.clone();
|
||||
}
|
||||
}
|
||||
|
||||
public record EvidenceDownload(String fileName, byte[] content) {
|
||||
public EvidenceDownload {
|
||||
content = content.clone();
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] content() {
|
||||
return content.clone();
|
||||
}
|
||||
}
|
||||
|
||||
private record StoredEvidence(NoticeEvidenceFile file) {
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.lease;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseEndPhase;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseEndingHistory;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseType;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
public record LeaseEndingState(LeaseId leaseId, boolean supported, Optional<String> unsupportedReason,
|
||||
LeaseEndPhase phase, LeaseType leaseType, Optional<LocalDate> fixedEndOn,
|
||||
Optional<LocalDate> effectiveEndOn, boolean correctionRequired,
|
||||
LeaseEndingHistory history) {
|
||||
public LeaseEndingState {
|
||||
Objects.requireNonNull(leaseId);
|
||||
unsupportedReason = unsupportedReason == null ? Optional.empty() : unsupportedReason;
|
||||
Objects.requireNonNull(phase);
|
||||
Objects.requireNonNull(leaseType);
|
||||
fixedEndOn = fixedEndOn == null ? Optional.empty() : fixedEndOn;
|
||||
effectiveEndOn = effectiveEndOn == null ? Optional.empty() : effectiveEndOn;
|
||||
Objects.requireNonNull(history);
|
||||
}
|
||||
}
|
||||
+7
@@ -36,6 +36,13 @@ public final class GenerateRentReceiptService {
|
||||
if (due.settlement() != RentDueSettlement.PAID) {
|
||||
throw new RentRuleViolation("Une quittance ne peut être émise que pour une échéance entièrement réglée.");
|
||||
}
|
||||
if (due.total().signum() == 0) {
|
||||
throw new RentRuleViolation("Une quittance ne peut pas être émise pour une échéance masquée de fin de bail.");
|
||||
}
|
||||
if (due.correctionRequired()) {
|
||||
throw new RentRuleViolation(
|
||||
"Une quittance ne peut pas être émise avant la correction des affectations de l'échéance.");
|
||||
}
|
||||
Lease lease = leases.find(landlordId, due.leaseId()).orElseThrow(() -> new ResourceNotFound("Lease"));
|
||||
LeaseContractVersion contract = lease.contractVersionOn(due.month().atEndOfMonth());
|
||||
RentReceipt receipt = new RentReceipt(due.id(), due.month(), due.rent(), due.charges(), due.total(),
|
||||
|
||||
+17
-3
@@ -21,6 +21,7 @@ import java.time.LocalDate;
|
||||
import java.time.YearMonth;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.ArrayList;
|
||||
|
||||
public final class RefreshRentScheduleService {
|
||||
private final LeaseRepository leases;
|
||||
@@ -68,9 +69,12 @@ public final class RefreshRentScheduleService {
|
||||
}
|
||||
YearMonth blockStart = existing.stream().map(RentDue::month).max(YearMonth::compareTo).orElseThrow()
|
||||
.plusMonths(1);
|
||||
List<RentDue> extension = RentSchedule.extendStandard(lease.id(), landlordId,
|
||||
if (lease.contractEndOn().filter(endOn -> blockStart.atDay(1).isAfter(endOn)).isPresent()) {
|
||||
return;
|
||||
}
|
||||
List<RentDue> extension = new ArrayList<>(RentSchedule.extendStandard(lease.id(), landlordId,
|
||||
lease.contractVersionOn(blockStart.atDay(1)), existing, today,
|
||||
month -> new RentDueId(identifiers.next()));
|
||||
month -> new RentDueId(identifiers.next())));
|
||||
if (!extension.isEmpty()) {
|
||||
YearMonth firstMonth = extension.getFirst().month();
|
||||
YearMonth lastMonth = extension.getLast().month();
|
||||
@@ -81,6 +85,15 @@ public final class RefreshRentScheduleService {
|
||||
new LeaseAmended(lease.id(), landlordId, amendment), extension,
|
||||
month -> new RentDueId(identifiers.next())));
|
||||
applyRevisions(landlordId, lease, extension);
|
||||
lease.contractEndOn().ifPresent(endOn -> {
|
||||
List<RentRevision> applied = revisions == null
|
||||
? List.of() : revisions.findAll(landlordId, lease.id());
|
||||
RentSchedule.EndReconciliation ending = RentSchedule.reconcileEnd(
|
||||
extension, lease.scheduledOccupancyPeriod(), endOn,
|
||||
RentTermsTimeline.finalMonthAmounts(
|
||||
lease, applied, lease.scheduledOccupancyPeriod(), endOn));
|
||||
extension.removeAll(ending.toRemove());
|
||||
});
|
||||
rentDues.saveAll(extension);
|
||||
}
|
||||
}
|
||||
@@ -124,7 +137,8 @@ public final class RefreshRentScheduleService {
|
||||
}
|
||||
|
||||
private static List<RentDue> sorted(List<RentDue> dues) {
|
||||
return dues.stream().sorted(Comparator.comparing(RentDue::month).thenComparing(due -> due.id().value()))
|
||||
return dues.stream().filter(due -> due.total().signum() > 0 || due.hasAllocations())
|
||||
.sorted(Comparator.comparing(RentDue::month).thenComparing(due -> due.id().value()))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -30,7 +30,8 @@ public final class RentDueQueryService {
|
||||
}
|
||||
|
||||
private static List<RentDue> sorted(List<RentDue> rentDues) {
|
||||
return rentDues.stream().sorted(Comparator.comparing(RentDue::month).thenComparing(due -> due.id().value()))
|
||||
return rentDues.stream().filter(due -> due.total().signum() > 0 || due.hasAllocations())
|
||||
.sorted(Comparator.comparing(RentDue::month).thenComparing(due -> due.id().value()))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
+25
-22
@@ -111,6 +111,7 @@ public final class RentRevisionService {
|
||||
LocalDate effectiveOn = effectiveDate(rentDues.findAll(landlordId, leaseId), request.requestedOn(),
|
||||
currentTerms.dueDay());
|
||||
rejectFinancialChangeBeforeEffect(lease, request.requestedOn(), effectiveOn);
|
||||
rejectAfterLeaseEnd(lease, effectiveOn);
|
||||
RentRevision.IndexReference indexReference = new RentRevision.IndexReference(preview.seriesId(),
|
||||
preview.seriesOverridden(), preview.seriesOverridden(),
|
||||
RentRevision.IndexSource.valueOf(preview.indexSource()), "MANUAL".equals(preview.indexSource()),
|
||||
@@ -222,11 +223,15 @@ public final class RentRevisionService {
|
||||
throw new LeaseRuleViolation("Le bail ne prévoit pas de révision IRL.");
|
||||
}
|
||||
LocalDate today = LocalDate.now(clock.withZone(request.timeZone()));
|
||||
LocalDate annualDate = revisionTerms.annualRevisionDate();
|
||||
int annualYear = lease.period().startDate().getYear();
|
||||
LocalDate annualDate = revisionTerms.annualRevisionDay().atYear(annualYear);
|
||||
if (!annualDate.isAfter(lease.period().startDate())) {
|
||||
annualDate = revisionTerms.annualRevisionDay().atYear(++annualYear);
|
||||
}
|
||||
LocalDate deadline = RentRevisionPolicy.requestDeadline(annualDate);
|
||||
int expiredYears = 0;
|
||||
while (today.isAfter(deadline) || alreadyApplied(landlordId, leaseId, annualDate)) {
|
||||
annualDate = annualDate.plusYears(1);
|
||||
annualDate = revisionTerms.annualRevisionDay().atYear(++annualYear);
|
||||
deadline = RentRevisionPolicy.requestDeadline(annualDate);
|
||||
expiredYears++;
|
||||
}
|
||||
@@ -256,29 +261,13 @@ public final class RentRevisionService {
|
||||
}
|
||||
|
||||
private RentTerms rentTermsOn(LandlordId landlordId, Lease lease, LocalDate date) {
|
||||
RentTerms contractTerms = lease.contractVersionOn(date).terms();
|
||||
if (revisions == null) {
|
||||
return contractTerms;
|
||||
}
|
||||
LocalDate lastFinancialAmendment = lease.amendments().stream()
|
||||
.filter(amendment -> !amendment.effectiveDate().isAfter(date))
|
||||
.filter(RentRevisionService::changesAmounts)
|
||||
.map(amendment -> amendment.effectiveDate()).max(LocalDate::compareTo).orElse(LocalDate.MIN);
|
||||
RentRevision latest = revisions.findAll(landlordId, lease.id()).stream()
|
||||
.filter(revision -> revision.status() == RentRevision.Status.APPLIED)
|
||||
.filter(revision -> !revision.application().effectiveOn().isAfter(date))
|
||||
.filter(revision -> !revision.application().effectiveOn().isBefore(lastFinancialAmendment))
|
||||
.max(Comparator.comparing(revision -> revision.application().effectiveOn())).orElse(null);
|
||||
if (latest == null) {
|
||||
return contractTerms;
|
||||
}
|
||||
return new RentTerms(latest.amounts().chosenRent(), latest.amounts().appliedCharges(),
|
||||
contractTerms.securityDeposit(), contractTerms.dueDay(), contractTerms.chargeMethod(),
|
||||
contractTerms.indexFlatRate());
|
||||
return RentTermsTimeline.on(lease,
|
||||
revisions == null ? List.of() : revisions.findAll(landlordId, lease.id()), date);
|
||||
}
|
||||
|
||||
private ApplyResult applyInTransaction(LandlordId landlordId, Lease lease, RentRevision proposed,
|
||||
RentRevision.Application application, List<RentDue> existing) {
|
||||
rejectAfterLeaseEnd(lease, application.effectiveOn());
|
||||
if (revisions.findAll(landlordId, lease.id()).stream()
|
||||
.anyMatch(revision -> !revision.id().equals(proposed.id())
|
||||
&& revision.status() == RentRevision.Status.APPLIED
|
||||
@@ -293,7 +282,15 @@ public final class RentRevisionService {
|
||||
List<RentDue> changed = boundary == null
|
||||
? RentSchedule.applyRevision(existing, application.effectiveOn(), amounts)
|
||||
: RentSchedule.applyRevision(existing, application.effectiveOn(), amounts, boundary);
|
||||
List<RentDue> savedDues = rentDues.saveAll(changed);
|
||||
java.util.LinkedHashSet<RentDue> boundedChanges = new java.util.LinkedHashSet<>(changed);
|
||||
lease.contractEndOn().ifPresent(endOn -> {
|
||||
List<RentRevision> timeline = new java.util.ArrayList<>(revisions.findAll(landlordId, lease.id()));
|
||||
timeline.add(applied);
|
||||
boundedChanges.addAll(RentSchedule.reconcileEnd(existing, lease.scheduledOccupancyPeriod(), endOn,
|
||||
RentTermsTimeline.finalMonthAmounts(
|
||||
lease, timeline, lease.scheduledOccupancyPeriod(), endOn)).toSave());
|
||||
});
|
||||
List<RentDue> savedDues = rentDues.saveAll(List.copyOf(boundedChanges));
|
||||
return new ApplyResult(revisions.save(applied), savedDues);
|
||||
}
|
||||
|
||||
@@ -331,6 +328,12 @@ public final class RentRevisionService {
|
||||
}
|
||||
}
|
||||
|
||||
private static void rejectAfterLeaseEnd(Lease lease, LocalDate effectiveOn) {
|
||||
if (lease.contractEndOn().filter(endOn -> effectiveOn.isAfter(endOn)).isPresent()) {
|
||||
throw new LeaseRuleViolation("Une révision IRL ne peut pas prendre effet après la fin du bail.");
|
||||
}
|
||||
}
|
||||
|
||||
private static RentSchedule.FinancialBoundary nextFinancialBoundary(Lease lease, LocalDate effectiveOn) {
|
||||
return lease.amendments().stream().filter(amendment -> amendment.effectiveDate().isAfter(effectiveOn))
|
||||
.filter(RentRevisionService::changesAmounts)
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rent;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseAmendment;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.OccupancyPeriod;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.RentTerms;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentRevision;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentSchedule;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.YearMonth;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
public final class RentTermsTimeline {
|
||||
private RentTermsTimeline() {
|
||||
}
|
||||
|
||||
static RentTerms on(Lease lease, List<RentRevision> revisions, LocalDate date) {
|
||||
RentTerms contractTerms = lease.contractVersionOn(date).terms();
|
||||
LocalDate lastFinancialAmendment = lease.amendments().stream()
|
||||
.filter(amendment -> !amendment.effectiveDate().isAfter(date))
|
||||
.filter(RentTermsTimeline::changesAmounts)
|
||||
.map(LeaseAmendment::effectiveDate).max(LocalDate::compareTo).orElse(LocalDate.MIN);
|
||||
RentRevision latest = safe(revisions).stream()
|
||||
.filter(revision -> revision.status() == RentRevision.Status.APPLIED)
|
||||
.filter(revision -> !revision.application().effectiveOn().isAfter(date))
|
||||
.filter(revision -> !revision.application().effectiveOn().isBefore(lastFinancialAmendment))
|
||||
.max(Comparator.comparing(revision -> revision.application().effectiveOn())).orElse(null);
|
||||
if (latest == null) {
|
||||
return contractTerms;
|
||||
}
|
||||
return new RentTerms(latest.amounts().chosenRent(), latest.amounts().appliedCharges(),
|
||||
contractTerms.securityDeposit(), contractTerms.dueDay(), contractTerms.chargeMethod(),
|
||||
contractTerms.indexFlatRate());
|
||||
}
|
||||
|
||||
public static RentSchedule.EndAmounts finalMonthAmounts(Lease lease, List<RentRevision> revisions,
|
||||
OccupancyPeriod scheduledOccupancy, LocalDate endOn) {
|
||||
YearMonth month = YearMonth.from(endOn);
|
||||
LocalDate firstOccupiedOn = later(month.atDay(1), scheduledOccupancy.startOn());
|
||||
LocalDate lastOccupiedOn = earlier(endOn, scheduledOccupancy.endOn().orElse(endOn));
|
||||
BigDecimal rentDays = BigDecimal.ZERO;
|
||||
BigDecimal chargeDays = BigDecimal.ZERO;
|
||||
for (LocalDate date = firstOccupiedOn; !date.isAfter(lastOccupiedOn); date = date.plusDays(1)) {
|
||||
RentTerms terms = on(lease, revisions, date);
|
||||
rentDays = rentDays.add(terms.monthlyRent());
|
||||
chargeDays = chargeDays.add(terms.monthlyCharges());
|
||||
}
|
||||
BigDecimal daysInMonth = BigDecimal.valueOf(month.lengthOfMonth());
|
||||
return new RentSchedule.EndAmounts(rentDays.divide(daysInMonth, 2, RoundingMode.HALF_UP),
|
||||
chargeDays.divide(daysInMonth, 2, RoundingMode.HALF_UP));
|
||||
}
|
||||
|
||||
static boolean changesAmounts(LeaseAmendment amendment) {
|
||||
return amendment.beforeVersion().terms().monthlyRent()
|
||||
.compareTo(amendment.afterVersion().terms().monthlyRent()) != 0
|
||||
|| amendment.beforeVersion().terms().monthlyCharges()
|
||||
.compareTo(amendment.afterVersion().terms().monthlyCharges()) != 0;
|
||||
}
|
||||
|
||||
private static List<RentRevision> safe(List<RentRevision> revisions) {
|
||||
return revisions == null ? List.of() : revisions;
|
||||
}
|
||||
|
||||
private static LocalDate later(LocalDate first, LocalDate second) {
|
||||
return first.isAfter(second) ? first : second;
|
||||
}
|
||||
|
||||
private static LocalDate earlier(LocalDate first, LocalDate second) {
|
||||
return first.isBefore(second) ? first : second;
|
||||
}
|
||||
}
|
||||
+8
-4
@@ -17,6 +17,8 @@ import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversalRepository;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Objects;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@@ -40,21 +42,23 @@ public final class ReverseRentPaymentService {
|
||||
}
|
||||
|
||||
public RentPaymentReversal reverse(LandlordId landlordId, RentPaymentId paymentId, long paymentVersion,
|
||||
String reason) {
|
||||
String reason, ZoneId timeZone) {
|
||||
Objects.requireNonNull(timeZone, "Time zone is required.");
|
||||
return transactions.run(() -> reversals.findByPayment(landlordId, paymentId)
|
||||
.map(existing -> idempotentReversal(existing, reason))
|
||||
.orElseGet(() -> reverseInTransaction(landlordId, paymentId, paymentVersion, reason)));
|
||||
.orElseGet(() -> reverseInTransaction(landlordId, paymentId, paymentVersion, reason, timeZone)));
|
||||
}
|
||||
|
||||
private RentPaymentReversal reverseInTransaction(LandlordId landlordId, RentPaymentId paymentId,
|
||||
long paymentVersion, String reason) {
|
||||
long paymentVersion, String reason, ZoneId timeZone) {
|
||||
RentPayment payment = payments.find(landlordId, paymentId)
|
||||
.orElseThrow(() -> new ResourceNotFound("Rent payment"));
|
||||
if (payment.version() != paymentVersion) {
|
||||
throw new ConcurrentChange("Rent payment");
|
||||
}
|
||||
RentPaymentReversal reversal = RentPaymentReversal.reverse(
|
||||
new RentPaymentReversalId(identifiers.next()), payment, LocalDate.now(clock), reason);
|
||||
new RentPaymentReversalId(identifiers.next()), payment,
|
||||
LocalDate.now(clock.withZone(timeZone)), reason);
|
||||
List<RentDue> affectedDues = new ArrayList<>();
|
||||
for (PaymentAllocation allocation : reversal.reversedAllocations()) {
|
||||
RentDue due = rentDues.find(landlordId, allocation.rentDueId())
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.lease;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Objects;
|
||||
|
||||
public record AgreedEarlyLeaseEnd(LocalDate endOn, String reason, LeaseActionAudit audit) {
|
||||
public AgreedEarlyLeaseEnd {
|
||||
Objects.requireNonNull(endOn);
|
||||
reason = LeaseEndingHistory.requiredReason(reason);
|
||||
Objects.requireNonNull(audit);
|
||||
}
|
||||
}
|
||||
+13
-1
@@ -34,6 +34,10 @@ public final class ApartmentOccupancy {
|
||||
}
|
||||
|
||||
public void reserve(LeaseId leaseId, RentalPeriod period) {
|
||||
reserve(leaseId, OccupancyPeriod.closed(period));
|
||||
}
|
||||
|
||||
public void reserve(LeaseId leaseId, OccupancyPeriod period) {
|
||||
Objects.requireNonNull(leaseId);
|
||||
Objects.requireNonNull(period);
|
||||
if (reservations.stream().anyMatch(existing -> existing.leaseId().equals(leaseId))) {
|
||||
@@ -48,6 +52,10 @@ public final class ApartmentOccupancy {
|
||||
}
|
||||
|
||||
public void reviseReservation(LeaseId leaseId, RentalPeriod period) {
|
||||
reviseReservation(leaseId, OccupancyPeriod.closed(period));
|
||||
}
|
||||
|
||||
public void reviseReservation(LeaseId leaseId, OccupancyPeriod period) {
|
||||
Objects.requireNonNull(leaseId);
|
||||
Objects.requireNonNull(period);
|
||||
Reservation current = reservations.stream().filter(existing -> existing.leaseId().equals(leaseId))
|
||||
@@ -77,7 +85,11 @@ public final class ApartmentOccupancy {
|
||||
return version;
|
||||
}
|
||||
|
||||
public record Reservation(LeaseId leaseId, RentalPeriod period) {
|
||||
public record Reservation(LeaseId leaseId, OccupancyPeriod period) {
|
||||
public Reservation(LeaseId leaseId, RentalPeriod period) {
|
||||
this(leaseId, OccupancyPeriod.closed(period));
|
||||
}
|
||||
|
||||
public Reservation {
|
||||
Objects.requireNonNull(leaseId);
|
||||
Objects.requireNonNull(period);
|
||||
|
||||
@@ -8,12 +8,14 @@ import java.time.LocalDate;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
public final class Lease {
|
||||
private final LeaseId id;
|
||||
private final LandlordId landlordId;
|
||||
private final long version;
|
||||
private final LeaseAmendmentHistory amendments;
|
||||
private final LeaseEndingHistory endingHistory;
|
||||
private ApartmentId apartmentId;
|
||||
private List<TenantId> tenantIds;
|
||||
private List<Guarantor> guarantors;
|
||||
@@ -28,7 +30,8 @@ public final class Lease {
|
||||
private Lease(LeaseId id, LandlordId landlordId, ApartmentId apartmentId, List<TenantId> tenantIds,
|
||||
List<Guarantor> guarantors, RentalPeriod period, RentTerms terms, LeaseType leaseType,
|
||||
List<LeaseSpecialClause> specialClauses, LeaseLegalTerms legalTerms, LeaseStatus status,
|
||||
LeaseSnapshot snapshot, List<LeaseAmendment> amendments, long version) {
|
||||
LeaseSnapshot snapshot, List<LeaseAmendment> amendments, LeaseEndingHistory endingHistory,
|
||||
long version) {
|
||||
this.id = Objects.requireNonNull(id);
|
||||
this.landlordId = Objects.requireNonNull(landlordId);
|
||||
if (version < -1) {
|
||||
@@ -47,13 +50,15 @@ public final class Lease {
|
||||
this.status = Objects.requireNonNull(status);
|
||||
this.snapshot = snapshot;
|
||||
this.amendments = new LeaseAmendmentHistory(amendments);
|
||||
this.endingHistory = endingHistory == null ? LeaseEndingHistory.empty() : endingHistory;
|
||||
}
|
||||
|
||||
public static Lease draft(LeaseId id, LandlordId landlordId, ApartmentId apartmentId,
|
||||
List<TenantId> tenantIds, List<Guarantor> guarantors, RentalPeriod period,
|
||||
RentTerms terms) {
|
||||
return new Lease(id, landlordId, apartmentId, tenantIds, guarantors, period, terms, LeaseType.STANDARD,
|
||||
List.of(), LeaseLegalTerms.empty(), LeaseStatus.DRAFT, null, List.of(), -1);
|
||||
List.of(), LeaseLegalTerms.empty(), LeaseStatus.DRAFT, null, List.of(), LeaseEndingHistory.empty(),
|
||||
-1);
|
||||
}
|
||||
|
||||
public static Lease draft(LeaseId id, LandlordId landlordId, ApartmentId apartmentId,
|
||||
@@ -76,7 +81,7 @@ public final class Lease {
|
||||
LeaseType type = LeaseType.orStandard(leaseType);
|
||||
return new Lease(id, landlordId, apartmentId, tenantIds, guarantors,
|
||||
type.initialPeriod(period.startDate()), terms, type, specialClauses, legalTerms, LeaseStatus.DRAFT,
|
||||
null, List.of(), -1);
|
||||
null, List.of(), LeaseEndingHistory.empty(), -1);
|
||||
}
|
||||
|
||||
public static Lease reconstitute(LeaseId id, LandlordId landlordId, ApartmentId apartmentId,
|
||||
@@ -133,6 +138,16 @@ public final class Lease {
|
||||
RentTerms terms, LeaseType leaseType, List<LeaseSpecialClause> specialClauses,
|
||||
LeaseLegalTerms legalTerms, LeaseStatus status, LeaseSnapshot snapshot,
|
||||
List<LeaseAmendment> amendments, long version) {
|
||||
return reconstitute(id, landlordId, apartmentId, tenantIds, guarantors, period, terms, leaseType,
|
||||
specialClauses, legalTerms, status, snapshot, amendments, LeaseEndingHistory.empty(), version);
|
||||
}
|
||||
|
||||
public static Lease reconstitute(LeaseId id, LandlordId landlordId, ApartmentId apartmentId,
|
||||
List<TenantId> tenantIds, List<Guarantor> guarantors, RentalPeriod period,
|
||||
RentTerms terms, LeaseType leaseType, List<LeaseSpecialClause> specialClauses,
|
||||
LeaseLegalTerms legalTerms, LeaseStatus status, LeaseSnapshot snapshot,
|
||||
List<LeaseAmendment> amendments, LeaseEndingHistory endingHistory,
|
||||
long version) {
|
||||
if (status == LeaseStatus.DRAFT && snapshot != null) {
|
||||
throw new LeaseRuleViolation("Un bail en brouillon ne peut pas avoir d'instantané contractuel.");
|
||||
}
|
||||
@@ -143,7 +158,7 @@ public final class Lease {
|
||||
throw new LeaseRuleViolation("Un bail en brouillon ne peut pas avoir d'avenant.");
|
||||
}
|
||||
Lease lease = new Lease(id, landlordId, apartmentId, tenantIds, guarantors, period, terms, leaseType,
|
||||
specialClauses, legalTerms, status, snapshot, amendments, version);
|
||||
specialClauses, legalTerms, status, snapshot, amendments, endingHistory, version);
|
||||
if (snapshot != null) {
|
||||
lease.validateTenantIds();
|
||||
lease.requireSnapshotIdentity(snapshot);
|
||||
@@ -208,19 +223,204 @@ public final class Lease {
|
||||
if (status != LeaseStatus.ACTIVE) {
|
||||
throw new LeaseRuleViolation("Seul un bail actif peut être modifié par avenant.");
|
||||
}
|
||||
Objects.requireNonNull(afterVersion, "Amended contract version is required.");
|
||||
if (contractEndOn().filter(endOn -> effectiveDate.isAfter(endOn)).isPresent()) {
|
||||
throw new LeaseRuleViolation("Un avenant ne peut pas prendre effet après la fin du bail.");
|
||||
}
|
||||
if (contractEndOn(afterVersion).filter(endOn -> effectiveDate.isAfter(endOn)).isPresent()) {
|
||||
throw new LeaseRuleViolation("Un avenant ne peut pas prendre effet après sa nouvelle fin de bail.");
|
||||
}
|
||||
if (hasActiveEndingFact()) {
|
||||
if (afterVersion.tenantIds().size() != 1) {
|
||||
throw new LeaseRuleViolation(
|
||||
"Un avenant ne peut pas transformer en colocation un bail ayant une fin active.");
|
||||
}
|
||||
if (!contractEndOn().equals(contractEndOn(afterVersion))) {
|
||||
throw new LeaseRuleViolation(
|
||||
"Un avenant ne peut pas modifier la fin contractuelle active du bail.");
|
||||
}
|
||||
}
|
||||
LeaseAmendment amendment = amendments.append(amendmentId, effectiveDate, contractVersion(), afterVersion);
|
||||
apply(amendment.afterVersion());
|
||||
return new LeaseAmended(id, landlordId, amendment);
|
||||
}
|
||||
|
||||
public void archive() {
|
||||
if (status == LeaseStatus.ARCHIVED) {
|
||||
return;
|
||||
public TenantNotice recordTenantNotice(TenantNoticeCommand command, LeaseActionAudit audit) {
|
||||
requireActiveEndingSupported();
|
||||
Objects.requireNonNull(command);
|
||||
Objects.requireNonNull(audit);
|
||||
if (command.receivedOn().isAfter(command.today())) {
|
||||
throw new LeaseRuleViolation("La date de réception du congé ne peut pas être future.");
|
||||
}
|
||||
if (status != LeaseStatus.ACTIVE) {
|
||||
throw new LeaseRuleViolation("Seul un bail actif peut être archivé.");
|
||||
if (command.receivedOn().isBefore(command.today()) && !command.lateRegistrationConfirmed()) {
|
||||
throw new LeaseRuleViolation("La saisie tardive du congé doit être confirmée.");
|
||||
}
|
||||
if (command.requestedDepartureOn().isBefore(command.receivedOn())) {
|
||||
throw new LeaseRuleViolation("La date de départ demandée ne peut pas précéder la réception du congé.");
|
||||
}
|
||||
if (command.deliveryMode() == NoticeDeliveryMode.OTHER
|
||||
&& !command.nonStandardDeliveryConfirmed()) {
|
||||
throw new LeaseRuleViolation("Le mode de remise non standard doit être confirmé.");
|
||||
}
|
||||
TenantNotice notice = new TenantNotice(command.id(), TenantNoticeStatus.ACTIVE, command.receivedOn(),
|
||||
command.requestedDepartureOn(), command.deliveryMode(), command.otherDeliveryMode(), 1,
|
||||
command.receivedOn().plusMonths(1), "2026-07", command.recordedWithoutEvidenceConfirmed(), audit);
|
||||
return endingHistory.recordNotice(notice);
|
||||
}
|
||||
|
||||
public NoticeWithdrawal acceptNoticeWithdrawal(TenantNoticeId noticeId, LocalDate acceptedOn, String reason,
|
||||
LocalDate today, LeaseActionAudit audit) {
|
||||
requireActiveEndingSupported();
|
||||
TenantNotice notice = endingHistory.notice(noticeId)
|
||||
.orElseThrow(() -> new LeaseRuleViolation("Le congé locataire est introuvable."));
|
||||
validateAdministrativeFactDate(acceptedOn, today, notice.receivedOn());
|
||||
return endingHistory.withdraw(noticeId, acceptedOn, reason, audit);
|
||||
}
|
||||
|
||||
public NoticeInvalidation invalidateNotice(TenantNoticeId noticeId, LocalDate invalidatedOn, String reason,
|
||||
LocalDate today, LeaseActionAudit audit) {
|
||||
requireActiveEndingSupported();
|
||||
TenantNotice notice = endingHistory.notice(noticeId)
|
||||
.orElseThrow(() -> new LeaseRuleViolation("Le congé locataire est introuvable."));
|
||||
validateAdministrativeFactDate(invalidatedOn, today, notice.receivedOn());
|
||||
return endingHistory.invalidate(noticeId, invalidatedOn, reason, audit);
|
||||
}
|
||||
|
||||
public AgreedEarlyLeaseEnd recordAgreedEarlyEnd(LocalDate endOn, String reason, LeaseActionAudit audit) {
|
||||
requireActiveEndingSupported();
|
||||
Objects.requireNonNull(endOn, "Agreed end date is required.");
|
||||
if (endOn.isBefore(period.startDate())) {
|
||||
throw new LeaseRuleViolation("La fin convenue ne peut pas précéder le début du bail.");
|
||||
}
|
||||
Optional<LocalDate> currentEnd = contractEndOn();
|
||||
if (currentEnd.isPresent() && !endOn.isBefore(currentEnd.orElseThrow())) {
|
||||
throw new LeaseRuleViolation("La fin convenue doit avancer la fin de bail actuelle.");
|
||||
}
|
||||
return endingHistory.agree(endOn, reason, audit);
|
||||
}
|
||||
|
||||
public NoticeEvidenceVersion appendNoticeEvidence(TenantNoticeId noticeId, NoticeEvidenceFile file,
|
||||
String reason, LeaseActionAudit audit) {
|
||||
if (status == LeaseStatus.DRAFT) {
|
||||
throw new LeaseRuleViolation("Un justificatif ne peut être ajouté qu'à un bail activé.");
|
||||
}
|
||||
return endingHistory.appendEvidence(noticeId, file, reason, audit);
|
||||
}
|
||||
|
||||
public LeaseClosure close(LocalDate keysReturnedOn, boolean confirmed, LocalDate today,
|
||||
LeaseActionAudit audit) {
|
||||
requireActiveEndingSupported();
|
||||
if (!confirmed) {
|
||||
throw new LeaseRuleViolation("La clôture du bail doit être explicitement confirmée.");
|
||||
}
|
||||
LocalDate endOn = contractEndOn()
|
||||
.orElseThrow(() -> new LeaseRuleViolation("Le bail n'a pas encore de fin contractuelle."));
|
||||
Objects.requireNonNull(keysReturnedOn, "Keys return date is required.");
|
||||
Objects.requireNonNull(today, "Current civil date is required.");
|
||||
if (today.isBefore(endOn)) {
|
||||
throw new LeaseRuleViolation("Le bail ne peut être clôturé avant sa fin contractuelle.");
|
||||
}
|
||||
if (keysReturnedOn.isBefore(period.startDate()) || keysReturnedOn.isAfter(today)) {
|
||||
throw new LeaseRuleViolation("La date de restitution des clés doit être comprise entre le début du bail et aujourd'hui.");
|
||||
}
|
||||
LeaseClosure closure = endingHistory.close(keysReturnedOn, audit);
|
||||
status = LeaseStatus.ARCHIVED;
|
||||
return closure;
|
||||
}
|
||||
|
||||
public boolean endingSupported() {
|
||||
return tenantIds.size() == 1;
|
||||
}
|
||||
|
||||
public Optional<LocalDate> fixedEndOn() {
|
||||
return leaseType == LeaseType.STUDENT ? Optional.of(period.endDate()) : Optional.empty();
|
||||
}
|
||||
|
||||
public Optional<LocalDate> contractEndOn() {
|
||||
return contractEndOn(leaseType, period);
|
||||
}
|
||||
|
||||
private Optional<LocalDate> contractEndOn(LeaseContractVersion version) {
|
||||
return contractEndOn(version.leaseType(), version.period());
|
||||
}
|
||||
|
||||
private Optional<LocalDate> contractEndOn(LeaseType type, RentalPeriod rentalPeriod) {
|
||||
Optional<LocalDate> end = type == LeaseType.STUDENT
|
||||
? Optional.of(rentalPeriod.endDate())
|
||||
: Optional.empty();
|
||||
Optional<TenantNotice> notice = endingHistory.activeNotice();
|
||||
if (notice.isPresent()) {
|
||||
end = earlier(end, notice.orElseThrow().effectiveEndOn());
|
||||
}
|
||||
Optional<AgreedEarlyLeaseEnd> agreement = endingHistory.latestAgreement();
|
||||
if (agreement.isPresent()) {
|
||||
end = earlier(end, agreement.orElseThrow().endOn());
|
||||
}
|
||||
return end;
|
||||
}
|
||||
|
||||
private boolean hasActiveEndingFact() {
|
||||
return endingHistory.activeNotice().isPresent() || endingHistory.latestAgreement().isPresent();
|
||||
}
|
||||
|
||||
public OccupancyPeriod occupancyPeriod() {
|
||||
Optional<LocalDate> endOn = contractEndOn();
|
||||
return endOn.isPresent()
|
||||
? OccupancyPeriod.closed(period.startDate(), endOn.orElseThrow())
|
||||
: OccupancyPeriod.open(period.startDate());
|
||||
}
|
||||
|
||||
public OccupancyPeriod scheduledOccupancyPeriod() {
|
||||
return leaseType == LeaseType.STUDENT
|
||||
? OccupancyPeriod.closed(period.startDate(), period.endDate())
|
||||
: OccupancyPeriod.open(period.startDate());
|
||||
}
|
||||
|
||||
public LeaseEndPhase endPhaseOn(LocalDate date) {
|
||||
Objects.requireNonNull(date, "Phase date is required.");
|
||||
if (status == LeaseStatus.ARCHIVED) {
|
||||
return LeaseEndPhase.ARCHIVED;
|
||||
}
|
||||
Optional<LocalDate> end = contractEndOn();
|
||||
if (end.isEmpty()) {
|
||||
return LeaseEndPhase.ACTIVE;
|
||||
}
|
||||
LocalDate endOn = end.orElseThrow();
|
||||
if (date.isAfter(endOn)) {
|
||||
return LeaseEndPhase.UNAUTHORIZED_OCCUPANCY;
|
||||
}
|
||||
if (date.equals(endOn)) {
|
||||
return LeaseEndPhase.AWAITING_CLOSURE;
|
||||
}
|
||||
return endingHistory.activeNotice().isPresent() || endingHistory.latestAgreement().isPresent()
|
||||
? LeaseEndPhase.NOTICE_PERIOD
|
||||
: LeaseEndPhase.ACTIVE;
|
||||
}
|
||||
|
||||
private void requireActiveEndingSupported() {
|
||||
if (status != LeaseStatus.ACTIVE) {
|
||||
throw new LeaseRuleViolation("Seul un bail actif peut recevoir un fait de fin de bail.");
|
||||
}
|
||||
requireEndingSupported();
|
||||
}
|
||||
|
||||
private void requireEndingSupported() {
|
||||
if (!endingSupported()) {
|
||||
throw new LeaseRuleViolation("La fin de bail locataire n'est pas prise en charge pour une colocation.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateAdministrativeFactDate(LocalDate factOn, LocalDate today,
|
||||
LocalDate noticeReceivedOn) {
|
||||
Objects.requireNonNull(factOn, "Administrative fact date is required.");
|
||||
Objects.requireNonNull(today, "Current civil date is required.");
|
||||
if (factOn.isBefore(noticeReceivedOn) || factOn.isAfter(today)) {
|
||||
throw new LeaseRuleViolation("La date administrative doit suivre le congé et ne peut pas être future.");
|
||||
}
|
||||
}
|
||||
|
||||
private static Optional<LocalDate> earlier(Optional<LocalDate> current, LocalDate candidate) {
|
||||
return current.isEmpty() || candidate.isBefore(current.orElseThrow()) ? Optional.of(candidate) : current;
|
||||
}
|
||||
|
||||
private void requireMatchingSnapshot(LeaseSnapshot frozenContract) {
|
||||
@@ -351,6 +551,10 @@ public final class Lease {
|
||||
return amendments.values();
|
||||
}
|
||||
|
||||
public LeaseEndingHistory endingHistory() {
|
||||
return endingHistory;
|
||||
}
|
||||
|
||||
public long version() {
|
||||
return version;
|
||||
}
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.lease;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
public record LeaseActionAudit(String authorId, Instant recordedAt) {
|
||||
public LeaseActionAudit {
|
||||
if (authorId == null || authorId.isBlank()) {
|
||||
throw new LeaseRuleViolation("L'auteur de l'action est obligatoire.");
|
||||
}
|
||||
authorId = authorId.trim();
|
||||
Objects.requireNonNull(recordedAt, "Action date is required.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.lease;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Objects;
|
||||
|
||||
public record LeaseClosure(LocalDate keysReturnedOn, LeaseActionAudit audit) {
|
||||
public LeaseClosure {
|
||||
Objects.requireNonNull(keysReturnedOn);
|
||||
Objects.requireNonNull(audit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.lease;
|
||||
|
||||
public enum LeaseEndPhase {
|
||||
ACTIVE,
|
||||
NOTICE_PERIOD,
|
||||
AWAITING_CLOSURE,
|
||||
UNAUTHORIZED_OCCUPANCY,
|
||||
ARCHIVED
|
||||
}
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.lease;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
public final class LeaseEndingHistory {
|
||||
private List<TenantNotice> notices;
|
||||
private List<NoticeWithdrawal> withdrawals;
|
||||
private List<NoticeInvalidation> invalidations;
|
||||
private List<AgreedEarlyLeaseEnd> agreedEarlyEnds;
|
||||
private List<NoticeEvidenceVersion> evidenceVersions;
|
||||
private LeaseClosure closure;
|
||||
|
||||
private LeaseEndingHistory(List<TenantNotice> notices, List<NoticeWithdrawal> withdrawals,
|
||||
List<NoticeInvalidation> invalidations,
|
||||
List<AgreedEarlyLeaseEnd> agreedEarlyEnds,
|
||||
List<NoticeEvidenceVersion> evidenceVersions, LeaseClosure closure) {
|
||||
this.notices = copy(notices);
|
||||
this.withdrawals = copy(withdrawals);
|
||||
this.invalidations = copy(invalidations);
|
||||
this.agreedEarlyEnds = copy(agreedEarlyEnds);
|
||||
this.evidenceVersions = copy(evidenceVersions);
|
||||
this.closure = closure;
|
||||
validateHistory();
|
||||
}
|
||||
|
||||
public static LeaseEndingHistory empty() {
|
||||
return new LeaseEndingHistory(List.of(), List.of(), List.of(), List.of(), List.of(), null);
|
||||
}
|
||||
|
||||
public static LeaseEndingHistory reconstitute(List<TenantNotice> notices,
|
||||
List<NoticeWithdrawal> withdrawals,
|
||||
List<NoticeInvalidation> invalidations,
|
||||
List<AgreedEarlyLeaseEnd> agreedEarlyEnds,
|
||||
List<NoticeEvidenceVersion> evidenceVersions,
|
||||
LeaseClosure closure) {
|
||||
return new LeaseEndingHistory(notices, withdrawals, invalidations, agreedEarlyEnds, evidenceVersions,
|
||||
closure);
|
||||
}
|
||||
|
||||
TenantNotice recordNotice(TenantNotice notice) {
|
||||
Objects.requireNonNull(notice);
|
||||
if (find(notice.id()).isPresent()) {
|
||||
throw new LeaseRuleViolation("Ce congé existe déjà.");
|
||||
}
|
||||
if (activeNotice().isPresent()) {
|
||||
throw new LeaseRuleViolation("Un congé locataire est déjà actif pour ce bail.");
|
||||
}
|
||||
notices = appended(notices, notice);
|
||||
return notice;
|
||||
}
|
||||
|
||||
NoticeWithdrawal withdraw(TenantNoticeId noticeId, LocalDate acceptedOn, String reason,
|
||||
LeaseActionAudit audit) {
|
||||
requireActive(noticeId);
|
||||
NoticeWithdrawal withdrawal = new NoticeWithdrawal(noticeId, acceptedOn, reason, audit);
|
||||
withdrawals = appended(withdrawals, withdrawal);
|
||||
return withdrawal;
|
||||
}
|
||||
|
||||
NoticeInvalidation invalidate(TenantNoticeId noticeId, LocalDate invalidatedOn, String reason,
|
||||
LeaseActionAudit audit) {
|
||||
requireActive(noticeId);
|
||||
NoticeInvalidation invalidation = new NoticeInvalidation(noticeId, invalidatedOn, reason, audit);
|
||||
invalidations = appended(invalidations, invalidation);
|
||||
return invalidation;
|
||||
}
|
||||
|
||||
AgreedEarlyLeaseEnd agree(LocalDate endOn, String reason, LeaseActionAudit audit) {
|
||||
AgreedEarlyLeaseEnd agreement = new AgreedEarlyLeaseEnd(endOn, reason, audit);
|
||||
agreedEarlyEnds = appended(agreedEarlyEnds, agreement);
|
||||
return agreement;
|
||||
}
|
||||
|
||||
NoticeEvidenceVersion appendEvidence(TenantNoticeId noticeId, NoticeEvidenceFile file, String reason,
|
||||
LeaseActionAudit audit) {
|
||||
if (find(noticeId).isEmpty()) {
|
||||
throw new LeaseRuleViolation("Le congé locataire est introuvable.");
|
||||
}
|
||||
int version = evidenceVersions(noticeId).size() + 1;
|
||||
NoticeEvidenceVersion evidence = new NoticeEvidenceVersion(noticeId, version, file, reason, audit);
|
||||
evidenceVersions = appended(evidenceVersions, evidence);
|
||||
return evidence;
|
||||
}
|
||||
|
||||
LeaseClosure close(LocalDate keysReturnedOn, LeaseActionAudit audit) {
|
||||
if (closure != null) {
|
||||
throw new LeaseRuleViolation("Le bail est déjà clôturé.");
|
||||
}
|
||||
closure = new LeaseClosure(keysReturnedOn, audit);
|
||||
return closure;
|
||||
}
|
||||
|
||||
public List<TenantNotice> notices() {
|
||||
return notices.stream().map(notice -> notice.withStatus(statusOf(notice.id()))).toList();
|
||||
}
|
||||
|
||||
List<TenantNotice> recordedNotices() {
|
||||
return notices;
|
||||
}
|
||||
|
||||
public List<NoticeWithdrawal> withdrawals() {
|
||||
return withdrawals;
|
||||
}
|
||||
|
||||
public List<NoticeInvalidation> invalidations() {
|
||||
return invalidations;
|
||||
}
|
||||
|
||||
public List<AgreedEarlyLeaseEnd> agreedEarlyEnds() {
|
||||
return agreedEarlyEnds;
|
||||
}
|
||||
|
||||
public List<NoticeEvidenceVersion> evidenceVersions() {
|
||||
return evidenceVersions;
|
||||
}
|
||||
|
||||
public List<NoticeEvidenceVersion> evidenceVersions(TenantNoticeId noticeId) {
|
||||
return evidenceVersions.stream().filter(version -> version.noticeId().equals(noticeId)).toList();
|
||||
}
|
||||
|
||||
public Optional<LeaseClosure> closure() {
|
||||
return Optional.ofNullable(closure);
|
||||
}
|
||||
|
||||
public Optional<TenantNotice> activeNotice() {
|
||||
return notices.stream().filter(notice -> statusOf(notice.id()) == TenantNoticeStatus.ACTIVE).findFirst();
|
||||
}
|
||||
|
||||
Optional<TenantNotice> notice(TenantNoticeId noticeId) {
|
||||
return find(noticeId);
|
||||
}
|
||||
|
||||
public Optional<AgreedEarlyLeaseEnd> latestAgreement() {
|
||||
return agreedEarlyEnds.isEmpty() ? Optional.empty() : Optional.of(agreedEarlyEnds.getLast());
|
||||
}
|
||||
|
||||
private Optional<TenantNotice> find(TenantNoticeId noticeId) {
|
||||
return notices.stream().filter(notice -> notice.id().equals(noticeId)).findFirst();
|
||||
}
|
||||
|
||||
private TenantNotice requireActive(TenantNoticeId noticeId) {
|
||||
TenantNotice notice = find(noticeId)
|
||||
.orElseThrow(() -> new LeaseRuleViolation("Le congé locataire est introuvable."));
|
||||
if (statusOf(noticeId) != TenantNoticeStatus.ACTIVE) {
|
||||
throw new LeaseRuleViolation("Seul un congé actif peut être modifié.");
|
||||
}
|
||||
return notice;
|
||||
}
|
||||
|
||||
private TenantNoticeStatus statusOf(TenantNoticeId noticeId) {
|
||||
if (invalidations.stream().anyMatch(fact -> fact.noticeId().equals(noticeId))) {
|
||||
return TenantNoticeStatus.INVALIDATED;
|
||||
}
|
||||
if (withdrawals.stream().anyMatch(fact -> fact.noticeId().equals(noticeId))) {
|
||||
return TenantNoticeStatus.WITHDRAWN;
|
||||
}
|
||||
return TenantNoticeStatus.ACTIVE;
|
||||
}
|
||||
|
||||
private void validateHistory() {
|
||||
if (notices.stream().map(TenantNotice::id).distinct().count() != notices.size()) {
|
||||
throw new LeaseRuleViolation("L'historique contient des congés en doublon.");
|
||||
}
|
||||
withdrawals.forEach(fact -> requireKnownNotice(fact.noticeId()));
|
||||
invalidations.forEach(fact -> requireKnownNotice(fact.noticeId()));
|
||||
evidenceVersions.forEach(fact -> requireKnownNotice(fact.noticeId()));
|
||||
notices.forEach(notice -> {
|
||||
long withdrawalsForNotice = withdrawals.stream()
|
||||
.filter(fact -> fact.noticeId().equals(notice.id())).count();
|
||||
long invalidationsForNotice = invalidations.stream()
|
||||
.filter(fact -> fact.noticeId().equals(notice.id())).count();
|
||||
if (withdrawalsForNotice > 1 || invalidationsForNotice > 1
|
||||
|| withdrawalsForNotice + invalidationsForNotice > 1) {
|
||||
throw new LeaseRuleViolation("Un congé ne peut avoir qu'une seule issue administrative.");
|
||||
}
|
||||
List<NoticeEvidenceVersion> versions = evidenceVersions(notice.id());
|
||||
for (int index = 0; index < versions.size(); index++) {
|
||||
if (versions.get(index).version() != index + 1) {
|
||||
throw new LeaseRuleViolation("Les versions de justificatif doivent être séquentielles.");
|
||||
}
|
||||
}
|
||||
});
|
||||
if (notices.stream().filter(notice -> statusOf(notice.id()) == TenantNoticeStatus.ACTIVE).count() > 1) {
|
||||
throw new LeaseRuleViolation("L'historique contient plusieurs congés actifs.");
|
||||
}
|
||||
}
|
||||
|
||||
private void requireKnownNotice(TenantNoticeId noticeId) {
|
||||
if (find(noticeId).isEmpty()) {
|
||||
throw new LeaseRuleViolation("Un fait de fin de bail référence un congé introuvable.");
|
||||
}
|
||||
}
|
||||
|
||||
static String requiredReason(String reason) {
|
||||
if (reason == null || reason.isBlank()) {
|
||||
throw new LeaseRuleViolation("La raison est obligatoire.");
|
||||
}
|
||||
String value = reason.trim();
|
||||
if (value.length() > 500) {
|
||||
throw new LeaseRuleViolation("La raison ne peut pas dépasser 500 caractères.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static <T> List<T> copy(List<T> values) {
|
||||
return values == null ? List.of() : List.copyOf(values);
|
||||
}
|
||||
|
||||
private static <T> List<T> appended(List<T> values, T value) {
|
||||
List<T> updated = new ArrayList<>(values);
|
||||
updated.add(value);
|
||||
return List.copyOf(updated);
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -2,6 +2,7 @@ package fr.nessar.lmnporganizer.rental.domain.lease;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.MonthDay;
|
||||
|
||||
public record LeaseLegalTerms(
|
||||
RentControlTerms rentControl,
|
||||
@@ -99,7 +100,7 @@ public record LeaseLegalTerms(
|
||||
}
|
||||
}
|
||||
|
||||
public record RentRevisionTerms(boolean enabled, LocalDate annualRevisionDate, Integer referenceQuarter,
|
||||
public record RentRevisionTerms(boolean enabled, MonthDay annualRevisionDay, Integer referenceQuarter,
|
||||
Integer referenceYear, String seriesId) {
|
||||
public RentRevisionTerms {
|
||||
seriesId = text(seriesId);
|
||||
@@ -109,13 +110,13 @@ public record LeaseLegalTerms(
|
||||
if (referenceYear != null && referenceYear <= 0) {
|
||||
throw new LeaseRuleViolation("L'année IRL de référence doit être positive.");
|
||||
}
|
||||
if (enabled && (annualRevisionDate == null || referenceQuarter == null || referenceYear == null
|
||||
if (enabled && (annualRevisionDay == null || referenceQuarter == null || referenceYear == null
|
||||
|| seriesId.isEmpty())) {
|
||||
throw new LeaseRuleViolation(
|
||||
"La date annuelle, le trimestre, l'année et la série IRL sont requis pour la révision.");
|
||||
"Le jour annuel, le trimestre, l'année et la série IRL sont requis pour la révision.");
|
||||
}
|
||||
if (!enabled) {
|
||||
annualRevisionDate = null;
|
||||
annualRevisionDay = null;
|
||||
referenceQuarter = null;
|
||||
referenceYear = null;
|
||||
seriesId = "";
|
||||
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.lease;
|
||||
|
||||
public enum NoticeDeliveryMode {
|
||||
REGISTERED_LETTER,
|
||||
BAILIFF,
|
||||
HAND_DELIVERY,
|
||||
OTHER
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.lease;
|
||||
|
||||
public record NoticeEvidenceFile(String storageKey, String fileName, String contentType, long size, String sha256) {
|
||||
public NoticeEvidenceFile {
|
||||
storageKey = required(storageKey, "La clé de stockage du justificatif est obligatoire.");
|
||||
fileName = required(fileName, "Le nom du justificatif est obligatoire.");
|
||||
contentType = required(contentType, "Le type de contenu du justificatif est obligatoire.");
|
||||
if (size <= 0) {
|
||||
throw new LeaseRuleViolation("Le justificatif doit être non vide.");
|
||||
}
|
||||
sha256 = required(sha256, "L'empreinte SHA-256 du justificatif est obligatoire.").toLowerCase();
|
||||
if (sha256.length() != 64 || !sha256.chars().allMatch(NoticeEvidenceFile::isHexadecimal)) {
|
||||
throw new LeaseRuleViolation("L'empreinte SHA-256 du justificatif est invalide.");
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isHexadecimal(int character) {
|
||||
return character >= '0' && character <= '9' || character >= 'a' && character <= 'f';
|
||||
}
|
||||
|
||||
private static String required(String value, String message) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new LeaseRuleViolation(message);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.lease;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public record NoticeEvidenceVersion(TenantNoticeId noticeId, int version, NoticeEvidenceFile file,
|
||||
String reason, LeaseActionAudit audit) {
|
||||
public NoticeEvidenceVersion {
|
||||
Objects.requireNonNull(noticeId);
|
||||
if (version <= 0) {
|
||||
throw new LeaseRuleViolation("La version du justificatif doit être positive.");
|
||||
}
|
||||
Objects.requireNonNull(file);
|
||||
reason = version > 1
|
||||
? LeaseEndingHistory.requiredReason(reason)
|
||||
: reason == null || reason.isBlank() ? null : LeaseEndingHistory.requiredReason(reason);
|
||||
Objects.requireNonNull(audit);
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.lease;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Objects;
|
||||
|
||||
public record NoticeInvalidation(TenantNoticeId noticeId, LocalDate invalidatedOn, String reason,
|
||||
LeaseActionAudit audit) {
|
||||
public NoticeInvalidation {
|
||||
Objects.requireNonNull(noticeId);
|
||||
Objects.requireNonNull(invalidatedOn);
|
||||
reason = LeaseEndingHistory.requiredReason(reason);
|
||||
Objects.requireNonNull(audit);
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.lease;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Objects;
|
||||
|
||||
public record NoticeWithdrawal(TenantNoticeId noticeId, LocalDate acceptedOn, String reason,
|
||||
LeaseActionAudit audit) {
|
||||
public NoticeWithdrawal {
|
||||
Objects.requireNonNull(noticeId);
|
||||
Objects.requireNonNull(acceptedOn);
|
||||
reason = LeaseEndingHistory.requiredReason(reason);
|
||||
Objects.requireNonNull(audit);
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.lease;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
public final class OccupancyPeriod {
|
||||
private final LocalDate startOn;
|
||||
private final LocalDate endOn;
|
||||
|
||||
private OccupancyPeriod(LocalDate startOn, LocalDate endOn) {
|
||||
this.startOn = Objects.requireNonNull(startOn, "Occupancy start date is required.");
|
||||
if (endOn != null && startOn.isAfter(endOn)) {
|
||||
throw new LeaseRuleViolation("La fin d'occupation doit suivre son début.");
|
||||
}
|
||||
this.endOn = endOn;
|
||||
}
|
||||
|
||||
public static OccupancyPeriod open(LocalDate startOn) {
|
||||
return new OccupancyPeriod(startOn, null);
|
||||
}
|
||||
|
||||
public static OccupancyPeriod closed(LocalDate startOn, LocalDate endOn) {
|
||||
return new OccupancyPeriod(startOn, Objects.requireNonNull(endOn));
|
||||
}
|
||||
|
||||
public static OccupancyPeriod closed(RentalPeriod period) {
|
||||
Objects.requireNonNull(period);
|
||||
return closed(period.startDate(), period.endDate());
|
||||
}
|
||||
|
||||
public boolean overlaps(OccupancyPeriod other) {
|
||||
Objects.requireNonNull(other);
|
||||
boolean thisEndsBeforeOther = endOn != null && endOn.isBefore(other.startOn);
|
||||
boolean otherEndsBeforeThis = other.endOn != null && other.endOn.isBefore(startOn);
|
||||
return !thisEndsBeforeOther && !otherEndsBeforeThis;
|
||||
}
|
||||
|
||||
public LocalDate startOn() {
|
||||
return startOn;
|
||||
}
|
||||
|
||||
public LocalDate startDate() {
|
||||
return startOn;
|
||||
}
|
||||
|
||||
public Optional<LocalDate> endOn() {
|
||||
return Optional.ofNullable(endOn);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
return other instanceof OccupancyPeriod period
|
||||
&& startOn.equals(period.startOn)
|
||||
&& Objects.equals(endOn, period.endOn);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(startOn, endOn);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.lease;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Objects;
|
||||
|
||||
public record TenantNotice(
|
||||
TenantNoticeId id,
|
||||
TenantNoticeStatus status,
|
||||
LocalDate receivedOn,
|
||||
LocalDate requestedDepartureOn,
|
||||
NoticeDeliveryMode deliveryMode,
|
||||
String otherDeliveryMode,
|
||||
int noticeMonths,
|
||||
LocalDate minimumEndOn,
|
||||
String ruleVersion,
|
||||
boolean recordedWithoutEvidenceConfirmed,
|
||||
LeaseActionAudit audit) {
|
||||
|
||||
public TenantNotice {
|
||||
Objects.requireNonNull(id);
|
||||
Objects.requireNonNull(status);
|
||||
Objects.requireNonNull(receivedOn);
|
||||
Objects.requireNonNull(requestedDepartureOn);
|
||||
Objects.requireNonNull(deliveryMode);
|
||||
Objects.requireNonNull(minimumEndOn);
|
||||
Objects.requireNonNull(audit);
|
||||
if (noticeMonths <= 0) {
|
||||
throw new LeaseRuleViolation("La durée de préavis doit être positive.");
|
||||
}
|
||||
if (ruleVersion == null || ruleVersion.isBlank()) {
|
||||
throw new LeaseRuleViolation("La version de règle du préavis est obligatoire.");
|
||||
}
|
||||
ruleVersion = ruleVersion.trim();
|
||||
otherDeliveryMode = optional(otherDeliveryMode);
|
||||
if (otherDeliveryMode != null && otherDeliveryMode.length() > 200) {
|
||||
throw new LeaseRuleViolation("Le libellé du mode de remise ne peut pas dépasser 200 caractères.");
|
||||
}
|
||||
if (deliveryMode == NoticeDeliveryMode.OTHER && otherDeliveryMode == null) {
|
||||
throw new LeaseRuleViolation("Le mode de remise alternatif est obligatoire.");
|
||||
}
|
||||
if (deliveryMode != NoticeDeliveryMode.OTHER && otherDeliveryMode != null) {
|
||||
throw new LeaseRuleViolation("Le détail du mode de remise est réservé au mode OTHER.");
|
||||
}
|
||||
}
|
||||
|
||||
public LocalDate effectiveEndOn() {
|
||||
return requestedDepartureOn.isAfter(minimumEndOn) ? requestedDepartureOn : minimumEndOn;
|
||||
}
|
||||
|
||||
TenantNotice withStatus(TenantNoticeStatus updatedStatus) {
|
||||
return new TenantNotice(id, updatedStatus, receivedOn, requestedDepartureOn, deliveryMode,
|
||||
otherDeliveryMode, noticeMonths, minimumEndOn, ruleVersion, recordedWithoutEvidenceConfirmed,
|
||||
audit);
|
||||
}
|
||||
|
||||
private static String optional(String value) {
|
||||
return value == null || value.isBlank() ? null : value.trim();
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.lease;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Objects;
|
||||
|
||||
public record TenantNoticeCommand(
|
||||
TenantNoticeId id,
|
||||
LocalDate receivedOn,
|
||||
LocalDate requestedDepartureOn,
|
||||
NoticeDeliveryMode deliveryMode,
|
||||
String otherDeliveryMode,
|
||||
boolean lateRegistrationConfirmed,
|
||||
boolean nonStandardDeliveryConfirmed,
|
||||
boolean recordedWithoutEvidenceConfirmed,
|
||||
LocalDate today) {
|
||||
public TenantNoticeCommand(TenantNoticeId id, LocalDate receivedOn, LocalDate requestedDepartureOn,
|
||||
NoticeDeliveryMode deliveryMode, String otherDeliveryMode,
|
||||
boolean lateRegistrationConfirmed, boolean nonStandardDeliveryConfirmed,
|
||||
LocalDate today) {
|
||||
this(id, receivedOn, requestedDepartureOn, deliveryMode, otherDeliveryMode, lateRegistrationConfirmed,
|
||||
nonStandardDeliveryConfirmed, false, today);
|
||||
}
|
||||
|
||||
public TenantNoticeCommand {
|
||||
Objects.requireNonNull(id);
|
||||
Objects.requireNonNull(receivedOn);
|
||||
Objects.requireNonNull(requestedDepartureOn);
|
||||
Objects.requireNonNull(deliveryMode);
|
||||
Objects.requireNonNull(today);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.lease;
|
||||
|
||||
public record TenantNoticeId(String value) {
|
||||
public TenantNoticeId {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException("Tenant notice ID is required.");
|
||||
}
|
||||
value = value.trim();
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.lease;
|
||||
|
||||
public enum TenantNoticeStatus {
|
||||
ACTIVE,
|
||||
WITHDRAWN,
|
||||
INVALIDATED
|
||||
}
|
||||
@@ -17,10 +17,18 @@ public final class RentDue {
|
||||
private BigDecimal rent;
|
||||
private BigDecimal charges;
|
||||
private BigDecimal allocatedAmount;
|
||||
private BigDecimal rentBeforeLeaseEnd;
|
||||
private BigDecimal chargesBeforeLeaseEnd;
|
||||
private final long version;
|
||||
|
||||
private RentDue(RentDueId id, LandlordId landlordId, LeaseId leaseId, YearMonth month, LocalDate dueDate,
|
||||
BigDecimal rent, BigDecimal charges, BigDecimal allocatedAmount, long version) {
|
||||
this(id, landlordId, leaseId, month, dueDate, rent, charges, allocatedAmount, null, null, version);
|
||||
}
|
||||
|
||||
private RentDue(RentDueId id, LandlordId landlordId, LeaseId leaseId, YearMonth month, LocalDate dueDate,
|
||||
BigDecimal rent, BigDecimal charges, BigDecimal allocatedAmount,
|
||||
BigDecimal rentBeforeLeaseEnd, BigDecimal chargesBeforeLeaseEnd, long version) {
|
||||
this.id = Objects.requireNonNull(id);
|
||||
this.landlordId = Objects.requireNonNull(landlordId);
|
||||
this.leaseId = Objects.requireNonNull(leaseId);
|
||||
@@ -29,11 +37,19 @@ public final class RentDue {
|
||||
this.rent = nonNegative(rent, "Rent");
|
||||
this.charges = nonNegative(charges, "Charges");
|
||||
this.allocatedAmount = nonNegative(allocatedAmount, "Allocated amount");
|
||||
if (this.rent.add(this.charges).signum() <= 0) {
|
||||
throw new RentRuleViolation("Une échéance doit avoir un montant strictement positif.");
|
||||
if ((rentBeforeLeaseEnd == null) != (chargesBeforeLeaseEnd == null)) {
|
||||
throw new RentRuleViolation("Les montants antérieurs à la fin de bail doivent être complets.");
|
||||
}
|
||||
if (this.allocatedAmount.compareTo(total()) > 0) {
|
||||
throw new RentRuleViolation("Une échéance ne peut pas être sur-réglée.");
|
||||
this.rentBeforeLeaseEnd = rentBeforeLeaseEnd == null ? null : nonNegative(rentBeforeLeaseEnd, "Previous rent");
|
||||
this.chargesBeforeLeaseEnd = chargesBeforeLeaseEnd == null
|
||||
? null : nonNegative(chargesBeforeLeaseEnd, "Previous charges");
|
||||
if (this.rentBeforeLeaseEnd == null && (total().signum() <= 0
|
||||
|| this.allocatedAmount.compareTo(total()) > 0)) {
|
||||
throw new RentRuleViolation("Une échéance normale doit être positive et ne peut pas être sur-réglée.");
|
||||
}
|
||||
if (this.rentBeforeLeaseEnd != null
|
||||
&& this.rentBeforeLeaseEnd.add(this.chargesBeforeLeaseEnd).signum() <= 0) {
|
||||
throw new RentRuleViolation("Le montant antérieur à la fin de bail doit être positif.");
|
||||
}
|
||||
if (version < -1) {
|
||||
throw new IllegalArgumentException("Rent due version cannot be less than -1.");
|
||||
@@ -43,7 +59,11 @@ public final class RentDue {
|
||||
|
||||
public static RentDue create(RentDueId id, LandlordId landlordId, LeaseId leaseId, YearMonth month,
|
||||
LocalDate dueDate, BigDecimal rent, BigDecimal charges) {
|
||||
return new RentDue(id, landlordId, leaseId, month, dueDate, rent, charges, BigDecimal.ZERO, -1);
|
||||
RentDue due = new RentDue(id, landlordId, leaseId, month, dueDate, rent, charges, BigDecimal.ZERO, -1);
|
||||
if (due.total().signum() <= 0) {
|
||||
throw new RentRuleViolation("Une échéance doit avoir un montant strictement positif.");
|
||||
}
|
||||
return due;
|
||||
}
|
||||
|
||||
public static RentDue reconstitute(RentDueId id, LandlordId landlordId, LeaseId leaseId, YearMonth month,
|
||||
@@ -52,6 +72,14 @@ public final class RentDue {
|
||||
return new RentDue(id, landlordId, leaseId, month, dueDate, rent, charges, allocatedAmount, version);
|
||||
}
|
||||
|
||||
public static RentDue reconstitute(RentDueId id, LandlordId landlordId, LeaseId leaseId, YearMonth month,
|
||||
LocalDate dueDate, BigDecimal rent, BigDecimal charges,
|
||||
BigDecimal allocatedAmount, BigDecimal rentBeforeLeaseEnd,
|
||||
BigDecimal chargesBeforeLeaseEnd, long version) {
|
||||
return new RentDue(id, landlordId, leaseId, month, dueDate, rent, charges, allocatedAmount,
|
||||
rentBeforeLeaseEnd, chargesBeforeLeaseEnd, version);
|
||||
}
|
||||
|
||||
public void allocate(BigDecimal amount) {
|
||||
BigDecimal allocation = positive(amount, "Allocation");
|
||||
if (allocation.compareTo(remainingAmount()) > 0) {
|
||||
@@ -90,6 +118,43 @@ public final class RentDue {
|
||||
this.charges = revisedCharges;
|
||||
}
|
||||
|
||||
void reviseForLeaseEnd(BigDecimal rent, BigDecimal charges) {
|
||||
if (rentBeforeLeaseEnd == null) {
|
||||
rentBeforeLeaseEnd = this.rent;
|
||||
chargesBeforeLeaseEnd = this.charges;
|
||||
}
|
||||
this.rent = nonNegative(rent, "Rent");
|
||||
this.charges = nonNegative(charges, "Charges");
|
||||
}
|
||||
|
||||
boolean restoreBeforeLeaseEnd() {
|
||||
if (rentBeforeLeaseEnd == null) {
|
||||
return false;
|
||||
}
|
||||
rent = rentBeforeLeaseEnd;
|
||||
charges = chargesBeforeLeaseEnd;
|
||||
rentBeforeLeaseEnd = null;
|
||||
chargesBeforeLeaseEnd = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
void reviseUnderlyingTerms(LocalDate dueDate, BigDecimal rent, BigDecimal charges) {
|
||||
if (!adjustedForLeaseEnd()) {
|
||||
reviseTerms(dueDate, rent, charges);
|
||||
return;
|
||||
}
|
||||
this.dueDate = Objects.requireNonNull(dueDate, "Due date is required.");
|
||||
rentBeforeLeaseEnd = nonNegative(rent, "Rent");
|
||||
chargesBeforeLeaseEnd = nonNegative(charges, "Charges");
|
||||
if (rentBeforeLeaseEnd.add(chargesBeforeLeaseEnd).signum() <= 0) {
|
||||
throw new RentRuleViolation("Le montant sous-jacent doit être positif.");
|
||||
}
|
||||
}
|
||||
|
||||
void reviseUnderlyingAmounts(BigDecimal rent, BigDecimal charges) {
|
||||
reviseUnderlyingTerms(dueDate, rent, charges);
|
||||
}
|
||||
|
||||
public boolean hasAllocations() {
|
||||
return allocatedAmount.signum() > 0;
|
||||
}
|
||||
@@ -99,14 +164,27 @@ public final class RentDue {
|
||||
}
|
||||
|
||||
public BigDecimal remainingAmount() {
|
||||
return total().subtract(allocatedAmount);
|
||||
return total().subtract(allocatedAmount).max(BigDecimal.ZERO);
|
||||
}
|
||||
|
||||
public boolean correctionRequired() {
|
||||
return allocatedAmount.compareTo(total()) > 0;
|
||||
}
|
||||
|
||||
public boolean adjustedForLeaseEnd() {
|
||||
return rentBeforeLeaseEnd != null;
|
||||
}
|
||||
|
||||
public RentDueSettlement settlement() {
|
||||
if (total().signum() == 0) {
|
||||
return RentDueSettlement.PAID;
|
||||
}
|
||||
if (allocatedAmount.signum() == 0) {
|
||||
return RentDueSettlement.UNPAID;
|
||||
}
|
||||
return remainingAmount().signum() == 0 ? RentDueSettlement.PAID : RentDueSettlement.PARTIALLY_PAID;
|
||||
return allocatedAmount.compareTo(total()) >= 0
|
||||
? RentDueSettlement.PAID
|
||||
: RentDueSettlement.PARTIALLY_PAID;
|
||||
}
|
||||
|
||||
public RentDueTiming timingOn(LocalDate date) {
|
||||
@@ -140,5 +218,7 @@ public final class RentDue {
|
||||
public BigDecimal rent() { return rent; }
|
||||
public BigDecimal charges() { return charges; }
|
||||
public BigDecimal allocatedAmount() { return allocatedAmount; }
|
||||
public BigDecimal rentBeforeLeaseEnd() { return rentBeforeLeaseEnd; }
|
||||
public BigDecimal chargesBeforeLeaseEnd() { return chargesBeforeLeaseEnd; }
|
||||
public long version() { return version; }
|
||||
}
|
||||
+103
-7
@@ -5,6 +5,7 @@ import fr.nessar.lmnporganizer.rental.domain.lease.LeaseAmended;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseContractVersion;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseType;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.OccupancyPeriod;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
@@ -15,6 +16,8 @@ import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Optional;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Function;
|
||||
|
||||
@@ -100,7 +103,7 @@ public final class RentSchedule {
|
||||
}
|
||||
Target target = targets.remove(entry.getKey());
|
||||
RentDue due = entry.getValue();
|
||||
if (due.settlement() == RentDueSettlement.PAID) {
|
||||
if (due.settlement() == RentDueSettlement.PAID && !due.adjustedForLeaseEnd()) {
|
||||
continue;
|
||||
}
|
||||
if (target == null) {
|
||||
@@ -109,9 +112,9 @@ public final class RentSchedule {
|
||||
}
|
||||
toRemove.add(due);
|
||||
} else if (!due.dueDate().equals(target.dueDate())
|
||||
|| due.rent().compareTo(target.rent()) != 0
|
||||
|| due.charges().compareTo(target.charges()) != 0) {
|
||||
due.reviseTerms(target.dueDate(), target.rent(), target.charges());
|
||||
|| underlyingRent(due).compareTo(target.rent()) != 0
|
||||
|| underlyingCharges(due).compareTo(target.charges()) != 0) {
|
||||
due.reviseUnderlyingTerms(target.dueDate(), target.rent(), target.charges());
|
||||
toSave.add(due);
|
||||
}
|
||||
}
|
||||
@@ -120,6 +123,76 @@ public final class RentSchedule {
|
||||
return new Reconciliation(toSave, toRemove);
|
||||
}
|
||||
|
||||
public static EndReconciliation reconcileEnd(List<RentDue> existingDues,
|
||||
OccupancyPeriod scheduledOccupancy, LocalDate endOn) {
|
||||
return reconcileEnd(existingDues, scheduledOccupancy, endOn, null);
|
||||
}
|
||||
|
||||
public static EndReconciliation reconcileEnd(List<RentDue> existingDues,
|
||||
OccupancyPeriod scheduledOccupancy, LocalDate endOn,
|
||||
EndAmounts finalMonthAmounts) {
|
||||
Objects.requireNonNull(endOn, "Lease end date is required.");
|
||||
Objects.requireNonNull(scheduledOccupancy, "Scheduled occupancy is required.");
|
||||
YearMonth finalMonth = YearMonth.from(endOn);
|
||||
List<RentDue> toSave = new ArrayList<>();
|
||||
List<RentDue> toRemove = new ArrayList<>();
|
||||
for (RentDue due : existingDues == null ? List.<RentDue>of() : existingDues) {
|
||||
if (due.month().isBefore(finalMonth)) {
|
||||
continue;
|
||||
}
|
||||
if (due.month().equals(finalMonth)) {
|
||||
int originalDays = occupiedDays(later(finalMonth.atDay(1), scheduledOccupancy.startOn()),
|
||||
earlier(finalMonth.atEndOfMonth(), scheduledOccupancy.endOn()
|
||||
.orElse(finalMonth.atEndOfMonth())));
|
||||
int targetDays = occupiedDays(later(finalMonth.atDay(1), scheduledOccupancy.startOn()),
|
||||
earlier(endOn, scheduledOccupancy.endOn().orElse(endOn)));
|
||||
BigDecimal baseRent = due.rentBeforeLeaseEnd() == null ? due.rent() : due.rentBeforeLeaseEnd();
|
||||
BigDecimal baseCharges = due.chargesBeforeLeaseEnd() == null
|
||||
? due.charges() : due.chargesBeforeLeaseEnd();
|
||||
BigDecimal rent = finalMonthAmounts == null
|
||||
? prorate(baseRent, targetDays, originalDays)
|
||||
: finalMonthAmounts.rent();
|
||||
BigDecimal charges = finalMonthAmounts == null
|
||||
? prorate(baseCharges, targetDays, originalDays)
|
||||
: finalMonthAmounts.charges();
|
||||
if (due.rent().compareTo(rent) != 0 || due.charges().compareTo(charges) != 0) {
|
||||
due.reviseForLeaseEnd(rent, charges);
|
||||
toSave.add(due);
|
||||
}
|
||||
} else {
|
||||
if (due.total().signum() != 0 || !due.adjustedForLeaseEnd()) {
|
||||
due.reviseForLeaseEnd(BigDecimal.ZERO, BigDecimal.ZERO);
|
||||
toSave.add(due);
|
||||
}
|
||||
}
|
||||
}
|
||||
boolean correctionRequired = toSave.stream().anyMatch(RentDue::correctionRequired);
|
||||
return new EndReconciliation(toSave, toRemove, correctionRequired);
|
||||
}
|
||||
|
||||
public static EndReconciliation restoreAfterEndExpansion(List<RentDue> existingDues,
|
||||
OccupancyPeriod scheduledOccupancy,
|
||||
Optional<LocalDate> expandedEndOn) {
|
||||
return restoreAfterEndExpansion(existingDues, scheduledOccupancy, expandedEndOn, null);
|
||||
}
|
||||
|
||||
public static EndReconciliation restoreAfterEndExpansion(List<RentDue> existingDues,
|
||||
OccupancyPeriod scheduledOccupancy,
|
||||
Optional<LocalDate> expandedEndOn,
|
||||
EndAmounts finalMonthAmounts) {
|
||||
List<RentDue> dues = existingDues == null ? List.of() : existingDues;
|
||||
LinkedHashSet<RentDue> changed = new LinkedHashSet<>();
|
||||
dues.stream().filter(RentDue::restoreBeforeLeaseEnd).forEach(changed::add);
|
||||
boolean correctionRequired = false;
|
||||
if (expandedEndOn.isPresent()) {
|
||||
EndReconciliation bounded = reconcileEnd(
|
||||
dues, scheduledOccupancy, expandedEndOn.orElseThrow(), finalMonthAmounts);
|
||||
changed.addAll(bounded.toSave());
|
||||
correctionRequired = bounded.correctionRequired();
|
||||
}
|
||||
return new EndReconciliation(List.copyOf(changed), List.of(), correctionRequired);
|
||||
}
|
||||
|
||||
public static List<RentDue> applyRevision(List<RentDue> existingDues, LocalDate effectiveDate,
|
||||
RevisionAmounts amounts) {
|
||||
return applyRevision(existingDues, effectiveDate, amounts, null);
|
||||
@@ -136,15 +209,15 @@ public final class RentSchedule {
|
||||
List<RentDue> changed = new ArrayList<>();
|
||||
for (RentDue due : existingDues == null ? List.<RentDue>of() : existingDues) {
|
||||
if (due.month().isBefore(effectiveMonth) || afterBoundary(due.month(), boundary)
|
||||
|| due.settlement() == RentDueSettlement.PAID) {
|
||||
|| due.settlement() == RentDueSettlement.PAID && !due.adjustedForLeaseEnd()) {
|
||||
continue;
|
||||
}
|
||||
BigDecimal rent = revisedAmount(due.month(), effectiveDate, amounts.previousRent(),
|
||||
amounts.revisedRent(), boundary == null ? null : boundary.rent(), boundary);
|
||||
BigDecimal charges = revisedAmount(due.month(), effectiveDate, amounts.previousCharges(),
|
||||
amounts.revisedCharges(), boundary == null ? null : boundary.charges(), boundary);
|
||||
if (due.rent().compareTo(rent) != 0 || due.charges().compareTo(charges) != 0) {
|
||||
due.reviseAmounts(rent, charges);
|
||||
if (underlyingRent(due).compareTo(rent) != 0 || underlyingCharges(due).compareTo(charges) != 0) {
|
||||
due.reviseUnderlyingAmounts(rent, charges);
|
||||
changed.add(due);
|
||||
}
|
||||
}
|
||||
@@ -155,6 +228,14 @@ public final class RentSchedule {
|
||||
return boundary != null && month.isAfter(YearMonth.from(boundary.effectiveDate()));
|
||||
}
|
||||
|
||||
private static BigDecimal underlyingRent(RentDue due) {
|
||||
return due.rentBeforeLeaseEnd() == null ? due.rent() : due.rentBeforeLeaseEnd();
|
||||
}
|
||||
|
||||
private static BigDecimal underlyingCharges(RentDue due) {
|
||||
return due.chargesBeforeLeaseEnd() == null ? due.charges() : due.chargesBeforeLeaseEnd();
|
||||
}
|
||||
|
||||
private static BigDecimal revisedAmount(YearMonth month, LocalDate effectiveDate, BigDecimal previous,
|
||||
BigDecimal revised, BigDecimal following,
|
||||
FinancialBoundary boundary) {
|
||||
@@ -253,6 +334,21 @@ public final class RentSchedule {
|
||||
}
|
||||
}
|
||||
|
||||
public record EndReconciliation(List<RentDue> toSave, List<RentDue> toRemove,
|
||||
boolean correctionRequired) {
|
||||
public EndReconciliation {
|
||||
toSave = List.copyOf(toSave);
|
||||
toRemove = List.copyOf(toRemove);
|
||||
}
|
||||
}
|
||||
|
||||
public record EndAmounts(BigDecimal rent, BigDecimal charges) {
|
||||
public EndAmounts {
|
||||
rent = RevisionAmounts.nonNegative(rent, "Final month rent");
|
||||
charges = RevisionAmounts.nonNegative(charges, "Final month charges");
|
||||
}
|
||||
}
|
||||
|
||||
public record RevisionAmounts(BigDecimal previousRent, BigDecimal previousCharges,
|
||||
BigDecimal revisedRent, BigDecimal revisedCharges) {
|
||||
public RevisionAmounts {
|
||||
|
||||
@@ -19,3 +19,148 @@ clauses:
|
||||
template: >-
|
||||
La détention d'un chien de première catégorie au sens de l'article L. 211-12 du code rural
|
||||
et de la pêche maritime est interdite dans le logement.
|
||||
- id: RENT_FREE_WORKS
|
||||
label: Franchise de loyer pour travaux d'embellissement
|
||||
description: Accorde une réduction de loyer temporaire en contrepartie de travaux d'embellissement du locataire.
|
||||
contexts: [ALL]
|
||||
template: >-
|
||||
En contrepartie des travaux de {{worksDescription}} que le locataire s'engage à réaliser à ses frais exclusifs
|
||||
et conformément aux règles de l'art dans le logement, le bailleur lui accorde une réduction mensuelle de loyer de
|
||||
{{discountAmount}} euros, pour une durée de {{durationMonths}} mois à compter du {{startDate}}. Les charges
|
||||
locatives restent dues en totalité durant cette période.
|
||||
parameters:
|
||||
- name: worksDescription
|
||||
label: Description des travaux
|
||||
type: TEXT
|
||||
required: true
|
||||
- name: discountAmount
|
||||
label: Montant mensuel de la réduction (en €)
|
||||
type: NUMBER
|
||||
required: true
|
||||
- name: durationMonths
|
||||
label: Durée de la réduction (en mois)
|
||||
type: NUMBER
|
||||
required: true
|
||||
- name: startDate
|
||||
label: Date de début de la réduction
|
||||
type: DATE
|
||||
required: true
|
||||
- id: JOINT_AND_SEVERAL_LIABILITY
|
||||
label: Solidarité des colocataires
|
||||
description: Rend les colocataires solidaires du paiement du loyer et des charges.
|
||||
contexts: [COLOCATION]
|
||||
template: >-
|
||||
Les colocataires sont tenus solidairement et indivisiblement de l'exécution de toutes les obligations
|
||||
du présent contrat, notamment du paiement du loyer, des charges et des éventuelles réparations locatives.
|
||||
Conformément à l'article 8-1 de la loi du 6 juillet 1989, la solidarité d'un colocataire sortant prend fin
|
||||
à la date d'effet de son congé si un nouveau colocataire le remplace, ou à défaut à l'expiration d'un délai
|
||||
de six mois après la date d'effet de son congé.
|
||||
- id: TENANT_INSURANCE
|
||||
label: Assurance habitation et défaut de justification
|
||||
description: Rappelle l'obligation d'assurance et permet au bailleur de souscrire une assurance pour le compte du locataire.
|
||||
contexts: [ALL]
|
||||
template: >-
|
||||
Le locataire s'oblige à s'assurer contre les risques locatifs dont il doit répondre en sa qualité de locataire
|
||||
et à en justifier lors de la remise des clés puis chaque année. À défaut de présentation d'une attestation d'assurance
|
||||
un mois après une mise en demeure restée infructueuse, le bailleur pourra souscrire une assurance pour son compte.
|
||||
Dans ce cas, le montant de la prime annuelle, éventuellement majoré de {{feePercent}}% pour frais de gestion dans la limite
|
||||
du plafond légal, sera récupérable auprès du locataire par douzième chaque mois.
|
||||
parameters:
|
||||
- name: feePercent
|
||||
label: Pourcentage de majoration pour frais de gestion (max 10%)
|
||||
type: NUMBER
|
||||
required: true
|
||||
- id: CDC_CONCILIATION
|
||||
label: Recours préalable à la Commission Départementale de Conciliation
|
||||
description: Prévoit la saisine de la CDC avant toute procédure judiciaire.
|
||||
contexts: [ALL]
|
||||
template: >-
|
||||
En cas de litige relatif aux loyers, charges, réparations, ou caractéristiques du logement, les parties s'engagent,
|
||||
avant toute saisine du tribunal judiciaire et conformément aux dispositions légales, à tenter de concilier leur différend
|
||||
en saisissant la Commission Départementale de Conciliation (CDC) du département de {{propertyDepartment}}.
|
||||
parameters:
|
||||
- name: propertyDepartment
|
||||
label: "Département du logement (ex: Paris)"
|
||||
type: TEXT
|
||||
required: true
|
||||
- id: RESOLUTORY_CLAUSE
|
||||
label: Clause résolutoire
|
||||
description: Permet la résiliation de plein droit du bail en cas de manquement grave du locataire (non-paiement, défaut d'assurance).
|
||||
contexts: [ALL]
|
||||
template: >-
|
||||
Le présent contrat sera résilié de plein droit, sans qu'il soit besoin de remplir aucune formalité judiciaire,
|
||||
un mois après un commandement de payer demeuré infructueux pour défaut de paiement du dépôt de garantie ou des
|
||||
loyers et charges, ou un mois après une mise en demeure restée infructueuse pour défaut d'assurance des risques locatifs.
|
||||
- id: MAINTENANCE_AND_REPAIRS
|
||||
label: Entretien et réparations locatives
|
||||
description: Rappelle les obligations d'entretien courant et de menues réparations à la charge du locataire.
|
||||
contexts: [ALL]
|
||||
template: >-
|
||||
Le locataire est tenu de prendre à sa charge l'entretien courant du logement, des équipements mentionnés au contrat
|
||||
ainsi que l'ensemble des menues réparations locatives définies par le décret n° 87-712 du 26 août 1987, sauf si elles
|
||||
sont occasionnées par vétusté, malfaçon ou force majeure.
|
||||
- id: ANNUAL_INSPECTION_RIGHT
|
||||
label: Droit de visite annuelle de contrôle
|
||||
description: Autorise le bailleur à visiter le logement une fois par an avec un préavis pour vérifier son état d'entretien.
|
||||
contexts: [ALL]
|
||||
template: >-
|
||||
Le bailleur ou son mandataire pourra visiter le logement loué une fois par an afin de s'assurer de son bon état
|
||||
d'entretien et de fonctionnement des équipements. Les modalités de cette visite (date, heure) seront convenues
|
||||
à l'avance avec le locataire avec un préavis raisonnable d'au moins {{noticeDays}} jours.
|
||||
parameters:
|
||||
- name: noticeDays
|
||||
label: Nombre de jours de préavis pour la visite
|
||||
type: NUMBER
|
||||
required: true
|
||||
- id: LEGAL_RESIDENCE_DESTINATION
|
||||
label: Usage exclusif d'habitation principale
|
||||
description: Interdit l'exercice d'activités commerciales ou professionnelles non autorisées dans le logement.
|
||||
contexts: [ALL]
|
||||
template: >-
|
||||
Les locaux loués devront être occupés exclusivement à usage d'habitation principale. L'exercice de toute activité
|
||||
commerciale, artisanale ou professionnelle y est strictement interdit, sauf accord exprès et écrit du bailleur.
|
||||
- id: BOILER_AND_CHIMNEY_MAINTENANCE
|
||||
label: Entretien de la chaudière et ramonage
|
||||
description: Rappelle l'obligation légale d'entretien annuel de la chaudière individuelle et de ramonage.
|
||||
contexts: [ALL]
|
||||
template: >-
|
||||
Si le logement est équipé d'un système de chauffage individuel au {{heatingType}}, le locataire est tenu
|
||||
de faire réaliser chaque année un entretien de cet équipement par un professionnel qualifié et d'en fournir
|
||||
l'attestation au bailleur. De même, le ramonage annuel des conduits d'évacuation des fumées, si applicables,
|
||||
doit être effectué par le locataire à ses frais.
|
||||
parameters:
|
||||
- name: heatingType
|
||||
label: Type de chauffage individuel
|
||||
type: CHOICE
|
||||
required: true
|
||||
choices:
|
||||
- value: "gaz"
|
||||
label: "gaz"
|
||||
- value: "fioul"
|
||||
label: "fioul"
|
||||
- value: "bois"
|
||||
label: "bois"
|
||||
- value: "autre"
|
||||
label: "autre combustible"
|
||||
- id: TENANT_TRANSFORMATIONS
|
||||
label: Encadrement des travaux de transformation
|
||||
description: Rappelle que les travaux de transformation (structure, cloisons, réseaux) nécessitent l'accord écrit préalable du bailleur.
|
||||
contexts: [ALL]
|
||||
template: >-
|
||||
Le locataire est libre d'effectuer des aménagements simples (peinture, décoration). Toutefois, il ne pourra entreprendre
|
||||
aucuns travaux de transformation des locaux loués ou de leurs équipements (abattage de cloison, modification de réseaux
|
||||
d'eau ou d'électricité, etc.) sans l'accord écrit et préalable du bailleur. À défaut, le bailleur pourra exiger la remise
|
||||
en état immédiate ou lors du départ du locataire aux frais exclusifs de ce dernier.
|
||||
- id: PROLONGED_VACANCY
|
||||
label: Inoccupation prolongée et mesures anti-gel
|
||||
description: Oblige le locataire à notifier le bailleur en cas d'absence prolongée et à protéger le logement du gel.
|
||||
contexts: [ALL]
|
||||
template: >-
|
||||
En cas d'absence prolongée du logement supérieure à {{minDays}} jours consécutifs, le locataire devra en informer le
|
||||
bailleur et prendre toutes les précautions nécessaires pour préserver le logement, notamment en maintenant le chauffage
|
||||
en mode hors-gel pendant la période hivernale afin d'éviter tout dégât des eaux par gel des canalisations.
|
||||
parameters:
|
||||
- name: minDays
|
||||
label: Nombre de jours d'absence minimale avant notification
|
||||
type: NUMBER
|
||||
required: true
|
||||
@@ -379,17 +379,11 @@
|
||||
</div>
|
||||
<div th:if="${legal.rentRevision.enabled}" class="keep">
|
||||
<h3>Modalités de révision du loyer</h3>
|
||||
<p>
|
||||
Révision annuelle le
|
||||
<span th:text="${dates.date(legal.rentRevision.annualRevisionDate)}"
|
||||
>1 Septembre 2027</span
|
||||
>, selon l’IRL du
|
||||
<span
|
||||
th:text="${formats.quarter(legal.rentRevision.referenceQuarter)}"
|
||||
>2e trimestre</span
|
||||
>
|
||||
<span th:text="${legal.rentRevision.referenceYear}">2026</span>, série
|
||||
INSEE <span th:text="${legal.rentRevision.seriesId}">001515333</span>.
|
||||
<p
|
||||
th:text="|Le loyer pourra être révisé chaque année le ${dates.monthDay(legal.rentRevision.annualRevisionDay)}, selon l’IRL du ${formats.quarter(legal.rentRevision.referenceQuarter)} ${legal.rentRevision.referenceYear}, série INSEE ${legal.rentRevision.seriesId}.|"
|
||||
>
|
||||
Le loyer pourra être révisé chaque année le 1 Septembre, selon l’IRL du 2e
|
||||
trimestre 2026, série INSEE 001515333.
|
||||
</p>
|
||||
</div>
|
||||
<h3>B. Charges récupérables</h3>
|
||||
|
||||
+38
-18
@@ -11,7 +11,6 @@ import fr.nessar.lmnporganizer.rental.adapter.in.web.tenant.TenantController;
|
||||
import fr.nessar.lmnporganizer.rental.application.apartment.ApartmentCatalogService;
|
||||
import fr.nessar.lmnporganizer.rental.application.landlord.LandlordAccountService;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.AmendLeaseService;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.ArchiveLeaseService;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.GenerateLeaseDocumentService;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.LeaseDraftService;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.AllocateRentPaymentService;
|
||||
@@ -125,8 +124,6 @@ class RentalWebAdaptersTest {
|
||||
@MockitoBean
|
||||
private LeaseDraftService leaseDrafts;
|
||||
@MockitoBean
|
||||
private ArchiveLeaseService archiving;
|
||||
@MockitoBean
|
||||
private AmendLeaseService amendments;
|
||||
@MockitoBean
|
||||
private GenerateLeaseDocumentService leaseDocuments;
|
||||
@@ -306,6 +303,20 @@ class RentalWebAdaptersTest {
|
||||
LeaseType.STUDENT));
|
||||
}
|
||||
|
||||
@Test
|
||||
void leaseDraftRequestsRejectAnImpossibleAnnualRevisionDay() throws Exception {
|
||||
mockMvc.perform(post("/api/leases").with(asLandlord()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content("""
|
||||
{"apartmentId":"apartment-1","tenantIds":["tenant-1"],"guarantors":[],
|
||||
"startDate":"2026-01-01","monthlyRent":900.00,"monthlyCharges":50.00,
|
||||
"securityDeposit":900.00,"legalTerms":{"rentRevision":{"enabled":true,
|
||||
"annualRevisionDay":31,"annualRevisionMonth":2,"referenceQuarter":1,
|
||||
"referenceYear":2026,"seriesId":"001515333"}}}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.message").value("Le jour annuel de révision IRL est invalide."));
|
||||
}
|
||||
|
||||
@Test
|
||||
void leaseDraftRequestsMapSpecialClausesAndExposeOnlyServerRenderedFacts() throws Exception {
|
||||
when(leaseDrafts.create(eq(LANDLORD_ID), any())).thenAnswer(invocation -> {
|
||||
@@ -354,29 +365,32 @@ class RentalWebAdaptersTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void leaseArchiveAndAmendDelegateScopedCommands() throws Exception {
|
||||
void leaseAmendDelegatesTheScopedCommand() throws Exception {
|
||||
Lease active = activeLease();
|
||||
Lease archived = activeLease();
|
||||
archived.archive();
|
||||
when(archiving.archive(LANDLORD_ID, LEASE_ID)).thenReturn(archived);
|
||||
when(amendments.amend(eq(LANDLORD_ID), eq(LEASE_ID), eq(LocalDate.of(2026, 7, 1)), any()))
|
||||
when(amendments.amend(eq(LANDLORD_ID), eq(LEASE_ID), eq(LocalDate.of(2026, 7, 1)),
|
||||
eq(java.time.ZoneId.of("Europe/Paris")), any()))
|
||||
.thenReturn(active);
|
||||
|
||||
mockMvc.perform(post("/api/leases/lease-1/archive").with(asLandlord()).with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.status").value("ARCHIVED"));
|
||||
|
||||
mockMvc.perform(post("/api/leases/lease-1/amendments").with(asLandlord()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content("""
|
||||
{"effectiveDate":"2026-07-01","tenantIds":["tenant-1"],"guarantors":[],
|
||||
{"effectiveDate":"2026-07-01","timeZone":"Europe/Paris",
|
||||
"tenantIds":["tenant-1"],"guarantors":[],
|
||||
"endDate":"2027-06-30","monthlyRent":1000.00,"monthlyCharges":75.00,
|
||||
"dueDay":12,"chargeMethod":"PROVISION","indexFlatRate":false}
|
||||
"""))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.id").value("lease-1"));
|
||||
|
||||
verify(archiving).archive(LANDLORD_ID, LEASE_ID);
|
||||
mockMvc.perform(post("/api/leases/lease-1/amendments").with(asLandlord()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content("""
|
||||
{"effectiveDate":"2026-07-01","timeZone":"Not/AZone",
|
||||
"tenantIds":["tenant-1"],"guarantors":[],
|
||||
"endDate":"2027-06-30","monthlyRent":1000.00,"monthlyCharges":75.00}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest());
|
||||
|
||||
verify(amendments).amend(LANDLORD_ID, LEASE_ID, LocalDate.of(2026, 7, 1),
|
||||
java.time.ZoneId.of("Europe/Paris"),
|
||||
new AmendLeaseService.Change(List.of(TENANT_ID), List.of(), LocalDate.of(2027, 6, 30),
|
||||
new BigDecimal("1000.00"), new BigDecimal("75.00"), 12, ChargeMethod.PROVISION, false));
|
||||
}
|
||||
@@ -385,6 +399,8 @@ class RentalWebAdaptersTest {
|
||||
void directLeaseActivationIsNotExposed() throws Exception {
|
||||
mockMvc.perform(post("/api/leases/lease-1/activate").with(asLandlord()).with(csrf()))
|
||||
.andExpect(status().isNotFound());
|
||||
mockMvc.perform(post("/api/leases/lease-1/archive").with(asLandlord()).with(csrf()))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -474,7 +490,8 @@ class RentalWebAdaptersTest {
|
||||
.thenReturn(payment);
|
||||
when(paymentRefunds.refund(LANDLORD_ID, PAYMENT_ID, "refund-1", 7, new BigDecimal("50"),
|
||||
LocalDate.of(2026, 2, 4), ZoneId.of("Europe/Paris"))).thenReturn(payment);
|
||||
when(paymentReversal.reverse(LANDLORD_ID, PAYMENT_ID, 7, "Erreur bancaire")).thenReturn(reversed);
|
||||
when(paymentReversal.reverse(LANDLORD_ID, PAYMENT_ID, 7, "Erreur bancaire",
|
||||
ZoneId.of("Europe/Paris"))).thenReturn(reversed);
|
||||
when(paymentHistory.get(LANDLORD_ID, PAYMENT_ID)).thenReturn(history);
|
||||
|
||||
mockMvc.perform(post("/api/rent-payments").with(asLandlord()).with(csrf())
|
||||
@@ -502,7 +519,8 @@ class RentalWebAdaptersTest {
|
||||
.andExpect(jsonPath("$.refunds[0].id").value("refund-1"));
|
||||
mockMvc.perform(post("/api/rent-payments/payment-1/reversal").with(asLandlord()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"paymentVersion\":7,\"reason\":\"Erreur bancaire\"}"))
|
||||
.content("{\"paymentVersion\":7,\"reason\":\"Erreur bancaire\","
|
||||
+ "\"timeZone\":\"Europe/Paris\"}"))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.reversal.reason").value("Erreur bancaire"));
|
||||
|
||||
@@ -512,7 +530,8 @@ class RentalWebAdaptersTest {
|
||||
new BigDecimal("50"));
|
||||
verify(paymentRefunds).refund(LANDLORD_ID, PAYMENT_ID, "refund-1", 7, new BigDecimal("50"),
|
||||
LocalDate.of(2026, 2, 4), ZoneId.of("Europe/Paris"));
|
||||
verify(paymentReversal).reverse(LANDLORD_ID, PAYMENT_ID, 7, "Erreur bancaire");
|
||||
verify(paymentReversal).reverse(LANDLORD_ID, PAYMENT_ID, 7, "Erreur bancaire",
|
||||
ZoneId.of("Europe/Paris"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -564,7 +583,8 @@ class RentalWebAdaptersTest {
|
||||
.andExpect(jsonPath("$.message").value("Le fuseau horaire est invalide."));
|
||||
mockMvc.perform(post("/api/rent-payments/payment-1/reversal").with(asLandlord()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"paymentVersion\":0,\"reason\":\"" + "a".repeat(501) + "\"}"))
|
||||
.content("{\"paymentVersion\":0,\"reason\":\"" + "a".repeat(501)
|
||||
+ "\",\"timeZone\":\"Europe/Paris\"}"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.message").isNotEmpty());
|
||||
}
|
||||
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
package fr.nessar.lmnporganizer.rental.adapter.in.web.lease;
|
||||
|
||||
import fr.nessar.lmnporganizer.LmnpOrganizerApplication;
|
||||
import fr.nessar.lmnporganizer.identity.adapter.in.security.SecurityConfiguration;
|
||||
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlord;
|
||||
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.RentalApiExceptionHandler;
|
||||
import fr.nessar.lmnporganizer.rental.application.landlord.LandlordAccountService;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.LeaseEndingService;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.LeaseEndingState;
|
||||
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.ExternalIdentity;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.Landlord;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseActionAudit;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseEndPhase;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSnapshot;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseType;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.NoticeDeliveryMode;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.NoticeEvidenceFile;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.RentTerms;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.RentalPeriod;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.TenantNoticeCommand;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.TenantNoticeId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.test.context.ContextConfiguration;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.request.RequestPostProcessor;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oidcLogin;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@WebMvcTest(controllers = LeaseEndingController.class)
|
||||
@ContextConfiguration(classes = LmnpOrganizerApplication.class)
|
||||
@Import({AuthenticatedLandlord.class, RentalApiExceptionHandler.class, SecurityConfiguration.class})
|
||||
class LeaseEndingWebTest {
|
||||
private static final LandlordId LANDLORD_ID = new LandlordId("landlord-1");
|
||||
private static final LeaseId LEASE_ID = new LeaseId("lease-1");
|
||||
private static final Instant NOW = Instant.parse("2026-07-19T00:30:00Z");
|
||||
|
||||
@Autowired
|
||||
MockMvc mockMvc;
|
||||
|
||||
@MockitoBean
|
||||
LandlordAccountService accounts;
|
||||
@MockitoBean
|
||||
LeaseEndingService endings;
|
||||
@MockitoBean
|
||||
Clock clock;
|
||||
|
||||
@BeforeEach
|
||||
void authenticate() {
|
||||
when(accounts.identify(any())).thenReturn(Landlord.register(LANDLORD_ID,
|
||||
new ExternalIdentity("subject-1"), "Camille", "Martin", "camille@example.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getRequiresAValidIanaTimeZoneAndExposesTheCanonicalAppendOnlyHistory() throws Exception {
|
||||
LeaseEndingState state = state();
|
||||
when(endings.state(LANDLORD_ID, LEASE_ID, ZoneId.of("America/Los_Angeles"))).thenReturn(state);
|
||||
|
||||
mockMvc.perform(get("/api/leases/lease-1/end").with(landlord()))
|
||||
.andExpect(status().isBadRequest());
|
||||
mockMvc.perform(get("/api/leases/lease-1/end").param("timeZone", "Mars/Olympus").with(landlord()))
|
||||
.andExpect(status().isBadRequest());
|
||||
mockMvc.perform(get("/api/leases/lease-1/end")
|
||||
.param("timeZone", "America/Los_Angeles").with(landlord()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.phase").value("NOTICE_PERIOD"))
|
||||
.andExpect(jsonPath("$.notices[0].ruleVersion").value("2026-07"))
|
||||
.andExpect(jsonPath("$.notices[0].recordedWithoutEvidenceConfirmed").value(true))
|
||||
.andExpect(jsonPath("$.notices[0].authorId").value("subject-1"))
|
||||
.andExpect(jsonPath("$.notices[0].recordedAt").value(NOW.toString()))
|
||||
.andExpect(jsonPath("$.notices[0].evidenceVersions[0].sha256").value("a".repeat(64)))
|
||||
.andExpect(jsonPath("$.agreedEarlyEnds.length()").value(1))
|
||||
.andExpect(jsonPath("$.agreedEarlyEnds[0].authorId").value("subject-1"));
|
||||
|
||||
verify(endings).state(LANDLORD_ID, LEASE_ID, ZoneId.of("America/Los_Angeles"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void noticeMultipartForwardsTimezoneEvidenceAttestationAndAuthenticatedAuthor() throws Exception {
|
||||
when(endings.recordNotice(any(), any(), any(), any(), any())).thenReturn(state());
|
||||
MockMultipartFile evidence = new MockMultipartFile("evidence", "conge.pdf", "application/pdf",
|
||||
"opaque".getBytes());
|
||||
|
||||
mockMvc.perform(multipart("/api/leases/lease-1/end/notices").file(evidence)
|
||||
.param("timeZone", "Europe/Paris")
|
||||
.param("receivedOn", "2026-07-19")
|
||||
.param("requestedDepartureOn", "2026-08-19")
|
||||
.param("deliveryMode", "REGISTERED_LETTER")
|
||||
.param("lateRegistrationConfirmed", "false")
|
||||
.param("nonStandardDeliveryConfirmed", "false")
|
||||
.param("missingEvidenceConfirmed", "false")
|
||||
.with(landlord()).with(csrf()))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.leaseId").value("lease-1"));
|
||||
|
||||
verify(endings).recordNotice(eq(LANDLORD_ID), eq(LEASE_ID), eq("subject-1"),
|
||||
eq(new LeaseEndingService.RecordNotice(ZoneId.of("Europe/Paris"),
|
||||
LocalDate.of(2026, 7, 19), LocalDate.of(2026, 8, 19),
|
||||
NoticeDeliveryMode.REGISTERED_LETTER, null, false, false, false)), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void evidenceDownloadIsAlwaysAnOpaqueSanitizedAttachment() throws Exception {
|
||||
when(endings.evidence(LANDLORD_ID, LEASE_ID, new TenantNoticeId("notice-1"), 2))
|
||||
.thenReturn(new LeaseEndingService.EvidenceDownload("../dangerous.html", "opaque".getBytes()));
|
||||
|
||||
mockMvc.perform(get("/api/leases/lease-1/end/notices/notice-1/evidence/2").with(landlord()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(header().string("Content-Type", MediaType.APPLICATION_OCTET_STREAM_VALUE))
|
||||
.andExpect(header().string("Content-Disposition", "attachment; filename=\"dangerous.html\""))
|
||||
.andExpect(header().string("X-Content-Type-Options", "nosniff"))
|
||||
.andExpect(content().bytes("opaque".getBytes()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void jsonMutationsForwardCivilDatesReasonsConfirmationAndAuthenticatedAuthor() throws Exception {
|
||||
when(endings.acceptWithdrawal(any(), any(), any(), any(), any())).thenReturn(state());
|
||||
when(endings.invalidateNotice(any(), any(), any(), any(), any())).thenReturn(state());
|
||||
when(endings.recordAgreedEarlyEnd(any(), any(), any(), any())).thenReturn(state());
|
||||
when(endings.close(any(), any(), any(), any())).thenReturn(state());
|
||||
|
||||
mockMvc.perform(post("/api/leases/lease-1/end/notices/notice-1/withdrawals")
|
||||
.with(landlord()).with(csrf()).contentType(MediaType.APPLICATION_JSON).content("""
|
||||
{"timeZone":"America/Los_Angeles","acceptedOn":"2026-07-18",
|
||||
"reason":"Le locataire reste"}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.leaseId").value("lease-1"));
|
||||
mockMvc.perform(post("/api/leases/lease-1/end/notices/notice-1/invalidations")
|
||||
.with(landlord()).with(csrf()).contentType(MediaType.APPLICATION_JSON).content("""
|
||||
{"timeZone":"Europe/London","invalidatedOn":"2026-07-19",
|
||||
"reason":"Erreur administrative"}
|
||||
"""))
|
||||
.andExpect(status().isOk());
|
||||
mockMvc.perform(post("/api/leases/lease-1/end/agreed-early-end")
|
||||
.with(landlord()).with(csrf()).contentType(MediaType.APPLICATION_JSON).content("""
|
||||
{"timeZone":"Europe/Paris","endOn":"2026-08-18","reason":"Accord écrit"}
|
||||
"""))
|
||||
.andExpect(status().isOk());
|
||||
mockMvc.perform(post("/api/leases/lease-1/end/closure")
|
||||
.with(landlord()).with(csrf()).contentType(MediaType.APPLICATION_JSON).content("""
|
||||
{"timeZone":"Europe/Paris","keysReturnedOn":"2026-08-18","confirmed":true}
|
||||
"""))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(endings).acceptWithdrawal(LANDLORD_ID, LEASE_ID, new TenantNoticeId("notice-1"), "subject-1",
|
||||
new LeaseEndingService.AdministrativeFact(ZoneId.of("America/Los_Angeles"),
|
||||
LocalDate.of(2026, 7, 18), "Le locataire reste"));
|
||||
verify(endings).invalidateNotice(LANDLORD_ID, LEASE_ID, new TenantNoticeId("notice-1"), "subject-1",
|
||||
new LeaseEndingService.AdministrativeFact(ZoneId.of("Europe/London"),
|
||||
LocalDate.of(2026, 7, 19), "Erreur administrative"));
|
||||
verify(endings).recordAgreedEarlyEnd(LANDLORD_ID, LEASE_ID, "subject-1",
|
||||
new LeaseEndingService.AgreedEnd(ZoneId.of("Europe/Paris"),
|
||||
LocalDate.of(2026, 8, 18), "Accord écrit"));
|
||||
verify(endings).close(LANDLORD_ID, LEASE_ID, "subject-1",
|
||||
new LeaseEndingService.Closure(ZoneId.of("Europe/Paris"),
|
||||
LocalDate.of(2026, 8, 18), true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidJsonMutationBodyIsRejectedBeforeTheService() throws Exception {
|
||||
mockMvc.perform(post("/api/leases/lease-1/end/notices/notice-1/withdrawals")
|
||||
.with(landlord()).with(csrf()).contentType(MediaType.APPLICATION_JSON).content("""
|
||||
{"timeZone":"Europe/Paris","acceptedOn":"2026-07-19","reason":" "}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest());
|
||||
|
||||
verifyNoInteractions(endings);
|
||||
}
|
||||
|
||||
@Test
|
||||
void leaseEndingRoutesRequireOidcAuthentication() throws Exception {
|
||||
mockMvc.perform(get("/api/leases/lease-1/end").param("timeZone", "Europe/Paris"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
private static LeaseEndingState state() {
|
||||
Lease lease = activeLease();
|
||||
LeaseActionAudit audit = new LeaseActionAudit("subject-1", NOW);
|
||||
TenantNoticeId noticeId = new TenantNoticeId("notice-1");
|
||||
lease.recordTenantNotice(new TenantNoticeCommand(noticeId, LocalDate.of(2026, 7, 19),
|
||||
LocalDate.of(2026, 8, 19), NoticeDeliveryMode.REGISTERED_LETTER, null,
|
||||
false, false, true, LocalDate.of(2026, 7, 19)), audit);
|
||||
lease.appendNoticeEvidence(noticeId, new NoticeEvidenceFile("blob", "conge.pdf", "application/pdf", 6,
|
||||
"a".repeat(64)), null, audit);
|
||||
lease.recordAgreedEarlyEnd(LocalDate.of(2026, 8, 18), "Fin convenue", audit);
|
||||
return new LeaseEndingState(LEASE_ID, true, Optional.empty(), LeaseEndPhase.NOTICE_PERIOD,
|
||||
LeaseType.STANDARD, Optional.empty(), Optional.of(LocalDate.of(2026, 8, 18)), false,
|
||||
lease.endingHistory());
|
||||
}
|
||||
|
||||
private static Lease activeLease() {
|
||||
ApartmentId apartmentId = new ApartmentId("apartment-1");
|
||||
TenantId tenantId = new TenantId("tenant-1");
|
||||
RentalPeriod period = new RentalPeriod(LocalDate.of(2026, 1, 1), LocalDate.of(2030, 1, 1));
|
||||
RentTerms terms = new RentTerms(new BigDecimal("1000"), new BigDecimal("100"), new BigDecimal("1000"));
|
||||
Lease lease = Lease.draft(LEASE_ID, LANDLORD_ID, apartmentId, List.of(tenantId), List.of(), period, terms,
|
||||
LeaseType.STANDARD);
|
||||
lease.activate(new LeaseSnapshot(
|
||||
new LeaseSnapshot.Landlord(LANDLORD_ID, "Camille", "Martin", "camille@example.com", "+331",
|
||||
"1 rue A"),
|
||||
new LeaseSnapshot.Apartment(apartmentId, "Studio", "2 rue B"),
|
||||
List.of(new LeaseSnapshot.Tenant(tenantId, "Alex", "Durand", "alex@example.com", "", "3 rue C")),
|
||||
List.of(), lease.period(), terms, LeaseType.STANDARD));
|
||||
return lease;
|
||||
}
|
||||
|
||||
private static RequestPostProcessor landlord() {
|
||||
return oidcLogin().idToken(token -> token.claim("sub", "subject-1").claim("given_name", "Camille")
|
||||
.claim("family_name", "Martin").claim("email", "camille@example.com"));
|
||||
}
|
||||
}
|
||||
+7
-2
@@ -93,11 +93,16 @@ class RentDueRefreshWebTest {
|
||||
|
||||
@Test
|
||||
void listingRemainsAQueryWithoutImplicitRefresh() throws Exception {
|
||||
when(rentDues.list(LANDLORD_ID)).thenReturn(List.of(due("due-2026-12", YearMonth.of(2026, 12))));
|
||||
RentDue correction = RentDue.reconstitute(new RentDueId("due-2026-12"), LANDLORD_ID,
|
||||
new LeaseId("lease-1"), YearMonth.of(2026, 12), LocalDate.of(2026, 12, 5),
|
||||
new BigDecimal("400"), new BigDecimal("40"), new BigDecimal("1320"),
|
||||
new BigDecimal("1200"), new BigDecimal("120"), 1);
|
||||
when(rentDues.list(LANDLORD_ID)).thenReturn(List.of(correction));
|
||||
|
||||
mockMvc.perform(get("/api/rent-dues").with(owner()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].month").value("2026-12"));
|
||||
.andExpect(jsonPath("$[0].month").value("2026-12"))
|
||||
.andExpect(jsonPath("$[0].correctionRequired").value(true));
|
||||
|
||||
verify(rentDues).list(LANDLORD_ID);
|
||||
verify(refresh, never()).refresh(any());
|
||||
|
||||
+79
-1
@@ -13,6 +13,7 @@ import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.ApartmentOccupancy;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.ChargeMethod;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseActionAudit;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseAmendmentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseContractVersion;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
@@ -20,11 +21,17 @@ import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSnapshot;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseStatus;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSpecialClause;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseType;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.NoticeDeliveryMode;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.NoticeEvidenceFile;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.OccupancyPeriod;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.RentTerms;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.RentalPeriod;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.TenantNoticeCommand;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.TenantNoticeId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import org.bson.Document;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -110,7 +117,8 @@ class MongoLeaseRepositoriesTest {
|
||||
MongoLeaseRepository.LeaseDocument legacy = new MongoLeaseRepository.LeaseDocument(
|
||||
LEASE_ID.value(), LANDLORD_ID.value(), APARTMENT_ID.value(), List.of(TENANT_ID.value()), List.of(),
|
||||
PERIOD.startDate(), PERIOD.endDate(), TERMS.monthlyRent(), TERMS.monthlyCharges(),
|
||||
TERMS.securityDeposit(), LeaseStatus.DRAFT.name(), null, null, null, null, null, null, null, null, 3L);
|
||||
TERMS.securityDeposit(), LeaseStatus.DRAFT.name(), null, null, null, null, null, null, null, null,
|
||||
null, 3L);
|
||||
|
||||
Lease restoredLease = MongoLeaseRepository.toDomain(legacy);
|
||||
RentTerms restored = restoredLease.terms();
|
||||
@@ -122,6 +130,45 @@ class MongoLeaseRepositoriesTest {
|
||||
assertThat(restoredLease.specialClauses()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void leaseEndingHistoryRoundTripsAsEmbeddedAuditedFactsAndDefaultsWhenAbsent() {
|
||||
Lease lease = standardActiveLease();
|
||||
LeaseActionAudit audit = new LeaseActionAudit("oidc:landlord-1",
|
||||
Instant.parse("2026-07-19T12:30:00Z"));
|
||||
TenantNoticeId firstNotice = new TenantNoticeId("notice-1");
|
||||
lease.recordTenantNotice(new TenantNoticeCommand(firstNotice, LocalDate.of(2026, 7, 1),
|
||||
LocalDate.of(2026, 8, 1), NoticeDeliveryMode.REGISTERED_LETTER, null,
|
||||
true, false, LocalDate.of(2026, 7, 19)), audit);
|
||||
lease.acceptNoticeWithdrawal(firstNotice, LocalDate.of(2026, 7, 2), "Le locataire reste",
|
||||
LocalDate.of(2026, 7, 19), audit);
|
||||
TenantNoticeId secondNotice = new TenantNoticeId("notice-2");
|
||||
lease.recordTenantNotice(new TenantNoticeCommand(secondNotice, LocalDate.of(2026, 7, 3),
|
||||
LocalDate.of(2026, 8, 3), NoticeDeliveryMode.BAILIFF, null,
|
||||
true, false, LocalDate.of(2026, 7, 19)), audit);
|
||||
lease.invalidateNotice(secondNotice, LocalDate.of(2026, 7, 4), "Erreur de date",
|
||||
LocalDate.of(2026, 7, 19), audit);
|
||||
lease.recordAgreedEarlyEnd(LocalDate.of(2026, 8, 31), "Fin convenue", audit);
|
||||
lease.close(LocalDate.of(2026, 8, 30), true, LocalDate.of(2026, 8, 31), audit);
|
||||
lease.appendNoticeEvidence(firstNotice, new NoticeEvidenceFile("blob-1", "conge.pdf",
|
||||
"application/pdf", 42, "a".repeat(64)), null, audit);
|
||||
|
||||
MongoLeaseRepository.LeaseDocument document = MongoLeaseRepository.toDocument(lease);
|
||||
Lease restored = MongoLeaseRepository.toDomain(document.withVersion(4L));
|
||||
|
||||
assertThat(document.endingHistory()).isNotNull();
|
||||
assertThat(document.endingHistory().notices()).hasSize(2);
|
||||
assertThat(restored.version()).isEqualTo(4);
|
||||
assertThat(restored.status()).isEqualTo(LeaseStatus.ARCHIVED);
|
||||
assertThat(restored.contractEndOn()).contains(LocalDate.of(2026, 8, 31));
|
||||
assertThat(restored.endingHistory().notices()).isEqualTo(lease.endingHistory().notices());
|
||||
assertThat(restored.endingHistory().withdrawals()).isEqualTo(lease.endingHistory().withdrawals());
|
||||
assertThat(restored.endingHistory().invalidations()).isEqualTo(lease.endingHistory().invalidations());
|
||||
assertThat(restored.endingHistory().agreedEarlyEnds()).isEqualTo(lease.endingHistory().agreedEarlyEnds());
|
||||
assertThat(restored.endingHistory().evidenceVersions())
|
||||
.isEqualTo(lease.endingHistory().evidenceVersions());
|
||||
assertThat(restored.endingHistory().closure()).isEqualTo(lease.endingHistory().closure());
|
||||
}
|
||||
|
||||
@Test
|
||||
void leaseLookupAndDraftDeleteRemainLandlordScoped() {
|
||||
Lease draft = Lease.reconstitute(
|
||||
@@ -198,6 +245,24 @@ class MongoLeaseRepositoriesTest {
|
||||
.append("apartmentId", APARTMENT_ID.value()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void occupancyDocumentStoresAnOpenEndAsNull() {
|
||||
ApartmentOccupancy occupancy = ApartmentOccupancy.empty(LANDLORD_ID, APARTMENT_ID);
|
||||
occupancy.reserve(LEASE_ID, OccupancyPeriod.open(PERIOD.startDate()));
|
||||
when(mongoTemplate.insert(any(MongoApartmentOccupancyRepository.OccupancyDocument.class)))
|
||||
.thenAnswer(invocation -> invocation
|
||||
.<MongoApartmentOccupancyRepository.OccupancyDocument>getArgument(0)
|
||||
.withVersion(0L));
|
||||
|
||||
ApartmentOccupancy saved = new MongoApartmentOccupancyRepository(mongoTemplate).save(occupancy);
|
||||
|
||||
assertThat(saved.reservations().getFirst().period().endOn()).isEmpty();
|
||||
ArgumentCaptor<MongoApartmentOccupancyRepository.OccupancyDocument> document =
|
||||
ArgumentCaptor.forClass(MongoApartmentOccupancyRepository.OccupancyDocument.class);
|
||||
verify(mongoTemplate).insert(document.capture());
|
||||
assertThat(document.getValue().reservations().getFirst().endDate()).isNull();
|
||||
}
|
||||
|
||||
private static Lease activeLease() {
|
||||
Lease lease = Lease.draft(LEASE_ID, LANDLORD_ID, APARTMENT_ID, List.of(TENANT_ID), List.of(), PERIOD, TERMS,
|
||||
LeaseType.STUDENT, List.of(
|
||||
@@ -207,6 +272,19 @@ class MongoLeaseRepositoriesTest {
|
||||
return lease;
|
||||
}
|
||||
|
||||
private static Lease standardActiveLease() {
|
||||
Lease lease = Lease.draft(new LeaseId("lease-ending"), LANDLORD_ID, APARTMENT_ID, List.of(TENANT_ID),
|
||||
List.of(), PERIOD, TERMS, LeaseType.STANDARD);
|
||||
lease.activate(new LeaseSnapshot(
|
||||
new LeaseSnapshot.Landlord(
|
||||
LANDLORD_ID, "Léa", "Dubois", "lea@example.com", "+33612345678", "1 rue A"),
|
||||
new LeaseSnapshot.Apartment(APARTMENT_ID, "Studio", "2 rue B", legalProfile()),
|
||||
List.of(new LeaseSnapshot.Tenant(
|
||||
TENANT_ID, "Camille", "Martin", "camille@example.com", "", "3 rue C")),
|
||||
List.of(), lease.period(), TERMS, LeaseType.STANDARD));
|
||||
return lease;
|
||||
}
|
||||
|
||||
private static LeaseSnapshot snapshot(RentalPeriod period) {
|
||||
return new LeaseSnapshot(
|
||||
new LeaseSnapshot.Landlord(
|
||||
|
||||
+5
-2
@@ -38,6 +38,7 @@ import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDate;
|
||||
import java.time.MonthDay;
|
||||
import java.time.YearMonth;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
@@ -195,7 +196,7 @@ class ThymeleafRentalDocumentRendererTest {
|
||||
new BigDecimal("24.60"), new BigDecimal("40"), "Vue dégagée exceptionnelle"),
|
||||
new LeaseLegalTerms.PreviousRentTerms(true, new BigDecimal("850"),
|
||||
LocalDate.of(2025, 9, 1), LocalDate.of(2026, 6, 5), true),
|
||||
new LeaseLegalTerms.RentRevisionTerms(true, LocalDate.of(2027, 9, 1), 2, 2026, "001515333"),
|
||||
new LeaseLegalTerms.RentRevisionTerms(true, MonthDay.of(9, 1), 2, 2026, "001515333"),
|
||||
new LeaseLegalTerms.RecentWork("Réfection complète des peintures", new BigDecimal("3000")),
|
||||
true, true, false, true, "Usage paisible de la cour intérieure.");
|
||||
LeaseContractVersion contract = contract(profile, LeaseType.STANDARD, legalTerms,
|
||||
@@ -215,7 +216,8 @@ class ThymeleafRentalDocumentRendererTest {
|
||||
"III. Date de prise d'effet et durée du contrat",
|
||||
"1 Septembre 2026", "31 Août 2027", "reconduit tacitement",
|
||||
"IV. Conditions financières", "900,00 €", "24,60 € / m²", "40,00 €",
|
||||
"1 Septembre 2025", "5 Juin 2026", "2e trimestre 2026", "001515333",
|
||||
"1 Septembre 2025", "5 Juin 2026", "Le loyer pourra être révisé chaque année le 1 Septembre",
|
||||
"2e trimestre 2026", "001515333",
|
||||
"Provision sur charges avec régularisation annuelle", "payables mensuellement d'avance",
|
||||
"950,00 €", "V. Travaux", "Réfection complète des peintures", "3 000,00 €",
|
||||
"VI. Garanties", "VII. Clause de solidarité", "VIII. Clause résolutoire",
|
||||
@@ -224,6 +226,7 @@ class ThymeleafRentalDocumentRendererTest {
|
||||
"Usage paisible de la cour intérieure.", "XI. Annexes", "Modèle FR-MEUBLE-2025-01 · Révision 4");
|
||||
List<String> missing = required.stream().filter(fragment -> !text.contains(fragment)).toList();
|
||||
assertTrue(missing.isEmpty(), () -> "Missing lease content: " + missing + " in:\n" + text);
|
||||
assertFalse(text.contains("Le loyer pourra être révisé chaque année le 1 Septembre 2027"));
|
||||
assertFalse(text.contains("[") || text.contains("null") || text.contains("Non renseigné")
|
||||
|| text.contains("+33612345678") || text.contains("+33611111111") || !text.contains("Fait le")
|
||||
|| !text.contains("Fait à"));
|
||||
|
||||
+11
-3
@@ -10,6 +10,7 @@ import fr.nessar.lmnporganizer.rental.domain.landlord.Landlord;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.ApartmentOccupancy;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.ApartmentOccupancyRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
|
||||
@@ -113,7 +114,7 @@ class ActivateLeaseServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAnotherActiveLeaseForTheApartmentEvenAfterItsInitialPeriodEnded() {
|
||||
void rejectsAnOpenOperationalOccupancyEvenAfterTheExistingStandardLeasesSignedPeriodEnded() {
|
||||
LandlordRepository landlords = mock(LandlordRepository.class);
|
||||
ApartmentRepository apartments = mock(ApartmentRepository.class);
|
||||
TenantRepository tenants = mock(TenantRepository.class);
|
||||
@@ -122,14 +123,21 @@ class ActivateLeaseServiceTest {
|
||||
ApartmentOccupancyRepository occupancies = mock(ApartmentOccupancyRepository.class);
|
||||
Lease draft = draft();
|
||||
Lease existing = activeLeaseEnding(LocalDate.of(2025, 12, 31));
|
||||
ApartmentOccupancy occupancy = ApartmentOccupancy.empty(LANDLORD_ID, APARTMENT_ID);
|
||||
occupancy.reserve(existing.id(), existing.occupancyPeriod());
|
||||
when(leases.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.of(draft));
|
||||
when(leases.findAll(LANDLORD_ID)).thenReturn(List.of(existing, draft));
|
||||
when(landlords.find(LANDLORD_ID)).thenReturn(Optional.of(completeLandlord()));
|
||||
when(apartments.find(LANDLORD_ID, APARTMENT_ID)).thenReturn(Optional.of(new Apartment(
|
||||
APARTMENT_ID, LANDLORD_ID, "Studio", "2 rue B", new BigDecimal("120000"), "", legalProfile())));
|
||||
when(tenants.find(LANDLORD_ID, TENANT_ID)).thenReturn(Optional.of(new Tenant(
|
||||
TENANT_ID, LANDLORD_ID, "Camille", "Martin", "camille@example.com", "", "3 rue C", List.of())));
|
||||
when(occupancies.find(LANDLORD_ID, APARTMENT_ID)).thenReturn(Optional.of(occupancy));
|
||||
|
||||
ActivateLeaseService service = new ActivateLeaseService(landlords, apartments, tenants, leases, rentDues,
|
||||
occupancies, new RecordingTransactions(), () -> "due-1");
|
||||
|
||||
assertThrows(LeaseRuleViolation.class, () -> service.activate(LANDLORD_ID, LEASE_ID));
|
||||
verify(landlords, never()).find(any());
|
||||
verify(leases, never()).findAll(any());
|
||||
verify(leases, never()).save(any());
|
||||
verify(rentDues, never()).saveAll(any());
|
||||
}
|
||||
|
||||
+80
-1
@@ -1,6 +1,7 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.lease;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
|
||||
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.ApartmentOccupancy;
|
||||
@@ -10,11 +11,15 @@ import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRuleViolation;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseActionAudit;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSnapshot;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSpecialClause;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseType;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.RentTerms;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.RentalPeriod;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.NoticeDeliveryMode;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.TenantNoticeCommand;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.TenantNoticeId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDue;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueRepository;
|
||||
@@ -28,6 +33,7 @@ import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.ZoneId;
|
||||
import java.time.YearMonth;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -41,6 +47,7 @@ import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
class AmendLeaseServiceTest {
|
||||
private static final LandlordId LANDLORD_ID = new LandlordId("landlord-1");
|
||||
@@ -73,6 +80,7 @@ class AmendLeaseServiceTest {
|
||||
|
||||
Lease amended = new AmendLeaseService(leases, tenants, rentDues, occupancies, transactions,
|
||||
() -> "amendment-1", CLOCK).amend(LANDLORD_ID, LEASE_ID, LocalDate.of(2026, 7, 16),
|
||||
ZoneOffset.UTC,
|
||||
new AmendLeaseService.Change(List.of(FIRST_TENANT_ID, SECOND_TENANT_ID), List.of(),
|
||||
LocalDate.of(2026, 8, 31), new BigDecimal("1200"), new BigDecimal("120"), 12,
|
||||
ChargeMethod.PROVISION, false));
|
||||
@@ -104,12 +112,83 @@ class AmendLeaseServiceTest {
|
||||
() -> "amendment-1", CLOCK);
|
||||
|
||||
assertThrows(LeaseRuleViolation.class, () -> service.amend(LANDLORD_ID, LEASE_ID,
|
||||
LocalDate.of(2026, 6, 30), new AmendLeaseService.Change(List.of(), List.of(),
|
||||
LocalDate.of(2026, 6, 30), ZoneOffset.UTC, new AmendLeaseService.Change(List.of(), List.of(),
|
||||
LocalDate.of(2026, 12, 31), new BigDecimal("1200"), new BigDecimal("120"))));
|
||||
|
||||
assertFalse(transactions.called);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAnAmendmentThatWouldMoveAnActiveLeaseEndBeforeTouchingOtherState() {
|
||||
LeaseRepository leases = mock(LeaseRepository.class);
|
||||
TenantRepository tenants = mock(TenantRepository.class);
|
||||
Lease lease = activeLease();
|
||||
lease.recordTenantNotice(new TenantNoticeCommand(new TenantNoticeId("notice-1"),
|
||||
LocalDate.of(2026, 7, 1), LocalDate.of(2027, 1, 1), NoticeDeliveryMode.BAILIFF,
|
||||
null, false, false, false, LocalDate.of(2026, 7, 1)),
|
||||
new LeaseActionAudit("subject-1", Instant.parse("2026-07-01T12:00:00Z")));
|
||||
Tenant first = tenant(FIRST_TENANT_ID, "Camille", "3 rue C");
|
||||
when(leases.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.of(lease));
|
||||
when(tenants.find(LANDLORD_ID, FIRST_TENANT_ID)).thenReturn(Optional.of(first));
|
||||
AmendLeaseService service = new AmendLeaseService(leases, tenants, mock(RentDueRepository.class),
|
||||
mock(ApartmentOccupancyRepository.class), new RecordingTransactions(),
|
||||
() -> "amendment-1", CLOCK);
|
||||
|
||||
assertThrows(LeaseRuleViolation.class, () -> service.amend(LANDLORD_ID, LEASE_ID,
|
||||
LocalDate.of(2026, 7, 1), ZoneOffset.UTC,
|
||||
new AmendLeaseService.Change(List.of(FIRST_TENANT_ID), List.of(),
|
||||
LocalDate.of(2026, 7, 31), new BigDecimal("1200"), new BigDecimal("120"))));
|
||||
|
||||
assertEquals(0, lease.amendments().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void retroactivityUsesTheBrowserCivilTimeZone() {
|
||||
LeaseRepository leases = mock(LeaseRepository.class);
|
||||
RecordingTransactions transactions = new RecordingTransactions();
|
||||
Clock boundary = Clock.fixed(Instant.parse("2026-07-01T00:30:00Z"), ZoneOffset.UTC);
|
||||
AmendLeaseService service = new AmendLeaseService(leases, mock(TenantRepository.class),
|
||||
mock(RentDueRepository.class), mock(ApartmentOccupancyRepository.class), transactions,
|
||||
() -> "amendment-1", boundary);
|
||||
AmendLeaseService.Change change = new AmendLeaseService.Change(List.of(FIRST_TENANT_ID), List.of(),
|
||||
LocalDate.of(2026, 9, 30), new BigDecimal("1200"), new BigDecimal("120"));
|
||||
|
||||
assertThrows(ResourceNotFound.class, () -> service.amend(LANDLORD_ID, LEASE_ID,
|
||||
LocalDate.of(2026, 6, 30), ZoneId.of("America/Los_Angeles"), change));
|
||||
|
||||
assertTrue(transactions.called);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shorteningAStudentLeaseNeverResavesADueDeletedByTheAmendment() {
|
||||
LeaseRepository leases = mock(LeaseRepository.class);
|
||||
TenantRepository tenants = mock(TenantRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
ApartmentOccupancyRepository occupancies = mock(ApartmentOccupancyRepository.class);
|
||||
Lease lease = activeLease();
|
||||
Tenant first = tenant(FIRST_TENANT_ID, "Camille", "3 rue C");
|
||||
ApartmentOccupancy occupancy = ApartmentOccupancy.empty(LANDLORD_ID, APARTMENT_ID);
|
||||
occupancy.reserve(LEASE_ID, lease.occupancyPeriod());
|
||||
RentDue september = due(YearMonth.of(2026, 9));
|
||||
when(leases.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.of(lease));
|
||||
when(leases.save(lease)).thenReturn(lease);
|
||||
when(tenants.find(LANDLORD_ID, FIRST_TENANT_ID)).thenReturn(Optional.of(first));
|
||||
when(occupancies.find(LANDLORD_ID, APARTMENT_ID)).thenReturn(Optional.of(occupancy));
|
||||
when(rentDues.findAll(LANDLORD_ID, LEASE_ID)).thenReturn(List.of(september));
|
||||
when(rentDues.deleteUnallocated(september)).thenReturn(true);
|
||||
|
||||
new AmendLeaseService(leases, tenants, rentDues, occupancies, new RecordingTransactions(),
|
||||
() -> "generated", CLOCK).amend(LANDLORD_ID, LEASE_ID, LocalDate.of(2026, 7, 16),
|
||||
ZoneOffset.UTC, new AmendLeaseService.Change(List.of(FIRST_TENANT_ID), List.of(),
|
||||
LocalDate.of(2026, 8, 31), new BigDecimal("1000"), new BigDecimal("100")));
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<List<RentDue>> saved = ArgumentCaptor.forClass(List.class);
|
||||
verify(rentDues).deleteUnallocated(september);
|
||||
verify(rentDues).saveAll(saved.capture());
|
||||
assertTrue(!saved.getValue().contains(september));
|
||||
}
|
||||
|
||||
private Lease activeLease() {
|
||||
RentalPeriod requestedPeriod = new RentalPeriod(LocalDate.of(2026, 1, 1), LocalDate.of(2026, 8, 31));
|
||||
RentTerms terms = new RentTerms(new BigDecimal("1000"), new BigDecimal("100"), new BigDecimal("1000"));
|
||||
|
||||
-86
@@ -1,86 +0,0 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.lease;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRuleViolation;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSnapshot;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseStatus;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.RentTerms;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.RentalPeriod;
|
||||
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ArchiveLeaseServiceTest {
|
||||
|
||||
private static final LandlordId LANDLORD_ID = new LandlordId("landlord-1");
|
||||
private static final LeaseId LEASE_ID = new LeaseId("lease-1");
|
||||
|
||||
@Test
|
||||
void archivesAndSavesTheLandlordScopedActiveLease() {
|
||||
LeaseRepository leases = mock(LeaseRepository.class);
|
||||
Lease active = activeLease();
|
||||
when(leases.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.of(active));
|
||||
when(leases.save(active)).thenReturn(active);
|
||||
|
||||
Lease archived = new ArchiveLeaseService(leases).archive(LANDLORD_ID, LEASE_ID);
|
||||
|
||||
assertEquals(LeaseStatus.ARCHIVED, archived.status());
|
||||
verify(leases).find(LANDLORD_ID, LEASE_ID);
|
||||
verify(leases).save(active);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsArchivingADraftWithoutSavingIt() {
|
||||
LeaseRepository leases = mock(LeaseRepository.class);
|
||||
Lease draft = draftLease();
|
||||
when(leases.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.of(draft));
|
||||
ArchiveLeaseService service = new ArchiveLeaseService(leases);
|
||||
|
||||
assertThrows(LeaseRuleViolation.class, () -> service.archive(LANDLORD_ID, LEASE_ID));
|
||||
|
||||
verify(leases).find(LANDLORD_ID, LEASE_ID);
|
||||
verify(leases, never()).save(any());
|
||||
}
|
||||
|
||||
private static Lease activeLease() {
|
||||
Lease lease = draftLease();
|
||||
RentalPeriod period = lease.period();
|
||||
RentTerms terms = lease.terms();
|
||||
lease.activate(new LeaseSnapshot(
|
||||
new LeaseSnapshot.Landlord(
|
||||
LANDLORD_ID, "Léa", "Dubois", "lea@example.com", "+33612345678", "1 rue A"),
|
||||
new LeaseSnapshot.Apartment(lease.apartmentId(), "Studio", "2 rue B"),
|
||||
List.of(new LeaseSnapshot.Tenant(
|
||||
lease.tenantIds().getFirst(), "Camille", "Martin", "camille@example.com", "", "3 rue C")),
|
||||
List.of(),
|
||||
period,
|
||||
terms));
|
||||
return lease;
|
||||
}
|
||||
|
||||
private static Lease draftLease() {
|
||||
return Lease.draft(
|
||||
LEASE_ID,
|
||||
LANDLORD_ID,
|
||||
new ApartmentId("apartment-1"),
|
||||
List.of(new TenantId("tenant-1")),
|
||||
List.of(),
|
||||
new RentalPeriod(LocalDate.of(2026, 1, 1), LocalDate.of(2026, 12, 31)),
|
||||
new RentTerms(new BigDecimal("900"), new BigDecimal("50"), new BigDecimal("900")));
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -18,7 +18,8 @@ class LeaseClauseCatalogTest {
|
||||
LeaseClauseCatalog catalog = LeaseClauseCatalog.load(
|
||||
getClass().getResourceAsStream("/lease-clauses.yml"));
|
||||
|
||||
assertEquals(2, catalog.available(LeaseClauseCatalog.Context.STANDARD).size());
|
||||
assertEquals(12, catalog.available(LeaseClauseCatalog.Context.STANDARD).size());
|
||||
assertEquals(13, catalog.available(LeaseClauseCatalog.Context.COLOCATION).size());
|
||||
var rendered = catalog.render("VISIT_SALE_RELETTING", LeaseClauseCatalog.Context.STANDARD,
|
||||
Map.of("startTime", "18:30"));
|
||||
assertTrue(rendered.text().contains("à partir de 18:30"));
|
||||
|
||||
+323
@@ -0,0 +1,323 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.lease;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.IdentifierGenerator;
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.LeaseDocumentStorage;
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
|
||||
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.ApartmentOccupancy;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.ApartmentOccupancyRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseActionAudit;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRuleViolation;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSnapshot;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseType;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseEndPhase;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.NoticeDeliveryMode;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.OccupancyPeriod;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.RentTerms;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.RentalPeriod;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.TenantNoticeCommand;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.TenantNoticeId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDue;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.YearMonth;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class LeaseEndingServiceTest {
|
||||
private static final LandlordId LANDLORD_ID = new LandlordId("landlord-1");
|
||||
private static final ApartmentId APARTMENT_ID = new ApartmentId("apartment-1");
|
||||
private static final LeaseId LEASE_ID = new LeaseId("lease-1");
|
||||
private static final TenantId TENANT_ID = new TenantId("tenant-1");
|
||||
private static final ZoneId PARIS = ZoneId.of("Europe/Paris");
|
||||
private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-07-19T12:30:00Z"), ZoneOffset.UTC);
|
||||
private static final byte[] EVIDENCE = "opaque evidence".getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
|
||||
@Test
|
||||
void recordingNoticeAtomicallyBoundsOccupancyAndReconcilesDues() {
|
||||
LeaseRepository leases = mock(LeaseRepository.class);
|
||||
ApartmentOccupancyRepository occupancies = mock(ApartmentOccupancyRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
Lease lease = activeStandardLease();
|
||||
ApartmentOccupancy occupancy = ApartmentOccupancy.empty(LANDLORD_ID, APARTMENT_ID);
|
||||
occupancy.reserve(LEASE_ID, OccupancyPeriod.open(lease.period().startDate()));
|
||||
RentDue august = due(YearMonth.of(2026, 8));
|
||||
RentDue september = due(YearMonth.of(2026, 9));
|
||||
when(leases.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.of(lease));
|
||||
when(occupancies.find(LANDLORD_ID, APARTMENT_ID)).thenReturn(Optional.of(occupancy));
|
||||
when(rentDues.findAll(LANDLORD_ID, LEASE_ID)).thenReturn(List.of(august, september));
|
||||
when(leases.save(lease)).thenReturn(lease);
|
||||
LeaseEndingService service = service(leases, occupancies, rentDues, mock(LeaseDocumentStorage.class));
|
||||
|
||||
LeaseEndingState state = service.recordNotice(LANDLORD_ID, LEASE_ID, "oidc:landlord-1",
|
||||
new LeaseEndingService.RecordNotice(PARIS, LocalDate.of(2026, 7, 19),
|
||||
LocalDate.of(2026, 8, 19), NoticeDeliveryMode.REGISTERED_LETTER,
|
||||
null, false, false, true), null);
|
||||
|
||||
assertEquals(LocalDate.of(2026, 8, 19), state.effectiveEndOn().orElseThrow());
|
||||
assertTrue(state.history().notices().getFirst().recordedWithoutEvidenceConfirmed());
|
||||
assertEquals(LocalDate.of(2026, 8, 19),
|
||||
occupancy.reservations().getFirst().period().endOn().orElseThrow());
|
||||
assertEquals(new BigDecimal("612.90"), august.rent());
|
||||
assertEquals(new BigDecimal("61.29"), august.charges());
|
||||
assertEquals(BigDecimal.ZERO, september.total());
|
||||
verify(rentDues).saveAll(List.of(august, september));
|
||||
verify(occupancies).save(occupancy);
|
||||
verify(leases).save(lease);
|
||||
}
|
||||
|
||||
@Test
|
||||
void noticeWithoutEvidenceRequiresTheExplicitAttestation() {
|
||||
LeaseRepository leases = mock(LeaseRepository.class);
|
||||
LeaseDocumentStorage storage = mock(LeaseDocumentStorage.class);
|
||||
LeaseEndingService service = service(leases, mock(ApartmentOccupancyRepository.class),
|
||||
mock(RentDueRepository.class), storage);
|
||||
|
||||
assertThrows(LeaseRuleViolation.class, () -> service.recordNotice(
|
||||
LANDLORD_ID, LEASE_ID, "oidc:landlord-1",
|
||||
new LeaseEndingService.RecordNotice(PARIS, LocalDate.of(2026, 7, 19),
|
||||
LocalDate.of(2026, 8, 19), NoticeDeliveryMode.BAILIFF,
|
||||
null, false, false, false), null));
|
||||
|
||||
verify(leases, never()).find(any(), any());
|
||||
verify(storage, never()).store(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void civilTodayIsComputedInTheLandlordSuppliedTimeZoneWithoutConvertingTheEnteredDate() {
|
||||
Clock boundaryClock = Clock.fixed(Instant.parse("2026-07-19T00:30:00Z"), ZoneOffset.UTC);
|
||||
Lease parisLease = activeStandardLease();
|
||||
LeaseRepository parisLeases = mock(LeaseRepository.class);
|
||||
ApartmentOccupancyRepository parisOccupancies = mock(ApartmentOccupancyRepository.class);
|
||||
RentDueRepository parisDues = mock(RentDueRepository.class);
|
||||
ApartmentOccupancy parisOccupancy = ApartmentOccupancy.empty(LANDLORD_ID, APARTMENT_ID);
|
||||
parisOccupancy.reserve(LEASE_ID, parisLease.occupancyPeriod());
|
||||
when(parisLeases.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.of(parisLease));
|
||||
when(parisLeases.save(parisLease)).thenReturn(parisLease);
|
||||
when(parisOccupancies.find(LANDLORD_ID, APARTMENT_ID)).thenReturn(Optional.of(parisOccupancy));
|
||||
when(parisDues.findAll(LANDLORD_ID, LEASE_ID)).thenReturn(List.of());
|
||||
LeaseEndingService parisService = service(
|
||||
parisLeases, parisOccupancies, parisDues, mock(LeaseDocumentStorage.class), boundaryClock);
|
||||
|
||||
LeaseEndingState paris = parisService.recordNotice(LANDLORD_ID, LEASE_ID, "oidc:landlord-1",
|
||||
new LeaseEndingService.RecordNotice(ZoneId.of("Europe/Paris"), LocalDate.of(2026, 7, 19),
|
||||
LocalDate.of(2026, 8, 19), NoticeDeliveryMode.BAILIFF,
|
||||
null, false, false, true), null);
|
||||
assertEquals(LocalDate.of(2026, 7, 19), paris.history().notices().getFirst().receivedOn());
|
||||
|
||||
Lease lasVegasLease = activeStandardLease();
|
||||
LeaseRepository lasVegasLeases = mock(LeaseRepository.class);
|
||||
when(lasVegasLeases.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.of(lasVegasLease));
|
||||
LeaseEndingService lasVegasService = service(lasVegasLeases, mock(ApartmentOccupancyRepository.class),
|
||||
mock(RentDueRepository.class), mock(LeaseDocumentStorage.class), boundaryClock);
|
||||
|
||||
assertThrows(LeaseRuleViolation.class, () -> lasVegasService.recordNotice(
|
||||
LANDLORD_ID, LEASE_ID, "oidc:landlord-1",
|
||||
new LeaseEndingService.RecordNotice(ZoneId.of("America/Los_Angeles"),
|
||||
LocalDate.of(2026, 7, 19), LocalDate.of(2026, 8, 19), NoticeDeliveryMode.BAILIFF,
|
||||
null, false, false, true), null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void withdrawingANoticeCannotReopenOccupancyAcrossAFinalizedSuccessor() {
|
||||
LeaseRepository leases = mock(LeaseRepository.class);
|
||||
ApartmentOccupancyRepository occupancies = mock(ApartmentOccupancyRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
Lease lease = activeStandardLease();
|
||||
LeaseActionAudit audit = new LeaseActionAudit("oidc:landlord-1", CLOCK.instant());
|
||||
TenantNoticeId noticeId = new TenantNoticeId("notice-1");
|
||||
lease.recordTenantNotice(new TenantNoticeCommand(noticeId, LocalDate.of(2026, 7, 19),
|
||||
LocalDate.of(2026, 8, 19), NoticeDeliveryMode.BAILIFF, null, false, false,
|
||||
LocalDate.of(2026, 7, 19)), audit);
|
||||
ApartmentOccupancy occupancy = ApartmentOccupancy.empty(LANDLORD_ID, APARTMENT_ID);
|
||||
occupancy.reserve(LEASE_ID, lease.occupancyPeriod());
|
||||
occupancy.reserve(new LeaseId("successor"), OccupancyPeriod.closed(
|
||||
LocalDate.of(2026, 8, 20), LocalDate.of(2027, 8, 19)));
|
||||
when(leases.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.of(lease));
|
||||
when(occupancies.find(LANDLORD_ID, APARTMENT_ID)).thenReturn(Optional.of(occupancy));
|
||||
LeaseEndingService service = service(leases, occupancies, rentDues, mock(LeaseDocumentStorage.class));
|
||||
|
||||
assertThrows(LeaseRuleViolation.class, () -> service.acceptWithdrawal(
|
||||
LANDLORD_ID, LEASE_ID, noticeId, "oidc:landlord-1",
|
||||
new LeaseEndingService.AdministrativeFact(PARIS, LocalDate.of(2026, 7, 19),
|
||||
"Le locataire reste")));
|
||||
|
||||
verify(leases, never()).save(any());
|
||||
verify(occupancies, never()).save(any());
|
||||
verify(rentDues, never()).saveAll(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void withdrawingANoticeRestoresTheExactPreEndingScheduleInTheSameTransaction() {
|
||||
LeaseRepository leases = mock(LeaseRepository.class);
|
||||
ApartmentOccupancyRepository occupancies = mock(ApartmentOccupancyRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
Lease lease = activeStandardLease();
|
||||
TenantNoticeId noticeId = new TenantNoticeId("notice-restore");
|
||||
lease.recordTenantNotice(new TenantNoticeCommand(noticeId, LocalDate.of(2026, 7, 19),
|
||||
LocalDate.of(2026, 8, 19), NoticeDeliveryMode.BAILIFF, null, false, false,
|
||||
LocalDate.of(2026, 7, 19)), new LeaseActionAudit("oidc:landlord-1", CLOCK.instant()));
|
||||
ApartmentOccupancy occupancy = ApartmentOccupancy.empty(LANDLORD_ID, APARTMENT_ID);
|
||||
occupancy.reserve(LEASE_ID, lease.occupancyPeriod());
|
||||
RentDue august = due(YearMonth.of(2026, 8));
|
||||
RentDue september = due(YearMonth.of(2026, 9));
|
||||
fr.nessar.lmnporganizer.rental.domain.rent.RentSchedule.reconcileEnd(
|
||||
List.of(august, september), lease.scheduledOccupancyPeriod(), LocalDate.of(2026, 8, 19));
|
||||
when(leases.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.of(lease));
|
||||
when(leases.save(lease)).thenReturn(lease);
|
||||
when(occupancies.find(LANDLORD_ID, APARTMENT_ID)).thenReturn(Optional.of(occupancy));
|
||||
when(rentDues.findAll(LANDLORD_ID, LEASE_ID)).thenReturn(List.of(august, september));
|
||||
LeaseEndingService service = service(leases, occupancies, rentDues, mock(LeaseDocumentStorage.class));
|
||||
|
||||
service.acceptWithdrawal(LANDLORD_ID, LEASE_ID, noticeId, "oidc:landlord-1",
|
||||
new LeaseEndingService.AdministrativeFact(PARIS, LocalDate.of(2026, 7, 19),
|
||||
"Le locataire reste"));
|
||||
|
||||
assertEquals(new BigDecimal("1000"), august.rent());
|
||||
assertEquals(new BigDecimal("100"), august.charges());
|
||||
assertEquals(new BigDecimal("1000"), september.rent());
|
||||
assertEquals(new BigDecimal("100"), september.charges());
|
||||
verify(rentDues).saveAll(List.of(august, september));
|
||||
verify(occupancies).save(occupancy);
|
||||
verify(leases).save(lease);
|
||||
}
|
||||
|
||||
@Test
|
||||
void evidenceIsStoredBeforeTheTransactionAndDiscardedWhenMetadataFails() {
|
||||
LeaseRepository leases = mock(LeaseRepository.class);
|
||||
ApartmentOccupancyRepository occupancies = mock(ApartmentOccupancyRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
LeaseDocumentStorage storage = mock(LeaseDocumentStorage.class);
|
||||
Lease lease = activeStandardLease();
|
||||
TenantNoticeId noticeId = new TenantNoticeId("notice-1");
|
||||
lease.recordTenantNotice(new TenantNoticeCommand(noticeId, LocalDate.of(2026, 7, 19),
|
||||
LocalDate.of(2026, 8, 19), NoticeDeliveryMode.BAILIFF, null, false, false,
|
||||
LocalDate.of(2026, 7, 19)), new LeaseActionAudit("oidc:landlord-1", CLOCK.instant()));
|
||||
when(storage.store(EVIDENCE)).thenReturn("blob-1");
|
||||
when(leases.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.of(lease));
|
||||
when(leases.save(lease)).thenThrow(new IllegalStateException("optimistic conflict"));
|
||||
LeaseEndingService service = service(leases, occupancies, rentDues, storage);
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> service.appendEvidence(
|
||||
LANDLORD_ID, LEASE_ID, noticeId, "oidc:landlord-1",
|
||||
new LeaseEndingService.EvidenceUpload(PARIS, "../congé.html", "text/html", EVIDENCE, null)));
|
||||
|
||||
verify(storage).store(EVIDENCE);
|
||||
verify(storage).discard("blob-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void stateReportsUnsupportedSharedLeaseAndOutstandingCorrection() {
|
||||
LeaseRepository leases = mock(LeaseRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
Lease shared = activeSharedStudentLease();
|
||||
RentDue due = due(YearMonth.of(2026, 8));
|
||||
due.allocate(new BigDecimal("100"));
|
||||
fr.nessar.lmnporganizer.rental.domain.rent.RentSchedule.reconcileEnd(
|
||||
List.of(due), shared.scheduledOccupancyPeriod(), LocalDate.of(2026, 7, 31));
|
||||
when(leases.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.of(shared));
|
||||
when(rentDues.findAll(LANDLORD_ID, LEASE_ID)).thenReturn(List.of(due));
|
||||
LeaseEndingService service = service(leases, mock(ApartmentOccupancyRepository.class), rentDues,
|
||||
mock(LeaseDocumentStorage.class));
|
||||
|
||||
LeaseEndingState state = service.state(LANDLORD_ID, LEASE_ID, PARIS);
|
||||
|
||||
assertTrue(!state.supported());
|
||||
assertTrue(state.unsupportedReason().isPresent());
|
||||
assertTrue(state.correctionRequired());
|
||||
}
|
||||
|
||||
@Test
|
||||
void stateRejectsDraftsButKeepsArchivedLeaseHistoryReadable() {
|
||||
LeaseRepository leases = mock(LeaseRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
RentalPeriod period = new RentalPeriod(LocalDate.of(2026, 1, 1), LocalDate.of(2030, 1, 1));
|
||||
RentTerms terms = new RentTerms(new BigDecimal("1000"), new BigDecimal("100"), new BigDecimal("1000"));
|
||||
Lease draft = Lease.draft(LEASE_ID, LANDLORD_ID, APARTMENT_ID, List.of(TENANT_ID), List.of(), period, terms);
|
||||
when(leases.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.of(draft));
|
||||
LeaseEndingService service = service(leases, mock(ApartmentOccupancyRepository.class), rentDues,
|
||||
mock(LeaseDocumentStorage.class));
|
||||
|
||||
assertThrows(LeaseRuleViolation.class, () -> service.state(LANDLORD_ID, LEASE_ID, PARIS));
|
||||
verify(rentDues, never()).findAll(any(), any());
|
||||
|
||||
Lease archived = activeStandardLease();
|
||||
archived.recordAgreedEarlyEnd(LocalDate.of(2026, 7, 18), "Fin convenue",
|
||||
new LeaseActionAudit("subject-1", CLOCK.instant()));
|
||||
archived.close(LocalDate.of(2026, 7, 18), true, LocalDate.of(2026, 7, 19),
|
||||
new LeaseActionAudit("subject-1", CLOCK.instant()));
|
||||
when(leases.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.of(archived));
|
||||
when(rentDues.findAll(LANDLORD_ID, LEASE_ID)).thenReturn(List.of());
|
||||
|
||||
assertEquals(LeaseEndPhase.ARCHIVED, service.state(LANDLORD_ID, LEASE_ID, PARIS).phase());
|
||||
}
|
||||
|
||||
private LeaseEndingService service(LeaseRepository leases, ApartmentOccupancyRepository occupancies,
|
||||
RentDueRepository rentDues, LeaseDocumentStorage storage) {
|
||||
return service(leases, occupancies, rentDues, storage, CLOCK);
|
||||
}
|
||||
|
||||
private LeaseEndingService service(LeaseRepository leases, ApartmentOccupancyRepository occupancies,
|
||||
RentDueRepository rentDues, LeaseDocumentStorage storage, Clock clock) {
|
||||
IdentifierGenerator identifiers = () -> "notice-generated";
|
||||
return new LeaseEndingService(leases, occupancies, rentDues, storage, new DirectTransactions(), identifiers,
|
||||
clock, 250L * 1024 * 1024);
|
||||
}
|
||||
|
||||
private Lease activeStandardLease() {
|
||||
return activeLease(LeaseType.STANDARD, List.of(TENANT_ID));
|
||||
}
|
||||
|
||||
private Lease activeSharedStudentLease() {
|
||||
return activeLease(LeaseType.STUDENT, List.of(TENANT_ID, new TenantId("tenant-2")));
|
||||
}
|
||||
|
||||
private Lease activeLease(LeaseType type, List<TenantId> tenantIds) {
|
||||
RentalPeriod requested = new RentalPeriod(LocalDate.of(2026, 1, 1), LocalDate.of(2030, 1, 1));
|
||||
RentTerms terms = new RentTerms(new BigDecimal("1000"), new BigDecimal("100"), new BigDecimal("1000"));
|
||||
Lease lease = Lease.draft(LEASE_ID, LANDLORD_ID, APARTMENT_ID, tenantIds, List.of(), requested, terms, type);
|
||||
lease.activate(new LeaseSnapshot(
|
||||
new LeaseSnapshot.Landlord(LANDLORD_ID, "Léa", "Martin", "lea@example.com", "+331", "1 rue A"),
|
||||
new LeaseSnapshot.Apartment(APARTMENT_ID, "Studio", "2 rue B"),
|
||||
tenantIds.stream().map(id -> new LeaseSnapshot.Tenant(id, "Camille", "Durand",
|
||||
id.value() + "@example.com", "", "3 rue C")).toList(),
|
||||
List.of(), lease.period(), terms, type));
|
||||
return lease;
|
||||
}
|
||||
|
||||
private RentDue due(YearMonth month) {
|
||||
return RentDue.create(new RentDueId("due-" + month), LANDLORD_ID, LEASE_ID, month, month.atDay(5),
|
||||
new BigDecimal("1000"), new BigDecimal("100"));
|
||||
}
|
||||
|
||||
private static final class DirectTransactions implements TransactionRunner {
|
||||
@Override
|
||||
public <T> T run(Supplier<T> operation) {
|
||||
return operation.get();
|
||||
}
|
||||
}
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rent;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
|
||||
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSnapshot;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseStatus;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.RentTerms;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.RentalPeriod;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDue;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueSettlement;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPayment;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversalRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentStatus;
|
||||
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.YearMonth;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class PostArchiveRentSettlementTest {
|
||||
private static final LandlordId LANDLORD_ID = new LandlordId("landlord-1");
|
||||
private static final LeaseId LEASE_ID = new LeaseId("lease-1");
|
||||
private static final RentDueId FIRST_DUE_ID = new RentDueId("due-1");
|
||||
private static final RentDueId SECOND_DUE_ID = new RentDueId("due-2");
|
||||
private static final RentPaymentId PAYMENT_ID = new RentPaymentId("payment-1");
|
||||
private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-07-19T12:00:00Z"), ZoneOffset.UTC);
|
||||
|
||||
@Test
|
||||
void existingDuesCanStillBeRecordedAllocatedAndReversedAfterLeaseArchival() {
|
||||
Lease archivedLease = archivedLease();
|
||||
RentDue firstDue = due(FIRST_DUE_ID, "900", "50");
|
||||
RentDue secondDue = due(SECOND_DUE_ID, "100", "0");
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
RentPaymentReversalRepository reversals = mock(RentPaymentReversalRepository.class);
|
||||
AtomicReference<RentPayment> storedPayment = new AtomicReference<>();
|
||||
when(payments.find(LANDLORD_ID, PAYMENT_ID))
|
||||
.thenAnswer(ignored -> Optional.ofNullable(storedPayment.get()));
|
||||
when(payments.save(any())).thenAnswer(invocation -> {
|
||||
RentPayment payment = invocation.getArgument(0);
|
||||
storedPayment.set(payment);
|
||||
return payment;
|
||||
});
|
||||
when(rentDues.find(LANDLORD_ID, FIRST_DUE_ID)).thenReturn(Optional.of(firstDue));
|
||||
when(rentDues.find(LANDLORD_ID, SECOND_DUE_ID)).thenReturn(Optional.of(secondDue));
|
||||
when(rentDues.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(rentDues.saveAll(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(reversals.findByPayment(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.empty());
|
||||
when(reversals.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
TransactionRunner transactions = new TransactionRunner() {
|
||||
@Override
|
||||
public <T> T run(Supplier<T> operation) {
|
||||
return operation.get();
|
||||
}
|
||||
};
|
||||
|
||||
RentPayment payment = new RecordRentPaymentService(payments, rentDues, transactions, CLOCK).record(
|
||||
LANDLORD_ID, PAYMENT_ID.value(), FIRST_DUE_ID, firstDue.version(),
|
||||
LocalDate.of(2026, 7, 18), ZoneId.of("Europe/Paris"), new BigDecimal("1050"));
|
||||
payment = new AllocateRentPaymentService(payments, rentDues, transactions, CLOCK).allocate(
|
||||
LANDLORD_ID, PAYMENT_ID, "allocation-2", payment.version(), SECOND_DUE_ID, secondDue.version(),
|
||||
new BigDecimal("100"));
|
||||
new ReverseRentPaymentService(payments, reversals, rentDues, transactions, () -> "reversal-1", CLOCK)
|
||||
.reverse(LANDLORD_ID, PAYMENT_ID, payment.version(), "Correction bancaire",
|
||||
ZoneId.of("Europe/Paris"));
|
||||
|
||||
assertEquals(LeaseStatus.ARCHIVED, archivedLease.status());
|
||||
assertEquals(RentPaymentStatus.REVERSED, payment.status());
|
||||
assertEquals(RentDueSettlement.UNPAID, firstDue.settlement());
|
||||
assertEquals(RentDueSettlement.UNPAID, secondDue.settlement());
|
||||
}
|
||||
|
||||
private static RentDue due(RentDueId id, String rent, String charges) {
|
||||
return RentDue.reconstitute(id, LANDLORD_ID, LEASE_ID, YearMonth.of(2026, 7),
|
||||
LocalDate.of(2026, 7, 5), new BigDecimal(rent), new BigDecimal(charges), BigDecimal.ZERO, 0);
|
||||
}
|
||||
|
||||
private static Lease archivedLease() {
|
||||
ApartmentId apartmentId = new ApartmentId("apartment-1");
|
||||
TenantId tenantId = new TenantId("tenant-1");
|
||||
RentalPeriod period = new RentalPeriod(LocalDate.of(2026, 1, 1), LocalDate.of(2026, 12, 31));
|
||||
RentTerms terms = new RentTerms(new BigDecimal("900"), new BigDecimal("50"), new BigDecimal("900"));
|
||||
LeaseSnapshot snapshot = new LeaseSnapshot(
|
||||
new LeaseSnapshot.Landlord(LANDLORD_ID, "Léa", "Martin", "lea@example.com", "+331", "1 rue A"),
|
||||
new LeaseSnapshot.Apartment(apartmentId, "Studio", "2 rue B"),
|
||||
List.of(new LeaseSnapshot.Tenant(tenantId, "Camille", "Durand", "camille@example.com", "",
|
||||
"3 rue C")), List.of(), period, terms);
|
||||
return Lease.reconstitute(LEASE_ID, LANDLORD_ID, apartmentId, List.of(tenantId), List.of(), period, terms,
|
||||
LeaseStatus.ARCHIVED, snapshot, 0);
|
||||
}
|
||||
}
|
||||
+16
@@ -124,6 +124,22 @@ class RefreshRentScheduleServiceTest {
|
||||
verify(rentDues, never()).saveAll(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshResponseHidesZeroAmountLeaseEndTombstones() {
|
||||
LeaseRepository leases = mock(LeaseRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
RentDue tombstone = RentDue.reconstitute(new RentDueId("hidden"), LANDLORD_ID, LEASE_ID,
|
||||
YearMonth.of(2027, 1), LocalDate.of(2027, 1, 12), BigDecimal.ZERO, BigDecimal.ZERO,
|
||||
BigDecimal.ZERO, new BigDecimal("1200"), new BigDecimal("120"), 1);
|
||||
when(leases.findAll(LANDLORD_ID)).thenReturn(List.of());
|
||||
when(rentDues.findAll(LANDLORD_ID)).thenReturn(List.of(tombstone));
|
||||
|
||||
List<RentDue> result = new RefreshRentScheduleService(leases, rentDues, new RecordingTransactions(),
|
||||
() -> "unused", clock("2026-12-01T10:00:00Z")).refresh(LANDLORD_ID);
|
||||
|
||||
assertEquals(List.of(), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void catchesUpMissingYearsThroughTheFirstFutureBlockWithCurrentTerms() {
|
||||
LeaseRepository leases = mock(LeaseRepository.class);
|
||||
|
||||
+54
-3
@@ -34,6 +34,7 @@ import java.math.BigDecimal;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.MonthDay;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.YearMonth;
|
||||
@@ -47,6 +48,7 @@ import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@@ -55,7 +57,7 @@ class RentRevisionServiceTest {
|
||||
private static final LeaseId LEASE_ID = new LeaseId("lease-1");
|
||||
|
||||
@Test
|
||||
void previewsOfficialCeilingsForTheLandlordCivilDate() {
|
||||
void previewsOfficialCeilingsAtTheFirstOccurrenceStrictlyAfterLeaseStart() {
|
||||
Lease lease = activeLease("G", "75");
|
||||
IrlIndexProvider provider = mock(IrlIndexProvider.class);
|
||||
IrlIndexProvider.Period previous = new IrlIndexProvider.Period(2026, 2);
|
||||
@@ -100,6 +102,23 @@ class RentRevisionServiceTest {
|
||||
verify(provider).indexes("001515333", previous, current);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reappliesALeapDayScheduleEveryYearInsteadOfLosingFebruary29() {
|
||||
Lease lease = activeLease("B", "75", "001515333", MonthDay.of(2, 29));
|
||||
IrlIndexProvider provider = mock(IrlIndexProvider.class);
|
||||
when(provider.indexes(any(), any(), any())).thenReturn(new IrlIndexProvider.Indexes(
|
||||
new BigDecimal("150.00"), new BigDecimal("153.00"), IrlIndexProvider.Source.INSEE));
|
||||
Clock clock = Clock.fixed(Instant.parse("2032-02-28T10:00:00Z"), ZoneOffset.UTC);
|
||||
|
||||
RentRevisionService.Preview preview = new RentRevisionService(repository(lease), provider, clock)
|
||||
.preview(LANDLORD_ID, LEASE_ID,
|
||||
RentRevisionService.PreviewRequest.automatic(ZoneId.of("Europe/Paris")));
|
||||
|
||||
assertEquals(LocalDate.of(2032, 2, 29), preview.annualRevisionDate());
|
||||
verify(provider).indexes("001515333", new IrlIndexProvider.Period(2031, 2),
|
||||
new IrlIndexProvider.Period(2032, 2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void advancesPastAnAppliedAnniversaryAndKeepsTheSeriesFrozenInTheLease() {
|
||||
Lease lease = activeLease("B", "75", "010760507");
|
||||
@@ -208,6 +227,33 @@ class RentRevisionServiceTest {
|
||||
assertEquals(List.of(), rentDueRepository.values);
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusesToRenderOrStoreAProposalWhoseEffectiveDateIsAfterTheLeaseEnd() {
|
||||
Lease lease = activeLease("B", "75");
|
||||
lease.recordAgreedEarlyEnd(LocalDate.of(2027, 9, 1), "Fin convenue",
|
||||
new fr.nessar.lmnporganizer.rental.domain.lease.LeaseActionAudit(
|
||||
"subject-1", Instant.parse("2027-08-01T10:00:00Z")));
|
||||
IrlIndexProvider provider = mock(IrlIndexProvider.class);
|
||||
when(provider.indexes("001515333", new IrlIndexProvider.Period(2026, 2),
|
||||
new IrlIndexProvider.Period(2027, 2))).thenReturn(new IrlIndexProvider.Indexes(
|
||||
new BigDecimal("143.46"), new BigDecimal("146.68"), IrlIndexProvider.Source.INSEE));
|
||||
RentalDocumentRenderer renderer = mock(RentalDocumentRenderer.class);
|
||||
LeaseDocumentStorage storage = mock(LeaseDocumentStorage.class);
|
||||
InMemoryRevisionRepository revisionRepository = new InMemoryRevisionRepository();
|
||||
RentRevisionService service = new RentRevisionService(repository(lease), provider, revisionRepository,
|
||||
new InMemoryRentDueRepository(List.of()), renderer, storage, directTransactions(),
|
||||
() -> "revision-1", Clock.fixed(Instant.parse("2027-09-02T10:00:00Z"), ZoneOffset.UTC), 1024);
|
||||
|
||||
assertThrows(fr.nessar.lmnporganizer.rental.domain.lease.LeaseRuleViolation.class,
|
||||
() -> service.propose(LANDLORD_ID, LEASE_ID, new RentRevisionService.ProposalRequest(
|
||||
RentRevisionService.PreviewRequest.automatic(ZoneId.of("Europe/Paris")),
|
||||
LocalDate.of(2027, 9, 2), new BigDecimal("915"), new BigDecimal("51"), Set.of()),
|
||||
"subject-1"));
|
||||
|
||||
verifyNoInteractions(renderer, storage);
|
||||
assertEquals(List.of(), revisionRepository.values);
|
||||
}
|
||||
|
||||
@Test
|
||||
void proposesFromTheFinancialTermsEffectiveOnTheRequestDateAfterAnAmendment() {
|
||||
Lease lease = leaseWithFinancialAmendmentAfterAnniversary();
|
||||
@@ -346,13 +392,18 @@ class RentRevisionServiceTest {
|
||||
}
|
||||
|
||||
private static Lease activeLease(String energyRating, String departmentCode, String seriesId) {
|
||||
return activeLease(energyRating, departmentCode, seriesId, MonthDay.of(9, 1));
|
||||
}
|
||||
|
||||
private static Lease activeLease(String energyRating, String departmentCode, String seriesId,
|
||||
MonthDay annualRevisionDay) {
|
||||
ApartmentId apartmentId = new ApartmentId("apartment-1");
|
||||
TenantId tenantId = new TenantId("tenant-1");
|
||||
RentalPeriod period = LeaseType.STANDARD.initialPeriod(LocalDate.of(2026, 9, 1));
|
||||
RentTerms terms = new RentTerms(new BigDecimal("900.00"), new BigDecimal("50.00"),
|
||||
new BigDecimal("900.00"), 5, ChargeMethod.FLAT_RATE, true);
|
||||
LeaseLegalTerms legalTerms = new LeaseLegalTerms(null, null,
|
||||
new LeaseLegalTerms.RentRevisionTerms(true, LocalDate.of(2027, 9, 1), 2, 2026, seriesId),
|
||||
new LeaseLegalTerms.RentRevisionTerms(true, annualRevisionDay, 2, 2026, seriesId),
|
||||
null, false, false, false, false, "");
|
||||
Lease lease = Lease.draft(LEASE_ID, LANDLORD_ID, apartmentId, List.of(tenantId), List.of(), period, terms,
|
||||
LeaseType.STANDARD, List.of(), legalTerms);
|
||||
@@ -388,7 +439,7 @@ class RentRevisionServiceTest {
|
||||
RentTerms terms = new RentTerms(new BigDecimal("900"), new BigDecimal("50"), new BigDecimal("900"),
|
||||
5, ChargeMethod.FLAT_RATE, true);
|
||||
LeaseLegalTerms legalTerms = new LeaseLegalTerms(null, null,
|
||||
new LeaseLegalTerms.RentRevisionTerms(true, LocalDate.of(2027, 3, 1), 2, 2026, "001515333"),
|
||||
new LeaseLegalTerms.RentRevisionTerms(true, MonthDay.of(3, 1), 2, 2026, "001515333"),
|
||||
null, false, false, false, false, "");
|
||||
Lease lease = Lease.draft(LEASE_ID, LANDLORD_ID, apartmentId, List.of(tenantId), List.of(), period, terms,
|
||||
LeaseType.STANDARD, List.of(), legalTerms);
|
||||
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rent;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseActionAudit;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseAmendmentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseContractVersion;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSnapshot;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.RentTerms;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.RentalPeriod;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentRevision;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentRevisionId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentSchedule;
|
||||
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class RentTermsTimelineTest {
|
||||
private static final LandlordId LANDLORD_ID = new LandlordId("landlord-1");
|
||||
private static final LeaseId LEASE_ID = new LeaseId("lease-1");
|
||||
private static final LocalDate END_ON = LocalDate.of(2026, 8, 19);
|
||||
|
||||
@Test
|
||||
void finalMonthUsesOnlyAmendmentSegmentsOnOrBeforeTheLeaseEnd() {
|
||||
Lease lease = activeLease();
|
||||
amendAmounts(lease, "before-end", LocalDate.of(2026, 8, 10), "1200", "120");
|
||||
amendAmounts(lease, "after-end", LocalDate.of(2026, 8, 25), "1400", "140");
|
||||
lease.recordAgreedEarlyEnd(END_ON, "Fin convenue",
|
||||
new LeaseActionAudit("subject-1", Instant.parse("2026-08-01T00:00:00Z")));
|
||||
|
||||
RentSchedule.EndAmounts amounts = RentTermsTimeline.finalMonthAmounts(
|
||||
lease, List.of(), lease.scheduledOccupancyPeriod(), END_ON);
|
||||
|
||||
assertEquals(new BigDecimal("677.42"), amounts.rent());
|
||||
assertEquals(new BigDecimal("67.74"), amounts.charges());
|
||||
}
|
||||
|
||||
@Test
|
||||
void finalMonthUsesOnlyAppliedIrlSegmentsOnOrBeforeTheLeaseEnd() {
|
||||
Lease lease = activeLease();
|
||||
List<RentRevision> revisions = List.of(
|
||||
appliedRevision("before-end", LocalDate.of(2026, 8, 10), "1000", "1100", "100", "110"),
|
||||
appliedRevision("after-end", LocalDate.of(2026, 8, 25), "1100", "1200", "110", "120"));
|
||||
lease.recordAgreedEarlyEnd(END_ON, "Fin convenue",
|
||||
new LeaseActionAudit("subject-1", Instant.parse("2026-08-01T00:00:00Z")));
|
||||
|
||||
RentSchedule.EndAmounts amounts = RentTermsTimeline.finalMonthAmounts(
|
||||
lease, revisions, lease.scheduledOccupancyPeriod(), END_ON);
|
||||
|
||||
assertEquals(new BigDecimal("645.16"), amounts.rent());
|
||||
assertEquals(new BigDecimal("64.52"), amounts.charges());
|
||||
}
|
||||
|
||||
@Test
|
||||
void financialAmendmentSupersedesAnEarlierIrlForTheRemainingDailySegments() {
|
||||
Lease lease = activeLease();
|
||||
RentRevision revision = appliedRevision(
|
||||
"irl", LocalDate.of(2026, 8, 8), "1000", "1100", "100", "110");
|
||||
amendAmounts(lease, "amendment", LocalDate.of(2026, 8, 15), "1200", "120");
|
||||
lease.recordAgreedEarlyEnd(END_ON, "Fin convenue",
|
||||
new LeaseActionAudit("subject-1", Instant.parse("2026-08-01T00:00:00Z")));
|
||||
|
||||
RentSchedule.EndAmounts amounts = RentTermsTimeline.finalMonthAmounts(
|
||||
lease, List.of(revision), lease.scheduledOccupancyPeriod(), END_ON);
|
||||
|
||||
assertEquals(new BigDecimal("667.74"), amounts.rent());
|
||||
assertEquals(new BigDecimal("66.77"), amounts.charges());
|
||||
}
|
||||
|
||||
private static void amendAmounts(Lease lease, String id, LocalDate effectiveOn,
|
||||
String rent, String charges) {
|
||||
LeaseContractVersion current = lease.contractVersion();
|
||||
LeaseContractVersion after = new LeaseContractVersion(current.landlord(), current.apartment(),
|
||||
current.tenants(), current.guarantors(), current.period(),
|
||||
new RentTerms(new BigDecimal(rent), new BigDecimal(charges), current.terms().securityDeposit()),
|
||||
current.leaseType(), current.specialClauses(), current.legalTerms());
|
||||
lease.amend(new LeaseAmendmentId(id), effectiveOn, after);
|
||||
}
|
||||
|
||||
private static Lease activeLease() {
|
||||
ApartmentId apartmentId = new ApartmentId("apartment-1");
|
||||
TenantId tenantId = new TenantId("tenant-1");
|
||||
RentalPeriod period = new RentalPeriod(LocalDate.of(2026, 1, 1), LocalDate.of(2028, 12, 31));
|
||||
RentTerms terms = new RentTerms(new BigDecimal("1000"), new BigDecimal("100"),
|
||||
new BigDecimal("1000"));
|
||||
Lease lease = Lease.draft(LEASE_ID, LANDLORD_ID, apartmentId, List.of(tenantId), List.of(), period, terms);
|
||||
lease.activate(new LeaseSnapshot(
|
||||
new LeaseSnapshot.Landlord(LANDLORD_ID, "Léa", "Dubois", "lea@example.com", "+331", "1 rue A"),
|
||||
new LeaseSnapshot.Apartment(apartmentId, "Studio", "2 rue B"),
|
||||
List.of(new LeaseSnapshot.Tenant(tenantId, "Camille", "Martin", "camille@example.com", "",
|
||||
"3 rue C")), List.of(), period, terms));
|
||||
return lease;
|
||||
}
|
||||
|
||||
private static RentRevision appliedRevision(String id, LocalDate effectiveOn, String previousRent,
|
||||
String chosenRent, String previousCharges,
|
||||
String chosenCharges) {
|
||||
Instant now = effectiveOn.atStartOfDay(ZoneOffset.UTC).toInstant();
|
||||
RentRevision proposed = new RentRevision(new RentRevisionId(id), LANDLORD_ID, LEASE_ID,
|
||||
new RentRevision.Window(effectiveOn, effectiveOn.plusYears(1).minusDays(1)),
|
||||
new RentRevision.IndexReference("001515333", false, false, RentRevision.IndexSource.INSEE,
|
||||
false, 2, 2025, 2026, new BigDecimal("140"), new BigDecimal("145")),
|
||||
new RentRevision.Amounts(new BigDecimal(previousRent), new BigDecimal(chosenRent),
|
||||
new BigDecimal(chosenRent), new BigDecimal(previousCharges), new BigDecimal(chosenCharges),
|
||||
new BigDecimal(chosenCharges)),
|
||||
new RentRevision.Request(effectiveOn, effectiveOn, "letter", "a".repeat(64), "subject-1", now),
|
||||
List.of(), null, -1);
|
||||
return proposed.apply(new RentRevision.Application(effectiveOn, RentRevision.DeliveryMethod.POSTAL_LETTER,
|
||||
effectiveOn, null, "subject-1", now));
|
||||
}
|
||||
}
|
||||
+34
@@ -232,6 +232,40 @@ class RentalDocumentGenerationServicesTest {
|
||||
verify(leases).find(LANDLORD_ID, LEASE_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rentReceiptRefusesADueThatStillRequiresAnAllocationCorrection() {
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
LeaseRepository leases = mock(LeaseRepository.class);
|
||||
RentalDocumentRenderer renderer = mock(RentalDocumentRenderer.class);
|
||||
RentDue due = RentDue.reconstitute(DUE_ID, LANDLORD_ID, LEASE_ID, YearMonth.of(2026, 8),
|
||||
LocalDate.of(2026, 8, 5), new BigDecimal("450.00"), new BigDecimal("25.00"),
|
||||
new BigDecimal("950.00"), new BigDecimal("900.00"), new BigDecimal("50.00"), 2);
|
||||
when(rentDues.find(LANDLORD_ID, DUE_ID)).thenReturn(Optional.of(due));
|
||||
|
||||
GenerateRentReceiptService service = new GenerateRentReceiptService(
|
||||
rentDues, leases, renderer, Clock.systemUTC());
|
||||
|
||||
assertThrows(RentRuleViolation.class, () -> service.generate(LANDLORD_ID, DUE_ID));
|
||||
verifyNoInteractions(leases, renderer);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rentReceiptRefusesAHiddenZeroAmountLeaseEndDue() {
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
LeaseRepository leases = mock(LeaseRepository.class);
|
||||
RentalDocumentRenderer renderer = mock(RentalDocumentRenderer.class);
|
||||
RentDue due = RentDue.reconstitute(DUE_ID, LANDLORD_ID, LEASE_ID, YearMonth.of(2026, 9),
|
||||
LocalDate.of(2026, 9, 5), BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO,
|
||||
new BigDecimal("900.00"), new BigDecimal("50.00"), 2);
|
||||
when(rentDues.find(LANDLORD_ID, DUE_ID)).thenReturn(Optional.of(due));
|
||||
|
||||
GenerateRentReceiptService service = new GenerateRentReceiptService(
|
||||
rentDues, leases, renderer, Clock.systemUTC());
|
||||
|
||||
assertThrows(RentRuleViolation.class, () -> service.generate(LANDLORD_ID, DUE_ID));
|
||||
verifyNoInteractions(leases, renderer);
|
||||
}
|
||||
|
||||
private static Lease activeAmendedLease() {
|
||||
Lease lease = Lease.draft(LEASE_ID, LANDLORD_ID, APARTMENT_ID, List.of(TENANT_ID), List.of(), PERIOD,
|
||||
INITIAL_TERMS);
|
||||
|
||||
+30
-4
@@ -25,6 +25,7 @@ import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.YearMonth;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.ZoneId;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Supplier;
|
||||
@@ -67,7 +68,8 @@ class ReverseRentPaymentServiceTest {
|
||||
when(reversals.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
RentPaymentReversal reversal = new ReverseRentPaymentService(payments, reversals, rentDues, transactions,
|
||||
() -> "reversal-1", CLOCK).reverse(LANDLORD_ID, PAYMENT_ID, 5, "Erreur bancaire");
|
||||
() -> "reversal-1", CLOCK).reverse(LANDLORD_ID, PAYMENT_ID, 5, "Erreur bancaire",
|
||||
ZoneId.of("Europe/Paris"));
|
||||
|
||||
assertEquals(RentPaymentStatus.REVERSED, payment.status());
|
||||
assertEquals(new BigDecimal("60"), payment.refundedAmount());
|
||||
@@ -87,9 +89,10 @@ class ReverseRentPaymentServiceTest {
|
||||
ReverseRentPaymentService service = new ReverseRentPaymentService(payments, reversals, rentDues,
|
||||
new RecordingTransactions(), () -> "unused", CLOCK);
|
||||
|
||||
assertEquals(existing, service.reverse(LANDLORD_ID, PAYMENT_ID, 999, " Erreur bancaire "));
|
||||
assertEquals(existing, service.reverse(LANDLORD_ID, PAYMENT_ID, 999, " Erreur bancaire ",
|
||||
ZoneId.of("Europe/Paris")));
|
||||
assertThrows(RentPaymentConflict.class,
|
||||
() -> service.reverse(LANDLORD_ID, PAYMENT_ID, 0, "Autre raison"));
|
||||
() -> service.reverse(LANDLORD_ID, PAYMENT_ID, 0, "Autre raison", ZoneId.of("Europe/Paris")));
|
||||
|
||||
verifyNoInteractions(payments, rentDues);
|
||||
verify(reversals, never()).save(any());
|
||||
@@ -109,13 +112,36 @@ class ReverseRentPaymentServiceTest {
|
||||
new RecordingTransactions(), () -> "reversal-1", CLOCK);
|
||||
|
||||
assertThrows(ConcurrentChange.class,
|
||||
() -> service.reverse(LANDLORD_ID, PAYMENT_ID, 3, "Erreur bancaire"));
|
||||
() -> service.reverse(LANDLORD_ID, PAYMENT_ID, 3, "Erreur bancaire",
|
||||
ZoneId.of("Europe/Paris")));
|
||||
|
||||
verifyNoInteractions(rentDues);
|
||||
verify(payments, never()).save(any());
|
||||
verify(reversals, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void reversalDateUsesTheUsersCalendarZone() {
|
||||
Clock boundary = Clock.fixed(Instant.parse("2026-07-19T06:30:00Z"), ZoneOffset.UTC);
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentPaymentReversalRepository reversals = mock(RentPaymentReversalRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
RentPayment payment = RentPayment.reconstitute(PAYMENT_ID, LANDLORD_ID, LEASE_ID,
|
||||
LocalDate.of(2026, 7, 18), new BigDecimal("1000"), RentPaymentStatus.CONFIRMED,
|
||||
List.of(), BigDecimal.ZERO, LocalDate.of(2026, 7, 18), 4);
|
||||
when(reversals.findByPayment(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.empty());
|
||||
when(payments.find(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(payment));
|
||||
when(payments.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(rentDues.saveAll(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(reversals.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
RentPaymentReversal reversed = new ReverseRentPaymentService(payments, reversals, rentDues,
|
||||
new RecordingTransactions(), () -> "reversal-1", boundary).reverse(
|
||||
LANDLORD_ID, PAYMENT_ID, 4, "Erreur bancaire", ZoneId.of("America/Los_Angeles"));
|
||||
|
||||
assertEquals(LocalDate.of(2026, 7, 18), reversed.reversedAt());
|
||||
}
|
||||
|
||||
private static final class RecordingTransactions implements TransactionRunner {
|
||||
private int calls;
|
||||
|
||||
|
||||
+23
-1
@@ -5,6 +5,7 @@ import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
@@ -25,7 +26,28 @@ class ApartmentOccupancyTest {
|
||||
assertThrows(LeaseRuleViolation.class, () -> occupancy.reviseReservation(new LeaseId("lease-1"),
|
||||
new RentalPeriod(LocalDate.of(2026, 1, 1), LocalDate.of(2026, 2, 1))));
|
||||
occupancy.reviseReservation(new LeaseId("lease-1"), period(1, 15));
|
||||
assertEquals(LocalDate.of(2026, 1, 15), occupancy.reservations().getFirst().period().endDate());
|
||||
assertEquals(LocalDate.of(2026, 1, 15),
|
||||
occupancy.reservations().getFirst().period().endOn().orElseThrow());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anOpenReservationBlocksSuccessorsUntilItIsExplicitlyBounded() {
|
||||
ApartmentOccupancy occupancy = ApartmentOccupancy.empty(new LandlordId("landlord-1"),
|
||||
new ApartmentId("apartment-1"));
|
||||
LeaseId current = new LeaseId("lease-open");
|
||||
LocalDate startOn = LocalDate.of(2026, 1, 15);
|
||||
occupancy.reserve(current, OccupancyPeriod.open(startOn));
|
||||
|
||||
assertEquals(Optional.empty(), occupancy.reservations().getFirst().period().endOn());
|
||||
assertThrows(LeaseRuleViolation.class, () -> occupancy.reserve(new LeaseId("successor"),
|
||||
OccupancyPeriod.closed(LocalDate.of(2027, 1, 1), LocalDate.of(2027, 12, 31))));
|
||||
|
||||
occupancy.reviseReservation(current,
|
||||
OccupancyPeriod.closed(startOn, LocalDate.of(2026, 8, 31)));
|
||||
occupancy.reserve(new LeaseId("successor"),
|
||||
OccupancyPeriod.closed(LocalDate.of(2026, 9, 1), LocalDate.of(2027, 8, 31)));
|
||||
|
||||
assertEquals(2, occupancy.reservations().size());
|
||||
}
|
||||
|
||||
private RentalPeriod period(int startDay, int endDay) {
|
||||
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.lease;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class LeaseEndingTest {
|
||||
private static final LandlordId LANDLORD_ID = new LandlordId("landlord-1");
|
||||
private static final ApartmentId APARTMENT_ID = new ApartmentId("apartment-1");
|
||||
private static final TenantId TENANT_ID = new TenantId("tenant-1");
|
||||
private static final LocalDate START_ON = LocalDate.of(2026, 1, 15);
|
||||
private static final LocalDate TODAY = LocalDate.of(2026, 7, 19);
|
||||
private static final RentTerms TERMS = new RentTerms(
|
||||
new BigDecimal("1000"), new BigDecimal("100"), new BigDecimal("1000"));
|
||||
private static final LeaseActionAudit AUDIT = new LeaseActionAudit(
|
||||
"oidc:landlord-1", Instant.parse("2026-07-19T12:30:00Z"));
|
||||
|
||||
@Test
|
||||
void standardLeaseHasNoRealEndUntilNoticeFreezesTheApplicableRule() {
|
||||
Lease lease = activeLease(LeaseType.STANDARD, List.of(TENANT_ID));
|
||||
|
||||
TenantNotice notice = lease.recordTenantNotice(notice("notice-1", LocalDate.of(2026, 7, 10),
|
||||
LocalDate.of(2026, 8, 1), NoticeDeliveryMode.REGISTERED_LETTER, null, true, false), AUDIT);
|
||||
|
||||
assertEquals(1, notice.noticeMonths());
|
||||
assertEquals("2026-07", notice.ruleVersion());
|
||||
assertEquals(LocalDate.of(2026, 8, 10), notice.minimumEndOn());
|
||||
assertEquals(LocalDate.of(2026, 8, 10), lease.contractEndOn().orElseThrow());
|
||||
assertEquals(LeaseEndPhase.NOTICE_PERIOD, lease.endPhaseOn(TODAY));
|
||||
assertTrue(lease.endingSupported());
|
||||
assertTrue(lease.fixedEndOn().isEmpty());
|
||||
assertEquals(AUDIT, notice.audit());
|
||||
}
|
||||
|
||||
@Test
|
||||
void noticeHonorsTheRequestedLaterDepartureAndValidatesCivilDateConfirmations() {
|
||||
Lease lease = activeLease(LeaseType.STANDARD, List.of(TENANT_ID));
|
||||
|
||||
assertThrows(LeaseRuleViolation.class, () -> lease.recordTenantNotice(notice(
|
||||
"late-unconfirmed", TODAY.minusDays(1), TODAY.plusMonths(1), NoticeDeliveryMode.BAILIFF,
|
||||
null, false, false), AUDIT));
|
||||
assertThrows(LeaseRuleViolation.class, () -> lease.recordTenantNotice(notice(
|
||||
"future", TODAY.plusDays(1), TODAY.plusMonths(1), NoticeDeliveryMode.BAILIFF,
|
||||
null, true, false), AUDIT));
|
||||
assertThrows(LeaseRuleViolation.class, () -> lease.recordTenantNotice(notice(
|
||||
"other-unconfirmed", TODAY, TODAY.plusMonths(1), NoticeDeliveryMode.OTHER,
|
||||
"Courriel", false, false), AUDIT));
|
||||
assertThrows(LeaseRuleViolation.class, () -> lease.recordTenantNotice(notice(
|
||||
"other-unnamed", TODAY, TODAY.plusMonths(1), NoticeDeliveryMode.OTHER,
|
||||
" ", false, true), AUDIT));
|
||||
assertThrows(LeaseRuleViolation.class, () -> lease.recordTenantNotice(notice(
|
||||
"other-too-long", TODAY, TODAY.plusMonths(1), NoticeDeliveryMode.OTHER,
|
||||
"x".repeat(201), false, true), AUDIT));
|
||||
|
||||
TenantNotice notice = lease.recordTenantNotice(notice(
|
||||
"notice-1", TODAY, LocalDate.of(2026, 9, 3), NoticeDeliveryMode.OTHER,
|
||||
"Courriel confirmé par le bailleur", false, true), AUDIT);
|
||||
|
||||
assertEquals(LocalDate.of(2026, 9, 3), notice.effectiveEndOn());
|
||||
assertEquals("Courriel confirmé par le bailleur", notice.otherDeliveryMode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void studentFixedEndCapsAnEarlierNoticeAndAllEndingCommandsRejectSharedLeases() {
|
||||
Lease student = activeLease(LeaseType.STUDENT, List.of(TENANT_ID));
|
||||
LocalDate fixedEnd = START_ON.plusMonths(9).minusDays(1);
|
||||
TenantNotice notice = student.recordTenantNotice(notice(
|
||||
"notice-1", TODAY, TODAY.plusMonths(6), NoticeDeliveryMode.HAND_DELIVERY,
|
||||
null, false, false), AUDIT);
|
||||
|
||||
assertEquals(fixedEnd, student.fixedEndOn().orElseThrow());
|
||||
assertEquals(fixedEnd, student.contractEndOn().orElseThrow());
|
||||
assertTrue(notice.effectiveEndOn().isAfter(fixedEnd));
|
||||
|
||||
Lease shared = activeLease(LeaseType.STUDENT, List.of(TENANT_ID, new TenantId("tenant-2")));
|
||||
assertFalse(shared.endingSupported());
|
||||
assertEquals(fixedEnd, shared.contractEndOn().orElseThrow());
|
||||
assertThrows(LeaseRuleViolation.class, () -> shared.recordTenantNotice(notice(
|
||||
"unsupported", TODAY, TODAY.plusMonths(1), NoticeDeliveryMode.BAILIFF,
|
||||
null, false, false), AUDIT));
|
||||
assertThrows(LeaseRuleViolation.class,
|
||||
() -> shared.recordAgreedEarlyEnd(TODAY.plusMonths(1), "Accord", AUDIT));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aStudentNoticeCanAdvanceTheFixedEnd() {
|
||||
Lease student = activeLease(LeaseType.STUDENT, List.of(TENANT_ID));
|
||||
|
||||
student.recordTenantNotice(notice("notice-early", TODAY, TODAY.plusMonths(1),
|
||||
NoticeDeliveryMode.BAILIFF, null, false, false), AUDIT);
|
||||
|
||||
assertEquals(TODAY.plusMonths(1), student.contractEndOn().orElseThrow());
|
||||
assertTrue(student.contractEndOn().orElseThrow().isBefore(student.fixedEndOn().orElseThrow()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void amendmentCannotChangeTheEffectiveEndOrCreateASharedLeaseWhileAnEndingFactIsActive() {
|
||||
Lease student = activeLease(LeaseType.STUDENT, List.of(TENANT_ID));
|
||||
student.recordTenantNotice(notice("notice-1", TODAY, TODAY.plusMonths(6),
|
||||
NoticeDeliveryMode.BAILIFF, null, false, false), AUDIT);
|
||||
LeaseContractVersion current = student.contractVersion();
|
||||
|
||||
LeaseContractVersion shortened = new LeaseContractVersion(current.landlord(), current.apartment(),
|
||||
current.tenants(), current.guarantors(),
|
||||
new RentalPeriod(current.period().startDate(), TODAY.plusDays(10)), current.terms(),
|
||||
current.leaseType(), current.specialClauses(), current.legalTerms());
|
||||
assertThrows(LeaseRuleViolation.class, () -> student.amend(new LeaseAmendmentId("shorter"), TODAY,
|
||||
shortened));
|
||||
|
||||
LeaseSnapshot.Tenant secondTenant = new LeaseSnapshot.Tenant(new TenantId("tenant-2"), "Bob", "Martin",
|
||||
"bob@example.com", "", "1 rue A");
|
||||
LeaseContractVersion shared = new LeaseContractVersion(current.landlord(), current.apartment(),
|
||||
List.of(current.tenants().getFirst(), secondTenant), current.guarantors(), current.period(),
|
||||
current.terms(), current.leaseType(), current.specialClauses(), current.legalTerms());
|
||||
assertThrows(LeaseRuleViolation.class, () -> student.amend(new LeaseAmendmentId("shared"), TODAY, shared));
|
||||
|
||||
assertEquals(0, student.amendments().size());
|
||||
assertEquals(current, student.contractVersion());
|
||||
}
|
||||
|
||||
@Test
|
||||
void withdrawalAndInvalidationAppendAuditFactsWithoutErasingNotices() {
|
||||
Lease lease = activeLease(LeaseType.STANDARD, List.of(TENANT_ID));
|
||||
lease.recordTenantNotice(notice("withdrawn", TODAY, TODAY.plusMonths(1),
|
||||
NoticeDeliveryMode.BAILIFF, null, false, false), AUDIT);
|
||||
|
||||
lease.acceptNoticeWithdrawal(new TenantNoticeId("withdrawn"), TODAY.plusDays(1),
|
||||
"Le locataire reste", TODAY.plusDays(1), AUDIT);
|
||||
|
||||
assertTrue(lease.contractEndOn().isEmpty());
|
||||
assertEquals(TenantNoticeStatus.WITHDRAWN, lease.endingHistory().notices().getFirst().status());
|
||||
assertEquals("Le locataire reste", lease.endingHistory().withdrawals().getFirst().reason());
|
||||
|
||||
lease.recordTenantNotice(notice("invalidated", TODAY, TODAY.plusMonths(2),
|
||||
NoticeDeliveryMode.BAILIFF, null, false, false), AUDIT);
|
||||
lease.invalidateNotice(new TenantNoticeId("invalidated"), TODAY.plusDays(2),
|
||||
"Erreur administrative documentée", TODAY.plusDays(2), AUDIT);
|
||||
|
||||
assertTrue(lease.contractEndOn().isEmpty());
|
||||
assertEquals(2, lease.endingHistory().notices().size());
|
||||
assertEquals(1, lease.endingHistory().withdrawals().size());
|
||||
assertEquals(1, lease.endingHistory().invalidations().size());
|
||||
assertThrows(LeaseRuleViolation.class, () -> lease.acceptNoticeWithdrawal(
|
||||
new TenantNoticeId("invalidated"), TODAY.plusDays(3), "Impossible", TODAY.plusDays(3), AUDIT));
|
||||
}
|
||||
|
||||
@Test
|
||||
void agreedEarlyEndWorksWithoutNoticeAndCanShortenAnExistingNotice() {
|
||||
Lease lease = activeLease(LeaseType.STANDARD, List.of(TENANT_ID));
|
||||
|
||||
lease.recordAgreedEarlyEnd(LocalDate.of(2026, 10, 1), "Accord autonome", AUDIT);
|
||||
assertEquals(LocalDate.of(2026, 10, 1), lease.contractEndOn().orElseThrow());
|
||||
|
||||
lease.recordTenantNotice(notice("notice-1", TODAY, LocalDate.of(2026, 9, 30),
|
||||
NoticeDeliveryMode.REGISTERED_LETTER, null, false, false), AUDIT);
|
||||
assertThrows(LeaseRuleViolation.class,
|
||||
() -> lease.recordAgreedEarlyEnd(LocalDate.of(2026, 9, 30), "Sans effet", AUDIT));
|
||||
assertThrows(LeaseRuleViolation.class,
|
||||
() -> lease.recordAgreedEarlyEnd(LocalDate.of(2026, 10, 1), "Repousse la fin", AUDIT));
|
||||
lease.recordAgreedEarlyEnd(LocalDate.of(2026, 8, 31), "Accord postérieur au congé", AUDIT);
|
||||
|
||||
assertEquals(LocalDate.of(2026, 8, 31), lease.contractEndOn().orElseThrow());
|
||||
assertEquals(2, lease.endingHistory().agreedEarlyEnds().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void evidenceIsVersionedAndEveryReplacementRequiresAReason() {
|
||||
Lease lease = activeLease(LeaseType.STANDARD, List.of(TENANT_ID));
|
||||
TenantNoticeId noticeId = new TenantNoticeId("notice-1");
|
||||
lease.recordTenantNotice(notice("notice-1", TODAY, TODAY.plusMonths(1),
|
||||
NoticeDeliveryMode.BAILIFF, null, false, false), AUDIT);
|
||||
NoticeEvidenceFile firstFile = new NoticeEvidenceFile(
|
||||
"evidence/key-1", "conge.pdf", "application/pdf", 42,
|
||||
"a".repeat(64));
|
||||
NoticeEvidenceFile replacement = new NoticeEvidenceFile(
|
||||
"evidence/key-2", "conge-corrige.pdf", "application/pdf", 84,
|
||||
"b".repeat(64));
|
||||
|
||||
NoticeEvidenceVersion first = lease.appendNoticeEvidence(noticeId, firstFile, null, AUDIT);
|
||||
assertEquals(1, first.version());
|
||||
assertThrows(LeaseRuleViolation.class,
|
||||
() -> lease.appendNoticeEvidence(noticeId, replacement, " ", AUDIT));
|
||||
assertThrows(LeaseRuleViolation.class,
|
||||
() -> lease.appendNoticeEvidence(noticeId, replacement, "x".repeat(501), AUDIT));
|
||||
NoticeEvidenceVersion second = lease.appendNoticeEvidence(
|
||||
noticeId, replacement, "Document corrigé", AUDIT);
|
||||
|
||||
assertEquals(2, second.version());
|
||||
assertEquals("Document corrigé", second.reason());
|
||||
assertEquals(List.of(first, second), lease.endingHistory().evidenceVersions(noticeId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void evidenceCanBeAddedToAHistoricWithdrawnNoticeAfterTheLeaseBecomesShared() {
|
||||
Lease lease = activeLease(LeaseType.STANDARD, List.of(TENANT_ID));
|
||||
TenantNoticeId noticeId = new TenantNoticeId("notice-1");
|
||||
lease.recordTenantNotice(notice("notice-1", TODAY, TODAY.plusMonths(1),
|
||||
NoticeDeliveryMode.BAILIFF, null, false, false), AUDIT);
|
||||
lease.acceptNoticeWithdrawal(noticeId, TODAY, "Le locataire reste", TODAY, AUDIT);
|
||||
LeaseContractVersion current = lease.contractVersion();
|
||||
LeaseSnapshot.Tenant secondTenant = new LeaseSnapshot.Tenant(new TenantId("tenant-2"), "Bob", "Martin",
|
||||
"bob@example.com", "", "1 rue A");
|
||||
LeaseContractVersion shared = new LeaseContractVersion(current.landlord(), current.apartment(),
|
||||
List.of(current.tenants().getFirst(), secondTenant), current.guarantors(), current.period(),
|
||||
current.terms(), current.leaseType(), current.specialClauses(), current.legalTerms());
|
||||
lease.amend(new LeaseAmendmentId("shared"), TODAY, shared);
|
||||
|
||||
NoticeEvidenceVersion evidence = lease.appendNoticeEvidence(noticeId,
|
||||
new NoticeEvidenceFile("evidence/key-1", "conge.pdf", "application/pdf", 42, "a".repeat(64)),
|
||||
null, AUDIT);
|
||||
|
||||
assertEquals(1, evidence.version());
|
||||
assertFalse(lease.endingSupported());
|
||||
}
|
||||
|
||||
@Test
|
||||
void onlyConfirmedClosureAfterTheContractEndArchivesTheLease() {
|
||||
Lease lease = activeLease(LeaseType.STANDARD, List.of(TENANT_ID));
|
||||
lease.recordAgreedEarlyEnd(TODAY.plusDays(10), "Fin convenue", AUDIT);
|
||||
|
||||
assertThrows(LeaseRuleViolation.class,
|
||||
() -> lease.close(TODAY.plusDays(9), false, TODAY.plusDays(10), AUDIT));
|
||||
assertThrows(LeaseRuleViolation.class,
|
||||
() -> lease.close(TODAY.plusDays(11), true, TODAY.plusDays(10), AUDIT));
|
||||
assertEquals(LeaseStatus.ACTIVE, lease.status());
|
||||
|
||||
lease.close(TODAY.plusDays(9), true, TODAY.plusDays(10), AUDIT);
|
||||
|
||||
assertEquals(LeaseStatus.ARCHIVED, lease.status());
|
||||
assertEquals(LeaseEndPhase.ARCHIVED, lease.endPhaseOn(TODAY.plusDays(10)));
|
||||
assertEquals(TODAY.plusDays(9), lease.endingHistory().closure().orElseThrow().keysReturnedOn());
|
||||
}
|
||||
|
||||
@Test
|
||||
void phasesAreDerivedFromCivilDatesAndRecordedFacts() {
|
||||
Lease lease = activeLease(LeaseType.STANDARD, List.of(TENANT_ID));
|
||||
LocalDate endOn = TODAY.plusMonths(1);
|
||||
|
||||
assertEquals(LeaseEndPhase.ACTIVE, lease.endPhaseOn(TODAY));
|
||||
lease.recordAgreedEarlyEnd(endOn, "Fin convenue", AUDIT);
|
||||
|
||||
assertEquals(LeaseEndPhase.NOTICE_PERIOD, lease.endPhaseOn(TODAY));
|
||||
assertEquals(LeaseEndPhase.AWAITING_CLOSURE, lease.endPhaseOn(endOn));
|
||||
assertEquals(LeaseEndPhase.UNAUTHORIZED_OCCUPANCY, lease.endPhaseOn(endOn.plusDays(1)));
|
||||
}
|
||||
|
||||
private Lease activeLease(LeaseType type, List<TenantId> tenantIds) {
|
||||
RentalPeriod input = new RentalPeriod(START_ON, START_ON.plusYears(5));
|
||||
Lease lease = Lease.draft(new LeaseId("lease-" + type + "-" + tenantIds.size()), LANDLORD_ID,
|
||||
APARTMENT_ID, tenantIds, List.of(), input, TERMS, type);
|
||||
List<LeaseSnapshot.Tenant> tenants = tenantIds.stream()
|
||||
.map(id -> new LeaseSnapshot.Tenant(id, "Alice", "Martin", "alice@example.com", "", "1 rue A"))
|
||||
.toList();
|
||||
lease.activate(new LeaseSnapshot(
|
||||
new LeaseSnapshot.Landlord(LANDLORD_ID, "Louise", "Durand", "louise@example.com", "+331",
|
||||
"2 rue B"),
|
||||
new LeaseSnapshot.Apartment(APARTMENT_ID, "Studio", "3 rue C"),
|
||||
tenants, List.of(), lease.period(), TERMS, type));
|
||||
return lease;
|
||||
}
|
||||
|
||||
private TenantNoticeCommand notice(String id, LocalDate receivedOn, LocalDate requestedDepartureOn,
|
||||
NoticeDeliveryMode deliveryMode, String otherDeliveryMode,
|
||||
boolean lateConfirmed, boolean nonStandardConfirmed) {
|
||||
return new TenantNoticeCommand(new TenantNoticeId(id), receivedOn, requestedDepartureOn, deliveryMode,
|
||||
otherDeliveryMode, lateConfirmed, nonStandardConfirmed, TODAY);
|
||||
}
|
||||
}
|
||||
@@ -76,11 +76,10 @@ class LeaseTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void activationRejectsIncompleteOrMismatchedFactsAndOnlyActiveLeasesArchive() {
|
||||
void activationRejectsIncompleteOrMismatchedFacts() {
|
||||
Lease empty = Lease.draft(new LeaseId("lease-empty"), LANDLORD_ID, APARTMENT_ID, List.of(), List.of(),
|
||||
PERIOD, TERMS);
|
||||
assertThrows(LeaseRuleViolation.class, () -> empty.activate(snapshot()));
|
||||
assertThrows(LeaseRuleViolation.class, empty::archive);
|
||||
|
||||
Lease lease = Lease.draft(new LeaseId("lease-1"), LANDLORD_ID, APARTMENT_ID, List.of(TENANT_ID),
|
||||
List.of(), PERIOD, TERMS);
|
||||
@@ -90,9 +89,6 @@ class LeaseTest {
|
||||
assertThrows(LeaseRuleViolation.class, () -> lease.activate(wrongApartment));
|
||||
|
||||
lease.activate(snapshot());
|
||||
lease.archive();
|
||||
lease.archive();
|
||||
assertEquals(LeaseStatus.ARCHIVED, lease.status());
|
||||
assertEquals(LeaseStatus.ARCHIVED, Lease.reconstitute(lease.id(), LANDLORD_ID, APARTMENT_ID,
|
||||
List.of(TENANT_ID), List.of(), PERIOD, TERMS, LeaseStatus.ARCHIVED, snapshot()).status());
|
||||
assertThrows(LeaseRuleViolation.class, () -> Lease.reconstitute(new LeaseId("bad-draft"), LANDLORD_ID,
|
||||
@@ -161,8 +157,10 @@ class LeaseTest {
|
||||
void onlyActiveLeasesMayBeAmended() {
|
||||
Lease draft = Lease.draft(new LeaseId("lease-draft"), LANDLORD_ID, APARTMENT_ID, List.of(TENANT_ID),
|
||||
List.of(), PERIOD, TERMS);
|
||||
Lease archived = activeLease();
|
||||
archived.archive();
|
||||
Lease active = activeLease();
|
||||
Lease archived = Lease.reconstitute(active.id(), active.landlordId(), active.apartmentId(),
|
||||
active.tenantIds(), active.guarantors(), active.period(), active.terms(), LeaseStatus.ARCHIVED,
|
||||
active.snapshot(), active.amendments(), active.version());
|
||||
|
||||
assertThrows(LeaseRuleViolation.class, () -> draft.amend(
|
||||
new LeaseAmendmentId("draft-amendment"), LocalDate.of(2026, 7, 1),
|
||||
|
||||
@@ -10,6 +10,7 @@ import fr.nessar.lmnporganizer.rental.domain.lease.LeaseContractVersion;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSnapshot;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseType;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.OccupancyPeriod;
|
||||
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.RentTerms;
|
||||
@@ -126,6 +127,172 @@ class RentDueTest {
|
||||
assertThrows(RentRuleViolation.class, () -> due.release(new BigDecimal("301")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void leaseEndProratesTheLastMonthRemovesUnallocatedFutureDuesAndFlagsOverAllocation() {
|
||||
LeaseId leaseId = new LeaseId("lease-1");
|
||||
LandlordId landlordId = new LandlordId("landlord-1");
|
||||
RentDue august = RentDue.create(new RentDueId("august"), landlordId, leaseId,
|
||||
YearMonth.of(2026, 8), LocalDate.of(2026, 8, 5), new BigDecimal("1000"),
|
||||
new BigDecimal("100"));
|
||||
august.allocate(new BigDecimal("600"));
|
||||
RentDue september = RentDue.create(new RentDueId("september"), landlordId, leaseId,
|
||||
YearMonth.of(2026, 9), LocalDate.of(2026, 9, 5), new BigDecimal("1000"),
|
||||
new BigDecimal("100"));
|
||||
RentDue october = RentDue.create(new RentDueId("october"), landlordId, leaseId,
|
||||
YearMonth.of(2026, 10), LocalDate.of(2026, 10, 5), new BigDecimal("1000"),
|
||||
new BigDecimal("100"));
|
||||
october.allocate(new BigDecimal("200"));
|
||||
|
||||
RentSchedule.EndReconciliation result = RentSchedule.reconcileEnd(
|
||||
List.of(august, september, october), OccupancyPeriod.open(LocalDate.of(2026, 1, 1)),
|
||||
LocalDate.of(2026, 8, 15));
|
||||
|
||||
assertEquals(new BigDecimal("483.87"), august.rent());
|
||||
assertEquals(new BigDecimal("48.39"), august.charges());
|
||||
assertEquals(new BigDecimal("600"), august.allocatedAmount());
|
||||
assertEquals(BigDecimal.ZERO, august.remainingAmount());
|
||||
assertTrue(august.correctionRequired());
|
||||
assertEquals(List.of(august, september, october), result.toSave());
|
||||
assertEquals(List.of(), result.toRemove());
|
||||
assertEquals(BigDecimal.ZERO, september.total());
|
||||
assertEquals(BigDecimal.ZERO, october.total());
|
||||
assertEquals(new BigDecimal("200"), october.allocatedAmount());
|
||||
assertTrue(result.correctionRequired());
|
||||
|
||||
august.release(new BigDecimal("100"));
|
||||
|
||||
assertFalse(august.correctionRequired());
|
||||
assertEquals(new BigDecimal("32.26"), august.remainingAmount());
|
||||
assertEquals(RentDueSettlement.PARTIALLY_PAID, august.settlement());
|
||||
|
||||
october.release(new BigDecimal("200"));
|
||||
|
||||
assertFalse(october.correctionRequired());
|
||||
assertEquals(BigDecimal.ZERO, october.remainingAmount());
|
||||
assertEquals(RentDueSettlement.PAID, october.settlement());
|
||||
}
|
||||
|
||||
@Test
|
||||
void expandingAnEndRestoresTheExactAmountsThatExistedBeforeLeaseEndReconciliation() {
|
||||
RentDue august = RentDue.create(new RentDueId("august"), new LandlordId("landlord-1"),
|
||||
new LeaseId("lease-1"), YearMonth.of(2026, 8), LocalDate.of(2026, 8, 5),
|
||||
new BigDecimal("1123.45"), new BigDecimal("98.76"));
|
||||
RentDue september = RentDue.create(new RentDueId("september"), new LandlordId("landlord-1"),
|
||||
new LeaseId("lease-1"), YearMonth.of(2026, 9), LocalDate.of(2026, 9, 5),
|
||||
new BigDecimal("1200.00"), new BigDecimal("110.00"));
|
||||
OccupancyPeriod scheduled = OccupancyPeriod.open(LocalDate.of(2026, 1, 1));
|
||||
RentSchedule.reconcileEnd(List.of(august, september), scheduled, LocalDate.of(2026, 8, 19));
|
||||
|
||||
RentSchedule.EndReconciliation restored = RentSchedule.restoreAfterEndExpansion(
|
||||
List.of(august, september), scheduled, java.util.Optional.empty());
|
||||
|
||||
assertEquals(List.of(august, september), restored.toSave());
|
||||
assertEquals(new BigDecimal("1123.45"), august.rent());
|
||||
assertEquals(new BigDecimal("98.76"), august.charges());
|
||||
assertEquals(new BigDecimal("1200.00"), september.rent());
|
||||
assertEquals(new BigDecimal("110.00"), september.charges());
|
||||
assertFalse(august.adjustedForLeaseEnd());
|
||||
assertFalse(september.adjustedForLeaseEnd());
|
||||
}
|
||||
|
||||
@Test
|
||||
void amendmentUpdatesHiddenPostEndingAmountsBeforeAWithdrawalRestoresThem() {
|
||||
LeaseId leaseId = new LeaseId("lease-1");
|
||||
LandlordId landlordId = new LandlordId("landlord-1");
|
||||
RentDue september = RentDue.create(new RentDueId("september"), landlordId, leaseId,
|
||||
YearMonth.of(2026, 9), LocalDate.of(2026, 9, 5), new BigDecimal("1000"),
|
||||
new BigDecimal("100"));
|
||||
OccupancyPeriod scheduled = OccupancyPeriod.open(LocalDate.of(2026, 1, 1));
|
||||
RentSchedule.reconcileEnd(List.of(september), scheduled, LocalDate.of(2026, 8, 19));
|
||||
LeaseContractVersion before = version(LocalDate.of(2026, 12, 31), "1000", "100");
|
||||
LeaseContractVersion after = version(LocalDate.of(2026, 12, 31), "1200", "120");
|
||||
LeaseAmended amendment = new LeaseAmended(leaseId, landlordId, new LeaseAmendment(
|
||||
new LeaseAmendmentId("amendment-1"), LocalDate.of(2026, 8, 1), before, after));
|
||||
|
||||
RentSchedule.reconcile(amendment, List.of(september), month -> new RentDueId("new-" + month));
|
||||
RentSchedule.reconcileEnd(List.of(september), scheduled, LocalDate.of(2026, 8, 19));
|
||||
RentSchedule.restoreAfterEndExpansion(List.of(september), scheduled, java.util.Optional.empty());
|
||||
|
||||
assertEquals(new BigDecimal("1200.00"), september.rent());
|
||||
assertEquals(new BigDecimal("120.00"), september.charges());
|
||||
}
|
||||
|
||||
@Test
|
||||
void irlUpdatesHiddenAmountsAndNeverReextendsTheFinalNoticeMonth() {
|
||||
LandlordId landlordId = new LandlordId("landlord-1");
|
||||
LeaseId leaseId = new LeaseId("lease-1");
|
||||
RentDue august = RentDue.create(new RentDueId("august"), landlordId, leaseId,
|
||||
YearMonth.of(2026, 8), LocalDate.of(2026, 8, 5), new BigDecimal("1000"),
|
||||
new BigDecimal("100"));
|
||||
RentDue september = RentDue.create(new RentDueId("september"), landlordId, leaseId,
|
||||
YearMonth.of(2026, 9), LocalDate.of(2026, 9, 5), new BigDecimal("1000"),
|
||||
new BigDecimal("100"));
|
||||
OccupancyPeriod scheduled = OccupancyPeriod.open(LocalDate.of(2026, 1, 1));
|
||||
LocalDate endOn = LocalDate.of(2026, 8, 19);
|
||||
RentSchedule.reconcileEnd(List.of(august, september), scheduled, endOn);
|
||||
|
||||
RentSchedule.applyRevision(List.of(august, september), LocalDate.of(2026, 8, 1),
|
||||
new RentSchedule.RevisionAmounts(new BigDecimal("1000"), new BigDecimal("100"),
|
||||
new BigDecimal("1250"), new BigDecimal("125")));
|
||||
RentSchedule.reconcileEnd(List.of(august, september), scheduled, endOn);
|
||||
|
||||
assertEquals(new BigDecimal("766.13"), august.rent());
|
||||
assertEquals(new BigDecimal("76.61"), august.charges());
|
||||
assertEquals(BigDecimal.ZERO, september.total());
|
||||
RentSchedule.restoreAfterEndExpansion(List.of(august, september), scheduled,
|
||||
java.util.Optional.empty());
|
||||
assertEquals(new BigDecimal("1250.00"), september.rent());
|
||||
assertEquals(new BigDecimal("125.00"), september.charges());
|
||||
}
|
||||
|
||||
@Test
|
||||
void reconstitutionOnlyAllowsZeroOrOverallocatedDuesWithAnExplicitLeaseEndBaseline() {
|
||||
assertThrows(RentRuleViolation.class, () -> RentDue.reconstitute(
|
||||
new RentDueId("zero"), new LandlordId("landlord-1"), new LeaseId("lease-1"),
|
||||
YearMonth.of(2026, 9), LocalDate.of(2026, 9, 5), BigDecimal.ZERO, BigDecimal.ZERO,
|
||||
BigDecimal.ZERO, -1));
|
||||
assertThrows(RentRuleViolation.class, () -> RentDue.reconstitute(
|
||||
new RentDueId("over"), new LandlordId("landlord-1"), new LeaseId("lease-1"),
|
||||
YearMonth.of(2026, 9), LocalDate.of(2026, 9, 5), new BigDecimal("10"), BigDecimal.ZERO,
|
||||
new BigDecimal("11"), -1));
|
||||
|
||||
RentDue ended = RentDue.reconstitute(new RentDueId("ended"), new LandlordId("landlord-1"),
|
||||
new LeaseId("lease-1"), YearMonth.of(2026, 9), LocalDate.of(2026, 9, 5),
|
||||
BigDecimal.ZERO, BigDecimal.ZERO, new BigDecimal("11"),
|
||||
new BigDecimal("1000"), new BigDecimal("100"), -1);
|
||||
assertTrue(ended.correctionRequired());
|
||||
}
|
||||
|
||||
@Test
|
||||
void leaseEndDoesNotDoubleProrateAStandardLeaseFirstMonth() {
|
||||
LeaseActivated activation = new LeaseActivated(new LeaseId("lease-1"), new LandlordId("landlord-1"),
|
||||
new RentalPeriod(LocalDate.of(2026, 8, 15), LocalDate.of(2027, 8, 14)),
|
||||
new RentTerms(new BigDecimal("1000"), new BigDecimal("100"), BigDecimal.ZERO));
|
||||
RentDue august = RentSchedule.from(activation, month -> new RentDueId("due-" + month)).getFirst();
|
||||
|
||||
RentSchedule.reconcileEnd(List.of(august), OccupancyPeriod.open(activation.period().startDate()),
|
||||
LocalDate.of(2026, 8, 20));
|
||||
|
||||
assertEquals(new BigDecimal("193.55"), august.rent());
|
||||
assertEquals(new BigDecimal("19.36"), august.charges());
|
||||
}
|
||||
|
||||
@Test
|
||||
void leaseEndDoesNotDoubleProrateAStudentLeaseAlreadyBoundedFinalMonth() {
|
||||
LocalDate startOn = LocalDate.of(2026, 1, 15);
|
||||
LocalDate fixedEndOn = startOn.plusMonths(9).minusDays(1);
|
||||
LeaseActivated activation = new LeaseActivated(new LeaseId("lease-1"), new LandlordId("landlord-1"),
|
||||
new RentalPeriod(startOn, fixedEndOn),
|
||||
new RentTerms(new BigDecimal("1000"), new BigDecimal("100"), BigDecimal.ZERO));
|
||||
RentDue october = RentSchedule.from(activation, month -> new RentDueId("due-" + month)).getLast();
|
||||
|
||||
RentSchedule.reconcileEnd(List.of(october), OccupancyPeriod.closed(startOn, fixedEndOn),
|
||||
LocalDate.of(2026, 10, 10));
|
||||
|
||||
assertEquals(new BigDecimal("322.58"), october.rent());
|
||||
assertEquals(new BigDecimal("32.26"), october.charges());
|
||||
}
|
||||
|
||||
@Test
|
||||
void amendmentReconciliationProratesItsMonthAndExtendsTheSchedule() {
|
||||
LeaseId leaseId = new LeaseId("lease-1");
|
||||
|
||||
+10
-5
@@ -122,7 +122,7 @@ class ReplacementRentalApiJourneyIT {
|
||||
"supplementJustification":"Vue exceptionnelle"},
|
||||
"previousRent":{"applicable":true,"amount":850,"lastRevisionDate":"2025-08-01",
|
||||
"lastPaymentDate":"2026-07-01","tenantConfirmation":true},
|
||||
"rentRevision":{"enabled":true,"annualRevisionDate":"2027-08-01",
|
||||
"rentRevision":{"enabled":true,"annualRevisionDay":29,"annualRevisionMonth":2,
|
||||
"referenceQuarter":2,"referenceYear":2026,"seriesId":"IRL-METROPOLE"},
|
||||
"recentWork":{"description":"Isolation","amount":5000},
|
||||
"coOwnershipRulesApplicable":true,"priorRentalAuthorizationApplicable":true,
|
||||
@@ -134,6 +134,8 @@ class ReplacementRentalApiJourneyIT {
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.legalTerms.rentControl.increasedReferenceRent").value(24))
|
||||
.andExpect(jsonPath("$.legalTerms.previousRent.lastPaymentDate").value("2026-07-01"))
|
||||
.andExpect(jsonPath("$.legalTerms.rentRevision.annualRevisionDay").value(29))
|
||||
.andExpect(jsonPath("$.legalTerms.rentRevision.annualRevisionMonth").value(2))
|
||||
.andExpect(jsonPath("$.legalTerms.rentRevision.seriesId").value("IRL-METROPOLE"))
|
||||
.andExpect(jsonPath("$.legalTerms.recentWork.amount").value(5000))
|
||||
.andExpect(jsonPath("$.legalTerms.jointAndSeveralLiability").value(true))
|
||||
@@ -246,7 +248,7 @@ class ReplacementRentalApiJourneyIT {
|
||||
{"apartmentId":"%s","tenantIds":["%s"],"guarantors":[],
|
||||
"startDate":"2026-07-01","endDate":"2026-08-31",
|
||||
"monthlyRent":900.00,"monthlyCharges":50.00,"securityDeposit":900.00,
|
||||
"legalTerms":{"rentRevision":{"enabled":true,"annualRevisionDate":"2027-07-01",
|
||||
"legalTerms":{"rentRevision":{"enabled":true,"annualRevisionDay":1,"annualRevisionMonth":7,
|
||||
"referenceQuarter":2,"referenceYear":2026,
|
||||
"seriesId":"IRL-METROPOLE"},
|
||||
"recentWork":{"description":"Peinture","amount":1200},
|
||||
@@ -403,7 +405,8 @@ class ReplacementRentalApiJourneyIT {
|
||||
mockMvc.perform(post("/api/rent-payments/{paymentId}/reversal", surplusPaymentId)
|
||||
.with(owner()).with(csrf()).contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"paymentVersion":%d,"reason":"Correction après remboursement"}
|
||||
{"paymentVersion":%d,"reason":"Correction après remboursement",
|
||||
"timeZone":"Europe/Paris"}
|
||||
""".formatted(refundedPaymentVersion)))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.reversal.reason").value("Correction après remboursement"))
|
||||
@@ -506,7 +509,8 @@ class ReplacementRentalApiJourneyIT {
|
||||
.andExpect(status().isNotFound());
|
||||
mockMvc.perform(post("/api/leases/{id}/amendments", leaseId).with(otherLandlord()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content("""
|
||||
{"effectiveDate":"2026-07-20","tenantIds":["%s"],"guarantors":[],
|
||||
{"effectiveDate":"2026-07-20","timeZone":"Europe/Paris",
|
||||
"tenantIds":["%s"],"guarantors":[],
|
||||
"endDate":"2026-07-31","monthlyRent":901.00,"monthlyCharges":50.00}
|
||||
""".formatted(tenantId)))
|
||||
.andExpect(status().isNotFound());
|
||||
@@ -526,7 +530,8 @@ class ReplacementRentalApiJourneyIT {
|
||||
.andExpect(status().isNotFound());
|
||||
mockMvc.perform(post("/api/rent-payments/{id}/reversal", paymentId)
|
||||
.with(otherLandlord()).with(csrf()).contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"paymentVersion\":0,\"reason\":\"Foreign mutation\"}"))
|
||||
.content("{\"paymentVersion\":0,\"reason\":\"Foreign mutation\","
|
||||
+ "\"timeZone\":\"Europe/Paris\"}"))
|
||||
.andExpect(status().isNotFound());
|
||||
mockMvc.perform(post("/api/rent-payments/{id}/refunds", surplusPaymentId)
|
||||
.with(otherLandlord()).with(csrf()).contentType(MediaType.APPLICATION_JSON)
|
||||
|
||||
@@ -9,3 +9,5 @@ A new `Lease` is always a mutable, permanently deletable `DRAFT`; draft overlap
|
||||
Lease activation, amendment reconciliation, payment confirmation and later allocation, reversal, and refund confirmation run in Mongo transactions whenever they change more than one aggregate document. Versioned aggregate documents reject stale concurrent writes; occupancy reservations, contract snapshots, generated or reconciled dues, payments, reversals, and refunds therefore commit or roll back without a single-process lock or manual database compensation. A payment owns at most one allocation per due, preventing two stale copies of one due from being mutated inside the same command.
|
||||
|
||||
Lease PDFs, payment receipts, and rent receipts are rendered from frozen contract versions. A payment receipt is available only for a confirmed payment and reports its complete amount, allocations, refunds, and remaining surplus. A rent receipt is available only for a fully settled due and reports exactly that due's rent and charges; it never treats unallocated payment surplus as rent income.
|
||||
|
||||
Lease ending is the deliberate exception to the rule that a settled due is never recalculated. Recording an earlier contractual end preserves each due's allocations but replaces its visible amounts with the exact amount owed through the end date. That final amount is calculated by occupied day from the effective-dated contract and applied IRL timeline, so an amendment or revision after the end contributes nothing. Later months remain persisted as hidden zero-amount tombstones carrying their pre-ending rent and charges; this makes withdrawal or invalidation restore the schedule exactly, including intervening amendments and revisions, without recreating aggregate identities. A tombstone is excluded from schedule responses and cannot produce a rent receipt. If the preserved allocations exceed a recalculated due, `correctionRequired` is exposed and rent-receipt generation remains blocked until the existing reversal workflow corrects the allocations. Payment recording, later allocation, and reversal remain available for existing dues after the lease is archived.
|
||||
@@ -2,6 +2,6 @@
|
||||
|
||||
**Status**: accepted
|
||||
|
||||
Lease endings are represented by immutable, audited facts owned by `Lease`: a standard furnished lease has no real end until a tenant notice is recorded, while a student lease retains its fixed nine-month end. Tenant notices freeze the legal rule and calculated notice period that applied when received; agreed early endings, accepted withdrawals, administrative invalidations, evidence versions, and closure confirmations are appended without erasing history. Operational labels such as notice in progress, awaiting closure, and unauthorized occupancy are derived from those facts and civil dates rather than persisted as additional lease statuses or advanced by a scheduler.
|
||||
Lease endings are represented by immutable, audited facts owned by `Lease`: a standard furnished lease has no real end until a tenant notice or mutually agreed early end is recorded, while a student lease retains its fixed nine-month end. Tenant notices freeze the legal rule and calculated notice period that applied when received; agreed early endings, accepted withdrawals, administrative invalidations, evidence versions, and closure confirmations are appended without erasing history. Operational labels such as notice in progress, awaiting closure, and unauthorized occupancy are derived from those facts and civil dates rather than persisted as additional lease statuses or advanced by a scheduler. User-entered civil dates are validated in the browser-provided IANA time zone and stored without an artificial UTC conversion, while audit timestamps are instants recorded in UTC.
|
||||
|
||||
A known contract end immediately bounds apartment occupancy and unpaid rent dues, including a prorated final month, while confirmed payments remain correctable only through the existing explicit reversal workflow. Closure after the contract end and key return is the sole transition to `ARCHIVED`; it neither clears outstanding debts nor prevents later settlement of existing dues. A successor lease may be finalized without date overlap before the former lease is closed, but any later withdrawal or correction that would create overlap is rejected. Landlord-issued notices, tenant notices for shared leases, security-deposit settlement, exit inspections, and occupancy compensation remain outside this increment.
|
||||
A known contract end immediately bounds the otherwise open-ended apartment occupancy and rent dues, including a prorated final month and removal of later unallocated dues. When that recalculation makes an existing allocation exceed its due, the due temporarily exposes a required financial correction while the confirmed payment and its allocation remain immutable; only the existing explicit reversal workflow removes the allocation before the correct payment is recorded. Closure once the contract end is reached and the actual key-return date is known is the sole transition to `ARCHIVED`; keys may have been returned earlier, and closure neither clears outstanding debts nor prevents later settlement of existing dues. A successor lease may be finalized without date overlap before the former lease is closed, but any later withdrawal or correction that would create overlap is rejected. Landlord-issued notices, tenant notices for shared leases, security-deposit settlement, exit inspections, and occupancy compensation remain outside this increment.
|
||||
@@ -8,6 +8,7 @@ describe('routes', () => {
|
||||
'apartments',
|
||||
'tenants',
|
||||
'leases/:leaseId/finalize',
|
||||
'leases/:leaseId/end',
|
||||
'leases',
|
||||
'rent-payments',
|
||||
'settings',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ApartmentsPage } from './components/apartments-page/apartments-page';
|
||||
import { DashboardPage } from './components/dashboard-page/dashboard-page';
|
||||
import { LeasesPage } from './components/leases-page/leases-page';
|
||||
import { LeaseFinalizationPage } from './components/lease-finalization-page/lease-finalization-page';
|
||||
import { LeaseEndPage } from './components/lease-end-page/lease-end-page';
|
||||
import { RentPaymentsPage } from './components/rent-payments-page/rent-payments-page';
|
||||
import { SettingsPage } from './components/settings-page/settings-page';
|
||||
import { TenantsPage } from './components/tenants-page/tenants-page';
|
||||
@@ -12,6 +13,7 @@ export const routes: Routes = [
|
||||
{ path: 'apartments', component: ApartmentsPage },
|
||||
{ path: 'tenants', component: TenantsPage },
|
||||
{ path: 'leases/:leaseId/finalize', component: LeaseFinalizationPage },
|
||||
{ path: 'leases/:leaseId/end', component: LeaseEndPage },
|
||||
{ path: 'leases', component: LeasesPage },
|
||||
{ path: 'rent-payments', component: RentPaymentsPage },
|
||||
{ path: 'settings', component: SettingsPage },
|
||||
|
||||
@@ -161,3 +161,11 @@
|
||||
.card-error {
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
.lease-end-unsupported {
|
||||
margin: 0.875rem 0 0;
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.75rem;
|
||||
background: var(--surface-soft, #f6f7f8);
|
||||
color: var(--text-muted, #5f6368);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
@@ -50,10 +50,13 @@
|
||||
}
|
||||
<dl class="terms">
|
||||
<div>
|
||||
<dt>Période</dt>
|
||||
<dt>{{ lease().leaseType === 'STANDARD' ? 'Période initiale' : 'Période' }}</dt>
|
||||
<dd>
|
||||
{{ civilDate(lease().startDate) }} →
|
||||
{{ civilDate(lease().endDate) }}
|
||||
@if (lease().leaseType === 'STANDARD') {
|
||||
· bail reconductible
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
@@ -150,7 +153,26 @@
|
||||
Supprimer
|
||||
}
|
||||
</button>
|
||||
} @else if (lease().status === 'ACTIVE' || lease().status === 'ARCHIVED') {
|
||||
<button
|
||||
mat-flat-button
|
||||
type="button"
|
||||
class="lease-end-action"
|
||||
(click)="leaseEndRequested.emit(leaseId)"
|
||||
>
|
||||
{{
|
||||
lease().status === 'ACTIVE' && lease().tenantIds.length === 1
|
||||
? 'Gérer la fin du bail'
|
||||
: 'Voir la fin du bail'
|
||||
}}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@if (lease().status === 'ACTIVE' && lease().tenantIds.length > 1) {
|
||||
<p class="lease-end-unsupported" role="note">
|
||||
La fin de bail en colocation n’est pas encore prise en charge. Aucun congé ni aucune clôture
|
||||
ne peut être enregistré ici.
|
||||
</p>
|
||||
}
|
||||
</article>
|
||||
@@ -194,6 +194,7 @@ describe('LeaseCard', () => {
|
||||
fixture.componentInstance.finalizationRequested.subscribe(requested);
|
||||
|
||||
const text = fixture.nativeElement.textContent.replace(/\s+/g, ' ');
|
||||
expect(text).toContain('Période initiale 1 janvier 2026 → 1 janvier 2027 · bail reconductible');
|
||||
expect(text).toContain('Générer / télécharger le bail');
|
||||
expect(text).toContain('Modifier');
|
||||
expect(text).toContain('Supprimer');
|
||||
@@ -215,6 +216,61 @@ describe('LeaseCard', () => {
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Finaliser le bail');
|
||||
});
|
||||
|
||||
it('manages an active single lease and keeps read access for colocation and archives', () => {
|
||||
const fixture = TestBed.createComponent(LeaseCard);
|
||||
fixture.componentRef.setInput('apartments', []);
|
||||
fixture.componentRef.setInput('tenants', []);
|
||||
fixture.componentRef.setInput('lease', {
|
||||
id: 'lease-1',
|
||||
apartmentId: 'apartment-1',
|
||||
tenantIds: ['tenant-1'],
|
||||
guarantors: [],
|
||||
specialClauses: [],
|
||||
legalTerms: emptyLeaseLegalTerms(),
|
||||
leaseType: 'STANDARD',
|
||||
startDate: '2026-01-01',
|
||||
endDate: '2028-12-31',
|
||||
monthlyRent: 800,
|
||||
monthlyCharges: 50,
|
||||
securityDeposit: 800,
|
||||
dueDay: 5,
|
||||
chargeMethod: 'FLAT_RATE',
|
||||
indexFlatRate: false,
|
||||
status: 'ACTIVE',
|
||||
});
|
||||
const requested = vi.fn();
|
||||
fixture.componentInstance.leaseEndRequested.subscribe(requested);
|
||||
fixture.detectChanges();
|
||||
|
||||
const action: HTMLButtonElement = fixture.nativeElement.querySelector('.lease-end-action');
|
||||
expect(action.textContent).toContain('Gérer la fin du bail');
|
||||
action.click();
|
||||
expect(requested).toHaveBeenCalledWith('lease-1');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Archiver');
|
||||
|
||||
fixture.componentRef.setInput('lease', {
|
||||
...fixture.componentInstance.lease(),
|
||||
tenantIds: ['tenant-1', 'tenant-2'],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
const colocationAction: HTMLButtonElement =
|
||||
fixture.nativeElement.querySelector('.lease-end-action');
|
||||
expect(colocationAction.textContent).toContain('Voir la fin du bail');
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'La fin de bail en colocation n’est pas encore prise en charge',
|
||||
);
|
||||
|
||||
fixture.componentRef.setInput('lease', {
|
||||
...fixture.componentInstance.lease(),
|
||||
status: 'ARCHIVED',
|
||||
});
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('.lease-end-action').textContent).toContain(
|
||||
'Voir la fin du bail',
|
||||
);
|
||||
});
|
||||
|
||||
it('offers an IRL preview only for an active lease with the revision clause enabled', () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
|
||||
@@ -5,16 +5,10 @@ import { Apartment } from '../../services/apartment.service';
|
||||
import { Lease, LeaseRevisionMetadata, RentDue } from '../../services/lease.service';
|
||||
import { Tenant } from '../../services/tenant.service';
|
||||
import { apartmentDocumentTypeLabel } from '../../services/apartment-document-label';
|
||||
import { formatFrenchCivilDate } from '../../services/civil-date';
|
||||
import { leaseWarningDescription } from '../../services/lease-warning-label';
|
||||
import { RentRevisionPreviewComponent } from '../rent-revision-preview/rent-revision-preview';
|
||||
|
||||
const frenchCivilDate = new Intl.DateTimeFormat('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
|
||||
@Component({
|
||||
selector: 'app-lease-card',
|
||||
imports: [MatButtonModule, MatProgressSpinnerModule, RentRevisionPreviewComponent],
|
||||
@@ -38,7 +32,9 @@ export class LeaseCard {
|
||||
readonly dossierRequested = output<Lease>();
|
||||
readonly editRequested = output<Lease>();
|
||||
readonly finalizationRequested = output<string>();
|
||||
readonly leaseEndRequested = output<string>();
|
||||
readonly rentRevisionOpen = signal(false);
|
||||
readonly civilDate = formatFrenchCivilDate;
|
||||
|
||||
toggleRentRevision(): void {
|
||||
this.rentRevisionOpen.update((open) => !open);
|
||||
@@ -76,9 +72,4 @@ export class LeaseCard {
|
||||
warningLabel(code: string): string {
|
||||
return leaseWarningDescription(code);
|
||||
}
|
||||
|
||||
civilDate(date: string): string {
|
||||
const [year, month, day] = date.split('-').map(Number);
|
||||
return frenchCivilDate.format(new Date(Date.UTC(year, month - 1, day)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
:host,
|
||||
.notice-card,
|
||||
header > div {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
.notice-card {
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--border, #dfe3e6);
|
||||
border-radius: 1rem;
|
||||
background: var(--surface, #fff);
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
h3,
|
||||
h4 {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
.notice-facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
|
||||
gap: 1rem;
|
||||
margin-block: 1.25rem;
|
||||
}
|
||||
dt {
|
||||
color: var(--text-muted, #5f6368);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
dd {
|
||||
margin: 0.25rem 0 0;
|
||||
}
|
||||
.evidence-list ul {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
padding-inline-start: 1.25rem;
|
||||
}
|
||||
.evidence-attestation {
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.75rem;
|
||||
background: var(--surface-soft, #f6f7f8);
|
||||
color: var(--text-muted, #5f6368);
|
||||
}
|
||||
.evidence-list li {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.notice-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
|
||||
gap: 1rem;
|
||||
margin-block-start: 1.25rem;
|
||||
padding-block-start: 1.25rem;
|
||||
border-block-start: 1px solid var(--border, #dfe3e6);
|
||||
}
|
||||
fieldset,
|
||||
label {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
fieldset {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
legend {
|
||||
margin-block-end: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
input,
|
||||
textarea {
|
||||
min-width: 0;
|
||||
min-height: 2.75rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
input:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: 2px solid var(--accent, #236b5d);
|
||||
outline-offset: 0.15rem;
|
||||
}
|
||||
.operation-error {
|
||||
color: var(--danger, #b42318);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
<article class="notice-card">
|
||||
<header>
|
||||
<div>
|
||||
<p class="eyebrow">{{ statusLabel() }}</p>
|
||||
<h3>Congé reçu le {{ civilDate(notice().receivedOn) }}</h3>
|
||||
</div>
|
||||
<span>Départ demandé le {{ civilDate(notice().requestedDepartureOn) }}</span>
|
||||
</header>
|
||||
<dl class="notice-facts">
|
||||
<div>
|
||||
<dt>Remise</dt>
|
||||
<dd>
|
||||
{{ deliveryModeLabel(notice().deliveryMode) }}
|
||||
@if (notice().otherDeliveryMode) {
|
||||
— {{ notice().otherDeliveryMode }}
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Règle figée</dt>
|
||||
<dd>
|
||||
Préavis retenu : {{ notice().noticeMonths }} mois · fin minimale le
|
||||
{{ civilDate(notice().minimumEndOn) }} · Version de règle : {{ notice().ruleVersion }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Audit</dt>
|
||||
<dd>{{ notice().authorId }} · {{ notice().recordedAt }}</dd>
|
||||
</div>
|
||||
@if (notice().withdrawal; as withdrawal) {
|
||||
<div>
|
||||
<dt>Retrait accepté</dt>
|
||||
<dd>
|
||||
{{ civilDate(withdrawal.acceptedOn) }} · {{ withdrawal.reason }} ·
|
||||
{{ withdrawal.authorId }} · {{ withdrawal.recordedAt }}
|
||||
</dd>
|
||||
</div>
|
||||
}
|
||||
@if (notice().invalidation; as invalidation) {
|
||||
<div>
|
||||
<dt>Invalidation administrative</dt>
|
||||
<dd>
|
||||
{{ civilDate(invalidation.invalidatedOn) }} · {{ invalidation.reason }} ·
|
||||
{{ invalidation.authorId }} · {{ invalidation.recordedAt }}
|
||||
</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
@if (notice().recordedWithoutEvidenceConfirmed) {
|
||||
<p class="evidence-attestation">
|
||||
Le bailleur a confirmé que ce congé a été enregistré sans justificatif initial.
|
||||
</p>
|
||||
}
|
||||
|
||||
<section class="evidence-list" aria-label="Versions des preuves">
|
||||
<h4>Preuves conservées</h4>
|
||||
@if (notice().evidenceVersions.length) {
|
||||
<ul>
|
||||
@for (evidence of notice().evidenceVersions; track evidence.version) {
|
||||
<li>
|
||||
<span>
|
||||
Version {{ evidence.version }} · {{ evidence.fileName }}
|
||||
@if (evidence.reason) {
|
||||
— {{ evidence.reason }}
|
||||
}
|
||||
</span>
|
||||
<span>Empreinte SHA-256 : {{ evidence.sha256 }}</span>
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
class="evidence-download"
|
||||
[disabled]="endService.evidenceDownloading(leaseId(), notice().id, evidence.version)"
|
||||
(click)="
|
||||
endService.downloadEvidence(
|
||||
leaseId(),
|
||||
notice().id,
|
||||
evidence.version,
|
||||
evidence.fileName
|
||||
)
|
||||
"
|
||||
>
|
||||
{{
|
||||
endService.evidenceDownloading(leaseId(), notice().id, evidence.version)
|
||||
? 'Téléchargement…'
|
||||
: 'Télécharger'
|
||||
}}
|
||||
</button>
|
||||
@if (
|
||||
endService.evidenceDownloadError(leaseId(), notice().id, evidence.version);
|
||||
as downloadError
|
||||
) {
|
||||
<span class="operation-error" role="alert">{{ downloadError }}</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<p>Aucune preuve téléversée.</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
@if (lifecycleMutationsEnabled() || evidenceUploadEnabled()) {
|
||||
<div class="notice-actions">
|
||||
@if (evidenceUploadEnabled()) {
|
||||
<form
|
||||
class="evidence-form"
|
||||
[attr.aria-busy]="evidencePending()"
|
||||
(submit)="submitEvidence(); $event.preventDefault()"
|
||||
>
|
||||
<fieldset [disabled]="evidencePending()">
|
||||
<legend>Ajouter une version de preuve</legend>
|
||||
<label>
|
||||
<span>Fichier (tout format)</span>
|
||||
<input type="file" (change)="selectEvidence($event)" />
|
||||
</label>
|
||||
@if (notice().evidenceVersions.length) {
|
||||
<label>
|
||||
<span>Raison de la nouvelle version</span>
|
||||
<input
|
||||
type="text"
|
||||
maxlength="500"
|
||||
[value]="evidenceReason()"
|
||||
(input)="updateEvidenceReason($event)"
|
||||
/>
|
||||
</label>
|
||||
}
|
||||
@if (evidenceError() || evidenceOperationError()) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ evidenceError() || evidenceOperationError() }}
|
||||
</p>
|
||||
}
|
||||
<button mat-stroked-button type="submit" [disabled]="evidencePending()">
|
||||
{{ evidencePending() ? 'Ajout…' : 'Ajouter la preuve' }}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
}
|
||||
|
||||
@if (lifecycleMutationsEnabled() && notice().status === 'ACTIVE') {
|
||||
<form
|
||||
[formRoot]="withdrawalForm"
|
||||
[attr.aria-busy]="withdrawalPending()"
|
||||
(submit)="submitWithdrawal(); $event.preventDefault()"
|
||||
>
|
||||
<fieldset [disabled]="withdrawalPending()">
|
||||
<legend>Accepter un retrait</legend>
|
||||
<label
|
||||
><span>Date d’acceptation</span
|
||||
><input type="date" [formField]="withdrawalForm.acceptedOn"
|
||||
/></label>
|
||||
<label
|
||||
><span>Raison</span><textarea rows="2" [formField]="withdrawalForm.reason"></textarea>
|
||||
</label>
|
||||
@if (withdrawalError() || withdrawalOperationError()) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ withdrawalError() || withdrawalOperationError() }}
|
||||
</p>
|
||||
}
|
||||
<button mat-stroked-button type="submit" [disabled]="withdrawalPending()">
|
||||
{{ withdrawalPending() ? 'Acceptation…' : 'Accepter le retrait' }}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
<form
|
||||
[formRoot]="invalidationForm"
|
||||
[attr.aria-busy]="invalidationPending()"
|
||||
(submit)="submitInvalidation(); $event.preventDefault()"
|
||||
>
|
||||
<fieldset [disabled]="invalidationPending()">
|
||||
<legend>Invalider administrativement</legend>
|
||||
<label
|
||||
><span>Date d’invalidation</span
|
||||
><input type="date" [formField]="invalidationForm.invalidatedOn"
|
||||
/></label>
|
||||
<label
|
||||
><span>Raison</span
|
||||
><textarea rows="2" [formField]="invalidationForm.reason"></textarea>
|
||||
</label>
|
||||
@if (invalidationError() || invalidationOperationError()) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ invalidationError() || invalidationOperationError() }}
|
||||
</p>
|
||||
}
|
||||
<button mat-stroked-button type="submit" [disabled]="invalidationPending()">
|
||||
{{ invalidationPending() ? 'Invalidation…' : 'Invalider ce congé' }}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</article>
|
||||
@@ -0,0 +1,111 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { vi } from 'vitest';
|
||||
import { LeaseEndNotice } from '../../services/lease-end.service';
|
||||
import { LeaseEndService } from '../../services/lease-end.service';
|
||||
import { LeaseEndNoticeCard } from './lease-end-notice-card';
|
||||
|
||||
describe('LeaseEndNoticeCard', () => {
|
||||
const downloadEvidence = vi.fn();
|
||||
const evidenceDownloading = vi.fn().mockReturnValue(false);
|
||||
const evidenceDownloadError = vi.fn().mockReturnValue('');
|
||||
const notice: LeaseEndNotice = {
|
||||
id: 'notice-1',
|
||||
status: 'ACTIVE',
|
||||
receivedOn: '2026-07-19',
|
||||
requestedDepartureOn: '2026-08-19',
|
||||
deliveryMode: 'REGISTERED_LETTER',
|
||||
otherDeliveryMode: null,
|
||||
noticeMonths: 1,
|
||||
minimumEndOn: '2026-08-19',
|
||||
ruleVersion: '2026-07',
|
||||
recordedWithoutEvidenceConfirmed: true,
|
||||
authorId: 'owner-1',
|
||||
recordedAt: '2026-07-19T10:00:00Z',
|
||||
evidenceVersions: [
|
||||
{
|
||||
version: 1,
|
||||
fileName: 'preuve.jpg',
|
||||
contentType: 'image/jpeg',
|
||||
size: 1200,
|
||||
sha256: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
reason: null,
|
||||
authorId: 'owner-1',
|
||||
recordedAt: '2026-07-19T10:00:00Z',
|
||||
},
|
||||
],
|
||||
withdrawal: null,
|
||||
invalidation: null,
|
||||
};
|
||||
|
||||
function createFixture() {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{
|
||||
provide: LeaseEndService,
|
||||
useValue: { downloadEvidence, evidenceDownloading, evidenceDownloadError },
|
||||
},
|
||||
],
|
||||
});
|
||||
const fixture = TestBed.createComponent(LeaseEndNoticeCard);
|
||||
fixture.componentRef.setInput('leaseId', 'lease-1');
|
||||
fixture.componentRef.setInput('notice', notice);
|
||||
fixture.componentRef.setInput('timeZone', 'Europe/Paris');
|
||||
fixture.componentRef.setInput('lifecycleMutationsEnabled', true);
|
||||
fixture.componentRef.setInput('evidenceUploadEnabled', true);
|
||||
fixture.componentRef.setInput('withdrawalPending', false);
|
||||
fixture.componentRef.setInput('invalidationPending', false);
|
||||
fixture.componentRef.setInput('evidencePending', false);
|
||||
fixture.detectChanges();
|
||||
return fixture;
|
||||
}
|
||||
|
||||
it('shows the rule snapshot and asks a reason from evidence version two', () => {
|
||||
const fixture = createFixture();
|
||||
const requested = vi.fn();
|
||||
fixture.componentInstance.evidenceRequested.subscribe(requested);
|
||||
const evidence = new File(['new'], 'preuve-2.txt', { type: 'text/plain' });
|
||||
fixture.componentInstance.evidence.set(evidence);
|
||||
|
||||
fixture.componentInstance.submitEvidence();
|
||||
expect(fixture.componentInstance.evidenceError()).toContain('raison');
|
||||
|
||||
fixture.componentInstance.evidenceReason.set('Version plus lisible');
|
||||
fixture.componentInstance.submitEvidence();
|
||||
expect(requested).toHaveBeenCalledWith({
|
||||
noticeId: 'notice-1',
|
||||
request: { timeZone: 'Europe/Paris', evidence, reason: 'Version plus lisible' },
|
||||
});
|
||||
expect(fixture.nativeElement.textContent).toContain('Préavis retenu : 1 mois');
|
||||
expect(fixture.nativeElement.textContent).toContain('Version de règle : 2026-07');
|
||||
expect(fixture.nativeElement.textContent).toContain('enregistré sans justificatif');
|
||||
expect(fixture.nativeElement.querySelector('input[type="text"]').maxLength).toBe(500);
|
||||
expect(
|
||||
[...fixture.nativeElement.querySelectorAll('textarea')].map(
|
||||
(textarea: HTMLTextAreaElement) => textarea.maxLength,
|
||||
),
|
||||
).toEqual([500, 500]);
|
||||
});
|
||||
|
||||
it('emits dated withdrawal and evidence download intents', () => {
|
||||
const fixture = createFixture();
|
||||
const withdrawal = vi.fn();
|
||||
fixture.componentInstance.withdrawalRequested.subscribe(withdrawal);
|
||||
fixture.componentInstance.withdrawalModel.set({
|
||||
acceptedOn: '2026-07-20',
|
||||
reason: 'Retrait écrit accepté',
|
||||
});
|
||||
|
||||
fixture.componentInstance.submitWithdrawal();
|
||||
fixture.nativeElement.querySelector('.evidence-download').click();
|
||||
|
||||
expect(withdrawal).toHaveBeenCalledWith({
|
||||
noticeId: 'notice-1',
|
||||
request: {
|
||||
timeZone: 'Europe/Paris',
|
||||
acceptedOn: '2026-07-20',
|
||||
reason: 'Retrait écrit accepté',
|
||||
},
|
||||
});
|
||||
expect(downloadEvidence).toHaveBeenCalledWith('lease-1', 'notice-1', 1, 'preuve.jpg');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Component, inject, input, output, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { FormField, FormRoot, form, maxLength } from '@angular/forms/signals';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { formatFrenchCivilDate } from '../../services/civil-date';
|
||||
import {
|
||||
AcceptWithdrawalRequest,
|
||||
AppendEvidenceRequest,
|
||||
InvalidateNoticeRequest,
|
||||
LeaseEndNotice,
|
||||
LeaseEndService,
|
||||
NoticeDeliveryMode,
|
||||
} from '../../services/lease-end.service';
|
||||
|
||||
export interface NoticeAction<T> {
|
||||
noticeId: string;
|
||||
request: T;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-lease-end-notice-card',
|
||||
imports: [FormsModule, FormField, FormRoot, MatButtonModule],
|
||||
templateUrl: './lease-end-notice-card.html',
|
||||
styleUrl: './lease-end-notice-card.css',
|
||||
})
|
||||
export class LeaseEndNoticeCard {
|
||||
readonly endService = inject(LeaseEndService);
|
||||
readonly leaseId = input.required<string>();
|
||||
readonly notice = input.required<LeaseEndNotice>();
|
||||
readonly timeZone = input.required<string>();
|
||||
readonly lifecycleMutationsEnabled = input.required<boolean>();
|
||||
readonly evidenceUploadEnabled = input.required<boolean>();
|
||||
readonly withdrawalPending = input(false);
|
||||
readonly invalidationPending = input(false);
|
||||
readonly evidencePending = input(false);
|
||||
readonly withdrawalOperationError = input('');
|
||||
readonly invalidationOperationError = input('');
|
||||
readonly evidenceOperationError = input('');
|
||||
readonly withdrawalRequested = output<NoticeAction<AcceptWithdrawalRequest>>();
|
||||
readonly invalidationRequested = output<NoticeAction<InvalidateNoticeRequest>>();
|
||||
readonly evidenceRequested = output<NoticeAction<AppendEvidenceRequest>>();
|
||||
readonly withdrawalModel = signal({ acceptedOn: '', reason: '' });
|
||||
readonly invalidationModel = signal({ invalidatedOn: '', reason: '' });
|
||||
readonly withdrawalForm = form(this.withdrawalModel, (fields) => {
|
||||
maxLength(fields.reason, 500);
|
||||
});
|
||||
readonly invalidationForm = form(this.invalidationModel, (fields) => {
|
||||
maxLength(fields.reason, 500);
|
||||
});
|
||||
readonly evidence = signal<File | null>(null);
|
||||
readonly evidenceReason = signal('');
|
||||
readonly withdrawalError = signal('');
|
||||
readonly invalidationError = signal('');
|
||||
readonly evidenceError = signal('');
|
||||
|
||||
readonly civilDate = formatFrenchCivilDate;
|
||||
|
||||
statusLabel(): string {
|
||||
return {
|
||||
ACTIVE: 'Congé en cours',
|
||||
WITHDRAWN: 'Retrait accepté',
|
||||
INVALIDATED: 'Congé invalidé',
|
||||
}[this.notice().status];
|
||||
}
|
||||
|
||||
deliveryModeLabel(mode: NoticeDeliveryMode): string {
|
||||
return {
|
||||
REGISTERED_LETTER: 'Lettre recommandée avec avis de réception',
|
||||
BAILIFF: 'Acte d’un commissaire de justice',
|
||||
HAND_DELIVERY: 'Remise en main propre contre récépissé',
|
||||
OTHER: 'Autre mode',
|
||||
}[mode];
|
||||
}
|
||||
|
||||
selectEvidence(event: Event): void {
|
||||
const input = event.target;
|
||||
this.evidence.set(input instanceof HTMLInputElement ? (input.files?.item(0) ?? null) : null);
|
||||
if (input instanceof HTMLInputElement) input.value = '';
|
||||
}
|
||||
|
||||
updateEvidenceReason(event: Event): void {
|
||||
const input = event.target;
|
||||
this.evidenceReason.set(input instanceof HTMLInputElement ? input.value : '');
|
||||
}
|
||||
|
||||
submitWithdrawal(): void {
|
||||
if (this.withdrawalPending()) return;
|
||||
const model = this.withdrawalModel();
|
||||
if (!model.acceptedOn || !model.reason.trim()) {
|
||||
this.withdrawalError.set('Renseignez la date et la raison du retrait accepté.');
|
||||
return;
|
||||
}
|
||||
this.withdrawalError.set('');
|
||||
this.withdrawalRequested.emit({
|
||||
noticeId: this.notice().id,
|
||||
request: {
|
||||
timeZone: this.timeZone(),
|
||||
acceptedOn: model.acceptedOn,
|
||||
reason: model.reason.trim(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
submitInvalidation(): void {
|
||||
if (this.invalidationPending()) return;
|
||||
const model = this.invalidationModel();
|
||||
if (!model.invalidatedOn || !model.reason.trim()) {
|
||||
this.invalidationError.set('Renseignez la date et la raison de l’invalidation.');
|
||||
return;
|
||||
}
|
||||
this.invalidationError.set('');
|
||||
this.invalidationRequested.emit({
|
||||
noticeId: this.notice().id,
|
||||
request: {
|
||||
timeZone: this.timeZone(),
|
||||
invalidatedOn: model.invalidatedOn,
|
||||
reason: model.reason.trim(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
submitEvidence(): void {
|
||||
if (this.evidencePending()) return;
|
||||
const evidence = this.evidence();
|
||||
const reason = this.evidenceReason().trim();
|
||||
if (!evidence) {
|
||||
this.evidenceError.set('Sélectionnez une preuve à conserver.');
|
||||
return;
|
||||
}
|
||||
if (this.notice().evidenceVersions.length > 0 && !reason) {
|
||||
this.evidenceError.set('Indiquez la raison de cette nouvelle version de preuve.');
|
||||
return;
|
||||
}
|
||||
this.evidenceError.set('');
|
||||
this.evidenceRequested.emit({
|
||||
noticeId: this.notice().id,
|
||||
request: { timeZone: this.timeZone(), evidence, ...(reason ? { reason } : {}) },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
:host {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
.notice-form {
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--border, #dfe3e6);
|
||||
border-radius: 1rem;
|
||||
background: var(--surface, #fff);
|
||||
}
|
||||
h2 {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
.notice-form > p,
|
||||
small {
|
||||
color: var(--text-muted, #5f6368);
|
||||
}
|
||||
fieldset {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
.field-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
label:not(.confirmation-field) {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
min-width: 0;
|
||||
}
|
||||
input,
|
||||
select {
|
||||
min-width: 0;
|
||||
min-height: 2.75rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.confirmation-field {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.65rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.confirmation-field input {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.missing-evidence-warning {
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.75rem;
|
||||
background: var(--surface-soft, #f6f7f8);
|
||||
}
|
||||
.missing-evidence-warning p {
|
||||
margin-block-start: 0;
|
||||
color: var(--text-muted, #5f6368);
|
||||
}
|
||||
input:focus-visible,
|
||||
select:focus-visible {
|
||||
outline: 2px solid var(--accent, #236b5d);
|
||||
outline-offset: 0.15rem;
|
||||
}
|
||||
.operation-error {
|
||||
color: var(--danger, #b42318);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<section class="notice-form" aria-labelledby="notice-form-title">
|
||||
<h2 id="notice-form-title">Enregistrer un congé</h2>
|
||||
<p>Les dates sont enregistrées comme des dates civiles dans le fuseau {{ timeZone() }}.</p>
|
||||
<form
|
||||
[formRoot]="noticeForm"
|
||||
[attr.aria-busy]="submitting()"
|
||||
(submit)="submit(); $event.preventDefault()"
|
||||
>
|
||||
<fieldset [disabled]="submitting()">
|
||||
<div class="field-grid">
|
||||
<label>
|
||||
<span>Date de réception</span>
|
||||
<input type="date" [formField]="noticeForm.receivedOn" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Départ demandé</span>
|
||||
<input type="date" [formField]="noticeForm.requestedDepartureOn" />
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
<span>Mode de remise</span>
|
||||
<select [formField]="noticeForm.deliveryMode">
|
||||
<option value="">Choisissez un mode</option>
|
||||
@for (mode of deliveryModes; track mode.value) {
|
||||
<option [value]="mode.value">{{ mode.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
@if (model().deliveryMode === 'OTHER') {
|
||||
<label>
|
||||
<span>Mode de remise utilisé</span>
|
||||
<input type="text" [formField]="noticeForm.otherDeliveryMode" />
|
||||
</label>
|
||||
<label class="confirmation-field">
|
||||
<input type="checkbox" [formField]="noticeForm.nonStandardDeliveryConfirmed" />
|
||||
<span>Je confirme avoir vérifié ce mode de remise non standard.</span>
|
||||
</label>
|
||||
}
|
||||
@if (lateRegistration()) {
|
||||
<label class="confirmation-field">
|
||||
<input type="checkbox" [formField]="noticeForm.lateRegistrationConfirmed" />
|
||||
<span>Je confirme enregistrer aujourd’hui un congé reçu à une date passée.</span>
|
||||
</label>
|
||||
}
|
||||
<label>
|
||||
<span>Preuve (facultative, tout format)</span>
|
||||
<input class="notice-evidence" type="file" (change)="selectEvidence($event)" />
|
||||
<small>{{ evidence()?.name ?? 'Aucun fichier sélectionné' }}</small>
|
||||
</label>
|
||||
@if (!evidence()) {
|
||||
<div class="missing-evidence-warning">
|
||||
<p>Aucun justificatif ne sera associé à ce congé lors de son enregistrement.</p>
|
||||
<label class="confirmation-field">
|
||||
<input type="checkbox" [formField]="noticeForm.missingEvidenceConfirmed" />
|
||||
<span>Je confirme enregistrer ce congé sans justificatif.</span>
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
@if (validationError() || operationError()) {
|
||||
<p class="operation-error" role="alert">{{ validationError() || operationError() }}</p>
|
||||
}
|
||||
<button mat-flat-button type="submit" [disabled]="submitting()">
|
||||
{{ submitting() ? 'Enregistrement…' : 'Enregistrer le congé' }}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</section>
|
||||
@@ -0,0 +1,119 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { vi } from 'vitest';
|
||||
import { LeaseEndNoticeForm } from './lease-end-notice-form';
|
||||
|
||||
describe('LeaseEndNoticeForm', () => {
|
||||
beforeEach(() => TestBed.configureTestingModule({}));
|
||||
|
||||
it('requires explicit confirmations for a late registration and OTHER delivery', () => {
|
||||
const fixture = TestBed.createComponent(LeaseEndNoticeForm);
|
||||
fixture.componentRef.setInput('timeZone', 'Europe/Paris');
|
||||
fixture.componentRef.setInput('today', '2026-07-19');
|
||||
fixture.componentRef.setInput('submitting', false);
|
||||
fixture.detectChanges();
|
||||
fixture.componentInstance.model.set({
|
||||
receivedOn: '2026-07-18',
|
||||
requestedDepartureOn: '2026-08-18',
|
||||
deliveryMode: 'OTHER',
|
||||
otherDeliveryMode: 'Remise par le gardien',
|
||||
lateRegistrationConfirmed: false,
|
||||
nonStandardDeliveryConfirmed: false,
|
||||
missingEvidenceConfirmed: false,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('input[type="text"]').maxLength).toBe(200);
|
||||
|
||||
fixture.componentInstance.submit();
|
||||
expect(fixture.componentInstance.validationError()).toContain('enregistrement tardif');
|
||||
|
||||
fixture.componentInstance.model.update((model) => ({
|
||||
...model,
|
||||
lateRegistrationConfirmed: true,
|
||||
}));
|
||||
fixture.componentInstance.submit();
|
||||
expect(fixture.componentInstance.validationError()).toContain('mode de remise non standard');
|
||||
});
|
||||
|
||||
it('emits civil dates, timezone and arbitrary evidence without converting the day', () => {
|
||||
const fixture = TestBed.createComponent(LeaseEndNoticeForm);
|
||||
fixture.componentRef.setInput('timeZone', 'America/Los_Angeles');
|
||||
fixture.componentRef.setInput('today', '2026-07-19');
|
||||
fixture.componentRef.setInput('submitting', false);
|
||||
const requested = vi.fn();
|
||||
fixture.componentInstance.noticeRequested.subscribe(requested);
|
||||
fixture.detectChanges();
|
||||
fixture.componentInstance.model.update((model) => ({
|
||||
...model,
|
||||
missingEvidenceConfirmed: true,
|
||||
}));
|
||||
const evidence = new File(['photo'], 'preuve.jpg', { type: 'image/jpeg' });
|
||||
const input = fixture.nativeElement.querySelector('.notice-evidence');
|
||||
Object.defineProperty(input, 'files', { value: { item: () => evidence } });
|
||||
input.dispatchEvent(new Event('change'));
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance.model().missingEvidenceConfirmed).toBe(false);
|
||||
expect(fixture.nativeElement.querySelector('.missing-evidence-warning')).toBeNull();
|
||||
fixture.componentInstance.model.set({
|
||||
receivedOn: '2026-07-19',
|
||||
requestedDepartureOn: '2026-08-19',
|
||||
deliveryMode: 'OTHER',
|
||||
otherDeliveryMode: 'Remise par le gardien',
|
||||
lateRegistrationConfirmed: false,
|
||||
nonStandardDeliveryConfirmed: true,
|
||||
missingEvidenceConfirmed: false,
|
||||
});
|
||||
|
||||
fixture.componentInstance.submit();
|
||||
|
||||
expect(requested).toHaveBeenCalledWith({
|
||||
timeZone: 'America/Los_Angeles',
|
||||
receivedOn: '2026-07-19',
|
||||
requestedDepartureOn: '2026-08-19',
|
||||
deliveryMode: 'OTHER',
|
||||
otherDeliveryMode: 'Remise par le gardien',
|
||||
lateRegistrationConfirmed: false,
|
||||
nonStandardDeliveryConfirmed: true,
|
||||
missingEvidenceConfirmed: false,
|
||||
evidence,
|
||||
});
|
||||
expect(input.getAttribute('accept')).toBeNull();
|
||||
});
|
||||
|
||||
it('requires and sends an explicit attestation when no evidence is selected', () => {
|
||||
const fixture = TestBed.createComponent(LeaseEndNoticeForm);
|
||||
fixture.componentRef.setInput('timeZone', 'Europe/Paris');
|
||||
fixture.componentRef.setInput('today', '2026-07-19');
|
||||
fixture.componentRef.setInput('submitting', false);
|
||||
const requested = vi.fn();
|
||||
fixture.componentInstance.noticeRequested.subscribe(requested);
|
||||
fixture.detectChanges();
|
||||
fixture.componentInstance.model.set({
|
||||
receivedOn: '2026-07-19',
|
||||
requestedDepartureOn: '2026-08-19',
|
||||
deliveryMode: 'HAND_DELIVERY',
|
||||
otherDeliveryMode: '',
|
||||
lateRegistrationConfirmed: false,
|
||||
nonStandardDeliveryConfirmed: false,
|
||||
missingEvidenceConfirmed: false,
|
||||
});
|
||||
|
||||
fixture.componentInstance.submit();
|
||||
expect(fixture.componentInstance.validationError()).toContain('sans justificatif');
|
||||
|
||||
fixture.componentInstance.model.update((model) => ({
|
||||
...model,
|
||||
missingEvidenceConfirmed: true,
|
||||
}));
|
||||
fixture.componentInstance.submit();
|
||||
expect(requested).toHaveBeenCalledWith({
|
||||
timeZone: 'Europe/Paris',
|
||||
receivedOn: '2026-07-19',
|
||||
requestedDepartureOn: '2026-08-19',
|
||||
deliveryMode: 'HAND_DELIVERY',
|
||||
lateRegistrationConfirmed: false,
|
||||
nonStandardDeliveryConfirmed: false,
|
||||
missingEvidenceConfirmed: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Component, computed, input, output, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { FormField, FormRoot, form, maxLength } from '@angular/forms/signals';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { NoticeDeliveryMode, RecordNoticeRequest } from '../../services/lease-end.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-lease-end-notice-form',
|
||||
imports: [FormsModule, FormField, FormRoot, MatButtonModule],
|
||||
templateUrl: './lease-end-notice-form.html',
|
||||
styleUrl: './lease-end-notice-form.css',
|
||||
})
|
||||
export class LeaseEndNoticeForm {
|
||||
readonly timeZone = input.required<string>();
|
||||
readonly today = input.required<string>();
|
||||
readonly submitting = input.required<boolean>();
|
||||
readonly operationError = input('');
|
||||
readonly noticeRequested = output<RecordNoticeRequest>();
|
||||
readonly model = signal({
|
||||
receivedOn: '',
|
||||
requestedDepartureOn: '',
|
||||
deliveryMode: '' as NoticeDeliveryMode | '',
|
||||
otherDeliveryMode: '',
|
||||
lateRegistrationConfirmed: false,
|
||||
nonStandardDeliveryConfirmed: false,
|
||||
missingEvidenceConfirmed: false,
|
||||
});
|
||||
readonly noticeForm = form(this.model, (fields) => {
|
||||
maxLength(fields.otherDeliveryMode, 200);
|
||||
});
|
||||
readonly evidence = signal<File | null>(null);
|
||||
readonly validationError = signal('');
|
||||
readonly lateRegistration = computed(
|
||||
() => this.model().receivedOn !== '' && this.model().receivedOn < this.today(),
|
||||
);
|
||||
readonly deliveryModes = [
|
||||
{ value: 'REGISTERED_LETTER', label: 'Lettre recommandée avec avis de réception' },
|
||||
{ value: 'BAILIFF', label: 'Acte d’un commissaire de justice' },
|
||||
{ value: 'HAND_DELIVERY', label: 'Remise en main propre contre récépissé' },
|
||||
{ value: 'OTHER', label: 'Autre mode' },
|
||||
] as const satisfies readonly { value: NoticeDeliveryMode; label: string }[];
|
||||
|
||||
selectEvidence(event: Event): void {
|
||||
const input = event.target;
|
||||
const evidence = input instanceof HTMLInputElement ? (input.files?.item(0) ?? null) : null;
|
||||
this.evidence.set(evidence);
|
||||
if (evidence) {
|
||||
this.model.update((model) => ({ ...model, missingEvidenceConfirmed: false }));
|
||||
}
|
||||
if (input instanceof HTMLInputElement) input.value = '';
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.submitting()) return;
|
||||
const model = this.model();
|
||||
if (!model.receivedOn || !model.requestedDepartureOn || !model.deliveryMode) {
|
||||
this.validationError.set('Renseignez les deux dates et le mode de remise du congé.');
|
||||
return;
|
||||
}
|
||||
if (this.lateRegistration() && !model.lateRegistrationConfirmed) {
|
||||
this.validationError.set('Confirmez cet enregistrement tardif avant de continuer.');
|
||||
return;
|
||||
}
|
||||
if (model.deliveryMode === 'OTHER' && !model.otherDeliveryMode.trim()) {
|
||||
this.validationError.set('Décrivez le mode de remise utilisé.');
|
||||
return;
|
||||
}
|
||||
if (model.deliveryMode === 'OTHER' && !model.nonStandardDeliveryConfirmed) {
|
||||
this.validationError.set('Confirmez le mode de remise non standard avant de continuer.');
|
||||
return;
|
||||
}
|
||||
if (!this.evidence() && !model.missingEvidenceConfirmed) {
|
||||
this.validationError.set(
|
||||
'Confirmez l’enregistrement de ce congé sans justificatif avant de continuer.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.validationError.set('');
|
||||
const evidence = this.evidence();
|
||||
this.noticeRequested.emit({
|
||||
timeZone: this.timeZone(),
|
||||
receivedOn: model.receivedOn,
|
||||
requestedDepartureOn: model.requestedDepartureOn,
|
||||
deliveryMode: model.deliveryMode,
|
||||
...(model.deliveryMode === 'OTHER'
|
||||
? { otherDeliveryMode: model.otherDeliveryMode.trim() }
|
||||
: {}),
|
||||
lateRegistrationConfirmed: this.lateRegistration(),
|
||||
nonStandardDeliveryConfirmed: model.deliveryMode === 'OTHER',
|
||||
missingEvidenceConfirmed: evidence === null,
|
||||
...(evidence ? { evidence } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
:host {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
.lease-end-page {
|
||||
max-width: 62rem;
|
||||
}
|
||||
.lease-end-skeleton {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.lease-end-skeleton span {
|
||||
min-height: 5rem;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
.phase-summary,
|
||||
.unsupported-state,
|
||||
.timeline,
|
||||
.correction-warning {
|
||||
min-width: 0;
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--border, #dfe3e6);
|
||||
border-radius: 1rem;
|
||||
background: var(--surface, #fff);
|
||||
}
|
||||
.phase-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
.phase-summary h2,
|
||||
.timeline h2,
|
||||
.notice-history > h2,
|
||||
.unsupported-state h2 {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
.student-summary {
|
||||
border-color: var(--accent, #236b5d);
|
||||
}
|
||||
.student-final-month {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
margin: 0;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.75rem;
|
||||
background: var(--surface-accent, #eaf5f1);
|
||||
}
|
||||
.student-final-month span {
|
||||
color: var(--text-muted, #5f6368);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.student-final-month strong {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.unsupported-state,
|
||||
.correction-warning,
|
||||
.timeline,
|
||||
.notice-history,
|
||||
app-lease-end-notice-form,
|
||||
app-lease-end-resolution-forms {
|
||||
margin-block-start: 1rem;
|
||||
}
|
||||
.unsupported-state {
|
||||
background: var(--surface-soft, #f6f7f8);
|
||||
}
|
||||
.correction-warning {
|
||||
color: var(--danger, #b42318);
|
||||
}
|
||||
.timeline ol {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 10rem), 1fr));
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.timeline li {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
padding-inline-start: 0.75rem;
|
||||
border-inline-start: 0.2rem solid var(--accent, #236b5d);
|
||||
}
|
||||
.timeline span {
|
||||
color: var(--text-muted, #5f6368);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.timeline small {
|
||||
color: var(--text-muted, #5f6368);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.notice-history {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<section class="form-page lease-end-page">
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<a mat-button routerLink="/leases" class="back-action">← Retour aux baux</a>
|
||||
<p class="eyebrow">Bail</p>
|
||||
<h1>Fin du bail</h1>
|
||||
<p>Suivez les faits datés jusqu’à la restitution effective des clés.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="lease-end-skeleton" aria-label="Chargement de la fin du bail">
|
||||
<span class="skeleton-line"></span>
|
||||
<span class="skeleton-line"></span>
|
||||
<span class="skeleton-line"></span>
|
||||
</div>
|
||||
} @else if (loadError()) {
|
||||
<article class="load-error" role="alert">
|
||||
<h2>Chargement impossible</h2>
|
||||
<p>{{ loadError() }}</p>
|
||||
<button mat-stroked-button type="button" (click)="load()">Réessayer</button>
|
||||
</article>
|
||||
} @else if (state(); as endState) {
|
||||
<article class="phase-summary" [class.student-summary]="endState.leaseType === 'STUDENT'">
|
||||
<div>
|
||||
<p class="eyebrow">Situation actuelle</p>
|
||||
<h2>{{ phaseLabel(endState.phase) }}</h2>
|
||||
</div>
|
||||
@if (endState.leaseType === 'STUDENT' && endState.fixedEndOn) {
|
||||
<p class="student-final-month">
|
||||
<span>Dernier mois du bail étudiant</span>
|
||||
<strong>{{ civilMonth(endState.fixedEndOn) }}</strong>
|
||||
</p>
|
||||
}
|
||||
</article>
|
||||
|
||||
@if (!endState.supported) {
|
||||
<article class="unsupported-state" role="status">
|
||||
<h2>Gestion non prise en charge</h2>
|
||||
<p>{{ endState.unsupportedReason ?? 'Ce type de bail ne peut pas être modifié ici.' }}</p>
|
||||
<p>
|
||||
Aucun nouveau fait de fin, retrait ou clôture n’est proposé. Les justificatifs des congés
|
||||
historiques peuvent toujours être complétés.
|
||||
</p>
|
||||
</article>
|
||||
}
|
||||
@if (endState.correctionRequired) {
|
||||
<article class="correction-warning" role="alert">
|
||||
<p>
|
||||
Une correction financière est requise : contrepasser le paiement concerné, puis
|
||||
enregistrer le bon paiement.
|
||||
</p>
|
||||
<a mat-stroked-button routerLink="/rent-payments">Corriger les paiements</a>
|
||||
</article>
|
||||
}
|
||||
|
||||
<section class="timeline" aria-labelledby="timeline-title">
|
||||
<h2 id="timeline-title">Chronologie</h2>
|
||||
<ol>
|
||||
@if (endState.fixedEndOn) {
|
||||
<li>
|
||||
<span>Terme fixe du bail étudiant</span
|
||||
><strong>{{ civilDate(endState.fixedEndOn) }}</strong>
|
||||
</li>
|
||||
}
|
||||
@for (notice of endState.notices; track notice.id) {
|
||||
<li>
|
||||
<span>Congé reçu</span><strong>{{ civilDate(notice.receivedOn) }}</strong>
|
||||
</li>
|
||||
<li>
|
||||
<span>Départ demandé</span><strong>{{ civilDate(notice.requestedDepartureOn) }}</strong>
|
||||
</li>
|
||||
}
|
||||
@for (agreement of endState.agreedEarlyEnds; track agreement.recordedAt) {
|
||||
<li>
|
||||
<span>Fin anticipée convenue</span>
|
||||
<strong>{{ civilDate(agreement.endOn) }}</strong>
|
||||
<small>
|
||||
{{ agreement.reason }} · {{ agreement.authorId }} · {{ agreement.recordedAt }}
|
||||
</small>
|
||||
</li>
|
||||
}
|
||||
@if (endState.effectiveEndOn) {
|
||||
<li>
|
||||
<span>Fin effective retenue</span
|
||||
><strong>{{ civilDate(endState.effectiveEndOn) }}</strong>
|
||||
</li>
|
||||
}
|
||||
@if (endState.closure) {
|
||||
<li>
|
||||
<span>Clés restituées</span
|
||||
><strong>{{ civilDate(endState.closure.keysReturnedOn) }}</strong>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
@if (endState.closure) {
|
||||
<p>
|
||||
Clôture confirmée par {{ endState.closure.authorId }} · {{ endState.closure.recordedAt }}
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
@if (endState.notices.length) {
|
||||
<section class="notice-history" aria-labelledby="notice-history-title">
|
||||
<h2 id="notice-history-title">Historique des congés</h2>
|
||||
@for (notice of endState.notices; track notice.id) {
|
||||
<app-lease-end-notice-card
|
||||
[leaseId]="leaseId"
|
||||
[notice]="notice"
|
||||
[timeZone]="timeZone"
|
||||
[lifecycleMutationsEnabled]="lifecycleMutationsEnabled()"
|
||||
[evidenceUploadEnabled]="true"
|
||||
[withdrawalPending]="operationPending('withdrawal:' + notice.id)"
|
||||
[invalidationPending]="operationPending('invalidation:' + notice.id)"
|
||||
[evidencePending]="operationPending('evidence:' + notice.id)"
|
||||
[withdrawalOperationError]="operationError('withdrawal:' + notice.id)"
|
||||
[invalidationOperationError]="operationError('invalidation:' + notice.id)"
|
||||
[evidenceOperationError]="operationError('evidence:' + notice.id)"
|
||||
(withdrawalRequested)="requestWithdrawal($event)"
|
||||
(invalidationRequested)="requestInvalidation($event)"
|
||||
(evidenceRequested)="appendEvidence($event)"
|
||||
/>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (canRecordNotice()) {
|
||||
<app-lease-end-notice-form
|
||||
[timeZone]="timeZone"
|
||||
[today]="today"
|
||||
[submitting]="operationPending('notice')"
|
||||
[operationError]="operationError('notice')"
|
||||
(noticeRequested)="recordNotice($event)"
|
||||
/>
|
||||
}
|
||||
@if (canRecordAgreement() || canClose()) {
|
||||
<app-lease-end-resolution-forms
|
||||
[timeZone]="timeZone"
|
||||
[showAgreement]="canRecordAgreement()"
|
||||
[showClosure]="canClose()"
|
||||
[agreementPending]="operationPending('agreement')"
|
||||
[closurePending]="operationPending('closure')"
|
||||
[agreementOperationError]="operationError('agreement')"
|
||||
[closureOperationError]="operationError('closure')"
|
||||
(agreedEndRequested)="recordAgreement($event)"
|
||||
(closureRequested)="closeLease($event)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
@@ -0,0 +1,258 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, provideRouter } from '@angular/router';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { of, Subject, throwError } from 'rxjs';
|
||||
import { vi } from 'vitest';
|
||||
import {
|
||||
LeaseEndService,
|
||||
LeaseEndState,
|
||||
RecordNoticeRequest,
|
||||
} from '../../services/lease-end.service';
|
||||
import { LeaseEndPage } from './lease-end-page';
|
||||
|
||||
describe('LeaseEndPage', () => {
|
||||
const state: LeaseEndState = {
|
||||
leaseId: 'lease-1',
|
||||
supported: true,
|
||||
unsupportedReason: null,
|
||||
phase: 'NOTICE_PERIOD',
|
||||
leaseType: 'STUDENT',
|
||||
fixedEndOn: '2026-07-31',
|
||||
effectiveEndOn: '2026-07-31',
|
||||
correctionRequired: false,
|
||||
agreedEarlyEnds: [],
|
||||
agreedEarlyEnd: null,
|
||||
closure: null,
|
||||
notices: [
|
||||
{
|
||||
id: 'notice-1',
|
||||
status: 'ACTIVE',
|
||||
receivedOn: '2026-07-19',
|
||||
requestedDepartureOn: '2026-08-19',
|
||||
deliveryMode: 'REGISTERED_LETTER',
|
||||
otherDeliveryMode: null,
|
||||
noticeMonths: 1,
|
||||
minimumEndOn: '2026-08-19',
|
||||
ruleVersion: '2026-07',
|
||||
recordedWithoutEvidenceConfirmed: true,
|
||||
authorId: 'owner-1',
|
||||
recordedAt: '2026-07-19T10:00:00Z',
|
||||
evidenceVersions: [],
|
||||
withdrawal: null,
|
||||
invalidation: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
const getState = vi.fn();
|
||||
const recordNotice = vi.fn();
|
||||
const acceptWithdrawal = vi.fn();
|
||||
const invalidateNotice = vi.fn();
|
||||
const appendEvidence = vi.fn();
|
||||
const recordAgreedEarlyEnd = vi.fn();
|
||||
const close = vi.fn();
|
||||
const dialog = { open: vi.fn() };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getState.mockReturnValue(of(state));
|
||||
dialog.open.mockReturnValue({ afterClosed: () => of(true) });
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: { snapshot: { paramMap: { get: () => 'lease-1' } } },
|
||||
},
|
||||
{
|
||||
provide: LeaseEndService,
|
||||
useValue: {
|
||||
getState,
|
||||
recordNotice,
|
||||
acceptWithdrawal,
|
||||
invalidateNotice,
|
||||
appendEvidence,
|
||||
recordAgreedEarlyEnd,
|
||||
close,
|
||||
evidenceDownloading: vi.fn().mockReturnValue(false),
|
||||
evidenceDownloadError: vi.fn().mockReturnValue(''),
|
||||
downloadEvidence: vi.fn(),
|
||||
},
|
||||
},
|
||||
{ provide: MatDialog, useValue: dialog },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a skeleton, then a retryable load error', () => {
|
||||
const response = new Subject<LeaseEndState>();
|
||||
getState.mockReturnValueOnce(response);
|
||||
const fixture = TestBed.createComponent(LeaseEndPage);
|
||||
fixture.detectChanges();
|
||||
expect(getState).toHaveBeenCalledWith('lease-1', fixture.componentInstance.timeZone);
|
||||
expect(fixture.nativeElement.querySelector('.lease-end-skeleton')).not.toBeNull();
|
||||
|
||||
response.error({ error: { message: 'Bail introuvable' } });
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).toContain('Bail introuvable');
|
||||
|
||||
getState.mockReturnValueOnce(of(state));
|
||||
fixture.nativeElement.querySelector('.load-error button').click();
|
||||
fixture.detectChanges();
|
||||
expect(getState).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('renders the authoritative phase, civil timeline and student final month', () => {
|
||||
const fixture = TestBed.createComponent(LeaseEndPage);
|
||||
fixture.detectChanges();
|
||||
|
||||
const text = fixture.nativeElement.textContent.replace(/\s+/g, ' ');
|
||||
expect(text).toContain('Préavis en cours');
|
||||
expect(text).toContain('19 juillet 2026');
|
||||
expect(text).toContain('Juillet 2026');
|
||||
expect(text).toContain('enregistré sans justificatif initial');
|
||||
expect(text).not.toContain('18 juillet 2026');
|
||||
});
|
||||
|
||||
it('disables unsupported lifecycle mutations but keeps historical evidence append-only', () => {
|
||||
getState.mockReturnValueOnce(
|
||||
of({
|
||||
...state,
|
||||
supported: false,
|
||||
unsupportedReason: 'La fin de bail en colocation doit être gérée locataire par locataire.',
|
||||
}),
|
||||
);
|
||||
const fixture = TestBed.createComponent(LeaseEndPage);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('doit être gérée locataire par locataire');
|
||||
expect(fixture.nativeElement.querySelector('app-lease-end-notice-form')).toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('app-lease-end-resolution-forms')).toBeNull();
|
||||
expect(fixture.nativeElement.textContent).toContain('Ajouter une version de preuve');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Accepter un retrait');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Invalider administrativement');
|
||||
});
|
||||
|
||||
it('keeps archived evidence append-only while hiding lifecycle mutations', () => {
|
||||
getState.mockReturnValueOnce(
|
||||
of({
|
||||
...state,
|
||||
phase: 'ARCHIVED',
|
||||
closure: {
|
||||
keysReturnedOn: '2026-07-31',
|
||||
authorId: 'owner-1',
|
||||
recordedAt: '2026-07-31T18:00:00Z',
|
||||
},
|
||||
}),
|
||||
);
|
||||
const fixture = TestBed.createComponent(LeaseEndPage);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('Ajouter une version de preuve');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Accepter un retrait');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Invalider administrativement');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Clôturer le bail');
|
||||
});
|
||||
|
||||
it('links a financial correction to the existing payment reversal journey', () => {
|
||||
getState.mockReturnValueOnce(of({ ...state, correctionRequired: true }));
|
||||
const fixture = TestBed.createComponent(LeaseEndPage);
|
||||
fixture.detectChanges();
|
||||
|
||||
const correction: HTMLAnchorElement =
|
||||
fixture.nativeElement.querySelector('.correction-warning a');
|
||||
expect(fixture.nativeElement.textContent).toContain('contrepasser le paiement concerné');
|
||||
expect(correction.getAttribute('href')).toBe('/rent-payments');
|
||||
});
|
||||
|
||||
it('keeps an earlier agreement actionable before the effective end without offering a new notice', () => {
|
||||
getState.mockReturnValueOnce(
|
||||
of({
|
||||
...state,
|
||||
notices: [],
|
||||
agreedEarlyEnds: [
|
||||
{
|
||||
endOn: '2026-08-10',
|
||||
reason: 'Premier accord',
|
||||
authorId: 'owner-1',
|
||||
recordedAt: '2026-07-10T10:00:00Z',
|
||||
},
|
||||
{
|
||||
endOn: '2026-07-31',
|
||||
reason: 'Accord avancé',
|
||||
authorId: 'owner-1',
|
||||
recordedAt: '2026-07-12T10:00:00Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const fixture = TestBed.createComponent(LeaseEndPage);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('app-lease-end-notice-form')).toBeNull();
|
||||
expect(fixture.nativeElement.textContent).toContain('Premier accord');
|
||||
expect(fixture.nativeElement.textContent).toContain('Accord avancé');
|
||||
expect(fixture.nativeElement.textContent).toContain('owner-1');
|
||||
expect(fixture.nativeElement.textContent).toContain('Enregistrer l’accord');
|
||||
});
|
||||
|
||||
it('keeps notice loading and failures local without publishing false success', () => {
|
||||
getState.mockReturnValueOnce(of({ ...state, phase: 'ACTIVE', notices: [] }));
|
||||
const response = new Subject<LeaseEndState>();
|
||||
recordNotice.mockReturnValue(response);
|
||||
const fixture = TestBed.createComponent(LeaseEndPage);
|
||||
fixture.detectChanges();
|
||||
const request: RecordNoticeRequest = {
|
||||
timeZone: 'Europe/Paris',
|
||||
receivedOn: '2026-07-19',
|
||||
requestedDepartureOn: '2026-08-19',
|
||||
deliveryMode: 'HAND_DELIVERY',
|
||||
lateRegistrationConfirmed: false,
|
||||
nonStandardDeliveryConfirmed: false,
|
||||
missingEvidenceConfirmed: true,
|
||||
};
|
||||
|
||||
fixture.componentInstance.recordNotice(request);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('app-lease-end-notice-form').textContent).toContain(
|
||||
'Enregistrement…',
|
||||
);
|
||||
|
||||
response.error({ error: { message: 'Congé refusé' } });
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).toContain('Congé refusé');
|
||||
expect(fixture.componentInstance.state()?.phase).toBe('ACTIVE');
|
||||
});
|
||||
|
||||
it('confirms a reasoned withdrawal before calling its dedicated endpoint', () => {
|
||||
acceptWithdrawal.mockReturnValue(of({ ...state, phase: 'ACTIVE' }));
|
||||
const fixture = TestBed.createComponent(LeaseEndPage);
|
||||
fixture.detectChanges();
|
||||
const request = {
|
||||
timeZone: 'Europe/Paris',
|
||||
acceptedOn: '2026-07-20',
|
||||
reason: 'Retrait écrit accepté',
|
||||
};
|
||||
|
||||
fixture.componentInstance.requestWithdrawal({ noticeId: 'notice-1', request });
|
||||
|
||||
expect(dialog.open).toHaveBeenCalledOnce();
|
||||
expect(acceptWithdrawal).toHaveBeenCalledWith('lease-1', 'notice-1', request);
|
||||
});
|
||||
|
||||
it('keeps a closure failure actionable', () => {
|
||||
close.mockReturnValue(throwError(() => ({ error: { message: 'Clôture refusée' } })));
|
||||
getState.mockReturnValueOnce(of({ ...state, phase: 'AWAITING_CLOSURE' }));
|
||||
const fixture = TestBed.createComponent(LeaseEndPage);
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.componentInstance.closeLease({
|
||||
timeZone: 'Europe/Paris',
|
||||
keysReturnedOn: '2026-08-19',
|
||||
confirmed: true,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('Clôture refusée');
|
||||
expect(fixture.componentInstance.state()?.phase).toBe('AWAITING_CLOSURE');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
import { Component, DestroyRef, computed, inject, signal } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { Observable, finalize } from 'rxjs';
|
||||
import {
|
||||
browserTimeZone,
|
||||
formatFrenchCivilDate,
|
||||
formatFrenchCivilMonth,
|
||||
todayInTimeZone,
|
||||
} from '../../services/civil-date';
|
||||
import { httpErrorMessage } from '../../services/http-error-message';
|
||||
import { withoutRecordKey } from '../../services/immutable-state';
|
||||
import {
|
||||
AgreedEarlyEndRequest,
|
||||
AppendEvidenceRequest,
|
||||
CloseLeaseRequest,
|
||||
InvalidateNoticeRequest,
|
||||
LeaseEndPhase,
|
||||
LeaseEndService,
|
||||
LeaseEndState,
|
||||
RecordNoticeRequest,
|
||||
AcceptWithdrawalRequest,
|
||||
} from '../../services/lease-end.service';
|
||||
import {
|
||||
ConfirmationDialog,
|
||||
ConfirmationDialogData,
|
||||
} from '../confirmation-dialog/confirmation-dialog';
|
||||
import { LeaseEndNoticeForm } from '../lease-end-notice-form/lease-end-notice-form';
|
||||
import { LeaseEndNoticeCard, NoticeAction } from '../lease-end-notice-card/lease-end-notice-card';
|
||||
import { LeaseEndResolutionForms } from '../lease-end-resolution-forms/lease-end-resolution-forms';
|
||||
|
||||
@Component({
|
||||
selector: 'app-lease-end-page',
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
RouterLink,
|
||||
LeaseEndNoticeForm,
|
||||
LeaseEndNoticeCard,
|
||||
LeaseEndResolutionForms,
|
||||
],
|
||||
templateUrl: './lease-end-page.html',
|
||||
styleUrl: './lease-end-page.css',
|
||||
})
|
||||
export class LeaseEndPage {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly endService = inject(LeaseEndService);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
readonly leaseId = this.route.snapshot.paramMap.get('leaseId') ?? '';
|
||||
readonly timeZone = browserTimeZone;
|
||||
readonly today = todayInTimeZone(this.timeZone);
|
||||
readonly state = signal<LeaseEndState | null>(null);
|
||||
readonly loading = signal(true);
|
||||
readonly loadError = signal('');
|
||||
readonly pendingOperations = signal<ReadonlySet<string>>(new Set());
|
||||
readonly confirmingOperations = signal<ReadonlySet<string>>(new Set());
|
||||
readonly operationErrors = signal<Record<string, string>>({});
|
||||
readonly lifecycleMutationsEnabled = computed(() => {
|
||||
const state = this.state();
|
||||
return !!state?.supported && state.phase !== 'ARCHIVED' && !state.closure;
|
||||
});
|
||||
readonly canRecordNotice = computed(
|
||||
() =>
|
||||
this.lifecycleMutationsEnabled() &&
|
||||
this.state()?.phase === 'ACTIVE' &&
|
||||
!this.state()?.notices.some((notice) => notice.status === 'ACTIVE'),
|
||||
);
|
||||
readonly canRecordAgreement = computed(() => {
|
||||
const state = this.state();
|
||||
return (
|
||||
this.lifecycleMutationsEnabled() &&
|
||||
!!state &&
|
||||
(state.phase === 'ACTIVE' || state.phase === 'NOTICE_PERIOD')
|
||||
);
|
||||
});
|
||||
readonly canClose = computed(() => {
|
||||
const state = this.state();
|
||||
return (
|
||||
this.lifecycleMutationsEnabled() &&
|
||||
!!state &&
|
||||
(state.phase === 'AWAITING_CLOSURE' || state.phase === 'UNAUTHORIZED_OCCUPANCY')
|
||||
);
|
||||
});
|
||||
|
||||
readonly civilDate = formatFrenchCivilDate;
|
||||
readonly civilMonth = formatFrenchCivilMonth;
|
||||
|
||||
constructor() {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading.set(true);
|
||||
this.loadError.set('');
|
||||
this.endService
|
||||
.getState(this.leaseId, this.timeZone)
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
finalize(() => this.loading.set(false)),
|
||||
)
|
||||
.subscribe({
|
||||
next: (state) => this.state.set(state),
|
||||
error: (error: unknown) =>
|
||||
this.loadError.set(httpErrorMessage(error, 'Impossible de charger la fin de ce bail.')),
|
||||
});
|
||||
}
|
||||
|
||||
phaseLabel(phase: LeaseEndPhase): string {
|
||||
return {
|
||||
ACTIVE: 'Bail actif',
|
||||
NOTICE_PERIOD: 'Préavis en cours',
|
||||
AWAITING_CLOSURE: 'En attente de clôture',
|
||||
UNAUTHORIZED_OCCUPANCY: 'Occupation sans droit ni titre',
|
||||
ARCHIVED: 'Bail clôturé et archivé',
|
||||
}[phase];
|
||||
}
|
||||
|
||||
recordNotice(request: RecordNoticeRequest): void {
|
||||
this.mutate(
|
||||
'notice',
|
||||
this.endService.recordNotice(this.leaseId, request),
|
||||
'Impossible d’enregistrer ce congé.',
|
||||
);
|
||||
}
|
||||
|
||||
requestWithdrawal(action: NoticeAction<AcceptWithdrawalRequest>): void {
|
||||
const key = `withdrawal:${action.noticeId}`;
|
||||
this.confirm(
|
||||
key,
|
||||
{
|
||||
title: 'Accepter le retrait de ce congé ?',
|
||||
message: `Le motif « ${action.request.reason} » sera conservé dans l’historique.`,
|
||||
confirmLabel: 'Accepter le retrait',
|
||||
},
|
||||
() =>
|
||||
this.mutate(
|
||||
key,
|
||||
this.endService.acceptWithdrawal(this.leaseId, action.noticeId, action.request),
|
||||
'Impossible d’accepter ce retrait.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
requestInvalidation(action: NoticeAction<InvalidateNoticeRequest>): void {
|
||||
const key = `invalidation:${action.noticeId}`;
|
||||
this.confirm(
|
||||
key,
|
||||
{
|
||||
title: 'Invalider administrativement ce congé ?',
|
||||
message: `Le motif « ${action.request.reason} » sera conservé dans l’historique.`,
|
||||
confirmLabel: 'Invalider le congé',
|
||||
},
|
||||
() =>
|
||||
this.mutate(
|
||||
key,
|
||||
this.endService.invalidateNotice(this.leaseId, action.noticeId, action.request),
|
||||
'Impossible d’invalider ce congé.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
appendEvidence(action: NoticeAction<AppendEvidenceRequest>): void {
|
||||
this.mutate(
|
||||
`evidence:${action.noticeId}`,
|
||||
this.endService.appendEvidence(this.leaseId, action.noticeId, action.request),
|
||||
'Impossible d’ajouter cette preuve.',
|
||||
);
|
||||
}
|
||||
|
||||
recordAgreement(request: AgreedEarlyEndRequest): void {
|
||||
this.mutate(
|
||||
'agreement',
|
||||
this.endService.recordAgreedEarlyEnd(this.leaseId, request),
|
||||
'Impossible d’enregistrer cette fin anticipée.',
|
||||
);
|
||||
}
|
||||
|
||||
closeLease(request: CloseLeaseRequest): void {
|
||||
this.mutate(
|
||||
'closure',
|
||||
this.endService.close(this.leaseId, request),
|
||||
'Impossible de clôturer ce bail.',
|
||||
);
|
||||
}
|
||||
|
||||
operationPending(key: string): boolean {
|
||||
return this.pendingOperations().has(key) || this.confirmingOperations().has(key);
|
||||
}
|
||||
|
||||
operationError(key: string): string {
|
||||
return this.operationErrors()[key] ?? '';
|
||||
}
|
||||
|
||||
private confirm(key: string, data: ConfirmationDialogData, action: () => void): void {
|
||||
if (this.operationPending(key)) return;
|
||||
this.confirmingOperations.update((keys) => new Set(keys).add(key));
|
||||
this.dialog
|
||||
.open(ConfirmationDialog, {
|
||||
data,
|
||||
role: 'alertdialog',
|
||||
width: 'min(440px, calc(100vw - 32px))',
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
})
|
||||
.afterClosed()
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((confirmed) => {
|
||||
this.confirmingOperations.update((keys) => withoutKey(keys, key));
|
||||
if (confirmed) action();
|
||||
});
|
||||
}
|
||||
|
||||
private mutate(key: string, request: Observable<LeaseEndState>, fallback: string): void {
|
||||
if (this.pendingOperations().has(key)) return;
|
||||
this.pendingOperations.update((keys) => new Set(keys).add(key));
|
||||
this.operationErrors.update((errors) => withoutRecordKey(errors, key));
|
||||
request
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
finalize(() => this.pendingOperations.update((keys) => withoutKey(keys, key))),
|
||||
)
|
||||
.subscribe({
|
||||
next: (state) => this.state.set(state),
|
||||
error: (error: unknown) =>
|
||||
this.operationErrors.update((errors) => ({
|
||||
...errors,
|
||||
[key]: httpErrorMessage(error, fallback),
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function withoutKey(keys: ReadonlySet<string>, key: string): ReadonlySet<string> {
|
||||
const next = new Set(keys);
|
||||
next.delete(key);
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
:host,
|
||||
.resolution-card {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
.resolution-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.resolution-card {
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--border, #dfe3e6);
|
||||
border-radius: 1rem;
|
||||
background: var(--surface, #fff);
|
||||
}
|
||||
.closure-card {
|
||||
border-color: var(--accent, #236b5d);
|
||||
}
|
||||
h2 {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
fieldset,
|
||||
label:not(.confirmation-field) {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
fieldset {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
input,
|
||||
textarea {
|
||||
min-width: 0;
|
||||
min-height: 2.75rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.confirmation-field {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.65rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.confirmation-field input {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
input:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: 2px solid var(--accent, #236b5d);
|
||||
outline-offset: 0.15rem;
|
||||
}
|
||||
.operation-error {
|
||||
color: var(--danger, #b42318);
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
<div class="resolution-grid">
|
||||
@if (showAgreement()) {
|
||||
<section class="resolution-card" aria-labelledby="agreement-title">
|
||||
<h2 id="agreement-title">Fin anticipée convenue</h2>
|
||||
<form
|
||||
[formRoot]="agreementForm"
|
||||
[attr.aria-busy]="agreementPending()"
|
||||
(submit)="submitAgreement(); $event.preventDefault()"
|
||||
>
|
||||
<fieldset [disabled]="agreementPending()">
|
||||
<label>
|
||||
<span>Date de fin convenue</span>
|
||||
<input type="date" [formField]="agreementForm.endOn" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Raison de l’accord</span>
|
||||
<textarea rows="3" [formField]="agreementForm.reason"></textarea>
|
||||
</label>
|
||||
@if (agreementError() || agreementOperationError()) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ agreementError() || agreementOperationError() }}
|
||||
</p>
|
||||
}
|
||||
<button mat-stroked-button type="submit" [disabled]="agreementPending()">
|
||||
{{ agreementPending() ? 'Enregistrement…' : 'Enregistrer l’accord' }}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (showClosure()) {
|
||||
<section class="resolution-card closure-card" aria-labelledby="closure-title">
|
||||
<h2 id="closure-title">Clôturer le bail</h2>
|
||||
<form
|
||||
[formRoot]="closureForm"
|
||||
[attr.aria-busy]="closurePending()"
|
||||
(submit)="submitClosure(); $event.preventDefault()"
|
||||
>
|
||||
<fieldset [disabled]="closurePending()">
|
||||
<label>
|
||||
<span>Date réelle de restitution des clés</span>
|
||||
<input type="date" [formField]="closureForm.keysReturnedOn" />
|
||||
</label>
|
||||
<label class="confirmation-field">
|
||||
<input type="checkbox" [formField]="closureForm.confirmed" />
|
||||
<span>Je confirme que les clés ont réellement été restituées à cette date.</span>
|
||||
</label>
|
||||
@if (closureError() || closureOperationError()) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ closureError() || closureOperationError() }}
|
||||
</p>
|
||||
}
|
||||
<button mat-flat-button type="submit" [disabled]="closurePending()">
|
||||
{{ closurePending() ? 'Clôture…' : 'Clôturer le bail' }}
|
||||
</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { vi } from 'vitest';
|
||||
import { LeaseEndResolutionForms } from './lease-end-resolution-forms';
|
||||
|
||||
describe('LeaseEndResolutionForms', () => {
|
||||
function createFixture() {
|
||||
const fixture = TestBed.createComponent(LeaseEndResolutionForms);
|
||||
fixture.componentRef.setInput('timeZone', 'America/Los_Angeles');
|
||||
fixture.componentRef.setInput('agreementPending', false);
|
||||
fixture.componentRef.setInput('closurePending', false);
|
||||
fixture.detectChanges();
|
||||
return fixture;
|
||||
}
|
||||
|
||||
it('records a reasoned agreed end, including a retroactive civil date', () => {
|
||||
const fixture = createFixture();
|
||||
const requested = vi.fn();
|
||||
fixture.componentInstance.agreedEndRequested.subscribe(requested);
|
||||
fixture.componentInstance.agreementModel.set({
|
||||
endOn: '2026-07-18',
|
||||
reason: 'Accord écrit des parties',
|
||||
});
|
||||
|
||||
fixture.componentInstance.submitAgreement();
|
||||
expect(requested).toHaveBeenCalledWith({
|
||||
timeZone: 'America/Los_Angeles',
|
||||
endOn: '2026-07-18',
|
||||
reason: 'Accord écrit des parties',
|
||||
});
|
||||
expect(fixture.nativeElement.querySelector('textarea').maxLength).toBe(500);
|
||||
});
|
||||
|
||||
it('only emits closure after explicit confirmation and keeps the civil day', () => {
|
||||
const fixture = createFixture();
|
||||
const requested = vi.fn();
|
||||
fixture.componentInstance.closureRequested.subscribe(requested);
|
||||
fixture.componentInstance.closureModel.set({
|
||||
keysReturnedOn: '2026-07-19',
|
||||
confirmed: false,
|
||||
});
|
||||
|
||||
fixture.componentInstance.submitClosure();
|
||||
expect(fixture.componentInstance.closureError()).toContain('Confirmez');
|
||||
|
||||
fixture.componentInstance.closureModel.update((model) => ({ ...model, confirmed: true }));
|
||||
fixture.componentInstance.submitClosure();
|
||||
expect(requested).toHaveBeenCalledWith({
|
||||
timeZone: 'America/Los_Angeles',
|
||||
keysReturnedOn: '2026-07-19',
|
||||
confirmed: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Component, input, output, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { FormField, FormRoot, form, maxLength } from '@angular/forms/signals';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { AgreedEarlyEndRequest, CloseLeaseRequest } from '../../services/lease-end.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-lease-end-resolution-forms',
|
||||
imports: [FormsModule, FormField, FormRoot, MatButtonModule],
|
||||
templateUrl: './lease-end-resolution-forms.html',
|
||||
styleUrl: './lease-end-resolution-forms.css',
|
||||
})
|
||||
export class LeaseEndResolutionForms {
|
||||
readonly timeZone = input.required<string>();
|
||||
readonly agreementPending = input(false);
|
||||
readonly closurePending = input(false);
|
||||
readonly agreementOperationError = input('');
|
||||
readonly closureOperationError = input('');
|
||||
readonly showAgreement = input(true);
|
||||
readonly showClosure = input(true);
|
||||
readonly agreedEndRequested = output<AgreedEarlyEndRequest>();
|
||||
readonly closureRequested = output<CloseLeaseRequest>();
|
||||
readonly agreementModel = signal({ endOn: '', reason: '' });
|
||||
readonly closureModel = signal({ keysReturnedOn: '', confirmed: false });
|
||||
readonly agreementForm = form(this.agreementModel, (fields) => {
|
||||
maxLength(fields.reason, 500);
|
||||
});
|
||||
readonly closureForm = form(this.closureModel);
|
||||
readonly agreementError = signal('');
|
||||
readonly closureError = signal('');
|
||||
|
||||
submitAgreement(): void {
|
||||
if (this.agreementPending()) return;
|
||||
const model = this.agreementModel();
|
||||
if (!model.endOn || !model.reason.trim()) {
|
||||
this.agreementError.set('Renseignez la date et la raison de la fin anticipée.');
|
||||
return;
|
||||
}
|
||||
this.agreementError.set('');
|
||||
this.agreedEndRequested.emit({
|
||||
timeZone: this.timeZone(),
|
||||
endOn: model.endOn,
|
||||
reason: model.reason.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
submitClosure(): void {
|
||||
if (this.closurePending()) return;
|
||||
const model = this.closureModel();
|
||||
if (!model.keysReturnedOn) {
|
||||
this.closureError.set('Renseignez la date réelle de restitution des clés.');
|
||||
return;
|
||||
}
|
||||
if (!model.confirmed) {
|
||||
this.closureError.set('Confirmez la restitution réelle des clés avant de clôturer.');
|
||||
return;
|
||||
}
|
||||
this.closureError.set('');
|
||||
this.closureRequested.emit({
|
||||
timeZone: this.timeZone(),
|
||||
keysReturnedOn: model.keysReturnedOn,
|
||||
confirmed: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -265,7 +265,8 @@ describe('LeaseForm', () => {
|
||||
...lease.legalTerms,
|
||||
rentRevision: {
|
||||
enabled: true,
|
||||
annualRevisionDate: '2026-09-01',
|
||||
annualRevisionDay: 1,
|
||||
annualRevisionMonth: 9,
|
||||
referenceQuarter: 2,
|
||||
referenceYear: 2026,
|
||||
seriesId: '001515333',
|
||||
@@ -284,6 +285,94 @@ describe('LeaseForm', () => {
|
||||
expect(fixture.nativeElement.textContent).not.toContain('à confirmer lors de la finalisation');
|
||||
});
|
||||
|
||||
it('submits a recurring IRL revision day and month without a year', () => {
|
||||
TestBed.configureTestingModule({ providers: [provideNativeDateAdapter()] });
|
||||
const fixture = TestBed.createComponent(LeaseForm);
|
||||
fixture.componentRef.setInput('apartments', []);
|
||||
fixture.componentRef.setInput('tenants', []);
|
||||
fixture.componentInstance.leaseModel.set({
|
||||
apartmentId: 'apartment-1',
|
||||
tenantIds: ['tenant-1'],
|
||||
leaseType: 'STANDARD',
|
||||
startDate: '2026-09-01',
|
||||
monthlyRent: 850,
|
||||
monthlyCharges: 50,
|
||||
securityDeposit: 850,
|
||||
dueDay: 5,
|
||||
chargeMethod: 'FLAT_RATE',
|
||||
indexFlatRate: false,
|
||||
legalTerms: {
|
||||
...emptyLeaseLegalTerms(),
|
||||
rentRevision: {
|
||||
enabled: true,
|
||||
annualRevisionDay: 1,
|
||||
annualRevisionMonth: 1,
|
||||
referenceQuarter: 1,
|
||||
referenceYear: 2026,
|
||||
seriesId: '001515333',
|
||||
},
|
||||
},
|
||||
});
|
||||
fixture.detectChanges();
|
||||
const submitted = vi.fn();
|
||||
fixture.componentInstance.submitted.subscribe(submitted);
|
||||
|
||||
fixture.componentInstance.save();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('#annualRevisionDay')).not.toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('#annualRevisionMonth')).not.toBeNull();
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Année de révision');
|
||||
expect(submitted).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
legalTerms: expect.objectContaining({
|
||||
rentRevision: expect.objectContaining({
|
||||
annualRevisionDay: 1,
|
||||
annualRevisionMonth: 1,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an impossible recurring IRL calendar date', () => {
|
||||
TestBed.configureTestingModule({ providers: [provideNativeDateAdapter()] });
|
||||
const fixture = TestBed.createComponent(LeaseForm);
|
||||
fixture.componentRef.setInput('apartments', []);
|
||||
fixture.componentRef.setInput('tenants', []);
|
||||
fixture.componentInstance.leaseModel.set({
|
||||
apartmentId: 'apartment-1',
|
||||
tenantIds: ['tenant-1'],
|
||||
leaseType: 'STANDARD',
|
||||
startDate: '2026-09-01',
|
||||
monthlyRent: 850,
|
||||
monthlyCharges: 50,
|
||||
securityDeposit: 850,
|
||||
dueDay: 5,
|
||||
chargeMethod: 'FLAT_RATE',
|
||||
indexFlatRate: false,
|
||||
legalTerms: {
|
||||
...emptyLeaseLegalTerms(),
|
||||
rentRevision: {
|
||||
enabled: true,
|
||||
annualRevisionDay: 31,
|
||||
annualRevisionMonth: 2,
|
||||
referenceQuarter: 1,
|
||||
referenceYear: 2026,
|
||||
seriesId: '001515333',
|
||||
},
|
||||
},
|
||||
});
|
||||
fixture.detectChanges();
|
||||
const submitted = vi.fn();
|
||||
fixture.componentInstance.submitted.subscribe(submitted);
|
||||
|
||||
fixture.componentInstance.save();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(submitted).not.toHaveBeenCalled();
|
||||
expect(fixture.nativeElement.textContent).toContain('ne forment pas une date valide');
|
||||
});
|
||||
|
||||
it('clears an unfinished rent supplement when the reference-rent scheme is disabled', async () => {
|
||||
TestBed.configureTestingModule({ providers: [provideNativeDateAdapter()] });
|
||||
const fixture = TestBed.createComponent(LeaseForm);
|
||||
|
||||
@@ -102,13 +102,36 @@
|
||||
}
|
||||
<div class="form-grid three-columns">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Date annuelle de révision</mat-label>
|
||||
<mat-label>Jour de révision</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="date"
|
||||
[formField]="legalTermsForm().rentRevision.annualRevisionDate"
|
||||
id="annualRevisionDay"
|
||||
type="number"
|
||||
step="1"
|
||||
[formField]="legalTermsForm().rentRevision.annualRevisionDay"
|
||||
/>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Mois de révision</mat-label>
|
||||
<mat-select
|
||||
id="annualRevisionMonth"
|
||||
[formField]="legalTermsForm().rentRevision.annualRevisionMonth"
|
||||
>
|
||||
<mat-option [value]="null">Non renseigné</mat-option>
|
||||
<mat-option [value]="1">Janvier</mat-option>
|
||||
<mat-option [value]="2">Février</mat-option>
|
||||
<mat-option [value]="3">Mars</mat-option>
|
||||
<mat-option [value]="4">Avril</mat-option>
|
||||
<mat-option [value]="5">Mai</mat-option>
|
||||
<mat-option [value]="6">Juin</mat-option>
|
||||
<mat-option [value]="7">Juillet</mat-option>
|
||||
<mat-option [value]="8">Août</mat-option>
|
||||
<mat-option [value]="9">Septembre</mat-option>
|
||||
<mat-option [value]="10">Octobre</mat-option>
|
||||
<mat-option [value]="11">Novembre</mat-option>
|
||||
<mat-option [value]="12">Décembre</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Trimestre IRL de référence</mat-label>
|
||||
<mat-select [formField]="legalTermsForm().rentRevision.referenceQuarter">
|
||||
|
||||
@@ -85,8 +85,21 @@ export class LeaseLegalTermsEditor {
|
||||
}
|
||||
}
|
||||
if (terms.rentRevision.enabled) {
|
||||
if (!terms.rentRevision.annualRevisionDate) {
|
||||
return 'Renseignez la date annuelle de révision du loyer.';
|
||||
const day = terms.rentRevision.annualRevisionDay;
|
||||
const month = terms.rentRevision.annualRevisionMonth;
|
||||
if (day === null || month === null) {
|
||||
return 'Renseignez le jour et le mois de révision annuelle du loyer.';
|
||||
}
|
||||
const lastDayOfMonth = new Date(Date.UTC(2000, month, 0)).getUTCDate();
|
||||
if (
|
||||
!Number.isInteger(day) ||
|
||||
!Number.isInteger(month) ||
|
||||
month < 1 ||
|
||||
month > 12 ||
|
||||
day < 1 ||
|
||||
day > lastDayOfMonth
|
||||
) {
|
||||
return 'Le jour et le mois de révision annuelle ne forment pas une date valide.';
|
||||
}
|
||||
if (
|
||||
terms.rentRevision.referenceQuarter === null ||
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
(dossierRequested)="workspace.downloadLeaseDossier($event)"
|
||||
(editRequested)="openForm($event)"
|
||||
(finalizationRequested)="openFinalization($event)"
|
||||
(leaseEndRequested)="openLeaseEnd($event)"
|
||||
(deleteRequested)="workspace.deleteLease($event)"
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -81,6 +81,14 @@ describe('LeasesPage', () => {
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/leases', 'lease-1', 'finalize']);
|
||||
});
|
||||
|
||||
it('navigates an active lease to its dedicated ending page', () => {
|
||||
const fixture = TestBed.createComponent(LeasesPage);
|
||||
|
||||
fixture.componentInstance.openLeaseEnd('lease-1');
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/leases', 'lease-1', 'end']);
|
||||
});
|
||||
|
||||
it('opens an existing draft for editing', () => {
|
||||
const fixture = TestBed.createComponent(LeasesPage);
|
||||
const draft = {
|
||||
|
||||
@@ -77,6 +77,10 @@ export class LeasesPage {
|
||||
void this.router.navigate(['/leases', leaseId, 'finalize']);
|
||||
}
|
||||
|
||||
openLeaseEnd(leaseId: string): void {
|
||||
void this.router.navigate(['/leases', leaseId, 'end']);
|
||||
}
|
||||
|
||||
retryLoad(): void {
|
||||
if (!this.workspace.apartmentLoadState().loaded || this.workspace.apartmentLoadState().error)
|
||||
this.workspace.loadApartments();
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
:host {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rent-due-correction {
|
||||
min-width: 0;
|
||||
margin-block-start: var(--space-4, 1rem);
|
||||
padding: var(--space-4, 1rem);
|
||||
border: 1px solid var(--warning, #9a6700);
|
||||
border-radius: var(--radius-md, 0.8125rem);
|
||||
background: var(--surface-soft, #f5f1e8);
|
||||
}
|
||||
|
||||
.rent-due-correction > strong {
|
||||
color: var(--warning, #9a6700);
|
||||
}
|
||||
|
||||
.rent-due-correction p {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.correction-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2, 0.5rem);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.correction-actions button {
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<section class="rent-due-correction" role="alert">
|
||||
<strong>Correction de règlement nécessaire</strong>
|
||||
<p>
|
||||
La fin du bail a rendu un ou plusieurs règlements suraffectés. Contrepassez chaque règlement
|
||||
concerné, puis enregistrez le règlement avec le montant correct.
|
||||
</p>
|
||||
@if (!loadState().loaded) {
|
||||
@if (loadState().error) {
|
||||
<p>{{ loadState().error }}</p>
|
||||
<button mat-stroked-button type="button" (click)="retryRequested.emit()">Réessayer</button>
|
||||
} @else {
|
||||
<p>Chargement du règlement concerné…</p>
|
||||
}
|
||||
} @else {
|
||||
<div class="correction-actions">
|
||||
@for (payment of matchingPayments(); track payment.id) {
|
||||
<button mat-stroked-button type="button" (click)="reversalRequested.emit(payment)">
|
||||
Contrepasser le règlement du {{ civilDate(payment.receivedAt) }}
|
||||
</button>
|
||||
} @empty {
|
||||
<p>Le règlement confirmé concerné est introuvable ou a déjà été contrepassé.</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Component, computed, input, output } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { formatFrenchCivilDate } from '../../services/civil-date';
|
||||
import { RentDue } from '../../services/lease.service';
|
||||
import { RentPayment } from '../../services/rent-payment.service';
|
||||
|
||||
interface PaymentLoadState {
|
||||
loaded: boolean;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-rent-due-correction',
|
||||
imports: [MatButtonModule],
|
||||
templateUrl: './rent-due-correction.html',
|
||||
styleUrl: './rent-due-correction.css',
|
||||
})
|
||||
export class RentDueCorrection {
|
||||
readonly rentDue = input.required<RentDue>();
|
||||
readonly payments = input.required<RentPayment[]>();
|
||||
readonly loadState = input.required<PaymentLoadState>();
|
||||
readonly reversalRequested = output<RentPayment>();
|
||||
readonly retryRequested = output<void>();
|
||||
readonly civilDate = formatFrenchCivilDate;
|
||||
readonly matchingPayments = computed(() =>
|
||||
this.payments().filter(
|
||||
(payment) =>
|
||||
payment.status === 'CONFIRMED' &&
|
||||
payment.allocations.some((allocation) => allocation.rentDueId === this.rentDue().id),
|
||||
),
|
||||
);
|
||||
}
|
||||
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user