feat: finalize lease documents and IRL revisions

This commit is contained in:
nessar committed 2026-08-17 14:18:16 +02:00
1 parent f127c69a28
commit 1bf6a3449c
196 files changed
+20564 -666

No files matched your search

+4
View File
@@ -54,6 +54,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>tools.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-yaml</artifactId>
</dependency>
<dependency>
<groupId>io.github.openhtmltopdf</groupId>
<artifactId>openhtmltopdf-pdfbox</artifactId>
@@ -0,0 +1,22 @@
package fr.nessar.lmnporganizer.rental.adapter.in.config;
import fr.nessar.lmnporganizer.rental.adapter.out.insee.InseeIrlIndexProvider;
import fr.nessar.lmnporganizer.rental.adapter.out.insee.InseeIrlProperties;
import fr.nessar.lmnporganizer.rental.application.port.out.IrlIndexProvider;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.mongodb.core.MongoTemplate;
import java.net.http.HttpClient;
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(InseeIrlProperties.class)
public class InseeIrlConfiguration {
@Bean
IrlIndexProvider inseeIrlIndexProvider(MongoTemplate mongoTemplate, InseeIrlProperties properties) {
HttpClient httpClient = HttpClient.newBuilder().connectTimeout(properties.requestTimeout()).build();
return new InseeIrlIndexProvider(httpClient, mongoTemplate, properties.baseUri(),
properties.requestTimeout());
}
}
@@ -0,0 +1,21 @@
package fr.nessar.lmnporganizer.rental.adapter.in.config;
import fr.nessar.lmnporganizer.rental.application.lease.LeaseClauseCatalog;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import java.io.IOException;
@Configuration
class LeaseClauseConfiguration {
@Bean
LeaseClauseCatalog leaseClauseCatalog() {
try {
return LeaseClauseCatalog.load(new ClassPathResource("lease-clauses.yml").getInputStream());
} catch (IOException exception) {
throw new IllegalStateException("Lease clause catalog could not be opened.", exception);
}
}
}
@@ -4,13 +4,19 @@ import fr.nessar.lmnporganizer.rental.application.lease.ActivateLeaseService;
import fr.nessar.lmnporganizer.rental.application.lease.ArchiveLeaseService;
import fr.nessar.lmnporganizer.rental.application.lease.GenerateLeaseDocumentService;
import fr.nessar.lmnporganizer.rental.application.lease.LeaseDraftService;
import fr.nessar.lmnporganizer.rental.application.lease.LeaseClauseCatalog;
import fr.nessar.lmnporganizer.rental.application.lease.LeaseFinalizationService;
import fr.nessar.lmnporganizer.rental.application.lease.AmendLeaseService;
import fr.nessar.lmnporganizer.rental.application.landlord.LandlordAccountService;
import fr.nessar.lmnporganizer.rental.application.apartment.ApartmentCatalogService;
import fr.nessar.lmnporganizer.rental.application.apartment.ApartmentDocumentService;
import fr.nessar.lmnporganizer.rental.application.tenant.DeleteTenantService;
import fr.nessar.lmnporganizer.rental.application.tenant.TenantAttachmentService;
import fr.nessar.lmnporganizer.rental.application.tenant.TenantDirectoryService;
import fr.nessar.lmnporganizer.rental.application.port.out.IdentifierGenerator;
import fr.nessar.lmnporganizer.rental.application.port.out.IrlIndexProvider;
import fr.nessar.lmnporganizer.rental.application.port.out.LeaseDocumentStorage;
import fr.nessar.lmnporganizer.rental.application.port.out.LeaseInformationNoticeCatalog;
import fr.nessar.lmnporganizer.rental.application.port.out.RentalDocumentRenderer;
import fr.nessar.lmnporganizer.rental.application.port.out.TenantAttachmentStorage;
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
@@ -18,18 +24,24 @@ import fr.nessar.lmnporganizer.rental.application.rent.AllocateRentPaymentServic
import fr.nessar.lmnporganizer.rental.application.rent.GeneratePaymentReceiptService;
import fr.nessar.lmnporganizer.rental.application.rent.GenerateRentReceiptService;
import fr.nessar.lmnporganizer.rental.application.rent.RecordRentPaymentService;
import fr.nessar.lmnporganizer.rental.application.rent.RefreshRentScheduleService;
import fr.nessar.lmnporganizer.rental.application.rent.RefundRentPaymentService;
import fr.nessar.lmnporganizer.rental.application.rent.ReverseRentPaymentService;
import fr.nessar.lmnporganizer.rental.application.rent.RentDueQueryService;
import fr.nessar.lmnporganizer.rental.application.rent.RentPaymentHistoryService;
import fr.nessar.lmnporganizer.rental.application.rent.RentRevisionService;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentRepository;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordRepository;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentRevisionRepository;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseFinalizationRepository;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseGuaranteeDeedRepository;
import fr.nessar.lmnporganizer.rental.domain.lease.ApartmentOccupancyRepository;
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueRepository;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefundRepository;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRepository;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversalRepository;
import fr.nessar.lmnporganizer.rental.domain.rent.RentRevisionRepository;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantRepository;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
@@ -56,6 +68,14 @@ public class RentalApplicationConfiguration {
return new ApartmentCatalogService(apartments, transactions, identifiers);
}
@Bean
ApartmentDocumentService apartmentDocumentService(ApartmentRepository apartments, LeaseDocumentStorage storage,
IdentifierGenerator identifiers, Clock rentalClock,
RentalAttachmentProperties properties) {
return new ApartmentDocumentService(apartments, storage, identifiers, rentalClock,
properties.maxSize().toBytes());
}
@Bean
TenantDirectoryService tenantDirectoryService(TenantRepository tenants, IdentifierGenerator identifiers) {
return new TenantDirectoryService(tenants, identifiers);
@@ -76,8 +96,8 @@ public class RentalApplicationConfiguration {
@Bean
LeaseDraftService leaseDraftService(ApartmentRepository apartments, TenantRepository tenants,
LeaseRepository leases, TransactionRunner transactions,
IdentifierGenerator identifiers) {
return new LeaseDraftService(apartments, tenants, leases, transactions, identifiers);
IdentifierGenerator identifiers, LeaseClauseCatalog clauseCatalog) {
return new LeaseDraftService(apartments, tenants, leases, transactions, identifiers, clauseCatalog);
}
@Bean
@@ -106,8 +126,32 @@ public class RentalApplicationConfiguration {
@Bean
GenerateLeaseDocumentService generateLeaseDocumentService(LeaseRepository leases,
RentalDocumentRenderer renderer, Clock rentalClock) {
return new GenerateLeaseDocumentService(leases, renderer, rentalClock);
RentalDocumentRenderer renderer, Clock rentalClock,
LandlordRepository landlords,
ApartmentRepository apartments,
TenantRepository tenants,
LeaseDocumentRevisionRepository revisions,
LeaseDocumentStorage storage,
ApartmentDocumentService apartmentDocuments,
LeaseInformationNoticeCatalog notices) {
return new GenerateLeaseDocumentService(leases, renderer, rentalClock, landlords, apartments, tenants,
revisions, storage, apartmentDocuments, notices);
}
@Bean
LeaseFinalizationService leaseFinalizationService(LeaseRepository leases, ApartmentRepository apartments,
TenantRepository tenants,
GenerateLeaseDocumentService documents,
LeaseDocumentRevisionRepository revisions,
LeaseGuaranteeDeedRepository guaranteeDeeds,
LeaseFinalizationRepository finalizations,
LeaseDocumentStorage storage,
ActivateLeaseService activation, RentDueRepository rentDues,
TransactionRunner transactions, Clock rentalClock,
RentalAttachmentProperties properties) {
return new LeaseFinalizationService(leases, apartments, tenants, documents, revisions, guaranteeDeeds,
finalizations, storage, activation, rentDues, transactions, rentalClock,
properties.maxSize().toBytes());
}
@Bean
@@ -167,4 +211,22 @@ public class RentalApplicationConfiguration {
RentDueQueryService rentDueQueryService(RentDueRepository rentDues) {
return new RentDueQueryService(rentDues);
}
@Bean
RentRevisionService rentRevisionService(LeaseRepository leases, IrlIndexProvider indices,
RentRevisionRepository revisions, RentDueRepository rentDues,
RentalDocumentRenderer renderer, LeaseDocumentStorage storage,
TransactionRunner transactions, IdentifierGenerator identifiers,
Clock rentalClock, RentalAttachmentProperties properties) {
return new RentRevisionService(leases, indices, revisions, rentDues, renderer, storage, transactions,
identifiers, rentalClock, properties.maxSize().toBytes());
}
@Bean
RefreshRentScheduleService refreshRentScheduleService(LeaseRepository leases, RentDueRepository rentDues,
RentRevisionRepository revisions,
TransactionRunner transactions,
IdentifierGenerator identifiers, Clock rentalClock) {
return new RefreshRentScheduleService(leases, rentDues, revisions, transactions, identifiers, rentalClock);
}
}
@@ -1,9 +1,16 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.apartment;
import fr.nessar.lmnporganizer.rental.domain.apartment.Apartment;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile.BuildingType;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile.ConstructionPeriod;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import java.math.BigDecimal;
@@ -11,15 +18,57 @@ final class ApartmentApi {
private ApartmentApi() {
}
record LegalProfileRequest(String taxIdentifier, BuildingType buildingType, String ownershipRegime,
String constructionPeriod, @PositiveOrZero BigDecimal habitableSurface,
@PositiveOrZero Integer roomCount, String otherPrivateAreas, String equipment,
String heating, String hotWater, String energyRating,
@PositiveOrZero BigDecimal estimatedAnnualEnergyCost,
@Min(1900) @Max(2100) Integer estimatedAnnualEnergyCostReferenceYear,
String destination,
String privateAccessories, String commonAreas, String internetAccess,
String departmentCode) {
ApartmentLegalProfile toDomain() {
if (estimatedAnnualEnergyCost != null && estimatedAnnualEnergyCostReferenceYear == null) {
throw new IllegalArgumentException("L'année de référence du coût énergétique est requise.");
}
return new ApartmentLegalProfile(taxIdentifier, buildingType, ownershipRegime,
ConstructionPeriod.fromCode(constructionPeriod), habitableSurface,
roomCount == null ? 0 : roomCount, otherPrivateAreas, equipment, heating, hotWater,
energyRating, estimatedAnnualEnergyCost, estimatedAnnualEnergyCostReferenceYear, destination,
privateAccessories, commonAreas, internetAccess, departmentCode);
}
}
record LegalProfileResponse(String taxIdentifier, BuildingType buildingType, String ownershipRegime,
String constructionPeriod, BigDecimal habitableSurface, int roomCount,
String otherPrivateAreas, String equipment, String heating, String hotWater,
String energyRating, BigDecimal estimatedAnnualEnergyCost,
Integer estimatedAnnualEnergyCostReferenceYear, String destination,
String privateAccessories, String commonAreas, String internetAccess,
String departmentCode) {
static LegalProfileResponse from(ApartmentLegalProfile profile) {
return new LegalProfileResponse(profile.taxIdentifier(), profile.buildingType(),
profile.ownershipRegime(),
profile.constructionPeriod() == null ? "" : profile.constructionPeriod().code(),
profile.habitableSurface(), profile.roomCount(), profile.otherPrivateAreas(),
profile.equipment(), profile.heating(), profile.hotWater(), profile.energyRating(),
profile.estimatedAnnualEnergyCost(), profile.estimatedAnnualEnergyCostReferenceYear(),
profile.destination(), profile.privateAccessories(), profile.commonAreas(),
profile.internetAccess(), profile.departmentCode());
}
}
record Request(@NotBlank String name, @NotBlank String address,
@NotNull @Positive BigDecimal purchasePrice, String description) {
@NotNull @Positive BigDecimal purchasePrice, String description,
@Valid LegalProfileRequest legalProfile) {
}
record Response(String id, String name, String address, BigDecimal purchasePrice, String description,
boolean referenced, long version) {
LegalProfileResponse legalProfile, boolean referenced, long version) {
static Response from(Apartment apartment) {
return new Response(apartment.id().value(), apartment.name(), apartment.address(),
apartment.purchasePrice(), apartment.description(), apartment.isReferenced(),
apartment.purchasePrice(), apartment.description(), LegalProfileResponse.from(
apartment.legalProfile()), apartment.isReferenced(),
apartment.version());
}
}
@@ -63,6 +63,6 @@ public class ApartmentController {
private static ApartmentCatalogService.Details details(ApartmentApi.Request request) {
return new ApartmentCatalogService.Details(request.name(), request.address(), request.purchasePrice(),
request.description());
request.description(), request.legalProfile() == null ? null : request.legalProfile().toDomain());
}
}
@@ -0,0 +1,20 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.apartment;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentDocument;
import java.time.Instant;
import java.time.LocalDate;
final class ApartmentDocumentApi {
private ApartmentDocumentApi() {
}
record Response(String id, String type, LocalDate establishedOn, LocalDate expiresOn, String fileName,
String sha256, String authorId, Instant uploadedAt) {
static Response from(ApartmentDocument document) {
return new Response(document.id(), document.type().code(), document.establishedOn(),
document.expiresOn(), document.fileName(), document.sha256(), document.authorId(),
document.uploadedAt());
}
}
}
@@ -0,0 +1,85 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.apartment;
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlord;
import fr.nessar.lmnporganizer.rental.application.apartment.ApartmentDocumentService;
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentStorageFailure;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentDocumentType;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.time.LocalDate;
import java.util.List;
@RestController
@RequestMapping("/api/apartments/{apartmentId}/documents")
public class ApartmentDocumentController {
private final ApartmentDocumentService documents;
private final AuthenticatedLandlord authenticatedLandlord;
public ApartmentDocumentController(ApartmentDocumentService documents,
AuthenticatedLandlord authenticatedLandlord) {
this.documents = documents;
this.authenticatedLandlord = authenticatedLandlord;
}
@GetMapping
List<ApartmentDocumentApi.Response> list(@AuthenticationPrincipal OidcUser principal,
@PathVariable String apartmentId) {
return documents.list(authenticatedLandlord.id(principal), new ApartmentId(apartmentId)).stream()
.map(ApartmentDocumentApi.Response::from).toList();
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
ResponseEntity<ApartmentDocumentApi.Response> upload(
@AuthenticationPrincipal OidcUser principal, @PathVariable String apartmentId,
@RequestParam String type, @RequestParam LocalDate establishedOn,
@RequestParam(required = false) LocalDate expiresOn, @RequestPart("file") MultipartFile file) {
var stored = documents.upload(authenticatedLandlord.id(principal), new ApartmentId(apartmentId),
ApartmentDocumentType.fromCode(type), establishedOn, expiresOn, upload(file), principal.getSubject());
return ResponseEntity.status(HttpStatus.CREATED).body(ApartmentDocumentApi.Response.from(stored));
}
@GetMapping("/{documentId}")
ResponseEntity<byte[]> download(@AuthenticationPrincipal OidcUser principal, @PathVariable String apartmentId,
@PathVariable String documentId) {
var download = documents.download(authenticatedLandlord.id(principal), new ApartmentId(apartmentId),
documentId);
byte[] content = download.content();
return ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF).contentLength(content.length)
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment().filename(download.document().fileName()).build().toString())
.body(content);
}
@DeleteMapping("/{documentId}")
ResponseEntity<Void> delete(@AuthenticationPrincipal OidcUser principal, @PathVariable String apartmentId,
@PathVariable String documentId) {
documents.delete(authenticatedLandlord.id(principal), new ApartmentId(apartmentId), documentId);
return ResponseEntity.noContent().build();
}
private static ApartmentDocumentService.PdfUpload upload(MultipartFile file) {
try {
return new ApartmentDocumentService.PdfUpload(file.getOriginalFilename(), file.getContentType(),
file.getBytes());
} catch (IOException failure) {
throw new AttachmentStorageFailure("Apartment document could not be opened.", failure);
}
}
}
@@ -4,11 +4,14 @@ import fr.nessar.lmnporganizer.rental.application.rent.RentPaymentConflict;
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentStorageFailure;
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentTooLarge;
import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
import fr.nessar.lmnporganizer.rental.application.shared.IrlIndexUnavailable;
import fr.nessar.lmnporganizer.rental.application.shared.ReferencedResource;
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
import fr.nessar.lmnporganizer.rental.domain.landlord.EmailAlreadyUsed;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRuleViolation;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseFinalizationConflict;
import fr.nessar.lmnporganizer.rental.domain.rent.RentRuleViolation;
import fr.nessar.lmnporganizer.rental.domain.rent.RentRevisionConflict;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
@@ -29,7 +32,8 @@ public class RentalApiExceptionHandler {
return error(HttpStatus.BAD_REQUEST, exception.getMessage());
}
@ExceptionHandler({ReferencedResource.class, EmailAlreadyUsed.class, RentPaymentConflict.class})
@ExceptionHandler({ReferencedResource.class, EmailAlreadyUsed.class, RentPaymentConflict.class,
LeaseFinalizationConflict.class, RentRevisionConflict.class})
ResponseEntity<ApiError> conflict(RuntimeException exception) {
return error(HttpStatus.CONFLICT, exception.getMessage());
}
@@ -49,6 +53,12 @@ public class RentalApiExceptionHandler {
return error(HttpStatus.INTERNAL_SERVER_ERROR, "Impossible de traiter la pièce jointe.");
}
@ExceptionHandler(IrlIndexUnavailable.class)
ResponseEntity<CodedApiError> irlUnavailable(IrlIndexUnavailable exception) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(new CodedApiError(IrlIndexUnavailable.CODE, exception.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
ResponseEntity<ApiError> invalidRequest(MethodArgumentNotValidException exception) {
String message = exception.getBindingResult().getFieldErrors().stream().findFirst()
@@ -62,4 +72,7 @@ public class RentalApiExceptionHandler {
record ApiError(String message) {
}
record CodedApiError(String code, String message) {
}
}
@@ -1,23 +1,36 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.lease;
import fr.nessar.lmnporganizer.rental.adapter.in.web.rent.RentDueApi;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile.BuildingType;
import fr.nessar.lmnporganizer.rental.domain.lease.ChargeMethod;
import fr.nessar.lmnporganizer.rental.domain.lease.Guarantor;
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseAmendment;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseContractVersion;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentAnnex;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentWarning;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseLegalTerms;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseStatus;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSnapshot;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSpecialClause;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseType;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.Instant;
import java.util.List;
import java.util.Map;
final class LeaseApi {
private LeaseApi() {
@@ -27,17 +40,80 @@ final class LeaseApi {
@NotBlank @Email String email, String phone, String address) {
}
record SpecialClauseRequest(@Size(max = 100) String catalogId,
@Size(max = 20) Map<@NotBlank @Size(max = 80) String,
@Size(max = 500) String> parameters,
@Size(max = 4000) String customText) {
}
record RentControlRequest(Boolean evolutionCapApplicable, Boolean referenceRentApplicable,
@PositiveOrZero BigDecimal baseReferenceRent,
@PositiveOrZero BigDecimal increasedReferenceRent,
@PositiveOrZero BigDecimal supplement,
@Size(max = 2000) String supplementJustification) {
LeaseLegalTerms.RentControlTerms toDomain() {
return new LeaseLegalTerms.RentControlTerms(Boolean.TRUE.equals(evolutionCapApplicable),
Boolean.TRUE.equals(referenceRentApplicable), baseReferenceRent, increasedReferenceRent,
supplement, supplementJustification);
}
}
record PreviousRentRequest(Boolean applicable, @PositiveOrZero BigDecimal amount,
LocalDate lastRevisionDate, LocalDate lastPaymentDate,
Boolean tenantConfirmation) {
LeaseLegalTerms.PreviousRentTerms toDomain() {
return new LeaseLegalTerms.PreviousRentTerms(Boolean.TRUE.equals(applicable), amount, lastRevisionDate,
lastPaymentDate, Boolean.TRUE.equals(tenantConfirmation));
}
}
record RentRevisionRequest(Boolean enabled, LocalDate annualRevisionDate,
@Min(1) @Max(4) Integer referenceQuarter,
@Positive Integer referenceYear, @Size(max = 100) String seriesId) {
LeaseLegalTerms.RentRevisionTerms toDomain() {
return new LeaseLegalTerms.RentRevisionTerms(Boolean.TRUE.equals(enabled), annualRevisionDate,
referenceQuarter, referenceYear, seriesId);
}
}
record RecentWorkRequest(@Size(max = 4000) String description, @PositiveOrZero BigDecimal amount) {
LeaseLegalTerms.RecentWork toDomain() {
return new LeaseLegalTerms.RecentWork(description, amount);
}
}
record LegalTermsRequest(@Valid RentControlRequest rentControl, @Valid PreviousRentRequest previousRent,
@Valid RentRevisionRequest rentRevision, @Valid RecentWorkRequest recentWork,
Boolean coOwnershipRulesApplicable, Boolean priorRentalAuthorizationApplicable,
Boolean primaryResidenceEasementApplicable, Boolean jointAndSeveralLiability,
@Size(max = 8000) String otherTerms) {
LeaseLegalTerms toDomain() {
return new LeaseLegalTerms(rentControl == null ? null : rentControl.toDomain(),
previousRent == null ? null : previousRent.toDomain(),
rentRevision == null ? null : rentRevision.toDomain(),
recentWork == null ? null : recentWork.toDomain(),
Boolean.TRUE.equals(coOwnershipRulesApplicable),
Boolean.TRUE.equals(priorRentalAuthorizationApplicable),
Boolean.TRUE.equals(primaryResidenceEasementApplicable),
Boolean.TRUE.equals(jointAndSeveralLiability), otherTerms);
}
}
record DraftRequest(@NotBlank String apartmentId, @NotEmpty List<@NotBlank String> tenantIds,
List<@Valid GuarantorRequest> guarantors, @NotNull LocalDate startDate,
@NotNull LocalDate endDate, @NotNull @Positive BigDecimal monthlyRent,
LocalDate endDate, LeaseType leaseType, @NotNull @Positive BigDecimal monthlyRent,
@NotNull @PositiveOrZero BigDecimal monthlyCharges,
@NotNull @PositiveOrZero BigDecimal securityDeposit) {
@NotNull @PositiveOrZero BigDecimal securityDeposit,
@Min(1) @Max(28) Integer dueDay, ChargeMethod chargeMethod, Boolean indexFlatRate,
@Size(max = 20) List<@Valid SpecialClauseRequest> specialClauses,
@Valid LegalTermsRequest legalTerms) {
}
record AmendmentRequest(@NotNull LocalDate effectiveDate, @NotEmpty List<@NotBlank String> tenantIds,
List<@Valid GuarantorRequest> guarantors, @NotNull LocalDate endDate,
@NotNull @Positive BigDecimal monthlyRent,
@NotNull @PositiveOrZero BigDecimal monthlyCharges) {
@NotNull @PositiveOrZero BigDecimal monthlyCharges,
@Min(1) @Max(28) Integer dueDay, ChargeMethod chargeMethod, Boolean indexFlatRate) {
}
record GuarantorResponse(String id, String firstName, String lastName, String email, String phone,
@@ -48,6 +124,60 @@ final class LeaseApi {
}
}
record SpecialClauseResponse(String catalogId, Map<String, String> parameters, String customText,
String renderedText, boolean custom) {
static SpecialClauseResponse from(LeaseSpecialClause clause) {
return new SpecialClauseResponse(clause.catalogId(), clause.parameters(),
clause.custom() ? clause.text() : "", clause.text(), clause.custom());
}
}
record RentControlResponse(boolean evolutionCapApplicable, boolean referenceRentApplicable,
BigDecimal baseReferenceRent, BigDecimal increasedReferenceRent,
BigDecimal supplement, String supplementJustification) {
static RentControlResponse from(LeaseLegalTerms.RentControlTerms terms) {
return new RentControlResponse(terms.evolutionCapApplicable(), terms.referenceRentApplicable(),
terms.baseReferenceRent(), terms.increasedReferenceRent(), terms.supplement(),
terms.supplementJustification());
}
}
record PreviousRentResponse(boolean applicable, BigDecimal amount, LocalDate lastRevisionDate,
LocalDate lastPaymentDate, boolean tenantConfirmation) {
static PreviousRentResponse from(LeaseLegalTerms.PreviousRentTerms terms) {
return new PreviousRentResponse(terms.applicable(), terms.amount(), terms.lastRevisionDate(),
terms.lastPaymentDate(), terms.tenantConfirmation());
}
}
record RentRevisionResponse(boolean enabled, LocalDate annualRevisionDate, Integer referenceQuarter,
Integer referenceYear, String seriesId) {
static RentRevisionResponse from(LeaseLegalTerms.RentRevisionTerms terms) {
return new RentRevisionResponse(terms.enabled(), terms.annualRevisionDate(), terms.referenceQuarter(),
terms.referenceYear(), terms.seriesId());
}
}
record RecentWorkResponse(String description, BigDecimal amount) {
static RecentWorkResponse from(LeaseLegalTerms.RecentWork work) {
return new RecentWorkResponse(work.description(), work.amount());
}
}
record LegalTermsResponse(RentControlResponse rentControl, PreviousRentResponse previousRent,
RentRevisionResponse rentRevision, RecentWorkResponse recentWork,
boolean coOwnershipRulesApplicable, boolean priorRentalAuthorizationApplicable,
boolean primaryResidenceEasementApplicable, boolean jointAndSeveralLiability,
String otherTerms) {
static LegalTermsResponse from(LeaseLegalTerms terms) {
return new LegalTermsResponse(RentControlResponse.from(terms.rentControl()),
PreviousRentResponse.from(terms.previousRent()), RentRevisionResponse.from(terms.rentRevision()),
RecentWorkResponse.from(terms.recentWork()), terms.coOwnershipRulesApplicable(),
terms.priorRentalAuthorizationApplicable(), terms.primaryResidenceEasementApplicable(),
terms.jointAndSeveralLiability(), terms.otherTerms());
}
}
record LandlordFacts(String id, String firstName, String lastName, String email, String phone, String address) {
static LandlordFacts from(LeaseSnapshot.Landlord landlord) {
return new LandlordFacts(landlord.id().value(), landlord.firstName(), landlord.lastName(),
@@ -55,9 +185,28 @@ final class LeaseApi {
}
}
record ApartmentFacts(String id, String name, String address) {
record LegalProfileFacts(String taxIdentifier, BuildingType buildingType, String ownershipRegime,
String constructionPeriod, BigDecimal habitableSurface, int roomCount,
String otherPrivateAreas, String equipment, String heating, String hotWater,
String energyRating, BigDecimal estimatedAnnualEnergyCost,
Integer estimatedAnnualEnergyCostReferenceYear, String destination,
String privateAccessories, String commonAreas, String internetAccess,
String departmentCode) {
static LegalProfileFacts from(ApartmentLegalProfile profile) {
return new LegalProfileFacts(profile.taxIdentifier(), profile.buildingType(), profile.ownershipRegime(),
profile.constructionPeriod() == null ? "" : profile.constructionPeriod().code(),
profile.habitableSurface(), profile.roomCount(), profile.otherPrivateAreas(), profile.equipment(),
profile.heating(), profile.hotWater(), profile.energyRating(),
profile.estimatedAnnualEnergyCost(), profile.estimatedAnnualEnergyCostReferenceYear(),
profile.destination(), profile.privateAccessories(), profile.commonAreas(),
profile.internetAccess(), profile.departmentCode());
}
}
record ApartmentFacts(String id, String name, String address, LegalProfileFacts legalProfile) {
static ApartmentFacts from(LeaseSnapshot.Apartment apartment) {
return new ApartmentFacts(apartment.id().value(), apartment.name(), apartment.address());
return new ApartmentFacts(apartment.id().value(), apartment.name(), apartment.address(),
LegalProfileFacts.from(apartment.legalProfile()));
}
}
@@ -70,13 +219,18 @@ final class LeaseApi {
record ContractVersion(LandlordFacts landlord, ApartmentFacts apartment, List<TenantFacts> tenants,
List<GuarantorResponse> guarantors, LocalDate startDate, LocalDate endDate,
BigDecimal monthlyRent, BigDecimal monthlyCharges, BigDecimal securityDeposit) {
BigDecimal monthlyRent, BigDecimal monthlyCharges, BigDecimal securityDeposit,
int dueDay, ChargeMethod chargeMethod, boolean indexFlatRate, LeaseType leaseType,
List<SpecialClauseResponse> specialClauses, LegalTermsResponse legalTerms) {
static ContractVersion from(LeaseContractVersion version) {
return new ContractVersion(LandlordFacts.from(version.landlord()), ApartmentFacts.from(version.apartment()),
version.tenants().stream().map(TenantFacts::from).toList(),
version.guarantors().stream().map(GuarantorResponse::from).toList(),
version.period().startDate(), version.period().endDate(), version.terms().monthlyRent(),
version.terms().monthlyCharges(), version.terms().securityDeposit());
version.terms().monthlyCharges(), version.terms().securityDeposit(), version.terms().dueDay(),
version.terms().chargeMethod(), version.terms().indexFlatRate(), version.leaseType(),
version.specialClauses().stream().map(SpecialClauseResponse::from).toList(),
LegalTermsResponse.from(version.legalTerms()));
}
}
@@ -90,7 +244,10 @@ final class LeaseApi {
record Response(String id, String apartmentId, List<String> tenantIds, List<GuarantorResponse> guarantors,
LocalDate startDate, LocalDate endDate, BigDecimal monthlyRent, BigDecimal monthlyCharges,
BigDecimal securityDeposit, String status, ContractVersion contract,
BigDecimal securityDeposit, int dueDay, ChargeMethod chargeMethod, boolean indexFlatRate,
LeaseType leaseType, List<SpecialClauseResponse> specialClauses,
LegalTermsResponse legalTerms, String status,
ContractVersion contract,
List<AmendmentResponse> amendments, long version) {
static Response from(Lease lease, LocalDate date) {
if (lease.status() == LeaseStatus.DRAFT) {
@@ -98,7 +255,10 @@ final class LeaseApi {
lease.tenantIds().stream().map(id -> id.value()).toList(),
lease.guarantors().stream().map(GuarantorResponse::from).toList(), lease.period().startDate(),
lease.period().endDate(), lease.terms().monthlyRent(), lease.terms().monthlyCharges(),
lease.terms().securityDeposit(), lease.status().name(), null,
lease.terms().securityDeposit(), lease.terms().dueDay(), lease.terms().chargeMethod(),
lease.terms().indexFlatRate(), lease.leaseType(),
lease.specialClauses().stream().map(SpecialClauseResponse::from).toList(),
LegalTermsResponse.from(lease.legalTerms()), lease.status().name(), null,
lease.amendments().stream().map(AmendmentResponse::from).toList(), lease.version());
}
LeaseContractVersion effective = lease.contractVersionOn(date);
@@ -106,7 +266,11 @@ final class LeaseApi {
effective.tenantIds().stream().map(id -> id.value()).toList(),
effective.guarantors().stream().map(GuarantorResponse::from).toList(),
effective.period().startDate(), effective.period().endDate(), effective.terms().monthlyRent(),
effective.terms().monthlyCharges(), effective.terms().securityDeposit(), lease.status().name(),
effective.terms().monthlyCharges(), effective.terms().securityDeposit(),
effective.terms().dueDay(), effective.terms().chargeMethod(), effective.terms().indexFlatRate(),
effective.leaseType(),
effective.specialClauses().stream().map(SpecialClauseResponse::from).toList(),
LegalTermsResponse.from(effective.legalTerms()), lease.status().name(),
ContractVersion.from(effective), lease.amendments().stream().map(AmendmentResponse::from).toList(),
lease.version());
}
@@ -114,4 +278,33 @@ final class LeaseApi {
record ActivationResponse(Response lease, List<RentDueApi.Response> rentDues) {
}
record RevisionAnnexResponse(String category, String documentType, String sourceId, String version,
String fileName, String sha256, LocalDate establishedOn, LocalDate expiresOn,
Instant sourceUploadedAt) {
static RevisionAnnexResponse from(LeaseDocumentAnnex annex) {
return new RevisionAnnexResponse(annex.category().name(),
annex.documentType() == null ? null : annex.documentType().code(), annex.sourceId(),
annex.version(), annex.fileName(), annex.sha256(), annex.establishedOn(), annex.expiresOn(),
annex.sourceUploadedAt());
}
}
record RevisionResponse(int number, String templateVersion, String sourceFingerprint, String sha256,
Instant generatedAt, String authorId, List<RevisionAnnexResponse> annexes,
List<String> warningCodes, List<RevisionWarningResponse> warnings) {
static RevisionResponse from(fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentRevision revision) {
return new RevisionResponse(revision.number(), revision.templateVersion(), revision.sourceFingerprint(),
revision.sha256(), revision.generatedAt(), revision.authorId(),
revision.annexes().stream().map(RevisionAnnexResponse::from).toList(),
revision.warnings().stream().map(LeaseDocumentWarning::code).toList(),
revision.warnings().stream().map(RevisionWarningResponse::from).toList());
}
}
record RevisionWarningResponse(String code, String ruleVersion) {
static RevisionWarningResponse from(LeaseDocumentWarning warning) {
return new RevisionWarningResponse(warning.code(), warning.ruleVersion());
}
}
}
@@ -0,0 +1,46 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.lease;
import fr.nessar.lmnporganizer.rental.application.lease.LeaseClauseCatalog;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/lease-clauses")
public class LeaseClauseController {
private final LeaseClauseCatalog catalog;
public LeaseClauseController(LeaseClauseCatalog catalog) {
this.catalog = catalog;
}
@GetMapping
List<ClauseResponse> list(@RequestParam(defaultValue = "ALL") LeaseClauseCatalog.Context context) {
return catalog.available(context).stream().map(ClauseResponse::from).toList();
}
record ClauseResponse(String id, String label, String description,
List<ParameterResponse> parameters) {
static ClauseResponse from(LeaseClauseCatalog.ClauseDefinition clause) {
return new ClauseResponse(clause.id(), clause.label(), clause.description(),
clause.parameters().stream().map(ParameterResponse::from).toList());
}
}
record ParameterResponse(String name, String label, LeaseClauseCatalog.ParameterType type, boolean required,
List<ChoiceResponse> choices) {
static ParameterResponse from(LeaseClauseCatalog.ParameterDefinition parameter) {
return new ParameterResponse(parameter.name(), parameter.label(), parameter.type(), parameter.required(),
parameter.choices().stream().map(ChoiceResponse::from).toList());
}
}
record ChoiceResponse(String value, String label) {
static ChoiceResponse from(LeaseClauseCatalog.Choice choice) {
return new ChoiceResponse(choice.value(), choice.label());
}
}
}
@@ -1,15 +1,13 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.lease;
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlord;
import fr.nessar.lmnporganizer.rental.adapter.in.web.rent.RentDueApi;
import fr.nessar.lmnporganizer.rental.application.lease.ActivateLeaseService;
import fr.nessar.lmnporganizer.rental.application.lease.AmendLeaseService;
import fr.nessar.lmnporganizer.rental.application.lease.ArchiveLeaseService;
import fr.nessar.lmnporganizer.rental.application.lease.GenerateLeaseDocumentService;
import fr.nessar.lmnporganizer.rental.application.lease.LeaseDraftService;
import fr.nessar.lmnporganizer.rental.application.rent.RentDueQueryService;
import fr.nessar.lmnporganizer.rental.application.shared.GeneratedDocument;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
import fr.nessar.lmnporganizer.rental.domain.lease.ChargeMethod;
import fr.nessar.lmnporganizer.rental.domain.lease.Guarantor;
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
@@ -42,24 +40,19 @@ import java.util.UUID;
@RequestMapping("/api/leases")
public class LeaseController {
private final LeaseDraftService drafts;
private final ActivateLeaseService activation;
private final ArchiveLeaseService archiving;
private final AmendLeaseService amendments;
private final GenerateLeaseDocumentService documents;
private final RentDueQueryService rentDues;
private final AuthenticatedLandlord authenticatedLandlord;
private final Clock clock;
public LeaseController(LeaseDraftService drafts, ActivateLeaseService activation,
ArchiveLeaseService archiving, AmendLeaseService amendments,
GenerateLeaseDocumentService documents, RentDueQueryService rentDues,
public LeaseController(LeaseDraftService drafts, ArchiveLeaseService archiving, AmendLeaseService amendments,
GenerateLeaseDocumentService documents,
AuthenticatedLandlord authenticatedLandlord, Clock clock) {
this.drafts = drafts;
this.activation = activation;
this.archiving = archiving;
this.amendments = amendments;
this.documents = documents;
this.rentDues = rentDues;
this.authenticatedLandlord = authenticatedLandlord;
this.clock = clock;
}
@@ -93,17 +86,6 @@ public class LeaseController {
return ResponseEntity.noContent().build();
}
@PostMapping("/{leaseId}/activate")
LeaseApi.ActivationResponse activate(@AuthenticationPrincipal OidcUser principal,
@PathVariable String leaseId) {
var landlordId = authenticatedLandlord.id(principal);
LeaseId id = new LeaseId(leaseId);
var lease = activation.activate(landlordId, id);
LocalDate today = LocalDate.now(clock);
return new LeaseApi.ActivationResponse(response(lease), rentDues.list(landlordId, id).stream()
.map(due -> RentDueApi.Response.from(due, today)).toList());
}
@PostMapping("/{leaseId}/archive")
LeaseApi.Response archive(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId) {
return response(archiving.archive(authenticatedLandlord.id(principal), new LeaseId(leaseId)));
@@ -114,7 +96,8 @@ public class LeaseController {
@PathVariable String leaseId,
@Valid @RequestBody LeaseApi.AmendmentRequest request) {
var change = new AmendLeaseService.Change(tenantIds(request.tenantIds()), guarantors(request.guarantors()),
request.endDate(), request.monthlyRent(), request.monthlyCharges());
request.endDate(), request.monthlyRent(), request.monthlyCharges(), request.dueDay(),
request.chargeMethod(), request.indexFlatRate());
var amended = amendments.amend(authenticatedLandlord.id(principal), new LeaseId(leaseId),
request.effectiveDate(), change);
return ResponseEntity.status(HttpStatus.CREATED).body(response(amended));
@@ -125,11 +108,52 @@ public class LeaseController {
return document(documents.generate(authenticatedLandlord.id(principal), new LeaseId(leaseId)));
}
@PostMapping("/{leaseId}/revisions")
ResponseEntity<LeaseApi.RevisionResponse> generateRevision(@AuthenticationPrincipal OidcUser principal,
@PathVariable String leaseId) {
var revision = documents.generateRevision(authenticatedLandlord.id(principal), new LeaseId(leaseId),
principal.getSubject());
return ResponseEntity.status(HttpStatus.CREATED).body(LeaseApi.RevisionResponse.from(revision));
}
@GetMapping("/{leaseId}/revisions/current")
LeaseApi.RevisionResponse currentRevision(@AuthenticationPrincipal OidcUser principal,
@PathVariable String leaseId) {
return LeaseApi.RevisionResponse.from(
documents.current(authenticatedLandlord.id(principal), new LeaseId(leaseId)));
}
@GetMapping("/{leaseId}/revisions/{revisionNumber}/pdf")
ResponseEntity<byte[]> revisionDocument(@AuthenticationPrincipal OidcUser principal,
@PathVariable String leaseId, @PathVariable int revisionNumber) {
return document(documents.download(authenticatedLandlord.id(principal), new LeaseId(leaseId),
revisionNumber));
}
@GetMapping("/{leaseId}/revisions/{revisionNumber}/zip")
ResponseEntity<byte[]> revisionDossier(@AuthenticationPrincipal OidcUser principal,
@PathVariable String leaseId, @PathVariable int revisionNumber) {
return document(documents.downloadDossier(authenticatedLandlord.id(principal), new LeaseId(leaseId),
revisionNumber));
}
@GetMapping("/{leaseId}/revisions/current/zip")
ResponseEntity<byte[]> currentRevisionDossier(@AuthenticationPrincipal OidcUser principal,
@PathVariable String leaseId) {
return document(documents.downloadCurrentDossier(authenticatedLandlord.id(principal),
new LeaseId(leaseId)));
}
private static LeaseDraftService.DraftDetails details(LeaseApi.DraftRequest request) {
return new LeaseDraftService.DraftDetails(new ApartmentId(request.apartmentId()),
tenantIds(request.tenantIds()), guarantors(request.guarantors()),
new RentalPeriod(request.startDate(), request.endDate()),
new RentTerms(request.monthlyRent(), request.monthlyCharges(), request.securityDeposit()));
new RentalPeriod(request.startDate(), request.startDate()),
new RentTerms(request.monthlyRent(), request.monthlyCharges(), request.securityDeposit(),
request.dueDay() == null ? RentTerms.DEFAULT_DUE_DAY : request.dueDay(),
request.chargeMethod() == null ? ChargeMethod.FLAT_RATE : request.chargeMethod(),
Boolean.TRUE.equals(request.indexFlatRate())), request.leaseType(),
specialClauses(request.specialClauses()),
request.legalTerms() == null ? null : request.legalTerms().toDomain());
}
private LeaseApi.Response response(Lease lease) {
@@ -147,6 +171,12 @@ public class LeaseController {
request.address())).toList();
}
private static List<LeaseDraftService.ClauseDetails> specialClauses(
List<LeaseApi.SpecialClauseRequest> requests) {
return requests == null ? null : requests.stream().map(request -> new LeaseDraftService.ClauseDetails(
request.catalogId(), request.parameters(), request.customText())).toList();
}
private static ResponseEntity<byte[]> document(GeneratedDocument document) {
return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
.filename(document.fileName()).build().toString())
@@ -0,0 +1,112 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.lease;
import fr.nessar.lmnporganizer.rental.adapter.in.web.rent.RentDueApi;
import fr.nessar.lmnporganizer.rental.application.lease.LeaseFinalizationService;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentRevision;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentAnnex;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentWarning;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseFinalization;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseFinalizationWarning;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseGuaranteeDeed;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
final class LeaseFinalizationApi {
private LeaseFinalizationApi() {
}
record DeedResponse(String guarantorId, String tenantId, String fileName, String sha256, String authorId,
Instant uploadedAt) {
static DeedResponse from(LeaseGuaranteeDeed deed) {
return new DeedResponse(deed.guarantorId(), deed.tenantId().value(), deed.fileName(), deed.sha256(),
deed.authorId(), deed.uploadedAt());
}
}
record RevisionAnnexResponse(String category, String documentType, String sourceId, String version,
String fileName, String sha256, LocalDate establishedOn, LocalDate expiresOn,
Instant sourceUploadedAt) {
static RevisionAnnexResponse from(LeaseDocumentAnnex annex) {
return new RevisionAnnexResponse(annex.category().name(),
annex.documentType() == null ? null : annex.documentType().code(), annex.sourceId(),
annex.version(), annex.fileName(), annex.sha256(), annex.establishedOn(), annex.expiresOn(),
annex.sourceUploadedAt());
}
}
record RevisionResponse(int number, String templateVersion, String sourceFingerprint, String sha256,
Instant generatedAt, String authorId, List<RevisionAnnexResponse> annexes,
List<String> warningCodes, List<WarningResponse> warnings) {
static RevisionResponse from(LeaseDocumentRevision revision) {
return revision == null ? null : new RevisionResponse(revision.number(), revision.templateVersion(),
revision.sourceFingerprint(), revision.sha256(), revision.generatedAt(), revision.authorId(),
revision.annexes().stream().map(RevisionAnnexResponse::from).toList(),
revision.warnings().stream().map(LeaseDocumentWarning::code).toList(),
revision.warnings().stream().map(WarningResponse::from).toList());
}
}
record WarningResponse(String code, String ruleVersion) {
static WarningResponse from(LeaseDocumentWarning warning) {
return new WarningResponse(warning.code(), warning.ruleVersion());
}
static WarningResponse from(String code, LeaseDocumentRevision revision) {
if (revision != null) {
var frozen = revision.warnings().stream().filter(warning -> warning.code().equals(code))
.findFirst();
if (frozen.isPresent()) {
return from(frozen.orElseThrow());
}
}
LeaseFinalizationWarning warning = LeaseFinalizationWarning.valueOf(code);
return new WarningResponse(code, warning.ruleVersion());
}
}
record OverrideResponse(String code, String ruleVersion, String authorId, Instant confirmedAt) {
static OverrideResponse from(LeaseFinalization.LegalOverride override) {
return new OverrideResponse(override.code(), override.ruleVersion(), override.authorId(),
override.confirmedAt());
}
}
record FinalizationMetadata(int revisionNumber, String templateVersion, LocalDate lastSignatureDate,
String signedFileName, String signedSha256, String authorId, Instant finalizedAt,
List<OverrideResponse> overrides) {
static FinalizationMetadata from(LeaseFinalization finalization) {
return finalization == null ? null : new FinalizationMetadata(finalization.revisionNumber(),
finalization.templateVersion(), finalization.lastSignatureDate(), finalization.signedFileName(),
finalization.signedSha256(), finalization.authorId(), finalization.finalizedAt(),
finalization.overrides().stream().map(OverrideResponse::from).toList());
}
}
record GuaranteeRequirementResponse(String guarantorId, String guarantorName, String tenantId,
String tenantName, DeedResponse deed) {
static GuaranteeRequirementResponse from(LeaseFinalizationService.GuaranteeRequirement requirement) {
return new GuaranteeRequirementResponse(requirement.guarantorId(), requirement.guarantorName(),
requirement.tenantId().value(), requirement.tenantName(),
requirement.deed() == null ? null : DeedResponse.from(requirement.deed()));
}
}
record StateResponse(RevisionResponse revision, String guaranteeSimulatorUrl, List<String> warningCodes,
List<WarningResponse> warnings,
List<GuaranteeRequirementResponse> guaranteeRequirements,
FinalizationMetadata finalization) {
static StateResponse from(LeaseFinalizationService.State state) {
return new StateResponse(RevisionResponse.from(state.revision()),
LeaseFinalizationService.GUARANTEE_SIMULATOR_URL, state.warningCodes(),
state.warningCodes().stream().map(code -> WarningResponse.from(code, state.revision())).toList(),
state.guaranteeRequirements().stream().map(GuaranteeRequirementResponse::from).toList(),
FinalizationMetadata.from(state.finalization()));
}
}
record FinalizationResponse(LeaseApi.Response lease, List<RentDueApi.Response> rentDues,
FinalizationMetadata finalization) {
}
}
@@ -0,0 +1,89 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.lease;
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlord;
import fr.nessar.lmnporganizer.rental.adapter.in.web.rent.RentDueApi;
import fr.nessar.lmnporganizer.rental.application.lease.LeaseFinalizationService;
import fr.nessar.lmnporganizer.rental.application.lease.LeaseFinalizationService.FinalizationRequest;
import fr.nessar.lmnporganizer.rental.application.lease.LeaseFinalizationService.PdfUpload;
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentStorageFailure;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.time.Clock;
import java.time.LocalDate;
import java.util.List;
import java.util.Set;
@RestController("rentalLeaseFinalizationController")
@RequestMapping("/api/leases/{leaseId}")
public class LeaseFinalizationController {
private final LeaseFinalizationService finalizations;
private final AuthenticatedLandlord authenticatedLandlord;
private final Clock clock;
public LeaseFinalizationController(LeaseFinalizationService finalizations,
AuthenticatedLandlord authenticatedLandlord, Clock clock) {
this.finalizations = finalizations;
this.authenticatedLandlord = authenticatedLandlord;
this.clock = clock;
}
@PostMapping(path = "/guarantee-deeds", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
ResponseEntity<LeaseFinalizationApi.DeedResponse> uploadGuaranteeDeed(
@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId,
@RequestParam("guarantorId") String guarantorId, @RequestParam("tenantId") String tenantId,
@RequestParam("attestation") boolean attestation, @RequestPart("file") MultipartFile file) {
var deed = finalizations.uploadGuaranteeDeed(authenticatedLandlord.id(principal), new LeaseId(leaseId),
guarantorId, new TenantId(tenantId), attestation, upload(file), principal.getSubject());
return ResponseEntity.status(HttpStatus.CREATED).body(LeaseFinalizationApi.DeedResponse.from(deed));
}
@GetMapping("/finalization")
LeaseFinalizationApi.StateResponse state(@AuthenticationPrincipal OidcUser principal,
@PathVariable String leaseId) {
return LeaseFinalizationApi.StateResponse.from(
finalizations.state(authenticatedLandlord.id(principal), new LeaseId(leaseId)));
}
@PostMapping(path = "/finalization", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
ResponseEntity<LeaseFinalizationApi.FinalizationResponse> finalizeLease(
@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId,
@RequestPart("signedLease") MultipartFile signedLease,
@RequestParam("revisionNumber") int revisionNumber,
@RequestParam("lastSignatureDate") LocalDate lastSignatureDate,
@RequestParam("allPartiesSigned") boolean allPartiesSigned,
@RequestParam("revisionMatches") boolean revisionMatches,
@RequestParam(value = "confirmedWarningCodes", required = false) List<String> confirmedWarningCodes) {
LocalDate today = LocalDate.now(clock);
var result = finalizations.finalizeLease(authenticatedLandlord.id(principal), new LeaseId(leaseId),
new FinalizationRequest(revisionNumber, lastSignatureDate, allPartiesSigned, revisionMatches,
confirmedWarningCodes == null ? Set.of() : Set.copyOf(confirmedWarningCodes)),
upload(signedLease), principal.getSubject());
var response = new LeaseFinalizationApi.FinalizationResponse(LeaseApi.Response.from(result.lease(), today),
result.rentDues().stream().map(due -> RentDueApi.Response.from(due, today)).toList(),
LeaseFinalizationApi.FinalizationMetadata.from(result.finalization()));
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
private static PdfUpload upload(MultipartFile file) {
try {
return new PdfUpload(file.getOriginalFilename(), file.getContentType(), file.getBytes());
} catch (IOException failure) {
throw new AttachmentStorageFailure("PDF could not be opened.", failure);
}
}
}
@@ -2,6 +2,7 @@ package fr.nessar.lmnporganizer.rental.adapter.in.web.rent;
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlord;
import fr.nessar.lmnporganizer.rental.application.rent.GenerateRentReceiptService;
import fr.nessar.lmnporganizer.rental.application.rent.RefreshRentScheduleService;
import fr.nessar.lmnporganizer.rental.application.rent.RentDueQueryService;
import fr.nessar.lmnporganizer.rental.application.shared.GeneratedDocument;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
@@ -13,6 +14,7 @@ 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.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@@ -25,18 +27,28 @@ import java.util.List;
@RequestMapping("/api/rent-dues")
public class RentDueController {
private final RentDueQueryService rentDues;
private final RefreshRentScheduleService refresh;
private final GenerateRentReceiptService receipts;
private final AuthenticatedLandlord authenticatedLandlord;
private final Clock clock;
public RentDueController(RentDueQueryService rentDues, GenerateRentReceiptService receipts,
public RentDueController(RentDueQueryService rentDues, RefreshRentScheduleService refresh,
GenerateRentReceiptService receipts,
AuthenticatedLandlord authenticatedLandlord, Clock clock) {
this.rentDues = rentDues;
this.refresh = refresh;
this.receipts = receipts;
this.authenticatedLandlord = authenticatedLandlord;
this.clock = clock;
}
@PostMapping("/refresh")
List<RentDueApi.Response> refresh(@AuthenticationPrincipal OidcUser principal) {
LocalDate today = LocalDate.now(clock);
return refresh.refresh(authenticatedLandlord.id(principal)).stream()
.map(due -> RentDueApi.Response.from(due, today)).toList();
}
@GetMapping
List<RentDueApi.Response> list(@AuthenticationPrincipal OidcUser principal,
@RequestParam(required = false) String leaseId) {
@@ -124,7 +124,7 @@ public class RentPaymentController {
.header(HttpHeaders.CONTENT_TYPE, document.contentType()).body(document.content());
}
private static ZoneId timeZone(String value) {
static ZoneId timeZone(String value) {
try {
return ZoneId.of(value);
} catch (DateTimeException exception) {
@@ -0,0 +1,109 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.rent;
import fr.nessar.lmnporganizer.rental.application.rent.RentRevisionService;
import fr.nessar.lmnporganizer.rental.domain.rent.RentRevision;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.Set;
final class RentRevisionApi {
private RentRevisionApi() {
}
record PreviewRequest(@NotBlank String timeZone, @Size(max = 9) String seriesIdOverride,
Boolean seriesOverrideConfirmed,
@Positive BigDecimal previousIndex, @Positive BigDecimal newIndex,
Boolean manualIndicesConfirmed) {
RentRevisionService.PreviewRequest toApplication() {
return new RentRevisionService.PreviewRequest(RentPaymentController.timeZone(timeZone),
seriesIdOverride, Boolean.TRUE.equals(seriesOverrideConfirmed), previousIndex, newIndex,
Boolean.TRUE.equals(manualIndicesConfirmed));
}
}
record ProposalRequest(@NotBlank String timeZone, @NotNull LocalDate requestedOn,
@NotNull @Positive BigDecimal chosenRent, @Positive BigDecimal chosenCharges,
List<@NotBlank String> confirmedWarningCodes, @Size(max = 9) String seriesIdOverride,
Boolean seriesOverrideConfirmed, @Positive BigDecimal previousIndex,
@Positive BigDecimal newIndex, Boolean manualIndicesConfirmed) {
RentRevisionService.ProposalRequest toApplication() {
var preview = new RentRevisionService.PreviewRequest(RentPaymentController.timeZone(timeZone),
seriesIdOverride, Boolean.TRUE.equals(seriesOverrideConfirmed), previousIndex, newIndex,
Boolean.TRUE.equals(manualIndicesConfirmed));
return new RentRevisionService.ProposalRequest(preview, requestedOn, chosenRent, chosenCharges,
confirmedWarningCodes == null ? Set.of() : Set.copyOf(confirmedWarningCodes));
}
}
record PreviewResponse(String leaseId, LocalDate annualRevisionDate, LocalDate requestDeadline,
String seriesId, boolean seriesOverridden, String indexSource, int referenceQuarter,
int previousIndexYear, int newIndexYear, BigDecimal previousIndex, BigDecimal newIndex,
BigDecimal previousRent, BigDecimal rentCeiling, BigDecimal previousCharges,
BigDecimal chargeCeiling, List<RentRevisionService.Warning> warnings) {
static PreviewResponse from(RentRevisionService.Preview preview) {
return new PreviewResponse(preview.leaseId().value(), preview.annualRevisionDate(),
preview.requestDeadline(), preview.seriesId(), preview.seriesOverridden(), preview.indexSource(),
preview.referenceQuarter(), preview.previousIndexYear(), preview.newIndexYear(),
preview.previousIndex(), preview.newIndex(), preview.previousRent(), preview.rentCeiling(),
preview.previousCharges(), preview.chargeCeiling(), preview.warnings());
}
}
record OverrideResponse(String code, String ruleVersion, String authorId, Instant confirmedAt) {
static OverrideResponse from(RentRevision.LegalOverride override) {
return new OverrideResponse(override.code(), override.ruleVersion(), override.authorId(),
override.confirmedAt());
}
}
record ProofResponse(String fileName, String sha256, String authorId, Instant uploadedAt) {
static ProofResponse from(RentRevision.Proof proof) {
return proof == null ? null : new ProofResponse(proof.fileName(), proof.sha256(), proof.authorId(),
proof.uploadedAt());
}
}
record ApplicationResponse(LocalDate sentOn, String deliveryMethod, LocalDate effectiveOn, ProofResponse proof,
String appliedBy, Instant appliedAt) {
static ApplicationResponse from(RentRevision.Application application) {
return application == null ? null : new ApplicationResponse(application.sentOn(),
application.deliveryMethod().name(), application.effectiveOn(), ProofResponse.from(application.proof()),
application.appliedBy(), application.appliedAt());
}
}
record RevisionResponse(String id, String leaseId, String status, long version,
LocalDate annualRevisionDate, LocalDate requestDeadline, LocalDate requestedOn,
LocalDate effectiveOn, String seriesId, boolean seriesOverridden, String indexSource,
int referenceQuarter, int previousIndexYear, int newIndexYear, BigDecimal previousIndex,
BigDecimal newIndex, BigDecimal previousRent, BigDecimal rentCeiling,
BigDecimal chosenRent, BigDecimal previousCharges, BigDecimal chargeCeiling,
BigDecimal chosenCharges, List<OverrideResponse> warningOverrides, String letterSha256,
String proposedBy, Instant proposedAt, ApplicationResponse application) {
static RevisionResponse from(RentRevision revision) {
return new RevisionResponse(revision.id().value(), revision.leaseId().value(), revision.status().name(),
revision.version(), revision.window().annualRevisionDate(), revision.window().requestDeadline(),
revision.request().requestedOn(), revision.request().effectiveOn(),
revision.indexReference().seriesId(), revision.indexReference().seriesOverridden(),
revision.indexReference().source().name(), revision.indexReference().referenceQuarter(),
revision.indexReference().previousYear(), revision.indexReference().newYear(),
revision.indexReference().previousIndex(), revision.indexReference().newIndex(),
revision.amounts().previousRent(), revision.amounts().rentCeiling(),
revision.amounts().chosenRent(), revision.amounts().previousCharges(),
revision.amounts().chargeCeiling(), revision.amounts().chosenCharges(),
revision.warningOverrides().stream().map(OverrideResponse::from).toList(),
revision.request().letterSha256(), revision.request().proposedBy(),
revision.request().proposedAt(), ApplicationResponse.from(revision.application()));
}
}
record ApplyResponse(RevisionResponse revision, List<RentDueApi.Response> rentDues) {
}
}
@@ -0,0 +1,136 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.rent;
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlord;
import fr.nessar.lmnporganizer.rental.application.rent.RentRevisionService;
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentStorageFailure;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.rent.RentRevision;
import fr.nessar.lmnporganizer.rental.domain.rent.RentRevisionId;
import jakarta.validation.Valid;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.time.Clock;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List;
@RestController
@RequestMapping("/api/leases/{leaseId}/rent-revisions")
public class RentRevisionController {
private final RentRevisionService revisions;
private final AuthenticatedLandlord authenticatedLandlord;
private final Clock clock;
public RentRevisionController(RentRevisionService revisions, AuthenticatedLandlord authenticatedLandlord,
Clock clock) {
this.revisions = revisions;
this.authenticatedLandlord = authenticatedLandlord;
this.clock = clock;
}
@PostMapping("/preview")
RentRevisionApi.PreviewResponse preview(@AuthenticationPrincipal OidcUser principal,
@PathVariable String leaseId,
@Valid @RequestBody RentRevisionApi.PreviewRequest request) {
return RentRevisionApi.PreviewResponse.from(revisions.preview(authenticatedLandlord.id(principal),
new LeaseId(leaseId), request.toApplication()));
}
@PostMapping
ResponseEntity<RentRevisionApi.RevisionResponse> propose(@AuthenticationPrincipal OidcUser principal,
@PathVariable String leaseId,
@Valid @RequestBody RentRevisionApi.ProposalRequest request) {
RentRevision revision = revisions.propose(authenticatedLandlord.id(principal), new LeaseId(leaseId),
request.toApplication(), principal.getSubject());
return ResponseEntity.status(HttpStatus.CREATED).body(RentRevisionApi.RevisionResponse.from(revision));
}
@GetMapping
List<RentRevisionApi.RevisionResponse> list(@AuthenticationPrincipal OidcUser principal,
@PathVariable String leaseId) {
return revisions.list(authenticatedLandlord.id(principal), new LeaseId(leaseId)).stream()
.map(RentRevisionApi.RevisionResponse::from).toList();
}
@GetMapping("/{revisionId}/letter")
ResponseEntity<byte[]> letter(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId,
@PathVariable String revisionId) {
RentRevisionService.Letter letter = revisions.letter(authenticatedLandlord.id(principal),
new LeaseId(leaseId), new RentRevisionId(revisionId));
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment().filename(letter.fileName()).build().toString())
.contentType(MediaType.APPLICATION_PDF).body(letter.content());
}
@GetMapping("/{revisionId}/proof")
ResponseEntity<byte[]> proof(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId,
@PathVariable String revisionId) {
RentRevisionService.ProofDownload proof = revisions.proof(authenticatedLandlord.id(principal),
new LeaseId(leaseId), new RentRevisionId(revisionId));
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment().filename(proof.fileName()).build().toString())
.contentType(MediaType.APPLICATION_PDF).body(proof.content());
}
@PostMapping(value = "/{revisionId}/apply", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
RentRevisionApi.ApplyResponse apply(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId,
@PathVariable String revisionId,
@RequestParam String timeZone, @RequestParam LocalDate sentOn,
@RequestParam String deliveryMethod,
@RequestParam boolean deliveryConfirmed,
@RequestPart(value = "proof", required = false) MultipartFile proof) {
ZoneId zone = RentPaymentController.timeZone(timeZone);
RentRevisionService.ApplyResult result = revisions.apply(authenticatedLandlord.id(principal),
new LeaseId(leaseId), new RentRevisionId(revisionId),
new RentRevisionService.ApplyRequest(zone, sentOn,
deliveryMethod(deliveryMethod), deliveryConfirmed, upload(proof)),
principal.getSubject());
LocalDate today = LocalDate.now(clock.withZone(zone));
return new RentRevisionApi.ApplyResponse(RentRevisionApi.RevisionResponse.from(result.revision()),
result.rentDues().stream().map(due -> RentDueApi.Response.from(due, today)).toList());
}
private static RentRevisionService.PdfUpload upload(MultipartFile file) {
if (file == null) {
return null;
}
try {
return new RentRevisionService.PdfUpload(file.getOriginalFilename(), file.getContentType(),
file.getBytes());
} catch (IOException failure) {
throw new AttachmentStorageFailure("PDF could not be opened.", failure);
}
}
private static RentRevision.DeliveryMethod deliveryMethod(String value) {
if (value == null) {
throw new IllegalArgumentException("Le mode d'envoi est invalide.");
}
return switch (value) {
case "REGISTERED_LETTER" -> RentRevision.DeliveryMethod.REGISTERED_LETTER;
case "POSTAL_LETTER" -> RentRevision.DeliveryMethod.POSTAL_LETTER;
case "HAND_DELIVERY" -> RentRevision.DeliveryMethod.HAND_DELIVERY;
case "EMAIL" -> RentRevision.DeliveryMethod.EMAIL;
case "OTHER" -> RentRevision.DeliveryMethod.OTHER;
default -> throw new IllegalArgumentException("Le mode d'envoi est invalide.");
};
}
}
@@ -0,0 +1,280 @@
package fr.nessar.lmnporganizer.rental.adapter.out.filesystem;
import fr.nessar.lmnporganizer.rental.adapter.in.config.RentalStorageProperties;
import fr.nessar.lmnporganizer.rental.application.port.out.LeaseDocumentStorage;
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentStorageFailure;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.file.DirectoryNotEmptyException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.Comparator;
import java.util.List;
import java.util.UUID;
@Component
public final class FileSystemLeaseDocumentStorage implements LeaseDocumentStorage {
private static final String STAGED = "staged";
private static final String BACKUP = "backup";
private static final String COMMITTED = "committed";
private static final Logger LOGGER = LoggerFactory.getLogger(FileSystemLeaseDocumentStorage.class);
private final Path root;
private final Path stagingRoot;
public FileSystemLeaseDocumentStorage(RentalStorageProperties properties) {
root = properties.attachments().toAbsolutePath().normalize().resolveSibling("leases");
stagingRoot = root.resolve(".staging");
recoverInterruptedDeletions();
}
@Override
public String store(byte[] content) {
sweepCommittedTombstones();
String storageKey = UUID.randomUUID() + ".pdf";
Path destination = path(storageKey);
try {
Files.createDirectories(root);
Files.write(destination, content, StandardOpenOption.CREATE_NEW);
return storageKey;
} catch (IOException | RuntimeException failure) {
try {
Files.deleteIfExists(destination);
} catch (IOException cleanupFailure) {
failure.addSuppressed(cleanupFailure);
}
throw failure("Lease document could not be stored.", failure);
}
}
@Override
public byte[] read(String storageKey) {
Path source = path(storageKey);
try {
if (!Files.isRegularFile(source, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Lease document not found.");
}
return Files.readAllBytes(source);
} catch (IOException | RuntimeException failure) {
throw failure("Lease document could not be read.", failure);
}
}
@Override
public void discard(String storageKey) {
try {
Files.deleteIfExists(path(storageKey));
} catch (IOException | RuntimeException failure) {
throw failure("Lease document could not be discarded.", failure);
}
}
@Override
public StagedDeletion stageDeletion(String storageKey) {
sweepCommittedTombstones();
Path active = path(storageKey);
Path deletionDirectory = stagingRoot.resolve(storageKey);
try {
if (!Files.isRegularFile(active, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Lease document not found.");
}
Files.createDirectories(deletionDirectory);
Path staged = deletionDirectory.resolve(STAGED);
Path backup = deletionDirectory.resolve(BACKUP);
Path committed = deletionDirectory.resolve(COMMITTED);
Files.move(active, staged, StandardCopyOption.ATOMIC_MOVE);
try {
createBackup(staged, backup);
} catch (IOException | RuntimeException failure) {
Files.deleteIfExists(backup);
Files.move(staged, active, StandardCopyOption.ATOMIC_MOVE);
cleanEmptyParents(deletionDirectory);
throw failure;
}
return new FileDeletion(active, staged, backup, committed);
} catch (IOException | RuntimeException failure) {
throw failure("Lease document deletion could not be staged.", failure);
}
}
@Override
public void restore(StagedDeletion deletion) {
FileDeletion files = files(deletion);
try {
Files.createDirectories(files.active().getParent());
Files.move(firstExisting(files.staged(), files.backup(), files.committed()), files.active(),
StandardCopyOption.ATOMIC_MOVE);
discardArtifacts(files.staged().getParent());
cleanEmptyParents(files.staged().getParent());
} catch (IOException | RuntimeException failure) {
throw failure("Lease document could not be restored.", failure);
}
}
@Override
public void completeDeletion(StagedDeletion deletion) {
FileDeletion files = files(deletion);
try {
Files.move(files.backup(), files.committed(), StandardCopyOption.ATOMIC_MOVE);
Files.deleteIfExists(files.staged());
Files.deleteIfExists(files.committed());
cleanEmptyParents(files.staged().getParent());
} catch (IOException | RuntimeException failure) {
LOGGER.warn("Committed lease document tombstone cleanup deferred", failure);
}
}
private Path path(String storageKey) {
if (storageKey == null || !storageKey.endsWith(".pdf")) {
throw failure("Invalid lease document path.", new IllegalArgumentException("Invalid storage key."));
}
String id = storageKey.substring(0, storageKey.length() - 4);
try {
if (!UUID.fromString(id).toString().equals(id)) {
throw new IllegalArgumentException("Non-canonical UUID.");
}
} catch (IllegalArgumentException failure) {
throw failure("Invalid lease document path.", failure);
}
Path candidate = root.resolve(storageKey).normalize();
if (!candidate.startsWith(root) || candidate.equals(root)) {
throw failure("Invalid lease document path.", new IllegalArgumentException("Path escapes root."));
}
return candidate;
}
private void recoverInterruptedDeletions() {
if (!Files.isDirectory(stagingRoot, LinkOption.NOFOLLOW_LINKS)) {
return;
}
try {
List<Path> deletionDirectories;
try (var paths = Files.list(stagingRoot)) {
deletionDirectories = paths.filter(path -> Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS))
.filter(this::hasArtifacts).sorted(Comparator.reverseOrder()).toList();
}
deletionDirectories.forEach(this::recoverInterruptedDeletion);
} catch (IOException | RuntimeException failure) {
throw failure("Interrupted lease document deletions could not be recovered.", failure);
}
}
private void recoverInterruptedDeletion(Path deletionDirectory) {
try {
if (!deletionDirectory.getParent().equals(stagingRoot)) {
throw new IOException("Invalid staged lease document path.");
}
Path committed = deletionDirectory.resolve(COMMITTED);
if (Files.isRegularFile(committed, LinkOption.NOFOLLOW_LINKS)) {
discardArtifacts(deletionDirectory);
} else {
Path active = path(deletionDirectory.getFileName().toString());
if (!Files.exists(active, LinkOption.NOFOLLOW_LINKS)) {
Files.createDirectories(active.getParent());
Files.move(firstExisting(deletionDirectory.resolve(STAGED), deletionDirectory.resolve(BACKUP)),
active, StandardCopyOption.ATOMIC_MOVE);
} else if (!Files.isRegularFile(active, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Recovered lease document path is not a regular file.");
}
discardArtifacts(deletionDirectory);
}
cleanEmptyParents(deletionDirectory);
} catch (IOException | RuntimeException failure) {
throw failure("Interrupted lease document deletion could not be recovered.", failure);
}
}
private void sweepCommittedTombstones() {
if (!Files.isDirectory(stagingRoot, LinkOption.NOFOLLOW_LINKS)) {
return;
}
try (var paths = Files.walk(stagingRoot)) {
paths.filter(candidate -> Files.isRegularFile(candidate, LinkOption.NOFOLLOW_LINKS))
.filter(candidate -> candidate.getFileName().toString().equals(COMMITTED))
.forEach(this::discardCommitted);
} catch (IOException | RuntimeException failure) {
LOGGER.warn("Committed lease document tombstones could not be scanned", failure);
}
}
private void discardCommitted(Path committed) {
try {
discardArtifacts(committed.getParent());
cleanEmptyParents(committed.getParent());
} catch (IOException | RuntimeException failure) {
LOGGER.warn("Committed lease document tombstone cleanup deferred: {}", committed.getFileName(),
failure);
}
}
private boolean hasArtifacts(Path directory) {
return Files.isRegularFile(directory.resolve(STAGED), LinkOption.NOFOLLOW_LINKS)
|| Files.isRegularFile(directory.resolve(BACKUP), LinkOption.NOFOLLOW_LINKS)
|| Files.isRegularFile(directory.resolve(COMMITTED), LinkOption.NOFOLLOW_LINKS);
}
private static void discardArtifacts(Path directory) throws IOException {
Files.deleteIfExists(directory.resolve(STAGED));
Files.deleteIfExists(directory.resolve(BACKUP));
Files.deleteIfExists(directory.resolve(COMMITTED));
}
private void cleanEmptyParents(Path directory) throws IOException {
Path candidate = directory;
while (candidate != null && candidate.startsWith(stagingRoot)) {
try {
Files.deleteIfExists(candidate);
} catch (DirectoryNotEmptyException ignored) {
return;
}
if (candidate.equals(stagingRoot)) {
return;
}
candidate = candidate.getParent();
}
}
private static Path firstExisting(Path... candidates) throws IOException {
for (Path candidate : candidates) {
if (Files.isRegularFile(candidate, LinkOption.NOFOLLOW_LINKS)) {
return candidate;
}
}
throw new IOException("Lease document backup not found.");
}
private static void createBackup(Path staged, Path backup) throws IOException {
try {
Files.createLink(backup, staged);
} catch (IOException | UnsupportedOperationException linkFailure) {
try {
Files.copy(staged, backup);
} catch (IOException | RuntimeException copyFailure) {
copyFailure.addSuppressed(linkFailure);
throw copyFailure;
}
}
}
private static FileDeletion files(StagedDeletion deletion) {
if (deletion instanceof FileDeletion fileDeletion) {
return fileDeletion;
}
throw new IllegalArgumentException("Unsupported lease document deletion token.");
}
private static AttachmentStorageFailure failure(String message, Throwable failure) {
return failure instanceof AttachmentStorageFailure storageFailure
? storageFailure
: new AttachmentStorageFailure(message, failure);
}
private record FileDeletion(Path active, Path staged, Path backup, Path committed) implements StagedDeletion {
}
}
@@ -0,0 +1,180 @@
package fr.nessar.lmnporganizer.rental.adapter.out.insee;
import fr.nessar.lmnporganizer.rental.application.port.out.IrlIndexProvider;
import fr.nessar.lmnporganizer.rental.application.shared.IrlIndexUnavailable;
import fr.nessar.lmnporganizer.rental.domain.rent.RentRevisionPolicy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.mapping.Field;
import org.springframework.data.mongodb.core.mapping.FieldType;
import org.w3c.dom.Element;
import org.xml.sax.SAXException;
import org.xml.sax.helpers.DefaultHandler;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigDecimal;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
public final class InseeIrlIndexProvider implements IrlIndexProvider {
private static final String ACCEPT = "application/vnd.sdmx.structurespecificdata+xml;version=2.1";
private static final int MAX_RESPONSE_BYTES = 256 * 1024;
private static final Logger LOGGER = LoggerFactory.getLogger(InseeIrlIndexProvider.class);
private final HttpClient httpClient;
private final MongoTemplate mongoTemplate;
private final URI baseUri;
private final Duration requestTimeout;
public InseeIrlIndexProvider(HttpClient httpClient, MongoTemplate mongoTemplate, URI baseUri,
Duration requestTimeout) {
this.httpClient = httpClient;
this.mongoTemplate = mongoTemplate;
this.baseUri = baseUri;
this.requestTimeout = requestTimeout;
}
@Override
public Indexes indexes(String seriesId, Period previousPeriod, Period newPeriod) {
if (!RentRevisionPolicy.isSupportedSeries(seriesId)) {
throw new IllegalArgumentException("Unsupported IRL series.");
}
try {
Map<Period, BigDecimal> observations = fetch(seriesId, previousPeriod, newPeriod);
BigDecimal previousIndex = required(observations, previousPeriod);
BigDecimal newIndex = required(observations, newPeriod);
mongoTemplate.save(CachedIndex.of(seriesId, previousPeriod, previousIndex));
mongoTemplate.save(CachedIndex.of(seriesId, newPeriod, newIndex));
return new Indexes(previousIndex, newIndex, Source.INSEE);
} catch (IOException | ParserConfigurationException | SAXException | RuntimeException exception) {
return cached(seriesId, previousPeriod, newPeriod, exception);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
return cached(seriesId, previousPeriod, newPeriod, exception);
}
}
private Indexes cached(String seriesId, Period previousPeriod, Period newPeriod, Exception failure) {
LOGGER.warn("INSEE IRL retrieval failed for series {} between {} and {}; trying Mongo cache: {}",
seriesId, token(previousPeriod), token(newPeriod), failure.toString());
try {
CachedIndex previous = mongoTemplate.findById(CachedIndex.id(seriesId, previousPeriod),
CachedIndex.class);
CachedIndex current = mongoTemplate.findById(CachedIndex.id(seriesId, newPeriod), CachedIndex.class);
if (previous == null || current == null) {
throw new IrlIndexUnavailable();
}
return new Indexes(previous.value(), current.value(), Source.CACHE);
} catch (IrlIndexUnavailable unavailable) {
throw unavailable;
} catch (RuntimeException cacheFailure) {
throw new IrlIndexUnavailable();
}
}
private Map<Period, BigDecimal> fetch(String seriesId, Period previousPeriod, Period newPeriod)
throws IOException, InterruptedException, ParserConfigurationException, SAXException {
URI uri = URI.create(normalizedBaseUri() + seriesId + "?startPeriod=" + token(previousPeriod)
+ "&endPeriod=" + token(newPeriod));
HttpRequest request = HttpRequest.newBuilder(uri).timeout(requestTimeout).header("Accept", ACCEPT).build();
HttpResponse<InputStream> response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
try (InputStream body = response.body()) {
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new IOException("INSEE returned HTTP " + response.statusCode());
}
byte[] xml = body.readNBytes(MAX_RESPONSE_BYTES + 1);
if (xml.length > MAX_RESPONSE_BYTES) {
throw new IOException("INSEE response is too large.");
}
return parse(xml, seriesId);
}
}
private String normalizedBaseUri() {
String value = baseUri.toString();
return value.endsWith("/") ? value : value + "/";
}
private static Map<Period, BigDecimal> parse(byte[] xml, String seriesId)
throws ParserConfigurationException, IOException, SAXException {
DocumentBuilderFactory factory = secureDocumentBuilderFactory();
var builder = factory.newDocumentBuilder();
builder.setErrorHandler(new DefaultHandler());
org.w3c.dom.Document document = builder.parse(new ByteArrayInputStream(xml));
Map<Period, BigDecimal> observations = new HashMap<>();
var series = document.getElementsByTagNameNS("*", "Series");
for (int seriesIndex = 0; seriesIndex < series.getLength(); seriesIndex++) {
Element element = (Element) series.item(seriesIndex);
if (!seriesId.equals(element.getAttribute("IDBANK"))) {
continue;
}
var values = element.getElementsByTagNameNS("*", "Obs");
for (int valueIndex = 0; valueIndex < values.getLength(); valueIndex++) {
Element value = (Element) values.item(valueIndex);
Period period = period(value.getAttribute("TIME_PERIOD"));
observations.put(period, new BigDecimal(value.getAttribute("OBS_VALUE")));
}
}
return observations;
}
private static DocumentBuilderFactory secureDocumentBuilderFactory() throws ParserConfigurationException {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setNamespaceAware(true);
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
factory.setXIncludeAware(false);
factory.setExpandEntityReferences(false);
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
return factory;
}
private static Period period(String token) {
int separator = token.indexOf("-Q");
if (separator < 1) {
throw new IllegalArgumentException("Invalid IRL period.");
}
return new Period(Integer.parseInt(token.substring(0, separator)),
Integer.parseInt(token.substring(separator + 2)));
}
private static BigDecimal required(Map<Period, BigDecimal> observations, Period period) {
BigDecimal value = observations.get(period);
if (value == null) {
throw new IrlIndexUnavailable();
}
return value;
}
private static String token(Period period) {
return period.year() + "-Q" + period.quarter();
}
@Document(collection = "irlIndexes")
record CachedIndex(@Id String id, String seriesId, int year, int quarter,
@Field(targetType = FieldType.DECIMAL128) BigDecimal value) {
private static CachedIndex of(String seriesId, Period period, BigDecimal value) {
return new CachedIndex(id(seriesId, period), seriesId, period.year(), period.quarter(), value);
}
private static String id(String seriesId, Period period) {
return seriesId + ":" + token(period);
}
}
}
@@ -0,0 +1,22 @@
package fr.nessar.lmnporganizer.rental.adapter.out.insee;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.net.URI;
import java.time.Duration;
import java.util.Objects;
@ConfigurationProperties("lmnp.insee-irl")
public record InseeIrlProperties(URI baseUri, Duration requestTimeout) {
public InseeIrlProperties {
Objects.requireNonNull(baseUri, "INSEE IRL base URI is required.");
String scheme = baseUri.getScheme();
if (!baseUri.isAbsolute() || !("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme))) {
throw new IllegalArgumentException("INSEE IRL base URI must use HTTP or HTTPS.");
}
Objects.requireNonNull(requestTimeout, "INSEE IRL request timeout is required.");
if (requestTimeout.isZero() || requestTimeout.isNegative()) {
throw new IllegalArgumentException("INSEE IRL request timeout must be strictly positive.");
}
}
}
@@ -3,10 +3,15 @@ package fr.nessar.lmnporganizer.rental.adapter.out.mongodb.apartment;
import fr.nessar.lmnporganizer.rental.adapter.out.mongodb.LandlordScopedMongoWriter;
import fr.nessar.lmnporganizer.rental.domain.apartment.Apartment;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile.BuildingType;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile.ConstructionPeriod;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentRepository;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import java.util.Set;
@@ -76,6 +81,8 @@ public class MongoApartmentRepository implements ApartmentRepository {
apartment.address(),
apartment.purchasePrice(),
apartment.description(),
toDocument(apartment.legalProfile()),
apartment.documents().stream().map(MongoApartmentRepository::toDocument).toList(),
apartment.leaseReferences().stream().map(LeaseId::value).toList(),
persistedVersion(apartment.version()));
}
@@ -88,11 +95,50 @@ public class MongoApartmentRepository implements ApartmentRepository {
document.address(),
document.purchasePrice(),
document.description(),
document.legalProfile() == null ? ApartmentLegalProfile.empty() : toDomain(document.legalProfile()),
document.leaseReferences() == null ? Set.of() : document.leaseReferences().stream()
.map(LeaseId::new).collect(java.util.stream.Collectors.toUnmodifiableSet()),
document.documents() == null ? List.of()
: document.documents().stream().map(MongoApartmentRepository::toDomain).toList(),
domainVersion(document.version()));
}
private static ApartmentDocumentMetadata toDocument(
fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentDocument document) {
return new ApartmentDocumentMetadata(document.id(), document.type().code(), document.establishedOn(),
document.expiresOn(), document.fileName(), document.storageKey(), document.sha256(),
document.authorId(), document.uploadedAt());
}
private static fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentDocument toDomain(
ApartmentDocumentMetadata document) {
return new fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentDocument(document.id(),
fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentDocumentType.fromCode(document.type()),
document.establishedOn(), document.expiresOn(), document.fileName(), document.storageKey(),
document.sha256(), document.authorId(), document.uploadedAt());
}
private static LegalProfileDocument toDocument(ApartmentLegalProfile profile) {
return new LegalProfileDocument(profile.taxIdentifier(),
profile.buildingType() == null ? null : profile.buildingType().name(), profile.ownershipRegime(),
profile.constructionPeriod() == null ? null : profile.constructionPeriod().code(),
profile.habitableSurface(), profile.roomCount(), profile.otherPrivateAreas(), profile.equipment(),
profile.heating(), profile.hotWater(), profile.energyRating(), profile.estimatedAnnualEnergyCost(),
profile.estimatedAnnualEnergyCostReferenceYear(), profile.destination(), profile.privateAccessories(),
profile.commonAreas(), profile.internetAccess(), profile.departmentCode());
}
private static ApartmentLegalProfile toDomain(LegalProfileDocument document) {
return new ApartmentLegalProfile(document.taxIdentifier(),
document.buildingType() == null ? null : BuildingType.valueOf(document.buildingType()),
document.ownershipRegime(), ConstructionPeriod.fromCode(document.constructionPeriod()),
document.habitableSurface(), document.roomCount() == null ? 0 : document.roomCount(),
document.otherPrivateAreas(), document.equipment(), document.heating(), document.hotWater(),
document.energyRating(), document.estimatedAnnualEnergyCost(),
document.estimatedAnnualEnergyCostReferenceYear(), document.destination(),
document.privateAccessories(), document.commonAreas(), document.internetAccess(), document.departmentCode());
}
private static Long persistedVersion(long version) {
return version == -1 ? null : version;
}
@@ -110,11 +156,38 @@ record ApartmentDocument(
String address,
@Field(targetType = FieldType.DECIMAL128) BigDecimal purchasePrice,
String description,
LegalProfileDocument legalProfile,
List<ApartmentDocumentMetadata> documents,
List<String> leaseReferences,
@Version Long version) {
ApartmentDocument withVersion(Long version) {
return new ApartmentDocument(id, landlordId, name, address, purchasePrice, description, leaseReferences,
version);
return new ApartmentDocument(id, landlordId, name, address, purchasePrice, description, legalProfile,
documents, leaseReferences, version);
}
}
record ApartmentDocumentMetadata(String id, String type, LocalDate establishedOn, LocalDate expiresOn,
String fileName, String storageKey, String sha256, String authorId,
Instant uploadedAt) {
}
record LegalProfileDocument(
String taxIdentifier,
String buildingType,
String ownershipRegime,
String constructionPeriod,
@Field(targetType = FieldType.DECIMAL128) BigDecimal habitableSurface,
Integer roomCount,
String otherPrivateAreas,
String equipment,
String heating,
String hotWater,
String energyRating,
@Field(targetType = FieldType.DECIMAL128) BigDecimal estimatedAnnualEnergyCost,
Integer estimatedAnnualEnergyCostReferenceYear,
String destination,
String privateAccessories,
String commonAreas,
String internetAccess,
String departmentCode) {
}
@@ -0,0 +1,130 @@
package fr.nessar.lmnporganizer.rental.adapter.out.mongodb.lease;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentDocumentType;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentAnnex;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentRevision;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentRevisionConflict;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentRevisionRepository;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentWarning;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.data.annotation.Id;
import org.springframework.data.domain.Sort;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.index.CompoundIndex;
import org.springframework.data.mongodb.core.index.CompoundIndexes;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.stereotype.Repository;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
@Repository
public class MongoLeaseDocumentRevisionRepository implements LeaseDocumentRevisionRepository {
private final MongoTemplate mongoTemplate;
public MongoLeaseDocumentRevisionRepository(MongoTemplate mongoTemplate) {
this.mongoTemplate = mongoTemplate;
}
@Override
public Optional<LeaseDocumentRevision> findCurrent(LandlordId landlordId, LeaseId leaseId) {
Query query = scoped(landlordId, leaseId).with(Sort.by(Sort.Direction.DESC, "number")).limit(1);
return Optional.ofNullable(mongoTemplate.findOne(query, RevisionDocument.class)).map(this::toDomain);
}
@Override
public Optional<LeaseDocumentRevision> findByFingerprint(LandlordId landlordId, LeaseId leaseId,
String fingerprint) {
Query query = scoped(landlordId, leaseId);
query.addCriteria(Criteria.where("sourceFingerprint").is(fingerprint));
return Optional.ofNullable(mongoTemplate.findOne(query, RevisionDocument.class)).map(this::toDomain);
}
@Override
public Optional<LeaseDocumentRevision> find(LandlordId landlordId, LeaseId leaseId, int number) {
Query query = scoped(landlordId, leaseId);
query.addCriteria(Criteria.where("number").is(number));
return Optional.ofNullable(mongoTemplate.findOne(query, RevisionDocument.class)).map(this::toDomain);
}
@Override
public LeaseDocumentRevision save(LeaseDocumentRevision revision) {
try {
return toDomain(mongoTemplate.insert(toDocument(revision)));
} catch (DuplicateKeyException failure) {
throw new LeaseDocumentRevisionConflict(failure);
}
}
private static Query scoped(LandlordId landlordId, LeaseId leaseId) {
return Query.query(Criteria.where("landlordId").is(landlordId.value()).and("leaseId").is(leaseId.value()));
}
private RevisionDocument toDocument(LeaseDocumentRevision revision) {
return new RevisionDocument(revision.id(), revision.landlordId().value(), revision.leaseId().value(),
revision.number(), revision.templateVersion(), revision.sourceFingerprint(), revision.storageKey(),
revision.sha256(), revision.generatedAt(), revision.authorId(),
revision.annexes().stream().map(this::toDocument).toList(),
revision.warnings().stream().map(this::toDocument).toList());
}
private LeaseDocumentRevision toDomain(RevisionDocument document) {
return new LeaseDocumentRevision(document.id(), new LandlordId(document.landlordId()),
new LeaseId(document.leaseId()), document.number(), document.templateVersion(),
document.sourceFingerprint(), document.storageKey(), document.sha256(), document.generatedAt(),
document.authorId(), annexes(document.annexes()), warnings(document.warnings()));
}
private AnnexDocument toDocument(LeaseDocumentAnnex annex) {
return new AnnexDocument(annex.category().name(),
annex.documentType() == null ? null : annex.documentType().name(), annex.sourceId(), annex.version(),
annex.fileName(), annex.storageKey(), annex.sha256(), annex.establishedOn(), annex.expiresOn(),
annex.sourceUploadedAt());
}
private LeaseDocumentAnnex toDomain(AnnexDocument annex) {
return new LeaseDocumentAnnex(LeaseDocumentAnnex.Category.valueOf(annex.category()),
annex.documentType() == null ? null : ApartmentDocumentType.valueOf(annex.documentType()),
annex.sourceId(), annex.version(), annex.fileName(), annex.storageKey(), annex.sha256(),
annex.establishedOn(), annex.expiresOn(), annex.sourceUploadedAt());
}
private WarningDocument toDocument(LeaseDocumentWarning warning) {
return new WarningDocument(warning.code(), warning.ruleVersion());
}
private static List<LeaseDocumentWarning> warnings(List<WarningDocument> warnings) {
return warnings == null ? List.of() : warnings.stream()
.map(warning -> new LeaseDocumentWarning(warning.code(), warning.ruleVersion())).toList();
}
private List<LeaseDocumentAnnex> annexes(List<AnnexDocument> annexes) {
return annexes == null ? List.of() : annexes.stream().map(this::toDomain).toList();
}
@Document(collection = "leaseDocumentRevisions")
@CompoundIndexes({
@CompoundIndex(name = "lease_revision_number_unique",
def = "{'landlordId': 1, 'leaseId': 1, 'number': 1}", unique = true),
@CompoundIndex(name = "lease_revision_fingerprint_unique",
def = "{'landlordId': 1, 'leaseId': 1, 'sourceFingerprint': 1}", unique = true)
})
record RevisionDocument(@Id String id, String landlordId, String leaseId, int number, String templateVersion,
String sourceFingerprint, String storageKey, String sha256, Instant generatedAt,
String authorId, List<AnnexDocument> annexes, List<WarningDocument> warnings) {
}
record AnnexDocument(String category, String documentType, String sourceId, String version, String fileName,
String storageKey, String sha256, LocalDate establishedOn, LocalDate expiresOn,
Instant sourceUploadedAt) {
}
record WarningDocument(String code, String ruleVersion) {
}
}
@@ -0,0 +1,86 @@
package fr.nessar.lmnporganizer.rental.adapter.out.mongodb.lease;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseFinalization;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseFinalizationConflict;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseFinalizationRepository;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.index.CompoundIndex;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.stereotype.Repository;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
@Repository
public class MongoLeaseFinalizationRepository implements LeaseFinalizationRepository {
private final MongoTemplate mongoTemplate;
public MongoLeaseFinalizationRepository(MongoTemplate mongoTemplate) {
this.mongoTemplate = mongoTemplate;
}
@Override
public Optional<LeaseFinalization> find(LandlordId landlordId, LeaseId leaseId) {
return Optional.ofNullable(mongoTemplate.findOne(scoped(landlordId, leaseId), FinalizationDocument.class))
.map(this::toDomain);
}
@Override
public LeaseFinalization save(LeaseFinalization finalization) {
try {
return toDomain(mongoTemplate.insert(toDocument(finalization)));
} catch (DuplicateKeyException failure) {
throw new LeaseFinalizationConflict("Ce bail est déjà finalisé.", failure);
}
}
private static Query scoped(LandlordId landlordId, LeaseId leaseId) {
return Query.query(Criteria.where("landlordId").is(landlordId.value()).and("leaseId").is(leaseId.value()));
}
private FinalizationDocument toDocument(LeaseFinalization finalization) {
return new FinalizationDocument(finalization.id(), finalization.landlordId().value(),
finalization.leaseId().value(), finalization.revisionNumber(), finalization.templateVersion(),
finalization.lastSignatureDate(), finalization.signedStorageKey(), finalization.signedFileName(),
finalization.signedSha256(), finalization.authorId(), finalization.finalizedAt(),
finalization.overrides().stream().map(this::toDocument).toList());
}
private OverrideDocument toDocument(LeaseFinalization.LegalOverride override) {
return new OverrideDocument(override.code(), override.ruleVersion(), override.authorId(),
override.confirmedAt());
}
private LeaseFinalization toDomain(FinalizationDocument document) {
List<LeaseFinalization.LegalOverride> overrides = document.overrides() == null ? List.of()
: document.overrides().stream().map(this::toDomain).toList();
return new LeaseFinalization(document.id(), new LandlordId(document.landlordId()),
new LeaseId(document.leaseId()), document.revisionNumber(), document.templateVersion(),
document.lastSignatureDate(), document.signedStorageKey(), document.signedFileName(),
document.signedSha256(), document.authorId(), document.finalizedAt(), overrides);
}
private LeaseFinalization.LegalOverride toDomain(OverrideDocument document) {
return new LeaseFinalization.LegalOverride(document.code(), document.ruleVersion(), document.authorId(),
document.confirmedAt());
}
@Document(collection = "leaseFinalizations")
@CompoundIndex(name = "lease_finalization_unique", def = "{'landlordId': 1, 'leaseId': 1}", unique = true)
record FinalizationDocument(@Id String id, String landlordId, String leaseId, int revisionNumber,
String templateVersion, LocalDate lastSignatureDate, String signedStorageKey,
String signedFileName, String signedSha256, String authorId, Instant finalizedAt,
List<OverrideDocument> overrides) {
}
record OverrideDocument(String code, String ruleVersion, String authorId, Instant confirmedAt) {
}
}
@@ -0,0 +1,77 @@
package fr.nessar.lmnporganizer.rental.adapter.out.mongodb.lease;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseFinalizationConflict;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseGuaranteeDeed;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseGuaranteeDeedRepository;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.index.CompoundIndex;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.stereotype.Repository;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
@Repository
public class MongoLeaseGuaranteeDeedRepository implements LeaseGuaranteeDeedRepository {
private final MongoTemplate mongoTemplate;
public MongoLeaseGuaranteeDeedRepository(MongoTemplate mongoTemplate) {
this.mongoTemplate = mongoTemplate;
}
@Override
public List<LeaseGuaranteeDeed> findAll(LandlordId landlordId, LeaseId leaseId) {
return mongoTemplate.find(scoped(landlordId, leaseId), GuaranteeDeedDocument.class).stream()
.map(this::toDomain).toList();
}
@Override
public Optional<LeaseGuaranteeDeed> find(LandlordId landlordId, LeaseId leaseId, String guarantorId,
TenantId tenantId) {
Query query = scoped(landlordId, leaseId);
query.addCriteria(Criteria.where("guarantorId").is(guarantorId).and("tenantId").is(tenantId.value()));
return Optional.ofNullable(mongoTemplate.findOne(query, GuaranteeDeedDocument.class)).map(this::toDomain);
}
@Override
public LeaseGuaranteeDeed save(LeaseGuaranteeDeed deed) {
try {
return toDomain(mongoTemplate.insert(toDocument(deed)));
} catch (DuplicateKeyException failure) {
throw new LeaseFinalizationConflict("Un acte existe déjà pour ce garant et ce locataire.", failure);
}
}
private static Query scoped(LandlordId landlordId, LeaseId leaseId) {
return Query.query(Criteria.where("landlordId").is(landlordId.value()).and("leaseId").is(leaseId.value()));
}
private GuaranteeDeedDocument toDocument(LeaseGuaranteeDeed deed) {
return new GuaranteeDeedDocument(deed.id(), deed.landlordId().value(), deed.leaseId().value(),
deed.guarantorId(), deed.tenantId().value(), deed.storageKey(), deed.fileName(), deed.sha256(),
deed.authorId(), deed.uploadedAt());
}
private LeaseGuaranteeDeed toDomain(GuaranteeDeedDocument document) {
return new LeaseGuaranteeDeed(document.id(), new LandlordId(document.landlordId()),
new LeaseId(document.leaseId()), document.guarantorId(), new TenantId(document.tenantId()),
document.storageKey(), document.fileName(), document.sha256(), document.authorId(),
document.uploadedAt());
}
@Document(collection = "leaseGuaranteeDeeds")
@CompoundIndex(name = "lease_guarantee_pair_unique",
def = "{'landlordId': 1, 'leaseId': 1, 'guarantorId': 1, 'tenantId': 1}", unique = true)
record GuaranteeDeedDocument(@Id String id, String landlordId, String leaseId, String guarantorId,
String tenantId, String storageKey, String fileName, String sha256, String authorId,
Instant uploadedAt) {
}
}
@@ -2,6 +2,10 @@ package fr.nessar.lmnporganizer.rental.adapter.out.mongodb.lease;
import fr.nessar.lmnporganizer.rental.adapter.out.mongodb.LandlordScopedMongoWriter;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile.BuildingType;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile.ConstructionPeriod;
import fr.nessar.lmnporganizer.rental.domain.lease.ChargeMethod;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.Guarantor;
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
@@ -9,9 +13,12 @@ import fr.nessar.lmnporganizer.rental.domain.lease.LeaseAmendment;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseAmendmentId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseContractVersion;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseLegalTerms;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSnapshot;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseStatus;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSpecialClause;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseType;
import fr.nessar.lmnporganizer.rental.domain.lease.RentTerms;
import fr.nessar.lmnporganizer.rental.domain.lease.RentalPeriod;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
@@ -77,6 +84,9 @@ public class MongoLeaseRepository implements LeaseRepository {
lease.guarantors().stream().map(MongoLeaseRepository::toDocument).toList(),
lease.period().startDate(), lease.period().endDate(), lease.terms().monthlyRent(),
lease.terms().monthlyCharges(), lease.terms().securityDeposit(), lease.status().name(),
lease.terms().dueDay(), lease.terms().chargeMethod().name(), lease.terms().indexFlatRate(),
lease.leaseType().name(), lease.specialClauses().stream().map(MongoLeaseRepository::toDocument).toList(),
toDocument(lease.legalTerms()),
lease.snapshot() == null ? null : toDocument(lease.snapshot()),
lease.amendments().stream().map(MongoLeaseRepository::toDocument).toList(),
persistedVersion(lease.version()));
@@ -88,7 +98,11 @@ public class MongoLeaseRepository implements LeaseRepository {
document.tenantIds().stream().map(TenantId::new).toList(),
document.guarantors().stream().map(MongoLeaseRepository::toDomain).toList(),
new RentalPeriod(document.startDate(), document.endDate()),
new RentTerms(document.monthlyRent(), document.monthlyCharges(), document.securityDeposit()),
rentTerms(document.monthlyRent(), document.monthlyCharges(), document.securityDeposit(),
document.dueDay(), document.chargeMethod(), document.indexFlatRate()),
leaseType(document.leaseType()),
specialClauses(document.specialClauses()),
toDomain(document.legalTerms()),
LeaseStatus.valueOf(document.status()),
document.snapshot() == null ? null : toDomain(document.snapshot()),
document.amendments() == null ? List.of()
@@ -112,13 +126,17 @@ public class MongoLeaseRepository implements LeaseRepository {
snapshot.landlord().lastName(), snapshot.landlord().email(), snapshot.landlord().phone(),
snapshot.landlord().address()),
new ApartmentSnapshotDocument(snapshot.apartment().id().value(), snapshot.apartment().name(),
snapshot.apartment().address()),
snapshot.apartment().address(), toDocument(snapshot.apartment().legalProfile())),
snapshot.tenants().stream().map(tenant -> new TenantSnapshotDocument(tenant.id().value(),
tenant.firstName(), tenant.lastName(), tenant.email(), tenant.phone(), tenant.address()))
.toList(),
snapshot.guarantors().stream().map(MongoLeaseRepository::toDocument).toList(),
snapshot.period().startDate(), snapshot.period().endDate(), snapshot.terms().monthlyRent(),
snapshot.terms().monthlyCharges(), snapshot.terms().securityDeposit());
snapshot.terms().monthlyCharges(), snapshot.terms().securityDeposit(), snapshot.terms().dueDay(),
snapshot.terms().chargeMethod().name(), snapshot.terms().indexFlatRate(),
snapshot.leaseType().name(),
snapshot.specialClauses().stream().map(MongoLeaseRepository::toDocument).toList(),
toDocument(snapshot.legalTerms()));
}
private static LeaseSnapshot toDomain(LeaseSnapshotDocument document) {
@@ -127,13 +145,17 @@ public class MongoLeaseRepository implements LeaseRepository {
document.landlord().firstName(), document.landlord().lastName(), document.landlord().email(),
document.landlord().phone(), document.landlord().address()),
new LeaseSnapshot.Apartment(new ApartmentId(document.apartment().id()),
document.apartment().name(), document.apartment().address()),
document.apartment().name(), document.apartment().address(),
toDomain(document.apartment().legalProfile())),
document.tenants().stream().map(tenant -> new LeaseSnapshot.Tenant(new TenantId(tenant.id()),
tenant.firstName(), tenant.lastName(), tenant.email(), tenant.phone(), tenant.address()))
.toList(),
document.guarantors().stream().map(MongoLeaseRepository::toDomain).toList(),
new RentalPeriod(document.startDate(), document.endDate()),
new RentTerms(document.monthlyRent(), document.monthlyCharges(), document.securityDeposit()));
rentTerms(document.monthlyRent(), document.monthlyCharges(), document.securityDeposit(),
document.dueDay(), document.chargeMethod(), document.indexFlatRate()),
leaseType(document.leaseType()), specialClauses(document.specialClauses()),
toDomain(document.legalTerms()));
}
private static LeaseAmendmentDocument toDocument(LeaseAmendment amendment) {
@@ -152,13 +174,17 @@ public class MongoLeaseRepository implements LeaseRepository {
version.landlord().lastName(), version.landlord().email(), version.landlord().phone(),
version.landlord().address()),
new ApartmentSnapshotDocument(version.apartment().id().value(), version.apartment().name(),
version.apartment().address()),
version.apartment().address(), toDocument(version.apartment().legalProfile())),
version.tenants().stream().map(tenant -> new TenantSnapshotDocument(tenant.id().value(),
tenant.firstName(), tenant.lastName(), tenant.email(), tenant.phone(), tenant.address()))
.toList(),
version.guarantors().stream().map(MongoLeaseRepository::toDocument).toList(),
version.period().startDate(), version.period().endDate(), version.terms().monthlyRent(),
version.terms().monthlyCharges(), version.terms().securityDeposit());
version.terms().monthlyCharges(), version.terms().securityDeposit(), version.terms().dueDay(),
version.terms().chargeMethod().name(), version.terms().indexFlatRate(),
version.leaseType().name(),
version.specialClauses().stream().map(MongoLeaseRepository::toDocument).toList(),
toDocument(version.legalTerms()));
}
private static LeaseContractVersion toDomain(LeaseContractVersionDocument document) {
@@ -167,13 +193,110 @@ public class MongoLeaseRepository implements LeaseRepository {
document.landlord().firstName(), document.landlord().lastName(), document.landlord().email(),
document.landlord().phone(), document.landlord().address()),
new LeaseSnapshot.Apartment(new ApartmentId(document.apartment().id()),
document.apartment().name(), document.apartment().address()),
document.apartment().name(), document.apartment().address(),
toDomain(document.apartment().legalProfile())),
document.tenants().stream().map(tenant -> new LeaseSnapshot.Tenant(new TenantId(tenant.id()),
tenant.firstName(), tenant.lastName(), tenant.email(), tenant.phone(), tenant.address()))
.toList(),
document.guarantors().stream().map(MongoLeaseRepository::toDomain).toList(),
new RentalPeriod(document.startDate(), document.endDate()),
new RentTerms(document.monthlyRent(), document.monthlyCharges(), document.securityDeposit()));
rentTerms(document.monthlyRent(), document.monthlyCharges(), document.securityDeposit(),
document.dueDay(), document.chargeMethod(), document.indexFlatRate()),
leaseType(document.leaseType()), specialClauses(document.specialClauses()),
toDomain(document.legalTerms()));
}
private static SpecialClauseDocument toDocument(LeaseSpecialClause clause) {
return new SpecialClauseDocument(clause.catalogId(), clause.parameters(), clause.text(), clause.custom());
}
private static List<LeaseSpecialClause> specialClauses(List<SpecialClauseDocument> documents) {
return documents == null ? List.of() : documents.stream().map(document -> new LeaseSpecialClause(
document.catalogId(), document.parameters(), document.text(), Boolean.TRUE.equals(document.custom())))
.toList();
}
private static LegalTermsDocument toDocument(LeaseLegalTerms terms) {
return new LegalTermsDocument(
new RentControlDocument(terms.rentControl().evolutionCapApplicable(),
terms.rentControl().referenceRentApplicable(), terms.rentControl().baseReferenceRent(),
terms.rentControl().increasedReferenceRent(), terms.rentControl().supplement(),
terms.rentControl().supplementJustification()),
new PreviousRentDocument(terms.previousRent().applicable(), terms.previousRent().amount(),
terms.previousRent().lastRevisionDate(), terms.previousRent().lastPaymentDate(),
terms.previousRent().tenantConfirmation()),
new RentRevisionDocument(terms.rentRevision().enabled(), terms.rentRevision().annualRevisionDate(),
terms.rentRevision().referenceQuarter(), terms.rentRevision().referenceYear(),
terms.rentRevision().seriesId()),
new RecentWorkDocument(terms.recentWork().description(), terms.recentWork().amount()),
terms.coOwnershipRulesApplicable(), terms.priorRentalAuthorizationApplicable(),
terms.primaryResidenceEasementApplicable(), terms.jointAndSeveralLiability(), terms.otherTerms());
}
private static LeaseLegalTerms toDomain(LegalTermsDocument document) {
if (document == null) {
return LeaseLegalTerms.empty();
}
RentControlDocument rentControl = document.rentControl();
PreviousRentDocument previousRent = document.previousRent();
RentRevisionDocument rentRevision = document.rentRevision();
RecentWorkDocument recentWork = document.recentWork();
return new LeaseLegalTerms(
rentControl == null ? null : new LeaseLegalTerms.RentControlTerms(
Boolean.TRUE.equals(rentControl.evolutionCapApplicable()),
Boolean.TRUE.equals(rentControl.referenceRentApplicable()), rentControl.baseReferenceRent(),
rentControl.increasedReferenceRent(), rentControl.supplement(),
rentControl.supplementJustification()),
previousRent == null ? null : new LeaseLegalTerms.PreviousRentTerms(
Boolean.TRUE.equals(previousRent.applicable()), previousRent.amount(),
previousRent.lastRevisionDate(), previousRent.lastPaymentDate(),
Boolean.TRUE.equals(previousRent.tenantConfirmation())),
rentRevision == null ? null : new LeaseLegalTerms.RentRevisionTerms(
Boolean.TRUE.equals(rentRevision.enabled()), rentRevision.annualRevisionDate(),
rentRevision.referenceQuarter(), rentRevision.referenceYear(), rentRevision.seriesId()),
recentWork == null ? null : new LeaseLegalTerms.RecentWork(recentWork.description(),
recentWork.amount()),
Boolean.TRUE.equals(document.coOwnershipRulesApplicable()),
Boolean.TRUE.equals(document.priorRentalAuthorizationApplicable()),
Boolean.TRUE.equals(document.primaryResidenceEasementApplicable()),
Boolean.TRUE.equals(document.jointAndSeveralLiability()), document.otherTerms());
}
private static ApartmentLegalProfileDocument toDocument(ApartmentLegalProfile profile) {
return new ApartmentLegalProfileDocument(profile.taxIdentifier(),
profile.buildingType() == null ? null : profile.buildingType().name(), profile.ownershipRegime(),
profile.constructionPeriod() == null ? null : profile.constructionPeriod().code(),
profile.habitableSurface(), profile.roomCount(), profile.otherPrivateAreas(), profile.equipment(),
profile.heating(), profile.hotWater(), profile.energyRating(), profile.estimatedAnnualEnergyCost(),
profile.estimatedAnnualEnergyCostReferenceYear(), profile.destination(), profile.privateAccessories(),
profile.commonAreas(), profile.internetAccess(), profile.departmentCode());
}
private static ApartmentLegalProfile toDomain(ApartmentLegalProfileDocument document) {
if (document == null) {
return ApartmentLegalProfile.empty();
}
return new ApartmentLegalProfile(document.taxIdentifier(),
document.buildingType() == null ? null : BuildingType.valueOf(document.buildingType()),
document.ownershipRegime(), ConstructionPeriod.fromCode(document.constructionPeriod()),
document.habitableSurface(), document.roomCount() == null ? 0 : document.roomCount(),
document.otherPrivateAreas(), document.equipment(), document.heating(), document.hotWater(),
document.energyRating(), document.estimatedAnnualEnergyCost(),
document.estimatedAnnualEnergyCostReferenceYear(), document.destination(),
document.privateAccessories(), document.commonAreas(), document.internetAccess(),
document.departmentCode());
}
private static RentTerms rentTerms(BigDecimal monthlyRent, BigDecimal monthlyCharges, BigDecimal securityDeposit,
Integer dueDay, String chargeMethod, Boolean indexFlatRate) {
return new RentTerms(monthlyRent, monthlyCharges, securityDeposit,
dueDay == null ? RentTerms.DEFAULT_DUE_DAY : dueDay,
chargeMethod == null ? ChargeMethod.FLAT_RATE : ChargeMethod.valueOf(chargeMethod),
Boolean.TRUE.equals(indexFlatRate));
}
private static LeaseType leaseType(String leaseType) {
return leaseType == null ? LeaseType.STANDARD : LeaseType.valueOf(leaseType);
}
private static Long persistedVersion(long version) {
@@ -191,11 +314,15 @@ public class MongoLeaseRepository implements LeaseRepository {
@Field(targetType = FieldType.DECIMAL128) BigDecimal monthlyRent,
@Field(targetType = FieldType.DECIMAL128) BigDecimal monthlyCharges,
@Field(targetType = FieldType.DECIMAL128) BigDecimal securityDeposit,
String status, LeaseSnapshotDocument snapshot, List<LeaseAmendmentDocument> amendments,
String status, Integer dueDay, String chargeMethod, Boolean indexFlatRate,
String leaseType, List<SpecialClauseDocument> specialClauses,
LegalTermsDocument legalTerms,
LeaseSnapshotDocument snapshot, List<LeaseAmendmentDocument> amendments,
@Version Long version) {
LeaseDocument withVersion(Long version) {
return new LeaseDocument(id, landlordId, apartmentId, tenantIds, guarantors, startDate, endDate,
monthlyRent, monthlyCharges, securityDeposit, status, snapshot, amendments, version);
monthlyRent, monthlyCharges, securityDeposit, status, dueDay, chargeMethod, indexFlatRate,
leaseType, specialClauses, legalTerms, snapshot, amendments, version);
}
}
@@ -203,19 +330,66 @@ public class MongoLeaseRepository implements LeaseRepository {
String address) {
}
record SpecialClauseDocument(String catalogId, java.util.Map<String, String> parameters, String text,
Boolean custom) {
}
record LegalTermsDocument(RentControlDocument rentControl, PreviousRentDocument previousRent,
RentRevisionDocument rentRevision, RecentWorkDocument recentWork,
Boolean coOwnershipRulesApplicable, Boolean priorRentalAuthorizationApplicable,
Boolean primaryResidenceEasementApplicable, Boolean jointAndSeveralLiability,
String otherTerms) {
}
record RentControlDocument(Boolean evolutionCapApplicable, Boolean referenceRentApplicable,
@Field(targetType = FieldType.DECIMAL128) BigDecimal baseReferenceRent,
@Field(targetType = FieldType.DECIMAL128) BigDecimal increasedReferenceRent,
@Field(targetType = FieldType.DECIMAL128) BigDecimal supplement,
String supplementJustification) {
}
record PreviousRentDocument(Boolean applicable,
@Field(targetType = FieldType.DECIMAL128) BigDecimal amount,
LocalDate lastRevisionDate, LocalDate lastPaymentDate,
Boolean tenantConfirmation) {
}
record RentRevisionDocument(Boolean enabled, LocalDate annualRevisionDate, Integer referenceQuarter,
Integer referenceYear, String seriesId) {
}
record RecentWorkDocument(String description,
@Field(targetType = FieldType.DECIMAL128) BigDecimal amount) {
}
record LeaseSnapshotDocument(LandlordSnapshotDocument landlord, ApartmentSnapshotDocument apartment,
List<TenantSnapshotDocument> tenants, List<GuarantorDocument> guarantors,
LocalDate startDate, LocalDate endDate,
@Field(targetType = FieldType.DECIMAL128) BigDecimal monthlyRent,
@Field(targetType = FieldType.DECIMAL128) BigDecimal monthlyCharges,
@Field(targetType = FieldType.DECIMAL128) BigDecimal securityDeposit) {
@Field(targetType = FieldType.DECIMAL128) BigDecimal securityDeposit,
Integer dueDay, String chargeMethod, Boolean indexFlatRate, String leaseType,
List<SpecialClauseDocument> specialClauses, LegalTermsDocument legalTerms) {
}
record LandlordSnapshotDocument(String id, String firstName, String lastName, String email, String phone,
String address) {
}
record ApartmentSnapshotDocument(String id, String name, String address) {
record ApartmentSnapshotDocument(String id, String name, String address,
ApartmentLegalProfileDocument legalProfile) {
}
record ApartmentLegalProfileDocument(String taxIdentifier, String buildingType, String ownershipRegime,
String constructionPeriod,
@Field(targetType = FieldType.DECIMAL128) BigDecimal habitableSurface,
Integer roomCount, String otherPrivateAreas, String equipment,
String heating, String hotWater, String energyRating,
@Field(targetType = FieldType.DECIMAL128)
BigDecimal estimatedAnnualEnergyCost,
Integer estimatedAnnualEnergyCostReferenceYear,
String destination, String privateAccessories, String commonAreas,
String internetAccess, String departmentCode) {
}
record TenantSnapshotDocument(String id, String firstName, String lastName, String email, String phone,
@@ -231,6 +405,9 @@ public class MongoLeaseRepository implements LeaseRepository {
LocalDate startDate, LocalDate endDate,
@Field(targetType = FieldType.DECIMAL128) BigDecimal monthlyRent,
@Field(targetType = FieldType.DECIMAL128) BigDecimal monthlyCharges,
@Field(targetType = FieldType.DECIMAL128) BigDecimal securityDeposit) {
@Field(targetType = FieldType.DECIMAL128) BigDecimal securityDeposit,
Integer dueDay, String chargeMethod, Boolean indexFlatRate,
String leaseType, List<SpecialClauseDocument> specialClauses,
LegalTermsDocument legalTerms) {
}
}
@@ -0,0 +1,203 @@
package fr.nessar.lmnporganizer.rental.adapter.out.mongodb.rent;
import fr.nessar.lmnporganizer.rental.adapter.out.mongodb.LandlordScopedMongoWriter;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.rent.RentRevision;
import fr.nessar.lmnporganizer.rental.domain.rent.RentRevisionConflict;
import fr.nessar.lmnporganizer.rental.domain.rent.RentRevisionId;
import fr.nessar.lmnporganizer.rental.domain.rent.RentRevisionRepository;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.Version;
import org.springframework.data.mongodb.core.MongoTemplate;
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 org.springframework.data.mongodb.core.mapping.Field;
import org.springframework.data.mongodb.core.mapping.FieldType;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.stereotype.Repository;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
@Repository
public class MongoRentRevisionRepository implements RentRevisionRepository {
private final MongoTemplate mongoTemplate;
public MongoRentRevisionRepository(MongoTemplate mongoTemplate) {
this.mongoTemplate = mongoTemplate;
}
@Override
public List<RentRevision> findAll(LandlordId landlordId, LeaseId leaseId) {
Query query = Query.query(Criteria.where("landlordId").is(landlordId.value())
.and("leaseId").is(leaseId.value()));
return mongoTemplate.find(query, RevisionDocument.class).stream().map(this::toDomain).toList();
}
@Override
public Optional<RentRevision> find(LandlordId landlordId, LeaseId leaseId, RentRevisionId revisionId) {
Query query = Query.query(Criteria.where("_id").is(revisionId.value())
.and("landlordId").is(landlordId.value()).and("leaseId").is(leaseId.value()));
return Optional.ofNullable(mongoTemplate.findOne(query, RevisionDocument.class)).map(this::toDomain);
}
@Override
public RentRevision save(RentRevision revision) {
RevisionDocument document = toDocument(revision);
try {
return toDomain(LandlordScopedMongoWriter.save(mongoTemplate, document, document.id(),
document.landlordId(), revision.version(), document::withVersion));
} catch (DuplicateKeyException failure) {
throw new RentRevisionConflict("Une révision IRL est déjà appliquée pour cette date anniversaire.",
failure);
} catch (OptimisticLockingFailureException failure) {
if (revision.application() != null && failure.getCause() instanceof DuplicateKeyException) {
throw new RentRevisionConflict("Une révision IRL est déjà appliquée pour cette date anniversaire.",
failure);
}
throw failure;
}
}
private RevisionDocument toDocument(RentRevision revision) {
RentRevision.Application applied = revision.application();
return new RevisionDocument(revision.id().value(), revision.landlordId().value(),
revision.leaseId().value(), revision.status().name(), revision.window().annualRevisionDate(),
revision.window().requestDeadline(), revision.indexReference().seriesId(),
revision.indexReference().seriesOverridden(), revision.indexReference().seriesOverrideConfirmed(),
revision.indexReference().source().name(), revision.indexReference().manualIndicesConfirmed(),
revision.indexReference().referenceQuarter(), revision.indexReference().previousYear(),
revision.indexReference().newYear(), revision.indexReference().previousIndex(),
revision.indexReference().newIndex(), revision.amounts().previousRent(),
revision.amounts().rentCeiling(), revision.amounts().chosenRent(),
revision.amounts().previousCharges(), revision.amounts().chargeCeiling(),
revision.amounts().chosenCharges(), toDocument(revision.request()),
revision.warningOverrides().stream().map(this::toDocument).toList(),
applied == null ? null : toDocument(applied),
applied == null ? null : appliedKey(revision), persistedVersion(revision.version()));
}
private RequestDocument toDocument(RentRevision.Request request) {
return new RequestDocument(request.requestedOn(), request.effectiveOn(), request.letterStorageKey(),
request.letterSha256(), request.proposedBy(), request.proposedAt());
}
private OverrideDocument toDocument(RentRevision.LegalOverride override) {
return new OverrideDocument(override.code(), override.ruleVersion(), override.authorId(),
override.confirmedAt());
}
private ApplicationDocument toDocument(RentRevision.Application application) {
return new ApplicationDocument(application.sentOn(), application.deliveryMethod().name(),
application.effectiveOn(), application.proof() == null ? null : toDocument(application.proof()),
application.appliedBy(), application.appliedAt());
}
private ProofDocument toDocument(RentRevision.Proof proof) {
return new ProofDocument(proof.storageKey(), proof.fileName(), proof.sha256(), proof.authorId(),
proof.uploadedAt());
}
private RentRevision toDomain(RevisionDocument document) {
RentRevision.Application application = document.application() == null ? null
: toDomain(document.application());
return new RentRevision(new RentRevisionId(document.id()), new LandlordId(document.landlordId()),
new LeaseId(document.leaseId()),
new RentRevision.Window(document.annualRevisionDate(), document.requestDeadline()),
new RentRevision.IndexReference(document.seriesId(), document.seriesOverridden(),
document.seriesOverrideConfirmed(), RentRevision.IndexSource.valueOf(document.indexSource()),
document.manualIndicesConfirmed(), document.referenceQuarter(), document.previousIndexYear(),
document.newIndexYear(), document.previousIndex(), document.newIndex()),
new RentRevision.Amounts(document.previousRent(), document.rentCeiling(), document.chosenRent(),
document.previousCharges(), document.chargeCeiling(), document.chosenCharges()),
toDomain(document.request()), document.warningOverrides() == null ? List.of()
: document.warningOverrides().stream().map(this::toDomain).toList(), application,
domainVersion(document.version()));
}
private RentRevision.Request toDomain(RequestDocument request) {
return new RentRevision.Request(request.requestedOn(), request.effectiveOn(), request.letterStorageKey(),
request.letterSha256(), request.proposedBy(), request.proposedAt());
}
private RentRevision.LegalOverride toDomain(OverrideDocument override) {
return new RentRevision.LegalOverride(override.code(), override.ruleVersion(), override.authorId(),
override.confirmedAt());
}
private RentRevision.Application toDomain(ApplicationDocument application) {
return new RentRevision.Application(application.sentOn(),
RentRevision.DeliveryMethod.valueOf(application.deliveryMethod()), application.effectiveOn(),
application.proof() == null ? null : toDomain(application.proof()), application.appliedBy(),
application.appliedAt());
}
private RentRevision.Proof toDomain(ProofDocument proof) {
return new RentRevision.Proof(proof.storageKey(), proof.fileName(), proof.sha256(), proof.authorId(),
proof.uploadedAt());
}
private static String appliedKey(RentRevision revision) {
return revision.landlordId().value() + "|" + revision.leaseId().value() + "|"
+ revision.window().annualRevisionDate();
}
private static Long persistedVersion(long version) {
return version < 0 ? null : version;
}
private static long domainVersion(Long version) {
return version == null ? -1 : version;
}
@Document(collection = "rentRevisions")
@CompoundIndex(name = "rent_revision_scope", def = "{'landlordId': 1, 'leaseId': 1}")
record RevisionDocument(@Id String id, String landlordId, String leaseId, String status,
LocalDate annualRevisionDate, LocalDate requestDeadline, String seriesId,
boolean seriesOverridden, boolean seriesOverrideConfirmed, String indexSource,
boolean manualIndicesConfirmed, int referenceQuarter, int previousIndexYear,
int newIndexYear,
@Field(targetType = FieldType.DECIMAL128) BigDecimal previousIndex,
@Field(targetType = FieldType.DECIMAL128) BigDecimal newIndex,
@Field(targetType = FieldType.DECIMAL128) BigDecimal previousRent,
@Field(targetType = FieldType.DECIMAL128) BigDecimal rentCeiling,
@Field(targetType = FieldType.DECIMAL128) BigDecimal chosenRent,
@Field(targetType = FieldType.DECIMAL128) BigDecimal previousCharges,
@Field(targetType = FieldType.DECIMAL128) BigDecimal chargeCeiling,
@Field(targetType = FieldType.DECIMAL128) BigDecimal chosenCharges,
RequestDocument request, List<OverrideDocument> warningOverrides,
ApplicationDocument application,
@Field(write = Field.Write.NON_NULL) @Indexed(unique = true, sparse = true)
String appliedKey,
@Version Long version) {
RevisionDocument withVersion(Long version) {
return new RevisionDocument(id, landlordId, leaseId, status, annualRevisionDate, requestDeadline,
seriesId, seriesOverridden, seriesOverrideConfirmed, indexSource, manualIndicesConfirmed,
referenceQuarter, previousIndexYear, newIndexYear, previousIndex, newIndex, previousRent,
rentCeiling, chosenRent, previousCharges, chargeCeiling, chosenCharges, request, warningOverrides,
application, appliedKey, version);
}
}
record RequestDocument(LocalDate requestedOn, LocalDate effectiveOn, String letterStorageKey,
String letterSha256, String proposedBy, Instant proposedAt) {
}
record OverrideDocument(String code, String ruleVersion, String authorId, Instant confirmedAt) {
}
record ApplicationDocument(LocalDate sentOn, String deliveryMethod, LocalDate effectiveOn, ProofDocument proof,
String appliedBy, Instant appliedAt) {
}
record ProofDocument(String storageKey, String fileName, String sha256, String authorId, Instant uploadedAt) {
}
}
@@ -0,0 +1,37 @@
package fr.nessar.lmnporganizer.rental.adapter.out.pdf;
import fr.nessar.lmnporganizer.rental.application.port.out.LeaseInformationNoticeCatalog;
import fr.nessar.lmnporganizer.rental.application.port.out.RentalDocumentRenderer;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
@Component
public final class ClasspathLeaseInformationNoticeCatalog implements LeaseInformationNoticeCatalog {
static final String VERSION = "FR-NOTICE-2023-03";
static final String RESOURCE = "legal/notices/notice-information-location-2023.txt";
private static final String FILE_NAME = "notice-information-location-2023.pdf";
private final InformationNotice current;
public ClasspathLeaseInformationNoticeCatalog(RentalDocumentRenderer renderer) {
String text = readResource();
byte[] pdf = renderer.renderInformationNotice(new RentalDocumentRenderer.InformationNotice(text));
current = new InformationNotice(VERSION, FILE_NAME, pdf);
}
@Override
public InformationNotice current() {
return current;
}
private static String readResource() {
try (var input = new ClassPathResource(RESOURCE).getInputStream()) {
return new String(input.readAllBytes(), StandardCharsets.UTF_8);
} catch (IOException failure) {
throw new IllegalStateException("The versioned rental information notice could not be loaded.", failure);
}
}
}
@@ -2,12 +2,19 @@ package fr.nessar.lmnporganizer.rental.adapter.out.pdf;
import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
import fr.nessar.lmnporganizer.rental.application.port.out.RentalDocumentRenderer;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile.BuildingType;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile.ConstructionPeriod;
import fr.nessar.lmnporganizer.rental.domain.lease.ChargeMethod;
import fr.nessar.lmnporganizer.rental.domain.lease.FurnishedLeaseTemplate;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseType;
import org.springframework.stereotype.Component;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.math.BigDecimal;
import java.text.NumberFormat;
import java.time.LocalDate;
import java.time.Month;
import java.time.YearMonth;
@@ -19,6 +26,7 @@ import java.util.Locale;
@Component
public class ThymeleafRentalDocumentRenderer implements RentalDocumentRenderer {
private static final FrenchDates DATES = new FrenchDates();
private static final FrenchFormats FORMATS = new FrenchFormats();
private final TemplateEngine templates;
@@ -36,6 +44,11 @@ public class ThymeleafRentalDocumentRenderer implements RentalDocumentRenderer {
return render("rental/lease", context);
}
@Override
public byte[] renderInformationNotice(InformationNotice document) {
return render("rental/information-notice", context(document));
}
@Override
public byte[] renderPaymentReceipt(PaymentReceipt document) {
Context context = context(document);
@@ -52,10 +65,19 @@ public class ThymeleafRentalDocumentRenderer implements RentalDocumentRenderer {
return render("rental/rent-receipt", context);
}
@Override
public byte[] renderRentRevisionRequest(RentRevisionRequest document) {
Context context = context(document);
context.setVariable("tenantNames", names(document.tenants().stream()
.map(tenant -> tenant.firstName() + " " + tenant.lastName()).toList()));
return render("rental/rent-revision-request", context);
}
private Context context(Object document) {
Context context = new Context(Locale.FRANCE);
context.setVariable("document", document);
context.setVariable("dates", DATES);
context.setVariable("formats", FORMATS);
return context;
}
@@ -90,4 +112,92 @@ public class ThymeleafRentalDocumentRenderer implements RentalDocumentRenderer {
return Character.toUpperCase(month.charAt(0)) + month.substring(1);
}
}
public static final class FrenchFormats {
private FrenchFormats() {
}
public String money(BigDecimal value) {
return value == null ? "" : NumberFormat.getCurrencyInstance(Locale.FRANCE).format(value);
}
public String moneyPerSquareMeter(BigDecimal value) {
return value == null ? "" : money(value) + " / m²";
}
public String number(BigDecimal value) {
if (value == null) {
return "";
}
NumberFormat format = NumberFormat.getNumberInstance(Locale.FRANCE);
format.setMinimumFractionDigits(0);
format.setMaximumFractionDigits(2);
return format.format(value);
}
public String revisionFormula(BigDecimal amount, BigDecimal newIndex, BigDecimal previousIndex,
BigDecimal ceiling) {
return money(amount) + " × " + number(newIndex) + " / " + number(previousIndex) + " = "
+ money(ceiling);
}
public String total(BigDecimal first, BigDecimal second) {
return money(first.add(second));
}
public String buildingType(BuildingType type) {
return type == BuildingType.APARTMENT_BUILDING ? "Immeuble collectif" : "Maison individuelle";
}
public String constructionPeriod(ConstructionPeriod period) {
return switch (period) {
case BEFORE_1949 -> "Avant 1949";
case PERIOD_1949_1974 -> "De 1949 à 1974";
case PERIOD_1975_1989 -> "De 1975 à 1989";
case PERIOD_1990_2005 -> "De 1990 à 2005";
case PERIOD_2006_2012 -> "De 2006 à 2012";
case AFTER_2012 -> "Après 2012";
};
}
public String chargeMethod(ChargeMethod method) {
return method == ChargeMethod.PROVISION
? "Provision sur charges avec régularisation annuelle"
: "Forfait de charges sans régularisation";
}
public String leaseType(LeaseType type) {
return type == LeaseType.STUDENT ? "Location meublée consentie à un étudiant"
: "Location meublée d'un an";
}
public String quarter(int quarter) {
return quarter == 1 ? "1er trimestre" : quarter + "e trimestre";
}
public String indexPeriod(String label, int quarter, int year) {
return label + " — " + quarter(quarter) + " " + year;
}
public String yesNo(boolean value) {
return value ? "Oui" : "Non";
}
public boolean present(String value) {
return value != null && !value.isBlank();
}
public boolean positive(BigDecimal value) {
return value != null && value.signum() > 0;
}
public boolean standard(LeaseType type) {
return type == LeaseType.STANDARD;
}
public boolean october2026(String version) {
return !"LEGACY".equals(version) && !FurnishedLeaseTemplate.fromVisibleId(version).effectiveFrom()
.isBefore(LocalDate.of(2026, 10, 1));
}
}
}
@@ -7,6 +7,7 @@ import fr.nessar.lmnporganizer.rental.application.shared.ReferencedResource;
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
import fr.nessar.lmnporganizer.rental.domain.apartment.Apartment;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentRepository;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
@@ -35,12 +36,16 @@ public final class ApartmentCatalogService {
public Apartment create(LandlordId landlordId, Details details) {
return apartments.save(new Apartment(new ApartmentId(identifiers.next()), landlordId, details.name(),
details.address(), details.purchasePrice(), details.description()));
details.address(), details.purchasePrice(), details.description(),
details.legalProfile() == null ? ApartmentLegalProfile.empty() : details.legalProfile()));
}
public Apartment revise(LandlordId landlordId, ApartmentId apartmentId, Details details) {
Apartment apartment = get(landlordId, apartmentId);
apartment.reviseDetails(details.name(), details.address(), details.purchasePrice(), details.description());
if (details.legalProfile() != null) {
apartment.reviseLegalProfile(details.legalProfile());
}
return apartments.save(apartment);
}
@@ -57,6 +62,10 @@ public final class ApartmentCatalogService {
});
}
public record Details(String name, String address, BigDecimal purchasePrice, String description) {
public record Details(String name, String address, BigDecimal purchasePrice, String description,
ApartmentLegalProfile legalProfile) {
public Details(String name, String address, BigDecimal purchasePrice, String description) {
this(name, address, purchasePrice, description, null);
}
}
}
@@ -0,0 +1,233 @@
package fr.nessar.lmnporganizer.rental.application.apartment;
import fr.nessar.lmnporganizer.rental.application.port.out.IdentifierGenerator;
import fr.nessar.lmnporganizer.rental.application.port.out.LeaseDocumentStorage;
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentTooLarge;
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentStorageFailure;
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
import fr.nessar.lmnporganizer.rental.domain.apartment.Apartment;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentDocument;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentDocumentType;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentRepository;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.util.Arrays;
import java.util.HexFormat;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
public final class ApartmentDocumentService {
private static final byte[] PDF_SIGNATURE = "%PDF-".getBytes(StandardCharsets.US_ASCII);
private final ApartmentRepository apartments;
private final LeaseDocumentStorage storage;
private final IdentifierGenerator identifiers;
private final Clock clock;
private final long maximumPdfSize;
public ApartmentDocumentService(ApartmentRepository apartments, LeaseDocumentStorage storage,
IdentifierGenerator identifiers, Clock clock, long maximumPdfSize) {
this.apartments = apartments;
this.storage = storage;
this.identifiers = identifiers;
this.clock = clock;
if (maximumPdfSize < 1) {
throw new IllegalArgumentException("Maximum PDF size must be positive.");
}
this.maximumPdfSize = maximumPdfSize;
}
public List<ApartmentDocument> list(LandlordId landlordId, ApartmentId apartmentId) {
return apartment(landlordId, apartmentId).documents();
}
public ApartmentDocument upload(LandlordId landlordId, ApartmentId apartmentId, ApartmentDocumentType type,
LocalDate establishedOn, LocalDate expiresOn, PdfUpload upload, String authorId) {
Apartment apartment = apartment(landlordId, apartmentId);
requireAuthor(authorId);
Objects.requireNonNull(type, "Apartment document type is required.");
Objects.requireNonNull(establishedOn, "Establishment date is required.");
if (expiresOn != null && expiresOn.isBefore(establishedOn)) {
throw new IllegalArgumentException("Expiry date cannot be before establishment date.");
}
String fileName = safeFileName(upload == null ? null : upload.fileName());
byte[] content = pdf(upload);
String documentId = required(identifiers.next(), "Apartment document ID");
String storageKey = storage.store(content);
ApartmentDocument document;
try {
document = new ApartmentDocument(documentId, type, establishedOn, expiresOn, fileName, storageKey,
sha256(content), authorId, Instant.now(clock));
} catch (RuntimeException failure) {
discard(storageKey, failure);
throw failure;
}
ApartmentDocument replaced = apartment.replaceDocument(document);
LeaseDocumentStorage.StagedDeletion deletion = null;
if (replaced != null) {
try {
deletion = storage.stageDeletion(replaced.storageKey());
} catch (RuntimeException failure) {
apartment.removeDocument(document.id());
apartment.replaceDocument(replaced);
discard(storageKey, failure);
throw failure;
}
}
Apartment saved;
try {
saved = apartments.save(apartment);
} catch (RuntimeException failure) {
apartment.removeDocument(document.id());
if (replaced != null) {
apartment.replaceDocument(replaced);
restore(deletion, failure);
}
discard(storageKey, failure);
throw failure;
}
if (deletion != null) {
storage.completeDeletion(deletion);
}
return saved.findDocument(document.id()).orElse(document);
}
public Download download(LandlordId landlordId, ApartmentId apartmentId, String documentId) {
ApartmentDocument document = document(apartment(landlordId, apartmentId), documentId);
byte[] content = storage.read(document.storageKey());
if (!document.sha256().equals(sha256(content))) {
throw new AttachmentStorageFailure("Apartment document integrity check failed.", null);
}
return new Download(document, content);
}
public void delete(LandlordId landlordId, ApartmentId apartmentId, String documentId) {
Apartment apartment = apartment(landlordId, apartmentId);
ApartmentDocument removed = document(apartment, documentId);
LeaseDocumentStorage.StagedDeletion deletion = storage.stageDeletion(removed.storageKey());
apartment.removeDocument(documentId);
try {
apartments.save(apartment);
} catch (RuntimeException failure) {
apartment.replaceDocument(removed);
restore(deletion, failure);
throw failure;
}
storage.completeDeletion(deletion);
}
private Apartment apartment(LandlordId landlordId, ApartmentId apartmentId) {
return apartments.find(landlordId, apartmentId).orElseThrow(() -> new ResourceNotFound("Apartment"));
}
private static ApartmentDocument document(Apartment apartment, String documentId) {
return apartment.findDocument(documentId).orElseThrow(() -> new ResourceNotFound("Apartment document"));
}
private byte[] pdf(PdfUpload upload) {
if (upload == null) {
throw new IllegalArgumentException("Un fichier PDF est obligatoire.");
}
byte[] content = upload.content();
if (content.length == 0) {
throw new IllegalArgumentException("Un fichier PDF est obligatoire.");
}
if (content.length > maximumPdfSize) {
throw new AttachmentTooLarge(maximumPdfSize);
}
if (!"application/pdf".equalsIgnoreCase(upload.contentType()) || content.length < PDF_SIGNATURE.length
|| !Arrays.equals(Arrays.copyOf(content, PDF_SIGNATURE.length), PDF_SIGNATURE)) {
throw new IllegalArgumentException("Le fichier doit être un PDF lisible.");
}
return content;
}
private static void requireAuthor(String authorId) {
if (authorId == null || authorId.isBlank()) {
throw new IllegalArgumentException("Author ID is required.");
}
}
private static String required(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " is required.");
}
return value.trim();
}
private static String safeFileName(String supplied) {
if (supplied == null || supplied.isBlank()) {
return "document.pdf";
}
String trimmed = supplied.trim();
int separator = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'));
String candidate = trimmed.substring(separator + 1);
StringBuilder safe = new StringBuilder(candidate.length());
for (int index = 0; index < candidate.length(); index++) {
char character = candidate.charAt(index);
safe.append(Character.isISOControl(character) ? '_' : character);
}
String name = safe.toString().trim();
if (name.isEmpty()) {
return "document.pdf";
}
if (!name.toLowerCase(Locale.ROOT).endsWith(".pdf")) {
name += ".pdf";
}
return name.length() <= 255 ? name : name.substring(0, 251) + ".pdf";
}
private static String sha256(byte[] content) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(content));
} catch (NoSuchAlgorithmException impossible) {
throw new IllegalStateException("SHA-256 is unavailable.", impossible);
}
}
private void discard(String storageKey, RuntimeException failure) {
try {
storage.discard(storageKey);
} catch (RuntimeException cleanupFailure) {
failure.addSuppressed(cleanupFailure);
}
}
private void restore(LeaseDocumentStorage.StagedDeletion deletion, RuntimeException failure) {
try {
storage.restore(deletion);
} catch (RuntimeException restoreFailure) {
failure.addSuppressed(restoreFailure);
}
}
public record PdfUpload(String fileName, String contentType, byte[] content) {
public PdfUpload {
content = content == null ? new byte[0] : Arrays.copyOf(content, content.length);
}
@Override
public byte[] content() {
return Arrays.copyOf(content, content.length);
}
}
public record Download(ApartmentDocument document, byte[] content) {
public Download {
content = Arrays.copyOf(content, content.length);
}
@Override
public byte[] content() {
return Arrays.copyOf(content, content.length);
}
}
}
@@ -16,6 +16,7 @@ import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRuleViolation;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSnapshot;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseStatus;
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueId;
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueRepository;
import fr.nessar.lmnporganizer.rental.domain.rent.RentSchedule;
@@ -52,14 +53,22 @@ public final class ActivateLeaseService {
return transactions.run(() -> activateInTransaction(landlordId, leaseId));
}
private Lease activateInTransaction(LandlordId landlordId, LeaseId leaseId) {
Lease activateInTransaction(LandlordId landlordId, LeaseId leaseId) {
Lease lease = leases.find(landlordId, leaseId).orElseThrow(() -> new ResourceNotFound("Lease"));
boolean alreadyLeased = leases.findAll(landlordId).stream()
.anyMatch(existing -> !existing.id().equals(lease.id())
&& existing.apartmentId().equals(lease.apartmentId())
&& existing.status() == LeaseStatus.ACTIVE);
if (alreadyLeased) {
throw new LeaseRuleViolation("Un autre bail actif existe déjà pour ce logement.");
}
Landlord landlord = landlords.find(landlordId).orElseThrow(() -> new ResourceNotFound("Landlord"));
if (!landlord.hasCompleteProfile()) {
throw new LeaseRuleViolation("Le profil bailleur doit être complet avant l'activation du bail.");
}
Apartment apartment = apartments.find(landlordId, lease.apartmentId())
.orElseThrow(() -> new ResourceNotFound("Apartment"));
apartment.legalProfile().requireLeaseReady();
List<Tenant> leaseTenants = lease.tenantIds().stream()
.map(tenantId -> tenants.find(landlordId, tenantId)
.orElseThrow(() -> new ResourceNotFound("Tenant")))
@@ -78,11 +87,13 @@ public final class ActivateLeaseService {
return new LeaseSnapshot(
new LeaseSnapshot.Landlord(landlord.id(), landlord.firstName(), landlord.lastName(), landlord.email(),
landlord.phone(), landlord.address()),
new LeaseSnapshot.Apartment(apartment.id(), apartment.name(), apartment.address()),
new LeaseSnapshot.Apartment(apartment.id(), apartment.name(), apartment.address(),
apartment.legalProfile()),
leaseTenants.stream()
.map(tenant -> new LeaseSnapshot.Tenant(tenant.id(), tenant.firstName(), tenant.lastName(),
tenant.email(), tenant.phone(), tenant.address()))
.toList(),
lease.guarantors(), lease.period(), lease.terms());
lease.guarantors(), lease.period(), lease.terms(), lease.leaseType(), lease.specialClauses(),
lease.legalTerms());
}
}
@@ -7,6 +7,7 @@ import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.ApartmentOccupancy;
import fr.nessar.lmnporganizer.rental.domain.lease.ApartmentOccupancyRepository;
import fr.nessar.lmnporganizer.rental.domain.lease.ChargeMethod;
import fr.nessar.lmnporganizer.rental.domain.lease.Guarantor;
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseAmended;
@@ -63,12 +64,20 @@ public final class AmendLeaseService {
Change change) {
Lease lease = leases.find(landlordId, leaseId).orElseThrow(() -> new ResourceNotFound("Lease"));
LeaseContractVersion current = lease.contractVersion();
ChargeMethod chargeMethod = change.chargeMethod() == null
? current.terms().chargeMethod()
: change.chargeMethod();
boolean indexFlatRate = change.indexFlatRate() == null
? chargeMethod == ChargeMethod.FLAT_RATE && current.terms().indexFlatRate()
: change.indexFlatRate();
List<Tenant> amendedTenants = change.tenantIds().stream().map(tenantId -> tenants.find(landlordId, tenantId)
.orElseThrow(() -> new ResourceNotFound("Tenant"))).toList();
LeaseContractVersion after = new LeaseContractVersion(current.landlord(), current.apartment(),
amendedTenants.stream().map(tenant -> snapshot(current, tenant)).toList(), change.guarantors(),
new RentalPeriod(current.period().startDate(), change.endDate()),
new RentTerms(change.monthlyRent(), change.monthlyCharges(), current.terms().securityDeposit()));
new RentTerms(change.monthlyRent(), change.monthlyCharges(), current.terms().securityDeposit(),
change.dueDay() == null ? current.terms().dueDay() : change.dueDay(), chargeMethod,
indexFlatRate), current.leaseType(), current.specialClauses(), current.legalTerms());
LeaseAmended event = lease.amend(new LeaseAmendmentId(identifiers.next()), effectiveDate, after);
ApartmentOccupancy occupancy = occupancies.find(landlordId, lease.apartmentId())
@@ -99,7 +108,13 @@ public final class AmendLeaseService {
}
public record Change(List<TenantId> tenantIds, List<Guarantor> guarantors, LocalDate endDate,
BigDecimal monthlyRent, BigDecimal monthlyCharges) {
BigDecimal monthlyRent, BigDecimal monthlyCharges, Integer dueDay,
ChargeMethod chargeMethod, Boolean indexFlatRate) {
public Change(List<TenantId> tenantIds, List<Guarantor> guarantors, LocalDate endDate,
BigDecimal monthlyRent, BigDecimal monthlyCharges) {
this(tenantIds, guarantors, endDate, monthlyRent, monthlyCharges, null, null, null);
}
public Change {
tenantIds = tenantIds == null ? List.of() : List.copyOf(tenantIds);
guarantors = guarantors == null ? List.of() : List.copyOf(guarantors);
@@ -1,35 +1,472 @@
package fr.nessar.lmnporganizer.rental.application.lease;
import fr.nessar.lmnporganizer.rental.application.apartment.ApartmentDocumentService;
import fr.nessar.lmnporganizer.rental.application.port.out.LeaseInformationNoticeCatalog;
import fr.nessar.lmnporganizer.rental.application.port.out.LeaseInformationNoticeCatalog.InformationNotice;
import fr.nessar.lmnporganizer.rental.application.port.out.LeaseDocumentStorage;
import fr.nessar.lmnporganizer.rental.application.port.out.RentalDocumentRenderer;
import fr.nessar.lmnporganizer.rental.application.port.out.RentalDocumentRenderer.LeaseDocument;
import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
import fr.nessar.lmnporganizer.rental.application.shared.GeneratedDocument;
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
import fr.nessar.lmnporganizer.rental.domain.apartment.Apartment;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentDocument;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentDocumentType;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentRepository;
import fr.nessar.lmnporganizer.rental.domain.landlord.Landlord;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordRepository;
import fr.nessar.lmnporganizer.rental.domain.lease.FurnishedLeaseTemplate;
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseContractVersion;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentAnnex;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentAnnex.Category;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentRevision;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentRevisionConflict;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentRevisionRepository;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentWarning;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseFinalizationWarning;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSnapshot;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseStatus;
import fr.nessar.lmnporganizer.rental.domain.tenant.Tenant;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantRepository;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.UUID;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
public final class GenerateLeaseDocumentService {
private static final int MAX_INSERT_ATTEMPTS = 3;
private final LeaseRepository leases;
private final RentalDocumentRenderer renderer;
private final Clock clock;
private final LandlordRepository landlords;
private final ApartmentRepository apartments;
private final TenantRepository tenants;
private final LeaseDocumentRevisionRepository revisions;
private final LeaseDocumentStorage storage;
private final ApartmentDocumentService apartmentDocuments;
private final LeaseInformationNoticeCatalog notices;
public GenerateLeaseDocumentService(LeaseRepository leases, RentalDocumentRenderer renderer, Clock clock) {
this(leases, renderer, clock, null, null, null, null, null, null, null);
}
public GenerateLeaseDocumentService(LeaseRepository leases, RentalDocumentRenderer renderer, Clock clock,
LandlordRepository landlords, ApartmentRepository apartments,
TenantRepository tenants, LeaseDocumentRevisionRepository revisions,
LeaseDocumentStorage storage) {
this(leases, renderer, clock, landlords, apartments, tenants, revisions, storage, null, null);
}
public GenerateLeaseDocumentService(LeaseRepository leases, RentalDocumentRenderer renderer, Clock clock,
LandlordRepository landlords, ApartmentRepository apartments,
TenantRepository tenants, LeaseDocumentRevisionRepository revisions,
LeaseDocumentStorage storage, ApartmentDocumentService apartmentDocuments,
LeaseInformationNoticeCatalog notices) {
this.leases = leases;
this.renderer = renderer;
this.clock = clock;
this.landlords = landlords;
this.apartments = apartments;
this.tenants = tenants;
this.revisions = revisions;
this.storage = storage;
this.apartmentDocuments = apartmentDocuments;
this.notices = notices;
}
/** Kept for callers that only need an ephemeral PDF from an activated lease. */
public GeneratedDocument generate(LandlordId landlordId, LeaseId leaseId) {
Lease lease = leases.find(landlordId, leaseId).orElseThrow(() -> new ResourceNotFound("Lease"));
Lease lease = lease(landlordId, leaseId);
LeaseContractVersion version = lease.contractVersionOn(LocalDate.now(clock));
LeaseDocument document = new LeaseDocument(lease.id(), version.landlord(), version.apartment(),
version.tenants(), version.guarantors(), version.period(), version.terms());
return new GeneratedDocument("bail-" + lease.id().value() + ".pdf", "application/pdf",
renderer.renderLease(document));
LeaseDocument document = document(lease.id(), version, "LEGACY", 0);
return generated(leaseId, 0, renderer.renderLease(document));
}
public LeaseDocumentRevision generateRevision(LandlordId landlordId, LeaseId leaseId, String authorId) {
requireRevisionDependencies();
if (authorId == null || authorId.isBlank()) {
throw new IllegalArgumentException("Author ID is required.");
}
Lease lease = lease(landlordId, leaseId);
FurnishedLeaseTemplate template = FurnishedLeaseTemplate.effectiveOn(LocalDate.now(clock));
Source source = source(landlordId, lease, template);
var existing = revisions.findByFingerprint(landlordId, leaseId, source.fingerprint());
if (existing.isPresent()) {
return existing.orElseThrow();
}
for (int attempt = 0; attempt < MAX_INSERT_ATTEMPTS; attempt++) {
int number = revisions.findCurrent(landlordId, leaseId).map(revision -> revision.number() + 1).orElse(1);
List<String> storedKeys = new ArrayList<>();
try {
byte[] bytes = renderer.renderLease(source.withRevision(number));
String storageKey = store(bytes, storedKeys);
List<LeaseDocumentAnnex> annexes = copyAnnexes(landlordId, lease, source, storedKeys);
LeaseDocumentRevision revision = new LeaseDocumentRevision(UUID.randomUUID().toString(), landlordId,
leaseId, number, template.visibleId(), source.fingerprint(), storageKey, sha256(bytes),
Instant.now(clock), authorId, annexes, source.warnings());
return revisions.save(revision);
} catch (LeaseDocumentRevisionConflict conflict) {
discard(storedKeys, conflict);
if (conflict.getSuppressed().length > 0) {
throw conflict;
}
var winner = revisions.findByFingerprint(landlordId, leaseId, source.fingerprint());
if (winner.isPresent()) {
return winner.orElseThrow();
}
} catch (RuntimeException failure) {
discard(storedKeys, failure);
throw failure;
}
}
throw new ConcurrentChange("Lease document revision");
}
public LeaseDocumentRevision current(LandlordId landlordId, LeaseId leaseId) {
requireRevisionDependencies();
lease(landlordId, leaseId);
return revisions.findCurrent(landlordId, leaseId)
.orElseThrow(() -> new ResourceNotFound("Lease document revision"));
}
public GeneratedDocument download(LandlordId landlordId, LeaseId leaseId, int number) {
requireRevisionDependencies();
lease(landlordId, leaseId);
LeaseDocumentRevision revision = revisions.find(landlordId, leaseId, number)
.orElseThrow(() -> new ResourceNotFound("Lease document revision"));
byte[] content = storage.read(revision.storageKey());
if (!revision.sha256().equals(sha256(content))) {
throw new IllegalStateException("Stored lease document fingerprint does not match its revision.");
}
return generated(leaseId, revision.number(), content);
}
public GeneratedDocument generateAndDownload(LandlordId landlordId, LeaseId leaseId, String authorId) {
LeaseDocumentRevision revision = generateRevision(landlordId, leaseId, authorId);
GeneratedDocument document = download(landlordId, leaseId, revision.number());
return new GeneratedDocument("bail-" + leaseId.value() + ".pdf", document.contentType(), document.content());
}
public GeneratedDocument downloadDossier(LandlordId landlordId, LeaseId leaseId, int number) {
requireRevisionDependencies();
lease(landlordId, leaseId);
LeaseDocumentRevision revision = revisions.find(landlordId, leaseId, number)
.orElseThrow(() -> new ResourceNotFound("Lease document revision"));
return dossier(leaseId, revision);
}
public GeneratedDocument downloadCurrentDossier(LandlordId landlordId, LeaseId leaseId) {
LeaseDocumentRevision revision = current(landlordId, leaseId);
return dossier(leaseId, revision);
}
boolean matchesCurrentSource(LandlordId landlordId, LeaseId leaseId, LeaseDocumentRevision revision) {
Lease lease = lease(landlordId, leaseId);
FurnishedLeaseTemplate template = FurnishedLeaseTemplate.fromVisibleId(revision.templateVersion());
if (revision.annexes().stream().noneMatch(annex -> annex.category() == Category.NOTICE)) {
return false;
}
ContractSource current = contractSource(landlordId, lease, template);
appendDossierFacts(current.facts(), revision.annexes(), revision.warnings());
return revision.landlordId().equals(landlordId) && revision.leaseId().equals(leaseId)
&& revision.sourceFingerprint().equals(sha256(current.facts().toString()
.getBytes(StandardCharsets.UTF_8)));
}
private Source source(LandlordId landlordId, Lease lease, FurnishedLeaseTemplate template) {
ContractSource contract = contractSource(landlordId, lease, template);
List<ApartmentDocument> currentDocuments = apartmentDocuments.list(landlordId, lease.apartmentId()).stream()
.sorted(Comparator.comparing(ApartmentDocument::type)).toList();
InformationNotice notice = notices.current();
List<LeaseDocumentWarning> dossierWarnings = warnings(lease, currentDocuments);
appendDossierFacts(contract.facts(), notice, currentDocuments, dossierWarnings);
return new Source(contract.document(), sha256(contract.facts().toString().getBytes(StandardCharsets.UTF_8)),
notice, currentDocuments, dossierWarnings);
}
private ContractSource contractSource(LandlordId landlordId, Lease lease, FurnishedLeaseTemplate template) {
if (lease.status() != LeaseStatus.DRAFT) {
LeaseContractVersion version = lease.contractVersionOn(LocalDate.now(clock));
StringBuilder facts = new StringBuilder(template.visibleId());
append(facts, "lease", lease.id().value(), lease.version());
facts.append('\n').append("contract:").append(version);
return new ContractSource(document(lease.id(), version, template.visibleId(), 0), facts);
}
Landlord landlord = landlords.find(landlordId).orElseThrow(() -> new ResourceNotFound("Landlord"));
Apartment apartment = apartments.find(landlordId, lease.apartmentId())
.orElseThrow(() -> new ResourceNotFound("Apartment"));
List<Tenant> leaseTenants = lease.tenantIds().stream()
.map(tenantId -> tenants.find(landlordId, tenantId)
.orElseThrow(() -> new ResourceNotFound("Tenant")))
.toList();
LeaseDocument document = new LeaseDocument(lease.id(),
new LeaseSnapshot.Landlord(landlord.id(), landlord.firstName(), landlord.lastName(), landlord.email(),
landlord.phone(), landlord.address()),
new LeaseSnapshot.Apartment(apartment.id(), apartment.name(), apartment.address(),
apartment.legalProfile()),
leaseTenants.stream().map(tenant -> new LeaseSnapshot.Tenant(tenant.id(), tenant.firstName(),
tenant.lastName(), tenant.email(), tenant.phone(), tenant.address())).toList(),
lease.guarantors(), lease.period(), lease.terms(), lease.leaseType(), lease.specialClauses(),
lease.legalTerms(), template.visibleId(), 0);
StringBuilder facts = new StringBuilder(template.visibleId());
append(facts, "lease", lease.id().value(), lease.version());
append(facts, "landlord", landlord.id().value(), landlord.version());
append(facts, "apartment-contract", document.apartment().toString());
leaseTenants.forEach(tenant -> append(facts, "tenant", tenant.id().value(), tenant.version()));
return new ContractSource(document, facts);
}
private List<LeaseDocumentAnnex> copyAnnexes(LandlordId landlordId, Lease lease, Source source,
List<String> storedKeys) {
List<LeaseDocumentAnnex> annexes = new ArrayList<>();
InformationNotice notice = source.notice();
byte[] noticeContent = notice.content();
annexes.add(new LeaseDocumentAnnex(Category.NOTICE, null, "", notice.version(), notice.fileName(),
store(noticeContent, storedKeys), sha256(noticeContent), null, null, null));
for (ApartmentDocument expected : source.apartmentDocuments()) {
ApartmentDocumentService.Download download = apartmentDocuments.download(landlordId,
lease.apartmentId(), expected.id());
if (!expected.equals(download.document())) {
throw new ConcurrentChange("Apartment document");
}
byte[] content = download.content();
String fingerprint = sha256(content);
if (!expected.sha256().equals(fingerprint)) {
throw new IllegalStateException("Apartment document fingerprint changed during dossier copy.");
}
annexes.add(new LeaseDocumentAnnex(Category.APARTMENT_DOCUMENT, expected.type(), expected.id(), "",
expected.fileName(), store(content, storedKeys), fingerprint, expected.establishedOn(),
expected.expiresOn(), expected.uploadedAt()));
}
return List.copyOf(annexes);
}
private List<LeaseDocumentWarning> warnings(Lease lease, List<ApartmentDocument> currentDocuments) {
LocalDate today = LocalDate.now(clock);
List<LeaseDocumentWarning> warnings = new ArrayList<>();
ApartmentDocument dpe = currentDocuments.stream()
.filter(document -> document.type() == ApartmentDocumentType.ENERGY_PERFORMANCE_DIAGNOSIS)
.findFirst().orElse(null);
if (dpe == null) {
warnings.add(LeaseDocumentWarning.from(LeaseFinalizationWarning.DPE_MISSING));
} else if (expired(dpe, today)) {
warnings.add(LeaseDocumentWarning.from(LeaseFinalizationWarning.DPE_EXPIRED));
}
if (currentDocuments.stream().anyMatch(document -> document.type()
!= ApartmentDocumentType.ENERGY_PERFORMANCE_DIAGNOSIS && diagnostic(document.type())
&& expired(document, today))) {
warnings.add(LeaseDocumentWarning.from(LeaseFinalizationWarning.DIAGNOSTIC_EXPIRED));
}
Set<ApartmentDocumentType> types = currentDocuments.stream().map(ApartmentDocument::type)
.collect(java.util.stream.Collectors.toSet());
if (lease.legalTerms().coOwnershipRulesApplicable()
&& !types.contains(ApartmentDocumentType.CO_OWNERSHIP_RULES_EXTRACT)) {
warnings.add(LeaseDocumentWarning.from(LeaseFinalizationWarning.CO_OWNERSHIP_RULES_EXTRACT_MISSING));
}
if (lease.legalTerms().priorRentalAuthorizationApplicable()
&& !types.contains(ApartmentDocumentType.PRIOR_RENTAL_AUTHORIZATION)) {
warnings.add(LeaseDocumentWarning.from(LeaseFinalizationWarning.PRIOR_RENTAL_AUTHORIZATION_MISSING));
}
return List.copyOf(warnings);
}
private static boolean diagnostic(ApartmentDocumentType type) {
return switch (type) {
case ENERGY_PERFORMANCE_DIAGNOSIS, LEAD_EXPOSURE_REPORT, ASBESTOS_REPORT,
ELECTRICAL_SAFETY_DIAGNOSIS, GAS_SAFETY_DIAGNOSIS, RISKS_AND_POLLUTION_REPORT,
NOISE_EXPOSURE_PLAN -> true;
case CO_OWNERSHIP_RULES_EXTRACT, PRIOR_RENTAL_AUTHORIZATION -> false;
};
}
private static boolean expired(ApartmentDocument document, LocalDate today) {
return document.expiresOn() != null && document.expiresOn().isBefore(today);
}
private static void appendDossierFacts(StringBuilder facts, InformationNotice notice,
List<ApartmentDocument> documents,
List<LeaseDocumentWarning> warnings) {
append(facts, "notice-version", notice.version());
append(facts, "notice-sha256", sha256(notice.content()));
for (ApartmentDocument document : documents) {
append(facts, "document-id", document.id());
append(facts, "document-type", document.type().name());
append(facts, "document-sha256", document.sha256());
append(facts, "document-established-on", document.establishedOn().toString());
append(facts, "document-expires-on", document.expiresOn() == null ? "" : document.expiresOn().toString());
append(facts, "document-uploaded-at", document.uploadedAt().toString());
}
appendWarningFacts(facts, warnings);
}
private static void appendDossierFacts(StringBuilder facts, List<LeaseDocumentAnnex> annexes,
List<LeaseDocumentWarning> warnings) {
LeaseDocumentAnnex notice = annexes.stream().filter(annex -> annex.category() == Category.NOTICE)
.findFirst().orElseThrow();
append(facts, "notice-version", notice.version());
append(facts, "notice-sha256", notice.sha256());
annexes.stream().filter(annex -> annex.category() == Category.APARTMENT_DOCUMENT)
.sorted(Comparator.comparing(LeaseDocumentAnnex::documentType)).forEach(document -> {
append(facts, "document-id", document.sourceId());
append(facts, "document-type", document.documentType().name());
append(facts, "document-sha256", document.sha256());
append(facts, "document-established-on", document.establishedOn().toString());
append(facts, "document-expires-on",
document.expiresOn() == null ? "" : document.expiresOn().toString());
append(facts, "document-uploaded-at", document.sourceUploadedAt().toString());
});
appendWarningFacts(facts, warnings);
}
private static void appendWarningFacts(StringBuilder facts, List<LeaseDocumentWarning> warnings) {
warnings.stream().sorted(Comparator.comparing(LeaseDocumentWarning::code)
.thenComparing(LeaseDocumentWarning::ruleVersion))
.forEach(warning -> {
append(facts, "warning-code", warning.code());
append(facts, "warning-rule-version", warning.ruleVersion());
});
}
private static LeaseDocument document(LeaseId leaseId, LeaseContractVersion version, String templateVersion,
int revisionNumber) {
return new LeaseDocument(leaseId, version.landlord(), version.apartment(), version.tenants(),
version.guarantors(), version.period(), version.terms(), version.leaseType(),
version.specialClauses(), version.legalTerms(), templateVersion, revisionNumber);
}
private Lease lease(LandlordId landlordId, LeaseId leaseId) {
return leases.find(landlordId, leaseId).orElseThrow(() -> new ResourceNotFound("Lease"));
}
private static void append(StringBuilder facts, String kind, String id, long version) {
facts.append('\n').append(kind).append(':').append(id.length()).append(':').append(id).append(':')
.append(version);
}
private static void append(StringBuilder facts, String kind, String value) {
facts.append('\n').append(kind).append(':').append(value.length()).append(':').append(value);
}
private static String sha256(byte[] content) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(content));
} catch (NoSuchAlgorithmException impossible) {
throw new IllegalStateException("SHA-256 is unavailable.", impossible);
}
}
private String store(byte[] content, List<String> storedKeys) {
String storageKey = storage.store(content);
storedKeys.add(storageKey);
return storageKey;
}
private void discard(List<String> storageKeys, RuntimeException failure) {
for (int index = storageKeys.size() - 1; index >= 0; index--) {
try {
LeaseDocumentStorage.StagedDeletion deletion = storage.stageDeletion(storageKeys.get(index));
storage.completeDeletion(deletion);
} catch (RuntimeException cleanupFailure) {
failure.addSuppressed(cleanupFailure);
}
}
}
private void requireRevisionDependencies() {
if (landlords == null || apartments == null || tenants == null || revisions == null || storage == null
|| apartmentDocuments == null || notices == null) {
throw new IllegalStateException("Lease document revisions are not configured.");
}
}
private static GeneratedDocument generated(LeaseId leaseId, int revisionNumber, byte[] content) {
String suffix = revisionNumber > 0 ? "-revision-" + revisionNumber : "";
return new GeneratedDocument("bail-" + leaseId.value() + suffix + ".pdf", "application/pdf", content);
}
private GeneratedDocument dossier(LeaseId leaseId, LeaseDocumentRevision revision) {
List<ZipContent> contents = new ArrayList<>();
contents.add(verified("bail.pdf", revision.storageKey(), revision.sha256()));
for (LeaseDocumentAnnex annex : revision.annexes()) {
contents.add(verified(zipEntryName(annex), annex.storageKey(), annex.sha256()));
}
Set<String> names = new HashSet<>();
if (contents.stream().anyMatch(content -> !names.add(content.name()))) {
throw new IllegalStateException("Lease dossier contains duplicate file names.");
}
try (ByteArrayOutputStream output = new ByteArrayOutputStream();
ZipOutputStream zip = new ZipOutputStream(output, StandardCharsets.UTF_8)) {
for (ZipContent content : contents) {
ZipEntry entry = new ZipEntry(content.name());
entry.setTime(0L);
zip.putNextEntry(entry);
zip.write(content.bytes());
zip.closeEntry();
}
zip.finish();
return new GeneratedDocument("dossier-bail-" + leaseId.value() + "-revision-" + revision.number()
+ ".zip", "application/zip", output.toByteArray());
} catch (IOException failure) {
throw new IllegalStateException("Lease dossier ZIP could not be generated.", failure);
}
}
private ZipContent verified(String name, String storageKey, String expectedSha256) {
byte[] content = storage.read(storageKey);
if (!expectedSha256.equals(sha256(content))) {
throw new IllegalStateException("Stored lease dossier file fingerprint does not match its manifest.");
}
return new ZipContent(name, content);
}
private static String zipEntryName(LeaseDocumentAnnex annex) {
if (annex.category() == Category.NOTICE) {
return annex.fileName();
}
return "annexes/" + annex.documentType().code().toLowerCase(Locale.ROOT) + "-" + annex.fileName();
}
private record Source(LeaseDocument document, String fingerprint, InformationNotice notice,
List<ApartmentDocument> apartmentDocuments, List<LeaseDocumentWarning> warnings) {
Source {
document.apartment().legalProfile().requireLeaseReady();
apartmentDocuments = List.copyOf(apartmentDocuments);
warnings = List.copyOf(warnings);
}
LeaseDocument withRevision(int number) {
return new LeaseDocument(document.leaseId(), document.landlord(), document.apartment(),
document.tenants(), document.guarantors(), document.period(), document.terms(),
document.leaseType(), document.specialClauses(), document.legalTerms(),
document.templateVersion(), number);
}
}
private record ContractSource(LeaseDocument document, StringBuilder facts) {
}
private record ZipContent(String name, byte[] bytes) {
}
}
@@ -0,0 +1,182 @@
package fr.nessar.lmnporganizer.rental.application.lease;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRuleViolation;
import tools.jackson.dataformat.yaml.YAMLMapper;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.Month;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.time.format.TextStyle;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
public final class LeaseClauseCatalog {
private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("HH:mm");
private final Map<String, ClauseDefinition> clauses;
private LeaseClauseCatalog(List<ClauseDefinition> definitions) {
Map<String, ClauseDefinition> indexed = new LinkedHashMap<>();
for (ClauseDefinition definition : definitions == null ? List.<ClauseDefinition>of() : definitions) {
if (indexed.put(definition.id(), definition) != null) {
throw new IllegalArgumentException("Duplicate lease clause: " + definition.id());
}
}
clauses = Map.copyOf(indexed);
}
public static LeaseClauseCatalog load(InputStream source) {
if (source == null) {
throw new IllegalArgumentException("Lease clause catalog is required.");
}
try (source) {
CatalogFile catalog = YAMLMapper.builder().build().readValue(source, CatalogFile.class);
return new LeaseClauseCatalog(catalog == null ? List.of() : catalog.clauses());
} catch (IOException exception) {
throw new IllegalStateException("Lease clause catalog could not be loaded.", exception);
}
}
public List<ClauseDefinition> available(Context context) {
return clauses.values().stream()
.filter(clause -> clause.contexts().contains(Context.ALL) || clause.contexts().contains(context))
.toList();
}
public RenderedClause render(String clauseId, Context context, Map<String, String> suppliedValues) {
ClauseDefinition clause = clauses.get(clauseId);
if (clause == null || !available(context).contains(clause)) {
throw new LeaseRuleViolation("Cette clause n'est pas disponible pour ce bail.");
}
Map<String, String> values = suppliedValues == null ? Map.of() : Map.copyOf(suppliedValues);
Set<String> expected = clause.parameters().stream().map(ParameterDefinition::name)
.collect(java.util.stream.Collectors.toUnmodifiableSet());
if (!expected.containsAll(values.keySet())) {
throw new LeaseRuleViolation("Un paramètre de clause est inconnu.");
}
String rendered = clause.template();
Map<String, String> normalized = new LinkedHashMap<>();
for (ParameterDefinition parameter : clause.parameters()) {
NormalizedValue value = normalize(parameter, values.get(parameter.name()));
normalized.put(parameter.name(), value.canonical());
rendered = rendered.replace("{{" + parameter.name() + "}}", value.display());
}
if (rendered.contains("{{") || rendered.contains("}}")) {
throw new LeaseRuleViolation("La clause contient un paramètre non déclaré.");
}
return new RenderedClause(clause.id(), clause.label(), Map.copyOf(normalized), rendered);
}
private static NormalizedValue normalize(ParameterDefinition parameter, String supplied) {
String value = supplied == null ? "" : supplied.trim();
if (value.isEmpty()) {
if (parameter.required()) {
throw new LeaseRuleViolation("Le paramètre « " + parameter.label() + " » est obligatoire.");
}
return new NormalizedValue("", "");
}
try {
return switch (parameter.type()) {
case TEXT -> new NormalizedValue(value, value);
case NUMBER -> same(new BigDecimal(value).stripTrailingZeros().toPlainString());
case DATE -> {
LocalDate date = LocalDate.parse(value);
yield new NormalizedValue(date.toString(), frenchDate(date));
}
case TIME -> same(LocalTime.parse(value, TIME).format(TIME));
case CHOICE -> parameter.choices().stream()
.filter(choice -> choice.value().equals(value))
.findFirst()
.map(choice -> new NormalizedValue(choice.value(), choice.label()))
.orElseThrow(() -> new LeaseRuleViolation("Le choix fourni est invalide."));
};
} catch (NumberFormatException | DateTimeParseException exception) {
throw new LeaseRuleViolation("Le paramètre « " + parameter.label() + " » est invalide.");
}
}
private static NormalizedValue same(String value) {
return new NormalizedValue(value, value);
}
private static String frenchDate(LocalDate date) {
String month = Month.from(date).getDisplayName(TextStyle.FULL, Locale.FRANCE);
return date.getDayOfMonth() + " " + Character.toUpperCase(month.charAt(0)) + month.substring(1)
+ " " + date.getYear();
}
public enum Context {
ALL,
STANDARD,
STUDENT,
COLOCATION
}
public enum ParameterType {
TEXT,
NUMBER,
DATE,
TIME,
CHOICE
}
public record Choice(String value, String label) {
public Choice {
value = required(value, "Choice value");
label = required(label, "Choice label");
}
}
public record ParameterDefinition(String name, String label, ParameterType type, boolean required,
List<Choice> choices) {
public ParameterDefinition {
name = LeaseClauseCatalog.required(name, "Parameter name");
label = LeaseClauseCatalog.required(label, "Parameter label");
type = java.util.Objects.requireNonNull(type, "Parameter type is required.");
choices = choices == null ? List.of() : List.copyOf(choices);
if (type == ParameterType.CHOICE && choices.isEmpty()) {
throw new IllegalArgumentException("A choice parameter requires choices.");
}
}
}
public record ClauseDefinition(String id, String label, String description, Set<Context> contexts,
String template, List<ParameterDefinition> parameters) {
public ClauseDefinition {
id = required(id, "Clause ID");
label = required(label, "Clause label");
description = description == null ? "" : description.trim();
contexts = contexts == null || contexts.isEmpty() ? Set.of(Context.ALL) : Set.copyOf(contexts);
template = required(template, "Clause template");
parameters = parameters == null ? List.of() : List.copyOf(parameters);
if (parameters.stream().map(ParameterDefinition::name).distinct().count() != parameters.size()) {
throw new IllegalArgumentException("Clause parameters must be unique.");
}
}
}
public record RenderedClause(String id, String label, Map<String, String> parameters, String text) {
}
private record NormalizedValue(String canonical, String display) {
}
private record CatalogFile(List<ClauseDefinition> clauses) {
}
private static String required(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " is required.");
}
return value.trim();
}
}
@@ -11,17 +11,22 @@ import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.Guarantor;
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseLegalTerms;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRuleViolation;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseStatus;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSpecialClause;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseType;
import fr.nessar.lmnporganizer.rental.domain.lease.RentTerms;
import fr.nessar.lmnporganizer.rental.domain.lease.RentalPeriod;
import fr.nessar.lmnporganizer.rental.domain.tenant.Tenant;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantRepository;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -32,14 +37,22 @@ public final class LeaseDraftService {
private final LeaseRepository leases;
private final TransactionRunner transactions;
private final IdentifierGenerator identifiers;
private final LeaseClauseCatalog clauseCatalog;
public LeaseDraftService(ApartmentRepository apartments, TenantRepository tenants, LeaseRepository leases,
TransactionRunner transactions, IdentifierGenerator identifiers) {
this(apartments, tenants, leases, transactions, identifiers, null);
}
public LeaseDraftService(ApartmentRepository apartments, TenantRepository tenants, LeaseRepository leases,
TransactionRunner transactions, IdentifierGenerator identifiers,
LeaseClauseCatalog clauseCatalog) {
this.apartments = apartments;
this.tenants = tenants;
this.leases = leases;
this.transactions = transactions;
this.identifiers = identifiers;
this.clauseCatalog = clauseCatalog;
}
public List<Lease> list(LandlordId landlordId) {
@@ -56,8 +69,13 @@ public final class LeaseDraftService {
Apartment apartment = apartment(landlordId, details.apartmentId());
List<Tenant> leaseTenants = tenantMap(landlordId, new HashSet<>(details.tenantIds())).values().stream()
.toList();
LeaseType leaseType = LeaseType.orStandard(details.leaseType());
List<LeaseSpecialClause> specialClauses = details.specialClauses() == null
? List.of()
: specialClauses(details.specialClauses(), context(details.tenantIds(), leaseType));
Lease lease = Lease.draft(leaseId, landlordId, details.apartmentId(), details.tenantIds(),
details.guarantors(), details.period(), details.terms());
details.guarantors(), details.period(), details.terms(),
leaseType, specialClauses, LeaseLegalTerms.orEmpty(details.legalTerms()));
apartment.referenceBy(leaseId);
leaseTenants.forEach(tenant -> tenant.referenceBy(leaseId));
apartments.save(apartment);
@@ -93,8 +111,13 @@ public final class LeaseDraftService {
affectedTenants.get(nextTenantId).referenceBy(leaseId);
}
affectedTenants.values().forEach(tenants::save);
LeaseType leaseType = details.leaseType() == null ? lease.leaseType() : details.leaseType();
List<LeaseSpecialClause> specialClauses = details.specialClauses() == null
? lease.specialClauses()
: specialClauses(details.specialClauses(), context(details.tenantIds(), leaseType));
LeaseLegalTerms legalTerms = details.legalTerms() == null ? lease.legalTerms() : details.legalTerms();
lease.reviseDraft(details.apartmentId(), details.tenantIds(), details.guarantors(), details.period(),
details.terms());
details.terms(), leaseType, specialClauses, legalTerms);
return leases.save(lease);
});
}
@@ -133,11 +156,74 @@ public final class LeaseDraftService {
}
}
private List<LeaseSpecialClause> specialClauses(List<ClauseDetails> details,
LeaseClauseCatalog.Context context) {
if (details.isEmpty()) {
return List.of();
}
if (clauseCatalog == null) {
throw new IllegalStateException("Lease clause catalog is not configured.");
}
return details.stream().map(clause -> specialClause(clause, context)).toList();
}
private LeaseSpecialClause specialClause(ClauseDetails details, LeaseClauseCatalog.Context context) {
if (details.catalogId() != null && !details.catalogId().isBlank()) {
if (details.customText() != null && !details.customText().isBlank()) {
throw new LeaseRuleViolation("Le texte d'une clause du catalogue est défini par le serveur.");
}
LeaseClauseCatalog.RenderedClause rendered = clauseCatalog.render(details.catalogId(), context,
details.parameters());
return LeaseSpecialClause.catalog(rendered.id(), rendered.parameters(), rendered.text());
}
if (details.parameters() != null && !details.parameters().isEmpty()) {
throw new LeaseRuleViolation("Une clause personnalisée ne peut pas avoir de paramètres catalogue.");
}
if (details.customText() == null || details.customText().isBlank()) {
throw new LeaseRuleViolation("Le texte de la clause personnalisée est obligatoire.");
}
return LeaseSpecialClause.custom(details.customText());
}
private static LeaseClauseCatalog.Context context(List<TenantId> tenantIds, LeaseType leaseType) {
if (tenantIds.size() > 1) {
return LeaseClauseCatalog.Context.COLOCATION;
}
return leaseType == LeaseType.STUDENT
? LeaseClauseCatalog.Context.STUDENT
: LeaseClauseCatalog.Context.STANDARD;
}
public record DraftDetails(ApartmentId apartmentId, List<TenantId> tenantIds, List<Guarantor> guarantors,
RentalPeriod period, RentTerms terms) {
RentalPeriod period, RentTerms terms, LeaseType leaseType,
List<ClauseDetails> specialClauses, LeaseLegalTerms legalTerms) {
public DraftDetails(ApartmentId apartmentId, List<TenantId> tenantIds, List<Guarantor> guarantors,
RentalPeriod period, RentTerms terms) {
this(apartmentId, tenantIds, guarantors, period, terms, LeaseType.STANDARD, null, null);
}
public DraftDetails(ApartmentId apartmentId, List<TenantId> tenantIds, List<Guarantor> guarantors,
RentalPeriod period, RentTerms terms, LeaseType leaseType) {
this(apartmentId, tenantIds, guarantors, period, terms, leaseType, null, null);
}
public DraftDetails(ApartmentId apartmentId, List<TenantId> tenantIds, List<Guarantor> guarantors,
RentalPeriod period, RentTerms terms, LeaseType leaseType,
List<ClauseDetails> specialClauses) {
this(apartmentId, tenantIds, guarantors, period, terms, leaseType, specialClauses, null);
}
public DraftDetails {
tenantIds = tenantIds == null ? List.of() : List.copyOf(tenantIds);
guarantors = guarantors == null ? List.of() : List.copyOf(guarantors);
specialClauses = specialClauses == null ? null : List.copyOf(specialClauses);
}
}
public record ClauseDetails(String catalogId, Map<String, String> parameters, String customText) {
public ClauseDetails {
parameters = parameters == null ? Map.of()
: Collections.unmodifiableMap(new LinkedHashMap<>(parameters));
}
}
}
@@ -0,0 +1,369 @@
package fr.nessar.lmnporganizer.rental.application.lease;
import fr.nessar.lmnporganizer.rental.application.port.out.LeaseDocumentStorage;
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentTooLarge;
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentRepository;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.FurnishedLeaseTemplate;
import fr.nessar.lmnporganizer.rental.domain.lease.Guarantor;
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentRevision;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentRevisionRepository;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentWarning;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseFinalization;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseFinalizationConflict;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseFinalizationRepository;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseFinalizationWarning;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseGuaranteeDeed;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseGuaranteeDeedRepository;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRuleViolation;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseStatus;
import fr.nessar.lmnporganizer.rental.domain.rent.RentDue;
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueRepository;
import fr.nessar.lmnporganizer.rental.domain.tenant.Tenant;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantRepository;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
public final class LeaseFinalizationService {
public static final String GUARANTEE_SIMULATOR_URL =
"https://www.service-public.fr/simulateur/calcul/ActeCautionnement";
private final LeaseRepository leases;
private final ApartmentRepository apartments;
private final TenantRepository tenants;
private final GenerateLeaseDocumentService documents;
private final LeaseDocumentRevisionRepository revisions;
private final LeaseGuaranteeDeedRepository guaranteeDeeds;
private final LeaseFinalizationRepository finalizations;
private final LeaseDocumentStorage storage;
private final ActivateLeaseService activation;
private final RentDueRepository rentDues;
private final TransactionRunner transactions;
private final Clock clock;
private final long maximumPdfSize;
public LeaseFinalizationService(LeaseRepository leases, ApartmentRepository apartments, TenantRepository tenants,
GenerateLeaseDocumentService documents,
LeaseDocumentRevisionRepository revisions,
LeaseGuaranteeDeedRepository guaranteeDeeds,
LeaseFinalizationRepository finalizations, LeaseDocumentStorage storage,
ActivateLeaseService activation, RentDueRepository rentDues,
TransactionRunner transactions, Clock clock, long maximumPdfSize) {
this.leases = leases;
this.apartments = apartments;
this.tenants = tenants;
this.documents = documents;
this.revisions = revisions;
this.guaranteeDeeds = guaranteeDeeds;
this.finalizations = finalizations;
this.storage = storage;
this.activation = activation;
this.rentDues = rentDues;
this.transactions = transactions;
this.clock = clock;
if (maximumPdfSize < 1) {
throw new IllegalArgumentException("Maximum PDF size must be positive.");
}
this.maximumPdfSize = maximumPdfSize;
}
public LeaseGuaranteeDeed uploadGuaranteeDeed(LandlordId landlordId, LeaseId leaseId, String guarantorId,
TenantId tenantId, boolean attestation, PdfUpload upload,
String authorId) {
Lease lease = draft(landlordId, leaseId);
requireAuthor(authorId);
if (!attestation) {
throw new LeaseRuleViolation("L'attestation de conformité de l'acte de cautionnement est obligatoire.");
}
requirePair(lease, guarantorId, tenantId);
byte[] content = pdf(upload);
String fingerprint = sha256(content);
Optional<LeaseGuaranteeDeed> existing = guaranteeDeeds.find(landlordId, leaseId, guarantorId, tenantId);
if (existing.isPresent()) {
return sameOrConflict(existing.orElseThrow(), fingerprint);
}
String storageKey = storage.store(content);
LeaseGuaranteeDeed deed = new LeaseGuaranteeDeed(UUID.randomUUID().toString(), landlordId, leaseId,
guarantorId, tenantId, storageKey, safeFileName(upload.fileName()), fingerprint, authorId,
Instant.now(clock));
try {
return guaranteeDeeds.save(deed);
} catch (LeaseFinalizationConflict conflict) {
discard(storageKey, conflict);
return guaranteeDeeds.find(landlordId, leaseId, guarantorId, tenantId)
.map(found -> sameOrConflict(found, fingerprint)).orElseThrow(() -> conflict);
} catch (RuntimeException failure) {
discard(storageKey, failure);
throw failure;
}
}
public State state(LandlordId landlordId, LeaseId leaseId) {
Lease lease = lease(landlordId, leaseId);
List<LeaseGuaranteeDeed> deeds = guaranteeDeeds.findAll(landlordId, leaseId);
LeaseDocumentRevision revision = revisions.findCurrent(landlordId, leaseId).orElse(null);
return new State(revision, warnings(landlordId, lease, revision).stream()
.map(LeaseDocumentWarning::code).toList(), requirements(landlordId, lease, deeds),
finalizations.find(landlordId, leaseId).orElse(null));
}
public Result finalizeLease(LandlordId landlordId, LeaseId leaseId, FinalizationRequest request,
PdfUpload signedLease, String authorId) {
Lease lease = lease(landlordId, leaseId);
requireAuthor(authorId);
if (finalizations.find(landlordId, leaseId).isPresent()) {
throw new LeaseFinalizationConflict("Ce bail est déjà finalisé.");
}
requireDraft(lease);
if (!request.allPartiesSigned() || !request.revisionMatches()) {
throw new LeaseRuleViolation("Les signatures et la correspondance de la révision doivent être attestées.");
}
if (request.lastSignatureDate() == null) {
throw new LeaseRuleViolation("La date de dernière signature est obligatoire.");
}
byte[] content = pdf(signedLease);
LeaseDocumentRevision revision = revisions.findCurrent(landlordId, leaseId)
.orElseThrow(() -> new LeaseRuleViolation("Une révision du bail doit être générée avant finalisation."));
if (request.revisionNumber() != revision.number()) {
throw new LeaseRuleViolation("La révision signée n'est plus la révision courante du bail.");
}
String expectedTemplate = FurnishedLeaseTemplate.effectiveOn(request.lastSignatureDate()).visibleId();
if (!expectedTemplate.equals(revision.templateVersion())) {
throw new LeaseRuleViolation("Le modèle signé ne correspond pas à la date de dernière signature.");
}
if (!documents.matchesCurrentSource(landlordId, leaseId, revision)) {
throw new LeaseRuleViolation("Le bail a changé depuis la génération de la révision signée.");
}
List<GuaranteeRequirement> requirements = requirements(landlordId, lease,
guaranteeDeeds.findAll(landlordId, leaseId));
if (requirements.stream().anyMatch(requirement -> requirement.deed() == null)) {
throw new LeaseRuleViolation("Tous les actes de cautionnement requis doivent être téléversés.");
}
List<LeaseFinalization.LegalOverride> overrides = overrides(landlordId, lease, revision,
request.confirmedWarningCodes(), authorId);
String storageKey = storage.store(content);
Instant now = Instant.now(clock);
LeaseFinalization proposed = new LeaseFinalization(UUID.randomUUID().toString(), landlordId, leaseId,
revision.number(), revision.templateVersion(), request.lastSignatureDate(), storageKey,
safeFileName(signedLease.fileName()), sha256(content), authorId, now, overrides);
try {
return transactions.run(() -> finalizeInTransaction(landlordId, leaseId, revision, proposed));
} catch (RuntimeException failure) {
discard(storageKey, failure);
throw failure;
}
}
private Result finalizeInTransaction(LandlordId landlordId, LeaseId leaseId, LeaseDocumentRevision revision,
LeaseFinalization proposed) {
if (finalizations.find(landlordId, leaseId).isPresent()) {
throw new LeaseFinalizationConflict("Ce bail est déjà finalisé.");
}
if (!documents.matchesCurrentSource(landlordId, leaseId, revision)) {
throw new LeaseRuleViolation("Le bail a changé depuis la génération de la révision signée.");
}
Lease activated = activation.activateInTransaction(landlordId, leaseId);
LeaseFinalization saved = finalizations.save(proposed);
List<RentDue> dues = rentDues.findAll(landlordId, leaseId).stream()
.sorted(Comparator.comparing(RentDue::month).thenComparing(due -> due.id().value())).toList();
return new Result(activated, dues, saved);
}
private List<GuaranteeRequirement> requirements(LandlordId landlordId, Lease lease,
List<LeaseGuaranteeDeed> deeds) {
if (lease.guarantors().isEmpty()) {
return List.of();
}
Map<TenantId, Tenant> leaseTenants = lease.tenantIds().stream().collect(java.util.stream.Collectors.toMap(
tenantId -> tenantId, tenantId -> tenants.find(landlordId, tenantId)
.orElseThrow(() -> new ResourceNotFound("Tenant"))));
return lease.guarantors().stream().flatMap(guarantor -> lease.tenantIds().stream()
.map(tenantId -> requirement(guarantor, leaseTenants.get(tenantId), deeds))).toList();
}
private GuaranteeRequirement requirement(Guarantor guarantor, Tenant tenant, List<LeaseGuaranteeDeed> deeds) {
TenantId tenantId = tenant.id();
LeaseGuaranteeDeed deed = deeds.stream().filter(candidate -> candidate.guarantorId().equals(guarantor.id())
&& candidate.tenantId().equals(tenantId)).findFirst().orElse(null);
return new GuaranteeRequirement(guarantor.id(), guarantor.firstName() + " " + guarantor.lastName(),
tenantId, tenant.firstName() + " " + tenant.lastName(), deed);
}
private static void requirePair(Lease lease, String guarantorId, TenantId tenantId) {
boolean guarantorExists = lease.guarantors().stream().anyMatch(guarantor -> guarantor.id().equals(guarantorId));
if (!guarantorExists || !lease.tenantIds().contains(tenantId)) {
throw new LeaseRuleViolation("Le couple garant-locataire n'appartient pas à ce bail.");
}
}
private List<LeaseFinalization.LegalOverride> overrides(LandlordId landlordId, Lease lease,
LeaseDocumentRevision revision,
Set<String> confirmedCodes, String authorId) {
Set<String> confirmed = confirmedCodes == null ? Set.of() : Set.copyOf(confirmedCodes);
for (String code : confirmed) {
try {
LeaseFinalizationWarning.valueOf(code);
} catch (IllegalArgumentException failure) {
throw new LeaseRuleViolation("Le code d'alerte confirmé est inconnu.");
}
}
Instant now = Instant.now(clock);
return warnings(landlordId, lease, revision).stream().map(warning -> {
if (!confirmed.contains(warning.code())) {
throw new LeaseRuleViolation("L'alerte " + warning.code() + " doit être confirmée.");
}
return new LeaseFinalization.LegalOverride(warning.code(), warning.ruleVersion(), authorId, now);
}).toList();
}
private List<LeaseDocumentWarning> warnings(LandlordId landlordId, Lease lease,
LeaseDocumentRevision revision) {
List<LeaseDocumentWarning> warnings = new ArrayList<>();
BigDecimal maximum = lease.terms().monthlyRent().multiply(BigDecimal.TWO);
if (lease.terms().securityDeposit().compareTo(maximum) > 0) {
warnings.add(LeaseDocumentWarning.from(LeaseFinalizationWarning.SECURITY_DEPOSIT_ABOVE_TWO_MONTHS));
}
if (lease.legalTerms().rentRevision().enabled() && isRestrictedEnergyRating(landlordId, lease)) {
warnings.add(LeaseDocumentWarning.from(LeaseFinalizationWarning.IRL_REVISION_WITH_F_OR_G_DPE));
}
if (revision != null) {
warnings.addAll(revision.warnings());
}
return List.copyOf(warnings);
}
private boolean isRestrictedEnergyRating(LandlordId landlordId, Lease lease) {
String rating = lease.snapshot() == null
? apartments.find(landlordId, lease.apartmentId())
.orElseThrow(() -> new ResourceNotFound("Apartment")).legalProfile().energyRating()
: lease.snapshot().apartment().legalProfile().energyRating();
return "F".equals(rating) || "G".equals(rating);
}
private Lease draft(LandlordId landlordId, LeaseId leaseId) {
Lease lease = lease(landlordId, leaseId);
requireDraft(lease);
return lease;
}
private static void requireDraft(Lease lease) {
if (lease.status() != LeaseStatus.DRAFT) {
throw new LeaseRuleViolation("Seul un bail en brouillon peut être finalisé.");
}
}
private Lease lease(LandlordId landlordId, LeaseId leaseId) {
return leases.find(landlordId, leaseId).orElseThrow(() -> new ResourceNotFound("Lease"));
}
private byte[] pdf(PdfUpload upload) {
if (upload == null) {
throw new LeaseRuleViolation("Un fichier PDF est obligatoire.");
}
byte[] content = upload.content();
if (content.length == 0) {
throw new LeaseRuleViolation("Un fichier PDF est obligatoire.");
}
if (content.length > maximumPdfSize) {
throw new AttachmentTooLarge(maximumPdfSize);
}
if (!"application/pdf".equalsIgnoreCase(upload.contentType()) || content.length < 5
|| !Arrays.equals(Arrays.copyOf(content, 5), "%PDF-".getBytes(StandardCharsets.US_ASCII))) {
throw new LeaseRuleViolation("Le fichier doit être un PDF lisible.");
}
return content;
}
private static LeaseGuaranteeDeed sameOrConflict(LeaseGuaranteeDeed existing, String fingerprint) {
if (!existing.sha256().equals(fingerprint)) {
throw new LeaseFinalizationConflict("Un acte différent existe déjà pour ce couple garant-locataire.");
}
return existing;
}
private static void requireAuthor(String authorId) {
if (authorId == null || authorId.isBlank()) {
throw new IllegalArgumentException("Author ID is required.");
}
}
private void discard(String storageKey, RuntimeException failure) {
try {
storage.discard(storageKey);
} catch (RuntimeException cleanupFailure) {
failure.addSuppressed(cleanupFailure);
}
}
private static String safeFileName(String supplied) {
if (supplied == null || supplied.isBlank()) {
return "document.pdf";
}
String trimmed = supplied.trim();
int separator = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'));
String name = trimmed.substring(separator + 1);
return name.isBlank() ? "document.pdf" : name;
}
private static String sha256(byte[] content) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(content));
} catch (NoSuchAlgorithmException impossible) {
throw new IllegalStateException("SHA-256 is unavailable.", impossible);
}
}
public record PdfUpload(String fileName, String contentType, byte[] content) {
public PdfUpload {
content = content == null ? new byte[0] : Arrays.copyOf(content, content.length);
}
@Override
public byte[] content() {
return Arrays.copyOf(content, content.length);
}
}
public record FinalizationRequest(int revisionNumber, LocalDate lastSignatureDate, boolean allPartiesSigned,
boolean revisionMatches, Set<String> confirmedWarningCodes) {
}
public record GuaranteeRequirement(String guarantorId, String guarantorName, TenantId tenantId,
String tenantName, LeaseGuaranteeDeed deed) {
}
public record State(LeaseDocumentRevision revision, List<String> warningCodes,
List<GuaranteeRequirement> guaranteeRequirements, LeaseFinalization finalization) {
public State {
warningCodes = List.copyOf(warningCodes);
guaranteeRequirements = List.copyOf(guaranteeRequirements);
}
}
public record Result(Lease lease, List<RentDue> rentDues, LeaseFinalization finalization) {
public Result {
rentDues = List.copyOf(rentDues);
}
}
}
@@ -0,0 +1,36 @@
package fr.nessar.lmnporganizer.rental.application.port.out;
import java.math.BigDecimal;
import java.util.Objects;
public interface IrlIndexProvider {
Indexes indexes(String seriesId, Period previousPeriod, Period newPeriod);
record Period(int year, int quarter) {
public Period {
if (year < 1 || quarter < 1 || quarter > 4) {
throw new IllegalArgumentException("IRL period is invalid.");
}
}
}
record Indexes(BigDecimal previousIndex, BigDecimal newIndex, Source source) {
public Indexes {
requirePositive(previousIndex);
requirePositive(newIndex);
Objects.requireNonNull(source, "IRL index source is required.");
}
private static void requirePositive(BigDecimal index) {
if (index == null || index.signum() <= 0) {
throw new IllegalArgumentException("IRL index must be strictly positive.");
}
}
}
enum Source {
INSEE,
CACHE,
MANUAL
}
}
@@ -0,0 +1,18 @@
package fr.nessar.lmnporganizer.rental.application.port.out;
public interface LeaseDocumentStorage {
String store(byte[] content);
byte[] read(String storageKey);
void discard(String storageKey);
StagedDeletion stageDeletion(String storageKey);
void restore(StagedDeletion deletion);
void completeDeletion(StagedDeletion deletion);
interface StagedDeletion {
}
}
@@ -0,0 +1,30 @@
package fr.nessar.lmnporganizer.rental.application.port.out;
import java.util.Arrays;
public interface LeaseInformationNoticeCatalog {
InformationNotice current();
record InformationNotice(String version, String fileName, byte[] content) {
public InformationNotice {
version = required(version, "Information notice version");
fileName = required(fileName, "Information notice file name");
content = content == null ? new byte[0] : Arrays.copyOf(content, content.length);
if (content.length == 0) {
throw new IllegalArgumentException("Information notice content is required.");
}
}
@Override
public byte[] content() {
return Arrays.copyOf(content, content.length);
}
private static String required(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " is required.");
}
return value.trim();
}
}
}
@@ -3,7 +3,10 @@ package fr.nessar.lmnporganizer.rental.application.port.out;
import fr.nessar.lmnporganizer.rental.domain.lease.Guarantor;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseContractVersion;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseLegalTerms;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSnapshot;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSpecialClause;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseType;
import fr.nessar.lmnporganizer.rental.domain.lease.RentTerms;
import fr.nessar.lmnporganizer.rental.domain.lease.RentalPeriod;
import fr.nessar.lmnporganizer.rental.domain.rent.PaymentAllocationId;
@@ -15,20 +18,51 @@ import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.List;
import java.util.Objects;
public interface RentalDocumentRenderer {
byte[] renderLease(LeaseDocument document);
byte[] renderInformationNotice(InformationNotice document);
byte[] renderPaymentReceipt(PaymentReceipt document);
byte[] renderRentReceipt(RentReceipt document);
byte[] renderRentRevisionRequest(RentRevisionRequest document);
record InformationNotice(String text) {
public InformationNotice {
if (text == null || text.isBlank()) {
throw new IllegalArgumentException("Information notice text is required.");
}
}
}
record LeaseDocument(LeaseId leaseId, LeaseSnapshot.Landlord landlord, LeaseSnapshot.Apartment apartment,
List<LeaseSnapshot.Tenant> tenants, List<Guarantor> guarantors, RentalPeriod period,
RentTerms terms) {
RentTerms terms, LeaseType leaseType, List<LeaseSpecialClause> specialClauses,
LeaseLegalTerms legalTerms, String templateVersion, int revisionNumber) {
public LeaseDocument {
tenants = List.copyOf(tenants);
guarantors = List.copyOf(guarantors);
leaseType = LeaseType.orStandard(leaseType);
specialClauses = specialClauses == null ? List.of() : List.copyOf(specialClauses);
legalTerms = LeaseLegalTerms.orEmpty(legalTerms);
}
public LeaseDocument(LeaseId leaseId, LeaseSnapshot.Landlord landlord, LeaseSnapshot.Apartment apartment,
List<LeaseSnapshot.Tenant> tenants, List<Guarantor> guarantors, RentalPeriod period,
RentTerms terms, String templateVersion, int revisionNumber) {
this(leaseId, landlord, apartment, tenants, guarantors, period, terms, LeaseType.STANDARD, List.of(),
LeaseLegalTerms.empty(), templateVersion, revisionNumber);
}
public LeaseDocument(LeaseId leaseId, LeaseSnapshot.Landlord landlord, LeaseSnapshot.Apartment apartment,
List<LeaseSnapshot.Tenant> tenants, List<Guarantor> guarantors, RentalPeriod period,
RentTerms terms) {
this(leaseId, landlord, apartment, tenants, guarantors, period, terms, LeaseType.STANDARD, List.of(),
LeaseLegalTerms.empty(), "LEGACY", 0);
}
}
@@ -52,4 +86,42 @@ public interface RentalDocumentRenderer {
record RentReceipt(RentDueId rentDueId, YearMonth month, BigDecimal rent, BigDecimal charges,
BigDecimal total, LocalDate issuedAt, LeaseContractVersion contract) {
}
record RentRevisionRequest(LeaseSnapshot.Landlord landlord, List<LeaseSnapshot.Tenant> tenants,
LeaseSnapshot.Apartment apartment, LocalDate requestedOn, LocalDate effectiveOn,
String seriesId, int referenceQuarter, int previousIndexYear, int newIndexYear,
BigDecimal previousIndex, BigDecimal newIndex, BigDecimal previousRent,
BigDecimal rentCeiling, BigDecimal chosenRent, FlatRateRevision flatRateRevision) {
public RentRevisionRequest {
landlord = Objects.requireNonNull(landlord, "Landlord is required.");
tenants = List.copyOf(tenants);
if (tenants.isEmpty()) {
throw new IllegalArgumentException("At least one tenant is required.");
}
apartment = Objects.requireNonNull(apartment, "Apartment is required.");
requestedOn = Objects.requireNonNull(requestedOn, "Request date is required.");
effectiveOn = Objects.requireNonNull(effectiveOn, "Effective date is required.");
if (seriesId == null || seriesId.isBlank()) {
throw new IllegalArgumentException("IRL series ID is required.");
}
seriesId = seriesId.trim();
if (referenceQuarter < 1 || referenceQuarter > 4 || previousIndexYear < 1 || newIndexYear < 1
|| newIndexYear != previousIndexYear + 1) {
throw new IllegalArgumentException("IRL reference period is invalid.");
}
previousIndex = Objects.requireNonNull(previousIndex, "Previous IRL index is required.");
newIndex = Objects.requireNonNull(newIndex, "New IRL index is required.");
previousRent = Objects.requireNonNull(previousRent, "Previous rent is required.");
rentCeiling = Objects.requireNonNull(rentCeiling, "Rent ceiling is required.");
chosenRent = Objects.requireNonNull(chosenRent, "Chosen rent is required.");
}
}
record FlatRateRevision(BigDecimal previousAmount, BigDecimal ceiling, BigDecimal chosenAmount) {
public FlatRateRevision {
previousAmount = Objects.requireNonNull(previousAmount, "Previous flat rate is required.");
ceiling = Objects.requireNonNull(ceiling, "Flat rate ceiling is required.");
chosenAmount = Objects.requireNonNull(chosenAmount, "Chosen flat rate is required.");
}
}
}
@@ -0,0 +1,130 @@
package fr.nessar.lmnporganizer.rental.application.rent;
import fr.nessar.lmnporganizer.rental.application.port.out.IdentifierGenerator;
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseAmended;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseStatus;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseType;
import fr.nessar.lmnporganizer.rental.domain.rent.RentDue;
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueId;
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueRepository;
import fr.nessar.lmnporganizer.rental.domain.rent.RentSchedule;
import fr.nessar.lmnporganizer.rental.domain.rent.RentRevision;
import fr.nessar.lmnporganizer.rental.domain.rent.RentRevisionRepository;
import org.springframework.dao.OptimisticLockingFailureException;
import java.time.Clock;
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.Comparator;
import java.util.List;
public final class RefreshRentScheduleService {
private final LeaseRepository leases;
private final RentDueRepository rentDues;
private final RentRevisionRepository revisions;
private final TransactionRunner transactions;
private final IdentifierGenerator identifiers;
private final Clock clock;
public RefreshRentScheduleService(LeaseRepository leases, RentDueRepository rentDues,
TransactionRunner transactions, IdentifierGenerator identifiers, Clock clock) {
this(leases, rentDues, null, transactions, identifiers, clock);
}
public RefreshRentScheduleService(LeaseRepository leases, RentDueRepository rentDues,
RentRevisionRepository revisions, TransactionRunner transactions,
IdentifierGenerator identifiers, Clock clock) {
this.leases = leases;
this.rentDues = rentDues;
this.revisions = revisions;
this.transactions = transactions;
this.identifiers = identifiers;
this.clock = clock;
}
public List<RentDue> refresh(LandlordId landlordId) {
try {
return transactions.run(() -> refreshInTransaction(landlordId));
} catch (OptimisticLockingFailureException concurrentInsert) {
return sorted(rentDues.findAll(landlordId));
}
}
private List<RentDue> refreshInTransaction(LandlordId landlordId) {
LocalDate today = LocalDate.now(clock);
leases.findAll(landlordId).stream().filter(RefreshRentScheduleService::canRoll)
.forEach(lease -> extend(landlordId, lease, today));
return sorted(rentDues.findAll(landlordId));
}
private void extend(LandlordId landlordId, Lease lease, LocalDate today) {
List<RentDue> existing = rentDues.findAll(landlordId, lease.id());
if (existing.isEmpty()) {
return;
}
YearMonth blockStart = existing.stream().map(RentDue::month).max(YearMonth::compareTo).orElseThrow()
.plusMonths(1);
List<RentDue> extension = RentSchedule.extendStandard(lease.id(), landlordId,
lease.contractVersionOn(blockStart.atDay(1)), existing, today,
month -> new RentDueId(identifiers.next()));
if (!extension.isEmpty()) {
YearMonth firstMonth = extension.getFirst().month();
YearMonth lastMonth = extension.getLast().month();
lease.amendments().stream()
.filter(amendment -> !YearMonth.from(amendment.effectiveDate()).isBefore(firstMonth))
.filter(amendment -> !YearMonth.from(amendment.effectiveDate()).isAfter(lastMonth))
.forEach(amendment -> RentSchedule.reconcile(
new LeaseAmended(lease.id(), landlordId, amendment), extension,
month -> new RentDueId(identifiers.next())));
applyRevisions(landlordId, lease, extension);
rentDues.saveAll(extension);
}
}
private void applyRevisions(LandlordId landlordId, Lease lease, List<RentDue> extension) {
if (revisions == null) {
return;
}
revisions.findAll(landlordId, lease.id()).stream()
.filter(revision -> revision.status() == RentRevision.Status.APPLIED)
.sorted(Comparator.comparing(revision -> revision.application().effectiveOn()))
.forEach(revision -> {
RentSchedule.RevisionAmounts amounts = new RentSchedule.RevisionAmounts(
revision.amounts().previousRent(), revision.amounts().previousCharges(),
revision.amounts().chosenRent(), revision.amounts().appliedCharges());
var boundary = lease.amendments().stream()
.filter(amendment -> amendment.effectiveDate()
.isAfter(revision.application().effectiveOn()))
.filter(RefreshRentScheduleService::changesAmounts)
.min(Comparator.comparing(amendment -> amendment.effectiveDate()))
.map(amendment -> new RentSchedule.FinancialBoundary(amendment.effectiveDate(),
amendment.afterVersion().terms().monthlyRent(),
amendment.afterVersion().terms().monthlyCharges())).orElse(null);
if (boundary == null) {
RentSchedule.applyRevision(extension, revision.application().effectiveOn(), amounts);
} else {
RentSchedule.applyRevision(extension, revision.application().effectiveOn(), amounts, boundary);
}
});
}
private static boolean changesAmounts(fr.nessar.lmnporganizer.rental.domain.lease.LeaseAmendment amendment) {
return amendment.beforeVersion().terms().monthlyRent()
.compareTo(amendment.afterVersion().terms().monthlyRent()) != 0
|| amendment.beforeVersion().terms().monthlyCharges()
.compareTo(amendment.afterVersion().terms().monthlyCharges()) != 0;
}
private static boolean canRoll(Lease lease) {
return lease.status() == LeaseStatus.ACTIVE && lease.leaseType() == LeaseType.STANDARD;
}
private static List<RentDue> sorted(List<RentDue> dues) {
return dues.stream().sorted(Comparator.comparing(RentDue::month).thenComparing(due -> due.id().value()))
.toList();
}
}
@@ -0,0 +1,590 @@
package fr.nessar.lmnporganizer.rental.application.rent;
import fr.nessar.lmnporganizer.rental.application.port.out.IdentifierGenerator;
import fr.nessar.lmnporganizer.rental.application.port.out.IrlIndexProvider;
import fr.nessar.lmnporganizer.rental.application.port.out.LeaseDocumentStorage;
import fr.nessar.lmnporganizer.rental.application.port.out.RentalDocumentRenderer;
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentTooLarge;
import fr.nessar.lmnporganizer.rental.application.shared.IrlIndexUnavailable;
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseFinalizationWarning;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseLegalTerms;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRuleViolation;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseStatus;
import fr.nessar.lmnporganizer.rental.domain.lease.RentTerms;
import fr.nessar.lmnporganizer.rental.domain.rent.RentDue;
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueRepository;
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueSettlement;
import fr.nessar.lmnporganizer.rental.domain.rent.RentRevision;
import fr.nessar.lmnporganizer.rental.domain.rent.RentRevisionConflict;
import fr.nessar.lmnporganizer.rental.domain.rent.RentRevisionId;
import fr.nessar.lmnporganizer.rental.domain.rent.RentRevisionPolicy;
import fr.nessar.lmnporganizer.rental.domain.rent.RentRevisionRepository;
import fr.nessar.lmnporganizer.rental.domain.rent.RentRuleViolation;
import fr.nessar.lmnporganizer.rental.domain.rent.RentSchedule;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.YearMonth;
import java.util.Arrays;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
public final class RentRevisionService {
private final LeaseRepository leases;
private final IrlIndexProvider indices;
private final RentRevisionRepository revisions;
private final RentDueRepository rentDues;
private final RentalDocumentRenderer renderer;
private final LeaseDocumentStorage storage;
private final TransactionRunner transactions;
private final IdentifierGenerator identifiers;
private final Clock clock;
private final long maximumPdfSize;
public RentRevisionService(LeaseRepository leases, IrlIndexProvider indices, Clock clock) {
this(leases, indices, null, null, null, null, null, null, clock, 1);
}
public RentRevisionService(LeaseRepository leases, IrlIndexProvider indices, RentRevisionRepository revisions,
RentDueRepository rentDues,
RentalDocumentRenderer renderer, LeaseDocumentStorage storage,
TransactionRunner transactions, IdentifierGenerator identifiers, Clock clock,
long maximumPdfSize) {
this.leases = leases;
this.indices = indices;
this.revisions = revisions;
this.rentDues = rentDues;
this.renderer = renderer;
this.storage = storage;
this.transactions = transactions;
this.identifiers = identifiers;
this.clock = clock;
if (maximumPdfSize < 1) {
throw new IllegalArgumentException("Maximum PDF size must be positive.");
}
this.maximumPdfSize = maximumPdfSize;
}
public RentRevision propose(LandlordId landlordId, LeaseId leaseId, ProposalRequest request, String authorId) {
requireProposalDependencies();
Objects.requireNonNull(request, "Rent revision proposal request is required.");
String author = required(authorId, "Proposal author");
Preview preview = preview(landlordId, leaseId, request.preview());
LocalDate today = LocalDate.now(clock.withZone(request.preview().timeZone()));
if (request.requestedOn() == null || request.requestedOn().isBefore(preview.annualRevisionDate())
|| request.requestedOn().isAfter(preview.requestDeadline())) {
throw new RentRuleViolation(
"La date de demande doit être comprise entre la date anniversaire et son échéance.");
}
if (request.requestedOn().isAfter(today)) {
throw new RentRuleViolation("La date de demande ne peut pas être future dans le fuseau du bailleur.");
}
Lease lease = leases.find(landlordId, leaseId).orElseThrow(() -> new ResourceNotFound("Lease"));
RentTerms currentTerms = rentTermsOn(landlordId, lease, request.requestedOn());
BigDecimal rentCeiling = RentRevisionPolicy.ceiling(currentTerms.monthlyRent(), preview.previousIndex(),
preview.newIndex());
BigDecimal chargeCeiling = RentRevisionPolicy.chargeCeiling(currentTerms, preview.previousIndex(),
preview.newIndex()).orElse(null);
RentRevision.Amounts amounts = new RentRevision.Amounts(currentTerms.monthlyRent(), rentCeiling,
request.chosenRent(), currentTerms.monthlyCharges(), chargeCeiling, request.chosenCharges());
Instant now = Instant.now(clock);
List<Warning> currentWarnings = warnings(lease, request.requestedOn());
List<RentRevision.LegalOverride> overrides = overrides(currentWarnings, request.confirmedWarningCodes(),
author, now);
var contract = lease.contractVersionOn(request.requestedOn());
LocalDate effectiveOn = effectiveDate(rentDues.findAll(landlordId, leaseId), request.requestedOn(),
currentTerms.dueDay());
rejectFinancialChangeBeforeEffect(lease, request.requestedOn(), effectiveOn);
RentRevision.IndexReference indexReference = new RentRevision.IndexReference(preview.seriesId(),
preview.seriesOverridden(), preview.seriesOverridden(),
RentRevision.IndexSource.valueOf(preview.indexSource()), "MANUAL".equals(preview.indexSource()),
preview.referenceQuarter(), preview.previousIndexYear(), preview.newIndexYear(),
preview.previousIndex(), preview.newIndex());
RentRevision existingProposal = revisions.findAll(landlordId, leaseId).stream()
.filter(revision -> sameProposal(revision, preview, request.requestedOn(), effectiveOn,
indexReference, amounts, overrides))
.findFirst().orElse(null);
if (existingProposal != null) {
return existingProposal;
}
RentalDocumentRenderer.FlatRateRevision flatRate = chargeCeiling == null ? null
: new RentalDocumentRenderer.FlatRateRevision(currentTerms.monthlyCharges(), chargeCeiling,
request.chosenCharges());
byte[] letter = renderer.renderRentRevisionRequest(new RentalDocumentRenderer.RentRevisionRequest(
contract.landlord(), contract.tenants(), contract.apartment(), request.requestedOn(),
effectiveOn, preview.seriesId(), preview.referenceQuarter(), preview.previousIndexYear(),
preview.newIndexYear(),
preview.previousIndex(), preview.newIndex(), currentTerms.monthlyRent(), rentCeiling,
request.chosenRent(), flatRate));
String storageKey = storage.store(letter);
RentRevision proposed = new RentRevision(new RentRevisionId(identifiers.next()), landlordId, leaseId,
new RentRevision.Window(preview.annualRevisionDate(), preview.requestDeadline()),
indexReference, amounts,
new RentRevision.Request(request.requestedOn(), effectiveOn, storageKey, sha256(letter), author, now),
overrides, null, -1);
try {
return transactions.run(() -> revisions.save(proposed));
} catch (RuntimeException failure) {
discard(storageKey, failure);
throw failure;
}
}
public ApplyResult apply(LandlordId landlordId, LeaseId leaseId, RentRevisionId revisionId,
ApplyRequest request, String authorId) {
requireProposalDependencies();
Objects.requireNonNull(request, "Rent revision application request is required.");
String author = required(authorId, "Application author");
if (!request.deliveryConfirmed()) {
throw new RentRuleViolation("L'envoi de la demande doit être confirmé.");
}
if (request.sentOn() == null || request.sentOn().isAfter(LocalDate.now(clock.withZone(request.timeZone())))) {
throw new RentRuleViolation("La date d'envoi ne peut pas être future dans le fuseau du bailleur.");
}
RentRevision proposed = revisions.find(landlordId, leaseId, revisionId)
.orElseThrow(() -> new ResourceNotFound("Rent revision"));
Lease lease = leases.find(landlordId, leaseId).orElseThrow(() -> new ResourceNotFound("Lease"));
assertCurrentAmounts(landlordId, lease, proposed, request.sentOn());
List<RentDue> existing = rentDues.findAll(landlordId, leaseId);
LocalDate effectiveOn = effectiveDate(existing, request.sentOn(),
lease.contractVersionOn(request.sentOn()).terms().dueDay());
rejectFinancialChangeBeforeEffect(lease, request.sentOn(), effectiveOn);
byte[] proofContent = request.proof() == null ? null : pdf(request.proof());
String proofStorageKey = proofContent == null ? null : storage.store(proofContent);
Instant now = Instant.now(clock);
RentRevision.Proof proof = proofContent == null ? null : new RentRevision.Proof(proofStorageKey,
safeFileName(request.proof().fileName()), sha256(proofContent), author, now);
RentRevision.Application application = new RentRevision.Application(request.sentOn(),
request.deliveryMethod(), effectiveOn, proof, author, now);
try {
return transactions.run(() -> applyInTransaction(landlordId, lease, proposed, application, existing));
} catch (RuntimeException failure) {
if (proofStorageKey != null) {
discard(proofStorageKey, failure);
}
throw failure;
}
}
public List<RentRevision> list(LandlordId landlordId, LeaseId leaseId) {
requireProposalDependencies();
if (leases.find(landlordId, leaseId).isEmpty()) {
throw new ResourceNotFound("Lease");
}
return revisions.findAll(landlordId, leaseId).stream()
.sorted(Comparator.comparing((RentRevision revision) -> revision.request().proposedAt()).reversed())
.toList();
}
public Letter letter(LandlordId landlordId, LeaseId leaseId, RentRevisionId revisionId) {
requireProposalDependencies();
RentRevision revision = revisions.find(landlordId, leaseId, revisionId)
.orElseThrow(() -> new ResourceNotFound("Rent revision"));
return new Letter("revision-loyer-" + leaseId.value() + "-" + revision.request().requestedOn() + ".pdf",
storage.read(revision.request().letterStorageKey()));
}
public ProofDownload proof(LandlordId landlordId, LeaseId leaseId, RentRevisionId revisionId) {
requireProposalDependencies();
RentRevision revision = revisions.find(landlordId, leaseId, revisionId)
.orElseThrow(() -> new ResourceNotFound("Rent revision"));
RentRevision.Proof proof = revision.application() == null ? null : revision.application().proof();
if (proof == null) {
throw new ResourceNotFound("Rent revision proof");
}
return new ProofDownload(proof.fileName(), storage.read(proof.storageKey()));
}
public Preview preview(LandlordId landlordId, LeaseId leaseId, PreviewRequest request) {
Objects.requireNonNull(request, "Rent revision preview request is required.");
Lease lease = leases.find(landlordId, leaseId).orElseThrow(() -> new ResourceNotFound("Lease"));
if (lease.status() != LeaseStatus.ACTIVE) {
throw new LeaseRuleViolation("Seul un bail actif peut faire l'objet d'une révision IRL.");
}
LeaseLegalTerms.RentRevisionTerms revisionTerms = lease.legalTerms().rentRevision();
if (!revisionTerms.enabled()) {
throw new LeaseRuleViolation("Le bail ne prévoit pas de révision IRL.");
}
LocalDate today = LocalDate.now(clock.withZone(request.timeZone()));
LocalDate annualDate = revisionTerms.annualRevisionDate();
LocalDate deadline = RentRevisionPolicy.requestDeadline(annualDate);
int expiredYears = 0;
while (today.isAfter(deadline) || alreadyApplied(landlordId, leaseId, annualDate)) {
annualDate = annualDate.plusYears(1);
deadline = RentRevisionPolicy.requestDeadline(annualDate);
expiredYears++;
}
String automaticSeries = revisionTerms.seriesId();
if (!RentRevisionPolicy.isSupportedSeries(automaticSeries)) {
throw new RentRuleViolation("La série IRL prévue par le bail n'est pas prise en charge.");
}
String series = series(request, automaticSeries);
int previousYear = revisionTerms.referenceYear() + expiredYears;
int newYear = previousYear + 1;
IrlIndexProvider.Indexes values = indexValues(request, series, revisionTerms.referenceQuarter(),
previousYear, newYear);
RentTerms rentTerms = rentTermsOn(landlordId, lease, today);
List<Warning> warnings = warnings(lease, today);
return new Preview(lease.id(), annualDate, deadline, series, !series.equals(automaticSeries),
values.source().name(), revisionTerms.referenceQuarter(), previousYear, newYear,
values.previousIndex(), values.newIndex(), rentTerms.monthlyRent(),
RentRevisionPolicy.ceiling(rentTerms.monthlyRent(), values.previousIndex(), values.newIndex()),
rentTerms.monthlyCharges(), RentRevisionPolicy.chargeCeiling(rentTerms, values.previousIndex(),
values.newIndex()).orElse(null), warnings);
}
private boolean alreadyApplied(LandlordId landlordId, LeaseId leaseId, LocalDate annualDate) {
return revisions != null && revisions.findAll(landlordId, leaseId).stream()
.anyMatch(revision -> revision.status() == RentRevision.Status.APPLIED
&& revision.window().annualRevisionDate().equals(annualDate));
}
private RentTerms rentTermsOn(LandlordId landlordId, Lease lease, LocalDate date) {
RentTerms contractTerms = lease.contractVersionOn(date).terms();
if (revisions == null) {
return contractTerms;
}
LocalDate lastFinancialAmendment = lease.amendments().stream()
.filter(amendment -> !amendment.effectiveDate().isAfter(date))
.filter(RentRevisionService::changesAmounts)
.map(amendment -> amendment.effectiveDate()).max(LocalDate::compareTo).orElse(LocalDate.MIN);
RentRevision latest = revisions.findAll(landlordId, lease.id()).stream()
.filter(revision -> revision.status() == RentRevision.Status.APPLIED)
.filter(revision -> !revision.application().effectiveOn().isAfter(date))
.filter(revision -> !revision.application().effectiveOn().isBefore(lastFinancialAmendment))
.max(Comparator.comparing(revision -> revision.application().effectiveOn())).orElse(null);
if (latest == null) {
return contractTerms;
}
return new RentTerms(latest.amounts().chosenRent(), latest.amounts().appliedCharges(),
contractTerms.securityDeposit(), contractTerms.dueDay(), contractTerms.chargeMethod(),
contractTerms.indexFlatRate());
}
private ApplyResult applyInTransaction(LandlordId landlordId, Lease lease, RentRevision proposed,
RentRevision.Application application, List<RentDue> existing) {
if (revisions.findAll(landlordId, lease.id()).stream()
.anyMatch(revision -> !revision.id().equals(proposed.id())
&& revision.status() == RentRevision.Status.APPLIED
&& revision.window().annualRevisionDate().equals(proposed.window().annualRevisionDate()))) {
throw new RentRevisionConflict("Une révision IRL est déjà appliquée pour cette date anniversaire.");
}
RentRevision applied = proposed.apply(application);
RentSchedule.RevisionAmounts amounts = new RentSchedule.RevisionAmounts(
proposed.amounts().previousRent(), proposed.amounts().previousCharges(),
proposed.amounts().chosenRent(), proposed.amounts().appliedCharges());
var boundary = nextFinancialBoundary(lease, application.effectiveOn());
List<RentDue> changed = boundary == null
? RentSchedule.applyRevision(existing, application.effectiveOn(), amounts)
: RentSchedule.applyRevision(existing, application.effectiveOn(), amounts, boundary);
List<RentDue> savedDues = rentDues.saveAll(changed);
return new ApplyResult(revisions.save(applied), savedDues);
}
private void assertCurrentAmounts(LandlordId landlordId, Lease lease, RentRevision proposed, LocalDate sentOn) {
RentTerms current = rentTermsOn(landlordId, lease, sentOn);
if (current.monthlyRent().compareTo(proposed.amounts().previousRent()) != 0
|| current.monthlyCharges().compareTo(proposed.amounts().previousCharges()) != 0) {
throw new RentRevisionConflict(
"Le montant courant a changé depuis la proposition ; régénérez la révision IRL.");
}
}
private static LocalDate effectiveDate(List<RentDue> dues, LocalDate sentOn, int dueDay) {
YearMonth sentMonth = YearMonth.from(sentOn);
YearMonth effectiveMonth = sentMonth;
while (isPaid(dues, effectiveMonth)) {
effectiveMonth = effectiveMonth.plusMonths(1);
}
return effectiveMonth.equals(sentMonth) ? sentOn : effectiveMonth.atDay(dueDay);
}
private static boolean isPaid(List<RentDue> dues, YearMonth month) {
return dues.stream().filter(due -> due.month().equals(month))
.anyMatch(due -> due.settlement() == RentDueSettlement.PAID);
}
private static void rejectFinancialChangeBeforeEffect(Lease lease, LocalDate requestedOn,
LocalDate effectiveOn) {
boolean interveningChange = lease.amendments().stream().filter(RentRevisionService::changesAmounts)
.map(amendment -> amendment.effectiveDate()).anyMatch(date -> date.isAfter(requestedOn)
&& !date.isAfter(effectiveOn));
if (interveningChange) {
throw new RentRevisionConflict(
"Un avenant financier intervient avant la date d'effet ; régénérez la révision IRL.");
}
}
private static RentSchedule.FinancialBoundary nextFinancialBoundary(Lease lease, LocalDate effectiveOn) {
return lease.amendments().stream().filter(amendment -> amendment.effectiveDate().isAfter(effectiveOn))
.filter(RentRevisionService::changesAmounts)
.min(Comparator.comparing(amendment -> amendment.effectiveDate()))
.map(amendment -> new RentSchedule.FinancialBoundary(amendment.effectiveDate(),
amendment.afterVersion().terms().monthlyRent(),
amendment.afterVersion().terms().monthlyCharges())).orElse(null);
}
private static boolean changesAmounts(fr.nessar.lmnporganizer.rental.domain.lease.LeaseAmendment amendment) {
return amendment.beforeVersion().terms().monthlyRent()
.compareTo(amendment.afterVersion().terms().monthlyRent()) != 0
|| amendment.beforeVersion().terms().monthlyCharges()
.compareTo(amendment.afterVersion().terms().monthlyCharges()) != 0;
}
private static boolean sameProposal(RentRevision revision, Preview preview, LocalDate requestedOn,
LocalDate effectiveOn, RentRevision.IndexReference indexReference,
RentRevision.Amounts amounts,
List<RentRevision.LegalOverride> overrides) {
return revision.status() == RentRevision.Status.PROPOSED
&& revision.window().annualRevisionDate().equals(preview.annualRevisionDate())
&& revision.request().requestedOn().equals(requestedOn)
&& revision.request().effectiveOn().equals(effectiveOn)
&& sameIndexReference(revision.indexReference(), indexReference)
&& sameAmounts(revision.amounts(), amounts)
&& revision.warningOverrides().stream().map(override -> override.code() + "|" + override.ruleVersion())
.toList().equals(overrides.stream()
.map(override -> override.code() + "|" + override.ruleVersion()).toList());
}
private static boolean sameAmounts(RentRevision.Amounts first, RentRevision.Amounts second) {
return equalAmount(first.previousRent(), second.previousRent())
&& equalAmount(first.rentCeiling(), second.rentCeiling())
&& equalAmount(first.chosenRent(), second.chosenRent())
&& equalAmount(first.previousCharges(), second.previousCharges())
&& equalAmount(first.chargeCeiling(), second.chargeCeiling())
&& equalAmount(first.chosenCharges(), second.chosenCharges());
}
private static boolean sameIndexReference(RentRevision.IndexReference first,
RentRevision.IndexReference second) {
return first.seriesId().equals(second.seriesId())
&& first.seriesOverridden() == second.seriesOverridden()
&& first.seriesOverrideConfirmed() == second.seriesOverrideConfirmed()
&& first.source() == second.source()
&& first.manualIndicesConfirmed() == second.manualIndicesConfirmed()
&& first.referenceQuarter() == second.referenceQuarter()
&& first.previousYear() == second.previousYear()
&& first.newYear() == second.newYear()
&& equalAmount(first.previousIndex(), second.previousIndex())
&& equalAmount(first.newIndex(), second.newIndex());
}
private static boolean equalAmount(BigDecimal first, BigDecimal second) {
return first == null ? second == null : second != null && first.compareTo(second) == 0;
}
private IrlIndexProvider.Indexes indexValues(PreviewRequest request, String series, int quarter,
int previousYear, int newYear) {
boolean hasManualValue = request.previousIndex() != null || request.newIndex() != null;
try {
IrlIndexProvider.Indexes official = indices.indexes(series,
new IrlIndexProvider.Period(previousYear, quarter),
new IrlIndexProvider.Period(newYear, quarter));
if (hasManualValue) {
throw new RentRuleViolation("La saisie manuelle est réservée à l'indisponibilité des indices officiels.");
}
return official;
} catch (IrlIndexUnavailable unavailable) {
if (!hasManualValue) {
throw unavailable;
}
if (request.previousIndex() == null || request.newIndex() == null || !request.manualIndicesConfirmed()) {
throw new RentRuleViolation("La saisie manuelle des indices doit être complète et confirmée.");
}
return new IrlIndexProvider.Indexes(request.previousIndex(), request.newIndex(),
IrlIndexProvider.Source.MANUAL);
}
}
private static String series(PreviewRequest request, String automaticSeries) {
if (request.seriesIdOverride() == null || request.seriesIdOverride().isBlank()) {
return automaticSeries;
}
String override = request.seriesIdOverride().trim();
if (!RentRevisionPolicy.isSupportedSeries(override)) {
throw new RentRuleViolation("La série IRL sélectionnée n'est pas prise en charge.");
}
if (!request.seriesOverrideConfirmed()) {
throw new RentRuleViolation("Le remplacement de la série IRL doit être confirmé.");
}
return override;
}
private static List<Warning> warnings(Lease lease, LocalDate annualDate) {
String energyRating = lease.contractVersionOn(annualDate).apartment().legalProfile().energyRating();
if (!"F".equals(energyRating) && !"G".equals(energyRating)) {
return List.of();
}
LeaseFinalizationWarning warning = LeaseFinalizationWarning.IRL_REVISION_WITH_F_OR_G_DPE;
return List.of(new Warning(warning.name(), warning.ruleVersion()));
}
private static List<RentRevision.LegalOverride> overrides(List<Warning> warnings, Set<String> confirmedCodes,
String authorId, Instant confirmedAt) {
Set<String> confirmed = confirmedCodes == null ? Set.of() : Set.copyOf(confirmedCodes);
Set<String> expected = warnings.stream().map(Warning::code).collect(java.util.stream.Collectors.toSet());
if (!expected.equals(confirmed)) {
throw new RentRuleViolation("Toutes les alertes juridiques et uniquement celles affichées doivent être confirmées.");
}
return warnings.stream().map(warning -> new RentRevision.LegalOverride(warning.code(),
warning.ruleVersion(), authorId, confirmedAt)).toList();
}
private void requireProposalDependencies() {
if (revisions == null || rentDues == null || renderer == null || storage == null || transactions == null
|| identifiers == null) {
throw new IllegalStateException("Rent revision proposal dependencies are not configured.");
}
}
private void discard(String storageKey, RuntimeException failure) {
try {
storage.discard(storageKey);
} catch (RuntimeException cleanupFailure) {
failure.addSuppressed(cleanupFailure);
}
}
private static String sha256(byte[] content) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(content));
} catch (NoSuchAlgorithmException impossible) {
throw new IllegalStateException("SHA-256 is not available.", impossible);
}
}
private byte[] pdf(PdfUpload upload) {
byte[] content = upload.content();
if (content == null || content.length == 0) {
throw new RentRuleViolation("Le justificatif PDF est vide.");
}
if (content.length > maximumPdfSize) {
throw new AttachmentTooLarge(maximumPdfSize);
}
if (!"application/pdf".equalsIgnoreCase(upload.contentType()) || content.length < 5
|| !Arrays.equals(Arrays.copyOf(content, 5), "%PDF-".getBytes(StandardCharsets.US_ASCII))) {
throw new RentRuleViolation("Le justificatif doit être un PDF lisible.");
}
return content;
}
private static String safeFileName(String fileName) {
if (fileName == null || fileName.isBlank()) {
return "justificatif.pdf";
}
String trimmed = fileName.trim();
int separator = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'));
String candidate = trimmed.substring(separator + 1);
StringBuilder safe = new StringBuilder(candidate.length());
for (int index = 0; index < candidate.length(); index++) {
char character = candidate.charAt(index);
safe.append(Character.isISOControl(character) ? '_' : character);
}
String name = safe.toString().trim();
if (name.isEmpty()) {
return "justificatif.pdf";
}
if (!name.toLowerCase(Locale.ROOT).endsWith(".pdf")) {
name += ".pdf";
}
return name.length() <= 255 ? name : name.substring(0, 251) + ".pdf";
}
private static String required(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " is required.");
}
return value.trim();
}
public record PreviewRequest(ZoneId timeZone, String seriesIdOverride, boolean seriesOverrideConfirmed,
BigDecimal previousIndex, BigDecimal newIndex, boolean manualIndicesConfirmed) {
public static PreviewRequest automatic(ZoneId timeZone) {
return new PreviewRequest(timeZone, null, false, null, null, false);
}
}
public record Preview(LeaseId leaseId, LocalDate annualRevisionDate, LocalDate requestDeadline,
String seriesId, boolean seriesOverridden, String indexSource, int referenceQuarter,
int previousIndexYear, int newIndexYear, BigDecimal previousIndex, BigDecimal newIndex,
BigDecimal previousRent, BigDecimal rentCeiling, BigDecimal previousCharges,
BigDecimal chargeCeiling, List<Warning> warnings) {
public Preview {
warnings = List.copyOf(warnings);
}
}
public record Warning(String code, String ruleVersion) {
}
public record ProposalRequest(PreviewRequest preview, LocalDate requestedOn, BigDecimal chosenRent,
BigDecimal chosenCharges, Set<String> confirmedWarningCodes) {
public ProposalRequest {
Objects.requireNonNull(preview, "Preview request is required.");
confirmedWarningCodes = confirmedWarningCodes == null ? Set.of() : Set.copyOf(confirmedWarningCodes);
}
}
public record ApplyRequest(ZoneId timeZone, LocalDate sentOn, RentRevision.DeliveryMethod deliveryMethod,
boolean deliveryConfirmed, PdfUpload proof) {
public ApplyRequest {
Objects.requireNonNull(timeZone, "Time zone is required.");
Objects.requireNonNull(deliveryMethod, "Delivery method is required.");
}
}
public record PdfUpload(String fileName, String contentType, byte[] content) {
public PdfUpload {
content = content == null ? null : content.clone();
}
@Override
public byte[] content() {
return content == null ? null : content.clone();
}
}
public record ApplyResult(RentRevision revision, List<RentDue> rentDues) {
public ApplyResult {
rentDues = List.copyOf(rentDues);
}
}
public record Letter(String fileName, byte[] content) {
public Letter {
content = content.clone();
}
@Override
public byte[] content() {
return content.clone();
}
}
public record ProofDownload(String fileName, byte[] content) {
public ProofDownload {
content = content.clone();
}
@Override
public byte[] content() {
return content.clone();
}
}
}
@@ -0,0 +1,9 @@
package fr.nessar.lmnporganizer.rental.application.shared;
public final class IrlIndexUnavailable extends RuntimeException {
public static final String CODE = "IRL_INDEX_UNAVAILABLE";
public IrlIndexUnavailable() {
super("Les indices IRL officiels sont indisponibles ; saisissez-les manuellement après confirmation.");
}
}
@@ -4,8 +4,12 @@ import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
public final class Apartment {
@@ -17,14 +21,22 @@ public final class Apartment {
private String address;
private BigDecimal purchasePrice;
private String description;
private ApartmentLegalProfile legalProfile;
private List<ApartmentDocument> documents;
public Apartment(ApartmentId id, LandlordId landlordId, String name, String address, BigDecimal purchasePrice,
String description) {
this(id, landlordId, name, address, purchasePrice, description, Set.of(), -1);
this(id, landlordId, name, address, purchasePrice, description, ApartmentLegalProfile.empty());
}
public Apartment(ApartmentId id, LandlordId landlordId, String name, String address, BigDecimal purchasePrice,
String description, ApartmentLegalProfile legalProfile) {
this(id, landlordId, name, address, purchasePrice, description, legalProfile, Set.of(), List.of(), -1);
}
private Apartment(ApartmentId id, LandlordId landlordId, String name, String address, BigDecimal purchasePrice,
String description, Set<LeaseId> leaseReferences, long version) {
String description, ApartmentLegalProfile legalProfile, Set<LeaseId> leaseReferences,
List<ApartmentDocument> documents, long version) {
this.id = Objects.requireNonNull(id, "Apartment ID is required.");
this.landlordId = Objects.requireNonNull(landlordId, "Landlord ID is required.");
if (version < -1) {
@@ -32,18 +44,38 @@ public final class Apartment {
}
this.version = version;
this.leaseReferences = leaseReferences == null ? Set.of() : Set.copyOf(leaseReferences);
this.legalProfile = Objects.requireNonNullElseGet(legalProfile, ApartmentLegalProfile::empty);
this.documents = currentDocuments(documents);
reviseDetails(name, address, purchasePrice, description);
}
public static Apartment reconstitute(ApartmentId id, LandlordId landlordId, String name, String address,
BigDecimal purchasePrice, String description, long version) {
return reconstitute(id, landlordId, name, address, purchasePrice, description, Set.of(), version);
return reconstitute(id, landlordId, name, address, purchasePrice, description,
ApartmentLegalProfile.empty(), Set.of(), version);
}
public static Apartment reconstitute(ApartmentId id, LandlordId landlordId, String name, String address,
BigDecimal purchasePrice, String description, Set<LeaseId> leaseReferences,
long version) {
return new Apartment(id, landlordId, name, address, purchasePrice, description, leaseReferences, version);
return reconstitute(id, landlordId, name, address, purchasePrice, description,
ApartmentLegalProfile.empty(), leaseReferences, version);
}
public static Apartment reconstitute(ApartmentId id, LandlordId landlordId, String name, String address,
BigDecimal purchasePrice, String description,
ApartmentLegalProfile legalProfile, Set<LeaseId> leaseReferences,
long version) {
return new Apartment(id, landlordId, name, address, purchasePrice, description, legalProfile,
leaseReferences, List.of(), version);
}
public static Apartment reconstitute(ApartmentId id, LandlordId landlordId, String name, String address,
BigDecimal purchasePrice, String description,
ApartmentLegalProfile legalProfile, Set<LeaseId> leaseReferences,
List<ApartmentDocument> documents, long version) {
return new Apartment(id, landlordId, name, address, purchasePrice, description, legalProfile,
leaseReferences, documents, version);
}
public void reviseDetails(String name, String address, BigDecimal purchasePrice, String description) {
@@ -58,6 +90,51 @@ public final class Apartment {
this.description = description == null ? "" : description.trim();
}
public void reviseLegalProfile(ApartmentLegalProfile legalProfile) {
this.legalProfile = Objects.requireNonNullElseGet(legalProfile, ApartmentLegalProfile::empty);
}
public ApartmentDocument replaceDocument(ApartmentDocument document) {
Objects.requireNonNull(document, "Apartment document is required.");
ApartmentDocument replaced = documents.stream().filter(existing -> existing.type() == document.type())
.findFirst().orElse(null);
List<ApartmentDocument> updated = new ArrayList<>(documents);
updated.removeIf(existing -> existing.type() == document.type());
updated.add(document);
documents = currentDocuments(updated);
return replaced;
}
public Optional<ApartmentDocument> findDocument(String documentId) {
return documents.stream().filter(document -> document.id().equals(documentId)).findFirst();
}
public ApartmentDocument removeDocument(String documentId) {
ApartmentDocument removed = findDocument(documentId).orElse(null);
if (removed != null) {
List<ApartmentDocument> updated = new ArrayList<>(documents);
updated.remove(removed);
documents = List.copyOf(updated);
}
return removed;
}
private static List<ApartmentDocument> currentDocuments(List<ApartmentDocument> documents) {
if (documents == null || documents.isEmpty()) {
return List.of();
}
List<ApartmentDocument> current = new ArrayList<>();
for (ApartmentDocument document : documents) {
Objects.requireNonNull(document, "Apartment document is required.");
if (current.stream().anyMatch(existing -> existing.type() == document.type())) {
throw new IllegalArgumentException("Only one current apartment document is allowed per type.");
}
current.add(document);
}
current.sort(Comparator.comparing(ApartmentDocument::type));
return List.copyOf(current);
}
private static String required(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " is required.");
@@ -111,6 +188,14 @@ public final class Apartment {
return description;
}
public ApartmentLegalProfile legalProfile() {
return legalProfile;
}
public List<ApartmentDocument> documents() {
return documents;
}
public long version() {
return version;
}
@@ -0,0 +1,58 @@
package fr.nessar.lmnporganizer.rental.domain.apartment;
import java.time.Instant;
import java.time.LocalDate;
import java.util.Objects;
public record ApartmentDocument(String id, ApartmentDocumentType type, LocalDate establishedOn,
LocalDate expiresOn, String fileName, String storageKey, String sha256,
String authorId, Instant uploadedAt) {
public ApartmentDocument {
id = required(id, "Apartment document ID");
type = Objects.requireNonNull(type, "Apartment document type is required.");
establishedOn = Objects.requireNonNull(establishedOn, "Establishment date is required.");
if (expiresOn != null && expiresOn.isBefore(establishedOn)) {
throw new IllegalArgumentException("Expiry date cannot be before establishment date.");
}
fileName = safeFileName(fileName);
storageKey = required(storageKey, "Apartment document storage key");
sha256 = sha256(sha256);
authorId = required(authorId, "Apartment document author ID");
uploadedAt = Objects.requireNonNull(uploadedAt, "Upload instant is required.");
}
private static String safeFileName(String value) {
String name = required(value, "Apartment document file name");
if (name.length() > 255 || name.indexOf('/') >= 0 || name.indexOf('\\') >= 0
|| !name.toLowerCase(java.util.Locale.ROOT).endsWith(".pdf")) {
throw new IllegalArgumentException("Apartment document file name is unsafe.");
}
for (int index = 0; index < name.length(); index++) {
if (Character.isISOControl(name.charAt(index))) {
throw new IllegalArgumentException("Apartment document file name is unsafe.");
}
}
return name;
}
private static String sha256(String value) {
String fingerprint = required(value, "Apartment document SHA-256").toLowerCase(java.util.Locale.ROOT);
if (fingerprint.length() != 64) {
throw new IllegalArgumentException("Apartment document SHA-256 is invalid.");
}
for (int index = 0; index < fingerprint.length(); index++) {
char character = fingerprint.charAt(index);
if (!Character.isDigit(character) && (character < 'a' || character > 'f')) {
throw new IllegalArgumentException("Apartment document SHA-256 is invalid.");
}
}
return fingerprint;
}
private static String required(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " is required.");
}
return value.trim();
}
}
@@ -0,0 +1,35 @@
package fr.nessar.lmnporganizer.rental.domain.apartment;
public enum ApartmentDocumentType {
ENERGY_PERFORMANCE_DIAGNOSIS("DPE"),
LEAD_EXPOSURE_REPORT("LEAD"),
ASBESTOS_REPORT("ASBESTOS"),
ELECTRICAL_SAFETY_DIAGNOSIS("ELECTRICITY"),
GAS_SAFETY_DIAGNOSIS("GAS"),
RISKS_AND_POLLUTION_REPORT("RISKS_AND_POLLUTION"),
NOISE_EXPOSURE_PLAN("NOISE"),
CO_OWNERSHIP_RULES_EXTRACT("CO_OWNERSHIP_EXTRACT"),
PRIOR_RENTAL_AUTHORIZATION("PRIOR_RENTAL_AUTHORIZATION");
private final String code;
ApartmentDocumentType(String code) {
this.code = code;
}
public String code() {
return code;
}
public static ApartmentDocumentType fromCode(String code) {
if (code != null) {
String candidate = code.trim();
for (ApartmentDocumentType type : values()) {
if (type.code.equals(candidate)) {
return type;
}
}
}
throw new IllegalArgumentException("Apartment document type is invalid.");
}
}
@@ -0,0 +1,158 @@
package fr.nessar.lmnporganizer.rental.domain.apartment;
import java.math.BigDecimal;
import java.util.Locale;
public record ApartmentLegalProfile(
String taxIdentifier,
BuildingType buildingType,
String ownershipRegime,
ConstructionPeriod constructionPeriod,
BigDecimal habitableSurface,
int roomCount,
String otherPrivateAreas,
String equipment,
String heating,
String hotWater,
String energyRating,
BigDecimal estimatedAnnualEnergyCost,
Integer estimatedAnnualEnergyCostReferenceYear,
String destination,
String privateAccessories,
String commonAreas,
String internetAccess,
String departmentCode) {
public ApartmentLegalProfile {
taxIdentifier = text(taxIdentifier);
ownershipRegime = text(ownershipRegime);
otherPrivateAreas = text(otherPrivateAreas);
equipment = text(equipment);
heating = text(heating);
hotWater = text(hotWater);
energyRating = text(energyRating).toUpperCase(Locale.ROOT);
destination = text(destination);
privateAccessories = text(privateAccessories);
commonAreas = text(commonAreas);
internetAccess = text(internetAccess);
departmentCode = text(departmentCode).toUpperCase(Locale.ROOT);
nonNegative(habitableSurface, "Habitable surface");
nonNegative(estimatedAnnualEnergyCost, "Estimated annual energy cost");
if (estimatedAnnualEnergyCostReferenceYear != null
&& (estimatedAnnualEnergyCostReferenceYear < 1900
|| estimatedAnnualEnergyCostReferenceYear > 2100)) {
throw new IllegalArgumentException("Estimated annual energy cost reference year is invalid.");
}
if (estimatedAnnualEnergyCost == null) {
estimatedAnnualEnergyCostReferenceYear = null;
}
if (roomCount < 0) {
throw new IllegalArgumentException("Room count cannot be negative.");
}
if (!energyRating.isEmpty()
&& (energyRating.length() != 1 || energyRating.charAt(0) < 'A' || energyRating.charAt(0) > 'G')) {
throw new IllegalArgumentException("Energy rating must be between A and G.");
}
if (!departmentCode.isEmpty() && !isDepartmentCode(departmentCode)) {
throw new IllegalArgumentException("Department code is invalid.");
}
}
public ApartmentLegalProfile(String taxIdentifier, BuildingType buildingType, String ownershipRegime,
ConstructionPeriod constructionPeriod, BigDecimal habitableSurface, int roomCount,
String otherPrivateAreas, String equipment, String heating, String hotWater,
String energyRating, BigDecimal estimatedAnnualEnergyCost, String destination,
String privateAccessories, String commonAreas, String internetAccess,
String departmentCode) {
this(taxIdentifier, buildingType, ownershipRegime, constructionPeriod, habitableSurface, roomCount,
otherPrivateAreas, equipment, heating, hotWater, energyRating, estimatedAnnualEnergyCost, null,
destination, privateAccessories, commonAreas, internetAccess, departmentCode);
}
public static ApartmentLegalProfile empty() {
return new ApartmentLegalProfile("", null, "", null, null, 0, "", "", "", "", "", null, null,
"", "", "", "", "");
}
public void requireLeaseReady() {
if (taxIdentifier.isEmpty() || buildingType == null || ownershipRegime.isEmpty()
|| constructionPeriod == null || habitableSurface == null || habitableSurface.signum() <= 0
|| roomCount <= 0 || heating.isEmpty() || hotWater.isEmpty() || energyRating.isEmpty()
|| estimatedAnnualEnergyCost == null || estimatedAnnualEnergyCostReferenceYear == null
|| destination.isEmpty()) {
throw new IllegalArgumentException(
"Le profil juridique de l'appartement doit être complet avant de générer ou finaliser le bail.");
}
}
private static String text(String value) {
return value == null ? "" : value.trim();
}
private static void nonNegative(BigDecimal value, String field) {
if (value != null && value.signum() < 0) {
throw new IllegalArgumentException(field + " cannot be negative.");
}
}
private static boolean isDepartmentCode(String value) {
if (value.equals("2A") || value.equals("2B")) {
return true;
}
if (value.length() == 2 && digits(value)) {
int number = Integer.parseInt(value);
return number >= 1 && number <= 95 && number != 20;
}
if (value.length() == 3 && digits(value)) {
int number = Integer.parseInt(value);
return number >= 971 && number <= 976;
}
return false;
}
private static boolean digits(String value) {
for (int index = 0; index < value.length(); index++) {
if (!Character.isDigit(value.charAt(index))) {
return false;
}
}
return true;
}
public enum BuildingType {
APARTMENT_BUILDING,
INDIVIDUAL_HOUSE
}
public enum ConstructionPeriod {
BEFORE_1949("BEFORE_1949"),
PERIOD_1949_1974("1949_1974"),
PERIOD_1975_1989("1975_1989"),
PERIOD_1990_2005("1990_2005"),
PERIOD_2006_2012("2006_2012"),
AFTER_2012("AFTER_2012");
private final String code;
ConstructionPeriod(String code) {
this.code = code;
}
public String code() {
return code;
}
public static ConstructionPeriod fromCode(String code) {
if (code == null || code.isBlank()) {
return null;
}
String value = code.trim();
for (ConstructionPeriod period : values()) {
if (period.code.equals(value)) {
return period;
}
}
throw new IllegalArgumentException("Construction period is invalid.");
}
}
}
@@ -0,0 +1,6 @@
package fr.nessar.lmnporganizer.rental.domain.lease;
public enum ChargeMethod {
FLAT_RATE,
PROVISION
}
@@ -0,0 +1,35 @@
package fr.nessar.lmnporganizer.rental.domain.lease;
import java.time.LocalDate;
import java.util.Arrays;
public enum FurnishedLeaseTemplate {
CURRENT("FR-MEUBLE-2025-01", LocalDate.MIN),
OCTOBER_2026("FR-MEUBLE-2026-10", LocalDate.of(2026, 10, 1));
private final String visibleId;
private final LocalDate effectiveFrom;
FurnishedLeaseTemplate(String visibleId, LocalDate effectiveFrom) {
this.visibleId = visibleId;
this.effectiveFrom = effectiveFrom;
}
public static FurnishedLeaseTemplate effectiveOn(LocalDate date) {
return Arrays.stream(values()).filter(template -> !template.effectiveFrom.isAfter(date))
.reduce((first, second) -> second).orElseThrow();
}
public static FurnishedLeaseTemplate fromVisibleId(String visibleId) {
return Arrays.stream(values()).filter(template -> template.visibleId.equals(visibleId)).findFirst()
.orElseThrow(() -> new IllegalArgumentException("Unknown furnished lease template version."));
}
public String visibleId() {
return visibleId;
}
public LocalDate effectiveFrom() {
return effectiveFrom;
}
}
@@ -19,11 +19,15 @@ public final class Lease {
private List<Guarantor> guarantors;
private RentalPeriod period;
private RentTerms terms;
private LeaseType leaseType;
private List<LeaseSpecialClause> specialClauses;
private LeaseLegalTerms legalTerms;
private LeaseStatus status;
private LeaseSnapshot snapshot;
private Lease(LeaseId id, LandlordId landlordId, ApartmentId apartmentId, List<TenantId> tenantIds,
List<Guarantor> guarantors, RentalPeriod period, RentTerms terms, LeaseStatus status,
List<Guarantor> guarantors, RentalPeriod period, RentTerms terms, LeaseType leaseType,
List<LeaseSpecialClause> specialClauses, LeaseLegalTerms legalTerms, LeaseStatus status,
LeaseSnapshot snapshot, List<LeaseAmendment> amendments, long version) {
this.id = Objects.requireNonNull(id);
this.landlordId = Objects.requireNonNull(landlordId);
@@ -36,6 +40,10 @@ public final class Lease {
this.guarantors = guarantors == null ? List.of() : List.copyOf(guarantors);
this.period = Objects.requireNonNull(period);
this.terms = Objects.requireNonNull(terms);
this.leaseType = LeaseType.orStandard(leaseType);
this.specialClauses = specialClauses == null ? List.of() : List.copyOf(specialClauses);
this.legalTerms = LeaseLegalTerms.orEmpty(legalTerms);
this.legalTerms.validateForTenantCount(this.tenantIds.size());
this.status = Objects.requireNonNull(status);
this.snapshot = snapshot;
this.amendments = new LeaseAmendmentHistory(amendments);
@@ -44,8 +52,31 @@ public final class Lease {
public static Lease draft(LeaseId id, LandlordId landlordId, ApartmentId apartmentId,
List<TenantId> tenantIds, List<Guarantor> guarantors, RentalPeriod period,
RentTerms terms) {
return new Lease(id, landlordId, apartmentId, tenantIds, guarantors, period, terms, LeaseStatus.DRAFT, null,
List.of(), -1);
return new Lease(id, landlordId, apartmentId, tenantIds, guarantors, period, terms, LeaseType.STANDARD,
List.of(), LeaseLegalTerms.empty(), LeaseStatus.DRAFT, null, List.of(), -1);
}
public static Lease draft(LeaseId id, LandlordId landlordId, ApartmentId apartmentId,
List<TenantId> tenantIds, List<Guarantor> guarantors, RentalPeriod period,
RentTerms terms, LeaseType leaseType) {
return draft(id, landlordId, apartmentId, tenantIds, guarantors, period, terms, leaseType, List.of());
}
public static Lease draft(LeaseId id, LandlordId landlordId, ApartmentId apartmentId,
List<TenantId> tenantIds, List<Guarantor> guarantors, RentalPeriod period,
RentTerms terms, LeaseType leaseType, List<LeaseSpecialClause> specialClauses) {
return draft(id, landlordId, apartmentId, tenantIds, guarantors, period, terms, leaseType, specialClauses,
LeaseLegalTerms.empty());
}
public static Lease draft(LeaseId id, LandlordId landlordId, ApartmentId apartmentId,
List<TenantId> tenantIds, List<Guarantor> guarantors, RentalPeriod period,
RentTerms terms, LeaseType leaseType, List<LeaseSpecialClause> specialClauses,
LeaseLegalTerms legalTerms) {
LeaseType type = LeaseType.orStandard(leaseType);
return new Lease(id, landlordId, apartmentId, tenantIds, guarantors,
type.initialPeriod(period.startDate()), terms, type, specialClauses, legalTerms, LeaseStatus.DRAFT,
null, List.of(), -1);
}
public static Lease reconstitute(LeaseId id, LandlordId landlordId, ApartmentId apartmentId,
@@ -74,6 +105,34 @@ public final class Lease {
List<TenantId> tenantIds, List<Guarantor> guarantors, RentalPeriod period,
RentTerms terms, LeaseStatus status, LeaseSnapshot snapshot,
List<LeaseAmendment> amendments, long version) {
return reconstitute(id, landlordId, apartmentId, tenantIds, guarantors, period, terms, LeaseType.STANDARD,
status, snapshot, amendments, version);
}
public static Lease reconstitute(LeaseId id, LandlordId landlordId, ApartmentId apartmentId,
List<TenantId> tenantIds, List<Guarantor> guarantors, RentalPeriod period,
RentTerms terms, LeaseType leaseType, LeaseStatus status, LeaseSnapshot snapshot,
List<LeaseAmendment> amendments, long version) {
return reconstitute(id, landlordId, apartmentId, tenantIds, guarantors, period, terms, leaseType, List.of(),
snapshot == null ? LeaseLegalTerms.empty() : snapshot.legalTerms(), status, snapshot, amendments,
version);
}
public static Lease reconstitute(LeaseId id, LandlordId landlordId, ApartmentId apartmentId,
List<TenantId> tenantIds, List<Guarantor> guarantors, RentalPeriod period,
RentTerms terms, LeaseType leaseType, List<LeaseSpecialClause> specialClauses,
LeaseStatus status, LeaseSnapshot snapshot, List<LeaseAmendment> amendments,
long version) {
return reconstitute(id, landlordId, apartmentId, tenantIds, guarantors, period, terms, leaseType,
specialClauses, snapshot == null ? LeaseLegalTerms.empty() : snapshot.legalTerms(), status, snapshot,
amendments, version);
}
public static Lease reconstitute(LeaseId id, LandlordId landlordId, ApartmentId apartmentId,
List<TenantId> tenantIds, List<Guarantor> guarantors, RentalPeriod period,
RentTerms terms, LeaseType leaseType, List<LeaseSpecialClause> specialClauses,
LeaseLegalTerms legalTerms, LeaseStatus status, LeaseSnapshot snapshot,
List<LeaseAmendment> amendments, long version) {
if (status == LeaseStatus.DRAFT && snapshot != null) {
throw new LeaseRuleViolation("Un bail en brouillon ne peut pas avoir d'instantané contractuel.");
}
@@ -83,8 +142,8 @@ public final class Lease {
if (status == LeaseStatus.DRAFT && amendments != null && !amendments.isEmpty()) {
throw new LeaseRuleViolation("Un bail en brouillon ne peut pas avoir d'avenant.");
}
Lease lease = new Lease(id, landlordId, apartmentId, tenantIds, guarantors, period, terms, status, snapshot,
amendments, version);
Lease lease = new Lease(id, landlordId, apartmentId, tenantIds, guarantors, period, terms, leaseType,
specialClauses, legalTerms, status, snapshot, amendments, version);
if (snapshot != null) {
lease.validateTenantIds();
lease.requireSnapshotIdentity(snapshot);
@@ -96,6 +155,7 @@ public final class Lease {
public void reviseDraft(ApartmentId apartmentId, List<TenantId> tenantIds, List<Guarantor> guarantors,
RentalPeriod period, RentTerms terms) {
requireDraft("modifié");
legalTerms.validateForTenantCount(tenantIds == null ? 0 : tenantIds.size());
this.apartmentId = Objects.requireNonNull(apartmentId);
this.tenantIds = copy(tenantIds);
this.guarantors = guarantors == null ? List.of() : List.copyOf(guarantors);
@@ -103,6 +163,37 @@ public final class Lease {
this.terms = Objects.requireNonNull(terms);
}
public void reviseDraft(ApartmentId apartmentId, List<TenantId> tenantIds, List<Guarantor> guarantors,
RentalPeriod period, RentTerms terms, LeaseType leaseType) {
LeaseType type = LeaseType.orStandard(leaseType);
reviseDraft(apartmentId, tenantIds, guarantors, type.initialPeriod(period.startDate()), terms);
this.leaseType = type;
}
public void reviseDraft(ApartmentId apartmentId, List<TenantId> tenantIds, List<Guarantor> guarantors,
RentalPeriod period, RentTerms terms, LeaseType leaseType,
List<LeaseSpecialClause> specialClauses) {
reviseDraft(apartmentId, tenantIds, guarantors, period, terms, leaseType);
this.specialClauses = specialClauses == null ? List.of() : List.copyOf(specialClauses);
}
public void reviseDraft(ApartmentId apartmentId, List<TenantId> tenantIds, List<Guarantor> guarantors,
RentalPeriod period, RentTerms terms, LeaseType leaseType,
List<LeaseSpecialClause> specialClauses, LeaseLegalTerms legalTerms) {
requireDraft("modifié");
LeaseType type = LeaseType.orStandard(leaseType);
LeaseLegalTerms updatedLegalTerms = LeaseLegalTerms.orEmpty(legalTerms);
updatedLegalTerms.validateForTenantCount(tenantIds == null ? 0 : tenantIds.size());
this.apartmentId = Objects.requireNonNull(apartmentId);
this.tenantIds = copy(tenantIds);
this.guarantors = guarantors == null ? List.of() : List.copyOf(guarantors);
this.period = type.initialPeriod(period.startDate());
this.terms = Objects.requireNonNull(terms);
this.leaseType = type;
this.specialClauses = specialClauses == null ? List.of() : List.copyOf(specialClauses);
this.legalTerms = updatedLegalTerms;
}
public LeaseActivated activate(LeaseSnapshot frozenContract) {
requireDraft("activé");
validateTenantIds();
@@ -140,7 +231,10 @@ public final class Lease {
|| !new HashSet<>(tenantIds).equals(new HashSet<>(snapshotTenantIds))
|| !guarantors.equals(frozenContract.guarantors())
|| !period.equals(frozenContract.period())
|| !terms.equals(frozenContract.terms())) {
|| !terms.equals(frozenContract.terms())
|| leaseType != frozenContract.leaseType()
|| !specialClauses.equals(frozenContract.specialClauses())
|| !legalTerms.equals(frozenContract.legalTerms())) {
throw new LeaseRuleViolation("L'instantané contractuel ne correspond pas au bail à activer.");
}
}
@@ -157,7 +251,10 @@ public final class Lease {
&& tenantIds.equals(version.tenantIds())
&& guarantors.equals(version.guarantors())
&& period.equals(version.period())
&& terms.equals(version.terms());
&& terms.equals(version.terms())
&& leaseType == version.leaseType()
&& specialClauses.equals(version.specialClauses())
&& legalTerms.equals(version.legalTerms());
}
private void apply(LeaseContractVersion contractVersion) {
@@ -166,6 +263,9 @@ public final class Lease {
guarantors = contractVersion.guarantors();
period = contractVersion.period();
terms = contractVersion.terms();
leaseType = contractVersion.leaseType();
specialClauses = contractVersion.specialClauses();
legalTerms = contractVersion.legalTerms();
}
private void validateTenantIds() {
@@ -227,6 +327,18 @@ public final class Lease {
return terms;
}
public LeaseType leaseType() {
return leaseType;
}
public List<LeaseSpecialClause> specialClauses() {
return specialClauses;
}
public LeaseLegalTerms legalTerms() {
return legalTerms;
}
public LeaseStatus status() {
return status;
}
@@ -80,10 +80,14 @@ final class LeaseAmendmentHistory {
LeaseContractVersion after = amendment.afterVersion();
if (!before.landlord().equals(after.landlord())
|| !before.apartment().equals(after.apartment())
|| before.leaseType() != after.leaseType()
|| !before.specialClauses().equals(after.specialClauses())
|| !before.legalTerms().equals(after.legalTerms())
|| !before.period().startDate().equals(after.period().startDate())
|| before.terms().securityDeposit().compareTo(after.terms().securityDeposit()) != 0) {
throw new LeaseRuleViolation(
"Un avenant ne peut modifier ni le logement, ni la date de début, ni le dépôt de garantie.");
"Un avenant ne peut modifier ni le logement, ni le type ou les clauses du bail, ni la date "
+ "de début, ni le dépôt de garantie.");
}
if (!before.period().contains(amendment.effectiveDate())
|| !after.period().contains(amendment.effectiveDate())) {
@@ -13,24 +13,53 @@ public record LeaseContractVersion(
List<LeaseSnapshot.Tenant> tenants,
List<Guarantor> guarantors,
RentalPeriod period,
RentTerms terms) {
RentTerms terms,
LeaseType leaseType,
List<LeaseSpecialClause> specialClauses,
LeaseLegalTerms legalTerms) {
public LeaseContractVersion(LeaseSnapshot.Landlord landlord, LeaseSnapshot.Apartment apartment,
List<LeaseSnapshot.Tenant> tenants, List<Guarantor> guarantors,
RentalPeriod period, RentTerms terms) {
this(landlord, apartment, tenants, guarantors, period, terms, LeaseType.STANDARD, List.of(),
LeaseLegalTerms.empty());
}
public LeaseContractVersion(LeaseSnapshot.Landlord landlord, LeaseSnapshot.Apartment apartment,
List<LeaseSnapshot.Tenant> tenants, List<Guarantor> guarantors,
RentalPeriod period, RentTerms terms, LeaseType leaseType) {
this(landlord, apartment, tenants, guarantors, period, terms, leaseType, List.of(), LeaseLegalTerms.empty());
}
public LeaseContractVersion(LeaseSnapshot.Landlord landlord, LeaseSnapshot.Apartment apartment,
List<LeaseSnapshot.Tenant> tenants, List<Guarantor> guarantors,
RentalPeriod period, RentTerms terms, LeaseType leaseType,
List<LeaseSpecialClause> specialClauses) {
this(landlord, apartment, tenants, guarantors, period, terms, leaseType, specialClauses,
LeaseLegalTerms.empty());
}
public LeaseContractVersion {
Objects.requireNonNull(landlord, "Landlord contract facts are required.");
Objects.requireNonNull(apartment, "Apartment contract facts are required.");
Objects.requireNonNull(period, "Rental period is required.");
Objects.requireNonNull(terms, "Rent terms are required.");
leaseType = LeaseType.orStandard(leaseType);
tenants = tenants == null ? List.of() : List.copyOf(tenants);
guarantors = guarantors == null ? List.of() : List.copyOf(guarantors);
specialClauses = specialClauses == null ? List.of() : List.copyOf(specialClauses);
legalTerms = LeaseLegalTerms.orEmpty(legalTerms);
if (tenants.isEmpty() || new HashSet<>(tenantIds(tenants)).size() != tenants.size()) {
throw new LeaseRuleViolation("Le bail doit contenir au moins un locataire, sans doublon.");
}
legalTerms.validateForTenantCount(tenants.size());
}
public static LeaseContractVersion from(LeaseSnapshot snapshot) {
Objects.requireNonNull(snapshot);
return new LeaseContractVersion(snapshot.landlord(), snapshot.apartment(), snapshot.tenants(),
snapshot.guarantors(), snapshot.period(), snapshot.terms());
snapshot.guarantors(), snapshot.period(), snapshot.terms(), snapshot.leaseType(),
snapshot.specialClauses(), snapshot.legalTerms());
}
public ApartmentId apartmentId() {
@@ -0,0 +1,75 @@
package fr.nessar.lmnporganizer.rental.domain.lease;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentDocumentType;
import java.time.Instant;
import java.time.LocalDate;
import java.util.Locale;
import java.util.Objects;
public record LeaseDocumentAnnex(Category category, ApartmentDocumentType documentType, String sourceId,
String version, String fileName, String storageKey, String sha256,
LocalDate establishedOn, LocalDate expiresOn, Instant sourceUploadedAt) {
public LeaseDocumentAnnex {
category = Objects.requireNonNull(category, "Lease document annex category is required.");
fileName = safeFileName(fileName);
storageKey = required(storageKey, "Lease document annex storage key");
sha256 = sha256(sha256);
sourceId = optional(sourceId);
version = optional(version);
if (expiresOn != null && establishedOn != null && expiresOn.isBefore(establishedOn)) {
throw new IllegalArgumentException("Lease document annex expiry date is invalid.");
}
if (category == Category.NOTICE) {
if (documentType != null || version.isEmpty()) {
throw new IllegalArgumentException("A notice annex requires a version and no document type.");
}
} else if (documentType == null || sourceId.isEmpty() || establishedOn == null || sourceUploadedAt == null) {
throw new IllegalArgumentException("An apartment document annex requires its source metadata.");
}
}
private static String safeFileName(String value) {
String name = required(value, "Lease document annex file name");
if (name.length() > 255 || name.indexOf('/') >= 0 || name.indexOf('\\') >= 0
|| !name.toLowerCase(Locale.ROOT).endsWith(".pdf")) {
throw new IllegalArgumentException("Lease document annex file name is unsafe.");
}
for (int index = 0; index < name.length(); index++) {
if (Character.isISOControl(name.charAt(index))) {
throw new IllegalArgumentException("Lease document annex file name is unsafe.");
}
}
return name;
}
private static String sha256(String value) {
String fingerprint = required(value, "Lease document annex SHA-256").toLowerCase(Locale.ROOT);
if (fingerprint.length() != 64) {
throw new IllegalArgumentException("Lease document annex SHA-256 is invalid.");
}
for (int index = 0; index < fingerprint.length(); index++) {
char character = fingerprint.charAt(index);
if (!Character.isDigit(character) && (character < 'a' || character > 'f')) {
throw new IllegalArgumentException("Lease document annex SHA-256 is invalid.");
}
}
return fingerprint;
}
private static String optional(String value) {
return value == null ? "" : value.trim();
}
private static String required(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " is required.");
}
return value.trim();
}
public enum Category {
NOTICE,
APARTMENT_DOCUMENT
}
}
@@ -0,0 +1,45 @@
package fr.nessar.lmnporganizer.rental.domain.lease;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import java.time.Instant;
import java.util.List;
public record LeaseDocumentRevision(String id, LandlordId landlordId, LeaseId leaseId, int number,
String templateVersion, String sourceFingerprint, String storageKey,
String sha256, Instant generatedAt, String authorId,
List<LeaseDocumentAnnex> annexes, List<LeaseDocumentWarning> warnings) {
public LeaseDocumentRevision {
id = required(id, "Revision ID");
if (landlordId == null || leaseId == null) {
throw new IllegalArgumentException("Revision owner and lease are required.");
}
if (number < 1) {
throw new IllegalArgumentException("Revision number must be positive.");
}
templateVersion = required(templateVersion, "Template version");
sourceFingerprint = required(sourceFingerprint, "Source fingerprint");
storageKey = required(storageKey, "Storage key");
sha256 = required(sha256, "SHA-256");
if (generatedAt == null) {
throw new IllegalArgumentException("Generation instant is required.");
}
authorId = required(authorId, "Author ID");
annexes = annexes == null ? List.of() : List.copyOf(annexes);
warnings = warnings == null ? List.of() : List.copyOf(warnings);
}
public LeaseDocumentRevision(String id, LandlordId landlordId, LeaseId leaseId, int number,
String templateVersion, String sourceFingerprint, String storageKey,
String sha256, Instant generatedAt, String authorId) {
this(id, landlordId, leaseId, number, templateVersion, sourceFingerprint, storageKey, sha256, generatedAt,
authorId, List.of(), List.of());
}
private static String required(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " is required.");
}
return value.trim();
}
}
@@ -0,0 +1,7 @@
package fr.nessar.lmnporganizer.rental.domain.lease;
public final class LeaseDocumentRevisionConflict extends RuntimeException {
public LeaseDocumentRevisionConflict(Throwable cause) {
super("Lease document revision was created concurrently.", cause);
}
}
@@ -0,0 +1,15 @@
package fr.nessar.lmnporganizer.rental.domain.lease;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import java.util.Optional;
public interface LeaseDocumentRevisionRepository {
Optional<LeaseDocumentRevision> findCurrent(LandlordId landlordId, LeaseId leaseId);
Optional<LeaseDocumentRevision> findByFingerprint(LandlordId landlordId, LeaseId leaseId, String fingerprint);
Optional<LeaseDocumentRevision> find(LandlordId landlordId, LeaseId leaseId, int number);
LeaseDocumentRevision save(LeaseDocumentRevision revision);
}
@@ -0,0 +1,19 @@
package fr.nessar.lmnporganizer.rental.domain.lease;
public record LeaseDocumentWarning(String code, String ruleVersion) {
public LeaseDocumentWarning {
code = required(code, "Lease document warning code");
ruleVersion = required(ruleVersion, "Lease document warning rule version");
}
public static LeaseDocumentWarning from(LeaseFinalizationWarning warning) {
return new LeaseDocumentWarning(warning.name(), warning.ruleVersion());
}
private static String required(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " is required.");
}
return value.trim();
}
}
@@ -0,0 +1,46 @@
package fr.nessar.lmnporganizer.rental.domain.lease;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
public record LeaseFinalization(String id, LandlordId landlordId, LeaseId leaseId, int revisionNumber,
String templateVersion, LocalDate lastSignatureDate, String signedStorageKey,
String signedFileName, String signedSha256, String authorId, Instant finalizedAt,
List<LegalOverride> overrides) {
public LeaseFinalization {
id = required(id, "Finalization ID");
if (landlordId == null || leaseId == null || lastSignatureDate == null || finalizedAt == null) {
throw new IllegalArgumentException("Finalization scope and dates are required.");
}
if (revisionNumber < 1) {
throw new IllegalArgumentException("Revision number must be positive.");
}
templateVersion = required(templateVersion, "Template version");
signedStorageKey = required(signedStorageKey, "Signed storage key");
signedFileName = required(signedFileName, "Signed file name");
signedSha256 = required(signedSha256, "Signed SHA-256");
authorId = required(authorId, "Author ID");
overrides = overrides == null ? List.of() : List.copyOf(overrides);
}
public record LegalOverride(String code, String ruleVersion, String authorId, Instant confirmedAt) {
public LegalOverride {
code = required(code, "Override code");
ruleVersion = required(ruleVersion, "Override rule version");
authorId = required(authorId, "Override author ID");
if (confirmedAt == null) {
throw new IllegalArgumentException("Override confirmation instant is required.");
}
}
}
private static String required(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " is required.");
}
return value.trim();
}
}
@@ -0,0 +1,11 @@
package fr.nessar.lmnporganizer.rental.domain.lease;
public final class LeaseFinalizationConflict extends RuntimeException {
public LeaseFinalizationConflict(String message) {
super(message);
}
public LeaseFinalizationConflict(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,11 @@
package fr.nessar.lmnporganizer.rental.domain.lease;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import java.util.Optional;
public interface LeaseFinalizationRepository {
Optional<LeaseFinalization> find(LandlordId landlordId, LeaseId leaseId);
LeaseFinalization save(LeaseFinalization finalization);
}
@@ -0,0 +1,21 @@
package fr.nessar.lmnporganizer.rental.domain.lease;
public enum LeaseFinalizationWarning {
SECURITY_DEPOSIT_ABOVE_TWO_MONTHS("1"),
IRL_REVISION_WITH_F_OR_G_DPE("2026-07"),
DPE_MISSING("2026-07"),
DPE_EXPIRED("2026-07"),
DIAGNOSTIC_EXPIRED("2026-07"),
CO_OWNERSHIP_RULES_EXTRACT_MISSING("2026-07"),
PRIOR_RENTAL_AUTHORIZATION_MISSING("2026-07");
private final String ruleVersion;
LeaseFinalizationWarning(String ruleVersion) {
this.ruleVersion = ruleVersion;
}
public String ruleVersion() {
return ruleVersion;
}
}
@@ -0,0 +1,32 @@
package fr.nessar.lmnporganizer.rental.domain.lease;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
import java.time.Instant;
public record LeaseGuaranteeDeed(String id, LandlordId landlordId, LeaseId leaseId, String guarantorId,
TenantId tenantId, String storageKey, String fileName, String sha256,
String authorId, Instant uploadedAt) {
public LeaseGuaranteeDeed {
id = required(id, "Guarantee deed ID");
if (landlordId == null || leaseId == null || tenantId == null) {
throw new IllegalArgumentException("Guarantee deed scope is required.");
}
guarantorId = required(guarantorId, "Guarantor ID");
storageKey = required(storageKey, "Storage key");
fileName = required(fileName, "File name");
sha256 = required(sha256, "SHA-256");
authorId = required(authorId, "Author ID");
if (uploadedAt == null) {
throw new IllegalArgumentException("Upload instant is required.");
}
}
private static String required(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " is required.");
}
return value.trim();
}
}
@@ -0,0 +1,15 @@
package fr.nessar.lmnporganizer.rental.domain.lease;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
import java.util.List;
import java.util.Optional;
public interface LeaseGuaranteeDeedRepository {
List<LeaseGuaranteeDeed> findAll(LandlordId landlordId, LeaseId leaseId);
Optional<LeaseGuaranteeDeed> find(LandlordId landlordId, LeaseId leaseId, String guarantorId, TenantId tenantId);
LeaseGuaranteeDeed save(LeaseGuaranteeDeed deed);
}
@@ -0,0 +1,157 @@
package fr.nessar.lmnporganizer.rental.domain.lease;
import java.math.BigDecimal;
import java.time.LocalDate;
public record LeaseLegalTerms(
RentControlTerms rentControl,
PreviousRentTerms previousRent,
RentRevisionTerms rentRevision,
RecentWork recentWork,
boolean coOwnershipRulesApplicable,
boolean priorRentalAuthorizationApplicable,
boolean primaryResidenceEasementApplicable,
boolean jointAndSeveralLiability,
String otherTerms) {
public LeaseLegalTerms {
rentControl = rentControl == null ? RentControlTerms.empty() : rentControl;
previousRent = previousRent == null ? PreviousRentTerms.empty() : previousRent;
rentRevision = rentRevision == null ? RentRevisionTerms.empty() : rentRevision;
recentWork = recentWork == null ? RecentWork.empty() : recentWork;
otherTerms = text(otherTerms);
}
public static LeaseLegalTerms empty() {
return new LeaseLegalTerms(null, null, null, null, false, false, false, false, "");
}
public static LeaseLegalTerms orEmpty(LeaseLegalTerms legalTerms) {
return legalTerms == null ? empty() : legalTerms;
}
public void validateForTenantCount(int tenantCount) {
if (jointAndSeveralLiability && tenantCount < 2) {
throw new LeaseRuleViolation("La clause de solidarité nécessite au moins deux locataires.");
}
}
public record RentControlTerms(boolean evolutionCapApplicable, boolean referenceRentApplicable,
BigDecimal baseReferenceRent, BigDecimal increasedReferenceRent,
BigDecimal supplement, String supplementJustification) {
public RentControlTerms {
supplementJustification = text(supplementJustification);
nonNegative(baseReferenceRent, "Le loyer de référence");
nonNegative(increasedReferenceRent, "Le loyer de référence majoré");
nonNegative(supplement, "Le complément de loyer");
if (positive(supplement) && !referenceRentApplicable) {
throw new LeaseRuleViolation(
"Un complément de loyer nécessite l'application du loyer de référence.");
}
if (!referenceRentApplicable) {
baseReferenceRent = null;
increasedReferenceRent = null;
supplement = null;
supplementJustification = "";
} else {
if (baseReferenceRent == null || increasedReferenceRent == null) {
throw new LeaseRuleViolation(
"Les loyers de référence de base et majoré sont requis quand l'encadrement s'applique.");
}
if (increasedReferenceRent.compareTo(baseReferenceRent) < 0) {
throw new LeaseRuleViolation(
"Le loyer de référence majoré ne peut pas être inférieur au loyer de référence.");
}
if (positive(supplement) && supplementJustification.isEmpty()) {
throw new LeaseRuleViolation("Le complément de loyer doit être justifié.");
}
if (!positive(supplement)) {
supplementJustification = "";
}
}
}
public static RentControlTerms empty() {
return new RentControlTerms(false, false, null, null, null, "");
}
}
public record PreviousRentTerms(boolean applicable, BigDecimal amount, LocalDate lastRevisionDate,
LocalDate lastPaymentDate, boolean tenantConfirmation) {
public PreviousRentTerms {
nonNegative(amount, "Le précédent loyer");
if (applicable && (amount == null || lastRevisionDate == null || lastPaymentDate == null
|| !tenantConfirmation)) {
throw new LeaseRuleViolation(
"Le montant, les dates de révision et de versement et la confirmation du précédent loyer "
+ "sont requis.");
}
if (!applicable) {
amount = null;
lastRevisionDate = null;
lastPaymentDate = null;
tenantConfirmation = false;
}
}
public static PreviousRentTerms empty() {
return new PreviousRentTerms(false, null, null, null, false);
}
}
public record RentRevisionTerms(boolean enabled, LocalDate annualRevisionDate, Integer referenceQuarter,
Integer referenceYear, String seriesId) {
public RentRevisionTerms {
seriesId = text(seriesId);
if (referenceQuarter != null && (referenceQuarter < 1 || referenceQuarter > 4)) {
throw new LeaseRuleViolation("Le trimestre IRL de référence doit être compris entre 1 et 4.");
}
if (referenceYear != null && referenceYear <= 0) {
throw new LeaseRuleViolation("L'année IRL de référence doit être positive.");
}
if (enabled && (annualRevisionDate == null || referenceQuarter == null || referenceYear == null
|| seriesId.isEmpty())) {
throw new LeaseRuleViolation(
"La date annuelle, le trimestre, l'année et la série IRL sont requis pour la révision.");
}
if (!enabled) {
annualRevisionDate = null;
referenceQuarter = null;
referenceYear = null;
seriesId = "";
}
}
public static RentRevisionTerms empty() {
return new RentRevisionTerms(false, null, null, null, "");
}
}
public record RecentWork(String description, BigDecimal amount) {
public RecentWork {
description = text(description);
nonNegative(amount, "Le montant des travaux récents");
if (positive(amount) && description.isEmpty()) {
throw new LeaseRuleViolation("Les travaux récents associés à un montant doivent être décrits.");
}
}
public static RecentWork empty() {
return new RecentWork("", null);
}
}
private static void nonNegative(BigDecimal value, String field) {
if (value != null && value.signum() < 0) {
throw new LeaseRuleViolation(field + " ne peut pas être négatif.");
}
}
private static boolean positive(BigDecimal value) {
return value != null && value.signum() > 0;
}
private static String text(String value) {
return value == null ? "" : value.trim();
}
}
@@ -1,6 +1,7 @@
package fr.nessar.lmnporganizer.rental.domain.lease;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
@@ -14,18 +15,43 @@ public record LeaseSnapshot(
List<Tenant> tenants,
List<Guarantor> guarantors,
RentalPeriod period,
RentTerms terms) {
RentTerms terms,
LeaseType leaseType,
List<LeaseSpecialClause> specialClauses,
LeaseLegalTerms legalTerms) {
public LeaseSnapshot(Landlord landlord, Apartment apartment, List<Tenant> tenants,
List<Guarantor> guarantors, RentalPeriod period, RentTerms terms) {
this(landlord, apartment, tenants, guarantors, period, terms, LeaseType.STANDARD, List.of(),
LeaseLegalTerms.empty());
}
public LeaseSnapshot(Landlord landlord, Apartment apartment, List<Tenant> tenants,
List<Guarantor> guarantors, RentalPeriod period, RentTerms terms, LeaseType leaseType) {
this(landlord, apartment, tenants, guarantors, period, terms, leaseType, List.of(), LeaseLegalTerms.empty());
}
public LeaseSnapshot(Landlord landlord, Apartment apartment, List<Tenant> tenants,
List<Guarantor> guarantors, RentalPeriod period, RentTerms terms, LeaseType leaseType,
List<LeaseSpecialClause> specialClauses) {
this(landlord, apartment, tenants, guarantors, period, terms, leaseType, specialClauses,
LeaseLegalTerms.empty());
}
public LeaseSnapshot {
Objects.requireNonNull(landlord, "Landlord snapshot is required.");
Objects.requireNonNull(apartment, "Apartment snapshot is required.");
Objects.requireNonNull(period, "Rental period is required.");
Objects.requireNonNull(terms, "Rent terms are required.");
leaseType = LeaseType.orStandard(leaseType);
tenants = tenants == null ? List.of() : List.copyOf(tenants);
guarantors = guarantors == null ? List.of() : List.copyOf(guarantors);
specialClauses = specialClauses == null ? List.of() : List.copyOf(specialClauses);
legalTerms = LeaseLegalTerms.orEmpty(legalTerms);
if (tenants.isEmpty() || new HashSet<>(tenants.stream().map(Tenant::id).toList()).size() != tenants.size()) {
throw new LeaseRuleViolation("Le bail doit contenir au moins un locataire, sans doublon.");
}
legalTerms.validateForTenantCount(tenants.size());
}
public record Landlord(LandlordId id, String firstName, String lastName, String email, String phone,
@@ -40,11 +66,16 @@ public record LeaseSnapshot(
}
}
public record Apartment(ApartmentId id, String name, String address) {
public record Apartment(ApartmentId id, String name, String address, ApartmentLegalProfile legalProfile) {
public Apartment(ApartmentId id, String name, String address) {
this(id, name, address, ApartmentLegalProfile.empty());
}
public Apartment {
Objects.requireNonNull(id, "Apartment ID is required.");
name = required(name, "Apartment name");
address = required(address, "Apartment address");
legalProfile = legalProfile == null ? ApartmentLegalProfile.empty() : legalProfile;
}
}
@@ -0,0 +1,43 @@
package fr.nessar.lmnporganizer.rental.domain.lease;
import java.util.LinkedHashMap;
import java.util.Map;
public record LeaseSpecialClause(String catalogId, Map<String, String> parameters, String text, boolean custom) {
public LeaseSpecialClause {
text = required(text, "Clause text");
parameters = normalized(parameters);
if (custom) {
if (catalogId != null || !parameters.isEmpty()) {
throw new IllegalArgumentException("A custom clause cannot reference the catalog or parameters.");
}
} else {
catalogId = required(catalogId, "Clause catalog ID");
}
}
public static LeaseSpecialClause catalog(String catalogId, Map<String, String> parameters, String text) {
return new LeaseSpecialClause(catalogId, parameters, text, false);
}
public static LeaseSpecialClause custom(String text) {
return new LeaseSpecialClause(null, Map.of(), text, true);
}
private static Map<String, String> normalized(Map<String, String> parameters) {
if (parameters == null || parameters.isEmpty()) {
return Map.of();
}
Map<String, String> normalized = new LinkedHashMap<>();
parameters.forEach((name, value) -> normalized.put(required(name, "Clause parameter name"),
value == null ? "" : value.trim()));
return Map.copyOf(normalized);
}
private static String required(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " is required.");
}
return value.trim();
}
}
@@ -0,0 +1,24 @@
package fr.nessar.lmnporganizer.rental.domain.lease;
import java.time.LocalDate;
import java.util.Objects;
public enum LeaseType {
STANDARD(12),
STUDENT(9);
private final int initialDurationInMonths;
LeaseType(int initialDurationInMonths) {
this.initialDurationInMonths = initialDurationInMonths;
}
public RentalPeriod initialPeriod(LocalDate startDate) {
Objects.requireNonNull(startDate, "Lease start date is required.");
return new RentalPeriod(startDate, startDate.plusMonths(initialDurationInMonths).minusDays(1));
}
public static LeaseType orStandard(LeaseType leaseType) {
return leaseType == null ? STANDARD : leaseType;
}
}
@@ -3,11 +3,19 @@ package fr.nessar.lmnporganizer.rental.domain.lease;
import java.math.BigDecimal;
import java.util.Objects;
public record RentTerms(BigDecimal monthlyRent, BigDecimal monthlyCharges, BigDecimal securityDeposit) {
public record RentTerms(BigDecimal monthlyRent, BigDecimal monthlyCharges, BigDecimal securityDeposit,
int dueDay, ChargeMethod chargeMethod, boolean indexFlatRate) {
public static final int DEFAULT_DUE_DAY = 5;
public RentTerms(BigDecimal monthlyRent, BigDecimal monthlyCharges, BigDecimal securityDeposit) {
this(monthlyRent, monthlyCharges, securityDeposit, DEFAULT_DUE_DAY, ChargeMethod.FLAT_RATE, false);
}
public RentTerms {
Objects.requireNonNull(monthlyRent, "Monthly rent is required.");
Objects.requireNonNull(monthlyCharges, "Monthly charges are required.");
Objects.requireNonNull(securityDeposit, "Security deposit is required.");
Objects.requireNonNull(chargeMethod, "Charge method is required.");
if (monthlyRent.signum() <= 0) {
throw new LeaseRuleViolation("Le loyer mensuel doit être strictement positif.");
}
@@ -17,5 +25,11 @@ public record RentTerms(BigDecimal monthlyRent, BigDecimal monthlyCharges, BigDe
if (securityDeposit.signum() < 0) {
throw new LeaseRuleViolation("Le dépôt de garantie doit être positif ou nul.");
}
if (dueDay < 1 || dueDay > 28) {
throw new LeaseRuleViolation("Le jour d'échéance doit être compris entre 1 et 28.");
}
if (chargeMethod == ChargeMethod.PROVISION && indexFlatRate) {
throw new LeaseRuleViolation("Une provision sur charges ne peut pas être indexée comme un forfait.");
}
}
}
@@ -13,7 +13,7 @@ public final class RentDue {
private final LandlordId landlordId;
private final LeaseId leaseId;
private final YearMonth month;
private final LocalDate dueDate;
private LocalDate dueDate;
private BigDecimal rent;
private BigDecimal charges;
private BigDecimal allocatedAmount;
@@ -69,6 +69,11 @@ public final class RentDue {
}
public void reviseAmounts(BigDecimal rent, BigDecimal charges) {
reviseTerms(dueDate, rent, charges);
}
public void reviseTerms(LocalDate dueDate, BigDecimal rent, BigDecimal charges) {
LocalDate revisedDueDate = Objects.requireNonNull(dueDate, "Due date is required.");
BigDecimal revisedRent = nonNegative(rent, "Rent");
BigDecimal revisedCharges = nonNegative(charges, "Charges");
if (revisedRent.add(revisedCharges).signum() <= 0) {
@@ -80,6 +85,7 @@ public final class RentDue {
if (allocatedAmount.compareTo(revisedRent.add(revisedCharges)) > 0) {
throw new RentRuleViolation("Le nouveau montant est inférieur aux affectations existantes.");
}
this.dueDate = revisedDueDate;
this.rent = revisedRent;
this.charges = revisedCharges;
}
@@ -0,0 +1,207 @@
package fr.nessar.lmnporganizer.rental.domain.rent;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
public record RentRevision(RentRevisionId id, LandlordId landlordId, LeaseId leaseId, Window window,
IndexReference indexReference, Amounts amounts, Request request,
List<LegalOverride> warningOverrides, Application application, long version) {
public RentRevision {
Objects.requireNonNull(id);
Objects.requireNonNull(landlordId);
Objects.requireNonNull(leaseId);
Objects.requireNonNull(window);
Objects.requireNonNull(indexReference);
Objects.requireNonNull(amounts);
Objects.requireNonNull(request);
warningOverrides = warningOverrides == null ? List.of() : List.copyOf(warningOverrides);
if (request.requestedOn().isBefore(window.annualRevisionDate())
|| request.requestedOn().isAfter(window.requestDeadline())) {
throw new RentRuleViolation(
"La date de demande doit être comprise entre la date anniversaire et son échéance.");
}
if (version < -1) {
throw new IllegalArgumentException("Rent revision version cannot be less than -1.");
}
}
public Status status() {
return application == null ? Status.PROPOSED : Status.APPLIED;
}
public RentRevision apply(Application applied) {
Objects.requireNonNull(applied);
if (application != null) {
throw new RentRevisionConflict("Cette révision IRL est déjà appliquée.");
}
if (!applied.sentOn().equals(request.requestedOn())) {
throw new RentRuleViolation("La date d'envoi doit correspondre à la date de la lettre générée.");
}
if (!applied.effectiveOn().equals(request.effectiveOn())) {
throw new RentRevisionConflict(
"L'état des échéances a changé depuis la génération de la lettre ; régénérez la révision IRL.");
}
if (applied.effectiveOn().isBefore(applied.sentOn())) {
throw new RentRuleViolation("La date d'effet ne peut pas précéder la demande.");
}
return new RentRevision(id, landlordId, leaseId, window, indexReference, amounts, request,
warningOverrides, applied, version);
}
public enum Status {
PROPOSED,
APPLIED
}
public enum IndexSource {
INSEE,
CACHE,
MANUAL
}
public enum DeliveryMethod {
REGISTERED_LETTER,
POSTAL_LETTER,
HAND_DELIVERY,
EMAIL,
OTHER
}
public record Window(LocalDate annualRevisionDate, LocalDate requestDeadline) {
public Window {
Objects.requireNonNull(annualRevisionDate);
Objects.requireNonNull(requestDeadline);
if (requestDeadline.isBefore(annualRevisionDate)) {
throw new IllegalArgumentException("Rent revision deadline cannot precede its annual date.");
}
}
}
public record IndexReference(String seriesId, boolean seriesOverridden, boolean seriesOverrideConfirmed,
IndexSource source, boolean manualIndicesConfirmed, int referenceQuarter,
int previousYear, int newYear, BigDecimal previousIndex, BigDecimal newIndex) {
public IndexReference {
seriesId = required(seriesId, "IRL series ID");
Objects.requireNonNull(source);
positive(previousIndex, "Previous IRL index");
positive(newIndex, "New IRL index");
if (referenceQuarter < 1 || referenceQuarter > 4 || previousYear < 1 || newYear != previousYear + 1) {
throw new IllegalArgumentException("IRL reference period is invalid.");
}
if (seriesOverridden && !seriesOverrideConfirmed) {
throw new RentRuleViolation("Le remplacement de la série IRL doit être confirmé.");
}
if (source == IndexSource.MANUAL && !manualIndicesConfirmed) {
throw new RentRuleViolation("La saisie manuelle des indices doit être confirmée.");
}
}
}
public record Amounts(BigDecimal previousRent, BigDecimal rentCeiling, BigDecimal chosenRent,
BigDecimal previousCharges, BigDecimal chargeCeiling, BigDecimal chosenCharges) {
public Amounts {
nonNegative(previousRent, "Previous rent");
nonNegative(previousCharges, "Previous charges");
nonNegative(rentCeiling, "Rent ceiling");
nonNegative(chosenRent, "Chosen rent");
if (chosenRent.compareTo(previousRent) < 0 || chosenRent.compareTo(rentCeiling) > 0) {
throw new RentRuleViolation(
"Le loyer choisi doit être compris entre le loyer actuel et son plafond IRL.");
}
if (chargeCeiling == null) {
if (chosenCharges != null) {
throw new RentRuleViolation("Les charges ne peuvent pas être indexées pour ce bail.");
}
} else {
nonNegative(chargeCeiling, "Charge ceiling");
nonNegative(chosenCharges, "Chosen charges");
if (chosenCharges.compareTo(previousCharges) < 0 || chosenCharges.compareTo(chargeCeiling) > 0) {
throw new RentRuleViolation(
"Les charges choisies doivent être comprises entre leur montant actuel et leur plafond IRL.");
}
}
}
public BigDecimal appliedCharges() {
return chosenCharges == null ? previousCharges : chosenCharges;
}
}
public record Request(LocalDate requestedOn, LocalDate effectiveOn, String letterStorageKey, String letterSha256,
String proposedBy, Instant proposedAt) {
public Request {
Objects.requireNonNull(requestedOn);
Objects.requireNonNull(effectiveOn);
if (effectiveOn.isBefore(requestedOn)) {
throw new RentRuleViolation("La date d'effet ne peut pas précéder la demande.");
}
letterStorageKey = required(letterStorageKey, "Letter storage key");
letterSha256 = required(letterSha256, "Letter SHA-256");
proposedBy = required(proposedBy, "Proposal author");
Objects.requireNonNull(proposedAt);
}
}
public record LegalOverride(String code, String ruleVersion, String authorId, Instant confirmedAt) {
public LegalOverride {
code = required(code, "Warning code");
ruleVersion = required(ruleVersion, "Warning rule version");
authorId = required(authorId, "Warning confirmation author");
Objects.requireNonNull(confirmedAt);
}
}
public record Application(LocalDate sentOn, DeliveryMethod deliveryMethod, LocalDate effectiveOn,
Proof proof, String appliedBy, Instant appliedAt) {
public Application {
Objects.requireNonNull(sentOn);
Objects.requireNonNull(deliveryMethod);
Objects.requireNonNull(effectiveOn);
appliedBy = required(appliedBy, "Application author");
Objects.requireNonNull(appliedAt);
}
}
public record Proof(String storageKey, String fileName, String sha256, String authorId, Instant uploadedAt) {
public Proof {
storageKey = required(storageKey, "Proof storage key");
fileName = required(fileName, "Proof file name");
if (fileName.length() > 255 || !fileName.toLowerCase(Locale.ROOT).endsWith(".pdf")
|| fileName.indexOf('/') >= 0 || fileName.indexOf('\\') >= 0
|| fileName.chars().anyMatch(Character::isISOControl)) {
throw new IllegalArgumentException("Proof file name is invalid.");
}
sha256 = required(sha256, "Proof SHA-256");
authorId = required(authorId, "Proof author");
Objects.requireNonNull(uploadedAt);
}
}
private static String required(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " is required.");
}
return value.trim();
}
private static void positive(BigDecimal value, String field) {
nonNegative(value, field);
if (value.signum() == 0) {
throw new IllegalArgumentException(field + " must be positive.");
}
}
private static void nonNegative(BigDecimal value, String field) {
Objects.requireNonNull(value, field + " is required.");
if (value.signum() < 0) {
throw new IllegalArgumentException(field + " cannot be negative.");
}
}
}
@@ -0,0 +1,11 @@
package fr.nessar.lmnporganizer.rental.domain.rent;
public class RentRevisionConflict extends RuntimeException {
public RentRevisionConflict(String message) {
super(message);
}
public RentRevisionConflict(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,10 @@
package fr.nessar.lmnporganizer.rental.domain.rent;
public record RentRevisionId(String value) {
public RentRevisionId {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("Rent revision ID is required.");
}
value = value.trim();
}
}
@@ -0,0 +1,68 @@
package fr.nessar.lmnporganizer.rental.domain.rent;
import fr.nessar.lmnporganizer.rental.domain.lease.ChargeMethod;
import fr.nessar.lmnporganizer.rental.domain.lease.RentTerms;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.Month;
import java.util.Locale;
import java.util.Optional;
import java.util.Objects;
import java.util.Set;
public final class RentRevisionPolicy {
public static final String METROPOLITAN_SERIES = "001515333";
public static final String CORSICA_SERIES = "010760507";
public static final String OVERSEAS_SERIES = "010760509";
private static final Set<String> CORSICA_DEPARTMENTS = Set.of("2A", "2B");
private static final Set<String> OVERSEAS_DEPARTMENTS = Set.of("971", "972", "973", "974", "976");
private RentRevisionPolicy() {
}
public static String seriesForDepartment(String department) {
String normalized = department == null ? "" : department.trim().toUpperCase(Locale.ROOT);
if (CORSICA_DEPARTMENTS.contains(normalized)) {
return CORSICA_SERIES;
}
return OVERSEAS_DEPARTMENTS.contains(normalized) ? OVERSEAS_SERIES : METROPOLITAN_SERIES;
}
public static boolean isSupportedSeries(String seriesId) {
return METROPOLITAN_SERIES.equals(seriesId) || CORSICA_SERIES.equals(seriesId)
|| OVERSEAS_SERIES.equals(seriesId);
}
public static BigDecimal ceiling(BigDecimal previousAmount, BigDecimal previousIndex, BigDecimal newIndex) {
requirePositive(previousAmount, "Previous amount");
requirePositive(previousIndex, "Previous index");
requirePositive(newIndex, "New index");
return previousAmount.multiply(newIndex).divide(previousIndex, 2, RoundingMode.HALF_UP);
}
public static Optional<BigDecimal> chargeCeiling(RentTerms terms, BigDecimal previousIndex,
BigDecimal newIndex) {
if (terms.chargeMethod() != ChargeMethod.FLAT_RATE || !terms.indexFlatRate()
|| terms.monthlyCharges().signum() == 0) {
return Optional.empty();
}
return Optional.of(ceiling(terms.monthlyCharges(), previousIndex, newIndex));
}
public static LocalDate requestDeadline(LocalDate annualRevisionDate) {
LocalDate date = Objects.requireNonNull(annualRevisionDate, "Annual revision date is required.");
LocalDate nextYear = date.plusYears(1);
return date.getMonth() == Month.FEBRUARY && date.getDayOfMonth() == 29
? nextYear
: nextYear.minusDays(1);
}
private static void requirePositive(BigDecimal value, String field) {
if (value == null || value.signum() <= 0) {
throw new RentRuleViolation(field + " must be strictly positive.");
}
}
}
@@ -0,0 +1,15 @@
package fr.nessar.lmnporganizer.rental.domain.rent;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import java.util.List;
import java.util.Optional;
public interface RentRevisionRepository {
List<RentRevision> findAll(LandlordId landlordId, LeaseId leaseId);
Optional<RentRevision> find(LandlordId landlordId, LeaseId leaseId, RentRevisionId revisionId);
RentRevision save(RentRevision revision);
}
@@ -3,10 +3,14 @@ package fr.nessar.lmnporganizer.rental.domain.rent;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseActivated;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseAmended;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseContractVersion;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseType;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.YearMonth;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
@@ -31,13 +35,50 @@ public final class RentSchedule {
: month.lengthOfMonth();
int occupiedDays = lastOccupiedDay - firstOccupiedDay + 1;
dues.add(RentDue.create(idForMonth.apply(month), event.landlordId(), event.leaseId(), month,
month.plusMonths(1).atDay(5),
dueDate(month, event.period().startDate(), event.terms().dueDay()),
prorate(event.terms().monthlyRent(), occupiedDays, month.lengthOfMonth()),
prorate(event.terms().monthlyCharges(), occupiedDays, month.lengthOfMonth())));
}
return List.copyOf(dues);
}
public static List<RentDue> extendStandard(LeaseId leaseId, LandlordId landlordId,
LeaseContractVersion contract, List<RentDue> existingDues,
LocalDate today, Function<YearMonth, RentDueId> idForMonth) {
Objects.requireNonNull(leaseId);
Objects.requireNonNull(landlordId);
Objects.requireNonNull(contract);
Objects.requireNonNull(today);
Objects.requireNonNull(idForMonth);
if (existingDues == null || existingDues.isEmpty()) {
return List.of();
}
YearMonth blockStart = existingDues.stream().map(RentDue::month).max(YearMonth::compareTo)
.orElseThrow().plusMonths(1);
if (YearMonth.from(today).isBefore(blockStart.minusMonths(1))) {
return List.of();
}
List<RentDue> extension = new ArrayList<>();
YearMonth currentMonth = YearMonth.from(today);
while (!blockStart.isAfter(currentMonth)) {
addAnnualBlock(extension, blockStart, leaseId, landlordId, contract, idForMonth);
blockStart = blockStart.plusMonths(12);
}
addAnnualBlock(extension, blockStart, leaseId, landlordId, contract, idForMonth);
return List.copyOf(extension);
}
private static void addAnnualBlock(List<RentDue> extension, YearMonth blockStart, LeaseId leaseId,
LandlordId landlordId, LeaseContractVersion contract,
Function<YearMonth, RentDueId> idForMonth) {
for (int offset = 0; offset < 12; offset++) {
YearMonth month = blockStart.plusMonths(offset);
extension.add(RentDue.create(idForMonth.apply(month), landlordId, leaseId, month,
month.atDay(contract.terms().dueDay()), contract.terms().monthlyRent(),
contract.terms().monthlyCharges()));
}
}
public static Reconciliation reconcile(LeaseAmended event, List<RentDue> existingDues,
Function<YearMonth, RentDueId> idForMonth) {
Objects.requireNonNull(event);
@@ -50,39 +91,104 @@ public final class RentSchedule {
}
}
Map<YearMonth, Amounts> targets = targets(event);
Map<YearMonth, Target> targets = targets(event, existingByMonth.keySet());
List<RentDue> toSave = new ArrayList<>();
List<RentDue> toRemove = new ArrayList<>();
for (Map.Entry<YearMonth, RentDue> entry : existingByMonth.entrySet()) {
if (entry.getKey().isBefore(effectiveMonth)) {
continue;
}
Amounts target = targets.remove(entry.getKey());
Target target = targets.remove(entry.getKey());
RentDue due = entry.getValue();
if (due.settlement() == RentDueSettlement.PAID) {
continue;
}
if (target == null) {
if (due.hasAllocations()) {
throw new RentRuleViolation("Une échéance affectée ne peut pas être supprimée par avenant.");
}
toRemove.add(due);
} else if (due.rent().compareTo(target.rent()) != 0
} else if (!due.dueDate().equals(target.dueDate())
|| due.rent().compareTo(target.rent()) != 0
|| due.charges().compareTo(target.charges()) != 0) {
due.reviseAmounts(target.rent(), target.charges());
due.reviseTerms(target.dueDate(), target.rent(), target.charges());
toSave.add(due);
}
}
targets.forEach((month, amount) -> toSave.add(RentDue.create(idForMonth.apply(month), event.landlordId(),
event.leaseId(), month, month.plusMonths(1).atDay(5), amount.rent(), amount.charges())));
event.leaseId(), month, amount.dueDate(), amount.rent(), amount.charges())));
return new Reconciliation(toSave, toRemove);
}
private static Map<YearMonth, Amounts> targets(LeaseAmended event) {
public static List<RentDue> applyRevision(List<RentDue> existingDues, LocalDate effectiveDate,
RevisionAmounts amounts) {
return applyRevision(existingDues, effectiveDate, amounts, null);
}
public static List<RentDue> applyRevision(List<RentDue> existingDues, LocalDate effectiveDate,
RevisionAmounts amounts, FinancialBoundary boundary) {
Objects.requireNonNull(effectiveDate);
Objects.requireNonNull(amounts);
if (boundary != null && !boundary.effectiveDate().isAfter(effectiveDate)) {
throw new RentRuleViolation("Le prochain changement financier doit suivre la révision IRL.");
}
YearMonth effectiveMonth = YearMonth.from(effectiveDate);
List<RentDue> changed = new ArrayList<>();
for (RentDue due : existingDues == null ? List.<RentDue>of() : existingDues) {
if (due.month().isBefore(effectiveMonth) || afterBoundary(due.month(), boundary)
|| due.settlement() == RentDueSettlement.PAID) {
continue;
}
BigDecimal rent = revisedAmount(due.month(), effectiveDate, amounts.previousRent(),
amounts.revisedRent(), boundary == null ? null : boundary.rent(), boundary);
BigDecimal charges = revisedAmount(due.month(), effectiveDate, amounts.previousCharges(),
amounts.revisedCharges(), boundary == null ? null : boundary.charges(), boundary);
if (due.rent().compareTo(rent) != 0 || due.charges().compareTo(charges) != 0) {
due.reviseAmounts(rent, charges);
changed.add(due);
}
}
return List.copyOf(changed);
}
private static boolean afterBoundary(YearMonth month, FinancialBoundary boundary) {
return boundary != null && month.isAfter(YearMonth.from(boundary.effectiveDate()));
}
private static BigDecimal revisedAmount(YearMonth month, LocalDate effectiveDate, BigDecimal previous,
BigDecimal revised, BigDecimal following,
FinancialBoundary boundary) {
int days = month.lengthOfMonth();
int previousDays = month.equals(YearMonth.from(effectiveDate)) ? effectiveDate.getDayOfMonth() - 1 : 0;
int followingDays = boundary != null && month.equals(YearMonth.from(boundary.effectiveDate()))
? days - boundary.effectiveDate().getDayOfMonth() + 1
: 0;
int revisedDays = days - previousDays - followingDays;
BigDecimal total = previous.multiply(BigDecimal.valueOf(previousDays))
.add(revised.multiply(BigDecimal.valueOf(revisedDays)));
if (followingDays > 0) {
total = total.add(following.multiply(BigDecimal.valueOf(followingDays)));
}
return total.divide(BigDecimal.valueOf(days), 2, RoundingMode.HALF_UP);
}
private static Map<YearMonth, Target> targets(LeaseAmended event, Iterable<YearMonth> existingMonths) {
LeaseContractVersion before = event.amendment().beforeVersion();
LeaseContractVersion after = event.amendment().afterVersion();
YearMonth effectiveMonth = YearMonth.from(event.amendment().effectiveDate());
YearMonth lastMonth = YearMonth.from(after.period().endDate());
Map<YearMonth, Amounts> targets = new HashMap<>();
if (after.leaseType() == LeaseType.STANDARD) {
for (YearMonth existingMonth : existingMonths) {
if (existingMonth.isAfter(lastMonth)) {
lastMonth = existingMonth;
}
}
}
Map<YearMonth, Target> targets = new HashMap<>();
for (YearMonth month = effectiveMonth; !month.isAfter(lastMonth); month = month.plusMonths(1)) {
targets.put(month, amountsFor(month, event.amendment().effectiveDate(), before, after));
Amounts amounts = amountsFor(month, event.amendment().effectiveDate(), before, after);
targets.put(month, new Target(dueDate(month, after.period().startDate(), after.terms().dueDay()),
amounts.rent(), amounts.charges()));
}
return targets;
}
@@ -92,10 +198,16 @@ public final class RentSchedule {
int days = month.lengthOfMonth();
java.time.LocalDate monthStart = month.atDay(1);
java.time.LocalDate monthEnd = month.atEndOfMonth();
java.time.LocalDate beforeEnd = before.leaseType() == LeaseType.STANDARD
? monthEnd
: before.period().endDate();
java.time.LocalDate afterEnd = after.leaseType() == LeaseType.STANDARD
? monthEnd
: after.period().endDate();
int beforeDays = occupiedDays(later(monthStart, before.period().startDate()),
earlier(monthEnd, earlier(before.period().endDate(), effectiveDate.minusDays(1))));
earlier(monthEnd, earlier(beforeEnd, effectiveDate.minusDays(1))));
int afterDays = occupiedDays(later(monthStart, later(after.period().startDate(), effectiveDate)),
earlier(monthEnd, after.period().endDate()));
earlier(monthEnd, afterEnd));
return new Amounts(
splitProrate(before.terms().monthlyRent(), beforeDays,
after.terms().monthlyRent(), afterDays, days),
@@ -127,6 +239,13 @@ public final class RentSchedule {
.divide(BigDecimal.valueOf(daysInMonth), 2, RoundingMode.HALF_UP);
}
private static java.time.LocalDate dueDate(YearMonth month, java.time.LocalDate leaseStart, int dueDay) {
if (month.equals(YearMonth.from(leaseStart)) && leaseStart.getDayOfMonth() > dueDay) {
return leaseStart;
}
return month.atDay(dueDay);
}
public record Reconciliation(List<RentDue> toSave, List<RentDue> toRemove) {
public Reconciliation {
toSave = List.copyOf(toSave);
@@ -134,6 +253,35 @@ public final class RentSchedule {
}
}
public record RevisionAmounts(BigDecimal previousRent, BigDecimal previousCharges,
BigDecimal revisedRent, BigDecimal revisedCharges) {
public RevisionAmounts {
previousRent = nonNegative(previousRent, "Previous rent");
previousCharges = nonNegative(previousCharges, "Previous charges");
revisedRent = nonNegative(revisedRent, "Revised rent");
revisedCharges = nonNegative(revisedCharges, "Revised charges");
}
private static BigDecimal nonNegative(BigDecimal amount, String field) {
Objects.requireNonNull(amount, field + " is required.");
if (amount.signum() < 0) {
throw new RentRuleViolation(field + " cannot be negative.");
}
return amount;
}
}
public record FinancialBoundary(LocalDate effectiveDate, BigDecimal rent, BigDecimal charges) {
public FinancialBoundary {
Objects.requireNonNull(effectiveDate, "Financial boundary date is required.");
rent = RevisionAmounts.nonNegative(rent, "Following rent");
charges = RevisionAmounts.nonNegative(charges, "Following charges");
}
}
private record Amounts(BigDecimal rent, BigDecimal charges) {
}
private record Target(java.time.LocalDate dueDate, BigDecimal rent, BigDecimal charges) {
}
}
@@ -2,6 +2,8 @@ spring.application.name=backend
spring.mongodb.uri=${SPRING_DATA_MONGODB_URI:mongodb://localhost:27017/lmnp?replicaSet=rs0&directConnection=true}
lmnp.storage.attachments=${LMNP_ATTACHMENT_STORAGE_PATH:${user.home}/.lmnp-organizer/attachments}
lmnp.attachments.max-size=${LMNP_ATTACHMENT_MAX_SIZE:250MB}
lmnp.insee-irl.base-uri=${INSEE_IRL_BASE_URI:https://bdm.insee.fr/series/sdmx/data/SERIES_BDM/}
lmnp.insee-irl.request-timeout=${INSEE_IRL_REQUEST_TIMEOUT:5s}
spring.servlet.multipart.max-file-size=${lmnp.attachments.max-size}
spring.servlet.multipart.max-request-size=251MB
@@ -0,0 +1,21 @@
clauses:
- id: VISIT_SALE_RELETTING
label: Droit de visite en cas de vente ou de relocation
description: Encadre les visites dans la limite légale de deux heures par jour ouvrable.
contexts: [ALL]
template: >-
En cas de vente du logement ou de relocation après congé, le locataire permettra les visites
à partir de {{startTime}}, les jours ouvrables hors jours fériés, pendant une durée qui ne pourra
excéder deux heures par jour. Les modalités concrètes seront convenues avec le locataire.
parameters:
- name: startTime
label: Heure de début des visites
type: TIME
required: true
- id: FIRST_CATEGORY_DOG_BAN
label: Interdiction des chiens de première catégorie
description: Interdit uniquement les chiens d'attaque classés en première catégorie.
contexts: [ALL]
template: >-
La détention d'un chien de première catégorie au sens de l'article L. 211-12 du code rural
et de la pêche maritime est interdite dans le logement.
@@ -0,0 +1,678 @@
ANNEXE
CONTENU DE LA NOTICE D'INFORMATION ANNEXÉE À CERTAINS CONTRATS DE LOCATION
Préambule.
1. Etablissement du bail.
1.1. Forme et contenu du contrat.
1.2. Durée du contrat.
1.3. Conditions financières de la location.
1.3.1. Loyer.
1.3.1.1. Fixation du loyer initial.
1.3.1.2. Evolution du loyer en cours de contrat.
1.3.1.2.1. Révision annuelle du loyer.
1.3.1.2.2. Evolution du loyer consécutive à des travaux.
1.3.1.3. Ajustement du loyer au renouvellement du bail.
1.3.2. Charges locatives.
1.3.3. Contribution du locataire au partage des économies de charges.
1.3.4. Modalités de paiement.
1.4. Garanties.
1.4.1. Dépôt de garantie.
1.4.2. Garantie autonome.
1.4.3. Cautionnement.
1.5. Etat des lieux.
2. Droits et obligations des parties.
2.1. Obligations générales du bailleur.
2.2. Obligations générales du locataire.
2.3. Obligations des parties en matière de lutte contre les nuisibles.
3. Fin de contrat et sortie du logement.
3.1. Congés.
3.1.1. Congé délivré par le locataire.
3.1.2. Congé délivré par le bailleur.
3.2. Sortie du logement.
3.2.1. Etat des lieux de sortie.
3.2.2. Restitution du dépôt de garantie.
4. Départ du conjoint victime de violences.
5. Règlement des litiges locatifs.
5.1. Règles de prescriptions.
5.2. Règlement amiable et conciliation.
5.3. Action en justice.
5.3.1. Généralités.
5.3.2. Résiliation judiciaire et de plein droit.
5.3.2.1. Mise en œuvre de la clause résolutoire de plein droit.
5.3.2.2. Résiliation judicaire.
5.3.2.3. Assignation aux fins de résiliation du bail et pouvoirs du juge.
5.4. Prévention des expulsions.
5.4.1. Les dispositifs de traitement de la dette locative.
5.4.1.1. Le fonds de solidarité pour le logement.
5.4.1.2. La commission de surendettement.
5.4.2. La commission de coordination des actions de prévention des expulsions locatives (CCAPEX).
5.5. Recherche d'un nouveau logement plus adapté aux ressources du locataire et droit au logement opposable (DALO).
5.6. Procédure d'expulsion.
6. Contacts utiles.
Préambule
Le régime de droit commun des baux d'habitation, applicable aux locations de logements constituant la résidence principale des locataires, est défini principalement par la loi n° 89-462 du 6 juillet 1989 tendant à améliorer les rapports locatifs.
La présente notice d'information rappelle les principaux droits et obligations des parties ainsi que certaines des voies de conciliation et de recours possibles pour régler leurs litiges.
Si la plupart des règles s'appliquent indifféremment à l'ensemble des locations, la loi prévoit certains aménagements pour les locations meublées ou les colocations afin de prendre en compte les spécificités attachées à ces catégories de location.
Pour prétendre à la qualification de meublé, un logement doit être équipé d'un mobilier en nombre et en qualité suffisants pour permettre au locataire d'y dormir, manger et vivre convenablement au regard des exigences de la vie courante (titre 1er bis de la loi du 6 juillet 1989). La liste de ce mobilier est fixée par décret (1).
Les colocations, définies comme la location d'un même logement par plusieurs locataires, sont soumises au régime applicable le cas échéant aux locations nues ou meublées et aux règles spécifiques prévues par la loi en matière de colocation (art. 8-1 [2]).
1. Etablissement du bail
1.1. Forme et contenu du contrat
Le contrat de location est établi par écrit et respecte un bail type défini par décret (3).
Le bail peut être établi directement entre le bailleur et le locataire, éventuellement avec l'aide d'un intermédiaire (agent immobilier, administrateur de biens, huissier, notaire …). Il doit être fait en autant d'originaux que de parties et remis à chacune d'elles.
Le contrat de location doit comporter certaines mentions et notamment l'identité des parties, la description du logement, sa surface habitable (4) et les conditions financières du contrat. Ainsi, en cas d'inexactitude supérieure à 5 % de la surface habitable mentionnée au bail d'une location nue, le locataire peut demander une diminution de loyer proportionnelle à l'écart constaté (art. 3 et 3-1).
Certaines clauses sont interdites. Si elles figurent dans le contrat, elles sont alors considérées comme étant inapplicables. Il s'agit notamment des clauses qui imposent comme mode de paiement du loyer le prélèvement automatique, qui prévoient des pénalités en cas de retard dans le paiement du loyer, qui interdisent au locataire d'héberger des personnes ne vivant pas habituellement avec lui, qui prévoient des frais de délivrance ou d'envoi de quittance, etc. (art. 4).
Le bailleur est tenu de remettre au locataire un certain nombre de documents lors de la signature du contrat, et notamment un dossier de diagnostic technique incluant le DPE (diagnostic de performance énergétique) (art. 3-3).
1.2. Durée du contrat
Location d'un logement nu :
Le bail d'un logement nu est conclu pour une durée minimum de trois ans lorsque le bailleur est une personne physique (par exemple, un particulier) ou une société civile immobilière familiale, et de six ans lorsqu'il est une personne morale (par exemple, une société, une association).
A la fin du bail et à défaut de congé donné par l'une ou l'autre des parties, le bail est renouvelé sur proposition du bailleur ou reconduit automatiquement et dans les mêmes conditions pour trois ans lorsque le bailleur est une personne physique ou une société civile immobilière familiale, et pour six ans lorsqu'il est une personne morale (art. 10).
Par exception, la durée peut être inférieure à trois ans, mais d'au minimum un an, si le bailleur (personne physique, membre d'une société civile immobilière familiale ou d'une indivision) prévoit de reprendre son logement pour des raisons familiales ou professionnelles. Les raisons et l'événement invoqué pour justifier la reprise doivent impérativement figurer dans le bail. Le bailleur confirme au locataire, par lettre recommandée avec avis de réception, au minimum deux mois avant la fin du bail que l'événement est réalisé et qu'il reprendra le logement à la date prévue. Si la réalisation de l'événement justifiant la reprise du logement est différée, le bailleur peut également proposer dans les mêmes délais un ultime report du terme du contrat. Lorsque l'événement ne s'est pas produit ou n'a pas été confirmé, le bail se poursuit jusqu'au délai de trois ans (art. 11).
Location d'un logement meublé :
Le contrat de location d'un logement meublé est conclu pour une durée d'au moins un an. A la fin du bail et à défaut de congé donné par le bailleur ou le locataire ou de proposition de renouvellement, le bail est reconduit automatiquement et dans les mêmes conditions pour un an.
Lorsque le locataire est un étudiant, les parties peuvent convenir d'un bail d'une durée de neuf mois qui n'est pas reconductible tacitement. Dans ce cas, si les parties ne conviennent pas de la reconduction du contrat, celui-ci prend fin à son terme (art. 25-7).
1.3. Conditions financières de la location
1.3.1. Loyer
1.3.1.1. Fixation du loyer initial (article 17)
En principe, le loyer initial est fixé librement entre les parties au contrat.
Toutefois, depuis le 24 août 2022, lorsqu'un logement est classé F ou G par le diagnostic de performance énergétique (logements généralement qualifiés de passoires thermiques ou passoires énergétiques), aucune augmentation de loyer ne peut être effectuée à la nouvelle location.
En outre, dans certaines zones où il existe un déséquilibre marqué entre l'offre et la demande de logements, entraînant des difficultés sérieuses d'accès au logement, les loyers peuvent être encadrés par deux mécanismes complémentaires au moment de la mise en location. La liste des communes comprises dans ces zones de tension du marché locatif est fixée par décret (5).
a) Logements situés dans des zones de tension du marché locatif (art. 18) :
Pour l'ensemble des communes comprises dans ces zones, un décret (6) fixe chaque année le montant maximum d'évolution des loyers en cas de relocation d'un logement. Ainsi, au moment du changement de locataire, le loyer d'un bien ne peut plus excéder le dernier loyer appliqué, revalorisé sur la base de l'évolution de l'indice de référence des loyers (IRL). L'augmentation de loyer peut être supérieure dans des cas particuliers : lorsque certains travaux d'amélioration ou de mise en conformité avec les caractéristiques de décence sont réalisés par le bailleur ou lorsque le loyer est manifestement sous-évalué.
Par ailleurs, les logements faisant l'objet d'une première location, les logements inoccupés par un locataire depuis plus de dix-huit mois et les logements ayant fait l'objet, depuis moins de six mois, de travaux d'amélioration d'un montant au moins égal à la dernière année de loyer, sont exclus du champ de ce dispositif. Il en est de même pour les logements qui sortent d'un conventionnement avec l'Agence nationale de l'habitat (ANAH), pour lesquels la fixation du loyer est libre sauf pour les « passoires énergétiques » (art. 18-1).
b) Logements situés dans certaines zones de tension du marché locatif dotées d'un observatoire local des loyers agréé (article17 et article 140 de la loi dite ELAN) :
Un mécanisme complémentaire a été introduit à titre expérimental, par l'article 140 de la loi ELAN du 23 novembre 2018. Il prévoit que, dans les zones tendues et dotées d'un observatoire local des loyers agréé par l'Etat, les établissements publics de coopération intercommunale compétents en matière d'habitat, la commune de Paris, les établissements publics territoriaux de la métropole du Grand Paris, la métropole de Lyon et la métropole d'Aix-Marseille-Provence peuvent demander qu'un dispositif d'encadrement des loyers soit mis en place. Un décret détermine le périmètre du territoire de la collectivité demandeuse sur lequel s'applique le dispositif, lorsqu'il existe un écart important entre le niveau moyen de loyer constaté dans le parc locatif privé et le loyer moyen pratiqué dans le parc locatif social ; un niveau de loyer médian élevé ; un taux de logements commencés, rapporté aux logements existants sur les cinq dernières années, faible ; des perspectives limitées de production pluriannuelle de logements inscrites dans le programme local de l'habitat et de faibles perspectives d'évolution de celles-ci.
Ce dispositif s'appuie sur des références de loyers déterminées à partir des données représentatives des loyers du marché locatif local produites par les observatoires locaux des loyers dans le respect de prescriptions méthodologiques définies par un conseil scientifique.
A partir de ces données, les préfets concernés fixent annuellement par arrêté et pour chaque catégorie de logement et secteur géographique donnés, des références de loyers (loyer de référence, loyer de référence majoré et loyer de référence minoré) exprimées par un prix au mètre carré de surface habitable, lequel varie notamment selon le nombre de pièces du logement, la période de construction de l'immeuble et le caractère meublé ou non de la location.
Dans les territoires où l'arrêté préfectoral est pris, le loyer au mètre carré des logements mis en location ne peut pas excéder le loyer de référence majoré, ce dernier devant être mentionné dans le contrat de location. Toutefois, lorsque le logement présente certaines caractéristiques de localisation ou de confort, le bailleur peut fixer un loyer supérieur au loyer de référence majoré en appliquant un complément de loyer au loyer de base, lorsque celui-ci est égal au loyer de référence majoré correspondant au logement. Le montant de ce complément de loyer et les caractéristiques le justifiant doivent être mentionnés dans le contrat de location. Le locataire dispose d'un délai de trois mois, à partir de la signature du bail, pour contester ce complément de loyer en saisissant au préalable et obligatoirement la commission départementale de conciliation compétente, et, en l'absence d'accord entre les parties, en ayant recours au juge. Aucun complément de loyer ne peut être appliqué lorsque le logement présente une ou plusieurs des caractéristiques suivantes : des sanitaires sur le palier, des signes d'humidité sur certains murs, un niveau de performance énergétique de classe F ou de classe G, des fenêtres laissant anormalement passer l'air hors grille de ventilation, un vis-à-vis à moins de dix mètres, des infiltrations ou des inondations provenant de l'extérieur du logement, des problèmes d'évacuation d'eau au cours des trois derniers mois, une installation électrique dégradée ou une mauvaise exposition de la pièce principale.
Des sites internet sont généralement mis à disposition des locataires et bailleurs par l'Etat ou les collectivités afin de leur permettre de connaître les loyers de références applicables au logement.
La liste des communes concernées par ce dispositif expérimental est disponible ici : https :// www. ecologie. gouv. fr/ encadrement-des-loyers.
1.3.1.2. Evolution du loyer en cours de contrat
1.3.1.2.1. Révision annuelle du loyer (art. 17-1)
Lorsqu'une clause le prévoit, le loyer peut être révisé, une fois par an, à une date de révision indiquée au bail ou, à défaut, à la date anniversaire du bail. Toutefois, pour tous les baux signés, renouvelés ou reconduits tacitement à compter du 24 août 2022, lorsqu'un logement est classé F ou G par le diagnostic de performance énergétique (passoire thermique ou énergétique), le bailleur n'est pas autorisé à réviser le loyer.
Cette augmentation annuelle du loyer ne peut être supérieure à la variation de l'indice de référence des loyers (IRL) publié par l'INSEE. Cet indice correspond à la moyenne, sur les douze derniers mois, de l'évolution des prix à la consommation, hors tabac et hors loyers.
L'indice de référence à prendre en compte est celui du trimestre qui figure dans le bail ou, à défaut, le dernier indice publié à la date de signature du contrat. Il est à comparer avec l'indice du même trimestre connu à la date de révision.
Le bailleur dispose d'un délai d'un an, à compter de la date de révision, pour en faire la demande. La révision prend effet au jour de sa demande ; elle ne peut donc pas être rétroactive.
Passé ce délai, la révision du loyer pour l'année écoulée n'est plus possible, le bailleur étant alors présumé avoir renoncé au bénéfice de cette clause pour l'année écoulée.
Si le bail ne prévoit pas de clause de révision, le loyer reste le même pendant toute la durée de la location.
1.3.1.2.2. Evolution du loyer consécutive à des travaux (art. 6 et 17-1)
Exceptionnellement, le loyer d'un logement répondant aux caractéristiques de décence peut être revu à la hausse ou à la baisse en cours de bail lorsque le bailleur et le locataire ont convenu de travaux que l'une ou l'autre des parties fera exécuter à ses frais durant l'exécution du contrat.
Toutefois, pour tous les baux signés, renouvelés ou reconduits tacitement à compter du 24 août 2022, lorsqu'un logement est classé F ou G par le diagnostic de performance énergétique (passoire thermique ou énergétique), le bailleur n'est pas autorisé à procéder à une majoration du loyer.
La clause du contrat de location ou l'avenant qui prévoient cet accord doivent fixer la majoration ou la diminution de loyer applicable suite à la réalisation des travaux et, selon le cas, fixer ses modalités d'application. Lorsqu'elle concerne des travaux à réaliser par le bailleur, cette clause ne peut porter que sur des travaux d'amélioration.
1.3.1.3. Ajustement du loyer au renouvellement du bail (art. 17-2 et 25-9)
Hors zones de tension du marché locatif, le loyer ne fait l'objet d'aucune réévaluation au moment du renouvellement du bail sauf s'il est manifestement sous-évalué. Toutefois, depuis le 24 août 2022, aucune réévaluation de loyer ne peut être appliquée dans les logements de la classe F ou de la classe G (passoires thermiques ou énergétiques).
A l'expiration du bail, si le bailleur considère que le loyer est manifestement sous-évalué, il peut proposer une augmentation de loyer, en se référant aux loyers habituellement constatés dans le voisinage pour des logements comparables.
Il doit faire cette proposition au moins six mois avant le terme du contrat par lettre recommandée avec accusé de réception, par acte de commissaire de justice (anciennement appelé huissier de justice) ou lettre remise en main propre contre récépissé ou émargement. Cette proposition doit reproduire intégralement, sous peine de nullité, les dispositions légales relatives à la réévaluation de loyer (art. 17-2 I) et indiquer le montant du nouveau loyer proposé ainsi que la liste des références de loyers ayant servi à le déterminer.
En cas de désaccord ou à défaut de réponse du locataire quatre mois avant le terme du contrat, la commission départementale de conciliation peut être saisie. Il s'agit d'un cas de saisine préalable obligatoire de la commission avant saisine du juge. Celle-ci s'efforce de concilier les parties et en cas d'échec rend un avis pouvant être transmis au juge saisi par l'une ou l'autre des parties.
A défaut de conciliation, le bailleur peut alors saisir le juge du tribunal judiciaire qui fixera alors lui-même le loyer applicable.
Si, au terme du contrat, aucun accord n'est trouvé, ou si le juge du tribunal judiciaire n'est pas saisi, le contrat est reconduit aux mêmes conditions de loyer (éventuellement révisé, si une clause le prévoit).
Lorsqu'une hausse de loyer a été convenue entre les parties ou fixée judiciairement, celle-ci s'applique progressivement au cours du bail renouvelé :
-lorsque la hausse est inférieure ou égale à 10 % de l'ancien loyer, l'augmentation est échelonnée par tiers sur trois ans, si le bailleur est un particulier (ou une SCI familiale), ou par sixième sur six ans, s'il est une personne morale (hors SCI familiale). Cette hausse s'entend hors révision annuelle liée à la variation de l'indice de référence des loyers ;
-lorsque la hausse est supérieure à 10 % de l'ancien loyer, elle doit être, dans tous les cas, étalée par sixième sur six ans. L'étalement par sixième s'applique même si le bail est renouvelé pour une durée inférieure à six ans, par exemple pour trois ans. Dans ce cas, l'augmentation continue à s'échelonner lors du renouvellement suivant.
Pour les logements meublés, la hausse du loyer convenue entre les parties ou fixée judiciairement s'applique au contrat renouvelé. Toutefois, si la hausse est supérieure à 10 %, elle s'applique par tiers annuel au contrat renouvelé et lors des renouvellements ultérieurs.
Enfin, sur certains territoires (cf. § 1.3.1.1), les modalités d'ajustement du loyer au renouvellement du contrat connaissent certaines particularités :
-dans l'ensemble des zones de tension du marché locatif, un décret limite la hausse de loyer applicable au renouvellement de bail (art. 18). Le loyer ne peut être réévalué que si le loyer est manifestement sous-évalué et dans les limites fixées par ce décret (7) ;
En outre, le dispositif expérimental de l'article 140 de la loi ELAN prévoit que dans les zones de tension du marché locatif dotées d'un observatoire local des loyers et pour lesquelles un arrêté préfectoral fixe des références de loyers, deux procédures d'encadrement des loyers au stade du renouvellement du bail sont ouvertes :
-le locataire peut engager une action en diminution de loyer si le montant du loyer fixé au contrat (hors montant du complément de loyer) s'avère supérieur au loyer de référence majoré publié par le préfet. Il doit alors faire une proposition au bailleur, au moins cinq mois avant le terme du bail, par lettre recommandée avec accusé de réception, par acte de commissaire de justice ou lettre remise en main propre contre récépissé ou émargement ;
-le bailleur peut engager une action en réévaluation du loyer dès lors que le loyer fixé au bail est inférieur au loyer de référence minoré publié par le préfet. Il doit alors faire une proposition au locataire, au moins six mois avant le terme du bail et dans les mêmes conditions de forme, qui ne peut être supérieure au loyer de référence minoré.
Pour le règlement des litiges liés à ces deux situations, comme pour les litiges relatifs à la réévaluation des loyers au renouvellement du bail en zones de tension du marché locatif, la saisine de la commission départementale de conciliation constitue également un préalable à la saisine du juge.
1.3.2. Charges locatives (art. 23)
Les charges locatives, ou charges récupérables, correspondent à certaines catégories de dépenses prises en charge par le bailleur, mais qui peuvent être récupérées auprès du locataire. Elles sont la contrepartie de services rendus liés à la chose louée, de dépenses d'entretien courant et de menues réparations relatives aux parties communes, et de certaines impositions liées à des services au locataire.
La liste des charges récupérables est limitativement énumérée par un décret (8) qui distingue huit postes de charges.
Le bailleur peut donc récupérer ces charges auprès des locataires de deux manières :
-de manière ponctuelle en apportant les justificatifs des dépenses engagées ;
-de manière régulière par versement périodique de provisions pour charges, par exemple tous les mois ou tous les trimestres.
Si les charges sont payées par provision, une régularisation doit être effectuée chaque année en comparant le total des provisions versées par le locataire avec les dépenses effectives engagées par le bailleur pendant l'année. Si les provisions sont supérieures aux dépenses réelles, celui-ci doit reverser le trop-perçu au locataire ; dans le cas contraire, il peut exiger un complément.
Le montant des charges récupérées par le bailleur doit être dans tous les cas justifié.
C'est ainsi que le bailleur doit communiquer au locataire :
-le décompte des charges locatives par nature de charges (électricité, eau chaude, eau froide, ascenseur …) ;
-le mode de répartition entre les locataires si le logement est situé dans un immeuble collectif ;
-une note d'information sur les modalités de calcul des charges de chauffage et de production d'eau chaude sanitaire collectives.
Dans les six mois suivant l'envoi du décompte, le bailleur doit tenir à la disposition du locataire, dans des conditions normales, l'ensemble des pièces justificatives de charges complémentaires (factures, contrats de fournitures). Depuis le 1er septembre 2015, il doit transmettre le récapitulatif des charges du logement par voie dématérialisée ou par voie postale dès lors que le locataire en fait la demande.
Si la régularisation des charges n'a pas été effectuée dans l'année suivant leur exigibilité, le locataire peut exiger un paiement échelonné sur douze mois (art. 23).
Location de logement meublé et colocation (de logement nu ou meublé) :
Les parties peuvent opter, lors de la conclusion du contrat, pour la récupération des charges, en fonction des dépenses réellement engagées comme décrit précédemment, ou sous la forme d'un forfait. Dans ce cas, le montant du forfait est fixé dès la conclusion du contrat et ne donne pas lieu à régularisation. Ce montant, éventuellement révisé chaque année dans les mêmes conditions que le loyer, ne doit pas être manifestement disproportionné au regard des charges dont le locataire se serait acquitté sur justification des dépenses engagées par le bailleur (art. 8-1 et 25-10).
1.3.3. Contribution du locataire au partage des économies de charges (art. 23-1)
Dans le cadre d'une location nue, une contribution financière peut être demandée au locataire en plus du loyer et des charges lorsque le bailleur a réalisé dans les parties privatives d'un logement, ou dans les parties communes de l'immeuble, des travaux d'économies d'énergie.
Cette contribution, limitée au maximum à quinze années et dont le montant fixe et non révisable ne peut être supérieur à la moitié du montant de l'économie d'énergie estimée, peut être demandée au locataire au titre du partage des économies de charge à partir de la date d'achèvement des travaux, dans des conditions strictement fixées par les textes. Elle ne peut être exigible qu'à la condition qu'un ensemble de travaux ait été réalisé et que le logement ait un niveau de performance compris entre la classe A et la classe E du diagnostic de performance énergétique.
1.3.4. Modalités de paiement
Le paiement du loyer et des charges doit être effectué à la date prévue au contrat.
Le bailleur est tenu de transmettre gratuitement au locataire qui en fait la demande, une quittance, qui indique le détail des sommes versées par le locataire en distinguant le loyer et les charges. Avec l'accord du locataire, la quittance peut être transmise par voie dématérialisée. Aucuns frais liés à la gestion de l'avis d'échéance ou de la quittance ne peuvent être facturés au locataire (art. 21).
1.4. Garanties
Fréquemment, le bailleur exige certaines garanties pour se prémunir de l'inexécution des obligations du locataire. Pour ce faire, le bailleur dispose de différents moyens, strictement encadrés par la loi.
1.4.1. Dépôt de garantie (art. 22)
Le contrat de location peut prévoir le paiement d'un dépôt de garantie qui sert à couvrir les éventuels manquements du locataire à ses obligations locatives.
Son montant doit obligatoirement figurer dans le bail. Il ne peut pas être supérieur à un mois de loyer, hors charges, pour les locations nues et ne peut faire l'objet d'aucune révision en cours ou au renouvellement du bail.
Pour les locations meublées, le montant du dépôt est limité à deux mois de loyer, hors charges (art. 25-6).
Au moment de la signature du bail, le dépôt de garantie est versé au bailleur directement par le locataire ou par l'intermédiaire d'un tiers.
Aucun dépôt de garantie ne peut être versé lorsque le loyer est payable d'avance pour une période supérieure à deux mois.
Les modalités de restitution du dépôt de garantie à la fin du bail sont précisées dans la partie 3.2.2.
1.4.2. Garantie autonome (art. 22-1-1)
Une garantie autonome peut être souscrite en lieu et place du dépôt de garantie, dans la limite du montant de celui-ci. Appliquée au contrat de location, la garantie autonome est l'engagement par lequel le garant s'oblige, en cas de non-respect par le locataire de ses obligations, à verser une somme, soit à première demande, soit suivant des modalités convenues sans pouvoir opposer aucune exception à l'obligation garantie.
1.4.3. Cautionnement (art. 22-1)
Le terme caution désigne la personne qui, dans un document écrit appelé Acte de cautionnement, s'engage envers le bailleur à payer les dettes locatives du locataire et à exécuter les obligations qui lui incombent en cas de défaillance de sa part (ex. : loyers, charges, frais de remise en état du logement …).
Exigibilité :
Le bailleur ne peut exiger de cautionnement, à peine de nullité, s'il a déjà souscrit une assurance, ou toute autre forme de garantie, garantissant les obligations locatives (ex. : garantie des risques locatifs, assurance privée) sauf si le logement est loué à un étudiant ou un apprenti.
Lorsque le bailleur est une personne morale, hors société immobilière familiale, une caution ne peut être demandée que si le logement est loué à un étudiant qui ne bénéficie pas d'une bourse de l'enseignement supérieur ou si elle est apportée par certains organismes.
Le bailleur ne peut enfin pas refuser la caution présentée au motif qu'elle ne possède pas la nationalité française ou qu'elle ne réside pas sur le territoire métropolitain.
Forme et contenu de l'acte de cautionnement :
L'acte de cautionnement doit être écrit et signé par la caution. Le bailleur doit remettre un exemplaire du contrat de location à la caution. L'acte de cautionnement doit obligatoirement comporter certaines informations : il doit faire apparaître le montant du loyer, les conditions de sa révision tels qu'ils figurent au contrat de location, et comporter la mention suivante : « Lorsque le cautionnement d'obligations résultant d'un contrat de location conclu en application du présent titre ne comporte aucune indication de durée ou lorsque la durée du cautionnement est stipulée indéterminée, la caution peut le résilier unilatéralement. La résiliation prend effet au terme du contrat de location, qu'il s'agisse du contrat initial ou d'un contrat reconduit ou renouvelé, au cours duquel le bailleur reçoit notification de la résiliation ».
En outre, la caution doit apposer elle-même la mention prévue par l'article 2297 du code civil selon laquelle elle s'engage en qualité de caution à payer au créancier ce que lui doit le débiteur en cas de défaillance de celui-ci, dans la limite d'un montant en principal et accessoires exprimé en toutes lettres et en chiffres.
Durée de l'engagement :
Engagement sans durée :
Si aucune durée d'engagement ne figure dans l'acte, la caution peut mettre fin à son engagement à tout moment par lettre recommandée avec avis de réception.
Toutefois, la résiliation signifiée au bailleur ne prend effet qu'à l'expiration du bail en cours.
En pratique, la caution qui résilie son engagement reste donc tenue des dettes locatives jusqu'à la fin du contrat de location en cours, elle n'en est plus tenue lorsque le bail est reconduit ou renouvelé.
Engagement à durée déterminée :
Quand une durée précise est indiquée dans l'acte de cautionnement, la caution ne peut pas résilier son engagement. Elle est tenue des dettes locatives jusqu'à la date initialement prévue.
Colocation (de logement loué nu ou meublé) (art. 8-1) :
Dans une colocation, les colocataires et les personnes qui se portent caution pour leur compte peuvent être solidairement tenus responsables du paiement du loyer. Lorsqu'un des colocataires donne congé, cette solidarité cesse lorsqu'un nouveau colocataire le remplace ou, à défaut de remplaçant, au bout de six mois après la fin du délai de préavis du congé.
Par ailleurs, l'engagement de la personne qui s'est portée caution pour le colocataire sortant cesse dans les mêmes conditions. A cet effet, l'acte de cautionnement doit nécessairement identifier un des colocataires.
1.5. Etat des lieux (art. 3-2)
Lors de la remise et de la restitution des clés, un état des lieux doit être établi selon les modalités définies par décret (9). Ce document décrit l'état du logement loué avec précision, ainsi que les équipements qu'il comporte. En comparant l'état des lieux dressé à l'arrivée et au départ du locataire, le bailleur pourra demander réparation des détériorations causées par le locataire. A défaut d'état des lieux d'entrée, le logement est présumé avoir été délivré en bon état sauf si le locataire en apporte la preuve contraire ou si le bailleur a fait obstacle à l'établissement de l'état des lieux.
Il est établi par écrit sur support papier ou sous forme électronique contradictoirement et amiablement par le bailleur et le locataire ou par un tiers mandaté par les deux parties. Un exemplaire doit être remis en main propre ou par voie dématérialisée à chacune des parties ou à leur mandataire, au moment de la signature. Le locataire peut demander au bailleur ou à son représentant de compléter l'état des lieux d'entrée dans les dix jours suivant sa date de réalisation pour tout élément concernant le logement, ou durant le premier mois de la période de chauffe concernant l'état des éléments de chauffage. Si le bailleur refuse de compléter l'état des lieux, le locataire peut saisir la commission départementale de conciliation dont dépend le logement.
Si les parties ne peuvent établir l'état des lieux de manière contradictoire et amiable (par exemple, si l'une des parties ne se présente pas ou si les parties ne s'accordent pas sur le contenu de l'état des lieux), le bailleur ou le locataire peut faire appel à un commissaire de justice pour l'établir. Ce dernier doit aviser les parties au moins sept jours à l'avance par lettre recommandée avec avis de réception. Dans ce cas, les frais de commissaire de justice sont partagés par moitié entre le bailleur et le locataire, les tarifs sont fixes et déterminés tous les deux ans par arrêté (10).
Location de logement meublé :
Au-delà de l'état des lieux, un inventaire et un état détaillé du mobilier doivent également être établis, sans que cela ne puisse donner lieu à une prise en charge financière supplémentaire du locataire (art. 25-5).
2. Droits et obligations des parties
Au-delà des conditions particulières prévues par le contrat de location, bailleurs et locataires sont soumis à un certain nombre d'obligations prévues par la loi durant l'exécution du contrat.
2.1. Obligations générales du bailleur (art. 6)
Délivrer un logement décent :
Le bailleur est tenu de remettre au locataire un logement décent, ne laissant pas apparaître de risques manifestes pouvant porter atteinte à la sécurité physique ou à la santé, exempt de toute infestation d'espèces nuisibles et parasites, répondant à un critère de performance énergétique minimale et doté des éléments de confort le rendant conforme à l'usage d'habitation.
Un décret (11) détermine le critère de performance énergétique à respecter et les caractéristiques relatives à la sécurité physique et à la santé des locataires, aux éléments d'équipements et de confort et à la surface et au volume habitable que doit respecter un logement décent.
A partir du 1er janvier 2023, et jusqu'au 1er janvier 2025, en France métropolitaine, ce critère de performance énergétique minimale est défini par un seuil maximal de consommation d'énergie finale par mètre carré et par an. Le logement doit avoir une consommation d'énergie, estimée par le diagnostic de performance énergétique, inférieure à 450 kilowattheures d'énergie finale par mètre carré de surface habitable et par an (12).
A compter du 1er janvier 2025, le logement doit répondre à un niveau de performance minimal compris, au sens de l'art. L. 173-1-1 du code de la construction et de l'habitation :
1° A compter du 1er janvier 2025, entre la classe A et la classe F du diagnostic de performance énergétique ;
2° A compter du 1er janvier 2028, entre la classe A et la classe E ;
3° A compter du 1er janvier 2034, entre la classe A et la classe D.
En Guadeloupe, en Martinique, en Guyane, à La Réunion et à Mayotte, le niveau de performance d'un logement décent est compris :
a) A compter du 1er janvier 2028, entre la classe A et la classe F ;
b) A compter du 1er janvier 2031, entre la classe A et la classe E.
Le logement mis en location qui ne répond pas aux critères précités aux échéances fixées est considéré comme non décent.
Ces échéances sont résumées dans le tableau suivant (France métropolitaine uniquement) :
Echéance
1er janvier 2023
1er janvier 2025
1er janvier 2028
1er janvier 2034
Critère ou niveau de performance énergétique minimal
Consommation en énergie finale
< 450 kWhEF/ m2/ an
Classe DPE F
Classe DPE E
Classe DPE D
Textes de référence
Article 17, loi n° 2019-1147 du 8 novembre 2019
Article 6, loi n° 89-462 du 6 juillet 1989 (version en vigueur au 1er janvier 2023)
Décret n° 2021-19 du 11 janvier 2021
Article 160, loi n° 2021-1104 du 22 août 2021
Article 6, loi n° 89-462 du 6 juillet 1989 (version en vigueur au 1er janvier 2025)
Application
Application aux nouveaux contrats de location et aux renouvellements ou reconductions tacites de contrats.
Le logement doit également assurer le clos et le couvert, disposer d'un gros œuvre en bon état d'entretien et de solidité, protéger le locataire contre l'humidité et les infiltrations d'eau et d'air parasites, permettre un renouvellement de l'air et une évacuation de l'humidité adaptés au regard des conditions d'occupation du logement et du fonctionnement des équipements, disposer de dispositifs de retenue dans un état conforme à leur usage, comporter des matériaux de construction, des canalisations et des revêtements ne présentant pas de risques manifestes pour la santé et la sécurité physique des locataires, disposer de réseaux, de branchements et d'équipements électriques et de gaz en bon état, être muni d'équipements de chauffage et de production d'eau chaude sanitaire conforme aux règles et normes de sécurité et être en bon état d'usage et de fonctionnement, disposer d'une aération et d'un éclairage suffisant.
Il doit disposer d'éléments d'équipements et de confort minimaux.
Il doit également disposer au moins d'une pièce principale ayant soit une surface habitable au moins égale à 9 mètres carrés et une hauteur sous plafond au moins égale à 2,20 mètres, soit un volume habitable au moins égal à 20 mètres cubes.
Des adaptations particulières de ces critères ont été définies pour certains départements et régions d'outre-mer.
Si le logement ne satisfait pas ces caractéristiques, le locataire peut demander au bailleur sa mise en conformité. A défaut de réponse de celui-ci à la demande de mise en conformité dans un délai de deux mois ou à défaut d'accord entre les parties, la commission départementale de conciliation peut être saisie par l'une ou l'autre des parties. Cette saisine est facultative. A défaut de saisine ou d'accord constaté par la commission, le juge peut être saisi du litige aux fins de déterminer la nature des travaux à réaliser et le délai de leur exécution.
Toutefois, à compter du 1er janvier 2023, le juge ne peut ordonner de mesure visant à permettre le respect du seuil maximal de consommation d'énergie finale lorsque le logement est en copropriété et que le copropriétaire concerné démontre que, malgré ses diligences en vue de l'examen de résolutions tendant à la réalisation de travaux relevant des parties communes ou d'équipements communs et la réalisation de travaux dans les parties privatives de son lot adaptés aux caractéristiques du bâtiment, il n'a pu parvenir à un niveau de consommation énergétique inférieur au seuil maximal. Au 1er janvier 2025, il ne peut également ordonner de travaux visant à permettre le respect du niveau de performance minimal lorsque le logement est soumis à des contraintes architecturales ou patrimoniales qui font obstacle à l'atteinte de ce niveau de performance minimal malgré la réalisation de travaux compatibles avec ces contraintes. Les critères relatifs à ces contraintes sont précisés par décret.
Délivrer un logement en bon état :
Le bailleur est tenu de remettre au locataire un logement en bon état d'usage et de réparations, et doté d'équipements en bon état de fonctionnement.
Si le logement n'est pas en bon état, les parties peuvent convenir, par une clause expresse insérée dans le bail, de travaux que le locataire exécutera ou fera exécuter en contrepartie d'une réduction de loyer pendant une période déterminée (cf. § 1.3.1.2.2). Une telle clause ne peut concerner que des logements répondant déjà aux caractéristiques de décence.
Entretenir le logement :
Le bailleur est tenu d'entretenir les locaux en état de servir à l'usage prévu par le contrat, et d'y faire toutes les réparations nécessaires au maintien en état et à l'entretien normal des locaux loués, en dehors de tout ce qui touche aux réparations locatives (menues réparations et entretien courant à la charge du locataire, cf. partie 2.2).
Aménagements réalisés par le locataire :
Le bailleur ne peut s'opposer aux aménagements que son locataire souhaite réaliser dans le logement, à condition qu'il ne s'agisse pas de travaux de transformation.
A titre d'exemple, la modification du coloris des peintures ou la pose du papier peint pourraient être considérés comme de simples aménagements du logement que le bailleur ne saurait interdire. En revanche, le fait d'abattre une cloison ou de transformer une chambre en cuisine constituerait une transformation nécessitant préalablement une autorisation écrite du bailleur.
Usage du logement :
Le bailleur doit assurer au locataire un usage paisible du logement. Le logement loué est le domicile du locataire. A ce titre, il en a la jouissance exclusive et peut utiliser les lieux librement dans le respect du contrat de location et, le cas échéant, d'un règlement intérieur à l'immeuble. Il peut aussi inviter ou héberger les personnes de son choix. Dès lors, le bailleur n'a pas le droit d'imposer un droit de visite à son locataire en dehors de certaines circonstances (vente du logement, départ du locataire …), d'interdire l'accès au logement à des personnes autres que la famille du locataire, de pénétrer dans le logement sans l'accord du locataire, de lui interdire d'avoir des animaux familiers, de lui interdire de fumer, etc.
En cas de troubles de voisinage causés par les personnes qui occupent ces locaux, le bailleur doit, après leur avoir adressé une mise en demeure dûment motivée, utiliser les droits dont il dispose en propre afin de faire cesser ces troubles de voisinage (art. 6-1).
Informations relatives à la consommation de chaleur, de froid et d'eau chaude sanitaire (art. 6-2) ainsi qu'à la qualité de l'eau (article 6-3) :
Lorsque l'immeuble est équipé d'une installation centrale de chauffage, de froid ou d'eau chaude sanitaire et muni des dispositifs d'individualisation des frais télé-relevables, le bailleur transmet au locataire une évaluation de la consommation de chaleur, de froid et d'eau chaude sanitaire de son local privatif, selon des modalités précisées par décret.
Lorsque le logement est situé dans un immeuble relevant du statut de la copropriété, le bailleur transmet au locataire les informations qu'il a reçues dans les conditions prévues par l'article 24-9 de la loi n° 65-557 du 10 juillet 1965 fixant le statut de la copropriété des immeubles bâtis.
Lorsque le contrat de fourniture d'eau n'est pas individualisé, le bailleur transmet au locataire la facture établie dans les conditions prévues à l'article L. 2224-12-1 du code général des collectivités territoriales, ainsi que les informations complémentaires sur la qualité de l'eau qui lui ont été adressées, concomitamment à la communication du décompte de charges ou, à défaut, au moins une fois par an.
Lorsque le logement est situé dans un immeuble relevant du statut de la copropriété, le bailleur transmet au locataire les informations qu'il a reçues dans les conditions prévues par l'article 24-11 de la loi n° 65-557 du 10 juillet 1965 fixant le statut de la copropriété des immeubles bâtis.
2.2. Obligations générales du locataire (art. 7)
Paiement du loyer et des charges :
Le locataire doit payer le loyer et les charges à la date prévue au contrat.
Le locataire ne doit en aucun cas cesser de payer de lui-même, sans autorisation d'un juge, tout ou partie de son loyer sous prétexte que le bailleur ne respecte pas ses obligations.
Utilisation du logement :
Le locataire jouit librement de son logement pendant toute la durée de la location, mais il doit respecter certaines obligations en la matière :
-le locataire est tenu d'utiliser paisiblement son logement et dans le respect de la tranquillité du voisinage ;
-le locataire doit veiller à respecter le règlement intérieur à l'immeuble (s'il existe), lorsque ce logement est situé dans un immeuble collectif (appartement) ;
-en copropriété, le bailleur est tenu de communiquer au locataire les extraits du règlement de copropriété concernant notamment la destination de l'immeuble comme la jouissance et l'usage des parties privatives et communes. Ces extraits du règlement de copropriété sont communiqués par voie dématérialisée, sauf opposition explicite de l'une des parties au contrat. Ce règlement peut par exemple interdire certaines pratiques (ex. : pose de jardinières, de linge aux fenêtres) ;
-le locataire doit respecter la destination prévue par le contrat de location (à usage d'habitation ou mixte professionnel et d'habitation). Par exemple, le local peut être loué à usage d'habitation uniquement, dès lors le locataire ne peut y exercer une activité commerciale ou professionnelle ;
-le locataire ne peut sous-louer tout ou partie de son logement sans l'accord écrit du bailleur. Dans l'hypothèse où le bailleur donne son accord, le montant du loyer au mètre carré de surface habitable appliqué au sous-locataire ne peut en aucun cas excéder celui payé par le locataire principal. Le locataire est également tenu de transmettre au sous-locataire l'autorisation écrite du bailleur et la copie du bail en cours.
Travaux :
-le locataire a le droit d'aménager librement le logement qu'il occupe (changement de moquette, pose de papier peint, modification des peintures …). Il ne peut toutefois y faire de travaux de transformation sans l'accord écrit du bailleur. A défaut, celui-ci peut, lors du départ du locataire, conserver le bénéfice des transformations sans indemnisation ou exiger la remise en état des lieux. En cas de litige, le caractère des travaux (simple aménagement ou transformation) relève de l'appréciation du juge. Toutefois, le locataire peut réaliser à ses frais des travaux d'adaptation du logement aux personnes en situation de handicap ou de perte d'autonomie, ou des travaux de rénovation énergétique, sans autorisation expresse du bailleur. Dans ces deux hypothèses, le locataire en fait la demande écrite par lettre recommandée avec demande d'avis de réception auprès du bailleur, et sans réponse dans un délai de deux mois, le silence du bailleur vaut accord. Au départ du locataire, le bailleur ne peut pas exiger la remise des lieux en l'état (la liste des travaux concernés est fixée par décrets [13]) ;
-le locataire doit laisser exécuter dans son logement certains travaux décidés par le bailleur : les réparations urgentes, les travaux d'amélioration ou d'entretien du logement loué ou des parties communes, les travaux d'amélioration de la performance énergétique et les travaux de mise aux normes de décence. Avant le début des travaux, le bailleur est tenu de remettre au locataire une notification de travaux, par lettre remise en main propre ou par courrier recommandé avec avis de réception, précisant leur nature et les modalités de leur exécution. Pour la préparation et la réalisation de ces travaux, le locataire est tenu de permettre l'accès à son logement tous les jours sauf les samedis, dimanches et jours fériés (à moins qu'il ne donne son accord pour que les travaux soient réalisés pendant ces jours). A noter qu'en copropriété, les travaux nécessitant l'accès aux parties privatives doivent être notifiés au bailleur (et non au locataire) par le syndic de l'immeuble au moins huit jours avant leur réalisation, sauf impératif de sécurité ou de conservation des biens.
Si les travaux durent plus de vingt et un jours et affectent l'utilisation du logement, une réduction de loyer doit être appliquée au profit du locataire. En cas d'abus (travaux à caractère vexatoire, ne respectant pas les conditions prévues dans la notification de travaux, rendant l'utilisation du logement impossible ou dangereuse), le locataire peut également saisir le juge pour interrompre ou interdire les travaux.
Entretien :
Le locataire doit veiller à maintenir en l'état le logement qu'il occupe. A ce titre :
-le locataire doit prendre en charge l'entretien courant, les menues réparations du logement et des équipements mentionnés dans le contrat (ex. : maintien en état de propreté, remplacement d'ampoules, etc.) ainsi que l'ensemble des réparations locatives dont la liste est définie par décret (14) sauf si elles sont occasionnées par vétusté, malfaçon, vice de construction, cas fortuit ou force majeure. Dans ce cas, les réparations incombent au bailleur ;
-le locataire est responsable des dégradations ou pertes qui pourraient survenir en cours de bail dans le logement, à moins qu'il ne prouve qu'elles ont eu lieu par cas de force majeure, par la faute du bailleur ou par la faute d'un tiers qu'il n'a pas introduit dans le logement.
Assurance :
Le locataire est tenu de s'assurer contre les risques locatifs (principalement dégâts des eaux, incendie, explosion) et de le justifier lors de la remise des clefs puis chaque année à la demande du bailleur, par la remise d'une attestation. S'il ne le fait pas, le bailleur peut demander la résiliation du bail ou souscrire une assurance à la place du locataire en lui répercutant le montant de la prime. Si le locataire n'arrive pas à trouver une assurance habitation, il peut se rapprocher du Bureau central de tarification afin de désigner un assureur le garantissant au titre de sa responsabilité civile (Bureau central de tarification, 1, rue Jules-Lefebvre, 75009 Paris, 01-53-21-50-40, bct @ agira. asso. fr).
En cas de colocation, dans un logement nu ou meublé, les parties peuvent convenir dès la conclusion du contrat de la souscription par le bailleur d'une assurance pour le compte des colocataires.
2.3. Obligations des parties en matière de lutte contre les nuisibles
Au titre de ses obligations en matière de décence du logement, le bailleur est tenu de remettre au locataire un logement exempt de toute infestation d'espèces nuisibles et parasites (art. 6). A ce titre, le bailleur est tenu de maintenir le logement en état de décence tout au long du bail.
Afin d'éviter l'infestation de son logement par des punaises de lit, le locataire est cependant invité à suivre les recommandations et mesures préventives consultables sur le site internet stop-punaises. gouv. fr.
En cas d'infestation du logement en cours de bail, une action rapide est nécessaire. Dans ce cas, le locataire est invité à adopter les gestes simples recommandés pour traiter et endiguer l'infestation et à contacter immédiatement le bailleur afin de lui demander de procéder au traitement des lieux ou s'entendre avec lui pour sa prise en charge financière.
En cas de désaccord ou de silence du bailleur, le locataire peut saisir la commission départementale de conciliation en application des dispositions de l'art. 20-1 de la loi du 6 juillet 1989 susvisée.
Le locataire doit permettre l'accès aux lieux loués pour les travaux nécessaires à la désinsectisation par la société mandatée pour intervenir. Il doit également suivre les recommandations données pour traiter la situation et maintenir le logement exempt d'infestation.
Pour une information juridique sur les punaises de lit, tant le bailleur que le locataire peuvent contacter notamment le numéro de téléphone 0806 706 806, qui leur permettra d'échanger avec un conseiller d'Agence départementale d'information sur le logement (ADIL).
3. Fin de contrat et sortie du logement
3.1. Congés (art. 15 et 25-8)
Les parties peuvent unilatéralement, dans les conditions déterminées par la loi, mettre fin au contrat de location et donner congé.
3.1.1. Congé délivré par le locataire
Motifs : le locataire peut donner congé à tout moment et sous réserve de respecter certaines conditions de forme. Le congé est effectif à l'expiration d'un délai de préavis variable selon les circonstances de son départ.
Forme : le locataire qui souhaite quitter son logement doit notifier son congé au bailleur par lettre recommandée avec avis de réception, acte de commissaire de justice, ou lettre remise en main propre contre émargement ou récépissé.
Effets : la réception de la lettre de congé par le bailleur fait courir un délai de préavis pendant lequel le locataire reste tenu au paiement de son loyer, même s'il a déjà quitté le logement, sauf si, en accord avec le locataire sortant, un nouveau locataire occupe le logement avant la fin du délai de préavis.
Durée du délai de préavis :
Location nue :
Le délai de préavis est en principe de trois mois. Ce délai est réduit à un mois lorsque le logement est situé dans des zones de tension du marché locatif ou lorsque le locataire justifie d'une des situations suivantes : obtention d'un premier emploi, de mutation, de perte d'emploi ou de nouvel emploi consécutif à une perte d'emploi, état de santé justifiant un changement de domicile et constaté par un certificat médical, bénéfice du revenu de solidarité active ou de l'allocation adulte handicapé, attribution d'un logement social, violences familiales lorsque le locataire est bénéficiaire d'une ordonnance de protection ou lorsque son conjoint, partenaire ou concubin fait l'objet de poursuites ou d'une condamnation. Le locataire doit alors préciser dans tous les cas le motif permettant de bénéficier de la réduction du délai de préavis et le justifier à l'occasion de la notification de congé. À défaut, le préavis de trois mois s'applique.
Location meublée :
Le délai de préavis est d'un mois.
3.1.2. Congé délivré par le bailleur
Motifs : le bailleur peut donner congé à son locataire à l'échéance du bail et dans trois cas :
1. Le bailleur souhaite reprendre le logement pour l'occuper, à titre de résidence principale, ou y loger un proche (conjoint, partenaire de PACS, concubin notoire depuis au moins un an à la date du congé, ascendants et descendants du bailleur ou de son conjoint, de son partenaire ou de son concubin notoire). Dans ce cas, il doit justifier du caractère réel et sérieux de sa décision de reprise.
2. Le bailleur souhaite vendre le logement inoccupé. Dans le cas d'un logement non meublé (art. 15 II), le locataire est alors prioritaire pour acquérir le logement. Le bailleur doit lui donner congé en lui indiquant le prix et les conditions de la vente. Cela constitue une offre de vente.
3. Le bailleur met fin au bail pour un motif légitime et sérieux : non-respect par le locataire de l'une de ses obligations, retards répétés de paiement des loyers, défaut d'entretien du logement, troubles de voisinage, etc.
Des restrictions peuvent s'appliquer quant à la possibilité pour le bailleur de donner congé, notamment en fonction du niveau de ressources et de l'âge du locataire ou, en location nue, lorsque le congé du bailleur est consécutif à l'acquisition d'un logement occupé.
Forme et délais :
La notification du congé doit être adressée à chacun des cotitulaires du bail par lettre recommandée avec avis de réception, acte de commissaire de justice, ou lettre remise en main propre contre récépissé. Le délai de préavis court à compter de la réception du courrier ou du passage du commissaire de justice. La notification contient le motif du congé et les mentions obligatoires en fonction du motif invoqué.
Location nue :
Le préavis doit être délivré au moins six mois avant la fin du bail. Le bailleur doit joindre une notice d'information définie par arrêté ministériel et relative à ses propres obligations et aux voies de recours et d'indemnisation du locataire en cas de congé pour reprise ou vente.
Location meublée :
Le préavis doit être délivré au moins trois mois avant la fin du bail.
Effet : si le locataire quitte le logement avant l'expiration du préavis, le locataire n'est redevable du loyer et des charges que pour le temps réel d'occupation du logement.
Sanction du congé frauduleux : si le motif du congé ne correspond pas à la réalité, le locataire peut le contester devant le juge. Par ailleurs, le bailleur qui délivre un congé pour vendre ou pour habiter frauduleusement risque une amende pénale pouvant aller jusqu'à 6 000 € ou 30 000 € s'il s'agit d'une personne morale.
3.2. Sortie du logement
3.2.1. Etat des lieux de sortie
A la remise des clés par le locataire, un état des lieux de sortie doit être établi entre le bailleur et le locataire. Les modalités d'établissement de l'état des lieux de sortie sont identiques à celles applicables lors de l'état des lieux d'entrée.
Cependant, dans le cadre d'un état des lieux amiable et lorsque le bailleur mandate un tiers pour le représenter (par exemple, un professionnel de l'immobilier), aucun frais ne peut être facturé au locataire.
3.2.2. Restitution du dépôt de garantie (art. 22)
Délais : le dépôt de garantie doit être restitué dans un délai maximal de deux mois à compter de la remise des clés par le locataire, déduction faite le cas échéant des sommes justifiées dues au titre des loyers, charges, réparations locatives ou des dégradations dont il pourrait être tenu responsable. Le délai s'apprécie à compter du jour de la restitution des clés par le locataire qui peut les remettre en main propre ou par lettre recommandée avec demande d'avis de réception, au bailleur ou à son mandataire. A cette occasion, le locataire indique au bailleur, ou à son mandataire, sa nouvelle adresse.
Dans certains cas particuliers, des aménagements sont prévus au délai de restitution du dépôt de garantie :
-si l'état des lieux de sortie est conforme à l'état des lieux d'entrée, le délai est réduit à un mois ;
-si le logement est situé dans un immeuble collectif, le bailleur peut conserver une provision maximale de 20 % du dépôt de garantie jusqu'au mois suivant l'approbation des comptes annuels de l'immeuble.
Le bailleur est tenu de justifier la retenue qu'il opère sur le dépôt de garantie par la remise au locataire de documents tels que l'état des lieux d'entrée et de sortie, des factures, devis, lettres de réclamation des loyers impayés restées sans réponse, etc. ;
-sanction de la restitution tardive du dépôt de garantie : à défaut de restitution du dépôt de garantie dans les délais impartis, le montant dû au locataire est majoré d'une somme égale à 10 % du loyer mensuel, pour chaque mois de retard commencé. Cette majoration n'est pas due lorsque l'origine du défaut de restitution dans les délais résulte de l'absence de transmission par le locataire de sa nouvelle adresse.
En cas de litige relatif au dépôt de garantie, la commission départementale de conciliation pourra être saisie.
4. Départ du conjoint victime de violences (art. 8-2)
Lorsque le conjoint du locataire, son partenaire lié par un pacte civil de solidarité ou son concubin notoire quitte le logement en raison de violences exercées au sein du couple ou sur un enfant qui réside habituellement avec lui, il en informe le bailleur par lettre recommandée avec demande d'avis de réception, accompagnée de la copie de l'ordonnance de protection délivrée par le juge dont il bénéficie et préalablement notifiée à l'autre membre du couple ou de la copie d'une condamnation pénale de ce dernier pour des faits de violences commis à son encontre ou sur un enfant qui réside habituellement avec lui et rendue depuis moins de six mois.
La solidarité du locataire victime des violences et celle de la personne qui s'est portée caution pour lui prennent fin le lendemain du jour de la première présentation du courrier au domicile du bailleur, pour les dettes nées à compter de cette date.
Le fait pour le locataire auteur des violences de ne pas acquitter son loyer à compter de la date mentionnée au deuxième alinéa est un motif légitime et sérieux.
5. Règlement des litiges locatifs
En cas de conflit, les parties peuvent tenter de trouver une solution amiable. En cas d'échec de résolution amiable du litige, le tribunal compétent peut être saisi pour le trancher.
5.1. Règles de prescriptions (art. 7-1)
En matière locative, la durée au-delà de laquelle une action en justice n'est plus recevable est en principe de trois ans. Ainsi, sauf interruption ou suspension des délais, le bailleur peut récupérer judiciairement les arriérés de charges et de loyer pendant trois ans à compter de leur exigibilité.
Il existe une exception à ce principe en matière d'action en révision de loyer, le bailleur ne disposant que d'un délai d'un an pour demander en justice le paiement du différentiel de loyer issu de la révision. En effet, à défaut d'avoir manifesté sa volonté d'appliquer la révision dans un délai d'un an suivant sa prise d'effet, celui-ci est réputé avoir renoncé à la clause de révision.
5.2. Règlement amiable et conciliation
Lorsqu'il existe un litige, il est conseillé d'adresser un courrier recommandé avec accusé de réception à l'autre partie relatant les faits le plus précisément possible, accompagné des arguments et preuves relatifs aux faits litigieux (références juridiques, factures, photos, etc.).
Ce courrier est une première étape préalable importante pour engager ensuite un recours éventuel devant le juge. Si le litige n'est pas résolu, il peut être utile de saisir la commission départementale de conciliation.
Voies de conciliation : les commissions départementales de conciliation (art. 20) :
Présentation : pour régler certains litiges entre le bailleur et le locataire, il est possible de faire appel localement aux commissions départementales de conciliation (CDC), présentes dans chaque département et placées auprès des services de l'Etat. Cette commission est composée à parts égales de représentants des bailleurs et de représentants des locataires. Elle est compétente pour connaître des litiges de nature individuelle et des difficultés de nature collective. Toute démarche devant la CDC est gratuite.
Rôle : la CDC s'efforce de concilier les parties en établissant un dialogue, afin d'éviter le recours au juge. La CDC doit traiter les litiges qui lui sont soumis dans un délai de deux mois à compter de sa saisine.
Le champ de compétence de la CDC est large.
La saisine de la CDC constitue une étape obligatoire avant toute saisine du juge pour les litiges relatifs à l'ajustement des loyers au renouvellement du bail (cf. § 1.3.1.3), pour certains litiges relatifs à l'encadrement des loyers (contestation du complément de loyer, action en diminution ou en réévaluation de loyer au renouvellement du bail) et pour ceux relatifs aux loyers déterminés en application du décret fixant annuellement le montant maximum d'évolution des loyers dans les zones tendues.
Elle intervient aussi de manière facultative pour les litiges relatifs aux thèmes suivants : dépôt de garantie, état des lieux, fixation et révision des charges locatives, réparations, décence, congés.
Enfin, la CDC est compétente pour les différends de nature collective : application des accords collectifs nationaux ou locaux, des plans de concertation locative, difficultés de fonctionnement d'un immeuble ou d'un groupe d'immeubles.
Modalités de saisine : la CDC peut être saisie par le bailleur ou le locataire concerné, lorsqu'il s'agit d'un litige de nature individuelle et par le bailleur, plusieurs locataires ou une association représentative des locataires, lorsqu'il s'agit d'une difficulté de nature collective.
La saisine de la commission doit être formulée en double exemplaire et adressée par lettre recommandée avec avis de réception au secrétariat de la commission. La saisine doit indiquer les nom, qualité et adresse du demandeur, ceux du défendeur ainsi que l'objet du litige ou de la difficulté. Dans tous les cas, la lettre de saisine doit être accompagnée de la copie des pièces en lien avec le litige (le bail, le courrier du bailleur proposant l'augmentation, etc.).
Déroulement des séances : le bailleur et le locataire en conflit sont convoqués, en personne, par lettre à une séance de conciliation au minimum quinze jours avant la séance.
Ils peuvent se faire assister par une personne de leur choix ou se faire représenter par une personne dûment mandatée. Si la demande a été introduite par une association ou par plusieurs locataires, seuls sont convoqués à la séance leurs représentants (2 au maximum), dont les noms auront été communiqués au préalable au secrétariat de la CDC.
Chaque partie expose son point de vue et la CDC aide les parties à trouver une solution à leur problème.
Si un accord intervient, les termes de la conciliation font l'objet d'un document signé par chacune des parties.
En l'absence d'accord, la CDC rend un avis qui constate la situation et fait apparaître les points essentiels de la position de chacun, ainsi que, le cas échéant, sa position.
5.3. Action en justice
5.3.1. Généralités
Si le locataire ou le bailleur ne respecte pas ses obligations, l'autre partie peut exercer une action en justice auprès du tribunal compétent pour l'obliger à exécuter son obligation, pour demander la résiliation du contrat ou pour demander une indemnisation.
Tout litige relatif à un bail d'habitation relève exclusivement du tribunal judiciaire dans le ressort duquel se situe le logement.
Lorsqu'un ou plusieurs locataires ont avec un même bailleur un litige locatif ayant une origine commune, ils peuvent donner par écrit mandat d'agir en justice en leur nom et pour leur compte à une association siégeant à la Commission nationale de concertation.
Si le litige porte sur les caractéristiques du logement pour en faire un logement décent, ce mandat peut être donné à une des associations précitées ou à une association compétente en matière d'insertion ou de logement des personnes défavorisées, à une collectivité territoriale compétente en matière d'habitat ou à un organisme payeur des aides au logement (CAF ou MSA).
5.3.2. Résiliation judiciaire et de plein droit (art. 24)
Des modalités particulières de résiliation du bail sont prévues par la loi lorsque le bailleur est à l'initiative de la demande judiciaire de résiliation du bail.
5.3.2.1. Mise en œuvre de la clause résolutoire de plein droit
Une clause du contrat de location, appelée clause résolutoire, peut prévoir sa résiliation de plein droit en cas d'inexécution des obligations du locataire, dans les quatre cas suivants :
-défaut de paiement des loyers et des charges locatives au terme convenu ;
-non-versement du dépôt de garantie ;
-défaut d'assurance du locataire contre les risques locatifs ;
-troubles de voisinage constatés par une décision de justice passée en force de chose jugée rendue au profit d'un tiers.
En pratique, le bailleur devra assigner le locataire devant le tribunal pour faire constater l'acquisition de la clause résolutoire et la résiliation de plein droit du bail qui pourra ensuite déboucher sur l'expulsion du locataire. Les modalités de mise en œuvre de cette clause varient selon la nature de la faute imputée au locataire.
Lorsque le bailleur souhaite mettre en œuvre la clause résolutoire pour défaut de paiement des loyers et des charges ou pour non-versement du dépôt de garantie, il doit préalablement faire signifier au locataire, par acte d'un commissaire de justice, un commandement de payer, qui doit mentionner certaines informations et notamment la faculté pour le locataire de saisir le fonds de solidarité pour le logement (FSL) (cf. § 5.4.1.1). De plus, pour les bailleurs personnes physiques ou les sociétés immobilières familiales, le commandement de payer doit être signalé par le commissaire de justice à la commission de coordination des actions de prévention des expulsions locatives (cf. § 5.4.2) dès lors que l'un des seuils relatifs au montant ou à l'ancienneté de la dette, fixé par arrêté préfectoral, est atteint.
L'objectif du commandement de payer est de permettre au locataire en situation d'impayé de loyer de rembourser sa dette locative dans l'intervalle de deux mois qu'il lui est accordé, afin d'éviter la résiliation de son bail et l'engagement d'une procédure judiciaire d'expulsion par son bailleur. Pour apurer sa dette locative, le locataire peut saisir le fonds de solidarité pour le logement de son département et/ ou la commission de surendettement de son lieu de résidence. L'obtention d'une décision de prise en charge de la dette locative par l'un de ces dispositifs ou le remboursement effectif de la dette par tout autre moyen avant la fin du délai de deux mois du commandement de payer permet au locataire de conserver son bail et de se maintenir dans le logement. En revanche, si le locataire ne s'est pas acquitté des sommes dues dans les deux mois suivant la signification, le bailleur peut alors assigner le locataire en justice pour faire constater la résiliation de plein droit du bail.
En cas de défaut d'assurance, le bailleur ne peut assigner en justice le locataire pour faire constater l'acquisition de la clause résolutoire qu'après un délai d'un mois après un commandement demeuré infructueux.
5.3.2.2. Résiliation judiciaire
Si le bailleur renonce à la mise en œuvre de la clause résolutoire, ou si celle-ci n'est pas prévue au bail ou si le litige a pour origine des motifs non couverts par la clause résolutoire, le bailleur peut saisir directement le juge pour demander la résiliation judiciaire du bail et procéder ensuite à l'expulsion du locataire. Le juge dispose alors d'un large pouvoir pour apprécier si la faute du locataire est d'une gravité suffisante pour justifier la résiliation du bail.
5.3.2.3. Assignation aux fins de résiliation du bail et pouvoirs du juge
L'assignation est la procédure obligatoire de saisine du juge lorsque le bailleur souhaite procéder à l'expulsion de son locataire. Il s'agit d'un acte établi et délivré par un commissaire de justice, par lequel le demandeur (le bailleur) prévient la partie adverse (le locataire) qu'une procédure judiciaire est ouverte contre lui afin de procéder à son expulsion.
Le bailleur personne morale, autre que les sociétés immobilières familiales, ne peut assigner à cette fin son locataire qu'après avoir saisi, au moins deux mois auparavant, la commission de coordination des actions de prévention des expulsions locatives (CCAPEX) (cf. § 5.4.2). Cette saisine est considérée comme réalisée lorsque le bailleur a effectué préalablement un signalement à la CAF/ CMSA afin d'assurer le maintien des aides au logement de son locataire.
Quand l'assignation est fondée sur une dette locative, cette assignation doit être notifiée par le commissaire de justice au préfet de département deux mois avant l'audience judiciaire. Ce délai de deux mois est prévu afin de permettre la réalisation par le locataire de démarches visant à prévenir son expulsion.
Il permet notamment la réalisation du diagnostic social et financier de la situation du locataire par un intervenant social ou juridique dans le cadre d'un entretien dédié, auquel il est important que le locataire participe. Ce diagnostic social et financier est transmis au juge avant l'audience afin qu'il puisse disposer des éléments nécessaires à la prise de sa décision : compréhension des éléments à l'origine de la dette locative, démarches réalisées par le locataire pour apurer sa dette, pour accéder à d'éventuelles prestations sociales ou rechercher un nouveau logement.
Le délai de deux mois avant l'audience permet également au locataire de solliciter l'assistance d'un avocat et, au besoin, si ses revenus ne le lui permettent pas, le bureau de l'aide juridictionnelle compétent pour son domicile afin de pouvoir bénéficier d'une prise en charge partielle ou totale des frais d'avocat par l'Etat.
A l'issue du délibéré qui suit l'audience, le juge peut :
-accorder au locataire des délais de paiement, pouvant aller jusqu'à trois ans, pour régler sa dette locative, s'il est en mesure de reprendre le paiement du loyer au jour de l'audience et qu'il dispose d'une capacité de remboursement. En plus d'une reprise effective du paiement du loyer, le dépôt d'une demande de logement social ainsi que la saisine préalable d'un dispositif d'apurement de la dette locative (FSL, surendettement) pourra être pris en compte par le juge pour décider de l'octroi de ces délais de paiement. Si le locataire s'acquitte chaque mois sans défaut du remboursement de sa dette dans le délai et selon les modalités fixées par le juge, en plus du paiement régulier de son loyer, le bail n'est pas résilié et l'expulsion n'est pas réalisée. Tout retard ou défaut de paiement de l'intégralité du loyer et de la dette entraîne à l'inverse la résiliation du bail et la reprise immédiate de la procédure d'expulsion ;
-ordonner l'expulsion assortie ou non d'un délai de grâce pour quitter les lieux (trois mois à trois ans) (article L. 412-1 et suivants du code des procédures civiles d'exécution). La décision de justice est signifiée au locataire après le délibéré, par un commissaire de justice.
5.4. Prévention des expulsions
Des dispositifs de prévention des expulsions ont été institués afin de traiter notamment le plus en amont les situations d'impayés locatifs. Au sein des différentes actions mises en place sur le territoire départemental, deux dispositifs sont susceptibles d'être mobilisés plus particulièrement.
5.4.1. Les dispositifs de traitement de la dette locative
5.4.1.1. Le fonds de solidarité pour le logement
Le fonds de solidarité pour le logement (FSL) a été constitué dans chaque département afin d'accorder des aides financières, sous forme de prêts, de subventions ou de garantie, aux personnes ayant de faibles ressources qui rencontrent des difficultés pour assurer leurs dépenses de logement (factures, loyers …).
Les aides du FSL peuvent notamment permettre d'aider au paiement :
-du dépôt de garantie, du premier loyer, de l'assurance du logement ;
-des dettes de loyers et de charges en vue du maintien dans le logement ou en vue de faciliter l'accès à un nouveau logement.
L'aide au maintien permet de prendre en charge la dette locative des locataires dont la situation financière s'est rétablie ou est sur le point de se rétablir. Elle vise à maintenir le locataire dans son logement. En plus des conditions d'attribution propres au département, cette aide ne peut être mobilisée que si le locataire a repris le paiement de son loyer et dispose des ressources nécessaires pour garantir le paiement sur la durée. Cette aide au maintien peut légalement être sollicitée dès le premier mois de la reprise du paiement du loyer, voire dès que la perspective de reprise est certaine à court terme (par exemple, en cas de signature d'un contrat de travail ou de prise de poste dans un nouvel emploi ou dans l'attente du versement certain du premier salaire à court terme).
L'aide « apurement-relogement » permet de prendre en charge la dette des locataires dont la situation financière ne s'est pas rétablie. Elle vise à assurer leur relogement lorsqu'ils ne peuvent plus se maintenir dans leur logement actuel. Cette aide doit être mobilisée afin de faciliter l'attribution d'un nouveau logement au locataire menacé d'expulsion, dans le parc social ou privé ;
-des impayés de factures d'eau et d'énergie. Cette aide permet la prise en charge des impayés de charges, d'eau, d'électricité, de gaz qui peuvent constituer tout ou partie de la dette locative du locataire assigné en justice.
Chaque département a ses propres critères d'attribution des aides. Il est notamment tenu compte des ressources de toutes les personnes composant le foyer. Pour en bénéficier, il convient de s'adresser aux services sociaux de son conseil départemental.
5.4.1.2. La commission de surendettement
La commission de surendettement peut proposer plusieurs modalités efficaces de prise en charge de la dette locative : plan d'apurement, effacement (plan de rétablissement personnel), effacement partiel, moratoire (suspension de l'exigibilité des créances).
La seule recevabilité du dossier de surendettement constitue une prise en charge de la dette locative répondant à l'injonction du commandement de payer.
Rappels :
-la dette locative est traitée en priorité par rapport aux autres dettes dans le cadre du surendettement. Cela signifie que toute capacité de remboursement disponible est affectée en priorité au remboursement de cette dette et, à l'inverse, qu'elle est effacée en dernier par rapport aux autres créances ;
-une fois le dossier de surendettement déposé, la commission de surendettement dispose de trois mois pour se prononcer sur la recevabilité de la demande.
5.4.2. La commission de coordination des actions de prévention des expulsions locatives (CCAPEX)
Dans chaque département, la CCAPEX a pour mission de délivrer des avis et des recommandations à tout organisme ou personne susceptible de contribuer à la prévention de l'expulsion ainsi qu'aux bailleurs et aux locataires concernés par une situation d'impayé ou de menace d'expulsion quel que soit le motif. Elle émet également des avis et recommandations à l'attention des instances compétentes pour l'attribution d'aides financières ou pour l'accompagnement social des personnes en situation d'impayé.
Pour exercer ses missions, elle est informée de l'existence d'un impayé de loyer dès la délivrance du commandement de payer (cf. § 5.3.2.1) puis de son évolution au stade de l'assignation, du commandement de quitter les lieux et enfin de la réquisition du concours de la force publique par le bailleur. En outre, elle peut être saisie par les organismes payeurs des aides personnelles au logement ou directement par le bailleur ou le locataire. Il convient de s'adresser à la préfecture de département pour connaître les coordonnées et modalités de saisine.
5.5. Recherche d'un nouveau logement plus adapté aux ressources du locataire et droit au logement opposable (DALO)
Le risque d'expulsion que représente tout impayé locatif, quelles que soient les démarches engagées pour y remédier, ou toute assignation en justice en vue de la résiliation du bail doit être anticipé par le locataire qui doit engager le plus tôt possible des démarches de relogement dans le parc privé mais aussi dans le parc social. A cette fin, le locataire peut déposer une demande de logement social en ligne ou auprès des services sociaux de son lieu de domicile dès que sa capacité de maintien dans son logement est compromise, notamment en raison d'une inadaptation de son loyer avec ses ressources.
Le dépôt d'une demande de logement social avant l'audience judiciaire ouvre entre autre la possibilité pour le locataire de déposer ultérieurement, uniquement après réception de la décision judiciaire et dans le cas où le juge prononce l'expulsion du locataire, un recours amiable dans le cadre du DALO afin d'être reconnu prioritaire au titre de celui-ci.
En effet, si le locataire dispose d'une demande de logement social valide et que le juge ordonne son expulsion, il peut déposer un recours amiable auprès de la commission de médiation de son département afin d'être reconnu prioritaire et à reloger d'urgence par l'Etat dans le cadre du DALO. La décision de justice prononçant l'expulsion doit impérativement être jointe au recours amiable afin de démontrer l'existence d'une menace effective d'expulsion.
5.6. Procédure d'expulsion
Validité de l'expulsion :
Le locataire ne peut être expulsé que sur le fondement d'une décision de justice ou d'un procès-verbal de conciliation exécutoire. Cette décision peut, notamment, faire suite au refus du locataire de quitter les lieux après un congé délivré régulièrement par le bailleur, à la mise en œuvre de la clause résolutoire ou à une résiliation judiciaire du contrat.
Le bailleur ne doit en aucun cas agir sans décision de justice, même avec l'assistance d'un commissaire de justice. Ainsi, le bailleur qui procède lui-même à l'expulsion d'un locataire indélicat est passible de trois ans de prison et de 30 000 € d'amende.
Par ailleurs, suite à la décision de justice d'expulsion, le bailleur est tenu de signifier au locataire, par un commissaire de justice, un commandement de quitter les lieux. L'expulsion ne peut avoir lieu qu'après un délai de deux mois suivant le commandement, ce délai peut être modifié par le juge dans les conditions prévues notamment à l'article L. 412-1 du code des procédures civiles d'exécution.
A l'issue du commandement de quitter les lieux, si le locataire n'a pas quitté son logement, le commissaire de justice qui le constate dresse un procès-verbal et sollicite les services du préfet afin de procéder à l'exécution de la décision de justice avec le concours de la force publique. L'Etat est en effet tenu de prêter son concours à l'exécution des jugements à moins qu'un motif tiré de l'ordre public ne l'autorise à refuser son concours. Le préfet dispose de deux mois pour rendre sa décision. Au cours de ce délai, le locataire est susceptible d'être convoqué au commissariat de police ou à la gendarmerie de son lieu de résidence afin de transmettre toutes les informations susceptibles d'éclairer la prise de décision du préfet.
En cas de refus du préfet d'octroyer le concours de la force publique, ou à l'expiration du délai de deux mois suivant le dépôt de la demande de concours, le bailleur peut effectuer une demande gracieuse d'indemnisation auprès du préfet, puis devant le tribunal administratif pour obtenir réparation du préjudice.
Trêve hivernale :
Durant la période dite de trêve hivernale, qui court du 1er novembre au 31 mars, les locataires sont protégés et ne peuvent être expulsés. Ce délai supplémentaire doit être mis à profit pour activer tous les dispositifs de relogement. Cette trêve signifie que tout jugement ordonnant l'expulsion d'un locataire, passée en force de chose jugée, ne peut être exécuté de force pendant cette période de grâce. Elle s'applique même si le juge a ordonné des délais pour exécuter l'expulsion et que ces délais ont expiré, sauf si l'expulsion est engagée à l'encontre :
-d'un locataire qui loue un logement dans un immeuble qui a fait l'objet d'un arrêté de péril ;
-ou d'un locataire dont l'expulsion est assortie d'un relogement correspondant à ses besoins familiaux (le nombre de pièces doit correspondre au nombre d'occupants).
En revanche, la trêve hivernale n'interdit pas aux bailleurs de demander et d'obtenir un jugement d'expulsion, ni même de signifier au locataire le commandement de quitter les lieux. En effet, seules les mesures d'exécution de l'expulsion avec le concours de la force publique sont suspendues.
6. Contacts utiles
Les ADIL : les agences départementales d'information sur le logement apportent au public un conseil gratuit, neutre et personnalisé sur toutes les questions juridiques, financières et fiscales relatives au logement et leur proposent des solutions adaptées à leur cas personnel. Les ADIL assurent ainsi de nombreuses permanences sur l'ensemble du territoire (http :// www. anil. org/ votre-adil/).
Les organismes payeurs des aides au logement :
-Caisses d'allocations familiales (CAF) ;
-Mutualité sociale agricole (MSA).
Les associations représentatives des bailleurs et des locataires :
Au plan local, il existe de nombreuses organisations ayant pour objet de représenter et de défendre l'intérêt des locataires ou des bailleurs.
Sont également considérées comme représentatives au niveau national les organisations représentatives des locataires et des bailleurs suivantes, qui siègent à ce titre à la commission nationale de concertation :
-organisations nationales représentatives des bailleurs :
-l'Union pour le développement du logement intermédiaire (UDLI) ;
-la Fédération des entreprises immobilières (FEI) ;
-l'Union nationale des propriétaires immobiliers (UNPI) ;
-organisations nationales représentatives des locataires :
-la Confédération nationale du logement (CNL) ;
-la Confédération générale du logement (CGL) ;
-la Confédération syndicale des familles (CSF) ;
-la Confédération de la consommation, du logement et du cadre de vie (CLCV) ;
-l'Association force ouvrière consommateurs (AFOC).
Maison de justice et du droit et point d'accès au droit : les maisons de justice et du droit et les points d'accès au droit accueillent gratuitement et anonymement les personnes rencontrant un problème juridique et/ ou administratif. Le site http :// www. annuaires. justice. gouv. fr vous permet de connaître, à partir de votre code postal, le point d'accès au droit ou la maison de justice et du droit le plus proche de votre domicile.
Information administrative :
-ministère de la transition écologique et de la cohésion des territoires/ ministère de la transition énergétique : https :// www. ecologie. gouv. fr/ rapports-entre-proprietaires-et-locataires ;
Pour constituer un dossier de location : le service « DossierFacile », le dossier de location numérique de l'Etat, propose aux candidats locataires du parc privé une aide pour la constitution d'un dossier de location conforme et sécurisé, et offre aux propriétaires la possibilité de faire vérifier les dossiers de leurs candidats locataires (https :// www. dossierfacile. fr/).
-site officiel de l'administration française : http :// www. service-public. fr/ ;
-numéro unique de renseignement des usagers Allo Service Public : 3939 (coût : service gratuit, coût de l'appel selon opérateur).
(1) Décret n° 2015-981 du 31 juillet 2015 fixant la liste des éléments de mobilier d'un logement meublé.
(2) Sauf indication contraire, les articles entre parenthèses mentionnés dans la présente notice renvoient à la loi n° 89-462 du 6 juillet 1989.
(3) Décret n° 2015-587 du 29 mai 2015 relatif aux contrats types de location de logement à usage de résidence principale.
(4) Définie à l'article R. 156-1 du code de la construction et de l'habitation.
(5) Décret n° 2013-392 du 10 mai 2013 relatif au champ d'application de la taxe annuelle sur les logements vacants instituée par l'article 232 du code général des impôts.
(6) Modification annuelle du décret n° 2017-1198 du 27 juillet 2017 relatif à l'évolution de certains loyers dans le cadre d'une nouvelle location ou d'un renouvellement de bail.
(7) Articles 5 et 7 du décret du 27 juillet 2017 susvisé.
(8) Décret n° 87-713 du 26 août 1987 pris en application de l'article 18 de la loi n° 86-1290 du 23 décembre 1986 tendant à favoriser l'investissement locatif, l'accession à la propriété de logements sociaux et le développement de l'offre foncière et fixant la liste des charges récupérables.
(9) Décret n° 2016-382 du 30 mars 2016 fixant les modalités d'établissement de l'état des lieux et de prise en compte de la vétusté des logements loués à usage de résidence principale.
(10) Articles A444-10 à A444-52 du code de commerce.
(11) Décret n° 2002-120 du 30 janvier 2002 relatif aux caractéristiques du logement décent pris pour l'application de l'article 187 de la loi n° 2000-1208 du 13 décembre 2000 relative à la solidarité b et au renouvellement urbain, modifié par le décret n° 2021-19 du 11 janvier 2021 relatif au critère de performance énergétique dans la définition du logement décent en France métropolitaine.
(12) Décret n° 2021-19 du 11 janvier 2021 relatif au critère de performance énergétique dans la définition du logement décent en France métropolitaine.
(13) Décret n° 2016-1282 du 29 septembre 2016 relatif aux travaux d'adaptation du logement aux personnes en situation de handicap ou de perte d'autonomie réalisés aux frais du locataire et décret n° 2022-1026 du 20 juillet 2022 relatif aux travaux de rénovation énergétique réalisés aux frais du locataire.
(14) Décret n° 87-712 du 26 août 1987 relatif aux réparations locatives.
@@ -0,0 +1,25 @@
<!doctype html>
<html lang="fr" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8" />
<title>Notice d'information</title>
<style>
@page {
size: A4;
margin: 16mm 14mm;
}
body {
font-family: sans-serif;
font-size: 9pt;
line-height: 1.25;
color: #111;
}
.legal-text {
white-space: pre-wrap;
}
</style>
</head>
<body>
<div class="legal-text" th:text="${document.text}"></div>
</body>
</html>
@@ -1,35 +1,605 @@
<!DOCTYPE html>
<!doctype html>
<html lang="fr" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8" />
<style>
@page { size: A4; margin: 2cm; }
body { color: #1f2937; font-family: serif; font-size: 11pt; line-height: 1.5; }
h1 { color: #0f766e; font-size: 22pt; margin: 0 0 0.5cm; }
.reference { color: #4b5563; margin-bottom: 1cm; }
table { border-collapse: collapse; width: 100%; }
th, td { border-bottom: 1px solid #d1d5db; padding: 0.25cm 0; text-align: left; vertical-align: top; }
th { width: 35%; font-weight: bold; }
footer { color: #4b5563; font-size: 9pt; margin-top: 1cm; }
</style>
</head>
<body>
<h1>BAIL D'HABITATION MEUBLÉE</h1>
<p class="reference">Référence : <span th:text="${document.leaseId.value}">lease-1</span></p>
<table>
<tr><th>Logement</th><td th:text="${document.apartment.name}">Studio République</td></tr>
<tr><th>Adresse</th><td th:text="${document.apartment.address}">12 rue de la République</td></tr>
<tr><th>Bailleur</th><td th:text="${document.landlord.firstName + ' ' + document.landlord.lastName}">Léa Dubois</td></tr>
<tr><th>E-mail du bailleur</th><td th:text="${document.landlord.email}">lea@example.com</td></tr>
<tr><th>Téléphone du bailleur</th><td th:text="${document.landlord.phone}">+33612345678</td></tr>
<tr><th>Adresse du bailleur</th><td th:text="${document.landlord.address}">1 rue de Paris</td></tr>
<tr><th>Locataire(s)</th><td th:text="${tenantNames}">Camille Martin</td></tr>
<tr><th>Période</th><td><span th:text="${dates.date(document.period.startDate)}">1 Septembre 2026</span> au <span th:text="${dates.date(document.period.endDate)}">31 Août 2027</span></td></tr>
<tr><th>Loyer mensuel</th><td><span th:text="${document.terms.monthlyRent}">850.00</span> EUR</td></tr>
<tr><th>Charges mensuelles</th><td><span th:text="${document.terms.monthlyCharges}">50.00</span> EUR</td></tr>
<tr><th>Dépôt de garantie</th><td><span th:text="${document.terms.securityDeposit}">850.00</span> EUR</td></tr>
<tr><th>Garant(s)</th><td th:text="${guarantorNames}">Alex Durand</td></tr>
</table>
<footer>Bail généré par LMNP Organizer à partir des faits contractuels figés.</footer>
</body>
<head>
<meta charset="UTF-8" />
<style th:inline="text">
@page {
size: A4;
margin: 1.7cm 1.8cm 2.2cm;
@bottom-center {
color: #4b5563;
content: "Modèle [[${document.templateVersion}]] · Révision [[${document.revisionNumber}]]";
font-family: serif;
font-size: 8pt;
}
}
body {
color: #1f2937;
font-family: serif;
font-size: 9.5pt;
line-height: 1.4;
}
h1 {
color: #0f766e;
font-size: 19pt;
margin: 0 0 0.2cm;
text-align: center;
}
h2 {
border-bottom: 1px solid #99a3af;
color: #0f766e;
font-size: 13pt;
margin: 0.7cm 0 0.25cm;
padding-bottom: 0.08cm;
}
h3 {
font-size: 10.5pt;
margin: 0.35cm 0 0.12cm;
}
p {
margin: 0.12cm 0;
}
ul {
margin: 0.12cm 0 0.25cm 0.45cm;
padding-left: 0.25cm;
}
li {
margin: 0.08cm 0;
}
table {
border-collapse: collapse;
margin: 0.1cm 0 0.25cm;
width: 100%;
}
th,
td {
border-bottom: 1px solid #e1e5e9;
padding: 0.11cm 0.08cm;
text-align: left;
vertical-align: top;
}
th {
width: 35%;
}
.reference,
.scope {
color: #4b5563;
text-align: center;
}
.reference {
margin-bottom: 0.45cm;
}
.party,
.keep {
page-break-inside: avoid;
}
.party {
border: 1px solid #d1d5db;
margin: 0.15cm 0;
padding: 0.2cm;
}
.note {
background: #f3f4f6;
border-left: 3px solid #0f766e;
margin: 0.2cm 0;
padding: 0.18cm 0.25cm;
}
.signature {
border: 1px solid #d1d5db;
height: 2.4cm;
padding: 0.2cm;
width: 48%;
}
.signature-gap {
border: 0;
width: 4%;
}
</style>
</head>
<body
th:with="profile=${document.apartment.legalProfile}, legal=${document.legalTerms}, october=${formats.october2026(document.templateVersion)}"
>
<h1>CONTRAT DE LOCATION D’UN LOGEMENT MEUBLÉ</h1>
<p class="scope">
Résidence principale — titre Ier bis de la loi du 6 juillet 1989
</p>
<p class="reference">
Référence : <span th:text="${document.leaseId.value}">lease-1</span>
</p>
<section>
<h2>I. Désignation des parties</h2>
<div class="party">
<strong>Le bailleur</strong><br />
<span
th:text="${document.landlord.firstName + ' ' + document.landlord.lastName}"
>Léa Dubois</span
><br />
<span th:text="${document.landlord.address}">1 rue de Paris</span><br />
<span th:text="${document.landlord.email}">lea@example.com</span>
<span th:if="${october and formats.present(document.landlord.phone)}"
><br /><span th:text="${document.landlord.phone}"
>+33612345678</span
></span
>
</div>
<div class="party" th:each="tenant : ${document.tenants}">
<strong
th:text="${#lists.size(document.tenants) > 1 ? 'Colocataire' : 'Le locataire'}"
>Le locataire</strong
><br />
<span th:text="${tenant.firstName + ' ' + tenant.lastName}"
>Camille Martin</span
><br />
<span th:text="${tenant.address}">3 rue de Lyon</span><br />
<span th:text="${tenant.email}">camille@example.com</span>
<span th:if="${october and formats.present(tenant.phone)}"
><br /><span th:text="${tenant.phone}">+33611111111</span></span
>
</div>
</section>
<section>
<h2>II. Objet du contrat</h2>
<h3>A. Consistance du logement</h3>
<table>
<tr>
<th>Localisation</th>
<td>
<span th:text="${document.apartment.name}">Studio République</span>
—
<span th:text="${document.apartment.address}"
>12 rue de la République</span
>
</td>
</tr>
<tr th:if="${formats.present(profile.taxIdentifier)}">
<th>Identifiant fiscal :</th>
<td th:text="${profile.taxIdentifier}">750561234567</td>
</tr>
<tr th:if="${profile.buildingType != null}">
<th>Type d’habitat</th>
<td th:text="${formats.buildingType(profile.buildingType)}">
Immeuble collectif
</td>
</tr>
<tr th:if="${formats.present(profile.ownershipRegime)}">
<th>Régime juridique</th>
<td th:text="${profile.ownershipRegime}">Copropriété</td>
</tr>
<tr th:if="${profile.constructionPeriod != null}">
<th>Période de construction</th>
<td
th:text="${formats.constructionPeriod(profile.constructionPeriod)}"
>
De 1949 à 1974
</td>
</tr>
<tr th:if="${profile.habitableSurface != null}">
<th>Surface habitable :</th>
<td>
<span th:text="${formats.number(profile.habitableSurface)}"
>42,5</span
>
m²
</td>
</tr>
<tr th:if="${profile.roomCount > 0}">
<th>Pièces principales</th>
<td th:text="${profile.roomCount}">2</td>
</tr>
<tr th:if="${formats.present(profile.otherPrivateAreas)}">
<th>Autres parties</th>
<td th:text="${profile.otherPrivateAreas}">Balcon</td>
</tr>
<tr th:if="${formats.present(profile.equipment)}">
<th>Équipements</th>
<td th:text="${profile.equipment}">Cuisine équipée</td>
</tr>
<tr th:if="${formats.present(profile.heating)}">
<th>Chauffage</th>
<td th:text="${profile.heating}">Individuel</td>
</tr>
<tr th:if="${formats.present(profile.hotWater)}">
<th>Eau chaude sanitaire</th>
<td th:text="${profile.hotWater}">Individuelle</td>
</tr>
<tr th:if="${formats.present(profile.energyRating)}">
<th>Niveau de performance</th>
<td>
Classe énergétique :
<span th:text="${profile.energyRating}">B</span>
</td>
</tr>
</table>
<div class="note">
En France métropolitaine, le niveau minimal de performance énergétique
correspond à la classe F à compter du 1er Janvier 2025, à la classe E à
compter du 1er Janvier 2028 et à la classe D à compter du 1er Janvier
2034. En Guadeloupe, Martinique, Guyane, à La Réunion et à Mayotte, il
correspond à la classe F à compter du 1er Janvier 2028 puis à la classe
E à compter du 1er Janvier 2031.
</div>
<h3 th:if="${formats.present(profile.destination)}">
B. Destination des locaux
</h3>
<p
th:if="${formats.present(profile.destination)}"
th:text="${profile.destination}"
>
Usage d’habitation principale
</p>
<div
th:if="${october and legal.primaryResidenceEasementApplicable}"
class="keep"
>
<h3>Servitude de résidence principale</h3>
<p>
Le logement objet du présent contrat est soumis à l’obligation prévue
à l’article L. 151-14-1 du code de l’urbanisme ; il est à usage
exclusif de résidence principale, au sens de l’article 2 de la loi du
6 juillet 1989.
</p>
</div>
<h3 th:if="${formats.present(profile.privateAccessories)}">
C. Locaux et équipements privatifs accessoires
</h3>
<p
th:if="${formats.present(profile.privateAccessories)}"
th:text="${profile.privateAccessories}"
>
Cave
</p>
<h3 th:if="${formats.present(profile.commonAreas)}">
D. Locaux et équipements communs
</h3>
<p
th:if="${formats.present(profile.commonAreas)}"
th:text="${profile.commonAreas}"
>
Local à vélos
</p>
<h3 th:if="${formats.present(profile.internetAccess)}">
E. Accès aux technologies de l’information et de la communication
</h3>
<p
th:if="${formats.present(profile.internetAccess)}"
th:text="${profile.internetAccess}"
>
Fibre optique
</p>
</section>
<section class="keep">
<h2>III. Date de prise d’effet et durée du contrat</h2>
<table>
<tr>
<th>Prise d’effet</th>
<td th:text="${dates.date(document.period.startDate)}">
1 Septembre 2026
</td>
</tr>
<tr>
<th>Échéance initiale</th>
<td th:text="${dates.date(document.period.endDate)}">31 Août 2027</td>
</tr>
<tr>
<th>Durée</th>
<td th:text="${formats.leaseType(document.leaseType)}">
Location meublée d’un an
</td>
</tr>
</table>
<p th:if="${formats.standard(document.leaseType)}">
À son terme, le contrat est reconduit tacitement pour une durée d’un an
et dans les mêmes conditions. Le locataire peut donner congé à tout
moment. Le bailleur peut donner congé à l’échéance dans les conditions
prévues par la loi.
</p>
<p th:unless="${formats.standard(document.leaseType)}">
Le bail étudiant de neuf mois n’est pas reconduit tacitement. Le
locataire peut donner congé à tout moment et le bailleur peut donner
congé à l’échéance dans les conditions prévues par la loi.
</p>
</section>
<section>
<h2>IV. Conditions financières</h2>
<h3>A. Loyer</h3>
<table>
<tr>
<th>Loyer mensuel hors charges</th>
<td th:text="${formats.money(document.terms.monthlyRent)}">
900,00 €
</td>
</tr>
<tr>
<th>Plafond d’évolution à la relocation applicable</th>
<td
th:text="${formats.yesNo(legal.rentControl.evolutionCapApplicable)}"
>
Non
</td>
</tr>
<tr>
<th>Loyer de référence majoré applicable</th>
<td
th:text="${formats.yesNo(legal.rentControl.referenceRentApplicable)}"
>
Non
</td>
</tr>
<tr th:if="${legal.rentControl.referenceRentApplicable}">
<th>Loyer de référence</th>
<td
th:text="${formats.moneyPerSquareMeter(legal.rentControl.baseReferenceRent)}"
>
20,50 € / m²
</td>
</tr>
<tr th:if="${legal.rentControl.referenceRentApplicable}">
<th>Loyer de référence majoré</th>
<td
th:text="${formats.moneyPerSquareMeter(legal.rentControl.increasedReferenceRent)}"
>
24,60 € / m²
</td>
</tr>
<tr th:if="${formats.positive(legal.rentControl.supplement)}">
<th>Complément de loyer</th>
<td>
<span th:text="${formats.money(legal.rentControl.supplement)}"
>40,00 €</span
>
—
<span th:text="${legal.rentControl.supplementJustification}"
>Justification</span
>
</td>
</tr>
</table>
<div th:if="${legal.previousRent.applicable}" class="keep">
<h3>Informations relatives au précédent loyer</h3>
<table>
<tr>
<th>Dernier loyer acquitté</th>
<td th:text="${formats.money(legal.previousRent.amount)}">
850,00 €
</td>
</tr>
<tr>
<th>Date du dernier versement</th>
<td th:text="${dates.date(legal.previousRent.lastPaymentDate)}">
5 Juin 2026
</td>
</tr>
<tr>
<th>Date de la dernière révision</th>
<td th:text="${dates.date(legal.previousRent.lastRevisionDate)}">
1 Septembre 2025
</td>
</tr>
</table>
</div>
<div th:if="${legal.rentRevision.enabled}" class="keep">
<h3>Modalités de révision du loyer</h3>
<p>
Révision annuelle le
<span th:text="${dates.date(legal.rentRevision.annualRevisionDate)}"
>1 Septembre 2027</span
>, selon l’IRL du
<span
th:text="${formats.quarter(legal.rentRevision.referenceQuarter)}"
>2e trimestre</span
>
<span th:text="${legal.rentRevision.referenceYear}">2026</span>, série
INSEE <span th:text="${legal.rentRevision.seriesId}">001515333</span>.
</p>
</div>
<h3>B. Charges récupérables</h3>
<p>
<span th:text="${formats.chargeMethod(document.terms.chargeMethod)}"
>Forfait de charges sans régularisation</span
>
:
<span th:text="${formats.money(document.terms.monthlyCharges)}"
>50,00 €</span
>
par mois.
</p>
<p th:if="${document.terms.indexFlatRate}">
Le forfait de charges est révisé annuellement dans les mêmes conditions
que le loyer principal.
</p>
<h3>D. Modalités de paiement</h3>
<p>
Le loyer et les charges sont payables mensuellement d’avance le
<span th:text="${document.terms.dueDay}">5</span> de chaque mois.
</p>
<p>
Montant dû pour une période mensuelle complète :
<strong
th:text="${formats.total(document.terms.monthlyRent, document.terms.monthlyCharges)}"
>950,00 €</strong
>, dont
<span th:text="${formats.money(document.terms.monthlyRent)}"
>900,00 €</span
>
de loyer et
<span th:text="${formats.money(document.terms.monthlyCharges)}"
>50,00 €</span
>
de charges.
</p>
<div th:if="${profile.estimatedAnnualEnergyCost != null}" class="keep">
<h3>F. Dépenses énergétiques pour information</h3>
<p>
Montant estimé des dépenses annuelles d’énergie pour un usage standard
:
<span th:text="${formats.money(profile.estimatedAnnualEnergyCost)}"
>1 250,00 €</span
><span
th:if="${profile.estimatedAnnualEnergyCostReferenceYear != null}"
>, estimation établie à partir des prix de l’année
<span th:text="${profile.estimatedAnnualEnergyCostReferenceYear}"
>2024</span
></span
>.
</p>
</div>
</section>
<section
th:if="${formats.present(legal.recentWork.description) or legal.recentWork.amount != null}"
class="keep"
>
<h2>V. Travaux</h2>
<p>
<span
th:if="${formats.present(legal.recentWork.description)}"
th:text="${legal.recentWork.description}"
>Travaux récents</span
><span th:if="${legal.recentWork.amount != null}">
— montant :
<span th:text="${formats.money(legal.recentWork.amount)}"
>3 000,00 €</span
></span
>.
</p>
</section>
<section class="keep">
<h2>VI. Garanties</h2>
<p>
Dépôt de garantie :
<strong th:text="${formats.money(document.terms.securityDeposit)}"
>900,00 €</strong
>.
</p>
<p th:unless="${document.guarantors.empty}">
Garant(s) déclaré(s) :
<span th:text="${guarantorNames}">Louise Martin</span>. Chaque
cautionnement fait l’objet d’un acte distinct.
</p>
</section>
<section th:if="${legal.jointAndSeveralLiability}" class="keep">
<h2>VII. Clause de solidarité</h2>
<p>
Les colocataires sont tenus solidairement et indivisiblement au paiement
des loyers, charges et accessoires ainsi qu’à l’exécution des
obligations du présent bail. Lors du départ de l’un d’eux, cette
solidarité cesse dans les conditions et délais prévus par la loi.
</p>
</section>
<section th:unless="${october}" class="keep">
<h2>VIII. Clause résolutoire</h2>
<p>
Une résiliation de plein droit ne peut résulter que d’un motif
expressément convenu par les parties à la section X et mis en œuvre dans
les conditions légales applicables.
</p>
</section>
<section th:if="${october}" class="keep">
<h2>VIII. Clause résolutoire</h2>
<p>
Le contrat de location est résilié de plein droit pour défaut de
paiement du loyer ou des charges aux termes convenus ou pour non
versement du dépôt de garantie. La clause de résiliation de plein droit
ne produit effet que six semaines après la date d’un commandement de
payer demeuré infructueux.
</p>
<h3>Autres motifs éventuels</h3>
<p>
Les autres motifs facultatifs de résiliation de plein droit ne sont
applicables que s’ils sont expressément stipulés par les parties à la
section X et mis en œuvre dans les conditions légales propres à chaque
motif.
</p>
</section>
<section>
<h2>X. Autres conditions particulières</h2>
<div th:unless="${document.specialClauses.empty}">
<h3>Clauses particulières</h3>
<ul>
<li th:each="clause : ${document.specialClauses}">
<strong th:if="${clause.custom}"
>Clause personnalisée — sous la responsabilité des parties : </strong
><span th:text="${clause.text}">Clause particulière</span>
</li>
</ul>
</div>
<div th:if="${formats.present(legal.otherTerms)}">
<h3>Autres conditions convenues</h3>
<p th:text="${legal.otherTerms}">Autres conditions</p>
</div>
<p
th:if="${document.specialClauses.empty and !formats.present(legal.otherTerms)}"
>
Aucune condition particulière n’est ajoutée.
</p>
</section>
<section>
<h2>XI. Annexes</h2>
<p>
Les pièces suivantes doivent être jointes séparément au contrat, selon
leur applicabilité :
</p>
<ul>
<li th:if="${legal.coOwnershipRulesApplicable}">
extrait du règlement de copropriété relatif à la destination de
l’immeuble, à l’usage des parties privatives et communes et à la
quote-part de charges ;
</li>
<li>
dossier de diagnostic technique, comprenant notamment le diagnostic de
performance énergétique et les diagnostics requis pour le logement ;
</li>
<li>
notice d’information relative aux droits et obligations des locataires
et des bailleurs ;
</li>
<li>
état des lieux d’entrée, inventaire et état détaillé du mobilier,
établis lors de la remise des clés ;
</li>
<li th:if="${legal.priorRentalAuthorizationApplicable}">
autorisation préalable de mise en location.
</li>
</ul>
</section>
<section class="keep">
<h2>Signatures</h2>
<p>
Les signatures sont précédées de la date et de la mention manuscrite
appropriée.
</p>
<p>
<strong>Fait le :</strong> ____________________
<strong>Fait à :</strong> ____________________
</p>
<table>
<tr>
<td class="signature">
<strong>Le bailleur</strong><br /><span
th:text="${document.landlord.firstName + ' ' + document.landlord.lastName}"
>Léa Dubois</span
>
</td>
<td class="signature-gap"></td>
<td class="signature">
<strong>Le ou les locataires</strong><br /><span
th:text="${tenantNames}"
>Camille Martin</span
>
</td>
</tr>
</table>
</section>
</body>
</html>
@@ -1,51 +1,121 @@
<!DOCTYPE html>
<!doctype html>
<html lang="fr" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8" />
<style>
@page { size: A4; margin: 2cm; }
body { color: #1f2937; font-family: serif; font-size: 11pt; line-height: 1.5; }
h1 { color: #0f766e; font-size: 22pt; }
table { border-collapse: collapse; width: 100%; }
th, td { border-bottom: 1px solid #d1d5db; padding: 0.22cm 0; text-align: left; }
th { width: 40%; }
</style>
</head>
<body>
<h1>REÇU DE RÈGLEMENT</h1>
<p>Référence : <span th:text="${document.paymentId.value}">payment-1</span></p>
<table>
<tr><th>Bailleur</th><td th:text="${document.contract.landlord.firstName + ' ' + document.contract.landlord.lastName}">Léa Dubois</td></tr>
<tr><th>Logement</th><td th:text="${document.contract.apartment.address}">12 rue de la République</td></tr>
<tr><th>Locataire(s)</th><td th:text="${tenantNames}">Camille Martin</td></tr>
<tr><th>Reçu le</th><td th:text="${dates.date(document.receivedAt)}">5 Juillet 2026</td></tr>
<tr><th>Montant reçu</th><td><span th:text="${document.amount}">1200.00</span> EUR</td></tr>
<tr><th>Montant remboursé</th><td><span th:text="${document.refundedAmount}">0.00</span> EUR</td></tr>
<tr><th>Solde non affecté</th><td><span th:text="${document.unallocatedBalance}">300.00</span> EUR</td></tr>
</table>
<h2>Affectations</h2>
<table>
<thead><tr><th>Mois</th><th>Montant</th><th>Date</th></tr></thead>
<tbody>
<tr th:each="allocation : ${document.allocations}">
<td th:text="${dates.month(allocation.month)}">Juillet 2026</td>
<td><span th:text="${allocation.amount}">900.00</span> EUR</td>
<td th:text="${dates.date(allocation.allocatedAt)}">5 Juillet 2026</td>
<head>
<meta charset="UTF-8" />
<style>
@page {
size: A4;
margin: 2cm;
}
body {
color: #1f2937;
font-family: serif;
font-size: 11pt;
line-height: 1.5;
}
h1 {
color: #0f766e;
font-size: 22pt;
}
table {
border-collapse: collapse;
width: 100%;
}
th,
td {
border-bottom: 1px solid #d1d5db;
padding: 0.22cm 0;
text-align: left;
}
th {
width: 40%;
}
</style>
</head>
<body>
<h1>REÇU DE RÈGLEMENT</h1>
<p>
Référence : <span th:text="${document.paymentId.value}">payment-1</span>
</p>
<table>
<tr>
<th>Bailleur</th>
<td
th:text="${document.contract.landlord.firstName + ' ' + document.contract.landlord.lastName}"
>
Léa Dubois
</td>
</tr>
<tr th:if="${document.allocations.empty}"><td colspan="3">Aucune affectation</td></tr>
</tbody>
</table>
<h2>Remboursements</h2>
<table>
<thead><tr><th>Référence</th><th>Montant</th><th>Date</th></tr></thead>
<tbody>
<tr th:each="refund : ${document.refunds}">
<td th:text="${refund.refundId.value}">refund-1</td>
<td><span th:text="${refund.amount}">100.00</span> EUR</td>
<td th:text="${dates.date(refund.refundedAt)}">6 Juillet 2026</td>
<tr>
<th>Logement</th>
<td th:text="${document.contract.apartment.address}">
12 rue de la République
</td>
</tr>
<tr th:if="${document.refunds.empty}"><td colspan="3">Aucun remboursement</td></tr>
</tbody>
</table>
</body>
<tr>
<th>Locataire(s)</th>
<td th:text="${tenantNames}">Camille Martin</td>
</tr>
<tr>
<th>Reçu le</th>
<td th:text="${dates.date(document.receivedAt)}">5 Juillet 2026</td>
</tr>
<tr>
<th>Montant reçu</th>
<td><span th:text="${document.amount}">1200.00</span> EUR</td>
</tr>
<tr>
<th>Montant remboursé</th>
<td><span th:text="${document.refundedAmount}">0.00</span> EUR</td>
</tr>
<tr>
<th>Solde non affecté</th>
<td>
<span th:text="${document.unallocatedBalance}">300.00</span> EUR
</td>
</tr>
</table>
<h2>Affectations</h2>
<table>
<thead>
<tr>
<th>Mois</th>
<th>Montant</th>
<th>Date</th>
</tr>
</thead>
<tbody>
<tr th:each="allocation : ${document.allocations}">
<td th:text="${dates.month(allocation.month)}">Juillet 2026</td>
<td><span th:text="${allocation.amount}">900.00</span> EUR</td>
<td th:text="${dates.date(allocation.allocatedAt)}">
5 Juillet 2026
</td>
</tr>
<tr th:if="${document.allocations.empty}">
<td colspan="3">Aucune affectation</td>
</tr>
</tbody>
</table>
<h2>Remboursements</h2>
<table>
<thead>
<tr>
<th>Référence</th>
<th>Montant</th>
<th>Date</th>
</tr>
</thead>
<tbody>
<tr th:each="refund : ${document.refunds}">
<td th:text="${refund.refundId.value}">refund-1</td>
<td><span th:text="${refund.amount}">100.00</span> EUR</td>
<td th:text="${dates.date(refund.refundedAt)}">6 Juillet 2026</td>
</tr>
<tr th:if="${document.refunds.empty}">
<td colspan="3">Aucun remboursement</td>
</tr>
</tbody>
</table>
</body>
</html>
@@ -1,31 +1,96 @@
<!DOCTYPE html>
<!doctype html>
<html lang="fr" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8" />
<style>
@page { size: A4; margin: 2cm; }
body { color: #1f2937; font-family: serif; font-size: 11pt; line-height: 1.5; }
h1 { color: #0f766e; font-size: 22pt; }
table { border-collapse: collapse; width: 100%; }
th, td { border-bottom: 1px solid #d1d5db; padding: 0.25cm 0; text-align: left; }
th { width: 35%; }
footer { color: #4b5563; font-size: 9pt; margin-top: 1cm; }
</style>
</head>
<body>
<h1>QUITTANCE DE LOYER</h1>
<p>Le bailleur reconnaît avoir reçu le règlement complet du loyer et des charges pour la période indiquée.</p>
<table>
<tr><th>Bailleur</th><td th:text="${document.contract.landlord.firstName + ' ' + document.contract.landlord.lastName}">Léa Dubois</td></tr>
<tr><th>Adresse du bailleur</th><td th:text="${document.contract.landlord.address}">1 rue de Paris</td></tr>
<tr><th>Logement</th><td th:text="${document.contract.apartment.name}">Studio République</td></tr>
<tr><th>Adresse</th><td th:text="${document.contract.apartment.address}">12 rue de la République</td></tr>
<tr><th>Locataire(s)</th><td th:text="${tenantNames}">Camille Martin</td></tr>
<tr><th>Période</th><td th:text="${dates.month(document.month)}">Juillet 2026</td></tr>
<tr><th>Loyer</th><td><span th:text="${document.rent}">850.00</span> EUR</td></tr>
<tr><th>Charges</th><td><span th:text="${document.charges}">50.00</span> EUR</td></tr>
<tr><th>Total réglé</th><td><span th:text="${document.total}">900.00</span> EUR</td></tr>
</table>
<footer>Quittance générée le <span th:text="${dates.date(document.issuedAt)}">18 Juillet 2026</span>. Le surplus non affecté n'est pas inclus.</footer>
</body>
<head>
<meta charset="UTF-8" />
<style>
@page {
size: A4;
margin: 2cm;
}
body {
color: #1f2937;
font-family: serif;
font-size: 11pt;
line-height: 1.5;
}
h1 {
color: #0f766e;
font-size: 22pt;
}
table {
border-collapse: collapse;
width: 100%;
}
th,
td {
border-bottom: 1px solid #d1d5db;
padding: 0.25cm 0;
text-align: left;
}
th {
width: 35%;
}
footer {
color: #4b5563;
font-size: 9pt;
margin-top: 1cm;
}
</style>
</head>
<body>
<h1>QUITTANCE DE LOYER</h1>
<p>
Le bailleur reconnaît avoir reçu le règlement complet du loyer et des
charges pour la période indiquée.
</p>
<table>
<tr>
<th>Bailleur</th>
<td
th:text="${document.contract.landlord.firstName + ' ' + document.contract.landlord.lastName}"
>
Léa Dubois
</td>
</tr>
<tr>
<th>Adresse du bailleur</th>
<td th:text="${document.contract.landlord.address}">1 rue de Paris</td>
</tr>
<tr>
<th>Logement</th>
<td th:text="${document.contract.apartment.name}">Studio République</td>
</tr>
<tr>
<th>Adresse</th>
<td th:text="${document.contract.apartment.address}">
12 rue de la République
</td>
</tr>
<tr>
<th>Locataire(s)</th>
<td th:text="${tenantNames}">Camille Martin</td>
</tr>
<tr>
<th>Période</th>
<td th:text="${dates.month(document.month)}">Juillet 2026</td>
</tr>
<tr>
<th>Loyer</th>
<td><span th:text="${document.rent}">850.00</span> EUR</td>
</tr>
<tr>
<th>Charges</th>
<td><span th:text="${document.charges}">50.00</span> EUR</td>
</tr>
<tr>
<th>Total réglé</th>
<td><span th:text="${document.total}">900.00</span> EUR</td>
</tr>
</table>
<footer>
Quittance générée le
<span th:text="${dates.date(document.issuedAt)}">18 Juillet 2026</span>.
Le surplus non affecté n'est pas inclus.
</footer>
</body>
</html>
@@ -0,0 +1,169 @@
<!doctype html>
<html lang="fr" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8" />
<style th:inline="text">
@page {
size: A4;
margin: 1.8cm 1.8cm 2.2cm;
@bottom-center {
color: #4b5563;
content: "Demande de révision IRL · Série [[${document.seriesId}]]";
font-family: serif;
font-size: 8pt;
}
}
body {
color: #1f2937;
font-family: serif;
font-size: 10.5pt;
line-height: 1.45;
}
h1 {
color: #0f766e;
font-size: 18pt;
margin: 1cm 0 0.6cm;
text-align: center;
}
h2 {
color: #0f766e;
font-size: 12pt;
margin: 0.7cm 0 0.2cm;
}
table {
border-collapse: collapse;
width: 100%;
}
th,
td {
border-bottom: 1px solid #d1d5db;
padding: 0.18cm 0;
text-align: left;
vertical-align: top;
}
th {
width: 35%;
}
.formula {
background: #f3f4f6;
border-left: 3px solid #0f766e;
padding: 0.25cm;
}
</style>
</head>
<body>
<table>
<tr>
<th>Bailleur</th>
<td
th:text="${document.landlord.firstName + ' ' + document.landlord.lastName}"
>
Léa Dubois
</td>
</tr>
<tr>
<th>Adresse du bailleur</th>
<td th:text="${document.landlord.address}">1 rue de Paris</td>
</tr>
<tr>
<th>Locataire(s)</th>
<td th:text="${tenantNames}">Camille Martin</td>
</tr>
<tr>
<th>Logement</th>
<td>
<span th:text="${document.apartment.name}">Studio République</span
><br />
<span th:text="${document.apartment.address}"
>12 rue de la République</span
>
</td>
</tr>
</table>
<h1>DEMANDE DE RÉVISION DU LOYER</h1>
<p>
Demande établie le
<strong th:text="${dates.date(document.requestedOn)}"
>19 Juillet 2026</strong
>, pour une prise d’effet le
<strong th:text="${dates.date(document.effectiveOn)}">1 Août 2026</strong
>.
</p>
<h2>Indice de référence des loyers</h2>
<p>
Série INSEE <strong th:text="${document.seriesId}">001515333</strong>.
</p>
<table>
<tr>
<th
th:text="${formats.indexPeriod('Ancien indice', document.referenceQuarter, document.previousIndexYear)}"
>
Ancien indice — 2e trimestre 2026
</th>
<td th:text="${formats.number(document.previousIndex)}">143,46</td>
</tr>
<tr>
<th
th:text="${formats.indexPeriod('Nouvel indice', document.referenceQuarter, document.newIndexYear)}"
>
Nouvel indice — 2e trimestre 2027
</th>
<td th:text="${formats.number(document.newIndex)}">146,68</td>
</tr>
</table>
<h2>Loyer</h2>
<p>
Ancien loyer :
<strong th:text="${formats.money(document.previousRent)}"
>900,00 €</strong
>
</p>
<p
class="formula"
th:text="${formats.revisionFormula(document.previousRent, document.newIndex, document.previousIndex, document.rentCeiling)}"
>
900,00 € × 146,68 / 143,46 = 920,20 €
</p>
<p>
Plafond calculé :
<strong th:text="${formats.money(document.rentCeiling)}">920,20 €</strong>
</p>
<p>
Loyer demandé :
<strong th:text="${formats.money(document.chosenRent)}">915,00 €</strong>
</p>
<section th:if="${document.flatRateRevision != null}">
<h2>Forfait de charges</h2>
<p>
Ancien forfait :
<strong
th:text="${formats.money(document.flatRateRevision.previousAmount)}"
>50,00 €</strong
>
</p>
<p
class="formula"
th:text="${formats.revisionFormula(document.flatRateRevision.previousAmount, document.newIndex, document.previousIndex, document.flatRateRevision.ceiling)}"
>
50,00 € × 146,68 / 143,46 = 51,12 €
</p>
<p>
Plafond du forfait :
<strong th:text="${formats.money(document.flatRateRevision.ceiling)}"
>51,12 €</strong
>
</p>
<p>
Nouveau forfait demandé :
<strong
th:text="${formats.money(document.flatRateRevision.chosenAmount)}"
>51,00 €</strong
>
</p>
</section>
</body>
</html>
@@ -0,0 +1,31 @@
package fr.nessar.lmnporganizer.identity.adapter.in.security;
import fr.nessar.lmnporganizer.LmnpOrganizerApplication;
import fr.nessar.lmnporganizer.identity.adapter.in.web.SessionController;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.cookie;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(SessionController.class)
@ContextConfiguration(classes = LmnpOrganizerApplication.class)
@Import(SecurityConfiguration.class)
class SecurityConfigurationWebTest {
@Autowired
private MockMvc mockMvc;
@Test
void anonymousSessionBootstrapProvidesTheAngularCsrfCookie() throws Exception {
mockMvc.perform(get("/api/user"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.authenticated").value(false))
.andExpect(cookie().exists("XSRF-TOKEN"));
}
}
@@ -0,0 +1,34 @@
package fr.nessar.lmnporganizer.rental.adapter.in.config;
import fr.nessar.lmnporganizer.rental.adapter.out.insee.InseeIrlProperties;
import fr.nessar.lmnporganizer.rental.application.port.out.IrlIndexProvider;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.data.mongodb.core.MongoTemplate;
import java.net.URI;
import java.time.Duration;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
class InseeIrlConfigurationTest {
@Test
void exposesOneProviderFromTypedProperties() {
MongoTemplate mongoTemplate = mock(MongoTemplate.class);
new ApplicationContextRunner()
.withBean(MongoTemplate.class, () -> mongoTemplate)
.withPropertyValues(
"lmnp.insee-irl.base-uri=http://localhost:9876/series/",
"lmnp.insee-irl.request-timeout=2s")
.withUserConfiguration(InseeIrlConfiguration.class)
.run(context -> {
assertThat(context).hasNotFailed().hasSingleBean(IrlIndexProvider.class)
.hasSingleBean(InseeIrlProperties.class);
InseeIrlProperties properties = context.getBean(InseeIrlProperties.class);
assertThat(properties.baseUri()).isEqualTo(URI.create("http://localhost:9876/series/"));
assertThat(properties.requestTimeout()).isEqualTo(Duration.ofSeconds(2));
});
}
}
@@ -10,7 +10,6 @@ import fr.nessar.lmnporganizer.rental.adapter.in.web.rent.RentPaymentController;
import fr.nessar.lmnporganizer.rental.adapter.in.web.tenant.TenantController;
import fr.nessar.lmnporganizer.rental.application.apartment.ApartmentCatalogService;
import fr.nessar.lmnporganizer.rental.application.landlord.LandlordAccountService;
import fr.nessar.lmnporganizer.rental.application.lease.ActivateLeaseService;
import fr.nessar.lmnporganizer.rental.application.lease.AmendLeaseService;
import fr.nessar.lmnporganizer.rental.application.lease.ArchiveLeaseService;
import fr.nessar.lmnporganizer.rental.application.lease.GenerateLeaseDocumentService;
@@ -19,12 +18,12 @@ import fr.nessar.lmnporganizer.rental.application.rent.AllocateRentPaymentServic
import fr.nessar.lmnporganizer.rental.application.rent.GeneratePaymentReceiptService;
import fr.nessar.lmnporganizer.rental.application.rent.RecordRentPaymentService;
import fr.nessar.lmnporganizer.rental.application.rent.RefundRentPaymentService;
import fr.nessar.lmnporganizer.rental.application.rent.RentDueQueryService;
import fr.nessar.lmnporganizer.rental.application.rent.RentPaymentConflict;
import fr.nessar.lmnporganizer.rental.application.rent.RentPaymentHistory;
import fr.nessar.lmnporganizer.rental.application.rent.RentPaymentHistoryService;
import fr.nessar.lmnporganizer.rental.application.rent.ReverseRentPaymentService;
import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
import fr.nessar.lmnporganizer.rental.application.shared.GeneratedDocument;
import fr.nessar.lmnporganizer.rental.application.shared.ReferencedResource;
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
import fr.nessar.lmnporganizer.rental.application.tenant.DeleteTenantService;
@@ -32,19 +31,24 @@ import fr.nessar.lmnporganizer.rental.application.tenant.TenantAttachmentService
import fr.nessar.lmnporganizer.rental.application.tenant.TenantDirectoryService;
import fr.nessar.lmnporganizer.rental.domain.apartment.Apartment;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile;
import fr.nessar.lmnporganizer.rental.domain.landlord.ExternalIdentity;
import fr.nessar.lmnporganizer.rental.domain.landlord.Landlord;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.ChargeMethod;
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseAmendmentId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseContractVersion;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentRevision;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentWarning;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSnapshot;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSpecialClause;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseType;
import fr.nessar.lmnporganizer.rental.domain.lease.RentTerms;
import fr.nessar.lmnporganizer.rental.domain.lease.RentalPeriod;
import fr.nessar.lmnporganizer.rental.domain.rent.PaymentAllocation;
import fr.nessar.lmnporganizer.rental.domain.rent.PaymentAllocationId;
import fr.nessar.lmnporganizer.rental.domain.rent.RentDue;
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueId;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPayment;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentId;
@@ -73,12 +77,12 @@ import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.YearMonth;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -121,16 +125,12 @@ class RentalWebAdaptersTest {
@MockitoBean
private LeaseDraftService leaseDrafts;
@MockitoBean
private ActivateLeaseService activation;
@MockitoBean
private ArchiveLeaseService archiving;
@MockitoBean
private AmendLeaseService amendments;
@MockitoBean
private GenerateLeaseDocumentService leaseDocuments;
@MockitoBean
private RentDueQueryService rentDues;
@MockitoBean
private RentPaymentHistoryService paymentHistory;
@MockitoBean
private RecordRentPaymentService paymentRecording;
@@ -182,6 +182,9 @@ class RentalWebAdaptersTest {
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value("apartment-1"))
.andExpect(jsonPath("$.purchasePrice").value(200000.00))
.andExpect(jsonPath("$.legalProfile.taxIdentifier").value(""))
.andExpect(jsonPath("$.legalProfile.roomCount").value(0))
.andExpect(jsonPath("$.legalProfile.energyRating").value(""))
.andExpect(jsonPath("$.referenced").value(false))
.andExpect(jsonPath("$.version").value(7));
@@ -198,6 +201,38 @@ class RentalWebAdaptersTest {
verify(apartments).get(LANDLORD_ID, APARTMENT_ID);
}
@Test
void apartmentLegalProfileIsMappedAtTheApiBoundary() throws Exception {
ApartmentLegalProfile profile = legalProfile();
Apartment apartment = new Apartment(APARTMENT_ID, LANDLORD_ID, "Studio République",
"12 rue de la République", new BigDecimal("200000.00"), "Meublé", profile);
when(apartments.create(eq(LANDLORD_ID), any())).thenReturn(apartment);
mockMvc.perform(post("/api/apartments").with(asLandlord()).with(csrf())
.contentType(MediaType.APPLICATION_JSON).content("""
{"name":"Studio République","address":"12 rue de la République",
"purchasePrice":200000.00,"description":"Meublé","legalProfile":{
"taxIdentifier":"tax-1","buildingType":"APARTMENT_BUILDING",
"ownershipRegime":"Copropriété","constructionPeriod":"1949_1974",
"habitableSurface":31.50,"roomCount":2,"otherPrivateAreas":"Cave",
"equipment":"Cuisine équipée","heating":"Individuel",
"hotWater":"Individuelle","energyRating":"b",
"estimatedAnnualEnergyCost":750.00,
"estimatedAnnualEnergyCostReferenceYear":2024,"destination":"Habitation",
"privateAccessories":"Balcon","commonAreas":"Local vélos",
"internetAccess":"Fibre","departmentCode":"2a"}}
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.legalProfile.buildingType").value("APARTMENT_BUILDING"))
.andExpect(jsonPath("$.legalProfile.constructionPeriod").value("1949_1974"))
.andExpect(jsonPath("$.legalProfile.energyRating").value("B"))
.andExpect(jsonPath("$.legalProfile.estimatedAnnualEnergyCostReferenceYear").value(2024))
.andExpect(jsonPath("$.legalProfile.departmentCode").value("2A"));
verify(apartments).create(LANDLORD_ID, new ApartmentCatalogService.Details("Studio République",
"12 rue de la République", new BigDecimal("200000.00"), "Meublé", profile));
}
@Test
void tenantAttachmentDownloadPreservesScopedHeadersAndContent() throws Exception {
byte[] file = "identity".getBytes();
@@ -218,24 +253,115 @@ class RentalWebAdaptersTest {
}
@Test
void leaseActivationMapsDuesAndArchiveAndAmendDelegateScopedCommands() throws Exception {
void legacyLeaseDraftRequestsUseDefaultPaymentTerms() throws Exception {
when(leaseDrafts.create(eq(LANDLORD_ID), any())).thenAnswer(invocation -> {
LeaseDraftService.DraftDetails details = invocation.getArgument(1);
return Lease.draft(LEASE_ID, LANDLORD_ID, details.apartmentId(), details.tenantIds(),
details.guarantors(), details.period(), details.terms(), details.leaseType());
});
mockMvc.perform(post("/api/leases").with(asLandlord()).with(csrf())
.contentType(MediaType.APPLICATION_JSON).content("""
{"apartmentId":"apartment-1","tenantIds":["tenant-1"],"guarantors":[],
"startDate":"2026-01-01","endDate":"2026-02-01",
"monthlyRent":900.00,"monthlyCharges":50.00,"securityDeposit":900.00}
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.dueDay").value(5))
.andExpect(jsonPath("$.chargeMethod").value("FLAT_RATE"))
.andExpect(jsonPath("$.indexFlatRate").value(false))
.andExpect(jsonPath("$.leaseType").value("STANDARD"))
.andExpect(jsonPath("$.specialClauses").isEmpty())
.andExpect(jsonPath("$.endDate").value("2026-12-31"));
verify(leaseDrafts).create(LANDLORD_ID, new LeaseDraftService.DraftDetails(APARTMENT_ID,
List.of(TENANT_ID), List.of(),
new RentalPeriod(LocalDate.of(2026, 1, 1), LocalDate.of(2026, 1, 1)),
new RentTerms(new BigDecimal("900.00"), new BigDecimal("50.00"), new BigDecimal("900.00")), null));
}
@Test
void studentLeaseDraftsDeriveANineMonthHorizon() throws Exception {
when(leaseDrafts.create(eq(LANDLORD_ID), any())).thenAnswer(invocation -> {
LeaseDraftService.DraftDetails details = invocation.getArgument(1);
return Lease.draft(LEASE_ID, LANDLORD_ID, details.apartmentId(), details.tenantIds(),
details.guarantors(), details.period(), details.terms(), details.leaseType());
});
mockMvc.perform(post("/api/leases").with(asLandlord()).with(csrf())
.contentType(MediaType.APPLICATION_JSON).content("""
{"apartmentId":"apartment-1","tenantIds":["tenant-1"],"guarantors":[],
"startDate":"2026-02-15","endDate":"2030-01-01","leaseType":"STUDENT",
"monthlyRent":900.00,"monthlyCharges":50.00,"securityDeposit":900.00}
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.leaseType").value("STUDENT"))
.andExpect(jsonPath("$.startDate").value("2026-02-15"))
.andExpect(jsonPath("$.endDate").value("2026-11-14"));
verify(leaseDrafts).create(LANDLORD_ID, new LeaseDraftService.DraftDetails(APARTMENT_ID,
List.of(TENANT_ID), List.of(),
new RentalPeriod(LocalDate.of(2026, 2, 15), LocalDate.of(2026, 2, 15)),
new RentTerms(new BigDecimal("900.00"), new BigDecimal("50.00"), new BigDecimal("900.00")),
LeaseType.STUDENT));
}
@Test
void leaseDraftRequestsMapSpecialClausesAndExposeOnlyServerRenderedFacts() throws Exception {
when(leaseDrafts.create(eq(LANDLORD_ID), any())).thenAnswer(invocation -> {
LeaseDraftService.DraftDetails details = invocation.getArgument(1);
return Lease.draft(LEASE_ID, LANDLORD_ID, details.apartmentId(), details.tenantIds(),
details.guarantors(), details.period(), details.terms(), LeaseType.STANDARD, List.of(
LeaseSpecialClause.catalog("VISIT_SALE_RELETTING", java.util.Map.of("startTime", "18:30"),
"Server rendered catalog text."),
LeaseSpecialClause.custom("Custom agreement.")));
});
mockMvc.perform(post("/api/leases").with(asLandlord()).with(csrf())
.contentType(MediaType.APPLICATION_JSON).content("""
{"apartmentId":"apartment-1","tenantIds":["tenant-1"],"guarantors":[],
"startDate":"2026-01-01","monthlyRent":900.00,"monthlyCharges":50.00,
"securityDeposit":900.00,"specialClauses":[
{"catalogId":"VISIT_SALE_RELETTING","parameters":{"startTime":"18:30"}},
{"customText":" Custom agreement. "}]}
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.specialClauses[0].catalogId").value("VISIT_SALE_RELETTING"))
.andExpect(jsonPath("$.specialClauses[0].parameters.startTime").value("18:30"))
.andExpect(jsonPath("$.specialClauses[0].customText").value(""))
.andExpect(jsonPath("$.specialClauses[0].renderedText").value("Server rendered catalog text."))
.andExpect(jsonPath("$.specialClauses[0].custom").value(false))
.andExpect(jsonPath("$.specialClauses[1].catalogId").doesNotExist())
.andExpect(jsonPath("$.specialClauses[1].customText").value("Custom agreement."))
.andExpect(jsonPath("$.specialClauses[1].renderedText").value("Custom agreement."))
.andExpect(jsonPath("$.specialClauses[1].custom").value(true));
verify(leaseDrafts).create(LANDLORD_ID, new LeaseDraftService.DraftDetails(APARTMENT_ID,
List.of(TENANT_ID), List.of(),
new RentalPeriod(LocalDate.of(2026, 1, 1), LocalDate.of(2026, 1, 1)),
new RentTerms(new BigDecimal("900.00"), new BigDecimal("50.00"), new BigDecimal("900.00")), null,
List.of(new LeaseDraftService.ClauseDetails("VISIT_SALE_RELETTING",
java.util.Map.of("startTime", "18:30"), null),
new LeaseDraftService.ClauseDetails(null, java.util.Map.of(), " Custom agreement. "))));
mockMvc.perform(post("/api/leases").with(asLandlord()).with(csrf())
.contentType(MediaType.APPLICATION_JSON).content("""
{"apartmentId":"apartment-1","tenantIds":["tenant-1"],"startDate":"2026-01-01",
"monthlyRent":900,"monthlyCharges":50,"securityDeposit":900,
"specialClauses":[{"customText":"%s"}]}
""".formatted("x".repeat(4001))))
.andExpect(status().isBadRequest());
}
@Test
void leaseArchiveAndAmendDelegateScopedCommands() throws Exception {
Lease active = activeLease();
Lease archived = activeLease();
archived.archive();
RentDue due = RentDue.create(DUE_ID, LANDLORD_ID, LEASE_ID, YearMonth.of(2026, 2),
LocalDate.of(2026, 2, 5), new BigDecimal("900.00"), new BigDecimal("50.00"));
when(activation.activate(LANDLORD_ID, LEASE_ID)).thenReturn(active);
when(rentDues.list(LANDLORD_ID, LEASE_ID)).thenReturn(List.of(due));
when(archiving.archive(LANDLORD_ID, LEASE_ID)).thenReturn(archived);
when(amendments.amend(eq(LANDLORD_ID), eq(LEASE_ID), eq(LocalDate.of(2026, 7, 1)), any()))
.thenReturn(active);
mockMvc.perform(post("/api/leases/lease-1/activate").with(asLandlord()).with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.lease.status").value("ACTIVE"))
.andExpect(jsonPath("$.rentDues[0].id").value("due-1"))
.andExpect(jsonPath("$.rentDues[0].timing").value("OVERDUE"));
mockMvc.perform(post("/api/leases/lease-1/archive").with(asLandlord()).with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("ARCHIVED"));
@@ -243,17 +369,22 @@ class RentalWebAdaptersTest {
mockMvc.perform(post("/api/leases/lease-1/amendments").with(asLandlord()).with(csrf())
.contentType(MediaType.APPLICATION_JSON).content("""
{"effectiveDate":"2026-07-01","tenantIds":["tenant-1"],"guarantors":[],
"endDate":"2027-06-30","monthlyRent":1000.00,"monthlyCharges":75.00}
"endDate":"2027-06-30","monthlyRent":1000.00,"monthlyCharges":75.00,
"dueDay":12,"chargeMethod":"PROVISION","indexFlatRate":false}
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value("lease-1"));
verify(activation).activate(LANDLORD_ID, LEASE_ID);
verify(rentDues).list(LANDLORD_ID, LEASE_ID);
verify(archiving).archive(LANDLORD_ID, LEASE_ID);
verify(amendments).amend(LANDLORD_ID, LEASE_ID, LocalDate.of(2026, 7, 1),
new AmendLeaseService.Change(List.of(TENANT_ID), List.of(), LocalDate.of(2027, 6, 30),
new BigDecimal("1000.00"), new BigDecimal("75.00")));
new BigDecimal("1000.00"), new BigDecimal("75.00"), 12, ChargeMethod.PROVISION, false));
}
@Test
void directLeaseActivationIsNotExposed() throws Exception {
mockMvc.perform(post("/api/leases/lease-1/activate").with(asLandlord()).with(csrf()))
.andExpect(status().isNotFound());
}
@Test
@@ -263,17 +394,66 @@ class RentalWebAdaptersTest {
LeaseContractVersion future = new LeaseContractVersion(initial.landlord(), initial.apartment(),
initial.tenants(), initial.guarantors(), initial.period(),
new RentTerms(new BigDecimal("1000.00"), new BigDecimal("75.00"),
initial.terms().securityDeposit()));
initial.terms().securityDeposit()), initial.leaseType(), initial.specialClauses());
lease.amend(new LeaseAmendmentId("amendment-1"), LocalDate.of(2026, 7, 1), future);
when(leaseDrafts.get(LANDLORD_ID, LEASE_ID)).thenReturn(lease);
mockMvc.perform(get("/api/leases/lease-1").with(asLandlord()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.monthlyRent").value(900.00))
.andExpect(jsonPath("$.leaseType").value("STANDARD"))
.andExpect(jsonPath("$.contract.monthlyRent").value(900.00))
.andExpect(jsonPath("$.contract.leaseType").value("STANDARD"))
.andExpect(jsonPath("$.contract.apartment.legalProfile.taxIdentifier").value("tax-1"))
.andExpect(jsonPath("$.contract.apartment.legalProfile.constructionPeriod").value("1949_1974"))
.andExpect(jsonPath("$.contract.specialClauses[0].customText").value("No smoking."))
.andExpect(jsonPath("$.contract.specialClauses[0].renderedText").value("No smoking."))
.andExpect(jsonPath("$.amendments[0].afterVersion.monthlyRent").value(1000.00));
}
@Test
void leaseRevisionEndpointsKeepAuthorAndLandlordScopeAndLegacyPdfHeaders() throws Exception {
byte[] pdf = "%PDF-revision".getBytes();
LeaseDocumentRevision revision = new LeaseDocumentRevision("revision-1", LANDLORD_ID, LEASE_ID, 2,
"FR-MEUBLE-2025-01", "source-hash", "storage-key", "pdf-hash",
Instant.parse("2026-03-01T12:00:00Z"), "subject-1", List.of(),
List.of(new LeaseDocumentWarning("DPE_MISSING", "2026-07")));
when(leaseDocuments.generateRevision(LANDLORD_ID, LEASE_ID, "subject-1")).thenReturn(revision);
when(leaseDocuments.current(LANDLORD_ID, LEASE_ID)).thenReturn(revision);
when(leaseDocuments.download(LANDLORD_ID, LEASE_ID, 2)).thenReturn(
new GeneratedDocument("bail-lease-1-revision-2.pdf", "application/pdf", pdf));
when(leaseDocuments.generate(LANDLORD_ID, LEASE_ID)).thenReturn(
new GeneratedDocument("bail-lease-1.pdf", "application/pdf", pdf));
mockMvc.perform(post("/api/leases/lease-1/revisions").with(asLandlord()).with(csrf()))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.number").value(2))
.andExpect(jsonPath("$.templateVersion").value("FR-MEUBLE-2025-01"))
.andExpect(jsonPath("$.warningCodes[0]").value("DPE_MISSING"))
.andExpect(jsonPath("$.warnings[0].code").value("DPE_MISSING"))
.andExpect(jsonPath("$.warnings[0].ruleVersion").value("2026-07"))
.andExpect(jsonPath("$.authorId").value("subject-1"));
mockMvc.perform(get("/api/leases/lease-1/revisions/current").with(asLandlord()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.sha256").value("pdf-hash"));
mockMvc.perform(get("/api/leases/lease-1/revisions/2/pdf").with(asLandlord()))
.andExpect(status().isOk())
.andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"bail-lease-1-revision-2.pdf\""))
.andExpect(content().bytes(pdf));
mockMvc.perform(get("/api/leases/lease-1/pdf").with(asLandlord()))
.andExpect(status().isOk())
.andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"bail-lease-1.pdf\""))
.andExpect(content().bytes(pdf));
verify(leaseDocuments).generateRevision(LANDLORD_ID, LEASE_ID, "subject-1");
verify(leaseDocuments).current(LANDLORD_ID, LEASE_ID);
verify(leaseDocuments).download(LANDLORD_ID, LEASE_ID, 2);
verify(leaseDocuments).generate(LANDLORD_ID, LEASE_ID);
verify(leaseDocuments, never()).generateAndDownload(any(), any(), any());
}
@Test
void rentPaymentCommandsDelegateScopedRecordingAllocationRefundAndReversal() throws Exception {
PaymentAllocation allocation = new PaymentAllocation(new PaymentAllocationId("allocation-1"), DUE_ID,
@@ -400,18 +580,27 @@ class RentalWebAdaptersTest {
"camille@example.com");
}
private static ApartmentLegalProfile legalProfile() {
return new ApartmentLegalProfile("tax-1", ApartmentLegalProfile.BuildingType.APARTMENT_BUILDING,
"Copropriété", ApartmentLegalProfile.ConstructionPeriod.PERIOD_1949_1974,
new BigDecimal("31.50"), 2, "Cave", "Cuisine équipée", "Individuel", "Individuelle", "B",
new BigDecimal("750.00"), 2024, "Habitation", "Balcon", "Local vélos", "Fibre", "2A");
}
private static Lease activeLease() {
RentalPeriod period = new RentalPeriod(LocalDate.of(2026, 1, 1), LocalDate.of(2026, 12, 31));
RentTerms terms = new RentTerms(new BigDecimal("900.00"), new BigDecimal("50.00"),
new BigDecimal("900.00"));
Lease lease = Lease.draft(LEASE_ID, LANDLORD_ID, APARTMENT_ID, List.of(TENANT_ID), List.of(), period, terms);
Lease lease = Lease.draft(LEASE_ID, LANDLORD_ID, APARTMENT_ID, List.of(TENANT_ID), List.of(), period, terms,
LeaseType.STANDARD, List.of(LeaseSpecialClause.custom("No smoking.")));
lease.activate(new LeaseSnapshot(
new LeaseSnapshot.Landlord(LANDLORD_ID, "Camille", "Martin", "camille@example.com",
"+33600000000", "1 rue A"),
new LeaseSnapshot.Apartment(APARTMENT_ID, "Studio République", "12 rue de la République"),
new LeaseSnapshot.Apartment(APARTMENT_ID, "Studio République", "12 rue de la République",
legalProfile()),
List.of(new LeaseSnapshot.Tenant(TENANT_ID, "Alex", "Bernard", "alex@example.com", "",
"2 rue B")),
List.of(), period, terms));
List.of(), period, terms, LeaseType.STANDARD, lease.specialClauses()));
return lease;
}
}
@@ -0,0 +1,133 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.apartment;
import fr.nessar.lmnporganizer.LmnpOrganizerApplication;
import fr.nessar.lmnporganizer.identity.adapter.in.security.SecurityConfiguration;
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlord;
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.RentalApiExceptionHandler;
import fr.nessar.lmnporganizer.rental.application.apartment.ApartmentDocumentService;
import fr.nessar.lmnporganizer.rental.application.landlord.LandlordAccountService;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentDocument;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentDocumentType;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
import fr.nessar.lmnporganizer.rental.domain.landlord.ExternalIdentity;
import fr.nessar.lmnporganizer.rental.domain.landlord.Landlord;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.RequestPostProcessor;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
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.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(controllers = ApartmentDocumentController.class)
@ContextConfiguration(classes = LmnpOrganizerApplication.class)
@Import({AuthenticatedLandlord.class, RentalApiExceptionHandler.class, SecurityConfiguration.class})
class ApartmentDocumentWebTest {
private static final LandlordId LANDLORD_ID = new LandlordId("landlord-1");
private static final ApartmentId APARTMENT_ID = new ApartmentId("apartment-1");
private static final byte[] PDF = "%PDF-document".getBytes(StandardCharsets.US_ASCII);
@Autowired
MockMvc mockMvc;
@MockitoBean
LandlordAccountService accounts;
@MockitoBean
ApartmentDocumentService documents;
@BeforeEach
void authenticate() {
when(accounts.identify(any())).thenReturn(Landlord.register(LANDLORD_ID,
new ExternalIdentity("subject-1"), "Camille", "Martin", "camille@example.com"));
}
@Test
void listExposesExplicitMetadataWithoutTheStorageKey() throws Exception {
when(documents.list(LANDLORD_ID, APARTMENT_ID)).thenReturn(List.of(document()));
mockMvc.perform(get("/api/apartments/apartment-1/documents").with(owner()))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].id").value("document-1"))
.andExpect(jsonPath("$[0].type").value("DPE"))
.andExpect(jsonPath("$[0].establishedOn").value("2026-07-01"))
.andExpect(jsonPath("$[0].expiresOn").value("2036-07-01"))
.andExpect(jsonPath("$[0].authorId").value("subject-1"))
.andExpect(jsonPath("$[0].uploadedAt").value("2026-07-19T12:00:00Z"))
.andExpect(jsonPath("$[0].storageKey").doesNotExist());
}
@Test
void uploadUsesTheAuthenticatedScopeAndNativeMultipartFields() throws Exception {
when(documents.upload(any(), any(), any(), any(), any(), any(), any())).thenReturn(document());
MockMultipartFile file = new MockMultipartFile("file", "dpe.pdf", MediaType.APPLICATION_PDF_VALUE, PDF);
mockMvc.perform(multipart("/api/apartments/apartment-1/documents").file(file)
.param("type", "DPE").param("establishedOn", "2026-07-01")
.param("expiresOn", "2036-07-01").with(owner()).with(csrf()))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value("document-1"))
.andExpect(jsonPath("$.type").value("DPE"));
verify(documents).upload(org.mockito.ArgumentMatchers.eq(LANDLORD_ID),
org.mockito.ArgumentMatchers.eq(APARTMENT_ID),
org.mockito.ArgumentMatchers.eq(ApartmentDocumentType.ENERGY_PERFORMANCE_DIAGNOSIS),
org.mockito.ArgumentMatchers.eq(LocalDate.of(2026, 7, 1)),
org.mockito.ArgumentMatchers.eq(LocalDate.of(2036, 7, 1)), any(),
org.mockito.ArgumentMatchers.eq("subject-1"));
}
@Test
void downloadReturnsPdfBytesWithASafeAttachmentHeader() throws Exception {
when(documents.download(LANDLORD_ID, APARTMENT_ID, "document-1"))
.thenReturn(new ApartmentDocumentService.Download(document(), PDF));
mockMvc.perform(get("/api/apartments/apartment-1/documents/document-1").with(owner()))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_PDF))
.andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"dpe.pdf\""))
.andExpect(content().bytes(PDF));
}
@Test
void deleteUsesTheAuthenticatedScope() throws Exception {
mockMvc.perform(delete("/api/apartments/apartment-1/documents/document-1")
.with(owner()).with(csrf()))
.andExpect(status().isNoContent());
verify(documents).delete(LANDLORD_ID, APARTMENT_ID, "document-1");
}
private static ApartmentDocument document() {
return new ApartmentDocument("document-1", ApartmentDocumentType.ENERGY_PERFORMANCE_DIAGNOSIS,
LocalDate.of(2026, 7, 1), LocalDate.of(2036, 7, 1), "dpe.pdf", "storage-1",
"a".repeat(64), "subject-1", Instant.parse("2026-07-19T12:00:00Z"));
}
private static RequestPostProcessor owner() {
return oidcLogin().idToken(token -> token.claim("sub", "subject-1").claim("given_name", "Camille")
.claim("family_name", "Martin").claim("email", "camille@example.com"));
}
}
@@ -0,0 +1,201 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.lease;
import fr.nessar.lmnporganizer.LmnpOrganizerApplication;
import fr.nessar.lmnporganizer.identity.adapter.in.security.SecurityConfiguration;
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlord;
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.RentalApiExceptionHandler;
import fr.nessar.lmnporganizer.rental.application.landlord.LandlordAccountService;
import fr.nessar.lmnporganizer.rental.application.lease.LeaseFinalizationService;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
import fr.nessar.lmnporganizer.rental.domain.landlord.ExternalIdentity;
import fr.nessar.lmnporganizer.rental.domain.landlord.Landlord;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentRevision;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseFinalization;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseFinalizationConflict;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseGuaranteeDeed;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSnapshot;
import fr.nessar.lmnporganizer.rental.domain.lease.RentTerms;
import fr.nessar.lmnporganizer.rental.domain.lease.RentalPeriod;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.RequestPostProcessor;
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.Set;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oidcLogin;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(controllers = LeaseFinalizationController.class)
@ContextConfiguration(classes = LmnpOrganizerApplication.class)
@Import({AuthenticatedLandlord.class, RentalApiExceptionHandler.class, SecurityConfiguration.class})
class LeaseFinalizationWebTest {
private static final LandlordId LANDLORD_ID = new LandlordId("landlord-1");
private static final LeaseId LEASE_ID = new LeaseId("lease-1");
private static final TenantId TENANT_ID = new TenantId("tenant-1");
private static final Instant NOW = Instant.parse("2026-07-19T12:00:00Z");
@Autowired
MockMvc mockMvc;
@MockitoBean
LandlordAccountService accounts;
@MockitoBean
LeaseFinalizationService finalizations;
@MockitoBean
Clock clock;
@BeforeEach
void authenticateAndFixTime() {
when(accounts.identify(any())).thenReturn(Landlord.register(LANDLORD_ID,
new ExternalIdentity("subject-1"), "Camille", "Martin", "camille@example.com"));
when(clock.instant()).thenReturn(NOW);
when(clock.getZone()).thenReturn(ZoneOffset.UTC);
}
@Test
void guaranteeUploadUsesAuthenticatedScopeAndMultipartAttestation() throws Exception {
LeaseGuaranteeDeed deed = deed();
when(finalizations.uploadGuaranteeDeed(any(), any(), any(), any(),
org.mockito.ArgumentMatchers.eq(true), any(), any())).thenReturn(deed);
mockMvc.perform(multipart("/api/leases/lease-1/guarantee-deeds").file(pdf("file"))
.param("guarantorId", "guarantor-1").param("tenantId", "tenant-1")
.param("attestation", "true").with(landlord()).with(csrf()))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.guarantorId").value("guarantor-1"))
.andExpect(jsonPath("$.tenantId").value("tenant-1"))
.andExpect(jsonPath("$.authorId").value("subject-1"));
verify(finalizations).uploadGuaranteeDeed(org.mockito.ArgumentMatchers.eq(LANDLORD_ID),
org.mockito.ArgumentMatchers.eq(LEASE_ID), org.mockito.ArgumentMatchers.eq("guarantor-1"),
org.mockito.ArgumentMatchers.eq(TENANT_ID), org.mockito.ArgumentMatchers.eq(true), any(),
org.mockito.ArgumentMatchers.eq("subject-1"));
}
@Test
void stateExposesHumanNamesSimulatorRevisionWarningsAndExistingDeed() throws Exception {
LeaseDocumentRevision revision = revision();
var requirement = new LeaseFinalizationService.GuaranteeRequirement("guarantor-1", "Alice Garant",
TENANT_ID, "Camille Locataire", deed());
when(finalizations.state(LANDLORD_ID, LEASE_ID)).thenReturn(new LeaseFinalizationService.State(revision,
List.of("SECURITY_DEPOSIT_ABOVE_TWO_MONTHS"), List.of(requirement), null));
mockMvc.perform(get("/api/leases/lease-1/finalization").with(landlord()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.revision.number").value(1))
.andExpect(jsonPath("$.guaranteeSimulatorUrl").value(
LeaseFinalizationService.GUARANTEE_SIMULATOR_URL))
.andExpect(jsonPath("$.warningCodes[0]").value("SECURITY_DEPOSIT_ABOVE_TWO_MONTHS"))
.andExpect(jsonPath("$.guaranteeRequirements[0].guarantorName").value("Alice Garant"))
.andExpect(jsonPath("$.guaranteeRequirements[0].tenantName").value("Camille Locataire"))
.andExpect(jsonPath("$.guaranteeRequirements[0].deed.sha256").value("deed-hash"));
}
@Test
void finalizationAcceptsNativeMultipartFieldsAndReturnsMetadata() throws Exception {
LeaseFinalization finalization = finalization();
when(finalizations.finalizeLease(any(), any(), any(), any(), any())).thenReturn(
new LeaseFinalizationService.Result(activeLease(), List.of(), finalization));
mockMvc.perform(multipart("/api/leases/lease-1/finalization").file(pdf("signedLease"))
.param("revisionNumber", "1").param("lastSignatureDate", "2026-07-19")
.param("allPartiesSigned", "true").param("revisionMatches", "true")
.param("confirmedWarningCodes", "SECURITY_DEPOSIT_ABOVE_TWO_MONTHS")
.with(landlord()).with(csrf()))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.lease.status").value("ACTIVE"))
.andExpect(jsonPath("$.finalization.revisionNumber").value(1))
.andExpect(jsonPath("$.finalization.lastSignatureDate").value("2026-07-19"))
.andExpect(jsonPath("$.finalization.overrides[0].ruleVersion").value("1"));
verify(finalizations).finalizeLease(org.mockito.ArgumentMatchers.eq(LANDLORD_ID),
org.mockito.ArgumentMatchers.eq(LEASE_ID),
org.mockito.ArgumentMatchers.eq(new LeaseFinalizationService.FinalizationRequest(1,
LocalDate.of(2026, 7, 19), true, true,
Set.of("SECURITY_DEPOSIT_ABOVE_TWO_MONTHS"))), any(),
org.mockito.ArgumentMatchers.eq("subject-1"));
}
@Test
void differentGuaranteeAndDoubleFinalizationConflictsMapTo409() throws Exception {
when(finalizations.uploadGuaranteeDeed(any(), any(), any(), any(), any(Boolean.class), any(), any()))
.thenThrow(new LeaseFinalizationConflict("Un acte différent existe déjà."));
when(finalizations.finalizeLease(any(), any(), any(), any(), any()))
.thenThrow(new LeaseFinalizationConflict("Ce bail est déjà finalisé."));
mockMvc.perform(multipart("/api/leases/lease-1/guarantee-deeds").file(pdf("file"))
.param("guarantorId", "guarantor-1").param("tenantId", "tenant-1")
.param("attestation", "true").with(landlord()).with(csrf()))
.andExpect(status().isConflict());
mockMvc.perform(multipart("/api/leases/lease-1/finalization").file(pdf("signedLease"))
.param("revisionNumber", "1").param("lastSignatureDate", "2026-07-19")
.param("allPartiesSigned", "true").param("revisionMatches", "true")
.with(landlord()).with(csrf()))
.andExpect(status().isConflict());
}
private static MockMultipartFile pdf(String partName) {
return new MockMultipartFile(partName, "signed.pdf", MediaType.APPLICATION_PDF_VALUE,
"%PDF-signed".getBytes());
}
private static RequestPostProcessor landlord() {
return oidcLogin().idToken(token -> token.claim("sub", "subject-1").claim("given_name", "Camille")
.claim("family_name", "Martin").claim("email", "camille@example.com"));
}
private static LeaseGuaranteeDeed deed() {
return new LeaseGuaranteeDeed("deed-1", LANDLORD_ID, LEASE_ID, "guarantor-1", TENANT_ID, "storage",
"deed.pdf", "deed-hash", "subject-1", NOW);
}
private static LeaseDocumentRevision revision() {
return new LeaseDocumentRevision("revision-1", LANDLORD_ID, LEASE_ID, 1, "FR-MEUBLE-2025-01", "source",
"storage", "revision-hash", NOW, "subject-1");
}
private static LeaseFinalization finalization() {
return new LeaseFinalization("finalization-1", LANDLORD_ID, LEASE_ID, 1, "FR-MEUBLE-2025-01",
LocalDate.of(2026, 7, 19), "signed-storage", "signed.pdf", "signed-hash", "subject-1", NOW,
List.of(new LeaseFinalization.LegalOverride("SECURITY_DEPOSIT_ABOVE_TWO_MONTHS", "1", "subject-1",
NOW)));
}
private static Lease activeLease() {
ApartmentId apartmentId = new ApartmentId("apartment-1");
RentalPeriod period = new RentalPeriod(LocalDate.of(2026, 7, 1), LocalDate.of(2027, 6, 30));
RentTerms terms = new RentTerms(new BigDecimal("900"), new BigDecimal("50"), new BigDecimal("900"));
Lease lease = Lease.draft(LEASE_ID, LANDLORD_ID, apartmentId, List.of(TENANT_ID), List.of(), period, terms);
lease.activate(new LeaseSnapshot(
new LeaseSnapshot.Landlord(LANDLORD_ID, "Camille", "Martin", "camille@example.com",
"+33600000000", "1 rue A"), new LeaseSnapshot.Apartment(apartmentId, "Studio", "2 rue B"),
List.of(new LeaseSnapshot.Tenant(TENANT_ID, "Alex", "Bernard", "alex@example.com", "", "3 rue C")),
List.of(), lease.period(), terms));
return lease;
}
}
@@ -0,0 +1,115 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.rent;
import fr.nessar.lmnporganizer.LmnpOrganizerApplication;
import fr.nessar.lmnporganizer.identity.adapter.in.security.SecurityConfiguration;
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlord;
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.RentalApiExceptionHandler;
import fr.nessar.lmnporganizer.rental.application.landlord.LandlordAccountService;
import fr.nessar.lmnporganizer.rental.application.rent.GenerateRentReceiptService;
import fr.nessar.lmnporganizer.rental.application.rent.RefreshRentScheduleService;
import fr.nessar.lmnporganizer.rental.application.rent.RentDueQueryService;
import fr.nessar.lmnporganizer.rental.domain.landlord.ExternalIdentity;
import fr.nessar.lmnporganizer.rental.domain.landlord.Landlord;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.rent.RentDue;
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueId;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.RequestPostProcessor;
import java.math.BigDecimal;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.YearMonth;
import java.time.ZoneOffset;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oidcLogin;
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.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(controllers = RentDueController.class)
@ContextConfiguration(classes = LmnpOrganizerApplication.class)
@Import({AuthenticatedLandlord.class, RentalApiExceptionHandler.class, SecurityConfiguration.class})
class RentDueRefreshWebTest {
private static final LandlordId LANDLORD_ID = new LandlordId("landlord-1");
@Autowired
MockMvc mockMvc;
@MockitoBean
LandlordAccountService accounts;
@MockitoBean
RentDueQueryService rentDues;
@MockitoBean
RefreshRentScheduleService refresh;
@MockitoBean
GenerateRentReceiptService receipts;
@MockitoBean
Clock clock;
@BeforeEach
void authenticateAndFixTime() {
when(accounts.identify(any())).thenReturn(Landlord.register(LANDLORD_ID,
new ExternalIdentity("subject-1"), "Camille", "Martin", "camille@example.com"));
when(clock.instant()).thenReturn(Instant.parse("2026-12-01T10:00:00Z"));
when(clock.getZone()).thenReturn(ZoneOffset.UTC);
}
@Test
void refreshUsesTheAuthenticatedLandlordAndReturnsTheUpdatedSchedule() throws Exception {
when(refresh.refresh(LANDLORD_ID)).thenReturn(List.of(due("due-2027-01", YearMonth.of(2027, 1))));
mockMvc.perform(post("/api/rent-dues/refresh").with(owner()).with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].id").value("due-2027-01"))
.andExpect(jsonPath("$[0].month").value("2027-01"))
.andExpect(jsonPath("$[0].settlement").value("UNPAID"));
verify(refresh).refresh(LANDLORD_ID);
}
@Test
void refreshRequiresACsrfToken() throws Exception {
mockMvc.perform(post("/api/rent-dues/refresh").with(owner()))
.andExpect(status().isForbidden());
verify(refresh, never()).refresh(any());
}
@Test
void listingRemainsAQueryWithoutImplicitRefresh() throws Exception {
when(rentDues.list(LANDLORD_ID)).thenReturn(List.of(due("due-2026-12", YearMonth.of(2026, 12))));
mockMvc.perform(get("/api/rent-dues").with(owner()))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].month").value("2026-12"));
verify(rentDues).list(LANDLORD_ID);
verify(refresh, never()).refresh(any());
}
private static RentDue due(String id, YearMonth month) {
return RentDue.create(new RentDueId(id), LANDLORD_ID, new LeaseId("lease-1"), month,
month.atDay(5), new BigDecimal("1200"), new BigDecimal("120"));
}
private static RequestPostProcessor owner() {
return oidcLogin().idToken(token -> token.claim("sub", "subject-1").claim("given_name", "Camille")
.claim("family_name", "Martin").claim("email", "camille@example.com"));
}
}
@@ -0,0 +1,244 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.rent;
import fr.nessar.lmnporganizer.LmnpOrganizerApplication;
import fr.nessar.lmnporganizer.identity.adapter.in.security.SecurityConfiguration;
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlord;
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.RentalApiExceptionHandler;
import fr.nessar.lmnporganizer.rental.application.landlord.LandlordAccountService;
import fr.nessar.lmnporganizer.rental.application.rent.RentRevisionService;
import fr.nessar.lmnporganizer.rental.application.shared.IrlIndexUnavailable;
import fr.nessar.lmnporganizer.rental.domain.landlord.ExternalIdentity;
import fr.nessar.lmnporganizer.rental.domain.landlord.Landlord;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.rent.RentDue;
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueId;
import fr.nessar.lmnporganizer.rental.domain.rent.RentRevision;
import fr.nessar.lmnporganizer.rental.domain.rent.RentRevisionId;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.RequestPostProcessor;
import org.springframework.mock.web.MockMultipartFile;
import java.math.BigDecimal;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List;
import java.util.Set;
import java.time.YearMonth;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oidcLogin;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(controllers = RentRevisionController.class)
@ContextConfiguration(classes = LmnpOrganizerApplication.class)
@Import({AuthenticatedLandlord.class, RentalApiExceptionHandler.class, SecurityConfiguration.class})
class RentRevisionWebTest {
private static final LandlordId LANDLORD_ID = new LandlordId("landlord-1");
private static final LeaseId LEASE_ID = new LeaseId("lease-1");
@Autowired
MockMvc mockMvc;
@MockitoBean
LandlordAccountService accounts;
@MockitoBean
RentRevisionService revisions;
@MockitoBean
Clock clock;
@BeforeEach
void authenticate() {
when(accounts.identify(any())).thenReturn(Landlord.register(LANDLORD_ID,
new ExternalIdentity("subject-1"), "Camille", "Martin", "camille@example.com"));
}
@Test
void previewExposesTheOfficialInputsCeilingsSourceAndVersionedWarnings() throws Exception {
when(revisions.preview(any(), any(), any())).thenReturn(new RentRevisionService.Preview(
LEASE_ID, LocalDate.of(2027, 9, 1), LocalDate.of(2028, 8, 31), "001515333", false,
"INSEE", 2, 2026, 2027, new BigDecimal("143.46"), new BigDecimal("146.68"),
new BigDecimal("900.00"), new BigDecimal("920.20"), new BigDecimal("50.00"),
new BigDecimal("51.12"), List.of(new RentRevisionService.Warning(
"IRL_REVISION_WITH_F_OR_G_DPE", "2026-07"))));
mockMvc.perform(post("/api/leases/lease-1/rent-revisions/preview").with(owner()).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"timeZone\":\"America/Los_Angeles\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.leaseId").value("lease-1"))
.andExpect(jsonPath("$.annualRevisionDate").value("2027-09-01"))
.andExpect(jsonPath("$.requestDeadline").value("2028-08-31"))
.andExpect(jsonPath("$.seriesId").value("001515333"))
.andExpect(jsonPath("$.seriesOverridden").value(false))
.andExpect(jsonPath("$.indexSource").value("INSEE"))
.andExpect(jsonPath("$.referenceQuarter").value(2))
.andExpect(jsonPath("$.previousIndexYear").value(2026))
.andExpect(jsonPath("$.newIndexYear").value(2027))
.andExpect(jsonPath("$.previousIndex").value(143.46))
.andExpect(jsonPath("$.newIndex").value(146.68))
.andExpect(jsonPath("$.previousRent").value(900.00))
.andExpect(jsonPath("$.rentCeiling").value(920.20))
.andExpect(jsonPath("$.previousCharges").value(50.00))
.andExpect(jsonPath("$.chargeCeiling").value(51.12))
.andExpect(jsonPath("$.warnings[0].code").value("IRL_REVISION_WITH_F_OR_G_DPE"))
.andExpect(jsonPath("$.warnings[0].ruleVersion").value("2026-07"));
verify(revisions).preview(LANDLORD_ID, LEASE_ID,
RentRevisionService.PreviewRequest.automatic(ZoneId.of("America/Los_Angeles")));
}
@Test
void unavailableOfficialAndCachedIndicesExposeAStableManualFallbackCode() throws Exception {
when(revisions.preview(any(), any(), any())).thenThrow(new IrlIndexUnavailable());
mockMvc.perform(post("/api/leases/lease-1/rent-revisions/preview").with(owner()).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"timeZone\":\"America/Los_Angeles\"}"))
.andExpect(status().isServiceUnavailable())
.andExpect(jsonPath("$.code").value("IRL_INDEX_UNAVAILABLE"))
.andExpect(jsonPath("$.message").value(
"Les indices IRL officiels sont indisponibles ; saisissez-les manuellement après confirmation."));
}
@Test
void rejectsAnUnknownDeliveryMethodWithoutExposingInternalClassNames() throws Exception {
mockMvc.perform(multipart("/api/leases/lease-1/rent-revisions/revision-1/apply")
.param("timeZone", "Europe/Paris").param("sentOn", "2027-09-02")
.param("deliveryMethod", "PIGEON").param("deliveryConfirmed", "true")
.with(owner()).with(csrf()))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message").value("Le mode d'envoi est invalide."));
}
@Test
void readsAProofThroughTheAuthenticatedLandlordAndLeaseScope() throws Exception {
when(revisions.proof(LANDLORD_ID, LEASE_ID, new fr.nessar.lmnporganizer.rental.domain.rent.RentRevisionId(
"revision-1"))).thenReturn(new RentRevisionService.ProofDownload("envoi.pdf", "%PDF-proof".getBytes()));
mockMvc.perform(get("/api/leases/lease-1/rent-revisions/revision-1/proof").with(owner()))
.andExpect(status().isOk())
.andExpect(header().string("Content-Disposition", "attachment; filename=\"envoi.pdf\""))
.andExpect(content().contentType(MediaType.APPLICATION_PDF))
.andExpect(content().bytes("%PDF-proof".getBytes()));
verify(revisions).proof(LANDLORD_ID, LEASE_ID,
new fr.nessar.lmnporganizer.rental.domain.rent.RentRevisionId("revision-1"));
}
@Test
void proposesAndMapsTheFrozenRevisionResponse() throws Exception {
RentRevision proposed = revision(false, false);
when(revisions.propose(any(), any(), any(), any())).thenReturn(proposed);
mockMvc.perform(post("/api/leases/lease-1/rent-revisions").with(owner()).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"timeZone":"Europe/Paris","requestedOn":"2027-09-02",
"chosenRent":915.00,"chosenCharges":51.00,
"confirmedWarningCodes":["IRL_REVISION_WITH_F_OR_G_DPE"]}
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value("revision-1"))
.andExpect(jsonPath("$.status").value("PROPOSED"))
.andExpect(jsonPath("$.effectiveOn").value("2027-09-02"))
.andExpect(jsonPath("$.previousIndexYear").value(2026))
.andExpect(jsonPath("$.newIndexYear").value(2027))
.andExpect(jsonPath("$.chosenRent").value(915.00))
.andExpect(jsonPath("$.warningOverrides[0].ruleVersion").value("2026-07"))
.andExpect(jsonPath("$.application").doesNotExist());
verify(revisions).propose(eqLandlord(), org.mockito.ArgumentMatchers.eq(LEASE_ID), any(),
org.mockito.ArgumentMatchers.eq("subject-1"));
}
@Test
void listsAndDownloadsTheFrozenLetter() throws Exception {
RentRevision proposed = revision(false, false);
when(revisions.list(LANDLORD_ID, LEASE_ID)).thenReturn(List.of(proposed));
when(revisions.letter(LANDLORD_ID, LEASE_ID, new RentRevisionId("revision-1")))
.thenReturn(new RentRevisionService.Letter("revision-loyer.pdf", "%PDF-letter".getBytes()));
mockMvc.perform(get("/api/leases/lease-1/rent-revisions").with(owner()))
.andExpect(status().isOk()).andExpect(jsonPath("$[0].id").value("revision-1"));
mockMvc.perform(get("/api/leases/lease-1/rent-revisions/revision-1/letter").with(owner()))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_PDF))
.andExpect(content().bytes("%PDF-letter".getBytes()));
}
@Test
void appliesMultipartProofAndMapsAffectedRentDues() throws Exception {
ZoneId zone = ZoneId.of("Europe/Paris");
when(clock.withZone(zone)).thenReturn(Clock.fixed(Instant.parse("2027-09-02T10:00:00Z"), zone));
RentRevision applied = revision(true, true);
RentDue due = RentDue.create(new RentDueId("due-1"), LANDLORD_ID, LEASE_ID,
YearMonth.of(2027, 9), LocalDate.of(2027, 9, 5), new BigDecimal("914.50"),
new BigDecimal("50.97"));
when(revisions.apply(any(), any(), any(), any(), any()))
.thenReturn(new RentRevisionService.ApplyResult(applied, List.of(due)));
MockMultipartFile proof = new MockMultipartFile("proof", "preuve.pdf", "application/pdf",
"%PDF-proof".getBytes());
mockMvc.perform(multipart("/api/leases/lease-1/rent-revisions/revision-1/apply").file(proof)
.param("timeZone", "Europe/Paris").param("sentOn", "2027-09-02")
.param("deliveryMethod", "POSTAL_LETTER").param("deliveryConfirmed", "true")
.with(owner()).with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.revision.status").value("APPLIED"))
.andExpect(jsonPath("$.revision.effectiveOn").value("2027-09-02"))
.andExpect(jsonPath("$.revision.application.proof.fileName").value("preuve.pdf"))
.andExpect(jsonPath("$.rentDues[0].id").value("due-1"))
.andExpect(jsonPath("$.rentDues[0].rent").value(914.50));
}
private static LandlordId eqLandlord() {
return org.mockito.ArgumentMatchers.eq(LANDLORD_ID);
}
private static RentRevision revision(boolean applied, boolean withProof) {
Instant now = Instant.parse("2027-09-02T10:00:00Z");
RentRevision revision = new RentRevision(new RentRevisionId("revision-1"), LANDLORD_ID, LEASE_ID,
new RentRevision.Window(LocalDate.of(2027, 9, 1), LocalDate.of(2028, 8, 31)),
new RentRevision.IndexReference("001515333", false, false, RentRevision.IndexSource.INSEE,
false, 2, 2026, 2027, new BigDecimal("143.46"), new BigDecimal("146.68")),
new RentRevision.Amounts(new BigDecimal("900"), new BigDecimal("920.20"),
new BigDecimal("915"), new BigDecimal("50"), new BigDecimal("51.12"),
new BigDecimal("51")),
new RentRevision.Request(LocalDate.of(2027, 9, 2), LocalDate.of(2027, 9, 2), "letter",
"a".repeat(64), "subject-1", now),
List.of(new RentRevision.LegalOverride("IRL_REVISION_WITH_F_OR_G_DPE", "2026-07", "subject-1",
now)), null, -1);
if (!applied) {
return revision;
}
RentRevision.Proof proof = withProof
? new RentRevision.Proof("proof", "preuve.pdf", "b".repeat(64), "subject-1", now)
: null;
return revision.apply(new RentRevision.Application(LocalDate.of(2027, 9, 2),
RentRevision.DeliveryMethod.POSTAL_LETTER, LocalDate.of(2027, 9, 2), proof, "subject-1", now));
}
private static RequestPostProcessor owner() {
return oidcLogin().idToken(token -> token.claim("sub", "subject-1").claim("given_name", "Camille")
.claim("family_name", "Martin").claim("email", "camille@example.com"));
}
}
@@ -0,0 +1,97 @@
package fr.nessar.lmnporganizer.rental.adapter.out.filesystem;
import fr.nessar.lmnporganizer.rental.adapter.in.config.RentalStorageProperties;
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentStorageFailure;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class FileSystemLeaseDocumentStorageTest {
@TempDir
Path temporaryDirectory;
@Test
void storesReadsAndDiscardsAnImmutableUuidNamedFile() {
Path root = temporaryDirectory.resolve("leases");
FileSystemLeaseDocumentStorage storage = new FileSystemLeaseDocumentStorage(
new RentalStorageProperties(temporaryDirectory.resolve("attachments")));
String key = storage.store(new byte[]{1, 2, 3});
assertTrue(key.endsWith(".pdf"));
assertArrayEquals(new byte[]{1, 2, 3}, storage.read(key));
assertTrue(java.nio.file.Files.exists(root.resolve(key)));
storage.discard(key);
assertFalse(java.nio.file.Files.exists(root.resolve(key)));
}
@Test
void rejectsKeysThatWereNotGeneratedByTheServer() {
FileSystemLeaseDocumentStorage storage = new FileSystemLeaseDocumentStorage(
new RentalStorageProperties(temporaryDirectory.resolve("attachments")));
assertThrows(AttachmentStorageFailure.class, () -> storage.read("../outside.pdf"));
assertThrows(AttachmentStorageFailure.class, () -> storage.read("not-a-uuid.pdf"));
}
@Test
void stagedDeletionCanBeRestoredCompletedOrRecoveredAfterRestart() {
Path root = temporaryDirectory.resolve("leases");
FileSystemLeaseDocumentStorage storage = new FileSystemLeaseDocumentStorage(
new RentalStorageProperties(temporaryDirectory.resolve("attachments")));
String restoredKey = storage.store(new byte[]{1});
var restored = storage.stageDeletion(restoredKey);
assertFalse(java.nio.file.Files.exists(root.resolve(restoredKey)));
storage.restore(restored);
assertArrayEquals(new byte[]{1}, storage.read(restoredKey));
var interrupted = storage.stageDeletion(restoredKey);
assertFalse(java.nio.file.Files.exists(root.resolve(restoredKey)));
storage = new FileSystemLeaseDocumentStorage(
new RentalStorageProperties(temporaryDirectory.resolve("attachments")));
assertArrayEquals(new byte[]{1}, storage.read(restoredKey));
var completed = storage.stageDeletion(restoredKey);
storage.completeDeletion(completed);
assertFalse(java.nio.file.Files.exists(root.resolve(restoredKey)));
assertTrue(stagingIsEmpty(root));
}
@Test
void startupFinishesACommittedDeletionWhoseCleanupWasInterrupted() throws Exception {
Path root = temporaryDirectory.resolve("leases");
FileSystemLeaseDocumentStorage storage = new FileSystemLeaseDocumentStorage(
new RentalStorageProperties(temporaryDirectory.resolve("attachments")));
String key = storage.store(new byte[]{1});
storage.stageDeletion(key);
Path deletion = root.resolve(".staging").resolve(key);
java.nio.file.Files.move(deletion.resolve("backup"), deletion.resolve("committed"),
StandardCopyOption.ATOMIC_MOVE);
new FileSystemLeaseDocumentStorage(
new RentalStorageProperties(temporaryDirectory.resolve("attachments")));
assertFalse(java.nio.file.Files.exists(root.resolve(key)));
assertTrue(stagingIsEmpty(root));
}
private static boolean stagingIsEmpty(Path root) {
Path staging = root.resolve(".staging");
if (!java.nio.file.Files.exists(staging)) {
return true;
}
try (var files = java.nio.file.Files.list(staging)) {
return files.findAny().isEmpty();
} catch (java.io.IOException failure) {
throw new AssertionError(failure);
}
}
}
@@ -0,0 +1,218 @@
package fr.nessar.lmnporganizer.rental.adapter.out.insee;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import fr.nessar.lmnporganizer.rental.application.port.out.IrlIndexProvider;
import fr.nessar.lmnporganizer.rental.application.shared.IrlIndexUnavailable;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.data.mongodb.core.MongoTemplate;
import java.io.IOException;
import java.math.BigDecimal;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class InseeIrlIndexProviderTest {
private static final String SDMX_XML = """
<?xml version="1.0" encoding="UTF-8"?>
<message:StructureSpecificData
xmlns:message="http://www.sdmx.org/resources/sdmxml/schemas/v2_1/message"
xmlns:ss="http://www.sdmx.org/resources/sdmxml/schemas/v2_1/data/structurespecific">
<message:DataSet>
<ss:Series IDBANK="001515333">
<ss:Obs TIME_PERIOD="2027-Q2" OBS_VALUE="146.68" />
<ss:Obs TIME_PERIOD="2026-Q2" OBS_VALUE="143.46" />
</ss:Series>
</message:DataSet>
</message:StructureSpecificData>
""";
@Test
void loadsTheTwoOfficialSdmxObservationsAndCachesEachQuarter() throws Exception {
MongoTemplate mongoTemplate = mock(MongoTemplate.class);
try (SdmxServer server = new SdmxServer(200, SDMX_XML)) {
var provider = new InseeIrlIndexProvider(HttpClient.newHttpClient(), mongoTemplate, server.baseUri(),
Duration.ofSeconds(2));
IrlIndexProvider.Indexes indexes = provider.indexes("001515333",
new IrlIndexProvider.Period(2026, 2), new IrlIndexProvider.Period(2027, 2));
assertThat(indexes.previousIndex()).isEqualByComparingTo("143.46");
assertThat(indexes.newIndex()).isEqualByComparingTo("146.68");
assertThat(indexes.source()).isEqualTo(IrlIndexProvider.Source.INSEE);
assertThat(server.requestUri().getPath()).isEqualTo("/series/001515333");
assertThat(server.requestUri().getRawQuery()).isEqualTo("startPeriod=2026-Q2&endPeriod=2027-Q2");
assertThat(server.accept()).isEqualTo(
"application/vnd.sdmx.structurespecificdata+xml;version=2.1");
ArgumentCaptor<InseeIrlIndexProvider.CachedIndex> cached =
ArgumentCaptor.forClass(InseeIrlIndexProvider.CachedIndex.class);
verify(mongoTemplate, times(2)).save(cached.capture());
assertThat(cached.getAllValues())
.extracting(InseeIrlIndexProvider.CachedIndex::id)
.containsExactly("001515333:2026-Q2", "001515333:2027-Q2");
assertThat(cached.getAllValues())
.extracting(InseeIrlIndexProvider.CachedIndex::value)
.containsExactlyElementsOf(List.of(indexes.previousIndex(), indexes.newIndex()));
}
}
@Test
void fallsBackToTheTwoCachedQuartersWhenInseeIsUnavailable() throws Exception {
MongoTemplate mongoTemplate = mock(MongoTemplate.class);
cacheTwoQuarters(mongoTemplate);
try (SdmxServer server = new SdmxServer(503, "Unavailable")) {
var provider = new InseeIrlIndexProvider(HttpClient.newHttpClient(), mongoTemplate, server.baseUri(),
Duration.ofSeconds(2));
IrlIndexProvider.Indexes indexes = provider.indexes("001515333",
new IrlIndexProvider.Period(2026, 2), new IrlIndexProvider.Period(2027, 2));
assertThat(indexes.previousIndex()).isEqualByComparingTo("143.46");
assertThat(indexes.newIndex()).isEqualByComparingTo("146.68");
assertThat(indexes.source()).isEqualTo(IrlIndexProvider.Source.CACHE);
}
}
@Test
void rejectsExternalXmlEntitiesWithoutContactingTheirTarget() throws Exception {
MongoTemplate mongoTemplate = mock(MongoTemplate.class);
try (SdmxServer externalDtd = new SdmxServer(200, "");
SdmxServer insee = new SdmxServer(200, xmlWithExternalDtd(externalDtd.baseUri().resolve("evil.dtd")))) {
var provider = new InseeIrlIndexProvider(HttpClient.newHttpClient(), mongoTemplate, insee.baseUri(),
Duration.ofSeconds(2));
assertThatThrownBy(() -> provider.indexes("001515333",
new IrlIndexProvider.Period(2026, 2), new IrlIndexProvider.Period(2027, 2)))
.isInstanceOf(IrlIndexUnavailable.class);
assertThat(externalDtd.requestUri()).isNull();
}
}
@Test
void fallsBackToCacheWhenTheSdmxResponseExceedsTheSafetyLimit() throws Exception {
MongoTemplate mongoTemplate = mock(MongoTemplate.class);
cacheTwoQuarters(mongoTemplate);
String oversizedXml = SDMX_XML.replace("</message:DataSet>",
" ".repeat(256 * 1024 + 1) + "</message:DataSet>");
try (SdmxServer server = new SdmxServer(200, oversizedXml)) {
var provider = new InseeIrlIndexProvider(HttpClient.newHttpClient(), mongoTemplate, server.baseUri(),
Duration.ofSeconds(2));
IrlIndexProvider.Indexes indexes = provider.indexes("001515333",
new IrlIndexProvider.Period(2026, 2), new IrlIndexProvider.Period(2027, 2));
assertThat(indexes.source()).isEqualTo(IrlIndexProvider.Source.CACHE);
}
}
@Test
void fallsBackToCacheWhenAnOfficialObservationIsNotNumeric() throws Exception {
MongoTemplate mongoTemplate = mock(MongoTemplate.class);
cacheTwoQuarters(mongoTemplate);
try (SdmxServer server = new SdmxServer(200, SDMX_XML.replace("143.46", "NaN"))) {
var provider = new InseeIrlIndexProvider(HttpClient.newHttpClient(), mongoTemplate, server.baseUri(),
Duration.ofSeconds(2));
IrlIndexProvider.Indexes indexes = provider.indexes("001515333",
new IrlIndexProvider.Period(2026, 2), new IrlIndexProvider.Period(2027, 2));
assertThat(indexes.source()).isEqualTo(IrlIndexProvider.Source.CACHE);
}
}
@Test
void reportsUnavailableWhenEitherCachedQuarterIsMissing() throws Exception {
MongoTemplate mongoTemplate = mock(MongoTemplate.class);
when(mongoTemplate.findById("001515333:2026-Q2", InseeIrlIndexProvider.CachedIndex.class))
.thenReturn(new InseeIrlIndexProvider.CachedIndex("001515333:2026-Q2", "001515333", 2026, 2,
new BigDecimal("143.46")));
try (SdmxServer server = new SdmxServer(503, "Unavailable")) {
var provider = new InseeIrlIndexProvider(HttpClient.newHttpClient(), mongoTemplate, server.baseUri(),
Duration.ofSeconds(2));
assertThatThrownBy(() -> provider.indexes("001515333",
new IrlIndexProvider.Period(2026, 2), new IrlIndexProvider.Period(2027, 2)))
.isInstanceOf(IrlIndexUnavailable.class);
}
}
@Test
void rejectsAnUnsupportedSeriesBeforeCallingInfrastructure() {
MongoTemplate mongoTemplate = mock(MongoTemplate.class);
var provider = new InseeIrlIndexProvider(HttpClient.newHttpClient(), mongoTemplate,
URI.create("http://127.0.0.1:1/series/"), Duration.ofSeconds(2));
assertThatThrownBy(() -> provider.indexes("001515334",
new IrlIndexProvider.Period(2026, 2), new IrlIndexProvider.Period(2027, 2)))
.isInstanceOf(IllegalArgumentException.class);
verifyNoInteractions(mongoTemplate);
}
private static String xmlWithExternalDtd(URI externalDtd) {
return SDMX_XML.replace("<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
+ "<!DOCTYPE StructureSpecificData SYSTEM \"" + externalDtd + "\">");
}
private static void cacheTwoQuarters(MongoTemplate mongoTemplate) {
when(mongoTemplate.findById("001515333:2026-Q2", InseeIrlIndexProvider.CachedIndex.class))
.thenReturn(new InseeIrlIndexProvider.CachedIndex("001515333:2026-Q2", "001515333", 2026, 2,
new BigDecimal("143.46")));
when(mongoTemplate.findById("001515333:2027-Q2", InseeIrlIndexProvider.CachedIndex.class))
.thenReturn(new InseeIrlIndexProvider.CachedIndex("001515333:2027-Q2", "001515333", 2027, 2,
new BigDecimal("146.68")));
}
private static final class SdmxServer implements AutoCloseable {
private final HttpServer server;
private final AtomicReference<URI> requestUri = new AtomicReference<>();
private final AtomicReference<String> accept = new AtomicReference<>();
private SdmxServer(int status, String body) throws IOException {
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/series/", exchange -> respond(exchange, status, body));
server.start();
}
private void respond(HttpExchange exchange, int status, String body) throws IOException {
requestUri.set(exchange.getRequestURI());
accept.set(exchange.getRequestHeaders().getFirst("Accept"));
byte[] content = body.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/xml");
exchange.sendResponseHeaders(status, content.length);
exchange.getResponseBody().write(content);
exchange.close();
}
private URI baseUri() {
return URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/series/");
}
private URI requestUri() {
return requestUri.get();
}
private String accept() {
return accept.get();
}
@Override
public void close() {
server.stop(0);
}
}
}
@@ -9,8 +9,12 @@ import static org.mockito.Mockito.when;
import com.mongodb.client.result.DeleteResult;
import fr.nessar.lmnporganizer.rental.domain.apartment.Apartment;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentDocumentType;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import org.bson.Document;
import org.junit.jupiter.api.Test;
@@ -54,6 +58,53 @@ class MongoApartmentRepositoryTest {
assertThat(saved.version()).isZero();
assertThat(MongoApartmentRepository.toDocument(persisted).version()).isEqualTo(4);
assertThat(mapped.purchasePrice()).isEqualByComparingTo("175000.25");
assertThat(mapped.legalProfile()).isEqualTo(apartment.legalProfile());
assertThat(document.legalProfile().constructionPeriod()).isEqualTo("1949_1974");
}
@Test
void roundTripsApartmentDocuments() {
fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentDocument apartmentDocument =
new fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentDocument("document-1",
ApartmentDocumentType.ENERGY_PERFORMANCE_DIAGNOSIS, LocalDate.of(2026, 7, 1),
LocalDate.of(2036, 7, 1), "dpe.pdf", "storage-1", "a".repeat(64), "subject-1",
Instant.parse("2026-07-19T12:00:00Z"));
Apartment source = Apartment.reconstitute(apartment().id(), LANDLORD_ID, apartment().name(),
apartment().address(), apartment().purchasePrice(), apartment().description(),
apartment().legalProfile(), java.util.Set.of(), List.of(apartmentDocument), 4);
fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentDocument stored =
MongoApartmentRepository.toDomain(MongoApartmentRepository.toDocument(source)).documents().getFirst();
assertThat(stored).isEqualTo(apartmentDocument);
}
@Test
void defaultsMissingLegacyLegalProfiles() {
ApartmentDocument legacy = new ApartmentDocument(APARTMENT_ID, LANDLORD_ID.value(), "Garden flat",
"1 Main Street", new BigDecimal("175000.25"), "Ground floor", null, null, List.of(), 2L);
Apartment restored = MongoApartmentRepository.toDomain(legacy);
assertThat(restored.legalProfile()).isEqualTo(ApartmentLegalProfile.empty());
assertThat(restored.documents()).isEmpty();
assertThat(restored.version()).isEqualTo(2);
}
@Test
void readsLegacyEnergyEstimatesWithoutAReferenceYear() {
Apartment legacyApartment = new Apartment(new ApartmentId(APARTMENT_ID), LANDLORD_ID, "Garden flat",
"1 Main Street", new BigDecimal("175000.25"), "Ground floor",
new ApartmentLegalProfile("tax-1", null, "", null, null, 0, "", "", "", "", "C",
new BigDecimal("750.00"), "Habitation", "", "", "", "75"));
when(mongoTemplate.findOne(any(Query.class), eq(ApartmentDocument.class)))
.thenReturn(MongoApartmentRepository.toDocument(legacyApartment));
MongoApartmentRepository repository = new MongoApartmentRepository(mongoTemplate);
Apartment restored = repository.find(LANDLORD_ID, legacyApartment.id()).orElseThrow();
assertThat(restored.legalProfile().estimatedAnnualEnergyCost()).isEqualByComparingTo("750.00");
assertThat(restored.legalProfile().estimatedAnnualEnergyCostReferenceYear()).isNull();
}
@Test
@@ -115,6 +166,10 @@ class MongoApartmentRepositoryTest {
"Garden flat",
"1 Main Street",
new BigDecimal("175000.25"),
"Ground floor");
"Ground floor",
new ApartmentLegalProfile("tax-1", ApartmentLegalProfile.BuildingType.APARTMENT_BUILDING,
"Copropriété", ApartmentLegalProfile.ConstructionPeriod.PERIOD_1949_1974,
new BigDecimal("31.50"), 2, "Cave", "Cuisine équipée", "Individuel", "Individuelle",
"C", new BigDecimal("750.00"), 2024, "Habitation", "Balcon", "Local vélos", "Fibre", "75"));
}
}
@@ -0,0 +1,81 @@
package fr.nessar.lmnporganizer.rental.adapter.out.mongodb.lease;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentDocumentType;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentAnnex;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentRevision;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentRevisionConflict;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentWarning;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Query;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class MongoLeaseDocumentRevisionRepositoryTest {
private static final LandlordId LANDLORD_ID = new LandlordId("landlord-1");
private static final LeaseId LEASE_ID = new LeaseId("lease-1");
@Mock
MongoTemplate mongoTemplate;
@Test
void currentLookupIsLandlordScopedAndReturnsHighestRevision() {
var document = new MongoLeaseDocumentRevisionRepository.RevisionDocument("revision-1", "landlord-1",
"lease-1", 2, "FR-MEUBLE-2025-01", "source", "storage", "hash",
Instant.parse("2026-07-19T12:00:00Z"), "subject-1", null, null);
when(mongoTemplate.findOne(any(Query.class),
org.mockito.ArgumentMatchers.eq(MongoLeaseDocumentRevisionRepository.RevisionDocument.class)))
.thenReturn(document);
MongoLeaseDocumentRevisionRepository repository = new MongoLeaseDocumentRevisionRepository(mongoTemplate);
LeaseDocumentRevision found = repository.findCurrent(LANDLORD_ID, LEASE_ID).orElseThrow();
assertEquals(2, found.number());
assertEquals(List.of(), found.annexes());
assertEquals(List.of(), found.warnings());
ArgumentCaptor<Query> query = ArgumentCaptor.forClass(Query.class);
verify(mongoTemplate).findOne(query.capture(),
org.mockito.ArgumentMatchers.eq(MongoLeaseDocumentRevisionRepository.RevisionDocument.class));
assertEquals("landlord-1", query.getValue().getQueryObject().getString("landlordId"));
assertEquals("lease-1", query.getValue().getQueryObject().getString("leaseId"));
assertEquals(-1, query.getValue().getSortObject().getInteger("number"));
assertEquals(1, query.getValue().getLimit());
}
@Test
void insertPreservesMetadataAndTranslatesUniqueConflicts() {
LeaseDocumentAnnex annex = new LeaseDocumentAnnex(LeaseDocumentAnnex.Category.APARTMENT_DOCUMENT,
ApartmentDocumentType.ENERGY_PERFORMANCE_DIAGNOSIS, "dpe-1", "", "dpe.pdf", "dossier/dpe.pdf",
"0".repeat(64), LocalDate.of(2026, 1, 1), LocalDate.of(2036, 1, 1),
Instant.parse("2026-01-01T12:00:00Z"));
LeaseDocumentRevision revision = new LeaseDocumentRevision("revision-1", LANDLORD_ID, LEASE_ID, 1,
"FR-MEUBLE-2025-01", "source", "storage", "hash",
Instant.parse("2026-07-19T12:00:00Z"), "subject-1", List.of(annex),
List.of(new LeaseDocumentWarning("DPE_EXPIRED", "2026-07")));
when(mongoTemplate.insert(any(MongoLeaseDocumentRevisionRepository.RevisionDocument.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
MongoLeaseDocumentRevisionRepository repository = new MongoLeaseDocumentRevisionRepository(mongoTemplate);
assertEquals(revision, repository.save(revision));
when(mongoTemplate.insert(any(MongoLeaseDocumentRevisionRepository.RevisionDocument.class)))
.thenThrow(new DuplicateKeyException("duplicate"));
assertThrows(LeaseDocumentRevisionConflict.class, () -> repository.save(revision));
}
}
@@ -0,0 +1,81 @@
package fr.nessar.lmnporganizer.rental.adapter.out.mongodb.lease;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseFinalization;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseFinalizationConflict;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseGuaranteeDeed;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Query;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class MongoLeaseFinalizationRepositoriesTest {
private static final LandlordId LANDLORD_ID = new LandlordId("landlord-1");
private static final LeaseId LEASE_ID = new LeaseId("lease-1");
private static final Instant NOW = Instant.parse("2026-07-19T12:00:00Z");
@Mock
MongoTemplate mongoTemplate;
@Test
void finalizationLookupIsScopedAndLegacyOverridesDefaultToEmpty() {
var document = new MongoLeaseFinalizationRepository.FinalizationDocument("finalization-1", "landlord-1",
"lease-1", 2, "FR-MEUBLE-2025-01", LocalDate.of(2026, 7, 19), "storage", "signed.pdf",
"hash", "subject-1", NOW, null);
when(mongoTemplate.findOne(any(Query.class),
eq(MongoLeaseFinalizationRepository.FinalizationDocument.class))).thenReturn(document);
MongoLeaseFinalizationRepository repository = new MongoLeaseFinalizationRepository(mongoTemplate);
LeaseFinalization found = repository.find(LANDLORD_ID, LEASE_ID).orElseThrow();
assertEquals(List.of(), found.overrides());
ArgumentCaptor<Query> query = ArgumentCaptor.forClass(Query.class);
verify(mongoTemplate).findOne(query.capture(),
eq(MongoLeaseFinalizationRepository.FinalizationDocument.class));
assertEquals("landlord-1", query.getValue().getQueryObject().getString("landlordId"));
assertEquals("lease-1", query.getValue().getQueryObject().getString("leaseId"));
}
@Test
void guaranteePairLookupIsScopedAndInsertConflictNeverOverwrites() {
var document = new MongoLeaseGuaranteeDeedRepository.GuaranteeDeedDocument("deed-1", "landlord-1",
"lease-1", "guarantor-1", "tenant-1", "storage", "deed.pdf", "hash", "subject-1", NOW);
when(mongoTemplate.findOne(any(Query.class),
eq(MongoLeaseGuaranteeDeedRepository.GuaranteeDeedDocument.class))).thenReturn(document);
MongoLeaseGuaranteeDeedRepository repository = new MongoLeaseGuaranteeDeedRepository(mongoTemplate);
LeaseGuaranteeDeed found = repository.find(LANDLORD_ID, LEASE_ID, "guarantor-1",
new TenantId("tenant-1")).orElseThrow();
assertEquals("hash", found.sha256());
ArgumentCaptor<Query> query = ArgumentCaptor.forClass(Query.class);
verify(mongoTemplate).findOne(query.capture(),
eq(MongoLeaseGuaranteeDeedRepository.GuaranteeDeedDocument.class));
assertEquals("landlord-1", query.getValue().getQueryObject().getString("landlordId"));
assertEquals("lease-1", query.getValue().getQueryObject().getString("leaseId"));
assertEquals("guarantor-1", query.getValue().getQueryObject().getString("guarantorId"));
assertEquals("tenant-1", query.getValue().getQueryObject().getString("tenantId"));
when(mongoTemplate.insert(any(MongoLeaseGuaranteeDeedRepository.GuaranteeDeedDocument.class)))
.thenThrow(new DuplicateKeyException("duplicate"));
assertThrows(LeaseFinalizationConflict.class, () -> repository.save(found));
}
}
@@ -8,14 +8,18 @@ import static org.mockito.Mockito.when;
import com.mongodb.client.result.DeleteResult;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.ApartmentOccupancy;
import fr.nessar.lmnporganizer.rental.domain.lease.ChargeMethod;
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseAmendmentId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseContractVersion;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSnapshot;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseStatus;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSpecialClause;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseType;
import fr.nessar.lmnporganizer.rental.domain.lease.RentTerms;
import fr.nessar.lmnporganizer.rental.domain.lease.RentalPeriod;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
@@ -61,7 +65,8 @@ class MongoLeaseRepositoriesTest {
List.of(revisedTenant, addedTenant),
before.guarantors(),
new RentalPeriod(PERIOD.startDate(), LocalDate.of(2027, 6, 30)),
new RentTerms(new BigDecimal("1025.00"), new BigDecimal("75.00"), TERMS.securityDeposit()));
new RentTerms(new BigDecimal("1025.00"), new BigDecimal("75.00"), TERMS.securityDeposit(), 12,
ChargeMethod.PROVISION, false), before.leaseType(), before.specialClauses());
lease.amend(new LeaseAmendmentId("amendment-1"), LocalDate.of(2026, 7, 1), after);
MongoLeaseRepository.LeaseDocument document = MongoLeaseRepository.toDocument(lease);
@@ -70,6 +75,16 @@ class MongoLeaseRepositoriesTest {
assertThat(document.version()).isNull();
assertThat(document.snapshot()).isNotNull();
assertThat(document.dueDay()).isEqualTo(12);
assertThat(document.chargeMethod()).isEqualTo("PROVISION");
assertThat(document.indexFlatRate()).isFalse();
assertThat(document.leaseType()).isEqualTo("STUDENT");
assertThat(document.snapshot().leaseType()).isEqualTo("STUDENT");
assertThat(document.snapshot().apartment().legalProfile().taxIdentifier()).isEqualTo("tax-1");
assertThat(document.specialClauses()).hasSize(2);
assertThat(document.snapshot().specialClauses()).hasSize(2);
assertThat(document.snapshot().dueDay()).isEqualTo(5);
assertThat(document.snapshot().chargeMethod()).isEqualTo("FLAT_RATE");
assertThat(document.amendments()).hasSize(1);
assertThat(freshRoundTrip.version()).isEqualTo(-1);
assertThat(freshRoundTrip.snapshot()).isEqualTo(lease.snapshot());
@@ -80,11 +95,33 @@ class MongoLeaseRepositoriesTest {
assertThat(amendment.afterVersion().tenants()).containsExactly(revisedTenant, addedTenant);
assertThat(amendment.afterVersion().period().endDate()).isEqualTo(LocalDate.of(2027, 6, 30));
assertThat(amendment.afterVersion().terms().monthlyRent()).isEqualByComparingTo("1025.00");
assertThat(amendment.afterVersion().terms().dueDay()).isEqualTo(12);
assertThat(amendment.afterVersion().terms().chargeMethod()).isEqualTo(ChargeMethod.PROVISION);
assertThat(amendment.afterVersion().leaseType()).isEqualTo(LeaseType.STUDENT);
assertThat(amendment.afterVersion().apartment().legalProfile()).isEqualTo(legalProfile());
assertThat(amendment.afterVersion().specialClauses()).isEqualTo(lease.specialClauses());
});
assertThat(persistedRoundTrip.version()).isEqualTo(5);
assertThat(persistedRoundTrip.amendments()).isEqualTo(freshRoundTrip.amendments());
}
@Test
void legacyLeaseDocumentsDefaultMissingPaymentTerms() {
MongoLeaseRepository.LeaseDocument legacy = new MongoLeaseRepository.LeaseDocument(
LEASE_ID.value(), LANDLORD_ID.value(), APARTMENT_ID.value(), List.of(TENANT_ID.value()), List.of(),
PERIOD.startDate(), PERIOD.endDate(), TERMS.monthlyRent(), TERMS.monthlyCharges(),
TERMS.securityDeposit(), LeaseStatus.DRAFT.name(), null, null, null, null, null, null, null, null, 3L);
Lease restoredLease = MongoLeaseRepository.toDomain(legacy);
RentTerms restored = restoredLease.terms();
assertThat(restored.dueDay()).isEqualTo(5);
assertThat(restored.chargeMethod()).isEqualTo(ChargeMethod.FLAT_RATE);
assertThat(restored.indexFlatRate()).isFalse();
assertThat(restoredLease.leaseType()).isEqualTo(LeaseType.STANDARD);
assertThat(restoredLease.specialClauses()).isEmpty();
}
@Test
void leaseLookupAndDraftDeleteRemainLandlordScoped() {
Lease draft = Lease.reconstitute(
@@ -162,20 +199,34 @@ class MongoLeaseRepositoriesTest {
}
private static Lease activeLease() {
Lease lease = Lease.draft(LEASE_ID, LANDLORD_ID, APARTMENT_ID, List.of(TENANT_ID), List.of(), PERIOD, TERMS);
lease.activate(snapshot());
Lease lease = Lease.draft(LEASE_ID, LANDLORD_ID, APARTMENT_ID, List.of(TENANT_ID), List.of(), PERIOD, TERMS,
LeaseType.STUDENT, List.of(
LeaseSpecialClause.catalog("VISITS", java.util.Map.of("time", "18:30"), "Visits at 18:30."),
LeaseSpecialClause.custom("No smoking.")));
lease.activate(snapshot(lease.period()));
return lease;
}
private static LeaseSnapshot snapshot() {
private static LeaseSnapshot snapshot(RentalPeriod period) {
return new LeaseSnapshot(
new LeaseSnapshot.Landlord(
LANDLORD_ID, "Léa", "Dubois", "lea@example.com", "+33612345678", "1 rue A"),
new LeaseSnapshot.Apartment(APARTMENT_ID, "Studio", "2 rue B"),
new LeaseSnapshot.Apartment(APARTMENT_ID, "Studio", "2 rue B", legalProfile()),
List.of(new LeaseSnapshot.Tenant(
TENANT_ID, "Camille", "Martin", "camille@example.com", "", "3 rue C")),
List.of(),
PERIOD,
TERMS);
period,
TERMS,
LeaseType.STUDENT,
List.of(LeaseSpecialClause.catalog("VISITS", java.util.Map.of("time", "18:30"),
"Visits at 18:30."),
LeaseSpecialClause.custom("No smoking.")));
}
private static ApartmentLegalProfile legalProfile() {
return new ApartmentLegalProfile("tax-1", ApartmentLegalProfile.BuildingType.APARTMENT_BUILDING,
"Copropriété", ApartmentLegalProfile.ConstructionPeriod.PERIOD_1949_1974,
new BigDecimal("31.50"), 2, "Cave", "Cuisine équipée", "Individuel", "Individuelle", "B",
new BigDecimal("750.00"), 2024, "Habitation", "Balcon", "Local vélos", "Fibre", "2A");
}
}
@@ -0,0 +1,134 @@
package fr.nessar.lmnporganizer.rental.adapter.out.mongodb.rent;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.rent.RentRevision;
import fr.nessar.lmnporganizer.rental.domain.rent.RentRevisionConflict;
import fr.nessar.lmnporganizer.rental.domain.rent.RentRevisionId;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.convert.MongoCustomConversions;
import org.springframework.data.mongodb.core.index.MongoPersistentEntityIndexResolver;
import org.springframework.data.mongodb.core.mapping.MongoMappingContext;
import org.springframework.data.mongodb.core.mapping.Field;
import org.springframework.data.mongodb.core.query.Query;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
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.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class MongoRentRevisionRepositoryTest {
private static final LandlordId LANDLORD_ID = new LandlordId("landlord-1");
private static final LeaseId LEASE_ID = new LeaseId("lease-1");
@Mock
MongoTemplate mongoTemplate;
@Test
void roundTripsAnAppliedRevisionAndScopesItsRead() {
RentRevision revision = appliedRevision();
when(mongoTemplate.insert(any(MongoRentRevisionRepository.RevisionDocument.class)))
.thenAnswer(invocation -> invocation.<MongoRentRevisionRepository.RevisionDocument>getArgument(0)
.withVersion(0L));
MongoRentRevisionRepository repository = new MongoRentRevisionRepository(mongoTemplate);
RentRevision saved = repository.save(revision);
assertEquals(RentRevision.Status.APPLIED, saved.status());
assertEquals(LocalDate.of(2027, 10, 5), saved.request().effectiveOn());
assertEquals("preuve.pdf", saved.application().proof().fileName());
assertEquals(0, saved.version());
when(mongoTemplate.findOne(any(Query.class), eq(MongoRentRevisionRepository.RevisionDocument.class)))
.thenReturn(invocationDocument(revision));
repository.find(LANDLORD_ID, LEASE_ID, revision.id()).orElseThrow();
ArgumentCaptor<Query> query = ArgumentCaptor.forClass(Query.class);
verify(mongoTemplate).findOne(query.capture(), eq(MongoRentRevisionRepository.RevisionDocument.class));
assertEquals("revision-1", query.getValue().getQueryObject().getString("_id"));
assertEquals("landlord-1", query.getValue().getQueryObject().getString("landlordId"));
assertEquals("lease-1", query.getValue().getQueryObject().getString("leaseId"));
}
@Test
void translatesTheUniqueAppliedAnniversaryConflict() {
when(mongoTemplate.insert(any(MongoRentRevisionRepository.RevisionDocument.class)))
.thenThrow(new DuplicateKeyException("duplicate"));
assertThrows(RentRevisionConflict.class,
() -> new MongoRentRevisionRepository(mongoTemplate).save(appliedRevision()));
}
@Test
void exposesTheUniqueSparseAppliedKeyToTheExplicitRentalIndexInitializer() throws Exception {
MongoMappingContext context = new MongoMappingContext();
context.setSimpleTypeHolder(MongoCustomConversions.create(adapter -> { }).getSimpleTypeHolder());
context.setInitialEntitySet(Set.of(MongoRentRevisionRepository.RevisionDocument.class));
context.afterPropertiesSet();
var indexes = new MongoPersistentEntityIndexResolver(context)
.resolveIndexFor(MongoRentRevisionRepository.RevisionDocument.class);
var appliedKey = java.util.stream.StreamSupport.stream(indexes.spliterator(), false)
.filter(index -> index.getIndexKeys().containsKey("appliedKey")).findFirst().orElseThrow();
assertTrue(appliedKey.getIndexOptions().getBoolean("unique"));
assertTrue(appliedKey.getIndexOptions().getBoolean("sparse"));
var appliedKeyField = MongoRentRevisionRepository.RevisionDocument.class.getDeclaredField("appliedKey");
assertEquals(Field.Write.NON_NULL, appliedKeyField.getAnnotation(Field.class).write());
}
private static MongoRentRevisionRepository.RevisionDocument invocationDocument(RentRevision revision) {
RentRevision.Request request = revision.request();
RentRevision.Application application = revision.application();
return new MongoRentRevisionRepository.RevisionDocument(revision.id().value(), LANDLORD_ID.value(),
LEASE_ID.value(), revision.status().name(), revision.window().annualRevisionDate(),
revision.window().requestDeadline(), revision.indexReference().seriesId(), false, false,
revision.indexReference().source().name(), false, revision.indexReference().referenceQuarter(),
revision.indexReference().previousYear(), revision.indexReference().newYear(),
revision.indexReference().previousIndex(), revision.indexReference().newIndex(),
revision.amounts().previousRent(), revision.amounts().rentCeiling(),
revision.amounts().chosenRent(), revision.amounts().previousCharges(),
revision.amounts().chargeCeiling(), revision.amounts().chosenCharges(),
new MongoRentRevisionRepository.RequestDocument(request.requestedOn(), request.effectiveOn(),
request.letterStorageKey(), request.letterSha256(), request.proposedBy(), request.proposedAt()),
List.of(), new MongoRentRevisionRepository.ApplicationDocument(application.sentOn(),
application.deliveryMethod().name(), application.effectiveOn(),
new MongoRentRevisionRepository.ProofDocument(application.proof().storageKey(),
application.proof().fileName(), application.proof().sha256(),
application.proof().authorId(), application.proof().uploadedAt()),
application.appliedBy(), application.appliedAt()),
"landlord-1|lease-1|2027-09-01", null);
}
private static RentRevision appliedRevision() {
Instant now = Instant.parse("2027-09-02T10:00:00Z");
RentRevision proposed = new RentRevision(new RentRevisionId("revision-1"), LANDLORD_ID, LEASE_ID,
new RentRevision.Window(LocalDate.of(2027, 9, 1), LocalDate.of(2028, 8, 31)),
new RentRevision.IndexReference("001515333", false, false, RentRevision.IndexSource.INSEE,
false, 2, 2026, 2027, new BigDecimal("143.46"), new BigDecimal("146.68")),
new RentRevision.Amounts(new BigDecimal("900"), new BigDecimal("920.20"),
new BigDecimal("915"), new BigDecimal("50"), new BigDecimal("51.12"),
new BigDecimal("51")),
new RentRevision.Request(LocalDate.of(2027, 9, 2), LocalDate.of(2027, 10, 5), "letter",
"a".repeat(64), "subject-1", now), List.of(), null, -1);
RentRevision.Proof proof = new RentRevision.Proof("proof", "preuve.pdf", "b".repeat(64), "subject-1",
now);
return proposed.apply(new RentRevision.Application(LocalDate.of(2027, 9, 2),
RentRevision.DeliveryMethod.POSTAL_LETTER, LocalDate.of(2027, 10, 5), proof, "subject-1", now));
}
}
@@ -0,0 +1,33 @@
package fr.nessar.lmnporganizer.rental.adapter.out.pdf;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class InformationNoticeResourceTest {
@Test
void keepsTheComplete2023RegulatoryAnnexWithoutTheIntroductoryOrder() throws IOException {
String text;
try (var input = Thread.currentThread().getContextClassLoader()
.getResourceAsStream(ClasspathLeaseInformationNoticeCatalog.RESOURCE)) {
if (input == null) {
throw new IOException("Information notice resource is missing.");
}
text = new String(input.readAllBytes(), StandardCharsets.UTF_8);
}
assertTrue(text.startsWith("ANNEXE\nCONTENU DE LA NOTICE D'INFORMATION"));
assertTrue(text.contains("24 août 2022"));
assertTrue(text.contains("classé F ou G"));
assertTrue(text.contains("Départ du conjoint victime de violences"));
assertTrue(text.contains("exempt de toute infestation d'espèces nuisibles et parasites"));
assertTrue(text.stripTrailing().endsWith("(14) Décret n° 87-712 du 26 août 1987 relatif aux réparations "
+ "locatives."));
assertFalse(text.contains("Fait le 16 février 2023"));
assertFalse(text.contains("F. Adam"));
}
}
@@ -1,16 +1,26 @@
package fr.nessar.lmnporganizer.rental.adapter.out.pdf;
import fr.nessar.lmnporganizer.rental.application.port.out.RentalDocumentRenderer.FlatRateRevision;
import fr.nessar.lmnporganizer.rental.application.port.out.RentalDocumentRenderer.InformationNotice;
import fr.nessar.lmnporganizer.rental.application.port.out.RentalDocumentRenderer.LeaseDocument;
import fr.nessar.lmnporganizer.rental.application.port.out.RentalDocumentRenderer.PaymentReceipt;
import fr.nessar.lmnporganizer.rental.application.port.out.RentalDocumentRenderer.ReceiptAllocation;
import fr.nessar.lmnporganizer.rental.application.port.out.RentalDocumentRenderer.ReceiptRefund;
import fr.nessar.lmnporganizer.rental.application.port.out.RentalDocumentRenderer.RentReceipt;
import fr.nessar.lmnporganizer.rental.application.port.out.RentalDocumentRenderer.RentRevisionRequest;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile.BuildingType;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile.ConstructionPeriod;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.ChargeMethod;
import fr.nessar.lmnporganizer.rental.domain.lease.Guarantor;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseContractVersion;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseLegalTerms;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSnapshot;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSpecialClause;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseType;
import fr.nessar.lmnporganizer.rental.domain.lease.RentTerms;
import fr.nessar.lmnporganizer.rental.domain.lease.RentalPeriod;
import fr.nessar.lmnporganizer.rental.domain.rent.PaymentAllocationId;
@@ -31,11 +41,13 @@ import java.time.LocalDate;
import java.time.YearMonth;
import java.util.Arrays;
import java.util.List;
import java.util.stream.IntStream;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ThymeleafRentalDocumentRendererTest {
@BeforeAll
@@ -52,7 +64,8 @@ class ThymeleafRentalDocumentRendererTest {
LocalDate.of(2026, 8, 6));
byte[] lease = renderer.renderLease(new LeaseDocument(new LeaseId("lease-1"), contract.landlord(),
contract.apartment(), contract.tenants(), contract.guarantors(), contract.period(), contract.terms()));
contract.apartment(), contract.tenants(), contract.guarantors(), contract.period(), contract.terms(),
"FR-MEUBLE-2026-10", 3));
byte[] paymentReceipt = renderer.renderPaymentReceipt(new PaymentReceipt(new RentPaymentId("payment-1"),
LocalDate.of(2026, 7, 5), LocalDate.of(2026, 7, 6), new BigDecimal("1200.00"),
new BigDecimal("50.00"), new BigDecimal("200.00"), contract, List.of(allocation),
@@ -66,6 +79,7 @@ class ThymeleafRentalDocumentRendererTest {
String leaseText = pdfText(lease);
assertTrue(leaseText.contains("1 Janvier 2026"));
assertTrue(leaseText.contains("31 Décembre 2026"));
assertTrue(leaseText.contains("Modèle FR-MEUBLE-2026-10 · Révision 3"));
String paymentText = pdfText(paymentReceipt);
assertTrue(paymentText.contains("5 Juillet 2026"));
assertTrue(paymentText.contains("Juin 2026"));
@@ -84,6 +98,170 @@ class ThymeleafRentalDocumentRendererTest {
assertFalse(rentText.contains("200.00"));
}
@Test
void rendersInformationNoticeTextFaithfullyWithoutInterpretingItAsHtml() throws Exception {
ThymeleafRentalDocumentRenderer renderer = new ThymeleafRentalDocumentRenderer(templateEngine());
byte[] notice = renderer.renderInformationNotice(new InformationNotice(
"ANNEXE\nCONTENU DE LA NOTICE\nLoyer < charges & dépôt"));
assertPdf(notice);
String text = pdfText(notice);
assertTrue(text.contains("ANNEXE"));
assertTrue(text.contains("CONTENU DE LA NOTICE"));
assertTrue(text.contains("Loyer < charges & dépôt"));
}
@Test
void rendersAnIrlRevisionRequestWithEveryProvidedFormulaAndFrenchDate() throws Exception {
ThymeleafRentalDocumentRenderer renderer = new ThymeleafRentalDocumentRenderer(templateEngine());
LeaseContractVersion contract = contract();
RentRevisionRequest request = new RentRevisionRequest(contract.landlord(), contract.tenants(),
contract.apartment(), LocalDate.of(2026, 7, 19), LocalDate.of(2026, 8, 1), "001515333",
2, 2026, 2027, new BigDecimal("143.46"), new BigDecimal("146.68"), new BigDecimal("900"),
new BigDecimal("920.20"), new BigDecimal("915"),
new FlatRateRevision(new BigDecimal("50"), new BigDecimal("51.12"), new BigDecimal("51")));
String text = normalized(pdfText(renderer.renderRentRevisionRequest(request)));
List<String> required = List.of("DEMANDE DE RÉVISION DU LOYER", "Léa Dubois", "Camille Martin",
"Studio", "2 rue B", "19 Juillet 2026", "1 Août 2026",
"001515333", "Ancien indice — 2e trimestre 2026", "Nouvel indice — 2e trimestre 2027",
"900,00 € × 146,68 / 143,46 = 920,20 €",
"Ancien loyer : 900,00 €", "Plafond calculé : 920,20 €", "Loyer demandé : 915,00 €",
"Forfait de charges", "Ancien forfait : 50,00 €",
"50,00 € × 146,68 / 143,46 = 51,12 €", "Plafond du forfait : 51,12 €",
"Nouveau forfait demandé : 51,00 €", "Demande de révision IRL · Série 001515333");
List<String> missing = required.stream().filter(fragment -> !text.contains(fragment)).toList();
assertTrue(missing.isEmpty(), () -> "Missing IRL request content: " + missing + " in:\n" + text);
assertFalse(text.contains("null") || text.contains("Non renseigné"));
}
@Test
void doesNotInventAFlatRateRevisionWhenNoneWasProvided() throws Exception {
ThymeleafRentalDocumentRenderer renderer = new ThymeleafRentalDocumentRenderer(templateEngine());
LeaseContractVersion contract = contract();
RentRevisionRequest request = new RentRevisionRequest(contract.landlord(), contract.tenants(),
contract.apartment(), LocalDate.of(2026, 7, 19), LocalDate.of(2026, 8, 1), "001515333",
2, 2026, 2027, new BigDecimal("143.46"), new BigDecimal("146.68"), new BigDecimal("900"),
new BigDecimal("920.20"), new BigDecimal("915"), null);
String text = normalized(pdfText(renderer.renderRentRevisionRequest(request)));
assertFalse(text.contains("Forfait de charges") || text.contains("Ancien forfait")
|| text.contains("Nouveau forfait demandé"));
}
@Test
void rejectsNonConsecutiveIrlIndexYears() {
LeaseContractVersion contract = contract();
assertThrows(IllegalArgumentException.class, () -> new RentRevisionRequest(contract.landlord(),
contract.tenants(), contract.apartment(), LocalDate.of(2026, 7, 19), LocalDate.of(2026, 8, 1),
"001515333", 2, 2026, 2028, new BigDecimal("143.46"), new BigDecimal("146.68"),
new BigDecimal("900"), new BigDecimal("920.20"), new BigDecimal("915"), null));
}
@Test
void printsTheTemplateAndRevisionOnEveryLeasePage() throws Exception {
ThymeleafRentalDocumentRenderer renderer = new ThymeleafRentalDocumentRenderer(templateEngine());
LeaseContractVersion contract = contract();
List<LeaseSnapshot.Tenant> tenants = IntStream.range(0, 500)
.mapToObj(index -> new LeaseSnapshot.Tenant(new TenantId("tenant-" + index), "Camille", "Martin",
"camille@example.com", "", "3 rue C"))
.toList();
byte[] lease = renderer.renderLease(new LeaseDocument(new LeaseId("lease-1"), contract.landlord(),
contract.apartment(), tenants, contract.guarantors(), contract.period(), contract.terms(),
"FR-MEUBLE-2026-10", 3));
try (var pdf = Loader.loadPDF(lease)) {
String text = new PDFTextStripper().getText(pdf);
assertTrue(pdf.getNumberOfPages() > 1);
assertTrue(text.split("Modèle FR-MEUBLE-2026-10 · Révision 3", -1).length - 1
== pdf.getNumberOfPages());
}
}
@Test
void rendersTheApplicable2025FurnishedLeaseFromStructuredFacts() throws Exception {
ThymeleafRentalDocumentRenderer renderer = new ThymeleafRentalDocumentRenderer(templateEngine());
ApartmentLegalProfile profile = new ApartmentLegalProfile("750561234567", BuildingType.APARTMENT_BUILDING,
"Copropriété", ConstructionPeriod.PERIOD_1949_1974, new BigDecimal("42.5"), 2, "Balcon",
"Cuisine équipée", "Chauffage individuel électrique", "Ballon électrique individuel", "B",
new BigDecimal("1250"), 2024, "Habitation principale", "Cave n° 8", "Local à vélos",
"Fibre optique", "75");
LeaseLegalTerms legalTerms = new LeaseLegalTerms(
new LeaseLegalTerms.RentControlTerms(true, true, new BigDecimal("20.50"),
new BigDecimal("24.60"), new BigDecimal("40"), "Vue dégagée exceptionnelle"),
new LeaseLegalTerms.PreviousRentTerms(true, new BigDecimal("850"),
LocalDate.of(2025, 9, 1), LocalDate.of(2026, 6, 5), true),
new LeaseLegalTerms.RentRevisionTerms(true, LocalDate.of(2027, 9, 1), 2, 2026, "001515333"),
new LeaseLegalTerms.RecentWork("Réfection complète des peintures", new BigDecimal("3000")),
true, true, false, true, "Usage paisible de la cour intérieure.");
LeaseContractVersion contract = contract(profile, LeaseType.STANDARD, legalTerms,
new RentTerms(new BigDecimal("900"), new BigDecimal("50"), new BigDecimal("900"), 5,
ChargeMethod.PROVISION, false),
List.of(LeaseSpecialClause.custom("Les animaux dangereux sont interdits.")));
String text = normalized(pdfText(renderer.renderLease(new LeaseDocument(new LeaseId("lease-1"),
contract.landlord(), contract.apartment(), contract.tenants(), contract.guarantors(),
contract.period(), contract.terms(), contract.leaseType(), contract.specialClauses(),
contract.legalTerms(), "FR-MEUBLE-2025-01", 4))));
List<String> required = List.of(
"I. Désignation des parties", "II. Objet du contrat", "Identifiant fiscal : 750561234567",
"Immeuble collectif", "De 1949 à 1974", "Surface habitable : 42,5 m²",
"Classe énergétique : B", "1er Janvier 2025", "1 250,00 €",
"III. Date de prise d'effet et durée du contrat",
"1 Septembre 2026", "31 Août 2027", "reconduit tacitement",
"IV. Conditions financières", "900,00 €", "24,60 € / m²", "40,00 €",
"1 Septembre 2025", "5 Juin 2026", "2e trimestre 2026", "001515333",
"Provision sur charges avec régularisation annuelle", "payables mensuellement d'avance",
"950,00 €", "V. Travaux", "Réfection complète des peintures", "3 000,00 €",
"VI. Garanties", "VII. Clause de solidarité", "VIII. Clause résolutoire",
"X. Autres conditions particulières", "Clause personnalisée — sous la responsabilité des parties",
"Les animaux dangereux sont interdits.",
"Usage paisible de la cour intérieure.", "XI. Annexes", "Modèle FR-MEUBLE-2025-01 · Révision 4");
List<String> missing = required.stream().filter(fragment -> !text.contains(fragment)).toList();
assertTrue(missing.isEmpty(), () -> "Missing lease content: " + missing + " in:\n" + text);
assertFalse(text.contains("[") || text.contains("null") || text.contains("Non renseigné")
|| text.contains("+33612345678") || text.contains("+33611111111") || !text.contains("Fait le")
|| !text.contains("Fait à"));
}
@Test
void rendersTheOctober2026RulesWithoutInventingOptionalTerms() throws Exception {
ThymeleafRentalDocumentRenderer renderer = new ThymeleafRentalDocumentRenderer(templateEngine());
LandlordId landlordId = new LandlordId("landlord-1");
LeaseLegalTerms legalTerms = new LeaseLegalTerms(null, null, null, null, false, false, true, false, "");
LeaseDocument document = new LeaseDocument(new LeaseId("lease-student"),
new LeaseSnapshot.Landlord(landlordId, "Léa", "Dubois", "lea@example.com", "+33612345678",
"1 rue A"),
new LeaseSnapshot.Apartment(new ApartmentId("apartment-1"), "Studio", "2 rue B",
ApartmentLegalProfile.empty()),
List.of(new LeaseSnapshot.Tenant(new TenantId("tenant-1"), "Camille", "Martin",
"camille@example.com", "+33611111111", "3 rue C")),
List.of(), new RentalPeriod(LocalDate.of(2026, 10, 1), LocalDate.of(2027, 6, 30)),
new RentTerms(new BigDecimal("900"), new BigDecimal("50"), new BigDecimal("900"), 5,
ChargeMethod.FLAT_RATE, false), LeaseType.STUDENT, List.of(), legalTerms,
"FR-MEUBLE-2026-10", 1);
String text = normalized(pdfText(renderer.renderLease(document)));
List<String> required = List.of("+33612345678", "+33611111111",
"Location meublée consentie à un étudiant", "Le bail étudiant de neuf mois n'est pas reconduit",
"Servitude de résidence principale", "article L. 151-14-1 du code de l'urbanisme",
"Le contrat de location est résilié de plein droit pour défaut de paiement du loyer ou des charges",
"non versement du dépôt de garantie", "ne produit effet que six semaines après la date d'un "
+ "commandement de payer demeuré infructueux",
"Autres motifs éventuels", "Forfait de charges sans régularisation",
"Modèle FR-MEUBLE-2026-10 · Révision 1");
List<String> missing = required.stream().filter(fragment -> !text.contains(fragment)).toList();
assertTrue(missing.isEmpty(), () -> "Missing October 2026 content: " + missing + " in:\n" + text);
assertFalse(text.contains("B. Destination des locaux") || text.contains("non-souscription d'une assurance")
|| text.contains("Une résiliation de plein droit ne peut résulter"));
}
private static void assertPdf(byte[] content) {
assertTrue(content.length > 4);
assertArrayEquals("%PDF".getBytes(StandardCharsets.US_ASCII), Arrays.copyOf(content, 4));
@@ -95,6 +273,11 @@ class ThymeleafRentalDocumentRendererTest {
}
}
private static String normalized(String value) {
return value.replace('\u00a0', ' ').replace('\u202f', ' ').replace('’', '\'')
.replaceAll("\\s+", " ").trim();
}
private static SpringTemplateEngine templateEngine() {
ClassLoaderTemplateResolver resolver = new ClassLoaderTemplateResolver();
resolver.setPrefix("templates/");
@@ -116,4 +299,22 @@ class ThymeleafRentalDocumentRendererTest {
new RentalPeriod(LocalDate.of(2026, 1, 1), LocalDate.of(2026, 12, 31)),
new RentTerms(new BigDecimal("900.00"), new BigDecimal("50.00"), new BigDecimal("900.00")));
}
private static LeaseContractVersion contract(ApartmentLegalProfile profile, LeaseType leaseType,
LeaseLegalTerms legalTerms, RentTerms terms,
List<LeaseSpecialClause> specialClauses) {
LandlordId landlordId = new LandlordId("landlord-1");
return new LeaseContractVersion(
new LeaseSnapshot.Landlord(landlordId, "Léa", "Dubois", "lea@example.com", "+33612345678",
"1 rue A"),
new LeaseSnapshot.Apartment(new ApartmentId("apartment-1"), "Studio", "2 rue B", profile),
List.of(
new LeaseSnapshot.Tenant(new TenantId("tenant-1"), "Camille", "Martin",
"camille@example.com", "+33611111111", "3 rue C"),
new LeaseSnapshot.Tenant(new TenantId("tenant-2"), "Noé", "Bernard",
"noe@example.com", "+33622222222", "4 rue D")),
List.of(new Guarantor("guarantor-1", "Louise", "Martin", "louise@example.com", "", "")),
new RentalPeriod(LocalDate.of(2026, 9, 1), LocalDate.of(2027, 8, 31)), terms, leaseType,
specialClauses, legalTerms);
}
}
Loaded 100 of 196 files, more files were not shown because too many files have changed in this diff. Show more