diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/config/DataInitializer.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/config/DataInitializer.java index abd897a..a469a37 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/config/DataInitializer.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/config/DataInitializer.java @@ -8,11 +8,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; @Component -@ConditionalOnProperty( - name = "lmnp.data-initializer.enabled", - havingValue = "true", - matchIfMissing = true -) +@ConditionalOnProperty(name = "lmnp.data-initializer.enabled", havingValue = "true", matchIfMissing = true) public class DataInitializer implements ApplicationRunner { private final LandlordRepository landlordRepository; @@ -25,13 +21,12 @@ public class DataInitializer implements ApplicationRunner { public void run(ApplicationArguments args) throws Exception { if (!landlordRepository.existsById("default-landlord")) { Landlord defaultLandlord = new Landlord( - "default-landlord", - "Bailleur", - "LMNP", - "bailleur@example.com", - "0102030405", - "123 Rue de la Gestion, Paris" - ); + "default-landlord", + "Bailleur", + "LMNP", + "bailleur@example.com", + "0102030405", + "123 Rue de la Gestion, Paris"); landlordRepository.save(defaultLandlord); System.out.println("Default landlord profile initialized in database."); } diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/RentPaymentController.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/RentPaymentController.java index 14eb4bd..6c2473c 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/RentPaymentController.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/RentPaymentController.java @@ -6,6 +6,8 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import fr.nessar.lmnporganizer.backend.dto.RentPaymentPaidRequest; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -26,8 +28,8 @@ public class RentPaymentController { } @PutMapping("/{id}/paid") - public ResponseEntity markPaid(@PathVariable String id) { - return rentPaymentService.markPaid(id); + public ResponseEntity markPaid(@PathVariable String id, @RequestBody RentPaymentPaidRequest request) { + return rentPaymentService.markPaid(id, request.getRent(), request.getCharges()); } @GetMapping("/{id}/receipt") diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/dto/RentPaymentPaidRequest.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/dto/RentPaymentPaidRequest.java new file mode 100644 index 0000000..17631cc --- /dev/null +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/dto/RentPaymentPaidRequest.java @@ -0,0 +1,37 @@ +package fr.nessar.lmnporganizer.backend.dto; + +import jakarta.validation.constraints.NotNull; +import java.math.BigDecimal; + +public class RentPaymentPaidRequest { + + @NotNull + private BigDecimal rent; + + @NotNull + private BigDecimal charges; + + public RentPaymentPaidRequest() { + } + + public RentPaymentPaidRequest(BigDecimal rent, BigDecimal charges) { + this.rent = rent; + this.charges = charges; + } + + public BigDecimal getRent() { + return rent; + } + + public void setRent(BigDecimal rent) { + this.rent = rent; + } + + public BigDecimal getCharges() { + return charges; + } + + public void setCharges(BigDecimal charges) { + this.charges = charges; + } +} diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/repository/LeaseRepository.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/repository/LeaseRepository.java index 4b4c8de..a05a68e 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/repository/LeaseRepository.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/repository/LeaseRepository.java @@ -10,4 +10,6 @@ import java.util.List; @Repository public interface LeaseRepository extends MongoRepository { List findAllByStatus(LeaseStatus status); + List findAllByApartmentId(String apartmentId); + List findAllByApartmentIdAndIdNot(String apartmentId, String id); } diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/repository/RentPaymentRepository.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/repository/RentPaymentRepository.java index 53825ac..867fe57 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/repository/RentPaymentRepository.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/repository/RentPaymentRepository.java @@ -13,4 +13,6 @@ public interface RentPaymentRepository extends MongoRepository findAllByLeaseIdAndMonthBetween(String leaseId, String firstMonth, String lastMonth); List findAllByOrderByDueDateAsc(); + + void deleteAllByLeaseId(String leaseId); } diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/LeaseService.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/LeaseService.java index 62dcb5e..b523c2c 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/LeaseService.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/LeaseService.java @@ -28,16 +28,19 @@ public class LeaseService { private final LeasePdfService leasePdfService; private final PhoneNumberNormalizer phoneNumberNormalizer; private final LeaseMapper leaseMapper; + private final RentPaymentService rentPaymentService; public LeaseService(LeaseRepository leaseRepository, ApartmentRepository apartmentRepository, TenantRepository tenantRepository, LeasePdfService leasePdfService, - PhoneNumberNormalizer phoneNumberNormalizer, LeaseMapper leaseMapper) { + PhoneNumberNormalizer phoneNumberNormalizer, LeaseMapper leaseMapper, + RentPaymentService rentPaymentService) { this.leaseRepository = leaseRepository; this.apartmentRepository = apartmentRepository; this.tenantRepository = tenantRepository; this.leasePdfService = leasePdfService; this.phoneNumberNormalizer = phoneNumberNormalizer; this.leaseMapper = leaseMapper; + this.rentPaymentService = rentPaymentService; } public List getAll() { @@ -54,9 +57,14 @@ public class LeaseService { if (!referencesExist(lease)) { return ResponseEntity.badRequest().build(); } + if (hasOverlappingLease(lease, null)) { + return ResponseEntity.status(HttpStatus.CONFLICT).build(); + } lease.setLandlordId("default-landlord"); normalizeGuarantorPhones(lease); - return ResponseEntity.status(HttpStatus.CREATED).body(leaseMapper.toResponse(leaseRepository.save(lease))); + lease = leaseRepository.save(lease); + rentPaymentService.generateForLease(lease); + return ResponseEntity.status(HttpStatus.CREATED).body(leaseMapper.toResponse(lease)); } public ResponseEntity update(String id, LeaseRequest request) { @@ -64,6 +72,9 @@ public class LeaseService { if (!referencesExist(details)) { return ResponseEntity.badRequest().build(); } + if (hasOverlappingLease(details, id)) { + return ResponseEntity.status(HttpStatus.CONFLICT).build(); + } return leaseRepository.findById(id).map(lease -> { leaseMapper.update(request, lease); lease.setLandlordId("default-landlord"); @@ -76,6 +87,7 @@ public class LeaseService { if (!leaseRepository.existsById(id)) { return ResponseEntity.notFound().build(); } + rentPaymentService.deleteForLease(id); leaseRepository.deleteById(id); return ResponseEntity.noContent().build(); } @@ -114,4 +126,17 @@ public class LeaseService { private void normalizeGuarantorPhones(Lease lease) { lease.getGuarantors().forEach(guarantor -> guarantor.setPhone(phoneNumberNormalizer.normalize(guarantor.getPhone()))); } + + private boolean hasOverlappingLease(Lease newLease, String excludeId) { + if (newLease.getStartDate() == null || newLease.getEndDate() == null) return false; + List existingLeases = excludeId == null + ? leaseRepository.findAllByApartmentId(newLease.getApartmentId()) + : leaseRepository.findAllByApartmentIdAndIdNot(newLease.getApartmentId(), excludeId); + + return existingLeases.stream().anyMatch(existing -> + existing.getStartDate() != null && existing.getEndDate() != null && + !newLease.getStartDate().isAfter(existing.getEndDate()) && + !newLease.getEndDate().isBefore(existing.getStartDate()) + ); + } } diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/RentPaymentService.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/RentPaymentService.java index 4a85dcf..2dcffbd 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/RentPaymentService.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/RentPaymentService.java @@ -4,7 +4,6 @@ import fr.nessar.lmnporganizer.backend.dto.RentPaymentResponse; import fr.nessar.lmnporganizer.backend.model.Apartment; import fr.nessar.lmnporganizer.backend.model.Landlord; import fr.nessar.lmnporganizer.backend.model.Lease; -import fr.nessar.lmnporganizer.backend.model.LeaseStatus; import fr.nessar.lmnporganizer.backend.model.RentPayment; import fr.nessar.lmnporganizer.backend.model.Tenant; import fr.nessar.lmnporganizer.backend.repository.ApartmentRepository; @@ -38,9 +37,9 @@ public class RentPaymentService { private final Clock clock; public RentPaymentService(RentPaymentRepository rentPaymentRepository, LeaseRepository leaseRepository, - ApartmentRepository apartmentRepository, LandlordRepository landlordRepository, - TenantRepository tenantRepository, - RentReceiptPdfService rentReceiptPdfService, Clock clock) { + ApartmentRepository apartmentRepository, LandlordRepository landlordRepository, + TenantRepository tenantRepository, + RentReceiptPdfService rentReceiptPdfService, Clock clock) { this.rentPaymentRepository = rentPaymentRepository; this.leaseRepository = leaseRepository; this.apartmentRepository = apartmentRepository; @@ -51,13 +50,14 @@ public class RentPaymentService { } public List getAll() { - generateForActiveLeases(); return rentPaymentRepository.findAllByOrderByDueDateAsc().stream().map(this::toResponse).toList(); } - public ResponseEntity markPaid(String id) { + public ResponseEntity markPaid(String id, java.math.BigDecimal rent, java.math.BigDecimal charges) { return rentPaymentRepository.findById(id).map(payment -> { if (payment.getPaidAt() == null) { + payment.setRent(rent); + payment.setCharges(charges); payment.setPaidAt(LocalDate.now(clock)); payment = rentPaymentRepository.save(payment); } @@ -67,11 +67,14 @@ public class RentPaymentService { public ResponseEntity downloadReceipt(String id) { RentPayment payment = rentPaymentRepository.findById(id).orElse(null); - if (payment == null) return ResponseEntity.notFound().build(); - if (payment.getPaidAt() == null) return ResponseEntity.status(HttpStatus.CONFLICT).build(); + if (payment == null) + return ResponseEntity.notFound().build(); + if (payment.getPaidAt() == null) + return ResponseEntity.status(HttpStatus.CONFLICT).build(); Lease lease = leaseRepository.findById(payment.getLeaseId()).orElse(null); - if (lease == null) return ResponseEntity.notFound().build(); + if (lease == null) + return ResponseEntity.notFound().build(); Apartment apartment = apartmentRepository.findById(lease.getApartmentId()).orElse(null); Landlord landlord = landlordRepository.findById(payment.getLandlordId()).orElse(null); List tenants = tenantsFor(lease.getTenantIds()); @@ -86,14 +89,15 @@ public class RentPaymentService { .body(rentReceiptPdfService.generate(payment, lease, apartment, landlord, tenants)); } - private void generateForActiveLeases() { - leaseRepository.findAllByStatus(LeaseStatus.ACTIVE).forEach(this::generateForLease); + public void deleteForLease(String leaseId) { + rentPaymentRepository.deleteAllByLeaseId(leaseId); } - private void generateForLease(Lease lease) { + public void generateForLease(Lease lease) { if (lease.getStartDate() == null || lease.getEndDate() == null || lease.getMonthlyRent() == null || lease.getMonthlyCharges() == null - || lease.getEndDate().isBefore(lease.getStartDate())) return; + || lease.getEndDate().isBefore(lease.getStartDate())) + return; YearMonth firstMonth = YearMonth.from(lease.getStartDate()); YearMonth lastMonth = YearMonth.from(lease.getEndDate()); @@ -103,15 +107,41 @@ public class RentPaymentService { List missingPayments = new ArrayList<>(); for (YearMonth month = firstMonth; !month.isAfter(lastMonth); month = month.plusMonths(1)) { if (!existingMonths.contains(month.toString())) { + java.math.BigDecimal rent = lease.getMonthlyRent(); + java.math.BigDecimal charges = lease.getMonthlyCharges(); + + if (month.equals(firstMonth) && month.equals(lastMonth)) { + int daysOccupied = lease.getEndDate().getDayOfMonth() - lease.getStartDate().getDayOfMonth() + 1; + int daysInMonth = month.lengthOfMonth(); + rent = proRata(rent, daysInMonth, daysOccupied); + charges = proRata(charges, daysInMonth, daysOccupied); + } else if (month.equals(firstMonth)) { + int daysOccupied = month.lengthOfMonth() - lease.getStartDate().getDayOfMonth() + 1; + int daysInMonth = month.lengthOfMonth(); + rent = proRata(rent, daysInMonth, daysOccupied); + charges = proRata(charges, daysInMonth, daysOccupied); + } else if (month.equals(lastMonth)) { + int daysOccupied = lease.getEndDate().getDayOfMonth(); + int daysInMonth = month.lengthOfMonth(); + rent = proRata(rent, daysInMonth, daysOccupied); + charges = proRata(charges, daysInMonth, daysOccupied); + } + missingPayments.add(new RentPayment(null, lease.getLandlordId(), lease.getId(), month.toString(), - dueDate(lease, month), lease.getMonthlyRent(), lease.getMonthlyCharges(), null)); + dueDate(lease, month), rent, charges, null)); } } - if (!missingPayments.isEmpty()) rentPaymentRepository.saveAll(missingPayments); + if (!missingPayments.isEmpty()) + rentPaymentRepository.saveAll(missingPayments); + } + + private java.math.BigDecimal proRata(java.math.BigDecimal amount, int daysInMonth, int daysOccupied) { + return amount.multiply(new java.math.BigDecimal(daysOccupied)) + .divide(new java.math.BigDecimal(daysInMonth), 2, java.math.RoundingMode.HALF_UP); } private LocalDate dueDate(Lease lease, YearMonth month) { - return month.atDay(Math.min(lease.getStartDate().getDayOfMonth(), month.lengthOfMonth())); + return month.plusMonths(1).atDay(5); } private RentPaymentResponse toResponse(RentPayment payment) { diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/LeaseControllerTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/LeaseControllerTest.java index cfb1227..43e8591 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/LeaseControllerTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/LeaseControllerTest.java @@ -8,6 +8,7 @@ import fr.nessar.lmnporganizer.backend.model.Tenant; import fr.nessar.lmnporganizer.backend.repository.ApartmentRepository; import fr.nessar.lmnporganizer.backend.repository.LeaseRepository; import fr.nessar.lmnporganizer.backend.repository.TenantRepository; +import fr.nessar.lmnporganizer.backend.service.RentPaymentService; import org.apache.pdfbox.Loader; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.text.PDFTextStripper; @@ -45,6 +46,9 @@ class LeaseControllerTest { @MockitoBean private TenantRepository tenantRepository; + @MockitoBean + private RentPaymentService rentPaymentService; + @Test void whenCreateLease_thenLinksApartmentTenantsAndGuarantors() throws Exception { when(apartmentRepository.existsById("apartment-1")).thenReturn(true); @@ -126,6 +130,7 @@ class LeaseControllerTest { mockMvc.perform(delete("/api/leases/lease-1").with(oidcLogin())) .andExpect(status().isNoContent()); + verify(rentPaymentService).deleteForLease("lease-1"); verify(leaseRepository).deleteById("lease-1"); } diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/RentPaymentControllerTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/RentPaymentControllerTest.java index 27d477c..ca5c123 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/RentPaymentControllerTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/RentPaymentControllerTest.java @@ -80,7 +80,10 @@ class RentPaymentControllerTest { when(rentPaymentRepository.findById("payment-1")).thenReturn(Optional.of(payment(null))); when(rentPaymentRepository.save(any(RentPayment.class))).thenAnswer(invocation -> invocation.getArgument(0)); - mockMvc.perform(put("/api/rent-payments/payment-1/paid").with(oidcLogin())) + mockMvc.perform(put("/api/rent-payments/payment-1/paid") + .contentType(org.springframework.http.MediaType.APPLICATION_JSON) + .content("{\"rent\": 850.00, \"charges\": 50.00}") + .with(oidcLogin())) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value("PAID")); } @@ -89,7 +92,10 @@ class RentPaymentControllerTest { void whenUnknownRentPaymentIsMarkedPaid_thenNotFound() throws Exception { when(rentPaymentRepository.findById("missing")).thenReturn(Optional.empty()); - mockMvc.perform(put("/api/rent-payments/missing/paid").with(oidcLogin())) + mockMvc.perform(put("/api/rent-payments/missing/paid") + .contentType(org.springframework.http.MediaType.APPLICATION_JSON) + .content("{\"rent\": 850.00, \"charges\": 50.00}") + .with(oidcLogin())) .andExpect(status().isNotFound()); } diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/backend/service/RentPaymentServiceTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/backend/service/RentPaymentServiceTest.java index 422090b..f66efc5 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/backend/service/RentPaymentServiceTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/backend/service/RentPaymentServiceTest.java @@ -71,46 +71,27 @@ class RentPaymentServiceTest { } @Test - void generatesARecordForEachMonthOfAnActiveLease() { + void generatesARecordForEachMonthOfALease() { Lease lease = lease(LocalDate.of(2099, 1, 31), LocalDate.of(2099, 3, 31)); - when(leaseRepository.findAllByStatus(LeaseStatus.ACTIVE)).thenReturn(List.of(lease)); when(rentPaymentRepository.findAllByLeaseIdAndMonthBetween("lease-1", "2099-01", "2099-03")) .thenReturn(List.of()); - when(rentPaymentRepository.findAllByOrderByDueDateAsc()).thenReturn(List.of( - payment("payment-1", "2099-01", LocalDate.of(2099, 1, 31), null), - payment("payment-2", "2099-02", LocalDate.of(2099, 2, 28), null), - payment("payment-3", "2099-03", LocalDate.of(2099, 3, 31), null))); - List payments = service.getAll(); + service.generateForLease(lease); - assertEquals(3, payments.size()); - assertEquals("2099-01", payments.getFirst().month()); - assertEquals(new BigDecimal("900.00"), payments.getFirst().total()); - assertEquals(RentPaymentStatus.UPCOMING, payments.getFirst().status()); verify(rentPaymentRepository).saveAll(argThat(generatedPayments -> { List records = StreamSupport.stream(generatedPayments.spliterator(), false).toList(); return records.stream().map(RentPayment::getMonth).toList().equals(List.of("2099-01", "2099-02", "2099-03")) - && records.get(1).getDueDate().equals(LocalDate.of(2099, 2, 28)); + && records.get(1).getDueDate().equals(LocalDate.of(2099, 3, 5)) + && records.get(0).getRent().equals(new BigDecimal("27.42")) + && records.get(0).getCharges().equals(new BigDecimal("1.61")) + && records.get(2).getRent().equals(new BigDecimal("850.00")); })); } @Test - void generatesEachRentPaymentOnlyOnceAcrossRepeatedCalls() { - Lease lease = lease(LocalDate.of(2099, 1, 31), LocalDate.of(2099, 3, 31)); - List existingPayments = List.of( - payment("payment-1", "2099-01", LocalDate.of(2099, 1, 31), null), - payment("payment-2", "2099-02", LocalDate.of(2099, 2, 28), null), - payment("payment-3", "2099-03", LocalDate.of(2099, 3, 31), null)); - when(leaseRepository.findAllByStatus(LeaseStatus.ACTIVE)).thenReturn(List.of(lease)); - when(rentPaymentRepository.findAllByLeaseIdAndMonthBetween("lease-1", "2099-01", "2099-03")) - .thenReturn(List.of()) - .thenReturn(existingPayments); - when(rentPaymentRepository.findAllByOrderByDueDateAsc()).thenReturn(existingPayments); - - service.getAll(); - service.getAll(); - - verify(rentPaymentRepository, times(1)).saveAll(anyList()); + void deletesRecordsForALease() { + service.deleteForLease("lease-1"); + verify(rentPaymentRepository).deleteAllByLeaseId("lease-1"); } @Test @@ -119,7 +100,7 @@ class RentPaymentServiceTest { when(rentPaymentRepository.findById("payment-1")).thenReturn(Optional.of(payment)); when(rentPaymentRepository.save(any(RentPayment.class))).thenAnswer(invocation -> invocation.getArgument(0)); - ResponseEntity response = service.markPaid("payment-1"); + ResponseEntity response = service.markPaid("payment-1", new BigDecimal("850.00"), new BigDecimal("50.00")); assertEquals(HttpStatus.OK, response.getStatusCode()); assertNotNull(response.getBody()); @@ -127,7 +108,7 @@ class RentPaymentServiceTest { assertEquals(LocalDate.of(2026, 7, 17), response.getBody().paidAt()); verify(rentPaymentRepository).save(payment); - service.markPaid("payment-1"); + service.markPaid("payment-1", new BigDecimal("850.00"), new BigDecimal("50.00")); verify(rentPaymentRepository, times(1)).save(payment); } diff --git a/frontend/src/app/components/lease-card/lease-card.html b/frontend/src/app/components/lease-card/lease-card.html index e90bfd7..b4aeba2 100644 --- a/frontend/src/app/components/lease-card/lease-card.html +++ b/frontend/src/app/components/lease-card/lease-card.html @@ -8,7 +8,7 @@
Période
-
{{ lease().startDate }} → {{ lease().endDate }}
+
{{ lease().startDate | date:'dd MMMM yyyy' }} → {{ lease().endDate | date:'dd MMMM yyyy' }}
Charges
diff --git a/frontend/src/app/components/lease-card/lease-card.ts b/frontend/src/app/components/lease-card/lease-card.ts index b5ba837..7363029 100644 --- a/frontend/src/app/components/lease-card/lease-card.ts +++ b/frontend/src/app/components/lease-card/lease-card.ts @@ -1,4 +1,5 @@ import { Component, input, output } from '@angular/core'; +import { DatePipe } from '@angular/common'; import { MatButtonModule } from '@angular/material/button'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { Apartment } from '../../services/apartment.service'; @@ -7,7 +8,7 @@ import { Tenant } from '../../services/tenant.service'; @Component({ selector: 'app-lease-card', - imports: [MatButtonModule, MatProgressSpinnerModule], + imports: [MatButtonModule, MatProgressSpinnerModule, DatePipe], templateUrl: './lease-card.html', styleUrl: './lease-card.css', }) diff --git a/frontend/src/app/components/lease-form/lease-form.html b/frontend/src/app/components/lease-form/lease-form.html index 91d83fc..4c2c0ef 100644 --- a/frontend/src/app/components/lease-form/lease-form.html +++ b/frontend/src/app/components/lease-form/lease-form.html @@ -87,6 +87,7 @@ >(); readonly tenants = input.required(); + readonly leases = input([]); readonly saving = input(false); readonly saveError = input(''); readonly cancelled = output(); @@ -43,6 +44,24 @@ export class LeaseForm { readonly guarantors = signal([]); readonly error = signal(''); + readonly dateFilter = (d: Date | null): boolean => { + if (!d) return false; + const aptId = this.apartmentId(); + if (!aptId) return true; + + const time = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); + + const overlapping = this.leases().some(lease => { + if (lease.apartmentId !== aptId) return false; + const [startYear, startMonth, startDay] = lease.startDate.split('-').map(Number); + const [endYear, endMonth, endDay] = lease.endDate.split('-').map(Number); + const start = new Date(startYear, startMonth - 1, startDay).getTime(); + const end = new Date(endYear, endMonth - 1, endDay).getTime(); + return time >= start && time <= end; + }); + return !overlapping; + }; + addGuarantor(guarantor: Guarantor): void { this.guarantors.update((guarantors) => [...guarantors, guarantor]); } diff --git a/frontend/src/app/components/leases-page/leases-page.html b/frontend/src/app/components/leases-page/leases-page.html index 9760f05..b3ec768 100644 --- a/frontend/src/app/components/leases-page/leases-page.html +++ b/frontend/src/app/components/leases-page/leases-page.html @@ -75,6 +75,7 @@ Valider le paiement +
+ +

Vérifiez ou modifiez les montants payés avant de valider l'échéance.

+
+ + Loyer principal (€) + + + + Charges (€) + + +
+
+ + + + +
diff --git a/frontend/src/app/components/rent-payment-validation-dialog/rent-payment-validation-dialog.ts b/frontend/src/app/components/rent-payment-validation-dialog/rent-payment-validation-dialog.ts new file mode 100644 index 0000000..16e6cef --- /dev/null +++ b/frontend/src/app/components/rent-payment-validation-dialog/rent-payment-validation-dialog.ts @@ -0,0 +1,45 @@ +import { Component, inject } from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; + +export interface RentPaymentValidationDialogData { + rent: number; + charges: number; +} + +@Component({ + selector: 'app-rent-payment-validation-dialog', + imports: [ + ReactiveFormsModule, + MatDialogModule, + MatButtonModule, + MatFormFieldModule, + MatInputModule, + ], + templateUrl: './rent-payment-validation-dialog.html', + styleUrl: './rent-payment-validation-dialog.css', +}) +export class RentPaymentValidationDialog { + readonly data = inject(MAT_DIALOG_DATA); + readonly dialogRef = inject(MatDialogRef); + + readonly form = new FormGroup({ + rent: new FormControl(this.data.rent, { + nonNullable: true, + validators: [Validators.required, Validators.min(0)], + }), + charges: new FormControl(this.data.charges, { + nonNullable: true, + validators: [Validators.required, Validators.min(0)], + }), + }); + + submit(): void { + if (this.form.valid) { + this.dialogRef.close(this.form.getRawValue()); + } + } +} diff --git a/frontend/src/app/components/rent-payments-page/rent-payments-page.html b/frontend/src/app/components/rent-payments-page/rent-payments-page.html index ad883c7..f8ff2ad 100644 --- a/frontend/src/app/components/rent-payments-page/rent-payments-page.html +++ b/frontend/src/app/components/rent-payments-page/rent-payments-page.html @@ -1,100 +1,101 @@
-
-
-

Location

-

Mes loyers

-

Suivez les échéances et téléchargez les quittances après règlement.

-
-
+
+
+

Location

+

Mes loyers

+

Suivez les échéances et téléchargez les quittances après règlement.

+
+
-
- @if (!contentReady() && loadError()) { - - } @else if (!contentReady()) { -
- @for (placeholder of [1, 2, 3]; track placeholder) { - - } -
- } @else { - @if (contentLoading()) { - - } - @if (loadError()) { - - } - @if (workspace.rentPayments().length === 0) { -
-

Échéancier

-

Aucune échéance de loyer

-

Les échéances sont créées automatiquement pour les baux actifs.

-
- } @else { -
- @for (payment of workspace.rentPayments(); track payment.id) { -
-
- - {{ statusLabel(payment.status) }} - - {{ payment.total.toLocaleString('fr-FR') }} € -
-

{{ apartmentName(payment) }}

-

{{ monthLabel(payment.month) }}

-
-
Loyer
{{ payment.rent.toLocaleString('fr-FR') }} €
-
Charges
{{ payment.charges.toLocaleString('fr-FR') }} €
-
Échéance
{{ payment.dueDate }}
-
- @if (workspace.rentPaymentSaveError(payment.id) || workspace.rentReceiptDownloadError(payment.id)) { - - } -
- @if (payment.status === 'PAID') { - - } @else { - - } -
-
- } -
- } - } -
-
+
+ @if (!contentReady() && loadError()) { + + } @else if (!contentReady()) { +
+ @for (placeholder of [1, 2, 3]; track placeholder) { + + } +
+ } @else { + @if (contentLoading()) { + + } + @if (loadError()) { + + } + @if (workspace.rentPayments().length === 0) { +
+

Échéancier

+

Aucune échéance de loyer

+

Les échéances sont créées automatiquement pour les baux actifs.

+
+ } @else { +
+ @for (payment of workspace.rentPayments(); track payment.id) { +
+
+ + {{ statusLabel(payment.status) }} + + {{ payment.total.toLocaleString('fr-FR') }} € +
+

{{ apartmentName(payment) }}

+

{{ monthLabel(payment.month) }}

+
+
+
Loyer
+
{{ payment.rent.toLocaleString('fr-FR') }} €
+
+
+
Charges
+
{{ payment.charges.toLocaleString('fr-FR') }} €
+
+
+
Échéance
+
{{ payment.dueDate | date:'dd MMMM yyyy' }}
+
+
+ @if (workspace.rentPaymentSaveError(payment.id) || workspace.rentReceiptDownloadError(payment.id)) { + + } +
+ @if (payment.status === 'PAID') { + + } @else { + + } +
+
+ } +
+ } + } +
+ \ No newline at end of file diff --git a/frontend/src/app/components/rent-payments-page/rent-payments-page.spec.ts b/frontend/src/app/components/rent-payments-page/rent-payments-page.spec.ts index 93aa3b1..1d5d862 100644 --- a/frontend/src/app/components/rent-payments-page/rent-payments-page.spec.ts +++ b/frontend/src/app/components/rent-payments-page/rent-payments-page.spec.ts @@ -1,5 +1,7 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { of } from 'rxjs'; import { WorkspaceService } from '../../services/workspace.service'; import { RentPaymentsPage } from './rent-payments-page'; @@ -34,8 +36,14 @@ describe('RentPaymentsPage', () => { rentReceiptDownloading: vi.fn(() => false), rentReceiptDownloadError: vi.fn(() => ''), }; + const dialog = { + open: vi.fn(() => ({ afterClosed: () => of({ rent: 800, charges: 50 }) })), + }; TestBed.configureTestingModule({ - providers: [{ provide: WorkspaceService, useValue: workspace }], + providers: [ + { provide: WorkspaceService, useValue: workspace }, + { provide: MatDialog, useValue: dialog }, + ], }); const fixture = TestBed.createComponent(RentPaymentsPage); @@ -44,6 +52,7 @@ describe('RentPaymentsPage', () => { expect(workspace.loadRentPayments).toHaveBeenCalled(); expect(fixture.nativeElement.textContent).toContain('T2'); fixture.nativeElement.querySelector('button').click(); - expect(workspace.markRentPaymentPaid).toHaveBeenCalledWith('payment-1'); + expect(dialog.open).toHaveBeenCalled(); + expect(workspace.markRentPaymentPaid).toHaveBeenCalledWith('payment-1', 800, 50); }); }); diff --git a/frontend/src/app/components/rent-payments-page/rent-payments-page.ts b/frontend/src/app/components/rent-payments-page/rent-payments-page.ts index 1779ad9..8310216 100644 --- a/frontend/src/app/components/rent-payments-page/rent-payments-page.ts +++ b/frontend/src/app/components/rent-payments-page/rent-payments-page.ts @@ -4,15 +4,19 @@ import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { RentPayment, RentPaymentStatus } from '../../services/rent-payment.service'; import { WorkspaceService } from '../../services/workspace.service'; +import { MatDialog } from '@angular/material/dialog'; +import { RentPaymentValidationDialog } from '../rent-payment-validation-dialog/rent-payment-validation-dialog'; +import { DatePipe } from '@angular/common'; @Component({ selector: 'app-rent-payments-page', - imports: [MatButtonModule, MatProgressBarModule, MatProgressSpinnerModule], + imports: [MatButtonModule, MatProgressBarModule, MatProgressSpinnerModule, DatePipe], templateUrl: './rent-payments-page.html', styleUrl: './rent-payments-page.css', }) export class RentPaymentsPage { readonly workspace = inject(WorkspaceService); + private readonly dialog = inject(MatDialog); readonly contentReady = computed( () => this.workspace.rentPaymentLoadState().loaded && @@ -56,4 +60,18 @@ export class RentPaymentsPage { this.workspace.loadLeases(); this.workspace.loadApartments(); } + + markPaid(payment: RentPayment): void { + this.dialog + .open(RentPaymentValidationDialog, { + data: { rent: payment.rent, charges: payment.charges }, + width: '400px', + }) + .afterClosed() + .subscribe((result) => { + if (result) { + this.workspace.markRentPaymentPaid(payment.id, result.rent, result.charges); + } + }); + } } diff --git a/frontend/src/app/services/rent-payment.service.spec.ts b/frontend/src/app/services/rent-payment.service.spec.ts index a3e50ef..107e616 100644 --- a/frontend/src/app/services/rent-payment.service.spec.ts +++ b/frontend/src/app/services/rent-payment.service.spec.ts @@ -15,11 +15,11 @@ describe('RentPaymentService', () => { const service = TestBed.inject(RentPaymentService); service.getRentPayments().subscribe(); - service.markPaid('payment-1').subscribe(); + service.markPaid('payment-1', 850, 50).subscribe(); service.downloadReceipt('payment-1').subscribe(); expect(http.get).toHaveBeenCalledWith('/api/rent-payments'); - expect(http.put).toHaveBeenCalledWith('/api/rent-payments/payment-1/paid', {}); + expect(http.put).toHaveBeenCalledWith('/api/rent-payments/payment-1/paid', { rent: 850, charges: 50 }); expect(http.get).toHaveBeenCalledWith('/api/rent-payments/payment-1/receipt', { responseType: 'blob', }); diff --git a/frontend/src/app/services/rent-payment.service.ts b/frontend/src/app/services/rent-payment.service.ts index 2cfafce..6b288b0 100644 --- a/frontend/src/app/services/rent-payment.service.ts +++ b/frontend/src/app/services/rent-payment.service.ts @@ -24,8 +24,8 @@ export class RentPaymentService { return this.http.get('/api/rent-payments'); } - markPaid(id: string): Observable { - return this.http.put(`/api/rent-payments/${id}/paid`, {}); + markPaid(id: string, rent: number, charges: number): Observable { + return this.http.put(`/api/rent-payments/${id}/paid`, { rent, charges }); } downloadReceipt(id: string): Observable { diff --git a/frontend/src/app/services/workspace.service.spec.ts b/frontend/src/app/services/workspace.service.spec.ts index 71cf498..55e5b40 100644 --- a/frontend/src/app/services/workspace.service.spec.ts +++ b/frontend/src/app/services/workspace.service.spec.ts @@ -348,7 +348,7 @@ describe('WorkspaceService', () => { service.saveLease(lease); service.deleteLease(lease.id); service.loadRentPayments(); - service.markRentPaymentPaid(rentPayment.id); + service.markRentPaymentPaid(rentPayment.id, rentPayment.rent, rentPayment.charges); expect(service.apartments()).toEqual([apartment]); expect(service.tenants()).toEqual([tenant]); @@ -357,7 +357,7 @@ describe('WorkspaceService', () => { expect(apartments.updateApartment).toHaveBeenCalledWith('apartment-1', apartment); expect(tenants.updateTenant).toHaveBeenCalledWith('tenant-1', tenant); expect(leases.deleteLease).toHaveBeenCalledWith('lease-1'); - expect(rentPayments.markPaid).toHaveBeenCalledWith('payment-1'); + expect(rentPayments.markPaid).toHaveBeenCalledWith('payment-1', rentPayment.rent, rentPayment.charges); }); it('opens a confirmation pop-in before deleting a resource', () => { diff --git a/frontend/src/app/services/workspace.service.ts b/frontend/src/app/services/workspace.service.ts index b417e63..7a2daf5 100644 --- a/frontend/src/app/services/workspace.service.ts +++ b/frontend/src/app/services/workspace.service.ts @@ -242,8 +242,8 @@ export class WorkspaceService { this.rentPaymentResource.load(); } - markRentPaymentPaid(id: string): void { - this.rentPaymentResource.save(id, this.rentPaymentService.markPaid(id)); + markRentPaymentPaid(id: string, rent: number, charges: number): void { + this.rentPaymentResource.save(id, this.rentPaymentService.markPaid(id, rent, charges)); } rentPaymentSaving(id: string): boolean {