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:
nessar committed 2026-08-17 14:18:15 +02:00
1 parent e02ad54bcb
commit b9e99348f7
76 files changed
+3141 -1531

No files matched your search

@@ -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
@@ -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());
}
@@ -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());
}
}
@@ -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) {
@@ -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);
}
}
}
@@ -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(),
@@ -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,
@@ -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);
@@ -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);
});
}
}
@@ -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) {
}
}
@@ -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));
@@ -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;
}
}
@@ -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"));
}
}
@@ -0,0 +1,7 @@
package fr.nessar.lmnporganizer.rental.application.rent;
public final class RentPaymentConflict extends RuntimeException {
public RentPaymentConflict(String message) {
super(message);
}
}
@@ -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"));
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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"));
}
}
@@ -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é.");
@@ -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);
}
}
@@ -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; }
}
@@ -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);
}
@@ -1,6 +0,0 @@
package fr.nessar.lmnporganizer.rental.domain.rent;
public enum RentPaymentRefundStatus {
DRAFT,
CONFIRMED
}
@@ -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) {
}
@@ -14,6 +14,4 @@ public interface RentPaymentRepository {
Optional<RentPayment> find(LandlordId landlordId, RentPaymentId paymentId);
RentPayment save(RentPayment payment);
boolean deleteDraft(RentPayment payment);
}
@@ -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,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>
@@ -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());
}
}
@@ -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() {
@@ -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()
@@ -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
@@ -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));
@@ -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();
}
}
}
@@ -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();
}
}
}
@@ -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();
}
}
}
@@ -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();
}
}
}
@@ -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")));
}
}
@@ -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);
}
}
@@ -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));
}
}
@@ -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);
@@ -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();
}
}
}
@@ -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);
}
}
@@ -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) {
@@ -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 {