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);