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:
Consultoria AS
2026-01-22 06:51:53 +00:00
parent 0c10c887d2
commit c3ce7199af
37 changed files with 1680 additions and 216 deletions

View File

@@ -18,11 +18,12 @@
"@prisma/client": "^5.22.0",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"exceljs": "^4.4.0",
"express": "^4.21.0",
"helmet": "^8.0.0",
"jsonwebtoken": "^9.0.2",
"zod": "^3.23.0",
"exceljs": "^4.4.0"
"zod": "^3.23.0"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",

View File

@@ -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) => {

View File

@@ -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) {

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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,
},
};
}

View File

@@ -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;

View File

@@ -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
}));
}
}

View File

@@ -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 }
});
}