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:
1 parent
6ce0719a20
commit
e02ad54bcb
263 files changed
+10847
-7183
No files matched your search
@@ -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();
|
||||
}
|
||||
}
|
||||
-59
@@ -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();
|
||||
}
|
||||
}
|
||||
-81
@@ -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) {
|
||||
}
|
||||
}
|
||||
-34
@@ -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();
|
||||
}
|
||||
}
|
||||
-29
@@ -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());
|
||||
}
|
||||
}
|
||||
-41
@@ -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());
|
||||
}
|
||||
}
|
||||
-107
@@ -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
|
||||
) {
|
||||
}
|
||||
-4
@@ -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) {
|
||||
}
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
package fr.nessar.lmnporganizer.backend.exception;
|
||||
|
||||
public class AttachmentTooLargeException extends RuntimeException {
|
||||
public AttachmentTooLargeException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
-7
@@ -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.");
|
||||
}
|
||||
}
|
||||
-7
@@ -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.");
|
||||
}
|
||||
}
|
||||
-7
@@ -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.");
|
||||
}
|
||||
}
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
package fr.nessar.lmnporganizer.backend.exception;
|
||||
|
||||
public class LeaseConflictException extends RuntimeException {
|
||||
public LeaseConflictException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
package fr.nessar.lmnporganizer.backend.exception;
|
||||
|
||||
public class LeaseValidationException extends RuntimeException {
|
||||
public LeaseValidationException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
-17
@@ -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; }
|
||||
}
|
||||
-16
@@ -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);
|
||||
}
|
||||
-14
@@ -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);
|
||||
}
|
||||
-14
@@ -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);
|
||||
}
|
||||
-18
@@ -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);
|
||||
}
|
||||
-19
@@ -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());
|
||||
}
|
||||
}
|
||||
-22
@@ -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) + "!";
|
||||
}
|
||||
}
|
||||
-95
@@ -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.");
|
||||
}
|
||||
}
|
||||
-25
@@ -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);
|
||||
}
|
||||
}
|
||||
-157
@@ -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;
|
||||
}
|
||||
}
|
||||
-55
@@ -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);
|
||||
}
|
||||
}
|
||||
-17
@@ -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;
|
||||
}
|
||||
}
|
||||
-196
@@ -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);
|
||||
}
|
||||
}
|
||||
+29
@@ -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();
|
||||
}
|
||||
}
|
||||
+35
@@ -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) {
|
||||
}
|
||||
}
|
||||
+179
@@ -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);
|
||||
}
|
||||
}
|
||||
+8
@@ -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) {
|
||||
}
|
||||
+9
@@ -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) {
|
||||
}
|
||||
+26
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+68
@@ -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());
|
||||
}
|
||||
}
|
||||
+34
@@ -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;
|
||||
}
|
||||
}
|
||||
+64
@@ -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) {
|
||||
}
|
||||
}
|
||||
+22
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
@@ -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));
|
||||
}
|
||||
}
|
||||
+117
@@ -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) {
|
||||
}
|
||||
}
|
||||
+155
@@ -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());
|
||||
}
|
||||
}
|
||||
+21
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+65
@@ -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());
|
||||
}
|
||||
}
|
||||
+78
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+193
@@ -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());
|
||||
}
|
||||
}
|
||||
+33
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+128
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+297
@@ -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 {
|
||||
}
|
||||
}
|
||||
+14
@@ -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();
|
||||
}
|
||||
}
|
||||
+48
@@ -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;
|
||||
}
|
||||
}
|
||||
+26
@@ -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));
|
||||
};
|
||||
}
|
||||
}
|
||||
+120
@@ -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);
|
||||
}
|
||||
}
|
||||
+123
@@ -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);
|
||||
}
|
||||
}
|
||||
+78
@@ -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) {
|
||||
}
|
||||
}
|
||||
+236
@@ -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) {
|
||||
}
|
||||
}
|
||||
+111
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+106
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+133
@@ -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) {
|
||||
}
|
||||
}
|
||||
+82
@@ -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
Reference in new issue
Block a user