From ca3e28d3a22e78329b514fdea0c52495d376aaff Mon Sep 17 00:00:00 2001 From: Nessar Date: Fri, 24 Jul 2026 19:09:38 +0200 Subject: [PATCH] Align core lease and amendment validation (#49) --- .../rental/adapter/in/web/lease/LeaseApi.java | 21 +- .../adapter/in/web/lease/LeaseController.java | 39 ++- .../in/web/lease/LeaseAmendmentWebTest.java | 83 ++++++ .../in/web/lease/LeaseApiValidationTest.java | 80 ++++++ .../domain/lease/LeaseInputRulesTest.java | 53 ++++ .../lease-amendment-form.html | 165 +++++++++++ .../lease-amendment-form.spec.ts | 182 ++++++++++++ .../lease-amendment-form.ts | 265 ++++++++++++++++++ .../app/components/lease-card/lease-card.html | 5 + .../app/components/lease-card/lease-card.ts | 1 + .../lease-form/lease-form-validation.ts | 83 ++++++ .../app/components/lease-form/lease-form.html | 92 ++++-- .../components/lease-form/lease-form.spec.ts | 110 +++++++- .../app/components/lease-form/lease-form.ts | 133 +++++++-- .../components/leases-page/leases-page.html | 18 +- .../leases-page/leases-page.spec.ts | 3 + .../app/components/leases-page/leases-page.ts | 21 +- .../src/app/services/lease.service.spec.ts | 21 +- frontend/src/app/services/lease.service.ts | 17 ++ .../src/app/services/workspace.service.ts | 25 +- 20 files changed, 1361 insertions(+), 56 deletions(-) create mode 100644 backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseAmendmentWebTest.java create mode 100644 backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseApiValidationTest.java create mode 100644 backend/src/test/java/fr/nessar/lmnporganizer/rental/domain/lease/LeaseInputRulesTest.java create mode 100644 frontend/src/app/components/lease-amendment-form/lease-amendment-form.html create mode 100644 frontend/src/app/components/lease-amendment-form/lease-amendment-form.spec.ts create mode 100644 frontend/src/app/components/lease-amendment-form/lease-amendment-form.ts create mode 100644 frontend/src/app/components/lease-form/lease-form-validation.ts diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseApi.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseApi.java index 846db36..22d4148 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseApi.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseApi.java @@ -19,6 +19,7 @@ import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSpecialClause; import fr.nessar.lmnporganizer.rental.domain.lease.LeaseStatus; import fr.nessar.lmnporganizer.rental.domain.lease.LeaseType; import jakarta.validation.Valid; +import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; @@ -33,6 +34,7 @@ import java.time.DateTimeException; import java.time.Instant; import java.time.LocalDate; import java.time.MonthDay; +import java.util.HashSet; import java.util.List; import java.util.Map; @@ -155,7 +157,12 @@ record DraftRequest( ChargeMethod chargeMethod, Boolean indexFlatRate, @Size(max = 20) List<@Valid SpecialClauseRequest> specialClauses, - @Valid LegalTermsRequest legalTerms) {} + @Valid LegalTermsRequest legalTerms) { + @AssertTrue + boolean isTenantIdsDistinct() { + return tenantIds == null || new HashSet<>(tenantIds).size() == tenantIds.size(); + } +} record AmendmentRequest( @NotNull LocalDate effectiveDate, @@ -167,7 +174,17 @@ record AmendmentRequest( @NotNull @PositiveOrZero BigDecimal monthlyCharges, @Min(1) @Max(28) Integer dueDay, ChargeMethod chargeMethod, - Boolean indexFlatRate) {} + Boolean indexFlatRate) { + @AssertTrue + boolean isTenantIdsDistinct() { + return tenantIds == null || new HashSet<>(tenantIds).size() == tenantIds.size(); + } + + @AssertTrue + boolean isEndDateOnOrAfterEffectiveDate() { + return effectiveDate == null || endDate == null || !endDate.isBefore(effectiveDate); + } +} record GuarantorResponse( String id, String firstName, String lastName, String email, String phone, String address) { diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseController.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseController.java index e1146f1..4bf00cf 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseController.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseController.java @@ -28,6 +28,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.oauth2.core.oidc.user.OidcUser; import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -97,6 +98,10 @@ ResponseEntity amend( @AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId, @Valid @RequestBody LeaseApi.AmendmentRequest request) { + ZoneId timeZone = zone(request.timeZone()); + if (request.effectiveDate().isBefore(LocalDate.now(clock.withZone(timeZone)))) { + throw new LeaseRequestValidationFailure("effectiveDate", "not_past"); + } var change = new AmendLeaseService.Change( tenantIds(request.tenantIds()), @@ -112,11 +117,21 @@ ResponseEntity amend( authenticatedLandlord.id(principal), new LeaseId(leaseId), request.effectiveDate(), - zone(request.timeZone()), + timeZone, change); return ResponseEntity.status(HttpStatus.CREATED).body(response(amended)); } +@ExceptionHandler(LeaseRequestValidationFailure.class) +ResponseEntity invalidAmendment( + LeaseRequestValidationFailure failure) { + return ResponseEntity.badRequest() + .body( + new LeaseRequestValidationError( + "validation_failed", + List.of(new LeaseRequestFieldError(failure.field(), failure.code())))); +} + @PostMapping("/{leaseId}/revisions") ResponseEntity generateRevision( @AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId) { @@ -233,4 +248,26 @@ private static ResponseEntity document(GeneratedDocument document) { .header(HttpHeaders.CONTENT_TYPE, document.contentType()) .body(document.content()); } + +private record LeaseRequestValidationError(String code, List errors) {} + +private record LeaseRequestFieldError(String field, String code) {} + +private static final class LeaseRequestValidationFailure extends RuntimeException { + private final String field; + private final String code; + + LeaseRequestValidationFailure(String field, String code) { + this.field = field; + this.code = code; + } + + String field() { + return field; + } + + String code() { + return code; + } +} } diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseAmendmentWebTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseAmendmentWebTest.java new file mode 100644 index 0000000..b747a50 --- /dev/null +++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseAmendmentWebTest.java @@ -0,0 +1,83 @@ +package fr.nessar.lmnporganizer.rental.adapter.in.web.lease; + +import static org.mockito.ArgumentMatchers.any; +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.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +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.AmendLeaseService; +import fr.nessar.lmnporganizer.rental.application.lease.GenerateLeaseDocumentService; +import fr.nessar.lmnporganizer.rental.application.lease.LeaseDraftService; +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 java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(controllers = LeaseController.class) +@ContextConfiguration(classes = LmnpOrganizerApplication.class) +@Import({AuthenticatedLandlord.class, RentalApiExceptionHandler.class, SecurityConfiguration.class}) +class LeaseAmendmentWebTest { +@Autowired MockMvc mockMvc; + +@MockitoBean LandlordAccountService accounts; +@MockitoBean LeaseDraftService drafts; +@MockitoBean AmendLeaseService amendments; +@MockitoBean GenerateLeaseDocumentService documents; +@MockitoBean Clock clock; + +@BeforeEach +void authenticate() { + when(accounts.identify(any())) + .thenReturn( + Landlord.register( + new LandlordId("landlord-1"), + new ExternalIdentity("subject-1"), + "Camille", + "Martin", + "camille@example.com")); + when(clock.withZone(ZoneId.of("Europe/Paris"))) + .thenReturn(Clock.fixed(Instant.parse("2026-07-24T10:00:00Z"), ZoneId.of("Europe/Paris"))); +} + +@Test +void rejectsAPastEffectiveDateAsAStableFieldErrorBeforeAmending() throws Exception { + mockMvc + .perform( + post("/api/leases/lease-1/amendments") + .with(oidcLogin()) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + {"effectiveDate":"2026-07-23","timeZone":"Europe/Paris", + "tenantIds":["tenant-1"],"guarantors":[],"endDate":"2027-06-30", + "monthlyRent":1000,"monthlyCharges":75,"dueDay":5,"chargeMethod":"FLAT_RATE"} + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("validation_failed")) + .andExpect(jsonPath("$.errors[0].field").value("effectiveDate")) + .andExpect(jsonPath("$.errors[0].code").value("not_past")); + + verifyNoInteractions(amendments); +} +} diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseApiValidationTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseApiValidationTest.java new file mode 100644 index 0000000..0ee4595 --- /dev/null +++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseApiValidationTest.java @@ -0,0 +1,80 @@ +package fr.nessar.lmnporganizer.rental.adapter.in.web.lease; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class LeaseApiValidationTest { +private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); + +@Test +void draftCoreFailuresHaveStableFieldPathsBeforeTheUseCase() { + Set failures = + validator + .validate( + new LeaseApi.DraftRequest( + "", + List.of("tenant-1", "tenant-1"), + List.of(), + null, + null, + null, + BigDecimal.ZERO, + new BigDecimal("-0.01"), + new BigDecimal("-0.01"), + 29, + null, + false, + List.of(), + null)) + .stream() + .map( + failure -> + failure.getPropertyPath() + + ":" + + failure + .getConstraintDescriptor() + .getAnnotation() + .annotationType() + .getSimpleName()) + .collect(java.util.stream.Collectors.toSet()); + + assertTrue(failures.contains("apartmentId:NotBlank")); + assertTrue(failures.contains("tenantIdsDistinct:AssertTrue"), failures::toString); + assertTrue(failures.contains("startDate:NotNull")); + assertTrue(failures.contains("monthlyRent:Positive")); + assertTrue(failures.contains("monthlyCharges:PositiveOrZero")); + assertTrue(failures.contains("securityDeposit:PositiveOrZero")); + assertTrue(failures.contains("dueDay:Max")); +} + +@Test +void amendmentReportsTheEndDateRelationBeforeTheUseCase() { + Set paths = + validator + .validate( + new LeaseApi.AmendmentRequest( + LocalDate.of(2026, 10, 2), + "Europe/Paris", + List.of("tenant-1", "tenant-1"), + List.of(), + LocalDate.of(2026, 10, 1), + BigDecimal.ONE, + BigDecimal.ZERO, + 5, + null, + false)) + .stream() + .map(failure -> failure.getPropertyPath().toString()) + .collect(java.util.stream.Collectors.toSet()); + + assertTrue(paths.contains("tenantIdsDistinct"), paths::toString); + assertTrue(paths.contains("endDateOnOrAfterEffectiveDate")); +} +} diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/domain/lease/LeaseInputRulesTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/domain/lease/LeaseInputRulesTest.java new file mode 100644 index 0000000..62b54a5 --- /dev/null +++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/domain/lease/LeaseInputRulesTest.java @@ -0,0 +1,53 @@ +package fr.nessar.lmnporganizer.rental.domain.lease; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.math.BigDecimal; +import java.time.LocalDate; +import org.junit.jupiter.api.Test; + +class LeaseInputRulesTest { +@Test +void acceptsCentimeRentAndZeroChargesOrDeposit() { + assertDoesNotThrow( + () -> + new RentTerms( + new BigDecimal("0.01"), + BigDecimal.ZERO, + BigDecimal.ZERO, + 28, + ChargeMethod.FLAT_RATE, + false)); +} + +@Test +void rejectsNegativeAmountsAndNonCivilDueDays() { + assertThrows( + LeaseRuleViolation.class, + () -> new RentTerms(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO)); + assertThrows( + LeaseRuleViolation.class, + () -> new RentTerms(BigDecimal.ONE, new BigDecimal("-0.01"), BigDecimal.ZERO)); + assertThrows( + LeaseRuleViolation.class, + () -> new RentTerms(BigDecimal.ONE, BigDecimal.ZERO, new BigDecimal("-0.01"))); + assertThrows( + LeaseRuleViolation.class, + () -> + new RentTerms( + BigDecimal.ONE, + BigDecimal.ZERO, + BigDecimal.ZERO, + 29, + ChargeMethod.FLAT_RATE, + false)); +} + +@Test +void rejectsAnEndDateBeforeTheLeaseStart() { + assertThrows( + LeaseRuleViolation.class, + () -> new RentalPeriod(LocalDate.of(2026, 10, 2), LocalDate.of(2026, 10, 1))); +} +} diff --git a/frontend/src/app/components/lease-amendment-form/lease-amendment-form.html b/frontend/src/app/components/lease-amendment-form/lease-amendment-form.html new file mode 100644 index 0000000..f3d7147 --- /dev/null +++ b/frontend/src/app/components/lease-amendment-form/lease-amendment-form.html @@ -0,0 +1,165 @@ +
+
+
+ +

Bail actif

+

Préparer un avenant

+

+ Seuls les locataires, garants, terme et conditions de paiement peuvent être + modifiés. +

+
+
+
+ @if (showSaveError()) { + + } +
+
+ + Prise d’effet + + @if (fieldError('effectiveDate')) { + {{ fieldError('effectiveDate') }} + } + + + Locataires + + @for (tenant of tenants(); track tenant.id) { + {{ tenant.firstName }} {{ tenant.lastName }} + } + + @if (fieldError('tenantIds')) { + {{ fieldError('tenantIds') }} + } + + + Fin de bail + + @if (fieldError('endDate')) { + {{ fieldError('endDate') }} + } + + + Loyer mensuel + + € + @if (fieldError('monthlyRent')) { + {{ fieldError('monthlyRent') }} + } + + + Charges mensuelles + + € + @if (fieldError('monthlyCharges')) { + {{ fieldError('monthlyCharges') }} + } + + + Mode de récupération des charges + + Forfait de charges + Provision avec régularisation annuelle + + + + Jour d’échéance + + @if (fieldError('dueDay')) { + {{ fieldError('dueDay') }} + } + + @if (amendmentModel().chargeMethod === 'FLAT_RATE') { + Indexer le forfait de charges avec le loyer + } +
+
+

Garants

+ @for (guarantor of guarantors(); track $index) { +
+ {{ guarantor.firstName }} {{ guarantor.lastName }} + +
+ } + +
+
+ + +
+
+
+
diff --git a/frontend/src/app/components/lease-amendment-form/lease-amendment-form.spec.ts b/frontend/src/app/components/lease-amendment-form/lease-amendment-form.spec.ts new file mode 100644 index 0000000..0dbb937 --- /dev/null +++ b/frontend/src/app/components/lease-amendment-form/lease-amendment-form.spec.ts @@ -0,0 +1,182 @@ +import { TestBed } from '@angular/core/testing'; +import { vi } from 'vitest'; +import { emptyLeaseLegalTerms } from '../../services/lease.service'; +import { LeaseAmendmentForm } from './lease-amendment-form'; + +describe('LeaseAmendmentForm', () => { + afterEach(() => vi.useRealTimers()); + + it('submits only mutable contract facts', () => { + const fixture = TestBed.createComponent(LeaseAmendmentForm); + fixture.componentRef.setInput('tenants', [ + { + id: 'tenant-1', + firstName: 'Camille', + lastName: 'Martin', + email: '', + phone: '', + address: '', + }, + ]); + fixture.componentRef.setInput('lease', { + id: 'lease-1', + apartmentId: 'apartment-1', + tenantIds: ['tenant-1'], + guarantors: [], + specialClauses: [], + legalTerms: emptyLeaseLegalTerms(), + leaseType: 'STANDARD', + startDate: '2026-09-01', + endDate: '2027-08-31', + monthlyRent: 850, + monthlyCharges: 50, + securityDeposit: 850, + dueDay: 5, + chargeMethod: 'FLAT_RATE', + indexFlatRate: false, + status: 'ACTIVE', + }); + fixture.componentInstance.amendmentModel.update((model) => ({ + ...model, + effectiveDate: '2026-10-01', + monthlyRent: 0.01, + monthlyCharges: 0, + dueDay: 28, + })); + const submitted = vi.fn(); + fixture.componentInstance.submitted.subscribe(submitted); + fixture.detectChanges(); + + fixture.componentInstance.save(); + + expect(submitted).toHaveBeenCalledWith( + expect.objectContaining({ + effectiveDate: '2026-10-01', + tenantIds: ['tenant-1'], + endDate: '2027-08-31', + monthlyRent: 0.01, + monthlyCharges: 0, + dueDay: 28, + }), + ); + expect(submitted.mock.calls[0][0]).not.toHaveProperty('securityDeposit'); + expect(submitted.mock.calls[0][0]).not.toHaveProperty('apartmentId'); + }); + + it('blocks a date before the amendment takes effect', () => { + const fixture = TestBed.createComponent(LeaseAmendmentForm); + fixture.componentRef.setInput('tenants', []); + fixture.componentRef.setInput('lease', { + id: 'lease-1', + apartmentId: 'apartment-1', + tenantIds: ['tenant-1'], + guarantors: [], + specialClauses: [], + legalTerms: emptyLeaseLegalTerms(), + leaseType: 'STANDARD', + startDate: '2026-09-01', + endDate: '2027-08-31', + monthlyRent: 850, + monthlyCharges: 50, + securityDeposit: 850, + dueDay: 5, + chargeMethod: 'FLAT_RATE', + indexFlatRate: false, + status: 'ACTIVE', + }); + fixture.componentInstance.amendmentModel.update((model) => ({ + ...model, + effectiveDate: '2026-10-01', + endDate: '2026-09-30', + })); + const submitted = vi.fn(); + fixture.componentInstance.submitted.subscribe(submitted); + fixture.detectChanges(); + + fixture.componentInstance.save(); + + expect(submitted).not.toHaveBeenCalled(); + expect(fixture.componentInstance.fieldError('endDate')).toContain('ne peut pas précéder'); + }); + + it('blocks a past or out-of-period effective date and focuses it', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-24T12:00:00')); + const fixture = TestBed.createComponent(LeaseAmendmentForm); + fixture.componentRef.setInput('tenants', []); + fixture.componentRef.setInput('lease', { + id: 'lease-1', + apartmentId: 'apartment-1', + tenantIds: ['tenant-1'], + guarantors: [], + specialClauses: [], + legalTerms: emptyLeaseLegalTerms(), + leaseType: 'STANDARD', + startDate: '2026-07-01', + endDate: '2027-06-30', + monthlyRent: 850, + monthlyCharges: 50, + securityDeposit: 850, + dueDay: 5, + chargeMethod: 'FLAT_RATE', + indexFlatRate: false, + status: 'ACTIVE', + }); + fixture.componentInstance.amendmentModel.update((model) => ({ + ...model, + effectiveDate: '2026-07-23', + endDate: '2027-06-30', + })); + fixture.detectChanges(); + + fixture.componentInstance.save(); + await new Promise((resolve) => queueMicrotask(resolve)); + expect(fixture.componentInstance.fieldError('effectiveDate')).toContain( + 'entre aujourd’hui', + ); + expect(document.activeElement?.id).toBe('effectiveDate'); + + fixture.componentInstance.amendmentModel.update((model) => ({ + ...model, + effectiveDate: '2027-07-01', + })); + expect(fixture.componentInstance.fieldError('effectiveDate')).toContain('fin du bail'); + }); + + it('clears virtual tenant and end-date error paths with their visible controls', () => { + const fixture = TestBed.createComponent(LeaseAmendmentForm); + fixture.componentRef.setInput('tenants', []); + fixture.componentRef.setInput('lease', { + id: 'lease-1', + apartmentId: 'apartment-1', + tenantIds: ['tenant-1'], + guarantors: [], + specialClauses: [], + legalTerms: emptyLeaseLegalTerms(), + leaseType: 'STANDARD', + startDate: '2026-09-01', + endDate: '2027-08-31', + monthlyRent: 850, + monthlyCharges: 50, + securityDeposit: 850, + dueDay: 5, + chargeMethod: 'FLAT_RATE', + indexFlatRate: false, + status: 'ACTIVE', + }); + fixture.componentRef.setInput('saveFieldErrors', [ + { field: 'tenantIdsDistinct', code: 'assert_true' }, + { field: 'endDateOnOrAfterEffectiveDate', code: 'assert_true' }, + ]); + const changed = vi.fn(); + fixture.componentInstance.fieldChanged.subscribe(changed); + fixture.detectChanges(); + + expect(fixture.componentInstance.fieldError('tenantIds')).toContain('sans doublon'); + expect(fixture.componentInstance.fieldError('endDate')).toContain('ne peut pas précéder'); + fixture.componentInstance.fieldWasChanged('tenantIds'); + fixture.componentInstance.fieldWasChanged('endDate'); + expect(changed).toHaveBeenCalledWith('tenantIdsDistinct'); + expect(changed).toHaveBeenCalledWith('endDateOnOrAfterEffectiveDate'); + }); +}); diff --git a/frontend/src/app/components/lease-amendment-form/lease-amendment-form.ts b/frontend/src/app/components/lease-amendment-form/lease-amendment-form.ts new file mode 100644 index 0000000..79972c8 --- /dev/null +++ b/frontend/src/app/components/lease-amendment-form/lease-amendment-form.ts @@ -0,0 +1,265 @@ +import { Component, effect, ElementRef, inject, input, output, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { FormField, FormRoot, form, max, min, required, validate } from '@angular/forms/signals'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatSelectModule } from '@angular/material/select'; +import { ChargeMethod, Guarantor, Lease, LeaseAmendmentInput } from '../../services/lease.service'; +import { FieldValidationError } from '../../services/http-error-message'; +import { Tenant } from '../../services/tenant.service'; +import { GuarantorForm } from '../guarantor-form/guarantor-form'; + +type AmendmentField = + 'effectiveDate' | 'tenantIds' | 'endDate' | 'monthlyRent' | 'monthlyCharges' | 'dueDay'; + +const serverErrorCodes: Record> = { + effectiveDate: new Set(['not_null', 'not_past']), + tenantIds: new Set(['not_empty', 'assert_true']), + endDate: new Set(['not_null', 'assert_true']), + monthlyRent: new Set(['positive']), + monthlyCharges: new Set(['positive_or_zero']), + dueDay: new Set(['min', 'max']), +}; + +const amendmentFields: AmendmentField[] = [ + 'effectiveDate', + 'tenantIds', + 'endDate', + 'monthlyRent', + 'monthlyCharges', + 'dueDay', +]; + +@Component({ + selector: 'app-lease-amendment-form', + imports: [ + FormsModule, + FormRoot, + FormField, + MatButtonModule, + MatCheckboxModule, + MatFormFieldModule, + MatInputModule, + MatProgressSpinnerModule, + MatSelectModule, + GuarantorForm, + ], + templateUrl: './lease-amendment-form.html', +}) +export class LeaseAmendmentForm { + private readonly element = inject>(ElementRef); + readonly lease = input.required(); + readonly tenants = input.required(); + readonly saving = input(false); + readonly saveError = input(''); + readonly saveFieldErrors = input([]); + readonly cancelled = output(); + readonly submitted = output(); + readonly fieldChanged = output(); + private previousServerErrorSignature = ''; + + readonly amendmentModel = signal({ + effectiveDate: '', + tenantIds: [] as string[], + endDate: '', + monthlyRent: 0, + monthlyCharges: 0, + dueDay: 5, + chargeMethod: 'FLAT_RATE' as ChargeMethod, + indexFlatRate: false, + }); + readonly guarantors = signal([]); + readonly amendmentForm = form(this.amendmentModel, (f) => { + required(f.effectiveDate); + validate(f.effectiveDate, ({ value, valueOf }) => { + const effectiveDate = value(); + const endDate = valueOf(f.endDate); + return !effectiveDate || + (effectiveDate >= browserToday() && + effectiveDate >= this.lease().startDate && + effectiveDate <= this.lease().endDate && + (!endDate || effectiveDate <= endDate)) + ? null + : { + kind: 'amendment-effective-date', + message: + 'La prise d’effet doit être comprise entre aujourd’hui et la fin du bail, sans dépasser sa nouvelle fin.', + }; + }); + required(f.tenantIds); + required(f.endDate); + validate(f.endDate, ({ value, valueOf }) => { + const endDate = value(); + const effectiveDate = valueOf(f.effectiveDate); + return !endDate || + (endDate >= this.lease().startDate && (!effectiveDate || endDate >= effectiveDate)) + ? null + : { + kind: 'amendment-end-date', + message: + 'La fin du bail ne peut pas précéder sa prise d’effet ni son début.', + }; + }); + required(f.monthlyRent); + min(f.monthlyRent, Number.MIN_VALUE); + required(f.monthlyCharges); + min(f.monthlyCharges, 0); + required(f.dueDay); + min(f.dueDay, 1); + max(f.dueDay, 28); + validate(f.dueDay, ({ value }) => + Number.isInteger(value()) + ? null + : { kind: 'whole-day', message: 'Le jour d’échéance doit être un nombre entier.' }, + ); + }); + + constructor() { + effect(() => this.reset(this.lease())); + effect(() => { + const errors = this.saveFieldErrors(); + const signature = errors + .map((error) => `${error.field}:${error.code}`) + .sort() + .join('|'); + if (signature && signature !== this.previousServerErrorSignature) { + queueMicrotask(() => this.focusFirstInvalid()); + } + this.previousServerErrorSignature = signature; + }); + } + + save(): void { + if (this.saving()) return; + this.amendmentForm().markAsTouched(); + if (this.amendmentForm().invalid()) { + queueMicrotask(() => this.focusFirstInvalid()); + return; + } + const model = this.amendmentModel(); + this.submitted.emit({ + ...model, + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'Europe/Paris', + guarantors: this.guarantors(), + indexFlatRate: model.chargeMethod === 'FLAT_RATE' && model.indexFlatRate, + }); + } + + addGuarantor(guarantor: Guarantor): void { + this.guarantors.update((guarantors) => [...guarantors, guarantor]); + } + + removeGuarantor(index: number): void { + this.guarantors.update((guarantors) => guarantors.filter((_, item) => item !== index)); + } + + fieldWasChanged(field: string): void { + this.fieldChanged.emit(field); + if (field === 'tenantIds') this.fieldChanged.emit('tenantIdsDistinct'); + if (field === 'endDate') this.fieldChanged.emit('endDateOnOrAfterEffectiveDate'); + } + + showSaveError(): boolean { + return ( + Boolean(this.saveError()) && + (this.saveFieldErrors().length === 0 || + this.saveFieldErrors().some((error) => !this.isInlineServerError(error))) + ); + } + + fieldError(field: AmendmentField): string { + const model = this.amendmentModel(); + const invalid = + (field === 'effectiveDate' && + (!model.effectiveDate || + model.effectiveDate < browserToday() || + model.effectiveDate < this.lease().startDate || + model.effectiveDate > this.lease().endDate || + (model.endDate !== '' && model.effectiveDate > model.endDate))) || + (field === 'tenantIds' && !model.tenantIds.length) || + (field === 'endDate' && + (!model.endDate || + model.endDate < this.lease().startDate || + (model.effectiveDate !== '' && model.endDate < model.effectiveDate))) || + (field === 'monthlyRent' && model.monthlyRent <= 0) || + (field === 'monthlyCharges' && model.monthlyCharges < 0) || + (field === 'dueDay' && + (!Number.isInteger(model.dueDay) || model.dueDay < 1 || model.dueDay > 28)); + if ((!invalid || !this.isTouched(field)) && !this.hasKnownServerError(field)) { + return ''; + } + return { + effectiveDate: + 'La prise d’effet doit être comprise entre aujourd’hui et la fin du bail, sans dépasser sa nouvelle fin.', + tenantIds: 'Sélectionnez au moins un locataire, sans doublon.', + endDate: 'La fin du bail ne peut pas précéder sa prise d’effet ni son début.', + monthlyRent: 'Le loyer mensuel doit être strictement positif.', + monthlyCharges: 'Les charges mensuelles doivent être positives ou nulles.', + dueDay: 'Le jour d’échéance doit être un entier entre 1 et 28.', + }[field]; + } + + private hasKnownServerError(field: AmendmentField): boolean { + return this.saveFieldErrors().some( + (error) => matchesField(field, error.field) && serverErrorCodes[field].has(error.code), + ); + } + + private isTouched(field: AmendmentField): boolean { + switch (field) { + case 'effectiveDate': + return this.amendmentForm.effectiveDate().touched(); + case 'tenantIds': + return this.amendmentForm.tenantIds().touched(); + case 'endDate': + return this.amendmentForm.endDate().touched(); + case 'monthlyRent': + return this.amendmentForm.monthlyRent().touched(); + case 'monthlyCharges': + return this.amendmentForm.monthlyCharges().touched(); + case 'dueDay': + return this.amendmentForm.dueDay().touched(); + } + } + + private isInlineServerError(error: FieldValidationError): boolean { + return amendmentFields.some( + (field) => matchesField(field, error.field) && serverErrorCodes[field].has(error.code), + ); + } + + private focusFirstInvalid(): void { + const field = amendmentFields.find((item) => this.fieldError(item)); + this.element.nativeElement.querySelector(`#${field ?? ''}`)?.focus(); + } + + private reset(lease: Lease): void { + this.amendmentModel.set({ + effectiveDate: '', + tenantIds: lease.tenantIds, + endDate: lease.endDate, + monthlyRent: lease.monthlyRent, + monthlyCharges: lease.monthlyCharges, + dueDay: lease.dueDay, + chargeMethod: lease.chargeMethod, + indexFlatRate: lease.indexFlatRate, + }); + this.guarantors.set(lease.guarantors); + } +} + +function matchesField(field: AmendmentField, path: string): boolean { + return ( + path === field || + (field === 'tenantIds' && path === 'tenantIdsDistinct') || + (field === 'endDate' && path === 'endDateOnOrAfterEffectiveDate') + ); +} + +function browserToday(): string { + const now = new Date(); + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; +} diff --git a/frontend/src/app/components/lease-card/lease-card.html b/frontend/src/app/components/lease-card/lease-card.html index e6c361a..0ffacfe 100644 --- a/frontend/src/app/components/lease-card/lease-card.html +++ b/frontend/src/app/components/lease-card/lease-card.html @@ -177,6 +177,11 @@ } } @else if (lease().status === 'ACTIVE' || lease().status === 'ARCHIVED') { + @if (lease().status === 'ACTIVE') { + + }