From 668680e8442f8fd599ac59aa7d09adb23132183a Mon Sep 17 00:00:00 2001
From: Nessar
Date: Fri, 17 Jul 2026 17:41:38 +0200
Subject: [PATCH] feat(#23): use landlord profiles in PDFs
---
.../controller/ApiExceptionHandler.java | 6 ++
.../backend/controller/LeaseController.java | 6 +-
.../controller/RentPaymentController.java | 6 +-
.../IncompleteLandlordProfileException.java | 7 ++
.../service/LandlordProfileService.java | 19 +++++
.../backend/service/LeasePdfService.java | 4 +-
.../backend/service/LeaseService.java | 10 ++-
.../backend/service/RentPaymentService.java | 13 ++--
.../src/main/resources/templates/lease.html | 4 ++
.../resources/templates/rent-receipt.html | 2 +
.../controller/LeaseControllerTest.java | 69 ++++++++++++++++++-
.../controller/RentPaymentControllerTest.java | 39 ++++++++++-
.../service/RentPaymentServiceTest.java | 11 ++-
.../src/app/services/file-download.service.ts | 22 +++++-
.../src/app/services/http-error-message.ts | 11 +++
.../tenant-attachment-transfer.service.ts | 14 +---
.../app/services/workspace.service.spec.ts | 17 +++++
17 files changed, 221 insertions(+), 39 deletions(-)
create mode 100644 backend/src/main/java/fr/nessar/lmnporganizer/backend/exception/IncompleteLandlordProfileException.java
create mode 100644 frontend/src/app/services/http-error-message.ts
diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/ApiExceptionHandler.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/ApiExceptionHandler.java
index c2c86ec..c3539aa 100644
--- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/ApiExceptionHandler.java
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/ApiExceptionHandler.java
@@ -3,6 +3,7 @@ package fr.nessar.lmnporganizer.backend.controller;
import fr.nessar.lmnporganizer.backend.exception.AttachmentTooLargeException;
import fr.nessar.lmnporganizer.backend.exception.EmailAlreadyUsedException;
import fr.nessar.lmnporganizer.backend.exception.InvalidPhoneNumberException;
+import fr.nessar.lmnporganizer.backend.exception.IncompleteLandlordProfileException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
@@ -32,6 +33,11 @@ public class ApiExceptionHandler {
return error(HttpStatus.CONFLICT, exception.getMessage());
}
+ @ExceptionHandler(IncompleteLandlordProfileException.class)
+ public ResponseEntity handleIncompleteLandlordProfile(IncompleteLandlordProfileException exception) {
+ return error(HttpStatus.UNPROCESSABLE_CONTENT, exception.getMessage());
+ }
+
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity handleInvalidRequest(MethodArgumentNotValidException exception) {
String message = exception.getBindingResult().getFieldErrors().stream()
diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/LeaseController.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/LeaseController.java
index 8f5851a..4d7defa 100644
--- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/LeaseController.java
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/LeaseController.java
@@ -5,6 +5,8 @@ import fr.nessar.lmnporganizer.backend.dto.LeaseResponse;
import fr.nessar.lmnporganizer.backend.service.LeaseService;
import jakarta.validation.Valid;
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 java.util.List;
@@ -45,7 +47,7 @@ public class LeaseController {
}
@GetMapping("/{id}/pdf")
- public ResponseEntity downloadPdf(@PathVariable String id) {
- return leaseService.downloadPdf(id);
+ public ResponseEntity downloadPdf(@PathVariable String id, @AuthenticationPrincipal OidcUser principal) {
+ return leaseService.downloadPdf(id, principal.getSubject());
}
}
diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/RentPaymentController.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/RentPaymentController.java
index 6c2473c..df6287f 100644
--- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/RentPaymentController.java
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/RentPaymentController.java
@@ -3,6 +3,8 @@ 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.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.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
@@ -33,7 +35,7 @@ public class RentPaymentController {
}
@GetMapping("/{id}/receipt")
- public ResponseEntity downloadReceipt(@PathVariable String id) {
- return rentPaymentService.downloadReceipt(id);
+ public ResponseEntity downloadReceipt(@PathVariable String id, @AuthenticationPrincipal OidcUser principal) {
+ return rentPaymentService.downloadReceipt(id, principal.getSubject());
}
}
diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/exception/IncompleteLandlordProfileException.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/exception/IncompleteLandlordProfileException.java
new file mode 100644
index 0000000..9b0aac6
--- /dev/null
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/exception/IncompleteLandlordProfileException.java
@@ -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.");
+ }
+}
diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/LandlordProfileService.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/LandlordProfileService.java
index 39a9045..b4f6925 100644
--- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/LandlordProfileService.java
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/LandlordProfileService.java
@@ -3,6 +3,7 @@ package fr.nessar.lmnporganizer.backend.service;
import fr.nessar.lmnporganizer.backend.dto.LandlordProfileRequest;
import fr.nessar.lmnporganizer.backend.dto.LandlordProfileResponse;
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.repository.LandlordRepository;
import org.springframework.dao.DuplicateKeyException;
@@ -43,6 +44,15 @@ public class LandlordProfileService {
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) {
try {
return landlordRepository.save(landlord);
@@ -64,4 +74,13 @@ public class LandlordProfileService {
private String value(String 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();
+ }
}
diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/LeasePdfService.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/LeasePdfService.java
index 0cb4bab..ce2f50f 100644
--- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/LeasePdfService.java
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/LeasePdfService.java
@@ -3,6 +3,7 @@ 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.Tenant;
import org.springframework.stereotype.Service;
import org.thymeleaf.TemplateEngine;
@@ -21,10 +22,11 @@ public class LeasePdfService {
this.templateEngine = templateEngine;
}
- public byte[] generate(Lease lease, Apartment apartment, List tenants) {
+ public byte[] generate(Lease lease, Apartment apartment, Landlord landlord, List tenants) {
Context context = new Context(Locale.FRANCE);
context.setVariable("lease", lease);
context.setVariable("apartment", apartment);
+ context.setVariable("landlord", landlord);
context.setVariable("tenantNames", tenantNamesOf(tenants));
context.setVariable("guarantorNames", namesOf(lease.getGuarantors().stream()
.map(guarantor -> guarantor.getFirstName() + " " + guarantor.getLastName()).toList()));
diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/LeaseService.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/LeaseService.java
index b523c2c..a50995a 100644
--- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/LeaseService.java
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/LeaseService.java
@@ -5,6 +5,7 @@ import fr.nessar.lmnporganizer.backend.dto.LeaseResponse;
import fr.nessar.lmnporganizer.backend.mapper.LeaseMapper;
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.Tenant;
import fr.nessar.lmnporganizer.backend.repository.ApartmentRepository;
import fr.nessar.lmnporganizer.backend.repository.LeaseRepository;
@@ -29,11 +30,12 @@ public class LeaseService {
private final PhoneNumberNormalizer phoneNumberNormalizer;
private final LeaseMapper leaseMapper;
private final RentPaymentService rentPaymentService;
+ private final LandlordProfileService landlordProfileService;
public LeaseService(LeaseRepository leaseRepository, ApartmentRepository apartmentRepository,
TenantRepository tenantRepository, LeasePdfService leasePdfService,
PhoneNumberNormalizer phoneNumberNormalizer, LeaseMapper leaseMapper,
- RentPaymentService rentPaymentService) {
+ RentPaymentService rentPaymentService, LandlordProfileService landlordProfileService) {
this.leaseRepository = leaseRepository;
this.apartmentRepository = apartmentRepository;
this.tenantRepository = tenantRepository;
@@ -41,6 +43,7 @@ public class LeaseService {
this.phoneNumberNormalizer = phoneNumberNormalizer;
this.leaseMapper = leaseMapper;
this.rentPaymentService = rentPaymentService;
+ this.landlordProfileService = landlordProfileService;
}
public List getAll() {
@@ -92,7 +95,7 @@ public class LeaseService {
return ResponseEntity.noContent().build();
}
- public ResponseEntity downloadPdf(String id) {
+ public ResponseEntity downloadPdf(String id, String oidcSubject) {
Lease lease = leaseRepository.findById(id).orElse(null);
if (lease == null) {
return ResponseEntity.notFound().build();
@@ -102,11 +105,12 @@ public class LeaseService {
if (apartment == null || tenants.size() != new HashSet<>(lease.getTenantIds()).size()) {
return ResponseEntity.notFound().build();
}
+ Landlord landlord = landlordProfileService.requireCompleteProfileFor(oidcSubject);
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_PDF)
.header(HttpHeaders.CONTENT_DISPOSITION,
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) {
diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/RentPaymentService.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/RentPaymentService.java
index 2dcffbd..8c4e711 100644
--- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/RentPaymentService.java
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/RentPaymentService.java
@@ -8,7 +8,6 @@ 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;
@@ -31,19 +30,19 @@ public class RentPaymentService {
private final RentPaymentRepository rentPaymentRepository;
private final LeaseRepository leaseRepository;
private final ApartmentRepository apartmentRepository;
- private final LandlordRepository landlordRepository;
+ private final LandlordProfileService landlordProfileService;
private final TenantRepository tenantRepository;
private final RentReceiptPdfService rentReceiptPdfService;
private final Clock clock;
public RentPaymentService(RentPaymentRepository rentPaymentRepository, LeaseRepository leaseRepository,
- ApartmentRepository apartmentRepository, LandlordRepository landlordRepository,
+ ApartmentRepository apartmentRepository, LandlordProfileService landlordProfileService,
TenantRepository tenantRepository,
RentReceiptPdfService rentReceiptPdfService, Clock clock) {
this.rentPaymentRepository = rentPaymentRepository;
this.leaseRepository = leaseRepository;
this.apartmentRepository = apartmentRepository;
- this.landlordRepository = landlordRepository;
+ this.landlordProfileService = landlordProfileService;
this.tenantRepository = tenantRepository;
this.rentReceiptPdfService = rentReceiptPdfService;
this.clock = clock;
@@ -65,7 +64,7 @@ public class RentPaymentService {
}).orElse(ResponseEntity.notFound().build());
}
- public ResponseEntity downloadReceipt(String id) {
+ public ResponseEntity downloadReceipt(String id, String oidcSubject) {
RentPayment payment = rentPaymentRepository.findById(id).orElse(null);
if (payment == null)
return ResponseEntity.notFound().build();
@@ -76,9 +75,9 @@ public class RentPaymentService {
if (lease == null)
return ResponseEntity.notFound().build();
Apartment apartment = apartmentRepository.findById(lease.getApartmentId()).orElse(null);
- Landlord landlord = landlordRepository.findById(payment.getLandlordId()).orElse(null);
+ Landlord landlord = landlordProfileService.requireCompleteProfileFor(oidcSubject);
List 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();
}
diff --git a/backend/src/main/resources/templates/lease.html b/backend/src/main/resources/templates/lease.html
index 1140321..ea84b05 100644
--- a/backend/src/main/resources/templates/lease.html
+++ b/backend/src/main/resources/templates/lease.html
@@ -19,6 +19,10 @@
| Logement | Studio République |
| Adresse | 12 rue de la République |
+ | Bailleur | Léa Dubois |
+ | E-mail du bailleur | lea@example.com |
+ | Téléphone du bailleur | +33612345678 |
+ | Adresse du bailleur | 1 rue de Paris |
| Locataire(s) | Camille Martin |
| Période | 2026-09-01 au 2027-08-31 |
| Loyer mensuel | 850.00 EUR |
diff --git a/backend/src/main/resources/templates/rent-receipt.html b/backend/src/main/resources/templates/rent-receipt.html
index 7f55424..72981e5 100644
--- a/backend/src/main/resources/templates/rent-receipt.html
+++ b/backend/src/main/resources/templates/rent-receipt.html
@@ -17,6 +17,8 @@
Le bailleur reconnaît avoir reçu le paiement du loyer et des charges pour la période indiquée.
| Bailleur | Bailleur LMNP |
+ | E-mail du bailleur | bailleur@example.com |
+ | Téléphone du bailleur | 0102030405 |
| Adresse du bailleur | 123 rue de la Gestion, Paris |
| Logement | Studio République |
| Adresse | 12 rue de la République |
diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/LeaseControllerTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/LeaseControllerTest.java
index 43e8591..dcf364d 100644
--- a/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/LeaseControllerTest.java
+++ b/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/LeaseControllerTest.java
@@ -4,8 +4,10 @@ import fr.nessar.lmnporganizer.backend.model.Apartment;
import fr.nessar.lmnporganizer.backend.model.Guarantor;
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.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.TenantRepository;
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.Mockito.*;
+import static org.junit.jupiter.api.Assertions.assertTrue;
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.result.MockMvcResultMatchers.*;
@@ -46,6 +49,9 @@ class LeaseControllerTest {
@MockitoBean
private TenantRepository tenantRepository;
+ @MockitoBean
+ private LandlordRepository landlordRepository;
+
@MockitoBean
private RentPaymentService rentPaymentService;
@@ -142,8 +148,10 @@ class LeaseControllerTest {
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(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(header().string("Content-Type", "application/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)) {
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");
}
} 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() {
return new Tenant("tenant-1", "default-landlord", "Camille", "Martin", "camille@example.com",
"+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() {
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",
diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/RentPaymentControllerTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/RentPaymentControllerTest.java
index ca5c123..a390348 100644
--- a/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/RentPaymentControllerTest.java
+++ b/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/RentPaymentControllerTest.java
@@ -11,6 +11,9 @@ 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.apache.pdfbox.Loader;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.text.PDFTextStripper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
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(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(landlordRepository.findByOidcSubject("subject-1")).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()))
+ mockMvc.perform(get("/api/rent-payments/payment-1/receipt")
+ .with(oidcLogin().idToken(token -> token.claim("sub", "subject-1"))))
.andExpect(status().isOk())
.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
diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/backend/service/RentPaymentServiceTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/backend/service/RentPaymentServiceTest.java
index f66efc5..d303a37 100644
--- a/backend/src/test/java/fr/nessar/lmnporganizer/backend/service/RentPaymentServiceTest.java
+++ b/backend/src/test/java/fr/nessar/lmnporganizer/backend/service/RentPaymentServiceTest.java
@@ -10,7 +10,6 @@ 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;
@@ -51,7 +50,7 @@ class RentPaymentServiceTest {
private ApartmentRepository apartmentRepository;
@Mock
- private LandlordRepository landlordRepository;
+ private LandlordProfileService landlordProfileService;
@Mock
private TenantRepository tenantRepository;
@@ -66,7 +65,7 @@ class RentPaymentServiceTest {
SpringTemplateEngine templateEngine = new SpringTemplateEngine();
templateEngine.setTemplateResolver(resolver);
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));
}
@@ -119,10 +118,10 @@ class RentPaymentServiceTest {
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(landlordProfileService.requireCompleteProfileFor("subject-1")).thenReturn(landlord());
when(tenantRepository.findAllById(List.of("tenant-1"))).thenReturn(List.of(tenant()));
- ResponseEntity response = service.downloadReceipt("payment-1");
+ ResponseEntity response = service.downloadReceipt("payment-1", "subject-1");
assertEquals(HttpStatus.OK, response.getStatusCode());
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);
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() {
diff --git a/frontend/src/app/services/file-download.service.ts b/frontend/src/app/services/file-download.service.ts
index 7681400..142ba8c 100644
--- a/frontend/src/app/services/file-download.service.ts
+++ b/frontend/src/app/services/file-download.service.ts
@@ -1,6 +1,7 @@
import { Injectable, signal } from '@angular/core';
import { finalize, Observable } from 'rxjs';
import { withoutRecordKey, withoutSetValue } from './immutable-state';
+import { httpErrorMessage } from './http-error-message';
@Injectable({ providedIn: 'root' })
export class FileDownloadService {
@@ -23,10 +24,29 @@ export class FileDownloadService {
.pipe(finalize(() => this.pendingKeys.update((keys) => withoutSetValue(keys, key))))
.subscribe({
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 {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
diff --git a/frontend/src/app/services/http-error-message.ts b/frontend/src/app/services/http-error-message.ts
new file mode 100644
index 0000000..ad919d1
--- /dev/null
+++ b/frontend/src/app/services/http-error-message.ts
@@ -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;
+}
diff --git a/frontend/src/app/services/tenant-attachment-transfer.service.ts b/frontend/src/app/services/tenant-attachment-transfer.service.ts
index b9bfb63..91f3cbb 100644
--- a/frontend/src/app/services/tenant-attachment-transfer.service.ts
+++ b/frontend/src/app/services/tenant-attachment-transfer.service.ts
@@ -4,6 +4,7 @@ import { finalize } from 'rxjs';
import { FileDownloadService } from './file-download.service';
import { TenantAttachment, TenantService } from './tenant.service';
import { withoutRecordKey, withoutSetValue } from './immutable-state';
+import { httpErrorMessage } from './http-error-message';
@Injectable({ providedIn: 'root' })
export class TenantAttachmentTransferService {
@@ -54,7 +55,7 @@ export class TenantAttachmentTransferService {
}
},
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 }));
},
});
@@ -98,15 +99,4 @@ export class TenantAttachmentTransferService {
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;
- }
}
diff --git a/frontend/src/app/services/workspace.service.spec.ts b/frontend/src/app/services/workspace.service.spec.ts
index 55e5b40..948bddd 100644
--- a/frontend/src/app/services/workspace.service.spec.ts
+++ b/frontend/src/app/services/workspace.service.spec.ts
@@ -415,4 +415,21 @@ describe('WorkspaceService', () => {
'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.'),
+ );
+ });
});