feat: módulo de renta de quinta categoría

Expone la grilla de retenciones mensuales sobre planilla.renta_quinta y
calcula la proyección anual por trabajador.

- Entity con las 115 columnas de la tabla, repository, DTOs y mapper manual
- Grilla agregada por documento (codper no identifica al trabajador en los
  datos reales) con filtros opcionales; solo periodo y mesCorte obligatorios
- Cálculo de quinta categoría: proyección, deducción de 7 UIT, tramos
  progresivos y divisor de retención por mes, con la UIT configurable por año
- Manejo de errores sin stack traces y Swagger UI en /docs
- Tests unitarios de la calculadora e integración con Testcontainers

La configuración con credenciales queda fuera del repo: partir de
application.example.yml.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WkHutqxgcr62rTe48Q28X4
parents
/mvnw text eol=lf
*.cmd text eol=crlf
### Config local (credenciales) ###
src/main/resources/application.yml
HELP.md
target/
.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
build/
!**/src/main/**/build/
!**/src/test/**/build/
### VS Code ###
.vscode/
wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip
# planilla-be
Backend de planilla (Saco Oliveros). Módulo inicial: **renta de quinta categoría** — consulta
de retenciones mensuales y cálculo de la proyección anual por trabajador.
## Stack
- Java 25 (`pom.xml`), Spring Boot 4.1.1, empaquetado **war** (`ServletInitializer`)
- Spring Data JPA (Hibernate 7) + PostgreSQL
- springdoc-openapi → Swagger UI en `/docs`
- Lombok (`@Getter/@Setter` en la entity)
- Tests: JUnit 5 + Testcontainers (PostgreSQL)
## Configuración
`src/main/resources/application.yml` **no está versionado** (contiene credenciales). Copiar
`application.example.yml` y completar:
```bash
cp src/main/resources/application.example.yml src/main/resources/application.yml
export DB_PASSWORD='...'
```
Datos del entorno de pruebas: PostgreSQL, base `prueba`, usuario `soporte`, esquema **`planilla`**
(no `public`).
`ddl-auto: validate` — la tabla ya existe y la carga la hace un proceso batch/ETL externo. Nunca
poner `update` ni `create`.
## Comandos
```bash
./mvnw spring-boot:run # levanta en :8080
./mvnw test # unitarios (no requieren Docker)
./mvnw verify # + tests de integración (requieren Docker)
./mvnw package # genera el war
```
Los tests `*IT` corren en failsafe y levantan PostgreSQL con Testcontainers usando el DDL real de
`src/test/resources/db/renta_quinta.sql`. Sin Docker, `verify` no los ejecuta.
## Estructura
```
so.planilla_be
├── config/OpenApiConfig
└── rentaquinta/
├── entity/RentaQuinta mapeo 1:1 de las 115 columnas de planilla.renta_quinta
├── repository/ RentaQuintaRepository + RentaQuintaResumenProjection
├── dto/ Resumen (grilla), Detalle (boleta), Historial, Filtro
├── calculo/ RentaQuintaCalculadora, UitProperties, CalculoRentaQuinta
├── mapper/RentaQuintaMapper manual (sin MapStruct)
├── service/ + service/impl/
├── controller/
└── exception/ excepción de negocio + GlobalExceptionHandler
```
## Endpoints
| Método | Ruta | Descripción |
|---|---|---|
| GET | `/api/v1/renta-quinta` | Grilla paginada de retenciones, con cálculo |
| GET | `/api/v1/renta-quinta/{id}` | Boleta completa por `id_renta_quinta` |
| GET | `/api/v1/renta-quinta/boleta` | Boleta por `codper` + `anio` + `mes` |
| GET | `/api/v1/renta-quinta/historial/{codper}/{anio}` | Meses del trabajador en el periodo |
### Filtros de la grilla
`periodo` y `mesCorte` son **obligatorios**; el resto es opcional y, si no se envía, no filtra.
Todos son `Integer` salvo `documentoOApellidos`, que es texto (busca por `numero_documento` o por
apellidos + nombres, con `LIKE` en mayúsculas).
Los filtros de catálogo (`sede`, `tipoPlanilla`, `areaGeneral`, `subArea`, `cargo`) llegan como
número y se comparan **como texto** contra columnas TEXT: el service los convierte con
`codigo()`. Las columnas `char(n)` se comparan con `trim()` por el padding de PostgreSQL.
> Pendiente: en los datos actuales esas columnas guardan texto (`sede='BARRANCO'`,
> `boleta_tipo_personal='ADM'`) y `codlocal` viene vacío, así que un filtro numérico no matchea
> nada. Cuando existan catálogos con id, o si los dropdowns pasan a mandar el texto, cambiar el
> tipo en `RentaQuintaFiltroDTO` y quitar `codigo()`.
`page` por defecto 0, `size` 20 y **tope 100**.
## Reglas de dominio
### Agregación de la grilla
Una fila = un trabajador (no un mes). Se agrupa por `numero_documento` + nombres, **no por
`codper`**: en los datos reales `codper` viene 0/1/2 y una misma persona puede tener varios, lo
que la duplicaba en la grilla.
- `historialReal` / `retenidoAnterior`: suma de los meses **anteriores** al de corte
- `planillaCargada` / retención registrada: el **mes de corte**
### Cálculo de quinta categoría
`RentaQuintaCalculadora` (art. 40 y 53 de la LIR; art. 40 del Reglamento):
1. Proyección = remuneración del mes de corte × meses restantes + gratificaciones de julio y
diciembre aún no percibidas, cada una `(básico + asignación familiar) × 1.09`
2. Renta bruta anual = historial + planilla cargada + proyección
3. Renta neta = bruta − **7 UIT**
4. Impuesto anual = tramos progresivos 8% / 14% / 17% / 20% / 30% (5, 20, 35 y 45 UIT)
5. Retención del mes = (impuesto anual − retenido previo) ÷ divisor del mes
(ene-mar 12, abr 9, may-jul 8, ago 5, set-nov 4, dic 1); **nunca negativa**
La **UIT es configurable por año** en `renta-quinta.uit.valores` — se actualiza cada año, no
hardcodear. **Falta cargar el valor 2026**: hoy cae en `por-defecto`, y de ese número dependen
todos los importes del periodo.
## Notas de la tabla `planilla.renta_quinta`
- Snapshot mensual por trabajador; el API es **solo lectura**
- Las columnas `char(n)` necesitan `columnDefinition = "bpchar"` o `ddl-auto: validate` falla
(`found [bpchar], but expecting [varchar]`)
- No hay índice único confirmado sobre `codper + anio + mes + tipo_personal`; validar con el
equipo antes de crearlo en producción
## Manejo de errores
`GlobalExceptionHandler` devuelve `{timestamp, status, message}` para 404 de negocio, filtros
fuera de rango, parámetros obligatorios ausentes y filtros numéricos con valor no numérico. No
exponer stack traces en las respuestas.
## Fuera de alcance por ahora
- Escritura de boletas desde el API (la carga es batch/ETL)
- Autenticación/autorización (se integrará con el esquema de Trismegisto/Saco Oliveros)
- Exportación a Excel y "calcular masivo" de la pantalla
This diff is collapsed. Click to expand it.
<# : batch portion
@REM ----------------------------------------------------------------------------
@REM Licensed to the Apache Software Foundation (ASF) under one
@REM or more contributor license agreements. See the NOTICE file
@REM distributed with this work for additional information
@REM regarding copyright ownership. The ASF licenses this file
@REM to you under the Apache License, Version 2.0 (the
@REM "License"); you may not use this file except in compliance
@REM with the License. You may obtain a copy of the License at
@REM
@REM http://www.apache.org/licenses/LICENSE-2.0
@REM
@REM Unless required by applicable law or agreed to in writing,
@REM software distributed under the License is distributed on an
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@REM KIND, either express or implied. See the License for the
@REM specific language governing permissions and limitations
@REM under the License.
@REM ----------------------------------------------------------------------------
@REM ----------------------------------------------------------------------------
@REM Apache Maven Wrapper startup batch script, version 3.3.4
@REM
@REM Optional ENV vars
@REM MVNW_REPOURL - repo url base for downloading maven distribution
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
@REM ----------------------------------------------------------------------------
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
@SET __MVNW_CMD__=
@SET __MVNW_ERROR__=
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
@SET PSModulePath=
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
)
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
@SET __MVNW_PSMODULEP_SAVE=
@SET __MVNW_ARG0_NAME__=
@SET MVNW_USERNAME=
@SET MVNW_PASSWORD=
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
@echo Cannot start maven from wrapper >&2 && exit /b 1
@GOTO :EOF
: end batch / begin powershell #>
$ErrorActionPreference = "Stop"
if ($env:MVNW_VERBOSE -eq "true") {
$VerbosePreference = "Continue"
}
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
if (!$distributionUrl) {
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
}
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
"maven-mvnd-*" {
$USE_MVND = $true
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
$MVN_CMD = "mvnd.cmd"
break
}
default {
$USE_MVND = $false
$MVN_CMD = $script -replace '^mvnw','mvn'
break
}
}
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
if ($env:MVNW_REPOURL) {
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
}
$distributionUrlName = $distributionUrl -replace '^.*/',''
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
$MAVEN_M2_PATH = "$HOME/.m2"
if ($env:MAVEN_USER_HOME) {
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
}
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
}
$MAVEN_WRAPPER_DISTS = $null
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
} else {
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
}
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
exit $?
}
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
}
# prepare tmp dir
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
trap {
if ($TMP_DOWNLOAD_DIR.Exists) {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
}
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
# Download and Install Apache Maven
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
Write-Verbose "Downloading from: $distributionUrl"
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
$webclient = New-Object System.Net.WebClient
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
# If specified, validate the SHA-256 sum of the Maven distribution zip file
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
if ($distributionSha256Sum) {
if ($USE_MVND) {
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
}
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
}
}
# unzip and move
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
# Find the actual extracted directory name (handles snapshots where filename != directory name)
$actualDistributionDir = ""
# First try the expected directory name (for regular distributions)
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
$actualDistributionDir = $distributionUrlNameMain
}
# If not found, search for any directory with the Maven executable (for snapshots)
if (!$actualDistributionDir) {
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
if (Test-Path -Path $testPath -PathType Leaf) {
$actualDistributionDir = $_.Name
}
}
}
if (!$actualDistributionDir) {
Write-Error "Could not find Maven distribution directory in extracted archive"
}
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
try {
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
} catch {
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
Write-Error "fail to move MAVEN_HOME"
}
} finally {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
<?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>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>so</groupId>
<artifactId>planilla-be</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>war</packaging>
<name/>
<description/>
<url/>
<licenses>
<license/>
</licenses>
<developers>
<developer/>
</developers>
<scm>
<connection/>
<developerConnection/>
<tag/>
<url/>
</scm>
<properties>
<java.version>25</java.version>
<springdoc.version>3.1.0</springdoc.version>
<testcontainers.version>1.21.3</testcontainers.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-bom</artifactId>
<version>${testcontainers.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</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-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<executions>
<execution>
<id>default-compile</id>
<phase>compile</phase>
<goals>
<goal>compile</goal>
</goals>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</path>
</annotationProcessorPaths>
</configuration>
</execution>
<execution>
<id>default-testCompile</id>
<phase>test-compile</phase>
<goals>
<goal>testCompile</goal>
</goals>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</path>
</annotationProcessorPaths>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
package so.planilla_be;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
@SpringBootApplication
@ConfigurationPropertiesScan
public class PlanillaBeApplication {
public static void main(String[] args) {
SpringApplication.run(PlanillaBeApplication.class, args);
}
}
package so.planilla_be;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
public class ServletInitializer extends SpringBootServletInitializer {
@Override
protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
return application.sources(PlanillaBeApplication.class);
}
}
package so.planilla_be.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class OpenApiConfig {
@Bean
public OpenAPI planillaOpenApi() {
return new OpenAPI().info(new Info()
.title("Planilla API")
.version("v1")
.description("Endpoints de planilla. Módulo inicial: renta de quinta categoría."));
}
}
package so.planilla_be.rentaquinta.calculo;
import java.math.BigDecimal;
/** Resultado del cálculo de quinta categoría para un trabajador en un mes de corte. */
public record CalculoRentaQuinta(
BigDecimal proyeccionFutura,
BigDecimal rentaBrutaAnual,
BigDecimal impuestoAnual,
BigDecimal retencionDelMes
) {}
package so.planilla_be.rentaquinta.calculo;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List;
/**
* Cálculo de la retención de quinta categoría (art. 40 y 53 de la Ley del Impuesto a la Renta
* y art. 40 de su Reglamento).
*
* <p>Procedimiento:
* <ol>
* <li>Renta bruta anual proyectada = percibido hasta el mes de corte + proyección de los meses
* que faltan + gratificaciones de julio/diciembre aún no percibidas.</li>
* <li>Renta neta = renta bruta anual − 7 UIT (deducción fija).</li>
* <li>Impuesto anual = tramos progresivos acumulativos sobre la renta neta.</li>
* <li>Retención del mes = (impuesto anual − retenciones previas) / divisor del mes.</li>
* </ol>
*/
@Component
public class RentaQuintaCalculadora {
private static final int ESCALA = 2;
private static final BigDecimal DEDUCCION_UIT = BigDecimal.valueOf(7);
/** Tramos del art. 53: límite superior en UIT (null = sin tope) y tasa. */
private static final List<Tramo> TRAMOS = List.of(
new Tramo(BigDecimal.valueOf(5), new BigDecimal("0.08")),
new Tramo(BigDecimal.valueOf(20), new BigDecimal("0.14")),
new Tramo(BigDecimal.valueOf(35), new BigDecimal("0.17")),
new Tramo(BigDecimal.valueOf(45), new BigDecimal("0.20")),
new Tramo(null, new BigDecimal("0.30")));
/** Bonificación extraordinaria (ex-EsSalud) que acompaña a la gratificación. */
private static final BigDecimal FACTOR_GRATIFICACION = new BigDecimal("1.09");
private final UitProperties uit;
public RentaQuintaCalculadora(UitProperties uit) {
this.uit = uit;
}
public CalculoRentaQuinta calcular(DatosCalculo datos) {
BigDecimal valorUit = uit.valorPara(datos.periodo());
BigDecimal proyeccionFutura = proyectarMesesFuturos(datos);
BigDecimal rentaBrutaAnual = orZero(datos.historialReal())
.add(orZero(datos.planillaCargada()))
.add(proyeccionFutura);
BigDecimal rentaNeta = rentaBrutaAnual.subtract(DEDUCCION_UIT.multiply(valorUit));
BigDecimal impuestoAnual = impuestoSobre(rentaNeta.max(BigDecimal.ZERO), valorUit);
BigDecimal saldo = impuestoAnual.subtract(orZero(datos.retenidoAnterior()));
BigDecimal retencionDelMes = saldo.max(BigDecimal.ZERO)
.divide(divisorDelMes(datos.mesCorte()), ESCALA, RoundingMode.HALF_UP);
return new CalculoRentaQuinta(
escala(proyeccionFutura), escala(rentaBrutaAnual), escala(impuestoAnual), retencionDelMes);
}
/**
* Los meses posteriores al de corte se proyectan con la remuneración del mes de corte, más las
* gratificaciones de julio y diciembre que todavía no se han percibido.
*/
private BigDecimal proyectarMesesFuturos(DatosCalculo datos) {
int mesesRestantes = 12 - datos.mesCorte();
BigDecimal proyeccion = orZero(datos.remuneracionMesCorte())
.multiply(BigDecimal.valueOf(mesesRestantes));
BigDecimal gratificacion = orZero(datos.basico())
.add(orZero(datos.asignacionFamiliar()))
.multiply(FACTOR_GRATIFICACION);
if (datos.mesCorte() < 7) {
proyeccion = proyeccion.add(gratificacion);
}
if (datos.mesCorte() < 12) {
proyeccion = proyeccion.add(gratificacion);
}
return proyeccion;
}
private BigDecimal impuestoSobre(BigDecimal rentaNeta, BigDecimal valorUit) {
BigDecimal impuesto = BigDecimal.ZERO;
BigDecimal pisoTramo = BigDecimal.ZERO;
for (Tramo tramo : TRAMOS) {
if (tramo.limiteUit() == null) {
impuesto = impuesto.add(rentaNeta.subtract(pisoTramo).max(BigDecimal.ZERO)
.multiply(tramo.tasa()));
break;
}
BigDecimal techoTramo = tramo.limiteUit().multiply(valorUit);
BigDecimal gravadoEnTramo = rentaNeta.min(techoTramo).subtract(pisoTramo).max(BigDecimal.ZERO);
impuesto = impuesto.add(gravadoEnTramo.multiply(tramo.tasa()));
if (rentaNeta.compareTo(techoTramo) <= 0) {
break;
}
pisoTramo = techoTramo;
}
return impuesto;
}
/** Divisores del art. 40 del Reglamento, según el mes en que se practica la retención. */
private BigDecimal divisorDelMes(int mes) {
return BigDecimal.valueOf(switch (mes) {
case 1, 2, 3 -> 12;
case 4 -> 9;
case 5, 6, 7 -> 8;
case 8 -> 5;
case 9, 10, 11 -> 4;
case 12 -> 1;
default -> throw new IllegalArgumentException("Mes fuera de rango: " + mes);
});
}
private BigDecimal escala(BigDecimal v) {
return v.setScale(ESCALA, RoundingMode.HALF_UP);
}
private BigDecimal orZero(BigDecimal v) {
return v == null ? BigDecimal.ZERO : v;
}
private record Tramo(BigDecimal limiteUit, BigDecimal tasa) {}
/** Insumos del cálculo, tomados del acumulado anual del trabajador. */
public record DatosCalculo(
Integer periodo,
int mesCorte,
BigDecimal historialReal,
BigDecimal planillaCargada,
BigDecimal remuneracionMesCorte,
BigDecimal basico,
BigDecimal asignacionFamiliar,
BigDecimal retenidoAnterior
) {}
}
package so.planilla_be.rentaquinta.calculo;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.math.BigDecimal;
import java.util.Map;
/**
* Valor de la UIT por periodo. Se configura en {@code renta-quinta.uit.valores}
* para no tener que recompilar cuando SUNAT publica el valor del año siguiente.
*/
@ConfigurationProperties(prefix = "renta-quinta.uit")
public record UitProperties(Map<Integer, BigDecimal> valores, BigDecimal porDefecto) {
public BigDecimal valorPara(Integer periodo) {
if (valores != null && periodo != null && valores.containsKey(periodo)) {
return valores.get(periodo);
}
return porDefecto;
}
}
package so.planilla_be.rentaquinta.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import org.springframework.data.domain.Page;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import so.planilla_be.rentaquinta.dto.RentaQuintaDetalleDTO;
import so.planilla_be.rentaquinta.dto.RentaQuintaFiltroDTO;
import so.planilla_be.rentaquinta.dto.RentaQuintaHistorialDTO;
import so.planilla_be.rentaquinta.dto.RentaQuintaResumenDTO;
import so.planilla_be.rentaquinta.service.RentaQuintaService;
import java.util.List;
@Tag(name = "Renta de quinta", description = "Consulta de retenciones de renta de quinta categoría")
@Validated
@RestController
@RequestMapping("/api/v1/renta-quinta")
public class RentaQuintaController {
private final RentaQuintaService service;
public RentaQuintaController(RentaQuintaService service) {
this.service = service;
}
@Operation(summary = "Grilla de proyección y control de quinta categoría (paginada)")
@GetMapping
public Page<RentaQuintaResumenDTO> buscar(
@RequestParam @NotNull Integer periodo,
@RequestParam @NotNull @Min(1) @Max(12) Integer mesCorte,
@RequestParam(required = false) Integer sede,
@RequestParam(required = false) Integer tipoPlanilla,
@RequestParam(required = false) Integer areaGeneral,
@RequestParam(required = false) Integer subArea,
@RequestParam(required = false) Integer cargo,
@RequestParam(required = false) String documentoOApellidos,
@RequestParam(defaultValue = "0") Integer page,
@RequestParam(defaultValue = "20") Integer size) {
return service.buscar(new RentaQuintaFiltroDTO(
periodo, mesCorte, sede, tipoPlanilla, areaGeneral, subArea, cargo,
documentoOApellidos, page, size));
}
@Operation(summary = "Detalle completo por id_renta_quinta")
@GetMapping("/{id}")
public RentaQuintaDetalleDTO obtenerPorId(@PathVariable Long id) {
return service.obtenerPorId(id);
}
@Operation(summary = "Detalle por codper + anio + mes")
@GetMapping("/boleta")
public RentaQuintaDetalleDTO obtenerBoleta(
@RequestParam Integer codper,
@RequestParam Integer anio,
@RequestParam @Min(1) @Max(12) Integer mes) {
return service.obtenerBoleta(codper, anio, mes);
}
@Operation(summary = "Ver historial: meses del trabajador en el periodo")
@GetMapping("/historial/{codper}/{anio}")
public List<RentaQuintaHistorialDTO> historialAnual(
@PathVariable Integer codper,
@PathVariable Integer anio) {
return service.historialAnual(codper, anio);
}
}
package so.planilla_be.rentaquinta.dto;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
/** Vista de boleta individual: refleja todas las columnas de renta_quinta. */
public record RentaQuintaDetalleDTO(
Long idRentaQuinta,
Integer boletaAnio,
Integer boletaMes,
String boletaTipoPersonal,
LocalDateTime fechaRegistro,
Integer codper,
String codlocal,
Integer unidadOrganizativa,
String tipoDocumento,
String apellidoPaterno,
String apellidoMaterno,
String nombres,
String documentoAfp,
BigDecimal sueldoBruto,
BigDecimal remContrato,
BigDecimal basico,
BigDecimal asignacionFamiliar,
BigDecimal movilidad,
BigDecimal asignacionEstudios,
BigDecimal bonoExtraordinario,
BigDecimal vacacionesTruncas,
BigDecimal compensacionVacacional,
BigDecimal vacacionesGozadasRemVaca,
BigDecimal cts,
BigDecimal gratificacionProporcional,
BigDecimal bonificacionExtraordinaria,
BigDecimal gratificacionJulioDiciembre,
BigDecimal bonificacionExtraordinaria2,
BigDecimal subsidioEnfermedad,
BigDecimal bonoProductividad,
BigDecimal licenciaGoceHaber,
BigDecimal subsidioMaternidad,
BigDecimal licenciaSaludFamiliarLey30012,
BigDecimal reintegro,
BigDecimal horasExtras25,
BigDecimal horasExtras35,
BigDecimal apoyoSalud,
BigDecimal sumaGraciosa,
BigDecimal bonoDesempeno,
BigDecimal asignacionFallecimientoFamiliar,
BigDecimal dominical,
BigDecimal bonoAniversario,
BigDecimal canastaNavidad,
BigDecimal valeConsumo,
BigDecimal obsequios,
BigDecimal remBruta,
BigDecimal remuneracionImponibleSnpAfp,
BigDecimal snp,
BigDecimal afpAportacionObligatoria,
BigDecimal primaSeguro,
String tipoComision,
BigDecimal comisionAfpPorcentual,
BigDecimal totalAfp,
BigDecimal renta5taCategoria,
BigDecimal adelanto,
BigDecimal adelantoVacaciones,
BigDecimal retencionJudicial,
BigDecimal tardanzas,
BigDecimal inasistencias,
BigDecimal pension,
BigDecimal prestamos,
BigDecimal canastaNavidad2,
BigDecimal valeConsumoDescuento,
BigDecimal obsequiosDescuento,
BigDecimal uniformesPavitosNavidadOtros,
BigDecimal asistenciaTardFsaSalidaAnticipada,
BigDecimal uniforme,
BigDecimal pavos,
BigDecimal colaboracion,
BigDecimal seguroAccidente,
BigDecimal rifa,
BigDecimal otros,
BigDecimal dctoEps,
BigDecimal totalDscto,
BigDecimal netoPorPagar,
BigDecimal aportes,
BigDecimal aporteEssaludEps,
BigDecimal essaludVida,
BigDecimal sctrSalud,
BigDecimal sctrPension,
BigDecimal polizaSeguro,
BigDecimal totalAportes,
String numeroDocumento,
String cussp,
LocalDate fechaNacimiento,
LocalDate fechaIngreso,
LocalDate fechaCese,
String fondoPension,
String tipoContrato,
LocalDate fechaInicioContrato,
LocalDate fechaTerminoContrato,
Integer diasLaborados,
Integer diasNoLaborados,
Integer diasSubsidiados,
Integer descansoMedico,
Integer horasNormales,
Integer horasAdicionales,
String areaGeneral,
String subArea,
String cargo,
String curso,
String observacion,
String sede,
String genero,
LocalDate inicioVacaciones,
LocalDate terminoVacaciones,
Integer lsgh,
Integer lcgh,
Integer lcghFallecimiento,
Integer faltas,
BigDecimal pago,
BigDecimal pendiente,
String tipoLabor,
Integer campo1,
Integer campo2
) {}
package so.planilla_be.rentaquinta.dto;
/**
* Filtros de la grilla de retenciones mensuales.
*
* <p>Todos los filtros son numéricos salvo {@code documentoOApellidos}, que es texto libre
* (nro de documento o apellidos). Los filtros de catálogo (sede, tipo de planilla, área,
* subárea, cargo) llegan como Integer y se comparan contra columnas TEXT convirtiendo el
* número a su representación en texto.
*/
public record RentaQuintaFiltroDTO(
Integer periodo,
Integer mesCorte,
Integer sede,
Integer tipoPlanilla,
Integer areaGeneral,
Integer subArea,
Integer cargo,
String documentoOApellidos,
Integer page,
Integer size
) {}
package so.planilla_be.rentaquinta.dto;
import java.math.BigDecimal;
/** Fila del "Ver historial": un mes del trabajador, con los conceptos que alimentan la renta. */
public record RentaQuintaHistorialDTO(
Long idRentaQuinta,
Integer codper,
Integer boletaAnio,
Integer boletaMes,
String boletaTipoPersonal,
String sede,
String areaGeneral,
String subArea,
String cargo,
BigDecimal sueldoBruto,
BigDecimal basico,
BigDecimal asignacionFamiliar,
BigDecimal horasExtras25,
BigDecimal horasExtras35,
BigDecimal gratificacionJulioDiciembre,
BigDecimal gratificacionProporcional,
BigDecimal bonificacionExtraordinaria,
BigDecimal vacacionesGozadasRemVaca,
BigDecimal reintegro,
BigDecimal remBruta,
BigDecimal remuneracionImponibleSnpAfp,
BigDecimal totalAfp,
BigDecimal snp,
BigDecimal renta5taCategoria,
BigDecimal totalDscto,
BigDecimal netoPorPagar,
Integer diasLaborados,
Integer diasNoLaborados
) {}
package so.planilla_be.rentaquinta.dto;
import java.math.BigDecimal;
/**
* Fila de la grilla "Proyección y control de quinta categoría".
*
* <p>{@code proyeccionFutura} e {@code impuestoAnual} dependen del cálculo SUNAT (tramos,
* proyección), fuera del alcance de esta versión: se devuelven en cero / null hasta que
* exista el módulo de cálculo.
*/
public record RentaQuintaResumenDTO(
Integer codper,
Integer periodo,
String numeroDocumento,
String nombreCompleto,
Integer mesProceso,
BigDecimal historialReal,
BigDecimal planillaCargada,
BigDecimal proyeccionFutura,
BigDecimal rentaBrutaAnual,
BigDecimal impuestoAnual,
BigDecimal retenidoAnterior,
BigDecimal retencionDelMes
) {}
package so.planilla_be.rentaquinta.exception;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import java.time.Instant;
import java.util.Map;
import java.util.stream.Collectors;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(RentaQuintaNoEncontradaException.class)
public ResponseEntity<Map<String, Object>> handleNotFound(RentaQuintaNoEncontradaException ex) {
return build(HttpStatus.NOT_FOUND, ex.getMessage());
}
/** Filtros fuera de rango (p. ej. mesCorte=13). */
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<Map<String, Object>> handleValidation(ConstraintViolationException ex) {
String detalle = ex.getConstraintViolations().stream()
.map(v -> parametro(v) + ": " + v.getMessage())
.collect(Collectors.joining("; "));
return build(HttpStatus.BAD_REQUEST, detalle);
}
private String parametro(ConstraintViolation<?> v) {
String path = v.getPropertyPath().toString();
return path.substring(path.lastIndexOf('.') + 1);
}
/** Falta periodo o mesCorte. */
@ExceptionHandler(MissingServletRequestParameterException.class)
public ResponseEntity<Map<String, Object>> handleMissingParam(MissingServletRequestParameterException ex) {
return build(HttpStatus.BAD_REQUEST,
"Falta el parámetro obligatorio '%s'".formatted(ex.getParameterName()));
}
/** Filtro numérico recibido con un valor no numérico. */
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<Map<String, Object>> handleTypeMismatch(MethodArgumentTypeMismatchException ex) {
return build(HttpStatus.BAD_REQUEST,
"El parámetro '%s' debe ser numérico".formatted(ex.getName()));
}
private ResponseEntity<Map<String, Object>> build(HttpStatus status, String message) {
return ResponseEntity.status(status).body(Map.of(
"timestamp", Instant.now().toString(),
"status", status.value(),
"message", message
));
}
}
package so.planilla_be.rentaquinta.exception;
public class RentaQuintaNoEncontradaException extends RuntimeException {
public RentaQuintaNoEncontradaException(String message) {
super(message);
}
}
package so.planilla_be.rentaquinta.mapper;
import org.springframework.stereotype.Component;
import so.planilla_be.rentaquinta.calculo.CalculoRentaQuinta;
import so.planilla_be.rentaquinta.dto.RentaQuintaDetalleDTO;
import so.planilla_be.rentaquinta.dto.RentaQuintaHistorialDTO;
import so.planilla_be.rentaquinta.dto.RentaQuintaResumenDTO;
import so.planilla_be.rentaquinta.entity.RentaQuinta;
import so.planilla_be.rentaquinta.repository.RentaQuintaResumenProjection;
import java.math.BigDecimal;
@Component
public class RentaQuintaMapper {
public RentaQuintaResumenDTO toResumen(RentaQuintaResumenProjection p, Integer periodo,
Integer mesCorte, CalculoRentaQuinta calculo) {
return new RentaQuintaResumenDTO(
p.getCodper(),
periodo,
p.getNumeroDocumento(),
nombreCompleto(p.getApellidoPaterno(), p.getApellidoMaterno(), p.getNombres()),
mesCorte,
orZero(p.getHistorialReal()),
orZero(p.getPlanillaCargada()),
calculo.proyeccionFutura(),
calculo.rentaBrutaAnual(),
calculo.impuestoAnual(),
orZero(p.getRetenidoAnterior()),
calculo.retencionDelMes());
}
public RentaQuintaHistorialDTO toHistorial(RentaQuinta e) {
return new RentaQuintaHistorialDTO(
e.getIdRentaQuinta(),
e.getCodper(),
e.getBoletaAnio(),
e.getBoletaMes(),
e.getBoletaTipoPersonal(),
e.getSede(),
e.getAreaGeneral(),
e.getSubArea(),
e.getCargo(),
e.getSueldoBruto(),
e.getBasico(),
e.getAsignacionFamiliar(),
e.getHorasExtras25(),
e.getHorasExtras35(),
e.getGratificacionJulioDiciembre(),
e.getGratificacionProporcional(),
e.getBonificacionExtraordinaria(),
e.getVacacionesGozadasRemVaca(),
e.getReintegro(),
e.getRemBruta(),
e.getRemuneracionImponibleSnpAfp(),
e.getTotalAfp(),
e.getSnp(),
e.getRenta5taCategoria(),
e.getTotalDscto(),
e.getNetoPorPagar(),
e.getDiasLaborados(),
e.getDiasNoLaborados());
}
private BigDecimal orZero(BigDecimal v) {
return v == null ? BigDecimal.ZERO : v;
}
private String nombreCompleto(String apellidoPaterno, String apellidoMaterno, String nombres) {
return String.join(" ", blankIfNull(apellidoPaterno), blankIfNull(apellidoMaterno), blankIfNull(nombres))
.trim()
.replaceAll("\\s{2,}", " ");
}
private String blankIfNull(String s) {
return s == null ? "" : s;
}
public RentaQuintaDetalleDTO toDetalle(RentaQuinta e) {
return new RentaQuintaDetalleDTO(
e.getIdRentaQuinta(),
e.getBoletaAnio(),
e.getBoletaMes(),
e.getBoletaTipoPersonal(),
e.getFechaRegistro(),
e.getCodper(),
e.getCodlocal(),
e.getUnidadOrganizativa(),
e.getTipoDocumento(),
e.getApellidoPaterno(),
e.getApellidoMaterno(),
e.getNombres(),
e.getDocumentoAfp(),
e.getSueldoBruto(),
e.getRemContrato(),
e.getBasico(),
e.getAsignacionFamiliar(),
e.getMovilidad(),
e.getAsignacionEstudios(),
e.getBonoExtraordinario(),
e.getVacacionesTruncas(),
e.getCompensacionVacacional(),
e.getVacacionesGozadasRemVaca(),
e.getCts(),
e.getGratificacionProporcional(),
e.getBonificacionExtraordinaria(),
e.getGratificacionJulioDiciembre(),
e.getBonificacionExtraordinaria2(),
e.getSubsidioEnfermedad(),
e.getBonoProductividad(),
e.getLicenciaGoceHaber(),
e.getSubsidioMaternidad(),
e.getLicenciaSaludFamiliarLey30012(),
e.getReintegro(),
e.getHorasExtras25(),
e.getHorasExtras35(),
e.getApoyoSalud(),
e.getSumaGraciosa(),
e.getBonoDesempeno(),
e.getAsignacionFallecimientoFamiliar(),
e.getDominical(),
e.getBonoAniversario(),
e.getCanastaNavidad(),
e.getValeConsumo(),
e.getObsequios(),
e.getRemBruta(),
e.getRemuneracionImponibleSnpAfp(),
e.getSnp(),
e.getAfpAportacionObligatoria(),
e.getPrimaSeguro(),
e.getTipoComision(),
e.getComisionAfpPorcentual(),
e.getTotalAfp(),
e.getRenta5taCategoria(),
e.getAdelanto(),
e.getAdelantoVacaciones(),
e.getRetencionJudicial(),
e.getTardanzas(),
e.getInasistencias(),
e.getPension(),
e.getPrestamos(),
e.getCanastaNavidad2(),
e.getValeConsumoDescuento(),
e.getObsequiosDescuento(),
e.getUniformesPavitosNavidadOtros(),
e.getAsistenciaTardFsaSalidaAnticipada(),
e.getUniforme(),
e.getPavos(),
e.getColaboracion(),
e.getSeguroAccidente(),
e.getRifa(),
e.getOtros(),
e.getDctoEps(),
e.getTotalDscto(),
e.getNetoPorPagar(),
e.getAportes(),
e.getAporteEssaludEps(),
e.getEssaludVida(),
e.getSctrSalud(),
e.getSctrPension(),
e.getPolizaSeguro(),
e.getTotalAportes(),
e.getNumeroDocumento(),
e.getCussp(),
e.getFechaNacimiento(),
e.getFechaIngreso(),
e.getFechaCese(),
e.getFondoPension(),
e.getTipoContrato(),
e.getFechaInicioContrato(),
e.getFechaTerminoContrato(),
e.getDiasLaborados(),
e.getDiasNoLaborados(),
e.getDiasSubsidiados(),
e.getDescansoMedico(),
e.getHorasNormales(),
e.getHorasAdicionales(),
e.getAreaGeneral(),
e.getSubArea(),
e.getCargo(),
e.getCurso(),
e.getObservacion(),
e.getSede(),
e.getGenero(),
e.getInicioVacaciones(),
e.getTerminoVacaciones(),
e.getLsgh(),
e.getLcgh(),
e.getLcghFallecimiento(),
e.getFaltas(),
e.getPago(),
e.getPendiente(),
e.getTipoLabor(),
e.getCampo1(),
e.getCampo2());
}
}
package so.planilla_be.rentaquinta.repository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import so.planilla_be.rentaquinta.entity.RentaQuinta;
import java.util.List;
import java.util.Optional;
public interface RentaQuintaRepository extends JpaRepository<RentaQuinta, Long> {
String FILTROS = """
and (:sede is null or trim(r.codlocal) = :sede)
and (:tipoPlanilla is null or trim(r.boletaTipoPersonal) = :tipoPlanilla)
and (:areaGeneral is null or r.areaGeneral = :areaGeneral)
and (:subArea is null or r.subArea = :subArea)
and (:cargo is null or r.cargo = :cargo)
and (:texto is null
or upper(r.numeroDocumento) like :texto
or upper(concat(r.apellidoPaterno, ' ', r.apellidoMaterno, ' ', r.nombres)) like :texto)
""";
List<RentaQuinta> findByCodperAndBoletaAnioOrderByBoletaMesAsc(
Integer codper, Integer boletaAnio);
Optional<RentaQuinta> findByCodperAndBoletaAnioAndBoletaMes(
Integer codper, Integer boletaAnio, Integer boletaMes);
/**
* Agregado anual por trabajador hasta el mes de corte: acumulado de meses previos
* (historial real / retenido) y valores del propio mes de corte (planilla cargada /
* retención del mes).
*/
@Query(value = """
select max(r.codper) as codper,
r.numeroDocumento as numeroDocumento,
r.apellidoPaterno as apellidoPaterno,
r.apellidoMaterno as apellidoMaterno,
r.nombres as nombres,
sum(case when r.boletaMes < :mesCorte then coalesce(r.remBruta, 0) else 0 end) as historialReal,
sum(case when r.boletaMes = :mesCorte then coalesce(r.remBruta, 0) else 0 end) as planillaCargada,
sum(case when r.boletaMes < :mesCorte then coalesce(r.renta5taCategoria, 0) else 0 end) as retenidoAnterior,
sum(case when r.boletaMes = :mesCorte then coalesce(r.renta5taCategoria, 0) else 0 end) as retencionDelMes,
sum(case when r.boletaMes = :mesCorte then coalesce(r.basico, 0) else 0 end) as basicoMesCorte,
sum(case when r.boletaMes = :mesCorte then coalesce(r.asignacionFamiliar, 0) else 0 end) as asignacionFamiliarMesCorte
from RentaQuinta r
where r.boletaAnio = :periodo
and r.boletaMes <= :mesCorte
""" + FILTROS + """
group by r.numeroDocumento, r.apellidoPaterno, r.apellidoMaterno, r.nombres
order by r.apellidoPaterno asc, r.apellidoMaterno asc, r.nombres asc
""",
countQuery = """
select count(distinct r.numeroDocumento)
from RentaQuinta r
where r.boletaAnio = :periodo
and r.boletaMes <= :mesCorte
""" + FILTROS)
Page<RentaQuintaResumenProjection> buscarResumen(
@Param("periodo") Integer periodo,
@Param("mesCorte") Integer mesCorte,
@Param("sede") String sede,
@Param("tipoPlanilla") String tipoPlanilla,
@Param("areaGeneral") String areaGeneral,
@Param("subArea") String subArea,
@Param("cargo") String cargo,
@Param("texto") String texto,
Pageable pageable);
@Query("""
select r from RentaQuinta r
where r.codper = :codper and r.boletaAnio = :anio
order by r.boletaMes asc
""")
List<RentaQuinta> historialAnual(
@Param("codper") Integer codper,
@Param("anio") Integer anio);
}
package so.planilla_be.rentaquinta.repository;
import java.math.BigDecimal;
/** Agregado anual por trabajador que alimenta la grilla de retenciones. */
public interface RentaQuintaResumenProjection {
Integer getCodper();
String getNumeroDocumento();
String getApellidoPaterno();
String getApellidoMaterno();
String getNombres();
BigDecimal getHistorialReal();
BigDecimal getPlanillaCargada();
BigDecimal getRetenidoAnterior();
BigDecimal getRetencionDelMes();
/** Básico del mes de corte, usado para proyectar gratificaciones. */
BigDecimal getBasicoMesCorte();
BigDecimal getAsignacionFamiliarMesCorte();
}
package so.planilla_be.rentaquinta.service;
import org.springframework.data.domain.Page;
import so.planilla_be.rentaquinta.dto.RentaQuintaDetalleDTO;
import so.planilla_be.rentaquinta.dto.RentaQuintaFiltroDTO;
import so.planilla_be.rentaquinta.dto.RentaQuintaHistorialDTO;
import so.planilla_be.rentaquinta.dto.RentaQuintaResumenDTO;
import java.util.List;
public interface RentaQuintaService {
Page<RentaQuintaResumenDTO> buscar(RentaQuintaFiltroDTO filtro);
RentaQuintaDetalleDTO obtenerPorId(Long id);
RentaQuintaDetalleDTO obtenerBoleta(Integer codper, Integer anio, Integer mes);
List<RentaQuintaHistorialDTO> historialAnual(Integer codper, Integer anio);
}
package so.planilla_be.rentaquinta.service.impl;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import so.planilla_be.rentaquinta.calculo.CalculoRentaQuinta;
import so.planilla_be.rentaquinta.calculo.RentaQuintaCalculadora;
import so.planilla_be.rentaquinta.calculo.RentaQuintaCalculadora.DatosCalculo;
import so.planilla_be.rentaquinta.dto.RentaQuintaDetalleDTO;
import so.planilla_be.rentaquinta.dto.RentaQuintaFiltroDTO;
import so.planilla_be.rentaquinta.dto.RentaQuintaHistorialDTO;
import so.planilla_be.rentaquinta.dto.RentaQuintaResumenDTO;
import so.planilla_be.rentaquinta.exception.RentaQuintaNoEncontradaException;
import so.planilla_be.rentaquinta.mapper.RentaQuintaMapper;
import so.planilla_be.rentaquinta.repository.RentaQuintaRepository;
import so.planilla_be.rentaquinta.repository.RentaQuintaResumenProjection;
import so.planilla_be.rentaquinta.service.RentaQuintaService;
import java.util.List;
@Service
@Transactional(readOnly = true)
public class RentaQuintaServiceImpl implements RentaQuintaService {
private static final int SIZE_DEFAULT = 20;
private static final int SIZE_MAX = 100;
private final RentaQuintaRepository repository;
private final RentaQuintaMapper mapper;
private final RentaQuintaCalculadora calculadora;
public RentaQuintaServiceImpl(RentaQuintaRepository repository, RentaQuintaMapper mapper,
RentaQuintaCalculadora calculadora) {
this.repository = repository;
this.mapper = mapper;
this.calculadora = calculadora;
}
@Override
public Page<RentaQuintaResumenDTO> buscar(RentaQuintaFiltroDTO filtro) {
return repository.buscarResumen(
filtro.periodo(),
filtro.mesCorte(),
codigo(filtro.sede()),
codigo(filtro.tipoPlanilla()),
codigo(filtro.areaGeneral()),
codigo(filtro.subArea()),
codigo(filtro.cargo()),
patron(filtro.documentoOApellidos()),
toPageable(filtro))
.map(p -> mapper.toResumen(p, filtro.periodo(), filtro.mesCorte(), calcular(p, filtro)));
}
private CalculoRentaQuinta calcular(RentaQuintaResumenProjection p, RentaQuintaFiltroDTO filtro) {
return calculadora.calcular(new DatosCalculo(
filtro.periodo(),
filtro.mesCorte(),
p.getHistorialReal(),
p.getPlanillaCargada(),
p.getPlanillaCargada(),
p.getBasicoMesCorte(),
p.getAsignacionFamiliarMesCorte(),
p.getRetenidoAnterior()));
}
/** Los catálogos llegan como Integer pero viven en columnas TEXT: se comparan como texto. */
private String codigo(Integer valor) {
return valor == null ? null : valor.toString();
}
private String patron(String texto) {
if (texto == null || texto.isBlank()) {
return null;
}
return "%" + texto.trim().toUpperCase() + "%";
}
private Pageable toPageable(RentaQuintaFiltroDTO filtro) {
int page = filtro.page() == null || filtro.page() < 0 ? 0 : filtro.page();
int size = filtro.size() == null || filtro.size() < 1 ? SIZE_DEFAULT : Math.min(filtro.size(), SIZE_MAX);
return PageRequest.of(page, size);
}
@Override
public RentaQuintaDetalleDTO obtenerPorId(Long id) {
return repository.findById(id)
.map(mapper::toDetalle)
.orElseThrow(() -> new RentaQuintaNoEncontradaException(
"No existe boleta de renta de quinta con id %d".formatted(id)));
}
@Override
public RentaQuintaDetalleDTO obtenerBoleta(Integer codper, Integer anio, Integer mes) {
return repository.findByCodperAndBoletaAnioAndBoletaMes(codper, anio, mes)
.map(mapper::toDetalle)
.orElseThrow(() -> new RentaQuintaNoEncontradaException(
"No existe boleta para codper %d, periodo %d-%02d".formatted(codper, anio, mes)));
}
@Override
public List<RentaQuintaHistorialDTO> historialAnual(Integer codper, Integer anio) {
List<RentaQuintaHistorialDTO> historial = repository.historialAnual(codper, anio).stream()
.map(mapper::toHistorial)
.toList();
if (historial.isEmpty()) {
throw new RentaQuintaNoEncontradaException(
"No existe historial para codper %d en el periodo %d".formatted(codper, anio));
}
return historial;
}
}
spring:
application:
name: planilla-be
datasource:
url: ${DB_URL:jdbc:postgresql://<HOST>:5432/<DB>}
username: ${DB_USERNAME:soporte}
password: ${DB_PASSWORD}
hikari:
maximum-pool-size: 10
jpa:
hibernate:
ddl-auto: validate # nunca 'update'/'create' sobre esta tabla
open-in-view: false
properties:
hibernate:
default_schema: planilla
format_sql: true
threads:
virtual:
enabled: true
server:
port: 8080
springdoc:
swagger-ui:
path: /docs
renta-quinta:
uit:
# Valor de la UIT por periodo (SUNAT). Actualizar cada año.
valores:
2023: 4950
2024: 5150
2025: 5350
# 2026: pendiente de confirmar el valor publicado por SUNAT
por-defecto: 5350
package so.planilla_be;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import so.planilla_be.rentaquinta.AbstractPostgresIT;
@SpringBootTest
class PlanillaBeApplicationIT extends AbstractPostgresIT {
@Test
void contextLoads() {
}
}
package so.planilla_be.rentaquinta;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
/** Base para tests de integración: levanta PostgreSQL con el DDL real de renta_quinta. */
@Testcontainers
public abstract class AbstractPostgresIT {
@Container
@ServiceConnection
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>("postgres:16-alpine")
.withInitScript("db/renta_quinta.sql");
}
package so.planilla_be.rentaquinta;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.test.web.servlet.MockMvc;
import so.planilla_be.rentaquinta.repository.RentaQuintaRepository;
import java.util.List;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
class RentaQuintaControllerIT extends AbstractPostgresIT {
@Autowired
private MockMvc mockMvc;
@Autowired
private RentaQuintaRepository repository;
@BeforeEach
void setUp() {
repository.deleteAll();
repository.saveAll(List.of(
RentaQuintaTestData.boleta(1001, "40669929", "AQUINO", 2026, 11, "47550.00", "1040.84"),
RentaQuintaTestData.boleta(1001, "40669929", "AQUINO", 2026, 12, "4000.00", "199.16"),
RentaQuintaTestData.boleta(2002, "45778812", "RAMIREZ", 2026, 12, "2800.00", "0.00")));
}
@Test
void grillaDevuelveLasColumnasDeLaTabla() throws Exception {
mockMvc.perform(get("/api/v1/renta-quinta")
.param("periodo", "2026")
.param("mesCorte", "12")
.param("documentoOApellidos", "aquino lopez"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.totalElements").value(1))
.andExpect(jsonPath("$.content[0].numeroDocumento").value("40669929"))
.andExpect(jsonPath("$.content[0].nombreCompleto").value("AQUINO LOPEZ ELIZABETH"))
.andExpect(jsonPath("$.content[0].mesProceso").value(12))
.andExpect(jsonPath("$.content[0].historialReal").value(47550.00))
.andExpect(jsonPath("$.content[0].planillaCargada").value(4000.00))
.andExpect(jsonPath("$.content[0].proyeccionFutura").value(0))
.andExpect(jsonPath("$.content[0].rentaBrutaAnual").value(51550.00))
.andExpect(jsonPath("$.content[0].impuestoAnual").doesNotExist())
.andExpect(jsonPath("$.content[0].retenidoAnterior").value(1040.84))
.andExpect(jsonPath("$.content[0].retencionDelMes").value(199.16));
}
@Test
void grillaLimitaSizeA100() throws Exception {
mockMvc.perform(get("/api/v1/renta-quinta")
.param("periodo", "2026").param("mesCorte", "12").param("size", "5000"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.size").value(100));
}
@Test
void grillaFiltraPorCatalogosNumericos() throws Exception {
mockMvc.perform(get("/api/v1/renta-quinta")
.param("periodo", "2026").param("mesCorte", "12")
.param("sede", "2").param("tipoPlanilla", "1")
.param("areaGeneral", "3").param("subArea", "4").param("cargo", "5"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.totalElements").value(2));
}
@Test
void obtenerBoletaInexistenteDevuelve404() throws Exception {
mockMvc.perform(get("/api/v1/renta-quinta/boleta")
.param("codper", "9999").param("anio", "2026").param("mes", "1"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.status").value(404));
}
@Test
void verHistorialDevuelveMesesConDetalle() throws Exception {
mockMvc.perform(get("/api/v1/renta-quinta/historial/1001/2026"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].boletaMes").value(11))
.andExpect(jsonPath("$[0].remBruta").value(47550.00))
.andExpect(jsonPath("$[0].renta5taCategoria").value(1040.84))
.andExpect(jsonPath("$[1].boletaMes").value(12));
}
}
package so.planilla_be.rentaquinta;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import so.planilla_be.rentaquinta.entity.RentaQuinta;
import so.planilla_be.rentaquinta.repository.RentaQuintaRepository;
import so.planilla_be.rentaquinta.repository.RentaQuintaResumenProjection;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
class RentaQuintaRepositoryIT extends AbstractPostgresIT {
@Autowired
private RentaQuintaRepository repository;
@BeforeEach
void setUp() {
repository.deleteAll();
repository.saveAll(List.of(
RentaQuintaTestData.boleta(1001, "40669929", "AQUINO", 2026, 11, "47550.00", "1040.84"),
RentaQuintaTestData.boleta(1001, "40669929", "AQUINO", 2026, 12, "4000.00", "199.16"),
RentaQuintaTestData.boleta(2002, "45778812", "RAMIREZ", 2026, 12, "2800.00", "0.00")));
}
@Test
void resumenAcumulaMesesPreviosYSeparaMesDeCorte() {
Page<RentaQuintaResumenProjection> page = repository.buscarResumen(
2026, 12, null, null, null, null, null, null, PageRequest.of(0, 20));
assertThat(page.getTotalElements()).isEqualTo(2);
RentaQuintaResumenProjection aquino = page.getContent().getFirst();
assertThat(aquino.getCodper()).isEqualTo(1001);
assertThat(aquino.getHistorialReal()).isEqualByComparingTo(new BigDecimal("47550.00"));
assertThat(aquino.getPlanillaCargada()).isEqualByComparingTo(new BigDecimal("4000.00"));
assertThat(aquino.getRetenidoAnterior()).isEqualByComparingTo(new BigDecimal("1040.84"));
assertThat(aquino.getRetencionDelMes()).isEqualByComparingTo(new BigDecimal("199.16"));
}
@Test
void resumenFiltraPorTextoDeApellidos() {
Page<RentaQuintaResumenProjection> page = repository.buscarResumen(
2026, 12, null, null, null, null, null, "%AQUINO LOPEZ%", PageRequest.of(0, 20));
assertThat(page.getTotalElements()).isEqualTo(1);
assertThat(page.getContent().getFirst().getNumeroDocumento()).isEqualTo("40669929");
}
@Test
void resumenFiltraPorDocumento() {
Page<RentaQuintaResumenProjection> page = repository.buscarResumen(
2026, 12, null, null, null, null, null, "%45778812%", PageRequest.of(0, 20));
assertThat(page.getTotalElements()).isEqualTo(1);
assertThat(page.getContent().getFirst().getCodper()).isEqualTo(2002);
}
@Test
void resumenFiltraPorCatalogosNumericosGuardadosComoTexto() {
Page<RentaQuintaResumenProjection> conSede = repository.buscarResumen(
2026, 12, "2", "1", "3", "4", "5", null, PageRequest.of(0, 20));
Page<RentaQuintaResumenProjection> sedeInexistente = repository.buscarResumen(
2026, 12, "9", null, null, null, null, null, PageRequest.of(0, 20));
assertThat(conSede.getTotalElements()).isEqualTo(2);
assertThat(sedeInexistente.getTotalElements()).isZero();
}
@Test
void historialAnualDevuelveMesesOrdenados() {
List<RentaQuinta> historial = repository.historialAnual(1001, 2026);
assertThat(historial).extracting(RentaQuinta::getBoletaMes).containsExactly(11, 12);
}
}
package so.planilla_be.rentaquinta;
import so.planilla_be.rentaquinta.entity.RentaQuinta;
import java.math.BigDecimal;
public final class RentaQuintaTestData {
private RentaQuintaTestData() {
}
public static RentaQuinta boleta(int codper, String documento, String apellidoPaterno,
int anio, int mes, String remBruta, String renta5ta) {
RentaQuinta r = new RentaQuinta();
r.setCodper(codper);
r.setNumeroDocumento(documento);
r.setBoletaAnio(anio);
r.setBoletaMes(mes);
r.setBoletaTipoPersonal("1");
r.setCodlocal("2");
r.setAreaGeneral("3");
r.setSubArea("4");
r.setCargo("5");
r.setApellidoPaterno(apellidoPaterno);
r.setApellidoMaterno("LOPEZ");
r.setNombres("ELIZABETH");
r.setSede("LIMA");
r.setSueldoBruto(new BigDecimal(remBruta));
r.setRemBruta(new BigDecimal(remBruta));
r.setRenta5taCategoria(new BigDecimal(renta5ta));
r.setTotalDscto(new BigDecimal("800.00"));
r.setNetoPorPagar(new BigDecimal("4200.00"));
r.setDiasLaborados(30);
return r;
}
}
package so.planilla_be.rentaquinta.calculo;
import org.junit.jupiter.api.Test;
import so.planilla_be.rentaquinta.calculo.RentaQuintaCalculadora.DatosCalculo;
import java.math.BigDecimal;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class RentaQuintaCalculadoraTest {
private static final BigDecimal UIT_2024 = new BigDecimal("5150");
private final RentaQuintaCalculadora calculadora =
new RentaQuintaCalculadora(new UitProperties(Map.of(2024, UIT_2024), UIT_2024));
private DatosCalculo enDiciembre(String historial, String mesActual, String retenidoAnterior) {
return new DatosCalculo(2024, 12, new BigDecimal(historial), new BigDecimal(mesActual),
new BigDecimal(mesActual), BigDecimal.ZERO, BigDecimal.ZERO, new BigDecimal(retenidoAnterior));
}
@Test
void enDiciembreNoProyectaYRetieneElSaldo() {
CalculoRentaQuinta r = calculadora.calcular(enDiciembre("47550.00", "4000.00", "1040.84"));
assertThat(r.proyeccionFutura()).isEqualByComparingTo("0.00");
assertThat(r.rentaBrutaAnual()).isEqualByComparingTo("51550.00");
assertThat(r.impuestoAnual()).isEqualByComparingTo("1240.00");
assertThat(r.retencionDelMes()).isEqualByComparingTo("199.16");
}
@Test
void rentaBajoLas7UitNoPagaImpuesto() {
CalculoRentaQuinta r = calculadora.calcular(enDiciembre("29600.00", "2800.00", "0.00"));
assertThat(r.rentaBrutaAnual()).isEqualByComparingTo("32400.00");
assertThat(r.impuestoAnual()).isEqualByComparingTo("0.00");
assertThat(r.retencionDelMes()).isEqualByComparingTo("0.00");
}
@Test
void aplicaTramosProgresivos() {
// 123,500 - 7 UIT = 87,450 -> 8% de las primeras 5 UIT + 14% del exceso
CalculoRentaQuinta r = calculadora.calcular(enDiciembre("114500.00", "9000.00", "11340.00"));
assertThat(r.impuestoAnual()).isEqualByComparingTo("10698.00");
// Ya se retuvo de más: la retención del mes no puede ser negativa.
assertThat(r.retencionDelMes()).isEqualByComparingTo("0.00");
}
@Test
void proyectaMesesRestantesYGratificaciones() {
// Mayo: faltan 7 meses + gratificación de julio y de diciembre.
DatosCalculo datos = new DatosCalculo(2024, 5, new BigDecimal("16000.00"), new BigDecimal("4000.00"),
new BigDecimal("4000.00"), new BigDecimal("3900.00"), new BigDecimal("100.00"),
new BigDecimal("300.00"));
CalculoRentaQuinta r = calculadora.calcular(datos);
// 4000 * 7 = 28,000 ; gratificaciones (3900+100)*1.09*2 = 8,720
assertThat(r.proyeccionFutura()).isEqualByComparingTo("36720.00");
assertThat(r.rentaBrutaAnual()).isEqualByComparingTo("56720.00");
}
@Test
void usaElDivisorDelMesDeRetencion() {
// Enero: el impuesto anual se reparte en 12.
DatosCalculo enero = new DatosCalculo(2024, 1, BigDecimal.ZERO, new BigDecimal("10000.00"),
new BigDecimal("10000.00"), BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO);
CalculoRentaQuinta r = calculadora.calcular(enero);
assertThat(r.rentaBrutaAnual()).isEqualByComparingTo("120000.00");
assertThat(r.retencionDelMes())
.isEqualByComparingTo(r.impuestoAnual().divide(new BigDecimal("12"), 2, java.math.RoundingMode.HALF_UP));
}
}
create table renta_quinta
(
id_renta_quinta bigint generated always as identity
primary key,
boleta_anio integer,
boleta_mes integer,
boleta_tipo_personal char(3),
fecha_registro timestamp default CURRENT_TIMESTAMP,
codper integer,
codlocal char(4),
unidad_organizativa integer,
tipo_documento char(2),
apellido_paterno text,
apellido_materno text,
nombres text,
documento_afp text,
sueldo_bruto numeric(15, 2),
rem_contrato numeric(15, 2),
basico numeric(15, 2),
asignacion_familiar numeric(15, 2),
movilidad numeric(15, 2),
asignacion_estudios numeric(15, 2),
bono_extraordinario numeric(15, 2),
vacaciones_truncas numeric(15, 2),
compensacion_vacacional numeric(15, 2),
vacaciones_gozadas_rem_vaca numeric(15, 2),
cts numeric(15, 2),
gratificacion_proporcional numeric(15, 2),
bonificacion_extraordinaria numeric(15, 2),
gratificacion_julio_diciembre numeric(15, 2),
bonificacion_extraordinaria_2 numeric(15, 2),
subsidio_enfermedad numeric(15, 2),
bono_productividad numeric(15, 2),
licencia_goce_haber numeric(15, 2),
subsidio_maternidad numeric(15, 2),
licencia_salud_familiar_ley_30012 numeric(15, 2),
reintegro numeric(15, 2),
horas_extras_25 numeric(15, 2),
horas_extras_35 numeric(15, 2),
apoyo_salud numeric(15, 2),
suma_graciosa numeric(15, 2),
bono_desempeno numeric(15, 2),
asignacion_fallecimiento_familiar numeric(15, 2),
dominical numeric(15, 2),
bono_aniversario numeric(15, 2),
canasta_navidad numeric(15, 2),
vale_consumo numeric(15, 2),
obsequios numeric(15, 2),
rem_bruta numeric(15, 2),
remuneracion_imponible_snp_afp numeric(15, 2),
snp numeric(15, 2),
afp_aportacion_obligatoria numeric(15, 2),
prima_seguro numeric(15, 2),
tipo_comision text,
comision_afp_porcentual numeric(10, 4),
total_afp numeric(15, 2),
renta_5ta_categoria numeric(15, 2),
adelanto numeric(15, 2),
adelanto_vacaciones numeric(15, 2),
retencion_judicial numeric(15, 2),
tardanzas numeric(15, 2),
inasistencias numeric(15, 2),
pension numeric(15, 2),
prestamos numeric(15, 2),
canasta_navidad_2 numeric(15, 2),
vale_consumo_descuento numeric(15, 2),
obsequios_descuento numeric(15, 2),
uniformes_pavitos_navidad_otros numeric(15, 2),
asistencia_tard_fsa_salida_anticipada numeric(15, 2),
uniforme numeric(15, 2),
pavos numeric(15, 2),
colaboracion numeric(15, 2),
seguro_accidente numeric(15, 2),
rifa numeric(15, 2),
otros numeric(15, 2),
dcto_eps numeric(15, 2),
total_dscto numeric(15, 2),
neto_por_pagar numeric(15, 2),
aportes numeric(15, 2),
aporte_essalud_eps numeric(15, 2),
essalud_vida numeric(15, 2),
sctr_salud numeric(15, 2),
sctr_pension numeric(15, 2),
poliza_seguro numeric(15, 2),
total_aportes numeric(15, 2),
numero_documento text,
cussp text,
fecha_nacimiento date,
fecha_ingreso date,
fecha_cese date,
fondo_pension text,
tipo_contrato text,
fecha_inicio_contrato date,
fecha_termino_contrato date,
dias_laborados integer,
dias_no_laborados integer,
dias_subsidiados integer,
descanso_medico integer,
horas_normales integer,
horas_adicionales integer,
area_general text,
sub_area text,
cargo text,
curso text,
observacion text,
sede text,
genero text,
inicio_vacaciones date,
termino_vacaciones date,
lsgh integer,
lcgh integer,
lcgh_fallecimiento integer,
faltas integer,
pago numeric(15, 2),
pendiente numeric(15, 2),
tipo_labor text,
campo_1 integer,
campo_2 integer
);
create index if not exists idx_renta_quinta_codper_anio_mes
on renta_quinta (codper, boleta_anio, boleta_mes);
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment