feat: activate draft leases into rent dues (#28)

This commit is contained in:
nessar committed 2026-08-17 14:18:15 +02:00
1 parent 971ffd6feb
commit c2afe515c0
42 files changed
+1735 -113

No files matched your search

@@ -22,6 +22,7 @@ public class SecurityConfig {
.requestMatchers("/api/apartments", "/api/apartments/**").authenticated()
.requestMatchers("/api/tenants", "/api/tenants/**").authenticated()
.requestMatchers("/api/leases", "/api/leases/**").authenticated()
.requestMatchers("/api/rent-dues", "/api/rent-dues/**").authenticated()
.requestMatchers("/api/rent-payments", "/api/rent-payments/**").authenticated()
.requestMatchers("/api/landlord-profile").authenticated()
.requestMatchers("/api/user").permitAll()
@@ -4,6 +4,8 @@ import fr.nessar.lmnporganizer.backend.exception.AttachmentTooLargeException;
import fr.nessar.lmnporganizer.backend.exception.EmailAlreadyUsedException;
import fr.nessar.lmnporganizer.backend.exception.InvalidPhoneNumberException;
import fr.nessar.lmnporganizer.backend.exception.IncompleteLandlordProfileException;
import fr.nessar.lmnporganizer.backend.exception.LeaseConflictException;
import fr.nessar.lmnporganizer.backend.exception.LeaseValidationException;
import fr.nessar.lmnporganizer.backend.exception.ReferencedByLeaseException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
@@ -46,6 +48,16 @@ public class ApiExceptionHandler {
return error(HttpStatus.CONFLICT, exception.getMessage());
}
@ExceptionHandler(LeaseValidationException.class)
public ResponseEntity<ApiError> handleLeaseValidation(LeaseValidationException exception) {
return error(HttpStatus.BAD_REQUEST, exception.getMessage());
}
@ExceptionHandler(LeaseConflictException.class)
public ResponseEntity<ApiError> handleLeaseConflict(LeaseConflictException exception) {
return error(HttpStatus.CONFLICT, exception.getMessage());
}
@ExceptionHandler(IOException.class)
public ResponseEntity<ApiError> handleAttachmentIOException() {
return error(HttpStatus.INTERNAL_SERVER_ERROR, "Impossible de traiter la pièce jointe.");
@@ -1,5 +1,6 @@
package fr.nessar.lmnporganizer.backend.controller;
import fr.nessar.lmnporganizer.backend.dto.LeaseActivationResponse;
import fr.nessar.lmnporganizer.backend.dto.LeaseRequest;
import fr.nessar.lmnporganizer.backend.dto.LeaseResponse;
import fr.nessar.lmnporganizer.backend.service.LandlordProfileService;
@@ -52,6 +53,12 @@ public class LeaseController {
return leaseService.delete(landlordId(principal), id);
}
@PostMapping("/{id}/activate")
public ResponseEntity<LeaseActivationResponse> activateLease(@AuthenticationPrincipal OidcUser principal,
@PathVariable String id) {
return leaseService.activate(landlordId(principal), id);
}
@GetMapping("/{id}/pdf")
public ResponseEntity<byte[]> downloadPdf(@PathVariable String id, @AuthenticationPrincipal OidcUser principal) {
return leaseService.downloadPdf(landlordId(principal), id, principal.getSubject());
@@ -0,0 +1,29 @@
package fr.nessar.lmnporganizer.backend.controller;
import fr.nessar.lmnporganizer.backend.dto.RentDueResponse;
import fr.nessar.lmnporganizer.backend.service.LandlordProfileService;
import fr.nessar.lmnporganizer.backend.service.RentDueService;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/rent-dues")
public class RentDueController {
private final RentDueService rentDueService;
private final LandlordProfileService landlordProfileService;
public RentDueController(RentDueService rentDueService, LandlordProfileService landlordProfileService) {
this.rentDueService = rentDueService;
this.landlordProfileService = landlordProfileService;
}
@GetMapping
public List<RentDueResponse> getRentDues(@AuthenticationPrincipal OidcUser principal) {
return rentDueService.getAll(landlordProfileService.findOrCreate(principal).getId());
}
}
@@ -0,0 +1,6 @@
package fr.nessar.lmnporganizer.backend.dto;
import java.util.List;
public record LeaseActivationResponse(LeaseResponse lease, List<RentDueResponse> rentDues) {
}
@@ -1,6 +1,7 @@
package fr.nessar.lmnporganizer.backend.dto;
import fr.nessar.lmnporganizer.backend.model.LeaseStatus;
import com.fasterxml.jackson.annotation.JsonAnySetter;
import fr.nessar.lmnporganizer.backend.exception.LeaseValidationException;
import jakarta.validation.Valid;
import java.math.BigDecimal;
@@ -15,7 +16,10 @@ public record LeaseRequest(
LocalDate endDate,
BigDecimal monthlyRent,
BigDecimal monthlyCharges,
BigDecimal securityDeposit,
LeaseStatus status
BigDecimal securityDeposit
) {
@JsonAnySetter
public void rejectUnknownField(String name, Object value) {
throw new LeaseValidationException("Le champ « " + name + " » n'est pas accepté pour un bail.");
}
}
@@ -0,0 +1,21 @@
package fr.nessar.lmnporganizer.backend.dto;
import fr.nessar.lmnporganizer.backend.model.RentDueSettlement;
import fr.nessar.lmnporganizer.backend.model.RentDueTiming;
import java.math.BigDecimal;
import java.time.LocalDate;
public record RentDueResponse(
String id,
String leaseId,
String month,
LocalDate dueDate,
BigDecimal rent,
BigDecimal charges,
BigDecimal total,
BigDecimal remainingAmount,
RentDueSettlement settlement,
RentDueTiming timing
) {
}
@@ -0,0 +1,7 @@
package fr.nessar.lmnporganizer.backend.exception;
public class LeaseConflictException extends RuntimeException {
public LeaseConflictException(String message) {
super(message);
}
}
@@ -0,0 +1,7 @@
package fr.nessar.lmnporganizer.backend.exception;
public class LeaseValidationException extends RuntimeException {
public LeaseValidationException(String message) {
super(message);
}
}
@@ -26,9 +26,11 @@ public interface LeaseMapper {
@Mapping(target = "id", ignore = true)
@Mapping(target = "landlordId", ignore = true)
@Mapping(target = "status", ignore = true)
Lease toEntity(LeaseRequest request);
@Mapping(target = "id", ignore = true)
@Mapping(target = "landlordId", ignore = true)
@Mapping(target = "status", ignore = true)
void update(LeaseRequest request, @MappingTarget Lease target);
}
@@ -1,6 +1,7 @@
package fr.nessar.lmnporganizer.backend.model;
import jakarta.validation.Valid;
import fr.nessar.lmnporganizer.backend.exception.LeaseValidationException;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
@@ -8,6 +9,7 @@ import org.springframework.data.mongodb.core.mapping.Document;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
@Document(collection = "leases")
@@ -77,4 +79,27 @@ public class Lease {
public void setSecurityDeposit(BigDecimal securityDeposit) { this.securityDeposit = securityDeposit; }
public LeaseStatus getStatus() { return status; }
public void setStatus(LeaseStatus status) { this.status = status; }
public void activate() {
if (status != LeaseStatus.DRAFT) {
throw new LeaseValidationException("Seul un bail en brouillon peut être activé.");
}
if (tenantIds.isEmpty() || tenantIds.stream().anyMatch(id -> id == null || id.isBlank())
|| new HashSet<>(tenantIds).size() != tenantIds.size()) {
throw new LeaseValidationException("Le bail doit contenir au moins un locataire, sans doublon.");
}
if (startDate == null || endDate == null || startDate.isAfter(endDate)) {
throw new LeaseValidationException("La date de début du bail doit précéder ou égaler sa date de fin.");
}
if (monthlyRent == null || monthlyRent.signum() <= 0) {
throw new LeaseValidationException("Le loyer mensuel doit être strictement positif.");
}
if (monthlyCharges == null || monthlyCharges.signum() < 0) {
throw new LeaseValidationException("Les charges mensuelles doivent être positives ou nulles.");
}
if (securityDeposit == null || securityDeposit.signum() < 0) {
throw new LeaseValidationException("Le dépôt de garantie doit être positif ou nul.");
}
status = LeaseStatus.ACTIVE;
}
}
@@ -0,0 +1,80 @@
package fr.nessar.lmnporganizer.backend.model;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.CompoundIndex;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.ArrayList;
import java.util.List;
@Document(collection = "rentDues")
@CompoundIndex(name = "rent_due_lease_month", def = "{'leaseId': 1, 'month': 1}", unique = true)
public class RentDue {
@Id
private String id;
@Indexed
private String landlordId;
@Indexed
private String leaseId;
private String month;
private LocalDate dueDate;
private BigDecimal rent;
private BigDecimal charges;
public RentDue() {
}
public RentDue(String id, String landlordId, String leaseId, String month, LocalDate dueDate, BigDecimal rent,
BigDecimal charges) {
this.id = id;
this.landlordId = landlordId;
this.leaseId = leaseId;
this.month = month;
this.dueDate = dueDate;
this.rent = rent;
this.charges = charges;
}
public static List<RentDue> forLease(Lease lease) {
YearMonth firstMonth = YearMonth.from(lease.getStartDate());
YearMonth lastMonth = YearMonth.from(lease.getEndDate());
List<RentDue> dues = new ArrayList<>();
for (YearMonth month = firstMonth; !month.isAfter(lastMonth); month = month.plusMonths(1)) {
int firstOccupiedDay = month.equals(firstMonth) ? lease.getStartDate().getDayOfMonth() : 1;
int lastOccupiedDay = month.equals(lastMonth) ? lease.getEndDate().getDayOfMonth() : month.lengthOfMonth();
int occupiedDays = lastOccupiedDay - firstOccupiedDay + 1;
dues.add(new RentDue(null, lease.getLandlordId(), lease.getId(), month.toString(),
month.plusMonths(1).atDay(5), prorate(lease.getMonthlyRent(), occupiedDays, month.lengthOfMonth()),
prorate(lease.getMonthlyCharges(), occupiedDays, month.lengthOfMonth())));
}
return dues;
}
private static BigDecimal prorate(BigDecimal amount, int occupiedDays, int daysInMonth) {
return amount.multiply(BigDecimal.valueOf(occupiedDays))
.divide(BigDecimal.valueOf(daysInMonth), 2, RoundingMode.HALF_UP);
}
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getLandlordId() { return landlordId; }
public void setLandlordId(String landlordId) { this.landlordId = landlordId; }
public String getLeaseId() { return leaseId; }
public void setLeaseId(String leaseId) { this.leaseId = leaseId; }
public String getMonth() { return month; }
public void setMonth(String month) { this.month = month; }
public LocalDate getDueDate() { return dueDate; }
public void setDueDate(LocalDate dueDate) { this.dueDate = dueDate; }
public BigDecimal getRent() { return rent; }
public void setRent(BigDecimal rent) { this.rent = rent; }
public BigDecimal getCharges() { return charges; }
public void setCharges(BigDecimal charges) { this.charges = charges; }
}
@@ -0,0 +1,5 @@
package fr.nessar.lmnporganizer.backend.model;
public enum RentDueSettlement {
UNPAID
}
@@ -0,0 +1,6 @@
package fr.nessar.lmnporganizer.backend.model;
public enum RentDueTiming {
UPCOMING,
OVERDUE
}
@@ -15,8 +15,6 @@ public interface LeaseRepository extends MongoRepository<Lease, String> {
long deleteByIdAndLandlordId(String id, String landlordId);
List<Lease> findAllByLandlordIdAndApartmentId(String landlordId, String apartmentId);
List<Lease> findAllByLandlordIdAndApartmentIdAndIdNot(String landlordId, String apartmentId, String id);
boolean existsByLandlordIdAndApartmentId(String landlordId, String apartmentId);
@@ -0,0 +1,14 @@
package fr.nessar.lmnporganizer.backend.repository;
import fr.nessar.lmnporganizer.backend.model.RentDue;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface RentDueRepository extends MongoRepository<RentDue, String> {
List<RentDue> findAllByLandlordIdOrderByDueDateAsc(String landlordId);
void deleteAllByLeaseId(String leaseId);
}
@@ -1,14 +1,20 @@
package fr.nessar.lmnporganizer.backend.service;
import fr.nessar.lmnporganizer.backend.dto.LeaseActivationResponse;
import fr.nessar.lmnporganizer.backend.dto.LeaseRequest;
import fr.nessar.lmnporganizer.backend.dto.LeaseResponse;
import fr.nessar.lmnporganizer.backend.exception.LeaseConflictException;
import fr.nessar.lmnporganizer.backend.exception.LeaseValidationException;
import fr.nessar.lmnporganizer.backend.mapper.LeaseMapper;
import fr.nessar.lmnporganizer.backend.model.Apartment;
import fr.nessar.lmnporganizer.backend.model.Lease;
import fr.nessar.lmnporganizer.backend.model.Landlord;
import fr.nessar.lmnporganizer.backend.model.LeaseStatus;
import fr.nessar.lmnporganizer.backend.model.RentDue;
import fr.nessar.lmnporganizer.backend.model.Tenant;
import fr.nessar.lmnporganizer.backend.repository.ApartmentRepository;
import fr.nessar.lmnporganizer.backend.repository.LeaseRepository;
import fr.nessar.lmnporganizer.backend.repository.RentDueRepository;
import fr.nessar.lmnporganizer.backend.repository.TenantRepository;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
@@ -28,14 +34,16 @@ public class LeaseService {
private final LeasePdfService leasePdfService;
private final PhoneNumberNormalizer phoneNumberNormalizer;
private final LeaseMapper leaseMapper;
private final RentPaymentService rentPaymentService;
private final RentDueRepository rentDueRepository;
private final RentDueService rentDueService;
private final LandlordProfileService landlordProfileService;
private final RentalIntegrityLock rentalIntegrityLock;
public LeaseService(LeaseRepository leaseRepository, ApartmentRepository apartmentRepository,
TenantRepository tenantRepository, LeasePdfService leasePdfService,
PhoneNumberNormalizer phoneNumberNormalizer, LeaseMapper leaseMapper,
RentPaymentService rentPaymentService, LandlordProfileService landlordProfileService,
RentDueRepository rentDueRepository, RentDueService rentDueService,
LandlordProfileService landlordProfileService,
RentalIntegrityLock rentalIntegrityLock) {
this.leaseRepository = leaseRepository;
this.apartmentRepository = apartmentRepository;
@@ -43,7 +51,8 @@ public class LeaseService {
this.leasePdfService = leasePdfService;
this.phoneNumberNormalizer = phoneNumberNormalizer;
this.leaseMapper = leaseMapper;
this.rentPaymentService = rentPaymentService;
this.rentDueRepository = rentDueRepository;
this.rentDueService = rentDueService;
this.landlordProfileService = landlordProfileService;
this.rentalIntegrityLock = rentalIntegrityLock;
}
@@ -66,15 +75,12 @@ public class LeaseService {
private ResponseEntity<LeaseResponse> createLease(String landlordId, LeaseRequest request) {
Lease lease = leaseMapper.toEntity(request);
if (!referencesExist(landlordId, lease)) {
return ResponseEntity.badRequest().build();
}
if (hasOverlappingLease(landlordId, lease, null)) {
return ResponseEntity.status(HttpStatus.CONFLICT).build();
throw invalidReferences();
}
lease.setLandlordId(landlordId);
lease.setStatus(LeaseStatus.DRAFT);
normalizeGuarantorPhones(lease);
lease = leaseRepository.save(lease);
rentPaymentService.generateForLease(lease);
return ResponseEntity.status(HttpStatus.CREATED).body(leaseMapper.toResponse(lease));
}
@@ -87,12 +93,10 @@ public class LeaseService {
if (lease == null) {
return ResponseEntity.notFound().build();
}
requireDraft(lease, "modifié");
Lease details = leaseMapper.toEntity(request);
if (!referencesExist(landlordId, details)) {
return ResponseEntity.badRequest().build();
}
if (hasOverlappingLease(landlordId, details, id)) {
return ResponseEntity.status(HttpStatus.CONFLICT).build();
throw invalidReferences();
}
leaseMapper.update(request, lease);
lease.setLandlordId(landlordId);
@@ -105,15 +109,73 @@ public class LeaseService {
}
private ResponseEntity<Void> deleteLease(String landlordId, String id) {
if (leaseRepository.findByIdAndLandlordId(id, landlordId).isEmpty()) {
Lease lease = leaseRepository.findByIdAndLandlordId(id, landlordId).orElse(null);
if (lease == null) {
return ResponseEntity.notFound().build();
}
rentPaymentService.deleteForLease(id);
requireDraft(lease, "supprimé");
return leaseRepository.deleteByIdAndLandlordId(id, landlordId) == 1
? ResponseEntity.noContent().build()
: ResponseEntity.notFound().build();
}
public ResponseEntity<LeaseActivationResponse> activate(String landlordId, String id) {
return rentalIntegrityLock.execute(() -> activateLease(landlordId, id));
}
private ResponseEntity<LeaseActivationResponse> activateLease(String landlordId, String id) {
Lease lease = leaseRepository.findByIdAndLandlordId(id, landlordId).orElse(null);
if (lease == null) {
return ResponseEntity.notFound().build();
}
if (lease.getStatus() != LeaseStatus.DRAFT) {
throw new LeaseConflictException("Seul un bail en brouillon peut être activé.");
}
// ponytail: retry reconciliation is the single-instance ceiling; use a Mongo transaction before
// production or multiple application instances.
rentDueRepository.deleteAllByLeaseId(id);
if (!referencesExist(landlordId, lease)) {
throw invalidReferences();
}
lease.activate();
if (hasBlockingOverlap(landlordId, lease)) {
lease.setStatus(LeaseStatus.DRAFT);
throw new LeaseConflictException(
"Ce bail chevauche un bail actif ou archivé pour cet appartement.");
}
List<RentDue> dues = RentDue.forLease(lease);
try {
dues = rentDueRepository.saveAll(dues);
lease = leaseRepository.save(lease);
} catch (RuntimeException failure) {
recoverFailedActivation(landlordId, id, lease, failure);
throw failure;
}
return ResponseEntity.ok(new LeaseActivationResponse(leaseMapper.toResponse(lease),
rentDueService.toResponses(dues)));
}
private void recoverFailedActivation(String landlordId, String id, Lease lease, RuntimeException failure) {
Lease persisted;
try {
persisted = leaseRepository.findByIdAndLandlordId(id, landlordId).orElse(null);
} catch (RuntimeException statusFailure) {
failure.addSuppressed(statusFailure);
return;
}
if (persisted != null && (persisted.getStatus() == LeaseStatus.ACTIVE
|| persisted.getStatus() == LeaseStatus.ARCHIVED)) {
return;
}
lease.setStatus(LeaseStatus.DRAFT);
try {
rentDueRepository.deleteAllByLeaseId(id);
} catch (RuntimeException cleanupFailure) {
failure.addSuppressed(cleanupFailure);
}
}
public ResponseEntity<byte[]> downloadPdf(String landlordId, String id, String oidcSubject) {
Lease lease = leaseRepository.findByIdAndLandlordId(id, landlordId).orElse(null);
if (lease == null) {
@@ -151,18 +213,25 @@ public class LeaseService {
guarantor.setPhone(phoneNumberNormalizer.normalize(guarantor.getPhone())));
}
private boolean hasOverlappingLease(String landlordId, Lease newLease, String excludeId) {
if (newLease.getStartDate() == null || newLease.getEndDate() == null) {
return false;
}
List<Lease> existingLeases = excludeId == null
? leaseRepository.findAllByLandlordIdAndApartmentId(landlordId, newLease.getApartmentId())
: leaseRepository.findAllByLandlordIdAndApartmentIdAndIdNot(landlordId,
newLease.getApartmentId(), excludeId);
private boolean hasBlockingOverlap(String landlordId, Lease lease) {
return leaseRepository.findAllByLandlordIdAndApartmentIdAndIdNot(
landlordId, lease.getApartmentId(), lease.getId()).stream()
.filter(existing -> existing.getStatus() == LeaseStatus.ACTIVE
|| existing.getStatus() == LeaseStatus.ARCHIVED)
.anyMatch(existing -> existing.getStartDate() != null && existing.getEndDate() != null
&& !lease.getStartDate().isAfter(existing.getEndDate())
&& !lease.getEndDate().isBefore(existing.getStartDate()));
}
return existingLeases.stream().anyMatch(existing ->
existing.getStartDate() != null && existing.getEndDate() != null
&& !newLease.getStartDate().isAfter(existing.getEndDate())
&& !newLease.getEndDate().isBefore(existing.getStartDate()));
private void requireDraft(Lease lease, String operation) {
if (lease.getStatus() != LeaseStatus.DRAFT) {
throw new LeaseConflictException("Seul un bail en brouillon peut être " + operation
+ ". Conservez l'historique du bail actif ou archivé.");
}
}
private LeaseValidationException invalidReferences() {
return new LeaseValidationException(
"L'appartement et les locataires doivent appartenir au portefeuille du bailleur.");
}
}
@@ -0,0 +1,51 @@
package fr.nessar.lmnporganizer.backend.service;
import fr.nessar.lmnporganizer.backend.dto.RentDueResponse;
import fr.nessar.lmnporganizer.backend.model.RentDue;
import fr.nessar.lmnporganizer.backend.model.RentDueSettlement;
import fr.nessar.lmnporganizer.backend.model.RentDueTiming;
import fr.nessar.lmnporganizer.backend.model.LeaseStatus;
import fr.nessar.lmnporganizer.backend.repository.LeaseRepository;
import fr.nessar.lmnporganizer.backend.repository.RentDueRepository;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.Clock;
import java.time.LocalDate;
import java.util.List;
import java.util.Set;
@Service
public class RentDueService {
private final RentDueRepository rentDueRepository;
private final LeaseRepository leaseRepository;
private final Clock clock;
public RentDueService(RentDueRepository rentDueRepository, LeaseRepository leaseRepository, Clock clock) {
this.rentDueRepository = rentDueRepository;
this.leaseRepository = leaseRepository;
this.clock = clock;
}
public List<RentDueResponse> getAll(String landlordId) {
Set<String> visibleLeaseIds = leaseRepository.findByLandlordId(landlordId).stream()
.filter(lease -> lease.getStatus() == LeaseStatus.ACTIVE || lease.getStatus() == LeaseStatus.ARCHIVED)
.map(lease -> lease.getId())
.collect(java.util.stream.Collectors.toSet());
return toResponses(rentDueRepository.findAllByLandlordIdOrderByDueDateAsc(landlordId).stream()
.filter(due -> visibleLeaseIds.contains(due.getLeaseId()))
.toList());
}
public List<RentDueResponse> toResponses(List<RentDue> dues) {
LocalDate today = LocalDate.now(clock);
return dues.stream().map(due -> toResponse(due, today)).toList();
}
private RentDueResponse toResponse(RentDue due, LocalDate today) {
BigDecimal total = due.getRent().add(due.getCharges());
RentDueTiming timing = due.getDueDate().isBefore(today) ? RentDueTiming.OVERDUE : RentDueTiming.UPCOMING;
return new RentDueResponse(due.getId(), due.getLeaseId(), due.getMonth(), due.getDueDate(), due.getRent(),
due.getCharges(), total, total, RentDueSettlement.UNPAID, timing);
}
}
@@ -10,7 +10,6 @@ import fr.nessar.lmnporganizer.backend.repository.ApartmentRepository;
import fr.nessar.lmnporganizer.backend.repository.LandlordRepository;
import fr.nessar.lmnporganizer.backend.repository.LeaseRepository;
import fr.nessar.lmnporganizer.backend.repository.TenantRepository;
import fr.nessar.lmnporganizer.backend.service.RentPaymentService;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.text.PDFTextStripper;
@@ -53,9 +52,6 @@ class LeaseControllerTest {
@MockitoBean
private LandlordRepository landlordRepository;
@MockitoBean
private RentPaymentService rentPaymentService;
@BeforeEach
void authenticateLandlord() {
when(landlordRepository.findByOidcSubject(anyString())).thenReturn(Optional.of(landlord()));
@@ -79,6 +75,7 @@ class LeaseControllerTest {
.content(leaseJson()))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value("lease-1"))
.andExpect(jsonPath("$.status").value("DRAFT"))
.andExpect(jsonPath("$.landlordId").doesNotExist())
.andExpect(jsonPath("$.apartmentId").value("apartment-1"))
.andExpect(jsonPath("$.tenantIds[0]").value("tenant-1"))
@@ -148,7 +145,6 @@ class LeaseControllerTest {
mockMvc.perform(delete("/api/leases/lease-1").with(oidcLogin()))
.andExpect(status().isNoContent());
verify(rentPaymentService).deleteForLease("lease-1");
verify(leaseRepository).deleteByIdAndLandlordId("lease-1", "landlord-1");
}
@@ -259,7 +255,7 @@ class LeaseControllerTest {
return new Lease("lease-1", "landlord-1", "apartment-1", List.of("tenant-1"),
List.of(new Guarantor("guarantor-1", "Alex", "Durand", "alex@example.com", "+33600000000",
"5 rue Victor Hugo")), LocalDate.of(2026, 9, 1), LocalDate.of(2027, 8, 31),
new BigDecimal("850.00"), new BigDecimal("50.00"), new BigDecimal("850.00"), LeaseStatus.ACTIVE);
new BigDecimal("850.00"), new BigDecimal("50.00"), new BigDecimal("850.00"), LeaseStatus.DRAFT);
}
private String leaseJson() {
@@ -278,8 +274,7 @@ class LeaseControllerTest {
"endDate": "2027-08-31",
"monthlyRent": 850.00,
"monthlyCharges": 50.00,
"securityDeposit": 850.00,
"status": "ACTIVE"
"securityDeposit": 850.00
}
""";
}
@@ -0,0 +1,349 @@
package fr.nessar.lmnporganizer.backend.integration;
import fr.nessar.lmnporganizer.backend.BackendApplication;
import fr.nessar.lmnporganizer.backend.model.Landlord;
import fr.nessar.lmnporganizer.backend.model.Lease;
import fr.nessar.lmnporganizer.backend.model.LeaseStatus;
import fr.nessar.lmnporganizer.backend.repository.ApartmentRepository;
import fr.nessar.lmnporganizer.backend.repository.LandlordRepository;
import fr.nessar.lmnporganizer.backend.repository.LeaseRepository;
import fr.nessar.lmnporganizer.backend.repository.RentDueRepository;
import fr.nessar.lmnporganizer.backend.repository.RentPaymentRepository;
import fr.nessar.lmnporganizer.backend.repository.TenantRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
import org.springframework.http.MediaType;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.RequestPostProcessor;
import org.testcontainers.mongodb.MongoDBContainer;
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.stream.Stream;
import static com.jayway.jsonpath.JsonPath.read;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oidcLogin;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest(classes = {BackendApplication.class, LeaseActivationApiIT.FixedClockConfiguration.class},
properties = {"lmnp.data-initializer.enabled=false", "spring.data.mongodb.auto-index-creation=true"})
@AutoConfigureMockMvc
class LeaseActivationApiIT {
private static final MongoDBContainer mongoDb = startMongoDb();
@Autowired
private MockMvc mockMvc;
@Autowired
private ApartmentRepository apartmentRepository;
@Autowired
private TenantRepository tenantRepository;
@Autowired
private LeaseRepository leaseRepository;
@Autowired
private RentDueRepository rentDueRepository;
@Autowired
private RentPaymentRepository rentPaymentRepository;
@Autowired
private LandlordRepository landlordRepository;
@DynamicPropertySource
static void configureMongo(DynamicPropertyRegistry registry) {
if (mongoDb != null) {
registry.add("spring.mongodb.uri", () -> mongoDb.getConnectionString() + "/lmnp-lease-activation");
}
}
@BeforeEach
void setUp() {
rentDueRepository.deleteAll();
rentPaymentRepository.deleteAll();
leaseRepository.deleteAll();
apartmentRepository.deleteAll();
tenantRepository.deleteAll();
landlordRepository.deleteAll();
landlordRepository.save(new Landlord("landlord-alice", "subject-alice", "Alice", "Martin",
"alice@example.com", "+33600000000", "1 rue de Paris"));
landlordRepository.save(new Landlord("landlord-bob", "subject-bob", "Bob", "Durand",
"bob@example.com", "+33600000001", "2 rue de Lyon"));
}
@Test
void draftJourneyAllowsOverlapAndActivationCreatesOnlyRentDues() throws Exception {
String apartmentId = createApartment(as("subject-alice"));
String tenantId = createTenant(as("subject-alice"));
String firstDraft = createDraft(apartmentId, List.of(tenantId), "2028-02-29", "2028-04-10",
"900.00", "50.00", "900.00", as("subject-alice"));
String overlappingDraft = createDraft(apartmentId, List.of(tenantId), "2028-03-01", "2028-03-31",
"800.00", "40.00", "800.00", as("subject-alice"));
mockMvc.perform(get("/api/rent-dues").with(as("subject-alice")))
.andExpect(status().isOk()).andExpect(jsonPath("$").isEmpty());
mockMvc.perform(get("/api/rent-payments").with(as("subject-alice")))
.andExpect(status().isOk()).andExpect(jsonPath("$").isEmpty());
mockMvc.perform(put("/api/leases/{id}", firstDraft).with(as("subject-alice"))
.contentType(MediaType.APPLICATION_JSON)
.content(leaseJson(apartmentId, List.of(tenantId), "2028-02-29", "2028-04-10",
"925.00", "50.00", "900.00")))
.andExpect(status().isOk()).andExpect(jsonPath("$.monthlyRent").value(925.00));
mockMvc.perform(delete("/api/leases/{id}", overlappingDraft).with(as("subject-alice")))
.andExpect(status().isNoContent());
mockMvc.perform(post("/api/leases/{id}/activate", firstDraft).with(as("subject-alice")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.lease.status").value("ACTIVE"))
.andExpect(jsonPath("$.rentDues.length()").value(3))
.andExpect(jsonPath("$.rentDues[0].month").value("2028-02"))
.andExpect(jsonPath("$.rentDues[0].rent").value(31.90))
.andExpect(jsonPath("$.rentDues[0].charges").value(1.72))
.andExpect(jsonPath("$.rentDues[2].rent").value(308.33))
.andExpect(jsonPath("$.rentDues[2].charges").value(16.67));
mockMvc.perform(get("/api/rent-dues").with(as("subject-alice")))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].dueDate").value("2028-03-05"))
.andExpect(jsonPath("$[0].settlement").value("UNPAID"))
.andExpect(jsonPath("$[0].timing").value("UPCOMING"))
.andExpect(jsonPath("$[0].remainingAmount").value(33.62));
assertThat(rentPaymentRepository.count()).isZero();
String update = leaseJson(apartmentId, List.of(tenantId), "2028-02-29", "2028-04-10",
"950.00", "50.00", "900.00");
mockMvc.perform(put("/api/leases/{id}", firstDraft).with(as("subject-alice"))
.contentType(MediaType.APPLICATION_JSON).content(update))
.andExpect(status().isConflict()).andExpect(jsonPath("$.message", containsString("brouillon")));
mockMvc.perform(delete("/api/leases/{id}", firstDraft).with(as("subject-alice")))
.andExpect(status().isConflict()).andExpect(jsonPath("$.message", containsString("brouillon")));
mockMvc.perform(post("/api/leases/{id}/activate", firstDraft).with(as("subject-alice")))
.andExpect(status().isConflict());
assertThat(rentDueRepository.findAll()).hasSize(3);
}
@ParameterizedTest(name = "activation rejects {0}")
@MethodSource("invalidDrafts")
void activationValidatesEveryContractInvariant(String description, InvalidDraft invalid) throws Exception {
String apartmentId = createApartment(as("subject-alice"));
String tenantId = createTenant(as("subject-alice"));
String leaseId;
if (invalid.persistDirectly()) {
leaseId = leaseRepository.save(new Lease(null, "landlord-alice", invalid.apartmentId(apartmentId),
invalid.tenantIds(tenantId), List.of(), invalid.startDate(), invalid.endDate(), invalid.rent(),
invalid.charges(), invalid.deposit(), LeaseStatus.DRAFT)).getId();
} else {
leaseId = createDraft(apartmentId, invalid.tenantIds(tenantId), date(invalid.startDate()),
date(invalid.endDate()), decimal(invalid.rent()), decimal(invalid.charges()),
decimal(invalid.deposit()), as("subject-alice"));
}
mockMvc.perform(post("/api/leases/{id}/activate", leaseId).with(as("subject-alice")))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message").isNotEmpty());
assertThat(leaseRepository.findById(leaseId).orElseThrow().getStatus()).isEqualTo(LeaseStatus.DRAFT);
assertThat(rentDueRepository.findAll()).isEmpty();
}
@Test
void activeAndArchivedInclusiveOverlapsBlockActivation() throws Exception {
String apartmentId = createApartment(as("subject-alice"));
String tenantId = createTenant(as("subject-alice"));
leaseRepository.save(lease("landlord-alice", apartmentId, tenantId,
LocalDate.of(2026, 1, 1), LocalDate.of(2026, 1, 31), LeaseStatus.ACTIVE));
Lease archived = leaseRepository.save(lease("landlord-alice", apartmentId, tenantId,
LocalDate.of(2026, 3, 1), LocalDate.of(2026, 3, 31), LeaseStatus.ARCHIVED));
for (String leaseId : List.of(
createDraft(apartmentId, List.of(tenantId), "2026-01-31", "2026-02-10", "900", "50", "900",
as("subject-alice")),
createDraft(apartmentId, List.of(tenantId), "2026-02-20", "2026-03-01", "900", "50", "900",
as("subject-alice")))) {
mockMvc.perform(post("/api/leases/{id}/activate", leaseId).with(as("subject-alice")))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.message", containsString("chevauche")));
}
String archivedUpdate = leaseJson(apartmentId, List.of(tenantId), "2026-03-01", "2026-03-31",
"950", "50", "900");
mockMvc.perform(put("/api/leases/{id}", archived.getId()).with(as("subject-alice"))
.contentType(MediaType.APPLICATION_JSON).content(archivedUpdate))
.andExpect(status().isConflict());
mockMvc.perform(delete("/api/leases/{id}", archived.getId()).with(as("subject-alice")))
.andExpect(status().isConflict());
assertThat(rentDueRepository.findAll()).isEmpty();
}
@Test
void leaseCommandsAndRentDuesArePortfolioIsolated() throws Exception {
String apartmentId = createApartment(as("subject-alice"));
String tenantId = createTenant(as("subject-alice"));
String leaseId = createDraft(apartmentId, List.of(tenantId), "2026-01-01", "2026-01-31",
"900", "50", "900", as("subject-alice"));
mockMvc.perform(post("/api/leases/{id}/activate", leaseId).with(as("subject-bob")))
.andExpect(status().isNotFound());
mockMvc.perform(put("/api/leases/{id}", leaseId).with(as("subject-bob"))
.contentType(MediaType.APPLICATION_JSON)
.content(leaseJson(apartmentId, List.of(tenantId), "2026-01-01", "2026-01-31",
"900", "50", "900")))
.andExpect(status().isNotFound());
mockMvc.perform(delete("/api/leases/{id}", leaseId).with(as("subject-bob")))
.andExpect(status().isNotFound());
mockMvc.perform(post("/api/leases/{id}/activate", leaseId).with(as("subject-alice")))
.andExpect(status().isOk());
mockMvc.perform(get("/api/rent-dues").with(as("subject-bob")))
.andExpect(status().isOk()).andExpect(jsonPath("$").isEmpty());
mockMvc.perform(get("/api/rent-dues").with(as("subject-alice")))
.andExpect(status().isOk()).andExpect(jsonPath("$.length()").value(1));
}
@Test
void leaseRequestDoesNotAcceptLifecycleStatus() throws Exception {
String apartmentId = createApartment(as("subject-alice"));
String tenantId = createTenant(as("subject-alice"));
String request = leaseJson(apartmentId, List.of(tenantId), "2026-01-01", "2026-01-31",
"900", "50", "900").replace("}", ",\"status\":\"ACTIVE\"}");
mockMvc.perform(post("/api/leases").with(as("subject-alice"))
.contentType(MediaType.APPLICATION_JSON).content(request))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message", containsString("status")));
assertThat(leaseRepository.findAll()).isEmpty();
}
private static Stream<org.junit.jupiter.params.provider.Arguments> invalidDrafts() {
return Stream.of(
invalid("dates inversées", LocalDate.of(2026, 2, 1), LocalDate.of(2026, 1, 31),
new BigDecimal("900"), BigDecimal.ZERO, BigDecimal.ZERO, false, false, false),
invalid("loyer nul", LocalDate.of(2026, 1, 1), LocalDate.of(2026, 1, 31),
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, false, false, false),
invalid("charges négatives", LocalDate.of(2026, 1, 1), LocalDate.of(2026, 1, 31),
new BigDecimal("900"), new BigDecimal("-0.01"), BigDecimal.ZERO, false, false, false),
invalid("dépôt négatif", LocalDate.of(2026, 1, 1), LocalDate.of(2026, 1, 31),
new BigDecimal("900"), BigDecimal.ZERO, new BigDecimal("-0.01"), false, false, false),
invalid("locataires vides", LocalDate.of(2026, 1, 1), LocalDate.of(2026, 1, 31),
new BigDecimal("900"), BigDecimal.ZERO, BigDecimal.ZERO, true, false, true),
invalid("locataires en doublon", LocalDate.of(2026, 1, 1), LocalDate.of(2026, 1, 31),
new BigDecimal("900"), BigDecimal.ZERO, BigDecimal.ZERO, false, true, false),
invalid("référence étrangère", LocalDate.of(2026, 1, 1), LocalDate.of(2026, 1, 31),
new BigDecimal("900"), BigDecimal.ZERO, BigDecimal.ZERO, false, false, true));
}
private static org.junit.jupiter.params.provider.Arguments invalid(String description, LocalDate start,
LocalDate end, BigDecimal rent, BigDecimal charges, BigDecimal deposit, boolean emptyTenants,
boolean duplicateTenants, boolean direct) {
return org.junit.jupiter.params.provider.Arguments.of(description,
new InvalidDraft(start, end, rent, charges, deposit, emptyTenants, duplicateTenants, direct));
}
private String createApartment(RequestPostProcessor principal) throws Exception {
String response = mockMvc.perform(post("/api/apartments").with(principal)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name":"Studio","address":"1 rue de Paris","purchasePrice":150000,"description":""}
"""))
.andExpect(status().isCreated()).andReturn().getResponse().getContentAsString();
return read(response, "$.id");
}
private String createTenant(RequestPostProcessor principal) throws Exception {
String response = mockMvc.perform(post("/api/tenants").with(principal)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"firstName":"Camille","lastName":"Martin","email":"camille@example.com","phone":"0612345678","address":"1 rue de Lyon"}
"""))
.andExpect(status().isCreated()).andReturn().getResponse().getContentAsString();
return read(response, "$.id");
}
private String createDraft(String apartmentId, List<String> tenantIds, String start, String end, String rent,
String charges, String deposit, RequestPostProcessor principal) throws Exception {
String response = mockMvc.perform(post("/api/leases").with(principal)
.contentType(MediaType.APPLICATION_JSON)
.content(leaseJson(apartmentId, tenantIds, start, end, rent, charges, deposit)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.status").value("DRAFT"))
.andReturn().getResponse().getContentAsString();
return read(response, "$.id");
}
private String leaseJson(String apartmentId, List<String> tenantIds, String start, String end, String rent,
String charges, String deposit) {
return """
{"apartmentId":"%s","tenantIds":%s,"guarantors":[],"startDate":"%s","endDate":"%s","monthlyRent":%s,"monthlyCharges":%s,"securityDeposit":%s}
""".formatted(apartmentId, jsonArray(tenantIds), start, end, rent, charges, deposit);
}
private String jsonArray(List<String> values) {
return values.stream().map(value -> "\"" + value + "\"").toList().toString();
}
private Lease lease(String landlordId, String apartmentId, String tenantId, LocalDate start, LocalDate end,
LeaseStatus status) {
return new Lease(null, landlordId, apartmentId, List.of(tenantId), List.of(), start, end,
new BigDecimal("900"), new BigDecimal("50"), new BigDecimal("900"), status);
}
private static String date(LocalDate value) {
return value == null ? "null" : value.toString();
}
private static String decimal(BigDecimal value) {
return value == null ? "null" : value.toPlainString();
}
private static RequestPostProcessor as(String subject) {
return oidcLogin().idToken(token -> token.claim("sub", subject));
}
private static MongoDBContainer startMongoDb() {
if (System.getenv("SPRING_DATA_MONGODB_URI") != null) {
return null;
}
MongoDBContainer container = new MongoDBContainer("mongo:8.0");
container.start();
return container;
}
private record InvalidDraft(LocalDate startDate, LocalDate endDate, BigDecimal rent, BigDecimal charges,
BigDecimal deposit, boolean emptyTenants, boolean duplicateTenants,
boolean persistDirectly) {
List<String> tenantIds(String tenantId) {
if (emptyTenants) {
return List.of();
}
return duplicateTenants ? List.of(tenantId, tenantId) : List.of(tenantId);
}
String apartmentId(String realApartmentId) {
return persistDirectly && !emptyTenants ? "foreign-apartment" : realApartmentId;
}
}
@TestConfiguration
static class FixedClockConfiguration {
@Bean
@Primary
Clock fixedClock() {
return Clock.fixed(Instant.parse("2026-07-18T10:00:00Z"), ZoneOffset.UTC);
}
}
}
@@ -167,7 +167,7 @@ class ReferencedRentalResourcesApiIT {
String apartmentId = createApartment(as("subject-alice"));
String tenantId = createTenant(as("subject-alice"));
String leaseId = createLease(apartmentId, tenantId, LeaseStatus.DRAFT, as("subject-alice"));
String updatedLease = leaseJson(apartmentId, tenantId, LeaseStatus.DRAFT)
String updatedLease = leaseJson(apartmentId, tenantId)
.replace("\"monthlyRent\":900.00", "\"monthlyRent\":925.00");
mockMvc.perform(put("/api/leases/{id}", leaseId)
@@ -287,11 +287,19 @@ class ReferencedRentalResourcesApiIT {
String response = mockMvc.perform(post("/api/leases")
.with(principal)
.contentType(MediaType.APPLICATION_JSON)
.content(leaseJson(apartmentId, tenantId, status)))
.content(leaseJson(apartmentId, tenantId)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.status").value(status.name()))
.andReturn().getResponse().getContentAsString();
return read(response, "$.id");
String leaseId = read(response, "$.id");
if (status == LeaseStatus.ACTIVE) {
mockMvc.perform(post("/api/leases/{id}/activate", leaseId).with(principal))
.andExpect(status().isOk());
} else if (status == LeaseStatus.ARCHIVED) {
Lease lease = leaseRepository.findById(leaseId).orElseThrow();
lease.setStatus(LeaseStatus.ARCHIVED);
leaseRepository.save(lease);
}
return leaseId;
}
private String rejectedLeaseCreate(String apartmentId, String tenantId, RequestPostProcessor principal)
@@ -299,7 +307,7 @@ class ReferencedRentalResourcesApiIT {
return mockMvc.perform(post("/api/leases")
.with(principal)
.contentType(MediaType.APPLICATION_JSON)
.content(leaseJson(apartmentId, tenantId, LeaseStatus.DRAFT)))
.content(leaseJson(apartmentId, tenantId)))
.andExpect(status().isBadRequest())
.andReturn().getResponse().getContentAsString();
}
@@ -308,12 +316,12 @@ class ReferencedRentalResourcesApiIT {
return mockMvc.perform(put("/api/leases/{id}", leaseId)
.with(as("subject-bob"))
.contentType(MediaType.APPLICATION_JSON)
.content(leaseJson(apartmentId, tenantId, LeaseStatus.DRAFT)))
.content(leaseJson(apartmentId, tenantId)))
.andExpect(status().isBadRequest())
.andReturn().getResponse().getContentAsString();
}
private String leaseJson(String apartmentId, String tenantId, LeaseStatus status) {
private String leaseJson(String apartmentId, String tenantId) {
return """
{
"apartmentId":"%s",
@@ -323,10 +331,9 @@ class ReferencedRentalResourcesApiIT {
"endDate":"2026-12-31",
"monthlyRent":900.00,
"monthlyCharges":50.00,
"securityDeposit":900.00,
"status":"%s"
"securityDeposit":900.00
}
""".formatted(apartmentId, tenantId, status);
""".formatted(apartmentId, tenantId);
}
private void saveInconsistentLease(String landlordId, String apartmentId, String tenantId, LeaseStatus status) {
@@ -51,13 +51,13 @@ class UpdateMapperTest {
BigDecimal.ONE, LeaseStatus.DRAFT);
LeaseRequest source = new LeaseRequest("new-apartment", List.of("tenant-1"), List.of(),
LocalDate.of(2026, 2, 1), LocalDate.of(2027, 2, 1), BigDecimal.TEN, BigDecimal.ONE,
BigDecimal.TEN, LeaseStatus.ACTIVE);
BigDecimal.TEN);
Mappers.getMapper(LeaseMapper.class).update(source, target);
assertEquals("lease-1", target.getId());
assertEquals("default-landlord", target.getLandlordId());
assertEquals("new-apartment", target.getApartmentId());
assertEquals(LeaseStatus.ACTIVE, target.getStatus());
assertEquals(LeaseStatus.DRAFT, target.getStatus());
}
}
@@ -0,0 +1,54 @@
package fr.nessar.lmnporganizer.backend.model;
import fr.nessar.lmnporganizer.backend.exception.LeaseValidationException;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class LeaseActivationTest {
@Test
void aValidDraftCanBeActivatedOnlyOnce() {
Lease lease = lease();
lease.activate();
assertEquals(LeaseStatus.ACTIVE, lease.getStatus());
assertThrows(LeaseValidationException.class, lease::activate);
}
@Test
void activationRejectsInvalidContractTerms() {
Lease lease = lease();
lease.setStartDate(LocalDate.of(2026, 2, 1));
lease.setEndDate(LocalDate.of(2026, 1, 31));
assertThrows(LeaseValidationException.class, lease::activate);
lease = lease();
lease.setMonthlyRent(BigDecimal.ZERO);
assertThrows(LeaseValidationException.class, lease::activate);
lease = lease();
lease.setMonthlyCharges(new BigDecimal("-0.01"));
assertThrows(LeaseValidationException.class, lease::activate);
lease = lease();
lease.setSecurityDeposit(new BigDecimal("-0.01"));
assertThrows(LeaseValidationException.class, lease::activate);
lease = lease();
lease.setTenantIds(List.of("tenant-1", "tenant-1"));
assertThrows(LeaseValidationException.class, lease::activate);
}
private Lease lease() {
return new Lease("lease-1", "landlord-1", "apartment-1", List.of("tenant-1"), List.of(),
LocalDate.of(2026, 1, 1), LocalDate.of(2026, 12, 31), new BigDecimal("900.00"),
new BigDecimal("50.00"), new BigDecimal("900.00"), LeaseStatus.DRAFT);
}
}
@@ -0,0 +1,39 @@
package fr.nessar.lmnporganizer.backend.model;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
class RentDueTest {
@Test
void generatesInclusiveBoundaryMonthsWithCalendarDayProration() {
List<RentDue> dues = RentDue.forLease(lease(LocalDate.of(2028, 2, 29), LocalDate.of(2028, 4, 10)));
assertEquals(List.of("2028-02", "2028-03", "2028-04"), dues.stream().map(RentDue::getMonth).toList());
assertEquals(new BigDecimal("31.03"), dues.get(0).getRent());
assertEquals(new BigDecimal("1.72"), dues.get(0).getCharges());
assertEquals(LocalDate.of(2028, 3, 5), dues.get(0).getDueDate());
assertEquals(new BigDecimal("900.00"), dues.get(1).getRent());
assertEquals(new BigDecimal("300.00"), dues.get(2).getRent());
assertEquals(new BigDecimal("16.67"), dues.get(2).getCharges());
}
@Test
void proratesALeaseContainedInOneMonthOnce() {
RentDue due = RentDue.forLease(lease(LocalDate.of(2026, 1, 10), LocalDate.of(2026, 1, 20))).getFirst();
assertEquals(new BigDecimal("319.35"), due.getRent());
assertEquals(new BigDecimal("17.74"), due.getCharges());
assertEquals(LocalDate.of(2026, 2, 5), due.getDueDate());
}
private Lease lease(LocalDate start, LocalDate end) {
return new Lease("lease-1", "landlord-1", "apartment-1", List.of("tenant-1"), List.of(), start, end,
new BigDecimal("900.00"), new BigDecimal("50.00"), new BigDecimal("900.00"), LeaseStatus.ACTIVE);
}
}
@@ -0,0 +1,165 @@
package fr.nessar.lmnporganizer.backend.service;
import fr.nessar.lmnporganizer.backend.dto.LeaseActivationResponse;
import fr.nessar.lmnporganizer.backend.mapper.LeaseMapper;
import fr.nessar.lmnporganizer.backend.model.Apartment;
import fr.nessar.lmnporganizer.backend.model.Lease;
import fr.nessar.lmnporganizer.backend.model.LeaseStatus;
import fr.nessar.lmnporganizer.backend.model.Tenant;
import fr.nessar.lmnporganizer.backend.repository.ApartmentRepository;
import fr.nessar.lmnporganizer.backend.repository.LeaseRepository;
import fr.nessar.lmnporganizer.backend.repository.RentDueRepository;
import fr.nessar.lmnporganizer.backend.repository.TenantRepository;
import org.junit.jupiter.api.Test;
import org.mapstruct.factory.Mappers;
import org.springframework.http.ResponseEntity;
import java.math.BigDecimal;
import java.time.Clock;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class LeaseActivationCompensationTest {
@Test
void failedRecoveryCleanupCanBeRetriedIntoOneCoherentActivation() {
Fixture fixture = fixture();
Lease persistedDraft = lease();
when(fixture.leases.findByIdAndLandlordId("lease-1", "landlord-1"))
.thenReturn(Optional.of(fixture.lease), Optional.of(persistedDraft), Optional.of(fixture.lease));
AtomicInteger deleteAttempts = new AtomicInteger();
AtomicInteger persistedDueCount = new AtomicInteger();
doAnswer(invocation -> {
if (deleteAttempts.incrementAndGet() == 2) {
throw new IllegalStateException("cleanup failure");
}
persistedDueCount.set(0);
return null;
}).when(fixture.dues).deleteAllByLeaseId("lease-1");
when(fixture.dues.saveAll(any())).thenAnswer(invocation -> {
List<?> saved = invocation.getArgument(0);
persistedDueCount.set(saved.size());
return saved;
});
when(fixture.leases.save(fixture.lease))
.thenThrow(new IllegalStateException("database failure"))
.thenAnswer(invocation -> invocation.getArgument(0));
assertThrows(IllegalStateException.class,
() -> fixture.service.activate("landlord-1", "lease-1"));
assertEquals(1, persistedDueCount.get());
ResponseEntity<LeaseActivationResponse> response = fixture.service.activate("landlord-1", "lease-1");
assertEquals(LeaseStatus.ACTIVE, fixture.lease.getStatus());
assertEquals(1, persistedDueCount.get());
assertNotNull(response.getBody());
assertEquals(1, response.getBody().rentDues().size());
verify(fixture.dues, times(3)).deleteAllByLeaseId("lease-1");
}
@Test
void ambiguousSuccessfulLeaseWriteKeepsGeneratedDues() {
Fixture fixture = fixture();
AtomicBoolean persistedDues = new AtomicBoolean();
doAnswer(invocation -> {
persistedDues.set(false);
return null;
}).when(fixture.dues).deleteAllByLeaseId("lease-1");
when(fixture.dues.saveAll(any())).thenAnswer(invocation -> {
persistedDues.set(true);
return invocation.getArgument(0);
});
when(fixture.leases.save(fixture.lease)).thenThrow(new IllegalStateException("ambiguous response"));
assertThrows(IllegalStateException.class,
() -> fixture.service.activate("landlord-1", "lease-1"));
assertTrue(persistedDues.get());
verify(fixture.dues).deleteAllByLeaseId("lease-1");
}
@Test
void unreadablePersistedStatusKeepsDuesForTheNextRetry() {
Fixture fixture = fixture();
when(fixture.leases.findByIdAndLandlordId("lease-1", "landlord-1"))
.thenReturn(Optional.of(fixture.lease))
.thenThrow(new IllegalStateException("read failure"));
AtomicBoolean persistedDues = new AtomicBoolean();
doAnswer(invocation -> {
persistedDues.set(false);
return null;
}).when(fixture.dues).deleteAllByLeaseId("lease-1");
when(fixture.dues.saveAll(any())).thenAnswer(invocation -> {
persistedDues.set(true);
return invocation.getArgument(0);
});
when(fixture.leases.save(fixture.lease)).thenThrow(new IllegalStateException("ambiguous response"));
IllegalStateException failure = assertThrows(IllegalStateException.class,
() -> fixture.service.activate("landlord-1", "lease-1"));
assertTrue(persistedDues.get());
assertEquals(1, failure.getSuppressed().length);
verify(fixture.dues).deleteAllByLeaseId("lease-1");
}
@Test
void failedLeasePersistenceRemovesGeneratedDuesAndKeepsTheDraft() {
Fixture fixture = fixture();
Lease persistedDraft = lease();
when(fixture.leases.findByIdAndLandlordId("lease-1", "landlord-1"))
.thenReturn(Optional.of(fixture.lease), Optional.of(persistedDraft));
when(fixture.dues.saveAll(any())).thenAnswer(invocation -> invocation.getArgument(0));
when(fixture.leases.save(fixture.lease)).thenThrow(new IllegalStateException("database failure"));
assertThrows(IllegalStateException.class,
() -> fixture.service.activate("landlord-1", "lease-1"));
assertEquals(LeaseStatus.DRAFT, fixture.lease.getStatus());
verify(fixture.dues, times(2)).deleteAllByLeaseId("lease-1");
}
private Fixture fixture() {
LeaseRepository leases = mock(LeaseRepository.class);
ApartmentRepository apartments = mock(ApartmentRepository.class);
TenantRepository tenants = mock(TenantRepository.class);
RentDueRepository dues = mock(RentDueRepository.class);
Lease lease = lease();
when(leases.findByIdAndLandlordId("lease-1", "landlord-1")).thenReturn(Optional.of(lease));
when(apartments.findByIdAndBailleurId("apartment-1", "landlord-1"))
.thenReturn(Optional.of(new Apartment()));
when(tenants.findAllByIdInAndLandlordId(List.of("tenant-1"), "landlord-1"))
.thenReturn(List.of(new Tenant()));
when(leases.findAllByLandlordIdAndApartmentIdAndIdNot("landlord-1", "apartment-1", "lease-1"))
.thenReturn(List.of());
LeaseMapper mapper = Mappers.getMapper(LeaseMapper.class);
RentDueService rentDueService = new RentDueService(dues, leases, Clock.systemUTC());
LeaseService service = new LeaseService(leases, apartments, tenants, mock(LeasePdfService.class),
mock(PhoneNumberNormalizer.class), mapper, dues, rentDueService,
mock(LandlordProfileService.class), new RentalIntegrityLock());
return new Fixture(leases, dues, lease, service);
}
private Lease lease() {
return new Lease("lease-1", "landlord-1", "apartment-1", List.of("tenant-1"), List.of(),
LocalDate.of(2026, 1, 1), LocalDate.of(2026, 1, 31), new BigDecimal("900.00"),
new BigDecimal("50.00"), new BigDecimal("900.00"), LeaseStatus.DRAFT);
}
private record Fixture(LeaseRepository leases, RentDueRepository dues, Lease lease, LeaseService service) {
}
}
@@ -0,0 +1,70 @@
package fr.nessar.lmnporganizer.backend.service;
import fr.nessar.lmnporganizer.backend.dto.RentDueResponse;
import fr.nessar.lmnporganizer.backend.model.RentDue;
import fr.nessar.lmnporganizer.backend.model.RentDueSettlement;
import fr.nessar.lmnporganizer.backend.model.RentDueTiming;
import fr.nessar.lmnporganizer.backend.model.Lease;
import fr.nessar.lmnporganizer.backend.model.LeaseStatus;
import fr.nessar.lmnporganizer.backend.repository.LeaseRepository;
import fr.nessar.lmnporganizer.backend.repository.RentDueRepository;
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 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.when;
class RentDueServiceTest {
@Test
void settlementAndTimingAreIndependent() {
RentDueRepository repository = mock(RentDueRepository.class);
LeaseRepository leases = mock(LeaseRepository.class);
when(repository.findAllByLandlordIdOrderByDueDateAsc("landlord-1")).thenReturn(List.of(
due("due-overdue", LocalDate.of(2026, 7, 17)),
due("due-today", LocalDate.of(2026, 7, 18))));
when(leases.findByLandlordId("landlord-1")).thenReturn(List.of(lease(LeaseStatus.ACTIVE)));
RentDueService service = new RentDueService(repository, leases,
Clock.fixed(Instant.parse("2026-07-18T10:00:00Z"), ZoneOffset.UTC));
List<RentDueResponse> responses = service.getAll("landlord-1");
assertEquals(RentDueSettlement.UNPAID, responses.get(0).settlement());
assertEquals(RentDueTiming.OVERDUE, responses.get(0).timing());
assertEquals(RentDueSettlement.UNPAID, responses.get(1).settlement());
assertEquals(RentDueTiming.UPCOMING, responses.get(1).timing());
assertEquals(new BigDecimal("950.00"), responses.get(0).total());
assertEquals(new BigDecimal("950.00"), responses.get(0).remainingAmount());
}
@Test
void duesBelongingToDraftsAreNotExposed() {
RentDueRepository repository = mock(RentDueRepository.class);
LeaseRepository leases = mock(LeaseRepository.class);
when(repository.findAllByLandlordIdOrderByDueDateAsc("landlord-1"))
.thenReturn(List.of(due("orphan-due", LocalDate.of(2026, 7, 17))));
when(leases.findByLandlordId("landlord-1")).thenReturn(List.of(lease(LeaseStatus.DRAFT)));
RentDueService service = new RentDueService(repository, leases, Clock.systemUTC());
assertTrue(service.getAll("landlord-1").isEmpty());
}
private RentDue due(String id, LocalDate dueDate) {
return new RentDue(id, "landlord-1", "lease-1", "2026-07", dueDate,
new BigDecimal("900.00"), new BigDecimal("50.00"));
}
private Lease lease(LeaseStatus status) {
return new Lease("lease-1", "landlord-1", "apartment-1", List.of("tenant-1"), List.of(),
LocalDate.of(2026, 1, 1), LocalDate.of(2026, 12, 31), new BigDecimal("900.00"),
new BigDecimal("50.00"), new BigDecimal("900.00"), status);
}
}
@@ -12,6 +12,7 @@ import fr.nessar.lmnporganizer.backend.model.Tenant;
import fr.nessar.lmnporganizer.backend.model.TenantAttachment;
import fr.nessar.lmnporganizer.backend.repository.ApartmentRepository;
import fr.nessar.lmnporganizer.backend.repository.LeaseRepository;
import fr.nessar.lmnporganizer.backend.repository.RentDueRepository;
import fr.nessar.lmnporganizer.backend.repository.TenantRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -58,7 +59,6 @@ class RentalIntegrityLockTest {
LeaseRepository leaseRepository = mock(LeaseRepository.class);
TenantRepository tenantRepository = mock(TenantRepository.class);
LeaseMapper leaseMapper = mock(LeaseMapper.class);
RentPaymentService rentPaymentService = mock(RentPaymentService.class);
RentalIntegrityLock integrityLock = new RentalIntegrityLock();
Apartment apartment = new Apartment(apartmentId, landlordId, "Studio", "1 rue de Paris", 100_000, "");
Tenant tenant = new Tenant("tenant-1", landlordId, "Camille", "Martin", "camille@example.com", "", "");
@@ -66,7 +66,7 @@ class RentalIntegrityLockTest {
lease.setApartmentId(apartmentId);
lease.setTenantIds(List.of(tenant.getId()));
LeaseRequest request = new LeaseRequest(apartmentId, List.of(tenant.getId()), List.of(),
null, null, null, null, null, null);
null, null, null, null, null);
CountDownLatch leaseReferenceLookupEntered = new CountDownLatch(1);
CountDownLatch releaseLeaseReferenceLookup = new CountDownLatch(1);
CountDownLatch deletionStarted = new CountDownLatch(1);
@@ -83,7 +83,6 @@ class RentalIntegrityLockTest {
});
when(tenantRepository.findAllByIdInAndLandlordId(List.of(tenant.getId()), landlordId))
.thenReturn(List.of(tenant));
when(leaseRepository.findAllByLandlordIdAndApartmentId(landlordId, apartmentId)).thenReturn(List.of());
when(leaseRepository.save(lease)).thenAnswer(invocation -> {
leaseSaved.set(true);
return lease;
@@ -92,7 +91,8 @@ class RentalIntegrityLockTest {
.thenAnswer(invocation -> leaseSaved.get());
LeaseService leaseService = new LeaseService(leaseRepository, apartmentRepository, tenantRepository,
mock(LeasePdfService.class), mock(PhoneNumberNormalizer.class), leaseMapper, rentPaymentService,
mock(LeasePdfService.class), mock(PhoneNumberNormalizer.class), leaseMapper,
mock(RentDueRepository.class), mock(RentDueService.class),
mock(LandlordProfileService.class), integrityLock);
ApartmentService apartmentService = new ApartmentService(apartmentRepository, leaseRepository,
mock(ApartmentMapper.class), integrityLock);
@@ -3,3 +3,7 @@
**Status**: accepted
Within Rental Management, `Lease` owns its contractual lifecycle, dates, rent terms, and lease-party relationships while referencing independent `Apartment` and `Tenant` aggregates by ID. Activation freezes the landlord identity and address, apartment address, tenant identities and addresses, guarantors, and financial terms; later changes are effective-dated immutable `LeaseAmendment` records that preserve both contract versions and may only recalculate future unpaid dues. `RentDue` and `RentPayment` are independent aggregates; a positive payment owns allocations whose sum cannot exceed the amount received, each allocation cannot exceed its due's remaining balance, and one payment may cover multiple dues only within the same lease. Any surplus stays unallocated. `Guarantor` remains internal to `Lease` until it gains an independent lifecycle or must be shared across leases.
A new `Lease` is always a mutable, permanently deletable `DRAFT`; draft overlap is allowed and creates no dues. Explicit activation rejects inclusive overlap with active or archived leases for the same apartment, then creates one uniquely constrained `RentDue` per occupied calendar month. Boundary months prorate rent and charges by occupied calendar days, while settlement and due-date timing remain independent facts on the due.
Until Mongo transactions replace the single-instance lock, activation retries reconcile any dues left by a failed draft activation and draft dues remain hidden from queries.
@@ -63,6 +63,45 @@
width: 100%;
font-size: 0.66rem;
}
.rent-schedule {
margin-top: 18px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.rent-schedule h3 {
margin: 0 0 10px;
font-size: 0.9rem;
}
.rent-due {
padding: 12px;
border-radius: 12px;
background: var(--surface-soft);
}
.rent-due + .rent-due {
margin-top: 8px;
}
.rent-due dl {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin: 10px 0;
}
.rent-due dt {
color: var(--text-subtle);
font-size: 0.68rem;
text-transform: uppercase;
}
.rent-due dd {
margin: 2px 0 0;
font-size: 0.8rem;
}
.rent-due-labels {
display: flex;
flex-wrap: wrap;
gap: 6px 12px;
color: var(--text-muted);
font-size: 0.75rem;
}
.card-actions {
margin-top: 14px;
}
@@ -1,4 +1,4 @@
<article class="lease-card" [attr.aria-busy]="deleting() || downloading()">
<article class="lease-card" [attr.aria-busy]="deleting() || downloading() || activating()">
<div class="lease-topline">
<span class="status-dot"></span><span>{{ statusLabel() }}</span
><strong>{{ lease().monthlyRent.toLocaleString('fr-FR') }} €</strong>
@@ -8,7 +8,10 @@
<dl class="terms">
<div>
<dt>Période</dt>
<dd>{{ lease().startDate | date:'dd MMMM yyyy' }} → {{ lease().endDate | date:'dd MMMM yyyy' }}</dd>
<dd>
{{ lease().startDate | date: 'dd MMMM yyyy' }} →
{{ lease().endDate | date: 'dd MMMM yyyy' }}
</dd>
</div>
<div>
<dt>Charges</dt>
@@ -27,14 +30,48 @@
}
</div>
}
@if (deleteError() || downloadError()) {
<p class="card-error" role="alert">{{ deleteError() || downloadError() }}</p>
@if (rentDues().length) {
<section class="rent-schedule" aria-label="Échéancier de loyer">
<h3>Échéancier</h3>
@for (rentDue of rentDues(); track rentDue.id) {
<article class="rent-due">
<strong>{{ rentDue.month }}</strong>
<dl>
<div>
<dt>Loyer</dt>
<dd>{{ rentDue.rent.toLocaleString('fr-FR') }} €</dd>
</div>
<div>
<dt>Charges</dt>
<dd>{{ rentDue.charges.toLocaleString('fr-FR') }} €</dd>
</div>
<div>
<dt>Total</dt>
<dd>{{ rentDue.total.toLocaleString('fr-FR') }} €</dd>
</div>
<div>
<dt>Restant</dt>
<dd>{{ rentDue.remainingAmount.toLocaleString('fr-FR') }} €</dd>
</div>
</dl>
<div class="rent-due-labels">
<span>Règlement : {{ settlementLabel(rentDue.settlement) }}</span>
<span>Temporalité : {{ timingLabel(rentDue.timing) }}</span>
</div>
</article>
}
</section>
}
@if (deleteError() || downloadError() || activationError()) {
<p class="card-error" role="alert">
{{ deleteError() || downloadError() || activationError() }}
</p>
}
<div class="card-actions" aria-live="polite">
<button
mat-button
type="button"
[disabled]="deleting() || downloading()"
[disabled]="deleting() || downloading() || activating()"
(click)="pdfRequested.emit(lease())"
>
@if (downloading()) {
@@ -43,12 +80,32 @@
Télécharger le bail
}
</button>
@if (lease().id) {
@if (lease().id && lease().status === 'DRAFT') {
<button
mat-button
type="button"
[disabled]="deleting() || downloading() || activating()"
(click)="editRequested.emit(lease())"
>
Modifier
</button>
<button
mat-flat-button
type="button"
[disabled]="deleting() || downloading() || activating()"
(click)="activationRequested.emit(lease().id!)"
>
@if (activating()) {
<mat-spinner diameter="16" aria-hidden="true" /> Activation…
} @else {
Activer
}
</button>
<button
mat-button
type="button"
class="danger-action"
[disabled]="deleting()"
[disabled]="deleting() || downloading() || activating()"
(click)="deleteRequested.emit(lease().id!)"
>
@if (deleting()) {
@@ -73,4 +73,87 @@ describe('LeaseCard', () => {
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Impossible de télécharger ce bail.');
});
it('offers draft lifecycle actions with activation progress and an actionable failure', () => {
const fixture = TestBed.createComponent(LeaseCard);
fixture.componentRef.setInput('apartments', []);
fixture.componentRef.setInput('tenants', []);
fixture.componentRef.setInput('lease', {
id: 'lease-1',
apartmentId: 'apartment-1',
tenantIds: [],
guarantors: [],
startDate: '2026-01-01',
endDate: '2027-01-01',
monthlyRent: 800,
monthlyCharges: 50,
securityDeposit: 800,
status: 'DRAFT',
});
fixture.componentRef.setInput('activating', true);
fixture.componentRef.setInput('activationError', 'Un bail actif chevauche cette période.');
fixture.detectChanges();
const text = fixture.nativeElement.textContent.replace(/\s+/g, ' ');
expect(text).toContain('Modifier');
expect(text).toContain('Supprimer');
expect(text).toContain('Activation…');
expect(text).toContain('Un bail actif chevauche cette période.');
const activation = [...fixture.nativeElement.querySelectorAll('button')].find((button) =>
button.textContent.includes('Activation…'),
);
expect(activation.disabled).toBe(true);
fixture.componentRef.setInput('lease', {
...fixture.componentInstance.lease(),
status: 'ACTIVE',
});
fixture.componentRef.setInput('activating', false);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).not.toContain('Modifier');
expect(fixture.nativeElement.textContent).not.toContain('Supprimer');
expect(fixture.nativeElement.textContent).not.toContain('Activer');
});
it('shows the resulting rent schedule with separate settlement and timing labels', () => {
const fixture = TestBed.createComponent(LeaseCard);
fixture.componentRef.setInput('apartments', []);
fixture.componentRef.setInput('tenants', []);
fixture.componentRef.setInput('lease', {
id: 'lease-1',
apartmentId: 'apartment-1',
tenantIds: [],
guarantors: [],
startDate: '2026-01-01',
endDate: '2026-01-31',
monthlyRent: 800,
monthlyCharges: 50,
securityDeposit: 800,
status: 'ACTIVE',
});
fixture.componentRef.setInput('rentDues', [
{
id: 'due-1',
leaseId: 'lease-1',
month: '2026-01',
dueDate: '2026-01-01',
rent: 800,
charges: 50,
total: 850,
remainingAmount: 850,
settlement: 'UNPAID',
timing: 'OVERDUE',
},
]);
fixture.detectChanges();
const text = fixture.nativeElement.textContent.replace(/\s+/g, ' ');
const amounts = [...fixture.nativeElement.querySelectorAll('.rent-due dl div')].map((term) =>
[...term.children].map((item) => item.textContent.trim()).join(' '),
);
expect(text).toContain('2026-01');
expect(amounts).toEqual(['Loyer 800 €', 'Charges 50 €', 'Total 850 €', 'Restant 850 €']);
expect(text).toContain('Règlement : Non réglée');
expect(text).toContain('Temporalité : En retard');
});
});
@@ -3,7 +3,7 @@ import { DatePipe } from '@angular/common';
import { MatButtonModule } from '@angular/material/button';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { Apartment } from '../../services/apartment.service';
import { Lease } from '../../services/lease.service';
import { Lease, RentDue } from '../../services/lease.service';
import { Tenant } from '../../services/tenant.service';
@Component({
@@ -20,8 +20,13 @@ export class LeaseCard {
readonly deleteError = input('');
readonly downloading = input(false);
readonly downloadError = input('');
readonly rentDues = input<RentDue[]>([]);
readonly activating = input(false);
readonly activationError = input('');
readonly deleteRequested = output<string>();
readonly pdfRequested = output<Lease>();
readonly editRequested = output<Lease>();
readonly activationRequested = output<string>();
apartmentName(): string {
return (
@@ -43,4 +48,14 @@ export class LeaseCard {
this.lease().status
];
}
settlementLabel(settlement: RentDue['settlement']): string {
return { UNPAID: 'Non réglée', PARTIALLY_PAID: 'Partiellement réglée', PAID: 'Réglée' }[
settlement
];
}
timingLabel(timing: RentDue['timing']): string {
return { UPCOMING: 'À venir', OVERDUE: 'En retard' }[timing];
}
}
@@ -10,8 +10,8 @@
>
← Retour
</button>
<p class="eyebrow">Nouveau bail</p>
<h1>Créer un bail</h1>
<p class="eyebrow">{{ lease() ? 'Brouillon' : 'Nouveau bail' }}</p>
<h1>{{ lease() ? 'Modifier le brouillon' : 'Créer un brouillon de bail' }}</h1>
<p>Associez le bien, les locataires et les éventuels garants.</p>
</div>
</header>
@@ -43,9 +43,7 @@
<div class="form-grid">
<mat-form-field appearance="outline"
><mat-label>Appartement</mat-label
><mat-select
id="apartmentId"
[formField]="leaseForm.apartmentId"
><mat-select id="apartmentId" [formField]="leaseForm.apartmentId"
><mat-option value="">Sélectionnez un appartement</mat-option>
@for (apartment of apartments(); track apartment.id) {
<mat-option [value]="apartment.id">{{ apartment.name }}</mat-option>
@@ -54,11 +52,7 @@
>
<mat-form-field appearance="outline"
><mat-label>Locataires</mat-label
><mat-select
id="tenantIds"
[formField]="leaseForm.tenantIds"
multiple
>
><mat-select id="tenantIds" [formField]="leaseForm.tenantIds" multiple>
@for (tenant of tenants(); track tenant.id) {
<mat-option [value]="tenant.id"
>{{ tenant.firstName }} {{ tenant.lastName }}</mat-option
@@ -163,7 +157,7 @@
@if (saving()) {
<mat-spinner diameter="18" aria-hidden="true" /> Enregistrement…
} @else {
Enregistrer le bail
{{ lease() ? 'Enregistrer les modifications' : 'Enregistrer le brouillon' }}
}
</span>
</button>
@@ -18,12 +18,84 @@ describe('LeaseForm', () => {
monthlyCharges: 50,
securityDeposit: 850,
});
let startDate = '';
component.submitted.subscribe((lease) => (startDate = lease.startDate));
let submitted: unknown;
component.submitted.subscribe((lease) => (submitted = lease));
component.save();
expect(startDate).toBe('2026-09-01');
expect(submitted).toEqual({
apartmentId: 'apartment-1',
tenantIds: ['tenant-1'],
guarantors: [],
startDate: '2026-09-01',
endDate: '2027-08-31',
monthlyRent: 850,
monthlyCharges: 50,
securityDeposit: 850,
});
});
it('allows overlapping draft dates but leaves active dates unavailable', () => {
TestBed.configureTestingModule({ providers: [provideNativeDateAdapter()] });
const fixture = TestBed.createComponent(LeaseForm);
fixture.componentRef.setInput('apartments', []);
fixture.componentRef.setInput('tenants', []);
fixture.componentRef.setInput('leases', [
{
id: 'draft-1',
apartmentId: 'apartment-1',
tenantIds: [],
guarantors: [],
startDate: '2026-01-01',
endDate: '2026-01-31',
monthlyRent: 800,
monthlyCharges: 50,
securityDeposit: 800,
status: 'DRAFT',
},
{
id: 'active-1',
apartmentId: 'apartment-1',
tenantIds: [],
guarantors: [],
startDate: '2026-02-01',
endDate: '2026-02-28',
monthlyRent: 800,
monthlyCharges: 50,
securityDeposit: 800,
status: 'ACTIVE',
},
]);
fixture.componentInstance.leaseModel.update((lease) => ({
...lease,
apartmentId: 'apartment-1',
}));
expect(fixture.componentInstance.dateFilter(new Date(2026, 0, 15))).toBe(true);
expect(fixture.componentInstance.dateFilter(new Date(2026, 1, 15))).toBe(false);
});
it('loads a draft for editing', () => {
TestBed.configureTestingModule({ providers: [provideNativeDateAdapter()] });
const fixture = TestBed.createComponent(LeaseForm);
fixture.componentRef.setInput('apartments', []);
fixture.componentRef.setInput('tenants', []);
fixture.componentRef.setInput('lease', {
id: 'draft-1',
apartmentId: 'apartment-1',
tenantIds: ['tenant-1'],
guarantors: [],
startDate: '2026-09-01',
endDate: '2027-08-31',
monthlyRent: 850,
monthlyCharges: 50,
securityDeposit: 850,
status: 'DRAFT',
});
fixture.detectChanges();
expect(fixture.componentInstance.leaseModel().apartmentId).toBe('apartment-1');
expect(fixture.nativeElement.textContent).toContain('Modifier le brouillon');
});
it('keeps guarantors in the lease form module', () => {
@@ -1,4 +1,4 @@
import { Component, input, output, signal } from '@angular/core';
import { Component, effect, input, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormRoot, FormField, form, required, min } from '@angular/forms/signals';
import { MatButtonModule } from '@angular/material/button';
@@ -8,7 +8,7 @@ import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { Apartment } from '../../services/apartment.service';
import { Guarantor, Lease } from '../../services/lease.service';
import { Guarantor, Lease, LeaseInput } from '../../services/lease.service';
import { Tenant } from '../../services/tenant.service';
import { GuarantorForm } from '../guarantor-form/guarantor-form';
@@ -33,10 +33,11 @@ export class LeaseForm {
readonly apartments = input.required<Apartment[]>();
readonly tenants = input.required<Tenant[]>();
readonly leases = input<Lease[]>([]);
readonly lease = input<Lease>();
readonly saving = input(false);
readonly saveError = input('');
readonly cancelled = output<void>();
readonly submitted = output<Lease>();
readonly submitted = output<LeaseInput>();
readonly leaseModel = signal({
apartmentId: '',
@@ -62,6 +63,23 @@ export class LeaseForm {
readonly guarantors = signal<Guarantor[]>([]);
readonly error = signal('');
constructor() {
effect(() => {
const lease = this.lease();
if (!lease) return;
this.leaseModel.set({
apartmentId: lease.apartmentId,
tenantIds: lease.tenantIds,
startDate: this.fromIsoDate(lease.startDate),
endDate: this.fromIsoDate(lease.endDate),
monthlyRent: lease.monthlyRent,
monthlyCharges: lease.monthlyCharges,
securityDeposit: lease.securityDeposit,
});
this.guarantors.set(lease.guarantors);
});
}
readonly dateFilter = (d: Date | null): boolean => {
if (!d) return false;
const aptId = this.leaseModel().apartmentId;
@@ -70,7 +88,7 @@ export class LeaseForm {
const time = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
const overlapping = this.leases().some((lease) => {
if (lease.apartmentId !== aptId) return false;
if (lease.status === 'DRAFT' || lease.apartmentId !== aptId) return false;
const [startYear, startMonth, startDay] = lease.startDate.split('-').map(Number);
const [endYear, endMonth, endDay] = lease.endDate.split('-').map(Number);
const start = new Date(startYear, startMonth - 1, startDay).getTime();
@@ -110,11 +128,15 @@ export class LeaseForm {
monthlyRent: modelVal.monthlyRent,
monthlyCharges: modelVal.monthlyCharges,
securityDeposit: modelVal.securityDeposit,
status: 'ACTIVE',
});
}
private toIsoDate(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
private fromIsoDate(date: string): Date {
const [year, month, day] = date.split('-').map(Number);
return new Date(year, month - 1, day);
}
}
@@ -4,7 +4,7 @@
<div>
<p class="eyebrow">Location</p>
<h1>Mes baux</h1>
<p>Les baux actifs, leurs parties et leurs conditions financières.</p>
<p>Préparez vos baux, activez-les et consultez leurs échéanciers.</p>
</div>
<button
mat-flat-button
@@ -62,7 +62,12 @@
[deleteError]="lease.id ? (workspace.leaseDeleteErrors()[lease.id] ?? '') : ''"
[downloading]="!!lease.id && workspace.leaseDownloading(lease.id)"
[downloadError]="lease.id ? workspace.leaseDownloadError(lease.id) : ''"
[rentDues]="rentDuesFor(lease)"
[activating]="!!lease.id && workspace.leaseActivating(lease.id)"
[activationError]="lease.id ? workspace.leaseActivationError(lease.id) : ''"
(pdfRequested)="workspace.downloadLeasePdf($event)"
(editRequested)="openForm($event)"
(activationRequested)="workspace.activateLease($event)"
(deleteRequested)="workspace.deleteLease($event)"
/>
}
@@ -76,8 +81,9 @@
[apartments]="workspace.apartments()"
[tenants]="workspace.tenants()"
[leases]="workspace.leases()"
[saving]="workspace.leaseSaving()"
[saveError]="workspace.leaseSaveError()"
[lease]="editingLease() ?? undefined"
[saving]="workspace.leaseSaving(editingLease()?.id)"
[saveError]="workspace.leaseSaveError(editingLease()?.id)"
(cancelled)="closeForm()"
(submitted)="saveLease($event)"
/>
@@ -2,26 +2,35 @@ import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { LeasesPage } from './leases-page';
import { WorkspaceService } from '../../services/workspace.service';
import { Lease, RentDue } from '../../services/lease.service';
describe('LeasesPage', () => {
const workspace = {
apartments: signal([]),
tenants: signal([]),
leases: signal([]),
leases: signal<Lease[]>([]),
apartmentLoadState: signal({ loaded: false, loading: true, error: '' }),
tenantLoadState: signal({ loaded: false, loading: true, error: '' }),
leaseLoadState: signal({ loaded: false, loading: true, error: '' }),
rentDues: signal<RentDue[]>([]),
rentDueLoadState: signal({ loaded: false, loading: true, error: '' }),
deletingLeaseIds: signal<ReadonlySet<string>>(new Set()),
leaseDeleteErrors: signal<Record<string, string>>({}),
loadApartments: vi.fn(),
loadTenants: vi.fn(),
loadLeases: vi.fn(),
loadRentDues: vi.fn(),
saveLease: vi.fn(),
deleteLease: vi.fn(),
activateLease: vi.fn(),
downloadLeasePdf: vi.fn(),
leaseSaving: vi.fn(() => false),
leaseSaveError: vi.fn(() => ''),
clearLeaseSaveError: vi.fn(),
leaseDownloading: vi.fn(() => false),
leaseDownloadError: vi.fn(() => ''),
leaseActivating: vi.fn(() => false),
leaseActivationError: vi.fn(() => ''),
};
beforeEach(() => {
@@ -29,12 +38,15 @@ describe('LeasesPage', () => {
workspace.apartmentLoadState.set({ loaded: false, loading: true, error: '' });
workspace.tenantLoadState.set({ loaded: false, loading: true, error: '' });
workspace.leaseLoadState.set({ loaded: false, loading: true, error: '' });
workspace.rentDueLoadState.set({ loaded: false, loading: true, error: '' });
workspace.leases.set([]);
workspace.rentDues.set([]);
TestBed.configureTestingModule({
providers: [{ provide: WorkspaceService, useValue: workspace }],
});
});
it('waits for baux, appartements, and locataires before revealing content', () => {
it('waits for baux, appartements, locataires, and échéances before revealing content', () => {
workspace.leaseLoadState.set({ loaded: true, loading: false, error: '' });
const fixture = TestBed.createComponent(LeasesPage);
fixture.detectChanges();
@@ -53,4 +65,113 @@ describe('LeasesPage', () => {
component.closeForm();
expect(component.showForm()).toBe(false);
});
it('opens an existing draft for editing', () => {
const fixture = TestBed.createComponent(LeasesPage);
const draft = {
id: 'lease-1',
apartmentId: 'apartment-1',
tenantIds: ['tenant-1'],
guarantors: [],
startDate: '2026-01-01',
endDate: '2026-12-31',
monthlyRent: 800,
monthlyCharges: 50,
securityDeposit: 800,
status: 'DRAFT' as const,
};
fixture.componentInstance.openForm(draft);
expect(fixture.componentInstance.editingLease()).toEqual(draft);
expect(fixture.componentInstance.showForm()).toBe(true);
});
it('shows and retries an échéance load failure', () => {
workspace.apartmentLoadState.set({ loaded: true, loading: false, error: '' });
workspace.tenantLoadState.set({ loaded: true, loading: false, error: '' });
workspace.leaseLoadState.set({ loaded: true, loading: false, error: '' });
workspace.rentDueLoadState.set({
loaded: false,
loading: false,
error: 'Impossible de charger les échéances de loyer.',
});
const fixture = TestBed.createComponent(LeasesPage);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain(
'Impossible de charger les échéances de loyer.',
);
workspace.loadRentDues.mockClear();
fixture.nativeElement.querySelector('.load-error button').click();
expect(workspace.loadRentDues).toHaveBeenCalledOnce();
});
it('renders each échéance only under its matching bail', () => {
workspace.apartmentLoadState.set({ loaded: true, loading: false, error: '' });
workspace.tenantLoadState.set({ loaded: true, loading: false, error: '' });
workspace.leaseLoadState.set({ loaded: true, loading: false, error: '' });
workspace.rentDueLoadState.set({ loaded: true, loading: false, error: '' });
workspace.leases.set([
{
id: 'lease-1',
apartmentId: 'apartment-1',
tenantIds: [],
guarantors: [],
startDate: '2026-01-01',
endDate: '2026-12-31',
monthlyRent: 800,
monthlyCharges: 50,
securityDeposit: 800,
status: 'ACTIVE',
},
{
id: 'lease-2',
apartmentId: 'apartment-2',
tenantIds: [],
guarantors: [],
startDate: '2027-01-01',
endDate: '2027-12-31',
monthlyRent: 900,
monthlyCharges: 60,
securityDeposit: 900,
status: 'ACTIVE',
},
]);
workspace.rentDues.set([
{
id: 'due-1',
leaseId: 'lease-1',
month: '2026-01',
dueDate: '2026-01-01',
rent: 800,
charges: 50,
total: 850,
remainingAmount: 850,
settlement: 'UNPAID',
timing: 'OVERDUE',
},
{
id: 'due-2',
leaseId: 'lease-2',
month: '2027-01',
dueDate: '2027-01-01',
rent: 900,
charges: 60,
total: 960,
remainingAmount: 960,
settlement: 'UNPAID',
timing: 'UPCOMING',
},
]);
const fixture = TestBed.createComponent(LeasesPage);
fixture.detectChanges();
const cards = fixture.nativeElement.querySelectorAll('.lease-card');
expect(cards).toHaveLength(2);
expect(cards[0].textContent).toContain('2026-01');
expect(cards[0].textContent).not.toContain('2027-01');
expect(cards[1].textContent).toContain('2027-01');
expect(cards[1].textContent).not.toContain('2026-01');
});
});
@@ -2,7 +2,7 @@ import { Component, computed, inject, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { WorkspaceService } from '../../services/workspace.service';
import { Lease } from '../../services/lease.service';
import { Lease, LeaseInput, RentDue } from '../../services/lease.service';
import { LeaseCard } from '../lease-card/lease-card';
import { LeaseForm } from '../lease-form/lease-form';
@@ -15,21 +15,34 @@ import { LeaseForm } from '../lease-form/lease-form';
export class LeasesPage {
readonly workspace = inject(WorkspaceService);
readonly showForm = signal(false);
readonly editingLease = signal<Lease | null>(null);
readonly rentDuesByLease = computed(() => {
const rentDuesByLease = new Map<string, RentDue[]>();
for (const rentDue of this.workspace.rentDues()) {
const leaseRentDues = rentDuesByLease.get(rentDue.leaseId);
if (leaseRentDues) leaseRentDues.push(rentDue);
else rentDuesByLease.set(rentDue.leaseId, [rentDue]);
}
return rentDuesByLease;
});
readonly contentReady = computed(
() =>
this.workspace.apartmentLoadState().loaded &&
this.workspace.tenantLoadState().loaded &&
this.workspace.leaseLoadState().loaded,
this.workspace.leaseLoadState().loaded &&
this.workspace.rentDueLoadState().loaded,
);
readonly contentLoading = computed(
() =>
this.workspace.apartmentLoadState().loading ||
this.workspace.tenantLoadState().loading ||
this.workspace.leaseLoadState().loading,
this.workspace.leaseLoadState().loading ||
this.workspace.rentDueLoadState().loading,
);
readonly loadError = computed(
() =>
this.workspace.leaseLoadState().error ||
this.workspace.rentDueLoadState().error ||
this.workspace.apartmentLoadState().error ||
this.workspace.tenantLoadState().error,
);
@@ -38,17 +51,24 @@ export class LeasesPage {
this.workspace.loadApartments();
this.workspace.loadTenants();
this.workspace.loadLeases();
this.workspace.loadRentDues();
}
openForm(): void {
this.workspace.clearLeaseSaveError();
openForm(lease: Lease | null = null): void {
this.editingLease.set(lease);
this.workspace.clearLeaseSaveError(lease?.id);
this.showForm.set(true);
}
closeForm(): void {
this.showForm.set(false);
this.editingLease.set(null);
}
saveLease(lease: Lease): void {
this.workspace.saveLease(lease, () => this.closeForm());
saveLease(lease: LeaseInput): void {
this.workspace.saveLease(lease, this.editingLease()?.id, () => this.closeForm());
}
rentDuesFor(lease: Lease): RentDue[] {
return lease.id ? (this.rentDuesByLease().get(lease.id) ?? []) : [];
}
retryLoad(): void {
@@ -58,5 +78,7 @@ export class LeasesPage {
this.workspace.loadTenants();
if (!this.workspace.leaseLoadState().loaded || this.workspace.leaseLoadState().error)
this.workspace.loadLeases();
if (!this.workspace.rentDueLoadState().loaded || this.workspace.rentDueLoadState().error)
this.workspace.loadRentDues();
}
}
@@ -2,7 +2,7 @@ import { TestBed } from '@angular/core/testing';
import { HttpClient } from '@angular/common/http';
import { of } from 'rxjs';
import { vi } from 'vitest';
import { Lease, LeaseService } from './lease.service';
import { LeaseInput, LeaseService } from './lease.service';
describe('LeaseService', () => {
const http = {
@@ -19,9 +19,9 @@ describe('LeaseService', () => {
});
});
it('uses lease CRUD and PDF endpoints', () => {
it('uses lease lifecycle, schedule, and PDF endpoints without sending status', () => {
const service = TestBed.inject(LeaseService);
const lease: Lease = {
const lease: LeaseInput = {
apartmentId: 'apartment-1',
tenantIds: ['tenant-1'],
guarantors: [],
@@ -30,18 +30,21 @@ describe('LeaseService', () => {
monthlyRent: 850,
monthlyCharges: 50,
securityDeposit: 850,
status: 'ACTIVE',
};
service.getLeases().subscribe();
service.getRentDues().subscribe();
service.createLease(lease).subscribe();
service.updateLease('lease-1', lease).subscribe();
service.activateLease('lease-1').subscribe();
service.deleteLease('lease-1').subscribe();
service.downloadPdf('lease-1').subscribe();
expect(http.get).toHaveBeenCalledWith('/api/leases');
expect(http.get).toHaveBeenCalledWith('/api/rent-dues');
expect(http.post).toHaveBeenCalledWith('/api/leases', lease);
expect(http.put).toHaveBeenCalledWith('/api/leases/lease-1', lease);
expect(http.post).toHaveBeenCalledWith('/api/leases/lease-1/activate', {});
expect(http.delete).toHaveBeenCalledWith('/api/leases/lease-1');
expect(http.get).toHaveBeenCalledWith('/api/leases/lease-1/pdf', {
responseType: 'blob',
+33 -4
View File
@@ -11,8 +11,7 @@ export interface Guarantor {
address: string;
}
export interface Lease {
id?: string;
export interface LeaseInput {
apartmentId: string;
tenantIds: string[];
guarantors: Guarantor[];
@@ -21,9 +20,31 @@ export interface Lease {
monthlyRent: number;
monthlyCharges: number;
securityDeposit: number;
}
export interface Lease extends LeaseInput {
id?: string;
status: 'DRAFT' | 'ACTIVE' | 'ARCHIVED';
}
export interface RentDue {
id: string;
leaseId: string;
month: string;
dueDate: string;
rent: number;
charges: number;
total: number;
remainingAmount: number;
settlement: 'UNPAID' | 'PARTIALLY_PAID' | 'PAID';
timing: 'UPCOMING' | 'OVERDUE';
}
export interface LeaseActivation {
lease: Lease;
rentDues: RentDue[];
}
@Injectable({ providedIn: 'root' })
export class LeaseService {
private readonly http = inject(HttpClient);
@@ -32,14 +53,22 @@ export class LeaseService {
return this.http.get<Lease[]>('/api/leases');
}
createLease(lease: Lease): Observable<Lease> {
getRentDues(): Observable<RentDue[]> {
return this.http.get<RentDue[]>('/api/rent-dues');
}
createLease(lease: LeaseInput): Observable<Lease> {
return this.http.post<Lease>('/api/leases', lease);
}
updateLease(id: string, lease: Lease): Observable<Lease> {
updateLease(id: string, lease: LeaseInput): Observable<Lease> {
return this.http.put<Lease>(`/api/leases/${id}`, lease);
}
activateLease(id: string): Observable<LeaseActivation> {
return this.http.post<LeaseActivation>(`/api/leases/${id}/activate`, {});
}
deleteLease(id: string): Observable<void> {
return this.http.delete<void>(`/api/leases/${id}`);
}
@@ -49,6 +49,18 @@ describe('WorkspaceService', () => {
status: 'LATE' as const,
paidAt: null,
};
const rentDue = {
id: 'due-1',
leaseId: 'lease-1',
month: '2026-01',
dueDate: '2026-01-01',
rent: 800,
charges: 50,
total: 850,
remainingAmount: 850,
settlement: 'UNPAID' as const,
timing: 'UPCOMING' as const,
};
const apartments = {
getApartments: vi.fn(),
createApartment: vi.fn(),
@@ -66,7 +78,10 @@ describe('WorkspaceService', () => {
};
const leases = {
getLeases: vi.fn(),
getRentDues: vi.fn(),
createLease: vi.fn(),
updateLease: vi.fn(),
activateLease: vi.fn(),
deleteLease: vi.fn(),
downloadPdf: vi.fn(),
};
@@ -93,7 +108,10 @@ describe('WorkspaceService', () => {
tenants.downloadAttachment.mockReturnValue(of(new Blob(['identity'])));
tenants.deleteAttachment.mockReturnValue(of(undefined));
leases.getLeases.mockReturnValue(of([lease]));
leases.getRentDues.mockReturnValue(of([rentDue]));
leases.createLease.mockReturnValue(of(lease));
leases.updateLease.mockReturnValue(of(lease));
leases.activateLease.mockReturnValue(of({ lease, rentDues: [rentDue] }));
leases.deleteLease.mockReturnValue(of(undefined));
leases.downloadPdf.mockReturnValue(of(new Blob(['pdf'])));
rentPayments.getRentPayments.mockReturnValue(of([rentPayment]));
@@ -349,7 +367,8 @@ describe('WorkspaceService', () => {
service.saveTenant(tenant, tenant.id);
service.deleteTenant(tenant.id);
service.loadLeases();
service.saveLease(lease);
service.loadRentDues();
service.saveLease(lease, lease.id);
service.deleteLease(lease.id);
service.loadRentPayments();
service.markRentPaymentPaid(rentPayment.id, rentPayment.rent, rentPayment.charges);
@@ -357,9 +376,20 @@ describe('WorkspaceService', () => {
expect(service.apartments()).toEqual([apartment]);
expect(service.tenants()).toEqual([tenant]);
expect(service.leases()).toEqual([lease]);
expect(service.rentDues()).toEqual([rentDue]);
expect(service.rentPayments()).toEqual([rentPayment]);
expect(apartments.updateApartment).toHaveBeenCalledWith('apartment-1', apartment);
expect(tenants.updateTenant).toHaveBeenCalledWith('tenant-1', tenant);
expect(leases.updateLease).toHaveBeenCalledWith('lease-1', {
apartmentId: lease.apartmentId,
tenantIds: lease.tenantIds,
guarantors: lease.guarantors,
startDate: lease.startDate,
endDate: lease.endDate,
monthlyRent: lease.monthlyRent,
monthlyCharges: lease.monthlyCharges,
securityDeposit: lease.securityDeposit,
});
expect(leases.deleteLease).toHaveBeenCalledWith('lease-1');
expect(rentPayments.markPaid).toHaveBeenCalledWith(
'payment-1',
@@ -442,4 +472,46 @@ describe('WorkspaceService', () => {
),
);
});
it('publishes activation results immediately, prevents duplicates, then reconciles', () => {
const activation = new Subject<{ lease: typeof lease; rentDues: (typeof rentDue)[] }>();
const leaseRefresh = new Subject<(typeof lease)[]>();
const dueRefresh = new Subject<(typeof rentDue)[]>();
leases.activateLease.mockReturnValueOnce(activation);
leases.getLeases.mockReturnValueOnce(leaseRefresh);
leases.getRentDues.mockReturnValueOnce(dueRefresh);
const service = TestBed.inject(WorkspaceService);
const draft = { ...lease, status: 'DRAFT' as const };
service.leases.set([draft]);
service.activateLease(lease.id);
service.activateLease(lease.id);
expect(service.leaseActivating(lease.id)).toBe(true);
expect(leases.activateLease).toHaveBeenCalledTimes(1);
expect(service.leases()).toEqual([draft]);
activation.next({ lease, rentDues: [rentDue] });
activation.complete();
expect(service.leases()).toEqual([lease]);
expect(service.rentDues()).toEqual([rentDue]);
expect(service.leaseActivating(lease.id)).toBe(false);
expect(service.leaseLoadState().loading).toBe(true);
expect(service.rentDueLoadState().loading).toBe(true);
});
it('keeps the draft and exposes the backend activation failure', () => {
leases.activateLease.mockReturnValueOnce(
throwError(() => ({ error: { message: 'Un bail actif chevauche cette période.' } })),
);
const service = TestBed.inject(WorkspaceService);
const draft = { ...lease, status: 'DRAFT' as const };
service.leases.set([draft]);
service.activateLease(lease.id);
expect(service.leases()).toEqual([draft]);
expect(service.leaseActivating(lease.id)).toBe(false);
expect(service.leaseActivationError(lease.id)).toBe('Un bail actif chevauche cette période.');
});
});
+70 -10
View File
@@ -1,4 +1,4 @@
import { Injectable, inject } from '@angular/core';
import { Injectable, inject, signal } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import {
ConfirmationDialog,
@@ -6,11 +6,14 @@ import {
} from '../components/confirmation-dialog/confirmation-dialog';
import { Apartment, ApartmentService } from './apartment.service';
import { FileDownloadService } from './file-download.service';
import { Lease, LeaseService } from './lease.service';
import { finalize } from 'rxjs';
import { Lease, LeaseInput, LeaseService } from './lease.service';
import { RentPayment, RentPaymentService } from './rent-payment.service';
import { Tenant, TenantAttachment, TenantService } from './tenant.service';
import { TenantAttachmentTransferService } from './tenant-attachment-transfer.service';
import { WorkspaceResource } from './workspace-resource';
import { httpErrorMessage } from './http-error-message';
import { withoutRecordKey, withoutSetValue } from './immutable-state';
const newResourceKey = 'new';
@@ -42,6 +45,11 @@ export class WorkspaceService {
save: 'Impossible d’enregistrer ce bail.',
delete: 'Impossible de supprimer ce bail.',
});
private readonly rentDueResource = new WorkspaceResource(() => this.leaseService.getRentDues(), {
load: 'Impossible de charger les échéances de loyer.',
save: '',
delete: '',
});
private readonly rentPaymentResource = new WorkspaceResource(
() => this.rentPaymentService.getRentPayments(),
{
@@ -63,6 +71,10 @@ export class WorkspaceService {
readonly leaseLoadState = this.leaseResource.loadState;
readonly deletingLeaseIds = this.leaseResource.deletingIds;
readonly leaseDeleteErrors = this.leaseResource.deleteErrors;
readonly rentDues = this.rentDueResource.items;
readonly rentDueLoadState = this.rentDueResource.loadState;
private readonly activatingLeaseIds = signal<ReadonlySet<string>>(new Set());
private readonly leaseActivationErrors = signal<Record<string, string>>({});
readonly rentPayments = this.rentPaymentResource.items;
readonly rentPaymentLoadState = this.rentPaymentResource.loadState;
@@ -193,20 +205,68 @@ export class WorkspaceService {
this.leaseResource.load();
}
saveLease(lease: Lease, onSuccess?: () => void): void {
this.leaseResource.save(newResourceKey, this.leaseService.createLease(lease), onSuccess);
loadRentDues(): void {
this.rentDueResource.load();
}
leaseSaving(): boolean {
return this.leaseResource.savingKeys().has(newResourceKey);
saveLease(lease: LeaseInput, id?: string, onSuccess?: () => void): void {
const input: LeaseInput = {
apartmentId: lease.apartmentId,
tenantIds: lease.tenantIds,
guarantors: lease.guarantors,
startDate: lease.startDate,
endDate: lease.endDate,
monthlyRent: lease.monthlyRent,
monthlyCharges: lease.monthlyCharges,
securityDeposit: lease.securityDeposit,
};
this.leaseResource.save(
this.resourceKey(id),
id ? this.leaseService.updateLease(id, input) : this.leaseService.createLease(input),
onSuccess,
);
}
leaseSaveError(): string {
return this.leaseResource.saveErrors()[newResourceKey] ?? '';
leaseSaving(id?: string): boolean {
return this.leaseResource.savingKeys().has(this.resourceKey(id));
}
clearLeaseSaveError(): void {
this.leaseResource.clearSaveError(newResourceKey);
leaseSaveError(id?: string): string {
return this.leaseResource.saveErrors()[this.resourceKey(id)] ?? '';
}
clearLeaseSaveError(id?: string): void {
this.leaseResource.clearSaveError(this.resourceKey(id));
}
activateLease(id: string): void {
if (this.activatingLeaseIds().has(id)) return;
this.activatingLeaseIds.update((ids) => new Set(ids).add(id));
this.leaseActivationErrors.update((errors) => withoutRecordKey(errors, id));
this.leaseService
.activateLease(id)
.pipe(finalize(() => this.activatingLeaseIds.update((ids) => withoutSetValue(ids, id))))
.subscribe({
next: ({ lease, rentDues }) => {
this.leaseResource.upsert(lease);
rentDues.forEach((rentDue) => this.rentDueResource.upsert(rentDue));
this.leaseResource.load();
this.rentDueResource.load();
},
error: (error) =>
this.leaseActivationErrors.update((errors) => ({
...errors,
[id]: httpErrorMessage(error, 'Impossible d’activer ce bail.'),
})),
});
}
leaseActivating(id: string): boolean {
return this.activatingLeaseIds().has(id);
}
leaseActivationError(id: string): string {
return this.leaseActivationErrors()[id] ?? '';
}
deleteLease(id: string): void {