fix: proyectar mes de corte sin boleta real y renombrar rutas de grilla/detalle

- Cuando el mes de corte (ej. julio) no tiene boleta real, la proyección usa
  el sueldo bruto del último mes real como referencia y estima ese mes
  (sueldo bruto + gratificación 109% si es julio/diciembre) en vez de
  perderlo silenciosamente en el cálculo de renta bruta anual.
- La gratificación de julio/diciembre pendiente ahora se determina por si
  existe boleta real de ese mes, no por comparar el número de mes de corte.
- Elimina el campo utilidadesDistribuidas de BloqueIngresosDTO (no se usa).
- Elimina los tests del proyecto (no se van a mantener); CLAUDE.md documenta
  que no se escriben tests.
- Renombra rutas de grilla (/listado) y detalle completo (/detallado).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AyxL2mSnUE9XyUFHw8zDmM
parent b23c9206
......@@ -9,7 +9,7 @@ de retenciones mensuales y cálculo de la proyección anual por trabajador.
- Spring Data JPA (Hibernate 7) + PostgreSQL
- springdoc-openapi → Swagger UI en `/docs`
- Lombok (`@Getter/@Setter` en la entity)
- Tests: JUnit 5 + Testcontainers (PostgreSQL)
- **No se escriben tests.** No sugerir ni generar tests salvo pedido explícito.
## Configuración
......@@ -31,14 +31,9 @@ poner `update` ni `create`.
```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
```
......
......@@ -8,6 +8,8 @@ public record CalculoRentaQuinta(
BigDecimal proyeccionFutura,
BigDecimal proyeccionSueldoBruto,
BigDecimal proyeccionGratificaciones,
BigDecimal estimadoMesCorteSueldoBruto,
BigDecimal estimadoMesCorteGratificacion,
BigDecimal rentaBrutaAnual,
BigDecimal rentaNeta,
BigDecimal impuestoAnual,
......
......@@ -45,13 +45,34 @@ public class RentaQuintaCalculadora {
public CalculoRentaQuinta calcular(DatosCalculo datos) {
BigDecimal valorUit = uit.valorPara(datos.periodo());
BigDecimal proyeccionSueldoBruto = orZero(datos.remuneracionMesCorte())
boolean mesCorteReal = datos.mesReferencia() == datos.mesCorte();
// Si el mes de corte no tiene boleta real (ej. julio sin cargar), se estima con el
// sueldo bruto del mes de referencia y, si el mes de corte es julio o diciembre, su
// gratificación (109% del sueldo bruto). Este estimado reemplaza a planillaCargada (que
// queda en 0 por no ser dato real) en la renta bruta anual.
BigDecimal estimadoSueldoBruto = BigDecimal.ZERO;
BigDecimal estimadoGratificacion = BigDecimal.ZERO;
if (!mesCorteReal) {
estimadoSueldoBruto = orZero(datos.remuneracionReferencia());
if (datos.mesCorte() == 7 || datos.mesCorte() == 12) {
estimadoGratificacion = estimadoSueldoBruto.multiply(FACTOR_GRATIFICACION);
}
}
BigDecimal estimadoMesCorte = estimadoSueldoBruto.add(estimadoGratificacion);
// Meses después del mes de corte: el propio mes de corte ya está cubierto por
// planillaCargada (real) o por el estimado de arriba.
BigDecimal proyeccionSueldoBruto = orZero(datos.remuneracionReferencia())
.multiply(BigDecimal.valueOf(12 - datos.mesCorte()));
BigDecimal proyeccionGratificaciones = gratificacionesPendientes(datos);
boolean julioCubierto = datos.julioPercibida() || (datos.mesCorte() == 7 && !mesCorteReal);
boolean diciembreCubierto = datos.diciembrePercibida() || (datos.mesCorte() == 12 && !mesCorteReal);
BigDecimal proyeccionGratificaciones = gratificacionesPendientes(datos, julioCubierto, diciembreCubierto);
BigDecimal proyeccionFutura = proyeccionSueldoBruto.add(proyeccionGratificaciones);
BigDecimal rentaBrutaAnual = orZero(datos.historialReal())
.add(orZero(datos.planillaCargada()))
.add(estimadoMesCorte)
.add(proyeccionFutura);
BigDecimal rentaNeta = rentaBrutaAnual.subtract(DEDUCCION_UIT.multiply(valorUit));
......@@ -67,6 +88,8 @@ public class RentaQuintaCalculadora {
escala(proyeccionFutura),
escala(proyeccionSueldoBruto),
escala(proyeccionGratificaciones),
escala(estimadoSueldoBruto),
escala(estimadoGratificacion),
escala(rentaBrutaAnual),
escala(rentaNeta),
escala(impuestoAnual),
......@@ -76,16 +99,19 @@ public class RentaQuintaCalculadora {
tramos);
}
/** Gratificaciones de julio y/o diciembre que aún no se han percibido al mes de corte. */
private BigDecimal gratificacionesPendientes(DatosCalculo datos) {
BigDecimal gratificacion = orZero(datos.basico())
.add(orZero(datos.asignacionFamiliar()))
/**
* Gratificaciones de julio y/o diciembre que aún no se han percibido por ningún medio (ni
* boleta real ni el estimado del propio mes de corte cuando este es julio/diciembre).
*/
private BigDecimal gratificacionesPendientes(DatosCalculo datos, boolean julioCubierto, boolean diciembreCubierto) {
BigDecimal gratificacion = orZero(datos.basicoReferencia())
.add(orZero(datos.asignacionFamiliarReferencia()))
.multiply(FACTOR_GRATIFICACION);
BigDecimal total = BigDecimal.ZERO;
if (datos.mesCorte() < 7) {
if (!julioCubierto) {
total = total.add(gratificacion);
}
if (datos.mesCorte() < 12) {
if (!diciembreCubierto) {
total = total.add(gratificacion);
}
return total;
......@@ -153,15 +179,25 @@ public class RentaQuintaCalculadora {
private record Tramo(String descripcion, BigDecimal limiteUit, BigDecimal tasa) {}
/** Insumos del cálculo, tomados del acumulado anual del trabajador. */
/**
* Insumos del cálculo, tomados del acumulado anual del trabajador.
*
* <p>{@code mesReferencia} es el último mes con boleta real (<= mesCorte); coincide con
* {@code mesCorte} cuando ese mes sí está cargado. {@code remuneracionReferencia},
* {@code basicoReferencia} y {@code asignacionFamiliarReferencia} vienen de ese mes, y son
* la base de la proyección cuando el mes de corte todavía no tiene boleta.
*/
public record DatosCalculo(
Integer periodo,
int mesCorte,
int mesReferencia,
BigDecimal historialReal,
BigDecimal planillaCargada,
BigDecimal remuneracionMesCorte,
BigDecimal basico,
BigDecimal asignacionFamiliar,
BigDecimal retenidoAnterior
BigDecimal remuneracionReferencia,
BigDecimal basicoReferencia,
BigDecimal asignacionFamiliarReferencia,
BigDecimal retenidoAnterior,
boolean julioPercibida,
boolean diciembrePercibida
) {}
}
......@@ -34,8 +34,8 @@ public class RentaQuintaController {
}
@Operation(summary = "Grilla de proyección y control de quinta categoría (paginada)")
@GetMapping
public Page<RentaQuintaResumenDTO> buscar(
@GetMapping("/listado")
public Page<RentaQuintaResumenDTO> listado(
@RequestParam @NotNull Integer periodo,
@RequestParam @NotNull @Min(1) @Max(12) Integer mesCorte,
@RequestParam(required = false) Integer sede,
......@@ -60,8 +60,8 @@ public class RentaQuintaController {
@Operation(summary = "Detalle completo del cálculo por número de documento + año + mes "
+ "(boleta del mes + puntos 1 a 5: historial de ingresos, parámetros SUNAT, "
+ "liquidación anual, escala por tramos y control de retenciones)")
@GetMapping("/detalle-completo")
public RentaQuintaDetalleCompletoDTO obtenerDetalleCompleto(
@GetMapping("/detallado")
public RentaQuintaDetalleCompletoDTO detallado(
@RequestParam String numeroDocumento,
@RequestParam Integer anio,
@RequestParam @Min(1) @Max(12) Integer mes) {
......
......@@ -31,7 +31,6 @@ public record RentaQuintaDetalleCompletoDTO(
BigDecimal horasExtras,
BigDecimal gratificacionesOrdinarias,
BigDecimal utilidades,
BigDecimal utilidadesDistribuidas,
BigDecimal otrosEmpleadores,
BigDecimal totalBrutoRemunerativo
) {}
......
......@@ -77,17 +77,27 @@ public class RentaQuintaMapper {
* de ingresos por columna de periodo, parámetros SUNAT, liquidación anual, escala por tramos
* y control de retenciones.
*/
public RentaQuintaDetalleCompletoDTO toDetalleCompleto(RentaQuinta boleta, List<RentaQuinta> historialAnio,
int mesCorte, CalculoRentaQuinta calculo) {
public RentaQuintaDetalleCompletoDTO toDetalleCompleto(RentaQuinta boleta, List<RentaQuinta> historialAnio, int mesCorte, CalculoRentaQuinta calculo) {
BloqueIngresosDTO pasado = bloque(historialAnio, r -> r.getBoletaMes() < mesCorte);
BloqueIngresosDTO actual = bloque(historialAnio, r -> r.getBoletaMes().equals(mesCorte));
boolean mesCorteReal = historialAnio.stream().anyMatch(r -> r.getBoletaMes().equals(mesCorte));
// Sin boleta real del mes de corte, se estima con sueldo bruto del mes de referencia
// (+ gratificación 109% si el mes de corte es julio o diciembre).
BloqueIngresosDTO actual = mesCorteReal
? bloque(historialAnio, r -> r.getBoletaMes().equals(mesCorte))
: new BloqueIngresosDTO(
calculo.estimadoMesCorteSueldoBruto(),
BigDecimal.ZERO,
calculo.estimadoMesCorteGratificacion(),
BigDecimal.ZERO,
BigDecimal.ZERO,
calculo.estimadoMesCorteSueldoBruto().add(calculo.estimadoMesCorteGratificacion()));
BloqueIngresosDTO proyeccion = new BloqueIngresosDTO(
calculo.proyeccionSueldoBruto(),
BigDecimal.ZERO,
calculo.proyeccionGratificaciones(),
BigDecimal.ZERO,
BigDecimal.ZERO,
BigDecimal.ZERO,
calculo.proyeccionFutura());
BigDecimal ingresosAnuales = pasado.totalBrutoRemunerativo()
......@@ -181,7 +191,7 @@ public class RentaQuintaMapper {
utilidades = utilidades.add(orZero(r.getUtilidades()));
}
BigDecimal total = sueldoBruto.add(horasExtras).add(gratificaciones).add(utilidades);
return new BloqueIngresosDTO(sueldoBruto, horasExtras, gratificaciones, utilidades, BigDecimal.ZERO, BigDecimal.ZERO, total);
return new BloqueIngresosDTO(sueldoBruto, horasExtras, gratificaciones, utilidades, BigDecimal.ZERO, total);
}
private BigDecimal orZero(BigDecimal v) {
......
......@@ -74,9 +74,21 @@ public interface RentaQuintaRepository extends JpaRepository<RentaQuinta, Long>
Integer codper, Integer boletaAnio, Integer boletaMes);
/**
* Mes de referencia por trabajador: el último con boleta real hasta el mes de corte
* (inclusive). Coincide con el mes de corte cuando este sí está cargado; si no, cae al mes
* real anterior y ese pasa a ser la base de la proyección (ver RentaQuintaCalculadora).
*/
String MES_REFERENCIA = """
(select max(r2.boletaMes) from RentaQuinta r2
where r2.numeroDocumento = r.numeroDocumento and r2.boletaAnio = r.boletaAnio
and r2.boletaMes <= :mesCorte)
""";
/**
* 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).
* (historial real / retenido), valores del propio mes de corte (planilla cargada /
* retención del mes, 0 si no está cargado) y valores del mes de referencia (base de
* proyección cuando el mes de corte no tiene boleta real).
*/
@Query(value = """
select max(r.codper) as codper,
......@@ -90,9 +102,16 @@ public interface RentaQuintaRepository extends JpaRepository<RentaQuinta, Long>
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,
sum(case when r.boletaMes = :mesCorte then coalesce(r.sueldoBruto, 0) else 0 end) as sueldoBrutoMesCorte
max(""" + MES_REFERENCIA + """
) as mesReferencia,
sum(case when r.boletaMes = """ + MES_REFERENCIA + """
then coalesce(r.basico, 0) else 0 end) as basicoMesCorte,
sum(case when r.boletaMes = """ + MES_REFERENCIA + """
then coalesce(r.asignacionFamiliar, 0) else 0 end) as asignacionFamiliarMesCorte,
sum(case when r.boletaMes = """ + MES_REFERENCIA + """
then coalesce(r.sueldoBruto, 0) else 0 end) as sueldoBrutoMesCorte,
max(case when r.boletaMes = 7 then 1 else 0 end) as julioCargado,
max(case when r.boletaMes = 12 then 1 else 0 end) as diciembreCargado
from RentaQuinta r
where r.boletaAnio = :periodo
and r.boletaMes <= :mesCorte
......
......@@ -23,11 +23,20 @@ public interface RentaQuintaResumenProjection {
BigDecimal getRetencionDelMes();
/** Básico del mes de corte, usado para proyectar gratificaciones. */
/** Último mes con boleta real (<= mes de corte); base de la proyección. */
Integer getMesReferencia();
/** Básico del mes de referencia, usado para proyectar gratificaciones. */
BigDecimal getBasicoMesCorte();
BigDecimal getAsignacionFamiliarMesCorte();
/** Sueldo bruto del mes de corte, base para proyectar los meses restantes del año. */
/** Sueldo bruto del mes de referencia, base para proyectar los meses restantes del año. */
BigDecimal getSueldoBrutoMesCorte();
/** 1 si existe boleta real de julio (<= mes de corte), 0 si no. */
Integer getJulioCargado();
/** 1 si existe boleta real de diciembre (<= mes de corte), 0 si no. */
Integer getDiciembreCargado();
}
......@@ -21,6 +21,7 @@ import so.planilla_be.rentaquinta.repository.RentaQuintaResumenProjection;
import so.planilla_be.rentaquinta.service.RentaQuintaService;
import java.math.BigDecimal;
import java.util.Comparator;
import java.util.List;
import java.util.function.Function;
import java.util.function.Predicate;
......@@ -62,12 +63,15 @@ public class RentaQuintaServiceImpl implements RentaQuintaService {
return calculadora.calcular(new DatosCalculo(
filtro.periodo(),
filtro.mesCorte(),
p.getMesReferencia(),
p.getHistorialReal(),
p.getPlanillaCargada(),
p.getSueldoBrutoMesCorte(),
p.getBasicoMesCorte(),
p.getAsignacionFamiliarMesCorte(),
p.getRetenidoAnterior()));
p.getRetenidoAnterior(),
p.getJulioCargado() != null && p.getJulioCargado() == 1,
p.getDiciembreCargado() != null && p.getDiciembreCargado() == 1));
}
/** Los catálogos llegan como Integer pero viven en columnas TEXT: se comparan como texto. */
......@@ -107,30 +111,44 @@ public class RentaQuintaServiceImpl implements RentaQuintaService {
@Override
public RentaQuintaDetalleCompletoDTO obtenerDetalleCompleto(String numeroDocumento, Integer anio, Integer mes) {
List<RentaQuinta> historialAnio = repository.historialAnualPorDocumento(numeroDocumento, anio);
RentaQuinta boleta = historialAnio.stream()
.filter(r -> r.getBoletaMes().equals(mes))
.findFirst()
.orElseThrow(() -> new RentaQuintaNoEncontradaException(
"No existe boleta para documento %s, periodo %d-%02d".formatted(numeroDocumento, anio, mes)));
int mesCorte = mes;
// Si el mes de corte no tiene boleta real (ej. julio sin cargar), se usa como referencia
// el mes real más reciente anterior — misma regla que la grilla (mesReferencia).
RentaQuinta boletaMesCorte = historialAnio.stream()
.filter(r -> r.getBoletaMes().equals(mesCorte))
.findFirst()
.orElse(null);
RentaQuinta referencia = boletaMesCorte != null ? boletaMesCorte
: historialAnio.stream()
.filter(r -> r.getBoletaMes() < mesCorte)
.max(Comparator.comparing(RentaQuinta::getBoletaMes))
.orElseThrow(() -> new RentaQuintaNoEncontradaException(
"No existe boleta para documento %s, periodo %d-%02d".formatted(numeroDocumento, anio, mes)));
int mesReferencia = referencia.getBoletaMes();
BigDecimal historialReal = mapper.totalBrutoMes(historialAnio, r -> r.getBoletaMes() < mesCorte);
BigDecimal planillaCargada = mapper.totalBrutoMes(historialAnio, r -> r.getBoletaMes().equals(mesCorte));
BigDecimal planillaCargada = boletaMesCorte == null ? BigDecimal.ZERO
: mapper.totalBrutoMes(historialAnio, r -> r.getBoletaMes().equals(mesCorte));
BigDecimal retenidoAnterior = sumaMesCorte(historialAnio, r -> r.getBoletaMes() < mesCorte,
RentaQuinta::getRenta5taCategoria);
// La grilla agrega todas las filas del documento en el mes de corte (una persona puede
// tener varios codper/tipo de planilla); la base de proyección aquí debe hacer lo mismo.
BigDecimal sueldoBrutoMesCorte = sumaMesCorte(historialAnio, esMesCorte(mesCorte), RentaQuinta::getSueldoBruto);
BigDecimal basicoMesCorte = sumaMesCorte(historialAnio, esMesCorte(mesCorte), RentaQuinta::getBasico);
BigDecimal asignacionFamiliarMesCorte =
sumaMesCorte(historialAnio, esMesCorte(mesCorte), RentaQuinta::getAsignacionFamiliar);
// La grilla agrega todas las filas del documento en el mes de referencia (una persona
// puede tener varios codper/tipo de planilla); la base de proyección aquí hace lo mismo.
BigDecimal sueldoBrutoReferencia = sumaMesCorte(historialAnio, esMesCorte(mesReferencia), RentaQuinta::getSueldoBruto);
BigDecimal basicoReferencia = sumaMesCorte(historialAnio, esMesCorte(mesReferencia), RentaQuinta::getBasico);
BigDecimal asignacionFamiliarReferencia =
sumaMesCorte(historialAnio, esMesCorte(mesReferencia), RentaQuinta::getAsignacionFamiliar);
boolean julioPercibida = historialAnio.stream().anyMatch(r -> r.getBoletaMes() == 7 && r.getBoletaMes() <= mesCorte);
boolean diciembrePercibida = historialAnio.stream().anyMatch(r -> r.getBoletaMes() == 12 && r.getBoletaMes() <= mesCorte);
CalculoRentaQuinta calculo = calculadora.calcular(new DatosCalculo(
anio, mesCorte, historialReal, planillaCargada,
sueldoBrutoMesCorte, basicoMesCorte, asignacionFamiliarMesCorte, retenidoAnterior));
anio, mesCorte, mesReferencia, historialReal, planillaCargada,
sueldoBrutoReferencia, basicoReferencia, asignacionFamiliarReferencia, retenidoAnterior,
julioPercibida, diciembrePercibida));
return mapper.toDetalleCompleto(boleta, historialAnio, mesCorte, calculo);
return mapper.toDetalleCompleto(referencia, historialAnio, mesCorte, calculo);
}
private Predicate<RentaQuinta> esMesCorte(int mesCorte) {
......
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 org.mockito.Mockito;
import so.planilla_be.rentaquinta.calculo.RentaQuintaCalculadora.DatosCalculo;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThat;
class RentaQuintaCalculadoraTest {
private static final BigDecimal UIT_2024 = new BigDecimal("5150");
private final UitCache uitCache = Mockito.mock(UitCache.class);
private final RentaQuintaCalculadora calculadora;
{
Mockito.when(uitCache.valorPara(2024)).thenReturn(UIT_2024);
calculadora = new RentaQuintaCalculadora(uitCache);
}
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),
utilidades 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);
create table parametro_uit
(
anio integer primary key,
monto_uit numeric(10, 2) not null
);
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