feat: add rent payment history and reversal dialogs
- Implemented RentPaymentHistoryServiceTest to validate payment retrieval with refunds and reversals. - Created ReverseRentPaymentServiceTest to ensure correct handling of payment reversals and allocation releases. - Developed rent-payment-allocation-dialog component with form for surplus allocation. - Added rent-payment-refund-dialog component for processing refunds with validation. - Introduced rent-payment-reversal-dialog component for reversing payments with reason input. - Implemented utility functions for formatting Euro values and handling date formatting. - Enhanced rent-payment-dialogs.spec.ts to cover new dialog functionalities and error handling.
This commit is contained in:
1 parent
e02ad54bcb
commit
b9e99348f7
76 files changed
+3094
-1484
No files matched your search
Vendored
+5
-2
@@ -8,7 +8,7 @@
|
||||
"presentation": {
|
||||
"hidden": true
|
||||
},
|
||||
"mainClass": "fr.nessar.lmnporganizer.backend.BackendApplication",
|
||||
"mainClass": "fr.nessar.lmnporganizer.LmnpOrganizerApplication",
|
||||
"projectName": "backend",
|
||||
"cwd": "${workspaceFolder}/backend",
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
@@ -34,7 +34,10 @@
|
||||
"compounds": [
|
||||
{
|
||||
"name": "Debug whole app",
|
||||
"configurations": ["Backend: Spring Boot", "Frontend: Angular"],
|
||||
"configurations": [
|
||||
"Backend: Spring Boot",
|
||||
"Frontend: Angular"
|
||||
],
|
||||
"stopAll": true,
|
||||
"presentation": {
|
||||
"order": 1
|
||||
|
||||
+5
-5
@@ -33,11 +33,11 @@ Loyer mensuel créé uniquement à l'activation d'un bail, généré à partir d
|
||||
_Avoid_: Paiement, mensualité
|
||||
|
||||
**Règlement de loyer** (Code: `RentPayment`):
|
||||
Encaissement daté d'un montant strictement positif pouvant être affecté en tout ou partie à des échéances de loyer, sans que la somme de ses affectations dépasse le montant reçu ; le surplus reste non affecté jusqu'à son report, son remboursement ou un autre traitement. Un règlement en brouillon est modifiable ou supprimable, tandis qu'un règlement confirmé est immuable et ne peut être corrigé que par contrepassation puis remplacement.
|
||||
_Avoid_: Échéance, mensualité
|
||||
Encaissement déjà reçu, donc jamais daté dans le futur selon le calendrier UTC, et d'un montant strictement positif, pouvant être affecté en tout ou partie à des échéances de loyer sans que la somme de ses affectations dépasse le montant reçu ; le surplus reste non affecté jusqu'à son report, son remboursement ou un autre traitement. Un règlement est enregistré comme un fait confirmé et immuable qui ne peut être corrigé que par contrepassation puis remplacement.
|
||||
_Avoid_: Échéance, mensualité, marquage manuel comme payé
|
||||
|
||||
**Contrepassation de règlement** (Code: `RentPaymentReversal`):
|
||||
Écriture immuable et consultable qui annule les effets d'un règlement confirmé sans effacer le fait historique ni sa trace d'audit. Ses affectations sont annulées et un éventuel règlement corrigé est enregistré séparément.
|
||||
Écriture immuable, motivée et consultable qui annule les effets d'un règlement confirmé sans effacer le fait historique ni sa trace d'audit. Ses affectations sont annulées, ses remboursements déjà confirmés restent des sorties historiques immuables, et un éventuel règlement corrigé est enregistré séparément.
|
||||
_Avoid_: Suppression, modification, correction silencieuse
|
||||
|
||||
**Quittance de loyer** (Code: `RentReceipt`):
|
||||
@@ -49,11 +49,11 @@ Document attestant le montant total d'un règlement confirmé et détaillant ses
|
||||
_Avoid_: Quittance de loyer
|
||||
|
||||
**Remboursement de trop-perçu** (Code: `RentPaymentRefund`):
|
||||
Restitution positive de tout ou partie du montant non affecté d'un règlement confirmé, sans jamais dépasser son solde non affecté. Une fois confirmé, le remboursement est immuable afin de préserver la trace d'audit.
|
||||
Restitution déjà effectuée, donc jamais datée dans le futur selon le calendrier UTC, de tout ou partie du montant non affecté d'un règlement confirmé, sans jamais dépasser son solde non affecté. Un remboursement est enregistré comme un fait confirmé et immuable afin de préserver la trace d'audit.
|
||||
_Avoid_: Suppression du règlement, affectation négative
|
||||
|
||||
**Affectation de règlement** (Code: `PaymentAllocation`):
|
||||
Part d'un règlement attribuée à une échéance de loyer sans dépasser le solde restant de celle-ci. Un règlement peut être réparti entre plusieurs échéances d'un même bail, mais jamais entre plusieurs baux.
|
||||
Part d'un règlement attribuée à une échéance de loyer non réglée ou partiellement réglée, sans dépasser le solde restant de celle-ci. Un règlement peut être réparti entre plusieurs échéances d'un même bail, mais jamais entre plusieurs baux.
|
||||
_Avoid_: Règlement, paiement
|
||||
|
||||
**Échéancier courant**:
|
||||
|
||||
+20
-29
@@ -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,
|
||||
RefundRentPaymentService refundRentPaymentService(RentPaymentRepository payments,
|
||||
RentPaymentRefundRepository refunds,
|
||||
TransactionRunner transactions,
|
||||
Clock rentalClock) {
|
||||
return new ConfirmRentPaymentRefundService(payments, refunds, transactions, rentalClock);
|
||||
TransactionRunner transactions, Clock rentalClock) {
|
||||
return new RefundRentPaymentService(payments, refunds, transactions, rentalClock);
|
||||
}
|
||||
|
||||
@Bean
|
||||
RentPaymentHistoryService rentPaymentHistoryService(RentPaymentRepository payments,
|
||||
RentPaymentRefundRepository refunds,
|
||||
RentPaymentReversalRepository reversals,
|
||||
LeaseRepository leases) {
|
||||
return new RentPaymentHistoryService(payments, refunds, reversals, leases);
|
||||
}
|
||||
|
||||
@Bean
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
package fr.nessar.lmnporganizer.rental.adapter.in.web.common;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.RentPaymentConflict;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentStorageFailure;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentTooLarge;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
|
||||
@@ -28,7 +29,7 @@ public class RentalApiExceptionHandler {
|
||||
return error(HttpStatus.BAD_REQUEST, exception.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler({ReferencedResource.class, EmailAlreadyUsed.class})
|
||||
@ExceptionHandler({ReferencedResource.class, EmailAlreadyUsed.class, RentPaymentConflict.class})
|
||||
ResponseEntity<ApiError> conflict(RuntimeException exception) {
|
||||
return error(HttpStatus.CONFLICT, exception.getMessage());
|
||||
}
|
||||
|
||||
+20
-22
@@ -1,14 +1,14 @@
|
||||
package fr.nessar.lmnporganizer.rental.adapter.in.web.rent;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.RentPaymentHistory;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.PaymentAllocation;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPayment;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefund;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversal;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
@@ -18,26 +18,21 @@ final class RentPaymentApi {
|
||||
private RentPaymentApi() {
|
||||
}
|
||||
|
||||
record CreateRequest(@NotBlank String leaseId, @NotNull LocalDate receivedAt,
|
||||
record CreateRequest(@NotBlank String operationId, @NotBlank String rentDueId,
|
||||
@NotNull @PositiveOrZero Long rentDueVersion, @NotNull LocalDate receivedAt,
|
||||
@NotNull @Positive BigDecimal amount) {
|
||||
}
|
||||
|
||||
record ReviseRequest(@NotNull LocalDate receivedAt, @NotNull @Positive BigDecimal amount) {
|
||||
record AllocationRequest(@NotBlank String operationId, @NotNull @PositiveOrZero Long paymentVersion,
|
||||
@NotBlank String rentDueId, @NotNull @PositiveOrZero Long rentDueVersion,
|
||||
@NotNull @Positive BigDecimal amount) {
|
||||
}
|
||||
|
||||
record AllocationRequest(@NotBlank String rentDueId, @NotNull @Positive BigDecimal amount) {
|
||||
record ReversalRequest(@NotNull @PositiveOrZero Long paymentVersion, @NotBlank @Size(max = 500) String reason) {
|
||||
}
|
||||
|
||||
record ConfirmRequest(List<@Valid AllocationRequest> allocations) {
|
||||
ConfirmRequest {
|
||||
allocations = allocations == null ? List.of() : List.copyOf(allocations);
|
||||
}
|
||||
}
|
||||
|
||||
record ReversalRequest(@NotBlank String reason) {
|
||||
}
|
||||
|
||||
record RefundRequest(@NotNull @Positive BigDecimal amount, @NotNull LocalDate refundedAt) {
|
||||
record RefundRequest(@NotBlank String operationId, @NotNull @PositiveOrZero Long paymentVersion,
|
||||
@NotNull @Positive BigDecimal amount, @NotNull LocalDate refundedAt) {
|
||||
}
|
||||
|
||||
record AllocationResponse(String id, String rentDueId, BigDecimal amount, LocalDate allocatedAt) {
|
||||
@@ -49,21 +44,24 @@ final class RentPaymentApi {
|
||||
|
||||
record Response(String id, String leaseId, LocalDate receivedAt, BigDecimal amount, String status,
|
||||
List<AllocationResponse> allocations, BigDecimal allocatedAmount, BigDecimal refundedAmount,
|
||||
BigDecimal unallocatedBalance, LocalDate confirmedAt, long version) {
|
||||
static Response from(RentPayment payment) {
|
||||
BigDecimal unallocatedBalance, LocalDate confirmedAt, long version,
|
||||
List<RefundResponse> refunds, ReversalResponse reversal) {
|
||||
static Response from(RentPaymentHistory history) {
|
||||
var payment = history.payment();
|
||||
return new Response(payment.id().value(), payment.leaseId().value(), payment.receivedAt(),
|
||||
payment.amount(), payment.status().name(),
|
||||
payment.allocations().stream().map(AllocationResponse::from).toList(), payment.allocatedAmount(),
|
||||
payment.refundedAmount(), payment.unallocatedBalance(), payment.confirmedAt(), payment.version());
|
||||
payment.refundedAmount(), payment.unallocatedBalance(), payment.confirmedAt(), payment.version(),
|
||||
history.refunds().stream().map(RefundResponse::from).toList(),
|
||||
history.reversal().map(ReversalResponse::from).orElse(null));
|
||||
}
|
||||
}
|
||||
|
||||
record RefundResponse(String id, String leaseId, String paymentId, BigDecimal amount, LocalDate refundedAt,
|
||||
String status, LocalDate confirmedAt, long version) {
|
||||
long version) {
|
||||
static RefundResponse from(RentPaymentRefund refund) {
|
||||
return new RefundResponse(refund.id().value(), refund.leaseId().value(), refund.paymentId().value(),
|
||||
refund.amount(), refund.refundedAt(), refund.status().name(), refund.confirmedAt(),
|
||||
refund.version());
|
||||
refund.amount(), refund.refundedAt(), refund.version());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+37
-106
@@ -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,
|
||||
ResponseEntity<RentPaymentApi.Response> 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)));
|
||||
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,
|
||||
ResponseEntity<RentPaymentApi.Response> refund(@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) {
|
||||
LandlordId landlordId = authenticatedLandlord.id(principal);
|
||||
requirePayment(refundDrafts.get(landlordId, new RentPaymentRefundId(refundId)), paymentId);
|
||||
return RentPaymentApi.RefundResponse.from(refundDrafts.revise(landlordId,
|
||||
new RentPaymentRefundId(refundId), request.amount(), request.refundedAt()));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{paymentId}/refunds/{refundId}")
|
||||
ResponseEntity<Void> deleteRefund(@AuthenticationPrincipal OidcUser principal,
|
||||
@PathVariable String paymentId, @PathVariable String refundId) {
|
||||
LandlordId landlordId = authenticatedLandlord.id(principal);
|
||||
requirePayment(refundDrafts.get(landlordId, new RentPaymentRefundId(refundId)), paymentId);
|
||||
refundDrafts.delete(landlordId, new RentPaymentRefundId(refundId));
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping("/{paymentId}/refunds/{refundId}/confirm")
|
||||
RentPaymentApi.RefundResponse confirmRefund(@AuthenticationPrincipal OidcUser principal,
|
||||
@PathVariable String paymentId, @PathVariable String refundId) {
|
||||
LandlordId landlordId = authenticatedLandlord.id(principal);
|
||||
requirePayment(refundDrafts.get(landlordId, new RentPaymentRefundId(refundId)), paymentId);
|
||||
return RentPaymentApi.RefundResponse.from(refundConfirmation.confirm(landlordId,
|
||||
new RentPaymentRefundId(refundId)));
|
||||
}
|
||||
|
||||
private static void requirePayment(RentPaymentRefund refund, String paymentId) {
|
||||
if (!refund.paymentId().equals(new RentPaymentId(paymentId))) {
|
||||
throw new ResourceNotFound("Rent payment refund");
|
||||
}
|
||||
RentPaymentId id = new RentPaymentId(paymentId);
|
||||
refunds.refund(landlordId, id, request.operationId(), request.paymentVersion(), request.amount(),
|
||||
request.refundedAt());
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(RentPaymentApi.Response.from(history.get(landlordId,
|
||||
id)));
|
||||
}
|
||||
|
||||
private static ResponseEntity<byte[]> document(GeneratedDocument document) {
|
||||
|
||||
+9
-22
@@ -7,7 +7,6 @@ import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefund;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefundId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefundRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefundStatus;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.annotation.Version;
|
||||
import org.springframework.data.mongodb.core.MongoTemplate;
|
||||
@@ -32,6 +31,12 @@ public class MongoRentPaymentRefundRepository implements RentPaymentRefundReposi
|
||||
this.mongoTemplate = mongoTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<RentPaymentRefund> findAll(LandlordId landlordId) {
|
||||
return mongoTemplate.find(Query.query(Criteria.where("landlordId").is(landlordId.value())),
|
||||
RefundDocument.class).stream().map(this::toDomain).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<RentPaymentRefund> findAll(LandlordId landlordId, RentPaymentId paymentId) {
|
||||
Query query = Query.query(Criteria.where("landlordId").is(landlordId.value())
|
||||
@@ -52,36 +57,20 @@ public class MongoRentPaymentRefundRepository implements RentPaymentRefundReposi
|
||||
refund.version(), document::withVersion));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deleteDraft(RentPaymentRefund refund) {
|
||||
Query query = scoped(refund.landlordId(), refund.id());
|
||||
query.addCriteria(Criteria.where("status").is(RentPaymentRefundStatus.DRAFT.name()));
|
||||
query.addCriteria(versionCriteria(refund.version()));
|
||||
return mongoTemplate.remove(query, RefundDocument.class).getDeletedCount() == 1;
|
||||
}
|
||||
|
||||
private static Query scoped(LandlordId landlordId, RentPaymentRefundId refundId) {
|
||||
return Query.query(Criteria.where("_id").is(refundId.value())
|
||||
.and("landlordId").is(landlordId.value()));
|
||||
}
|
||||
|
||||
private static Criteria versionCriteria(long version) {
|
||||
return version < 0
|
||||
? new Criteria().orOperator(Criteria.where("version").exists(false), Criteria.where("version").is(null))
|
||||
: Criteria.where("version").is(version);
|
||||
}
|
||||
|
||||
private RefundDocument toDocument(RentPaymentRefund refund) {
|
||||
return new RefundDocument(refund.id().value(), refund.landlordId().value(), refund.leaseId().value(),
|
||||
refund.paymentId().value(), refund.amount(), refund.refundedAt(), refund.status().name(),
|
||||
refund.confirmedAt(), persistedVersion(refund.version()));
|
||||
refund.paymentId().value(), refund.amount(), refund.refundedAt(), persistedVersion(refund.version()));
|
||||
}
|
||||
|
||||
private RentPaymentRefund toDomain(RefundDocument document) {
|
||||
return RentPaymentRefund.reconstitute(new RentPaymentRefundId(document.id()),
|
||||
new LandlordId(document.landlordId()), new LeaseId(document.leaseId()),
|
||||
new RentPaymentId(document.paymentId()), document.amount(), document.refundedAt(),
|
||||
RentPaymentRefundStatus.valueOf(document.status()), document.confirmedAt(),
|
||||
domainVersion(document.version()));
|
||||
}
|
||||
|
||||
@@ -96,11 +85,9 @@ public class MongoRentPaymentRefundRepository implements RentPaymentRefundReposi
|
||||
@Document(collection = "rentPaymentRefunds")
|
||||
record RefundDocument(@Id String id, @Indexed String landlordId, @Indexed String leaseId,
|
||||
@Indexed String paymentId, @Field(targetType = FieldType.DECIMAL128) BigDecimal amount,
|
||||
LocalDate refundedAt, String status,
|
||||
LocalDate confirmedAt, @Version Long version) {
|
||||
LocalDate refundedAt, @Version Long version) {
|
||||
RefundDocument withVersion(Long version) {
|
||||
return new RefundDocument(id, landlordId, leaseId, paymentId, amount, refundedAt, status, confirmedAt,
|
||||
version);
|
||||
return new RefundDocument(id, landlordId, leaseId, paymentId, amount, refundedAt, version);
|
||||
}
|
||||
}
|
||||
}
|
||||
-14
@@ -58,14 +58,6 @@ public class MongoRentPaymentRepository implements RentPaymentRepository {
|
||||
payment.version(), document::withVersion));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deleteDraft(RentPayment payment) {
|
||||
Query query = scoped(payment.landlordId(), payment.id());
|
||||
query.addCriteria(Criteria.where("status").is(RentPaymentStatus.DRAFT.name()));
|
||||
query.addCriteria(versionCriteria(payment.version()));
|
||||
return mongoTemplate.remove(query, RentPaymentDocument.class).getDeletedCount() == 1;
|
||||
}
|
||||
|
||||
private List<RentPayment> find(Query query) {
|
||||
return mongoTemplate.find(query, RentPaymentDocument.class).stream().map(this::toDomain).toList();
|
||||
}
|
||||
@@ -75,12 +67,6 @@ public class MongoRentPaymentRepository implements RentPaymentRepository {
|
||||
.and("landlordId").is(landlordId.value()));
|
||||
}
|
||||
|
||||
private static Criteria versionCriteria(long version) {
|
||||
return version < 0
|
||||
? new Criteria().orOperator(Criteria.where("version").exists(false), Criteria.where("version").is(null))
|
||||
: Criteria.where("version").is(version);
|
||||
}
|
||||
|
||||
private RentPaymentDocument toDocument(RentPayment payment) {
|
||||
return new RentPaymentDocument(payment.id().value(), payment.landlordId().value(), payment.leaseId().value(),
|
||||
payment.receivedAt(), payment.amount(), payment.status().name(),
|
||||
|
||||
+1
-2
@@ -46,8 +46,7 @@ public interface RentalDocumentRenderer {
|
||||
BigDecimal amount, LocalDate allocatedAt) {
|
||||
}
|
||||
|
||||
record ReceiptRefund(RentPaymentRefundId refundId, BigDecimal amount, LocalDate refundedAt,
|
||||
LocalDate confirmedAt) {
|
||||
record ReceiptRefund(RentPaymentRefundId refundId, BigDecimal amount, LocalDate refundedAt) {
|
||||
}
|
||||
|
||||
record RentReceipt(RentDueId rentDueId, YearMonth month, BigDecimal rent, BigDecimal charges,
|
||||
|
||||
+27
-8
@@ -1,7 +1,7 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rent;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.IdentifierGenerator;
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.PaymentAllocation;
|
||||
@@ -9,6 +9,7 @@ import fr.nessar.lmnporganizer.rental.domain.rent.PaymentAllocationId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDue;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueSettlement;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPayment;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRepository;
|
||||
@@ -22,30 +23,48 @@ public final class AllocateRentPaymentService {
|
||||
private final RentPaymentRepository payments;
|
||||
private final RentDueRepository rentDues;
|
||||
private final TransactionRunner transactions;
|
||||
private final IdentifierGenerator identifiers;
|
||||
private final Clock clock;
|
||||
|
||||
public AllocateRentPaymentService(RentPaymentRepository payments, RentDueRepository rentDues,
|
||||
TransactionRunner transactions, IdentifierGenerator identifiers, Clock clock) {
|
||||
TransactionRunner transactions, Clock clock) {
|
||||
this.payments = payments;
|
||||
this.rentDues = rentDues;
|
||||
this.transactions = transactions;
|
||||
this.identifiers = identifiers;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public RentPayment allocate(LandlordId landlordId, RentPaymentId paymentId, RentDueId rentDueId,
|
||||
BigDecimal amount) {
|
||||
public RentPayment allocate(LandlordId landlordId, RentPaymentId paymentId, String operationId,
|
||||
long paymentVersion, RentDueId rentDueId, long rentDueVersion, BigDecimal amount) {
|
||||
return transactions.run(() -> {
|
||||
RentPayment payment = payments.find(landlordId, paymentId)
|
||||
.orElseThrow(() -> new ResourceNotFound("Rent payment"));
|
||||
PaymentAllocationId allocationId = new PaymentAllocationId(operationId);
|
||||
PaymentAllocation existing = payment.allocations().stream()
|
||||
.filter(allocation -> allocation.id().equals(allocationId)).findFirst().orElse(null);
|
||||
if (existing != null) {
|
||||
if (!existing.rentDueId().equals(rentDueId) || amount == null
|
||||
|| existing.amount().compareTo(amount) != 0) {
|
||||
throw new RentPaymentConflict(
|
||||
"Cet identifiant d'opération a déjà été utilisé avec d'autres données.");
|
||||
}
|
||||
return payment;
|
||||
}
|
||||
if (payment.version() != paymentVersion) {
|
||||
throw new ConcurrentChange("Rent payment");
|
||||
}
|
||||
RentDue due = rentDues.find(landlordId, rentDueId)
|
||||
.orElseThrow(() -> new ResourceNotFound("Rent due"));
|
||||
if (due.version() != rentDueVersion) {
|
||||
throw new ConcurrentChange("Rent due");
|
||||
}
|
||||
if (due.settlement() == RentDueSettlement.PAID) {
|
||||
throw new RentPaymentConflict("Une échéance déjà réglée ne peut pas recevoir une affectation.");
|
||||
}
|
||||
if (!payment.leaseId().equals(due.leaseId())) {
|
||||
throw new RentRuleViolation("Une affectation doit viser une échéance du même bail.");
|
||||
}
|
||||
PaymentAllocation allocation = new PaymentAllocation(new PaymentAllocationId(identifiers.next()),
|
||||
due.id(), amount, LocalDate.now(clock));
|
||||
PaymentAllocation allocation = new PaymentAllocation(allocationId, due.id(), amount,
|
||||
LocalDate.now(clock));
|
||||
payment.allocate(allocation);
|
||||
due.allocate(amount);
|
||||
rentDues.save(due);
|
||||
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rent;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPayment;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefund;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefundId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefundRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRepository;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDate;
|
||||
|
||||
public final class ConfirmRentPaymentRefundService {
|
||||
private final RentPaymentRepository payments;
|
||||
private final RentPaymentRefundRepository refunds;
|
||||
private final TransactionRunner transactions;
|
||||
private final Clock clock;
|
||||
|
||||
public ConfirmRentPaymentRefundService(RentPaymentRepository payments, RentPaymentRefundRepository refunds,
|
||||
TransactionRunner transactions, Clock clock) {
|
||||
this.payments = payments;
|
||||
this.refunds = refunds;
|
||||
this.transactions = transactions;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public RentPaymentRefund confirm(LandlordId landlordId, RentPaymentRefundId refundId) {
|
||||
return transactions.run(() -> {
|
||||
RentPaymentRefund refund = refunds.find(landlordId, refundId)
|
||||
.orElseThrow(() -> new ResourceNotFound("Rent payment refund"));
|
||||
RentPayment payment = payments.find(landlordId, refund.paymentId())
|
||||
.orElseThrow(() -> new ResourceNotFound("Rent payment"));
|
||||
refund.confirm(payment, LocalDate.now(clock));
|
||||
payments.save(payment);
|
||||
return refunds.save(refund);
|
||||
});
|
||||
}
|
||||
}
|
||||
-74
@@ -1,74 +0,0 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rent;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.IdentifierGenerator;
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.PaymentAllocation;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.PaymentAllocationId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDue;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPayment;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentRuleViolation;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
|
||||
public final class ConfirmRentPaymentService {
|
||||
private final RentPaymentRepository payments;
|
||||
private final RentDueRepository rentDues;
|
||||
private final TransactionRunner transactions;
|
||||
private final IdentifierGenerator identifiers;
|
||||
private final Clock clock;
|
||||
|
||||
public ConfirmRentPaymentService(RentPaymentRepository payments, RentDueRepository rentDues,
|
||||
TransactionRunner transactions, IdentifierGenerator identifiers, Clock clock) {
|
||||
this.payments = payments;
|
||||
this.rentDues = rentDues;
|
||||
this.transactions = transactions;
|
||||
this.identifiers = identifiers;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public RentPayment confirm(LandlordId landlordId, RentPaymentId paymentId, List<RequestedAllocation> requested) {
|
||||
return transactions.run(() -> confirmInTransaction(landlordId, paymentId, requested));
|
||||
}
|
||||
|
||||
private RentPayment confirmInTransaction(LandlordId landlordId, RentPaymentId paymentId,
|
||||
List<RequestedAllocation> requested) {
|
||||
RentPayment payment = payments.find(landlordId, paymentId)
|
||||
.orElseThrow(() -> new ResourceNotFound("Rent payment"));
|
||||
LocalDate confirmationDate = LocalDate.now(clock);
|
||||
List<RequestedAllocation> allocationsToApply = requested == null ? List.of() : List.copyOf(requested);
|
||||
if (new HashSet<>(allocationsToApply.stream().map(RequestedAllocation::rentDueId).toList()).size()
|
||||
!= allocationsToApply.size()) {
|
||||
throw new RentRuleViolation("Un règlement ne peut avoir qu'une affectation par échéance.");
|
||||
}
|
||||
List<RentDue> affectedDues = new ArrayList<>();
|
||||
List<PaymentAllocation> allocations = new ArrayList<>();
|
||||
for (RequestedAllocation allocation : allocationsToApply) {
|
||||
RentDue due = rentDues.find(landlordId, allocation.rentDueId())
|
||||
.orElseThrow(() -> new ResourceNotFound("Rent due"));
|
||||
if (!payment.leaseId().equals(due.leaseId())) {
|
||||
throw new RentRuleViolation("Une affectation doit viser une échéance du même bail.");
|
||||
}
|
||||
due.allocate(allocation.amount());
|
||||
affectedDues.add(due);
|
||||
allocations.add(new PaymentAllocation(new PaymentAllocationId(identifiers.next()), due.id(),
|
||||
allocation.amount(), confirmationDate));
|
||||
}
|
||||
payment.confirm(allocations, confirmationDate);
|
||||
rentDues.saveAll(affectedDues);
|
||||
return payments.save(payment);
|
||||
}
|
||||
|
||||
public record RequestedAllocation(RentDueId rentDueId, BigDecimal amount) {
|
||||
}
|
||||
}
|
||||
+1
-4
@@ -17,7 +17,6 @@ import fr.nessar.lmnporganizer.rental.domain.rent.RentPayment;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefundRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefundStatus;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentStatus;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentRuleViolation;
|
||||
|
||||
@@ -50,9 +49,7 @@ public final class GeneratePaymentReceiptService {
|
||||
payment.amount(), payment.refundedAmount(), payment.unallocatedBalance(), contract,
|
||||
payment.allocations().stream().map(allocation -> allocation(allocation, landlordId)).toList(),
|
||||
refunds.findAll(landlordId, paymentId).stream()
|
||||
.filter(refund -> refund.status() == RentPaymentRefundStatus.CONFIRMED)
|
||||
.map(refund -> new ReceiptRefund(refund.id(), refund.amount(), refund.refundedAt(),
|
||||
refund.confirmedAt()))
|
||||
.map(refund -> new ReceiptRefund(refund.id(), refund.amount(), refund.refundedAt()))
|
||||
.toList());
|
||||
return new GeneratedDocument("recu-reglement-" + payment.id().value() + ".pdf", "application/pdf",
|
||||
renderer.renderPaymentReceipt(receipt));
|
||||
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rent;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.PaymentAllocation;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.PaymentAllocationId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDue;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueSettlement;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPayment;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentRuleViolation;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
public final class RecordRentPaymentService {
|
||||
private final RentPaymentRepository payments;
|
||||
private final RentDueRepository rentDues;
|
||||
private final TransactionRunner transactions;
|
||||
private final Clock clock;
|
||||
|
||||
public RecordRentPaymentService(RentPaymentRepository payments, RentDueRepository rentDues,
|
||||
TransactionRunner transactions, Clock clock) {
|
||||
this.payments = payments;
|
||||
this.rentDues = rentDues;
|
||||
this.transactions = transactions;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public RentPayment record(LandlordId landlordId, String operationId, RentDueId rentDueId, long rentDueVersion,
|
||||
LocalDate receivedAt, BigDecimal amount) {
|
||||
RentPaymentId paymentId = new RentPaymentId(operationId);
|
||||
return transactions.run(() -> payments.find(landlordId, paymentId)
|
||||
.map(existing -> idempotentPayment(existing, rentDueId, receivedAt, amount))
|
||||
.orElseGet(() -> recordInTransaction(landlordId, paymentId, rentDueId, rentDueVersion, receivedAt,
|
||||
amount)));
|
||||
}
|
||||
|
||||
private RentPayment idempotentPayment(RentPayment existing, RentDueId rentDueId, LocalDate receivedAt,
|
||||
BigDecimal amount) {
|
||||
boolean sameInitialDue = existing.allocations().stream()
|
||||
.filter(allocation -> allocation.id().value().equals(existing.id().value()))
|
||||
.anyMatch(allocation -> allocation.rentDueId().equals(rentDueId));
|
||||
if (!existing.receivedAt().equals(receivedAt) || !sameAmount(existing.amount(), amount) || !sameInitialDue) {
|
||||
throw new RentPaymentConflict("Cet identifiant d'opération a déjà été utilisé avec d'autres données.");
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
private RentPayment recordInTransaction(LandlordId landlordId, RentPaymentId paymentId, RentDueId rentDueId,
|
||||
long rentDueVersion, LocalDate receivedAt, BigDecimal amount) {
|
||||
LocalDate today = LocalDate.now(clock);
|
||||
if (receivedAt == null || receivedAt.isAfter(today)) {
|
||||
throw new RentRuleViolation("La date de réception du règlement ne peut pas être future.");
|
||||
}
|
||||
RentDue due = rentDues.find(landlordId, rentDueId).orElseThrow(() -> new ResourceNotFound("Rent due"));
|
||||
if (due.version() != rentDueVersion) {
|
||||
throw new ConcurrentChange("Rent due");
|
||||
}
|
||||
if (due.settlement() == RentDueSettlement.PAID) {
|
||||
throw new RentPaymentConflict("Une échéance déjà réglée ne peut pas recevoir un nouveau règlement.");
|
||||
}
|
||||
|
||||
RentPayment payment = RentPayment.confirmed(paymentId, landlordId, due.leaseId(), receivedAt, amount,
|
||||
List.of(), today);
|
||||
BigDecimal allocationAmount = payment.unallocatedBalance().min(due.remainingAmount());
|
||||
PaymentAllocation allocation = new PaymentAllocation(new PaymentAllocationId(paymentId.value()), due.id(),
|
||||
allocationAmount, today);
|
||||
payment.allocate(allocation);
|
||||
due.allocate(allocationAmount);
|
||||
rentDues.save(due);
|
||||
return payments.save(payment);
|
||||
}
|
||||
|
||||
private static boolean sameAmount(BigDecimal first, BigDecimal second) {
|
||||
return second != null && first.compareTo(second) == 0;
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rent;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPayment;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefund;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefundId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefundRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentRuleViolation;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDate;
|
||||
|
||||
public final class RefundRentPaymentService {
|
||||
private final RentPaymentRepository payments;
|
||||
private final RentPaymentRefundRepository refunds;
|
||||
private final TransactionRunner transactions;
|
||||
private final Clock clock;
|
||||
|
||||
public RefundRentPaymentService(RentPaymentRepository payments, RentPaymentRefundRepository refunds,
|
||||
TransactionRunner transactions, Clock clock) {
|
||||
this.payments = payments;
|
||||
this.refunds = refunds;
|
||||
this.transactions = transactions;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public RentPayment refund(LandlordId landlordId, RentPaymentId paymentId, String operationId,
|
||||
long paymentVersion, BigDecimal amount, LocalDate refundedAt) {
|
||||
RentPaymentRefundId refundId = new RentPaymentRefundId(operationId);
|
||||
return transactions.run(() -> refunds.find(landlordId, refundId)
|
||||
.map(existing -> idempotentPayment(landlordId, paymentId, amount, refundedAt, existing))
|
||||
.orElseGet(() -> refundInTransaction(landlordId, paymentId, refundId, paymentVersion, amount,
|
||||
refundedAt)));
|
||||
}
|
||||
|
||||
private RentPayment refundInTransaction(LandlordId landlordId, RentPaymentId paymentId,
|
||||
RentPaymentRefundId refundId, long paymentVersion, BigDecimal amount,
|
||||
LocalDate refundedAt) {
|
||||
LocalDate today = LocalDate.now(clock);
|
||||
if (refundedAt == null || refundedAt.isAfter(today)) {
|
||||
throw new RentRuleViolation("La date du remboursement ne peut pas être future.");
|
||||
}
|
||||
RentPayment payment = payment(landlordId, paymentId);
|
||||
if (payment.version() != paymentVersion) {
|
||||
throw new ConcurrentChange("Rent payment");
|
||||
}
|
||||
RentPaymentRefund refund = RentPaymentRefund.recorded(refundId, payment, amount, refundedAt);
|
||||
RentPayment saved = payments.save(payment);
|
||||
refunds.save(refund);
|
||||
return saved;
|
||||
}
|
||||
|
||||
private RentPayment idempotentPayment(LandlordId landlordId, RentPaymentId paymentId, BigDecimal amount,
|
||||
LocalDate refundedAt, RentPaymentRefund existing) {
|
||||
if (!existing.paymentId().equals(paymentId) || !existing.refundedAt().equals(refundedAt)
|
||||
|| amount == null || existing.amount().compareTo(amount) != 0) {
|
||||
throw new RentPaymentConflict("Cet identifiant d'opération a déjà été utilisé avec d'autres données.");
|
||||
}
|
||||
return payment(landlordId, paymentId);
|
||||
}
|
||||
|
||||
private RentPayment payment(LandlordId landlordId, RentPaymentId paymentId) {
|
||||
return payments.find(landlordId, paymentId).orElseThrow(() -> new ResourceNotFound("Rent payment"));
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rent;
|
||||
|
||||
public final class RentPaymentConflict extends RuntimeException {
|
||||
public RentPaymentConflict(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
-71
@@ -1,71 +0,0 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rent;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.IdentifierGenerator;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseStatus;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPayment;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentRuleViolation;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
public final class RentPaymentDraftService {
|
||||
private final LeaseRepository leases;
|
||||
private final RentPaymentRepository payments;
|
||||
private final IdentifierGenerator identifiers;
|
||||
|
||||
public RentPaymentDraftService(LeaseRepository leases, RentPaymentRepository payments,
|
||||
IdentifierGenerator identifiers) {
|
||||
this.leases = leases;
|
||||
this.payments = payments;
|
||||
this.identifiers = identifiers;
|
||||
}
|
||||
|
||||
public List<RentPayment> list(LandlordId landlordId) {
|
||||
return payments.findAll(landlordId);
|
||||
}
|
||||
|
||||
public List<RentPayment> list(LandlordId landlordId, LeaseId leaseId) {
|
||||
lease(landlordId, leaseId);
|
||||
return payments.findAll(landlordId, leaseId);
|
||||
}
|
||||
|
||||
public RentPayment get(LandlordId landlordId, RentPaymentId paymentId) {
|
||||
return payments.find(landlordId, paymentId).orElseThrow(() -> new ResourceNotFound("Rent payment"));
|
||||
}
|
||||
|
||||
public RentPayment create(LandlordId landlordId, LeaseId leaseId, LocalDate receivedAt, BigDecimal amount) {
|
||||
Lease lease = lease(landlordId, leaseId);
|
||||
if (lease.status() == LeaseStatus.DRAFT) {
|
||||
throw new RentRuleViolation("Un règlement ne peut pas être enregistré pour un bail en brouillon.");
|
||||
}
|
||||
return payments.save(RentPayment.draft(new RentPaymentId(identifiers.next()), landlordId, leaseId,
|
||||
receivedAt, amount));
|
||||
}
|
||||
|
||||
public RentPayment revise(LandlordId landlordId, RentPaymentId paymentId, LocalDate receivedAt,
|
||||
BigDecimal amount) {
|
||||
RentPayment payment = get(landlordId, paymentId);
|
||||
payment.revise(receivedAt, amount);
|
||||
return payments.save(payment);
|
||||
}
|
||||
|
||||
public void delete(LandlordId landlordId, RentPaymentId paymentId) {
|
||||
RentPayment payment = get(landlordId, paymentId);
|
||||
if (!payments.deleteDraft(payment)) {
|
||||
throw new ConcurrentChange("Rent payment");
|
||||
}
|
||||
}
|
||||
|
||||
private Lease lease(LandlordId landlordId, LeaseId leaseId) {
|
||||
return leases.find(landlordId, leaseId).orElseThrow(() -> new ResourceNotFound("Lease"));
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rent;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPayment;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefund;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversal;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
public record RentPaymentHistory(RentPayment payment, List<RentPaymentRefund> refunds,
|
||||
Optional<RentPaymentReversal> reversal) {
|
||||
public RentPaymentHistory {
|
||||
Objects.requireNonNull(payment);
|
||||
refunds = List.copyOf(refunds);
|
||||
reversal = Objects.requireNonNull(reversal);
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rent;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPayment;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefund;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefundRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversal;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversalRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public final class RentPaymentHistoryService {
|
||||
private final RentPaymentRepository payments;
|
||||
private final RentPaymentRefundRepository refunds;
|
||||
private final RentPaymentReversalRepository reversals;
|
||||
private final LeaseRepository leases;
|
||||
|
||||
public RentPaymentHistoryService(RentPaymentRepository payments, RentPaymentRefundRepository refunds,
|
||||
RentPaymentReversalRepository reversals, LeaseRepository leases) {
|
||||
this.payments = payments;
|
||||
this.refunds = refunds;
|
||||
this.reversals = reversals;
|
||||
this.leases = leases;
|
||||
}
|
||||
|
||||
public RentPaymentHistory get(LandlordId landlordId, RentPaymentId paymentId) {
|
||||
RentPayment payment = payments.find(landlordId, paymentId)
|
||||
.orElseThrow(() -> new ResourceNotFound("Rent payment"));
|
||||
return new RentPaymentHistory(payment, refunds.findAll(landlordId, paymentId),
|
||||
reversals.findByPayment(landlordId, paymentId));
|
||||
}
|
||||
|
||||
public List<RentPaymentHistory> list(LandlordId landlordId, LeaseId leaseId) {
|
||||
List<RentPayment> landlordPayments;
|
||||
if (leaseId == null) {
|
||||
landlordPayments = payments.findAll(landlordId);
|
||||
} else {
|
||||
leases.find(landlordId, leaseId).orElseThrow(() -> new ResourceNotFound("Lease"));
|
||||
landlordPayments = payments.findAll(landlordId, leaseId);
|
||||
}
|
||||
Map<RentPaymentId, List<RentPaymentRefund>> refundsByPayment = refunds.findAll(landlordId).stream()
|
||||
.collect(Collectors.groupingBy(RentPaymentRefund::paymentId));
|
||||
Map<RentPaymentId, RentPaymentReversal> reversalsByPayment = reversals.findAll(landlordId).stream()
|
||||
.collect(Collectors.toMap(RentPaymentReversal::paymentId, reversal -> reversal));
|
||||
return landlordPayments.stream().map(payment -> new RentPaymentHistory(payment,
|
||||
refundsByPayment.getOrDefault(payment.id(), List.of()),
|
||||
Optional.ofNullable(reversalsByPayment.get(payment.id())))).toList();
|
||||
}
|
||||
}
|
||||
-66
@@ -1,66 +0,0 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rent;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.IdentifierGenerator;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPayment;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefund;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefundId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefundRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRepository;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
public final class RentPaymentRefundDraftService {
|
||||
private final RentPaymentRepository payments;
|
||||
private final RentPaymentRefundRepository refunds;
|
||||
private final IdentifierGenerator identifiers;
|
||||
|
||||
public RentPaymentRefundDraftService(RentPaymentRepository payments, RentPaymentRefundRepository refunds,
|
||||
IdentifierGenerator identifiers) {
|
||||
this.payments = payments;
|
||||
this.refunds = refunds;
|
||||
this.identifiers = identifiers;
|
||||
}
|
||||
|
||||
public List<RentPaymentRefund> list(LandlordId landlordId, RentPaymentId paymentId) {
|
||||
payment(landlordId, paymentId);
|
||||
return refunds.findAll(landlordId, paymentId);
|
||||
}
|
||||
|
||||
public RentPaymentRefund get(LandlordId landlordId, RentPaymentRefundId refundId) {
|
||||
return refunds.find(landlordId, refundId)
|
||||
.orElseThrow(() -> new ResourceNotFound("Rent payment refund"));
|
||||
}
|
||||
|
||||
public RentPaymentRefund create(LandlordId landlordId, RentPaymentId paymentId, BigDecimal amount,
|
||||
LocalDate refundedAt) {
|
||||
RentPayment payment = payment(landlordId, paymentId);
|
||||
payment.ensureRefundable(amount);
|
||||
return refunds.save(RentPaymentRefund.draft(new RentPaymentRefundId(identifiers.next()), landlordId,
|
||||
payment.leaseId(), paymentId, amount, refundedAt));
|
||||
}
|
||||
|
||||
public RentPaymentRefund revise(LandlordId landlordId, RentPaymentRefundId refundId, BigDecimal amount,
|
||||
LocalDate refundedAt) {
|
||||
RentPaymentRefund refund = get(landlordId, refundId);
|
||||
payment(landlordId, refund.paymentId()).ensureRefundable(amount);
|
||||
refund.revise(amount, refundedAt);
|
||||
return refunds.save(refund);
|
||||
}
|
||||
|
||||
public void delete(LandlordId landlordId, RentPaymentRefundId refundId) {
|
||||
RentPaymentRefund refund = get(landlordId, refundId);
|
||||
if (!refunds.deleteDraft(refund)) {
|
||||
throw new ConcurrentChange("Rent payment refund");
|
||||
}
|
||||
}
|
||||
|
||||
private RentPayment payment(LandlordId landlordId, RentPaymentId paymentId) {
|
||||
return payments.find(landlordId, paymentId).orElseThrow(() -> new ResourceNotFound("Rent payment"));
|
||||
}
|
||||
}
|
||||
+16
-8
@@ -2,6 +2,7 @@ package fr.nessar.lmnporganizer.rental.application.rent;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.IdentifierGenerator;
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.PaymentAllocation;
|
||||
@@ -38,20 +39,20 @@ public final class ReverseRentPaymentService {
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public RentPaymentReversal reverse(LandlordId landlordId, RentPaymentId paymentId, String reason) {
|
||||
public RentPaymentReversal reverse(LandlordId landlordId, RentPaymentId paymentId, long paymentVersion,
|
||||
String reason) {
|
||||
return transactions.run(() -> reversals.findByPayment(landlordId, paymentId)
|
||||
.orElseGet(() -> reverseInTransaction(landlordId, paymentId, reason)));
|
||||
}
|
||||
|
||||
public RentPaymentReversal get(LandlordId landlordId, RentPaymentId paymentId) {
|
||||
return reversals.findByPayment(landlordId, paymentId)
|
||||
.orElseThrow(() -> new ResourceNotFound("Rent payment reversal"));
|
||||
.map(existing -> idempotentReversal(existing, reason))
|
||||
.orElseGet(() -> reverseInTransaction(landlordId, paymentId, paymentVersion, reason)));
|
||||
}
|
||||
|
||||
private RentPaymentReversal reverseInTransaction(LandlordId landlordId, RentPaymentId paymentId,
|
||||
String reason) {
|
||||
long paymentVersion, String reason) {
|
||||
RentPayment payment = payments.find(landlordId, paymentId)
|
||||
.orElseThrow(() -> new ResourceNotFound("Rent payment"));
|
||||
if (payment.version() != paymentVersion) {
|
||||
throw new ConcurrentChange("Rent payment");
|
||||
}
|
||||
RentPaymentReversal reversal = RentPaymentReversal.reverse(
|
||||
new RentPaymentReversalId(identifiers.next()), payment, LocalDate.now(clock), reason);
|
||||
List<RentDue> affectedDues = new ArrayList<>();
|
||||
@@ -65,4 +66,11 @@ public final class ReverseRentPaymentService {
|
||||
payments.save(payment);
|
||||
return reversals.save(reversal);
|
||||
}
|
||||
|
||||
private RentPaymentReversal idempotentReversal(RentPaymentReversal existing, String reason) {
|
||||
if (reason == null || !existing.reason().equals(reason.trim())) {
|
||||
throw new RentPaymentConflict("Ce règlement a déjà été contrepassé pour une autre raison.");
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
@@ -14,12 +14,12 @@ public final class RentPayment {
|
||||
private final RentPaymentId id;
|
||||
private final LandlordId landlordId;
|
||||
private final LeaseId leaseId;
|
||||
private LocalDate receivedAt;
|
||||
private BigDecimal amount;
|
||||
private final LocalDate receivedAt;
|
||||
private final BigDecimal amount;
|
||||
private RentPaymentStatus status;
|
||||
private List<PaymentAllocation> allocations;
|
||||
private BigDecimal refundedAmount;
|
||||
private LocalDate confirmedAt;
|
||||
private final LocalDate confirmedAt;
|
||||
private final long version;
|
||||
|
||||
private RentPayment(RentPaymentId id, LandlordId landlordId, LeaseId leaseId, LocalDate receivedAt,
|
||||
@@ -41,10 +41,11 @@ public final class RentPayment {
|
||||
validateState();
|
||||
}
|
||||
|
||||
public static RentPayment draft(RentPaymentId id, LandlordId landlordId, LeaseId leaseId, LocalDate receivedAt,
|
||||
BigDecimal amount) {
|
||||
return new RentPayment(id, landlordId, leaseId, receivedAt, amount, RentPaymentStatus.DRAFT, List.of(),
|
||||
BigDecimal.ZERO, null, -1);
|
||||
public static RentPayment confirmed(RentPaymentId id, LandlordId landlordId, LeaseId leaseId,
|
||||
LocalDate receivedAt, BigDecimal amount,
|
||||
List<PaymentAllocation> allocations, LocalDate confirmedAt) {
|
||||
return new RentPayment(id, landlordId, leaseId, receivedAt, amount, RentPaymentStatus.CONFIRMED,
|
||||
allocations, BigDecimal.ZERO, confirmedAt, -1);
|
||||
}
|
||||
|
||||
public static RentPayment reconstitute(RentPaymentId id, LandlordId landlordId, LeaseId leaseId,
|
||||
@@ -55,22 +56,6 @@ public final class RentPayment {
|
||||
confirmedAt, version);
|
||||
}
|
||||
|
||||
public void revise(LocalDate receivedAt, BigDecimal amount) {
|
||||
requireDraft();
|
||||
this.receivedAt = Objects.requireNonNull(receivedAt);
|
||||
this.amount = positive(amount, "Le montant du règlement doit être strictement positif.");
|
||||
}
|
||||
|
||||
public RentPaymentConfirmed confirm(List<PaymentAllocation> initialAllocations, LocalDate confirmationDate) {
|
||||
requireDraft();
|
||||
List<PaymentAllocation> requested = initialAllocations == null ? List.of() : List.copyOf(initialAllocations);
|
||||
validateAllocations(requested);
|
||||
allocations = requested;
|
||||
confirmedAt = Objects.requireNonNull(confirmationDate);
|
||||
status = RentPaymentStatus.CONFIRMED;
|
||||
return new RentPaymentConfirmed(id, landlordId, leaseId, amount, receivedAt, confirmedAt, allocations);
|
||||
}
|
||||
|
||||
public void allocate(PaymentAllocation allocation) {
|
||||
requireConfirmed();
|
||||
Objects.requireNonNull(allocation);
|
||||
@@ -112,11 +97,7 @@ public final class RentPayment {
|
||||
|
||||
private void validateState() {
|
||||
validateAllocations(allocations);
|
||||
if (status == RentPaymentStatus.DRAFT
|
||||
&& (!allocations.isEmpty() || refundedAmount.signum() != 0 || confirmedAt != null)) {
|
||||
throw new RentRuleViolation("Un règlement en brouillon ne peut pas avoir d'écritures confirmées.");
|
||||
}
|
||||
if (status != RentPaymentStatus.DRAFT && confirmedAt == null) {
|
||||
if (confirmedAt == null) {
|
||||
throw new RentRuleViolation("Un règlement confirmé doit conserver sa date de confirmation.");
|
||||
}
|
||||
if (unallocatedBalance().signum() < 0) {
|
||||
@@ -138,12 +119,6 @@ public final class RentPayment {
|
||||
}
|
||||
}
|
||||
|
||||
private void requireDraft() {
|
||||
if (status != RentPaymentStatus.DRAFT) {
|
||||
throw new RentRuleViolation("Seul un règlement en brouillon peut être modifié.");
|
||||
}
|
||||
}
|
||||
|
||||
private void requireConfirmed() {
|
||||
if (status != RentPaymentStatus.CONFIRMED) {
|
||||
throw new RentRuleViolation("Le règlement doit être confirmé et non contrepassé.");
|
||||
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.rent;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
public record RentPaymentConfirmed(RentPaymentId paymentId, LandlordId landlordId, LeaseId leaseId,
|
||||
BigDecimal amount, LocalDate receivedAt, LocalDate confirmedAt,
|
||||
List<PaymentAllocation> allocations) {
|
||||
public RentPaymentConfirmed {
|
||||
allocations = List.copyOf(allocations);
|
||||
}
|
||||
}
|
||||
+10
-45
@@ -12,70 +12,37 @@ public final class RentPaymentRefund {
|
||||
private final LandlordId landlordId;
|
||||
private final LeaseId leaseId;
|
||||
private final RentPaymentId paymentId;
|
||||
private BigDecimal amount;
|
||||
private LocalDate refundedAt;
|
||||
private RentPaymentRefundStatus status;
|
||||
private LocalDate confirmedAt;
|
||||
private final BigDecimal amount;
|
||||
private final LocalDate refundedAt;
|
||||
private final long version;
|
||||
|
||||
private RentPaymentRefund(RentPaymentRefundId id, LandlordId landlordId, LeaseId leaseId,
|
||||
RentPaymentId paymentId, BigDecimal amount, LocalDate refundedAt,
|
||||
RentPaymentRefundStatus status, LocalDate confirmedAt, long version) {
|
||||
long version) {
|
||||
this.id = Objects.requireNonNull(id);
|
||||
this.landlordId = Objects.requireNonNull(landlordId);
|
||||
this.leaseId = Objects.requireNonNull(leaseId);
|
||||
this.paymentId = Objects.requireNonNull(paymentId);
|
||||
this.amount = positive(amount);
|
||||
this.refundedAt = Objects.requireNonNull(refundedAt);
|
||||
this.status = Objects.requireNonNull(status);
|
||||
this.confirmedAt = confirmedAt;
|
||||
if (version < -1) {
|
||||
throw new IllegalArgumentException("Rent payment refund version cannot be less than -1.");
|
||||
}
|
||||
this.version = version;
|
||||
if ((status == RentPaymentRefundStatus.DRAFT && confirmedAt != null)
|
||||
|| (status == RentPaymentRefundStatus.CONFIRMED && confirmedAt == null)) {
|
||||
throw new RentRuleViolation("Le statut du remboursement ne correspond pas à sa confirmation.");
|
||||
}
|
||||
}
|
||||
|
||||
public static RentPaymentRefund draft(RentPaymentRefundId id, LandlordId landlordId, LeaseId leaseId,
|
||||
RentPaymentId paymentId, BigDecimal amount, LocalDate refundedAt) {
|
||||
return new RentPaymentRefund(id, landlordId, leaseId, paymentId, amount, refundedAt,
|
||||
RentPaymentRefundStatus.DRAFT, null, -1);
|
||||
public static RentPaymentRefund recorded(RentPaymentRefundId id, RentPayment payment, BigDecimal amount,
|
||||
LocalDate refundedAt) {
|
||||
Objects.requireNonNull(payment);
|
||||
payment.registerRefund(amount);
|
||||
return new RentPaymentRefund(id, payment.landlordId(), payment.leaseId(), payment.id(), amount, refundedAt,
|
||||
-1);
|
||||
}
|
||||
|
||||
public static RentPaymentRefund reconstitute(RentPaymentRefundId id, LandlordId landlordId, LeaseId leaseId,
|
||||
RentPaymentId paymentId, BigDecimal amount, LocalDate refundedAt,
|
||||
RentPaymentRefundStatus status, LocalDate confirmedAt,
|
||||
long version) {
|
||||
return new RentPaymentRefund(id, landlordId, leaseId, paymentId, amount, refundedAt, status, confirmedAt,
|
||||
version);
|
||||
}
|
||||
|
||||
public void revise(BigDecimal amount, LocalDate refundedAt) {
|
||||
requireDraft();
|
||||
this.amount = positive(amount);
|
||||
this.refundedAt = Objects.requireNonNull(refundedAt);
|
||||
}
|
||||
|
||||
public RentPaymentRefunded confirm(RentPayment payment, LocalDate confirmationDate) {
|
||||
requireDraft();
|
||||
Objects.requireNonNull(payment);
|
||||
if (!landlordId.equals(payment.landlordId()) || !leaseId.equals(payment.leaseId())
|
||||
|| !paymentId.equals(payment.id())) {
|
||||
throw new RentRuleViolation("Le remboursement et le règlement doivent appartenir au même bail.");
|
||||
}
|
||||
payment.registerRefund(amount);
|
||||
confirmedAt = Objects.requireNonNull(confirmationDate);
|
||||
status = RentPaymentRefundStatus.CONFIRMED;
|
||||
return new RentPaymentRefunded(id, landlordId, leaseId, paymentId, amount, refundedAt, confirmedAt);
|
||||
}
|
||||
|
||||
private void requireDraft() {
|
||||
if (status != RentPaymentRefundStatus.DRAFT) {
|
||||
throw new RentRuleViolation("Un remboursement confirmé est immuable.");
|
||||
}
|
||||
return new RentPaymentRefund(id, landlordId, leaseId, paymentId, amount, refundedAt, version);
|
||||
}
|
||||
|
||||
private static BigDecimal positive(BigDecimal amount) {
|
||||
@@ -92,7 +59,5 @@ public final class RentPaymentRefund {
|
||||
public RentPaymentId paymentId() { return paymentId; }
|
||||
public BigDecimal amount() { return amount; }
|
||||
public LocalDate refundedAt() { return refundedAt; }
|
||||
public RentPaymentRefundStatus status() { return status; }
|
||||
public LocalDate confirmedAt() { return confirmedAt; }
|
||||
public long version() { return version; }
|
||||
}
|
||||
+2
-2
@@ -6,11 +6,11 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface RentPaymentRefundRepository {
|
||||
List<RentPaymentRefund> findAll(LandlordId landlordId);
|
||||
|
||||
List<RentPaymentRefund> findAll(LandlordId landlordId, RentPaymentId paymentId);
|
||||
|
||||
Optional<RentPaymentRefund> find(LandlordId landlordId, RentPaymentRefundId refundId);
|
||||
|
||||
RentPaymentRefund save(RentPaymentRefund refund);
|
||||
|
||||
boolean deleteDraft(RentPaymentRefund refund);
|
||||
}
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.rent;
|
||||
|
||||
public enum RentPaymentRefundStatus {
|
||||
DRAFT,
|
||||
CONFIRMED
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.rent;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record RentPaymentRefunded(RentPaymentRefundId refundId, LandlordId landlordId, LeaseId leaseId,
|
||||
RentPaymentId paymentId, BigDecimal amount, LocalDate refundedAt,
|
||||
LocalDate confirmedAt) {
|
||||
}
|
||||
-2
@@ -14,6 +14,4 @@ public interface RentPaymentRepository {
|
||||
Optional<RentPayment> find(LandlordId landlordId, RentPaymentId paymentId);
|
||||
|
||||
RentPayment save(RentPayment payment);
|
||||
|
||||
boolean deleteDraft(RentPayment payment);
|
||||
}
|
||||
+14
-6
@@ -22,17 +22,25 @@ public record RentPaymentReversal(
|
||||
Objects.requireNonNull(leaseId, "Lease ID is required.");
|
||||
Objects.requireNonNull(paymentId, "Rent payment ID is required.");
|
||||
Objects.requireNonNull(reversedAt, "Reversal date is required.");
|
||||
if (reason == null || reason.isBlank()) {
|
||||
throw new RentRuleViolation("La raison de la contrepassation est obligatoire.");
|
||||
}
|
||||
reason = reason.trim();
|
||||
reason = validatedReason(reason);
|
||||
reversedAllocations = List.copyOf(reversedAllocations);
|
||||
}
|
||||
|
||||
public static RentPaymentReversal reverse(RentPaymentReversalId id, RentPayment payment, LocalDate reversedAt,
|
||||
String reason) {
|
||||
Objects.requireNonNull(payment);
|
||||
return new RentPaymentReversal(id, payment.landlordId(), payment.leaseId(), payment.id(), reversedAt, reason,
|
||||
payment.reverse());
|
||||
return new RentPaymentReversal(id, payment.landlordId(), payment.leaseId(), payment.id(), reversedAt,
|
||||
validatedReason(reason), payment.reverse());
|
||||
}
|
||||
|
||||
private static String validatedReason(String reason) {
|
||||
if (reason == null || reason.isBlank()) {
|
||||
throw new RentRuleViolation("La raison de la contrepassation est obligatoire.");
|
||||
}
|
||||
String validated = reason.trim();
|
||||
if (validated.length() > 500) {
|
||||
throw new RentRuleViolation("La raison de la contrepassation ne peut pas dépasser 500 caractères.");
|
||||
}
|
||||
return validated;
|
||||
}
|
||||
}
|
||||
-1
@@ -1,7 +1,6 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.rent;
|
||||
|
||||
public enum RentPaymentStatus {
|
||||
DRAFT,
|
||||
CONFIRMED,
|
||||
REVERSED
|
||||
}
|
||||
@@ -37,15 +37,14 @@
|
||||
</table>
|
||||
<h2>Remboursements</h2>
|
||||
<table>
|
||||
<thead><tr><th>Référence</th><th>Montant</th><th>Date</th><th>Confirmé le</th></tr></thead>
|
||||
<thead><tr><th>Référence</th><th>Montant</th><th>Date</th></tr></thead>
|
||||
<tbody>
|
||||
<tr th:each="refund : ${document.refunds}">
|
||||
<td th:text="${refund.refundId.value}">refund-1</td>
|
||||
<td><span th:text="${refund.amount}">100.00</span> EUR</td>
|
||||
<td th:text="${refund.refundedAt}">2026-07-06</td>
|
||||
<td th:text="${refund.confirmedAt}">2026-07-07</td>
|
||||
</tr>
|
||||
<tr th:if="${document.refunds.empty}"><td colspan="4">Aucun remboursement</td></tr>
|
||||
<tr th:if="${document.refunds.empty}"><td colspan="3">Aucun remboursement</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package fr.nessar.lmnporganizer.rental.adapter.in.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.ZoneOffset;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class RentalApplicationConfigurationTest {
|
||||
@Test
|
||||
void rentalClockUsesUtc() {
|
||||
assertEquals(ZoneOffset.UTC, new RentalApplicationConfiguration().rentalClock().getZone());
|
||||
}
|
||||
}
|
||||
+79
-49
@@ -16,13 +16,15 @@ import fr.nessar.lmnporganizer.rental.application.lease.ArchiveLeaseService;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.GenerateLeaseDocumentService;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.LeaseDraftService;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.AllocateRentPaymentService;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.ConfirmRentPaymentRefundService;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.ConfirmRentPaymentService;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.GeneratePaymentReceiptService;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.RecordRentPaymentService;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.RefundRentPaymentService;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.RentDueQueryService;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.RentPaymentDraftService;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.RentPaymentRefundDraftService;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.RentPaymentConflict;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.RentPaymentHistory;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.RentPaymentHistoryService;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.ReverseRentPaymentService;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ReferencedResource;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
|
||||
import fr.nessar.lmnporganizer.rental.application.tenant.DeleteTenantService;
|
||||
@@ -48,7 +50,6 @@ import fr.nessar.lmnporganizer.rental.domain.rent.RentPayment;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefund;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefundId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefundStatus;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversal;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversalId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantAttachment;
|
||||
@@ -75,7 +76,6 @@ import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.times;
|
||||
@@ -130,17 +130,15 @@ class RentalWebAdaptersTest {
|
||||
@MockitoBean
|
||||
private RentDueQueryService rentDues;
|
||||
@MockitoBean
|
||||
private RentPaymentDraftService paymentDrafts;
|
||||
private RentPaymentHistoryService paymentHistory;
|
||||
@MockitoBean
|
||||
private ConfirmRentPaymentService paymentConfirmation;
|
||||
private RecordRentPaymentService paymentRecording;
|
||||
@MockitoBean
|
||||
private AllocateRentPaymentService paymentAllocation;
|
||||
@MockitoBean
|
||||
private ReverseRentPaymentService paymentReversal;
|
||||
@MockitoBean
|
||||
private RentPaymentRefundDraftService refundDrafts;
|
||||
@MockitoBean
|
||||
private ConfirmRentPaymentRefundService refundConfirmation;
|
||||
private RefundRentPaymentService paymentRefunds;
|
||||
@MockitoBean
|
||||
private GeneratePaymentReceiptService paymentReceipts;
|
||||
@MockitoBean
|
||||
@@ -276,62 +274,64 @@ class RentalWebAdaptersTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void rentPaymentCommandsDelegateScopedConfirmationAllocationRefundAndReversal() throws Exception {
|
||||
void rentPaymentCommandsDelegateScopedRecordingAllocationRefundAndReversal() throws Exception {
|
||||
PaymentAllocation allocation = new PaymentAllocation(new PaymentAllocationId("allocation-1"), DUE_ID,
|
||||
new BigDecimal("900"), LocalDate.of(2026, 2, 3));
|
||||
RentPayment payment = RentPayment.draft(PAYMENT_ID, LANDLORD_ID, LEASE_ID,
|
||||
LocalDate.of(2026, 2, 2), new BigDecimal("1000"));
|
||||
payment.confirm(List.of(allocation), LocalDate.of(2026, 2, 3));
|
||||
RentPaymentRefund draftRefund = RentPaymentRefund.draft(REFUND_ID, LANDLORD_ID, LEASE_ID, PAYMENT_ID,
|
||||
new BigDecimal("50"), LocalDate.of(2026, 2, 4));
|
||||
RentPaymentRefund confirmedRefund = RentPaymentRefund.reconstitute(REFUND_ID, LANDLORD_ID, LEASE_ID,
|
||||
PAYMENT_ID, new BigDecimal("50"), LocalDate.of(2026, 2, 4), RentPaymentRefundStatus.CONFIRMED,
|
||||
LocalDate.of(2026, 2, 5), 3);
|
||||
RentPayment payment = RentPayment.reconstitute(PAYMENT_ID, LANDLORD_ID, LEASE_ID,
|
||||
LocalDate.of(2026, 2, 2), new BigDecimal("1000"),
|
||||
fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentStatus.CONFIRMED, List.of(allocation),
|
||||
new BigDecimal("50"), LocalDate.of(2026, 2, 3), 7);
|
||||
RentPaymentRefund refund = RentPaymentRefund.reconstitute(REFUND_ID, LANDLORD_ID, LEASE_ID,
|
||||
PAYMENT_ID, new BigDecimal("50"), LocalDate.of(2026, 2, 4), 3);
|
||||
RentPaymentReversal reversed = new RentPaymentReversal(new RentPaymentReversalId("reversal-1"), LANDLORD_ID,
|
||||
LEASE_ID, PAYMENT_ID, LocalDate.of(2026, 2, 6), "Erreur bancaire", List.of(allocation));
|
||||
when(paymentConfirmation.confirm(eq(LANDLORD_ID), eq(PAYMENT_ID), anyList())).thenReturn(payment);
|
||||
when(paymentAllocation.allocate(LANDLORD_ID, PAYMENT_ID, DUE_ID, new BigDecimal("50")))
|
||||
RentPaymentHistory history = new RentPaymentHistory(payment, List.of(refund), java.util.Optional.of(reversed));
|
||||
when(paymentRecording.record(LANDLORD_ID, "payment-1", DUE_ID, 2,
|
||||
LocalDate.of(2026, 2, 2), new BigDecimal("1000"))).thenReturn(payment);
|
||||
when(paymentAllocation.allocate(LANDLORD_ID, PAYMENT_ID, "allocation-2", 7, DUE_ID, 2,
|
||||
new BigDecimal("50")))
|
||||
.thenReturn(payment);
|
||||
when(refundDrafts.create(LANDLORD_ID, PAYMENT_ID, new BigDecimal("50"), LocalDate.of(2026, 2, 4)))
|
||||
.thenReturn(draftRefund);
|
||||
when(refundDrafts.get(LANDLORD_ID, REFUND_ID)).thenReturn(draftRefund);
|
||||
when(refundConfirmation.confirm(LANDLORD_ID, REFUND_ID)).thenReturn(confirmedRefund);
|
||||
when(paymentReversal.reverse(LANDLORD_ID, PAYMENT_ID, "Erreur bancaire")).thenReturn(reversed);
|
||||
when(paymentReversal.get(LANDLORD_ID, PAYMENT_ID)).thenReturn(reversed);
|
||||
when(paymentRefunds.refund(LANDLORD_ID, PAYMENT_ID, "refund-1", 7, new BigDecimal("50"),
|
||||
LocalDate.of(2026, 2, 4))).thenReturn(payment);
|
||||
when(paymentReversal.reverse(LANDLORD_ID, PAYMENT_ID, 7, "Erreur bancaire")).thenReturn(reversed);
|
||||
when(paymentHistory.get(LANDLORD_ID, PAYMENT_ID)).thenReturn(history);
|
||||
|
||||
mockMvc.perform(post("/api/rent-payments/payment-1/confirm").with(asLandlord()).with(csrf())
|
||||
mockMvc.perform(post("/api/rent-payments").with(asLandlord()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"allocations\":[{\"rentDueId\":\"due-1\",\"amount\":900}]}"))
|
||||
.andExpect(status().isOk())
|
||||
.content("""
|
||||
{"operationId":"payment-1","rentDueId":"due-1","rentDueVersion":2,
|
||||
"receivedAt":"2026-02-02","amount":1000}
|
||||
"""))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.status").value("CONFIRMED"));
|
||||
mockMvc.perform(post("/api/rent-payments/payment-1/allocations").with(asLandlord()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"rentDueId\":\"due-1\",\"amount\":50}"))
|
||||
.content("""
|
||||
{"operationId":"allocation-2","paymentVersion":7,"rentDueId":"due-1",
|
||||
"rentDueVersion":2,"amount":50}
|
||||
"""))
|
||||
.andExpect(status().isOk());
|
||||
mockMvc.perform(post("/api/rent-payments/payment-1/refunds").with(asLandlord()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"amount\":50,\"refundedAt\":\"2026-02-04\"}"))
|
||||
.content("""
|
||||
{"operationId":"refund-1","paymentVersion":7,"amount":50,
|
||||
"refundedAt":"2026-02-04"}
|
||||
"""))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.status").value("DRAFT"));
|
||||
mockMvc.perform(post("/api/rent-payments/payment-1/refunds/refund-1/confirm").with(asLandlord()).with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.status").value("CONFIRMED"));
|
||||
.andExpect(jsonPath("$.refunds[0].id").value("refund-1"));
|
||||
mockMvc.perform(post("/api/rent-payments/payment-1/reversal").with(asLandlord()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"reason\":\"Erreur bancaire\"}"))
|
||||
.content("{\"paymentVersion\":7,\"reason\":\"Erreur bancaire\"}"))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.reason").value("Erreur bancaire"));
|
||||
mockMvc.perform(get("/api/rent-payments/payment-1/reversal").with(asLandlord()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.reason").value("Erreur bancaire"));
|
||||
.andExpect(jsonPath("$.reversal.reason").value("Erreur bancaire"));
|
||||
|
||||
verify(paymentConfirmation).confirm(LANDLORD_ID, PAYMENT_ID, List.of(
|
||||
new ConfirmRentPaymentService.RequestedAllocation(DUE_ID, new BigDecimal("900"))));
|
||||
verify(paymentAllocation).allocate(LANDLORD_ID, PAYMENT_ID, DUE_ID, new BigDecimal("50"));
|
||||
verify(refundDrafts).create(LANDLORD_ID, PAYMENT_ID, new BigDecimal("50"), LocalDate.of(2026, 2, 4));
|
||||
verify(refundConfirmation).confirm(LANDLORD_ID, REFUND_ID);
|
||||
verify(paymentReversal).reverse(LANDLORD_ID, PAYMENT_ID, "Erreur bancaire");
|
||||
verify(paymentReversal).get(LANDLORD_ID, PAYMENT_ID);
|
||||
verify(paymentRecording).record(LANDLORD_ID, "payment-1", DUE_ID, 2,
|
||||
LocalDate.of(2026, 2, 2), new BigDecimal("1000"));
|
||||
verify(paymentAllocation).allocate(LANDLORD_ID, PAYMENT_ID, "allocation-2", 7, DUE_ID, 2,
|
||||
new BigDecimal("50"));
|
||||
verify(paymentRefunds).refund(LANDLORD_ID, PAYMENT_ID, "refund-1", 7, new BigDecimal("50"),
|
||||
LocalDate.of(2026, 2, 4));
|
||||
verify(paymentReversal).reverse(LANDLORD_ID, PAYMENT_ID, 7, "Erreur bancaire");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -340,6 +340,10 @@ class RentalWebAdaptersTest {
|
||||
.thenThrow(new ResourceNotFound("Apartment"));
|
||||
doThrow(new ReferencedResource("Apartment")).when(apartments)
|
||||
.delete(LANDLORD_ID, APARTMENT_ID);
|
||||
when(paymentRecording.record(eq(LANDLORD_ID), eq("stale-operation"), eq(DUE_ID), eq(2L), any(), any()))
|
||||
.thenThrow(new ConcurrentChange("Rent due"));
|
||||
when(paymentRecording.record(eq(LANDLORD_ID), eq("paid-operation"), eq(DUE_ID), eq(2L), any(), any()))
|
||||
.thenThrow(new RentPaymentConflict("Échéance déjà réglée"));
|
||||
|
||||
mockMvc.perform(get("/api/apartments/missing").with(asLandlord()))
|
||||
.andExpect(status().isNotFound());
|
||||
@@ -349,6 +353,32 @@ class RentalWebAdaptersTest {
|
||||
.andExpect(status().isBadRequest());
|
||||
mockMvc.perform(delete("/api/apartments/apartment-1").with(asLandlord()).with(csrf()))
|
||||
.andExpect(status().isConflict());
|
||||
mockMvc.perform(post("/api/rent-payments").with(asLandlord()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content("""
|
||||
{"operationId":"stale-operation","rentDueId":"due-1","rentDueVersion":2,
|
||||
"receivedAt":"2026-02-02","amount":1000}
|
||||
"""))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.message").isNotEmpty());
|
||||
mockMvc.perform(post("/api/rent-payments").with(asLandlord()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content("""
|
||||
{"operationId":"paid-operation","rentDueId":"due-1","rentDueVersion":2,
|
||||
"receivedAt":"2026-02-02","amount":1000}
|
||||
"""))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.message").value("Échéance déjà réglée"));
|
||||
mockMvc.perform(post("/api/rent-payments").with(asLandlord()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content("""
|
||||
{"operationId":"missing-version","rentDueId":"due-1",
|
||||
"receivedAt":"2026-02-02","amount":1000}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.message").isNotEmpty());
|
||||
mockMvc.perform(post("/api/rent-payments/payment-1/reversal").with(asLandlord()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"paymentVersion\":0,\"reason\":\"" + "a".repeat(501) + "\"}"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.message").isNotEmpty());
|
||||
}
|
||||
|
||||
private static RequestPostProcessor asLandlord() {
|
||||
|
||||
+5
-5
@@ -118,9 +118,8 @@ class RentalMongoTransactionIT {
|
||||
RentPaymentId paymentId = new RentPaymentId("payment-1");
|
||||
rentDues.save(RentDue.create(dueId, landlordId, leaseId, YearMonth.of(2026, 1),
|
||||
LocalDate.of(2026, 2, 5), new BigDecimal("900"), new BigDecimal("50")));
|
||||
RentPayment payment = RentPayment.draft(paymentId, landlordId, leaseId, LocalDate.of(2026, 1, 5),
|
||||
new BigDecimal("1000"));
|
||||
payment.confirm(List.of(), LocalDate.of(2026, 1, 6));
|
||||
RentPayment payment = RentPayment.confirmed(paymentId, landlordId, leaseId, LocalDate.of(2026, 1, 5),
|
||||
new BigDecimal("1000"), List.of(), LocalDate.of(2026, 1, 6));
|
||||
rentPayments.save(payment);
|
||||
|
||||
RentPayment stale = rentPayments.find(landlordId, paymentId).orElseThrow();
|
||||
@@ -133,10 +132,11 @@ class RentalMongoTransactionIT {
|
||||
when(staleView.find(landlordId, paymentId)).thenReturn(Optional.of(stale));
|
||||
when(staleView.save(any())).thenAnswer(invocation -> rentPayments.save(invocation.getArgument(0)));
|
||||
AllocateRentPaymentService service = new AllocateRentPaymentService(staleView, rentDues, transactions,
|
||||
() -> "service-allocation", Clock.fixed(Instant.parse("2026-01-07T12:00:00Z"), ZoneOffset.UTC));
|
||||
Clock.fixed(Instant.parse("2026-01-07T12:00:00Z"), ZoneOffset.UTC));
|
||||
|
||||
assertThrows(OptimisticLockingFailureException.class,
|
||||
() -> service.allocate(landlordId, paymentId, dueId, new BigDecimal("100")));
|
||||
() -> service.allocate(landlordId, paymentId, "service-allocation", stale.version(), dueId, 0,
|
||||
new BigDecimal("100")));
|
||||
|
||||
assertEquals(0, rentDues.find(landlordId, dueId).orElseThrow().allocatedAmount().signum());
|
||||
assertEquals(List.of(new RentDueId("other-due")), rentPayments.find(landlordId, paymentId).orElseThrow()
|
||||
|
||||
+3
-32
@@ -6,7 +6,6 @@ import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.mongodb.client.result.DeleteResult;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.PaymentAllocation;
|
||||
@@ -17,7 +16,6 @@ import fr.nessar.lmnporganizer.rental.domain.rent.RentPayment;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefund;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefundId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefundStatus;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversal;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversalId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentStatus;
|
||||
@@ -84,7 +82,7 @@ class MongoRentRepositoriesTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void rentPaymentRoundTripsEveryAllocationRefundAndScopedDraftDelete() {
|
||||
void rentPaymentRoundTripsEveryAllocationRefundAndLandlordScope() {
|
||||
RentPayment payment = RentPayment.reconstitute(
|
||||
PAYMENT_ID,
|
||||
LANDLORD_ID,
|
||||
@@ -99,8 +97,6 @@ class MongoRentRepositoriesTest {
|
||||
when(mongoTemplate.insert(any(MongoRentPaymentRepository.RentPaymentDocument.class)))
|
||||
.thenAnswer(invocation -> invocation.<MongoRentPaymentRepository.RentPaymentDocument>getArgument(0)
|
||||
.withVersion(0L));
|
||||
when(mongoTemplate.remove(any(Query.class), eq(MongoRentPaymentRepository.RentPaymentDocument.class)))
|
||||
.thenReturn(DeleteResult.acknowledged(1));
|
||||
MongoRentPaymentRepository repository = new MongoRentPaymentRepository(mongoTemplate);
|
||||
|
||||
RentPayment saved = repository.save(payment);
|
||||
@@ -121,9 +117,6 @@ class MongoRentRepositoriesTest {
|
||||
when(mongoTemplate.findOne(any(Query.class), eq(MongoRentPaymentRepository.RentPaymentDocument.class)))
|
||||
.thenReturn(document.getValue().withVersion(4L));
|
||||
RentPayment found = repository.find(LANDLORD_ID, PAYMENT_ID).orElseThrow();
|
||||
RentPayment draft = RentPayment.reconstitute(PAYMENT_ID, LANDLORD_ID, LEASE_ID, RECEIVED_AT,
|
||||
new BigDecimal("1200.00"), RentPaymentStatus.DRAFT, List.of(), BigDecimal.ZERO, null, 4);
|
||||
assertThat(repository.deleteDraft(draft)).isTrue();
|
||||
|
||||
assertThat(found.allocations()).containsExactlyElementsOf(allocations());
|
||||
assertThat(found.version()).isEqualTo(4);
|
||||
@@ -132,17 +125,10 @@ class MongoRentRepositoriesTest {
|
||||
assertThat(findQuery.getValue().getQueryObject())
|
||||
.containsEntry("_id", PAYMENT_ID.value())
|
||||
.containsEntry("landlordId", LANDLORD_ID.value());
|
||||
ArgumentCaptor<Query> deleteQuery = ArgumentCaptor.forClass(Query.class);
|
||||
verify(mongoTemplate).remove(deleteQuery.capture(), eq(MongoRentPaymentRepository.RentPaymentDocument.class));
|
||||
assertThat(deleteQuery.getValue().getQueryObject())
|
||||
.containsEntry("_id", PAYMENT_ID.value())
|
||||
.containsEntry("landlordId", LANDLORD_ID.value())
|
||||
.containsEntry("status", RentPaymentStatus.DRAFT.name())
|
||||
.containsEntry("version", 4L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void refundRoundTripsConfirmationVersionAndScopedDraftDelete() {
|
||||
void immutableRefundRoundTripsItsVersionAndLandlordScope() {
|
||||
RentPaymentRefund refund = RentPaymentRefund.reconstitute(
|
||||
new RentPaymentRefundId("refund-1"),
|
||||
LANDLORD_ID,
|
||||
@@ -150,14 +136,10 @@ class MongoRentRepositoriesTest {
|
||||
PAYMENT_ID,
|
||||
new BigDecimal("100.00"),
|
||||
LocalDate.of(2026, 3, 7),
|
||||
RentPaymentRefundStatus.CONFIRMED,
|
||||
LocalDate.of(2026, 3, 8),
|
||||
-1);
|
||||
when(mongoTemplate.insert(any(MongoRentPaymentRefundRepository.RefundDocument.class)))
|
||||
.thenAnswer(invocation -> invocation.<MongoRentPaymentRefundRepository.RefundDocument>getArgument(0)
|
||||
.withVersion(0L));
|
||||
when(mongoTemplate.remove(any(Query.class), eq(MongoRentPaymentRefundRepository.RefundDocument.class)))
|
||||
.thenReturn(DeleteResult.acknowledged(1));
|
||||
MongoRentPaymentRefundRepository repository = new MongoRentPaymentRefundRepository(mongoTemplate);
|
||||
|
||||
RentPaymentRefund saved = repository.save(refund);
|
||||
@@ -166,17 +148,13 @@ class MongoRentRepositoriesTest {
|
||||
ArgumentCaptor.forClass(MongoRentPaymentRefundRepository.RefundDocument.class);
|
||||
verify(mongoTemplate).insert(document.capture());
|
||||
assertThat(document.getValue().amount()).isEqualByComparingTo("100.00");
|
||||
assertThat(document.getValue().confirmedAt()).isEqualTo(LocalDate.of(2026, 3, 8));
|
||||
assertThat(document.getValue().version()).isNull();
|
||||
assertThat(saved.status()).isEqualTo(RentPaymentRefundStatus.CONFIRMED);
|
||||
assertThat(saved.refundedAt()).isEqualTo(LocalDate.of(2026, 3, 7));
|
||||
assertThat(saved.version()).isZero();
|
||||
|
||||
when(mongoTemplate.findOne(any(Query.class), eq(MongoRentPaymentRefundRepository.RefundDocument.class)))
|
||||
.thenReturn(document.getValue().withVersion(2L));
|
||||
RentPaymentRefund found = repository.find(LANDLORD_ID, refund.id()).orElseThrow();
|
||||
RentPaymentRefund draft = RentPaymentRefund.reconstitute(refund.id(), LANDLORD_ID, LEASE_ID, PAYMENT_ID,
|
||||
new BigDecimal("100.00"), LocalDate.of(2026, 3, 7), RentPaymentRefundStatus.DRAFT, null, 2);
|
||||
assertThat(repository.deleteDraft(draft)).isTrue();
|
||||
|
||||
assertThat(found.version()).isEqualTo(2);
|
||||
ArgumentCaptor<Query> findQuery = ArgumentCaptor.forClass(Query.class);
|
||||
@@ -184,13 +162,6 @@ class MongoRentRepositoriesTest {
|
||||
assertThat(findQuery.getValue().getQueryObject())
|
||||
.containsEntry("_id", refund.id().value())
|
||||
.containsEntry("landlordId", LANDLORD_ID.value());
|
||||
ArgumentCaptor<Query> deleteQuery = ArgumentCaptor.forClass(Query.class);
|
||||
verify(mongoTemplate).remove(deleteQuery.capture(), eq(MongoRentPaymentRefundRepository.RefundDocument.class));
|
||||
assertThat(deleteQuery.getValue().getQueryObject())
|
||||
.containsEntry("_id", refund.id().value())
|
||||
.containsEntry("landlordId", LANDLORD_ID.value())
|
||||
.containsEntry("status", RentPaymentRefundStatus.DRAFT.name())
|
||||
.containsEntry("version", 2L);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ class ThymeleafRentalDocumentRendererTest {
|
||||
LocalDate.of(2026, 7, 5), LocalDate.of(2026, 7, 6), new BigDecimal("1200.00"),
|
||||
new BigDecimal("50.00"), new BigDecimal("200.00"), contract, List.of(allocation),
|
||||
List.of(new ReceiptRefund(new RentPaymentRefundId("refund-1"), new BigDecimal("50.00"),
|
||||
LocalDate.of(2026, 7, 6), LocalDate.of(2026, 7, 7)))));
|
||||
LocalDate.of(2026, 7, 6)))));
|
||||
byte[] rentReceipt = renderer.renderRentReceipt(new RentReceipt(new RentDueId("due-1"),
|
||||
YearMonth.of(2026, 7), new BigDecimal("900.00"), new BigDecimal("50.00"),
|
||||
new BigDecimal("950.00"), LocalDate.of(2026, 7, 18), contract));
|
||||
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rent;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.PaymentAllocation;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.PaymentAllocationId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDue;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueSettlement;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPayment;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentStatus;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.YearMonth;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class AllocateRentPaymentServiceTest {
|
||||
private static final LandlordId LANDLORD_ID = new LandlordId("landlord-1");
|
||||
private static final LeaseId LEASE_ID = new LeaseId("lease-1");
|
||||
private static final RentPaymentId PAYMENT_ID = new RentPaymentId("payment-1");
|
||||
private static final RentDueId DUE_ID = new RentDueId("due-2");
|
||||
private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-02-10T12:00:00Z"), ZoneOffset.UTC);
|
||||
|
||||
@Test
|
||||
void allocatesSurplusToOneCurrentDueOfTheSameLease() {
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
RecordingTransactions transactions = new RecordingTransactions();
|
||||
PaymentAllocation initial = new PaymentAllocation(new PaymentAllocationId("initial"),
|
||||
new RentDueId("due-1"), new BigDecimal("900"), LocalDate.of(2026, 2, 2));
|
||||
RentPayment payment = RentPayment.reconstitute(PAYMENT_ID, LANDLORD_ID, LEASE_ID,
|
||||
LocalDate.of(2026, 2, 2), new BigDecimal("1000"), RentPaymentStatus.CONFIRMED,
|
||||
List.of(initial), BigDecimal.ZERO, LocalDate.of(2026, 2, 2), 4);
|
||||
RentDue due = RentDue.reconstitute(DUE_ID, LANDLORD_ID, LEASE_ID, YearMonth.of(2026, 2),
|
||||
LocalDate.of(2026, 3, 5), new BigDecimal("50"), BigDecimal.ZERO, BigDecimal.ZERO, 2);
|
||||
when(payments.find(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(payment));
|
||||
when(rentDues.find(LANDLORD_ID, DUE_ID)).thenReturn(Optional.of(due));
|
||||
when(payments.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(rentDues.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
RentPayment result = new AllocateRentPaymentService(payments, rentDues, transactions, CLOCK).allocate(
|
||||
LANDLORD_ID, PAYMENT_ID, "operation-2", 4, DUE_ID, 2, new BigDecimal("50"));
|
||||
|
||||
assertEquals(new BigDecimal("950"), result.allocatedAmount());
|
||||
assertEquals(new BigDecimal("50"), result.unallocatedBalance());
|
||||
assertEquals(RentDueSettlement.PAID, due.settlement());
|
||||
assertEquals(new PaymentAllocationId("operation-2"), result.allocations().getLast().id());
|
||||
assertEquals(1, transactions.calls);
|
||||
verify(rentDues).save(due);
|
||||
verify(payments).save(payment);
|
||||
}
|
||||
|
||||
@Test
|
||||
void retryRequiresTheSameAllocationPayloadAndIgnoresStaleVersions() {
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
PaymentAllocation existing = new PaymentAllocation(new PaymentAllocationId("operation-2"), DUE_ID,
|
||||
new BigDecimal("50"), LocalDate.of(2026, 2, 10));
|
||||
RentPayment payment = RentPayment.reconstitute(PAYMENT_ID, LANDLORD_ID, LEASE_ID,
|
||||
LocalDate.of(2026, 2, 2), new BigDecimal("100"), RentPaymentStatus.CONFIRMED,
|
||||
List.of(existing), BigDecimal.ZERO, LocalDate.of(2026, 2, 2), 5);
|
||||
when(payments.find(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(payment));
|
||||
AllocateRentPaymentService service = new AllocateRentPaymentService(payments, rentDues,
|
||||
new RecordingTransactions(), CLOCK);
|
||||
|
||||
assertEquals(payment, service.allocate(LANDLORD_ID, PAYMENT_ID, "operation-2", 999, DUE_ID, 999,
|
||||
new BigDecimal("50.00")));
|
||||
assertThrows(RentPaymentConflict.class, () -> service.allocate(LANDLORD_ID, PAYMENT_ID, "operation-2",
|
||||
5, new RentDueId("due-3"), 0, new BigDecimal("50")));
|
||||
assertThrows(RentPaymentConflict.class, () -> service.allocate(LANDLORD_ID, PAYMENT_ID, "operation-2",
|
||||
5, DUE_ID, 0, new BigDecimal("51")));
|
||||
|
||||
verifyNoInteractions(rentDues);
|
||||
verify(payments, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleVersionsAndAlreadyPaidDueCreateNoAllocation() {
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
RentPayment payment = RentPayment.reconstitute(PAYMENT_ID, LANDLORD_ID, LEASE_ID,
|
||||
LocalDate.of(2026, 2, 2), new BigDecimal("100"), RentPaymentStatus.CONFIRMED,
|
||||
List.of(), BigDecimal.ZERO, LocalDate.of(2026, 2, 2), 4);
|
||||
RentDue due = RentDue.reconstitute(DUE_ID, LANDLORD_ID, LEASE_ID, YearMonth.of(2026, 2),
|
||||
LocalDate.of(2026, 3, 5), new BigDecimal("50"), BigDecimal.ZERO, BigDecimal.ZERO, 2);
|
||||
when(payments.find(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(payment));
|
||||
when(rentDues.find(LANDLORD_ID, DUE_ID)).thenReturn(Optional.of(due));
|
||||
AllocateRentPaymentService service = new AllocateRentPaymentService(payments, rentDues,
|
||||
new RecordingTransactions(), CLOCK);
|
||||
|
||||
assertThrows(ConcurrentChange.class, () -> service.allocate(LANDLORD_ID, PAYMENT_ID, "operation-2", 3,
|
||||
DUE_ID, 2, new BigDecimal("10")));
|
||||
assertThrows(ConcurrentChange.class, () -> service.allocate(LANDLORD_ID, PAYMENT_ID, "operation-2", 4,
|
||||
DUE_ID, 1, new BigDecimal("10")));
|
||||
due.allocate(due.total());
|
||||
assertThrows(RentPaymentConflict.class, () -> service.allocate(LANDLORD_ID, PAYMENT_ID, "operation-2", 4,
|
||||
DUE_ID, 2, new BigDecimal("10")));
|
||||
|
||||
verify(rentDues, never()).save(any());
|
||||
verify(payments, never()).save(any());
|
||||
}
|
||||
|
||||
private static final class RecordingTransactions implements TransactionRunner {
|
||||
private int calls;
|
||||
|
||||
@Override
|
||||
public <T> T run(Supplier<T> operation) {
|
||||
calls++;
|
||||
return operation.get();
|
||||
}
|
||||
}
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rent;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.PaymentAllocation;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.PaymentAllocationId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDue;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueSettlement;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPayment;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentStatus;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentRuleViolation;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.YearMonth;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Optional;
|
||||
import java.util.List;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class RecordRentPaymentServiceTest {
|
||||
private static final LandlordId LANDLORD_ID = new LandlordId("landlord-1");
|
||||
private static final RentDueId DUE_ID = new RentDueId("due-1");
|
||||
private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-02-09T23:30:00Z"), ZoneOffset.UTC);
|
||||
|
||||
@Test
|
||||
void recordsConfirmedCashAndAllocatesOnlyTheDuesRemainingBalance() {
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
RecordingTransactions transactions = new RecordingTransactions();
|
||||
RentDue due = RentDue.reconstitute(DUE_ID, LANDLORD_ID, new LeaseId("lease-1"),
|
||||
YearMonth.of(2026, 1), LocalDate.of(2026, 2, 5), new BigDecimal("900"),
|
||||
new BigDecimal("50"), BigDecimal.ZERO, 3);
|
||||
when(payments.find(LANDLORD_ID, new RentPaymentId("operation-1"))).thenReturn(Optional.empty());
|
||||
when(rentDues.find(LANDLORD_ID, DUE_ID)).thenReturn(Optional.of(due));
|
||||
when(payments.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(rentDues.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
RentPayment payment = new RecordRentPaymentService(payments, rentDues, transactions, CLOCK).record(
|
||||
LANDLORD_ID, "operation-1", DUE_ID, 3, LocalDate.of(2026, 2, 9),
|
||||
new BigDecimal("1000"));
|
||||
|
||||
assertEquals(new RentPaymentId("operation-1"), payment.id());
|
||||
assertEquals(new BigDecimal("950"), payment.allocatedAmount());
|
||||
assertEquals(new BigDecimal("50"), payment.unallocatedBalance());
|
||||
assertEquals(RentDueSettlement.PAID, due.settlement());
|
||||
assertEquals(1, transactions.calls);
|
||||
verify(rentDues).save(due);
|
||||
verify(payments).save(payment);
|
||||
}
|
||||
|
||||
@Test
|
||||
void retryReturnsTheExistingPaymentBeforeInspectingStaleDueData() {
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
RentPayment existing = RentPayment.reconstitute(new RentPaymentId("operation-1"), LANDLORD_ID,
|
||||
new LeaseId("lease-1"), LocalDate.of(2026, 2, 9), new BigDecimal("1000"),
|
||||
RentPaymentStatus.CONFIRMED, List.of(new PaymentAllocation(
|
||||
new PaymentAllocationId("operation-1"), DUE_ID,
|
||||
new BigDecimal("950"), LocalDate.of(2026, 2, 10))), BigDecimal.ZERO,
|
||||
LocalDate.of(2026, 2, 10), 0);
|
||||
when(payments.find(LANDLORD_ID, existing.id())).thenReturn(Optional.of(existing));
|
||||
|
||||
RentPayment result = new RecordRentPaymentService(payments, rentDues, new RecordingTransactions(), CLOCK)
|
||||
.record(LANDLORD_ID, "operation-1", DUE_ID, 999, LocalDate.of(2026, 2, 9),
|
||||
new BigDecimal("1000"));
|
||||
|
||||
assertEquals(existing, result);
|
||||
verifyNoInteractions(rentDues);
|
||||
verify(payments, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsReuseOfARecordingOperationIdWithDifferentImmutableData() {
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
RentPayment existing = RentPayment.reconstitute(new RentPaymentId("operation-1"), LANDLORD_ID,
|
||||
new LeaseId("lease-1"), LocalDate.of(2026, 2, 9), new BigDecimal("1000"),
|
||||
RentPaymentStatus.CONFIRMED, List.of(new PaymentAllocation(
|
||||
new PaymentAllocationId("operation-1"), DUE_ID,
|
||||
new BigDecimal("950"), LocalDate.of(2026, 2, 10))), BigDecimal.ZERO,
|
||||
LocalDate.of(2026, 2, 10), 0);
|
||||
when(payments.find(LANDLORD_ID, existing.id())).thenReturn(Optional.of(existing));
|
||||
RecordRentPaymentService service = new RecordRentPaymentService(payments, rentDues,
|
||||
new RecordingTransactions(), CLOCK);
|
||||
|
||||
assertThrows(RentPaymentConflict.class, () -> service.record(LANDLORD_ID, "operation-1", DUE_ID, 0,
|
||||
LocalDate.of(2026, 2, 9), new BigDecimal("999")));
|
||||
assertThrows(RentPaymentConflict.class, () -> service.record(LANDLORD_ID, "operation-1",
|
||||
new RentDueId("due-2"), 0, LocalDate.of(2026, 2, 9), new BigDecimal("1000")));
|
||||
|
||||
verifyNoInteractions(rentDues);
|
||||
verify(payments, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleOrPaidDueAndCashFromTheNextUtcDayCreateNoPayment() {
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
RentDue due = RentDue.reconstitute(DUE_ID, LANDLORD_ID, new LeaseId("lease-1"),
|
||||
YearMonth.of(2026, 1), LocalDate.of(2026, 2, 5), new BigDecimal("900"),
|
||||
new BigDecimal("50"), BigDecimal.ZERO, 4);
|
||||
when(payments.find(LANDLORD_ID, new RentPaymentId("operation-1"))).thenReturn(Optional.empty());
|
||||
when(rentDues.find(LANDLORD_ID, DUE_ID)).thenReturn(Optional.of(due));
|
||||
RecordRentPaymentService service = new RecordRentPaymentService(payments, rentDues,
|
||||
new RecordingTransactions(), CLOCK);
|
||||
|
||||
assertThrows(ConcurrentChange.class, () -> service.record(LANDLORD_ID, "operation-1", DUE_ID, 3,
|
||||
LocalDate.of(2026, 2, 9), new BigDecimal("1000")));
|
||||
assertThrows(RentRuleViolation.class, () -> service.record(LANDLORD_ID, "operation-1", DUE_ID, 4,
|
||||
LocalDate.of(2026, 2, 10), new BigDecimal("1000")));
|
||||
due.allocate(due.total());
|
||||
assertThrows(RentPaymentConflict.class, () -> service.record(LANDLORD_ID, "operation-1", DUE_ID, 4,
|
||||
LocalDate.of(2026, 2, 9), new BigDecimal("1000")));
|
||||
|
||||
verify(rentDues, never()).save(any());
|
||||
verify(payments, never()).save(any());
|
||||
}
|
||||
|
||||
private static final class RecordingTransactions implements TransactionRunner {
|
||||
private int calls;
|
||||
|
||||
@Override
|
||||
public <T> T run(Supplier<T> operation) {
|
||||
calls++;
|
||||
return operation.get();
|
||||
}
|
||||
}
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rent;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPayment;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefund;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefundId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefundRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentStatus;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentRuleViolation;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class RefundRentPaymentServiceTest {
|
||||
private static final LandlordId LANDLORD_ID = new LandlordId("landlord-1");
|
||||
private static final RentPaymentId PAYMENT_ID = new RentPaymentId("payment-1");
|
||||
private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-02-10T12:00:00Z"), ZoneOffset.UTC);
|
||||
|
||||
@Test
|
||||
void atomicallyRecordsAConfirmedRefundAgainstCurrentSurplus() {
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentPaymentRefundRepository refunds = mock(RentPaymentRefundRepository.class);
|
||||
RecordingTransactions transactions = new RecordingTransactions();
|
||||
RentPayment payment = RentPayment.reconstitute(PAYMENT_ID, LANDLORD_ID, new LeaseId("lease-1"),
|
||||
LocalDate.of(2026, 2, 2), new BigDecimal("1000"), RentPaymentStatus.CONFIRMED,
|
||||
List.of(), BigDecimal.ZERO, LocalDate.of(2026, 2, 2), 4);
|
||||
RentPaymentRefundId refundId = new RentPaymentRefundId("operation-1");
|
||||
when(refunds.find(LANDLORD_ID, refundId)).thenReturn(Optional.empty());
|
||||
when(payments.find(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(payment));
|
||||
when(payments.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(refunds.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
RentPayment result = new RefundRentPaymentService(payments, refunds, transactions, CLOCK).refund(
|
||||
LANDLORD_ID, PAYMENT_ID, "operation-1", 4, new BigDecimal("250"),
|
||||
LocalDate.of(2026, 2, 9));
|
||||
|
||||
assertEquals(new BigDecimal("250"), result.refundedAmount());
|
||||
assertEquals(new BigDecimal("750"), result.unallocatedBalance());
|
||||
assertEquals(1, transactions.calls);
|
||||
verify(refunds).save(any(RentPaymentRefund.class));
|
||||
verify(payments).save(payment);
|
||||
}
|
||||
|
||||
@Test
|
||||
void retryRequiresTheSameRefundPayloadAndIgnoresAStalePaymentVersion() {
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentPaymentRefundRepository refunds = mock(RentPaymentRefundRepository.class);
|
||||
RentPayment payment = RentPayment.reconstitute(PAYMENT_ID, LANDLORD_ID, new LeaseId("lease-1"),
|
||||
LocalDate.of(2026, 2, 2), new BigDecimal("1000"), RentPaymentStatus.CONFIRMED,
|
||||
List.of(), new BigDecimal("250"), LocalDate.of(2026, 2, 2), 5);
|
||||
RentPaymentRefund existing = RentPaymentRefund.reconstitute(new RentPaymentRefundId("operation-1"),
|
||||
LANDLORD_ID, payment.leaseId(), PAYMENT_ID, new BigDecimal("250"),
|
||||
LocalDate.of(2026, 2, 9), 0);
|
||||
when(refunds.find(LANDLORD_ID, existing.id())).thenReturn(Optional.of(existing));
|
||||
when(payments.find(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(payment));
|
||||
RefundRentPaymentService service = new RefundRentPaymentService(payments, refunds,
|
||||
new RecordingTransactions(), CLOCK);
|
||||
|
||||
assertEquals(payment, service.refund(LANDLORD_ID, PAYMENT_ID, "operation-1", 999,
|
||||
new BigDecimal("250.00"), LocalDate.of(2026, 2, 9)));
|
||||
assertThrows(RentPaymentConflict.class, () -> service.refund(LANDLORD_ID, PAYMENT_ID, "operation-1", 5,
|
||||
new BigDecimal("249"), LocalDate.of(2026, 2, 9)));
|
||||
assertThrows(RentPaymentConflict.class, () -> service.refund(LANDLORD_ID, PAYMENT_ID, "operation-1", 5,
|
||||
new BigDecimal("250"), LocalDate.of(2026, 2, 8)));
|
||||
|
||||
verify(payments, never()).save(any());
|
||||
verify(refunds, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void futureDateAndStalePaymentVersionCreateNoRefund() {
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentPaymentRefundRepository refunds = mock(RentPaymentRefundRepository.class);
|
||||
RentPayment payment = RentPayment.reconstitute(PAYMENT_ID, LANDLORD_ID, new LeaseId("lease-1"),
|
||||
LocalDate.of(2026, 2, 2), new BigDecimal("1000"), RentPaymentStatus.CONFIRMED,
|
||||
List.of(), BigDecimal.ZERO, LocalDate.of(2026, 2, 2), 4);
|
||||
RentPaymentRefundId refundId = new RentPaymentRefundId("operation-1");
|
||||
when(refunds.find(LANDLORD_ID, refundId)).thenReturn(Optional.empty());
|
||||
when(payments.find(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(payment));
|
||||
RefundRentPaymentService service = new RefundRentPaymentService(payments, refunds,
|
||||
new RecordingTransactions(), CLOCK);
|
||||
|
||||
assertThrows(RentRuleViolation.class,
|
||||
() -> service.refund(LANDLORD_ID, PAYMENT_ID, "operation-1", 4, new BigDecimal("50"),
|
||||
LocalDate.of(2026, 2, 11)));
|
||||
assertThrows(ConcurrentChange.class, () -> service.refund(LANDLORD_ID, PAYMENT_ID, "operation-1", 3,
|
||||
new BigDecimal("50"), LocalDate.of(2026, 2, 9)));
|
||||
|
||||
verify(payments, never()).save(any());
|
||||
verify(refunds, never()).save(any());
|
||||
}
|
||||
|
||||
private static final class RecordingTransactions implements TransactionRunner {
|
||||
private int calls;
|
||||
|
||||
@Override
|
||||
public <T> T run(Supplier<T> operation) {
|
||||
calls++;
|
||||
return operation.get();
|
||||
}
|
||||
}
|
||||
}
|
||||
-187
@@ -1,187 +0,0 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rent;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDue;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueSettlement;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPayment;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefund;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefundId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefundRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversal;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversalId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversalRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentStatus;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentRuleViolation;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.YearMonth;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class RentMoneyMovementServicesTest {
|
||||
private static final LandlordId LANDLORD_ID = new LandlordId("landlord-1");
|
||||
private static final LeaseId LEASE_ID = new LeaseId("lease-1");
|
||||
private static final RentPaymentId PAYMENT_ID = new RentPaymentId("payment-1");
|
||||
private static final RentDueId DUE_ID = new RentDueId("due-1");
|
||||
private static final RentDueId LATER_DUE_ID = new RentDueId("due-2");
|
||||
private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-02-10T12:00:00Z"), ZoneOffset.UTC);
|
||||
|
||||
@Test
|
||||
void confirmsAllocatesAndReversesMoneyWithItsDueBalances() {
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
RentPaymentReversalRepository reversals = mock(RentPaymentReversalRepository.class);
|
||||
RecordingTransactions transactions = new RecordingTransactions();
|
||||
AtomicInteger ids = new AtomicInteger();
|
||||
RentPayment payment = RentPayment.draft(PAYMENT_ID, LANDLORD_ID, LEASE_ID,
|
||||
LocalDate.of(2026, 2, 2), new BigDecimal("1000"));
|
||||
RentDue due = due(LEASE_ID);
|
||||
RentDue laterDue = RentDue.create(LATER_DUE_ID, LANDLORD_ID, LEASE_ID, YearMonth.of(2026, 2),
|
||||
LocalDate.of(2026, 3, 5), new BigDecimal("50"), BigDecimal.ZERO);
|
||||
when(payments.find(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(payment));
|
||||
when(rentDues.find(LANDLORD_ID, DUE_ID)).thenReturn(Optional.of(due));
|
||||
when(rentDues.find(LANDLORD_ID, LATER_DUE_ID)).thenReturn(Optional.of(laterDue));
|
||||
when(payments.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(rentDues.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(rentDues.saveAll(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(reversals.findByPayment(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.empty());
|
||||
when(reversals.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
new ConfirmRentPaymentService(payments, rentDues, transactions,
|
||||
() -> "id-" + ids.incrementAndGet(), CLOCK).confirm(LANDLORD_ID, PAYMENT_ID,
|
||||
List.of(new ConfirmRentPaymentService.RequestedAllocation(DUE_ID, new BigDecimal("900"))));
|
||||
new AllocateRentPaymentService(payments, rentDues, transactions,
|
||||
() -> "id-" + ids.incrementAndGet(), CLOCK).allocate(LANDLORD_ID, PAYMENT_ID, LATER_DUE_ID,
|
||||
new BigDecimal("50"));
|
||||
|
||||
assertEquals(RentPaymentStatus.CONFIRMED, payment.status());
|
||||
assertEquals(RentDueSettlement.PARTIALLY_PAID, due.settlement());
|
||||
assertEquals(RentDueSettlement.PAID, laterDue.settlement());
|
||||
assertEquals(new BigDecimal("50"), payment.unallocatedBalance());
|
||||
|
||||
RentPaymentReversal reversal = new ReverseRentPaymentService(payments, reversals, rentDues, transactions,
|
||||
() -> "id-" + ids.incrementAndGet(), CLOCK).reverse(LANDLORD_ID, PAYMENT_ID, "Erreur bancaire");
|
||||
|
||||
assertEquals(RentPaymentStatus.REVERSED, payment.status());
|
||||
assertEquals(RentDueSettlement.UNPAID, due.settlement());
|
||||
assertEquals(new BigDecimal("0"), due.allocatedAmount());
|
||||
assertEquals(RentDueSettlement.UNPAID, laterDue.settlement());
|
||||
assertEquals(2, reversal.reversedAllocations().size());
|
||||
assertEquals(3, transactions.calls);
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsTheImmutableReversalAuditByScopedPayment() {
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
RentPaymentReversalRepository reversals = mock(RentPaymentReversalRepository.class);
|
||||
RentPaymentReversal expected = new RentPaymentReversal(new RentPaymentReversalId("reversal-1"),
|
||||
LANDLORD_ID, LEASE_ID, PAYMENT_ID, LocalDate.of(2026, 2, 10), "Erreur bancaire", List.of());
|
||||
when(reversals.findByPayment(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(expected));
|
||||
|
||||
RentPaymentReversal actual = new ReverseRentPaymentService(payments, reversals, rentDues,
|
||||
new RecordingTransactions(), () -> "unused", CLOCK).get(LANDLORD_ID, PAYMENT_ID);
|
||||
|
||||
assertEquals(expected, actual);
|
||||
verify(reversals).findByPayment(LANDLORD_ID, PAYMENT_ID);
|
||||
verifyNoInteractions(payments, rentDues);
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmsARefundAgainstOnlyTheUnallocatedBalance() {
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentPaymentRefundRepository refunds = mock(RentPaymentRefundRepository.class);
|
||||
RecordingTransactions transactions = new RecordingTransactions();
|
||||
RentPayment payment = RentPayment.draft(PAYMENT_ID, LANDLORD_ID, LEASE_ID,
|
||||
LocalDate.of(2026, 2, 2), new BigDecimal("1000"));
|
||||
payment.confirm(List.of(), LocalDate.of(2026, 2, 3));
|
||||
RentPaymentRefund refund = RentPaymentRefund.draft(new RentPaymentRefundId("refund-1"), LANDLORD_ID,
|
||||
LEASE_ID, PAYMENT_ID, new BigDecimal("150"), LocalDate.of(2026, 2, 9));
|
||||
when(payments.find(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(payment));
|
||||
when(refunds.find(LANDLORD_ID, refund.id())).thenReturn(Optional.of(refund));
|
||||
when(payments.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(refunds.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
RentPaymentRefund confirmed = new ConfirmRentPaymentRefundService(payments, refunds, transactions, CLOCK)
|
||||
.confirm(LANDLORD_ID, refund.id());
|
||||
|
||||
assertEquals(new BigDecimal("850"), payment.unallocatedBalance());
|
||||
assertEquals(LocalDate.of(2026, 2, 10), confirmed.confirmedAt());
|
||||
assertEquals(1, transactions.calls);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsCrossLeaseAllocationsWithoutSavingEitherAggregate() {
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
RentPayment payment = RentPayment.draft(PAYMENT_ID, LANDLORD_ID, LEASE_ID,
|
||||
LocalDate.of(2026, 2, 2), new BigDecimal("1000"));
|
||||
RentDue otherLeaseDue = due(new LeaseId("lease-2"));
|
||||
when(payments.find(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(payment));
|
||||
when(rentDues.find(LANDLORD_ID, DUE_ID)).thenReturn(Optional.of(otherLeaseDue));
|
||||
|
||||
ConfirmRentPaymentService service = new ConfirmRentPaymentService(payments, rentDues,
|
||||
new RecordingTransactions(), () -> "allocation-1", CLOCK);
|
||||
|
||||
assertThrows(RentRuleViolation.class, () -> service.confirm(LANDLORD_ID, PAYMENT_ID,
|
||||
List.of(new ConfirmRentPaymentService.RequestedAllocation(DUE_ID, new BigDecimal("100")))));
|
||||
verify(payments, never()).save(any());
|
||||
verify(rentDues, never()).saveAll(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDuplicateDueAllocationsBeforeLookingUpOrMutatingDues() {
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
RentPayment payment = RentPayment.draft(PAYMENT_ID, LANDLORD_ID, LEASE_ID,
|
||||
LocalDate.of(2026, 2, 2), new BigDecimal("1000"));
|
||||
when(payments.find(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(payment));
|
||||
ConfirmRentPaymentService service = new ConfirmRentPaymentService(payments, rentDues,
|
||||
new RecordingTransactions(), () -> "allocation-1", CLOCK);
|
||||
|
||||
assertThrows(RentRuleViolation.class, () -> service.confirm(LANDLORD_ID, PAYMENT_ID, List.of(
|
||||
new ConfirmRentPaymentService.RequestedAllocation(DUE_ID, new BigDecimal("400")),
|
||||
new ConfirmRentPaymentService.RequestedAllocation(DUE_ID, new BigDecimal("500")))));
|
||||
|
||||
verifyNoInteractions(rentDues);
|
||||
verify(payments, never()).save(any());
|
||||
assertEquals(RentPaymentStatus.DRAFT, payment.status());
|
||||
}
|
||||
|
||||
private RentDue due(LeaseId leaseId) {
|
||||
return RentDue.create(DUE_ID, LANDLORD_ID, leaseId, YearMonth.of(2026, 1),
|
||||
LocalDate.of(2026, 2, 5), new BigDecimal("900"), new BigDecimal("50"));
|
||||
}
|
||||
|
||||
private static final class RecordingTransactions implements TransactionRunner {
|
||||
private int calls;
|
||||
|
||||
@Override
|
||||
public <T> T run(Supplier<T> operation) {
|
||||
calls++;
|
||||
return operation.get();
|
||||
}
|
||||
}
|
||||
}
|
||||
-135
@@ -1,135 +0,0 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rent;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
|
||||
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSnapshot;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.RentTerms;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.RentalPeriod;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPayment;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentRuleViolation;
|
||||
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class RentPaymentDraftServiceTest {
|
||||
|
||||
private static final LandlordId LANDLORD_ID = new LandlordId("landlord-1");
|
||||
private static final LeaseId LEASE_ID = new LeaseId("lease-1");
|
||||
private static final RentPaymentId PAYMENT_ID = new RentPaymentId("payment-1");
|
||||
private static final LocalDate RECEIVED_AT = LocalDate.of(2026, 2, 2);
|
||||
|
||||
@Test
|
||||
void scopesLeaseListingAndCreatesADraftForAnActiveLease() {
|
||||
LeaseRepository leases = mock(LeaseRepository.class);
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
Lease lease = activeLease();
|
||||
RentPayment existing = RentPayment.draft(
|
||||
PAYMENT_ID, LANDLORD_ID, LEASE_ID, RECEIVED_AT, new BigDecimal("900"));
|
||||
when(leases.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.of(lease));
|
||||
when(payments.findAll(LANDLORD_ID, LEASE_ID)).thenReturn(List.of(existing));
|
||||
when(payments.save(any(RentPayment.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
RentPaymentDraftService service = new RentPaymentDraftService(leases, payments, () -> "payment-2");
|
||||
|
||||
assertEquals(List.of(existing), service.list(LANDLORD_ID, LEASE_ID));
|
||||
RentPayment created = service.create(
|
||||
LANDLORD_ID, LEASE_ID, LocalDate.of(2026, 2, 5), new BigDecimal("1000"));
|
||||
|
||||
assertEquals(new RentPaymentId("payment-2"), created.id());
|
||||
assertEquals(LANDLORD_ID, created.landlordId());
|
||||
assertEquals(LEASE_ID, created.leaseId());
|
||||
verify(leases, times(2)).find(LANDLORD_ID, LEASE_ID);
|
||||
verify(payments).findAll(LANDLORD_ID, LEASE_ID);
|
||||
verify(payments).save(created);
|
||||
}
|
||||
|
||||
@Test
|
||||
void revisesAndDeletesOnlyTheScopedDraftAndPropagatesAStaleDelete() {
|
||||
LeaseRepository leases = mock(LeaseRepository.class);
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentPayment draft = RentPayment.draft(
|
||||
PAYMENT_ID, LANDLORD_ID, LEASE_ID, RECEIVED_AT, new BigDecimal("900"));
|
||||
when(payments.find(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(draft));
|
||||
when(payments.save(draft)).thenReturn(draft);
|
||||
when(payments.deleteDraft(draft)).thenReturn(true, false);
|
||||
RentPaymentDraftService service = new RentPaymentDraftService(leases, payments, () -> "unused");
|
||||
|
||||
RentPayment revised = service.revise(
|
||||
LANDLORD_ID, PAYMENT_ID, LocalDate.of(2026, 2, 4), new BigDecimal("950"));
|
||||
service.delete(LANDLORD_ID, PAYMENT_ID);
|
||||
|
||||
assertEquals(LocalDate.of(2026, 2, 4), revised.receivedAt());
|
||||
assertEquals(new BigDecimal("950"), revised.amount());
|
||||
assertThrows(ConcurrentChange.class, () -> service.delete(LANDLORD_ID, PAYMENT_ID));
|
||||
verify(payments, times(3)).find(LANDLORD_ID, PAYMENT_ID);
|
||||
verify(payments).save(draft);
|
||||
verify(payments, times(2)).deleteDraft(draft);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsCreationForADraftLeaseAndRevisionOfAConfirmedPayment() {
|
||||
LeaseRepository leases = mock(LeaseRepository.class);
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
when(leases.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.of(draftLease()));
|
||||
RentPayment confirmed = confirmedPayment();
|
||||
when(payments.find(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(confirmed));
|
||||
RentPaymentDraftService service = new RentPaymentDraftService(leases, payments, () -> "payment-2");
|
||||
|
||||
assertThrows(RentRuleViolation.class, () -> service.create(
|
||||
LANDLORD_ID, LEASE_ID, RECEIVED_AT, new BigDecimal("900")));
|
||||
assertThrows(RentRuleViolation.class, () -> service.revise(
|
||||
LANDLORD_ID, PAYMENT_ID, RECEIVED_AT.plusDays(1), new BigDecimal("950")));
|
||||
verify(payments, never()).save(any());
|
||||
}
|
||||
|
||||
private static RentPayment confirmedPayment() {
|
||||
RentPayment payment = RentPayment.draft(
|
||||
PAYMENT_ID, LANDLORD_ID, LEASE_ID, RECEIVED_AT, new BigDecimal("1000"));
|
||||
payment.confirm(List.of(), RECEIVED_AT.plusDays(1));
|
||||
return payment;
|
||||
}
|
||||
|
||||
private static Lease activeLease() {
|
||||
Lease lease = draftLease();
|
||||
RentalPeriod period = lease.period();
|
||||
RentTerms terms = lease.terms();
|
||||
lease.activate(new LeaseSnapshot(
|
||||
new LeaseSnapshot.Landlord(
|
||||
LANDLORD_ID, "Léa", "Dubois", "lea@example.com", "+33612345678", "1 rue A"),
|
||||
new LeaseSnapshot.Apartment(lease.apartmentId(), "Studio", "2 rue B"),
|
||||
List.of(new LeaseSnapshot.Tenant(
|
||||
lease.tenantIds().getFirst(), "Camille", "Martin", "camille@example.com", "", "3 rue C")),
|
||||
List.of(),
|
||||
period,
|
||||
terms));
|
||||
return lease;
|
||||
}
|
||||
|
||||
private static Lease draftLease() {
|
||||
return Lease.draft(
|
||||
LEASE_ID,
|
||||
LANDLORD_ID,
|
||||
new ApartmentId("apartment-1"),
|
||||
List.of(new TenantId("tenant-1")),
|
||||
List.of(),
|
||||
new RentalPeriod(LocalDate.of(2026, 1, 1), LocalDate.of(2026, 12, 31)),
|
||||
new RentTerms(new BigDecimal("900"), new BigDecimal("50"), new BigDecimal("900")));
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rent;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPayment;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefund;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefundId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefundRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversal;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversalId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversalRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class RentPaymentHistoryServiceTest {
|
||||
private static final LandlordId LANDLORD_ID = new LandlordId("landlord-1");
|
||||
private static final LeaseId LEASE_ID = new LeaseId("lease-1");
|
||||
private static final RentPaymentId PAYMENT_ID = new RentPaymentId("payment-1");
|
||||
|
||||
@Test
|
||||
void returnsThePaymentWithItsRefundsAndOptionalReversal() {
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentPaymentRefundRepository refunds = mock(RentPaymentRefundRepository.class);
|
||||
RentPaymentReversalRepository reversals = mock(RentPaymentReversalRepository.class);
|
||||
RentPayment payment = RentPayment.confirmed(PAYMENT_ID, LANDLORD_ID, LEASE_ID,
|
||||
LocalDate.of(2026, 2, 2), new BigDecimal("1000"), List.of(), LocalDate.of(2026, 2, 2));
|
||||
RentPaymentRefund refund = RentPaymentRefund.reconstitute(new RentPaymentRefundId("refund-1"), LANDLORD_ID,
|
||||
LEASE_ID, PAYMENT_ID, new BigDecimal("50"), LocalDate.of(2026, 2, 3), 0);
|
||||
RentPaymentReversal reversal = new RentPaymentReversal(new RentPaymentReversalId("reversal-1"),
|
||||
LANDLORD_ID, LEASE_ID, PAYMENT_ID, LocalDate.of(2026, 2, 4), "Correction", List.of());
|
||||
when(payments.find(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(payment));
|
||||
when(refunds.findAll(LANDLORD_ID, PAYMENT_ID)).thenReturn(List.of(refund));
|
||||
when(reversals.findByPayment(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(reversal));
|
||||
|
||||
RentPaymentHistory history = new RentPaymentHistoryService(payments, refunds, reversals,
|
||||
mock(LeaseRepository.class)).get(LANDLORD_ID, PAYMENT_ID);
|
||||
|
||||
assertEquals(payment, history.payment());
|
||||
assertEquals(List.of(refund), history.refunds());
|
||||
assertTrue(history.reversal().isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void listsEnrichedHistoryWithoutOneQueryPerPayment() {
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentPaymentRefundRepository refunds = mock(RentPaymentRefundRepository.class);
|
||||
RentPaymentReversalRepository reversals = mock(RentPaymentReversalRepository.class);
|
||||
RentPayment payment = RentPayment.confirmed(PAYMENT_ID, LANDLORD_ID, LEASE_ID,
|
||||
LocalDate.of(2026, 2, 2), new BigDecimal("1000"), List.of(), LocalDate.of(2026, 2, 2));
|
||||
RentPaymentRefund refund = RentPaymentRefund.reconstitute(new RentPaymentRefundId("refund-1"), LANDLORD_ID,
|
||||
LEASE_ID, PAYMENT_ID, new BigDecimal("50"), LocalDate.of(2026, 2, 3), 0);
|
||||
RentPaymentReversal reversal = new RentPaymentReversal(new RentPaymentReversalId("reversal-1"),
|
||||
LANDLORD_ID, LEASE_ID, PAYMENT_ID, LocalDate.of(2026, 2, 4), "Correction", List.of());
|
||||
when(payments.findAll(LANDLORD_ID)).thenReturn(List.of(payment));
|
||||
when(refunds.findAll(LANDLORD_ID)).thenReturn(List.of(refund));
|
||||
when(reversals.findAll(LANDLORD_ID)).thenReturn(List.of(reversal));
|
||||
|
||||
List<RentPaymentHistory> history = new RentPaymentHistoryService(payments, refunds, reversals,
|
||||
mock(LeaseRepository.class)).list(LANDLORD_ID, null);
|
||||
|
||||
assertEquals(1, history.size());
|
||||
assertEquals(List.of(refund), history.getFirst().refunds());
|
||||
assertTrue(history.getFirst().reversal().isPresent());
|
||||
verify(refunds).findAll(LANDLORD_ID);
|
||||
verify(reversals).findAll(LANDLORD_ID);
|
||||
}
|
||||
}
|
||||
-140
@@ -1,140 +0,0 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rent;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPayment;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefund;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefundId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefundRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentRuleViolation;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class RentPaymentRefundDraftServiceTest {
|
||||
|
||||
private static final LandlordId LANDLORD_ID = new LandlordId("landlord-1");
|
||||
private static final LeaseId LEASE_ID = new LeaseId("lease-1");
|
||||
private static final RentPaymentId PAYMENT_ID = new RentPaymentId("payment-1");
|
||||
private static final RentPaymentRefundId REFUND_ID = new RentPaymentRefundId("refund-1");
|
||||
private static final LocalDate RECEIVED_AT = LocalDate.of(2026, 2, 2);
|
||||
|
||||
@Test
|
||||
void scopesListingAndCreatesADraftForAConfirmedPayment() {
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentPaymentRefundRepository refunds = mock(RentPaymentRefundRepository.class);
|
||||
RentPayment payment = confirmedPayment();
|
||||
RentPaymentRefund existing = refund();
|
||||
when(payments.find(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(payment));
|
||||
when(refunds.findAll(LANDLORD_ID, PAYMENT_ID)).thenReturn(List.of(existing));
|
||||
when(refunds.save(any(RentPaymentRefund.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
RentPaymentRefundDraftService service =
|
||||
new RentPaymentRefundDraftService(payments, refunds, () -> "refund-2");
|
||||
|
||||
assertEquals(List.of(existing), service.list(LANDLORD_ID, PAYMENT_ID));
|
||||
RentPaymentRefund created = service.create(
|
||||
LANDLORD_ID, PAYMENT_ID, new BigDecimal("100"), LocalDate.of(2026, 2, 10));
|
||||
|
||||
assertEquals(new RentPaymentRefundId("refund-2"), created.id());
|
||||
assertEquals(LANDLORD_ID, created.landlordId());
|
||||
assertEquals(LEASE_ID, created.leaseId());
|
||||
assertEquals(PAYMENT_ID, created.paymentId());
|
||||
verify(payments, times(2)).find(LANDLORD_ID, PAYMENT_ID);
|
||||
verify(refunds).findAll(LANDLORD_ID, PAYMENT_ID);
|
||||
verify(refunds).save(created);
|
||||
}
|
||||
|
||||
@Test
|
||||
void revisesAndDeletesOnlyTheScopedDraft() {
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentPaymentRefundRepository refunds = mock(RentPaymentRefundRepository.class);
|
||||
RentPaymentRefund draft = refund();
|
||||
when(refunds.find(LANDLORD_ID, REFUND_ID)).thenReturn(Optional.of(draft));
|
||||
when(payments.find(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(confirmedPayment()));
|
||||
when(refunds.save(draft)).thenReturn(draft);
|
||||
when(refunds.deleteDraft(draft)).thenReturn(true);
|
||||
RentPaymentRefundDraftService service =
|
||||
new RentPaymentRefundDraftService(payments, refunds, () -> "unused");
|
||||
|
||||
RentPaymentRefund revised = service.revise(
|
||||
LANDLORD_ID, REFUND_ID, new BigDecimal("125"), LocalDate.of(2026, 2, 11));
|
||||
service.delete(LANDLORD_ID, REFUND_ID);
|
||||
|
||||
assertEquals(new BigDecimal("125"), revised.amount());
|
||||
assertEquals(LocalDate.of(2026, 2, 11), revised.refundedAt());
|
||||
verify(refunds, times(2)).find(LANDLORD_ID, REFUND_ID);
|
||||
verify(refunds).save(draft);
|
||||
verify(refunds).deleteDraft(draft);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsCreationForANonConfirmedPaymentAndRevisionOfAConfirmedRefund() {
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentPaymentRefundRepository refunds = mock(RentPaymentRefundRepository.class);
|
||||
RentPayment unconfirmed = RentPayment.draft(
|
||||
PAYMENT_ID, LANDLORD_ID, LEASE_ID, RECEIVED_AT, new BigDecimal("1000"));
|
||||
when(payments.find(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(unconfirmed));
|
||||
RentPayment confirmedPayment = confirmedPayment();
|
||||
RentPaymentRefund confirmedRefund = refund();
|
||||
confirmedRefund.confirm(confirmedPayment, LocalDate.of(2026, 2, 12));
|
||||
when(refunds.find(LANDLORD_ID, REFUND_ID)).thenReturn(Optional.of(confirmedRefund));
|
||||
RentPaymentRefundDraftService service =
|
||||
new RentPaymentRefundDraftService(payments, refunds, () -> "refund-2");
|
||||
|
||||
assertThrows(RentRuleViolation.class, () -> service.create(
|
||||
LANDLORD_ID, PAYMENT_ID, new BigDecimal("100"), LocalDate.of(2026, 2, 10)));
|
||||
assertThrows(RentRuleViolation.class, () -> service.revise(
|
||||
LANDLORD_ID, REFUND_ID, new BigDecimal("125"), LocalDate.of(2026, 2, 11)));
|
||||
verify(refunds, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDraftAmountsAboveThePaymentsCurrentUnallocatedBalance() {
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentPaymentRefundRepository refunds = mock(RentPaymentRefundRepository.class);
|
||||
RentPayment payment = confirmedPayment();
|
||||
RentPaymentRefund draft = refund();
|
||||
when(payments.find(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(payment));
|
||||
when(refunds.find(LANDLORD_ID, REFUND_ID)).thenReturn(Optional.of(draft));
|
||||
RentPaymentRefundDraftService service =
|
||||
new RentPaymentRefundDraftService(payments, refunds, () -> "refund-2");
|
||||
|
||||
assertThrows(RentRuleViolation.class, () -> service.create(
|
||||
LANDLORD_ID, PAYMENT_ID, new BigDecimal("1000.01"), LocalDate.of(2026, 2, 10)));
|
||||
assertThrows(RentRuleViolation.class, () -> service.revise(
|
||||
LANDLORD_ID, REFUND_ID, new BigDecimal("1000.01"), LocalDate.of(2026, 2, 11)));
|
||||
|
||||
assertEquals(new BigDecimal("100"), draft.amount());
|
||||
verify(refunds, never()).save(any());
|
||||
}
|
||||
|
||||
private static RentPayment confirmedPayment() {
|
||||
RentPayment payment = RentPayment.draft(
|
||||
PAYMENT_ID, LANDLORD_ID, LEASE_ID, RECEIVED_AT, new BigDecimal("1000"));
|
||||
payment.confirm(List.of(), RECEIVED_AT.plusDays(1));
|
||||
return payment;
|
||||
}
|
||||
|
||||
private static RentPaymentRefund refund() {
|
||||
return RentPaymentRefund.draft(
|
||||
REFUND_ID,
|
||||
LANDLORD_ID,
|
||||
LEASE_ID,
|
||||
PAYMENT_ID,
|
||||
new BigDecimal("100"),
|
||||
LocalDate.of(2026, 2, 10));
|
||||
}
|
||||
}
|
||||
+6
-7
@@ -30,7 +30,6 @@ import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefund;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefundId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefundRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefundStatus;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentStatus;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentRuleViolation;
|
||||
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
|
||||
@@ -119,14 +118,15 @@ class RentalDocumentGenerationServicesTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void paymentReceiptRefusesAnUnconfirmedPaymentBeforeLoadingItsLease() {
|
||||
void paymentReceiptRefusesAReversedPaymentBeforeLoadingItsLease() {
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
RentPaymentRefundRepository refunds = mock(RentPaymentRefundRepository.class);
|
||||
LeaseRepository leases = mock(LeaseRepository.class);
|
||||
RentalDocumentRenderer renderer = mock(RentalDocumentRenderer.class);
|
||||
RentPayment payment = RentPayment.draft(PAYMENT_ID, LANDLORD_ID, LEASE_ID,
|
||||
LocalDate.of(2026, 8, 2), new BigDecimal("1200.00"));
|
||||
RentPayment payment = RentPayment.confirmed(PAYMENT_ID, LANDLORD_ID, LEASE_ID,
|
||||
LocalDate.of(2026, 8, 2), new BigDecimal("1200.00"), List.of(), LocalDate.of(2026, 8, 2));
|
||||
payment.reverse();
|
||||
when(payments.find(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(payment));
|
||||
|
||||
GeneratePaymentReceiptService service = new GeneratePaymentReceiptService(
|
||||
@@ -156,7 +156,7 @@ class RentalDocumentGenerationServicesTest {
|
||||
RentDue due = due(YearMonth.of(2026, 8), BigDecimal.ZERO);
|
||||
RentPaymentRefund refund = RentPaymentRefund.reconstitute(new RentPaymentRefundId("refund-1"),
|
||||
LANDLORD_ID, LEASE_ID, PAYMENT_ID, new BigDecimal("100.00"), LocalDate.of(2026, 8, 5),
|
||||
RentPaymentRefundStatus.CONFIRMED, LocalDate.of(2026, 8, 6), 2);
|
||||
2);
|
||||
when(payments.find(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(payment));
|
||||
when(leases.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.of(lease));
|
||||
when(rentDues.find(LANDLORD_ID, DUE_ID)).thenReturn(Optional.of(due));
|
||||
@@ -178,8 +178,7 @@ class RentalDocumentGenerationServicesTest {
|
||||
assertEquals(LeaseContractVersion.from(lease.snapshot()), value.contract());
|
||||
assertEquals(List.of(new ReceiptAllocation(allocation.id(), DUE_ID, YearMonth.of(2026, 8),
|
||||
new BigDecimal("900.00"), allocatedAt)), value.allocations());
|
||||
assertEquals(List.of(new ReceiptRefund(refund.id(), refund.amount(), refund.refundedAt(),
|
||||
refund.confirmedAt())), value.refunds());
|
||||
assertEquals(List.of(new ReceiptRefund(refund.id(), refund.amount(), refund.refundedAt())), value.refunds());
|
||||
assertEquals("recu-reglement-payment-1.pdf", generated.fileName());
|
||||
verify(payments).find(LANDLORD_ID, PAYMENT_ID);
|
||||
verify(leases).find(LANDLORD_ID, LEASE_ID);
|
||||
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rent;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.PaymentAllocation;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.PaymentAllocationId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDue;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueSettlement;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPayment;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversal;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversalId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversalRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentStatus;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.YearMonth;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class ReverseRentPaymentServiceTest {
|
||||
private static final LandlordId LANDLORD_ID = new LandlordId("landlord-1");
|
||||
private static final LeaseId LEASE_ID = new LeaseId("lease-1");
|
||||
private static final RentPaymentId PAYMENT_ID = new RentPaymentId("payment-1");
|
||||
private static final RentDueId DUE_ID = new RentDueId("due-1");
|
||||
private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-02-10T12:00:00Z"), ZoneOffset.UTC);
|
||||
|
||||
@Test
|
||||
void reversesTheCurrentPaymentAndReleasesItsAllocationsAfterRefunds() {
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentPaymentReversalRepository reversals = mock(RentPaymentReversalRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
RecordingTransactions transactions = new RecordingTransactions();
|
||||
PaymentAllocation allocation = new PaymentAllocation(new PaymentAllocationId("allocation-1"), DUE_ID,
|
||||
new BigDecimal("900"), LocalDate.of(2026, 2, 2));
|
||||
RentPayment payment = RentPayment.reconstitute(PAYMENT_ID, LANDLORD_ID, LEASE_ID,
|
||||
LocalDate.of(2026, 2, 2), new BigDecimal("1000"), RentPaymentStatus.CONFIRMED,
|
||||
List.of(allocation), new BigDecimal("60"), LocalDate.of(2026, 2, 2), 5);
|
||||
RentDue due = RentDue.reconstitute(DUE_ID, LANDLORD_ID, LEASE_ID, YearMonth.of(2026, 1),
|
||||
LocalDate.of(2026, 2, 5), new BigDecimal("900"), new BigDecimal("50"),
|
||||
new BigDecimal("900"), 3);
|
||||
when(reversals.findByPayment(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.empty());
|
||||
when(payments.find(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(payment));
|
||||
when(rentDues.find(LANDLORD_ID, DUE_ID)).thenReturn(Optional.of(due));
|
||||
when(payments.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(rentDues.saveAll(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(reversals.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
RentPaymentReversal reversal = new ReverseRentPaymentService(payments, reversals, rentDues, transactions,
|
||||
() -> "reversal-1", CLOCK).reverse(LANDLORD_ID, PAYMENT_ID, 5, "Erreur bancaire");
|
||||
|
||||
assertEquals(RentPaymentStatus.REVERSED, payment.status());
|
||||
assertEquals(new BigDecimal("60"), payment.refundedAmount());
|
||||
assertEquals(RentDueSettlement.UNPAID, due.settlement());
|
||||
assertEquals("Erreur bancaire", reversal.reason());
|
||||
assertEquals(1, transactions.calls);
|
||||
}
|
||||
|
||||
@Test
|
||||
void retryRequiresTheSameReversalReasonAndIgnoresAStalePaymentVersion() {
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentPaymentReversalRepository reversals = mock(RentPaymentReversalRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
RentPaymentReversal existing = new RentPaymentReversal(new RentPaymentReversalId("reversal-1"),
|
||||
LANDLORD_ID, LEASE_ID, PAYMENT_ID, LocalDate.of(2026, 2, 10), "Erreur bancaire", List.of());
|
||||
when(reversals.findByPayment(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(existing));
|
||||
ReverseRentPaymentService service = new ReverseRentPaymentService(payments, reversals, rentDues,
|
||||
new RecordingTransactions(), () -> "unused", CLOCK);
|
||||
|
||||
assertEquals(existing, service.reverse(LANDLORD_ID, PAYMENT_ID, 999, " Erreur bancaire "));
|
||||
assertThrows(RentPaymentConflict.class,
|
||||
() -> service.reverse(LANDLORD_ID, PAYMENT_ID, 0, "Autre raison"));
|
||||
|
||||
verifyNoInteractions(payments, rentDues);
|
||||
verify(reversals, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void stalePaymentVersionCreatesNoReversal() {
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentPaymentReversalRepository reversals = mock(RentPaymentReversalRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
RentPayment payment = RentPayment.reconstitute(PAYMENT_ID, LANDLORD_ID, LEASE_ID,
|
||||
LocalDate.of(2026, 2, 2), new BigDecimal("1000"),
|
||||
RentPaymentStatus.CONFIRMED, List.of(), BigDecimal.ZERO, LocalDate.of(2026, 2, 2), 4);
|
||||
when(reversals.findByPayment(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.empty());
|
||||
when(payments.find(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(payment));
|
||||
ReverseRentPaymentService service = new ReverseRentPaymentService(payments, reversals, rentDues,
|
||||
new RecordingTransactions(), () -> "reversal-1", CLOCK);
|
||||
|
||||
assertThrows(ConcurrentChange.class,
|
||||
() -> service.reverse(LANDLORD_ID, PAYMENT_ID, 3, "Erreur bancaire"));
|
||||
|
||||
verifyNoInteractions(rentDues);
|
||||
verify(payments, never()).save(any());
|
||||
verify(reversals, never()).save(any());
|
||||
}
|
||||
|
||||
private static final class RecordingTransactions implements TransactionRunner {
|
||||
private int calls;
|
||||
|
||||
@Override
|
||||
public <T> T run(Supplier<T> operation) {
|
||||
calls++;
|
||||
return operation.get();
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
-14
@@ -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"),
|
||||
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);
|
||||
return payment;
|
||||
}
|
||||
}
|
||||
+21
-13
@@ -12,29 +12,36 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class RentPaymentTest {
|
||||
@Test
|
||||
void confirmedPaymentIsBornAsAnImmutableCashFact() {
|
||||
PaymentAllocation allocation = allocation("operation-1", "due-1", "950");
|
||||
|
||||
RentPayment payment = RentPayment.confirmed(new RentPaymentId("payment-1"),
|
||||
new LandlordId("landlord-1"), new LeaseId("lease-1"), LocalDate.of(2026, 2, 1),
|
||||
new BigDecimal("1000"), List.of(allocation), LocalDate.of(2026, 2, 2));
|
||||
|
||||
assertEquals(RentPaymentStatus.CONFIRMED, payment.status());
|
||||
assertEquals(List.of(allocation), payment.allocations());
|
||||
assertEquals(new BigDecimal("50"), payment.unallocatedBalance());
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmedPaymentOwnsAllocationsAndRetainsSurplus() {
|
||||
RentPayment payment = payment("1000");
|
||||
PaymentAllocation first = allocation("allocation-1", "due-1", "400");
|
||||
|
||||
RentPaymentConfirmed event = payment.confirm(List.of(first), LocalDate.of(2026, 2, 2));
|
||||
payment.allocate(allocation("allocation-2", "due-2", "500"));
|
||||
|
||||
assertEquals(RentPaymentStatus.CONFIRMED, payment.status());
|
||||
assertEquals(new BigDecimal("900"), payment.allocatedAmount());
|
||||
assertEquals(new BigDecimal("100"), payment.unallocatedBalance());
|
||||
assertEquals(List.of(first), event.allocations());
|
||||
assertEquals(new BigDecimal("500"), payment.allocatedAmount());
|
||||
assertEquals(new BigDecimal("500"), payment.unallocatedBalance());
|
||||
assertThrows(RentRuleViolation.class,
|
||||
() -> payment.allocate(allocation("allocation-3", "due-3", "101")));
|
||||
assertThrows(RentRuleViolation.class,
|
||||
() -> payment.revise(LocalDate.of(2026, 2, 3), new BigDecimal("1200")));
|
||||
() -> payment.allocate(allocation("allocation-3", "due-3", "501")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void refundsUseOnlySurplusAndReversalIsAuditableAndIdempotent() {
|
||||
RentPayment payment = payment("1000");
|
||||
PaymentAllocation allocation = allocation("allocation-1", "due-1", "900");
|
||||
payment.confirm(List.of(allocation), LocalDate.of(2026, 2, 2));
|
||||
payment.allocate(allocation);
|
||||
payment.registerRefund(new BigDecimal("40"));
|
||||
assertEquals(new BigDecimal("60"), payment.unallocatedBalance());
|
||||
assertThrows(RentRuleViolation.class, () -> payment.registerRefund(new BigDecimal("61")));
|
||||
@@ -45,13 +52,14 @@ class RentPaymentTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void draftMustRepresentPositiveCash() {
|
||||
void confirmedPaymentMustRepresentPositiveCash() {
|
||||
assertThrows(RentRuleViolation.class, () -> payment("0"));
|
||||
}
|
||||
|
||||
private RentPayment payment(String amount) {
|
||||
return RentPayment.draft(new RentPaymentId("payment-1"), new LandlordId("landlord-1"),
|
||||
new LeaseId("lease-1"), LocalDate.of(2026, 2, 1), new BigDecimal(amount));
|
||||
return RentPayment.confirmed(new RentPaymentId("payment-1"), new LandlordId("landlord-1"),
|
||||
new LeaseId("lease-1"), LocalDate.of(2026, 2, 1), new BigDecimal(amount), List.of(),
|
||||
LocalDate.of(2026, 2, 2));
|
||||
}
|
||||
|
||||
private PaymentAllocation allocation(String id, String dueId, String amount) {
|
||||
|
||||
+71
-61
@@ -102,17 +102,18 @@ class ReplacementRentalApiJourneyIT {
|
||||
|
||||
String leaseId = create("/api/leases", """
|
||||
{"apartmentId":"%s","tenantIds":["%s"],"guarantors":[],
|
||||
"startDate":"2026-07-01","endDate":"2026-07-31",
|
||||
"startDate":"2026-07-01","endDate":"2026-08-31",
|
||||
"monthlyRent":900.00,"monthlyCharges":50.00,"securityDeposit":900.00}
|
||||
""".formatted(apartmentId, tenantId));
|
||||
|
||||
String activation = mockMvc.perform(post("/api/leases/{leaseId}/activate", leaseId).with(owner()).with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.lease.status").value("ACTIVE"))
|
||||
.andExpect(jsonPath("$.rentDues.length()").value(1))
|
||||
.andExpect(jsonPath("$.rentDues.length()").value(2))
|
||||
.andExpect(jsonPath("$.rentDues[0].total").value(950.00))
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
String dueId = read(activation, "$.rentDues[0].id");
|
||||
String secondDueId = read(activation, "$.rentDues[1].id");
|
||||
|
||||
mockMvc.perform(get("/api/rent-dues").param("leaseId", leaseId).with(owner()))
|
||||
.andExpect(status().isOk())
|
||||
@@ -120,45 +121,68 @@ class ReplacementRentalApiJourneyIT {
|
||||
.andExpect(jsonPath("$[0].settlement").value("UNPAID"));
|
||||
|
||||
String paymentId = create("/api/rent-payments", """
|
||||
{"leaseId":"%s","receivedAt":"2026-07-05","amount":950.00}
|
||||
""".formatted(leaseId));
|
||||
mockMvc.perform(post("/api/rent-payments/{paymentId}/confirm", paymentId).with(owner()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content("""
|
||||
{"allocations":[{"rentDueId":"%s","amount":950.00}]}
|
||||
""".formatted(dueId)))
|
||||
{"operationId":"payment-1","rentDueId":"%s","rentDueVersion":0,
|
||||
"receivedAt":"2026-07-05","amount":1000.00}
|
||||
""".formatted(dueId));
|
||||
mockMvc.perform(post("/api/rent-payments/{paymentId}/allocations", paymentId)
|
||||
.with(owner()).with(csrf()).contentType(MediaType.APPLICATION_JSON).content("""
|
||||
{"operationId":"allocation-1","paymentVersion":0,"rentDueId":"%s",
|
||||
"rentDueVersion":0,"amount":25.00}
|
||||
""".formatted(secondDueId)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.status").value("CONFIRMED"))
|
||||
.andExpect(jsonPath("$.allocatedAmount").value(950.00))
|
||||
.andExpect(jsonPath("$.unallocatedBalance").value(0));
|
||||
.andExpect(jsonPath("$.allocations.length()").value(2))
|
||||
.andExpect(jsonPath("$.allocations[1].id").value("allocation-1"))
|
||||
.andExpect(jsonPath("$.allocatedAmount").value(975.00))
|
||||
.andExpect(jsonPath("$.unallocatedBalance").value(25.00));
|
||||
mockMvc.perform(get("/api/rent-dues").param("leaseId", leaseId).with(owner()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[1].settlement").value("PARTIALLY_PAID"))
|
||||
.andExpect(jsonPath("$[1].allocatedAmount").value(25.00));
|
||||
mockMvc.perform(post("/api/rent-payments").with(owner()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content("""
|
||||
{"operationId":"paid-conflict","rentDueId":"%s","rentDueVersion":1,
|
||||
"receivedAt":"2026-07-05","amount":1.00}
|
||||
""".formatted(dueId)))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.message").isNotEmpty());
|
||||
|
||||
String surplusPaymentId = create("/api/rent-payments", """
|
||||
{"leaseId":"%s","receivedAt":"2026-07-06","amount":1.00}
|
||||
""".formatted(leaseId));
|
||||
mockMvc.perform(post("/api/rent-payments/{paymentId}/confirm", surplusPaymentId)
|
||||
.with(owner()).with(csrf()).contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"allocations\":[]}"))
|
||||
.andExpect(status().isOk());
|
||||
{"operationId":"payment-2","rentDueId":"%s","rentDueVersion":1,
|
||||
"receivedAt":"2026-07-06","amount":926.00}
|
||||
""".formatted(secondDueId));
|
||||
mockMvc.perform(post("/api/rent-payments/{paymentId}/refunds", surplusPaymentId)
|
||||
.with(owner()).with(csrf()).contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"amount\":1.01,\"refundedAt\":\"2026-07-07\"}"))
|
||||
.content("""
|
||||
{"operationId":"invalid-refund","paymentVersion":0,"amount":1.01,
|
||||
"refundedAt":"2026-07-07"}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest());
|
||||
String refundResponse = mockMvc.perform(post("/api/rent-payments/{paymentId}/refunds", surplusPaymentId)
|
||||
.with(owner()).with(csrf()).contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"amount\":0.50,\"refundedAt\":\"2026-07-07\"}"))
|
||||
.content("""
|
||||
{"operationId":"refund-1","paymentVersion":0,"amount":0.50,
|
||||
"refundedAt":"2026-07-07"}
|
||||
"""))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.refunds[0].id").value("refund-1"))
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
String refundId = read(refundResponse, "$.id");
|
||||
mockMvc.perform(post("/api/rent-payments/{paymentId}/refunds/{refundId}/confirm",
|
||||
surplusPaymentId, refundId).with(owner()).with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.status").value("CONFIRMED"));
|
||||
int refundedPaymentVersion = read(refundResponse, "$.version");
|
||||
mockMvc.perform(post("/api/rent-payments/{paymentId}/refunds", surplusPaymentId)
|
||||
.with(owner()).with(csrf()).contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"operationId":"refund-1","paymentVersion":0,"amount":0.50,
|
||||
"refundedAt":"2026-07-07"}
|
||||
"""))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.refunds.length()").value(1));
|
||||
mockMvc.perform(post("/api/rent-payments/{paymentId}/reversal", surplusPaymentId)
|
||||
.with(owner()).with(csrf()).contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"reason\":\"Correction après remboursement\"}"))
|
||||
.andExpect(status().isCreated());
|
||||
mockMvc.perform(get("/api/rent-payments/{paymentId}/reversal", surplusPaymentId).with(owner()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.reason").value("Correction après remboursement"));
|
||||
.content("""
|
||||
{"paymentVersion":%d,"reason":"Correction après remboursement"}
|
||||
""".formatted(refundedPaymentVersion)))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.reversal.reason").value("Correction après remboursement"))
|
||||
.andExpect(jsonPath("$.refunds[0].id").value("refund-1"));
|
||||
|
||||
assertPdf(mockMvc.perform(get("/api/rent-payments/{paymentId}/receipt", paymentId).with(owner()))
|
||||
.andExpect(status().isOk()).andExpect(content().contentType(MediaType.APPLICATION_PDF))
|
||||
@@ -168,12 +192,12 @@ class ReplacementRentalApiJourneyIT {
|
||||
.andReturn());
|
||||
|
||||
assertForeignPortfolioIsIsolated(apartmentId, tenantId, attachmentId, leaseId, dueId, paymentId,
|
||||
surplusPaymentId, refundId);
|
||||
surplusPaymentId);
|
||||
}
|
||||
|
||||
private void assertForeignPortfolioIsIsolated(String apartmentId, String tenantId, String attachmentId,
|
||||
String leaseId, String dueId, String paymentId,
|
||||
String surplusPaymentId, String refundId) throws Exception {
|
||||
String surplusPaymentId) throws Exception {
|
||||
mockMvc.perform(get("/api/apartments").with(otherLandlord()))
|
||||
.andExpect(status().isOk()).andExpect(jsonPath("$.length()").value(0));
|
||||
mockMvc.perform(get("/api/tenants").with(otherLandlord()))
|
||||
@@ -255,43 +279,27 @@ class ReplacementRentalApiJourneyIT {
|
||||
|
||||
mockMvc.perform(post("/api/rent-payments").with(otherLandlord()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content("""
|
||||
{"leaseId":"%s","receivedAt":"2026-07-09","amount":1.00}
|
||||
""".formatted(leaseId)))
|
||||
.andExpect(status().isNotFound());
|
||||
mockMvc.perform(put("/api/rent-payments/{id}", paymentId).with(otherLandlord()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"receivedAt\":\"2026-07-09\",\"amount\":1.00}"))
|
||||
.andExpect(status().isNotFound());
|
||||
mockMvc.perform(delete("/api/rent-payments/{id}", paymentId).with(otherLandlord()).with(csrf()))
|
||||
.andExpect(status().isNotFound());
|
||||
mockMvc.perform(post("/api/rent-payments/{id}/confirm", paymentId).with(otherLandlord()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content("{\"allocations\":[]}"))
|
||||
{"operationId":"foreign-payment","rentDueId":"%s","rentDueVersion":1,
|
||||
"receivedAt":"2026-07-09","amount":1.00}
|
||||
""".formatted(dueId)))
|
||||
.andExpect(status().isNotFound());
|
||||
mockMvc.perform(post("/api/rent-payments/{id}/allocations", paymentId)
|
||||
.with(otherLandlord()).with(csrf()).contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"rentDueId\":\"%s\",\"amount\":1.00}".formatted(dueId)))
|
||||
.content("""
|
||||
{"operationId":"foreign-allocation","paymentVersion":0,"rentDueId":"%s",
|
||||
"rentDueVersion":1,"amount":1.00}
|
||||
""".formatted(dueId)))
|
||||
.andExpect(status().isNotFound());
|
||||
mockMvc.perform(post("/api/rent-payments/{id}/reversal", paymentId)
|
||||
.with(otherLandlord()).with(csrf()).contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"reason\":\"Foreign mutation\"}"))
|
||||
.andExpect(status().isNotFound());
|
||||
mockMvc.perform(get("/api/rent-payments/{id}/reversal", surplusPaymentId).with(otherLandlord()))
|
||||
.andExpect(status().isNotFound());
|
||||
mockMvc.perform(get("/api/rent-payments/{id}/refunds", surplusPaymentId).with(otherLandlord()))
|
||||
.content("{\"paymentVersion\":0,\"reason\":\"Foreign mutation\"}"))
|
||||
.andExpect(status().isNotFound());
|
||||
mockMvc.perform(post("/api/rent-payments/{id}/refunds", surplusPaymentId)
|
||||
.with(otherLandlord()).with(csrf()).contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"amount\":0.25,\"refundedAt\":\"2026-07-10\"}"))
|
||||
.andExpect(status().isNotFound());
|
||||
mockMvc.perform(put("/api/rent-payments/{paymentId}/refunds/{refundId}", surplusPaymentId, refundId)
|
||||
.with(otherLandlord()).with(csrf()).contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"amount\":0.25,\"refundedAt\":\"2026-07-10\"}"))
|
||||
.andExpect(status().isNotFound());
|
||||
mockMvc.perform(delete("/api/rent-payments/{paymentId}/refunds/{refundId}", surplusPaymentId, refundId)
|
||||
.with(otherLandlord()).with(csrf()))
|
||||
.andExpect(status().isNotFound());
|
||||
mockMvc.perform(post("/api/rent-payments/{paymentId}/refunds/{refundId}/confirm",
|
||||
surplusPaymentId, refundId).with(otherLandlord()).with(csrf()))
|
||||
.content("""
|
||||
{"operationId":"foreign-refund","paymentVersion":2,"amount":0.25,
|
||||
"refundedAt":"2026-07-10"}
|
||||
"""))
|
||||
.andExpect(status().isNotFound());
|
||||
|
||||
mockMvc.perform(get("/api/leases/{id}/pdf", leaseId).with(otherLandlord()))
|
||||
@@ -313,8 +321,10 @@ class ReplacementRentalApiJourneyIT {
|
||||
.andExpect(jsonPath("$.monthlyRent").value(900.00));
|
||||
mockMvc.perform(get("/api/rent-payments/{id}", paymentId).with(owner()))
|
||||
.andExpect(status().isOk()).andExpect(jsonPath("$.status").value("CONFIRMED"));
|
||||
mockMvc.perform(get("/api/rent-payments/{id}/refunds", surplusPaymentId).with(owner()))
|
||||
.andExpect(status().isOk()).andExpect(jsonPath("$[0].status").value("CONFIRMED"));
|
||||
mockMvc.perform(get("/api/rent-payments/{id}", surplusPaymentId).with(owner()))
|
||||
.andExpect(status().isOk()).andExpect(jsonPath("$.status").value("REVERSED"))
|
||||
.andExpect(jsonPath("$.refunds[0].id").value("refund-1"))
|
||||
.andExpect(jsonPath("$.reversal.reason").value("Correction après remboursement"));
|
||||
}
|
||||
|
||||
private String create(String path, String request) throws Exception {
|
||||
|
||||
@@ -17,7 +17,7 @@ A containerized web application comprising a Java 25 / Spring Boot 4 backend and
|
||||
5. As a Landlord (`Bailleur`), I want to record a Security Deposit (`Dépôt de garantie`) paid by the tenants at the beginning of the lease, so that I can track the balance to be returned later.
|
||||
6. As a Landlord (`Bailleur`), I want to associate one or more Guarantors (`Garants`) to a Lease (`Bail`), so that I can contact them in case the tenants default.
|
||||
7. As a Landlord (`Bailleur`), I want to generate monthly Rent Receipts (`Quittances de loyer`) for active leases, so that I can send them as PDFs to tenants who paid their rent.
|
||||
8. As a Landlord (`Bailleur`), I want to track rent payments month by month, marking them as Paid, Late, or Upcoming, so that I can monitor my rental income flow.
|
||||
8. As a Landlord (`Bailleur`), I want to record rent payments already received and see how they settle monthly rent dues, so that I can monitor my rental income flow without manually marking dues as paid.
|
||||
9. As a Landlord (`Bailleur`), I want to record and view a Furniture Inventory (`Inventaire du mobilier`) for each apartment, so that I can meet LMNP legal requirements.
|
||||
10. As a Landlord (`Bailleur`), I want to establish a Property Condition Report (`État des lieux`) at check-in or check-out, so that I can record the state of each room and furniture item.
|
||||
11. As a Landlord (`Bailleur`), I want to be guided by a Photo Sequence (`Parcours de photos`) during the property inspection, so that I don't forget to take crucial pictures (kitchen, bathroom, air conditioner) to use as proof.
|
||||
|
||||
+130
-1
@@ -275,6 +275,9 @@ test('creates a tenant with an attachment and a lease with a guarantor', async (
|
||||
}
|
||||
await route.fulfill({ contentType: 'application/json', json: leases });
|
||||
});
|
||||
await page.route('**/api/rent-dues', (route) =>
|
||||
route.fulfill({ contentType: 'application/json', json: [] }),
|
||||
);
|
||||
|
||||
await page.goto('/');
|
||||
await page.getByTestId('primary-navigation').getByRole('button', { name: 'Locataires' }).click();
|
||||
@@ -312,7 +315,7 @@ test('creates a tenant with an attachment and a lease with a guarantor', async (
|
||||
await page.getByLabel('Nom du garant', { exact: true }).fill('Durand');
|
||||
await page.getByLabel('E-mail du garant').fill('alex@example.com');
|
||||
await page.getByRole('button', { name: 'Ajouter le garant' }).click();
|
||||
await page.getByRole('button', { name: 'Enregistrer le bail' }).click();
|
||||
await page.getByRole('button', { name: 'Enregistrer le brouillon' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Studio République' })).toBeVisible();
|
||||
await expect(page.getByText('Alex Durand')).toBeVisible();
|
||||
@@ -322,3 +325,129 @@ test('creates a tenant with an attachment and a lease with a guarantor', async (
|
||||
expect(download.suggestedFilename()).toBe('bail-lease-1.pdf');
|
||||
await expectNoHorizontalOverflow(page);
|
||||
});
|
||||
|
||||
test('records a received rent payment and keeps its surplus visible', async ({ page }) => {
|
||||
const apartment = {
|
||||
id: 'apartment-1',
|
||||
name: 'Studio République',
|
||||
address: '12 rue de la République, Lyon',
|
||||
purchasePrice: 150000,
|
||||
description: '',
|
||||
};
|
||||
const lease = {
|
||||
id: 'lease-1',
|
||||
apartmentId: apartment.id,
|
||||
tenantIds: ['tenant-1'],
|
||||
guarantors: [],
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2027-06-30',
|
||||
monthlyRent: 850,
|
||||
monthlyCharges: 50,
|
||||
securityDeposit: 850,
|
||||
status: 'ACTIVE',
|
||||
};
|
||||
let rentDues = [
|
||||
{
|
||||
id: 'due-1',
|
||||
leaseId: lease.id,
|
||||
month: '2026-07',
|
||||
dueDate: '2026-07-05',
|
||||
rent: 850,
|
||||
charges: 50,
|
||||
total: 900,
|
||||
allocatedAmount: 0,
|
||||
remainingAmount: 900,
|
||||
settlement: 'UNPAID',
|
||||
timing: 'OVERDUE',
|
||||
version: 1,
|
||||
},
|
||||
];
|
||||
let payments: Record<string, unknown>[] = [];
|
||||
let submittedPayment: Record<string, unknown> | undefined;
|
||||
|
||||
await mockUser(page, true);
|
||||
await page.route('**/api/apartments', (route) =>
|
||||
route.fulfill({ contentType: 'application/json', json: [apartment] }),
|
||||
);
|
||||
await page.route('**/api/leases', (route) =>
|
||||
route.fulfill({ contentType: 'application/json', json: [lease] }),
|
||||
);
|
||||
await page.route('**/api/rent-dues', (route) =>
|
||||
route.fulfill({ contentType: 'application/json', json: rentDues }),
|
||||
);
|
||||
await page.route('**/api/rent-payments', async (route) => {
|
||||
if (route.request().method() === 'POST') {
|
||||
submittedPayment = route.request().postDataJSON() as Record<string, unknown>;
|
||||
const receivedAt = submittedPayment['receivedAt'] as string;
|
||||
payments = [
|
||||
{
|
||||
id: 'payment-1',
|
||||
leaseId: lease.id,
|
||||
receivedAt,
|
||||
amount: 950,
|
||||
status: 'CONFIRMED',
|
||||
allocations: [
|
||||
{
|
||||
id: 'allocation-1',
|
||||
rentDueId: 'due-1',
|
||||
amount: 900,
|
||||
allocatedAt: `${receivedAt}T12:00:00Z`,
|
||||
},
|
||||
],
|
||||
allocatedAmount: 900,
|
||||
refundedAmount: 0,
|
||||
unallocatedBalance: 50,
|
||||
confirmedAt: `${receivedAt}T12:00:00Z`,
|
||||
version: 1,
|
||||
refunds: [],
|
||||
reversal: null,
|
||||
},
|
||||
];
|
||||
rentDues = [
|
||||
{
|
||||
...rentDues[0],
|
||||
allocatedAmount: 900,
|
||||
remainingAmount: 0,
|
||||
settlement: 'PAID',
|
||||
version: 2,
|
||||
},
|
||||
];
|
||||
await route.fulfill({ contentType: 'application/json', json: payments[0] });
|
||||
return;
|
||||
}
|
||||
await route.fulfill({ contentType: 'application/json', json: payments });
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await page.getByTestId('primary-navigation').getByRole('button', { name: 'Loyers' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Studio République' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Enregistrer un règlement' }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog.getByRole('heading', { name: 'Enregistrer un règlement' })).toBeVisible();
|
||||
await dialog.getByLabel('Montant reçu').fill('950');
|
||||
await expect(dialog.getByText(/Surplus non affecté/)).toContainText('50,00');
|
||||
await dialog.getByRole('button', { name: 'Confirmer le règlement' }).click();
|
||||
|
||||
await expect(page.getByRole('status')).toHaveText('Le règlement a été enregistré.');
|
||||
expect(submittedPayment).toMatchObject({
|
||||
rentDueId: 'due-1',
|
||||
rentDueVersion: 1,
|
||||
amount: 950,
|
||||
});
|
||||
expect(submittedPayment?.['operationId']).toEqual(expect.any(String));
|
||||
|
||||
const dueCard = page
|
||||
.getByRole('article')
|
||||
.filter({ has: page.getByRole('heading', { name: 'Studio République' }) });
|
||||
await expect(dueCard.getByText('Réglée', { exact: true })).toBeVisible();
|
||||
await expect(dueCard.getByRole('button', { name: 'Télécharger la quittance' })).toBeVisible();
|
||||
|
||||
const paymentCard = page
|
||||
.getByRole('article')
|
||||
.filter({ has: page.getByText('Confirmé', { exact: true }) });
|
||||
await expect(paymentCard.getByText(/^950,00\s€$/)).toBeVisible();
|
||||
await expect(paymentCard.getByText(/^50,00\s€$/)).toBeVisible();
|
||||
await expect(paymentCard.getByRole('button', { name: 'Rembourser' })).toBeVisible();
|
||||
await expectNoHorizontalOverflow(page);
|
||||
});
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
.action-dialog-content,
|
||||
.dialog-fieldset {
|
||||
display: grid;
|
||||
gap: var(--space-4, 1rem);
|
||||
}
|
||||
|
||||
.action-dialog-content {
|
||||
width: min(32rem, calc(100vw - 4rem));
|
||||
max-width: 100%;
|
||||
max-height: 75vh;
|
||||
overscroll-behavior-y: contain;
|
||||
}
|
||||
|
||||
.dialog-fieldset {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.outcome-summary {
|
||||
margin: 0;
|
||||
padding: var(--space-4, 1rem);
|
||||
border: 1px solid var(--border, #dce5df);
|
||||
border-radius: var(--radius-md, 0.8125rem);
|
||||
background: var(--surface-accent, #dcefeb);
|
||||
}
|
||||
|
||||
.dialog-error {
|
||||
margin: 0;
|
||||
color: var(--danger, #b42318);
|
||||
}
|
||||
|
||||
mat-dialog-actions button {
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
|
||||
mat-spinner {
|
||||
display: inline-block;
|
||||
margin-inline-end: var(--space-2, 0.5rem);
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
<h2 mat-dialog-title>Affecter le surplus</h2>
|
||||
<form [formGroup]="form" (ngSubmit)="submit()">
|
||||
<mat-dialog-content class="action-dialog-content">
|
||||
<p>
|
||||
Solde non affecté disponible :
|
||||
<strong>{{ formatEuro(data.payment.unallocatedBalance) }}</strong>
|
||||
</p>
|
||||
<fieldset class="dialog-fieldset" [disabled]="saving()">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Échéance cible</mat-label>
|
||||
<mat-select formControlName="rentDueId" required>
|
||||
@for (item of data.eligibleRentDues; track item.rentDue.id) {
|
||||
<mat-option [value]="item.rentDue.id">
|
||||
{{ item.label }} — {{ formatEuro(item.rentDue.remainingAmount) }} restant
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Montant à affecter</mat-label>
|
||||
<input matInput type="number" step="0.01" min="0.01" formControlName="amount" required />
|
||||
<span matTextSuffix>€</span>
|
||||
@if (form.controls.amount.hasError('unavailableAmount')) {
|
||||
<mat-error>Le montant dépasse le surplus ou le solde de l’échéance.</mat-error>
|
||||
} @else if (form.controls.amount.invalid) {
|
||||
<mat-error>Le montant doit être strictement positif.</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</fieldset>
|
||||
@if (selectedRentDue(); as item) {
|
||||
<p class="outcome-summary" aria-live="polite">
|
||||
Solde de l’échéance après affectation :
|
||||
<strong>{{ formatEuro(remainingAfter()) }}</strong>
|
||||
</p>
|
||||
}
|
||||
@if (error()) {
|
||||
<p class="dialog-error" role="alert">{{ error() }}</p>
|
||||
}
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button mat-dialog-close type="button" [disabled]="saving()">Annuler</button>
|
||||
<button mat-flat-button type="submit" [disabled]="form.invalid || saving()">
|
||||
@if (saving()) {
|
||||
<mat-spinner diameter="18" aria-label="Affectation en cours" />
|
||||
}
|
||||
Confirmer l’affectation
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</form>
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
import { Component, computed, DestroyRef, inject, signal } from '@angular/core';
|
||||
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
|
||||
import {
|
||||
AbstractControl,
|
||||
FormControl,
|
||||
FormGroup,
|
||||
ReactiveFormsModule,
|
||||
ValidationErrors,
|
||||
Validators,
|
||||
} from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { finalize } from 'rxjs';
|
||||
import { RentDue } from '../../services/lease.service';
|
||||
import { httpErrorMessage } from '../../services/http-error-message';
|
||||
import { formatEuro } from '../../services/rent-payment-format';
|
||||
import { RentPayment, RentPaymentService } from '../../services/rent-payment.service';
|
||||
import { isRentPaymentDataStale, RENT_PAYMENT_DATA_STALE } from '../rent-payment-dialog-result';
|
||||
|
||||
export interface EligibleRentDue {
|
||||
rentDue: RentDue;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface RentPaymentAllocationDialogData {
|
||||
payment: RentPayment;
|
||||
eligibleRentDues: EligibleRentDue[];
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-rent-payment-allocation-dialog',
|
||||
imports: [
|
||||
ReactiveFormsModule,
|
||||
MatButtonModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatSelectModule,
|
||||
],
|
||||
templateUrl: './rent-payment-allocation-dialog.html',
|
||||
styleUrl: './rent-payment-allocation-dialog.css',
|
||||
})
|
||||
export class RentPaymentAllocationDialog {
|
||||
readonly data = inject<RentPaymentAllocationDialogData>(MAT_DIALOG_DATA);
|
||||
private readonly dialogRef = inject(MatDialogRef<RentPaymentAllocationDialog>);
|
||||
private readonly payments = inject(RentPaymentService);
|
||||
private readonly operationId = crypto.randomUUID();
|
||||
readonly saving = signal(false);
|
||||
readonly error = signal('');
|
||||
readonly formatEuro = formatEuro;
|
||||
|
||||
readonly form = new FormGroup({
|
||||
rentDueId: new FormControl(this.data.eligibleRentDues[0]?.rentDue.id ?? '', {
|
||||
nonNullable: true,
|
||||
validators: Validators.required,
|
||||
}),
|
||||
amount: new FormControl(0, {
|
||||
nonNullable: true,
|
||||
validators: [Validators.required, Validators.min(0.01)],
|
||||
}),
|
||||
});
|
||||
|
||||
private readonly rentDueId = toSignal(this.form.controls.rentDueId.valueChanges, {
|
||||
initialValue: this.form.controls.rentDueId.value,
|
||||
});
|
||||
readonly selectedRentDue = computed(() =>
|
||||
this.data.eligibleRentDues.find((item) => item.rentDue.id === this.rentDueId()),
|
||||
);
|
||||
readonly maximumAmount = computed(() =>
|
||||
Math.min(
|
||||
this.data.payment.unallocatedBalance,
|
||||
this.selectedRentDue()?.rentDue.remainingAmount ?? 0,
|
||||
),
|
||||
);
|
||||
private readonly amount = toSignal(this.form.controls.amount.valueChanges, {
|
||||
initialValue: this.form.controls.amount.value,
|
||||
});
|
||||
readonly remainingAfter = computed(() =>
|
||||
Math.max((this.selectedRentDue()?.rentDue.remainingAmount ?? 0) - (this.amount() ?? 0), 0),
|
||||
);
|
||||
|
||||
constructor() {
|
||||
const destroyRef = inject(DestroyRef);
|
||||
this.form.controls.amount.addValidators(this.withinAvailableAmount.bind(this));
|
||||
this.form.controls.rentDueId.valueChanges
|
||||
.pipe(takeUntilDestroyed(destroyRef))
|
||||
.subscribe(() => this.useMaximum());
|
||||
this.useMaximum();
|
||||
}
|
||||
|
||||
useMaximum(): void {
|
||||
this.form.controls.amount.setValue(this.maximumAmount());
|
||||
this.form.controls.amount.updateValueAndValidity();
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
this.form.markAllAsTouched();
|
||||
const due = this.selectedRentDue()?.rentDue;
|
||||
if (!due || this.form.invalid || this.saving()) return;
|
||||
this.saving.set(true);
|
||||
this.error.set('');
|
||||
this.payments
|
||||
.allocate(this.data.payment.id, {
|
||||
operationId: this.operationId,
|
||||
paymentVersion: this.data.payment.version,
|
||||
rentDueId: due.id,
|
||||
rentDueVersion: due.version,
|
||||
amount: this.form.controls.amount.value,
|
||||
})
|
||||
.pipe(finalize(() => this.saving.set(false)))
|
||||
.subscribe({
|
||||
next: (payment) => this.dialogRef.close(payment),
|
||||
error: (error) => {
|
||||
if (isRentPaymentDataStale(error)) {
|
||||
this.dialogRef.close(RENT_PAYMENT_DATA_STALE);
|
||||
return;
|
||||
}
|
||||
this.error.set(httpErrorMessage(error, 'Impossible d’affecter ce règlement.'));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private withinAvailableAmount(control: AbstractControl<number>): ValidationErrors | null {
|
||||
return control.value > this.maximumAmount() ? { unavailableAmount: true } : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { RentPayment } from '../services/rent-payment.service';
|
||||
|
||||
export const RENT_PAYMENT_DATA_STALE = 'RENT_PAYMENT_DATA_STALE';
|
||||
|
||||
export type RentPaymentDialogResult = RentPayment | typeof RENT_PAYMENT_DATA_STALE;
|
||||
|
||||
export function isRentPaymentDataStale(error: unknown): boolean {
|
||||
return typeof error === 'object' && error !== null && 'status' in error && error.status === 409;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { Type } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { RENT_PAYMENT_DATA_STALE } from './rent-payment-dialog-result';
|
||||
import { RentPaymentAllocationDialog } from './rent-payment-allocation-dialog/rent-payment-allocation-dialog';
|
||||
import { RentPaymentRefundDialog } from './rent-payment-refund-dialog/rent-payment-refund-dialog';
|
||||
import { RentPaymentReversalDialog } from './rent-payment-reversal-dialog/rent-payment-reversal-dialog';
|
||||
import { RentPaymentValidationDialog } from './rent-payment-validation-dialog/rent-payment-validation-dialog';
|
||||
import { RentDue } from '../services/lease.service';
|
||||
import { utcIsoDate } from '../services/rent-payment-format';
|
||||
import { RentPayment, RentPaymentService } from '../services/rent-payment.service';
|
||||
|
||||
describe('rent payment dialogs', () => {
|
||||
const dialogRef = { close: vi.fn() };
|
||||
const payments = {
|
||||
record: vi.fn(),
|
||||
allocate: vi.fn(),
|
||||
refund: vi.fn(),
|
||||
reverse: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('uses UTC for payment and refund date defaults', () => {
|
||||
expect(utcIsoDate(new Date('2026-07-18T23:30:00Z'))).toBe('2026-07-18');
|
||||
});
|
||||
|
||||
it('records one received amount and exposes its surplus outcome', () => {
|
||||
const result = payment({ amount: 950, unallocatedBalance: 100 });
|
||||
payments.record.mockReturnValue(of(result));
|
||||
const component = create(RentPaymentValidationDialog, {
|
||||
rentDue: due(),
|
||||
apartmentName: 'T2',
|
||||
});
|
||||
component.form.controls.amount.setValue(950);
|
||||
|
||||
component.submit();
|
||||
|
||||
expect(component.surplus()).toBe(100);
|
||||
expect(payments.record).toHaveBeenCalledWith({
|
||||
operationId: expect.any(String),
|
||||
rentDueId: 'due-1',
|
||||
rentDueVersion: 2,
|
||||
receivedAt: component.today,
|
||||
amount: 950,
|
||||
});
|
||||
expect(dialogRef.close).toHaveBeenCalledWith(result);
|
||||
});
|
||||
|
||||
it('closes stale data so the page can refresh instead of retrying an obsolete version', () => {
|
||||
payments.record.mockReturnValue(throwError(() => ({ status: 409 })));
|
||||
const component = create(RentPaymentValidationDialog, {
|
||||
rentDue: due(),
|
||||
apartmentName: 'T2',
|
||||
});
|
||||
|
||||
component.submit();
|
||||
|
||||
expect(dialogRef.close).toHaveBeenCalledWith(RENT_PAYMENT_DATA_STALE);
|
||||
});
|
||||
|
||||
it('defaults a surplus allocation to the largest safe amount', () => {
|
||||
const result = payment({ unallocatedBalance: 20, version: 4 });
|
||||
payments.allocate.mockReturnValue(of(result));
|
||||
const component = create(RentPaymentAllocationDialog, {
|
||||
payment: payment({ unallocatedBalance: 100, version: 3 }),
|
||||
eligibleRentDues: [
|
||||
{ rentDue: due({ id: 'due-2', remainingAmount: 80, version: 5 }), label: 'Août' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(component.form.controls.amount.value).toBe(80);
|
||||
component.form.controls.amount.setValue(50);
|
||||
component.submit();
|
||||
|
||||
expect(payments.allocate).toHaveBeenCalledWith('payment-1', {
|
||||
operationId: expect.any(String),
|
||||
paymentVersion: 3,
|
||||
rentDueId: 'due-2',
|
||||
rentDueVersion: 5,
|
||||
amount: 50,
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults a refund to the full surplus and prevents a future date', () => {
|
||||
const result = payment({ unallocatedBalance: 0, refundedAmount: 100, version: 4 });
|
||||
payments.refund.mockReturnValue(of(result));
|
||||
const component = create(
|
||||
RentPaymentRefundDialog,
|
||||
payment({ unallocatedBalance: 100, version: 3 }),
|
||||
);
|
||||
|
||||
expect(component.form.controls.amount.value).toBe(100);
|
||||
component.form.controls.refundedAt.setValue('9999-01-01');
|
||||
component.submit();
|
||||
expect(payments.refund).not.toHaveBeenCalled();
|
||||
|
||||
component.form.controls.refundedAt.setValue(component.today);
|
||||
component.submit();
|
||||
expect(payments.refund).toHaveBeenCalledWith(
|
||||
'payment-1',
|
||||
expect.objectContaining({ amount: 100, paymentVersion: 3 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('requires and submits a reversal reason', () => {
|
||||
const result = payment({ status: 'REVERSED', version: 4 });
|
||||
payments.reverse.mockReturnValue(of(result));
|
||||
const component = create(RentPaymentReversalDialog, payment({ version: 3 }));
|
||||
|
||||
component.submit();
|
||||
expect(payments.reverse).not.toHaveBeenCalled();
|
||||
|
||||
component.form.controls.reason.setValue('Erreur de saisie');
|
||||
component.submit();
|
||||
expect(payments.reverse).toHaveBeenCalledWith('payment-1', 3, 'Erreur de saisie');
|
||||
expect(dialogRef.close).toHaveBeenCalledWith(result);
|
||||
});
|
||||
|
||||
function create<T>(component: Type<T>, data: unknown): T {
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: MAT_DIALOG_DATA, useValue: data },
|
||||
{ provide: MatDialogRef, useValue: dialogRef },
|
||||
{ provide: RentPaymentService, useValue: payments },
|
||||
],
|
||||
});
|
||||
return TestBed.createComponent(component).componentInstance;
|
||||
}
|
||||
});
|
||||
|
||||
function due(overrides: Partial<RentDue> = {}): RentDue {
|
||||
return {
|
||||
id: 'due-1',
|
||||
leaseId: 'lease-1',
|
||||
month: '2026-07',
|
||||
dueDate: '2026-07-05',
|
||||
rent: 800,
|
||||
charges: 50,
|
||||
total: 850,
|
||||
allocatedAmount: 0,
|
||||
remainingAmount: 850,
|
||||
settlement: 'UNPAID',
|
||||
timing: 'OVERDUE',
|
||||
version: 2,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function payment(overrides: Partial<RentPayment> = {}): RentPayment {
|
||||
return {
|
||||
id: 'payment-1',
|
||||
leaseId: 'lease-1',
|
||||
receivedAt: '2026-07-06',
|
||||
amount: 850,
|
||||
status: 'CONFIRMED',
|
||||
allocations: [],
|
||||
allocatedAmount: 850,
|
||||
refundedAmount: 0,
|
||||
unallocatedBalance: 0,
|
||||
confirmedAt: '2026-07-06',
|
||||
version: 0,
|
||||
refunds: [],
|
||||
reversal: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
.action-dialog-content,
|
||||
.dialog-fieldset {
|
||||
display: grid;
|
||||
gap: var(--space-4, 1rem);
|
||||
}
|
||||
|
||||
.action-dialog-content {
|
||||
width: min(30rem, calc(100vw - 4rem));
|
||||
max-width: 100%;
|
||||
max-height: 75vh;
|
||||
overscroll-behavior-y: contain;
|
||||
}
|
||||
|
||||
.dialog-fieldset {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.dialog-error {
|
||||
margin: 0;
|
||||
color: var(--danger, #b42318);
|
||||
}
|
||||
|
||||
mat-dialog-actions button {
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
|
||||
mat-spinner {
|
||||
display: inline-block;
|
||||
margin-inline-end: var(--space-2, 0.5rem);
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
<h2 mat-dialog-title>Rembourser le surplus</h2>
|
||||
<form [formGroup]="form" (ngSubmit)="submit()">
|
||||
<mat-dialog-content class="action-dialog-content">
|
||||
<p>
|
||||
Solde non affecté disponible : <strong>{{ formatEuro(payment.unallocatedBalance) }}</strong>
|
||||
</p>
|
||||
<fieldset class="dialog-fieldset" [disabled]="saving()">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Date du remboursement</mat-label>
|
||||
<input matInput type="date" formControlName="refundedAt" [max]="today" required />
|
||||
@if (form.controls.refundedAt.hasError('futureDate')) {
|
||||
<mat-error>La date du remboursement ne peut pas être dans le futur.</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Montant remboursé</mat-label>
|
||||
<input matInput type="number" step="0.01" min="0.01" formControlName="amount" required />
|
||||
<span matTextSuffix>€</span>
|
||||
@if (form.controls.amount.hasError('max')) {
|
||||
<mat-error>Le remboursement dépasse le solde non affecté.</mat-error>
|
||||
} @else if (form.controls.amount.invalid) {
|
||||
<mat-error>Le montant doit être strictement positif.</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</fieldset>
|
||||
@if (error()) {
|
||||
<p class="dialog-error" role="alert">{{ error() }}</p>
|
||||
}
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button mat-dialog-close type="button" [disabled]="saving()">Annuler</button>
|
||||
<button mat-flat-button type="submit" [disabled]="form.invalid || saving()">
|
||||
@if (saving()) {
|
||||
<mat-spinner diameter="18" aria-label="Remboursement en cours" />
|
||||
}
|
||||
Confirmer le remboursement
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</form>
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import {
|
||||
AbstractControl,
|
||||
FormControl,
|
||||
FormGroup,
|
||||
ReactiveFormsModule,
|
||||
ValidationErrors,
|
||||
Validators,
|
||||
} from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { finalize } from 'rxjs';
|
||||
import { httpErrorMessage } from '../../services/http-error-message';
|
||||
import { formatEuro, utcIsoDate } from '../../services/rent-payment-format';
|
||||
import { RentPayment, RentPaymentService } from '../../services/rent-payment.service';
|
||||
import { isRentPaymentDataStale, RENT_PAYMENT_DATA_STALE } from '../rent-payment-dialog-result';
|
||||
|
||||
@Component({
|
||||
selector: 'app-rent-payment-refund-dialog',
|
||||
imports: [
|
||||
ReactiveFormsModule,
|
||||
MatButtonModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
],
|
||||
templateUrl: './rent-payment-refund-dialog.html',
|
||||
styleUrl: './rent-payment-refund-dialog.css',
|
||||
})
|
||||
export class RentPaymentRefundDialog {
|
||||
readonly payment = inject<RentPayment>(MAT_DIALOG_DATA);
|
||||
private readonly dialogRef = inject(MatDialogRef<RentPaymentRefundDialog>);
|
||||
private readonly payments = inject(RentPaymentService);
|
||||
private readonly operationId = crypto.randomUUID();
|
||||
readonly today = utcIsoDate();
|
||||
readonly saving = signal(false);
|
||||
readonly error = signal('');
|
||||
readonly formatEuro = formatEuro;
|
||||
|
||||
readonly form = new FormGroup({
|
||||
refundedAt: new FormControl(this.today, {
|
||||
nonNullable: true,
|
||||
validators: [Validators.required, this.notFuture.bind(this)],
|
||||
}),
|
||||
amount: new FormControl(this.payment.unallocatedBalance, {
|
||||
nonNullable: true,
|
||||
validators: [
|
||||
Validators.required,
|
||||
Validators.min(0.01),
|
||||
Validators.max(this.payment.unallocatedBalance),
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
submit(): void {
|
||||
this.form.markAllAsTouched();
|
||||
if (this.form.invalid || this.saving()) return;
|
||||
this.saving.set(true);
|
||||
this.error.set('');
|
||||
this.payments
|
||||
.refund(this.payment.id, {
|
||||
operationId: this.operationId,
|
||||
paymentVersion: this.payment.version,
|
||||
...this.form.getRawValue(),
|
||||
})
|
||||
.pipe(finalize(() => this.saving.set(false)))
|
||||
.subscribe({
|
||||
next: (payment) => this.dialogRef.close(payment),
|
||||
error: (error) => {
|
||||
if (isRentPaymentDataStale(error)) {
|
||||
this.dialogRef.close(RENT_PAYMENT_DATA_STALE);
|
||||
return;
|
||||
}
|
||||
this.error.set(httpErrorMessage(error, 'Impossible d’enregistrer ce remboursement.'));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private notFuture(control: AbstractControl<string>): ValidationErrors | null {
|
||||
return control.value > this.today ? { futureDate: true } : null;
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
.action-dialog-content,
|
||||
.dialog-fieldset {
|
||||
display: grid;
|
||||
gap: var(--space-4, 1rem);
|
||||
}
|
||||
|
||||
.action-dialog-content {
|
||||
width: min(30rem, calc(100vw - 4rem));
|
||||
max-width: 100%;
|
||||
max-height: 75vh;
|
||||
overscroll-behavior-y: contain;
|
||||
}
|
||||
|
||||
.dialog-fieldset {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.refund-warning {
|
||||
padding: var(--space-4, 1rem);
|
||||
border-radius: var(--radius-md, 0.8125rem);
|
||||
background: var(--danger-soft, #fee9e7);
|
||||
color: var(--danger, #b42318);
|
||||
}
|
||||
|
||||
.dialog-error {
|
||||
margin: 0;
|
||||
color: var(--danger, #b42318);
|
||||
}
|
||||
|
||||
.danger-action.mat-mdc-unelevated-button {
|
||||
--mdc-filled-button-container-color: var(--danger, #b42318);
|
||||
--mdc-filled-button-label-text-color: #ffffff;
|
||||
}
|
||||
|
||||
mat-dialog-actions button {
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
|
||||
mat-spinner {
|
||||
display: inline-block;
|
||||
margin-inline-end: var(--space-2, 0.5rem);
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
<h2 mat-dialog-title>Contrepasser le règlement</h2>
|
||||
<form [formGroup]="form" (ngSubmit)="submit()">
|
||||
<mat-dialog-content class="action-dialog-content">
|
||||
<p>
|
||||
Les affectations de ce règlement de <strong>{{ formatEuro(payment.amount) }}</strong> seront
|
||||
annulées. Le règlement restera visible dans l’historique.
|
||||
</p>
|
||||
@if (payment.refundedAmount > 0) {
|
||||
<p class="refund-warning" role="note">
|
||||
Les remboursements déjà confirmés, pour {{ formatEuro(payment.refundedAmount) }}, resteront
|
||||
des sorties historiques.
|
||||
</p>
|
||||
}
|
||||
<fieldset class="dialog-fieldset" [disabled]="saving()">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Motif de la contrepassation</mat-label>
|
||||
<textarea matInput rows="4" maxlength="500" formControlName="reason" required></textarea>
|
||||
@if (form.controls.reason.invalid) {
|
||||
<mat-error>Le motif est obligatoire.</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</fieldset>
|
||||
@if (error()) {
|
||||
<p class="dialog-error" role="alert">{{ error() }}</p>
|
||||
}
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button mat-dialog-close type="button" [disabled]="saving()">Annuler</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
type="submit"
|
||||
class="danger-action"
|
||||
[disabled]="form.invalid || saving()"
|
||||
>
|
||||
@if (saving()) {
|
||||
<mat-spinner diameter="18" aria-label="Contrepassation en cours" />
|
||||
}
|
||||
Contrepasser
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</form>
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { finalize } from 'rxjs';
|
||||
import { httpErrorMessage } from '../../services/http-error-message';
|
||||
import { formatEuro } from '../../services/rent-payment-format';
|
||||
import { RentPayment, RentPaymentService } from '../../services/rent-payment.service';
|
||||
import { isRentPaymentDataStale, RENT_PAYMENT_DATA_STALE } from '../rent-payment-dialog-result';
|
||||
|
||||
@Component({
|
||||
selector: 'app-rent-payment-reversal-dialog',
|
||||
imports: [
|
||||
ReactiveFormsModule,
|
||||
MatButtonModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
],
|
||||
templateUrl: './rent-payment-reversal-dialog.html',
|
||||
styleUrl: './rent-payment-reversal-dialog.css',
|
||||
})
|
||||
export class RentPaymentReversalDialog {
|
||||
readonly payment = inject<RentPayment>(MAT_DIALOG_DATA);
|
||||
private readonly dialogRef = inject(MatDialogRef<RentPaymentReversalDialog>);
|
||||
private readonly payments = inject(RentPaymentService);
|
||||
readonly saving = signal(false);
|
||||
readonly error = signal('');
|
||||
readonly formatEuro = formatEuro;
|
||||
|
||||
readonly form = new FormGroup({
|
||||
reason: new FormControl('', {
|
||||
nonNullable: true,
|
||||
validators: [Validators.required, Validators.maxLength(500)],
|
||||
}),
|
||||
});
|
||||
|
||||
submit(): void {
|
||||
this.form.markAllAsTouched();
|
||||
if (this.form.invalid || this.saving()) return;
|
||||
this.saving.set(true);
|
||||
this.error.set('');
|
||||
this.payments
|
||||
.reverse(this.payment.id, this.payment.version, this.form.controls.reason.value)
|
||||
.pipe(finalize(() => this.saving.set(false)))
|
||||
.subscribe({
|
||||
next: (payment) => this.dialogRef.close(payment),
|
||||
error: (error) => {
|
||||
if (isRentPaymentDataStale(error)) {
|
||||
this.dialogRef.close(RENT_PAYMENT_DATA_STALE);
|
||||
return;
|
||||
}
|
||||
this.error.set(httpErrorMessage(error, 'Impossible de contrepasser ce règlement.'));
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
+63
-9
@@ -1,12 +1,66 @@
|
||||
.form-dialog-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding-top: 8px;
|
||||
.payment-dialog-content,
|
||||
.dialog-fieldset,
|
||||
.outcome-summary {
|
||||
display: grid;
|
||||
gap: var(--space-4, 1rem);
|
||||
}
|
||||
|
||||
.form-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
.payment-dialog-content {
|
||||
width: min(32rem, calc(100vw - 4rem));
|
||||
max-width: 100%;
|
||||
max-height: 75vh;
|
||||
overscroll-behavior-y: contain;
|
||||
}
|
||||
|
||||
.due-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 12rem), 1fr));
|
||||
gap: var(--space-2, 0.5rem) var(--space-5, 1.25rem);
|
||||
margin: 0;
|
||||
padding: var(--space-4, 1rem);
|
||||
border-radius: var(--radius-md, 0.8125rem);
|
||||
background: var(--surface-soft, #e9efeb);
|
||||
}
|
||||
|
||||
.due-summary div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.due-summary dt {
|
||||
color: var(--text-muted, #687670);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.due-summary dd {
|
||||
margin: var(--space-1, 0.25rem) 0 0;
|
||||
font-weight: 700;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.dialog-fieldset {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.outcome-summary {
|
||||
padding: var(--space-4, 1rem);
|
||||
border: 1px solid var(--border, #dce5df);
|
||||
border-radius: var(--radius-md, 0.8125rem);
|
||||
background: var(--surface-accent, #dcefeb);
|
||||
}
|
||||
|
||||
.dialog-error {
|
||||
margin: 0;
|
||||
color: var(--danger, #b42318);
|
||||
}
|
||||
|
||||
mat-dialog-actions button {
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
|
||||
mat-spinner {
|
||||
display: inline-block;
|
||||
margin-inline-end: var(--space-2, 0.5rem);
|
||||
}
|
||||
+65
-14
@@ -1,20 +1,71 @@
|
||||
<h2 mat-dialog-title>Valider le paiement</h2>
|
||||
<h2 mat-dialog-title>Enregistrer un règlement</h2>
|
||||
<form [formGroup]="form" (ngSubmit)="submit()">
|
||||
<mat-dialog-content class="form-dialog-content">
|
||||
<p>Vérifiez ou modifiez les montants payés avant de valider l'échéance.</p>
|
||||
<div class="form-fields">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Loyer principal (€)</mat-label>
|
||||
<input matInput type="number" step="0.01" min="0" formControlName="rent" required />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Charges (€)</mat-label>
|
||||
<input matInput type="number" step="0.01" min="0" formControlName="charges" required />
|
||||
</mat-form-field>
|
||||
<mat-dialog-content class="payment-dialog-content">
|
||||
<p>
|
||||
Règlement reçu pour <strong>{{ data.apartmentName }}</strong
|
||||
>. Vérifiez la date et le montant avant de confirmer.
|
||||
</p>
|
||||
|
||||
<dl class="due-summary">
|
||||
<div>
|
||||
<dt>Loyer</dt>
|
||||
<dd>{{ formatEuro(data.rentDue.rent) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Charges</dt>
|
||||
<dd>{{ formatEuro(data.rentDue.charges) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Déjà affecté</dt>
|
||||
<dd>{{ formatEuro(data.rentDue.allocatedAmount) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Restant à régler</dt>
|
||||
<dd>{{ formatEuro(data.rentDue.remainingAmount) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<fieldset class="dialog-fieldset" [disabled]="saving()">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Date de réception</mat-label>
|
||||
<input matInput type="date" formControlName="receivedAt" [max]="today" required />
|
||||
@if (form.controls.receivedAt.hasError('futureDate')) {
|
||||
<mat-error>La date de réception ne peut pas être dans le futur.</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Montant reçu</mat-label>
|
||||
<input matInput type="number" step="0.01" min="0.01" formControlName="amount" required />
|
||||
<span matTextSuffix>€</span>
|
||||
@if (form.controls.amount.invalid) {
|
||||
<mat-error>Le montant doit être strictement positif.</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</fieldset>
|
||||
|
||||
<div class="outcome-summary" aria-live="polite">
|
||||
@if (surplus() > 0) {
|
||||
<strong>L’échéance sera réglée.</strong>
|
||||
<span>Surplus non affecté : {{ formatEuro(surplus()) }}</span>
|
||||
} @else if (remainingAfter() > 0) {
|
||||
<strong>L’échéance restera partiellement réglée.</strong>
|
||||
<span>Solde après règlement : {{ formatEuro(remainingAfter()) }}</span>
|
||||
} @else {
|
||||
<strong>L’échéance sera entièrement réglée.</strong>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (error()) {
|
||||
<p class="dialog-error" role="alert">{{ error() }}</p>
|
||||
}
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button mat-dialog-close type="button">Annuler</button>
|
||||
<button mat-flat-button type="submit" [disabled]="form.invalid">Valider le paiement</button>
|
||||
<button mat-button mat-dialog-close type="button" [disabled]="saving()">Annuler</button>
|
||||
<button mat-flat-button type="submit" [disabled]="form.invalid || saving()">
|
||||
@if (saving()) {
|
||||
<mat-spinner diameter="18" aria-label="Enregistrement en cours" />
|
||||
}
|
||||
Confirmer le règlement
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</form>
|
||||
+69
-11
@@ -1,13 +1,28 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { Component, computed, inject, signal } from '@angular/core';
|
||||
import {
|
||||
AbstractControl,
|
||||
FormControl,
|
||||
FormGroup,
|
||||
ReactiveFormsModule,
|
||||
ValidationErrors,
|
||||
Validators,
|
||||
} from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { finalize } from 'rxjs';
|
||||
import { RentDue } from '../../services/lease.service';
|
||||
import { httpErrorMessage } from '../../services/http-error-message';
|
||||
import { formatEuro, utcIsoDate } from '../../services/rent-payment-format';
|
||||
import { RentPaymentService } from '../../services/rent-payment.service';
|
||||
import { isRentPaymentDataStale, RENT_PAYMENT_DATA_STALE } from '../rent-payment-dialog-result';
|
||||
|
||||
export interface RentPaymentValidationDialogData {
|
||||
rent: number;
|
||||
charges: number;
|
||||
rentDue: RentDue;
|
||||
apartmentName: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -18,28 +33,71 @@ export interface RentPaymentValidationDialogData {
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
],
|
||||
templateUrl: './rent-payment-validation-dialog.html',
|
||||
styleUrl: './rent-payment-validation-dialog.css',
|
||||
})
|
||||
export class RentPaymentValidationDialog {
|
||||
readonly data = inject<RentPaymentValidationDialogData>(MAT_DIALOG_DATA);
|
||||
readonly dialogRef = inject(MatDialogRef<RentPaymentValidationDialog>);
|
||||
private readonly dialogRef = inject(MatDialogRef<RentPaymentValidationDialog>);
|
||||
private readonly payments = inject(RentPaymentService);
|
||||
private readonly operationId = crypto.randomUUID();
|
||||
readonly today = utcIsoDate();
|
||||
readonly saving = signal(false);
|
||||
readonly error = signal('');
|
||||
readonly formatEuro = formatEuro;
|
||||
|
||||
readonly form = new FormGroup({
|
||||
rent: new FormControl(this.data.rent, {
|
||||
receivedAt: new FormControl(this.today, {
|
||||
nonNullable: true,
|
||||
validators: [Validators.required, Validators.min(0)],
|
||||
validators: [Validators.required, this.notFuture.bind(this)],
|
||||
}),
|
||||
charges: new FormControl(this.data.charges, {
|
||||
amount: new FormControl(this.data.rentDue.remainingAmount, {
|
||||
nonNullable: true,
|
||||
validators: [Validators.required, Validators.min(0)],
|
||||
validators: [Validators.required, Validators.min(0.01)],
|
||||
}),
|
||||
});
|
||||
|
||||
private readonly amount = toSignal(this.form.controls.amount.valueChanges, {
|
||||
initialValue: this.form.controls.amount.value,
|
||||
});
|
||||
readonly allocatedAmount = computed(() =>
|
||||
Math.min(Math.max(this.amount() ?? 0, 0), this.data.rentDue.remainingAmount),
|
||||
);
|
||||
readonly remainingAfter = computed(() =>
|
||||
Math.max(this.data.rentDue.remainingAmount - this.allocatedAmount(), 0),
|
||||
);
|
||||
readonly surplus = computed(() =>
|
||||
Math.max((this.amount() ?? 0) - this.data.rentDue.remainingAmount, 0),
|
||||
);
|
||||
|
||||
submit(): void {
|
||||
if (this.form.valid) {
|
||||
this.dialogRef.close(this.form.getRawValue());
|
||||
this.form.markAllAsTouched();
|
||||
if (this.form.invalid || this.saving()) return;
|
||||
this.saving.set(true);
|
||||
this.error.set('');
|
||||
this.payments
|
||||
.record({
|
||||
operationId: this.operationId,
|
||||
rentDueId: this.data.rentDue.id,
|
||||
rentDueVersion: this.data.rentDue.version,
|
||||
...this.form.getRawValue(),
|
||||
})
|
||||
.pipe(finalize(() => this.saving.set(false)))
|
||||
.subscribe({
|
||||
next: (payment) => this.dialogRef.close(payment),
|
||||
error: (error) => {
|
||||
if (isRentPaymentDataStale(error)) {
|
||||
this.dialogRef.close(RENT_PAYMENT_DATA_STALE);
|
||||
return;
|
||||
}
|
||||
this.error.set(httpErrorMessage(error, 'Impossible d’enregistrer ce règlement.'));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private notFuture(control: AbstractControl<string>): ValidationErrors | null {
|
||||
return control.value > this.today ? { futureDate: true } : null;
|
||||
}
|
||||
}
|
||||
@@ -1,88 +1,188 @@
|
||||
.rent-payment-grid {
|
||||
.rent-payment-grid,
|
||||
.payment-history-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 20rem), 1fr));
|
||||
gap: var(--space-4, 1rem);
|
||||
}
|
||||
|
||||
.schedule-actions {
|
||||
margin-bottom: var(--space-4, 1rem);
|
||||
.schedule-actions,
|
||||
.payment-history {
|
||||
margin-block-start: var(--space-6, 1.5rem);
|
||||
}
|
||||
|
||||
.rent-payment {
|
||||
.schedule-actions {
|
||||
margin-block-end: var(--space-4, 1rem);
|
||||
}
|
||||
|
||||
.payment-history {
|
||||
padding-block-start: var(--space-8, 2rem);
|
||||
border-block-start: 1px solid var(--border, #dce5df);
|
||||
}
|
||||
|
||||
.operation-feedback {
|
||||
padding: var(--space-4, 1rem);
|
||||
border: 1px solid;
|
||||
border-radius: var(--radius-md, 0.8125rem);
|
||||
}
|
||||
|
||||
.success-message {
|
||||
border-color: var(--accent, #0f766e);
|
||||
background: var(--surface-accent, #dcefeb);
|
||||
color: var(--accent-strong, #0a5f59);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
border-color: var(--danger, #b42318);
|
||||
background: var(--danger-soft, #fee9e7);
|
||||
color: var(--danger, #b42318);
|
||||
}
|
||||
|
||||
.rent-payment,
|
||||
.payment-history-card {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
padding: var(--space-5, 1.25rem);
|
||||
border: 1px solid var(--border, #dce5df);
|
||||
border-radius: var(--radius-xl, 1.375rem);
|
||||
background: var(--surface, #ffffff);
|
||||
}
|
||||
|
||||
.payment-history-card.reversed {
|
||||
background: var(--surface-soft, #e9efeb);
|
||||
}
|
||||
|
||||
.payment-topline,
|
||||
.rent-payment dl > div {
|
||||
.rent-payment dl > div,
|
||||
.payment-history-card dl > div,
|
||||
.movement-list li {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3, 0.75rem);
|
||||
}
|
||||
|
||||
.payment-topline {
|
||||
margin-bottom: var(--space-4, 1rem);
|
||||
margin-block-end: var(--space-4, 1rem);
|
||||
}
|
||||
|
||||
.rent-payment h2 {
|
||||
margin-bottom: var(--space-1, 0.25rem);
|
||||
margin-block-end: var(--space-1, 0.25rem);
|
||||
}
|
||||
|
||||
.payment-month,
|
||||
.rent-payment dt {
|
||||
.rent-payment dt,
|
||||
.payment-history-card dt,
|
||||
.payment-history-card > p {
|
||||
color: var(--text-muted, #687670);
|
||||
}
|
||||
|
||||
.rent-payment dl {
|
||||
.rent-payment dl,
|
||||
.payment-history-card dl {
|
||||
margin: var(--space-5, 1.25rem) 0;
|
||||
}
|
||||
|
||||
.rent-payment dl > div {
|
||||
padding: var(--space-2, 0.5rem) 0;
|
||||
border-bottom: 1px solid var(--border, #dce5df);
|
||||
.rent-payment dl > div,
|
||||
.payment-history-card dl > div {
|
||||
padding-block: var(--space-2, 0.5rem);
|
||||
border-block-end: 1px solid var(--border, #dce5df);
|
||||
}
|
||||
|
||||
.rent-payment dd {
|
||||
.rent-payment dd,
|
||||
.payment-history-card dd {
|
||||
margin: 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rent-due-labels {
|
||||
.rent-due-labels,
|
||||
.card-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2, 0.5rem);
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
margin-block-start: auto;
|
||||
padding-block-start: var(--space-4, 1rem);
|
||||
}
|
||||
|
||||
.card-actions button {
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
|
||||
.settlement-status,
|
||||
.timing-status {
|
||||
.timing-status,
|
||||
.payment-status {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.settlement-status.paid {
|
||||
.settlement-status.paid,
|
||||
.payment-status.confirmed {
|
||||
color: var(--accent, #0f766e);
|
||||
}
|
||||
|
||||
.settlement-status.unpaid,
|
||||
.settlement-status.partially_paid {
|
||||
color: var(--warning, #9a6700);
|
||||
}
|
||||
.timing-status.overdue {
|
||||
|
||||
.timing-status.overdue,
|
||||
.payment-status.reversed,
|
||||
.danger-link.mat-mdc-button {
|
||||
color: var(--danger, #b42318);
|
||||
}
|
||||
|
||||
.timing-status.upcoming {
|
||||
color: var(--text-muted, #687670);
|
||||
}
|
||||
|
||||
@media (min-width: 700px) {
|
||||
.rent-payment-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.movement-list {
|
||||
margin-block-start: var(--space-4, 1rem);
|
||||
}
|
||||
|
||||
@media (min-width: 1100px) {
|
||||
.rent-payment-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.movement-list h3 {
|
||||
margin-block-end: var(--space-2, 0.5rem);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.movement-list ul {
|
||||
display: grid;
|
||||
gap: var(--space-2, 0.5rem);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.movement-list li {
|
||||
min-width: 0;
|
||||
padding: var(--space-2, 0.5rem);
|
||||
border-radius: var(--radius-sm, 0.75rem);
|
||||
background: var(--surface-soft, #e9efeb);
|
||||
}
|
||||
|
||||
.movement-list li span {
|
||||
min-width: 0;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.reversal-details {
|
||||
display: grid;
|
||||
gap: var(--space-2, 0.5rem);
|
||||
margin-block-start: var(--space-4, 1rem);
|
||||
padding: var(--space-4, 1rem);
|
||||
border-radius: var(--radius-md, 0.8125rem);
|
||||
background: var(--danger-soft, #fee9e7);
|
||||
color: var(--danger, #b42318);
|
||||
}
|
||||
|
||||
.card-error {
|
||||
margin: var(--space-3, 0.75rem) 0 0;
|
||||
color: var(--danger, #b42318);
|
||||
}
|
||||
|
||||
mat-spinner {
|
||||
display: inline-block;
|
||||
margin-inline-end: var(--space-2, 0.5rem);
|
||||
}
|
||||
@@ -3,18 +3,29 @@
|
||||
<div>
|
||||
<p class="eyebrow">Location</p>
|
||||
<h1>Mes loyers</h1>
|
||||
<p>Suivez les échéances de vos baux actifs.</p>
|
||||
<p>Suivez les échéances et enregistrez les règlements déjà reçus.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="content-region" [attr.aria-busy]="contentLoading()">
|
||||
@if (!contentReady() && loadError()) {
|
||||
@if (feedback(); as feedback) {
|
||||
<p
|
||||
class="operation-feedback"
|
||||
[class.success-message]="feedback.kind === 'success'"
|
||||
[class.error-message]="feedback.kind === 'error'"
|
||||
[attr.role]="feedback.kind === 'error' ? 'alert' : 'status'"
|
||||
>
|
||||
{{ feedback.message }}
|
||||
</p>
|
||||
}
|
||||
|
||||
<div class="content-region" [attr.aria-busy]="scheduleLoading()">
|
||||
@if (!scheduleReady() && scheduleError()) {
|
||||
<article class="load-error" role="alert">
|
||||
<h2>Chargement impossible</h2>
|
||||
<p>{{ loadError() }}</p>
|
||||
<p>{{ scheduleError() }}</p>
|
||||
<button mat-stroked-button type="button" (click)="load()">Réessayer</button>
|
||||
</article>
|
||||
} @else if (!contentReady()) {
|
||||
} @else if (!scheduleReady()) {
|
||||
<div class="rent-payment-grid" aria-label="Chargement des loyers">
|
||||
@for (placeholder of [1, 2, 3]; track placeholder) {
|
||||
<article class="skeleton-card" aria-hidden="true">
|
||||
@@ -25,12 +36,12 @@
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
@if (contentLoading()) {
|
||||
@if (scheduleLoading()) {
|
||||
<mat-progress-bar mode="indeterminate" aria-label="Actualisation des loyers" />
|
||||
}
|
||||
@if (loadError()) {
|
||||
@if (scheduleError()) {
|
||||
<div class="refresh-error" role="alert">
|
||||
<p>{{ loadError() }}</p>
|
||||
<p>{{ scheduleError() }}</p>
|
||||
<button mat-button type="button" (click)="load()">Réessayer</button>
|
||||
</div>
|
||||
}
|
||||
@@ -66,32 +77,196 @@
|
||||
{{ timingLabel(rentDue.timing) }}
|
||||
</span>
|
||||
</div>
|
||||
<strong>{{ rentDue.total.toLocaleString('fr-FR') }} €</strong>
|
||||
<strong>{{ formatEuro(rentDue.total) }}</strong>
|
||||
</div>
|
||||
<h2>{{ apartmentName(rentDue) }}</h2>
|
||||
<p class="payment-month">{{ monthLabel(rentDue.month) }}</p>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Loyer</dt>
|
||||
<dd>{{ rentDue.rent.toLocaleString('fr-FR') }} €</dd>
|
||||
<dd>{{ formatEuro(rentDue.rent) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Charges</dt>
|
||||
<dd>{{ rentDue.charges.toLocaleString('fr-FR') }} €</dd>
|
||||
<dd>{{ formatEuro(rentDue.charges) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Déjà réglé</dt>
|
||||
<dd>{{ formatEuro(rentDue.allocatedAmount) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Restant à régler</dt>
|
||||
<dd>{{ rentDue.remainingAmount.toLocaleString('fr-FR') }} €</dd>
|
||||
<dd>{{ formatEuro(rentDue.remainingAmount) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Échéance</dt>
|
||||
<dd>{{ rentDue.dueDate | date: 'dd MMMM yyyy' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="card-actions">
|
||||
@if (rentDue.settlement !== 'PAID') {
|
||||
<button mat-flat-button type="button" (click)="recordPayment(rentDue)">
|
||||
Enregistrer un règlement
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[disabled]="workspace.rentReceiptDownloading(rentDue.id)"
|
||||
(click)="workspace.downloadRentReceipt(rentDue)"
|
||||
>
|
||||
@if (workspace.rentReceiptDownloading(rentDue.id)) {
|
||||
<mat-spinner diameter="18" aria-label="Téléchargement en cours" />
|
||||
}
|
||||
Télécharger la quittance
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (workspace.rentReceiptDownloadError(rentDue.id)) {
|
||||
<p class="card-error" role="alert">
|
||||
{{ workspace.rentReceiptDownloadError(rentDue.id) }}
|
||||
</p>
|
||||
}
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<section class="payment-history" aria-labelledby="payment-history-title">
|
||||
<header>
|
||||
<p class="eyebrow">Encaissements</p>
|
||||
<h2 id="payment-history-title">Règlements enregistrés</h2>
|
||||
</header>
|
||||
<div [attr.aria-busy]="workspace.rentPaymentLoadState().loading">
|
||||
@if (!workspace.rentPaymentLoadState().loaded && workspace.rentPaymentLoadState().error) {
|
||||
<article class="load-error" role="alert">
|
||||
<p>{{ workspace.rentPaymentLoadState().error }}</p>
|
||||
<button mat-stroked-button type="button" (click)="workspace.loadRentPayments()">
|
||||
Réessayer
|
||||
</button>
|
||||
</article>
|
||||
} @else if (!workspace.rentPaymentLoadState().loaded) {
|
||||
<div class="payment-history-grid" aria-label="Chargement des règlements">
|
||||
@for (placeholder of [1, 2]; track placeholder) {
|
||||
<article class="skeleton-card" aria-hidden="true"></article>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
@if (workspace.rentPaymentLoadState().loading) {
|
||||
<mat-progress-bar mode="indeterminate" aria-label="Actualisation des règlements" />
|
||||
}
|
||||
@if (workspace.rentPaymentLoadState().error) {
|
||||
<p class="refresh-error" role="alert">{{ workspace.rentPaymentLoadState().error }}</p>
|
||||
}
|
||||
@if (displayedPayments().length === 0) {
|
||||
<article class="empty-state"><p>Aucun règlement enregistré.</p></article>
|
||||
} @else {
|
||||
<div class="payment-history-grid">
|
||||
@for (payment of displayedPayments(); track payment.id) {
|
||||
<article
|
||||
class="payment-history-card"
|
||||
[class.reversed]="payment.status === 'REVERSED'"
|
||||
>
|
||||
<div class="payment-topline">
|
||||
<span class="payment-status" [class]="payment.status.toLowerCase()">
|
||||
{{ paymentStatusLabel(payment.status) }}
|
||||
</span>
|
||||
<strong>{{ formatEuro(payment.amount) }}</strong>
|
||||
</div>
|
||||
<p>Reçu le {{ payment.receivedAt | date: 'dd MMMM yyyy' }}</p>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Affecté</dt>
|
||||
<dd>{{ formatEuro(payment.allocatedAmount) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Remboursé</dt>
|
||||
<dd>{{ formatEuro(payment.refundedAmount) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Solde non affecté</dt>
|
||||
<dd>{{ formatEuro(payment.unallocatedBalance) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
@if (payment.allocations.length > 0) {
|
||||
<div class="movement-list">
|
||||
<h3>Affectations</h3>
|
||||
<ul>
|
||||
@for (allocation of payment.allocations; track allocation.id) {
|
||||
<li>
|
||||
<span>{{ allocationLabel(allocation) }}</span
|
||||
><strong>{{ formatEuro(allocation.amount) }}</strong>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
@if (payment.refunds.length > 0) {
|
||||
<div class="movement-list">
|
||||
<h3>Remboursements</h3>
|
||||
<ul>
|
||||
@for (refund of payment.refunds; track refund.id) {
|
||||
<li>
|
||||
<span>{{ refund.refundedAt | date: 'dd MMMM yyyy' }}</span
|
||||
><strong>{{ formatEuro(refund.amount) }}</strong>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
@if (payment.reversal; as reversal) {
|
||||
<div class="reversal-details">
|
||||
<strong>Contrepassé le {{ reversal.reversedAt | date: 'dd MMMM yyyy' }}</strong>
|
||||
<span>{{ reversal.reason }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (payment.status === 'CONFIRMED') {
|
||||
<div class="card-actions">
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[disabled]="workspace.paymentReceiptDownloading(payment.id)"
|
||||
(click)="workspace.downloadPaymentReceipt(payment)"
|
||||
>
|
||||
@if (workspace.paymentReceiptDownloading(payment.id)) {
|
||||
<mat-spinner diameter="18" aria-label="Téléchargement en cours" />
|
||||
}
|
||||
Télécharger le reçu
|
||||
</button>
|
||||
@if (canAllocate(payment)) {
|
||||
<button mat-stroked-button type="button" (click)="allocateSurplus(payment)">
|
||||
Affecter
|
||||
</button>
|
||||
}
|
||||
@if (payment.unallocatedBalance > 0) {
|
||||
<button mat-stroked-button type="button" (click)="refundSurplus(payment)">
|
||||
Rembourser
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
class="danger-link"
|
||||
(click)="reversePayment(payment)"
|
||||
>
|
||||
Contrepasser
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (workspace.paymentReceiptDownloadError(payment.id)) {
|
||||
<p class="card-error" role="alert">
|
||||
{{ workspace.paymentReceiptDownloadError(payment.id) }}
|
||||
</p>
|
||||
}
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
@@ -1,103 +1,90 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { of } from 'rxjs';
|
||||
import { RentPaymentAllocationDialog } from '../rent-payment-allocation-dialog/rent-payment-allocation-dialog';
|
||||
import { RentPaymentRefundDialog } from '../rent-payment-refund-dialog/rent-payment-refund-dialog';
|
||||
import { RentPaymentReversalDialog } from '../rent-payment-reversal-dialog/rent-payment-reversal-dialog';
|
||||
import { RentPaymentValidationDialog } from '../rent-payment-validation-dialog/rent-payment-validation-dialog';
|
||||
import { RentPaymentDialogResult, RENT_PAYMENT_DATA_STALE } from '../rent-payment-dialog-result';
|
||||
import { RentDue } from '../../services/lease.service';
|
||||
import { RentPayment } from '../../services/rent-payment.service';
|
||||
import { WorkspaceService } from '../../services/workspace.service';
|
||||
import { RentPaymentsPage } from './rent-payments-page';
|
||||
|
||||
describe('RentPaymentsPage', () => {
|
||||
it('loads and renders RentDue records instead of the legacy combined model', () => {
|
||||
const workspace = {
|
||||
...createWorkspace([rentDue('due-1', '2027-08', 'UPCOMING')]),
|
||||
rentPayments: signal([payment('legacy-payment', '2026-07', 'LATE')]),
|
||||
loadRentPayments: vi.fn(),
|
||||
};
|
||||
const fixture = createFixture(workspace);
|
||||
it('loads dues and payment history and offers recording on every non-paid due', () => {
|
||||
const { fixture, workspace } = createFixture([
|
||||
rentDue('due-1', '2027-08', 'UPCOMING'),
|
||||
rentDue('due-2', '2027-09', 'UPCOMING', 'PAID', { allocatedAmount: 850, remainingAmount: 0 }),
|
||||
]);
|
||||
|
||||
expect(workspace.loadRentDues).toHaveBeenCalledOnce();
|
||||
expect(workspace.loadRentPayments).not.toHaveBeenCalled();
|
||||
expect(paymentMonths(fixture)).toEqual(['août 2027']);
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Marquer comme payé');
|
||||
expect(workspace.loadRentPayments).toHaveBeenCalledOnce();
|
||||
expect(button(fixture, 'Enregistrer un règlement')).not.toBeNull();
|
||||
expect(fixture.nativeElement.textContent).toContain('Aucun règlement enregistré');
|
||||
});
|
||||
|
||||
it('renders amounts, settlement and timing as independent RentDue facts', () => {
|
||||
const fixture = createFixture(
|
||||
createWorkspace([
|
||||
it('renders amounts, settlement, and timing as independent RentDue facts', () => {
|
||||
const { fixture } = createFixture([
|
||||
rentDue('due-1', '2026-07', 'OVERDUE', 'PARTIALLY_PAID', {
|
||||
allocatedAmount: 500,
|
||||
remainingAmount: 350,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
]);
|
||||
const card = fixture.nativeElement.querySelector('.rent-payment') as HTMLElement;
|
||||
|
||||
expect(card.textContent).toContain('T2');
|
||||
expect(card.textContent).toContain('800 €');
|
||||
expect(card.textContent).toContain('50 €');
|
||||
expect(card.textContent).toContain('850 €');
|
||||
expect(card.textContent).toContain('350 €');
|
||||
expect(card.textContent).toContain('800,00 €');
|
||||
expect(card.textContent).toContain('500,00 €');
|
||||
expect(card.textContent).toContain('350,00 €');
|
||||
expect(card.querySelector('.settlement-status.partially_paid')?.textContent).toContain(
|
||||
'Partiellement réglée',
|
||||
);
|
||||
expect(card.querySelector('.timing-status.overdue')?.textContent).toContain('En retard');
|
||||
expect(card.querySelector('.card-actions')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows overdue dues through the next upcoming due', () => {
|
||||
const workspace = createWorkspace([
|
||||
const { fixture } = createFixture([
|
||||
rentDue('overdue-january', '2026-01', 'OVERDUE'),
|
||||
rentDue('overdue-february', '2026-02', 'OVERDUE'),
|
||||
rentDue('upcoming-march', '2026-03', 'UPCOMING'),
|
||||
rentDue('upcoming-april', '2026-04', 'UPCOMING'),
|
||||
]);
|
||||
const fixture = createFixture(workspace);
|
||||
|
||||
expect(paymentMonths(fixture)).toEqual(['janvier 2026', 'février 2026', 'mars 2026']);
|
||||
});
|
||||
|
||||
it('starts the current schedule at the older of the last paid and first unpaid due', () => {
|
||||
const workspace = createWorkspace([
|
||||
rentDue('paid-january', '2026-01', 'OVERDUE', 'PAID'),
|
||||
const { fixture } = createFixture([
|
||||
rentDue('paid-january', '2026-01', 'OVERDUE', 'PAID', {
|
||||
allocatedAmount: 850,
|
||||
remainingAmount: 0,
|
||||
}),
|
||||
rentDue('unpaid-february', '2026-02', 'OVERDUE'),
|
||||
rentDue('paid-march', '2026-03', 'OVERDUE', 'PAID'),
|
||||
rentDue('paid-march', '2026-03', 'OVERDUE', 'PAID', {
|
||||
allocatedAmount: 850,
|
||||
remainingAmount: 0,
|
||||
}),
|
||||
rentDue('unpaid-april', '2026-04', 'OVERDUE'),
|
||||
rentDue('upcoming-may', '2026-05', 'UPCOMING'),
|
||||
rentDue('upcoming-june', '2026-06', 'UPCOMING'),
|
||||
]);
|
||||
const fixture = createFixture(workspace);
|
||||
|
||||
expect(paymentMonths(fixture)).toEqual([
|
||||
'février 2026',
|
||||
'mars 2026',
|
||||
'avril 2026',
|
||||
'mai 2026',
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows only the next due when none are overdue', () => {
|
||||
const workspace = createWorkspace([
|
||||
rentDue('upcoming-march', '2026-03', 'UPCOMING'),
|
||||
rentDue('upcoming-april', '2026-04', 'UPCOMING'),
|
||||
]);
|
||||
const fixture = createFixture(workspace);
|
||||
|
||||
expect(paymentMonths(fixture)).toEqual(['mars 2026']);
|
||||
expect(paymentMonths(fixture)).toEqual(['février 2026', 'mars 2026', 'avril 2026', 'mai 2026']);
|
||||
});
|
||||
|
||||
it('reveals and hides the full chronological schedule', () => {
|
||||
const workspace = createWorkspace([
|
||||
const { fixture } = createFixture([
|
||||
rentDue('upcoming-april', '2026-04', 'UPCOMING'),
|
||||
rentDue('overdue-january', '2026-01', 'OVERDUE'),
|
||||
rentDue('upcoming-march', '2026-03', 'UPCOMING'),
|
||||
rentDue('overdue-february', '2026-02', 'OVERDUE'),
|
||||
]);
|
||||
const fixture = createFixture(workspace);
|
||||
|
||||
const toggle = fixture.nativeElement.querySelector('[aria-pressed]') as HTMLButtonElement;
|
||||
expect(toggle.textContent).toContain('Afficher toutes les échéances');
|
||||
expect(paymentMonths(fixture)).toEqual(['janvier 2026', 'février 2026', 'mars 2026']);
|
||||
|
||||
toggle.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(toggle.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(paymentMonths(fixture)).toEqual([
|
||||
'janvier 2026',
|
||||
@@ -105,52 +92,134 @@ describe('RentPaymentsPage', () => {
|
||||
'mars 2026',
|
||||
'avril 2026',
|
||||
]);
|
||||
|
||||
toggle.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(toggle.getAttribute('aria-pressed')).toBe('false');
|
||||
expect(paymentMonths(fixture)).toEqual(['janvier 2026', 'février 2026', 'mars 2026']);
|
||||
});
|
||||
|
||||
it('shows loading and error states before the schedule is ready', () => {
|
||||
const loadingFixture = createFixture(
|
||||
createWorkspace([], { loaded: false, loading: true, error: '' }),
|
||||
);
|
||||
expect(loadingFixture.nativeElement.querySelector('.skeleton-card')).not.toBeNull();
|
||||
it('keeps the payment modal result and refreshes the ledger after success', () => {
|
||||
const recorded = payment({ unallocatedBalance: 100, amount: 950 });
|
||||
const { fixture, workspace, dialog } = createFixture([rentDue('due-1', '2026-07', 'UPCOMING')]);
|
||||
dialog.open.mockReturnValueOnce({ afterClosed: () => of(recorded) });
|
||||
|
||||
const errorFixture = createFixture(
|
||||
createWorkspace([], { loaded: false, loading: false, error: 'Erreur de chargement.' }),
|
||||
button(fixture, 'Enregistrer un règlement')?.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(dialog.open).toHaveBeenCalledWith(
|
||||
RentPaymentValidationDialog,
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ apartmentName: 'T2' }),
|
||||
}),
|
||||
);
|
||||
expect(errorFixture.nativeElement.textContent).toContain('Erreur de chargement.');
|
||||
expect(workspace.acceptRentPayment).toHaveBeenCalledWith(recorded);
|
||||
expect(fixture.nativeElement.textContent).toContain('Le règlement a été enregistré.');
|
||||
});
|
||||
|
||||
it('refreshes stale payment data and announces the rejected operation', () => {
|
||||
const { fixture, workspace, dialog } = createFixture([rentDue('due-1', '2026-07', 'UPCOMING')]);
|
||||
dialog.open.mockReturnValueOnce({ afterClosed: () => of(RENT_PAYMENT_DATA_STALE) });
|
||||
|
||||
button(fixture, 'Enregistrer un règlement')?.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(workspace.loadRentDues).toHaveBeenCalledTimes(2);
|
||||
expect(workspace.loadRentPayments).toHaveBeenCalledTimes(2);
|
||||
expect(workspace.acceptRentPayment).not.toHaveBeenCalled();
|
||||
expect(fixture.nativeElement.querySelector('[role="alert"]')?.textContent).toContain(
|
||||
'Les données ont changé',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows confirmed payment history and opens each lifecycle action', () => {
|
||||
const recorded = payment({ unallocatedBalance: 100, amount: 950 });
|
||||
const { fixture, dialog } = createFixture(
|
||||
[
|
||||
rentDue('due-1', '2026-07', 'OVERDUE', 'PAID', {
|
||||
allocatedAmount: 850,
|
||||
remainingAmount: 0,
|
||||
}),
|
||||
rentDue('due-2', '2026-08', 'UPCOMING'),
|
||||
],
|
||||
[recorded],
|
||||
);
|
||||
dialog.open.mockReturnValue({ afterClosed: () => of(undefined) });
|
||||
|
||||
button(fixture, 'Affecter')?.click();
|
||||
button(fixture, 'Rembourser')?.click();
|
||||
button(fixture, 'Contrepasser')?.click();
|
||||
|
||||
expect(dialog.open.mock.calls.map((call) => call[0])).toEqual([
|
||||
RentPaymentAllocationDialog,
|
||||
RentPaymentRefundDialog,
|
||||
RentPaymentReversalDialog,
|
||||
]);
|
||||
expect(fixture.nativeElement.textContent).toContain('100,00 €');
|
||||
expect(fixture.nativeElement.textContent).toContain('Télécharger le reçu');
|
||||
});
|
||||
|
||||
it('keeps reversal metadata visible without mutation actions', () => {
|
||||
const reversed = payment({
|
||||
status: 'REVERSED',
|
||||
reversal: {
|
||||
id: 'reversal-1',
|
||||
reversedAt: '2026-07-08',
|
||||
reason: 'Erreur de saisie',
|
||||
reversedAllocations: [],
|
||||
},
|
||||
});
|
||||
const { fixture } = createFixture([], [reversed]);
|
||||
const history = fixture.nativeElement.querySelector('.payment-history-card') as HTMLElement;
|
||||
|
||||
expect(history.textContent).toContain('Contrepassé');
|
||||
expect(history.textContent).toContain('Erreur de saisie');
|
||||
expect(history.textContent).not.toContain('Télécharger le reçu');
|
||||
});
|
||||
|
||||
it('shows independent loading and error states', () => {
|
||||
const { fixture } = createFixture([], [], { loaded: false, loading: false, error: 'Erreur.' });
|
||||
expect(fixture.nativeElement.textContent).toContain('Erreur.');
|
||||
expect(fixture.nativeElement.querySelector('[role="alert"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
function createFixture(workspace: ReturnType<typeof createWorkspace>) {
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [{ provide: WorkspaceService, useValue: workspace }],
|
||||
});
|
||||
const fixture = TestBed.createComponent(RentPaymentsPage);
|
||||
fixture.detectChanges();
|
||||
return fixture;
|
||||
}
|
||||
|
||||
function createWorkspace(
|
||||
function createFixture(
|
||||
rentDues: RentDue[],
|
||||
rentPayments: RentPayment[] = [],
|
||||
loadState = { loaded: true, loading: false, error: '' },
|
||||
) {
|
||||
return {
|
||||
TestBed.resetTestingModule();
|
||||
const workspace = {
|
||||
apartments: signal([{ id: 'apartment-1', name: 'T2' }]),
|
||||
leases: signal([{ id: 'lease-1', apartmentId: 'apartment-1' }]),
|
||||
rentDues: signal(rentDues),
|
||||
rentPayments: signal(rentPayments),
|
||||
apartmentLoadState: signal(loadState),
|
||||
leaseLoadState: signal(loadState),
|
||||
rentDueLoadState: signal(loadState),
|
||||
rentPaymentLoadState: signal(loadState),
|
||||
loadApartments: vi.fn(),
|
||||
loadLeases: vi.fn(),
|
||||
loadRentDues: vi.fn(),
|
||||
loadRentPayments: vi.fn(),
|
||||
acceptRentPayment: vi.fn(),
|
||||
rentReceiptDownloading: vi.fn(() => false),
|
||||
rentReceiptDownloadError: vi.fn(() => ''),
|
||||
downloadRentReceipt: vi.fn(),
|
||||
paymentReceiptDownloading: vi.fn(() => false),
|
||||
paymentReceiptDownloadError: vi.fn(() => ''),
|
||||
downloadPaymentReceipt: vi.fn(),
|
||||
};
|
||||
const dialog = {
|
||||
open: vi
|
||||
.fn()
|
||||
.mockReturnValue({ afterClosed: () => of<RentPaymentDialogResult | undefined>(undefined) }),
|
||||
};
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: WorkspaceService, useValue: workspace },
|
||||
{ provide: MatDialog, useValue: dialog },
|
||||
],
|
||||
});
|
||||
const fixture = TestBed.createComponent(RentPaymentsPage);
|
||||
fixture.detectChanges();
|
||||
return { fixture, workspace, dialog };
|
||||
}
|
||||
|
||||
function rentDue(
|
||||
@@ -168,29 +237,47 @@ function rentDue(
|
||||
rent: 800,
|
||||
charges: 50,
|
||||
total: 850,
|
||||
allocatedAmount: 0,
|
||||
remainingAmount: 850,
|
||||
settlement,
|
||||
timing,
|
||||
version: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function payment(id: string, month: string, status: 'PAID' | 'LATE' | 'UPCOMING') {
|
||||
function payment(overrides: Partial<RentPayment> = {}): RentPayment {
|
||||
return {
|
||||
id,
|
||||
id: 'payment-1',
|
||||
leaseId: 'lease-1',
|
||||
month,
|
||||
dueDate: `${month}-05`,
|
||||
rent: 800,
|
||||
charges: 50,
|
||||
total: 850,
|
||||
status,
|
||||
paidAt: status === 'PAID' ? `${month}-05` : null,
|
||||
receivedAt: '2026-07-06',
|
||||
amount: 850,
|
||||
status: 'CONFIRMED',
|
||||
allocations: [
|
||||
{ id: 'allocation-1', rentDueId: 'due-1', amount: 850, allocatedAt: '2026-07-06' },
|
||||
],
|
||||
allocatedAmount: 850,
|
||||
refundedAmount: 0,
|
||||
unallocatedBalance: 0,
|
||||
confirmedAt: '2026-07-06',
|
||||
version: 0,
|
||||
refunds: [],
|
||||
reversal: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function paymentMonths(fixture: ReturnType<typeof createFixture>): string[] {
|
||||
function paymentMonths(fixture: ReturnType<typeof TestBed.createComponent>): string[] {
|
||||
return [...fixture.nativeElement.querySelectorAll('.payment-month')].map((element) =>
|
||||
element.textContent.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
function button(
|
||||
fixture: ReturnType<typeof TestBed.createComponent>,
|
||||
text: string,
|
||||
): HTMLButtonElement | undefined {
|
||||
return [...fixture.nativeElement.querySelectorAll('button')].find((element) =>
|
||||
element.textContent.includes(text),
|
||||
);
|
||||
}
|
||||
@@ -1,37 +1,67 @@
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { Component, computed, inject, signal } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import {
|
||||
EligibleRentDue,
|
||||
RentPaymentAllocationDialog,
|
||||
} from '../rent-payment-allocation-dialog/rent-payment-allocation-dialog';
|
||||
import { RentPaymentRefundDialog } from '../rent-payment-refund-dialog/rent-payment-refund-dialog';
|
||||
import { RentPaymentReversalDialog } from '../rent-payment-reversal-dialog/rent-payment-reversal-dialog';
|
||||
import {
|
||||
RentPaymentValidationDialog,
|
||||
RentPaymentValidationDialogData,
|
||||
} from '../rent-payment-validation-dialog/rent-payment-validation-dialog';
|
||||
import { RentDue } from '../../services/lease.service';
|
||||
import { formatEuro } from '../../services/rent-payment-format';
|
||||
import { PaymentAllocation, RentPayment } from '../../services/rent-payment.service';
|
||||
import { WorkspaceService } from '../../services/workspace.service';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { RentPaymentDialogResult, RENT_PAYMENT_DATA_STALE } from '../rent-payment-dialog-result';
|
||||
|
||||
interface PaymentFeedback {
|
||||
kind: 'success' | 'error';
|
||||
message: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-rent-payments-page',
|
||||
imports: [MatButtonModule, MatProgressBarModule, DatePipe],
|
||||
imports: [DatePipe, MatButtonModule, MatProgressBarModule, MatProgressSpinnerModule],
|
||||
templateUrl: './rent-payments-page.html',
|
||||
styleUrl: './rent-payments-page.css',
|
||||
})
|
||||
export class RentPaymentsPage {
|
||||
readonly workspace = inject(WorkspaceService);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
readonly showFullSchedule = signal(false);
|
||||
readonly feedback = signal<PaymentFeedback | null>(null);
|
||||
readonly formatEuro = formatEuro;
|
||||
readonly displayedRentDues = computed(() =>
|
||||
this.showFullSchedule()
|
||||
? sortRentDues(this.workspace.rentDues())
|
||||
: currentRentDues(this.workspace.rentDues()),
|
||||
);
|
||||
readonly contentReady = computed(
|
||||
readonly displayedPayments = computed(() =>
|
||||
[...this.workspace.rentPayments()].sort(
|
||||
(left, right) =>
|
||||
right.receivedAt.localeCompare(left.receivedAt) ||
|
||||
right.confirmedAt.localeCompare(left.confirmedAt),
|
||||
),
|
||||
);
|
||||
readonly scheduleReady = computed(
|
||||
() =>
|
||||
this.workspace.rentDueLoadState().loaded &&
|
||||
this.workspace.leaseLoadState().loaded &&
|
||||
this.workspace.apartmentLoadState().loaded,
|
||||
);
|
||||
readonly contentLoading = computed(
|
||||
readonly scheduleLoading = computed(
|
||||
() =>
|
||||
this.workspace.rentDueLoadState().loading ||
|
||||
this.workspace.leaseLoadState().loading ||
|
||||
this.workspace.apartmentLoadState().loading,
|
||||
);
|
||||
readonly loadError = computed(
|
||||
readonly scheduleError = computed(
|
||||
() =>
|
||||
this.workspace.rentDueLoadState().error ||
|
||||
this.workspace.leaseLoadState().error ||
|
||||
@@ -66,15 +96,119 @@ export class RentPaymentsPage {
|
||||
return { UPCOMING: 'À venir', OVERDUE: 'En retard' }[timing];
|
||||
}
|
||||
|
||||
paymentStatusLabel(status: RentPayment['status']): string {
|
||||
return { CONFIRMED: 'Confirmé', REVERSED: 'Contrepassé' }[status];
|
||||
}
|
||||
|
||||
allocationLabel(allocation: PaymentAllocation): string {
|
||||
const due = this.workspace.rentDues().find((item) => item.id === allocation.rentDueId);
|
||||
return due ? `${this.apartmentName(due)} — ${this.monthLabel(due.month)}` : 'Échéance inconnue';
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.workspace.loadRentDues();
|
||||
this.workspace.loadLeases();
|
||||
this.workspace.loadApartments();
|
||||
this.workspace.loadRentPayments();
|
||||
}
|
||||
|
||||
toggleSchedule(): void {
|
||||
this.showFullSchedule.update((showFullSchedule) => !showFullSchedule);
|
||||
}
|
||||
|
||||
recordPayment(rentDue: RentDue): void {
|
||||
this.dialog
|
||||
.open<RentPaymentValidationDialog, RentPaymentValidationDialogData, RentPaymentDialogResult>(
|
||||
RentPaymentValidationDialog,
|
||||
{
|
||||
data: { rentDue, apartmentName: this.apartmentName(rentDue) },
|
||||
width: 'min(36rem, calc(100vw - 2rem))',
|
||||
maxWidth: 'calc(100vw - 2rem)',
|
||||
},
|
||||
)
|
||||
.afterClosed()
|
||||
.subscribe((payment) => this.accept(payment, 'Le règlement a été enregistré.'));
|
||||
}
|
||||
|
||||
allocateSurplus(payment: RentPayment): void {
|
||||
const eligibleRentDues = this.eligibleRentDues(payment);
|
||||
if (eligibleRentDues.length === 0) return;
|
||||
this.dialog
|
||||
.open<
|
||||
RentPaymentAllocationDialog,
|
||||
{ payment: RentPayment; eligibleRentDues: EligibleRentDue[] },
|
||||
RentPaymentDialogResult
|
||||
>(RentPaymentAllocationDialog, {
|
||||
data: { payment, eligibleRentDues },
|
||||
width: 'min(36rem, calc(100vw - 2rem))',
|
||||
maxWidth: 'calc(100vw - 2rem)',
|
||||
})
|
||||
.afterClosed()
|
||||
.subscribe((updated) => this.accept(updated, 'Le surplus a été affecté.'));
|
||||
}
|
||||
|
||||
refundSurplus(payment: RentPayment): void {
|
||||
this.dialog
|
||||
.open<RentPaymentRefundDialog, RentPayment, RentPaymentDialogResult>(
|
||||
RentPaymentRefundDialog,
|
||||
{
|
||||
data: payment,
|
||||
width: 'min(34rem, calc(100vw - 2rem))',
|
||||
maxWidth: 'calc(100vw - 2rem)',
|
||||
},
|
||||
)
|
||||
.afterClosed()
|
||||
.subscribe((updated) => this.accept(updated, 'Le remboursement a été enregistré.'));
|
||||
}
|
||||
|
||||
reversePayment(payment: RentPayment): void {
|
||||
this.dialog
|
||||
.open<RentPaymentReversalDialog, RentPayment, RentPaymentDialogResult>(
|
||||
RentPaymentReversalDialog,
|
||||
{
|
||||
data: payment,
|
||||
role: 'alertdialog',
|
||||
width: 'min(34rem, calc(100vw - 2rem))',
|
||||
maxWidth: 'calc(100vw - 2rem)',
|
||||
},
|
||||
)
|
||||
.afterClosed()
|
||||
.subscribe((updated) => this.accept(updated, 'Le règlement a été contrepassé.'));
|
||||
}
|
||||
|
||||
canAllocate(payment: RentPayment): boolean {
|
||||
return payment.unallocatedBalance > 0 && this.eligibleRentDues(payment).length > 0;
|
||||
}
|
||||
|
||||
private eligibleRentDues(payment: RentPayment): EligibleRentDue[] {
|
||||
const allocatedDueIds = new Set(payment.allocations.map((allocation) => allocation.rentDueId));
|
||||
return sortRentDues(this.workspace.rentDues())
|
||||
.filter(
|
||||
(due) =>
|
||||
due.leaseId === payment.leaseId &&
|
||||
due.settlement !== 'PAID' &&
|
||||
!allocatedDueIds.has(due.id),
|
||||
)
|
||||
.map((rentDue) => ({
|
||||
rentDue,
|
||||
label: `${this.apartmentName(rentDue)} — ${this.monthLabel(rentDue.month)}`,
|
||||
}));
|
||||
}
|
||||
|
||||
private accept(result: RentPaymentDialogResult | undefined, message: string): void {
|
||||
if (!result) return;
|
||||
if (result === RENT_PAYMENT_DATA_STALE) {
|
||||
this.workspace.loadRentDues();
|
||||
this.workspace.loadRentPayments();
|
||||
this.feedback.set({
|
||||
kind: 'error',
|
||||
message: 'Les données ont changé. Elles ont été actualisées ; recommencez l’opération.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.workspace.acceptRentPayment(result);
|
||||
this.feedback.set({ kind: 'success', message });
|
||||
}
|
||||
}
|
||||
|
||||
function sortRentDues(rentDues: readonly RentDue[]): RentDue[] {
|
||||
|
||||
@@ -39,6 +39,7 @@ describe('LeaseService', () => {
|
||||
service.activateLease('lease-1').subscribe();
|
||||
service.deleteLease('lease-1').subscribe();
|
||||
service.downloadPdf('lease-1').subscribe();
|
||||
service.downloadRentReceipt('due-1').subscribe();
|
||||
|
||||
expect(http.get).toHaveBeenCalledWith('/api/leases');
|
||||
expect(http.get).toHaveBeenCalledWith('/api/rent-dues');
|
||||
@@ -49,5 +50,8 @@ describe('LeaseService', () => {
|
||||
expect(http.get).toHaveBeenCalledWith('/api/leases/lease-1/pdf', {
|
||||
responseType: 'blob',
|
||||
});
|
||||
expect(http.get).toHaveBeenCalledWith('/api/rent-dues/due-1/receipt', {
|
||||
responseType: 'blob',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -35,9 +35,11 @@ export interface RentDue {
|
||||
rent: number;
|
||||
charges: number;
|
||||
total: number;
|
||||
allocatedAmount: number;
|
||||
remainingAmount: number;
|
||||
settlement: 'UNPAID' | 'PARTIALLY_PAID' | 'PAID';
|
||||
timing: 'UPCOMING' | 'OVERDUE';
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface LeaseActivation {
|
||||
@@ -57,6 +59,10 @@ export class LeaseService {
|
||||
return this.http.get<RentDue[]>('/api/rent-dues');
|
||||
}
|
||||
|
||||
downloadRentReceipt(id: string): Observable<Blob> {
|
||||
return this.http.get(`/api/rent-dues/${id}/receipt`, { responseType: 'blob' });
|
||||
}
|
||||
|
||||
createLease(lease: LeaseInput): Observable<Lease> {
|
||||
return this.http.post<Lease>('/api/leases', lease);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
const euroFormatter = new Intl.NumberFormat('fr-FR', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
export function formatEuro(value: number): string {
|
||||
return euroFormatter.format(value);
|
||||
}
|
||||
|
||||
export function utcIsoDate(date = new Date()): string {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { RentPayment } from './rent-payment.service';
|
||||
|
||||
export function sortRentPayments(payments: readonly RentPayment[]): RentPayment[] {
|
||||
return [...payments].sort((left, right) => left.dueDate.localeCompare(right.dueDate));
|
||||
}
|
||||
|
||||
export function currentRentSchedule(payments: readonly RentPayment[]): RentPayment[] {
|
||||
const paymentsByLease = new Map<string, RentPayment[]>();
|
||||
for (const payment of sortRentPayments(payments)) {
|
||||
const schedule = paymentsByLease.get(payment.leaseId);
|
||||
if (schedule) schedule.push(payment);
|
||||
else paymentsByLease.set(payment.leaseId, [payment]);
|
||||
}
|
||||
|
||||
return sortRentPayments([...paymentsByLease.values()].flatMap(currentSchedule));
|
||||
}
|
||||
|
||||
function currentSchedule(schedule: RentPayment[]): RentPayment[] {
|
||||
const nextDueIndex = schedule.findIndex((payment) => payment.status === 'UPCOMING');
|
||||
const paymentsThroughNextDue = nextDueIndex === -1 ? schedule : schedule.slice(0, nextDueIndex + 1);
|
||||
const lastPaidIndex = paymentsThroughNextDue.reduce(
|
||||
(lastPaid, payment, index) => (payment.status === 'PAID' ? index : lastPaid),
|
||||
-1,
|
||||
);
|
||||
const firstUnpaidIndex = paymentsThroughNextDue.findIndex((payment) => payment.status !== 'PAID');
|
||||
const firstVisibleIndex = Math.min(...[lastPaidIndex, firstUnpaidIndex].filter((index) => index !== -1));
|
||||
|
||||
return firstVisibleIndex === Infinity ? [] : paymentsThroughNextDue.slice(firstVisibleIndex);
|
||||
}
|
||||
@@ -4,22 +4,51 @@ import { of } from 'rxjs';
|
||||
import { RentPaymentService } from './rent-payment.service';
|
||||
|
||||
describe('RentPaymentService', () => {
|
||||
it('uses the rent payment schedule, payment, and receipt endpoints', () => {
|
||||
it('uses the confirmed payment lifecycle and receipt endpoints', () => {
|
||||
const http = {
|
||||
get: vi.fn(() => of([])),
|
||||
put: vi.fn(() => of({})),
|
||||
post: vi.fn(() => of({})),
|
||||
};
|
||||
TestBed.configureTestingModule({
|
||||
providers: [RentPaymentService, { provide: HttpClient, useValue: http }],
|
||||
});
|
||||
const service = TestBed.inject(RentPaymentService);
|
||||
const record = {
|
||||
operationId: 'operation-1',
|
||||
rentDueId: 'due-1',
|
||||
rentDueVersion: 2,
|
||||
receivedAt: '2026-01-05',
|
||||
amount: 900,
|
||||
};
|
||||
const allocation = {
|
||||
operationId: 'operation-2',
|
||||
paymentVersion: 1,
|
||||
rentDueId: 'due-2',
|
||||
rentDueVersion: 3,
|
||||
amount: 50,
|
||||
};
|
||||
const refund = {
|
||||
operationId: 'operation-3',
|
||||
paymentVersion: 2,
|
||||
amount: 25,
|
||||
refundedAt: '2026-01-06',
|
||||
};
|
||||
|
||||
service.getRentPayments().subscribe();
|
||||
service.markPaid('payment-1', 850, 50).subscribe();
|
||||
service.record(record).subscribe();
|
||||
service.allocate('payment-1', allocation).subscribe();
|
||||
service.refund('payment-1', refund).subscribe();
|
||||
service.reverse('payment-1', 3, 'Erreur de saisie').subscribe();
|
||||
service.downloadReceipt('payment-1').subscribe();
|
||||
|
||||
expect(http.get).toHaveBeenCalledWith('/api/rent-payments');
|
||||
expect(http.put).toHaveBeenCalledWith('/api/rent-payments/payment-1/paid', { rent: 850, charges: 50 });
|
||||
expect(http.post).toHaveBeenCalledWith('/api/rent-payments', record);
|
||||
expect(http.post).toHaveBeenCalledWith('/api/rent-payments/payment-1/allocations', allocation);
|
||||
expect(http.post).toHaveBeenCalledWith('/api/rent-payments/payment-1/refunds', refund);
|
||||
expect(http.post).toHaveBeenCalledWith('/api/rent-payments/payment-1/reversal', {
|
||||
paymentVersion: 3,
|
||||
reason: 'Erreur de saisie',
|
||||
});
|
||||
expect(http.get).toHaveBeenCalledWith('/api/rent-payments/payment-1/receipt', {
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
@@ -2,18 +2,66 @@ import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
export type RentPaymentStatus = 'PAID' | 'LATE' | 'UPCOMING';
|
||||
export type RentPaymentStatus = 'CONFIRMED' | 'REVERSED';
|
||||
|
||||
export interface PaymentAllocation {
|
||||
id: string;
|
||||
rentDueId: string;
|
||||
amount: number;
|
||||
allocatedAt: string;
|
||||
}
|
||||
|
||||
export interface RentPaymentRefund {
|
||||
id: string;
|
||||
amount: number;
|
||||
refundedAt: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface RentPaymentReversal {
|
||||
id: string;
|
||||
reversedAt: string;
|
||||
reason: string;
|
||||
reversedAllocations: PaymentAllocation[];
|
||||
}
|
||||
|
||||
export interface RentPayment {
|
||||
id: string;
|
||||
leaseId: string;
|
||||
month: string;
|
||||
dueDate: string;
|
||||
rent: number;
|
||||
charges: number;
|
||||
total: number;
|
||||
receivedAt: string;
|
||||
amount: number;
|
||||
status: RentPaymentStatus;
|
||||
paidAt: string | null;
|
||||
allocations: PaymentAllocation[];
|
||||
allocatedAmount: number;
|
||||
refundedAmount: number;
|
||||
unallocatedBalance: number;
|
||||
confirmedAt: string;
|
||||
version: number;
|
||||
refunds: RentPaymentRefund[];
|
||||
reversal: RentPaymentReversal | null;
|
||||
}
|
||||
|
||||
export interface RecordRentPaymentRequest {
|
||||
operationId: string;
|
||||
rentDueId: string;
|
||||
rentDueVersion: number;
|
||||
receivedAt: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface AllocateRentPaymentRequest {
|
||||
operationId: string;
|
||||
paymentVersion: number;
|
||||
rentDueId: string;
|
||||
rentDueVersion: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface RefundRentPaymentRequest {
|
||||
operationId: string;
|
||||
paymentVersion: number;
|
||||
amount: number;
|
||||
refundedAt: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@@ -24,8 +72,23 @@ export class RentPaymentService {
|
||||
return this.http.get<RentPayment[]>('/api/rent-payments');
|
||||
}
|
||||
|
||||
markPaid(id: string, rent: number, charges: number): Observable<RentPayment> {
|
||||
return this.http.put<RentPayment>(`/api/rent-payments/${id}/paid`, { rent, charges });
|
||||
record(request: RecordRentPaymentRequest): Observable<RentPayment> {
|
||||
return this.http.post<RentPayment>('/api/rent-payments', request);
|
||||
}
|
||||
|
||||
allocate(paymentId: string, request: AllocateRentPaymentRequest): Observable<RentPayment> {
|
||||
return this.http.post<RentPayment>(`/api/rent-payments/${paymentId}/allocations`, request);
|
||||
}
|
||||
|
||||
refund(paymentId: string, request: RefundRentPaymentRequest): Observable<RentPayment> {
|
||||
return this.http.post<RentPayment>(`/api/rent-payments/${paymentId}/refunds`, request);
|
||||
}
|
||||
|
||||
reverse(paymentId: string, paymentVersion: number, reason: string): Observable<RentPayment> {
|
||||
return this.http.post<RentPayment>(`/api/rent-payments/${paymentId}/reversal`, {
|
||||
paymentVersion,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
|
||||
downloadReceipt(id: string): Observable<Blob> {
|
||||
|
||||
@@ -41,13 +41,19 @@ describe('WorkspaceService', () => {
|
||||
const rentPayment = {
|
||||
id: 'payment-1',
|
||||
leaseId: 'lease-1',
|
||||
month: '2026-01',
|
||||
dueDate: '2026-01-01',
|
||||
rent: 800,
|
||||
charges: 50,
|
||||
total: 850,
|
||||
status: 'LATE' as const,
|
||||
paidAt: null,
|
||||
receivedAt: '2026-01-02',
|
||||
amount: 850,
|
||||
status: 'CONFIRMED' as const,
|
||||
allocations: [
|
||||
{ id: 'allocation-1', rentDueId: 'due-1', amount: 850, allocatedAt: '2026-01-02' },
|
||||
],
|
||||
allocatedAmount: 850,
|
||||
refundedAmount: 0,
|
||||
unallocatedBalance: 0,
|
||||
confirmedAt: '2026-01-02',
|
||||
version: 0,
|
||||
refunds: [],
|
||||
reversal: null,
|
||||
};
|
||||
const rentDue = {
|
||||
id: 'due-1',
|
||||
@@ -57,9 +63,11 @@ describe('WorkspaceService', () => {
|
||||
rent: 800,
|
||||
charges: 50,
|
||||
total: 850,
|
||||
allocatedAmount: 0,
|
||||
remainingAmount: 850,
|
||||
settlement: 'UNPAID' as const,
|
||||
timing: 'UPCOMING' as const,
|
||||
version: 0,
|
||||
};
|
||||
const apartments = {
|
||||
getApartments: vi.fn(),
|
||||
@@ -84,10 +92,10 @@ describe('WorkspaceService', () => {
|
||||
activateLease: vi.fn(),
|
||||
deleteLease: vi.fn(),
|
||||
downloadPdf: vi.fn(),
|
||||
downloadRentReceipt: vi.fn(),
|
||||
};
|
||||
const rentPayments = {
|
||||
getRentPayments: vi.fn(),
|
||||
markPaid: vi.fn(),
|
||||
downloadReceipt: vi.fn(),
|
||||
};
|
||||
const dialog = {
|
||||
@@ -114,10 +122,8 @@ describe('WorkspaceService', () => {
|
||||
leases.activateLease.mockReturnValue(of({ lease, rentDues: [rentDue] }));
|
||||
leases.deleteLease.mockReturnValue(of(undefined));
|
||||
leases.downloadPdf.mockReturnValue(of(new Blob(['pdf'])));
|
||||
leases.downloadRentReceipt.mockReturnValue(of(new Blob(['rent receipt'])));
|
||||
rentPayments.getRentPayments.mockReturnValue(of([rentPayment]));
|
||||
rentPayments.markPaid.mockReturnValue(
|
||||
of({ ...rentPayment, status: 'PAID', paidAt: '2026-01-02' }),
|
||||
);
|
||||
rentPayments.downloadReceipt.mockReturnValue(of(new Blob(['receipt'])));
|
||||
dialog.open.mockImplementation(() => ({ afterClosed: () => of(true) }));
|
||||
vi.stubGlobal('URL', { createObjectURL: vi.fn(() => 'blob:test'), revokeObjectURL: vi.fn() });
|
||||
@@ -371,7 +377,7 @@ describe('WorkspaceService', () => {
|
||||
service.saveLease(lease, lease.id);
|
||||
service.deleteLease(lease.id);
|
||||
service.loadRentPayments();
|
||||
service.markRentPaymentPaid(rentPayment.id, rentPayment.rent, rentPayment.charges);
|
||||
service.acceptRentPayment(rentPayment);
|
||||
|
||||
expect(service.apartments()).toEqual([apartment]);
|
||||
expect(service.tenants()).toEqual([tenant]);
|
||||
@@ -391,11 +397,6 @@ describe('WorkspaceService', () => {
|
||||
securityDeposit: lease.securityDeposit,
|
||||
});
|
||||
expect(leases.deleteLease).toHaveBeenCalledWith('lease-1');
|
||||
expect(rentPayments.markPaid).toHaveBeenCalledWith(
|
||||
'payment-1',
|
||||
rentPayment.rent,
|
||||
rentPayment.charges,
|
||||
);
|
||||
});
|
||||
|
||||
it('opens a confirmation pop-in before deleting a resource', () => {
|
||||
@@ -430,13 +431,14 @@ describe('WorkspaceService', () => {
|
||||
service.uploadTenantAttachment('tenant-1', new File(['id'], 'id.pdf'));
|
||||
service.downloadTenantAttachment('tenant-1', { id: 'attachment-1', fileName: 'id.pdf' });
|
||||
service.downloadLeasePdf(lease);
|
||||
service.downloadRentReceipt(rentPayment);
|
||||
service.downloadRentReceipt(rentDue);
|
||||
service.downloadPaymentReceipt(rentPayment);
|
||||
service.downloadLeasePdf({ ...lease, id: undefined });
|
||||
|
||||
expect(tenants.deleteTenant).toHaveBeenCalledWith('tenant-1');
|
||||
expect(leases.deleteLease).toHaveBeenCalledWith('lease-1');
|
||||
expect(URL.createObjectURL).toHaveBeenCalledTimes(3);
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(3);
|
||||
expect(URL.createObjectURL).toHaveBeenCalledTimes(4);
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('displays a server attachment error or its fallback', () => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
import { Apartment, ApartmentService } from './apartment.service';
|
||||
import { FileDownloadService } from './file-download.service';
|
||||
import { finalize } from 'rxjs';
|
||||
import { Lease, LeaseInput, LeaseService } from './lease.service';
|
||||
import { Lease, LeaseInput, LeaseService, RentDue } from './lease.service';
|
||||
import { RentPayment, RentPaymentService } from './rent-payment.service';
|
||||
import { Tenant, TenantAttachment, TenantService } from './tenant.service';
|
||||
import { TenantAttachmentTransferService } from './tenant-attachment-transfer.service';
|
||||
@@ -302,27 +302,30 @@ export class WorkspaceService {
|
||||
this.rentPaymentResource.load();
|
||||
}
|
||||
|
||||
markRentPaymentPaid(id: string, rent: number, charges: number): void {
|
||||
this.rentPaymentResource.save(id, this.rentPaymentService.markPaid(id, rent, charges));
|
||||
acceptRentPayment(payment: RentPayment): void {
|
||||
this.rentPaymentResource.upsert(payment);
|
||||
this.rentPaymentResource.load();
|
||||
this.rentDueResource.load();
|
||||
}
|
||||
|
||||
rentPaymentSaving(id: string): boolean {
|
||||
return this.rentPaymentResource.savingKeys().has(id);
|
||||
}
|
||||
|
||||
rentPaymentSaveError(id: string): string {
|
||||
return this.rentPaymentResource.saveErrors()[id] ?? '';
|
||||
}
|
||||
|
||||
downloadRentReceipt(payment: RentPayment): void {
|
||||
downloadRentReceipt(rentDue: RentDue): void {
|
||||
this.downloads.download(
|
||||
this.rentReceiptDownloadKey(payment.id),
|
||||
this.rentPaymentService.downloadReceipt(payment.id),
|
||||
`quittance-${payment.month}.pdf`,
|
||||
this.rentReceiptDownloadKey(rentDue.id),
|
||||
this.leaseService.downloadRentReceipt(rentDue.id),
|
||||
`quittance-${rentDue.month}.pdf`,
|
||||
'Impossible de télécharger cette quittance.',
|
||||
);
|
||||
}
|
||||
|
||||
downloadPaymentReceipt(payment: RentPayment): void {
|
||||
this.downloads.download(
|
||||
this.paymentReceiptDownloadKey(payment.id),
|
||||
this.rentPaymentService.downloadReceipt(payment.id),
|
||||
`recu-reglement-${payment.id}.pdf`,
|
||||
'Impossible de télécharger ce reçu de règlement.',
|
||||
);
|
||||
}
|
||||
|
||||
rentReceiptDownloading(paymentId: string): boolean {
|
||||
return this.downloads.isPending(this.rentReceiptDownloadKey(paymentId));
|
||||
}
|
||||
@@ -331,6 +334,14 @@ export class WorkspaceService {
|
||||
return this.downloads.error(this.rentReceiptDownloadKey(paymentId));
|
||||
}
|
||||
|
||||
paymentReceiptDownloading(paymentId: string): boolean {
|
||||
return this.downloads.isPending(this.paymentReceiptDownloadKey(paymentId));
|
||||
}
|
||||
|
||||
paymentReceiptDownloadError(paymentId: string): string {
|
||||
return this.downloads.error(this.paymentReceiptDownloadKey(paymentId));
|
||||
}
|
||||
|
||||
private resourceKey(id?: string): string {
|
||||
return id ?? newResourceKey;
|
||||
}
|
||||
@@ -343,6 +354,10 @@ export class WorkspaceService {
|
||||
return `rent-receipt:${paymentId}`;
|
||||
}
|
||||
|
||||
private paymentReceiptDownloadKey(paymentId: string): string {
|
||||
return `payment-receipt:${paymentId}`;
|
||||
}
|
||||
|
||||
private requestConfirmation(data: ConfirmationDialogData, onConfirmed: () => void): void {
|
||||
this.dialog
|
||||
.open(ConfirmationDialog, {
|
||||
|
||||
Reference in new issue
Block a user