feat: add rent payment schedules and receipts

This commit is contained in:
nessar authored and Nessar committed 2026-08-17 14:18:15 +02:00
1 parent f876639c02
commit 47cb4bcdaf
26 files changed
+1118 -3

No files matched your search

@@ -22,6 +22,7 @@ public class SecurityConfig {
.requestMatchers("/api/apartments", "/api/apartments/**").authenticated()
.requestMatchers("/api/tenants", "/api/tenants/**").authenticated()
.requestMatchers("/api/leases", "/api/leases/**").authenticated()
.requestMatchers("/api/rent-payments", "/api/rent-payments/**").authenticated()
.requestMatchers("/api/user").permitAll()
.anyRequest().permitAll()
)
@@ -0,0 +1,37 @@
package fr.nessar.lmnporganizer.backend.controller;
import fr.nessar.lmnporganizer.backend.dto.RentPaymentResponse;
import fr.nessar.lmnporganizer.backend.service.RentPaymentService;
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.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/rent-payments")
public class RentPaymentController {
private final RentPaymentService rentPaymentService;
public RentPaymentController(RentPaymentService rentPaymentService) {
this.rentPaymentService = rentPaymentService;
}
@GetMapping
public List<RentPaymentResponse> getRentPayments() {
return rentPaymentService.getAll();
}
@PutMapping("/{id}/paid")
public ResponseEntity<RentPaymentResponse> markPaid(@PathVariable String id) {
return rentPaymentService.markPaid(id);
}
@GetMapping("/{id}/receipt")
public ResponseEntity<byte[]> downloadReceipt(@PathVariable String id) {
return rentPaymentService.downloadReceipt(id);
}
}
@@ -0,0 +1,19 @@
package fr.nessar.lmnporganizer.backend.dto;
import fr.nessar.lmnporganizer.backend.model.RentPaymentStatus;
import java.math.BigDecimal;
import java.time.LocalDate;
public record RentPaymentResponse(
String id,
String leaseId,
String month,
LocalDate dueDate,
BigDecimal rent,
BigDecimal charges,
BigDecimal total,
RentPaymentStatus status,
LocalDate paidAt
) {
}
@@ -0,0 +1,65 @@
package fr.nessar.lmnporganizer.backend.model;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.CompoundIndex;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
import java.math.BigDecimal;
import java.time.LocalDate;
@Document(collection = "rentPayments")
@CompoundIndex(name = "lease_month", def = "{'leaseId': 1, 'month': 1}", unique = true)
public class RentPayment {
@Id
private String id;
@Indexed
private String landlordId;
@Indexed
private String leaseId;
private String month;
private LocalDate dueDate;
private BigDecimal rent;
private BigDecimal charges;
private LocalDate paidAt;
public RentPayment() {
}
public RentPayment(String id, String landlordId, String leaseId, String month, LocalDate dueDate,
BigDecimal rent, BigDecimal charges, LocalDate paidAt) {
this.id = id;
this.landlordId = landlordId;
this.leaseId = leaseId;
this.month = month;
this.dueDate = dueDate;
this.rent = rent;
this.charges = charges;
this.paidAt = paidAt;
}
public RentPaymentStatus statusOn(LocalDate date) {
if (paidAt != null) return RentPaymentStatus.PAID;
return dueDate.isBefore(date) ? RentPaymentStatus.LATE : RentPaymentStatus.UPCOMING;
}
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getLandlordId() { return landlordId; }
public void setLandlordId(String landlordId) { this.landlordId = landlordId; }
public String getLeaseId() { return leaseId; }
public void setLeaseId(String leaseId) { this.leaseId = leaseId; }
public String getMonth() { return month; }
public void setMonth(String month) { this.month = month; }
public LocalDate getDueDate() { return dueDate; }
public void setDueDate(LocalDate dueDate) { this.dueDate = dueDate; }
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; }
public LocalDate getPaidAt() { return paidAt; }
public void setPaidAt(LocalDate paidAt) { this.paidAt = paidAt; }
}
@@ -0,0 +1,7 @@
package fr.nessar.lmnporganizer.backend.model;
public enum RentPaymentStatus {
PAID,
LATE,
UPCOMING
}
@@ -1,9 +1,13 @@
package fr.nessar.lmnporganizer.backend.repository;
import fr.nessar.lmnporganizer.backend.model.Lease;
import fr.nessar.lmnporganizer.backend.model.LeaseStatus;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface LeaseRepository extends MongoRepository<Lease, String> {
List<Lease> findAllByStatus(LeaseStatus status);
}
@@ -0,0 +1,14 @@
package fr.nessar.lmnporganizer.backend.repository;
import fr.nessar.lmnporganizer.backend.model.RentPayment;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface RentPaymentRepository extends MongoRepository<RentPayment, String> {
List<RentPayment> findAllByLeaseIdAndMonthBetween(String leaseId, String firstMonth, String lastMonth);
List<RentPayment> findAllByOrderByDueDateDesc();
}
@@ -0,0 +1,125 @@
package fr.nessar.lmnporganizer.backend.service;
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;
import fr.nessar.lmnporganizer.backend.repository.LeaseRepository;
import fr.nessar.lmnporganizer.backend.repository.LandlordRepository;
import fr.nessar.lmnporganizer.backend.repository.RentPaymentRepository;
import fr.nessar.lmnporganizer.backend.repository.TenantRepository;
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.stereotype.Service;
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@Service
public class RentPaymentService {
private final RentPaymentRepository rentPaymentRepository;
private final LeaseRepository leaseRepository;
private final ApartmentRepository apartmentRepository;
private final LandlordRepository landlordRepository;
private final TenantRepository tenantRepository;
private final RentReceiptPdfService rentReceiptPdfService;
public RentPaymentService(RentPaymentRepository rentPaymentRepository, LeaseRepository leaseRepository,
ApartmentRepository apartmentRepository, LandlordRepository landlordRepository,
TenantRepository tenantRepository,
RentReceiptPdfService rentReceiptPdfService) {
this.rentPaymentRepository = rentPaymentRepository;
this.leaseRepository = leaseRepository;
this.apartmentRepository = apartmentRepository;
this.landlordRepository = landlordRepository;
this.tenantRepository = tenantRepository;
this.rentReceiptPdfService = rentReceiptPdfService;
}
public List<RentPaymentResponse> getAll() {
generateForActiveLeases();
return rentPaymentRepository.findAllByOrderByDueDateDesc().stream().map(this::toResponse).toList();
}
public ResponseEntity<RentPaymentResponse> markPaid(String id) {
return rentPaymentRepository.findById(id).map(payment -> {
if (payment.getPaidAt() == null) {
payment.setPaidAt(LocalDate.now());
payment = rentPaymentRepository.save(payment);
}
return ResponseEntity.ok(toResponse(payment));
}).orElse(ResponseEntity.notFound().build());
}
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();
Lease lease = leaseRepository.findById(payment.getLeaseId()).orElse(null);
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());
if (apartment == null || landlord == null || tenants.size() != new HashSet<>(lease.getTenantIds()).size()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_PDF)
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
.filename("quittance-" + payment.getMonth() + ".pdf").build().toString())
.body(rentReceiptPdfService.generate(payment, lease, apartment, landlord, tenants));
}
private void generateForActiveLeases() {
leaseRepository.findAllByStatus(LeaseStatus.ACTIVE).forEach(this::generateForLease);
}
private void generateForLease(Lease lease) {
if (lease.getStartDate() == null || lease.getEndDate() == null || lease.getMonthlyRent() == null
|| lease.getMonthlyCharges() == null
|| lease.getEndDate().isBefore(lease.getStartDate())) return;
YearMonth firstMonth = YearMonth.from(lease.getStartDate());
YearMonth lastMonth = YearMonth.from(lease.getEndDate());
Set<String> existingMonths = new HashSet<>(rentPaymentRepository
.findAllByLeaseIdAndMonthBetween(lease.getId(), firstMonth.toString(), lastMonth.toString())
.stream().map(RentPayment::getMonth).toList());
List<RentPayment> missingPayments = new ArrayList<>();
for (YearMonth month = firstMonth; !month.isAfter(lastMonth); month = month.plusMonths(1)) {
if (!existingMonths.contains(month.toString())) {
missingPayments.add(new RentPayment(null, lease.getLandlordId(), lease.getId(), month.toString(),
dueDate(lease, month), lease.getMonthlyRent(), lease.getMonthlyCharges(), null));
}
}
if (!missingPayments.isEmpty()) rentPaymentRepository.saveAll(missingPayments);
}
private LocalDate dueDate(Lease lease, YearMonth month) {
return month.atDay(Math.min(lease.getStartDate().getDayOfMonth(), month.lengthOfMonth()));
}
private RentPaymentResponse toResponse(RentPayment payment) {
return new RentPaymentResponse(payment.getId(), payment.getLeaseId(), payment.getMonth(), payment.getDueDate(),
payment.getRent(), payment.getCharges(), payment.getRent().add(payment.getCharges()),
payment.statusOn(LocalDate.now()), payment.getPaidAt());
}
private List<Tenant> tenantsFor(List<String> ids) {
List<Tenant> tenants = new ArrayList<>();
tenantRepository.findAllById(ids).forEach(tenants::add);
return tenants;
}
}
@@ -0,0 +1,55 @@
package fr.nessar.lmnporganizer.backend.service;
import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
import fr.nessar.lmnporganizer.backend.model.Apartment;
import fr.nessar.lmnporganizer.backend.model.Lease;
import fr.nessar.lmnporganizer.backend.model.Landlord;
import fr.nessar.lmnporganizer.backend.model.RentPayment;
import fr.nessar.lmnporganizer.backend.model.Tenant;
import org.springframework.stereotype.Service;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.time.YearMonth;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Locale;
@Service
public class RentReceiptPdfService {
private final TemplateEngine templateEngine;
public RentReceiptPdfService(TemplateEngine templateEngine) {
this.templateEngine = templateEngine;
}
public byte[] generate(RentPayment payment, Lease lease, Apartment apartment, Landlord landlord,
List<Tenant> tenants) {
Context context = new Context(Locale.FRANCE);
context.setVariable("payment", payment);
context.setVariable("lease", lease);
context.setVariable("apartment", apartment);
context.setVariable("landlord", landlord);
context.setVariable("tenantNames", namesOf(tenants));
context.setVariable("paymentMonth", YearMonth.parse(payment.getMonth())
.format(DateTimeFormatter.ofPattern("MMMM yyyy", Locale.FRANCE)));
context.setVariable("total", payment.getRent().add(payment.getCharges()));
try (ByteArrayOutputStream output = new ByteArrayOutputStream()) {
new PdfRendererBuilder().withHtmlContent(templateEngine.process("rent-receipt", context), null)
.toStream(output).run();
return output.toByteArray();
} catch (IOException exception) {
throw new IllegalStateException("Rent receipt PDF could not be generated", exception);
}
}
private String namesOf(List<Tenant> tenants) {
List<String> names = tenants.stream()
.map(tenant -> tenant.getFirstName() + " " + tenant.getLastName())
.toList();
return names.isEmpty() ? "Aucun" : String.join(", ", names);
}
}
@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="fr" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8" />
<style>
@page { size: A4; margin: 2cm; }
body { color: #1f2937; font-family: serif; font-size: 11pt; line-height: 1.5; }
h1 { color: #0f766e; font-size: 22pt; margin: 0 0 0.5cm; }
table { border-collapse: collapse; width: 100%; }
th, td { border-bottom: 1px solid #d1d5db; padding: 0.25cm 0; text-align: left; }
th { width: 35%; font-weight: bold; }
footer { color: #4b5563; font-size: 9pt; margin-top: 1cm; }
</style>
</head>
<body>
<h1>QUITTANCE DE LOYER</h1>
<p>Le bailleur reconnaît avoir reçu le paiement du loyer et des charges pour la période indiquée.</p>
<table>
<tr><th>Bailleur</th><td th:text="${landlord.firstName + ' ' + landlord.lastName}">Bailleur LMNP</td></tr>
<tr><th>Adresse du bailleur</th><td th:text="${landlord.address}">123 rue de la Gestion, Paris</td></tr>
<tr><th>Logement</th><td th:text="${apartment.name}">Studio République</td></tr>
<tr><th>Adresse</th><td th:text="${apartment.address}">12 rue de la République</td></tr>
<tr><th>Locataire(s)</th><td th:text="${tenantNames}">Camille Martin</td></tr>
<tr><th>Période</th><td th:text="${paymentMonth}">juillet 2026</td></tr>
<tr><th>Loyer</th><td><span th:text="${payment.rent}">850.00</span> EUR</td></tr>
<tr><th>Charges</th><td><span th:text="${payment.charges}">50.00</span> EUR</td></tr>
<tr><th>Total réglé</th><td><span th:text="${total}">900.00</span> EUR</td></tr>
</table>
<footer>Quittance générée par LMNP Organizer le <span th:text="${payment.paidAt}">2026-07-17</span>.</footer>
</body>
</html>
@@ -0,0 +1,138 @@
package fr.nessar.lmnporganizer.backend.controller;
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;
import fr.nessar.lmnporganizer.backend.repository.LandlordRepository;
import fr.nessar.lmnporganizer.backend.repository.LeaseRepository;
import fr.nessar.lmnporganizer.backend.repository.RentPaymentRepository;
import fr.nessar.lmnporganizer.backend.repository.TenantRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
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.put;
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;
@SpringBootTest(properties = "lmnp.data-initializer.enabled=false")
@AutoConfigureMockMvc
class RentPaymentControllerTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private RentPaymentRepository rentPaymentRepository;
@MockitoBean
private LeaseRepository leaseRepository;
@MockitoBean
private ApartmentRepository apartmentRepository;
@MockitoBean
private LandlordRepository landlordRepository;
@MockitoBean
private TenantRepository tenantRepository;
@Test
void whenRentScheduleIsRequestedWithoutAuthentication_thenUnauthorized() throws Exception {
mockMvc.perform(get("/api/rent-payments"))
.andExpect(status().isUnauthorized());
}
@Test
void whenRentScheduleIsRequested_thenReturnsGeneratedPayments() throws Exception {
when(leaseRepository.findAllByStatus(LeaseStatus.ACTIVE)).thenReturn(List.of());
when(rentPaymentRepository.findAllByOrderByDueDateDesc()).thenReturn(List.of(payment(null)));
mockMvc.perform(get("/api/rent-payments").with(oidcLogin()))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].id").value("payment-1"))
.andExpect(jsonPath("$[0].status").value("LATE"));
}
@Test
void whenRentPaymentIsMarkedPaid_thenReturnsPaidPayment() throws Exception {
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()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("PAID"));
}
@Test
void whenUnknownRentPaymentIsMarkedPaid_thenNotFound() throws Exception {
when(rentPaymentRepository.findById("missing")).thenReturn(Optional.empty());
mockMvc.perform(put("/api/rent-payments/missing/paid").with(oidcLogin()))
.andExpect(status().isNotFound());
}
@Test
void whenPaidReceiptIsRequested_thenReturnsPdfAttachment() throws Exception {
when(rentPaymentRepository.findById("payment-1")).thenReturn(Optional.of(payment(LocalDate.of(2026, 7, 2))));
when(leaseRepository.findById("lease-1")).thenReturn(Optional.of(lease()));
when(apartmentRepository.findById("apartment-1")).thenReturn(Optional.of(apartment()));
when(landlordRepository.findById("default-landlord")).thenReturn(Optional.of(landlord()));
when(tenantRepository.findAllById(List.of("tenant-1"))).thenReturn(List.of(tenant()));
mockMvc.perform(get("/api/rent-payments/payment-1/receipt").with(oidcLogin()))
.andExpect(status().isOk())
.andExpect(header().string("Content-Type", "application/pdf"))
.andExpect(header().string("Content-Disposition", org.hamcrest.Matchers.containsString("quittance-2026-07.pdf")));
}
@Test
void whenUnpaidReceiptIsRequested_thenConflict() throws Exception {
when(rentPaymentRepository.findById("payment-1")).thenReturn(Optional.of(payment(null)));
mockMvc.perform(get("/api/rent-payments/payment-1/receipt").with(oidcLogin()))
.andExpect(status().isConflict());
}
private RentPayment payment(LocalDate paidAt) {
return new RentPayment("payment-1", "default-landlord", "lease-1", "2026-07",
LocalDate.of(2026, 7, 1), new BigDecimal("850.00"), new BigDecimal("50.00"), paidAt);
}
private Lease lease() {
return new Lease("lease-1", "default-landlord", "apartment-1", List.of("tenant-1"), List.of(),
LocalDate.of(2026, 1, 1), LocalDate.of(2026, 12, 31), new BigDecimal("850.00"),
new BigDecimal("50.00"), new BigDecimal("850.00"), LeaseStatus.ACTIVE);
}
private Apartment apartment() {
return new Apartment("apartment-1", "default-landlord", "Studio République", "12 rue de la République",
150000, "");
}
private Landlord landlord() {
return new Landlord("default-landlord", "Bailleur", "LMNP", "bailleur@example.com", "0102030405",
"123 rue de la Gestion, Paris");
}
private Tenant tenant() {
return new Tenant("tenant-1", "default-landlord", "Camille", "Martin", "camille@example.com",
"+33612345678", "10 rue des Lilas");
}
}
@@ -192,4 +192,34 @@ class ModelCoverageTest {
BigDecimal.ZERO, BigDecimal.TEN, LeaseStatus.DRAFT);
assertEquals("lease-2", fullLease.getId());
}
@Test
void testRentPaymentAccessorsAndStatus() {
RentPayment payment = new RentPayment();
payment.setId("payment-1");
payment.setLandlordId("default-landlord");
payment.setLeaseId("lease-1");
payment.setMonth("2026-07");
payment.setDueDate(LocalDate.of(2026, 7, 1));
payment.setRent(new BigDecimal("850"));
payment.setCharges(new BigDecimal("50"));
assertEquals("payment-1", payment.getId());
assertEquals("default-landlord", payment.getLandlordId());
assertEquals("lease-1", payment.getLeaseId());
assertEquals("2026-07", payment.getMonth());
assertEquals(LocalDate.of(2026, 7, 1), payment.getDueDate());
assertEquals(new BigDecimal("850"), payment.getRent());
assertEquals(new BigDecimal("50"), payment.getCharges());
assertEquals(RentPaymentStatus.LATE, payment.statusOn(LocalDate.of(2026, 7, 2)));
assertEquals(RentPaymentStatus.UPCOMING, payment.statusOn(LocalDate.of(2026, 7, 1)));
payment.setPaidAt(LocalDate.of(2026, 7, 3));
assertEquals(LocalDate.of(2026, 7, 3), payment.getPaidAt());
assertEquals(RentPaymentStatus.PAID, payment.statusOn(LocalDate.of(2026, 7, 2)));
RentPayment fullPayment = new RentPayment("payment-2", "landlord", "lease-2", "2026-08",
LocalDate.of(2026, 8, 1), BigDecimal.ONE, BigDecimal.ZERO, null);
assertEquals("payment-2", fullPayment.getId());
}
}
@@ -0,0 +1,164 @@
package fr.nessar.lmnporganizer.backend.service;
import fr.nessar.lmnporganizer.backend.dto.RentPaymentResponse;
import fr.nessar.lmnporganizer.backend.model.Apartment;
import fr.nessar.lmnporganizer.backend.model.Lease;
import fr.nessar.lmnporganizer.backend.model.LeaseStatus;
import fr.nessar.lmnporganizer.backend.model.Landlord;
import fr.nessar.lmnporganizer.backend.model.RentPayment;
import fr.nessar.lmnporganizer.backend.model.RentPaymentStatus;
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.LandlordRepository;
import fr.nessar.lmnporganizer.backend.repository.RentPaymentRepository;
import fr.nessar.lmnporganizer.backend.repository.TenantRepository;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.text.PDFTextStripper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.thymeleaf.spring6.SpringTemplateEngine;
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import java.util.stream.StreamSupport;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class RentPaymentServiceTest {
@Mock
private RentPaymentRepository rentPaymentRepository;
@Mock
private LeaseRepository leaseRepository;
@Mock
private ApartmentRepository apartmentRepository;
@Mock
private LandlordRepository landlordRepository;
@Mock
private TenantRepository tenantRepository;
private RentPaymentService service;
@BeforeEach
void setUp() {
ClassLoaderTemplateResolver resolver = new ClassLoaderTemplateResolver();
resolver.setPrefix("templates/");
resolver.setSuffix(".html");
SpringTemplateEngine templateEngine = new SpringTemplateEngine();
templateEngine.setTemplateResolver(resolver);
service = new RentPaymentService(rentPaymentRepository, leaseRepository, apartmentRepository,
landlordRepository, tenantRepository, new RentReceiptPdfService(templateEngine));
}
@Test
void generatesARecordForEachMonthOfAnActiveLease() {
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.findAllByOrderByDueDateDesc()).thenReturn(List.of(
payment("payment-3", "2099-03", LocalDate.of(2099, 3, 31), null),
payment("payment-2", "2099-02", LocalDate.of(2099, 2, 28), null),
payment("payment-1", "2099-01", LocalDate.of(2099, 1, 31), null)));
List<RentPaymentResponse> payments = service.getAll();
assertEquals(3, payments.size());
assertEquals("2099-03", 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));
}));
}
@Test
void marksAnUnpaidRentPaymentPaidOnlyOnce() {
RentPayment payment = payment("payment-1", "2000-01", LocalDate.of(2000, 1, 1), null);
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");
assertEquals(HttpStatus.OK, response.getStatusCode());
assertNotNull(response.getBody());
assertEquals(RentPaymentStatus.PAID, response.getBody().status());
assertNotNull(response.getBody().paidAt());
verify(rentPaymentRepository).save(payment);
service.markPaid("payment-1");
verify(rentPaymentRepository, times(1)).save(payment);
}
@Test
void rendersAPdfReceiptOnlyAfterPayment() throws Exception {
RentPayment payment = payment("payment-1", "2026-07", LocalDate.of(2026, 7, 1), LocalDate.of(2026, 7, 2));
when(rentPaymentRepository.findById("payment-1")).thenReturn(Optional.of(payment));
when(leaseRepository.findById("lease-1")).thenReturn(Optional.of(lease(
LocalDate.of(2026, 1, 1), LocalDate.of(2026, 12, 31))));
when(apartmentRepository.findById("apartment-1")).thenReturn(Optional.of(apartment()));
when(landlordRepository.findById("default-landlord")).thenReturn(Optional.of(landlord()));
when(tenantRepository.findAllById(List.of("tenant-1"))).thenReturn(List.of(tenant()));
ResponseEntity<byte[]> response = service.downloadReceipt("payment-1");
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals("application/pdf", response.getHeaders().getContentType().toString());
assertTrue(response.getHeaders().getFirst("Content-Disposition").contains("quittance-2026-07.pdf"));
try (PDDocument document = Loader.loadPDF(response.getBody())) {
String pdfText = new PDFTextStripper().getText(document);
assertTrue(pdfText.contains("QUITTANCE DE LOYER"));
assertTrue(pdfText.contains("Studio République"));
assertTrue(pdfText.contains("Camille Martin"));
assertTrue(pdfText.contains("Bailleur LMNP"));
}
RentPayment unpaid = payment("payment-2", "2026-08", LocalDate.of(2026, 8, 1), null);
when(rentPaymentRepository.findById("payment-2")).thenReturn(Optional.of(unpaid));
assertEquals(HttpStatus.CONFLICT, service.downloadReceipt("payment-2").getStatusCode());
}
private Apartment apartment() {
return new Apartment("apartment-1", "default-landlord", "Studio République", "12 rue de la République",
150000, "");
}
private Tenant tenant() {
return new Tenant("tenant-1", "default-landlord", "Camille", "Martin", "camille@example.com",
"+33612345678", "10 rue des Lilas");
}
private Landlord landlord() {
return new Landlord("default-landlord", "Bailleur", "LMNP", "bailleur@example.com", "0102030405",
"123 rue de la Gestion, Paris");
}
private Lease lease(LocalDate startDate, LocalDate endDate) {
return new Lease("lease-1", "default-landlord", "apartment-1", List.of("tenant-1"), List.of(), startDate,
endDate, new BigDecimal("850.00"), new BigDecimal("50.00"), new BigDecimal("850.00"),
LeaseStatus.ACTIVE);
}
private RentPayment payment(String id, String month, LocalDate dueDate, LocalDate paidAt) {
return new RentPayment(id, "default-landlord", "lease-1", month, dueDate, new BigDecimal("850.00"),
new BigDecimal("50.00"), paidAt);
}
}