From 96bd02e3a651b9cb993ad353e1bb2e272e5e9d5f Mon Sep 17 00:00:00 2001
From: Nessar
Date: Fri, 24 Jul 2026 19:08:52 +0200
Subject: [PATCH] Align rent payment dialog validation (#55)
---
.../adapter/in/web/rent/RentPaymentApi.java | 6 +
.../in/web/rent/RentPaymentController.java | 24 ++++
.../rent/AllocateRentPaymentService.java | 31 ++++-
.../rent/RecordRentPaymentService.java | 6 +-
.../rent/RefundRentPaymentService.java | 14 ++-
.../rent/RentPaymentValidationFailure.java | 22 ++++
.../application/shared/ConcurrentChange.java | 2 +
.../RentPaymentConflictContractTest.java | 19 +++
.../rent/RentPaymentControllerErrorTest.java | 64 ++++++++++
.../rent/AllocateRentPaymentServiceTest.java | 110 ++++++++++++++++++
.../rent/RecordRentPaymentServiceTest.java | 28 ++---
.../rent/RefundRentPaymentServiceTest.java | 79 ++++++++++---
.../rental/domain/rent/RentPaymentTest.java | 1 +
.../rent-payment-allocation-dialog.html | 22 +++-
.../rent-payment-allocation-dialog.ts | 57 ++++++++-
.../components/rent-payment-dialog-result.ts | 13 ++-
.../components/rent-payment-dialogs.spec.ts | 74 +++++++++++-
.../rent-payment-refund-dialog.html | 28 ++++-
.../rent-payment-refund-dialog.ts | 53 +++++++--
.../rent-payment-reversal-dialog.html | 19 +--
.../rent-payment-reversal-dialog.ts | 63 ++++++++--
.../rent-payment-validation-dialog.html | 23 +++-
.../rent-payment-validation-dialog.ts | 52 +++++++--
23 files changed, 723 insertions(+), 87 deletions(-)
create mode 100644 backend/src/main/java/fr/nessar/lmnporganizer/rental/application/rent/RentPaymentValidationFailure.java
create mode 100644 backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/common/RentPaymentConflictContractTest.java
create mode 100644 backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/rent/RentPaymentControllerErrorTest.java
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 e7199fe..5029376 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
@@ -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 errors) {}
+
record CreateRequest(
@NotBlank String operationId,
@NotBlank String rentDueId,
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 d62d329..8df38c2 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
@@ -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 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 invalidPayment(
+ RentPaymentValidationFailure failure) {
+ return ResponseEntity.badRequest()
+ .body(
+ new RentPaymentApi.ValidationFailureResponse(
+ "validation_failed",
+ List.of(new RentPaymentApi.FieldErrorResponse(failure.field(), failure.code()))));
+}
+
@GetMapping
List list(
@AuthenticationPrincipal OidcUser principal, @RequestParam(required = false) String leaseId) {
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 f19ed31..8160462 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
@@ -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));
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 52b340a..512bd82 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
@@ -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"));
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 6f0056f..1627a1f 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
@@ -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);
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
new file mode 100644
index 0000000..3a471de
--- /dev/null
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/rent/RentPaymentValidationFailure.java
@@ -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;
+}
+}
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 ba401f8..8bea64f 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,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.");
}
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
new file mode 100644
index 0000000..6dbd3dc
--- /dev/null
+++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/common/RentPaymentConflictContractTest.java
@@ -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());
+}
+}
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
new file mode 100644
index 0000000..1a13e6a
--- /dev/null
+++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/rent/RentPaymentControllerErrorTest.java
@@ -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));
+}
+}
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 579ea7f..4cacd14 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
@@ -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;
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 612d3f1..291a6be 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
@@ -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,
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 570739e..1880dae 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
@@ -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,
diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/domain/rent/RentPaymentTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/domain/rent/RentPaymentTest.java
index 968d750..dd9f582 100644
--- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/domain/rent/RentPaymentTest.java
+++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/domain/rent/RentPaymentTest.java
@@ -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) {
diff --git a/frontend/src/app/components/rent-payment-allocation-dialog/rent-payment-allocation-dialog.html b/frontend/src/app/components/rent-payment-allocation-dialog/rent-payment-allocation-dialog.html
index c06b965..5e6a8f7 100644
--- a/frontend/src/app/components/rent-payment-allocation-dialog/rent-payment-allocation-dialog.html
+++ b/frontend/src/app/components/rent-payment-allocation-dialog/rent-payment-allocation-dialog.html
@@ -8,7 +8,11 @@