feat(api): add impuestos API endpoints (IVA/ISR mensual y resumen)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import { errorMiddleware } from './middlewares/error.middleware.js';
|
|||||||
import { authRoutes } from './routes/auth.routes.js';
|
import { authRoutes } from './routes/auth.routes.js';
|
||||||
import { dashboardRoutes } from './routes/dashboard.routes.js';
|
import { dashboardRoutes } from './routes/dashboard.routes.js';
|
||||||
import { cfdiRoutes } from './routes/cfdi.routes.js';
|
import { cfdiRoutes } from './routes/cfdi.routes.js';
|
||||||
|
import { impuestosRoutes } from './routes/impuestos.routes.js';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ app.get('/health', (req, res) => {
|
|||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
app.use('/api/dashboard', dashboardRoutes);
|
app.use('/api/dashboard', dashboardRoutes);
|
||||||
app.use('/api/cfdi', cfdiRoutes);
|
app.use('/api/cfdi', cfdiRoutes);
|
||||||
|
app.use('/api/impuestos', impuestosRoutes);
|
||||||
|
|
||||||
// Error handling
|
// Error handling
|
||||||
app.use(errorMiddleware);
|
app.use(errorMiddleware);
|
||||||
|
|||||||
63
apps/api/src/controllers/impuestos.controller.ts
Normal file
63
apps/api/src/controllers/impuestos.controller.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
|
import * as impuestosService from '../services/impuestos.service.js';
|
||||||
|
import { AppError } from '../middlewares/error.middleware.js';
|
||||||
|
|
||||||
|
export async function getIvaMensual(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.tenantSchema) {
|
||||||
|
return next(new AppError(400, 'Schema no configurado'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const año = parseInt(req.query.año as string) || new Date().getFullYear();
|
||||||
|
const data = await impuestosService.getIvaMensual(req.tenantSchema, año);
|
||||||
|
res.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getResumenIva(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.tenantSchema) {
|
||||||
|
return next(new AppError(400, 'Schema no configurado'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const año = parseInt(req.query.año as string) || new Date().getFullYear();
|
||||||
|
const mes = parseInt(req.query.mes as string) || new Date().getMonth() + 1;
|
||||||
|
|
||||||
|
const resumen = await impuestosService.getResumenIva(req.tenantSchema, año, mes);
|
||||||
|
res.json(resumen);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getIsrMensual(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.tenantSchema) {
|
||||||
|
return next(new AppError(400, 'Schema no configurado'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const año = parseInt(req.query.año as string) || new Date().getFullYear();
|
||||||
|
const data = await impuestosService.getIsrMensual(req.tenantSchema, año);
|
||||||
|
res.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getResumenIsr(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.tenantSchema) {
|
||||||
|
return next(new AppError(400, 'Schema no configurado'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const año = parseInt(req.query.año as string) || new Date().getFullYear();
|
||||||
|
const mes = parseInt(req.query.mes as string) || new Date().getMonth() + 1;
|
||||||
|
|
||||||
|
const resumen = await impuestosService.getResumenIsr(req.tenantSchema, año, mes);
|
||||||
|
res.json(resumen);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
apps/api/src/routes/impuestos.routes.ts
Normal file
16
apps/api/src/routes/impuestos.routes.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||||
|
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
|
||||||
|
import * as impuestosController from '../controllers/impuestos.controller.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.use(authenticate);
|
||||||
|
router.use(tenantMiddleware);
|
||||||
|
|
||||||
|
router.get('/iva/mensual', impuestosController.getIvaMensual);
|
||||||
|
router.get('/iva/resumen', impuestosController.getResumenIva);
|
||||||
|
router.get('/isr/mensual', impuestosController.getIsrMensual);
|
||||||
|
router.get('/isr/resumen', impuestosController.getResumenIsr);
|
||||||
|
|
||||||
|
export { router as impuestosRoutes };
|
||||||
158
apps/api/src/services/impuestos.service.ts
Normal file
158
apps/api/src/services/impuestos.service.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { prisma } from '../config/database.js';
|
||||||
|
import type { IvaMensual, IsrMensual, ResumenIva, ResumenIsr } from '@horux/shared';
|
||||||
|
|
||||||
|
export async function getIvaMensual(schema: string, año: number): Promise<IvaMensual[]> {
|
||||||
|
const data = await prisma.$queryRawUnsafe<IvaMensual[]>(`
|
||||||
|
SELECT
|
||||||
|
id, año, mes,
|
||||||
|
iva_trasladado as "ivaTrasladado",
|
||||||
|
iva_acreditable as "ivaAcreditable",
|
||||||
|
COALESCE(iva_retenido, 0) as "ivaRetenido",
|
||||||
|
resultado, acumulado, estado,
|
||||||
|
fecha_declaracion as "fechaDeclaracion"
|
||||||
|
FROM "${schema}".iva_mensual
|
||||||
|
WHERE año = $1
|
||||||
|
ORDER BY mes
|
||||||
|
`, año);
|
||||||
|
|
||||||
|
return data.map(row => ({
|
||||||
|
...row,
|
||||||
|
ivaTrasladado: Number(row.ivaTrasladado),
|
||||||
|
ivaAcreditable: Number(row.ivaAcreditable),
|
||||||
|
ivaRetenido: Number(row.ivaRetenido),
|
||||||
|
resultado: Number(row.resultado),
|
||||||
|
acumulado: Number(row.acumulado),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getResumenIva(schema: string, año: number, mes: number): Promise<ResumenIva> {
|
||||||
|
// Get from iva_mensual if exists
|
||||||
|
const existing = await prisma.$queryRawUnsafe<any[]>(`
|
||||||
|
SELECT * FROM "${schema}".iva_mensual WHERE año = $1 AND mes = $2
|
||||||
|
`, año, mes);
|
||||||
|
|
||||||
|
if (existing && existing.length > 0) {
|
||||||
|
const record = existing[0];
|
||||||
|
const [acumuladoResult] = await prisma.$queryRawUnsafe<[{ total: number }]>(`
|
||||||
|
SELECT COALESCE(SUM(resultado), 0) as total
|
||||||
|
FROM "${schema}".iva_mensual
|
||||||
|
WHERE año = $1 AND mes <= $2
|
||||||
|
`, año, mes);
|
||||||
|
|
||||||
|
return {
|
||||||
|
trasladado: Number(record.iva_trasladado || 0),
|
||||||
|
acreditable: Number(record.iva_acreditable || 0),
|
||||||
|
retenido: Number(record.iva_retenido || 0),
|
||||||
|
resultado: Number(record.resultado || 0),
|
||||||
|
acumuladoAnual: Number(acumuladoResult?.total || 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate from CFDIs if no iva_mensual record
|
||||||
|
const [calcResult] = await prisma.$queryRawUnsafe<[{
|
||||||
|
trasladado: number;
|
||||||
|
acreditable: number;
|
||||||
|
retenido: number;
|
||||||
|
}]>(`
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN iva ELSE 0 END), 0) as trasladado,
|
||||||
|
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN iva ELSE 0 END), 0) as acreditable,
|
||||||
|
COALESCE(SUM(iva_retenido), 0) as retenido
|
||||||
|
FROM "${schema}".cfdis
|
||||||
|
WHERE estado = 'vigente'
|
||||||
|
AND EXTRACT(YEAR FROM fecha_emision) = $1
|
||||||
|
AND EXTRACT(MONTH FROM fecha_emision) = $2
|
||||||
|
`, año, mes);
|
||||||
|
|
||||||
|
const trasladado = Number(calcResult?.trasladado || 0);
|
||||||
|
const acreditable = Number(calcResult?.acreditable || 0);
|
||||||
|
const retenido = Number(calcResult?.retenido || 0);
|
||||||
|
const resultado = trasladado - acreditable - retenido;
|
||||||
|
|
||||||
|
return {
|
||||||
|
trasladado,
|
||||||
|
acreditable,
|
||||||
|
retenido,
|
||||||
|
resultado,
|
||||||
|
acumuladoAnual: resultado,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getIsrMensual(schema: string, año: number): Promise<IsrMensual[]> {
|
||||||
|
// Check if isr_mensual table exists
|
||||||
|
try {
|
||||||
|
const data = await prisma.$queryRawUnsafe<IsrMensual[]>(`
|
||||||
|
SELECT
|
||||||
|
id, año, mes,
|
||||||
|
ingresos_acumulados as "ingresosAcumulados",
|
||||||
|
deducciones,
|
||||||
|
base_gravable as "baseGravable",
|
||||||
|
isr_causado as "isrCausado",
|
||||||
|
isr_retenido as "isrRetenido",
|
||||||
|
isr_a_pagar as "isrAPagar",
|
||||||
|
estado,
|
||||||
|
fecha_declaracion as "fechaDeclaracion"
|
||||||
|
FROM "${schema}".isr_mensual
|
||||||
|
WHERE año = $1
|
||||||
|
ORDER BY mes
|
||||||
|
`, año);
|
||||||
|
|
||||||
|
return data.map(row => ({
|
||||||
|
...row,
|
||||||
|
ingresosAcumulados: Number(row.ingresosAcumulados),
|
||||||
|
deducciones: Number(row.deducciones),
|
||||||
|
baseGravable: Number(row.baseGravable),
|
||||||
|
isrCausado: Number(row.isrCausado),
|
||||||
|
isrRetenido: Number(row.isrRetenido),
|
||||||
|
isrAPagar: Number(row.isrAPagar),
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
// Table doesn't exist, return empty array
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getResumenIsr(schema: string, año: number, mes: number): Promise<ResumenIsr> {
|
||||||
|
// Calculate from CFDIs
|
||||||
|
const [ingresos] = await prisma.$queryRawUnsafe<[{ total: number }]>(`
|
||||||
|
SELECT COALESCE(SUM(total), 0) as total
|
||||||
|
FROM "${schema}".cfdis
|
||||||
|
WHERE tipo = 'ingreso' AND estado = 'vigente'
|
||||||
|
AND EXTRACT(YEAR FROM fecha_emision) = $1
|
||||||
|
AND EXTRACT(MONTH FROM fecha_emision) <= $2
|
||||||
|
`, año, mes);
|
||||||
|
|
||||||
|
const [egresos] = await prisma.$queryRawUnsafe<[{ total: number }]>(`
|
||||||
|
SELECT COALESCE(SUM(total), 0) as total
|
||||||
|
FROM "${schema}".cfdis
|
||||||
|
WHERE tipo = 'egreso' AND estado = 'vigente'
|
||||||
|
AND EXTRACT(YEAR FROM fecha_emision) = $1
|
||||||
|
AND EXTRACT(MONTH FROM fecha_emision) <= $2
|
||||||
|
`, año, mes);
|
||||||
|
|
||||||
|
const [retenido] = await prisma.$queryRawUnsafe<[{ total: number }]>(`
|
||||||
|
SELECT COALESCE(SUM(isr_retenido), 0) as total
|
||||||
|
FROM "${schema}".cfdis
|
||||||
|
WHERE estado = 'vigente'
|
||||||
|
AND EXTRACT(YEAR FROM fecha_emision) = $1
|
||||||
|
AND EXTRACT(MONTH FROM fecha_emision) <= $2
|
||||||
|
`, año, mes);
|
||||||
|
|
||||||
|
const ingresosAcumulados = Number(ingresos?.total || 0);
|
||||||
|
const deducciones = Number(egresos?.total || 0);
|
||||||
|
const baseGravable = Math.max(0, ingresosAcumulados - deducciones);
|
||||||
|
|
||||||
|
// Simplified ISR calculation (actual calculation would use SAT tables)
|
||||||
|
const isrCausado = baseGravable * 0.30; // 30% simplified rate
|
||||||
|
const isrRetenido = Number(retenido?.total || 0);
|
||||||
|
const isrAPagar = Math.max(0, isrCausado - isrRetenido);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ingresosAcumulados,
|
||||||
|
deducciones,
|
||||||
|
baseGravable,
|
||||||
|
isrCausado,
|
||||||
|
isrRetenido,
|
||||||
|
isrAPagar,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user