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 @@
Échéance cible - + @for (item of data.eligibleRentDues; track item.rentDue.id) { {{ item.label }} — @@ -16,19 +20,27 @@ } + @if (form.controls.rentDueId.invalid) { + Choisissez une autre échéance de ce bail. + } Montant à affecter € - @if (form.controls.amount.hasError('unavailableAmount')) { + @if ( + form.controls.amount.hasError('unavailableAmount') || + form.controls.amount.hasError('exceeds_payment_balance') || + form.controls.amount.hasError('exceeds_rent_due_balance') + ) { Le montant dépasse le surplus ou le solde de l’échéance. } @else if (form.controls.amount.invalid) { Le montant doit être strictement positif. @@ -47,7 +59,7 @@ - -
@@ -33,12 +41,7 @@ - -