feat: add rent payment schedules and receipts
This commit is contained in:
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()
|
||||
)
|
||||
|
||||
+37
@@ -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);
|
||||
}
|
||||
+14
@@ -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();
|
||||
}
|
||||
+125
@@ -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;
|
||||
}
|
||||
}
|
||||
+55
@@ -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>
|
||||
+138
@@ -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());
|
||||
}
|
||||
}
|
||||
+164
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user