Align rent payment dialog validation (#55)
This commit is contained in:
1 parent
cd32d05466
commit
96bd02e3a6
23 files changed
+723
-87
No files matched your search
+6
@@ -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,
|
||||
|
||||
+24
@@ -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) {
|
||||
|
||||
+28
-3
@@ -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));
|
||||
|
||||
+4
-2
@@ -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"));
|
||||
|
||||
+12
-2
@@ -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);
|
||||
|
||||
+22
@@ -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;
|
||||
}
|
||||
}
|
||||
+2
@@ -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.");
|
||||
}
|
||||
|
||||
+19
@@ -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());
|
||||
}
|
||||
}
|
||||
+64
@@ -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));
|
||||
}
|
||||
}
|
||||
+110
@@ -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;
|
||||
|
||||
|
||||
+15
-13
@@ -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,
|
||||
|
||||
+66
-13
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user