feat: bulk XML upload, period selector, and session persistence
- Add bulk XML CFDI upload support (up to 300MB) - Add period selector component for month/year navigation - Fix session persistence on page refresh (Zustand hydration) - Fix income/expense classification based on tenant RFC - Fix IVA calculation from XML (correct Impuestos element) - Add error handling to reportes page - Support multiple CORS origins - Update reportes service with proper Decimal/BigInt handling - Add RFC to tenant view store for proper CFDI classification - Update README with changelog and new features Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import { env } from './config/env.js';
|
||||
import { env, getCorsOrigins } from './config/env.js';
|
||||
import { errorMiddleware } from './middlewares/error.middleware.js';
|
||||
import { authRoutes } from './routes/auth.routes.js';
|
||||
import { dashboardRoutes } from './routes/dashboard.routes.js';
|
||||
@@ -19,13 +19,13 @@ const app = express();
|
||||
// Security
|
||||
app.use(helmet());
|
||||
app.use(cors({
|
||||
origin: env.CORS_ORIGIN,
|
||||
origin: getCorsOrigins(),
|
||||
credentials: true,
|
||||
}));
|
||||
|
||||
// Body parsing
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
// Body parsing - increased limit for bulk XML uploads
|
||||
app.use(express.json({ limit: '300mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '300mb' }));
|
||||
|
||||
// Health check
|
||||
app.get('/health', (req, res) => {
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { z } from 'zod';
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
|
||||
// Load .env file from the api package root
|
||||
config({ path: resolve(process.cwd(), '.env') });
|
||||
|
||||
const envSchema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||
@@ -10,6 +15,11 @@ const envSchema = z.object({
|
||||
CORS_ORIGIN: z.string().default('http://localhost:3000'),
|
||||
});
|
||||
|
||||
// Parse CORS origins (comma-separated) into array
|
||||
export function getCorsOrigins(): string[] {
|
||||
return parsed.data.CORS_ORIGIN.split(',').map(origin => origin.trim());
|
||||
}
|
||||
|
||||
const parsed = envSchema.safeParse(process.env);
|
||||
|
||||
if (!parsed.success) {
|
||||
|
||||
@@ -60,3 +60,70 @@ export async function getResumen(req: Request, res: Response, next: NextFunction
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createCfdi(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantSchema) {
|
||||
return next(new AppError(400, 'Schema no configurado'));
|
||||
}
|
||||
|
||||
// Only admin and contador can create CFDIs
|
||||
if (!['admin', 'contador'].includes(req.user!.role)) {
|
||||
return next(new AppError(403, 'No tienes permisos para agregar CFDIs'));
|
||||
}
|
||||
|
||||
const cfdi = await cfdiService.createCfdi(req.tenantSchema, req.body);
|
||||
res.status(201).json(cfdi);
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('duplicate')) {
|
||||
return next(new AppError(409, 'Este CFDI ya existe (UUID duplicado)'));
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createManyCfdis(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantSchema) {
|
||||
return next(new AppError(400, 'Schema no configurado'));
|
||||
}
|
||||
|
||||
if (!['admin', 'contador'].includes(req.user!.role)) {
|
||||
return next(new AppError(403, 'No tienes permisos para agregar CFDIs'));
|
||||
}
|
||||
|
||||
if (!Array.isArray(req.body.cfdis)) {
|
||||
return next(new AppError(400, 'Se requiere un array de CFDIs'));
|
||||
}
|
||||
|
||||
console.log(`[CFDI Bulk] Recibidos ${req.body.cfdis.length} CFDIs para schema ${req.tenantSchema}`);
|
||||
|
||||
// Log first CFDI for debugging
|
||||
if (req.body.cfdis.length > 0) {
|
||||
console.log('[CFDI Bulk] Primer CFDI:', JSON.stringify(req.body.cfdis[0], null, 2));
|
||||
}
|
||||
|
||||
const count = await cfdiService.createManyCfdis(req.tenantSchema, req.body.cfdis);
|
||||
res.status(201).json({ message: `${count} CFDIs creados exitosamente`, count });
|
||||
} catch (error: any) {
|
||||
console.error('[CFDI Bulk Error]', error.message, error.stack);
|
||||
next(new AppError(400, error.message || 'Error al procesar CFDIs'));
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCfdi(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantSchema) {
|
||||
return next(new AppError(400, 'Schema no configurado'));
|
||||
}
|
||||
|
||||
if (!['admin', 'contador'].includes(req.user!.role)) {
|
||||
return next(new AppError(403, 'No tienes permisos para eliminar CFDIs'));
|
||||
}
|
||||
|
||||
await cfdiService.deleteCfdi(req.tenantSchema, req.params.id);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,11 @@ export async function getEstadoResultados(req: Request, res: Response, next: Nex
|
||||
const inicio = (fechaInicio as string) || `${now.getFullYear()}-01-01`;
|
||||
const fin = (fechaFin as string) || now.toISOString().split('T')[0];
|
||||
|
||||
console.log('[reportes] getEstadoResultados - schema:', req.tenantSchema, 'inicio:', inicio, 'fin:', fin);
|
||||
const data = await reportesService.getEstadoResultados(req.tenantSchema!, inicio, fin);
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
console.error('[reportes] Error en getEstadoResultados:', error);
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,3 +58,40 @@ export async function createTenant(req: Request, res: Response, next: NextFuncti
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateTenant(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (req.user!.role !== 'admin') {
|
||||
throw new AppError(403, 'Solo administradores pueden editar clientes');
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const { nombre, rfc, plan, cfdiLimit, usersLimit, active } = req.body;
|
||||
|
||||
const tenant = await tenantsService.updateTenant(id, {
|
||||
nombre,
|
||||
rfc,
|
||||
plan,
|
||||
cfdiLimit,
|
||||
usersLimit,
|
||||
active,
|
||||
});
|
||||
|
||||
res.json(tenant);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTenant(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (req.user!.role !== 'admin') {
|
||||
throw new AppError(403, 'Solo administradores pueden eliminar clientes');
|
||||
}
|
||||
|
||||
await tenantsService.deleteTenant(req.params.id);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,5 +11,8 @@ router.use(tenantMiddleware);
|
||||
router.get('/', cfdiController.getCfdis);
|
||||
router.get('/resumen', cfdiController.getResumen);
|
||||
router.get('/:id', cfdiController.getCfdiById);
|
||||
router.post('/', cfdiController.createCfdi);
|
||||
router.post('/bulk', cfdiController.createManyCfdis);
|
||||
router.delete('/:id', cfdiController.deleteCfdi);
|
||||
|
||||
export { router as cfdiRoutes };
|
||||
|
||||
@@ -9,5 +9,7 @@ router.use(authenticate);
|
||||
router.get('/', tenantsController.getAllTenants);
|
||||
router.get('/:id', tenantsController.getTenant);
|
||||
router.post('/', tenantsController.createTenant);
|
||||
router.put('/:id', tenantsController.updateTenant);
|
||||
router.delete('/:id', tenantsController.deleteTenant);
|
||||
|
||||
export { router as tenantsRoutes };
|
||||
|
||||
@@ -78,6 +78,7 @@ export async function register(data: RegisterRequest): Promise<LoginResponse> {
|
||||
role: user.role,
|
||||
tenantId: tenant.id,
|
||||
tenantName: tenant.nombre,
|
||||
tenantRfc: tenant.rfc,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -140,6 +141,7 @@ export async function login(data: LoginRequest): Promise<LoginResponse> {
|
||||
role: user.role,
|
||||
tenantId: user.tenantId,
|
||||
tenantName: user.tenant.nombre,
|
||||
tenantRfc: user.tenant.rfc,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -94,6 +94,147 @@ export async function getCfdiById(schema: string, id: string): Promise<Cfdi | nu
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
export interface CreateCfdiData {
|
||||
uuidFiscal: string;
|
||||
tipo: 'ingreso' | 'egreso' | 'traslado' | 'nomina' | 'pago';
|
||||
serie?: string;
|
||||
folio?: string;
|
||||
fechaEmision: string;
|
||||
fechaTimbrado: string;
|
||||
rfcEmisor: string;
|
||||
nombreEmisor: string;
|
||||
rfcReceptor: string;
|
||||
nombreReceptor: string;
|
||||
subtotal: number;
|
||||
descuento?: number;
|
||||
iva?: number;
|
||||
isrRetenido?: number;
|
||||
ivaRetenido?: number;
|
||||
total: number;
|
||||
moneda?: string;
|
||||
tipoCambio?: number;
|
||||
metodoPago?: string;
|
||||
formaPago?: string;
|
||||
usoCfdi?: string;
|
||||
estado?: string;
|
||||
xmlUrl?: string;
|
||||
pdfUrl?: string;
|
||||
}
|
||||
|
||||
export async function createCfdi(schema: string, data: CreateCfdiData): Promise<Cfdi> {
|
||||
// Validate required fields
|
||||
if (!data.uuidFiscal) throw new Error('UUID Fiscal es requerido');
|
||||
if (!data.fechaEmision) throw new Error('Fecha de emisión es requerida');
|
||||
if (!data.rfcEmisor) throw new Error('RFC Emisor es requerido');
|
||||
if (!data.rfcReceptor) throw new Error('RFC Receptor es requerido');
|
||||
|
||||
// Parse dates safely - handle YYYY-MM-DD format explicitly
|
||||
let fechaEmision: Date;
|
||||
let fechaTimbrado: Date;
|
||||
|
||||
// If date is in YYYY-MM-DD format, add time to avoid timezone issues
|
||||
const dateStr = typeof data.fechaEmision === 'string' && data.fechaEmision.match(/^\d{4}-\d{2}-\d{2}$/)
|
||||
? `${data.fechaEmision}T12:00:00`
|
||||
: data.fechaEmision;
|
||||
|
||||
fechaEmision = new Date(dateStr);
|
||||
|
||||
if (isNaN(fechaEmision.getTime())) {
|
||||
throw new Error(`Fecha de emisión inválida: ${data.fechaEmision}`);
|
||||
}
|
||||
|
||||
const timbradoStr = data.fechaTimbrado
|
||||
? (typeof data.fechaTimbrado === 'string' && data.fechaTimbrado.match(/^\d{4}-\d{2}-\d{2}$/)
|
||||
? `${data.fechaTimbrado}T12:00:00`
|
||||
: data.fechaTimbrado)
|
||||
: null;
|
||||
|
||||
fechaTimbrado = timbradoStr ? new Date(timbradoStr) : fechaEmision;
|
||||
|
||||
if (isNaN(fechaTimbrado.getTime())) {
|
||||
throw new Error(`Fecha de timbrado inválida: ${data.fechaTimbrado}`);
|
||||
}
|
||||
|
||||
const result = await prisma.$queryRawUnsafe<Cfdi[]>(`
|
||||
INSERT INTO "${schema}".cfdis (
|
||||
uuid_fiscal, tipo, serie, folio, fecha_emision, fecha_timbrado,
|
||||
rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor,
|
||||
subtotal, descuento, iva, isr_retenido, iva_retenido, total,
|
||||
moneda, tipo_cambio, metodo_pago, forma_pago, uso_cfdi, estado, xml_url, pdf_url
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)
|
||||
RETURNING
|
||||
id, uuid_fiscal as "uuidFiscal", tipo, serie, folio,
|
||||
fecha_emision as "fechaEmision", fecha_timbrado as "fechaTimbrado",
|
||||
rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor",
|
||||
rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor",
|
||||
subtotal, descuento, iva, isr_retenido as "isrRetenido",
|
||||
iva_retenido as "ivaRetenido", total, moneda,
|
||||
tipo_cambio as "tipoCambio", metodo_pago as "metodoPago",
|
||||
forma_pago as "formaPago", uso_cfdi as "usoCfdi",
|
||||
estado, xml_url as "xmlUrl", pdf_url as "pdfUrl",
|
||||
created_at as "createdAt"
|
||||
`,
|
||||
data.uuidFiscal,
|
||||
data.tipo || 'ingreso',
|
||||
data.serie || null,
|
||||
data.folio || null,
|
||||
fechaEmision,
|
||||
fechaTimbrado,
|
||||
data.rfcEmisor,
|
||||
data.nombreEmisor || 'Sin nombre',
|
||||
data.rfcReceptor,
|
||||
data.nombreReceptor || 'Sin nombre',
|
||||
data.subtotal || 0,
|
||||
data.descuento || 0,
|
||||
data.iva || 0,
|
||||
data.isrRetenido || 0,
|
||||
data.ivaRetenido || 0,
|
||||
data.total || 0,
|
||||
data.moneda || 'MXN',
|
||||
data.tipoCambio || 1,
|
||||
data.metodoPago || null,
|
||||
data.formaPago || null,
|
||||
data.usoCfdi || null,
|
||||
data.estado || 'vigente',
|
||||
data.xmlUrl || null,
|
||||
data.pdfUrl || null
|
||||
);
|
||||
|
||||
return result[0];
|
||||
}
|
||||
|
||||
export async function createManyCfdis(schema: string, cfdis: CreateCfdiData[]): Promise<number> {
|
||||
let count = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (let i = 0; i < cfdis.length; i++) {
|
||||
const cfdi = cfdis[i];
|
||||
try {
|
||||
await createCfdi(schema, cfdi);
|
||||
count++;
|
||||
} catch (error: any) {
|
||||
const errorMsg = error.message || 'Error desconocido';
|
||||
// Skip duplicates (uuid_fiscal is unique)
|
||||
if (errorMsg.includes('duplicate') || errorMsg.includes('unique')) {
|
||||
console.log(`[CFDI ${i + 1}] Duplicado: ${cfdi.uuidFiscal}`);
|
||||
continue;
|
||||
}
|
||||
console.error(`[CFDI ${i + 1}] Error: ${errorMsg}`, { uuid: cfdi.uuidFiscal });
|
||||
errors.push(`CFDI ${i + 1} (${cfdi.uuidFiscal?.substring(0, 8) || 'sin UUID'}): ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0 && count === 0) {
|
||||
throw new Error(`No se pudo crear ningun CFDI. Errores: ${errors.slice(0, 3).join('; ')}`);
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
export async function deleteCfdi(schema: string, id: string): Promise<void> {
|
||||
await prisma.$queryRawUnsafe(`DELETE FROM "${schema}".cfdis WHERE id = $1`, id);
|
||||
}
|
||||
|
||||
export async function getResumenCfdis(schema: string, año: number, mes: number) {
|
||||
const result = await prisma.$queryRawUnsafe<[{
|
||||
total_ingresos: number;
|
||||
|
||||
@@ -1,48 +1,61 @@
|
||||
import { prisma } from '../config/database.js';
|
||||
import type { EstadoResultados, FlujoEfectivo, ComparativoPeriodos, ConcentradoRfc } from '@horux/shared';
|
||||
|
||||
// Helper to convert Prisma Decimal/BigInt to number
|
||||
function toNumber(value: unknown): number {
|
||||
if (value === null || value === undefined) return 0;
|
||||
if (typeof value === 'number') return value;
|
||||
if (typeof value === 'bigint') return Number(value);
|
||||
if (typeof value === 'string') return parseFloat(value) || 0;
|
||||
if (typeof value === 'object' && value !== null && 'toNumber' in value) {
|
||||
return (value as { toNumber: () => number }).toNumber();
|
||||
}
|
||||
return Number(value) || 0;
|
||||
}
|
||||
|
||||
export async function getEstadoResultados(
|
||||
schema: string,
|
||||
fechaInicio: string,
|
||||
fechaFin: string
|
||||
): Promise<EstadoResultados> {
|
||||
const ingresos = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string; total: number }[]>(`
|
||||
const ingresos = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string; total: unknown }[]>(`
|
||||
SELECT rfc_receptor as rfc, nombre_receptor as nombre, SUM(subtotal) as total
|
||||
FROM "${schema}".cfdis
|
||||
WHERE tipo = 'ingreso' AND estado = 'vigente'
|
||||
AND fecha_emision BETWEEN $1 AND $2
|
||||
AND fecha_emision BETWEEN $1::date AND $2::date
|
||||
GROUP BY rfc_receptor, nombre_receptor
|
||||
ORDER BY total DESC LIMIT 10
|
||||
`, fechaInicio, fechaFin);
|
||||
|
||||
const egresos = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string; total: number }[]>(`
|
||||
const egresos = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string; total: unknown }[]>(`
|
||||
SELECT rfc_emisor as rfc, nombre_emisor as nombre, SUM(subtotal) as total
|
||||
FROM "${schema}".cfdis
|
||||
WHERE tipo = 'egreso' AND estado = 'vigente'
|
||||
AND fecha_emision BETWEEN $1 AND $2
|
||||
AND fecha_emision BETWEEN $1::date AND $2::date
|
||||
GROUP BY rfc_emisor, nombre_emisor
|
||||
ORDER BY total DESC LIMIT 10
|
||||
`, fechaInicio, fechaFin);
|
||||
|
||||
const [totales] = await prisma.$queryRawUnsafe<[{ ingresos: number; egresos: number; iva: number }]>(`
|
||||
const totalesResult = await prisma.$queryRawUnsafe<{ ingresos: unknown; egresos: unknown; iva: unknown }[]>(`
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN subtotal ELSE 0 END), 0) as ingresos,
|
||||
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN subtotal ELSE 0 END), 0) as egresos,
|
||||
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN iva ELSE 0 END), 0) -
|
||||
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN iva ELSE 0 END), 0) as iva
|
||||
FROM "${schema}".cfdis
|
||||
WHERE estado = 'vigente' AND fecha_emision BETWEEN $1 AND $2
|
||||
WHERE estado = 'vigente' AND fecha_emision BETWEEN $1::date AND $2::date
|
||||
`, fechaInicio, fechaFin);
|
||||
|
||||
const totalIngresos = Number(totales?.ingresos || 0);
|
||||
const totalEgresos = Number(totales?.egresos || 0);
|
||||
const totales = totalesResult[0];
|
||||
const totalIngresos = toNumber(totales?.ingresos);
|
||||
const totalEgresos = toNumber(totales?.egresos);
|
||||
const utilidadBruta = totalIngresos - totalEgresos;
|
||||
const impuestos = Number(totales?.iva || 0);
|
||||
const impuestos = toNumber(totales?.iva);
|
||||
|
||||
return {
|
||||
periodo: { inicio: fechaInicio, fin: fechaFin },
|
||||
ingresos: ingresos.map(i => ({ concepto: i.nombre, monto: Number(i.total) })),
|
||||
egresos: egresos.map(e => ({ concepto: e.nombre, monto: Number(e.total) })),
|
||||
ingresos: ingresos.map(i => ({ concepto: i.nombre, monto: toNumber(i.total) })),
|
||||
egresos: egresos.map(e => ({ concepto: e.nombre, monto: toNumber(e.total) })),
|
||||
totalIngresos,
|
||||
totalEgresos,
|
||||
utilidadBruta,
|
||||
@@ -56,32 +69,32 @@ export async function getFlujoEfectivo(
|
||||
fechaInicio: string,
|
||||
fechaFin: string
|
||||
): Promise<FlujoEfectivo> {
|
||||
const entradas = await prisma.$queryRawUnsafe<{ mes: string; total: number }[]>(`
|
||||
const entradas = await prisma.$queryRawUnsafe<{ mes: string; total: unknown }[]>(`
|
||||
SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, SUM(total) as total
|
||||
FROM "${schema}".cfdis
|
||||
WHERE tipo = 'ingreso' AND estado = 'vigente'
|
||||
AND fecha_emision BETWEEN $1 AND $2
|
||||
AND fecha_emision BETWEEN $1::date AND $2::date
|
||||
GROUP BY TO_CHAR(fecha_emision, 'YYYY-MM')
|
||||
ORDER BY mes
|
||||
`, fechaInicio, fechaFin);
|
||||
|
||||
const salidas = await prisma.$queryRawUnsafe<{ mes: string; total: number }[]>(`
|
||||
const salidas = await prisma.$queryRawUnsafe<{ mes: string; total: unknown }[]>(`
|
||||
SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, SUM(total) as total
|
||||
FROM "${schema}".cfdis
|
||||
WHERE tipo = 'egreso' AND estado = 'vigente'
|
||||
AND fecha_emision BETWEEN $1 AND $2
|
||||
AND fecha_emision BETWEEN $1::date AND $2::date
|
||||
GROUP BY TO_CHAR(fecha_emision, 'YYYY-MM')
|
||||
ORDER BY mes
|
||||
`, fechaInicio, fechaFin);
|
||||
|
||||
const totalEntradas = entradas.reduce((sum, e) => sum + Number(e.total), 0);
|
||||
const totalSalidas = salidas.reduce((sum, s) => sum + Number(s.total), 0);
|
||||
const totalEntradas = entradas.reduce((sum, e) => sum + toNumber(e.total), 0);
|
||||
const totalSalidas = salidas.reduce((sum, s) => sum + toNumber(s.total), 0);
|
||||
|
||||
return {
|
||||
periodo: { inicio: fechaInicio, fin: fechaFin },
|
||||
saldoInicial: 0,
|
||||
entradas: entradas.map(e => ({ concepto: e.mes, monto: Number(e.total) })),
|
||||
salidas: salidas.map(s => ({ concepto: s.mes, monto: Number(s.total) })),
|
||||
entradas: entradas.map(e => ({ concepto: e.mes, monto: toNumber(e.total) })),
|
||||
salidas: salidas.map(s => ({ concepto: s.mes, monto: toNumber(s.total) })),
|
||||
totalEntradas,
|
||||
totalSalidas,
|
||||
flujoNeto: totalEntradas - totalSalidas,
|
||||
@@ -93,7 +106,7 @@ export async function getComparativo(
|
||||
schema: string,
|
||||
año: number
|
||||
): Promise<ComparativoPeriodos> {
|
||||
const actual = await prisma.$queryRawUnsafe<{ mes: number; ingresos: number; egresos: number }[]>(`
|
||||
const actual = await prisma.$queryRawUnsafe<{ mes: number; ingresos: unknown; egresos: unknown }[]>(`
|
||||
SELECT EXTRACT(MONTH FROM fecha_emision)::int as mes,
|
||||
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN total ELSE 0 END), 0) as ingresos,
|
||||
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN total ELSE 0 END), 0) as egresos
|
||||
@@ -102,7 +115,7 @@ export async function getComparativo(
|
||||
GROUP BY mes ORDER BY mes
|
||||
`, año);
|
||||
|
||||
const anterior = await prisma.$queryRawUnsafe<{ mes: number; ingresos: number; egresos: number }[]>(`
|
||||
const anterior = await prisma.$queryRawUnsafe<{ mes: number; ingresos: unknown; egresos: unknown }[]>(`
|
||||
SELECT EXTRACT(MONTH FROM fecha_emision)::int as mes,
|
||||
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN total ELSE 0 END), 0) as ingresos,
|
||||
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN total ELSE 0 END), 0) as egresos
|
||||
@@ -112,14 +125,14 @@ export async function getComparativo(
|
||||
`, año - 1);
|
||||
|
||||
const meses = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'];
|
||||
const ingresos = meses.map((_, i) => Number(actual.find(a => a.mes === i + 1)?.ingresos || 0));
|
||||
const egresos = meses.map((_, i) => Number(actual.find(a => a.mes === i + 1)?.egresos || 0));
|
||||
const ingresos = meses.map((_, i) => toNumber(actual.find(a => a.mes === i + 1)?.ingresos));
|
||||
const egresos = meses.map((_, i) => toNumber(actual.find(a => a.mes === i + 1)?.egresos));
|
||||
const utilidad = ingresos.map((ing, i) => ing - egresos[i]);
|
||||
|
||||
const totalActualIng = ingresos.reduce((a, b) => a + b, 0);
|
||||
const totalAnteriorIng = anterior.reduce((a, b) => a + Number(b.ingresos), 0);
|
||||
const totalAnteriorIng = anterior.reduce((a, b) => a + toNumber(b.ingresos), 0);
|
||||
const totalActualEgr = egresos.reduce((a, b) => a + b, 0);
|
||||
const totalAnteriorEgr = anterior.reduce((a, b) => a + Number(b.egresos), 0);
|
||||
const totalAnteriorEgr = anterior.reduce((a, b) => a + toNumber(b.egresos), 0);
|
||||
|
||||
return {
|
||||
periodos: meses,
|
||||
@@ -139,7 +152,7 @@ export async function getConcentradoRfc(
|
||||
tipo: 'cliente' | 'proveedor'
|
||||
): Promise<ConcentradoRfc[]> {
|
||||
if (tipo === 'cliente') {
|
||||
const data = await prisma.$queryRawUnsafe<ConcentradoRfc[]>(`
|
||||
const data = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string; tipo: string; totalFacturado: unknown; totalIva: unknown; cantidadCfdis: number }[]>(`
|
||||
SELECT rfc_receptor as rfc, nombre_receptor as nombre,
|
||||
'cliente' as tipo,
|
||||
SUM(total) as "totalFacturado",
|
||||
@@ -147,13 +160,20 @@ export async function getConcentradoRfc(
|
||||
COUNT(*)::int as "cantidadCfdis"
|
||||
FROM "${schema}".cfdis
|
||||
WHERE tipo = 'ingreso' AND estado = 'vigente'
|
||||
AND fecha_emision BETWEEN $1 AND $2
|
||||
AND fecha_emision BETWEEN $1::date AND $2::date
|
||||
GROUP BY rfc_receptor, nombre_receptor
|
||||
ORDER BY "totalFacturado" DESC
|
||||
`, fechaInicio, fechaFin);
|
||||
return data.map(d => ({ ...d, totalFacturado: Number(d.totalFacturado), totalIva: Number(d.totalIva) }));
|
||||
return data.map(d => ({
|
||||
rfc: d.rfc,
|
||||
nombre: d.nombre,
|
||||
tipo: 'cliente' as const,
|
||||
totalFacturado: toNumber(d.totalFacturado),
|
||||
totalIva: toNumber(d.totalIva),
|
||||
cantidadCfdis: d.cantidadCfdis
|
||||
}));
|
||||
} else {
|
||||
const data = await prisma.$queryRawUnsafe<ConcentradoRfc[]>(`
|
||||
const data = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string; tipo: string; totalFacturado: unknown; totalIva: unknown; cantidadCfdis: number }[]>(`
|
||||
SELECT rfc_emisor as rfc, nombre_emisor as nombre,
|
||||
'proveedor' as tipo,
|
||||
SUM(total) as "totalFacturado",
|
||||
@@ -161,10 +181,17 @@ export async function getConcentradoRfc(
|
||||
COUNT(*)::int as "cantidadCfdis"
|
||||
FROM "${schema}".cfdis
|
||||
WHERE tipo = 'egreso' AND estado = 'vigente'
|
||||
AND fecha_emision BETWEEN $1 AND $2
|
||||
AND fecha_emision BETWEEN $1::date AND $2::date
|
||||
GROUP BY rfc_emisor, nombre_emisor
|
||||
ORDER BY "totalFacturado" DESC
|
||||
`, fechaInicio, fechaFin);
|
||||
return data.map(d => ({ ...d, totalFacturado: Number(d.totalFacturado), totalIva: Number(d.totalIva) }));
|
||||
return data.map(d => ({
|
||||
rfc: d.rfc,
|
||||
nombre: d.nombre,
|
||||
tipo: 'proveedor' as const,
|
||||
totalFacturado: toNumber(d.totalFacturado),
|
||||
totalIva: toNumber(d.totalIva),
|
||||
cantidadCfdis: d.cantidadCfdis
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,3 +139,43 @@ export async function createTenant(data: {
|
||||
|
||||
return tenant;
|
||||
}
|
||||
|
||||
export async function updateTenant(id: string, data: {
|
||||
nombre?: string;
|
||||
rfc?: string;
|
||||
plan?: 'starter' | 'business' | 'professional' | 'enterprise';
|
||||
cfdiLimit?: number;
|
||||
usersLimit?: number;
|
||||
active?: boolean;
|
||||
}) {
|
||||
return prisma.tenant.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(data.nombre && { nombre: data.nombre }),
|
||||
...(data.rfc && { rfc: data.rfc.toUpperCase() }),
|
||||
...(data.plan && { plan: data.plan }),
|
||||
...(data.cfdiLimit !== undefined && { cfdiLimit: data.cfdiLimit }),
|
||||
...(data.usersLimit !== undefined && { usersLimit: data.usersLimit }),
|
||||
...(data.active !== undefined && { active: data.active }),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
nombre: true,
|
||||
rfc: true,
|
||||
plan: true,
|
||||
schemaName: true,
|
||||
cfdiLimit: true,
|
||||
usersLimit: true,
|
||||
active: true,
|
||||
createdAt: true,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteTenant(id: string) {
|
||||
// Soft delete - just mark as inactive
|
||||
return prisma.tenant.update({
|
||||
where: { id },
|
||||
data: { active: false }
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user