Keep payment violations domain-shaped (#55)
This commit is contained in:
1 parent
9054cc62d3
commit
41b086fb33
14 files changed
+106
-69
No files matched your search
+2
-1
@@ -103,7 +103,8 @@ ResponseEntity<ValidationApiError> unreadableRequest() {
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
|
||||
ResponseEntity<ValidationApiError> invalidParameter(MethodArgumentTypeMismatchException exception) {
|
||||
ResponseEntity<ValidationApiError> invalidParameter(
|
||||
MethodArgumentTypeMismatchException exception) {
|
||||
return validation(exception.getName(), "invalid");
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -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) {}
|
||||
|
||||
+22
-3
@@ -68,7 +68,7 @@ ResponseEntity<RentPaymentApi.ConcurrentChangeResponse> 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<RentPaymentApi.ValidationFailureResponse> 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
|
||||
|
||||
+4
-8
@@ -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 =
|
||||
|
||||
+1
-2
@@ -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 =
|
||||
|
||||
+3
-3
@@ -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);
|
||||
|
||||
+15
-11
@@ -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;
|
||||
}
|
||||
}
|
||||
-2
@@ -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.");
|
||||
}
|
||||
|
||||
+2
-5
@@ -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"));
|
||||
}
|
||||
|
||||
|
||||
+12
@@ -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());
|
||||
}
|
||||
}
|
||||
+29
-22
@@ -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<RentPaymentValidationFailure.Rule, RentPaymentApi.FieldErrorResponse> 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() {
|
||||
|
||||
+7
-6
@@ -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());
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+5
-4
@@ -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());
|
||||
|
||||
Reference in new issue
Block a user