diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/common/RentalApiExceptionHandler.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/common/RentalApiExceptionHandler.java index 0c6621a..71c3ed7 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/common/RentalApiExceptionHandler.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/common/RentalApiExceptionHandler.java @@ -103,7 +103,8 @@ ResponseEntity unreadableRequest() { } @ExceptionHandler(MethodArgumentTypeMismatchException.class) -ResponseEntity invalidParameter(MethodArgumentTypeMismatchException exception) { +ResponseEntity invalidParameter( + MethodArgumentTypeMismatchException exception) { return validation(exception.getName(), "invalid"); } diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/rent/RentPaymentApi.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/rent/RentPaymentApi.java index 5029376..60eff84 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/rent/RentPaymentApi.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/rent/RentPaymentApi.java @@ -14,6 +14,8 @@ import java.time.LocalDate; import java.util.List; final class RentPaymentApi { +static final String CONCURRENT_CHANGE_CODE = "concurrent_change"; + private RentPaymentApi() {} record ConcurrentChangeResponse(String code, String message) {} diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/rent/RentPaymentController.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/rent/RentPaymentController.java index 8df38c2..a6c1c88 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/rent/RentPaymentController.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/rent/RentPaymentController.java @@ -68,7 +68,7 @@ ResponseEntity concurrentChange( return ResponseEntity.status(HttpStatus.CONFLICT) .body( new RentPaymentApi.ConcurrentChangeResponse( - ConcurrentChange.CODE, + RentPaymentApi.CONCURRENT_CHANGE_CODE, "La ressource a été modifiée ; rechargez-la puis réessayez.")); } @@ -78,8 +78,27 @@ ResponseEntity invalidPayment( return ResponseEntity.badRequest() .body( new RentPaymentApi.ValidationFailureResponse( - "validation_failed", - List.of(new RentPaymentApi.FieldErrorResponse(failure.field(), failure.code())))); + "validation_failed", List.of(validationError(failure.rule())))); +} + +private static RentPaymentApi.FieldErrorResponse validationError( + RentPaymentValidationFailure.Rule rule) { + return switch (rule) { + case RECEIVED_AT_MUST_NOT_BE_FUTURE -> + new RentPaymentApi.FieldErrorResponse("receivedAt", "future_date"); + case REFUNDED_AT_MUST_NOT_BE_FUTURE -> + new RentPaymentApi.FieldErrorResponse("refundedAt", "future_date"); + case RENT_DUE_MUST_BELONG_TO_PAYMENT_LEASE -> + new RentPaymentApi.FieldErrorResponse("rentDueId", "different_lease"); + case RENT_DUE_MUST_NOT_ALREADY_BE_ALLOCATED -> + new RentPaymentApi.FieldErrorResponse("rentDueId", "already_allocated"); + case AMOUNT_MUST_NOT_EXCEED_PAYMENT_BALANCE -> + new RentPaymentApi.FieldErrorResponse("amount", "exceeds_payment_balance"); + case AMOUNT_MUST_NOT_EXCEED_RENT_DUE_BALANCE -> + new RentPaymentApi.FieldErrorResponse("amount", "exceeds_rent_due_balance"); + case AMOUNT_MUST_NOT_EXCEED_UNALLOCATED_BALANCE -> + new RentPaymentApi.FieldErrorResponse("amount", "exceeds_unallocated_balance"); + }; } @GetMapping diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/rent/AllocateRentPaymentService.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/rent/AllocateRentPaymentService.java index 8160462..fcb7fa4 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/rent/AllocateRentPaymentService.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/rent/AllocateRentPaymentService.java @@ -79,15 +79,13 @@ public RentPayment allocate( } if (!payment.leaseId().equals(due.leaseId())) { throw new RentPaymentValidationFailure( - "rentDueId", - "different_lease", + RentPaymentValidationFailure.Rule.RENT_DUE_MUST_BELONG_TO_PAYMENT_LEASE, "Une affectation doit viser une échéance du même bail."); } if (payment.allocations().stream() .anyMatch(allocation -> allocation.rentDueId().equals(due.id()))) { throw new RentPaymentValidationFailure( - "rentDueId", - "already_allocated", + RentPaymentValidationFailure.Rule.RENT_DUE_MUST_NOT_ALREADY_BE_ALLOCATED, "Cette échéance a déjà reçu une affectation de ce règlement."); } if (due.settlement() == RentDueSettlement.PAID) { @@ -96,14 +94,12 @@ public RentPayment allocate( } if (amount != null && amount.compareTo(payment.unallocatedBalance()) > 0) { throw new RentPaymentValidationFailure( - "amount", - "exceeds_payment_balance", + RentPaymentValidationFailure.Rule.AMOUNT_MUST_NOT_EXCEED_PAYMENT_BALANCE, "L'affectation dépasse le solde non affecté du règlement."); } if (amount != null && amount.compareTo(due.remainingAmount()) > 0) { throw new RentPaymentValidationFailure( - "amount", - "exceeds_rent_due_balance", + RentPaymentValidationFailure.Rule.AMOUNT_MUST_NOT_EXCEED_RENT_DUE_BALANCE, "L'affectation dépasse le solde restant de l'échéance."); } PaymentAllocation allocation = diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/rent/RecordRentPaymentService.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/rent/RecordRentPaymentService.java index 512bd82..a5c274e 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/rent/RecordRentPaymentService.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/rent/RecordRentPaymentService.java @@ -88,8 +88,7 @@ private RentPayment recordInTransaction( LocalDate today = LocalDate.now(clock.withZone(timeZone)); if (receivedAt == null || receivedAt.isAfter(today)) { throw new RentPaymentValidationFailure( - "receivedAt", - "future_date", + RentPaymentValidationFailure.Rule.RECEIVED_AT_MUST_NOT_BE_FUTURE, "La date de réception du règlement ne peut pas être future."); } RentDue due = diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/rent/RefundRentPaymentService.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/rent/RefundRentPaymentService.java index 1627a1f..788d610 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/rent/RefundRentPaymentService.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/rent/RefundRentPaymentService.java @@ -72,7 +72,8 @@ private RentPayment refundInTransaction( LocalDate today = LocalDate.now(clock.withZone(timeZone)); if (refundedAt == null || refundedAt.isAfter(today)) { throw new RentPaymentValidationFailure( - "refundedAt", "future_date", "La date du remboursement ne peut pas être future."); + RentPaymentValidationFailure.Rule.REFUNDED_AT_MUST_NOT_BE_FUTURE, + "La date du remboursement ne peut pas être future."); } RentPayment payment = payment(landlordId, paymentId); if (payment.version() != paymentVersion) { @@ -83,8 +84,7 @@ private RentPayment refundInTransaction( } if (amount != null && amount.compareTo(payment.unallocatedBalance()) > 0) { throw new RentPaymentValidationFailure( - "amount", - "exceeds_unallocated_balance", + RentPaymentValidationFailure.Rule.AMOUNT_MUST_NOT_EXCEED_UNALLOCATED_BALANCE, "Le remboursement dépasse le solde non affecté du règlement."); } RentPaymentRefund refund = RentPaymentRefund.recorded(refundId, payment, amount, refundedAt); diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/rent/RentPaymentValidationFailure.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/rent/RentPaymentValidationFailure.java index 3a471de..9f15f86 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/rent/RentPaymentValidationFailure.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/rent/RentPaymentValidationFailure.java @@ -3,20 +3,24 @@ package fr.nessar.lmnporganizer.rental.application.rent; import java.util.Objects; public final class RentPaymentValidationFailure extends RuntimeException { -private final String field; -private final String code; +public enum Rule { + RECEIVED_AT_MUST_NOT_BE_FUTURE, + REFUNDED_AT_MUST_NOT_BE_FUTURE, + RENT_DUE_MUST_BELONG_TO_PAYMENT_LEASE, + RENT_DUE_MUST_NOT_ALREADY_BE_ALLOCATED, + AMOUNT_MUST_NOT_EXCEED_PAYMENT_BALANCE, + AMOUNT_MUST_NOT_EXCEED_RENT_DUE_BALANCE, + AMOUNT_MUST_NOT_EXCEED_UNALLOCATED_BALANCE +} -public RentPaymentValidationFailure(String field, String code, String message) { +private final Rule rule; + +public RentPaymentValidationFailure(Rule rule, String message) { super(message); - this.field = Objects.requireNonNull(field); - this.code = Objects.requireNonNull(code); + this.rule = Objects.requireNonNull(rule); } -public String field() { - return field; -} - -public String code() { - return code; +public Rule rule() { + return rule; } } diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/shared/ConcurrentChange.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/shared/ConcurrentChange.java index 8bea64f..ba401f8 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/shared/ConcurrentChange.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/shared/ConcurrentChange.java @@ -1,8 +1,6 @@ package fr.nessar.lmnporganizer.rental.application.shared; public final class ConcurrentChange extends RuntimeException { -public static final String CODE = "concurrent_change"; - public ConcurrentChange(String resource) { super(resource + " changed concurrently; reload it and retry."); } diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/RentalWebAdaptersTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/RentalWebAdaptersTest.java index 64fc14f..7649dac 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/RentalWebAdaptersTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/RentalWebAdaptersTest.java @@ -148,8 +148,7 @@ void authenticateLandlordAndFixTime() { LeaseClauseCatalog.load(getClass().getResourceAsStream("/lease-clauses.yml")); when(leaseClauseCatalog.available(anyString(), any())) .thenAnswer( - invocation -> - clauses.available(invocation.getArgument(0), invocation.getArgument(1))); + invocation -> clauses.available(invocation.getArgument(0), invocation.getArgument(1))); } @Test @@ -444,9 +443,7 @@ void leaseDraftRequestsRejectAnImpossibleAnnualRevisionDay() throws Exception { """)) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value("validation_failed")) - .andExpect( - jsonPath("$.errors[0].field") - .value("legalTerms.rentRevision.annualRevisionDay")) + .andExpect(jsonPath("$.errors[0].field").value("legalTerms.rentRevision.annualRevisionDay")) .andExpect(jsonPath("$.errors[0].code").value("invalid_date")); } diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/common/RentPaymentConflictContractTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/common/RentPaymentConflictContractTest.java index 6dbd3dc..6135e41 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/common/RentPaymentConflictContractTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/common/RentPaymentConflictContractTest.java @@ -3,6 +3,7 @@ package fr.nessar.lmnporganizer.rental.adapter.in.web.common; import static org.junit.jupiter.api.Assertions.assertEquals; import fr.nessar.lmnporganizer.rental.application.rent.RentPaymentConflict; +import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange; import org.junit.jupiter.api.Test; import org.springframework.http.HttpStatus; @@ -16,4 +17,15 @@ void keepsBusinessConflictsAsUncoded409Responses() { assertEquals( new RentalApiExceptionHandler.ApiError("Échéance déjà réglée"), response.getBody()); } + +@Test +void keepsSharedConcurrencyFailuresAsSafeUncoded409Responses() { + var response = new RentalApiExceptionHandler().concurrent(new ConcurrentChange("Rent payment")); + + assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); + assertEquals( + new RentalApiExceptionHandler.ApiError( + "La ressource a été modifiée ; rechargez-la puis réessayez."), + response.getBody()); +} } diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/rent/RentPaymentControllerErrorTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/rent/RentPaymentControllerErrorTest.java index 1a13e6a..64d7cda 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/rent/RentPaymentControllerErrorTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/rent/RentPaymentControllerErrorTest.java @@ -13,34 +13,41 @@ import fr.nessar.lmnporganizer.rental.application.rent.RentPaymentValidationFail import fr.nessar.lmnporganizer.rental.application.rent.ReverseRentPaymentService; import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.Test; import org.springframework.http.HttpStatus; class RentPaymentControllerErrorTest { @Test -void mapsPaymentInputFailuresToTheStableValidationContract() { - RentPaymentController controller = - new RentPaymentController( - mock(RentPaymentHistoryService.class), - mock(RecordRentPaymentService.class), - mock(AllocateRentPaymentService.class), - mock(RefundRentPaymentService.class), - mock(ReverseRentPaymentService.class), - mock(GeneratePaymentReceiptService.class), - mock(AuthenticatedLandlord.class)); +void mapsPaymentRulesToTheStableValidationContract() { + Map expected = + Map.of( + RentPaymentValidationFailure.Rule.RECEIVED_AT_MUST_NOT_BE_FUTURE, + new RentPaymentApi.FieldErrorResponse("receivedAt", "future_date"), + RentPaymentValidationFailure.Rule.REFUNDED_AT_MUST_NOT_BE_FUTURE, + new RentPaymentApi.FieldErrorResponse("refundedAt", "future_date"), + RentPaymentValidationFailure.Rule.RENT_DUE_MUST_BELONG_TO_PAYMENT_LEASE, + new RentPaymentApi.FieldErrorResponse("rentDueId", "different_lease"), + RentPaymentValidationFailure.Rule.RENT_DUE_MUST_NOT_ALREADY_BE_ALLOCATED, + new RentPaymentApi.FieldErrorResponse("rentDueId", "already_allocated"), + RentPaymentValidationFailure.Rule.AMOUNT_MUST_NOT_EXCEED_PAYMENT_BALANCE, + new RentPaymentApi.FieldErrorResponse("amount", "exceeds_payment_balance"), + RentPaymentValidationFailure.Rule.AMOUNT_MUST_NOT_EXCEED_RENT_DUE_BALANCE, + new RentPaymentApi.FieldErrorResponse("amount", "exceeds_rent_due_balance"), + RentPaymentValidationFailure.Rule.AMOUNT_MUST_NOT_EXCEED_UNALLOCATED_BALANCE, + new RentPaymentApi.FieldErrorResponse("amount", "exceeds_unallocated_balance")); - var response = - controller.invalidPayment( - new RentPaymentValidationFailure( - "amount", "exceeds_unallocated_balance", "Le remboursement dépasse le solde.")); + expected.forEach( + (rule, error) -> { + var response = + controller() + .invalidPayment(new RentPaymentValidationFailure(rule, "internal detail")); - assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); - assertEquals( - new RentPaymentApi.ValidationFailureResponse( - "validation_failed", - List.of( - new RentPaymentApi.FieldErrorResponse("amount", "exceeds_unallocated_balance"))), - response.getBody()); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + assertEquals( + new RentPaymentApi.ValidationFailureResponse("validation_failed", List.of(error)), + response.getBody()); + }); } @Test @@ -48,7 +55,7 @@ void mapsOnlyConcurrentChangesToTheCodedStaleContract() { var response = controller().concurrentChange(new ConcurrentChange("Rent payment")); assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); - assertEquals(ConcurrentChange.CODE, response.getBody().code()); + assertEquals(RentPaymentApi.CONCURRENT_CHANGE_CODE, response.getBody().code()); } private static RentPaymentController controller() { diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/rent/AllocateRentPaymentServiceTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/rent/AllocateRentPaymentServiceTest.java index 4cacd14..25c77a4 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/rent/AllocateRentPaymentServiceTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/rent/AllocateRentPaymentServiceTest.java @@ -271,8 +271,8 @@ void allocationMapsDuplicateAndBalanceFailuresToFields() { () -> service.allocate( LANDLORD_ID, PAYMENT_ID, "operation-2", 4, DUE_ID, 2, new BigDecimal("10"))); - assertEquals("rentDueId", duplicate.field()); - assertEquals("already_allocated", duplicate.code()); + assertEquals( + RentPaymentValidationFailure.Rule.RENT_DUE_MUST_NOT_ALREADY_BE_ALLOCATED, duplicate.rule()); RentPayment otherPayment = RentPayment.reconstitute( @@ -293,16 +293,17 @@ void allocationMapsDuplicateAndBalanceFailuresToFields() { () -> service.allocate( LANDLORD_ID, PAYMENT_ID, "operation-3", 4, DUE_ID, 2, new BigDecimal("101"))); - assertEquals("amount", balance.field()); - assertEquals("exceeds_payment_balance", balance.code()); + assertEquals( + RentPaymentValidationFailure.Rule.AMOUNT_MUST_NOT_EXCEED_PAYMENT_BALANCE, balance.rule()); RentPaymentValidationFailure dueBalance = assertThrows( RentPaymentValidationFailure.class, () -> service.allocate( LANDLORD_ID, PAYMENT_ID, "operation-4", 4, DUE_ID, 2, new BigDecimal("41"))); - assertEquals("amount", dueBalance.field()); - assertEquals("exceeds_rent_due_balance", dueBalance.code()); + assertEquals( + RentPaymentValidationFailure.Rule.AMOUNT_MUST_NOT_EXCEED_RENT_DUE_BALANCE, + dueBalance.rule()); verify(rentDues, never()).save(any()); verify(payments, never()).save(any()); diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/rent/RecordRentPaymentServiceTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/rent/RecordRentPaymentServiceTest.java index 291a6be..d3fc1e3 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/rent/RecordRentPaymentServiceTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/rent/RecordRentPaymentServiceTest.java @@ -219,8 +219,8 @@ void staleOrPaidDueAndCashFromTheNextParisDayCreateNoPayment() { LocalDate.of(2026, 2, 11), PARIS, new BigDecimal("1000"))); - assertEquals("receivedAt", futureDate.field()); - assertEquals("future_date", futureDate.code()); + assertEquals( + RentPaymentValidationFailure.Rule.RECEIVED_AT_MUST_NOT_BE_FUTURE, futureDate.rule()); due.allocate(due.total()); assertThrows( RentPaymentConflict.class, diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/rent/RefundRentPaymentServiceTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/rent/RefundRentPaymentServiceTest.java index 1880dae..2ba244a 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/rent/RefundRentPaymentServiceTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/rent/RefundRentPaymentServiceTest.java @@ -179,8 +179,8 @@ void futureDateAndStalePaymentVersionCreateNoRefund() { new BigDecimal("50"), LocalDate.of(2026, 2, 11), PARIS)); - assertEquals("refundedAt", futureDate.field()); - assertEquals("future_date", futureDate.code()); + assertEquals( + RentPaymentValidationFailure.Rule.REFUNDED_AT_MUST_NOT_BE_FUTURE, futureDate.rule()); assertThrows( ConcurrentChange.class, () -> @@ -204,8 +204,9 @@ void futureDateAndStalePaymentVersionCreateNoRefund() { new BigDecimal("1001"), LocalDate.of(2026, 2, 9), PARIS)); - assertEquals("amount", excessAmount.field()); - assertEquals("exceeds_unallocated_balance", excessAmount.code()); + assertEquals( + RentPaymentValidationFailure.Rule.AMOUNT_MUST_NOT_EXCEED_UNALLOCATED_BALANCE, + excessAmount.rule()); verify(payments, never()).save(any()); verify(refunds, never()).save(any());