Initial commit: proyecto ContabilidadSaPolar completo

This commit is contained in:
root
2026-08-19 21:30:23 +00:00
commit 0d5c6f9512
232 changed files with 26730 additions and 0 deletions
+205
View File
@@ -0,0 +1,205 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.sapolar</groupId>
<artifactId>contabilidad-sa-polar</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>sa-polar-backend</artifactId>
<packaging>jar</packaging>
<properties>
<spring-boot.version>3.4.1</spring-boot.version>
<jjwt.version>0.12.6</jjwt.version>
<springdoc.version>2.7.0</springdoc.version>
<mapstruct.version>1.6.3</mapstruct.version>
<lombok.version>1.18.36</lombok.version>
<itext.version>8.0.5</itext.version>
<poi.version>5.3.0</poi.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- Spring Boot Starters -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<!-- Database -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<!-- OpenAPI / Swagger -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
<!-- MapStruct -->
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${mapstruct.version}</version>
</dependency>
<!-- Jackson Hibernate Module -->
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-hibernate6</artifactId>
</dependency>
<!-- Reports: PDF -->
<dependency>
<groupId>com.itextpdf</groupId>
<artifactId>kernel</artifactId>
<version>${itext.version}</version>
</dependency>
<dependency>
<groupId>com.itextpdf</groupId>
<artifactId>layout</artifactId>
<version>${itext.version}</version>
</dependency>
<!-- Reports: Excel -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>${poi.version}</version>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring-boot.version}</version>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<source>21</source>
<target>21</target>
<parameters>true</parameters>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,14 @@
package com.sapolar;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
public class SaPolarApplication {
public static void main(String[] args) {
SpringApplication.run(SaPolarApplication.class, args);
}
}
@@ -0,0 +1,39 @@
package com.sapolar.auth;
import com.sapolar.auth.dto.LoginRequest;
import com.sapolar.auth.dto.RegisterRequest;
import com.sapolar.auth.dto.TokenResponse;
import com.sapolar.common.dto.ApiResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
@PostMapping("/login")
public ResponseEntity<ApiResponse<TokenResponse>> login(@Valid @RequestBody LoginRequest request) {
TokenResponse response = authService.login(request);
return ResponseEntity.ok(ApiResponse.success("Inicio de sesión exitoso", response));
}
@PostMapping("/register")
public ResponseEntity<ApiResponse<TokenResponse>> register(@Valid @RequestBody RegisterRequest request) {
TokenResponse response = authService.register(request);
return ResponseEntity.ok(ApiResponse.success("Usuario registrado exitosamente", response));
}
@PostMapping("/refresh")
public ResponseEntity<ApiResponse<TokenResponse>> refresh(@RequestBody Map<String, String> request) {
String refreshToken = request.get("refreshToken");
TokenResponse response = authService.refresh(refreshToken);
return ResponseEntity.ok(ApiResponse.success("Token refrescado exitosamente", response));
}
}
@@ -0,0 +1,95 @@
package com.sapolar.auth;
import com.sapolar.auth.dto.LoginRequest;
import com.sapolar.auth.dto.RegisterRequest;
import com.sapolar.auth.dto.TokenResponse;
import com.sapolar.common.exception.BadRequestException;
import com.sapolar.common.exception.DuplicateResourceException;
import com.sapolar.common.exception.ResourceNotFoundException;
import com.sapolar.user.*;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class AuthService {
private final UserRepository userRepository;
private final RoleRepository roleRepository;
private final PasswordEncoder passwordEncoder;
private final JwtTokenProvider jwtTokenProvider;
private final AuthenticationManager authenticationManager;
@Transactional(readOnly = true)
public TokenResponse login(LoginRequest request) {
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword())
);
User user = userRepository.findByUsername(request.getUsername())
.orElseThrow(() -> new ResourceNotFoundException("Usuario", 0L));
String accessToken = jwtTokenProvider.generateAccessToken(
user.getId(), user.getUsername(), user.getRole().getName());
String refreshToken = jwtTokenProvider.generateRefreshToken(
user.getId(), user.getUsername());
return new TokenResponse(accessToken, refreshToken, user.getId(),
user.getUsername(), user.getRole().getName());
}
@Transactional
public TokenResponse register(RegisterRequest request) {
if (userRepository.existsByUsername(request.getUsername())) {
throw new DuplicateResourceException("El usuario ya existe: " + request.getUsername());
}
if (userRepository.existsByEmail(request.getEmail())) {
throw new DuplicateResourceException("El email ya está registrado: " + request.getEmail());
}
String roleName = request.getRoleName() != null ? request.getRoleName() : "VISUALIZADOR";
Role role = roleRepository.findByName(roleName)
.orElseThrow(() -> new BadRequestException("Rol no válido: " + roleName));
User user = new User();
user.setUsername(request.getUsername());
user.setEmail(request.getEmail());
user.setPasswordHash(passwordEncoder.encode(request.getPassword()));
user.setFullName(request.getFullName());
user.setPhone(request.getPhone());
user.setRole(role);
user.setActive(true);
user = userRepository.save(user);
String accessToken = jwtTokenProvider.generateAccessToken(
user.getId(), user.getUsername(), user.getRole().getName());
String refreshToken = jwtTokenProvider.generateRefreshToken(
user.getId(), user.getUsername());
return new TokenResponse(accessToken, refreshToken, user.getId(),
user.getUsername(), user.getRole().getName());
}
@Transactional(readOnly = true)
public TokenResponse refresh(String refreshToken) {
if (!jwtTokenProvider.validateToken(refreshToken)) {
throw new BadRequestException("Token de refresco inválido o expirado");
}
Long userId = jwtTokenProvider.getUserIdFromToken(refreshToken);
User user = userRepository.findById(userId)
.orElseThrow(() -> new ResourceNotFoundException("Usuario", userId));
String accessToken = jwtTokenProvider.generateAccessToken(
user.getId(), user.getUsername(), user.getRole().getName());
String newRefreshToken = jwtTokenProvider.generateRefreshToken(
user.getId(), user.getUsername());
return new TokenResponse(accessToken, newRefreshToken, user.getId(),
user.getUsername(), user.getRole().getName());
}
}
@@ -0,0 +1,33 @@
package com.sapolar.auth;
import com.sapolar.user.User;
import com.sapolar.user.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
@RequiredArgsConstructor
public class AuthUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("Usuario no encontrado: " + username));
return new org.springframework.security.core.userdetails.User(
user.getUsername(),
user.getPasswordHash(),
user.getActive(),
true, true, true,
List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().getName()))
);
}
}
@@ -0,0 +1,60 @@
package com.sapolar.auth;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
@Override
protected void doFilterInternal(@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain) throws ServletException, IOException {
String token = extractToken(request);
if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) {
String username = jwtTokenProvider.getUsernameFromToken(token);
Long userId = jwtTokenProvider.getUserIdFromToken(token);
String role = jwtTokenProvider.getRoleFromToken(token);
List<SimpleGrantedAuthority> authorities = List.of(
new SimpleGrantedAuthority("ROLE_" + role)
);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
new SecurityUser(userId, username, role),
null,
authorities
);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
private String extractToken(HttpServletRequest request) {
String bearer = request.getHeader("Authorization");
if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) {
return bearer.substring(7);
}
return null;
}
}
@@ -0,0 +1,80 @@
package com.sapolar.auth;
import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.util.Date;
@Component
public class JwtTokenProvider {
private final SecretKey secretKey;
private final long expirationMs;
private final long refreshExpirationMs;
public JwtTokenProvider(
@Value("${app.jwt.secret}") String secret,
@Value("${app.jwt.expiration-ms}") long expirationMs,
@Value("${app.jwt.refresh-expiration-ms}") long refreshExpirationMs) {
this.secretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret));
this.expirationMs = expirationMs;
this.refreshExpirationMs = refreshExpirationMs;
}
public String generateAccessToken(Long userId, String username, String role) {
Date now = new Date();
return Jwts.builder()
.subject(username)
.claim("userId", userId)
.claim("role", role)
.issuedAt(now)
.expiration(new Date(now.getTime() + expirationMs))
.signWith(secretKey)
.compact();
}
public String generateRefreshToken(Long userId, String username) {
Date now = new Date();
return Jwts.builder()
.subject(username)
.claim("userId", userId)
.claim("type", "refresh")
.issuedAt(now)
.expiration(new Date(now.getTime() + refreshExpirationMs))
.signWith(secretKey)
.compact();
}
public String getUsernameFromToken(String token) {
return parseClaims(token).getSubject();
}
public Long getUserIdFromToken(String token) {
return parseClaims(token).get("userId", Long.class);
}
public String getRoleFromToken(String token) {
return parseClaims(token).get("role", String.class);
}
public boolean validateToken(String token) {
try {
parseClaims(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
private Claims parseClaims(String token) {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload();
}
}
@@ -0,0 +1,4 @@
package com.sapolar.auth;
public record SecurityUser(Long userId, String username, String role) {
}
@@ -0,0 +1,15 @@
package com.sapolar.auth.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class LoginRequest {
@NotBlank(message = "El usuario es obligatorio")
private String username;
@NotBlank(message = "La contraseña es obligatoria")
private String password;
}
@@ -0,0 +1,29 @@
package com.sapolar.auth.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class RegisterRequest {
@NotBlank(message = "El usuario es obligatorio")
@Size(min = 3, max = 50, message = "El usuario debe tener entre 3 y 50 caracteres")
private String username;
@NotBlank(message = "El email es obligatorio")
@Email(message = "Email no válido")
private String email;
@NotBlank(message = "La contraseña es obligatoria")
@Size(min = 6, message = "La contraseña debe tener al menos 6 caracteres")
private String password;
@NotBlank(message = "El nombre completo es obligatorio")
private String fullName;
private String phone;
private String roleName;
}
@@ -0,0 +1,26 @@
package com.sapolar.auth.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@AllArgsConstructor
public class TokenResponse {
private String accessToken;
private String refreshToken;
private String tokenType = "Bearer";
private Long userId;
private String username;
private String role;
public TokenResponse(String accessToken, String refreshToken, Long userId, String username, String role) {
this.accessToken = accessToken;
this.refreshToken = refreshToken;
this.tokenType = "Bearer";
this.userId = userId;
this.username = username;
this.role = role;
}
}
@@ -0,0 +1,35 @@
package com.sapolar.common.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class ApiResponse<T> {
private boolean success;
private String message;
private T data;
private LocalDateTime timestamp = LocalDateTime.now();
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(true, "OK", data, LocalDateTime.now());
}
public static <T> ApiResponse<T> success(String message, T data) {
return new ApiResponse<>(true, message, data, LocalDateTime.now());
}
public static ApiResponse<Void> success(String message) {
return new ApiResponse<>(true, message, null, LocalDateTime.now());
}
public static <T> ApiResponse<T> error(String message) {
return new ApiResponse<>(false, message, null, LocalDateTime.now());
}
}
@@ -0,0 +1,31 @@
package com.sapolar.common.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;
import org.springframework.data.domain.Page;
import java.util.List;
@Getter
@Setter
@AllArgsConstructor
public class PagedResponse<T> {
private List<T> content;
private int page;
private int size;
private long totalElements;
private int totalPages;
private boolean last;
public static <T> PagedResponse<T> from(Page<T> page) {
return new PagedResponse<>(
page.getContent(),
page.getNumber(),
page.getSize(),
page.getTotalElements(),
page.getTotalPages(),
page.isLast()
);
}
}
@@ -0,0 +1,7 @@
package com.sapolar.common.exception;
public class BadRequestException extends RuntimeException {
public BadRequestException(String message) {
super(message);
}
}
@@ -0,0 +1,7 @@
package com.sapolar.common.exception;
public class DuplicateResourceException extends RuntimeException {
public DuplicateResourceException(String message) {
super(message);
}
}
@@ -0,0 +1,59 @@
package com.sapolar.common.exception;
import com.sapolar.common.dto.ApiResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.stream.Collectors;
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ApiResponse<Void>> handleNotFound(ResourceNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error(ex.getMessage()));
}
@ExceptionHandler(BadRequestException.class)
public ResponseEntity<ApiResponse<Void>> handleBadRequest(BadRequestException ex) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error(ex.getMessage()));
}
@ExceptionHandler(DuplicateResourceException.class)
public ResponseEntity<ApiResponse<Void>> handleDuplicate(DuplicateResourceException ex) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(ApiResponse.error(ex.getMessage()));
}
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ApiResponse<Void>> handleAccessDenied(AccessDeniedException ex) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("Acceso denegado"));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleValidation(MethodArgumentNotValidException ex) {
String errors = ex.getBindingResult().getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining(", "));
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error(errors));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleGeneral(Exception ex) {
log.error("Unhandled exception", ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("Error interno del servidor: " + ex.getMessage()));
}
}
@@ -0,0 +1,11 @@
package com.sapolar.common.exception;
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String resource, Long id) {
super(resource + " no encontrado con id: " + id);
}
public ResourceNotFoundException(String message) {
super(message);
}
}
@@ -0,0 +1,34 @@
package com.sapolar.common.util;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@MappedSuperclass
public abstract class BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}
@@ -0,0 +1,33 @@
package com.sapolar.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import java.util.List;
@Configuration
public class CorsConfig {
@Value("${app.cors.allowed-origins}")
private String allowedOrigins;
@Bean
public CorsFilter corsFilter() {
List<String> origins = List.of(allowedOrigins.split(","));
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(origins);
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setExposedHeaders(List.of("Authorization", "Content-Disposition"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
}
@@ -0,0 +1,34 @@
package com.sapolar.config;
import jakarta.annotation.PostConstruct;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
@Getter
@Setter
@Configuration
@ConfigurationProperties(prefix = "app.upload")
public class FileStorageConfig {
private String path;
private long maxFileSize;
@PostConstruct
public void init() {
Path uploadPath = Paths.get(path);
if (!Files.exists(uploadPath)) {
try {
Files.createDirectories(uploadPath);
} catch (IOException e) {
throw new RuntimeException("No se pudo crear el directorio de uploads: " + path, e);
}
}
}
}
@@ -0,0 +1,69 @@
package com.sapolar.config;
import org.flywaydb.core.Flyway;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.flyway.FlywayMigrationStrategy;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.core.env.Environment;
/**
* Estrategia de migración de Flyway según el perfil de Spring.
*
* - Perfil "dev" (desarrollo): solo repair + migrate. NUNCA hace clean
* para preservar los datos de desarrollo.
*
* - Perfil "prod" (producción): SOLO hace repair + migrate.
* Si repair no es suficiente, falla y debe intervenir un administrador.
*/
@Configuration
public class FlywayRepairConfig {
private static final Logger log = LoggerFactory.getLogger(FlywayRepairConfig.class);
/**
* Estrategia para desarrollo: solo repair + migrate.
* NUNCA hace clean porque borraría datos.
* Activada cuando el perfil activo es "dev" o no hay perfil definido.
*/
@Bean
@Profile({"dev", "default"})
public FlywayMigrationStrategy devFlywayMigrationStrategy() {
return flyway -> {
try {
log.info("Flyway [dev]: ejecutando repair + migrate");
flyway.repair();
flyway.migrate();
log.info("Flyway [dev]: migraciones aplicadas correctamente");
} catch (Exception e) {
log.error("Flyway [dev]: repair + migrate falló: {}", e.getMessage());
log.error("Flyway [dev]: NO se ejecutará clean para preservar datos. Revise el problema manualmente.");
throw e;
}
};
}
/**
* Estrategia para producción: NUNCA hace clean. Solo repair + migrate.
* Si repair no es suficiente, falla y debe intervenir un administrador.
*/
@Bean
@Profile("prod")
public FlywayMigrationStrategy prodFlywayMigrationStrategy(Environment env) {
return flyway -> {
log.info("Flyway [prod]: ejecutando repair + migrate");
try {
flyway.repair();
flyway.migrate();
log.info("Flyway [prod]: migraciones aplicadas correctamente");
} catch (Exception e) {
log.error("Flyway [prod]: ERROR en migraciones. NO se ejecutará clean.", e);
log.error("Flyway [prod]: Revise manualmente el esquema y flyway_schema_history.");
log.error("Flyway [prod]: Si necesita reparar, use: mvn flyway:repair");
throw e;
}
};
}
}
@@ -0,0 +1,18 @@
package com.sapolar.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.hibernate6.Hibernate6Module;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class JacksonConfig {
@Bean
public Hibernate6Module hibernate6Module() {
Hibernate6Module module = new Hibernate6Module();
module.disable(Hibernate6Module.Feature.USE_TRANSIENT_ANNOTATION);
module.enable(Hibernate6Module.Feature.FORCE_LAZY_LOADING);
return module;
}
}
@@ -0,0 +1,38 @@
package com.sapolar.config;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.License;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class OpenApiConfig {
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("Sa Polar API - Gestión de Alquileres")
.version("1.0.0")
.description("API REST para la gestión y contabilidad de una empresa de alquileres")
.contact(new Contact()
.name("Sa Polar")
.email("info@sapolar.com"))
.license(new License()
.name("MIT")
.url("https://opensource.org/licenses/MIT")))
.addSecurityItem(new SecurityRequirement().addList("Bearer"))
.components(new Components()
.addSecuritySchemes("Bearer",
new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")
.description("Ingrese el token JWT")));
}
}
@@ -0,0 +1,63 @@
package com.sapolar.config;
import com.sapolar.auth.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> cors.configure(http))
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api-docs/**", "/swagger-ui/**", "/v3/api-docs/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/reports/**").hasAnyRole("ADMIN", "GERENTE", "CONTABLE")
.requestMatchers("/api/users/**").hasRole("ADMIN")
.requestMatchers("/api/properties/**").hasAnyRole("ADMIN", "GERENTE")
.requestMatchers("/api/tenants/**").hasAnyRole("ADMIN", "GERENTE")
.requestMatchers("/api/contracts/**").hasAnyRole("ADMIN", "GERENTE")
.requestMatchers("/api/incomes/**").hasAnyRole("ADMIN", "GERENTE", "CONTABLE")
.requestMatchers("/api/expenses/**").hasAnyRole("ADMIN", "GERENTE", "CONTABLE")
.requestMatchers("/api/incidents/**").hasAnyRole("ADMIN", "GERENTE")
.requestMatchers("/api/maintenance/**").hasAnyRole("ADMIN", "GERENTE")
.requestMatchers("/api/dashboard/**").hasAnyRole("ADMIN", "GERENTE", "CONTABLE")
.requestMatchers("/api/receipts/**").hasAnyRole("ADMIN", "GERENTE", "CONTABLE")
.requestMatchers("/api/reports/**").hasAnyRole("ADMIN", "GERENTE", "CONTABLE")
.requestMatchers("/api/notifications/**").authenticated()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}
@@ -0,0 +1,106 @@
package com.sapolar.contract;
import com.sapolar.property.Property;
import com.sapolar.user.User;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Getter
@Setter
@Entity
@Table(name = "contracts", indexes = {
@Index(name = "idx_contracts_property", columnList = "property_id"),
@Index(name = "idx_contracts_status", columnList = "status_id"),
@Index(name = "idx_contracts_dates", columnList = "start_date, end_date")
})
public class Contract {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "property_id", nullable = false)
private Property property;
@OneToMany(mappedBy = "contract", cascade = CascadeType.ALL, orphanRemoval = true)
private List<ContractTenant> contractTenants = new ArrayList<>();
@OneToMany(mappedBy = "contract", cascade = CascadeType.ALL, orphanRemoval = true)
private List<ExpenseRepercussion> expenseRepercussions = new ArrayList<>();
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "status_id", nullable = false)
private ContractStatus status;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "period_id", nullable = false)
private PaymentPeriod paymentPeriod;
@Column(name = "contract_number", unique = true, length = 50)
private String contractNumber;
@Column(name = "start_date", nullable = false)
private LocalDate startDate;
@Column(name = "end_date")
private LocalDate endDate;
@Column(name = "renewal_date")
private LocalDate renewalDate;
@Column(name = "rental_amount", nullable = false, precision = 12, scale = 2)
private BigDecimal rentalAmount;
@Column(name = "deposit_amount", precision = 12, scale = 2)
private BigDecimal depositAmount;
@Column(name = "payment_day", nullable = false)
private Integer paymentDay = 1;
@Column(name = "payment_day_end")
private Integer paymentDayEnd;
@Column(name = "iban_charge", length = 34)
private String ibanCharge;
@Column(columnDefinition = "TEXT")
private String notes;
@Column(name = "signed_at")
private LocalDate signedAt;
@Column(name = "terminated_at")
private LocalDate terminatedAt;
@Column(name = "termination_cause", length = 500)
private String terminationCause;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "created_by")
private User createdBy;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}
@@ -0,0 +1,70 @@
package com.sapolar.contract;
import com.sapolar.auth.SecurityUser;
import com.sapolar.common.dto.ApiResponse;
import com.sapolar.common.dto.PagedResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/contracts")
@RequiredArgsConstructor
public class ContractController {
private final ContractService contractService;
@GetMapping
public ResponseEntity<ApiResponse<PagedResponse<Contract>>> findAll(
@RequestParam(required = false) Long propertyId,
@RequestParam(required = false) Long tenantId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "id") String sort,
@RequestParam(defaultValue = "asc") String dir) {
Sort sorting = dir.equalsIgnoreCase("desc") ? Sort.by(sort).descending() : Sort.by(sort).ascending();
Pageable pageable = PageRequest.of(page, size, sorting);
if (propertyId != null) {
List<Contract> list = contractService.findByProperty(propertyId);
return ResponseEntity.ok(ApiResponse.success(new PagedResponse<>(list, 0, list.size(), list.size(), 1, true)));
}
if (tenantId != null) {
List<Contract> list = contractService.findByTenant(tenantId);
return ResponseEntity.ok(ApiResponse.success(new PagedResponse<>(list, 0, list.size(), list.size(), 1, true)));
}
return ResponseEntity.ok(ApiResponse.success(PagedResponse.from(contractService.findAll(pageable))));
}
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<Contract>> findById(@PathVariable Long id) {
return ResponseEntity.ok(ApiResponse.success(contractService.findById(id)));
}
@PostMapping
public ResponseEntity<ApiResponse<Contract>> create(@RequestBody Contract contract,
@AuthenticationPrincipal SecurityUser user) {
return ResponseEntity.ok(ApiResponse.success("Contrato creado",
contractService.create(contract, user.userId())));
}
@PutMapping("/{id}")
public ResponseEntity<ApiResponse<Contract>> update(@PathVariable Long id, @RequestBody Contract contract) {
return ResponseEntity.ok(ApiResponse.success("Contrato actualizado",
contractService.update(id, contract)));
}
@PostMapping("/{id}/terminate")
public ResponseEntity<ApiResponse<Contract>> terminate(@PathVariable Long id,
@RequestBody Map<String, String> body) {
String cause = body.getOrDefault("cause", "Rescisión");
return ResponseEntity.ok(ApiResponse.success("Contrato rescindido",
contractService.terminate(id, cause)));
}
}
@@ -0,0 +1,25 @@
package com.sapolar.contract;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
@Repository
public interface ContractRepository extends JpaRepository<Contract, Long> {
List<Contract> findByPropertyId(Long propertyId);
List<Contract> findByStatusId(Integer statusId);
Optional<Contract> findByContractNumber(String contractNumber);
@Query("SELECT c FROM Contract c WHERE c.status.name = 'ACTIVO' AND c.property.id = :propertyId")
Optional<Contract> findActiveByPropertyId(@Param("propertyId") Long propertyId);
@Query("SELECT c FROM Contract c WHERE c.status.name = 'ACTIVO' AND c.endDate BETWEEN :start AND :end")
List<Contract> findContractsExpiringBetween(@Param("start") LocalDate start, @Param("end") LocalDate end);
boolean existsByContractNumber(String contractNumber);
}
@@ -0,0 +1,162 @@
package com.sapolar.contract;
import com.sapolar.common.exception.BadRequestException;
import com.sapolar.common.exception.DuplicateResourceException;
import com.sapolar.common.exception.ResourceNotFoundException;
import com.sapolar.property.Property;
import com.sapolar.property.PropertyRepository;
import com.sapolar.property.PropertyStatus;
import com.sapolar.property.PropertyStatusRepository;
import com.sapolar.tenant.Tenant;
import com.sapolar.tenant.TenantRepository;
import com.sapolar.user.User;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
@Service
@RequiredArgsConstructor
public class ContractService {
private final ContractRepository contractRepository;
private final ContractTenantRepository contractTenantRepository;
private final PropertyRepository propertyRepository;
private final TenantRepository tenantRepository;
private final PropertyStatusRepository propertyStatusRepository;
public List<Contract> findAll() {
return contractRepository.findAll();
}
public Page<Contract> findAll(Pageable pageable) {
return contractRepository.findAll(pageable);
}
public List<Contract> findByProperty(Long propertyId) {
return contractRepository.findByPropertyId(propertyId);
}
public List<Contract> findByTenant(Long tenantId) {
List<ContractTenant> cts = contractTenantRepository.findByTenantId(tenantId);
return cts.stream().map(ContractTenant::getContract).toList();
}
public Contract findById(Long id) {
return contractRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Contrato", id));
}
void validateNoOverlap(LocalDate newStart, LocalDate newEnd, Long propertyId, Long excludeId) {
LocalDate e2 = newEnd != null ? newEnd : LocalDate.MAX;
List<Contract> existing = contractRepository.findByPropertyId(propertyId);
for (Contract c : existing) {
if (c.getId().equals(excludeId)) continue;
LocalDate e1 = c.getEndDate() != null ? c.getEndDate() : LocalDate.MAX;
if (!newStart.isAfter(e1) && !c.getStartDate().isAfter(e2)) {
throw new BadRequestException(
"La propiedad ya tiene un contrato (" + c.getContractNumber() + ") vigente en esas fechas");
}
}
}
@Transactional
public Contract create(Contract contract, Long userId) {
if (contract.getProperty() == null || contract.getProperty().getId() == null) {
throw new BadRequestException("La propiedad es obligatoria");
}
if (contract.getContractNumber() != null &&
contractRepository.existsByContractNumber(contract.getContractNumber())) {
throw new DuplicateResourceException("Ya existe un contrato con número: " + contract.getContractNumber());
}
Property property = propertyRepository.findById(contract.getProperty().getId())
.orElseThrow(() -> new ResourceNotFoundException("Propiedad", contract.getProperty().getId()));
contract.setProperty(property);
validateNoOverlap(contract.getStartDate(), contract.getEndDate(), property.getId(), -1L);
List<ContractTenant> resolved = new ArrayList<>();
for (ContractTenant ct : contract.getContractTenants()) {
Tenant tenant = tenantRepository.findById(ct.getTenant().getId())
.orElseThrow(() -> new ResourceNotFoundException("Arrendatario", ct.getTenant().getId()));
ct.setTenant(tenant);
ct.setContract(contract);
resolved.add(ct);
}
contract.setContractTenants(resolved);
User user = new User();
user.setId(userId);
contract.setCreatedBy(user);
Contract saved = contractRepository.save(contract);
PropertyStatus rentedStatus = propertyStatusRepository.findByName("ALQUILADO")
.orElseThrow(() -> new BadRequestException("Estado ALQUILADO no encontrado"));
property.setStatus(rentedStatus);
propertyRepository.save(property);
return saved;
}
@Transactional
public Contract update(Long id, Contract updated) {
Contract contract = findById(id);
LocalDate newStart = updated.getStartDate() != null ? updated.getStartDate() : contract.getStartDate();
LocalDate newEnd = updated.getEndDate();
validateNoOverlap(newStart, newEnd, contract.getProperty().getId(), id);
if (updated.getStartDate() != null) contract.setStartDate(updated.getStartDate());
contract.setEndDate(updated.getEndDate());
contract.setRentalAmount(updated.getRentalAmount());
contract.setDepositAmount(updated.getDepositAmount());
contract.setPaymentDay(updated.getPaymentDay());
contract.setIbanCharge(updated.getIbanCharge());
contract.setNotes(updated.getNotes());
if (updated.getStatus() != null) contract.setStatus(updated.getStatus());
if (updated.getPaymentPeriod() != null) contract.setPaymentPeriod(updated.getPaymentPeriod());
if (updated.getContractTenants() != null && !updated.getContractTenants().isEmpty()) {
contractTenantRepository.deleteByContractId(contract.getId());
List<ContractTenant> resolved = new ArrayList<>();
for (ContractTenant ct : updated.getContractTenants()) {
Tenant tenant = tenantRepository.findById(ct.getTenant().getId())
.orElseThrow(() -> new ResourceNotFoundException("Arrendatario", ct.getTenant().getId()));
ContractTenant newCt = new ContractTenant();
newCt.setContract(contract);
newCt.setTenant(tenant);
newCt.setRole(ct.getRole());
resolved.add(newCt);
}
contract.setContractTenants(resolved);
}
return contractRepository.save(contract);
}
@Transactional
public Contract terminate(Long id, String cause) {
Contract contract = findById(id);
contract.setTerminatedAt(java.time.LocalDate.now());
contract.setTerminationCause(cause);
ContractStatus terminatedStatus = new ContractStatus();
terminatedStatus.setId(4);
contract.setStatus(terminatedStatus);
PropertyStatus vacantStatus = propertyStatusRepository.findByName("VACIO")
.orElseThrow(() -> new BadRequestException("Estado VACIO no encontrado"));
contract.getProperty().setStatus(vacantStatus);
propertyRepository.save(contract.getProperty());
return contractRepository.save(contract);
}
}
@@ -0,0 +1,19 @@
package com.sapolar.contract;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Entity
@Table(name = "contract_statuses")
public class ContractStatus {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(nullable = false, unique = true, length = 30)
private String name;
}
@@ -0,0 +1,32 @@
package com.sapolar.contract;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.sapolar.tenant.Tenant;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Entity
@Table(name = "contract_tenants", uniqueConstraints = {
@UniqueConstraint(columnNames = {"contract_id", "tenant_id"})
})
public class ContractTenant {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "contract_id", nullable = false)
@JsonIgnore
private Contract contract;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "tenant_id", nullable = false)
private Tenant tenant;
@Column(nullable = false, length = 20)
private String role = "TITULAR";
}
@@ -0,0 +1,13 @@
package com.sapolar.contract;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface ContractTenantRepository extends JpaRepository<ContractTenant, Long> {
List<ContractTenant> findByContractId(Long contractId);
List<ContractTenant> findByTenantId(Long tenantId);
void deleteByContractId(Long contractId);
}
@@ -0,0 +1,55 @@
package com.sapolar.contract;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Getter
@Setter
@Entity
@Table(name = "expense_repercussions")
public class ExpenseRepercussion {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "contract_id", nullable = false)
private Contract contract;
@Column(name = "expense_type", nullable = false, length = 50)
private String expenseType;
@Column(name = "amount", nullable = false, precision = 10, scale = 2)
private BigDecimal amount;
@Column(name = "billing_period", length = 30)
private String billingPeriod;
@Column(columnDefinition = "TEXT")
private String observations;
@Column(name = "active", nullable = false)
private Boolean active = true;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}
@@ -0,0 +1,51 @@
package com.sapolar.contract;
import com.sapolar.common.dto.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/expense-repercussions")
@RequiredArgsConstructor
public class ExpenseRepercussionController {
private final ExpenseRepercussionService expenseRepercussionService;
@GetMapping
public ResponseEntity<ApiResponse<List<ExpenseRepercussion>>> findByContract(
@RequestParam Long contractId) {
return ResponseEntity.ok(ApiResponse.success(
expenseRepercussionService.findByContract(contractId)));
}
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<ExpenseRepercussion>> findById(@PathVariable Long id) {
return ResponseEntity.ok(ApiResponse.success(
expenseRepercussionService.findById(id)));
}
@PostMapping
public ResponseEntity<ApiResponse<ExpenseRepercussion>> create(
@RequestParam Long contractId,
@RequestBody ExpenseRepercussion repercussion) {
return ResponseEntity.ok(ApiResponse.success("Repercusión de gasto creada",
expenseRepercussionService.create(repercussion, contractId)));
}
@PutMapping("/{id}")
public ResponseEntity<ApiResponse<ExpenseRepercussion>> update(
@PathVariable Long id,
@RequestBody ExpenseRepercussion repercussion) {
return ResponseEntity.ok(ApiResponse.success("Repercusión de gasto actualizada",
expenseRepercussionService.update(id, repercussion)));
}
@DeleteMapping("/{id}")
public ResponseEntity<ApiResponse<Void>> delete(@PathVariable Long id) {
expenseRepercussionService.delete(id);
return ResponseEntity.ok(ApiResponse.success("Repercusión de gasto eliminada", null));
}
}
@@ -0,0 +1,12 @@
package com.sapolar.contract;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface ExpenseRepercussionRepository extends JpaRepository<ExpenseRepercussion, Long> {
List<ExpenseRepercussion> findByContractId(Long contractId);
List<ExpenseRepercussion> findByContractIdAndActiveTrue(Long contractId);
}
@@ -0,0 +1,65 @@
package com.sapolar.contract;
import com.sapolar.common.exception.BadRequestException;
import com.sapolar.common.exception.ResourceNotFoundException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@RequiredArgsConstructor
public class ExpenseRepercussionService {
private final ExpenseRepercussionRepository expenseRepercussionRepository;
private final ContractRepository contractRepository;
public List<ExpenseRepercussion> findByContract(Long contractId) {
return expenseRepercussionRepository.findByContractIdAndActiveTrue(contractId);
}
public ExpenseRepercussion findById(Long id) {
return expenseRepercussionRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Repercusión de gasto", id));
}
@Transactional
public ExpenseRepercussion create(ExpenseRepercussion repercussion, Long contractId) {
if (repercussion.getExpenseType() == null || repercussion.getExpenseType().isBlank()) {
throw new BadRequestException("El tipo de gasto es obligatorio");
}
if (repercussion.getAmount() == null) {
throw new BadRequestException("El importe es obligatorio");
}
Contract contract = contractRepository.findById(contractId)
.orElseThrow(() -> new ResourceNotFoundException("Contrato", contractId));
repercussion.setContract(contract);
return expenseRepercussionRepository.save(repercussion);
}
@Transactional
public ExpenseRepercussion update(Long id, ExpenseRepercussion updated) {
ExpenseRepercussion existing = findById(id);
if (updated.getExpenseType() != null) {
existing.setExpenseType(updated.getExpenseType());
}
if (updated.getAmount() != null) {
existing.setAmount(updated.getAmount());
}
existing.setBillingPeriod(updated.getBillingPeriod());
existing.setObservations(updated.getObservations());
return expenseRepercussionRepository.save(existing);
}
@Transactional
public void delete(Long id) {
ExpenseRepercussion repercussion = findById(id);
repercussion.setActive(false);
expenseRepercussionRepository.save(repercussion);
}
}
@@ -0,0 +1,19 @@
package com.sapolar.contract;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Entity
@Table(name = "payment_periods")
public class PaymentPeriod {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(nullable = false, unique = true, length = 30)
private String name;
}
@@ -0,0 +1,8 @@
package com.sapolar.contract;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface PaymentPeriodRepository extends JpaRepository<PaymentPeriod, Integer> {
}
@@ -0,0 +1,30 @@
package com.sapolar.dashboard;
import com.sapolar.common.dto.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/dashboard")
@RequiredArgsConstructor
public class DashboardController {
private final DashboardService dashboardService;
@GetMapping("/summary")
public ResponseEntity<ApiResponse<Map<String, Object>>> getSummary() {
return ResponseEntity.ok(ApiResponse.success(dashboardService.getSummary()));
}
@GetMapping("/income-expense")
public ResponseEntity<ApiResponse<Map<String, Object>>> getMonthlyIncomeExpense(
@RequestParam(required = false) Integer year) {
if (year == null) {
year = java.time.LocalDate.now().getYear();
}
return ResponseEntity.ok(ApiResponse.success(dashboardService.getMonthlyIncomeExpense(year)));
}
}
@@ -0,0 +1,181 @@
package com.sapolar.dashboard;
import com.sapolar.contract.Contract;
import com.sapolar.contract.ContractRepository;
import com.sapolar.finance.expense.ExpenseReceipt;
import com.sapolar.finance.expense.ExpenseReceiptRepository;
import com.sapolar.finance.income.IncomeReceipt;
import com.sapolar.finance.income.IncomeReceiptRepository;
import com.sapolar.incident.IncidentRepository;
import com.sapolar.maintenance.ScheduledMaintenanceRepository;
import com.sapolar.property.PropertyRepository;
import com.sapolar.tenant.TenantRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class DashboardService {
private final IncomeReceiptRepository incomeReceiptRepository;
private final ExpenseReceiptRepository expenseReceiptRepository;
private final PropertyRepository propertyRepository;
private final IncidentRepository incidentRepository;
private final ScheduledMaintenanceRepository maintenanceRepository;
private final TenantRepository tenantRepository;
private final ContractRepository contractRepository;
public Map<String, Object> getSummary() {
int year = LocalDate.now().getYear();
int month = LocalDate.now().getMonthValue();
LocalDate startOfYear = LocalDate.of(year, 1, 1);
LocalDate startOfMonth = LocalDate.of(year, month, 1);
LocalDate today = LocalDate.now();
BigDecimal incomeYear = incomeReceiptRepository.sumPaidBetween(startOfYear, today);
BigDecimal expenseYear = expenseReceiptRepository.sumPaidBetween(startOfYear, today);
BigDecimal incomeMonth = incomeReceiptRepository.sumPaidBetween(startOfMonth, today);
BigDecimal expenseMonth = expenseReceiptRepository.sumPaidBetween(startOfMonth, today);
long pendingIncomes = incomeReceiptRepository.countByStatusId(1);
long overdueIncomes = incomeReceiptRepository.countByStatusId(3);
long pendingExpenses = expenseReceiptRepository.countByStatusId(1);
long overdueExpenses = expenseReceiptRepository.countByStatusId(3);
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("totalProperties", propertyRepository.count());
summary.put("rentedProperties", propertyRepository.findByStatusId(2).size());
summary.put("freeProperties", propertyRepository.findByStatusId(1).size());
summary.put("totalTenants", tenantRepository.count());
summary.put("activeContracts", contractRepository.findByStatusId(1).size());
summary.put("pendingIncomes", pendingIncomes);
summary.put("overdueIncomes", overdueIncomes);
summary.put("pendingExpenses", pendingExpenses);
summary.put("overdueExpenses", overdueExpenses);
summary.put("incomeThisYear", incomeYear);
summary.put("expenseThisYear", expenseYear);
summary.put("balanceThisYear", incomeYear.subtract(expenseYear));
summary.put("incomeThisMonth", incomeMonth);
summary.put("expenseThisMonth", expenseMonth);
summary.put("balanceThisMonth", incomeMonth.subtract(expenseMonth));
summary.put("openIncidents", incidentRepository.count() - incidentRepository.countByStatusId(4)); // sin REPARADO
summary.put("pendingMaintenance", maintenanceRepository.findByCompletedFalse().size());
// ── Listas de pendientes (top 8) ──
summary.put("pendingIncomeList", buildPendingIncomeList());
summary.put("pendingExpenseList", buildPendingExpenseList());
// ── Contratos próximos a vencer (60 días) ──
summary.put("expiringContracts", buildExpiringContracts(today));
// ── Ingresos / Gastos por categoría ──
summary.put("incomeByCategory", buildIncomeByCategory(year));
summary.put("expenseByCategory", buildExpenseByCategory(year));
return summary;
}
public Map<String, Object> getMonthlyIncomeExpense(int year) {
Map<String, Object> result = new LinkedHashMap<>();
for (int m = 1; m <= 12; m++) {
LocalDate start = LocalDate.of(year, m, 1);
LocalDate end = start.withDayOfMonth(start.lengthOfMonth());
BigDecimal income = incomeReceiptRepository.sumPaidBetween(start, end);
BigDecimal expense = expenseReceiptRepository.sumPaidBetween(start, end);
Map<String, BigDecimal> monthData = new LinkedHashMap<>();
monthData.put("income", income);
monthData.put("expense", expense);
result.put(java.time.Month.of(m).name(), monthData);
}
return result;
}
// ─────────────────────────────────────────────────
// Métodos auxiliares
// ─────────────────────────────────────────────────
private List<Map<String, Object>> buildPendingIncomeList() {
List<IncomeReceipt> list = incomeReceiptRepository.findPendingOrderByDueDate();
return list.stream().limit(8).map(r -> {
Map<String, Object> m = new LinkedHashMap<>();
m.put("id", r.getId());
m.put("propertyName", r.getProperty().getName());
m.put("tenantName", r.getTenant() != null ? r.getTenant().getFullName() : null);
m.put("amount", r.getAmount());
m.put("periodLabel", r.getPeriodLabel());
m.put("dueDate", r.getDueDate());
m.put("status", r.getStatus().getName());
if (r.getDueDate() != null) {
long days = ChronoUnit.DAYS.between(r.getDueDate(), LocalDate.now());
m.put("daysOverdue", Math.max(0, days));
} else {
m.put("daysOverdue", 0);
}
return m;
}).collect(Collectors.toList());
}
private List<Map<String, Object>> buildPendingExpenseList() {
List<ExpenseReceipt> list = expenseReceiptRepository.findPendingOrderByDueDate();
return list.stream().limit(8).map(e -> {
Map<String, Object> m = new LinkedHashMap<>();
m.put("id", e.getId());
m.put("supplierName", e.getSupplierName());
m.put("categoryName", e.getCategory() != null ? e.getCategory().getName() : null);
m.put("description", e.getDescription());
m.put("amount", e.getTotalAmount());
m.put("dueDate", e.getDueDate());
m.put("status", e.getStatus().getName());
m.put("propertyName", e.getProperty() != null ? e.getProperty().getName() : null);
if (e.getDueDate() != null) {
long days = ChronoUnit.DAYS.between(e.getDueDate(), LocalDate.now());
m.put("daysOverdue", Math.max(0, days));
} else {
m.put("daysOverdue", 0);
}
return m;
}).collect(Collectors.toList());
}
private List<Map<String, Object>> buildExpiringContracts(LocalDate today) {
LocalDate endWindow = today.plusDays(60);
List<Contract> contracts = contractRepository.findContractsExpiringBetween(today, endWindow);
return contracts.stream().map(c -> {
Map<String, Object> m = new LinkedHashMap<>();
m.put("id", c.getId());
m.put("propertyName", c.getProperty().getName());
m.put("contractNumber", c.getContractNumber());
m.put("endDate", c.getEndDate());
m.put("daysLeft", ChronoUnit.DAYS.between(today, c.getEndDate()));
return m;
}).collect(Collectors.toList());
}
private Map<String, BigDecimal> buildIncomeByCategory(int year) {
Map<String, BigDecimal> result = new LinkedHashMap<>();
for (Object[] row : incomeReceiptRepository.sumByCategoryForYear(year)) {
result.put((String) row[0], (BigDecimal) row[1]);
}
return result;
}
private Map<String, BigDecimal> buildExpenseByCategory(int year) {
Map<String, BigDecimal> result = new LinkedHashMap<>();
for (Object[] row : expenseReceiptRepository.sumByCategoryForYear(year)) {
result.put((String) row[0], (BigDecimal) row[1]);
}
return result;
}
}
@@ -0,0 +1,72 @@
package com.sapolar.document;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.sapolar.user.User;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.Set;
@Getter
@Setter
@Entity
@Table(name = "documents", indexes = {
@Index(name = "idx_documents_type", columnList = "document_type_id")
})
public class Document {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "document_type_id", nullable = false)
private DocumentType documentType;
@JsonIgnore
@OneToMany(mappedBy = "document", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
private Set<DocumentEntity> entities = new HashSet<>();
@Column(name = "original_name", nullable = false, length = 255)
private String originalName;
@Column(name = "stored_name", nullable = false, length = 255)
private String storedName;
@Column(name = "mime_type", length = 100)
private String mimeType;
@Column(name = "file_size")
private Long fileSize;
@Column(length = 500)
private String description;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "uploaded_by")
private User uploadedBy;
@Column(name = "uploaded_at", nullable = false, updatable = false)
private LocalDateTime uploadedAt;
@PrePersist
protected void onCreate() {
uploadedAt = LocalDateTime.now();
}
public void addEntity(String entityType, Long entityId) {
DocumentEntity entity = new DocumentEntity();
entity.setDocument(this);
entity.setEntityType(entityType);
entity.setEntityId(entityId);
this.entities.add(entity);
}
public void removeEntity(String entityType, Long entityId) {
this.entities.removeIf(e ->
e.getEntityType().equals(entityType) && e.getEntityId().equals(entityId));
}
}
@@ -0,0 +1,114 @@
package com.sapolar.document;
import com.sapolar.auth.SecurityUser;
import com.sapolar.common.dto.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
@RestController
@RequestMapping("/api/documents")
@RequiredArgsConstructor
public class DocumentController {
private final DocumentService documentService;
@PostMapping("/upload")
public ResponseEntity<ApiResponse<Document>> upload(
@RequestParam("file") MultipartFile file,
@RequestParam("entityType") String entityType,
@RequestParam("entityId") Long entityId,
@RequestParam("documentTypeId") Integer documentTypeId,
@RequestParam(required = false) String description,
@AuthenticationPrincipal SecurityUser user) {
Document doc = documentService.uploadFile(file, entityType, entityId,
documentTypeId, description, user.userId());
return ResponseEntity.ok(ApiResponse.success("Archivo subido", doc));
}
@GetMapping("/entity/{entityType}/{entityId}")
public ResponseEntity<ApiResponse<List<Document>>> getEntityDocuments(
@PathVariable String entityType, @PathVariable Long entityId) {
return ResponseEntity.ok(ApiResponse.success(
documentService.getDocumentsForEntity(entityType, entityId)));
}
@GetMapping("/search")
public ResponseEntity<ApiResponse<List<Document>>> searchDocuments(
@RequestParam(required = false) String entityType,
@RequestParam(required = false) Long entityId,
@RequestParam(required = false) Integer documentTypeId,
@RequestParam(required = false) String originalName) {
// Convert empty strings to null for proper query handling
String entityTypeParam = (entityType != null && entityType.isBlank()) ? null : entityType;
String originalNameParam = (originalName != null && originalName.isBlank()) ? null : originalName;
return ResponseEntity.ok(ApiResponse.success(
documentService.searchDocuments(entityTypeParam, entityId, documentTypeId, originalNameParam)));
}
@GetMapping("/{id}/download")
public ResponseEntity<Resource> download(@PathVariable Long id) {
DocumentService.DocumentDownloadResult result = documentService.downloadFile(id);
String mimeType = result.mimeType();
if (mimeType == null) {
mimeType = "application/octet-stream";
}
MediaType mediaType = MediaType.parseMediaType(mimeType);
return ResponseEntity.ok()
.contentType(mediaType)
.header(HttpHeaders.CONTENT_DISPOSITION,
"inline; filename=\"" + result.filename() + "\"")
.body(result.resource());
}
@DeleteMapping("/{id}")
public ResponseEntity<ApiResponse<Void>> delete(@PathVariable Long id) {
documentService.deleteDocument(id);
return ResponseEntity.ok(ApiResponse.success("Documento eliminado", null));
}
// Associations management
@GetMapping("/{id}/entities")
public ResponseEntity<ApiResponse<List<DocumentEntity>>> getDocumentEntities(
@PathVariable Long id) {
return ResponseEntity.ok(ApiResponse.success(
documentService.getDocumentEntities(id)));
}
@PostMapping("/{id}/entities")
public ResponseEntity<ApiResponse<Document>> addEntity(
@PathVariable Long id,
@RequestParam String entityType,
@RequestParam Long entityId) {
Document doc = documentService.addEntityAssociation(id, entityType, entityId);
return ResponseEntity.ok(ApiResponse.success("Documento asociado", doc));
}
@DeleteMapping("/{id}/entities/{entityType}/{entityId}")
public ResponseEntity<ApiResponse<Void>> removeEntity(
@PathVariable Long id,
@PathVariable String entityType,
@PathVariable Long entityId) {
documentService.removeEntityAssociation(id, entityType, entityId);
return ResponseEntity.ok(ApiResponse.success("Asociación eliminada", null));
}
@GetMapping("/types")
public ResponseEntity<ApiResponse<List<DocumentType>>> getDocumentTypes() {
return ResponseEntity.ok(ApiResponse.success(documentService.getDocumentTypes()));
}
@GetMapping("/types/entity/{entityType}")
public ResponseEntity<ApiResponse<List<DocumentTypeForEntityDto>>> getDocumentTypesForEntity(
@PathVariable String entityType) {
return ResponseEntity.ok(ApiResponse.success(
documentService.getDocumentTypesForEntity(entityType)));
}
}
@@ -0,0 +1,38 @@
package com.sapolar.document;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Entity
@Table(name = "document_entities")
@Getter
@Setter
public class DocumentEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "document_id", nullable = false)
@JsonIgnoreProperties({"entities", "documentType", "uploadedBy"})
private Document document;
@Column(name = "entity_type", nullable = false, length = 30)
private String entityType;
@Column(name = "entity_id", nullable = false)
private Long entityId;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
}
}
@@ -0,0 +1,11 @@
package com.sapolar.document;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface DocumentEntityRepository extends JpaRepository<DocumentEntity, Long> {
Optional<DocumentEntity> findByDocumentIdAndEntityTypeAndEntityId(Long documentId, String entityType, Long entityId);
}
@@ -0,0 +1,31 @@
package com.sapolar.document;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface DocumentRepository extends JpaRepository<Document, Long> {
@Query("SELECT d FROM Document d JOIN d.entities e WHERE e.entityType = :entityType AND e.entityId = :entityId")
List<Document> findByEntityTypeAndEntityId(@Param("entityType") String entityType, @Param("entityId") Long entityId);
List<Document> findByDocumentTypeId(Integer documentTypeId);
@Query("SELECT d FROM Document d WHERE d.documentType.id = :typeId")
List<Document> findByDocumentType(@Param("typeId") Integer typeId);
@Query("SELECT d FROM Document d JOIN d.entities e WHERE " +
"(:entityType IS NULL OR e.entityType = :entityType) AND " +
"(:entityId IS NULL OR e.entityId = :entityId) AND " +
"(:documentTypeId IS NULL OR d.documentType.id = :documentTypeId) AND " +
"(:originalName IS NULL OR LOWER(d.originalName) LIKE LOWER(CONCAT('%', :originalName, '%')))")
List<Document> searchDocuments(
@Param("entityType") String entityType,
@Param("entityId") Long entityId,
@Param("documentTypeId") Integer documentTypeId,
@Param("originalName") String originalName);
}
@@ -0,0 +1,237 @@
package com.sapolar.document;
import com.sapolar.common.exception.BadRequestException;
import com.sapolar.common.exception.ResourceNotFoundException;
import com.sapolar.config.FileStorageConfig;
import lombok.RequiredArgsConstructor;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.net.MalformedURLException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.List;
@Service
@RequiredArgsConstructor
public class DocumentService {
private final FileStorageConfig fileStorageConfig;
private final DocumentRepository documentRepository;
private final DocumentEntityRepository documentEntityRepository;
private final DocumentTypeRepository documentTypeRepository;
private final DocumentTypeEntityAllowedRepository documentTypeEntityAllowedRepository;
@Transactional
public Document uploadFile(MultipartFile file, String entityType, Long entityId,
Integer documentTypeId, String description, Long uploaderId) {
String originalName = StringUtils.cleanPath(file.getOriginalFilename());
if (originalName.isBlank()) {
throw new BadRequestException("Nombre de archivo no válido");
}
// Validar que el tipo de documento está permitido para esta entidad
if (!isDocumentTypeAllowedForEntity(documentTypeId, entityType)) {
throw new BadRequestException(
"El tipo de documento no está permitido para entidades de tipo " + entityType);
}
String extension = "";
int dotIndex = originalName.lastIndexOf('.');
if (dotIndex > 0) {
extension = originalName.substring(dotIndex);
}
String storedName = java.util.UUID.randomUUID().toString() + extension;
// Guardar archivo en disco (usando la primera entidad para la ruta)
Path uploadDir = Paths.get(fileStorageConfig.getPath())
.resolve(entityType.toLowerCase())
.resolve(entityId.toString());
try {
Files.createDirectories(uploadDir);
Path targetPath = uploadDir.resolve(storedName);
Files.copy(file.getInputStream(), targetPath, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
throw new RuntimeException("Error al subir el archivo: " + originalName, e);
}
// Crear documento
Document document = new Document();
DocumentType docType = new DocumentType();
docType.setId(documentTypeId);
document.setDocumentType(docType);
document.setOriginalName(originalName);
document.setStoredName(storedName);
document.setMimeType(file.getContentType());
document.setFileSize(file.getSize());
document.setDescription(description);
Document savedDoc = documentRepository.save(document);
// Crear asociación con la entidad primaria
savedDoc.addEntity(entityType, entityId);
return documentRepository.save(savedDoc);
}
/**
* Valida si un tipo de documento está permitido para una entidad.
* Si la relación no existe en la tabla de permitidos, se permite por defecto
* (comportamiento backward compatible).
*/
public boolean isDocumentTypeAllowedForEntity(Integer documentTypeId, String entityType) {
// Si no existe la tabla de permitidos, permitir (backward compatible)
if (!documentTypeEntityAllowedRepository.existsByDocumentTypeIdAndEntityType(documentTypeId, entityType)) {
// Verificar si existe alguna configuración para este tipo de documento
List<DocumentTypeEntityAllowed> configs = documentTypeEntityAllowedRepository
.findByDocumentTypeIdAndEntityType(documentTypeId, entityType)
.stream().toList();
if (configs.isEmpty()) {
// No hay configuración, verificar si hay alguna para este document type
long totalConfigs = documentTypeEntityAllowedRepository.findAll().stream()
.filter(c -> c.getDocumentType().getId().equals(documentTypeId))
.count();
// Si no hay ninguna configuración para este tipo de documento, permitir
// (es un tipo antiguo sin restricciones)
if (totalConfigs == 0) {
return true;
}
// Hay configuraciones pero ninguna para esta entidad específica
return false;
}
}
return documentTypeEntityAllowedRepository.existsByDocumentTypeIdAndEntityType(documentTypeId, entityType);
}
public DocumentDownloadResult downloadFile(Long documentId) {
Document document = documentRepository.findById(documentId)
.orElseThrow(() -> new ResourceNotFoundException("Documento", documentId));
// Usar la primera entidad para determinar la ruta del archivo
if (document.getEntities().isEmpty()) {
throw new ResourceNotFoundException("Documento", documentId);
}
String entityType = document.getEntities().iterator().next().getEntityType();
Long entityId = document.getEntities().iterator().next().getEntityId();
try {
Path filePath = Paths.get(fileStorageConfig.getPath())
.resolve(entityType.toLowerCase())
.resolve(entityId.toString())
.resolve(document.getStoredName());
Resource resource = new UrlResource(filePath.toUri());
if (resource.exists() && resource.isReadable()) {
return new DocumentDownloadResult(resource, document.getMimeType(), document.getOriginalName());
}
throw new RuntimeException("No se puede leer el archivo: " + document.getOriginalName());
} catch (MalformedURLException e) {
throw new RuntimeException("Error al acceder al archivo", e);
}
}
public List<Document> getDocumentsForEntity(String entityType, Long entityId) {
return documentRepository.findByEntityTypeAndEntityId(entityType, entityId);
}
public List<Document> searchDocuments(String entityType, Long entityId,
Integer documentTypeId, String originalName) {
return documentRepository.searchDocuments(entityType, entityId, documentTypeId, originalName);
}
@Transactional
public void deleteDocument(Long documentId) {
Document document = documentRepository.findById(documentId)
.orElseThrow(() -> new ResourceNotFoundException("Documento", documentId));
// Eliminar archivo físico
if (!document.getEntities().isEmpty()) {
DocumentEntity firstEntity = document.getEntities().iterator().next();
try {
Path filePath = Paths.get(fileStorageConfig.getPath())
.resolve(firstEntity.getEntityType().toLowerCase())
.resolve(firstEntity.getEntityId().toString())
.resolve(document.getStoredName());
Files.deleteIfExists(filePath);
} catch (IOException e) {
// Log error but continue with DB deletion
}
}
documentRepository.delete(document);
}
@Transactional
public Document addEntityAssociation(Long documentId, String entityType, Long entityId) {
Document document = documentRepository.findById(documentId)
.orElseThrow(() -> new ResourceNotFoundException("Documento", documentId));
// Validar que el tipo de documento está permitido para esta entidad
if (!isDocumentTypeAllowedForEntity(document.getDocumentType().getId(), entityType)) {
throw new BadRequestException(
"El tipo de documento no está permitido para entidades de tipo " + entityType);
}
// Verificar que no exista ya
if (documentEntityRepository.findByDocumentIdAndEntityTypeAndEntityId(documentId, entityType, entityId).isPresent()) {
throw new BadRequestException("El documento ya está asociado a esta entidad");
}
document.addEntity(entityType, entityId);
return documentRepository.save(document);
}
@Transactional
public void removeEntityAssociation(Long documentId, String entityType, Long entityId) {
DocumentEntity entity = documentEntityRepository
.findByDocumentIdAndEntityTypeAndEntityId(documentId, entityType, entityId)
.orElseThrow(() -> new ResourceNotFoundException("Asociación no encontrada"));
// Verificar que no sea la última asociación (el documento debe tener al menos una)
Document document = entity.getDocument();
if (document.getEntities().size() <= 1) {
throw new BadRequestException("No se puede eliminar la última asociación. Elimine el documento completo.");
}
document.removeEntity(entityType, entityId);
documentEntityRepository.delete(entity);
}
public List<DocumentEntity> getDocumentEntities(Long documentId) {
Document document = documentRepository.findById(documentId)
.orElseThrow(() -> new ResourceNotFoundException("Documento", documentId));
return List.copyOf(document.getEntities());
}
public List<DocumentType> getDocumentTypes() {
return documentTypeRepository.findAll();
}
/**
* Obtiene los tipos de documento permitidos para una entidad específica.
* Incluye información sobre si es obligatorio o no.
*/
public List<DocumentTypeForEntityDto> getDocumentTypesForEntity(String entityType) {
List<DocumentTypeEntityAllowed> allowed = documentTypeEntityAllowedRepository
.findAllowedTypesForEntity(entityType);
return allowed.stream()
.map(dtea -> new DocumentTypeForEntityDto(
dtea.getDocumentType().getId(),
dtea.getDocumentType().getName(),
dtea.getEntityType(),
dtea.getCanUpload(),
dtea.getMustHave(),
dtea.getDescription()))
.toList();
}
// Record para devolver el archivo y su información de cabecera
public record DocumentDownloadResult(Resource resource, String mimeType, String filename) {}
}
@@ -0,0 +1,19 @@
package com.sapolar.document;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Entity
@Table(name = "document_types")
public class DocumentType {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(nullable = false, unique = true, length = 50)
private String name;
}
@@ -0,0 +1,43 @@
package com.sapolar.document;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@Entity
@Table(name = "document_type_entity_allowed", indexes = {
@Index(name = "idx_dtea_entity", columnList = "entity_type")
})
public class DocumentTypeEntityAllowed {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "document_type_id", nullable = false)
private DocumentType documentType;
@Column(name = "entity_type", nullable = false, length = 30)
private String entityType;
@Column(name = "can_upload", nullable = false)
private Boolean canUpload = true;
@Column(name = "must_have", nullable = false)
private Boolean mustHave = false;
@Column(length = 255)
private String description;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
}
}
@@ -0,0 +1,27 @@
package com.sapolar.document;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface DocumentTypeEntityAllowedRepository extends JpaRepository<DocumentTypeEntityAllowed, Integer> {
List<DocumentTypeEntityAllowed> findByEntityType(String entityType);
List<DocumentTypeEntityAllowed> findByEntityTypeAndCanUploadTrue(String entityType);
Optional<DocumentTypeEntityAllowed> findByDocumentTypeIdAndEntityType(Integer documentTypeId, String entityType);
boolean existsByDocumentTypeIdAndEntityType(Integer documentTypeId, String entityType);
@Query("SELECT dtea FROM DocumentTypeEntityAllowed dtea " +
"JOIN FETCH dtea.documentType dt " +
"WHERE dtea.entityType = :entityType AND dtea.canUpload = true " +
"ORDER BY dt.name")
List<DocumentTypeEntityAllowed> findAllowedTypesForEntity(@Param("entityType") String entityType);
}
@@ -0,0 +1,19 @@
package com.sapolar.document;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class DocumentTypeForEntityDto {
private Integer documentTypeId;
private String documentTypeName;
private String entityType;
private Boolean canUpload;
private Boolean mustHave;
private String description;
}
@@ -0,0 +1,8 @@
package com.sapolar.document;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface DocumentTypeRepository extends JpaRepository<DocumentType, Integer> {
}
@@ -0,0 +1,62 @@
package com.sapolar.finance.bank;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@Entity
@Table(name = "bank_accounts")
public class BankAccount {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 100)
private String name;
@Column(nullable = false, length = 200)
private String holder;
@Column(nullable = false, length = 34)
private String iban;
@Column(name = "bank_name", length = 100)
private String bankName;
@Column(name = "swift_bic", length = 11)
private String swiftBic;
@Column(nullable = false, length = 3)
private String currency = "EUR";
@Column(name = "is_default", nullable = false)
private Boolean isDefault = false;
@Column(nullable = false)
private Boolean active = true;
@Column(columnDefinition = "TEXT")
private String description;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}
@@ -0,0 +1,52 @@
package com.sapolar.finance.bank;
import com.sapolar.common.dto.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/bank-accounts")
@RequiredArgsConstructor
public class BankAccountController {
private final BankAccountService bankAccountService;
@GetMapping
public ResponseEntity<ApiResponse<List<BankAccount>>> findAll(
@RequestParam(defaultValue = "false") boolean all) {
List<BankAccount> list = all ? bankAccountService.findAll()
: bankAccountService.findAllActive();
return ResponseEntity.ok(ApiResponse.success(list));
}
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<BankAccount>> findById(@PathVariable Long id) {
return ResponseEntity.ok(ApiResponse.success(bankAccountService.findById(id)));
}
@PostMapping
@PreAuthorize("hasAnyRole('ADMIN', 'GERENTE', 'CONTABLE')")
public ResponseEntity<ApiResponse<BankAccount>> create(@RequestBody BankAccount account) {
return ResponseEntity.ok(ApiResponse.success("Cuenta bancaria creada",
bankAccountService.create(account)));
}
@PutMapping("/{id}")
@PreAuthorize("hasAnyRole('ADMIN', 'GERENTE', 'CONTABLE')")
public ResponseEntity<ApiResponse<BankAccount>> update(@PathVariable Long id,
@RequestBody BankAccount account) {
return ResponseEntity.ok(ApiResponse.success("Cuenta bancaria actualizada",
bankAccountService.update(id, account)));
}
@DeleteMapping("/{id}")
@PreAuthorize("hasAnyRole('ADMIN', 'GERENTE')")
public ResponseEntity<ApiResponse<Void>> delete(@PathVariable Long id) {
bankAccountService.delete(id);
return ResponseEntity.ok(ApiResponse.success("Cuenta bancaria desactivada", null));
}
}
@@ -0,0 +1,13 @@
package com.sapolar.finance.bank;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface BankAccountRepository extends JpaRepository<BankAccount, Long> {
List<BankAccount> findByActiveTrueOrderByName();
Optional<BankAccount> findByIsDefaultTrueAndActiveTrue();
}
@@ -0,0 +1,72 @@
package com.sapolar.finance.bank;
import com.sapolar.common.exception.ResourceNotFoundException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@RequiredArgsConstructor
public class BankAccountService {
private final BankAccountRepository repository;
public List<BankAccount> findAllActive() {
return repository.findByActiveTrueOrderByName();
}
public List<BankAccount> findAll() {
return repository.findAll();
}
public BankAccount findById(Long id) {
return repository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Cuenta bancaria", id));
}
@Transactional
public BankAccount create(BankAccount account) {
if (account.getIsDefault()) {
clearDefaultFlag();
}
return repository.save(account);
}
@Transactional
public BankAccount update(Long id, BankAccount updated) {
BankAccount account = findById(id);
account.setName(updated.getName());
account.setHolder(updated.getHolder());
account.setIban(updated.getIban());
account.setBankName(updated.getBankName());
account.setSwiftBic(updated.getSwiftBic());
account.setCurrency(updated.getCurrency());
account.setDescription(updated.getDescription());
if (updated.getIsDefault() && !Boolean.TRUE.equals(account.getIsDefault())) {
clearDefaultFlag();
account.setIsDefault(true);
} else if (!Boolean.TRUE.equals(updated.getIsDefault())) {
account.setIsDefault(false);
}
return repository.save(account);
}
@Transactional
public void delete(Long id) {
BankAccount account = findById(id);
account.setActive(false);
repository.save(account);
}
private void clearDefaultFlag() {
repository.findByIsDefaultTrueAndActiveTrue()
.ifPresent(a -> {
a.setIsDefault(false);
repository.save(a);
});
}
}
@@ -0,0 +1,25 @@
package com.sapolar.finance.expense;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Entity
@Table(name = "expense_categories")
public class ExpenseCategory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 100)
private String name;
@Column(length = 255)
private String description;
@Column(nullable = false)
private Boolean active = true;
}
@@ -0,0 +1,11 @@
package com.sapolar.finance.expense;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface ExpenseCategoryRepository extends JpaRepository<ExpenseCategory, Long> {
List<ExpenseCategory> findByActiveTrueOrderByName();
}
@@ -0,0 +1,125 @@
package com.sapolar.finance.expense;
import com.sapolar.finance.bank.BankAccount;
import com.sapolar.property.Property;
import com.sapolar.property.PropertyGroup;
import com.sapolar.user.User;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "expense_receipts", indexes = {
@Index(name = "idx_expense_receipts_template", columnList = "template_id"),
@Index(name = "idx_expense_receipts_property", columnList = "property_id"),
@Index(name = "idx_expense_receipts_property_group", columnList = "property_group_id"),
@Index(name = "idx_expense_receipts_category", columnList = "category_id"),
@Index(name = "idx_expense_receipts_status", columnList = "status_id"),
@Index(name = "idx_expense_receipts_issue_date", columnList = "issue_date")
})
public class ExpenseReceipt {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "template_id")
private ExpenseTemplate template;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "property_id")
private Property property;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "property_group_id")
private PropertyGroup propertyGroup;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "bank_account_id")
private BankAccount bankAccount;
@Column(name = "is_domiciled", nullable = false)
private Boolean isDomiciled = false;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id")
private ExpenseCategory category;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "status_id", nullable = false)
private ExpenseStatus status;
@Column(name = "supplier_name", length = 200)
private String supplierName;
@Column(name = "supplier_fiscal_id", length = 20)
private String supplierFiscalId;
@Column(name = "invoice_number", length = 50)
private String invoiceNumber;
@Column(nullable = false, precision = 12, scale = 2)
private BigDecimal amount = BigDecimal.ZERO;
@Column(name = "tax_amount", precision = 12, scale = 2)
private BigDecimal taxAmount = BigDecimal.ZERO;
@Column(name = "total_amount", precision = 12, scale = 2)
private BigDecimal totalAmount;
@Column(name = "is_variable", nullable = false)
private Boolean isVariable = false;
@Column(name = "previous_amount", precision = 12, scale = 2)
private BigDecimal previousAmount;
@Column(name = "issue_date", nullable = false)
private LocalDate issueDate;
@Column(name = "due_date")
private LocalDate dueDate;
@Column(name = "payment_date")
private LocalDate paymentDate;
@Column(name = "payment_method", length = 30)
private String paymentMethod;
@Column(nullable = false, length = 500)
private String description;
@Column(columnDefinition = "TEXT")
private String notes;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "created_by")
private User createdBy;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}
@@ -0,0 +1,113 @@
package com.sapolar.finance.expense;
import com.sapolar.auth.SecurityUser;
import com.sapolar.common.dto.ApiResponse;
import com.sapolar.common.dto.PagedResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/expense-receipts")
@RequiredArgsConstructor
public class ExpenseReceiptController {
private final ExpenseReceiptService expenseReceiptService;
@GetMapping
public ResponseEntity<ApiResponse<PagedResponse<ExpenseReceipt>>> findAll(
@RequestParam(required = false) Long propertyId,
@RequestParam(required = false) Long templateId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "id") String sort,
@RequestParam(defaultValue = "asc") String dir) {
Sort sorting = dir.equalsIgnoreCase("desc") ? Sort.by(sort).descending() : Sort.by(sort).ascending();
Pageable pageable = PageRequest.of(page, size, sorting);
if (propertyId != null) {
List<ExpenseReceipt> list = expenseReceiptService.findByProperty(propertyId);
return ResponseEntity.ok(ApiResponse.success(new PagedResponse<>(list, 0, list.size(), list.size(), 1, true)));
}
if (templateId != null) {
List<ExpenseReceipt> list = expenseReceiptService.findByTemplate(templateId);
return ResponseEntity.ok(ApiResponse.success(new PagedResponse<>(list, 0, list.size(), list.size(), 1, true)));
}
return ResponseEntity.ok(ApiResponse.success(PagedResponse.from(expenseReceiptService.findAll(pageable))));
}
@GetMapping("/pending")
public ResponseEntity<ApiResponse<List<ExpenseReceipt>>> getPending() {
return ResponseEntity.ok(ApiResponse.success(expenseReceiptService.findPending()));
}
@GetMapping("/pending/count")
public ResponseEntity<ApiResponse<Map<String, Long>>> getPendingCount() {
long total = expenseReceiptService.countPending();
long variable = expenseReceiptService.findPendingVariable().size();
return ResponseEntity.ok(ApiResponse.success(Map.of("total", total, "variable", variable)));
}
@GetMapping("/pending/variable")
public ResponseEntity<ApiResponse<List<ExpenseReceipt>>> getPendingVariable() {
return ResponseEntity.ok(ApiResponse.success(expenseReceiptService.findPendingVariable()));
}
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<ExpenseReceipt>> findById(@PathVariable Long id) {
return ResponseEntity.ok(ApiResponse.success(expenseReceiptService.findById(id)));
}
@PostMapping
public ResponseEntity<ApiResponse<ExpenseReceipt>> createManual(@Valid @RequestBody ExpenseReceipt receipt,
@AuthenticationPrincipal SecurityUser user) {
ExpenseReceipt data = receipt;
return ResponseEntity.ok(ApiResponse.success("Recibo de gasto creado",
expenseReceiptService.createManual(data, user.userId())));
}
@PutMapping("/{id}")
public ResponseEntity<ApiResponse<ExpenseReceipt>> update(@PathVariable Long id,
@Valid @RequestBody ExpenseReceipt receipt) {
ExpenseReceipt data = receipt;
return ResponseEntity.ok(ApiResponse.success("Recibo de gasto actualizado",
expenseReceiptService.update(id, data)));
}
@PatchMapping("/{id}/pay")
public ResponseEntity<ApiResponse<ExpenseReceipt>> registerPayment(@PathVariable Long id,
@RequestBody Map<String, String> body) {
String paymentDateStr = body.get("paymentDate");
if (paymentDateStr == null) {
paymentDateStr = LocalDate.now().toString();
}
String paymentMethodStr = body.getOrDefault("paymentMethod", "TRANSFERENCIA");
return ResponseEntity.ok(ApiResponse.success("Pago registrado",
expenseReceiptService.registerPayment(id, paymentDateStr, paymentMethodStr)));
}
@PatchMapping("/{id}/amount")
public ResponseEntity<ApiResponse<ExpenseReceipt>> updateAmount(@PathVariable Long id,
@RequestBody Map<String, Object> body) {
BigDecimal amount = body.containsKey("amount")
? new BigDecimal(body.get("amount").toString())
: BigDecimal.ZERO;
return ResponseEntity.ok(ApiResponse.success("Importe actualizado",
expenseReceiptService.updateAmount(id, amount)));
}
@DeleteMapping("/{id}")
public ResponseEntity<ApiResponse<Void>> delete(@PathVariable Long id) {
expenseReceiptService.delete(id);
return ResponseEntity.ok(ApiResponse.success("Recibo de gasto eliminado", null));
}
}
@@ -0,0 +1,43 @@
package com.sapolar.finance.expense;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
@Repository
public interface ExpenseReceiptRepository extends JpaRepository<ExpenseReceipt, Long> {
List<ExpenseReceipt> findByTemplateId(Long templateId);
List<ExpenseReceipt> findByPropertyId(Long propertyId);
List<ExpenseReceipt> findByStatusId(Integer statusId);
List<ExpenseReceipt> findByTemplateIdAndIssueDateBetween(Long templateId, LocalDate start, LocalDate end);
@Query("SELECT COALESCE(SUM(e.totalAmount), 0) FROM ExpenseReceipt e WHERE e.property.id = :propertyId " +
"AND e.status.name = 'PAGADO' AND e.issueDate BETWEEN :start AND :end")
BigDecimal sumPaidByPropertyBetween(@Param("propertyId") Long propertyId, @Param("start") LocalDate start, @Param("end") LocalDate end);
@Query("SELECT COALESCE(SUM(e.totalAmount), 0) FROM ExpenseReceipt e WHERE e.status.name = 'PAGADO' " +
"AND e.issueDate BETWEEN :start AND :end")
BigDecimal sumPaidBetween(@Param("start") LocalDate start, @Param("end") LocalDate end);
long countByStatusId(Integer statusId);
Optional<ExpenseReceipt> findTopByTemplateIdOrderByIssueDateDesc(Long templateId);
@Query("SELECT e FROM ExpenseReceipt e JOIN FETCH e.category " +
"WHERE e.status.name IN ('PENDIENTE', 'VENCIDO') ORDER BY e.dueDate ASC")
List<ExpenseReceipt> findPendingOrderByDueDate();
@Query("SELECT c.name, COALESCE(SUM(e.totalAmount), 0) FROM ExpenseReceipt e JOIN e.category c " +
"WHERE YEAR(e.issueDate) = :year AND e.status.name = 'PAGADO' " +
"GROUP BY c.name ORDER BY SUM(e.totalAmount) DESC")
List<Object[]> sumByCategoryForYear(@Param("year") int year);
}
@@ -0,0 +1,261 @@
package com.sapolar.finance.expense;
import com.sapolar.common.exception.BadRequestException;
import com.sapolar.common.exception.ResourceNotFoundException;
import com.sapolar.finance.bank.BankAccount;
import com.sapolar.finance.bank.BankAccountRepository;
import com.sapolar.property.Property;
import com.sapolar.property.PropertyGroup;
import com.sapolar.property.PropertyGroupRepository;
import com.sapolar.property.PropertyRepository;
import com.sapolar.user.User;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class ExpenseReceiptService {
private final ExpenseReceiptRepository expenseReceiptRepository;
private final ExpenseTemplateRepository expenseTemplateRepository;
private final PropertyRepository propertyRepository;
private final PropertyGroupRepository propertyGroupRepository;
private final BankAccountRepository bankAccountRepository;
private final ExpenseCategoryRepository expenseCategoryRepository;
public List<ExpenseReceipt> findAll() {
return expenseReceiptRepository.findAll();
}
public Page<ExpenseReceipt> findAll(Pageable pageable) {
return expenseReceiptRepository.findAll(pageable);
}
public ExpenseReceipt findById(Long id) {
return expenseReceiptRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Recibo de gasto", id));
}
public List<ExpenseReceipt> findByProperty(Long propertyId) {
return expenseReceiptRepository.findByPropertyId(propertyId);
}
public List<ExpenseReceipt> findByTemplate(Long templateId) {
return expenseReceiptRepository.findByTemplateId(templateId);
}
public List<ExpenseReceipt> findPending() {
return expenseReceiptRepository.findByStatusId(1);
}
public long countPending() {
return expenseReceiptRepository.countByStatusId(1);
}
public List<ExpenseReceipt> findPendingVariable() {
return expenseReceiptRepository.findAll().stream()
.filter(r -> Boolean.TRUE.equals(r.getIsVariable()))
.filter(r -> r.getAmount() == null || r.getAmount().compareTo(BigDecimal.ZERO) == 0)
.collect(Collectors.toList());
}
@Transactional
public ExpenseReceipt createFromTemplate(ExpenseTemplate template, LocalDate issueDate, Long userId) {
ExpenseReceipt receipt = new ExpenseReceipt();
receipt.setTemplate(template);
receipt.setProperty(template.getProperty());
receipt.setPropertyGroup(template.getPropertyGroup());
receipt.setBankAccount(template.getBankAccount());
receipt.setIsDomiciled(template.getIsDomiciled());
receipt.setCategory(template.getCategory());
receipt.setSupplierName(template.getSupplierName());
receipt.setSupplierFiscalId(template.getSupplierFiscalId());
receipt.setDescription(template.getDescription());
receipt.setNotes(template.getNotes());
receipt.setIssueDate(issueDate);
receipt.setDueDate(issueDate.plusDays(30));
if (Boolean.TRUE.equals(template.getIsVariable())) {
receipt.setIsVariable(true);
receipt.setAmount(BigDecimal.ZERO);
receipt.setTaxAmount(BigDecimal.ZERO);
receipt.setTotalAmount(BigDecimal.ZERO);
expenseReceiptRepository.findTopByTemplateIdOrderByIssueDateDesc(template.getId())
.ifPresent(last -> receipt.setPreviousAmount(last.getAmount()));
} else {
receipt.setIsVariable(false);
receipt.setAmount(template.getAmount());
receipt.setTaxAmount(template.getTaxAmount() != null ? template.getTaxAmount() : BigDecimal.ZERO);
receipt.setTotalAmount(receipt.getAmount().add(receipt.getTaxAmount()));
}
ExpenseStatus status = new ExpenseStatus();
status.setId(1);
receipt.setStatus(status);
User user = new User();
user.setId(userId);
receipt.setCreatedBy(user);
return expenseReceiptRepository.save(receipt);
}
@Transactional
public ExpenseReceipt createManual(ExpenseReceipt receipt, Long userId) {
if (receipt.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
throw new BadRequestException("El importe debe ser mayor que cero");
}
if (receipt.getProperty() != null && receipt.getProperty().getId() != null) {
Property property = propertyRepository.findById(receipt.getProperty().getId())
.orElseThrow(() -> new ResourceNotFoundException("Propiedad", receipt.getProperty().getId()));
receipt.setProperty(property);
}
if (receipt.getPropertyGroup() != null && receipt.getPropertyGroup().getId() != null) {
PropertyGroup group = propertyGroupRepository.findById(receipt.getPropertyGroup().getId())
.orElseThrow(() -> new ResourceNotFoundException("Conjunto", receipt.getPropertyGroup().getId()));
receipt.setPropertyGroup(group);
}
if (receipt.getBankAccount() != null && receipt.getBankAccount().getId() != null) {
BankAccount bank = bankAccountRepository.findById(receipt.getBankAccount().getId())
.orElseThrow(() -> new ResourceNotFoundException("Cuenta bancaria", receipt.getBankAccount().getId()));
receipt.setBankAccount(bank);
}
if (receipt.getCategory() != null && receipt.getCategory().getId() != null) {
ExpenseCategory category = expenseCategoryRepository.findById(receipt.getCategory().getId())
.orElseThrow(() -> new ResourceNotFoundException("Categoría de gasto", receipt.getCategory().getId()));
receipt.setCategory(category);
}
if (receipt.getTotalAmount() == null) {
BigDecimal tax = receipt.getTaxAmount() != null ? receipt.getTaxAmount() : BigDecimal.ZERO;
receipt.setTotalAmount(receipt.getAmount().add(tax));
}
if (receipt.getStatus() == null) {
ExpenseStatus status = new ExpenseStatus();
status.setId(1);
receipt.setStatus(status);
}
User user = new User();
user.setId(userId);
receipt.setCreatedBy(user);
return expenseReceiptRepository.save(receipt);
}
@Transactional
public ExpenseReceipt update(Long id, ExpenseReceipt updated) {
ExpenseReceipt receipt = findById(id);
if (updated.getTemplate() != null && updated.getTemplate().getId() != null) {
ExpenseTemplate template = expenseTemplateRepository.findById(updated.getTemplate().getId())
.orElseThrow(() -> new ResourceNotFoundException("Plantilla de gasto", updated.getTemplate().getId()));
receipt.setTemplate(template);
} else {
receipt.setTemplate(null);
}
if (updated.getProperty() != null && updated.getProperty().getId() != null) {
Property property = propertyRepository.findById(updated.getProperty().getId())
.orElseThrow(() -> new ResourceNotFoundException("Propiedad", updated.getProperty().getId()));
receipt.setProperty(property);
} else {
receipt.setProperty(null);
}
if (updated.getPropertyGroup() != null && updated.getPropertyGroup().getId() != null) {
PropertyGroup group = propertyGroupRepository.findById(updated.getPropertyGroup().getId())
.orElseThrow(() -> new ResourceNotFoundException("Conjunto", updated.getPropertyGroup().getId()));
receipt.setPropertyGroup(group);
} else {
receipt.setPropertyGroup(null);
}
if (updated.getBankAccount() != null && updated.getBankAccount().getId() != null) {
BankAccount bank = bankAccountRepository.findById(updated.getBankAccount().getId())
.orElseThrow(() -> new ResourceNotFoundException("Cuenta bancaria", updated.getBankAccount().getId()));
receipt.setBankAccount(bank);
} else {
receipt.setBankAccount(null);
}
receipt.setIsDomiciled(updated.getIsDomiciled());
if (updated.getCategory() != null && updated.getCategory().getId() != null) {
ExpenseCategory category = expenseCategoryRepository.findById(updated.getCategory().getId())
.orElseThrow(() -> new ResourceNotFoundException("Categoría de gasto", updated.getCategory().getId()));
receipt.setCategory(category);
} else {
receipt.setCategory(null);
}
if (updated.getStatus() != null && updated.getStatus().getId() != null) {
ExpenseStatus status = new ExpenseStatus();
status.setId(updated.getStatus().getId());
receipt.setStatus(status);
}
receipt.setSupplierName(updated.getSupplierName());
receipt.setSupplierFiscalId(updated.getSupplierFiscalId());
receipt.setInvoiceNumber(updated.getInvoiceNumber());
receipt.setAmount(updated.getAmount());
receipt.setTaxAmount(updated.getTaxAmount());
receipt.setTotalAmount(updated.getTotalAmount());
receipt.setIsVariable(updated.getIsVariable());
receipt.setPreviousAmount(updated.getPreviousAmount());
receipt.setIssueDate(updated.getIssueDate());
receipt.setDueDate(updated.getDueDate());
receipt.setPaymentDate(updated.getPaymentDate());
receipt.setPaymentMethod(updated.getPaymentMethod());
receipt.setDescription(updated.getDescription());
receipt.setNotes(updated.getNotes());
return expenseReceiptRepository.save(receipt);
}
@Transactional
public ExpenseReceipt registerPayment(Long id, String paymentDate, String paymentMethod) {
ExpenseReceipt receipt = findById(id);
receipt.setPaymentDate(LocalDate.parse(paymentDate));
receipt.setPaymentMethod(paymentMethod);
ExpenseStatus status = new ExpenseStatus();
status.setId(2);
receipt.setStatus(status);
return expenseReceiptRepository.save(receipt);
}
@Transactional
public ExpenseReceipt updateAmount(Long id, BigDecimal amount) {
ExpenseReceipt receipt = findById(id);
if (!Boolean.TRUE.equals(receipt.getIsVariable())) {
throw new BadRequestException("Solo se puede actualizar el importe de recibos variables");
}
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new BadRequestException("El importe debe ser mayor que cero");
}
receipt.setAmount(amount);
BigDecimal tax = receipt.getTaxAmount() != null ? receipt.getTaxAmount() : BigDecimal.ZERO;
receipt.setTotalAmount(amount.add(tax));
return expenseReceiptRepository.save(receipt);
}
@Transactional
public void delete(Long id) {
ExpenseReceipt receipt = findById(id);
expenseReceiptRepository.delete(receipt);
}
}
@@ -0,0 +1,19 @@
package com.sapolar.finance.expense;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Entity
@Table(name = "expense_statuses")
public class ExpenseStatus {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(nullable = false, unique = true, length = 30)
private String name;
}
@@ -0,0 +1,104 @@
package com.sapolar.finance.expense;
import com.sapolar.contract.PaymentPeriod;
import com.sapolar.finance.bank.BankAccount;
import com.sapolar.property.Property;
import com.sapolar.property.PropertyGroup;
import com.sapolar.user.User;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "expense_templates", indexes = {
@Index(name = "idx_expense_templates_property", columnList = "property_id"),
@Index(name = "idx_expense_templates_property_group", columnList = "property_group_id"),
@Index(name = "idx_expense_templates_category", columnList = "category_id"),
@Index(name = "idx_expense_templates_period", columnList = "period_id")
})
public class ExpenseTemplate {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "property_id")
private Property property;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "property_group_id")
private PropertyGroup propertyGroup;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "bank_account_id")
private BankAccount bankAccount;
@Column(name = "is_domiciled", nullable = false)
private Boolean isDomiciled = false;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id")
private ExpenseCategory category;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "period_id", nullable = false)
private PaymentPeriod period;
@Column(name = "payment_day", nullable = false)
private Integer paymentDay;
@Column(name = "supplier_name", length = 200)
private String supplierName;
@Column(name = "supplier_fiscal_id", length = 20)
private String supplierFiscalId;
@Column(precision = 12, scale = 2)
private BigDecimal amount;
@Column(name = "tax_amount", precision = 12, scale = 2)
private BigDecimal taxAmount = BigDecimal.ZERO;
@Column(nullable = false, length = 500)
private String description;
@Column(columnDefinition = "TEXT")
private String notes;
@Column(name = "is_variable", nullable = false)
private Boolean isVariable = false;
@Column(nullable = false)
private Boolean active = true;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "created_by")
private User createdBy;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}
@@ -0,0 +1,66 @@
package com.sapolar.finance.expense;
import com.sapolar.auth.SecurityUser;
import com.sapolar.common.dto.ApiResponse;
import com.sapolar.common.dto.PagedResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/expense-templates")
@RequiredArgsConstructor
public class ExpenseTemplateController {
private final ExpenseTemplateService expenseTemplateService;
@GetMapping
public ResponseEntity<ApiResponse<PagedResponse<ExpenseTemplate>>> findAll(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "id") String sort,
@RequestParam(defaultValue = "asc") String dir) {
List<ExpenseTemplate> list = expenseTemplateService.findAll();
return ResponseEntity.ok(ApiResponse.success(new PagedResponse<>(list, 0, list.size(), list.size(), 1, true)));
}
@GetMapping("/active")
public ResponseEntity<ApiResponse<List<ExpenseTemplate>>> findActive() {
return ResponseEntity.ok(ApiResponse.success(expenseTemplateService.findActive()));
}
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<ExpenseTemplate>> findById(@PathVariable Long id) {
return ResponseEntity.ok(ApiResponse.success(expenseTemplateService.findById(id)));
}
@PostMapping
public ResponseEntity<ApiResponse<ExpenseTemplate>> create(@Valid @RequestBody ExpenseTemplate template,
@AuthenticationPrincipal SecurityUser user) {
return ResponseEntity.ok(ApiResponse.success("Plantilla de gasto creada",
expenseTemplateService.create(template, user.userId())));
}
@PutMapping("/{id}")
public ResponseEntity<ApiResponse<ExpenseTemplate>> update(@PathVariable Long id,
@Valid @RequestBody ExpenseTemplate template) {
return ResponseEntity.ok(ApiResponse.success("Plantilla de gasto actualizada",
expenseTemplateService.update(id, template)));
}
@PatchMapping("/{id}/toggle-active")
public ResponseEntity<ApiResponse<ExpenseTemplate>> toggleActive(@PathVariable Long id) {
return ResponseEntity.ok(ApiResponse.success("Estado de plantilla cambiado",
expenseTemplateService.toggleActive(id)));
}
@DeleteMapping("/{id}")
public ResponseEntity<ApiResponse<Void>> delete(@PathVariable Long id) {
expenseTemplateService.delete(id);
return ResponseEntity.ok(ApiResponse.success("Plantilla de gasto eliminada", null));
}
}
@@ -0,0 +1,11 @@
package com.sapolar.finance.expense;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface ExpenseTemplateRepository extends JpaRepository<ExpenseTemplate, Long> {
List<ExpenseTemplate> findByActiveTrue();
}
@@ -0,0 +1,150 @@
package com.sapolar.finance.expense;
import com.sapolar.common.exception.ResourceNotFoundException;
import com.sapolar.contract.PaymentPeriod;
import com.sapolar.contract.PaymentPeriodRepository;
import com.sapolar.finance.bank.BankAccount;
import com.sapolar.finance.bank.BankAccountRepository;
import com.sapolar.property.Property;
import com.sapolar.property.PropertyGroup;
import com.sapolar.property.PropertyGroupRepository;
import com.sapolar.property.PropertyRepository;
import com.sapolar.user.User;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@RequiredArgsConstructor
public class ExpenseTemplateService {
private final ExpenseTemplateRepository expenseTemplateRepository;
private final PropertyRepository propertyRepository;
private final PropertyGroupRepository propertyGroupRepository;
private final BankAccountRepository bankAccountRepository;
private final ExpenseCategoryRepository expenseCategoryRepository;
private final PaymentPeriodRepository paymentPeriodRepository;
public List<ExpenseTemplate> findAll() {
return expenseTemplateRepository.findAll();
}
public ExpenseTemplate findById(Long id) {
return expenseTemplateRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Plantilla de gasto", id));
}
public Page<ExpenseTemplate> findAll(Pageable pageable) {
return expenseTemplateRepository.findAll(pageable);
}
public List<ExpenseTemplate> findActive() {
return expenseTemplateRepository.findByActiveTrue();
}
@Transactional
public ExpenseTemplate create(ExpenseTemplate template, Long userId) {
if (template.getProperty() != null && template.getProperty().getId() != null) {
Property property = propertyRepository.findById(template.getProperty().getId())
.orElseThrow(() -> new ResourceNotFoundException("Propiedad", template.getProperty().getId()));
template.setProperty(property);
}
if (template.getPropertyGroup() != null && template.getPropertyGroup().getId() != null) {
PropertyGroup group = propertyGroupRepository.findById(template.getPropertyGroup().getId())
.orElseThrow(() -> new ResourceNotFoundException("Conjunto", template.getPropertyGroup().getId()));
template.setPropertyGroup(group);
}
if (template.getBankAccount() != null && template.getBankAccount().getId() != null) {
BankAccount bank = bankAccountRepository.findById(template.getBankAccount().getId())
.orElseThrow(() -> new ResourceNotFoundException("Cuenta bancaria", template.getBankAccount().getId()));
template.setBankAccount(bank);
}
if (template.getCategory() != null && template.getCategory().getId() != null) {
ExpenseCategory category = expenseCategoryRepository.findById(template.getCategory().getId())
.orElseThrow(() -> new ResourceNotFoundException("Categoría de gasto", template.getCategory().getId()));
template.setCategory(category);
}
if (template.getPeriod() != null && template.getPeriod().getId() != null) {
PaymentPeriod period = paymentPeriodRepository.findById(template.getPeriod().getId())
.orElseThrow(() -> new ResourceNotFoundException("Periodo no encontrado con id: " + template.getPeriod().getId()));
template.setPeriod(period);
}
User user = new User();
user.setId(userId);
template.setCreatedBy(user);
return expenseTemplateRepository.save(template);
}
@Transactional
public ExpenseTemplate update(Long id, ExpenseTemplate updated) {
ExpenseTemplate template = findById(id);
if (updated.getProperty() != null && updated.getProperty().getId() != null) {
Property property = propertyRepository.findById(updated.getProperty().getId())
.orElseThrow(() -> new ResourceNotFoundException("Propiedad", updated.getProperty().getId()));
template.setProperty(property);
} else {
template.setProperty(null);
}
if (updated.getPropertyGroup() != null && updated.getPropertyGroup().getId() != null) {
PropertyGroup group = propertyGroupRepository.findById(updated.getPropertyGroup().getId())
.orElseThrow(() -> new ResourceNotFoundException("Conjunto", updated.getPropertyGroup().getId()));
template.setPropertyGroup(group);
} else {
template.setPropertyGroup(null);
}
if (updated.getBankAccount() != null && updated.getBankAccount().getId() != null) {
BankAccount bank = bankAccountRepository.findById(updated.getBankAccount().getId())
.orElseThrow(() -> new ResourceNotFoundException("Cuenta bancaria", updated.getBankAccount().getId()));
template.setBankAccount(bank);
} else {
template.setBankAccount(null);
}
template.setIsDomiciled(updated.getIsDomiciled());
if (updated.getCategory() != null && updated.getCategory().getId() != null) {
ExpenseCategory category = expenseCategoryRepository.findById(updated.getCategory().getId())
.orElseThrow(() -> new ResourceNotFoundException("Categoría de gasto", updated.getCategory().getId()));
template.setCategory(category);
} else {
template.setCategory(null);
}
if (updated.getPeriod() != null && updated.getPeriod().getId() != null) {
PaymentPeriod period = paymentPeriodRepository.findById(updated.getPeriod().getId())
.orElseThrow(() -> new ResourceNotFoundException("Periodo no encontrado con id: " + updated.getPeriod().getId()));
template.setPeriod(period);
}
template.setPaymentDay(updated.getPaymentDay());
template.setSupplierName(updated.getSupplierName());
template.setSupplierFiscalId(updated.getSupplierFiscalId());
template.setAmount(updated.getAmount());
template.setTaxAmount(updated.getTaxAmount());
template.setDescription(updated.getDescription());
template.setNotes(updated.getNotes());
template.setIsVariable(updated.getIsVariable());
template.setActive(updated.getActive());
return expenseTemplateRepository.save(template);
}
@Transactional
public void delete(Long id) {
ExpenseTemplate template = findById(id);
template.setActive(false);
expenseTemplateRepository.save(template);
}
@Transactional
public ExpenseTemplate toggleActive(Long id) {
ExpenseTemplate template = findById(id);
template.setActive(!template.getActive());
return expenseTemplateRepository.save(template);
}
}
@@ -0,0 +1,25 @@
package com.sapolar.finance.income;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Entity
@Table(name = "income_categories")
public class IncomeCategory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 100)
private String name;
@Column(length = 255)
private String description;
@Column(nullable = false)
private Boolean active = true;
}
@@ -0,0 +1,8 @@
package com.sapolar.finance.income;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface IncomeCategoryRepository extends JpaRepository<IncomeCategory, Long> {
}
@@ -0,0 +1,117 @@
package com.sapolar.finance.income;
import com.sapolar.contract.Contract;
import com.sapolar.finance.bank.BankAccount;
import com.sapolar.property.Property;
import com.sapolar.tenant.Tenant;
import com.sapolar.user.User;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "income_receipts", indexes = {
@Index(name = "idx_income_receipts_property", columnList = "property_id"),
@Index(name = "idx_income_receipts_contract", columnList = "contract_id"),
@Index(name = "idx_income_receipts_tenant", columnList = "tenant_id"),
@Index(name = "idx_income_receipts_status", columnList = "status_id"),
@Index(name = "idx_income_receipts_period_label", columnList = "period_label"),
@Index(name = "idx_income_receipts_issue_date", columnList = "issue_date")
})
public class IncomeReceipt {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "contract_id")
private Contract contract;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "property_id", nullable = false)
private Property property;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "tenant_id")
private Tenant tenant;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "bank_account_id")
private BankAccount bankAccount;
@Column(name = "is_domiciled", nullable = false)
private Boolean isDomiciled = false;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id")
private IncomeCategory category;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "status_id", nullable = false)
private IncomeStatus status;
@Column(name = "period_label", length = 20)
private String periodLabel;
@Column(nullable = false, precision = 12, scale = 2)
private BigDecimal amount;
@Column(name = "tax_withheld", precision = 12, scale = 2)
private BigDecimal taxWithheld = BigDecimal.ZERO;
@Column(name = "net_amount", precision = 12, scale = 2)
private BigDecimal netAmount;
@Column(name = "issue_date", nullable = false)
private LocalDate issueDate;
@Column(name = "due_date")
private LocalDate dueDate;
@Column(name = "payment_date")
private LocalDate paymentDate;
@Column(name = "payment_method", length = 30)
private String paymentMethod;
@Column(length = 500)
private String description;
@Column(name = "receipt_number", length = 50)
private String receiptNumber;
@Column(columnDefinition = "TEXT")
private String notes;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "created_by")
private User createdBy;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}
@@ -0,0 +1,98 @@
package com.sapolar.finance.income;
import com.sapolar.auth.SecurityUser;
import com.sapolar.common.dto.ApiResponse;
import com.sapolar.common.dto.PagedResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/income-receipts")
@RequiredArgsConstructor
public class IncomeReceiptController {
private final IncomeReceiptService incomeReceiptService;
@GetMapping
public ResponseEntity<ApiResponse<PagedResponse<IncomeReceipt>>> findAll(
@RequestParam(required = false) Long contractId,
@RequestParam(required = false) Long propertyId,
@RequestParam(required = false) String periodLabel,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "id") String sort,
@RequestParam(defaultValue = "asc") String dir) {
Sort sorting = dir.equalsIgnoreCase("desc") ? Sort.by(sort).descending() : Sort.by(sort).ascending();
Pageable pageable = PageRequest.of(page, size, sorting);
if (contractId != null) {
List<IncomeReceipt> list = incomeReceiptService.findByContract(contractId);
return ResponseEntity.ok(ApiResponse.success(new PagedResponse<>(list, 0, list.size(), list.size(), 1, true)));
}
if (propertyId != null) {
List<IncomeReceipt> list = incomeReceiptService.findByProperty(propertyId);
return ResponseEntity.ok(ApiResponse.success(new PagedResponse<>(list, 0, list.size(), list.size(), 1, true)));
}
if (periodLabel != null) {
List<IncomeReceipt> list = incomeReceiptService.findByPeriodLabel(periodLabel);
return ResponseEntity.ok(ApiResponse.success(new PagedResponse<>(list, 0, list.size(), list.size(), 1, true)));
}
return ResponseEntity.ok(ApiResponse.success(PagedResponse.from(incomeReceiptService.findAll(pageable))));
}
@GetMapping("/pending")
public ResponseEntity<ApiResponse<List<IncomeReceipt>>> getPending() {
return ResponseEntity.ok(ApiResponse.success(incomeReceiptService.findPending()));
}
@GetMapping("/pending/count")
public ResponseEntity<ApiResponse<Long>> getPendingCount() {
return ResponseEntity.ok(ApiResponse.success(incomeReceiptService.countPending()));
}
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<IncomeReceipt>> findById(@PathVariable Long id) {
return ResponseEntity.ok(ApiResponse.success(incomeReceiptService.findById(id)));
}
@PostMapping
public ResponseEntity<ApiResponse<IncomeReceipt>> create(@Valid @RequestBody IncomeReceipt receipt,
@AuthenticationPrincipal SecurityUser user) {
return ResponseEntity.ok(ApiResponse.success("Recibo de ingreso creado",
incomeReceiptService.create(receipt, user.userId())));
}
@PutMapping("/{id}")
public ResponseEntity<ApiResponse<IncomeReceipt>> update(@PathVariable Long id,
@Valid @RequestBody IncomeReceipt receipt) {
return ResponseEntity.ok(ApiResponse.success("Recibo de ingreso actualizado",
incomeReceiptService.update(id, receipt)));
}
@PatchMapping("/{id}/pay")
public ResponseEntity<ApiResponse<IncomeReceipt>> registerPayment(@PathVariable Long id,
@RequestBody Map<String, String> body) {
String paymentDateStr = body.get("paymentDate");
if (paymentDateStr == null) {
paymentDateStr = LocalDate.now().toString();
}
String paymentMethodStr = body.getOrDefault("paymentMethod", "TRANSFERENCIA");
return ResponseEntity.ok(ApiResponse.success("Pago registrado",
incomeReceiptService.registerPayment(id, paymentDateStr, paymentMethodStr)));
}
@DeleteMapping("/{id}")
public ResponseEntity<ApiResponse<Void>> delete(@PathVariable Long id) {
incomeReceiptService.delete(id);
return ResponseEntity.ok(ApiResponse.success("Recibo de ingreso eliminado", null));
}
}
@@ -0,0 +1,43 @@
package com.sapolar.finance.income;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
@Repository
public interface IncomeReceiptRepository extends JpaRepository<IncomeReceipt, Long> {
List<IncomeReceipt> findByContractId(Long contractId);
List<IncomeReceipt> findByPropertyId(Long propertyId);
List<IncomeReceipt> findByStatusId(Integer statusId);
Optional<IncomeReceipt> findByContractIdAndPeriodLabel(Long contractId, String periodLabel);
List<IncomeReceipt> findByPeriodLabel(String periodLabel);
@Query("SELECT COALESCE(SUM(r.netAmount), 0) FROM IncomeReceipt r WHERE r.property.id = :propertyId " +
"AND r.status.name = 'PAGADO' AND r.issueDate BETWEEN :start AND :end")
BigDecimal sumPaidByPropertyBetween(@Param("propertyId") Long propertyId, @Param("start") LocalDate start, @Param("end") LocalDate end);
@Query("SELECT COALESCE(SUM(r.netAmount), 0) FROM IncomeReceipt r WHERE r.status.name = 'PAGADO' " +
"AND r.issueDate BETWEEN :start AND :end")
BigDecimal sumPaidBetween(@Param("start") LocalDate start, @Param("end") LocalDate end);
long countByStatusId(Integer statusId);
@Query("SELECT r FROM IncomeReceipt r JOIN FETCH r.property p " +
"WHERE r.status.name IN ('PENDIENTE', 'VENCIDO') ORDER BY r.dueDate ASC")
List<IncomeReceipt> findPendingOrderByDueDate();
@Query("SELECT c.name, COALESCE(SUM(r.netAmount), 0) FROM IncomeReceipt r JOIN r.category c " +
"WHERE YEAR(r.issueDate) = :year AND r.status.name IN ('PAGADO', 'PARCIAL') " +
"GROUP BY c.name ORDER BY SUM(r.netAmount) DESC")
List<Object[]> sumByCategoryForYear(@Param("year") int year);
}
@@ -0,0 +1,206 @@
package com.sapolar.finance.income;
import com.sapolar.common.exception.BadRequestException;
import com.sapolar.common.exception.ResourceNotFoundException;
import com.sapolar.contract.Contract;
import com.sapolar.contract.ContractRepository;
import com.sapolar.finance.bank.BankAccount;
import com.sapolar.finance.bank.BankAccountRepository;
import com.sapolar.property.Property;
import com.sapolar.property.PropertyRepository;
import com.sapolar.tenant.Tenant;
import com.sapolar.tenant.TenantRepository;
import com.sapolar.user.User;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
@Service
@RequiredArgsConstructor
public class IncomeReceiptService {
private final IncomeReceiptRepository incomeReceiptRepository;
private final ContractRepository contractRepository;
private final PropertyRepository propertyRepository;
private final TenantRepository tenantRepository;
private final BankAccountRepository bankAccountRepository;
private final IncomeCategoryRepository incomeCategoryRepository;
public List<IncomeReceipt> findAll() {
return incomeReceiptRepository.findAll();
}
public Page<IncomeReceipt> findAll(Pageable pageable) {
return incomeReceiptRepository.findAll(pageable);
}
public IncomeReceipt findById(Long id) {
return incomeReceiptRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Recibo de ingreso", id));
}
public List<IncomeReceipt> findByContract(Long contractId) {
return incomeReceiptRepository.findByContractId(contractId);
}
public List<IncomeReceipt> findByProperty(Long propertyId) {
return incomeReceiptRepository.findByPropertyId(propertyId);
}
public List<IncomeReceipt> findByPeriodLabel(String periodLabel) {
return incomeReceiptRepository.findByPeriodLabel(periodLabel);
}
public List<IncomeReceipt> findPending() {
return incomeReceiptRepository.findByStatusId(1);
}
public long countPending() {
return incomeReceiptRepository.countByStatusId(1);
}
@Transactional
public IncomeReceipt create(IncomeReceipt receipt, Long userId) {
if (receipt.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
throw new BadRequestException("El importe debe ser mayor que cero");
}
Contract contract = null;
if (receipt.getContract() != null && receipt.getContract().getId() != null) {
contract = contractRepository.findById(receipt.getContract().getId())
.orElseThrow(() -> new ResourceNotFoundException("Contrato", receipt.getContract().getId()));
receipt.setContract(contract);
}
if (receipt.getProperty() != null && receipt.getProperty().getId() != null) {
Property property = propertyRepository.findById(receipt.getProperty().getId())
.orElseThrow(() -> new ResourceNotFoundException("Propiedad", receipt.getProperty().getId()));
receipt.setProperty(property);
}
if (receipt.getTenant() != null && receipt.getTenant().getId() != null) {
Tenant tenant = tenantRepository.findById(receipt.getTenant().getId())
.orElseThrow(() -> new ResourceNotFoundException("Inquilino", receipt.getTenant().getId()));
receipt.setTenant(tenant);
}
if (receipt.getBankAccount() != null && receipt.getBankAccount().getId() != null) {
BankAccount bank = bankAccountRepository.findById(receipt.getBankAccount().getId())
.orElseThrow(() -> new ResourceNotFoundException("Cuenta bancaria", receipt.getBankAccount().getId()));
receipt.setBankAccount(bank);
}
if (receipt.getCategory() != null && receipt.getCategory().getId() != null) {
IncomeCategory category = incomeCategoryRepository.findById(receipt.getCategory().getId())
.orElseThrow(() -> new ResourceNotFoundException("Categoría de ingreso", receipt.getCategory().getId()));
receipt.setCategory(category);
}
if (receipt.getNetAmount() == null) {
BigDecimal tax = receipt.getTaxWithheld() != null ? receipt.getTaxWithheld() : BigDecimal.ZERO;
receipt.setNetAmount(receipt.getAmount().subtract(tax));
}
if (receipt.getStatus() == null) {
IncomeStatus status = new IncomeStatus();
status.setId(1);
receipt.setStatus(status);
}
User user = new User();
user.setId(userId);
receipt.setCreatedBy(user);
return incomeReceiptRepository.save(receipt);
}
@Transactional
public IncomeReceipt update(Long id, IncomeReceipt updated) {
IncomeReceipt receipt = findById(id);
if (updated.getContract() != null && updated.getContract().getId() != null) {
Contract contract = contractRepository.findById(updated.getContract().getId())
.orElseThrow(() -> new ResourceNotFoundException("Contrato", updated.getContract().getId()));
receipt.setContract(contract);
} else {
receipt.setContract(null);
}
if (updated.getProperty() != null && updated.getProperty().getId() != null) {
Property property = propertyRepository.findById(updated.getProperty().getId())
.orElseThrow(() -> new ResourceNotFoundException("Propiedad", updated.getProperty().getId()));
receipt.setProperty(property);
}
if (updated.getTenant() != null && updated.getTenant().getId() != null) {
Tenant tenant = tenantRepository.findById(updated.getTenant().getId())
.orElseThrow(() -> new ResourceNotFoundException("Inquilino", updated.getTenant().getId()));
receipt.setTenant(tenant);
} else {
receipt.setTenant(null);
}
if (updated.getBankAccount() != null && updated.getBankAccount().getId() != null) {
BankAccount bank = bankAccountRepository.findById(updated.getBankAccount().getId())
.orElseThrow(() -> new ResourceNotFoundException("Cuenta bancaria", updated.getBankAccount().getId()));
receipt.setBankAccount(bank);
} else {
receipt.setBankAccount(null);
}
receipt.setIsDomiciled(updated.getIsDomiciled());
if (updated.getCategory() != null && updated.getCategory().getId() != null) {
IncomeCategory category = incomeCategoryRepository.findById(updated.getCategory().getId())
.orElseThrow(() -> new ResourceNotFoundException("Categoría de ingreso", updated.getCategory().getId()));
receipt.setCategory(category);
} else {
receipt.setCategory(null);
}
if (updated.getStatus() != null && updated.getStatus().getId() != null) {
IncomeStatus status = new IncomeStatus();
status.setId(updated.getStatus().getId());
receipt.setStatus(status);
}
receipt.setPeriodLabel(updated.getPeriodLabel());
receipt.setAmount(updated.getAmount());
receipt.setTaxWithheld(updated.getTaxWithheld());
receipt.setNetAmount(updated.getNetAmount());
receipt.setIssueDate(updated.getIssueDate());
receipt.setDueDate(updated.getDueDate());
receipt.setPaymentDate(updated.getPaymentDate());
receipt.setPaymentMethod(updated.getPaymentMethod());
receipt.setDescription(updated.getDescription());
receipt.setReceiptNumber(updated.getReceiptNumber());
receipt.setNotes(updated.getNotes());
return incomeReceiptRepository.save(receipt);
}
@Transactional
public IncomeReceipt registerPayment(Long id, String paymentDate, String paymentMethod) {
IncomeReceipt receipt = findById(id);
receipt.setPaymentDate(LocalDate.parse(paymentDate));
receipt.setPaymentMethod(paymentMethod);
IncomeStatus status = new IncomeStatus();
status.setId(2);
receipt.setStatus(status);
return incomeReceiptRepository.save(receipt);
}
@Transactional
public void delete(Long id) {
IncomeReceipt receipt = findById(id);
incomeReceiptRepository.delete(receipt);
}
}
@@ -0,0 +1,19 @@
package com.sapolar.finance.income;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Entity
@Table(name = "income_statuses")
public class IncomeStatus {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(nullable = false, unique = true, length = 30)
private String name;
}
@@ -0,0 +1,44 @@
package com.sapolar.finance.receipt;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@Entity
@Table(name = "email_log")
public class EmailLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "income_receipt_id")
private Long incomeReceiptId;
@Column(name = "recipient_email", nullable = false, length = 200)
private String recipientEmail;
@Column(name = "subject", nullable = false, length = 300)
private String subject;
@Column(name = "body", columnDefinition = "TEXT")
private String body;
@Column(name = "success", nullable = false)
private Boolean success = false;
@Column(name = "error_message", columnDefinition = "TEXT")
private String errorMessage;
@Column(name = "sent_at", nullable = false)
private LocalDateTime sentAt;
@PrePersist
protected void onCreate() {
sentAt = LocalDateTime.now();
}
}
@@ -0,0 +1,11 @@
package com.sapolar.finance.receipt;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface EmailLogRepository extends JpaRepository<EmailLog, Long> {
List<EmailLog> findByIncomeReceiptIdOrderBySentAtDesc(Long incomeReceiptId);
}
@@ -0,0 +1,66 @@
package com.sapolar.finance.receipt;
import com.sapolar.finance.income.IncomeReceipt;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class EmailReceiptService {
private final JavaMailSender mailSender;
private final EmailLogRepository emailLogRepository;
@Value("${app.receipt.from-email:noreply@sapolar.com}")
private String fromEmail;
public void sendReceipt(IncomeReceipt income, byte[] pdf, String toEmail) {
EmailLog log = new EmailLog();
log.setIncomeReceiptId(income.getId());
log.setRecipientEmail(toEmail);
log.setSubject("Recibo de alquiler - " + income.getReceiptNumber());
try {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setFrom(fromEmail);
helper.setTo(toEmail);
helper.setSubject(log.getSubject());
String body = buildEmailBody(income);
helper.setText(body, true);
String filename = "recibo_" + (income.getReceiptNumber() != null ? income.getReceiptNumber() : income.getId()) + ".pdf";
helper.addAttachment(filename, () -> new java.io.ByteArrayInputStream(pdf));
mailSender.send(message);
log.setSuccess(true);
log.setBody(body);
} catch (MessagingException e) {
log.setSuccess(false);
log.setErrorMessage(e.getMessage());
}
emailLogRepository.save(log);
}
private String buildEmailBody(IncomeReceipt income) {
return "<html><body>" +
"<h2>SA POLAR - Gestión de Alquileres</h2>" +
"<p>Estimado/a " + (income.getTenant() != null ? income.getTenant().getFullName() : "") + ",</p>" +
"<p>Adjuntamos el recibo de alquiler correspondiente.</p>" +
"<p><strong>Recibo Nº:</strong> " + (income.getReceiptNumber() != null ? income.getReceiptNumber() : "S/N") + "</p>" +
"<p><strong>Propiedad:</strong> " + (income.getProperty() != null ? income.getProperty().getName() : "") + "</p>" +
"<p><strong>Importe:</strong> " + income.getAmount() + " €</p>" +
"<p><strong>Periodo:</strong> " + (income.getDescription() != null ? income.getDescription() : "") + "</p>" +
"<hr/><p><small>Este mensaje se ha generado automáticamente.</small></p>" +
"</body></html>";
}
}
@@ -0,0 +1,116 @@
package com.sapolar.finance.receipt;
import com.itextpdf.kernel.pdf.PdfDocument;
import com.itextpdf.kernel.pdf.PdfWriter;
import com.itextpdf.layout.Document;
import com.itextpdf.layout.element.Cell;
import com.itextpdf.layout.element.Paragraph;
import com.itextpdf.layout.element.Table;
import com.itextpdf.layout.properties.UnitValue;
import com.sapolar.finance.income.IncomeReceipt;
import org.springframework.stereotype.Service;
import java.io.ByteArrayOutputStream;
import java.math.BigDecimal;
import java.text.NumberFormat;
import java.util.Locale;
@Service
public class PdfReceiptService {
public byte[] generatePdf(IncomeReceipt income) {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (PdfDocument pdfDoc = new PdfDocument(new PdfWriter(baos));
Document document = new Document(pdfDoc)) {
document.add(new Paragraph("SA POLAR - GESTIÓN DE ALQUILERES")
.setBold().setFontSize(18));
document.add(new Paragraph("CIF/NIF: B-12345678"));
document.add(new Paragraph("C/ Ejemplo, 123 - 07001 Palma"));
document.add(new Paragraph(""));
document.add(new Paragraph(""));
document.add(new Paragraph("RECIBO Nº: " + (income.getReceiptNumber() != null ? income.getReceiptNumber() : "S/N"))
.setBold().setFontSize(14));
document.add(new Paragraph(""));
document.add(new Paragraph("Fecha de emisión: " + income.getIssueDate()));
document.add(new Paragraph(""));
document.add(new Paragraph(""));
Table infoTable = new Table(UnitValue.createPercentArray(new float[]{30, 70}));
infoTable.addCell(createLabelCell("Inquilino/a:"));
infoTable.addCell(createValueCell(
income.getTenant() != null ? income.getTenant().getFullName() : ""));
infoTable.addCell(createLabelCell("NIF/CIF:"));
infoTable.addCell(createValueCell(
income.getTenant() != null ? income.getTenant().getFiscalId() : ""));
infoTable.addCell(createLabelCell("Propiedad:"));
infoTable.addCell(createValueCell(
income.getProperty() != null ? income.getProperty().getName() : ""));
infoTable.addCell(createLabelCell("Dirección:"));
infoTable.addCell(createValueCell(
income.getProperty() != null ? income.getProperty().getAddressStreet() : ""));
infoTable.addCell(createLabelCell("Período:"));
infoTable.addCell(createValueCell(income.getDescription()));
document.add(infoTable);
document.add(new Paragraph(""));
document.add(new Paragraph(""));
Table amountTable = new Table(UnitValue.createPercentArray(new float[]{70, 30}));
amountTable.addCell(createHeaderCell("Concepto"));
amountTable.addCell(createHeaderCell("Importe"));
String concept = income.getDescription() != null ? income.getDescription() : "Alquiler mensual";
amountTable.addCell(createValueCell(concept));
amountTable.addCell(createValueCell(formatCurrency(income.getAmount())));
if (income.getTaxWithheld() != null && income.getTaxWithheld().compareTo(BigDecimal.ZERO) > 0) {
amountTable.addCell(createLabelCell("Retención IRPF"));
amountTable.addCell(createValueCell("-" + formatCurrency(income.getTaxWithheld())));
}
amountTable.addCell(createHeaderCell("TOTAL"));
amountTable.addCell(createHeaderCell(formatCurrency(
income.getNetAmount() != null ? income.getNetAmount() : income.getAmount())));
document.add(amountTable);
document.add(new Paragraph(""));
document.add(new Paragraph(""));
document.add(new Paragraph("Forma de pago: " +
(income.getPaymentMethod() != null ? income.getPaymentMethod() : "Pendiente")));
if (income.getPaymentDate() != null) {
document.add(new Paragraph("Fecha de pago: " + income.getPaymentDate()));
}
} catch (Exception e) {
throw new RuntimeException("Error al generar el PDF del recibo", e);
}
return baos.toByteArray();
}
private Cell createLabelCell(String text) {
return new Cell().add(new Paragraph(text).setBold())
.setPadding(4);
}
private Cell createValueCell(String text) {
return new Cell().add(new Paragraph(text != null ? text : ""))
.setPadding(4);
}
private Cell createHeaderCell(String text) {
return new Cell().add(new Paragraph(text).setBold())
.setPadding(6);
}
private String formatCurrency(BigDecimal amount) {
if (amount == null) return "0,00 €";
NumberFormat nf = NumberFormat.getNumberInstance(new Locale("es", "ES"));
nf.setMinimumFractionDigits(2);
nf.setMaximumFractionDigits(2);
return nf.format(amount) + "";
}
}
@@ -0,0 +1,88 @@
package com.sapolar.finance.receipt;
import com.sapolar.common.dto.ApiResponse;
import com.sapolar.finance.income.IncomeReceipt;
import com.sapolar.finance.receipt.dto.ReceiptGenerateRequest;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/receipts")
@RequiredArgsConstructor
public class ReceiptController {
private final ReceiptService receiptService;
private final ReportService reportService;
@GetMapping
@Operation(summary = "Listar todos los recibos")
public ResponseEntity<ApiResponse<List<IncomeReceipt>>> findAll() {
return ResponseEntity.ok(ApiResponse.success(receiptService.findAll()));
}
@GetMapping("/{id}")
@Operation(summary = "Obtener recibo por ID")
public ResponseEntity<ApiResponse<IncomeReceipt>> findById(@PathVariable Long id) {
return ResponseEntity.ok(ApiResponse.success(receiptService.findById(id)));
}
@PostMapping("/generate")
@Operation(summary = "Generar un recibo manualmente")
public ResponseEntity<ApiResponse<IncomeReceipt>> generate(@RequestBody ReceiptGenerateRequest request) {
IncomeReceipt income = receiptService.generateReceipt(request);
return ResponseEntity.ok(ApiResponse.success("Recibo generado correctamente", income));
}
@PostMapping("/generate-monthly")
@Operation(summary = "Generar recibos mensuales para todos los contratos activos")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<ApiResponse<Void>> generateMonthly() {
receiptService.generateMonthlyReceipts();
return ResponseEntity.ok(ApiResponse.success("Recibos mensuales generados"));
}
@GetMapping("/{id}/pdf")
@Operation(summary = "Descargar PDF del recibo")
public ResponseEntity<ByteArrayResource> downloadPdf(@PathVariable Long id) {
byte[] pdf = receiptService.generateReceiptPdf(id);
IncomeReceipt income = receiptService.findById(id);
String filename = "recibo_" + (income.getReceiptNumber() != null ? income.getReceiptNumber() : id) + ".pdf";
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
.contentType(MediaType.APPLICATION_PDF)
.contentLength(pdf.length)
.body(new ByteArrayResource(pdf));
}
@PostMapping("/{id}/send-email")
@Operation(summary = "Enviar recibo por email")
public ResponseEntity<ApiResponse<Void>> sendEmail(@PathVariable Long id,
@RequestParam String email) {
receiptService.sendReceiptEmail(id, email);
return ResponseEntity.ok(ApiResponse.success("Recibo enviado por email"));
}
@GetMapping("/reports/monthly")
@Operation(summary = "Descargar informe mensual Excel")
@PreAuthorize("hasAnyRole('ADMIN', 'GERENTE', 'CONTABLE')")
public ResponseEntity<ByteArrayResource> downloadMonthlyReport(
@RequestParam int year, @RequestParam int month) {
byte[] report = reportService.generateMonthlyReport(year, month);
String filename = "informe_" + year + "_" + String.format("%02d", month) + ".xlsx";
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.contentLength(report.length)
.body(new ByteArrayResource(report));
}
}
@@ -0,0 +1,50 @@
package com.sapolar.finance.receipt;
import com.sapolar.contract.Contract;
import com.sapolar.contract.ContractRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.List;
@Slf4j
@Component
@RequiredArgsConstructor
public class ReceiptScheduler {
private final ReceiptService receiptService;
private final ContractRepository contractRepository;
private final EmailReceiptService emailReceiptService;
@Scheduled(cron = "0 0 6 1 * ?")
@Transactional
public void generateMonthlyReceipts() {
log.info("Iniciando generación automática de recibos mensuales...");
receiptService.generateMonthlyReceipts();
log.info("Generación de recibos completada.");
}
@Scheduled(cron = "0 0 2 * * ?")
@Transactional
public void markOverdueIncomes() {
log.info("Iniciando marcado de recibos vencidos...");
receiptService.markOverdueIncomes();
log.info("Marcado de recibos vencidos completado.");
}
@Scheduled(cron = "0 0 7 1 * ?")
@Transactional
public void checkExpiringContracts() {
log.info("Revisando contratos próximos a vencer...");
LocalDate start = LocalDate.now();
LocalDate end = start.plusMonths(1);
List<Contract> expiring = contractRepository.findContractsExpiringBetween(start, end);
if (!expiring.isEmpty()) {
log.warn("Se encontraron {} contratos próximos a vencer", expiring.size());
}
}
}
@@ -0,0 +1,50 @@
package com.sapolar.finance.receipt;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@Entity
@Table(name = "receipt_series")
public class ReceiptSeries {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "series_name", nullable = false, length = 50)
private String seriesName;
@Column(name = "fiscal_year", nullable = false)
private Integer fiscalYear;
@Column(name = "last_number", nullable = false)
private Integer lastNumber = 0;
@Column(name = "prefix", length = 20)
private String prefix;
@Column(name = "active", nullable = false)
private Boolean active = true;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}
@@ -0,0 +1,12 @@
package com.sapolar.finance.receipt;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface ReceiptSeriesRepository extends JpaRepository<ReceiptSeries, Long> {
Optional<ReceiptSeries> findByFiscalYearAndSeriesName(Integer fiscalYear, String seriesName);
Optional<ReceiptSeries> findByFiscalYearAndActiveTrue(Integer fiscalYear);
}
@@ -0,0 +1,191 @@
package com.sapolar.finance.receipt;
import com.sapolar.common.exception.BadRequestException;
import com.sapolar.common.exception.ResourceNotFoundException;
import com.sapolar.contract.Contract;
import com.sapolar.contract.ContractRepository;
import com.sapolar.contract.ContractTenant;
import com.sapolar.contract.ContractTenantRepository;
import com.sapolar.finance.income.*;
import com.sapolar.finance.receipt.dto.ReceiptGenerateRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class ReceiptService {
private final IncomeReceiptRepository incomeReceiptRepository;
private final ContractRepository contractRepository;
private final ContractTenantRepository contractTenantRepository;
private final ReceiptSeriesRepository receiptSeriesRepository;
private final PdfReceiptService pdfReceiptService;
private final EmailReceiptService emailReceiptService;
public List<IncomeReceipt> findAll() {
return incomeReceiptRepository.findAll();
}
public IncomeReceipt findById(Long id) {
return incomeReceiptRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Recibo", id));
}
@Transactional
public IncomeReceipt generateReceipt(ReceiptGenerateRequest request) {
Contract contract = contractRepository.findById(request.getContractId())
.orElseThrow(() -> new ResourceNotFoundException("Contrato", request.getContractId()));
if (!"ACTIVO".equals(contract.getStatus().getName())) {
throw new BadRequestException("El contrato no está activo");
}
IncomeReceipt income = new IncomeReceipt();
income.setContract(contract);
income.setProperty(contract.getProperty());
List<ContractTenant> cts = contractTenantRepository.findByContractId(contract.getId());
income.setTenant(cts.stream().filter(ct -> "TITULAR".equals(ct.getRole()))
.findFirst().map(ContractTenant::getTenant)
.orElseGet(() -> cts.isEmpty() ? null : cts.get(0).getTenant()));
IncomeCategory defaultCategory = new IncomeCategory();
defaultCategory.setId(1L);
income.setCategory(defaultCategory);
IncomeStatus pendingStatus = new IncomeStatus();
pendingStatus.setId(1);
income.setStatus(pendingStatus);
income.setAmount(contract.getRentalAmount());
income.setNetAmount(contract.getRentalAmount());
income.setIssueDate(request.getIssueDate() != null ? request.getIssueDate() : LocalDate.now());
income.setDueDate(request.getDueDate() != null ? request.getDueDate()
: income.getIssueDate().plusDays(30));
income.setReceiptNumber(generateReceiptNumber(income.getIssueDate().getYear()));
// Period label based on issue date
String periodLabel = income.getIssueDate().getYear() + "-"
+ String.format("%02d", income.getIssueDate().getMonthValue());
income.setPeriodLabel(periodLabel);
income.setDescription(request.getDescription() != null ? request.getDescription()
: "Recibo mensual alquiler - " + contract.getProperty().getName());
return incomeReceiptRepository.save(income);
}
@Transactional
public void generateMonthlyReceipts() {
List<Contract> activeContracts = contractRepository.findByStatusId(1);
LocalDate today = LocalDate.now();
int currentMonth = today.getMonthValue();
int currentYear = today.getYear();
String periodLabel = currentYear + "-" + String.format("%02d", currentMonth);
for (Contract contract : activeContracts) {
Integer periodId = contract.getPaymentPeriod() != null
? contract.getPaymentPeriod().getId() : 1;
// Determinar si este mes toca generar según la periodicidad
if (!shouldGenerateForPeriod(periodId, contract.getStartDate(), currentMonth, currentYear)) {
continue;
}
// Evitar duplicados por contrato+periodo
if (incomeReceiptRepository.findByContractIdAndPeriodLabel(contract.getId(), periodLabel).isPresent()) {
log.debug("Ya existe recibo para contrato {} en periodo {}", contract.getId(), periodLabel);
continue;
}
// Calcular día de emisión: usa paymentDay, sin pasarse de los días del mes
int day = contract.getPaymentDay() != null
? Math.min(contract.getPaymentDay(), today.lengthOfMonth())
: today.lengthOfMonth();
LocalDate issueDate = LocalDate.of(currentYear, currentMonth, day);
LocalDate dueDate = issueDate.plusDays(30);
ReceiptGenerateRequest req = new ReceiptGenerateRequest();
req.setContractId(contract.getId());
req.setIssueDate(issueDate);
req.setDueDate(dueDate);
generateReceipt(req);
log.info("Recibo generado para contrato {} - periodo {}", contract.getId(), periodLabel);
}
}
/**
* Determina si se debe generar recibo este mes según la periodicidad del contrato.
* periodId: 1=MENSUAL, 2=TRIMESTRAL, 3=SEMESTRAL, 4=ANUAL
*/
private boolean shouldGenerateForPeriod(Integer periodId, LocalDate startDate,
int currentMonth, int currentYear) {
if (periodId == null || periodId == 1) {
return true; // MENSUAL: siempre generar
}
// Para períodos >1 mes, calcular los meses transcurridos desde el inicio
int monthsSinceStart = (currentYear - startDate.getYear()) * 12
+ (currentMonth - startDate.getMonthValue());
if (monthsSinceStart < 0) return false; // contrato aún no ha empezado
return switch (periodId) {
case 2 -> monthsSinceStart % 3 == 0; // TRIMESTRAL: cada 3 meses
case 3 -> monthsSinceStart % 6 == 0; // SEMESTRAL: cada 6 meses
case 4 -> monthsSinceStart % 12 == 0; // ANUAL: cada 12 meses
default -> true;
};
}
@Transactional
public void markOverdueIncomes() {
IncomeStatus overdueStatus = new IncomeStatus();
overdueStatus.setId(3);
List<IncomeReceipt> pending = incomeReceiptRepository.findByStatusId(1);
LocalDate today = LocalDate.now();
for (IncomeReceipt income : pending) {
if (income.getDueDate() != null && income.getDueDate().isBefore(today)) {
income.setStatus(overdueStatus);
incomeReceiptRepository.save(income);
}
}
}
public byte[] generateReceiptPdf(Long incomeId) {
IncomeReceipt income = findById(incomeId);
return pdfReceiptService.generatePdf(income);
}
public void sendReceiptEmail(Long incomeId, String toEmail) {
IncomeReceipt income = findById(incomeId);
byte[] pdf = pdfReceiptService.generatePdf(income);
emailReceiptService.sendReceipt(income, pdf, toEmail);
}
private String generateReceiptNumber(int fiscalYear) {
ReceiptSeries series = receiptSeriesRepository
.findByFiscalYearAndActiveTrue(fiscalYear)
.orElseGet(() -> {
ReceiptSeries newSeries = new ReceiptSeries();
newSeries.setSeriesName("RECIBOS");
newSeries.setFiscalYear(fiscalYear);
newSeries.setPrefix("R-" + fiscalYear + "-");
newSeries.setLastNumber(0);
newSeries.setActive(true);
return receiptSeriesRepository.save(newSeries);
});
series.setLastNumber(series.getLastNumber() + 1);
receiptSeriesRepository.save(series);
return series.getPrefix() + String.format("%05d", series.getLastNumber());
}
}
@@ -0,0 +1,78 @@
package com.sapolar.finance.receipt;
import com.sapolar.finance.expense.ExpenseReceiptRepository;
import com.sapolar.finance.income.IncomeReceiptRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.*;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.stereotype.Service;
import java.io.ByteArrayOutputStream;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
@Slf4j
@Service
@RequiredArgsConstructor
public class ReportService {
private final IncomeReceiptRepository incomeReceiptRepository;
private final ExpenseReceiptRepository expenseReceiptRepository;
public byte[] generateMonthlyReport(int year, int month) {
LocalDate start = LocalDate.of(year, month, 1);
LocalDate end = start.withDayOfMonth(start.lengthOfMonth());
BigDecimal totalIncome = incomeReceiptRepository.sumPaidBetween(start, end);
BigDecimal totalExpense = expenseReceiptRepository.sumPaidBetween(start, end);
totalIncome = totalIncome != null ? totalIncome : BigDecimal.ZERO;
totalExpense = totalExpense != null ? totalExpense : BigDecimal.ZERO;
try (Workbook workbook = new XSSFWorkbook()) {
Sheet sheet = workbook.createSheet("Informe " + month + "-" + year);
CellStyle headerStyle = workbook.createCellStyle();
Font headerFont = workbook.createFont();
headerFont.setBold(true);
headerFont.setFontHeightInPoints((short) 12);
headerStyle.setFont(headerFont);
headerStyle.setFillForegroundColor(IndexedColors.LIGHT_BLUE.getIndex());
headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND);
Row titleRow = sheet.createRow(0);
Cell titleCell = titleRow.createCell(0);
titleCell.setCellValue("Informe mensual - " + month + "/" + year);
titleCell.setCellStyle(headerStyle);
Row incomeRow = sheet.createRow(2);
incomeRow.createCell(0).setCellValue("Total Ingresos");
incomeRow.createCell(1).setCellValue(totalIncome.doubleValue());
Row expenseRow = sheet.createRow(3);
expenseRow.createCell(0).setCellValue("Total Gastos");
expenseRow.createCell(1).setCellValue(totalExpense.doubleValue());
Row balanceRow = sheet.createRow(5);
balanceRow.createCell(0).setCellValue("Saldo Neto");
CellStyle boldStyle = workbook.createCellStyle();
Font boldFont = workbook.createFont();
boldFont.setBold(true);
boldStyle.setFont(boldFont);
balanceRow.createCell(0).setCellStyle(boldStyle);
balanceRow.createCell(1).setCellValue(totalIncome.subtract(totalExpense).doubleValue());
sheet.autoSizeColumn(0);
sheet.autoSizeColumn(1);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
workbook.write(baos);
return baos.toByteArray();
} catch (Exception e) {
throw new RuntimeException("Error al generar informe Excel", e);
}
}
}
@@ -0,0 +1,15 @@
package com.sapolar.finance.receipt.dto;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDate;
@Getter
@Setter
public class ReceiptGenerateRequest {
private Long contractId;
private LocalDate issueDate;
private LocalDate dueDate;
private String description;
}
@@ -0,0 +1,96 @@
package com.sapolar.incident;
import com.sapolar.property.Property;
import com.sapolar.property.PropertyGroup;
import com.sapolar.user.User;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Getter
@Setter
@Entity
@Table(name = "incidents", indexes = {
@Index(name = "idx_incidents_property", columnList = "property_id"),
@Index(name = "idx_incidents_status", columnList = "status_id"),
@Index(name = "idx_incidents_priority", columnList = "priority_id"),
@Index(name = "idx_incidents_reported", columnList = "reported_at")
})
public class Incident {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "property_id")
private Property property;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "property_group_id")
private PropertyGroup propertyGroup;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "status_id", nullable = false)
private IncidentStatus status;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "priority_id", nullable = false)
private IncidentPriority priority;
@Column(nullable = false, length = 200)
private String title;
@Column(nullable = false, columnDefinition = "TEXT")
private String description;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "reported_by")
private User reportedBy;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "assigned_to")
private User assignedTo;
@Column(name = "reported_at", nullable = false)
private LocalDateTime reportedAt;
@Column(name = "scheduled_date")
private LocalDate scheduledDate;
@Column(name = "resolved_at")
private LocalDateTime resolvedAt;
@Column(name = "resolution_notes", columnDefinition = "TEXT")
private String resolutionNotes;
@Column(name = "cost_estimate", precision = 12, scale = 2)
private BigDecimal costEstimate;
@Column(name = "final_cost", precision = 12, scale = 2)
private BigDecimal finalCost;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
if (reportedAt == null) {
reportedAt = LocalDateTime.now();
}
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}
@@ -0,0 +1,91 @@
package com.sapolar.incident;
import com.sapolar.auth.SecurityUser;
import com.sapolar.common.dto.ApiResponse;
import com.sapolar.common.dto.PagedResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/incidents")
@RequiredArgsConstructor
public class IncidentController {
private final IncidentService incidentService;
@GetMapping
public ResponseEntity<ApiResponse<PagedResponse<Incident>>> findAll(
@RequestParam(required = false) Long propertyId,
@RequestParam(required = false) Integer statusId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "id") String sort,
@RequestParam(defaultValue = "asc") String dir) {
Sort sorting = dir.equalsIgnoreCase("desc") ? Sort.by(sort).descending() : Sort.by(sort).ascending();
Pageable pageable = PageRequest.of(page, size, sorting);
if (propertyId != null) {
List<Incident> list = incidentService.findByProperty(propertyId);
return ResponseEntity.ok(ApiResponse.success(new PagedResponse<>(list, 0, list.size(), list.size(), 1, true)));
}
if (statusId != null) {
List<Incident> list = incidentService.findByStatus(statusId);
return ResponseEntity.ok(ApiResponse.success(new PagedResponse<>(list, 0, list.size(), list.size(), 1, true)));
}
return ResponseEntity.ok(ApiResponse.success(PagedResponse.from(incidentService.findAll(pageable))));
}
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<Incident>> findById(@PathVariable Long id) {
return ResponseEntity.ok(ApiResponse.success(incidentService.findById(id)));
}
@PostMapping
public ResponseEntity<ApiResponse<Incident>> create(@RequestBody Incident incident,
@AuthenticationPrincipal SecurityUser user) {
return ResponseEntity.ok(ApiResponse.success("Incidencia creada",
incidentService.create(incident, user.userId())));
}
@PutMapping("/{id}")
public ResponseEntity<ApiResponse<Incident>> update(@PathVariable Long id, @RequestBody Incident incident) {
return ResponseEntity.ok(ApiResponse.success("Incidencia actualizada",
incidentService.update(id, incident)));
}
@PatchMapping("/{id}/status")
public ResponseEntity<ApiResponse<Incident>> updateStatus(@PathVariable Long id,
@RequestBody Map<String, Object> body) {
Integer statusId = (Integer) body.get("statusId");
String notes = (String) body.get("resolutionNotes");
return ResponseEntity.ok(ApiResponse.success("Estado actualizado",
incidentService.updateStatus(id, statusId, notes)));
}
@PatchMapping("/{id}/assign")
public ResponseEntity<ApiResponse<Incident>> assign(@PathVariable Long id,
@RequestBody Map<String, Long> body) {
return ResponseEntity.ok(ApiResponse.success("Técnico asignado",
incidentService.assignTechnician(id, body.get("technicianId"))));
}
@PatchMapping("/{id}/schedule")
public ResponseEntity<ApiResponse<Incident>> schedule(@PathVariable Long id,
@RequestBody Map<String, String> body) {
return ResponseEntity.ok(ApiResponse.success("Reparación programada",
incidentService.scheduleRepair(id, java.time.LocalDate.parse(body.get("scheduledDate")))));
}
@DeleteMapping("/{id}")
public ResponseEntity<ApiResponse<Void>> delete(@PathVariable Long id) {
incidentService.delete(id);
return ResponseEntity.ok(ApiResponse.success("Incidencia eliminada", null));
}
}
@@ -0,0 +1,19 @@
package com.sapolar.incident;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Entity
@Table(name = "incident_priorities")
public class IncidentPriority {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(nullable = false, unique = true, length = 20)
private String name;
}
@@ -0,0 +1,16 @@
package com.sapolar.incident;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface IncidentRepository extends JpaRepository<Incident, Long> {
List<Incident> findByPropertyId(Long propertyId);
List<Incident> findByStatusId(Integer statusId);
List<Incident> findByPriorityId(Integer priorityId);
List<Incident> findByAssignedToId(Long userId);
List<Incident> findByReportedById(Long userId);
long countByStatusId(Integer statusId);
}
@@ -0,0 +1,139 @@
package com.sapolar.incident;
import com.sapolar.common.exception.ResourceNotFoundException;
import com.sapolar.property.Property;
import com.sapolar.property.PropertyGroup;
import com.sapolar.property.PropertyGroupRepository;
import com.sapolar.property.PropertyRepository;
import com.sapolar.user.User;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
@Service
@RequiredArgsConstructor
public class IncidentService {
private final IncidentRepository incidentRepository;
private final PropertyRepository propertyRepository;
private final PropertyGroupRepository propertyGroupRepository;
public List<Incident> findAll() {
return incidentRepository.findAll();
}
public Page<Incident> findAll(Pageable pageable) {
return incidentRepository.findAll(pageable);
}
public List<Incident> findByProperty(Long propertyId) {
return incidentRepository.findByPropertyId(propertyId);
}
public List<Incident> findByStatus(Integer statusId) {
return incidentRepository.findByStatusId(statusId);
}
public Incident findById(Long id) {
return incidentRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Incidencia", id));
}
@Transactional
public Incident create(Incident incident, Long userId) {
// Asociar propiedad o conjunto
if (incident.getProperty() != null && incident.getProperty().getId() != null) {
Property property = propertyRepository.findById(incident.getProperty().getId())
.orElseThrow(() -> new ResourceNotFoundException("Propiedad", incident.getProperty().getId()));
incident.setProperty(property);
}
if (incident.getPropertyGroup() != null && incident.getPropertyGroup().getId() != null) {
PropertyGroup group = propertyGroupRepository.findById(incident.getPropertyGroup().getId())
.orElseThrow(() -> new ResourceNotFoundException("Conjunto", incident.getPropertyGroup().getId()));
incident.setPropertyGroup(group);
}
User user = new User();
user.setId(userId);
incident.setReportedBy(user);
return incidentRepository.save(incident);
}
@Transactional
public Incident updateStatus(Long id, Integer statusId, String resolutionNotes) {
Incident incident = findById(id);
IncidentStatus status = new IncidentStatus();
status.setId(statusId);
incident.setStatus(status);
if (statusId == 4) {
incident.setResolvedAt(LocalDateTime.now());
}
if (resolutionNotes != null) {
incident.setResolutionNotes(resolutionNotes);
}
return incidentRepository.save(incident);
}
@Transactional
public Incident assignTechnician(Long id, Long technicianId) {
Incident incident = findById(id);
User technician = new User();
technician.setId(technicianId);
incident.setAssignedTo(technician);
IncidentStatus status = new IncidentStatus();
status.setId(2);
incident.setStatus(status);
return incidentRepository.save(incident);
}
@Transactional
public Incident scheduleRepair(Long id, java.time.LocalDate scheduledDate) {
Incident incident = findById(id);
incident.setScheduledDate(scheduledDate);
IncidentStatus status = new IncidentStatus();
status.setId(3);
incident.setStatus(status);
return incidentRepository.save(incident);
}
@Transactional
public Incident update(Long id, Incident updated) {
Incident incident = findById(id);
incident.setTitle(updated.getTitle());
incident.setDescription(updated.getDescription());
if (updated.getPriority() != null) incident.setPriority(updated.getPriority());
if (updated.getStatus() != null) incident.setStatus(updated.getStatus());
if (updated.getCostEstimate() != null) incident.setCostEstimate(updated.getCostEstimate());
if (updated.getAssignedTo() != null) {
User technician = new User();
technician.setId(updated.getAssignedTo().getId());
incident.setAssignedTo(technician);
}
incident.setScheduledDate(updated.getScheduledDate());
incident.setResolutionNotes(updated.getResolutionNotes());
return incidentRepository.save(incident);
}
@Transactional
public void delete(Long id) {
incidentRepository.deleteById(id);
}
public long countOpen() {
return incidentRepository.countByStatusId(1)
+ incidentRepository.countByStatusId(2)
+ incidentRepository.countByStatusId(3);
}
}
@@ -0,0 +1,19 @@
package com.sapolar.incident;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Entity
@Table(name = "incident_statuses")
public class IncidentStatus {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(nullable = false, unique = true, length = 50)
private String name;
}
@@ -0,0 +1,87 @@
package com.sapolar.maintenance;
import com.sapolar.auth.SecurityUser;
import com.sapolar.common.dto.ApiResponse;
import com.sapolar.finance.expense.ExpenseCategory;
import com.sapolar.finance.expense.ExpenseCategoryRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.List;
@RestController
@RequestMapping("/api/maintenance")
@RequiredArgsConstructor
public class MaintenanceController {
private final MaintenanceService maintenanceService;
private final ExpenseCategoryRepository expenseCategoryRepository;
@GetMapping
public ResponseEntity<ApiResponse<List<ScheduledMaintenance>>> findAll(
@RequestParam(required = false) Long propertyId) {
if (propertyId != null) {
return ResponseEntity.ok(ApiResponse.success(maintenanceService.findByProperty(propertyId)));
}
return ResponseEntity.ok(ApiResponse.success(maintenanceService.findAll()));
}
@GetMapping("/pending")
public ResponseEntity<ApiResponse<List<ScheduledMaintenance>>> getPending() {
return ResponseEntity.ok(ApiResponse.success(maintenanceService.findPending()));
}
@GetMapping("/upcoming")
public ResponseEntity<ApiResponse<List<ScheduledMaintenance>>> getUpcoming(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) {
return ResponseEntity.ok(ApiResponse.success(maintenanceService.findUpcoming(from, to)));
}
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<ScheduledMaintenance>> findById(@PathVariable Long id) {
return ResponseEntity.ok(ApiResponse.success(maintenanceService.findById(id)));
}
@GetMapping("/expense-categories")
public ResponseEntity<ApiResponse<List<ExpenseCategory>>> getExpenseCategories() {
return ResponseEntity.ok(ApiResponse.success(expenseCategoryRepository.findAll()));
}
@PostMapping
public ResponseEntity<ApiResponse<ScheduledMaintenance>> create(@RequestBody ScheduledMaintenance maintenance,
@AuthenticationPrincipal SecurityUser user) {
return ResponseEntity.ok(ApiResponse.success("Mantenimiento creado",
maintenanceService.create(maintenance, user.userId())));
}
@PutMapping("/{id}")
public ResponseEntity<ApiResponse<ScheduledMaintenance>> update(@PathVariable Long id,
@RequestBody ScheduledMaintenance maintenance) {
return ResponseEntity.ok(ApiResponse.success("Mantenimiento actualizado",
maintenanceService.update(id, maintenance)));
}
@PatchMapping("/{id}/complete")
public ResponseEntity<ApiResponse<ScheduledMaintenance>> complete(@PathVariable Long id,
@AuthenticationPrincipal SecurityUser user) {
return ResponseEntity.ok(ApiResponse.success("Mantenimiento completado",
maintenanceService.markCompleted(id, user.userId())));
}
@PatchMapping("/{id}/reopen")
public ResponseEntity<ApiResponse<ScheduledMaintenance>> reopen(@PathVariable Long id) {
return ResponseEntity.ok(ApiResponse.success("Mantenimiento reabierto",
maintenanceService.reopen(id)));
}
@DeleteMapping("/{id}")
public ResponseEntity<ApiResponse<Void>> delete(@PathVariable Long id) {
maintenanceService.delete(id);
return ResponseEntity.ok(ApiResponse.success("Mantenimiento eliminado", null));
}
}
@@ -0,0 +1,19 @@
package com.sapolar.maintenance;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Entity
@Table(name = "maintenance_periods")
public class MaintenancePeriod {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(nullable = false, unique = true, length = 30)
private String name;
}
@@ -0,0 +1,190 @@
package com.sapolar.maintenance;
import com.sapolar.common.exception.ResourceNotFoundException;
import com.sapolar.finance.expense.ExpenseCategory;
import com.sapolar.finance.expense.ExpenseReceipt;
import com.sapolar.finance.expense.ExpenseReceiptRepository;
import com.sapolar.finance.expense.ExpenseStatus;
import com.sapolar.property.Property;
import com.sapolar.property.PropertyGroup;
import com.sapolar.property.PropertyGroupRepository;
import com.sapolar.property.PropertyRepository;
import com.sapolar.user.User;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
@Service
@RequiredArgsConstructor
public class MaintenanceService {
private final ScheduledMaintenanceRepository maintenanceRepository;
private final PropertyRepository propertyRepository;
private final PropertyGroupRepository propertyGroupRepository;
private final ExpenseReceiptRepository expenseReceiptRepository;
public List<ScheduledMaintenance> findAll() {
return maintenanceRepository.findAll();
}
public List<ScheduledMaintenance> findByProperty(Long propertyId) {
return maintenanceRepository.findByPropertyId(propertyId);
}
public List<ScheduledMaintenance> findPending() {
return maintenanceRepository.findByCompletedFalse();
}
public List<ScheduledMaintenance> findUpcoming(LocalDate start, LocalDate end) {
return maintenanceRepository.findByCompletedFalseAndNextExecutionBetween(start, end);
}
public ScheduledMaintenance findById(Long id) {
return maintenanceRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Mantenimiento", id));
}
@Transactional
public ScheduledMaintenance create(ScheduledMaintenance maintenance, Long userId) {
// Asociar propiedad o conjunto
if (maintenance.getProperty() != null && maintenance.getProperty().getId() != null) {
Property property = propertyRepository.findById(maintenance.getProperty().getId())
.orElseThrow(() -> new ResourceNotFoundException("Propiedad", maintenance.getProperty().getId()));
maintenance.setProperty(property);
}
if (maintenance.getPropertyGroup() != null && maintenance.getPropertyGroup().getId() != null) {
PropertyGroup group = propertyGroupRepository.findById(maintenance.getPropertyGroup().getId())
.orElseThrow(() -> new ResourceNotFoundException("Conjunto", maintenance.getPropertyGroup().getId()));
maintenance.setPropertyGroup(group);
}
User user = new User();
user.setId(userId);
maintenance.setCreatedBy(user);
maintenance.setCompleted(false);
ScheduledMaintenance saved = maintenanceRepository.save(maintenance);
// Auto-generar gasto si la opción está activa
if (Boolean.TRUE.equals(saved.getGenerateExpense()) && saved.getExpenseCategory() != null) {
createExpenseFromMaintenance(saved, userId);
}
return saved;
}
@Transactional
public ScheduledMaintenance update(Long id, ScheduledMaintenance updated) {
ScheduledMaintenance maintenance = findById(id);
maintenance.setTitle(updated.getTitle());
maintenance.setDescription(updated.getDescription());
maintenance.setEstimatedCost(updated.getEstimatedCost());
maintenance.setNextExecution(updated.getNextExecution());
maintenance.setReminderDaysBefore(updated.getReminderDaysBefore());
maintenance.setResponsible(updated.getResponsible());
maintenance.setNotes(updated.getNotes());
maintenance.setGenerateExpense(updated.getGenerateExpense());
if (updated.getPeriod() != null) maintenance.setPeriod(updated.getPeriod());
if (updated.getExpenseCategory() != null) maintenance.setExpenseCategory(updated.getExpenseCategory());
return maintenanceRepository.save(maintenance);
}
@Transactional
public ScheduledMaintenance markCompleted(Long id, Long userId) {
ScheduledMaintenance maintenance = findById(id);
maintenance.setCompleted(true);
maintenance.setCompletedAt(LocalDate.now());
User user = new User();
user.setId(userId);
maintenance.setCompletedBy(user);
ScheduledMaintenance saved = maintenanceRepository.save(maintenance);
// Auto-generar gasto al completar si la opción está activa y es periódico
if (Boolean.TRUE.equals(saved.getGenerateExpense()) && saved.getExpenseCategory() != null) {
// Para periódicos (no única vez): generar gasto cada vez que se completa
if (saved.getPeriod() != null && saved.getPeriod().getId() != 1) {
createExpenseFromMaintenance(saved, userId);
}
}
return saved;
}
@Transactional
public ScheduledMaintenance reopen(Long id) {
ScheduledMaintenance maintenance = findById(id);
maintenance.setCompleted(false);
maintenance.setCompletedAt(null);
maintenance.setCompletedBy(null);
return maintenanceRepository.save(maintenance);
}
@Transactional
public void delete(Long id) {
maintenanceRepository.deleteById(id);
}
/**
* Crea un gasto a partir de los datos de una tarea de mantenimiento.
*/
private ExpenseReceipt createExpenseFromMaintenance(ScheduledMaintenance maintenance, Long userId) {
ExpenseReceipt expense = new ExpenseReceipt();
// Propiedad o conjunto asociado
if (maintenance.getProperty() != null && maintenance.getProperty().getId() != null) {
Property propertyRef = new Property();
propertyRef.setId(maintenance.getProperty().getId());
expense.setProperty(propertyRef);
}
if (maintenance.getPropertyGroup() != null && maintenance.getPropertyGroup().getId() != null) {
PropertyGroup groupRef = new PropertyGroup();
groupRef.setId(maintenance.getPropertyGroup().getId());
expense.setPropertyGroup(groupRef);
}
// Categoría de gasto
expense.setCategory(maintenance.getExpenseCategory());
// Estado PENDIENTE (id=1)
ExpenseStatus status = new ExpenseStatus();
status.setId(1);
expense.setStatus(status);
// Importes
BigDecimal amount = maintenance.getEstimatedCost() != null
? maintenance.getEstimatedCost()
: BigDecimal.ZERO;
expense.setAmount(amount);
expense.setTaxAmount(BigDecimal.ZERO);
expense.setTotalAmount(amount);
// Fechas
expense.setIssueDate(LocalDate.now());
if (maintenance.getNextExecution() != null) {
expense.setDueDate(maintenance.getNextExecution());
}
// Descripción
String desc = "Mantenimiento: " + maintenance.getTitle();
if (maintenance.getProperty() != null && maintenance.getProperty().getName() != null) {
desc += " - " + maintenance.getProperty().getName();
}
expense.setDescription(desc);
// Responsable como proveedor
expense.setSupplierName(maintenance.getResponsible());
// Creador
User user = new User();
user.setId(userId);
expense.setCreatedBy(user);
return expenseReceiptRepository.save(expense);
}
}
@@ -0,0 +1,102 @@
package com.sapolar.maintenance;
import com.sapolar.finance.expense.ExpenseCategory;
import com.sapolar.property.Property;
import com.sapolar.property.PropertyGroup;
import com.sapolar.user.User;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Getter
@Setter
@Entity
@Table(name = "scheduled_maintenance", indexes = {
@Index(name = "idx_maint_property", columnList = "property_id"),
@Index(name = "idx_maint_next_exec", columnList = "next_execution"),
@Index(name = "idx_maint_completed", columnList = "completed")
})
public class ScheduledMaintenance {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "property_id")
private Property property;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "property_group_id")
private PropertyGroup propertyGroup;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "period_id", nullable = false)
private MaintenancePeriod period;
@Column(nullable = false, length = 200)
private String title;
@Column(columnDefinition = "TEXT")
private String description;
@Column(name = "estimated_cost", precision = 12, scale = 2)
private BigDecimal estimatedCost;
@Column(name = "last_execution")
private LocalDate lastExecution;
@Column(name = "next_execution", nullable = false)
private LocalDate nextExecution;
@Column(name = "reminder_days_before", nullable = false)
private Integer reminderDaysBefore = 30;
@Column(length = 200)
private String responsible;
@Column(columnDefinition = "TEXT")
private String notes;
@Column(name = "generate_expense", nullable = false)
private Boolean generateExpense = false;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "expense_category_id")
private ExpenseCategory expenseCategory;
@Column(nullable = false)
private Boolean completed = false;
@Column(name = "completed_at")
private LocalDate completedAt;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "completed_by")
private User completedBy;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "created_by")
private User createdBy;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}
@@ -0,0 +1,15 @@
package com.sapolar.maintenance;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.time.LocalDate;
import java.util.List;
@Repository
public interface ScheduledMaintenanceRepository extends JpaRepository<ScheduledMaintenance, Long> {
List<ScheduledMaintenance> findByPropertyId(Long propertyId);
List<ScheduledMaintenance> findByCompletedFalse();
List<ScheduledMaintenance> findByCompletedFalseAndNextExecutionBetween(LocalDate start, LocalDate end);
List<ScheduledMaintenance> findByCompletedFalseAndNextExecutionBefore(LocalDate date);
}
@@ -0,0 +1,59 @@
package com.sapolar.notification;
import com.sapolar.user.User;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@Entity
@Table(name = "notifications", indexes = {
@Index(name = "idx_notifications_user", columnList = "user_id, `read`"),
@Index(name = "idx_notifications_created", columnList = "created_at")
})
public class Notification {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "type_id", nullable = false)
private NotificationType type;
@Column(nullable = false, length = 200)
private String title;
@Column(columnDefinition = "TEXT")
private String body;
@Column(name = "entity_type", length = 30)
private String entityType;
@Column(name = "entity_id")
private Long entityId;
@Column(name = "sent_by_email", nullable = false)
private Boolean sentByEmail = false;
@Column(name = "`read`", nullable = false)
private Boolean read = false;
@Column(name = "read_at")
private LocalDateTime readAt;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
}
}
@@ -0,0 +1,48 @@
package com.sapolar.notification;
import com.sapolar.auth.SecurityUser;
import com.sapolar.common.dto.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/notifications")
@RequiredArgsConstructor
public class NotificationController {
private final NotificationService notificationService;
@GetMapping
public ResponseEntity<ApiResponse<List<Notification>>> findAll(
@AuthenticationPrincipal SecurityUser user,
@RequestParam(required = false) Boolean unreadOnly) {
if (Boolean.TRUE.equals(unreadOnly)) {
return ResponseEntity.ok(ApiResponse.success(
notificationService.findUnreadByUser(user.userId())));
}
return ResponseEntity.ok(ApiResponse.success(
notificationService.findByUser(user.userId())));
}
@GetMapping("/unread-count")
public ResponseEntity<ApiResponse<Long>> countUnread(@AuthenticationPrincipal SecurityUser user) {
return ResponseEntity.ok(ApiResponse.success(notificationService.countUnread(user.userId())));
}
@PatchMapping("/{id}/read")
public ResponseEntity<ApiResponse<Notification>> markAsRead(
@PathVariable Long id, @AuthenticationPrincipal SecurityUser user) {
return ResponseEntity.ok(ApiResponse.success("Notificación marcada como leída",
notificationService.markAsRead(id, user.userId())));
}
@PatchMapping("/read-all")
public ResponseEntity<ApiResponse<Void>> markAllAsRead(@AuthenticationPrincipal SecurityUser user) {
notificationService.markAllAsRead(user.userId());
return ResponseEntity.ok(ApiResponse.success("Todas las notificaciones marcadas como leídas", null));
}
}
@@ -0,0 +1,13 @@
package com.sapolar.notification;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface NotificationRepository extends JpaRepository<Notification, Long> {
List<Notification> findByUserIdOrderByCreatedAtDesc(Long userId);
List<Notification> findByUserIdAndReadFalseOrderByCreatedAtDesc(Long userId);
long countByUserIdAndReadFalse(Long userId);
}
@@ -0,0 +1,72 @@
package com.sapolar.notification;
import com.sapolar.common.exception.ResourceNotFoundException;
import com.sapolar.user.User;
import com.sapolar.user.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
@Service
@RequiredArgsConstructor
public class NotificationService {
private final NotificationRepository notificationRepository;
private final UserRepository userRepository;
public List<Notification> findByUser(Long userId) {
return notificationRepository.findByUserIdOrderByCreatedAtDesc(userId);
}
public List<Notification> findUnreadByUser(Long userId) {
return notificationRepository.findByUserIdAndReadFalseOrderByCreatedAtDesc(userId);
}
public long countUnread(Long userId) {
return notificationRepository.countByUserIdAndReadFalse(userId);
}
@Transactional
public Notification create(Long userId, Integer typeId, String title, String body,
String entityType, Long entityId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new ResourceNotFoundException("Usuario", userId));
Notification notification = new Notification();
notification.setUser(user);
NotificationType type = new NotificationType();
type.setId(typeId);
notification.setType(type);
notification.setTitle(title);
notification.setBody(body);
notification.setEntityType(entityType);
notification.setEntityId(entityId);
notification.setRead(false);
return notificationRepository.save(notification);
}
@Transactional
public Notification markAsRead(Long notificationId, Long userId) {
Notification notification = notificationRepository.findById(notificationId)
.orElseThrow(() -> new ResourceNotFoundException("Notificación", notificationId));
notification.setRead(true);
notification.setReadAt(LocalDateTime.now());
return notificationRepository.save(notification);
}
@Transactional
public void markAllAsRead(Long userId) {
List<Notification> unread = notificationRepository.findByUserIdAndReadFalseOrderByCreatedAtDesc(userId);
unread.forEach(n -> {
n.setRead(true);
n.setReadAt(LocalDateTime.now());
});
notificationRepository.saveAll(unread);
}
}
@@ -0,0 +1,19 @@
package com.sapolar.notification;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Entity
@Table(name = "notification_types")
public class NotificationType {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(nullable = false, unique = true, length = 50)
private String name;
}
@@ -0,0 +1,125 @@
package com.sapolar.property;
import com.sapolar.user.User;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Getter
@Setter
@Entity
@Table(name = "properties", indexes = {
@Index(name = "idx_properties_parent", columnList = "parent_id"),
@Index(name = "idx_properties_type", columnList = "type_id"),
@Index(name = "idx_properties_status", columnList = "status_id"),
@Index(name = "idx_properties_active", columnList = "active")
})
public class Property {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_id")
private Property parent;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "group_id")
private PropertyGroup group;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "type_id", nullable = false)
private PropertyType type;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "status_id", nullable = false)
private PropertyStatus status;
@Column(length = 50)
private String reference;
@Column(nullable = false, length = 200)
private String name;
@Column(columnDefinition = "TEXT")
private String description;
@Column(name = "address_street", length = 200)
private String addressStreet;
@Column(name = "address_number", length = 20)
private String addressNumber;
@Column(name = "address_city", length = 100)
private String addressCity;
@Column(name = "address_postal_code", length = 10)
private String addressPostalCode;
@Column(name = "address_province", length = 100)
private String addressProvince;
@Column(name = "cadastral_ref", length = 30)
private String cadastralRef;
@Column(name = "surface_m2", precision = 10, scale = 2)
private BigDecimal surfaceM2;
@Column(length = 50)
private String floor;
@Column(length = 50)
private String door;
@Column(name = "rental_amount", precision = 12, scale = 2)
private BigDecimal rentalAmount;
@Column(name = "rented_since")
private LocalDate rentedSince;
@Column(name = "vacant_since")
private LocalDate vacantSince;
@Column(name = "occupied_since")
private LocalDate occupiedSince;
@Column(columnDefinition = "TEXT")
private String notes;
@Column(nullable = false)
private Boolean active = true;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "created_by")
private User createdBy;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
// Convertir reference vacío a null para evitar errores de clave única
if (reference != null && reference.trim().isEmpty()) {
reference = null;
}
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
// Convertir reference vacío a null para evitar errores de clave única
if (reference != null && reference.trim().isEmpty()) {
reference = null;
}
}
}

Some files were not shown because too many files have changed in this diff Show More