feat: add lease end page and resolution forms with necessary services and components
- Implemented LeaseEndPage component for managing lease end processes. - Created LeaseEndResolutionForms component for handling agreement and closure submissions. - Added civil date formatting utilities for French date representation. - Developed styles for lease end resolution forms and rent due correction components. - Integrated HTTP service for lease end operations including notice recording, withdrawal acceptance, and closure. - Added unit tests for new components and services to ensure functionality.
This commit is contained in:
1 parent
3d42c9a256
commit
c1e6b905d5
114 files changed
+6582
-345
No files matched your search
+11
-5
@@ -1,11 +1,11 @@
|
||||
package fr.nessar.lmnporganizer.rental.adapter.in.config;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.ActivateLeaseService;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.ArchiveLeaseService;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.GenerateLeaseDocumentService;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.LeaseDraftService;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.LeaseClauseCatalog;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.LeaseFinalizationService;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.LeaseEndingService;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.AmendLeaseService;
|
||||
import fr.nessar.lmnporganizer.rental.application.landlord.LandlordAccountService;
|
||||
import fr.nessar.lmnporganizer.rental.application.apartment.ApartmentCatalogService;
|
||||
@@ -102,10 +102,11 @@ public class RentalApplicationConfiguration {
|
||||
|
||||
@Bean
|
||||
AmendLeaseService amendLeaseService(LeaseRepository leases, TenantRepository tenants,
|
||||
RentDueRepository rentDues, ApartmentOccupancyRepository occupancies,
|
||||
RentDueRepository rentDues, RentRevisionRepository revisions,
|
||||
ApartmentOccupancyRepository occupancies,
|
||||
TransactionRunner transactions, IdentifierGenerator identifiers,
|
||||
Clock rentalClock) {
|
||||
return new AmendLeaseService(leases, tenants, rentDues, occupancies, transactions, identifiers,
|
||||
return new AmendLeaseService(leases, tenants, rentDues, revisions, occupancies, transactions, identifiers,
|
||||
rentalClock);
|
||||
}
|
||||
|
||||
@@ -120,8 +121,13 @@ public class RentalApplicationConfiguration {
|
||||
}
|
||||
|
||||
@Bean
|
||||
ArchiveLeaseService archiveLeaseService(LeaseRepository leases) {
|
||||
return new ArchiveLeaseService(leases);
|
||||
LeaseEndingService leaseEndingService(LeaseRepository leases, ApartmentOccupancyRepository occupancies,
|
||||
RentDueRepository rentDues, RentRevisionRepository revisions,
|
||||
LeaseDocumentStorage storage,
|
||||
TransactionRunner transactions, IdentifierGenerator identifiers,
|
||||
Clock rentalClock, RentalAttachmentProperties properties) {
|
||||
return new LeaseEndingService(leases, occupancies, rentDues, revisions, storage, transactions, identifiers,
|
||||
rentalClock, properties.maxSize().toBytes());
|
||||
}
|
||||
|
||||
@Bean
|
||||
|
||||
+26
-8
@@ -11,6 +11,7 @@ import fr.nessar.lmnporganizer.rental.domain.lease.LeaseContractVersion;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentAnnex;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentWarning;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseLegalTerms;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRuleViolation;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseStatus;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSnapshot;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSpecialClause;
|
||||
@@ -27,8 +28,10 @@ import jakarta.validation.constraints.PositiveOrZero;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.DateTimeException;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.MonthDay;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -67,13 +70,25 @@ final class LeaseApi {
|
||||
}
|
||||
}
|
||||
|
||||
record RentRevisionRequest(Boolean enabled, LocalDate annualRevisionDate,
|
||||
record RentRevisionRequest(Boolean enabled, @Min(1) @Max(31) Integer annualRevisionDay,
|
||||
@Min(1) @Max(12) Integer annualRevisionMonth,
|
||||
@Min(1) @Max(4) Integer referenceQuarter,
|
||||
@Positive Integer referenceYear, @Size(max = 100) String seriesId) {
|
||||
LeaseLegalTerms.RentRevisionTerms toDomain() {
|
||||
return new LeaseLegalTerms.RentRevisionTerms(Boolean.TRUE.equals(enabled), annualRevisionDate,
|
||||
return new LeaseLegalTerms.RentRevisionTerms(Boolean.TRUE.equals(enabled), toMonthDay(),
|
||||
referenceQuarter, referenceYear, seriesId);
|
||||
}
|
||||
|
||||
private MonthDay toMonthDay() {
|
||||
if (annualRevisionDay == null || annualRevisionMonth == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return MonthDay.of(annualRevisionMonth, annualRevisionDay);
|
||||
} catch (DateTimeException exception) {
|
||||
throw new LeaseRuleViolation("Le jour annuel de révision IRL est invalide.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
record RecentWorkRequest(@Size(max = 4000) String description, @PositiveOrZero BigDecimal amount) {
|
||||
@@ -109,7 +124,8 @@ final class LeaseApi {
|
||||
@Valid LegalTermsRequest legalTerms) {
|
||||
}
|
||||
|
||||
record AmendmentRequest(@NotNull LocalDate effectiveDate, @NotEmpty List<@NotBlank String> tenantIds,
|
||||
record AmendmentRequest(@NotNull LocalDate effectiveDate, @NotBlank String timeZone,
|
||||
@NotEmpty List<@NotBlank String> tenantIds,
|
||||
List<@Valid GuarantorRequest> guarantors, @NotNull LocalDate endDate,
|
||||
@NotNull @Positive BigDecimal monthlyRent,
|
||||
@NotNull @PositiveOrZero BigDecimal monthlyCharges,
|
||||
@@ -150,11 +166,13 @@ final class LeaseApi {
|
||||
}
|
||||
}
|
||||
|
||||
record RentRevisionResponse(boolean enabled, LocalDate annualRevisionDate, Integer referenceQuarter,
|
||||
Integer referenceYear, String seriesId) {
|
||||
record RentRevisionResponse(boolean enabled, Integer annualRevisionDay, Integer annualRevisionMonth,
|
||||
Integer referenceQuarter, Integer referenceYear, String seriesId) {
|
||||
static RentRevisionResponse from(LeaseLegalTerms.RentRevisionTerms terms) {
|
||||
return new RentRevisionResponse(terms.enabled(), terms.annualRevisionDate(), terms.referenceQuarter(),
|
||||
terms.referenceYear(), terms.seriesId());
|
||||
MonthDay day = terms.annualRevisionDay();
|
||||
return new RentRevisionResponse(terms.enabled(), day == null ? null : day.getDayOfMonth(),
|
||||
day == null ? null : day.getMonthValue(), terms.referenceQuarter(), terms.referenceYear(),
|
||||
terms.seriesId());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+12
-10
@@ -2,7 +2,6 @@ package fr.nessar.lmnporganizer.rental.adapter.in.web.lease;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlord;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.AmendLeaseService;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.ArchiveLeaseService;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.GenerateLeaseDocumentService;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.LeaseDraftService;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.GeneratedDocument;
|
||||
@@ -32,7 +31,9 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.DateTimeException;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -40,17 +41,15 @@ import java.util.UUID;
|
||||
@RequestMapping("/api/leases")
|
||||
public class LeaseController {
|
||||
private final LeaseDraftService drafts;
|
||||
private final ArchiveLeaseService archiving;
|
||||
private final AmendLeaseService amendments;
|
||||
private final GenerateLeaseDocumentService documents;
|
||||
private final AuthenticatedLandlord authenticatedLandlord;
|
||||
private final Clock clock;
|
||||
|
||||
public LeaseController(LeaseDraftService drafts, ArchiveLeaseService archiving, AmendLeaseService amendments,
|
||||
public LeaseController(LeaseDraftService drafts, AmendLeaseService amendments,
|
||||
GenerateLeaseDocumentService documents,
|
||||
AuthenticatedLandlord authenticatedLandlord, Clock clock) {
|
||||
this.drafts = drafts;
|
||||
this.archiving = archiving;
|
||||
this.amendments = amendments;
|
||||
this.documents = documents;
|
||||
this.authenticatedLandlord = authenticatedLandlord;
|
||||
@@ -86,11 +85,6 @@ public class LeaseController {
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping("/{leaseId}/archive")
|
||||
LeaseApi.Response archive(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId) {
|
||||
return response(archiving.archive(authenticatedLandlord.id(principal), new LeaseId(leaseId)));
|
||||
}
|
||||
|
||||
@PostMapping("/{leaseId}/amendments")
|
||||
ResponseEntity<LeaseApi.Response> amend(@AuthenticationPrincipal OidcUser principal,
|
||||
@PathVariable String leaseId,
|
||||
@@ -99,7 +93,7 @@ public class LeaseController {
|
||||
request.endDate(), request.monthlyRent(), request.monthlyCharges(), request.dueDay(),
|
||||
request.chargeMethod(), request.indexFlatRate());
|
||||
var amended = amendments.amend(authenticatedLandlord.id(principal), new LeaseId(leaseId),
|
||||
request.effectiveDate(), change);
|
||||
request.effectiveDate(), zone(request.timeZone()), change);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(response(amended));
|
||||
}
|
||||
|
||||
@@ -160,6 +154,14 @@ public class LeaseController {
|
||||
return LeaseApi.Response.from(lease, LocalDate.now(clock));
|
||||
}
|
||||
|
||||
private static ZoneId zone(String value) {
|
||||
try {
|
||||
return ZoneId.of(value);
|
||||
} catch (DateTimeException failure) {
|
||||
throw new IllegalArgumentException("Le fuseau horaire IANA est invalide.", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<TenantId> tenantIds(List<String> ids) {
|
||||
return ids.stream().map(TenantId::new).toList();
|
||||
}
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
package fr.nessar.lmnporganizer.rental.adapter.in.web.lease;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.LeaseEndingState;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.AgreedEarlyLeaseEnd;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseClosure;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseEndPhase;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseEndingHistory;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseType;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.NoticeDeliveryMode;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.NoticeEvidenceVersion;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.NoticeInvalidation;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.NoticeWithdrawal;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.TenantNotice;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.TenantNoticeStatus;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
final class LeaseEndingApi {
|
||||
private LeaseEndingApi() {
|
||||
}
|
||||
|
||||
record StateResponse(String leaseId, boolean supported, String unsupportedReason, LeaseEndPhase phase,
|
||||
LeaseType leaseType, LocalDate fixedEndOn, LocalDate effectiveEndOn,
|
||||
boolean correctionRequired, List<NoticeResponse> notices,
|
||||
AgreedEarlyEndResponse agreedEarlyEnd, List<AgreedEarlyEndResponse> agreedEarlyEnds,
|
||||
ClosureResponse closure) {
|
||||
static StateResponse from(LeaseEndingState state) {
|
||||
LeaseEndingHistory history = state.history();
|
||||
return new StateResponse(state.leaseId().value(), state.supported(),
|
||||
state.unsupportedReason().orElse(null), state.phase(), state.leaseType(),
|
||||
state.fixedEndOn().orElse(null), state.effectiveEndOn().orElse(null),
|
||||
state.correctionRequired(), history.notices().stream()
|
||||
.map(notice -> NoticeResponse.from(notice, history)).toList(),
|
||||
history.latestAgreement().map(AgreedEarlyEndResponse::from).orElse(null),
|
||||
history.agreedEarlyEnds().stream().map(AgreedEarlyEndResponse::from).toList(),
|
||||
history.closure().map(ClosureResponse::from).orElse(null));
|
||||
}
|
||||
}
|
||||
|
||||
record NoticeResponse(String id, TenantNoticeStatus status, LocalDate receivedOn,
|
||||
LocalDate requestedDepartureOn, NoticeDeliveryMode deliveryMode,
|
||||
String otherDeliveryMode, int noticeMonths, LocalDate minimumEndOn,
|
||||
String ruleVersion, boolean recordedWithoutEvidenceConfirmed,
|
||||
String authorId, Instant recordedAt, List<EvidenceResponse> evidenceVersions,
|
||||
WithdrawalResponse withdrawal, InvalidationResponse invalidation) {
|
||||
static NoticeResponse from(TenantNotice notice, LeaseEndingHistory history) {
|
||||
NoticeWithdrawal withdrawal = history.withdrawals().stream()
|
||||
.filter(fact -> fact.noticeId().equals(notice.id())).findFirst().orElse(null);
|
||||
NoticeInvalidation invalidation = history.invalidations().stream()
|
||||
.filter(fact -> fact.noticeId().equals(notice.id())).findFirst().orElse(null);
|
||||
return new NoticeResponse(notice.id().value(), notice.status(), notice.receivedOn(),
|
||||
notice.requestedDepartureOn(), notice.deliveryMode(), notice.otherDeliveryMode(),
|
||||
notice.noticeMonths(), notice.minimumEndOn(), notice.ruleVersion(),
|
||||
notice.recordedWithoutEvidenceConfirmed(), notice.audit().authorId(),
|
||||
notice.audit().recordedAt(), history.evidenceVersions(notice.id()).stream()
|
||||
.map(EvidenceResponse::from).toList(),
|
||||
withdrawal == null ? null : WithdrawalResponse.from(withdrawal),
|
||||
invalidation == null ? null : InvalidationResponse.from(invalidation));
|
||||
}
|
||||
}
|
||||
|
||||
record EvidenceResponse(int version, String fileName, String contentType, long size, String sha256,
|
||||
String reason, String authorId, Instant recordedAt) {
|
||||
static EvidenceResponse from(NoticeEvidenceVersion evidence) {
|
||||
return new EvidenceResponse(evidence.version(), evidence.file().fileName(),
|
||||
evidence.file().contentType(), evidence.file().size(), evidence.file().sha256(),
|
||||
evidence.reason(), evidence.audit().authorId(), evidence.audit().recordedAt());
|
||||
}
|
||||
}
|
||||
|
||||
record WithdrawalResponse(LocalDate acceptedOn, String reason, String authorId, Instant recordedAt) {
|
||||
static WithdrawalResponse from(NoticeWithdrawal withdrawal) {
|
||||
return new WithdrawalResponse(withdrawal.acceptedOn(), withdrawal.reason(),
|
||||
withdrawal.audit().authorId(), withdrawal.audit().recordedAt());
|
||||
}
|
||||
}
|
||||
|
||||
record InvalidationResponse(LocalDate invalidatedOn, String reason, String authorId, Instant recordedAt) {
|
||||
static InvalidationResponse from(NoticeInvalidation invalidation) {
|
||||
return new InvalidationResponse(invalidation.invalidatedOn(), invalidation.reason(),
|
||||
invalidation.audit().authorId(), invalidation.audit().recordedAt());
|
||||
}
|
||||
}
|
||||
|
||||
record AgreedEarlyEndResponse(LocalDate endOn, String reason, String authorId, Instant recordedAt) {
|
||||
static AgreedEarlyEndResponse from(AgreedEarlyLeaseEnd agreement) {
|
||||
return new AgreedEarlyEndResponse(agreement.endOn(), agreement.reason(),
|
||||
agreement.audit().authorId(), agreement.audit().recordedAt());
|
||||
}
|
||||
}
|
||||
|
||||
record ClosureResponse(LocalDate keysReturnedOn, String authorId, Instant recordedAt) {
|
||||
static ClosureResponse from(LeaseClosure closure) {
|
||||
return new ClosureResponse(closure.keysReturnedOn(), closure.audit().authorId(),
|
||||
closure.audit().recordedAt());
|
||||
}
|
||||
}
|
||||
}
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
package fr.nessar.lmnporganizer.rental.adapter.in.web.lease;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlord;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.LeaseEndingService;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentStorageFailure;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.NoticeDeliveryMode;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.TenantNoticeId;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.time.DateTimeException;
|
||||
|
||||
@RestController("rentalLeaseEndingController")
|
||||
@RequestMapping("/api/leases/{leaseId}/end")
|
||||
public class LeaseEndingController {
|
||||
private final LeaseEndingService endings;
|
||||
private final AuthenticatedLandlord authenticatedLandlord;
|
||||
|
||||
public LeaseEndingController(LeaseEndingService endings, AuthenticatedLandlord authenticatedLandlord) {
|
||||
this.endings = endings;
|
||||
this.authenticatedLandlord = authenticatedLandlord;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
LeaseEndingApi.StateResponse state(@AuthenticationPrincipal OidcUser principal,
|
||||
@PathVariable String leaseId,
|
||||
@RequestParam String timeZone) {
|
||||
return LeaseEndingApi.StateResponse.from(endings.state(authenticatedLandlord.id(principal),
|
||||
new LeaseId(leaseId), zone(timeZone)));
|
||||
}
|
||||
|
||||
@PostMapping(path = "/notices", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
ResponseEntity<LeaseEndingApi.StateResponse> recordNotice(
|
||||
@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId,
|
||||
@RequestParam String timeZone, @RequestParam LocalDate receivedOn,
|
||||
@RequestParam LocalDate requestedDepartureOn, @RequestParam NoticeDeliveryMode deliveryMode,
|
||||
@RequestParam(required = false) String otherDeliveryMode,
|
||||
@RequestParam boolean lateRegistrationConfirmed,
|
||||
@RequestParam boolean nonStandardDeliveryConfirmed,
|
||||
@RequestParam boolean missingEvidenceConfirmed,
|
||||
@RequestPart(value = "evidence", required = false) MultipartFile evidence) {
|
||||
var state = endings.recordNotice(authenticatedLandlord.id(principal), new LeaseId(leaseId),
|
||||
principal.getSubject(), new LeaseEndingService.RecordNotice(zone(timeZone), receivedOn,
|
||||
requestedDepartureOn, deliveryMode, otherDeliveryMode, lateRegistrationConfirmed,
|
||||
nonStandardDeliveryConfirmed, missingEvidenceConfirmed), upload(evidence, timeZone, null));
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(LeaseEndingApi.StateResponse.from(state));
|
||||
}
|
||||
|
||||
@PostMapping(path = "/notices/{noticeId}/evidence", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
LeaseEndingApi.StateResponse appendEvidence(
|
||||
@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId,
|
||||
@PathVariable String noticeId, @RequestParam String timeZone,
|
||||
@RequestPart("evidence") MultipartFile evidence, @RequestParam(required = false) String reason) {
|
||||
return LeaseEndingApi.StateResponse.from(endings.appendEvidence(authenticatedLandlord.id(principal),
|
||||
new LeaseId(leaseId), new TenantNoticeId(noticeId), principal.getSubject(),
|
||||
upload(evidence, timeZone, reason)));
|
||||
}
|
||||
|
||||
@GetMapping("/notices/{noticeId}/evidence/{version}")
|
||||
ResponseEntity<byte[]> evidence(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId,
|
||||
@PathVariable String noticeId, @PathVariable int version) {
|
||||
var evidence = endings.evidence(authenticatedLandlord.id(principal), new LeaseId(leaseId),
|
||||
new TenantNoticeId(noticeId), version);
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_OCTET_STREAM_VALUE)
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
|
||||
.filename(safeFileName(evidence.fileName())).build().toString())
|
||||
.header("X-Content-Type-Options", "nosniff")
|
||||
.body(evidence.content());
|
||||
}
|
||||
|
||||
@PostMapping("/notices/{noticeId}/withdrawals")
|
||||
LeaseEndingApi.StateResponse withdraw(@AuthenticationPrincipal OidcUser principal,
|
||||
@PathVariable String leaseId, @PathVariable String noticeId,
|
||||
@Valid @RequestBody WithdrawalRequest request) {
|
||||
return LeaseEndingApi.StateResponse.from(endings.acceptWithdrawal(authenticatedLandlord.id(principal),
|
||||
new LeaseId(leaseId), new TenantNoticeId(noticeId), principal.getSubject(),
|
||||
new LeaseEndingService.AdministrativeFact(zone(request.timeZone()), request.acceptedOn(),
|
||||
request.reason())));
|
||||
}
|
||||
|
||||
@PostMapping("/notices/{noticeId}/invalidations")
|
||||
LeaseEndingApi.StateResponse invalidate(@AuthenticationPrincipal OidcUser principal,
|
||||
@PathVariable String leaseId, @PathVariable String noticeId,
|
||||
@Valid @RequestBody InvalidationRequest request) {
|
||||
return LeaseEndingApi.StateResponse.from(endings.invalidateNotice(authenticatedLandlord.id(principal),
|
||||
new LeaseId(leaseId), new TenantNoticeId(noticeId), principal.getSubject(),
|
||||
new LeaseEndingService.AdministrativeFact(zone(request.timeZone()), request.invalidatedOn(),
|
||||
request.reason())));
|
||||
}
|
||||
|
||||
@PostMapping("/agreed-early-end")
|
||||
LeaseEndingApi.StateResponse agree(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId,
|
||||
@Valid @RequestBody AgreedEndRequest request) {
|
||||
return LeaseEndingApi.StateResponse.from(endings.recordAgreedEarlyEnd(authenticatedLandlord.id(principal),
|
||||
new LeaseId(leaseId), principal.getSubject(), new LeaseEndingService.AgreedEnd(
|
||||
zone(request.timeZone()), request.endOn(), request.reason())));
|
||||
}
|
||||
|
||||
@PostMapping("/closure")
|
||||
LeaseEndingApi.StateResponse close(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId,
|
||||
@Valid @RequestBody ClosureRequest request) {
|
||||
return LeaseEndingApi.StateResponse.from(endings.close(authenticatedLandlord.id(principal),
|
||||
new LeaseId(leaseId), principal.getSubject(), new LeaseEndingService.Closure(
|
||||
zone(request.timeZone()), request.keysReturnedOn(), request.confirmed())));
|
||||
}
|
||||
|
||||
private static LeaseEndingService.EvidenceUpload upload(MultipartFile file, String timeZone, String reason) {
|
||||
if (file == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new LeaseEndingService.EvidenceUpload(zone(timeZone), file.getOriginalFilename(),
|
||||
file.getContentType(), file.getBytes(), reason);
|
||||
} catch (IOException failure) {
|
||||
throw new AttachmentStorageFailure("Evidence could not be opened.", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static ZoneId zone(String value) {
|
||||
try {
|
||||
return ZoneId.of(value);
|
||||
} catch (DateTimeException failure) {
|
||||
throw new IllegalArgumentException("Le fuseau horaire IANA est invalide.", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static String safeFileName(String value) {
|
||||
String normalized = value == null ? "justificatif" : value.replace('\\', '/');
|
||||
String leaf = normalized.substring(normalized.lastIndexOf('/') + 1).trim();
|
||||
StringBuilder safe = new StringBuilder();
|
||||
leaf.codePoints().filter(character -> !Character.isISOControl(character)).forEach(safe::appendCodePoint);
|
||||
return safe.isEmpty() || safe.toString().equals(".") || safe.toString().equals("..")
|
||||
? "justificatif" : safe.toString();
|
||||
}
|
||||
|
||||
record WithdrawalRequest(@NotBlank String timeZone, @NotNull LocalDate acceptedOn,
|
||||
@NotBlank String reason) {
|
||||
}
|
||||
|
||||
record InvalidationRequest(@NotBlank String timeZone, @NotNull LocalDate invalidatedOn,
|
||||
@NotBlank String reason) {
|
||||
}
|
||||
|
||||
record AgreedEndRequest(@NotBlank String timeZone, @NotNull LocalDate endOn, @NotBlank String reason) {
|
||||
}
|
||||
|
||||
record ClosureRequest(@NotBlank String timeZone, @NotNull LocalDate keysReturnedOn,
|
||||
boolean confirmed) {
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -11,11 +11,12 @@ public final class RentDueApi {
|
||||
|
||||
public record Response(String id, String leaseId, String month, LocalDate dueDate, BigDecimal rent,
|
||||
BigDecimal charges, BigDecimal total, BigDecimal allocatedAmount,
|
||||
BigDecimal remainingAmount, String settlement, String timing, long version) {
|
||||
BigDecimal remainingAmount, String settlement, String timing,
|
||||
boolean correctionRequired, long version) {
|
||||
public static Response from(RentDue due, LocalDate today) {
|
||||
return new Response(due.id().value(), due.leaseId().value(), due.month().toString(), due.dueDate(),
|
||||
due.rent(), due.charges(), due.total(), due.allocatedAmount(), due.remainingAmount(),
|
||||
due.settlement().name(), due.timingOn(today).name(), due.version());
|
||||
due.settlement().name(), due.timingOn(today).name(), due.correctionRequired(), due.version());
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -28,7 +28,8 @@ final class RentPaymentApi {
|
||||
@NotNull @Positive BigDecimal amount) {
|
||||
}
|
||||
|
||||
record ReversalRequest(@NotNull @PositiveOrZero Long paymentVersion, @NotBlank @Size(max = 500) String reason) {
|
||||
record ReversalRequest(@NotNull @PositiveOrZero Long paymentVersion, @NotBlank @Size(max = 500) String reason,
|
||||
@NotBlank String timeZone) {
|
||||
}
|
||||
|
||||
record RefundRequest(@NotBlank String operationId, @NotNull @PositiveOrZero Long paymentVersion,
|
||||
|
||||
+2
-1
@@ -96,7 +96,8 @@ public class RentPaymentController {
|
||||
@Valid @RequestBody RentPaymentApi.ReversalRequest request) {
|
||||
LandlordId landlordId = authenticatedLandlord.id(principal);
|
||||
RentPaymentId id = new RentPaymentId(paymentId);
|
||||
reversal.reverse(landlordId, id, request.paymentVersion(), request.reason());
|
||||
reversal.reverse(landlordId, id, request.paymentVersion(), request.reason(),
|
||||
timeZone(request.timeZone()));
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(RentPaymentApi.Response.from(history.get(landlordId,
|
||||
id)));
|
||||
}
|
||||
|
||||
+5
-3
@@ -6,7 +6,7 @@ import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.ApartmentOccupancy;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.ApartmentOccupancyRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.RentalPeriod;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.OccupancyPeriod;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.annotation.Version;
|
||||
import org.springframework.data.mongodb.core.MongoTemplate;
|
||||
@@ -47,7 +47,7 @@ public class MongoApartmentOccupancyRepository implements ApartmentOccupancyRepo
|
||||
occupancy.landlordId().value(), occupancy.apartmentId().value(),
|
||||
occupancy.reservations().stream().map(reservation -> new ReservationDocument(
|
||||
reservation.leaseId().value(), reservation.period().startDate(),
|
||||
reservation.period().endDate())).toList(),
|
||||
reservation.period().endOn().orElse(null))).toList(),
|
||||
occupancy.version() < 0 ? null : occupancy.version());
|
||||
}
|
||||
|
||||
@@ -56,7 +56,9 @@ public class MongoApartmentOccupancyRepository implements ApartmentOccupancyRepo
|
||||
new ApartmentId(document.apartmentId()),
|
||||
document.reservations().stream().map(reservation -> new ApartmentOccupancy.Reservation(
|
||||
new LeaseId(reservation.leaseId()),
|
||||
new RentalPeriod(reservation.startDate(), reservation.endDate()))).toList(),
|
||||
reservation.endDate() == null
|
||||
? OccupancyPeriod.open(reservation.startDate())
|
||||
: OccupancyPeriod.closed(reservation.startDate(), reservation.endDate()))).toList(),
|
||||
document.version() == null ? -1 : document.version());
|
||||
}
|
||||
|
||||
|
||||
+128
-5
@@ -6,21 +6,33 @@ import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile;
|
||||
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile.BuildingType;
|
||||
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile.ConstructionPeriod;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.ChargeMethod;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.AgreedEarlyLeaseEnd;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.Guarantor;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseActionAudit;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseAmendment;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseAmendmentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseContractVersion;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseClosure;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseEndingHistory;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseLegalTerms;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSnapshot;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseStatus;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSpecialClause;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseType;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.NoticeDeliveryMode;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.NoticeEvidenceFile;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.NoticeEvidenceVersion;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.NoticeInvalidation;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.NoticeWithdrawal;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.RentTerms;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.RentalPeriod;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.TenantNotice;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.TenantNoticeId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.TenantNoticeStatus;
|
||||
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.annotation.Version;
|
||||
@@ -35,6 +47,8 @@ import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.Instant;
|
||||
import java.time.MonthDay;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@@ -89,6 +103,7 @@ public class MongoLeaseRepository implements LeaseRepository {
|
||||
toDocument(lease.legalTerms()),
|
||||
lease.snapshot() == null ? null : toDocument(lease.snapshot()),
|
||||
lease.amendments().stream().map(MongoLeaseRepository::toDocument).toList(),
|
||||
toDocument(lease.endingHistory()),
|
||||
persistedVersion(lease.version()));
|
||||
}
|
||||
|
||||
@@ -107,9 +122,73 @@ public class MongoLeaseRepository implements LeaseRepository {
|
||||
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()));
|
||||
}
|
||||
|
||||
private static EndingHistoryDocument toDocument(LeaseEndingHistory history) {
|
||||
return new EndingHistoryDocument(
|
||||
history.notices().stream().map(notice -> new TenantNoticeDocument(
|
||||
notice.id().value(), notice.receivedOn(), notice.requestedDepartureOn(),
|
||||
notice.deliveryMode().name(), notice.otherDeliveryMode(), notice.noticeMonths(),
|
||||
notice.minimumEndOn(), notice.ruleVersion(), notice.recordedWithoutEvidenceConfirmed(),
|
||||
toDocument(notice.audit()))).toList(),
|
||||
history.withdrawals().stream().map(withdrawal -> new NoticeWithdrawalDocument(
|
||||
withdrawal.noticeId().value(), withdrawal.acceptedOn(), withdrawal.reason(),
|
||||
toDocument(withdrawal.audit()))).toList(),
|
||||
history.invalidations().stream().map(invalidation -> new NoticeInvalidationDocument(
|
||||
invalidation.noticeId().value(), invalidation.invalidatedOn(), invalidation.reason(),
|
||||
toDocument(invalidation.audit()))).toList(),
|
||||
history.agreedEarlyEnds().stream().map(agreement -> new AgreedEarlyEndDocument(
|
||||
agreement.endOn(), agreement.reason(), toDocument(agreement.audit()))).toList(),
|
||||
history.evidenceVersions().stream().map(evidence -> new NoticeEvidenceDocument(
|
||||
evidence.noticeId().value(), evidence.version(), evidence.file().storageKey(),
|
||||
evidence.file().fileName(), evidence.file().contentType(), evidence.file().size(),
|
||||
evidence.file().sha256(), evidence.reason(), toDocument(evidence.audit()))).toList(),
|
||||
history.closure().map(closure -> new LeaseClosureDocument(
|
||||
closure.keysReturnedOn(), toDocument(closure.audit()))).orElse(null));
|
||||
}
|
||||
|
||||
private static LeaseEndingHistory toDomain(EndingHistoryDocument history) {
|
||||
if (history == null) {
|
||||
return LeaseEndingHistory.empty();
|
||||
}
|
||||
return LeaseEndingHistory.reconstitute(
|
||||
values(history.notices()).stream().map(notice -> new TenantNotice(
|
||||
new TenantNoticeId(notice.id()), TenantNoticeStatus.ACTIVE, notice.receivedOn(),
|
||||
notice.requestedDepartureOn(), NoticeDeliveryMode.valueOf(notice.deliveryMode()),
|
||||
notice.otherDeliveryMode(), notice.noticeMonths(), notice.minimumEndOn(),
|
||||
notice.ruleVersion(), Boolean.TRUE.equals(notice.recordedWithoutEvidenceConfirmed()),
|
||||
toDomain(notice.audit()))).toList(),
|
||||
values(history.withdrawals()).stream().map(withdrawal -> new NoticeWithdrawal(
|
||||
new TenantNoticeId(withdrawal.noticeId()), withdrawal.acceptedOn(), withdrawal.reason(),
|
||||
toDomain(withdrawal.audit()))).toList(),
|
||||
values(history.invalidations()).stream().map(invalidation -> new NoticeInvalidation(
|
||||
new TenantNoticeId(invalidation.noticeId()), invalidation.invalidatedOn(),
|
||||
invalidation.reason(), toDomain(invalidation.audit()))).toList(),
|
||||
values(history.agreedEarlyEnds()).stream().map(agreement -> new AgreedEarlyLeaseEnd(
|
||||
agreement.endOn(), agreement.reason(), toDomain(agreement.audit()))).toList(),
|
||||
values(history.evidenceVersions()).stream().map(evidence -> new NoticeEvidenceVersion(
|
||||
new TenantNoticeId(evidence.noticeId()), evidence.version(),
|
||||
new NoticeEvidenceFile(evidence.storageKey(), evidence.fileName(), evidence.contentType(),
|
||||
evidence.size(), evidence.sha256()), evidence.reason(), toDomain(evidence.audit())))
|
||||
.toList(),
|
||||
history.closure() == null ? null
|
||||
: new LeaseClosure(history.closure().keysReturnedOn(), toDomain(history.closure().audit())));
|
||||
}
|
||||
|
||||
private static LeaseAuditDocument toDocument(LeaseActionAudit audit) {
|
||||
return new LeaseAuditDocument(audit.authorId(), audit.recordedAt());
|
||||
}
|
||||
|
||||
private static LeaseActionAudit toDomain(LeaseAuditDocument audit) {
|
||||
return new LeaseActionAudit(audit.authorId(), audit.recordedAt());
|
||||
}
|
||||
|
||||
private static <T> List<T> values(List<T> values) {
|
||||
return values == null ? List.of() : values;
|
||||
}
|
||||
|
||||
private static GuarantorDocument toDocument(Guarantor guarantor) {
|
||||
return new GuarantorDocument(guarantor.id(), guarantor.firstName(), guarantor.lastName(), guarantor.email(),
|
||||
guarantor.phone(), guarantor.address());
|
||||
@@ -225,7 +304,11 @@ public class MongoLeaseRepository implements LeaseRepository {
|
||||
new PreviousRentDocument(terms.previousRent().applicable(), terms.previousRent().amount(),
|
||||
terms.previousRent().lastRevisionDate(), terms.previousRent().lastPaymentDate(),
|
||||
terms.previousRent().tenantConfirmation()),
|
||||
new RentRevisionDocument(terms.rentRevision().enabled(), terms.rentRevision().annualRevisionDate(),
|
||||
new RentRevisionDocument(terms.rentRevision().enabled(),
|
||||
terms.rentRevision().annualRevisionDay() == null ? null
|
||||
: terms.rentRevision().annualRevisionDay().getDayOfMonth(),
|
||||
terms.rentRevision().annualRevisionDay() == null ? null
|
||||
: terms.rentRevision().annualRevisionDay().getMonthValue(),
|
||||
terms.rentRevision().referenceQuarter(), terms.rentRevision().referenceYear(),
|
||||
terms.rentRevision().seriesId()),
|
||||
new RecentWorkDocument(terms.recentWork().description(), terms.recentWork().amount()),
|
||||
@@ -252,7 +335,10 @@ public class MongoLeaseRepository implements LeaseRepository {
|
||||
previousRent.lastRevisionDate(), previousRent.lastPaymentDate(),
|
||||
Boolean.TRUE.equals(previousRent.tenantConfirmation())),
|
||||
rentRevision == null ? null : new LeaseLegalTerms.RentRevisionTerms(
|
||||
Boolean.TRUE.equals(rentRevision.enabled()), rentRevision.annualRevisionDate(),
|
||||
Boolean.TRUE.equals(rentRevision.enabled()),
|
||||
rentRevision.annualRevisionDay() == null || rentRevision.annualRevisionMonth() == null
|
||||
? null : MonthDay.of(rentRevision.annualRevisionMonth(),
|
||||
rentRevision.annualRevisionDay()),
|
||||
rentRevision.referenceQuarter(), rentRevision.referenceYear(), rentRevision.seriesId()),
|
||||
recentWork == null ? null : new LeaseLegalTerms.RecentWork(recentWork.description(),
|
||||
recentWork.amount()),
|
||||
@@ -318,11 +404,12 @@ public class MongoLeaseRepository implements LeaseRepository {
|
||||
String leaseType, List<SpecialClauseDocument> specialClauses,
|
||||
LegalTermsDocument legalTerms,
|
||||
LeaseSnapshotDocument snapshot, List<LeaseAmendmentDocument> amendments,
|
||||
EndingHistoryDocument endingHistory,
|
||||
@Version Long version) {
|
||||
LeaseDocument withVersion(Long version) {
|
||||
return new LeaseDocument(id, landlordId, apartmentId, tenantIds, guarantors, startDate, endDate,
|
||||
monthlyRent, monthlyCharges, securityDeposit, status, dueDay, chargeMethod, indexFlatRate,
|
||||
leaseType, specialClauses, legalTerms, snapshot, amendments, version);
|
||||
leaseType, specialClauses, legalTerms, snapshot, amendments, endingHistory, version);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,8 +441,8 @@ public class MongoLeaseRepository implements LeaseRepository {
|
||||
Boolean tenantConfirmation) {
|
||||
}
|
||||
|
||||
record RentRevisionDocument(Boolean enabled, LocalDate annualRevisionDate, Integer referenceQuarter,
|
||||
Integer referenceYear, String seriesId) {
|
||||
record RentRevisionDocument(Boolean enabled, Integer annualRevisionDay, Integer annualRevisionMonth,
|
||||
Integer referenceQuarter, Integer referenceYear, String seriesId) {
|
||||
}
|
||||
|
||||
record RecentWorkDocument(String description,
|
||||
@@ -400,6 +487,42 @@ public class MongoLeaseRepository implements LeaseRepository {
|
||||
LeaseContractVersionDocument afterVersion) {
|
||||
}
|
||||
|
||||
record EndingHistoryDocument(List<TenantNoticeDocument> notices,
|
||||
List<NoticeWithdrawalDocument> withdrawals,
|
||||
List<NoticeInvalidationDocument> invalidations,
|
||||
List<AgreedEarlyEndDocument> agreedEarlyEnds,
|
||||
List<NoticeEvidenceDocument> evidenceVersions,
|
||||
LeaseClosureDocument closure) {
|
||||
}
|
||||
|
||||
record TenantNoticeDocument(String id, LocalDate receivedOn, LocalDate requestedDepartureOn,
|
||||
String deliveryMode, String otherDeliveryMode, int noticeMonths,
|
||||
LocalDate minimumEndOn, String ruleVersion,
|
||||
Boolean recordedWithoutEvidenceConfirmed, LeaseAuditDocument audit) {
|
||||
}
|
||||
|
||||
record NoticeWithdrawalDocument(String noticeId, LocalDate acceptedOn, String reason,
|
||||
LeaseAuditDocument audit) {
|
||||
}
|
||||
|
||||
record NoticeInvalidationDocument(String noticeId, LocalDate invalidatedOn, String reason,
|
||||
LeaseAuditDocument audit) {
|
||||
}
|
||||
|
||||
record AgreedEarlyEndDocument(LocalDate endOn, String reason, LeaseAuditDocument audit) {
|
||||
}
|
||||
|
||||
record NoticeEvidenceDocument(String noticeId, int version, String storageKey, String fileName,
|
||||
String contentType, long size, String sha256, String reason,
|
||||
LeaseAuditDocument audit) {
|
||||
}
|
||||
|
||||
record LeaseClosureDocument(LocalDate keysReturnedOn, LeaseAuditDocument audit) {
|
||||
}
|
||||
|
||||
record LeaseAuditDocument(String authorId, Instant recordedAt) {
|
||||
}
|
||||
|
||||
record LeaseContractVersionDocument(LandlordSnapshotDocument landlord, ApartmentSnapshotDocument apartment,
|
||||
List<TenantSnapshotDocument> tenants, List<GuarantorDocument> guarantors,
|
||||
LocalDate startDate, LocalDate endDate,
|
||||
|
||||
+6
-2
@@ -78,13 +78,15 @@ public class MongoRentDueRepository implements RentDueRepository {
|
||||
private RentDueDocument toDocument(RentDue due) {
|
||||
return new RentDueDocument(due.id().value(), due.landlordId().value(), due.leaseId().value(),
|
||||
due.month().toString(), due.dueDate(), due.rent(), due.charges(), due.allocatedAmount(),
|
||||
due.rentBeforeLeaseEnd(), due.chargesBeforeLeaseEnd(),
|
||||
persistedVersion(due.version()));
|
||||
}
|
||||
|
||||
private RentDue toDomain(RentDueDocument document) {
|
||||
return RentDue.reconstitute(new RentDueId(document.id()), new LandlordId(document.landlordId()),
|
||||
new LeaseId(document.leaseId()), YearMonth.parse(document.month()), document.dueDate(),
|
||||
document.rent(), document.charges(), document.allocatedAmount(), domainVersion(document.version()));
|
||||
document.rent(), document.charges(), document.allocatedAmount(), document.rentBeforeLeaseEnd(),
|
||||
document.chargesBeforeLeaseEnd(), domainVersion(document.version()));
|
||||
}
|
||||
|
||||
private static Long persistedVersion(long version) {
|
||||
@@ -102,10 +104,12 @@ public class MongoRentDueRepository implements RentDueRepository {
|
||||
LocalDate dueDate, @Field(targetType = FieldType.DECIMAL128) BigDecimal rent,
|
||||
@Field(targetType = FieldType.DECIMAL128) BigDecimal charges,
|
||||
@Field(targetType = FieldType.DECIMAL128) BigDecimal allocatedAmount,
|
||||
@Field(targetType = FieldType.DECIMAL128) BigDecimal rentBeforeLeaseEnd,
|
||||
@Field(targetType = FieldType.DECIMAL128) BigDecimal chargesBeforeLeaseEnd,
|
||||
@Version Long version) {
|
||||
RentDueDocument withVersion(Long version) {
|
||||
return new RentDueDocument(id, landlordId, leaseId, month, dueDate, rent, charges, allocatedAmount,
|
||||
version);
|
||||
rentBeforeLeaseEnd, chargesBeforeLeaseEnd, version);
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
@@ -17,6 +17,7 @@ import java.math.BigDecimal;
|
||||
import java.text.NumberFormat;
|
||||
import java.time.LocalDate;
|
||||
import java.time.Month;
|
||||
import java.time.MonthDay;
|
||||
import java.time.YearMonth;
|
||||
import java.time.format.TextStyle;
|
||||
import java.time.temporal.TemporalAccessor;
|
||||
@@ -103,6 +104,10 @@ public class ThymeleafRentalDocumentRenderer implements RentalDocumentRenderer {
|
||||
return date.getDayOfMonth() + " " + monthName(date) + " " + date.getYear();
|
||||
}
|
||||
|
||||
public String monthDay(MonthDay date) {
|
||||
return date.getDayOfMonth() + " " + monthName(date);
|
||||
}
|
||||
|
||||
public String month(YearMonth month) {
|
||||
return monthName(month) + " " + month.getYear();
|
||||
}
|
||||
|
||||
+1
-9
@@ -16,7 +16,6 @@ import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRuleViolation;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSnapshot;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseStatus;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentSchedule;
|
||||
@@ -55,13 +54,6 @@ public final class ActivateLeaseService {
|
||||
|
||||
Lease activateInTransaction(LandlordId landlordId, LeaseId leaseId) {
|
||||
Lease lease = leases.find(landlordId, leaseId).orElseThrow(() -> new ResourceNotFound("Lease"));
|
||||
boolean alreadyLeased = leases.findAll(landlordId).stream()
|
||||
.anyMatch(existing -> !existing.id().equals(lease.id())
|
||||
&& existing.apartmentId().equals(lease.apartmentId())
|
||||
&& existing.status() == LeaseStatus.ACTIVE);
|
||||
if (alreadyLeased) {
|
||||
throw new LeaseRuleViolation("Un autre bail actif existe déjà pour ce logement.");
|
||||
}
|
||||
Landlord landlord = landlords.find(landlordId).orElseThrow(() -> new ResourceNotFound("Landlord"));
|
||||
if (!landlord.hasCompleteProfile()) {
|
||||
throw new LeaseRuleViolation("Le profil bailleur doit être complet avant l'activation du bail.");
|
||||
@@ -76,7 +68,7 @@ public final class ActivateLeaseService {
|
||||
|
||||
ApartmentOccupancy occupancy = occupancies.find(landlordId, lease.apartmentId())
|
||||
.orElseGet(() -> ApartmentOccupancy.empty(landlordId, lease.apartmentId()));
|
||||
occupancy.reserve(lease.id(), lease.period());
|
||||
occupancy.reserve(lease.id(), lease.occupancyPeriod());
|
||||
LeaseActivated event = lease.activate(snapshot(landlord, apartment, leaseTenants, lease));
|
||||
occupancies.save(occupancy);
|
||||
rentDues.saveAll(RentSchedule.from(event, month -> new RentDueId(identifiers.next())));
|
||||
|
||||
+33
-5
@@ -23,6 +23,9 @@ import fr.nessar.lmnporganizer.rental.domain.rent.RentDue;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentSchedule;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentRevision;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentRevisionRepository;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.RentTermsTimeline;
|
||||
import fr.nessar.lmnporganizer.rental.domain.tenant.Tenant;
|
||||
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantRepository;
|
||||
@@ -30,12 +33,16 @@ import fr.nessar.lmnporganizer.rental.domain.tenant.TenantRepository;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.List;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
|
||||
public final class AmendLeaseService {
|
||||
private final LeaseRepository leases;
|
||||
private final TenantRepository tenants;
|
||||
private final RentDueRepository rentDues;
|
||||
private final RentRevisionRepository revisions;
|
||||
private final ApartmentOccupancyRepository occupancies;
|
||||
private final TransactionRunner transactions;
|
||||
private final IdentifierGenerator identifiers;
|
||||
@@ -44,17 +51,25 @@ public final class AmendLeaseService {
|
||||
public AmendLeaseService(LeaseRepository leases, TenantRepository tenants, RentDueRepository rentDues,
|
||||
ApartmentOccupancyRepository occupancies, TransactionRunner transactions,
|
||||
IdentifierGenerator identifiers, Clock clock) {
|
||||
this(leases, tenants, rentDues, null, occupancies, transactions, identifiers, clock);
|
||||
}
|
||||
|
||||
public AmendLeaseService(LeaseRepository leases, TenantRepository tenants, RentDueRepository rentDues,
|
||||
RentRevisionRepository revisions, ApartmentOccupancyRepository occupancies,
|
||||
TransactionRunner transactions, IdentifierGenerator identifiers, Clock clock) {
|
||||
this.leases = leases;
|
||||
this.tenants = tenants;
|
||||
this.rentDues = rentDues;
|
||||
this.revisions = revisions;
|
||||
this.occupancies = occupancies;
|
||||
this.transactions = transactions;
|
||||
this.identifiers = identifiers;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public Lease amend(LandlordId landlordId, LeaseId leaseId, LocalDate effectiveDate, Change change) {
|
||||
if (effectiveDate.isBefore(LocalDate.now(clock))) {
|
||||
public Lease amend(LandlordId landlordId, LeaseId leaseId, LocalDate effectiveDate, ZoneId timeZone,
|
||||
Change change) {
|
||||
if (effectiveDate.isBefore(LocalDate.now(clock.withZone(timeZone)))) {
|
||||
throw new LeaseRuleViolation("Un avenant ne peut pas prendre effet rétroactivement.");
|
||||
}
|
||||
return transactions.run(() -> amendInTransaction(landlordId, leaseId, effectiveDate, change));
|
||||
@@ -82,9 +97,10 @@ public final class AmendLeaseService {
|
||||
|
||||
ApartmentOccupancy occupancy = occupancies.find(landlordId, lease.apartmentId())
|
||||
.orElseThrow(() -> new ResourceNotFound("Apartment occupancy"));
|
||||
occupancy.reviseReservation(lease.id(), lease.period());
|
||||
occupancy.reviseReservation(lease.id(), lease.occupancyPeriod());
|
||||
List<RentDue> existingDues = rentDues.findAll(landlordId, leaseId);
|
||||
RentSchedule.Reconciliation reconciliation = RentSchedule.reconcile(event,
|
||||
rentDues.findAll(landlordId, leaseId), month -> new RentDueId(identifiers.next()));
|
||||
existingDues, month -> new RentDueId(identifiers.next()));
|
||||
for (RentDue removed : reconciliation.toRemove()) {
|
||||
if (!rentDues.deleteUnallocated(removed)) {
|
||||
throw new ConcurrentChange("Rent due");
|
||||
@@ -92,7 +108,19 @@ public final class AmendLeaseService {
|
||||
}
|
||||
amendedTenants.forEach(tenant -> tenant.referenceBy(leaseId));
|
||||
amendedTenants.forEach(tenants::save);
|
||||
rentDues.saveAll(reconciliation.toSave());
|
||||
LinkedHashSet<RentDue> toSave = new LinkedHashSet<>(reconciliation.toSave());
|
||||
lease.contractEndOn().ifPresent(endOn -> {
|
||||
List<RentDue> boundedDues = new ArrayList<>(existingDues);
|
||||
boundedDues.removeAll(reconciliation.toRemove());
|
||||
reconciliation.toSave().stream().filter(due -> !boundedDues.contains(due)).forEach(boundedDues::add);
|
||||
List<RentRevision> applied = revisions == null
|
||||
? List.of() : revisions.findAll(landlordId, leaseId);
|
||||
toSave.addAll(RentSchedule.reconcileEnd(
|
||||
boundedDues, lease.scheduledOccupancyPeriod(), endOn,
|
||||
RentTermsTimeline.finalMonthAmounts(
|
||||
lease, applied, lease.scheduledOccupancyPeriod(), endOn)).toSave());
|
||||
});
|
||||
rentDues.saveAll(List.copyOf(toSave));
|
||||
occupancies.save(occupancy);
|
||||
return leases.save(lease);
|
||||
}
|
||||
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.lease;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
|
||||
|
||||
public final class ArchiveLeaseService {
|
||||
private final LeaseRepository leases;
|
||||
|
||||
public ArchiveLeaseService(LeaseRepository leases) {
|
||||
this.leases = leases;
|
||||
}
|
||||
|
||||
public Lease archive(LandlordId landlordId, LeaseId leaseId) {
|
||||
Lease lease = leases.find(landlordId, leaseId).orElseThrow(() -> new ResourceNotFound("Lease"));
|
||||
lease.archive();
|
||||
return leases.save(lease);
|
||||
}
|
||||
}
|
||||
+354
@@ -0,0 +1,354 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.lease;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.IdentifierGenerator;
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.LeaseDocumentStorage;
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentTooLarge;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.ApartmentOccupancy;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.ApartmentOccupancyRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseActionAudit;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRuleViolation;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseStatus;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.NoticeDeliveryMode;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.NoticeEvidenceFile;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.NoticeEvidenceVersion;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.TenantNoticeCommand;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.TenantNoticeId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDue;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentSchedule;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentRevision;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentRevisionRepository;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.RentTermsTimeline;
|
||||
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
public final class LeaseEndingService {
|
||||
private final LeaseRepository leases;
|
||||
private final ApartmentOccupancyRepository occupancies;
|
||||
private final RentDueRepository rentDues;
|
||||
private final RentRevisionRepository revisions;
|
||||
private final LeaseDocumentStorage storage;
|
||||
private final TransactionRunner transactions;
|
||||
private final IdentifierGenerator identifiers;
|
||||
private final Clock clock;
|
||||
private final long maxEvidenceBytes;
|
||||
|
||||
public LeaseEndingService(LeaseRepository leases, ApartmentOccupancyRepository occupancies,
|
||||
RentDueRepository rentDues, LeaseDocumentStorage storage,
|
||||
TransactionRunner transactions, IdentifierGenerator identifiers,
|
||||
Clock clock, long maxEvidenceBytes) {
|
||||
this(leases, occupancies, rentDues, null, storage, transactions, identifiers, clock, maxEvidenceBytes);
|
||||
}
|
||||
|
||||
public LeaseEndingService(LeaseRepository leases, ApartmentOccupancyRepository occupancies,
|
||||
RentDueRepository rentDues, RentRevisionRepository revisions,
|
||||
LeaseDocumentStorage storage, TransactionRunner transactions,
|
||||
IdentifierGenerator identifiers, Clock clock, long maxEvidenceBytes) {
|
||||
this.leases = leases;
|
||||
this.occupancies = occupancies;
|
||||
this.rentDues = rentDues;
|
||||
this.revisions = revisions;
|
||||
this.storage = storage;
|
||||
this.transactions = transactions;
|
||||
this.identifiers = identifiers;
|
||||
this.clock = clock;
|
||||
if (maxEvidenceBytes <= 0) {
|
||||
throw new IllegalArgumentException("Maximum evidence size must be positive.");
|
||||
}
|
||||
this.maxEvidenceBytes = maxEvidenceBytes;
|
||||
}
|
||||
|
||||
public LeaseEndingState state(LandlordId landlordId, LeaseId leaseId, ZoneId timeZone) {
|
||||
Lease lease = findLease(landlordId, leaseId);
|
||||
requireFinalized(lease);
|
||||
return state(lease, rentDues.findAll(landlordId, leaseId), today(timeZone));
|
||||
}
|
||||
|
||||
public LeaseEndingState recordNotice(LandlordId landlordId, LeaseId leaseId, String authorId,
|
||||
RecordNotice request, EvidenceUpload evidence) {
|
||||
Objects.requireNonNull(request);
|
||||
if (evidence == null && !request.missingEvidenceConfirmed()) {
|
||||
throw new LeaseRuleViolation("L'absence de justificatif doit être confirmée.");
|
||||
}
|
||||
TenantNoticeId noticeId = new TenantNoticeId(identifiers.next());
|
||||
StoredEvidence stored = evidence == null ? null : store(evidence);
|
||||
try {
|
||||
return transactions.run(() -> changeEnd(landlordId, leaseId, lease -> {
|
||||
lease.recordTenantNotice(new TenantNoticeCommand(noticeId, request.receivedOn(),
|
||||
request.requestedDepartureOn(), request.deliveryMode(), request.otherDeliveryMode(),
|
||||
request.lateRegistrationConfirmed(), request.nonStandardDeliveryConfirmed(),
|
||||
evidence == null, today(request.timeZone())), audit(authorId));
|
||||
if (stored != null) {
|
||||
lease.appendNoticeEvidence(noticeId, stored.file(), null, audit(authorId));
|
||||
}
|
||||
}, request.timeZone()));
|
||||
} catch (RuntimeException failure) {
|
||||
discard(stored, failure);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
public LeaseEndingState acceptWithdrawal(LandlordId landlordId, LeaseId leaseId, TenantNoticeId noticeId,
|
||||
String authorId, AdministrativeFact request) {
|
||||
return transactions.run(() -> changeEnd(landlordId, leaseId,
|
||||
lease -> lease.acceptNoticeWithdrawal(noticeId, request.on(), request.reason(),
|
||||
today(request.timeZone()), audit(authorId)), request.timeZone()));
|
||||
}
|
||||
|
||||
public LeaseEndingState invalidateNotice(LandlordId landlordId, LeaseId leaseId, TenantNoticeId noticeId,
|
||||
String authorId, AdministrativeFact request) {
|
||||
return transactions.run(() -> changeEnd(landlordId, leaseId,
|
||||
lease -> lease.invalidateNotice(noticeId, request.on(), request.reason(),
|
||||
today(request.timeZone()), audit(authorId)), request.timeZone()));
|
||||
}
|
||||
|
||||
public LeaseEndingState recordAgreedEarlyEnd(LandlordId landlordId, LeaseId leaseId, String authorId,
|
||||
AgreedEnd request) {
|
||||
return transactions.run(() -> changeEnd(landlordId, leaseId,
|
||||
lease -> lease.recordAgreedEarlyEnd(request.endOn(), request.reason(), audit(authorId)),
|
||||
request.timeZone()));
|
||||
}
|
||||
|
||||
public LeaseEndingState close(LandlordId landlordId, LeaseId leaseId, String authorId, Closure request) {
|
||||
return transactions.run(() -> {
|
||||
Lease lease = findLease(landlordId, leaseId);
|
||||
lease.close(request.keysReturnedOn(), request.confirmed(), today(request.timeZone()), audit(authorId));
|
||||
Lease saved = leases.save(lease);
|
||||
return state(saved, rentDues.findAll(landlordId, leaseId), today(request.timeZone()));
|
||||
});
|
||||
}
|
||||
|
||||
public LeaseEndingState appendEvidence(LandlordId landlordId, LeaseId leaseId, TenantNoticeId noticeId,
|
||||
String authorId, EvidenceUpload evidence) {
|
||||
StoredEvidence stored = store(evidence);
|
||||
try {
|
||||
return transactions.run(() -> {
|
||||
Lease lease = findLease(landlordId, leaseId);
|
||||
lease.appendNoticeEvidence(noticeId, stored.file(), evidence.reason(), audit(authorId));
|
||||
Lease saved = leases.save(lease);
|
||||
return state(saved, rentDues.findAll(landlordId, leaseId), today(evidence.timeZone()));
|
||||
});
|
||||
} catch (RuntimeException failure) {
|
||||
discard(stored, failure);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
public EvidenceDownload evidence(LandlordId landlordId, LeaseId leaseId, TenantNoticeId noticeId, int version) {
|
||||
Lease lease = findLease(landlordId, leaseId);
|
||||
NoticeEvidenceVersion evidence = lease.endingHistory().evidenceVersions(noticeId).stream()
|
||||
.filter(candidate -> candidate.version() == version)
|
||||
.findFirst().orElseThrow(() -> new ResourceNotFound("Lease ending evidence"));
|
||||
byte[] content = storage.read(evidence.file().storageKey());
|
||||
if (content.length != evidence.file().size() || !sha256(content).equals(evidence.file().sha256())) {
|
||||
throw new IllegalStateException("Lease ending evidence integrity check failed.");
|
||||
}
|
||||
return new EvidenceDownload(evidence.file().fileName(), content);
|
||||
}
|
||||
|
||||
private LeaseEndingState changeEnd(LandlordId landlordId, LeaseId leaseId, Consumer<Lease> change,
|
||||
ZoneId timeZone) {
|
||||
Lease lease = findLease(landlordId, leaseId);
|
||||
Optional<LocalDate> previousEnd = lease.contractEndOn();
|
||||
change.accept(lease);
|
||||
ApartmentOccupancy occupancy = occupancies.find(landlordId, lease.apartmentId())
|
||||
.orElseThrow(() -> new ResourceNotFound("Apartment occupancy"));
|
||||
occupancy.reviseReservation(lease.id(), lease.occupancyPeriod());
|
||||
List<RentDue> dues = rentDues.findAll(landlordId, leaseId);
|
||||
Optional<LocalDate> currentEnd = lease.contractEndOn();
|
||||
if (currentEnd.isPresent() && isEarlier(currentEnd.orElseThrow(), previousEnd)) {
|
||||
RentSchedule.EndReconciliation reconciliation = RentSchedule.reconcileEnd(
|
||||
dues, lease.scheduledOccupancyPeriod(), currentEnd.orElseThrow(),
|
||||
finalMonthAmounts(landlordId, lease, currentEnd.orElseThrow()));
|
||||
if (!reconciliation.toSave().isEmpty()) {
|
||||
rentDues.saveAll(reconciliation.toSave());
|
||||
}
|
||||
} else if (isExpanded(previousEnd, currentEnd)) {
|
||||
RentSchedule.EndReconciliation restoration = RentSchedule.restoreAfterEndExpansion(
|
||||
dues, lease.scheduledOccupancyPeriod(), currentEnd,
|
||||
currentEnd.map(endOn -> finalMonthAmounts(landlordId, lease, endOn)).orElse(null));
|
||||
if (!restoration.toSave().isEmpty()) {
|
||||
rentDues.saveAll(restoration.toSave());
|
||||
}
|
||||
}
|
||||
occupancies.save(occupancy);
|
||||
Lease saved = leases.save(lease);
|
||||
return state(saved, dues, today(timeZone));
|
||||
}
|
||||
|
||||
private LeaseEndingState state(Lease lease, List<RentDue> dues, LocalDate today) {
|
||||
boolean supported = lease.endingSupported();
|
||||
return new LeaseEndingState(lease.id(), supported,
|
||||
supported ? Optional.empty()
|
||||
: Optional.of("La fin de bail locataire n'est pas prise en charge pour une colocation."),
|
||||
lease.endPhaseOn(today), lease.leaseType(), lease.fixedEndOn(), lease.contractEndOn(),
|
||||
dues.stream().anyMatch(RentDue::correctionRequired), lease.endingHistory());
|
||||
}
|
||||
|
||||
private RentSchedule.EndAmounts finalMonthAmounts(LandlordId landlordId, Lease lease, LocalDate endOn) {
|
||||
List<RentRevision> applied = revisions == null ? List.of() : revisions.findAll(landlordId, lease.id());
|
||||
return RentTermsTimeline.finalMonthAmounts(lease, applied, lease.scheduledOccupancyPeriod(), endOn);
|
||||
}
|
||||
|
||||
private static void requireFinalized(Lease lease) {
|
||||
if (lease.status() == LeaseStatus.DRAFT) {
|
||||
throw new LeaseRuleViolation("La fin de bail n'est disponible que pour un bail finalisé.");
|
||||
}
|
||||
}
|
||||
|
||||
private Lease findLease(LandlordId landlordId, LeaseId leaseId) {
|
||||
return leases.find(landlordId, leaseId).orElseThrow(() -> new ResourceNotFound("Lease"));
|
||||
}
|
||||
|
||||
private LocalDate today(ZoneId timeZone) {
|
||||
return LocalDate.now(clock.withZone(Objects.requireNonNull(timeZone, "Time zone is required.")));
|
||||
}
|
||||
|
||||
private LeaseActionAudit audit(String authorId) {
|
||||
return new LeaseActionAudit(authorId, Instant.now(clock));
|
||||
}
|
||||
|
||||
private StoredEvidence store(EvidenceUpload upload) {
|
||||
Objects.requireNonNull(upload, "Evidence is required.");
|
||||
byte[] content = Objects.requireNonNull(upload.content(), "Evidence content is required.").clone();
|
||||
if (content.length == 0) {
|
||||
throw new LeaseRuleViolation("Le justificatif est vide.");
|
||||
}
|
||||
if (content.length > maxEvidenceBytes) {
|
||||
throw new AttachmentTooLarge(maxEvidenceBytes);
|
||||
}
|
||||
String key = storage.store(content);
|
||||
return new StoredEvidence(new NoticeEvidenceFile(key, safeFileName(upload.fileName()),
|
||||
contentType(upload.contentType()), content.length, sha256(content)));
|
||||
}
|
||||
|
||||
private void discard(StoredEvidence evidence, RuntimeException failure) {
|
||||
if (evidence == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
storage.discard(evidence.file().storageKey());
|
||||
} catch (RuntimeException cleanupFailure) {
|
||||
failure.addSuppressed(cleanupFailure);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isEarlier(LocalDate current, Optional<LocalDate> previous) {
|
||||
return previous.isEmpty() || current.isBefore(previous.orElseThrow());
|
||||
}
|
||||
|
||||
private static boolean isExpanded(Optional<LocalDate> previous, Optional<LocalDate> current) {
|
||||
return previous.isPresent()
|
||||
&& (current.isEmpty() || current.orElseThrow().isAfter(previous.orElseThrow()));
|
||||
}
|
||||
|
||||
private static String safeFileName(String fileName) {
|
||||
if (fileName == null || fileName.isBlank()) {
|
||||
return "justificatif";
|
||||
}
|
||||
String normalized = fileName.replace('\\', '/');
|
||||
String leaf = normalized.substring(normalized.lastIndexOf('/') + 1).trim();
|
||||
StringBuilder safe = new StringBuilder();
|
||||
leaf.codePoints().filter(character -> !Character.isISOControl(character))
|
||||
.forEach(safe::appendCodePoint);
|
||||
if (safe.isEmpty() || safe.toString().equals(".") || safe.toString().equals("..")) {
|
||||
return "justificatif";
|
||||
}
|
||||
return safe.length() > 255 ? safe.substring(0, 255) : safe.toString();
|
||||
}
|
||||
|
||||
private static String contentType(String contentType) {
|
||||
return contentType == null || contentType.isBlank() ? "application/octet-stream" : contentType.trim();
|
||||
}
|
||||
|
||||
private static String sha256(byte[] content) {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(content));
|
||||
} catch (NoSuchAlgorithmException impossible) {
|
||||
throw new IllegalStateException("SHA-256 is unavailable.", impossible);
|
||||
}
|
||||
}
|
||||
|
||||
public record RecordNotice(ZoneId timeZone, LocalDate receivedOn, LocalDate requestedDepartureOn,
|
||||
NoticeDeliveryMode deliveryMode, String otherDeliveryMode,
|
||||
boolean lateRegistrationConfirmed, boolean nonStandardDeliveryConfirmed,
|
||||
boolean missingEvidenceConfirmed) {
|
||||
public RecordNotice(ZoneId timeZone, LocalDate receivedOn, LocalDate requestedDepartureOn,
|
||||
NoticeDeliveryMode deliveryMode, String otherDeliveryMode,
|
||||
boolean lateRegistrationConfirmed, boolean nonStandardDeliveryConfirmed) {
|
||||
this(timeZone, receivedOn, requestedDepartureOn, deliveryMode, otherDeliveryMode,
|
||||
lateRegistrationConfirmed, nonStandardDeliveryConfirmed, false);
|
||||
}
|
||||
|
||||
public RecordNotice {
|
||||
Objects.requireNonNull(timeZone);
|
||||
Objects.requireNonNull(receivedOn);
|
||||
Objects.requireNonNull(requestedDepartureOn);
|
||||
Objects.requireNonNull(deliveryMode);
|
||||
}
|
||||
}
|
||||
|
||||
public record AdministrativeFact(ZoneId timeZone, LocalDate on, String reason) {
|
||||
public AdministrativeFact {
|
||||
Objects.requireNonNull(timeZone);
|
||||
Objects.requireNonNull(on);
|
||||
}
|
||||
}
|
||||
|
||||
public record AgreedEnd(ZoneId timeZone, LocalDate endOn, String reason) {
|
||||
public AgreedEnd {
|
||||
Objects.requireNonNull(timeZone);
|
||||
Objects.requireNonNull(endOn);
|
||||
}
|
||||
}
|
||||
|
||||
public record Closure(ZoneId timeZone, LocalDate keysReturnedOn, boolean confirmed) {
|
||||
public Closure {
|
||||
Objects.requireNonNull(timeZone);
|
||||
Objects.requireNonNull(keysReturnedOn);
|
||||
}
|
||||
}
|
||||
|
||||
public record EvidenceUpload(ZoneId timeZone, String fileName, String contentType, byte[] content,
|
||||
String reason) {
|
||||
public EvidenceUpload {
|
||||
Objects.requireNonNull(timeZone);
|
||||
content = content == null ? null : content.clone();
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] content() {
|
||||
return content == null ? null : content.clone();
|
||||
}
|
||||
}
|
||||
|
||||
public record EvidenceDownload(String fileName, byte[] content) {
|
||||
public EvidenceDownload {
|
||||
content = content.clone();
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] content() {
|
||||
return content.clone();
|
||||
}
|
||||
}
|
||||
|
||||
private record StoredEvidence(NoticeEvidenceFile file) {
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.lease;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseEndPhase;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseEndingHistory;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseType;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
public record LeaseEndingState(LeaseId leaseId, boolean supported, Optional<String> unsupportedReason,
|
||||
LeaseEndPhase phase, LeaseType leaseType, Optional<LocalDate> fixedEndOn,
|
||||
Optional<LocalDate> effectiveEndOn, boolean correctionRequired,
|
||||
LeaseEndingHistory history) {
|
||||
public LeaseEndingState {
|
||||
Objects.requireNonNull(leaseId);
|
||||
unsupportedReason = unsupportedReason == null ? Optional.empty() : unsupportedReason;
|
||||
Objects.requireNonNull(phase);
|
||||
Objects.requireNonNull(leaseType);
|
||||
fixedEndOn = fixedEndOn == null ? Optional.empty() : fixedEndOn;
|
||||
effectiveEndOn = effectiveEndOn == null ? Optional.empty() : effectiveEndOn;
|
||||
Objects.requireNonNull(history);
|
||||
}
|
||||
}
|
||||
+7
@@ -36,6 +36,13 @@ public final class GenerateRentReceiptService {
|
||||
if (due.settlement() != RentDueSettlement.PAID) {
|
||||
throw new RentRuleViolation("Une quittance ne peut être émise que pour une échéance entièrement réglée.");
|
||||
}
|
||||
if (due.total().signum() == 0) {
|
||||
throw new RentRuleViolation("Une quittance ne peut pas être émise pour une échéance masquée de fin de bail.");
|
||||
}
|
||||
if (due.correctionRequired()) {
|
||||
throw new RentRuleViolation(
|
||||
"Une quittance ne peut pas être émise avant la correction des affectations de l'échéance.");
|
||||
}
|
||||
Lease lease = leases.find(landlordId, due.leaseId()).orElseThrow(() -> new ResourceNotFound("Lease"));
|
||||
LeaseContractVersion contract = lease.contractVersionOn(due.month().atEndOfMonth());
|
||||
RentReceipt receipt = new RentReceipt(due.id(), due.month(), due.rent(), due.charges(), due.total(),
|
||||
|
||||
+17
-3
@@ -21,6 +21,7 @@ import java.time.LocalDate;
|
||||
import java.time.YearMonth;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.ArrayList;
|
||||
|
||||
public final class RefreshRentScheduleService {
|
||||
private final LeaseRepository leases;
|
||||
@@ -68,9 +69,12 @@ public final class RefreshRentScheduleService {
|
||||
}
|
||||
YearMonth blockStart = existing.stream().map(RentDue::month).max(YearMonth::compareTo).orElseThrow()
|
||||
.plusMonths(1);
|
||||
List<RentDue> extension = RentSchedule.extendStandard(lease.id(), landlordId,
|
||||
if (lease.contractEndOn().filter(endOn -> blockStart.atDay(1).isAfter(endOn)).isPresent()) {
|
||||
return;
|
||||
}
|
||||
List<RentDue> extension = new ArrayList<>(RentSchedule.extendStandard(lease.id(), landlordId,
|
||||
lease.contractVersionOn(blockStart.atDay(1)), existing, today,
|
||||
month -> new RentDueId(identifiers.next()));
|
||||
month -> new RentDueId(identifiers.next())));
|
||||
if (!extension.isEmpty()) {
|
||||
YearMonth firstMonth = extension.getFirst().month();
|
||||
YearMonth lastMonth = extension.getLast().month();
|
||||
@@ -81,6 +85,15 @@ public final class RefreshRentScheduleService {
|
||||
new LeaseAmended(lease.id(), landlordId, amendment), extension,
|
||||
month -> new RentDueId(identifiers.next())));
|
||||
applyRevisions(landlordId, lease, extension);
|
||||
lease.contractEndOn().ifPresent(endOn -> {
|
||||
List<RentRevision> applied = revisions == null
|
||||
? List.of() : revisions.findAll(landlordId, lease.id());
|
||||
RentSchedule.EndReconciliation ending = RentSchedule.reconcileEnd(
|
||||
extension, lease.scheduledOccupancyPeriod(), endOn,
|
||||
RentTermsTimeline.finalMonthAmounts(
|
||||
lease, applied, lease.scheduledOccupancyPeriod(), endOn));
|
||||
extension.removeAll(ending.toRemove());
|
||||
});
|
||||
rentDues.saveAll(extension);
|
||||
}
|
||||
}
|
||||
@@ -124,7 +137,8 @@ public final class RefreshRentScheduleService {
|
||||
}
|
||||
|
||||
private static List<RentDue> sorted(List<RentDue> dues) {
|
||||
return dues.stream().sorted(Comparator.comparing(RentDue::month).thenComparing(due -> due.id().value()))
|
||||
return dues.stream().filter(due -> due.total().signum() > 0 || due.hasAllocations())
|
||||
.sorted(Comparator.comparing(RentDue::month).thenComparing(due -> due.id().value()))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -30,7 +30,8 @@ public final class RentDueQueryService {
|
||||
}
|
||||
|
||||
private static List<RentDue> sorted(List<RentDue> rentDues) {
|
||||
return rentDues.stream().sorted(Comparator.comparing(RentDue::month).thenComparing(due -> due.id().value()))
|
||||
return rentDues.stream().filter(due -> due.total().signum() > 0 || due.hasAllocations())
|
||||
.sorted(Comparator.comparing(RentDue::month).thenComparing(due -> due.id().value()))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
+25
-22
@@ -111,6 +111,7 @@ public final class RentRevisionService {
|
||||
LocalDate effectiveOn = effectiveDate(rentDues.findAll(landlordId, leaseId), request.requestedOn(),
|
||||
currentTerms.dueDay());
|
||||
rejectFinancialChangeBeforeEffect(lease, request.requestedOn(), effectiveOn);
|
||||
rejectAfterLeaseEnd(lease, effectiveOn);
|
||||
RentRevision.IndexReference indexReference = new RentRevision.IndexReference(preview.seriesId(),
|
||||
preview.seriesOverridden(), preview.seriesOverridden(),
|
||||
RentRevision.IndexSource.valueOf(preview.indexSource()), "MANUAL".equals(preview.indexSource()),
|
||||
@@ -222,11 +223,15 @@ public final class RentRevisionService {
|
||||
throw new LeaseRuleViolation("Le bail ne prévoit pas de révision IRL.");
|
||||
}
|
||||
LocalDate today = LocalDate.now(clock.withZone(request.timeZone()));
|
||||
LocalDate annualDate = revisionTerms.annualRevisionDate();
|
||||
int annualYear = lease.period().startDate().getYear();
|
||||
LocalDate annualDate = revisionTerms.annualRevisionDay().atYear(annualYear);
|
||||
if (!annualDate.isAfter(lease.period().startDate())) {
|
||||
annualDate = revisionTerms.annualRevisionDay().atYear(++annualYear);
|
||||
}
|
||||
LocalDate deadline = RentRevisionPolicy.requestDeadline(annualDate);
|
||||
int expiredYears = 0;
|
||||
while (today.isAfter(deadline) || alreadyApplied(landlordId, leaseId, annualDate)) {
|
||||
annualDate = annualDate.plusYears(1);
|
||||
annualDate = revisionTerms.annualRevisionDay().atYear(++annualYear);
|
||||
deadline = RentRevisionPolicy.requestDeadline(annualDate);
|
||||
expiredYears++;
|
||||
}
|
||||
@@ -256,29 +261,13 @@ public final class RentRevisionService {
|
||||
}
|
||||
|
||||
private RentTerms rentTermsOn(LandlordId landlordId, Lease lease, LocalDate date) {
|
||||
RentTerms contractTerms = lease.contractVersionOn(date).terms();
|
||||
if (revisions == null) {
|
||||
return contractTerms;
|
||||
}
|
||||
LocalDate lastFinancialAmendment = lease.amendments().stream()
|
||||
.filter(amendment -> !amendment.effectiveDate().isAfter(date))
|
||||
.filter(RentRevisionService::changesAmounts)
|
||||
.map(amendment -> amendment.effectiveDate()).max(LocalDate::compareTo).orElse(LocalDate.MIN);
|
||||
RentRevision latest = revisions.findAll(landlordId, lease.id()).stream()
|
||||
.filter(revision -> revision.status() == RentRevision.Status.APPLIED)
|
||||
.filter(revision -> !revision.application().effectiveOn().isAfter(date))
|
||||
.filter(revision -> !revision.application().effectiveOn().isBefore(lastFinancialAmendment))
|
||||
.max(Comparator.comparing(revision -> revision.application().effectiveOn())).orElse(null);
|
||||
if (latest == null) {
|
||||
return contractTerms;
|
||||
}
|
||||
return new RentTerms(latest.amounts().chosenRent(), latest.amounts().appliedCharges(),
|
||||
contractTerms.securityDeposit(), contractTerms.dueDay(), contractTerms.chargeMethod(),
|
||||
contractTerms.indexFlatRate());
|
||||
return RentTermsTimeline.on(lease,
|
||||
revisions == null ? List.of() : revisions.findAll(landlordId, lease.id()), date);
|
||||
}
|
||||
|
||||
private ApplyResult applyInTransaction(LandlordId landlordId, Lease lease, RentRevision proposed,
|
||||
RentRevision.Application application, List<RentDue> existing) {
|
||||
rejectAfterLeaseEnd(lease, application.effectiveOn());
|
||||
if (revisions.findAll(landlordId, lease.id()).stream()
|
||||
.anyMatch(revision -> !revision.id().equals(proposed.id())
|
||||
&& revision.status() == RentRevision.Status.APPLIED
|
||||
@@ -293,7 +282,15 @@ public final class RentRevisionService {
|
||||
List<RentDue> changed = boundary == null
|
||||
? RentSchedule.applyRevision(existing, application.effectiveOn(), amounts)
|
||||
: RentSchedule.applyRevision(existing, application.effectiveOn(), amounts, boundary);
|
||||
List<RentDue> savedDues = rentDues.saveAll(changed);
|
||||
java.util.LinkedHashSet<RentDue> boundedChanges = new java.util.LinkedHashSet<>(changed);
|
||||
lease.contractEndOn().ifPresent(endOn -> {
|
||||
List<RentRevision> timeline = new java.util.ArrayList<>(revisions.findAll(landlordId, lease.id()));
|
||||
timeline.add(applied);
|
||||
boundedChanges.addAll(RentSchedule.reconcileEnd(existing, lease.scheduledOccupancyPeriod(), endOn,
|
||||
RentTermsTimeline.finalMonthAmounts(
|
||||
lease, timeline, lease.scheduledOccupancyPeriod(), endOn)).toSave());
|
||||
});
|
||||
List<RentDue> savedDues = rentDues.saveAll(List.copyOf(boundedChanges));
|
||||
return new ApplyResult(revisions.save(applied), savedDues);
|
||||
}
|
||||
|
||||
@@ -331,6 +328,12 @@ public final class RentRevisionService {
|
||||
}
|
||||
}
|
||||
|
||||
private static void rejectAfterLeaseEnd(Lease lease, LocalDate effectiveOn) {
|
||||
if (lease.contractEndOn().filter(endOn -> effectiveOn.isAfter(endOn)).isPresent()) {
|
||||
throw new LeaseRuleViolation("Une révision IRL ne peut pas prendre effet après la fin du bail.");
|
||||
}
|
||||
}
|
||||
|
||||
private static RentSchedule.FinancialBoundary nextFinancialBoundary(Lease lease, LocalDate effectiveOn) {
|
||||
return lease.amendments().stream().filter(amendment -> amendment.effectiveDate().isAfter(effectiveOn))
|
||||
.filter(RentRevisionService::changesAmounts)
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rent;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseAmendment;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.OccupancyPeriod;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.RentTerms;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentRevision;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentSchedule;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.YearMonth;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
public final class RentTermsTimeline {
|
||||
private RentTermsTimeline() {
|
||||
}
|
||||
|
||||
static RentTerms on(Lease lease, List<RentRevision> revisions, LocalDate date) {
|
||||
RentTerms contractTerms = lease.contractVersionOn(date).terms();
|
||||
LocalDate lastFinancialAmendment = lease.amendments().stream()
|
||||
.filter(amendment -> !amendment.effectiveDate().isAfter(date))
|
||||
.filter(RentTermsTimeline::changesAmounts)
|
||||
.map(LeaseAmendment::effectiveDate).max(LocalDate::compareTo).orElse(LocalDate.MIN);
|
||||
RentRevision latest = safe(revisions).stream()
|
||||
.filter(revision -> revision.status() == RentRevision.Status.APPLIED)
|
||||
.filter(revision -> !revision.application().effectiveOn().isAfter(date))
|
||||
.filter(revision -> !revision.application().effectiveOn().isBefore(lastFinancialAmendment))
|
||||
.max(Comparator.comparing(revision -> revision.application().effectiveOn())).orElse(null);
|
||||
if (latest == null) {
|
||||
return contractTerms;
|
||||
}
|
||||
return new RentTerms(latest.amounts().chosenRent(), latest.amounts().appliedCharges(),
|
||||
contractTerms.securityDeposit(), contractTerms.dueDay(), contractTerms.chargeMethod(),
|
||||
contractTerms.indexFlatRate());
|
||||
}
|
||||
|
||||
public static RentSchedule.EndAmounts finalMonthAmounts(Lease lease, List<RentRevision> revisions,
|
||||
OccupancyPeriod scheduledOccupancy, LocalDate endOn) {
|
||||
YearMonth month = YearMonth.from(endOn);
|
||||
LocalDate firstOccupiedOn = later(month.atDay(1), scheduledOccupancy.startOn());
|
||||
LocalDate lastOccupiedOn = earlier(endOn, scheduledOccupancy.endOn().orElse(endOn));
|
||||
BigDecimal rentDays = BigDecimal.ZERO;
|
||||
BigDecimal chargeDays = BigDecimal.ZERO;
|
||||
for (LocalDate date = firstOccupiedOn; !date.isAfter(lastOccupiedOn); date = date.plusDays(1)) {
|
||||
RentTerms terms = on(lease, revisions, date);
|
||||
rentDays = rentDays.add(terms.monthlyRent());
|
||||
chargeDays = chargeDays.add(terms.monthlyCharges());
|
||||
}
|
||||
BigDecimal daysInMonth = BigDecimal.valueOf(month.lengthOfMonth());
|
||||
return new RentSchedule.EndAmounts(rentDays.divide(daysInMonth, 2, RoundingMode.HALF_UP),
|
||||
chargeDays.divide(daysInMonth, 2, RoundingMode.HALF_UP));
|
||||
}
|
||||
|
||||
static boolean changesAmounts(LeaseAmendment amendment) {
|
||||
return amendment.beforeVersion().terms().monthlyRent()
|
||||
.compareTo(amendment.afterVersion().terms().monthlyRent()) != 0
|
||||
|| amendment.beforeVersion().terms().monthlyCharges()
|
||||
.compareTo(amendment.afterVersion().terms().monthlyCharges()) != 0;
|
||||
}
|
||||
|
||||
private static List<RentRevision> safe(List<RentRevision> revisions) {
|
||||
return revisions == null ? List.of() : revisions;
|
||||
}
|
||||
|
||||
private static LocalDate later(LocalDate first, LocalDate second) {
|
||||
return first.isAfter(second) ? first : second;
|
||||
}
|
||||
|
||||
private static LocalDate earlier(LocalDate first, LocalDate second) {
|
||||
return first.isBefore(second) ? first : second;
|
||||
}
|
||||
}
|
||||
+8
-4
@@ -17,6 +17,8 @@ import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversalRepository;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Objects;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@@ -40,21 +42,23 @@ public final class ReverseRentPaymentService {
|
||||
}
|
||||
|
||||
public RentPaymentReversal reverse(LandlordId landlordId, RentPaymentId paymentId, long paymentVersion,
|
||||
String reason) {
|
||||
String reason, ZoneId timeZone) {
|
||||
Objects.requireNonNull(timeZone, "Time zone is required.");
|
||||
return transactions.run(() -> reversals.findByPayment(landlordId, paymentId)
|
||||
.map(existing -> idempotentReversal(existing, reason))
|
||||
.orElseGet(() -> reverseInTransaction(landlordId, paymentId, paymentVersion, reason)));
|
||||
.orElseGet(() -> reverseInTransaction(landlordId, paymentId, paymentVersion, reason, timeZone)));
|
||||
}
|
||||
|
||||
private RentPaymentReversal reverseInTransaction(LandlordId landlordId, RentPaymentId paymentId,
|
||||
long paymentVersion, String reason) {
|
||||
long paymentVersion, String reason, ZoneId timeZone) {
|
||||
RentPayment payment = payments.find(landlordId, paymentId)
|
||||
.orElseThrow(() -> new ResourceNotFound("Rent payment"));
|
||||
if (payment.version() != paymentVersion) {
|
||||
throw new ConcurrentChange("Rent payment");
|
||||
}
|
||||
RentPaymentReversal reversal = RentPaymentReversal.reverse(
|
||||
new RentPaymentReversalId(identifiers.next()), payment, LocalDate.now(clock), reason);
|
||||
new RentPaymentReversalId(identifiers.next()), payment,
|
||||
LocalDate.now(clock.withZone(timeZone)), reason);
|
||||
List<RentDue> affectedDues = new ArrayList<>();
|
||||
for (PaymentAllocation allocation : reversal.reversedAllocations()) {
|
||||
RentDue due = rentDues.find(landlordId, allocation.rentDueId())
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.lease;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Objects;
|
||||
|
||||
public record AgreedEarlyLeaseEnd(LocalDate endOn, String reason, LeaseActionAudit audit) {
|
||||
public AgreedEarlyLeaseEnd {
|
||||
Objects.requireNonNull(endOn);
|
||||
reason = LeaseEndingHistory.requiredReason(reason);
|
||||
Objects.requireNonNull(audit);
|
||||
}
|
||||
}
|
||||
+13
-1
@@ -34,6 +34,10 @@ public final class ApartmentOccupancy {
|
||||
}
|
||||
|
||||
public void reserve(LeaseId leaseId, RentalPeriod period) {
|
||||
reserve(leaseId, OccupancyPeriod.closed(period));
|
||||
}
|
||||
|
||||
public void reserve(LeaseId leaseId, OccupancyPeriod period) {
|
||||
Objects.requireNonNull(leaseId);
|
||||
Objects.requireNonNull(period);
|
||||
if (reservations.stream().anyMatch(existing -> existing.leaseId().equals(leaseId))) {
|
||||
@@ -48,6 +52,10 @@ public final class ApartmentOccupancy {
|
||||
}
|
||||
|
||||
public void reviseReservation(LeaseId leaseId, RentalPeriod period) {
|
||||
reviseReservation(leaseId, OccupancyPeriod.closed(period));
|
||||
}
|
||||
|
||||
public void reviseReservation(LeaseId leaseId, OccupancyPeriod period) {
|
||||
Objects.requireNonNull(leaseId);
|
||||
Objects.requireNonNull(period);
|
||||
Reservation current = reservations.stream().filter(existing -> existing.leaseId().equals(leaseId))
|
||||
@@ -77,7 +85,11 @@ public final class ApartmentOccupancy {
|
||||
return version;
|
||||
}
|
||||
|
||||
public record Reservation(LeaseId leaseId, RentalPeriod period) {
|
||||
public record Reservation(LeaseId leaseId, OccupancyPeriod period) {
|
||||
public Reservation(LeaseId leaseId, RentalPeriod period) {
|
||||
this(leaseId, OccupancyPeriod.closed(period));
|
||||
}
|
||||
|
||||
public Reservation {
|
||||
Objects.requireNonNull(leaseId);
|
||||
Objects.requireNonNull(period);
|
||||
|
||||
@@ -8,12 +8,14 @@ import java.time.LocalDate;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
public final class Lease {
|
||||
private final LeaseId id;
|
||||
private final LandlordId landlordId;
|
||||
private final long version;
|
||||
private final LeaseAmendmentHistory amendments;
|
||||
private final LeaseEndingHistory endingHistory;
|
||||
private ApartmentId apartmentId;
|
||||
private List<TenantId> tenantIds;
|
||||
private List<Guarantor> guarantors;
|
||||
@@ -28,7 +30,8 @@ public final class Lease {
|
||||
private Lease(LeaseId id, LandlordId landlordId, ApartmentId apartmentId, List<TenantId> tenantIds,
|
||||
List<Guarantor> guarantors, RentalPeriod period, RentTerms terms, LeaseType leaseType,
|
||||
List<LeaseSpecialClause> specialClauses, LeaseLegalTerms legalTerms, LeaseStatus status,
|
||||
LeaseSnapshot snapshot, List<LeaseAmendment> amendments, long version) {
|
||||
LeaseSnapshot snapshot, List<LeaseAmendment> amendments, LeaseEndingHistory endingHistory,
|
||||
long version) {
|
||||
this.id = Objects.requireNonNull(id);
|
||||
this.landlordId = Objects.requireNonNull(landlordId);
|
||||
if (version < -1) {
|
||||
@@ -47,13 +50,15 @@ public final class Lease {
|
||||
this.status = Objects.requireNonNull(status);
|
||||
this.snapshot = snapshot;
|
||||
this.amendments = new LeaseAmendmentHistory(amendments);
|
||||
this.endingHistory = endingHistory == null ? LeaseEndingHistory.empty() : endingHistory;
|
||||
}
|
||||
|
||||
public static Lease draft(LeaseId id, LandlordId landlordId, ApartmentId apartmentId,
|
||||
List<TenantId> tenantIds, List<Guarantor> guarantors, RentalPeriod period,
|
||||
RentTerms terms) {
|
||||
return new Lease(id, landlordId, apartmentId, tenantIds, guarantors, period, terms, LeaseType.STANDARD,
|
||||
List.of(), LeaseLegalTerms.empty(), LeaseStatus.DRAFT, null, List.of(), -1);
|
||||
List.of(), LeaseLegalTerms.empty(), LeaseStatus.DRAFT, null, List.of(), LeaseEndingHistory.empty(),
|
||||
-1);
|
||||
}
|
||||
|
||||
public static Lease draft(LeaseId id, LandlordId landlordId, ApartmentId apartmentId,
|
||||
@@ -76,7 +81,7 @@ public final class Lease {
|
||||
LeaseType type = LeaseType.orStandard(leaseType);
|
||||
return new Lease(id, landlordId, apartmentId, tenantIds, guarantors,
|
||||
type.initialPeriod(period.startDate()), terms, type, specialClauses, legalTerms, LeaseStatus.DRAFT,
|
||||
null, List.of(), -1);
|
||||
null, List.of(), LeaseEndingHistory.empty(), -1);
|
||||
}
|
||||
|
||||
public static Lease reconstitute(LeaseId id, LandlordId landlordId, ApartmentId apartmentId,
|
||||
@@ -133,6 +138,16 @@ public final class Lease {
|
||||
RentTerms terms, LeaseType leaseType, List<LeaseSpecialClause> specialClauses,
|
||||
LeaseLegalTerms legalTerms, LeaseStatus status, LeaseSnapshot snapshot,
|
||||
List<LeaseAmendment> amendments, long version) {
|
||||
return reconstitute(id, landlordId, apartmentId, tenantIds, guarantors, period, terms, leaseType,
|
||||
specialClauses, legalTerms, status, snapshot, amendments, LeaseEndingHistory.empty(), version);
|
||||
}
|
||||
|
||||
public static Lease reconstitute(LeaseId id, LandlordId landlordId, ApartmentId apartmentId,
|
||||
List<TenantId> tenantIds, List<Guarantor> guarantors, RentalPeriod period,
|
||||
RentTerms terms, LeaseType leaseType, List<LeaseSpecialClause> specialClauses,
|
||||
LeaseLegalTerms legalTerms, LeaseStatus status, LeaseSnapshot snapshot,
|
||||
List<LeaseAmendment> amendments, LeaseEndingHistory endingHistory,
|
||||
long version) {
|
||||
if (status == LeaseStatus.DRAFT && snapshot != null) {
|
||||
throw new LeaseRuleViolation("Un bail en brouillon ne peut pas avoir d'instantané contractuel.");
|
||||
}
|
||||
@@ -143,7 +158,7 @@ public final class Lease {
|
||||
throw new LeaseRuleViolation("Un bail en brouillon ne peut pas avoir d'avenant.");
|
||||
}
|
||||
Lease lease = new Lease(id, landlordId, apartmentId, tenantIds, guarantors, period, terms, leaseType,
|
||||
specialClauses, legalTerms, status, snapshot, amendments, version);
|
||||
specialClauses, legalTerms, status, snapshot, amendments, endingHistory, version);
|
||||
if (snapshot != null) {
|
||||
lease.validateTenantIds();
|
||||
lease.requireSnapshotIdentity(snapshot);
|
||||
@@ -208,19 +223,204 @@ public final class Lease {
|
||||
if (status != LeaseStatus.ACTIVE) {
|
||||
throw new LeaseRuleViolation("Seul un bail actif peut être modifié par avenant.");
|
||||
}
|
||||
Objects.requireNonNull(afterVersion, "Amended contract version is required.");
|
||||
if (contractEndOn().filter(endOn -> effectiveDate.isAfter(endOn)).isPresent()) {
|
||||
throw new LeaseRuleViolation("Un avenant ne peut pas prendre effet après la fin du bail.");
|
||||
}
|
||||
if (contractEndOn(afterVersion).filter(endOn -> effectiveDate.isAfter(endOn)).isPresent()) {
|
||||
throw new LeaseRuleViolation("Un avenant ne peut pas prendre effet après sa nouvelle fin de bail.");
|
||||
}
|
||||
if (hasActiveEndingFact()) {
|
||||
if (afterVersion.tenantIds().size() != 1) {
|
||||
throw new LeaseRuleViolation(
|
||||
"Un avenant ne peut pas transformer en colocation un bail ayant une fin active.");
|
||||
}
|
||||
if (!contractEndOn().equals(contractEndOn(afterVersion))) {
|
||||
throw new LeaseRuleViolation(
|
||||
"Un avenant ne peut pas modifier la fin contractuelle active du bail.");
|
||||
}
|
||||
}
|
||||
LeaseAmendment amendment = amendments.append(amendmentId, effectiveDate, contractVersion(), afterVersion);
|
||||
apply(amendment.afterVersion());
|
||||
return new LeaseAmended(id, landlordId, amendment);
|
||||
}
|
||||
|
||||
public void archive() {
|
||||
if (status == LeaseStatus.ARCHIVED) {
|
||||
return;
|
||||
public TenantNotice recordTenantNotice(TenantNoticeCommand command, LeaseActionAudit audit) {
|
||||
requireActiveEndingSupported();
|
||||
Objects.requireNonNull(command);
|
||||
Objects.requireNonNull(audit);
|
||||
if (command.receivedOn().isAfter(command.today())) {
|
||||
throw new LeaseRuleViolation("La date de réception du congé ne peut pas être future.");
|
||||
}
|
||||
if (status != LeaseStatus.ACTIVE) {
|
||||
throw new LeaseRuleViolation("Seul un bail actif peut être archivé.");
|
||||
if (command.receivedOn().isBefore(command.today()) && !command.lateRegistrationConfirmed()) {
|
||||
throw new LeaseRuleViolation("La saisie tardive du congé doit être confirmée.");
|
||||
}
|
||||
if (command.requestedDepartureOn().isBefore(command.receivedOn())) {
|
||||
throw new LeaseRuleViolation("La date de départ demandée ne peut pas précéder la réception du congé.");
|
||||
}
|
||||
if (command.deliveryMode() == NoticeDeliveryMode.OTHER
|
||||
&& !command.nonStandardDeliveryConfirmed()) {
|
||||
throw new LeaseRuleViolation("Le mode de remise non standard doit être confirmé.");
|
||||
}
|
||||
TenantNotice notice = new TenantNotice(command.id(), TenantNoticeStatus.ACTIVE, command.receivedOn(),
|
||||
command.requestedDepartureOn(), command.deliveryMode(), command.otherDeliveryMode(), 1,
|
||||
command.receivedOn().plusMonths(1), "2026-07", command.recordedWithoutEvidenceConfirmed(), audit);
|
||||
return endingHistory.recordNotice(notice);
|
||||
}
|
||||
|
||||
public NoticeWithdrawal acceptNoticeWithdrawal(TenantNoticeId noticeId, LocalDate acceptedOn, String reason,
|
||||
LocalDate today, LeaseActionAudit audit) {
|
||||
requireActiveEndingSupported();
|
||||
TenantNotice notice = endingHistory.notice(noticeId)
|
||||
.orElseThrow(() -> new LeaseRuleViolation("Le congé locataire est introuvable."));
|
||||
validateAdministrativeFactDate(acceptedOn, today, notice.receivedOn());
|
||||
return endingHistory.withdraw(noticeId, acceptedOn, reason, audit);
|
||||
}
|
||||
|
||||
public NoticeInvalidation invalidateNotice(TenantNoticeId noticeId, LocalDate invalidatedOn, String reason,
|
||||
LocalDate today, LeaseActionAudit audit) {
|
||||
requireActiveEndingSupported();
|
||||
TenantNotice notice = endingHistory.notice(noticeId)
|
||||
.orElseThrow(() -> new LeaseRuleViolation("Le congé locataire est introuvable."));
|
||||
validateAdministrativeFactDate(invalidatedOn, today, notice.receivedOn());
|
||||
return endingHistory.invalidate(noticeId, invalidatedOn, reason, audit);
|
||||
}
|
||||
|
||||
public AgreedEarlyLeaseEnd recordAgreedEarlyEnd(LocalDate endOn, String reason, LeaseActionAudit audit) {
|
||||
requireActiveEndingSupported();
|
||||
Objects.requireNonNull(endOn, "Agreed end date is required.");
|
||||
if (endOn.isBefore(period.startDate())) {
|
||||
throw new LeaseRuleViolation("La fin convenue ne peut pas précéder le début du bail.");
|
||||
}
|
||||
Optional<LocalDate> currentEnd = contractEndOn();
|
||||
if (currentEnd.isPresent() && !endOn.isBefore(currentEnd.orElseThrow())) {
|
||||
throw new LeaseRuleViolation("La fin convenue doit avancer la fin de bail actuelle.");
|
||||
}
|
||||
return endingHistory.agree(endOn, reason, audit);
|
||||
}
|
||||
|
||||
public NoticeEvidenceVersion appendNoticeEvidence(TenantNoticeId noticeId, NoticeEvidenceFile file,
|
||||
String reason, LeaseActionAudit audit) {
|
||||
if (status == LeaseStatus.DRAFT) {
|
||||
throw new LeaseRuleViolation("Un justificatif ne peut être ajouté qu'à un bail activé.");
|
||||
}
|
||||
return endingHistory.appendEvidence(noticeId, file, reason, audit);
|
||||
}
|
||||
|
||||
public LeaseClosure close(LocalDate keysReturnedOn, boolean confirmed, LocalDate today,
|
||||
LeaseActionAudit audit) {
|
||||
requireActiveEndingSupported();
|
||||
if (!confirmed) {
|
||||
throw new LeaseRuleViolation("La clôture du bail doit être explicitement confirmée.");
|
||||
}
|
||||
LocalDate endOn = contractEndOn()
|
||||
.orElseThrow(() -> new LeaseRuleViolation("Le bail n'a pas encore de fin contractuelle."));
|
||||
Objects.requireNonNull(keysReturnedOn, "Keys return date is required.");
|
||||
Objects.requireNonNull(today, "Current civil date is required.");
|
||||
if (today.isBefore(endOn)) {
|
||||
throw new LeaseRuleViolation("Le bail ne peut être clôturé avant sa fin contractuelle.");
|
||||
}
|
||||
if (keysReturnedOn.isBefore(period.startDate()) || keysReturnedOn.isAfter(today)) {
|
||||
throw new LeaseRuleViolation("La date de restitution des clés doit être comprise entre le début du bail et aujourd'hui.");
|
||||
}
|
||||
LeaseClosure closure = endingHistory.close(keysReturnedOn, audit);
|
||||
status = LeaseStatus.ARCHIVED;
|
||||
return closure;
|
||||
}
|
||||
|
||||
public boolean endingSupported() {
|
||||
return tenantIds.size() == 1;
|
||||
}
|
||||
|
||||
public Optional<LocalDate> fixedEndOn() {
|
||||
return leaseType == LeaseType.STUDENT ? Optional.of(period.endDate()) : Optional.empty();
|
||||
}
|
||||
|
||||
public Optional<LocalDate> contractEndOn() {
|
||||
return contractEndOn(leaseType, period);
|
||||
}
|
||||
|
||||
private Optional<LocalDate> contractEndOn(LeaseContractVersion version) {
|
||||
return contractEndOn(version.leaseType(), version.period());
|
||||
}
|
||||
|
||||
private Optional<LocalDate> contractEndOn(LeaseType type, RentalPeriod rentalPeriod) {
|
||||
Optional<LocalDate> end = type == LeaseType.STUDENT
|
||||
? Optional.of(rentalPeriod.endDate())
|
||||
: Optional.empty();
|
||||
Optional<TenantNotice> notice = endingHistory.activeNotice();
|
||||
if (notice.isPresent()) {
|
||||
end = earlier(end, notice.orElseThrow().effectiveEndOn());
|
||||
}
|
||||
Optional<AgreedEarlyLeaseEnd> agreement = endingHistory.latestAgreement();
|
||||
if (agreement.isPresent()) {
|
||||
end = earlier(end, agreement.orElseThrow().endOn());
|
||||
}
|
||||
return end;
|
||||
}
|
||||
|
||||
private boolean hasActiveEndingFact() {
|
||||
return endingHistory.activeNotice().isPresent() || endingHistory.latestAgreement().isPresent();
|
||||
}
|
||||
|
||||
public OccupancyPeriod occupancyPeriod() {
|
||||
Optional<LocalDate> endOn = contractEndOn();
|
||||
return endOn.isPresent()
|
||||
? OccupancyPeriod.closed(period.startDate(), endOn.orElseThrow())
|
||||
: OccupancyPeriod.open(period.startDate());
|
||||
}
|
||||
|
||||
public OccupancyPeriod scheduledOccupancyPeriod() {
|
||||
return leaseType == LeaseType.STUDENT
|
||||
? OccupancyPeriod.closed(period.startDate(), period.endDate())
|
||||
: OccupancyPeriod.open(period.startDate());
|
||||
}
|
||||
|
||||
public LeaseEndPhase endPhaseOn(LocalDate date) {
|
||||
Objects.requireNonNull(date, "Phase date is required.");
|
||||
if (status == LeaseStatus.ARCHIVED) {
|
||||
return LeaseEndPhase.ARCHIVED;
|
||||
}
|
||||
Optional<LocalDate> end = contractEndOn();
|
||||
if (end.isEmpty()) {
|
||||
return LeaseEndPhase.ACTIVE;
|
||||
}
|
||||
LocalDate endOn = end.orElseThrow();
|
||||
if (date.isAfter(endOn)) {
|
||||
return LeaseEndPhase.UNAUTHORIZED_OCCUPANCY;
|
||||
}
|
||||
if (date.equals(endOn)) {
|
||||
return LeaseEndPhase.AWAITING_CLOSURE;
|
||||
}
|
||||
return endingHistory.activeNotice().isPresent() || endingHistory.latestAgreement().isPresent()
|
||||
? LeaseEndPhase.NOTICE_PERIOD
|
||||
: LeaseEndPhase.ACTIVE;
|
||||
}
|
||||
|
||||
private void requireActiveEndingSupported() {
|
||||
if (status != LeaseStatus.ACTIVE) {
|
||||
throw new LeaseRuleViolation("Seul un bail actif peut recevoir un fait de fin de bail.");
|
||||
}
|
||||
requireEndingSupported();
|
||||
}
|
||||
|
||||
private void requireEndingSupported() {
|
||||
if (!endingSupported()) {
|
||||
throw new LeaseRuleViolation("La fin de bail locataire n'est pas prise en charge pour une colocation.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateAdministrativeFactDate(LocalDate factOn, LocalDate today,
|
||||
LocalDate noticeReceivedOn) {
|
||||
Objects.requireNonNull(factOn, "Administrative fact date is required.");
|
||||
Objects.requireNonNull(today, "Current civil date is required.");
|
||||
if (factOn.isBefore(noticeReceivedOn) || factOn.isAfter(today)) {
|
||||
throw new LeaseRuleViolation("La date administrative doit suivre le congé et ne peut pas être future.");
|
||||
}
|
||||
}
|
||||
|
||||
private static Optional<LocalDate> earlier(Optional<LocalDate> current, LocalDate candidate) {
|
||||
return current.isEmpty() || candidate.isBefore(current.orElseThrow()) ? Optional.of(candidate) : current;
|
||||
}
|
||||
|
||||
private void requireMatchingSnapshot(LeaseSnapshot frozenContract) {
|
||||
@@ -351,6 +551,10 @@ public final class Lease {
|
||||
return amendments.values();
|
||||
}
|
||||
|
||||
public LeaseEndingHistory endingHistory() {
|
||||
return endingHistory;
|
||||
}
|
||||
|
||||
public long version() {
|
||||
return version;
|
||||
}
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.lease;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
public record LeaseActionAudit(String authorId, Instant recordedAt) {
|
||||
public LeaseActionAudit {
|
||||
if (authorId == null || authorId.isBlank()) {
|
||||
throw new LeaseRuleViolation("L'auteur de l'action est obligatoire.");
|
||||
}
|
||||
authorId = authorId.trim();
|
||||
Objects.requireNonNull(recordedAt, "Action date is required.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.lease;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Objects;
|
||||
|
||||
public record LeaseClosure(LocalDate keysReturnedOn, LeaseActionAudit audit) {
|
||||
public LeaseClosure {
|
||||
Objects.requireNonNull(keysReturnedOn);
|
||||
Objects.requireNonNull(audit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.lease;
|
||||
|
||||
public enum LeaseEndPhase {
|
||||
ACTIVE,
|
||||
NOTICE_PERIOD,
|
||||
AWAITING_CLOSURE,
|
||||
UNAUTHORIZED_OCCUPANCY,
|
||||
ARCHIVED
|
||||
}
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.lease;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
public final class LeaseEndingHistory {
|
||||
private List<TenantNotice> notices;
|
||||
private List<NoticeWithdrawal> withdrawals;
|
||||
private List<NoticeInvalidation> invalidations;
|
||||
private List<AgreedEarlyLeaseEnd> agreedEarlyEnds;
|
||||
private List<NoticeEvidenceVersion> evidenceVersions;
|
||||
private LeaseClosure closure;
|
||||
|
||||
private LeaseEndingHistory(List<TenantNotice> notices, List<NoticeWithdrawal> withdrawals,
|
||||
List<NoticeInvalidation> invalidations,
|
||||
List<AgreedEarlyLeaseEnd> agreedEarlyEnds,
|
||||
List<NoticeEvidenceVersion> evidenceVersions, LeaseClosure closure) {
|
||||
this.notices = copy(notices);
|
||||
this.withdrawals = copy(withdrawals);
|
||||
this.invalidations = copy(invalidations);
|
||||
this.agreedEarlyEnds = copy(agreedEarlyEnds);
|
||||
this.evidenceVersions = copy(evidenceVersions);
|
||||
this.closure = closure;
|
||||
validateHistory();
|
||||
}
|
||||
|
||||
public static LeaseEndingHistory empty() {
|
||||
return new LeaseEndingHistory(List.of(), List.of(), List.of(), List.of(), List.of(), null);
|
||||
}
|
||||
|
||||
public static LeaseEndingHistory reconstitute(List<TenantNotice> notices,
|
||||
List<NoticeWithdrawal> withdrawals,
|
||||
List<NoticeInvalidation> invalidations,
|
||||
List<AgreedEarlyLeaseEnd> agreedEarlyEnds,
|
||||
List<NoticeEvidenceVersion> evidenceVersions,
|
||||
LeaseClosure closure) {
|
||||
return new LeaseEndingHistory(notices, withdrawals, invalidations, agreedEarlyEnds, evidenceVersions,
|
||||
closure);
|
||||
}
|
||||
|
||||
TenantNotice recordNotice(TenantNotice notice) {
|
||||
Objects.requireNonNull(notice);
|
||||
if (find(notice.id()).isPresent()) {
|
||||
throw new LeaseRuleViolation("Ce congé existe déjà.");
|
||||
}
|
||||
if (activeNotice().isPresent()) {
|
||||
throw new LeaseRuleViolation("Un congé locataire est déjà actif pour ce bail.");
|
||||
}
|
||||
notices = appended(notices, notice);
|
||||
return notice;
|
||||
}
|
||||
|
||||
NoticeWithdrawal withdraw(TenantNoticeId noticeId, LocalDate acceptedOn, String reason,
|
||||
LeaseActionAudit audit) {
|
||||
requireActive(noticeId);
|
||||
NoticeWithdrawal withdrawal = new NoticeWithdrawal(noticeId, acceptedOn, reason, audit);
|
||||
withdrawals = appended(withdrawals, withdrawal);
|
||||
return withdrawal;
|
||||
}
|
||||
|
||||
NoticeInvalidation invalidate(TenantNoticeId noticeId, LocalDate invalidatedOn, String reason,
|
||||
LeaseActionAudit audit) {
|
||||
requireActive(noticeId);
|
||||
NoticeInvalidation invalidation = new NoticeInvalidation(noticeId, invalidatedOn, reason, audit);
|
||||
invalidations = appended(invalidations, invalidation);
|
||||
return invalidation;
|
||||
}
|
||||
|
||||
AgreedEarlyLeaseEnd agree(LocalDate endOn, String reason, LeaseActionAudit audit) {
|
||||
AgreedEarlyLeaseEnd agreement = new AgreedEarlyLeaseEnd(endOn, reason, audit);
|
||||
agreedEarlyEnds = appended(agreedEarlyEnds, agreement);
|
||||
return agreement;
|
||||
}
|
||||
|
||||
NoticeEvidenceVersion appendEvidence(TenantNoticeId noticeId, NoticeEvidenceFile file, String reason,
|
||||
LeaseActionAudit audit) {
|
||||
if (find(noticeId).isEmpty()) {
|
||||
throw new LeaseRuleViolation("Le congé locataire est introuvable.");
|
||||
}
|
||||
int version = evidenceVersions(noticeId).size() + 1;
|
||||
NoticeEvidenceVersion evidence = new NoticeEvidenceVersion(noticeId, version, file, reason, audit);
|
||||
evidenceVersions = appended(evidenceVersions, evidence);
|
||||
return evidence;
|
||||
}
|
||||
|
||||
LeaseClosure close(LocalDate keysReturnedOn, LeaseActionAudit audit) {
|
||||
if (closure != null) {
|
||||
throw new LeaseRuleViolation("Le bail est déjà clôturé.");
|
||||
}
|
||||
closure = new LeaseClosure(keysReturnedOn, audit);
|
||||
return closure;
|
||||
}
|
||||
|
||||
public List<TenantNotice> notices() {
|
||||
return notices.stream().map(notice -> notice.withStatus(statusOf(notice.id()))).toList();
|
||||
}
|
||||
|
||||
List<TenantNotice> recordedNotices() {
|
||||
return notices;
|
||||
}
|
||||
|
||||
public List<NoticeWithdrawal> withdrawals() {
|
||||
return withdrawals;
|
||||
}
|
||||
|
||||
public List<NoticeInvalidation> invalidations() {
|
||||
return invalidations;
|
||||
}
|
||||
|
||||
public List<AgreedEarlyLeaseEnd> agreedEarlyEnds() {
|
||||
return agreedEarlyEnds;
|
||||
}
|
||||
|
||||
public List<NoticeEvidenceVersion> evidenceVersions() {
|
||||
return evidenceVersions;
|
||||
}
|
||||
|
||||
public List<NoticeEvidenceVersion> evidenceVersions(TenantNoticeId noticeId) {
|
||||
return evidenceVersions.stream().filter(version -> version.noticeId().equals(noticeId)).toList();
|
||||
}
|
||||
|
||||
public Optional<LeaseClosure> closure() {
|
||||
return Optional.ofNullable(closure);
|
||||
}
|
||||
|
||||
public Optional<TenantNotice> activeNotice() {
|
||||
return notices.stream().filter(notice -> statusOf(notice.id()) == TenantNoticeStatus.ACTIVE).findFirst();
|
||||
}
|
||||
|
||||
Optional<TenantNotice> notice(TenantNoticeId noticeId) {
|
||||
return find(noticeId);
|
||||
}
|
||||
|
||||
public Optional<AgreedEarlyLeaseEnd> latestAgreement() {
|
||||
return agreedEarlyEnds.isEmpty() ? Optional.empty() : Optional.of(agreedEarlyEnds.getLast());
|
||||
}
|
||||
|
||||
private Optional<TenantNotice> find(TenantNoticeId noticeId) {
|
||||
return notices.stream().filter(notice -> notice.id().equals(noticeId)).findFirst();
|
||||
}
|
||||
|
||||
private TenantNotice requireActive(TenantNoticeId noticeId) {
|
||||
TenantNotice notice = find(noticeId)
|
||||
.orElseThrow(() -> new LeaseRuleViolation("Le congé locataire est introuvable."));
|
||||
if (statusOf(noticeId) != TenantNoticeStatus.ACTIVE) {
|
||||
throw new LeaseRuleViolation("Seul un congé actif peut être modifié.");
|
||||
}
|
||||
return notice;
|
||||
}
|
||||
|
||||
private TenantNoticeStatus statusOf(TenantNoticeId noticeId) {
|
||||
if (invalidations.stream().anyMatch(fact -> fact.noticeId().equals(noticeId))) {
|
||||
return TenantNoticeStatus.INVALIDATED;
|
||||
}
|
||||
if (withdrawals.stream().anyMatch(fact -> fact.noticeId().equals(noticeId))) {
|
||||
return TenantNoticeStatus.WITHDRAWN;
|
||||
}
|
||||
return TenantNoticeStatus.ACTIVE;
|
||||
}
|
||||
|
||||
private void validateHistory() {
|
||||
if (notices.stream().map(TenantNotice::id).distinct().count() != notices.size()) {
|
||||
throw new LeaseRuleViolation("L'historique contient des congés en doublon.");
|
||||
}
|
||||
withdrawals.forEach(fact -> requireKnownNotice(fact.noticeId()));
|
||||
invalidations.forEach(fact -> requireKnownNotice(fact.noticeId()));
|
||||
evidenceVersions.forEach(fact -> requireKnownNotice(fact.noticeId()));
|
||||
notices.forEach(notice -> {
|
||||
long withdrawalsForNotice = withdrawals.stream()
|
||||
.filter(fact -> fact.noticeId().equals(notice.id())).count();
|
||||
long invalidationsForNotice = invalidations.stream()
|
||||
.filter(fact -> fact.noticeId().equals(notice.id())).count();
|
||||
if (withdrawalsForNotice > 1 || invalidationsForNotice > 1
|
||||
|| withdrawalsForNotice + invalidationsForNotice > 1) {
|
||||
throw new LeaseRuleViolation("Un congé ne peut avoir qu'une seule issue administrative.");
|
||||
}
|
||||
List<NoticeEvidenceVersion> versions = evidenceVersions(notice.id());
|
||||
for (int index = 0; index < versions.size(); index++) {
|
||||
if (versions.get(index).version() != index + 1) {
|
||||
throw new LeaseRuleViolation("Les versions de justificatif doivent être séquentielles.");
|
||||
}
|
||||
}
|
||||
});
|
||||
if (notices.stream().filter(notice -> statusOf(notice.id()) == TenantNoticeStatus.ACTIVE).count() > 1) {
|
||||
throw new LeaseRuleViolation("L'historique contient plusieurs congés actifs.");
|
||||
}
|
||||
}
|
||||
|
||||
private void requireKnownNotice(TenantNoticeId noticeId) {
|
||||
if (find(noticeId).isEmpty()) {
|
||||
throw new LeaseRuleViolation("Un fait de fin de bail référence un congé introuvable.");
|
||||
}
|
||||
}
|
||||
|
||||
static String requiredReason(String reason) {
|
||||
if (reason == null || reason.isBlank()) {
|
||||
throw new LeaseRuleViolation("La raison est obligatoire.");
|
||||
}
|
||||
String value = reason.trim();
|
||||
if (value.length() > 500) {
|
||||
throw new LeaseRuleViolation("La raison ne peut pas dépasser 500 caractères.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static <T> List<T> copy(List<T> values) {
|
||||
return values == null ? List.of() : List.copyOf(values);
|
||||
}
|
||||
|
||||
private static <T> List<T> appended(List<T> values, T value) {
|
||||
List<T> updated = new ArrayList<>(values);
|
||||
updated.add(value);
|
||||
return List.copyOf(updated);
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -2,6 +2,7 @@ package fr.nessar.lmnporganizer.rental.domain.lease;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.MonthDay;
|
||||
|
||||
public record LeaseLegalTerms(
|
||||
RentControlTerms rentControl,
|
||||
@@ -99,7 +100,7 @@ public record LeaseLegalTerms(
|
||||
}
|
||||
}
|
||||
|
||||
public record RentRevisionTerms(boolean enabled, LocalDate annualRevisionDate, Integer referenceQuarter,
|
||||
public record RentRevisionTerms(boolean enabled, MonthDay annualRevisionDay, Integer referenceQuarter,
|
||||
Integer referenceYear, String seriesId) {
|
||||
public RentRevisionTerms {
|
||||
seriesId = text(seriesId);
|
||||
@@ -109,13 +110,13 @@ public record LeaseLegalTerms(
|
||||
if (referenceYear != null && referenceYear <= 0) {
|
||||
throw new LeaseRuleViolation("L'année IRL de référence doit être positive.");
|
||||
}
|
||||
if (enabled && (annualRevisionDate == null || referenceQuarter == null || referenceYear == null
|
||||
if (enabled && (annualRevisionDay == null || referenceQuarter == null || referenceYear == null
|
||||
|| seriesId.isEmpty())) {
|
||||
throw new LeaseRuleViolation(
|
||||
"La date annuelle, le trimestre, l'année et la série IRL sont requis pour la révision.");
|
||||
"Le jour annuel, le trimestre, l'année et la série IRL sont requis pour la révision.");
|
||||
}
|
||||
if (!enabled) {
|
||||
annualRevisionDate = null;
|
||||
annualRevisionDay = null;
|
||||
referenceQuarter = null;
|
||||
referenceYear = null;
|
||||
seriesId = "";
|
||||
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.lease;
|
||||
|
||||
public enum NoticeDeliveryMode {
|
||||
REGISTERED_LETTER,
|
||||
BAILIFF,
|
||||
HAND_DELIVERY,
|
||||
OTHER
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.lease;
|
||||
|
||||
public record NoticeEvidenceFile(String storageKey, String fileName, String contentType, long size, String sha256) {
|
||||
public NoticeEvidenceFile {
|
||||
storageKey = required(storageKey, "La clé de stockage du justificatif est obligatoire.");
|
||||
fileName = required(fileName, "Le nom du justificatif est obligatoire.");
|
||||
contentType = required(contentType, "Le type de contenu du justificatif est obligatoire.");
|
||||
if (size <= 0) {
|
||||
throw new LeaseRuleViolation("Le justificatif doit être non vide.");
|
||||
}
|
||||
sha256 = required(sha256, "L'empreinte SHA-256 du justificatif est obligatoire.").toLowerCase();
|
||||
if (sha256.length() != 64 || !sha256.chars().allMatch(NoticeEvidenceFile::isHexadecimal)) {
|
||||
throw new LeaseRuleViolation("L'empreinte SHA-256 du justificatif est invalide.");
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isHexadecimal(int character) {
|
||||
return character >= '0' && character <= '9' || character >= 'a' && character <= 'f';
|
||||
}
|
||||
|
||||
private static String required(String value, String message) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new LeaseRuleViolation(message);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.lease;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public record NoticeEvidenceVersion(TenantNoticeId noticeId, int version, NoticeEvidenceFile file,
|
||||
String reason, LeaseActionAudit audit) {
|
||||
public NoticeEvidenceVersion {
|
||||
Objects.requireNonNull(noticeId);
|
||||
if (version <= 0) {
|
||||
throw new LeaseRuleViolation("La version du justificatif doit être positive.");
|
||||
}
|
||||
Objects.requireNonNull(file);
|
||||
reason = version > 1
|
||||
? LeaseEndingHistory.requiredReason(reason)
|
||||
: reason == null || reason.isBlank() ? null : LeaseEndingHistory.requiredReason(reason);
|
||||
Objects.requireNonNull(audit);
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.lease;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Objects;
|
||||
|
||||
public record NoticeInvalidation(TenantNoticeId noticeId, LocalDate invalidatedOn, String reason,
|
||||
LeaseActionAudit audit) {
|
||||
public NoticeInvalidation {
|
||||
Objects.requireNonNull(noticeId);
|
||||
Objects.requireNonNull(invalidatedOn);
|
||||
reason = LeaseEndingHistory.requiredReason(reason);
|
||||
Objects.requireNonNull(audit);
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.lease;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Objects;
|
||||
|
||||
public record NoticeWithdrawal(TenantNoticeId noticeId, LocalDate acceptedOn, String reason,
|
||||
LeaseActionAudit audit) {
|
||||
public NoticeWithdrawal {
|
||||
Objects.requireNonNull(noticeId);
|
||||
Objects.requireNonNull(acceptedOn);
|
||||
reason = LeaseEndingHistory.requiredReason(reason);
|
||||
Objects.requireNonNull(audit);
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.lease;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
public final class OccupancyPeriod {
|
||||
private final LocalDate startOn;
|
||||
private final LocalDate endOn;
|
||||
|
||||
private OccupancyPeriod(LocalDate startOn, LocalDate endOn) {
|
||||
this.startOn = Objects.requireNonNull(startOn, "Occupancy start date is required.");
|
||||
if (endOn != null && startOn.isAfter(endOn)) {
|
||||
throw new LeaseRuleViolation("La fin d'occupation doit suivre son début.");
|
||||
}
|
||||
this.endOn = endOn;
|
||||
}
|
||||
|
||||
public static OccupancyPeriod open(LocalDate startOn) {
|
||||
return new OccupancyPeriod(startOn, null);
|
||||
}
|
||||
|
||||
public static OccupancyPeriod closed(LocalDate startOn, LocalDate endOn) {
|
||||
return new OccupancyPeriod(startOn, Objects.requireNonNull(endOn));
|
||||
}
|
||||
|
||||
public static OccupancyPeriod closed(RentalPeriod period) {
|
||||
Objects.requireNonNull(period);
|
||||
return closed(period.startDate(), period.endDate());
|
||||
}
|
||||
|
||||
public boolean overlaps(OccupancyPeriod other) {
|
||||
Objects.requireNonNull(other);
|
||||
boolean thisEndsBeforeOther = endOn != null && endOn.isBefore(other.startOn);
|
||||
boolean otherEndsBeforeThis = other.endOn != null && other.endOn.isBefore(startOn);
|
||||
return !thisEndsBeforeOther && !otherEndsBeforeThis;
|
||||
}
|
||||
|
||||
public LocalDate startOn() {
|
||||
return startOn;
|
||||
}
|
||||
|
||||
public LocalDate startDate() {
|
||||
return startOn;
|
||||
}
|
||||
|
||||
public Optional<LocalDate> endOn() {
|
||||
return Optional.ofNullable(endOn);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
return other instanceof OccupancyPeriod period
|
||||
&& startOn.equals(period.startOn)
|
||||
&& Objects.equals(endOn, period.endOn);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(startOn, endOn);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.lease;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Objects;
|
||||
|
||||
public record TenantNotice(
|
||||
TenantNoticeId id,
|
||||
TenantNoticeStatus status,
|
||||
LocalDate receivedOn,
|
||||
LocalDate requestedDepartureOn,
|
||||
NoticeDeliveryMode deliveryMode,
|
||||
String otherDeliveryMode,
|
||||
int noticeMonths,
|
||||
LocalDate minimumEndOn,
|
||||
String ruleVersion,
|
||||
boolean recordedWithoutEvidenceConfirmed,
|
||||
LeaseActionAudit audit) {
|
||||
|
||||
public TenantNotice {
|
||||
Objects.requireNonNull(id);
|
||||
Objects.requireNonNull(status);
|
||||
Objects.requireNonNull(receivedOn);
|
||||
Objects.requireNonNull(requestedDepartureOn);
|
||||
Objects.requireNonNull(deliveryMode);
|
||||
Objects.requireNonNull(minimumEndOn);
|
||||
Objects.requireNonNull(audit);
|
||||
if (noticeMonths <= 0) {
|
||||
throw new LeaseRuleViolation("La durée de préavis doit être positive.");
|
||||
}
|
||||
if (ruleVersion == null || ruleVersion.isBlank()) {
|
||||
throw new LeaseRuleViolation("La version de règle du préavis est obligatoire.");
|
||||
}
|
||||
ruleVersion = ruleVersion.trim();
|
||||
otherDeliveryMode = optional(otherDeliveryMode);
|
||||
if (otherDeliveryMode != null && otherDeliveryMode.length() > 200) {
|
||||
throw new LeaseRuleViolation("Le libellé du mode de remise ne peut pas dépasser 200 caractères.");
|
||||
}
|
||||
if (deliveryMode == NoticeDeliveryMode.OTHER && otherDeliveryMode == null) {
|
||||
throw new LeaseRuleViolation("Le mode de remise alternatif est obligatoire.");
|
||||
}
|
||||
if (deliveryMode != NoticeDeliveryMode.OTHER && otherDeliveryMode != null) {
|
||||
throw new LeaseRuleViolation("Le détail du mode de remise est réservé au mode OTHER.");
|
||||
}
|
||||
}
|
||||
|
||||
public LocalDate effectiveEndOn() {
|
||||
return requestedDepartureOn.isAfter(minimumEndOn) ? requestedDepartureOn : minimumEndOn;
|
||||
}
|
||||
|
||||
TenantNotice withStatus(TenantNoticeStatus updatedStatus) {
|
||||
return new TenantNotice(id, updatedStatus, receivedOn, requestedDepartureOn, deliveryMode,
|
||||
otherDeliveryMode, noticeMonths, minimumEndOn, ruleVersion, recordedWithoutEvidenceConfirmed,
|
||||
audit);
|
||||
}
|
||||
|
||||
private static String optional(String value) {
|
||||
return value == null || value.isBlank() ? null : value.trim();
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.lease;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Objects;
|
||||
|
||||
public record TenantNoticeCommand(
|
||||
TenantNoticeId id,
|
||||
LocalDate receivedOn,
|
||||
LocalDate requestedDepartureOn,
|
||||
NoticeDeliveryMode deliveryMode,
|
||||
String otherDeliveryMode,
|
||||
boolean lateRegistrationConfirmed,
|
||||
boolean nonStandardDeliveryConfirmed,
|
||||
boolean recordedWithoutEvidenceConfirmed,
|
||||
LocalDate today) {
|
||||
public TenantNoticeCommand(TenantNoticeId id, LocalDate receivedOn, LocalDate requestedDepartureOn,
|
||||
NoticeDeliveryMode deliveryMode, String otherDeliveryMode,
|
||||
boolean lateRegistrationConfirmed, boolean nonStandardDeliveryConfirmed,
|
||||
LocalDate today) {
|
||||
this(id, receivedOn, requestedDepartureOn, deliveryMode, otherDeliveryMode, lateRegistrationConfirmed,
|
||||
nonStandardDeliveryConfirmed, false, today);
|
||||
}
|
||||
|
||||
public TenantNoticeCommand {
|
||||
Objects.requireNonNull(id);
|
||||
Objects.requireNonNull(receivedOn);
|
||||
Objects.requireNonNull(requestedDepartureOn);
|
||||
Objects.requireNonNull(deliveryMode);
|
||||
Objects.requireNonNull(today);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.lease;
|
||||
|
||||
public record TenantNoticeId(String value) {
|
||||
public TenantNoticeId {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException("Tenant notice ID is required.");
|
||||
}
|
||||
value = value.trim();
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.lease;
|
||||
|
||||
public enum TenantNoticeStatus {
|
||||
ACTIVE,
|
||||
WITHDRAWN,
|
||||
INVALIDATED
|
||||
}
|
||||
@@ -17,10 +17,18 @@ public final class RentDue {
|
||||
private BigDecimal rent;
|
||||
private BigDecimal charges;
|
||||
private BigDecimal allocatedAmount;
|
||||
private BigDecimal rentBeforeLeaseEnd;
|
||||
private BigDecimal chargesBeforeLeaseEnd;
|
||||
private final long version;
|
||||
|
||||
private RentDue(RentDueId id, LandlordId landlordId, LeaseId leaseId, YearMonth month, LocalDate dueDate,
|
||||
BigDecimal rent, BigDecimal charges, BigDecimal allocatedAmount, long version) {
|
||||
this(id, landlordId, leaseId, month, dueDate, rent, charges, allocatedAmount, null, null, version);
|
||||
}
|
||||
|
||||
private RentDue(RentDueId id, LandlordId landlordId, LeaseId leaseId, YearMonth month, LocalDate dueDate,
|
||||
BigDecimal rent, BigDecimal charges, BigDecimal allocatedAmount,
|
||||
BigDecimal rentBeforeLeaseEnd, BigDecimal chargesBeforeLeaseEnd, long version) {
|
||||
this.id = Objects.requireNonNull(id);
|
||||
this.landlordId = Objects.requireNonNull(landlordId);
|
||||
this.leaseId = Objects.requireNonNull(leaseId);
|
||||
@@ -29,11 +37,19 @@ public final class RentDue {
|
||||
this.rent = nonNegative(rent, "Rent");
|
||||
this.charges = nonNegative(charges, "Charges");
|
||||
this.allocatedAmount = nonNegative(allocatedAmount, "Allocated amount");
|
||||
if (this.rent.add(this.charges).signum() <= 0) {
|
||||
throw new RentRuleViolation("Une échéance doit avoir un montant strictement positif.");
|
||||
if ((rentBeforeLeaseEnd == null) != (chargesBeforeLeaseEnd == null)) {
|
||||
throw new RentRuleViolation("Les montants antérieurs à la fin de bail doivent être complets.");
|
||||
}
|
||||
if (this.allocatedAmount.compareTo(total()) > 0) {
|
||||
throw new RentRuleViolation("Une échéance ne peut pas être sur-réglée.");
|
||||
this.rentBeforeLeaseEnd = rentBeforeLeaseEnd == null ? null : nonNegative(rentBeforeLeaseEnd, "Previous rent");
|
||||
this.chargesBeforeLeaseEnd = chargesBeforeLeaseEnd == null
|
||||
? null : nonNegative(chargesBeforeLeaseEnd, "Previous charges");
|
||||
if (this.rentBeforeLeaseEnd == null && (total().signum() <= 0
|
||||
|| this.allocatedAmount.compareTo(total()) > 0)) {
|
||||
throw new RentRuleViolation("Une échéance normale doit être positive et ne peut pas être sur-réglée.");
|
||||
}
|
||||
if (this.rentBeforeLeaseEnd != null
|
||||
&& this.rentBeforeLeaseEnd.add(this.chargesBeforeLeaseEnd).signum() <= 0) {
|
||||
throw new RentRuleViolation("Le montant antérieur à la fin de bail doit être positif.");
|
||||
}
|
||||
if (version < -1) {
|
||||
throw new IllegalArgumentException("Rent due version cannot be less than -1.");
|
||||
@@ -43,7 +59,11 @@ public final class RentDue {
|
||||
|
||||
public static RentDue create(RentDueId id, LandlordId landlordId, LeaseId leaseId, YearMonth month,
|
||||
LocalDate dueDate, BigDecimal rent, BigDecimal charges) {
|
||||
return new RentDue(id, landlordId, leaseId, month, dueDate, rent, charges, BigDecimal.ZERO, -1);
|
||||
RentDue due = new RentDue(id, landlordId, leaseId, month, dueDate, rent, charges, BigDecimal.ZERO, -1);
|
||||
if (due.total().signum() <= 0) {
|
||||
throw new RentRuleViolation("Une échéance doit avoir un montant strictement positif.");
|
||||
}
|
||||
return due;
|
||||
}
|
||||
|
||||
public static RentDue reconstitute(RentDueId id, LandlordId landlordId, LeaseId leaseId, YearMonth month,
|
||||
@@ -52,6 +72,14 @@ public final class RentDue {
|
||||
return new RentDue(id, landlordId, leaseId, month, dueDate, rent, charges, allocatedAmount, version);
|
||||
}
|
||||
|
||||
public static RentDue reconstitute(RentDueId id, LandlordId landlordId, LeaseId leaseId, YearMonth month,
|
||||
LocalDate dueDate, BigDecimal rent, BigDecimal charges,
|
||||
BigDecimal allocatedAmount, BigDecimal rentBeforeLeaseEnd,
|
||||
BigDecimal chargesBeforeLeaseEnd, long version) {
|
||||
return new RentDue(id, landlordId, leaseId, month, dueDate, rent, charges, allocatedAmount,
|
||||
rentBeforeLeaseEnd, chargesBeforeLeaseEnd, version);
|
||||
}
|
||||
|
||||
public void allocate(BigDecimal amount) {
|
||||
BigDecimal allocation = positive(amount, "Allocation");
|
||||
if (allocation.compareTo(remainingAmount()) > 0) {
|
||||
@@ -90,6 +118,43 @@ public final class RentDue {
|
||||
this.charges = revisedCharges;
|
||||
}
|
||||
|
||||
void reviseForLeaseEnd(BigDecimal rent, BigDecimal charges) {
|
||||
if (rentBeforeLeaseEnd == null) {
|
||||
rentBeforeLeaseEnd = this.rent;
|
||||
chargesBeforeLeaseEnd = this.charges;
|
||||
}
|
||||
this.rent = nonNegative(rent, "Rent");
|
||||
this.charges = nonNegative(charges, "Charges");
|
||||
}
|
||||
|
||||
boolean restoreBeforeLeaseEnd() {
|
||||
if (rentBeforeLeaseEnd == null) {
|
||||
return false;
|
||||
}
|
||||
rent = rentBeforeLeaseEnd;
|
||||
charges = chargesBeforeLeaseEnd;
|
||||
rentBeforeLeaseEnd = null;
|
||||
chargesBeforeLeaseEnd = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
void reviseUnderlyingTerms(LocalDate dueDate, BigDecimal rent, BigDecimal charges) {
|
||||
if (!adjustedForLeaseEnd()) {
|
||||
reviseTerms(dueDate, rent, charges);
|
||||
return;
|
||||
}
|
||||
this.dueDate = Objects.requireNonNull(dueDate, "Due date is required.");
|
||||
rentBeforeLeaseEnd = nonNegative(rent, "Rent");
|
||||
chargesBeforeLeaseEnd = nonNegative(charges, "Charges");
|
||||
if (rentBeforeLeaseEnd.add(chargesBeforeLeaseEnd).signum() <= 0) {
|
||||
throw new RentRuleViolation("Le montant sous-jacent doit être positif.");
|
||||
}
|
||||
}
|
||||
|
||||
void reviseUnderlyingAmounts(BigDecimal rent, BigDecimal charges) {
|
||||
reviseUnderlyingTerms(dueDate, rent, charges);
|
||||
}
|
||||
|
||||
public boolean hasAllocations() {
|
||||
return allocatedAmount.signum() > 0;
|
||||
}
|
||||
@@ -99,14 +164,27 @@ public final class RentDue {
|
||||
}
|
||||
|
||||
public BigDecimal remainingAmount() {
|
||||
return total().subtract(allocatedAmount);
|
||||
return total().subtract(allocatedAmount).max(BigDecimal.ZERO);
|
||||
}
|
||||
|
||||
public boolean correctionRequired() {
|
||||
return allocatedAmount.compareTo(total()) > 0;
|
||||
}
|
||||
|
||||
public boolean adjustedForLeaseEnd() {
|
||||
return rentBeforeLeaseEnd != null;
|
||||
}
|
||||
|
||||
public RentDueSettlement settlement() {
|
||||
if (total().signum() == 0) {
|
||||
return RentDueSettlement.PAID;
|
||||
}
|
||||
if (allocatedAmount.signum() == 0) {
|
||||
return RentDueSettlement.UNPAID;
|
||||
}
|
||||
return remainingAmount().signum() == 0 ? RentDueSettlement.PAID : RentDueSettlement.PARTIALLY_PAID;
|
||||
return allocatedAmount.compareTo(total()) >= 0
|
||||
? RentDueSettlement.PAID
|
||||
: RentDueSettlement.PARTIALLY_PAID;
|
||||
}
|
||||
|
||||
public RentDueTiming timingOn(LocalDate date) {
|
||||
@@ -140,5 +218,7 @@ public final class RentDue {
|
||||
public BigDecimal rent() { return rent; }
|
||||
public BigDecimal charges() { return charges; }
|
||||
public BigDecimal allocatedAmount() { return allocatedAmount; }
|
||||
public BigDecimal rentBeforeLeaseEnd() { return rentBeforeLeaseEnd; }
|
||||
public BigDecimal chargesBeforeLeaseEnd() { return chargesBeforeLeaseEnd; }
|
||||
public long version() { return version; }
|
||||
}
|
||||
+103
-7
@@ -5,6 +5,7 @@ import fr.nessar.lmnporganizer.rental.domain.lease.LeaseAmended;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseContractVersion;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseType;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.OccupancyPeriod;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
@@ -15,6 +16,8 @@ import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Optional;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Function;
|
||||
|
||||
@@ -100,7 +103,7 @@ public final class RentSchedule {
|
||||
}
|
||||
Target target = targets.remove(entry.getKey());
|
||||
RentDue due = entry.getValue();
|
||||
if (due.settlement() == RentDueSettlement.PAID) {
|
||||
if (due.settlement() == RentDueSettlement.PAID && !due.adjustedForLeaseEnd()) {
|
||||
continue;
|
||||
}
|
||||
if (target == null) {
|
||||
@@ -109,9 +112,9 @@ public final class RentSchedule {
|
||||
}
|
||||
toRemove.add(due);
|
||||
} else if (!due.dueDate().equals(target.dueDate())
|
||||
|| due.rent().compareTo(target.rent()) != 0
|
||||
|| due.charges().compareTo(target.charges()) != 0) {
|
||||
due.reviseTerms(target.dueDate(), target.rent(), target.charges());
|
||||
|| underlyingRent(due).compareTo(target.rent()) != 0
|
||||
|| underlyingCharges(due).compareTo(target.charges()) != 0) {
|
||||
due.reviseUnderlyingTerms(target.dueDate(), target.rent(), target.charges());
|
||||
toSave.add(due);
|
||||
}
|
||||
}
|
||||
@@ -120,6 +123,76 @@ public final class RentSchedule {
|
||||
return new Reconciliation(toSave, toRemove);
|
||||
}
|
||||
|
||||
public static EndReconciliation reconcileEnd(List<RentDue> existingDues,
|
||||
OccupancyPeriod scheduledOccupancy, LocalDate endOn) {
|
||||
return reconcileEnd(existingDues, scheduledOccupancy, endOn, null);
|
||||
}
|
||||
|
||||
public static EndReconciliation reconcileEnd(List<RentDue> existingDues,
|
||||
OccupancyPeriod scheduledOccupancy, LocalDate endOn,
|
||||
EndAmounts finalMonthAmounts) {
|
||||
Objects.requireNonNull(endOn, "Lease end date is required.");
|
||||
Objects.requireNonNull(scheduledOccupancy, "Scheduled occupancy is required.");
|
||||
YearMonth finalMonth = YearMonth.from(endOn);
|
||||
List<RentDue> toSave = new ArrayList<>();
|
||||
List<RentDue> toRemove = new ArrayList<>();
|
||||
for (RentDue due : existingDues == null ? List.<RentDue>of() : existingDues) {
|
||||
if (due.month().isBefore(finalMonth)) {
|
||||
continue;
|
||||
}
|
||||
if (due.month().equals(finalMonth)) {
|
||||
int originalDays = occupiedDays(later(finalMonth.atDay(1), scheduledOccupancy.startOn()),
|
||||
earlier(finalMonth.atEndOfMonth(), scheduledOccupancy.endOn()
|
||||
.orElse(finalMonth.atEndOfMonth())));
|
||||
int targetDays = occupiedDays(later(finalMonth.atDay(1), scheduledOccupancy.startOn()),
|
||||
earlier(endOn, scheduledOccupancy.endOn().orElse(endOn)));
|
||||
BigDecimal baseRent = due.rentBeforeLeaseEnd() == null ? due.rent() : due.rentBeforeLeaseEnd();
|
||||
BigDecimal baseCharges = due.chargesBeforeLeaseEnd() == null
|
||||
? due.charges() : due.chargesBeforeLeaseEnd();
|
||||
BigDecimal rent = finalMonthAmounts == null
|
||||
? prorate(baseRent, targetDays, originalDays)
|
||||
: finalMonthAmounts.rent();
|
||||
BigDecimal charges = finalMonthAmounts == null
|
||||
? prorate(baseCharges, targetDays, originalDays)
|
||||
: finalMonthAmounts.charges();
|
||||
if (due.rent().compareTo(rent) != 0 || due.charges().compareTo(charges) != 0) {
|
||||
due.reviseForLeaseEnd(rent, charges);
|
||||
toSave.add(due);
|
||||
}
|
||||
} else {
|
||||
if (due.total().signum() != 0 || !due.adjustedForLeaseEnd()) {
|
||||
due.reviseForLeaseEnd(BigDecimal.ZERO, BigDecimal.ZERO);
|
||||
toSave.add(due);
|
||||
}
|
||||
}
|
||||
}
|
||||
boolean correctionRequired = toSave.stream().anyMatch(RentDue::correctionRequired);
|
||||
return new EndReconciliation(toSave, toRemove, correctionRequired);
|
||||
}
|
||||
|
||||
public static EndReconciliation restoreAfterEndExpansion(List<RentDue> existingDues,
|
||||
OccupancyPeriod scheduledOccupancy,
|
||||
Optional<LocalDate> expandedEndOn) {
|
||||
return restoreAfterEndExpansion(existingDues, scheduledOccupancy, expandedEndOn, null);
|
||||
}
|
||||
|
||||
public static EndReconciliation restoreAfterEndExpansion(List<RentDue> existingDues,
|
||||
OccupancyPeriod scheduledOccupancy,
|
||||
Optional<LocalDate> expandedEndOn,
|
||||
EndAmounts finalMonthAmounts) {
|
||||
List<RentDue> dues = existingDues == null ? List.of() : existingDues;
|
||||
LinkedHashSet<RentDue> changed = new LinkedHashSet<>();
|
||||
dues.stream().filter(RentDue::restoreBeforeLeaseEnd).forEach(changed::add);
|
||||
boolean correctionRequired = false;
|
||||
if (expandedEndOn.isPresent()) {
|
||||
EndReconciliation bounded = reconcileEnd(
|
||||
dues, scheduledOccupancy, expandedEndOn.orElseThrow(), finalMonthAmounts);
|
||||
changed.addAll(bounded.toSave());
|
||||
correctionRequired = bounded.correctionRequired();
|
||||
}
|
||||
return new EndReconciliation(List.copyOf(changed), List.of(), correctionRequired);
|
||||
}
|
||||
|
||||
public static List<RentDue> applyRevision(List<RentDue> existingDues, LocalDate effectiveDate,
|
||||
RevisionAmounts amounts) {
|
||||
return applyRevision(existingDues, effectiveDate, amounts, null);
|
||||
@@ -136,15 +209,15 @@ public final class RentSchedule {
|
||||
List<RentDue> changed = new ArrayList<>();
|
||||
for (RentDue due : existingDues == null ? List.<RentDue>of() : existingDues) {
|
||||
if (due.month().isBefore(effectiveMonth) || afterBoundary(due.month(), boundary)
|
||||
|| due.settlement() == RentDueSettlement.PAID) {
|
||||
|| due.settlement() == RentDueSettlement.PAID && !due.adjustedForLeaseEnd()) {
|
||||
continue;
|
||||
}
|
||||
BigDecimal rent = revisedAmount(due.month(), effectiveDate, amounts.previousRent(),
|
||||
amounts.revisedRent(), boundary == null ? null : boundary.rent(), boundary);
|
||||
BigDecimal charges = revisedAmount(due.month(), effectiveDate, amounts.previousCharges(),
|
||||
amounts.revisedCharges(), boundary == null ? null : boundary.charges(), boundary);
|
||||
if (due.rent().compareTo(rent) != 0 || due.charges().compareTo(charges) != 0) {
|
||||
due.reviseAmounts(rent, charges);
|
||||
if (underlyingRent(due).compareTo(rent) != 0 || underlyingCharges(due).compareTo(charges) != 0) {
|
||||
due.reviseUnderlyingAmounts(rent, charges);
|
||||
changed.add(due);
|
||||
}
|
||||
}
|
||||
@@ -155,6 +228,14 @@ public final class RentSchedule {
|
||||
return boundary != null && month.isAfter(YearMonth.from(boundary.effectiveDate()));
|
||||
}
|
||||
|
||||
private static BigDecimal underlyingRent(RentDue due) {
|
||||
return due.rentBeforeLeaseEnd() == null ? due.rent() : due.rentBeforeLeaseEnd();
|
||||
}
|
||||
|
||||
private static BigDecimal underlyingCharges(RentDue due) {
|
||||
return due.chargesBeforeLeaseEnd() == null ? due.charges() : due.chargesBeforeLeaseEnd();
|
||||
}
|
||||
|
||||
private static BigDecimal revisedAmount(YearMonth month, LocalDate effectiveDate, BigDecimal previous,
|
||||
BigDecimal revised, BigDecimal following,
|
||||
FinancialBoundary boundary) {
|
||||
@@ -253,6 +334,21 @@ public final class RentSchedule {
|
||||
}
|
||||
}
|
||||
|
||||
public record EndReconciliation(List<RentDue> toSave, List<RentDue> toRemove,
|
||||
boolean correctionRequired) {
|
||||
public EndReconciliation {
|
||||
toSave = List.copyOf(toSave);
|
||||
toRemove = List.copyOf(toRemove);
|
||||
}
|
||||
}
|
||||
|
||||
public record EndAmounts(BigDecimal rent, BigDecimal charges) {
|
||||
public EndAmounts {
|
||||
rent = RevisionAmounts.nonNegative(rent, "Final month rent");
|
||||
charges = RevisionAmounts.nonNegative(charges, "Final month charges");
|
||||
}
|
||||
}
|
||||
|
||||
public record RevisionAmounts(BigDecimal previousRent, BigDecimal previousCharges,
|
||||
BigDecimal revisedRent, BigDecimal revisedCharges) {
|
||||
public RevisionAmounts {
|
||||
|
||||
@@ -19,3 +19,148 @@ clauses:
|
||||
template: >-
|
||||
La détention d'un chien de première catégorie au sens de l'article L. 211-12 du code rural
|
||||
et de la pêche maritime est interdite dans le logement.
|
||||
- id: RENT_FREE_WORKS
|
||||
label: Franchise de loyer pour travaux d'embellissement
|
||||
description: Accorde une réduction de loyer temporaire en contrepartie de travaux d'embellissement du locataire.
|
||||
contexts: [ALL]
|
||||
template: >-
|
||||
En contrepartie des travaux de {{worksDescription}} que le locataire s'engage à réaliser à ses frais exclusifs
|
||||
et conformément aux règles de l'art dans le logement, le bailleur lui accorde une réduction mensuelle de loyer de
|
||||
{{discountAmount}} euros, pour une durée de {{durationMonths}} mois à compter du {{startDate}}. Les charges
|
||||
locatives restent dues en totalité durant cette période.
|
||||
parameters:
|
||||
- name: worksDescription
|
||||
label: Description des travaux
|
||||
type: TEXT
|
||||
required: true
|
||||
- name: discountAmount
|
||||
label: Montant mensuel de la réduction (en €)
|
||||
type: NUMBER
|
||||
required: true
|
||||
- name: durationMonths
|
||||
label: Durée de la réduction (en mois)
|
||||
type: NUMBER
|
||||
required: true
|
||||
- name: startDate
|
||||
label: Date de début de la réduction
|
||||
type: DATE
|
||||
required: true
|
||||
- id: JOINT_AND_SEVERAL_LIABILITY
|
||||
label: Solidarité des colocataires
|
||||
description: Rend les colocataires solidaires du paiement du loyer et des charges.
|
||||
contexts: [COLOCATION]
|
||||
template: >-
|
||||
Les colocataires sont tenus solidairement et indivisiblement de l'exécution de toutes les obligations
|
||||
du présent contrat, notamment du paiement du loyer, des charges et des éventuelles réparations locatives.
|
||||
Conformément à l'article 8-1 de la loi du 6 juillet 1989, la solidarité d'un colocataire sortant prend fin
|
||||
à la date d'effet de son congé si un nouveau colocataire le remplace, ou à défaut à l'expiration d'un délai
|
||||
de six mois après la date d'effet de son congé.
|
||||
- id: TENANT_INSURANCE
|
||||
label: Assurance habitation et défaut de justification
|
||||
description: Rappelle l'obligation d'assurance et permet au bailleur de souscrire une assurance pour le compte du locataire.
|
||||
contexts: [ALL]
|
||||
template: >-
|
||||
Le locataire s'oblige à s'assurer contre les risques locatifs dont il doit répondre en sa qualité de locataire
|
||||
et à en justifier lors de la remise des clés puis chaque année. À défaut de présentation d'une attestation d'assurance
|
||||
un mois après une mise en demeure restée infructueuse, le bailleur pourra souscrire une assurance pour son compte.
|
||||
Dans ce cas, le montant de la prime annuelle, éventuellement majoré de {{feePercent}}% pour frais de gestion dans la limite
|
||||
du plafond légal, sera récupérable auprès du locataire par douzième chaque mois.
|
||||
parameters:
|
||||
- name: feePercent
|
||||
label: Pourcentage de majoration pour frais de gestion (max 10%)
|
||||
type: NUMBER
|
||||
required: true
|
||||
- id: CDC_CONCILIATION
|
||||
label: Recours préalable à la Commission Départementale de Conciliation
|
||||
description: Prévoit la saisine de la CDC avant toute procédure judiciaire.
|
||||
contexts: [ALL]
|
||||
template: >-
|
||||
En cas de litige relatif aux loyers, charges, réparations, ou caractéristiques du logement, les parties s'engagent,
|
||||
avant toute saisine du tribunal judiciaire et conformément aux dispositions légales, à tenter de concilier leur différend
|
||||
en saisissant la Commission Départementale de Conciliation (CDC) du département de {{propertyDepartment}}.
|
||||
parameters:
|
||||
- name: propertyDepartment
|
||||
label: "Département du logement (ex: Paris)"
|
||||
type: TEXT
|
||||
required: true
|
||||
- id: RESOLUTORY_CLAUSE
|
||||
label: Clause résolutoire
|
||||
description: Permet la résiliation de plein droit du bail en cas de manquement grave du locataire (non-paiement, défaut d'assurance).
|
||||
contexts: [ALL]
|
||||
template: >-
|
||||
Le présent contrat sera résilié de plein droit, sans qu'il soit besoin de remplir aucune formalité judiciaire,
|
||||
un mois après un commandement de payer demeuré infructueux pour défaut de paiement du dépôt de garantie ou des
|
||||
loyers et charges, ou un mois après une mise en demeure restée infructueuse pour défaut d'assurance des risques locatifs.
|
||||
- id: MAINTENANCE_AND_REPAIRS
|
||||
label: Entretien et réparations locatives
|
||||
description: Rappelle les obligations d'entretien courant et de menues réparations à la charge du locataire.
|
||||
contexts: [ALL]
|
||||
template: >-
|
||||
Le locataire est tenu de prendre à sa charge l'entretien courant du logement, des équipements mentionnés au contrat
|
||||
ainsi que l'ensemble des menues réparations locatives définies par le décret n° 87-712 du 26 août 1987, sauf si elles
|
||||
sont occasionnées par vétusté, malfaçon ou force majeure.
|
||||
- id: ANNUAL_INSPECTION_RIGHT
|
||||
label: Droit de visite annuelle de contrôle
|
||||
description: Autorise le bailleur à visiter le logement une fois par an avec un préavis pour vérifier son état d'entretien.
|
||||
contexts: [ALL]
|
||||
template: >-
|
||||
Le bailleur ou son mandataire pourra visiter le logement loué une fois par an afin de s'assurer de son bon état
|
||||
d'entretien et de fonctionnement des équipements. Les modalités de cette visite (date, heure) seront convenues
|
||||
à l'avance avec le locataire avec un préavis raisonnable d'au moins {{noticeDays}} jours.
|
||||
parameters:
|
||||
- name: noticeDays
|
||||
label: Nombre de jours de préavis pour la visite
|
||||
type: NUMBER
|
||||
required: true
|
||||
- id: LEGAL_RESIDENCE_DESTINATION
|
||||
label: Usage exclusif d'habitation principale
|
||||
description: Interdit l'exercice d'activités commerciales ou professionnelles non autorisées dans le logement.
|
||||
contexts: [ALL]
|
||||
template: >-
|
||||
Les locaux loués devront être occupés exclusivement à usage d'habitation principale. L'exercice de toute activité
|
||||
commerciale, artisanale ou professionnelle y est strictement interdit, sauf accord exprès et écrit du bailleur.
|
||||
- id: BOILER_AND_CHIMNEY_MAINTENANCE
|
||||
label: Entretien de la chaudière et ramonage
|
||||
description: Rappelle l'obligation légale d'entretien annuel de la chaudière individuelle et de ramonage.
|
||||
contexts: [ALL]
|
||||
template: >-
|
||||
Si le logement est équipé d'un système de chauffage individuel au {{heatingType}}, le locataire est tenu
|
||||
de faire réaliser chaque année un entretien de cet équipement par un professionnel qualifié et d'en fournir
|
||||
l'attestation au bailleur. De même, le ramonage annuel des conduits d'évacuation des fumées, si applicables,
|
||||
doit être effectué par le locataire à ses frais.
|
||||
parameters:
|
||||
- name: heatingType
|
||||
label: Type de chauffage individuel
|
||||
type: CHOICE
|
||||
required: true
|
||||
choices:
|
||||
- value: "gaz"
|
||||
label: "gaz"
|
||||
- value: "fioul"
|
||||
label: "fioul"
|
||||
- value: "bois"
|
||||
label: "bois"
|
||||
- value: "autre"
|
||||
label: "autre combustible"
|
||||
- id: TENANT_TRANSFORMATIONS
|
||||
label: Encadrement des travaux de transformation
|
||||
description: Rappelle que les travaux de transformation (structure, cloisons, réseaux) nécessitent l'accord écrit préalable du bailleur.
|
||||
contexts: [ALL]
|
||||
template: >-
|
||||
Le locataire est libre d'effectuer des aménagements simples (peinture, décoration). Toutefois, il ne pourra entreprendre
|
||||
aucuns travaux de transformation des locaux loués ou de leurs équipements (abattage de cloison, modification de réseaux
|
||||
d'eau ou d'électricité, etc.) sans l'accord écrit et préalable du bailleur. À défaut, le bailleur pourra exiger la remise
|
||||
en état immédiate ou lors du départ du locataire aux frais exclusifs de ce dernier.
|
||||
- id: PROLONGED_VACANCY
|
||||
label: Inoccupation prolongée et mesures anti-gel
|
||||
description: Oblige le locataire à notifier le bailleur en cas d'absence prolongée et à protéger le logement du gel.
|
||||
contexts: [ALL]
|
||||
template: >-
|
||||
En cas d'absence prolongée du logement supérieure à {{minDays}} jours consécutifs, le locataire devra en informer le
|
||||
bailleur et prendre toutes les précautions nécessaires pour préserver le logement, notamment en maintenant le chauffage
|
||||
en mode hors-gel pendant la période hivernale afin d'éviter tout dégât des eaux par gel des canalisations.
|
||||
parameters:
|
||||
- name: minDays
|
||||
label: Nombre de jours d'absence minimale avant notification
|
||||
type: NUMBER
|
||||
required: true
|
||||
@@ -379,17 +379,11 @@
|
||||
</div>
|
||||
<div th:if="${legal.rentRevision.enabled}" class="keep">
|
||||
<h3>Modalités de révision du loyer</h3>
|
||||
<p>
|
||||
Révision annuelle le
|
||||
<span th:text="${dates.date(legal.rentRevision.annualRevisionDate)}"
|
||||
>1 Septembre 2027</span
|
||||
>, selon l’IRL du
|
||||
<span
|
||||
th:text="${formats.quarter(legal.rentRevision.referenceQuarter)}"
|
||||
>2e trimestre</span
|
||||
>
|
||||
<span th:text="${legal.rentRevision.referenceYear}">2026</span>, série
|
||||
INSEE <span th:text="${legal.rentRevision.seriesId}">001515333</span>.
|
||||
<p
|
||||
th:text="|Le loyer pourra être révisé chaque année le ${dates.monthDay(legal.rentRevision.annualRevisionDay)}, selon l’IRL du ${formats.quarter(legal.rentRevision.referenceQuarter)} ${legal.rentRevision.referenceYear}, série INSEE ${legal.rentRevision.seriesId}.|"
|
||||
>
|
||||
Le loyer pourra être révisé chaque année le 1 Septembre, selon l’IRL du 2e
|
||||
trimestre 2026, série INSEE 001515333.
|
||||
</p>
|
||||
</div>
|
||||
<h3>B. Charges récupérables</h3>
|
||||
|
||||
+38
-18
@@ -11,7 +11,6 @@ import fr.nessar.lmnporganizer.rental.adapter.in.web.tenant.TenantController;
|
||||
import fr.nessar.lmnporganizer.rental.application.apartment.ApartmentCatalogService;
|
||||
import fr.nessar.lmnporganizer.rental.application.landlord.LandlordAccountService;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.AmendLeaseService;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.ArchiveLeaseService;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.GenerateLeaseDocumentService;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.LeaseDraftService;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.AllocateRentPaymentService;
|
||||
@@ -125,8 +124,6 @@ class RentalWebAdaptersTest {
|
||||
@MockitoBean
|
||||
private LeaseDraftService leaseDrafts;
|
||||
@MockitoBean
|
||||
private ArchiveLeaseService archiving;
|
||||
@MockitoBean
|
||||
private AmendLeaseService amendments;
|
||||
@MockitoBean
|
||||
private GenerateLeaseDocumentService leaseDocuments;
|
||||
@@ -306,6 +303,20 @@ class RentalWebAdaptersTest {
|
||||
LeaseType.STUDENT));
|
||||
}
|
||||
|
||||
@Test
|
||||
void leaseDraftRequestsRejectAnImpossibleAnnualRevisionDay() throws Exception {
|
||||
mockMvc.perform(post("/api/leases").with(asLandlord()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content("""
|
||||
{"apartmentId":"apartment-1","tenantIds":["tenant-1"],"guarantors":[],
|
||||
"startDate":"2026-01-01","monthlyRent":900.00,"monthlyCharges":50.00,
|
||||
"securityDeposit":900.00,"legalTerms":{"rentRevision":{"enabled":true,
|
||||
"annualRevisionDay":31,"annualRevisionMonth":2,"referenceQuarter":1,
|
||||
"referenceYear":2026,"seriesId":"001515333"}}}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.message").value("Le jour annuel de révision IRL est invalide."));
|
||||
}
|
||||
|
||||
@Test
|
||||
void leaseDraftRequestsMapSpecialClausesAndExposeOnlyServerRenderedFacts() throws Exception {
|
||||
when(leaseDrafts.create(eq(LANDLORD_ID), any())).thenAnswer(invocation -> {
|
||||
@@ -354,29 +365,32 @@ class RentalWebAdaptersTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void leaseArchiveAndAmendDelegateScopedCommands() throws Exception {
|
||||
void leaseAmendDelegatesTheScopedCommand() throws Exception {
|
||||
Lease active = activeLease();
|
||||
Lease archived = activeLease();
|
||||
archived.archive();
|
||||
when(archiving.archive(LANDLORD_ID, LEASE_ID)).thenReturn(archived);
|
||||
when(amendments.amend(eq(LANDLORD_ID), eq(LEASE_ID), eq(LocalDate.of(2026, 7, 1)), any()))
|
||||
when(amendments.amend(eq(LANDLORD_ID), eq(LEASE_ID), eq(LocalDate.of(2026, 7, 1)),
|
||||
eq(java.time.ZoneId.of("Europe/Paris")), any()))
|
||||
.thenReturn(active);
|
||||
|
||||
mockMvc.perform(post("/api/leases/lease-1/archive").with(asLandlord()).with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.status").value("ARCHIVED"));
|
||||
|
||||
mockMvc.perform(post("/api/leases/lease-1/amendments").with(asLandlord()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content("""
|
||||
{"effectiveDate":"2026-07-01","tenantIds":["tenant-1"],"guarantors":[],
|
||||
{"effectiveDate":"2026-07-01","timeZone":"Europe/Paris",
|
||||
"tenantIds":["tenant-1"],"guarantors":[],
|
||||
"endDate":"2027-06-30","monthlyRent":1000.00,"monthlyCharges":75.00,
|
||||
"dueDay":12,"chargeMethod":"PROVISION","indexFlatRate":false}
|
||||
"""))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.id").value("lease-1"));
|
||||
|
||||
verify(archiving).archive(LANDLORD_ID, LEASE_ID);
|
||||
mockMvc.perform(post("/api/leases/lease-1/amendments").with(asLandlord()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content("""
|
||||
{"effectiveDate":"2026-07-01","timeZone":"Not/AZone",
|
||||
"tenantIds":["tenant-1"],"guarantors":[],
|
||||
"endDate":"2027-06-30","monthlyRent":1000.00,"monthlyCharges":75.00}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest());
|
||||
|
||||
verify(amendments).amend(LANDLORD_ID, LEASE_ID, LocalDate.of(2026, 7, 1),
|
||||
java.time.ZoneId.of("Europe/Paris"),
|
||||
new AmendLeaseService.Change(List.of(TENANT_ID), List.of(), LocalDate.of(2027, 6, 30),
|
||||
new BigDecimal("1000.00"), new BigDecimal("75.00"), 12, ChargeMethod.PROVISION, false));
|
||||
}
|
||||
@@ -385,6 +399,8 @@ class RentalWebAdaptersTest {
|
||||
void directLeaseActivationIsNotExposed() throws Exception {
|
||||
mockMvc.perform(post("/api/leases/lease-1/activate").with(asLandlord()).with(csrf()))
|
||||
.andExpect(status().isNotFound());
|
||||
mockMvc.perform(post("/api/leases/lease-1/archive").with(asLandlord()).with(csrf()))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -474,7 +490,8 @@ class RentalWebAdaptersTest {
|
||||
.thenReturn(payment);
|
||||
when(paymentRefunds.refund(LANDLORD_ID, PAYMENT_ID, "refund-1", 7, new BigDecimal("50"),
|
||||
LocalDate.of(2026, 2, 4), ZoneId.of("Europe/Paris"))).thenReturn(payment);
|
||||
when(paymentReversal.reverse(LANDLORD_ID, PAYMENT_ID, 7, "Erreur bancaire")).thenReturn(reversed);
|
||||
when(paymentReversal.reverse(LANDLORD_ID, PAYMENT_ID, 7, "Erreur bancaire",
|
||||
ZoneId.of("Europe/Paris"))).thenReturn(reversed);
|
||||
when(paymentHistory.get(LANDLORD_ID, PAYMENT_ID)).thenReturn(history);
|
||||
|
||||
mockMvc.perform(post("/api/rent-payments").with(asLandlord()).with(csrf())
|
||||
@@ -502,7 +519,8 @@ class RentalWebAdaptersTest {
|
||||
.andExpect(jsonPath("$.refunds[0].id").value("refund-1"));
|
||||
mockMvc.perform(post("/api/rent-payments/payment-1/reversal").with(asLandlord()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"paymentVersion\":7,\"reason\":\"Erreur bancaire\"}"))
|
||||
.content("{\"paymentVersion\":7,\"reason\":\"Erreur bancaire\","
|
||||
+ "\"timeZone\":\"Europe/Paris\"}"))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.reversal.reason").value("Erreur bancaire"));
|
||||
|
||||
@@ -512,7 +530,8 @@ class RentalWebAdaptersTest {
|
||||
new BigDecimal("50"));
|
||||
verify(paymentRefunds).refund(LANDLORD_ID, PAYMENT_ID, "refund-1", 7, new BigDecimal("50"),
|
||||
LocalDate.of(2026, 2, 4), ZoneId.of("Europe/Paris"));
|
||||
verify(paymentReversal).reverse(LANDLORD_ID, PAYMENT_ID, 7, "Erreur bancaire");
|
||||
verify(paymentReversal).reverse(LANDLORD_ID, PAYMENT_ID, 7, "Erreur bancaire",
|
||||
ZoneId.of("Europe/Paris"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -564,7 +583,8 @@ class RentalWebAdaptersTest {
|
||||
.andExpect(jsonPath("$.message").value("Le fuseau horaire est invalide."));
|
||||
mockMvc.perform(post("/api/rent-payments/payment-1/reversal").with(asLandlord()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"paymentVersion\":0,\"reason\":\"" + "a".repeat(501) + "\"}"))
|
||||
.content("{\"paymentVersion\":0,\"reason\":\"" + "a".repeat(501)
|
||||
+ "\",\"timeZone\":\"Europe/Paris\"}"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.message").isNotEmpty());
|
||||
}
|
||||
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
package fr.nessar.lmnporganizer.rental.adapter.in.web.lease;
|
||||
|
||||
import fr.nessar.lmnporganizer.LmnpOrganizerApplication;
|
||||
import fr.nessar.lmnporganizer.identity.adapter.in.security.SecurityConfiguration;
|
||||
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlord;
|
||||
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.RentalApiExceptionHandler;
|
||||
import fr.nessar.lmnporganizer.rental.application.landlord.LandlordAccountService;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.LeaseEndingService;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.LeaseEndingState;
|
||||
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.ExternalIdentity;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.Landlord;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseActionAudit;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseEndPhase;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSnapshot;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseType;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.NoticeDeliveryMode;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.NoticeEvidenceFile;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.RentTerms;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.RentalPeriod;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.TenantNoticeCommand;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.TenantNoticeId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.test.context.ContextConfiguration;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.request.RequestPostProcessor;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oidcLogin;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@WebMvcTest(controllers = LeaseEndingController.class)
|
||||
@ContextConfiguration(classes = LmnpOrganizerApplication.class)
|
||||
@Import({AuthenticatedLandlord.class, RentalApiExceptionHandler.class, SecurityConfiguration.class})
|
||||
class LeaseEndingWebTest {
|
||||
private static final LandlordId LANDLORD_ID = new LandlordId("landlord-1");
|
||||
private static final LeaseId LEASE_ID = new LeaseId("lease-1");
|
||||
private static final Instant NOW = Instant.parse("2026-07-19T00:30:00Z");
|
||||
|
||||
@Autowired
|
||||
MockMvc mockMvc;
|
||||
|
||||
@MockitoBean
|
||||
LandlordAccountService accounts;
|
||||
@MockitoBean
|
||||
LeaseEndingService endings;
|
||||
@MockitoBean
|
||||
Clock clock;
|
||||
|
||||
@BeforeEach
|
||||
void authenticate() {
|
||||
when(accounts.identify(any())).thenReturn(Landlord.register(LANDLORD_ID,
|
||||
new ExternalIdentity("subject-1"), "Camille", "Martin", "camille@example.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getRequiresAValidIanaTimeZoneAndExposesTheCanonicalAppendOnlyHistory() throws Exception {
|
||||
LeaseEndingState state = state();
|
||||
when(endings.state(LANDLORD_ID, LEASE_ID, ZoneId.of("America/Los_Angeles"))).thenReturn(state);
|
||||
|
||||
mockMvc.perform(get("/api/leases/lease-1/end").with(landlord()))
|
||||
.andExpect(status().isBadRequest());
|
||||
mockMvc.perform(get("/api/leases/lease-1/end").param("timeZone", "Mars/Olympus").with(landlord()))
|
||||
.andExpect(status().isBadRequest());
|
||||
mockMvc.perform(get("/api/leases/lease-1/end")
|
||||
.param("timeZone", "America/Los_Angeles").with(landlord()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.phase").value("NOTICE_PERIOD"))
|
||||
.andExpect(jsonPath("$.notices[0].ruleVersion").value("2026-07"))
|
||||
.andExpect(jsonPath("$.notices[0].recordedWithoutEvidenceConfirmed").value(true))
|
||||
.andExpect(jsonPath("$.notices[0].authorId").value("subject-1"))
|
||||
.andExpect(jsonPath("$.notices[0].recordedAt").value(NOW.toString()))
|
||||
.andExpect(jsonPath("$.notices[0].evidenceVersions[0].sha256").value("a".repeat(64)))
|
||||
.andExpect(jsonPath("$.agreedEarlyEnds.length()").value(1))
|
||||
.andExpect(jsonPath("$.agreedEarlyEnds[0].authorId").value("subject-1"));
|
||||
|
||||
verify(endings).state(LANDLORD_ID, LEASE_ID, ZoneId.of("America/Los_Angeles"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void noticeMultipartForwardsTimezoneEvidenceAttestationAndAuthenticatedAuthor() throws Exception {
|
||||
when(endings.recordNotice(any(), any(), any(), any(), any())).thenReturn(state());
|
||||
MockMultipartFile evidence = new MockMultipartFile("evidence", "conge.pdf", "application/pdf",
|
||||
"opaque".getBytes());
|
||||
|
||||
mockMvc.perform(multipart("/api/leases/lease-1/end/notices").file(evidence)
|
||||
.param("timeZone", "Europe/Paris")
|
||||
.param("receivedOn", "2026-07-19")
|
||||
.param("requestedDepartureOn", "2026-08-19")
|
||||
.param("deliveryMode", "REGISTERED_LETTER")
|
||||
.param("lateRegistrationConfirmed", "false")
|
||||
.param("nonStandardDeliveryConfirmed", "false")
|
||||
.param("missingEvidenceConfirmed", "false")
|
||||
.with(landlord()).with(csrf()))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.leaseId").value("lease-1"));
|
||||
|
||||
verify(endings).recordNotice(eq(LANDLORD_ID), eq(LEASE_ID), eq("subject-1"),
|
||||
eq(new LeaseEndingService.RecordNotice(ZoneId.of("Europe/Paris"),
|
||||
LocalDate.of(2026, 7, 19), LocalDate.of(2026, 8, 19),
|
||||
NoticeDeliveryMode.REGISTERED_LETTER, null, false, false, false)), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void evidenceDownloadIsAlwaysAnOpaqueSanitizedAttachment() throws Exception {
|
||||
when(endings.evidence(LANDLORD_ID, LEASE_ID, new TenantNoticeId("notice-1"), 2))
|
||||
.thenReturn(new LeaseEndingService.EvidenceDownload("../dangerous.html", "opaque".getBytes()));
|
||||
|
||||
mockMvc.perform(get("/api/leases/lease-1/end/notices/notice-1/evidence/2").with(landlord()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(header().string("Content-Type", MediaType.APPLICATION_OCTET_STREAM_VALUE))
|
||||
.andExpect(header().string("Content-Disposition", "attachment; filename=\"dangerous.html\""))
|
||||
.andExpect(header().string("X-Content-Type-Options", "nosniff"))
|
||||
.andExpect(content().bytes("opaque".getBytes()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void jsonMutationsForwardCivilDatesReasonsConfirmationAndAuthenticatedAuthor() throws Exception {
|
||||
when(endings.acceptWithdrawal(any(), any(), any(), any(), any())).thenReturn(state());
|
||||
when(endings.invalidateNotice(any(), any(), any(), any(), any())).thenReturn(state());
|
||||
when(endings.recordAgreedEarlyEnd(any(), any(), any(), any())).thenReturn(state());
|
||||
when(endings.close(any(), any(), any(), any())).thenReturn(state());
|
||||
|
||||
mockMvc.perform(post("/api/leases/lease-1/end/notices/notice-1/withdrawals")
|
||||
.with(landlord()).with(csrf()).contentType(MediaType.APPLICATION_JSON).content("""
|
||||
{"timeZone":"America/Los_Angeles","acceptedOn":"2026-07-18",
|
||||
"reason":"Le locataire reste"}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.leaseId").value("lease-1"));
|
||||
mockMvc.perform(post("/api/leases/lease-1/end/notices/notice-1/invalidations")
|
||||
.with(landlord()).with(csrf()).contentType(MediaType.APPLICATION_JSON).content("""
|
||||
{"timeZone":"Europe/London","invalidatedOn":"2026-07-19",
|
||||
"reason":"Erreur administrative"}
|
||||
"""))
|
||||
.andExpect(status().isOk());
|
||||
mockMvc.perform(post("/api/leases/lease-1/end/agreed-early-end")
|
||||
.with(landlord()).with(csrf()).contentType(MediaType.APPLICATION_JSON).content("""
|
||||
{"timeZone":"Europe/Paris","endOn":"2026-08-18","reason":"Accord écrit"}
|
||||
"""))
|
||||
.andExpect(status().isOk());
|
||||
mockMvc.perform(post("/api/leases/lease-1/end/closure")
|
||||
.with(landlord()).with(csrf()).contentType(MediaType.APPLICATION_JSON).content("""
|
||||
{"timeZone":"Europe/Paris","keysReturnedOn":"2026-08-18","confirmed":true}
|
||||
"""))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(endings).acceptWithdrawal(LANDLORD_ID, LEASE_ID, new TenantNoticeId("notice-1"), "subject-1",
|
||||
new LeaseEndingService.AdministrativeFact(ZoneId.of("America/Los_Angeles"),
|
||||
LocalDate.of(2026, 7, 18), "Le locataire reste"));
|
||||
verify(endings).invalidateNotice(LANDLORD_ID, LEASE_ID, new TenantNoticeId("notice-1"), "subject-1",
|
||||
new LeaseEndingService.AdministrativeFact(ZoneId.of("Europe/London"),
|
||||
LocalDate.of(2026, 7, 19), "Erreur administrative"));
|
||||
verify(endings).recordAgreedEarlyEnd(LANDLORD_ID, LEASE_ID, "subject-1",
|
||||
new LeaseEndingService.AgreedEnd(ZoneId.of("Europe/Paris"),
|
||||
LocalDate.of(2026, 8, 18), "Accord écrit"));
|
||||
verify(endings).close(LANDLORD_ID, LEASE_ID, "subject-1",
|
||||
new LeaseEndingService.Closure(ZoneId.of("Europe/Paris"),
|
||||
LocalDate.of(2026, 8, 18), true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidJsonMutationBodyIsRejectedBeforeTheService() throws Exception {
|
||||
mockMvc.perform(post("/api/leases/lease-1/end/notices/notice-1/withdrawals")
|
||||
.with(landlord()).with(csrf()).contentType(MediaType.APPLICATION_JSON).content("""
|
||||
{"timeZone":"Europe/Paris","acceptedOn":"2026-07-19","reason":" "}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest());
|
||||
|
||||
verifyNoInteractions(endings);
|
||||
}
|
||||
|
||||
@Test
|
||||
void leaseEndingRoutesRequireOidcAuthentication() throws Exception {
|
||||
mockMvc.perform(get("/api/leases/lease-1/end").param("timeZone", "Europe/Paris"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
private static LeaseEndingState state() {
|
||||
Lease lease = activeLease();
|
||||
LeaseActionAudit audit = new LeaseActionAudit("subject-1", NOW);
|
||||
TenantNoticeId noticeId = new TenantNoticeId("notice-1");
|
||||
lease.recordTenantNotice(new TenantNoticeCommand(noticeId, LocalDate.of(2026, 7, 19),
|
||||
LocalDate.of(2026, 8, 19), NoticeDeliveryMode.REGISTERED_LETTER, null,
|
||||
false, false, true, LocalDate.of(2026, 7, 19)), audit);
|
||||
lease.appendNoticeEvidence(noticeId, new NoticeEvidenceFile("blob", "conge.pdf", "application/pdf", 6,
|
||||
"a".repeat(64)), null, audit);
|
||||
lease.recordAgreedEarlyEnd(LocalDate.of(2026, 8, 18), "Fin convenue", audit);
|
||||
return new LeaseEndingState(LEASE_ID, true, Optional.empty(), LeaseEndPhase.NOTICE_PERIOD,
|
||||
LeaseType.STANDARD, Optional.empty(), Optional.of(LocalDate.of(2026, 8, 18)), false,
|
||||
lease.endingHistory());
|
||||
}
|
||||
|
||||
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(2030, 1, 1));
|
||||
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,
|
||||
LeaseType.STANDARD);
|
||||
lease.activate(new LeaseSnapshot(
|
||||
new LeaseSnapshot.Landlord(LANDLORD_ID, "Camille", "Martin", "camille@example.com", "+331",
|
||||
"1 rue A"),
|
||||
new LeaseSnapshot.Apartment(apartmentId, "Studio", "2 rue B"),
|
||||
List.of(new LeaseSnapshot.Tenant(tenantId, "Alex", "Durand", "alex@example.com", "", "3 rue C")),
|
||||
List.of(), lease.period(), terms, LeaseType.STANDARD));
|
||||
return lease;
|
||||
}
|
||||
|
||||
private static RequestPostProcessor landlord() {
|
||||
return oidcLogin().idToken(token -> token.claim("sub", "subject-1").claim("given_name", "Camille")
|
||||
.claim("family_name", "Martin").claim("email", "camille@example.com"));
|
||||
}
|
||||
}
|
||||
+7
-2
@@ -93,11 +93,16 @@ class RentDueRefreshWebTest {
|
||||
|
||||
@Test
|
||||
void listingRemainsAQueryWithoutImplicitRefresh() throws Exception {
|
||||
when(rentDues.list(LANDLORD_ID)).thenReturn(List.of(due("due-2026-12", YearMonth.of(2026, 12))));
|
||||
RentDue correction = RentDue.reconstitute(new RentDueId("due-2026-12"), LANDLORD_ID,
|
||||
new LeaseId("lease-1"), YearMonth.of(2026, 12), LocalDate.of(2026, 12, 5),
|
||||
new BigDecimal("400"), new BigDecimal("40"), new BigDecimal("1320"),
|
||||
new BigDecimal("1200"), new BigDecimal("120"), 1);
|
||||
when(rentDues.list(LANDLORD_ID)).thenReturn(List.of(correction));
|
||||
|
||||
mockMvc.perform(get("/api/rent-dues").with(owner()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].month").value("2026-12"));
|
||||
.andExpect(jsonPath("$[0].month").value("2026-12"))
|
||||
.andExpect(jsonPath("$[0].correctionRequired").value(true));
|
||||
|
||||
verify(rentDues).list(LANDLORD_ID);
|
||||
verify(refresh, never()).refresh(any());
|
||||
|
||||
+79
-1
@@ -13,6 +13,7 @@ import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.ApartmentOccupancy;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.ChargeMethod;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseActionAudit;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseAmendmentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseContractVersion;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
@@ -20,11 +21,17 @@ import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSnapshot;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseStatus;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSpecialClause;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseType;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.NoticeDeliveryMode;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.NoticeEvidenceFile;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.OccupancyPeriod;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.RentTerms;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.RentalPeriod;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.TenantNoticeCommand;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.TenantNoticeId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import org.bson.Document;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -110,7 +117,8 @@ class MongoLeaseRepositoriesTest {
|
||||
MongoLeaseRepository.LeaseDocument legacy = new MongoLeaseRepository.LeaseDocument(
|
||||
LEASE_ID.value(), LANDLORD_ID.value(), APARTMENT_ID.value(), List.of(TENANT_ID.value()), List.of(),
|
||||
PERIOD.startDate(), PERIOD.endDate(), TERMS.monthlyRent(), TERMS.monthlyCharges(),
|
||||
TERMS.securityDeposit(), LeaseStatus.DRAFT.name(), null, null, null, null, null, null, null, null, 3L);
|
||||
TERMS.securityDeposit(), LeaseStatus.DRAFT.name(), null, null, null, null, null, null, null, null,
|
||||
null, 3L);
|
||||
|
||||
Lease restoredLease = MongoLeaseRepository.toDomain(legacy);
|
||||
RentTerms restored = restoredLease.terms();
|
||||
@@ -122,6 +130,45 @@ class MongoLeaseRepositoriesTest {
|
||||
assertThat(restoredLease.specialClauses()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void leaseEndingHistoryRoundTripsAsEmbeddedAuditedFactsAndDefaultsWhenAbsent() {
|
||||
Lease lease = standardActiveLease();
|
||||
LeaseActionAudit audit = new LeaseActionAudit("oidc:landlord-1",
|
||||
Instant.parse("2026-07-19T12:30:00Z"));
|
||||
TenantNoticeId firstNotice = new TenantNoticeId("notice-1");
|
||||
lease.recordTenantNotice(new TenantNoticeCommand(firstNotice, LocalDate.of(2026, 7, 1),
|
||||
LocalDate.of(2026, 8, 1), NoticeDeliveryMode.REGISTERED_LETTER, null,
|
||||
true, false, LocalDate.of(2026, 7, 19)), audit);
|
||||
lease.acceptNoticeWithdrawal(firstNotice, LocalDate.of(2026, 7, 2), "Le locataire reste",
|
||||
LocalDate.of(2026, 7, 19), audit);
|
||||
TenantNoticeId secondNotice = new TenantNoticeId("notice-2");
|
||||
lease.recordTenantNotice(new TenantNoticeCommand(secondNotice, LocalDate.of(2026, 7, 3),
|
||||
LocalDate.of(2026, 8, 3), NoticeDeliveryMode.BAILIFF, null,
|
||||
true, false, LocalDate.of(2026, 7, 19)), audit);
|
||||
lease.invalidateNotice(secondNotice, LocalDate.of(2026, 7, 4), "Erreur de date",
|
||||
LocalDate.of(2026, 7, 19), audit);
|
||||
lease.recordAgreedEarlyEnd(LocalDate.of(2026, 8, 31), "Fin convenue", audit);
|
||||
lease.close(LocalDate.of(2026, 8, 30), true, LocalDate.of(2026, 8, 31), audit);
|
||||
lease.appendNoticeEvidence(firstNotice, new NoticeEvidenceFile("blob-1", "conge.pdf",
|
||||
"application/pdf", 42, "a".repeat(64)), null, audit);
|
||||
|
||||
MongoLeaseRepository.LeaseDocument document = MongoLeaseRepository.toDocument(lease);
|
||||
Lease restored = MongoLeaseRepository.toDomain(document.withVersion(4L));
|
||||
|
||||
assertThat(document.endingHistory()).isNotNull();
|
||||
assertThat(document.endingHistory().notices()).hasSize(2);
|
||||
assertThat(restored.version()).isEqualTo(4);
|
||||
assertThat(restored.status()).isEqualTo(LeaseStatus.ARCHIVED);
|
||||
assertThat(restored.contractEndOn()).contains(LocalDate.of(2026, 8, 31));
|
||||
assertThat(restored.endingHistory().notices()).isEqualTo(lease.endingHistory().notices());
|
||||
assertThat(restored.endingHistory().withdrawals()).isEqualTo(lease.endingHistory().withdrawals());
|
||||
assertThat(restored.endingHistory().invalidations()).isEqualTo(lease.endingHistory().invalidations());
|
||||
assertThat(restored.endingHistory().agreedEarlyEnds()).isEqualTo(lease.endingHistory().agreedEarlyEnds());
|
||||
assertThat(restored.endingHistory().evidenceVersions())
|
||||
.isEqualTo(lease.endingHistory().evidenceVersions());
|
||||
assertThat(restored.endingHistory().closure()).isEqualTo(lease.endingHistory().closure());
|
||||
}
|
||||
|
||||
@Test
|
||||
void leaseLookupAndDraftDeleteRemainLandlordScoped() {
|
||||
Lease draft = Lease.reconstitute(
|
||||
@@ -198,6 +245,24 @@ class MongoLeaseRepositoriesTest {
|
||||
.append("apartmentId", APARTMENT_ID.value()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void occupancyDocumentStoresAnOpenEndAsNull() {
|
||||
ApartmentOccupancy occupancy = ApartmentOccupancy.empty(LANDLORD_ID, APARTMENT_ID);
|
||||
occupancy.reserve(LEASE_ID, OccupancyPeriod.open(PERIOD.startDate()));
|
||||
when(mongoTemplate.insert(any(MongoApartmentOccupancyRepository.OccupancyDocument.class)))
|
||||
.thenAnswer(invocation -> invocation
|
||||
.<MongoApartmentOccupancyRepository.OccupancyDocument>getArgument(0)
|
||||
.withVersion(0L));
|
||||
|
||||
ApartmentOccupancy saved = new MongoApartmentOccupancyRepository(mongoTemplate).save(occupancy);
|
||||
|
||||
assertThat(saved.reservations().getFirst().period().endOn()).isEmpty();
|
||||
ArgumentCaptor<MongoApartmentOccupancyRepository.OccupancyDocument> document =
|
||||
ArgumentCaptor.forClass(MongoApartmentOccupancyRepository.OccupancyDocument.class);
|
||||
verify(mongoTemplate).insert(document.capture());
|
||||
assertThat(document.getValue().reservations().getFirst().endDate()).isNull();
|
||||
}
|
||||
|
||||
private static Lease activeLease() {
|
||||
Lease lease = Lease.draft(LEASE_ID, LANDLORD_ID, APARTMENT_ID, List.of(TENANT_ID), List.of(), PERIOD, TERMS,
|
||||
LeaseType.STUDENT, List.of(
|
||||
@@ -207,6 +272,19 @@ class MongoLeaseRepositoriesTest {
|
||||
return lease;
|
||||
}
|
||||
|
||||
private static Lease standardActiveLease() {
|
||||
Lease lease = Lease.draft(new LeaseId("lease-ending"), LANDLORD_ID, APARTMENT_ID, List.of(TENANT_ID),
|
||||
List.of(), PERIOD, TERMS, LeaseType.STANDARD);
|
||||
lease.activate(new LeaseSnapshot(
|
||||
new LeaseSnapshot.Landlord(
|
||||
LANDLORD_ID, "Léa", "Dubois", "lea@example.com", "+33612345678", "1 rue A"),
|
||||
new LeaseSnapshot.Apartment(APARTMENT_ID, "Studio", "2 rue B", legalProfile()),
|
||||
List.of(new LeaseSnapshot.Tenant(
|
||||
TENANT_ID, "Camille", "Martin", "camille@example.com", "", "3 rue C")),
|
||||
List.of(), lease.period(), TERMS, LeaseType.STANDARD));
|
||||
return lease;
|
||||
}
|
||||
|
||||
private static LeaseSnapshot snapshot(RentalPeriod period) {
|
||||
return new LeaseSnapshot(
|
||||
new LeaseSnapshot.Landlord(
|
||||
|
||||
+5
-2
@@ -38,6 +38,7 @@ import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDate;
|
||||
import java.time.MonthDay;
|
||||
import java.time.YearMonth;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
@@ -195,7 +196,7 @@ class ThymeleafRentalDocumentRendererTest {
|
||||
new BigDecimal("24.60"), new BigDecimal("40"), "Vue dégagée exceptionnelle"),
|
||||
new LeaseLegalTerms.PreviousRentTerms(true, new BigDecimal("850"),
|
||||
LocalDate.of(2025, 9, 1), LocalDate.of(2026, 6, 5), true),
|
||||
new LeaseLegalTerms.RentRevisionTerms(true, LocalDate.of(2027, 9, 1), 2, 2026, "001515333"),
|
||||
new LeaseLegalTerms.RentRevisionTerms(true, MonthDay.of(9, 1), 2, 2026, "001515333"),
|
||||
new LeaseLegalTerms.RecentWork("Réfection complète des peintures", new BigDecimal("3000")),
|
||||
true, true, false, true, "Usage paisible de la cour intérieure.");
|
||||
LeaseContractVersion contract = contract(profile, LeaseType.STANDARD, legalTerms,
|
||||
@@ -215,7 +216,8 @@ class ThymeleafRentalDocumentRendererTest {
|
||||
"III. Date de prise d'effet et durée du contrat",
|
||||
"1 Septembre 2026", "31 Août 2027", "reconduit tacitement",
|
||||
"IV. Conditions financières", "900,00 €", "24,60 € / m²", "40,00 €",
|
||||
"1 Septembre 2025", "5 Juin 2026", "2e trimestre 2026", "001515333",
|
||||
"1 Septembre 2025", "5 Juin 2026", "Le loyer pourra être révisé chaque année le 1 Septembre",
|
||||
"2e trimestre 2026", "001515333",
|
||||
"Provision sur charges avec régularisation annuelle", "payables mensuellement d'avance",
|
||||
"950,00 €", "V. Travaux", "Réfection complète des peintures", "3 000,00 €",
|
||||
"VI. Garanties", "VII. Clause de solidarité", "VIII. Clause résolutoire",
|
||||
@@ -224,6 +226,7 @@ class ThymeleafRentalDocumentRendererTest {
|
||||
"Usage paisible de la cour intérieure.", "XI. Annexes", "Modèle FR-MEUBLE-2025-01 · Révision 4");
|
||||
List<String> missing = required.stream().filter(fragment -> !text.contains(fragment)).toList();
|
||||
assertTrue(missing.isEmpty(), () -> "Missing lease content: " + missing + " in:\n" + text);
|
||||
assertFalse(text.contains("Le loyer pourra être révisé chaque année le 1 Septembre 2027"));
|
||||
assertFalse(text.contains("[") || text.contains("null") || text.contains("Non renseigné")
|
||||
|| text.contains("+33612345678") || text.contains("+33611111111") || !text.contains("Fait le")
|
||||
|| !text.contains("Fait à"));
|
||||
|
||||
+11
-3
@@ -10,6 +10,7 @@ import fr.nessar.lmnporganizer.rental.domain.landlord.Landlord;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.ApartmentOccupancy;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.ApartmentOccupancyRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
|
||||
@@ -113,7 +114,7 @@ class ActivateLeaseServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAnotherActiveLeaseForTheApartmentEvenAfterItsInitialPeriodEnded() {
|
||||
void rejectsAnOpenOperationalOccupancyEvenAfterTheExistingStandardLeasesSignedPeriodEnded() {
|
||||
LandlordRepository landlords = mock(LandlordRepository.class);
|
||||
ApartmentRepository apartments = mock(ApartmentRepository.class);
|
||||
TenantRepository tenants = mock(TenantRepository.class);
|
||||
@@ -122,14 +123,21 @@ class ActivateLeaseServiceTest {
|
||||
ApartmentOccupancyRepository occupancies = mock(ApartmentOccupancyRepository.class);
|
||||
Lease draft = draft();
|
||||
Lease existing = activeLeaseEnding(LocalDate.of(2025, 12, 31));
|
||||
ApartmentOccupancy occupancy = ApartmentOccupancy.empty(LANDLORD_ID, APARTMENT_ID);
|
||||
occupancy.reserve(existing.id(), existing.occupancyPeriod());
|
||||
when(leases.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.of(draft));
|
||||
when(leases.findAll(LANDLORD_ID)).thenReturn(List.of(existing, draft));
|
||||
when(landlords.find(LANDLORD_ID)).thenReturn(Optional.of(completeLandlord()));
|
||||
when(apartments.find(LANDLORD_ID, APARTMENT_ID)).thenReturn(Optional.of(new Apartment(
|
||||
APARTMENT_ID, LANDLORD_ID, "Studio", "2 rue B", new BigDecimal("120000"), "", legalProfile())));
|
||||
when(tenants.find(LANDLORD_ID, TENANT_ID)).thenReturn(Optional.of(new Tenant(
|
||||
TENANT_ID, LANDLORD_ID, "Camille", "Martin", "camille@example.com", "", "3 rue C", List.of())));
|
||||
when(occupancies.find(LANDLORD_ID, APARTMENT_ID)).thenReturn(Optional.of(occupancy));
|
||||
|
||||
ActivateLeaseService service = new ActivateLeaseService(landlords, apartments, tenants, leases, rentDues,
|
||||
occupancies, new RecordingTransactions(), () -> "due-1");
|
||||
|
||||
assertThrows(LeaseRuleViolation.class, () -> service.activate(LANDLORD_ID, LEASE_ID));
|
||||
verify(landlords, never()).find(any());
|
||||
verify(leases, never()).findAll(any());
|
||||
verify(leases, never()).save(any());
|
||||
verify(rentDues, never()).saveAll(any());
|
||||
}
|
||||
|
||||
+80
-1
@@ -1,6 +1,7 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.lease;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
|
||||
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.ApartmentOccupancy;
|
||||
@@ -10,11 +11,15 @@ import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRuleViolation;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseActionAudit;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSnapshot;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSpecialClause;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseType;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.RentTerms;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.RentalPeriod;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.NoticeDeliveryMode;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.TenantNoticeCommand;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.TenantNoticeId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDue;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueRepository;
|
||||
@@ -28,6 +33,7 @@ import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.ZoneId;
|
||||
import java.time.YearMonth;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -41,6 +47,7 @@ import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
class AmendLeaseServiceTest {
|
||||
private static final LandlordId LANDLORD_ID = new LandlordId("landlord-1");
|
||||
@@ -73,6 +80,7 @@ class AmendLeaseServiceTest {
|
||||
|
||||
Lease amended = new AmendLeaseService(leases, tenants, rentDues, occupancies, transactions,
|
||||
() -> "amendment-1", CLOCK).amend(LANDLORD_ID, LEASE_ID, LocalDate.of(2026, 7, 16),
|
||||
ZoneOffset.UTC,
|
||||
new AmendLeaseService.Change(List.of(FIRST_TENANT_ID, SECOND_TENANT_ID), List.of(),
|
||||
LocalDate.of(2026, 8, 31), new BigDecimal("1200"), new BigDecimal("120"), 12,
|
||||
ChargeMethod.PROVISION, false));
|
||||
@@ -104,12 +112,83 @@ class AmendLeaseServiceTest {
|
||||
() -> "amendment-1", CLOCK);
|
||||
|
||||
assertThrows(LeaseRuleViolation.class, () -> service.amend(LANDLORD_ID, LEASE_ID,
|
||||
LocalDate.of(2026, 6, 30), new AmendLeaseService.Change(List.of(), List.of(),
|
||||
LocalDate.of(2026, 6, 30), ZoneOffset.UTC, new AmendLeaseService.Change(List.of(), List.of(),
|
||||
LocalDate.of(2026, 12, 31), new BigDecimal("1200"), new BigDecimal("120"))));
|
||||
|
||||
assertFalse(transactions.called);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAnAmendmentThatWouldMoveAnActiveLeaseEndBeforeTouchingOtherState() {
|
||||
LeaseRepository leases = mock(LeaseRepository.class);
|
||||
TenantRepository tenants = mock(TenantRepository.class);
|
||||
Lease lease = activeLease();
|
||||
lease.recordTenantNotice(new TenantNoticeCommand(new TenantNoticeId("notice-1"),
|
||||
LocalDate.of(2026, 7, 1), LocalDate.of(2027, 1, 1), NoticeDeliveryMode.BAILIFF,
|
||||
null, false, false, false, LocalDate.of(2026, 7, 1)),
|
||||
new LeaseActionAudit("subject-1", Instant.parse("2026-07-01T12:00:00Z")));
|
||||
Tenant first = tenant(FIRST_TENANT_ID, "Camille", "3 rue C");
|
||||
when(leases.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.of(lease));
|
||||
when(tenants.find(LANDLORD_ID, FIRST_TENANT_ID)).thenReturn(Optional.of(first));
|
||||
AmendLeaseService service = new AmendLeaseService(leases, tenants, mock(RentDueRepository.class),
|
||||
mock(ApartmentOccupancyRepository.class), new RecordingTransactions(),
|
||||
() -> "amendment-1", CLOCK);
|
||||
|
||||
assertThrows(LeaseRuleViolation.class, () -> service.amend(LANDLORD_ID, LEASE_ID,
|
||||
LocalDate.of(2026, 7, 1), ZoneOffset.UTC,
|
||||
new AmendLeaseService.Change(List.of(FIRST_TENANT_ID), List.of(),
|
||||
LocalDate.of(2026, 7, 31), new BigDecimal("1200"), new BigDecimal("120"))));
|
||||
|
||||
assertEquals(0, lease.amendments().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void retroactivityUsesTheBrowserCivilTimeZone() {
|
||||
LeaseRepository leases = mock(LeaseRepository.class);
|
||||
RecordingTransactions transactions = new RecordingTransactions();
|
||||
Clock boundary = Clock.fixed(Instant.parse("2026-07-01T00:30:00Z"), ZoneOffset.UTC);
|
||||
AmendLeaseService service = new AmendLeaseService(leases, mock(TenantRepository.class),
|
||||
mock(RentDueRepository.class), mock(ApartmentOccupancyRepository.class), transactions,
|
||||
() -> "amendment-1", boundary);
|
||||
AmendLeaseService.Change change = new AmendLeaseService.Change(List.of(FIRST_TENANT_ID), List.of(),
|
||||
LocalDate.of(2026, 9, 30), new BigDecimal("1200"), new BigDecimal("120"));
|
||||
|
||||
assertThrows(ResourceNotFound.class, () -> service.amend(LANDLORD_ID, LEASE_ID,
|
||||
LocalDate.of(2026, 6, 30), ZoneId.of("America/Los_Angeles"), change));
|
||||
|
||||
assertTrue(transactions.called);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shorteningAStudentLeaseNeverResavesADueDeletedByTheAmendment() {
|
||||
LeaseRepository leases = mock(LeaseRepository.class);
|
||||
TenantRepository tenants = mock(TenantRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
ApartmentOccupancyRepository occupancies = mock(ApartmentOccupancyRepository.class);
|
||||
Lease lease = activeLease();
|
||||
Tenant first = tenant(FIRST_TENANT_ID, "Camille", "3 rue C");
|
||||
ApartmentOccupancy occupancy = ApartmentOccupancy.empty(LANDLORD_ID, APARTMENT_ID);
|
||||
occupancy.reserve(LEASE_ID, lease.occupancyPeriod());
|
||||
RentDue september = due(YearMonth.of(2026, 9));
|
||||
when(leases.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.of(lease));
|
||||
when(leases.save(lease)).thenReturn(lease);
|
||||
when(tenants.find(LANDLORD_ID, FIRST_TENANT_ID)).thenReturn(Optional.of(first));
|
||||
when(occupancies.find(LANDLORD_ID, APARTMENT_ID)).thenReturn(Optional.of(occupancy));
|
||||
when(rentDues.findAll(LANDLORD_ID, LEASE_ID)).thenReturn(List.of(september));
|
||||
when(rentDues.deleteUnallocated(september)).thenReturn(true);
|
||||
|
||||
new AmendLeaseService(leases, tenants, rentDues, occupancies, new RecordingTransactions(),
|
||||
() -> "generated", CLOCK).amend(LANDLORD_ID, LEASE_ID, LocalDate.of(2026, 7, 16),
|
||||
ZoneOffset.UTC, new AmendLeaseService.Change(List.of(FIRST_TENANT_ID), List.of(),
|
||||
LocalDate.of(2026, 8, 31), new BigDecimal("1000"), new BigDecimal("100")));
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<List<RentDue>> saved = ArgumentCaptor.forClass(List.class);
|
||||
verify(rentDues).deleteUnallocated(september);
|
||||
verify(rentDues).saveAll(saved.capture());
|
||||
assertTrue(!saved.getValue().contains(september));
|
||||
}
|
||||
|
||||
private Lease activeLease() {
|
||||
RentalPeriod requestedPeriod = new RentalPeriod(LocalDate.of(2026, 1, 1), LocalDate.of(2026, 8, 31));
|
||||
RentTerms terms = new RentTerms(new BigDecimal("1000"), new BigDecimal("100"), new BigDecimal("1000"));
|
||||
|
||||
-86
@@ -1,86 +0,0 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.lease;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRuleViolation;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSnapshot;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseStatus;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.RentTerms;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.RentalPeriod;
|
||||
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ArchiveLeaseServiceTest {
|
||||
|
||||
private static final LandlordId LANDLORD_ID = new LandlordId("landlord-1");
|
||||
private static final LeaseId LEASE_ID = new LeaseId("lease-1");
|
||||
|
||||
@Test
|
||||
void archivesAndSavesTheLandlordScopedActiveLease() {
|
||||
LeaseRepository leases = mock(LeaseRepository.class);
|
||||
Lease active = activeLease();
|
||||
when(leases.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.of(active));
|
||||
when(leases.save(active)).thenReturn(active);
|
||||
|
||||
Lease archived = new ArchiveLeaseService(leases).archive(LANDLORD_ID, LEASE_ID);
|
||||
|
||||
assertEquals(LeaseStatus.ARCHIVED, archived.status());
|
||||
verify(leases).find(LANDLORD_ID, LEASE_ID);
|
||||
verify(leases).save(active);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsArchivingADraftWithoutSavingIt() {
|
||||
LeaseRepository leases = mock(LeaseRepository.class);
|
||||
Lease draft = draftLease();
|
||||
when(leases.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.of(draft));
|
||||
ArchiveLeaseService service = new ArchiveLeaseService(leases);
|
||||
|
||||
assertThrows(LeaseRuleViolation.class, () -> service.archive(LANDLORD_ID, LEASE_ID));
|
||||
|
||||
verify(leases).find(LANDLORD_ID, LEASE_ID);
|
||||
verify(leases, never()).save(any());
|
||||
}
|
||||
|
||||
private static Lease activeLease() {
|
||||
Lease lease = draftLease();
|
||||
RentalPeriod period = lease.period();
|
||||
RentTerms terms = lease.terms();
|
||||
lease.activate(new LeaseSnapshot(
|
||||
new LeaseSnapshot.Landlord(
|
||||
LANDLORD_ID, "Léa", "Dubois", "lea@example.com", "+33612345678", "1 rue A"),
|
||||
new LeaseSnapshot.Apartment(lease.apartmentId(), "Studio", "2 rue B"),
|
||||
List.of(new LeaseSnapshot.Tenant(
|
||||
lease.tenantIds().getFirst(), "Camille", "Martin", "camille@example.com", "", "3 rue C")),
|
||||
List.of(),
|
||||
period,
|
||||
terms));
|
||||
return lease;
|
||||
}
|
||||
|
||||
private static Lease draftLease() {
|
||||
return Lease.draft(
|
||||
LEASE_ID,
|
||||
LANDLORD_ID,
|
||||
new ApartmentId("apartment-1"),
|
||||
List.of(new TenantId("tenant-1")),
|
||||
List.of(),
|
||||
new RentalPeriod(LocalDate.of(2026, 1, 1), LocalDate.of(2026, 12, 31)),
|
||||
new RentTerms(new BigDecimal("900"), new BigDecimal("50"), new BigDecimal("900")));
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -18,7 +18,8 @@ class LeaseClauseCatalogTest {
|
||||
LeaseClauseCatalog catalog = LeaseClauseCatalog.load(
|
||||
getClass().getResourceAsStream("/lease-clauses.yml"));
|
||||
|
||||
assertEquals(2, catalog.available(LeaseClauseCatalog.Context.STANDARD).size());
|
||||
assertEquals(12, catalog.available(LeaseClauseCatalog.Context.STANDARD).size());
|
||||
assertEquals(13, catalog.available(LeaseClauseCatalog.Context.COLOCATION).size());
|
||||
var rendered = catalog.render("VISIT_SALE_RELETTING", LeaseClauseCatalog.Context.STANDARD,
|
||||
Map.of("startTime", "18:30"));
|
||||
assertTrue(rendered.text().contains("à partir de 18:30"));
|
||||
|
||||
+323
@@ -0,0 +1,323 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.lease;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.IdentifierGenerator;
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.LeaseDocumentStorage;
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
|
||||
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.ApartmentOccupancy;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.ApartmentOccupancyRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseActionAudit;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRuleViolation;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSnapshot;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseType;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseEndPhase;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.NoticeDeliveryMode;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.OccupancyPeriod;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.RentTerms;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.RentalPeriod;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.TenantNoticeCommand;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.TenantNoticeId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDue;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.YearMonth;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class LeaseEndingServiceTest {
|
||||
private static final LandlordId LANDLORD_ID = new LandlordId("landlord-1");
|
||||
private static final ApartmentId APARTMENT_ID = new ApartmentId("apartment-1");
|
||||
private static final LeaseId LEASE_ID = new LeaseId("lease-1");
|
||||
private static final TenantId TENANT_ID = new TenantId("tenant-1");
|
||||
private static final ZoneId PARIS = ZoneId.of("Europe/Paris");
|
||||
private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-07-19T12:30:00Z"), ZoneOffset.UTC);
|
||||
private static final byte[] EVIDENCE = "opaque evidence".getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
|
||||
@Test
|
||||
void recordingNoticeAtomicallyBoundsOccupancyAndReconcilesDues() {
|
||||
LeaseRepository leases = mock(LeaseRepository.class);
|
||||
ApartmentOccupancyRepository occupancies = mock(ApartmentOccupancyRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
Lease lease = activeStandardLease();
|
||||
ApartmentOccupancy occupancy = ApartmentOccupancy.empty(LANDLORD_ID, APARTMENT_ID);
|
||||
occupancy.reserve(LEASE_ID, OccupancyPeriod.open(lease.period().startDate()));
|
||||
RentDue august = due(YearMonth.of(2026, 8));
|
||||
RentDue september = due(YearMonth.of(2026, 9));
|
||||
when(leases.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.of(lease));
|
||||
when(occupancies.find(LANDLORD_ID, APARTMENT_ID)).thenReturn(Optional.of(occupancy));
|
||||
when(rentDues.findAll(LANDLORD_ID, LEASE_ID)).thenReturn(List.of(august, september));
|
||||
when(leases.save(lease)).thenReturn(lease);
|
||||
LeaseEndingService service = service(leases, occupancies, rentDues, mock(LeaseDocumentStorage.class));
|
||||
|
||||
LeaseEndingState state = service.recordNotice(LANDLORD_ID, LEASE_ID, "oidc:landlord-1",
|
||||
new LeaseEndingService.RecordNotice(PARIS, LocalDate.of(2026, 7, 19),
|
||||
LocalDate.of(2026, 8, 19), NoticeDeliveryMode.REGISTERED_LETTER,
|
||||
null, false, false, true), null);
|
||||
|
||||
assertEquals(LocalDate.of(2026, 8, 19), state.effectiveEndOn().orElseThrow());
|
||||
assertTrue(state.history().notices().getFirst().recordedWithoutEvidenceConfirmed());
|
||||
assertEquals(LocalDate.of(2026, 8, 19),
|
||||
occupancy.reservations().getFirst().period().endOn().orElseThrow());
|
||||
assertEquals(new BigDecimal("612.90"), august.rent());
|
||||
assertEquals(new BigDecimal("61.29"), august.charges());
|
||||
assertEquals(BigDecimal.ZERO, september.total());
|
||||
verify(rentDues).saveAll(List.of(august, september));
|
||||
verify(occupancies).save(occupancy);
|
||||
verify(leases).save(lease);
|
||||
}
|
||||
|
||||
@Test
|
||||
void noticeWithoutEvidenceRequiresTheExplicitAttestation() {
|
||||
LeaseRepository leases = mock(LeaseRepository.class);
|
||||
LeaseDocumentStorage storage = mock(LeaseDocumentStorage.class);
|
||||
LeaseEndingService service = service(leases, mock(ApartmentOccupancyRepository.class),
|
||||
mock(RentDueRepository.class), storage);
|
||||
|
||||
assertThrows(LeaseRuleViolation.class, () -> service.recordNotice(
|
||||
LANDLORD_ID, LEASE_ID, "oidc:landlord-1",
|
||||
new LeaseEndingService.RecordNotice(PARIS, LocalDate.of(2026, 7, 19),
|
||||
LocalDate.of(2026, 8, 19), NoticeDeliveryMode.BAILIFF,
|
||||
null, false, false, false), null));
|
||||
|
||||
verify(leases, never()).find(any(), any());
|
||||
verify(storage, never()).store(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void civilTodayIsComputedInTheLandlordSuppliedTimeZoneWithoutConvertingTheEnteredDate() {
|
||||
Clock boundaryClock = Clock.fixed(Instant.parse("2026-07-19T00:30:00Z"), ZoneOffset.UTC);
|
||||
Lease parisLease = activeStandardLease();
|
||||
LeaseRepository parisLeases = mock(LeaseRepository.class);
|
||||
ApartmentOccupancyRepository parisOccupancies = mock(ApartmentOccupancyRepository.class);
|
||||
RentDueRepository parisDues = mock(RentDueRepository.class);
|
||||
ApartmentOccupancy parisOccupancy = ApartmentOccupancy.empty(LANDLORD_ID, APARTMENT_ID);
|
||||
parisOccupancy.reserve(LEASE_ID, parisLease.occupancyPeriod());
|
||||
when(parisLeases.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.of(parisLease));
|
||||
when(parisLeases.save(parisLease)).thenReturn(parisLease);
|
||||
when(parisOccupancies.find(LANDLORD_ID, APARTMENT_ID)).thenReturn(Optional.of(parisOccupancy));
|
||||
when(parisDues.findAll(LANDLORD_ID, LEASE_ID)).thenReturn(List.of());
|
||||
LeaseEndingService parisService = service(
|
||||
parisLeases, parisOccupancies, parisDues, mock(LeaseDocumentStorage.class), boundaryClock);
|
||||
|
||||
LeaseEndingState paris = parisService.recordNotice(LANDLORD_ID, LEASE_ID, "oidc:landlord-1",
|
||||
new LeaseEndingService.RecordNotice(ZoneId.of("Europe/Paris"), LocalDate.of(2026, 7, 19),
|
||||
LocalDate.of(2026, 8, 19), NoticeDeliveryMode.BAILIFF,
|
||||
null, false, false, true), null);
|
||||
assertEquals(LocalDate.of(2026, 7, 19), paris.history().notices().getFirst().receivedOn());
|
||||
|
||||
Lease lasVegasLease = activeStandardLease();
|
||||
LeaseRepository lasVegasLeases = mock(LeaseRepository.class);
|
||||
when(lasVegasLeases.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.of(lasVegasLease));
|
||||
LeaseEndingService lasVegasService = service(lasVegasLeases, mock(ApartmentOccupancyRepository.class),
|
||||
mock(RentDueRepository.class), mock(LeaseDocumentStorage.class), boundaryClock);
|
||||
|
||||
assertThrows(LeaseRuleViolation.class, () -> lasVegasService.recordNotice(
|
||||
LANDLORD_ID, LEASE_ID, "oidc:landlord-1",
|
||||
new LeaseEndingService.RecordNotice(ZoneId.of("America/Los_Angeles"),
|
||||
LocalDate.of(2026, 7, 19), LocalDate.of(2026, 8, 19), NoticeDeliveryMode.BAILIFF,
|
||||
null, false, false, true), null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void withdrawingANoticeCannotReopenOccupancyAcrossAFinalizedSuccessor() {
|
||||
LeaseRepository leases = mock(LeaseRepository.class);
|
||||
ApartmentOccupancyRepository occupancies = mock(ApartmentOccupancyRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
Lease lease = activeStandardLease();
|
||||
LeaseActionAudit audit = new LeaseActionAudit("oidc:landlord-1", CLOCK.instant());
|
||||
TenantNoticeId noticeId = new TenantNoticeId("notice-1");
|
||||
lease.recordTenantNotice(new TenantNoticeCommand(noticeId, LocalDate.of(2026, 7, 19),
|
||||
LocalDate.of(2026, 8, 19), NoticeDeliveryMode.BAILIFF, null, false, false,
|
||||
LocalDate.of(2026, 7, 19)), audit);
|
||||
ApartmentOccupancy occupancy = ApartmentOccupancy.empty(LANDLORD_ID, APARTMENT_ID);
|
||||
occupancy.reserve(LEASE_ID, lease.occupancyPeriod());
|
||||
occupancy.reserve(new LeaseId("successor"), OccupancyPeriod.closed(
|
||||
LocalDate.of(2026, 8, 20), LocalDate.of(2027, 8, 19)));
|
||||
when(leases.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.of(lease));
|
||||
when(occupancies.find(LANDLORD_ID, APARTMENT_ID)).thenReturn(Optional.of(occupancy));
|
||||
LeaseEndingService service = service(leases, occupancies, rentDues, mock(LeaseDocumentStorage.class));
|
||||
|
||||
assertThrows(LeaseRuleViolation.class, () -> service.acceptWithdrawal(
|
||||
LANDLORD_ID, LEASE_ID, noticeId, "oidc:landlord-1",
|
||||
new LeaseEndingService.AdministrativeFact(PARIS, LocalDate.of(2026, 7, 19),
|
||||
"Le locataire reste")));
|
||||
|
||||
verify(leases, never()).save(any());
|
||||
verify(occupancies, never()).save(any());
|
||||
verify(rentDues, never()).saveAll(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void withdrawingANoticeRestoresTheExactPreEndingScheduleInTheSameTransaction() {
|
||||
LeaseRepository leases = mock(LeaseRepository.class);
|
||||
ApartmentOccupancyRepository occupancies = mock(ApartmentOccupancyRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
Lease lease = activeStandardLease();
|
||||
TenantNoticeId noticeId = new TenantNoticeId("notice-restore");
|
||||
lease.recordTenantNotice(new TenantNoticeCommand(noticeId, LocalDate.of(2026, 7, 19),
|
||||
LocalDate.of(2026, 8, 19), NoticeDeliveryMode.BAILIFF, null, false, false,
|
||||
LocalDate.of(2026, 7, 19)), new LeaseActionAudit("oidc:landlord-1", CLOCK.instant()));
|
||||
ApartmentOccupancy occupancy = ApartmentOccupancy.empty(LANDLORD_ID, APARTMENT_ID);
|
||||
occupancy.reserve(LEASE_ID, lease.occupancyPeriod());
|
||||
RentDue august = due(YearMonth.of(2026, 8));
|
||||
RentDue september = due(YearMonth.of(2026, 9));
|
||||
fr.nessar.lmnporganizer.rental.domain.rent.RentSchedule.reconcileEnd(
|
||||
List.of(august, september), lease.scheduledOccupancyPeriod(), LocalDate.of(2026, 8, 19));
|
||||
when(leases.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.of(lease));
|
||||
when(leases.save(lease)).thenReturn(lease);
|
||||
when(occupancies.find(LANDLORD_ID, APARTMENT_ID)).thenReturn(Optional.of(occupancy));
|
||||
when(rentDues.findAll(LANDLORD_ID, LEASE_ID)).thenReturn(List.of(august, september));
|
||||
LeaseEndingService service = service(leases, occupancies, rentDues, mock(LeaseDocumentStorage.class));
|
||||
|
||||
service.acceptWithdrawal(LANDLORD_ID, LEASE_ID, noticeId, "oidc:landlord-1",
|
||||
new LeaseEndingService.AdministrativeFact(PARIS, LocalDate.of(2026, 7, 19),
|
||||
"Le locataire reste"));
|
||||
|
||||
assertEquals(new BigDecimal("1000"), august.rent());
|
||||
assertEquals(new BigDecimal("100"), august.charges());
|
||||
assertEquals(new BigDecimal("1000"), september.rent());
|
||||
assertEquals(new BigDecimal("100"), september.charges());
|
||||
verify(rentDues).saveAll(List.of(august, september));
|
||||
verify(occupancies).save(occupancy);
|
||||
verify(leases).save(lease);
|
||||
}
|
||||
|
||||
@Test
|
||||
void evidenceIsStoredBeforeTheTransactionAndDiscardedWhenMetadataFails() {
|
||||
LeaseRepository leases = mock(LeaseRepository.class);
|
||||
ApartmentOccupancyRepository occupancies = mock(ApartmentOccupancyRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
LeaseDocumentStorage storage = mock(LeaseDocumentStorage.class);
|
||||
Lease lease = activeStandardLease();
|
||||
TenantNoticeId noticeId = new TenantNoticeId("notice-1");
|
||||
lease.recordTenantNotice(new TenantNoticeCommand(noticeId, LocalDate.of(2026, 7, 19),
|
||||
LocalDate.of(2026, 8, 19), NoticeDeliveryMode.BAILIFF, null, false, false,
|
||||
LocalDate.of(2026, 7, 19)), new LeaseActionAudit("oidc:landlord-1", CLOCK.instant()));
|
||||
when(storage.store(EVIDENCE)).thenReturn("blob-1");
|
||||
when(leases.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.of(lease));
|
||||
when(leases.save(lease)).thenThrow(new IllegalStateException("optimistic conflict"));
|
||||
LeaseEndingService service = service(leases, occupancies, rentDues, storage);
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> service.appendEvidence(
|
||||
LANDLORD_ID, LEASE_ID, noticeId, "oidc:landlord-1",
|
||||
new LeaseEndingService.EvidenceUpload(PARIS, "../congé.html", "text/html", EVIDENCE, null)));
|
||||
|
||||
verify(storage).store(EVIDENCE);
|
||||
verify(storage).discard("blob-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void stateReportsUnsupportedSharedLeaseAndOutstandingCorrection() {
|
||||
LeaseRepository leases = mock(LeaseRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
Lease shared = activeSharedStudentLease();
|
||||
RentDue due = due(YearMonth.of(2026, 8));
|
||||
due.allocate(new BigDecimal("100"));
|
||||
fr.nessar.lmnporganizer.rental.domain.rent.RentSchedule.reconcileEnd(
|
||||
List.of(due), shared.scheduledOccupancyPeriod(), LocalDate.of(2026, 7, 31));
|
||||
when(leases.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.of(shared));
|
||||
when(rentDues.findAll(LANDLORD_ID, LEASE_ID)).thenReturn(List.of(due));
|
||||
LeaseEndingService service = service(leases, mock(ApartmentOccupancyRepository.class), rentDues,
|
||||
mock(LeaseDocumentStorage.class));
|
||||
|
||||
LeaseEndingState state = service.state(LANDLORD_ID, LEASE_ID, PARIS);
|
||||
|
||||
assertTrue(!state.supported());
|
||||
assertTrue(state.unsupportedReason().isPresent());
|
||||
assertTrue(state.correctionRequired());
|
||||
}
|
||||
|
||||
@Test
|
||||
void stateRejectsDraftsButKeepsArchivedLeaseHistoryReadable() {
|
||||
LeaseRepository leases = mock(LeaseRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
RentalPeriod period = new RentalPeriod(LocalDate.of(2026, 1, 1), LocalDate.of(2030, 1, 1));
|
||||
RentTerms terms = new RentTerms(new BigDecimal("1000"), new BigDecimal("100"), new BigDecimal("1000"));
|
||||
Lease draft = Lease.draft(LEASE_ID, LANDLORD_ID, APARTMENT_ID, List.of(TENANT_ID), List.of(), period, terms);
|
||||
when(leases.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.of(draft));
|
||||
LeaseEndingService service = service(leases, mock(ApartmentOccupancyRepository.class), rentDues,
|
||||
mock(LeaseDocumentStorage.class));
|
||||
|
||||
assertThrows(LeaseRuleViolation.class, () -> service.state(LANDLORD_ID, LEASE_ID, PARIS));
|
||||
verify(rentDues, never()).findAll(any(), any());
|
||||
|
||||
Lease archived = activeStandardLease();
|
||||
archived.recordAgreedEarlyEnd(LocalDate.of(2026, 7, 18), "Fin convenue",
|
||||
new LeaseActionAudit("subject-1", CLOCK.instant()));
|
||||
archived.close(LocalDate.of(2026, 7, 18), true, LocalDate.of(2026, 7, 19),
|
||||
new LeaseActionAudit("subject-1", CLOCK.instant()));
|
||||
when(leases.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.of(archived));
|
||||
when(rentDues.findAll(LANDLORD_ID, LEASE_ID)).thenReturn(List.of());
|
||||
|
||||
assertEquals(LeaseEndPhase.ARCHIVED, service.state(LANDLORD_ID, LEASE_ID, PARIS).phase());
|
||||
}
|
||||
|
||||
private LeaseEndingService service(LeaseRepository leases, ApartmentOccupancyRepository occupancies,
|
||||
RentDueRepository rentDues, LeaseDocumentStorage storage) {
|
||||
return service(leases, occupancies, rentDues, storage, CLOCK);
|
||||
}
|
||||
|
||||
private LeaseEndingService service(LeaseRepository leases, ApartmentOccupancyRepository occupancies,
|
||||
RentDueRepository rentDues, LeaseDocumentStorage storage, Clock clock) {
|
||||
IdentifierGenerator identifiers = () -> "notice-generated";
|
||||
return new LeaseEndingService(leases, occupancies, rentDues, storage, new DirectTransactions(), identifiers,
|
||||
clock, 250L * 1024 * 1024);
|
||||
}
|
||||
|
||||
private Lease activeStandardLease() {
|
||||
return activeLease(LeaseType.STANDARD, List.of(TENANT_ID));
|
||||
}
|
||||
|
||||
private Lease activeSharedStudentLease() {
|
||||
return activeLease(LeaseType.STUDENT, List.of(TENANT_ID, new TenantId("tenant-2")));
|
||||
}
|
||||
|
||||
private Lease activeLease(LeaseType type, List<TenantId> tenantIds) {
|
||||
RentalPeriod requested = new RentalPeriod(LocalDate.of(2026, 1, 1), LocalDate.of(2030, 1, 1));
|
||||
RentTerms terms = new RentTerms(new BigDecimal("1000"), new BigDecimal("100"), new BigDecimal("1000"));
|
||||
Lease lease = Lease.draft(LEASE_ID, LANDLORD_ID, APARTMENT_ID, tenantIds, List.of(), requested, terms, type);
|
||||
lease.activate(new LeaseSnapshot(
|
||||
new LeaseSnapshot.Landlord(LANDLORD_ID, "Léa", "Martin", "lea@example.com", "+331", "1 rue A"),
|
||||
new LeaseSnapshot.Apartment(APARTMENT_ID, "Studio", "2 rue B"),
|
||||
tenantIds.stream().map(id -> new LeaseSnapshot.Tenant(id, "Camille", "Durand",
|
||||
id.value() + "@example.com", "", "3 rue C")).toList(),
|
||||
List.of(), lease.period(), terms, type));
|
||||
return lease;
|
||||
}
|
||||
|
||||
private RentDue due(YearMonth month) {
|
||||
return RentDue.create(new RentDueId("due-" + month), LANDLORD_ID, LEASE_ID, month, month.atDay(5),
|
||||
new BigDecimal("1000"), new BigDecimal("100"));
|
||||
}
|
||||
|
||||
private static final class DirectTransactions implements TransactionRunner {
|
||||
@Override
|
||||
public <T> T run(Supplier<T> operation) {
|
||||
return operation.get();
|
||||
}
|
||||
}
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rent;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
|
||||
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSnapshot;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseStatus;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.RentTerms;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.RentalPeriod;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDue;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueSettlement;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPayment;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversalRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentStatus;
|
||||
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.YearMonth;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class PostArchiveRentSettlementTest {
|
||||
private static final LandlordId LANDLORD_ID = new LandlordId("landlord-1");
|
||||
private static final LeaseId LEASE_ID = new LeaseId("lease-1");
|
||||
private static final RentDueId FIRST_DUE_ID = new RentDueId("due-1");
|
||||
private static final RentDueId SECOND_DUE_ID = new RentDueId("due-2");
|
||||
private static final RentPaymentId PAYMENT_ID = new RentPaymentId("payment-1");
|
||||
private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-07-19T12:00:00Z"), ZoneOffset.UTC);
|
||||
|
||||
@Test
|
||||
void existingDuesCanStillBeRecordedAllocatedAndReversedAfterLeaseArchival() {
|
||||
Lease archivedLease = archivedLease();
|
||||
RentDue firstDue = due(FIRST_DUE_ID, "900", "50");
|
||||
RentDue secondDue = due(SECOND_DUE_ID, "100", "0");
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
RentPaymentReversalRepository reversals = mock(RentPaymentReversalRepository.class);
|
||||
AtomicReference<RentPayment> storedPayment = new AtomicReference<>();
|
||||
when(payments.find(LANDLORD_ID, PAYMENT_ID))
|
||||
.thenAnswer(ignored -> Optional.ofNullable(storedPayment.get()));
|
||||
when(payments.save(any())).thenAnswer(invocation -> {
|
||||
RentPayment payment = invocation.getArgument(0);
|
||||
storedPayment.set(payment);
|
||||
return payment;
|
||||
});
|
||||
when(rentDues.find(LANDLORD_ID, FIRST_DUE_ID)).thenReturn(Optional.of(firstDue));
|
||||
when(rentDues.find(LANDLORD_ID, SECOND_DUE_ID)).thenReturn(Optional.of(secondDue));
|
||||
when(rentDues.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(rentDues.saveAll(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(reversals.findByPayment(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.empty());
|
||||
when(reversals.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
TransactionRunner transactions = new TransactionRunner() {
|
||||
@Override
|
||||
public <T> T run(Supplier<T> operation) {
|
||||
return operation.get();
|
||||
}
|
||||
};
|
||||
|
||||
RentPayment payment = new RecordRentPaymentService(payments, rentDues, transactions, CLOCK).record(
|
||||
LANDLORD_ID, PAYMENT_ID.value(), FIRST_DUE_ID, firstDue.version(),
|
||||
LocalDate.of(2026, 7, 18), ZoneId.of("Europe/Paris"), new BigDecimal("1050"));
|
||||
payment = new AllocateRentPaymentService(payments, rentDues, transactions, CLOCK).allocate(
|
||||
LANDLORD_ID, PAYMENT_ID, "allocation-2", payment.version(), SECOND_DUE_ID, secondDue.version(),
|
||||
new BigDecimal("100"));
|
||||
new ReverseRentPaymentService(payments, reversals, rentDues, transactions, () -> "reversal-1", CLOCK)
|
||||
.reverse(LANDLORD_ID, PAYMENT_ID, payment.version(), "Correction bancaire",
|
||||
ZoneId.of("Europe/Paris"));
|
||||
|
||||
assertEquals(LeaseStatus.ARCHIVED, archivedLease.status());
|
||||
assertEquals(RentPaymentStatus.REVERSED, payment.status());
|
||||
assertEquals(RentDueSettlement.UNPAID, firstDue.settlement());
|
||||
assertEquals(RentDueSettlement.UNPAID, secondDue.settlement());
|
||||
}
|
||||
|
||||
private static RentDue due(RentDueId id, String rent, String charges) {
|
||||
return RentDue.reconstitute(id, LANDLORD_ID, LEASE_ID, YearMonth.of(2026, 7),
|
||||
LocalDate.of(2026, 7, 5), new BigDecimal(rent), new BigDecimal(charges), BigDecimal.ZERO, 0);
|
||||
}
|
||||
|
||||
private static Lease archivedLease() {
|
||||
ApartmentId apartmentId = new ApartmentId("apartment-1");
|
||||
TenantId tenantId = new TenantId("tenant-1");
|
||||
RentalPeriod period = new RentalPeriod(LocalDate.of(2026, 1, 1), LocalDate.of(2026, 12, 31));
|
||||
RentTerms terms = new RentTerms(new BigDecimal("900"), new BigDecimal("50"), new BigDecimal("900"));
|
||||
LeaseSnapshot snapshot = new LeaseSnapshot(
|
||||
new LeaseSnapshot.Landlord(LANDLORD_ID, "Léa", "Martin", "lea@example.com", "+331", "1 rue A"),
|
||||
new LeaseSnapshot.Apartment(apartmentId, "Studio", "2 rue B"),
|
||||
List.of(new LeaseSnapshot.Tenant(tenantId, "Camille", "Durand", "camille@example.com", "",
|
||||
"3 rue C")), List.of(), period, terms);
|
||||
return Lease.reconstitute(LEASE_ID, LANDLORD_ID, apartmentId, List.of(tenantId), List.of(), period, terms,
|
||||
LeaseStatus.ARCHIVED, snapshot, 0);
|
||||
}
|
||||
}
|
||||
+16
@@ -124,6 +124,22 @@ class RefreshRentScheduleServiceTest {
|
||||
verify(rentDues, never()).saveAll(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshResponseHidesZeroAmountLeaseEndTombstones() {
|
||||
LeaseRepository leases = mock(LeaseRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
RentDue tombstone = RentDue.reconstitute(new RentDueId("hidden"), LANDLORD_ID, LEASE_ID,
|
||||
YearMonth.of(2027, 1), LocalDate.of(2027, 1, 12), BigDecimal.ZERO, BigDecimal.ZERO,
|
||||
BigDecimal.ZERO, new BigDecimal("1200"), new BigDecimal("120"), 1);
|
||||
when(leases.findAll(LANDLORD_ID)).thenReturn(List.of());
|
||||
when(rentDues.findAll(LANDLORD_ID)).thenReturn(List.of(tombstone));
|
||||
|
||||
List<RentDue> result = new RefreshRentScheduleService(leases, rentDues, new RecordingTransactions(),
|
||||
() -> "unused", clock("2026-12-01T10:00:00Z")).refresh(LANDLORD_ID);
|
||||
|
||||
assertEquals(List.of(), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void catchesUpMissingYearsThroughTheFirstFutureBlockWithCurrentTerms() {
|
||||
LeaseRepository leases = mock(LeaseRepository.class);
|
||||
|
||||
+54
-3
@@ -34,6 +34,7 @@ import java.math.BigDecimal;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.MonthDay;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.YearMonth;
|
||||
@@ -47,6 +48,7 @@ import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@@ -55,7 +57,7 @@ class RentRevisionServiceTest {
|
||||
private static final LeaseId LEASE_ID = new LeaseId("lease-1");
|
||||
|
||||
@Test
|
||||
void previewsOfficialCeilingsForTheLandlordCivilDate() {
|
||||
void previewsOfficialCeilingsAtTheFirstOccurrenceStrictlyAfterLeaseStart() {
|
||||
Lease lease = activeLease("G", "75");
|
||||
IrlIndexProvider provider = mock(IrlIndexProvider.class);
|
||||
IrlIndexProvider.Period previous = new IrlIndexProvider.Period(2026, 2);
|
||||
@@ -100,6 +102,23 @@ class RentRevisionServiceTest {
|
||||
verify(provider).indexes("001515333", previous, current);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reappliesALeapDayScheduleEveryYearInsteadOfLosingFebruary29() {
|
||||
Lease lease = activeLease("B", "75", "001515333", MonthDay.of(2, 29));
|
||||
IrlIndexProvider provider = mock(IrlIndexProvider.class);
|
||||
when(provider.indexes(any(), any(), any())).thenReturn(new IrlIndexProvider.Indexes(
|
||||
new BigDecimal("150.00"), new BigDecimal("153.00"), IrlIndexProvider.Source.INSEE));
|
||||
Clock clock = Clock.fixed(Instant.parse("2032-02-28T10:00:00Z"), ZoneOffset.UTC);
|
||||
|
||||
RentRevisionService.Preview preview = new RentRevisionService(repository(lease), provider, clock)
|
||||
.preview(LANDLORD_ID, LEASE_ID,
|
||||
RentRevisionService.PreviewRequest.automatic(ZoneId.of("Europe/Paris")));
|
||||
|
||||
assertEquals(LocalDate.of(2032, 2, 29), preview.annualRevisionDate());
|
||||
verify(provider).indexes("001515333", new IrlIndexProvider.Period(2031, 2),
|
||||
new IrlIndexProvider.Period(2032, 2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void advancesPastAnAppliedAnniversaryAndKeepsTheSeriesFrozenInTheLease() {
|
||||
Lease lease = activeLease("B", "75", "010760507");
|
||||
@@ -208,6 +227,33 @@ class RentRevisionServiceTest {
|
||||
assertEquals(List.of(), rentDueRepository.values);
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusesToRenderOrStoreAProposalWhoseEffectiveDateIsAfterTheLeaseEnd() {
|
||||
Lease lease = activeLease("B", "75");
|
||||
lease.recordAgreedEarlyEnd(LocalDate.of(2027, 9, 1), "Fin convenue",
|
||||
new fr.nessar.lmnporganizer.rental.domain.lease.LeaseActionAudit(
|
||||
"subject-1", Instant.parse("2027-08-01T10:00:00Z")));
|
||||
IrlIndexProvider provider = mock(IrlIndexProvider.class);
|
||||
when(provider.indexes("001515333", new IrlIndexProvider.Period(2026, 2),
|
||||
new IrlIndexProvider.Period(2027, 2))).thenReturn(new IrlIndexProvider.Indexes(
|
||||
new BigDecimal("143.46"), new BigDecimal("146.68"), IrlIndexProvider.Source.INSEE));
|
||||
RentalDocumentRenderer renderer = mock(RentalDocumentRenderer.class);
|
||||
LeaseDocumentStorage storage = mock(LeaseDocumentStorage.class);
|
||||
InMemoryRevisionRepository revisionRepository = new InMemoryRevisionRepository();
|
||||
RentRevisionService service = new RentRevisionService(repository(lease), provider, revisionRepository,
|
||||
new InMemoryRentDueRepository(List.of()), renderer, storage, directTransactions(),
|
||||
() -> "revision-1", Clock.fixed(Instant.parse("2027-09-02T10:00:00Z"), ZoneOffset.UTC), 1024);
|
||||
|
||||
assertThrows(fr.nessar.lmnporganizer.rental.domain.lease.LeaseRuleViolation.class,
|
||||
() -> service.propose(LANDLORD_ID, LEASE_ID, new RentRevisionService.ProposalRequest(
|
||||
RentRevisionService.PreviewRequest.automatic(ZoneId.of("Europe/Paris")),
|
||||
LocalDate.of(2027, 9, 2), new BigDecimal("915"), new BigDecimal("51"), Set.of()),
|
||||
"subject-1"));
|
||||
|
||||
verifyNoInteractions(renderer, storage);
|
||||
assertEquals(List.of(), revisionRepository.values);
|
||||
}
|
||||
|
||||
@Test
|
||||
void proposesFromTheFinancialTermsEffectiveOnTheRequestDateAfterAnAmendment() {
|
||||
Lease lease = leaseWithFinancialAmendmentAfterAnniversary();
|
||||
@@ -346,13 +392,18 @@ class RentRevisionServiceTest {
|
||||
}
|
||||
|
||||
private static Lease activeLease(String energyRating, String departmentCode, String seriesId) {
|
||||
return activeLease(energyRating, departmentCode, seriesId, MonthDay.of(9, 1));
|
||||
}
|
||||
|
||||
private static Lease activeLease(String energyRating, String departmentCode, String seriesId,
|
||||
MonthDay annualRevisionDay) {
|
||||
ApartmentId apartmentId = new ApartmentId("apartment-1");
|
||||
TenantId tenantId = new TenantId("tenant-1");
|
||||
RentalPeriod period = LeaseType.STANDARD.initialPeriod(LocalDate.of(2026, 9, 1));
|
||||
RentTerms terms = new RentTerms(new BigDecimal("900.00"), new BigDecimal("50.00"),
|
||||
new BigDecimal("900.00"), 5, ChargeMethod.FLAT_RATE, true);
|
||||
LeaseLegalTerms legalTerms = new LeaseLegalTerms(null, null,
|
||||
new LeaseLegalTerms.RentRevisionTerms(true, LocalDate.of(2027, 9, 1), 2, 2026, seriesId),
|
||||
new LeaseLegalTerms.RentRevisionTerms(true, annualRevisionDay, 2, 2026, seriesId),
|
||||
null, false, false, false, false, "");
|
||||
Lease lease = Lease.draft(LEASE_ID, LANDLORD_ID, apartmentId, List.of(tenantId), List.of(), period, terms,
|
||||
LeaseType.STANDARD, List.of(), legalTerms);
|
||||
@@ -388,7 +439,7 @@ class RentRevisionServiceTest {
|
||||
RentTerms terms = new RentTerms(new BigDecimal("900"), new BigDecimal("50"), new BigDecimal("900"),
|
||||
5, ChargeMethod.FLAT_RATE, true);
|
||||
LeaseLegalTerms legalTerms = new LeaseLegalTerms(null, null,
|
||||
new LeaseLegalTerms.RentRevisionTerms(true, LocalDate.of(2027, 3, 1), 2, 2026, "001515333"),
|
||||
new LeaseLegalTerms.RentRevisionTerms(true, MonthDay.of(3, 1), 2, 2026, "001515333"),
|
||||
null, false, false, false, false, "");
|
||||
Lease lease = Lease.draft(LEASE_ID, LANDLORD_ID, apartmentId, List.of(tenantId), List.of(), period, terms,
|
||||
LeaseType.STANDARD, List.of(), legalTerms);
|
||||
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rent;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
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.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSnapshot;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.RentTerms;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.RentalPeriod;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentRevision;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentRevisionId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentSchedule;
|
||||
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class RentTermsTimelineTest {
|
||||
private static final LandlordId LANDLORD_ID = new LandlordId("landlord-1");
|
||||
private static final LeaseId LEASE_ID = new LeaseId("lease-1");
|
||||
private static final LocalDate END_ON = LocalDate.of(2026, 8, 19);
|
||||
|
||||
@Test
|
||||
void finalMonthUsesOnlyAmendmentSegmentsOnOrBeforeTheLeaseEnd() {
|
||||
Lease lease = activeLease();
|
||||
amendAmounts(lease, "before-end", LocalDate.of(2026, 8, 10), "1200", "120");
|
||||
amendAmounts(lease, "after-end", LocalDate.of(2026, 8, 25), "1400", "140");
|
||||
lease.recordAgreedEarlyEnd(END_ON, "Fin convenue",
|
||||
new LeaseActionAudit("subject-1", Instant.parse("2026-08-01T00:00:00Z")));
|
||||
|
||||
RentSchedule.EndAmounts amounts = RentTermsTimeline.finalMonthAmounts(
|
||||
lease, List.of(), lease.scheduledOccupancyPeriod(), END_ON);
|
||||
|
||||
assertEquals(new BigDecimal("677.42"), amounts.rent());
|
||||
assertEquals(new BigDecimal("67.74"), amounts.charges());
|
||||
}
|
||||
|
||||
@Test
|
||||
void finalMonthUsesOnlyAppliedIrlSegmentsOnOrBeforeTheLeaseEnd() {
|
||||
Lease lease = activeLease();
|
||||
List<RentRevision> revisions = List.of(
|
||||
appliedRevision("before-end", LocalDate.of(2026, 8, 10), "1000", "1100", "100", "110"),
|
||||
appliedRevision("after-end", LocalDate.of(2026, 8, 25), "1100", "1200", "110", "120"));
|
||||
lease.recordAgreedEarlyEnd(END_ON, "Fin convenue",
|
||||
new LeaseActionAudit("subject-1", Instant.parse("2026-08-01T00:00:00Z")));
|
||||
|
||||
RentSchedule.EndAmounts amounts = RentTermsTimeline.finalMonthAmounts(
|
||||
lease, revisions, lease.scheduledOccupancyPeriod(), END_ON);
|
||||
|
||||
assertEquals(new BigDecimal("645.16"), amounts.rent());
|
||||
assertEquals(new BigDecimal("64.52"), amounts.charges());
|
||||
}
|
||||
|
||||
@Test
|
||||
void financialAmendmentSupersedesAnEarlierIrlForTheRemainingDailySegments() {
|
||||
Lease lease = activeLease();
|
||||
RentRevision revision = appliedRevision(
|
||||
"irl", LocalDate.of(2026, 8, 8), "1000", "1100", "100", "110");
|
||||
amendAmounts(lease, "amendment", LocalDate.of(2026, 8, 15), "1200", "120");
|
||||
lease.recordAgreedEarlyEnd(END_ON, "Fin convenue",
|
||||
new LeaseActionAudit("subject-1", Instant.parse("2026-08-01T00:00:00Z")));
|
||||
|
||||
RentSchedule.EndAmounts amounts = RentTermsTimeline.finalMonthAmounts(
|
||||
lease, List.of(revision), lease.scheduledOccupancyPeriod(), END_ON);
|
||||
|
||||
assertEquals(new BigDecimal("667.74"), amounts.rent());
|
||||
assertEquals(new BigDecimal("66.77"), amounts.charges());
|
||||
}
|
||||
|
||||
private static void amendAmounts(Lease lease, String id, LocalDate effectiveOn,
|
||||
String rent, String charges) {
|
||||
LeaseContractVersion current = lease.contractVersion();
|
||||
LeaseContractVersion after = new LeaseContractVersion(current.landlord(), current.apartment(),
|
||||
current.tenants(), current.guarantors(), current.period(),
|
||||
new RentTerms(new BigDecimal(rent), new BigDecimal(charges), current.terms().securityDeposit()),
|
||||
current.leaseType(), current.specialClauses(), current.legalTerms());
|
||||
lease.amend(new LeaseAmendmentId(id), effectiveOn, after);
|
||||
}
|
||||
|
||||
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));
|
||||
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(
|
||||
new LeaseSnapshot.Landlord(LANDLORD_ID, "Léa", "Dubois", "lea@example.com", "+331", "1 rue A"),
|
||||
new LeaseSnapshot.Apartment(apartmentId, "Studio", "2 rue B"),
|
||||
List.of(new LeaseSnapshot.Tenant(tenantId, "Camille", "Martin", "camille@example.com", "",
|
||||
"3 rue C")), List.of(), period, terms));
|
||||
return lease;
|
||||
}
|
||||
|
||||
private static RentRevision appliedRevision(String id, LocalDate effectiveOn, String previousRent,
|
||||
String chosenRent, String previousCharges,
|
||||
String chosenCharges) {
|
||||
Instant now = effectiveOn.atStartOfDay(ZoneOffset.UTC).toInstant();
|
||||
RentRevision proposed = new RentRevision(new RentRevisionId(id), LANDLORD_ID, LEASE_ID,
|
||||
new RentRevision.Window(effectiveOn, effectiveOn.plusYears(1).minusDays(1)),
|
||||
new RentRevision.IndexReference("001515333", false, false, RentRevision.IndexSource.INSEE,
|
||||
false, 2, 2025, 2026, new BigDecimal("140"), new BigDecimal("145")),
|
||||
new RentRevision.Amounts(new BigDecimal(previousRent), new BigDecimal(chosenRent),
|
||||
new BigDecimal(chosenRent), new BigDecimal(previousCharges), new BigDecimal(chosenCharges),
|
||||
new BigDecimal(chosenCharges)),
|
||||
new RentRevision.Request(effectiveOn, effectiveOn, "letter", "a".repeat(64), "subject-1", now),
|
||||
List.of(), null, -1);
|
||||
return proposed.apply(new RentRevision.Application(effectiveOn, RentRevision.DeliveryMethod.POSTAL_LETTER,
|
||||
effectiveOn, null, "subject-1", now));
|
||||
}
|
||||
}
|
||||
+34
@@ -232,6 +232,40 @@ class RentalDocumentGenerationServicesTest {
|
||||
verify(leases).find(LANDLORD_ID, LEASE_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rentReceiptRefusesADueThatStillRequiresAnAllocationCorrection() {
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
LeaseRepository leases = mock(LeaseRepository.class);
|
||||
RentalDocumentRenderer renderer = mock(RentalDocumentRenderer.class);
|
||||
RentDue due = RentDue.reconstitute(DUE_ID, LANDLORD_ID, LEASE_ID, YearMonth.of(2026, 8),
|
||||
LocalDate.of(2026, 8, 5), new BigDecimal("450.00"), new BigDecimal("25.00"),
|
||||
new BigDecimal("950.00"), new BigDecimal("900.00"), new BigDecimal("50.00"), 2);
|
||||
when(rentDues.find(LANDLORD_ID, DUE_ID)).thenReturn(Optional.of(due));
|
||||
|
||||
GenerateRentReceiptService service = new GenerateRentReceiptService(
|
||||
rentDues, leases, renderer, Clock.systemUTC());
|
||||
|
||||
assertThrows(RentRuleViolation.class, () -> service.generate(LANDLORD_ID, DUE_ID));
|
||||
verifyNoInteractions(leases, renderer);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rentReceiptRefusesAHiddenZeroAmountLeaseEndDue() {
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
LeaseRepository leases = mock(LeaseRepository.class);
|
||||
RentalDocumentRenderer renderer = mock(RentalDocumentRenderer.class);
|
||||
RentDue due = RentDue.reconstitute(DUE_ID, LANDLORD_ID, LEASE_ID, YearMonth.of(2026, 9),
|
||||
LocalDate.of(2026, 9, 5), BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO,
|
||||
new BigDecimal("900.00"), new BigDecimal("50.00"), 2);
|
||||
when(rentDues.find(LANDLORD_ID, DUE_ID)).thenReturn(Optional.of(due));
|
||||
|
||||
GenerateRentReceiptService service = new GenerateRentReceiptService(
|
||||
rentDues, leases, renderer, Clock.systemUTC());
|
||||
|
||||
assertThrows(RentRuleViolation.class, () -> service.generate(LANDLORD_ID, DUE_ID));
|
||||
verifyNoInteractions(leases, renderer);
|
||||
}
|
||||
|
||||
private static Lease activeAmendedLease() {
|
||||
Lease lease = Lease.draft(LEASE_ID, LANDLORD_ID, APARTMENT_ID, List.of(TENANT_ID), List.of(), PERIOD,
|
||||
INITIAL_TERMS);
|
||||
|
||||
+30
-4
@@ -25,6 +25,7 @@ import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.YearMonth;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.ZoneId;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Supplier;
|
||||
@@ -67,7 +68,8 @@ class ReverseRentPaymentServiceTest {
|
||||
when(reversals.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
RentPaymentReversal reversal = new ReverseRentPaymentService(payments, reversals, rentDues, transactions,
|
||||
() -> "reversal-1", CLOCK).reverse(LANDLORD_ID, PAYMENT_ID, 5, "Erreur bancaire");
|
||||
() -> "reversal-1", CLOCK).reverse(LANDLORD_ID, PAYMENT_ID, 5, "Erreur bancaire",
|
||||
ZoneId.of("Europe/Paris"));
|
||||
|
||||
assertEquals(RentPaymentStatus.REVERSED, payment.status());
|
||||
assertEquals(new BigDecimal("60"), payment.refundedAmount());
|
||||
@@ -87,9 +89,10 @@ class ReverseRentPaymentServiceTest {
|
||||
ReverseRentPaymentService service = new ReverseRentPaymentService(payments, reversals, rentDues,
|
||||
new RecordingTransactions(), () -> "unused", CLOCK);
|
||||
|
||||
assertEquals(existing, service.reverse(LANDLORD_ID, PAYMENT_ID, 999, " Erreur bancaire "));
|
||||
assertEquals(existing, service.reverse(LANDLORD_ID, PAYMENT_ID, 999, " Erreur bancaire ",
|
||||
ZoneId.of("Europe/Paris")));
|
||||
assertThrows(RentPaymentConflict.class,
|
||||
() -> service.reverse(LANDLORD_ID, PAYMENT_ID, 0, "Autre raison"));
|
||||
() -> service.reverse(LANDLORD_ID, PAYMENT_ID, 0, "Autre raison", ZoneId.of("Europe/Paris")));
|
||||
|
||||
verifyNoInteractions(payments, rentDues);
|
||||
verify(reversals, never()).save(any());
|
||||
@@ -109,13 +112,36 @@ class ReverseRentPaymentServiceTest {
|
||||
new RecordingTransactions(), () -> "reversal-1", CLOCK);
|
||||
|
||||
assertThrows(ConcurrentChange.class,
|
||||
() -> service.reverse(LANDLORD_ID, PAYMENT_ID, 3, "Erreur bancaire"));
|
||||
() -> service.reverse(LANDLORD_ID, PAYMENT_ID, 3, "Erreur bancaire",
|
||||
ZoneId.of("Europe/Paris")));
|
||||
|
||||
verifyNoInteractions(rentDues);
|
||||
verify(payments, never()).save(any());
|
||||
verify(reversals, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void reversalDateUsesTheUsersCalendarZone() {
|
||||
Clock boundary = Clock.fixed(Instant.parse("2026-07-19T06:30:00Z"), ZoneOffset.UTC);
|
||||
RentPaymentRepository payments = mock(RentPaymentRepository.class);
|
||||
RentPaymentReversalRepository reversals = mock(RentPaymentReversalRepository.class);
|
||||
RentDueRepository rentDues = mock(RentDueRepository.class);
|
||||
RentPayment payment = RentPayment.reconstitute(PAYMENT_ID, LANDLORD_ID, LEASE_ID,
|
||||
LocalDate.of(2026, 7, 18), new BigDecimal("1000"), RentPaymentStatus.CONFIRMED,
|
||||
List.of(), BigDecimal.ZERO, LocalDate.of(2026, 7, 18), 4);
|
||||
when(reversals.findByPayment(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.empty());
|
||||
when(payments.find(LANDLORD_ID, PAYMENT_ID)).thenReturn(Optional.of(payment));
|
||||
when(payments.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(rentDues.saveAll(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(reversals.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
RentPaymentReversal reversed = new ReverseRentPaymentService(payments, reversals, rentDues,
|
||||
new RecordingTransactions(), () -> "reversal-1", boundary).reverse(
|
||||
LANDLORD_ID, PAYMENT_ID, 4, "Erreur bancaire", ZoneId.of("America/Los_Angeles"));
|
||||
|
||||
assertEquals(LocalDate.of(2026, 7, 18), reversed.reversedAt());
|
||||
}
|
||||
|
||||
private static final class RecordingTransactions implements TransactionRunner {
|
||||
private int calls;
|
||||
|
||||
|
||||
+23
-1
@@ -5,6 +5,7 @@ import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
@@ -25,7 +26,28 @@ class ApartmentOccupancyTest {
|
||||
assertThrows(LeaseRuleViolation.class, () -> occupancy.reviseReservation(new LeaseId("lease-1"),
|
||||
new RentalPeriod(LocalDate.of(2026, 1, 1), LocalDate.of(2026, 2, 1))));
|
||||
occupancy.reviseReservation(new LeaseId("lease-1"), period(1, 15));
|
||||
assertEquals(LocalDate.of(2026, 1, 15), occupancy.reservations().getFirst().period().endDate());
|
||||
assertEquals(LocalDate.of(2026, 1, 15),
|
||||
occupancy.reservations().getFirst().period().endOn().orElseThrow());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anOpenReservationBlocksSuccessorsUntilItIsExplicitlyBounded() {
|
||||
ApartmentOccupancy occupancy = ApartmentOccupancy.empty(new LandlordId("landlord-1"),
|
||||
new ApartmentId("apartment-1"));
|
||||
LeaseId current = new LeaseId("lease-open");
|
||||
LocalDate startOn = LocalDate.of(2026, 1, 15);
|
||||
occupancy.reserve(current, OccupancyPeriod.open(startOn));
|
||||
|
||||
assertEquals(Optional.empty(), occupancy.reservations().getFirst().period().endOn());
|
||||
assertThrows(LeaseRuleViolation.class, () -> occupancy.reserve(new LeaseId("successor"),
|
||||
OccupancyPeriod.closed(LocalDate.of(2027, 1, 1), LocalDate.of(2027, 12, 31))));
|
||||
|
||||
occupancy.reviseReservation(current,
|
||||
OccupancyPeriod.closed(startOn, LocalDate.of(2026, 8, 31)));
|
||||
occupancy.reserve(new LeaseId("successor"),
|
||||
OccupancyPeriod.closed(LocalDate.of(2026, 9, 1), LocalDate.of(2027, 8, 31)));
|
||||
|
||||
assertEquals(2, occupancy.reservations().size());
|
||||
}
|
||||
|
||||
private RentalPeriod period(int startDay, int endDay) {
|
||||
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.lease;
|
||||
|
||||
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 org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class LeaseEndingTest {
|
||||
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 LocalDate START_ON = LocalDate.of(2026, 1, 15);
|
||||
private static final LocalDate TODAY = LocalDate.of(2026, 7, 19);
|
||||
private static final RentTerms TERMS = new RentTerms(
|
||||
new BigDecimal("1000"), new BigDecimal("100"), new BigDecimal("1000"));
|
||||
private static final LeaseActionAudit AUDIT = new LeaseActionAudit(
|
||||
"oidc:landlord-1", Instant.parse("2026-07-19T12:30:00Z"));
|
||||
|
||||
@Test
|
||||
void standardLeaseHasNoRealEndUntilNoticeFreezesTheApplicableRule() {
|
||||
Lease lease = activeLease(LeaseType.STANDARD, List.of(TENANT_ID));
|
||||
|
||||
TenantNotice notice = lease.recordTenantNotice(notice("notice-1", LocalDate.of(2026, 7, 10),
|
||||
LocalDate.of(2026, 8, 1), NoticeDeliveryMode.REGISTERED_LETTER, null, true, false), AUDIT);
|
||||
|
||||
assertEquals(1, notice.noticeMonths());
|
||||
assertEquals("2026-07", notice.ruleVersion());
|
||||
assertEquals(LocalDate.of(2026, 8, 10), notice.minimumEndOn());
|
||||
assertEquals(LocalDate.of(2026, 8, 10), lease.contractEndOn().orElseThrow());
|
||||
assertEquals(LeaseEndPhase.NOTICE_PERIOD, lease.endPhaseOn(TODAY));
|
||||
assertTrue(lease.endingSupported());
|
||||
assertTrue(lease.fixedEndOn().isEmpty());
|
||||
assertEquals(AUDIT, notice.audit());
|
||||
}
|
||||
|
||||
@Test
|
||||
void noticeHonorsTheRequestedLaterDepartureAndValidatesCivilDateConfirmations() {
|
||||
Lease lease = activeLease(LeaseType.STANDARD, List.of(TENANT_ID));
|
||||
|
||||
assertThrows(LeaseRuleViolation.class, () -> lease.recordTenantNotice(notice(
|
||||
"late-unconfirmed", TODAY.minusDays(1), TODAY.plusMonths(1), NoticeDeliveryMode.BAILIFF,
|
||||
null, false, false), AUDIT));
|
||||
assertThrows(LeaseRuleViolation.class, () -> lease.recordTenantNotice(notice(
|
||||
"future", TODAY.plusDays(1), TODAY.plusMonths(1), NoticeDeliveryMode.BAILIFF,
|
||||
null, true, false), AUDIT));
|
||||
assertThrows(LeaseRuleViolation.class, () -> lease.recordTenantNotice(notice(
|
||||
"other-unconfirmed", TODAY, TODAY.plusMonths(1), NoticeDeliveryMode.OTHER,
|
||||
"Courriel", false, false), AUDIT));
|
||||
assertThrows(LeaseRuleViolation.class, () -> lease.recordTenantNotice(notice(
|
||||
"other-unnamed", TODAY, TODAY.plusMonths(1), NoticeDeliveryMode.OTHER,
|
||||
" ", false, true), AUDIT));
|
||||
assertThrows(LeaseRuleViolation.class, () -> lease.recordTenantNotice(notice(
|
||||
"other-too-long", TODAY, TODAY.plusMonths(1), NoticeDeliveryMode.OTHER,
|
||||
"x".repeat(201), false, true), AUDIT));
|
||||
|
||||
TenantNotice notice = lease.recordTenantNotice(notice(
|
||||
"notice-1", TODAY, LocalDate.of(2026, 9, 3), NoticeDeliveryMode.OTHER,
|
||||
"Courriel confirmé par le bailleur", false, true), AUDIT);
|
||||
|
||||
assertEquals(LocalDate.of(2026, 9, 3), notice.effectiveEndOn());
|
||||
assertEquals("Courriel confirmé par le bailleur", notice.otherDeliveryMode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void studentFixedEndCapsAnEarlierNoticeAndAllEndingCommandsRejectSharedLeases() {
|
||||
Lease student = activeLease(LeaseType.STUDENT, List.of(TENANT_ID));
|
||||
LocalDate fixedEnd = START_ON.plusMonths(9).minusDays(1);
|
||||
TenantNotice notice = student.recordTenantNotice(notice(
|
||||
"notice-1", TODAY, TODAY.plusMonths(6), NoticeDeliveryMode.HAND_DELIVERY,
|
||||
null, false, false), AUDIT);
|
||||
|
||||
assertEquals(fixedEnd, student.fixedEndOn().orElseThrow());
|
||||
assertEquals(fixedEnd, student.contractEndOn().orElseThrow());
|
||||
assertTrue(notice.effectiveEndOn().isAfter(fixedEnd));
|
||||
|
||||
Lease shared = activeLease(LeaseType.STUDENT, List.of(TENANT_ID, new TenantId("tenant-2")));
|
||||
assertFalse(shared.endingSupported());
|
||||
assertEquals(fixedEnd, shared.contractEndOn().orElseThrow());
|
||||
assertThrows(LeaseRuleViolation.class, () -> shared.recordTenantNotice(notice(
|
||||
"unsupported", TODAY, TODAY.plusMonths(1), NoticeDeliveryMode.BAILIFF,
|
||||
null, false, false), AUDIT));
|
||||
assertThrows(LeaseRuleViolation.class,
|
||||
() -> shared.recordAgreedEarlyEnd(TODAY.plusMonths(1), "Accord", AUDIT));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aStudentNoticeCanAdvanceTheFixedEnd() {
|
||||
Lease student = activeLease(LeaseType.STUDENT, List.of(TENANT_ID));
|
||||
|
||||
student.recordTenantNotice(notice("notice-early", TODAY, TODAY.plusMonths(1),
|
||||
NoticeDeliveryMode.BAILIFF, null, false, false), AUDIT);
|
||||
|
||||
assertEquals(TODAY.plusMonths(1), student.contractEndOn().orElseThrow());
|
||||
assertTrue(student.contractEndOn().orElseThrow().isBefore(student.fixedEndOn().orElseThrow()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void amendmentCannotChangeTheEffectiveEndOrCreateASharedLeaseWhileAnEndingFactIsActive() {
|
||||
Lease student = activeLease(LeaseType.STUDENT, List.of(TENANT_ID));
|
||||
student.recordTenantNotice(notice("notice-1", TODAY, TODAY.plusMonths(6),
|
||||
NoticeDeliveryMode.BAILIFF, null, false, false), AUDIT);
|
||||
LeaseContractVersion current = student.contractVersion();
|
||||
|
||||
LeaseContractVersion shortened = new LeaseContractVersion(current.landlord(), current.apartment(),
|
||||
current.tenants(), current.guarantors(),
|
||||
new RentalPeriod(current.period().startDate(), TODAY.plusDays(10)), current.terms(),
|
||||
current.leaseType(), current.specialClauses(), current.legalTerms());
|
||||
assertThrows(LeaseRuleViolation.class, () -> student.amend(new LeaseAmendmentId("shorter"), TODAY,
|
||||
shortened));
|
||||
|
||||
LeaseSnapshot.Tenant secondTenant = new LeaseSnapshot.Tenant(new TenantId("tenant-2"), "Bob", "Martin",
|
||||
"bob@example.com", "", "1 rue A");
|
||||
LeaseContractVersion shared = new LeaseContractVersion(current.landlord(), current.apartment(),
|
||||
List.of(current.tenants().getFirst(), secondTenant), current.guarantors(), current.period(),
|
||||
current.terms(), current.leaseType(), current.specialClauses(), current.legalTerms());
|
||||
assertThrows(LeaseRuleViolation.class, () -> student.amend(new LeaseAmendmentId("shared"), TODAY, shared));
|
||||
|
||||
assertEquals(0, student.amendments().size());
|
||||
assertEquals(current, student.contractVersion());
|
||||
}
|
||||
|
||||
@Test
|
||||
void withdrawalAndInvalidationAppendAuditFactsWithoutErasingNotices() {
|
||||
Lease lease = activeLease(LeaseType.STANDARD, List.of(TENANT_ID));
|
||||
lease.recordTenantNotice(notice("withdrawn", TODAY, TODAY.plusMonths(1),
|
||||
NoticeDeliveryMode.BAILIFF, null, false, false), AUDIT);
|
||||
|
||||
lease.acceptNoticeWithdrawal(new TenantNoticeId("withdrawn"), TODAY.plusDays(1),
|
||||
"Le locataire reste", TODAY.plusDays(1), AUDIT);
|
||||
|
||||
assertTrue(lease.contractEndOn().isEmpty());
|
||||
assertEquals(TenantNoticeStatus.WITHDRAWN, lease.endingHistory().notices().getFirst().status());
|
||||
assertEquals("Le locataire reste", lease.endingHistory().withdrawals().getFirst().reason());
|
||||
|
||||
lease.recordTenantNotice(notice("invalidated", TODAY, TODAY.plusMonths(2),
|
||||
NoticeDeliveryMode.BAILIFF, null, false, false), AUDIT);
|
||||
lease.invalidateNotice(new TenantNoticeId("invalidated"), TODAY.plusDays(2),
|
||||
"Erreur administrative documentée", TODAY.plusDays(2), AUDIT);
|
||||
|
||||
assertTrue(lease.contractEndOn().isEmpty());
|
||||
assertEquals(2, lease.endingHistory().notices().size());
|
||||
assertEquals(1, lease.endingHistory().withdrawals().size());
|
||||
assertEquals(1, lease.endingHistory().invalidations().size());
|
||||
assertThrows(LeaseRuleViolation.class, () -> lease.acceptNoticeWithdrawal(
|
||||
new TenantNoticeId("invalidated"), TODAY.plusDays(3), "Impossible", TODAY.plusDays(3), AUDIT));
|
||||
}
|
||||
|
||||
@Test
|
||||
void agreedEarlyEndWorksWithoutNoticeAndCanShortenAnExistingNotice() {
|
||||
Lease lease = activeLease(LeaseType.STANDARD, List.of(TENANT_ID));
|
||||
|
||||
lease.recordAgreedEarlyEnd(LocalDate.of(2026, 10, 1), "Accord autonome", AUDIT);
|
||||
assertEquals(LocalDate.of(2026, 10, 1), lease.contractEndOn().orElseThrow());
|
||||
|
||||
lease.recordTenantNotice(notice("notice-1", TODAY, LocalDate.of(2026, 9, 30),
|
||||
NoticeDeliveryMode.REGISTERED_LETTER, null, false, false), AUDIT);
|
||||
assertThrows(LeaseRuleViolation.class,
|
||||
() -> lease.recordAgreedEarlyEnd(LocalDate.of(2026, 9, 30), "Sans effet", AUDIT));
|
||||
assertThrows(LeaseRuleViolation.class,
|
||||
() -> lease.recordAgreedEarlyEnd(LocalDate.of(2026, 10, 1), "Repousse la fin", AUDIT));
|
||||
lease.recordAgreedEarlyEnd(LocalDate.of(2026, 8, 31), "Accord postérieur au congé", AUDIT);
|
||||
|
||||
assertEquals(LocalDate.of(2026, 8, 31), lease.contractEndOn().orElseThrow());
|
||||
assertEquals(2, lease.endingHistory().agreedEarlyEnds().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void evidenceIsVersionedAndEveryReplacementRequiresAReason() {
|
||||
Lease lease = activeLease(LeaseType.STANDARD, List.of(TENANT_ID));
|
||||
TenantNoticeId noticeId = new TenantNoticeId("notice-1");
|
||||
lease.recordTenantNotice(notice("notice-1", TODAY, TODAY.plusMonths(1),
|
||||
NoticeDeliveryMode.BAILIFF, null, false, false), AUDIT);
|
||||
NoticeEvidenceFile firstFile = new NoticeEvidenceFile(
|
||||
"evidence/key-1", "conge.pdf", "application/pdf", 42,
|
||||
"a".repeat(64));
|
||||
NoticeEvidenceFile replacement = new NoticeEvidenceFile(
|
||||
"evidence/key-2", "conge-corrige.pdf", "application/pdf", 84,
|
||||
"b".repeat(64));
|
||||
|
||||
NoticeEvidenceVersion first = lease.appendNoticeEvidence(noticeId, firstFile, null, AUDIT);
|
||||
assertEquals(1, first.version());
|
||||
assertThrows(LeaseRuleViolation.class,
|
||||
() -> lease.appendNoticeEvidence(noticeId, replacement, " ", AUDIT));
|
||||
assertThrows(LeaseRuleViolation.class,
|
||||
() -> lease.appendNoticeEvidence(noticeId, replacement, "x".repeat(501), AUDIT));
|
||||
NoticeEvidenceVersion second = lease.appendNoticeEvidence(
|
||||
noticeId, replacement, "Document corrigé", AUDIT);
|
||||
|
||||
assertEquals(2, second.version());
|
||||
assertEquals("Document corrigé", second.reason());
|
||||
assertEquals(List.of(first, second), lease.endingHistory().evidenceVersions(noticeId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void evidenceCanBeAddedToAHistoricWithdrawnNoticeAfterTheLeaseBecomesShared() {
|
||||
Lease lease = activeLease(LeaseType.STANDARD, List.of(TENANT_ID));
|
||||
TenantNoticeId noticeId = new TenantNoticeId("notice-1");
|
||||
lease.recordTenantNotice(notice("notice-1", TODAY, TODAY.plusMonths(1),
|
||||
NoticeDeliveryMode.BAILIFF, null, false, false), AUDIT);
|
||||
lease.acceptNoticeWithdrawal(noticeId, TODAY, "Le locataire reste", TODAY, AUDIT);
|
||||
LeaseContractVersion current = lease.contractVersion();
|
||||
LeaseSnapshot.Tenant secondTenant = new LeaseSnapshot.Tenant(new TenantId("tenant-2"), "Bob", "Martin",
|
||||
"bob@example.com", "", "1 rue A");
|
||||
LeaseContractVersion shared = new LeaseContractVersion(current.landlord(), current.apartment(),
|
||||
List.of(current.tenants().getFirst(), secondTenant), current.guarantors(), current.period(),
|
||||
current.terms(), current.leaseType(), current.specialClauses(), current.legalTerms());
|
||||
lease.amend(new LeaseAmendmentId("shared"), TODAY, shared);
|
||||
|
||||
NoticeEvidenceVersion evidence = lease.appendNoticeEvidence(noticeId,
|
||||
new NoticeEvidenceFile("evidence/key-1", "conge.pdf", "application/pdf", 42, "a".repeat(64)),
|
||||
null, AUDIT);
|
||||
|
||||
assertEquals(1, evidence.version());
|
||||
assertFalse(lease.endingSupported());
|
||||
}
|
||||
|
||||
@Test
|
||||
void onlyConfirmedClosureAfterTheContractEndArchivesTheLease() {
|
||||
Lease lease = activeLease(LeaseType.STANDARD, List.of(TENANT_ID));
|
||||
lease.recordAgreedEarlyEnd(TODAY.plusDays(10), "Fin convenue", AUDIT);
|
||||
|
||||
assertThrows(LeaseRuleViolation.class,
|
||||
() -> lease.close(TODAY.plusDays(9), false, TODAY.plusDays(10), AUDIT));
|
||||
assertThrows(LeaseRuleViolation.class,
|
||||
() -> lease.close(TODAY.plusDays(11), true, TODAY.plusDays(10), AUDIT));
|
||||
assertEquals(LeaseStatus.ACTIVE, lease.status());
|
||||
|
||||
lease.close(TODAY.plusDays(9), true, TODAY.plusDays(10), AUDIT);
|
||||
|
||||
assertEquals(LeaseStatus.ARCHIVED, lease.status());
|
||||
assertEquals(LeaseEndPhase.ARCHIVED, lease.endPhaseOn(TODAY.plusDays(10)));
|
||||
assertEquals(TODAY.plusDays(9), lease.endingHistory().closure().orElseThrow().keysReturnedOn());
|
||||
}
|
||||
|
||||
@Test
|
||||
void phasesAreDerivedFromCivilDatesAndRecordedFacts() {
|
||||
Lease lease = activeLease(LeaseType.STANDARD, List.of(TENANT_ID));
|
||||
LocalDate endOn = TODAY.plusMonths(1);
|
||||
|
||||
assertEquals(LeaseEndPhase.ACTIVE, lease.endPhaseOn(TODAY));
|
||||
lease.recordAgreedEarlyEnd(endOn, "Fin convenue", AUDIT);
|
||||
|
||||
assertEquals(LeaseEndPhase.NOTICE_PERIOD, lease.endPhaseOn(TODAY));
|
||||
assertEquals(LeaseEndPhase.AWAITING_CLOSURE, lease.endPhaseOn(endOn));
|
||||
assertEquals(LeaseEndPhase.UNAUTHORIZED_OCCUPANCY, lease.endPhaseOn(endOn.plusDays(1)));
|
||||
}
|
||||
|
||||
private Lease activeLease(LeaseType type, List<TenantId> tenantIds) {
|
||||
RentalPeriod input = new RentalPeriod(START_ON, START_ON.plusYears(5));
|
||||
Lease lease = Lease.draft(new LeaseId("lease-" + type + "-" + tenantIds.size()), LANDLORD_ID,
|
||||
APARTMENT_ID, tenantIds, List.of(), input, TERMS, type);
|
||||
List<LeaseSnapshot.Tenant> tenants = tenantIds.stream()
|
||||
.map(id -> new LeaseSnapshot.Tenant(id, "Alice", "Martin", "alice@example.com", "", "1 rue A"))
|
||||
.toList();
|
||||
lease.activate(new LeaseSnapshot(
|
||||
new LeaseSnapshot.Landlord(LANDLORD_ID, "Louise", "Durand", "louise@example.com", "+331",
|
||||
"2 rue B"),
|
||||
new LeaseSnapshot.Apartment(APARTMENT_ID, "Studio", "3 rue C"),
|
||||
tenants, List.of(), lease.period(), TERMS, type));
|
||||
return lease;
|
||||
}
|
||||
|
||||
private TenantNoticeCommand notice(String id, LocalDate receivedOn, LocalDate requestedDepartureOn,
|
||||
NoticeDeliveryMode deliveryMode, String otherDeliveryMode,
|
||||
boolean lateConfirmed, boolean nonStandardConfirmed) {
|
||||
return new TenantNoticeCommand(new TenantNoticeId(id), receivedOn, requestedDepartureOn, deliveryMode,
|
||||
otherDeliveryMode, lateConfirmed, nonStandardConfirmed, TODAY);
|
||||
}
|
||||
}
|
||||
@@ -76,11 +76,10 @@ class LeaseTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void activationRejectsIncompleteOrMismatchedFactsAndOnlyActiveLeasesArchive() {
|
||||
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(LeaseRuleViolation.class, empty::archive);
|
||||
|
||||
Lease lease = Lease.draft(new LeaseId("lease-1"), LANDLORD_ID, APARTMENT_ID, List.of(TENANT_ID),
|
||||
List.of(), PERIOD, TERMS);
|
||||
@@ -90,9 +89,6 @@ class LeaseTest {
|
||||
assertThrows(LeaseRuleViolation.class, () -> lease.activate(wrongApartment));
|
||||
|
||||
lease.activate(snapshot());
|
||||
lease.archive();
|
||||
lease.archive();
|
||||
assertEquals(LeaseStatus.ARCHIVED, lease.status());
|
||||
assertEquals(LeaseStatus.ARCHIVED, Lease.reconstitute(lease.id(), LANDLORD_ID, APARTMENT_ID,
|
||||
List.of(TENANT_ID), List.of(), PERIOD, TERMS, LeaseStatus.ARCHIVED, snapshot()).status());
|
||||
assertThrows(LeaseRuleViolation.class, () -> Lease.reconstitute(new LeaseId("bad-draft"), LANDLORD_ID,
|
||||
@@ -161,8 +157,10 @@ class LeaseTest {
|
||||
void onlyActiveLeasesMayBeAmended() {
|
||||
Lease draft = Lease.draft(new LeaseId("lease-draft"), LANDLORD_ID, APARTMENT_ID, List.of(TENANT_ID),
|
||||
List.of(), PERIOD, TERMS);
|
||||
Lease archived = activeLease();
|
||||
archived.archive();
|
||||
Lease active = activeLease();
|
||||
Lease archived = Lease.reconstitute(active.id(), active.landlordId(), active.apartmentId(),
|
||||
active.tenantIds(), active.guarantors(), active.period(), active.terms(), LeaseStatus.ARCHIVED,
|
||||
active.snapshot(), active.amendments(), active.version());
|
||||
|
||||
assertThrows(LeaseRuleViolation.class, () -> draft.amend(
|
||||
new LeaseAmendmentId("draft-amendment"), LocalDate.of(2026, 7, 1),
|
||||
|
||||
@@ -10,6 +10,7 @@ import fr.nessar.lmnporganizer.rental.domain.lease.LeaseContractVersion;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSnapshot;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseType;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.OccupancyPeriod;
|
||||
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.RentTerms;
|
||||
@@ -126,6 +127,172 @@ class RentDueTest {
|
||||
assertThrows(RentRuleViolation.class, () -> due.release(new BigDecimal("301")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void leaseEndProratesTheLastMonthRemovesUnallocatedFutureDuesAndFlagsOverAllocation() {
|
||||
LeaseId leaseId = new LeaseId("lease-1");
|
||||
LandlordId landlordId = new LandlordId("landlord-1");
|
||||
RentDue august = RentDue.create(new RentDueId("august"), landlordId, leaseId,
|
||||
YearMonth.of(2026, 8), LocalDate.of(2026, 8, 5), new BigDecimal("1000"),
|
||||
new BigDecimal("100"));
|
||||
august.allocate(new BigDecimal("600"));
|
||||
RentDue september = RentDue.create(new RentDueId("september"), landlordId, leaseId,
|
||||
YearMonth.of(2026, 9), LocalDate.of(2026, 9, 5), new BigDecimal("1000"),
|
||||
new BigDecimal("100"));
|
||||
RentDue october = RentDue.create(new RentDueId("october"), landlordId, leaseId,
|
||||
YearMonth.of(2026, 10), LocalDate.of(2026, 10, 5), new BigDecimal("1000"),
|
||||
new BigDecimal("100"));
|
||||
october.allocate(new BigDecimal("200"));
|
||||
|
||||
RentSchedule.EndReconciliation result = RentSchedule.reconcileEnd(
|
||||
List.of(august, september, october), OccupancyPeriod.open(LocalDate.of(2026, 1, 1)),
|
||||
LocalDate.of(2026, 8, 15));
|
||||
|
||||
assertEquals(new BigDecimal("483.87"), august.rent());
|
||||
assertEquals(new BigDecimal("48.39"), august.charges());
|
||||
assertEquals(new BigDecimal("600"), august.allocatedAmount());
|
||||
assertEquals(BigDecimal.ZERO, august.remainingAmount());
|
||||
assertTrue(august.correctionRequired());
|
||||
assertEquals(List.of(august, september, october), result.toSave());
|
||||
assertEquals(List.of(), result.toRemove());
|
||||
assertEquals(BigDecimal.ZERO, september.total());
|
||||
assertEquals(BigDecimal.ZERO, october.total());
|
||||
assertEquals(new BigDecimal("200"), october.allocatedAmount());
|
||||
assertTrue(result.correctionRequired());
|
||||
|
||||
august.release(new BigDecimal("100"));
|
||||
|
||||
assertFalse(august.correctionRequired());
|
||||
assertEquals(new BigDecimal("32.26"), august.remainingAmount());
|
||||
assertEquals(RentDueSettlement.PARTIALLY_PAID, august.settlement());
|
||||
|
||||
october.release(new BigDecimal("200"));
|
||||
|
||||
assertFalse(october.correctionRequired());
|
||||
assertEquals(BigDecimal.ZERO, october.remainingAmount());
|
||||
assertEquals(RentDueSettlement.PAID, october.settlement());
|
||||
}
|
||||
|
||||
@Test
|
||||
void expandingAnEndRestoresTheExactAmountsThatExistedBeforeLeaseEndReconciliation() {
|
||||
RentDue august = RentDue.create(new RentDueId("august"), new LandlordId("landlord-1"),
|
||||
new LeaseId("lease-1"), YearMonth.of(2026, 8), LocalDate.of(2026, 8, 5),
|
||||
new BigDecimal("1123.45"), new BigDecimal("98.76"));
|
||||
RentDue september = RentDue.create(new RentDueId("september"), new LandlordId("landlord-1"),
|
||||
new LeaseId("lease-1"), YearMonth.of(2026, 9), LocalDate.of(2026, 9, 5),
|
||||
new BigDecimal("1200.00"), new BigDecimal("110.00"));
|
||||
OccupancyPeriod scheduled = OccupancyPeriod.open(LocalDate.of(2026, 1, 1));
|
||||
RentSchedule.reconcileEnd(List.of(august, september), scheduled, LocalDate.of(2026, 8, 19));
|
||||
|
||||
RentSchedule.EndReconciliation restored = RentSchedule.restoreAfterEndExpansion(
|
||||
List.of(august, september), scheduled, java.util.Optional.empty());
|
||||
|
||||
assertEquals(List.of(august, september), restored.toSave());
|
||||
assertEquals(new BigDecimal("1123.45"), august.rent());
|
||||
assertEquals(new BigDecimal("98.76"), august.charges());
|
||||
assertEquals(new BigDecimal("1200.00"), september.rent());
|
||||
assertEquals(new BigDecimal("110.00"), september.charges());
|
||||
assertFalse(august.adjustedForLeaseEnd());
|
||||
assertFalse(september.adjustedForLeaseEnd());
|
||||
}
|
||||
|
||||
@Test
|
||||
void amendmentUpdatesHiddenPostEndingAmountsBeforeAWithdrawalRestoresThem() {
|
||||
LeaseId leaseId = new LeaseId("lease-1");
|
||||
LandlordId landlordId = new LandlordId("landlord-1");
|
||||
RentDue september = RentDue.create(new RentDueId("september"), landlordId, leaseId,
|
||||
YearMonth.of(2026, 9), LocalDate.of(2026, 9, 5), new BigDecimal("1000"),
|
||||
new BigDecimal("100"));
|
||||
OccupancyPeriod scheduled = OccupancyPeriod.open(LocalDate.of(2026, 1, 1));
|
||||
RentSchedule.reconcileEnd(List.of(september), scheduled, LocalDate.of(2026, 8, 19));
|
||||
LeaseContractVersion before = version(LocalDate.of(2026, 12, 31), "1000", "100");
|
||||
LeaseContractVersion after = version(LocalDate.of(2026, 12, 31), "1200", "120");
|
||||
LeaseAmended amendment = new LeaseAmended(leaseId, landlordId, new LeaseAmendment(
|
||||
new LeaseAmendmentId("amendment-1"), LocalDate.of(2026, 8, 1), before, after));
|
||||
|
||||
RentSchedule.reconcile(amendment, List.of(september), month -> new RentDueId("new-" + month));
|
||||
RentSchedule.reconcileEnd(List.of(september), scheduled, LocalDate.of(2026, 8, 19));
|
||||
RentSchedule.restoreAfterEndExpansion(List.of(september), scheduled, java.util.Optional.empty());
|
||||
|
||||
assertEquals(new BigDecimal("1200.00"), september.rent());
|
||||
assertEquals(new BigDecimal("120.00"), september.charges());
|
||||
}
|
||||
|
||||
@Test
|
||||
void irlUpdatesHiddenAmountsAndNeverReextendsTheFinalNoticeMonth() {
|
||||
LandlordId landlordId = new LandlordId("landlord-1");
|
||||
LeaseId leaseId = new LeaseId("lease-1");
|
||||
RentDue august = RentDue.create(new RentDueId("august"), landlordId, leaseId,
|
||||
YearMonth.of(2026, 8), LocalDate.of(2026, 8, 5), new BigDecimal("1000"),
|
||||
new BigDecimal("100"));
|
||||
RentDue september = RentDue.create(new RentDueId("september"), landlordId, leaseId,
|
||||
YearMonth.of(2026, 9), LocalDate.of(2026, 9, 5), new BigDecimal("1000"),
|
||||
new BigDecimal("100"));
|
||||
OccupancyPeriod scheduled = OccupancyPeriod.open(LocalDate.of(2026, 1, 1));
|
||||
LocalDate endOn = LocalDate.of(2026, 8, 19);
|
||||
RentSchedule.reconcileEnd(List.of(august, september), scheduled, endOn);
|
||||
|
||||
RentSchedule.applyRevision(List.of(august, september), LocalDate.of(2026, 8, 1),
|
||||
new RentSchedule.RevisionAmounts(new BigDecimal("1000"), new BigDecimal("100"),
|
||||
new BigDecimal("1250"), new BigDecimal("125")));
|
||||
RentSchedule.reconcileEnd(List.of(august, september), scheduled, endOn);
|
||||
|
||||
assertEquals(new BigDecimal("766.13"), august.rent());
|
||||
assertEquals(new BigDecimal("76.61"), august.charges());
|
||||
assertEquals(BigDecimal.ZERO, september.total());
|
||||
RentSchedule.restoreAfterEndExpansion(List.of(august, september), scheduled,
|
||||
java.util.Optional.empty());
|
||||
assertEquals(new BigDecimal("1250.00"), september.rent());
|
||||
assertEquals(new BigDecimal("125.00"), september.charges());
|
||||
}
|
||||
|
||||
@Test
|
||||
void reconstitutionOnlyAllowsZeroOrOverallocatedDuesWithAnExplicitLeaseEndBaseline() {
|
||||
assertThrows(RentRuleViolation.class, () -> RentDue.reconstitute(
|
||||
new RentDueId("zero"), new LandlordId("landlord-1"), new LeaseId("lease-1"),
|
||||
YearMonth.of(2026, 9), LocalDate.of(2026, 9, 5), BigDecimal.ZERO, BigDecimal.ZERO,
|
||||
BigDecimal.ZERO, -1));
|
||||
assertThrows(RentRuleViolation.class, () -> RentDue.reconstitute(
|
||||
new RentDueId("over"), new LandlordId("landlord-1"), new LeaseId("lease-1"),
|
||||
YearMonth.of(2026, 9), LocalDate.of(2026, 9, 5), new BigDecimal("10"), BigDecimal.ZERO,
|
||||
new BigDecimal("11"), -1));
|
||||
|
||||
RentDue ended = RentDue.reconstitute(new RentDueId("ended"), new LandlordId("landlord-1"),
|
||||
new LeaseId("lease-1"), YearMonth.of(2026, 9), LocalDate.of(2026, 9, 5),
|
||||
BigDecimal.ZERO, BigDecimal.ZERO, new BigDecimal("11"),
|
||||
new BigDecimal("1000"), new BigDecimal("100"), -1);
|
||||
assertTrue(ended.correctionRequired());
|
||||
}
|
||||
|
||||
@Test
|
||||
void leaseEndDoesNotDoubleProrateAStandardLeaseFirstMonth() {
|
||||
LeaseActivated activation = new LeaseActivated(new LeaseId("lease-1"), new LandlordId("landlord-1"),
|
||||
new RentalPeriod(LocalDate.of(2026, 8, 15), LocalDate.of(2027, 8, 14)),
|
||||
new RentTerms(new BigDecimal("1000"), new BigDecimal("100"), BigDecimal.ZERO));
|
||||
RentDue august = RentSchedule.from(activation, month -> new RentDueId("due-" + month)).getFirst();
|
||||
|
||||
RentSchedule.reconcileEnd(List.of(august), OccupancyPeriod.open(activation.period().startDate()),
|
||||
LocalDate.of(2026, 8, 20));
|
||||
|
||||
assertEquals(new BigDecimal("193.55"), august.rent());
|
||||
assertEquals(new BigDecimal("19.36"), august.charges());
|
||||
}
|
||||
|
||||
@Test
|
||||
void leaseEndDoesNotDoubleProrateAStudentLeaseAlreadyBoundedFinalMonth() {
|
||||
LocalDate startOn = LocalDate.of(2026, 1, 15);
|
||||
LocalDate fixedEndOn = startOn.plusMonths(9).minusDays(1);
|
||||
LeaseActivated activation = new LeaseActivated(new LeaseId("lease-1"), new LandlordId("landlord-1"),
|
||||
new RentalPeriod(startOn, fixedEndOn),
|
||||
new RentTerms(new BigDecimal("1000"), new BigDecimal("100"), BigDecimal.ZERO));
|
||||
RentDue october = RentSchedule.from(activation, month -> new RentDueId("due-" + month)).getLast();
|
||||
|
||||
RentSchedule.reconcileEnd(List.of(october), OccupancyPeriod.closed(startOn, fixedEndOn),
|
||||
LocalDate.of(2026, 10, 10));
|
||||
|
||||
assertEquals(new BigDecimal("322.58"), october.rent());
|
||||
assertEquals(new BigDecimal("32.26"), october.charges());
|
||||
}
|
||||
|
||||
@Test
|
||||
void amendmentReconciliationProratesItsMonthAndExtendsTheSchedule() {
|
||||
LeaseId leaseId = new LeaseId("lease-1");
|
||||
|
||||
+10
-5
@@ -122,7 +122,7 @@ class ReplacementRentalApiJourneyIT {
|
||||
"supplementJustification":"Vue exceptionnelle"},
|
||||
"previousRent":{"applicable":true,"amount":850,"lastRevisionDate":"2025-08-01",
|
||||
"lastPaymentDate":"2026-07-01","tenantConfirmation":true},
|
||||
"rentRevision":{"enabled":true,"annualRevisionDate":"2027-08-01",
|
||||
"rentRevision":{"enabled":true,"annualRevisionDay":29,"annualRevisionMonth":2,
|
||||
"referenceQuarter":2,"referenceYear":2026,"seriesId":"IRL-METROPOLE"},
|
||||
"recentWork":{"description":"Isolation","amount":5000},
|
||||
"coOwnershipRulesApplicable":true,"priorRentalAuthorizationApplicable":true,
|
||||
@@ -134,6 +134,8 @@ class ReplacementRentalApiJourneyIT {
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.legalTerms.rentControl.increasedReferenceRent").value(24))
|
||||
.andExpect(jsonPath("$.legalTerms.previousRent.lastPaymentDate").value("2026-07-01"))
|
||||
.andExpect(jsonPath("$.legalTerms.rentRevision.annualRevisionDay").value(29))
|
||||
.andExpect(jsonPath("$.legalTerms.rentRevision.annualRevisionMonth").value(2))
|
||||
.andExpect(jsonPath("$.legalTerms.rentRevision.seriesId").value("IRL-METROPOLE"))
|
||||
.andExpect(jsonPath("$.legalTerms.recentWork.amount").value(5000))
|
||||
.andExpect(jsonPath("$.legalTerms.jointAndSeveralLiability").value(true))
|
||||
@@ -246,7 +248,7 @@ class ReplacementRentalApiJourneyIT {
|
||||
{"apartmentId":"%s","tenantIds":["%s"],"guarantors":[],
|
||||
"startDate":"2026-07-01","endDate":"2026-08-31",
|
||||
"monthlyRent":900.00,"monthlyCharges":50.00,"securityDeposit":900.00,
|
||||
"legalTerms":{"rentRevision":{"enabled":true,"annualRevisionDate":"2027-07-01",
|
||||
"legalTerms":{"rentRevision":{"enabled":true,"annualRevisionDay":1,"annualRevisionMonth":7,
|
||||
"referenceQuarter":2,"referenceYear":2026,
|
||||
"seriesId":"IRL-METROPOLE"},
|
||||
"recentWork":{"description":"Peinture","amount":1200},
|
||||
@@ -403,7 +405,8 @@ class ReplacementRentalApiJourneyIT {
|
||||
mockMvc.perform(post("/api/rent-payments/{paymentId}/reversal", surplusPaymentId)
|
||||
.with(owner()).with(csrf()).contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"paymentVersion":%d,"reason":"Correction après remboursement"}
|
||||
{"paymentVersion":%d,"reason":"Correction après remboursement",
|
||||
"timeZone":"Europe/Paris"}
|
||||
""".formatted(refundedPaymentVersion)))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.reversal.reason").value("Correction après remboursement"))
|
||||
@@ -506,7 +509,8 @@ class ReplacementRentalApiJourneyIT {
|
||||
.andExpect(status().isNotFound());
|
||||
mockMvc.perform(post("/api/leases/{id}/amendments", leaseId).with(otherLandlord()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content("""
|
||||
{"effectiveDate":"2026-07-20","tenantIds":["%s"],"guarantors":[],
|
||||
{"effectiveDate":"2026-07-20","timeZone":"Europe/Paris",
|
||||
"tenantIds":["%s"],"guarantors":[],
|
||||
"endDate":"2026-07-31","monthlyRent":901.00,"monthlyCharges":50.00}
|
||||
""".formatted(tenantId)))
|
||||
.andExpect(status().isNotFound());
|
||||
@@ -526,7 +530,8 @@ class ReplacementRentalApiJourneyIT {
|
||||
.andExpect(status().isNotFound());
|
||||
mockMvc.perform(post("/api/rent-payments/{id}/reversal", paymentId)
|
||||
.with(otherLandlord()).with(csrf()).contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"paymentVersion\":0,\"reason\":\"Foreign mutation\"}"))
|
||||
.content("{\"paymentVersion\":0,\"reason\":\"Foreign mutation\","
|
||||
+ "\"timeZone\":\"Europe/Paris\"}"))
|
||||
.andExpect(status().isNotFound());
|
||||
mockMvc.perform(post("/api/rent-payments/{id}/refunds", surplusPaymentId)
|
||||
.with(otherLandlord()).with(csrf()).contentType(MediaType.APPLICATION_JSON)
|
||||
|
||||
Reference in new issue
Block a user