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
+847 -130

No files matched your search

@@ -19,14 +19,22 @@ import java.util.List;
final class LeaseEndingApi { final class LeaseEndingApi {
private LeaseEndingApi() {} private LeaseEndingApi() {}
record OperationError(String message) {}
record ValidationError(String code, List<FieldError> errors) {}
record FieldError(String field, String code) {}
record StateResponse( record StateResponse(
String leaseId, String leaseId,
boolean supported, boolean supported,
String unsupportedReason, String unsupportedReason,
LeaseEndPhase phase, LeaseEndPhase phase,
LeaseType leaseType, LeaseType leaseType,
LocalDate leaseStartOn,
LocalDate fixedEndOn, LocalDate fixedEndOn,
LocalDate effectiveEndOn, LocalDate effectiveEndOn,
long maxEvidenceBytes,
boolean correctionRequired, boolean correctionRequired,
List<NoticeResponse> notices, List<NoticeResponse> notices,
AgreedEarlyEndResponse agreedEarlyEnd, AgreedEarlyEndResponse agreedEarlyEnd,
@@ -41,8 +49,10 @@ record StateResponse(
state.unsupportedReason().orElse(null), state.unsupportedReason().orElse(null),
state.phase(), state.phase(),
state.leaseType(), state.leaseType(),
state.leaseStartOn(),
state.fixedEndOn().orElse(null), state.fixedEndOn().orElse(null),
state.effectiveEndOn().orElse(null), state.effectiveEndOn().orElse(null),
state.maxEvidenceBytes(),
state.correctionRequired(), state.correctionRequired(),
history.notices().stream().map(notice -> NoticeResponse.from(notice, history)).toList(), history.notices().stream().map(notice -> NoticeResponse.from(notice, history)).toList(),
history.latestAgreement().map(AgreedEarlyEndResponse::from).orElse(null), 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.adapter.in.web.common.AuthenticatedLandlord;
import fr.nessar.lmnporganizer.rental.application.lease.LeaseEndingService; import fr.nessar.lmnporganizer.rental.application.lease.LeaseEndingService;
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentStorageFailure; 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.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.lease.NoticeDeliveryMode; import fr.nessar.lmnporganizer.rental.domain.lease.NoticeDeliveryMode;
import fr.nessar.lmnporganizer.rental.domain.lease.TenantNoticeId; import fr.nessar.lmnporganizer.rental.domain.lease.TenantNoticeId;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.io.IOException; import java.io.IOException;
import java.time.DateTimeException; import java.time.DateTimeException;
import java.time.LocalDate; import java.time.LocalDate;
@@ -18,8 +22,11 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.oidc.user.OidcUser; 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.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; 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.RequestParam;
import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController; 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.MultipartFile;
import org.springframework.web.multipart.support.MissingServletRequestPartException;
@RestController("rentalLeaseEndingController") @RestController("rentalLeaseEndingController")
@RequestMapping("/api/leases/{leaseId}/end") @RequestMapping("/api/leases/{leaseId}/end")
@@ -42,6 +51,34 @@ public LeaseEndingController(
this.authenticatedLandlord = authenticatedLandlord; 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 @GetMapping
LeaseEndingApi.StateResponse state( LeaseEndingApi.StateResponse state(
@AuthenticationPrincipal OidcUser principal, @AuthenticationPrincipal OidcUser principal,
@@ -205,7 +242,7 @@ private static ZoneId zone(String value) {
try { try {
return ZoneId.of(value); return ZoneId.of(value);
} catch (DateTimeException failure) { } 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( 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( 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( 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.ApartmentOccupancyRepository;
import fr.nessar.lmnporganizer.rental.domain.lease.Lease; import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseActionAudit; 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.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository; import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRuleViolation; import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRuleViolation;
@@ -92,7 +94,7 @@ public LeaseEndingState recordNotice(
EvidenceUpload evidence) { EvidenceUpload evidence) {
Objects.requireNonNull(request); Objects.requireNonNull(request);
if (evidence == null && !request.missingEvidenceConfirmed()) { 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()); TenantNoticeId noticeId = new TenantNoticeId(identifiers.next());
StoredEvidence stored = evidence == null ? null : store(evidence); StoredEvidence stored = evidence == null ? null : store(evidence);
@@ -191,7 +193,7 @@ public LeaseEndingState close(
.filter(PropertyConditionReport::finalized) .filter(PropertyConditionReport::finalized)
.orElseThrow( .orElseThrow(
() -> () ->
new LeaseRuleViolation( new LeaseEndingConflict(
"Un état des lieux de sortie finalisé est requis pour clôturer le bail.")); "Un état des lieux de sortie finalisé est requis pour clôturer le bail."));
lease.close( lease.close(
exitReport.occurredOn(), exitReport.occurredOn(),
@@ -295,8 +297,10 @@ private LeaseEndingState state(
"La fin de bail locataire n'est pas prise en charge pour une colocation."), "La fin de bail locataire n'est pas prise en charge pour une colocation."),
lease.endPhaseOn(today), lease.endPhaseOn(today),
lease.leaseType(), lease.leaseType(),
lease.period().startDate(),
lease.fixedEndOn(), lease.fixedEndOn(),
lease.contractEndOn(), lease.contractEndOn(),
maxEvidenceBytes,
dues.stream().anyMatch(RentDue::correctionRequired), dues.stream().anyMatch(RentDue::correctionRequired),
reports reports
.find(landlordId, lease.id(), PropertyConditionReportType.EXIT) .find(landlordId, lease.id(), PropertyConditionReportType.EXIT)
@@ -339,7 +343,7 @@ private StoredEvidence store(EvidenceUpload upload) {
byte[] content = byte[] content =
Objects.requireNonNull(upload.content(), "Evidence content is required.").clone(); Objects.requireNonNull(upload.content(), "Evidence content is required.").clone();
if (content.length == 0) { if (content.length == 0) {
throw new LeaseRuleViolation("Le justificatif est vide."); throw new LeaseEndingValidationFailure("evidence", "empty");
} }
if (content.length > maxEvidenceBytes) { if (content.length > maxEvidenceBytes) {
throw new AttachmentTooLarge(maxEvidenceBytes); throw new AttachmentTooLarge(maxEvidenceBytes);
@@ -14,8 +14,10 @@ public record LeaseEndingState(
Optional<String> unsupportedReason, Optional<String> unsupportedReason,
LeaseEndPhase phase, LeaseEndPhase phase,
LeaseType leaseType, LeaseType leaseType,
LocalDate leaseStartOn,
Optional<LocalDate> fixedEndOn, Optional<LocalDate> fixedEndOn,
Optional<LocalDate> effectiveEndOn, Optional<LocalDate> effectiveEndOn,
long maxEvidenceBytes,
boolean correctionRequired, boolean correctionRequired,
Optional<ExitConditionReport> exitConditionReport, Optional<ExitConditionReport> exitConditionReport,
LeaseEndingHistory history) { LeaseEndingHistory history) {
@@ -24,8 +26,12 @@ public LeaseEndingState {
unsupportedReason = unsupportedReason == null ? Optional.empty() : unsupportedReason; unsupportedReason = unsupportedReason == null ? Optional.empty() : unsupportedReason;
Objects.requireNonNull(phase); Objects.requireNonNull(phase);
Objects.requireNonNull(leaseType); Objects.requireNonNull(leaseType);
Objects.requireNonNull(leaseStartOn);
fixedEndOn = fixedEndOn == null ? Optional.empty() : fixedEndOn; fixedEndOn = fixedEndOn == null ? Optional.empty() : fixedEndOn;
effectiveEndOn = effectiveEndOn == null ? Optional.empty() : effectiveEndOn; effectiveEndOn = effectiveEndOn == null ? Optional.empty() : effectiveEndOn;
if (maxEvidenceBytes <= 0) {
throw new IllegalArgumentException("Maximum evidence size must be positive.");
}
exitConditionReport = exitConditionReport == null ? Optional.empty() : exitConditionReport; exitConditionReport = exitConditionReport == null ? Optional.empty() : exitConditionReport;
Objects.requireNonNull(history); Objects.requireNonNull(history);
} }
@@ -507,18 +507,17 @@ public TenantNotice recordTenantNotice(TenantNoticeCommand command, LeaseActionA
Objects.requireNonNull(command); Objects.requireNonNull(command);
Objects.requireNonNull(audit); Objects.requireNonNull(audit);
if (command.receivedOn().isAfter(command.today())) { 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()) { 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())) { if (command.requestedDepartureOn().isBefore(command.receivedOn())) {
throw new LeaseRuleViolation( throw new LeaseEndingValidationFailure("requestedDepartureOn", "after_or_equal_received_on");
"La date de départ demandée ne peut pas précéder la réception du congé.");
} }
if (command.deliveryMode() == NoticeDeliveryMode.OTHER if (command.deliveryMode() == NoticeDeliveryMode.OTHER
&& !command.nonStandardDeliveryConfirmed()) { && !command.nonStandardDeliveryConfirmed()) {
throw new LeaseRuleViolation("Le mode de remise non standard doit être confirmé."); throw new LeaseEndingValidationFailure("nonStandardDeliveryConfirmed", "required");
} }
TenantNotice notice = TenantNotice notice =
new TenantNotice( new TenantNotice(
@@ -547,7 +546,7 @@ public NoticeWithdrawal acceptNoticeWithdrawal(
endingHistory endingHistory
.notice(noticeId) .notice(noticeId)
.orElseThrow(() -> new LeaseRuleViolation("Le congé locataire est introuvable.")); .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); return endingHistory.withdraw(noticeId, acceptedOn, reason, audit);
} }
@@ -562,7 +561,7 @@ public NoticeInvalidation invalidateNotice(
endingHistory endingHistory
.notice(noticeId) .notice(noticeId)
.orElseThrow(() -> new LeaseRuleViolation("Le congé locataire est introuvable.")); .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); return endingHistory.invalidate(noticeId, invalidatedOn, reason, audit);
} }
@@ -571,11 +570,11 @@ public AgreedEarlyLeaseEnd recordAgreedEarlyEnd(
requireActiveEndingSupported(); requireActiveEndingSupported();
Objects.requireNonNull(endOn, "Agreed end date is required."); Objects.requireNonNull(endOn, "Agreed end date is required.");
if (endOn.isBefore(period.startDate())) { 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(); Optional<LocalDate> currentEnd = contractEndOn();
if (currentEnd.isPresent() && !endOn.isBefore(currentEnd.orElseThrow())) { 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); return endingHistory.agree(endOn, reason, audit);
} }
@@ -597,11 +596,11 @@ public LeaseClosure close(
LocalDate endOn = LocalDate endOn =
contractEndOn() contractEndOn()
.orElseThrow( .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(keysReturnedOn, "Keys return date is required.");
Objects.requireNonNull(today, "Current civil date is required."); Objects.requireNonNull(today, "Current civil date is required.");
if (today.isBefore(endOn)) { 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)) { if (keysReturnedOn.isBefore(period.startDate()) || keysReturnedOn.isAfter(today)) {
throw new LeaseRuleViolation( throw new LeaseRuleViolation(
@@ -682,25 +681,24 @@ public LeaseEndPhase endPhaseOn(LocalDate date) {
private void requireActiveEndingSupported() { private void requireActiveEndingSupported() {
if (status != LeaseStatus.ACTIVE) { 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(); requireEndingSupported();
} }
private void requireEndingSupported() { private void requireEndingSupported() {
if (!endingSupported()) { if (!endingSupported()) {
throw new LeaseRuleViolation( throw new LeaseEndingConflict(
"La fin de bail locataire n'est pas prise en charge pour une colocation."); "La fin de bail locataire n'est pas prise en charge pour une colocation.");
} }
} }
private static void validateAdministrativeFactDate( 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(factOn, "Administrative fact date is required.");
Objects.requireNonNull(today, "Current civil date is required."); Objects.requireNonNull(today, "Current civil date is required.");
if (factOn.isBefore(noticeReceivedOn) || factOn.isAfter(today)) { if (factOn.isBefore(noticeReceivedOn) || factOn.isAfter(today)) {
throw new LeaseRuleViolation( throw new LeaseEndingValidationFailure(field, "between_notice_received_on_and_today");
"La date administrative doit suivre le congé et ne peut pas être future.");
} }
} }
@@ -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à."); throw new LeaseRuleViolation("Ce congé existe déjà.");
} }
if (activeNotice().isPresent()) { 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); notices = appended(notices, notice);
return notice; return notice;
@@ -94,7 +94,7 @@ NoticeEvidenceVersion appendEvidence(
LeaseClosure close(LocalDate keysReturnedOn, LeaseActionAudit audit) { LeaseClosure close(LocalDate keysReturnedOn, LeaseActionAudit audit) {
if (closure != null) { 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); closure = new LeaseClosure(keysReturnedOn, audit);
return closure; return closure;
@@ -157,7 +157,7 @@ private TenantNotice requireActive(TenantNoticeId noticeId) {
find(noticeId) find(noticeId)
.orElseThrow(() -> new LeaseRuleViolation("Le congé locataire est introuvable.")); .orElseThrow(() -> new LeaseRuleViolation("Le congé locataire est introuvable."));
if (statusOf(noticeId) != TenantNoticeStatus.ACTIVE) { 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; return notice;
} }
@@ -215,11 +215,11 @@ private void requireKnownNotice(TenantNoticeId noticeId) {
static String requiredReason(String reason) { static String requiredReason(String reason) {
if (reason == null || reason.isBlank()) { if (reason == null || reason.isBlank()) {
throw new LeaseRuleViolation("La raison est obligatoire."); throw new LeaseEndingValidationFailure("reason", "required");
} }
String value = reason.trim(); String value = reason.trim();
if (value.length() > 500) { 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; 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(); ruleVersion = ruleVersion.trim();
otherDeliveryMode = optional(otherDeliveryMode); otherDeliveryMode = optional(otherDeliveryMode);
if (otherDeliveryMode != null && otherDeliveryMode.length() > 200) { if (otherDeliveryMode != null && otherDeliveryMode.length() > 200) {
throw new LeaseRuleViolation( throw new LeaseEndingValidationFailure("otherDeliveryMode", "max_length");
"Le libellé du mode de remise ne peut pas dépasser 200 caractères.");
} }
if (deliveryMode == NoticeDeliveryMode.OTHER && otherDeliveryMode == null) { 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) { 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.Lease;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseActionAudit; import fr.nessar.lmnporganizer.rental.domain.lease.LeaseActionAudit;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseEndPhase; 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.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSnapshot; import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSnapshot;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseType; import fr.nessar.lmnporganizer.rental.domain.lease.LeaseType;
@@ -102,6 +104,8 @@ void getRequiresAValidIanaTimeZoneAndExposesTheCanonicalAppendOnlyHistory() thro
.with(landlord())) .with(landlord()))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.phase").value("NOTICE_PERIOD")) .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].ruleVersion").value("2026-07"))
.andExpect(jsonPath("$.notices[0].recordedWithoutEvidenceConfirmed").value(true)) .andExpect(jsonPath("$.notices[0].recordedWithoutEvidenceConfirmed").value(true))
.andExpect(jsonPath("$.notices[0].authorId").value("subject-1")) .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")); 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 @Test
void noticeMultipartForwardsTimezoneEvidenceAttestationAndAuthenticatedAuthor() throws Exception { void noticeMultipartForwardsTimezoneEvidenceAttestationAndAuthenticatedAuthor() throws Exception {
when(endings.recordNotice(any(), any(), any(), any(), any())).thenReturn(state()); when(endings.recordNotice(any(), any(), any(), any(), any())).thenReturn(state());
@@ -310,8 +358,10 @@ private static LeaseEndingState state() {
Optional.empty(), Optional.empty(),
LeaseEndPhase.NOTICE_PERIOD, LeaseEndPhase.NOTICE_PERIOD,
LeaseType.STANDARD, LeaseType.STANDARD,
LocalDate.of(2026, 1, 1),
Optional.empty(), Optional.empty(),
Optional.of(LocalDate.of(2026, 8, 18)), Optional.of(LocalDate.of(2026, 8, 18)),
250L * 1024 * 1024,
false, false,
Optional.of(new LeaseEndingState.ExitConditionReport(LocalDate.of(2026, 8, 18), true)), Optional.of(new LeaseEndingState.ExitConditionReport(LocalDate.of(2026, 8, 18), true)),
lease.endingHistory()); 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.Lease;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseActionAudit; import fr.nessar.lmnporganizer.rental.domain.lease.LeaseActionAudit;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseEndPhase; 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.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository; import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRuleViolation; import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRuleViolation;
@@ -118,7 +120,7 @@ void noticeWithoutEvidenceRequiresTheExplicitAttestation() {
storage); storage);
assertThrows( assertThrows(
LeaseRuleViolation.class, LeaseEndingValidationFailure.class,
() -> () ->
service.recordNotice( service.recordNotice(
LANDLORD_ID, LANDLORD_ID,
@@ -189,7 +191,7 @@ void civilTodayIsComputedInTheLandlordSuppliedTimeZoneWithoutConvertingTheEntere
boundaryClock); boundaryClock);
assertThrows( assertThrows(
LeaseRuleViolation.class, LeaseEndingValidationFailure.class,
() -> () ->
lasVegasService.recordNotice( lasVegasService.recordNotice(
LANDLORD_ID, LANDLORD_ID,
@@ -459,7 +461,7 @@ void closingRejectsAnUnfinalizedExitReport() {
reports); reports);
assertThrows( assertThrows(
LeaseRuleViolation.class, LeaseEndingConflict.class,
() -> () ->
service.close( service.close(
LANDLORD_ID, LANDLORD_ID,
@@ -56,7 +56,7 @@ void noticeHonorsTheRequestedLaterDepartureAndValidatesCivilDateConfirmations()
Lease lease = activeLease(LeaseType.STANDARD, List.of(TENANT_ID)); Lease lease = activeLease(LeaseType.STANDARD, List.of(TENANT_ID));
assertThrows( assertThrows(
LeaseRuleViolation.class, LeaseEndingValidationFailure.class,
() -> () ->
lease.recordTenantNotice( lease.recordTenantNotice(
notice( notice(
@@ -69,7 +69,7 @@ void noticeHonorsTheRequestedLaterDepartureAndValidatesCivilDateConfirmations()
false), false),
AUDIT)); AUDIT));
assertThrows( assertThrows(
LeaseRuleViolation.class, LeaseEndingValidationFailure.class,
() -> () ->
lease.recordTenantNotice( lease.recordTenantNotice(
notice( notice(
@@ -82,7 +82,7 @@ void noticeHonorsTheRequestedLaterDepartureAndValidatesCivilDateConfirmations()
false), false),
AUDIT)); AUDIT));
assertThrows( assertThrows(
LeaseRuleViolation.class, LeaseEndingValidationFailure.class,
() -> () ->
lease.recordTenantNotice( lease.recordTenantNotice(
notice( notice(
@@ -95,7 +95,7 @@ void noticeHonorsTheRequestedLaterDepartureAndValidatesCivilDateConfirmations()
false), false),
AUDIT)); AUDIT));
assertThrows( assertThrows(
LeaseRuleViolation.class, LeaseEndingValidationFailure.class,
() -> () ->
lease.recordTenantNotice( lease.recordTenantNotice(
notice( notice(
@@ -108,7 +108,7 @@ void noticeHonorsTheRequestedLaterDepartureAndValidatesCivilDateConfirmations()
true), true),
AUDIT)); AUDIT));
assertThrows( assertThrows(
LeaseRuleViolation.class, LeaseEndingValidationFailure.class,
() -> () ->
lease.recordTenantNotice( lease.recordTenantNotice(
notice( notice(
@@ -161,7 +161,7 @@ void studentFixedEndCapsAnEarlierNoticeAndAllEndingCommandsRejectSharedLeases()
assertFalse(shared.endingSupported()); assertFalse(shared.endingSupported());
assertEquals(fixedEnd, shared.contractEndOn().orElseThrow()); assertEquals(fixedEnd, shared.contractEndOn().orElseThrow());
assertThrows( assertThrows(
LeaseRuleViolation.class, LeaseEndingConflict.class,
() -> () ->
shared.recordTenantNotice( shared.recordTenantNotice(
notice( notice(
@@ -174,7 +174,7 @@ void studentFixedEndCapsAnEarlierNoticeAndAllEndingCommandsRejectSharedLeases()
false), false),
AUDIT)); AUDIT));
assertThrows( assertThrows(
LeaseRuleViolation.class, LeaseEndingConflict.class,
() -> shared.recordAgreedEarlyEnd(TODAY.plusMonths(1), "Accord", AUDIT)); () -> shared.recordAgreedEarlyEnd(TODAY.plusMonths(1), "Accord", AUDIT));
} }
@@ -290,7 +290,7 @@ void withdrawalAndInvalidationAppendAuditFactsWithoutErasingNotices() {
assertEquals(1, lease.endingHistory().withdrawals().size()); assertEquals(1, lease.endingHistory().withdrawals().size());
assertEquals(1, lease.endingHistory().invalidations().size()); assertEquals(1, lease.endingHistory().invalidations().size());
assertThrows( assertThrows(
LeaseRuleViolation.class, LeaseEndingConflict.class,
() -> () ->
lease.acceptNoticeWithdrawal( lease.acceptNoticeWithdrawal(
new TenantNoticeId("invalidated"), new TenantNoticeId("invalidated"),
@@ -317,11 +317,14 @@ void agreedEarlyEndWorksWithoutNoticeAndCanShortenAnExistingNotice() {
false, false,
false), false),
AUDIT); 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( assertThrows(
LeaseRuleViolation.class, LeaseEndingValidationFailure.class,
() -> lease.recordAgreedEarlyEnd(LocalDate.of(2026, 9, 30), "Sans effet", AUDIT));
assertThrows(
LeaseRuleViolation.class,
() -> lease.recordAgreedEarlyEnd(LocalDate.of(2026, 10, 1), "Repousse la fin", AUDIT)); () -> lease.recordAgreedEarlyEnd(LocalDate.of(2026, 10, 1), "Repousse la fin", AUDIT));
lease.recordAgreedEarlyEnd(LocalDate.of(2026, 8, 31), "Accord postérieur au congé", 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); NoticeEvidenceVersion first = lease.appendNoticeEvidence(noticeId, firstFile, null, AUDIT);
assertEquals(1, first.version()); assertEquals(1, first.version());
assertThrows( assertThrows(
LeaseRuleViolation.class, LeaseEndingValidationFailure.class,
() -> lease.appendNoticeEvidence(noticeId, replacement, " ", AUDIT)); () -> lease.appendNoticeEvidence(noticeId, replacement, " ", AUDIT));
assertThrows( assertThrows(
LeaseRuleViolation.class, LeaseEndingValidationFailure.class,
() -> lease.appendNoticeEvidence(noticeId, replacement, "x".repeat(501), AUDIT)); () -> lease.appendNoticeEvidence(noticeId, replacement, "x".repeat(501), AUDIT));
NoticeEvidenceVersion second = NoticeEvidenceVersion second =
lease.appendNoticeEvidence(noticeId, replacement, "Document corrigé", AUDIT); lease.appendNoticeEvidence(noticeId, replacement, "Document corrigé", AUDIT);
@@ -159,17 +159,45 @@
> >
<fieldset [disabled]="withdrawalPending()"> <fieldset [disabled]="withdrawalPending()">
<legend>Accepter un retrait</legend> <legend>Accepter un retrait</legend>
<label <label>
><span>Date d’acceptation</span <span>Date d’acceptation</span>
><input type="date" [formField]="withdrawalForm.acceptedOn" <input
/></label> 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 <label
><span>Raison</span ><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> </label>
@if (withdrawalError() || withdrawalOperationError()) { @if (withdrawalReasonError()) {
<p class="operation-error" role="alert"> <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> </p>
} }
<button mat-stroked-button type="submit" [disabled]="withdrawalPending()"> <button mat-stroked-button type="submit" [disabled]="withdrawalPending()">
@@ -184,17 +212,47 @@
> >
<fieldset [disabled]="invalidationPending()"> <fieldset [disabled]="invalidationPending()">
<legend>Invalider administrativement</legend> <legend>Invalider administrativement</legend>
<label <label>
><span>Date d’invalidation</span <span>Date d’invalidation</span>
><input type="date" [formField]="invalidationForm.invalidatedOn" <input
/></label> 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 <label
><span>Raison</span ><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> </label>
@if (invalidationError() || invalidationOperationError()) { @if (invalidationReasonError()) {
<p class="operation-error" role="alert"> <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> </p>
} }
<button mat-stroked-button type="submit" [disabled]="invalidationPending()"> <button mat-stroked-button type="submit" [disabled]="invalidationPending()">
@@ -50,6 +50,8 @@ describe('LeaseEndNoticeCard', () => {
fixture.componentRef.setInput('leaseId', 'lease-1'); fixture.componentRef.setInput('leaseId', 'lease-1');
fixture.componentRef.setInput('notice', notice); fixture.componentRef.setInput('notice', notice);
fixture.componentRef.setInput('timeZone', 'Europe/Paris'); 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('lifecycleMutationsEnabled', true);
fixture.componentRef.setInput('evidenceUploadEnabled', true); fixture.componentRef.setInput('evidenceUploadEnabled', true);
fixture.componentRef.setInput('withdrawalPending', false); 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 { FormsModule } from '@angular/forms';
import { FormField, FormRoot, form, maxLength } from '@angular/forms/signals'; import { FormField, FormRoot, form, maxLength } from '@angular/forms/signals';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
@@ -11,6 +11,7 @@ import {
LeaseEndService, LeaseEndService,
NoticeDeliveryMode, NoticeDeliveryMode,
} from '../../services/lease-end.service'; } from '../../services/lease-end.service';
import { FieldValidationError } from '../../services/http-error-message';
export interface NoticeAction<T> { export interface NoticeAction<T> {
noticeId: string; noticeId: string;
@@ -24,10 +25,13 @@ export interface NoticeAction<T> {
styleUrl: './lease-end-notice-card.css', styleUrl: './lease-end-notice-card.css',
}) })
export class LeaseEndNoticeCard { export class LeaseEndNoticeCard {
private readonly element = inject(ElementRef<HTMLElement>);
readonly endService = inject(LeaseEndService); readonly endService = inject(LeaseEndService);
readonly leaseId = input.required<string>(); readonly leaseId = input.required<string>();
readonly notice = input.required<LeaseEndNotice>(); readonly notice = input.required<LeaseEndNotice>();
readonly timeZone = input.required<string>(); readonly timeZone = input.required<string>();
readonly today = input.required<string>();
readonly maxEvidenceBytes = input.required<number>();
readonly lifecycleMutationsEnabled = input.required<boolean>(); readonly lifecycleMutationsEnabled = input.required<boolean>();
readonly evidenceUploadEnabled = input.required<boolean>(); readonly evidenceUploadEnabled = input.required<boolean>();
readonly withdrawalPending = input(false); readonly withdrawalPending = input(false);
@@ -36,9 +40,13 @@ export class LeaseEndNoticeCard {
readonly withdrawalOperationError = input(''); readonly withdrawalOperationError = input('');
readonly invalidationOperationError = input(''); readonly invalidationOperationError = input('');
readonly evidenceOperationError = input(''); readonly evidenceOperationError = input('');
readonly withdrawalFieldErrors = input<FieldValidationError[]>([]);
readonly invalidationFieldErrors = input<FieldValidationError[]>([]);
readonly withdrawalRequested = output<NoticeAction<AcceptWithdrawalRequest>>(); readonly withdrawalRequested = output<NoticeAction<AcceptWithdrawalRequest>>();
readonly invalidationRequested = output<NoticeAction<InvalidateNoticeRequest>>(); readonly invalidationRequested = output<NoticeAction<InvalidateNoticeRequest>>();
readonly evidenceRequested = output<NoticeAction<AppendEvidenceRequest>>(); readonly evidenceRequested = output<NoticeAction<AppendEvidenceRequest>>();
readonly withdrawalFieldChanged = output<string>();
readonly invalidationFieldChanged = output<string>();
readonly withdrawalModel = signal({ acceptedOn: '', reason: '' }); readonly withdrawalModel = signal({ acceptedOn: '', reason: '' });
readonly invalidationModel = signal({ invalidatedOn: '', reason: '' }); readonly invalidationModel = signal({ invalidatedOn: '', reason: '' });
readonly withdrawalForm = form(this.withdrawalModel, (fields) => { readonly withdrawalForm = form(this.withdrawalModel, (fields) => {
@@ -49,12 +57,41 @@ export class LeaseEndNoticeCard {
}); });
readonly evidence = signal<File | null>(null); readonly evidence = signal<File | null>(null);
readonly evidenceReason = signal(''); readonly evidenceReason = signal('');
readonly withdrawalError = signal('');
readonly invalidationError = signal('');
readonly evidenceError = signal(''); readonly evidenceError = signal('');
readonly withdrawalSubmitted = signal(false);
readonly invalidationSubmitted = signal(false);
readonly civilDate = formatFrenchCivilDate; 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 { statusLabel(): string {
return { return {
ACTIVE: 'Congé en cours', ACTIVE: 'Congé en cours',
@@ -87,12 +124,12 @@ export class LeaseEndNoticeCard {
submitWithdrawal(): void { submitWithdrawal(): void {
if (this.withdrawalPending()) return; if (this.withdrawalPending()) return;
this.withdrawalSubmitted.set(true);
this.withdrawalForm().markAsTouched();
const model = this.withdrawalModel(); const model = this.withdrawalModel();
if (!model.acceptedOn || !model.reason.trim()) { if (this.withdrawalDateError() || this.withdrawalReasonError()) {
this.withdrawalError.set('Renseignez la date et la raison du retrait accepté.');
return; return;
} }
this.withdrawalError.set('');
this.withdrawalRequested.emit({ this.withdrawalRequested.emit({
noticeId: this.notice().id, noticeId: this.notice().id,
request: { request: {
@@ -105,12 +142,12 @@ export class LeaseEndNoticeCard {
submitInvalidation(): void { submitInvalidation(): void {
if (this.invalidationPending()) return; if (this.invalidationPending()) return;
this.invalidationSubmitted.set(true);
this.invalidationForm().markAsTouched();
const model = this.invalidationModel(); const model = this.invalidationModel();
if (!model.invalidatedOn || !model.reason.trim()) { if (this.invalidationDateError() || this.invalidationReasonError()) {
this.invalidationError.set('Renseignez la date et la raison de l’invalidation.');
return; return;
} }
this.invalidationError.set('');
this.invalidationRequested.emit({ this.invalidationRequested.emit({
noticeId: this.notice().id, noticeId: this.notice().id,
request: { request: {
@@ -129,6 +166,18 @@ export class LeaseEndNoticeCard {
this.evidenceError.set('Sélectionnez une preuve à conserver.'); this.evidenceError.set('Sélectionnez une preuve à conserver.');
return; 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) { if (this.notice().evidenceVersions.length > 0 && !reason) {
this.evidenceError.set('Indiquez la raison de cette nouvelle version de preuve.'); this.evidenceError.set('Indiquez la raison de cette nouvelle version de preuve.');
return; return;
@@ -139,4 +188,60 @@ export class LeaseEndNoticeCard {
request: { timeZone: this.timeZone(), evidence, ...(reason ? { reason } : {}) }, 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"> <section class="notice-form" aria-labelledby="notice-form-title">
<h2 id="notice-form-title">Enregistrer un congé</h2> <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 <form
[formRoot]="noticeForm" [formRoot]="noticeForm"
[attr.aria-busy]="submitting()" [attr.aria-busy]="submitting()"
@@ -10,39 +10,109 @@
<div class="field-grid"> <div class="field-grid">
<label> <label>
<span>Date de réception</span> <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>
<label> <label>
<span>Départ demandé</span> <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> </label>
</div> </div>
<label> <label>
<span>Mode de remise</span> <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> <option value="">Choisissez un mode</option>
@for (mode of deliveryModes; track mode.value) { @for (mode of deliveryModes; track mode.value) {
<option [value]="mode.value">{{ mode.label }}</option> <option [value]="mode.value">{{ mode.label }}</option>
} }
</select> </select>
@if (deliveryModeError()) {
<p class="operation-error" role="alert">{{ deliveryModeError() }}</p>
}
</label> </label>
@if (model().deliveryMode === 'OTHER') { @if (model().deliveryMode === 'OTHER') {
<label> <label>
<span>Mode de remise utilisé</span> <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>
<label class="confirmation-field"> <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> <span>Je confirme avoir vérifié ce mode de remise non standard.</span>
</label> </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()) { @if (lateRegistration()) {
<label class="confirmation-field"> <label class="confirmation-field">
<input type="checkbox" [formField]="noticeForm.lateRegistrationConfirmed" /> <input
type="checkbox"
[formField]="noticeForm.lateRegistrationConfirmed"
(change)="fieldWasChanged('lateRegistrationConfirmed')"
/>
<span <span
>Je confirme enregistrer aujourd’hui un congé reçu à une date passée.</span >Je confirme enregistrer aujourd’hui un congé reçu à une date passée.</span
> >
</label> </label>
@if (lateRegistrationError()) {
<p class="operation-error" role="alert">{{ lateRegistrationError() }}</p>
}
@if (serverError('lateRegistrationConfirmed')) {
<p class="operation-error" role="alert">
{{ serverError('lateRegistrationConfirmed') }}
</p>
}
} }
<label> <label>
<span>Preuve (facultative, tout format)</span> <span>Preuve (facultative, tout format)</span>
@@ -53,14 +123,23 @@
<div class="missing-evidence-warning"> <div class="missing-evidence-warning">
<p>Aucun justificatif ne sera associé à ce congé lors de son enregistrement.</p> <p>Aucun justificatif ne sera associé à ce congé lors de son enregistrement.</p>
<label class="confirmation-field"> <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> <span>Je confirme enregistrer ce congé sans justificatif.</span>
</label> </label>
@if (serverError('missingEvidenceConfirmed')) {
<p class="operation-error" role="alert">
{{ serverError('missingEvidenceConfirmed') }}
</p>
}
</div> </div>
} }
@if (validationError() || operationError()) { @if (evidenceError() || operationError()) {
<p class="operation-error" role="alert"> <p class="operation-error" role="alert">
{{ validationError() || operationError() }} {{ evidenceError() || operationError() }}
</p> </p>
} }
<button mat-flat-button type="submit" [disabled]="submitting()"> <button mat-flat-button type="submit" [disabled]="submitting()">
@@ -9,6 +9,7 @@ describe('LeaseEndNoticeForm', () => {
const fixture = TestBed.createComponent(LeaseEndNoticeForm); const fixture = TestBed.createComponent(LeaseEndNoticeForm);
fixture.componentRef.setInput('timeZone', 'Europe/Paris'); fixture.componentRef.setInput('timeZone', 'Europe/Paris');
fixture.componentRef.setInput('today', '2026-07-19'); fixture.componentRef.setInput('today', '2026-07-19');
fixture.componentRef.setInput('maxEvidenceBytes', 1024);
fixture.componentRef.setInput('submitting', false); fixture.componentRef.setInput('submitting', false);
fixture.detectChanges(); fixture.detectChanges();
fixture.componentInstance.model.set({ fixture.componentInstance.model.set({
@@ -25,22 +26,48 @@ describe('LeaseEndNoticeForm', () => {
expect(fixture.nativeElement.querySelector('input[type="text"]').maxLength).toBe(200); expect(fixture.nativeElement.querySelector('input[type="text"]').maxLength).toBe(200);
fixture.componentInstance.submit(); fixture.componentInstance.submit();
expect(fixture.componentInstance.validationError()).toContain('enregistrement tardif'); expect(fixture.componentInstance.lateRegistrationError()).toContain(
'enregistrement tardif',
);
fixture.componentInstance.model.update((model) => ({ fixture.componentInstance.model.update((model) => ({
...model, ...model,
lateRegistrationConfirmed: true, lateRegistrationConfirmed: true,
})); }));
fixture.componentInstance.submit(); fixture.componentInstance.submit();
expect(fixture.componentInstance.validationError()).toContain( expect(fixture.componentInstance.nonStandardDeliveryError()).toContain(
'mode de remise non standard', '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', () => { it('emits civil dates, timezone and arbitrary evidence without converting the day', () => {
const fixture = TestBed.createComponent(LeaseEndNoticeForm); const fixture = TestBed.createComponent(LeaseEndNoticeForm);
fixture.componentRef.setInput('timeZone', 'America/Los_Angeles'); fixture.componentRef.setInput('timeZone', 'America/Los_Angeles');
fixture.componentRef.setInput('today', '2026-07-19'); fixture.componentRef.setInput('today', '2026-07-19');
fixture.componentRef.setInput('maxEvidenceBytes', 1024);
fixture.componentRef.setInput('submitting', false); fixture.componentRef.setInput('submitting', false);
const requested = vi.fn(); const requested = vi.fn();
fixture.componentInstance.noticeRequested.subscribe(requested); fixture.componentInstance.noticeRequested.subscribe(requested);
@@ -86,6 +113,7 @@ describe('LeaseEndNoticeForm', () => {
const fixture = TestBed.createComponent(LeaseEndNoticeForm); const fixture = TestBed.createComponent(LeaseEndNoticeForm);
fixture.componentRef.setInput('timeZone', 'Europe/Paris'); fixture.componentRef.setInput('timeZone', 'Europe/Paris');
fixture.componentRef.setInput('today', '2026-07-19'); fixture.componentRef.setInput('today', '2026-07-19');
fixture.componentRef.setInput('maxEvidenceBytes', 1024);
fixture.componentRef.setInput('submitting', false); fixture.componentRef.setInput('submitting', false);
const requested = vi.fn(); const requested = vi.fn();
fixture.componentInstance.noticeRequested.subscribe(requested); fixture.componentInstance.noticeRequested.subscribe(requested);
@@ -101,7 +129,7 @@ describe('LeaseEndNoticeForm', () => {
}); });
fixture.componentInstance.submit(); fixture.componentInstance.submit();
expect(fixture.componentInstance.validationError()).toContain('sans justificatif'); expect(fixture.componentInstance.evidenceError()).toContain('sans justificatif');
fixture.componentInstance.model.update((model) => ({ fixture.componentInstance.model.update((model) => ({
...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 { 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 { MatButtonModule } from '@angular/material/button';
import { NoticeDeliveryMode, RecordNoticeRequest } from '../../services/lease-end.service'; 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({ @Component({
selector: 'app-lease-end-notice-form', selector: 'app-lease-end-notice-form',
@@ -11,11 +40,15 @@ import { NoticeDeliveryMode, RecordNoticeRequest } from '../../services/lease-en
styleUrl: './lease-end-notice-form.css', styleUrl: './lease-end-notice-form.css',
}) })
export class LeaseEndNoticeForm { export class LeaseEndNoticeForm {
private readonly element = inject(ElementRef<HTMLElement>);
readonly timeZone = input.required<string>(); readonly timeZone = input.required<string>();
readonly today = input.required<string>(); readonly today = input.required<string>();
readonly maxEvidenceBytes = input.required<number>();
readonly submitting = input.required<boolean>(); readonly submitting = input.required<boolean>();
readonly operationError = input(''); readonly operationError = input('');
readonly fieldErrors = input<FieldValidationError[]>([]);
readonly noticeRequested = output<RecordNoticeRequest>(); readonly noticeRequested = output<RecordNoticeRequest>();
readonly fieldChanged = output<string>();
readonly model = signal({ readonly model = signal({
receivedOn: '', receivedOn: '',
requestedDepartureOn: '', requestedDepartureOn: '',
@@ -26,10 +59,13 @@ export class LeaseEndNoticeForm {
missingEvidenceConfirmed: false, missingEvidenceConfirmed: false,
}); });
readonly noticeForm = form(this.model, (fields) => { readonly noticeForm = form(this.model, (fields) => {
required(fields.receivedOn);
required(fields.requestedDepartureOn);
required(fields.deliveryMode);
maxLength(fields.otherDeliveryMode, 200); maxLength(fields.otherDeliveryMode, 200);
}); });
readonly evidence = signal<File | null>(null); readonly evidence = signal<File | null>(null);
readonly validationError = signal(''); readonly submitted = signal(false);
readonly lateRegistration = computed( readonly lateRegistration = computed(
() => this.model().receivedOn !== '' && this.model().receivedOn < this.today(), () => this.model().receivedOn !== '' && this.model().receivedOn < this.today(),
); );
@@ -40,6 +76,12 @@ export class LeaseEndNoticeForm {
{ value: 'OTHER', label: 'Autre mode' }, { value: 'OTHER', label: 'Autre mode' },
] as const satisfies readonly { value: NoticeDeliveryMode; label: string }[]; ] as const satisfies readonly { value: NoticeDeliveryMode; label: string }[];
constructor() {
effect(() => {
if (this.fieldErrors().length) queueMicrotask(() => this.focusFirstInvalid());
});
}
selectEvidence(event: Event): void { selectEvidence(event: Event): void {
const input = event.target; const input = event.target;
const evidence = input instanceof HTMLInputElement ? (input.files?.item(0) ?? null) : null; const evidence = input instanceof HTMLInputElement ? (input.files?.item(0) ?? null) : null;
@@ -50,47 +92,127 @@ export class LeaseEndNoticeForm {
if (input instanceof HTMLInputElement) input.value = ''; 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 { submit(): void {
if (this.submitting()) return; if (this.submitting()) return;
this.submitted.set(true);
this.noticeForm().markAsTouched();
const model = this.model(); const model = this.model();
if (!model.receivedOn || !model.requestedDepartureOn || !model.deliveryMode) { if (
this.validationError.set('Renseignez les deux dates et le mode de remise du congé.'); this.receivedOnError() ||
this.requestedDepartureOnError() ||
this.deliveryModeError() ||
this.otherDeliveryModeError() ||
this.lateRegistrationError() ||
this.nonStandardDeliveryError() ||
this.evidenceError()
) {
return; return;
} }
if (this.lateRegistration() && !model.lateRegistrationConfirmed) { const deliveryMode = model.deliveryMode;
this.validationError.set('Confirmez cet enregistrement tardif avant de continuer.'); if (!deliveryMode) return;
return;
}
if (model.deliveryMode === 'OTHER' && !model.otherDeliveryMode.trim()) {
this.validationError.set('Décrivez le mode de remise utilisé.');
return;
}
if (model.deliveryMode === 'OTHER' && !model.nonStandardDeliveryConfirmed) {
this.validationError.set(
'Confirmez le mode de remise non standard avant de continuer.',
);
return;
}
if (!this.evidence() && !model.missingEvidenceConfirmed) {
this.validationError.set(
'Confirmez l’enregistrement de ce congé sans justificatif avant de continuer.',
);
return;
}
this.validationError.set('');
const evidence = this.evidence(); const evidence = this.evidence();
this.noticeRequested.emit({ this.noticeRequested.emit({
timeZone: this.timeZone(), timeZone: this.timeZone(),
receivedOn: model.receivedOn, receivedOn: model.receivedOn,
requestedDepartureOn: model.requestedDepartureOn, requestedDepartureOn: model.requestedDepartureOn,
deliveryMode: model.deliveryMode, deliveryMode,
...(model.deliveryMode === 'OTHER' ...(deliveryMode === 'OTHER'
? { otherDeliveryMode: model.otherDeliveryMode.trim() } ? { otherDeliveryMode: model.otherDeliveryMode.trim() }
: {}), : {}),
lateRegistrationConfirmed: this.lateRegistration(), lateRegistrationConfirmed: this.lateRegistration(),
nonStandardDeliveryConfirmed: model.deliveryMode === 'OTHER', nonStandardDeliveryConfirmed: deliveryMode === 'OTHER',
missingEvidenceConfirmed: evidence === null, missingEvidenceConfirmed: evidence === null,
...(evidence ? { evidence } : {}), ...(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" [leaseId]="leaseId"
[notice]="notice" [notice]="notice"
[timeZone]="timeZone" [timeZone]="timeZone"
[today]="today"
[maxEvidenceBytes]="endState.maxEvidenceBytes"
[lifecycleMutationsEnabled]="lifecycleMutationsEnabled()" [lifecycleMutationsEnabled]="lifecycleMutationsEnabled()"
[evidenceUploadEnabled]="true" [evidenceUploadEnabled]="true"
[withdrawalPending]="operationPending('withdrawal:' + notice.id)" [withdrawalPending]="operationPending('withdrawal:' + notice.id)"
@@ -124,9 +126,17 @@
[withdrawalOperationError]="operationError('withdrawal:' + notice.id)" [withdrawalOperationError]="operationError('withdrawal:' + notice.id)"
[invalidationOperationError]="operationError('invalidation:' + notice.id)" [invalidationOperationError]="operationError('invalidation:' + notice.id)"
[evidenceOperationError]="operationError('evidence:' + notice.id)" [evidenceOperationError]="operationError('evidence:' + notice.id)"
[withdrawalFieldErrors]="operationFieldError('withdrawal:' + notice.id)"
[invalidationFieldErrors]="operationFieldError('invalidation:' + notice.id)"
(withdrawalRequested)="requestWithdrawal($event)" (withdrawalRequested)="requestWithdrawal($event)"
(invalidationRequested)="requestInvalidation($event)" (invalidationRequested)="requestInvalidation($event)"
(evidenceRequested)="appendEvidence($event)" (evidenceRequested)="appendEvidence($event)"
(withdrawalFieldChanged)="
clearOperationFieldError('withdrawal:' + notice.id, $event)
"
(invalidationFieldChanged)="
clearOperationFieldError('invalidation:' + notice.id, $event)
"
/> />
} }
</section> </section>
@@ -136,14 +146,19 @@
<app-lease-end-notice-form <app-lease-end-notice-form
[timeZone]="timeZone" [timeZone]="timeZone"
[today]="today" [today]="today"
[maxEvidenceBytes]="endState.maxEvidenceBytes"
[submitting]="operationPending('notice')" [submitting]="operationPending('notice')"
[operationError]="operationError('notice')" [operationError]="operationError('notice')"
[fieldErrors]="operationFieldError('notice')"
(noticeRequested)="recordNotice($event)" (noticeRequested)="recordNotice($event)"
(fieldChanged)="clearOperationFieldError('notice', $event)"
/> />
} }
@if (canRecordAgreement() || canClose()) { @if (canRecordAgreement() || canClose()) {
<app-lease-end-resolution-forms <app-lease-end-resolution-forms
[timeZone]="timeZone" [timeZone]="timeZone"
[leaseStartOn]="endState.leaseStartOn"
[currentEndOn]="endState.effectiveEndOn"
[showAgreement]="canRecordAgreement()" [showAgreement]="canRecordAgreement()"
[showClosure]="canClose()" [showClosure]="canClose()"
[keysReturnedOn]="exitReportDate()" [keysReturnedOn]="exitReportDate()"
@@ -152,8 +167,12 @@
[closurePending]="operationPending('closure')" [closurePending]="operationPending('closure')"
[agreementOperationError]="operationError('agreement')" [agreementOperationError]="operationError('agreement')"
[closureOperationError]="operationError('closure')" [closureOperationError]="operationError('closure')"
[agreementFieldErrors]="operationFieldError('agreement')"
[closureFieldErrors]="operationFieldError('closure')"
(agreedEndRequested)="recordAgreement($event)" (agreedEndRequested)="recordAgreement($event)"
(closureRequested)="closeLease($event)" (closureRequested)="closeLease($event)"
(agreementFieldChanged)="clearOperationFieldError('agreement', $event)"
(closureFieldChanged)="clearOperationFieldError('closure', $event)"
/> />
} }
} }
@@ -17,8 +17,10 @@ describe('LeaseEndPage', () => {
unsupportedReason: null, unsupportedReason: null,
phase: 'NOTICE_PERIOD', phase: 'NOTICE_PERIOD',
leaseType: 'STUDENT', leaseType: 'STUDENT',
leaseStartOn: '2026-01-01',
fixedEndOn: '2026-07-31', fixedEndOn: '2026-07-31',
effectiveEndOn: '2026-07-31', effectiveEndOn: '2026-07-31',
maxEvidenceBytes: 1024,
correctionRequired: false, correctionRequired: false,
agreedEarlyEnds: [], agreedEarlyEnds: [],
agreedEarlyEnd: null, agreedEarlyEnd: null,
@@ -230,6 +232,36 @@ describe('LeaseEndPage', () => {
expect(fixture.componentInstance.state()?.phase).toBe('ACTIVE'); 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', () => { it('confirms a reasoned withdrawal before calling its dedicated endpoint', () => {
acceptWithdrawal.mockReturnValue(of({ ...state, phase: 'ACTIVE' })); acceptWithdrawal.mockReturnValue(of({ ...state, phase: 'ACTIVE' }));
const fixture = TestBed.createComponent(LeaseEndPage); const fixture = TestBed.createComponent(LeaseEndPage);
@@ -10,7 +10,7 @@ import {
formatFrenchCivilMonth, formatFrenchCivilMonth,
todayInTimeZone, todayInTimeZone,
} from '../../services/civil-date'; } 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 { withoutRecordKey } from '../../services/immutable-state';
import { import {
AgreedEarlyEndRequest, AgreedEarlyEndRequest,
@@ -58,6 +58,7 @@ export class LeaseEndPage {
readonly pendingOperations = signal<ReadonlySet<string>>(new Set()); readonly pendingOperations = signal<ReadonlySet<string>>(new Set());
readonly confirmingOperations = signal<ReadonlySet<string>>(new Set()); readonly confirmingOperations = signal<ReadonlySet<string>>(new Set());
readonly operationErrors = signal<Record<string, string>>({}); readonly operationErrors = signal<Record<string, string>>({});
readonly operationFieldErrors = signal<Record<string, FieldValidationError[]>>({});
readonly lifecycleMutationsEnabled = computed(() => { readonly lifecycleMutationsEnabled = computed(() => {
const state = this.state(); const state = this.state();
return !!state?.supported && state.phase !== 'ARCHIVED' && !state.closure; return !!state?.supported && state.phase !== 'ARCHIVED' && !state.closure;
@@ -110,7 +111,7 @@ export class LeaseEndPage {
next: (state) => this.state.set(state), next: (state) => this.state.set(state),
error: (error: unknown) => error: (error: unknown) =>
this.loadError.set( 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] ?? ''; 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 { private confirm(key: string, data: ConfirmationDialogData, action: () => void): void {
if (this.operationPending(key)) return; if (this.operationPending(key)) return;
this.confirmingOperations.update((keys) => new Set(keys).add(key)); this.confirmingOperations.update((keys) => new Set(keys).add(key));
@@ -223,6 +240,7 @@ export class LeaseEndPage {
if (this.pendingOperations().has(key)) return; if (this.pendingOperations().has(key)) return;
this.pendingOperations.update((keys) => new Set(keys).add(key)); this.pendingOperations.update((keys) => new Set(keys).add(key));
this.operationErrors.update((errors) => withoutRecordKey(errors, key)); this.operationErrors.update((errors) => withoutRecordKey(errors, key));
this.operationFieldErrors.update((errors) => withoutRecordKey(errors, key));
request request
.pipe( .pipe(
takeUntilDestroyed(this.destroyRef), takeUntilDestroyed(this.destroyRef),
@@ -230,11 +248,17 @@ export class LeaseEndPage {
) )
.subscribe({ .subscribe({
next: (state) => this.state.set(state), next: (state) => this.state.set(state),
error: (error: unknown) => error: (error: unknown) => {
const failure = saveFailure(error, fallback);
this.operationErrors.update((errors) => ({ this.operationErrors.update((errors) => ({
...errors, ...errors,
[key]: httpErrorMessage(error, fallback), [key]: failure.message,
})), }));
this.operationFieldErrors.update((errors) => ({
...errors,
[key]: failure.fieldErrors,
}));
},
}); });
} }
} }
@@ -10,15 +10,41 @@
<fieldset [disabled]="agreementPending()"> <fieldset [disabled]="agreementPending()">
<label> <label>
<span>Date de fin convenue</span> <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> </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> <label>
<span>Raison de l’accord</span> <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> </label>
@if (agreementError() || agreementOperationError()) { @if (agreementReasonError()) {
<p class="operation-error" role="alert">{{ agreementReasonError() }}</p>
}
@if (serverError(agreementFieldErrors(), 'reason')) {
<p class="operation-error" role="alert"> <p class="operation-error" role="alert">
{{ agreementError() || agreementOperationError() }} {{ serverError(agreementFieldErrors(), 'reason') }}
</p>
}
@if (agreementOperationError()) {
<p class="operation-error" role="alert">
{{ agreementOperationError() }}
</p> </p>
} }
<button mat-stroked-button type="submit" [disabled]="agreementPending()"> <button mat-stroked-button type="submit" [disabled]="agreementPending()">
@@ -47,12 +73,22 @@
}}</strong> }}</strong>
</p> </p>
<label class="confirmation-field"> <label class="confirmation-field">
<input type="checkbox" [formField]="closureForm.confirmed" /> <input
id="closure-confirmed"
type="checkbox"
[formField]="closureForm.confirmed"
(change)="closureFieldChanged.emit('confirmed')"
/>
<span <span
>Je confirme que les clés ont réellement été restituées à cette >Je confirme que les clés ont réellement été restituées à cette
date.</span date.</span
> >
</label> </label>
@if (serverError(closureFieldErrors(), 'confirmed')) {
<p class="operation-error" role="alert">
{{ serverError(closureFieldErrors(), 'confirmed') }}
</p>
}
@if (closureError() || closureOperationError()) { @if (closureError() || closureOperationError()) {
<p class="operation-error" role="alert"> <p class="operation-error" role="alert">
{{ closureError() || closureOperationError() }} {{ closureError() || closureOperationError() }}
@@ -6,6 +6,7 @@ describe('LeaseEndResolutionForms', () => {
function createFixture() { function createFixture() {
const fixture = TestBed.createComponent(LeaseEndResolutionForms); const fixture = TestBed.createComponent(LeaseEndResolutionForms);
fixture.componentRef.setInput('timeZone', 'America/Los_Angeles'); fixture.componentRef.setInput('timeZone', 'America/Los_Angeles');
fixture.componentRef.setInput('leaseStartOn', '2026-01-01');
fixture.componentRef.setInput('agreementPending', false); fixture.componentRef.setInput('agreementPending', false);
fixture.componentRef.setInput('closurePending', false); fixture.componentRef.setInput('closurePending', false);
fixture.detectChanges(); 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 { FormsModule } from '@angular/forms';
import { FormField, FormRoot, form, maxLength } from '@angular/forms/signals'; import { FormField, FormRoot, form, maxLength } from '@angular/forms/signals';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { AgreedEarlyEndRequest, CloseLeaseRequest } from '../../services/lease-end.service'; import { AgreedEarlyEndRequest, CloseLeaseRequest } from '../../services/lease-end.service';
import { FieldValidationError } from '../../services/http-error-message';
@Component({ @Component({
selector: 'app-lease-end-resolution-forms', selector: 'app-lease-end-resolution-forms',
@@ -11,34 +12,64 @@ import { AgreedEarlyEndRequest, CloseLeaseRequest } from '../../services/lease-e
styleUrl: './lease-end-resolution-forms.css', styleUrl: './lease-end-resolution-forms.css',
}) })
export class LeaseEndResolutionForms { export class LeaseEndResolutionForms {
private readonly element = inject(ElementRef<HTMLElement>);
readonly timeZone = input.required<string>(); readonly timeZone = input.required<string>();
readonly leaseStartOn = input.required<string>();
readonly currentEndOn = input<string | null>(null);
readonly agreementPending = input(false); readonly agreementPending = input(false);
readonly closurePending = input(false); readonly closurePending = input(false);
readonly agreementOperationError = input(''); readonly agreementOperationError = input('');
readonly closureOperationError = input(''); readonly closureOperationError = input('');
readonly agreementFieldErrors = input<FieldValidationError[]>([]);
readonly closureFieldErrors = input<FieldValidationError[]>([]);
readonly showAgreement = input(true); readonly showAgreement = input(true);
readonly showClosure = input(true); readonly showClosure = input(true);
readonly keysReturnedOn = input<string | null>(null); readonly keysReturnedOn = input<string | null>(null);
readonly closureEnabled = input(false); readonly closureEnabled = input(false);
readonly agreedEndRequested = output<AgreedEarlyEndRequest>(); readonly agreedEndRequested = output<AgreedEarlyEndRequest>();
readonly closureRequested = output<CloseLeaseRequest>(); readonly closureRequested = output<CloseLeaseRequest>();
readonly agreementFieldChanged = output<string>();
readonly closureFieldChanged = output<string>();
readonly agreementModel = signal({ endOn: '', reason: '' }); readonly agreementModel = signal({ endOn: '', reason: '' });
readonly closureModel = signal({ confirmed: false }); readonly closureModel = signal({ confirmed: false });
readonly agreementForm = form(this.agreementModel, (fields) => { readonly agreementForm = form(this.agreementModel, (fields) => {
maxLength(fields.reason, 500); maxLength(fields.reason, 500);
}); });
readonly closureForm = form(this.closureModel); readonly closureForm = form(this.closureModel);
readonly agreementError = signal('');
readonly closureError = 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 { submitAgreement(): void {
if (this.agreementPending()) return; if (this.agreementPending()) return;
this.agreementSubmitted.set(true);
this.agreementForm().markAsTouched();
const model = this.agreementModel(); const model = this.agreementModel();
if (!model.endOn || !model.reason.trim()) { if (this.agreementDateError() || this.agreementReasonError()) {
this.agreementError.set('Renseignez la date et la raison de la fin anticipée.');
return; return;
} }
this.agreementError.set('');
this.agreedEndRequested.emit({ this.agreedEndRequested.emit({
timeZone: this.timeZone(), timeZone: this.timeZone(),
endOn: model.endOn, 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 { submitClosure(): void {
if (this.closurePending()) return; if (this.closurePending()) return;
const model = this.closureModel(); const model = this.closureModel();
@@ -65,4 +130,8 @@ export class LeaseEndResolutionForms {
confirmed: true, confirmed: true,
}); });
} }
private show(field: { touched(): boolean }): boolean {
return this.agreementSubmitted() || field.touched();
}
} }
@@ -48,8 +48,10 @@ export interface LeaseEndState {
unsupportedReason: string | null; unsupportedReason: string | null;
phase: LeaseEndPhase; phase: LeaseEndPhase;
leaseType: 'STANDARD' | 'STUDENT'; leaseType: 'STANDARD' | 'STUDENT';
leaseStartOn: string;
fixedEndOn: string | null; fixedEndOn: string | null;
effectiveEndOn: string | null; effectiveEndOn: string | null;
maxEvidenceBytes: number;
correctionRequired: boolean; correctionRequired: boolean;
exitConditionReport: { exitConditionReport: {
occurredOn: string; occurredOn: string;