Align lease ending validation flows (#54)

This commit is contained in:
nessar committed 2026-08-17 14:18:16 +02:00
1 parent ca3e28d3a2
commit a0f86bb511
25 files changed
+845 -128

No files matched your search

@@ -19,14 +19,22 @@ import java.util.List;
final class LeaseEndingApi {
private LeaseEndingApi() {}
record OperationError(String message) {}
record ValidationError(String code, List<FieldError> 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<NoticeResponse> 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),
@@ -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<LeaseEndingApi.OperationError> conflict(LeaseEndingConflict failure) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(new LeaseEndingApi.OperationError(failure.getMessage()));
}
@ExceptionHandler(LeaseEndingValidationFailure.class)
ResponseEntity<LeaseEndingApi.ValidationError> 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<LeaseEndingApi.OperationError> 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) {}
}
@@ -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);
@@ -14,8 +14,10 @@ public record LeaseEndingState(
Optional<String> unsupportedReason,
LeaseEndPhase phase,
LeaseType leaseType,
LocalDate leaseStartOn,
Optional<LocalDate> fixedEndOn,
Optional<LocalDate> effectiveEndOn,
long maxEvidenceBytes,
boolean correctionRequired,
Optional<ExitConditionReport> 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);
}
@@ -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<LocalDate> 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");
}
}
@@ -0,0 +1,7 @@
package fr.nessar.lmnporganizer.rental.domain.lease;
public final class LeaseEndingConflict extends RuntimeException {
public LeaseEndingConflict(String message) {
super(message);
}
}
@@ -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;
}
@@ -0,0 +1,18 @@
package fr.nessar.lmnporganizer.rental.domain.lease;
import java.util.List;
public final class LeaseEndingValidationFailure extends RuntimeException {
private final List<FieldError> 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<FieldError> errors() {
return errors;
}
public record FieldError(String field, String code) {}
}
@@ -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");
}
}
@@ -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());
@@ -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,
@@ -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(
LeaseRuleViolation.class,
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,
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);
@@ -159,17 +159,45 @@
>
<fieldset [disabled]="withdrawalPending()">
<legend>Accepter un retrait</legend>
<label
><span>Date d’acceptation</span
><input type="date" [formField]="withdrawalForm.acceptedOn"
/></label>
<label>
<span>Date d’acceptation</span>
<input
id="withdrawal-date"
type="date"
[formField]="withdrawalForm.acceptedOn"
(input)="withdrawalFieldChanged.emit('acceptedOn')"
/>
</label>
@if (withdrawalDateError()) {
<p class="operation-error" role="alert">{{ withdrawalDateError() }}</p>
}
@if (serverError(withdrawalFieldErrors(), 'acceptedOn')) {
<p class="operation-error" role="alert">
{{ serverError(withdrawalFieldErrors(), 'acceptedOn') }}
</p>
}
<label
><span>Raison</span
><textarea rows="2" [formField]="withdrawalForm.reason"></textarea>
><textarea
id="withdrawal-reason"
rows="2"
[formField]="withdrawalForm.reason"
(input)="withdrawalFieldChanged.emit('reason')"
></textarea>
</label>
@if (withdrawalError() || withdrawalOperationError()) {
@if (withdrawalReasonError()) {
<p class="operation-error" role="alert">
{{ withdrawalError() || withdrawalOperationError() }}
{{ withdrawalReasonError() }}
</p>
}
@if (serverError(withdrawalFieldErrors(), 'reason')) {
<p class="operation-error" role="alert">
{{ serverError(withdrawalFieldErrors(), 'reason') }}
</p>
}
@if (withdrawalOperationError()) {
<p class="operation-error" role="alert">
{{ withdrawalOperationError() }}
</p>
}
<button mat-stroked-button type="submit" [disabled]="withdrawalPending()">
@@ -184,17 +212,47 @@
>
<fieldset [disabled]="invalidationPending()">
<legend>Invalider administrativement</legend>
<label
><span>Date d’invalidation</span
><input type="date" [formField]="invalidationForm.invalidatedOn"
/></label>
<label>
<span>Date d’invalidation</span>
<input
id="invalidation-date"
type="date"
[formField]="invalidationForm.invalidatedOn"
(input)="invalidationFieldChanged.emit('invalidatedOn')"
/>
</label>
@if (invalidationDateError()) {
<p class="operation-error" role="alert">
{{ invalidationDateError() }}
</p>
}
@if (serverError(invalidationFieldErrors(), 'invalidatedOn')) {
<p class="operation-error" role="alert">
{{ serverError(invalidationFieldErrors(), 'invalidatedOn') }}
</p>
}
<label
><span>Raison</span
><textarea rows="2" [formField]="invalidationForm.reason"></textarea>
><textarea
id="invalidation-reason"
rows="2"
[formField]="invalidationForm.reason"
(input)="invalidationFieldChanged.emit('reason')"
></textarea>
</label>
@if (invalidationError() || invalidationOperationError()) {
@if (invalidationReasonError()) {
<p class="operation-error" role="alert">
{{ invalidationError() || invalidationOperationError() }}
{{ invalidationReasonError() }}
</p>
}
@if (serverError(invalidationFieldErrors(), 'reason')) {
<p class="operation-error" role="alert">
{{ serverError(invalidationFieldErrors(), 'reason') }}
</p>
}
@if (invalidationOperationError()) {
<p class="operation-error" role="alert">
{{ invalidationOperationError() }}
</p>
}
<button mat-stroked-button type="submit" [disabled]="invalidationPending()">
@@ -50,6 +50,8 @@ describe('LeaseEndNoticeCard', () => {
fixture.componentRef.setInput('leaseId', 'lease-1');
fixture.componentRef.setInput('notice', notice);
fixture.componentRef.setInput('timeZone', 'Europe/Paris');
fixture.componentRef.setInput('today', '2026-07-20');
fixture.componentRef.setInput('maxEvidenceBytes', 1024);
fixture.componentRef.setInput('lifecycleMutationsEnabled', true);
fixture.componentRef.setInput('evidenceUploadEnabled', true);
fixture.componentRef.setInput('withdrawalPending', false);
@@ -1,4 +1,4 @@
import { Component, inject, 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';
@@ -11,6 +11,7 @@ import {
LeaseEndService,
NoticeDeliveryMode,
} from '../../services/lease-end.service';
import { FieldValidationError } from '../../services/http-error-message';
export interface NoticeAction<T> {
noticeId: string;
@@ -24,10 +25,13 @@ export interface NoticeAction<T> {
styleUrl: './lease-end-notice-card.css',
})
export class LeaseEndNoticeCard {
private readonly element = inject(ElementRef<HTMLElement>);
readonly endService = inject(LeaseEndService);
readonly leaseId = input.required<string>();
readonly notice = input.required<LeaseEndNotice>();
readonly timeZone = input.required<string>();
readonly today = input.required<string>();
readonly maxEvidenceBytes = input.required<number>();
readonly lifecycleMutationsEnabled = input.required<boolean>();
readonly evidenceUploadEnabled = input.required<boolean>();
readonly withdrawalPending = input(false);
@@ -36,9 +40,13 @@ export class LeaseEndNoticeCard {
readonly withdrawalOperationError = input('');
readonly invalidationOperationError = input('');
readonly evidenceOperationError = input('');
readonly withdrawalFieldErrors = input<FieldValidationError[]>([]);
readonly invalidationFieldErrors = input<FieldValidationError[]>([]);
readonly withdrawalRequested = output<NoticeAction<AcceptWithdrawalRequest>>();
readonly invalidationRequested = output<NoticeAction<InvalidateNoticeRequest>>();
readonly evidenceRequested = output<NoticeAction<AppendEvidenceRequest>>();
readonly withdrawalFieldChanged = output<string>();
readonly invalidationFieldChanged = output<string>();
readonly withdrawalModel = signal({ acceptedOn: '', reason: '' });
readonly invalidationModel = signal({ invalidatedOn: '', reason: '' });
readonly withdrawalForm = form(this.withdrawalModel, (fields) => {
@@ -49,12 +57,41 @@ export class LeaseEndNoticeCard {
});
readonly evidence = signal<File | null>(null);
readonly evidenceReason = signal('');
readonly withdrawalError = signal('');
readonly invalidationError = signal('');
readonly evidenceError = signal('');
readonly withdrawalSubmitted = signal(false);
readonly invalidationSubmitted = signal(false);
readonly civilDate = formatFrenchCivilDate;
constructor() {
effect(() => {
const withdrawalField = this.withdrawalFieldErrors()[0]?.field;
if (withdrawalField) {
queueMicrotask(() =>
(this.element.nativeElement as HTMLElement)
.querySelector<HTMLElement>(
withdrawalField === 'reason'
? '#withdrawal-reason'
: '#withdrawal-date',
)
?.focus(),
);
}
const invalidationField = this.invalidationFieldErrors()[0]?.field;
if (invalidationField) {
queueMicrotask(() =>
(this.element.nativeElement as HTMLElement)
.querySelector<HTMLElement>(
invalidationField === 'reason'
? '#invalidation-reason'
: '#invalidation-date',
)
?.focus(),
);
}
});
}
statusLabel(): string {
return {
ACTIVE: 'Congé en cours',
@@ -87,12 +124,12 @@ export class LeaseEndNoticeCard {
submitWithdrawal(): void {
if (this.withdrawalPending()) return;
this.withdrawalSubmitted.set(true);
this.withdrawalForm().markAsTouched();
const model = this.withdrawalModel();
if (!model.acceptedOn || !model.reason.trim()) {
this.withdrawalError.set('Renseignez la date et la raison du retrait accepté.');
if (this.withdrawalDateError() || this.withdrawalReasonError()) {
return;
}
this.withdrawalError.set('');
this.withdrawalRequested.emit({
noticeId: this.notice().id,
request: {
@@ -105,12 +142,12 @@ export class LeaseEndNoticeCard {
submitInvalidation(): void {
if (this.invalidationPending()) return;
this.invalidationSubmitted.set(true);
this.invalidationForm().markAsTouched();
const model = this.invalidationModel();
if (!model.invalidatedOn || !model.reason.trim()) {
this.invalidationError.set('Renseignez la date et la raison de l’invalidation.');
if (this.invalidationDateError() || this.invalidationReasonError()) {
return;
}
this.invalidationError.set('');
this.invalidationRequested.emit({
noticeId: this.notice().id,
request: {
@@ -129,6 +166,18 @@ export class LeaseEndNoticeCard {
this.evidenceError.set('Sélectionnez une preuve à conserver.');
return;
}
if (evidence.size === 0) {
this.evidenceError.set('La preuve sélectionnée est vide.');
return;
}
if (evidence.size > this.maxEvidenceBytes()) {
this.evidenceError.set('La preuve dépasse la taille maximale autorisée.');
return;
}
if (reason.length > 500) {
this.evidenceError.set('La raison ne peut pas dépasser 500 caractères.');
return;
}
if (this.notice().evidenceVersions.length > 0 && !reason) {
this.evidenceError.set('Indiquez la raison de cette nouvelle version de preuve.');
return;
@@ -139,4 +188,60 @@ export class LeaseEndNoticeCard {
request: { timeZone: this.timeZone(), evidence, ...(reason ? { reason } : {}) },
});
}
withdrawalDateError(): string {
const date = this.withdrawalModel().acceptedOn;
if (!this.show(this.withdrawalSubmitted(), this.withdrawalForm.acceptedOn())) return '';
if (!date) return 'Renseignez la date d’acceptation.';
return date < this.notice().receivedOn || date > this.today()
? 'La date doit être comprise entre la réception du congé et aujourd’hui.'
: '';
}
withdrawalReasonError(): string {
return this.reasonError(
this.withdrawalSubmitted(),
this.withdrawalForm.reason(),
this.withdrawalModel().reason,
);
}
invalidationDateError(): string {
const date = this.invalidationModel().invalidatedOn;
if (!this.show(this.invalidationSubmitted(), this.invalidationForm.invalidatedOn()))
return '';
if (!date) return 'Renseignez la date d’invalidation.';
return date < this.notice().receivedOn || date > this.today()
? 'La date doit être comprise entre la réception du congé et aujourd’hui.'
: '';
}
invalidationReasonError(): string {
return this.reasonError(
this.invalidationSubmitted(),
this.invalidationForm.reason(),
this.invalidationModel().reason,
);
}
serverError(errors: FieldValidationError[], field: string): string {
const code = errors.find((error) => error.field === field)?.code;
if (field === 'reason' && code === 'max_length')
return 'La raison ne peut pas dépasser 500 caractères.';
if (field === 'reason' && code === 'required') return 'Renseignez la raison.';
if ((field === 'acceptedOn' || field === 'invalidatedOn') && code) {
return 'La date doit être comprise entre la réception du congé et aujourd’hui.';
}
return '';
}
private reasonError(submitted: boolean, field: { touched(): boolean }, value: string): string {
if (!this.show(submitted, field)) return '';
if (!value.trim()) return 'Renseignez la raison.';
return value.trim().length > 500 ? 'La raison ne peut pas dépasser 500 caractères.' : '';
}
private show(submitted: boolean, field: { touched(): boolean }): boolean {
return submitted || field.touched();
}
}
@@ -1,6 +1,6 @@
<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>
<p>Les dates restent des dates civiles dans le fuseau {{ timeZone() }}.</p>
<form
[formRoot]="noticeForm"
[attr.aria-busy]="submitting()"
@@ -10,39 +10,109 @@
<div class="field-grid">
<label>
<span>Date de réception</span>
<input type="date" [formField]="noticeForm.receivedOn" />
<input
id="notice-received-on"
type="date"
[formField]="noticeForm.receivedOn"
(input)="fieldWasChanged('receivedOn')"
/>
@if (receivedOnError()) {
<p class="operation-error" role="alert">{{ receivedOnError() }}</p>
}
@if (serverError('receivedOn')) {
<p class="operation-error" role="alert">{{ serverError('receivedOn') }}</p>
}
</label>
<label>
<span>Départ demandé</span>
<input type="date" [formField]="noticeForm.requestedDepartureOn" />
<input
id="notice-requested-departure-on"
type="date"
[formField]="noticeForm.requestedDepartureOn"
(input)="fieldWasChanged('requestedDepartureOn')"
/>
@if (requestedDepartureOnError()) {
<p class="operation-error" role="alert">
{{ requestedDepartureOnError() }}
</p>
}
@if (serverError('requestedDepartureOn')) {
<p class="operation-error" role="alert">
{{ serverError('requestedDepartureOn') }}
</p>
}
</label>
</div>
<label>
<span>Mode de remise</span>
<select [formField]="noticeForm.deliveryMode">
<select
id="notice-delivery-mode"
[formField]="noticeForm.deliveryMode"
(change)="fieldWasChanged('deliveryMode')"
>
<option value="">Choisissez un mode</option>
@for (mode of deliveryModes; track mode.value) {
<option [value]="mode.value">{{ mode.label }}</option>
}
</select>
@if (deliveryModeError()) {
<p class="operation-error" role="alert">{{ deliveryModeError() }}</p>
}
</label>
@if (model().deliveryMode === 'OTHER') {
<label>
<span>Mode de remise utilisé</span>
<input type="text" [formField]="noticeForm.otherDeliveryMode" />
<input
id="notice-other-delivery-mode"
type="text"
[formField]="noticeForm.otherDeliveryMode"
(input)="fieldWasChanged('otherDeliveryMode')"
/>
@if (otherDeliveryModeError()) {
<p class="operation-error" role="alert">{{ otherDeliveryModeError() }}</p>
}
@if (serverError('otherDeliveryMode')) {
<p class="operation-error" role="alert">
{{ serverError('otherDeliveryMode') }}
</p>
}
</label>
<label class="confirmation-field">
<input type="checkbox" [formField]="noticeForm.nonStandardDeliveryConfirmed" />
<input
type="checkbox"
[formField]="noticeForm.nonStandardDeliveryConfirmed"
(change)="fieldWasChanged('nonStandardDeliveryConfirmed')"
/>
<span>Je confirme avoir vérifié ce mode de remise non standard.</span>
</label>
@if (nonStandardDeliveryError()) {
<p class="operation-error" role="alert">{{ nonStandardDeliveryError() }}</p>
}
@if (serverError('nonStandardDeliveryConfirmed')) {
<p class="operation-error" role="alert">
{{ serverError('nonStandardDeliveryConfirmed') }}
</p>
}
}
@if (lateRegistration()) {
<label class="confirmation-field">
<input type="checkbox" [formField]="noticeForm.lateRegistrationConfirmed" />
<input
type="checkbox"
[formField]="noticeForm.lateRegistrationConfirmed"
(change)="fieldWasChanged('lateRegistrationConfirmed')"
/>
<span
>Je confirme enregistrer aujourd’hui un congé reçu à une date passée.</span
>
</label>
@if (lateRegistrationError()) {
<p class="operation-error" role="alert">{{ lateRegistrationError() }}</p>
}
@if (serverError('lateRegistrationConfirmed')) {
<p class="operation-error" role="alert">
{{ serverError('lateRegistrationConfirmed') }}
</p>
}
}
<label>
<span>Preuve (facultative, tout format)</span>
@@ -53,14 +123,23 @@
<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" />
<input
type="checkbox"
[formField]="noticeForm.missingEvidenceConfirmed"
(change)="fieldWasChanged('missingEvidenceConfirmed')"
/>
<span>Je confirme enregistrer ce congé sans justificatif.</span>
</label>
@if (serverError('missingEvidenceConfirmed')) {
<p class="operation-error" role="alert">
{{ serverError('missingEvidenceConfirmed') }}
</p>
}
</div>
}
@if (validationError() || operationError()) {
@if (evidenceError() || operationError()) {
<p class="operation-error" role="alert">
{{ validationError() || operationError() }}
{{ evidenceError() || operationError() }}
</p>
}
<button mat-flat-button type="submit" [disabled]="submitting()">
@@ -9,6 +9,7 @@ describe('LeaseEndNoticeForm', () => {
const fixture = TestBed.createComponent(LeaseEndNoticeForm);
fixture.componentRef.setInput('timeZone', 'Europe/Paris');
fixture.componentRef.setInput('today', '2026-07-19');
fixture.componentRef.setInput('maxEvidenceBytes', 1024);
fixture.componentRef.setInput('submitting', false);
fixture.detectChanges();
fixture.componentInstance.model.set({
@@ -25,22 +26,48 @@ describe('LeaseEndNoticeForm', () => {
expect(fixture.nativeElement.querySelector('input[type="text"]').maxLength).toBe(200);
fixture.componentInstance.submit();
expect(fixture.componentInstance.validationError()).toContain('enregistrement tardif');
expect(fixture.componentInstance.lateRegistrationError()).toContain(
'enregistrement tardif',
);
fixture.componentInstance.model.update((model) => ({
...model,
lateRegistrationConfirmed: true,
}));
fixture.componentInstance.submit();
expect(fixture.componentInstance.validationError()).toContain(
expect(fixture.componentInstance.nonStandardDeliveryError()).toContain(
'mode de remise non standard',
);
});
it('shows the requested departure guidance after submit', () => {
const fixture = TestBed.createComponent(LeaseEndNoticeForm);
fixture.componentRef.setInput('timeZone', 'Europe/Paris');
fixture.componentRef.setInput('today', '2026-07-19');
fixture.componentRef.setInput('maxEvidenceBytes', 1024);
fixture.componentRef.setInput('submitting', false);
fixture.componentInstance.model.set({
receivedOn: '2026-07-19',
requestedDepartureOn: '2026-07-18',
deliveryMode: 'HAND_DELIVERY',
otherDeliveryMode: '',
lateRegistrationConfirmed: false,
nonStandardDeliveryConfirmed: false,
missingEvidenceConfirmed: true,
});
fixture.componentInstance.submit();
expect(fixture.componentInstance.requestedDepartureOnError()).toContain(
'ne peut pas précéder',
);
});
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('maxEvidenceBytes', 1024);
fixture.componentRef.setInput('submitting', false);
const requested = vi.fn();
fixture.componentInstance.noticeRequested.subscribe(requested);
@@ -86,6 +113,7 @@ describe('LeaseEndNoticeForm', () => {
const fixture = TestBed.createComponent(LeaseEndNoticeForm);
fixture.componentRef.setInput('timeZone', 'Europe/Paris');
fixture.componentRef.setInput('today', '2026-07-19');
fixture.componentRef.setInput('maxEvidenceBytes', 1024);
fixture.componentRef.setInput('submitting', false);
const requested = vi.fn();
fixture.componentInstance.noticeRequested.subscribe(requested);
@@ -101,7 +129,7 @@ describe('LeaseEndNoticeForm', () => {
});
fixture.componentInstance.submit();
expect(fixture.componentInstance.validationError()).toContain('sans justificatif');
expect(fixture.componentInstance.evidenceError()).toContain('sans justificatif');
fixture.componentInstance.model.update((model) => ({
...model,
@@ -1,8 +1,37 @@
import { Component, computed, input, output, signal } from '@angular/core';
import {
Component,
computed,
effect,
ElementRef,
inject,
input,
output,
signal,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormField, FormRoot, form, maxLength } from '@angular/forms/signals';
import { FormField, FormRoot, form, maxLength, required } from '@angular/forms/signals';
import { MatButtonModule } from '@angular/material/button';
import { NoticeDeliveryMode, RecordNoticeRequest } from '../../services/lease-end.service';
import { FieldValidationError } from '../../services/http-error-message';
const serverMessages: Record<string, Record<string, string>> = {
receivedOn: { not_future: 'La date de réception ne peut pas être future.' },
requestedDepartureOn: {
after_or_equal_received_on:
'La date de départ demandée ne peut pas précéder la réception du congé.',
},
otherDeliveryMode: {
required: 'Décrivez le mode de remise utilisé.',
max_length: 'Le mode de remise ne peut pas dépasser 200 caractères.',
},
lateRegistrationConfirmed: {
required: 'Confirmez cet enregistrement tardif avant de continuer.',
},
nonStandardDeliveryConfirmed: {
required: 'Confirmez le mode de remise non standard avant de continuer.',
},
missingEvidenceConfirmed: { required: 'Confirmez l’enregistrement sans justificatif.' },
};
@Component({
selector: 'app-lease-end-notice-form',
@@ -11,11 +40,15 @@ import { NoticeDeliveryMode, RecordNoticeRequest } from '../../services/lease-en
styleUrl: './lease-end-notice-form.css',
})
export class LeaseEndNoticeForm {
private readonly element = inject(ElementRef<HTMLElement>);
readonly timeZone = input.required<string>();
readonly today = input.required<string>();
readonly maxEvidenceBytes = input.required<number>();
readonly submitting = input.required<boolean>();
readonly operationError = input('');
readonly fieldErrors = input<FieldValidationError[]>([]);
readonly noticeRequested = output<RecordNoticeRequest>();
readonly fieldChanged = output<string>();
readonly model = signal({
receivedOn: '',
requestedDepartureOn: '',
@@ -26,10 +59,13 @@ export class LeaseEndNoticeForm {
missingEvidenceConfirmed: false,
});
readonly noticeForm = form(this.model, (fields) => {
required(fields.receivedOn);
required(fields.requestedDepartureOn);
required(fields.deliveryMode);
maxLength(fields.otherDeliveryMode, 200);
});
readonly evidence = signal<File | null>(null);
readonly validationError = signal('');
readonly submitted = signal(false);
readonly lateRegistration = computed(
() => this.model().receivedOn !== '' && this.model().receivedOn < this.today(),
);
@@ -40,6 +76,12 @@ export class LeaseEndNoticeForm {
{ value: 'OTHER', label: 'Autre mode' },
] as const satisfies readonly { value: NoticeDeliveryMode; label: string }[];
constructor() {
effect(() => {
if (this.fieldErrors().length) queueMicrotask(() => this.focusFirstInvalid());
});
}
selectEvidence(event: Event): void {
const input = event.target;
const evidence = input instanceof HTMLInputElement ? (input.files?.item(0) ?? null) : null;
@@ -50,47 +92,127 @@ export class LeaseEndNoticeForm {
if (input instanceof HTMLInputElement) input.value = '';
}
receivedOnError(): string {
if (!this.show(this.noticeForm.receivedOn())) return '';
if (!this.model().receivedOn) return 'Renseignez la date de réception du congé.';
return this.model().receivedOn > this.today()
? 'La date de réception ne peut pas être future.'
: '';
}
requestedDepartureOnError(): string {
if (!this.show(this.noticeForm.requestedDepartureOn())) return '';
if (!this.model().requestedDepartureOn) return 'Renseignez la date de départ demandée.';
return this.model().receivedOn &&
this.model().requestedDepartureOn < this.model().receivedOn
? 'La date de départ demandée ne peut pas précéder la réception du congé.'
: '';
}
deliveryModeError(): string {
return this.show(this.noticeForm.deliveryMode()) && !this.model().deliveryMode
? 'Choisissez le mode de remise du congé.'
: '';
}
otherDeliveryModeError(): string {
if (
this.model().deliveryMode !== 'OTHER' ||
!this.show(this.noticeForm.otherDeliveryMode())
) {
return '';
}
return !this.model().otherDeliveryMode.trim()
? 'Décrivez le mode de remise utilisé.'
: this.model().otherDeliveryMode.trim().length > 200
? 'Le mode de remise ne peut pas dépasser 200 caractères.'
: '';
}
lateRegistrationError(): string {
return this.lateRegistration() &&
this.show(this.noticeForm.lateRegistrationConfirmed()) &&
!this.model().lateRegistrationConfirmed
? 'Confirmez cet enregistrement tardif avant de continuer.'
: '';
}
nonStandardDeliveryError(): string {
return this.model().deliveryMode === 'OTHER' &&
this.show(this.noticeForm.nonStandardDeliveryConfirmed()) &&
!this.model().nonStandardDeliveryConfirmed
? 'Confirmez le mode de remise non standard avant de continuer.'
: '';
}
evidenceError(): string {
const evidence = this.evidence();
if (evidence?.size === 0) return 'La preuve sélectionnée est vide.';
if (evidence && evidence.size > this.maxEvidenceBytes()) {
return 'La preuve dépasse la taille maximale autorisée.';
}
return !evidence && this.submitted() && !this.model().missingEvidenceConfirmed
? 'Confirmez l’enregistrement de ce congé sans justificatif avant de continuer.'
: '';
}
serverError(field: string): string {
const code = this.fieldErrors().find((error) => error.field === field)?.code;
return serverMessages[field]?.[code ?? ''] ?? '';
}
fieldWasChanged(field: string): void {
this.fieldChanged.emit(field);
}
submit(): void {
if (this.submitting()) return;
this.submitted.set(true);
this.noticeForm().markAsTouched();
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é.');
if (
this.receivedOnError() ||
this.requestedDepartureOnError() ||
this.deliveryModeError() ||
this.otherDeliveryModeError() ||
this.lateRegistrationError() ||
this.nonStandardDeliveryError() ||
this.evidenceError()
) {
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 deliveryMode = model.deliveryMode;
if (!deliveryMode) return;
const evidence = this.evidence();
this.noticeRequested.emit({
timeZone: this.timeZone(),
receivedOn: model.receivedOn,
requestedDepartureOn: model.requestedDepartureOn,
deliveryMode: model.deliveryMode,
...(model.deliveryMode === 'OTHER'
deliveryMode,
...(deliveryMode === 'OTHER'
? { otherDeliveryMode: model.otherDeliveryMode.trim() }
: {}),
lateRegistrationConfirmed: this.lateRegistration(),
nonStandardDeliveryConfirmed: model.deliveryMode === 'OTHER',
nonStandardDeliveryConfirmed: deliveryMode === 'OTHER',
missingEvidenceConfirmed: evidence === null,
...(evidence ? { evidence } : {}),
});
}
private show(field: { touched(): boolean }): boolean {
return this.submitted() || field.touched();
}
private focusFirstInvalid(): void {
const field = this.fieldErrors()[0]?.field;
const selector =
field === 'receivedOn'
? '#notice-received-on'
: field === 'requestedDepartureOn'
? '#notice-requested-departure-on'
: field === 'otherDeliveryMode'
? '#notice-other-delivery-mode'
: '#notice-delivery-mode';
(this.element.nativeElement as HTMLElement).querySelector<HTMLElement>(selector)?.focus();
}
}
@@ -116,6 +116,8 @@
[leaseId]="leaseId"
[notice]="notice"
[timeZone]="timeZone"
[today]="today"
[maxEvidenceBytes]="endState.maxEvidenceBytes"
[lifecycleMutationsEnabled]="lifecycleMutationsEnabled()"
[evidenceUploadEnabled]="true"
[withdrawalPending]="operationPending('withdrawal:' + notice.id)"
@@ -124,9 +126,17 @@
[withdrawalOperationError]="operationError('withdrawal:' + notice.id)"
[invalidationOperationError]="operationError('invalidation:' + notice.id)"
[evidenceOperationError]="operationError('evidence:' + notice.id)"
[withdrawalFieldErrors]="operationFieldError('withdrawal:' + notice.id)"
[invalidationFieldErrors]="operationFieldError('invalidation:' + notice.id)"
(withdrawalRequested)="requestWithdrawal($event)"
(invalidationRequested)="requestInvalidation($event)"
(evidenceRequested)="appendEvidence($event)"
(withdrawalFieldChanged)="
clearOperationFieldError('withdrawal:' + notice.id, $event)
"
(invalidationFieldChanged)="
clearOperationFieldError('invalidation:' + notice.id, $event)
"
/>
}
</section>
@@ -136,14 +146,19 @@
<app-lease-end-notice-form
[timeZone]="timeZone"
[today]="today"
[maxEvidenceBytes]="endState.maxEvidenceBytes"
[submitting]="operationPending('notice')"
[operationError]="operationError('notice')"
[fieldErrors]="operationFieldError('notice')"
(noticeRequested)="recordNotice($event)"
(fieldChanged)="clearOperationFieldError('notice', $event)"
/>
}
@if (canRecordAgreement() || canClose()) {
<app-lease-end-resolution-forms
[timeZone]="timeZone"
[leaseStartOn]="endState.leaseStartOn"
[currentEndOn]="endState.effectiveEndOn"
[showAgreement]="canRecordAgreement()"
[showClosure]="canClose()"
[keysReturnedOn]="exitReportDate()"
@@ -152,8 +167,12 @@
[closurePending]="operationPending('closure')"
[agreementOperationError]="operationError('agreement')"
[closureOperationError]="operationError('closure')"
[agreementFieldErrors]="operationFieldError('agreement')"
[closureFieldErrors]="operationFieldError('closure')"
(agreedEndRequested)="recordAgreement($event)"
(closureRequested)="closeLease($event)"
(agreementFieldChanged)="clearOperationFieldError('agreement', $event)"
(closureFieldChanged)="clearOperationFieldError('closure', $event)"
/>
}
}
@@ -17,8 +17,10 @@ describe('LeaseEndPage', () => {
unsupportedReason: null,
phase: 'NOTICE_PERIOD',
leaseType: 'STUDENT',
leaseStartOn: '2026-01-01',
fixedEndOn: '2026-07-31',
effectiveEndOn: '2026-07-31',
maxEvidenceBytes: 1024,
correctionRequired: false,
agreedEarlyEnds: [],
agreedEarlyEnd: null,
@@ -230,6 +232,36 @@ describe('LeaseEndPage', () => {
expect(fixture.componentInstance.state()?.phase).toBe('ACTIVE');
});
it('clears only the edited structured field error and its generic message', () => {
getState.mockReturnValueOnce(of({ ...state, phase: 'ACTIVE', notices: [] }));
recordNotice.mockReturnValue(
throwError(() => ({
status: 400,
error: {
code: 'validation_failed',
errors: [{ field: 'receivedOn', code: 'not_future' }],
},
})),
);
const fixture = TestBed.createComponent(LeaseEndPage);
fixture.detectChanges();
fixture.componentInstance.recordNotice({
timeZone: 'Europe/Paris',
receivedOn: '2026-07-20',
requestedDepartureOn: '2026-08-20',
deliveryMode: 'HAND_DELIVERY',
lateRegistrationConfirmed: false,
nonStandardDeliveryConfirmed: false,
missingEvidenceConfirmed: true,
});
expect(fixture.componentInstance.operationFieldError('notice')).toHaveLength(1);
fixture.componentInstance.clearOperationFieldError('notice', 'receivedOn');
expect(fixture.componentInstance.operationFieldError('notice')).toEqual([]);
expect(fixture.componentInstance.operationError('notice')).toBe('');
});
it('confirms a reasoned withdrawal before calling its dedicated endpoint', () => {
acceptWithdrawal.mockReturnValue(of({ ...state, phase: 'ACTIVE' }));
const fixture = TestBed.createComponent(LeaseEndPage);
@@ -10,7 +10,7 @@ import {
formatFrenchCivilMonth,
todayInTimeZone,
} from '../../services/civil-date';
import { httpErrorMessage } from '../../services/http-error-message';
import { FieldValidationError, saveFailure } from '../../services/http-error-message';
import { withoutRecordKey } from '../../services/immutable-state';
import {
AgreedEarlyEndRequest,
@@ -58,6 +58,7 @@ export class LeaseEndPage {
readonly pendingOperations = signal<ReadonlySet<string>>(new Set());
readonly confirmingOperations = signal<ReadonlySet<string>>(new Set());
readonly operationErrors = signal<Record<string, string>>({});
readonly operationFieldErrors = signal<Record<string, FieldValidationError[]>>({});
readonly lifecycleMutationsEnabled = computed(() => {
const state = this.state();
return !!state?.supported && state.phase !== 'ARCHIVED' && !state.closure;
@@ -110,7 +111,7 @@ export class LeaseEndPage {
next: (state) => this.state.set(state),
error: (error: unknown) =>
this.loadError.set(
httpErrorMessage(error, 'Impossible de charger la fin de ce bail.'),
saveFailure(error, 'Impossible de charger la fin de ce bail.').message,
),
});
}
@@ -201,6 +202,22 @@ export class LeaseEndPage {
return this.operationErrors()[key] ?? '';
}
operationFieldError(key: string): FieldValidationError[] {
return this.operationFieldErrors()[key] ?? [];
}
clearOperationFieldError(key: string, field: string): void {
const errors = this.operationFieldError(key);
if (!errors.some((error) => error.field === field)) return;
const remaining = errors.filter((error) => error.field !== field);
this.operationFieldErrors.update((all) =>
remaining.length ? { ...all, [key]: remaining } : withoutRecordKey(all, key),
);
if (!remaining.length) {
this.operationErrors.update((all) => withoutRecordKey(all, key));
}
}
private confirm(key: string, data: ConfirmationDialogData, action: () => void): void {
if (this.operationPending(key)) return;
this.confirmingOperations.update((keys) => new Set(keys).add(key));
@@ -223,6 +240,7 @@ export class LeaseEndPage {
if (this.pendingOperations().has(key)) return;
this.pendingOperations.update((keys) => new Set(keys).add(key));
this.operationErrors.update((errors) => withoutRecordKey(errors, key));
this.operationFieldErrors.update((errors) => withoutRecordKey(errors, key));
request
.pipe(
takeUntilDestroyed(this.destroyRef),
@@ -230,11 +248,17 @@ export class LeaseEndPage {
)
.subscribe({
next: (state) => this.state.set(state),
error: (error: unknown) =>
error: (error: unknown) => {
const failure = saveFailure(error, fallback);
this.operationErrors.update((errors) => ({
...errors,
[key]: httpErrorMessage(error, fallback),
})),
[key]: failure.message,
}));
this.operationFieldErrors.update((errors) => ({
...errors,
[key]: failure.fieldErrors,
}));
},
});
}
}
@@ -10,15 +10,41 @@
<fieldset [disabled]="agreementPending()">
<label>
<span>Date de fin convenue</span>
<input type="date" [formField]="agreementForm.endOn" />
<input
id="agreement-end-on"
type="date"
[formField]="agreementForm.endOn"
(input)="agreementFieldChanged.emit('endOn')"
/>
</label>
@if (agreementDateError()) {
<p class="operation-error" role="alert">{{ agreementDateError() }}</p>
}
@if (serverError(agreementFieldErrors(), 'endOn')) {
<p class="operation-error" role="alert">
{{ serverError(agreementFieldErrors(), 'endOn') }}
</p>
}
<label>
<span>Raison de l’accord</span>
<textarea rows="3" [formField]="agreementForm.reason"></textarea>
<textarea
id="agreement-reason"
rows="3"
[formField]="agreementForm.reason"
(input)="agreementFieldChanged.emit('reason')"
></textarea>
</label>
@if (agreementError() || agreementOperationError()) {
@if (agreementReasonError()) {
<p class="operation-error" role="alert">{{ agreementReasonError() }}</p>
}
@if (serverError(agreementFieldErrors(), 'reason')) {
<p class="operation-error" role="alert">
{{ agreementError() || agreementOperationError() }}
{{ serverError(agreementFieldErrors(), 'reason') }}
</p>
}
@if (agreementOperationError()) {
<p class="operation-error" role="alert">
{{ agreementOperationError() }}
</p>
}
<button mat-stroked-button type="submit" [disabled]="agreementPending()">
@@ -47,12 +73,22 @@
}}</strong>
</p>
<label class="confirmation-field">
<input type="checkbox" [formField]="closureForm.confirmed" />
<input
id="closure-confirmed"
type="checkbox"
[formField]="closureForm.confirmed"
(change)="closureFieldChanged.emit('confirmed')"
/>
<span
>Je confirme que les clés ont réellement été restituées à cette
date.</span
>
</label>
@if (serverError(closureFieldErrors(), 'confirmed')) {
<p class="operation-error" role="alert">
{{ serverError(closureFieldErrors(), 'confirmed') }}
</p>
}
@if (closureError() || closureOperationError()) {
<p class="operation-error" role="alert">
{{ closureError() || closureOperationError() }}
@@ -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();
@@ -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<HTMLElement>);
readonly timeZone = input.required<string>();
readonly leaseStartOn = input.required<string>();
readonly currentEndOn = input<string | null>(null);
readonly agreementPending = input(false);
readonly closurePending = input(false);
readonly agreementOperationError = input('');
readonly closureOperationError = input('');
readonly agreementFieldErrors = input<FieldValidationError[]>([]);
readonly closureFieldErrors = input<FieldValidationError[]>([]);
readonly showAgreement = input(true);
readonly showClosure = input(true);
readonly keysReturnedOn = input<string | null>(null);
readonly closureEnabled = input(false);
readonly agreedEndRequested = output<AgreedEarlyEndRequest>();
readonly closureRequested = output<CloseLeaseRequest>();
readonly agreementFieldChanged = output<string>();
readonly closureFieldChanged = output<string>();
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<HTMLElement>(
agreementField === 'reason' ? '#agreement-reason' : '#agreement-end-on',
)
?.focus(),
);
}
const closureField = this.closureFieldErrors()[0]?.field;
if (closureField) {
queueMicrotask(() =>
(this.element.nativeElement as HTMLElement)
.querySelector<HTMLElement>('#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();
}
}
@@ -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;