feat: add rent payment history and reversal dialogs
- Implemented RentPaymentHistoryServiceTest to validate payment retrieval with refunds and reversals. - Created ReverseRentPaymentServiceTest to ensure correct handling of payment reversals and allocation releases. - Developed rent-payment-allocation-dialog component with form for surplus allocation. - Added rent-payment-refund-dialog component for processing refunds with validation. - Introduced rent-payment-reversal-dialog component for reversing payments with reason input. - Implemented utility functions for formatting Euro values and handling date formatting. - Enhanced rent-payment-dialogs.spec.ts to cover new dialog functionalities and error handling.
This commit is contained in:
1 parent
e02ad54bcb
commit
b9e99348f7
76 files changed
+3141
-1531
No files matched your search
+21
-30
@@ -15,14 +15,13 @@ import fr.nessar.lmnporganizer.rental.application.port.out.RentalDocumentRendere
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.TenantAttachmentStorage;
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.AllocateRentPaymentService;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.ConfirmRentPaymentRefundService;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.ConfirmRentPaymentService;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.GeneratePaymentReceiptService;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.GenerateRentReceiptService;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.RecordRentPaymentService;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.RefundRentPaymentService;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.ReverseRentPaymentService;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.RentPaymentDraftService;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.RentPaymentRefundDraftService;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.RentDueQueryService;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.RentPaymentHistoryService;
|
||||
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
|
||||
@@ -43,7 +42,7 @@ import java.time.Clock;
|
||||
public class RentalApplicationConfiguration {
|
||||
@Bean
|
||||
Clock rentalClock() {
|
||||
return Clock.systemDefaultZone();
|
||||
return Clock.systemUTC();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@@ -112,32 +111,17 @@ public class RentalApplicationConfiguration {
|
||||
}
|
||||
|
||||
@Bean
|
||||
RentPaymentDraftService rentPaymentDraftService(LeaseRepository leases, RentPaymentRepository payments,
|
||||
IdentifierGenerator identifiers) {
|
||||
return new RentPaymentDraftService(leases, payments, identifiers);
|
||||
}
|
||||
|
||||
@Bean
|
||||
RentPaymentRefundDraftService rentPaymentRefundDraftService(RentPaymentRepository payments,
|
||||
RentPaymentRefundRepository refunds,
|
||||
IdentifierGenerator identifiers) {
|
||||
return new RentPaymentRefundDraftService(payments, refunds, identifiers);
|
||||
}
|
||||
|
||||
@Bean
|
||||
ConfirmRentPaymentService confirmRentPaymentService(RentPaymentRepository payments,
|
||||
RentDueRepository rentDues,
|
||||
TransactionRunner transactions,
|
||||
IdentifierGenerator identifiers, Clock rentalClock) {
|
||||
return new ConfirmRentPaymentService(payments, rentDues, transactions, identifiers, rentalClock);
|
||||
RecordRentPaymentService recordRentPaymentService(RentPaymentRepository payments, RentDueRepository rentDues,
|
||||
TransactionRunner transactions, Clock rentalClock) {
|
||||
return new RecordRentPaymentService(payments, rentDues, transactions, rentalClock);
|
||||
}
|
||||
|
||||
@Bean
|
||||
AllocateRentPaymentService allocateRentPaymentService(RentPaymentRepository payments,
|
||||
RentDueRepository rentDues,
|
||||
TransactionRunner transactions,
|
||||
IdentifierGenerator identifiers, Clock rentalClock) {
|
||||
return new AllocateRentPaymentService(payments, rentDues, transactions, identifiers, rentalClock);
|
||||
Clock rentalClock) {
|
||||
return new AllocateRentPaymentService(payments, rentDues, transactions, rentalClock);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@@ -150,11 +134,18 @@ public class RentalApplicationConfiguration {
|
||||
}
|
||||
|
||||
@Bean
|
||||
ConfirmRentPaymentRefundService confirmRentPaymentRefundService(RentPaymentRepository payments,
|
||||
RentPaymentRefundRepository refunds,
|
||||
TransactionRunner transactions,
|
||||
Clock rentalClock) {
|
||||
return new ConfirmRentPaymentRefundService(payments, refunds, transactions, rentalClock);
|
||||
RefundRentPaymentService refundRentPaymentService(RentPaymentRepository payments,
|
||||
RentPaymentRefundRepository refunds,
|
||||
TransactionRunner transactions, Clock rentalClock) {
|
||||
return new RefundRentPaymentService(payments, refunds, transactions, rentalClock);
|
||||
}
|
||||
|
||||
@Bean
|
||||
RentPaymentHistoryService rentPaymentHistoryService(RentPaymentRepository payments,
|
||||
RentPaymentRefundRepository refunds,
|
||||
RentPaymentReversalRepository reversals,
|
||||
LeaseRepository leases) {
|
||||
return new RentPaymentHistoryService(payments, refunds, reversals, leases);
|
||||
}
|
||||
|
||||
@Bean
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
package fr.nessar.lmnporganizer.rental.adapter.in.web.common;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.RentPaymentConflict;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentStorageFailure;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentTooLarge;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
|
||||
@@ -28,7 +29,7 @@ public class RentalApiExceptionHandler {
|
||||
return error(HttpStatus.BAD_REQUEST, exception.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler({ReferencedResource.class, EmailAlreadyUsed.class})
|
||||
@ExceptionHandler({ReferencedResource.class, EmailAlreadyUsed.class, RentPaymentConflict.class})
|
||||
ResponseEntity<ApiError> conflict(RuntimeException exception) {
|
||||
return error(HttpStatus.CONFLICT, exception.getMessage());
|
||||
}
|
||||
|
||||
+20
-22
@@ -1,14 +1,14 @@
|
||||
package fr.nessar.lmnporganizer.rental.adapter.in.web.rent;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.RentPaymentHistory;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.PaymentAllocation;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPayment;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefund;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversal;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
@@ -18,26 +18,21 @@ final class RentPaymentApi {
|
||||
private RentPaymentApi() {
|
||||
}
|
||||
|
||||
record CreateRequest(@NotBlank String leaseId, @NotNull LocalDate receivedAt,
|
||||
record CreateRequest(@NotBlank String operationId, @NotBlank String rentDueId,
|
||||
@NotNull @PositiveOrZero Long rentDueVersion, @NotNull LocalDate receivedAt,
|
||||
@NotNull @Positive BigDecimal amount) {
|
||||
}
|
||||
|
||||
record ReviseRequest(@NotNull LocalDate receivedAt, @NotNull @Positive BigDecimal amount) {
|
||||
record AllocationRequest(@NotBlank String operationId, @NotNull @PositiveOrZero Long paymentVersion,
|
||||
@NotBlank String rentDueId, @NotNull @PositiveOrZero Long rentDueVersion,
|
||||
@NotNull @Positive BigDecimal amount) {
|
||||
}
|
||||
|
||||
record AllocationRequest(@NotBlank String rentDueId, @NotNull @Positive BigDecimal amount) {
|
||||
record ReversalRequest(@NotNull @PositiveOrZero Long paymentVersion, @NotBlank @Size(max = 500) String reason) {
|
||||
}
|
||||
|
||||
record ConfirmRequest(List<@Valid AllocationRequest> allocations) {
|
||||
ConfirmRequest {
|
||||
allocations = allocations == null ? List.of() : List.copyOf(allocations);
|
||||
}
|
||||
}
|
||||
|
||||
record ReversalRequest(@NotBlank String reason) {
|
||||
}
|
||||
|
||||
record RefundRequest(@NotNull @Positive BigDecimal amount, @NotNull LocalDate refundedAt) {
|
||||
record RefundRequest(@NotBlank String operationId, @NotNull @PositiveOrZero Long paymentVersion,
|
||||
@NotNull @Positive BigDecimal amount, @NotNull LocalDate refundedAt) {
|
||||
}
|
||||
|
||||
record AllocationResponse(String id, String rentDueId, BigDecimal amount, LocalDate allocatedAt) {
|
||||
@@ -49,21 +44,24 @@ final class RentPaymentApi {
|
||||
|
||||
record Response(String id, String leaseId, LocalDate receivedAt, BigDecimal amount, String status,
|
||||
List<AllocationResponse> allocations, BigDecimal allocatedAmount, BigDecimal refundedAmount,
|
||||
BigDecimal unallocatedBalance, LocalDate confirmedAt, long version) {
|
||||
static Response from(RentPayment payment) {
|
||||
BigDecimal unallocatedBalance, LocalDate confirmedAt, long version,
|
||||
List<RefundResponse> refunds, ReversalResponse reversal) {
|
||||
static Response from(RentPaymentHistory history) {
|
||||
var payment = history.payment();
|
||||
return new Response(payment.id().value(), payment.leaseId().value(), payment.receivedAt(),
|
||||
payment.amount(), payment.status().name(),
|
||||
payment.allocations().stream().map(AllocationResponse::from).toList(), payment.allocatedAmount(),
|
||||
payment.refundedAmount(), payment.unallocatedBalance(), payment.confirmedAt(), payment.version());
|
||||
payment.refundedAmount(), payment.unallocatedBalance(), payment.confirmedAt(), payment.version(),
|
||||
history.refunds().stream().map(RefundResponse::from).toList(),
|
||||
history.reversal().map(ReversalResponse::from).orElse(null));
|
||||
}
|
||||
}
|
||||
|
||||
record RefundResponse(String id, String leaseId, String paymentId, BigDecimal amount, LocalDate refundedAt,
|
||||
String status, LocalDate confirmedAt, long version) {
|
||||
long version) {
|
||||
static RefundResponse from(RentPaymentRefund refund) {
|
||||
return new RefundResponse(refund.id().value(), refund.leaseId().value(), refund.paymentId().value(),
|
||||
refund.amount(), refund.refundedAt(), refund.status().name(), refund.confirmedAt(),
|
||||
refund.version());
|
||||
refund.amount(), refund.refundedAt(), refund.version());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+41
-110
@@ -2,20 +2,16 @@ package fr.nessar.lmnporganizer.rental.adapter.in.web.rent;
|
||||
|
||||
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.ConfirmRentPaymentRefundService;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.ConfirmRentPaymentService;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.GeneratePaymentReceiptService;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.RentPaymentDraftService;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.RentPaymentRefundDraftService;
|
||||
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.ReverseRentPaymentService;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.GeneratedDocument;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefund;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefundId;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
@@ -23,11 +19,9 @@ 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.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
@@ -38,27 +32,24 @@ import java.util.List;
|
||||
@RestController("rentalRentPaymentController")
|
||||
@RequestMapping("/api/rent-payments")
|
||||
public class RentPaymentController {
|
||||
private final RentPaymentDraftService drafts;
|
||||
private final ConfirmRentPaymentService confirmation;
|
||||
private final RentPaymentHistoryService history;
|
||||
private final RecordRentPaymentService recording;
|
||||
private final AllocateRentPaymentService allocation;
|
||||
private final RefundRentPaymentService refunds;
|
||||
private final ReverseRentPaymentService reversal;
|
||||
private final RentPaymentRefundDraftService refundDrafts;
|
||||
private final ConfirmRentPaymentRefundService refundConfirmation;
|
||||
private final GeneratePaymentReceiptService receipts;
|
||||
private final AuthenticatedLandlord authenticatedLandlord;
|
||||
|
||||
public RentPaymentController(RentPaymentDraftService drafts, ConfirmRentPaymentService confirmation,
|
||||
AllocateRentPaymentService allocation, ReverseRentPaymentService reversal,
|
||||
RentPaymentRefundDraftService refundDrafts,
|
||||
ConfirmRentPaymentRefundService refundConfirmation,
|
||||
public RentPaymentController(RentPaymentHistoryService history, RecordRentPaymentService recording,
|
||||
AllocateRentPaymentService allocation, RefundRentPaymentService refunds,
|
||||
ReverseRentPaymentService reversal,
|
||||
GeneratePaymentReceiptService receipts,
|
||||
AuthenticatedLandlord authenticatedLandlord) {
|
||||
this.drafts = drafts;
|
||||
this.confirmation = confirmation;
|
||||
this.history = history;
|
||||
this.recording = recording;
|
||||
this.allocation = allocation;
|
||||
this.refunds = refunds;
|
||||
this.reversal = reversal;
|
||||
this.refundDrafts = refundDrafts;
|
||||
this.refundConfirmation = refundConfirmation;
|
||||
this.receipts = receipts;
|
||||
this.authenticatedLandlord = authenticatedLandlord;
|
||||
}
|
||||
@@ -67,67 +58,45 @@ public class RentPaymentController {
|
||||
List<RentPaymentApi.Response> list(@AuthenticationPrincipal OidcUser principal,
|
||||
@RequestParam(required = false) String leaseId) {
|
||||
LandlordId landlordId = authenticatedLandlord.id(principal);
|
||||
var payments = leaseId == null ? drafts.list(landlordId) : drafts.list(landlordId, new LeaseId(leaseId));
|
||||
return payments.stream().map(RentPaymentApi.Response::from).toList();
|
||||
return history.list(landlordId, leaseId == null ? null : new LeaseId(leaseId)).stream()
|
||||
.map(RentPaymentApi.Response::from).toList();
|
||||
}
|
||||
|
||||
@GetMapping("/{paymentId}")
|
||||
RentPaymentApi.Response get(@AuthenticationPrincipal OidcUser principal, @PathVariable String paymentId) {
|
||||
return RentPaymentApi.Response.from(drafts.get(authenticatedLandlord.id(principal),
|
||||
return RentPaymentApi.Response.from(history.get(authenticatedLandlord.id(principal),
|
||||
new RentPaymentId(paymentId)));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
ResponseEntity<RentPaymentApi.Response> create(@AuthenticationPrincipal OidcUser principal,
|
||||
@Valid @RequestBody RentPaymentApi.CreateRequest request) {
|
||||
var created = drafts.create(authenticatedLandlord.id(principal), new LeaseId(request.leaseId()),
|
||||
request.receivedAt(), request.amount());
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(RentPaymentApi.Response.from(created));
|
||||
}
|
||||
|
||||
@PutMapping("/{paymentId}")
|
||||
RentPaymentApi.Response revise(@AuthenticationPrincipal OidcUser principal, @PathVariable String paymentId,
|
||||
@Valid @RequestBody RentPaymentApi.ReviseRequest request) {
|
||||
return RentPaymentApi.Response.from(drafts.revise(authenticatedLandlord.id(principal),
|
||||
new RentPaymentId(paymentId), request.receivedAt(), request.amount()));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{paymentId}")
|
||||
ResponseEntity<Void> delete(@AuthenticationPrincipal OidcUser principal, @PathVariable String paymentId) {
|
||||
drafts.delete(authenticatedLandlord.id(principal), new RentPaymentId(paymentId));
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping("/{paymentId}/confirm")
|
||||
RentPaymentApi.Response confirm(@AuthenticationPrincipal OidcUser principal, @PathVariable String paymentId,
|
||||
@Valid @RequestBody RentPaymentApi.ConfirmRequest request) {
|
||||
var requested = request.allocations().stream().map(item -> new ConfirmRentPaymentService.RequestedAllocation(
|
||||
new RentDueId(item.rentDueId()), item.amount())).toList();
|
||||
return RentPaymentApi.Response.from(confirmation.confirm(authenticatedLandlord.id(principal),
|
||||
new RentPaymentId(paymentId), requested));
|
||||
LandlordId landlordId = authenticatedLandlord.id(principal);
|
||||
var created = recording.record(landlordId, request.operationId(), new RentDueId(request.rentDueId()),
|
||||
request.rentDueVersion(), request.receivedAt(), request.amount());
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(RentPaymentApi.Response.from(history.get(landlordId, created.id())));
|
||||
}
|
||||
|
||||
@PostMapping("/{paymentId}/allocations")
|
||||
RentPaymentApi.Response allocate(@AuthenticationPrincipal OidcUser principal, @PathVariable String paymentId,
|
||||
@Valid @RequestBody RentPaymentApi.AllocationRequest request) {
|
||||
return RentPaymentApi.Response.from(allocation.allocate(authenticatedLandlord.id(principal),
|
||||
new RentPaymentId(paymentId), new RentDueId(request.rentDueId()), request.amount()));
|
||||
LandlordId landlordId = authenticatedLandlord.id(principal);
|
||||
allocation.allocate(landlordId, new RentPaymentId(paymentId), request.operationId(),
|
||||
request.paymentVersion(), new RentDueId(request.rentDueId()), request.rentDueVersion(),
|
||||
request.amount());
|
||||
return RentPaymentApi.Response.from(history.get(landlordId, new RentPaymentId(paymentId)));
|
||||
}
|
||||
|
||||
@PostMapping("/{paymentId}/reversal")
|
||||
ResponseEntity<RentPaymentApi.ReversalResponse> reverse(@AuthenticationPrincipal OidcUser principal,
|
||||
@PathVariable String paymentId,
|
||||
@Valid @RequestBody RentPaymentApi.ReversalRequest request) {
|
||||
var reversed = reversal.reverse(authenticatedLandlord.id(principal), new RentPaymentId(paymentId),
|
||||
request.reason());
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(RentPaymentApi.ReversalResponse.from(reversed));
|
||||
}
|
||||
|
||||
@GetMapping("/{paymentId}/reversal")
|
||||
RentPaymentApi.ReversalResponse reversal(@AuthenticationPrincipal OidcUser principal,
|
||||
@PathVariable String paymentId) {
|
||||
return RentPaymentApi.ReversalResponse.from(reversal.get(authenticatedLandlord.id(principal),
|
||||
new RentPaymentId(paymentId)));
|
||||
ResponseEntity<RentPaymentApi.Response> reverse(@AuthenticationPrincipal OidcUser principal,
|
||||
@PathVariable String paymentId,
|
||||
@Valid @RequestBody RentPaymentApi.ReversalRequest request) {
|
||||
LandlordId landlordId = authenticatedLandlord.id(principal);
|
||||
RentPaymentId id = new RentPaymentId(paymentId);
|
||||
reversal.reverse(landlordId, id, request.paymentVersion(), request.reason());
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(RentPaymentApi.Response.from(history.get(landlordId,
|
||||
id)));
|
||||
}
|
||||
|
||||
@GetMapping("/{paymentId}/receipt")
|
||||
@@ -135,54 +104,16 @@ public class RentPaymentController {
|
||||
return document(receipts.generate(authenticatedLandlord.id(principal), new RentPaymentId(paymentId)));
|
||||
}
|
||||
|
||||
@GetMapping("/{paymentId}/refunds")
|
||||
List<RentPaymentApi.RefundResponse> refunds(@AuthenticationPrincipal OidcUser principal,
|
||||
@PathVariable String paymentId) {
|
||||
return refundDrafts.list(authenticatedLandlord.id(principal), new RentPaymentId(paymentId)).stream()
|
||||
.map(RentPaymentApi.RefundResponse::from).toList();
|
||||
}
|
||||
|
||||
@PostMapping("/{paymentId}/refunds")
|
||||
ResponseEntity<RentPaymentApi.RefundResponse> createRefund(@AuthenticationPrincipal OidcUser principal,
|
||||
@PathVariable String paymentId,
|
||||
@Valid @RequestBody RentPaymentApi.RefundRequest request) {
|
||||
var created = refundDrafts.create(authenticatedLandlord.id(principal), new RentPaymentId(paymentId),
|
||||
request.amount(), request.refundedAt());
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(RentPaymentApi.RefundResponse.from(created));
|
||||
}
|
||||
|
||||
@PutMapping("/{paymentId}/refunds/{refundId}")
|
||||
RentPaymentApi.RefundResponse reviseRefund(@AuthenticationPrincipal OidcUser principal,
|
||||
@PathVariable String paymentId, @PathVariable String refundId,
|
||||
@Valid @RequestBody RentPaymentApi.RefundRequest request) {
|
||||
ResponseEntity<RentPaymentApi.Response> refund(@AuthenticationPrincipal OidcUser principal,
|
||||
@PathVariable String paymentId,
|
||||
@Valid @RequestBody RentPaymentApi.RefundRequest request) {
|
||||
LandlordId landlordId = authenticatedLandlord.id(principal);
|
||||
requirePayment(refundDrafts.get(landlordId, new RentPaymentRefundId(refundId)), paymentId);
|
||||
return RentPaymentApi.RefundResponse.from(refundDrafts.revise(landlordId,
|
||||
new RentPaymentRefundId(refundId), request.amount(), request.refundedAt()));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{paymentId}/refunds/{refundId}")
|
||||
ResponseEntity<Void> deleteRefund(@AuthenticationPrincipal OidcUser principal,
|
||||
@PathVariable String paymentId, @PathVariable String refundId) {
|
||||
LandlordId landlordId = authenticatedLandlord.id(principal);
|
||||
requirePayment(refundDrafts.get(landlordId, new RentPaymentRefundId(refundId)), paymentId);
|
||||
refundDrafts.delete(landlordId, new RentPaymentRefundId(refundId));
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping("/{paymentId}/refunds/{refundId}/confirm")
|
||||
RentPaymentApi.RefundResponse confirmRefund(@AuthenticationPrincipal OidcUser principal,
|
||||
@PathVariable String paymentId, @PathVariable String refundId) {
|
||||
LandlordId landlordId = authenticatedLandlord.id(principal);
|
||||
requirePayment(refundDrafts.get(landlordId, new RentPaymentRefundId(refundId)), paymentId);
|
||||
return RentPaymentApi.RefundResponse.from(refundConfirmation.confirm(landlordId,
|
||||
new RentPaymentRefundId(refundId)));
|
||||
}
|
||||
|
||||
private static void requirePayment(RentPaymentRefund refund, String paymentId) {
|
||||
if (!refund.paymentId().equals(new RentPaymentId(paymentId))) {
|
||||
throw new ResourceNotFound("Rent payment refund");
|
||||
}
|
||||
RentPaymentId id = new RentPaymentId(paymentId);
|
||||
refunds.refund(landlordId, id, request.operationId(), request.paymentVersion(), request.amount(),
|
||||
request.refundedAt());
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(RentPaymentApi.Response.from(history.get(landlordId,
|
||||
id)));
|
||||
}
|
||||
|
||||
private static ResponseEntity<byte[]> document(GeneratedDocument document) {
|
||||
|
||||
+9
-22
@@ -7,7 +7,6 @@ import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentId;
|
||||
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.RentPaymentRefundStatus;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.annotation.Version;
|
||||
import org.springframework.data.mongodb.core.MongoTemplate;
|
||||
@@ -32,6 +31,12 @@ public class MongoRentPaymentRefundRepository implements RentPaymentRefundReposi
|
||||
this.mongoTemplate = mongoTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<RentPaymentRefund> findAll(LandlordId landlordId) {
|
||||
return mongoTemplate.find(Query.query(Criteria.where("landlordId").is(landlordId.value())),
|
||||
RefundDocument.class).stream().map(this::toDomain).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<RentPaymentRefund> findAll(LandlordId landlordId, RentPaymentId paymentId) {
|
||||
Query query = Query.query(Criteria.where("landlordId").is(landlordId.value())
|
||||
@@ -52,36 +57,20 @@ public class MongoRentPaymentRefundRepository implements RentPaymentRefundReposi
|
||||
refund.version(), document::withVersion));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deleteDraft(RentPaymentRefund refund) {
|
||||
Query query = scoped(refund.landlordId(), refund.id());
|
||||
query.addCriteria(Criteria.where("status").is(RentPaymentRefundStatus.DRAFT.name()));
|
||||
query.addCriteria(versionCriteria(refund.version()));
|
||||
return mongoTemplate.remove(query, RefundDocument.class).getDeletedCount() == 1;
|
||||
}
|
||||
|
||||
private static Query scoped(LandlordId landlordId, RentPaymentRefundId refundId) {
|
||||
return Query.query(Criteria.where("_id").is(refundId.value())
|
||||
.and("landlordId").is(landlordId.value()));
|
||||
}
|
||||
|
||||
private static Criteria versionCriteria(long version) {
|
||||
return version < 0
|
||||
? new Criteria().orOperator(Criteria.where("version").exists(false), Criteria.where("version").is(null))
|
||||
: Criteria.where("version").is(version);
|
||||
}
|
||||
|
||||
private RefundDocument toDocument(RentPaymentRefund refund) {
|
||||
return new RefundDocument(refund.id().value(), refund.landlordId().value(), refund.leaseId().value(),
|
||||
refund.paymentId().value(), refund.amount(), refund.refundedAt(), refund.status().name(),
|
||||
refund.confirmedAt(), persistedVersion(refund.version()));
|
||||
refund.paymentId().value(), refund.amount(), refund.refundedAt(), persistedVersion(refund.version()));
|
||||
}
|
||||
|
||||
private RentPaymentRefund toDomain(RefundDocument document) {
|
||||
return RentPaymentRefund.reconstitute(new RentPaymentRefundId(document.id()),
|
||||
new LandlordId(document.landlordId()), new LeaseId(document.leaseId()),
|
||||
new RentPaymentId(document.paymentId()), document.amount(), document.refundedAt(),
|
||||
RentPaymentRefundStatus.valueOf(document.status()), document.confirmedAt(),
|
||||
domainVersion(document.version()));
|
||||
}
|
||||
|
||||
@@ -96,11 +85,9 @@ public class MongoRentPaymentRefundRepository implements RentPaymentRefundReposi
|
||||
@Document(collection = "rentPaymentRefunds")
|
||||
record RefundDocument(@Id String id, @Indexed String landlordId, @Indexed String leaseId,
|
||||
@Indexed String paymentId, @Field(targetType = FieldType.DECIMAL128) BigDecimal amount,
|
||||
LocalDate refundedAt, String status,
|
||||
LocalDate confirmedAt, @Version Long version) {
|
||||
LocalDate refundedAt, @Version Long version) {
|
||||
RefundDocument withVersion(Long version) {
|
||||
return new RefundDocument(id, landlordId, leaseId, paymentId, amount, refundedAt, status, confirmedAt,
|
||||
version);
|
||||
return new RefundDocument(id, landlordId, leaseId, paymentId, amount, refundedAt, version);
|
||||
}
|
||||
}
|
||||
}
|
||||
-14
@@ -58,14 +58,6 @@ public class MongoRentPaymentRepository implements RentPaymentRepository {
|
||||
payment.version(), document::withVersion));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deleteDraft(RentPayment payment) {
|
||||
Query query = scoped(payment.landlordId(), payment.id());
|
||||
query.addCriteria(Criteria.where("status").is(RentPaymentStatus.DRAFT.name()));
|
||||
query.addCriteria(versionCriteria(payment.version()));
|
||||
return mongoTemplate.remove(query, RentPaymentDocument.class).getDeletedCount() == 1;
|
||||
}
|
||||
|
||||
private List<RentPayment> find(Query query) {
|
||||
return mongoTemplate.find(query, RentPaymentDocument.class).stream().map(this::toDomain).toList();
|
||||
}
|
||||
@@ -75,12 +67,6 @@ public class MongoRentPaymentRepository implements RentPaymentRepository {
|
||||
.and("landlordId").is(landlordId.value()));
|
||||
}
|
||||
|
||||
private static Criteria versionCriteria(long version) {
|
||||
return version < 0
|
||||
? new Criteria().orOperator(Criteria.where("version").exists(false), Criteria.where("version").is(null))
|
||||
: Criteria.where("version").is(version);
|
||||
}
|
||||
|
||||
private RentPaymentDocument toDocument(RentPayment payment) {
|
||||
return new RentPaymentDocument(payment.id().value(), payment.landlordId().value(), payment.leaseId().value(),
|
||||
payment.receivedAt(), payment.amount(), payment.status().name(),
|
||||
|
||||
+1
-2
@@ -46,8 +46,7 @@ public interface RentalDocumentRenderer {
|
||||
BigDecimal amount, LocalDate allocatedAt) {
|
||||
}
|
||||
|
||||
record ReceiptRefund(RentPaymentRefundId refundId, BigDecimal amount, LocalDate refundedAt,
|
||||
LocalDate confirmedAt) {
|
||||
record ReceiptRefund(RentPaymentRefundId refundId, BigDecimal amount, LocalDate refundedAt) {
|
||||
}
|
||||
|
||||
record RentReceipt(RentDueId rentDueId, YearMonth month, BigDecimal rent, BigDecimal charges,
|
||||
|
||||
+27
-8
@@ -1,7 +1,7 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rent;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.IdentifierGenerator;
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.PaymentAllocation;
|
||||
@@ -9,6 +9,7 @@ import fr.nessar.lmnporganizer.rental.domain.rent.PaymentAllocationId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDue;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueRepository;
|
||||
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;
|
||||
@@ -22,30 +23,48 @@ public final class AllocateRentPaymentService {
|
||||
private final RentPaymentRepository payments;
|
||||
private final RentDueRepository rentDues;
|
||||
private final TransactionRunner transactions;
|
||||
private final IdentifierGenerator identifiers;
|
||||
private final Clock clock;
|
||||
|
||||
public AllocateRentPaymentService(RentPaymentRepository payments, RentDueRepository rentDues,
|
||||
TransactionRunner transactions, IdentifierGenerator identifiers, Clock clock) {
|
||||
TransactionRunner transactions, Clock clock) {
|
||||
this.payments = payments;
|
||||
this.rentDues = rentDues;
|
||||
this.transactions = transactions;
|
||||
this.identifiers = identifiers;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public RentPayment allocate(LandlordId landlordId, RentPaymentId paymentId, RentDueId rentDueId,
|
||||
BigDecimal amount) {
|
||||
public RentPayment allocate(LandlordId landlordId, RentPaymentId paymentId, String operationId,
|
||||
long paymentVersion, RentDueId rentDueId, long rentDueVersion, BigDecimal amount) {
|
||||
return transactions.run(() -> {
|
||||
RentPayment payment = payments.find(landlordId, paymentId)
|
||||
.orElseThrow(() -> new ResourceNotFound("Rent payment"));
|
||||
PaymentAllocationId allocationId = new PaymentAllocationId(operationId);
|
||||
PaymentAllocation existing = payment.allocations().stream()
|
||||
.filter(allocation -> allocation.id().equals(allocationId)).findFirst().orElse(null);
|
||||
if (existing != null) {
|
||||
if (!existing.rentDueId().equals(rentDueId) || amount == null
|
||||
|| existing.amount().compareTo(amount) != 0) {
|
||||
throw new RentPaymentConflict(
|
||||
"Cet identifiant d'opération a déjà été utilisé avec d'autres données.");
|
||||
}
|
||||
return payment;
|
||||
}
|
||||
if (payment.version() != paymentVersion) {
|
||||
throw new ConcurrentChange("Rent payment");
|
||||
}
|
||||
RentDue due = rentDues.find(landlordId, rentDueId)
|
||||
.orElseThrow(() -> new ResourceNotFound("Rent due"));
|
||||
if (due.version() != rentDueVersion) {
|
||||
throw new ConcurrentChange("Rent due");
|
||||
}
|
||||
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.");
|
||||
}
|
||||
PaymentAllocation allocation = new PaymentAllocation(new PaymentAllocationId(identifiers.next()),
|
||||
due.id(), amount, LocalDate.now(clock));
|
||||
PaymentAllocation allocation = new PaymentAllocation(allocationId, due.id(), amount,
|
||||
LocalDate.now(clock));
|
||||
payment.allocate(allocation);
|
||||
due.allocate(amount);
|
||||
rentDues.save(due);
|
||||
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rent;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPayment;
|
||||
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 java.time.Clock;
|
||||
import java.time.LocalDate;
|
||||
|
||||
public final class ConfirmRentPaymentRefundService {
|
||||
private final RentPaymentRepository payments;
|
||||
private final RentPaymentRefundRepository refunds;
|
||||
private final TransactionRunner transactions;
|
||||
private final Clock clock;
|
||||
|
||||
public ConfirmRentPaymentRefundService(RentPaymentRepository payments, RentPaymentRefundRepository refunds,
|
||||
TransactionRunner transactions, Clock clock) {
|
||||
this.payments = payments;
|
||||
this.refunds = refunds;
|
||||
this.transactions = transactions;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public RentPaymentRefund confirm(LandlordId landlordId, RentPaymentRefundId refundId) {
|
||||
return transactions.run(() -> {
|
||||
RentPaymentRefund refund = refunds.find(landlordId, refundId)
|
||||
.orElseThrow(() -> new ResourceNotFound("Rent payment refund"));
|
||||
RentPayment payment = payments.find(landlordId, refund.paymentId())
|
||||
.orElseThrow(() -> new ResourceNotFound("Rent payment"));
|
||||
refund.confirm(payment, LocalDate.now(clock));
|
||||
payments.save(payment);
|
||||
return refunds.save(refund);
|
||||
});
|
||||
}
|
||||
}
|
||||
-74
@@ -1,74 +0,0 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rent;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.IdentifierGenerator;
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.PaymentAllocation;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.PaymentAllocationId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDue;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueRepository;
|
||||
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;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
|
||||
public final class ConfirmRentPaymentService {
|
||||
private final RentPaymentRepository payments;
|
||||
private final RentDueRepository rentDues;
|
||||
private final TransactionRunner transactions;
|
||||
private final IdentifierGenerator identifiers;
|
||||
private final Clock clock;
|
||||
|
||||
public ConfirmRentPaymentService(RentPaymentRepository payments, RentDueRepository rentDues,
|
||||
TransactionRunner transactions, IdentifierGenerator identifiers, Clock clock) {
|
||||
this.payments = payments;
|
||||
this.rentDues = rentDues;
|
||||
this.transactions = transactions;
|
||||
this.identifiers = identifiers;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public RentPayment confirm(LandlordId landlordId, RentPaymentId paymentId, List<RequestedAllocation> requested) {
|
||||
return transactions.run(() -> confirmInTransaction(landlordId, paymentId, requested));
|
||||
}
|
||||
|
||||
private RentPayment confirmInTransaction(LandlordId landlordId, RentPaymentId paymentId,
|
||||
List<RequestedAllocation> requested) {
|
||||
RentPayment payment = payments.find(landlordId, paymentId)
|
||||
.orElseThrow(() -> new ResourceNotFound("Rent payment"));
|
||||
LocalDate confirmationDate = LocalDate.now(clock);
|
||||
List<RequestedAllocation> allocationsToApply = requested == null ? List.of() : List.copyOf(requested);
|
||||
if (new HashSet<>(allocationsToApply.stream().map(RequestedAllocation::rentDueId).toList()).size()
|
||||
!= allocationsToApply.size()) {
|
||||
throw new RentRuleViolation("Un règlement ne peut avoir qu'une affectation par échéance.");
|
||||
}
|
||||
List<RentDue> affectedDues = new ArrayList<>();
|
||||
List<PaymentAllocation> allocations = new ArrayList<>();
|
||||
for (RequestedAllocation allocation : allocationsToApply) {
|
||||
RentDue due = rentDues.find(landlordId, allocation.rentDueId())
|
||||
.orElseThrow(() -> new ResourceNotFound("Rent due"));
|
||||
if (!payment.leaseId().equals(due.leaseId())) {
|
||||
throw new RentRuleViolation("Une affectation doit viser une échéance du même bail.");
|
||||
}
|
||||
due.allocate(allocation.amount());
|
||||
affectedDues.add(due);
|
||||
allocations.add(new PaymentAllocation(new PaymentAllocationId(identifiers.next()), due.id(),
|
||||
allocation.amount(), confirmationDate));
|
||||
}
|
||||
payment.confirm(allocations, confirmationDate);
|
||||
rentDues.saveAll(affectedDues);
|
||||
return payments.save(payment);
|
||||
}
|
||||
|
||||
public record RequestedAllocation(RentDueId rentDueId, BigDecimal amount) {
|
||||
}
|
||||
}
|
||||
+1
-4
@@ -17,7 +17,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.RentPaymentRefundRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefundStatus;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentStatus;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentRuleViolation;
|
||||
|
||||
@@ -50,9 +49,7 @@ public final class GeneratePaymentReceiptService {
|
||||
payment.amount(), payment.refundedAmount(), payment.unallocatedBalance(), contract,
|
||||
payment.allocations().stream().map(allocation -> allocation(allocation, landlordId)).toList(),
|
||||
refunds.findAll(landlordId, paymentId).stream()
|
||||
.filter(refund -> refund.status() == RentPaymentRefundStatus.CONFIRMED)
|
||||
.map(refund -> new ReceiptRefund(refund.id(), refund.amount(), refund.refundedAt(),
|
||||
refund.confirmedAt()))
|
||||
.map(refund -> new ReceiptRefund(refund.id(), refund.amount(), refund.refundedAt()))
|
||||
.toList());
|
||||
return new GeneratedDocument("recu-reglement-" + payment.id().value() + ".pdf", "application/pdf",
|
||||
renderer.renderPaymentReceipt(receipt));
|
||||
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rent;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.PaymentAllocation;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.PaymentAllocationId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDue;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueRepository;
|
||||
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;
|
||||
import java.util.List;
|
||||
|
||||
public final class RecordRentPaymentService {
|
||||
private final RentPaymentRepository payments;
|
||||
private final RentDueRepository rentDues;
|
||||
private final TransactionRunner transactions;
|
||||
private final Clock clock;
|
||||
|
||||
public RecordRentPaymentService(RentPaymentRepository payments, RentDueRepository rentDues,
|
||||
TransactionRunner transactions, Clock clock) {
|
||||
this.payments = payments;
|
||||
this.rentDues = rentDues;
|
||||
this.transactions = transactions;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public RentPayment record(LandlordId landlordId, String operationId, RentDueId rentDueId, long rentDueVersion,
|
||||
LocalDate receivedAt, BigDecimal amount) {
|
||||
RentPaymentId paymentId = new RentPaymentId(operationId);
|
||||
return transactions.run(() -> payments.find(landlordId, paymentId)
|
||||
.map(existing -> idempotentPayment(existing, rentDueId, receivedAt, amount))
|
||||
.orElseGet(() -> recordInTransaction(landlordId, paymentId, rentDueId, rentDueVersion, receivedAt,
|
||||
amount)));
|
||||
}
|
||||
|
||||
private RentPayment idempotentPayment(RentPayment existing, RentDueId rentDueId, LocalDate receivedAt,
|
||||
BigDecimal amount) {
|
||||
boolean sameInitialDue = existing.allocations().stream()
|
||||
.filter(allocation -> allocation.id().value().equals(existing.id().value()))
|
||||
.anyMatch(allocation -> allocation.rentDueId().equals(rentDueId));
|
||||
if (!existing.receivedAt().equals(receivedAt) || !sameAmount(existing.amount(), amount) || !sameInitialDue) {
|
||||
throw new RentPaymentConflict("Cet identifiant d'opération a déjà été utilisé avec d'autres données.");
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
private RentPayment recordInTransaction(LandlordId landlordId, RentPaymentId paymentId, RentDueId rentDueId,
|
||||
long rentDueVersion, LocalDate receivedAt, BigDecimal amount) {
|
||||
LocalDate today = LocalDate.now(clock);
|
||||
if (receivedAt == null || receivedAt.isAfter(today)) {
|
||||
throw new RentRuleViolation("La date de réception du règlement ne peut pas être future.");
|
||||
}
|
||||
RentDue due = rentDues.find(landlordId, rentDueId).orElseThrow(() -> new ResourceNotFound("Rent due"));
|
||||
if (due.version() != rentDueVersion) {
|
||||
throw new ConcurrentChange("Rent due");
|
||||
}
|
||||
if (due.settlement() == RentDueSettlement.PAID) {
|
||||
throw new RentPaymentConflict("Une échéance déjà réglée ne peut pas recevoir un nouveau règlement.");
|
||||
}
|
||||
|
||||
RentPayment payment = RentPayment.confirmed(paymentId, landlordId, due.leaseId(), receivedAt, amount,
|
||||
List.of(), today);
|
||||
BigDecimal allocationAmount = payment.unallocatedBalance().min(due.remainingAmount());
|
||||
PaymentAllocation allocation = new PaymentAllocation(new PaymentAllocationId(paymentId.value()), due.id(),
|
||||
allocationAmount, today);
|
||||
payment.allocate(allocation);
|
||||
due.allocate(allocationAmount);
|
||||
rentDues.save(due);
|
||||
return payments.save(payment);
|
||||
}
|
||||
|
||||
private static boolean sameAmount(BigDecimal first, BigDecimal second) {
|
||||
return second != null && first.compareTo(second) == 0;
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rent;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPayment;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentId;
|
||||
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 java.math.BigDecimal;
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDate;
|
||||
|
||||
public final class RefundRentPaymentService {
|
||||
private final RentPaymentRepository payments;
|
||||
private final RentPaymentRefundRepository refunds;
|
||||
private final TransactionRunner transactions;
|
||||
private final Clock clock;
|
||||
|
||||
public RefundRentPaymentService(RentPaymentRepository payments, RentPaymentRefundRepository refunds,
|
||||
TransactionRunner transactions, Clock clock) {
|
||||
this.payments = payments;
|
||||
this.refunds = refunds;
|
||||
this.transactions = transactions;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public RentPayment refund(LandlordId landlordId, RentPaymentId paymentId, String operationId,
|
||||
long paymentVersion, BigDecimal amount, LocalDate refundedAt) {
|
||||
RentPaymentRefundId refundId = new RentPaymentRefundId(operationId);
|
||||
return transactions.run(() -> refunds.find(landlordId, refundId)
|
||||
.map(existing -> idempotentPayment(landlordId, paymentId, amount, refundedAt, existing))
|
||||
.orElseGet(() -> refundInTransaction(landlordId, paymentId, refundId, paymentVersion, amount,
|
||||
refundedAt)));
|
||||
}
|
||||
|
||||
private RentPayment refundInTransaction(LandlordId landlordId, RentPaymentId paymentId,
|
||||
RentPaymentRefundId refundId, long paymentVersion, BigDecimal amount,
|
||||
LocalDate refundedAt) {
|
||||
LocalDate today = LocalDate.now(clock);
|
||||
if (refundedAt == null || refundedAt.isAfter(today)) {
|
||||
throw new RentRuleViolation("La date du remboursement ne peut pas être future.");
|
||||
}
|
||||
RentPayment payment = payment(landlordId, paymentId);
|
||||
if (payment.version() != paymentVersion) {
|
||||
throw new ConcurrentChange("Rent payment");
|
||||
}
|
||||
RentPaymentRefund refund = RentPaymentRefund.recorded(refundId, payment, amount, refundedAt);
|
||||
RentPayment saved = payments.save(payment);
|
||||
refunds.save(refund);
|
||||
return saved;
|
||||
}
|
||||
|
||||
private RentPayment idempotentPayment(LandlordId landlordId, RentPaymentId paymentId, BigDecimal amount,
|
||||
LocalDate refundedAt, RentPaymentRefund existing) {
|
||||
if (!existing.paymentId().equals(paymentId) || !existing.refundedAt().equals(refundedAt)
|
||||
|| amount == null || existing.amount().compareTo(amount) != 0) {
|
||||
throw new RentPaymentConflict("Cet identifiant d'opération a déjà été utilisé avec d'autres données.");
|
||||
}
|
||||
return payment(landlordId, paymentId);
|
||||
}
|
||||
|
||||
private RentPayment payment(LandlordId landlordId, RentPaymentId paymentId) {
|
||||
return payments.find(landlordId, paymentId).orElseThrow(() -> new ResourceNotFound("Rent payment"));
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rent;
|
||||
|
||||
public final class RentPaymentConflict extends RuntimeException {
|
||||
public RentPaymentConflict(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
-71
@@ -1,71 +0,0 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rent;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.IdentifierGenerator;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseStatus;
|
||||
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.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
public final class RentPaymentDraftService {
|
||||
private final LeaseRepository leases;
|
||||
private final RentPaymentRepository payments;
|
||||
private final IdentifierGenerator identifiers;
|
||||
|
||||
public RentPaymentDraftService(LeaseRepository leases, RentPaymentRepository payments,
|
||||
IdentifierGenerator identifiers) {
|
||||
this.leases = leases;
|
||||
this.payments = payments;
|
||||
this.identifiers = identifiers;
|
||||
}
|
||||
|
||||
public List<RentPayment> list(LandlordId landlordId) {
|
||||
return payments.findAll(landlordId);
|
||||
}
|
||||
|
||||
public List<RentPayment> list(LandlordId landlordId, LeaseId leaseId) {
|
||||
lease(landlordId, leaseId);
|
||||
return payments.findAll(landlordId, leaseId);
|
||||
}
|
||||
|
||||
public RentPayment get(LandlordId landlordId, RentPaymentId paymentId) {
|
||||
return payments.find(landlordId, paymentId).orElseThrow(() -> new ResourceNotFound("Rent payment"));
|
||||
}
|
||||
|
||||
public RentPayment create(LandlordId landlordId, LeaseId leaseId, LocalDate receivedAt, BigDecimal amount) {
|
||||
Lease lease = lease(landlordId, leaseId);
|
||||
if (lease.status() == LeaseStatus.DRAFT) {
|
||||
throw new RentRuleViolation("Un règlement ne peut pas être enregistré pour un bail en brouillon.");
|
||||
}
|
||||
return payments.save(RentPayment.draft(new RentPaymentId(identifiers.next()), landlordId, leaseId,
|
||||
receivedAt, amount));
|
||||
}
|
||||
|
||||
public RentPayment revise(LandlordId landlordId, RentPaymentId paymentId, LocalDate receivedAt,
|
||||
BigDecimal amount) {
|
||||
RentPayment payment = get(landlordId, paymentId);
|
||||
payment.revise(receivedAt, amount);
|
||||
return payments.save(payment);
|
||||
}
|
||||
|
||||
public void delete(LandlordId landlordId, RentPaymentId paymentId) {
|
||||
RentPayment payment = get(landlordId, paymentId);
|
||||
if (!payments.deleteDraft(payment)) {
|
||||
throw new ConcurrentChange("Rent payment");
|
||||
}
|
||||
}
|
||||
|
||||
private Lease lease(LandlordId landlordId, LeaseId leaseId) {
|
||||
return leases.find(landlordId, leaseId).orElseThrow(() -> new ResourceNotFound("Lease"));
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rent;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPayment;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefund;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversal;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
public record RentPaymentHistory(RentPayment payment, List<RentPaymentRefund> refunds,
|
||||
Optional<RentPaymentReversal> reversal) {
|
||||
public RentPaymentHistory {
|
||||
Objects.requireNonNull(payment);
|
||||
refunds = List.copyOf(refunds);
|
||||
reversal = Objects.requireNonNull(reversal);
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rent;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPayment;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefund;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefundRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversal;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversalRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public final class RentPaymentHistoryService {
|
||||
private final RentPaymentRepository payments;
|
||||
private final RentPaymentRefundRepository refunds;
|
||||
private final RentPaymentReversalRepository reversals;
|
||||
private final LeaseRepository leases;
|
||||
|
||||
public RentPaymentHistoryService(RentPaymentRepository payments, RentPaymentRefundRepository refunds,
|
||||
RentPaymentReversalRepository reversals, LeaseRepository leases) {
|
||||
this.payments = payments;
|
||||
this.refunds = refunds;
|
||||
this.reversals = reversals;
|
||||
this.leases = leases;
|
||||
}
|
||||
|
||||
public RentPaymentHistory get(LandlordId landlordId, RentPaymentId paymentId) {
|
||||
RentPayment payment = payments.find(landlordId, paymentId)
|
||||
.orElseThrow(() -> new ResourceNotFound("Rent payment"));
|
||||
return new RentPaymentHistory(payment, refunds.findAll(landlordId, paymentId),
|
||||
reversals.findByPayment(landlordId, paymentId));
|
||||
}
|
||||
|
||||
public List<RentPaymentHistory> list(LandlordId landlordId, LeaseId leaseId) {
|
||||
List<RentPayment> landlordPayments;
|
||||
if (leaseId == null) {
|
||||
landlordPayments = payments.findAll(landlordId);
|
||||
} else {
|
||||
leases.find(landlordId, leaseId).orElseThrow(() -> new ResourceNotFound("Lease"));
|
||||
landlordPayments = payments.findAll(landlordId, leaseId);
|
||||
}
|
||||
Map<RentPaymentId, List<RentPaymentRefund>> refundsByPayment = refunds.findAll(landlordId).stream()
|
||||
.collect(Collectors.groupingBy(RentPaymentRefund::paymentId));
|
||||
Map<RentPaymentId, RentPaymentReversal> reversalsByPayment = reversals.findAll(landlordId).stream()
|
||||
.collect(Collectors.toMap(RentPaymentReversal::paymentId, reversal -> reversal));
|
||||
return landlordPayments.stream().map(payment -> new RentPaymentHistory(payment,
|
||||
refundsByPayment.getOrDefault(payment.id(), List.of()),
|
||||
Optional.ofNullable(reversalsByPayment.get(payment.id())))).toList();
|
||||
}
|
||||
}
|
||||
-66
@@ -1,66 +0,0 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rent;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.IdentifierGenerator;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPayment;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentId;
|
||||
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 java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
public final class RentPaymentRefundDraftService {
|
||||
private final RentPaymentRepository payments;
|
||||
private final RentPaymentRefundRepository refunds;
|
||||
private final IdentifierGenerator identifiers;
|
||||
|
||||
public RentPaymentRefundDraftService(RentPaymentRepository payments, RentPaymentRefundRepository refunds,
|
||||
IdentifierGenerator identifiers) {
|
||||
this.payments = payments;
|
||||
this.refunds = refunds;
|
||||
this.identifiers = identifiers;
|
||||
}
|
||||
|
||||
public List<RentPaymentRefund> list(LandlordId landlordId, RentPaymentId paymentId) {
|
||||
payment(landlordId, paymentId);
|
||||
return refunds.findAll(landlordId, paymentId);
|
||||
}
|
||||
|
||||
public RentPaymentRefund get(LandlordId landlordId, RentPaymentRefundId refundId) {
|
||||
return refunds.find(landlordId, refundId)
|
||||
.orElseThrow(() -> new ResourceNotFound("Rent payment refund"));
|
||||
}
|
||||
|
||||
public RentPaymentRefund create(LandlordId landlordId, RentPaymentId paymentId, BigDecimal amount,
|
||||
LocalDate refundedAt) {
|
||||
RentPayment payment = payment(landlordId, paymentId);
|
||||
payment.ensureRefundable(amount);
|
||||
return refunds.save(RentPaymentRefund.draft(new RentPaymentRefundId(identifiers.next()), landlordId,
|
||||
payment.leaseId(), paymentId, amount, refundedAt));
|
||||
}
|
||||
|
||||
public RentPaymentRefund revise(LandlordId landlordId, RentPaymentRefundId refundId, BigDecimal amount,
|
||||
LocalDate refundedAt) {
|
||||
RentPaymentRefund refund = get(landlordId, refundId);
|
||||
payment(landlordId, refund.paymentId()).ensureRefundable(amount);
|
||||
refund.revise(amount, refundedAt);
|
||||
return refunds.save(refund);
|
||||
}
|
||||
|
||||
public void delete(LandlordId landlordId, RentPaymentRefundId refundId) {
|
||||
RentPaymentRefund refund = get(landlordId, refundId);
|
||||
if (!refunds.deleteDraft(refund)) {
|
||||
throw new ConcurrentChange("Rent payment refund");
|
||||
}
|
||||
}
|
||||
|
||||
private RentPayment payment(LandlordId landlordId, RentPaymentId paymentId) {
|
||||
return payments.find(landlordId, paymentId).orElseThrow(() -> new ResourceNotFound("Rent payment"));
|
||||
}
|
||||
}
|
||||
+16
-8
@@ -2,6 +2,7 @@ package fr.nessar.lmnporganizer.rental.application.rent;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.IdentifierGenerator;
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.PaymentAllocation;
|
||||
@@ -38,20 +39,20 @@ public final class ReverseRentPaymentService {
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public RentPaymentReversal reverse(LandlordId landlordId, RentPaymentId paymentId, String reason) {
|
||||
public RentPaymentReversal reverse(LandlordId landlordId, RentPaymentId paymentId, long paymentVersion,
|
||||
String reason) {
|
||||
return transactions.run(() -> reversals.findByPayment(landlordId, paymentId)
|
||||
.orElseGet(() -> reverseInTransaction(landlordId, paymentId, reason)));
|
||||
}
|
||||
|
||||
public RentPaymentReversal get(LandlordId landlordId, RentPaymentId paymentId) {
|
||||
return reversals.findByPayment(landlordId, paymentId)
|
||||
.orElseThrow(() -> new ResourceNotFound("Rent payment reversal"));
|
||||
.map(existing -> idempotentReversal(existing, reason))
|
||||
.orElseGet(() -> reverseInTransaction(landlordId, paymentId, paymentVersion, reason)));
|
||||
}
|
||||
|
||||
private RentPaymentReversal reverseInTransaction(LandlordId landlordId, RentPaymentId paymentId,
|
||||
String reason) {
|
||||
long paymentVersion, String reason) {
|
||||
RentPayment payment = payments.find(landlordId, paymentId)
|
||||
.orElseThrow(() -> new ResourceNotFound("Rent payment"));
|
||||
if (payment.version() != paymentVersion) {
|
||||
throw new ConcurrentChange("Rent payment");
|
||||
}
|
||||
RentPaymentReversal reversal = RentPaymentReversal.reverse(
|
||||
new RentPaymentReversalId(identifiers.next()), payment, LocalDate.now(clock), reason);
|
||||
List<RentDue> affectedDues = new ArrayList<>();
|
||||
@@ -65,4 +66,11 @@ public final class ReverseRentPaymentService {
|
||||
payments.save(payment);
|
||||
return reversals.save(reversal);
|
||||
}
|
||||
|
||||
private RentPaymentReversal idempotentReversal(RentPaymentReversal existing, String reason) {
|
||||
if (reason == null || !existing.reason().equals(reason.trim())) {
|
||||
throw new RentPaymentConflict("Ce règlement a déjà été contrepassé pour une autre raison.");
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
@@ -14,12 +14,12 @@ public final class RentPayment {
|
||||
private final RentPaymentId id;
|
||||
private final LandlordId landlordId;
|
||||
private final LeaseId leaseId;
|
||||
private LocalDate receivedAt;
|
||||
private BigDecimal amount;
|
||||
private final LocalDate receivedAt;
|
||||
private final BigDecimal amount;
|
||||
private RentPaymentStatus status;
|
||||
private List<PaymentAllocation> allocations;
|
||||
private BigDecimal refundedAmount;
|
||||
private LocalDate confirmedAt;
|
||||
private final LocalDate confirmedAt;
|
||||
private final long version;
|
||||
|
||||
private RentPayment(RentPaymentId id, LandlordId landlordId, LeaseId leaseId, LocalDate receivedAt,
|
||||
@@ -41,10 +41,11 @@ public final class RentPayment {
|
||||
validateState();
|
||||
}
|
||||
|
||||
public static RentPayment draft(RentPaymentId id, LandlordId landlordId, LeaseId leaseId, LocalDate receivedAt,
|
||||
BigDecimal amount) {
|
||||
return new RentPayment(id, landlordId, leaseId, receivedAt, amount, RentPaymentStatus.DRAFT, List.of(),
|
||||
BigDecimal.ZERO, null, -1);
|
||||
public static RentPayment confirmed(RentPaymentId id, LandlordId landlordId, LeaseId leaseId,
|
||||
LocalDate receivedAt, BigDecimal amount,
|
||||
List<PaymentAllocation> allocations, LocalDate confirmedAt) {
|
||||
return new RentPayment(id, landlordId, leaseId, receivedAt, amount, RentPaymentStatus.CONFIRMED,
|
||||
allocations, BigDecimal.ZERO, confirmedAt, -1);
|
||||
}
|
||||
|
||||
public static RentPayment reconstitute(RentPaymentId id, LandlordId landlordId, LeaseId leaseId,
|
||||
@@ -55,22 +56,6 @@ public final class RentPayment {
|
||||
confirmedAt, version);
|
||||
}
|
||||
|
||||
public void revise(LocalDate receivedAt, BigDecimal amount) {
|
||||
requireDraft();
|
||||
this.receivedAt = Objects.requireNonNull(receivedAt);
|
||||
this.amount = positive(amount, "Le montant du règlement doit être strictement positif.");
|
||||
}
|
||||
|
||||
public RentPaymentConfirmed confirm(List<PaymentAllocation> initialAllocations, LocalDate confirmationDate) {
|
||||
requireDraft();
|
||||
List<PaymentAllocation> requested = initialAllocations == null ? List.of() : List.copyOf(initialAllocations);
|
||||
validateAllocations(requested);
|
||||
allocations = requested;
|
||||
confirmedAt = Objects.requireNonNull(confirmationDate);
|
||||
status = RentPaymentStatus.CONFIRMED;
|
||||
return new RentPaymentConfirmed(id, landlordId, leaseId, amount, receivedAt, confirmedAt, allocations);
|
||||
}
|
||||
|
||||
public void allocate(PaymentAllocation allocation) {
|
||||
requireConfirmed();
|
||||
Objects.requireNonNull(allocation);
|
||||
@@ -112,11 +97,7 @@ public final class RentPayment {
|
||||
|
||||
private void validateState() {
|
||||
validateAllocations(allocations);
|
||||
if (status == RentPaymentStatus.DRAFT
|
||||
&& (!allocations.isEmpty() || refundedAmount.signum() != 0 || confirmedAt != null)) {
|
||||
throw new RentRuleViolation("Un règlement en brouillon ne peut pas avoir d'écritures confirmées.");
|
||||
}
|
||||
if (status != RentPaymentStatus.DRAFT && confirmedAt == null) {
|
||||
if (confirmedAt == null) {
|
||||
throw new RentRuleViolation("Un règlement confirmé doit conserver sa date de confirmation.");
|
||||
}
|
||||
if (unallocatedBalance().signum() < 0) {
|
||||
@@ -138,12 +119,6 @@ public final class RentPayment {
|
||||
}
|
||||
}
|
||||
|
||||
private void requireDraft() {
|
||||
if (status != RentPaymentStatus.DRAFT) {
|
||||
throw new RentRuleViolation("Seul un règlement en brouillon peut être modifié.");
|
||||
}
|
||||
}
|
||||
|
||||
private void requireConfirmed() {
|
||||
if (status != RentPaymentStatus.CONFIRMED) {
|
||||
throw new RentRuleViolation("Le règlement doit être confirmé et non contrepassé.");
|
||||
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.rent;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
public record RentPaymentConfirmed(RentPaymentId paymentId, LandlordId landlordId, LeaseId leaseId,
|
||||
BigDecimal amount, LocalDate receivedAt, LocalDate confirmedAt,
|
||||
List<PaymentAllocation> allocations) {
|
||||
public RentPaymentConfirmed {
|
||||
allocations = List.copyOf(allocations);
|
||||
}
|
||||
}
|
||||
+10
-45
@@ -12,70 +12,37 @@ public final class RentPaymentRefund {
|
||||
private final LandlordId landlordId;
|
||||
private final LeaseId leaseId;
|
||||
private final RentPaymentId paymentId;
|
||||
private BigDecimal amount;
|
||||
private LocalDate refundedAt;
|
||||
private RentPaymentRefundStatus status;
|
||||
private LocalDate confirmedAt;
|
||||
private final BigDecimal amount;
|
||||
private final LocalDate refundedAt;
|
||||
private final long version;
|
||||
|
||||
private RentPaymentRefund(RentPaymentRefundId id, LandlordId landlordId, LeaseId leaseId,
|
||||
RentPaymentId paymentId, BigDecimal amount, LocalDate refundedAt,
|
||||
RentPaymentRefundStatus status, LocalDate confirmedAt, long version) {
|
||||
long version) {
|
||||
this.id = Objects.requireNonNull(id);
|
||||
this.landlordId = Objects.requireNonNull(landlordId);
|
||||
this.leaseId = Objects.requireNonNull(leaseId);
|
||||
this.paymentId = Objects.requireNonNull(paymentId);
|
||||
this.amount = positive(amount);
|
||||
this.refundedAt = Objects.requireNonNull(refundedAt);
|
||||
this.status = Objects.requireNonNull(status);
|
||||
this.confirmedAt = confirmedAt;
|
||||
if (version < -1) {
|
||||
throw new IllegalArgumentException("Rent payment refund version cannot be less than -1.");
|
||||
}
|
||||
this.version = version;
|
||||
if ((status == RentPaymentRefundStatus.DRAFT && confirmedAt != null)
|
||||
|| (status == RentPaymentRefundStatus.CONFIRMED && confirmedAt == null)) {
|
||||
throw new RentRuleViolation("Le statut du remboursement ne correspond pas à sa confirmation.");
|
||||
}
|
||||
}
|
||||
|
||||
public static RentPaymentRefund draft(RentPaymentRefundId id, LandlordId landlordId, LeaseId leaseId,
|
||||
RentPaymentId paymentId, BigDecimal amount, LocalDate refundedAt) {
|
||||
return new RentPaymentRefund(id, landlordId, leaseId, paymentId, amount, refundedAt,
|
||||
RentPaymentRefundStatus.DRAFT, null, -1);
|
||||
public static RentPaymentRefund recorded(RentPaymentRefundId id, RentPayment payment, BigDecimal amount,
|
||||
LocalDate refundedAt) {
|
||||
Objects.requireNonNull(payment);
|
||||
payment.registerRefund(amount);
|
||||
return new RentPaymentRefund(id, payment.landlordId(), payment.leaseId(), payment.id(), amount, refundedAt,
|
||||
-1);
|
||||
}
|
||||
|
||||
public static RentPaymentRefund reconstitute(RentPaymentRefundId id, LandlordId landlordId, LeaseId leaseId,
|
||||
RentPaymentId paymentId, BigDecimal amount, LocalDate refundedAt,
|
||||
RentPaymentRefundStatus status, LocalDate confirmedAt,
|
||||
long version) {
|
||||
return new RentPaymentRefund(id, landlordId, leaseId, paymentId, amount, refundedAt, status, confirmedAt,
|
||||
version);
|
||||
}
|
||||
|
||||
public void revise(BigDecimal amount, LocalDate refundedAt) {
|
||||
requireDraft();
|
||||
this.amount = positive(amount);
|
||||
this.refundedAt = Objects.requireNonNull(refundedAt);
|
||||
}
|
||||
|
||||
public RentPaymentRefunded confirm(RentPayment payment, LocalDate confirmationDate) {
|
||||
requireDraft();
|
||||
Objects.requireNonNull(payment);
|
||||
if (!landlordId.equals(payment.landlordId()) || !leaseId.equals(payment.leaseId())
|
||||
|| !paymentId.equals(payment.id())) {
|
||||
throw new RentRuleViolation("Le remboursement et le règlement doivent appartenir au même bail.");
|
||||
}
|
||||
payment.registerRefund(amount);
|
||||
confirmedAt = Objects.requireNonNull(confirmationDate);
|
||||
status = RentPaymentRefundStatus.CONFIRMED;
|
||||
return new RentPaymentRefunded(id, landlordId, leaseId, paymentId, amount, refundedAt, confirmedAt);
|
||||
}
|
||||
|
||||
private void requireDraft() {
|
||||
if (status != RentPaymentRefundStatus.DRAFT) {
|
||||
throw new RentRuleViolation("Un remboursement confirmé est immuable.");
|
||||
}
|
||||
return new RentPaymentRefund(id, landlordId, leaseId, paymentId, amount, refundedAt, version);
|
||||
}
|
||||
|
||||
private static BigDecimal positive(BigDecimal amount) {
|
||||
@@ -92,7 +59,5 @@ public final class RentPaymentRefund {
|
||||
public RentPaymentId paymentId() { return paymentId; }
|
||||
public BigDecimal amount() { return amount; }
|
||||
public LocalDate refundedAt() { return refundedAt; }
|
||||
public RentPaymentRefundStatus status() { return status; }
|
||||
public LocalDate confirmedAt() { return confirmedAt; }
|
||||
public long version() { return version; }
|
||||
}
|
||||
+2
-2
@@ -6,11 +6,11 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface RentPaymentRefundRepository {
|
||||
List<RentPaymentRefund> findAll(LandlordId landlordId);
|
||||
|
||||
List<RentPaymentRefund> findAll(LandlordId landlordId, RentPaymentId paymentId);
|
||||
|
||||
Optional<RentPaymentRefund> find(LandlordId landlordId, RentPaymentRefundId refundId);
|
||||
|
||||
RentPaymentRefund save(RentPaymentRefund refund);
|
||||
|
||||
boolean deleteDraft(RentPaymentRefund refund);
|
||||
}
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.rent;
|
||||
|
||||
public enum RentPaymentRefundStatus {
|
||||
DRAFT,
|
||||
CONFIRMED
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.rent;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record RentPaymentRefunded(RentPaymentRefundId refundId, LandlordId landlordId, LeaseId leaseId,
|
||||
RentPaymentId paymentId, BigDecimal amount, LocalDate refundedAt,
|
||||
LocalDate confirmedAt) {
|
||||
}
|
||||
-2
@@ -14,6 +14,4 @@ public interface RentPaymentRepository {
|
||||
Optional<RentPayment> find(LandlordId landlordId, RentPaymentId paymentId);
|
||||
|
||||
RentPayment save(RentPayment payment);
|
||||
|
||||
boolean deleteDraft(RentPayment payment);
|
||||
}
|
||||
+14
-6
@@ -22,17 +22,25 @@ public record RentPaymentReversal(
|
||||
Objects.requireNonNull(leaseId, "Lease ID is required.");
|
||||
Objects.requireNonNull(paymentId, "Rent payment ID is required.");
|
||||
Objects.requireNonNull(reversedAt, "Reversal date is required.");
|
||||
if (reason == null || reason.isBlank()) {
|
||||
throw new RentRuleViolation("La raison de la contrepassation est obligatoire.");
|
||||
}
|
||||
reason = reason.trim();
|
||||
reason = validatedReason(reason);
|
||||
reversedAllocations = List.copyOf(reversedAllocations);
|
||||
}
|
||||
|
||||
public static RentPaymentReversal reverse(RentPaymentReversalId id, RentPayment payment, LocalDate reversedAt,
|
||||
String reason) {
|
||||
Objects.requireNonNull(payment);
|
||||
return new RentPaymentReversal(id, payment.landlordId(), payment.leaseId(), payment.id(), reversedAt, reason,
|
||||
payment.reverse());
|
||||
return new RentPaymentReversal(id, payment.landlordId(), payment.leaseId(), payment.id(), reversedAt,
|
||||
validatedReason(reason), payment.reverse());
|
||||
}
|
||||
|
||||
private static String validatedReason(String reason) {
|
||||
if (reason == null || reason.isBlank()) {
|
||||
throw new RentRuleViolation("La raison de la contrepassation est obligatoire.");
|
||||
}
|
||||
String validated = reason.trim();
|
||||
if (validated.length() > 500) {
|
||||
throw new RentRuleViolation("La raison de la contrepassation ne peut pas dépasser 500 caractères.");
|
||||
}
|
||||
return validated;
|
||||
}
|
||||
}
|
||||
-1
@@ -1,7 +1,6 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.rent;
|
||||
|
||||
public enum RentPaymentStatus {
|
||||
DRAFT,
|
||||
CONFIRMED,
|
||||
REVERSED
|
||||
}
|
||||
@@ -37,15 +37,14 @@
|
||||
</table>
|
||||
<h2>Remboursements</h2>
|
||||
<table>
|
||||
<thead><tr><th>Référence</th><th>Montant</th><th>Date</th><th>Confirmé le</th></tr></thead>
|
||||
<thead><tr><th>Référence</th><th>Montant</th><th>Date</th></tr></thead>
|
||||
<tbody>
|
||||
<tr th:each="refund : ${document.refunds}">
|
||||
<td th:text="${refund.refundId.value}">refund-1</td>
|
||||
<td><span th:text="${refund.amount}">100.00</span> EUR</td>
|
||||
<td th:text="${refund.refundedAt}">2026-07-06</td>
|
||||
<td th:text="${refund.confirmedAt}">2026-07-07</td>
|
||||
</tr>
|
||||
<tr th:if="${document.refunds.empty}"><td colspan="4">Aucun remboursement</td></tr>
|
||||
<tr th:if="${document.refunds.empty}"><td colspan="3">Aucun remboursement</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package fr.nessar.lmnporganizer.rental.adapter.in.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.ZoneOffset;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class RentalApplicationConfigurationTest {
|
||||
@Test
|
||||
void rentalClockUsesUtc() {
|
||||
assertEquals(ZoneOffset.UTC, new RentalApplicationConfiguration().rentalClock().getZone());
|
||||
}
|
||||
}
|
||||
+79
-49
@@ -16,13 +16,15 @@ import fr.nessar.lmnporganizer.rental.application.lease.ArchiveLeaseService;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.GenerateLeaseDocumentService;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.LeaseDraftService;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.AllocateRentPaymentService;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.ConfirmRentPaymentRefundService;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.ConfirmRentPaymentService;
|
||||
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.RentDueQueryService;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.RentPaymentDraftService;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.RentPaymentRefundDraftService;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.RentPaymentConflict;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.RentPaymentHistory;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.RentPaymentHistoryService;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.ReverseRentPaymentService;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ReferencedResource;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
|
||||
import fr.nessar.lmnporganizer.rental.application.tenant.DeleteTenantService;
|
||||
@@ -48,7 +50,6 @@ import fr.nessar.lmnporganizer.rental.domain.rent.RentPayment;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefund;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefundId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefundStatus;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversal;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversalId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantAttachment;
|
||||
@@ -75,7 +76,6 @@ import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.times;
|
||||
@@ -130,17 +130,15 @@ class RentalWebAdaptersTest {
|
||||
@MockitoBean
|
||||
private RentDueQueryService rentDues;
|
||||
@MockitoBean
|
||||
private RentPaymentDraftService paymentDrafts;
|
||||
private RentPaymentHistoryService paymentHistory;
|
||||
@MockitoBean
|
||||
private ConfirmRentPaymentService paymentConfirmation;
|
||||
private RecordRentPaymentService paymentRecording;
|
||||
@MockitoBean
|
||||
private AllocateRentPaymentService paymentAllocation;
|
||||
@MockitoBean
|
||||
private ReverseRentPaymentService paymentReversal;
|
||||
@MockitoBean
|
||||
private RentPaymentRefundDraftService refundDrafts;
|
||||
@MockitoBean
|
||||
private ConfirmRentPaymentRefundService refundConfirmation;
|
||||
private RefundRentPaymentService paymentRefunds;
|
||||
@MockitoBean
|
||||
private GeneratePaymentReceiptService paymentReceipts;
|
||||
@MockitoBean
|
||||
@@ -276,62 +274,64 @@ class RentalWebAdaptersTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void rentPaymentCommandsDelegateScopedConfirmationAllocationRefundAndReversal() throws Exception {
|
||||
void rentPaymentCommandsDelegateScopedRecordingAllocationRefundAndReversal() throws Exception {
|
||||
PaymentAllocation allocation = new PaymentAllocation(new PaymentAllocationId("allocation-1"), DUE_ID,
|
||||
new BigDecimal("900"), LocalDate.of(2026, 2, 3));
|
||||
RentPayment payment = RentPayment.draft(PAYMENT_ID, LANDLORD_ID, LEASE_ID,
|
||||
LocalDate.of(2026, 2, 2), new BigDecimal("1000"));
|
||||
payment.confirm(List.of(allocation), LocalDate.of(2026, 2, 3));
|
||||
RentPaymentRefund draftRefund = RentPaymentRefund.draft(REFUND_ID, LANDLORD_ID, LEASE_ID, PAYMENT_ID,
|
||||
new BigDecimal("50"), LocalDate.of(2026, 2, 4));
|
||||
RentPaymentRefund confirmedRefund = RentPaymentRefund.reconstitute(REFUND_ID, LANDLORD_ID, LEASE_ID,
|
||||
PAYMENT_ID, new BigDecimal("50"), LocalDate.of(2026, 2, 4), RentPaymentRefundStatus.CONFIRMED,
|
||||
LocalDate.of(2026, 2, 5), 3);
|
||||
RentPayment payment = RentPayment.reconstitute(PAYMENT_ID, LANDLORD_ID, LEASE_ID,
|
||||
LocalDate.of(2026, 2, 2), new BigDecimal("1000"),
|
||||
fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentStatus.CONFIRMED, List.of(allocation),
|
||||
new BigDecimal("50"), LocalDate.of(2026, 2, 3), 7);
|
||||
RentPaymentRefund refund = RentPaymentRefund.reconstitute(REFUND_ID, LANDLORD_ID, LEASE_ID,
|
||||
PAYMENT_ID, new BigDecimal("50"), LocalDate.of(2026, 2, 4), 3);
|
||||
RentPaymentReversal reversed = new RentPaymentReversal(new RentPaymentReversalId("reversal-1"), LANDLORD_ID,
|
||||
LEASE_ID, PAYMENT_ID, LocalDate.of(2026, 2, 6), "Erreur bancaire", List.of(allocation));
|
||||
when(paymentConfirmation.confirm(eq(LANDLORD_ID), eq(PAYMENT_ID), anyList())).thenReturn(payment);
|
||||
when(paymentAllocation.allocate(LANDLORD_ID, PAYMENT_ID, DUE_ID, new BigDecimal("50")))
|
||||
RentPaymentHistory history = new RentPaymentHistory(payment, List.of(refund), java.util.Optional.of(reversed));
|
||||
when(paymentRecording.record(LANDLORD_ID, "payment-1", DUE_ID, 2,
|
||||
LocalDate.of(2026, 2, 2), new BigDecimal("1000"))).thenReturn(payment);
|
||||
when(paymentAllocation.allocate(LANDLORD_ID, PAYMENT_ID, "allocation-2", 7, DUE_ID, 2,
|
||||
new BigDecimal("50")))
|
||||
.thenReturn(payment);
|
||||
when(refundDrafts.create(LANDLORD_ID, PAYMENT_ID, new BigDecimal("50"), LocalDate.of(2026, 2, 4)))
|
||||
.thenReturn(draftRefund);
|
||||
when(refundDrafts.get(LANDLORD_ID, REFUND_ID)).thenReturn(draftRefund);
|
||||
when(refundConfirmation.confirm(LANDLORD_ID, REFUND_ID)).thenReturn(confirmedRefund);
|
||||
when(paymentReversal.reverse(LANDLORD_ID, PAYMENT_ID, "Erreur bancaire")).thenReturn(reversed);
|
||||
when(paymentReversal.get(LANDLORD_ID, PAYMENT_ID)).thenReturn(reversed);
|
||||
when(paymentRefunds.refund(LANDLORD_ID, PAYMENT_ID, "refund-1", 7, new BigDecimal("50"),
|
||||
LocalDate.of(2026, 2, 4))).thenReturn(payment);
|
||||
when(paymentReversal.reverse(LANDLORD_ID, PAYMENT_ID, 7, "Erreur bancaire")).thenReturn(reversed);
|
||||
when(paymentHistory.get(LANDLORD_ID, PAYMENT_ID)).thenReturn(history);
|
||||
|
||||
mockMvc.perform(post("/api/rent-payments/payment-1/confirm").with(asLandlord()).with(csrf())
|
||||
mockMvc.perform(post("/api/rent-payments").with(asLandlord()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"allocations\":[{\"rentDueId\":\"due-1\",\"amount\":900}]}"))
|
||||
.andExpect(status().isOk())
|
||||
.content("""
|
||||
{"operationId":"payment-1","rentDueId":"due-1","rentDueVersion":2,
|
||||
"receivedAt":"2026-02-02","amount":1000}
|
||||
"""))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.status").value("CONFIRMED"));
|
||||
mockMvc.perform(post("/api/rent-payments/payment-1/allocations").with(asLandlord()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"rentDueId\":\"due-1\",\"amount\":50}"))
|
||||
.content("""
|
||||
{"operationId":"allocation-2","paymentVersion":7,"rentDueId":"due-1",
|
||||
"rentDueVersion":2,"amount":50}
|
||||
"""))
|
||||
.andExpect(status().isOk());
|
||||
mockMvc.perform(post("/api/rent-payments/payment-1/refunds").with(asLandlord()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"amount\":50,\"refundedAt\":\"2026-02-04\"}"))
|
||||
.content("""
|
||||
{"operationId":"refund-1","paymentVersion":7,"amount":50,
|
||||
"refundedAt":"2026-02-04"}
|
||||
"""))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.status").value("DRAFT"));
|
||||
mockMvc.perform(post("/api/rent-payments/payment-1/refunds/refund-1/confirm").with(asLandlord()).with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.status").value("CONFIRMED"));
|
||||
.andExpect(jsonPath("$.refunds[0].id").value("refund-1"));
|
||||
mockMvc.perform(post("/api/rent-payments/payment-1/reversal").with(asLandlord()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"reason\":\"Erreur bancaire\"}"))
|
||||
.content("{\"paymentVersion\":7,\"reason\":\"Erreur bancaire\"}"))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.reason").value("Erreur bancaire"));
|
||||
mockMvc.perform(get("/api/rent-payments/payment-1/reversal").with(asLandlord()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.reason").value("Erreur bancaire"));
|
||||
.andExpect(jsonPath("$.reversal.reason").value("Erreur bancaire"));
|
||||
|
||||
verify(paymentConfirmation).confirm(LANDLORD_ID, PAYMENT_ID, List.of(
|
||||
new ConfirmRentPaymentService.RequestedAllocation(DUE_ID, new BigDecimal("900"))));
|
||||
verify(paymentAllocation).allocate(LANDLORD_ID, PAYMENT_ID, DUE_ID, new BigDecimal("50"));
|
||||
verify(refundDrafts).create(LANDLORD_ID, PAYMENT_ID, new BigDecimal("50"), LocalDate.of(2026, 2, 4));
|
||||
verify(refundConfirmation).confirm(LANDLORD_ID, REFUND_ID);
|
||||
verify(paymentReversal).reverse(LANDLORD_ID, PAYMENT_ID, "Erreur bancaire");
|
||||
verify(paymentReversal).get(LANDLORD_ID, PAYMENT_ID);
|
||||
verify(paymentRecording).record(LANDLORD_ID, "payment-1", DUE_ID, 2,
|
||||
LocalDate.of(2026, 2, 2), new BigDecimal("1000"));
|
||||
verify(paymentAllocation).allocate(LANDLORD_ID, PAYMENT_ID, "allocation-2", 7, DUE_ID, 2,
|
||||
new BigDecimal("50"));
|
||||
verify(paymentRefunds).refund(LANDLORD_ID, PAYMENT_ID, "refund-1", 7, new BigDecimal("50"),
|
||||
LocalDate.of(2026, 2, 4));
|
||||
verify(paymentReversal).reverse(LANDLORD_ID, PAYMENT_ID, 7, "Erreur bancaire");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -340,6 +340,10 @@ class RentalWebAdaptersTest {
|
||||
.thenThrow(new ResourceNotFound("Apartment"));
|
||||
doThrow(new ReferencedResource("Apartment")).when(apartments)
|
||||
.delete(LANDLORD_ID, APARTMENT_ID);
|
||||
when(paymentRecording.record(eq(LANDLORD_ID), eq("stale-operation"), eq(DUE_ID), eq(2L), any(), any()))
|
||||
.thenThrow(new ConcurrentChange("Rent due"));
|
||||
when(paymentRecording.record(eq(LANDLORD_ID), eq("paid-operation"), eq(DUE_ID), eq(2L), any(), any()))
|
||||
.thenThrow(new RentPaymentConflict("Échéance déjà réglée"));
|
||||
|
||||
mockMvc.perform(get("/api/apartments/missing").with(asLandlord()))
|
||||
.andExpect(status().isNotFound());
|
||||
@@ -349,6 +353,32 @@ class RentalWebAdaptersTest {
|
||||
.andExpect(status().isBadRequest());
|
||||
mockMvc.perform(delete("/api/apartments/apartment-1").with(asLandlord()).with(csrf()))
|
||||
.andExpect(status().isConflict());
|
||||
mockMvc.perform(post("/api/rent-payments").with(asLandlord()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content("""
|
||||
{"operationId":"stale-operation","rentDueId":"due-1","rentDueVersion":2,
|
||||
"receivedAt":"2026-02-02","amount":1000}
|
||||
"""))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.message").isNotEmpty());
|
||||
mockMvc.perform(post("/api/rent-payments").with(asLandlord()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content("""
|
||||
{"operationId":"paid-operation","rentDueId":"due-1","rentDueVersion":2,
|
||||
"receivedAt":"2026-02-02","amount":1000}
|
||||
"""))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.message").value("Échéance déjà réglée"));
|
||||
mockMvc.perform(post("/api/rent-payments").with(asLandlord()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content("""
|
||||
{"operationId":"missing-version","rentDueId":"due-1",
|
||||
"receivedAt":"2026-02-02","amount":1000}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.message").isNotEmpty());
|
||||
mockMvc.perform(post("/api/rent-payments/payment-1/reversal").with(asLandlord()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"paymentVersion\":0,\"reason\":\"" + "a".repeat(501) + "\"}"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.message").isNotEmpty());
|
||||
}
|
||||
|
||||
private static RequestPostProcessor asLandlord() {
|
||||
|
||||
+5
-5
@@ -118,9 +118,8 @@ class RentalMongoTransactionIT {
|
||||
RentPaymentId paymentId = new RentPaymentId("payment-1");
|
||||
rentDues.save(RentDue.create(dueId, landlordId, leaseId, YearMonth.of(2026, 1),
|
||||
LocalDate.of(2026, 2, 5), new BigDecimal("900"), new BigDecimal("50")));
|
||||
RentPayment payment = RentPayment.draft(paymentId, landlordId, leaseId, LocalDate.of(2026, 1, 5),
|
||||
new BigDecimal("1000"));
|
||||
payment.confirm(List.of(), LocalDate.of(2026, 1, 6));
|
||||
RentPayment payment = RentPayment.confirmed(paymentId, landlordId, leaseId, LocalDate.of(2026, 1, 5),
|
||||
new BigDecimal("1000"), List.of(), LocalDate.of(2026, 1, 6));
|
||||
rentPayments.save(payment);
|
||||
|
||||
RentPayment stale = rentPayments.find(landlordId, paymentId).orElseThrow();
|
||||
@@ -133,10 +132,11 @@ class RentalMongoTransactionIT {
|
||||
when(staleView.find(landlordId, paymentId)).thenReturn(Optional.of(stale));
|
||||
when(staleView.save(any())).thenAnswer(invocation -> rentPayments.save(invocation.getArgument(0)));
|
||||
AllocateRentPaymentService service = new AllocateRentPaymentService(staleView, rentDues, transactions,
|
||||
() -> "service-allocation", Clock.fixed(Instant.parse("2026-01-07T12:00:00Z"), ZoneOffset.UTC));
|
||||
Clock.fixed(Instant.parse("2026-01-07T12:00:00Z"), ZoneOffset.UTC));
|
||||
|
||||
assertThrows(OptimisticLockingFailureException.class,
|
||||
() -> service.allocate(landlordId, paymentId, dueId, new BigDecimal("100")));
|
||||
() -> service.allocate(landlordId, paymentId, "service-allocation", stale.version(), dueId, 0,
|
||||
new BigDecimal("100")));
|
||||
|
||||
assertEquals(0, rentDues.find(landlordId, dueId).orElseThrow().allocatedAmount().signum());
|
||||
assertEquals(List.of(new RentDueId("other-due")), rentPayments.find(landlordId, paymentId).orElseThrow()
|
||||
|
||||
+3
-32
@@ -6,7 +6,6 @@ import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.mongodb.client.result.DeleteResult;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.PaymentAllocation;
|
||||
@@ -17,7 +16,6 @@ import fr.nessar.lmnporganizer.rental.domain.rent.RentPayment;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefund;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefundId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefundStatus;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversal;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversalId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentStatus;
|
||||
@@ -84,7 +82,7 @@ class MongoRentRepositoriesTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void rentPaymentRoundTripsEveryAllocationRefundAndScopedDraftDelete() {
|
||||
void rentPaymentRoundTripsEveryAllocationRefundAndLandlordScope() {
|
||||
RentPayment payment = RentPayment.reconstitute(
|
||||
PAYMENT_ID,
|
||||
LANDLORD_ID,
|
||||
@@ -99,8 +97,6 @@ class MongoRentRepositoriesTest {
|
||||
when(mongoTemplate.insert(any(MongoRentPaymentRepository.RentPaymentDocument.class)))
|
||||
.thenAnswer(invocation -> invocation.<MongoRentPaymentRepository.RentPaymentDocument>getArgument(0)
|
||||
.withVersion(0L));
|
||||
when(mongoTemplate.remove(any(Query.class), eq(MongoRentPaymentRepository.RentPaymentDocument.class)))
|
||||
.thenReturn(DeleteResult.acknowledged(1));
|
||||
MongoRentPaymentRepository repository = new MongoRentPaymentRepository(mongoTemplate);
|
||||
|
||||
RentPayment saved = repository.save(payment);
|
||||
@@ -121,9 +117,6 @@ class MongoRentRepositoriesTest {
|
||||
when(mongoTemplate.findOne(any(Query.class), eq(MongoRentPaymentRepository.RentPaymentDocument.class)))
|
||||
.thenReturn(document.getValue().withVersion(4L));
|
||||
RentPayment found = repository.find(LANDLORD_ID, PAYMENT_ID).orElseThrow();
|
||||
RentPayment draft = RentPayment.reconstitute(PAYMENT_ID, LANDLORD_ID, LEASE_ID, RECEIVED_AT,
|
||||
new BigDecimal("1200.00"), RentPaymentStatus.DRAFT, List.of(), BigDecimal.ZERO, null, 4);
|
||||
assertThat(repository.deleteDraft(draft)).isTrue();
|
||||
|
||||
assertThat(found.allocations()).containsExactlyElementsOf(allocations());
|
||||
assertThat(found.version()).isEqualTo(4);
|
||||
@@ -132,17 +125,10 @@ class MongoRentRepositoriesTest {
|
||||
assertThat(findQuery.getValue().getQueryObject())
|
||||
.containsEntry("_id", PAYMENT_ID.value())
|
||||
.containsEntry("landlordId", LANDLORD_ID.value());
|
||||
ArgumentCaptor<Query> deleteQuery = ArgumentCaptor.forClass(Query.class);
|
||||
verify(mongoTemplate).remove(deleteQuery.capture(), eq(MongoRentPaymentRepository.RentPaymentDocument.class));
|
||||
assertThat(deleteQuery.getValue().getQueryObject())
|
||||
.containsEntry("_id", PAYMENT_ID.value())
|
||||
.containsEntry("landlordId", LANDLORD_ID.value())
|
||||
.containsEntry("status", RentPaymentStatus.DRAFT.name())
|
||||
.containsEntry("version", 4L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void refundRoundTripsConfirmationVersionAndScopedDraftDelete() {
|
||||
void immutableRefundRoundTripsItsVersionAndLandlordScope() {
|
||||
RentPaymentRefund refund = RentPaymentRefund.reconstitute(
|
||||
new RentPaymentRefundId("refund-1"),
|
||||
LANDLORD_ID,
|
||||
@@ -150,14 +136,10 @@ class MongoRentRepositoriesTest {
|
||||
PAYMENT_ID,
|
||||
new BigDecimal("100.00"),
|
||||
LocalDate.of(2026, 3, 7),
|
||||
RentPaymentRefundStatus.CONFIRMED,
|
||||
LocalDate.of(2026, 3, 8),
|
||||
-1);
|
||||
when(mongoTemplate.insert(any(MongoRentPaymentRefundRepository.RefundDocument.class)))
|
||||
.thenAnswer(invocation -> invocation.<MongoRentPaymentRefundRepository.RefundDocument>getArgument(0)
|
||||
.withVersion(0L));
|
||||
when(mongoTemplate.remove(any(Query.class), eq(MongoRentPaymentRefundRepository.RefundDocument.class)))
|
||||
.thenReturn(DeleteResult.acknowledged(1));
|
||||
MongoRentPaymentRefundRepository repository = new MongoRentPaymentRefundRepository(mongoTemplate);
|
||||
|
||||
RentPaymentRefund saved = repository.save(refund);
|
||||
@@ -166,17 +148,13 @@ class MongoRentRepositoriesTest {
|
||||
ArgumentCaptor.forClass(MongoRentPaymentRefundRepository.RefundDocument.class);
|
||||
verify(mongoTemplate).insert(document.capture());
|
||||
assertThat(document.getValue().amount()).isEqualByComparingTo("100.00");
|
||||
assertThat(document.getValue().confirmedAt()).isEqualTo(LocalDate.of(2026, 3, 8));
|
||||
assertThat(document.getValue().version()).isNull();
|
||||
assertThat(saved.status()).isEqualTo(RentPaymentRefundStatus.CONFIRMED);
|
||||
assertThat(saved.refundedAt()).isEqualTo(LocalDate.of(2026, 3, 7));
|
||||
assertThat(saved.version()).isZero();
|
||||
|
||||
when(mongoTemplate.findOne(any(Query.class), eq(MongoRentPaymentRefundRepository.RefundDocument.class)))
|
||||
.thenReturn(document.getValue().withVersion(2L));
|
||||
RentPaymentRefund found = repository.find(LANDLORD_ID, refund.id()).orElseThrow();
|
||||
RentPaymentRefund draft = RentPaymentRefund.reconstitute(refund.id(), LANDLORD_ID, LEASE_ID, PAYMENT_ID,
|
||||
new BigDecimal("100.00"), LocalDate.of(2026, 3, 7), RentPaymentRefundStatus.DRAFT, null, 2);
|
||||
assertThat(repository.deleteDraft(draft)).isTrue();
|
||||
|
||||
assertThat(found.version()).isEqualTo(2);
|
||||
ArgumentCaptor<Query> findQuery = ArgumentCaptor.forClass(Query.class);
|
||||
@@ -184,13 +162,6 @@ class MongoRentRepositoriesTest {
|
||||
assertThat(findQuery.getValue().getQueryObject())
|
||||
.containsEntry("_id", refund.id().value())
|
||||
.containsEntry("landlordId", LANDLORD_ID.value());
|
||||
ArgumentCaptor<Query> deleteQuery = ArgumentCaptor.forClass(Query.class);
|
||||
verify(mongoTemplate).remove(deleteQuery.capture(), eq(MongoRentPaymentRefundRepository.RefundDocument.class));
|
||||
assertThat(deleteQuery.getValue().getQueryObject())
|
||||
.containsEntry("_id", refund.id().value())
|
||||
.containsEntry("landlordId", LANDLORD_ID.value())
|
||||
.containsEntry("status", RentPaymentRefundStatus.DRAFT.name())
|
||||
.containsEntry("version", 2L);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ class ThymeleafRentalDocumentRendererTest {
|
||||
LocalDate.of(2026, 7, 5), LocalDate.of(2026, 7, 6), new BigDecimal("1200.00"),
|
||||
new BigDecimal("50.00"), new BigDecimal("200.00"), contract, List.of(allocation),
|
||||
List.of(new ReceiptRefund(new RentPaymentRefundId("refund-1"), new BigDecimal("50.00"),
|
||||
LocalDate.of(2026, 7, 6), LocalDate.of(2026, 7, 7)))));
|
||||
LocalDate.of(2026, 7, 6)))));
|
||||
byte[] rentReceipt = renderer.renderRentReceipt(new RentReceipt(new RentDueId("due-1"),
|
||||
YearMonth.of(2026, 7), new BigDecimal("900.00"), new BigDecimal("50.00"),
|
||||
new BigDecimal("950.00"), LocalDate.of(2026, 7, 18), contract));
|
||||
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rent;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.PaymentAllocation;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.PaymentAllocationId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDue;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueRepository;
|
||||
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.RentPaymentStatus;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.YearMonth;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class AllocateRentPaymentServiceTest {
|
||||
private static final LandlordId LANDLORD_ID = new LandlordId("landlord-1");
|
||||
private static final LeaseId LEASE_ID = new LeaseId("lease-1");
|
||||
private static final RentPaymentId PAYMENT_ID = new RentPaymentId("payment-1");
|
||||
private static final RentDueId DUE_ID = new RentDueId("due-2");
|
||||
private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-02-10T12:00:00Z"), ZoneOffset.UTC);
|
||||
|
||||
@Test
|
||||
void allocatesSurplusToOneCurrentDueOfTheSameLease() {
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
RecordingTransactions transactions = new RecordingTransactions();
|
||||
PaymentAllocation initial = new PaymentAllocation(new PaymentAllocationId("initial"),
|
||||
new RentDueId("due-1"), new BigDecimal("900"), LocalDate.of(2026, 2, 2));
|
||||
RentPayment payment = RentPayment.reconstitute(PAYMENT_ID, LANDLORD_ID, LEASE_ID,
|
||||
LocalDate.of(2026, 2, 2), new BigDecimal("1000"), RentPaymentStatus.CONFIRMED,
|
||||
List.of(initial), 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, BigDecimal.ZERO, 2);
|
||||
when(payments.find(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(payment));
|
||||
when(rentDues.find(LANDLORD_ID, DUE_ID)).thenReturn(Optional.of(due));
|
||||
when(payments.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(rentDues.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
RentPayment result = new AllocateRentPaymentService(payments, rentDues, transactions, CLOCK).allocate(
|
||||
LANDLORD_ID, PAYMENT_ID, "operation-2", 4, DUE_ID, 2, new BigDecimal("50"));
|
||||
|
||||
assertEquals(new BigDecimal("950"), result.allocatedAmount());
|
||||
assertEquals(new BigDecimal("50"), result.unallocatedBalance());
|
||||
assertEquals(RentDueSettlement.PAID, due.settlement());
|
||||
assertEquals(new PaymentAllocationId("operation-2"), result.allocations().getLast().id());
|
||||
assertEquals(1, transactions.calls);
|
||||
verify(rentDues).save(due);
|
||||
verify(payments).save(payment);
|
||||
}
|
||||
|
||||
@Test
|
||||
void retryRequiresTheSameAllocationPayloadAndIgnoresStaleVersions() {
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
PaymentAllocation existing = new PaymentAllocation(new PaymentAllocationId("operation-2"), DUE_ID,
|
||||
new BigDecimal("50"), LocalDate.of(2026, 2, 10));
|
||||
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), 5);
|
||||
when(payments.find(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(payment));
|
||||
AllocateRentPaymentService service = new AllocateRentPaymentService(payments, rentDues,
|
||||
new RecordingTransactions(), CLOCK);
|
||||
|
||||
assertEquals(payment, service.allocate(LANDLORD_ID, PAYMENT_ID, "operation-2", 999, DUE_ID, 999,
|
||||
new BigDecimal("50.00")));
|
||||
assertThrows(RentPaymentConflict.class, () -> service.allocate(LANDLORD_ID, PAYMENT_ID, "operation-2",
|
||||
5, new RentDueId("due-3"), 0, new BigDecimal("50")));
|
||||
assertThrows(RentPaymentConflict.class, () -> service.allocate(LANDLORD_ID, PAYMENT_ID, "operation-2",
|
||||
5, DUE_ID, 0, new BigDecimal("51")));
|
||||
|
||||
verifyNoInteractions(rentDues);
|
||||
verify(payments, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleVersionsAndAlreadyPaidDueCreateNoAllocation() {
|
||||
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.CONFIRMED,
|
||||
List.of(), 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, BigDecimal.ZERO, 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);
|
||||
|
||||
assertThrows(ConcurrentChange.class, () -> service.allocate(LANDLORD_ID, PAYMENT_ID, "operation-2", 3,
|
||||
DUE_ID, 2, new BigDecimal("10")));
|
||||
assertThrows(ConcurrentChange.class, () -> service.allocate(LANDLORD_ID, PAYMENT_ID, "operation-2", 4,
|
||||
DUE_ID, 1, new BigDecimal("10")));
|
||||
due.allocate(due.total());
|
||||
assertThrows(RentPaymentConflict.class, () -> service.allocate(LANDLORD_ID, PAYMENT_ID, "operation-2", 4,
|
||||
DUE_ID, 2, new BigDecimal("10")));
|
||||
|
||||
verify(rentDues, never()).save(any());
|
||||
verify(payments, never()).save(any());
|
||||
}
|
||||
|
||||
private static final class RecordingTransactions implements TransactionRunner {
|
||||
private int calls;
|
||||
|
||||
@Override
|
||||
public <T> T run(Supplier<T> operation) {
|
||||
calls++;
|
||||
return operation.get();
|
||||
}
|
||||
}
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rent;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.PaymentAllocation;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.PaymentAllocationId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDue;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueRepository;
|
||||
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.RentPaymentStatus;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentRuleViolation;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.YearMonth;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Optional;
|
||||
import java.util.List;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class RecordRentPaymentServiceTest {
|
||||
private static final LandlordId LANDLORD_ID = new LandlordId("landlord-1");
|
||||
private static final RentDueId DUE_ID = new RentDueId("due-1");
|
||||
private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-02-09T23:30:00Z"), ZoneOffset.UTC);
|
||||
|
||||
@Test
|
||||
void recordsConfirmedCashAndAllocatesOnlyTheDuesRemainingBalance() {
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
RecordingTransactions transactions = new RecordingTransactions();
|
||||
RentDue due = RentDue.reconstitute(DUE_ID, LANDLORD_ID, new LeaseId("lease-1"),
|
||||
YearMonth.of(2026, 1), LocalDate.of(2026, 2, 5), new BigDecimal("900"),
|
||||
new BigDecimal("50"), BigDecimal.ZERO, 3);
|
||||
when(payments.find(LANDLORD_ID, new RentPaymentId("operation-1"))).thenReturn(Optional.empty());
|
||||
when(rentDues.find(LANDLORD_ID, DUE_ID)).thenReturn(Optional.of(due));
|
||||
when(payments.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(rentDues.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
RentPayment payment = new RecordRentPaymentService(payments, rentDues, transactions, CLOCK).record(
|
||||
LANDLORD_ID, "operation-1", DUE_ID, 3, LocalDate.of(2026, 2, 9),
|
||||
new BigDecimal("1000"));
|
||||
|
||||
assertEquals(new RentPaymentId("operation-1"), payment.id());
|
||||
assertEquals(new BigDecimal("950"), payment.allocatedAmount());
|
||||
assertEquals(new BigDecimal("50"), payment.unallocatedBalance());
|
||||
assertEquals(RentDueSettlement.PAID, due.settlement());
|
||||
assertEquals(1, transactions.calls);
|
||||
verify(rentDues).save(due);
|
||||
verify(payments).save(payment);
|
||||
}
|
||||
|
||||
@Test
|
||||
void retryReturnsTheExistingPaymentBeforeInspectingStaleDueData() {
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
RentPayment existing = RentPayment.reconstitute(new RentPaymentId("operation-1"), LANDLORD_ID,
|
||||
new LeaseId("lease-1"), LocalDate.of(2026, 2, 9), new BigDecimal("1000"),
|
||||
RentPaymentStatus.CONFIRMED, List.of(new PaymentAllocation(
|
||||
new PaymentAllocationId("operation-1"), DUE_ID,
|
||||
new BigDecimal("950"), LocalDate.of(2026, 2, 10))), BigDecimal.ZERO,
|
||||
LocalDate.of(2026, 2, 10), 0);
|
||||
when(payments.find(LANDLORD_ID, existing.id())).thenReturn(Optional.of(existing));
|
||||
|
||||
RentPayment result = new RecordRentPaymentService(payments, rentDues, new RecordingTransactions(), CLOCK)
|
||||
.record(LANDLORD_ID, "operation-1", DUE_ID, 999, LocalDate.of(2026, 2, 9),
|
||||
new BigDecimal("1000"));
|
||||
|
||||
assertEquals(existing, result);
|
||||
verifyNoInteractions(rentDues);
|
||||
verify(payments, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsReuseOfARecordingOperationIdWithDifferentImmutableData() {
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
RentPayment existing = RentPayment.reconstitute(new RentPaymentId("operation-1"), LANDLORD_ID,
|
||||
new LeaseId("lease-1"), LocalDate.of(2026, 2, 9), new BigDecimal("1000"),
|
||||
RentPaymentStatus.CONFIRMED, List.of(new PaymentAllocation(
|
||||
new PaymentAllocationId("operation-1"), DUE_ID,
|
||||
new BigDecimal("950"), LocalDate.of(2026, 2, 10))), BigDecimal.ZERO,
|
||||
LocalDate.of(2026, 2, 10), 0);
|
||||
when(payments.find(LANDLORD_ID, existing.id())).thenReturn(Optional.of(existing));
|
||||
RecordRentPaymentService service = new RecordRentPaymentService(payments, rentDues,
|
||||
new RecordingTransactions(), CLOCK);
|
||||
|
||||
assertThrows(RentPaymentConflict.class, () -> service.record(LANDLORD_ID, "operation-1", DUE_ID, 0,
|
||||
LocalDate.of(2026, 2, 9), new BigDecimal("999")));
|
||||
assertThrows(RentPaymentConflict.class, () -> service.record(LANDLORD_ID, "operation-1",
|
||||
new RentDueId("due-2"), 0, LocalDate.of(2026, 2, 9), new BigDecimal("1000")));
|
||||
|
||||
verifyNoInteractions(rentDues);
|
||||
verify(payments, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleOrPaidDueAndCashFromTheNextUtcDayCreateNoPayment() {
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
RentDue due = RentDue.reconstitute(DUE_ID, LANDLORD_ID, new LeaseId("lease-1"),
|
||||
YearMonth.of(2026, 1), LocalDate.of(2026, 2, 5), new BigDecimal("900"),
|
||||
new BigDecimal("50"), BigDecimal.ZERO, 4);
|
||||
when(payments.find(LANDLORD_ID, new RentPaymentId("operation-1"))).thenReturn(Optional.empty());
|
||||
when(rentDues.find(LANDLORD_ID, DUE_ID)).thenReturn(Optional.of(due));
|
||||
RecordRentPaymentService service = new RecordRentPaymentService(payments, rentDues,
|
||||
new RecordingTransactions(), CLOCK);
|
||||
|
||||
assertThrows(ConcurrentChange.class, () -> service.record(LANDLORD_ID, "operation-1", DUE_ID, 3,
|
||||
LocalDate.of(2026, 2, 9), new BigDecimal("1000")));
|
||||
assertThrows(RentRuleViolation.class, () -> service.record(LANDLORD_ID, "operation-1", DUE_ID, 4,
|
||||
LocalDate.of(2026, 2, 10), new BigDecimal("1000")));
|
||||
due.allocate(due.total());
|
||||
assertThrows(RentPaymentConflict.class, () -> service.record(LANDLORD_ID, "operation-1", DUE_ID, 4,
|
||||
LocalDate.of(2026, 2, 9), new BigDecimal("1000")));
|
||||
|
||||
verify(rentDues, never()).save(any());
|
||||
verify(payments, never()).save(any());
|
||||
}
|
||||
|
||||
private static final class RecordingTransactions implements TransactionRunner {
|
||||
private int calls;
|
||||
|
||||
@Override
|
||||
public <T> T run(Supplier<T> operation) {
|
||||
calls++;
|
||||
return operation.get();
|
||||
}
|
||||
}
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rent;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPayment;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentId;
|
||||
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.RentPaymentStatus;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentRuleViolation;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class RefundRentPaymentServiceTest {
|
||||
private static final LandlordId LANDLORD_ID = new LandlordId("landlord-1");
|
||||
private static final RentPaymentId PAYMENT_ID = new RentPaymentId("payment-1");
|
||||
private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-02-10T12:00:00Z"), ZoneOffset.UTC);
|
||||
|
||||
@Test
|
||||
void atomicallyRecordsAConfirmedRefundAgainstCurrentSurplus() {
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentPaymentRefundRepository refunds = mock(RentPaymentRefundRepository.class);
|
||||
RecordingTransactions transactions = new RecordingTransactions();
|
||||
RentPayment payment = RentPayment.reconstitute(PAYMENT_ID, LANDLORD_ID, new LeaseId("lease-1"),
|
||||
LocalDate.of(2026, 2, 2), new BigDecimal("1000"), RentPaymentStatus.CONFIRMED,
|
||||
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));
|
||||
when(payments.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(refunds.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
RentPayment result = new RefundRentPaymentService(payments, refunds, transactions, CLOCK).refund(
|
||||
LANDLORD_ID, PAYMENT_ID, "operation-1", 4, new BigDecimal("250"),
|
||||
LocalDate.of(2026, 2, 9));
|
||||
|
||||
assertEquals(new BigDecimal("250"), result.refundedAmount());
|
||||
assertEquals(new BigDecimal("750"), result.unallocatedBalance());
|
||||
assertEquals(1, transactions.calls);
|
||||
verify(refunds).save(any(RentPaymentRefund.class));
|
||||
verify(payments).save(payment);
|
||||
}
|
||||
|
||||
@Test
|
||||
void retryRequiresTheSameRefundPayloadAndIgnoresAStalePaymentVersion() {
|
||||
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.CONFIRMED,
|
||||
List.of(), new BigDecimal("250"), LocalDate.of(2026, 2, 2), 5);
|
||||
RentPaymentRefund existing = RentPaymentRefund.reconstitute(new RentPaymentRefundId("operation-1"),
|
||||
LANDLORD_ID, payment.leaseId(), PAYMENT_ID, new BigDecimal("250"),
|
||||
LocalDate.of(2026, 2, 9), 0);
|
||||
when(refunds.find(LANDLORD_ID, existing.id())).thenReturn(Optional.of(existing));
|
||||
when(payments.find(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(payment));
|
||||
RefundRentPaymentService service = new RefundRentPaymentService(payments, refunds,
|
||||
new RecordingTransactions(), CLOCK);
|
||||
|
||||
assertEquals(payment, service.refund(LANDLORD_ID, PAYMENT_ID, "operation-1", 999,
|
||||
new BigDecimal("250.00"), LocalDate.of(2026, 2, 9)));
|
||||
assertThrows(RentPaymentConflict.class, () -> service.refund(LANDLORD_ID, PAYMENT_ID, "operation-1", 5,
|
||||
new BigDecimal("249"), LocalDate.of(2026, 2, 9)));
|
||||
assertThrows(RentPaymentConflict.class, () -> service.refund(LANDLORD_ID, PAYMENT_ID, "operation-1", 5,
|
||||
new BigDecimal("250"), LocalDate.of(2026, 2, 8)));
|
||||
|
||||
verify(payments, never()).save(any());
|
||||
verify(refunds, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void futureDateAndStalePaymentVersionCreateNoRefund() {
|
||||
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.CONFIRMED,
|
||||
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));
|
||||
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)));
|
||||
assertThrows(ConcurrentChange.class, () -> service.refund(LANDLORD_ID, PAYMENT_ID, "operation-1", 3,
|
||||
new BigDecimal("50"), LocalDate.of(2026, 2, 9)));
|
||||
|
||||
verify(payments, never()).save(any());
|
||||
verify(refunds, never()).save(any());
|
||||
}
|
||||
|
||||
private static final class RecordingTransactions implements TransactionRunner {
|
||||
private int calls;
|
||||
|
||||
@Override
|
||||
public <T> T run(Supplier<T> operation) {
|
||||
calls++;
|
||||
return operation.get();
|
||||
}
|
||||
}
|
||||
}
|
||||
-187
@@ -1,187 +0,0 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rent;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDue;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueRepository;
|
||||
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.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.RentPaymentReversal;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversalId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversalRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentStatus;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentRuleViolation;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.YearMonth;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class RentMoneyMovementServicesTest {
|
||||
private static final LandlordId LANDLORD_ID = new LandlordId("landlord-1");
|
||||
private static final LeaseId LEASE_ID = new LeaseId("lease-1");
|
||||
private static final RentPaymentId PAYMENT_ID = new RentPaymentId("payment-1");
|
||||
private static final RentDueId DUE_ID = new RentDueId("due-1");
|
||||
private static final RentDueId LATER_DUE_ID = new RentDueId("due-2");
|
||||
private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-02-10T12:00:00Z"), ZoneOffset.UTC);
|
||||
|
||||
@Test
|
||||
void confirmsAllocatesAndReversesMoneyWithItsDueBalances() {
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
RentPaymentReversalRepository reversals = mock(RentPaymentReversalRepository.class);
|
||||
RecordingTransactions transactions = new RecordingTransactions();
|
||||
AtomicInteger ids = new AtomicInteger();
|
||||
RentPayment payment = RentPayment.draft(PAYMENT_ID, LANDLORD_ID, LEASE_ID,
|
||||
LocalDate.of(2026, 2, 2), new BigDecimal("1000"));
|
||||
RentDue due = due(LEASE_ID);
|
||||
RentDue laterDue = RentDue.create(LATER_DUE_ID, LANDLORD_ID, LEASE_ID, YearMonth.of(2026, 2),
|
||||
LocalDate.of(2026, 3, 5), new BigDecimal("50"), BigDecimal.ZERO);
|
||||
when(payments.find(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(payment));
|
||||
when(rentDues.find(LANDLORD_ID, DUE_ID)).thenReturn(Optional.of(due));
|
||||
when(rentDues.find(LANDLORD_ID, LATER_DUE_ID)).thenReturn(Optional.of(laterDue));
|
||||
when(payments.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(rentDues.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(rentDues.saveAll(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(reversals.findByPayment(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.empty());
|
||||
when(reversals.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
new ConfirmRentPaymentService(payments, rentDues, transactions,
|
||||
() -> "id-" + ids.incrementAndGet(), CLOCK).confirm(LANDLORD_ID, PAYMENT_ID,
|
||||
List.of(new ConfirmRentPaymentService.RequestedAllocation(DUE_ID, new BigDecimal("900"))));
|
||||
new AllocateRentPaymentService(payments, rentDues, transactions,
|
||||
() -> "id-" + ids.incrementAndGet(), CLOCK).allocate(LANDLORD_ID, PAYMENT_ID, LATER_DUE_ID,
|
||||
new BigDecimal("50"));
|
||||
|
||||
assertEquals(RentPaymentStatus.CONFIRMED, payment.status());
|
||||
assertEquals(RentDueSettlement.PARTIALLY_PAID, due.settlement());
|
||||
assertEquals(RentDueSettlement.PAID, laterDue.settlement());
|
||||
assertEquals(new BigDecimal("50"), payment.unallocatedBalance());
|
||||
|
||||
RentPaymentReversal reversal = new ReverseRentPaymentService(payments, reversals, rentDues, transactions,
|
||||
() -> "id-" + ids.incrementAndGet(), CLOCK).reverse(LANDLORD_ID, PAYMENT_ID, "Erreur bancaire");
|
||||
|
||||
assertEquals(RentPaymentStatus.REVERSED, payment.status());
|
||||
assertEquals(RentDueSettlement.UNPAID, due.settlement());
|
||||
assertEquals(new BigDecimal("0"), due.allocatedAmount());
|
||||
assertEquals(RentDueSettlement.UNPAID, laterDue.settlement());
|
||||
assertEquals(2, reversal.reversedAllocations().size());
|
||||
assertEquals(3, transactions.calls);
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsTheImmutableReversalAuditByScopedPayment() {
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
RentPaymentReversalRepository reversals = mock(RentPaymentReversalRepository.class);
|
||||
RentPaymentReversal expected = new RentPaymentReversal(new RentPaymentReversalId("reversal-1"),
|
||||
LANDLORD_ID, LEASE_ID, PAYMENT_ID, LocalDate.of(2026, 2, 10), "Erreur bancaire", List.of());
|
||||
when(reversals.findByPayment(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(expected));
|
||||
|
||||
RentPaymentReversal actual = new ReverseRentPaymentService(payments, reversals, rentDues,
|
||||
new RecordingTransactions(), () -> "unused", CLOCK).get(LANDLORD_ID, PAYMENT_ID);
|
||||
|
||||
assertEquals(expected, actual);
|
||||
verify(reversals).findByPayment(LANDLORD_ID, PAYMENT_ID);
|
||||
verifyNoInteractions(payments, rentDues);
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmsARefundAgainstOnlyTheUnallocatedBalance() {
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentPaymentRefundRepository refunds = mock(RentPaymentRefundRepository.class);
|
||||
RecordingTransactions transactions = new RecordingTransactions();
|
||||
RentPayment payment = RentPayment.draft(PAYMENT_ID, LANDLORD_ID, LEASE_ID,
|
||||
LocalDate.of(2026, 2, 2), new BigDecimal("1000"));
|
||||
payment.confirm(List.of(), LocalDate.of(2026, 2, 3));
|
||||
RentPaymentRefund refund = RentPaymentRefund.draft(new RentPaymentRefundId("refund-1"), LANDLORD_ID,
|
||||
LEASE_ID, PAYMENT_ID, new BigDecimal("150"), LocalDate.of(2026, 2, 9));
|
||||
when(payments.find(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(payment));
|
||||
when(refunds.find(LANDLORD_ID, refund.id())).thenReturn(Optional.of(refund));
|
||||
when(payments.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(refunds.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
RentPaymentRefund confirmed = new ConfirmRentPaymentRefundService(payments, refunds, transactions, CLOCK)
|
||||
.confirm(LANDLORD_ID, refund.id());
|
||||
|
||||
assertEquals(new BigDecimal("850"), payment.unallocatedBalance());
|
||||
assertEquals(LocalDate.of(2026, 2, 10), confirmed.confirmedAt());
|
||||
assertEquals(1, transactions.calls);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsCrossLeaseAllocationsWithoutSavingEitherAggregate() {
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
RentPayment payment = RentPayment.draft(PAYMENT_ID, LANDLORD_ID, LEASE_ID,
|
||||
LocalDate.of(2026, 2, 2), new BigDecimal("1000"));
|
||||
RentDue otherLeaseDue = due(new LeaseId("lease-2"));
|
||||
when(payments.find(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(payment));
|
||||
when(rentDues.find(LANDLORD_ID, DUE_ID)).thenReturn(Optional.of(otherLeaseDue));
|
||||
|
||||
ConfirmRentPaymentService service = new ConfirmRentPaymentService(payments, rentDues,
|
||||
new RecordingTransactions(), () -> "allocation-1", CLOCK);
|
||||
|
||||
assertThrows(RentRuleViolation.class, () -> service.confirm(LANDLORD_ID, PAYMENT_ID,
|
||||
List.of(new ConfirmRentPaymentService.RequestedAllocation(DUE_ID, new BigDecimal("100")))));
|
||||
verify(payments, never()).save(any());
|
||||
verify(rentDues, never()).saveAll(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDuplicateDueAllocationsBeforeLookingUpOrMutatingDues() {
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
RentPayment payment = RentPayment.draft(PAYMENT_ID, LANDLORD_ID, LEASE_ID,
|
||||
LocalDate.of(2026, 2, 2), new BigDecimal("1000"));
|
||||
when(payments.find(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(payment));
|
||||
ConfirmRentPaymentService service = new ConfirmRentPaymentService(payments, rentDues,
|
||||
new RecordingTransactions(), () -> "allocation-1", CLOCK);
|
||||
|
||||
assertThrows(RentRuleViolation.class, () -> service.confirm(LANDLORD_ID, PAYMENT_ID, List.of(
|
||||
new ConfirmRentPaymentService.RequestedAllocation(DUE_ID, new BigDecimal("400")),
|
||||
new ConfirmRentPaymentService.RequestedAllocation(DUE_ID, new BigDecimal("500")))));
|
||||
|
||||
verifyNoInteractions(rentDues);
|
||||
verify(payments, never()).save(any());
|
||||
assertEquals(RentPaymentStatus.DRAFT, payment.status());
|
||||
}
|
||||
|
||||
private RentDue due(LeaseId leaseId) {
|
||||
return RentDue.create(DUE_ID, LANDLORD_ID, leaseId, YearMonth.of(2026, 1),
|
||||
LocalDate.of(2026, 2, 5), new BigDecimal("900"), new BigDecimal("50"));
|
||||
}
|
||||
|
||||
private static final class RecordingTransactions implements TransactionRunner {
|
||||
private int calls;
|
||||
|
||||
@Override
|
||||
public <T> T run(Supplier<T> operation) {
|
||||
calls++;
|
||||
return operation.get();
|
||||
}
|
||||
}
|
||||
}
|
||||
-135
@@ -1,135 +0,0 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rent;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
|
||||
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSnapshot;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.RentTerms;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.RentalPeriod;
|
||||
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.tenant.TenantId;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class RentPaymentDraftServiceTest {
|
||||
|
||||
private static final LandlordId LANDLORD_ID = new LandlordId("landlord-1");
|
||||
private static final LeaseId LEASE_ID = new LeaseId("lease-1");
|
||||
private static final RentPaymentId PAYMENT_ID = new RentPaymentId("payment-1");
|
||||
private static final LocalDate RECEIVED_AT = LocalDate.of(2026, 2, 2);
|
||||
|
||||
@Test
|
||||
void scopesLeaseListingAndCreatesADraftForAnActiveLease() {
|
||||
LeaseRepository leases = mock(LeaseRepository.class);
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
Lease lease = activeLease();
|
||||
RentPayment existing = RentPayment.draft(
|
||||
PAYMENT_ID, LANDLORD_ID, LEASE_ID, RECEIVED_AT, new BigDecimal("900"));
|
||||
when(leases.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.of(lease));
|
||||
when(payments.findAll(LANDLORD_ID, LEASE_ID)).thenReturn(List.of(existing));
|
||||
when(payments.save(any(RentPayment.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
RentPaymentDraftService service = new RentPaymentDraftService(leases, payments, () -> "payment-2");
|
||||
|
||||
assertEquals(List.of(existing), service.list(LANDLORD_ID, LEASE_ID));
|
||||
RentPayment created = service.create(
|
||||
LANDLORD_ID, LEASE_ID, LocalDate.of(2026, 2, 5), new BigDecimal("1000"));
|
||||
|
||||
assertEquals(new RentPaymentId("payment-2"), created.id());
|
||||
assertEquals(LANDLORD_ID, created.landlordId());
|
||||
assertEquals(LEASE_ID, created.leaseId());
|
||||
verify(leases, times(2)).find(LANDLORD_ID, LEASE_ID);
|
||||
verify(payments).findAll(LANDLORD_ID, LEASE_ID);
|
||||
verify(payments).save(created);
|
||||
}
|
||||
|
||||
@Test
|
||||
void revisesAndDeletesOnlyTheScopedDraftAndPropagatesAStaleDelete() {
|
||||
LeaseRepository leases = mock(LeaseRepository.class);
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentPayment draft = RentPayment.draft(
|
||||
PAYMENT_ID, LANDLORD_ID, LEASE_ID, RECEIVED_AT, new BigDecimal("900"));
|
||||
when(payments.find(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(draft));
|
||||
when(payments.save(draft)).thenReturn(draft);
|
||||
when(payments.deleteDraft(draft)).thenReturn(true, false);
|
||||
RentPaymentDraftService service = new RentPaymentDraftService(leases, payments, () -> "unused");
|
||||
|
||||
RentPayment revised = service.revise(
|
||||
LANDLORD_ID, PAYMENT_ID, LocalDate.of(2026, 2, 4), new BigDecimal("950"));
|
||||
service.delete(LANDLORD_ID, PAYMENT_ID);
|
||||
|
||||
assertEquals(LocalDate.of(2026, 2, 4), revised.receivedAt());
|
||||
assertEquals(new BigDecimal("950"), revised.amount());
|
||||
assertThrows(ConcurrentChange.class, () -> service.delete(LANDLORD_ID, PAYMENT_ID));
|
||||
verify(payments, times(3)).find(LANDLORD_ID, PAYMENT_ID);
|
||||
verify(payments).save(draft);
|
||||
verify(payments, times(2)).deleteDraft(draft);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsCreationForADraftLeaseAndRevisionOfAConfirmedPayment() {
|
||||
LeaseRepository leases = mock(LeaseRepository.class);
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
when(leases.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.of(draftLease()));
|
||||
RentPayment confirmed = confirmedPayment();
|
||||
when(payments.find(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(confirmed));
|
||||
RentPaymentDraftService service = new RentPaymentDraftService(leases, payments, () -> "payment-2");
|
||||
|
||||
assertThrows(RentRuleViolation.class, () -> service.create(
|
||||
LANDLORD_ID, LEASE_ID, RECEIVED_AT, new BigDecimal("900")));
|
||||
assertThrows(RentRuleViolation.class, () -> service.revise(
|
||||
LANDLORD_ID, PAYMENT_ID, RECEIVED_AT.plusDays(1), new BigDecimal("950")));
|
||||
verify(payments, never()).save(any());
|
||||
}
|
||||
|
||||
private static RentPayment confirmedPayment() {
|
||||
RentPayment payment = RentPayment.draft(
|
||||
PAYMENT_ID, LANDLORD_ID, LEASE_ID, RECEIVED_AT, new BigDecimal("1000"));
|
||||
payment.confirm(List.of(), RECEIVED_AT.plusDays(1));
|
||||
return payment;
|
||||
}
|
||||
|
||||
private static Lease activeLease() {
|
||||
Lease lease = draftLease();
|
||||
RentalPeriod period = lease.period();
|
||||
RentTerms terms = lease.terms();
|
||||
lease.activate(new LeaseSnapshot(
|
||||
new LeaseSnapshot.Landlord(
|
||||
LANDLORD_ID, "Léa", "Dubois", "lea@example.com", "+33612345678", "1 rue A"),
|
||||
new LeaseSnapshot.Apartment(lease.apartmentId(), "Studio", "2 rue B"),
|
||||
List.of(new LeaseSnapshot.Tenant(
|
||||
lease.tenantIds().getFirst(), "Camille", "Martin", "camille@example.com", "", "3 rue C")),
|
||||
List.of(),
|
||||
period,
|
||||
terms));
|
||||
return lease;
|
||||
}
|
||||
|
||||
private static Lease draftLease() {
|
||||
return Lease.draft(
|
||||
LEASE_ID,
|
||||
LANDLORD_ID,
|
||||
new ApartmentId("apartment-1"),
|
||||
List.of(new TenantId("tenant-1")),
|
||||
List.of(),
|
||||
new RentalPeriod(LocalDate.of(2026, 1, 1), LocalDate.of(2026, 12, 31)),
|
||||
new RentTerms(new BigDecimal("900"), new BigDecimal("50"), new BigDecimal("900")));
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rent;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPayment;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentId;
|
||||
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.RentPaymentReversal;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversalId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversalRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class RentPaymentHistoryServiceTest {
|
||||
private static final LandlordId LANDLORD_ID = new LandlordId("landlord-1");
|
||||
private static final LeaseId LEASE_ID = new LeaseId("lease-1");
|
||||
private static final RentPaymentId PAYMENT_ID = new RentPaymentId("payment-1");
|
||||
|
||||
@Test
|
||||
void returnsThePaymentWithItsRefundsAndOptionalReversal() {
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentPaymentRefundRepository refunds = mock(RentPaymentRefundRepository.class);
|
||||
RentPaymentReversalRepository reversals = mock(RentPaymentReversalRepository.class);
|
||||
RentPayment payment = RentPayment.confirmed(PAYMENT_ID, LANDLORD_ID, LEASE_ID,
|
||||
LocalDate.of(2026, 2, 2), new BigDecimal("1000"), List.of(), LocalDate.of(2026, 2, 2));
|
||||
RentPaymentRefund refund = RentPaymentRefund.reconstitute(new RentPaymentRefundId("refund-1"), LANDLORD_ID,
|
||||
LEASE_ID, PAYMENT_ID, new BigDecimal("50"), LocalDate.of(2026, 2, 3), 0);
|
||||
RentPaymentReversal reversal = new RentPaymentReversal(new RentPaymentReversalId("reversal-1"),
|
||||
LANDLORD_ID, LEASE_ID, PAYMENT_ID, LocalDate.of(2026, 2, 4), "Correction", List.of());
|
||||
when(payments.find(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(payment));
|
||||
when(refunds.findAll(LANDLORD_ID, PAYMENT_ID)).thenReturn(List.of(refund));
|
||||
when(reversals.findByPayment(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(reversal));
|
||||
|
||||
RentPaymentHistory history = new RentPaymentHistoryService(payments, refunds, reversals,
|
||||
mock(LeaseRepository.class)).get(LANDLORD_ID, PAYMENT_ID);
|
||||
|
||||
assertEquals(payment, history.payment());
|
||||
assertEquals(List.of(refund), history.refunds());
|
||||
assertTrue(history.reversal().isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void listsEnrichedHistoryWithoutOneQueryPerPayment() {
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentPaymentRefundRepository refunds = mock(RentPaymentRefundRepository.class);
|
||||
RentPaymentReversalRepository reversals = mock(RentPaymentReversalRepository.class);
|
||||
RentPayment payment = RentPayment.confirmed(PAYMENT_ID, LANDLORD_ID, LEASE_ID,
|
||||
LocalDate.of(2026, 2, 2), new BigDecimal("1000"), List.of(), LocalDate.of(2026, 2, 2));
|
||||
RentPaymentRefund refund = RentPaymentRefund.reconstitute(new RentPaymentRefundId("refund-1"), LANDLORD_ID,
|
||||
LEASE_ID, PAYMENT_ID, new BigDecimal("50"), LocalDate.of(2026, 2, 3), 0);
|
||||
RentPaymentReversal reversal = new RentPaymentReversal(new RentPaymentReversalId("reversal-1"),
|
||||
LANDLORD_ID, LEASE_ID, PAYMENT_ID, LocalDate.of(2026, 2, 4), "Correction", List.of());
|
||||
when(payments.findAll(LANDLORD_ID)).thenReturn(List.of(payment));
|
||||
when(refunds.findAll(LANDLORD_ID)).thenReturn(List.of(refund));
|
||||
when(reversals.findAll(LANDLORD_ID)).thenReturn(List.of(reversal));
|
||||
|
||||
List<RentPaymentHistory> history = new RentPaymentHistoryService(payments, refunds, reversals,
|
||||
mock(LeaseRepository.class)).list(LANDLORD_ID, null);
|
||||
|
||||
assertEquals(1, history.size());
|
||||
assertEquals(List.of(refund), history.getFirst().refunds());
|
||||
assertTrue(history.getFirst().reversal().isPresent());
|
||||
verify(refunds).findAll(LANDLORD_ID);
|
||||
verify(reversals).findAll(LANDLORD_ID);
|
||||
}
|
||||
}
|
||||
-140
@@ -1,140 +0,0 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rent;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPayment;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentId;
|
||||
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 java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class RentPaymentRefundDraftServiceTest {
|
||||
|
||||
private static final LandlordId LANDLORD_ID = new LandlordId("landlord-1");
|
||||
private static final LeaseId LEASE_ID = new LeaseId("lease-1");
|
||||
private static final RentPaymentId PAYMENT_ID = new RentPaymentId("payment-1");
|
||||
private static final RentPaymentRefundId REFUND_ID = new RentPaymentRefundId("refund-1");
|
||||
private static final LocalDate RECEIVED_AT = LocalDate.of(2026, 2, 2);
|
||||
|
||||
@Test
|
||||
void scopesListingAndCreatesADraftForAConfirmedPayment() {
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentPaymentRefundRepository refunds = mock(RentPaymentRefundRepository.class);
|
||||
RentPayment payment = confirmedPayment();
|
||||
RentPaymentRefund existing = refund();
|
||||
when(payments.find(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(payment));
|
||||
when(refunds.findAll(LANDLORD_ID, PAYMENT_ID)).thenReturn(List.of(existing));
|
||||
when(refunds.save(any(RentPaymentRefund.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
RentPaymentRefundDraftService service =
|
||||
new RentPaymentRefundDraftService(payments, refunds, () -> "refund-2");
|
||||
|
||||
assertEquals(List.of(existing), service.list(LANDLORD_ID, PAYMENT_ID));
|
||||
RentPaymentRefund created = service.create(
|
||||
LANDLORD_ID, PAYMENT_ID, new BigDecimal("100"), LocalDate.of(2026, 2, 10));
|
||||
|
||||
assertEquals(new RentPaymentRefundId("refund-2"), created.id());
|
||||
assertEquals(LANDLORD_ID, created.landlordId());
|
||||
assertEquals(LEASE_ID, created.leaseId());
|
||||
assertEquals(PAYMENT_ID, created.paymentId());
|
||||
verify(payments, times(2)).find(LANDLORD_ID, PAYMENT_ID);
|
||||
verify(refunds).findAll(LANDLORD_ID, PAYMENT_ID);
|
||||
verify(refunds).save(created);
|
||||
}
|
||||
|
||||
@Test
|
||||
void revisesAndDeletesOnlyTheScopedDraft() {
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentPaymentRefundRepository refunds = mock(RentPaymentRefundRepository.class);
|
||||
RentPaymentRefund draft = refund();
|
||||
when(refunds.find(LANDLORD_ID, REFUND_ID)).thenReturn(Optional.of(draft));
|
||||
when(payments.find(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(confirmedPayment()));
|
||||
when(refunds.save(draft)).thenReturn(draft);
|
||||
when(refunds.deleteDraft(draft)).thenReturn(true);
|
||||
RentPaymentRefundDraftService service =
|
||||
new RentPaymentRefundDraftService(payments, refunds, () -> "unused");
|
||||
|
||||
RentPaymentRefund revised = service.revise(
|
||||
LANDLORD_ID, REFUND_ID, new BigDecimal("125"), LocalDate.of(2026, 2, 11));
|
||||
service.delete(LANDLORD_ID, REFUND_ID);
|
||||
|
||||
assertEquals(new BigDecimal("125"), revised.amount());
|
||||
assertEquals(LocalDate.of(2026, 2, 11), revised.refundedAt());
|
||||
verify(refunds, times(2)).find(LANDLORD_ID, REFUND_ID);
|
||||
verify(refunds).save(draft);
|
||||
verify(refunds).deleteDraft(draft);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsCreationForANonConfirmedPaymentAndRevisionOfAConfirmedRefund() {
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentPaymentRefundRepository refunds = mock(RentPaymentRefundRepository.class);
|
||||
RentPayment unconfirmed = RentPayment.draft(
|
||||
PAYMENT_ID, LANDLORD_ID, LEASE_ID, RECEIVED_AT, new BigDecimal("1000"));
|
||||
when(payments.find(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(unconfirmed));
|
||||
RentPayment confirmedPayment = confirmedPayment();
|
||||
RentPaymentRefund confirmedRefund = refund();
|
||||
confirmedRefund.confirm(confirmedPayment, LocalDate.of(2026, 2, 12));
|
||||
when(refunds.find(LANDLORD_ID, REFUND_ID)).thenReturn(Optional.of(confirmedRefund));
|
||||
RentPaymentRefundDraftService service =
|
||||
new RentPaymentRefundDraftService(payments, refunds, () -> "refund-2");
|
||||
|
||||
assertThrows(RentRuleViolation.class, () -> service.create(
|
||||
LANDLORD_ID, PAYMENT_ID, new BigDecimal("100"), LocalDate.of(2026, 2, 10)));
|
||||
assertThrows(RentRuleViolation.class, () -> service.revise(
|
||||
LANDLORD_ID, REFUND_ID, new BigDecimal("125"), LocalDate.of(2026, 2, 11)));
|
||||
verify(refunds, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDraftAmountsAboveThePaymentsCurrentUnallocatedBalance() {
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentPaymentRefundRepository refunds = mock(RentPaymentRefundRepository.class);
|
||||
RentPayment payment = confirmedPayment();
|
||||
RentPaymentRefund draft = refund();
|
||||
when(payments.find(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(payment));
|
||||
when(refunds.find(LANDLORD_ID, REFUND_ID)).thenReturn(Optional.of(draft));
|
||||
RentPaymentRefundDraftService service =
|
||||
new RentPaymentRefundDraftService(payments, refunds, () -> "refund-2");
|
||||
|
||||
assertThrows(RentRuleViolation.class, () -> service.create(
|
||||
LANDLORD_ID, PAYMENT_ID, new BigDecimal("1000.01"), LocalDate.of(2026, 2, 10)));
|
||||
assertThrows(RentRuleViolation.class, () -> service.revise(
|
||||
LANDLORD_ID, REFUND_ID, new BigDecimal("1000.01"), LocalDate.of(2026, 2, 11)));
|
||||
|
||||
assertEquals(new BigDecimal("100"), draft.amount());
|
||||
verify(refunds, never()).save(any());
|
||||
}
|
||||
|
||||
private static RentPayment confirmedPayment() {
|
||||
RentPayment payment = RentPayment.draft(
|
||||
PAYMENT_ID, LANDLORD_ID, LEASE_ID, RECEIVED_AT, new BigDecimal("1000"));
|
||||
payment.confirm(List.of(), RECEIVED_AT.plusDays(1));
|
||||
return payment;
|
||||
}
|
||||
|
||||
private static RentPaymentRefund refund() {
|
||||
return RentPaymentRefund.draft(
|
||||
REFUND_ID,
|
||||
LANDLORD_ID,
|
||||
LEASE_ID,
|
||||
PAYMENT_ID,
|
||||
new BigDecimal("100"),
|
||||
LocalDate.of(2026, 2, 10));
|
||||
}
|
||||
}
|
||||
+6
-7
@@ -30,7 +30,6 @@ import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRepository;
|
||||
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.RentPaymentRefundStatus;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentStatus;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentRuleViolation;
|
||||
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
|
||||
@@ -119,14 +118,15 @@ class RentalDocumentGenerationServicesTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void paymentReceiptRefusesAnUnconfirmedPaymentBeforeLoadingItsLease() {
|
||||
void paymentReceiptRefusesAReversedPaymentBeforeLoadingItsLease() {
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
RentPaymentRefundRepository refunds = mock(RentPaymentRefundRepository.class);
|
||||
LeaseRepository leases = mock(LeaseRepository.class);
|
||||
RentalDocumentRenderer renderer = mock(RentalDocumentRenderer.class);
|
||||
RentPayment payment = RentPayment.draft(PAYMENT_ID, LANDLORD_ID, LEASE_ID,
|
||||
LocalDate.of(2026, 8, 2), new BigDecimal("1200.00"));
|
||||
RentPayment payment = RentPayment.confirmed(PAYMENT_ID, LANDLORD_ID, LEASE_ID,
|
||||
LocalDate.of(2026, 8, 2), new BigDecimal("1200.00"), List.of(), LocalDate.of(2026, 8, 2));
|
||||
payment.reverse();
|
||||
when(payments.find(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(payment));
|
||||
|
||||
GeneratePaymentReceiptService service = new GeneratePaymentReceiptService(
|
||||
@@ -156,7 +156,7 @@ class RentalDocumentGenerationServicesTest {
|
||||
RentDue due = due(YearMonth.of(2026, 8), BigDecimal.ZERO);
|
||||
RentPaymentRefund refund = RentPaymentRefund.reconstitute(new RentPaymentRefundId("refund-1"),
|
||||
LANDLORD_ID, LEASE_ID, PAYMENT_ID, new BigDecimal("100.00"), LocalDate.of(2026, 8, 5),
|
||||
RentPaymentRefundStatus.CONFIRMED, LocalDate.of(2026, 8, 6), 2);
|
||||
2);
|
||||
when(payments.find(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(payment));
|
||||
when(leases.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.of(lease));
|
||||
when(rentDues.find(LANDLORD_ID, DUE_ID)).thenReturn(Optional.of(due));
|
||||
@@ -178,8 +178,7 @@ class RentalDocumentGenerationServicesTest {
|
||||
assertEquals(LeaseContractVersion.from(lease.snapshot()), value.contract());
|
||||
assertEquals(List.of(new ReceiptAllocation(allocation.id(), DUE_ID, YearMonth.of(2026, 8),
|
||||
new BigDecimal("900.00"), allocatedAt)), value.allocations());
|
||||
assertEquals(List.of(new ReceiptRefund(refund.id(), refund.amount(), refund.refundedAt(),
|
||||
refund.confirmedAt())), value.refunds());
|
||||
assertEquals(List.of(new ReceiptRefund(refund.id(), refund.amount(), refund.refundedAt())), value.refunds());
|
||||
assertEquals("recu-reglement-payment-1.pdf", generated.fileName());
|
||||
verify(payments).find(LANDLORD_ID, PAYMENT_ID);
|
||||
verify(leases).find(LANDLORD_ID, LEASE_ID);
|
||||
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rent;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.PaymentAllocation;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.PaymentAllocationId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDue;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueRepository;
|
||||
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.RentPaymentReversal;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversalId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversalRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentStatus;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.YearMonth;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class ReverseRentPaymentServiceTest {
|
||||
private static final LandlordId LANDLORD_ID = new LandlordId("landlord-1");
|
||||
private static final LeaseId LEASE_ID = new LeaseId("lease-1");
|
||||
private static final RentPaymentId PAYMENT_ID = new RentPaymentId("payment-1");
|
||||
private static final RentDueId DUE_ID = new RentDueId("due-1");
|
||||
private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-02-10T12:00:00Z"), ZoneOffset.UTC);
|
||||
|
||||
@Test
|
||||
void reversesTheCurrentPaymentAndReleasesItsAllocationsAfterRefunds() {
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentPaymentReversalRepository reversals = mock(RentPaymentReversalRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
RecordingTransactions transactions = new RecordingTransactions();
|
||||
PaymentAllocation allocation = new PaymentAllocation(new PaymentAllocationId("allocation-1"), DUE_ID,
|
||||
new BigDecimal("900"), LocalDate.of(2026, 2, 2));
|
||||
RentPayment payment = RentPayment.reconstitute(PAYMENT_ID, LANDLORD_ID, LEASE_ID,
|
||||
LocalDate.of(2026, 2, 2), new BigDecimal("1000"), RentPaymentStatus.CONFIRMED,
|
||||
List.of(allocation), new BigDecimal("60"), LocalDate.of(2026, 2, 2), 5);
|
||||
RentDue due = RentDue.reconstitute(DUE_ID, LANDLORD_ID, LEASE_ID, YearMonth.of(2026, 1),
|
||||
LocalDate.of(2026, 2, 5), new BigDecimal("900"), new BigDecimal("50"),
|
||||
new BigDecimal("900"), 3);
|
||||
when(reversals.findByPayment(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.empty());
|
||||
when(payments.find(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(payment));
|
||||
when(rentDues.find(LANDLORD_ID, DUE_ID)).thenReturn(Optional.of(due));
|
||||
when(payments.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(rentDues.saveAll(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(reversals.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
RentPaymentReversal reversal = new ReverseRentPaymentService(payments, reversals, rentDues, transactions,
|
||||
() -> "reversal-1", CLOCK).reverse(LANDLORD_ID, PAYMENT_ID, 5, "Erreur bancaire");
|
||||
|
||||
assertEquals(RentPaymentStatus.REVERSED, payment.status());
|
||||
assertEquals(new BigDecimal("60"), payment.refundedAmount());
|
||||
assertEquals(RentDueSettlement.UNPAID, due.settlement());
|
||||
assertEquals("Erreur bancaire", reversal.reason());
|
||||
assertEquals(1, transactions.calls);
|
||||
}
|
||||
|
||||
@Test
|
||||
void retryRequiresTheSameReversalReasonAndIgnoresAStalePaymentVersion() {
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentPaymentReversalRepository reversals = mock(RentPaymentReversalRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
RentPaymentReversal existing = new RentPaymentReversal(new RentPaymentReversalId("reversal-1"),
|
||||
LANDLORD_ID, LEASE_ID, PAYMENT_ID, LocalDate.of(2026, 2, 10), "Erreur bancaire", List.of());
|
||||
when(reversals.findByPayment(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(existing));
|
||||
ReverseRentPaymentService service = new ReverseRentPaymentService(payments, reversals, rentDues,
|
||||
new RecordingTransactions(), () -> "unused", CLOCK);
|
||||
|
||||
assertEquals(existing, service.reverse(LANDLORD_ID, PAYMENT_ID, 999, " Erreur bancaire "));
|
||||
assertThrows(RentPaymentConflict.class,
|
||||
() -> service.reverse(LANDLORD_ID, PAYMENT_ID, 0, "Autre raison"));
|
||||
|
||||
verifyNoInteractions(payments, rentDues);
|
||||
verify(reversals, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void stalePaymentVersionCreatesNoReversal() {
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentPaymentReversalRepository reversals = mock(RentPaymentReversalRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
RentPayment payment = RentPayment.reconstitute(PAYMENT_ID, LANDLORD_ID, LEASE_ID,
|
||||
LocalDate.of(2026, 2, 2), new BigDecimal("1000"),
|
||||
RentPaymentStatus.CONFIRMED, List.of(), BigDecimal.ZERO, LocalDate.of(2026, 2, 2), 4);
|
||||
when(reversals.findByPayment(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.empty());
|
||||
when(payments.find(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(payment));
|
||||
ReverseRentPaymentService service = new ReverseRentPaymentService(payments, reversals, rentDues,
|
||||
new RecordingTransactions(), () -> "reversal-1", CLOCK);
|
||||
|
||||
assertThrows(ConcurrentChange.class,
|
||||
() -> service.reverse(LANDLORD_ID, PAYMENT_ID, 3, "Erreur bancaire"));
|
||||
|
||||
verifyNoInteractions(rentDues);
|
||||
verify(payments, never()).save(any());
|
||||
verify(reversals, never()).save(any());
|
||||
}
|
||||
|
||||
private static final class RecordingTransactions implements TransactionRunner {
|
||||
private int calls;
|
||||
|
||||
@Override
|
||||
public <T> T run(Supplier<T> operation) {
|
||||
calls++;
|
||||
return operation.get();
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
-15
@@ -27,21 +27,37 @@ class RentPaymentCorrectionTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmedRefundIsImmutableAndCannotExceedSurplus() {
|
||||
void reversalReasonCannotExceedTheAuditLimit() {
|
||||
RentPayment payment = confirmedPayment("1000", "900");
|
||||
RentPaymentRefund refund = RentPaymentRefund.draft(new RentPaymentRefundId("refund-1"),
|
||||
payment.landlordId(), payment.leaseId(), payment.id(), new BigDecimal("60"), DATE.plusDays(1));
|
||||
|
||||
RentPaymentRefunded event = refund.confirm(payment, DATE.plusDays(2));
|
||||
assertThrows(RentRuleViolation.class, () -> RentPaymentReversal.reverse(
|
||||
new RentPaymentReversalId("reversal-1"), payment, DATE.plusDays(1), "a".repeat(501)));
|
||||
|
||||
assertEquals(RentPaymentRefundStatus.CONFIRMED, refund.status());
|
||||
assertEquals(RentPaymentStatus.CONFIRMED, payment.status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void refundIsBornAsAnImmutableConfirmedFact() {
|
||||
RentPayment payment = confirmedPayment("1000", "900");
|
||||
|
||||
RentPaymentRefund refund = RentPaymentRefund.recorded(new RentPaymentRefundId("refund-1"), payment,
|
||||
new BigDecimal("60"), DATE.plusDays(1));
|
||||
|
||||
assertEquals(new BigDecimal("60"), refund.amount());
|
||||
assertEquals(DATE.plusDays(1), refund.refundedAt());
|
||||
assertEquals(new BigDecimal("40"), payment.unallocatedBalance());
|
||||
assertEquals(new BigDecimal("60"), event.amount());
|
||||
assertThrows(RentRuleViolation.class, () -> refund.revise(new BigDecimal("10"), DATE));
|
||||
}
|
||||
|
||||
RentPaymentRefund excessive = RentPaymentRefund.draft(new RentPaymentRefundId("refund-2"),
|
||||
payment.landlordId(), payment.leaseId(), payment.id(), new BigDecimal("41"), DATE);
|
||||
assertThrows(RentRuleViolation.class, () -> excessive.confirm(payment, DATE));
|
||||
@Test
|
||||
void refundCannotExceedSurplusAndReversalPreservesIt() {
|
||||
RentPayment payment = confirmedPayment("1000", "900");
|
||||
RentPaymentRefund refund = RentPaymentRefund.recorded(new RentPaymentRefundId("refund-1"), payment,
|
||||
new BigDecimal("60"), DATE.plusDays(1));
|
||||
|
||||
assertEquals(new BigDecimal("40"), payment.unallocatedBalance());
|
||||
assertEquals(new BigDecimal("60"), refund.amount());
|
||||
assertThrows(RentRuleViolation.class, () -> RentPaymentRefund.recorded(
|
||||
new RentPaymentRefundId("refund-2"), payment, new BigDecimal("41"), DATE));
|
||||
|
||||
RentPaymentReversal reversal = RentPaymentReversal.reverse(new RentPaymentReversalId("reversal-1"),
|
||||
payment, DATE.plusDays(3), "Correction après remboursement");
|
||||
@@ -51,10 +67,9 @@ class RentPaymentCorrectionTest {
|
||||
}
|
||||
|
||||
private RentPayment confirmedPayment(String received, String allocated) {
|
||||
RentPayment payment = RentPayment.draft(new RentPaymentId("payment-1"), new LandlordId("landlord-1"),
|
||||
new LeaseId("lease-1"), DATE, new BigDecimal(received));
|
||||
payment.confirm(List.of(new PaymentAllocation(new PaymentAllocationId("allocation-1"),
|
||||
new RentDueId("due-1"), new BigDecimal(allocated), DATE)), DATE);
|
||||
return payment;
|
||||
return RentPayment.confirmed(new RentPaymentId("payment-1"), new LandlordId("landlord-1"),
|
||||
new LeaseId("lease-1"), DATE, new BigDecimal(received),
|
||||
List.of(new PaymentAllocation(new PaymentAllocationId("allocation-1"),
|
||||
new RentDueId("due-1"), new BigDecimal(allocated), DATE)), DATE);
|
||||
}
|
||||
}
|
||||
+21
-13
@@ -12,29 +12,36 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class RentPaymentTest {
|
||||
@Test
|
||||
void confirmedPaymentIsBornAsAnImmutableCashFact() {
|
||||
PaymentAllocation allocation = allocation("operation-1", "due-1", "950");
|
||||
|
||||
RentPayment payment = RentPayment.confirmed(new RentPaymentId("payment-1"),
|
||||
new LandlordId("landlord-1"), new LeaseId("lease-1"), LocalDate.of(2026, 2, 1),
|
||||
new BigDecimal("1000"), List.of(allocation), LocalDate.of(2026, 2, 2));
|
||||
|
||||
assertEquals(RentPaymentStatus.CONFIRMED, payment.status());
|
||||
assertEquals(List.of(allocation), payment.allocations());
|
||||
assertEquals(new BigDecimal("50"), payment.unallocatedBalance());
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmedPaymentOwnsAllocationsAndRetainsSurplus() {
|
||||
RentPayment payment = payment("1000");
|
||||
PaymentAllocation first = allocation("allocation-1", "due-1", "400");
|
||||
|
||||
RentPaymentConfirmed event = payment.confirm(List.of(first), LocalDate.of(2026, 2, 2));
|
||||
payment.allocate(allocation("allocation-2", "due-2", "500"));
|
||||
|
||||
assertEquals(RentPaymentStatus.CONFIRMED, payment.status());
|
||||
assertEquals(new BigDecimal("900"), payment.allocatedAmount());
|
||||
assertEquals(new BigDecimal("100"), payment.unallocatedBalance());
|
||||
assertEquals(List.of(first), event.allocations());
|
||||
assertEquals(new BigDecimal("500"), payment.allocatedAmount());
|
||||
assertEquals(new BigDecimal("500"), payment.unallocatedBalance());
|
||||
assertThrows(RentRuleViolation.class,
|
||||
() -> payment.allocate(allocation("allocation-3", "due-3", "101")));
|
||||
assertThrows(RentRuleViolation.class,
|
||||
() -> payment.revise(LocalDate.of(2026, 2, 3), new BigDecimal("1200")));
|
||||
() -> payment.allocate(allocation("allocation-3", "due-3", "501")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void refundsUseOnlySurplusAndReversalIsAuditableAndIdempotent() {
|
||||
RentPayment payment = payment("1000");
|
||||
PaymentAllocation allocation = allocation("allocation-1", "due-1", "900");
|
||||
payment.confirm(List.of(allocation), LocalDate.of(2026, 2, 2));
|
||||
payment.allocate(allocation);
|
||||
payment.registerRefund(new BigDecimal("40"));
|
||||
assertEquals(new BigDecimal("60"), payment.unallocatedBalance());
|
||||
assertThrows(RentRuleViolation.class, () -> payment.registerRefund(new BigDecimal("61")));
|
||||
@@ -45,13 +52,14 @@ class RentPaymentTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void draftMustRepresentPositiveCash() {
|
||||
void confirmedPaymentMustRepresentPositiveCash() {
|
||||
assertThrows(RentRuleViolation.class, () -> payment("0"));
|
||||
}
|
||||
|
||||
private RentPayment payment(String amount) {
|
||||
return RentPayment.draft(new RentPaymentId("payment-1"), new LandlordId("landlord-1"),
|
||||
new LeaseId("lease-1"), LocalDate.of(2026, 2, 1), new BigDecimal(amount));
|
||||
return RentPayment.confirmed(new RentPaymentId("payment-1"), new LandlordId("landlord-1"),
|
||||
new LeaseId("lease-1"), LocalDate.of(2026, 2, 1), new BigDecimal(amount), List.of(),
|
||||
LocalDate.of(2026, 2, 2));
|
||||
}
|
||||
|
||||
private PaymentAllocation allocation(String id, String dueId, String amount) {
|
||||
|
||||
+71
-61
@@ -102,17 +102,18 @@ class ReplacementRentalApiJourneyIT {
|
||||
|
||||
String leaseId = create("/api/leases", """
|
||||
{"apartmentId":"%s","tenantIds":["%s"],"guarantors":[],
|
||||
"startDate":"2026-07-01","endDate":"2026-07-31",
|
||||
"startDate":"2026-07-01","endDate":"2026-08-31",
|
||||
"monthlyRent":900.00,"monthlyCharges":50.00,"securityDeposit":900.00}
|
||||
""".formatted(apartmentId, tenantId));
|
||||
|
||||
String activation = mockMvc.perform(post("/api/leases/{leaseId}/activate", leaseId).with(owner()).with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.lease.status").value("ACTIVE"))
|
||||
.andExpect(jsonPath("$.rentDues.length()").value(1))
|
||||
.andExpect(jsonPath("$.rentDues.length()").value(2))
|
||||
.andExpect(jsonPath("$.rentDues[0].total").value(950.00))
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
String dueId = read(activation, "$.rentDues[0].id");
|
||||
String secondDueId = read(activation, "$.rentDues[1].id");
|
||||
|
||||
mockMvc.perform(get("/api/rent-dues").param("leaseId", leaseId).with(owner()))
|
||||
.andExpect(status().isOk())
|
||||
@@ -120,45 +121,68 @@ class ReplacementRentalApiJourneyIT {
|
||||
.andExpect(jsonPath("$[0].settlement").value("UNPAID"));
|
||||
|
||||
String paymentId = create("/api/rent-payments", """
|
||||
{"leaseId":"%s","receivedAt":"2026-07-05","amount":950.00}
|
||||
""".formatted(leaseId));
|
||||
mockMvc.perform(post("/api/rent-payments/{paymentId}/confirm", paymentId).with(owner()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content("""
|
||||
{"allocations":[{"rentDueId":"%s","amount":950.00}]}
|
||||
""".formatted(dueId)))
|
||||
{"operationId":"payment-1","rentDueId":"%s","rentDueVersion":0,
|
||||
"receivedAt":"2026-07-05","amount":1000.00}
|
||||
""".formatted(dueId));
|
||||
mockMvc.perform(post("/api/rent-payments/{paymentId}/allocations", paymentId)
|
||||
.with(owner()).with(csrf()).contentType(MediaType.APPLICATION_JSON).content("""
|
||||
{"operationId":"allocation-1","paymentVersion":0,"rentDueId":"%s",
|
||||
"rentDueVersion":0,"amount":25.00}
|
||||
""".formatted(secondDueId)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.status").value("CONFIRMED"))
|
||||
.andExpect(jsonPath("$.allocatedAmount").value(950.00))
|
||||
.andExpect(jsonPath("$.unallocatedBalance").value(0));
|
||||
.andExpect(jsonPath("$.allocations.length()").value(2))
|
||||
.andExpect(jsonPath("$.allocations[1].id").value("allocation-1"))
|
||||
.andExpect(jsonPath("$.allocatedAmount").value(975.00))
|
||||
.andExpect(jsonPath("$.unallocatedBalance").value(25.00));
|
||||
mockMvc.perform(get("/api/rent-dues").param("leaseId", leaseId).with(owner()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[1].settlement").value("PARTIALLY_PAID"))
|
||||
.andExpect(jsonPath("$[1].allocatedAmount").value(25.00));
|
||||
mockMvc.perform(post("/api/rent-payments").with(owner()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content("""
|
||||
{"operationId":"paid-conflict","rentDueId":"%s","rentDueVersion":1,
|
||||
"receivedAt":"2026-07-05","amount":1.00}
|
||||
""".formatted(dueId)))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.message").isNotEmpty());
|
||||
|
||||
String surplusPaymentId = create("/api/rent-payments", """
|
||||
{"leaseId":"%s","receivedAt":"2026-07-06","amount":1.00}
|
||||
""".formatted(leaseId));
|
||||
mockMvc.perform(post("/api/rent-payments/{paymentId}/confirm", surplusPaymentId)
|
||||
.with(owner()).with(csrf()).contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"allocations\":[]}"))
|
||||
.andExpect(status().isOk());
|
||||
{"operationId":"payment-2","rentDueId":"%s","rentDueVersion":1,
|
||||
"receivedAt":"2026-07-06","amount":926.00}
|
||||
""".formatted(secondDueId));
|
||||
mockMvc.perform(post("/api/rent-payments/{paymentId}/refunds", surplusPaymentId)
|
||||
.with(owner()).with(csrf()).contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"amount\":1.01,\"refundedAt\":\"2026-07-07\"}"))
|
||||
.content("""
|
||||
{"operationId":"invalid-refund","paymentVersion":0,"amount":1.01,
|
||||
"refundedAt":"2026-07-07"}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest());
|
||||
String refundResponse = mockMvc.perform(post("/api/rent-payments/{paymentId}/refunds", surplusPaymentId)
|
||||
.with(owner()).with(csrf()).contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"amount\":0.50,\"refundedAt\":\"2026-07-07\"}"))
|
||||
.content("""
|
||||
{"operationId":"refund-1","paymentVersion":0,"amount":0.50,
|
||||
"refundedAt":"2026-07-07"}
|
||||
"""))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.refunds[0].id").value("refund-1"))
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
String refundId = read(refundResponse, "$.id");
|
||||
mockMvc.perform(post("/api/rent-payments/{paymentId}/refunds/{refundId}/confirm",
|
||||
surplusPaymentId, refundId).with(owner()).with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.status").value("CONFIRMED"));
|
||||
int refundedPaymentVersion = read(refundResponse, "$.version");
|
||||
mockMvc.perform(post("/api/rent-payments/{paymentId}/refunds", surplusPaymentId)
|
||||
.with(owner()).with(csrf()).contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"operationId":"refund-1","paymentVersion":0,"amount":0.50,
|
||||
"refundedAt":"2026-07-07"}
|
||||
"""))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.refunds.length()").value(1));
|
||||
mockMvc.perform(post("/api/rent-payments/{paymentId}/reversal", surplusPaymentId)
|
||||
.with(owner()).with(csrf()).contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"reason\":\"Correction après remboursement\"}"))
|
||||
.andExpect(status().isCreated());
|
||||
mockMvc.perform(get("/api/rent-payments/{paymentId}/reversal", surplusPaymentId).with(owner()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.reason").value("Correction après remboursement"));
|
||||
.content("""
|
||||
{"paymentVersion":%d,"reason":"Correction après remboursement"}
|
||||
""".formatted(refundedPaymentVersion)))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.reversal.reason").value("Correction après remboursement"))
|
||||
.andExpect(jsonPath("$.refunds[0].id").value("refund-1"));
|
||||
|
||||
assertPdf(mockMvc.perform(get("/api/rent-payments/{paymentId}/receipt", paymentId).with(owner()))
|
||||
.andExpect(status().isOk()).andExpect(content().contentType(MediaType.APPLICATION_PDF))
|
||||
@@ -168,12 +192,12 @@ class ReplacementRentalApiJourneyIT {
|
||||
.andReturn());
|
||||
|
||||
assertForeignPortfolioIsIsolated(apartmentId, tenantId, attachmentId, leaseId, dueId, paymentId,
|
||||
surplusPaymentId, refundId);
|
||||
surplusPaymentId);
|
||||
}
|
||||
|
||||
private void assertForeignPortfolioIsIsolated(String apartmentId, String tenantId, String attachmentId,
|
||||
String leaseId, String dueId, String paymentId,
|
||||
String surplusPaymentId, String refundId) throws Exception {
|
||||
String surplusPaymentId) throws Exception {
|
||||
mockMvc.perform(get("/api/apartments").with(otherLandlord()))
|
||||
.andExpect(status().isOk()).andExpect(jsonPath("$.length()").value(0));
|
||||
mockMvc.perform(get("/api/tenants").with(otherLandlord()))
|
||||
@@ -255,43 +279,27 @@ class ReplacementRentalApiJourneyIT {
|
||||
|
||||
mockMvc.perform(post("/api/rent-payments").with(otherLandlord()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content("""
|
||||
{"leaseId":"%s","receivedAt":"2026-07-09","amount":1.00}
|
||||
""".formatted(leaseId)))
|
||||
.andExpect(status().isNotFound());
|
||||
mockMvc.perform(put("/api/rent-payments/{id}", paymentId).with(otherLandlord()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"receivedAt\":\"2026-07-09\",\"amount\":1.00}"))
|
||||
.andExpect(status().isNotFound());
|
||||
mockMvc.perform(delete("/api/rent-payments/{id}", paymentId).with(otherLandlord()).with(csrf()))
|
||||
.andExpect(status().isNotFound());
|
||||
mockMvc.perform(post("/api/rent-payments/{id}/confirm", paymentId).with(otherLandlord()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content("{\"allocations\":[]}"))
|
||||
{"operationId":"foreign-payment","rentDueId":"%s","rentDueVersion":1,
|
||||
"receivedAt":"2026-07-09","amount":1.00}
|
||||
""".formatted(dueId)))
|
||||
.andExpect(status().isNotFound());
|
||||
mockMvc.perform(post("/api/rent-payments/{id}/allocations", paymentId)
|
||||
.with(otherLandlord()).with(csrf()).contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"rentDueId\":\"%s\",\"amount\":1.00}".formatted(dueId)))
|
||||
.content("""
|
||||
{"operationId":"foreign-allocation","paymentVersion":0,"rentDueId":"%s",
|
||||
"rentDueVersion":1,"amount":1.00}
|
||||
""".formatted(dueId)))
|
||||
.andExpect(status().isNotFound());
|
||||
mockMvc.perform(post("/api/rent-payments/{id}/reversal", paymentId)
|
||||
.with(otherLandlord()).with(csrf()).contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"reason\":\"Foreign mutation\"}"))
|
||||
.andExpect(status().isNotFound());
|
||||
mockMvc.perform(get("/api/rent-payments/{id}/reversal", surplusPaymentId).with(otherLandlord()))
|
||||
.andExpect(status().isNotFound());
|
||||
mockMvc.perform(get("/api/rent-payments/{id}/refunds", surplusPaymentId).with(otherLandlord()))
|
||||
.content("{\"paymentVersion\":0,\"reason\":\"Foreign mutation\"}"))
|
||||
.andExpect(status().isNotFound());
|
||||
mockMvc.perform(post("/api/rent-payments/{id}/refunds", surplusPaymentId)
|
||||
.with(otherLandlord()).with(csrf()).contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"amount\":0.25,\"refundedAt\":\"2026-07-10\"}"))
|
||||
.andExpect(status().isNotFound());
|
||||
mockMvc.perform(put("/api/rent-payments/{paymentId}/refunds/{refundId}", surplusPaymentId, refundId)
|
||||
.with(otherLandlord()).with(csrf()).contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"amount\":0.25,\"refundedAt\":\"2026-07-10\"}"))
|
||||
.andExpect(status().isNotFound());
|
||||
mockMvc.perform(delete("/api/rent-payments/{paymentId}/refunds/{refundId}", surplusPaymentId, refundId)
|
||||
.with(otherLandlord()).with(csrf()))
|
||||
.andExpect(status().isNotFound());
|
||||
mockMvc.perform(post("/api/rent-payments/{paymentId}/refunds/{refundId}/confirm",
|
||||
surplusPaymentId, refundId).with(otherLandlord()).with(csrf()))
|
||||
.content("""
|
||||
{"operationId":"foreign-refund","paymentVersion":2,"amount":0.25,
|
||||
"refundedAt":"2026-07-10"}
|
||||
"""))
|
||||
.andExpect(status().isNotFound());
|
||||
|
||||
mockMvc.perform(get("/api/leases/{id}/pdf", leaseId).with(otherLandlord()))
|
||||
@@ -313,8 +321,10 @@ class ReplacementRentalApiJourneyIT {
|
||||
.andExpect(jsonPath("$.monthlyRent").value(900.00));
|
||||
mockMvc.perform(get("/api/rent-payments/{id}", paymentId).with(owner()))
|
||||
.andExpect(status().isOk()).andExpect(jsonPath("$.status").value("CONFIRMED"));
|
||||
mockMvc.perform(get("/api/rent-payments/{id}/refunds", surplusPaymentId).with(owner()))
|
||||
.andExpect(status().isOk()).andExpect(jsonPath("$[0].status").value("CONFIRMED"));
|
||||
mockMvc.perform(get("/api/rent-payments/{id}", surplusPaymentId).with(owner()))
|
||||
.andExpect(status().isOk()).andExpect(jsonPath("$.status").value("REVERSED"))
|
||||
.andExpect(jsonPath("$.refunds[0].id").value("refund-1"))
|
||||
.andExpect(jsonPath("$.reversal.reason").value("Correction après remboursement"));
|
||||
}
|
||||
|
||||
private String create(String path, String request) throws Exception {
|
||||
|
||||
Reference in new issue
Block a user