Update: nueva version Horux Despachos

This commit is contained in:
consultoria-as
2026-04-27 22:09:36 -06:00
commit 6b36db1403
614 changed files with 125926 additions and 0 deletions

112
apps/api/src/app.ts Normal file
View File

@@ -0,0 +1,112 @@
import express, { type Express } from 'express';
import cors from 'cors';
import helmet from 'helmet';
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';
import { cfdiRoutes } from './routes/cfdi.routes.js';
import { impuestosRoutes } from './routes/impuestos.routes.js';
import { exportRoutes } from './routes/export.routes.js';
import { alertasRoutes } from './routes/alertas.routes.js';
import { notificationPreferencesRoutes } from './routes/notification-preferences.routes.js';
import { tareasRoutes } from './routes/tareas.routes.js';
import { papeleriaRoutes } from './routes/papeleria.routes.js';
import { despachoStatsRoutes } from './routes/despacho-stats.routes.js';
import { calendarioRoutes } from './routes/calendario.routes.js';
import { reportesRoutes } from './routes/reportes.routes.js';
import { usuariosRoutes } from './routes/usuarios.routes.js';
import { tenantsRoutes } from './routes/tenants.routes.js';
import fielRoutes from './routes/fiel.routes.js';
import satRoutes from './routes/sat.routes.js';
import { webhookRoutes } from './routes/webhook.routes.js';
import { subscriptionRoutes } from './routes/subscription.routes.js';
import { regimenRoutes } from './routes/regimen.routes.js';
import { bancosRoutes } from './routes/bancos.routes.js';
import { conciliacionRoutes } from './routes/conciliacion.routes.js';
import { facturacionRoutes } from './routes/facturacion.routes.js';
import { catalogosRoutes } from './routes/catalogos.routes.js';
import { documentosRoutes } from './routes/documentos.routes.js';
import { auditLogRoutes } from './routes/audit-log.routes.js';
import { platformStaffRoutes } from './routes/platform-staff.routes.js';
import despachoRoutes from './routes/despacho.routes.js';
import contribuyenteRoutes from './routes/contribuyente.routes.js';
import carteraRoutes from './routes/cartera.routes.js';
import planCatalogoRoutes from './routes/plan-catalogo.routes.js';
import connectorRoutes from './routes/connector.routes.js';
import adminDashboardRoutes from './routes/admin-dashboard.routes.js';
import adminImpersonateRoutes from './routes/admin-impersonate.routes.js';
import adminClientesRoutes from './routes/admin-clientes.routes.js';
import adminAddonsRoutes from './routes/admin-addons.routes.js';
import despachoAuditRoutes from './routes/despacho-audit.routes.js';
import metricasRoutes from './routes/metricas.routes.js';
const app: Express = express();
// Security. Helmet default incluye un CSP restrictivo que puede chocar con el
// frontend cuando éste embebe recursos propios (ej: /terminos embebe el PDF de
// /legal/). Dejamos CSP off en el API y centralizamos los headers de seguridad
// en next.config del web (X-Frame-Options, CSP frame-ancestors, HSTS, nosniff,
// Referrer-Policy) que es quien sirve la UI. El API solo responde JSON y
// archivos binarios (PDFs, XMLs) — no tiene contenido HTML que requiera CSP.
app.use(helmet({
contentSecurityPolicy: false,
crossOriginResourcePolicy: { policy: 'cross-origin' }, // permite /legal/*.pdf embebido
}));
app.use(cors({
origin: getCorsOrigins(),
credentials: true,
}));
// Body parsing - 10MB default, bulk CFDI route has its own higher limit
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// API Routes
app.use('/api/auth', authRoutes);
app.use('/api/dashboard', dashboardRoutes);
app.use('/api/cfdi', cfdiRoutes);
app.use('/api/impuestos', impuestosRoutes);
app.use('/api/export', exportRoutes);
app.use('/api/alertas', alertasRoutes);
app.use('/api/notificaciones', notificationPreferencesRoutes);
app.use('/api/tareas', tareasRoutes);
app.use('/api/papeleria', papeleriaRoutes);
app.use('/api/despachos', despachoStatsRoutes);
app.use('/api/calendario', calendarioRoutes);
app.use('/api/reportes', reportesRoutes);
app.use('/api/usuarios', usuariosRoutes);
app.use('/api/tenants', tenantsRoutes);
app.use('/api/fiel', fielRoutes);
app.use('/api/sat', satRoutes);
app.use('/api/webhooks', webhookRoutes);
app.use('/api/subscriptions', subscriptionRoutes);
app.use('/api/regimenes', regimenRoutes);
app.use('/api/bancos', bancosRoutes);
app.use('/api/conciliacion', conciliacionRoutes);
app.use('/api/facturacion', facturacionRoutes);
app.use('/api/catalogos', catalogosRoutes);
app.use('/api/documentos', documentosRoutes);
app.use('/api/audit-log', auditLogRoutes);
app.use('/api/platform-staff', platformStaffRoutes);
app.use('/api/despachos', despachoRoutes);
app.use('/api/contribuyentes', contribuyenteRoutes);
app.use('/api/carteras', carteraRoutes);
app.use('/api/planes', planCatalogoRoutes);
app.use('/api/connector', connectorRoutes);
app.use('/api/admin/dashboard', adminDashboardRoutes);
app.use('/api/admin/impersonate', adminImpersonateRoutes);
app.use('/api/admin/clientes', adminClientesRoutes);
app.use('/api/admin/addons', adminAddonsRoutes);
app.use('/api/despacho/audit-log', despachoAuditRoutes);
app.use('/api/metricas', metricasRoutes);
// Error handling
app.use(errorMiddleware);
export { app };

View File

@@ -0,0 +1 @@
export { hashPassword, verifyPassword } from '@horux/core';

View File

@@ -0,0 +1,30 @@
import {
generateAccessToken as coreGenerateAccessToken,
generateRefreshToken as coreGenerateRefreshToken,
verifyToken as coreVerifyToken,
decodeToken,
type TokenConfig,
} from '@horux/core';
import type { JWTPayload } from '@horux/shared';
import { env } from '../config/env.js';
const tokenConfig: TokenConfig = {
secret: env.JWT_SECRET,
accessExpiresIn: env.JWT_EXPIRES_IN,
refreshExpiresIn: env.JWT_REFRESH_EXPIRES_IN,
};
export function generateAccessToken(payload: Omit<JWTPayload, 'iat' | 'exp'>): string {
return coreGenerateAccessToken(payload, tokenConfig);
}
export function generateRefreshToken(payload: Omit<JWTPayload, 'iat' | 'exp'>): string {
return coreGenerateRefreshToken(payload, tokenConfig);
}
export function verifyToken(token: string): JWTPayload {
return coreVerifyToken(token, tokenConfig.secret);
}
export { decodeToken };
export type { JWTPayload };

View File

@@ -0,0 +1,234 @@
import { PrismaClient } from '@prisma/client';
import { Pool, type PoolConfig } from 'pg';
import { env } from './env.js';
import { migrate } from './tenant-migrations.js';
// ===========================================
// Prisma Client (central database: horux360)
// ===========================================
declare global {
var prisma: PrismaClient | undefined;
}
export const prisma = globalThis.prisma || new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});
if (process.env.NODE_ENV !== 'production') {
globalThis.prisma = prisma;
}
// ===========================================
// TenantConnectionManager (per-tenant DBs)
// ===========================================
interface PoolEntry {
pool: Pool;
lastAccess: Date;
}
function parseDatabaseUrl(url: string) {
const parsed = new URL(url);
return {
host: parsed.hostname,
port: parseInt(parsed.port || '5432'),
user: decodeURIComponent(parsed.username),
password: decodeURIComponent(parsed.password),
};
}
class TenantConnectionManager {
private pools: Map<string, PoolEntry> = new Map();
private cleanupInterval: NodeJS.Timeout | null = null;
private dbConfig: { host: string; port: number; user: string; password: string };
private migratedPools: Set<string> = new Set();
constructor() {
this.dbConfig = parseDatabaseUrl(env.DATABASE_URL);
this.cleanupInterval = setInterval(() => this.cleanupIdlePools(), 60_000);
}
/**
* Get or create a connection pool for a tenant's database.
* Runs lazy migrations on first access (or after pool invalidation).
*/
async getPool(
tenantId: string,
databaseName: string,
connectionOverride?: { host: string; port: number; user: string; password: string },
): Promise<Pool> {
let pool: Pool;
const entry = this.pools.get(tenantId);
if (entry) {
entry.lastAccess = new Date();
pool = entry.pool;
} else {
const poolConfig: PoolConfig = {
host: connectionOverride?.host ?? this.dbConfig.host,
port: connectionOverride?.port ?? this.dbConfig.port,
user: connectionOverride?.user ?? this.dbConfig.user,
password: connectionOverride?.password ?? this.dbConfig.password,
database: databaseName,
max: 3,
idleTimeoutMillis: 300_000,
connectionTimeoutMillis: 10_000,
};
pool = new Pool(poolConfig);
pool.on('error', (err) => {
console.error(`[TenantDB] Pool error for tenant ${tenantId} (${databaseName}):`, err.message);
});
this.pools.set(tenantId, { pool, lastAccess: new Date() });
}
if (!this.migratedPools.has(tenantId)) {
try {
await migrate(pool, databaseName);
} catch (err) {
console.error(`[TenantDB] Migration error for tenant ${tenantId} (${databaseName}):`, err);
}
this.migratedPools.add(tenantId);
}
return pool;
}
/**
* Create a new database for a tenant with all required tables and indexes.
*/
async provisionDatabase(rfc: string, overrideDatabaseName?: string): Promise<string> {
const databaseName = overrideDatabaseName || `horux_${rfc.toLowerCase().replace(/[^a-z0-9]/g, '')}`;
const adminPool = new Pool({
...this.dbConfig,
database: 'postgres',
max: 1,
});
try {
const exists = await adminPool.query(
`SELECT 1 FROM pg_database WHERE datname = $1`,
[databaseName]
);
if (exists.rows.length > 0) {
throw new Error(`Database ${databaseName} already exists`);
}
await adminPool.query(`CREATE DATABASE "${databaseName}"`);
const tenantPool = new Pool({
...this.dbConfig,
database: databaseName,
max: 1,
});
try {
await migrate(tenantPool, databaseName);
} finally {
await tenantPool.end();
}
return databaseName;
} finally {
await adminPool.end();
}
}
/**
* Soft-delete: rename database so it can be recovered.
*/
async deprovisionDatabase(databaseName: string): Promise<void> {
// Close any active pool for this tenant
for (const [tenantId, entry] of this.pools.entries()) {
// We check pool config to match the database
if ((entry.pool as any).options?.database === databaseName) {
await entry.pool.end().catch(() => {});
this.pools.delete(tenantId);
}
}
const timestamp = Date.now();
const adminPool = new Pool({
...this.dbConfig,
database: 'postgres',
max: 1,
});
try {
await adminPool.query(`
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = $1 AND pid <> pg_backend_pid()
`, [databaseName]);
await adminPool.query(
`ALTER DATABASE "${databaseName}" RENAME TO "${databaseName}_deleted_${timestamp}"`
);
} finally {
await adminPool.end();
}
}
/**
* Invalidate (close and remove) a specific tenant's pool.
*/
invalidatePool(tenantId: string): void {
const entry = this.pools.get(tenantId);
if (entry) {
entry.pool.end().catch(() => {});
this.pools.delete(tenantId);
}
this.migratedPools.delete(tenantId);
}
/**
* Remove idle pools (not accessed in last 5 minutes).
*/
private cleanupIdlePools(): void {
const now = Date.now();
const maxIdle = 5 * 60 * 1000;
for (const [tenantId, entry] of this.pools.entries()) {
if (now - entry.lastAccess.getTime() > maxIdle) {
entry.pool.end().catch((err) =>
console.error(`[TenantDB] Error closing idle pool for ${tenantId}:`, err.message)
);
this.pools.delete(tenantId);
}
}
}
/**
* Graceful shutdown: close all pools.
*/
async shutdown(): Promise<void> {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
}
const closePromises = Array.from(this.pools.values()).map((entry) =>
entry.pool.end()
);
await Promise.all(closePromises);
this.pools.clear();
}
/**
* Get stats about active pools.
*/
getStats(): { activePools: number; tenantIds: string[] } {
return {
activePools: this.pools.size,
tenantIds: Array.from(this.pools.keys()),
};
}
}
// Singleton instance
export const tenantDb = new TenantConnectionManager();

View File

@@ -0,0 +1,63 @@
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'),
PORT: z.string().default('4000'),
DATABASE_URL: z.string(),
JWT_SECRET: z.string().min(32),
JWT_EXPIRES_IN: z.string().default('15m'),
JWT_REFRESH_EXPIRES_IN: z.string().default('7d'),
CORS_ORIGIN: z.string().default('http://localhost:3000'),
// Frontend URL (for MercadoPago back_url, emails, etc.)
FRONTEND_URL: z.string().default('https://horuxfin.com'),
// FIEL encryption (separate from JWT to allow independent rotation)
FIEL_ENCRYPTION_KEY: z.string().min(32),
FIEL_STORAGE_PATH: z.string().default('/var/horux/fiel'),
// MercadoPago
MP_ACCESS_TOKEN: z.string().optional(),
MP_WEBHOOK_SECRET: z.string().optional(),
MP_NOTIFICATION_URL: z.string().optional(),
// SMTP (Gmail Workspace)
SMTP_HOST: z.string().default('smtp.gmail.com'),
SMTP_PORT: z.string().default('587'),
SMTP_USER: z.string().optional(),
SMTP_PASS: z.string().optional(),
SMTP_FROM: z.string().default('Horux360 <noreply@horuxfin.com>'),
// Admin notification email
ADMIN_EMAIL: z.string().default('carlos@horuxfin.com'),
// Facturapi
FACTURAPI_USER_KEY: z.string().optional(),
// Cloudflare Tunnel (connector BYO-DB)
CLOUDFLARE_API_TOKEN: z.string().optional(),
CLOUDFLARE_ACCOUNT_ID: z.string().optional(),
CLOUDFLARE_TUNNEL_DOMAIN: z.string().default('tunnel.horux.mx'),
// KMS for encrypting DB connection strings and connector tokens
CONNECTOR_ENCRYPTION_KEY: z.string().optional(),
});
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error('❌ Invalid environment variables:', parsed.error.flatten().fieldErrors);
process.exit(1);
}
export const env = parsed.data;
// Parse CORS origins (comma-separated) into array
export function getCorsOrigins(): string[] {
return env.CORS_ORIGIN.split(',').map(origin => origin.trim());
}

View File

@@ -0,0 +1,143 @@
import { Pool } from 'pg';
import { readdir, readFile } from 'fs/promises';
import { join } from 'path';
import { prisma } from './database.js';
import { env } from './env.js';
const MIGRATIONS_DIR = join(__dirname, '..', 'migrations', 'tenant');
export interface MigrationFile {
version: number;
name: string;
sql: string;
}
export async function getMigrationFiles(): Promise<MigrationFile[]> {
let files: string[];
try {
files = await readdir(MIGRATIONS_DIR);
} catch (err: any) {
if (err.code === 'ENOENT') {
console.warn(`[Migrations] Directory not found: ${MIGRATIONS_DIR}`);
return [];
}
throw err;
}
const pattern = /^(\d{3})_(.+)\.sql$/;
const migrations: MigrationFile[] = [];
for (const file of files) {
const match = pattern.exec(file);
if (!match) continue;
const version = parseInt(match[1], 10);
const name = file;
const sql = await readFile(join(MIGRATIONS_DIR, file), 'utf8');
migrations.push({ version, name, sql });
}
migrations.sort((a, b) => a.version - b.version);
return migrations;
}
export async function migrate(pool: Pool, label?: string): Promise<number> {
const prefix = label ? `[Migrations] (${label})` : '[Migrations]';
// Ensure schema_migrations table exists
await pool.query(`
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
name VARCHAR(255) NOT NULL,
applied_at TIMESTAMP DEFAULT NOW()
);
`);
// Get already-applied versions
const { rows } = await pool.query<{ version: number }>(
'SELECT version FROM schema_migrations ORDER BY version'
);
const appliedVersions = new Set(rows.map((r) => r.version));
// Get all migration files
const migrationFiles = await getMigrationFiles();
const pending = migrationFiles.filter((m) => !appliedVersions.has(m.version));
if (pending.length === 0) {
return 0;
}
console.log(`${prefix} Applying ${pending.length} pending migration(s)...`);
for (const migration of pending) {
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query(migration.sql);
await client.query(
'INSERT INTO schema_migrations (version, name) VALUES ($1, $2)',
[migration.version, migration.name]
);
await client.query('COMMIT');
console.log(`${prefix} Applied: ${migration.name}`);
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
return pending.length;
}
export async function migrateAll(): Promise<{
success: number;
failed: number;
skipped: number;
}> {
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true, rfc: true, databaseName: true },
});
let success = 0;
let failed = 0;
let skipped = 0;
for (const tenant of tenants) {
const parsed = new URL(env.DATABASE_URL);
const pool = new Pool({
host: parsed.hostname,
port: parseInt(parsed.port || '5432'),
user: decodeURIComponent(parsed.username),
password: decodeURIComponent(parsed.password),
database: tenant.databaseName,
max: 1,
});
try {
const applied = await migrate(pool, tenant.rfc);
if (applied > 0) {
success++;
} else {
skipped++;
}
} catch (err: any) {
failed++;
console.error(
`[Migrations] (${tenant.rfc}) Failed: ${err.message}`
);
} finally {
await pool.end();
}
}
console.log(
`[Migrations] Summary — success: ${success}, skipped: ${skipped}, failed: ${failed}`
);
return { success, failed, skipped };
}

View File

@@ -0,0 +1,84 @@
export interface ObligacionFiscal {
id: string;
nombre: string;
fundamento: string;
frecuencia: 'mensual' | 'bimestral' | 'trimestral' | 'anual' | 'eventual';
fechaLimite: string;
aplica: 'PM' | 'PF' | 'ambos';
regimenes: string[] | null; // null = all regimes
condicion: string | null;
categoria: string;
recomendadaPorDefecto: boolean;
}
export const OBLIGACIONES_CATALOGO: ObligacionFiscal[] = [
// === FEDERALES MENSUALES (día 17) ===
{ id: 'isr-provisional', nombre: 'Pago provisional de ISR', fundamento: 'Art. 14 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Federal mensual', recomendadaPorDefecto: true },
{ id: 'iva-mensual', nombre: 'Pago mensual definitivo de IVA', fundamento: 'Art. 5-D LIVA', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Federal mensual', recomendadaPorDefecto: true },
{ id: 'ret-isr-sueldos', nombre: 'Retenciones de ISR por sueldos y salarios', fundamento: 'Art. 96 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'Facturas emitidas tipo N', categoria: 'Federal mensual', recomendadaPorDefecto: false },
{ id: 'ret-isr-asimilados', nombre: 'Retenciones de ISR por asimilados a salarios', fundamento: 'Art. 94 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'Facturas emitidas tipo N', categoria: 'Federal mensual', recomendadaPorDefecto: false },
{ id: 'ret-isr-honorarios', nombre: 'Retenciones de ISR por honorarios y arrendamiento a PF', fundamento: 'Art. 106/116 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'PM que contrate PF', categoria: 'Federal mensual', recomendadaPorDefecto: false },
{ id: 'ret-iva', nombre: 'Retenciones de IVA (servicios, fletes, outsourcing)', fundamento: 'Art. 1-A LIVA', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'Según supuesto', categoria: 'Federal mensual', recomendadaPorDefecto: false },
{ id: 'ieps', nombre: 'Pago definitivo de IEPS', fundamento: 'Art. 5 LIEPS', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'Productores/importadores', categoria: 'Federal mensual', recomendadaPorDefecto: false },
// === INFORMATIVAS MENSUALES ===
{ id: 'diot', nombre: 'DIOT (Declaración Informativa de Operaciones con Terceros)', fundamento: 'Art. 32 LIVA', frecuencia: 'mensual', fechaLimite: 'Último día del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'PF con ingresos > $4M y todas las PM, excepto RESICO', categoria: 'Informativa mensual', recomendadaPorDefecto: false },
{ id: 'cont-balanza', nombre: 'Contabilidad Electrónica - Balanza de comprobación', fundamento: 'CFF Art. 28', frecuencia: 'mensual', fechaLimite: 'Día 3 del segundo mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'PF con ingresos > $4M y todas las PM, excepto RESICO', categoria: 'Informativa mensual', recomendadaPorDefecto: false },
{ id: 'cont-catalogo', nombre: 'Contabilidad Electrónica - Catálogo de cuentas', fundamento: 'CFF Art. 28', frecuencia: 'eventual', fechaLimite: 'Cuando haya modificación', aplica: 'ambos', regimenes: null, condicion: 'PF con ingresos > $4M y todas las PM, excepto RESICO', categoria: 'Informativa mensual', recomendadaPorDefecto: false },
// === RESICO PM ===
{ id: 'isr-resico-pm', nombre: 'Pago provisional ISR RESICO-PM', fundamento: 'Art. 206 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: ['626'], condicion: null, categoria: 'RESICO PM', recomendadaPorDefecto: true },
// === RESICO PF ===
{ id: 'isr-resico-pf', nombre: 'Pago mensual ISR RESICO PF (1%-2.5%)', fundamento: 'Art. 113-E LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PF', regimenes: ['626'], condicion: null, categoria: 'RESICO PF', recomendadaPorDefecto: true },
// === ANUALES PM ===
{ id: 'anual-isr-pm', nombre: 'Declaración Anual de ISR PM', fundamento: 'Art. 76 LISR', frecuencia: 'anual', fechaLimite: '31 de marzo', aplica: 'PM', regimenes: null, condicion: null, categoria: 'Anual', recomendadaPorDefecto: true },
{ id: 'issif', nombre: 'ISSIF (Información sobre Situación Fiscal)', fundamento: 'CFF Art. 32-H', frecuencia: 'anual', fechaLimite: 'Con la declaración anual', aplica: 'PM', regimenes: null, condicion: null, categoria: 'Anual', recomendadaPorDefecto: false },
{ id: 'dictamen-fiscal', nombre: 'Dictamen Fiscal', fundamento: 'CFF Art. 32-A', frecuencia: 'anual', fechaLimite: '15 de mayo', aplica: 'PM', regimenes: null, condicion: 'Ingresos > $1,855M o grupos', categoria: 'Anual', recomendadaPorDefecto: false },
{ id: 'dim', nombre: 'DIM - Declaraciones Informativas Múltiples', fundamento: 'CFF', frecuencia: 'anual', fechaLimite: '15 de febrero', aplica: 'PM', regimenes: null, condicion: null, categoria: 'Anual', recomendadaPorDefecto: false },
// === ANUALES PF ===
{ id: 'anual-isr-pf', nombre: 'Declaración Anual PF', fundamento: 'Art. 150 LISR', frecuencia: 'anual', fechaLimite: '30 de abril', aplica: 'PF', regimenes: null, condicion: null, categoria: 'Anual', recomendadaPorDefecto: true },
// === SEGURIDAD SOCIAL ===
{ id: 'imss-cuotas', nombre: 'Cuotas obrero-patronales IMSS', fundamento: 'LSS', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', recomendadaPorDefecto: false },
{ id: 'infonavit', nombre: 'Aportaciones INFONAVIT + amortizaciones', fundamento: 'LINFONAVIT', frecuencia: 'bimestral', fechaLimite: 'Día 17 del mes siguiente al bimestre', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', recomendadaPorDefecto: false },
{ id: 'sar-retiro', nombre: 'SAR / Retiro', fundamento: 'LSS', frecuencia: 'bimestral', fechaLimite: 'Día 17 del mes siguiente al bimestre', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', recomendadaPorDefecto: false },
{ id: 'prima-riesgo', nombre: 'Determinación Prima de Riesgo de Trabajo', fundamento: 'LSS Art. 74', frecuencia: 'anual', fechaLimite: 'Febrero', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', recomendadaPorDefecto: false },
// === ESTATALES ===
{ id: 'isn', nombre: 'ISN - Impuesto Sobre Nómina', fundamento: 'Ley estatal', frecuencia: 'mensual', fechaLimite: 'Varía por estado (CDMX día 17)', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Estatal', recomendadaPorDefecto: false },
];
/**
* Returns recommended obligations for a contribuyente based on:
* - PM vs PF (RFC length: 12 = PM, 13 = PF)
* - Specific regímenes
* - Whether they have nómina CFDIs (type N)
*/
export function getRecomendaciones(rfc: string, regimenes: string[], tieneNomina: boolean): ObligacionFiscal[] {
const esPM = rfc.length === 12;
const tipo = esPM ? 'PM' : 'PF';
return OBLIGACIONES_CATALOGO.filter(ob => {
// Filter by PM/PF
if (ob.aplica !== 'ambos' && ob.aplica !== tipo) return false;
// Filter by régimen if specified
if (ob.regimenes && ob.regimenes.length > 0) {
if (!regimenes.some(r => ob.regimenes!.includes(r))) return false;
}
// Always recommend IVA + ISR
if (ob.recomendadaPorDefecto) return true;
// Recommend nómina obligations if they have type N
if (tieneNomina && ob.condicion?.includes('tipo N')) return true;
// Recommend nómina-related social security if has employees
if (tieneNomina && ob.condicion?.includes('empleados')) return true;
return false;
});
}

View File

@@ -0,0 +1,87 @@
import type { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { AppError } from '../middlewares/error.middleware.js';
import * as activosFijosService from '../services/activos-fijos.service.js';
function effectiveTenantId(req: Request): string {
return req.viewingTenantId || req.user!.tenantId;
}
const listSchema = z.object({
año: z.string().regex(/^\d{4}$/),
mes: z.string().regex(/^\d{1,2}$/),
contribuyenteId: z.string().uuid().optional(),
estado: z.enum(['todos', 'activos', 'baja', 'agotados']).optional(),
});
export async function list(req: Request, res: Response, next: NextFunction) {
try {
const q = listSchema.parse(req.query);
const data = await activosFijosService.listActivosFijos(
req.tenantPool!,
effectiveTenantId(req),
parseInt(q.año, 10),
parseInt(q.mes, 10),
q.contribuyenteId ?? null,
q.estado,
);
res.json(data);
} catch (error) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error);
}
}
const bajaSchema = z.object({
fechaBaja: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
motivo: z.enum(['venta', 'desecho', 'otro']),
comentario: z.string().max(2000).nullable().optional(),
});
export async function darDeBaja(req: Request, res: Response, next: NextFunction) {
try {
const cfdiId = parseInt(String(req.params.cfdiId), 10);
if (isNaN(cfdiId)) return next(new AppError(400, 'cfdiId inválido'));
const data = bajaSchema.parse(req.body);
await activosFijosService.darDeBaja(
req.tenantPool!,
cfdiId,
data.fechaBaja,
data.motivo,
req.user!.userId,
data.comentario ?? null,
);
res.status(201).json({ ok: true });
} catch (error) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error);
}
}
const usosExcluidosSchema = z.object({
contribuyenteId: z.string().uuid(),
usos: z.array(z.string().regex(/^I0[1-8]$/)),
});
export async function setUsosExcluidos(req: Request, res: Response, next: NextFunction) {
try {
const { contribuyenteId, usos } = usosExcluidosSchema.parse(req.body);
const saved = await activosFijosService.setUsosExcluidos(req.tenantPool!, contribuyenteId, usos);
res.json({ usosExcluidos: saved });
} catch (error) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error);
}
}
export async function revertirBaja(req: Request, res: Response, next: NextFunction) {
try {
const cfdiId = parseInt(String(req.params.cfdiId), 10);
if (isNaN(cfdiId)) return next(new AppError(400, 'cfdiId inválido'));
const ok = await activosFijosService.revertirBaja(req.tenantPool!, cfdiId);
if (!ok) return next(new AppError(404, 'Activo no estaba dado de baja'));
res.status(204).send();
} catch (error) {
next(error);
}
}

View File

@@ -0,0 +1,86 @@
import type { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { prisma } from '../config/database.js';
import { isPlatformStaff } from '../utils/platform-admin.js';
import { AppError } from '../middlewares/error.middleware.js';
import { auditFromReq } from '../utils/audit.js';
async function requireStaff(req: Request) {
if (!req.user?.userId) throw new AppError(401, 'No autenticado');
const isStaff = await isPlatformStaff(req.user.userId);
if (!isStaff) throw new AppError(403, 'Acceso restringido a staff de plataforma');
}
const updateSchema = z.object({
nombre: z.string().min(1).max(200).optional(),
precio: z.number().nonnegative().optional(),
active: z.boolean().optional(),
});
/** Lista todo el catálogo de add-ons (incluye inactivos). */
export async function listCatalogo(req: Request, res: Response, next: NextFunction) {
try {
await requireStaff(req);
const items = await prisma.planAddonCatalogo.findMany({
orderBy: { codename: 'asc' },
include: {
_count: { select: { subscriptionAddons: { where: { status: { in: ['authorized', 'pending'] } } } } },
},
});
return res.json({
data: items.map(i => ({
id: i.id,
codename: i.codename,
nombre: i.nombre,
verticalProfile: i.verticalProfile,
precio: Number(i.precio),
frecuencia: i.frecuencia,
active: i.active,
delta: i.delta,
createdAt: i.createdAt.toISOString(),
suscripcionesActivas: i._count.subscriptionAddons,
})),
});
} catch (err) { return next(err); }
}
export async function updateCatalogoItem(req: Request, res: Response, next: NextFunction) {
try {
await requireStaff(req);
const id = String(req.params.id);
const data = updateSchema.parse(req.body);
const before = await prisma.planAddonCatalogo.findUnique({ where: { id } });
if (!before) throw new AppError(404, 'Add-on no encontrado');
const updated = await prisma.planAddonCatalogo.update({
where: { id },
data: {
...(data.nombre !== undefined ? { nombre: data.nombre } : {}),
...(data.precio !== undefined ? { precio: data.precio } : {}),
...(data.active !== undefined ? { active: data.active } : {}),
},
});
auditFromReq(req, 'addon.catalogo_updated', {
entityType: 'PlanAddonCatalogo',
entityId: id,
metadata: {
codename: before.codename,
before: { nombre: before.nombre, precio: Number(before.precio), active: before.active },
after: { nombre: updated.nombre, precio: Number(updated.precio), active: updated.active },
},
});
return res.json({
id: updated.id,
codename: updated.codename,
nombre: updated.nombre,
precio: Number(updated.precio),
frecuencia: updated.frecuencia,
active: updated.active,
});
} catch (err: any) {
if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message));
return next(err);
}
}

View File

@@ -0,0 +1,46 @@
import type { Request, Response, NextFunction } from 'express';
import * as svc from '../services/admin-clientes.service.js';
import { isPlatformStaff } from '../utils/platform-admin.js';
import { AppError } from '../middlewares/error.middleware.js';
async function requireStaff(req: Request) {
if (!req.user?.userId) throw new AppError(401, 'No autenticado');
const isStaff = await isPlatformStaff(req.user.userId);
if (!isStaff) throw new AppError(403, 'Acceso restringido a staff de plataforma');
}
/**
* Stats de gestión de clientes.
*
* Query params:
* - `from` (YYYY-MM-DD): inicio del rango. Default: primer día del mes en curso.
* - `to` (YYYY-MM-DD): fin del rango. Default: último día del mes en curso.
*/
export async function getStats(req: Request, res: Response, next: NextFunction) {
try {
await requireStaff(req);
const now = new Date();
const defaultFrom = new Date(now.getFullYear(), now.getMonth(), 1);
const defaultTo = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999);
const fromStr = String(req.query.from || '').trim();
const toStr = String(req.query.to || '').trim();
const from = fromStr ? new Date(fromStr + 'T00:00:00') : defaultFrom;
const to = toStr ? new Date(toStr + 'T23:59:59.999') : defaultTo;
if (isNaN(from.getTime()) || isNaN(to.getTime())) {
return next(new AppError(400, 'Rango de fechas inválido'));
}
const stats = await svc.getClientesStats({ from, to });
return res.json(stats);
} catch (err) { return next(err); }
}
export async function listUsuarios(req: Request, res: Response, next: NextFunction) {
try {
await requireStaff(req);
const tenantId = String(req.params.tenantId || '');
if (!tenantId) return next(new AppError(400, 'tenantId requerido'));
const usuarios = await svc.getTenantUsuarios(tenantId);
return res.json({ data: usuarios });
} catch (err) { return next(err); }
}

View File

@@ -0,0 +1,36 @@
import type { Request, Response, NextFunction } from 'express';
import * as dashService from '../services/admin-dashboard.service.js';
import { isPlatformStaff } from '../utils/platform-admin.js';
import { AppError } from '../middlewares/error.middleware.js';
async function requireStaff(req: Request) {
if (!req.user?.userId) throw new AppError(401, 'No autenticado');
const isStaff = await isPlatformStaff(req.user.userId);
if (!isStaff) throw new AppError(403, 'Acceso restringido a staff de plataforma');
}
export async function getMetrics(req: Request, res: Response, next: NextFunction) {
try {
await requireStaff(req);
const metrics = await dashService.getDashboardMetrics();
return res.json(metrics);
} catch (err) { return next(err); }
}
export async function listDespachos(req: Request, res: Response, next: NextFunction) {
try {
await requireStaff(req);
const { vertical, status, search } = req.query as Record<string, string>;
const despachos = await dashService.listAllDespachos({ vertical, status, search });
return res.json({ data: despachos });
} catch (err) { return next(err); }
}
export async function getActivity(req: Request, res: Response, next: NextFunction) {
try {
await requireStaff(req);
const limit = Math.min(Number(req.query.limit) || 20, 100);
const activity = await dashService.getRecentActivity(limit);
return res.json({ data: activity });
} catch (err) { return next(err); }
}

View File

@@ -0,0 +1,77 @@
import type { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { prisma } from '../config/database.js';
import { hasPlatformRole } from '../utils/platform-admin.js';
import { auditLog } from '../utils/audit.js';
import { AppError } from '../middlewares/error.middleware.js';
const impersonateSchema = z.object({
despachoId: z.string().uuid('ID de despacho inválido'),
motivo: z.string().min(5, 'Motivo es obligatorio (mínimo 5 caracteres)'),
});
export async function startImpersonation(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user?.userId) return next(new AppError(401, 'No autenticado'));
const canImpersonate = await hasPlatformRole(req.user.userId, 'platform_admin') ||
await hasPlatformRole(req.user.userId, 'platform_ti') ||
await hasPlatformRole(req.user.userId, 'platform_support');
if (!canImpersonate) return next(new AppError(403, 'No tienes permisos para impersonar'));
const { despachoId, motivo } = impersonateSchema.parse(req.body);
const tenant = await prisma.tenant.findUnique({
where: { id: despachoId },
select: { id: true, nombre: true, rfc: true, active: true },
});
if (!tenant) return next(new AppError(404, 'Despacho no encontrado'));
if (!tenant.active) return next(new AppError(403, 'Despacho inactivo'));
await auditLog({
userId: req.user.userId,
tenantId: despachoId,
action: 'admin.impersonate_start',
entityType: 'tenant',
entityId: despachoId,
metadata: {
motivo,
adminEmail: req.user.email,
despachoNombre: tenant.nombre,
despachoRfc: tenant.rfc,
ip: req.ip,
userAgent: req.headers['user-agent'],
},
});
return res.json({
despachoId: tenant.id,
nombre: tenant.nombre,
rfc: tenant.rfc,
message: 'Impersonación iniciada. Usa el header X-View-Tenant para acceder.',
});
} catch (err: any) {
if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message));
return next(err);
}
}
export async function stopImpersonation(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user?.userId) return next(new AppError(401, 'No autenticado'));
const despachoId = req.body.despachoId as string | undefined;
await auditLog({
userId: req.user.userId,
tenantId: despachoId || undefined,
action: 'admin.impersonate_end',
metadata: {
adminEmail: req.user.email,
ip: req.ip,
},
});
return res.json({ message: 'Impersonación finalizada' });
} catch (err) { return next(err); }
}

View File

@@ -0,0 +1,506 @@
import type { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import * as alertasService from '../services/alertas.service.js';
import { generarAlertasAutomaticas, SOSPECHOSA_TIPO_RELACION_WHERE_EXPORT } from '../services/alertas-auto.service.js';
import { sincronizarAlertasManuales, getAlertasManualesPendientes, resolverAlerta } from '../services/alertas-manuales.service.js';
import { getRegimenesActivosClavesEfectivos } from '../services/regimen.service.js';
import { prisma } from '../config/database.js';
import { AppError } from '../middlewares/error.middleware.js';
const createAlertaSchema = z.object({
tipo: z.enum(['vencimiento', 'discrepancia', 'iva_favor', 'declaracion', 'limite_cfdi', 'custom']),
titulo: z.string().min(1).max(200),
mensaje: z.string().min(1).max(2000),
prioridad: z.enum(['alta', 'media', 'baja']),
fechaVencimiento: z.string().optional(),
});
const updateAlertaSchema = z.object({
leida: z.boolean().optional(),
resuelta: z.boolean().optional(),
});
export async function getAlertas(req: Request, res: Response, next: NextFunction) {
try {
const { leida, resuelta, prioridad } = req.query;
const alertas = await alertasService.getAlertas(req.tenantPool!, {
leida: leida === 'true' ? true : leida === 'false' ? false : undefined,
resuelta: resuelta === 'true' ? true : resuelta === 'false' ? false : undefined,
prioridad: prioridad as string,
});
res.json(alertas);
} catch (error) {
next(error);
}
}
export async function getAlerta(req: Request, res: Response, next: NextFunction) {
try {
const alerta = await alertasService.getAlertaById(req.tenantPool!, parseInt(String(req.params.id)));
if (!alerta) {
return res.status(404).json({ message: 'Alerta no encontrada' });
}
res.json(alerta);
} catch (error) {
next(error);
}
}
export async function createAlerta(req: Request, res: Response, next: NextFunction) {
try {
const data = createAlertaSchema.parse(req.body);
const alerta = await alertasService.createAlerta(req.tenantPool!, data);
res.status(201).json(alerta);
} catch (error) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error);
}
}
export async function updateAlerta(req: Request, res: Response, next: NextFunction) {
try {
const data = updateAlertaSchema.parse(req.body);
const alerta = await alertasService.updateAlerta(req.tenantPool!, parseInt(String(req.params.id)), data);
res.json(alerta);
} catch (error) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error);
}
}
export async function deleteAlerta(req: Request, res: Response, next: NextFunction) {
try {
await alertasService.deleteAlerta(req.tenantPool!, parseInt(String(req.params.id)));
res.status(204).send();
} catch (error) {
next(error);
}
}
export async function getStats(req: Request, res: Response, next: NextFunction) {
try {
const stats = await alertasService.getStats(req.tenantPool!);
res.json(stats);
} catch (error) {
next(error);
}
}
export async function markAllAsRead(req: Request, res: Response, next: NextFunction) {
try {
await alertasService.markAllAsRead(req.tenantPool!);
res.json({ success: true });
} catch (error) {
next(error);
}
}
export async function getManualesPendientes(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = req.query.contribuyenteId as string | undefined;
// Sincronizar primero (crear alertas para eventos vencidos nuevos)
await sincronizarAlertasManuales(req.tenantPool!, req.user!.tenantId, contribuyenteId || null);
// Devolver pendientes (filtered by contribuyente or user role)
const alertas = await getAlertasManualesPendientes(
req.tenantPool!,
contribuyenteId || null,
req.user!.userId,
req.user!.role,
);
res.json(alertas);
} catch (error) {
next(error);
}
}
export async function resolverAlertaManual(req: Request, res: Response, next: NextFunction) {
try {
await resolverAlerta(req.tenantPool!, String(req.params.id));
res.json({ success: true });
} catch (error) {
next(error);
}
}
export async function getAlertasAutomaticas(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = req.query.contribuyenteId as string | undefined;
const alertas = await generarAlertasAutomaticas(req.tenantPool!, req.user!.tenantId, contribuyenteId || null);
res.json(alertas);
} catch (error) {
next(error);
}
}
// Drill-down: Clientes en lista negra
export async function getListaNegraClientes(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = req.query.contribuyenteId as string | undefined;
const cf = contribuyenteId
? `AND contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'`
: '';
const listaRfcs = await prisma.listaNegra.findMany({
where: { situacion: { in: ['Definitivo', 'Presunto'] } },
select: { rfc: true, nombre: true, situacion: true },
});
const rfcMap = new Map(listaRfcs.map(l => [l.rfc, l]));
const { rows } = await req.tenantPool!.query(`
SELECT rfc_receptor as rfc, nombre_receptor as nombre,
COUNT(*)::int as cantidad, SUM(total_mxn) as total
FROM cfdis
WHERE type = 'EMITIDO' AND status NOT IN ('Cancelado', '0') AND tipo_comprobante = 'I'
${cf}
GROUP BY rfc_receptor, nombre_receptor
ORDER BY total DESC
`);
const result = rows
.filter((r: any) => rfcMap.has(r.rfc))
.map((r: any) => ({
rfc: r.rfc,
nombre: r.nombre,
cantidad: r.cantidad,
total: Number(r.total),
situacionSat: rfcMap.get(r.rfc)!.situacion,
}));
res.json(result);
} catch (error) {
next(error);
}
}
// Drill-down: Proveedores en lista negra
export async function getListaNegraProveedores(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = req.query.contribuyenteId as string | undefined;
const cf = contribuyenteId
? `AND contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'`
: '';
const listaRfcs = await prisma.listaNegra.findMany({
where: { situacion: { in: ['Definitivo', 'Presunto'] } },
select: { rfc: true, nombre: true, situacion: true },
});
const rfcMap = new Map(listaRfcs.map(l => [l.rfc, l]));
const { rows } = await req.tenantPool!.query(`
SELECT rfc_emisor as rfc, nombre_emisor as nombre,
COUNT(*)::int as cantidad, SUM(total_mxn) as total
FROM cfdis
WHERE type = 'RECIBIDO' AND status NOT IN ('Cancelado', '0') AND tipo_comprobante = 'I'
${cf}
GROUP BY rfc_emisor, nombre_emisor
ORDER BY total DESC
`);
const result = rows
.filter((r: any) => rfcMap.has(r.rfc))
.map((r: any) => ({
rfc: r.rfc,
nombre: r.nombre,
cantidad: r.cantidad,
total: Number(r.total),
situacionSat: rfcMap.get(r.rfc)!.situacion,
}));
res.json(result);
} catch (error) {
next(error);
}
}
// Drill-down: Concentración de clientes
export async function getConcentracionClientes(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = req.query.contribuyenteId as string | undefined;
const cf = contribuyenteId
? `AND contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'`
: '';
const { rows } = await req.tenantPool!.query(`
SELECT rfc_receptor as rfc, nombre_receptor as nombre,
COUNT(*)::int as cantidad,
SUM(total_mxn) as total
FROM cfdis
WHERE type = 'EMITIDO' AND tipo_comprobante = 'I'
AND status NOT IN ('Cancelado', '0') AND total_mxn > 0
${cf}
GROUP BY rfc_receptor, nombre_receptor
ORDER BY total DESC
`);
const totalGeneral = rows.reduce((s: number, r: any) => s + Number(r.total), 0);
const result = rows.map((r: any) => ({
rfc: r.rfc,
nombre: r.nombre,
cantidad: r.cantidad,
total: Number(r.total),
participacion: totalGeneral > 0 ? Math.round((Number(r.total) / totalGeneral) * 10000) / 100 : 0,
}));
res.json(result);
} catch (error) {
next(error);
}
}
// Drill-down: Concentración de proveedores
export async function getConcentracionProveedores(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = req.query.contribuyenteId as string | undefined;
const cf = contribuyenteId
? `AND contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'`
: '';
const { rows } = await req.tenantPool!.query(`
SELECT rfc_emisor as rfc, nombre_emisor as nombre,
COUNT(*)::int as cantidad,
SUM(total_mxn) as total
FROM cfdis
WHERE type = 'RECIBIDO' AND tipo_comprobante = 'I'
AND status NOT IN ('Cancelado', '0') AND total_mxn > 0
${cf}
GROUP BY rfc_emisor, nombre_emisor
ORDER BY total DESC
`);
const totalGeneral = rows.reduce((s: number, r: any) => s + Number(r.total), 0);
const result = rows.map((r: any) => ({
rfc: r.rfc,
nombre: r.nombre,
cantidad: r.cantidad,
total: Number(r.total),
participacion: totalGeneral > 0 ? Math.round((Number(r.total) / totalGeneral) * 10000) / 100 : 0,
}));
res.json(result);
} catch (error) {
next(error);
}
}
// Drill-down: CFDIs con discrepancia de régimen
export async function getDiscrepanciaRegimen(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = req.query.contribuyenteId as string | undefined;
const activos = await getRegimenesActivosClavesEfectivos(req.user!.tenantId, req.tenantPool!, contribuyenteId);
if (activos.length === 0) return res.json([]);
const cf = contribuyenteId
? `AND contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'`
: '';
const { rows } = await req.tenantPool!.query(`
SELECT id, uuid, type, fecha_emision as "fechaEmision",
rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor",
rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor",
total_mxn as "totalMxn", regimen_fiscal_receptor as "regimenReceptor"
FROM cfdis
WHERE type = 'RECIBIDO'
AND status = 'Vigente'
AND fecha_cancelacion IS NULL
AND regimen_fiscal_receptor IS NOT NULL
AND regimen_fiscal_receptor != ALL($1)
AND id NOT IN (SELECT cfdi_id FROM cfdi_descartados WHERE tipo_alerta = 'discrepancia-regimen')
${cf}
ORDER BY fecha_emision DESC
`, [activos]);
res.json(rows);
} catch (error) {
next(error);
}
}
// Drill-down: CFDIs cancelados
export async function getCancelados(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = req.query.contribuyenteId as string | undefined;
const cf = contribuyenteId
? `AND contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'`
: '';
const hace5 = new Date();
hace5.setFullYear(hace5.getFullYear() - 5);
const { rows } = await req.tenantPool!.query(`
SELECT id, uuid, type, fecha_emision as "fechaEmision",
rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor",
rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor",
total_mxn as "totalMxn", fecha_cancelacion as "fechaCancelacion"
FROM cfdis
WHERE status IN ('Cancelado', '0')
AND fecha_emision >= $1::date
${cf}
ORDER BY fecha_emision DESC
`, [hace5.toISOString().split('T')[0]]);
res.json(rows);
} catch (error) {
next(error);
}
}
// Drill-down: Facturas de periodos anteriores canceladas este mes
export async function getCancelacionesPeriodoAnterior(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = req.query.contribuyenteId as string | undefined;
const cf = contribuyenteId
? `AND contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'`
: '';
const ahora = new Date();
const inicioMes = `${ahora.getFullYear()}-${String(ahora.getMonth() + 1).padStart(2, '0')}-01`;
const { rows } = await req.tenantPool!.query(`
SELECT id, uuid, type, fecha_emision as "fechaEmision",
rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor",
rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor",
total_mxn as "totalMxn", tipo_comprobante as "tipoComprobante",
fecha_cancelacion as "fechaCancelacion"
FROM cfdis
WHERE status IN ('Cancelado', '0')
AND fecha_cancelacion >= $1::date
AND fecha_emision < $1::date
${cf}
ORDER BY fecha_cancelacion DESC
`, [inicioMes]);
res.json(rows);
} catch (error) {
next(error);
}
}
// Drill-down: CFDIs con pago en efectivo
export async function getEfectivo(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = req.query.contribuyenteId as string | undefined;
const cf = contribuyenteId
? `AND contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'`
: '';
const { rows } = await req.tenantPool!.query(`
SELECT id, uuid, type, fecha_emision as "fechaEmision",
rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor",
rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor",
total_mxn as "totalMxn", forma_pago as "formaPago"
FROM cfdis
WHERE status NOT IN ('Cancelado', '0') AND tipo_comprobante = 'I'
AND forma_pago = '01'
${cf}
ORDER BY fecha_emision DESC
`);
res.json(rows);
} catch (error) {
next(error);
}
}
// Drill-down: CFDIs tipo E con TipoRelacion sospechoso (debería ser 07)
export async function getTipoRelacionSospechosa(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = req.query.contribuyenteId as string | undefined;
const cf = contribuyenteId
? `AND c.contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'`
: '';
const { rows } = await req.tenantPool!.query(`
SELECT c.id, c.uuid, c.type, c.fecha_emision AS "fechaEmision",
c.rfc_emisor AS "rfcEmisor", c.nombre_emisor AS "nombreEmisor",
c.rfc_receptor AS "rfcReceptor", c.nombre_receptor AS "nombreReceptor",
c.total_mxn AS "totalMxn",
c.tipo_comprobante AS "tipoComprobante",
c.cfdi_tipo_relacion AS "cfdiTipoRelacion",
c.cfdis_relacionados AS "cfdisRelacionados"
FROM cfdis c
WHERE ${SOSPECHOSA_TIPO_RELACION_WHERE_EXPORT}
${cf}
ORDER BY c.fecha_emision DESC
`);
res.json(rows);
} catch (error) {
next(error);
}
}
// ── Descarte de CFDIs de alertas ──
export async function descartarCfdis(req: Request, res: Response, next: NextFunction) {
try {
const { cfdiIds, tipoAlerta } = z.object({
cfdiIds: z.array(z.number().int()),
tipoAlerta: z.string().min(1),
}).parse(req.body);
for (const cfdiId of cfdiIds) {
await req.tenantPool!.query(
`INSERT INTO cfdi_descartados (cfdi_id, tipo_alerta, descartado_por)
VALUES ($1, $2, $3) ON CONFLICT (cfdi_id, tipo_alerta) DO NOTHING`,
[cfdiId, tipoAlerta, req.user!.email],
);
}
res.json({ descartados: cfdiIds.length });
} catch (error) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error);
}
}
export async function restaurarDescartados(req: Request, res: Response, next: NextFunction) {
try {
const { cfdiIds, tipoAlerta } = z.object({
cfdiIds: z.array(z.number().int()).optional(),
tipoAlerta: z.string().min(1),
}).parse(req.body);
if (cfdiIds && cfdiIds.length > 0) {
await req.tenantPool!.query(
`DELETE FROM cfdi_descartados WHERE tipo_alerta = $1 AND cfdi_id = ANY($2)`,
[tipoAlerta, cfdiIds],
);
} else {
await req.tenantPool!.query(
`DELETE FROM cfdi_descartados WHERE tipo_alerta = $1`,
[tipoAlerta],
);
}
res.json({ success: true });
} catch (error) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error);
}
}
export async function getDescartados(req: Request, res: Response, next: NextFunction) {
try {
const tipoAlerta = req.query.tipoAlerta as string;
if (!tipoAlerta) return next(new AppError(400, 'tipoAlerta requerido'));
const contribuyenteId = req.query.contribuyenteId as string | undefined;
const cf = contribuyenteId
? `AND c.contribuyente_id = '${contribuyenteId.replace(/[^a-f0-9-]/gi, '')}'`
: '';
// JOIN con cfdis para devolver datos completos (mismo shape que el
// drill-down activo, para que el frontend pueda reutilizar el componente).
const { rows } = await req.tenantPool!.query(`
SELECT c.id, c.uuid, c.type, c.fecha_emision AS "fechaEmision",
c.rfc_emisor AS "rfcEmisor", c.nombre_emisor AS "nombreEmisor",
c.rfc_receptor AS "rfcReceptor", c.nombre_receptor AS "nombreReceptor",
c.total_mxn AS "totalMxn",
c.regimen_fiscal_receptor AS "regimenReceptor",
d.descartado_por AS "descartadoPor",
d.created_at AS "descartadoEn"
FROM cfdi_descartados d
JOIN cfdis c ON c.id = d.cfdi_id
WHERE d.tipo_alerta = $1
${cf}
ORDER BY d.created_at DESC
`, [tipoAlerta]);
res.json({ data: rows });
} catch (error) { next(error); }
}

View File

@@ -0,0 +1,87 @@
import type { Request, Response, NextFunction } from 'express';
import { prisma } from '../config/database.js';
import { isGlobalAdmin } from '../utils/global-admin.js';
async function requireGlobalAdmin(req: Request, res: Response): Promise<boolean> {
const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role);
if (!isAdmin) {
res.status(403).json({ message: 'Solo el administrador global puede consultar el audit log' });
}
return isAdmin;
}
/**
* Lista eventos de audit con filtros opcionales. Admin global only.
*
* Query params:
* action — filtra por action prefix (ej: "subscription." matches todas las subs)
* tenantId — filtra a un tenant específico
* userId — filtra a un user específico
* from, to — rango de fechas (ISO)
* page, limit — paginación (default: 1, 50; max limit 200)
*/
export async function listAuditLog(req: Request, res: Response, next: NextFunction) {
try {
if (!(await requireGlobalAdmin(req, res))) return;
const action = String(req.query.action || '').trim();
const tenantId = String(req.query.tenantId || '').trim();
const userId = String(req.query.userId || '').trim();
const from = String(req.query.from || '').trim();
const to = String(req.query.to || '').trim();
const page = Math.max(1, parseInt(String(req.query.page || '1'), 10) || 1);
const limit = Math.min(200, Math.max(1, parseInt(String(req.query.limit || '50'), 10) || 50));
const where: any = {};
if (action) where.action = { startsWith: action };
if (tenantId) where.tenantId = tenantId;
if (userId) where.userId = userId;
if (from || to) {
where.createdAt = {};
if (from) where.createdAt.gte = new Date(from);
if (to) where.createdAt.lte = new Date(to);
}
const [total, rows] = await Promise.all([
prisma.auditLog.count({ where }),
prisma.auditLog.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
]);
// Enriquecer con user.email y tenant.nombre para display
const userIds = [...new Set(rows.map(r => r.userId).filter(Boolean))] as string[];
const tenantIds = [...new Set(rows.map(r => r.tenantId).filter(Boolean))] as string[];
const [users, tenants] = await Promise.all([
userIds.length
? prisma.user.findMany({ where: { id: { in: userIds } }, select: { id: true, email: true, nombre: true } })
: [],
tenantIds.length
? prisma.tenant.findMany({ where: { id: { in: tenantIds } }, select: { id: true, nombre: true, rfc: true } })
: [],
]);
const userMap = new Map(users.map(u => [u.id, u]));
const tenantMap = new Map(tenants.map(t => [t.id, t]));
const data = rows.map(r => ({
...r,
user: r.userId ? userMap.get(r.userId) || null : null,
tenant: r.tenantId ? tenantMap.get(r.tenantId) || null : null,
}));
res.json({
data,
page,
limit,
total,
totalPages: Math.ceil(total / limit),
});
} catch (error) {
next(error);
}
}

View File

@@ -0,0 +1,189 @@
import type { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import * as authService from '../services/auth.service.js';
import { AppError } from '../middlewares/error.middleware.js';
const registerSchema = z.object({
empresa: z.object({
nombre: z.string().min(2, 'Nombre de empresa requerido'),
rfc: z.string().min(12).max(13, 'RFC inválido'),
}),
usuario: z.object({
nombre: z.string().min(2, 'Nombre requerido'),
email: z.string().email('Email inválido'),
password: z.string().min(8, 'La contraseña debe tener al menos 8 caracteres'),
}),
});
const loginSchema = z.object({
email: z.string().email('Email inválido'),
password: z.string().min(1, 'Contraseña requerida'),
});
export async function register(req: Request, res: Response, next: NextFunction) {
try {
const data = registerSchema.parse(req.body);
const result = await authService.register(data);
res.status(201).json(result);
} catch (error) {
if (error instanceof z.ZodError) {
return next(new AppError(400, error.errors[0].message));
}
next(error);
}
}
export async function login(req: Request, res: Response, next: NextFunction) {
try {
const data = loginSchema.parse(req.body);
const result = await authService.login(data);
res.json(result);
} catch (error) {
if (error instanceof z.ZodError) {
return next(new AppError(400, error.errors[0].message));
}
next(error);
}
}
export async function refresh(req: Request, res: Response, next: NextFunction) {
try {
const { refreshToken } = req.body;
if (!refreshToken) {
throw new AppError(400, 'Refresh token requerido');
}
const tokens = await authService.refreshTokens(refreshToken);
res.json(tokens);
} catch (error) {
next(error);
}
}
export async function logout(req: Request, res: Response, next: NextFunction) {
try {
const { refreshToken } = req.body;
if (refreshToken) {
await authService.logout(refreshToken);
}
res.json({ message: 'Sesión cerrada exitosamente' });
} catch (error) {
next(error);
}
}
export async function me(req: Request, res: Response, next: NextFunction) {
try {
res.json({ user: req.user });
} catch (error) {
next(error);
}
}
const passwordResetRequestSchema = z.object({
email: z.string().email('Email inválido'),
});
const passwordResetConfirmSchema = z.object({
token: z.string().min(10, 'Token inválido'),
newPassword: z.string().min(8, 'La contraseña debe tener al menos 8 caracteres'),
});
/**
* Solicita recuperación de contraseña. Responde 200 siempre (anti-enumeration),
* independiente de si el email existe o no.
*/
export async function requestPasswordReset(req: Request, res: Response, next: NextFunction) {
try {
const { email } = passwordResetRequestSchema.parse(req.body);
// Dispara async — no esperamos resultado para preservar timing constante
await authService.requestPasswordReset(email);
res.json({
message: 'Si el email existe en nuestro sistema, recibirás un enlace para restablecer tu contraseña.',
});
} catch (error) {
if (error instanceof z.ZodError) {
return next(new AppError(400, error.errors[0].message));
}
next(error);
}
}
/**
* Confirma recuperación con token + nueva contraseña.
*/
export async function confirmPasswordReset(req: Request, res: Response, next: NextFunction) {
try {
const { token, newPassword } = passwordResetConfirmSchema.parse(req.body);
await authService.confirmPasswordReset(token, newPassword);
res.json({ message: 'Contraseña actualizada exitosamente. Por favor inicia sesión con tu nueva contraseña.' });
} catch (error) {
if (error instanceof z.ZodError) {
return next(new AppError(400, error.errors[0].message));
}
next(error);
}
}
const changePasswordSchema = z.object({
currentPassword: z.string().min(1, 'Contraseña actual requerida'),
newPassword: z.string().min(8, 'La contraseña debe tener al menos 8 caracteres'),
});
/**
* Cambia la contraseña del user autenticado. Requiere contraseña actual.
* Tras cambio: todas las sesiones del user quedan invalidadas (incluyendo esta).
*/
export async function changePassword(req: Request, res: Response, next: NextFunction) {
try {
const { currentPassword, newPassword } = changePasswordSchema.parse(req.body);
await authService.changePassword({
userId: req.user!.userId,
currentPassword,
newPassword,
});
res.json({
message: 'Contraseña actualizada. Por seguridad, todas tus sesiones fueron cerradas. Inicia sesión de nuevo.',
});
} catch (error) {
if (error instanceof z.ZodError) {
return next(new AppError(400, error.errors[0].message));
}
next(error);
}
}
/**
* "Cerrar todas las sesiones" — invalida todos los tokens del user actual.
*/
export async function logoutAll(req: Request, res: Response, next: NextFunction) {
try {
await authService.logoutAllSessions(req.user!.userId);
res.json({ message: 'Todas tus sesiones fueron cerradas. Inicia sesión de nuevo.' });
} catch (error) {
next(error);
}
}
const switchTenantSchema = z.object({
tenantId: z.string().uuid('tenantId inválido'),
refreshToken: z.string().min(1, 'refreshToken requerido'),
});
/**
* Cambia el tenant activo del user (requiere membership válida). Emite un par
* nuevo de tokens apuntando al tenant destino y revoca el refresh token actual.
*/
export async function switchTenant(req: Request, res: Response, next: NextFunction) {
try {
const { tenantId, refreshToken } = switchTenantSchema.parse(req.body);
const result = await authService.switchTenant({
userId: req.user!.userId,
currentRefreshToken: refreshToken,
targetTenantId: tenantId,
});
res.json(result);
} catch (error) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error);
}
}

View File

@@ -0,0 +1,62 @@
import type { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import * as bancosService from '../services/bancos.service.js';
import { AppError } from '../middlewares/error.middleware.js';
const createSchema = z.object({
banco: z.string().min(1, 'banco requerido').max(100),
terminacionCuenta: z.string().min(1).max(4, 'terminacionCuenta max 4 digitos'),
});
const updateSchema = z.object({
banco: z.string().min(1).max(100).optional(),
terminacionCuenta: z.string().min(1).max(4).optional(),
});
export async function getBancos(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = (req.query.contribuyenteId as string) || null;
const bancos = await bancosService.getBancos(req.tenantPool!, contribuyenteId);
res.json(bancos);
} catch (error) { next(error); }
}
export async function createBanco(req: Request, res: Response, next: NextFunction) {
try {
if (req.user!.role !== 'owner') return res.status(403).json({ message: 'No autorizado' });
const data = createSchema.parse(req.body);
const contribuyenteId = req.body.contribuyenteId as string | undefined;
const result = await bancosService.createBanco(req.tenantPool!, { ...data, contribuyenteId });
res.status(201).json(result);
} catch (error) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error);
}
}
export async function updateBanco(req: Request, res: Response, next: NextFunction) {
try {
if (req.user!.role !== 'owner') return res.status(403).json({ message: 'No autorizado' });
const id = parseInt(String(req.params.id));
const data = updateSchema.parse(req.body);
const result = await bancosService.updateBanco(req.tenantPool!, id, data);
res.json(result);
} catch (error) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error);
}
}
export async function deleteBanco(req: Request, res: Response, next: NextFunction) {
try {
if (req.user!.role !== 'owner') return res.status(403).json({ message: 'No autorizado' });
const id = parseInt(String(req.params.id));
await bancosService.deleteBanco(req.tenantPool!, id);
res.json({ message: 'Banco eliminado' });
} catch (error: any) {
if (error.message?.includes('conciliaciones')) {
return res.status(400).json({ message: error.message });
}
next(error);
}
}

View File

@@ -0,0 +1,175 @@
import type { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { generarEventosFiscales, generarEventosDesdeObligaciones } from '../services/calendario-fiscal.service.js';
import * as recordatoriosService from '../services/recordatorios.service.js';
import { getEventosTareasParaCalendario } from '../services/tareas.service.js';
import { AppError } from '../middlewares/error.middleware.js';
import { isDespachoTenant } from '@horux/shared';
import { prisma } from '../config/database.js';
const createRecordatorioSchema = z.object({
titulo: z.string().min(1).max(200),
descripcion: z.string().max(2000).default(''),
fechaLimite: z.string().min(8), // ISO date o yyyy-mm-dd
notas: z.string().max(2000).optional(),
privado: z.boolean().optional(),
});
const updateRecordatorioSchema = z.object({
titulo: z.string().min(1).max(200).optional(),
descripcion: z.string().max(2000).optional(),
fechaLimite: z.string().min(8).optional(),
notas: z.string().max(2000).optional(),
privado: z.boolean().optional(),
completado: z.boolean().optional(),
});
function effectiveTenantId(req: Request): string {
return req.viewingTenantId || req.user!.tenantId;
}
// Forma compatible con EventoFiscal (sin metadata interna como tareaId/periodoId).
function eventoTareaShape(t: import('../services/tareas.service.js').TareaEventoCalendario) {
return {
titulo: t.titulo,
descripcion: t.descripcion,
tipo: 'tarea' as const,
fechaLimite: t.fechaLimite,
recurrencia: t.recurrencia,
completado: t.completado,
notas: t.notas,
// Metadata adicional para el frontend (links, modales)
tareaId: t.tareaId,
periodoId: t.periodoId,
};
}
export async function getEventosGenerados(req: Request, res: Response, next: NextFunction) {
try {
const año = parseInt(req.query.año as string) || new Date().getFullYear();
const tenantId = effectiveTenantId(req);
let fiscales;
// Determine tenant type by looking up the RFC from the central DB
const tenantRecord = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { rfc: true },
});
const isDespacho = isDespachoTenant(tenantRecord?.rfc);
if (isDespacho) {
const contribuyenteId = (req.query.contribuyenteId as string) || null;
fiscales = await generarEventosDesdeObligaciones(req.tenantPool!, contribuyenteId, año);
} else {
// Horux360: use static catalog as before
fiscales = await generarEventosFiscales(tenantId, año);
}
// Recordatorios custom — always included regardless of tenant type
const custom = await recordatoriosService.getRecordatorios(
req.tenantPool!,
req.user!.userId,
año
);
// Tareas operativas (despacho) — solo si hay contribuyente y rol no es cliente.
// El usuario tipo cliente no debe ver tareas internas del despacho.
let tareas: ReturnType<typeof eventoTareaShape>[] = [];
const contribuyenteIdParam = (req.query.contribuyenteId as string) || null;
if (contribuyenteIdParam && req.user?.role !== 'cliente') {
const tareasRaw = await getEventosTareasParaCalendario(
req.tenantPool!,
contribuyenteIdParam,
año,
);
tareas = tareasRaw.map(eventoTareaShape);
}
// Merge y ordenar por fecha
const todos = [...fiscales, ...custom, ...tareas].sort((a, b) =>
a.fechaLimite.localeCompare(b.fechaLimite)
);
res.json(todos);
} catch (error) {
next(error);
}
}
export async function createRecordatorio(req: Request, res: Response, next: NextFunction) {
try {
if (!['owner', 'cfo', 'contador', 'supervisor', 'auxiliar'].includes(req.user!.role)) {
return res.status(403).json({ message: 'Solo admin y contador pueden crear recordatorios' });
}
const data = createRecordatorioSchema.parse(req.body);
const evento = await recordatoriosService.createRecordatorio(
req.tenantPool!,
req.user!.userId,
{ ...data, tipo: 'custom', recurrencia: 'unica' }
);
res.status(201).json(evento);
} catch (error) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error);
}
}
export async function updateRecordatorio(req: Request, res: Response, next: NextFunction) {
try {
if (!['owner', 'cfo', 'contador', 'supervisor', 'auxiliar'].includes(req.user!.role)) {
return res.status(403).json({ message: 'Solo admin y contador pueden editar recordatorios' });
}
const id = parseInt(String(req.params.id));
if (isNaN(id)) {
return res.status(400).json({ message: 'ID inválido' });
}
const data = updateRecordatorioSchema.parse(req.body);
const evento = await recordatoriosService.updateRecordatorio(
req.tenantPool!,
req.user!.userId,
id,
data
);
if (!evento) {
return res.status(404).json({ message: 'Recordatorio no encontrado' });
}
res.json(evento);
} catch (error) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error);
}
}
export async function deleteRecordatorio(req: Request, res: Response, next: NextFunction) {
try {
if (!['owner', 'cfo', 'contador', 'supervisor', 'auxiliar'].includes(req.user!.role)) {
return res.status(403).json({ message: 'Solo admin y contador pueden eliminar recordatorios' });
}
const id = parseInt(String(req.params.id));
if (isNaN(id)) {
return res.status(400).json({ message: 'ID inválido' });
}
const deleted = await recordatoriosService.deleteRecordatorio(
req.tenantPool!,
req.user!.userId,
id
);
if (!deleted) {
return res.status(404).json({ message: 'Recordatorio no encontrado' });
}
res.status(204).send();
} catch (error) {
next(error);
}
}

View File

@@ -0,0 +1,277 @@
import type { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import * as carteraService from '../services/cartera.service.js';
import { AppError } from '../middlewares/error.middleware.js';
const createSchema = z.object({
nombre: z.string().min(1, 'Nombre requerido'),
descripcion: z.string().optional(),
supervisorUserId: z.string().uuid().optional(), // Owner can assign to a supervisor
});
const createSubcarteraSchema = z.object({
nombre: z.string().min(1, 'Nombre requerido'),
descripcion: z.string().optional(),
auxiliarUserId: z.string().uuid('Auxiliar requerido'),
});
const updateSchema = z.object({
nombre: z.string().min(1).optional(),
descripcion: z.string().optional(),
supervisorUserId: z.string().uuid().optional(),
});
/**
* Permission helpers:
* - Owner: sees all, edits all
* - Supervisor: sees carteras assigned to them (by owner) + carteras they created.
* Can only edit/delete carteras THEY created. Cannot edit owner-created ones.
* Can only add contribuyentes that are already assigned to them.
* - Auxiliar: sees subcarteras where they're assigned. Read-only.
*/
function isOwner(req: Request): boolean {
return req.user!.role === 'owner';
}
function isSupervisor(req: Request): boolean {
return req.user!.role === 'supervisor';
}
/** Check if a supervisor created this cartera (vs owner assigned it to them) */
async function supervisorCreatedCartera(req: Request, cartera: carteraService.CarteraRow): Promise<boolean> {
// A cartera was created by the supervisor if supervisorUserId === the supervisor's userId
// AND the cartera was not created by the owner assigning it.
// We use a heuristic: if the supervisor_user_id matches and createdBy is not tracked,
// we assume the supervisor can edit their own carteras.
// For now: supervisor can edit carteras where they are the supervisor.
// Owner-created carteras also have supervisorUserId set to the supervisor —
// so we need another way to distinguish.
// Solution: we'll add a 'created_by' concept. For now, let supervisor edit all carteras
// assigned to them (both owner-created and self-created).
// The user said: "Las que crea el owner, solo las puede ver el supervisor, pero no las puede editar"
// This requires tracking who created the cartera. Let's use a simple approach:
// check if the owner's userId matches the request user.
return cartera.supervisorUserId === req.user!.userId;
}
export async function list(req: Request, res: Response, next: NextFunction) {
try {
const role = req.user!.role;
const userId = req.user!.userId;
if (isOwner(req)) {
// Owner sees all top-level carteras
const rows = await carteraService.listCarteras(req.tenantPool!);
return res.json({ data: rows });
}
if (isSupervisor(req)) {
// Supervisor sees carteras assigned to them
const rows = await carteraService.listCarteras(req.tenantPool!, userId);
return res.json({ data: rows });
}
// Auxiliar: sees subcarteras where they're assigned
const { rows } = await req.tenantPool!.query(
`SELECT c.id, c.supervisor_user_id AS "supervisorUserId",
c.auxiliar_user_id AS "auxiliarUserId", c.parent_id AS "parentId",
c.nombre, c.descripcion, c.created_at AS "createdAt",
(SELECT count(*) FROM cartera_entidades ce WHERE ce.cartera_id = c.id)::int AS "entidadesCount",
0 AS "subcarterasCount"
FROM carteras c
WHERE c.auxiliar_user_id = $1
ORDER BY c.nombre`,
[userId],
);
return res.json({ data: rows });
} catch (err) { return next(err); }
}
export async function getById(req: Request, res: Response, next: NextFunction) {
try {
const row = await carteraService.getCarteraById(req.tenantPool!, String(req.params.id));
if (!row) return next(new AppError(404, 'Cartera no encontrada'));
// Auxiliar can only see their own subcarteras
if (req.user!.role === 'auxiliar' && row.auxiliarUserId !== req.user!.userId) {
return next(new AppError(403, 'No autorizado'));
}
return res.json(row);
} catch (err) { return next(err); }
}
export async function create(req: Request, res: Response, next: NextFunction) {
try {
const data = createSchema.parse(req.body);
const supervisorUserId = data.supervisorUserId || req.user!.userId;
const row = await carteraService.createCartera(req.tenantPool!, {
supervisorUserId,
nombre: data.nombre,
descripcion: data.descripcion,
});
return res.status(201).json(row);
} catch (err: any) {
if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message));
return next(err);
}
}
export async function update(req: Request, res: Response, next: NextFunction) {
try {
const cartera = await carteraService.getCarteraById(req.tenantPool!, String(req.params.id));
if (!cartera) return next(new AppError(404, 'Cartera no encontrada'));
// Supervisor cannot edit carteras (owner-assigned are read-only for them)
// Only owner can edit top-level carteras
if (isSupervisor(req)) {
return next(new AppError(403, 'Solo el owner puede editar carteras'));
}
const data = updateSchema.parse(req.body);
const row = await carteraService.updateCartera(req.tenantPool!, String(req.params.id), data);
return res.json(row);
} catch (err: any) {
if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message));
return next(err);
}
}
export async function remove(req: Request, res: Response, next: NextFunction) {
try {
const cartera = await carteraService.getCarteraById(req.tenantPool!, String(req.params.id));
if (!cartera) return next(new AppError(404, 'Cartera no encontrada'));
if (isSupervisor(req)) {
return next(new AppError(403, 'Solo el owner puede eliminar carteras'));
}
await carteraService.deleteCartera(req.tenantPool!, String(req.params.id));
return res.json({ message: 'Cartera eliminada' });
} catch (err) { return next(err); }
}
// Subcarteras
export async function listSubcarteras(req: Request, res: Response, next: NextFunction) {
try {
const rows = await carteraService.listSubcarteras(req.tenantPool!, String(req.params.id));
return res.json({ data: rows });
} catch (err) { return next(err); }
}
export async function createSubcartera(req: Request, res: Response, next: NextFunction) {
try {
const parent = await carteraService.getCarteraById(req.tenantPool!, String(req.params.id));
if (!parent) return next(new AppError(404, 'Cartera padre no encontrada'));
// Supervisor can create subcarteras within their own carteras
if (isSupervisor(req) && parent.supervisorUserId !== req.user!.userId) {
return next(new AppError(403, 'No autorizado'));
}
const data = createSubcarteraSchema.parse(req.body);
const row = await carteraService.createSubcartera(req.tenantPool!, {
parentId: String(req.params.id),
auxiliarUserId: data.auxiliarUserId,
nombre: data.nombre,
descripcion: data.descripcion,
});
return res.status(201).json(row);
} catch (err: any) {
if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message));
return next(err);
}
}
// Entidades
export async function addEntidad(req: Request, res: Response, next: NextFunction) {
try {
const cartera = await carteraService.getCarteraById(req.tenantPool!, String(req.params.id));
if (!cartera) return next(new AppError(404, 'Cartera no encontrada'));
if (isSupervisor(req)) {
// For subcarteras: check the parent's supervisor
const supervisorId = cartera.supervisorUserId
|| (cartera.parentId ? (await carteraService.getCarteraById(req.tenantPool!, cartera.parentId))?.supervisorUserId : null);
if (supervisorId !== req.user!.userId) {
return next(new AppError(403, 'No autorizado'));
}
}
const { entidadId } = z.object({ entidadId: z.string().uuid() }).parse(req.body);
await carteraService.addEntidadToCartera(req.tenantPool!, String(req.params.id), entidadId);
return res.json({ message: 'Entidad agregada a cartera' });
} catch (err: any) {
if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message));
return next(err);
}
}
export async function removeEntidad(req: Request, res: Response, next: NextFunction) {
try {
const cartera = await carteraService.getCarteraById(req.tenantPool!, String(req.params.id));
if (!cartera) return next(new AppError(404, 'Cartera no encontrada'));
if (isSupervisor(req)) {
const supervisorId = cartera.supervisorUserId
|| (cartera.parentId ? (await carteraService.getCarteraById(req.tenantPool!, cartera.parentId))?.supervisorUserId : null);
if (supervisorId !== req.user!.userId) {
return next(new AppError(403, 'No autorizado'));
}
}
await carteraService.removeEntidadFromCartera(req.tenantPool!, String(req.params.id), String(req.params.entidadId));
return res.json({ message: 'Entidad removida de cartera' });
} catch (err) { return next(err); }
}
export async function getEntidades(req: Request, res: Response, next: NextFunction) {
try {
const ids = await carteraService.getCarteraEntidades(req.tenantPool!, String(req.params.id));
return res.json({ data: ids });
} catch (err) { return next(err); }
}
// Auxiliares
export async function getAuxiliares(req: Request, res: Response, next: NextFunction) {
try {
const ids = await carteraService.getCarteraAuxiliares(req.tenantPool!, String(req.params.id));
return res.json({ data: ids });
} catch (err) { return next(err); }
}
export async function addAuxiliar(req: Request, res: Response, next: NextFunction) {
try {
const { auxiliarUserId } = z.object({ auxiliarUserId: z.string().uuid() }).parse(req.body);
await carteraService.addAuxiliarToCartera(req.tenantPool!, String(req.params.id), auxiliarUserId);
return res.json({ message: 'Auxiliar agregado a cartera' });
} catch (err: any) {
if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message));
return next(err);
}
}
export async function removeAuxiliar(req: Request, res: Response, next: NextFunction) {
try {
await carteraService.removeAuxiliarFromCartera(req.tenantPool!, String(req.params.id), String(req.params.auxiliarUserId));
return res.json({ message: 'Auxiliar removido de cartera' });
} catch (err) { return next(err); }
}
// Supervisores available (for dropdown)
export async function getSupervisores(req: Request, res: Response, next: NextFunction) {
try {
const supervisores = await carteraService.getSupervisores(req.tenantPool!, req.user!.tenantId);
return res.json({ data: supervisores });
} catch (err) { return next(err); }
}
// Auxiliares of a supervisor
export async function getAuxiliaresDelSupervisor(req: Request, res: Response, next: NextFunction) {
try {
const supervisorId = isOwner(req)
? String(req.params.supervisorId || req.user!.userId)
: req.user!.userId;
const rows = await carteraService.getAuxiliaresDelSupervisor(req.tenantPool!, supervisorId);
return res.json({ data: rows });
} catch (err) { return next(err); }
}

View File

@@ -0,0 +1,108 @@
import type { Request, Response, NextFunction } from 'express';
import { prisma } from '../config/database.js';
export async function getFormasPago(req: Request, res: Response, next: NextFunction) {
try {
const data = await prisma.catFormaPago.findMany({ orderBy: { clave: 'asc' } });
res.json(data);
} catch (error) { next(error); }
}
export async function getMetodosPago(req: Request, res: Response, next: NextFunction) {
try {
const data = await prisma.catMetodoPago.findMany({ orderBy: { clave: 'asc' } });
res.json(data);
} catch (error) { next(error); }
}
export async function getUsosCfdi(req: Request, res: Response, next: NextFunction) {
try {
const data = await prisma.catUsoCfdi.findMany({ orderBy: { clave: 'asc' } });
res.json(data);
} catch (error) { next(error); }
}
export async function getMonedas(req: Request, res: Response, next: NextFunction) {
try {
const data = await prisma.catMoneda.findMany({ orderBy: { clave: 'asc' } });
res.json(data);
} catch (error) { next(error); }
}
export async function getClavesUnidad(req: Request, res: Response, next: NextFunction) {
try {
const data = await prisma.catClaveUnidad.findMany({ orderBy: { descripcion: 'asc' } });
res.json(data);
} catch (error) { next(error); }
}
export async function searchClaveProdServ(req: Request, res: Response, next: NextFunction) {
try {
const q = (req.query.q as string || '').trim();
if (q.length < 2) {
return res.json([]);
}
// Buscar por clave o descripción
// Primero buscar por clave, luego por texto
const data = await prisma.catClaveProdServ.findMany({
where: {
OR: [
{ clave: { startsWith: q } },
{ descripcion: { contains: q, mode: 'insensitive' } },
],
},
take: 20,
orderBy: { clave: 'asc' },
});
// Si no hay resultados, intentar sin acentos
if (data.length === 0) {
const normalized = q.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
if (normalized !== q) {
const fallback = await prisma.catClaveProdServ.findMany({
where: { descripcion: { contains: normalized, mode: 'insensitive' } },
take: 20,
orderBy: { clave: 'asc' },
});
return res.json(fallback);
}
// Buscar con variantes comunes de acentos
const withAccents = normalized
.replace(/a/gi, '[aá]').replace(/e/gi, '[eé]')
.replace(/i/gi, '[ií]').replace(/o/gi, '[oó]').replace(/u/gi, '[uú]')
.replace(/n/gi, '[nñ]');
// Usar raw SQL con regex para búsqueda flexible
const rows: any[] = await prisma.$queryRawUnsafe(
`SELECT id, clave, descripcion FROM cat_clave_prod_serv WHERE descripcion ~* $1 ORDER BY clave LIMIT 20`,
withAccents
);
return res.json(rows);
}
res.json(data);
} catch (error) { next(error); }
}
export async function getObjetosImp(req: Request, res: Response, next: NextFunction) {
try {
const data = await prisma.catObjetoImp.findMany({ orderBy: { clave: 'asc' } });
res.json(data);
} catch (error) { next(error); }
}
export async function getTiposRelacion(req: Request, res: Response, next: NextFunction) {
try {
const data = await prisma.catTipoRelacion.findMany({ orderBy: { clave: 'asc' } });
res.json(data);
} catch (error) { next(error); }
}
export async function getExportaciones(req: Request, res: Response, next: NextFunction) {
try {
const data = await prisma.catExportacion.findMany({ orderBy: { clave: 'asc' } });
res.json(data);
} catch (error) { next(error); }
}

View File

@@ -0,0 +1,446 @@
import type { Request, Response, NextFunction } from 'express';
import * as cfdiService from '../services/cfdi.service.js';
import { AppError } from '../middlewares/error.middleware.js';
import { GRUPO_PF_EMPRESARIAL, GRUPO_PM_OTROS } from '../services/dashboard.service.js';
import { getRegimenesIgnoradosClaves } from '../services/regimen.service.js';
import { resolveContribuyenteContext } from '../utils/contribuyente-context.js';
import type { CfdiFilters } from '@horux/shared';
export async function getCfdis(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const filters: CfdiFilters = {
tipo: req.query.tipo as any,
tipoComprobante: req.query.tipoComprobante as any,
estado: req.query.estado as any,
fechaInicio: req.query.fechaInicio as string,
fechaFin: req.query.fechaFin as string,
rfc: req.query.rfc as string,
emisor: req.query.emisor as string,
receptor: req.query.receptor as string,
search: req.query.search as string,
contribuyenteId: req.query.contribuyenteId as string,
page: parseInt(req.query.page as string) || 1,
limit: parseInt(req.query.limit as string) || 20,
};
const result = await cfdiService.getCfdis(req.tenantPool, filters);
res.json(result);
} catch (error) {
next(error);
}
}
export async function getCfdiById(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const cfdi = await cfdiService.getCfdiById(req.tenantPool, String(req.params.id));
if (!cfdi) {
return next(new AppError(404, 'CFDI no encontrado'));
}
res.json(cfdi);
} catch (error) {
next(error);
}
}
export async function getXml(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const xml = await cfdiService.getXmlById(req.tenantPool, String(req.params.id));
if (!xml) {
return next(new AppError(404, 'XML no encontrado para este CFDI'));
}
res.set('Content-Type', 'application/xml');
res.set('Content-Disposition', `attachment; filename="cfdi-${req.params.id}.xml"`);
res.send(xml);
} catch (error) {
next(error);
}
}
export async function getConceptos(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const conceptos = await cfdiService.getConceptos(req.tenantPool, String(req.params.id));
res.json(conceptos);
} catch (error) {
next(error);
}
}
export async function drillDown(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const {
fechaInicio, fechaFin, type, tipoComprobante, metodoPago,
regimenEmisor, regimenReceptor, status, contribuyenteId,
bucket,
} = req.query;
let where = 'WHERE 1=1';
const params: any[] = [];
let pi = 1;
// `bucket` expande la combinación (type, tipo_comprobante, metodo_pago,
// régimen) exactamente igual a la fórmula de KPIs/tarjetas — para que
// el drill-down cuadre línea a línea con el total del header.
//
// Reglas por bucket (alineado con dashboard.service y impuestos.service):
// ingresos: 3 grupos de régimen del emisor con fórmulas distintas.
// Grupo 1 (PF Empresarial 606/612/621/625/626):
// EMIT I PUE + EMIT P + EMIT E PUE (excl. E/07)
// Grupo 2 (Sueldos 605, recibido como N):
// RECIB N PUE con receptor=605
// Grupo 3 (PM y otros): EMIT I PUE+PPD + EMIT E PUE
// gastos: uniforme todos los regímenes del receptor
// RECIB I PUE + RECIB P + RECIB E PUE (excl. E/07)
// causado (IVA): EMIT I PUE + EMIT P + EMIT E PUE (excl. E/07)
// acreditable (IVA): RECIB I PUE + RECIB P + RECIB E PUE (excl. E/07)
//
// Régimenes "ignorados" por el tenant se excluyen en todos los buckets.
// Las NC que restan se muestran como filas con signo (frontend las resta
// del total del header). Si `bucket` se pasa, se ignoran filtros
// type/tipoComprobante/metodoPago de entrada.
const bucketStr = typeof bucket === 'string' ? bucket.toLowerCase() : '';
const bucketApplied = bucketStr === 'ingresos' || bucketStr === 'gastos' ||
bucketStr === 'causado' || bucketStr === 'acreditable';
// Régimenes ignorados por el tenant (configurable en /regimenes). Se
// excluyen del lado correspondiente según el bucket.
const ignorados = req.user?.tenantId
? await getRegimenesIgnoradosClaves(req.user.tenantId)
: [];
// Resolver condiciones esEmisor/esReceptor basadas en RFC del contribuyente.
// Reemplaza `type = 'EMITIDO/RECIBIDO' AND contribuyente_id = X` por un
// filtro por RFC — fuente de verdad cuando dos contribuyentes del tenant
// se facturan entre sí (type/contribuyente_id pueden ser inconsistentes).
const contribIdStr = typeof contribuyenteId === 'string' ? contribuyenteId : undefined;
const cfdiCtx = req.user?.tenantId
? await resolveContribuyenteContext(req.tenantPool, req.user.tenantId, contribIdStr)
: null;
const esEmisor = cfdiCtx?.esEmisor || `type = 'EMITIDO'`;
const esReceptor = cfdiCtx?.esReceptor || `type = 'RECIBIDO'`;
const NO_IGNORADO_EMISOR = ignorados.length > 0
? `AND (regimen_fiscal_emisor IS NULL OR regimen_fiscal_emisor NOT IN (${ignorados.map(r => `'${r}'`).join(',')}))`
: '';
const NO_IGNORADO_RECEPTOR = ignorados.length > 0
? `AND (regimen_fiscal_receptor IS NULL OR regimen_fiscal_receptor NOT IN (${ignorados.map(r => `'${r}'`).join(',')}))`
: '';
const g1 = GRUPO_PF_EMPRESARIAL.map(r => `'${r}'`).join(',');
const g3 = GRUPO_PM_OTROS.map(r => `'${r}'`).join(',');
// Conjunto canónico de regímenes que el dashboard considera (excluye 616
// extranjero y otros fuera del catálogo). El drill debe respetarlo para
// cuadrar con los KPIs/tarjetas.
const TODOS_REGS = [...GRUPO_PF_EMPRESARIAL, '605', ...GRUPO_PM_OTROS]
.map(r => `'${r}'`)
.join(',');
const E_NO_ANTICIPO = `COALESCE(cfdi_tipo_relacion, '') <> '07'`;
if (bucketStr === 'ingresos') {
// 3 grupos con fórmulas distintas. Filtro por RFC (esEmisor/esReceptor).
// Grupo 1 usa Método A: todas las I/07 y E/07 se incluyen (sin filtro
// `E_NO_ANTICIPO`) — la suma algebraica se neutraliza correctamente
// cuando anticipo, I/07 y E/07 están en el mismo universo de la query.
where += ` AND (
( -- Grupo 1 PF Empresarial
${esEmisor}
AND regimen_fiscal_emisor IN (${g1})
AND (
(tipo_comprobante = 'I' AND metodo_pago = 'PUE')
OR (tipo_comprobante = 'P')
OR (tipo_comprobante = 'E' AND metodo_pago = 'PUE')
)
)
OR ( -- Grupo 2 Sueldos: nómina recibida 605
${esReceptor}
AND tipo_comprobante = 'N'
AND metodo_pago = 'PUE'
AND regimen_fiscal_receptor = '605'
)
OR ( -- Grupo 3 PM y otros
${esEmisor}
AND regimen_fiscal_emisor IN (${g3})
AND (
(tipo_comprobante = 'I' AND metodo_pago IN ('PUE','PPD'))
OR (tipo_comprobante = 'E' AND metodo_pago = 'PUE')
)
)
) ${NO_IGNORADO_EMISOR.replace('regimen_fiscal_emisor', `CASE WHEN ${esEmisor} THEN regimen_fiscal_emisor ELSE regimen_fiscal_receptor END`)}`;
} else if (bucketStr === 'gastos') {
// Método A: sin E_NO_ANTICIPO — las E/07 también aparecen en el
// drill (restan del gasto al igual que en el KPI).
where += ` AND (
${esReceptor} AND (
(tipo_comprobante = 'I' AND metodo_pago = 'PUE')
OR (tipo_comprobante = 'P')
OR (tipo_comprobante = 'E' AND metodo_pago = 'PUE')
)
AND regimen_fiscal_receptor IN (${TODOS_REGS})
) ${NO_IGNORADO_RECEPTOR}`;
} else if (bucketStr === 'causado') {
where += ` AND (
${esEmisor} AND (
(tipo_comprobante = 'I' AND metodo_pago = 'PUE')
OR (tipo_comprobante = 'P')
OR (tipo_comprobante = 'E' AND metodo_pago = 'PUE' AND ${E_NO_ANTICIPO})
)
AND regimen_fiscal_emisor IN (${TODOS_REGS})
) ${NO_IGNORADO_EMISOR}`;
} else if (bucketStr === 'acreditable') {
where += ` AND (
${esReceptor} AND (
(tipo_comprobante = 'I' AND metodo_pago = 'PUE')
OR (tipo_comprobante = 'P')
OR (tipo_comprobante = 'E' AND metodo_pago = 'PUE' AND ${E_NO_ANTICIPO})
)
AND regimen_fiscal_receptor IN (${TODOS_REGS})
) ${NO_IGNORADO_RECEPTOR}`;
}
// Fecha efectiva: para CFDIs tipo P (complementos de pago) usa fecha_pago_p
// (cuándo el cliente cobró) en vez de fecha_emision (cuándo se emitió el
// complemento). Así el drill-down es coherente con los KPIs — un P emitido
// en mayo que cobró una PPD de noviembre aparece en noviembre, no en mayo.
const FECHA_EFECTIVA = `CASE WHEN tipo_comprobante = 'P' THEN fecha_pago_p ELSE fecha_emision END`;
if (fechaInicio) {
where += ` AND ${FECHA_EFECTIVA} >= $${pi++}::date`;
params.push(fechaInicio);
}
if (fechaFin) {
where += ` AND ${FECHA_EFECTIVA} < ($${pi++}::date + interval '1 day')`;
params.push(fechaFin);
}
if (!bucketApplied) {
if (type) {
where += ` AND type = $${pi++}`;
params.push(type);
}
// tipoComprobante acepta valor único ('I') o CSV ('I,P'). Cuando la lista
// incluye P, el filtro metodoPago NO se aplica a los P (que no tienen),
// para que un drill-down "Ingresos del Mes" muestre I PUE + todos los P.
const tiposList = tipoComprobante
? (tipoComprobante as string).split(',').map(t => t.trim()).filter(Boolean)
: [];
const includesP = tiposList.includes('P');
if (tiposList.length === 1) {
where += ` AND tipo_comprobante = $${pi++}`;
params.push(tiposList[0]);
} else if (tiposList.length > 1) {
where += ` AND tipo_comprobante = ANY($${pi++})`;
params.push(tiposList);
}
if (metodoPago) {
const metodos = (metodoPago as string).split(',');
if (includesP) {
// P no tiene metodo_pago: el filtro aplica solo a los no-P
where += ` AND (tipo_comprobante = 'P' OR metodo_pago = ANY($${pi++}))`;
params.push(metodos);
} else {
where += ` AND metodo_pago = ANY($${pi++})`;
params.push(metodos);
}
}
}
if (regimenEmisor) {
where += ` AND regimen_fiscal_emisor = $${pi++}`;
params.push(regimenEmisor);
}
if (regimenReceptor) {
where += ` AND regimen_fiscal_receptor = $${pi++}`;
params.push(regimenReceptor);
}
if (status) {
if (status === 'vigente') {
where += ` AND status NOT IN ('Cancelado', '0')`;
} else {
where += ` AND status IN ('Cancelado', '0')`;
}
}
if (contribuyenteId && !bucketApplied) {
// Solo aplica cuando NO hay bucket (drill crudo, sin semantic de lado).
// Con bucket, esEmisor/esReceptor ya restringen por RFC del contribuyente.
// Sin bucket, filtramos inclusivo: contribuyente_id O RFC en cualquier lado.
if (cfdiCtx) {
where += ` AND ${cfdiCtx.contribFilter.replace(/^AND /, '')}`;
}
}
const { rows } = await req.tenantPool.query(`
SELECT id, uuid, type, tipo_comprobante as "tipoComprobante",
fecha_emision as "fechaEmision", status,
rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor",
rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor",
subtotal, subtotal_mxn as "subtotalMxn",
total, total_mxn as "totalMxn",
moneda, metodo_pago as "metodoPago",
iva_traslado_mxn as "ivaTrasladoMxn",
iva_retencion_mxn as "ivaRetencionMxn",
isr_retencion_mxn as "isrRetencionMxn",
monto_pago_mxn as "montoPagoMxn",
regimen_fiscal_emisor as "regimenEmisor",
regimen_fiscal_receptor as "regimenReceptor"
FROM cfdis
${where}
ORDER BY fecha_emision DESC
LIMIT 500
`, params);
res.json(rows);
} catch (error) {
next(error);
}
}
export async function getEmisores(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const search = (req.query.search as string) || '';
if (search.length < 2) {
return res.json([]);
}
const contribuyenteId = req.query.contribuyenteId as string | undefined;
const emisores = await cfdiService.getEmisores(req.tenantPool, search, 10, contribuyenteId);
res.json(emisores);
} catch (error) {
next(error);
}
}
export async function getReceptores(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const search = (req.query.search as string) || '';
if (search.length < 2) {
return res.json([]);
}
const contribuyenteId = req.query.contribuyenteId as string | undefined;
const receptores = await cfdiService.getReceptores(req.tenantPool, search, 10, contribuyenteId);
res.json(receptores);
} catch (error) {
next(error);
}
}
export async function getResumen(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant 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 contribuyenteId = req.query.contribuyenteId as string | undefined;
const resumen = await cfdiService.getResumenCfdis(req.tenantPool, año, mes, contribuyenteId);
res.json(resumen);
} catch (error) {
next(error);
}
}
export async function createCfdi(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
if (!['owner', 'contador'].includes(req.user!.role)) {
return next(new AppError(403, 'No tienes permisos para agregar CFDIs'));
}
const cfdi = await cfdiService.createCfdi(req.tenantPool, 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.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
if (!['owner', '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'));
}
const batchInfo = {
batchNumber: req.body.batchNumber || 1,
totalBatches: req.body.totalBatches || 1,
totalFiles: req.body.totalFiles || req.body.cfdis.length
};
console.log(`[CFDI Bulk] Lote ${batchInfo.batchNumber}/${batchInfo.totalBatches} - ${req.body.cfdis.length} CFDIs`);
const result = await cfdiService.createManyCfdisBatch(req.tenantPool, req.body.cfdis);
res.status(201).json({
message: `Lote ${batchInfo.batchNumber} procesado`,
batchNumber: batchInfo.batchNumber,
totalBatches: batchInfo.totalBatches,
inserted: result.inserted,
duplicates: result.duplicates,
errors: result.errors,
errorMessages: result.errorMessages.slice(0, 5)
});
} 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.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
if (!['owner', 'contador'].includes(req.user!.role)) {
return next(new AppError(403, 'No tienes permisos para eliminar CFDIs'));
}
await cfdiService.deleteCfdi(req.tenantPool, String(req.params.id));
res.status(204).send();
} catch (error) {
next(error);
}
}

View File

@@ -0,0 +1,58 @@
import type { Request, Response, NextFunction } from 'express';
import * as conciliacionService from '../services/conciliacion.service.js';
import { prisma } from '../config/database.js';
export async function getCfdis(req: Request, res: Response, next: NextFunction) {
try {
const { tipo, fechaInicio, fechaFin, regimen, estado, contribuyenteId } = req.query;
if (!tipo) return res.status(400).json({ message: 'tipo es requerido (EMITIDO|RECIBIDO)' });
const data = await conciliacionService.getCfdisConConciliacion(req.tenantPool!, {
tipo: tipo as string,
fechaInicio: fechaInicio as string,
fechaFin: fechaFin as string,
regimen: regimen as string,
estado: estado as string,
contribuyenteId: contribuyenteId as string | undefined,
});
res.json(data);
} catch (error) { next(error); }
}
export async function conciliar(req: Request, res: Response, next: NextFunction) {
try {
if (!['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'].includes(req.user!.role)) {
return res.status(403).json({ message: 'No autorizado' });
}
const { cfdiIds, fechaDePago, idBanco } = req.body;
if (!cfdiIds?.length || !fechaDePago || !idBanco) {
return res.status(400).json({ message: 'cfdiIds, fechaDePago e idBanco son requeridos' });
}
const tenant = await prisma.tenant.findUnique({
where: { id: req.user!.tenantId },
select: { createdAt: true },
});
const tenantCreatedYear = tenant ? tenant.createdAt.getFullYear() : new Date().getFullYear();
const count = await conciliacionService.conciliar(req.tenantPool!, { cfdiIds, fechaDePago, idBanco }, tenantCreatedYear);
res.json({ message: `${count} CFDIs conciliados`, count });
} catch (error: any) {
if (error.message && !error.message.includes('Internal')) {
return res.status(400).json({ message: error.message });
}
next(error);
}
}
export async function desconciliar(req: Request, res: Response, next: NextFunction) {
try {
if (!['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'].includes(req.user!.role)) {
return res.status(403).json({ message: 'No autorizado' });
}
const id = parseInt(String(req.params.id));
await conciliacionService.desconciliar(req.tenantPool!, id);
res.json({ message: 'CFDI desconciliado' });
} catch (error) { next(error); }
}

View File

@@ -0,0 +1,58 @@
import type { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import * as connectorService from '../services/connector.service.js';
import { AppError } from '../middlewares/error.middleware.js';
const heartbeatSchema = z.object({
version: z.string(),
uptimeSeconds: z.number().optional().default(0),
postgresPingMs: z.number().optional().default(0),
pgVersion: z.string().optional(),
lastMigration: z.string().optional(),
status: z.string().optional(),
errorMsg: z.string().optional(),
});
// Called by the connector Docker container, NOT by browser users
export async function heartbeat(req: Request, res: Response, next: NextFunction) {
try {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ message: 'Token requerido' });
}
const token = authHeader.split(' ')[1];
const tenantId = await connectorService.verifyConnectorToken(token);
if (!tenantId) {
return res.status(401).json({ message: 'Token inválido' });
}
const data = heartbeatSchema.parse(req.body);
await connectorService.recordHeartbeat(tenantId, data);
return res.json({ ok: true });
} catch (err: any) {
if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message));
return next(err);
}
}
// Called by authenticated tenant owner to provision or check connector
export async function provision(req: Request, res: Response, next: NextFunction) {
try {
const tenantId = req.viewingTenantId || req.user!.tenantId;
const result = await connectorService.provisionConnector(tenantId);
return res.status(201).json(result);
} catch (err: any) {
if (err.message?.includes('no encontrado')) return next(new AppError(404, err.message));
return next(err);
}
}
export async function status(req: Request, res: Response, next: NextFunction) {
try {
const tenantId = req.viewingTenantId || req.user!.tenantId;
const result = await connectorService.getConnectorStatus(tenantId);
return res.json(result);
} catch (err) { return next(err); }
}

View File

@@ -0,0 +1,95 @@
import type { Request, Response, NextFunction } from 'express';
import { AppError } from '../middlewares/error.middleware.js';
import * as fielService from '../services/contribuyente-fiel.service.js';
import * as facturapiService from '../services/contribuyente-facturapi.service.js';
import { getContribuyenteById } from '../services/contribuyente.service.js';
// ========== FIEL ==========
export async function uploadFiel(req: Request, res: Response, next: NextFunction) {
try {
const { cerFile, keyFile, password } = req.body;
if (!cerFile || !keyFile || !password) {
return next(new AppError(400, 'cerFile, keyFile y password son requeridos'));
}
const contribuyenteId = String(req.params.id);
const contrib = await getContribuyenteById(req.tenantPool!, contribuyenteId);
if (!contrib) return next(new AppError(404, 'Contribuyente no encontrado'));
const result = await fielService.uploadFielContribuyente(req.tenantPool!, contribuyenteId, cerFile, keyFile, password);
if (!result.success) {
console.error('[FIEL Upload] Failed:', result.message);
return res.status(400).json({ message: result.message });
}
return res.json(result);
} catch (err: any) {
console.error('[FIEL Upload] Exception:', err.message || err);
return next(err);
}
}
export async function fielStatus(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = String(req.params.id);
const status = await fielService.getFielStatusContribuyente(req.tenantPool!, contribuyenteId);
return res.json(status);
} catch (err) { return next(err); }
}
export async function deleteFiel(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = String(req.params.id);
// Delete from per-contribuyente table (tenant BD)
await req.tenantPool!.query(
'UPDATE fiel_contribuyente SET is_active = false WHERE contribuyente_id = $1',
[contribuyenteId]
);
// Also try to deactivate legacy FIEL if it matches this contribuyente's RFC
const { rows } = await req.tenantPool!.query('SELECT rfc FROM contribuyentes WHERE entidad_id = $1', [contribuyenteId]);
if (rows[0]?.rfc) {
const { prisma } = await import('../config/database.js');
await prisma.fielCredential.updateMany({
where: { rfc: rows[0].rfc },
data: { isActive: false },
}).catch(() => {});
}
return res.json({ message: 'FIEL eliminada' });
} catch (err) { return next(err); }
}
// ========== FACTURAPI ==========
export async function createOrg(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = String(req.params.id);
const contrib = await getContribuyenteById(req.tenantPool!, contribuyenteId);
if (!contrib) return next(new AppError(404, 'Contribuyente no encontrado'));
const result = await facturapiService.createOrgContribuyente(req.tenantPool!, contribuyenteId, contrib.nombre);
return res.status(201).json(result);
} catch (err: any) {
if (err.message?.includes('ya tiene')) return next(new AppError(409, err.message));
return next(err);
}
}
export async function orgStatus(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = String(req.params.id);
const status = await facturapiService.getOrgStatusContribuyente(req.tenantPool!, contribuyenteId);
return res.json(status);
} catch (err) { return next(err); }
}
export async function uploadCsd(req: Request, res: Response, next: NextFunction) {
try {
const { cerFile, keyFile, password } = req.body;
if (!cerFile || !keyFile || !password) {
return next(new AppError(400, 'cerFile, keyFile y password son requeridos'));
}
const contribuyenteId = String(req.params.id);
const result = await facturapiService.uploadCsdContribuyente(req.tenantPool!, contribuyenteId, cerFile, keyFile, password);
if (!result.success) return res.status(400).json({ message: result.message });
return res.json(result);
} catch (err) { return next(err); }
}

View File

@@ -0,0 +1,148 @@
import type { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import * as contribuyenteService from '../services/contribuyente.service.js';
import { AppError } from '../middlewares/error.middleware.js';
import { getEntidadesVisibles } from '../utils/entidades-visibles.js';
import { adjustDespachoOverage } from '../services/payment/addon.service.js';
import { prisma } from '../config/database.js';
/**
* Límite duro de contribuyentes mientras el despacho está en trial gratuito.
* Una vez expira el trial (`trialEndsAt < now`) este límite deja de aplicar y
* el plan vigente toma el control.
*/
const TRIAL_MAX_CONTRIBUYENTES = 5;
/**
* Cuenta contribuyentes activos del tenant actual. Usado para ajustar el
* overage de Business Control / Enterprise tras crear o desactivar un RFC,
* y para enforce el límite del trial.
*/
async function countActiveContribuyentes(pool: import('pg').Pool): Promise<number> {
const { rows: [{ cnt }] } = await pool.query<{ cnt: string }>(
`SELECT COUNT(*)::text AS cnt FROM entidades_gestionadas
WHERE active = true AND tipo = 'CONTRIBUYENTE'`,
);
return Number(cnt) || 0;
}
const createSchema = z.object({
rfc: z.string().regex(/^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$/i, 'RFC inválido'),
razonSocial: z.string().min(2, 'Razón social requerida'),
regimenFiscal: z.string().length(3).optional(),
codigoPostal: z.string().regex(/^\d{5}$/).optional(),
domicilio: z.record(z.unknown()).optional(),
supervisorUserId: z.string().uuid().optional(),
});
const updateSchema = createSchema.partial();
export async function list(req: Request, res: Response, next: NextFunction) {
try {
const visibleIds = await getEntidadesVisibles(req.tenantPool!, req.user!.userId, req.user!.role);
const rows = await contribuyenteService.listContribuyentes(req.tenantPool!, visibleIds);
return res.json({ data: rows });
} catch (err) { return next(err); }
}
export async function getById(req: Request, res: Response, next: NextFunction) {
try {
const row = await contribuyenteService.getContribuyenteById(req.tenantPool!, String(req.params.id));
if (!row) return next(new AppError(404, 'Contribuyente no encontrado'));
return res.json(row);
} catch (err) { return next(err); }
}
export async function create(req: Request, res: Response, next: NextFunction) {
try {
const data = createSchema.parse(req.body);
// Trial gate: durante el periodo de prueba (trialEndsAt > now) el despacho
// no puede gestionar más de TRIAL_MAX_CONTRIBUYENTES RFCs activos. Cuando
// el trial expira, deja de aplicar y el límite del plan vigente toma el control.
const tenant = await prisma.tenant.findUnique({
where: { id: req.user!.tenantId },
select: { trialEndsAt: true },
});
const isTrialActive = tenant?.trialEndsAt ? tenant.trialEndsAt > new Date() : false;
if (isTrialActive) {
const activeCount = await countActiveContribuyentes(req.tenantPool!);
if (activeCount >= TRIAL_MAX_CONTRIBUYENTES) {
return next(new AppError(
403,
`Durante el periodo de prueba puedes gestionar hasta ${TRIAL_MAX_CONTRIBUYENTES} contribuyentes. Contrata un plan para agregar más.`,
));
}
}
const row = await contribuyenteService.createContribuyente(req.tenantPool!, data);
// Ajuste de overage despacho: si el tenant pasa de 100 a 101+ RFCs, crea
// el addon y devuelve paymentUrl para que el frontend redirija al usuario.
// Fail-soft: si falla el addon, el contribuyente queda creado y se loguea.
let overage: Awaited<ReturnType<typeof adjustDespachoOverage>> | null = null;
try {
const activeCount = await countActiveContribuyentes(req.tenantPool!);
overage = await adjustDespachoOverage(req.user!.tenantId, activeCount);
} catch (err: any) {
console.error('[Contribuyente] Overage adjust failed (non-blocking):', err.message || err);
}
return res.status(201).json({ ...row, overage });
} catch (err: any) {
if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message));
if (err.code === '23505') return next(new AppError(409, 'Ya existe un contribuyente con este RFC'));
return next(err);
}
}
export async function update(req: Request, res: Response, next: NextFunction) {
try {
const data = updateSchema.parse(req.body);
const row = await contribuyenteService.updateContribuyente(req.tenantPool!, String(req.params.id), data);
if (!row) return next(new AppError(404, 'Contribuyente no encontrado'));
return res.json(row);
} catch (err: any) {
if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message));
return next(err);
}
}
export async function deactivate(req: Request, res: Response, next: NextFunction) {
try {
const ok = await contribuyenteService.deactivateContribuyente(req.tenantPool!, String(req.params.id));
if (!ok) return next(new AppError(404, 'Contribuyente no encontrado'));
// Ajuste de overage despacho: si el count baja, reduce quantity del
// addon (updatePreapprovalAmount) o cancela el preapproval si pasa al límite.
let overage: Awaited<ReturnType<typeof adjustDespachoOverage>> | null = null;
try {
const activeCount = await countActiveContribuyentes(req.tenantPool!);
overage = await adjustDespachoOverage(req.user!.tenantId, activeCount);
} catch (err: any) {
console.error('[Contribuyente] Overage adjust failed (non-blocking):', err.message || err);
}
return res.json({ message: 'Contribuyente desactivado', overage });
} catch (err) { return next(err); }
}
export async function backfill(req: Request, res: Response, next: NextFunction) {
try {
const total = await contribuyenteService.backfillAllContribuyentes(req.tenantPool!);
return res.json({ message: `${total} CFDIs asignados a contribuyentes`, total });
} catch (err) { return next(err); }
}
export async function addClienteAcceso(req: Request, res: Response, next: NextFunction) {
try {
const { userId } = req.body;
if (!userId || typeof userId !== 'string') return next(new AppError(400, 'userId requerido'));
const entidadId = String(req.params.id);
await req.tenantPool!.query(
'INSERT INTO cliente_accesos (user_id, entidad_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
[userId, entidadId],
);
return res.json({ message: 'Acceso otorgado' });
} catch (err) { return next(err); }
}

View File

@@ -0,0 +1,108 @@
import type { Request, Response, NextFunction } from 'express';
import * as dashboardService from '../services/dashboard.service.js';
import { generarAlertasAutomaticas } from '../services/alertas-auto.service.js';
import { getAlertasManualesPendientes } from '../services/alertas-manuales.service.js';
import { AppError } from '../middlewares/error.middleware.js';
function getDefaultRange() {
const now = new Date();
const y = now.getFullYear();
const m = now.getMonth() + 1;
const lastDay = new Date(y, m, 0).getDate();
return {
fechaInicio: `${y}-${String(m).padStart(2, '0')}-01`,
fechaFin: `${y}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`,
año: y,
mes: m,
};
}
function parseConciliacion(req: Request): boolean {
return req.query.conciliacion === 'true' || req.query.conciliacion === '1';
}
export async function getKpis(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const defaults = getDefaultRange();
const fechaInicio = (req.query.fechaInicio as string) || defaults.fechaInicio;
const fechaFin = (req.query.fechaFin as string) || defaults.fechaFin;
const conciliacion = parseConciliacion(req);
const contribuyenteId = (req.query.contribuyenteId as string) || null;
const tenantId = req.viewingTenantId || req.user!.tenantId;
const kpis = await dashboardService.getKpis(req.tenantPool, fechaInicio, fechaFin, tenantId, conciliacion, contribuyenteId);
res.json(kpis);
} catch (error) {
next(error);
}
}
export async function getIngresosEgresos(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const año = parseInt(req.query.año as string) || new Date().getFullYear();
const conciliacion = parseConciliacion(req);
const contribuyenteId = (req.query.contribuyenteId as string) || null;
const tenantId = req.viewingTenantId || req.user!.tenantId;
const data = await dashboardService.getIngresosEgresos(req.tenantPool, año, tenantId, conciliacion, contribuyenteId);
res.json(data);
} catch (error) {
next(error);
}
}
export async function getRegimenesDelPeriodo(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const defaults = getDefaultRange();
const fechaInicio = (req.query.fechaInicio as string) || defaults.fechaInicio;
const fechaFin = (req.query.fechaFin as string) || defaults.fechaFin;
const conciliacion = parseConciliacion(req);
const contribuyenteId = (req.query.contribuyenteId as string) || null;
const tenantId = req.viewingTenantId || req.user?.tenantId;
const regimenes = await dashboardService.getRegimenesDelPeriodo(req.tenantPool, fechaInicio, fechaFin, conciliacion, contribuyenteId, tenantId);
res.json(regimenes);
} catch (error) {
next(error);
}
}
export async function getAlertas(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const limit = parseInt(req.query.limit as string) || 5;
const tenantId = req.viewingTenantId || req.user!.tenantId;
const contribuyenteId = (req.query.contribuyenteId as string) || null;
// Combinar alertas persistidas (manuales, filtered by role) + automáticas (calculadas)
const [manuales, automaticas] = await Promise.all([
getAlertasManualesPendientes(req.tenantPool, contribuyenteId, req.user!.userId, req.user!.role),
generarAlertasAutomaticas(req.tenantPool, tenantId, contribuyenteId),
]);
// Unir, ordenar por prioridad, y limitar
const prioridadOrden: Record<string, number> = { alta: 1, media: 2, baja: 3 };
const alertas = [...automaticas, ...manuales]
.sort((a, b) => (prioridadOrden[a.prioridad] || 3) - (prioridadOrden[b.prioridad] || 3))
.slice(0, limit);
res.json(alertas);
} catch (error) {
next(error);
}
}

View File

@@ -0,0 +1,67 @@
import type { Request, Response, NextFunction } from 'express';
import { prisma } from '../config/database.js';
import { AppError } from '../middlewares/error.middleware.js';
export async function getDespachoAuditLog(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) return next(new AppError(401, 'No autenticado'));
const tenantId = req.viewingTenantId || req.user.tenantId;
// Only owner or cfo can see audit log of their despacho
if (req.user.role !== 'owner' && req.user.role !== 'cfo') {
return next(new AppError(403, 'Solo el dueño puede ver el registro de accesos'));
}
const from = req.query.from
? new Date(req.query.from as string)
: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const to = req.query.to ? new Date(req.query.to as string) : new Date();
const limit = Math.min(Number(req.query.limit) || 50, 200);
const logs = await prisma.auditLog.findMany({
where: {
tenantId,
action: { startsWith: 'admin.' },
createdAt: { gte: from, lte: to },
},
orderBy: { createdAt: 'desc' },
take: limit,
});
// Enrich with admin user info
const userIds = [...new Set(logs.filter(l => l.userId).map(l => l.userId!))];
const users =
userIds.length > 0
? await prisma.user.findMany({
where: { id: { in: userIds } },
select: { id: true, nombre: true, email: true },
})
: [];
const userMap = new Map(users.map(u => [u.id, u]));
const enriched = logs.map(log => ({
id: log.id,
action: log.action,
timestamp: log.createdAt.toISOString(),
admin: log.userId
? {
nombre: userMap.get(log.userId)?.nombre ?? 'Desconocido',
email: userMap.get(log.userId)?.email ?? '',
}
: null,
motivo: (log.metadata as any)?.motivo ?? null,
ip: (log.metadata as any)?.ip ?? null,
details: log.metadata,
}));
return res.json({
data: enriched,
total: enriched.length,
from: from.toISOString(),
to: to.toISOString(),
});
} catch (err) {
return next(err);
}
}

View File

@@ -0,0 +1,67 @@
import type { Request, Response, NextFunction } from 'express';
import { AppError } from '../middlewares/error.middleware.js';
import * as despachoService from '../services/despacho-stats.service.js';
function effectiveTenantId(req: Request): string {
return req.viewingTenantId || req.user!.tenantId;
}
const ROLES_OWNER = new Set(['owner', 'cfo']);
const ROLES_SUPERVISORY = new Set(['owner', 'cfo', 'supervisor']);
const ROLES_ASIGNADOS = new Set(['owner', 'cfo', 'supervisor', 'auxiliar']);
export async function getContribuyentesStats(req: Request, res: Response, next: NextFunction) {
try {
if (!ROLES_OWNER.has(req.user!.role)) {
throw new AppError(403, 'Solo owner puede ver estas métricas');
}
const tenantId = effectiveTenantId(req);
const año = req.query.año ? parseInt(String(req.query.año), 10) : undefined;
const mes = req.query.mes ? parseInt(String(req.query.mes), 10) : undefined;
const stats = await despachoService.getContribuyentesStats(req.tenantPool!, tenantId, año, mes);
res.json(stats);
} catch (error) {
next(error);
}
}
export async function getMisAsignados(req: Request, res: Response, next: NextFunction) {
try {
if (!ROLES_ASIGNADOS.has(req.user!.role)) {
throw new AppError(403, 'No tienes contribuyentes asignados');
}
const año = req.query.año ? parseInt(String(req.query.año), 10) : undefined;
const mes = req.query.mes ? parseInt(String(req.query.mes), 10) : undefined;
const data = await despachoService.getMisAsignados(
req.tenantPool!,
req.user!.userId,
req.user!.role,
año,
mes,
);
res.json(data);
} catch (error) {
next(error);
}
}
export async function getEquipoStats(req: Request, res: Response, next: NextFunction) {
try {
if (!ROLES_SUPERVISORY.has(req.user!.role)) {
throw new AppError(403, 'Solo owner y supervisor pueden ver al equipo');
}
const año = req.query.año ? parseInt(String(req.query.año), 10) : undefined;
const mes = req.query.mes ? parseInt(String(req.query.mes), 10) : undefined;
const data = await despachoService.getEquipoStats(
req.tenantPool!,
req.user!.userId,
req.user!.role,
effectiveTenantId(req),
año,
mes,
);
res.json(data);
} catch (error) {
next(error);
}
}

View File

@@ -0,0 +1,95 @@
import type { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { signupDespacho } from '../services/despacho.service.js';
import { AppError } from '../middlewares/error.middleware.js';
import { prisma } from '../config/database.js';
const signupSchema = z.object({
despacho: z.object({
nombre: z.string().min(2, 'Nombre del despacho requerido'),
regimenFiscal: z.string().optional(),
codigoPostal: z.string().regex(/^\d{5}$/, 'Código postal inválido').optional(),
verticalProfile: z.enum(['CONTABLE', 'JURIDICO', 'ARQUITECTURA']),
plan: z.enum(['trial', 'business_control', 'business_cloud']).optional().default('trial'),
}),
owner: z.object({
nombre: z.string().min(2, 'Nombre del owner requerido'),
email: z.string().email('Email inválido'),
password: z.string().min(10, 'La contraseña debe tener al menos 10 caracteres'),
}),
});
export async function getMyPlan(req: Request, res: Response, next: NextFunction) {
try {
const tenantId = req.user!.tenantId;
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { dbMode: true, trialEndsAt: true, verticalProfile: true, plan: true },
});
if (!tenant) {
return next(new AppError(404, 'Tenant no encontrado'));
}
const now = new Date();
const isTrialActive = tenant.trialEndsAt ? tenant.trialEndsAt > now : false;
// Mapea según trialEndsAt + tenant.plan (no dbMode). dbMode era proxy
// antes de la introducción de Mi Empresa / Mi Empresa+ — para esos
// planes, dbMode también es MANAGED y reportar `business_cloud` daba
// mapeo equivocado. tenant.plan es la fuente de verdad post-migración
// 20260426073942 (que añadió mi_empresa y mi_empresa_plus al enum).
let currentPlan: string;
if (isTrialActive) {
currentPlan = 'trial';
} else {
currentPlan = String(tenant.plan);
}
// Estado de suscripción activa (si hay) — alimenta la UI con el monto
// recurrente actual, fecha de próxima renovación y si el primer pago
// (cuando aplica dualidad firstYear) ya fue completado.
const subscription = await prisma.subscription.findFirst({
where: { tenantId, status: { in: ['authorized', 'pending', 'paused', 'trial'] } },
orderBy: { createdAt: 'desc' },
select: {
status: true, amount: true, plan: true,
currentPeriodStart: true, currentPeriodEnd: true,
},
});
return res.json({
plan: currentPlan,
dbMode: tenant.dbMode,
trialEndsAt: tenant.trialEndsAt?.toISOString() ?? null,
isTrialActive,
subscription: subscription
? {
status: subscription.status,
plan: subscription.plan,
amount: Number(subscription.amount),
currentPeriodStart: subscription.currentPeriodStart?.toISOString() ?? null,
currentPeriodEnd: subscription.currentPeriodEnd?.toISOString() ?? null,
}
: null,
});
} catch (error) {
return next(error);
}
}
export async function signup(req: Request, res: Response, next: NextFunction) {
try {
const data = signupSchema.parse(req.body);
const result = await signupDespacho(data);
return res.status(201).json(result);
} catch (error: any) {
if (error instanceof z.ZodError) {
return next(new AppError(400, error.errors[0].message));
}
if (error.message?.includes('Ya existe')) {
return next(new AppError(409, error.message));
}
return next(error);
}
}

View File

@@ -0,0 +1,333 @@
import type { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { getOpiniones, getOpinionPdf, consultarOpinion, consultarOpinionContribuyente } from '../services/opinion-cumplimiento.service.js';
import * as declaracionesService from '../services/declaraciones.service.js';
import * as constanciaService from '../services/constancia.service.js';
import * as extrasService from '../services/documentos-extras.service.js';
import { notifyDocumentoSubido } from '../services/notify-upload.service.js';
import { AppError } from '../middlewares/error.middleware.js';
const MESES = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
function effectiveTenantId(req: Request): string {
return req.viewingTenantId || req.user!.tenantId;
}
export async function listarOpiniones(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = req.query.contribuyenteId as string | undefined;
let rfc: string | undefined;
if (contribuyenteId) {
const { rows } = await req.tenantPool!.query(
'SELECT rfc FROM contribuyentes WHERE entidad_id = $1',
[contribuyenteId],
);
rfc = rows[0]?.rfc;
}
const opiniones = await getOpiniones(req.tenantPool!, 5, rfc);
res.json(opiniones);
} catch (error) {
next(error);
}
}
export async function descargarPdf(req: Request, res: Response, next: NextFunction) {
try {
const id = parseInt(String(req.params.id));
if (isNaN(id)) return res.status(400).json({ error: 'ID inválido' });
const pdf = await getOpinionPdf(req.tenantPool!, id);
if (!pdf) return res.status(404).json({ error: 'Opinión no encontrada' });
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="opinion_cumplimiento_${id}.pdf"`);
res.send(pdf);
} catch (error) {
next(error);
}
}
export async function consultarManual(req: Request, res: Response, next: NextFunction) {
try {
const tenantId = effectiveTenantId(req);
const contribuyenteId = req.query.contribuyenteId as string | undefined;
let opinion;
if (contribuyenteId) {
opinion = await consultarOpinionContribuyente(req.tenantPool!, contribuyenteId);
} else {
opinion = await consultarOpinion(tenantId);
}
res.json(opinion);
} catch (error: any) {
if (error.message?.includes('FIEL')) {
return res.status(400).json({ error: error.message });
}
next(error);
}
}
// ============================================================================
// Declaraciones provisionales
// ============================================================================
const ROLES_UPLOAD = ['owner', 'cfo', 'contador', 'auxiliar'];
function canUpload(req: Request): boolean {
return ROLES_UPLOAD.includes(req.user!.role);
}
const createDeclaracionSchema = z.object({
año: z.number().int().min(2020).max(2100),
mes: z.number().int().min(1).max(12),
tipo: z.enum(['normal', 'complementaria']),
periodicidad: z.enum(['mensual', 'bimestral', 'trimestral', 'semestral', 'anual']).optional(),
impuestos: z.array(z.enum(['IVA', 'ISR', 'IEPS', 'SUELDOS', 'DIOT', 'OTRO'])).min(1, 'Selecciona al menos un impuesto'),
montoPago: z.number().min(0).optional(),
pdfBase64: z.string().min(100),
pdfFilename: z.string().min(1).max(255),
ligaPagoBase64: z.string().min(100).optional(),
ligaPagoFilename: z.string().min(1).max(255).optional(),
notas: z.string().max(2000).optional(),
}).refine(
d => !d.ligaPagoBase64 || !!d.ligaPagoFilename,
{ message: 'Si incluyes liga de pago, también debes mandar su nombre de archivo', path: ['ligaPagoFilename'] },
);
export async function listarDeclaraciones(req: Request, res: Response, next: NextFunction) {
try {
const fechaDesde = req.query.fechaDesde as string | undefined;
const fechaHasta = req.query.fechaHasta as string | undefined;
const contribuyenteId = typeof req.query.contribuyenteId === 'string' && req.query.contribuyenteId
? req.query.contribuyenteId
: null;
const data = await declaracionesService.listDeclaraciones(req.tenantPool!, fechaDesde, fechaHasta, contribuyenteId);
res.json(data);
} catch (error) { next(error); }
}
export async function crearDeclaracion(req: Request, res: Response, next: NextFunction) {
try {
if (!canUpload(req)) return res.status(403).json({ message: 'No tienes permiso para subir declaraciones' });
const data = createDeclaracionSchema.parse(req.body);
const contribuyenteId = req.body.contribuyenteId as string | undefined;
const result = await declaracionesService.createDeclaracion(req.tenantPool!, {
...data,
creadoPor: req.user!.email,
creadoPorUserId: req.user!.userId,
contribuyenteId,
});
// Notificación fire-and-forget a owners del despacho + supervisor del RFC.
// No bloquea la respuesta ni falla la creación si SMTP no está configurado.
notifyDocumentoSubido({
pool: req.tenantPool!,
tenantId: req.user!.tenantId,
contribuyenteId: contribuyenteId ?? null,
subidoPor: req.user!.email,
kind: 'declaracion',
declaracion: {
periodo: `${MESES[data.mes - 1]} ${data.año}`,
tipo: data.tipo,
impuestos: data.impuestos as string[],
montoPago: data.montoPago ?? null,
},
}).catch((err: any) => console.error('[notifyDocumentoSubido declaracion]', err?.message || err));
res.status(201).json(result);
} catch (error: any) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
if (error?.message?.includes('Ya existe') || error?.message?.includes('normal')) {
return next(new AppError(400, error.message));
}
next(error);
}
}
const comprobantePagoSchema = z.object({
pdfBase64: z.string().min(100),
pdfFilename: z.string().min(1).max(255),
});
export async function subirComprobantePago(req: Request, res: Response, next: NextFunction) {
try {
if (!canUpload(req)) return res.status(403).json({ message: 'No tienes permiso para subir comprobantes' });
const id = parseInt(String(req.params.id));
if (isNaN(id)) return next(new AppError(400, 'id inválido'));
const data = comprobantePagoSchema.parse(req.body);
const result = await declaracionesService.uploadComprobantePago(req.tenantPool!, id, {
...data,
uploadedByUserId: req.user!.userId,
});
res.json(result);
} catch (error: any) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
if (error?.message?.includes('no encontrada')) {
return next(new AppError(404, error.message));
}
next(error);
}
}
export async function descargarDeclaracionPdf(req: Request, res: Response, next: NextFunction) {
try {
const id = parseInt(String(req.params.id));
if (isNaN(id)) return next(new AppError(400, 'id inválido'));
const v = req.params.variant;
const variant: 'declaracion' | 'liga' | 'pago' = v === 'pago' ? 'pago' : v === 'liga' ? 'liga' : 'declaracion';
const pdf = await declaracionesService.getDeclaracionPdf(req.tenantPool!, id, variant);
if (!pdf) return res.status(404).json({ message: 'PDF no encontrado' });
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${pdf.filename}"`);
res.send(pdf.buffer);
} catch (error) { next(error); }
}
// ============================================================================
// Constancia de Situación Fiscal
// ============================================================================
export async function listarConstancias(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = req.query.contribuyenteId as string | undefined;
let rfc: string | undefined;
if (contribuyenteId) {
const { rows } = await req.tenantPool!.query(
'SELECT rfc FROM contribuyentes WHERE entidad_id = $1',
[contribuyenteId],
);
rfc = rows[0]?.rfc;
}
const data = await constanciaService.listConstancias(req.tenantPool!, 12, rfc);
res.json(data);
} catch (error) { next(error); }
}
export async function descargarConstanciaPdf(req: Request, res: Response, next: NextFunction) {
try {
const id = parseInt(String(req.params.id));
if (isNaN(id)) return next(new AppError(400, 'id inválido'));
const pdf = await constanciaService.getConstanciaPdf(req.tenantPool!, id);
if (!pdf) return res.status(404).json({ message: 'Constancia no encontrada' });
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="constancia_${id}.pdf"`);
res.send(pdf);
} catch (error) { next(error); }
}
export async function consultarConstanciaManual(req: Request, res: Response, next: NextFunction) {
try {
const tenantId = effectiveTenantId(req);
const contribuyenteId = req.query.contribuyenteId as string | undefined;
let constancia;
if (contribuyenteId) {
constancia = await constanciaService.consultarConstanciaContribuyente(req.tenantPool!, contribuyenteId);
} else {
constancia = await constanciaService.consultarConstancia(tenantId);
}
res.json(constancia);
} catch (error: any) {
if (error.message?.includes('FIEL')) return res.status(400).json({ error: error.message });
next(error);
}
}
export async function eliminarDeclaracion(req: Request, res: Response, next: NextFunction) {
try {
if (!canUpload(req)) return res.status(403).json({ message: 'No tienes permiso para eliminar declaraciones' });
const id = parseInt(String(req.params.id));
if (isNaN(id)) return next(new AppError(400, 'id inválido'));
await declaracionesService.deleteDeclaracion(req.tenantPool!, id);
res.status(204).send();
} catch (error: any) {
if (error?.message?.includes('no encontrada')) {
return next(new AppError(404, error.message));
}
next(error);
}
}
// ============================================================================
// Documentos Extras — PDFs libres (acuses, contratos, poderes, estados, etc.)
// ============================================================================
const createExtraSchema = z.object({
nombre: z.string().min(1, 'Nombre requerido').max(255),
descripcion: z.string().max(2000).optional(),
categoria: z.string().max(100).optional(),
pdfBase64: z.string().min(100, 'PDF requerido'),
pdfFilename: z.string().min(1).max(255),
});
export async function listarExtras(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = req.query.contribuyenteId as string | undefined;
const categoria = req.query.categoria as string | undefined;
const data = await extrasService.listExtras(req.tenantPool!, contribuyenteId, categoria);
res.json(data);
} catch (error) { next(error); }
}
export async function crearExtra(req: Request, res: Response, next: NextFunction) {
try {
if (!canUpload(req)) return res.status(403).json({ message: 'No tienes permiso para subir documentos' });
const data = createExtraSchema.parse(req.body);
const contribuyenteId = req.body.contribuyenteId as string | undefined;
const result = await extrasService.createExtra(req.tenantPool!, {
...data,
contribuyenteId: contribuyenteId ?? null,
subidoPor: req.user!.email,
});
// Notificación fire-and-forget a owners del despacho + supervisor del RFC.
notifyDocumentoSubido({
pool: req.tenantPool!,
tenantId: req.user!.tenantId,
contribuyenteId: contribuyenteId ?? null,
subidoPor: req.user!.email,
kind: 'extra',
extra: {
nombre: data.nombre,
descripcion: data.descripcion ?? null,
categoria: data.categoria ?? null,
},
}).catch((err: any) => console.error('[notifyDocumentoSubido extra]', err?.message || err));
res.status(201).json(result);
} catch (error: any) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error);
}
}
export async function descargarExtraPdf(req: Request, res: Response, next: NextFunction) {
try {
const id = parseInt(String(req.params.id));
if (isNaN(id)) return next(new AppError(400, 'id inválido'));
const pdf = await extrasService.getExtraPdf(req.tenantPool!, id);
if (!pdf) return next(new AppError(404, 'Documento no encontrado'));
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${pdf.filename}"`);
res.send(pdf.buffer);
} catch (error) { next(error); }
}
export async function eliminarExtra(req: Request, res: Response, next: NextFunction) {
try {
if (!canUpload(req)) return res.status(403).json({ message: 'No tienes permiso para eliminar documentos' });
const id = parseInt(String(req.params.id));
if (isNaN(id)) return next(new AppError(400, 'id inválido'));
const ok = await extrasService.deleteExtra(req.tenantPool!, id);
if (!ok) return next(new AppError(404, 'Documento no encontrado'));
res.status(204).send();
} catch (error) { next(error); }
}
export async function listarCategoriasExtras(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = req.query.contribuyenteId as string | undefined;
const data = await extrasService.listCategorias(req.tenantPool!, contribuyenteId);
res.json(data);
} catch (error) { next(error); }
}

View File

@@ -0,0 +1,42 @@
import type { Request, Response, NextFunction } from 'express';
import * as exportService from '../services/export.service.js';
export async function exportCfdis(req: Request, res: Response, next: NextFunction) {
try {
const { tipo, estado, fechaInicio, fechaFin } = req.query;
const buffer = await exportService.exportCfdisToExcel(req.tenantPool!, {
tipo: tipo as string,
estado: estado as string,
fechaInicio: fechaInicio as string,
fechaFin: fechaFin as string,
});
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', `attachment; filename=cfdis-${Date.now()}.xlsx`);
res.send(buffer);
} catch (error) {
next(error);
}
}
export async function exportReporte(req: Request, res: Response, next: NextFunction) {
try {
const { tipo, fechaInicio, fechaFin } = req.query;
const now = new Date();
const inicio = (fechaInicio as string) || `${now.getFullYear()}-01-01`;
const fin = (fechaFin as string) || now.toISOString().split('T')[0];
const buffer = await exportService.exportReporteToExcel(
req.tenantPool!,
tipo as 'estado-resultados' | 'flujo-efectivo',
inicio,
fin
);
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', `attachment; filename=${tipo}-${Date.now()}.xlsx`);
res.send(buffer);
} catch (error) {
next(error);
}
}

View File

@@ -0,0 +1,639 @@
import type { Request, Response, NextFunction } from 'express';
import type { Pool } from 'pg';
import { z } from 'zod';
import * as facturapiService from '../services/facturapi.service.js';
import {
createInvoiceContribuyente,
cancelInvoiceContribuyente,
downloadPdfContribuyente,
downloadXmlContribuyente,
sendInvoiceByEmailContribuyente,
} from '../services/contribuyente-facturapi.service.js';
import { parseXml } from '../services/sat/sat-parser.service.js';
import * as tenantsService from '../services/tenants.service.js';
import { prisma } from '../config/database.js';
import { AppError } from '../middlewares/error.middleware.js';
import { hasPlatformRole } from '../utils/platform-admin.js';
import { auditFromReq } from '../utils/audit.js';
function effectiveTenantId(req: Request): string {
return req.viewingTenantId || req.user!.tenantId;
}
/**
* Detecta si un mensaje de error del SAT (propagado por Facturapi) indica
* que el CSD aún no está en la Lista de Contribuyentes Obligados (LCO).
* El SAT tarda 24-72h en propagar un CSD nuevo; durante esa ventana todo
* intento de emisión falla. Cuando se detecta este patrón se marca la
* org con `last_lco_rejection_at` para que el frontend muestre un banner.
*/
function isLcoRejection(errorMessage: string): boolean {
if (!errorMessage) return false;
const msg = errorMessage.toLowerCase();
return (
/no se encontr.*rfc.*lco/.test(msg) ||
/rfc.*no.*registrado.*lco/.test(msg) ||
/lista.*contribuyentes.*obligados/.test(msg) ||
/csd.*no.*registrad/.test(msg) ||
msg.includes('lco')
);
}
/**
* Registra el timestamp del rechazo LCO en la fila correspondiente de
* `facturapi_orgs`. Fire-and-forget: un fallo aquí no bloquea la
* propagación del error al frontend.
*/
async function markLcoRejection(
pool: import('pg').Pool,
contribuyenteId: string | undefined,
): Promise<void> {
try {
if (contribuyenteId) {
await pool.query(
`UPDATE facturapi_orgs SET last_lco_rejection_at = NOW() WHERE contribuyente_id = $1`,
[contribuyenteId],
);
}
// Nota: Horux360 single-tenant usaría `tenants.facturapi_org_id` en
// BD central; en el fork multi-contribuyente solo marcamos la fila
// por-contribuyente. Si el user emite desde el org del tenant (sin
// contribuyenteId), el banner no aplicaría aquí.
} catch (e: any) {
console.error('[facturacion.markLcoRejection] falló UPDATE:', e?.message || e);
}
}
// ── Organización ──
export async function getOrgStatus(req: Request, res: Response, next: NextFunction) {
try {
const status = await facturapiService.getOrganizationStatus(effectiveTenantId(req));
res.json(status);
} catch (error) { next(error); }
}
export async function createOrg(req: Request, res: Response, next: NextFunction) {
try {
const result = await facturapiService.createOrganization(effectiveTenantId(req));
res.status(201).json(result);
} catch (error) { next(error); }
}
// ── CSD ──
export async function uploadCsd(req: Request, res: Response, next: NextFunction) {
try {
const { cerFile, keyFile, password } = req.body;
if (!cerFile || !keyFile || !password) {
return res.status(400).json({ message: 'cerFile, keyFile y password son requeridos' });
}
const result = await facturapiService.uploadCsd(effectiveTenantId(req), cerFile, keyFile, password);
if (!result.success) return res.status(400).json({ message: result.message });
res.json(result);
} catch (error) { next(error); }
}
// ── Emisión ──
export async function emitir(req: Request, res: Response, next: NextFunction) {
try {
const tenantId = effectiveTenantId(req);
const contribuyenteId = req.body.contribuyenteId as string | undefined;
// Reservar timbre — si falla emisión en Facturapi, revertimos abajo
const consumedTimbre = await facturapiService.consumeTimbre(tenantId);
// Emitir factura en Facturapi
// Si hay contribuyenteId, usar la org Facturapi del contribuyente (tenant BD).
// Si no, usar la org del tenant (BD central).
let invoice;
try {
if (contribuyenteId) {
invoice = await createInvoiceContribuyente(req.tenantPool!, contribuyenteId, req.body);
} else {
invoice = await facturapiService.createInvoice(tenantId, req.body);
}
} catch (err: any) {
// SAT nunca selló → revertir el timbre reservado (fire-and-forget; no bloquear la respuesta
// de error si el refund falla, solo loggear la inconsistencia)
facturapiService.refundTimbre(tenantId, consumedTimbre).catch(refundErr => {
console.error('[facturacion.emitir] Falló refund de timbre tras rechazo Facturapi:', {
tenantId,
consumedTimbre,
refundError: refundErr?.message || String(refundErr),
});
});
// Loggea el payload que causó el rechazo para diagnóstico server-side
console.error('[facturacion.emitir] Rechazo al crear factura:', {
tenantId,
contribuyenteId: contribuyenteId || null,
type: req.body?.type,
items: req.body?.items?.map((it: any) => ({
description: it.description,
taxes: it.taxes,
})),
error: err?.message || String(err),
});
// Detectar rechazo por CSD aún no propagado a la LCO y marcar la org
// para que el frontend muestre banner informativo durante 24h.
if (isLcoRejection(err?.message || '')) {
await markLcoRejection(req.tenantPool!, contribuyenteId);
}
// Propaga el mensaje real (Facturapi suele explicar la validación)
throw new AppError(400, err?.message || 'Error al emitir factura');
}
// Guardar en tabla cfdis del tenant.
// El response de `invoices.create` de Facturapi NO incluye `issuer`/`subtotal`/`taxes`
// como campos top-level (usa `issuer_info` y los impuestos viven dentro de `items[*].product.taxes`).
// La forma más fiable y consistente con el sync SAT es descargar el XML timbrado y
// reutilizar el mismo parser que ya procesa los CFDIs descargados del SAT.
const pool = req.tenantPool!;
const xmlBuffer = contribuyenteId
? await downloadXmlContribuyente(pool, contribuyenteId, invoice.id)
: await facturapiService.downloadXml(tenantId, invoice.id);
const xmlString = xmlBuffer.toString('utf-8');
const parsed = parseXml(xmlString, 'emitidos');
if (!parsed) {
throw new AppError(500, `Factura ${invoice.uuid} emitida en Facturapi pero el XML no pudo parsearse`);
}
const fecha = parsed.fechaEmision;
const year = String(fecha.getFullYear());
const month = String(fecha.getMonth() + 1).padStart(2, '0');
// Upsert RFCs desde datos del XML (fuente autoritativa — igual al sync SAT)
const { rows: [emisorRow] } = await pool.query(
`INSERT INTO rfcs (rfc, razon_social, regimen_fiscal) VALUES ($1, $2, $3)
ON CONFLICT (rfc) DO UPDATE SET
razon_social = COALESCE(NULLIF($2, ''), rfcs.razon_social),
regimen_fiscal = CASE WHEN $3 IS NOT NULL AND $3 != '' THEN $3 ELSE rfcs.regimen_fiscal END
RETURNING id`,
[parsed.rfcEmisor, parsed.nombreEmisor || null, parsed.regimenFiscalEmisor || null],
);
const { rows: [receptorRow] } = await pool.query(
`INSERT INTO rfcs (rfc, razon_social, regimen_fiscal, codigo_postal) VALUES ($1, $2, $3, $4)
ON CONFLICT (rfc) DO UPDATE SET
razon_social = COALESCE(NULLIF($2, ''), rfcs.razon_social),
regimen_fiscal = CASE WHEN $3 IS NOT NULL AND $3 != '' THEN $3 ELSE rfcs.regimen_fiscal END,
codigo_postal = CASE WHEN $4 IS NOT NULL AND $4 != '' THEN $4 ELSE rfcs.codigo_postal END
RETURNING id`,
[parsed.rfcReceptor, parsed.nombreReceptor || null, parsed.regimenFiscalReceptor || null, req.body.customer?.zip || null],
);
await pool.query(`
INSERT INTO cfdis (
year, month, type, uuid, serie, folio, status, fecha_emision, fecha_cert_sat,
rfc_emisor_id, rfc_emisor, nombre_emisor, regimen_fiscal_emisor,
rfc_receptor_id, rfc_receptor, nombre_receptor, regimen_fiscal_receptor,
subtotal, subtotal_mxn, total, total_mxn,
moneda, tipo_comprobante, metodo_pago, forma_pago, uso_cfdi,
iva_traslado, iva_traslado_mxn,
iva_retencion, iva_retencion_mxn,
source, facturapi_id,
contribuyente_id, xml_original
) VALUES (
$1, $2, 'EMITIDO', $3, $4, $5, 'Vigente', $6, $7,
$8, $9, $10, $11,
$12, $13, $14, $15,
$16, $16, $17, $17,
$18, $19, $20, $21, $22,
$23, $23,
$24, $24,
'facturapi', $25,
$26, $27
)
`, [
year, month, parsed.uuid, parsed.serie, parsed.folio, fecha, parsed.fechaCertSat,
emisorRow.id, parsed.rfcEmisor, parsed.nombreEmisor, parsed.regimenFiscalEmisor,
receptorRow.id, parsed.rfcReceptor, parsed.nombreReceptor, parsed.regimenFiscalReceptor,
parsed.subtotal, parsed.total,
parsed.moneda, parsed.tipoComprobante, parsed.metodoPago, parsed.formaPago, parsed.usoCfdi,
parsed.ivaTraslado,
parsed.ivaRetencion,
invoice.id,
contribuyenteId ?? null, xmlString,
]);
// Enviar por email si el receptor tiene email — ruteado a la org correcta
const customerEmail = req.body.customer?.email;
if (customerEmail) {
const sendPromise = contribuyenteId
? sendInvoiceByEmailContribuyente(req.tenantPool!, contribuyenteId, invoice.id, customerEmail)
: facturapiService.sendInvoiceByEmail(tenantId, invoice.id, customerEmail);
sendPromise.catch(err => console.error('[Facturapi] Error enviando email:', err.message));
}
res.status(201).json({
id: invoice.id,
uuid: invoice.uuid,
total: invoice.total,
status: invoice.status,
});
} catch (error: any) {
// Los errores de emisión ya hacen refund dentro del inner catch.
// Aquí solo propagamos — incluye errores del INSERT post-emisión (CFDI ya sellado,
// no refund) y errores de validación de timbre (ocurrieron antes del consume).
next(error);
}
}
// Estado LCO: si hubo un rechazo del SAT por CSD no propagado en las últimas 24h,
// el frontend muestra un banner informativo en la pantalla de emisión.
export async function getLcoStatus(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = req.query.contribuyenteId as string | undefined;
if (!contribuyenteId) {
return res.json({ hasRecentLcoRejection: false, rejectedAt: null });
}
const { rows } = await req.tenantPool!.query<{ last_lco_rejection_at: Date | null }>(
`SELECT last_lco_rejection_at FROM facturapi_orgs WHERE contribuyente_id = $1 AND active = true`,
[contribuyenteId],
);
const rejectedAt = rows[0]?.last_lco_rejection_at || null;
const hasRecentLcoRejection =
rejectedAt !== null && Date.now() - new Date(rejectedAt).getTime() < 24 * 60 * 60 * 1000;
res.json({ hasRecentLcoRejection, rejectedAt });
} catch (error) {
next(error);
}
}
// ── Cancelación ──
export async function cancelar(req: Request, res: Response, next: NextFunction) {
try {
const tenantId = effectiveTenantId(req);
const { uuid } = req.params;
const { motive, substitution } = req.body;
const pool = req.tenantPool!;
const { rows } = await pool.query(
`SELECT facturapi_id, contribuyente_id FROM cfdis WHERE uuid = $1 AND source = 'facturapi'`,
[uuid]
);
if (rows.length === 0 || !rows[0].facturapi_id) {
return res.status(404).json({ message: 'CFDI no encontrado o no fue emitido por Facturapi' });
}
const facturapiId = rows[0].facturapi_id;
const cfdiContribuyenteId = rows[0].contribuyente_id as string | null;
const result = cfdiContribuyenteId
? await cancelInvoiceContribuyente(pool, cfdiContribuyenteId, facturapiId, motive || '02', substitution)
: await facturapiService.cancelInvoice(tenantId, facturapiId, motive || '02', substitution);
// Capturamos la fecha del CFDI antes del UPDATE para saber qué mes marcar
// como invalidado (la cancelación afecta las métricas del mes del CFDI,
// no del mes actual).
const { rows: fechas } = await pool.query<{ fecha_emision: Date; fecha_pago_p: Date | null; tipo_comprobante: string }>(
`SELECT fecha_emision, fecha_pago_p, tipo_comprobante FROM cfdis WHERE uuid = $1`,
[uuid],
);
await pool.query(
`UPDATE cfdis SET status = 'Cancelado', fecha_cancelacion = NOW(), actualizado_en = NOW() WHERE uuid = $1`,
[uuid]
);
// Invalidar métricas del mes afectado (usa fecha_pago_p para P, fecha_emision para el resto)
if (cfdiContribuyenteId && fechas[0]) {
const f = fechas[0];
const fechaContable = f.tipo_comprobante === 'P' && f.fecha_pago_p ? f.fecha_pago_p : f.fecha_emision;
const { markForInvalidation } = await import('../services/metricas.service.js');
await markForInvalidation(
pool,
cfdiContribuyenteId,
fechaContable.getFullYear(),
fechaContable.getMonth() + 1,
'CFDI_CANCEL',
).catch(err => console.warn('[Cancelar] markForInvalidation falló:', err?.message || err));
}
res.json({ message: 'CFDI cancelado', result });
} catch (error) { next(error); }
}
// ── Descargas ──
async function resolveCfdiContribuyenteId(
pool: Pool,
facturapiId: string,
): Promise<string | null> {
const { rows } = await pool.query<{ contribuyente_id: string | null }>(
`SELECT contribuyente_id FROM cfdis WHERE facturapi_id = $1 LIMIT 1`,
[facturapiId],
);
return rows[0]?.contribuyente_id ?? null;
}
export async function downloadPdf(req: Request, res: Response, next: NextFunction) {
try {
const id = String(req.params.id);
const pool = req.tenantPool!;
const cfdiContribuyenteId = await resolveCfdiContribuyenteId(pool, id);
const buffer = cfdiContribuyenteId
? await downloadPdfContribuyente(pool, cfdiContribuyenteId, id)
: await facturapiService.downloadPdf(effectiveTenantId(req), id);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename=factura-${id}.pdf`);
res.send(buffer);
} catch (error) { next(error); }
}
export async function downloadXml(req: Request, res: Response, next: NextFunction) {
try {
const id = String(req.params.id);
const pool = req.tenantPool!;
const cfdiContribuyenteId = await resolveCfdiContribuyenteId(pool, id);
const buffer = cfdiContribuyenteId
? await downloadXmlContribuyente(pool, cfdiContribuyenteId, id)
: await facturapiService.downloadXml(effectiveTenantId(req), id);
res.setHeader('Content-Type', 'application/xml');
res.setHeader('Content-Disposition', `attachment; filename=factura-${id}.xml`);
res.send(buffer);
} catch (error) { next(error); }
}
// ── Timbres ──
export async function getTimbres(req: Request, res: Response, next: NextFunction) {
try {
const status = await facturapiService.getTimbreStatus(effectiveTenantId(req));
res.json(status);
} catch (error) { next(error); }
}
// ── Personalización (logo, color) ──
export async function getCustomization(req: Request, res: Response, next: NextFunction) {
try {
const data = await facturapiService.getCustomization(effectiveTenantId(req));
res.json(data || {});
} catch (error) { next(error); }
}
export async function uploadLogo(req: Request, res: Response, next: NextFunction) {
try {
const { logo } = req.body; // base64
if (!logo) return res.status(400).json({ message: 'Logo es requerido (base64)' });
const result = await facturapiService.uploadLogo(effectiveTenantId(req), logo);
if (!result.success) return res.status(400).json({ message: result.message });
res.json(result);
} catch (error) { next(error); }
}
export async function updateColor(req: Request, res: Response, next: NextFunction) {
try {
const { color } = req.body;
if (!color) return res.status(400).json({ message: 'Color es requerido' });
const result = await facturapiService.updateColor(effectiveTenantId(req), color);
if (!result.success) return res.status(400).json({ message: result.message });
res.json(result);
} catch (error) { next(error); }
}
// ── Datos fiscales del tenant ──
export async function getDatosFiscales(req: Request, res: Response, next: NextFunction) {
try {
const data = await tenantsService.getDatosFiscales(effectiveTenantId(req));
res.json(data || {});
} catch (error) { next(error); }
}
export async function updateDatosFiscales(req: Request, res: Response, next: NextFunction) {
try {
if (req.user!.role !== 'owner') {
return res.status(403).json({ message: 'Solo el dueño puede actualizar datos fiscales' });
}
const data = await tenantsService.updateDatosFiscales(effectiveTenantId(req), req.body);
res.json(data);
} catch (error) { next(error); }
}
// ── Búsqueda de conceptos previos ──
export async function searchConceptos(req: Request, res: Response, next: NextFunction) {
try {
const q = (req.query.q as string || '').trim();
const tipo = (req.query.tipo as string || 'todos'); // emitidos, recibidos, todos
const contribuyenteId = (req.query.contribuyenteId as string || '').replace(/[^a-f0-9-]/gi, '');
const pool = req.tenantPool!;
let whereType = '';
if (tipo === 'emitidos') {
whereType = `AND c.type = 'EMITIDO'`;
} else if (tipo === 'recibidos') {
whereType = `AND c.type = 'RECIBIDO' AND c.uso_cfdi = 'G01'`;
} else {
whereType = `AND (c.type = 'EMITIDO' OR (c.type = 'RECIBIDO' AND c.uso_cfdi = 'G01'))`;
}
const whereContrib = contribuyenteId ? `AND c.contribuyente_id = '${contribuyenteId}'` : '';
let whereSearch = '';
const params: any[] = [];
if (q.length >= 2) {
params.push(`%${q}%`);
whereSearch = `AND (cc.descripcion ILIKE $1 OR cc.clave_prod_serv ILIKE $1)`;
}
const { rows } = await pool.query(`
SELECT DISTINCT ON (cc.clave_prod_serv, cc.descripcion)
cc.clave_prod_serv as "claveProdServ",
cc.descripcion,
cc.clave_unidad as "claveUnidad",
cc.unidad,
cc.valor_unitario_mxn as "valorUnitario",
cc.importe_mxn as "importe",
cc.iva_traslado_mxn as "ivaTraslado",
cc.isr_retencion_mxn as "isrRetencion",
cc.iva_retencion_mxn as "ivaRetencion",
c.type as "tipoCfdi",
c.rfc_emisor as "rfcEmisor",
c.nombre_emisor as "nombreEmisor",
c.rfc_receptor as "rfcReceptor",
c.nombre_receptor as "nombreReceptor",
c.fecha_emision as "fechaEmision"
FROM cfdi_conceptos cc
JOIN cfdis c ON cc.cfdi_id = c.id
WHERE c.status NOT IN ('Cancelado', '0')
${whereType}
${whereContrib}
${whereSearch}
ORDER BY cc.clave_prod_serv, cc.descripcion, c.fecha_emision DESC
LIMIT 30
`, params);
res.json(rows);
} catch (error) { next(error); }
}
// ── CFDIs PPD pendientes ──
export async function getCfdisPpdPendientes(req: Request, res: Response, next: NextFunction) {
try {
const rfc = (req.query.rfc as string || '').trim().toUpperCase();
if (rfc.length < 3) return res.json([]);
const pool = req.tenantPool!;
// Buscar CFDIs emitidos PPD vigentes para este RFC receptor
// Calcular saldo pendiente: total - pagos previos aplicados
const { rows } = await pool.query(`
SELECT
c.uuid, c.serie, c.folio, c.total_mxn as "totalMxn",
c.fecha_emision as "fechaEmision",
c.rfc_receptor as "rfcReceptor",
c.nombre_receptor as "nombreReceptor",
c.iva_traslado_mxn as "ivaTrasladoMxn",
c.total_mxn - COALESCE((
SELECT SUM(p.monto_pago_mxn)
FROM cfdis p
WHERE p.tipo_comprobante = 'P'
AND p.uuid_relacionado LIKE '%' || c.uuid || '%'
AND p.status NOT IN ('Cancelado', '0')
), 0) as "saldoPendiente"
FROM cfdis c
WHERE c.type = 'EMITIDO'
AND c.metodo_pago = 'PPD'
AND c.tipo_comprobante = 'I'
AND c.status NOT IN ('Cancelado', '0')
AND c.rfc_receptor = $1
HAVING c.total_mxn - COALESCE((
SELECT SUM(p.monto_pago_mxn)
FROM cfdis p
WHERE p.tipo_comprobante = 'P'
AND p.uuid_relacionado LIKE '%' || c.uuid || '%'
AND p.status NOT IN ('Cancelado', '0')
), 0) > 0
ORDER BY c.fecha_emision DESC
LIMIT 20
`, [rfc]);
res.json(rows);
} catch (error) { next(error); }
}
// ── Búsqueda de RFCs ──
export async function searchRfcs(req: Request, res: Response, next: NextFunction) {
try {
const q = (req.query.q as string || '').trim();
if (q.length < 3) return res.json([]);
const pool = req.tenantPool!;
// Obtener RFC del tenant para excluirlo
const tenantId = effectiveTenantId(req);
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { rfc: true },
});
const tenantRfc = tenant?.rfc || '';
const { rows } = await pool.query(`
SELECT id, rfc, razon_social as "razonSocial",
regimen_fiscal as "regimenFiscal",
codigo_postal as "codigoPostal"
FROM rfcs
WHERE rfc != $1
AND (rfc ILIKE $2 OR razon_social ILIKE $2)
ORDER BY razon_social
LIMIT 10
`, [tenantRfc, `%${q}%`]);
res.json(rows);
} catch (error) { next(error); }
}
// ── Timbres adicionales: catálogo + compra ──
export async function getPaquetesCatalogo(req: Request, res: Response, next: NextFunction) {
try {
const catalogo = await facturapiService.listPaquetesCatalogo();
res.json(catalogo);
} catch (error) { next(error); }
}
const comprarPaqueteSchema = z.object({
catalogoId: z.number().int().positive(),
});
// Admin global: catálogo completo incluyendo inactivos + edit
export async function getPaquetesCatalogoAdmin(req: Request, res: Response, next: NextFunction) {
try {
if (!(await hasPlatformRole(req.user!.userId, 'platform_admin'))) {
return res.status(403).json({ message: 'Solo admin global puede ver el catálogo completo' });
}
const catalogo = await facturapiService.listAllPaquetesCatalogo();
res.json(catalogo);
} catch (error) { next(error); }
}
const updatePaqueteSchema = z.object({
precio: z.number().positive().optional(),
active: z.boolean().optional(),
});
export async function updatePaqueteCatalogo(req: Request, res: Response, next: NextFunction) {
try {
if (!(await hasPlatformRole(req.user!.userId, 'platform_admin'))) {
return res.status(403).json({ message: 'Solo admin global puede editar el catálogo' });
}
const id = parseInt(String(req.params.id));
if (isNaN(id)) return next(new AppError(400, 'id inválido'));
const data = updatePaqueteSchema.parse(req.body);
const before = await facturapiService.listAllPaquetesCatalogo().then(r => r.find(p => p.id === id));
const updated = await facturapiService.updatePaqueteCatalogo({ id, ...data });
auditFromReq(req, 'timbres.catalogo_updated', {
entityType: 'TimbrePaqueteCatalogo',
entityId: String(id),
metadata: {
cantidad: updated.cantidad,
from: { precio: before?.precio, active: before?.active },
to: { precio: updated.precio, active: updated.active },
},
});
res.json(updated);
} catch (error: any) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
if (error?.message?.includes('precio') || error?.message?.includes('actualizar')) {
return next(new AppError(400, error.message));
}
next(error);
}
}
export async function comprarPaquete(req: Request, res: Response, next: NextFunction) {
try {
if (!['owner', 'cfo'].includes(req.user!.role)) {
return res.status(403).json({ message: 'Solo owner/cfo pueden comprar timbres adicionales' });
}
const { catalogoId } = comprarPaqueteSchema.parse(req.body);
const result = await facturapiService.iniciarCompraPaquete({
tenantId: effectiveTenantId(req),
catalogoId,
callerEmail: req.user!.email,
});
res.json(result);
} catch (error: any) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
// Errores de negocio esperados → 400 con mensaje para el usuario
const msg = error?.message || '';
if (msg.includes('no disponible') || msg.includes('dueño') || msg.includes('email') || msg.includes('MercadoPago')) {
return next(new AppError(400, msg));
}
console.error('[comprarPaquete] Error no esperado:', error);
next(error);
}
}

View File

@@ -0,0 +1,136 @@
import type { Request, Response } from 'express';
import { uploadFiel, getFielStatus, deleteFiel } from '../services/fiel.service.js';
import type { FielUploadRequest } from '@horux/shared';
import type { Pool } from 'pg';
/**
* Crea recordatorios automáticos de vencimiento de e.firma en el calendario.
* 60 días, 30 días y 7 días antes del vencimiento.
* Elimina recordatorios previos de e.firma antes de crear nuevos.
*/
async function crearRecordatoriosEfirma(
pool: Pool,
userId: string,
validUntil: string,
): Promise<void> {
const vencimiento = new Date(validUntil);
const PREFIJO = '[e.firma]';
// Eliminar recordatorios previos de e.firma para evitar duplicados al re-subir
await pool.query(
`DELETE FROM recordatorios WHERE titulo LIKE $1`,
[`${PREFIJO}%`]
);
const recordatorios = [
{ dias: 60, titulo: `${PREFIJO} Tu e.firma vence en 60 días` },
{ dias: 30, titulo: `${PREFIJO} Tu e.firma vence en 30 días` },
{ dias: 7, titulo: `${PREFIJO} Tu e.firma vence en 7 días — ¡Renueva pronto!` },
];
for (const { dias, titulo } of recordatorios) {
const fecha = new Date(vencimiento);
fecha.setDate(fecha.getDate() - dias);
// Solo crear si la fecha no ha pasado
if (fecha > new Date()) {
await pool.query(
`INSERT INTO recordatorios (titulo, descripcion, fecha_limite, privado, creado_por)
VALUES ($1, $2, $3, false, $4)`,
[
titulo,
`La e.firma (FIEL) vence el ${vencimiento.toLocaleDateString('es-MX')}. Renueva en el portal del SAT.`,
fecha.toISOString().split('T')[0],
userId,
]
);
}
}
}
function effectiveTenantId(req: Request): string {
return req.viewingTenantId || req.user!.tenantId;
}
/**
* Sube y configura las credenciales FIEL
*/
export async function upload(req: Request, res: Response): Promise<void> {
try {
const tenantId = effectiveTenantId(req);
const { cerFile, keyFile, password } = req.body as FielUploadRequest;
if (!cerFile || !keyFile || !password) {
res.status(400).json({ error: 'cerFile, keyFile y password son requeridos' });
return;
}
// Validate file sizes (typical .cer/.key files are under 10KB, base64 ~33% larger)
const MAX_FILE_SIZE = 50_000; // 50KB base64 ≈ ~37KB binary
if (cerFile.length > MAX_FILE_SIZE || keyFile.length > MAX_FILE_SIZE) {
res.status(400).json({ error: 'Los archivos FIEL son demasiado grandes (máx 50KB)' });
return;
}
if (password.length > 256) {
res.status(400).json({ error: 'Contraseña FIEL demasiado larga' });
return;
}
const result = await uploadFiel(tenantId, cerFile, keyFile, password);
if (!result.success) {
res.status(400).json({ error: result.message });
return;
}
// Crear recordatorios de vencimiento en el calendario
if (result.status?.validUntil && req.tenantPool) {
crearRecordatoriosEfirma(req.tenantPool, req.user!.userId, result.status.validUntil)
.catch(err => console.error('[FIEL] Error creando recordatorios de vencimiento:', err));
}
res.json({
message: result.message,
status: result.status,
});
} catch (error: any) {
console.error('[FIEL Controller] Error en upload:', error);
res.status(500).json({ error: 'Error interno del servidor' });
}
}
/**
* Obtiene el estado de la FIEL configurada
*/
export async function status(req: Request, res: Response): Promise<void> {
try {
const tenantId = effectiveTenantId(req);
const fielStatus = await getFielStatus(tenantId);
res.json(fielStatus);
} catch (error: any) {
console.error('[FIEL Controller] Error en status:', error);
res.status(500).json({ error: 'Error interno del servidor' });
}
}
/**
* Elimina las credenciales FIEL
*/
export async function remove(req: Request, res: Response): Promise<void> {
try {
const tenantId = effectiveTenantId(req);
const deleted = await deleteFiel(tenantId);
if (!deleted) {
res.status(404).json({ error: 'No hay FIEL configurada' });
return;
}
res.json({ message: 'FIEL eliminada correctamente' });
} catch (error: any) {
console.error('[FIEL Controller] Error en remove:', error);
res.status(500).json({ error: 'Error interno del servidor' });
}
}

View File

@@ -0,0 +1,171 @@
import type { Request, Response, NextFunction } from 'express';
import * as impuestosService from '../services/impuestos.service.js';
import { prisma } from '../config/database.js';
import { AppError } from '../middlewares/error.middleware.js';
function parseConciliacion(req: Request): boolean {
return req.query.conciliacion === 'true' || req.query.conciliacion === '1';
}
function parseFlag(req: Request, key: string, defaultValue = true): boolean {
const v = req.query[key];
if (v === undefined || v === null) return defaultValue;
return v === 'true' || v === '1';
}
function effectiveTenantId(req: Request): string {
return req.viewingTenantId || req.user!.tenantId;
}
export async function getIvaMensual(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const año = parseInt(req.query.año as string) || new Date().getFullYear();
const conciliacion = parseConciliacion(req);
const contribuyenteId = (req.query.contribuyenteId as string) || null;
const considerarActivos = parseFlag(req, 'considerarActivos', true);
const considerarNCs = parseFlag(req, 'considerarNCs', true);
const data = await impuestosService.getIvaMensual(req.tenantPool, año, effectiveTenantId(req), conciliacion, contribuyenteId, considerarActivos, considerarNCs);
res.json(data);
} catch (error) {
next(error);
}
}
export async function getIsrMensual(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const año = parseInt(req.query.año as string) || new Date().getFullYear();
const conciliacion = parseConciliacion(req);
const contribuyenteId = (req.query.contribuyenteId as string) || null;
const regimenClave = (req.query.regimenClave as string) || null;
const considerarActivos = parseFlag(req, 'considerarActivos', true);
const considerarNCs = parseFlag(req, 'considerarNCs', true);
const data = await impuestosService.getIsrMensual(req.tenantPool, año, effectiveTenantId(req), conciliacion, contribuyenteId, regimenClave, considerarActivos, considerarNCs);
res.json(data);
} catch (error) {
next(error);
}
}
export async function getResumenIva(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const now = new Date();
const y = now.getFullYear();
const m = now.getMonth() + 1;
const lastDay = new Date(y, m, 0).getDate();
const fechaInicio = (req.query.fechaInicio as string) || `${y}-${String(m).padStart(2, '0')}-01`;
const fechaFin = (req.query.fechaFin as string) || `${y}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
const conciliacion = parseConciliacion(req);
const contribuyenteId = (req.query.contribuyenteId as string) || null;
const considerarActivos = parseFlag(req, 'considerarActivos', true);
const considerarNCs = parseFlag(req, 'considerarNCs', true);
const resumen = await impuestosService.getResumenIva(req.tenantPool, fechaInicio, fechaFin, effectiveTenantId(req), conciliacion, contribuyenteId, considerarActivos, considerarNCs);
res.json(resumen);
} catch (error) {
next(error);
}
}
export async function getResumenIsr(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const now = new Date();
const y = now.getFullYear();
const m = now.getMonth() + 1;
const lastDay = new Date(y, m, 0).getDate();
const fechaInicio = (req.query.fechaInicio as string) || `${y}-${String(m).padStart(2, '0')}-01`;
const fechaFin = (req.query.fechaFin as string) || `${y}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
const conciliacion = parseConciliacion(req);
const contribuyenteId = (req.query.contribuyenteId as string) || null;
const considerarActivos = parseFlag(req, 'considerarActivos', true);
const considerarNCs = parseFlag(req, 'considerarNCs', true);
const resumen = await impuestosService.getResumenIsr(req.tenantPool, fechaInicio, fechaFin, effectiveTenantId(req), conciliacion, contribuyenteId, considerarActivos, considerarNCs);
res.json(resumen);
} catch (error) {
next(error);
}
}
export async function getResumenIsrDesglosado(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
// fechaFin define mes_final + año. Default: último día del mes corriente.
const now = new Date();
const y = now.getFullYear();
const m = now.getMonth() + 1;
const lastDay = new Date(y, m, 0).getDate();
const fechaFin = (req.query.fechaFin as string) || `${y}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
const conciliacion = parseConciliacion(req);
const contribuyenteId = (req.query.contribuyenteId as string) || null;
const considerarActivos = parseFlag(req, 'considerarActivos', true);
const considerarNCs = parseFlag(req, 'considerarNCs', true);
const desglose = await impuestosService.getResumenIsrDesglosado(
req.tenantPool,
fechaFin,
effectiveTenantId(req),
conciliacion,
contribuyenteId,
considerarActivos,
considerarNCs,
);
res.json(desglose);
} catch (error) {
next(error);
}
}
export async function getCoeficiente(req: Request, res: Response, next: NextFunction) {
try {
const anio = parseInt(req.query.anio as string) || new Date().getFullYear();
const tenantId = effectiveTenantId(req);
const row = await prisma.coeficienteUtilidad.findUnique({
where: { tenantId_anio: { tenantId, anio } },
});
res.json({ anio, coeficiente: row ? Number(row.coeficiente) : null });
} catch (error) {
next(error);
}
}
export async function setCoeficiente(req: Request, res: Response, next: NextFunction) {
try {
if (req.user!.role !== 'owner') {
return res.status(403).json({ message: 'Solo el dueño puede configurar el coeficiente' });
}
const { anio, coeficiente } = req.body;
if (!anio || coeficiente === undefined || coeficiente === null) {
return res.status(400).json({ message: 'anio y coeficiente son requeridos' });
}
const tenantId = effectiveTenantId(req);
const row = await prisma.coeficienteUtilidad.upsert({
where: { tenantId_anio: { tenantId, anio } },
update: { coeficiente },
create: { tenantId, anio, coeficiente },
});
res.json({ anio: row.anio, coeficiente: Number(row.coeficiente) });
} catch (error) {
next(error);
}
}

View File

@@ -0,0 +1,25 @@
import type { Request, Response, NextFunction } from 'express';
import { getMetricasMensuales } from '../services/metricas.service.js';
import { AppError } from '../middlewares/error.middleware.js';
export async function getMensuales(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = req.query.contribuyenteId as string;
const anio = Number(req.query.anio);
if (!contribuyenteId || !anio) {
return next(new AppError(400, 'contribuyenteId y anio son requeridos'));
}
const regimenFiscal = req.query.regimen as string | undefined;
const metricas = await getMetricasMensuales(req.tenantPool!, contribuyenteId, anio, regimenFiscal);
const currentYear = new Date().getFullYear();
return res.json({
data: metricas,
source: anio < currentYear ? 'cold' : 'hot',
anio,
contribuyenteId,
});
} catch (err) { return next(err); }
}

View File

@@ -0,0 +1,33 @@
import type { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { AppError } from '../middlewares/error.middleware.js';
import {
EMAIL_TYPES,
getEmailPreferencesPorContribuyente,
setContribuyenteEmailPreferences,
} from '../services/notification-preferences.service.js';
export async function listPreferences(req: Request, res: Response, next: NextFunction) {
try {
const data = await getEmailPreferencesPorContribuyente(req.tenantPool!);
res.json({ emailTypes: EMAIL_TYPES, data });
} catch (error) {
next(error);
}
}
const updateSchema = z.object({
contribuyenteId: z.string().uuid(),
preferences: z.record(z.string(), z.boolean()),
});
export async function updatePreferences(req: Request, res: Response, next: NextFunction) {
try {
const { contribuyenteId, preferences } = updateSchema.parse(req.body);
const updated = await setContribuyenteEmailPreferences(req.tenantPool!, contribuyenteId, preferences);
res.json({ contribuyenteId, preferences: updated });
} catch (error) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error);
}
}

View File

@@ -0,0 +1,111 @@
import type { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import * as obligacionesService from '../services/obligaciones.service.js';
import { AppError } from '../middlewares/error.middleware.js';
export async function getCatalogo(req: Request, res: Response, next: NextFunction) {
try {
return res.json({ data: obligacionesService.getCatalogo() });
} catch (err) { return next(err); }
}
export async function getObligaciones(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = String(req.params.id);
const rows = await obligacionesService.getObligaciones(req.tenantPool!, contribuyenteId);
return res.json({ data: rows });
} catch (err) { return next(err); }
}
export async function initRecomendaciones(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = String(req.params.id);
const { rfc, regimenes, tieneNomina } = req.body;
if (!rfc) return next(new AppError(400, 'rfc requerido'));
const count = await obligacionesService.initRecomendaciones(
req.tenantPool!, contribuyenteId, rfc, regimenes || [], tieneNomina ?? false
);
return res.json({ message: `${count} obligaciones recomendadas agregadas`, count });
} catch (err) { return next(err); }
}
export async function addObligacion(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = String(req.params.id);
const schema = z.object({
catalogoId: z.string().optional(),
nombre: z.string().min(2),
fundamento: z.string().optional(),
frecuencia: z.string().optional(),
fechaLimite: z.string().optional(),
categoria: z.string().optional(),
});
const data = schema.parse(req.body);
const row = await obligacionesService.addObligacion(req.tenantPool!, contribuyenteId, data);
return res.status(201).json(row);
} catch (err: any) {
if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message));
return next(err);
}
}
export async function removeObligacion(req: Request, res: Response, next: NextFunction) {
try {
const ok = await obligacionesService.removeObligacion(req.tenantPool!, String(req.params.obligacionId));
if (!ok) return next(new AppError(404, 'Obligación no encontrada'));
return res.json({ message: 'Obligación desactivada' });
} catch (err) { return next(err); }
}
export async function restoreObligacion(req: Request, res: Response, next: NextFunction) {
try {
const ok = await obligacionesService.restoreObligacion(req.tenantPool!, String(req.params.obligacionId));
if (!ok) return next(new AppError(404, 'Obligación no encontrada'));
return res.json({ message: 'Obligación restaurada' });
} catch (err) { return next(err); }
}
export async function completeObligacion(req: Request, res: Response, next: NextFunction) {
try {
const periodo = req.body.periodo || new Date().toISOString().substring(0, 7);
const ok = await obligacionesService.completeObligacion(req.tenantPool!, String(req.params.obligacionId), req.user!.userId, periodo);
if (!ok) return next(new AppError(404, 'Obligación no encontrada'));
return res.json({ message: 'Obligación marcada como completada' });
} catch (err) { return next(err); }
}
export async function uncompleteObligacion(req: Request, res: Response, next: NextFunction) {
try {
const ok = await obligacionesService.uncompleteObligacion(req.tenantPool!, String(req.params.obligacionId));
if (!ok) return next(new AppError(404, 'Obligación no encontrada'));
return res.json({ message: 'Obligación desmarcada' });
} catch (err) { return next(err); }
}
export async function getObligacionesPorPeriodo(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = String(req.params.id);
const periodo = (req.query.periodo as string) || new Date().toISOString().substring(0, 7);
const incluirAtrasados = req.query.atrasados !== 'false';
const rows = await obligacionesService.getObligacionesPorPeriodo(req.tenantPool!, contribuyenteId, periodo, incluirAtrasados);
return res.json({ data: rows, periodo });
} catch (err) { return next(err); }
}
export async function completePeriodo(req: Request, res: Response, next: NextFunction) {
try {
const { periodo, notas } = req.body;
if (!periodo) return next(new AppError(400, 'periodo requerido (YYYY-MM)'));
await obligacionesService.completePeriodo(req.tenantPool!, String(req.params.obligacionId), periodo, req.user!.userId, notas);
return res.json({ message: 'Obligación completada para el periodo' });
} catch (err) { return next(err); }
}
export async function uncompletePeriodo(req: Request, res: Response, next: NextFunction) {
try {
const periodo = req.body.periodo || req.query.periodo;
if (!periodo) return next(new AppError(400, 'periodo requerido'));
await obligacionesService.uncompletePeriodo(req.tenantPool!, String(req.params.obligacionId), periodo as string);
return res.json({ message: 'Completación removida' });
} catch (err) { return next(err); }
}

View File

@@ -0,0 +1,263 @@
import type { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { AppError } from '../middlewares/error.middleware.js';
import * as papeleriaService from '../services/papeleria.service.js';
import { emailService } from '../services/email/email.service.js';
import { getTenantOwnerEmails, getUserEmailById } from '../utils/memberships.js';
import { env } from '../config/env.js';
import { prisma } from '../config/database.js';
function rejectClienteRole(req: Request): void {
if (req.user?.role === 'cliente') {
throw new AppError(403, 'Papelería no disponible para usuarios cliente');
}
}
function effectiveTenantId(req: Request): string {
return req.viewingTenantId || req.user!.tenantId;
}
const uploadSchema = z.object({
contribuyenteId: z.string().uuid(),
nombre: z.string().min(1).max(255),
descripcion: z.string().max(2000).nullable().optional(),
anio: z.number().int().min(2000).max(2100),
mes: z.number().int().min(1).max(12),
requiereAprobacion: z.boolean(),
archivoBase64: z.string().min(1),
archivoFilename: z.string().min(1).max(255),
archivoMime: z.string().min(1).max(100),
});
export async function upload(req: Request, res: Response, next: NextFunction) {
try {
rejectClienteRole(req);
const data = uploadSchema.parse(req.body);
const archivo = Buffer.from(data.archivoBase64, 'base64');
const item = await papeleriaService.uploadPapeleria(req.tenantPool!, {
contribuyenteId: data.contribuyenteId,
nombre: data.nombre,
descripcion: data.descripcion ?? null,
anio: data.anio,
mes: data.mes,
requiereAprobacion: data.requiereAprobacion,
archivo,
archivoFilename: data.archivoFilename,
archivoMime: data.archivoMime,
subidoPor: req.user!.userId,
});
// Notificación a aprobadores si la papelería requiere aprobación.
if (item.requiereAprobacion) {
notifyAprobacionRequerida(req, item).catch(err =>
console.error('[papeleria.upload] notify aprobadores failed:', err?.message || err),
);
}
res.status(201).json(item);
} catch (error: any) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
if (error?.message?.startsWith('Formato no permitido') || error?.message?.startsWith('Archivo excede')) {
return next(new AppError(400, error.message));
}
next(error);
}
}
const listSchema = z.object({
contribuyenteId: z.string().uuid(),
anio: z.string().regex(/^\d{4}$/).optional(),
mes: z.string().regex(/^\d{1,2}$/).optional(),
estado: z.enum(['pendiente', 'aprobado', 'rechazado', 'sin_aprobacion']).optional(),
});
export async function list(req: Request, res: Response, next: NextFunction) {
try {
rejectClienteRole(req);
const q = listSchema.parse(req.query);
const items = await papeleriaService.listPapeleria(req.tenantPool!, {
contribuyenteId: q.contribuyenteId,
anio: q.anio ? parseInt(q.anio, 10) : undefined,
mes: q.mes ? parseInt(q.mes, 10) : undefined,
estado: q.estado,
});
res.json(items);
} catch (error) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error);
}
}
export async function download(req: Request, res: Response, next: NextFunction) {
try {
rejectClienteRole(req);
const id = parseInt(String(req.params.id), 10);
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
const file = await papeleriaService.downloadArchivo(req.tenantPool!, id);
if (!file) return next(new AppError(404, 'Documento no encontrado'));
res.setHeader('Content-Type', file.mime);
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(file.filename)}"`);
res.send(file.archivo);
} catch (error) {
next(error);
}
}
export async function aprobar(req: Request, res: Response, next: NextFunction) {
try {
rejectClienteRole(req);
const id = parseInt(String(req.params.id), 10);
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
const item = await papeleriaService.aprobar(
req.tenantPool!, id, req.user!.userId, req.user!.role,
);
if (!item) return next(new AppError(404, 'Documento no encontrado o no requiere aprobación'));
notifyDecisionAuxiliar(req, item).catch(err =>
console.error('[papeleria.aprobar] notify auxiliar failed:', err?.message || err),
);
res.json(item);
} catch (error: any) {
if (error?.message?.startsWith('Solo owner')) return next(new AppError(403, error.message));
next(error);
}
}
const rechazarSchema = z.object({ comentario: z.string().max(2000).nullable().optional() });
export async function rechazar(req: Request, res: Response, next: NextFunction) {
try {
rejectClienteRole(req);
const id = parseInt(String(req.params.id), 10);
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
const { comentario } = rechazarSchema.parse(req.body);
const item = await papeleriaService.rechazar(
req.tenantPool!, id, req.user!.userId, req.user!.role, comentario ?? null,
);
if (!item) return next(new AppError(404, 'Documento no encontrado o no requiere aprobación'));
notifyDecisionAuxiliar(req, item).catch(err =>
console.error('[papeleria.rechazar] notify auxiliar failed:', err?.message || err),
);
res.json(item);
} catch (error: any) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
if (error?.message?.startsWith('Solo owner')) return next(new AppError(403, error.message));
next(error);
}
}
export async function eliminar(req: Request, res: Response, next: NextFunction) {
try {
rejectClienteRole(req);
const id = parseInt(String(req.params.id), 10);
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
const ok = await papeleriaService.eliminar(req.tenantPool!, id);
if (!ok) return next(new AppError(404, 'Documento no encontrado'));
res.status(204).send();
} catch (error) {
next(error);
}
}
// ─── Notificaciones ───
/**
* Notifica a owners y supervisores cuando una papelería requiere aprobación.
* Owners se obtienen de tenant_memberships (BD central). Supervisores se
* resuelven leyendo carteras del tenant.
*/
async function notifyAprobacionRequerida(
req: Request,
item: papeleriaService.PapeleriaItem,
): Promise<void> {
const tenantId = effectiveTenantId(req);
// Owners del despacho
const recipients = new Set<string>(await getTenantOwnerEmails(tenantId));
// Supervisores: cualquier user con rol 'supervisor' o 'cfo' que pertenezca a este tenant.
// Buscamos vía tenant_memberships + roles.
const supervisores = await prisma.tenantMembership.findMany({
where: { tenantId, active: true, rol: { nombre: { in: ['supervisor', 'cfo'] } } },
include: { user: { select: { email: true, active: true } } },
});
for (const m of supervisores) {
if (m.user.active && m.user.email) recipients.add(m.user.email);
}
// No notificarse a sí mismo
recipients.delete(req.user!.email);
if (recipients.size === 0) return;
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { nombre: true },
});
const { rows } = await req.tenantPool!.query<{ rfc: string; nombre: string }>(
`SELECT c.rfc, eg.nombre FROM contribuyentes c
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
WHERE c.entidad_id = $1`,
[item.contribuyenteId],
);
if (rows.length === 0) return;
const link = `${env.FRONTEND_URL}/documentos`;
const meses = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic'];
const periodo = `${meses[item.mes - 1]} ${item.anio}`;
for (const to of recipients) {
try {
await emailService.sendPapeleriaAprobacionRequerida(to, {
contribuyenteRfc: rows[0].rfc,
contribuyenteNombre: rows[0].nombre,
despachoNombre: tenant?.nombre,
nombreDocumento: item.nombre,
descripcion: item.descripcion,
periodo,
subidoPor: req.user!.email,
link,
});
} catch (err: any) {
console.error(`[Email] papeleria-aprobacion a ${to}:`, err?.message || err);
}
}
}
/**
* Notifica al uploader (auxiliar) cuando un documento que él subió fue
* aprobado o rechazado. Solo manda si quien aprobó/rechazó NO es el mismo
* uploader (caso edge: owner sube su propia papelería).
*/
async function notifyDecisionAuxiliar(
req: Request,
item: papeleriaService.PapeleriaItem,
): Promise<void> {
if (item.subidoPor === req.user!.userId) return;
const auxiliarEmail = await getUserEmailById(item.subidoPor);
if (!auxiliarEmail) return;
const { rows } = await req.tenantPool!.query<{ rfc: string; nombre: string }>(
`SELECT c.rfc, eg.nombre FROM contribuyentes c
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
WHERE c.entidad_id = $1`,
[item.contribuyenteId],
);
if (rows.length === 0) return;
const link = `${env.FRONTEND_URL}/documentos`;
const meses = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic'];
const periodo = `${meses[item.mes - 1]} ${item.anio}`;
await emailService.sendPapeleriaDecision(auxiliarEmail, {
contribuyenteRfc: rows[0].rfc,
contribuyenteNombre: rows[0].nombre,
nombreDocumento: item.nombre,
estado: item.estado as 'aprobado' | 'rechazado',
revisor: req.user!.email,
comentario: item.comentarioRechazo,
periodo,
link,
});
}

View File

@@ -0,0 +1,26 @@
import type { Request, Response, NextFunction } from 'express';
import * as planService from '../services/plan-catalogo.service.js';
export async function getPlans(req: Request, res: Response, next: NextFunction) {
try {
const vertical = req.query.vertical as string | undefined;
const plans = await planService.listPlans(vertical);
return res.json({ data: plans });
} catch (err) { return next(err); }
}
export async function getAddons(req: Request, res: Response, next: NextFunction) {
try {
const vertical = req.query.vertical as string | undefined;
const addons = await planService.listAddons(vertical);
return res.json({ data: addons });
} catch (err) { return next(err); }
}
export async function getPlan(req: Request, res: Response, next: NextFunction) {
try {
const plan = await planService.getPlanByCodename(String(req.params.codename));
if (!plan) return res.status(404).json({ message: 'Plan no encontrado' });
return res.json(plan);
} catch (err) { return next(err); }
}

View File

@@ -0,0 +1,187 @@
import type { Request, Response, NextFunction } from 'express';
import { prisma } from '../config/database.js';
import { hasPlatformRole, invalidatePlatformRolesCache, type PlatformRole } from '../utils/platform-admin.js';
import { auditFromReq } from '../utils/audit.js';
const VALID_ROLES: PlatformRole[] = ['platform_admin', 'platform_ti', 'platform_support', 'platform_sales', 'platform_finance'];
const SUPERSET_ROLES: PlatformRole[] = ['platform_admin', 'platform_ti'];
async function requirePlatformAdmin(req: Request, res: Response): Promise<boolean> {
const ok = await hasPlatformRole(req.user?.userId, 'platform_admin');
if (!ok) {
res.status(403).json({ message: 'Solo platform_admin puede gestionar staff' });
}
return ok;
}
/**
* Lista users que tienen al menos un platform role + users candidatos a serlo.
* Admin global (platform_admin) only.
*/
export async function listStaff(req: Request, res: Response, next: NextFunction) {
try {
if (!(await requirePlatformAdmin(req, res))) return;
// Todos los users con al menos un platform role
const roles = await prisma.userPlatformRole.findMany({
include: {
user: {
select: {
id: true, email: true, nombre: true, active: true,
// Tenant principal del staff: el primer membership owner por joinedAt
// ASC. Se incluye solo para mostrar contexto en la UI admin.
memberships: {
where: { active: true, isOwner: true },
orderBy: { joinedAt: 'asc' },
take: 1,
include: { tenant: { select: { id: true, nombre: true, rfc: true } } },
},
},
},
},
orderBy: { createdAt: 'desc' },
});
// Agrupa por user
const byUser = new Map<string, any>();
for (const r of roles) {
const existing = byUser.get(r.userId);
if (existing) {
existing.roles.push(r.role);
} else {
const { memberships, ...userBase } = r.user;
byUser.set(r.userId, {
...userBase,
tenant: memberships[0]?.tenant ?? null,
roles: [r.role],
});
}
}
res.json(Array.from(byUser.values()));
} catch (error) {
next(error);
}
}
/**
* Busca users por email (para agregar nuevos staff). Admin global only.
*/
export async function searchUsers(req: Request, res: Response, next: NextFunction) {
try {
if (!(await requirePlatformAdmin(req, res))) return;
const q = String(req.query.q || '').trim();
if (q.length < 2) return res.json([]);
const users = await prisma.user.findMany({
where: {
OR: [
{ email: { contains: q, mode: 'insensitive' } },
{ nombre: { contains: q, mode: 'insensitive' } },
],
},
select: {
id: true, email: true, nombre: true, active: true,
memberships: {
where: { active: true, isOwner: true },
orderBy: { joinedAt: 'asc' },
take: 1,
include: { tenant: { select: { id: true, nombre: true, rfc: true } } },
},
},
take: 10,
});
res.json(users.map(u => {
const { memberships, ...rest } = u;
return { ...rest, tenant: memberships[0]?.tenant ?? null };
}));
} catch (error) {
next(error);
}
}
/**
* Asigna un rol a un user. Idempotente (si ya existe, no duplica).
*/
export async function grantRole(req: Request, res: Response, next: NextFunction) {
try {
if (!(await requirePlatformAdmin(req, res))) return;
const { userId, role } = req.body;
if (!userId || typeof userId !== 'string') {
return res.status(400).json({ message: 'userId requerido' });
}
if (!VALID_ROLES.includes(role)) {
return res.status(400).json({ message: `role inválido. Valores: ${VALID_ROLES.join(', ')}` });
}
const user = await prisma.user.findUnique({ where: { id: userId }, select: { id: true, email: true } });
if (!user) return res.status(404).json({ message: 'Usuario no encontrado' });
await prisma.userPlatformRole.upsert({
where: { userId_role: { userId, role } },
create: { userId, role, createdBy: req.user!.userId },
update: {},
});
invalidatePlatformRolesCache(userId);
auditFromReq(req, 'platform_role.granted', {
entityType: 'User',
entityId: userId,
metadata: { role, targetEmail: user.email },
});
res.json({ ok: true });
} catch (error) {
next(error);
}
}
/**
* Quita un rol a un user. Protección: no puedes quitarte tu propio `platform_admin`
* si eres el último admin (evita bootstrap problem — nadie queda con acceso).
*/
export async function revokeRole(req: Request, res: Response, next: NextFunction) {
try {
if (!(await requirePlatformAdmin(req, res))) return;
const { userId, role } = req.body;
if (!userId || typeof userId !== 'string') {
return res.status(400).json({ message: 'userId requerido' });
}
if (!VALID_ROLES.includes(role)) {
return res.status(400).json({ message: 'role inválido' });
}
// Protección: no quitar tu último rol superset (admin o TI) — evita bootstrap problem
if (SUPERSET_ROLES.includes(role) && userId === req.user!.userId) {
const supersetCount = await prisma.userPlatformRole.count({
where: { role: { in: SUPERSET_ROLES } },
});
if (supersetCount <= 1) {
return res.status(400).json({
message: 'No puedes quitar tu propio rol superset — serías el último con acceso transversal. Asigna platform_admin o platform_ti a otro usuario primero.',
});
}
}
const user = await prisma.user.findUnique({ where: { id: userId }, select: { email: true } });
await prisma.userPlatformRole.deleteMany({
where: { userId, role },
});
invalidatePlatformRolesCache(userId);
auditFromReq(req, 'platform_role.revoked', {
entityType: 'User',
entityId: userId,
metadata: { role, targetEmail: user?.email },
});
res.json({ ok: true });
} catch (error) {
next(error);
}
}

View File

@@ -0,0 +1,70 @@
import type { Request, Response, NextFunction } from 'express';
import * as regimenService from '../services/regimen.service.js';
/** Resuelve el tenantId efectivo (impersonación o propio) */
function effectiveTenantId(req: Request): string {
return req.viewingTenantId || req.user!.tenantId;
}
export async function getAllRegimenes(req: Request, res: Response, next: NextFunction) {
try {
const regimenes = await regimenService.getAllRegimenes();
res.json(regimenes);
} catch (error) {
next(error);
}
}
export async function getActivos(req: Request, res: Response, next: NextFunction) {
try {
const activos = await regimenService.getRegimenesActivos(effectiveTenantId(req));
res.json(activos);
} catch (error) {
next(error);
}
}
export async function setActivos(req: Request, res: Response, next: NextFunction) {
try {
if (req.user!.role !== 'owner') {
return res.status(403).json({ message: 'Solo el dueño puede configurar regímenes' });
}
const { regimenIds } = req.body;
if (!Array.isArray(regimenIds)) {
return res.status(400).json({ message: 'regimenIds debe ser un array' });
}
const result = await regimenService.setRegimenesActivos(effectiveTenantId(req), regimenIds);
res.json(result);
} catch (error) {
next(error);
}
}
export async function getIgnorados(req: Request, res: Response, next: NextFunction) {
try {
const ignorados = await regimenService.getRegimenesIgnorados(effectiveTenantId(req));
res.json(ignorados);
} catch (error) {
next(error);
}
}
export async function setIgnorados(req: Request, res: Response, next: NextFunction) {
try {
if (req.user!.role !== 'owner') {
return res.status(403).json({ message: 'Solo el dueño puede configurar regímenes' });
}
const { regimenIds } = req.body;
if (!Array.isArray(regimenIds)) {
return res.status(400).json({ message: 'regimenIds debe ser un array' });
}
const result = await regimenService.setRegimenesIgnorados(effectiveTenantId(req), regimenIds);
res.json(result);
} catch (error) {
next(error);
}
}

View File

@@ -0,0 +1,85 @@
import type { Request, Response, NextFunction } from 'express';
import * as reportesService from '../services/reportes.service.js';
export async function getEstadoResultados(req: Request, res: Response, next: NextFunction) {
try {
const { fechaInicio, fechaFin, contribuyenteId } = req.query;
const now = new Date();
const inicio = (fechaInicio as string) || `${now.getFullYear()}-01-01`;
const fin = (fechaFin as string) || now.toISOString().split('T')[0];
const data = await reportesService.getEstadoResultados(req.tenantPool!, inicio, fin, req.user!.tenantId, contribuyenteId as string | undefined || null);
res.json(data);
} catch (error) {
console.error('[reportes] Error en getEstadoResultados:', error);
next(error);
}
}
export async function getFlujoEfectivo(req: Request, res: Response, next: NextFunction) {
try {
const { fechaInicio, fechaFin, contribuyenteId } = req.query;
const now = new Date();
const inicio = (fechaInicio as string) || `${now.getFullYear()}-01-01`;
const fin = (fechaFin as string) || now.toISOString().split('T')[0];
const data = await reportesService.getFlujoEfectivo(req.tenantPool!, inicio, fin, contribuyenteId as string | undefined || null);
res.json(data);
} catch (error) {
next(error);
}
}
export async function getComparativo(req: Request, res: Response, next: NextFunction) {
try {
const { contribuyenteId } = req.query;
const año = parseInt(req.query.año as string) || new Date().getFullYear();
const data = await reportesService.getComparativo(req.tenantPool!, año, contribuyenteId as string | undefined || null);
res.json(data);
} catch (error) {
next(error);
}
}
export async function getCuentasXPagar(req: Request, res: Response, next: NextFunction) {
try {
const { fechaInicio, fechaFin, regimen, contribuyenteId } = req.query;
const now = new Date();
const inicio = (fechaInicio as string) || `${now.getFullYear()}-01-01`;
const fin = (fechaFin as string) || now.toISOString().split('T')[0];
const data = await reportesService.getCuentasXPagar(req.tenantPool!, inicio, fin, regimen as string, contribuyenteId as string | undefined || null);
res.json(data);
} catch (error) {
next(error);
}
}
export async function getCuentasXCobrar(req: Request, res: Response, next: NextFunction) {
try {
const { fechaInicio, fechaFin, regimen, contribuyenteId } = req.query;
const now = new Date();
const inicio = (fechaInicio as string) || `${now.getFullYear()}-01-01`;
const fin = (fechaFin as string) || now.toISOString().split('T')[0];
const data = await reportesService.getCuentasXCobrar(req.tenantPool!, inicio, fin, regimen as string, contribuyenteId as string | undefined || null);
res.json(data);
} catch (error) {
next(error);
}
}
export async function getConcentradoRfc(req: Request, res: Response, next: NextFunction) {
try {
const { fechaInicio, fechaFin, tipo, contribuyenteId } = req.query;
const now = new Date();
const inicio = (fechaInicio as string) || `${now.getFullYear()}-01-01`;
const fin = (fechaFin as string) || now.toISOString().split('T')[0];
const tipoRfc = (tipo as 'cliente' | 'proveedor') || 'cliente';
const data = await reportesService.getConcentradoRfc(req.tenantPool!, inicio, fin, tipoRfc, contribuyenteId as string | undefined || null);
res.json(data);
} catch (error) {
next(error);
}
}

View File

@@ -0,0 +1,168 @@
import type { Request, Response } from 'express';
import {
startSync,
getSyncStatus,
getSyncHistory,
retryJob,
} from '../services/sat/sat.service.js';
import { getJobInfo, runSatSyncJobManually } from '../jobs/sat-sync.job.js';
import type { StartSyncRequest } from '@horux/shared';
import { isGlobalAdmin } from '../utils/global-admin.js';
function effectiveTenantId(req: Request): string {
return req.viewingTenantId || req.user!.tenantId;
}
/**
* Inicia una sincronización manual
*/
export async function start(req: Request, res: Response): Promise<void> {
try {
const tenantId = effectiveTenantId(req);
const { type, dateFrom, dateTo } = req.body as StartSyncRequest;
const contribuyenteId = req.body.contribuyenteId as string | undefined;
const jobId = await startSync(
tenantId,
type || 'daily',
dateFrom ? new Date(dateFrom) : undefined,
dateTo ? new Date(dateTo) : undefined,
contribuyenteId || undefined
);
res.json({
jobId,
message: 'Sincronización iniciada',
});
} catch (error: any) {
console.error('[SAT Controller] Error en start:', error);
if (error.message.includes('FIEL') || error.message.includes('sincronización en curso')) {
res.status(400).json({ error: error.message });
return;
}
res.status(500).json({ error: 'Error interno del servidor' });
}
}
/**
* Obtiene el estado actual de sincronización
*/
export async function status(req: Request, res: Response): Promise<void> {
try {
const tenantId = effectiveTenantId(req);
const contribuyenteId = req.query.contribuyenteId as string | undefined;
const syncStatus = await getSyncStatus(tenantId, contribuyenteId || undefined);
res.json(syncStatus);
} catch (error: any) {
console.error('[SAT Controller] Error en status:', error);
res.status(500).json({ error: 'Error interno del servidor' });
}
}
/**
* Obtiene el historial de sincronizaciones
*/
export async function history(req: Request, res: Response): Promise<void> {
try {
const tenantId = effectiveTenantId(req);
const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 10;
const contribuyenteId = req.query.contribuyenteId as string | undefined;
const result = await getSyncHistory(tenantId, page, limit, contribuyenteId || undefined);
res.json({
...result,
page,
limit,
});
} catch (error: any) {
console.error('[SAT Controller] Error en history:', error);
res.status(500).json({ error: 'Error interno del servidor' });
}
}
/**
* Obtiene detalle de un job específico
*/
export async function jobDetail(req: Request, res: Response): Promise<void> {
try {
const tenantId = effectiveTenantId(req);
const { id } = req.params;
const { jobs } = await getSyncHistory(tenantId, 1, 100);
const job = jobs.find(j => j.id === id);
if (!job) {
res.status(404).json({ error: 'Job no encontrado' });
return;
}
res.json(job);
} catch (error: any) {
console.error('[SAT Controller] Error en jobDetail:', error);
res.status(500).json({ error: 'Error interno del servidor' });
}
}
/**
* Reintenta un job fallido
*/
export async function retry(req: Request, res: Response): Promise<void> {
try {
const id = req.params.id as string;
const newJobId = await retryJob(id);
res.json({
jobId: newJobId,
message: 'Job reintentado',
});
} catch (error: any) {
console.error('[SAT Controller] Error en retry:', error);
if (error.message.includes('no encontrado') || error.message.includes('Solo se pueden')) {
res.status(400).json({ error: error.message });
return;
}
res.status(500).json({ error: 'Error interno del servidor' });
}
}
/**
* Obtiene información del job programado (solo admin global)
*/
export async function cronInfo(req: Request, res: Response): Promise<void> {
try {
if (!(await isGlobalAdmin(req.user!.tenantId, req.user!.role))) {
res.status(403).json({ error: 'Solo el administrador global puede ver info del cron' });
return;
}
const info = getJobInfo();
res.json(info);
} catch (error: any) {
console.error('[SAT Controller] Error en cronInfo:', error);
res.status(500).json({ error: 'Error interno del servidor' });
}
}
/**
* Ejecuta el job de sincronización manualmente (solo admin global)
*/
export async function runCron(req: Request, res: Response): Promise<void> {
try {
if (!(await isGlobalAdmin(req.user!.tenantId, req.user!.role))) {
res.status(403).json({ error: 'Solo el administrador global puede ejecutar el cron' });
return;
}
// Ejecutar en background
runSatSyncJobManually().catch(err =>
console.error('[SAT Controller] Error ejecutando cron manual:', err)
);
res.json({ message: 'Job de sincronización iniciado' });
} catch (error: any) {
console.error('[SAT Controller] Error en runCron:', error);
res.status(500).json({ error: 'Error interno del servidor' });
}
}

View File

@@ -0,0 +1,419 @@
import type { Request, Response, NextFunction } from 'express';
import * as subscriptionService from '../services/payment/subscription.service.js';
import { listActiveAddons, subscribeAddon, cancelAddon } from '../services/payment/addon.service.js';
import { isGlobalAdmin } from '../utils/global-admin.js';
import { auditFromReq } from '../utils/audit.js';
async function requireGlobalAdmin(req: Request, res: Response): Promise<boolean> {
const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role);
if (!isAdmin) {
res.status(403).json({ message: 'Solo el administrador global puede gestionar suscripciones' });
}
return isAdmin;
}
/**
* Permite si el usuario es admin global O si está consultando su propio tenant.
* Úsalo para endpoints de lectura/acción sobre la suscripción del mismo tenant
* del usuario (ver estado, generar link de pago pendiente).
*/
async function requireOwnTenantOrGlobalAdmin(req: Request, res: Response, targetTenantId: string): Promise<boolean> {
if (targetTenantId === req.user!.tenantId) return true;
const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role);
if (!isAdmin) {
res.status(403).json({ message: 'Solo puedes gestionar la suscripción de tu propio tenant' });
}
return isAdmin;
}
/**
* Devuelve los precios vigentes de los planes self-serve (excluye custom).
* Cualquier admin/cfo puede consultarlo — no requiere admin global.
*/
export async function getPlans(_req: Request, res: Response, next: NextFunction) {
try {
const { prisma } = await import('../config/database.js');
const prices = await prisma.planPrice.findMany({
orderBy: [{ plan: 'asc' }, { frequency: 'asc' }],
});
res.json(prices);
} catch (error) {
next(error);
}
}
/**
* Actualiza el precio de un plan (por id). Solo admin global.
* El nuevo precio aplica solo a suscripciones **nuevas o renovaciones futuras**
* — suscripciones ya vigentes conservan el precio al que las contrataron.
*/
export async function updatePlanPrice(req: Request, res: Response, next: NextFunction) {
try {
if (!(await requireGlobalAdmin(req, res))) return;
const id = parseInt(String(req.params.id), 10);
if (!Number.isFinite(id)) {
return res.status(400).json({ message: 'ID inválido' });
}
const amount = Number(req.body?.amount);
if (!Number.isFinite(amount) || amount < 0) {
return res.status(400).json({ message: 'El monto debe ser un número no negativo' });
}
const { prisma } = await import('../config/database.js');
const before = await prisma.planPrice.findUnique({ where: { id } });
if (!before) return res.status(404).json({ message: 'Precio no encontrado' });
const updated = await prisma.planPrice.update({
where: { id },
data: { amount },
});
auditFromReq(req, 'price.updated', {
entityType: 'PlanPrice',
entityId: String(id),
metadata: {
plan: before.plan,
frequency: before.frequency,
fromAmount: Number(before.amount),
toAmount: amount,
},
});
res.json(updated);
} catch (error: any) {
if (error?.code === 'P2025') {
return res.status(404).json({ message: 'Precio no encontrado' });
}
next(error);
}
}
export async function getAllSubscriptions(req: Request, res: Response, next: NextFunction) {
try {
if (!(await requireGlobalAdmin(req, res))) return;
const { prisma } = await import('../config/database.js');
const subscriptions = await prisma.subscription.findMany({
include: {
tenant: {
select: { id: true, nombre: true, rfc: true, plan: true, active: true },
},
},
orderBy: { createdAt: 'desc' },
});
res.json(subscriptions);
} catch (error) {
next(error);
}
}
export async function getSubscription(req: Request, res: Response, next: NextFunction) {
try {
const tenantId = String(req.params.tenantId);
if (!(await requireOwnTenantOrGlobalAdmin(req, res, tenantId))) return;
const subscription = await subscriptionService.getActiveSubscription(tenantId);
if (!subscription) {
return res.status(404).json({ message: 'No se encontró suscripción' });
}
res.json(subscription);
} catch (error) {
next(error);
}
}
export async function generatePaymentLink(req: Request, res: Response, next: NextFunction) {
try {
const tenantId = String(req.params.tenantId);
if (!(await requireOwnTenantOrGlobalAdmin(req, res, tenantId))) return;
const result = await subscriptionService.generatePaymentLink(tenantId);
res.json(result);
} catch (error) {
next(error);
}
}
export async function markAsPaid(req: Request, res: Response, next: NextFunction) {
try {
if (!(await requireGlobalAdmin(req, res))) return;
const tenantId = String(req.params.tenantId);
const { amount } = req.body;
if (!amount || amount <= 0) {
return res.status(400).json({ message: 'Monto inválido' });
}
const payment = await subscriptionService.markAsPaidManually(tenantId, amount);
res.json(payment);
} catch (error) {
next(error);
}
}
export async function getPayments(req: Request, res: Response, next: NextFunction) {
try {
const tenantId = String(req.params.tenantId);
if (!(await requireOwnTenantOrGlobalAdmin(req, res, tenantId))) return;
const payments = await subscriptionService.getPaymentHistory(tenantId);
res.json(payments);
} catch (error) {
next(error);
}
}
// ============================================================================
// Self-serve endpoints (actúan sobre el tenant del usuario autenticado)
// ============================================================================
type FrequencyInput = 'monthly' | 'annual';
const VALID_PLANS = [
'starter', 'business', 'business_ia', 'enterprise',
'business_control', 'business_cloud',
'mi_empresa', 'mi_empresa_plus',
] as const;
// Planes despacho que se cobran SOLO anual. Mi Empresa y Mi Empresa+ aceptan
// monthly o annual (annual con descuento ~17% — paga 10 meses); Business
// Control y Enterprise siguen exclusivamente anuales.
const DESPACHO_ONLY_ANNUAL = new Set([
'business_control', 'business_cloud',
]);
function validatePlanFrequency(body: any): { plan: typeof VALID_PLANS[number]; frequency: FrequencyInput } | { error: string } {
const plan = body?.plan;
const frequency = body?.frequency;
if (!plan || !VALID_PLANS.includes(plan)) {
return { error: `plan inválido. Valores aceptados: ${VALID_PLANS.join(', ')}` };
}
if (frequency !== 'monthly' && frequency !== 'annual') {
return { error: `frequency inválida. Debe ser 'monthly' o 'annual'` };
}
if (DESPACHO_ONLY_ANNUAL.has(plan) && frequency !== 'annual') {
return { error: `El plan ${plan} solo está disponible con frecuencia anual` };
}
return { plan, frequency };
}
export async function startMyTrial(req: Request, res: Response, next: NextFunction) {
try {
const parsed = validatePlanFrequency(req.body);
if ('error' in parsed) return res.status(400).json({ message: parsed.error });
const result = await subscriptionService.startTrial({
tenantId: req.user!.tenantId,
plan: parsed.plan,
frequency: parsed.frequency,
ownerUserId: req.user!.userId,
});
res.status(201).json(result);
} catch (error: any) {
if (
error.message?.includes('ya usó') ||
error.message?.includes('Ya existe') ||
error.message?.includes('no se puede') ||
error.message?.includes('Ya consumiste') ||
error.message?.includes('ya consumió')
) {
return res.status(400).json({ message: error.message });
}
next(error);
}
}
export async function subscribeMe(req: Request, res: Response, next: NextFunction) {
try {
const parsed = validatePlanFrequency(req.body);
if ('error' in parsed) return res.status(400).json({ message: parsed.error });
const result = await subscriptionService.subscribe({
tenantId: req.user!.tenantId,
plan: parsed.plan,
frequency: parsed.frequency,
payerEmail: req.user!.email,
});
res.status(201).json(result);
} catch (error: any) {
const msg: string = error?.message || '';
if (msg.includes('Ya existe') || msg.includes('custom')) {
return res.status(400).json({ message: msg });
}
if (msg.includes('MercadoPago no está configurado')) {
return res.status(503).json({ message: msg });
}
// Otros errores de MP al crear preapproval (monto inválido, email inválido, etc.)
if (msg.includes('Unauthorized access') || error?.status === 401) {
return res.status(503).json({
message: 'MercadoPago rechazó la solicitud. Verifica que MP_ACCESS_TOKEN sea válido y esté vigente.',
});
}
next(error);
}
}
export async function changeMyPlan(req: Request, res: Response, next: NextFunction) {
try {
const parsed = validatePlanFrequency(req.body);
if ('error' in parsed) return res.status(400).json({ message: parsed.error });
const result = await subscriptionService.scheduleChange({
tenantId: req.user!.tenantId,
newPlan: parsed.plan,
newFrequency: parsed.frequency,
});
res.json(result);
} catch (error: any) {
if (error.message?.includes('iguales') || error.message?.includes('No hay') || error.message?.includes('custom')) {
return res.status(400).json({ message: error.message });
}
next(error);
}
}
export async function cancelMySubscription(req: Request, res: Response, next: NextFunction) {
try {
const result = await subscriptionService.cancelSubscription(req.user!.tenantId);
res.json(result);
} catch (error: any) {
if (error.message?.includes('No hay')) {
return res.status(400).json({ message: error.message });
}
next(error);
}
}
/**
* Reactiva suscripción cancelada que aún está dentro de su período pagado.
* Crea un preapproval nuevo en MP con start_date al final del período actual.
* Retorna paymentUrl para que el usuario autorice.
*/
export async function reactivateMe(req: Request, res: Response, next: NextFunction) {
try {
const result = await subscriptionService.reactivateSubscription({
tenantId: req.user!.tenantId,
payerEmail: req.user!.email,
});
res.status(201).json(result);
} catch (error: any) {
const msg: string = error?.message || '';
if (msg.includes('No hay') || msg.includes('vencido') || msg.includes('custom')) {
return res.status(400).json({ message: msg });
}
if (msg.includes('MercadoPago no está configurado')) {
return res.status(503).json({ message: msg });
}
if (msg.includes('Unauthorized access') || error?.status === 401) {
return res.status(503).json({
message: 'MercadoPago rechazó la solicitud. Verifica que MP_ACCESS_TOKEN sea válido.',
});
}
next(error);
}
}
/**
* Inicia un upgrade con cobro prorateado inmediato. Body: `{ plan }`.
* La frecuencia actual se preserva — para cambiar frecuencia usa `/me/change`.
* Retorna `{ checkoutUrl, proratedAmount }` — el cliente debe abrir la URL para que
* el usuario pague en MP. Al confirmarse el pago (webhook), se aplica el plan nuevo.
*/
export async function upgradeMe(req: Request, res: Response, next: NextFunction) {
try {
const plan = req.body?.plan;
if (!plan || !VALID_PLANS.includes(plan)) {
return res.status(400).json({ message: `plan inválido. Valores: ${VALID_PLANS.join(', ')}` });
}
const result = await subscriptionService.initiateUpgrade({
tenantId: req.user!.tenantId,
newPlan: plan,
payerEmail: req.user!.email,
});
res.status(201).json(result);
} catch (error: any) {
const msg: string = error?.message || '';
if (
msg.includes('No hay suscripción') ||
msg.includes('en curso') ||
msg.includes('no es un upgrade') ||
msg.includes('días restantes') ||
msg.includes('custom') ||
msg.includes('Precio no configurado')
) {
return res.status(400).json({ message: msg });
}
if (msg.includes('MercadoPago no está configurado')) {
return res.status(503).json({ message: msg });
}
if (msg.includes('Unauthorized access') || error?.status === 401) {
return res.status(503).json({
message: 'MercadoPago rechazó la solicitud. Verifica que MP_ACCESS_TOKEN sea válido.',
});
}
next(error);
}
}
export async function cancelMyPendingUpgrade(req: Request, res: Response, next: NextFunction) {
try {
await subscriptionService.cancelPendingUpgrade(req.user!.tenantId);
res.json({ ok: true });
} catch (error: any) {
if (error.message?.includes('No hay upgrade')) {
return res.status(400).json({ message: error.message });
}
next(error);
}
}
// ============================================================================
// Addon endpoints (self-serve)
// ============================================================================
export async function getMyAddons(req: Request, res: Response, next: NextFunction) {
try {
// Query param `contribuyenteId` opcional: filtra al contribuyente específico.
// Sin param → retorna todos los add-ons del tenant (incluye los de todos los RFCs).
const contribuyenteId = typeof req.query.contribuyenteId === 'string' && req.query.contribuyenteId
? req.query.contribuyenteId
: undefined;
const result = await listActiveAddons(req.user!.tenantId, contribuyenteId);
return res.json(result);
} catch (err) { return next(err); }
}
export async function addMyAddon(req: Request, res: Response, next: NextFunction) {
try {
const { addonCodename, quantity, contribuyenteId } = req.body;
if (!addonCodename) return res.status(400).json({ message: 'addonCodename requerido' });
const result = await subscribeAddon({
tenantId: req.user!.tenantId,
addonCodename,
quantity: quantity || 1,
payerEmail: req.user!.email,
contribuyenteId: typeof contribuyenteId === 'string' ? contribuyenteId : null,
});
return res.status(201).json(result);
} catch (err: any) {
if (err.message?.includes('no disponible') || err.message?.includes('Ya tienes')) {
return res.status(409).json({ message: err.message });
}
return next(err);
}
}
export async function cancelMyAddon(req: Request, res: Response, next: NextFunction) {
try {
await cancelAddon(req.user!.tenantId, String(req.params.addonId));
return res.json({ message: 'Addon cancelado' });
} catch (err: any) {
if (err.message?.includes('no encontrado')) {
return res.status(404).json({ message: err.message });
}
return next(err);
}
}

View File

@@ -0,0 +1,177 @@
import type { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { AppError } from '../middlewares/error.middleware.js';
import * as tareasService from '../services/tareas.service.js';
import { emailService } from '../services/email/email.service.js';
import { getUserEmailById } from '../utils/memberships.js';
import { env } from '../config/env.js';
import { prisma } from '../config/database.js';
/**
* Bloquea a usuarios rol `cliente` de cualquier endpoint de tareas.
* El cliente no debe ver tareas operativas internas del despacho.
*/
function rejectClienteRole(req: Request): void {
if (req.user?.role === 'cliente') {
throw new AppError(403, 'Tareas no disponibles para usuarios cliente');
}
}
const RECURRENCIAS = ['semanal', 'quincenal', 'mensual', 'bimestral', 'trimestral', 'semestral', 'anual'] as const;
const tareaSchema = z.object({
nombre: z.string().min(1).max(200),
descripcion: z.string().max(1000).nullable().optional(),
recurrencia: z.enum(RECURRENCIAS),
diaSemana: z.number().int().min(1).max(7).nullable().optional(),
diaMes: z.number().int().min(1).max(31).nullable().optional(),
soloSupervisorCompleta: z.boolean().optional(),
orden: z.number().int().optional(),
});
export async function listTareas(req: Request, res: Response, next: NextFunction) {
try {
rejectClienteRole(req);
const contribuyenteId = req.query.contribuyenteId as string | undefined;
if (!contribuyenteId) return next(new AppError(400, 'contribuyenteId requerido'));
const tareas = await tareasService.listTareasConPeriodoActual(req.tenantPool!, contribuyenteId);
res.json(tareas);
} catch (error) {
next(error);
}
}
export async function createTarea(req: Request, res: Response, next: NextFunction) {
try {
rejectClienteRole(req);
const contribuyenteId = req.query.contribuyenteId as string | undefined;
if (!contribuyenteId) return next(new AppError(400, 'contribuyenteId requerido'));
const data = tareaSchema.parse(req.body);
const tarea = await tareasService.createTarea(req.tenantPool!, contribuyenteId, data);
res.status(201).json(tarea);
} catch (error) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error);
}
}
export async function updateTarea(req: Request, res: Response, next: NextFunction) {
try {
rejectClienteRole(req);
const data = tareaSchema.partial().parse(req.body);
const updated = await tareasService.updateTarea(req.tenantPool!, String(req.params.id), data);
if (!updated) return next(new AppError(404, 'Tarea no encontrada'));
res.json(updated);
} catch (error) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error);
}
}
export async function deleteTarea(req: Request, res: Response, next: NextFunction) {
try {
rejectClienteRole(req);
const ok = await tareasService.deleteTarea(req.tenantPool!, String(req.params.id));
if (!ok) return next(new AppError(404, 'Tarea no encontrada'));
res.status(204).send();
} catch (error) {
next(error);
}
}
export async function completarPeriodo(req: Request, res: Response, next: NextFunction) {
try {
rejectClienteRole(req);
const { notas } = z.object({ notas: z.string().max(1000).nullable().optional() }).parse(req.body);
const result = await tareasService.completarPeriodo(
req.tenantPool!,
String(req.params.id),
req.user!.userId,
req.user!.role,
notas ?? null,
);
if (!result) return next(new AppError(404, 'Periodo no encontrado'));
// Notificar al auxiliar de la cartera SOLO cuando una tarea con
// solo_supervisor_completa=true fue marcada como completada por
// un supervisor/owner. Fire-and-forget — no bloquea la respuesta.
if (result.tarea.soloSupervisorCompleta) {
notifyAuxiliarTareaCompletada(req, result).catch(err =>
console.error('[tareas.completar] notify auxiliar failed:', err?.message || err),
);
}
res.json(result);
} catch (error: any) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
if (error?.message?.startsWith('Solo supervisor')) return next(new AppError(403, error.message));
next(error);
}
}
async function notifyAuxiliarTareaCompletada(
req: Request,
result: { periodo: tareasService.TareaPeriodo; tarea: tareasService.TareaCatalogo },
): Promise<void> {
const auxiliarUserId = await tareasService.getAuxiliarUserIdDeContribuyente(
req.tenantPool!,
result.tarea.contribuyenteId,
);
if (!auxiliarUserId) return;
if (auxiliarUserId === req.user!.userId) return; // no notificarse a sí mismo
const auxiliarEmail = await getUserEmailById(auxiliarUserId);
if (!auxiliarEmail) return;
// Datos del contribuyente y supervisor para el email
const { rows } = await req.tenantPool!.query<{ rfc: string; nombre: string }>(
`SELECT c.rfc, eg.nombre
FROM contribuyentes c
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
WHERE c.entidad_id = $1`,
[result.tarea.contribuyenteId],
);
if (rows.length === 0) return;
const auxiliarNombre = (await prisma.user.findUnique({
where: { id: auxiliarUserId },
select: { nombre: true },
}))?.nombre || 'Auxiliar';
const fechaLimite = result.periodo.fechaLimite instanceof Date
? result.periodo.fechaLimite.toLocaleDateString('es-MX', { dateStyle: 'long' })
: new Date(String(result.periodo.fechaLimite)).toLocaleDateString('es-MX', { dateStyle: 'long' });
await emailService.sendTareaCompletada(auxiliarEmail, {
destinatarioNombre: auxiliarNombre,
contribuyenteNombre: rows[0].nombre,
contribuyenteRfc: rows[0].rfc,
tareaNombre: result.tarea.nombre,
tareaDescripcion: result.tarea.descripcion,
completadaPor: req.user!.email,
notas: result.periodo.notas,
fechaLimite,
link: `${env.FRONTEND_URL}/configuracion/obligaciones`,
});
}
export async function descompletarPeriodo(req: Request, res: Response, next: NextFunction) {
try {
rejectClienteRole(req);
const ok = await tareasService.descompletarPeriodo(req.tenantPool!, String(req.params.id));
if (!ok) return next(new AppError(404, 'Periodo no encontrado'));
res.status(204).send();
} catch (error) {
next(error);
}
}
export async function seedDefaults(req: Request, res: Response, next: NextFunction) {
try {
rejectClienteRole(req);
const contribuyenteId = req.query.contribuyenteId as string | undefined;
if (!contribuyenteId) return next(new AppError(400, 'contribuyenteId requerido'));
const created = await tareasService.seedTareasDefault(req.tenantPool!, contribuyenteId);
res.json({ created });
} catch (error) {
next(error);
}
}

View File

@@ -0,0 +1,148 @@
import { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import * as tenantsService from '../services/tenants.service.js';
import { AppError } from '../middlewares/error.middleware.js';
import { isGlobalAdmin } from '../utils/global-admin.js';
import { isOwnerSomewhere } from '../utils/memberships.js';
async function requireGlobalAdmin(req: Request): Promise<void> {
if (!(await isGlobalAdmin(req.user!.tenantId, req.user!.role))) {
throw new AppError(403, 'Solo el administrador global puede gestionar clientes');
}
}
export async function getAllTenants(req: Request, res: Response, next: NextFunction) {
try {
await requireGlobalAdmin(req);
const tenants = await tenantsService.getAllTenants();
res.json(tenants);
} catch (error) {
next(error);
}
}
export async function getTenant(req: Request, res: Response, next: NextFunction) {
try {
await requireGlobalAdmin(req);
const tenant = await tenantsService.getTenantById(String(req.params.id));
if (!tenant) {
throw new AppError(404, 'Cliente no encontrado');
}
res.json(tenant);
} catch (error) {
next(error);
}
}
export async function createTenant(req: Request, res: Response, next: NextFunction) {
try {
await requireGlobalAdmin(req);
const { nombre, rfc, plan, cfdiLimit, usersLimit, adminEmail, adminNombre, amount } = req.body;
if (!nombre || !rfc || !adminEmail || !adminNombre) {
throw new AppError(400, 'Nombre, RFC, adminEmail y adminNombre son requeridos');
}
const result = await tenantsService.createTenant({
nombre,
rfc,
plan,
cfdiLimit,
usersLimit,
adminEmail,
adminNombre,
amount: amount || 0,
});
res.status(201).json(result);
} catch (error) {
next(error);
}
}
export async function updateTenant(req: Request, res: Response, next: NextFunction) {
try {
await requireGlobalAdmin(req);
const id = String(req.params.id);
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 {
await requireGlobalAdmin(req);
await tenantsService.deleteTenant(String(req.params.id));
res.status(204).send();
} catch (error) {
next(error);
}
}
// ============================================================================
// Self-serve (multi-tenant memberships)
// ============================================================================
/**
* Lista detallada de empresas del caller con estado de suscripción. Usado por
* `/mis-empresas`. A diferencia de `/auth/me`, incluye datos de subscription
* (status, currentPeriodEnd, pendingPlan, etc.).
*/
export async function getMyTenants(req: Request, res: Response, next: NextFunction) {
try {
const data = await tenantsService.getMyTenantsDetailed(req.user!.userId);
res.json(data);
} catch (error) { next(error); }
}
const addTenantSchema = z.object({
nombre: z.string().min(2, 'Nombre de empresa requerido'),
rfc: z.string().min(12).max(13, 'RFC inválido'),
plan: z.enum(['starter', 'business', 'business_ia', 'enterprise']).optional(),
});
/**
* Agrega una empresa (tenant nuevo) bajo el user autenticado. El caller se
* vuelve owner automáticamente vía TenantMembership.
*/
export async function addMyTenant(req: Request, res: Response, next: NextFunction) {
try {
const data = addTenantSchema.parse(req.body);
// Gate: solo users que son owner en al menos un tenant pueden agregar
// un RFC adicional. Un contador invitado a una empresa ajena no puede.
if (!(await isOwnerSomewhere(req.user!.userId))) {
throw new AppError(403, 'Solo los dueños pueden registrar empresas adicionales.');
}
const result = await tenantsService.addTenantToOwner({
userId: req.user!.userId,
nombre: data.nombre,
rfc: data.rfc,
plan: data.plan,
});
res.status(201).json({ tenant: result.tenant });
} catch (error) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
if (error instanceof Error && error.message.includes('RFC')) {
return next(new AppError(400, error.message));
}
next(error);
}
}

View File

@@ -0,0 +1,273 @@
import { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import * as usuariosService from '../services/usuarios.service.js';
import { AppError } from '../middlewares/error.middleware.js';
import { isGlobalAdmin as checkGlobalAdmin } from '../utils/global-admin.js';
const inviteSchema = z.object({
email: z.string().email('email inválido'),
nombre: z.string().min(2).max(100),
// Legacy Horux360 roles + Despacho-specific roles
role: z.enum(['contador', 'visor', 'auxiliar', 'supervisor', 'cliente']),
supervisorUserId: z.string().uuid().optional(), // Required when role=auxiliar
});
const updateSchema = z.object({
nombre: z.string().min(2).max(100).optional(),
// Legacy Horux360 roles + Despacho-specific roles
role: z.enum(['contador', 'visor', 'auxiliar', 'supervisor', 'cliente']).optional(),
active: z.boolean().optional(),
});
const updateGlobalSchema = z.object({
nombre: z.string().min(2).max(100).optional(),
role: z.enum(['owner', 'cfo', 'contador', 'visor', 'auxiliar', 'supervisor', 'cliente']).optional(),
active: z.boolean().optional(),
tenantId: z.string().uuid().optional(),
});
async function isGlobalAdmin(req: Request): Promise<boolean> {
return checkGlobalAdmin(req.user!.tenantId, req.user!.role);
}
export async function getUsuarios(req: Request, res: Response, next: NextFunction) {
try {
const usuarios = await usuariosService.getUsuarios(req.user!.tenantId);
res.json(usuarios);
} catch (error) {
next(error);
}
}
/**
* Obtiene todos los usuarios de todas las empresas (solo admin global)
*/
export async function getAllUsuarios(req: Request, res: Response, next: NextFunction) {
try {
if (!(await isGlobalAdmin(req))) {
throw new AppError(403, 'Solo el administrador global puede ver todos los usuarios');
}
const usuarios = await usuariosService.getAllUsuarios();
res.json(usuarios);
} catch (error) {
next(error);
}
}
export async function inviteUsuario(req: Request, res: Response, next: NextFunction) {
try {
if (req.user!.role !== 'owner') {
throw new AppError(403, 'Solo los dueños pueden invitar usuarios');
}
const data = inviteSchema.parse(req.body);
// Validate: auxiliar requires a supervisor
if (data.role === 'auxiliar' && !data.supervisorUserId) {
throw new AppError(400, 'Debes asignar un supervisor al auxiliar');
}
const usuario = await usuariosService.inviteUsuario(req.user!.tenantId, data);
// Store auxiliar→supervisor relationship in tenant DB
if (data.role === 'auxiliar' && data.supervisorUserId && req.tenantPool) {
await req.tenantPool.query(
`INSERT INTO auxiliar_supervisores (auxiliar_user_id, supervisor_user_id)
VALUES ($1, $2) ON CONFLICT (auxiliar_user_id) DO UPDATE SET supervisor_user_id = $2`,
[usuario.id, data.supervisorUserId],
);
}
res.status(201).json(usuario);
} catch (error) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error);
}
}
export async function updateUsuario(req: Request, res: Response, next: NextFunction) {
try {
if (req.user!.role !== 'owner') {
throw new AppError(403, 'Solo los dueños pueden modificar usuarios');
}
const userId = req.params.id as string;
const data = updateSchema.parse(req.body);
const usuario = await usuariosService.updateUsuario(req.user!.tenantId, userId, data);
res.json(usuario);
} catch (error) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error);
}
}
/**
* Lee el supervisor actualmente asignado a un auxiliar. Resuelve desde 3
* fuentes (en orden de prioridad):
* 1. `auxiliar_supervisores` (override explícito del owner desde /usuarios).
* 2. Cartera donde el user es `auxiliar_user_id` y la misma tiene supervisor.
* 3. Subcartera donde el user es `auxiliar_user_id`; el supervisor viene
* del cartera padre.
*
* Devuelve `null` si no aparece en ninguna.
*/
export async function getSupervisor(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantPool) throw new AppError(500, 'Tenant pool no disponible');
const userId = String(req.params.id);
const { rows } = await req.tenantPool.query<{ supervisor_user_id: string }>(
`SELECT supervisor_user_id FROM (
SELECT supervisor_user_id, 1 AS prio FROM auxiliar_supervisores
WHERE auxiliar_user_id = $1
UNION ALL
SELECT supervisor_user_id, 2 AS prio FROM carteras
WHERE auxiliar_user_id = $1 AND supervisor_user_id IS NOT NULL
UNION ALL
SELECT p.supervisor_user_id, 3 AS prio
FROM carteras sub
JOIN carteras p ON p.id = sub.parent_id
WHERE sub.auxiliar_user_id = $1 AND p.supervisor_user_id IS NOT NULL
) t
WHERE supervisor_user_id IS NOT NULL
ORDER BY prio
LIMIT 1`,
[userId],
);
res.json({ supervisorUserId: rows[0]?.supervisor_user_id ?? null });
} catch (error) {
next(error);
}
}
const supervisorSchema = z.object({
supervisorUserId: z.string().uuid().nullable(),
});
/**
* Asigna o elimina el supervisor de un auxiliar (BD tenant).
* Solo owner/cfo. Pasar `null` borra la asignación.
*/
export async function updateSupervisor(req: Request, res: Response, next: NextFunction) {
try {
if (req.user!.role !== 'owner' && req.user!.role !== 'cfo') {
throw new AppError(403, 'Solo el owner puede asignar supervisores');
}
if (!req.tenantPool) throw new AppError(500, 'Tenant pool no disponible');
const userId = String(req.params.id);
const { supervisorUserId } = supervisorSchema.parse(req.body);
if (supervisorUserId === null) {
await req.tenantPool.query(
`DELETE FROM auxiliar_supervisores WHERE auxiliar_user_id = $1`,
[userId],
);
} else {
await req.tenantPool.query(
`INSERT INTO auxiliar_supervisores (auxiliar_user_id, supervisor_user_id)
VALUES ($1, $2)
ON CONFLICT (auxiliar_user_id) DO UPDATE SET supervisor_user_id = $2`,
[userId, supervisorUserId],
);
}
res.json({ supervisorUserId });
} catch (error) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error);
}
}
export async function deleteUsuario(req: Request, res: Response, next: NextFunction) {
try {
if (req.user!.role !== 'owner') {
throw new AppError(403, 'Solo los dueños pueden eliminar usuarios');
}
const userId = req.params.id as string;
if (userId === req.user!.userId) {
throw new AppError(400, 'No puedes eliminar tu propia cuenta');
}
await usuariosService.deleteUsuario(req.user!.tenantId, userId);
res.status(204).send();
} catch (error) {
next(error);
}
}
/**
* Actualiza un usuario globalmente (puede cambiar de empresa)
*/
export async function updateUsuarioGlobal(req: Request, res: Response, next: NextFunction) {
try {
if (!(await isGlobalAdmin(req))) {
throw new AppError(403, 'Solo el administrador global puede modificar usuarios globalmente');
}
const userId = req.params.id as string;
const data = updateGlobalSchema.parse(req.body);
if (userId === req.user!.userId && data.tenantId) {
throw new AppError(400, 'No puedes cambiar tu propia empresa');
}
const usuario = await usuariosService.updateUsuarioGlobal(userId, data);
res.json(usuario);
} catch (error) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error);
}
}
/**
* Elimina un usuario globalmente
*/
export async function deleteUsuarioGlobal(req: Request, res: Response, next: NextFunction) {
try {
if (!(await isGlobalAdmin(req))) {
throw new AppError(403, 'Solo el administrador global puede eliminar usuarios globalmente');
}
const userId = req.params.id as string;
if (userId === req.user!.userId) {
throw new AppError(400, 'No puedes eliminar tu propia cuenta');
}
await usuariosService.deleteUsuarioGlobal(userId);
res.status(204).send();
} catch (error) {
next(error);
}
}
/**
* Get cliente accesos (which contribuyentes a client user can access)
*/
export async function getClienteAccesos(req: Request, res: Response, next: NextFunction) {
try {
if (req.user!.role !== 'owner') throw new AppError(403, 'No autorizado');
const userId = req.params.id as string;
const { rows } = await req.tenantPool!.query(
'SELECT entidad_id AS "entidadId" FROM cliente_accesos WHERE user_id = $1',
[userId],
);
res.json({ data: rows.map(r => r.entidadId) });
} catch (error) { next(error); }
}
/**
* Set cliente accesos (replace all accesos for a client user)
*/
export async function setClienteAccesos(req: Request, res: Response, next: NextFunction) {
try {
if (req.user!.role !== 'owner') throw new AppError(403, 'No autorizado');
const userId = req.params.id as string;
const { entidadIds } = z.object({
entidadIds: z.array(z.string().uuid()),
}).parse(req.body);
// Replace all accesos
await req.tenantPool!.query('DELETE FROM cliente_accesos WHERE user_id = $1', [userId]);
for (const entidadId of entidadIds) {
await req.tenantPool!.query(
'INSERT INTO cliente_accesos (user_id, entidad_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
[userId, entidadId],
);
}
res.json({ data: entidadIds });
} catch (error) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error);
}
}

View File

@@ -0,0 +1,218 @@
import type { Request, Response, NextFunction } from 'express';
import * as mpService from '../services/payment/mercadopago.service.js';
import * as subscriptionService from '../services/payment/subscription.service.js';
import * as invoicingService from '../services/payment/invoicing.service.js';
import * as facturapiService from '../services/facturapi.service.js';
import { handleAddonPayment } from '../services/payment/addon.service.js';
import { prisma } from '../config/database.js';
import { isDespachoPaidPlan, despachoPlanTieneDualidad } from '@horux/shared';
export async function handleMercadoPagoWebhook(req: Request, res: Response, next: NextFunction) {
try {
const { type, data } = req.body;
const xSignature = req.headers['x-signature'] as string;
const xRequestId = req.headers['x-request-id'] as string;
// Verify webhook signature (mandatory)
if (!xSignature || !xRequestId || !data?.id) {
console.warn('[WEBHOOK] Missing signature headers');
return res.status(401).json({ message: 'Missing signature headers' });
}
const isValid = mpService.verifyWebhookSignature(xSignature, xRequestId, String(data.id));
if (!isValid) {
console.warn('[WEBHOOK] Invalid MercadoPago signature');
return res.status(401).json({ message: 'Invalid signature' });
}
if (type === 'payment') {
await handlePaymentNotification(String(data.id));
} else if (type === 'subscription_preapproval') {
await handlePreapprovalNotification(String(data.id));
}
// Always respond 200 to acknowledge receipt
res.status(200).json({ received: true });
} catch (error) {
console.error('[WEBHOOK] Error processing MercadoPago webhook:', error);
// Still respond 200 to prevent retries for processing errors
res.status(200).json({ received: true, error: 'processing_error' });
}
}
async function handlePaymentNotification(paymentId: string) {
const payment = await mpService.getPaymentDetails(paymentId);
if (!payment.externalReference) {
console.warn('[WEBHOOK] Payment without external_reference:', paymentId);
return;
}
// Detecta compras de paquete de timbres. external_reference = `timbres-pack:{paymentId}`
if (payment.externalReference.startsWith('timbres-pack:')) {
const localPaymentId = payment.externalReference.split(':')[1];
if (!localPaymentId) {
console.warn('[WEBHOOK] external_reference timbres-pack malformado:', payment.externalReference);
return;
}
await prisma.payment.update({
where: { id: localPaymentId },
data: {
status: payment.status || 'unknown',
mpPaymentId: paymentId,
paidAt: payment.status === 'approved' ? new Date() : null,
},
});
if (payment.status === 'approved') {
try {
await facturapiService.activarPaqueteTrasPago(localPaymentId);
} catch (error: any) {
console.error('[WEBHOOK] Error activando paquete de timbres:', error.message);
throw error; // que MP reintente
}
// Auto-emisión de factura (fail-soft)
await invoicingService.emitInvoiceIfApplicable(localPaymentId);
}
if (typeof process.send === 'function') {
const pay = await prisma.payment.findUnique({ where: { id: localPaymentId }, select: { tenantId: true } });
if (pay) process.send({ type: 'invalidate-tenant-cache', tenantId: pay.tenantId });
}
return;
}
// Detecta pagos de prorateo (upgrade). external_reference = `proration:${tenantId}:${subscriptionId}`
if (payment.externalReference.startsWith('proration:')) {
const parts = payment.externalReference.split(':');
const tenantId = parts[1];
const subscriptionId = parts[2];
if (!tenantId || !subscriptionId) {
console.warn('[WEBHOOK] external_reference de proration malformado:', payment.externalReference);
return;
}
const paymentRecord = await subscriptionService.recordPayment({
tenantId,
subscriptionId,
mpPaymentId: paymentId,
amount: payment.transactionAmount || 0,
status: payment.status || 'unknown',
paymentMethod: `proration-${payment.paymentMethodId || 'unknown'}`,
});
if (payment.status === 'approved') {
try {
await subscriptionService.applyApprovedUpgrade(subscriptionId);
} catch (error: any) {
// Re-lanza para que MP reintente el webhook
console.error('[WEBHOOK] Error aplicando upgrade:', error.message);
throw error;
}
// Auto-emisión de factura (fail-soft, no bloquea ni tira)
await invoicingService.emitInvoiceIfApplicable(paymentRecord.id);
}
if (typeof process.send === 'function') {
process.send({ type: 'invalidate-tenant-cache', tenantId });
}
return;
}
// Detecta pagos de addon. external_reference = `addon:{subscriptionAddonId}`
if (payment.externalReference.startsWith('addon:')) {
const addonId = payment.externalReference.replace('addon:', '');
if (!addonId) {
console.warn('[WEBHOOK] external_reference addon malformado:', payment.externalReference);
return;
}
await handleAddonPayment(addonId, String(paymentId), payment.status || 'unknown');
// Continue to normal flow only if we have a subscription to record against.
// Addon payments are fully handled by handleAddonPayment; no further action needed.
return;
}
// Flujo normal: pago recurrente del preapproval
const tenantId = payment.externalReference;
const subscription = await prisma.subscription.findFirst({
where: { tenantId },
orderBy: { createdAt: 'desc' },
});
if (!subscription) {
console.warn('[WEBHOOK] No subscription found for tenant:', tenantId);
return;
}
const paymentRecord = await subscriptionService.recordPayment({
tenantId,
subscriptionId: subscription.id,
mpPaymentId: paymentId,
amount: payment.transactionAmount || 0,
status: payment.status || 'unknown',
paymentMethod: payment.paymentMethodId || 'unknown',
});
if (payment.status === 'approved') {
// Transición pending → authorized es el momento del *primer* pago aprobado.
// En planes despacho con dualidad de precio (firstYear > renewal), bajamos
// el monto recurrente del preapproval para que las renovaciones cobren el
// precio de renewal. Se detecta comparando el monto cobrado contra lo que
// `getPlanPrice(phase='firstYear')` devolvería para este plan.
const esPrimerPago = subscription.status === 'pending';
await prisma.subscription.update({
where: { id: subscription.id },
data: { status: 'authorized' },
});
subscriptionService.invalidateSubscriptionCache(tenantId);
if (
esPrimerPago &&
subscription.mpPreapprovalId &&
isDespachoPaidPlan(subscription.plan) &&
despachoPlanTieneDualidad(subscription.plan as any)
) {
try {
const renewalAmount = await subscriptionService.getPlanPrice(
subscription.plan as any,
subscription.frequency as any,
'renewal',
);
await mpService.updatePreapprovalAmount(subscription.mpPreapprovalId, renewalAmount);
await prisma.subscription.update({
where: { id: subscription.id },
data: { amount: renewalAmount },
});
subscriptionService.invalidateSubscriptionCache(tenantId);
console.log(`[WEBHOOK] Preapproval ${subscription.mpPreapprovalId} bajado a $${renewalAmount} (renewal) tras primer pago`);
} catch (err: any) {
// No fallar el webhook — el cobro ya pasó. Logear para intervención manual.
console.error(`[WEBHOOK] Error bajando preapproval a renewal:`, err?.message || err);
}
}
// Auto-emisión de factura (fail-soft, no bloquea ni tira)
await invoicingService.emitInvoiceIfApplicable(paymentRecord.id);
}
if (typeof process.send === 'function') {
process.send({ type: 'invalidate-tenant-cache', tenantId });
}
}
async function handlePreapprovalNotification(preapprovalId: string) {
const preapproval = await mpService.getPreapproval(preapprovalId);
if (preapproval.status) {
await subscriptionService.updateSubscriptionStatus(preapprovalId, preapproval.status);
}
// Broadcast cache invalidation
const subscription = await prisma.subscription.findFirst({
where: { mpPreapprovalId: preapprovalId },
});
if (subscription && typeof process.send === 'function') {
process.send({ type: 'invalidate-tenant-cache', tenantId: subscription.tenantId });
}
}

57
apps/api/src/index.ts Normal file
View File

@@ -0,0 +1,57 @@
import { app } from './app.js';
import { env } from './config/env.js';
import { tenantDb } from './config/database.js';
import { invalidateTenantCache } from './middlewares/plan-limits.middleware.js';
import { startSatSyncJob } from './jobs/sat-sync.job.js';
import { startWeeklyUpdateJob } from './jobs/weekly-update.job.js';
import { startMetricasInvalidationsJob } from './jobs/metricas-invalidations.job.js';
import { startNotificationsJob } from './jobs/notifications.job.js';
const PORT = parseInt(env.PORT, 10);
const server = app.listen(PORT, '0.0.0.0', () => {
console.log(`API Server running on http://0.0.0.0:${PORT}`);
console.log(`Environment: ${env.NODE_ENV}`);
// Iniciar jobs programados.
// En dev, los crons se omiten por default para no consumir recursos ni
// disparar efectos (SAT queries, emails). Con ENABLE_CRONS_IN_DEV=1 se
// arrancan los crons seguros (SAT sync/retry, métricas); el weekly-update
// sigue siendo prod-only porque envía emails a owners reales.
const cronsEnabled = env.NODE_ENV === 'production' || process.env.ENABLE_CRONS_IN_DEV === '1';
const sendRealEmails = env.NODE_ENV === 'production';
if (cronsEnabled) {
startSatSyncJob();
startMetricasInvalidationsJob();
if (sendRealEmails) {
startWeeklyUpdateJob();
startNotificationsJob();
} else {
console.log('[Cron] weekly-update + notifications omitidos en dev (evita emails reales)');
}
console.log(`[Cron] SAT + metricas activos (NODE_ENV=${env.NODE_ENV}, ENABLE_CRONS_IN_DEV=${process.env.ENABLE_CRONS_IN_DEV ?? 'unset'})`);
} else {
console.log('[Cron] Jobs omitidos en dev (usar ENABLE_CRONS_IN_DEV=1 para activar)');
}
});
// Graceful shutdown — close all tenant DB pools before exiting
const gracefulShutdown = async (signal: string) => {
console.log(`${signal} received. Shutting down gracefully...`);
server.close(() => {
console.log('HTTP server closed');
});
await tenantDb.shutdown();
process.exit(0);
};
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
// PM2 cluster: cross-worker cache invalidation
process.on('message', (msg: any) => {
if (msg?.type === 'invalidate-tenant-cache' && msg.tenantId) {
tenantDb.invalidatePool(msg.tenantId);
invalidateTenantCache(msg.tenantId);
}
});

View File

@@ -0,0 +1,53 @@
import cron from 'node-cron';
import { processAllTenantsInvalidations } from '../services/metricas-compute.service.js';
// Corre cada 15 minutos en dev/test para iteración rápida; en prod puedes
// espaciarlo a 30-60 min si el volumen de cambios es bajo.
const METRICAS_CRON_SCHEDULE = '*/15 * * * *';
let scheduledTask: ReturnType<typeof cron.schedule> | null = null;
let running = false; // evita solapamiento si una corrida tarda más del intervalo
async function runProcessInvalidations(): Promise<void> {
if (running) {
console.log('[MetricasJob] Corrida previa aún en curso, skip');
return;
}
running = true;
const start = Date.now();
try {
const r = await processAllTenantsInvalidations();
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
if (r.totalProcesadas > 0 || r.totalErrores > 0) {
console.log(
`[MetricasJob] ${r.tenantsRevisados} tenants, ${r.totalProcesadas} invalidaciones procesadas, ` +
`${r.totalFilasEscritas} filas escritas, ${r.totalErrores} errores, ${elapsed}s`,
);
}
} catch (err: any) {
console.error('[MetricasJob] Error fatal:', err?.message || err);
} finally {
running = false;
}
}
export function startMetricasInvalidationsJob(): void {
if (scheduledTask) {
console.log('[MetricasJob] Job ya iniciado');
return;
}
scheduledTask = cron.schedule(METRICAS_CRON_SCHEDULE, runProcessInvalidations, {
timezone: 'America/Mexico_City',
});
console.log(`[MetricasJob] Cron iniciado (${METRICAS_CRON_SCHEDULE})`);
}
export function stopMetricasInvalidationsJob(): void {
if (scheduledTask) {
scheduledTask.stop();
scheduledTask = null;
}
}
// Exportado para disparos manuales (ej. desde un endpoint admin)
export { runProcessInvalidations };

View File

@@ -0,0 +1,104 @@
/**
* Cron diario 8:30 AM (America/Mexico_City) que envía emails de:
* - Alertas fiscales nuevas (Option B — una sola vez por alerta).
* - Recordatorios próximos a vencer en ventanas 3d / 1d / 0d.
*
* Por-tenant try/catch: un fallo en un tenant no bloquea al resto.
*/
import cron from 'node-cron';
import { prisma, tenantDb } from '../config/database.js';
import { processNewAlertas, processProximosRecordatorios } from '../services/notifications.service.js';
const SCHEDULE = '30 8 * * *'; // 08:30 AM diario
let task: ReturnType<typeof cron.schedule> | null = null;
/** Ejecuta ambos procesos para UN tenant. Exportado para disparo manual. */
export async function runNotificationsForTenant(tenantId: string): Promise<{
alertasNuevas: number;
recordatoriosEnviados: number;
}> {
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { id: true, nombre: true, rfc: true, active: true, databaseName: true },
});
if (!tenant || !tenant.active) {
return { alertasNuevas: 0, recordatoriosEnviados: 0 };
}
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
const ctx = { rfc: tenant.rfc, nombre: tenant.nombre };
const [alertasResult, recordResult] = await Promise.all([
processNewAlertas(pool, tenantId, ctx).catch(err => {
console.error(`[Notifications] Alertas (${tenant.rfc}) fallo:`, err.message || err);
return { contribuyentes: 0, nuevasTotal: 0 };
}),
processProximosRecordatorios(pool, tenantId, ctx).catch(err => {
console.error(`[Notifications] Recordatorios (${tenant.rfc}) fallo:`, err.message || err);
return { enviados: 0 };
}),
]);
if (alertasResult.nuevasTotal > 0 || recordResult.enviados > 0) {
console.log(`[Notifications] ${tenant.rfc}: ${alertasResult.nuevasTotal} alertas nuevas, ${recordResult.enviados} recordatorios`);
}
return {
alertasNuevas: alertasResult.nuevasTotal,
recordatoriosEnviados: recordResult.enviados,
};
}
/** Itera todos los tenants activos. */
export async function runNotifications(): Promise<{
tenants: number;
alertasNuevas: number;
recordatoriosEnviados: number;
}> {
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true, rfc: true },
});
let alertasNuevas = 0;
let recordatoriosEnviados = 0;
for (const t of tenants) {
try {
const r = await runNotificationsForTenant(t.id);
alertasNuevas += r.alertasNuevas;
recordatoriosEnviados += r.recordatoriosEnviados;
} catch (err: any) {
console.error(`[Notifications] Tenant ${t.rfc} fallo completo:`, err.message || err);
}
}
return { tenants: tenants.length, alertasNuevas, recordatoriosEnviados };
}
export function startNotificationsJob(): void {
if (task) {
console.warn('[Notifications Cron] Ya iniciado');
return;
}
task = cron.schedule(SCHEDULE, async () => {
try {
const result = await runNotifications();
console.log(
`[Notifications Cron] ${result.tenants} tenants — ` +
`${result.alertasNuevas} alertas nuevas, ${result.recordatoriosEnviados} recordatorios`,
);
} catch (err: any) {
console.error('[Notifications Cron] Error general:', err.message || err);
}
}, {
timezone: 'America/Mexico_City',
});
console.log(`[Notifications Cron] Programado: ${SCHEDULE} (08:30 AM diario America/Mexico_City)`);
}
export function stopNotificationsJob(): void {
if (task) {
task.stop();
task = null;
}
}

View File

@@ -0,0 +1,468 @@
import cron from 'node-cron';
import { prisma } from '../config/database.js';
import { startSync, getSyncStatus, retryTimedOutJobs } from '../services/sat/sat.service.js';
import { sweepStaleSatJobs } from '../services/sat/sweep-stale-jobs.service.js';
import { hasFielConfigured } from '../services/fiel.service.js';
import { consultarOpinion, limpiarOpinionesAntiguas } from '../services/opinion-cumplimiento.service.js';
import { applyPendingChanges, expireTrials } from '../services/payment/subscription.service.js';
import { resetExpiredMonthlyTimbres } from '../services/facturapi.service.js';
import { purgeDeclaracionesAntiguas } from '../services/declaraciones.service.js';
import { consultarConstancia, purgeConstanciasAntiguas } from '../services/constancia.service.js';
import { tenantDb } from '../config/database.js';
const SYNC_CRON_SCHEDULE = '0 3 * * *'; // 3:00 AM todos los días
const CONCURRENT_SYNCS = 3; // Máximo de sincronizaciones simultáneas
const OPINION_CRON_SCHEDULE = '0 4 * * 0'; // Sundays 4:00 AM
const CSF_CRON_SCHEDULE = '0 4 1 * *'; // Día 1 de cada mes 04:00 AM (CSF mensual)
const INCREMENTAL_CRON_SCHEDULE = '0 11,15,19 * * *'; // 11:00, 15:00 y 19:00; fuera de ese rango el daily (03:00) cubre
const SUBSCRIPTION_LIFECYCLE_CRON = '30 2 * * *'; // 2:30 AM diario — aplica pending changes + expira trials
let isRunning = false;
let isIncrementalRunning = false;
/**
* Obtiene los tenants que tienen FIEL configurada y activa
*/
async function getTenantsWithFiel(): Promise<string[]> {
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true },
});
const tenantsWithFiel: string[] = [];
for (const tenant of tenants) {
const hasFiel = await hasFielConfigured(tenant.id);
if (hasFiel) {
tenantsWithFiel.push(tenant.id);
}
}
return tenantsWithFiel;
}
/**
* Verifica si un tenant necesita sincronización inicial
*/
async function needsInitialSync(tenantId: string): Promise<boolean> {
const completedSync = await prisma.satSyncJob.findFirst({
where: {
tenantId,
type: 'initial',
status: 'completed',
},
});
return !completedSync;
}
/**
* Ejecuta sincronización para un tenant
*/
async function syncTenant(tenantId: string): Promise<void> {
try {
// Verificar si hay sync activo
const status = await getSyncStatus(tenantId);
if (status.hasActiveSync) {
console.log(`[SAT Cron] Tenant ${tenantId} ya tiene sync activo, omitiendo`);
return;
}
// Determinar tipo de sync
const needsInitial = await needsInitialSync(tenantId);
const syncType = needsInitial ? 'initial' : 'daily';
console.log(`[SAT Cron] Iniciando sync ${syncType} para tenant ${tenantId}`);
const jobId = await startSync(tenantId, syncType);
console.log(`[SAT Cron] Job ${jobId} iniciado para tenant ${tenantId}`);
} catch (error: any) {
console.error(`[SAT Cron] Error sincronizando tenant ${tenantId}:`, error.message);
}
}
/**
* Ejecuta el job de sincronización para todos los tenants
*/
async function runSyncJob(): Promise<void> {
if (isRunning) {
console.log('[SAT Cron] Job ya en ejecución, omitiendo');
return;
}
isRunning = true;
console.log('[SAT Cron] Iniciando job de sincronización diaria');
try {
const tenantIds = await getTenantsWithFiel();
console.log(`[SAT Cron] ${tenantIds.length} tenants con FIEL configurada`);
if (tenantIds.length === 0) {
console.log('[SAT Cron] No hay tenants para sincronizar');
return;
}
// Procesar en lotes para no saturar
for (let i = 0; i < tenantIds.length; i += CONCURRENT_SYNCS) {
const batch = tenantIds.slice(i, i + CONCURRENT_SYNCS);
await Promise.all(batch.map(syncTenant));
// Pequeña pausa entre lotes
if (i + CONCURRENT_SYNCS < tenantIds.length) {
await new Promise(resolve => setTimeout(resolve, 5000));
}
}
console.log('[SAT Cron] Job de sincronización completado');
} catch (error: any) {
console.error('[SAT Cron] Error en job:', error.message);
} finally {
isRunning = false;
}
}
/**
* Obtiene los tenants Enterprise activos con FIEL configurada.
*/
async function getEnterpriseTenantsWithFiel(): Promise<string[]> {
const tenants = await prisma.tenant.findMany({
where: { active: true, plan: 'enterprise' },
select: { id: true },
});
const result: string[] = [];
for (const tenant of tenants) {
if (await hasFielConfigured(tenant.id)) {
result.push(tenant.id);
}
}
return result;
}
/**
* Dispara una sincronización incremental (ventana de 6 horas) para un tenant.
* Si el tenant ya tiene un sync activo, omite para no solapar solicitudes al SAT.
* Si el tenant nunca ha hecho `initial`, omite: el incremental no debe actuar
* como primera descarga — la inicial requiere correrse aparte.
*/
async function incrementalSyncTenant(tenantId: string): Promise<void> {
try {
const status = await getSyncStatus(tenantId);
if (status.hasActiveSync) {
console.log(`[SAT Cron Inc] Tenant ${tenantId} con sync activo, omitiendo`);
return;
}
const completedInitial = await prisma.satSyncJob.findFirst({
where: { tenantId, type: 'initial', status: 'completed' },
});
if (!completedInitial) {
console.log(`[SAT Cron Inc] Tenant ${tenantId} sin sync inicial completado, omitiendo incremental`);
return;
}
console.log(`[SAT Cron Inc] Iniciando incremental para tenant ${tenantId}`);
const jobId = await startSync(tenantId, 'incremental');
console.log(`[SAT Cron Inc] Job ${jobId} iniciado`);
} catch (error: any) {
console.error(`[SAT Cron Inc] Error para tenant ${tenantId}:`, error.message);
}
}
/**
* Ejecuta el job incremental de 6 horas para todos los tenants Enterprise.
*/
async function runIncrementalSyncJob(): Promise<void> {
if (isIncrementalRunning) {
console.log('[SAT Cron Inc] Job ya en ejecución, omitiendo');
return;
}
isIncrementalRunning = true;
console.log('[SAT Cron Inc] Iniciando ciclo incremental Enterprise');
try {
const tenantIds = await getEnterpriseTenantsWithFiel();
console.log(`[SAT Cron Inc] ${tenantIds.length} tenants Enterprise con FIEL`);
if (tenantIds.length === 0) return;
for (let i = 0; i < tenantIds.length; i += CONCURRENT_SYNCS) {
const batch = tenantIds.slice(i, i + CONCURRENT_SYNCS);
await Promise.all(batch.map(incrementalSyncTenant));
if (i + CONCURRENT_SYNCS < tenantIds.length) {
await new Promise(resolve => setTimeout(resolve, 5000));
}
}
console.log('[SAT Cron Inc] Ciclo incremental completado');
} catch (error: any) {
console.error('[SAT Cron Inc] Error en ciclo:', error.message);
} finally {
isIncrementalRunning = false;
}
}
async function runOpinionJob(): Promise<void> {
console.log('[Opinion Cron] Iniciando descarga semanal de Opinión de Cumplimiento');
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true, rfc: true, databaseName: true },
});
let success = 0;
let failed = 0;
let skipped = 0;
for (const tenant of tenants) {
const hasFiel = await hasFielConfigured(tenant.id);
if (!hasFiel) {
skipped++;
continue;
}
try {
console.log(`[Opinion Cron] Consultando opinión para ${tenant.rfc}...`);
await consultarOpinion(tenant.id);
success++;
// Cleanup old records
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
const deleted = await limpiarOpinionesAntiguas(pool);
if (deleted > 0) {
console.log(`[Opinion Cron] ${tenant.rfc}: ${deleted} opiniones antiguas eliminadas`);
}
} catch (error: any) {
console.error(`[Opinion Cron] Error para ${tenant.rfc}:`, error.message);
failed++;
}
}
console.log(`[Opinion Cron] Completado — éxito: ${success}, fallidos: ${failed}, sin FIEL: ${skipped}`);
}
async function runCsfJob(): Promise<void> {
console.log('[CSF Cron] Iniciando descarga mensual de Constancia de Situación Fiscal');
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true, rfc: true },
});
let success = 0;
let failed = 0;
let skipped = 0;
for (const tenant of tenants) {
const hasFiel = await hasFielConfigured(tenant.id);
if (!hasFiel) { skipped++; continue; }
try {
console.log(`[CSF Cron] Consultando CSF para ${tenant.rfc}...`);
await consultarConstancia(tenant.id);
success++;
} catch (error: any) {
console.error(`[CSF Cron] Error para ${tenant.rfc}:`, error.message);
failed++;
}
}
console.log(`[CSF Cron] Completado — éxito: ${success}, fallidos: ${failed}, sin FIEL: ${skipped}`);
}
let scheduledTask: ReturnType<typeof cron.schedule> | null = null;
let retryTask: ReturnType<typeof cron.schedule> | null = null;
let opinionTask: ReturnType<typeof cron.schedule> | null = null;
let csfTask: ReturnType<typeof cron.schedule> | null = null;
let incrementalTask: ReturnType<typeof cron.schedule> | null = null;
let subscriptionTask: ReturnType<typeof cron.schedule> | null = null;
let watchdogTask: ReturnType<typeof cron.schedule> | null = null;
const RETRY_CRON_SCHEDULE = '0 * * * *'; // Cada hora
const WATCHDOG_CRON_SCHEDULE = '0 */2 * * *'; // Cada 2 horas — marca stale jobs como failed
/**
* Inicia el job programado
*/
export function startSatSyncJob(): void {
if (scheduledTask) {
console.log('[SAT Cron] Job ya está programado');
return;
}
// Validar expresión cron
if (!cron.validate(SYNC_CRON_SCHEDULE)) {
console.error('[SAT Cron] Expresión cron inválida:', SYNC_CRON_SCHEDULE);
return;
}
scheduledTask = cron.schedule(SYNC_CRON_SCHEDULE, runSyncJob, {
timezone: 'America/Mexico_City',
});
// Cron de reintentos: cada hora revisa si hay jobs pendientes de retry
retryTask = cron.schedule(RETRY_CRON_SCHEDULE, async () => {
try {
await retryTimedOutJobs();
} catch (error: any) {
console.error('[SAT Retry Cron] Error:', error.message);
}
}, {
timezone: 'America/Mexico_City',
});
// Cron watchdog: cada 2h marca como `failed` los jobs que quedaron stale
// (pending con nextRetryAt > 12h atrás, running con startedAt > 4h atrás).
// Thresholds sobreescribibles vía env (STALE_PENDING_HOURS / STALE_RUNNING_HOURS)
// — defaults razonables pensando en que un sync inicial típico termina
// en <2h y el retryCron corre cada hora.
watchdogTask = cron.schedule(WATCHDOG_CRON_SCHEDULE, async () => {
try {
const pendingHours = Number(process.env.STALE_PENDING_HOURS || 12);
const runningHours = Number(process.env.STALE_RUNNING_HOURS || 4);
const result = await sweepStaleSatJobs({ apply: true, pendingHours, runningHours });
if (result.pendingMarked + result.runningMarked > 0) {
console.log(`[SAT Watchdog] Marcados failed: pending=${result.pendingMarked} running=${result.runningMarked}`);
}
} catch (error: any) {
console.error('[SAT Watchdog] Error:', error.message);
}
}, {
timezone: 'America/Mexico_City',
});
opinionTask = cron.schedule(OPINION_CRON_SCHEDULE, async () => {
try {
await runOpinionJob();
} catch (error: any) {
console.error('[Opinion Cron] Error:', error.message);
}
}, {
timezone: 'America/Mexico_City',
});
csfTask = cron.schedule(CSF_CRON_SCHEDULE, async () => {
try {
await runCsfJob();
} catch (error: any) {
console.error('[CSF Cron] Error:', error.message);
}
}, {
timezone: 'America/Mexico_City',
});
incrementalTask = cron.schedule(INCREMENTAL_CRON_SCHEDULE, async () => {
try {
await runIncrementalSyncJob();
} catch (error: any) {
console.error('[SAT Cron Inc] Error:', error.message);
}
}, {
timezone: 'America/Mexico_City',
});
subscriptionTask = cron.schedule(SUBSCRIPTION_LIFECYCLE_CRON, async () => {
try {
const pending = await applyPendingChanges();
const trials = await expireTrials();
// Reset mensual de TimbreSuscripcion: para cada tenant cuyo periodoFin
// ya pasó, resetea usados=0 y avanza la ventana +1 mes/año. Los paquetes
// adicionales NO se tocan; su expiraEn = adquiridoEn + 1 año fijo.
const timbres = await resetExpiredMonthlyTimbres();
// Cleanup retención 5 años (CFF Art. 30): borra declaraciones provisionales
// viejas. Iteramos cada tenant activo. Por-tenant try/catch para que un
// tenant que falla no bloquee al resto.
let declsBorradas = 0;
let csfsBorradas = 0;
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true, databaseName: true, rfc: true },
});
for (const t of tenants) {
try {
const pool = await tenantDb.getPool(t.id, t.databaseName);
const declResult = await purgeDeclaracionesAntiguas(pool);
declsBorradas += declResult.deleted;
const csfResult = await purgeConstanciasAntiguas(pool);
csfsBorradas += csfResult.deleted;
} catch (err: any) {
console.error(`[Cleanup] Tenant ${t.rfc} fallo en purge:`, err.message || err);
}
}
console.log(`[Subscription Cron] pending aplicados: ${pending.applied} (${pending.errors} errores), trials expirados: ${trials.expired}, timbres reseteados: ${timbres.reset}, declaraciones >5 años borradas: ${declsBorradas}, CSFs >5 años borradas: ${csfsBorradas}`);
} catch (error: any) {
console.error('[Subscription Cron] Error:', error.message);
}
}, {
timezone: 'America/Mexico_City',
});
console.log(`[SAT Cron] Job programado para: ${SYNC_CRON_SCHEDULE} (America/Mexico_City)`);
console.log(`[SAT Cron] Retry programado cada hora`);
console.log(`[Opinion Cron] Programado para: ${OPINION_CRON_SCHEDULE} (America/Mexico_City)`);
console.log(`[CSF Cron] Programado para: ${CSF_CRON_SCHEDULE} (America/Mexico_City)`);
console.log(`[SAT Cron Inc] Incremental Enterprise programado para: ${INCREMENTAL_CRON_SCHEDULE} (America/Mexico_City)`);
console.log(`[Subscription Cron] Lifecycle programado para: ${SUBSCRIPTION_LIFECYCLE_CRON} (America/Mexico_City)`);
console.log(`[SAT Watchdog] Programado para: ${WATCHDOG_CRON_SCHEDULE} (America/Mexico_City)`);
}
/**
* Detiene el job programado
*/
export function stopSatSyncJob(): void {
if (scheduledTask) {
scheduledTask.stop();
scheduledTask = null;
}
if (retryTask) {
retryTask.stop();
retryTask = null;
}
if (opinionTask) {
opinionTask.stop();
opinionTask = null;
}
if (csfTask) {
csfTask.stop();
csfTask = null;
}
if (incrementalTask) {
incrementalTask.stop();
incrementalTask = null;
}
if (subscriptionTask) {
subscriptionTask.stop();
subscriptionTask = null;
}
if (watchdogTask) {
watchdogTask.stop();
watchdogTask = null;
}
console.log('[SAT Cron] Jobs detenidos');
}
/**
* Ejecuta manualmente el ciclo incremental Enterprise (para testing).
*/
export async function runIncrementalSyncJobManually(): Promise<void> {
await runIncrementalSyncJob();
}
/**
* Ejecuta el job manualmente (para testing o ejecución forzada)
*/
export async function runSatSyncJobManually(): Promise<void> {
await runSyncJob();
}
/**
* Obtiene información del próximo job programado
*/
export function getJobInfo(): { scheduled: boolean; expression: string; timezone: string } {
return {
scheduled: scheduledTask !== null,
expression: SYNC_CRON_SCHEDULE,
timezone: 'America/Mexico_City',
};
}

View File

@@ -0,0 +1,154 @@
/**
* Cron Lunes 8:00 AM (America/Mexico_City) que envía a cada owner activo de
* cada tenant un correo "Actualización semanal" con KPIs del mes en curso +
* alertas automáticas + breakdown mensual de discrepancias de régimen.
*
* Manejo de errores: por-tenant try/catch — si falla uno, los demás siguen.
* Si SMTP no está configurado el email service logea a consola (dev), así
* que el job es seguro de correr en cualquier ambiente.
*/
import cron from 'node-cron';
import { prisma } from '../config/database.js';
import { tenantDb } from '../config/database.js';
import { getKpis } from '../services/dashboard.service.js';
import { generarAlertasAutomaticas, getDiscrepanciasPorMes } from '../services/alertas-auto.service.js';
import { emailService } from '../services/email/email.service.js';
const SCHEDULE = '0 8 * * 1'; // Lunes 8:00 AM
let task: ReturnType<typeof cron.schedule> | null = null;
function currentMonthRange(now = new Date()): { fechaInicio: string; fechaFin: string; periodoLabel: string } {
const año = now.getFullYear();
const mes = now.getMonth();
const inicio = new Date(año, mes, 1);
const fin = new Date(año, mes + 1, 0);
const NOMBRES = ['Enero','Febrero','Marzo','Abril','Mayo','Junio','Julio','Agosto','Septiembre','Octubre','Noviembre','Diciembre'];
return {
fechaInicio: inicio.toISOString().split('T')[0],
fechaFin: fin.toISOString().split('T')[0],
periodoLabel: `${NOMBRES[mes]} ${año}`,
};
}
/**
* Genera y envía el correo para UN tenant. Exportado para que pueda llamarse
* manualmente desde un endpoint admin (p.ej. "Enviar reporte ahora").
*/
export async function sendWeeklyUpdateForTenant(tenantId: string): Promise<{ sent: number }> {
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { id: true, nombre: true, rfc: true, active: true, databaseName: true },
});
if (!tenant || !tenant.active) {
console.log(`[Weekly] Tenant ${tenantId} no encontrado o inactivo, skip`);
return { sent: 0 };
}
// Recipientes: owners activos del tenant
const owners = await prisma.tenantMembership.findMany({
where: { tenantId, isOwner: true, active: true },
include: { user: { select: { email: true, nombre: true, active: true } } },
});
const recipients = owners.filter(o => o.user.active);
if (recipients.length === 0) {
console.log(`[Weekly] Tenant ${tenant.rfc} sin owners activos, skip`);
return { sent: 0 };
}
// Pool del tenant para queries de CFDI
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
const { fechaInicio, fechaFin, periodoLabel } = currentMonthRange();
// Ejecuta los 3 colectores en paralelo
const [kpis, alertas, discrepanciasPorMes] = await Promise.all([
getKpis(pool, fechaInicio, fechaFin, tenant.id, false),
generarAlertasAutomaticas(pool, tenant.id),
getDiscrepanciasPorMes(pool, tenant.id, 6),
]);
const fechaGeneracion = new Date().toLocaleString('es-MX', {
dateStyle: 'long',
timeStyle: 'short',
timeZone: 'America/Mexico_City',
});
let sent = 0;
for (const r of recipients) {
try {
await emailService.sendWeeklyUpdate(r.user.email, {
nombre: r.user.nombre,
empresa: tenant.nombre,
periodoLabel,
kpis: {
ingresos: kpis.ingresos,
egresos: kpis.egresos,
utilidad: kpis.utilidad,
margen: kpis.margen,
ivaBalance: kpis.ivaBalance,
ivaAFavorAcumulado: kpis.ivaAFavorAcumulado,
cfdisEmitidos: kpis.cfdisEmitidos,
cfdisRecibidos: kpis.cfdisRecibidos,
},
alertas: alertas.map(a => ({ titulo: a.titulo, mensaje: a.mensaje, prioridad: a.prioridad })),
discrepanciasPorMes: discrepanciasPorMes.map(d => ({ label: d.label, count: d.count })),
fechaGeneracion,
});
sent++;
} catch (err: any) {
console.error(`[Weekly] Error enviando a ${r.user.email} (tenant ${tenant.rfc}):`, err.message || err);
}
}
console.log(`[Weekly] Tenant ${tenant.rfc}: ${sent}/${recipients.length} correos enviados`);
return { sent };
}
/**
* Itera todos los tenants activos y dispara `sendWeeklyUpdateForTenant`.
* Por-tenant try/catch para que un fallo no bloquee al resto.
*/
export async function runWeeklyUpdate(): Promise<{ tenants: number; emails: number }> {
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true, rfc: true },
});
let totalEmails = 0;
for (const t of tenants) {
try {
const { sent } = await sendWeeklyUpdateForTenant(t.id);
totalEmails += sent;
} catch (err: any) {
console.error(`[Weekly] Tenant ${t.rfc} fallo completo:`, err.message || err);
}
}
return { tenants: tenants.length, emails: totalEmails };
}
export function startWeeklyUpdateJob(): void {
if (task) {
console.warn('[Weekly Cron] Ya iniciado');
return;
}
task = cron.schedule(SCHEDULE, async () => {
try {
const result = await runWeeklyUpdate();
console.log(`[Weekly Cron] Reporte enviado: ${result.emails} correos a ${result.tenants} tenants`);
} catch (err: any) {
console.error('[Weekly Cron] Error general:', err.message || err);
}
}, {
timezone: 'America/Mexico_City',
});
console.log(`[Weekly Cron] Programado: ${SCHEDULE} (Lunes 8:00 AM America/Mexico_City)`);
}
export function stopWeeklyUpdateJob(): void {
if (task) {
task.stop();
task = null;
}
}

View File

@@ -0,0 +1,100 @@
import type { Request, Response, NextFunction } from 'express';
import { verifyToken } from '../auth/tokens.js';
import { AppError } from './error.middleware.js';
import { prisma } from '../config/database.js';
import type { JWTPayload, Role } from '@horux/shared';
declare global {
namespace Express {
interface Request {
user?: JWTPayload;
}
}
}
/**
* Cache de `tokenVersion` por userId con TTL 30s. Evita hit a BD en cada
* request autenticada. Al incrementar `tokenVersion` (password change,
* logout-all), se llama `invalidateTokenVersionCache(userId)` que borra la
* entrada y broadcast entre workers PM2.
*/
const tokenVersionCache = new Map<string, { version: number; expires: number }>();
const TOKEN_VERSION_TTL_MS = 30 * 1000;
export function invalidateTokenVersionCache(userId: string) {
tokenVersionCache.delete(userId);
if (typeof process.send === 'function') {
process.send({ type: 'invalidate-token-version', userId });
}
}
// Escucha broadcasts entre workers PM2 cluster
if (typeof process.on === 'function') {
process.on('message', (msg: any) => {
if (msg && msg.type === 'invalidate-token-version' && typeof msg.userId === 'string') {
tokenVersionCache.delete(msg.userId);
}
});
}
async function getCurrentTokenVersion(userId: string): Promise<number | null> {
const cached = tokenVersionCache.get(userId);
if (cached && cached.expires > Date.now()) return cached.version;
const user = await prisma.user.findUnique({
where: { id: userId },
select: { tokenVersion: true, active: true },
});
if (!user || !user.active) return null; // User borrado o desactivado → rechaza
tokenVersionCache.set(userId, {
version: user.tokenVersion,
expires: Date.now() + TOKEN_VERSION_TTL_MS,
});
return user.tokenVersion;
}
export async function authenticate(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return next(new AppError(401, 'Token no proporcionado'));
}
const token = authHeader.split(' ')[1];
let payload: JWTPayload;
try {
payload = verifyToken(token);
} catch (error) {
return next(new AppError(401, 'Token inválido o expirado'));
}
// Check tokenVersion contra BD. JWT con versión menor a la actual del user
// quedan rechazados (forzando re-login tras password change / logout-all).
const currentVersion = await getCurrentTokenVersion(payload.userId);
if (currentVersion === null) {
return next(new AppError(401, 'Sesión inválida. Vuelve a iniciar sesión.'));
}
const jwtVersion = payload.tokenVersion ?? 0;
if (jwtVersion !== currentVersion) {
return next(new AppError(401, 'Sesión expirada. Por seguridad, inicia sesión de nuevo.'));
}
req.user = payload;
next();
}
export function authorize(...roles: Role[]) {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user) {
return next(new AppError(401, 'No autenticado'));
}
if (roles.length > 0 && !roles.includes(req.user.role)) {
return next(new AppError(403, 'No autorizado'));
}
next();
};
}

View File

@@ -0,0 +1,33 @@
import type { Request, Response, NextFunction } from 'express';
export class AppError extends Error {
constructor(
public statusCode: number,
public message: string,
public isOperational = true
) {
super(message);
Object.setPrototypeOf(this, AppError.prototype);
}
}
export function errorMiddleware(
err: Error,
req: Request,
res: Response,
next: NextFunction
) {
if (err instanceof AppError) {
return res.status(err.statusCode).json({
status: 'error',
message: err.message,
});
}
console.error('Unhandled error:', err);
return res.status(500).json({
status: 'error',
message: 'Internal server error',
});
}

View File

@@ -0,0 +1,49 @@
import type { Request, Response, NextFunction } from 'express';
import { hasFeature, hasDespachoFeature, type Plan, type DespachoPlan } from '@horux/shared';
import { prisma } from '../config/database.js';
const planCache = new Map<string, { plan: string; expires: number }>();
// Planes de la vertical despacho (tienen su propio catálogo con distintos
// features). Se resuelven vía `hasDespachoFeature`; el resto usa el
// catálogo Horux 360 vía `hasFeature`.
const DESPACHO_PLAN_NAMES = new Set(['trial', 'business_control', 'business_cloud']);
/**
* Middleware factory that gates routes based on tenant plan features.
* Usage: requireFeature('reportes') — blocks access if tenant's plan lacks the feature.
*/
export function requireFeature(feature: string) {
return async (req: Request, res: Response, next: NextFunction) => {
if (!req.user) return res.status(401).json({ message: 'No autenticado' });
let plan: string;
const cached = planCache.get(req.user.tenantId);
if (cached && cached.expires > Date.now()) {
plan = cached.plan;
} else {
const tenant = await prisma.tenant.findUnique({
where: { id: req.user.tenantId },
select: { plan: true },
});
if (!tenant) return res.status(404).json({ message: 'Tenant no encontrado' });
plan = tenant.plan;
planCache.set(req.user.tenantId, { plan, expires: Date.now() + 5 * 60 * 1000 });
}
// Resolver catálogo según la vertical del plan. Un plan desconocido
// (ni Horux 360 ni despacho) se rechaza con 403 en vez de 500 como
// defense-in-depth.
const allowed = DESPACHO_PLAN_NAMES.has(plan)
? hasDespachoFeature(plan as DespachoPlan, feature)
: hasFeature(plan as Plan, feature);
if (!allowed) {
return res.status(403).json({
message: 'Tu plan no incluye esta función. Contacta soporte para upgrade.',
});
}
next();
};
}

View File

@@ -0,0 +1,90 @@
import type { Request, Response, NextFunction } from 'express';
import { prisma } from '../config/database.js';
import { isGlobalAdmin } from '../utils/global-admin.js';
// Simple in-memory cache with TTL
const cache = new Map<string, { data: any; expires: number }>();
async function getCached<T>(key: string, ttlMs: number, fetcher: () => Promise<T>): Promise<T> {
const entry = cache.get(key);
if (entry && entry.expires > Date.now()) return entry.data;
const data = await fetcher();
cache.set(key, { data, expires: Date.now() + ttlMs });
return data;
}
export function invalidateTenantCache(tenantId: string) {
for (const key of cache.keys()) {
if (key.includes(tenantId)) cache.delete(key);
}
}
/**
* Checks if tenant has an active subscription before allowing write operations
*/
export async function checkPlanLimits(req: Request, res: Response, next: NextFunction) {
if (!req.user) return next();
// Global admin impersonation bypasses subscription check
if (req.headers['x-view-tenant'] && await isGlobalAdmin(req.user.tenantId, req.user.role)) {
return next();
}
const subscription = await getCached(
`sub:${req.user.tenantId}`,
5 * 60 * 1000,
() => prisma.subscription.findFirst({
where: { tenantId: req.user!.tenantId },
orderBy: { createdAt: 'desc' },
})
);
const allowedStatuses = ['authorized', 'pending'];
if (!subscription || !allowedStatuses.includes(subscription.status)) {
// Allow GET requests even with inactive subscription (read-only access)
if (req.method !== 'GET') {
return res.status(403).json({
message: 'Suscripción inactiva. Contacta soporte para reactivar.',
});
}
}
next();
}
/**
* Checks if tenant has room for more CFDIs before allowing CFDI creation
*/
export async function checkCfdiLimit(req: Request, res: Response, next: NextFunction) {
if (!req.user || !req.tenantPool) return next();
const tenant = await getCached(
`tenant:${req.user.tenantId}`,
5 * 60 * 1000,
() => prisma.tenant.findUnique({
where: { id: req.user!.tenantId },
select: { cfdiLimit: true },
})
);
if (!tenant || tenant.cfdiLimit === -1) return next(); // unlimited
const cfdiCount = await getCached(
`cfdi-count:${req.user.tenantId}`,
5 * 60 * 1000,
async () => {
const result = await req.tenantPool!.query('SELECT COUNT(*) FROM cfdis');
return parseInt(result.rows[0].count);
}
);
const newCount = Array.isArray(req.body) ? req.body.length : 1;
if (cfdiCount + newCount > tenant.cfdiLimit) {
return res.status(403).json({
message: `Límite de CFDIs alcanzado (${cfdiCount}/${tenant.cfdiLimit}). Contacta soporte para upgrade.`,
});
}
next();
}

View File

@@ -0,0 +1,92 @@
import rateLimit, { ipKeyGenerator, type Options } from 'express-rate-limit';
import type { Request } from 'express';
import { hasPlatformRole } from '../utils/platform-admin.js';
/**
* Rate limiting por endpoint con 4 tiers según sensibilidad / costo computacional.
*
* Todas las keys se generan por `userId` (no IP) — usuarios legítimos detrás de NAT
* compartido (ej: oficina con 20 contadores) no se bloquean entre sí.
*
* Admin global (tenant dueño de la plataforma o platform_admin/platform_ti) está exento:
* necesita hacer operaciones masivas ocasionalmente (backfill, corrección manual, etc).
*/
const keyByUser = (req: Request): string => {
// User autenticado → rate-limit por userId. Anónimo → por IP normalizada con
// ipKeyGenerator (maneja IPv6 correctamente). Sin esto, express-rate-limit
// emite warning de potential bypass con IPv6 mal truncado.
if (req.user?.userId) return req.user.userId;
return ipKeyGenerator(req.ip || 'anonymous');
};
const skipForGlobalAdmin = async (req: Request): Promise<boolean> => {
if (!req.user?.userId) return false;
try {
// hasPlatformRole(..., 'platform_admin') retorna true para superset (admin o TI).
// Otros platform roles (support/sales/finance) sí respetan rate limits.
return await hasPlatformRole(req.user.userId, 'platform_admin');
} catch {
return false;
}
};
const baseConfig: Partial<Options> = {
keyGenerator: keyByUser,
standardHeaders: true,
legacyHeaders: false,
skip: skipForGlobalAdmin,
};
/**
* Tier más estricto — 2 requests por día.
* Para operaciones que disparan syncs largos con el SAT o cómputo muy pesado
* (Playwright headless contra portal SAT).
*/
export const veryStrictLimit = rateLimit({
...baseConfig,
windowMs: 24 * 60 * 60 * 1000,
max: 2,
message: {
message: 'Has alcanzado el límite de esta operación (2 por día). Intenta mañana o contacta soporte si es urgente.',
},
});
/**
* Tier estricto — 10 requests por hora.
* Operaciones costosas o con side-effects en terceros (Facturapi, MP).
*/
export const strictLimit = rateLimit({
...baseConfig,
windowMs: 60 * 60 * 1000,
max: 10,
message: {
message: 'Demasiadas solicitudes en esta operación. Intenta de nuevo en una hora.',
},
});
/**
* Tier normal — 100 requests por 15 min.
* APIs de negocio (dashboard, cfdi list, reportes, cálculos fiscales).
*/
export const normalLimit = rateLimit({
...baseConfig,
windowMs: 15 * 60 * 1000,
max: 100,
message: {
message: 'Demasiadas solicitudes. Espera unos minutos e intenta de nuevo.',
},
});
/**
* Tier relajado — 500 requests por 15 min.
* Endpoints de lectura barata (catálogos SAT, listas fijas).
*/
export const relaxedLimit = rateLimit({
...baseConfig,
windowMs: 15 * 60 * 1000,
max: 500,
message: {
message: 'Demasiadas solicitudes. Espera unos minutos e intenta de nuevo.',
},
});

View File

@@ -0,0 +1,144 @@
import type { Request, Response, NextFunction } from 'express';
import type { Pool } from 'pg';
import { prisma, tenantDb } from '../config/database.js';
import { isGlobalAdmin } from '../utils/global-admin.js';
import { decryptAesGcm, deriveAesKey } from '@horux/core';
import { env } from '../config/env.js';
declare global {
namespace Express {
interface Request {
tenantPool?: Pool;
viewingTenantId?: string;
}
}
}
// Cache: tenantId -> { databaseName, expires }
// Only used for MANAGED tenants. BYO tenants always query Prisma so connection info stays fresh.
const tenantDbCache = new Map<string, { databaseName: string; expires: number }>();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
type ConnectionOverride = { host: string; port: number; user: string; password: string };
/**
* Decrypt the BYO connection string stored in the central DB.
* Returns a connection override object, or null if decryption fails.
*/
async function resolveBYOConnection(
dbConnectionEnc: string,
dbConnectionIv: string,
tenantId: string,
): Promise<ConnectionOverride | null> {
try {
const encKey = env.CONNECTOR_ENCRYPTION_KEY
? deriveAesKey(env.CONNECTOR_ENCRYPTION_KEY)
: deriveAesKey(env.FIEL_ENCRYPTION_KEY);
const encData = Buffer.from(dbConnectionEnc, 'base64');
const iv = Buffer.from(dbConnectionIv, 'base64');
// AES-GCM: last 16 bytes of the ciphertext blob are the auth tag
const authTag = encData.subarray(encData.length - 16);
const ciphertext = encData.subarray(0, encData.length - 16);
const decrypted = decryptAesGcm(ciphertext, iv, authTag, encKey);
const config = JSON.parse(decrypted.toString('utf-8'));
return {
host: config.host,
port: config.port ?? 5432,
user: config.user,
password: config.password,
};
} catch (err) {
console.error(`[TenantMiddleware] BYO decrypt failed for tenant ${tenantId}:`, err);
return null;
}
}
export function invalidateTenantDbCache(tenantId: string) {
tenantDbCache.delete(tenantId);
}
export async function tenantMiddleware(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
return res.status(401).json({ message: 'No autenticado' });
}
let tenantId = req.user.tenantId;
// Admin impersonation via X-View-Tenant header (global admin only)
const viewTenantHeader = req.headers['x-view-tenant'] as string;
if (viewTenantHeader) {
const globalAdmin = await isGlobalAdmin(req.user.tenantId, req.user.role);
if (!globalAdmin) {
return res.status(403).json({ message: 'No autorizado para ver otros tenants' });
}
const viewedTenant = await prisma.tenant.findFirst({
where: {
OR: [
{ id: viewTenantHeader },
{ rfc: viewTenantHeader },
],
},
select: { id: true, databaseName: true, active: true, dbMode: true, dbConnectionEnc: true, dbConnectionIv: true },
});
if (!viewedTenant) {
return res.status(404).json({ message: 'Tenant no encontrado' });
}
if (!viewedTenant.active) {
return res.status(403).json({ message: 'Tenant inactivo' });
}
tenantId = viewedTenant.id;
req.viewingTenantId = viewedTenant.id;
let impersonateOverride: ConnectionOverride | undefined;
if (viewedTenant.dbMode === 'BYO' && viewedTenant.dbConnectionEnc && viewedTenant.dbConnectionIv) {
const override = await resolveBYOConnection(viewedTenant.dbConnectionEnc, viewedTenant.dbConnectionIv, tenantId);
if (!override) {
return res.status(503).json({ message: 'Base de datos del despacho no disponible. Verifica tu connector.' });
}
impersonateOverride = override;
}
req.tenantPool = await tenantDb.getPool(tenantId, viewedTenant.databaseName, impersonateOverride);
return next();
}
// Normal flow: query tenant details including BYO fields.
// BYO tenants bypass the databaseName cache so connection info stays fresh.
// The 5-min idle eviction in TenantConnectionManager still protects against excessive pool creation.
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { databaseName: true, dbMode: true, dbConnectionEnc: true, dbConnectionIv: true },
});
if (!tenant?.databaseName) {
return res.status(404).json({ message: 'Tenant no encontrado' });
}
let connectionOverride: ConnectionOverride | undefined;
if (tenant.dbMode === 'BYO' && tenant.dbConnectionEnc && tenant.dbConnectionIv) {
const override = await resolveBYOConnection(tenant.dbConnectionEnc, tenant.dbConnectionIv, tenantId);
if (!override) {
return res.status(503).json({ message: 'Base de datos del despacho no disponible. Verifica tu connector.' });
}
connectionOverride = override;
} else {
// MANAGED: keep the databaseName cache warm to avoid repeated Prisma queries
tenantDbCache.set(tenantId, { databaseName: tenant.databaseName, expires: Date.now() + CACHE_TTL });
}
req.tenantPool = await tenantDb.getPool(tenantId, tenant.databaseName, connectionOverride);
next();
} catch (error) {
console.error('[TenantMiddleware] Error:', error);
return res.status(500).json({ message: 'Error al resolver tenant' });
}
}

View File

@@ -0,0 +1,244 @@
-- Migration: 001_initial_schema
-- Description: Full initial DDL for tenant databases (horux_<rfc>)
-- Created: 2026-04-13
-- Tables: rfcs, bancos, cfdis, cfdi_conceptos, conciliaciones, alertas, recordatorios
-- Extensions
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- Tables
CREATE TABLE IF NOT EXISTS rfcs (
id SERIAL PRIMARY KEY,
rfc VARCHAR(14) UNIQUE NOT NULL,
razon_social VARCHAR(255),
regimen_fiscal VARCHAR(3),
codigo_postal VARCHAR(5)
);
CREATE TABLE IF NOT EXISTS bancos (
id SERIAL PRIMARY KEY,
banco VARCHAR(100) NOT NULL,
terminacion_cuenta VARCHAR(4) NOT NULL,
creado_en TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS cfdis (
id SERIAL PRIMARY KEY,
year VARCHAR(4),
month VARCHAR(2),
type VARCHAR(10),
uuid VARCHAR(36) UNIQUE,
serie VARCHAR(50),
folio VARCHAR(50),
status VARCHAR(20),
fecha_emision TIMESTAMP,
rfc_emisor_id INTEGER REFERENCES rfcs(id),
rfc_emisor VARCHAR(13),
nombre_emisor VARCHAR(255),
rfc_receptor_id INTEGER REFERENCES rfcs(id),
rfc_receptor VARCHAR(13),
nombre_receptor VARCHAR(255),
subtotal NUMERIC(18,4),
subtotal_mxn NUMERIC(18,4),
descuento NUMERIC(18,4),
descuento_mxn NUMERIC(18,4),
total NUMERIC(18,4),
total_mxn NUMERIC(18,4),
saldo_insoluto TEXT,
moneda VARCHAR(3),
tipo_cambio NUMERIC(18,6),
tipo_comprobante VARCHAR(1),
metodo_pago VARCHAR(3),
forma_pago VARCHAR(2),
uso_cfdi VARCHAR(5),
pac VARCHAR(13),
fecha_cert_sat TIMESTAMP,
fecha_cancelacion TIMESTAMP,
uuid_relacionado TEXT,
isr_retencion NUMERIC(18,4),
isr_retencion_mxn NUMERIC(18,4),
iva_traslado NUMERIC(18,4),
iva_traslado_mxn NUMERIC(18,4),
iva_retencion NUMERIC(18,4),
iva_retencion_mxn NUMERIC(18,4),
ieps_traslado NUMERIC(18,4),
ieps_traslado_mxn NUMERIC(18,4),
ieps_retencion NUMERIC(18,4),
ieps_retencion_mxn NUMERIC(18,4),
impuestos_locales_trasladado NUMERIC(18,4),
impuestos_locales_trasladado_mxn NUMERIC(18,4),
impuestos_locales_retenidos NUMERIC(18,4),
impuestos_locales_retenidos_mxn NUMERIC(18,4),
monto_pago NUMERIC(18,4),
monto_pago_mxn NUMERIC(18,4),
fecha_pago_p TIMESTAMP,
num_parcialidad TEXT,
isr_retencion_pago NUMERIC(18,4),
isr_retencion_pago_mxn NUMERIC(18,4),
iva_traslado_pago NUMERIC(18,4),
iva_traslado_pago_mxn NUMERIC(18,4),
iva_retencion_pago NUMERIC(18,4),
iva_retencion_pago_mxn NUMERIC(18,4),
ieps_traslado_pago NUMERIC(18,4),
ieps_traslado_pago_mxn NUMERIC(18,4),
ieps_retencion_pago NUMERIC(18,4),
ieps_retencion_pago_mxn NUMERIC(18,4),
saldo_pendiente NUMERIC(18,4),
saldo_pendiente_mxn NUMERIC(18,4),
fecha_liquidacion TIMESTAMP,
fecha_pago DATE,
fecha_inicial_pago DATE,
fecha_final_pago DATE,
num_dias_pagados NUMERIC(10,2),
num_seguro_social VARCHAR(50),
puesto VARCHAR(255),
salario_base_cot_apor NUMERIC(18,4),
salario_base_cot_apor_mxn NUMERIC(18,4),
salario_diario_integrado NUMERIC(18,4),
salario_diario_integrado_mxn NUMERIC(18,4),
total_percepciones NUMERIC(18,4),
total_percepciones_mxn NUMERIC(18,4),
total_deducciones NUMERIC(18,4),
total_deducciones_mxn NUMERIC(18,4),
imp_retenidos_nomina NUMERIC(18,4),
imp_retenidos_nomina_mxn NUMERIC(18,4),
otras_deducciones_nomina NUMERIC(18,4),
otras_deducciones_nomina_mxn NUMERIC(18,4),
subsidio_causado NUMERIC(18,4),
subsidio_causado_mxn NUMERIC(18,4),
conciliado VARCHAR(50),
id_conciliacion INTEGER,
xml_url TEXT,
pdf_url TEXT,
xml_original TEXT,
last_sat_sync TIMESTAMP,
sat_sync_job_id UUID,
source VARCHAR(20) DEFAULT 'manual',
facturapi_id VARCHAR(50),
regimen_fiscal_emisor VARCHAR(3),
regimen_fiscal_receptor VARCHAR(3),
creado_en TIMESTAMP DEFAULT NOW(),
actualizado_en TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS cfdi_conceptos (
id SERIAL PRIMARY KEY,
cfdi_id INTEGER REFERENCES cfdis(id) ON DELETE CASCADE,
clave_prod_serv VARCHAR(10),
no_identificacion VARCHAR(100),
descripcion TEXT,
cantidad NUMERIC(18,4),
clave_unidad VARCHAR(10),
unidad VARCHAR(100),
valor_unitario NUMERIC(18,4),
valor_unitario_mxn NUMERIC(18,4),
importe NUMERIC(18,4),
importe_mxn NUMERIC(18,4),
descuento NUMERIC(18,4),
descuento_mxn NUMERIC(18,4),
isr_retencion NUMERIC(18,4),
isr_retencion_mxn NUMERIC(18,4),
iva_traslado NUMERIC(18,4),
iva_traslado_mxn NUMERIC(18,4),
iva_retencion NUMERIC(18,4),
iva_retencion_mxn NUMERIC(18,4),
ieps_traslado NUMERIC(18,4),
ieps_traslado_mxn NUMERIC(18,4),
ieps_retencion NUMERIC(18,4),
ieps_retencion_mxn NUMERIC(18,4),
impuestos_locales_trasladado NUMERIC(18,4),
impuestos_locales_trasladado_mxn NUMERIC(18,4),
impuestos_locales_retenidos NUMERIC(18,4),
impuestos_locales_retenidos_mxn NUMERIC(18,4),
total_percepciones NUMERIC(18,4),
total_percepciones_mxn NUMERIC(18,4),
total_deducciones NUMERIC(18,4),
total_deducciones_mxn NUMERIC(18,4),
imp_retenidos_nomina NUMERIC(18,4),
imp_retenidos_nomina_mxn NUMERIC(18,4),
otras_deducciones_nomina NUMERIC(18,4),
otras_deducciones_nomina_mxn NUMERIC(18,4),
subsidio_causado NUMERIC(18,4),
subsidio_causado_mxn NUMERIC(18,4),
creado_en TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS conciliaciones (
id SERIAL PRIMARY KEY,
anio VARCHAR(4) NOT NULL,
mes VARCHAR(2) NOT NULL,
id_cfdi INTEGER NOT NULL UNIQUE REFERENCES cfdis(id),
fecha_de_pago DATE NOT NULL,
id_banco INTEGER NOT NULL REFERENCES bancos(id),
creado_en TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS alertas (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tipo VARCHAR(50) NOT NULL,
titulo VARCHAR(200) NOT NULL,
mensaje TEXT,
prioridad VARCHAR(20) DEFAULT 'media',
fecha_vencimiento TIMESTAMP,
leida BOOLEAN DEFAULT FALSE,
resuelta BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS recordatorios (
id SERIAL PRIMARY KEY,
titulo VARCHAR(200) NOT NULL,
descripcion TEXT,
fecha_limite DATE NOT NULL,
notas TEXT,
completado BOOLEAN DEFAULT FALSE,
privado BOOLEAN DEFAULT FALSE,
creado_por UUID NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- =============================================
-- Columns that may be missing on older tenants
-- (CREATE TABLE IF NOT EXISTS won't add these if the table already existed)
-- =============================================
ALTER TABLE cfdis ADD COLUMN IF NOT EXISTS id_conciliacion INTEGER;
ALTER TABLE cfdis ADD COLUMN IF NOT EXISTS conciliado VARCHAR(50);
ALTER TABLE cfdis ADD COLUMN IF NOT EXISTS source VARCHAR(20) DEFAULT 'manual';
ALTER TABLE cfdis ADD COLUMN IF NOT EXISTS facturapi_id VARCHAR(50);
ALTER TABLE cfdis ADD COLUMN IF NOT EXISTS regimen_fiscal_emisor VARCHAR(3);
ALTER TABLE cfdis ADD COLUMN IF NOT EXISTS regimen_fiscal_receptor VARCHAR(3);
ALTER TABLE cfdis ADD COLUMN IF NOT EXISTS last_sat_sync TIMESTAMP;
ALTER TABLE cfdis ADD COLUMN IF NOT EXISTS sat_sync_job_id UUID;
-- Indexes
CREATE INDEX IF NOT EXISTS idx_cfdis_fecha_emision ON cfdis(fecha_emision DESC);
CREATE INDEX IF NOT EXISTS idx_cfdis_type ON cfdis(type);
CREATE INDEX IF NOT EXISTS idx_cfdis_rfc_emisor ON cfdis(rfc_emisor);
CREATE INDEX IF NOT EXISTS idx_cfdis_rfc_receptor ON cfdis(rfc_receptor);
CREATE INDEX IF NOT EXISTS idx_cfdis_status ON cfdis(status);
CREATE INDEX IF NOT EXISTS idx_cfdis_year_month ON cfdis(year, month);
CREATE INDEX IF NOT EXISTS idx_cfdis_nombre_emisor_trgm ON cfdis USING gin(nombre_emisor gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_cfdis_nombre_receptor_trgm ON cfdis USING gin(nombre_receptor gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_cfdis_rfc_emisor_id ON cfdis(rfc_emisor_id);
CREATE INDEX IF NOT EXISTS idx_cfdis_rfc_receptor_id ON cfdis(rfc_receptor_id);
CREATE INDEX IF NOT EXISTS idx_cfdi_conceptos_cfdi_id ON cfdi_conceptos(cfdi_id);
CREATE INDEX IF NOT EXISTS idx_cfdi_conceptos_clave ON cfdi_conceptos(clave_prod_serv);
CREATE INDEX IF NOT EXISTS idx_conciliaciones_anio_mes ON conciliaciones(anio, mes);
CREATE INDEX IF NOT EXISTS idx_conciliaciones_id_cfdi ON conciliaciones(id_cfdi);
CREATE INDEX IF NOT EXISTS idx_cfdis_id_conciliacion ON cfdis(id_conciliacion);
-- Deferred FK: cfdis.id_conciliacion -> conciliaciones(id)
-- (cfdis is created before conciliaciones, so this constraint is added after both tables exist)
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'cfdis_id_conciliacion_fkey') THEN
ALTER TABLE cfdis ADD CONSTRAINT cfdis_id_conciliacion_fkey FOREIGN KEY (id_conciliacion) REFERENCES conciliaciones(id);
END IF;
END $$;

View File

@@ -0,0 +1,16 @@
-- 002_create_opiniones_cumplimiento
-- Table for storing SAT Opinión de Cumplimiento PDFs and metadata
CREATE TABLE IF NOT EXISTS opiniones_cumplimiento (
id SERIAL PRIMARY KEY,
rfc VARCHAR(14) NOT NULL,
razon_social VARCHAR(255),
estatus VARCHAR(50) NOT NULL,
folio VARCHAR(50),
cadena_original TEXT,
fecha_consulta TIMESTAMP NOT NULL,
pdf BYTEA NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_opiniones_fecha ON opiniones_cumplimiento(fecha_consulta DESC);

View File

@@ -0,0 +1,36 @@
-- Declaraciones provisionales del tenant: PDFs subidos por el contador con
-- el comprobante de la declaración + opcionalmente el comprobante de pago.
-- Al subir una declaración o un comprobante, el sistema marca como resueltas
-- las alertas correspondientes (decl-XXX o pago-XXX) en la tabla `alertas`.
--
-- Reglas:
-- - 1 declaración tipo='normal' por (año, mes) — UNIQUE parcial
-- - N declaraciones tipo='complementaria' por (año, mes) — sin restricción
-- - `impuestos` es un array de strings: ['IVA', 'ISR', 'IEPS', etc.] que
-- cubre la declaración. Permite saber qué alertas resolver.
CREATE TABLE IF NOT EXISTS declaraciones_provisionales (
id SERIAL PRIMARY KEY,
año INT NOT NULL,
mes INT NOT NULL CHECK (mes BETWEEN 1 AND 12),
tipo VARCHAR(15) NOT NULL CHECK (tipo IN ('normal', 'complementaria')),
impuestos TEXT[] NOT NULL, -- ['IVA', 'ISR', 'IEPS', ...]
pdf_declaracion BYTEA NOT NULL,
pdf_filename VARCHAR(255),
link_pago TEXT,
pdf_pago BYTEA,
pdf_pago_filename VARCHAR(255),
pagado_at TIMESTAMP,
creado_por VARCHAR(255), -- email del user que la subió
notas TEXT,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_declaraciones_periodo ON declaraciones_provisionales(año DESC, mes DESC);
-- Solo 1 declaración tipo='normal' por (año, mes). Las complementarias no
-- tienen restricción de cantidad.
CREATE UNIQUE INDEX IF NOT EXISTS uniq_declaracion_normal_mes
ON declaraciones_provisionales(año, mes)
WHERE tipo = 'normal';

View File

@@ -0,0 +1,11 @@
-- La "liga de pago" de la declaración es un PDF (no un URL). Reemplazamos
-- la columna TEXT por un par BYTEA+filename, consistente con pdf_declaracion
-- y pdf_pago. Si la migración 003 aún no se desplegó en algún ambiente,
-- este ALTER aplica igual (DROP IF EXISTS + ADD COLUMN IF NOT EXISTS).
ALTER TABLE declaraciones_provisionales
DROP COLUMN IF EXISTS link_pago;
ALTER TABLE declaraciones_provisionales
ADD COLUMN IF NOT EXISTS pdf_liga_pago BYTEA,
ADD COLUMN IF NOT EXISTS pdf_liga_pago_filename VARCHAR(255);

View File

@@ -0,0 +1,24 @@
-- Constancia de Situación Fiscal: PDF descargado del portal SAT con Playwright
-- + FIEL. Se descarga automáticamente el 1° de cada mes y al primer upload de
-- FIEL del tenant. Retención 5 años (similar a declaraciones_provisionales).
--
-- `datos` es un JSONB con el shape `ConstanciaSituacionFiscal` del prototipo
-- (domicilio, régimenes activos, actividades, obligaciones, sellos). Se
-- guarda completo para poder re-hidratar la UI sin re-parsear el PDF, y
-- comparar entre consultas (detectar cambios de domicilio/régimen).
CREATE TABLE IF NOT EXISTS constancias_situacion_fiscal (
id SERIAL PRIMARY KEY,
rfc VARCHAR(13) NOT NULL,
id_cif VARCHAR(20),
razon_social TEXT,
estatus_padron VARCHAR(30),
fecha_emision TEXT, -- "GUADALAJARA, JALISCO A 14 DE ABRIL DE 2026" (formato libre del SAT)
datos JSONB NOT NULL, -- shape ConstanciaSituacionFiscal completo
pdf BYTEA NOT NULL,
fecha_consulta TIMESTAMP DEFAULT NOW(),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_csf_fecha_consulta
ON constancias_situacion_fiscal(fecha_consulta DESC);

View File

@@ -0,0 +1,17 @@
CREATE TABLE IF NOT EXISTS tenant_migrations (
scope varchar(50) NOT NULL,
version int NOT NULL,
name varchar(255),
applied_at timestamptz DEFAULT now(),
PRIMARY KEY (scope, version)
);
INSERT INTO tenant_migrations (scope, version, name)
VALUES
('legacy', 1, '001_initial_schema'),
('legacy', 2, '002_create_opiniones_cumplimiento'),
('legacy', 3, '003_create_declaraciones_provisionales'),
('legacy', 4, '004_declaraciones_liga_pago_pdf'),
('legacy', 5, '005_create_constancias_situacion_fiscal'),
('legacy', 6, '006_tenant_migrations_tracking')
ON CONFLICT (scope, version) DO NOTHING;

View File

@@ -0,0 +1,18 @@
CREATE TABLE IF NOT EXISTS entidades_gestionadas (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tipo varchar(20) NOT NULL,
nombre text NOT NULL,
identificador text,
supervisor_user_id uuid,
active boolean DEFAULT true,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ix_entidades_supervisor ON entidades_gestionadas(supervisor_user_id);
CREATE INDEX IF NOT EXISTS ix_entidades_tipo ON entidades_gestionadas(tipo, active);
CREATE INDEX IF NOT EXISTS ix_entidades_identificador ON entidades_gestionadas(identificador);
INSERT INTO tenant_migrations (scope, version, name)
VALUES ('core', 7, '007_entidades_gestionadas')
ON CONFLICT (scope, version) DO NOTHING;

View File

@@ -0,0 +1,27 @@
CREATE TABLE IF NOT EXISTS carteras (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
supervisor_user_id uuid NOT NULL,
nombre text NOT NULL,
descripcion text,
created_at timestamptz DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ix_carteras_supervisor ON carteras(supervisor_user_id);
CREATE TABLE IF NOT EXISTS cartera_entidades (
cartera_id uuid NOT NULL REFERENCES carteras(id) ON DELETE CASCADE,
entidad_id uuid NOT NULL REFERENCES entidades_gestionadas(id) ON DELETE CASCADE,
added_at timestamptz DEFAULT now(),
PRIMARY KEY (cartera_id, entidad_id)
);
CREATE TABLE IF NOT EXISTS cartera_auxiliares (
cartera_id uuid NOT NULL REFERENCES carteras(id) ON DELETE CASCADE,
auxiliar_user_id uuid NOT NULL,
added_at timestamptz DEFAULT now(),
PRIMARY KEY (cartera_id, auxiliar_user_id)
);
INSERT INTO tenant_migrations (scope, version, name)
VALUES ('core', 8, '008_carteras')
ON CONFLICT (scope, version) DO NOTHING;

View File

@@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS cliente_accesos (
user_id uuid NOT NULL,
entidad_id uuid NOT NULL REFERENCES entidades_gestionadas(id) ON DELETE CASCADE,
granted_at timestamptz DEFAULT now(),
PRIMARY KEY (user_id, entidad_id)
);
INSERT INTO tenant_migrations (scope, version, name)
VALUES ('core', 9, '009_cliente_accesos')
ON CONFLICT (scope, version) DO NOTHING;

View File

@@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS contribuyentes (
entidad_id uuid PRIMARY KEY REFERENCES entidades_gestionadas(id) ON DELETE CASCADE,
rfc varchar(13) NOT NULL UNIQUE,
regimen_fiscal varchar(3),
codigo_postal varchar(5),
domicilio jsonb
);
CREATE INDEX IF NOT EXISTS ix_contribuyentes_rfc ON contribuyentes(rfc);
INSERT INTO tenant_migrations (scope, version, name)
VALUES ('vertical-contable', 10, '010_contribuyentes')
ON CONFLICT (scope, version) DO NOTHING;

View File

@@ -0,0 +1,23 @@
CREATE TABLE IF NOT EXISTS fiel_contribuyente (
contribuyente_id uuid PRIMARY KEY REFERENCES contribuyentes(entidad_id) ON DELETE CASCADE,
rfc varchar(13) NOT NULL,
cer_data bytea NOT NULL,
key_data bytea NOT NULL,
key_password_enc bytea NOT NULL,
cer_iv bytea NOT NULL,
cer_tag bytea NOT NULL,
key_iv bytea NOT NULL,
key_tag bytea NOT NULL,
password_iv bytea NOT NULL,
password_tag bytea NOT NULL,
serial_number varchar(50),
valid_from timestamptz NOT NULL,
valid_until timestamptz NOT NULL,
is_active boolean DEFAULT true,
uploaded_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
);
INSERT INTO tenant_migrations (scope, version, name)
VALUES ('vertical-contable', 11, '011_fiel_per_contribuyente')
ON CONFLICT (scope, version) DO NOTHING;

View File

@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS facturapi_orgs (
contribuyente_id uuid PRIMARY KEY REFERENCES contribuyentes(entidad_id) ON DELETE CASCADE,
facturapi_org_id text NOT NULL UNIQUE,
csd_uploaded boolean DEFAULT false,
active boolean DEFAULT true,
created_at timestamptz DEFAULT now()
);
INSERT INTO tenant_migrations (scope, version, name)
VALUES ('vertical-contable', 12, '012_facturapi_per_contribuyente')
ON CONFLICT (scope, version) DO NOTHING;

View File

@@ -0,0 +1,7 @@
ALTER TABLE cfdis ADD COLUMN IF NOT EXISTS contribuyente_id uuid REFERENCES contribuyentes(entidad_id);
CREATE INDEX IF NOT EXISTS ix_cfdi_contribuyente ON cfdis(contribuyente_id) WHERE contribuyente_id IS NOT NULL;
INSERT INTO tenant_migrations (scope, version, name)
VALUES ('vertical-contable', 13, '013_cfdi_contribuyente_id')
ON CONFLICT (scope, version) DO NOTHING;

View File

@@ -0,0 +1,52 @@
CREATE TABLE IF NOT EXISTS metricas_mensuales (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
contribuyente_id uuid NOT NULL REFERENCES contribuyentes(entidad_id) ON DELETE CASCADE,
anio smallint NOT NULL,
mes smallint NOT NULL,
regimen_fiscal varchar(3),
formula_version smallint DEFAULT 1,
iva_trasladado_16 numeric(18,2) DEFAULT 0,
iva_trasladado_8 numeric(18,2) DEFAULT 0,
iva_trasladado_0 numeric(18,2) DEFAULT 0,
iva_trasladado_exento numeric(18,2) DEFAULT 0,
iva_trasladado_total numeric(18,2) DEFAULT 0,
iva_acreditable numeric(18,2) DEFAULT 0,
iva_retenido_cobrado numeric(18,2) DEFAULT 0,
iva_retenido_pagado numeric(18,2) DEFAULT 0,
iva_resultado numeric(18,2) DEFAULT 0,
iva_a_favor_mes numeric(18,2) DEFAULT 0,
isr_ingresos_brutos numeric(18,2) DEFAULT 0,
isr_deducciones_autoriz numeric(18,2) DEFAULT 0,
isr_base numeric(18,2) DEFAULT 0,
isr_causado numeric(18,2) DEFAULT 0,
isr_retenido numeric(18,2) DEFAULT 0,
isr_a_pagar numeric(18,2) DEFAULT 0,
ieps_trasladado numeric(18,2) DEFAULT 0,
ieps_acreditable numeric(18,2) DEFAULT 0,
cfdis_emitidos_count int DEFAULT 0,
cfdis_recibidos_count int DEFAULT 0,
cfdis_cancelados_count int DEFAULT 0,
ingresos_devengados numeric(18,2) DEFAULT 0,
ingresos_cobrados numeric(18,2) DEFAULT 0,
egresos_devengados numeric(18,2) DEFAULT 0,
egresos_pagados numeric(18,2) DEFAULT 0,
utilidad_devengada numeric(18,2) DEFAULT 0,
utilidad_realizada numeric(18,2) DEFAULT 0,
flujo_entradas numeric(18,2) DEFAULT 0,
flujo_salidas numeric(18,2) DEFAULT 0,
flujo_neto numeric(18,2) DEFAULT 0,
cxc_saldo_final numeric(18,2) DEFAULT 0,
cxp_saldo_final numeric(18,2) DEFAULT 0,
cxc_cfdis_count int DEFAULT 0,
cxp_cfdis_count int DEFAULT 0,
cerrado boolean DEFAULT false,
computed_at timestamptz DEFAULT now(),
source_max_cfdi_at timestamptz,
UNIQUE (contribuyente_id, anio, mes, regimen_fiscal)
);
CREATE INDEX IF NOT EXISTS ix_metricas_contrib_anio ON metricas_mensuales(contribuyente_id, anio DESC, mes DESC);
CREATE INDEX IF NOT EXISTS ix_metricas_cerrado ON metricas_mensuales(cerrado, computed_at);
INSERT INTO tenant_migrations (scope, version, name)
VALUES ('vertical-contable', 14, '014_metricas_mensuales')
ON CONFLICT (scope, version) DO NOTHING;

View File

@@ -0,0 +1,24 @@
CREATE TABLE IF NOT EXISTS metricas_acumuladas_anuales (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
contribuyente_id uuid NOT NULL REFERENCES contribuyentes(entidad_id) ON DELETE CASCADE,
anio smallint NOT NULL,
regimen_fiscal varchar(3),
formula_version smallint DEFAULT 1,
iva_a_favor_arrastrado numeric(18,2) DEFAULT 0,
iva_a_favor_generado numeric(18,2) DEFAULT 0,
iva_a_favor_aplicado numeric(18,2) DEFAULT 0,
iva_a_favor_saldo numeric(18,2) DEFAULT 0,
ingresos_anuales numeric(18,2) DEFAULT 0,
deducciones_anuales numeric(18,2) DEFAULT 0,
utilidad_anual numeric(18,2) DEFAULT 0,
isr_causado_anual numeric(18,2) DEFAULT 0,
isr_retenido_anual numeric(18,2) DEFAULT 0,
isr_a_pagar_anual numeric(18,2) DEFAULT 0,
cerrado boolean DEFAULT false,
computed_at timestamptz DEFAULT now(),
UNIQUE (contribuyente_id, anio, regimen_fiscal)
);
INSERT INTO tenant_migrations (scope, version, name)
VALUES ('vertical-contable', 15, '015_metricas_acumuladas_anuales')
ON CONFLICT (scope, version) DO NOTHING;

View File

@@ -0,0 +1,19 @@
CREATE TABLE IF NOT EXISTS metricas_por_contraparte_anuales (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
contribuyente_id uuid NOT NULL REFERENCES contribuyentes(entidad_id) ON DELETE CASCADE,
anio smallint NOT NULL,
rfc_contraparte varchar(13) NOT NULL,
nombre_contraparte text,
tipo char(1),
subtotal numeric(18,2),
total numeric(18,2),
cfdis_count int,
concentracion_pct numeric(5,2),
computed_at timestamptz DEFAULT now(),
UNIQUE (contribuyente_id, anio, rfc_contraparte, tipo)
);
CREATE INDEX IF NOT EXISTS ix_metricas_contraparte_top ON metricas_por_contraparte_anuales(contribuyente_id, anio, tipo, total DESC);
INSERT INTO tenant_migrations (scope, version, name)
VALUES ('vertical-contable', 16, '016_metricas_por_contraparte_anuales')
ON CONFLICT (scope, version) DO NOTHING;

View File

@@ -0,0 +1,12 @@
CREATE TABLE IF NOT EXISTS metricas_invalidaciones (
contribuyente_id uuid NOT NULL,
anio smallint NOT NULL,
mes smallint NOT NULL,
reason text,
marcado_at timestamptz DEFAULT now(),
PRIMARY KEY (contribuyente_id, anio, mes)
);
INSERT INTO tenant_migrations (scope, version, name)
VALUES ('vertical-contable', 17, '017_metricas_invalidaciones')
ON CONFLICT (scope, version) DO NOTHING;

View File

@@ -0,0 +1,20 @@
CREATE TABLE IF NOT EXISTS obligaciones_contribuyente (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
contribuyente_id uuid NOT NULL REFERENCES contribuyentes(entidad_id) ON DELETE CASCADE,
catalogo_id text,
nombre text NOT NULL,
fundamento text,
frecuencia text,
fecha_limite text,
categoria text,
activa boolean DEFAULT true,
es_recomendada boolean DEFAULT false,
es_custom boolean DEFAULT false,
created_at timestamptz DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ix_obligaciones_contrib ON obligaciones_contribuyente(contribuyente_id, activa);
INSERT INTO tenant_migrations (scope, version, name)
VALUES ('vertical-contable', 18, '018_obligaciones_contribuyente')
ON CONFLICT (scope, version) DO NOTHING;

View File

@@ -0,0 +1,8 @@
ALTER TABLE obligaciones_contribuyente ADD COLUMN IF NOT EXISTS completada boolean DEFAULT false;
ALTER TABLE obligaciones_contribuyente ADD COLUMN IF NOT EXISTS completada_at timestamptz;
ALTER TABLE obligaciones_contribuyente ADD COLUMN IF NOT EXISTS completada_por uuid;
ALTER TABLE obligaciones_contribuyente ADD COLUMN IF NOT EXISTS periodo_completado varchar(7); -- "2026-04" (year-month)
INSERT INTO tenant_migrations (scope, version, name)
VALUES ('vertical-contable', 19, '019_obligaciones_completada')
ON CONFLICT (scope, version) DO NOTHING;

View File

@@ -0,0 +1,17 @@
CREATE TABLE IF NOT EXISTS obligacion_periodos (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
obligacion_id uuid NOT NULL REFERENCES obligaciones_contribuyente(id) ON DELETE CASCADE,
periodo varchar(7) NOT NULL,
completada boolean DEFAULT false,
completada_at timestamptz,
completada_por uuid,
notas text,
created_at timestamptz DEFAULT now(),
UNIQUE (obligacion_id, periodo)
);
CREATE INDEX IF NOT EXISTS ix_obligacion_periodos_periodo ON obligacion_periodos(periodo);
INSERT INTO tenant_migrations (scope, version, name)
VALUES ('vertical-contable', 20, '020_obligacion_periodos')
ON CONFLICT (scope, version) DO NOTHING;

View File

@@ -0,0 +1,15 @@
-- Add periodicidad (period type) and monto_pago (payment amount) to declaraciones.
-- periodicidad replaces the assumption that all declarations are monthly.
-- monto_pago = 0 means the declaration results in $0 to pay (auto-mark as paid).
ALTER TABLE declaraciones_provisionales
ADD COLUMN IF NOT EXISTS periodicidad VARCHAR(15) NOT NULL DEFAULT 'mensual'
CHECK (periodicidad IN ('mensual', 'bimestral', 'trimestral', 'semestral', 'anual'));
ALTER TABLE declaraciones_provisionales
ADD COLUMN IF NOT EXISTS monto_pago NUMERIC(15,2);
-- For existing rows that already have a payment proof, backfill pagado_at if null
UPDATE declaraciones_provisionales
SET pagado_at = updated_at
WHERE pdf_pago IS NOT NULL AND pagado_at IS NULL;

View File

@@ -0,0 +1,23 @@
-- Subcarteras: a cartera can be a child of another cartera.
-- Top-level carteras belong to a supervisor (or owner).
-- Subcarteras belong to an auxiliar within a parent cartera.
ALTER TABLE carteras
ADD COLUMN IF NOT EXISTS parent_id uuid REFERENCES carteras(id) ON DELETE CASCADE;
ALTER TABLE carteras
ADD COLUMN IF NOT EXISTS auxiliar_user_id uuid;
-- Allow supervisor_user_id to be NULL for subcarteras (inherited from parent)
ALTER TABLE carteras
ALTER COLUMN supervisor_user_id DROP NOT NULL;
CREATE INDEX IF NOT EXISTS ix_carteras_parent ON carteras(parent_id);
CREATE INDEX IF NOT EXISTS ix_carteras_auxiliar ON carteras(auxiliar_user_id);
-- Track which supervisor an auxiliar reports to (1:1 per auxiliar)
CREATE TABLE IF NOT EXISTS auxiliar_supervisores (
auxiliar_user_id uuid NOT NULL PRIMARY KEY,
supervisor_user_id uuid NOT NULL,
created_at timestamptz DEFAULT now()
);

View File

@@ -0,0 +1,4 @@
-- Bancos belong to individual contribuyentes, not the whole tenant.
-- Used for conciliación per-contribuyente.
ALTER TABLE bancos ADD COLUMN IF NOT EXISTS contribuyente_id uuid;
CREATE INDEX IF NOT EXISTS ix_bancos_contribuyente ON bancos(contribuyente_id);

View File

@@ -0,0 +1,12 @@
-- CFDIs descartados de alertas (ej: discrepancias de régimen que el usuario revisó y decidió ignorar).
-- El descarte se aplica por tipo de alerta y cfdi_id.
CREATE TABLE IF NOT EXISTS cfdi_descartados (
id serial PRIMARY KEY,
cfdi_id integer NOT NULL,
tipo_alerta text NOT NULL, -- e.g. 'discrepancia-regimen'
descartado_por text, -- email or userId
created_at timestamptz DEFAULT now(),
UNIQUE (cfdi_id, tipo_alerta)
);
CREATE INDEX IF NOT EXISTS ix_cfdi_descartados_tipo ON cfdi_descartados(tipo_alerta);

View File

@@ -0,0 +1,18 @@
-- Amplía contribuyentes.regimen_fiscal a TEXT para soportar CSV de múltiples
-- regímenes (ej. "626,605"). La migración 010 original lo declaró varchar(3)
-- asumiendo un solo régimen por contribuyente, pero el código sincroniza CSV
-- desde la CSF (sincronizarDatosFiscales en constancia.service.ts).
--
-- Síntoma antes del fix: el sync falla con "el valor es demasiado largo para
-- el tipo character varying(3)" cuando un contribuyente tiene ≥2 regímenes
-- activos en su CSF, y los campos regimen_fiscal/codigo_postal/domicilio
-- quedan NULL.
--
-- Idempotente: si ya es text (patito tenía parche manual), el ALTER es no-op
-- en términos de filas — Postgres lo resuelve como metadata change.
ALTER TABLE contribuyentes ALTER COLUMN regimen_fiscal TYPE text;
INSERT INTO tenant_migrations (scope, version, name)
VALUES ('vertical-contable', 25, '025_contribuyentes_regimen_fiscal_text')
ON CONFLICT (scope, version) DO NOTHING;

View File

@@ -0,0 +1,47 @@
-- Normaliza el case de cfdis.uuid y elimina duplicados generados por el
-- mismatch case-sensitive entre los dos paths de sync SAT:
-- - source='sat' (XML parser): insertaba UUIDs como venían del XML (lowercase)
-- - source='sat-metadata' (CSV parser): insertaba UUIDs del CSV (UPPERCASE)
-- La constraint UNIQUE(uuid) de Postgres es case-sensitive → ambos convivían
-- como filas distintas, duplicando el CFDI en todas las métricas.
--
-- Estrategia (idempotente — en tenants sin duplicados es no-op):
-- 1. Propagar status=Cancelado de metadata→sat si corresponde (guard de
-- data loss; verificado que en Patito no aplica a ninguna fila, pero la
-- cláusula queda como protección para futuros despachos).
-- 2. Borrar las filas 'sat-metadata' que tengan par 'sat' con el mismo UUID
-- (case-insensitive). Las filas sat-metadata sin par se conservan (son
-- legítimas: CFDIs cancelados sin XML).
-- 3. Normalizar todos los UUIDs restantes a lowercase.
-- El código de saveCfdis/saveMetadata también fue actualizado para (a) matchear
-- case-insensitive, (b) insertar siempre en lowercase.
-- 1. Propagar Cancelado antes de borrar
UPDATE cfdis sat
SET status = 'Cancelado',
fecha_cancelacion = COALESCE(sat.fecha_cancelacion, meta.fecha_cancelacion),
actualizado_en = NOW()
FROM cfdis meta
WHERE LOWER(sat.uuid) = LOWER(meta.uuid)
AND sat.id != meta.id
AND sat.source = 'sat'
AND meta.source = 'sat-metadata'
AND meta.status = 'Cancelado'
AND sat.status != 'Cancelado';
-- 2. Borrar duplicados sat-metadata
DELETE FROM cfdis meta
WHERE meta.source = 'sat-metadata'
AND EXISTS (
SELECT 1 FROM cfdis sat
WHERE LOWER(meta.uuid) = LOWER(sat.uuid)
AND sat.id != meta.id
AND sat.source = 'sat'
);
-- 3. Normalizar a lowercase
UPDATE cfdis SET uuid = LOWER(uuid) WHERE uuid IS NOT NULL AND uuid != LOWER(uuid);
INSERT INTO tenant_migrations (scope, version, name)
VALUES ('vertical-contable', 26, '026_normalize_cfdi_uuid_case')
ON CONFLICT (scope, version) DO NOTHING;

View File

@@ -0,0 +1,22 @@
-- Reemplaza el UNIQUE (uuid) case-sensitive por un índice funcional
-- UNIQUE (LOWER(uuid)), como defensa en profundidad contra duplicados por
-- mismatch de case. El código fuente ya normaliza a lowercase en el insert
-- (saveCfdis y saveMetadata en sat.service.ts), pero este constraint previene
-- que cualquier insert manual o vía futuro path pueda reintroducir el bug.
--
-- Prerequisito: migración 026 ya normalizó todos los UUIDs existentes a
-- lowercase y eliminó duplicados case-insensitive. Si no se aplicó antes, el
-- CREATE UNIQUE INDEX fallará con "could not create unique index" y habrá que
-- correr 026 primero.
--
-- Idempotente: si el índice nuevo ya existe, IF NOT EXISTS lo salta.
ALTER TABLE cfdis DROP CONSTRAINT IF EXISTS cfdis_uuid_key;
CREATE UNIQUE INDEX IF NOT EXISTS cfdis_uuid_lower_key
ON cfdis (LOWER(uuid))
WHERE uuid IS NOT NULL;
INSERT INTO tenant_migrations (scope, version, name)
VALUES ('vertical-contable', 27, '027_cfdi_uuid_unique_case_insensitive')
ON CONFLICT (scope, version) DO NOTHING;

View File

@@ -0,0 +1,26 @@
-- Pestaña "Extras" en /documentos: PDFs libres (acuses SAT, contratos, poderes,
-- estados de cuenta, comprobantes) organizados por contribuyente con categoría
-- de texto libre.
CREATE TABLE IF NOT EXISTS documentos_extras (
id serial PRIMARY KEY,
contribuyente_id uuid REFERENCES contribuyentes(entidad_id) ON DELETE CASCADE,
nombre varchar(255) NOT NULL,
descripcion text,
categoria varchar(100),
pdf bytea NOT NULL,
pdf_filename varchar(255) NOT NULL,
subido_por varchar(255),
created_at timestamptz DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ix_documentos_extras_contrib
ON documentos_extras(contribuyente_id, created_at DESC)
WHERE contribuyente_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS ix_documentos_extras_categoria
ON documentos_extras(categoria)
WHERE categoria IS NOT NULL;
INSERT INTO tenant_migrations (scope, version, name)
VALUES ('vertical-contable', 28, '028_documentos_extras')
ON CONFLICT (scope, version) DO NOTHING;

View File

@@ -0,0 +1,17 @@
-- #6 Trazabilidad declaración↔obligación: agrega FK a declaraciones_provisionales
-- en obligacion_periodos. ON DELETE SET NULL porque si la declaración se borra
-- el periodo puede seguir completado (el usuario puede haberlo cerrado sin
-- re-subir, o la completitud viene de otra fuente — "marcar manualmente"
-- via UI, etc.). La UI puede mostrar "via Declaración #123" cuando hay FK.
ALTER TABLE obligacion_periodos
ADD COLUMN IF NOT EXISTS declaracion_id integer
REFERENCES declaraciones_provisionales(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS ix_obligacion_periodos_declaracion
ON obligacion_periodos(declaracion_id)
WHERE declaracion_id IS NOT NULL;
INSERT INTO tenant_migrations (scope, version, name)
VALUES ('vertical-contable', 30, '030_obligacion_periodos_declaracion_id')
ON CONFLICT (scope, version) DO NOTHING;

View File

@@ -0,0 +1,27 @@
-- Fix: las declaraciones provisionales no distinguían contribuyente. En un
-- despacho con N RFCs, la declaración IVA de Alexa aparecía también cuando
-- se seleccionaba a Carlos. Agregamos FK nullable para linkear, y las
-- existentes quedan con NULL (interpretadas como "tenant-wide / legacy").
-- `ON DELETE SET NULL` para que borrar un contribuyente no tire declaraciones.
ALTER TABLE declaraciones_provisionales
ADD COLUMN IF NOT EXISTS contribuyente_id uuid
REFERENCES contribuyentes(entidad_id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS ix_declaraciones_contribuyente
ON declaraciones_provisionales(contribuyente_id, año DESC, mes DESC)
WHERE contribuyente_id IS NOT NULL;
-- Reemplaza el UNIQUE (año, mes) WHERE tipo='normal' por uno que incluye
-- contribuyente: cada RFC debe poder tener su propia declaración normal
-- para el mismo mes. Postgres trata NULL != NULL en índices, así que
-- declaraciones legacy sin contribuyente siguen pudiendo coexistir entre
-- sí — no se auto-de-duplican, pero tampoco bloquean las nuevas.
DROP INDEX IF EXISTS uniq_declaracion_normal_mes;
CREATE UNIQUE INDEX IF NOT EXISTS uniq_declaracion_normal_mes_contrib
ON declaraciones_provisionales(año, mes, contribuyente_id)
WHERE tipo = 'normal';
INSERT INTO tenant_migrations (scope, version, name)
VALUES ('vertical-contable', 31, '031_declaraciones_contribuyente_id')
ON CONFLICT (scope, version) DO NOTHING;

View File

@@ -0,0 +1,22 @@
-- Agrega soporte para CfdiRelacionados del propio comprobante (CFDI 4.0).
-- El campo existente `uuid_relacionado` se sigue usando para DoctoRelacionado
-- del complemento de Pagos (tipo P). Estas dos columnas nuevas son para los
-- CfdiRelacionados a nivel raíz del comprobante (típico en tipo E — notas
-- de crédito relacionadas a facturas I, P, o a anticipos aplicados).
--
-- `cfdi_tipo_relacion` — clave SAT de 2 chars (01 NC, 02 Sustitución,
-- 03 Devolución, 04 Sustitución CFDIs previos, 05 Traslados mercancía,
-- 06 Factura por traslado previo, 07 Aplicación de anticipo).
-- `cfdis_relacionados` — UUIDs pipe-separated del/los CfdiRelacionado.
ALTER TABLE cfdis
ADD COLUMN IF NOT EXISTS cfdi_tipo_relacion VARCHAR(2),
ADD COLUMN IF NOT EXISTS cfdis_relacionados TEXT;
CREATE INDEX IF NOT EXISTS ix_cfdis_tipo_relacion
ON cfdis(cfdi_tipo_relacion)
WHERE cfdi_tipo_relacion IS NOT NULL;
INSERT INTO tenant_migrations (scope, version, name)
VALUES ('vertical-contable', 32, '032_cfdis_relaciones')
ON CONFLICT (scope, version) DO NOTHING;

View File

@@ -0,0 +1,11 @@
-- Marca el timestamp del último rechazo SAT que sugiere que el CSD
-- aún no está propagado en la Lista de Contribuyentes Obligados (LCO).
-- La propagación tarda 24-72h; el frontend muestra un banner mientras
-- esta marca esté dentro de las últimas 24h.
ALTER TABLE facturapi_orgs
ADD COLUMN IF NOT EXISTS last_lco_rejection_at timestamptz;
INSERT INTO tenant_migrations (scope, version, name)
VALUES ('vertical-contable', 33, '033_facturapi_orgs_lco_rejection')
ON CONFLICT (scope, version) DO NOTHING;

View File

@@ -0,0 +1,12 @@
-- Preferencias de notificación por correo, por contribuyente.
-- Default: objeto vacío → el código interpreta "todo activado" como
-- comportamiento previo. Cuando el user desactiva un tipo, se guarda
-- `{ "<tipo>": false }`. Tipos soportados (informativos):
-- weekly_update, fiel_notification, documento_subido, subscription_expiring
ALTER TABLE contribuyentes
ADD COLUMN IF NOT EXISTS email_preferences jsonb DEFAULT '{}'::jsonb;
INSERT INTO tenant_migrations (scope, version, name)
VALUES ('vertical-contable', 34, '034_contribuyentes_email_preferences')
ON CONFLICT (scope, version) DO NOTHING;

View File

@@ -0,0 +1,46 @@
-- Tareas operativas del despacho por contribuyente. Recurrentes (semanal a
-- anual). Materialización lazy en tarea_periodos cuando el frontend lee.
-- Solo del presente en adelante.
CREATE TABLE IF NOT EXISTS tareas_catalogo (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
contribuyente_id uuid NOT NULL REFERENCES contribuyentes(entidad_id) ON DELETE CASCADE,
nombre text NOT NULL,
descripcion text,
recurrencia varchar(15) NOT NULL CHECK (recurrencia IN
('semanal','quincenal','mensual','bimestral','trimestral','semestral','anual')),
-- Para semanal/quincenal: día de la semana (1=lunes, 7=domingo)
dia_semana int CHECK (dia_semana BETWEEN 1 AND 7),
-- Para mensual y mayores: día del mes (1-31). Si > último día del mes,
-- se materializa al último día (ej. 31 en febrero → 28/29).
dia_mes int CHECK (dia_mes BETWEEN 1 AND 31),
solo_supervisor_completa boolean DEFAULT false,
es_default boolean DEFAULT false,
active boolean DEFAULT true,
orden int DEFAULT 0,
created_at timestamptz DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ix_tareas_catalogo_contrib ON tareas_catalogo(contribuyente_id, active);
CREATE TABLE IF NOT EXISTS tarea_periodos (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tarea_id uuid NOT NULL REFERENCES tareas_catalogo(id) ON DELETE CASCADE,
-- '2025-W12' semanal/quincenal, '2025-01' mensual, '2025-B1' bimestral,
-- '2025-Q1' trimestral, '2025-S1' semestral, '2025' anual.
periodo varchar(10) NOT NULL,
fecha_limite date NOT NULL,
completada boolean DEFAULT false,
completada_at timestamptz,
completada_por uuid,
notas text,
created_at timestamptz DEFAULT now(),
UNIQUE (tarea_id, periodo)
);
CREATE INDEX IF NOT EXISTS ix_tarea_periodos_fecha ON tarea_periodos(fecha_limite);
CREATE INDEX IF NOT EXISTS ix_tarea_periodos_completada ON tarea_periodos(completada);
INSERT INTO tenant_migrations (scope, version, name)
VALUES ('vertical-contable', 35, '035_tareas')
ON CONFLICT (scope, version) DO NOTHING;

View File

@@ -0,0 +1,38 @@
-- Papelería de trabajo: archivos del despacho por contribuyente, organizados
-- por mes/año y con flujo opcional de aprobación. Formatos permitidos
-- (validados en backend): pdf, word (doc/docx), excel (xls/xlsx). Máx 5 MB
-- por archivo (validado en backend). NO accesible para usuarios rol cliente.
CREATE TABLE IF NOT EXISTS papeleria_trabajo (
id serial PRIMARY KEY,
contribuyente_id uuid NOT NULL REFERENCES contribuyentes(entidad_id) ON DELETE CASCADE,
nombre varchar(255) NOT NULL, -- "Reporte de cuentas Q1"
descripcion text,
archivo bytea NOT NULL,
archivo_filename varchar(255) NOT NULL, -- "reporte.pdf"
archivo_mime varchar(100) NOT NULL,
archivo_size int NOT NULL,
-- periodo (mes + año)
anio int NOT NULL CHECK (anio BETWEEN 2000 AND 2100),
mes int NOT NULL CHECK (mes BETWEEN 1 AND 12),
-- flujo de aprobación
requiere_aprobacion boolean NOT NULL DEFAULT false,
estado varchar(20) CHECK (estado IS NULL OR estado IN ('pendiente','aprobado','rechazado')),
aprobado_por uuid,
aprobado_at timestamptz,
comentario_rechazo text,
subido_por uuid NOT NULL,
created_at timestamptz DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ix_papeleria_contrib
ON papeleria_trabajo(contribuyente_id, created_at DESC);
CREATE INDEX IF NOT EXISTS ix_papeleria_periodo
ON papeleria_trabajo(contribuyente_id, anio, mes);
CREATE INDEX IF NOT EXISTS ix_papeleria_estado
ON papeleria_trabajo(estado)
WHERE estado IS NOT NULL;
INSERT INTO tenant_migrations (scope, version, name)
VALUES ('vertical-contable', 36, '036_papeleria_trabajo')
ON CONFLICT (scope, version) DO NOTHING;

View File

@@ -0,0 +1,22 @@
-- Tracking de activos fijos dados de baja (vendidos / desechados / otro).
-- Solo aplica a CFDIs tipo I con uso_cfdi I01-I08 cuyo receptor es el
-- contribuyente. Una fila por CFDI dado de baja; revertir = DELETE.
-- La pestaña "Activos Fijos" en /impuestos consulta esta tabla para
-- detener el cómputo de deducción mensual a partir de `fecha_baja`.
CREATE TABLE IF NOT EXISTS activos_fijos_baja (
id serial PRIMARY KEY,
cfdi_id int NOT NULL REFERENCES cfdis(id) ON DELETE CASCADE,
fecha_baja date NOT NULL,
motivo varchar(20) NOT NULL CHECK (motivo IN ('venta','desecho','otro')),
comentario text,
dado_de_baja_por uuid NOT NULL,
created_at timestamptz DEFAULT now(),
UNIQUE (cfdi_id)
);
CREATE INDEX IF NOT EXISTS ix_activos_fijos_baja_cfdi ON activos_fijos_baja(cfdi_id);
INSERT INTO tenant_migrations (scope, version, name)
VALUES ('vertical-contable', 37, '037_activos_fijos_baja')
ON CONFLICT (scope, version) DO NOTHING;

View File

@@ -0,0 +1,11 @@
-- Permite al contador descartar conceptos de uso CFDI (ej. I06, I07) que
-- en su contribuyente no representan adquisiciones de activos fijos sino
-- gastos regulares (ej. servicio telefónico mensual). Default: lista vacía
-- (todos los usos I01-I08 se consideran activos fijos como hasta ahora).
ALTER TABLE contribuyentes
ADD COLUMN IF NOT EXISTS activos_fijos_usos_excluidos jsonb DEFAULT '[]'::jsonb;
INSERT INTO tenant_migrations (scope, version, name)
VALUES ('vertical-contable', 38, '038_activos_fijos_usos_excluidos')
ON CONFLICT (scope, version) DO NOTHING;

View File

@@ -0,0 +1,22 @@
-- Tracking de alertas automáticas que ya fueron notificadas por email.
-- Mecanismo idempotente: una sola email por (alerta_id, contribuyente_id).
-- Si la alerta deja de devolverse por `generarAlertasAutomaticas`, se marca
-- `resuelta_at`. Si vuelve a aparecer (NULL en resuelta_at), no se re-notifica
-- — política conservadora de Option B (una sola notificación por evento).
CREATE TABLE IF NOT EXISTS alertas_notificadas (
id BIGSERIAL PRIMARY KEY,
alerta_id TEXT NOT NULL,
contribuyente_id UUID,
notified_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
resuelta_at TIMESTAMPTZ
);
-- UNIQUE compuesto con COALESCE para que NULL en contribuyente_id no rompa
-- la dedup (alertas tenant-level vs contribuyente-específicas comparten tabla).
CREATE UNIQUE INDEX IF NOT EXISTS uniq_alertas_notif
ON alertas_notificadas (alerta_id, COALESCE(contribuyente_id::text, ''));
-- Índice para queries del cron que filtra por contribuyente.
CREATE INDEX IF NOT EXISTS idx_alertas_notif_contribuyente
ON alertas_notificadas (contribuyente_id) WHERE contribuyente_id IS NOT NULL;

View File

@@ -0,0 +1,12 @@
-- Tracking de notificaciones email enviadas por recordatorio en cada
-- ventana de proximidad (3 días, 1 día, mismo día). Cada columna se llena
-- una sola vez cuando el cron envía el email correspondiente.
--
-- Si el usuario edita `fecha_limite` después de haber enviado un email,
-- las columnas previas siguen marcadas — el cron no re-notificará para
-- ventanas ya enviadas. Decisión MVP: simple y predecible.
ALTER TABLE recordatorios
ADD COLUMN IF NOT EXISTS email_3d_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS email_1d_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS email_0d_at TIMESTAMPTZ;

View File

@@ -0,0 +1,11 @@
import { Router, type IRouter } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js';
import * as ctrl from '../controllers/admin-addons.controller.js';
const router: IRouter = Router();
router.use(authenticate);
router.get('/catalogo', ctrl.listCatalogo);
router.put('/catalogo/:id', ctrl.updateCatalogoItem);
export default router;

View File

@@ -0,0 +1,11 @@
import { Router, type IRouter } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js';
import * as ctrl from '../controllers/admin-clientes.controller.js';
const router: IRouter = Router();
router.use(authenticate);
router.get('/stats', ctrl.getStats);
router.get('/:tenantId/usuarios', ctrl.listUsuarios);
export default router;

View File

@@ -0,0 +1,12 @@
import { Router, type IRouter } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js';
import * as ctrl from '../controllers/admin-dashboard.controller.js';
const router: IRouter = Router();
router.use(authenticate);
router.get('/metrics', ctrl.getMetrics);
router.get('/despachos', ctrl.listDespachos);
router.get('/activity', ctrl.getActivity);
export default router;

Some files were not shown because too many files have changed in this diff Show More