Align nested lease validation flows (#50)
This commit is contained in:
1 parent
88d3f3c2a5
commit
41590a6a78
14 files changed
+886
-59
No files matched your search
+17
-16
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+196
@@ -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);
|
||||
}
|
||||
}
|
||||
+5
@@ -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);
|
||||
|
||||
+91
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user