feat(#23): use landlord profiles in PDFs

This commit is contained in:
nessar committed 2026-08-17 14:18:15 +02:00
1 parent ac5a7942c9
commit 668680e844
17 files changed
+221 -39

No files matched your search

@@ -3,6 +3,7 @@ package fr.nessar.lmnporganizer.backend.controller;
import fr.nessar.lmnporganizer.backend.exception.AttachmentTooLargeException; import fr.nessar.lmnporganizer.backend.exception.AttachmentTooLargeException;
import fr.nessar.lmnporganizer.backend.exception.EmailAlreadyUsedException; import fr.nessar.lmnporganizer.backend.exception.EmailAlreadyUsedException;
import fr.nessar.lmnporganizer.backend.exception.InvalidPhoneNumberException; import fr.nessar.lmnporganizer.backend.exception.InvalidPhoneNumberException;
import fr.nessar.lmnporganizer.backend.exception.IncompleteLandlordProfileException;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MethodArgumentNotValidException;
@@ -32,6 +33,11 @@ public class ApiExceptionHandler {
return error(HttpStatus.CONFLICT, exception.getMessage()); return error(HttpStatus.CONFLICT, exception.getMessage());
} }
@ExceptionHandler(IncompleteLandlordProfileException.class)
public ResponseEntity<ApiError> handleIncompleteLandlordProfile(IncompleteLandlordProfileException exception) {
return error(HttpStatus.UNPROCESSABLE_CONTENT, exception.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class) @ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiError> handleInvalidRequest(MethodArgumentNotValidException exception) { public ResponseEntity<ApiError> handleInvalidRequest(MethodArgumentNotValidException exception) {
String message = exception.getBindingResult().getFieldErrors().stream() String message = exception.getBindingResult().getFieldErrors().stream()
@@ -5,6 +5,8 @@ import fr.nessar.lmnporganizer.backend.dto.LeaseResponse;
import fr.nessar.lmnporganizer.backend.service.LeaseService; import fr.nessar.lmnporganizer.backend.service.LeaseService;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
@@ -45,7 +47,7 @@ public class LeaseController {
} }
@GetMapping("/{id}/pdf") @GetMapping("/{id}/pdf")
public ResponseEntity<byte[]> downloadPdf(@PathVariable String id) { public ResponseEntity<byte[]> downloadPdf(@PathVariable String id, @AuthenticationPrincipal OidcUser principal) {
return leaseService.downloadPdf(id); return leaseService.downloadPdf(id, principal.getSubject());
} }
} }
@@ -3,6 +3,8 @@ package fr.nessar.lmnporganizer.backend.controller;
import fr.nessar.lmnporganizer.backend.dto.RentPaymentResponse; import fr.nessar.lmnporganizer.backend.dto.RentPaymentResponse;
import fr.nessar.lmnporganizer.backend.service.RentPaymentService; import fr.nessar.lmnporganizer.backend.service.RentPaymentService;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.PutMapping;
@@ -33,7 +35,7 @@ public class RentPaymentController {
} }
@GetMapping("/{id}/receipt") @GetMapping("/{id}/receipt")
public ResponseEntity<byte[]> downloadReceipt(@PathVariable String id) { public ResponseEntity<byte[]> downloadReceipt(@PathVariable String id, @AuthenticationPrincipal OidcUser principal) {
return rentPaymentService.downloadReceipt(id); return rentPaymentService.downloadReceipt(id, principal.getSubject());
} }
} }
@@ -0,0 +1,7 @@
package fr.nessar.lmnporganizer.backend.exception;
public class IncompleteLandlordProfileException extends RuntimeException {
public IncompleteLandlordProfileException() {
super("Votre profil bailleur est incomplet. Complétez-le dans les réglages avant de générer un PDF.");
}
}
@@ -3,6 +3,7 @@ package fr.nessar.lmnporganizer.backend.service;
import fr.nessar.lmnporganizer.backend.dto.LandlordProfileRequest; import fr.nessar.lmnporganizer.backend.dto.LandlordProfileRequest;
import fr.nessar.lmnporganizer.backend.dto.LandlordProfileResponse; import fr.nessar.lmnporganizer.backend.dto.LandlordProfileResponse;
import fr.nessar.lmnporganizer.backend.exception.EmailAlreadyUsedException; import fr.nessar.lmnporganizer.backend.exception.EmailAlreadyUsedException;
import fr.nessar.lmnporganizer.backend.exception.IncompleteLandlordProfileException;
import fr.nessar.lmnporganizer.backend.model.Landlord; import fr.nessar.lmnporganizer.backend.model.Landlord;
import fr.nessar.lmnporganizer.backend.repository.LandlordRepository; import fr.nessar.lmnporganizer.backend.repository.LandlordRepository;
import org.springframework.dao.DuplicateKeyException; import org.springframework.dao.DuplicateKeyException;
@@ -43,6 +44,15 @@ public class LandlordProfileService {
return response(save(landlord)); return response(save(landlord));
} }
public Landlord requireCompleteProfileFor(String oidcSubject) {
Landlord landlord = landlordRepository.findByOidcSubject(oidcSubject)
.orElseThrow(IncompleteLandlordProfileException::new);
if (isIncomplete(landlord)) {
throw new IncompleteLandlordProfileException();
}
return landlord;
}
private Landlord save(Landlord landlord) { private Landlord save(Landlord landlord) {
try { try {
return landlordRepository.save(landlord); return landlordRepository.save(landlord);
@@ -64,4 +74,13 @@ public class LandlordProfileService {
private String value(String value) { private String value(String value) {
return value == null ? "" : value; return value == null ? "" : value;
} }
private boolean isIncomplete(Landlord landlord) {
return isBlank(landlord.getFirstName()) || isBlank(landlord.getLastName()) || isBlank(landlord.getEmail())
|| isBlank(landlord.getPhone()) || isBlank(landlord.getAddress());
}
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
} }
@@ -3,6 +3,7 @@ package fr.nessar.lmnporganizer.backend.service;
import com.openhtmltopdf.pdfboxout.PdfRendererBuilder; import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
import fr.nessar.lmnporganizer.backend.model.Apartment; import fr.nessar.lmnporganizer.backend.model.Apartment;
import fr.nessar.lmnporganizer.backend.model.Lease; import fr.nessar.lmnporganizer.backend.model.Lease;
import fr.nessar.lmnporganizer.backend.model.Landlord;
import fr.nessar.lmnporganizer.backend.model.Tenant; import fr.nessar.lmnporganizer.backend.model.Tenant;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.thymeleaf.TemplateEngine; import org.thymeleaf.TemplateEngine;
@@ -21,10 +22,11 @@ public class LeasePdfService {
this.templateEngine = templateEngine; this.templateEngine = templateEngine;
} }
public byte[] generate(Lease lease, Apartment apartment, List<Tenant> tenants) { public byte[] generate(Lease lease, Apartment apartment, Landlord landlord, List<Tenant> tenants) {
Context context = new Context(Locale.FRANCE); Context context = new Context(Locale.FRANCE);
context.setVariable("lease", lease); context.setVariable("lease", lease);
context.setVariable("apartment", apartment); context.setVariable("apartment", apartment);
context.setVariable("landlord", landlord);
context.setVariable("tenantNames", tenantNamesOf(tenants)); context.setVariable("tenantNames", tenantNamesOf(tenants));
context.setVariable("guarantorNames", namesOf(lease.getGuarantors().stream() context.setVariable("guarantorNames", namesOf(lease.getGuarantors().stream()
.map(guarantor -> guarantor.getFirstName() + " " + guarantor.getLastName()).toList())); .map(guarantor -> guarantor.getFirstName() + " " + guarantor.getLastName()).toList()));
@@ -5,6 +5,7 @@ import fr.nessar.lmnporganizer.backend.dto.LeaseResponse;
import fr.nessar.lmnporganizer.backend.mapper.LeaseMapper; import fr.nessar.lmnporganizer.backend.mapper.LeaseMapper;
import fr.nessar.lmnporganizer.backend.model.Apartment; import fr.nessar.lmnporganizer.backend.model.Apartment;
import fr.nessar.lmnporganizer.backend.model.Lease; import fr.nessar.lmnporganizer.backend.model.Lease;
import fr.nessar.lmnporganizer.backend.model.Landlord;
import fr.nessar.lmnporganizer.backend.model.Tenant; import fr.nessar.lmnporganizer.backend.model.Tenant;
import fr.nessar.lmnporganizer.backend.repository.ApartmentRepository; import fr.nessar.lmnporganizer.backend.repository.ApartmentRepository;
import fr.nessar.lmnporganizer.backend.repository.LeaseRepository; import fr.nessar.lmnporganizer.backend.repository.LeaseRepository;
@@ -29,11 +30,12 @@ public class LeaseService {
private final PhoneNumberNormalizer phoneNumberNormalizer; private final PhoneNumberNormalizer phoneNumberNormalizer;
private final LeaseMapper leaseMapper; private final LeaseMapper leaseMapper;
private final RentPaymentService rentPaymentService; private final RentPaymentService rentPaymentService;
private final LandlordProfileService landlordProfileService;
public LeaseService(LeaseRepository leaseRepository, ApartmentRepository apartmentRepository, public LeaseService(LeaseRepository leaseRepository, ApartmentRepository apartmentRepository,
TenantRepository tenantRepository, LeasePdfService leasePdfService, TenantRepository tenantRepository, LeasePdfService leasePdfService,
PhoneNumberNormalizer phoneNumberNormalizer, LeaseMapper leaseMapper, PhoneNumberNormalizer phoneNumberNormalizer, LeaseMapper leaseMapper,
RentPaymentService rentPaymentService) { RentPaymentService rentPaymentService, LandlordProfileService landlordProfileService) {
this.leaseRepository = leaseRepository; this.leaseRepository = leaseRepository;
this.apartmentRepository = apartmentRepository; this.apartmentRepository = apartmentRepository;
this.tenantRepository = tenantRepository; this.tenantRepository = tenantRepository;
@@ -41,6 +43,7 @@ public class LeaseService {
this.phoneNumberNormalizer = phoneNumberNormalizer; this.phoneNumberNormalizer = phoneNumberNormalizer;
this.leaseMapper = leaseMapper; this.leaseMapper = leaseMapper;
this.rentPaymentService = rentPaymentService; this.rentPaymentService = rentPaymentService;
this.landlordProfileService = landlordProfileService;
} }
public List<LeaseResponse> getAll() { public List<LeaseResponse> getAll() {
@@ -92,7 +95,7 @@ public class LeaseService {
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
public ResponseEntity<byte[]> downloadPdf(String id) { public ResponseEntity<byte[]> downloadPdf(String id, String oidcSubject) {
Lease lease = leaseRepository.findById(id).orElse(null); Lease lease = leaseRepository.findById(id).orElse(null);
if (lease == null) { if (lease == null) {
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
@@ -102,11 +105,12 @@ public class LeaseService {
if (apartment == null || tenants.size() != new HashSet<>(lease.getTenantIds()).size()) { if (apartment == null || tenants.size() != new HashSet<>(lease.getTenantIds()).size()) {
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }
Landlord landlord = landlordProfileService.requireCompleteProfileFor(oidcSubject);
return ResponseEntity.ok() return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_PDF) .contentType(MediaType.APPLICATION_PDF)
.header(HttpHeaders.CONTENT_DISPOSITION, .header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment().filename("bail-" + id + ".pdf").build().toString()) ContentDisposition.attachment().filename("bail-" + id + ".pdf").build().toString())
.body(leasePdfService.generate(lease, apartment, tenants)); .body(leasePdfService.generate(lease, apartment, landlord, tenants));
} }
private boolean referencesExist(Lease lease) { private boolean referencesExist(Lease lease) {
@@ -8,7 +8,6 @@ import fr.nessar.lmnporganizer.backend.model.RentPayment;
import fr.nessar.lmnporganizer.backend.model.Tenant; import fr.nessar.lmnporganizer.backend.model.Tenant;
import fr.nessar.lmnporganizer.backend.repository.ApartmentRepository; import fr.nessar.lmnporganizer.backend.repository.ApartmentRepository;
import fr.nessar.lmnporganizer.backend.repository.LeaseRepository; 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.RentPaymentRepository;
import fr.nessar.lmnporganizer.backend.repository.TenantRepository; import fr.nessar.lmnporganizer.backend.repository.TenantRepository;
import org.springframework.http.ContentDisposition; import org.springframework.http.ContentDisposition;
@@ -31,19 +30,19 @@ public class RentPaymentService {
private final RentPaymentRepository rentPaymentRepository; private final RentPaymentRepository rentPaymentRepository;
private final LeaseRepository leaseRepository; private final LeaseRepository leaseRepository;
private final ApartmentRepository apartmentRepository; private final ApartmentRepository apartmentRepository;
private final LandlordRepository landlordRepository; private final LandlordProfileService landlordProfileService;
private final TenantRepository tenantRepository; private final TenantRepository tenantRepository;
private final RentReceiptPdfService rentReceiptPdfService; private final RentReceiptPdfService rentReceiptPdfService;
private final Clock clock; private final Clock clock;
public RentPaymentService(RentPaymentRepository rentPaymentRepository, LeaseRepository leaseRepository, public RentPaymentService(RentPaymentRepository rentPaymentRepository, LeaseRepository leaseRepository,
ApartmentRepository apartmentRepository, LandlordRepository landlordRepository, ApartmentRepository apartmentRepository, LandlordProfileService landlordProfileService,
TenantRepository tenantRepository, TenantRepository tenantRepository,
RentReceiptPdfService rentReceiptPdfService, Clock clock) { RentReceiptPdfService rentReceiptPdfService, Clock clock) {
this.rentPaymentRepository = rentPaymentRepository; this.rentPaymentRepository = rentPaymentRepository;
this.leaseRepository = leaseRepository; this.leaseRepository = leaseRepository;
this.apartmentRepository = apartmentRepository; this.apartmentRepository = apartmentRepository;
this.landlordRepository = landlordRepository; this.landlordProfileService = landlordProfileService;
this.tenantRepository = tenantRepository; this.tenantRepository = tenantRepository;
this.rentReceiptPdfService = rentReceiptPdfService; this.rentReceiptPdfService = rentReceiptPdfService;
this.clock = clock; this.clock = clock;
@@ -65,7 +64,7 @@ public class RentPaymentService {
}).orElse(ResponseEntity.notFound().build()); }).orElse(ResponseEntity.notFound().build());
} }
public ResponseEntity<byte[]> downloadReceipt(String id) { public ResponseEntity<byte[]> downloadReceipt(String id, String oidcSubject) {
RentPayment payment = rentPaymentRepository.findById(id).orElse(null); RentPayment payment = rentPaymentRepository.findById(id).orElse(null);
if (payment == null) if (payment == null)
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
@@ -76,9 +75,9 @@ public class RentPaymentService {
if (lease == null) if (lease == null)
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
Apartment apartment = apartmentRepository.findById(lease.getApartmentId()).orElse(null); Apartment apartment = apartmentRepository.findById(lease.getApartmentId()).orElse(null);
Landlord landlord = landlordRepository.findById(payment.getLandlordId()).orElse(null); Landlord landlord = landlordProfileService.requireCompleteProfileFor(oidcSubject);
List<Tenant> tenants = tenantsFor(lease.getTenantIds()); List<Tenant> tenants = tenantsFor(lease.getTenantIds());
if (apartment == null || landlord == null || tenants.size() != new HashSet<>(lease.getTenantIds()).size()) { if (apartment == null || tenants.size() != new HashSet<>(lease.getTenantIds()).size()) {
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }
@@ -19,6 +19,10 @@
<table> <table>
<tr><th>Logement</th><td th:text="${apartment.name}">Studio République</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>Adresse</th><td th:text="${apartment.address}">12 rue de la République</td></tr>
<tr><th>Bailleur</th><td th:text="${landlord.firstName + ' ' + landlord.lastName}">Léa Dubois</td></tr>
<tr><th>E-mail du bailleur</th><td th:text="${landlord.email}">lea@example.com</td></tr>
<tr><th>Téléphone du bailleur</th><td th:text="${landlord.phone}">+33612345678</td></tr>
<tr><th>Adresse du bailleur</th><td th:text="${landlord.address}">1 rue de Paris</td></tr>
<tr><th>Locataire(s)</th><td th:text="${tenantNames}">Camille Martin</td></tr> <tr><th>Locataire(s)</th><td th:text="${tenantNames}">Camille Martin</td></tr>
<tr><th>Période</th><td><span th:text="${lease.startDate}">2026-09-01</span> au <span th:text="${lease.endDate}">2027-08-31</span></td></tr> <tr><th>Période</th><td><span th:text="${lease.startDate}">2026-09-01</span> au <span th:text="${lease.endDate}">2027-08-31</span></td></tr>
<tr><th>Loyer mensuel</th><td><span th:text="${lease.monthlyRent}">850.00</span> EUR</td></tr> <tr><th>Loyer mensuel</th><td><span th:text="${lease.monthlyRent}">850.00</span> EUR</td></tr>
@@ -17,6 +17,8 @@
<p>Le bailleur reconnaît avoir reçu le paiement du loyer et des charges pour la période indiquée.</p> <p>Le bailleur reconnaît avoir reçu le paiement du loyer et des charges pour la période indiquée.</p>
<table> <table>
<tr><th>Bailleur</th><td th:text="${landlord.firstName + ' ' + landlord.lastName}">Bailleur LMNP</td></tr> <tr><th>Bailleur</th><td th:text="${landlord.firstName + ' ' + landlord.lastName}">Bailleur LMNP</td></tr>
<tr><th>E-mail du bailleur</th><td th:text="${landlord.email}">bailleur@example.com</td></tr>
<tr><th>Téléphone du bailleur</th><td th:text="${landlord.phone}">0102030405</td></tr>
<tr><th>Adresse du bailleur</th><td th:text="${landlord.address}">123 rue de la Gestion, Paris</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>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>Adresse</th><td th:text="${apartment.address}">12 rue de la République</td></tr>
@@ -4,8 +4,10 @@ import fr.nessar.lmnporganizer.backend.model.Apartment;
import fr.nessar.lmnporganizer.backend.model.Guarantor; import fr.nessar.lmnporganizer.backend.model.Guarantor;
import fr.nessar.lmnporganizer.backend.model.Lease; import fr.nessar.lmnporganizer.backend.model.Lease;
import fr.nessar.lmnporganizer.backend.model.LeaseStatus; import fr.nessar.lmnporganizer.backend.model.LeaseStatus;
import fr.nessar.lmnporganizer.backend.model.Landlord;
import fr.nessar.lmnporganizer.backend.model.Tenant; import fr.nessar.lmnporganizer.backend.model.Tenant;
import fr.nessar.lmnporganizer.backend.repository.ApartmentRepository; 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.LeaseRepository;
import fr.nessar.lmnporganizer.backend.repository.TenantRepository; import fr.nessar.lmnporganizer.backend.repository.TenantRepository;
import fr.nessar.lmnporganizer.backend.service.RentPaymentService; import fr.nessar.lmnporganizer.backend.service.RentPaymentService;
@@ -27,6 +29,7 @@ import java.util.Optional;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oidcLogin; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oidcLogin;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@@ -46,6 +49,9 @@ class LeaseControllerTest {
@MockitoBean @MockitoBean
private TenantRepository tenantRepository; private TenantRepository tenantRepository;
@MockitoBean
private LandlordRepository landlordRepository;
@MockitoBean @MockitoBean
private RentPaymentService rentPaymentService; private RentPaymentService rentPaymentService;
@@ -142,8 +148,10 @@ class LeaseControllerTest {
when(leaseRepository.findById("lease-1")).thenReturn(Optional.of(lease)); when(leaseRepository.findById("lease-1")).thenReturn(Optional.of(lease));
when(apartmentRepository.findById("apartment-1")).thenReturn(Optional.of(apartment)); when(apartmentRepository.findById("apartment-1")).thenReturn(Optional.of(apartment));
when(tenantRepository.findAllById(List.of("tenant-1"))).thenReturn(List.of(tenant())); when(tenantRepository.findAllById(List.of("tenant-1"))).thenReturn(List.of(tenant()));
when(landlordRepository.findByOidcSubject("subject-1")).thenReturn(Optional.of(landlord()));
mockMvc.perform(get("/api/leases/lease-1/pdf").with(oidcLogin())) mockMvc.perform(get("/api/leases/lease-1/pdf")
.with(oidcLogin().idToken(token -> token.claim("sub", "subject-1"))))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(header().string("Content-Type", "application/pdf")) .andExpect(header().string("Content-Type", "application/pdf"))
.andExpect(header().string("Content-Disposition", org.hamcrest.Matchers.containsString("bail-lease-1.pdf"))) .andExpect(header().string("Content-Disposition", org.hamcrest.Matchers.containsString("bail-lease-1.pdf")))
@@ -154,7 +162,9 @@ class LeaseControllerTest {
} }
try (PDDocument document = Loader.loadPDF(content)) { try (PDDocument document = Loader.loadPDF(content)) {
String pdfText = new PDFTextStripper().getText(document); String pdfText = new PDFTextStripper().getText(document);
if (!pdfText.contains("Studio République") || !pdfText.contains("Camille Martin")) { if (!pdfText.contains("Studio République") || !pdfText.contains("Camille Martin")
|| !pdfText.contains("Léa Dubois") || !pdfText.contains("lea@example.com")
|| !pdfText.contains("+33612345678") || !pdfText.contains("1 rue de Paris")) {
throw new AssertionError("Expected rendered lease template values"); throw new AssertionError("Expected rendered lease template values");
} }
} catch (java.io.IOException exception) { } catch (java.io.IOException exception) {
@@ -163,11 +173,66 @@ class LeaseControllerTest {
}); });
} }
@Test
void whenLandlordProfileIsIncomplete_thenLeasePdfDownloadIsRejectedWithSettingsGuidance() throws Exception {
when(leaseRepository.findById("lease-1")).thenReturn(Optional.of(lease()));
when(apartmentRepository.findById("apartment-1")).thenReturn(Optional.of(new Apartment("apartment-1",
"default-landlord", "Studio République", "12 rue de la République", 150000, "")));
when(tenantRepository.findAllById(List.of("tenant-1"))).thenReturn(List.of(tenant()));
when(landlordRepository.findByOidcSubject("subject-1")).thenReturn(Optional.of(new Landlord("profile-1",
"subject-1", "Léa", "Dubois", "lea@example.com", "+33612345678", "")));
mockMvc.perform(get("/api/leases/lease-1/pdf")
.with(oidcLogin().idToken(token -> token.claim("sub", "subject-1"))))
.andExpect(status().isUnprocessableContent())
.andExpect(jsonPath("$.message").value(
"Votre profil bailleur est incomplet. Complétez-le dans les réglages avant de générer un PDF."));
}
@Test
void whenLandlordProfileChanges_thenTheNextLeasePdfUsesItsUpdatedContactDetails() throws Exception {
when(leaseRepository.findById("lease-1")).thenReturn(Optional.of(lease()));
when(apartmentRepository.findById("apartment-1")).thenReturn(Optional.of(new Apartment("apartment-1",
"default-landlord", "Studio République", "12 rue de la République", 150000, "")));
when(tenantRepository.findAllById(List.of("tenant-1"))).thenReturn(List.of(tenant()));
Landlord updatedLandlord = new Landlord("profile-1", "subject-1", "Alice", "Renard",
"alice@example.com", "+33698765432", "2 rue de Lyon");
when(landlordRepository.findByOidcSubject("subject-1"))
.thenReturn(Optional.of(landlord()))
.thenReturn(Optional.of(updatedLandlord));
String firstPdfText = downloadedLeasePdfText();
String updatedPdfText = downloadedLeasePdfText();
assertTrue(firstPdfText.contains("Léa Dubois"));
assertTrue(updatedPdfText.contains("Alice Renard"));
assertTrue(updatedPdfText.contains("alice@example.com"));
assertTrue(updatedPdfText.contains("+33698765432"));
assertTrue(updatedPdfText.contains("2 rue de Lyon"));
}
private Tenant tenant() { private Tenant tenant() {
return new Tenant("tenant-1", "default-landlord", "Camille", "Martin", "camille@example.com", return new Tenant("tenant-1", "default-landlord", "Camille", "Martin", "camille@example.com",
"+33612345678", "10 rue des Lilas"); "+33612345678", "10 rue des Lilas");
} }
private Landlord landlord() {
return new Landlord("profile-1", "subject-1", "Léa", "Dubois", "lea@example.com", "+33612345678",
"1 rue de Paris");
}
private String downloadedLeasePdfText() throws Exception {
byte[] content = mockMvc.perform(get("/api/leases/lease-1/pdf")
.with(oidcLogin().idToken(token -> token.claim("sub", "subject-1"))))
.andExpect(status().isOk())
.andReturn()
.getResponse()
.getContentAsByteArray();
try (PDDocument document = Loader.loadPDF(content)) {
return new PDFTextStripper().getText(document);
}
}
private Lease lease() { private Lease lease() {
return new Lease("lease-1", "default-landlord", "apartment-1", List.of("tenant-1"), return new Lease("lease-1", "default-landlord", "apartment-1", List.of("tenant-1"),
List.of(new Guarantor("guarantor-1", "Alex", "Durand", "alex@example.com", "+33600000000", List.of(new Guarantor("guarantor-1", "Alex", "Durand", "alex@example.com", "+33600000000",
@@ -11,6 +11,9 @@ import fr.nessar.lmnporganizer.backend.repository.LandlordRepository;
import fr.nessar.lmnporganizer.backend.repository.LeaseRepository; import fr.nessar.lmnporganizer.backend.repository.LeaseRepository;
import fr.nessar.lmnporganizer.backend.repository.RentPaymentRepository; import fr.nessar.lmnporganizer.backend.repository.RentPaymentRepository;
import fr.nessar.lmnporganizer.backend.repository.TenantRepository; 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.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
@@ -104,13 +107,43 @@ class RentPaymentControllerTest {
when(rentPaymentRepository.findById("payment-1")).thenReturn(Optional.of(payment(LocalDate.of(2026, 7, 2)))); when(rentPaymentRepository.findById("payment-1")).thenReturn(Optional.of(payment(LocalDate.of(2026, 7, 2))));
when(leaseRepository.findById("lease-1")).thenReturn(Optional.of(lease())); when(leaseRepository.findById("lease-1")).thenReturn(Optional.of(lease()));
when(apartmentRepository.findById("apartment-1")).thenReturn(Optional.of(apartment())); when(apartmentRepository.findById("apartment-1")).thenReturn(Optional.of(apartment()));
when(landlordRepository.findById("default-landlord")).thenReturn(Optional.of(landlord())); when(landlordRepository.findByOidcSubject("subject-1")).thenReturn(Optional.of(landlord()));
when(tenantRepository.findAllById(List.of("tenant-1"))).thenReturn(List.of(tenant())); when(tenantRepository.findAllById(List.of("tenant-1"))).thenReturn(List.of(tenant()));
mockMvc.perform(get("/api/rent-payments/payment-1/receipt").with(oidcLogin())) mockMvc.perform(get("/api/rent-payments/payment-1/receipt")
.with(oidcLogin().idToken(token -> token.claim("sub", "subject-1"))))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(header().string("Content-Type", "application/pdf")) .andExpect(header().string("Content-Type", "application/pdf"))
.andExpect(header().string("Content-Disposition", org.hamcrest.Matchers.containsString("quittance-2026-07.pdf"))); .andExpect(header().string("Content-Disposition", org.hamcrest.Matchers.containsString("quittance-2026-07.pdf")))
.andExpect(result -> {
try (PDDocument document = Loader.loadPDF(result.getResponse().getContentAsByteArray())) {
String pdfText = new PDFTextStripper().getText(document);
if (!pdfText.contains("Bailleur LMNP") || !pdfText.contains("bailleur@example.com")
|| !pdfText.contains("0102030405")
|| !pdfText.contains("123 rue de la Gestion, Paris")) {
throw new AssertionError("Expected rendered landlord profile values");
}
} catch (java.io.IOException exception) {
throw new AssertionError("Expected a readable PDF", exception);
}
});
}
@Test
void whenLandlordProfileIsIncomplete_thenReceiptDownloadIsRejectedWithSettingsGuidance() 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(tenantRepository.findAllById(List.of("tenant-1"))).thenReturn(List.of(tenant()));
when(landlordRepository.findByOidcSubject("subject-1")).thenReturn(Optional.of(new Landlord("profile-1",
"subject-1", "Bailleur", "LMNP", "bailleur@example.com", "0102030405", "")));
mockMvc.perform(get("/api/rent-payments/payment-1/receipt")
.with(oidcLogin().idToken(token -> token.claim("sub", "subject-1"))))
.andExpect(status().isUnprocessableContent())
.andExpect(jsonPath("$.message").value(
"Votre profil bailleur est incomplet. Complétez-le dans les réglages avant de générer un PDF."));
} }
@Test @Test
@@ -10,7 +10,6 @@ import fr.nessar.lmnporganizer.backend.model.RentPaymentStatus;
import fr.nessar.lmnporganizer.backend.model.Tenant; import fr.nessar.lmnporganizer.backend.model.Tenant;
import fr.nessar.lmnporganizer.backend.repository.ApartmentRepository; import fr.nessar.lmnporganizer.backend.repository.ApartmentRepository;
import fr.nessar.lmnporganizer.backend.repository.LeaseRepository; 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.RentPaymentRepository;
import fr.nessar.lmnporganizer.backend.repository.TenantRepository; import fr.nessar.lmnporganizer.backend.repository.TenantRepository;
import org.apache.pdfbox.Loader; import org.apache.pdfbox.Loader;
@@ -51,7 +50,7 @@ class RentPaymentServiceTest {
private ApartmentRepository apartmentRepository; private ApartmentRepository apartmentRepository;
@Mock @Mock
private LandlordRepository landlordRepository; private LandlordProfileService landlordProfileService;
@Mock @Mock
private TenantRepository tenantRepository; private TenantRepository tenantRepository;
@@ -66,7 +65,7 @@ class RentPaymentServiceTest {
SpringTemplateEngine templateEngine = new SpringTemplateEngine(); SpringTemplateEngine templateEngine = new SpringTemplateEngine();
templateEngine.setTemplateResolver(resolver); templateEngine.setTemplateResolver(resolver);
service = new RentPaymentService(rentPaymentRepository, leaseRepository, apartmentRepository, service = new RentPaymentService(rentPaymentRepository, leaseRepository, apartmentRepository,
landlordRepository, tenantRepository, new RentReceiptPdfService(templateEngine), landlordProfileService, tenantRepository, new RentReceiptPdfService(templateEngine),
Clock.fixed(Instant.parse("2026-07-17T00:00:00Z"), ZoneOffset.UTC)); Clock.fixed(Instant.parse("2026-07-17T00:00:00Z"), ZoneOffset.UTC));
} }
@@ -119,10 +118,10 @@ class RentPaymentServiceTest {
when(leaseRepository.findById("lease-1")).thenReturn(Optional.of(lease( when(leaseRepository.findById("lease-1")).thenReturn(Optional.of(lease(
LocalDate.of(2026, 1, 1), LocalDate.of(2026, 12, 31)))); LocalDate.of(2026, 1, 1), LocalDate.of(2026, 12, 31))));
when(apartmentRepository.findById("apartment-1")).thenReturn(Optional.of(apartment())); when(apartmentRepository.findById("apartment-1")).thenReturn(Optional.of(apartment()));
when(landlordRepository.findById("default-landlord")).thenReturn(Optional.of(landlord())); when(landlordProfileService.requireCompleteProfileFor("subject-1")).thenReturn(landlord());
when(tenantRepository.findAllById(List.of("tenant-1"))).thenReturn(List.of(tenant())); when(tenantRepository.findAllById(List.of("tenant-1"))).thenReturn(List.of(tenant()));
ResponseEntity<byte[]> response = service.downloadReceipt("payment-1"); ResponseEntity<byte[]> response = service.downloadReceipt("payment-1", "subject-1");
assertEquals(HttpStatus.OK, response.getStatusCode()); assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals("application/pdf", response.getHeaders().getContentType().toString()); assertEquals("application/pdf", response.getHeaders().getContentType().toString());
@@ -137,7 +136,7 @@ class RentPaymentServiceTest {
RentPayment unpaid = payment("payment-2", "2026-08", LocalDate.of(2026, 8, 1), null); RentPayment unpaid = payment("payment-2", "2026-08", LocalDate.of(2026, 8, 1), null);
when(rentPaymentRepository.findById("payment-2")).thenReturn(Optional.of(unpaid)); when(rentPaymentRepository.findById("payment-2")).thenReturn(Optional.of(unpaid));
assertEquals(HttpStatus.CONFLICT, service.downloadReceipt("payment-2").getStatusCode()); assertEquals(HttpStatus.CONFLICT, service.downloadReceipt("payment-2", "subject-1").getStatusCode());
} }
private Apartment apartment() { private Apartment apartment() {
@@ -1,6 +1,7 @@
import { Injectable, signal } from '@angular/core'; import { Injectable, signal } from '@angular/core';
import { finalize, Observable } from 'rxjs'; import { finalize, Observable } from 'rxjs';
import { withoutRecordKey, withoutSetValue } from './immutable-state'; import { withoutRecordKey, withoutSetValue } from './immutable-state';
import { httpErrorMessage } from './http-error-message';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class FileDownloadService { export class FileDownloadService {
@@ -23,10 +24,29 @@ export class FileDownloadService {
.pipe(finalize(() => this.pendingKeys.update((keys) => withoutSetValue(keys, key)))) .pipe(finalize(() => this.pendingKeys.update((keys) => withoutSetValue(keys, key))))
.subscribe({ .subscribe({
next: (blob) => this.save(blob, fileName), next: (blob) => this.save(blob, fileName),
error: () => this.errors.update((errors) => ({ ...errors, [key]: errorMessage })), error: (error) => this.recordError(key, error, errorMessage),
}); });
} }
private recordError(key: string, error: unknown, fallback: string): void {
if (this.hasBlobBody(error)) {
void error.error
.text()
.then((body) => this.setError(key, httpErrorMessage({ error: JSON.parse(body) }, fallback)))
.catch(() => this.setError(key, fallback));
return;
}
this.setError(key, httpErrorMessage(error, fallback));
}
private hasBlobBody(error: unknown): error is { error: Blob } {
return typeof error === 'object' && error !== null && 'error' in error && error.error instanceof Blob;
}
private setError(key: string, message: string): void {
this.errors.update((errors) => ({ ...errors, [key]: message }));
}
private save(blob: Blob, fileName: string): void { private save(blob: Blob, fileName: string): void {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const link = document.createElement('a'); const link = document.createElement('a');
@@ -0,0 +1,11 @@
export function httpErrorMessage(error: unknown, fallback: string): string {
return typeof error === 'object' &&
error !== null &&
'error' in error &&
typeof error.error === 'object' &&
error.error !== null &&
'message' in error.error &&
typeof error.error.message === 'string'
? error.error.message
: fallback;
}
@@ -4,6 +4,7 @@ import { finalize } from 'rxjs';
import { FileDownloadService } from './file-download.service'; import { FileDownloadService } from './file-download.service';
import { TenantAttachment, TenantService } from './tenant.service'; import { TenantAttachment, TenantService } from './tenant.service';
import { withoutRecordKey, withoutSetValue } from './immutable-state'; import { withoutRecordKey, withoutSetValue } from './immutable-state';
import { httpErrorMessage } from './http-error-message';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class TenantAttachmentTransferService { export class TenantAttachmentTransferService {
@@ -54,7 +55,7 @@ export class TenantAttachmentTransferService {
} }
}, },
error: (error: unknown) => { error: (error: unknown) => {
const message = this.errorMessage(error, 'Impossible d’envoyer cette pièce jointe.'); const message = httpErrorMessage(error, 'Impossible d’envoyer cette pièce jointe.');
this.errors.update((errors) => ({ ...errors, [tenantId]: message })); this.errors.update((errors) => ({ ...errors, [tenantId]: message }));
}, },
}); });
@@ -98,15 +99,4 @@ export class TenantAttachmentTransferService {
return `attachment:${attachmentId}`; return `attachment:${attachmentId}`;
} }
private errorMessage(error: unknown, fallback: string): string {
return typeof error === 'object' &&
error !== null &&
'error' in error &&
typeof error.error === 'object' &&
error.error !== null &&
'message' in error.error &&
typeof error.error.message === 'string'
? error.error.message
: fallback;
}
} }
@@ -415,4 +415,21 @@ describe('WorkspaceService', () => {
'Impossible d’envoyer cette pièce jointe.', 'Impossible d’envoyer cette pièce jointe.',
); );
}); });
it('keeps profile-completion guidance returned as a blocked PDF download blob', async () => {
const service = TestBed.inject(WorkspaceService);
leases.downloadPdf.mockReturnValueOnce(
throwError(() => ({
error: new Blob(['{"message":"Complétez votre profil dans les réglages."}'], {
type: 'application/json',
}),
})),
);
service.downloadLeasePdf(lease);
await vi.waitFor(() =>
expect(service.leaseDownloadError(lease.id)).toBe('Complétez votre profil dans les réglages.'),
);
});
}); });