This commit is contained in:
nessar committed 2026-09-27 17:27:23 +02:00
1 parent 57e07ebdc8
commit 1be3d20b48
267 files changed
+9534 -2263

No files matched your search

+2 -2
View File
@@ -127,7 +127,7 @@ Document requis seulement selon la situation déclarée par le bailleur, notamme
_Avoid_: Détection géographique automatique, lien vers un document courant, obligation réglementaire codée comme invariant permanent
**Bail** (Code: `Lease`):
Contrat de location meublée de résidence principale liant le propriétaire à un ou plusieurs locataires (co-signataires) pour un appartement donné, définissant les conditions de la location (loyer, charges, durée). La première version couvre la gestion directe par le bailleur, sans mandataire ni honoraires d'agence, ainsi que le bail d'un an reconductible, le bail étudiant de neuf mois non reconductible et la colocation à bail unique, mais pas le bail mobilité. Le bail ordinaire conserve un anniversaire annuel calculé mais aucune date de fin réelle tant qu'un congé ou une fin anticipée convenue n'est pas enregistré ; le bail étudiant possède une fin fixe calculée après neuf mois. Son brouillon reçoit une date de début et dérive lui-même l'horizon correspondant au type de bail, sans accepter de date de fin fournie par le client ; il combine une copie des caractéristiques de l'appartement avec les données propres à la location, notamment les parties, montants, règles d'encadrement et de révision du loyer, modalités de paiement, travaux récents et clauses. Un brouillon peut être enregistré avec des rubriques incomplètes dès lors que les valeurs déjà fournies respectent leurs formats et invariants locaux ; sa complétude reste visible et les faits contractuels manquants ne deviennent bloquants qu'avant la génération d'une Révision du document de bail ou sa Finalisation. Un brouillon peut être prévisualisé et supprimé ; après signature de toutes les parties, sa finalisation conserve le document signé, fige les faits contractuels, réserve la période de l'appartement et crée les échéances, tandis qu'un bail finalisé est archivé à sa clôture mais jamais supprimé.
Contrat de location meublée de résidence principale liant le propriétaire à un ou plusieurs locataires (co-signataires) pour un appartement donné, définissant les conditions de la location (loyer, charges, durée). La première version couvre la gestion directe par le bailleur, sans mandataire ni honoraires d'agence, ainsi que le bail d'un an reconductible, le bail étudiant de neuf mois non reconductible et la colocation à bail unique, mais pas le bail mobilité. Le bail ordinaire conserve un anniversaire annuel calculé mais aucune date de fin réelle tant qu'un congé ou une fin anticipée convenue n'est pas enregistré ; le bail étudiant possède une fin fixe calculée après neuf mois. Son brouillon peut recevoir une date de début et dérive lui-même l'horizon correspondant dès que son type et cette date sont renseignés, sans accepter de date de fin fournie par le client ; il combine une copie des caractéristiques de l'appartement avec les données propres à la location, notamment les parties, montants, règles d'encadrement et de révision du loyer, modalités de paiement, travaux récents et clauses. Un brouillon peut être enregistré avec des rubriques incomplètes dès lors que les valeurs déjà fournies respectent leurs formats et invariants locaux ; sa complétude reste visible et les faits contractuels manquants ne deviennent bloquants qu'avant la génération d'une Révision du document de bail ou sa Finalisation. Un brouillon peut être prévisualisé et supprimé ; après signature de toutes les parties, sa finalisation conserve le document signé, fige les faits contractuels, réserve la période de l'appartement et crée les échéances, tandis qu'un bail finalisé est archivé à sa clôture mais jamais supprimé.
_Avoid_: Contrat, convention de location
**Anniversaire du bail** (Code: `LeaseAnniversary`):
@@ -631,7 +631,7 @@ Fichier numérique (facture PDF, photo de preuve d'état des lieux, contrat sign
_Avoid_: Fichier, document brut
**Tableau de bord** (Code: `Dashboard`):
Interface d'accueil synthétique à deux portées complémentaires : une vue financière calculée par Exercice fiscal et une vue opérationnelle courante permettant au Bailleur de suivre ses règlements et ses obligations, avec des accès rapides vers ses échéances et événements lorsqu'ils existent.
Interface d'accueil synthétique à deux portées complémentaires : une vue financière calculée par Exercice fiscal et une vue opérationnelle courante permettant au Bailleur de suivre ses règlements et ses obligations, avec des accès rapides vers ses échéances et événements lorsqu'ils existent. Il présente deux parcours indépendants de prochaine action, « Mettre en location » et « Préparer la comptabilité », tant que leurs étapes restent à accomplir ; l'absence d'Exercice fiscal ne masque pas la vue opérationnelle.
_Avoid_: Page d'accueil, synthèse
**Taux d'occupation** (Code: `OccupancyRate`):
@@ -147,6 +147,12 @@ public record TaxWorkspace(LandlordId landlordId, LmnpTaxProfile profile, TaxHis
if (siret.length() != 14 || !digits(siret)) {
throw new IllegalArgumentException("SIRET must contain exactly 14 digits.");
}
if (siret.chars().allMatch(character -> character == '0')) {
throw new IllegalArgumentException("SIRET cannot contain only zeros.");
}
if (!validSiretChecksum(siret)) {
throw new IllegalArgumentException("SIRET check digit is invalid.");
}
activityStartDate = Objects.requireNonNull(activityStartDate, "LMNP activity start date is required.");
firstName = required(firstName, "Tax profile first name");
lastName = required(lastName, "Tax profile last name");
@@ -417,7 +423,22 @@ public record TaxWorkspace(LandlordId landlordId, LmnpTaxProfile profile, TaxHis
}
private static boolean digits(String value) {
return value.chars().allMatch(Character::isDigit);
return value.chars().allMatch(character -> character >= '0' && character <= '9');
}
private static boolean validSiretChecksum(String value) {
int sum = 0;
for (int index = 0; index < value.length(); index++) {
int digit = value.charAt(index) - '0';
if (index % 2 == 0) {
digit *= 2;
if (digit > 9) {
digit -= 9;
}
}
sum += digit;
}
return sum % 10 == 0;
}
private static BigDecimal positiveMoney(BigDecimal value, String field) {
@@ -0,0 +1,23 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.common;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDraftCompleteness.Field;
public final class LeaseDraftWireFormat {
private LeaseDraftWireFormat() {
}
public static String fieldPath(Field field) {
return switch (field) {
case APARTMENT_ID -> "parties.apartmentId";
case TENANT_IDS -> "parties.tenantIds";
case START_DATE -> "conditions.startDate";
case MONTHLY_RENT -> "conditions.monthlyRent";
case MONTHLY_CHARGES -> "conditions.monthlyCharges";
case SECURITY_DEPOSIT -> "conditions.securityDeposit";
case DUE_DAY -> "conditions.dueDay";
case CHARGE_METHOD -> "conditions.chargeMethod";
case INDEX_FLAT_RATE -> "conditions.indexFlatRate";
case LEASE_TYPE -> "conditions.leaseType";
};
}
}
@@ -14,6 +14,7 @@ import fr.nessar.lmnporganizer.rental.application.shared.IrlIndexUnavailable;
import fr.nessar.lmnporganizer.rental.application.shared.PropertyConditionPhotoNormalizationFailure;
import fr.nessar.lmnporganizer.rental.application.shared.ReferencedResource;
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
import fr.nessar.lmnporganizer.rental.domain.apartment.IncompleteApartmentLegalProfile;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationOperationNotAllowed;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationDocumentQuotaExceeded;
@@ -26,6 +27,7 @@ import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventoryPrivate
import fr.nessar.lmnporganizer.rental.domain.landlord.EmailAlreadyUsed;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseFinalizationConflict;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRuleViolation;
import fr.nessar.lmnporganizer.rental.domain.lease.IncompleteLeaseDraft;
import fr.nessar.lmnporganizer.rental.domain.rent.RentRevisionConflict;
import fr.nessar.lmnporganizer.rental.domain.rent.RentRuleViolation;
import fr.nessar.lmnporganizer.rental.domain.tenant.IncompleteTenantContractProfile;
@@ -65,9 +67,11 @@ public class RentalApiExceptionHandler {
}
@ExceptionHandler(IncompleteApartmentLegalProfile.class)
ResponseEntity<CodedApiError> incompleteApartmentLegalProfile(IncompleteApartmentLegalProfile exception) {
ResponseEntity<ApartmentLegalProfilePrerequisiteError> incompleteApartmentLegalProfile(
IncompleteApartmentLegalProfile exception) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(new CodedApiError("apartment_legal_profile_incomplete", exception.getMessage()));
.body(new ApartmentLegalProfilePrerequisiteError("apartment_legal_profile_incomplete",
exception.getMessage(), exception.missingFields(), correctionPath(exception.apartmentId())));
}
@ExceptionHandler(IncompleteTenantContractProfile.class)
@@ -76,6 +80,15 @@ public class RentalApiExceptionHandler {
.body(new CodedApiError("tenant_contract_profile_incomplete", exception.getMessage()));
}
@ExceptionHandler(IncompleteLeaseDraft.class)
ResponseEntity<LeaseDraftPrerequisiteError> incompleteLeaseDraft(IncompleteLeaseDraft exception) {
List<String> missingFields = exception.missingFields().stream().map(LeaseDraftWireFormat::fieldPath).toList();
String message = "Le brouillon du bail est incomplet. Informations manquantes : "
+ String.join(", ", missingFields);
return ResponseEntity.status(HttpStatus.CONFLICT).body(new LeaseDraftPrerequisiteError("lease_draft_incomplete",
message, exception.missingSections().stream().map(Enum::name).toList(), missingFields));
}
@ExceptionHandler(ApplicationVaultRequired.class)
ResponseEntity<CodedApiError> applicationVaultRequired(ApplicationVaultRequired exception) {
return ResponseEntity.status(HttpStatus.CONFLICT)
@@ -109,6 +122,17 @@ public class RentalApiExceptionHandler {
exception.apartmentId()));
}
@ExceptionHandler(EntryConditionReportPrerequisite.class)
ResponseEntity<?> entryConditionReportPrerequisite(EntryConditionReportPrerequisite exception) {
if (exception.reason() == EntryConditionReportPrerequisite.FailureReason.PRIVATE_AREAS_MISSING) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(new EntryConditionReportPrerequisiteError("entry_report_private_areas_missing",
"Ajoutez au moins un espace privatif avant de commencer l’état des lieux.",
value(exception.apartmentId()), correctionPath(exception.apartmentId())));
}
return ResponseEntity.status(HttpStatus.CONFLICT).body(conflictError(exception));
}
@ExceptionHandler(IllegalArgumentException.class)
ResponseEntity<CodedApiError> invalidArgument() {
return error(HttpStatus.BAD_REQUEST, "invalid_request", "La requête est invalide.");
@@ -126,8 +150,8 @@ public class RentalApiExceptionHandler {
}
@ExceptionHandler({ReferencedResource.class, EmailAlreadyUsed.class, RentPaymentConflict.class,
LeaseFinalizationConflict.class, RentRevisionConflict.class, EntryConditionReportPrerequisite.class,
PropertyConditionReportConflict.class, AcquisitionFactsCorrectionNotAllowed.class})
LeaseFinalizationConflict.class, RentRevisionConflict.class, PropertyConditionReportConflict.class,
AcquisitionFactsCorrectionNotAllowed.class})
ResponseEntity<CodedApiError> conflict(RuntimeException exception) {
return ResponseEntity.status(HttpStatus.CONFLICT).body(conflictError(exception));
}
@@ -247,6 +271,14 @@ public class RentalApiExceptionHandler {
return ResponseEntity.status(status).body(new CodedApiError(code, message));
}
private static String correctionPath(ApartmentId apartmentId) {
return apartmentId == null ? "/apartments" : "/apartments/" + apartmentId.value() + "/edit";
}
private static String value(ApartmentId apartmentId) {
return apartmentId == null ? null : apartmentId.value();
}
private static CodedApiError conflictError(RuntimeException exception) {
if (exception instanceof ReferencedResource) {
return new CodedApiError("referenced_resource",
@@ -333,6 +365,21 @@ public class RentalApiExceptionHandler {
record FurnitureInventoryPrerequisiteError(String code, String message, String apartmentId) {
}
record EntryConditionReportPrerequisiteError(String code, String message, String apartmentId,
String correctionPath) {
}
record ApartmentLegalProfilePrerequisiteError(String code, String message, List<String> missingFields,
String correctionPath) {
ApartmentLegalProfilePrerequisiteError {
missingFields = List.copyOf(missingFields);
}
}
record LeaseDraftPrerequisiteError(String code, String message, List<String> missingSections,
List<String> missingFields) {
}
record ValidationApiError(String code, List<FieldValidationError> errors) {
}
@@ -10,6 +10,8 @@ import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureLine;
import fr.nessar.lmnporganizer.rental.domain.furniture.FurniturePresence;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
@@ -105,10 +107,11 @@ record Response(String id, String templateVersion, List<LineResponse> lines, Lis
List<FurnitureCategory> missingCategories, List<FurnitureCategory> absentCategories,
List<FurnitureInventoryWarning> legalWarnings, boolean readyForLeaseDocument, boolean frozen, long version) {
static Response from(FurnitureInventory inventory, List<PrivateArea> privateAreas) {
Set<String> privateAreaIds = privateAreas.stream().map(PrivateArea::id).collect(Collectors.toSet());
return new Response(inventory.id().value(), inventory.templateVersion(),
inventory.lines().stream().map(LineResponse::from).toList(),
privateAreas.stream().map(PrivateAreaResponse::from).toList(), inventory.missingCategories(),
inventory.absentCategories(), inventory.legalWarnings(), inventory.isReadyForLeaseDocument(),
inventory.frozen(), inventory.version());
inventory.absentCategories(), inventory.legalWarnings(),
inventory.isReadyForLeaseDocument(privateAreaIds), inventory.frozen(), inventory.version());
}
}
@@ -54,9 +54,7 @@ final class FurnitureInventoryRequestValidator {
private static void validate(List<FieldError> errors, Set<String> privateAreaIds, LineRequest line, int index) {
if (blank(line.id()))
errors.add(new FieldError(field(index, "id"), "not_blank"));
if (blank(line.privateAreaId())) {
errors.add(new FieldError(field(index, "privateAreaId"), "not_blank"));
} else if (!privateAreaIds.contains(line.privateAreaId().trim())) {
if (!blank(line.privateAreaId()) && !privateAreaIds.contains(line.privateAreaId().trim())) {
errors.add(new FieldError(field(index, "privateAreaId"), "unknown_private_area"));
}
if (blank(line.designation()))
@@ -69,10 +67,8 @@ final class FurnitureInventoryRequestValidator {
if (line.category() == null && line.presence() != null) {
errors.add(new FieldError(field(index, "presence"), "invalid_presence"));
}
if (line.category() != null) {
if (line.presence() == null && line.quantity() != null && line.quantity() != 0) {
errors.add(new FieldError(field(index, "quantity"), "undeclared_quantity"));
}
if (line.category() != null && line.presence() == null && line.quantity() != null && line.quantity() != 0) {
errors.add(new FieldError(field(index, "quantity"), "undeclared_quantity"));
}
if (line.presence() == FurniturePresence.PRESENT && line.quantity() != null && line.quantity() == 0) {
errors.add(new FieldError(field(index, "quantity"), "present_quantity"));
@@ -13,6 +13,8 @@ import fr.nessar.lmnporganizer.rental.domain.lease.LeaseContractVersion;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentAnnex;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentRevision;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentWarning;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDraftCompleteness;
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.LeaseDraftWireFormat;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseGuarantee;
import fr.nessar.lmnporganizer.rental.domain.lease.GuarantorProfile;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseLegalTerms;
@@ -49,6 +51,7 @@ import java.time.MonthDay;
import java.util.HashSet;
import java.util.Base64;
import java.util.List;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
@@ -139,7 +142,7 @@ final class LeaseApi {
@Valid PreviousRentRequest previousRent, @Valid RentRevisionRequest rentRevision,
@Valid RecentWorkRequest recentWork, Boolean coOwnershipRulesApplicable,
Boolean priorRentalAuthorizationApplicable, Boolean primaryResidenceEasementApplicable,
Boolean jointAndSeveralLiability, @NotNull @Valid EncryptedGroupRequest otherTerms) {
Boolean jointAndSeveralLiability, @Valid EncryptedGroupRequest otherTerms) {
LeaseLegalTerms toDomain() {
return new LeaseLegalTerms(vaultPublicKey, rentControl == null ? null : rentControl.toDomain(),
previousRent == null ? null : previousRent.toDomain(),
@@ -147,18 +150,17 @@ final class LeaseApi {
recentWork == null ? null : recentWork.toDomain(), Boolean.TRUE.equals(coOwnershipRulesApplicable),
Boolean.TRUE.equals(priorRentalAuthorizationApplicable),
Boolean.TRUE.equals(primaryResidenceEasementApplicable),
Boolean.TRUE.equals(jointAndSeveralLiability), otherTerms.toDomain());
Boolean.TRUE.equals(jointAndSeveralLiability), otherTerms == null ? null : otherTerms.toDomain());
}
}
record DraftRequest(@NotBlank @Size(max = 100) String id, @NotBlank @Size(max = 1400) String vaultPublicKey,
@NotBlank String apartmentId, @NotEmpty List<@NotBlank String> tenantIds,
@NotNull List<@Valid LeaseGuaranteeRequest> guarantees, @Valid VisaleGuaranteeRequest visaleGuarantee,
@NotNull LocalDate startDate, LeaseType leaseType, @NotNull @Positive BigDecimal monthlyRent,
@NotNull @PositiveOrZero BigDecimal monthlyCharges, @NotNull @PositiveOrZero BigDecimal securityDeposit,
List<@Valid LeaseGuaranteeRequest> guarantees, @Valid VisaleGuaranteeRequest visaleGuarantee,
LocalDate startDate, LeaseType leaseType, @Positive BigDecimal monthlyRent,
@PositiveOrZero BigDecimal monthlyCharges, @PositiveOrZero BigDecimal securityDeposit,
@Min(1) @Max(28) Integer dueDay, ChargeMethod chargeMethod, Boolean indexFlatRate,
@NotNull @Size(max = 20) List<@Valid SpecialClauseRequest> specialClauses,
@NotNull @Valid LegalTermsRequest legalTerms) {
@Size(max = 20) List<@Valid SpecialClauseRequest> specialClauses, @Valid LegalTermsRequest legalTerms) {
@AssertTrue
boolean isTenantIdsDistinct() {
@@ -304,6 +306,9 @@ final class LeaseApi {
boolean priorRentalAuthorizationApplicable, boolean primaryResidenceEasementApplicable,
boolean jointAndSeveralLiability, EncryptedGroupResponse otherTerms) {
static LegalTermsResponse from(LeaseLegalTerms terms) {
if (terms == null) {
return null;
}
return new LegalTermsResponse(terms.vaultPublicKey(), RentControlResponse.from(terms.rentControl()),
PreviousRentResponse.from(terms.previousRent()), RentRevisionResponse.from(terms.rentRevision()),
RecentWorkResponse.from(terms.recentWork()), terms.coOwnershipRulesApplicable(),
@@ -387,28 +392,37 @@ final class LeaseApi {
record Response(String id, String landlordId, String apartmentId, List<String> tenantIds,
List<LeaseGuaranteeResponse> guarantees, VisaleGuaranteeResponse visaleGuarantee,
VisaleVisaResponse visaleVisa, LocalDate startDate, LocalDate endDate, BigDecimal monthlyRent,
BigDecimal monthlyCharges, BigDecimal securityDeposit, int dueDay, ChargeMethod chargeMethod,
boolean indexFlatRate, LeaseType leaseType, List<SpecialClauseResponse> specialClauses,
BigDecimal monthlyCharges, BigDecimal securityDeposit, Integer dueDay, ChargeMethod chargeMethod,
Boolean indexFlatRate, LeaseType leaseType, List<SpecialClauseResponse> specialClauses,
LegalTermsResponse legalTerms, String status, ContractVersion contract, List<AmendmentResponse> amendments,
long version, String protectionStatus) {
long version, String protectionStatus, CompletenessResponse completeness) {
static Response from(Lease lease, LocalDate date) {
return from(lease, date, ApplicationVaultAccess.ProtectionStatus.TEMPORARILY_LOCKED);
}
static Response from(Lease lease, LocalDate date, ApplicationVaultAccess.ProtectionStatus protectionStatus) {
if (lease.status() == LeaseStatus.DRAFT) {
var facts = lease.draftFacts();
var period = lease.period();
var terms = lease.terms();
Integer dueDay = terms == null ? facts.dueDay() : Integer.valueOf(terms.dueDay());
Boolean indexFlatRate = terms == null ? facts.indexFlatRate() : Boolean.valueOf(terms.indexFlatRate());
return new Response(lease.id().value(), lease.landlordId().value(), lease.apartmentId().value(),
lease.tenantIds().stream().map(id -> id.value()).toList(),
lease.guarantees().stream().map(LeaseGuaranteeResponse::from).toList(),
VisaleGuaranteeResponse.from(lease.visaleGuarantee()),
VisaleVisaResponse.from(lease.visaleVisa()), lease.period().startDate(),
lease.period().endDate(), lease.terms().monthlyRent(), lease.terms().monthlyCharges(),
lease.terms().securityDeposit(), lease.terms().dueDay(), lease.terms().chargeMethod(),
lease.terms().indexFlatRate(), lease.leaseType(),
VisaleVisaResponse.from(lease.visaleVisa()),
period == null ? facts.startDate() : period.startDate(),
period == null ? null : period.endDate(),
terms == null ? facts.monthlyRent() : terms.monthlyRent(),
terms == null ? facts.monthlyCharges() : terms.monthlyCharges(),
terms == null ? facts.securityDeposit() : terms.securityDeposit(), dueDay,
terms == null ? facts.chargeMethod() : terms.chargeMethod(), indexFlatRate, facts.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(),
Objects.requireNonNull(protectionStatus).wireValue());
Objects.requireNonNull(protectionStatus).wireValue(),
CompletenessResponse.from(LeaseDraftCompleteness.evaluate(lease)));
}
LeaseContractVersion effective = lease.contractVersionOn(date);
return new Response(lease.id().value(), lease.landlordId().value(), effective.apartmentId().value(),
@@ -422,7 +436,19 @@ final class LeaseApi {
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(), Objects.requireNonNull(protectionStatus).wireValue());
lease.version(), Objects.requireNonNull(protectionStatus).wireValue(),
CompletenessResponse.from(LeaseDraftCompleteness.evaluate(lease)));
}
}
record CompletenessResponse(boolean complete, List<String> missingSections, List<String> missingFields,
Map<String, Boolean> sections) {
static CompletenessResponse from(LeaseDraftCompleteness.Result result) {
Map<String, Boolean> sections = new LinkedHashMap<>();
result.sections().forEach((section, complete) -> sections.put(section.name(), complete));
return new CompletenessResponse(result.complete(),
result.missingSections().stream().map(Enum::name).toList(),
result.missingFields().stream().map(LeaseDraftWireFormat::fieldPath).toList(), sections);
}
}
@@ -9,11 +9,10 @@ import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationV
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
import fr.nessar.lmnporganizer.rental.domain.lease.ChargeMethod;
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDraftFacts;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseGuarantee;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseStatus;
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 jakarta.validation.Valid;
import jakarta.validation.constraints.PositiveOrZero;
@@ -165,21 +164,20 @@ public class LeaseController {
}
private static LeaseDraftService.DraftDetails details(LeaseApi.DraftRequest request) {
LeaseDraftFacts facts = new LeaseDraftFacts(request.startDate(), request.monthlyRent(),
request.monthlyCharges(), request.securityDeposit(), request.dueDay(), request.chargeMethod(),
request.indexFlatRate(), request.leaseType());
return new LeaseDraftService.DraftDetails(request.id(), request.vaultPublicKey(),
new ApartmentId(request.apartmentId()), tenantIds(request.tenantIds()),
guarantees(request.guarantees()), 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().toDomain(),
request.visaleGuarantee() == null ? null : request.visaleGuarantee().toDomain());
guarantees(request.guarantees()), specialClauses(request.specialClauses()),
request.legalTerms() == null ? null : request.legalTerms().toDomain(),
request.visaleGuarantee() == null ? null : request.visaleGuarantee().toDomain(), facts);
}
private LeaseApi.Response response(Lease lease) {
LocalDate today = LocalDate.now(clock);
String vaultPublicKey = lease.status() == LeaseStatus.DRAFT
? lease.legalTerms().vaultPublicKey()
? lease.legalTerms() == null ? null : lease.legalTerms().vaultPublicKey()
: lease.contractVersionOn(today).legalTerms().vaultPublicKey();
if (vaultPublicKey == null || vaultPublicKey.isBlank())
throw new IllegalStateException("Lease vault public key is required.");
@@ -87,7 +87,6 @@ final class LeaseNestedRequestValidator {
private void clauses(List<LeaseApi.SpecialClauseRequest> requests, LeaseClauseCatalog.Context context,
List<LeaseController.LeaseRequestFieldError> errors) {
if (requests == null) {
errors.add(error("specialClauses", "required"));
return;
}
var ids = new HashSet<String>();
@@ -158,12 +157,8 @@ final class LeaseNestedRequestValidator {
private static void legalTerms(LeaseApi.LegalTermsRequest terms, int tenantCount,
List<LeaseController.LeaseRequestFieldError> errors) {
if (terms == null) {
errors.add(error("legalTerms", "required"));
return;
}
if (terms.otherTerms() == null) {
errors.add(error("legalTerms.otherTerms", "required"));
}
LeaseApi.RentControlRequest rentControl = terms.rentControl();
if (rentControl != null && Boolean.TRUE.equals(rentControl.referenceRentApplicable())) {
if (rentControl.baseReferenceRent() == null)
@@ -20,6 +20,7 @@ import fr.nessar.lmnporganizer.rental.domain.lease.GuarantorProfile;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseGuarantee;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseLegalTerms;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDraftFacts;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSnapshot;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSpecialClause;
@@ -120,13 +121,27 @@ public class MongoLeaseRepository implements LeaseRepository {
}
static LeaseDocument toDocument(Lease lease) {
LeaseDraftFacts facts = lease.draftFacts();
RentalPeriod period = lease.period();
RentTerms terms = lease.terms();
LocalDate startDate = facts == null ? period.startDate() : facts.startDate();
LocalDate endDate = facts == null ? period.endDate() : period == null ? null : period.endDate();
BigDecimal monthlyRent = facts == null ? terms.monthlyRent() : facts.monthlyRent();
BigDecimal monthlyCharges = facts == null ? terms.monthlyCharges() : facts.monthlyCharges();
BigDecimal securityDeposit = facts == null ? terms.securityDeposit() : facts.securityDeposit();
Integer dueDay = facts == null ? Integer.valueOf(terms.dueDay()) : facts.dueDay();
String chargeMethod = facts == null
? terms.chargeMethod().name()
: facts.chargeMethod() == null ? null : facts.chargeMethod().name();
Boolean indexFlatRate = facts == null ? Boolean.valueOf(terms.indexFlatRate()) : facts.indexFlatRate();
String leaseType = facts == null
? lease.leaseType().name()
: facts.leaseType() == null ? null : facts.leaseType().name();
return new LeaseDocument(lease.id().value(), lease.landlordId().value(), lease.apartmentId().value(),
lease.tenantIds().stream().map(TenantId::value).toList(),
lease.guarantees().stream().map(MongoLeaseRepository::toDocument).toList(),
toDocument(lease.visaleGuarantee()), toDocument(lease.visaleVisa()), 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(),
toDocument(lease.visaleGuarantee()), toDocument(lease.visaleVisa()), startDate, endDate, monthlyRent,
monthlyCharges, securityDeposit, lease.status().name(), dueDay, chargeMethod, indexFlatRate, leaseType,
lease.specialClauses().stream().map(MongoLeaseRepository::toDocument).toList(),
toDocument(lease.legalTerms()), lease.snapshot() == null ? null : toDocument(lease.snapshot()),
lease.amendments().stream().map(MongoLeaseRepository::toDocument).toList(),
@@ -134,19 +149,34 @@ public class MongoLeaseRepository implements LeaseRepository {
}
static Lease toDomain(LeaseDocument document) {
Lease lease = Lease.reconstitute(new LeaseId(document.id()), new LandlordId(document.landlordId()),
new ApartmentId(document.apartmentId()), document.tenantIds().stream().map(TenantId::new).toList(),
document.guarantees().stream().map(MongoLeaseRepository::toDomain).toList(),
new RentalPeriod(document.startDate(), document.endDate()),
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()
: document.amendments().stream().map(MongoLeaseRepository::toDomain).toList(),
toDomain(document.endingHistory()), domainVersion(document.version()));
LeaseStatus status = LeaseStatus.valueOf(document.status());
LeaseDraftFacts draftFacts = status == LeaseStatus.DRAFT
? new LeaseDraftFacts(document.startDate(), document.monthlyRent(), document.monthlyCharges(),
document.securityDeposit(), document.dueDay(), chargeMethod(document.chargeMethod()),
document.indexFlatRate(),
document.leaseType() == null ? null : LeaseType.valueOf(document.leaseType()))
: null;
List<LeaseAmendment> amendments = document.amendments() == null
? List.of()
: document.amendments().stream().map(MongoLeaseRepository::toDomain).toList();
Lease lease = status == LeaseStatus.DRAFT
? Lease.reconstitute(new LeaseId(document.id()), new LandlordId(document.landlordId()),
new ApartmentId(document.apartmentId()),
document.tenantIds().stream().map(TenantId::new).toList(),
document.guarantees().stream().map(MongoLeaseRepository::toDomain).toList(), draftFacts,
specialClauses(document.specialClauses()), toDomain(document.legalTerms()), status, null,
amendments, toDomain(document.endingHistory()), domainVersion(document.version()))
: Lease.reconstitute(new LeaseId(document.id()), new LandlordId(document.landlordId()),
new ApartmentId(document.apartmentId()),
document.tenantIds().stream().map(TenantId::new).toList(),
document.guarantees().stream().map(MongoLeaseRepository::toDomain).toList(),
new RentalPeriod(document.startDate(), document.endDate()),
rentTerms(document.monthlyRent(), document.monthlyCharges(), document.securityDeposit(),
document.dueDay(), document.chargeMethod(), document.indexFlatRate()),
leaseType(document.leaseType()), specialClauses(document.specialClauses()),
toDomain(document.legalTerms()), status,
document.snapshot() == null ? null : toDomain(document.snapshot()), amendments,
toDomain(document.endingHistory()), domainVersion(document.version()));
lease.restoreVisaleGuarantee(toDomain(document.visaleGuarantee()));
lease.restoreVisaleVisa(toDomain(document.visaleVisa()));
return lease;
@@ -419,6 +449,9 @@ public class MongoLeaseRepository implements LeaseRepository {
}
private static LegalTermsDocument toDocument(LeaseLegalTerms terms) {
if (terms == null) {
return null;
}
return new LegalTermsDocument(terms.vaultPublicKey(),
new RentControlDocument(terms.rentControl().evolutionCapApplicable(),
terms.rentControl().referenceRentApplicable(), terms.rentControl().baseReferenceRent(),
@@ -532,6 +565,10 @@ public class MongoLeaseRepository implements LeaseRepository {
return leaseType == null ? LeaseType.STANDARD : LeaseType.valueOf(leaseType);
}
private static ChargeMethod chargeMethod(String chargeMethod) {
return chargeMethod == null ? null : ChargeMethod.valueOf(chargeMethod);
}
private static Long persistedVersion(long version) {
return version < 0 ? null : version;
}
@@ -408,19 +408,19 @@ public final class PropertyConditionReportService {
throw new EntryConditionReportPrerequisite(
EntryConditionReportPrerequisite.FailureReason.LANDLORD_PROFILE_INCOMPLETE);
}
apartment.legalProfile().requireLeaseReady();
apartment.legalProfile().requireLeaseReady(apartment.id());
if (apartment.privateAreas().isEmpty()) {
throw new EntryConditionReportPrerequisite(
EntryConditionReportPrerequisite.FailureReason.PRIVATE_AREAS_MISSING);
EntryConditionReportPrerequisite.FailureReason.PRIVATE_AREAS_MISSING, apartment.id());
}
var inventory = inventories.find(landlord, leaseId).orElseThrow(() -> new EntryConditionReportPrerequisite(
EntryConditionReportPrerequisite.FailureReason.INVENTORY_MISSING));
if (!inventory.isReadyForLeaseDocument()) {
if (!inventory.missingCategories().isEmpty()) {
throw new EntryConditionReportPrerequisite(
EntryConditionReportPrerequisite.FailureReason.INVENTORY_INCOMPLETE);
}
Set<String> privateAreaIds = apartment.privateAreas().stream().map(PrivateArea::id).collect(Collectors.toSet());
if (inventory.lines().stream().anyMatch(line -> !privateAreaIds.contains(line.privateAreaId()))) {
if (!inventory.isReadyForLeaseDocument(privateAreaIds)) {
throw new EntryConditionReportPrerequisite(
EntryConditionReportPrerequisite.FailureReason.INVENTORY_PRIVATE_AREA_MISSING);
}
@@ -63,7 +63,9 @@ public final class FurnitureInventoryService {
public FurnitureInventory freeze(LandlordId landlordId, LeaseId leaseId) {
FurnitureInventory inventory = get(landlordId, leaseId);
inventory.freeze();
Set<String> privateAreaIds = privateAreas(landlordId, leaseId).stream().map(PrivateArea::id)
.collect(Collectors.toSet());
inventory.freeze(privateAreaIds);
return inventories.save(inventory);
}
@@ -76,9 +78,9 @@ public final class FurnitureInventoryService {
}
private FurnitureInventory initialize(LandlordId landlordId, Lease lease) {
String areaId = privateAreas(landlordId, lease.id()).getFirst().id();
privateAreas(landlordId, lease.id());
return inventories.save(FurnitureInventory.prefilled(new FurnitureInventoryId(identifiers.next()), landlordId,
lease.id(), currentTemplateVersion(), ignored -> identifiers.next(), areaId));
lease.id(), currentTemplateVersion(), ignored -> identifiers.next()));
}
private String currentTemplateVersion() {
@@ -88,10 +90,9 @@ public final class FurnitureInventoryService {
private void validatePrivateAreas(LandlordId landlordId, Lease lease, List<FurnitureLine> lines) {
Set<String> privateAreaIds = privateAreas(landlordId, lease.id()).stream().map(area -> area.id())
.collect(Collectors.toSet());
if (lines == null
|| lines.stream().anyMatch(line -> line == null || !privateAreaIds.contains(line.privateAreaId()))) {
throw new IllegalArgumentException(
"Chaque ligne d'inventaire doit référencer un espace privatif du logement.");
if (lines == null || lines.stream().anyMatch(line -> line == null
|| line.privateAreaId() != null && !privateAreaIds.contains(line.privateAreaId()))) {
throw new IllegalArgumentException("Chaque espace privatif renseigné doit appartenir au logement.");
}
}
@@ -29,6 +29,7 @@ import fr.nessar.lmnporganizer.rental.domain.lease.LeaseContractVersion;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentAnnex;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentRevision;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentWarning;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDraftCompleteness;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseFinalizationConflict;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseFinalizationWarning;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
@@ -84,6 +85,7 @@ public final class LeaseDocumentRevisionSource {
}
Source forGeneration(LandlordId landlordId, Lease lease, FurnishedLeaseTemplate template) {
LeaseDraftCompleteness.requireComplete(lease);
PropertyConditionReport entry = entryReport(landlordId, lease.id());
FurnitureInventory inventory = inventory(landlordId, lease, template);
FurnitureInventorySnapshot furniture = FurnitureInventorySnapshot.from(inventory);
@@ -242,7 +244,7 @@ public final class LeaseDocumentRevisionSource {
inventory.alignWithTemplate(template.visibleId());
inventories.save(inventory);
}
if (!inventory.isReadyForLeaseDocument()) {
if (!inventory.missingCategories().isEmpty()) {
throw new LeaseRuleViolation("Chaque catégorie réglementaire de mobilier doit être renseignée.");
}
if (template != null && !inventory.templateVersion().equals(template.visibleId())) {
@@ -438,7 +440,7 @@ public final class LeaseDocumentRevisionSource {
VisaleSuretyContract visaleContract, PropertyConditionReport entry, Lease lease, Landlord landlord,
Apartment apartment, FurnitureInventory inventory) {
Source {
document.apartment().legalProfile().requireLeaseReady();
document.apartment().legalProfile().requireLeaseReady(document.apartment().id());
apartmentDocuments = List.copyOf(apartmentDocuments);
warnings = List.copyOf(warnings);
}
@@ -15,6 +15,7 @@ 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.LeaseDocumentRevisionRepository;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDraftFacts;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseGuarantee;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseGuaranteeDeed;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseGuaranteeDeedRepository;
@@ -25,8 +26,6 @@ import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRuleViolation;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSpecialClause;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseStatus;
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.lease.VisaleGuarantee;
import fr.nessar.lmnporganizer.rental.domain.lease.VisaleSuretyContract;
import fr.nessar.lmnporganizer.rental.domain.lease.VisaleSuretyContractRepository;
@@ -93,18 +92,16 @@ public final class LeaseDraftService {
LeaseId leaseId = new LeaseId(details.leaseId());
fenceCurrentVault(landlordId, details.vaultPublicKey());
validatePrivateFields(details);
requireParties(details);
requireCompleteGuarantees(details.guarantees());
Apartment apartment = apartment(landlordId, details.apartmentId());
List<Tenant> leaseTenants = tenantMap(landlordId, new HashSet<>(details.tenantIds())).values().stream()
.toList();
leaseTenants.forEach(Tenant::requireCompleteContractProfile);
requireCurrentTenantVault(details.vaultPublicKey(), leaseTenants);
LeaseType leaseType = LeaseType.orStandard(details.leaseType());
LeaseType leaseType = details.draftFacts().leaseType();
List<LeaseSpecialClause> specialClauses = specialClauses(details.specialClauses(),
context(details.tenantIds(), leaseType));
context(details.tenantIds(), leaseType == null ? LeaseType.STANDARD : leaseType));
Lease lease = Lease.draft(leaseId, landlordId, details.apartmentId(), details.tenantIds(),
details.guarantees(), details.period(), details.terms(), leaseType, specialClauses,
details.legalTerms());
details.guarantees(), details.draftFacts(), specialClauses, legalTerms(details));
lease.reviseVisaleGuarantee(details.visaleGuarantee());
apartment.referenceBy(leaseId);
leaseTenants.forEach(tenant -> tenant.referenceBy(leaseId));
@@ -122,6 +119,7 @@ public final class LeaseDraftService {
fenceCurrentVault(landlordId, details.vaultPublicKey());
validatePrivateFields(details);
requireDraft(lease);
requireParties(details);
requireCompleteGuarantees(details.guarantees());
if (lease.version() != expectedVersion)
throw new ConcurrentChange("Lease");
@@ -133,9 +131,6 @@ public final class LeaseDraftService {
Set<TenantId> allTenantIds = new HashSet<>(lease.tenantIds());
allTenantIds.addAll(details.tenantIds());
Map<TenantId, Tenant> affectedTenants = tenantMap(landlordId, allTenantIds);
List<Tenant> currentTenants = details.tenantIds().stream().map(affectedTenants::get).toList();
currentTenants.forEach(Tenant::requireCompleteContractProfile);
requireCurrentTenantVault(details.vaultPublicKey(), currentTenants);
if (!previousApartment.id().equals(nextApartment.id())) {
previousApartment.removeDraftReference(leaseId);
@@ -152,12 +147,11 @@ public final class LeaseDraftService {
affectedTenants.get(nextTenantId).referenceBy(leaseId);
}
affectedTenants.values().forEach(tenants::save);
LeaseType leaseType = details.leaseType() == null ? lease.leaseType() : details.leaseType();
LeaseType leaseType = details.draftFacts().leaseType();
List<LeaseSpecialClause> specialClauses = specialClauses(details.specialClauses(),
context(details.tenantIds(), leaseType));
LeaseLegalTerms legalTerms = details.legalTerms();
lease.reviseDraft(details.apartmentId(), details.tenantIds(), details.guarantees(), details.period(),
details.terms(), leaseType, specialClauses, legalTerms, details.visaleGuarantee());
context(details.tenantIds(), leaseType == null ? LeaseType.STANDARD : leaseType));
lease.reviseDraft(details.apartmentId(), details.tenantIds(), details.guarantees(),
details.draftFacts(), specialClauses, legalTerms(details), details.visaleGuarantee());
return leases.save(lease);
});
completeDeletions(deletions);
@@ -227,12 +221,6 @@ public final class LeaseDraftService {
}
}
private static void requireCurrentTenantVault(String vaultPublicKey, List<Tenant> tenants) {
if (tenants.stream().anyMatch(tenant -> !vaultPublicKey.equals(tenant.profile().vaultPublicKey()))) {
throw new ApplicationVaultRequired();
}
}
private void removeDocumentRevisions(LandlordId landlordId, LeaseId leaseId, List<PreparedDeletion> deletions) {
for (LeaseDocumentRevision revision : revisions.findAll(landlordId, leaseId)) {
deletions.add(stageDeletion(revision.storageKey()));
@@ -382,19 +370,36 @@ public final class LeaseDraftService {
}
private static void validatePrivateFields(DraftDetails details) {
if (!details.vaultPublicKey().equals(details.legalTerms().vaultPublicKey())) {
if (details.legalTerms() != null && details.legalTerms().vaultPublicKey() != null
&& !details.vaultPublicKey().equals(details.legalTerms().vaultPublicKey())) {
throw new ApplicationVaultRequired();
}
if (details.guarantees() != null && details.guarantees().stream().anyMatch(guarantee -> guarantee == null
|| !details.vaultPublicKey().equals(guarantee.profile().vaultPublicKey()))) {
if (details.guarantees() != null
&& details.guarantees().stream().anyMatch(guarantee -> guarantee == null || guarantee.profile() == null
|| !details.vaultPublicKey().equals(guarantee.profile().vaultPublicKey()))) {
throw new ApplicationVaultRequired();
}
if (details.specialClauses() != null && details.specialClauses().stream()
.anyMatch(clause -> !details.vaultPublicKey().equals(clause.vaultPublicKey()))) {
.anyMatch(clause -> clause == null || !details.vaultPublicKey().equals(clause.vaultPublicKey()))) {
throw new ApplicationVaultRequired();
}
}
private static void requireParties(DraftDetails details) {
if (details.apartmentId() == null) {
throw new LeaseRuleViolation("Un appartement est obligatoire pour un brouillon de bail.");
}
if (details.tenantIds().isEmpty()) {
throw new LeaseRuleViolation("Un brouillon de bail doit contenir au moins un locataire.");
}
}
private static LeaseLegalTerms legalTerms(DraftDetails details) {
return details.legalTerms() == null
? LeaseLegalTerms.empty(details.vaultPublicKey(), null)
: details.legalTerms();
}
private static LeaseClauseCatalog.Context context(List<TenantId> tenantIds, LeaseType leaseType) {
if (tenantIds.size() > 1) {
return LeaseClauseCatalog.Context.COLOCATION;
@@ -405,15 +410,15 @@ public final class LeaseDraftService {
}
public record DraftDetails(String leaseId, String vaultPublicKey, ApartmentId apartmentId, List<TenantId> tenantIds,
List<LeaseGuarantee> guarantees, RentalPeriod period, RentTerms terms, LeaseType leaseType,
List<ClauseDetails> specialClauses, LeaseLegalTerms legalTerms, VisaleGuarantee visaleGuarantee) {
List<LeaseGuarantee> guarantees, List<ClauseDetails> specialClauses, LeaseLegalTerms legalTerms,
VisaleGuarantee visaleGuarantee, LeaseDraftFacts draftFacts) {
public DraftDetails {
leaseId = required(leaseId, "Lease ID");
vaultPublicKey = required(vaultPublicKey, "Lease vault public key");
tenantIds = tenantIds == null ? List.of() : List.copyOf(tenantIds);
guarantees = guarantees == null ? List.of() : List.copyOf(guarantees);
specialClauses = Objects.requireNonNull(specialClauses, "Special clauses are required.");
legalTerms = Objects.requireNonNull(legalTerms, "Legal terms are required.");
specialClauses = specialClauses == null ? List.of() : List.copyOf(specialClauses);
draftFacts = Objects.requireNonNull(draftFacts, "Draft facts are required.");
}
}
@@ -10,6 +10,7 @@ import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationV
import fr.nessar.lmnporganizer.rental.application.rentalapplication.ApplicationVaultRequired;
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
import fr.nessar.lmnporganizer.rental.domain.apartment.Apartment;
import fr.nessar.lmnporganizer.rental.domain.apartment.PrivateArea;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVault;
import fr.nessar.lmnporganizer.rental.domain.application.ApplicationVaultRepository;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentRepository;
@@ -30,6 +31,7 @@ import fr.nessar.lmnporganizer.rental.domain.lease.LeaseActivated;
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.LeaseDraftCompleteness;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseFinalization;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseFinalizationConflict;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseFinalizationRepository;
@@ -244,6 +246,7 @@ public final class LeaseFinalizationService {
public State state(LandlordId landlordId, LeaseId leaseId) {
Lease lease = lease(landlordId, leaseId);
LeaseDraftCompleteness.requireComplete(lease);
List<LeaseGuaranteeDeed> deeds = guaranteeDeeds.findAll(landlordId, leaseId);
LeaseDocumentRevision revision = revisions.findCurrent(landlordId, leaseId).orElse(null);
LeaseFinalization finalization = finalizations.find(landlordId, leaseId).orElse(null);
@@ -376,7 +379,7 @@ public final class LeaseFinalizationService {
throw new LeaseFinalizationConflict("Le bail a changé depuis la génération de la révision signée.");
revisionSource.coordinateCurrentSource(landlordId, leaseId, current, sourceNotice);
Activation activation = prepareActivation(landlordId, currentLease);
freezeInventory(landlordId, leaseId);
freezeInventory(landlordId, leaseId, activation.snapshot().apartment().privateAreas());
Lease activated = activateInTransaction(activation);
LeaseFinalization saved = finalizations.save(proposed);
var entry = conditionReports.find(landlordId, leaseId, PropertyConditionReportType.ENTRY)
@@ -456,8 +459,8 @@ public final class LeaseFinalizationService {
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) {
if (lease.terms() != null && lease.terms().securityDeposit()
.compareTo(lease.terms().monthlyRent().multiply(BigDecimal.TWO)) > 0) {
warnings.add(LeaseDocumentWarning.from(LeaseFinalizationWarning.SECURITY_DEPOSIT_ABOVE_TWO_MONTHS));
}
if (lease.legalTerms().rentRevision().enabled() && isRestrictedEnergyRating(landlordId, lease)) {
@@ -477,10 +480,11 @@ public final class LeaseFinalizationService {
return "F".equals(rating) || "G".equals(rating);
}
private void freezeInventory(LandlordId landlordId, LeaseId leaseId) {
private void freezeInventory(LandlordId landlordId, LeaseId leaseId, List<PrivateArea> privateAreas) {
FurnitureInventory inventory = inventories.find(landlordId, leaseId)
.orElseThrow(() -> new LeaseRuleViolation("L'inventaire du mobilier doit être renseigné."));
inventory.freeze();
Set<String> privateAreaIds = privateAreas.stream().map(PrivateArea::id).collect(Collectors.toSet());
inventory.freeze(privateAreaIds);
inventories.save(inventory);
}
@@ -554,13 +558,14 @@ public final class LeaseFinalizationService {
}
private Activation prepareActivation(LandlordId landlordId, Lease lease) {
LeaseDraftCompleteness.requireComplete(lease);
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();
apartment.legalProfile().requireLeaseReady(apartment.id());
List<Tenant> leaseTenants = lease.tenantIds().stream()
.map(tenantId -> tenants.find(landlordId, tenantId).orElseThrow(() -> new ResourceNotFound("Tenant")))
.toList();
@@ -1,6 +1,8 @@
package fr.nessar.lmnporganizer.rental.domain.apartment;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
public record ApartmentLegalProfile(String taxIdentifier, BuildingType buildingType, String ownershipRegime,
@@ -64,11 +66,35 @@ public record ApartmentLegalProfile(String taxIdentifier, BuildingType buildingT
}
public void requireLeaseReady() {
if (taxIdentifier.isEmpty() || buildingType == null || ownershipRegime.isEmpty() || constructionPeriod == null
|| habitableSurface == null || habitableSurface.signum() <= 0 || heating.isEmpty() || hotWater.isEmpty()
|| energyRating.isEmpty() || estimatedAnnualEnergyCost == null
|| estimatedAnnualEnergyCostReferenceYear == null || destination.isEmpty()) {
throw new IncompleteApartmentLegalProfile();
requireLeaseReady(null);
}
public void requireLeaseReady(ApartmentId apartmentId) {
List<String> missing = new ArrayList<>();
if (taxIdentifier.isEmpty())
missing.add("taxIdentifier");
if (buildingType == null)
missing.add("buildingType");
if (ownershipRegime.isEmpty())
missing.add("ownershipRegime");
if (constructionPeriod == null)
missing.add("constructionPeriod");
if (habitableSurface == null || habitableSurface.signum() <= 0)
missing.add("habitableSurface");
if (heating.isEmpty())
missing.add("heating");
if (hotWater.isEmpty())
missing.add("hotWater");
if (energyRating.isEmpty())
missing.add("energyRating");
if (estimatedAnnualEnergyCost == null)
missing.add("estimatedAnnualEnergyCost");
if (estimatedAnnualEnergyCostReferenceYear == null)
missing.add("estimatedAnnualEnergyCostReferenceYear");
if (destination.isEmpty())
missing.add("destination");
if (!missing.isEmpty()) {
throw new IncompleteApartmentLegalProfile(missing, apartmentId);
}
}
@@ -1,7 +1,37 @@
package fr.nessar.lmnporganizer.rental.domain.apartment;
import java.util.List;
public final class IncompleteApartmentLegalProfile extends RuntimeException {
private final List<String> missingFields;
private final ApartmentId apartmentId;
public IncompleteApartmentLegalProfile() {
super("Le profil juridique de l'appartement doit être complet avant de générer ou finaliser le bail.");
this(List.of(), null);
}
public IncompleteApartmentLegalProfile(List<String> missingFields) {
this(missingFields, null);
}
public IncompleteApartmentLegalProfile(List<String> missingFields, ApartmentId apartmentId) {
super(message(missingFields));
this.missingFields = List.copyOf(missingFields);
this.apartmentId = apartmentId;
}
public List<String> missingFields() {
return missingFields;
}
public ApartmentId apartmentId() {
return apartmentId;
}
private static String message(List<String> missingFields) {
return missingFields == null || missingFields.isEmpty()
? "Le profil juridique de l'appartement doit être complet avant de générer ou finaliser le bail."
: "Le profil juridique de l'appartement est incomplet. Informations manquantes : "
+ String.join(", ", missingFields);
}
}
@@ -1,17 +1,30 @@
package fr.nessar.lmnporganizer.rental.domain.condition;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
import java.util.Objects;
public final class EntryConditionReportPrerequisite extends IllegalStateException {
public enum FailureReason {
LEASE_NOT_DRAFT, LANDLORD_PROFILE_INCOMPLETE, PRIVATE_AREAS_MISSING, INVENTORY_MISSING, INVENTORY_INCOMPLETE, INVENTORY_PRIVATE_AREA_MISSING
}
private final FailureReason reason;
private final ApartmentId apartmentId;
public EntryConditionReportPrerequisite(FailureReason reason) {
this.reason = reason;
this(reason, null);
}
public EntryConditionReportPrerequisite(FailureReason reason, ApartmentId apartmentId) {
this.reason = Objects.requireNonNull(reason);
this.apartmentId = apartmentId;
}
public FailureReason reason() {
return reason;
}
public ApartmentId apartmentId() {
return apartmentId;
}
}
@@ -35,13 +35,10 @@ public final class FurnitureInventory {
}
public static FurnitureInventory prefilled(FurnitureInventoryId id, LandlordId landlordId, LeaseId leaseId,
String templateVersion, Function<Integer, String> lineIds, String privateAreaId) {
String templateVersion, Function<Integer, String> lineIds) {
Objects.requireNonNull(lineIds, "Furniture line ID generator is required.");
if (privateAreaId == null || privateAreaId.isBlank()) {
throw new IllegalArgumentException("A private area is required for the legal furniture catalog.");
}
List<FurnitureLine> lines = legalCatalog(templateVersion).stream()
.map(category -> new FurnitureLine(lineIds.apply(category.ordinal()), category, null, privateAreaId,
.map(category -> new FurnitureLine(lineIds.apply(category.ordinal()), category, null, null,
category.label(), 0, ""))
.toList();
return new FurnitureInventory(id, landlordId, leaseId, templateVersion, lines, false, -1);
@@ -91,8 +88,10 @@ public final class FurnitureInventory {
this.templateVersion = FurnishedLeaseTemplate.fromVisibleId(templateVersion).visibleId();
}
public boolean isReadyForLeaseDocument() {
return missingCategories().isEmpty();
public boolean isReadyForLeaseDocument(Set<String> privateAreaIds) {
Objects.requireNonNull(privateAreaIds);
return missingCategories().isEmpty() && lines.stream()
.allMatch(line -> line.privateAreaId() != null && privateAreaIds.contains(line.privateAreaId()));
}
public List<FurnitureCategory> missingCategories() {
@@ -113,9 +112,9 @@ public final class FurnitureInventory {
.toList();
}
public void freeze() {
if (!isReadyForLeaseDocument()) {
throw new IllegalStateException("Every legal furniture category must be declared.");
public void freeze(Set<String> privateAreaIds) {
if (!isReadyForLeaseDocument(privateAreaIds)) {
throw new IllegalStateException("Every furniture line must reference an existing private area.");
}
frozen = true;
}
@@ -3,19 +3,18 @@ package fr.nessar.lmnporganizer.rental.domain.furniture;
public record FurnitureLine(String id, FurnitureCategory category, FurniturePresence presence, String privateAreaId,
String designation, int quantity, String description) {
public FurnitureLine {
if (id == null || id.isBlank() || privateAreaId == null || privateAreaId.isBlank() || designation == null
|| designation.isBlank() || quantity < 0) {
if (id == null || id.isBlank() || designation == null || designation.isBlank() || quantity < 0) {
throw new IllegalArgumentException("Furniture line is invalid.");
}
id = id.trim();
privateAreaId = privateAreaId.trim();
privateAreaId = privateAreaId == null || privateAreaId.isBlank() ? null : privateAreaId.trim();
designation = designation.trim();
description = description == null ? "" : description.trim();
if (category == null && presence != null) {
throw new IllegalArgumentException("Only legal furniture categories may declare a presence.");
}
if (category != null && presence == null && quantity != 0) {
throw new IllegalArgumentException("An undeclared legal furniture category must have a zero quantity.");
throw new IllegalArgumentException("Undeclared furniture must have a zero quantity.");
}
if (presence == FurniturePresence.PRESENT && quantity == 0) {
throw new IllegalArgumentException("Present furniture must have a positive quantity.");
@@ -0,0 +1,23 @@
package fr.nessar.lmnporganizer.rental.domain.lease;
import java.util.List;
public final class IncompleteLeaseDraft extends RuntimeException {
private final List<LeaseDraftCompleteness.Section> missingSections;
private final List<LeaseDraftCompleteness.Field> missingFields;
public IncompleteLeaseDraft(List<LeaseDraftCompleteness.Section> missingSections,
List<LeaseDraftCompleteness.Field> missingFields) {
super("Le brouillon du bail est incomplet. Informations manquantes : " + missingFields);
this.missingSections = List.copyOf(missingSections);
this.missingFields = List.copyOf(missingFields);
}
public List<LeaseDraftCompleteness.Section> missingSections() {
return missingSections;
}
public List<LeaseDraftCompleteness.Field> missingFields() {
return missingFields;
}
}
@@ -18,6 +18,7 @@ public final class Lease {
private final LeaseEndingHistory endingHistory;
private ApartmentId apartmentId;
private List<TenantId> tenantIds;
private LeaseDraftFacts draftFacts;
private List<LeaseGuarantee> guarantees;
private VisaleGuarantee visaleGuarantee;
private VisaleVisa visaleVisa;
@@ -30,9 +31,10 @@ public final class Lease {
private LeaseSnapshot snapshot;
private Lease(LeaseId id, LandlordId landlordId, ApartmentId apartmentId, List<TenantId> tenantIds,
List<LeaseGuarantee> guarantees, RentalPeriod period, RentTerms terms, LeaseType leaseType,
List<LeaseSpecialClause> specialClauses, LeaseLegalTerms legalTerms, LeaseStatus status,
LeaseSnapshot snapshot, List<LeaseAmendment> amendments, LeaseEndingHistory endingHistory, long version) {
List<LeaseGuarantee> guarantees, LeaseDraftFacts draftFacts, RentalPeriod period, RentTerms terms,
LeaseType leaseType, List<LeaseSpecialClause> specialClauses, LeaseLegalTerms legalTerms,
LeaseStatus status, LeaseSnapshot snapshot, List<LeaseAmendment> amendments,
LeaseEndingHistory endingHistory, long version) {
this.id = Objects.requireNonNull(id);
this.landlordId = Objects.requireNonNull(landlordId);
if (version < -1) {
@@ -41,16 +43,23 @@ public final class Lease {
this.version = version;
this.apartmentId = Objects.requireNonNull(apartmentId);
this.tenantIds = copy(tenantIds);
this.draftFacts = draftFacts;
this.guarantees = guarantees == null ? List.of() : List.copyOf(guarantees);
validateGuarantees(this.tenantIds, this.guarantees);
this.visaleGuarantee = null;
this.visaleVisa = null;
this.period = Objects.requireNonNull(period);
this.terms = Objects.requireNonNull(terms);
this.leaseType = LeaseType.orStandard(leaseType);
this.period = period;
this.terms = terms;
this.leaseType = leaseType;
this.specialClauses = specialClauses == null ? List.of() : List.copyOf(specialClauses);
this.legalTerms = LeaseLegalTerms.orEmpty(legalTerms);
this.legalTerms.validateForTenantCount(this.tenantIds.size());
this.legalTerms = status == LeaseStatus.DRAFT ? LeaseLegalTerms.orEmpty(legalTerms) : legalTerms;
if (status != LeaseStatus.DRAFT) {
if (period == null || terms == null || leaseType == null || legalTerms == null)
throw new LeaseRuleViolation("Un bail activé doit conserver toutes ses données contractuelles.");
this.legalTerms.validateForTenantCount(this.tenantIds.size());
} else if (legalTerms != null) {
this.legalTerms.validateForTenantCount(this.tenantIds.size());
}
this.status = Objects.requireNonNull(status);
this.snapshot = snapshot;
this.amendments = new LeaseAmendmentHistory(amendments);
@@ -59,8 +68,8 @@ public final class Lease {
public static Lease draft(LeaseId id, LandlordId landlordId, ApartmentId apartmentId, List<TenantId> tenantIds,
List<LeaseGuarantee> guarantees, RentalPeriod period, RentTerms terms) {
return new Lease(id, landlordId, apartmentId, tenantIds, guarantees, period, terms, LeaseType.STANDARD,
List.of(), LeaseLegalTerms.empty(), LeaseStatus.DRAFT, null, List.of(), LeaseEndingHistory.empty(), -1);
return draft(id, landlordId, apartmentId, tenantIds, guarantees, period, terms, LeaseType.STANDARD, List.of(),
LeaseLegalTerms.empty());
}
public static Lease draft(LeaseId id, LandlordId landlordId, ApartmentId apartmentId, List<TenantId> tenantIds,
@@ -78,10 +87,21 @@ public final class Lease {
public static Lease draft(LeaseId id, LandlordId landlordId, ApartmentId apartmentId, List<TenantId> tenantIds,
List<LeaseGuarantee> guarantees, RentalPeriod period, RentTerms terms, LeaseType leaseType,
List<LeaseSpecialClause> specialClauses, LeaseLegalTerms legalTerms) {
LeaseType type = LeaseType.orStandard(leaseType);
return new Lease(id, landlordId, apartmentId, tenantIds, guarantees, type.initialPeriod(period.startDate()),
terms, type, specialClauses, legalTerms, LeaseStatus.DRAFT, null, List.of(), LeaseEndingHistory.empty(),
-1);
LeaseType type = leaseType == null ? null : LeaseType.orStandard(leaseType);
LeaseDraftFacts facts = LeaseDraftFacts.from(period, terms, type);
return new Lease(id, landlordId, apartmentId, tenantIds, guarantees, facts, facts.period(), facts.terms(), type,
specialClauses, legalTerms, LeaseStatus.DRAFT, null, List.of(), LeaseEndingHistory.empty(), -1);
}
public static Lease draft(LeaseId id, LandlordId landlordId, ApartmentId apartmentId, List<TenantId> tenantIds,
List<LeaseGuarantee> guarantees, LeaseDraftFacts facts, List<LeaseSpecialClause> specialClauses,
LeaseLegalTerms legalTerms) {
LeaseDraftFacts draftFacts = facts == null
? new LeaseDraftFacts(null, null, null, null, null, null, null, null)
: facts;
return new Lease(id, landlordId, apartmentId, tenantIds, guarantees, draftFacts, draftFacts.period(),
draftFacts.terms(), draftFacts.leaseType(), specialClauses, legalTerms, LeaseStatus.DRAFT, null,
List.of(), LeaseEndingHistory.empty(), -1);
}
public static Lease reconstitute(LeaseId id, LandlordId landlordId, ApartmentId apartmentId,
@@ -143,6 +163,26 @@ public final class Lease {
LeaseType leaseType, List<LeaseSpecialClause> specialClauses, LeaseLegalTerms legalTerms,
LeaseStatus status, LeaseSnapshot snapshot, List<LeaseAmendment> amendments,
LeaseEndingHistory endingHistory, long version) {
LeaseDraftFacts facts = status == LeaseStatus.DRAFT ? LeaseDraftFacts.from(period, terms, leaseType) : null;
return reconstitute(id, landlordId, apartmentId, tenantIds, guarantees, facts, specialClauses, legalTerms,
status, snapshot, amendments, endingHistory, version, period, terms, leaseType);
}
public static Lease reconstitute(LeaseId id, LandlordId landlordId, ApartmentId apartmentId,
List<TenantId> tenantIds, List<LeaseGuarantee> guarantees, LeaseDraftFacts draftFacts,
List<LeaseSpecialClause> specialClauses, LeaseLegalTerms legalTerms, LeaseStatus status,
LeaseSnapshot snapshot, List<LeaseAmendment> amendments, LeaseEndingHistory endingHistory, long version) {
LeaseDraftFacts facts = status == LeaseStatus.DRAFT ? Objects.requireNonNull(draftFacts) : null;
return reconstitute(id, landlordId, apartmentId, tenantIds, guarantees, facts, specialClauses, legalTerms,
status, snapshot, amendments, endingHistory, version, facts == null ? null : facts.period(),
facts == null ? null : facts.terms(), facts == null ? null : facts.leaseType());
}
private static Lease reconstitute(LeaseId id, LandlordId landlordId, ApartmentId apartmentId,
List<TenantId> tenantIds, List<LeaseGuarantee> guarantees, LeaseDraftFacts draftFacts,
List<LeaseSpecialClause> specialClauses, LeaseLegalTerms legalTerms, LeaseStatus status,
LeaseSnapshot snapshot, List<LeaseAmendment> amendments, LeaseEndingHistory endingHistory, long version,
RentalPeriod period, RentTerms terms, LeaseType leaseType) {
if (status == LeaseStatus.DRAFT && snapshot != null) {
throw new LeaseRuleViolation("Un bail en brouillon ne peut pas avoir d'instantané contractuel.");
}
@@ -152,8 +192,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, guarantees, period, terms, leaseType,
specialClauses, legalTerms, status, snapshot, amendments, endingHistory, version);
Lease lease = new Lease(id, landlordId, apartmentId, tenantIds, guarantees, draftFacts, period, terms,
leaseType, specialClauses, legalTerms, status, snapshot, amendments, endingHistory, version);
if (snapshot != null) {
lease.validateTenantIds();
lease.requireSnapshotIdentity(snapshot);
@@ -219,11 +259,42 @@ public final class Lease {
this.legalTerms = updatedLegalTerms;
}
public void reviseDraft(ApartmentId apartmentId, List<TenantId> tenantIds, List<LeaseGuarantee> guarantees,
LeaseDraftFacts draftFacts, List<LeaseSpecialClause> specialClauses, LeaseLegalTerms legalTerms,
VisaleGuarantee visaleGuarantee) {
requireDraft("modifié");
LeaseDraftFacts updatedFacts = Objects.requireNonNull(draftFacts);
List<TenantId> updatedTenantIds = copy(tenantIds);
List<LeaseGuarantee> updatedGuarantees = guarantees == null ? List.of() : List.copyOf(guarantees);
LeaseLegalTerms updatedLegalTerms = LeaseLegalTerms.orEmpty(legalTerms);
if (updatedLegalTerms != null) {
updatedLegalTerms.validateForTenantCount(updatedTenantIds.size());
}
validateGuarantees(updatedTenantIds, updatedGuarantees);
validateVisaleGuarantee(updatedTenantIds, visaleGuarantee);
if (visaleGuarantee == null && visaleVisa != null) {
throw new LeaseRuleViolation("Supprimez d’abord le PDF du visa Visale.");
}
this.apartmentId = Objects.requireNonNull(apartmentId);
this.tenantIds = updatedTenantIds;
this.guarantees = updatedGuarantees;
this.visaleGuarantee = visaleGuarantee;
this.draftFacts = updatedFacts;
this.period = updatedFacts.period();
this.terms = updatedFacts.terms();
this.leaseType = updatedFacts.leaseType();
this.specialClauses = specialClauses == null ? List.of() : List.copyOf(specialClauses);
this.legalTerms = updatedLegalTerms;
}
public LeaseActivated activate(LeaseSnapshot frozenContract) {
requireDraft("activé");
LeaseDraftCompleteness.requireComplete(this);
validateTenantIds();
requireMatchingSnapshot(frozenContract);
snapshot = frozenContract;
draftFacts = null;
status = LeaseStatus.ACTIVE;
return new LeaseActivated(id, landlordId, period, terms);
}
@@ -474,6 +545,7 @@ public final class Lease {
tenantIds = contractVersion.tenantIds();
guarantees = contractVersion.guarantees();
visaleGuarantee = contractVersion.visaleGuarantee();
draftFacts = null;
period = contractVersion.period();
terms = contractVersion.terms();
leaseType = contractVersion.leaseType();
@@ -543,6 +615,10 @@ public final class Lease {
return tenantIds;
}
public LeaseDraftFacts draftFacts() {
return status == LeaseStatus.DRAFT ? draftFacts : null;
}
public List<LeaseGuarantee> guarantees() {
return guarantees;
}
@@ -0,0 +1,89 @@
package fr.nessar.lmnporganizer.rental.domain.lease;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* One completeness calculation shared by the draft response and package guards.
*/
public final class LeaseDraftCompleteness {
private LeaseDraftCompleteness() {
}
public static Result evaluate(Lease lease) {
List<Section> missingSections = new ArrayList<>();
List<Field> missingFields = new ArrayList<>();
Map<Section, Boolean> sections = new LinkedHashMap<>();
for (Section section : Section.values())
sections.put(section, true);
if (lease.apartmentId() == null || lease.tenantIds().isEmpty()) {
missingSections.add(Section.PARTIES);
sections.put(Section.PARTIES, false);
if (lease.apartmentId() == null)
missingFields.add(Field.APARTMENT_ID);
if (lease.tenantIds().isEmpty())
missingFields.add(Field.TENANT_IDS);
}
LeaseDraftFacts facts = lease.draftFacts();
if (facts != null) {
for (Field field : Field.CONTRACT_FIELDS)
if (facts.isMissing(field))
missingFields.add(field);
} else if (lease.status() == LeaseStatus.DRAFT) {
missingFields.addAll(Field.CONTRACT_FIELDS);
}
boolean conditionsIncomplete = missingFields.stream().anyMatch(field -> field.section() == Section.CONDITIONS);
if (conditionsIncomplete) {
missingSections.add(Section.CONDITIONS);
sections.put(Section.CONDITIONS, false);
}
return new Result(missingSections.stream().distinct().toList(), missingFields.stream().distinct().toList(),
sections);
}
public static void requireComplete(Lease lease) {
Result result = evaluate(lease);
if (!result.complete())
throw new IncompleteLeaseDraft(result.missingSections(), result.missingFields());
}
public record Result(List<Section> missingSections, List<Field> missingFields, Map<Section, Boolean> sections) {
public Result {
missingSections = List.copyOf(missingSections);
missingFields = List.copyOf(missingFields);
sections = Collections.unmodifiableMap(new LinkedHashMap<>(sections));
}
public boolean complete() {
return missingSections.isEmpty() && missingFields.isEmpty();
}
}
public enum Section {
PARTIES, CONDITIONS, LEGAL_TERMS, CLAUSES, GUARANTEES
}
public enum Field {
APARTMENT_ID(Section.PARTIES), TENANT_IDS(Section.PARTIES), START_DATE(Section.CONDITIONS), MONTHLY_RENT(
Section.CONDITIONS), MONTHLY_CHARGES(Section.CONDITIONS), SECURITY_DEPOSIT(Section.CONDITIONS), DUE_DAY(
Section.CONDITIONS), CHARGE_METHOD(Section.CONDITIONS), INDEX_FLAT_RATE(
Section.CONDITIONS), LEASE_TYPE(Section.CONDITIONS);
private static final List<Field> CONTRACT_FIELDS = List.of(START_DATE, MONTHLY_RENT, MONTHLY_CHARGES,
SECURITY_DEPOSIT, DUE_DAY, CHARGE_METHOD, INDEX_FLAT_RATE, LEASE_TYPE);
private final Section section;
Field(Section section) {
this.section = section;
}
public Section section() {
return section;
}
}
}
@@ -0,0 +1,75 @@
package fr.nessar.lmnporganizer.rental.domain.lease;
import java.math.BigDecimal;
import java.time.LocalDate;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDraftCompleteness.Field;
/**
* The editable contract fields of a draft. A draft may carry only some of these
* facts; an activated lease is always converted to the existing complete value
* objects.
*/
public record LeaseDraftFacts(LocalDate startDate, BigDecimal monthlyRent, BigDecimal monthlyCharges,
BigDecimal securityDeposit, Integer dueDay, ChargeMethod chargeMethod, Boolean indexFlatRate,
LeaseType leaseType) {
public LeaseDraftFacts {
if (monthlyRent != null && monthlyRent.signum() <= 0) {
throw new LeaseRuleViolation("Le loyer mensuel doit être strictement positif.");
}
if (monthlyCharges != null && monthlyCharges.signum() < 0) {
throw new LeaseRuleViolation("Les charges mensuelles doivent être positives ou nulles.");
}
if (securityDeposit != null && securityDeposit.signum() < 0) {
throw new LeaseRuleViolation("Le dépôt de garantie doit être positif ou nul.");
}
if (dueDay != null && (dueDay < 1 || dueDay > 28)) {
throw new LeaseRuleViolation("Le jour d'échéance doit être compris entre 1 et 28.");
}
if (chargeMethod == ChargeMethod.PROVISION && Boolean.TRUE.equals(indexFlatRate)) {
throw new LeaseRuleViolation("Une provision sur charges ne peut pas être indexée comme un forfait.");
}
}
public static LeaseDraftFacts from(RentalPeriod period, RentTerms terms, LeaseType leaseType) {
return new LeaseDraftFacts(period == null ? null : period.startDate(),
terms == null ? null : terms.monthlyRent(), terms == null ? null : terms.monthlyCharges(),
terms == null ? null : terms.securityDeposit(), terms == null ? null : terms.dueDay(),
terms == null ? null : terms.chargeMethod(), terms == null ? null : terms.indexFlatRate(), leaseType);
}
public boolean contractComplete() {
return startDate != null && monthlyRent != null && monthlyCharges != null && securityDeposit != null
&& dueDay != null && chargeMethod != null && indexFlatRate != null && leaseType != null;
}
public RentalPeriod period() {
if (startDate == null || leaseType == null) {
return null;
}
return leaseType.initialPeriod(startDate);
}
public RentTerms terms() {
if (monthlyRent == null || monthlyCharges == null || securityDeposit == null || dueDay == null
|| chargeMethod == null || indexFlatRate == null) {
return null;
}
return new RentTerms(monthlyRent, monthlyCharges, securityDeposit, dueDay, chargeMethod, indexFlatRate);
}
public boolean isMissing(Field field) {
return switch (field) {
case START_DATE -> startDate == null;
case MONTHLY_RENT -> monthlyRent == null;
case MONTHLY_CHARGES -> monthlyCharges == null;
case SECURITY_DEPOSIT -> securityDeposit == null;
case DUE_DAY -> dueDay == null;
case CHARGE_METHOD -> chargeMethod == null;
case INDEX_FLAT_RATE -> indexFlatRate == null;
case LEASE_TYPE -> leaseType == null;
default -> throw new IllegalArgumentException("Not a lease contract fact: " + field);
};
}
}
@@ -19,9 +19,6 @@ public record LeaseLegalTerms(String vaultPublicKey, RentControlTerms rentContro
vaultPublicKey = vaultPublicKey == null
? null
: ApplicationCryptoValue.encoded(vaultPublicKey, "Lease vault public key", 384, 1024);
if (vaultPublicKey != null && otherTerms == null) {
throw new LeaseRuleViolation("Les autres conditions chiffrées sont obligatoires.");
}
rentControl = rentControl == null ? RentControlTerms.empty() : rentControl;
previousRent = previousRent == null ? PreviousRentTerms.empty() : previousRent;
rentRevision = rentRevision == null ? RentRevisionTerms.empty() : rentRevision;
@@ -38,7 +38,7 @@
<table class="key-value">
<tr><th>Exploitant</th><td th:text="${document.profile.firstName + ' ' + document.profile.lastName}">Ada Lovelace</td></tr>
<tr><th>Adresse</th><td th:text="${document.profile.address}">1 rue du Test</td></tr>
<tr><th>SIRET</th><td th:text="${document.profile.siret}">12345678901234</td></tr>
<tr><th>SIRET</th><td th:text="${document.profile.siret}">73282932000074</td></tr>
<tr><th>Référentiel</th><td th:text="${'Exercice ' + document.fiscalYear + ' · formulaires ' + document.ruleSet.formYear}">Exercice 2025 · formulaires 2026</td></tr>
</table>
@@ -103,7 +103,7 @@ class TaxControllerWebTest {
.andExpect(jsonPath("$.profile").doesNotExist())
.andExpect(jsonPath("$.suggestedFirstName").value("Ada"));
LmnpTaxProfile savedProfile = new LmnpTaxProfile("12345678901234", LocalDate.of(2025, 1, 1), "Ada", "Lovelace",
LmnpTaxProfile savedProfile = new LmnpTaxProfile("12345678901237", LocalDate.of(2025, 1, 1), "Ada", "Lovelace",
"1 rue du Profil", true, DeclarationPosition.DECLARANT_1, true);
when(workspaces.saveProfile(LANDLORD, -1, savedProfile))
.thenReturn(TaxWorkspace.empty(LANDLORD).withProfile(savedProfile));
@@ -114,10 +114,10 @@ class TaxControllerWebTest {
put("/api/tax/profile").with(oidcLogin().idToken(token -> token.issuer("https://issuer.example")))
.with(csrf()).contentType(MediaType.APPLICATION_JSON).content(
"""
{"version":-1,"siret":"12345678901234","activityStartDate":"2025-01-01","firstName":"Ada","lastName":"Lovelace","address":"1 rue du Profil","taxResidentInFrance":true,"declarationPosition":"DECLARANT_1","directlyOwnedByNaturalPerson":true}
{"version":-1,"siret":"12345678901237","activityStartDate":"2025-01-01","firstName":"Ada","lastName":"Lovelace","address":"1 rue du Profil","taxResidentInFrance":true,"declarationPosition":"DECLARANT_1","directlyOwnedByNaturalPerson":true}
"""))
.andExpect(status().isOk()).andExpect(jsonPath("$.version").value(0))
.andExpect(jsonPath("$.profile.siret").value("12345678901234"));
.andExpect(jsonPath("$.profile.siret").value("12345678901237"));
verify(workspaces).saveProfile(LANDLORD, -1, savedProfile);
}
@@ -135,7 +135,7 @@ class TaxControllerWebTest {
@Test
void lists_every_open_year_since_the_activity_started() throws Exception {
LmnpTaxProfile profile = new LmnpTaxProfile("12345678901234", LocalDate.of(2024, 1, 1), "Ada", "Lovelace",
LmnpTaxProfile profile = new LmnpTaxProfile("12345678901237", LocalDate.of(2024, 1, 1), "Ada", "Lovelace",
"1 rue du Profil", true, DeclarationPosition.DECLARANT_1, true);
when(workspaces.workspace(LANDLORD)).thenReturn(TaxWorkspace.empty(LANDLORD).withProfile(profile));
@@ -151,7 +151,7 @@ class TaxControllerWebTest {
DeferredAmortization archived = new DeferredAmortization("archived-plan", "archived-asset", "apartment-1", 2023,
new BigDecimal("200.00"), "Ancien plan", "Ancien mobilier", "Studio République — 1 rue");
TaxOpeningBalance opening = new TaxOpeningBalance(2024, List.of(), List.of(archived), null);
LmnpTaxProfile profile = new LmnpTaxProfile("12345678901234", LocalDate.of(2020, 1, 1), "Ada", "Lovelace",
LmnpTaxProfile profile = new LmnpTaxProfile("12345678901237", LocalDate.of(2020, 1, 1), "Ada", "Lovelace",
"1 rue du Profil", true, DeclarationPosition.DECLARANT_1, true);
TaxWorkspace workspace = new TaxWorkspace(LANDLORD, profile,
new TaxHistoryEntry(EntrySituation.PRIOR_FILINGS, opening, "author", Instant.now()), List.of(),
@@ -22,7 +22,7 @@ class MongoTaxWorkspaceRepositoryTest {
void stores_and_reads_the_single_versioned_workspace_under_the_landlord_id() {
LandlordId landlordId = new LandlordId("landlord-1");
TaxWorkspace workspace = TaxWorkspace.empty(landlordId)
.withProfile(new LmnpTaxProfile("12345678901234", LocalDate.of(2024, 1, 1), "Ada", "Lovelace",
.withProfile(new LmnpTaxProfile("12345678901237", LocalDate.of(2024, 1, 1), "Ada", "Lovelace",
"1 rue du Test", true, DeclarationPosition.DECLARANT_1, true));
MongoTemplate mongo = mock(MongoTemplate.class);
when(mongo.insert(any(TaxWorkspaceDocument.class))).thenAnswer(invocation -> invocation.getArgument(0));
@@ -197,7 +197,7 @@ class TaxFilingMongoIT {
}
private static LmnpTaxProfile profile() {
return new LmnpTaxProfile("12345678901234", LocalDate.of(2025, 1, 1), "Ada", "Lovelace", "1 rue du Test", true,
return new LmnpTaxProfile("12345678901237", LocalDate.of(2025, 1, 1), "Ada", "Lovelace", "1 rue du Test", true,
DeclarationPosition.DECLARANT_1, true);
}
@@ -49,7 +49,7 @@ class ThymeleafTaxSimulationPdfRendererTest {
"Studio République — 1 rue de Paris", new BigDecimal("200.49"), "/amortizations/1",
"Dotation de l’exercice 2025", SourceStatus.RECORDED)),
List.of(), List.of(), DeclarationPosition.DECLARANT_1, Set.of("FOREIGN_ACCOUNT")));
LmnpTaxProfile profile = new LmnpTaxProfile("12345678901234", LocalDate.of(2025, 1, 1), "Ada", "Lovelace",
LmnpTaxProfile profile = new LmnpTaxProfile("12345678901237", LocalDate.of(2025, 1, 1), "Ada", "Lovelace",
"1 rue du Test", true, DeclarationPosition.DECLARANT_1, true);
AnnualTaxData annual = AnnualTaxData.empty(2025)
.withSupplementalInput(new SupplementalTaxInput("technical-input-id",
@@ -62,7 +62,7 @@ class ThymeleafTaxSimulationPdfRendererTest {
String text = pdfText(pdf).replace('\u00a0', ' ').replace('\u202f', ' ');
assertThat(text).contains("DOSSIER DE SIMULATION FISCALE LMNP", "Exercice", "Révision", "2025", "2",
"Ada Lovelace", "12345678901234", "ni un formulaire Cerfa ni un document déposable",
"Ada Lovelace", "12345678901237", "ni un formulaire Cerfa ni un document déposable",
"Calculs détaillés", "Recettes acquises", "Loyers acquis", "1 200,50 € = 1 200,50 €",
"Loyer acquis pour 2025-12 · payé le 2026-01-08", "Studio République — 1 rue de Paris", "Enregistré",
"Charges hors amortissements", "Amortissements admis", "Plan du studio — Mobilier", "2033-B:218",
@@ -399,7 +399,7 @@ class TaxFilingServiceTest {
}
private static LmnpTaxProfile profile() {
return new LmnpTaxProfile("12345678901234", LocalDate.of(2025, 1, 1), "Ada", "Lovelace", "1 rue du Test", true,
return new LmnpTaxProfile("12345678901237", LocalDate.of(2025, 1, 1), "Ada", "Lovelace", "1 rue du Test", true,
DeclarationPosition.DECLARANT_1, true);
}
}
@@ -229,7 +229,7 @@ class TaxSimulationServiceTest {
@Test
void current_preview_refreshes_human_labels_without_mutating_the_frozen_opening_balance() {
LmnpTaxProfile profile = new LmnpTaxProfile("12345678901234", LocalDate.of(2024, 1, 1), "Ada", "Lovelace",
LmnpTaxProfile profile = new LmnpTaxProfile("12345678901237", LocalDate.of(2024, 1, 1), "Ada", "Lovelace",
"1 rue du Test", true, DeclarationPosition.DECLARANT_1, true);
DeferredAmortization frozen = new DeferredAmortization("plan-1", "asset-1", "apartment-1", 2024,
new BigDecimal("100.00"), "Ancien plan", "Ancienne immobilisation", "Ancien appartement");
@@ -365,7 +365,7 @@ class TaxSimulationServiceTest {
}
private static TaxWorkspace completeWorkspace(DataCompletenessConfirmation confirmation) {
LmnpTaxProfile profile = new LmnpTaxProfile("12345678901234", LocalDate.of(2025, 1, 1), "Ada", "Lovelace",
LmnpTaxProfile profile = new LmnpTaxProfile("12345678901237", LocalDate.of(2025, 1, 1), "Ada", "Lovelace",
"1 rue du Test", true, DeclarationPosition.DECLARANT_1, true);
TaxHistoryEntry history = new TaxHistoryEntry(EntrySituation.NONE_FILED, null, "author", NOW);
AnnualTaxRegimeConfirmation regime = new AnnualTaxRegimeConfirmation(true, true, true, true, true, true, true,
@@ -84,7 +84,7 @@ class TaxWorkspaceServiceTest {
CLOCK);
var firstOpen = service.profile(LANDLORD_ID);
LmnpTaxProfile profile = new LmnpTaxProfile("12345678901234", LocalDate.of(2024, 2, 1), firstOpen.firstName(),
LmnpTaxProfile profile = new LmnpTaxProfile("12345678901237", LocalDate.of(2024, 2, 1), firstOpen.firstName(),
firstOpen.lastName(), firstOpen.address(), true, DeclarationPosition.DECLARANT_1, true);
TaxWorkspace saved = service.saveProfile(LANDLORD_ID, -1, profile);
@@ -266,7 +266,7 @@ class TaxWorkspaceServiceTest {
void validates_historical_deferred_amortization_against_the_asset_history() {
TaxWorkspaceRepository workspaces = mock(TaxWorkspaceRepository.class);
AmortizationPlanRepository plans = mock(AmortizationPlanRepository.class);
LmnpTaxProfile profile = new LmnpTaxProfile("12345678901234", LocalDate.of(2020, 1, 1), "Ada", "Lovelace",
LmnpTaxProfile profile = new LmnpTaxProfile("12345678901237", LocalDate.of(2020, 1, 1), "Ada", "Lovelace",
"1 rue du Test", true, DeclarationPosition.DECLARANT_1, true);
TaxWorkspace base = new TaxWorkspace(LANDLORD_ID, profile, null, List.of(), List.of(), List.of(), List.of(), 1);
AssetAmortizationPlan plan = mock(AssetAmortizationPlan.class);
@@ -315,7 +315,7 @@ class TaxWorkspaceServiceTest {
DeferredAmortization archived = new DeferredAmortization("archived-plan", "archived-asset", "apartment-1", 2023,
new BigDecimal("100.00"), "Ancien plan", "Ancien mobilier", "Studio République — 1 rue");
TaxOpeningBalance opening = new TaxOpeningBalance(2024, List.of(), List.of(archived), null);
LmnpTaxProfile historicalProfile = new LmnpTaxProfile("12345678901234", LocalDate.of(2020, 1, 1), "Ada",
LmnpTaxProfile historicalProfile = new LmnpTaxProfile("12345678901237", LocalDate.of(2020, 1, 1), "Ada",
"Lovelace", "1 rue du Test", true, DeclarationPosition.DECLARANT_1, true);
TaxWorkspace base = new TaxWorkspace(LANDLORD_ID, historicalProfile,
new TaxHistoryEntry(EntrySituation.PRIOR_FILINGS, opening, "author", CLOCK.instant()), List.of(),
@@ -423,7 +423,7 @@ class TaxWorkspaceServiceTest {
TaxWorkspaceService service = new TaxWorkspaceService(workspaces, mock(LandlordRepository.class),
mock(ApartmentRepository.class), mock(AmortizationPlanRepository.class), mock(TaxFactCollector.class),
mock(TaxEvidenceStorage.class), CLOCK);
LmnpTaxProfile later = new LmnpTaxProfile("12345678901234", LocalDate.of(2026, 1, 1), "Ada", "Lovelace",
LmnpTaxProfile later = new LmnpTaxProfile("12345678901237", LocalDate.of(2026, 1, 1), "Ada", "Lovelace",
"1 rue du Test", true, DeclarationPosition.DECLARANT_1, true);
assertThatThrownBy(() -> service.saveProfile(LANDLORD_ID, 1, later))
@@ -443,7 +443,7 @@ class TaxWorkspaceServiceTest {
assertThatThrownBy(
() -> service.saveProfile(LANDLORD_ID, 1,
new LmnpTaxProfile("12345678901234", LocalDate.of(2027, 1, 1), "Ada", "Lovelace", "Address",
new LmnpTaxProfile("12345678901237", LocalDate.of(2027, 1, 1), "Ada", "Lovelace", "Address",
true, DeclarationPosition.DECLARANT_1, true)))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> service.saveProfile(LANDLORD_ID, 0, profile()))
@@ -467,7 +467,7 @@ class TaxWorkspaceServiceTest {
}
private static LmnpTaxProfile profile() {
return new LmnpTaxProfile("12345678901234", LocalDate.of(2025, 1, 1), "Ada", "Lovelace", "1 rue du Test", true,
return new LmnpTaxProfile("12345678901237", LocalDate.of(2025, 1, 1), "Ada", "Lovelace", "1 rue du Test", true,
DeclarationPosition.DECLARANT_1, true);
}
@@ -37,12 +37,12 @@ class TaxWorkspaceTest {
@Test
void stores_an_explicit_profile_and_unknown_history_without_inventing_zero_opening_balances() {
TaxWorkspace workspace = TaxWorkspace.empty(new LandlordId("landlord-1"))
.withProfile(new LmnpTaxProfile("12345678901234", LocalDate.of(2024, 2, 1), "Ada", "Lovelace",
.withProfile(new LmnpTaxProfile("12345678901237", LocalDate.of(2024, 2, 1), "Ada", "Lovelace",
"1 rue du Portefeuille", false, DeclarationPosition.DECLARANT_1, true))
.withHistory(new TaxHistoryEntry(EntrySituation.UNKNOWN, null, "landlord-1",
Instant.parse("2026-08-13T10:00:00Z")));
assertThat(workspace.profile().siret()).isEqualTo("12345678901234");
assertThat(workspace.profile().siret()).isEqualTo("12345678901237");
assertThat(workspace.profile().taxResidentInFrance()).isFalse();
assertThat(workspace.history().situation()).isEqualTo(EntrySituation.UNKNOWN);
assertThat(workspace.history().openingBalance()).isNull();
@@ -114,7 +114,7 @@ class TaxWorkspaceTest {
@Test
void creates_and_preserves_a_complete_immutable_filing_history() {
Instant now = Instant.parse("2026-05-01T10:00:00Z");
LmnpTaxProfile profile = new LmnpTaxProfile("12345678901234", LocalDate.of(2025, 1, 1), "Ada", "Lovelace",
LmnpTaxProfile profile = new LmnpTaxProfile("12345678901237", LocalDate.of(2025, 1, 1), "Ada", "Lovelace",
"1 rue du Test", true, DeclarationPosition.DECLARANT_2, true);
DataCompletenessConfirmation confirmation = new DataCompletenessConfirmation("source-fingerprint", "author",
now);
@@ -173,6 +173,18 @@ class TaxWorkspaceTest {
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void validates_siret_check_digit_locally() {
assertThat(new LmnpTaxProfile("73282932000074", LocalDate.of(2025, 1, 1), "Ada", "Lovelace", "Address", true,
DeclarationPosition.DECLARANT_1, true).siret()).isEqualTo("73282932000074");
assertThatThrownBy(() -> new LmnpTaxProfile("73282932000075", LocalDate.of(2025, 1, 1), "Ada", "Lovelace",
"Address", true, DeclarationPosition.DECLARANT_1, true)).isInstanceOf(IllegalArgumentException.class)
.hasMessage("SIRET check digit is invalid.");
assertThatThrownBy(() -> new LmnpTaxProfile("00000000000000", LocalDate.of(2025, 1, 1), "Ada", "Lovelace",
"Address", true, DeclarationPosition.DECLARANT_1, true)).isInstanceOf(IllegalArgumentException.class)
.hasMessage("SIRET cannot contain only zeros.");
}
private static TaxSimulationRevision revision(String id, int number) {
TaxSimulationRevision revision = mock(TaxSimulationRevision.class);
when(revision.id()).thenReturn(id);
@@ -119,7 +119,7 @@ class DashboardServiceTest {
@Test
void buildsASelectedPeriodWithNullableAccruedGapsAndZeroFilledCashSeries() {
var profile = new TaxWorkspace.LmnpTaxProfile("12345678901234", LocalDate.of(2025, 5, 1), "Ada", "Lovelace",
var profile = new TaxWorkspace.LmnpTaxProfile("12345678901237", LocalDate.of(2025, 5, 1), "Ada", "Lovelace",
"1 rue du Profil", true, DeclarationPosition.DECLARANT_1, true);
when(taxWorkspaces.workspace(LANDLORD)).thenReturn(TaxWorkspace.empty(LANDLORD).withProfile(profile));
when(dues.findAll(LANDLORD)).thenReturn(
@@ -342,7 +342,7 @@ class DashboardServiceTest {
private static TaxWorkspace workspaceWithProfile(int activityStartYear) {
return TaxWorkspace.empty(LANDLORD)
.withProfile(new TaxWorkspace.LmnpTaxProfile("12345678901234", LocalDate.of(activityStartYear, 5, 1),
.withProfile(new TaxWorkspace.LmnpTaxProfile("12345678901237", LocalDate.of(activityStartYear, 5, 1),
"Ada", "Lovelace", "1 rue du Profil", true, DeclarationPosition.DECLARANT_1, true));
}
@@ -68,6 +68,7 @@ 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.LeaseDraftFacts;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseLegalTerms;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSnapshot;
@@ -364,11 +365,11 @@ class RentalWebAdaptersTest {
}
@Test
void leaseDraftRequestsApplyDefaultPaymentTerms() throws Exception {
void leaseDraftRequestsPreserveMissingPaymentTermsAsDraftFacts() 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.guarantees(),
details.period(), details.terms(), details.leaseType(), List.of(), details.legalTerms());
details.draftFacts(), List.of(), details.legalTerms());
});
mockMvc.perform(
@@ -381,18 +382,17 @@ class RentalWebAdaptersTest {
"""
.formatted(LEASE_VAULT_PUBLIC_KEY, LEASE_VAULT_PUBLIC_KEY,
encryptedLeaseGroupJson("other-terms"))))
.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"));
.andExpect(status().isCreated()).andExpect(jsonPath("$.dueDay").doesNotExist())
.andExpect(jsonPath("$.chargeMethod").doesNotExist())
.andExpect(jsonPath("$.indexFlatRate").doesNotExist()).andExpect(jsonPath("$.leaseType").doesNotExist())
.andExpect(jsonPath("$.specialClauses").isEmpty()).andExpect(jsonPath("$.endDate").doesNotExist());
verify(leaseDrafts).create(LANDLORD_ID,
new LeaseDraftService.DraftDetails("lease-1", LEASE_VAULT_PUBLIC_KEY, 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(), LeaseLegalTerms.empty(LEASE_VAULT_PUBLIC_KEY, encryptedGroup("other-terms")),
null));
List.of(), List.of(),
LeaseLegalTerms.empty(LEASE_VAULT_PUBLIC_KEY, encryptedGroup("other-terms")), null,
new LeaseDraftFacts(LocalDate.of(2026, 1, 1), new BigDecimal("900.00"), new BigDecimal("50.00"),
new BigDecimal("900.00"), null, null, null, null)));
}
@Test
@@ -400,7 +400,7 @@ class RentalWebAdaptersTest {
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.guarantees(),
details.period(), details.terms(), details.leaseType(), List.of(), details.legalTerms());
details.draftFacts(), List.of(), details.legalTerms());
});
mockMvc.perform(
@@ -419,10 +419,11 @@ class RentalWebAdaptersTest {
verify(leaseDrafts).create(LANDLORD_ID,
new LeaseDraftService.DraftDetails("lease-1", LEASE_VAULT_PUBLIC_KEY, 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, List.of(),
LeaseLegalTerms.empty(LEASE_VAULT_PUBLIC_KEY, encryptedGroup("other-terms")), null));
List.of(), List.of(),
LeaseLegalTerms.empty(LEASE_VAULT_PUBLIC_KEY, encryptedGroup("other-terms")), null,
new LeaseDraftFacts(LocalDate.of(2026, 2, 15), new BigDecimal("900.00"),
new BigDecimal("50.00"), new BigDecimal("900.00"), null, null, null,
LeaseType.STUDENT)));
}
@Test
@@ -463,8 +464,7 @@ class RentalWebAdaptersTest {
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.guarantees(),
details.period(), details.terms(), LeaseType.STANDARD,
details.specialClauses().stream().map(RentalWebAdaptersTest::clause).toList(),
details.draftFacts(), details.specialClauses().stream().map(RentalWebAdaptersTest::clause).toList(),
details.legalTerms());
});
@@ -494,15 +494,15 @@ class RentalWebAdaptersTest {
verify(leaseDrafts).create(LANDLORD_ID,
new LeaseDraftService.DraftDetails("lease-1", LEASE_VAULT_PUBLIC_KEY, 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(),
List.of(new LeaseDraftService.ClauseDetails("catalog-clause", LEASE_VAULT_PUBLIC_KEY,
"VISIT_SALE_RELETTING", Map.of("startTime", "18:30"), Map.of(),
encryptedGroup("catalog-rendered")),
new LeaseDraftService.ClauseDetails("custom-clause", LEASE_VAULT_PUBLIC_KEY, null,
Map.of(), Map.of(), encryptedGroup("custom-rendered"))),
LeaseLegalTerms.empty(LEASE_VAULT_PUBLIC_KEY, encryptedGroup("other-terms")), null));
LeaseLegalTerms.empty(LEASE_VAULT_PUBLIC_KEY, encryptedGroup("other-terms")), null,
new LeaseDraftFacts(LocalDate.of(2026, 1, 1), new BigDecimal("900.00"), new BigDecimal("50.00"),
new BigDecimal("900.00"), null, null, null, null)));
}
@Test
@@ -10,6 +10,7 @@ import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
import fr.nessar.lmnporganizer.rental.application.shared.PropertyConditionPhotoNormalizationFailure;
import fr.nessar.lmnporganizer.rental.application.shared.ReferencedResource;
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
import fr.nessar.lmnporganizer.rental.domain.apartment.IncompleteApartmentLegalProfile;
import fr.nessar.lmnporganizer.rental.domain.condition.EntryConditionReportPrerequisite;
import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportConflict;
@@ -17,6 +18,8 @@ import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportRu
import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventoryPrivateAreasMissing;
import fr.nessar.lmnporganizer.rental.domain.landlord.EmailAlreadyUsed;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseFinalizationConflict;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDraftCompleteness;
import fr.nessar.lmnporganizer.rental.domain.lease.IncompleteLeaseDraft;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRuleViolation;
import fr.nessar.lmnporganizer.rental.domain.rent.RentRevisionConflict;
import fr.nessar.lmnporganizer.rental.domain.rent.RentRuleViolation;
@@ -58,9 +61,13 @@ class RentalApiExceptionHandlerContractTest {
assertEntryPrerequisite(handler, EntryConditionReportPrerequisite.FailureReason.LANDLORD_PROFILE_INCOMPLETE,
"entry_report_landlord_profile_incomplete",
"Complétez le profil du bailleur avant de commencer l’état des lieux.");
assertEntryPrerequisite(handler, EntryConditionReportPrerequisite.FailureReason.PRIVATE_AREAS_MISSING,
var privateAreasResponse = handler.entryConditionReportPrerequisite(new EntryConditionReportPrerequisite(
EntryConditionReportPrerequisite.FailureReason.PRIVATE_AREAS_MISSING, new ApartmentId("apartment-1")));
assertEquals(HttpStatus.CONFLICT, privateAreasResponse.getStatusCode());
assertEquals(new RentalApiExceptionHandler.EntryConditionReportPrerequisiteError(
"entry_report_private_areas_missing",
"Ajoutez au moins un espace privatif avant de commencer l’état des lieux.");
"Ajoutez au moins un espace privatif avant de commencer l’état des lieux.", "apartment-1",
"/apartments/apartment-1/edit"), privateAreasResponse.getBody());
assertEntryPrerequisite(handler, EntryConditionReportPrerequisite.FailureReason.INVENTORY_MISSING,
"entry_report_inventory_missing",
"Créez l’inventaire du mobilier du bail avant de commencer l’état des lieux.");
@@ -86,8 +93,37 @@ class RentalApiExceptionHandlerContractTest {
.incompleteApartmentLegalProfile(new IncompleteApartmentLegalProfile());
assertEquals(HttpStatus.CONFLICT, response.getStatusCode());
assertEquals(new RentalApiExceptionHandler.CodedApiError("apartment_legal_profile_incomplete",
"Le profil juridique de l'appartement doit être complet avant de générer ou finaliser le bail."),
assertEquals(new RentalApiExceptionHandler.ApartmentLegalProfilePrerequisiteError(
"apartment_legal_profile_incomplete",
"Le profil juridique de l'appartement doit être complet avant de générer ou finaliser le bail.",
List.of(), "/apartments"), response.getBody());
}
@Test
void includesMissingApartmentLegalProfileFactsAndCorrectionPath() {
var response = new RentalApiExceptionHandler().incompleteApartmentLegalProfile(
new IncompleteApartmentLegalProfile(List.of("taxIdentifier", "habitableSurface"),
new ApartmentId("apartment-1")));
assertEquals(HttpStatus.CONFLICT, response.getStatusCode());
assertEquals(new RentalApiExceptionHandler.ApartmentLegalProfilePrerequisiteError(
"apartment_legal_profile_incomplete",
"Le profil juridique de l'appartement est incomplet. Informations manquantes : taxIdentifier, habitableSurface",
List.of("taxIdentifier", "habitableSurface"), "/apartments/apartment-1/edit"), response.getBody());
}
@Test
void mapsTypedLeaseDraftPrerequisitesToTheExistingWireNames() {
var exception = new IncompleteLeaseDraft(
List.of(LeaseDraftCompleteness.Section.PARTIES, LeaseDraftCompleteness.Section.CONDITIONS),
List.of(LeaseDraftCompleteness.Field.APARTMENT_ID, LeaseDraftCompleteness.Field.START_DATE));
var response = new RentalApiExceptionHandler().incompleteLeaseDraft(exception);
assertEquals(HttpStatus.CONFLICT, response.getStatusCode());
assertEquals(new RentalApiExceptionHandler.LeaseDraftPrerequisiteError("lease_draft_incomplete",
"Le brouillon du bail est incomplet. Informations manquantes : parties.apartmentId, conditions.startDate",
List.of("PARTIES", "CONDITIONS"), List.of("parties.apartmentId", "conditions.startDate")),
response.getBody());
}
@@ -72,6 +72,7 @@ class FurnitureInventoryWebTest {
.andExpect(jsonPath("$.templateVersion").value("FR-MEUBLE-2025-01"))
.andExpect(jsonPath("$.lines[0].category").value("BEDDING"))
.andExpect(jsonPath("$.lines[0].presence").doesNotExist())
.andExpect(jsonPath("$.lines[0].privateAreaId").doesNotExist())
.andExpect(jsonPath("$.privateAreas[0].id").value("area-1"))
.andExpect(jsonPath("$.privateAreas[0].label").value("Séjour"))
.andExpect(jsonPath("$.readyForLeaseDocument").value(false));
@@ -100,6 +101,10 @@ class FurnitureInventoryWebTest {
mockMvc.perform(put("/api/leases/lease-1/inventory").contentType(MediaType.APPLICATION_JSON)
.content("{\"lines\":[]}").with(landlord()).with(csrf())).andExpect(status().isBadRequest());
mockMvc.perform(put("/api/leases/lease-1/inventory").contentType(MediaType.APPLICATION_JSON).content(
"{\"version\":0,\"lines\":[{\"id\":\"line-1\",\"category\":\"BEDDING\",\"designation\":\"Literie\",\"quantity\":0}]}")
.with(landlord()).with(csrf())).andExpect(status().isOk());
}
@Test
@@ -118,16 +123,16 @@ class FurnitureInventoryWebTest {
""")
.with(landlord()).with(csrf())).andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("validation_failed"))
.andExpect(jsonPath("$.errors.length()").value(11))
.andExpect(jsonPath("$.errors[*].field").value(hasItems("version", "lines[0].privateAreaId",
"lines[0].designation", "lines[0].quantity", "lines[1].privateAreaId", "lines[1].quantity",
"lines[2].presence", "lines[0].category", "lines[1].category")))
.andExpect(jsonPath("$.errors.length()").value(10))
.andExpect(jsonPath("$.errors[*].field").value(
hasItems("version", "lines[0].designation", "lines[0].quantity", "lines[1].privateAreaId",
"lines[1].quantity", "lines[2].presence", "lines[0].category", "lines[1].category")))
.andExpect(jsonPath("$.errors[*].field").value(not(hasItem(containsString("equipmentKind")))));
}
private static FurnitureInventory inventory() {
return FurnitureInventory.prefilled(new FurnitureInventoryId("inventory-1"), LANDLORD_ID, LEASE_ID,
"FR-MEUBLE-2025-01", id -> "line-" + id, "area-1");
"FR-MEUBLE-2025-01", id -> "line-" + id);
}
private static RequestPostProcessor landlord() {
@@ -0,0 +1,33 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.lease;
import static org.junit.jupiter.api.Assertions.assertEquals;
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDraftCompleteness;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDraftFacts;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseLegalTerms;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseType;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import java.util.List;
import org.junit.jupiter.api.Test;
class LeaseApiCompletenessResponseTest {
@Test
void mapsTypedCompletenessToTheExistingResponseValues() {
Lease lease = Lease.draft(new LeaseId("lease-1"), new LandlordId("landlord-1"), new ApartmentId("apartment-1"),
List.of(), List.of(), new LeaseDraftFacts(null, null, null, null, null, null, null, LeaseType.STANDARD),
List.of(), LeaseLegalTerms.empty());
LeaseApi.CompletenessResponse response = LeaseApi.CompletenessResponse
.from(LeaseDraftCompleteness.evaluate(lease));
assertEquals(List.of("PARTIES", "CONDITIONS"), response.missingSections());
assertEquals(List.of("parties.tenantIds", "conditions.startDate", "conditions.monthlyRent",
"conditions.monthlyCharges", "conditions.securityDeposit", "conditions.dueDay",
"conditions.chargeMethod", "conditions.indexFlatRate"), response.missingFields());
assertEquals(Boolean.FALSE, response.sections().get("PARTIES"));
assertEquals(Boolean.FALSE, response.sections().get("CONDITIONS"));
}
}
@@ -1,5 +1,6 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.lease;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import fr.nessar.lmnporganizer.rental.application.lease.AmendLeaseService;
@@ -30,7 +31,7 @@ class LeaseApiValidationTest {
assertTrue(failures.contains("apartmentId:NotBlank"));
assertTrue(failures.contains("tenantIdsDistinct:AssertTrue"), failures::toString);
assertTrue(failures.contains("startDate:NotNull"));
assertFalse(failures.contains("startDate:NotNull"), failures::toString);
assertTrue(failures.contains("monthlyRent:Positive"));
assertTrue(failures.contains("monthlyCharges:PositiveOrZero"));
assertTrue(failures.contains("securityDeposit:PositiveOrZero"));
@@ -17,13 +17,10 @@ class LeaseNestedRequestValidatorTest {
private static final String VAULT_PUBLIC_KEY = "A".repeat(512);
@Test
void rejectsARequestWithoutTheCurrentEncryptedLegalTermsObject() {
void acceptsADraftWithoutOptionalLegalTerms() {
LeaseNestedRequestValidator validator = validator();
LeaseController.LeaseRequestValidationFailure failure = assertThrows(
LeaseController.LeaseRequestValidationFailure.class, () -> validator.validate(draft(List.of(), null)));
assertTrue(failure.errors().contains(new LeaseController.LeaseRequestFieldError("legalTerms", "required")));
assertDoesNotThrow(() -> validator.validate(draft(List.of(), null)));
}
@Test
@@ -59,17 +56,12 @@ class LeaseNestedRequestValidatorTest {
}
@Test
void requiresTheEncryptedOtherTermsEnvelopeEvenWhenNoFreeTextWasEntered() {
void acceptsLegalTermsWithoutOptionalOtherTermsEnvelope() {
LeaseNestedRequestValidator validator = validator();
LeaseApi.LegalTermsRequest missingOtherTerms = new LeaseApi.LegalTermsRequest(VAULT_PUBLIC_KEY, null, null,
null, null, false, false, false, false, null);
LeaseController.LeaseRequestValidationFailure failure = assertThrows(
LeaseController.LeaseRequestValidationFailure.class,
() -> validator.validate(draft(List.of(), missingOtherTerms)));
assertTrue(failure.errors()
.contains(new LeaseController.LeaseRequestFieldError("legalTerms.otherTerms", "required")));
assertDoesNotThrow(() -> validator.validate(draft(List.of(), missingOtherTerms)));
}
@Test
@@ -16,6 +16,7 @@ import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseActionAudit;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseAmendmentId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseContractVersion;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDraftFacts;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSnapshot;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSpecialClause;
@@ -130,21 +131,22 @@ class MongoLeaseRepositoriesTest {
}
@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(), null, null,
PERIOD.startDate(), PERIOD.endDate(), TERMS.monthlyRent(), TERMS.monthlyCharges(),
TERMS.securityDeposit(), LeaseStatus.DRAFT.name(), null, null, null, null, null, null, null, null, null,
3L);
void incompleteDraftFactsRoundTripWithoutDefaults() {
LeaseDraftFacts facts = new LeaseDraftFacts(PERIOD.startDate(), TERMS.monthlyRent(), TERMS.monthlyCharges(),
TERMS.securityDeposit(), null, null, null, null);
Lease lease = Lease.draft(LEASE_ID, LANDLORD_ID, APARTMENT_ID, List.of(TENANT_ID), List.of(), facts, List.of(),
LEGAL_TERMS);
Lease restoredLease = MongoLeaseRepository.toDomain(legacy);
RentTerms restored = restoredLease.terms();
MongoLeaseRepository.LeaseDocument document = MongoLeaseRepository.toDocument(lease);
Lease restoredLease = MongoLeaseRepository.toDomain(document);
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();
assertThat(document.dueDay()).isNull();
assertThat(document.chargeMethod()).isNull();
assertThat(document.indexFlatRate()).isNull();
assertThat(document.leaseType()).isNull();
assertThat(restoredLease.draftFacts()).isEqualTo(facts);
assertThat(restoredLease.period()).isNull();
assertThat(restoredLease.terms()).isNull();
}
@Test
@@ -103,6 +103,17 @@ class PropertyConditionReportServiceTest {
EntryConditionReportPrerequisite.FailureReason.INVENTORY_PRIVATE_AREA_MISSING);
}
@Test
void identifiesTheApartmentWhenPrivateAreasAreMissing() {
Fixture fixture = fixture(revision(1, "source-1"));
Mockito.when(fixture.apartment.privateAreas()).thenReturn(List.of());
EntryConditionReportPrerequisite failure = assertThrows(EntryConditionReportPrerequisite.class,
() -> fixture.service.get(LANDLORD, LEASE, PropertyConditionReportType.ENTRY, "Europe/Paris"));
assertEquals("apartment-1", failure.apartmentId().value());
}
@Test
void allowsAnIncompleteDraftButRejectsForeignPartiesAndZones() {
Fixture fixture = fixture(revision(1, "source-1"));
@@ -566,6 +577,7 @@ class PropertyConditionReportServiceTest {
RecordingTransactions transactions = new RecordingTransactions();
ApartmentRepository apartments = Mockito.mock(ApartmentRepository.class);
Apartment apartment = Mockito.mock(Apartment.class);
Mockito.when(apartment.id()).thenReturn(new ApartmentId("apartment-1"));
Mockito.when(apartment.legalProfile()).thenReturn(Mockito.mock(ApartmentLegalProfile.class));
Mockito.when(apartment.privateAreas()).thenReturn(List.of(new PrivateArea("kitchen",
PrivateArea.Family.PIECE_DE_SERVICE, PrivateArea.Subtype.KITCHEN, "Cuisine")));
@@ -19,6 +19,7 @@ import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventoryId;
import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventoryPrivateAreasMissing;
import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventoryRepository;
import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureLine;
import fr.nessar.lmnporganizer.rental.domain.furniture.FurniturePresence;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
@@ -55,7 +56,7 @@ class FurnitureInventoryServiceTest {
ApartmentRepository apartments;
@Test
void initializesOneUndeclaredLegalLinePerCategoryInTheLeaseApartmentFirstArea() {
void initializesOneUndeclaredLegalLinePerCategoryWithoutGuessingAnArea() {
when(leases.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.of(lease()));
when(apartments.find(LANDLORD_ID, APARTMENT_ID)).thenReturn(Optional.of(apartment()));
when(inventories.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.empty());
@@ -68,7 +69,7 @@ class FurnitureInventoryServiceTest {
assertEquals("FR-MEUBLE-2025-01", inventory.templateVersion());
assertEquals(FurnitureCategory.values().length, inventory.lines().size());
assertEquals(FurnitureCategory.values().length, inventory.missingCategories().size());
assertEquals("area-kitchen", inventory.lines().getFirst().privateAreaId());
assertEquals(null, inventory.lines().getFirst().privateAreaId());
ArgumentCaptor<FurnitureInventory> captured = ArgumentCaptor.forClass(FurnitureInventory.class);
verify(inventories).save(captured.capture());
assertEquals("id-1", captured.getValue().id().value());
@@ -105,6 +106,37 @@ class FurnitureInventoryServiceTest {
() -> service(() -> "unused").replace(LANDLORD_ID, LEASE_ID, 1, List.of()));
}
@Test
void allowsUnassignedLinesButRejectsUnknownAssignedAreas() {
FurnitureInventory inventory = FurnitureInventory.reconstitute(new FurnitureInventoryId("inventory-1"),
LANDLORD_ID, LEASE_ID, "FR-MEUBLE-2025-01", List.of(), false, 2);
when(leases.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.of(lease()));
when(apartments.find(LANDLORD_ID, APARTMENT_ID)).thenReturn(Optional.of(apartment()));
when(inventories.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.of(inventory));
when(inventories.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
FurnitureLine unassigned = new FurnitureLine("line-1", null, null, null, "Lampe", 0, "");
assertEquals(null, service(() -> "unused").replace(LANDLORD_ID, LEASE_ID, 2, List.of(unassigned)).lines()
.getFirst().privateAreaId());
assertThrows(IllegalArgumentException.class, () -> service(() -> "unused").replace(LANDLORD_ID, LEASE_ID, 2,
List.of(new FurnitureLine("line-1", null, null, "area-other", "Lampe", 0, ""))));
}
@Test
void refusesToFreezeLinesAssignedToAnUnknownArea() {
FurnitureInventory inventory = FurnitureInventory
.reconstitute(new FurnitureInventoryId("inventory-1"), LANDLORD_ID, LEASE_ID, "FR-MEUBLE-2025-01",
FurnitureInventory.legalCatalog().stream().map(category -> new FurnitureLine(category.name(),
category, FurniturePresence.PRESENT, "area-other", category.label(), 1, "")).toList(),
false, 2);
when(leases.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.of(lease()));
when(apartments.find(LANDLORD_ID, APARTMENT_ID)).thenReturn(Optional.of(apartment()));
when(inventories.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.of(inventory));
assertThrows(IllegalStateException.class, () -> service(() -> "unused").freeze(LANDLORD_ID, LEASE_ID));
verify(inventories, Mockito.never()).save(any());
}
@Test
void alignsAnExistingEditableInventoryWhenTheLeaseTemplateChanges() {
FurnitureInventory inventory = FurnitureInventory.reconstitute(new FurnitureInventoryId("inventory-1"),
@@ -1,6 +1,7 @@
package fr.nessar.lmnporganizer.rental.application.lease;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -44,7 +45,7 @@ import fr.nessar.lmnporganizer.rental.domain.lease.RentalPeriod;
import fr.nessar.lmnporganizer.rental.domain.lease.VisaleGuarantee;
import fr.nessar.lmnporganizer.rental.domain.lease.VisaleSuretyContract;
import fr.nessar.lmnporganizer.rental.domain.lease.VisaleSuretyContractRepository;
import fr.nessar.lmnporganizer.rental.domain.tenant.IncompleteTenantContractProfile;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDraftFacts;
import fr.nessar.lmnporganizer.rental.domain.tenant.Tenant;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantProfile;
@@ -82,19 +83,21 @@ class LeaseDraftServiceTest {
private static final EncryptedGroup ENCRYPTED_GROUP = encryptedGroup("shared");
@Test
void currentDraftDetailsRequireStableIdVaultClausesAndLegalTerms() {
void draftDetailsRequireStableIdentityAndCarryNullableContractFacts() {
LeaseLegalTerms legalTerms = legalTerms();
LeaseDraftFacts facts = LeaseDraftFacts.from(PERIOD, TERMS, LeaseType.STANDARD);
LeaseDraftService.DraftDetails details = new LeaseDraftService.DraftDetails(LEASE_ID.value(), VAULT_PUBLIC_KEY,
FIRST_APARTMENT_ID, List.of(FIRST_TENANT_ID), List.of(), PERIOD, TERMS, LeaseType.STANDARD,
List.of(clauseDetails()), legalTerms, null);
FIRST_APARTMENT_ID, List.of(FIRST_TENANT_ID), List.of(), List.of(clauseDetails()), legalTerms, null,
facts);
assertEquals(LEASE_ID.value(), details.leaseId());
assertEquals(VAULT_PUBLIC_KEY, details.vaultPublicKey());
assertEquals(legalTerms, details.legalTerms());
assertThrows(NullPointerException.class,
() -> new LeaseDraftService.DraftDetails(LEASE_ID.value(), VAULT_PUBLIC_KEY, FIRST_APARTMENT_ID,
List.of(FIRST_TENANT_ID), List.of(), PERIOD, TERMS, LeaseType.STANDARD, List.of(), null, null));
assertEquals(facts, details.draftFacts());
assertDoesNotThrow(() -> new LeaseDraftService.DraftDetails(LEASE_ID.value(), VAULT_PUBLIC_KEY,
FIRST_APARTMENT_ID, List.of(FIRST_TENANT_ID), null, null, null, null,
new LeaseDraftFacts(null, null, null, null, null, null, null, null)));
}
@Test
@@ -108,12 +111,12 @@ class LeaseDraftServiceTest {
}
@Test
void numericLegalTermsStillRequireAnEncryptedOtherTermsEnvelope() {
void numericLegalTermsMayOmitOptionalEncryptedOtherTerms() {
LeaseLegalTerms terms = legalTerms();
assertEquals(VAULT_PUBLIC_KEY, terms.vaultPublicKey());
assertEquals(ENCRYPTED_GROUP, terms.otherTerms());
assertThrows(LeaseRuleViolation.class,
assertDoesNotThrow(
() -> new LeaseLegalTerms(VAULT_PUBLIC_KEY, null, null, null, null, false, false, false, false, null));
}
@@ -147,35 +150,37 @@ class LeaseDraftServiceTest {
}
@Test
void refusesToPrepareALeaseWithAnIncompleteTenantProfile() {
void allowsSavingAnIncompleteTenantProfileInADraft() {
ApartmentRepository apartments = mock(ApartmentRepository.class);
TenantRepository tenants = mock(TenantRepository.class);
LeaseRepository leases = mock(LeaseRepository.class);
Tenant incomplete = tenant(FIRST_TENANT_ID, VAULT_PUBLIC_KEY, false);
when(apartments.find(LANDLORD_ID, FIRST_APARTMENT_ID)).thenReturn(Optional.of(apartment(FIRST_APARTMENT_ID)));
when(tenants.find(LANDLORD_ID, FIRST_TENANT_ID)).thenReturn(Optional.of(incomplete));
when(leases.save(any(Lease.class))).thenAnswer(invocation -> invocation.getArgument(0));
LeaseDraftService service = service(apartments, tenants, leases, new RecordingTransactions());
assertThrows(IncompleteTenantContractProfile.class,
() -> service.create(LANDLORD_ID, details(FIRST_APARTMENT_ID, List.of(FIRST_TENANT_ID))));
Lease created = service.create(LANDLORD_ID, details(FIRST_APARTMENT_ID, List.of(FIRST_TENANT_ID)));
verify(leases, never()).save(any());
assertEquals(List.of(FIRST_TENANT_ID), created.tenantIds());
verify(leases).save(created);
}
@Test
void refusesToPrepareALeaseWithATenantFromAnotherVaultGeneration() {
void allowsSavingATenantFromAnotherVaultGenerationInADraft() {
ApartmentRepository apartments = mock(ApartmentRepository.class);
TenantRepository tenants = mock(TenantRepository.class);
LeaseRepository leases = mock(LeaseRepository.class);
when(apartments.find(LANDLORD_ID, FIRST_APARTMENT_ID)).thenReturn(Optional.of(apartment(FIRST_APARTMENT_ID)));
when(tenants.find(LANDLORD_ID, FIRST_TENANT_ID))
.thenReturn(Optional.of(tenant(FIRST_TENANT_ID, WRONG_VAULT_PUBLIC_KEY, true)));
when(leases.save(any(Lease.class))).thenAnswer(invocation -> invocation.getArgument(0));
LeaseDraftService service = service(apartments, tenants, leases, new RecordingTransactions());
assertThrows(ApplicationVaultRequired.class,
() -> service.create(LANDLORD_ID, details(FIRST_APARTMENT_ID, List.of(FIRST_TENANT_ID))));
Lease created = service.create(LANDLORD_ID, details(FIRST_APARTMENT_ID, List.of(FIRST_TENANT_ID)));
verify(leases, never()).save(any());
assertEquals(List.of(FIRST_TENANT_ID), created.tenantIds());
verify(leases).save(created);
}
@Test
@@ -443,7 +448,8 @@ class LeaseDraftServiceTest {
private static LeaseDraftService.DraftDetails details(ApartmentId apartmentId, List<TenantId> tenantIds,
List<LeaseGuarantee> guarantees, VisaleGuarantee visaleGuarantee) {
return new LeaseDraftService.DraftDetails(LEASE_ID.value(), VAULT_PUBLIC_KEY, apartmentId, tenantIds,
guarantees, PERIOD, TERMS, LeaseType.STANDARD, List.of(), legalTerms(), visaleGuarantee);
guarantees, List.of(), legalTerms(), visaleGuarantee,
LeaseDraftFacts.from(PERIOD, TERMS, LeaseType.STANDARD));
}
private static LeaseDraftService service(ApartmentRepository apartments, TenantRepository tenants,
@@ -57,6 +57,7 @@ import fr.nessar.lmnporganizer.rental.domain.lease.ApartmentOccupancy;
import fr.nessar.lmnporganizer.rental.domain.lease.ApartmentOccupancyRepository;
import fr.nessar.lmnporganizer.rental.domain.lease.FurnishedLeaseTemplate;
import fr.nessar.lmnporganizer.rental.domain.lease.GuarantorProfile;
import fr.nessar.lmnporganizer.rental.domain.lease.IncompleteLeaseDraft;
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentRevision;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentRevisionRepository;
@@ -148,6 +149,17 @@ class LeaseFinalizationServiceTest {
@Mock
ApplicationVaultAccess vaultAccess;
@Test
void stateRejectsAnIncompleteDraftBeforeReadingContractRequirements() {
Lease lease = Lease.draft(LEASE_ID, LANDLORD_ID, APARTMENT_ID, List.of(TENANT_1),
List.of(guarantee("g1", "Alice")), (RentalPeriod) null, null, null);
when(leases.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.of(lease));
assertThrows(IncompleteLeaseDraft.class,
() -> service(clock("2026-07-19T12:00:00Z")).state(LANDLORD_ID, LEASE_ID));
verifyNoInteractions(landlords, apartments, tenants, guaranteeDeeds);
}
@Test
void stateBuildsOnlyDeclaredLeaseGuaranteeTenantPairsWithHumanNames() {
Lease lease = draft(List.of(TENANT_1, TENANT_2),
@@ -97,7 +97,7 @@ class RentTermsTimelineTest {
private static Lease activeLease() {
ApartmentId apartmentId = new ApartmentId("apartment-1");
TenantId tenantId = new TenantId("tenant-1");
RentalPeriod period = new RentalPeriod(LocalDate.of(2026, 1, 1), LocalDate.of(2028, 12, 31));
RentalPeriod period = new RentalPeriod(LocalDate.of(2026, 1, 1), LocalDate.of(2026, 12, 31));
RentTerms terms = new RentTerms(new BigDecimal("1000"), new BigDecimal("100"), new BigDecimal("1000"));
Lease lease = Lease.draft(LEASE_ID, LANDLORD_ID, apartmentId, List.of(tenantId), List.of(), period, terms);
lease.activate(new LeaseSnapshot(
@@ -79,6 +79,17 @@ class ApartmentTest {
assertThrows(IncompleteApartmentLegalProfile.class, ApartmentLegalProfile.empty()::requireLeaseReady);
}
@Test
void reportsEveryMissingLeaseReadyProfileFactWithCorrectionContext() {
IncompleteApartmentLegalProfile failure = assertThrows(IncompleteApartmentLegalProfile.class,
() -> ApartmentLegalProfile.empty().requireLeaseReady(new ApartmentId("apartment-1")));
assertEquals(List.of("taxIdentifier", "buildingType", "ownershipRegime", "constructionPeriod",
"habitableSurface", "heating", "hotWater", "energyRating", "estimatedAnnualEnergyCost",
"estimatedAnnualEnergyCostReferenceYear", "destination"), failure.missingFields());
assertEquals("apartment-1", failure.apartmentId().value());
}
@Test
void legalProfileAcceptsFrenchDepartmentCodesAndDropsAnOrphanedEnergyReferenceYear() {
assertEquals(true, ApartmentLegalProfile.isValidDepartmentCode("01"));
@@ -8,22 +8,22 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.Test;
class FurnitureInventoryTest {
@Test
void requiresEveryLegalCategoryToBeExplicitlyDeclaredAndFreezesAfterLeaseFinalization() {
FurnitureInventory inventory = FurnitureInventory.prefilled(new FurnitureInventoryId("inventory-1"),
new LandlordId("landlord-1"), new LeaseId("lease-1"), "FR-MEUBLE-2025-01", id -> "line-" + id,
"area-1");
new LandlordId("landlord-1"), new LeaseId("lease-1"), "FR-MEUBLE-2025-01", id -> "line-" + id);
assertFalse(inventory.isReadyForLeaseDocument());
assertFalse(inventory.isReadyForLeaseDocument(Set.of("area-1")));
inventory.replaceLines(FurnitureInventory.legalCatalog("FR-MEUBLE-2025-01").stream()
.map(category -> new FurnitureLine(category.name().toLowerCase(), category, FurniturePresence.PRESENT,
"area-1", category.label(), 1, ""))
.toList());
assertTrue(inventory.isReadyForLeaseDocument());
inventory.freeze();
assertTrue(inventory.isReadyForLeaseDocument(Set.of("area-1")));
inventory.freeze(Set.of("area-1"));
assertThrows(IllegalStateException.class, () -> inventory.replaceLines(List.of()));
}
@@ -31,15 +31,14 @@ class FurnitureInventoryTest {
@Test
void exposesAnAuditableWarningForEachExplicitLegalAbsence() {
FurnitureInventory inventory = FurnitureInventory.prefilled(new FurnitureInventoryId("inventory-1"),
new LandlordId("landlord-1"), new LeaseId("lease-1"), "FR-MEUBLE-2025-01", id -> "line-" + id,
"area-1");
new LandlordId("landlord-1"), new LeaseId("lease-1"), "FR-MEUBLE-2025-01", id -> "line-" + id);
inventory.replaceLines(FurnitureInventory.legalCatalog("FR-MEUBLE-2025-01").stream()
.map(category -> new FurnitureLine(category.name().toLowerCase(), category,
category == FurnitureCategory.BEDDING ? FurniturePresence.ABSENT : FurniturePresence.PRESENT,
"area-1", category.label(), category == FurnitureCategory.BEDDING ? 0 : 1, ""))
.toList());
assertTrue(inventory.isReadyForLeaseDocument());
assertTrue(inventory.isReadyForLeaseDocument(Set.of("area-1")));
assertEquals(List.of(FurnitureCategory.BEDDING), inventory.absentCategories());
assertEquals(List.of(new FurnitureInventoryWarning("FURNITURE_CATEGORY_ABSENT_BEDDING", "FR-MEUBLE-2025-01",
FurnitureCategory.BEDDING)), inventory.legalWarnings());
@@ -64,8 +63,7 @@ class FurnitureInventoryTest {
@Test
void alignsAnEditableInventoryWithTheCurrentLeaseTemplate() {
FurnitureInventory inventory = FurnitureInventory.prefilled(new FurnitureInventoryId("inventory-1"),
new LandlordId("landlord-1"), new LeaseId("lease-1"), "FR-MEUBLE-2025-01", id -> "line-" + id,
"area-1");
new LandlordId("landlord-1"), new LeaseId("lease-1"), "FR-MEUBLE-2025-01", id -> "line-" + id);
inventory.alignWithTemplate("FR-MEUBLE-2026-10");
@@ -74,4 +72,26 @@ class FurnitureInventoryTest {
inventory.leaseId(), inventory.templateVersion(), inventory.lines(), true, 0);
assertThrows(IllegalStateException.class, () -> frozen.alignWithTemplate("FR-MEUBLE-2025-01"));
}
@Test
void prefillsLegalCatalogWithoutGuessingAPrivateArea() {
FurnitureInventory inventory = FurnitureInventory.prefilled(new FurnitureInventoryId("inventory-1"),
new LandlordId("landlord-1"), new LeaseId("lease-1"), "FR-MEUBLE-2025-01", id -> "line-" + id);
assertTrue(inventory.lines().stream().allMatch(line -> line.privateAreaId() == null));
assertFalse(inventory.isReadyForLeaseDocument(Set.of("area-1")));
}
@Test
void readinessAndFreezeRequireExistingPrivateAreas() {
FurnitureInventory inventory = FurnitureInventory
.reconstitute(new FurnitureInventoryId("inventory-1"), new LandlordId("landlord-1"),
new LeaseId("lease-1"), "FR-MEUBLE-2025-01",
FurnitureInventory.legalCatalog().stream().map(category -> new FurnitureLine(category.name(),
category, FurniturePresence.PRESENT, "area-other", category.label(), 1, "")).toList(),
false, 0);
assertFalse(inventory.isReadyForLeaseDocument(Set.of("area-1")));
assertThrows(IllegalStateException.class, () -> inventory.freeze(Set.of("area-1")));
}
}
@@ -0,0 +1,68 @@
package fr.nessar.lmnporganizer.rental.domain.lease;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import org.junit.jupiter.api.Test;
class LeaseDraftCompletenessTest {
private static final LandlordId LANDLORD_ID = new LandlordId("landlord-1");
private static final ApartmentId APARTMENT_ID = new ApartmentId("apartment-1");
private static final TenantId TENANT_ID = new TenantId("tenant-1");
private static final RentalPeriod PERIOD = new RentalPeriod(LocalDate.of(2026, 1, 1), LocalDate.of(2026, 12, 31));
private static final RentTerms TERMS = new RentTerms(new BigDecimal("900.00"), new BigDecimal("50.00"),
new BigDecimal("900.00"));
@Test
void reportsEveryFormSectionAndOnlyTheMissingContractFacts() {
Lease lease = Lease.draft(new LeaseId("lease-1"), LANDLORD_ID, APARTMENT_ID, List.of(TENANT_ID), List.of(),
new LeaseDraftFacts(null, new BigDecimal("900.00"), null, null, null, null, null, null), List.of(),
LeaseLegalTerms.empty());
LeaseDraftCompleteness.Result result = LeaseDraftCompleteness.evaluate(lease);
assertEquals(List.of(LeaseDraftCompleteness.Section.CONDITIONS), result.missingSections());
assertEquals(List.of(LeaseDraftCompleteness.Field.START_DATE, LeaseDraftCompleteness.Field.MONTHLY_CHARGES,
LeaseDraftCompleteness.Field.SECURITY_DEPOSIT, LeaseDraftCompleteness.Field.DUE_DAY,
LeaseDraftCompleteness.Field.CHARGE_METHOD, LeaseDraftCompleteness.Field.INDEX_FLAT_RATE,
LeaseDraftCompleteness.Field.LEASE_TYPE), result.missingFields());
assertEquals(5, result.sections().size());
assertTrue(result.sections().get(LeaseDraftCompleteness.Section.PARTIES));
assertFalse(result.sections().get(LeaseDraftCompleteness.Section.CONDITIONS));
assertTrue(result.sections().get(LeaseDraftCompleteness.Section.LEGAL_TERMS));
assertTrue(result.sections().get(LeaseDraftCompleteness.Section.CLAUSES));
assertTrue(result.sections().get(LeaseDraftCompleteness.Section.GUARANTEES));
}
@Test
void completeDraftsAreAcceptedByTheSharedGuard() {
Lease lease = Lease.draft(new LeaseId("lease-1"), LANDLORD_ID, APARTMENT_ID, List.of(TENANT_ID), List.of(),
PERIOD, TERMS);
LeaseDraftCompleteness.Result result = LeaseDraftCompleteness.evaluate(lease);
assertTrue(result.complete());
LeaseDraftCompleteness.requireComplete(lease);
}
@Test
void activationRejectsMissingConditionsBeforeSnapshotMatching() {
Lease lease = Lease.draft(new LeaseId("lease-1"), LANDLORD_ID, APARTMENT_ID, List.of(TENANT_ID), List.of(),
null, null, LeaseType.STANDARD, List.of(), LeaseLegalTerms.empty());
IncompleteLeaseDraft exception = assertThrows(IncompleteLeaseDraft.class, () -> lease.activate(null));
assertEquals(List.of(LeaseDraftCompleteness.Section.CONDITIONS), exception.missingSections());
assertEquals(List.of(LeaseDraftCompleteness.Field.START_DATE, LeaseDraftCompleteness.Field.MONTHLY_RENT,
LeaseDraftCompleteness.Field.MONTHLY_CHARGES, LeaseDraftCompleteness.Field.SECURITY_DEPOSIT,
LeaseDraftCompleteness.Field.DUE_DAY, LeaseDraftCompleteness.Field.CHARGE_METHOD,
LeaseDraftCompleteness.Field.INDEX_FLAT_RATE), exception.missingFields());
}
}
@@ -127,7 +127,7 @@ class LeaseTest {
}
@Test
void typedDraftsDeriveTheirLegalHorizonWhileLegacyFactoriesDefaultToStandard() {
void typedAndLegacyDraftsDeriveTheirLegalHorizon() {
RentalPeriod inconsistent = new RentalPeriod(LocalDate.of(2026, 2, 15), LocalDate.of(2030, 1, 1));
Lease student = Lease.draft(new LeaseId("student"), LANDLORD_ID, APARTMENT_ID, List.of(TENANT_ID), List.of(),
@@ -138,7 +138,7 @@ class LeaseTest {
assertEquals(LeaseType.STUDENT, student.leaseType());
assertEquals(new RentalPeriod(LocalDate.of(2026, 2, 15), LocalDate.of(2026, 11, 14)), student.period());
assertEquals(LeaseType.STANDARD, legacy.leaseType());
assertEquals(inconsistent, legacy.period());
assertEquals(new RentalPeriod(LocalDate.of(2026, 2, 15), LocalDate.of(2027, 2, 14)), legacy.period());
assertEquals(ApartmentLegalProfile.empty(),
new LeaseSnapshot.Apartment(APARTMENT_ID, "Studio", "2 rue B", ApartmentLegalProfile.empty(), List.of())
.legalProfile());
@@ -181,7 +181,7 @@ class LeaseTest {
void activationRejectsIncompleteOrMismatchedFacts() {
Lease empty = Lease.draft(new LeaseId("lease-empty"), LANDLORD_ID, APARTMENT_ID, List.of(), List.of(), PERIOD,
TERMS);
assertThrows(LeaseRuleViolation.class, () -> empty.activate(snapshot()));
assertThrows(IncompleteLeaseDraft.class, () -> empty.activate(snapshot()));
Lease lease = Lease.draft(new LeaseId("lease-1"), LANDLORD_ID, APARTMENT_ID, List.of(TENANT_ID), List.of(),
PERIOD, TERMS, LeaseType.STANDARD, List.of(), LEGAL_TERMS);
@@ -330,7 +330,9 @@ class ReplacementRentalApiJourneyIT {
.andExpect(jsonPath("$.privateAreas").isEmpty());
mockMvc.perform(get("/api/leases/{leaseId}/condition-reports/ENTRY", leaseId).with(owner()))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("entry_report_private_areas_missing"));
.andExpect(jsonPath("$.code").value("entry_report_private_areas_missing"))
.andExpect(jsonPath("$.apartmentId").value(apartmentId))
.andExpect(jsonPath("$.correctionPath").value("/apartments/" + apartmentId + "/edit"));
}
@Test
@@ -1117,8 +1119,10 @@ class ReplacementRentalApiJourneyIT {
String legalTerms, String specialClauses) {
return """
{"id":"%s","vaultPublicKey":"%s","apartmentId":"%s","tenantIds":%s,
"guarantees":%s,"startDate":"%s","monthlyRent":%s,"monthlyCharges":%s,
"securityDeposit":%s,"specialClauses":%s,"legalTerms":%s}
"guarantees":%s,"startDate":"%s","leaseType":"STANDARD",
"monthlyRent":%s,"monthlyCharges":%s,"securityDeposit":%s,
"dueDay":5,"chargeMethod":"FLAT_RATE","indexFlatRate":false,
"specialClauses":%s,"legalTerms":%s}
""".formatted(leaseId, JOURNEY_VAULT_PUBLIC_KEY, apartmentId, jsonArray(tenantIds), guarantees,
startDate, monthlyRent, monthlyCharges, securityDeposit, specialClauses, legalTerms);
}
@@ -1378,6 +1382,10 @@ class ReplacementRentalApiJourneyIT {
private String completeInventory(String leaseId, List<String> privateAreaIds) throws Exception {
String response = mockMvc.perform(get("/api/leases/{leaseId}/inventory", leaseId).with(owner()))
.andExpect(status().isOk()).andReturn().getResponse().getContentAsString();
if (privateAreaIds.isEmpty()) {
List<Map<String, Object>> areas = read(response, "$.privateAreas");
privateAreaIds = areas.stream().map(area -> (String) area.get("id")).toList();
}
List<Map<String, Object>> lines = read(response, "$.lines");
for (int index = 0; index < lines.size(); index++) {
Map<String, Object> line = lines.get(index);
+14 -9
View File
@@ -1,7 +1,7 @@
# Validation Parity Checklist
This document accounts for the application's user-editable Angular forms and mutable rental HTTP
commands for Epic #42 and Issue #59. It records the implemented contracts; it is not a claim that
commands for Epic #42 and Issues #59 and #160. It records the implemented contracts; it is not a claim that
unrecorded CI or local test gates passed.
The backend remains authoritative. Angular mirrors only important save-time constraints that can
@@ -184,10 +184,13 @@ attachment-transfer endpoint or `targetTenantId` request.
`LeaseNestedRequestValidator`, `GenerateLeaseDocumentService`
- **Authoritative draft and private-state constraints**:
- `apartmentId` is non-blank and `tenantIds` is a non-empty list of distinct, non-blank IDs.
- `guarantees` is a required list. `startDate`, `monthlyRent`, `monthlyCharges`, and
`securityDeposit` are non-null; the server derives the lease horizon and accepts no draft
`endDate`.
- Rent is strictly positive; charges and deposit are positive or zero; `dueDay` is 1 through 28.
- A draft may omit `guarantees` and individual contract facts: `startDate`, `leaseType`,
`monthlyRent`, `monthlyCharges`, `securityDeposit`, `dueDay`, `chargeMethod`, and
`indexFlatRate`. The server derives the lease horizon when its facts are complete and accepts
no draft `endDate`. Revision preparation and finalization reject incomplete drafts with HTTP
409 `lease_draft_incomplete` and structured missing sections and fields.
- Present rent must be strictly positive; present charges and deposit must be positive or zero;
present `dueDay` must be 1 through 28.
- Each `guarantees[]` entry names one `tenantId` already present in `tenantIds`; its
`profile` contains the guarantor's encrypted identity/address and contacts, the vault key,
completeness attestation, and identity generation. Tenant and guarantor names, addresses,
@@ -227,8 +230,9 @@ attachment-transfer endpoint or `targetTenantId` request.
returns a JSON descriptor with base64url bytes; protected annexes carry envelope metadata, while
a public reference annex may remain ordinary. The browser checks lease/revision/fingerprint
scope and decrypts protected bytes locally. These are not binary plaintext-PDF endpoints.
- **Angular mirror**: required operational fields and all private values are validated in the
browser before encryption. Current profile edits and frozen contract snapshots are kept distinct;
- **Angular mirror**: locally invalid present values and private values are validated in the
browser before encryption. Incomplete contract facts may be saved as a draft. Current profile
edits and frozen contract snapshots are kept distinct;
changes to a current tenant or guarantor do not rewrite an established contract version. Draft
save, preparation, commit, and downloads expose separate pending/error state.
@@ -239,8 +243,9 @@ attachment-transfer endpoint or `targetTenantId` request.
- **Backend**: `FurnitureInventoryController`, `FurnitureInventoryRequestValidator`
- **Authoritative constraints**:
- `version` and `lines` are required; version is non-negative.
- Every line requires a unique non-blank ID, valid apartment `privateAreaId`, non-blank
designation, and non-negative integer quantity.
- Every line requires a unique non-blank ID, non-blank designation, and non-negative integer
quantity. A draft line may omit `privateAreaId`; a present ID must identify a current
apartment private area. Every line must be assigned before the inventory is ready to freeze.
- A regulatory category is unique; present, absent, and undeclared quantities must agree with
presence. Its inventory line carries the only furniture observation in the condition report,
without a derived equipment zone.
+4 -3
View File
@@ -79,7 +79,8 @@ test('lets an authenticated landlord delete an apartment after confirmation', as
.filter({ has: page.getByRole('heading', { name: 'T2 République' }) });
await expect(card).toBeVisible();
await card.getByRole('button', { name: 'Supprimer' }).click();
await card.getByRole('button', { name: 'Actions pour T2 République' }).click();
await page.getByRole('menuitem', { name: 'Supprimer', exact: true }).click();
const confirmation = page.getByRole('alertdialog');
await expect(confirmation).toBeVisible();
await expect(
@@ -94,8 +95,8 @@ test('lets an authenticated landlord delete an apartment after confirmation', as
await expect(confirmation).toBeHidden();
await expect.poll(() => deleteRequests).toBe(1);
await expect(card).toHaveAttribute('aria-busy', 'true');
const pendingDelete = card.getByRole('button', { name: /Suppression/ });
await expect(pendingDelete).toBeDisabled();
const pendingDelete = card.getByRole('status').filter({ hasText: 'Suppression…' });
await expect(pendingDelete).toBeVisible();
await expect(pendingDelete).toContainText('Suppression…');
releaseDelete();
+1 -1
View File
@@ -82,7 +82,7 @@ test('manages an apartment legal document from its edit screen', async ({ page }
await page.goto('/apartments');
await expect(page.getByRole('heading', { name: 'Mes appartements' })).toBeVisible();
await page.getByRole('button', { name: 'Modifier' }).click();
await page.getByRole('link', { name: 'Ouvrir la fiche de T2 République' }).click();
await expect(page.getByRole('heading', { name: "Modifier l'appartement" })).toBeVisible();
await expect(page.getByRole('heading', { level: 2, name: /Documents/ })).toBeVisible();
await expect(page.getByText('Aucun document enregistré.')).toBeVisible();
+15 -11
View File
@@ -29,7 +29,7 @@ test('persists an apartment legal profile through creation, revision, and reopen
let apartments: Apartment[] = [];
const postBodies: Apartment[] = [];
const putBodies: Apartment[] = [];
let rejectNextPut = true;
let rejectNextPut = false;
await mockAuthenticatedUser(page);
await page.route('**/api/apartments**', async (route) => {
@@ -96,12 +96,15 @@ test('persists an apartment legal profile through creation, revision, and reopen
await page.goto('/');
await navigateToMenu(page, 'Appartements');
await page.getByRole('button', { name: 'Ajouter un appartement' }).first().click();
const legalProfileSection = page.getByRole('region', { name: 'Identification juridique' });
const legalProfileSection = page.locator('app-apartment-legal-profile-form');
await page.getByLabel("Nom de l'appartement").fill('T2 République');
await page.getByLabel('Adresse complète').fill('12 rue de la République, Lyon');
await page.getByLabel("Prix d'acquisition").fill('185000');
await page.getByLabel('Date d’acquisition').fill('2024-06-15');
await page.getByRole('button', { name: 'Enregistrer', exact: true }).click();
await expect(page.getByRole('heading', { name: "Modifier l'appartement" })).toBeVisible();
expect(postBodies).toHaveLength(1);
await page
.getByLabel('Identifiant fiscal du logement')
.fill(completeLegalProfile.taxIdentifier);
@@ -147,7 +150,7 @@ test('persists an apartment legal profile through creation, revision, and reopen
await expect(
page.getByText("Renseignez l'année de référence du coût énergétique."),
).toBeVisible();
expect(postBodies).toHaveLength(0);
expect(putBodies).toHaveLength(0);
await page
.getByLabel('Année de référence du coût énergétique')
@@ -156,15 +159,16 @@ test('persists an apartment legal profile through creation, revision, and reopen
await expect(legalProfileSection.getByRole('alert')).toContainText(
'Saisissez un département français',
);
expect(postBodies).toHaveLength(0);
expect(putBodies).toHaveLength(0);
await page.getByLabel('Département').fill(completeLegalProfile.departmentCode);
await page.getByRole('button', { name: 'Enregistrer', exact: true }).click();
await expect(page.getByRole('heading', { name: 'T2 République' })).toBeVisible();
expect(postBodies).toHaveLength(1);
expect(postBodies[0]).toEqual(expect.objectContaining({ legalProfile: completeLegalProfile }));
expect(putBodies).toHaveLength(1);
expect(putBodies[0]).toEqual(expect.objectContaining({ legalProfile: completeLegalProfile }));
rejectNextPut = true;
await page.getByRole('button', { name: 'Modifier' }).click();
await page.getByRole('link', { name: 'Ouvrir la fiche de T2 République' }).click();
await expect(page.getByRole('heading', { name: "Modifier l'appartement" })).toBeVisible();
await page.getByLabel('Département').fill('75');
await page.getByRole('button', { name: 'Enregistrer', exact: true }).click();
@@ -172,17 +176,17 @@ test('persists an apartment legal profile through creation, revision, and reopen
'Saisissez un département français',
);
await expect(page.getByLabel('Département')).toHaveValue('75');
expect(putBodies).toHaveLength(1);
expect(putBodies).toHaveLength(2);
await page.getByLabel('Département').fill(completeLegalProfile.departmentCode);
await page.getByRole('button', { name: 'Enregistrer', exact: true }).click();
await expect(page.getByRole('heading', { name: 'T2 République' })).toBeVisible();
expect(putBodies).toHaveLength(2);
expect(putBodies[1]).toEqual(expect.objectContaining({ legalProfile: completeLegalProfile }));
expect(putBodies).toHaveLength(3);
expect(putBodies[2]).toEqual(expect.objectContaining({ legalProfile: completeLegalProfile }));
await page.reload();
await navigateToMenu(page, 'Appartements');
await page.getByRole('button', { name: 'Modifier' }).click();
await page.getByRole('link', { name: 'Ouvrir la fiche de T2 République' }).click();
await expect(page.getByLabel('Identifiant fiscal du logement')).toHaveValue(
completeLegalProfile.taxIdentifier,
);
+306 -46
View File
@@ -5,6 +5,7 @@ import type { LeaseInput } from '../src/app/services/lease.service';
import { emptyApartmentLegalProfile } from './support/rental-fixtures';
import {
createTenantVaultFixture,
encryptTenantProfile,
encryptPayload,
encryptTenantAttachment,
installTenantVaultBoundary,
@@ -88,6 +89,18 @@ async function mockUser(page: Page, authenticated: boolean, completeProfile = tr
await route.fulfill({ contentType: 'application/json', json: profile });
});
await mockEmptyDashboardSnapshot(page);
for (const path of [
'/api/tenants',
'/api/leases',
'/api/rent-dues/refresh',
'/api/expense-documents',
'/api/mortgages',
'/api/amortizations',
]) {
await page.route(`**${path}`, (route) =>
route.fulfill({ contentType: 'application/json', json: [] }),
);
}
}
async function mockEmptyApartmentList(page: Page): Promise<void> {
@@ -177,6 +190,39 @@ async function expectNoHorizontalOverflow(page: Page): Promise<void> {
expect(hasHorizontalOverflow).toBe(false);
}
async function expectSubtleTextContrast(page: Page): Promise<void> {
const ratios = await page.locator('html').evaluate((element) => {
const styles = getComputedStyle(element);
const canvas = element.ownerDocument.createElement('canvas');
canvas.width = canvas.height = 1;
const context = canvas.getContext('2d');
if (!context) throw new Error('Canvas is required to measure contrast.');
const luminance = (token: string): number => {
context.fillStyle = styles.getPropertyValue(token).trim();
context.fillRect(0, 0, 1, 1);
const channels = context.getImageData(0, 0, 1, 1).data;
return [0, 1, 2]
.map((index) => channels[index] / 255)
.map((channel) =>
channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4,
)
.reduce(
(sum, channel, index) => sum + channel * [0.2126, 0.7152, 0.0722][index],
0,
);
};
const foreground = luminance('--text-subtle');
return ['--surface', '--surface-raised', '--surface-accent'].map((token) => {
const background = luminance(token);
return (
(Math.max(foreground, background) + 0.05) /
(Math.min(foreground, background) + 0.05)
);
});
});
for (const ratio of ratios) expect(ratio).toBeGreaterThanOrEqual(4.5);
}
async function expectNoScrollbars(locator: Locator): Promise<void> {
const scrollableElements = await locator.evaluate((element) => {
return [element, ...element.querySelectorAll<HTMLElement>('*')]
@@ -433,6 +479,7 @@ test('keeps the Settings profile save action stable while saving', async ({ page
await page.goto('/');
await navigateToMenu(page, 'Réglages');
await expect(page.getByRole('heading', { name: 'Réglages' })).toBeVisible();
await page.getByLabel('Téléphone').fill('+33612345678');
const saveButton = page.locator('app-landlord-profile-form button.primary-action');
await expect(saveButton).toHaveAccessibleName('Enregistrer le profil');
await expectPendingProfileSave(saveButton, pendingSave.saveCount);
@@ -470,10 +517,12 @@ test('persists a dark appearance preference from Settings', async ({ page }) =>
await mockEmptyApartmentList(page);
await page.goto('/');
await expectSubtleTextContrast(page);
await navigateToMenu(page, 'Réglages');
await page.getByRole('radio', { name: 'Sombre' }).check();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
await expectSubtleTextContrast(page);
const materialPalette = await page.locator('html').evaluate((element) => {
const styles = getComputedStyle(element);
const read = (name: string) => styles.getPropertyValue(name).trim();
@@ -513,11 +562,19 @@ test('persists a dark appearance preference from Settings', async ({ page }) =>
test('adapts authenticated navigation to the viewport', async ({ page }, testInfo) => {
await mockUser(page, true);
await mockEmptyApartmentList(page);
const isDesktop = testInfo.project.name === 'desktop';
const isMobile = testInfo.project.name === 'mobile';
if (isDesktop || isMobile) {
const viewport = page.viewportSize();
if (!viewport) throw new Error('The responsive navigation test requires a viewport.');
await page.setViewportSize({ width: isDesktop ? 2174 : 390, height: viewport.height });
}
await page.goto('/');
const navigation = page.getByTestId('primary-navigation');
await expect(navigation).toBeVisible();
await expect(page.getByRole('heading', { name: 'Tableau de bord' })).toBeVisible();
const navigationBox = await navigation.boundingBox();
const viewport = page.viewportSize();
@@ -526,6 +583,19 @@ test('adapts authenticated navigation to the viewport', async ({ page }, testInf
if (testInfo.project.name === 'desktop') {
expect(navigationBox?.x).toBe(0);
expect(navigationBox?.height).toBe(viewport?.height);
for (const group of ['Pilotage', 'Location', 'Finances et fiscalité', 'Système']) {
await expect(
navigation.getByRole('heading', { name: group, exact: true }),
).toBeVisible();
}
const dashboardLink = navigation.getByRole('link', {
name: 'Tableau de bord',
exact: true,
});
await dashboardLink.focus();
await expect(dashboardLink).toBeFocused();
await page.keyboard.press('Enter');
await expect(page).toHaveURL(/\/dashboard$/);
} else {
expect(Math.round((navigationBox?.y ?? 0) + (navigationBox?.height ?? 0))).toBe(
viewport?.height,
@@ -543,15 +613,75 @@ test('adapts authenticated navigation to the viewport', async ({ page }, testInf
buttonBox!.x + buttonBox!.width,
);
}
await expect(navigation.getByRole('link', { name: 'Accueil', exact: true })).toBeVisible();
await expect(navigation.getByRole('link', { name: 'Loyers', exact: true })).toBeVisible();
await expect(navigation.getByRole('link', { name: 'Dépenses', exact: true })).toBeVisible();
const moreButton = navigation.getByRole('button', {
name: 'Afficher les autres sections',
});
await moreButton.focus();
await expect(moreButton).toBeFocused();
await page.keyboard.press('Enter');
const moreMenu = page.getByRole('menu');
await expect(moreMenu).toBeVisible();
for (const group of ['Pilotage', 'Location', 'Finances et fiscalité', 'Système']) {
await expect(moreMenu.getByRole('group', { name: group, exact: true })).toBeVisible();
}
await page.keyboard.press('Escape');
await expect(moreMenu).toBeHidden();
}
if (testInfo.project.name === 'desktop') {
await navigateToMenu(page, 'Appartements');
const apartmentsLink = navigation.getByRole('link', {
name: 'Appartements',
exact: true,
});
await expect(apartmentsLink).toBeVisible();
await apartmentsLink.click();
} else {
await navigation.getByRole('button', { name: 'Afficher les autres sections' }).click();
await page.getByRole('menuitem', { name: 'Appartements' }).click();
const moreButton = navigation.getByRole('button', {
name: 'Afficher les autres sections',
});
await moreButton.focus();
await page.keyboard.press('Enter');
const apartmentsMenuItem = page.getByRole('menuitem', {
name: 'Appartements',
exact: true,
});
await expect(apartmentsMenuItem).toBeVisible();
await apartmentsMenuItem.focus();
await page.keyboard.press('Enter');
}
await expect(page.getByRole('heading', { name: 'Mes appartements' })).toBeVisible();
await page.getByRole('button', { name: 'Ajouter un appartement' }).first().click();
const acquisitionDate = page.getByRole('textbox', { name: 'Date d’acquisition' });
const acquisitionDateToggle = acquisitionDate
.locator('xpath=ancestor::mat-form-field')
.getByRole('button');
await expect(acquisitionDateToggle).toHaveAccessibleName('Ouvrir le calendrier');
await acquisitionDateToggle.click();
const datepicker = page.locator('mat-datepicker-content');
await expect(datepicker).toBeVisible();
await expect(
datepicker.getByRole('button', { name: 'Mois précédent', exact: true }),
).toBeVisible();
await expect(
datepicker.getByRole('button', { name: 'Mois suivant', exact: true }),
).toBeVisible();
const monthYearButton = datepicker.getByRole('button', {
name: 'Choisir le mois et l’année',
exact: true,
});
await expect(monthYearButton).toBeVisible();
await monthYearButton.click();
await expect(
datepicker.getByRole('button', { name: '24 années précédentes', exact: true }),
).toBeVisible();
await expect(
datepicker.getByRole('button', { name: '24 années suivantes', exact: true }),
).toBeVisible();
await page.keyboard.press('Escape');
await expect(datepicker).toBeHidden();
await expectNoHorizontalOverflow(page);
});
@@ -577,7 +707,12 @@ test('keeps apartment add and edit workflows usable', async ({ page }) => {
await page.route('**/api/apartments**', async (route) => {
const request = route.request();
const method = request.method();
const id = new URL(request.url()).pathname.split('/').at(-1);
const path = new URL(request.url()).pathname;
if (path.endsWith('/documents')) {
await route.fulfill({ contentType: 'application/json', json: [] });
return;
}
const id = path.split('/').at(-1);
if (method === 'POST') {
const apartment = {
@@ -613,12 +748,12 @@ test('keeps apartment add and edit workflows usable', async ({ page }) => {
await navigateToMenu(page, 'Appartements');
await expect(page.getByRole('heading', { name: 'T2 République' })).toBeVisible();
await page.getByRole('button', { name: 'Modifier' }).click();
await page.getByRole('link', { name: 'Ouvrir la fiche de T2 République' }).click();
await expect(page).toHaveURL(/\/apartments\/apt-1\/edit$/);
await page.goBack();
await expect(page).toHaveURL(/\/apartments$/);
await expect(page.getByRole('heading', { name: 'T2 République' })).toBeVisible();
await page.getByRole('button', { name: 'Modifier' }).click();
await page.getByRole('link', { name: 'Ouvrir la fiche de T2 République' }).click();
await page.getByLabel("Nom de l'appartement").fill('T2 République rénové');
await page.getByRole('button', { name: 'Enregistrer' }).click();
await expect(page.getByRole('heading', { name: 'T2 République rénové' })).toBeVisible();
@@ -629,13 +764,19 @@ test('keeps apartment add and edit workflows usable', async ({ page }) => {
await page.getByLabel("Prix d'acquisition").fill('125000');
await page.getByLabel('Date d’acquisition').fill('2024-06-15');
await page.getByRole('button', { name: 'Enregistrer' }).click();
await expect(page.getByRole('heading', { name: 'Studio Croix-Rousse' })).toBeVisible();
await expect(page).toHaveURL(/\/apartments\/apt-2\/edit$/);
await expect(page.getByRole('heading', { name: "Modifier l'appartement" })).toBeVisible();
await expect(page.getByLabel("Nom de l'appartement")).toHaveValue('Studio Croix-Rousse');
page.once('dialog', (dialog) => dialog.accept());
await navigateToMenu(page, 'Appartements');
const renovatedApartment = page
.getByRole('article')
.filter({ has: page.getByRole('heading', { name: 'T2 République rénové' }) });
await renovatedApartment.getByRole('button', { name: 'Supprimer' }).click();
await renovatedApartment
.getByRole('button', { name: 'Actions pour T2 République rénové' })
.click();
await page.getByRole('menuitem', { name: 'Supprimer' }).click();
await page.getByRole('alertdialog').getByRole('button', { name: 'Supprimer' }).click();
await expect(page.getByRole('heading', { name: 'T2 République rénové' })).toBeHidden();
await expectNoHorizontalOverflow(page);
@@ -645,17 +786,35 @@ test('keeps private-area validation focused and clears the structured server err
page,
}) => {
const apartments: object[] = [];
let createAttempts = 0;
let updateAttempts = 0;
await mockUser(page, true);
await page.route('**/api/apartments', async (route) => {
if (route.request().method() === 'GET') {
await page.route('**/api/apartments**', async (route) => {
const method = route.request().method();
if (new URL(route.request().url()).pathname.endsWith('/documents')) {
await route.fulfill({ contentType: 'application/json', json: [] });
return;
}
if (method === 'GET') {
await route.fulfill({ contentType: 'application/json', json: apartments });
return;
}
createAttempts += 1;
if (createAttempts === 1) {
if (method === 'POST') {
const apartment = {
id: 'apt-private-area',
mainRoomCount: 0,
referenced: false,
version: 0,
...route.request().postDataJSON(),
};
apartments.push(apartment);
await route.fulfill({ contentType: 'application/json', json: apartment });
return;
}
updateAttempts += 1;
if (updateAttempts === 1) {
await route.fulfill({
status: 400,
contentType: 'application/json',
@@ -667,41 +826,37 @@ test('keeps private-area validation focused and clears the structured server err
return;
}
const apartment = {
id: 'apt-private-area',
mainRoomCount: 1,
referenced: false,
version: 0,
...route.request().postDataJSON(),
};
apartments.push(apartment);
const apartment = apartments[0];
Object.assign(apartment, route.request().postDataJSON());
await route.fulfill({ contentType: 'application/json', json: apartment });
});
await page.goto('/');
await navigateToMenu(page, 'Appartements');
await page.locator('.page-heading > .primary-action').click();
await page.getByRole('button', { name: 'Ajouter un appartement' }).first().click();
await page.getByLabel("Nom de l'appartement").fill('Studio Croix-Rousse');
await page.getByLabel('Adresse complète').fill('8 montée de la Boucle, Lyon');
await page.getByLabel("Prix d'acquisition").fill('125000');
await page.getByLabel('Date d’acquisition').fill('2024-06-15');
await page.getByRole('button', { name: 'Enregistrer' }).click();
await expect(page).toHaveURL(/\/apartments\/apt-private-area\/edit$/);
await page.getByRole('button', { name: 'Ajouter un espace' }).click();
const label = page.getByLabel('Libellé');
await page.getByRole('button', { name: 'Enregistrer' }).click();
await expect(page.getByText('Renseignez le libellé de chaque espace privatif.')).toBeVisible();
await expect(label).toBeFocused();
expect(createAttempts).toBe(0);
expect(updateAttempts).toBe(0);
await label.fill('Chambre');
await page.getByRole('button', { name: 'Enregistrer' }).click();
await expect(page.getByText('Le libellé est obligatoire.')).toBeVisible();
await expect.poll(() => updateAttempts).toBe(1);
await label.fill('Chambre 1');
await expect(page.getByText('Le libellé est obligatoire.')).toBeHidden();
await page.getByRole('button', { name: 'Enregistrer' }).click();
await expect(page.getByRole('heading', { name: 'Studio Croix-Rousse' })).toBeVisible();
expect(createAttempts).toBe(2);
expect(updateAttempts).toBe(2);
});
test('keeps logout available', async ({ page }) => {
@@ -1130,7 +1285,7 @@ test('guides an entry condition report from an incomplete area to the combined p
});
await page.goto('/');
await navigateToMenu(page, 'Appartements');
await page.getByRole('button', { name: 'Modifier' }).click();
await page.getByRole('link', { name: 'Ouvrir la fiche de T2 République' }).click();
const areas = page.locator('.private-area');
await expect(areas.nth(0)).toContainText('Séjour');
await expect(areas.nth(1)).toContainText('Cuisine');
@@ -1146,7 +1301,7 @@ test('guides an entry condition report from an incomplete area to the combined p
await expect(page.getByRole('heading', { name: 'T2 République' })).toBeVisible();
expect(apartment.privateAreas.map((area) => area.id)).toEqual(['area-kitchen', 'area-living']);
await page.reload();
await page.getByRole('button', { name: 'Modifier' }).click();
await page.getByRole('link', { name: 'Ouvrir la fiche de T2 République' }).click();
await expect(areas.nth(0)).toContainText('Cuisine');
await expect(areas.nth(1)).toContainText('Séjour');
let uploads = 0;
@@ -1288,9 +1443,47 @@ test('guides an entry condition report from an incomplete area to the combined p
);
const photoSha = await sha256Hex(photoBytes);
let reconciliations = 0;
let conditionReportBlocked = true;
const inventory = {
id: 'inventory-1',
templateVersion: '2026.07',
lines: [
{
id: 'chair',
category: 'TABLE_AND_SEATING',
presence: 'PRESENT',
privateAreaId: 'area-kitchen',
designation: 'Chaise',
quantity: 1,
description: '',
},
],
missingCategories: [],
absentCategories: [],
legalWarnings: [],
readyForLeaseDocument: false,
frozen: false,
privateAreas: [
{ id: 'area-kitchen', label: 'Cuisine' },
{ id: 'area-living', label: 'Séjour' },
],
version: 0,
};
await page.route(
(url) => url.pathname === '/api/leases/lease-1/inventory',
async (route) => route.fulfill({ contentType: 'application/json', json: inventory }),
);
await page.route(
(url) => url.pathname === '/api/leases/lease-1/condition-reports/ENTRY',
async (route) => {
if (conditionReportBlocked && route.request().method() === 'GET') {
await route.fulfill({
status: 409,
contentType: 'application/json',
json: { code: 'entry_report_inventory_incomplete' },
});
return;
}
if (route.request().method() === 'PUT') {
if (failNextAutosave) {
failNextAutosave = false;
@@ -1481,6 +1674,35 @@ test('guides an entry condition report from an incomplete area to the combined p
},
);
await page.goto('/leases/lease-1/condition-reports/ENTRY');
await expect(
page.getByRole('heading', { name: 'Inventaire du mobilier requis' }),
).toBeVisible();
await expect(page.getByRole('button', { name: 'Réessayer' })).toHaveCount(0);
const completeInventoryLink = page.getByRole('link', { name: 'Compléter l’inventaire' });
await expect(completeInventoryLink).toBeVisible();
const completeInventoryUrl = new URL(
(await completeInventoryLink.getAttribute('href')) ?? '',
page.url(),
);
expect(completeInventoryUrl.pathname).toBe('/leases/lease-1/inventory');
expect(completeInventoryUrl.searchParams.get('returnTo')).toBe(
'/leases/lease-1/condition-reports/ENTRY',
);
await completeInventoryLink.click();
await expect(page).toHaveURL(/\/leases\/lease-1\/inventory\?returnTo=/);
await expect(page.getByRole('heading', { name: 'Inventaire du mobilier' })).toBeVisible();
const resumeConditionReportLink = page.getByRole('link', {
name: 'Reprendre l’état des lieux',
});
await expect(resumeConditionReportLink).toBeVisible();
const resumeConditionReportUrl = new URL(
(await resumeConditionReportLink.getAttribute('href')) ?? '',
page.url(),
);
expect(resumeConditionReportUrl.pathname).toBe('/leases/lease-1/condition-reports/ENTRY');
conditionReportBlocked = false;
await resumeConditionReportLink.click();
await expect(page).toHaveURL('/leases/lease-1/condition-reports/ENTRY');
const occurredOn = page.getByLabel('Date civile');
const kitchenStep = page.locator('mat-step-header').filter({ hasText: 'Cuisine' });
const livingStep = page.locator('mat-step-header').filter({ hasText: 'Séjour' });
@@ -1584,7 +1806,10 @@ test('guides an entry condition report from an incomplete area to the combined p
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0, 0, 0, 0, 0,
]),
});
await livingPanel.getByRole('button', { name: 'Utiliser cette photo' }).click();
const livingUploadButton = livingPanel.getByRole('button', { name: 'Utiliser cette photo' });
await expect(livingUploadButton).toBeVisible();
await livingUploadButton.click();
await expect.poll(() => uploads).toBe(3);
await livingPanel.getByRole('button', { name: 'Confirmer cette étape' }).click();
await page.getByRole('checkbox', { name: 'Aucun compteur individuel' }).check();
await page.getByRole('checkbox', { name: 'Aucun moyen d’accès remis' }).check();
@@ -1610,9 +1835,7 @@ test('guides an entry condition report from an incomplete area to the combined p
await expect(page.getByRole('link', { name: 'Préparer le dossier à signer' })).toBeHidden();
await kitchenStep.click();
await expect(kitchenPanel.getByRole('button', { name: 'Confirmer cette étape' })).toBeEnabled();
const savesBeforeReconfirmation = saves;
await kitchenPanel.getByRole('button', { name: 'Confirmer cette étape' }).click();
await expect.poll(() => saves).toBeGreaterThan(savesBeforeReconfirmation);
await page.getByRole('button', { name: 'Vérification finale' }).click();
await expect(page.getByRole('link', { name: 'Préparer le dossier à signer' })).toBeVisible();
await page.getByRole('link', { name: 'Préparer le dossier à signer' }).click();
@@ -1654,6 +1877,15 @@ test('guides an entry condition report from an incomplete area to the combined p
test('preselects IRL and keeps legal choices readable', async ({ page }) => {
await mockUser(page, true);
const vault = await createTenantVaultFixture();
const tenantProfile = await encryptTenantProfile(vault, 'tenant-irl', {
firstName: 'Camille',
lastName: 'Martin',
email: 'camille@example.com',
phone: '+33612345678',
address: '10 rue des Lilas, Lyon',
});
await installTenantVaultBoundary(page, vault);
await page.route('**/api/apartments', (route) =>
route.fulfill({
contentType: 'application/json',
@@ -1693,7 +1925,10 @@ test('preselects IRL and keeps legal choices readable', async ({ page }) => {
}),
);
await page.route('**/api/tenants', (route) =>
route.fulfill({ contentType: 'application/json', json: [] }),
route.fulfill({
contentType: 'application/json',
json: [tenantResponse(vault, 'tenant-irl', tenantProfile)],
}),
);
await page.route('**/api/leases', (route) =>
route.fulfill({ contentType: 'application/json', json: [] }),
@@ -1706,12 +1941,20 @@ test('preselects IRL and keeps legal choices readable', async ({ page }) => {
);
await page.goto('/');
await navigateToMenu(page, 'Locataires');
await page.getByRole('button', { name: 'Déverrouiller', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Camille Martin' })).toBeVisible();
await navigateToMenu(page, 'Baux');
await expect(page).toHaveURL(/\/leases$/);
await expect(page.getByRole('heading', { name: 'Mes baux' })).toBeVisible();
const createLease = page.locator('.page-heading > .primary-action');
await expect(createLease).toBeEnabled();
await createLease.click();
await page.getByRole('combobox', { name: 'Appartement' }).click();
await page.getByRole('option', { name: 'Studio Ajaccio' }).click();
await page.getByRole('combobox', { name: 'Locataires' }).click();
await page.getByRole('option', { name: 'Camille Martin' }).click();
await page.keyboard.press('Escape');
await page
.getByRole('checkbox', { name: "Prévoir une révision annuelle selon l'IRL." })
.check();
@@ -1959,11 +2202,14 @@ test('creates a tenant with an attachment and a lease with a guarantee', async (
await page.getByLabel('E-mail', { exact: true }).fill('camille@example.com');
await page.getByLabel(/Téléphone/).fill('+33612345678');
await page.getByLabel('Adresse complète').fill('10 rue des Lilas, Lyon');
await page.getByLabel('Ajouter des pièces jointes').setInputFiles({
name: 'identite.pdf',
mimeType: 'application/pdf',
buffer: Buffer.from(pdfBytes()),
});
await page
.locator('app-tenant-attachment-editor input[type=file]')
.first()
.setInputFiles({
name: 'identite.pdf',
mimeType: 'application/pdf',
buffer: Buffer.from(pdfBytes()),
});
await expect(page.getByText('En attente d’envoi')).toBeVisible();
await page.getByRole('button', { name: 'Enregistrer' }).click();
await expect(page.getByRole('heading', { name: 'Modifier le locataire' })).toBeVisible();
@@ -2028,7 +2274,8 @@ test('creates a tenant with an attachment and a lease with a guarantee', async (
await expect(page.getByRole('heading', { name: 'Studio République' })).toBeVisible();
await expect(page.getByText('Alex Durand')).toBeVisible();
await expect(page.getByRole('button', { name: 'État des lieux d’entrée' })).toBeVisible();
await page.getByRole('button', { name: 'Plus d’actions' }).click();
await expect(page.getByRole('menuitem', { name: 'État des lieux d’entrée' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Générer / télécharger le bail' })).toHaveCount(
0,
);
@@ -2124,10 +2371,13 @@ test('keeps failed tenant attachments pending and retries only the failed file',
await page.getByLabel('Nom', { exact: true }).fill('Martin');
await page.getByLabel('E-mail', { exact: true }).fill('camille@example.com');
await page.getByLabel('Adresse complète').fill('10 rue des Lilas, Lyon');
await page.getByLabel('Ajouter des pièces jointes').setInputFiles([
{ name: 'identite.pdf', mimeType: 'application/pdf', buffer: Buffer.from(pdfBytes()) },
{ name: 'revenus.pdf', mimeType: 'application/pdf', buffer: Buffer.from(pdfBytes()) },
]);
await page
.locator('app-tenant-attachment-editor input[type=file]')
.first()
.setInputFiles([
{ name: 'identite.pdf', mimeType: 'application/pdf', buffer: Buffer.from(pdfBytes()) },
{ name: 'revenus.pdf', mimeType: 'application/pdf', buffer: Buffer.from(pdfBytes()) },
]);
await page.getByRole('button', { name: 'Enregistrer' }).click();
await expect(page.getByText('Impossible d’enregistrer ce locataire.')).toBeVisible();
expect(uploads).toBe(0);
@@ -2250,14 +2500,24 @@ test('records a received rent payment and keeps its surplus visible', async ({ p
});
await page.goto('/');
await navigateToMenu(page, 'Loyers');
const mobileRentLink = page
.getByTestId('primary-navigation')
.getByRole('link', { name: 'Loyers', exact: true });
if (await mobileRentLink.isVisible()) {
await mobileRentLink.click();
} else {
await page
.getByTestId('primary-navigation')
.getByRole('link', { name: 'Échéances et règlements', exact: true })
.click();
}
await expect(page.getByRole('heading', { name: 'Studio République' })).toBeVisible();
await page.getByRole('button', { name: 'Enregistrer un règlement' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog.getByRole('heading', { name: 'Enregistrer un règlement' })).toBeVisible();
await dialog.getByLabel('Montant reçu').fill('950');
await expect(dialog.getByText(/Surplus non affecté/)).toContainText('50,00');
await expect(dialog.getByText(/Surplus non affecté/)).toContainText('50');
await dialog.getByRole('button', { name: 'Confirmer le règlement' }).click();
await expect(page.getByRole('status')).toHaveText('Le règlement a été enregistré.');
@@ -2277,8 +2537,8 @@ test('records a received rent payment and keeps its surplus visible', async ({ p
const paymentCard = page
.getByRole('article')
.filter({ has: page.getByText('Confirmé', { exact: true }) });
await expect(paymentCard.getByText(/^950,00\s€$/)).toBeVisible();
await expect(paymentCard.getByText(/^50,00\s€$/)).toBeVisible();
await expect(paymentCard.getByText(/^950\s*€$/)).toBeVisible();
await expect(paymentCard.getByText(/^50\s*€$/)).toBeVisible();
await expect(paymentCard.getByRole('button', { name: 'Rembourser' })).toBeVisible();
await expectNoHorizontalOverflow(page);
});
+59 -1
View File
@@ -71,8 +71,66 @@ test.describe('public application QR', () => {
fullPage: true,
});
});
test('copies and downloads the public-link QR as a readable PNG', async ({ page }) => {
const expectedUrl = await openApplicationLinkManager(page, 'light');
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
await page.getByRole('button', { name: 'Copier le QR code' }).click();
await expect(page.getByText('QR code copié dans le presse-papiers.')).toBeVisible();
const copiedBytes = await page.evaluate(async () => {
const [item] = await navigator.clipboard.read();
return Array.from(
new Uint8Array(await (await item.getType('image/png')).arrayBuffer()),
);
});
expect(await decodePng(page, copiedBytes)).toBe(expectedUrl);
const downloadReady = page.waitForEvent('download');
await page.getByRole('button', { name: 'Télécharger le QR code' }).click();
const download = await downloadReady;
expect(download.suggestedFilename()).toBe('qr-candidature.png');
const stream = await download.createReadStream();
if (!stream) throw new Error('QR download stream is unavailable.');
const chunks: Buffer[] = [];
for await (const chunk of stream) chunks.push(Buffer.from(chunk));
expect(await decodePng(page, [...Buffer.concat(chunks)])).toBe(expectedUrl);
await expect(page.getByText('QR code téléchargé.')).toBeVisible();
});
});
async function decodePng(
page: import('@playwright/test').Page,
bytes: number[],
): Promise<string | null> {
const pixels = await page.evaluate(async (data) => {
const image = await createImageBitmap(
new Blob([new Uint8Array(data)], { type: 'image/png' }),
);
try {
const canvas = document.createElement('canvas');
canvas.width = image.width;
canvas.height = image.height;
const context = canvas.getContext('2d');
if (!context) return null;
context.drawImage(image, 0, 0);
return {
data: Array.from(context.getImageData(0, 0, image.width, image.height).data),
width: image.width,
height: image.height,
};
} finally {
image.close();
}
}, bytes);
if (!pixels) return null;
return (
jsQR(new Uint8ClampedArray(pixels.data), pixels.width, pixels.height, {
inversionAttempts: 'attemptBoth',
})?.data ?? null
);
}
async function openApplicationLinkManager(
page: import('@playwright/test').Page,
theme: 'light' | 'dark',
@@ -105,7 +163,7 @@ async function openApplicationLinkManager(
await page.goto('/apartments');
await expect(page.getByRole('heading', { name: apartment.name })).toBeVisible();
await page.getByRole('button', { name: 'Modifier' }).click();
await page.getByRole('link', { name: 'Ouvrir la fiche de T2 QR' }).click();
await expect(page.getByRole('heading', { name: "Modifier l'appartement" })).toBeVisible();
const qr = page.locator('app-application-link-qr');
+21 -14
View File
@@ -8,7 +8,7 @@ import { mockAuthenticatedUser } from './support/auth';
import { buildVisaleFixture } from './support/visale';
import { encryptTenantProfile, tenantResponse } from './support/tenant-vault';
test('edits, adds, removes, and saves a draft lease furniture inventory', async ({ page }) => {
test('assigns, edits, and explicitly saves a draft lease furniture inventory', async ({ page }) => {
const vault = await mockAuthenticatedUser(page);
const fixture = await buildVisaleFixture('lease-1', null, null, vault);
const apartment = {
@@ -44,17 +44,17 @@ test('edits, adds, removes, and saves a draft lease furniture inventory', async
{
id: 'bed-1',
category: 'BEDDING',
presence: 'PRESENT',
privateAreaId: 'bedroom-1',
presence: null,
privateAreaId: null,
designation: 'Lit',
quantity: 1,
quantity: 0,
description: '',
},
],
missingCategories: [],
missingCategories: ['BEDDING'],
absentCategories: [],
legalWarnings: [],
readyForLeaseDocument: true,
readyForLeaseDocument: false,
frozen: false,
privateAreas: [{ id: 'bedroom-1', label: 'Chambre' }],
version: 7,
@@ -92,7 +92,7 @@ test('edits, adds, removes, and saves a draft lease furniture inventory', async
...inventory,
lines: savedRequest.lines,
version: savedRequest.version + 1,
readyForLeaseDocument: true,
readyForLeaseDocument: false,
},
});
});
@@ -103,12 +103,19 @@ test('edits, adds, removes, and saves a draft lease furniture inventory', async
await expect(page.getByText('Bob Martin')).toBeVisible();
await navigateToMenu(page, 'Baux');
await expect(page.getByRole('heading', { name: 'Mes baux' })).toBeVisible();
await page.getByRole('button', { name: 'Inventaire du mobilier' }).click();
await page.getByRole('button', { name: 'Plus d’actions' }).click();
await page.getByRole('menuitem', { name: 'Inventaire du mobilier' }).click();
await expect(page.getByRole('heading', { name: 'Inventaire du mobilier' })).toBeVisible();
await expect(page.getByText('L’inventaire est prêt pour la génération du bail.')).toBeVisible();
await expect(page.getByText('À compléter', { exact: true })).toBeVisible();
await expect(page.getByText('Catégories déclarées', { exact: true })).toBeVisible();
await expect(page.getByText('0 / 11', { exact: true })).toBeVisible();
const bedding = page.locator('app-inventory-line-card').filter({ hasText: 'Literie' });
await bedding.getByRole('combobox', { name: 'Espace privatif' }).click();
await page.getByRole('option', { name: 'Chambre', exact: true }).click();
await bedding.getByRole('combobox', { name: 'Présence' }).click();
await page.getByRole('option', { name: 'Présente', exact: true }).click();
await bedding.getByLabel('Désignation').fill('Lit double');
await bedding.getByLabel('Quantité').fill('2');
await expect(bedding.getByLabel('Désignation')).toHaveValue('Lit double');
@@ -122,23 +129,23 @@ test('edits, adds, removes, and saves a draft lease furniture inventory', async
await freeLine.getByRole('button', { name: 'Supprimer cet élément' }).click();
await expect(freeLine).toHaveCount(0);
await page.getByRole('button', { name: 'Enregistrer et revenir aux baux' }).click();
await page.getByRole('button', { name: 'Enregistrer', exact: true }).click();
await expect.poll(() => savedRequest).toBeDefined();
await expect(page.getByText('Inventaire enregistré.')).toBeVisible();
await expect(page).toHaveURL(/\/leases$/);
await expect(page).toHaveURL(/\/leases\/lease-1\/inventory$/);
expect(savedRequest).toMatchObject({
version: 7,
lines: [
expect.objectContaining({
id: 'bed-1',
privateAreaId: 'bedroom-1',
presence: 'PRESENT',
designation: 'Lit double',
quantity: 2,
}),
],
});
expect(savedRequest?.lines).toHaveLength(1);
await page.getByRole('button', { name: 'Inventaire du mobilier' }).click();
await expect(page.getByText('L’inventaire est prêt pour la génération du bail.')).toBeVisible();
await expect(bedding.getByRole('combobox', { name: 'Espace privatif' })).toHaveText('Chambre');
});
+2 -1
View File
@@ -69,7 +69,8 @@ test('creates a financial amendment on an active lease and shows the amended ter
has: page.getByRole('heading', { name: 'Studio République' }),
});
await expect(leaseCard).toContainText('Bail actif');
await leaseCard.getByRole('button', { name: 'Préparer un avenant' }).click();
await leaseCard.getByRole('button', { name: 'Plus d’actions' }).click();
await page.getByRole('menuitem', { name: 'Préparer un avenant' }).click();
await expect(page.getByRole('heading', { name: 'Préparer un avenant' })).toBeVisible();
await page.getByLabel('Prise d’effet').fill('15/08/2026');
+4 -2
View File
@@ -78,7 +78,8 @@ test('selects and persists a configured catalogue clause on a draft lease', asyn
await expect(page.getByText('Camille Martin')).toBeVisible();
await navigateToMenu(page, 'Baux');
await expect(page.getByRole('heading', { name: 'Mes baux' })).toBeVisible();
await page.locator('app-lease-card').getByRole('button', { name: 'Modifier' }).click();
await page.locator('app-lease-card').getByRole('button', { name: 'Plus d’actions' }).click();
await page.getByRole('menuitem', { name: 'Modifier' }).click();
const editor = page.locator('app-lease-clause-editor');
await expect(editor.getByRole('heading', { name: 'Clauses particulières' })).toBeVisible();
@@ -109,7 +110,8 @@ test('selects and persists a configured catalogue clause on a draft lease', asyn
]);
await expect(page.getByRole('heading', { name: 'Mes baux' })).toBeVisible();
await page.locator('app-lease-card').getByRole('button', { name: 'Modifier' }).click();
await page.locator('app-lease-card').getByRole('button', { name: 'Plus d’actions' }).click();
await page.getByRole('menuitem', { name: 'Modifier' }).click();
const persistedEditor = page.locator('app-lease-clause-editor');
await expect.poll(() => clauseGetCount).toBeGreaterThan(1);
await expect(persistedEditor.getByRole('checkbox', { name: clause.label })).toBeChecked();
+6 -3
View File
@@ -62,7 +62,8 @@ test('adds, edits, saves, reopens, and removes a custom lease clause', async ({
await expect(page.getByText('Camille Martin')).toBeVisible();
await navigateToMenu(page, 'Baux');
await expect(page.getByRole('heading', { name: 'Mes baux' })).toBeVisible();
await page.locator('app-lease-card').getByRole('button', { name: 'Modifier' }).click();
await page.locator('app-lease-card').getByRole('button', { name: 'Plus d’actions' }).click();
await page.getByRole('menuitem', { name: 'Modifier' }).click();
await page.getByRole('button', { name: 'Ajouter une clause personnalisée' }).click();
const customClause = page.locator('.custom-clause');
@@ -93,7 +94,8 @@ test('adds, edits, saves, reopens, and removes a custom lease clause', async ({
expect(JSON.stringify(updates[0]['specialClauses'])).not.toContain(editedClause.customText);
await expect(page.getByRole('heading', { name: 'Mes baux' })).toBeVisible();
await page.locator('app-lease-card').getByRole('button', { name: 'Modifier' }).click();
await page.locator('app-lease-card').getByRole('button', { name: 'Plus d’actions' }).click();
await page.getByRole('menuitem', { name: 'Modifier' }).click();
const persistedClause = page.locator('.custom-clause');
await expect(
persistedClause.getByRole('textbox', { name: 'Clause personnalisée 1' }),
@@ -106,7 +108,8 @@ test('adds, edits, saves, reopens, and removes a custom lease clause', async ({
expect(updates[1]['specialClauses']).toEqual([]);
await expect(page.getByRole('heading', { name: 'Mes baux' })).toBeVisible();
await page.locator('app-lease-card').getByRole('button', { name: 'Modifier' }).click();
await page.locator('app-lease-card').getByRole('button', { name: 'Plus d’actions' }).click();
await page.getByRole('menuitem', { name: 'Modifier' }).click();
await expect(page.locator('.custom-clause')).toHaveCount(0);
await expect(
page.getByRole('button', { name: 'Ajouter une clause personnalisée' }),
+4 -2
View File
@@ -74,13 +74,15 @@ test('a landlord edits and then deletes a draft lease', async ({ page }) => {
const leaseCard = page.locator('app-lease-card');
await expect(leaseCard).toContainText('850 €');
await leaseCard.getByRole('button', { name: 'Modifier' }).click();
await leaseCard.getByRole('button', { name: 'Plus d’actions' }).click();
await page.getByRole('menuitem', { name: 'Modifier' }).click();
await expect(page.getByRole('heading', { name: 'Modifier le brouillon' })).toBeVisible();
await page.getByLabel('Loyer mensuel').fill('925');
await page.getByRole('button', { name: 'Enregistrer les modifications' }).click();
await expect(leaseCard).toContainText('925 €');
await leaseCard.getByRole('button', { name: 'Supprimer' }).click();
await leaseCard.getByRole('button', { name: 'Plus d’actions' }).click();
await page.getByRole('menuitem', { name: 'Supprimer' }).click();
const confirmation = page.getByRole('alertdialog');
await expect(confirmation).toContainText('Supprimer ce bail ?');
+11 -13
View File
@@ -162,11 +162,12 @@ test('downloads the current lease PDF and final dossier from its active card', a
await leaseCard.getByRole('button', { name: 'Déverrouiller le coffre' }).click();
await expect(leaseCard).toContainText('Camille Martin');
const pdfButton = leaseCard.getByRole('button', {
await leaseCard.getByRole('button', { name: 'Plus d’actions' }).click();
const pdfButton = page.getByRole('menuitem', {
name: 'Télécharger le bail',
exact: true,
});
const dossierButton = leaseCard.getByRole('button', {
const dossierButton = page.getByRole('menuitem', {
name: 'Télécharger le Dossier final',
exact: true,
});
@@ -177,11 +178,7 @@ test('downloads the current lease PDF and final dossier from its active card', a
await pdfButton.click();
await expect.poll(() => pdfRequests).toBe(1);
await expect(leaseCard).toHaveAttribute('aria-busy', 'true');
const pdfPendingButton = leaseCard.getByRole('button', {
name: 'Téléchargement…',
exact: true,
});
await expect(pdfPendingButton).toBeDisabled();
await expect(leaseCard.getByRole('status')).toContainText('Téléchargement du bail…');
releasePdf();
const downloadedPdf = await pdfDownload;
expect(downloadedPdf.suggestedFilename()).toBe('bail-lease-1-revision-4.pdf');
@@ -189,21 +186,22 @@ test('downloads the current lease PDF and final dossier from its active card', a
expect(pdfPath).not.toBeNull();
expect(await readFile(pdfPath!)).toEqual(Buffer.from(revisionPdf));
await expect(leaseCard).toHaveAttribute('aria-busy', 'false');
await expect(pdfButton).toBeEnabled();
await leaseCard.getByRole('button', { name: 'Plus d’actions' }).click();
await expect(
page.getByRole('menuitem', { name: 'Télécharger le bail', exact: true }),
).toBeEnabled();
await page.keyboard.press('Escape');
await expect(page.getByText('Révision 4 · modèle 2026.07', { exact: true })).toBeVisible();
await leaseCard.getByText('Contenu du dossier (1 annexes)', { exact: true }).click();
await expect(leaseCard).toContainText('notice-information.pdf');
const zipDownload = page.waitForEvent('download');
await leaseCard.getByRole('button', { name: 'Plus d’actions' }).click();
await dossierButton.click();
await expect.poll(() => zipRequests).toBe(1);
await expect(leaseCard).toHaveAttribute('aria-busy', 'true');
const dossierPendingButton = leaseCard.getByRole('button', {
name: 'Préparation du Dossier final…',
exact: true,
});
await expect(dossierPendingButton).toBeDisabled();
await expect(leaseCard.getByRole('status')).toContainText('Préparation du Dossier final…');
releaseZip();
const downloadedZip = await zipDownload;
expect(downloadedZip.suggestedFilename()).toBe('dossier-final-bail-lease-1.zip');
+2 -2
View File
@@ -208,7 +208,7 @@ test('persists, corrects, resets, and deletes a mortgage through its public flow
expect(api.previewRequests).toEqual([totalDeferralTerms]);
await page.getByRole('button', { name: 'Enregistrer le prêt' }).click();
await expect.poll(() => api.createRequests).toEqual([totalDeferralTerms]);
await expect.poll(() => api.listRequests).toHaveLength(2);
await expect.poll(() => api.listRequests).toHaveLength(1);
await expect(page.getByLabel('Filtrer par appartement')).toContainText('T2 République');
const search = page.getByLabel('Rechercher un prêt');
@@ -217,7 +217,7 @@ test('persists, corrects, resets, and deletes a mortgage through its public flow
page.getByRole('heading', { name: 'Aucun prêt pour ces filtres' }),
).toBeVisible();
await search.fill('différé');
expect(api.listRequests).toHaveLength(2);
expect(api.listRequests).toHaveLength(1);
const mortgageLink = page.getByRole('link', {
name: totalDeferralTerms.label,
exact: true,
+5 -4
View File
@@ -9,7 +9,7 @@ interface PrivateAreaUpdate {
label: string;
}
test('persists an apartment private-area visit order', async ({ page }) => {
test('persists an apartment private-area visit order', async ({ page }, testInfo) => {
await mockAuthenticatedUser(page);
const apartment = {
@@ -90,7 +90,7 @@ test('persists an apartment private-area visit order', async ({ page }) => {
const apartmentCard = page
.getByRole('article')
.filter({ has: page.getByRole('heading', { name: apartment.name }) });
await apartmentCard.getByRole('button', { name: 'Modifier' }).click();
await apartmentCard.getByRole('link', { name: 'Ouvrir la fiche de T2 République' }).click();
await expect(page.getByRole('heading', { name: "Modifier l'appartement" })).toBeVisible();
const areas = page.locator('.private-area');
@@ -100,18 +100,19 @@ test('persists an apartment private-area visit order', async ({ page }) => {
await page.getByRole('button', { name: 'Enregistrer', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Mes appartements' })).toBeVisible();
await expect(page).toHaveURL(/\/apartments$/);
await expect
.poll(() => savedPrivateAreas?.map(({ id }) => id))
.toEqual(['bedroom', 'living-room']);
await navigateToMenu(page, 'Tableau de bord');
await navigateToMenu(page, testInfo.project.name === 'mobile' ? 'Accueil' : 'Tableau de bord');
await expect(page.getByRole('heading', { name: 'Tableau de bord' })).toBeVisible();
await navigateToMenu(page, 'Appartements');
await expect(page.getByRole('heading', { name: 'Mes appartements' })).toBeVisible();
await page
.getByRole('article')
.filter({ has: page.getByRole('heading', { name: apartment.name }) })
.getByRole('button', { name: 'Modifier' })
.getByRole('link', { name: 'Ouvrir la fiche de T2 République' })
.click();
await expect(page.getByRole('heading', { name: "Modifier l'appartement" })).toBeVisible();
+1 -1
View File
@@ -232,7 +232,7 @@ test('corrects an overallocated due through its visible reversal and replacement
});
await page.goto('/rent-payments');
await expect(page.getByRole('heading', { name: 'Mes loyers' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Échéances et règlements' })).toBeVisible();
const dueCard = page
.getByRole('article')
+3 -3
View File
@@ -126,19 +126,19 @@ test('allocates an existing payment surplus to an unpaid rent due', async ({ pag
.locator('dl > div')
.filter({ hasText: /^Restant à régler/ })
.locator('dd'),
).toHaveText(/^0,00\s€$/);
).toHaveText(/^0\s€$/);
await expect(
paymentCard
.locator('dl > div')
.filter({ hasText: /^Affecté/ })
.locator('dd'),
).toHaveText(/^100,00\s€$/);
).toHaveText(/^100\s€$/);
await expect(
paymentCard
.locator('dl > div')
.filter({ hasText: /^Solde non affecté/ })
.locator('dd'),
).toHaveText(/^0,00\s€$/);
).toHaveText(/^0\s€$/);
await expect(paymentCard.getByText('Studio République — août 2026')).toBeVisible();
expect(allocationRequest).toMatchObject({
+5 -6
View File
@@ -1,4 +1,3 @@
import { navigateToMenu } from './support/navigation';
import { expect, test } from '@playwright/test';
import { mockAuthenticatedUser } from './support/auth';
import type { Apartment } from '../src/app/services/apartment.service';
@@ -98,18 +97,18 @@ test('refunds part of an existing payment surplus', async ({ page }) => {
});
await page.goto('/');
await navigateToMenu(page, 'Loyers');
await page.goto('/rent-payments');
await expect(page.getByRole('heading', { name: 'Studio République' })).toBeVisible();
const paymentCard = page
.getByRole('article')
.filter({ has: page.getByText('Confirmé', { exact: true }) });
await expect(paymentCard.getByText(/^50,00\s€$/)).toBeVisible();
await expect(paymentCard.getByText(/^50\s€$/)).toBeVisible();
await paymentCard.getByRole('button', { name: 'Rembourser' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog.getByRole('heading', { name: 'Rembourser le surplus' })).toBeVisible();
await expect(dialog.getByText(/Solde non affecté disponible/)).toContainText('50,00');
await expect(dialog.getByText(/Solde non affecté disponible/)).toContainText('50 €');
const refundedAt = dialog.getByLabel('Date du remboursement');
await refundedAt.fill('2026-07-07');
await dialog.getByLabel('Montant remboursé').fill('20');
@@ -126,8 +125,8 @@ test('refunds part of an existing payment surplus', async ({ page }) => {
});
await expect.poll(() => refundRequest?.operationId).toEqual(expect.any(String));
await expect(paymentCard.getByText(/^30,00\s€$/)).toBeVisible();
await expect(paymentCard.getByText(/^30\s€$/)).toBeVisible();
await expect(paymentCard.getByRole('heading', { name: 'Remboursements' })).toBeVisible();
await expect(paymentCard.getByText('7 juillet 2026')).toBeVisible();
await expect(paymentCard.getByText(/^20,00\s€$/)).toHaveCount(2);
await expect(paymentCard.getByText(/^20\s€$/)).toHaveCount(2);
});
+2 -2
View File
@@ -126,7 +126,7 @@ test('reverses a confirmed rent payment with a required reason', async ({ page }
});
await page.goto('/rent-payments');
const paymentCard = page.locator('.payment-history-card').filter({ hasText: '900,00' });
const paymentCard = page.locator('.payment-history-card').filter({ hasText: '900 €' });
await expect(paymentCard.getByText('Confirmé', { exact: true })).toBeVisible();
await paymentCard.getByRole('button', { name: 'Contrepasser' }).click();
@@ -159,7 +159,7 @@ test('reverses a confirmed rent payment with a required reason', async ({ page }
});
expect(JSON.stringify(reversalRequest)).not.toContain(reason);
await expect(paymentCard.getByText('Contrepassé', { exact: true })).toBeVisible();
await expect(paymentCard).toContainText('900,00');
await expect(paymentCard).toContainText('900 €');
await expect(
paymentCard.getByText('Motif protégé — déverrouillez le coffre pour l’afficher.', {
exact: true,
+2 -3
View File
@@ -1,5 +1,4 @@
import { Buffer } from 'node:buffer';
import { navigateToMenu } from './support/navigation';
import { expect, test } from '@playwright/test';
import { PDFArray, PDFDocument, PDFRawStream, decodePDFRawStream } from 'pdf-lib';
import type { Apartment } from '../src/app/services/apartment.service';
@@ -177,11 +176,11 @@ test('downloads payment and rent receipts from the rent page', async ({ page })
});
await page.goto('/');
await navigateToMenu(page, 'Locataires');
await page.goto('/tenants');
await expect(page.getByRole('button', { name: 'Déverrouiller' })).toBeVisible();
await page.getByRole('button', { name: 'Déverrouiller' }).click();
await expect(page.getByRole('heading', { name: 'Camille Martin' })).toBeVisible();
await navigateToMenu(page, 'Loyers');
await page.goto('/rent-payments');
const paymentCard = page.getByRole('article').filter({ hasText: 'Confirmé' });
const paymentButton = paymentCard.getByRole('button', { name: 'Télécharger le reçu' });
+2 -1
View File
@@ -213,7 +213,8 @@ test('downloads the frozen letter and delivery proof from an applied rent revisi
).toBeVisible();
await card.getByRole('button', { name: 'Déverrouiller le coffre' }).click();
await expect(card.getByText('Camille Martin')).toBeVisible();
await card.getByRole('button', { name: 'Réviser le loyer selon l’IRL' }).click();
await card.getByRole('button', { name: 'Plus d’actions' }).click();
await page.getByRole('menuitem', { name: 'Réviser le loyer selon l’IRL' }).click();
const revisionItem = card.locator('.revision-list li').first();
await expect(revisionItem).toContainText('Révision appliquée');
+2 -1
View File
@@ -386,7 +386,8 @@ test('previews, proposes, and applies an annual IRL rent revision', async ({ pag
).toBeVisible();
await card.getByRole('button', { name: 'Déverrouiller le coffre' }).click();
await expect(card.getByText('Camille Martin')).toBeVisible();
await card.getByRole('button', { name: 'Réviser le loyer selon l’IRL' }).click();
await card.getByRole('button', { name: 'Plus d’actions' }).click();
await page.getByRole('menuitem', { name: 'Réviser le loyer selon l’IRL' }).click();
const revisionPanel = card.locator('app-rent-revision-preview');
await expect(
+11 -29
View File
@@ -1,8 +1,5 @@
import { expect, test } from '@playwright/test';
import type {
TenantAttachmentRetentionWire,
TenantWireResponse,
} from '../src/app/services/tenant-profile';
import type { TenantWireResponse } from '../src/app/services/tenant-profile';
import { mockAuthenticatedUser } from './support/auth';
import {
encryptTenantAttachment,
@@ -10,7 +7,7 @@ import {
tenantResponse,
} from './support/tenant-vault';
test('downloads an existing tenant attachment from its card', async ({ page }) => {
test('downloads an existing tenant attachment from the tenant fiche', async ({ page }) => {
let downloadRequests = 0;
let releaseDownload!: () => void;
const downloadResponse = new Promise<void>((resolve) => {
@@ -30,14 +27,16 @@ test('downloads an existing tenant attachment from its card', async ({ page }) =
'attachment-1',
'identite.pdf',
);
const attachment = withNativeRetention(encrypted.wire);
const tenant: TenantWireResponse = tenantResponse(vault, 'tenant-1', profile, {
attachments: [attachment],
attachments: [encrypted.wire],
});
await page.route('**/api/apartments', (route) =>
route.fulfill({ contentType: 'application/json', json: [] }),
);
await page.route('**/api/leases', (route) =>
route.fulfill({ contentType: 'application/json', json: [] }),
);
await page.route('**/api/tenants', (route) =>
route.fulfill({ contentType: 'application/json', json: [tenant] }),
);
@@ -62,13 +61,14 @@ test('downloads an existing tenant attachment from its card', async ({ page }) =
await page.getByRole('button', { name: 'Déverrouiller' }).click();
const tenantCard = page.locator('app-tenant-card').filter({ hasText: 'Camille Martin' });
await expect(tenantCard).toBeVisible();
const attachmentItem = tenantCard.locator('.attachment-item');
await tenantCard.getByRole('button', { name: 'Modifier' }).click();
await expect(page.getByRole('heading', { name: 'Modifier le locataire' })).toBeVisible();
const attachmentItem = page.locator('.form-attachment');
const attachmentButton = attachmentItem.getByRole('button', {
name: 'Télécharger la pièce jointe Fichier chiffré',
name: /Télécharg/,
});
await expect(attachmentButton).toBeVisible();
await expect(attachmentItem).toContainText('Finalité');
await expect(attachmentItem).toContainText('Clôture des comptes + 3 ans');
await expect(attachmentItem).toContainText('Fichier chiffré');
const download = page.waitForEvent('download');
await attachmentButton.click();
@@ -82,21 +82,3 @@ test('downloads an existing tenant attachment from its card', async ({ page }) =
await expect(attachmentItem).toHaveAttribute('aria-busy', 'false');
await expect(attachmentButton).toBeEnabled();
});
function withNativeRetention(
attachment: TenantWireResponse['attachments'][number],
): TenantWireResponse['attachments'][number] {
const retention: TenantAttachmentRetentionWire = {
source: 'NATIVE_TENANT',
sourceReference: `tenant-1/${attachment.id}`,
storageReference: `tenant-1/${attachment.id}`,
sourceGeneration: 1,
createdAt: '2026-09-08T10:00:00Z',
retainedAt: '2026-09-08T10:00:00Z',
purpose: 'TENANT_RENTAL_MANAGEMENT',
rule: 'ACCOUNT_CLOSURE_PLUS_THREE_YEARS',
status: 'ACTIVE_RENTAL_PURPOSE',
eligibleOn: null,
};
return { ...attachment, retention };
}
+4 -4
View File
@@ -75,11 +75,11 @@ test('edits and deletes an existing tenant', async ({ page }) => {
await page.getByRole('button', { name: '← Retour' }).click();
await expect(page.getByRole('heading', { name: 'Élodie Martin' })).toBeVisible();
await page
const editedTenantCard = page
.getByRole('article')
.filter({ has: page.getByRole('heading', { name: 'Élodie Martin' }) })
.getByRole('button', { name: 'Supprimer' })
.click();
.filter({ has: page.getByRole('heading', { name: 'Élodie Martin' }) });
await editedTenantCard.getByRole('button', { name: 'Actions pour Élodie Martin' }).click();
await page.getByRole('menuitem', { name: 'Supprimer', exact: true }).click();
const confirmation = page.getByRole('alertdialog');
await expect(
confirmation.getByRole('heading', { name: 'Supprimer ce locataire ?' }),
+6 -3
View File
@@ -68,7 +68,8 @@ test('declares and removes Visale coverage on a draft lease', async ({ page }) =
await navigateToMenu(page, 'Baux');
await page.waitForURL('**/leases');
await expect(page.getByRole('heading', { name: 'Mes baux' })).toBeVisible();
await page.locator('app-lease-card').getByRole('button', { name: 'Modifier' }).click();
await page.locator('app-lease-card').getByRole('button', { name: 'Plus d’actions' }).click();
await page.getByRole('menuitem', { name: 'Modifier' }).click();
const editor = page.locator('app-visale-editor');
await editor.getByRole('button', { name: 'Déclarer une garantie Visale' }).click();
@@ -92,7 +93,8 @@ test('declares and removes Visale coverage on a draft lease', async ({ page }) =
expect(JSON.stringify(updates[0]['visaleGuarantee'])).not.toContain(declaration.visaNumber);
await expect(page.getByRole('heading', { name: 'Mes baux' })).toBeVisible();
await page.locator('app-lease-card').getByRole('button', { name: 'Modifier' }).click();
await page.locator('app-lease-card').getByRole('button', { name: 'Plus d’actions' }).click();
await page.getByRole('menuitem', { name: 'Modifier' }).click();
const persistedEditor = page.locator('app-visale-editor');
await expect(persistedEditor.getByLabel('Numéro de visa Visale')).toHaveValue(
declaration.visaNumber,
@@ -111,7 +113,8 @@ test('declares and removes Visale coverage on a draft lease', async ({ page }) =
expect(updates[1]['visaleGuarantee']).toBeNull();
await expect(page.getByRole('heading', { name: 'Mes baux' })).toBeVisible();
await page.locator('app-lease-card').getByRole('button', { name: 'Modifier' }).click();
await page.locator('app-lease-card').getByRole('button', { name: 'Plus d’actions' }).click();
await page.getByRole('menuitem', { name: 'Modifier' }).click();
await expect(
page
.locator('app-visale-editor')
+2 -1
View File
@@ -124,7 +124,8 @@ test('manages Visale documents from an existing draft lease', async ({ page }) =
await navigateToMenu(page, 'Baux');
await page.waitForURL('**/leases');
await expect(page.getByRole('heading', { name: 'Mes baux' })).toBeVisible();
await page.getByRole('button', { name: 'Modifier' }).click();
await page.locator('app-lease-card').getByRole('button', { name: 'Plus d’actions' }).click();
await page.getByRole('menuitem', { name: 'Modifier' }).click();
const editor = page.locator('app-visale-editor');
await expect(editor.getByLabel('Numéro de visa Visale')).toHaveValue('VISA-123');
+2 -1
View File
@@ -75,7 +75,8 @@ test('downloads an existing Visale visa from a draft lease', async ({ page }) =>
await navigateToMenu(page, 'Baux');
await page.waitForURL('**/leases');
await expect(page.getByRole('heading', { name: 'Mes baux' })).toBeVisible();
await page.locator('app-lease-card').getByRole('button', { name: 'Modifier' }).click();
await page.locator('app-lease-card').getByRole('button', { name: 'Plus d’actions' }).click();
await page.getByRole('menuitem', { name: 'Modifier' }).click();
const editor = page.locator('app-visale-editor');
await expect(editor.getByLabel('Numéro de visa Visale')).toHaveValue('VISA-123');
+2 -2
View File
@@ -18,7 +18,7 @@ $material-colors: (
--surface-accent: oklch(94% 0.035 180);
--text: oklch(24% 0.025 165);
--text-muted: oklch(45% 0.028 165);
--text-subtle: oklch(57% 0.024 165);
--text-subtle: oklch(52% 0.024 165);
--accent: oklch(48% 0.12 180);
--accent-strong: oklch(38% 0.11 180);
--accent-contrast: oklch(100% 0 0);
@@ -65,7 +65,7 @@ $material-colors: (
--surface-accent: oklch(32% 0.07 180);
--text: oklch(96% 0.012 165);
--text-muted: oklch(76% 0.025 165);
--text-subtle: oklch(62% 0.025 165);
--text-subtle: oklch(71% 0.025 165);
--accent: oklch(80% 0.09 180);
--accent-strong: oklch(87% 0.07 180);
--accent-contrast: oklch(25% 0.05 180);
+4
View File
@@ -4,6 +4,7 @@ import { confirmUnsavedChanges } from './services/unsaved-changes.guard';
export const amortizationRoutes: Routes = [
{
path: 'new',
title: 'Nouveau plan d’amortissement — LMNP Organizer',
canDeactivate: [confirmUnsavedChanges],
loadComponent: () =>
import('./components/amortization-create-page/amortization-create-page').then(
@@ -12,6 +13,7 @@ export const amortizationRoutes: Routes = [
},
{
path: 'fiscal-year',
title: 'Amortissements par exercice — LMNP Organizer',
loadComponent: () =>
import('./components/amortization-fiscal-year-page/amortization-fiscal-year-page').then(
({ AmortizationFiscalYearPage }) => AmortizationFiscalYearPage,
@@ -19,6 +21,7 @@ export const amortizationRoutes: Routes = [
},
{
path: ':planId',
title: 'Plan d’amortissement — LMNP Organizer',
canDeactivate: [confirmUnsavedChanges],
loadComponent: () =>
import('./components/amortization-detail-page/amortization-detail-page').then(
@@ -27,6 +30,7 @@ export const amortizationRoutes: Routes = [
},
{
path: '',
title: 'Amortissements — LMNP Organizer',
loadComponent: () =>
import('./components/amortizations-page/amortizations-page').then(
({ AmortizationsPage }) => AmortizationsPage,
+125 -1
View File
@@ -1,5 +1,15 @@
import { describe, expect, it } from 'vitest';
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { provideRouter, Router } from '@angular/router';
import { RouterTestingHarness } from '@angular/router/testing';
import { of } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { routes } from './app.routes';
import { TaxService } from './services/tax.service';
@Component({ standalone: true, template: '' })
class OtherRouteProbe {}
describe('routes', () => {
it('maps every top-level workspace feature to a URL', () => {
@@ -38,6 +48,120 @@ describe('routes', () => {
expect(typeof adminRoute?.loadComponent).toBe('function');
});
it('protects the fiscal profile route when edits are not saved', () => {
const taxRoute = routes.find((route) => route.path === 'tax');
expect(taxRoute?.canDeactivate).toHaveLength(1);
expect(typeof taxRoute?.loadComponent).toBe('function');
});
it('protects apartment, lease, and inventory edits when navigating away', () => {
for (const path of [
'apartments',
'apartments/:apartmentId/edit',
'leases',
'leases/:leaseId/inventory',
]) {
expect(routes.find((route) => route.path === path)?.canDeactivate).toHaveLength(1);
}
});
it('runs the fiscal profile guard during navigation away from a dirty route', async () => {
const taxRoute = routes.find((route) => route.path === 'tax');
if (!taxRoute?.canDeactivate) throw new Error('The fiscal profile route needs a guard.');
const tax = {
profile: () =>
of({
version: -1,
profile: null,
suggestedFirstName: 'Ada',
suggestedLastName: 'Lovelace',
suggestedAddress: '1 rue du Test',
}),
years: () => of({ years: [] }),
};
const dialog = {
open: vi.fn(() => ({ afterClosed: () => of(false) })),
};
TestBed.configureTestingModule({
providers: [
provideRouter([
{
...taxRoute,
},
{ path: 'other', component: OtherRouteProbe },
]),
{ provide: TaxService, useValue: tax },
{ provide: MatDialog, useValue: dialog },
],
});
const harness = await RouterTestingHarness.create('/tax');
const router = TestBed.inject(Router);
const siret =
harness.routeNativeElement?.querySelector<HTMLInputElement>('#tax-profile-siret');
if (!siret) throw new Error('The fiscal profile form did not render.');
siret.value = '73282932000074';
siret.dispatchEvent(new Event('input', { bubbles: true }));
harness.detectChanges();
await harness.navigateByUrl('/other');
expect(router.url).toBe('/tax');
expect(dialog.open).toHaveBeenCalledOnce();
});
it('gives every page route a descriptive LMNP Organizer title', async () => {
const pageRoutes = routes.filter(
(route) =>
typeof route.loadComponent === 'function' ||
typeof route.loadChildren === 'function',
);
expect(
pageRoutes.every(
(route) =>
typeof route.title === 'string' && route.title.endsWith(' — LMNP Organizer'),
),
).toBe(true);
const amortizationRoute = pageRoutes.find((route) => route.path === 'amortizations');
if (typeof amortizationRoute?.loadChildren !== 'function') {
throw new Error('The amortization feature must be lazy-loaded.');
}
const childRoutes = await amortizationRoute.loadChildren();
if (!Array.isArray(childRoutes)) throw new Error('Expected amortization child routes.');
expect(
childRoutes
.filter(
(route) =>
typeof route.loadComponent === 'function' ||
typeof route.loadChildren === 'function',
)
.every(
(route) =>
typeof route.title === 'string' &&
route.title.endsWith(' — LMNP Organizer'),
),
).toBe(true);
});
it('updates the browser title when public and workspace routes are navigated', async () => {
const initialTitle = document.title;
TestBed.configureTestingModule({ providers: [provideRouter(routes)] });
const router = TestBed.inject(Router);
try {
await router.navigateByUrl('/candidatures/postuler/public-token');
expect(document.title).toBe('Candidature locative — LMNP Organizer');
await router.navigateByUrl('/dashboard');
expect(document.title).toBe('Tableau de bord — LMNP Organizer');
} finally {
document.title = initialTitle;
}
});
it('lazy-loads every workspace page and amortization child route', async () => {
const workspaceRoutes = routes.filter((route) => route.path !== '' && route.path !== '**');
expect(workspaceRoutes.every((route) => route.component === undefined)).toBe(true);
+27
View File
@@ -5,6 +5,7 @@ import { confirmUnsavedChanges } from './services/unsaved-changes.guard';
export const routes: Routes = [
{
path: 'candidatures/postuler/:token',
title: 'Candidature locative — LMNP Organizer',
loadComponent: () =>
import('./components/public-application-page/public-application-page').then(
({ PublicApplicationPage }) => PublicApplicationPage,
@@ -12,6 +13,7 @@ export const routes: Routes = [
},
{
path: 'candidatures/reprendre',
title: 'Candidature locative — LMNP Organizer',
canDeactivate: [confirmUnsavedChanges],
loadComponent: () =>
import('./components/candidate-application-page/candidate-application-page').then(
@@ -20,6 +22,7 @@ export const routes: Routes = [
},
{
path: 'applications',
title: 'Candidatures — LMNP Organizer',
loadComponent: () =>
import('./components/applications-page/applications-page').then(
({ ApplicationsPage }) => ApplicationsPage,
@@ -27,6 +30,7 @@ export const routes: Routes = [
},
{
path: 'dashboard',
title: 'Tableau de bord — LMNP Organizer',
loadComponent: () =>
import('./components/dashboard-page/dashboard-page').then(
({ DashboardPage }) => DashboardPage,
@@ -34,6 +38,7 @@ export const routes: Routes = [
},
{
path: 'calendar',
title: 'Calendrier — LMNP Organizer',
loadComponent: () =>
import('./components/calendar-page/calendar-page').then(
({ CalendarPage }) => CalendarPage,
@@ -41,6 +46,8 @@ export const routes: Routes = [
},
{
path: 'apartments/:apartmentId/edit',
title: 'Appartement — LMNP Organizer',
canDeactivate: [confirmUnsavedChanges],
loadComponent: () =>
import('./components/apartments-page/apartments-page').then(
({ ApartmentsPage }) => ApartmentsPage,
@@ -48,6 +55,8 @@ export const routes: Routes = [
},
{
path: 'apartments',
title: 'Appartements — LMNP Organizer',
canDeactivate: [confirmUnsavedChanges],
loadComponent: () =>
import('./components/apartments-page/apartments-page').then(
({ ApartmentsPage }) => ApartmentsPage,
@@ -55,11 +64,13 @@ export const routes: Routes = [
},
{
path: 'tenants',
title: 'Locataires — LMNP Organizer',
loadComponent: () =>
import('./components/tenants-page/tenants-page').then(({ TenantsPage }) => TenantsPage),
},
{
path: 'leases/:leaseId/finalize',
title: 'Finalisation du bail — LMNP Organizer',
loadComponent: () =>
import('./components/lease-finalization-page/lease-finalization-page').then(
({ LeaseFinalizationPage }) => LeaseFinalizationPage,
@@ -67,6 +78,7 @@ export const routes: Routes = [
},
{
path: 'leases/:leaseId/end',
title: 'Fin du bail — LMNP Organizer',
loadComponent: () =>
import('./components/lease-end-page/lease-end-page').then(
({ LeaseEndPage }) => LeaseEndPage,
@@ -74,6 +86,7 @@ export const routes: Routes = [
},
{
path: 'leases/:leaseId/condition-reports/:type',
title: 'État des lieux — LMNP Organizer',
loadComponent: () =>
import('./components/condition-report-page/condition-report-page').then(
({ ConditionReportPage }) => ConditionReportPage,
@@ -81,6 +94,8 @@ export const routes: Routes = [
},
{
path: 'leases/:leaseId/inventory',
title: 'Inventaire du mobilier — LMNP Organizer',
canDeactivate: [confirmUnsavedChanges],
loadComponent: () =>
import('./components/inventory-page/inventory-page').then(
({ InventoryPage }) => InventoryPage,
@@ -88,11 +103,14 @@ export const routes: Routes = [
},
{
path: 'leases',
title: 'Baux — LMNP Organizer',
canDeactivate: [confirmUnsavedChanges],
loadComponent: () =>
import('./components/leases-page/leases-page').then(({ LeasesPage }) => LeasesPage),
},
{
path: 'rent-payments',
title: 'Échéances et règlements — LMNP Organizer',
loadComponent: () =>
import('./components/rent-payments-page/rent-payments-page').then(
({ RentPaymentsPage }) => RentPaymentsPage,
@@ -100,11 +118,13 @@ export const routes: Routes = [
},
{
path: 'amortizations',
title: 'Amortissements — LMNP Organizer',
loadChildren: () =>
import('./amortization.routes').then(({ amortizationRoutes }) => amortizationRoutes),
},
{
path: 'mortgages/:mortgageId',
title: 'Prêt immobilier — LMNP Organizer',
loadComponent: () =>
import('./components/mortgage-detail-page/mortgage-detail-page').then(
({ MortgageDetailPage }) => MortgageDetailPage,
@@ -112,6 +132,7 @@ export const routes: Routes = [
},
{
path: 'mortgages',
title: 'Prêts immobiliers — LMNP Organizer',
loadComponent: () =>
import('./components/mortgages-page/mortgages-page').then(
({ MortgagesPage }) => MortgagesPage,
@@ -119,6 +140,7 @@ export const routes: Routes = [
},
{
path: 'expenses',
title: 'Justificatifs de dépense — LMNP Organizer',
loadComponent: () =>
import('./components/expenses-page/expenses-page').then(
({ ExpensesPage }) => ExpensesPage,
@@ -126,6 +148,7 @@ export const routes: Routes = [
},
{
path: 'tax/:year',
title: 'Exercice fiscal — LMNP Organizer',
loadComponent: () =>
import('./components/tax-year-page/tax-year-page').then(
({ TaxYearPage }) => TaxYearPage,
@@ -133,11 +156,14 @@ export const routes: Routes = [
},
{
path: 'tax',
title: 'Fiscalité — LMNP Organizer',
canDeactivate: [confirmUnsavedChanges],
loadComponent: () =>
import('./components/tax-page/tax-page').then(({ TaxPage }) => TaxPage),
},
{
path: 'admin',
title: 'Administration — LMNP Organizer',
canMatch: [administratorGuard],
loadComponent: () =>
import('./components/administration-page/administration-page').then(
@@ -146,6 +172,7 @@ export const routes: Routes = [
},
{
path: 'settings',
title: 'Réglages — LMNP Organizer',
loadComponent: () =>
import('./components/settings-page/settings-page').then(
({ SettingsPage }) => SettingsPage,
@@ -0,0 +1,23 @@
import { TestBed } from '@angular/core/testing';
import { MatDatepickerIntl } from '@angular/material/datepicker';
import { describe, expect, it } from 'vitest';
import civilDateProviders from './civil-date.providers';
describe('civil date providers', () => {
it('provides French datepicker labels through the shared Material internationalization service', () => {
TestBed.configureTestingModule({ providers: [...civilDateProviders] });
const intl = TestBed.inject(MatDatepickerIntl);
expect(intl.calendarLabel).toBe('Calendrier');
expect(intl.openCalendarLabel).toBe('Ouvrir le calendrier');
expect(intl.closeCalendarLabel).toBe('Fermer le calendrier');
expect(intl.prevMonthLabel).toBe('Mois précédent');
expect(intl.nextMonthLabel).toBe('Mois suivant');
expect(intl.prevYearLabel).toBe('Année précédente');
expect(intl.nextYearLabel).toBe('Année suivante');
expect(intl.prevMultiYearLabel).toBe('24 années précédentes');
expect(intl.nextMultiYearLabel).toBe('24 années suivantes');
expect(intl.switchToMonthViewLabel).toBe('Choisir une date');
expect(intl.switchToMultiYearViewLabel).toBe('Choisir le mois et l’année');
});
});
+32 -1
View File
@@ -1,12 +1,42 @@
import { Provider } from '@angular/core';
import { Injectable, NgModule, Provider } from '@angular/core';
import {
DateAdapter,
MAT_DATE_FORMATS,
MAT_DATE_LOCALE,
MAT_NATIVE_DATE_FORMATS,
} from '@angular/material/core';
import { MatDatepickerIntl, MatDatepickerModule } from '@angular/material/datepicker';
import { CivilDateAdapter } from './services/civil-date-adapter';
@Injectable()
export class FrenchMatDatepickerIntl extends MatDatepickerIntl {
override calendarLabel = 'Calendrier';
override openCalendarLabel = 'Ouvrir le calendrier';
override closeCalendarLabel = 'Fermer le calendrier';
override prevMonthLabel = 'Mois précédent';
override nextMonthLabel = 'Mois suivant';
override prevYearLabel = 'Année précédente';
override nextYearLabel = 'Année suivante';
override prevMultiYearLabel = '24 années précédentes';
override nextMultiYearLabel = '24 années suivantes';
override switchToMonthViewLabel = 'Choisir une date';
override switchToMultiYearViewLabel = 'Choisir le mois et l’année';
override startDateLabel = 'Date de début';
override endDateLabel = 'Date de fin';
override comparisonDateLabel = 'Période de comparaison';
override formatYearRangeLabel(start: string, end: string): string {
return `${start} à ${end}`;
}
}
@NgModule({
imports: [MatDatepickerModule],
exports: [MatDatepickerModule],
providers: [{ provide: MatDatepickerIntl, useClass: FrenchMatDatepickerIntl }],
})
export class CivilDatepickerModule {}
const frenchDateFormats = {
...MAT_NATIVE_DATE_FORMATS,
display: {
@@ -19,6 +49,7 @@ const civilDateProviders: Provider[] = [
{ provide: DateAdapter, useClass: CivilDateAdapter },
{ provide: MAT_DATE_FORMATS, useValue: frenchDateFormats },
{ provide: MAT_DATE_LOCALE, useValue: 'fr-FR' },
{ provide: MatDatepickerIntl, useClass: FrenchMatDatepickerIntl },
];
export default civilDateProviders;
@@ -5,11 +5,6 @@
<h1>Administration</h1>
<p>Consultez l’utilisation technique et fonctionnelle de l’installation.</p>
</div>
@if (loaded()) {
<button mat-stroked-button type="button" [disabled]="loading()" (click)="load()">
Actualiser
</button>
}
</header>
@if (!loaded() && loading()) {
@@ -42,7 +37,7 @@
<div class="usage-grid" [attr.aria-busy]="loading()">
<article class="feature-card usage-card" aria-labelledby="retained-file-count-title">
<p class="eyebrow">Stockage retenu</p>
<h2 id="retained-file-count-title">Fichiers conservés</h2>
<h2 id="retained-file-count-title">Fichiers stockés</h2>
<strong class="usage-count" data-metric="retained-file-count">{{
current.retainedFileCount
}}</strong>
@@ -50,7 +45,7 @@
</article>
<article class="feature-card usage-card" aria-labelledby="retained-bytes-title">
<p class="eyebrow">Stockage retenu</p>
<h2 id="retained-bytes-title">Taille totale conservée</h2>
<h2 id="retained-bytes-title">Espace utilisé</h2>
<strong
class="usage-bytes"
data-metric="retained-bytes"
@@ -63,7 +58,7 @@
</article>
<article class="feature-card usage-card" aria-labelledby="apartment-usage-title">
<p class="eyebrow">Usage fonctionnel</p>
<h2 id="apartment-usage-title">Appartements persistés</h2>
<h2 id="apartment-usage-title">Appartements enregistrés</h2>
<strong class="usage-count" data-metric="apartment-count">{{
current.apartmentCount
}}</strong>
@@ -35,6 +35,15 @@ describe('AdministrationPage', () => {
response.complete();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('header button')).toBeNull();
expect(fixture.nativeElement.textContent).not.toContain('Actualiser');
expect(fixture.nativeElement.textContent).toContain('Fichiers stockés');
expect(fixture.nativeElement.textContent).toContain('Espace utilisé');
expect(fixture.nativeElement.textContent).toContain('Appartements enregistrés');
expect(fixture.nativeElement.textContent).not.toContain('Fichiers conservés');
expect(fixture.nativeElement.textContent).not.toContain('Taille totale conservée');
expect(fixture.nativeElement.textContent).not.toContain('Appartements persistés');
expect(
fixture.nativeElement
.querySelector('[data-metric="retained-file-count"]')
@@ -18,7 +18,14 @@
<div class="field-grid">
<mat-form-field appearance="outline">
<mat-label>Date de mise en service</mat-label>
<input matInput type="date" formControlName="placedInServiceDate" required />
<input
matInput
[matDatepicker]="simpleServiceDatePicker"
formControlName="placedInServiceDate"
required
/>
<mat-datepicker-toggle matIconSuffix [for]="simpleServiceDatePicker" />
<mat-datepicker #simpleServiceDatePicker />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Durée</mat-label>
@@ -55,7 +62,14 @@
>
<mat-form-field appearance="outline">
<mat-label>Date de mise en service</mat-label>
<input matInput type="date" formControlName="placedInServiceDate" required />
<input
matInput
[matDatepicker]="propertyServiceDatePicker"
formControlName="placedInServiceDate"
required
/>
<mat-datepicker-toggle matIconSuffix [for]="propertyServiceDatePicker" />
<mat-datepicker #propertyServiceDatePicker />
</mat-form-field>
<app-property-land-share-fields
[group]="propertyForm.controls.landShare"
@@ -13,6 +13,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { CivilDatepickerModule } from '../../civil-date.providers';
import { MatExpansionModule } from '@angular/material/expansion';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
@@ -44,6 +45,7 @@ import {
ReactiveFormsModule,
MatButtonModule,
MatCheckboxModule,
CivilDatepickerModule,
MatExpansionModule,
MatFormFieldModule,
MatInputModule,
@@ -1,5 +1,9 @@
@if (apartmentId; as propertyId) {
<app-property-amortization-create [apartmentId]="propertyId" />
<app-property-amortization-create
[apartmentId]="propertyId"
[returnTo]="returnTo"
[returnFiscalYear]="returnFiscalYear"
/>
} @else {
<app-simple-amortization-create [expenseId]="expenseId" />
}
@@ -42,7 +42,7 @@ describe('AmortizationCreatePage', () => {
it('shows immutable source facts and validates the two required parameters', async () => {
expect(fixture.nativeElement.textContent).toContain('Mobilier du logement');
expect(fixture.nativeElement.textContent.replace(/\s/g, ' ')).toContain('1 200,00');
expect(fixture.nativeElement.textContent.replace(/\s/g, ' ')).toContain('1 200 €');
expect(fixture.nativeElement.textContent).toContain('10 août 2026');
submit();
await fixture.whenStable();
@@ -204,6 +204,45 @@ describe('AmortizationCreatePage', () => {
}
});
describe('AmortizationCreatePage property route', () => {
it('passes the selected apartment and fiscal-year return context to the property form', () => {
TestBed.configureTestingModule({
imports: [AmortizationCreatePage],
providers: [
provideHttpClient(),
provideHttpClientTesting(),
provideRouter([{ path: 'amortizations/:planId', component: EmptyRouteComponent }]),
{
provide: ActivatedRoute,
useValue: {
snapshot: {
queryParamMap: convertToParamMap({
apartment: 'apartment-1',
returnTo: '/amortizations/fiscal-year',
fiscalYear: '2025',
}),
},
},
},
],
});
const fixture = TestBed.createComponent(AmortizationCreatePage);
const http = TestBed.inject(HttpTestingController);
fixture.detectChanges();
http.expectOne('/api/amortizations/property-sources/apartment-1').flush(propertySource());
http.expectOne('/api/amortizations/opening-balance-availability').flush({
referenceFiscalYear: null,
});
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Lilas');
const returnLink = fixture.nativeElement.querySelector('a') as HTMLAnchorElement;
expect(returnLink.href).toContain('/amortizations/fiscal-year');
expect(returnLink.href).toContain('fiscalYear=2025');
http.verify();
});
});
@Component({ template: '' })
class EmptyRouteComponent {}
@@ -220,6 +259,35 @@ function source() {
};
}
function propertySource() {
return {
apartmentId: 'apartment-1',
apartmentName: 'Lilas',
purchasePrice: 200000,
acquisitionDate: '2024-06-15',
acquisitionCostTreatment: 'CAPITALIZATION',
acquisitionFees: [
{ documentId: 'document-1', expenseId: 'fee-1', label: 'Notaire', amount: 1200 },
],
templateVersion: 'LOGEMENT_STANDARD_V1',
officialReferenceId: 'BOI-ANNX-000115',
officialReferenceDate: '2014-10-13',
templateComponents: [
{
key: 'structure',
standard: 'STRUCTURE',
label: 'Gros œuvre',
ratio: 100,
durationInYears: 60,
minimumRatio: 1,
maximumRatio: 100,
minimumDuration: 1,
maximumDuration: 200,
},
],
};
}
function preview(years: number, placedInServiceDate = '2030-02-28') {
const startYear = Number(placedInServiceDate.slice(0, 4));
const dateSuffix = placedInServiceDate.slice(4);
@@ -14,6 +14,10 @@ export class AmortizationCreatePage {
private readonly simpleCreator = viewChild(SimpleAmortizationCreate);
readonly apartmentId = this.route.snapshot.queryParamMap.get('apartment');
readonly expenseId = this.route.snapshot.queryParamMap.get('expense');
readonly returnTo = returnRoute(this.route.snapshot.queryParamMap.get('returnTo'));
readonly returnFiscalYear = fiscalYearFromQuery(
this.route.snapshot.queryParamMap.get('fiscalYear'),
);
hasUnsavedChanges(): boolean {
return (
@@ -23,3 +27,14 @@ export class AmortizationCreatePage {
);
}
}
function returnRoute(value: string | null): string | null {
return value === '/amortizations/fiscal-year' || value === 'fiscal-year'
? '/amortizations/fiscal-year'
: null;
}
function fiscalYearFromQuery(value: string | null): number | null {
const year = Number(value);
return Number.isInteger(year) && year >= 1 && year <= 9999 ? year : null;
}
@@ -233,31 +233,31 @@
<ng-container matColumnDef="openingNetBookValue"
><th mat-header-cell *matHeaderCellDef scope="col">VNC d’ouverture</th>
<td mat-cell *matCellDef="let line">
{{ formatMoney(line.openingNetBookValue) }}
{{ formatTableMoney(line.openingNetBookValue) }}
</td></ng-container
>
<ng-container matColumnDef="placedInService"
><th mat-header-cell *matHeaderCellDef scope="col">Mise en service</th>
<td mat-cell *matCellDef="let line">
{{ formatMoney(line.placedInService) }}
{{ formatTableMoney(line.placedInService) }}
</td></ng-container
>
<ng-container matColumnDef="depreciationCharge"
><th mat-header-cell *matHeaderCellDef scope="col">Dotation</th>
<td mat-cell *matCellDef="let line">
{{ formatMoney(line.depreciationCharge) }}
{{ formatTableMoney(line.depreciationCharge) }}
</td></ng-container
>
<ng-container matColumnDef="retiredNetBookValue"
><th mat-header-cell *matHeaderCellDef scope="col">VNC sortie</th>
<td mat-cell *matCellDef="let line">
{{ formatMoney(line.retiredNetBookValue) }}
{{ formatTableMoney(line.retiredNetBookValue) }}
</td></ng-container
>
<ng-container matColumnDef="closingNetBookValue"
><th mat-header-cell *matHeaderCellDef scope="col">VNC de clôture</th>
<td mat-cell *matCellDef="let line">
{{ formatMoney(line.closingNetBookValue) }}
{{ formatTableMoney(line.closingNetBookValue) }}
</td></ng-container
>
<ng-container matColumnDef="segments"
@@ -267,14 +267,14 @@
<summary>Segments</summary>
<p>
Total exact avant arrondi :
{{ line.exactDepreciationCharge }}
{{ formatTableMoney(line.exactDepreciationCharge) }}
</p>
@for (segment of line.segments; track segment.startInclusive) {
<p>
{{ formatDate(segment.startInclusive) }} →
{{ formatDate(segment.endExclusive) }} ·
{{ segment.durationInDays }} jours 30/360 · base
{{ formatMoney(segment.base) }}
{{ formatTableMoney(segment.base) }}
</p>
}
@for (
@@ -301,7 +301,7 @@
<p>
{{ retirementReason(retirement.reason) }} le
{{ formatDate(retirement.effectiveDate) }} · brut
{{ formatMoney(retirement.grossAmount) }}
{{ formatTableMoney(retirement.grossAmount) }}
</p>
}
</details>
@@ -142,6 +142,29 @@ describe('AmortizationDetailPage', () => {
http.expectNone('/api/amortizations/plan-1');
});
it('keeps schedule amounts at two decimals', () => {
const detail = plan();
const baseSchedule = schedule();
const fractionalSchedule = {
...baseSchedule,
lines: baseSchedule.lines.map((line, index) =>
index === 0 ? { ...line, depreciationCharge: 400.5 } : line,
),
};
http.expectOne('/api/amortizations/plan-1').flush({
...detail,
schedule: fractionalSchedule,
components: [{ ...detail.components[0], schedule: fractionalSchedule }],
});
http.expectOne('/api/amortizations/opening-balance-availability').flush({
referenceFiscalYear: 2025,
});
fixture.detectChanges();
const tableText = fixture.nativeElement.querySelector('table')?.textContent ?? '';
expect(tableText.replace(/\s+/g, ' ')).toContain('400,50 €');
});
it('keeps lifecycle histories available after a plan is retired', () => {
http.expectOne('/api/amortizations/plan-1').flush({
...plan(),
@@ -15,7 +15,7 @@ import {
import { formatFrenchCivilDate } from '../../services/civil-date';
import { FileDownloadService } from '../../services/file-download.service';
import { httpErrorMessage } from '../../services/http-error-message';
import { formatEuro } from '../../services/rent-payment-format';
import { formatAccountingEuro, formatEuro } from '../../services/rent-payment-format';
import { AmortizationOpeningBalanceEditor } from '../amortization-opening-balance-editor/amortization-opening-balance-editor';
import { AmortizationCorrectionEditor } from '../amortization-correction-editor/amortization-correction-editor';
import { AmortizationReplacementEditor } from '../amortization-replacement-editor/amortization-replacement-editor';
@@ -118,6 +118,7 @@ export class AmortizationDetailPage {
formatDate = formatFrenchCivilDate;
formatMoney = formatEuro;
formatTableMoney = formatAccountingEuro;
trackLine = (_index: number, line: DepreciationLine) => line.fiscalYear;
downloadLandDocument(): void {
@@ -45,7 +45,18 @@
<ul>
@for (blocker of current.blockers; track blocker.type + blocker.sourceId) {
<li>
<strong>{{ blockerLabel(blocker.type) }}</strong> — {{ blocker.label }}
<span>
<strong>{{ blockerLabel(blocker.type) }}</strong> —
{{ blocker.label }}
</span>
@if (planConfigurationQuery(blocker, current.fiscalYear); as query) {
<a
mat-stroked-button
[routerLink]="['/amortizations/new']"
[queryParams]="query"
>Configurer le plan</a
>
}
</li>
}
</ul>
@@ -75,25 +86,25 @@
<ng-container matColumnDef="openingNetBookValue">
<th mat-header-cell *matHeaderCellDef scope="col">VNC d’ouverture</th>
<td mat-cell *matCellDef="let line">
{{ formatMoney(line.openingNetBookValue) }}
{{ formatTableMoney(line.openingNetBookValue) }}
</td>
</ng-container>
<ng-container matColumnDef="placedInService">
<th mat-header-cell *matHeaderCellDef scope="col">Mise en service</th>
<td mat-cell *matCellDef="let line">
{{ formatMoney(line.placedInService) }}
{{ formatTableMoney(line.placedInService) }}
</td>
</ng-container>
<ng-container matColumnDef="depreciationCharge">
<th mat-header-cell *matHeaderCellDef scope="col">Dotation</th>
<td mat-cell *matCellDef="let line">
{{ formatMoney(line.depreciationCharge) }}
{{ formatTableMoney(line.depreciationCharge) }}
</td>
</ng-container>
<ng-container matColumnDef="retiredNetBookValue">
<th mat-header-cell *matHeaderCellDef scope="col">VNC sortie</th>
<td mat-cell *matCellDef="let line">
{{ formatMoney(line.retiredNetBookValue) }}
{{ formatTableMoney(line.retiredNetBookValue) }}
</td>
</ng-container>
<ng-container matColumnDef="retirements">
@@ -118,7 +129,7 @@
<ng-container matColumnDef="closingNetBookValue">
<th mat-header-cell *matHeaderCellDef scope="col">VNC de clôture</th>
<td mat-cell *matCellDef="let line">
{{ formatMoney(line.closingNetBookValue) }}
{{ formatTableMoney(line.closingNetBookValue) }}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
@@ -34,6 +34,14 @@ form {
margin: 0;
}
.blocker-card li {
display: flex;
align-items: center;
justify-content: space-between;
gap: tokens.space(3);
flex-wrap: wrap;
}
.success-card {
color: var(--accent-strong);
}
@@ -41,6 +41,12 @@ describe('AmortizationFiscalYearPage', () => {
},
],
blockers: [
{
type: 'APARTMENT_WITHOUT_PLAN',
sourceId: 'apartment-1',
apartmentId: 'apartment-1',
label: 'Lilas',
},
{
type: 'EXPENSE_WITHOUT_PLAN',
sourceId: 'expense-2',
@@ -53,6 +59,14 @@ describe('AmortizationFiscalYearPage', () => {
expect(fixture.nativeElement.textContent).toContain('Four');
expect(fixture.nativeElement.textContent).toContain('300,00');
expect(fixture.nativeElement.textContent).toContain('€');
expect(fixture.nativeElement.textContent).toContain('Clôture bloquée');
expect(fixture.nativeElement.textContent).toContain('Configurer le plan');
const configureLink = fixture.nativeElement.querySelector(
'a[href*="/amortizations/new"]',
) as HTMLAnchorElement;
expect(configureLink.href).toContain('apartment=apartment-1');
expect(configureLink.href).toContain('fiscalYear=' + year);
expect(configureLink.href).toContain('returnTo=');
});
});
@@ -6,7 +6,7 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatTableModule } from '@angular/material/table';
import { RouterLink } from '@angular/router';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { finalize } from 'rxjs';
import {
AmortizationService,
@@ -21,7 +21,7 @@ import {
} from '../../services/civil-date';
import { httpErrorMessage } from '../../services/http-error-message';
import { integerRange } from '../../services/integer-range-validator';
import { formatEuro } from '../../services/rent-payment-format';
import { formatAccountingEuro, formatEuro } from '../../services/rent-payment-format';
@Component({
selector: 'app-amortization-fiscal-year-page',
@@ -40,6 +40,7 @@ import { formatEuro } from '../../services/rent-payment-format';
export class AmortizationFiscalYearPage {
private readonly amortizations = inject(AmortizationService);
private readonly destroyRef = inject(DestroyRef);
private readonly route = inject(ActivatedRoute);
readonly form = new FormGroup({
fiscalYear: new FormControl(Number(todayInTimeZone(propertyCivilTimeZone).slice(0, 4)), {
nonNullable: true,
@@ -61,6 +62,10 @@ export class AmortizationFiscalYearPage {
];
constructor() {
const requestedYear = fiscalYearFromQuery(
this.route.snapshot.queryParamMap.get('fiscalYear'),
);
if (requestedYear !== null) this.form.controls.fiscalYear.setValue(requestedYear);
this.load();
}
@@ -95,9 +100,27 @@ export class AmortizationFiscalYearPage {
: 'Base d’acquisition désynchronisée';
}
planConfigurationQuery(
blocker: FiscalYearView['blockers'][number],
fiscalYear: number,
): Record<string, string | number> | null {
if (blocker.type !== 'APARTMENT_WITHOUT_PLAN' || !blocker.apartmentId) return null;
return {
apartment: blocker.apartmentId,
returnTo: '/amortizations/fiscal-year',
fiscalYear,
};
}
trackLine = (_index: number, line: FiscalYearAccountingLine) =>
`${line.planId}:${line.componentId}`;
formatMoney = formatEuro;
formatTableMoney = formatAccountingEuro;
formatDate = formatFrenchCivilDate;
retirementReason = formatRetirementReason;
}
function fiscalYearFromQuery(value: string | null): number | null {
const year = Number(value);
return Number.isInteger(year) && year >= 1 && year <= 9999 ? year : null;
}
@@ -120,9 +120,11 @@
<input
[id]="'opening-end-' + index"
matInput
type="date"
[matDatepicker]="openingEndPicker"
formControlName="plannedEndDate"
/>
<mat-datepicker-toggle matIconSuffix [for]="openingEndPicker" />
<mat-datepicker #openingEndPicker />
@if (
form.controls.componentBalances
.at(index)
@@ -3,6 +3,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormArray, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatButtonModule } from '@angular/material/button';
import { CivilDatepickerModule } from '../../civil-date.providers';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
@@ -34,6 +35,7 @@ type BalanceGroup = FormGroup<{
ReactiveFormsModule,
MatButtonModule,
MatCheckboxModule,
CivilDatepickerModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
Loaded 100 of 267 files, more files were not shown because too many files have changed in this diff. Show more