feat: enhance lease and rent payment management

- Added methods in LeaseRepository to find leases by apartment ID and exclude a specific lease ID.
- Introduced a method in RentPaymentRepository to delete all payments by lease ID.
- Updated LeaseService to handle overlapping leases and generate rent payments upon lease creation.
- Implemented rent payment deletion in LeaseService when a lease is deleted.
- Enhanced RentPaymentService to generate rent payments with pro-rata calculations for partial months.
- Modified RentPaymentController to accept rent and charges when marking payments as paid.
- Created RentPaymentPaidRequest DTO for validating rent payment requests.
- Added a RentPaymentValidationDialog for confirming payment amounts before processing.
- Updated frontend components to support new rent payment features, including validation and date filtering for leases.
- Adjusted tests to cover new functionalities and ensure proper integration of rent payment management.
This commit is contained in:
nessar committed 2026-08-17 14:18:15 +02:00
1 parent 479012d661
commit 9cfb55d79c
25 files changed
+389 -175

No files matched your search

@@ -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.");
}
@@ -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<RentPaymentResponse> markPaid(@PathVariable String id) {
return rentPaymentService.markPaid(id);
public ResponseEntity<RentPaymentResponse> markPaid(@PathVariable String id, @RequestBody RentPaymentPaidRequest request) {
return rentPaymentService.markPaid(id, request.getRent(), request.getCharges());
}
@GetMapping("/{id}/receipt")
@@ -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;
}
}
@@ -10,4 +10,6 @@ import java.util.List;
@Repository
public interface LeaseRepository extends MongoRepository<Lease, String> {
List<Lease> findAllByStatus(LeaseStatus status);
List<Lease> findAllByApartmentId(String apartmentId);
List<Lease> findAllByApartmentIdAndIdNot(String apartmentId, String id);
}
@@ -13,4 +13,6 @@ public interface RentPaymentRepository extends MongoRepository<RentPayment, Stri
List<RentPayment> findAllByLeaseIdAndMonthBetween(String leaseId, String firstMonth, String lastMonth);
List<RentPayment> findAllByOrderByDueDateAsc();
void deleteAllByLeaseId(String leaseId);
}
@@ -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<LeaseResponse> 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<LeaseResponse> 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<Lease> 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())
);
}
}
@@ -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<RentPaymentResponse> getAll() {
generateForActiveLeases();
return rentPaymentRepository.findAllByOrderByDueDateAsc().stream().map(this::toResponse).toList();
}
public ResponseEntity<RentPaymentResponse> markPaid(String id) {
public ResponseEntity<RentPaymentResponse> 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<byte[]> 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<Tenant> 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<RentPayment> 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) {
@@ -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");
}
@@ -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());
}
@@ -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<RentPaymentResponse> 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<RentPayment> 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<RentPayment> 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<RentPaymentResponse> response = service.markPaid("payment-1");
ResponseEntity<RentPaymentResponse> 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);
}