Update: nueva version Horux Despachos
This commit is contained in:
112
apps/api/src/app.ts
Normal file
112
apps/api/src/app.ts
Normal 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 };
|
||||
1
apps/api/src/auth/passwords.ts
Normal file
1
apps/api/src/auth/passwords.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { hashPassword, verifyPassword } from '@horux/core';
|
||||
30
apps/api/src/auth/tokens.ts
Normal file
30
apps/api/src/auth/tokens.ts
Normal 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 };
|
||||
234
apps/api/src/config/database.ts
Normal file
234
apps/api/src/config/database.ts
Normal 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();
|
||||
63
apps/api/src/config/env.ts
Normal file
63
apps/api/src/config/env.ts
Normal 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());
|
||||
}
|
||||
143
apps/api/src/config/tenant-migrations.ts
Normal file
143
apps/api/src/config/tenant-migrations.ts
Normal 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 };
|
||||
}
|
||||
84
apps/api/src/constants/obligaciones-fiscales.ts
Normal file
84
apps/api/src/constants/obligaciones-fiscales.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
87
apps/api/src/controllers/activos-fijos.controller.ts
Normal file
87
apps/api/src/controllers/activos-fijos.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
86
apps/api/src/controllers/admin-addons.controller.ts
Normal file
86
apps/api/src/controllers/admin-addons.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
46
apps/api/src/controllers/admin-clientes.controller.ts
Normal file
46
apps/api/src/controllers/admin-clientes.controller.ts
Normal 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); }
|
||||
}
|
||||
36
apps/api/src/controllers/admin-dashboard.controller.ts
Normal file
36
apps/api/src/controllers/admin-dashboard.controller.ts
Normal 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); }
|
||||
}
|
||||
77
apps/api/src/controllers/admin-impersonate.controller.ts
Normal file
77
apps/api/src/controllers/admin-impersonate.controller.ts
Normal 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); }
|
||||
}
|
||||
506
apps/api/src/controllers/alertas.controller.ts
Normal file
506
apps/api/src/controllers/alertas.controller.ts
Normal 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); }
|
||||
}
|
||||
87
apps/api/src/controllers/audit-log.controller.ts
Normal file
87
apps/api/src/controllers/audit-log.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
189
apps/api/src/controllers/auth.controller.ts
Normal file
189
apps/api/src/controllers/auth.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
62
apps/api/src/controllers/bancos.controller.ts
Normal file
62
apps/api/src/controllers/bancos.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
175
apps/api/src/controllers/calendario.controller.ts
Normal file
175
apps/api/src/controllers/calendario.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
277
apps/api/src/controllers/cartera.controller.ts
Normal file
277
apps/api/src/controllers/cartera.controller.ts
Normal 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); }
|
||||
}
|
||||
108
apps/api/src/controllers/catalogos.controller.ts
Normal file
108
apps/api/src/controllers/catalogos.controller.ts
Normal 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); }
|
||||
}
|
||||
446
apps/api/src/controllers/cfdi.controller.ts
Normal file
446
apps/api/src/controllers/cfdi.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
58
apps/api/src/controllers/conciliacion.controller.ts
Normal file
58
apps/api/src/controllers/conciliacion.controller.ts
Normal 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); }
|
||||
}
|
||||
58
apps/api/src/controllers/connector.controller.ts
Normal file
58
apps/api/src/controllers/connector.controller.ts
Normal 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); }
|
||||
}
|
||||
95
apps/api/src/controllers/contribuyente-config.controller.ts
Normal file
95
apps/api/src/controllers/contribuyente-config.controller.ts
Normal 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); }
|
||||
}
|
||||
148
apps/api/src/controllers/contribuyente.controller.ts
Normal file
148
apps/api/src/controllers/contribuyente.controller.ts
Normal 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); }
|
||||
}
|
||||
108
apps/api/src/controllers/dashboard.controller.ts
Normal file
108
apps/api/src/controllers/dashboard.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
67
apps/api/src/controllers/despacho-audit.controller.ts
Normal file
67
apps/api/src/controllers/despacho-audit.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
67
apps/api/src/controllers/despacho-stats.controller.ts
Normal file
67
apps/api/src/controllers/despacho-stats.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
95
apps/api/src/controllers/despacho.controller.ts
Normal file
95
apps/api/src/controllers/despacho.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
333
apps/api/src/controllers/documentos.controller.ts
Normal file
333
apps/api/src/controllers/documentos.controller.ts
Normal 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); }
|
||||
}
|
||||
42
apps/api/src/controllers/export.controller.ts
Normal file
42
apps/api/src/controllers/export.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
639
apps/api/src/controllers/facturacion.controller.ts
Normal file
639
apps/api/src/controllers/facturacion.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
136
apps/api/src/controllers/fiel.controller.ts
Normal file
136
apps/api/src/controllers/fiel.controller.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
171
apps/api/src/controllers/impuestos.controller.ts
Normal file
171
apps/api/src/controllers/impuestos.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
25
apps/api/src/controllers/metricas.controller.ts
Normal file
25
apps/api/src/controllers/metricas.controller.ts
Normal 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); }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
111
apps/api/src/controllers/obligaciones.controller.ts
Normal file
111
apps/api/src/controllers/obligaciones.controller.ts
Normal 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); }
|
||||
}
|
||||
263
apps/api/src/controllers/papeleria.controller.ts
Normal file
263
apps/api/src/controllers/papeleria.controller.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
26
apps/api/src/controllers/plan-catalogo.controller.ts
Normal file
26
apps/api/src/controllers/plan-catalogo.controller.ts
Normal 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); }
|
||||
}
|
||||
187
apps/api/src/controllers/platform-staff.controller.ts
Normal file
187
apps/api/src/controllers/platform-staff.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
70
apps/api/src/controllers/regimen.controller.ts
Normal file
70
apps/api/src/controllers/regimen.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
85
apps/api/src/controllers/reportes.controller.ts
Normal file
85
apps/api/src/controllers/reportes.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
168
apps/api/src/controllers/sat.controller.ts
Normal file
168
apps/api/src/controllers/sat.controller.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
419
apps/api/src/controllers/subscription.controller.ts
Normal file
419
apps/api/src/controllers/subscription.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
177
apps/api/src/controllers/tareas.controller.ts
Normal file
177
apps/api/src/controllers/tareas.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
148
apps/api/src/controllers/tenants.controller.ts
Normal file
148
apps/api/src/controllers/tenants.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
273
apps/api/src/controllers/usuarios.controller.ts
Normal file
273
apps/api/src/controllers/usuarios.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
218
apps/api/src/controllers/webhook.controller.ts
Normal file
218
apps/api/src/controllers/webhook.controller.ts
Normal 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
57
apps/api/src/index.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
53
apps/api/src/jobs/metricas-invalidations.job.ts
Normal file
53
apps/api/src/jobs/metricas-invalidations.job.ts
Normal 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 };
|
||||
104
apps/api/src/jobs/notifications.job.ts
Normal file
104
apps/api/src/jobs/notifications.job.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
468
apps/api/src/jobs/sat-sync.job.ts
Normal file
468
apps/api/src/jobs/sat-sync.job.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
154
apps/api/src/jobs/weekly-update.job.ts
Normal file
154
apps/api/src/jobs/weekly-update.job.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
100
apps/api/src/middlewares/auth.middleware.ts
Normal file
100
apps/api/src/middlewares/auth.middleware.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
33
apps/api/src/middlewares/error.middleware.ts
Normal file
33
apps/api/src/middlewares/error.middleware.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
49
apps/api/src/middlewares/feature-gate.middleware.ts
Normal file
49
apps/api/src/middlewares/feature-gate.middleware.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
90
apps/api/src/middlewares/plan-limits.middleware.ts
Normal file
90
apps/api/src/middlewares/plan-limits.middleware.ts
Normal 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();
|
||||
}
|
||||
92
apps/api/src/middlewares/rate-limit.middleware.ts
Normal file
92
apps/api/src/middlewares/rate-limit.middleware.ts
Normal 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.',
|
||||
},
|
||||
});
|
||||
144
apps/api/src/middlewares/tenant.middleware.ts
Normal file
144
apps/api/src/middlewares/tenant.middleware.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
244
apps/api/src/migrations/tenant/001_initial_schema.sql
Normal file
244
apps/api/src/migrations/tenant/001_initial_schema.sql
Normal 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 $$;
|
||||
@@ -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);
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
18
apps/api/src/migrations/tenant/007_entidades_gestionadas.sql
Normal file
18
apps/api/src/migrations/tenant/007_entidades_gestionadas.sql
Normal 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;
|
||||
27
apps/api/src/migrations/tenant/008_carteras.sql
Normal file
27
apps/api/src/migrations/tenant/008_carteras.sql
Normal 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;
|
||||
10
apps/api/src/migrations/tenant/009_cliente_accesos.sql
Normal file
10
apps/api/src/migrations/tenant/009_cliente_accesos.sql
Normal 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;
|
||||
13
apps/api/src/migrations/tenant/010_contribuyentes.sql
Normal file
13
apps/api/src/migrations/tenant/010_contribuyentes.sql
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
52
apps/api/src/migrations/tenant/014_metricas_mensuales.sql
Normal file
52
apps/api/src/migrations/tenant/014_metricas_mensuales.sql
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
17
apps/api/src/migrations/tenant/020_obligacion_periodos.sql
Normal file
17
apps/api/src/migrations/tenant/020_obligacion_periodos.sql
Normal 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;
|
||||
@@ -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;
|
||||
23
apps/api/src/migrations/tenant/022_carteras_subcarteras.sql
Normal file
23
apps/api/src/migrations/tenant/022_carteras_subcarteras.sql
Normal 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()
|
||||
);
|
||||
@@ -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);
|
||||
12
apps/api/src/migrations/tenant/024_cfdi_descartados.sql
Normal file
12
apps/api/src/migrations/tenant/024_cfdi_descartados.sql
Normal 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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
26
apps/api/src/migrations/tenant/028_documentos_extras.sql
Normal file
26
apps/api/src/migrations/tenant/028_documentos_extras.sql
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
22
apps/api/src/migrations/tenant/032_cfdis_relaciones.sql
Normal file
22
apps/api/src/migrations/tenant/032_cfdis_relaciones.sql
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
46
apps/api/src/migrations/tenant/035_tareas.sql
Normal file
46
apps/api/src/migrations/tenant/035_tareas.sql
Normal 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;
|
||||
38
apps/api/src/migrations/tenant/036_papeleria_trabajo.sql
Normal file
38
apps/api/src/migrations/tenant/036_papeleria_trabajo.sql
Normal 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;
|
||||
22
apps/api/src/migrations/tenant/037_activos_fijos_baja.sql
Normal file
22
apps/api/src/migrations/tenant/037_activos_fijos_baja.sql
Normal 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;
|
||||
@@ -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;
|
||||
22
apps/api/src/migrations/tenant/039_alertas_notificadas.sql
Normal file
22
apps/api/src/migrations/tenant/039_alertas_notificadas.sql
Normal 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;
|
||||
@@ -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;
|
||||
11
apps/api/src/routes/admin-addons.routes.ts
Normal file
11
apps/api/src/routes/admin-addons.routes.ts
Normal 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;
|
||||
11
apps/api/src/routes/admin-clientes.routes.ts
Normal file
11
apps/api/src/routes/admin-clientes.routes.ts
Normal 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;
|
||||
12
apps/api/src/routes/admin-dashboard.routes.ts
Normal file
12
apps/api/src/routes/admin-dashboard.routes.ts
Normal 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
Reference in New Issue
Block a user