Align rent payment dialog validation (#55)

This commit is contained in:
nessar committed 2026-08-17 14:18:16 +02:00
1 parent cd32d05466
commit 96bd02e3a6
23 files changed
+723 -87

No files matched your search

@@ -16,6 +16,12 @@ import java.util.List;
final class RentPaymentApi {
private RentPaymentApi() {}
record ConcurrentChangeResponse(String code, String message) {}
record FieldErrorResponse(String field, String code) {}
record ValidationFailureResponse(String code, List<FieldErrorResponse> errors) {}
record CreateRequest(
@NotBlank String operationId,
@NotBlank String rentDueId,
@@ -6,7 +6,9 @@ import fr.nessar.lmnporganizer.rental.application.rent.GeneratePaymentReceiptSer
import fr.nessar.lmnporganizer.rental.application.rent.RecordRentPaymentService;
import fr.nessar.lmnporganizer.rental.application.rent.RefundRentPaymentService;
import fr.nessar.lmnporganizer.rental.application.rent.RentPaymentHistoryService;
import fr.nessar.lmnporganizer.rental.application.rent.RentPaymentValidationFailure;
import fr.nessar.lmnporganizer.rental.application.rent.ReverseRentPaymentService;
import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
import fr.nessar.lmnporganizer.rental.application.shared.GeneratedDocument;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
@@ -16,12 +18,14 @@ import jakarta.validation.Valid;
import java.time.DateTimeException;
import java.time.ZoneId;
import java.util.List;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
@@ -58,6 +62,26 @@ public RentPaymentController(
this.authenticatedLandlord = authenticatedLandlord;
}
@ExceptionHandler({ConcurrentChange.class, OptimisticLockingFailureException.class})
ResponseEntity<RentPaymentApi.ConcurrentChangeResponse> concurrentChange(
RuntimeException exception) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(
new RentPaymentApi.ConcurrentChangeResponse(
ConcurrentChange.CODE,
"La ressource a été modifiée ; rechargez-la puis réessayez."));
}
@ExceptionHandler(RentPaymentValidationFailure.class)
ResponseEntity<RentPaymentApi.ValidationFailureResponse> invalidPayment(
RentPaymentValidationFailure failure) {
return ResponseEntity.badRequest()
.body(
new RentPaymentApi.ValidationFailureResponse(
"validation_failed",
List.of(new RentPaymentApi.FieldErrorResponse(failure.field(), failure.code()))));
}
@GetMapping
List<RentPaymentApi.Response> list(
@AuthenticationPrincipal OidcUser principal, @RequestParam(required = false) String leaseId) {
@@ -13,7 +13,7 @@ import fr.nessar.lmnporganizer.rental.domain.rent.RentDueSettlement;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPayment;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentId;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRepository;
import fr.nessar.lmnporganizer.rental.domain.rent.RentRuleViolation;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentStatus;
import java.math.BigDecimal;
import java.time.Clock;
import java.time.LocalDate;
@@ -67,6 +67,9 @@ public RentPayment allocate(
if (payment.version() != paymentVersion) {
throw new ConcurrentChange("Rent payment");
}
if (payment.status() != RentPaymentStatus.CONFIRMED) {
throw new RentPaymentConflict("Un règlement contrepassé ne peut pas être affecté.");
}
RentDue due =
rentDues
.find(landlordId, rentDueId)
@@ -74,12 +77,34 @@ public RentPayment allocate(
if (due.version() != rentDueVersion) {
throw new ConcurrentChange("Rent due");
}
if (!payment.leaseId().equals(due.leaseId())) {
throw new RentPaymentValidationFailure(
"rentDueId",
"different_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",
"Cette échéance a déjà reçu une affectation de ce règlement.");
}
if (due.settlement() == RentDueSettlement.PAID) {
throw new RentPaymentConflict(
"Une échéance déjà réglée ne peut pas recevoir une affectation.");
}
if (!payment.leaseId().equals(due.leaseId())) {
throw new RentRuleViolation("Une affectation doit viser une échéance du même bail.");
if (amount != null && amount.compareTo(payment.unallocatedBalance()) > 0) {
throw new RentPaymentValidationFailure(
"amount",
"exceeds_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",
"L'affectation dépasse le solde restant de l'échéance.");
}
PaymentAllocation allocation =
new PaymentAllocation(allocationId, due.id(), amount, LocalDate.now(clock));
@@ -13,7 +13,6 @@ import fr.nessar.lmnporganizer.rental.domain.rent.RentDueSettlement;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPayment;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentId;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRepository;
import fr.nessar.lmnporganizer.rental.domain.rent.RentRuleViolation;
import java.math.BigDecimal;
import java.time.Clock;
import java.time.LocalDate;
@@ -88,7 +87,10 @@ private RentPayment recordInTransaction(
BigDecimal amount) {
LocalDate today = LocalDate.now(clock.withZone(timeZone));
if (receivedAt == null || receivedAt.isAfter(today)) {
throw new RentRuleViolation("La date de réception du règlement ne peut pas être future.");
throw new RentPaymentValidationFailure(
"receivedAt",
"future_date",
"La date de réception du règlement ne peut pas être future.");
}
RentDue due =
rentDues.find(landlordId, rentDueId).orElseThrow(() -> new ResourceNotFound("Rent due"));
@@ -10,7 +10,7 @@ import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefund;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefundId;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefundRepository;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRepository;
import fr.nessar.lmnporganizer.rental.domain.rent.RentRuleViolation;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentStatus;
import java.math.BigDecimal;
import java.time.Clock;
import java.time.LocalDate;
@@ -71,12 +71,22 @@ private RentPayment refundInTransaction(
ZoneId timeZone) {
LocalDate today = LocalDate.now(clock.withZone(timeZone));
if (refundedAt == null || refundedAt.isAfter(today)) {
throw new RentRuleViolation("La date du remboursement ne peut pas être future.");
throw new RentPaymentValidationFailure(
"refundedAt", "future_date", "La date du remboursement ne peut pas être future.");
}
RentPayment payment = payment(landlordId, paymentId);
if (payment.version() != paymentVersion) {
throw new ConcurrentChange("Rent payment");
}
if (payment.status() != RentPaymentStatus.CONFIRMED) {
throw new RentPaymentConflict("Un règlement contrepassé ne peut pas être remboursé.");
}
if (amount != null && amount.compareTo(payment.unallocatedBalance()) > 0) {
throw new RentPaymentValidationFailure(
"amount",
"exceeds_unallocated_balance",
"Le remboursement dépasse le solde non affecté du règlement.");
}
RentPaymentRefund refund = RentPaymentRefund.recorded(refundId, payment, amount, refundedAt);
RentPayment saved = payments.save(payment);
refunds.save(refund);
@@ -0,0 +1,22 @@
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 RentPaymentValidationFailure(String field, String code, String message) {
super(message);
this.field = Objects.requireNonNull(field);
this.code = Objects.requireNonNull(code);
}
public String field() {
return field;
}
public String code() {
return code;
}
}
@@ -1,6 +1,8 @@
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.");
}
@@ -0,0 +1,19 @@
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 org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
class RentPaymentConflictContractTest {
@Test
void keepsBusinessConflictsAsUncoded409Responses() {
var response =
new RentalApiExceptionHandler().conflict(new RentPaymentConflict("Échéance déjà réglée"));
assertEquals(HttpStatus.CONFLICT, response.getStatusCode());
assertEquals(
new RentalApiExceptionHandler.ApiError("Échéance déjà réglée"), response.getBody());
}
}
@@ -0,0 +1,64 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.rent;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlord;
import fr.nessar.lmnporganizer.rental.application.rent.AllocateRentPaymentService;
import fr.nessar.lmnporganizer.rental.application.rent.GeneratePaymentReceiptService;
import fr.nessar.lmnporganizer.rental.application.rent.RecordRentPaymentService;
import fr.nessar.lmnporganizer.rental.application.rent.RefundRentPaymentService;
import fr.nessar.lmnporganizer.rental.application.rent.RentPaymentHistoryService;
import fr.nessar.lmnporganizer.rental.application.rent.RentPaymentValidationFailure;
import fr.nessar.lmnporganizer.rental.application.rent.ReverseRentPaymentService;
import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
import java.util.List;
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));
var response =
controller.invalidPayment(
new RentPaymentValidationFailure(
"amount", "exceeds_unallocated_balance", "Le remboursement dépasse le solde."));
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
assertEquals(
new RentPaymentApi.ValidationFailureResponse(
"validation_failed",
List.of(
new RentPaymentApi.FieldErrorResponse("amount", "exceeds_unallocated_balance"))),
response.getBody());
}
@Test
void mapsOnlyConcurrentChangesToTheCodedStaleContract() {
var response = controller().concurrentChange(new ConcurrentChange("Rent payment"));
assertEquals(HttpStatus.CONFLICT, response.getStatusCode());
assertEquals(ConcurrentChange.CODE, response.getBody().code());
}
private static RentPaymentController controller() {
return new RentPaymentController(
mock(RentPaymentHistoryService.class),
mock(RecordRentPaymentService.class),
mock(AllocateRentPaymentService.class),
mock(RefundRentPaymentService.class),
mock(ReverseRentPaymentService.class),
mock(GeneratePaymentReceiptService.class),
mock(AuthenticatedLandlord.class));
}
}
@@ -198,6 +198,116 @@ void staleVersionsAndAlreadyPaidDueCreateNoAllocation() {
verify(payments, never()).save(any());
}
@Test
void reversedPaymentCannotReceiveAnotherAllocation() {
RentPaymentRepository payments = mock(RentPaymentRepository.class);
RentDueRepository rentDues = mock(RentDueRepository.class);
RentPayment payment =
RentPayment.reconstitute(
PAYMENT_ID,
LANDLORD_ID,
LEASE_ID,
LocalDate.of(2026, 2, 2),
new BigDecimal("100"),
RentPaymentStatus.REVERSED,
List.of(),
BigDecimal.ZERO,
LocalDate.of(2026, 2, 2),
4);
when(payments.find(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(payment));
assertThrows(
RentPaymentConflict.class,
() ->
new AllocateRentPaymentService(payments, rentDues, new RecordingTransactions(), CLOCK)
.allocate(
LANDLORD_ID, PAYMENT_ID, "operation-2", 4, DUE_ID, 2, new BigDecimal("10")));
verifyNoInteractions(rentDues);
verify(payments, never()).save(any());
}
@Test
void allocationMapsDuplicateAndBalanceFailuresToFields() {
RentPaymentRepository payments = mock(RentPaymentRepository.class);
RentDueRepository rentDues = mock(RentDueRepository.class);
PaymentAllocation existing =
new PaymentAllocation(
new PaymentAllocationId("allocation-1"),
DUE_ID,
new BigDecimal("10"),
LocalDate.of(2026, 2, 2));
RentPayment payment =
RentPayment.reconstitute(
PAYMENT_ID,
LANDLORD_ID,
LEASE_ID,
LocalDate.of(2026, 2, 2),
new BigDecimal("100"),
RentPaymentStatus.CONFIRMED,
List.of(existing),
BigDecimal.ZERO,
LocalDate.of(2026, 2, 2),
4);
RentDue due =
RentDue.reconstitute(
DUE_ID,
LANDLORD_ID,
LEASE_ID,
YearMonth.of(2026, 2),
LocalDate.of(2026, 3, 5),
new BigDecimal("50"),
BigDecimal.ZERO,
new BigDecimal("10"),
2);
when(payments.find(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(payment));
when(rentDues.find(LANDLORD_ID, DUE_ID)).thenReturn(Optional.of(due));
AllocateRentPaymentService service =
new AllocateRentPaymentService(payments, rentDues, new RecordingTransactions(), CLOCK);
RentPaymentValidationFailure duplicate =
assertThrows(
RentPaymentValidationFailure.class,
() ->
service.allocate(
LANDLORD_ID, PAYMENT_ID, "operation-2", 4, DUE_ID, 2, new BigDecimal("10")));
assertEquals("rentDueId", duplicate.field());
assertEquals("already_allocated", duplicate.code());
RentPayment otherPayment =
RentPayment.reconstitute(
PAYMENT_ID,
LANDLORD_ID,
LEASE_ID,
LocalDate.of(2026, 2, 2),
new BigDecimal("100"),
RentPaymentStatus.CONFIRMED,
List.of(),
BigDecimal.ZERO,
LocalDate.of(2026, 2, 2),
4);
when(payments.find(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(otherPayment));
RentPaymentValidationFailure balance =
assertThrows(
RentPaymentValidationFailure.class,
() ->
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());
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());
verify(rentDues, never()).save(any());
verify(payments, never()).save(any());
}
private static final class RecordingTransactions implements TransactionRunner {
private int calls;
@@ -23,7 +23,6 @@ import fr.nessar.lmnporganizer.rental.domain.rent.RentPayment;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentId;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRepository;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentStatus;
import fr.nessar.lmnporganizer.rental.domain.rent.RentRuleViolation;
import java.math.BigDecimal;
import java.time.Clock;
import java.time.Instant;
@@ -208,17 +207,20 @@ void staleOrPaidDueAndCashFromTheNextParisDayCreateNoPayment() {
LocalDate.of(2026, 2, 9),
PARIS,
new BigDecimal("1000")));
assertThrows(
RentRuleViolation.class,
() ->
service.record(
LANDLORD_ID,
"operation-1",
DUE_ID,
4,
LocalDate.of(2026, 2, 11),
PARIS,
new BigDecimal("1000")));
RentPaymentValidationFailure futureDate =
assertThrows(
RentPaymentValidationFailure.class,
() ->
service.record(
LANDLORD_ID,
"operation-1",
DUE_ID,
4,
LocalDate.of(2026, 2, 11),
PARIS,
new BigDecimal("1000")));
assertEquals("receivedAt", futureDate.field());
assertEquals("future_date", futureDate.code());
due.allocate(due.total());
assertThrows(
RentPaymentConflict.class,
@@ -262,7 +264,7 @@ void validatesReceivedDateInTheUsersCalendarZone() {
assertEquals(LocalDate.of(2026, 7, 19), LocalDate.now(boundary.withZone(PARIS)));
assertThrows(
RentRuleViolation.class,
RentPaymentValidationFailure.class,
() ->
service.record(
LANDLORD_ID,
@@ -19,7 +19,6 @@ import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefundId;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefundRepository;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRepository;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentStatus;
import fr.nessar.lmnporganizer.rental.domain.rent.RentRuleViolation;
import java.math.BigDecimal;
import java.time.Clock;
import java.time.Instant;
@@ -168,17 +167,20 @@ void futureDateAndStalePaymentVersionCreateNoRefund() {
RefundRentPaymentService service =
new RefundRentPaymentService(payments, refunds, new RecordingTransactions(), CLOCK);
assertThrows(
RentRuleViolation.class,
() ->
service.refund(
LANDLORD_ID,
PAYMENT_ID,
"operation-1",
4,
new BigDecimal("50"),
LocalDate.of(2026, 2, 11),
PARIS));
RentPaymentValidationFailure futureDate =
assertThrows(
RentPaymentValidationFailure.class,
() ->
service.refund(
LANDLORD_ID,
PAYMENT_ID,
"operation-1",
4,
new BigDecimal("50"),
LocalDate.of(2026, 2, 11),
PARIS));
assertEquals("refundedAt", futureDate.field());
assertEquals("future_date", futureDate.code());
assertThrows(
ConcurrentChange.class,
() ->
@@ -190,6 +192,57 @@ void futureDateAndStalePaymentVersionCreateNoRefund() {
new BigDecimal("50"),
LocalDate.of(2026, 2, 9),
PARIS));
RentPaymentValidationFailure excessAmount =
assertThrows(
RentPaymentValidationFailure.class,
() ->
service.refund(
LANDLORD_ID,
PAYMENT_ID,
"operation-1",
4,
new BigDecimal("1001"),
LocalDate.of(2026, 2, 9),
PARIS));
assertEquals("amount", excessAmount.field());
assertEquals("exceeds_unallocated_balance", excessAmount.code());
verify(payments, never()).save(any());
verify(refunds, never()).save(any());
}
@Test
void reversedPaymentCannotBeRefunded() {
RentPaymentRepository payments = mock(RentPaymentRepository.class);
RentPaymentRefundRepository refunds = mock(RentPaymentRefundRepository.class);
RentPayment payment =
RentPayment.reconstitute(
PAYMENT_ID,
LANDLORD_ID,
new LeaseId("lease-1"),
LocalDate.of(2026, 2, 2),
new BigDecimal("1000"),
RentPaymentStatus.REVERSED,
List.of(),
BigDecimal.ZERO,
LocalDate.of(2026, 2, 2),
4);
RentPaymentRefundId refundId = new RentPaymentRefundId("operation-1");
when(refunds.find(LANDLORD_ID, refundId)).thenReturn(Optional.empty());
when(payments.find(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(payment));
assertThrows(
RentPaymentConflict.class,
() ->
new RefundRentPaymentService(payments, refunds, new RecordingTransactions(), CLOCK)
.refund(
LANDLORD_ID,
PAYMENT_ID,
"operation-1",
4,
new BigDecimal("50"),
LocalDate.of(2026, 2, 9),
PARIS));
verify(payments, never()).save(any());
verify(refunds, never()).save(any());
@@ -223,7 +276,7 @@ void validatesRefundDateInTheUsersCalendarZone() {
assertEquals(LocalDate.of(2026, 7, 19), LocalDate.now(boundary.withZone(PARIS)));
assertThrows(
RentRuleViolation.class,
RentPaymentValidationFailure.class,
() ->
service.refund(
LANDLORD_ID,
@@ -60,6 +60,7 @@ void refundsUseOnlySurplusAndReversalIsAuditableAndIdempotent() {
@Test
void confirmedPaymentMustRepresentPositiveCash() {
assertThrows(RentRuleViolation.class, () -> payment("0"));
assertEquals(new BigDecimal("0.001"), payment("0.001").amount());
}
private RentPayment payment(String amount) {