diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseEndingApi.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseEndingApi.java
index 2e3b1bf..195f5af 100644
--- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseEndingApi.java
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseEndingApi.java
@@ -19,14 +19,22 @@ import java.util.List;
final class LeaseEndingApi {
private LeaseEndingApi() {}
+record OperationError(String message) {}
+
+record ValidationError(String code, List errors) {}
+
+record FieldError(String field, String code) {}
+
record StateResponse(
String leaseId,
boolean supported,
String unsupportedReason,
LeaseEndPhase phase,
LeaseType leaseType,
+ LocalDate leaseStartOn,
LocalDate fixedEndOn,
LocalDate effectiveEndOn,
+ long maxEvidenceBytes,
boolean correctionRequired,
List notices,
AgreedEarlyEndResponse agreedEarlyEnd,
@@ -41,8 +49,10 @@ record StateResponse(
state.unsupportedReason().orElse(null),
state.phase(),
state.leaseType(),
+ state.leaseStartOn(),
state.fixedEndOn().orElse(null),
state.effectiveEndOn().orElse(null),
+ state.maxEvidenceBytes(),
state.correctionRequired(),
history.notices().stream().map(notice -> NoticeResponse.from(notice, history)).toList(),
history.latestAgreement().map(AgreedEarlyEndResponse::from).orElse(null),
diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseEndingController.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseEndingController.java
index 5fb5f5b..99b557a 100644
--- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseEndingController.java
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseEndingController.java
@@ -3,12 +3,16 @@ 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.LeaseEndingConflict;
+import fr.nessar.lmnporganizer.rental.domain.lease.LeaseEndingValidationFailure;
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.AssertTrue;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
import java.io.IOException;
import java.time.DateTimeException;
import java.time.LocalDate;
@@ -18,8 +22,11 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
+import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
+import org.springframework.web.bind.MissingServletRequestParameterException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
@@ -28,7 +35,9 @@ 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.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.multipart.MultipartFile;
+import org.springframework.web.multipart.support.MissingServletRequestPartException;
@RestController("rentalLeaseEndingController")
@RequestMapping("/api/leases/{leaseId}/end")
@@ -42,6 +51,34 @@ public LeaseEndingController(
this.authenticatedLandlord = authenticatedLandlord;
}
+@ExceptionHandler(LeaseEndingConflict.class)
+ResponseEntity conflict(LeaseEndingConflict failure) {
+ return ResponseEntity.status(HttpStatus.CONFLICT)
+ .body(new LeaseEndingApi.OperationError(failure.getMessage()));
+}
+
+@ExceptionHandler(LeaseEndingValidationFailure.class)
+ResponseEntity invalid(LeaseEndingValidationFailure failure) {
+ return ResponseEntity.badRequest()
+ .body(
+ new LeaseEndingApi.ValidationError(
+ "validation_failed",
+ failure.errors().stream()
+ .map(error -> new LeaseEndingApi.FieldError(error.field(), error.code()))
+ .toList()));
+}
+
+@ExceptionHandler({
+ MissingServletRequestParameterException.class,
+ MissingServletRequestPartException.class,
+ MethodArgumentTypeMismatchException.class,
+ HttpMessageNotReadableException.class
+})
+ResponseEntity invalidRequest() {
+ return ResponseEntity.badRequest()
+ .body(new LeaseEndingApi.OperationError("La requête de fin de bail est invalide."));
+}
+
@GetMapping
LeaseEndingApi.StateResponse state(
@AuthenticationPrincipal OidcUser principal,
@@ -205,7 +242,7 @@ private static ZoneId zone(String value) {
try {
return ZoneId.of(value);
} catch (DateTimeException failure) {
- throw new IllegalArgumentException("Le fuseau horaire IANA est invalide.", failure);
+ throw new LeaseEndingValidationFailure("timeZone", "iana_time_zone");
}
}
@@ -222,13 +259,19 @@ private static String safeFileName(String value) {
}
record WithdrawalRequest(
- @NotBlank String timeZone, @NotNull LocalDate acceptedOn, @NotBlank String reason) {}
+ @NotBlank String timeZone,
+ @NotNull LocalDate acceptedOn,
+ @NotBlank @Size(max = 500) String reason) {}
record InvalidationRequest(
- @NotBlank String timeZone, @NotNull LocalDate invalidatedOn, @NotBlank String reason) {}
+ @NotBlank String timeZone,
+ @NotNull LocalDate invalidatedOn,
+ @NotBlank @Size(max = 500) String reason) {}
record AgreedEndRequest(
- @NotBlank String timeZone, @NotNull LocalDate endOn, @NotBlank String reason) {}
+ @NotBlank String timeZone,
+ @NotNull LocalDate endOn,
+ @NotBlank @Size(max = 500) String reason) {}
-record ClosureRequest(@NotBlank String timeZone, boolean confirmed) {}
+record ClosureRequest(@NotBlank String timeZone, @AssertTrue boolean confirmed) {}
}
diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/lease/LeaseEndingService.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/lease/LeaseEndingService.java
index 3f99767..c03f1a7 100644
--- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/lease/LeaseEndingService.java
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/lease/LeaseEndingService.java
@@ -14,6 +14,8 @@ 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.LeaseEndingConflict;
+import fr.nessar.lmnporganizer.rental.domain.lease.LeaseEndingValidationFailure;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRuleViolation;
@@ -92,7 +94,7 @@ public LeaseEndingState recordNotice(
EvidenceUpload evidence) {
Objects.requireNonNull(request);
if (evidence == null && !request.missingEvidenceConfirmed()) {
- throw new LeaseRuleViolation("L'absence de justificatif doit être confirmée.");
+ throw new LeaseEndingValidationFailure("missingEvidenceConfirmed", "required");
}
TenantNoticeId noticeId = new TenantNoticeId(identifiers.next());
StoredEvidence stored = evidence == null ? null : store(evidence);
@@ -191,7 +193,7 @@ public LeaseEndingState close(
.filter(PropertyConditionReport::finalized)
.orElseThrow(
() ->
- new LeaseRuleViolation(
+ new LeaseEndingConflict(
"Un état des lieux de sortie finalisé est requis pour clôturer le bail."));
lease.close(
exitReport.occurredOn(),
@@ -295,8 +297,10 @@ private LeaseEndingState state(
"La fin de bail locataire n'est pas prise en charge pour une colocation."),
lease.endPhaseOn(today),
lease.leaseType(),
+ lease.period().startDate(),
lease.fixedEndOn(),
lease.contractEndOn(),
+ maxEvidenceBytes,
dues.stream().anyMatch(RentDue::correctionRequired),
reports
.find(landlordId, lease.id(), PropertyConditionReportType.EXIT)
@@ -339,7 +343,7 @@ private StoredEvidence store(EvidenceUpload upload) {
byte[] content =
Objects.requireNonNull(upload.content(), "Evidence content is required.").clone();
if (content.length == 0) {
- throw new LeaseRuleViolation("Le justificatif est vide.");
+ throw new LeaseEndingValidationFailure("evidence", "empty");
}
if (content.length > maxEvidenceBytes) {
throw new AttachmentTooLarge(maxEvidenceBytes);
diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/lease/LeaseEndingState.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/lease/LeaseEndingState.java
index 1759c1f..a0992b3 100644
--- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/lease/LeaseEndingState.java
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/lease/LeaseEndingState.java
@@ -14,8 +14,10 @@ public record LeaseEndingState(
Optional unsupportedReason,
LeaseEndPhase phase,
LeaseType leaseType,
+ LocalDate leaseStartOn,
Optional fixedEndOn,
Optional effectiveEndOn,
+ long maxEvidenceBytes,
boolean correctionRequired,
Optional exitConditionReport,
LeaseEndingHistory history) {
@@ -24,8 +26,12 @@ public LeaseEndingState {
unsupportedReason = unsupportedReason == null ? Optional.empty() : unsupportedReason;
Objects.requireNonNull(phase);
Objects.requireNonNull(leaseType);
+ Objects.requireNonNull(leaseStartOn);
fixedEndOn = fixedEndOn == null ? Optional.empty() : fixedEndOn;
effectiveEndOn = effectiveEndOn == null ? Optional.empty() : effectiveEndOn;
+ if (maxEvidenceBytes <= 0) {
+ throw new IllegalArgumentException("Maximum evidence size must be positive.");
+ }
exitConditionReport = exitConditionReport == null ? Optional.empty() : exitConditionReport;
Objects.requireNonNull(history);
}
diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/lease/Lease.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/lease/Lease.java
index f8fe36a..ef8a3b8 100644
--- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/lease/Lease.java
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/lease/Lease.java
@@ -507,18 +507,17 @@ public TenantNotice recordTenantNotice(TenantNoticeCommand command, LeaseActionA
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.");
+ throw new LeaseEndingValidationFailure("receivedOn", "not_future");
}
if (command.receivedOn().isBefore(command.today()) && !command.lateRegistrationConfirmed()) {
- throw new LeaseRuleViolation("La saisie tardive du congé doit être confirmée.");
+ throw new LeaseEndingValidationFailure("lateRegistrationConfirmed", "required");
}
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é.");
+ throw new LeaseEndingValidationFailure("requestedDepartureOn", "after_or_equal_received_on");
}
if (command.deliveryMode() == NoticeDeliveryMode.OTHER
&& !command.nonStandardDeliveryConfirmed()) {
- throw new LeaseRuleViolation("Le mode de remise non standard doit être confirmé.");
+ throw new LeaseEndingValidationFailure("nonStandardDeliveryConfirmed", "required");
}
TenantNotice notice =
new TenantNotice(
@@ -547,7 +546,7 @@ public NoticeWithdrawal acceptNoticeWithdrawal(
endingHistory
.notice(noticeId)
.orElseThrow(() -> new LeaseRuleViolation("Le congé locataire est introuvable."));
- validateAdministrativeFactDate(acceptedOn, today, notice.receivedOn());
+ validateAdministrativeFactDate(acceptedOn, today, notice.receivedOn(), "acceptedOn");
return endingHistory.withdraw(noticeId, acceptedOn, reason, audit);
}
@@ -562,7 +561,7 @@ public NoticeInvalidation invalidateNotice(
endingHistory
.notice(noticeId)
.orElseThrow(() -> new LeaseRuleViolation("Le congé locataire est introuvable."));
- validateAdministrativeFactDate(invalidatedOn, today, notice.receivedOn());
+ validateAdministrativeFactDate(invalidatedOn, today, notice.receivedOn(), "invalidatedOn");
return endingHistory.invalidate(noticeId, invalidatedOn, reason, audit);
}
@@ -571,11 +570,11 @@ public AgreedEarlyLeaseEnd recordAgreedEarlyEnd(
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.");
+ throw new LeaseEndingValidationFailure("endOn", "after_or_equal_lease_start");
}
Optional currentEnd = contractEndOn();
if (currentEnd.isPresent() && !endOn.isBefore(currentEnd.orElseThrow())) {
- throw new LeaseRuleViolation("La fin convenue doit avancer la fin de bail actuelle.");
+ throw new LeaseEndingValidationFailure("endOn", "before_current_end");
}
return endingHistory.agree(endOn, reason, audit);
}
@@ -597,11 +596,11 @@ public LeaseClosure close(
LocalDate endOn =
contractEndOn()
.orElseThrow(
- () -> new LeaseRuleViolation("Le bail n'a pas encore de fin contractuelle."));
+ () -> new LeaseEndingConflict("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.");
+ throw new LeaseEndingConflict("Le bail ne peut être clôturé avant sa fin contractuelle.");
}
if (keysReturnedOn.isBefore(period.startDate()) || keysReturnedOn.isAfter(today)) {
throw new LeaseRuleViolation(
@@ -682,25 +681,24 @@ public LeaseEndPhase endPhaseOn(LocalDate date) {
private void requireActiveEndingSupported() {
if (status != LeaseStatus.ACTIVE) {
- throw new LeaseRuleViolation("Seul un bail actif peut recevoir un fait de fin de bail.");
+ throw new LeaseEndingConflict("Seul un bail actif peut recevoir un fait de fin de bail.");
}
requireEndingSupported();
}
private void requireEndingSupported() {
if (!endingSupported()) {
- throw new LeaseRuleViolation(
+ throw new LeaseEndingConflict(
"La fin de bail locataire n'est pas prise en charge pour une colocation.");
}
}
private static void validateAdministrativeFactDate(
- LocalDate factOn, LocalDate today, LocalDate noticeReceivedOn) {
+ LocalDate factOn, LocalDate today, LocalDate noticeReceivedOn, String field) {
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.");
+ throw new LeaseEndingValidationFailure(field, "between_notice_received_on_and_today");
}
}
diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/lease/LeaseEndingConflict.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/lease/LeaseEndingConflict.java
new file mode 100644
index 0000000..b4cba7a
--- /dev/null
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/lease/LeaseEndingConflict.java
@@ -0,0 +1,7 @@
+package fr.nessar.lmnporganizer.rental.domain.lease;
+
+public final class LeaseEndingConflict extends RuntimeException {
+public LeaseEndingConflict(String message) {
+ super(message);
+}
+}
diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/lease/LeaseEndingHistory.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/lease/LeaseEndingHistory.java
index 97426d8..0cf2194 100644
--- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/lease/LeaseEndingHistory.java
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/lease/LeaseEndingHistory.java
@@ -51,7 +51,7 @@ TenantNotice recordNotice(TenantNotice notice) {
throw new LeaseRuleViolation("Ce congé existe déjà.");
}
if (activeNotice().isPresent()) {
- throw new LeaseRuleViolation("Un congé locataire est déjà actif pour ce bail.");
+ throw new LeaseEndingConflict("Un congé locataire est déjà actif pour ce bail.");
}
notices = appended(notices, notice);
return notice;
@@ -94,7 +94,7 @@ NoticeEvidenceVersion appendEvidence(
LeaseClosure close(LocalDate keysReturnedOn, LeaseActionAudit audit) {
if (closure != null) {
- throw new LeaseRuleViolation("Le bail est déjà clôturé.");
+ throw new LeaseEndingConflict("Le bail est déjà clôturé.");
}
closure = new LeaseClosure(keysReturnedOn, audit);
return closure;
@@ -157,7 +157,7 @@ private TenantNotice requireActive(TenantNoticeId noticeId) {
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é.");
+ throw new LeaseEndingConflict("Seul un congé actif peut être modifié.");
}
return notice;
}
@@ -215,11 +215,11 @@ private void requireKnownNotice(TenantNoticeId noticeId) {
static String requiredReason(String reason) {
if (reason == null || reason.isBlank()) {
- throw new LeaseRuleViolation("La raison est obligatoire.");
+ throw new LeaseEndingValidationFailure("reason", "required");
}
String value = reason.trim();
if (value.length() > 500) {
- throw new LeaseRuleViolation("La raison ne peut pas dépasser 500 caractères.");
+ throw new LeaseEndingValidationFailure("reason", "max_length");
}
return value;
}
diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/lease/LeaseEndingValidationFailure.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/lease/LeaseEndingValidationFailure.java
new file mode 100644
index 0000000..4a06a83
--- /dev/null
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/lease/LeaseEndingValidationFailure.java
@@ -0,0 +1,18 @@
+package fr.nessar.lmnporganizer.rental.domain.lease;
+
+import java.util.List;
+
+public final class LeaseEndingValidationFailure extends RuntimeException {
+private final List errors;
+
+public LeaseEndingValidationFailure(String field, String code) {
+ super("La demande de fin de bail est invalide.");
+ this.errors = List.of(new FieldError(field, code));
+}
+
+public List errors() {
+ return errors;
+}
+
+public record FieldError(String field, String code) {}
+}
diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/lease/TenantNotice.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/lease/TenantNotice.java
index 7cbb884..0c5e153 100644
--- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/lease/TenantNotice.java
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/lease/TenantNotice.java
@@ -33,14 +33,13 @@ public TenantNotice {
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.");
+ throw new LeaseEndingValidationFailure("otherDeliveryMode", "max_length");
}
if (deliveryMode == NoticeDeliveryMode.OTHER && otherDeliveryMode == null) {
- throw new LeaseRuleViolation("Le mode de remise alternatif est obligatoire.");
+ throw new LeaseEndingValidationFailure("otherDeliveryMode", "required");
}
if (deliveryMode != NoticeDeliveryMode.OTHER && otherDeliveryMode != null) {
- throw new LeaseRuleViolation("Le détail du mode de remise est réservé au mode OTHER.");
+ throw new LeaseEndingValidationFailure("otherDeliveryMode", "forbidden");
}
}
diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseEndingWebTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseEndingWebTest.java
index c6220bd..840064f 100644
--- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseEndingWebTest.java
+++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseEndingWebTest.java
@@ -29,6 +29,8 @@ 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.LeaseEndingConflict;
+import fr.nessar.lmnporganizer.rental.domain.lease.LeaseEndingValidationFailure;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSnapshot;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseType;
@@ -102,6 +104,8 @@ void getRequiresAValidIanaTimeZoneAndExposesTheCanonicalAppendOnlyHistory() thro
.with(landlord()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.phase").value("NOTICE_PERIOD"))
+ .andExpect(jsonPath("$.leaseStartOn").value("2026-01-01"))
+ .andExpect(jsonPath("$.maxEvidenceBytes").value(262144000))
.andExpect(jsonPath("$.notices[0].ruleVersion").value("2026-07"))
.andExpect(jsonPath("$.notices[0].recordedWithoutEvidenceConfirmed").value(true))
.andExpect(jsonPath("$.notices[0].authorId").value("subject-1"))
@@ -115,6 +119,50 @@ void getRequiresAValidIanaTimeZoneAndExposesTheCanonicalAppendOnlyHistory() thro
verify(endings).state(LANDLORD_ID, LEASE_ID, ZoneId.of("America/Los_Angeles"));
}
+@Test
+void lifecycleConflictsRemainDistinctFromRequestValidation() throws Exception {
+ when(endings.recordAgreedEarlyEnd(any(), any(), any(), any()))
+ .thenThrow(
+ new LeaseEndingConflict("La fin convenue doit avancer la fin de bail actuelle."));
+
+ 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().isConflict())
+ .andExpect(
+ jsonPath("$.message").value("La fin convenue doit avancer la fin de bail actuelle."));
+}
+
+@Test
+void domainValidationUsesStableFieldPathsAndCodes() throws Exception {
+ when(endings.recordNotice(any(), any(), any(), any(), any()))
+ .thenThrow(new LeaseEndingValidationFailure("receivedOn", "not_future"));
+
+ mockMvc
+ .perform(
+ multipart("/api/leases/lease-1/end/notices")
+ .param("timeZone", "Europe/Paris")
+ .param("receivedOn", "2026-07-20")
+ .param("requestedDepartureOn", "2026-08-20")
+ .param("deliveryMode", "REGISTERED_LETTER")
+ .param("lateRegistrationConfirmed", "false")
+ .param("nonStandardDeliveryConfirmed", "false")
+ .param("missingEvidenceConfirmed", "true")
+ .with(landlord())
+ .with(csrf()))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value("validation_failed"))
+ .andExpect(jsonPath("$.errors[0].field").value("receivedOn"))
+ .andExpect(jsonPath("$.errors[0].code").value("not_future"));
+}
+
@Test
void noticeMultipartForwardsTimezoneEvidenceAttestationAndAuthenticatedAuthor() throws Exception {
when(endings.recordNotice(any(), any(), any(), any(), any())).thenReturn(state());
@@ -310,8 +358,10 @@ private static LeaseEndingState state() {
Optional.empty(),
LeaseEndPhase.NOTICE_PERIOD,
LeaseType.STANDARD,
+ LocalDate.of(2026, 1, 1),
Optional.empty(),
Optional.of(LocalDate.of(2026, 8, 18)),
+ 250L * 1024 * 1024,
false,
Optional.of(new LeaseEndingState.ExitConditionReport(LocalDate.of(2026, 8, 18), true)),
lease.endingHistory());
diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/lease/LeaseEndingServiceTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/lease/LeaseEndingServiceTest.java
index c215ff2..1438a1b 100644
--- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/lease/LeaseEndingServiceTest.java
+++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/lease/LeaseEndingServiceTest.java
@@ -22,6 +22,8 @@ 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.LeaseEndPhase;
+import fr.nessar.lmnporganizer.rental.domain.lease.LeaseEndingConflict;
+import fr.nessar.lmnporganizer.rental.domain.lease.LeaseEndingValidationFailure;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRuleViolation;
@@ -118,7 +120,7 @@ void noticeWithoutEvidenceRequiresTheExplicitAttestation() {
storage);
assertThrows(
- LeaseRuleViolation.class,
+ LeaseEndingValidationFailure.class,
() ->
service.recordNotice(
LANDLORD_ID,
@@ -189,7 +191,7 @@ void civilTodayIsComputedInTheLandlordSuppliedTimeZoneWithoutConvertingTheEntere
boundaryClock);
assertThrows(
- LeaseRuleViolation.class,
+ LeaseEndingValidationFailure.class,
() ->
lasVegasService.recordNotice(
LANDLORD_ID,
@@ -459,7 +461,7 @@ void closingRejectsAnUnfinalizedExitReport() {
reports);
assertThrows(
- LeaseRuleViolation.class,
+ LeaseEndingConflict.class,
() ->
service.close(
LANDLORD_ID,
diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/domain/lease/LeaseEndingTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/domain/lease/LeaseEndingTest.java
index 11f9338..4cc02d8 100644
--- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/domain/lease/LeaseEndingTest.java
+++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/domain/lease/LeaseEndingTest.java
@@ -56,7 +56,7 @@ void noticeHonorsTheRequestedLaterDepartureAndValidatesCivilDateConfirmations()
Lease lease = activeLease(LeaseType.STANDARD, List.of(TENANT_ID));
assertThrows(
- LeaseRuleViolation.class,
+ LeaseEndingValidationFailure.class,
() ->
lease.recordTenantNotice(
notice(
@@ -69,7 +69,7 @@ void noticeHonorsTheRequestedLaterDepartureAndValidatesCivilDateConfirmations()
false),
AUDIT));
assertThrows(
- LeaseRuleViolation.class,
+ LeaseEndingValidationFailure.class,
() ->
lease.recordTenantNotice(
notice(
@@ -82,7 +82,7 @@ void noticeHonorsTheRequestedLaterDepartureAndValidatesCivilDateConfirmations()
false),
AUDIT));
assertThrows(
- LeaseRuleViolation.class,
+ LeaseEndingValidationFailure.class,
() ->
lease.recordTenantNotice(
notice(
@@ -95,7 +95,7 @@ void noticeHonorsTheRequestedLaterDepartureAndValidatesCivilDateConfirmations()
false),
AUDIT));
assertThrows(
- LeaseRuleViolation.class,
+ LeaseEndingValidationFailure.class,
() ->
lease.recordTenantNotice(
notice(
@@ -108,7 +108,7 @@ void noticeHonorsTheRequestedLaterDepartureAndValidatesCivilDateConfirmations()
true),
AUDIT));
assertThrows(
- LeaseRuleViolation.class,
+ LeaseEndingValidationFailure.class,
() ->
lease.recordTenantNotice(
notice(
@@ -161,7 +161,7 @@ void studentFixedEndCapsAnEarlierNoticeAndAllEndingCommandsRejectSharedLeases()
assertFalse(shared.endingSupported());
assertEquals(fixedEnd, shared.contractEndOn().orElseThrow());
assertThrows(
- LeaseRuleViolation.class,
+ LeaseEndingConflict.class,
() ->
shared.recordTenantNotice(
notice(
@@ -174,7 +174,7 @@ void studentFixedEndCapsAnEarlierNoticeAndAllEndingCommandsRejectSharedLeases()
false),
AUDIT));
assertThrows(
- LeaseRuleViolation.class,
+ LeaseEndingConflict.class,
() -> shared.recordAgreedEarlyEnd(TODAY.plusMonths(1), "Accord", AUDIT));
}
@@ -290,7 +290,7 @@ void withdrawalAndInvalidationAppendAuditFactsWithoutErasingNotices() {
assertEquals(1, lease.endingHistory().withdrawals().size());
assertEquals(1, lease.endingHistory().invalidations().size());
assertThrows(
- LeaseRuleViolation.class,
+ LeaseEndingConflict.class,
() ->
lease.acceptNoticeWithdrawal(
new TenantNoticeId("invalidated"),
@@ -317,11 +317,14 @@ void agreedEarlyEndWorksWithoutNoticeAndCanShortenAnExistingNotice() {
false,
false),
AUDIT);
+ LeaseEndingValidationFailure noChange =
+ assertThrows(
+ LeaseEndingValidationFailure.class,
+ () -> lease.recordAgreedEarlyEnd(LocalDate.of(2026, 9, 30), "Sans effet", AUDIT));
+ assertEquals("endOn", noChange.errors().getFirst().field());
+ assertEquals("before_current_end", noChange.errors().getFirst().code());
assertThrows(
- LeaseRuleViolation.class,
- () -> lease.recordAgreedEarlyEnd(LocalDate.of(2026, 9, 30), "Sans effet", AUDIT));
- assertThrows(
- LeaseRuleViolation.class,
+ LeaseEndingValidationFailure.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);
@@ -347,10 +350,10 @@ void evidenceIsVersionedAndEveryReplacementRequiresAReason() {
NoticeEvidenceVersion first = lease.appendNoticeEvidence(noticeId, firstFile, null, AUDIT);
assertEquals(1, first.version());
assertThrows(
- LeaseRuleViolation.class,
+ LeaseEndingValidationFailure.class,
() -> lease.appendNoticeEvidence(noticeId, replacement, " ", AUDIT));
assertThrows(
- LeaseRuleViolation.class,
+ LeaseEndingValidationFailure.class,
() -> lease.appendNoticeEvidence(noticeId, replacement, "x".repeat(501), AUDIT));
NoticeEvidenceVersion second =
lease.appendNoticeEvidence(noticeId, replacement, "Document corrigé", AUDIT);
diff --git a/frontend/src/app/components/lease-end-notice-card/lease-end-notice-card.html b/frontend/src/app/components/lease-end-notice-card/lease-end-notice-card.html
index 06b3d0c..a53f333 100644
--- a/frontend/src/app/components/lease-end-notice-card/lease-end-notice-card.html
+++ b/frontend/src/app/components/lease-end-notice-card/lease-end-notice-card.html
@@ -159,17 +159,45 @@
>
+ @if (serverError(closureFieldErrors(), 'confirmed')) {
+
+ {{ serverError(closureFieldErrors(), 'confirmed') }}
+
+ }
@if (closureError() || closureOperationError()) {
{{ closureError() || closureOperationError() }}
diff --git a/frontend/src/app/components/lease-end-resolution-forms/lease-end-resolution-forms.spec.ts b/frontend/src/app/components/lease-end-resolution-forms/lease-end-resolution-forms.spec.ts
index 02d8efa..be53726 100644
--- a/frontend/src/app/components/lease-end-resolution-forms/lease-end-resolution-forms.spec.ts
+++ b/frontend/src/app/components/lease-end-resolution-forms/lease-end-resolution-forms.spec.ts
@@ -6,6 +6,7 @@ describe('LeaseEndResolutionForms', () => {
function createFixture() {
const fixture = TestBed.createComponent(LeaseEndResolutionForms);
fixture.componentRef.setInput('timeZone', 'America/Los_Angeles');
+ fixture.componentRef.setInput('leaseStartOn', '2026-01-01');
fixture.componentRef.setInput('agreementPending', false);
fixture.componentRef.setInput('closurePending', false);
fixture.detectChanges();
diff --git a/frontend/src/app/components/lease-end-resolution-forms/lease-end-resolution-forms.ts b/frontend/src/app/components/lease-end-resolution-forms/lease-end-resolution-forms.ts
index 3816860..4cbd009 100644
--- a/frontend/src/app/components/lease-end-resolution-forms/lease-end-resolution-forms.ts
+++ b/frontend/src/app/components/lease-end-resolution-forms/lease-end-resolution-forms.ts
@@ -1,8 +1,9 @@
-import { Component, input, output, signal } from '@angular/core';
+import { Component, effect, ElementRef, 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 { AgreedEarlyEndRequest, CloseLeaseRequest } from '../../services/lease-end.service';
+import { FieldValidationError } from '../../services/http-error-message';
@Component({
selector: 'app-lease-end-resolution-forms',
@@ -11,34 +12,64 @@ import { AgreedEarlyEndRequest, CloseLeaseRequest } from '../../services/lease-e
styleUrl: './lease-end-resolution-forms.css',
})
export class LeaseEndResolutionForms {
+ private readonly element = inject(ElementRef);
readonly timeZone = input.required();
+ readonly leaseStartOn = input.required();
+ readonly currentEndOn = input(null);
readonly agreementPending = input(false);
readonly closurePending = input(false);
readonly agreementOperationError = input('');
readonly closureOperationError = input('');
+ readonly agreementFieldErrors = input([]);
+ readonly closureFieldErrors = input([]);
readonly showAgreement = input(true);
readonly showClosure = input(true);
readonly keysReturnedOn = input(null);
readonly closureEnabled = input(false);
readonly agreedEndRequested = output();
readonly closureRequested = output();
+ readonly agreementFieldChanged = output();
+ readonly closureFieldChanged = output();
readonly agreementModel = signal({ endOn: '', reason: '' });
readonly closureModel = signal({ confirmed: false });
readonly agreementForm = form(this.agreementModel, (fields) => {
maxLength(fields.reason, 500);
});
readonly closureForm = form(this.closureModel);
- readonly agreementError = signal('');
readonly closureError = signal('');
+ readonly agreementSubmitted = signal(false);
+
+ constructor() {
+ effect(() => {
+ const agreementField = this.agreementFieldErrors()[0]?.field;
+ if (agreementField) {
+ queueMicrotask(() =>
+ (this.element.nativeElement as HTMLElement)
+ .querySelector(
+ agreementField === 'reason' ? '#agreement-reason' : '#agreement-end-on',
+ )
+ ?.focus(),
+ );
+ }
+ const closureField = this.closureFieldErrors()[0]?.field;
+ if (closureField) {
+ queueMicrotask(() =>
+ (this.element.nativeElement as HTMLElement)
+ .querySelector('#closure-confirmed')
+ ?.focus(),
+ );
+ }
+ });
+ }
submitAgreement(): void {
if (this.agreementPending()) return;
+ this.agreementSubmitted.set(true);
+ this.agreementForm().markAsTouched();
const model = this.agreementModel();
- if (!model.endOn || !model.reason.trim()) {
- this.agreementError.set('Renseignez la date et la raison de la fin anticipée.');
+ if (this.agreementDateError() || this.agreementReasonError()) {
return;
}
- this.agreementError.set('');
this.agreedEndRequested.emit({
timeZone: this.timeZone(),
endOn: model.endOn,
@@ -46,6 +77,40 @@ export class LeaseEndResolutionForms {
});
}
+ agreementDateError(): string {
+ if (!this.show(this.agreementForm.endOn())) return '';
+ const endOn = this.agreementModel().endOn;
+ if (!endOn) return 'Renseignez la date de fin convenue.';
+ if (endOn < this.leaseStartOn()) {
+ return 'La fin convenue ne peut pas précéder le début du bail.';
+ }
+ const currentEndOn = this.currentEndOn();
+ return currentEndOn && endOn >= currentEndOn
+ ? 'La fin convenue doit être antérieure à la fin actuelle du bail.'
+ : '';
+ }
+
+ agreementReasonError(): string {
+ if (!this.show(this.agreementForm.reason())) return '';
+ const reason = this.agreementModel().reason.trim();
+ if (!reason) return 'Renseignez la raison de la fin anticipée.';
+ return reason.length > 500 ? 'La raison ne peut pas dépasser 500 caractères.' : '';
+ }
+
+ serverError(errors: FieldValidationError[], field: string): string {
+ const code = errors.find((error) => error.field === field)?.code;
+ if (field === 'endOn' && code === 'after_or_equal_lease_start') {
+ return 'La fin convenue ne peut pas précéder le début du bail.';
+ }
+ if (field === 'endOn' && code === 'before_current_end') {
+ return 'La fin convenue doit être antérieure à la fin actuelle du bail.';
+ }
+ if (field === 'reason' && code === 'max_length')
+ return 'La raison ne peut pas dépasser 500 caractères.';
+ if (field === 'confirmed' && code) return 'Confirmez la restitution réelle des clés.';
+ return '';
+ }
+
submitClosure(): void {
if (this.closurePending()) return;
const model = this.closureModel();
@@ -65,4 +130,8 @@ export class LeaseEndResolutionForms {
confirmed: true,
});
}
+
+ private show(field: { touched(): boolean }): boolean {
+ return this.agreementSubmitted() || field.touched();
+ }
}
diff --git a/frontend/src/app/services/lease-end.service.ts b/frontend/src/app/services/lease-end.service.ts
index 0ec21f6..8ee5814 100644
--- a/frontend/src/app/services/lease-end.service.ts
+++ b/frontend/src/app/services/lease-end.service.ts
@@ -48,8 +48,10 @@ export interface LeaseEndState {
unsupportedReason: string | null;
phase: LeaseEndPhase;
leaseType: 'STANDARD' | 'STUDENT';
+ leaseStartOn: string;
fixedEndOn: string | null;
effectiveEndOn: string | null;
+ maxEvidenceBytes: number;
correctionRequired: boolean;
exitConditionReport: {
occurredOn: string;