feat: add unit and integration tests for rental management domain

- Introduced RentPaymentCorrectionTest and RentPaymentTest to validate payment and refund logic.
- Added PhoneNumberTest to ensure proper normalization of phone numbers.
- Created TenantTest to verify attachment management and tenant referencing.
- Implemented ReplacementRentalApiJourneyIT for end-to-end testing of rental and payment workflows.
- Documented architectural decisions in ADR-0014, emphasizing strict DDD boundaries and transaction management.
This commit is contained in:
nessar committed 2026-08-17 14:18:15 +02:00
1 parent 6ce0719a20
commit e02ad54bcb
263 files changed
+10847 -7183

No files matched your search

-14
View File
@@ -28,7 +28,6 @@
</scm>
<properties>
<java.version>25</java.version>
<mapstruct.version>1.6.3</mapstruct.version>
</properties>
<dependencies>
<dependency>
@@ -60,12 +59,6 @@
<artifactId>openhtmltopdf-pdfbox</artifactId>
<version>1.1.40</version>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${mapstruct.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
@@ -94,13 +87,6 @@
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
</path>
</annotationProcessorPaths>
<!-- Deprecated APIs are forbidden in production and test code. -->
<compilerArgs>
<arg>-Xlint:deprecation</arg>
@@ -0,0 +1,16 @@
package fr.nessar.lmnporganizer;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.data.mongodb.autoconfigure.DataMongoRepositoriesAutoConfiguration;
import org.springframework.boot.persistence.autoconfigure.EntityScan;
@EntityScan(basePackages = "fr.nessar.lmnporganizer.rental.adapter.out.mongodb")
@SpringBootApplication(
scanBasePackages = {"fr.nessar.lmnporganizer.rental", "fr.nessar.lmnporganizer.identity"},
exclude = DataMongoRepositoriesAutoConfiguration.class)
public class LmnpOrganizerApplication {
public static void main(String[] args) {
SpringApplication.run(LmnpOrganizerApplication.class, args);
}
}
@@ -1,13 +0,0 @@
package fr.nessar.lmnporganizer.backend;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class BackendApplication {
public static void main(String[] args) {
SpringApplication.run(BackendApplication.class, args);
}
}
@@ -1,21 +0,0 @@
package fr.nessar.lmnporganizer.backend;
import fr.nessar.lmnporganizer.backend.service.GreetingService;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class HelloController {
private final GreetingService greetingService;
public HelloController(GreetingService greetingService) {
this.greetingService = greetingService;
}
@GetMapping("/api/hello")
public String hello(@AuthenticationPrincipal OAuth2User user) {
return greetingService.greetingFor(user);
}
}
@@ -1,47 +0,0 @@
package fr.nessar.lmnporganizer.backend;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/api/hello").authenticated()
.requestMatchers("/api/apartments", "/api/apartments/**").authenticated()
.requestMatchers("/api/tenants", "/api/tenants/**").authenticated()
.requestMatchers("/api/leases", "/api/leases/**").authenticated()
.requestMatchers("/api/rent-dues", "/api/rent-dues/**").authenticated()
.requestMatchers("/api/rent-payments", "/api/rent-payments/**").authenticated()
.requestMatchers("/api/landlord-profile").authenticated()
.requestMatchers("/api/user").permitAll()
.anyRequest().permitAll()
)
.exceptionHandling(exceptions -> exceptions
.defaultAuthenticationEntryPointFor(
new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED),
PathPatternRequestMatcher.pathPattern("/api/**")
)
)
.oauth2Login(oauth2 -> oauth2
.defaultSuccessUrl("/", true)
)
.logout(logout -> logout
.logoutRequestMatcher(PathPatternRequestMatcher.pathPattern(HttpMethod.GET, "/logout"))
.logoutSuccessUrl("/")
)
.csrf(csrf -> csrf.disable());
return http.build();
}
}
@@ -1,14 +0,0 @@
package fr.nessar.lmnporganizer.backend.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.Clock;
@Configuration
public class TimeConfiguration {
@Bean
Clock clock() {
return Clock.systemDefaultZone();
}
}
@@ -1,59 +0,0 @@
package fr.nessar.lmnporganizer.backend.controller;
import fr.nessar.lmnporganizer.backend.dto.ApartmentRequest;
import fr.nessar.lmnporganizer.backend.dto.ApartmentResponse;
import fr.nessar.lmnporganizer.backend.service.ApartmentService;
import fr.nessar.lmnporganizer.backend.service.LandlordProfileService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
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;
@RestController
@RequestMapping("/api/apartments")
public class ApartmentController {
private final ApartmentService apartmentService;
private final LandlordProfileService landlordProfileService;
public ApartmentController(ApartmentService apartmentService, LandlordProfileService landlordProfileService) {
this.apartmentService = apartmentService;
this.landlordProfileService = landlordProfileService;
}
@GetMapping
public List<ApartmentResponse> getAllApartments(@AuthenticationPrincipal OidcUser principal) {
return apartmentService.getAll(landlordId(principal));
}
@GetMapping("/{id}")
public ResponseEntity<ApartmentResponse> getApartmentById(@AuthenticationPrincipal OidcUser principal,
@PathVariable String id) {
return ResponseEntity.of(apartmentService.getById(landlordId(principal), id));
}
@PostMapping
public ResponseEntity<ApartmentResponse> createApartment(@AuthenticationPrincipal OidcUser principal,
@Valid @RequestBody ApartmentRequest request) {
return ResponseEntity.status(HttpStatus.CREATED).body(apartmentService.create(landlordId(principal), request));
}
@PutMapping("/{id}")
public ResponseEntity<ApartmentResponse> updateApartment(@AuthenticationPrincipal OidcUser principal,
@PathVariable String id, @Valid @RequestBody ApartmentRequest request) {
return ResponseEntity.of(apartmentService.update(landlordId(principal), id, request));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteApartment(@AuthenticationPrincipal OidcUser principal, @PathVariable String id) {
return apartmentService.delete(landlordId(principal), id) ? ResponseEntity.noContent().build()
: ResponseEntity.notFound().build();
}
private String landlordId(OidcUser principal) {
return landlordProfileService.findOrCreate(principal).getId();
}
}
@@ -1,81 +0,0 @@
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 fr.nessar.lmnporganizer.backend.exception.LeaseConflictException;
import fr.nessar.lmnporganizer.backend.exception.LeaseValidationException;
import fr.nessar.lmnporganizer.backend.exception.ReferencedByLeaseException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import java.io.IOException;
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(AttachmentTooLargeException.class)
public ResponseEntity<ApiError> handleAttachmentTooLarge(AttachmentTooLargeException exception) {
return error(HttpStatus.CONTENT_TOO_LARGE, exception.getMessage());
}
@ExceptionHandler(MaxUploadSizeExceededException.class)
public ResponseEntity<ApiError> handleMultipartTooLarge() {
return error(HttpStatus.CONTENT_TOO_LARGE, "Une pièce jointe ne peut pas dépasser 250 Mo.");
}
@ExceptionHandler(InvalidPhoneNumberException.class)
public ResponseEntity<ApiError> handleInvalidPhone(InvalidPhoneNumberException exception) {
return error(HttpStatus.BAD_REQUEST, exception.getMessage());
}
@ExceptionHandler(EmailAlreadyUsedException.class)
public ResponseEntity<ApiError> handleDuplicateEmail(EmailAlreadyUsedException exception) {
return error(HttpStatus.CONFLICT, exception.getMessage());
}
@ExceptionHandler(IncompleteLandlordProfileException.class)
public ResponseEntity<ApiError> handleIncompleteLandlordProfile(IncompleteLandlordProfileException exception) {
return error(HttpStatus.UNPROCESSABLE_CONTENT, exception.getMessage());
}
@ExceptionHandler(ReferencedByLeaseException.class)
public ResponseEntity<ApiError> handleReferencedByLease(ReferencedByLeaseException exception) {
return error(HttpStatus.CONFLICT, exception.getMessage());
}
@ExceptionHandler(LeaseValidationException.class)
public ResponseEntity<ApiError> handleLeaseValidation(LeaseValidationException exception) {
return error(HttpStatus.BAD_REQUEST, exception.getMessage());
}
@ExceptionHandler(LeaseConflictException.class)
public ResponseEntity<ApiError> handleLeaseConflict(LeaseConflictException exception) {
return error(HttpStatus.CONFLICT, exception.getMessage());
}
@ExceptionHandler(IOException.class)
public ResponseEntity<ApiError> handleAttachmentIOException() {
return error(HttpStatus.INTERNAL_SERVER_ERROR, "Impossible de traiter la pièce jointe.");
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiError> handleInvalidRequest(MethodArgumentNotValidException exception) {
String message = exception.getBindingResult().getFieldErrors().stream()
.findFirst()
.map(error -> error.getDefaultMessage())
.orElse("La requête est invalide.");
return error(HttpStatus.BAD_REQUEST, message);
}
private ResponseEntity<ApiError> error(HttpStatus status, String message) {
return ResponseEntity.status(status).body(new ApiError(message));
}
private record ApiError(String message) {
}
}
@@ -1,34 +0,0 @@
package fr.nessar.lmnporganizer.backend.controller;
import fr.nessar.lmnporganizer.backend.dto.LandlordProfileRequest;
import fr.nessar.lmnporganizer.backend.dto.LandlordProfileResponse;
import fr.nessar.lmnporganizer.backend.service.LandlordProfileService;
import jakarta.validation.Valid;
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.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/landlord-profile")
public class LandlordProfileController {
private final LandlordProfileService landlordProfileService;
public LandlordProfileController(LandlordProfileService landlordProfileService) {
this.landlordProfileService = landlordProfileService;
}
@GetMapping
public LandlordProfileResponse getProfile(@AuthenticationPrincipal OidcUser principal) {
return landlordProfileService.getProfile(principal);
}
@PutMapping
public LandlordProfileResponse updateProfile(@AuthenticationPrincipal OidcUser principal,
@Valid @RequestBody LandlordProfileRequest request) {
return landlordProfileService.updateProfile(principal, request);
}
}
@@ -1,70 +0,0 @@
package fr.nessar.lmnporganizer.backend.controller;
import fr.nessar.lmnporganizer.backend.dto.LeaseActivationResponse;
import fr.nessar.lmnporganizer.backend.dto.LeaseRequest;
import fr.nessar.lmnporganizer.backend.dto.LeaseResponse;
import fr.nessar.lmnporganizer.backend.service.LandlordProfileService;
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;
@RestController
@RequestMapping("/api/leases")
public class LeaseController {
private final LeaseService leaseService;
private final LandlordProfileService landlordProfileService;
public LeaseController(LeaseService leaseService, LandlordProfileService landlordProfileService) {
this.leaseService = leaseService;
this.landlordProfileService = landlordProfileService;
}
@GetMapping
public List<LeaseResponse> getAllLeases(@AuthenticationPrincipal OidcUser principal) {
return leaseService.getAll(landlordId(principal));
}
@GetMapping("/{id}")
public ResponseEntity<LeaseResponse> getLease(@AuthenticationPrincipal OidcUser principal,
@PathVariable String id) {
return leaseService.getById(landlordId(principal), id);
}
@PostMapping
public ResponseEntity<LeaseResponse> createLease(@AuthenticationPrincipal OidcUser principal,
@Valid @RequestBody LeaseRequest request) {
return leaseService.create(landlordId(principal), request);
}
@PutMapping("/{id}")
public ResponseEntity<LeaseResponse> updateLease(@AuthenticationPrincipal OidcUser principal,
@PathVariable String id,
@Valid @RequestBody LeaseRequest request) {
return leaseService.update(landlordId(principal), id, request);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteLease(@AuthenticationPrincipal OidcUser principal, @PathVariable String id) {
return leaseService.delete(landlordId(principal), id);
}
@PostMapping("/{id}/activate")
public ResponseEntity<LeaseActivationResponse> activateLease(@AuthenticationPrincipal OidcUser principal,
@PathVariable String id) {
return leaseService.activate(landlordId(principal), id);
}
@GetMapping("/{id}/pdf")
public ResponseEntity<byte[]> downloadPdf(@PathVariable String id, @AuthenticationPrincipal OidcUser principal) {
return leaseService.downloadPdf(landlordId(principal), id, principal.getSubject());
}
private String landlordId(OidcUser principal) {
return landlordProfileService.findOrCreate(principal).getId();
}
}
@@ -1,29 +0,0 @@
package fr.nessar.lmnporganizer.backend.controller;
import fr.nessar.lmnporganizer.backend.dto.RentDueResponse;
import fr.nessar.lmnporganizer.backend.service.LandlordProfileService;
import fr.nessar.lmnporganizer.backend.service.RentDueService;
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.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/rent-dues")
public class RentDueController {
private final RentDueService rentDueService;
private final LandlordProfileService landlordProfileService;
public RentDueController(RentDueService rentDueService, LandlordProfileService landlordProfileService) {
this.rentDueService = rentDueService;
this.landlordProfileService = landlordProfileService;
}
@GetMapping
public List<RentDueResponse> getRentDues(@AuthenticationPrincipal OidcUser principal) {
return rentDueService.getAll(landlordProfileService.findOrCreate(principal).getId());
}
}
@@ -1,41 +0,0 @@
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;
import org.springframework.web.bind.annotation.RequestBody;
import fr.nessar.lmnporganizer.backend.dto.RentPaymentPaidRequest;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
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, @RequestBody RentPaymentPaidRequest request) {
return rentPaymentService.markPaid(id, request.getRent(), request.getCharges());
}
@GetMapping("/{id}/receipt")
public ResponseEntity<byte[]> downloadReceipt(@PathVariable String id, @AuthenticationPrincipal OidcUser principal) {
return rentPaymentService.downloadReceipt(id, principal.getSubject());
}
}
@@ -1,107 +0,0 @@
package fr.nessar.lmnporganizer.backend.controller;
import fr.nessar.lmnporganizer.backend.dto.TenantAttachmentResponse;
import fr.nessar.lmnporganizer.backend.dto.TenantRequest;
import fr.nessar.lmnporganizer.backend.dto.TenantResponse;
import fr.nessar.lmnporganizer.backend.service.LandlordProfileService;
import fr.nessar.lmnporganizer.backend.service.TenantService;
import jakarta.validation.Valid;
import org.springframework.core.io.Resource;
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.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.List;
@RestController
@RequestMapping("/api/tenants")
public class TenantController {
private final TenantService tenantService;
private final LandlordProfileService landlordProfileService;
public TenantController(TenantService tenantService, LandlordProfileService landlordProfileService) {
this.tenantService = tenantService;
this.landlordProfileService = landlordProfileService;
}
@GetMapping
public List<TenantResponse> getAllTenants(@AuthenticationPrincipal OidcUser principal) {
return tenantService.getAll(landlordId(principal));
}
@GetMapping("/{id}")
public ResponseEntity<TenantResponse> getTenant(@AuthenticationPrincipal OidcUser principal,
@PathVariable String id) {
return ResponseEntity.of(tenantService.getById(landlordId(principal), id));
}
@PostMapping
public ResponseEntity<TenantResponse> createTenant(@AuthenticationPrincipal OidcUser principal,
@Valid @RequestBody TenantRequest request) {
return ResponseEntity.status(HttpStatus.CREATED).body(tenantService.create(landlordId(principal), request));
}
@PutMapping("/{id}")
public ResponseEntity<TenantResponse> updateTenant(@AuthenticationPrincipal OidcUser principal,
@PathVariable String id,
@Valid @RequestBody TenantRequest request) {
return ResponseEntity.of(tenantService.update(landlordId(principal), id, request));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteTenant(@AuthenticationPrincipal OidcUser principal, @PathVariable String id)
throws IOException {
return tenantService.delete(landlordId(principal), id) ? ResponseEntity.noContent().build()
: ResponseEntity.notFound().build();
}
@PostMapping(path = "/{tenantId}/attachments", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<TenantAttachmentResponse> uploadAttachment(@AuthenticationPrincipal OidcUser principal,
@PathVariable String tenantId,
@RequestPart("file") MultipartFile file) throws IOException {
return tenantService.uploadAttachment(landlordId(principal), tenantId, file)
.map(attachment -> ResponseEntity.status(HttpStatus.CREATED).body(attachment))
.orElse(ResponseEntity.notFound().build());
}
@GetMapping("/{tenantId}/attachments/{attachmentId}")
public ResponseEntity<Resource> downloadAttachment(@AuthenticationPrincipal OidcUser principal,
@PathVariable String tenantId, @PathVariable String attachmentId)
throws IOException {
return tenantService.downloadAttachment(landlordId(principal), tenantId, attachmentId)
.map(download -> ResponseEntity.ok()
.contentType(mediaTypeFor(download.contentType()))
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment().filename(download.fileName()).build().toString())
.body(download.resource()))
.orElse(ResponseEntity.notFound().build());
}
@DeleteMapping("/{tenantId}/attachments/{attachmentId}")
public ResponseEntity<Void> deleteAttachment(@AuthenticationPrincipal OidcUser principal,
@PathVariable String tenantId, @PathVariable String attachmentId)
throws IOException {
return tenantService.deleteAttachment(landlordId(principal), tenantId, attachmentId)
? ResponseEntity.noContent().build()
: ResponseEntity.notFound().build();
}
private String landlordId(OidcUser principal) {
return landlordProfileService.findOrCreate(principal).getId();
}
private MediaType mediaTypeFor(String contentType) {
try {
return MediaType.parseMediaType(contentType);
} catch (IllegalArgumentException exception) {
return MediaType.APPLICATION_OCTET_STREAM;
}
}
}
@@ -1,24 +0,0 @@
package fr.nessar.lmnporganizer.backend.controller;
import fr.nessar.lmnporganizer.backend.dto.UserResponse;
import fr.nessar.lmnporganizer.backend.service.UserService;
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.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping("/user")
public UserResponse getUser(@AuthenticationPrincipal OidcUser principal) {
return userService.getUser(principal);
}
}
@@ -1,11 +0,0 @@
package fr.nessar.lmnporganizer.backend.dto;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
public record ApartmentRequest(
@NotBlank(message = "Le nom est obligatoire.") String name,
@NotBlank(message = "L'adresse est obligatoire.") String address,
@DecimalMin(value = "1", message = "Le prix d'achat doit être supérieur ou égal à 1.") double purchasePrice,
String description) {
}
@@ -1,4 +0,0 @@
package fr.nessar.lmnporganizer.backend.dto;
public record ApartmentResponse(String id, String name, String address, double purchasePrice, String description) {
}
@@ -1,14 +0,0 @@
package fr.nessar.lmnporganizer.backend.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
public record GuarantorDto(
String id,
@NotBlank(message = "Le prénom du garant est obligatoire.") String firstName,
@NotBlank(message = "Le nom du garant est obligatoire.") String lastName,
@NotBlank(message = "L'adresse e-mail du garant est obligatoire.") @Email(message = "L'adresse e-mail du garant est invalide.") String email,
String phone,
String address
) {
}
@@ -1,13 +0,0 @@
package fr.nessar.lmnporganizer.backend.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
public record LandlordProfileRequest(
@NotBlank(message = "Le prénom est obligatoire.") String firstName,
@NotBlank(message = "Le nom est obligatoire.") String lastName,
@NotBlank(message = "L'adresse e-mail est obligatoire.") @Email(message = "L'adresse e-mail est invalide.") String email,
@NotBlank(message = "Le numéro de téléphone est obligatoire.") String phone,
@NotBlank(message = "L'adresse postale est obligatoire.") String address
) {
}
@@ -1,4 +0,0 @@
package fr.nessar.lmnporganizer.backend.dto;
public record LandlordProfileResponse(String firstName, String lastName, String email, String phone, String address) {
}
@@ -1,6 +0,0 @@
package fr.nessar.lmnporganizer.backend.dto;
import java.util.List;
public record LeaseActivationResponse(LeaseResponse lease, List<RentDueResponse> rentDues) {
}
@@ -1,25 +0,0 @@
package fr.nessar.lmnporganizer.backend.dto;
import com.fasterxml.jackson.annotation.JsonAnySetter;
import fr.nessar.lmnporganizer.backend.exception.LeaseValidationException;
import jakarta.validation.Valid;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
public record LeaseRequest(
String apartmentId,
List<String> tenantIds,
List<@Valid GuarantorDto> guarantors,
LocalDate startDate,
LocalDate endDate,
BigDecimal monthlyRent,
BigDecimal monthlyCharges,
BigDecimal securityDeposit
) {
@JsonAnySetter
public void rejectUnknownField(String name, Object value) {
throw new LeaseValidationException("Le champ « " + name + " » n'est pas accepté pour un bail.");
}
}
@@ -1,21 +0,0 @@
package fr.nessar.lmnporganizer.backend.dto;
import fr.nessar.lmnporganizer.backend.model.LeaseStatus;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
public record LeaseResponse(
String id,
String apartmentId,
List<String> tenantIds,
List<GuarantorDto> guarantors,
LocalDate startDate,
LocalDate endDate,
BigDecimal monthlyRent,
BigDecimal monthlyCharges,
BigDecimal securityDeposit,
LeaseStatus status
) {
}
@@ -1,21 +0,0 @@
package fr.nessar.lmnporganizer.backend.dto;
import fr.nessar.lmnporganizer.backend.model.RentDueSettlement;
import fr.nessar.lmnporganizer.backend.model.RentDueTiming;
import java.math.BigDecimal;
import java.time.LocalDate;
public record RentDueResponse(
String id,
String leaseId,
String month,
LocalDate dueDate,
BigDecimal rent,
BigDecimal charges,
BigDecimal total,
BigDecimal remainingAmount,
RentDueSettlement settlement,
RentDueTiming timing
) {
}
@@ -1,37 +0,0 @@
package fr.nessar.lmnporganizer.backend.dto;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
public class RentPaymentPaidRequest {
@NotNull
private BigDecimal rent;
@NotNull
private BigDecimal charges;
public RentPaymentPaidRequest() {
}
public RentPaymentPaidRequest(BigDecimal rent, BigDecimal charges) {
this.rent = rent;
this.charges = charges;
}
public BigDecimal getRent() {
return rent;
}
public void setRent(BigDecimal rent) {
this.rent = rent;
}
public BigDecimal getCharges() {
return charges;
}
public void setCharges(BigDecimal charges) {
this.charges = charges;
}
}
@@ -1,19 +0,0 @@
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
) {
}
@@ -1,4 +0,0 @@
package fr.nessar.lmnporganizer.backend.dto;
public record TenantAttachmentResponse(String id, String fileName, String contentType, long size) {
}
@@ -1,13 +0,0 @@
package fr.nessar.lmnporganizer.backend.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
public record TenantRequest(
@NotBlank(message = "Le prénom est obligatoire.") String firstName,
@NotBlank(message = "Le nom est obligatoire.") String lastName,
@NotBlank(message = "L'adresse e-mail est obligatoire.") @Email(message = "L'adresse e-mail est invalide.") String email,
String phone,
String address
) {
}
@@ -1,17 +0,0 @@
package fr.nessar.lmnporganizer.backend.dto;
import java.util.List;
public record TenantResponse(
String id,
String firstName,
String lastName,
String email,
String phone,
String address,
List<TenantAttachmentResponse> attachments
) {
public TenantResponse {
attachments = attachments == null ? List.of() : List.copyOf(attachments);
}
}
@@ -1,4 +0,0 @@
package fr.nessar.lmnporganizer.backend.dto;
public record UserResponse(boolean authenticated, String username, String email) {
}
@@ -1,7 +0,0 @@
package fr.nessar.lmnporganizer.backend.exception;
public class AttachmentTooLargeException extends RuntimeException {
public AttachmentTooLargeException(String message) {
super(message);
}
}
@@ -1,7 +0,0 @@
package fr.nessar.lmnporganizer.backend.exception;
public class EmailAlreadyUsedException extends RuntimeException {
public EmailAlreadyUsedException() {
super("Cette adresse e-mail est déjà utilisée.");
}
}
@@ -1,7 +0,0 @@
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.");
}
}
@@ -1,7 +0,0 @@
package fr.nessar.lmnporganizer.backend.exception;
public class InvalidPhoneNumberException extends RuntimeException {
public InvalidPhoneNumberException() {
super("Le numéro de téléphone doit être français ou international.");
}
}
@@ -1,7 +0,0 @@
package fr.nessar.lmnporganizer.backend.exception;
public class LeaseConflictException extends RuntimeException {
public LeaseConflictException(String message) {
super(message);
}
}
@@ -1,7 +0,0 @@
package fr.nessar.lmnporganizer.backend.exception;
public class LeaseValidationException extends RuntimeException {
public LeaseValidationException(String message) {
super(message);
}
}
@@ -1,17 +0,0 @@
package fr.nessar.lmnporganizer.backend.exception;
public class ReferencedByLeaseException extends RuntimeException {
private ReferencedByLeaseException(String message) {
super(message);
}
public static ReferencedByLeaseException apartment() {
return new ReferencedByLeaseException("Cet appartement ne peut pas être supprimé car il est référencé par un bail. "
+ "Conservez-le pour préserver l'historique du bail.");
}
public static ReferencedByLeaseException tenant() {
return new ReferencedByLeaseException("Ce locataire ne peut pas être supprimé car il est référencé par un bail. "
+ "Conservez-le pour préserver l'historique du bail.");
}
}
@@ -1,28 +0,0 @@
package fr.nessar.lmnporganizer.backend.mapper;
import fr.nessar.lmnporganizer.backend.dto.ApartmentRequest;
import fr.nessar.lmnporganizer.backend.dto.ApartmentResponse;
import fr.nessar.lmnporganizer.backend.model.Apartment;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.MappingConstants;
import org.mapstruct.MappingTarget;
import org.mapstruct.ReportingPolicy;
import java.util.List;
@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface ApartmentMapper {
ApartmentResponse toResponse(Apartment apartment);
List<ApartmentResponse> toResponses(List<Apartment> apartments);
@Mapping(target = "id", ignore = true)
@Mapping(target = "bailleurId", ignore = true)
Apartment toEntity(ApartmentRequest request);
@Mapping(target = "id", ignore = true)
@Mapping(target = "bailleurId", ignore = true)
void update(ApartmentRequest request, @MappingTarget Apartment target);
}
@@ -1,36 +0,0 @@
package fr.nessar.lmnporganizer.backend.mapper;
import fr.nessar.lmnporganizer.backend.dto.GuarantorDto;
import fr.nessar.lmnporganizer.backend.dto.LeaseRequest;
import fr.nessar.lmnporganizer.backend.dto.LeaseResponse;
import fr.nessar.lmnporganizer.backend.model.Guarantor;
import fr.nessar.lmnporganizer.backend.model.Lease;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.MappingConstants;
import org.mapstruct.MappingTarget;
import org.mapstruct.ReportingPolicy;
import java.util.List;
@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface LeaseMapper {
LeaseResponse toResponse(Lease lease);
List<LeaseResponse> toResponses(List<Lease> leases);
GuarantorDto toDto(Guarantor guarantor);
Guarantor toEntity(GuarantorDto guarantor);
@Mapping(target = "id", ignore = true)
@Mapping(target = "landlordId", ignore = true)
@Mapping(target = "status", ignore = true)
Lease toEntity(LeaseRequest request);
@Mapping(target = "id", ignore = true)
@Mapping(target = "landlordId", ignore = true)
@Mapping(target = "status", ignore = true)
void update(LeaseRequest request, @MappingTarget Lease target);
}
@@ -1,34 +0,0 @@
package fr.nessar.lmnporganizer.backend.mapper;
import fr.nessar.lmnporganizer.backend.dto.TenantAttachmentResponse;
import fr.nessar.lmnporganizer.backend.dto.TenantRequest;
import fr.nessar.lmnporganizer.backend.dto.TenantResponse;
import fr.nessar.lmnporganizer.backend.model.Tenant;
import fr.nessar.lmnporganizer.backend.model.TenantAttachment;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.MappingConstants;
import org.mapstruct.MappingTarget;
import org.mapstruct.ReportingPolicy;
import java.util.List;
@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface TenantMapper {
TenantResponse toResponse(Tenant tenant);
List<TenantResponse> toResponses(List<Tenant> tenants);
TenantAttachmentResponse toResponse(TenantAttachment attachment);
@Mapping(target = "id", ignore = true)
@Mapping(target = "landlordId", ignore = true)
@Mapping(target = "attachments", ignore = true)
Tenant toEntity(TenantRequest request);
@Mapping(target = "id", ignore = true)
@Mapping(target = "landlordId", ignore = true)
@Mapping(target = "attachments", ignore = true)
void update(TenantRequest request, @MappingTarget Tenant target);
}
@@ -1,80 +0,0 @@
package fr.nessar.lmnporganizer.backend.model;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
@Document(collection = "apartments")
public class Apartment {
@Id
private String id;
@Indexed
private String bailleurId;
@Indexed
private String name;
private String address;
private double purchasePrice;
private String description;
public Apartment() {}
public Apartment(String id, String bailleurId, String name, String address, double purchasePrice, String description) {
this.id = id;
this.bailleurId = bailleurId;
this.name = name;
this.address = address;
this.purchasePrice = purchasePrice;
this.description = description;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getBailleurId() {
return bailleurId;
}
public void setBailleurId(String bailleurId) {
this.bailleurId = bailleurId;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getAddress() {
return address;
}
public void setAddress(String address) {
this.address = address;
}
public double getPurchasePrice() {
return purchasePrice;
}
public void setPurchasePrice(double purchasePrice) {
this.purchasePrice = purchasePrice;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
}
@@ -1,79 +0,0 @@
package fr.nessar.lmnporganizer.backend.model;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
public class Guarantor {
private String id;
@NotBlank(message = "Le prénom du garant est obligatoire.")
private String firstName;
@NotBlank(message = "Le nom du garant est obligatoire.")
private String lastName;
@NotBlank(message = "L'adresse e-mail du garant est obligatoire.")
@Email(message = "L'adresse e-mail du garant est invalide.")
private String email;
private String phone;
private String address;
public Guarantor() {
}
public Guarantor(String id, String firstName, String lastName, String email, String phone, String address) {
this.id = id;
this.firstName = firstName;
this.lastName = lastName;
this.email = email;
this.phone = phone;
this.address = address;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getFirstName() {
return firstName;
}
public void setFirstName(String firstName) {
this.firstName = firstName;
}
public String getLastName() {
return lastName;
}
public void setLastName(String lastName) {
this.lastName = lastName;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getPhone() {
return phone;
}
public void setPhone(String phone) {
this.phone = phone;
}
public String getAddress() {
return address;
}
public void setAddress(String address) {
this.address = address;
}
}
@@ -1,91 +0,0 @@
package fr.nessar.lmnporganizer.backend.model;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
@Document(collection = "landlords")
public class Landlord {
@Id
private String id;
@Indexed(unique = true)
private String oidcSubject;
private String firstName;
private String lastName;
private String email;
private String phone;
private String address;
public Landlord() {}
public Landlord(String id, String firstName, String lastName, String email, String phone, String address) {
this.id = id;
this.firstName = firstName;
this.lastName = lastName;
this.email = email;
this.phone = phone;
this.address = address;
}
public Landlord(String id, String oidcSubject, String firstName, String lastName, String email, String phone,
String address) {
this(id, firstName, lastName, email, phone, address);
this.oidcSubject = oidcSubject;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getOidcSubject() {
return oidcSubject;
}
public void setOidcSubject(String oidcSubject) {
this.oidcSubject = oidcSubject;
}
public String getFirstName() {
return firstName;
}
public void setFirstName(String firstName) {
this.firstName = firstName;
}
public String getLastName() {
return lastName;
}
public void setLastName(String lastName) {
this.lastName = lastName;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getPhone() {
return phone;
}
public void setPhone(String phone) {
this.phone = phone;
}
public String getAddress() {
return address;
}
public void setAddress(String address) {
this.address = address;
}
}
@@ -1,105 +0,0 @@
package fr.nessar.lmnporganizer.backend.model;
import jakarta.validation.Valid;
import fr.nessar.lmnporganizer.backend.exception.LeaseValidationException;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
@Document(collection = "leases")
public class Lease {
@Id
private String id;
@Indexed
private String landlordId;
@Indexed
private String apartmentId;
@Indexed
private List<String> tenantIds = new ArrayList<>();
private List<@Valid Guarantor> guarantors = new ArrayList<>();
private LocalDate startDate;
private LocalDate endDate;
private BigDecimal monthlyRent;
private BigDecimal monthlyCharges;
private BigDecimal securityDeposit;
private LeaseStatus status;
public Lease() {
}
public Lease(String id, String landlordId, String apartmentId, List<String> tenantIds,
List<Guarantor> guarantors, LocalDate startDate, LocalDate endDate, BigDecimal monthlyRent,
BigDecimal monthlyCharges, BigDecimal securityDeposit, LeaseStatus status) {
this.id = id;
this.landlordId = landlordId;
this.apartmentId = apartmentId;
setTenantIds(tenantIds);
setGuarantors(guarantors);
this.startDate = startDate;
this.endDate = endDate;
this.monthlyRent = monthlyRent;
this.monthlyCharges = monthlyCharges;
this.securityDeposit = securityDeposit;
this.status = status;
}
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 getApartmentId() { return apartmentId; }
public void setApartmentId(String apartmentId) { this.apartmentId = apartmentId; }
public List<String> getTenantIds() { return tenantIds; }
public void setTenantIds(List<String> tenantIds) {
this.tenantIds = tenantIds == null ? new ArrayList<>() : new ArrayList<>(tenantIds);
}
public List<Guarantor> getGuarantors() { return guarantors; }
public void setGuarantors(List<Guarantor> guarantors) {
this.guarantors = guarantors == null ? new ArrayList<>() : new ArrayList<>(guarantors);
}
public LocalDate getStartDate() { return startDate; }
public void setStartDate(LocalDate startDate) { this.startDate = startDate; }
public LocalDate getEndDate() { return endDate; }
public void setEndDate(LocalDate endDate) { this.endDate = endDate; }
public BigDecimal getMonthlyRent() { return monthlyRent; }
public void setMonthlyRent(BigDecimal monthlyRent) { this.monthlyRent = monthlyRent; }
public BigDecimal getMonthlyCharges() { return monthlyCharges; }
public void setMonthlyCharges(BigDecimal monthlyCharges) { this.monthlyCharges = monthlyCharges; }
public BigDecimal getSecurityDeposit() { return securityDeposit; }
public void setSecurityDeposit(BigDecimal securityDeposit) { this.securityDeposit = securityDeposit; }
public LeaseStatus getStatus() { return status; }
public void setStatus(LeaseStatus status) { this.status = status; }
public void activate() {
if (status != LeaseStatus.DRAFT) {
throw new LeaseValidationException("Seul un bail en brouillon peut être activé.");
}
if (tenantIds.isEmpty() || tenantIds.stream().anyMatch(id -> id == null || id.isBlank())
|| new HashSet<>(tenantIds).size() != tenantIds.size()) {
throw new LeaseValidationException("Le bail doit contenir au moins un locataire, sans doublon.");
}
if (startDate == null || endDate == null || startDate.isAfter(endDate)) {
throw new LeaseValidationException("La date de début du bail doit précéder ou égaler sa date de fin.");
}
if (monthlyRent == null || monthlyRent.signum() <= 0) {
throw new LeaseValidationException("Le loyer mensuel doit être strictement positif.");
}
if (monthlyCharges == null || monthlyCharges.signum() < 0) {
throw new LeaseValidationException("Les charges mensuelles doivent être positives ou nulles.");
}
if (securityDeposit == null || securityDeposit.signum() < 0) {
throw new LeaseValidationException("Le dépôt de garantie doit être positif ou nul.");
}
status = LeaseStatus.ACTIVE;
}
}
@@ -1,80 +0,0 @@
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.math.RoundingMode;
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.ArrayList;
import java.util.List;
@Document(collection = "rentDues")
@CompoundIndex(name = "rent_due_lease_month", def = "{'leaseId': 1, 'month': 1}", unique = true)
public class RentDue {
@Id
private String id;
@Indexed
private String landlordId;
@Indexed
private String leaseId;
private String month;
private LocalDate dueDate;
private BigDecimal rent;
private BigDecimal charges;
public RentDue() {
}
public RentDue(String id, String landlordId, String leaseId, String month, LocalDate dueDate, BigDecimal rent,
BigDecimal charges) {
this.id = id;
this.landlordId = landlordId;
this.leaseId = leaseId;
this.month = month;
this.dueDate = dueDate;
this.rent = rent;
this.charges = charges;
}
public static List<RentDue> forLease(Lease lease) {
YearMonth firstMonth = YearMonth.from(lease.getStartDate());
YearMonth lastMonth = YearMonth.from(lease.getEndDate());
List<RentDue> dues = new ArrayList<>();
for (YearMonth month = firstMonth; !month.isAfter(lastMonth); month = month.plusMonths(1)) {
int firstOccupiedDay = month.equals(firstMonth) ? lease.getStartDate().getDayOfMonth() : 1;
int lastOccupiedDay = month.equals(lastMonth) ? lease.getEndDate().getDayOfMonth() : month.lengthOfMonth();
int occupiedDays = lastOccupiedDay - firstOccupiedDay + 1;
dues.add(new RentDue(null, lease.getLandlordId(), lease.getId(), month.toString(),
month.plusMonths(1).atDay(5), prorate(lease.getMonthlyRent(), occupiedDays, month.lengthOfMonth()),
prorate(lease.getMonthlyCharges(), occupiedDays, month.lengthOfMonth())));
}
return dues;
}
private static BigDecimal prorate(BigDecimal amount, int occupiedDays, int daysInMonth) {
return amount.multiply(BigDecimal.valueOf(occupiedDays))
.divide(BigDecimal.valueOf(daysInMonth), 2, RoundingMode.HALF_UP);
}
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; }
}
@@ -1,5 +0,0 @@
package fr.nessar.lmnporganizer.backend.model;
public enum RentDueSettlement {
UNPAID
}
@@ -1,65 +0,0 @@
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; }
}
@@ -1,7 +0,0 @@
package fr.nessar.lmnporganizer.backend.model;
public enum RentPaymentStatus {
PAID,
LATE,
UPCOMING
}
@@ -1,132 +0,0 @@
package fr.nessar.lmnporganizer.backend.model;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
@Document(collection = "tenants")
public class Tenant {
@Id
private String id;
@Indexed
private String landlordId;
@NotBlank(message = "Le prénom est obligatoire.")
private String firstName;
@NotBlank(message = "Le nom est obligatoire.")
private String lastName;
@NotBlank(message = "L'adresse e-mail est obligatoire.")
@Email(message = "L'adresse e-mail est invalide.")
private String email;
private String phone;
private String address;
private List<TenantAttachment> attachments = new ArrayList<>();
public Tenant() {
}
public Tenant(String id, String landlordId, String firstName, String lastName, String email, String phone,
String address) {
this.id = id;
this.landlordId = landlordId;
this.firstName = firstName;
this.lastName = lastName;
this.email = email;
this.phone = phone;
this.address = address;
}
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 getFirstName() {
return firstName;
}
public void setFirstName(String firstName) {
this.firstName = firstName;
}
public String getLastName() {
return lastName;
}
public void setLastName(String lastName) {
this.lastName = lastName;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getPhone() {
return phone;
}
public void setPhone(String phone) {
this.phone = phone;
}
public String getAddress() {
return address;
}
public void setAddress(String address) {
this.address = address;
}
public List<TenantAttachment> getAttachments() {
return List.copyOf(attachments);
}
public void setAttachments(List<TenantAttachment> attachments) {
this.attachments = attachments == null ? new ArrayList<>() : new ArrayList<>(attachments);
}
public void addAttachment(TenantAttachment attachment) {
attachments.add(Objects.requireNonNull(attachment));
}
public Optional<TenantAttachment> findAttachment(String attachmentId) {
return attachments.stream()
.filter(attachment -> Objects.equals(attachment.getId(), attachmentId))
.findFirst();
}
public Optional<TenantAttachment> removeAttachment(String attachmentId) {
Optional<TenantAttachment> attachment = findAttachment(attachmentId);
attachment.ifPresent(attachments::remove);
return attachment;
}
public boolean hasAttachments() {
return !attachments.isEmpty();
}
}
@@ -1,35 +0,0 @@
package fr.nessar.lmnporganizer.backend.model;
import com.fasterxml.jackson.annotation.JsonIgnore;
public class TenantAttachment {
private String id;
private String fileName;
private String contentType;
private long size;
@JsonIgnore
private String storagePath;
public TenantAttachment() {
}
public TenantAttachment(String id, String fileName, String contentType, long size, String storagePath) {
this.id = id;
this.fileName = fileName;
this.contentType = contentType;
this.size = size;
this.storagePath = storagePath;
}
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getFileName() { return fileName; }
public void setFileName(String fileName) { this.fileName = fileName; }
public String getContentType() { return contentType; }
public void setContentType(String contentType) { this.contentType = contentType; }
public long getSize() { return size; }
public void setSize(long size) { this.size = size; }
public String getStoragePath() { return storagePath; }
public void setStoragePath(String storagePath) { this.storagePath = storagePath; }
}
@@ -1,16 +0,0 @@
package fr.nessar.lmnporganizer.backend.repository;
import fr.nessar.lmnporganizer.backend.model.Apartment;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface ApartmentRepository extends MongoRepository<Apartment, String> {
List<Apartment> findByBailleurId(String bailleurId);
Optional<Apartment> findByIdAndBailleurId(String id, String bailleurId);
long deleteByIdAndBailleurId(String id, String bailleurId);
}
@@ -1,14 +0,0 @@
package fr.nessar.lmnporganizer.backend.repository;
import fr.nessar.lmnporganizer.backend.model.Landlord;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface LandlordRepository extends MongoRepository<Landlord, String> {
Optional<Landlord> findByOidcSubject(String oidcSubject);
Optional<Landlord> findByEmail(String email);
}
@@ -1,23 +0,0 @@
package fr.nessar.lmnporganizer.backend.repository;
import fr.nessar.lmnporganizer.backend.model.Lease;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface LeaseRepository extends MongoRepository<Lease, String> {
List<Lease> findByLandlordId(String landlordId);
Optional<Lease> findByIdAndLandlordId(String id, String landlordId);
long deleteByIdAndLandlordId(String id, String landlordId);
List<Lease> findAllByLandlordIdAndApartmentIdAndIdNot(String landlordId, String apartmentId, String id);
boolean existsByLandlordIdAndApartmentId(String landlordId, String apartmentId);
boolean existsByLandlordIdAndTenantIdsContaining(String landlordId, String tenantId);
}
@@ -1,14 +0,0 @@
package fr.nessar.lmnporganizer.backend.repository;
import fr.nessar.lmnporganizer.backend.model.RentDue;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface RentDueRepository extends MongoRepository<RentDue, String> {
List<RentDue> findAllByLandlordIdOrderByDueDateAsc(String landlordId);
void deleteAllByLeaseId(String leaseId);
}
@@ -1,18 +0,0 @@
package fr.nessar.lmnporganizer.backend.repository;
import fr.nessar.lmnporganizer.backend.model.RentPayment;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.data.mongodb.repository.Query;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface RentPaymentRepository extends MongoRepository<RentPayment, String> {
@Query("{ 'leaseId': ?0, 'month': { $gte: ?1, $lte: ?2 } }")
List<RentPayment> findAllByLeaseIdAndMonthBetween(String leaseId, String firstMonth, String lastMonth);
List<RentPayment> findAllByOrderByDueDateAsc();
void deleteAllByLeaseId(String leaseId);
}
@@ -1,19 +0,0 @@
package fr.nessar.lmnporganizer.backend.repository;
import fr.nessar.lmnporganizer.backend.model.Tenant;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface TenantRepository extends MongoRepository<Tenant, String> {
List<Tenant> findByLandlordId(String landlordId);
Optional<Tenant> findByIdAndLandlordId(String id, String landlordId);
List<Tenant> findAllByIdInAndLandlordId(List<String> ids, String landlordId);
long deleteByIdAndLandlordId(String id, String landlordId);
}
@@ -1,72 +0,0 @@
package fr.nessar.lmnporganizer.backend.service;
import fr.nessar.lmnporganizer.backend.dto.ApartmentRequest;
import fr.nessar.lmnporganizer.backend.dto.ApartmentResponse;
import fr.nessar.lmnporganizer.backend.exception.ReferencedByLeaseException;
import fr.nessar.lmnporganizer.backend.mapper.ApartmentMapper;
import fr.nessar.lmnporganizer.backend.model.Apartment;
import fr.nessar.lmnporganizer.backend.repository.ApartmentRepository;
import fr.nessar.lmnporganizer.backend.repository.LeaseRepository;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
@Service
public class ApartmentService {
private final ApartmentRepository apartmentRepository;
private final LeaseRepository leaseRepository;
private final ApartmentMapper apartmentMapper;
private final RentalIntegrityLock rentalIntegrityLock;
public ApartmentService(ApartmentRepository apartmentRepository, LeaseRepository leaseRepository,
ApartmentMapper apartmentMapper, RentalIntegrityLock rentalIntegrityLock) {
this.apartmentRepository = apartmentRepository;
this.leaseRepository = leaseRepository;
this.apartmentMapper = apartmentMapper;
this.rentalIntegrityLock = rentalIntegrityLock;
}
public List<ApartmentResponse> getAll(String landlordId) {
return apartmentMapper.toResponses(apartmentRepository.findByBailleurId(landlordId));
}
public Optional<ApartmentResponse> getById(String landlordId, String id) {
return apartmentRepository.findByIdAndBailleurId(id, landlordId)
.map(apartmentMapper::toResponse);
}
public ApartmentResponse create(String landlordId, ApartmentRequest request) {
Apartment apartment = apartmentMapper.toEntity(trim(request));
apartment.setBailleurId(landlordId);
return apartmentMapper.toResponse(apartmentRepository.save(apartment));
}
public Optional<ApartmentResponse> update(String landlordId, String id, ApartmentRequest request) {
return rentalIntegrityLock.execute(() -> updateApartment(landlordId, id, request));
}
private Optional<ApartmentResponse> updateApartment(String landlordId, String id, ApartmentRequest request) {
return apartmentRepository.findByIdAndBailleurId(id, landlordId).map(apartment -> {
apartmentMapper.update(trim(request), apartment);
return apartmentMapper.toResponse(apartmentRepository.save(apartment));
});
}
public boolean delete(String landlordId, String id) {
return rentalIntegrityLock.execute(() -> {
if (apartmentRepository.findByIdAndBailleurId(id, landlordId).isEmpty()) {
return false;
}
if (leaseRepository.existsByLandlordIdAndApartmentId(landlordId, id)) {
throw ReferencedByLeaseException.apartment();
}
return apartmentRepository.deleteByIdAndBailleurId(id, landlordId) == 1;
});
}
private ApartmentRequest trim(ApartmentRequest request) {
return new ApartmentRequest(request.name().trim(), request.address().trim(), request.purchasePrice(),
request.description() == null ? "" : request.description().trim());
}
}
@@ -1,22 +0,0 @@
package fr.nessar.lmnporganizer.backend.service;
import fr.nessar.lmnporganizer.backend.exception.AttachmentTooLargeException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.util.unit.DataSize;
import org.springframework.web.multipart.MultipartFile;
@Service
public class AttachmentUploadPolicy {
private final DataSize maximumSize;
public AttachmentUploadPolicy(@Value("${lmnp.attachments.max-size:250MB}") String maximumSize) {
this.maximumSize = DataSize.parse(maximumSize);
}
public void validate(MultipartFile attachment) {
if (attachment.getSize() > maximumSize.toBytes()) {
throw new AttachmentTooLargeException("Une pièce jointe ne peut pas dépasser 250 Mo.");
}
}
}
@@ -1,15 +0,0 @@
package fr.nessar.lmnporganizer.backend.service;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
@Service
public class GreetingService {
public String greetingFor(OAuth2User user) {
String name = user.getAttribute("preferred_username");
if (name == null) {
name = user.getAttribute("name");
}
return "Hello, " + (name == null ? user.getName() : name) + "!";
}
}
@@ -1,95 +0,0 @@
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;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.stereotype.Service;
@Service
public class LandlordProfileService {
private final LandlordRepository landlordRepository;
private final PhoneNumberNormalizer phoneNumberNormalizer;
public LandlordProfileService(LandlordRepository landlordRepository, PhoneNumberNormalizer phoneNumberNormalizer) {
this.landlordRepository = landlordRepository;
this.phoneNumberNormalizer = phoneNumberNormalizer;
}
public LandlordProfileResponse getProfile(OidcUser principal) {
return response(findOrCreate(principal));
}
public LandlordProfileResponse updateProfile(OidcUser principal, LandlordProfileRequest request) {
Landlord landlord = findOrCreate(principal);
String email = request.email().trim();
landlordRepository.findByEmail(email)
.filter(existing -> !existing.getId().equals(landlord.getId()))
.ifPresent(existing -> {
throw new EmailAlreadyUsedException();
});
landlord.setFirstName(request.firstName().trim());
landlord.setLastName(request.lastName().trim());
landlord.setEmail(email);
landlord.setPhone(phoneNumberNormalizer.normalize(request.phone()));
landlord.setAddress(request.address().trim());
return response(save(landlord));
}
public Landlord findOrCreate(OidcUser principal) {
return landlordRepository.findByOidcSubject(principal.getSubject()).orElseGet(() -> {
Landlord landlord = new Landlord("oidc:" + principal.getSubject(), principal.getSubject(),
claim(principal, "given_name"), claim(principal, "family_name"),
claim(principal, "email"), "", "");
try {
return landlordRepository.save(landlord);
} catch (DuplicateKeyException exception) {
return landlordRepository.findByOidcSubject(principal.getSubject()).orElseThrow(() -> exception);
}
});
}
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);
} catch (DuplicateKeyException exception) {
throw new EmailAlreadyUsedException();
}
}
private String claim(OidcUser principal, String name) {
String value = principal.getClaimAsString(name);
return value == null ? "" : value;
}
private LandlordProfileResponse response(Landlord landlord) {
return new LandlordProfileResponse(value(landlord.getFirstName()), value(landlord.getLastName()),
value(landlord.getEmail()), value(landlord.getPhone()), value(landlord.getAddress()));
}
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();
}
}
@@ -1,50 +0,0 @@
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;
import org.thymeleaf.context.Context;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.List;
import java.util.Locale;
@Service
public class LeasePdfService {
private final TemplateEngine templateEngine;
public LeasePdfService(TemplateEngine templateEngine) {
this.templateEngine = templateEngine;
}
public byte[] generate(Lease lease, Apartment apartment, Landlord landlord, List<Tenant> 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()));
try (ByteArrayOutputStream output = new ByteArrayOutputStream()) {
new PdfRendererBuilder().withHtmlContent(templateEngine.process("lease", context), null)
.toStream(output).run();
return output.toByteArray();
} catch (IOException exception) {
throw new IllegalStateException("Lease PDF could not be generated", exception);
}
}
private String tenantNamesOf(List<Tenant> tenants) {
return namesOf(tenants.stream().map(tenant -> tenant.getFirstName() + " " + tenant.getLastName()).toList());
}
private String namesOf(List<String> names) {
return names.isEmpty() ? "Aucun" : String.join(", ", names);
}
}
@@ -1,237 +0,0 @@
package fr.nessar.lmnporganizer.backend.service;
import fr.nessar.lmnporganizer.backend.dto.LeaseActivationResponse;
import fr.nessar.lmnporganizer.backend.dto.LeaseRequest;
import fr.nessar.lmnporganizer.backend.dto.LeaseResponse;
import fr.nessar.lmnporganizer.backend.exception.LeaseConflictException;
import fr.nessar.lmnporganizer.backend.exception.LeaseValidationException;
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.LeaseStatus;
import fr.nessar.lmnporganizer.backend.model.RentDue;
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.RentDueRepository;
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.util.HashSet;
import java.util.List;
@Service
public class LeaseService {
private final LeaseRepository leaseRepository;
private final ApartmentRepository apartmentRepository;
private final TenantRepository tenantRepository;
private final LeasePdfService leasePdfService;
private final PhoneNumberNormalizer phoneNumberNormalizer;
private final LeaseMapper leaseMapper;
private final RentDueRepository rentDueRepository;
private final RentDueService rentDueService;
private final LandlordProfileService landlordProfileService;
private final RentalIntegrityLock rentalIntegrityLock;
public LeaseService(LeaseRepository leaseRepository, ApartmentRepository apartmentRepository,
TenantRepository tenantRepository, LeasePdfService leasePdfService,
PhoneNumberNormalizer phoneNumberNormalizer, LeaseMapper leaseMapper,
RentDueRepository rentDueRepository, RentDueService rentDueService,
LandlordProfileService landlordProfileService,
RentalIntegrityLock rentalIntegrityLock) {
this.leaseRepository = leaseRepository;
this.apartmentRepository = apartmentRepository;
this.tenantRepository = tenantRepository;
this.leasePdfService = leasePdfService;
this.phoneNumberNormalizer = phoneNumberNormalizer;
this.leaseMapper = leaseMapper;
this.rentDueRepository = rentDueRepository;
this.rentDueService = rentDueService;
this.landlordProfileService = landlordProfileService;
this.rentalIntegrityLock = rentalIntegrityLock;
}
public List<LeaseResponse> getAll(String landlordId) {
return leaseMapper.toResponses(leaseRepository.findByLandlordId(landlordId));
}
public ResponseEntity<LeaseResponse> getById(String landlordId, String id) {
return leaseRepository.findByIdAndLandlordId(id, landlordId)
.map(leaseMapper::toResponse)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
public ResponseEntity<LeaseResponse> create(String landlordId, LeaseRequest request) {
return rentalIntegrityLock.execute(() -> createLease(landlordId, request));
}
private ResponseEntity<LeaseResponse> createLease(String landlordId, LeaseRequest request) {
Lease lease = leaseMapper.toEntity(request);
if (!referencesExist(landlordId, lease)) {
throw invalidReferences();
}
lease.setLandlordId(landlordId);
lease.setStatus(LeaseStatus.DRAFT);
normalizeGuarantorPhones(lease);
lease = leaseRepository.save(lease);
return ResponseEntity.status(HttpStatus.CREATED).body(leaseMapper.toResponse(lease));
}
public ResponseEntity<LeaseResponse> update(String landlordId, String id, LeaseRequest request) {
return rentalIntegrityLock.execute(() -> updateLease(landlordId, id, request));
}
private ResponseEntity<LeaseResponse> updateLease(String landlordId, String id, LeaseRequest request) {
Lease lease = leaseRepository.findByIdAndLandlordId(id, landlordId).orElse(null);
if (lease == null) {
return ResponseEntity.notFound().build();
}
requireDraft(lease, "modifié");
Lease details = leaseMapper.toEntity(request);
if (!referencesExist(landlordId, details)) {
throw invalidReferences();
}
leaseMapper.update(request, lease);
lease.setLandlordId(landlordId);
normalizeGuarantorPhones(lease);
return ResponseEntity.ok(leaseMapper.toResponse(leaseRepository.save(lease)));
}
public ResponseEntity<Void> delete(String landlordId, String id) {
return rentalIntegrityLock.execute(() -> deleteLease(landlordId, id));
}
private ResponseEntity<Void> deleteLease(String landlordId, String id) {
Lease lease = leaseRepository.findByIdAndLandlordId(id, landlordId).orElse(null);
if (lease == null) {
return ResponseEntity.notFound().build();
}
requireDraft(lease, "supprimé");
return leaseRepository.deleteByIdAndLandlordId(id, landlordId) == 1
? ResponseEntity.noContent().build()
: ResponseEntity.notFound().build();
}
public ResponseEntity<LeaseActivationResponse> activate(String landlordId, String id) {
return rentalIntegrityLock.execute(() -> activateLease(landlordId, id));
}
private ResponseEntity<LeaseActivationResponse> activateLease(String landlordId, String id) {
Lease lease = leaseRepository.findByIdAndLandlordId(id, landlordId).orElse(null);
if (lease == null) {
return ResponseEntity.notFound().build();
}
if (lease.getStatus() != LeaseStatus.DRAFT) {
throw new LeaseConflictException("Seul un bail en brouillon peut être activé.");
}
// ponytail: retry reconciliation is the single-instance ceiling; use a Mongo transaction before
// production or multiple application instances.
rentDueRepository.deleteAllByLeaseId(id);
if (!referencesExist(landlordId, lease)) {
throw invalidReferences();
}
lease.activate();
if (hasBlockingOverlap(landlordId, lease)) {
lease.setStatus(LeaseStatus.DRAFT);
throw new LeaseConflictException(
"Ce bail chevauche un bail actif ou archivé pour cet appartement.");
}
List<RentDue> dues = RentDue.forLease(lease);
try {
dues = rentDueRepository.saveAll(dues);
lease = leaseRepository.save(lease);
} catch (RuntimeException failure) {
recoverFailedActivation(landlordId, id, lease, failure);
throw failure;
}
return ResponseEntity.ok(new LeaseActivationResponse(leaseMapper.toResponse(lease),
rentDueService.toResponses(dues)));
}
private void recoverFailedActivation(String landlordId, String id, Lease lease, RuntimeException failure) {
Lease persisted;
try {
persisted = leaseRepository.findByIdAndLandlordId(id, landlordId).orElse(null);
} catch (RuntimeException statusFailure) {
failure.addSuppressed(statusFailure);
return;
}
if (persisted != null && (persisted.getStatus() == LeaseStatus.ACTIVE
|| persisted.getStatus() == LeaseStatus.ARCHIVED)) {
return;
}
lease.setStatus(LeaseStatus.DRAFT);
try {
rentDueRepository.deleteAllByLeaseId(id);
} catch (RuntimeException cleanupFailure) {
failure.addSuppressed(cleanupFailure);
}
}
public ResponseEntity<byte[]> downloadPdf(String landlordId, String id, String oidcSubject) {
Lease lease = leaseRepository.findByIdAndLandlordId(id, landlordId).orElse(null);
if (lease == null) {
return ResponseEntity.notFound().build();
}
Apartment apartment = apartmentRepository.findByIdAndBailleurId(lease.getApartmentId(), landlordId)
.orElse(null);
List<Tenant> tenants = tenantsFor(landlordId, lease.getTenantIds());
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, landlord, tenants));
}
private boolean referencesExist(String landlordId, Lease lease) {
if (lease.getApartmentId() == null
|| apartmentRepository.findByIdAndBailleurId(lease.getApartmentId(), landlordId).isEmpty()
|| lease.getTenantIds().isEmpty()) {
return false;
}
return tenantsFor(landlordId, lease.getTenantIds()).size() == new HashSet<>(lease.getTenantIds()).size();
}
private List<Tenant> tenantsFor(String landlordId, List<String> ids) {
return tenantRepository.findAllByIdInAndLandlordId(ids, landlordId);
}
private void normalizeGuarantorPhones(Lease lease) {
lease.getGuarantors().forEach(guarantor ->
guarantor.setPhone(phoneNumberNormalizer.normalize(guarantor.getPhone())));
}
private boolean hasBlockingOverlap(String landlordId, Lease lease) {
return leaseRepository.findAllByLandlordIdAndApartmentIdAndIdNot(
landlordId, lease.getApartmentId(), lease.getId()).stream()
.filter(existing -> existing.getStatus() == LeaseStatus.ACTIVE
|| existing.getStatus() == LeaseStatus.ARCHIVED)
.anyMatch(existing -> existing.getStartDate() != null && existing.getEndDate() != null
&& !lease.getStartDate().isAfter(existing.getEndDate())
&& !lease.getEndDate().isBefore(existing.getStartDate()));
}
private void requireDraft(Lease lease, String operation) {
if (lease.getStatus() != LeaseStatus.DRAFT) {
throw new LeaseConflictException("Seul un bail en brouillon peut être " + operation
+ ". Conservez l'historique du bail actif ou archivé.");
}
}
private LeaseValidationException invalidReferences() {
return new LeaseValidationException(
"L'appartement et les locataires doivent appartenir au portefeuille du bailleur.");
}
}
@@ -1,25 +0,0 @@
package fr.nessar.lmnporganizer.backend.service;
import fr.nessar.lmnporganizer.backend.exception.InvalidPhoneNumberException;
import org.springframework.stereotype.Service;
@Service
public class PhoneNumberNormalizer {
public String normalize(String phone) {
if (phone == null || phone.isBlank()) {
return "";
}
String compact = phone.trim().replaceAll("[\\s()./-]", "");
if (compact.startsWith("00")) {
compact = "+" + compact.substring(2);
}
if (compact.matches("0\\d{9}")) {
return "+33" + compact.substring(1);
}
if (compact.matches("\\+\\d{8,15}")) {
return compact;
}
throw new InvalidPhoneNumberException();
}
}
@@ -1,51 +0,0 @@
package fr.nessar.lmnporganizer.backend.service;
import fr.nessar.lmnporganizer.backend.dto.RentDueResponse;
import fr.nessar.lmnporganizer.backend.model.RentDue;
import fr.nessar.lmnporganizer.backend.model.RentDueSettlement;
import fr.nessar.lmnporganizer.backend.model.RentDueTiming;
import fr.nessar.lmnporganizer.backend.model.LeaseStatus;
import fr.nessar.lmnporganizer.backend.repository.LeaseRepository;
import fr.nessar.lmnporganizer.backend.repository.RentDueRepository;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.Clock;
import java.time.LocalDate;
import java.util.List;
import java.util.Set;
@Service
public class RentDueService {
private final RentDueRepository rentDueRepository;
private final LeaseRepository leaseRepository;
private final Clock clock;
public RentDueService(RentDueRepository rentDueRepository, LeaseRepository leaseRepository, Clock clock) {
this.rentDueRepository = rentDueRepository;
this.leaseRepository = leaseRepository;
this.clock = clock;
}
public List<RentDueResponse> getAll(String landlordId) {
Set<String> visibleLeaseIds = leaseRepository.findByLandlordId(landlordId).stream()
.filter(lease -> lease.getStatus() == LeaseStatus.ACTIVE || lease.getStatus() == LeaseStatus.ARCHIVED)
.map(lease -> lease.getId())
.collect(java.util.stream.Collectors.toSet());
return toResponses(rentDueRepository.findAllByLandlordIdOrderByDueDateAsc(landlordId).stream()
.filter(due -> visibleLeaseIds.contains(due.getLeaseId()))
.toList());
}
public List<RentDueResponse> toResponses(List<RentDue> dues) {
LocalDate today = LocalDate.now(clock);
return dues.stream().map(due -> toResponse(due, today)).toList();
}
private RentDueResponse toResponse(RentDue due, LocalDate today) {
BigDecimal total = due.getRent().add(due.getCharges());
RentDueTiming timing = due.getDueDate().isBefore(today) ? RentDueTiming.OVERDUE : RentDueTiming.UPCOMING;
return new RentDueResponse(due.getId(), due.getLeaseId(), due.getMonth(), due.getDueDate(), due.getRent(),
due.getCharges(), total, total, RentDueSettlement.UNPAID, timing);
}
}
@@ -1,157 +0,0 @@
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.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.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.Clock;
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 LandlordProfileService landlordProfileService;
private final TenantRepository tenantRepository;
private final RentReceiptPdfService rentReceiptPdfService;
private final Clock clock;
public RentPaymentService(RentPaymentRepository rentPaymentRepository, LeaseRepository leaseRepository,
ApartmentRepository apartmentRepository, LandlordProfileService landlordProfileService,
TenantRepository tenantRepository,
RentReceiptPdfService rentReceiptPdfService, Clock clock) {
this.rentPaymentRepository = rentPaymentRepository;
this.leaseRepository = leaseRepository;
this.apartmentRepository = apartmentRepository;
this.landlordProfileService = landlordProfileService;
this.tenantRepository = tenantRepository;
this.rentReceiptPdfService = rentReceiptPdfService;
this.clock = clock;
}
public List<RentPaymentResponse> getAll() {
return rentPaymentRepository.findAllByOrderByDueDateAsc().stream().map(this::toResponse).toList();
}
public ResponseEntity<RentPaymentResponse> markPaid(String id, java.math.BigDecimal rent, java.math.BigDecimal charges) {
return rentPaymentRepository.findById(id).map(payment -> {
if (payment.getPaidAt() == null) {
payment.setRent(rent);
payment.setCharges(charges);
payment.setPaidAt(LocalDate.now(clock));
payment = rentPaymentRepository.save(payment);
}
return ResponseEntity.ok(toResponse(payment));
}).orElse(ResponseEntity.notFound().build());
}
public ResponseEntity<byte[]> downloadReceipt(String id, String oidcSubject) {
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 = landlordProfileService.requireCompleteProfileFor(oidcSubject);
List<Tenant> tenants = tenantsFor(lease.getTenantIds());
if (apartment == 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));
}
public void deleteForLease(String leaseId) {
rentPaymentRepository.deleteAllByLeaseId(leaseId);
}
public 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())) {
java.math.BigDecimal rent = lease.getMonthlyRent();
java.math.BigDecimal charges = lease.getMonthlyCharges();
if (month.equals(firstMonth) && month.equals(lastMonth)) {
int daysOccupied = lease.getEndDate().getDayOfMonth() - lease.getStartDate().getDayOfMonth() + 1;
int daysInMonth = month.lengthOfMonth();
rent = proRata(rent, daysInMonth, daysOccupied);
charges = proRata(charges, daysInMonth, daysOccupied);
} else if (month.equals(firstMonth)) {
int daysOccupied = month.lengthOfMonth() - lease.getStartDate().getDayOfMonth() + 1;
int daysInMonth = month.lengthOfMonth();
rent = proRata(rent, daysInMonth, daysOccupied);
charges = proRata(charges, daysInMonth, daysOccupied);
} else if (month.equals(lastMonth)) {
int daysOccupied = lease.getEndDate().getDayOfMonth();
int daysInMonth = month.lengthOfMonth();
rent = proRata(rent, daysInMonth, daysOccupied);
charges = proRata(charges, daysInMonth, daysOccupied);
}
missingPayments.add(new RentPayment(null, lease.getLandlordId(), lease.getId(), month.toString(),
dueDate(lease, month), rent, charges, null));
}
}
if (!missingPayments.isEmpty())
rentPaymentRepository.saveAll(missingPayments);
}
private java.math.BigDecimal proRata(java.math.BigDecimal amount, int daysInMonth, int daysOccupied) {
return amount.multiply(new java.math.BigDecimal(daysOccupied))
.divide(new java.math.BigDecimal(daysInMonth), 2, java.math.RoundingMode.HALF_UP);
}
private LocalDate dueDate(Lease lease, YearMonth month) {
return month.plusMonths(1).atDay(5);
}
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(clock)), payment.getPaidAt());
}
private List<Tenant> tenantsFor(List<String> ids) {
List<Tenant> tenants = new ArrayList<>();
tenantRepository.findAllById(ids).forEach(tenants::add);
return tenants;
}
}
@@ -1,55 +0,0 @@
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);
}
}
@@ -1,17 +0,0 @@
package fr.nessar.lmnporganizer.backend.service;
import org.springframework.stereotype.Component;
@Component
public class RentalIntegrityLock {
// ponytail: single-instance consistency boundary; use Mongo transactions or distributed coordination
// before horizontal scaling.
public synchronized <T, E extends Exception> T execute(CheckedOperation<T, E> operation) throws E {
return operation.run();
}
@FunctionalInterface
public interface CheckedOperation<T, E extends Exception> {
T run() throws E;
}
}
@@ -1,196 +0,0 @@
package fr.nessar.lmnporganizer.backend.service;
import fr.nessar.lmnporganizer.backend.model.TenantAttachment;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.UUID;
@Service
public class TenantAttachmentStorage {
private static final String STAGING_DIRECTORY = ".staging";
private static final String COMMITTED_SUFFIX = ".committed";
private static final Logger LOGGER = LoggerFactory.getLogger(TenantAttachmentStorage.class);
private final Path root;
private final Path stagingRoot;
public TenantAttachmentStorage(@Value("${lmnp.storage.attachments}") String root) {
this.root = Path.of(root).toAbsolutePath().normalize();
this.stagingRoot = this.root.resolve(STAGING_DIRECTORY);
sweepCommittedTombstones();
}
public TenantAttachment store(String tenantId, MultipartFile file) throws IOException {
sweepCommittedTombstones();
String attachmentId = UUID.randomUUID().toString();
String suppliedName = file.getOriginalFilename();
String originalName = suppliedName == null || suppliedName.isBlank()
? "attachment"
: Path.of(suppliedName).getFileName().toString();
Path tenantDirectory = root.resolve(tenantId).normalize();
if (!tenantDirectory.startsWith(root) || tenantDirectory.startsWith(stagingRoot)) {
throw new IOException("Invalid tenant attachment path");
}
Files.createDirectories(tenantDirectory);
Path destination = tenantDirectory.resolve(attachmentId).normalize();
try {
file.transferTo(destination);
} catch (IOException | RuntimeException failure) {
try {
Files.deleteIfExists(destination);
} catch (IOException cleanupFailure) {
failure.addSuppressed(cleanupFailure);
}
throw failure;
}
String contentType = file.getContentType() == null ? "application/octet-stream" : file.getContentType();
return new TenantAttachment(attachmentId, originalName, contentType, file.getSize(),
root.relativize(destination).toString());
}
public Resource read(TenantAttachment attachment) throws IOException {
Path path = activePath(attachment);
if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Tenant attachment not found");
}
return new UrlResource(path.toUri());
}
public void delete(TenantAttachment attachment) throws IOException {
StagedDeletion deletion = stageDeletion(attachment);
try {
finalizeDeletion(deletion);
markCommitted(deletion);
} catch (IOException | RuntimeException failure) {
try {
restore(deletion);
} catch (IOException | RuntimeException rollbackFailure) {
failure.addSuppressed(rollbackFailure);
}
throw failure;
}
discardCommittedBestEffort(deletion);
}
StagedDeletion stageDeletion(TenantAttachment attachment) throws IOException {
sweepCommittedTombstones();
Path activePath = activePath(attachment);
if (!Files.isRegularFile(activePath, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Tenant attachment not found");
}
Files.createDirectories(stagingRoot);
String stagingId = UUID.randomUUID().toString();
Path stagedPath = stagingRoot.resolve(stagingId + ".staged");
Path backupPath = stagingRoot.resolve(stagingId + ".backup");
Path committedPath = stagingRoot.resolve(stagingId + COMMITTED_SUFFIX);
Files.move(activePath, stagedPath, StandardCopyOption.ATOMIC_MOVE);
try {
createBackup(stagedPath, backupPath);
} catch (IOException | RuntimeException failure) {
try {
Files.deleteIfExists(backupPath);
Files.move(stagedPath, activePath, StandardCopyOption.ATOMIC_MOVE);
} catch (IOException | RuntimeException rollbackFailure) {
failure.addSuppressed(rollbackFailure);
}
throw failure;
}
return new StagedDeletion(activePath, stagedPath, backupPath, committedPath);
}
void restore(StagedDeletion deletion) throws IOException {
Path source = deletion.stagedPath();
if (!Files.isRegularFile(source, LinkOption.NOFOLLOW_LINKS)) {
source = deletion.backupPath();
}
if (!Files.isRegularFile(source, LinkOption.NOFOLLOW_LINKS)) {
source = deletion.committedPath();
}
if (!Files.isRegularFile(source, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Tenant attachment backup not found");
}
Files.createDirectories(deletion.activePath().getParent());
Files.move(source, deletion.activePath(), StandardCopyOption.ATOMIC_MOVE);
Files.deleteIfExists(deletion.stagedPath());
Files.deleteIfExists(deletion.backupPath());
Files.deleteIfExists(deletion.committedPath());
}
void finalizeDeletion(StagedDeletion deletion) throws IOException {
Files.deleteIfExists(deletion.stagedPath());
}
void markCommitted(StagedDeletion deletion) throws IOException {
Files.move(deletion.backupPath(), deletion.committedPath(), StandardCopyOption.ATOMIC_MOVE);
}
void discardCommitted(StagedDeletion deletion) throws IOException {
Files.deleteIfExists(deletion.committedPath());
}
void discardBackup(StagedDeletion deletion) throws IOException {
Files.deleteIfExists(deletion.backupPath());
}
private void discardCommittedBestEffort(StagedDeletion deletion) {
try {
discardCommitted(deletion);
} catch (IOException | RuntimeException failure) {
LOGGER.warn("Committed tenant attachment tombstone cleanup deferred", failure);
}
}
private void sweepCommittedTombstones() {
if (!Files.isDirectory(stagingRoot, LinkOption.NOFOLLOW_LINKS)) {
return;
}
try (var paths = Files.list(stagingRoot)) {
for (Path path : paths.filter(candidate -> candidate.getFileName().toString().endsWith(COMMITTED_SUFFIX))
.toList()) {
try {
Files.deleteIfExists(path);
} catch (IOException | RuntimeException failure) {
LOGGER.warn("Committed tenant attachment tombstone cleanup deferred: {}", path.getFileName(),
failure);
}
}
} catch (IOException | RuntimeException failure) {
LOGGER.warn("Committed tenant attachment tombstones could not be scanned", failure);
}
}
private void createBackup(Path stagedPath, Path backupPath) throws IOException {
try {
Files.createLink(backupPath, stagedPath);
} catch (IOException | UnsupportedOperationException linkFailure) {
try {
Files.copy(stagedPath, backupPath);
} catch (IOException | RuntimeException copyFailure) {
copyFailure.addSuppressed(linkFailure);
throw copyFailure;
}
}
}
private Path activePath(TenantAttachment attachment) throws IOException {
Path path = root.resolve(attachment.getStoragePath()).normalize();
if (path.equals(root) || !path.startsWith(root) || path.startsWith(stagingRoot)) {
throw new IOException("Invalid tenant attachment path");
}
return path;
}
record StagedDeletion(Path activePath, Path stagedPath, Path backupPath, Path committedPath) {
}
}
@@ -1,310 +0,0 @@
package fr.nessar.lmnporganizer.backend.service;
import fr.nessar.lmnporganizer.backend.dto.TenantAttachmentResponse;
import fr.nessar.lmnporganizer.backend.dto.TenantRequest;
import fr.nessar.lmnporganizer.backend.dto.TenantResponse;
import fr.nessar.lmnporganizer.backend.exception.ReferencedByLeaseException;
import fr.nessar.lmnporganizer.backend.mapper.TenantMapper;
import fr.nessar.lmnporganizer.backend.model.Tenant;
import fr.nessar.lmnporganizer.backend.model.TenantAttachment;
import fr.nessar.lmnporganizer.backend.repository.LeaseRepository;
import fr.nessar.lmnporganizer.backend.repository.TenantRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@Service
public class TenantService {
private static final Logger LOGGER = LoggerFactory.getLogger(TenantService.class);
private final TenantRepository tenantRepository;
private final LeaseRepository leaseRepository;
private final TenantAttachmentStorage attachmentStorage;
private final AttachmentUploadPolicy attachmentUploadPolicy;
private final PhoneNumberNormalizer phoneNumberNormalizer;
private final TenantMapper tenantMapper;
private final RentalIntegrityLock rentalIntegrityLock;
public TenantService(TenantRepository tenantRepository, LeaseRepository leaseRepository,
TenantAttachmentStorage attachmentStorage,
AttachmentUploadPolicy attachmentUploadPolicy, PhoneNumberNormalizer phoneNumberNormalizer,
TenantMapper tenantMapper, RentalIntegrityLock rentalIntegrityLock) {
this.tenantRepository = tenantRepository;
this.leaseRepository = leaseRepository;
this.attachmentStorage = attachmentStorage;
this.attachmentUploadPolicy = attachmentUploadPolicy;
this.phoneNumberNormalizer = phoneNumberNormalizer;
this.tenantMapper = tenantMapper;
this.rentalIntegrityLock = rentalIntegrityLock;
}
public List<TenantResponse> getAll(String landlordId) {
return tenantMapper.toResponses(tenantRepository.findByLandlordId(landlordId));
}
public Optional<TenantResponse> getById(String landlordId, String id) {
return tenantRepository.findByIdAndLandlordId(id, landlordId).map(tenantMapper::toResponse);
}
public TenantResponse create(String landlordId, TenantRequest request) {
Tenant tenant = tenantMapper.toEntity(normalize(request));
tenant.setLandlordId(landlordId);
tenant.setPhone(phoneNumberNormalizer.normalize(tenant.getPhone()));
return tenantMapper.toResponse(tenantRepository.save(tenant));
}
public Optional<TenantResponse> update(String landlordId, String id, TenantRequest request) {
return rentalIntegrityLock.execute(() -> updateTenant(landlordId, id, request));
}
private Optional<TenantResponse> updateTenant(String landlordId, String id, TenantRequest request) {
return tenantRepository.findByIdAndLandlordId(id, landlordId).map(tenant -> {
TenantRequest normalized = normalize(request);
tenantMapper.update(normalized, tenant);
tenant.setPhone(phoneNumberNormalizer.normalize(normalized.phone()));
return tenantMapper.toResponse(tenantRepository.save(tenant));
});
}
public boolean delete(String landlordId, String id) throws IOException {
return rentalIntegrityLock.execute(() -> deletePermanently(landlordId, id));
}
private boolean deletePermanently(String landlordId, String id) throws IOException {
Optional<Tenant> tenant = tenantRepository.findByIdAndLandlordId(id, landlordId);
if (tenant.isEmpty()) {
return false;
}
if (leaseRepository.existsByLandlordIdAndTenantIdsContaining(landlordId, id)) {
throw ReferencedByLeaseException.tenant();
}
List<TenantAttachmentStorage.StagedDeletion> staged = stage(tenant.get().getAttachments());
long deleted;
try {
deleted = tenantRepository.deleteByIdAndLandlordId(id, landlordId);
} catch (RuntimeException failure) {
restore(staged, failure);
throw failure;
}
if (deleted != 1) {
finalizeDeletions(staged);
discardAlreadyDeletedAttachments(staged);
return false;
}
try {
finalizeDeletions(staged);
promoteDeletions(staged);
} catch (IOException | RuntimeException failure) {
compensateDeletion(tenant.get(), staged, failure);
throw failure;
}
discardCommittedDeletions(staged);
return true;
}
public Optional<TenantAttachmentResponse> uploadAttachment(String landlordId, String tenantId,
MultipartFile file) throws IOException {
return rentalIntegrityLock.execute(() -> uploadTenantAttachment(landlordId, tenantId, file));
}
private Optional<TenantAttachmentResponse> uploadTenantAttachment(String landlordId, String tenantId,
MultipartFile file) throws IOException {
Tenant tenant = tenantRepository.findByIdAndLandlordId(tenantId, landlordId).orElse(null);
if (tenant == null) {
return Optional.empty();
}
attachmentUploadPolicy.validate(file);
TenantAttachment attachment = attachmentStorage.store(tenantId, file);
tenant.addAttachment(attachment);
try {
tenantRepository.save(tenant);
} catch (RuntimeException failure) {
tenant.removeAttachment(attachment.getId());
try {
attachmentStorage.delete(attachment);
} catch (IOException | RuntimeException cleanupFailure) {
failure.addSuppressed(cleanupFailure);
}
throw failure;
}
return Optional.of(tenantMapper.toResponse(attachment));
}
public Optional<AttachmentDownload> downloadAttachment(String landlordId, String tenantId, String attachmentId)
throws IOException {
Tenant tenant = tenantRepository.findByIdAndLandlordId(tenantId, landlordId).orElse(null);
if (tenant == null) {
return Optional.empty();
}
Optional<TenantAttachment> attachment = tenant.findAttachment(attachmentId);
if (attachment.isEmpty()) {
return Optional.empty();
}
return Optional.of(new AttachmentDownload(attachmentStorage.read(attachment.get()), attachment.get().getFileName(),
attachment.get().getContentType()));
}
public boolean deleteAttachment(String landlordId, String tenantId, String attachmentId) throws IOException {
return rentalIntegrityLock.execute(() -> deleteTenantAttachment(landlordId, tenantId, attachmentId));
}
private boolean deleteTenantAttachment(String landlordId, String tenantId, String attachmentId)
throws IOException {
Tenant tenant = tenantRepository.findByIdAndLandlordId(tenantId, landlordId).orElse(null);
if (tenant == null) {
return false;
}
Optional<TenantAttachment> attachment = tenant.findAttachment(attachmentId);
if (attachment.isEmpty()) {
return false;
}
TenantAttachmentStorage.StagedDeletion deletion = attachmentStorage.stageDeletion(attachment.get());
tenant.removeAttachment(attachmentId);
try {
tenantRepository.save(tenant);
} catch (RuntimeException failure) {
tenant.addAttachment(attachment.get());
try {
attachmentStorage.restore(deletion);
} catch (IOException | RuntimeException rollbackFailure) {
failure.addSuppressed(rollbackFailure);
}
throw failure;
}
try {
attachmentStorage.finalizeDeletion(deletion);
attachmentStorage.markCommitted(deletion);
} catch (IOException | RuntimeException failure) {
compensateAttachmentDeletion(tenant, attachment.get(), deletion, failure);
throw failure;
}
discardCommittedDeletions(List.of(deletion));
return true;
}
private TenantRequest normalize(TenantRequest request) {
return new TenantRequest(request.firstName().trim(), request.lastName().trim(), request.email().trim(),
value(request.phone()), value(request.address()));
}
private String value(String value) {
return value == null ? "" : value.trim();
}
private List<TenantAttachmentStorage.StagedDeletion> stage(List<TenantAttachment> attachments) throws IOException {
List<TenantAttachmentStorage.StagedDeletion> staged = new ArrayList<>();
try {
for (TenantAttachment attachment : attachments) {
staged.add(attachmentStorage.stageDeletion(attachment));
}
return staged;
} catch (IOException | RuntimeException failure) {
restore(staged, failure);
throw failure;
}
}
private void restore(List<TenantAttachmentStorage.StagedDeletion> staged, Throwable original) throws IOException {
IOException restoreFailure = null;
for (int index = staged.size() - 1; index >= 0; index--) {
try {
attachmentStorage.restore(staged.get(index));
} catch (IOException | RuntimeException failure) {
if (original != null) {
original.addSuppressed(failure);
} else if (restoreFailure == null) {
restoreFailure = failure instanceof IOException ioFailure
? ioFailure
: new IOException("Impossible de restaurer une pièce jointe.", failure);
} else {
restoreFailure.addSuppressed(failure);
}
}
}
if (restoreFailure != null) {
throw restoreFailure;
}
}
private void finalizeDeletions(List<TenantAttachmentStorage.StagedDeletion> staged) throws IOException {
for (TenantAttachmentStorage.StagedDeletion deletion : staged) {
attachmentStorage.finalizeDeletion(deletion);
}
}
private void promoteDeletions(List<TenantAttachmentStorage.StagedDeletion> staged) throws IOException {
for (TenantAttachmentStorage.StagedDeletion deletion : staged) {
attachmentStorage.markCommitted(deletion);
}
}
private void discardCommittedDeletions(List<TenantAttachmentStorage.StagedDeletion> committed) {
for (TenantAttachmentStorage.StagedDeletion deletion : committed) {
try {
attachmentStorage.discardCommitted(deletion);
} catch (IOException | RuntimeException failure) {
LOGGER.warn("Committed tenant attachment tombstone cleanup deferred", failure);
}
}
}
private void discardAlreadyDeletedAttachments(List<TenantAttachmentStorage.StagedDeletion> staged) {
for (TenantAttachmentStorage.StagedDeletion deletion : staged) {
try {
attachmentStorage.markCommitted(deletion);
attachmentStorage.discardCommitted(deletion);
} catch (IOException | RuntimeException promotionFailure) {
LOGGER.warn("Already-deleted tenant attachment could not be promoted to a tombstone",
promotionFailure);
try {
attachmentStorage.discardBackup(deletion);
} catch (IOException | RuntimeException cleanupFailure) {
LOGGER.warn("Already-deleted tenant attachment cleanup deferred", cleanupFailure);
}
}
}
}
private void compensateDeletion(Tenant tenant, List<TenantAttachmentStorage.StagedDeletion> staged,
Throwable original) {
try {
tenantRepository.save(tenant);
} catch (RuntimeException failure) {
original.addSuppressed(failure);
return;
}
try {
restore(staged, original);
} catch (IOException failure) {
original.addSuppressed(failure);
}
}
private void compensateAttachmentDeletion(Tenant tenant, TenantAttachment attachment,
TenantAttachmentStorage.StagedDeletion deletion, Throwable original) {
tenant.addAttachment(attachment);
try {
tenantRepository.save(tenant);
} catch (RuntimeException failure) {
original.addSuppressed(failure);
return;
}
try {
attachmentStorage.restore(deletion);
} catch (IOException | RuntimeException failure) {
original.addSuppressed(failure);
}
}
public record AttachmentDownload(Resource resource, String fileName, String contentType) {
}
}
@@ -1,17 +0,0 @@
package fr.nessar.lmnporganizer.backend.service;
import fr.nessar.lmnporganizer.backend.dto.UserResponse;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.stereotype.Service;
@Service
public class UserService {
public UserResponse getUser(OidcUser principal) {
if (principal == null) {
return new UserResponse(false, null, null);
}
String username = principal.getPreferredUsername() != null ? principal.getPreferredUsername() : principal.getName();
String email = principal.getEmail() != null ? principal.getEmail() : "";
return new UserResponse(true, username, email);
}
}
@@ -0,0 +1,29 @@
package fr.nessar.lmnporganizer.identity.adapter.in.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
@Configuration
@EnableWebSecurity
public class SecurityConfiguration {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/api/user").permitAll()
.requestMatchers("/api/**").authenticated()
.anyRequest().permitAll())
.exceptionHandling(exceptions -> exceptions.defaultAuthenticationEntryPointFor(
new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED),
PathPatternRequestMatcher.pathPattern("/api/**")))
.oauth2Login(oauth2 -> oauth2.defaultSuccessUrl("/", true))
.logout(logout -> logout.logoutSuccessUrl("/"))
.csrf(csrf -> csrf.spa());
return http.build();
}
}
@@ -0,0 +1,35 @@
package fr.nessar.lmnporganizer.identity.adapter.in.web;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api")
public class SessionController {
@GetMapping("/user")
Session user(@AuthenticationPrincipal OidcUser principal) {
if (principal == null) {
return new Session(false, null, null);
}
String username = principal.getPreferredUsername() == null
? principal.getName()
: principal.getPreferredUsername();
return new Session(true, username, principal.getEmail() == null ? "" : principal.getEmail());
}
@GetMapping("/hello")
String hello(@AuthenticationPrincipal OAuth2User principal) {
String name = principal.getAttribute("preferred_username");
if (name == null) {
name = principal.getAttribute("name");
}
return "Hello, " + (name == null ? principal.getName() : name) + "!";
}
record Session(boolean authenticated, String username, String email) {
}
}
@@ -0,0 +1,179 @@
package fr.nessar.lmnporganizer.rental.adapter.in.config;
import fr.nessar.lmnporganizer.rental.application.lease.ActivateLeaseService;
import fr.nessar.lmnporganizer.rental.application.lease.ArchiveLeaseService;
import fr.nessar.lmnporganizer.rental.application.lease.GenerateLeaseDocumentService;
import fr.nessar.lmnporganizer.rental.application.lease.LeaseDraftService;
import fr.nessar.lmnporganizer.rental.application.lease.AmendLeaseService;
import fr.nessar.lmnporganizer.rental.application.landlord.LandlordAccountService;
import fr.nessar.lmnporganizer.rental.application.apartment.ApartmentCatalogService;
import fr.nessar.lmnporganizer.rental.application.tenant.DeleteTenantService;
import fr.nessar.lmnporganizer.rental.application.tenant.TenantAttachmentService;
import fr.nessar.lmnporganizer.rental.application.tenant.TenantDirectoryService;
import fr.nessar.lmnporganizer.rental.application.port.out.IdentifierGenerator;
import fr.nessar.lmnporganizer.rental.application.port.out.RentalDocumentRenderer;
import fr.nessar.lmnporganizer.rental.application.port.out.TenantAttachmentStorage;
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
import fr.nessar.lmnporganizer.rental.application.rent.AllocateRentPaymentService;
import fr.nessar.lmnporganizer.rental.application.rent.ConfirmRentPaymentRefundService;
import fr.nessar.lmnporganizer.rental.application.rent.ConfirmRentPaymentService;
import fr.nessar.lmnporganizer.rental.application.rent.GeneratePaymentReceiptService;
import fr.nessar.lmnporganizer.rental.application.rent.GenerateRentReceiptService;
import fr.nessar.lmnporganizer.rental.application.rent.ReverseRentPaymentService;
import fr.nessar.lmnporganizer.rental.application.rent.RentPaymentDraftService;
import fr.nessar.lmnporganizer.rental.application.rent.RentPaymentRefundDraftService;
import fr.nessar.lmnporganizer.rental.application.rent.RentDueQueryService;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentRepository;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordRepository;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
import fr.nessar.lmnporganizer.rental.domain.lease.ApartmentOccupancyRepository;
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueRepository;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefundRepository;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRepository;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversalRepository;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantRepository;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.Clock;
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties({RentalAttachmentProperties.class, RentalStorageProperties.class})
public class RentalApplicationConfiguration {
@Bean
Clock rentalClock() {
return Clock.systemDefaultZone();
}
@Bean
LandlordAccountService landlordAccountService(LandlordRepository landlords) {
return new LandlordAccountService(landlords);
}
@Bean
ApartmentCatalogService apartmentCatalogService(ApartmentRepository apartments, TransactionRunner transactions,
IdentifierGenerator identifiers) {
return new ApartmentCatalogService(apartments, transactions, identifiers);
}
@Bean
TenantDirectoryService tenantDirectoryService(TenantRepository tenants, IdentifierGenerator identifiers) {
return new TenantDirectoryService(tenants, identifiers);
}
@Bean
DeleteTenantService deleteTenantService(TenantRepository tenants, TenantAttachmentStorage storage) {
return new DeleteTenantService(tenants, storage);
}
@Bean
TenantAttachmentService tenantAttachmentService(TenantRepository tenants, TenantAttachmentStorage storage,
IdentifierGenerator identifiers,
RentalAttachmentProperties properties) {
return new TenantAttachmentService(tenants, storage, identifiers, properties.maxSize().toBytes());
}
@Bean
LeaseDraftService leaseDraftService(ApartmentRepository apartments, TenantRepository tenants,
LeaseRepository leases, TransactionRunner transactions,
IdentifierGenerator identifiers) {
return new LeaseDraftService(apartments, tenants, leases, transactions, identifiers);
}
@Bean
AmendLeaseService amendLeaseService(LeaseRepository leases, TenantRepository tenants,
RentDueRepository rentDues, ApartmentOccupancyRepository occupancies,
TransactionRunner transactions, IdentifierGenerator identifiers,
Clock rentalClock) {
return new AmendLeaseService(leases, tenants, rentDues, occupancies, transactions, identifiers,
rentalClock);
}
@Bean
ActivateLeaseService activateLeaseService(LandlordRepository landlords, ApartmentRepository apartments,
TenantRepository tenants, LeaseRepository leases,
RentDueRepository rentDues, ApartmentOccupancyRepository occupancies,
TransactionRunner transactions,
IdentifierGenerator identifiers) {
return new ActivateLeaseService(landlords, apartments, tenants, leases, rentDues, occupancies, transactions,
identifiers);
}
@Bean
ArchiveLeaseService archiveLeaseService(LeaseRepository leases) {
return new ArchiveLeaseService(leases);
}
@Bean
GenerateLeaseDocumentService generateLeaseDocumentService(LeaseRepository leases,
RentalDocumentRenderer renderer, Clock rentalClock) {
return new GenerateLeaseDocumentService(leases, renderer, rentalClock);
}
@Bean
RentPaymentDraftService rentPaymentDraftService(LeaseRepository leases, RentPaymentRepository payments,
IdentifierGenerator identifiers) {
return new RentPaymentDraftService(leases, payments, identifiers);
}
@Bean
RentPaymentRefundDraftService rentPaymentRefundDraftService(RentPaymentRepository payments,
RentPaymentRefundRepository refunds,
IdentifierGenerator identifiers) {
return new RentPaymentRefundDraftService(payments, refunds, identifiers);
}
@Bean
ConfirmRentPaymentService confirmRentPaymentService(RentPaymentRepository payments,
RentDueRepository rentDues,
TransactionRunner transactions,
IdentifierGenerator identifiers, Clock rentalClock) {
return new ConfirmRentPaymentService(payments, rentDues, transactions, identifiers, rentalClock);
}
@Bean
AllocateRentPaymentService allocateRentPaymentService(RentPaymentRepository payments,
RentDueRepository rentDues,
TransactionRunner transactions,
IdentifierGenerator identifiers, Clock rentalClock) {
return new AllocateRentPaymentService(payments, rentDues, transactions, identifiers, rentalClock);
}
@Bean
ReverseRentPaymentService reverseRentPaymentService(RentPaymentRepository payments,
RentPaymentReversalRepository reversals,
RentDueRepository rentDues,
TransactionRunner transactions,
IdentifierGenerator identifiers, Clock rentalClock) {
return new ReverseRentPaymentService(payments, reversals, rentDues, transactions, identifiers, rentalClock);
}
@Bean
ConfirmRentPaymentRefundService confirmRentPaymentRefundService(RentPaymentRepository payments,
RentPaymentRefundRepository refunds,
TransactionRunner transactions,
Clock rentalClock) {
return new ConfirmRentPaymentRefundService(payments, refunds, transactions, rentalClock);
}
@Bean
GeneratePaymentReceiptService generatePaymentReceiptService(RentPaymentRepository payments,
RentDueRepository rentDues,
RentPaymentRefundRepository refunds,
LeaseRepository leases,
RentalDocumentRenderer renderer) {
return new GeneratePaymentReceiptService(payments, rentDues, refunds, leases, renderer);
}
@Bean
GenerateRentReceiptService generateRentReceiptService(RentDueRepository rentDues, LeaseRepository leases,
RentalDocumentRenderer renderer, Clock rentalClock) {
return new GenerateRentReceiptService(rentDues, leases, renderer, rentalClock);
}
@Bean
RentDueQueryService rentDueQueryService(RentDueRepository rentDues) {
return new RentDueQueryService(rentDues);
}
}
@@ -0,0 +1,8 @@
package fr.nessar.lmnporganizer.rental.adapter.in.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.util.unit.DataSize;
@ConfigurationProperties("lmnp.attachments")
public record RentalAttachmentProperties(DataSize maxSize) {
}
@@ -0,0 +1,9 @@
package fr.nessar.lmnporganizer.rental.adapter.in.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.nio.file.Path;
@ConfigurationProperties("lmnp.storage")
public record RentalStorageProperties(Path attachments) {
}
@@ -0,0 +1,26 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.apartment;
import fr.nessar.lmnporganizer.rental.domain.apartment.Apartment;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import java.math.BigDecimal;
final class ApartmentApi {
private ApartmentApi() {
}
record Request(@NotBlank String name, @NotBlank String address,
@NotNull @Positive BigDecimal purchasePrice, String description) {
}
record Response(String id, String name, String address, BigDecimal purchasePrice, String description,
boolean referenced, long version) {
static Response from(Apartment apartment) {
return new Response(apartment.id().value(), apartment.name(), apartment.address(),
apartment.purchasePrice(), apartment.description(), apartment.isReferenced(),
apartment.version());
}
}
}
@@ -0,0 +1,68 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.apartment;
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlord;
import fr.nessar.lmnporganizer.rental.application.apartment.ApartmentCatalogService;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
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.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController("rentalApartmentController")
@RequestMapping("/api/apartments")
public class ApartmentController {
private final ApartmentCatalogService apartments;
private final AuthenticatedLandlord authenticatedLandlord;
public ApartmentController(ApartmentCatalogService apartments, AuthenticatedLandlord authenticatedLandlord) {
this.apartments = apartments;
this.authenticatedLandlord = authenticatedLandlord;
}
@GetMapping
List<ApartmentApi.Response> list(@AuthenticationPrincipal OidcUser principal) {
return apartments.list(authenticatedLandlord.id(principal)).stream().map(ApartmentApi.Response::from).toList();
}
@GetMapping("/{apartmentId}")
ApartmentApi.Response get(@AuthenticationPrincipal OidcUser principal, @PathVariable String apartmentId) {
return ApartmentApi.Response.from(apartments.get(authenticatedLandlord.id(principal),
new ApartmentId(apartmentId)));
}
@PostMapping
ResponseEntity<ApartmentApi.Response> create(@AuthenticationPrincipal OidcUser principal,
@Valid @RequestBody ApartmentApi.Request request) {
var created = apartments.create(authenticatedLandlord.id(principal), details(request));
return ResponseEntity.status(HttpStatus.CREATED).body(ApartmentApi.Response.from(created));
}
@PutMapping("/{apartmentId}")
ApartmentApi.Response revise(@AuthenticationPrincipal OidcUser principal, @PathVariable String apartmentId,
@Valid @RequestBody ApartmentApi.Request request) {
return ApartmentApi.Response.from(apartments.revise(authenticatedLandlord.id(principal),
new ApartmentId(apartmentId), details(request)));
}
@DeleteMapping("/{apartmentId}")
ResponseEntity<Void> delete(@AuthenticationPrincipal OidcUser principal, @PathVariable String apartmentId) {
apartments.delete(authenticatedLandlord.id(principal), new ApartmentId(apartmentId));
return ResponseEntity.noContent().build();
}
private static ApartmentCatalogService.Details details(ApartmentApi.Request request) {
return new ApartmentCatalogService.Details(request.name(), request.address(), request.purchasePrice(),
request.description());
}
}
@@ -0,0 +1,34 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.common;
import fr.nessar.lmnporganizer.rental.application.landlord.LandlordAccountService;
import fr.nessar.lmnporganizer.rental.domain.landlord.Landlord;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.stereotype.Component;
@Component
public final class AuthenticatedLandlord {
private final LandlordAccountService accounts;
public AuthenticatedLandlord(LandlordAccountService accounts) {
this.accounts = accounts;
}
public Landlord account(OidcUser principal) {
return accounts.identify(claims(principal));
}
public LandlordId id(OidcUser principal) {
return account(principal).id();
}
public LandlordAccountService.IdentityClaims claims(OidcUser principal) {
return new LandlordAccountService.IdentityClaims(principal.getSubject(), claim(principal, "given_name"),
claim(principal, "family_name"), claim(principal, "email"));
}
private static String claim(OidcUser principal, String name) {
String value = principal.getClaimAsString(name);
return value == null ? "" : value;
}
}
@@ -0,0 +1,64 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.common;
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentStorageFailure;
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentTooLarge;
import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
import fr.nessar.lmnporganizer.rental.application.shared.ReferencedResource;
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
import fr.nessar.lmnporganizer.rental.domain.landlord.EmailAlreadyUsed;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRuleViolation;
import fr.nessar.lmnporganizer.rental.domain.rent.RentRuleViolation;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
@RestControllerAdvice(basePackages = "fr.nessar.lmnporganizer.rental.adapter.in.web")
public class RentalApiExceptionHandler {
@ExceptionHandler(ResourceNotFound.class)
ResponseEntity<ApiError> notFound(ResourceNotFound exception) {
return error(HttpStatus.NOT_FOUND, exception.getMessage());
}
@ExceptionHandler({LeaseRuleViolation.class, RentRuleViolation.class, IllegalArgumentException.class})
ResponseEntity<ApiError> invalid(RuntimeException exception) {
return error(HttpStatus.BAD_REQUEST, exception.getMessage());
}
@ExceptionHandler({ReferencedResource.class, EmailAlreadyUsed.class})
ResponseEntity<ApiError> conflict(RuntimeException exception) {
return error(HttpStatus.CONFLICT, exception.getMessage());
}
@ExceptionHandler({ConcurrentChange.class, OptimisticLockingFailureException.class})
ResponseEntity<ApiError> concurrent(RuntimeException exception) {
return error(HttpStatus.CONFLICT, "La ressource a été modifiée ; rechargez-la puis réessayez.");
}
@ExceptionHandler({AttachmentTooLarge.class, MaxUploadSizeExceededException.class})
ResponseEntity<ApiError> tooLarge(RuntimeException exception) {
return error(HttpStatus.CONTENT_TOO_LARGE, "La pièce jointe dépasse la taille maximale autorisée.");
}
@ExceptionHandler(AttachmentStorageFailure.class)
ResponseEntity<ApiError> storageFailure() {
return error(HttpStatus.INTERNAL_SERVER_ERROR, "Impossible de traiter la pièce jointe.");
}
@ExceptionHandler(MethodArgumentNotValidException.class)
ResponseEntity<ApiError> invalidRequest(MethodArgumentNotValidException exception) {
String message = exception.getBindingResult().getFieldErrors().stream().findFirst()
.map(error -> error.getDefaultMessage()).orElse("La requête est invalide.");
return error(HttpStatus.BAD_REQUEST, message);
}
private static ResponseEntity<ApiError> error(HttpStatus status, String message) {
return ResponseEntity.status(status).body(new ApiError(message));
}
record ApiError(String message) {
}
}
@@ -0,0 +1,22 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.landlord;
import fr.nessar.lmnporganizer.rental.domain.landlord.Landlord;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
final class LandlordProfileApi {
private LandlordProfileApi() {
}
record Request(@NotBlank String firstName, @NotBlank String lastName,
@NotBlank @Email String email, @NotBlank String phone, @NotBlank String address) {
}
record Response(String firstName, String lastName, String email, String phone, String address,
boolean complete, long version) {
static Response from(Landlord landlord) {
return new Response(landlord.firstName(), landlord.lastName(), landlord.email(), landlord.phone(),
landlord.address(), landlord.hasCompleteProfile(), landlord.version());
}
}
}
@@ -0,0 +1,39 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.landlord;
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlord;
import fr.nessar.lmnporganizer.rental.application.landlord.LandlordAccountService;
import jakarta.validation.Valid;
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.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController("rentalLandlordProfileController")
@RequestMapping("/api/landlord-profile")
public class LandlordProfileController {
private final LandlordAccountService accounts;
private final AuthenticatedLandlord authenticatedLandlord;
public LandlordProfileController(LandlordAccountService accounts,
AuthenticatedLandlord authenticatedLandlord) {
this.accounts = accounts;
this.authenticatedLandlord = authenticatedLandlord;
}
@GetMapping
LandlordProfileApi.Response get(@AuthenticationPrincipal OidcUser principal) {
return LandlordProfileApi.Response.from(authenticatedLandlord.account(principal));
}
@PutMapping
LandlordProfileApi.Response update(@AuthenticationPrincipal OidcUser principal,
@Valid @RequestBody LandlordProfileApi.Request request) {
var profile = new LandlordAccountService.Profile(request.firstName(), request.lastName(), request.email(),
request.phone(), request.address());
return LandlordProfileApi.Response.from(accounts.updateProfile(authenticatedLandlord.claims(principal),
profile));
}
}
@@ -0,0 +1,117 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.lease;
import fr.nessar.lmnporganizer.rental.adapter.in.web.rent.RentDueApi;
import fr.nessar.lmnporganizer.rental.domain.lease.Guarantor;
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseAmendment;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseContractVersion;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseStatus;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSnapshot;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
final class LeaseApi {
private LeaseApi() {
}
record GuarantorRequest(String id, @NotBlank String firstName, @NotBlank String lastName,
@NotBlank @Email String email, String phone, String address) {
}
record DraftRequest(@NotBlank String apartmentId, @NotEmpty List<@NotBlank String> tenantIds,
List<@Valid GuarantorRequest> guarantors, @NotNull LocalDate startDate,
@NotNull LocalDate endDate, @NotNull @Positive BigDecimal monthlyRent,
@NotNull @PositiveOrZero BigDecimal monthlyCharges,
@NotNull @PositiveOrZero BigDecimal securityDeposit) {
}
record AmendmentRequest(@NotNull LocalDate effectiveDate, @NotEmpty List<@NotBlank String> tenantIds,
List<@Valid GuarantorRequest> guarantors, @NotNull LocalDate endDate,
@NotNull @Positive BigDecimal monthlyRent,
@NotNull @PositiveOrZero BigDecimal monthlyCharges) {
}
record GuarantorResponse(String id, String firstName, String lastName, String email, String phone,
String address) {
static GuarantorResponse from(Guarantor guarantor) {
return new GuarantorResponse(guarantor.id(), guarantor.firstName(), guarantor.lastName(),
guarantor.email(), guarantor.phone(), guarantor.address());
}
}
record LandlordFacts(String id, String firstName, String lastName, String email, String phone, String address) {
static LandlordFacts from(LeaseSnapshot.Landlord landlord) {
return new LandlordFacts(landlord.id().value(), landlord.firstName(), landlord.lastName(),
landlord.email(), landlord.phone(), landlord.address());
}
}
record ApartmentFacts(String id, String name, String address) {
static ApartmentFacts from(LeaseSnapshot.Apartment apartment) {
return new ApartmentFacts(apartment.id().value(), apartment.name(), apartment.address());
}
}
record TenantFacts(String id, String firstName, String lastName, String email, String phone, String address) {
static TenantFacts from(LeaseSnapshot.Tenant tenant) {
return new TenantFacts(tenant.id().value(), tenant.firstName(), tenant.lastName(), tenant.email(),
tenant.phone(), tenant.address());
}
}
record ContractVersion(LandlordFacts landlord, ApartmentFacts apartment, List<TenantFacts> tenants,
List<GuarantorResponse> guarantors, LocalDate startDate, LocalDate endDate,
BigDecimal monthlyRent, BigDecimal monthlyCharges, BigDecimal securityDeposit) {
static ContractVersion from(LeaseContractVersion version) {
return new ContractVersion(LandlordFacts.from(version.landlord()), ApartmentFacts.from(version.apartment()),
version.tenants().stream().map(TenantFacts::from).toList(),
version.guarantors().stream().map(GuarantorResponse::from).toList(),
version.period().startDate(), version.period().endDate(), version.terms().monthlyRent(),
version.terms().monthlyCharges(), version.terms().securityDeposit());
}
}
record AmendmentResponse(String id, LocalDate effectiveDate, ContractVersion beforeVersion,
ContractVersion afterVersion) {
static AmendmentResponse from(LeaseAmendment amendment) {
return new AmendmentResponse(amendment.id().value(), amendment.effectiveDate(),
ContractVersion.from(amendment.beforeVersion()), ContractVersion.from(amendment.afterVersion()));
}
}
record Response(String id, String apartmentId, List<String> tenantIds, List<GuarantorResponse> guarantors,
LocalDate startDate, LocalDate endDate, BigDecimal monthlyRent, BigDecimal monthlyCharges,
BigDecimal securityDeposit, String status, ContractVersion contract,
List<AmendmentResponse> amendments, long version) {
static Response from(Lease lease, LocalDate date) {
if (lease.status() == LeaseStatus.DRAFT) {
return new Response(lease.id().value(), lease.apartmentId().value(),
lease.tenantIds().stream().map(id -> id.value()).toList(),
lease.guarantors().stream().map(GuarantorResponse::from).toList(), lease.period().startDate(),
lease.period().endDate(), lease.terms().monthlyRent(), lease.terms().monthlyCharges(),
lease.terms().securityDeposit(), lease.status().name(), null,
lease.amendments().stream().map(AmendmentResponse::from).toList(), lease.version());
}
LeaseContractVersion effective = lease.contractVersionOn(date);
return new Response(lease.id().value(), effective.apartmentId().value(),
effective.tenantIds().stream().map(id -> id.value()).toList(),
effective.guarantors().stream().map(GuarantorResponse::from).toList(),
effective.period().startDate(), effective.period().endDate(), effective.terms().monthlyRent(),
effective.terms().monthlyCharges(), effective.terms().securityDeposit(), lease.status().name(),
ContractVersion.from(effective), lease.amendments().stream().map(AmendmentResponse::from).toList(),
lease.version());
}
}
record ActivationResponse(Response lease, List<RentDueApi.Response> rentDues) {
}
}
@@ -0,0 +1,155 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.lease;
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlord;
import fr.nessar.lmnporganizer.rental.adapter.in.web.rent.RentDueApi;
import fr.nessar.lmnporganizer.rental.application.lease.ActivateLeaseService;
import fr.nessar.lmnporganizer.rental.application.lease.AmendLeaseService;
import fr.nessar.lmnporganizer.rental.application.lease.ArchiveLeaseService;
import fr.nessar.lmnporganizer.rental.application.lease.GenerateLeaseDocumentService;
import fr.nessar.lmnporganizer.rental.application.lease.LeaseDraftService;
import fr.nessar.lmnporganizer.rental.application.rent.RentDueQueryService;
import fr.nessar.lmnporganizer.rental.application.shared.GeneratedDocument;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
import fr.nessar.lmnporganizer.rental.domain.lease.Guarantor;
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.lease.RentTerms;
import fr.nessar.lmnporganizer.rental.domain.lease.RentalPeriod;
import fr.nessar.lmnporganizer.rental.domain.shared.PhoneNumber;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
import jakarta.validation.Valid;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
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.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.Clock;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
@RestController("rentalLeaseController")
@RequestMapping("/api/leases")
public class LeaseController {
private final LeaseDraftService drafts;
private final ActivateLeaseService activation;
private final ArchiveLeaseService archiving;
private final AmendLeaseService amendments;
private final GenerateLeaseDocumentService documents;
private final RentDueQueryService rentDues;
private final AuthenticatedLandlord authenticatedLandlord;
private final Clock clock;
public LeaseController(LeaseDraftService drafts, ActivateLeaseService activation,
ArchiveLeaseService archiving, AmendLeaseService amendments,
GenerateLeaseDocumentService documents, RentDueQueryService rentDues,
AuthenticatedLandlord authenticatedLandlord, Clock clock) {
this.drafts = drafts;
this.activation = activation;
this.archiving = archiving;
this.amendments = amendments;
this.documents = documents;
this.rentDues = rentDues;
this.authenticatedLandlord = authenticatedLandlord;
this.clock = clock;
}
@GetMapping
List<LeaseApi.Response> list(@AuthenticationPrincipal OidcUser principal) {
return drafts.list(authenticatedLandlord.id(principal)).stream().map(this::response).toList();
}
@GetMapping("/{leaseId}")
LeaseApi.Response get(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId) {
return response(drafts.get(authenticatedLandlord.id(principal), new LeaseId(leaseId)));
}
@PostMapping
ResponseEntity<LeaseApi.Response> create(@AuthenticationPrincipal OidcUser principal,
@Valid @RequestBody LeaseApi.DraftRequest request) {
var created = drafts.create(authenticatedLandlord.id(principal), details(request));
return ResponseEntity.status(HttpStatus.CREATED).body(response(created));
}
@PutMapping("/{leaseId}")
LeaseApi.Response revise(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId,
@Valid @RequestBody LeaseApi.DraftRequest request) {
return response(drafts.revise(authenticatedLandlord.id(principal), new LeaseId(leaseId), details(request)));
}
@DeleteMapping("/{leaseId}")
ResponseEntity<Void> delete(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId) {
drafts.delete(authenticatedLandlord.id(principal), new LeaseId(leaseId));
return ResponseEntity.noContent().build();
}
@PostMapping("/{leaseId}/activate")
LeaseApi.ActivationResponse activate(@AuthenticationPrincipal OidcUser principal,
@PathVariable String leaseId) {
var landlordId = authenticatedLandlord.id(principal);
LeaseId id = new LeaseId(leaseId);
var lease = activation.activate(landlordId, id);
LocalDate today = LocalDate.now(clock);
return new LeaseApi.ActivationResponse(response(lease), rentDues.list(landlordId, id).stream()
.map(due -> RentDueApi.Response.from(due, today)).toList());
}
@PostMapping("/{leaseId}/archive")
LeaseApi.Response archive(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId) {
return response(archiving.archive(authenticatedLandlord.id(principal), new LeaseId(leaseId)));
}
@PostMapping("/{leaseId}/amendments")
ResponseEntity<LeaseApi.Response> amend(@AuthenticationPrincipal OidcUser principal,
@PathVariable String leaseId,
@Valid @RequestBody LeaseApi.AmendmentRequest request) {
var change = new AmendLeaseService.Change(tenantIds(request.tenantIds()), guarantors(request.guarantors()),
request.endDate(), request.monthlyRent(), request.monthlyCharges());
var amended = amendments.amend(authenticatedLandlord.id(principal), new LeaseId(leaseId),
request.effectiveDate(), change);
return ResponseEntity.status(HttpStatus.CREATED).body(response(amended));
}
@GetMapping("/{leaseId}/pdf")
ResponseEntity<byte[]> document(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId) {
return document(documents.generate(authenticatedLandlord.id(principal), new LeaseId(leaseId)));
}
private static LeaseDraftService.DraftDetails details(LeaseApi.DraftRequest request) {
return new LeaseDraftService.DraftDetails(new ApartmentId(request.apartmentId()),
tenantIds(request.tenantIds()), guarantors(request.guarantors()),
new RentalPeriod(request.startDate(), request.endDate()),
new RentTerms(request.monthlyRent(), request.monthlyCharges(), request.securityDeposit()));
}
private LeaseApi.Response response(Lease lease) {
return LeaseApi.Response.from(lease, LocalDate.now(clock));
}
private static List<TenantId> tenantIds(List<String> ids) {
return ids.stream().map(TenantId::new).toList();
}
private static List<Guarantor> guarantors(List<LeaseApi.GuarantorRequest> requests) {
return requests == null ? List.of() : requests.stream().map(request -> new Guarantor(
request.id() == null || request.id().isBlank() ? UUID.randomUUID().toString() : request.id(),
request.firstName(), request.lastName(), request.email(), new PhoneNumber(request.phone()).value(),
request.address())).toList();
}
private static ResponseEntity<byte[]> document(GeneratedDocument document) {
return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
.filename(document.fileName()).build().toString())
.header(HttpHeaders.CONTENT_TYPE, document.contentType()).body(document.content());
}
}
@@ -0,0 +1,21 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.rent;
import fr.nessar.lmnporganizer.rental.domain.rent.RentDue;
import java.math.BigDecimal;
import java.time.LocalDate;
public final class RentDueApi {
private RentDueApi() {
}
public record Response(String id, String leaseId, String month, LocalDate dueDate, BigDecimal rent,
BigDecimal charges, BigDecimal total, BigDecimal allocatedAmount,
BigDecimal remainingAmount, String settlement, String timing, long version) {
public static Response from(RentDue due, LocalDate today) {
return new Response(due.id().value(), due.leaseId().value(), due.month().toString(), due.dueDate(),
due.rent(), due.charges(), due.total(), due.allocatedAmount(), due.remainingAmount(),
due.settlement().name(), due.timingOn(today).name(), due.version());
}
}
}
@@ -0,0 +1,65 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.rent;
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlord;
import fr.nessar.lmnporganizer.rental.application.rent.GenerateRentReceiptService;
import fr.nessar.lmnporganizer.rental.application.rent.RentDueQueryService;
import fr.nessar.lmnporganizer.rental.application.shared.GeneratedDocument;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueId;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
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.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.Clock;
import java.time.LocalDate;
import java.util.List;
@RestController("rentalRentDueController")
@RequestMapping("/api/rent-dues")
public class RentDueController {
private final RentDueQueryService rentDues;
private final GenerateRentReceiptService receipts;
private final AuthenticatedLandlord authenticatedLandlord;
private final Clock clock;
public RentDueController(RentDueQueryService rentDues, GenerateRentReceiptService receipts,
AuthenticatedLandlord authenticatedLandlord, Clock clock) {
this.rentDues = rentDues;
this.receipts = receipts;
this.authenticatedLandlord = authenticatedLandlord;
this.clock = clock;
}
@GetMapping
List<RentDueApi.Response> list(@AuthenticationPrincipal OidcUser principal,
@RequestParam(required = false) String leaseId) {
var landlordId = authenticatedLandlord.id(principal);
var dues = leaseId == null ? rentDues.list(landlordId) : rentDues.list(landlordId, new LeaseId(leaseId));
LocalDate today = LocalDate.now(clock);
return dues.stream().map(due -> RentDueApi.Response.from(due, today)).toList();
}
@GetMapping("/{rentDueId}")
RentDueApi.Response get(@AuthenticationPrincipal OidcUser principal, @PathVariable String rentDueId) {
return RentDueApi.Response.from(rentDues.get(authenticatedLandlord.id(principal), new RentDueId(rentDueId)),
LocalDate.now(clock));
}
@GetMapping("/{rentDueId}/receipt")
ResponseEntity<byte[]> receipt(@AuthenticationPrincipal OidcUser principal, @PathVariable String rentDueId) {
return document(receipts.generate(authenticatedLandlord.id(principal), new RentDueId(rentDueId)));
}
private static ResponseEntity<byte[]> document(GeneratedDocument document) {
return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
.filename(document.fileName()).build().toString())
.header(HttpHeaders.CONTENT_TYPE, document.contentType()).body(document.content());
}
}
@@ -0,0 +1,78 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.rent;
import fr.nessar.lmnporganizer.rental.domain.rent.PaymentAllocation;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPayment;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefund;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversal;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
final class RentPaymentApi {
private RentPaymentApi() {
}
record CreateRequest(@NotBlank String leaseId, @NotNull LocalDate receivedAt,
@NotNull @Positive BigDecimal amount) {
}
record ReviseRequest(@NotNull LocalDate receivedAt, @NotNull @Positive BigDecimal amount) {
}
record AllocationRequest(@NotBlank String rentDueId, @NotNull @Positive BigDecimal amount) {
}
record ConfirmRequest(List<@Valid AllocationRequest> allocations) {
ConfirmRequest {
allocations = allocations == null ? List.of() : List.copyOf(allocations);
}
}
record ReversalRequest(@NotBlank String reason) {
}
record RefundRequest(@NotNull @Positive BigDecimal amount, @NotNull LocalDate refundedAt) {
}
record AllocationResponse(String id, String rentDueId, BigDecimal amount, LocalDate allocatedAt) {
static AllocationResponse from(PaymentAllocation allocation) {
return new AllocationResponse(allocation.id().value(), allocation.rentDueId().value(),
allocation.amount(), allocation.allocatedAt());
}
}
record Response(String id, String leaseId, LocalDate receivedAt, BigDecimal amount, String status,
List<AllocationResponse> allocations, BigDecimal allocatedAmount, BigDecimal refundedAmount,
BigDecimal unallocatedBalance, LocalDate confirmedAt, long version) {
static Response from(RentPayment payment) {
return new Response(payment.id().value(), payment.leaseId().value(), payment.receivedAt(),
payment.amount(), payment.status().name(),
payment.allocations().stream().map(AllocationResponse::from).toList(), payment.allocatedAmount(),
payment.refundedAmount(), payment.unallocatedBalance(), payment.confirmedAt(), payment.version());
}
}
record RefundResponse(String id, String leaseId, String paymentId, BigDecimal amount, LocalDate refundedAt,
String status, LocalDate confirmedAt, long version) {
static RefundResponse from(RentPaymentRefund refund) {
return new RefundResponse(refund.id().value(), refund.leaseId().value(), refund.paymentId().value(),
refund.amount(), refund.refundedAt(), refund.status().name(), refund.confirmedAt(),
refund.version());
}
}
record ReversalResponse(String id, String leaseId, String paymentId, LocalDate reversedAt, String reason,
List<AllocationResponse> reversedAllocations) {
static ReversalResponse from(RentPaymentReversal reversal) {
return new ReversalResponse(reversal.id().value(), reversal.leaseId().value(),
reversal.paymentId().value(), reversal.reversedAt(), reversal.reason(),
reversal.reversedAllocations().stream().map(AllocationResponse::from).toList());
}
}
}
@@ -0,0 +1,193 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.rent;
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlord;
import fr.nessar.lmnporganizer.rental.application.rent.AllocateRentPaymentService;
import fr.nessar.lmnporganizer.rental.application.rent.ConfirmRentPaymentRefundService;
import fr.nessar.lmnporganizer.rental.application.rent.ConfirmRentPaymentService;
import fr.nessar.lmnporganizer.rental.application.rent.GeneratePaymentReceiptService;
import fr.nessar.lmnporganizer.rental.application.rent.RentPaymentDraftService;
import fr.nessar.lmnporganizer.rental.application.rent.RentPaymentRefundDraftService;
import fr.nessar.lmnporganizer.rental.application.rent.ReverseRentPaymentService;
import fr.nessar.lmnporganizer.rental.application.shared.GeneratedDocument;
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueId;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentId;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefund;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefundId;
import jakarta.validation.Valid;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
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.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController("rentalRentPaymentController")
@RequestMapping("/api/rent-payments")
public class RentPaymentController {
private final RentPaymentDraftService drafts;
private final ConfirmRentPaymentService confirmation;
private final AllocateRentPaymentService allocation;
private final ReverseRentPaymentService reversal;
private final RentPaymentRefundDraftService refundDrafts;
private final ConfirmRentPaymentRefundService refundConfirmation;
private final GeneratePaymentReceiptService receipts;
private final AuthenticatedLandlord authenticatedLandlord;
public RentPaymentController(RentPaymentDraftService drafts, ConfirmRentPaymentService confirmation,
AllocateRentPaymentService allocation, ReverseRentPaymentService reversal,
RentPaymentRefundDraftService refundDrafts,
ConfirmRentPaymentRefundService refundConfirmation,
GeneratePaymentReceiptService receipts,
AuthenticatedLandlord authenticatedLandlord) {
this.drafts = drafts;
this.confirmation = confirmation;
this.allocation = allocation;
this.reversal = reversal;
this.refundDrafts = refundDrafts;
this.refundConfirmation = refundConfirmation;
this.receipts = receipts;
this.authenticatedLandlord = authenticatedLandlord;
}
@GetMapping
List<RentPaymentApi.Response> list(@AuthenticationPrincipal OidcUser principal,
@RequestParam(required = false) String leaseId) {
LandlordId landlordId = authenticatedLandlord.id(principal);
var payments = leaseId == null ? drafts.list(landlordId) : drafts.list(landlordId, new LeaseId(leaseId));
return payments.stream().map(RentPaymentApi.Response::from).toList();
}
@GetMapping("/{paymentId}")
RentPaymentApi.Response get(@AuthenticationPrincipal OidcUser principal, @PathVariable String paymentId) {
return RentPaymentApi.Response.from(drafts.get(authenticatedLandlord.id(principal),
new RentPaymentId(paymentId)));
}
@PostMapping
ResponseEntity<RentPaymentApi.Response> create(@AuthenticationPrincipal OidcUser principal,
@Valid @RequestBody RentPaymentApi.CreateRequest request) {
var created = drafts.create(authenticatedLandlord.id(principal), new LeaseId(request.leaseId()),
request.receivedAt(), request.amount());
return ResponseEntity.status(HttpStatus.CREATED).body(RentPaymentApi.Response.from(created));
}
@PutMapping("/{paymentId}")
RentPaymentApi.Response revise(@AuthenticationPrincipal OidcUser principal, @PathVariable String paymentId,
@Valid @RequestBody RentPaymentApi.ReviseRequest request) {
return RentPaymentApi.Response.from(drafts.revise(authenticatedLandlord.id(principal),
new RentPaymentId(paymentId), request.receivedAt(), request.amount()));
}
@DeleteMapping("/{paymentId}")
ResponseEntity<Void> delete(@AuthenticationPrincipal OidcUser principal, @PathVariable String paymentId) {
drafts.delete(authenticatedLandlord.id(principal), new RentPaymentId(paymentId));
return ResponseEntity.noContent().build();
}
@PostMapping("/{paymentId}/confirm")
RentPaymentApi.Response confirm(@AuthenticationPrincipal OidcUser principal, @PathVariable String paymentId,
@Valid @RequestBody RentPaymentApi.ConfirmRequest request) {
var requested = request.allocations().stream().map(item -> new ConfirmRentPaymentService.RequestedAllocation(
new RentDueId(item.rentDueId()), item.amount())).toList();
return RentPaymentApi.Response.from(confirmation.confirm(authenticatedLandlord.id(principal),
new RentPaymentId(paymentId), requested));
}
@PostMapping("/{paymentId}/allocations")
RentPaymentApi.Response allocate(@AuthenticationPrincipal OidcUser principal, @PathVariable String paymentId,
@Valid @RequestBody RentPaymentApi.AllocationRequest request) {
return RentPaymentApi.Response.from(allocation.allocate(authenticatedLandlord.id(principal),
new RentPaymentId(paymentId), new RentDueId(request.rentDueId()), request.amount()));
}
@PostMapping("/{paymentId}/reversal")
ResponseEntity<RentPaymentApi.ReversalResponse> reverse(@AuthenticationPrincipal OidcUser principal,
@PathVariable String paymentId,
@Valid @RequestBody RentPaymentApi.ReversalRequest request) {
var reversed = reversal.reverse(authenticatedLandlord.id(principal), new RentPaymentId(paymentId),
request.reason());
return ResponseEntity.status(HttpStatus.CREATED).body(RentPaymentApi.ReversalResponse.from(reversed));
}
@GetMapping("/{paymentId}/reversal")
RentPaymentApi.ReversalResponse reversal(@AuthenticationPrincipal OidcUser principal,
@PathVariable String paymentId) {
return RentPaymentApi.ReversalResponse.from(reversal.get(authenticatedLandlord.id(principal),
new RentPaymentId(paymentId)));
}
@GetMapping("/{paymentId}/receipt")
ResponseEntity<byte[]> receipt(@AuthenticationPrincipal OidcUser principal, @PathVariable String paymentId) {
return document(receipts.generate(authenticatedLandlord.id(principal), new RentPaymentId(paymentId)));
}
@GetMapping("/{paymentId}/refunds")
List<RentPaymentApi.RefundResponse> refunds(@AuthenticationPrincipal OidcUser principal,
@PathVariable String paymentId) {
return refundDrafts.list(authenticatedLandlord.id(principal), new RentPaymentId(paymentId)).stream()
.map(RentPaymentApi.RefundResponse::from).toList();
}
@PostMapping("/{paymentId}/refunds")
ResponseEntity<RentPaymentApi.RefundResponse> createRefund(@AuthenticationPrincipal OidcUser principal,
@PathVariable String paymentId,
@Valid @RequestBody RentPaymentApi.RefundRequest request) {
var created = refundDrafts.create(authenticatedLandlord.id(principal), new RentPaymentId(paymentId),
request.amount(), request.refundedAt());
return ResponseEntity.status(HttpStatus.CREATED).body(RentPaymentApi.RefundResponse.from(created));
}
@PutMapping("/{paymentId}/refunds/{refundId}")
RentPaymentApi.RefundResponse reviseRefund(@AuthenticationPrincipal OidcUser principal,
@PathVariable String paymentId, @PathVariable String refundId,
@Valid @RequestBody RentPaymentApi.RefundRequest request) {
LandlordId landlordId = authenticatedLandlord.id(principal);
requirePayment(refundDrafts.get(landlordId, new RentPaymentRefundId(refundId)), paymentId);
return RentPaymentApi.RefundResponse.from(refundDrafts.revise(landlordId,
new RentPaymentRefundId(refundId), request.amount(), request.refundedAt()));
}
@DeleteMapping("/{paymentId}/refunds/{refundId}")
ResponseEntity<Void> deleteRefund(@AuthenticationPrincipal OidcUser principal,
@PathVariable String paymentId, @PathVariable String refundId) {
LandlordId landlordId = authenticatedLandlord.id(principal);
requirePayment(refundDrafts.get(landlordId, new RentPaymentRefundId(refundId)), paymentId);
refundDrafts.delete(landlordId, new RentPaymentRefundId(refundId));
return ResponseEntity.noContent().build();
}
@PostMapping("/{paymentId}/refunds/{refundId}/confirm")
RentPaymentApi.RefundResponse confirmRefund(@AuthenticationPrincipal OidcUser principal,
@PathVariable String paymentId, @PathVariable String refundId) {
LandlordId landlordId = authenticatedLandlord.id(principal);
requirePayment(refundDrafts.get(landlordId, new RentPaymentRefundId(refundId)), paymentId);
return RentPaymentApi.RefundResponse.from(refundConfirmation.confirm(landlordId,
new RentPaymentRefundId(refundId)));
}
private static void requirePayment(RentPaymentRefund refund, String paymentId) {
if (!refund.paymentId().equals(new RentPaymentId(paymentId))) {
throw new ResourceNotFound("Rent payment refund");
}
}
private static ResponseEntity<byte[]> document(GeneratedDocument document) {
return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
.filename(document.fileName()).build().toString())
.header(HttpHeaders.CONTENT_TYPE, document.contentType()).body(document.content());
}
}
@@ -0,0 +1,33 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.tenant;
import fr.nessar.lmnporganizer.rental.domain.tenant.Tenant;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantAttachment;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import java.util.List;
final class TenantApi {
private TenantApi() {
}
record Request(@NotBlank String firstName, @NotBlank String lastName,
@NotBlank @Email String email, String phone, String address) {
}
record AttachmentResponse(String id, String fileName, String contentType, long size) {
static AttachmentResponse from(TenantAttachment attachment) {
return new AttachmentResponse(attachment.id(), attachment.fileName(), attachment.contentType(),
attachment.size());
}
}
record Response(String id, String firstName, String lastName, String email, String phone, String address,
List<AttachmentResponse> attachments, boolean referenced, long version) {
static Response from(Tenant tenant) {
return new Response(tenant.id().value(), tenant.firstName(), tenant.lastName(), tenant.email(),
tenant.phone(), tenant.address(), tenant.attachments().stream().map(AttachmentResponse::from)
.toList(), tenant.isReferenced(), tenant.version());
}
}
}
@@ -0,0 +1,128 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.tenant;
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlord;
import fr.nessar.lmnporganizer.rental.application.port.out.TenantAttachmentStorage;
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentStorageFailure;
import fr.nessar.lmnporganizer.rental.application.tenant.DeleteTenantService;
import fr.nessar.lmnporganizer.rental.application.tenant.TenantAttachmentService;
import fr.nessar.lmnporganizer.rental.application.tenant.TenantDirectoryService;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
import jakarta.validation.Valid;
import org.springframework.core.io.InputStreamResource;
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.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.List;
@RestController("rentalTenantController")
@RequestMapping("/api/tenants")
public class TenantController {
private final TenantDirectoryService tenants;
private final DeleteTenantService deletion;
private final TenantAttachmentService attachments;
private final AuthenticatedLandlord authenticatedLandlord;
public TenantController(TenantDirectoryService tenants, DeleteTenantService deletion,
TenantAttachmentService attachments, AuthenticatedLandlord authenticatedLandlord) {
this.tenants = tenants;
this.deletion = deletion;
this.attachments = attachments;
this.authenticatedLandlord = authenticatedLandlord;
}
@GetMapping
List<TenantApi.Response> list(@AuthenticationPrincipal OidcUser principal) {
return tenants.list(authenticatedLandlord.id(principal)).stream().map(TenantApi.Response::from).toList();
}
@GetMapping("/{tenantId}")
TenantApi.Response get(@AuthenticationPrincipal OidcUser principal, @PathVariable String tenantId) {
return TenantApi.Response.from(tenants.get(authenticatedLandlord.id(principal), new TenantId(tenantId)));
}
@PostMapping
ResponseEntity<TenantApi.Response> create(@AuthenticationPrincipal OidcUser principal,
@Valid @RequestBody TenantApi.Request request) {
var created = tenants.create(authenticatedLandlord.id(principal), details(request));
return ResponseEntity.status(HttpStatus.CREATED).body(TenantApi.Response.from(created));
}
@PutMapping("/{tenantId}")
TenantApi.Response revise(@AuthenticationPrincipal OidcUser principal, @PathVariable String tenantId,
@Valid @RequestBody TenantApi.Request request) {
return TenantApi.Response.from(tenants.revise(authenticatedLandlord.id(principal), new TenantId(tenantId),
details(request)));
}
@DeleteMapping("/{tenantId}")
ResponseEntity<Void> delete(@AuthenticationPrincipal OidcUser principal, @PathVariable String tenantId) {
deletion.delete(authenticatedLandlord.id(principal), new TenantId(tenantId));
return ResponseEntity.noContent().build();
}
@PostMapping(path = "/{tenantId}/attachments", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
ResponseEntity<TenantApi.AttachmentResponse> upload(@AuthenticationPrincipal OidcUser principal,
@PathVariable String tenantId,
@RequestPart("file") MultipartFile file) {
var stored = attachments.upload(authenticatedLandlord.id(principal), new TenantId(tenantId), upload(file));
return ResponseEntity.status(HttpStatus.CREATED).body(TenantApi.AttachmentResponse.from(stored));
}
@GetMapping("/{tenantId}/attachments/{attachmentId}")
ResponseEntity<InputStreamResource> download(@AuthenticationPrincipal OidcUser principal,
@PathVariable String tenantId,
@PathVariable String attachmentId) {
var download = attachments.download(authenticatedLandlord.id(principal), new TenantId(tenantId),
attachmentId);
return ResponseEntity.ok().contentType(mediaType(download.attachment().contentType()))
.contentLength(download.attachment().size())
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
.filename(download.attachment().fileName()).build().toString())
.body(new InputStreamResource(download.content()));
}
@DeleteMapping("/{tenantId}/attachments/{attachmentId}")
ResponseEntity<Void> deleteAttachment(@AuthenticationPrincipal OidcUser principal,
@PathVariable String tenantId, @PathVariable String attachmentId) {
attachments.delete(authenticatedLandlord.id(principal), new TenantId(tenantId), attachmentId);
return ResponseEntity.noContent().build();
}
private static TenantDirectoryService.ContactDetails details(TenantApi.Request request) {
return new TenantDirectoryService.ContactDetails(request.firstName(), request.lastName(), request.email(),
request.phone(), request.address());
}
private static TenantAttachmentStorage.Upload upload(MultipartFile file) {
try {
return new TenantAttachmentStorage.Upload(file.getOriginalFilename(), file.getContentType(),
file.getSize(), file.getInputStream());
} catch (IOException failure) {
throw new AttachmentStorageFailure("Tenant attachment could not be opened.", failure);
}
}
private static MediaType mediaType(String contentType) {
try {
return MediaType.parseMediaType(contentType);
} catch (IllegalArgumentException exception) {
return MediaType.APPLICATION_OCTET_STREAM;
}
}
}
@@ -0,0 +1,297 @@
package fr.nessar.lmnporganizer.rental.adapter.out.filesystem;
import fr.nessar.lmnporganizer.rental.adapter.in.config.RentalStorageProperties;
import fr.nessar.lmnporganizer.rental.application.port.out.TenantAttachmentStorage;
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentStorageFailure;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantAttachment;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.DirectoryNotEmptyException;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.Comparator;
import java.util.List;
@Component
public class FileSystemTenantAttachmentStorage implements TenantAttachmentStorage {
private static final String STAGED = "staged";
private static final String BACKUP = "backup";
private static final String COMMITTED = "committed";
private static final Logger LOGGER = LoggerFactory.getLogger(FileSystemTenantAttachmentStorage.class);
private final Path root;
private final Path stagingRoot;
public FileSystemTenantAttachmentStorage(RentalStorageProperties properties) {
root = properties.attachments().toAbsolutePath().normalize();
stagingRoot = root.resolve(".staging");
// ponytail: startup recovery assumes one process owns this filesystem root; use object storage when scaling out.
recoverInterruptedDeletions();
}
@Override
public TenantAttachment store(TenantId tenantId, String attachmentId, Upload upload) {
sweepCommittedTombstones();
Path tenantDirectory = insideRoot(root.resolve(tenantId.value()), "Invalid tenant attachment directory.");
Path destination = insideRoot(tenantDirectory.resolve(attachmentId), "Invalid tenant attachment path.");
try {
Files.createDirectories(tenantDirectory);
try (InputStream content = upload.content();
OutputStream output = Files.newOutputStream(destination, StandardOpenOption.CREATE_NEW)) {
content.transferTo(output);
}
return new TenantAttachment(attachmentId, safeFileName(upload.fileName()), contentType(upload.contentType()),
upload.size(), root.relativize(destination).toString());
} catch (IOException | RuntimeException failure) {
try {
Files.deleteIfExists(destination);
} catch (IOException cleanupFailure) {
failure.addSuppressed(cleanupFailure);
}
throw failure("Tenant attachment could not be stored.", failure);
}
}
@Override
public InputStream read(TenantAttachment attachment) {
Path path = activePath(attachment);
try {
if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Tenant attachment not found.");
}
return Files.newInputStream(path);
} catch (IOException failure) {
throw failure("Tenant attachment could not be read.", failure);
}
}
@Override
public void discardStored(TenantAttachment attachment) {
try {
Files.deleteIfExists(activePath(attachment));
} catch (IOException failure) {
throw failure("Uncommitted tenant attachment could not be discarded.", failure);
}
}
@Override
public StagedDeletion stageDeletion(TenantAttachment attachment) {
sweepCommittedTombstones();
Path active = activePath(attachment);
try {
if (!Files.isRegularFile(active, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Tenant attachment not found.");
}
Path deletionDirectory = stagingRoot.resolve(root.relativize(active));
Files.createDirectories(deletionDirectory);
Path staged = deletionDirectory.resolve(STAGED);
Path backup = deletionDirectory.resolve(BACKUP);
Path committed = deletionDirectory.resolve(COMMITTED);
Files.move(active, staged, StandardCopyOption.ATOMIC_MOVE);
try {
createBackup(staged, backup);
} catch (IOException | RuntimeException failure) {
Files.deleteIfExists(backup);
Files.move(staged, active, StandardCopyOption.ATOMIC_MOVE);
cleanEmptyParents(deletionDirectory);
throw failure;
}
return new FileDeletion(active, staged, backup, committed);
} catch (IOException | RuntimeException failure) {
throw failure("Tenant attachment deletion could not be staged.", failure);
}
}
@Override
public void restore(StagedDeletion deletion) {
FileDeletion files = files(deletion);
try {
Path source = firstExisting(files.staged(), files.backup(), files.committed());
Files.createDirectories(files.active().getParent());
Files.move(source, files.active(), StandardCopyOption.ATOMIC_MOVE);
Files.deleteIfExists(files.staged());
Files.deleteIfExists(files.backup());
Files.deleteIfExists(files.committed());
cleanEmptyParents(files.staged().getParent());
} catch (IOException failure) {
throw failure("Tenant attachment could not be restored.", failure);
}
}
@Override
public void completeDeletion(StagedDeletion deletion) {
FileDeletion files = files(deletion);
try {
Files.deleteIfExists(files.staged());
Files.move(files.backup(), files.committed(), StandardCopyOption.ATOMIC_MOVE);
Files.deleteIfExists(files.committed());
cleanEmptyParents(files.staged().getParent());
} catch (IOException | RuntimeException failure) {
LOGGER.warn("Committed tenant attachment tombstone cleanup deferred", failure);
}
}
private Path activePath(TenantAttachment attachment) {
return insideRoot(root.resolve(attachment.storageKey()), "Invalid tenant attachment path.");
}
private Path insideRoot(Path candidate, String message) {
Path normalized = candidate.normalize();
if (normalized.equals(root) || !normalized.startsWith(root) || normalized.startsWith(stagingRoot)) {
throw new AttachmentStorageFailure(message, new IllegalArgumentException(message));
}
return normalized;
}
private void sweepCommittedTombstones() {
if (!Files.isDirectory(stagingRoot, LinkOption.NOFOLLOW_LINKS)) {
return;
}
try (var paths = Files.walk(stagingRoot)) {
paths.filter(candidate -> Files.isRegularFile(candidate, LinkOption.NOFOLLOW_LINKS))
.filter(candidate -> candidate.getFileName().toString().equals(COMMITTED))
.forEach(this::discardCommitted);
} catch (IOException | RuntimeException failure) {
LOGGER.warn("Committed tenant attachment tombstones could not be scanned", failure);
}
}
private void discardCommitted(Path path) {
try {
discardArtifacts(path.getParent());
cleanEmptyParents(path.getParent());
} catch (IOException | RuntimeException failure) {
LOGGER.warn("Committed tenant attachment tombstone cleanup deferred: {}", path.getFileName(), failure);
}
}
private void recoverInterruptedDeletions() {
if (!Files.isDirectory(stagingRoot, LinkOption.NOFOLLOW_LINKS)) {
return;
}
try {
List<Path> deletionDirectories;
try (var paths = Files.walk(stagingRoot)) {
deletionDirectories = paths
.filter(path -> Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS))
.filter(this::hasArtifacts)
.sorted(Comparator.reverseOrder())
.toList();
}
deletionDirectories.forEach(this::recoverInterruptedDeletion);
} catch (IOException | RuntimeException failure) {
throw failure("Interrupted tenant attachment deletions could not be recovered.", failure);
}
}
private boolean hasArtifacts(Path directory) {
return Files.isRegularFile(directory.resolve(STAGED), LinkOption.NOFOLLOW_LINKS)
|| Files.isRegularFile(directory.resolve(BACKUP), LinkOption.NOFOLLOW_LINKS)
|| Files.isRegularFile(directory.resolve(COMMITTED), LinkOption.NOFOLLOW_LINKS);
}
private void recoverInterruptedDeletion(Path deletionDirectory) {
try {
Path committed = deletionDirectory.resolve(COMMITTED);
if (Files.isRegularFile(committed, LinkOption.NOFOLLOW_LINKS)) {
discardArtifacts(deletionDirectory);
} else {
Path active = insideRoot(root.resolve(stagingRoot.relativize(deletionDirectory)),
"Invalid staged tenant attachment path.");
if (!Files.exists(active, LinkOption.NOFOLLOW_LINKS)) {
Files.createDirectories(active.getParent());
Files.move(firstExisting(deletionDirectory.resolve(STAGED), deletionDirectory.resolve(BACKUP)),
active, StandardCopyOption.ATOMIC_MOVE);
} else if (!Files.isRegularFile(active, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Recovered tenant attachment path is not a regular file.");
}
discardArtifacts(deletionDirectory);
}
cleanEmptyParents(deletionDirectory);
} catch (IOException | RuntimeException failure) {
throw failure("Interrupted tenant attachment deletion could not be recovered.", failure);
}
}
private static void discardArtifacts(Path directory) throws IOException {
Files.deleteIfExists(directory.resolve(STAGED));
Files.deleteIfExists(directory.resolve(BACKUP));
Files.deleteIfExists(directory.resolve(COMMITTED));
}
private void cleanEmptyParents(Path directory) throws IOException {
Path candidate = directory;
while (candidate != null && candidate.startsWith(stagingRoot)) {
try {
Files.deleteIfExists(candidate);
} catch (DirectoryNotEmptyException ignored) {
return;
}
if (candidate.equals(stagingRoot)) {
return;
}
candidate = candidate.getParent();
}
}
private static Path firstExisting(Path... candidates) throws IOException {
for (Path candidate : candidates) {
if (Files.isRegularFile(candidate, LinkOption.NOFOLLOW_LINKS)) {
return candidate;
}
}
throw new IOException("Tenant attachment backup not found.");
}
private static void createBackup(Path staged, Path backup) throws IOException {
try {
Files.createLink(backup, staged);
} catch (IOException | UnsupportedOperationException linkFailure) {
try {
Files.copy(staged, backup);
} catch (IOException | RuntimeException copyFailure) {
copyFailure.addSuppressed(linkFailure);
throw copyFailure;
}
}
}
private static String safeFileName(String supplied) {
if (supplied == null || supplied.isBlank()) {
return "attachment";
}
String trimmed = supplied.trim();
int separator = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'));
String name = trimmed.substring(separator + 1);
return name.isBlank() ? "attachment" : name;
}
private static String contentType(String supplied) {
return supplied == null || supplied.isBlank() ? "application/octet-stream" : supplied.trim();
}
private static FileDeletion files(StagedDeletion deletion) {
if (deletion instanceof FileDeletion fileDeletion) {
return fileDeletion;
}
throw new IllegalArgumentException("Unsupported tenant attachment deletion token.");
}
private static AttachmentStorageFailure failure(String message, Throwable failure) {
return failure instanceof AttachmentStorageFailure storageFailure
? storageFailure
: new AttachmentStorageFailure(message, failure);
}
private record FileDeletion(Path active, Path staged, Path backup, Path committed) implements StagedDeletion {
}
}
@@ -0,0 +1,14 @@
package fr.nessar.lmnporganizer.rental.adapter.out.identity;
import fr.nessar.lmnporganizer.rental.application.port.out.IdentifierGenerator;
import org.springframework.stereotype.Component;
import java.util.UUID;
@Component
public final class UuidIdentifierGenerator implements IdentifierGenerator {
@Override
public String next() {
return UUID.randomUUID().toString();
}
}
@@ -0,0 +1,48 @@
package fr.nessar.lmnporganizer.rental.adapter.out.mongodb;
import com.mongodb.client.result.UpdateResult;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import java.util.function.LongFunction;
public final class LandlordScopedMongoWriter {
private LandlordScopedMongoWriter() {
}
public static <D> D save(MongoTemplate mongoTemplate, D document, String id, String landlordId, long version,
LongFunction<D> withVersion) {
return save(mongoTemplate, document, id, "landlordId", landlordId, version, withVersion);
}
public static <D> D saveLandlord(MongoTemplate mongoTemplate, D document, String landlordId, long version,
LongFunction<D> withVersion) {
return save(mongoTemplate, document, landlordId, null, null, version, withVersion);
}
private static <D> D save(MongoTemplate mongoTemplate, D document, String id, String scopeField,
String scopeValue, long version, LongFunction<D> withVersion) {
if (version < 0) {
try {
return mongoTemplate.insert(document);
} catch (DuplicateKeyException failure) {
throw new OptimisticLockingFailureException(
"Landlord-scoped aggregate was created concurrently.", failure);
}
}
D replacement = withVersion.apply(version + 1);
Criteria criteria = Criteria.where("_id").is(id);
if (scopeField != null) {
criteria = criteria.and(scopeField).is(scopeValue);
}
Query query = Query.query(criteria.and("version").is(version));
UpdateResult result = mongoTemplate.replace(query, replacement);
if (result.getMatchedCount() != 1) {
throw new OptimisticLockingFailureException("Landlord-scoped aggregate changed concurrently.");
}
return replacement;
}
}
@@ -0,0 +1,26 @@
package fr.nessar.lmnporganizer.rental.adapter.out.mongodb;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.index.MongoPersistentEntityIndexResolver;
import org.springframework.data.mongodb.core.mapping.Document;
@Configuration(proxyBeanMethods = false)
public class RentalMongoIndexConfiguration {
private static final String RENTAL_MONGO_PACKAGE = "fr.nessar.lmnporganizer.rental.adapter.out.mongodb";
@Bean
ApplicationRunner rentalMongoIndexInitializer(MongoTemplate mongoTemplate) {
return arguments -> {
var mappingContext = mongoTemplate.getConverter().getMappingContext();
var resolver = new MongoPersistentEntityIndexResolver(mappingContext);
mappingContext.getPersistentEntities().stream()
.filter(entity -> entity.getType().getPackageName().startsWith(RENTAL_MONGO_PACKAGE))
.filter(entity -> entity.isAnnotationPresent(Document.class))
.flatMap(entity -> resolver.resolveIndexForEntity(entity).stream())
.forEach(index -> mongoTemplate.indexOps(index.getCollection()).createIndex(index));
};
}
}
@@ -0,0 +1,120 @@
package fr.nessar.lmnporganizer.rental.adapter.out.mongodb.apartment;
import fr.nessar.lmnporganizer.rental.adapter.out.mongodb.LandlordScopedMongoWriter;
import fr.nessar.lmnporganizer.rental.domain.apartment.Apartment;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentRepository;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.Version;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.mapping.Field;
import org.springframework.data.mongodb.core.mapping.FieldType;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.stereotype.Repository;
@Repository
public class MongoApartmentRepository implements ApartmentRepository {
static final String COLLECTION = "apartments";
private final MongoTemplate mongoTemplate;
public MongoApartmentRepository(MongoTemplate mongoTemplate) {
this.mongoTemplate = mongoTemplate;
}
@Override
public List<Apartment> findAll(LandlordId landlordId) {
Query query = Query.query(Criteria.where("landlordId").is(landlordId.value()));
return mongoTemplate.find(query, ApartmentDocument.class).stream()
.map(MongoApartmentRepository::toDomain)
.toList();
}
@Override
public Optional<Apartment> find(LandlordId landlordId, ApartmentId apartmentId) {
return Optional.ofNullable(mongoTemplate.findOne(scopedQuery(landlordId, apartmentId), ApartmentDocument.class))
.map(MongoApartmentRepository::toDomain);
}
@Override
public Apartment save(Apartment apartment) {
ApartmentDocument document = toDocument(apartment);
return toDomain(LandlordScopedMongoWriter.save(mongoTemplate, document, document.id(), document.landlordId(),
apartment.version(), document::withVersion));
}
@Override
public boolean delete(Apartment apartment) {
Query query = scopedQuery(apartment.landlordId(), apartment.id());
query.addCriteria(Criteria.where("version").is(apartment.version()));
return mongoTemplate.remove(query, ApartmentDocument.class).getDeletedCount() == 1;
}
private static Query scopedQuery(LandlordId landlordId, ApartmentId apartmentId) {
return Query.query(
Criteria.where("_id")
.is(apartmentId.value())
.and("landlordId")
.is(landlordId.value()));
}
static ApartmentDocument toDocument(Apartment apartment) {
return new ApartmentDocument(
apartment.id().value(),
apartment.landlordId().value(),
apartment.name(),
apartment.address(),
apartment.purchasePrice(),
apartment.description(),
apartment.leaseReferences().stream().map(LeaseId::value).toList(),
persistedVersion(apartment.version()));
}
static Apartment toDomain(ApartmentDocument document) {
return Apartment.reconstitute(
new ApartmentId(document.id()),
new LandlordId(document.landlordId()),
document.name(),
document.address(),
document.purchasePrice(),
document.description(),
document.leaseReferences() == null ? Set.of() : document.leaseReferences().stream()
.map(LeaseId::new).collect(java.util.stream.Collectors.toUnmodifiableSet()),
domainVersion(document.version()));
}
private static Long persistedVersion(long version) {
return version == -1 ? null : version;
}
private static long domainVersion(Long version) {
return version == null ? -1 : version;
}
}
@Document(collection = MongoApartmentRepository.COLLECTION)
record ApartmentDocument(
@Id String id,
@Indexed String landlordId,
String name,
String address,
@Field(targetType = FieldType.DECIMAL128) BigDecimal purchasePrice,
String description,
List<String> leaseReferences,
@Version Long version) {
ApartmentDocument withVersion(Long version) {
return new ApartmentDocument(id, landlordId, name, address, purchasePrice, description, leaseReferences,
version);
}
}
@@ -0,0 +1,123 @@
package fr.nessar.lmnporganizer.rental.adapter.out.mongodb.landlord;
import fr.nessar.lmnporganizer.rental.adapter.out.mongodb.LandlordScopedMongoWriter;
import fr.nessar.lmnporganizer.rental.domain.landlord.ExternalIdentity;
import fr.nessar.lmnporganizer.rental.domain.landlord.EmailAlreadyUsed;
import fr.nessar.lmnporganizer.rental.domain.landlord.Landlord;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordRepository;
import java.util.Optional;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.Version;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.FindAndModifyOptions;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.data.mongodb.core.query.Update;
import org.springframework.stereotype.Repository;
@Repository
public class MongoLandlordRepository implements LandlordRepository {
static final String COLLECTION = "landlords";
private final MongoTemplate mongoTemplate;
public MongoLandlordRepository(MongoTemplate mongoTemplate) {
this.mongoTemplate = mongoTemplate;
}
@Override
public Optional<Landlord> find(LandlordId id) {
return findOne(Query.query(Criteria.where("_id").is(id.value())));
}
@Override
public Landlord registerIfAbsent(Landlord proposedLandlord) {
LandlordDocument proposed = toDocument(proposedLandlord);
Query query = Query.query(Criteria.where("_id").is(proposed.id()));
Update initial = new Update()
.setOnInsert("_id", proposed.id())
.setOnInsert("oidcSubject", proposed.oidcSubject())
.setOnInsert("firstName", proposed.firstName())
.setOnInsert("lastName", proposed.lastName())
.setOnInsert("email", proposed.email())
.setOnInsert("phone", proposed.phone())
.setOnInsert("address", proposed.address())
.setOnInsert("version", 0L);
try {
LandlordDocument registered = mongoTemplate.findAndModify(query, initial,
FindAndModifyOptions.options().upsert(true).returnNew(true), LandlordDocument.class);
return toDomain(java.util.Objects.requireNonNull(registered));
} catch (DuplicateKeyException exception) {
throw new EmailAlreadyUsed();
}
}
@Override
public Landlord save(Landlord landlord) {
try {
LandlordDocument document = toDocument(landlord);
return toDomain(LandlordScopedMongoWriter.saveLandlord(mongoTemplate, document, document.id(),
landlord.version(), document::withVersion));
} catch (DuplicateKeyException exception) {
throw new EmailAlreadyUsed();
}
}
private Optional<Landlord> findOne(Query query) {
return Optional.ofNullable(mongoTemplate.findOne(query, LandlordDocument.class))
.map(MongoLandlordRepository::toDomain);
}
static LandlordDocument toDocument(Landlord landlord) {
return new LandlordDocument(
landlord.id().value(),
landlord.externalIdentity().subject(),
landlord.firstName(),
landlord.lastName(),
landlord.email(),
landlord.phone(),
landlord.address(),
persistedVersion(landlord.version()));
}
static Landlord toDomain(LandlordDocument document) {
return Landlord.reconstitute(
new LandlordId(document.id()),
new ExternalIdentity(document.oidcSubject()),
document.firstName(),
document.lastName(),
document.email(),
document.phone(),
document.address(),
domainVersion(document.version()));
}
private static Long persistedVersion(long version) {
return version == -1 ? null : version;
}
private static long domainVersion(Long version) {
return version == null ? -1 : version;
}
}
@Document(collection = MongoLandlordRepository.COLLECTION)
record LandlordDocument(
@Id String id,
@Indexed(unique = true) String oidcSubject,
String firstName,
String lastName,
@Indexed(unique = true, partialFilter = "{'email': {'$gt': ''}}") String email,
String phone,
String address,
@Version Long version) {
LandlordDocument withVersion(Long version) {
return new LandlordDocument(id, oidcSubject, firstName, lastName, email, phone, address, version);
}
}
@@ -0,0 +1,78 @@
package fr.nessar.lmnporganizer.rental.adapter.out.mongodb.lease;
import fr.nessar.lmnporganizer.rental.adapter.out.mongodb.LandlordScopedMongoWriter;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.ApartmentOccupancy;
import fr.nessar.lmnporganizer.rental.domain.lease.ApartmentOccupancyRepository;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.lease.RentalPeriod;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.Version;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.index.CompoundIndex;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.stereotype.Repository;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
@Repository
public class MongoApartmentOccupancyRepository implements ApartmentOccupancyRepository {
private final MongoTemplate mongoTemplate;
public MongoApartmentOccupancyRepository(MongoTemplate mongoTemplate) {
this.mongoTemplate = mongoTemplate;
}
@Override
public Optional<ApartmentOccupancy> find(LandlordId landlordId, ApartmentId apartmentId) {
Query query = Query.query(Criteria.where("landlordId").is(landlordId.value())
.and("apartmentId").is(apartmentId.value()));
return Optional.ofNullable(mongoTemplate.findOne(query, OccupancyDocument.class)).map(this::toDomain);
}
@Override
public ApartmentOccupancy save(ApartmentOccupancy occupancy) {
OccupancyDocument document = toDocument(occupancy);
return toDomain(LandlordScopedMongoWriter.save(mongoTemplate, document, document.id(), document.landlordId(),
occupancy.version(), document::withVersion));
}
private OccupancyDocument toDocument(ApartmentOccupancy occupancy) {
return new OccupancyDocument(documentId(occupancy.landlordId(), occupancy.apartmentId()),
occupancy.landlordId().value(), occupancy.apartmentId().value(),
occupancy.reservations().stream().map(reservation -> new ReservationDocument(
reservation.leaseId().value(), reservation.period().startDate(),
reservation.period().endDate())).toList(),
occupancy.version() < 0 ? null : occupancy.version());
}
private ApartmentOccupancy toDomain(OccupancyDocument document) {
return ApartmentOccupancy.reconstitute(new LandlordId(document.landlordId()),
new ApartmentId(document.apartmentId()),
document.reservations().stream().map(reservation -> new ApartmentOccupancy.Reservation(
new LeaseId(reservation.leaseId()),
new RentalPeriod(reservation.startDate(), reservation.endDate()))).toList(),
document.version() == null ? -1 : document.version());
}
private static String documentId(LandlordId landlordId, ApartmentId apartmentId) {
return landlordId.value() + ":" + apartmentId.value();
}
@Document(collection = "apartmentOccupancies")
@CompoundIndex(name = "occupancy_landlord_apartment", def = "{'landlordId': 1, 'apartmentId': 1}", unique = true)
record OccupancyDocument(@Id String id, String landlordId, String apartmentId,
List<ReservationDocument> reservations, @Version Long version) {
OccupancyDocument withVersion(Long version) {
return new OccupancyDocument(id, landlordId, apartmentId, reservations, version);
}
}
record ReservationDocument(String leaseId, LocalDate startDate, LocalDate endDate) {
}
}
@@ -0,0 +1,236 @@
package fr.nessar.lmnporganizer.rental.adapter.out.mongodb.lease;
import fr.nessar.lmnporganizer.rental.adapter.out.mongodb.LandlordScopedMongoWriter;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.Guarantor;
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseAmendment;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseAmendmentId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseContractVersion;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSnapshot;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseStatus;
import fr.nessar.lmnporganizer.rental.domain.lease.RentTerms;
import fr.nessar.lmnporganizer.rental.domain.lease.RentalPeriod;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.Version;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.mapping.Field;
import org.springframework.data.mongodb.core.mapping.FieldType;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.stereotype.Repository;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
@Repository
public class MongoLeaseRepository implements LeaseRepository {
private final MongoTemplate mongoTemplate;
public MongoLeaseRepository(MongoTemplate mongoTemplate) {
this.mongoTemplate = mongoTemplate;
}
@Override
public List<Lease> findAll(LandlordId landlordId) {
return mongoTemplate.find(Query.query(Criteria.where("landlordId").is(landlordId.value())),
LeaseDocument.class)
.stream().map(MongoLeaseRepository::toDomain).toList();
}
@Override
public Optional<Lease> find(LandlordId landlordId, LeaseId leaseId) {
return Optional.ofNullable(mongoTemplate.findOne(scoped(landlordId, leaseId), LeaseDocument.class))
.map(MongoLeaseRepository::toDomain);
}
@Override
public Lease save(Lease lease) {
LeaseDocument document = toDocument(lease);
return toDomain(LandlordScopedMongoWriter.save(mongoTemplate, document, document.id(), document.landlordId(),
lease.version(), document::withVersion));
}
@Override
public boolean deleteDraft(Lease lease) {
Query query = scoped(lease.landlordId(), lease.id());
query.addCriteria(Criteria.where("status").is(LeaseStatus.DRAFT.name()));
query.addCriteria(Criteria.where("version").is(lease.version()));
return mongoTemplate.remove(query, LeaseDocument.class).getDeletedCount() == 1;
}
private static Query scoped(LandlordId landlordId, LeaseId leaseId) {
return Query.query(Criteria.where("_id").is(leaseId.value()).and("landlordId").is(landlordId.value()));
}
static LeaseDocument toDocument(Lease lease) {
return new LeaseDocument(lease.id().value(), lease.landlordId().value(), lease.apartmentId().value(),
lease.tenantIds().stream().map(TenantId::value).toList(),
lease.guarantors().stream().map(MongoLeaseRepository::toDocument).toList(),
lease.period().startDate(), lease.period().endDate(), lease.terms().monthlyRent(),
lease.terms().monthlyCharges(), lease.terms().securityDeposit(), lease.status().name(),
lease.snapshot() == null ? null : toDocument(lease.snapshot()),
lease.amendments().stream().map(MongoLeaseRepository::toDocument).toList(),
persistedVersion(lease.version()));
}
static Lease toDomain(LeaseDocument document) {
return Lease.reconstitute(new LeaseId(document.id()), new LandlordId(document.landlordId()),
new ApartmentId(document.apartmentId()),
document.tenantIds().stream().map(TenantId::new).toList(),
document.guarantors().stream().map(MongoLeaseRepository::toDomain).toList(),
new RentalPeriod(document.startDate(), document.endDate()),
new RentTerms(document.monthlyRent(), document.monthlyCharges(), document.securityDeposit()),
LeaseStatus.valueOf(document.status()),
document.snapshot() == null ? null : toDomain(document.snapshot()),
document.amendments() == null ? List.of()
: document.amendments().stream().map(MongoLeaseRepository::toDomain).toList(),
domainVersion(document.version()));
}
private static GuarantorDocument toDocument(Guarantor guarantor) {
return new GuarantorDocument(guarantor.id(), guarantor.firstName(), guarantor.lastName(), guarantor.email(),
guarantor.phone(), guarantor.address());
}
private static Guarantor toDomain(GuarantorDocument document) {
return new Guarantor(document.id(), document.firstName(), document.lastName(), document.email(),
document.phone(), document.address());
}
private static LeaseSnapshotDocument toDocument(LeaseSnapshot snapshot) {
return new LeaseSnapshotDocument(
new LandlordSnapshotDocument(snapshot.landlord().id().value(), snapshot.landlord().firstName(),
snapshot.landlord().lastName(), snapshot.landlord().email(), snapshot.landlord().phone(),
snapshot.landlord().address()),
new ApartmentSnapshotDocument(snapshot.apartment().id().value(), snapshot.apartment().name(),
snapshot.apartment().address()),
snapshot.tenants().stream().map(tenant -> new TenantSnapshotDocument(tenant.id().value(),
tenant.firstName(), tenant.lastName(), tenant.email(), tenant.phone(), tenant.address()))
.toList(),
snapshot.guarantors().stream().map(MongoLeaseRepository::toDocument).toList(),
snapshot.period().startDate(), snapshot.period().endDate(), snapshot.terms().monthlyRent(),
snapshot.terms().monthlyCharges(), snapshot.terms().securityDeposit());
}
private static LeaseSnapshot toDomain(LeaseSnapshotDocument document) {
return new LeaseSnapshot(
new LeaseSnapshot.Landlord(new LandlordId(document.landlord().id()),
document.landlord().firstName(), document.landlord().lastName(), document.landlord().email(),
document.landlord().phone(), document.landlord().address()),
new LeaseSnapshot.Apartment(new ApartmentId(document.apartment().id()),
document.apartment().name(), document.apartment().address()),
document.tenants().stream().map(tenant -> new LeaseSnapshot.Tenant(new TenantId(tenant.id()),
tenant.firstName(), tenant.lastName(), tenant.email(), tenant.phone(), tenant.address()))
.toList(),
document.guarantors().stream().map(MongoLeaseRepository::toDomain).toList(),
new RentalPeriod(document.startDate(), document.endDate()),
new RentTerms(document.monthlyRent(), document.monthlyCharges(), document.securityDeposit()));
}
private static LeaseAmendmentDocument toDocument(LeaseAmendment amendment) {
return new LeaseAmendmentDocument(amendment.id().value(), amendment.effectiveDate(),
toDocument(amendment.beforeVersion()), toDocument(amendment.afterVersion()));
}
private static LeaseAmendment toDomain(LeaseAmendmentDocument document) {
return new LeaseAmendment(new LeaseAmendmentId(document.id()), document.effectiveDate(),
toDomain(document.beforeVersion()), toDomain(document.afterVersion()));
}
private static LeaseContractVersionDocument toDocument(LeaseContractVersion version) {
return new LeaseContractVersionDocument(
new LandlordSnapshotDocument(version.landlord().id().value(), version.landlord().firstName(),
version.landlord().lastName(), version.landlord().email(), version.landlord().phone(),
version.landlord().address()),
new ApartmentSnapshotDocument(version.apartment().id().value(), version.apartment().name(),
version.apartment().address()),
version.tenants().stream().map(tenant -> new TenantSnapshotDocument(tenant.id().value(),
tenant.firstName(), tenant.lastName(), tenant.email(), tenant.phone(), tenant.address()))
.toList(),
version.guarantors().stream().map(MongoLeaseRepository::toDocument).toList(),
version.period().startDate(), version.period().endDate(), version.terms().monthlyRent(),
version.terms().monthlyCharges(), version.terms().securityDeposit());
}
private static LeaseContractVersion toDomain(LeaseContractVersionDocument document) {
return new LeaseContractVersion(
new LeaseSnapshot.Landlord(new LandlordId(document.landlord().id()),
document.landlord().firstName(), document.landlord().lastName(), document.landlord().email(),
document.landlord().phone(), document.landlord().address()),
new LeaseSnapshot.Apartment(new ApartmentId(document.apartment().id()),
document.apartment().name(), document.apartment().address()),
document.tenants().stream().map(tenant -> new LeaseSnapshot.Tenant(new TenantId(tenant.id()),
tenant.firstName(), tenant.lastName(), tenant.email(), tenant.phone(), tenant.address()))
.toList(),
document.guarantors().stream().map(MongoLeaseRepository::toDomain).toList(),
new RentalPeriod(document.startDate(), document.endDate()),
new RentTerms(document.monthlyRent(), document.monthlyCharges(), document.securityDeposit()));
}
private static Long persistedVersion(long version) {
return version < 0 ? null : version;
}
private static long domainVersion(Long version) {
return version == null ? -1 : version;
}
@Document(collection = "leases")
record LeaseDocument(@Id String id, @Indexed String landlordId, @Indexed String apartmentId,
@Indexed List<String> tenantIds, List<GuarantorDocument> guarantors,
LocalDate startDate, LocalDate endDate,
@Field(targetType = FieldType.DECIMAL128) BigDecimal monthlyRent,
@Field(targetType = FieldType.DECIMAL128) BigDecimal monthlyCharges,
@Field(targetType = FieldType.DECIMAL128) BigDecimal securityDeposit,
String status, LeaseSnapshotDocument snapshot, List<LeaseAmendmentDocument> amendments,
@Version Long version) {
LeaseDocument withVersion(Long version) {
return new LeaseDocument(id, landlordId, apartmentId, tenantIds, guarantors, startDate, endDate,
monthlyRent, monthlyCharges, securityDeposit, status, snapshot, amendments, version);
}
}
record GuarantorDocument(String id, String firstName, String lastName, String email, String phone,
String address) {
}
record LeaseSnapshotDocument(LandlordSnapshotDocument landlord, ApartmentSnapshotDocument apartment,
List<TenantSnapshotDocument> tenants, List<GuarantorDocument> guarantors,
LocalDate startDate, LocalDate endDate,
@Field(targetType = FieldType.DECIMAL128) BigDecimal monthlyRent,
@Field(targetType = FieldType.DECIMAL128) BigDecimal monthlyCharges,
@Field(targetType = FieldType.DECIMAL128) BigDecimal securityDeposit) {
}
record LandlordSnapshotDocument(String id, String firstName, String lastName, String email, String phone,
String address) {
}
record ApartmentSnapshotDocument(String id, String name, String address) {
}
record TenantSnapshotDocument(String id, String firstName, String lastName, String email, String phone,
String address) {
}
record LeaseAmendmentDocument(String id, LocalDate effectiveDate, LeaseContractVersionDocument beforeVersion,
LeaseContractVersionDocument afterVersion) {
}
record LeaseContractVersionDocument(LandlordSnapshotDocument landlord, ApartmentSnapshotDocument apartment,
List<TenantSnapshotDocument> tenants, List<GuarantorDocument> guarantors,
LocalDate startDate, LocalDate endDate,
@Field(targetType = FieldType.DECIMAL128) BigDecimal monthlyRent,
@Field(targetType = FieldType.DECIMAL128) BigDecimal monthlyCharges,
@Field(targetType = FieldType.DECIMAL128) BigDecimal securityDeposit) {
}
}
@@ -0,0 +1,111 @@
package fr.nessar.lmnporganizer.rental.adapter.out.mongodb.rent;
import fr.nessar.lmnporganizer.rental.adapter.out.mongodb.LandlordScopedMongoWriter;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.rent.RentDue;
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueId;
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueRepository;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.Version;
import org.springframework.data.mongodb.core.MongoTemplate;
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 org.springframework.data.mongodb.core.mapping.Field;
import org.springframework.data.mongodb.core.mapping.FieldType;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.stereotype.Repository;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.List;
import java.util.Optional;
@Repository
public class MongoRentDueRepository implements RentDueRepository {
private final MongoTemplate mongoTemplate;
public MongoRentDueRepository(MongoTemplate mongoTemplate) {
this.mongoTemplate = mongoTemplate;
}
@Override
public List<RentDue> findAll(LandlordId landlordId) {
return find(Query.query(Criteria.where("landlordId").is(landlordId.value())));
}
@Override
public List<RentDue> findAll(LandlordId landlordId, LeaseId leaseId) {
return find(Query.query(Criteria.where("landlordId").is(landlordId.value())
.and("leaseId").is(leaseId.value())));
}
@Override
public Optional<RentDue> find(LandlordId landlordId, RentDueId rentDueId) {
Query query = Query.query(Criteria.where("_id").is(rentDueId.value())
.and("landlordId").is(landlordId.value()));
return Optional.ofNullable(mongoTemplate.findOne(query, RentDueDocument.class)).map(this::toDomain);
}
@Override
public RentDue save(RentDue rentDue) {
RentDueDocument document = toDocument(rentDue);
return toDomain(LandlordScopedMongoWriter.save(mongoTemplate, document, document.id(), document.landlordId(),
rentDue.version(), document::withVersion));
}
@Override
public List<RentDue> saveAll(List<RentDue> rentDues) {
return rentDues.stream().map(this::save).toList();
}
@Override
public boolean deleteUnallocated(RentDue rentDue) {
Query query = Query.query(Criteria.where("_id").is(rentDue.id().value())
.and("landlordId").is(rentDue.landlordId().value())
.and("version").is(rentDue.version())
.and("allocatedAmount").is(BigDecimal.ZERO));
return mongoTemplate.remove(query, RentDueDocument.class).getDeletedCount() == 1;
}
private List<RentDue> find(Query query) {
return mongoTemplate.find(query, RentDueDocument.class).stream().map(this::toDomain).toList();
}
private RentDueDocument toDocument(RentDue due) {
return new RentDueDocument(due.id().value(), due.landlordId().value(), due.leaseId().value(),
due.month().toString(), due.dueDate(), due.rent(), due.charges(), due.allocatedAmount(),
persistedVersion(due.version()));
}
private RentDue toDomain(RentDueDocument document) {
return RentDue.reconstitute(new RentDueId(document.id()), new LandlordId(document.landlordId()),
new LeaseId(document.leaseId()), YearMonth.parse(document.month()), document.dueDate(),
document.rent(), document.charges(), document.allocatedAmount(), domainVersion(document.version()));
}
private static Long persistedVersion(long version) {
return version < 0 ? null : version;
}
private static long domainVersion(Long version) {
return version == null ? -1 : version;
}
@Document(collection = "rentDues")
@CompoundIndex(name = "rent_due_landlord_lease_month",
def = "{'landlordId': 1, 'leaseId': 1, 'month': 1}", unique = true)
record RentDueDocument(@Id String id, @Indexed String landlordId, @Indexed String leaseId, String month,
LocalDate dueDate, @Field(targetType = FieldType.DECIMAL128) BigDecimal rent,
@Field(targetType = FieldType.DECIMAL128) BigDecimal charges,
@Field(targetType = FieldType.DECIMAL128) BigDecimal allocatedAmount,
@Version Long version) {
RentDueDocument withVersion(Long version) {
return new RentDueDocument(id, landlordId, leaseId, month, dueDate, rent, charges, allocatedAmount,
version);
}
}
}
@@ -0,0 +1,106 @@
package fr.nessar.lmnporganizer.rental.adapter.out.mongodb.rent;
import fr.nessar.lmnporganizer.rental.adapter.out.mongodb.LandlordScopedMongoWriter;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentId;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefund;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefundId;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefundRepository;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRefundStatus;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.Version;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.mapping.Field;
import org.springframework.data.mongodb.core.mapping.FieldType;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.stereotype.Repository;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
@Repository
public class MongoRentPaymentRefundRepository implements RentPaymentRefundRepository {
private final MongoTemplate mongoTemplate;
public MongoRentPaymentRefundRepository(MongoTemplate mongoTemplate) {
this.mongoTemplate = mongoTemplate;
}
@Override
public List<RentPaymentRefund> findAll(LandlordId landlordId, RentPaymentId paymentId) {
Query query = Query.query(Criteria.where("landlordId").is(landlordId.value())
.and("paymentId").is(paymentId.value()));
return mongoTemplate.find(query, RefundDocument.class).stream().map(this::toDomain).toList();
}
@Override
public Optional<RentPaymentRefund> find(LandlordId landlordId, RentPaymentRefundId refundId) {
return Optional.ofNullable(mongoTemplate.findOne(scoped(landlordId, refundId), RefundDocument.class))
.map(this::toDomain);
}
@Override
public RentPaymentRefund save(RentPaymentRefund refund) {
RefundDocument document = toDocument(refund);
return toDomain(LandlordScopedMongoWriter.save(mongoTemplate, document, document.id(), document.landlordId(),
refund.version(), document::withVersion));
}
@Override
public boolean deleteDraft(RentPaymentRefund refund) {
Query query = scoped(refund.landlordId(), refund.id());
query.addCriteria(Criteria.where("status").is(RentPaymentRefundStatus.DRAFT.name()));
query.addCriteria(versionCriteria(refund.version()));
return mongoTemplate.remove(query, RefundDocument.class).getDeletedCount() == 1;
}
private static Query scoped(LandlordId landlordId, RentPaymentRefundId refundId) {
return Query.query(Criteria.where("_id").is(refundId.value())
.and("landlordId").is(landlordId.value()));
}
private static Criteria versionCriteria(long version) {
return version < 0
? new Criteria().orOperator(Criteria.where("version").exists(false), Criteria.where("version").is(null))
: Criteria.where("version").is(version);
}
private RefundDocument toDocument(RentPaymentRefund refund) {
return new RefundDocument(refund.id().value(), refund.landlordId().value(), refund.leaseId().value(),
refund.paymentId().value(), refund.amount(), refund.refundedAt(), refund.status().name(),
refund.confirmedAt(), persistedVersion(refund.version()));
}
private RentPaymentRefund toDomain(RefundDocument document) {
return RentPaymentRefund.reconstitute(new RentPaymentRefundId(document.id()),
new LandlordId(document.landlordId()), new LeaseId(document.leaseId()),
new RentPaymentId(document.paymentId()), document.amount(), document.refundedAt(),
RentPaymentRefundStatus.valueOf(document.status()), document.confirmedAt(),
domainVersion(document.version()));
}
private static Long persistedVersion(long version) {
return version < 0 ? null : version;
}
private static long domainVersion(Long version) {
return version == null ? -1 : version;
}
@Document(collection = "rentPaymentRefunds")
record RefundDocument(@Id String id, @Indexed String landlordId, @Indexed String leaseId,
@Indexed String paymentId, @Field(targetType = FieldType.DECIMAL128) BigDecimal amount,
LocalDate refundedAt, String status,
LocalDate confirmedAt, @Version Long version) {
RefundDocument withVersion(Long version) {
return new RefundDocument(id, landlordId, leaseId, paymentId, amount, refundedAt, status, confirmedAt,
version);
}
}
}
@@ -0,0 +1,133 @@
package fr.nessar.lmnporganizer.rental.adapter.out.mongodb.rent;
import fr.nessar.lmnporganizer.rental.adapter.out.mongodb.LandlordScopedMongoWriter;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.rent.PaymentAllocation;
import fr.nessar.lmnporganizer.rental.domain.rent.PaymentAllocationId;
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueId;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPayment;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentId;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRepository;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentStatus;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.Version;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.mapping.Field;
import org.springframework.data.mongodb.core.mapping.FieldType;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.stereotype.Repository;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
@Repository
public class MongoRentPaymentRepository implements RentPaymentRepository {
private final MongoTemplate mongoTemplate;
public MongoRentPaymentRepository(MongoTemplate mongoTemplate) {
this.mongoTemplate = mongoTemplate;
}
@Override
public List<RentPayment> findAll(LandlordId landlordId) {
return find(Query.query(Criteria.where("landlordId").is(landlordId.value())));
}
@Override
public List<RentPayment> findAll(LandlordId landlordId, LeaseId leaseId) {
return find(Query.query(Criteria.where("landlordId").is(landlordId.value())
.and("leaseId").is(leaseId.value())));
}
@Override
public Optional<RentPayment> find(LandlordId landlordId, RentPaymentId paymentId) {
return Optional.ofNullable(mongoTemplate.findOne(scoped(landlordId, paymentId), RentPaymentDocument.class))
.map(this::toDomain);
}
@Override
public RentPayment save(RentPayment payment) {
RentPaymentDocument document = toDocument(payment);
return toDomain(LandlordScopedMongoWriter.save(mongoTemplate, document, document.id(), document.landlordId(),
payment.version(), document::withVersion));
}
@Override
public boolean deleteDraft(RentPayment payment) {
Query query = scoped(payment.landlordId(), payment.id());
query.addCriteria(Criteria.where("status").is(RentPaymentStatus.DRAFT.name()));
query.addCriteria(versionCriteria(payment.version()));
return mongoTemplate.remove(query, RentPaymentDocument.class).getDeletedCount() == 1;
}
private List<RentPayment> find(Query query) {
return mongoTemplate.find(query, RentPaymentDocument.class).stream().map(this::toDomain).toList();
}
private static Query scoped(LandlordId landlordId, RentPaymentId paymentId) {
return Query.query(Criteria.where("_id").is(paymentId.value())
.and("landlordId").is(landlordId.value()));
}
private static Criteria versionCriteria(long version) {
return version < 0
? new Criteria().orOperator(Criteria.where("version").exists(false), Criteria.where("version").is(null))
: Criteria.where("version").is(version);
}
private RentPaymentDocument toDocument(RentPayment payment) {
return new RentPaymentDocument(payment.id().value(), payment.landlordId().value(), payment.leaseId().value(),
payment.receivedAt(), payment.amount(), payment.status().name(),
payment.allocations().stream().map(this::toDocument).toList(), payment.refundedAmount(),
payment.confirmedAt(), persistedVersion(payment.version()));
}
private RentPayment toDomain(RentPaymentDocument document) {
return RentPayment.reconstitute(new RentPaymentId(document.id()), new LandlordId(document.landlordId()),
new LeaseId(document.leaseId()), document.receivedAt(), document.amount(),
RentPaymentStatus.valueOf(document.status()),
document.allocations().stream().map(this::toDomain).toList(), document.refundedAmount(),
document.confirmedAt(), domainVersion(document.version()));
}
private PaymentAllocationDocument toDocument(PaymentAllocation allocation) {
return new PaymentAllocationDocument(allocation.id().value(), allocation.rentDueId().value(),
allocation.amount(), allocation.allocatedAt());
}
private PaymentAllocation toDomain(PaymentAllocationDocument document) {
return new PaymentAllocation(new PaymentAllocationId(document.id()), new RentDueId(document.rentDueId()),
document.amount(), document.allocatedAt());
}
private static Long persistedVersion(long version) {
return version < 0 ? null : version;
}
private static long domainVersion(Long version) {
return version == null ? -1 : version;
}
@Document(collection = "rentPayments")
record RentPaymentDocument(@Id String id, @Indexed String landlordId, @Indexed String leaseId,
LocalDate receivedAt, @Field(targetType = FieldType.DECIMAL128) BigDecimal amount,
String status, List<PaymentAllocationDocument> allocations,
@Field(targetType = FieldType.DECIMAL128) BigDecimal refundedAmount,
LocalDate confirmedAt, @Version Long version) {
RentPaymentDocument withVersion(Long version) {
return new RentPaymentDocument(id, landlordId, leaseId, receivedAt, amount, status, allocations,
refundedAmount, confirmedAt, version);
}
}
record PaymentAllocationDocument(String id, String rentDueId,
@Field(targetType = FieldType.DECIMAL128) BigDecimal amount,
LocalDate allocatedAt) {
}
}
@@ -0,0 +1,82 @@
package fr.nessar.lmnporganizer.rental.adapter.out.mongodb.rent;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.rent.PaymentAllocation;
import fr.nessar.lmnporganizer.rental.domain.rent.PaymentAllocationId;
import fr.nessar.lmnporganizer.rental.domain.rent.RentDueId;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentId;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversal;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversalId;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversalRepository;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.MongoTemplate;
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 org.springframework.data.mongodb.core.mapping.Field;
import org.springframework.data.mongodb.core.mapping.FieldType;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.stereotype.Repository;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
@Repository
public class MongoRentPaymentReversalRepository implements RentPaymentReversalRepository {
private final MongoTemplate mongoTemplate;
public MongoRentPaymentReversalRepository(MongoTemplate mongoTemplate) {
this.mongoTemplate = mongoTemplate;
}
@Override
public List<RentPaymentReversal> findAll(LandlordId landlordId) {
return mongoTemplate.find(Query.query(Criteria.where("landlordId").is(landlordId.value())),
ReversalDocument.class)
.stream().map(this::toDomain).toList();
}
@Override
public Optional<RentPaymentReversal> findByPayment(LandlordId landlordId, RentPaymentId paymentId) {
Query query = Query.query(Criteria.where("landlordId").is(landlordId.value())
.and("paymentId").is(paymentId.value()));
return Optional.ofNullable(mongoTemplate.findOne(query, ReversalDocument.class)).map(this::toDomain);
}
@Override
public RentPaymentReversal save(RentPaymentReversal reversal) {
return toDomain(mongoTemplate.insert(toDocument(reversal)));
}
private ReversalDocument toDocument(RentPaymentReversal reversal) {
return new ReversalDocument(reversal.id().value(), reversal.landlordId().value(),
reversal.leaseId().value(), reversal.paymentId().value(), reversal.reversedAt(), reversal.reason(),
reversal.reversedAllocations().stream().map(allocation -> new AllocationDocument(
allocation.id().value(), allocation.rentDueId().value(), allocation.amount(),
allocation.allocatedAt())).toList());
}
private RentPaymentReversal toDomain(ReversalDocument document) {
return new RentPaymentReversal(new RentPaymentReversalId(document.id()),
new LandlordId(document.landlordId()), new LeaseId(document.leaseId()),
new RentPaymentId(document.paymentId()), document.reversedAt(), document.reason(),
document.allocations().stream().map(allocation -> new PaymentAllocation(
new PaymentAllocationId(allocation.id()), new RentDueId(allocation.rentDueId()),
allocation.amount(), allocation.allocatedAt())).toList());
}
@Document(collection = "rentPaymentReversals")
@CompoundIndex(name = "reversal_landlord_payment", def = "{'landlordId': 1, 'paymentId': 1}", unique = true)
record ReversalDocument(@Id String id, @Indexed String landlordId, @Indexed String leaseId, String paymentId,
LocalDate reversedAt, String reason, List<AllocationDocument> allocations) {
}
record AllocationDocument(String id, String rentDueId,
@Field(targetType = FieldType.DECIMAL128) BigDecimal amount,
LocalDate allocatedAt) {
}
}
Loaded 100 of 263 files, more files were not shown because too many files have changed in this diff. Show more