Align nested lease validation flows (#50)

This commit is contained in:
nessar committed 2026-08-17 14:18:16 +02:00
1 parent 88d3f3c2a5
commit 41590a6a78
14 files changed
+886 -59

No files matched your search

@@ -3,6 +3,7 @@ package fr.nessar.lmnporganizer.rental.adapter.in.web.lease;
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlord;
import fr.nessar.lmnporganizer.rental.application.lease.AmendLeaseService;
import fr.nessar.lmnporganizer.rental.application.lease.GenerateLeaseDocumentService;
import fr.nessar.lmnporganizer.rental.application.lease.LeaseClauseCatalog;
import fr.nessar.lmnporganizer.rental.application.lease.LeaseDraftService;
import fr.nessar.lmnporganizer.rental.application.shared.GeneratedDocument;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
@@ -45,18 +46,21 @@ private final AmendLeaseService amendments;
private final GenerateLeaseDocumentService documents;
private final AuthenticatedLandlord authenticatedLandlord;
private final Clock clock;
private final LeaseNestedRequestValidator nestedValidator;
public LeaseController(
LeaseDraftService drafts,
AmendLeaseService amendments,
GenerateLeaseDocumentService documents,
AuthenticatedLandlord authenticatedLandlord,
Clock clock) {
Clock clock,
LeaseClauseCatalog clauseCatalog) {
this.drafts = drafts;
this.amendments = amendments;
this.documents = documents;
this.authenticatedLandlord = authenticatedLandlord;
this.clock = clock;
this.nestedValidator = new LeaseNestedRequestValidator(clauseCatalog);
}
@GetMapping
@@ -73,6 +77,7 @@ LeaseApi.Response get(@AuthenticationPrincipal OidcUser principal, @PathVariable
ResponseEntity<LeaseApi.Response> create(
@AuthenticationPrincipal OidcUser principal,
@Valid @RequestBody LeaseApi.DraftRequest request) {
nestedValidator.validate(request);
var created = drafts.create(authenticatedLandlord.id(principal), details(request));
return ResponseEntity.status(HttpStatus.CREATED).body(response(created));
}
@@ -82,6 +87,7 @@ LeaseApi.Response revise(
@AuthenticationPrincipal OidcUser principal,
@PathVariable String leaseId,
@Valid @RequestBody LeaseApi.DraftRequest request) {
nestedValidator.validate(request);
return response(
drafts.revise(authenticatedLandlord.id(principal), new LeaseId(leaseId), details(request)));
}
@@ -123,13 +129,10 @@ ResponseEntity<LeaseApi.Response> amend(
}
@ExceptionHandler(LeaseRequestValidationFailure.class)
ResponseEntity<LeaseRequestValidationError> invalidAmendment(
ResponseEntity<LeaseRequestValidationError> invalidRequest(
LeaseRequestValidationFailure failure) {
return ResponseEntity.badRequest()
.body(
new LeaseRequestValidationError(
"validation_failed",
List.of(new LeaseRequestFieldError(failure.field(), failure.code()))));
.body(new LeaseRequestValidationError("validation_failed", failure.errors()));
}
@PostMapping("/{leaseId}/revisions")
@@ -251,23 +254,21 @@ private static ResponseEntity<byte[]> document(GeneratedDocument document) {
private record LeaseRequestValidationError(String code, List<LeaseRequestFieldError> errors) {}
private record LeaseRequestFieldError(String field, String code) {}
record LeaseRequestFieldError(String field, String code) {}
private static final class LeaseRequestValidationFailure extends RuntimeException {
private final String field;
private final String code;
static final class LeaseRequestValidationFailure extends RuntimeException {
private final List<LeaseRequestFieldError> errors;
LeaseRequestValidationFailure(String field, String code) {
this.field = field;
this.code = code;
this(List.of(new LeaseRequestFieldError(field, code)));
}
String field() {
return field;
LeaseRequestValidationFailure(List<LeaseRequestFieldError> errors) {
this.errors = List.copyOf(errors);
}
String code() {
return code;
List<LeaseRequestFieldError> errors() {
return errors;
}
}
}
@@ -0,0 +1,196 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.lease;
import fr.nessar.lmnporganizer.rental.application.lease.LeaseClauseCatalog;
import fr.nessar.lmnporganizer.rental.domain.shared.PhoneNumber;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
final class LeaseNestedRequestValidator {
private final LeaseClauseCatalog clauses;
LeaseNestedRequestValidator(LeaseClauseCatalog clauses) {
this.clauses = clauses;
}
void validate(LeaseApi.DraftRequest request) {
List<LeaseController.LeaseRequestFieldError> errors = new ArrayList<>();
guarantors(request.guarantors(), errors);
clauses(request.specialClauses(), context(request), errors);
legalTerms(
request.legalTerms(), request.tenantIds() == null ? 0 : request.tenantIds().size(), errors);
if (!errors.isEmpty()) throw new LeaseController.LeaseRequestValidationFailure(errors);
}
private static void guarantors(
List<LeaseApi.GuarantorRequest> guarantors,
List<LeaseController.LeaseRequestFieldError> errors) {
if (guarantors == null) return;
for (int index = 0; index < guarantors.size(); index++) {
if (guarantors.get(index) == null) {
errors.add(error("guarantors[" + index + "]", "not_null"));
continue;
}
try {
new PhoneNumber(guarantors.get(index).phone());
} catch (IllegalArgumentException exception) {
errors.add(error("guarantors[" + index + "].phone", "invalid"));
}
}
}
private void clauses(
List<LeaseApi.SpecialClauseRequest> requests,
LeaseClauseCatalog.Context context,
List<LeaseController.LeaseRequestFieldError> errors) {
if (requests == null) return;
for (int index = 0; index < requests.size(); index++) {
LeaseApi.SpecialClauseRequest request = requests.get(index);
String path = "specialClauses[" + index + "]";
if (request == null) {
errors.add(error(path, "not_null"));
continue;
}
if (request.catalogId() == null || request.catalogId().isBlank()) {
if (request.parameters() != null && !request.parameters().isEmpty()) {
errors.add(error(path + ".parameters", "not_allowed"));
}
if (request.customText() == null || request.customText().isBlank()) {
errors.add(error(path + ".customText", "required"));
}
continue;
}
if (request.customText() != null && !request.customText().isBlank()) {
errors.add(error(path + ".customText", "server_defined"));
}
LeaseClauseCatalog.ClauseDefinition definition =
clauses.available(request.catalogId(), context);
if (definition == null) {
errors.add(error(path + ".catalogId", "unavailable"));
continue;
}
Map<String, String> parameters =
request.parameters() == null ? Map.of() : request.parameters();
for (String name : parameters.keySet()) {
if (definition.parameters().stream()
.noneMatch(parameter -> parameter.name().equals(name))) {
errors.add(error(path + ".parameters." + name, "unknown"));
}
}
for (LeaseClauseCatalog.ParameterDefinition parameter : definition.parameters()) {
String value = parameters.get(parameter.name());
if (value == null || value.trim().isEmpty()) {
if (parameter.required())
errors.add(error(path + ".parameters." + parameter.name(), "required"));
continue;
}
if (!valid(parameter, value.trim())) {
errors.add(error(path + ".parameters." + parameter.name(), "invalid"));
}
}
}
}
private static boolean valid(LeaseClauseCatalog.ParameterDefinition parameter, String value) {
try {
return switch (parameter.type()) {
case TEXT -> true;
case NUMBER -> {
new BigDecimal(value);
yield true;
}
case DATE -> LocalDate.parse(value) != null;
case TIME -> value.length() == 5 && LocalTime.parse(value).getSecond() == 0;
case CHOICE ->
parameter.choices().stream().anyMatch(choice -> choice.value().equals(value));
};
} catch (RuntimeException exception) {
return false;
}
}
private static void legalTerms(
LeaseApi.LegalTermsRequest terms,
int tenantCount,
List<LeaseController.LeaseRequestFieldError> errors) {
if (terms == null) return;
LeaseApi.RentControlRequest rentControl = terms.rentControl();
if (rentControl != null && Boolean.TRUE.equals(rentControl.referenceRentApplicable())) {
if (rentControl.baseReferenceRent() == null)
errors.add(error("legalTerms.rentControl.baseReferenceRent", "required"));
if (rentControl.increasedReferenceRent() == null)
errors.add(error("legalTerms.rentControl.increasedReferenceRent", "required"));
if (rentControl.baseReferenceRent() != null
&& rentControl.increasedReferenceRent() != null
&& rentControl.increasedReferenceRent().compareTo(rentControl.baseReferenceRent()) < 0) {
errors.add(error("legalTerms.rentControl.increasedReferenceRent", "below_base"));
}
if (positive(rentControl.supplement())
&& (rentControl.supplementJustification() == null
|| rentControl.supplementJustification().isBlank())) {
errors.add(error("legalTerms.rentControl.supplementJustification", "required"));
}
}
LeaseApi.PreviousRentRequest previousRent = terms.previousRent();
if (previousRent != null && Boolean.TRUE.equals(previousRent.applicable())) {
if (previousRent.amount() == null)
errors.add(error("legalTerms.previousRent.amount", "required"));
if (previousRent.lastRevisionDate() == null)
errors.add(error("legalTerms.previousRent.lastRevisionDate", "required"));
if (previousRent.lastPaymentDate() == null)
errors.add(error("legalTerms.previousRent.lastPaymentDate", "required"));
if (!Boolean.TRUE.equals(previousRent.tenantConfirmation())) {
errors.add(error("legalTerms.previousRent.tenantConfirmation", "required"));
}
}
LeaseApi.RentRevisionRequest revision = terms.rentRevision();
if (revision != null && Boolean.TRUE.equals(revision.enabled())) {
if (revision.annualRevisionDay() == null)
errors.add(error("legalTerms.rentRevision.annualRevisionDay", "required"));
if (revision.annualRevisionMonth() == null)
errors.add(error("legalTerms.rentRevision.annualRevisionMonth", "required"));
if (revision.referenceQuarter() == null)
errors.add(error("legalTerms.rentRevision.referenceQuarter", "required"));
if (revision.referenceYear() == null)
errors.add(error("legalTerms.rentRevision.referenceYear", "required"));
if (revision.seriesId() == null || revision.seriesId().isBlank())
errors.add(error("legalTerms.rentRevision.seriesId", "required"));
if (revision.annualRevisionDay() != null && revision.annualRevisionMonth() != null) {
try {
java.time.MonthDay.of(revision.annualRevisionMonth(), revision.annualRevisionDay());
} catch (RuntimeException exception) {
errors.add(error("legalTerms.rentRevision.annualRevisionDay", "invalid_date"));
}
}
}
LeaseApi.RecentWorkRequest work = terms.recentWork();
if (work != null
&& positive(work.amount())
&& (work.description() == null || work.description().isBlank())) {
errors.add(error("legalTerms.recentWork.description", "required"));
}
if (Boolean.TRUE.equals(terms.jointAndSeveralLiability()) && tenantCount < 2) {
errors.add(error("legalTerms.jointAndSeveralLiability", "requires_colocation"));
}
}
private static boolean positive(BigDecimal value) {
return value != null && value.signum() > 0;
}
private static LeaseClauseCatalog.Context context(LeaseApi.DraftRequest request) {
if (request.tenantIds() != null && request.tenantIds().size() > 1) {
return LeaseClauseCatalog.Context.COLOCATION;
}
return request.leaseType() == fr.nessar.lmnporganizer.rental.domain.lease.LeaseType.STUDENT
? LeaseClauseCatalog.Context.STUDENT
: LeaseClauseCatalog.Context.STANDARD;
}
private static LeaseController.LeaseRequestFieldError error(String field, String code) {
return new LeaseController.LeaseRequestFieldError(field, code);
}
}
@@ -53,6 +53,11 @@ public List<ClauseDefinition> available(Context context) {
.toList();
}
public ClauseDefinition available(String id, Context context) {
ClauseDefinition clause = clauses.get(id);
return clause != null && available(context).contains(clause) ? clause : null;
}
public RenderedClause render(
String clauseId, Context context, Map<String, String> suppliedValues) {
ClauseDefinition clause = clauses.get(clauseId);
@@ -0,0 +1,91 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.lease;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import fr.nessar.lmnporganizer.rental.application.lease.LeaseClauseCatalog;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseType;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
class LeaseNestedRequestValidatorTest {
@Test
void reportsIndexedNestedPathsWithoutChangingClauseOrLiabilitySemantics() {
LeaseNestedRequestValidator validator =
new LeaseNestedRequestValidator(
LeaseClauseCatalog.load(getClass().getResourceAsStream("/lease-clauses.yml")));
LeaseController.LeaseRequestValidationFailure failure =
assertThrows(
LeaseController.LeaseRequestValidationFailure.class,
() ->
validator.validate(
new LeaseApi.DraftRequest(
"apartment-1",
List.of("tenant-1"),
List.of(
new LeaseApi.GuarantorRequest(
"guarantor-1", "Alex", "Durand", "alex@example.com", "bad", "")),
LocalDate.of(2026, 10, 1),
null,
LeaseType.STANDARD,
BigDecimal.ONE,
BigDecimal.ZERO,
BigDecimal.ZERO,
5,
null,
false,
List.of(
new LeaseApi.SpecialClauseRequest(
"VISIT_SALE_RELETTING", Map.of(), null)),
new LeaseApi.LegalTermsRequest(
null, null, null, null, false, false, false, true, ""))));
assertEquals(
List.of(
new LeaseController.LeaseRequestFieldError("guarantors[0].phone", "invalid"),
new LeaseController.LeaseRequestFieldError(
"specialClauses[0].parameters.startTime", "required"),
new LeaseController.LeaseRequestFieldError(
"legalTerms.jointAndSeveralLiability", "requires_colocation")),
failure.errors());
}
@Test
void reportsNullNestedElementsWithStableIndexedPaths() {
LeaseNestedRequestValidator validator =
new LeaseNestedRequestValidator(
LeaseClauseCatalog.load(getClass().getResourceAsStream("/lease-clauses.yml")));
LeaseController.LeaseRequestValidationFailure failure =
assertThrows(
LeaseController.LeaseRequestValidationFailure.class,
() ->
validator.validate(
new LeaseApi.DraftRequest(
"apartment-1",
List.of("tenant-1"),
java.util.Arrays.asList((LeaseApi.GuarantorRequest) null),
LocalDate.of(2026, 10, 1),
null,
LeaseType.STANDARD,
BigDecimal.ONE,
BigDecimal.ZERO,
BigDecimal.ZERO,
5,
null,
false,
java.util.Arrays.asList((LeaseApi.SpecialClauseRequest) null),
null)));
assertEquals(
List.of(
new LeaseController.LeaseRequestFieldError("guarantors[0]", "not_null"),
new LeaseController.LeaseRequestFieldError("specialClauses[0]", "not_null")),
failure.errors());
}
}