Keep payment violations domain-shaped (#55)

This commit is contained in:
nessar committed 2026-08-17 14:18:16 +02:00
1 parent 9054cc62d3
commit 41b086fb33
14 files changed
+106 -69

No files matched your search

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