feat: activate draft leases into rent dues (#28)
This commit is contained in:
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()
|
||||
|
||||
+12
@@ -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());
|
||||
|
||||
+29
@@ -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
|
||||
) {
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package fr.nessar.lmnporganizer.backend.exception;
|
||||
|
||||
public class LeaseConflictException extends RuntimeException {
|
||||
public LeaseConflictException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
+7
@@ -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);
|
||||
|
||||
+14
@@ -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);
|
||||
}
|
||||
}
|
||||
+3
-8
@@ -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
|
||||
}
|
||||
""";
|
||||
}
|
||||
|
||||
+349
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-10
@@ -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);
|
||||
}
|
||||
}
|
||||
+165
@@ -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);
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user