Align lease finalization validation flows (#53)

This commit is contained in:
nessar committed 2026-08-17 14:18:16 +02:00
1 parent 8b37c96671
commit d16239881a
17 files changed
+839 -163

No files matched your search

@@ -15,11 +15,47 @@ import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
final class LeaseFinalizationApi {
private LeaseFinalizationApi() {}
record ValidationErrorResponse(String code, List<FieldValidationError> errors) {}
record FieldValidationError(String field, String code) {}
record GuaranteeDeedRequest(
Integer requirementIndex, String guarantorId, String tenantId, Boolean attestation) {
List<FieldValidationError> validationErrors() {
List<FieldValidationError> errors = new ArrayList<>();
if (requirementIndex == null || requirementIndex < 0) {
errors.add(new FieldValidationError("guaranteeRequirements", "invalid"));
return errors;
}
String path = "guaranteeRequirements[" + requirementIndex + "].";
if (guarantorId == null || guarantorId.isBlank()) {
errors.add(new FieldValidationError(path + "guarantorId", "required"));
}
if (tenantId == null || tenantId.isBlank()) {
errors.add(new FieldValidationError(path + "tenantId", "required"));
}
if (!Boolean.TRUE.equals(attestation)) {
errors.add(new FieldValidationError(path + "attestation", "required"));
}
return errors;
}
String filePath() {
return "guaranteeRequirements[" + requirementIndex + "].file";
}
String attestationPath() {
return "guaranteeRequirements[" + requirementIndex + "].attestation";
}
}
record DeedResponse(
String guarantorId,
String tenantId,
@@ -178,6 +214,30 @@ record FinalizationRequest(
@NotNull Boolean entryInventoryDiscrepancies,
@NotNull List<@Valid InventoryCorrectionRequest> inventoryCorrections,
@NotNull Boolean allInventoryCorrectionsInitialed) {
List<FieldValidationError> validationErrors() {
List<FieldValidationError> errors = new ArrayList<>();
if (Boolean.FALSE.equals(allPartiesSigned)) {
errors.add(new FieldValidationError("allPartiesSigned", "required"));
}
if (Boolean.FALSE.equals(revisionMatches)) {
errors.add(new FieldValidationError("revisionMatches", "required"));
}
if (Boolean.FALSE.equals(entryInventoryDiscrepancies)
&& (inventoryCorrections != null && !inventoryCorrections.isEmpty()
|| Boolean.TRUE.equals(allInventoryCorrectionsInitialed))) {
errors.add(new FieldValidationError("entryInventoryDiscrepancies", "invalid"));
}
if (Boolean.TRUE.equals(entryInventoryDiscrepancies)
&& (inventoryCorrections == null || inventoryCorrections.isEmpty())) {
errors.add(new FieldValidationError("inventoryCorrections", "required"));
}
if (Boolean.TRUE.equals(entryInventoryDiscrepancies)
&& Boolean.FALSE.equals(allInventoryCorrectionsInitialed)) {
errors.add(new FieldValidationError("allInventoryCorrectionsInitialed", "required"));
}
return errors;
}
LeaseFinalizationService.FinalizationRequest toServiceRequest() {
return new LeaseFinalizationService.FinalizationRequest(
revisionNumber,
@@ -4,18 +4,23 @@ import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlor
import fr.nessar.lmnporganizer.rental.adapter.in.web.rent.RentDueApi;
import fr.nessar.lmnporganizer.rental.application.lease.LeaseFinalizationService;
import fr.nessar.lmnporganizer.rental.application.lease.LeaseFinalizationService.PdfUpload;
import fr.nessar.lmnporganizer.rental.application.lease.LeaseFinalizationValidationFailure;
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentStorageFailure;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseGuaranteeDeed;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
import jakarta.validation.Valid;
import java.io.IOException;
import java.time.Clock;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
@@ -45,11 +50,25 @@ public LeaseFinalizationController(
ResponseEntity<LeaseFinalizationApi.DeedResponse> uploadGuaranteeDeed(
@AuthenticationPrincipal OidcUser principal,
@PathVariable String leaseId,
@RequestParam("guarantorId") String guarantorId,
@RequestParam("tenantId") String tenantId,
@RequestParam("attestation") boolean attestation,
@RequestPart("file") MultipartFile file) {
var deed =
@RequestParam(value = "requirementIndex", required = false) Integer requirementIndex,
@RequestParam(value = "guarantorId", required = false) String guarantorId,
@RequestParam(value = "tenantId", required = false) String tenantId,
@RequestParam(value = "attestation", required = false) Boolean attestation,
@RequestPart(value = "file", required = false) MultipartFile file) {
LeaseFinalizationApi.GuaranteeDeedRequest request =
new LeaseFinalizationApi.GuaranteeDeedRequest(
requirementIndex, guarantorId, tenantId, attestation);
List<LeaseFinalizationApi.FieldValidationError> errors =
new ArrayList<>(request.validationErrors());
if (file == null || file.isEmpty()) {
errors.add(new LeaseFinalizationApi.FieldValidationError(request.filePath(), "required"));
}
if (!errors.isEmpty()) {
throw new FinalizationValidationFailure(errors);
}
LeaseGuaranteeDeed deed;
try {
deed =
finalizations.uploadGuaranteeDeed(
authenticatedLandlord.id(principal),
new LeaseId(leaseId),
@@ -58,6 +77,9 @@ ResponseEntity<LeaseFinalizationApi.DeedResponse> uploadGuaranteeDeed(
attestation,
upload(file),
principal.getSubject());
} catch (LeaseFinalizationValidationFailure failure) {
throw deedValidationFailure(request, failure);
}
return ResponseEntity.status(HttpStatus.CREATED)
.body(LeaseFinalizationApi.DeedResponse.from(deed));
}
@@ -73,16 +95,29 @@ LeaseFinalizationApi.StateResponse state(
ResponseEntity<LeaseFinalizationApi.FinalizationResponse> finalizeLease(
@AuthenticationPrincipal OidcUser principal,
@PathVariable String leaseId,
@RequestPart("signedLease") MultipartFile signedLease,
@RequestPart(value = "signedLease", required = false) MultipartFile signedLease,
@RequestPart("metadata") @Valid LeaseFinalizationApi.FinalizationRequest metadata) {
List<LeaseFinalizationApi.FieldValidationError> errors =
new ArrayList<>(metadata.validationErrors());
if (signedLease == null || signedLease.isEmpty()) {
errors.add(new LeaseFinalizationApi.FieldValidationError("signedLease", "required"));
}
if (!errors.isEmpty()) {
throw new FinalizationValidationFailure(errors);
}
LocalDate today = LocalDate.now(clock);
var result =
LeaseFinalizationService.Result result;
try {
result =
finalizations.finalizeLease(
authenticatedLandlord.id(principal),
new LeaseId(leaseId),
metadata.toServiceRequest(),
upload(signedLease),
principal.getSubject());
} catch (LeaseFinalizationValidationFailure failure) {
throw finalizationValidationFailure(failure);
}
var response =
new LeaseFinalizationApi.FinalizationResponse(
LeaseApi.Response.from(result.lease(), today),
@@ -91,6 +126,15 @@ ResponseEntity<LeaseFinalizationApi.FinalizationResponse> finalizeLease(
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@ExceptionHandler(FinalizationValidationFailure.class)
ResponseEntity<LeaseFinalizationApi.ValidationErrorResponse> invalid(
FinalizationValidationFailure failure) {
return ResponseEntity.badRequest()
.body(
new LeaseFinalizationApi.ValidationErrorResponse(
"validation_failed", failure.errors()));
}
private static PdfUpload upload(MultipartFile file) {
try {
return new PdfUpload(file.getOriginalFilename(), file.getContentType(), file.getBytes());
@@ -98,4 +142,42 @@ private static PdfUpload upload(MultipartFile file) {
throw new AttachmentStorageFailure("PDF could not be opened.", failure);
}
}
private static FinalizationValidationFailure deedValidationFailure(
LeaseFinalizationApi.GuaranteeDeedRequest request,
LeaseFinalizationValidationFailure failure) {
if (LeaseFinalizationValidationFailure.ATTESTATION.equals(failure.field())) {
return new FinalizationValidationFailure(
List.of(
new LeaseFinalizationApi.FieldValidationError(
request.attestationPath(), failure.code())));
}
if (LeaseFinalizationValidationFailure.FILE.equals(failure.field())) {
return new FinalizationValidationFailure(
List.of(new LeaseFinalizationApi.FieldValidationError(request.filePath(), failure.code())));
}
throw failure;
}
private static FinalizationValidationFailure finalizationValidationFailure(
LeaseFinalizationValidationFailure failure) {
String field =
LeaseFinalizationValidationFailure.FILE.equals(failure.field())
? "signedLease"
: failure.field();
return new FinalizationValidationFailure(
List.of(new LeaseFinalizationApi.FieldValidationError(field, failure.code())));
}
private static final class FinalizationValidationFailure extends RuntimeException {
private final List<LeaseFinalizationApi.FieldValidationError> errors;
private FinalizationValidationFailure(List<LeaseFinalizationApi.FieldValidationError> errors) {
this.errors = List.copyOf(errors);
}
private List<LeaseFinalizationApi.FieldValidationError> errors() {
return errors;
}
}
}
@@ -119,8 +119,8 @@ public LeaseGuaranteeDeed uploadGuaranteeDeed(
Lease lease = draft(landlordId, leaseId);
requireAuthor(authorId);
if (!attestation) {
throw new LeaseRuleViolation(
"L'attestation de conformité de l'acte de cautionnement est obligatoire.");
throw new LeaseFinalizationValidationFailure(
LeaseFinalizationValidationFailure.ATTESTATION, "required");
}
requirePair(lease, guarantorId, tenantId);
byte[] content = pdf(upload);
@@ -240,41 +240,25 @@ public Result finalizeLease(
if (finalizations.find(landlordId, leaseId).isPresent()) {
throw new LeaseFinalizationConflict("Ce bail est déjà finalisé.");
}
requireDraft(lease);
if (!request.allPartiesSigned() || !request.revisionMatches()) {
throw new LeaseRuleViolation(
"Les signatures et la correspondance de la révision doivent être attestées.");
requireDraftForFinalization(lease);
if (!request.allPartiesSigned()) {
throw new LeaseFinalizationValidationFailure("allPartiesSigned", "required");
}
if (!request.revisionMatches()) {
throw new LeaseFinalizationValidationFailure("revisionMatches", "required");
}
if (request.lastSignatureDate() == null) {
throw new LeaseRuleViolation("La date de dernière signature est obligatoire.");
throw new LeaseFinalizationValidationFailure("lastSignatureDate", "required");
}
requireInventoryCorrectionAttestation(request);
byte[] content = pdf(signedLease);
LeaseDocumentRevision revision =
revisions
.findCurrent(landlordId, leaseId)
.orElseThrow(
() ->
new LeaseRuleViolation(
"Une révision du bail doit être générée avant finalisation."));
if (request.revisionNumber() != revision.number()) {
throw new LeaseRuleViolation("La révision signée n'est plus la révision courante du bail.");
}
LeaseDocumentRevision revision = currentRevision(landlordId, leaseId);
requireCurrentRevision(landlordId, leaseId, request, revision);
validateInventoryCorrections(revision, request.inventoryCorrections());
String expectedTemplate =
FurnishedLeaseTemplate.effectiveOn(request.lastSignatureDate()).visibleId();
if (!expectedTemplate.equals(revision.templateVersion())) {
throw new LeaseRuleViolation(
"Le modèle signé ne correspond pas à la date de dernière signature.");
}
if (!documents.matchesCurrentSource(landlordId, leaseId, revision)) {
throw new LeaseRuleViolation("Le bail a changé depuis la génération de la révision signée.");
}
List<GuaranteeRequirement> requirements =
requirements(landlordId, lease, guaranteeDeeds.findAll(landlordId, leaseId));
if (requirements.stream().anyMatch(requirement -> requirement.deed() == null)) {
throw new LeaseRuleViolation(
"Tous les actes de cautionnement requis doivent être téléversés.");
throw new LeaseFinalizationValidationFailure("guaranteeRequirements", "required");
}
List<LeaseFinalization.LegalOverride> overrides =
overrides(landlordId, lease, revision, request.confirmedWarningCodes(), authorId);
@@ -298,7 +282,8 @@ public Result finalizeLease(
request.inventoryCorrections(),
request.allInventoryCorrectionsInitialed());
try {
return transactions.run(() -> finalizeInTransaction(landlordId, leaseId, revision, proposed));
return transactions.run(
() -> finalizeInTransaction(landlordId, leaseId, request, revision, proposed));
} catch (RuntimeException failure) {
discard(storageKey, failure);
throw failure;
@@ -308,14 +293,16 @@ public Result finalizeLease(
private Result finalizeInTransaction(
LandlordId landlordId,
LeaseId leaseId,
FinalizationRequest request,
LeaseDocumentRevision revision,
LeaseFinalization proposed) {
if (finalizations.find(landlordId, leaseId).isPresent()) {
throw new LeaseFinalizationConflict("Ce bail est déjà finalisé.");
}
if (!documents.matchesCurrentSource(landlordId, leaseId, revision)) {
throw new LeaseRuleViolation("Le bail a changé depuis la génération de la révision signée.");
}
requireDraftForFinalization(lease(landlordId, leaseId));
LeaseDocumentRevision current = currentRevision(landlordId, leaseId);
requireSameRevision(revision, current);
requireCurrentRevision(landlordId, leaseId, request, current);
freezeInventory(landlordId, leaseId);
Lease activated = activation.activateInTransaction(landlordId, leaseId);
LeaseFinalization saved = finalizations.save(proposed);
@@ -371,7 +358,8 @@ private static void requirePair(Lease lease, String guarantorId, TenantId tenant
boolean guarantorExists =
lease.guarantors().stream().anyMatch(guarantor -> guarantor.id().equals(guarantorId));
if (!guarantorExists || !lease.tenantIds().contains(tenantId)) {
throw new LeaseRuleViolation("Le couple garant-locataire n'appartient pas à ce bail.");
throw new LeaseFinalizationConflict(
"L’occupation ou les garanties du bail ont changé ; rechargez avant de continuer.");
}
}
@@ -385,15 +373,14 @@ private List<LeaseFinalization.LegalOverride> overrides(
List<LeaseDocumentWarning> warnings = warnings(landlordId, lease, revision);
if (confirmed.stream()
.anyMatch(code -> warnings.stream().noneMatch(warning -> warning.code().equals(code)))) {
throw new LeaseRuleViolation("Le code d'alerte confirmé est inconnu.");
throw new LeaseFinalizationValidationFailure("confirmedWarningCodes", "invalid");
}
Instant now = Instant.now(clock);
return warnings.stream()
.map(
warning -> {
if (!confirmed.contains(warning.code())) {
throw new LeaseRuleViolation(
"L'alerte " + warning.code() + " doit être confirmée.");
throw new LeaseFinalizationValidationFailure("confirmedWarningCodes", "required");
}
return new LeaseFinalization.LegalOverride(
warning.code(), warning.ruleVersion(), authorId, now);
@@ -452,28 +439,76 @@ private static void validateInventoryCorrections(
revision.furnitureInventory().lines().stream()
.map(FurnitureInventorySnapshot.Line::id)
.collect(java.util.stream.Collectors.toSet());
for (LeaseFinalization.InventoryCorrection correction : corrections) {
for (int index = 0; index < corrections.size(); index++) {
LeaseFinalization.InventoryCorrection correction = corrections.get(index);
if (!privateAreaIds.contains(correction.privateAreaId())) {
throw new LeaseRuleViolation(
"L'espace privatif corrigé n'appartient pas à la révision du bail.");
throw new LeaseFinalizationValidationFailure(
"inventoryCorrections[" + index + "].privateAreaId", "invalid");
}
if (correction.inventoryLineId() != null
&& !inventoryLineIds.contains(correction.inventoryLineId())) {
throw new LeaseRuleViolation(
"La ligne d'inventaire corrigée n'appartient pas à la révision du bail.");
throw new LeaseFinalizationValidationFailure(
"inventoryCorrections[" + index + "].inventoryLineId", "invalid");
}
}
}
private Lease draft(LandlordId landlordId, LeaseId leaseId) {
Lease lease = lease(landlordId, leaseId);
requireDraft(lease);
requireDraftForFinalization(lease);
return lease;
}
private static void requireDraft(Lease lease) {
private static void requireDraftForFinalization(Lease lease) {
if (lease.status() != LeaseStatus.DRAFT) {
throw new LeaseRuleViolation("Seul un bail en brouillon peut être finalisé.");
throw new LeaseFinalizationConflict(
"Ce bail n'est plus un brouillon ; rechargez-le avant de finaliser.");
}
}
private LeaseDocumentRevision currentRevision(LandlordId landlordId, LeaseId leaseId) {
return revisions
.findCurrent(landlordId, leaseId)
.orElseThrow(
() ->
new LeaseFinalizationConflict(
"La révision signée n'est plus disponible ; générez-la à nouveau."));
}
private void requireCurrentRevision(
LandlordId landlordId,
LeaseId leaseId,
FinalizationRequest request,
LeaseDocumentRevision revision) {
if (request.revisionNumber() != revision.number()) {
throw new LeaseFinalizationConflict(
"La révision signée n'est plus la révision courante du bail.");
}
String expectedTemplate =
FurnishedLeaseTemplate.effectiveOn(request.lastSignatureDate()).visibleId();
if (!expectedTemplate.equals(revision.templateVersion())) {
throw new LeaseFinalizationConflict(
"Le modèle signé ne correspond pas à la date de dernière signature.");
}
if (!documents.matchesCurrentSource(landlordId, leaseId, revision)) {
throw new LeaseFinalizationConflict(
"Le bail a changé depuis la génération de la révision signée.");
}
}
private static void requireSameRevision(
LeaseDocumentRevision signedRevision, LeaseDocumentRevision currentRevision) {
if (signedRevision.number() != currentRevision.number()) {
throw new LeaseFinalizationConflict(
"La révision signée n'est plus la révision courante du bail.");
}
if (!signedRevision.templateVersion().equals(currentRevision.templateVersion())) {
throw new LeaseFinalizationConflict(
"Le modèle signé ne correspond plus à la révision courante du bail.");
}
if (!signedRevision.sourceFingerprint().equals(currentRevision.sourceFingerprint())) {
throw new LeaseFinalizationConflict(
"Le bail a changé depuis la génération de la révision signée.");
}
}
@@ -483,11 +518,13 @@ private Lease lease(LandlordId landlordId, LeaseId leaseId) {
private byte[] pdf(PdfUpload upload) {
if (upload == null) {
throw new LeaseRuleViolation("Un fichier PDF est obligatoire.");
throw new LeaseFinalizationValidationFailure(
LeaseFinalizationValidationFailure.FILE, "required");
}
byte[] content = upload.content();
if (content.length == 0) {
throw new LeaseRuleViolation("Un fichier PDF est obligatoire.");
throw new LeaseFinalizationValidationFailure(
LeaseFinalizationValidationFailure.FILE, "required");
}
if (content.length > maximumPdfSize) {
throw new AttachmentTooLarge(maximumPdfSize);
@@ -495,7 +532,8 @@ private byte[] pdf(PdfUpload upload) {
if (!"application/pdf".equalsIgnoreCase(upload.contentType())
|| content.length < 5
|| !Arrays.equals(Arrays.copyOf(content, 5), "%PDF-".getBytes(StandardCharsets.US_ASCII))) {
throw new LeaseRuleViolation("Le fichier doit être un PDF lisible.");
throw new LeaseFinalizationValidationFailure(
LeaseFinalizationValidationFailure.FILE, "invalid");
}
return content;
}
@@ -519,13 +557,13 @@ private static void requireInventoryCorrectionAttestation(FinalizationRequest re
if (!request.entryInventoryDiscrepancies()
&& (!request.inventoryCorrections().isEmpty()
|| request.allInventoryCorrectionsInitialed())) {
throw new LeaseRuleViolation("Les corrections d’inventaire exigent une réponse positive.");
throw new LeaseFinalizationValidationFailure("entryInventoryDiscrepancies", "invalid");
}
if (request.entryInventoryDiscrepancies()
&& (request.inventoryCorrections().isEmpty()
|| !request.allInventoryCorrectionsInitialed())) {
throw new LeaseRuleViolation(
"Chaque correction manuscrite d’inventaire doit être déclarée et paraphée.");
if (request.entryInventoryDiscrepancies() && request.inventoryCorrections().isEmpty()) {
throw new LeaseFinalizationValidationFailure("inventoryCorrections", "required");
}
if (request.entryInventoryDiscrepancies() && !request.allInventoryCorrectionsInitialed()) {
throw new LeaseFinalizationValidationFailure("allInventoryCorrectionsInitialed", "required");
}
}
@@ -0,0 +1,22 @@
package fr.nessar.lmnporganizer.rental.application.lease;
public final class LeaseFinalizationValidationFailure extends RuntimeException {
public static final String FILE = "file";
public static final String ATTESTATION = "attestation";
private final String field;
private final String code;
public LeaseFinalizationValidationFailure(String field, String code) {
this.field = field;
this.code = code;
}
public String field() {
return field;
}
public String code() {
return code;
}
}
@@ -16,6 +16,7 @@ import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlor
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.RentalApiExceptionHandler;
import fr.nessar.lmnporganizer.rental.application.landlord.LandlordAccountService;
import fr.nessar.lmnporganizer.rental.application.lease.LeaseFinalizationService;
import fr.nessar.lmnporganizer.rental.application.lease.LeaseFinalizationValidationFailure;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
import fr.nessar.lmnporganizer.rental.domain.apartment.PrivateArea;
import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureEquipmentKind;
@@ -92,6 +93,7 @@ void guaranteeUploadUsesAuthenticatedScopeAndMultipartAttestation() throws Excep
.perform(
multipart("/api/leases/lease-1/guarantee-deeds")
.file(pdf("file"))
.param("requirementIndex", "0")
.param("guarantorId", "guarantor-1")
.param("tenantId", "tenant-1")
.param("attestation", "true")
@@ -198,6 +200,7 @@ void differentGuaranteeAndDoubleFinalizationConflictsMapTo409() throws Exception
.perform(
multipart("/api/leases/lease-1/guarantee-deeds")
.file(pdf("file"))
.param("requirementIndex", "0")
.param("guarantorId", "guarantor-1")
.param("tenantId", "tenant-1")
.param("attestation", "true")
@@ -214,6 +217,42 @@ void differentGuaranteeAndDoubleFinalizationConflictsMapTo409() throws Exception
.andExpect(status().isConflict());
}
@Test
void guaranteeAndFinalizationValidationUseVisibleStableFieldPaths() throws Exception {
mockMvc
.perform(
multipart("/api/leases/lease-1/guarantee-deeds")
.file(pdf("file"))
.param("requirementIndex", "0")
.param("guarantorId", "")
.param("tenantId", "")
.param("attestation", "false")
.with(landlord())
.with(csrf()))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("validation_failed"))
.andExpect(jsonPath("$.errors[0].field").value("guaranteeRequirements[0].guarantorId"))
.andExpect(jsonPath("$.errors[1].field").value("guaranteeRequirements[0].tenantId"))
.andExpect(jsonPath("$.errors[2].field").value("guaranteeRequirements[0].attestation"));
when(finalizations.finalizeLease(any(), any(), any(), any(), any()))
.thenThrow(
new LeaseFinalizationValidationFailure(
"inventoryCorrections[0].privateAreaId", "invalid"));
mockMvc
.perform(
multipart("/api/leases/lease-1/finalization")
.file(pdf("signedLease"))
.file(metadata())
.with(landlord())
.with(csrf()))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("validation_failed"))
.andExpect(
jsonPath("$.errors[0].field").value("inventoryCorrections[0].privateAreaId"))
.andExpect(jsonPath("$.errors[0].code").value("invalid"));
}
private static MockMultipartFile pdf(String partName) {
return new MockMultipartFile(
partName, "signed.pdf", MediaType.APPLICATION_PDF_VALUE, "%PDF-signed".getBytes());
@@ -46,7 +46,6 @@ import fr.nessar.lmnporganizer.rental.domain.lease.LeaseGuaranteeDeed;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseGuaranteeDeedRepository;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRuleViolation;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSnapshot;
import fr.nessar.lmnporganizer.rental.domain.lease.RentTerms;
import fr.nessar.lmnporganizer.rental.domain.lease.RentalPeriod;
@@ -210,7 +209,7 @@ void incompatibleTemplateAndStaleRevisionOrFactsAreBlockedBeforeStorage() {
LeaseFinalizationService service = service(clock("2026-10-02T12:00:00Z"));
assertThrows(
LeaseRuleViolation.class,
LeaseFinalizationConflict.class,
() ->
service.finalizeLease(
LANDLORD_ID,
@@ -219,7 +218,7 @@ void incompatibleTemplateAndStaleRevisionOrFactsAreBlockedBeforeStorage() {
upload(PDF),
"subject-1"));
assertThrows(
LeaseRuleViolation.class,
LeaseFinalizationConflict.class,
() ->
service.finalizeLease(
LANDLORD_ID,
@@ -231,7 +230,7 @@ void incompatibleTemplateAndStaleRevisionOrFactsAreBlockedBeforeStorage() {
LeaseDocumentRevision current = revision(2, FurnishedLeaseTemplate.OCTOBER_2026.visibleId());
when(documents.matchesCurrentSource(LANDLORD_ID, LEASE_ID, current)).thenReturn(false);
assertThrows(
LeaseRuleViolation.class,
LeaseFinalizationConflict.class,
() ->
service.finalizeLease(
LANDLORD_ID,
@@ -242,6 +241,45 @@ void incompatibleTemplateAndStaleRevisionOrFactsAreBlockedBeforeStorage() {
verify(storage, never()).store(any());
}
@Test
void finalizationRechecksRevisionAndOccupancyInsideTheTransaction() {
Lease draft = draft(List.of(TENANT_1), List.of(), new BigDecimal("900"));
LeaseDocumentRevision signedRevision = revision(1, FurnishedLeaseTemplate.CURRENT.visibleId());
LeaseDocumentRevision newerRevision = revision(2, FurnishedLeaseTemplate.CURRENT.visibleId());
when(leases.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.of(draft));
when(documents.matchesCurrentSource(LANDLORD_ID, LEASE_ID, signedRevision)).thenReturn(true);
when(storage.store(any())).thenReturn("signed-key");
when(revisions.findCurrent(LANDLORD_ID, LEASE_ID))
.thenReturn(Optional.of(signedRevision), Optional.of(newerRevision));
assertThrows(
LeaseFinalizationConflict.class,
() ->
service(clock("2026-07-19T12:00:00Z"))
.finalizeLease(
LANDLORD_ID,
LEASE_ID,
request(1, LocalDate.of(2026, 7, 19), Set.of()),
upload(PDF),
"subject-1"));
verify(storage).discard("signed-key");
when(leases.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.of(draft), Optional.of(active()));
when(revisions.findCurrent(LANDLORD_ID, LEASE_ID))
.thenReturn(Optional.of(signedRevision), Optional.of(signedRevision));
assertThrows(
LeaseFinalizationConflict.class,
() ->
service(clock("2026-07-19T12:00:00Z"))
.finalizeLease(
LANDLORD_ID,
LEASE_ID,
request(1, LocalDate.of(2026, 7, 19), Set.of()),
upload(PDF),
"subject-1"));
verify(storage, org.mockito.Mockito.times(2)).discard("signed-key");
}
@Test
void excessiveDepositRequiresAndRecordsVersionedOverride() {
Lease draft = draft(List.of(TENANT_1), List.of(), new BigDecimal("1900"));
@@ -252,7 +290,7 @@ void excessiveDepositRequiresAndRecordsVersionedOverride() {
LeaseFinalizationService service = service(clock("2026-07-19T12:00:00Z"));
assertThrows(
LeaseRuleViolation.class,
LeaseFinalizationValidationFailure.class,
() ->
service.finalizeLease(
LANDLORD_ID,
@@ -309,7 +347,7 @@ void finalizationRequiresAndAuditsWarningsFrozenOnTheRevisionWithoutReadingApart
List.of(LeaseFinalizationWarning.DPE_EXPIRED.name()),
service.state(LANDLORD_ID, LEASE_ID).warningCodes());
assertThrows(
LeaseRuleViolation.class,
LeaseFinalizationValidationFailure.class,
() ->
service.finalizeLease(
LANDLORD_ID,
@@ -417,7 +455,7 @@ void finalizationRequiresInitialedStructuredInventoryCorrectionsWhenDiscrepancie
"correction-1", null, "area-1", "Chaise", 0, "Absente à l'entrée");
assertThrows(
LeaseRuleViolation.class,
LeaseFinalizationValidationFailure.class,
() ->
service(clock("2026-07-19T12:00:00Z"))
.finalizeLease(
@@ -466,7 +504,7 @@ void
LeaseFinalizationService service = service(clock("2026-07-19T12:00:00Z"));
assertThrows(
LeaseRuleViolation.class,
LeaseFinalizationValidationFailure.class,
() ->
service.finalizeLease(
LANDLORD_ID,
@@ -477,7 +515,7 @@ void
upload(PDF),
"subject-1"));
assertThrows(
LeaseRuleViolation.class,
LeaseFinalizationValidationFailure.class,
() ->
service.finalizeLease(
LANDLORD_ID,