Update: nueva version Horux Despachos

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

116
docs/superpowers/INDEX.md Normal file
View File

@@ -0,0 +1,116 @@
# Horux Despachos — Índice del proyecto
Este documento es el punto de entrada para cualquier sesión de trabajo en el pivote "Horux Despachos". Empieza aquí.
**Producto:** SaaS para despachos contables mexicanos (MVP), extensible a otras verticales profesionales (jurídica, arquitectura).
**Base de código:** fork de Horux360 en esta carpeta.
**Estado:** diseño aprobado, Plan 1 listo para ejecución, Planes 2-8 pendientes de brainstorm.
**Fecha inicio:** 2026-04-16.
---
## Lo primero que debes leer
1. **[Spec de diseño](specs/2026-04-16-horux-despachos-design.md)** — 16 secciones, decisiones arquitectónicas, modelo de datos, flujos, riesgos, roadmap de fases. Fuente de verdad para qué se construye.
2. **[Plan 1 — Refactor preparatorio del monorepo](plans/2026-04-16-refactor-monorepo-packages.md)** — 20 tasks, ~100 steps. Se ejecuta primero porque desbloquea todas las fases siguientes.
---
## Roadmap de 8 planes
Cada plan se construye sobre los anteriores. Cada uno pasa por su propio ciclo brainstorm → spec → plan → ejecución con la skill apropiada.
| # | Plan | Estado | Estimado | Spec | Plan |
|---|------|--------|----------|------|------|
| **1** | **Refactor preparatorio del monorepo** | ✅ Plan listo | 1-2 sem | Ver spec §13 | [Plan 1](plans/2026-04-16-refactor-monorepo-packages.md) |
| 2 | Cimientos de Despachos | ⏳ Pendiente | 3-4 sem | Pendiente | Pendiente |
| 3 | Roles y carteras | ⏳ Pendiente | 2 sem | Pendiente | Pendiente |
| 4 | Pricing y pagos | ⏳ Pendiente | 2-3 sem | Pendiente | Pendiente |
| 5 | Connector BYO-DB | ⏳ Pendiente | 2 sem | Pendiente | Pendiente |
| 6 | Admin global + dashboard cross-despacho | ⏳ Pendiente | 2 sem | Pendiente | Pendiente |
| 7 | Métricas pre-calculadas | ⏳ Pendiente | 2 sem | Pendiente | Pendiente |
| 8 | Polish + launch privado | ⏳ Pendiente | 1-2 sem | Pendiente | Pendiente |
**Total estimado:** 15-19 semanas hasta launch privado.
Detalle de alcance de cada plan en el spec §15.
---
## Cómo arrancar la próxima sesión
### Si vas a ejecutar el Plan 1 (refactor):
1. Verifica estado del repo:
```bash
cd /c/Users/chtr1/Downloads/Horux_despacho
git status
git log --oneline -5
```
2. Asegura deps instaladas:
```bash
pnpm install
pnpm -r typecheck # baseline sin errores antes de empezar
```
3. Invoca la skill de ejecución:
- **Recomendada:** `superpowers:subagent-driven-development` — fresh subagent por task, review entre tasks, contexto limpio.
- **Alternativa:** `superpowers:executing-plans` — ejecución inline con checkpoints.
4. Apunta la skill al plan:
- Path: `docs/superpowers/plans/2026-04-16-refactor-monorepo-packages.md`
5. Commits locales frecuentes (uno por task); **NO hacer `git push`** (no hay remote todavía).
### Si vas a brainstormear el siguiente plan (Plan 2):
1. Leer el spec completo (ya aprobado — no re-brainstorm decisiones ya cerradas).
2. Invocar `superpowers:brainstorming` con contexto: "brainstorm Plan 2 — Cimientos de Despachos: BD Central (Prisma), BD Tenant (migrations core + vertical contable), auth con despachoId, signup → trial → agregar contribuyente → FIEL/CSD → primera emisión CFDI."
3. El brainstorm genera un spec propio en `specs/` y termina en writing-plans que escribe el Plan 2 en `plans/`.
---
## Decisiones cerradas (no re-brainstorm)
Estas decisiones están aprobadas en el spec y NO deben revisarse salvo nueva evidencia:
- **Hosting:** Opción A (SaaS central + BYO-DB / Managed).
- **Tiers BD:** dos tiers (BYO barato, Managed premium).
- **Roles despacho:** Owner / Supervisor / Auxiliar / Cliente.
- **Carteras:** jerárquicas con cascada; Owner = Supervisor implícito.
- **Facturapi:** cuenta maestra Horux broker; 1 org por contribuyente; pool único timbres por despacho.
- **Connector:** Cloudflare Tunnel + Docker `horux/connector`.
- **Pricing:** tiers fijos + add-ons + paquetes one-shot.
- **Monorepo:** unificado, refactor preparatorio a `packages/core`, `packages/vertical-contable`, `packages/shared-ui`.
- **Admins globales:** impersonación con motivo + audit + dashboard cross-despacho.
- **Clientes-visores:** password + magic link + multi-RFC + multi-despacho.
- **Multi-vertical:** arquitectura preparada desde MVP.
- **Métricas:** hot/cold con drill-down + formula_version + invalidación dirigida.
---
## Archivos del directorio
```
docs/superpowers/
├── INDEX.md ← este archivo
├── specs/
│ └── 2026-04-16-horux-despachos-design.md ← spec completo
└── plans/
└── 2026-04-16-refactor-monorepo-packages.md ← Plan 1
```
Los Planes 2-8 agregarán specs y plans a sus respectivas carpetas conforme se vayan brainstormeando.
---
## Convenciones del proyecto
- **Disciplina TS:** `pnpm typecheck` tras cada cambio relevante. CI/guardrail local.
- **Tests:** Plan 1 NO agrega framework de tests (eso es proyecto aparte); valida con typecheck + smoke test manual.
- **Git:** commits locales por task; sin push (no hay remote); no `--amend` sobre commits existentes; no `--no-verify`.
- **Working directory único:** todo en `Downloads/Horux_despacho`. El repo de Horux360 en OneDrive NO se toca para este pivote.
- **Convenciones Prisma:** modelos en PascalCase, campos camelCase; relaciones explícitas con `@relation`.
- **Convenciones SQL (BD tenant):** snake_case, FK NOT NULL donde aplica, índices sobre `contribuyente_id` en todas las tablas verticales.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,895 @@
# Conciliacion Module Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a reconciliation module that lets users match CFDIs to bank payments, with bank account management in settings.
**Architecture:** Two new tables in tenant DBs (`bancos`, `conciliaciones`) + new column `id_conciliacion` in `cfdis`. Backend: service/controller/routes for each entity. Frontend: new `/conciliacion` page with tabs + bancos section in configuracion.
**Tech Stack:** Express + pg Pool (backend), Next.js + React Query (frontend), existing shadcn/ui components.
**Spec:** `docs/superpowers/specs/2026-04-12-conciliacion-design.md`
---
### Task 1: Database Schema — DDL and Migration
**Files:**
- Modify: `apps/api/src/config/database.ts:359-371` (createTables, after alertas)
- Modify: `apps/api/src/config/database.ts:374-393` (createIndexes)
- [ ] **Step 1: Add `bancos` and `conciliaciones` tables to createTables()**
In `apps/api/src/config/database.ts`, inside `createTables()`, after the `alertas` table block (line 369) and before the closing backtick+`);` (line 371), add:
```sql
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 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()
);
```
- [ ] **Step 2: Add `id_conciliacion` column to `cfdis` DDL**
In the same `createTables()`, in the `cfdis` CREATE TABLE block, after the `conciliado VARCHAR(50),` line (around line 304), add:
```sql
id_conciliacion INTEGER REFERENCES conciliaciones(id),
```
**Note:** `conciliaciones` table must be created BEFORE `cfdis` for the FK to work. Move the `bancos` and `conciliaciones` CREATE TABLE blocks to BEFORE the `cfdis` block (after `rfcs`, before `cfdis`).
- [ ] **Step 3: Add indexes for conciliaciones in createIndexes()**
In `createIndexes()`, after the cfdi_conceptos indexes, add:
```sql
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);
```
- [ ] **Step 4: Migrate existing tenant**
Run these SQL commands against `horux_ede123456ab1`:
```sql
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 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()
);
ALTER TABLE cfdis ADD COLUMN IF NOT EXISTS id_conciliacion INTEGER REFERENCES conciliaciones(id);
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);
```
- [ ] **Step 5: Verify**
```bash
psql "postgresql://postgres:Hesoy%40m11@localhost:5432/horux_ede123456ab1" -c "\dt"
```
Expected: `bancos` and `conciliaciones` in the table list, `cfdis` has `id_conciliacion` column.
---
### Task 2: Backend — Bancos Service, Controller, Routes
**Files:**
- Create: `apps/api/src/services/bancos.service.ts`
- Create: `apps/api/src/controllers/bancos.controller.ts`
- Create: `apps/api/src/routes/bancos.routes.ts`
- Modify: `apps/api/src/app.ts`
- [ ] **Step 1: Create bancos service**
Create `apps/api/src/services/bancos.service.ts`:
```typescript
import type { Pool } from 'pg';
export interface Banco {
id: number;
banco: string;
terminacionCuenta: string;
creadoEn: string;
}
export async function getBancos(pool: Pool): Promise<Banco[]> {
const { rows } = await pool.query(`
SELECT id, banco, terminacion_cuenta as "terminacionCuenta",
creado_en as "creadoEn"
FROM bancos ORDER BY banco
`);
return rows;
}
export async function createBanco(pool: Pool, data: { banco: string; terminacionCuenta: string }): Promise<Banco> {
const { rows } = await pool.query(`
INSERT INTO bancos (banco, terminacion_cuenta)
VALUES ($1, $2)
RETURNING id, banco, terminacion_cuenta as "terminacionCuenta", creado_en as "creadoEn"
`, [data.banco, data.terminacionCuenta]);
return rows[0];
}
export async function updateBanco(pool: Pool, id: number, data: { banco?: string; terminacionCuenta?: string }): Promise<Banco> {
const fields: string[] = [];
const params: any[] = [];
let idx = 1;
if (data.banco) { fields.push(`banco = $${idx++}`); params.push(data.banco); }
if (data.terminacionCuenta) { fields.push(`terminacion_cuenta = $${idx++}`); params.push(data.terminacionCuenta); }
if (fields.length === 0) throw new Error('Nada que actualizar');
params.push(id);
const { rows } = await pool.query(`
UPDATE bancos SET ${fields.join(', ')} WHERE id = $${idx}
RETURNING id, banco, terminacion_cuenta as "terminacionCuenta", creado_en as "creadoEn"
`, params);
if (rows.length === 0) throw new Error('Banco no encontrado');
return rows[0];
}
export async function deleteBanco(pool: Pool, id: number): Promise<void> {
const { rows } = await pool.query(
`SELECT COUNT(*)::int as count FROM conciliaciones WHERE id_banco = $1`, [id]
);
if (rows[0].count > 0) {
throw new Error('No se puede eliminar un banco con conciliaciones asociadas');
}
await pool.query(`DELETE FROM bancos WHERE id = $1`, [id]);
}
```
- [ ] **Step 2: Create bancos controller**
Create `apps/api/src/controllers/bancos.controller.ts`:
```typescript
import type { Request, Response, NextFunction } from 'express';
import * as bancosService from '../services/bancos.service.js';
export async function getBancos(req: Request, res: Response, next: NextFunction) {
try {
const bancos = await bancosService.getBancos(req.tenantPool!);
res.json(bancos);
} catch (error) { next(error); }
}
export async function createBanco(req: Request, res: Response, next: NextFunction) {
try {
if (req.user!.role !== 'admin') return res.status(403).json({ message: 'No autorizado' });
const { banco, terminacionCuenta } = req.body;
if (!banco || !terminacionCuenta) return res.status(400).json({ message: 'banco y terminacionCuenta son requeridos' });
if (terminacionCuenta.length > 4) return res.status(400).json({ message: 'terminacionCuenta max 4 digitos' });
const result = await bancosService.createBanco(req.tenantPool!, { banco, terminacionCuenta });
res.status(201).json(result);
} catch (error) { next(error); }
}
export async function updateBanco(req: Request, res: Response, next: NextFunction) {
try {
if (req.user!.role !== 'admin') return res.status(403).json({ message: 'No autorizado' });
const id = parseInt(req.params.id);
const result = await bancosService.updateBanco(req.tenantPool!, id, req.body);
res.json(result);
} catch (error) { next(error); }
}
export async function deleteBanco(req: Request, res: Response, next: NextFunction) {
try {
if (req.user!.role !== 'admin') return res.status(403).json({ message: 'No autorizado' });
const id = parseInt(req.params.id);
await bancosService.deleteBanco(req.tenantPool!, id);
res.json({ message: 'Banco eliminado' });
} catch (error) { next(error); }
}
```
- [ ] **Step 3: Create bancos routes**
Create `apps/api/src/routes/bancos.routes.ts`:
```typescript
import { Router, type IRouter } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js';
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
import * as bancosController from '../controllers/bancos.controller.js';
const router: IRouter = Router();
router.use(authenticate);
router.use(tenantMiddleware);
router.get('/', bancosController.getBancos);
router.post('/', bancosController.createBanco);
router.put('/:id', bancosController.updateBanco);
router.delete('/:id', bancosController.deleteBanco);
export { router as bancosRoutes };
```
- [ ] **Step 4: Register bancos routes in app.ts**
In `apps/api/src/app.ts`, add import and route:
```typescript
import { bancosRoutes } from './routes/bancos.routes.js';
// ... after regimenRoutes line:
app.use('/api/bancos', bancosRoutes);
```
- [ ] **Step 5: Verify bancos API**
```bash
TOKEN=$(curl -s -X POST http://localhost:4000/api/auth/login -H "Content-Type: application/json" -d '{"email":"admin@demo.com","password":"demo123"}' | node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>process.stdout.write(JSON.parse(d).accessToken))")
curl -s -X POST http://localhost:4000/api/bancos -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"banco":"BBVA","terminacionCuenta":"1234"}'
curl -s http://localhost:4000/api/bancos -H "Authorization: Bearer $TOKEN"
```
Expected: banco created and listed.
---
### Task 3: Backend — Conciliacion Service, Controller, Routes
**Files:**
- Create: `apps/api/src/services/conciliacion.service.ts`
- Create: `apps/api/src/controllers/conciliacion.controller.ts`
- Create: `apps/api/src/routes/conciliacion.routes.ts`
- Modify: `apps/api/src/app.ts`
- [ ] **Step 1: Create conciliacion service**
Create `apps/api/src/services/conciliacion.service.ts`:
```typescript
import type { Pool } from 'pg';
const VIGENTE = `status NOT IN ('Cancelado', '0')`;
export interface ConciliacionCfdi {
id: number;
uuid: string;
type: string;
fechaEmision: string;
rfcEmisor: string;
nombreEmisor: string;
rfcReceptor: string;
nombreReceptor: string;
total: number;
totalMxn: number;
metodoPago: string | null;
conciliado: string | null;
idConciliacion: number | null;
conciliacion: {
id: number;
fechaDePago: string;
banco: string;
terminacionCuenta: string;
} | null;
}
export async function getCfdisConConciliacion(
pool: Pool,
filters: {
tipo: string;
fechaInicio?: string;
fechaFin?: string;
regimen?: string;
estado?: string;
}
): Promise<ConciliacionCfdi[]> {
const params: any[] = [];
let idx = 1;
let where = `WHERE c.type = $${idx++} AND c.${VIGENTE}`;
params.push(filters.tipo);
if (filters.fechaInicio) {
where += ` AND c.fecha_emision >= $${idx++}::date`;
params.push(filters.fechaInicio);
}
if (filters.fechaFin) {
where += ` AND c.fecha_emision <= ($${idx++}::date + interval '1 day')`;
params.push(filters.fechaFin);
}
if (filters.regimen) {
const regimenCol = filters.tipo === 'EMITIDO' ? 'regimen_fiscal_emisor' : 'regimen_fiscal_receptor';
where += ` AND c.${regimenCol} = $${idx++}`;
params.push(filters.regimen);
}
if (filters.estado === 'conciliado') {
where += ` AND c.conciliado = 'true'`;
} else if (filters.estado === 'pendiente') {
where += ` AND (c.conciliado IS NULL OR c.conciliado != 'true')`;
}
const { rows } = await pool.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, c.total_mxn as "totalMxn",
c.metodo_pago as "metodoPago",
c.conciliado,
c.id_conciliacion as "idConciliacion",
con.id as "conId",
con.fecha_de_pago as "conFechaDePago",
b.banco as "conBanco",
b.terminacion_cuenta as "conTerminacionCuenta"
FROM cfdis c
LEFT JOIN conciliaciones con ON con.id_cfdi = c.id
LEFT JOIN bancos b ON b.id = con.id_banco
${where}
ORDER BY c.fecha_emision DESC
`, params);
return rows.map((r: any) => ({
id: r.id,
uuid: r.uuid,
type: r.type,
fechaEmision: r.fechaEmision,
rfcEmisor: r.rfcEmisor,
nombreEmisor: r.nombreEmisor,
rfcReceptor: r.rfcReceptor,
nombreReceptor: r.nombreReceptor,
total: Number(r.total),
totalMxn: Number(r.totalMxn),
metodoPago: r.metodoPago,
conciliado: r.conciliado,
idConciliacion: r.idConciliacion,
conciliacion: r.conId ? {
id: r.conId,
fechaDePago: r.conFechaDePago,
banco: r.conBanco,
terminacionCuenta: r.conTerminacionCuenta,
} : null,
}));
}
export async function conciliar(
pool: Pool,
data: { cfdiIds: number[]; fechaDePago: string; idBanco: number },
tenantCreatedYear: number,
): Promise<number> {
const fechaPago = new Date(data.fechaDePago + 'T12:00:00');
const anio = String(fechaPago.getFullYear());
const mes = String(fechaPago.getMonth() + 1).padStart(2, '0');
if (fechaPago.getFullYear() < tenantCreatedYear) {
throw new Error(`Solo se puede conciliar del año ${tenantCreatedYear} en adelante`);
}
// Validate banco exists
const { rows: bancoRows } = await pool.query(`SELECT id FROM bancos WHERE id = $1`, [data.idBanco]);
if (bancoRows.length === 0) throw new Error('Banco no encontrado');
// Validate CFDIs exist, are vigente, and not already conciliado
const { rows: cfdis } = await pool.query(`
SELECT id, conciliado FROM cfdis
WHERE id = ANY($1) AND ${VIGENTE}
`, [data.cfdiIds]);
if (cfdis.length !== data.cfdiIds.length) {
throw new Error('Algunos CFDIs no existen o estan cancelados');
}
const yaConc = cfdis.filter((c: any) => c.conciliado === 'true');
if (yaConc.length > 0) {
throw new Error(`${yaConc.length} CFDIs ya estan conciliados`);
}
let count = 0;
for (const cfdiId of data.cfdiIds) {
const { rows: inserted } = await pool.query(`
INSERT INTO conciliaciones (anio, mes, id_cfdi, fecha_de_pago, id_banco)
VALUES ($1, $2, $3, $4, $5)
RETURNING id
`, [anio, mes, cfdiId, data.fechaDePago, data.idBanco]);
await pool.query(`
UPDATE cfdis SET conciliado = 'true', id_conciliacion = $1 WHERE id = $2
`, [inserted[0].id, cfdiId]);
count++;
}
return count;
}
export async function desconciliar(pool: Pool, conciliacionId: number): Promise<void> {
const { rows } = await pool.query(`SELECT id_cfdi FROM conciliaciones WHERE id = $1`, [conciliacionId]);
if (rows.length === 0) throw new Error('Conciliacion no encontrada');
await pool.query(`UPDATE cfdis SET conciliado = NULL, id_conciliacion = NULL WHERE id_conciliacion = $1`, [conciliacionId]);
await pool.query(`DELETE FROM conciliaciones WHERE id = $1`, [conciliacionId]);
}
```
- [ ] **Step 2: Create conciliacion controller**
Create `apps/api/src/controllers/conciliacion.controller.ts`:
```typescript
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 } = 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,
});
res.json(data);
} catch (error) { next(error); }
}
export async function conciliar(req: Request, res: Response, next: NextFunction) {
try {
if (!['admin', 'contador'].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) { next(error); }
}
export async function desconciliar(req: Request, res: Response, next: NextFunction) {
try {
if (!['admin', 'contador'].includes(req.user!.role)) {
return res.status(403).json({ message: 'No autorizado' });
}
const id = parseInt(req.params.id);
await conciliacionService.desconciliar(req.tenantPool!, id);
res.json({ message: 'CFDI desconciliado' });
} catch (error) { next(error); }
}
```
- [ ] **Step 3: Create conciliacion routes**
Create `apps/api/src/routes/conciliacion.routes.ts`:
```typescript
import { Router, type IRouter } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js';
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
import { requireFeature } from '../middlewares/feature-gate.middleware.js';
import * as conciliacionController from '../controllers/conciliacion.controller.js';
const router: IRouter = Router();
router.use(authenticate);
router.use(tenantMiddleware);
router.use(requireFeature('conciliacion'));
router.get('/', conciliacionController.getCfdis);
router.post('/', conciliacionController.conciliar);
router.delete('/:id', conciliacionController.desconciliar);
export { router as conciliacionRoutes };
```
- [ ] **Step 4: Register in app.ts**
In `apps/api/src/app.ts`, add import and route:
```typescript
import { conciliacionRoutes } from './routes/conciliacion.routes.js';
// ... after bancosRoutes:
app.use('/api/conciliacion', conciliacionRoutes);
```
- [ ] **Step 5: Verify conciliacion API**
```bash
TOKEN=$(curl -s -X POST http://localhost:4000/api/auth/login -H "Content-Type: application/json" -d '{"email":"admin@demo.com","password":"demo123"}' | node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>process.stdout.write(JSON.parse(d).accessToken))")
curl -s "http://localhost:4000/api/conciliacion?tipo=EMITIDO" -H "Authorization: Bearer $TOKEN"
```
Expected: array of CFDIs with `conciliacion: null` for all.
---
### Task 4: Frontend — API Clients and Hooks
**Files:**
- Create: `apps/web/lib/api/bancos.ts`
- Create: `apps/web/lib/api/conciliacion.ts`
- Create: `apps/web/lib/hooks/use-bancos.ts`
- Create: `apps/web/lib/hooks/use-conciliacion.ts`
- [ ] **Step 1: Create bancos API client**
Create `apps/web/lib/api/bancos.ts`:
```typescript
import { apiClient } from './client';
export interface Banco {
id: number;
banco: string;
terminacionCuenta: string;
}
export async function getBancos(): Promise<Banco[]> {
const res = await apiClient.get<Banco[]>('/bancos');
return res.data;
}
export async function createBanco(data: { banco: string; terminacionCuenta: string }): Promise<Banco> {
const res = await apiClient.post<Banco>('/bancos', data);
return res.data;
}
export async function updateBanco(id: number, data: { banco?: string; terminacionCuenta?: string }): Promise<Banco> {
const res = await apiClient.put<Banco>(`/bancos/${id}`, data);
return res.data;
}
export async function deleteBanco(id: number): Promise<void> {
await apiClient.delete(`/bancos/${id}`);
}
```
- [ ] **Step 2: Create conciliacion API client**
Create `apps/web/lib/api/conciliacion.ts`:
```typescript
import { apiClient } from './client';
export interface ConciliacionCfdi {
id: number;
uuid: string;
type: string;
fechaEmision: string;
rfcEmisor: string;
nombreEmisor: string;
rfcReceptor: string;
nombreReceptor: string;
total: number;
totalMxn: number;
metodoPago: string | null;
conciliado: string | null;
idConciliacion: number | null;
conciliacion: {
id: number;
fechaDePago: string;
banco: string;
terminacionCuenta: string;
} | null;
}
export async function getCfdisConConciliacion(params: {
tipo: string;
fechaInicio?: string;
fechaFin?: string;
regimen?: string;
estado?: string;
}): Promise<ConciliacionCfdi[]> {
const q = new URLSearchParams();
q.set('tipo', params.tipo);
if (params.fechaInicio) q.set('fechaInicio', params.fechaInicio);
if (params.fechaFin) q.set('fechaFin', params.fechaFin);
if (params.regimen) q.set('regimen', params.regimen);
if (params.estado) q.set('estado', params.estado);
const res = await apiClient.get<ConciliacionCfdi[]>(`/conciliacion?${q}`);
return res.data;
}
export async function conciliar(data: {
cfdiIds: number[];
fechaDePago: string;
idBanco: number;
}): Promise<{ count: number }> {
const res = await apiClient.post<{ count: number }>('/conciliacion', data);
return res.data;
}
export async function desconciliar(id: number): Promise<void> {
await apiClient.delete(`/conciliacion/${id}`);
}
```
- [ ] **Step 3: Create bancos hook**
Create `apps/web/lib/hooks/use-bancos.ts`:
```typescript
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as bancosApi from '@/lib/api/bancos';
export function useBancos() {
return useQuery({
queryKey: ['bancos'],
queryFn: bancosApi.getBancos,
});
}
export function useCreateBanco() {
const qc = useQueryClient();
return useMutation({
mutationFn: bancosApi.createBanco,
onSuccess: () => qc.invalidateQueries({ queryKey: ['bancos'] }),
});
}
export function useDeleteBanco() {
const qc = useQueryClient();
return useMutation({
mutationFn: bancosApi.deleteBanco,
onSuccess: () => qc.invalidateQueries({ queryKey: ['bancos'] }),
});
}
```
- [ ] **Step 4: Create conciliacion hook**
Create `apps/web/lib/hooks/use-conciliacion.ts`:
```typescript
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as conciliacionApi from '@/lib/api/conciliacion';
export function useCfdisConConciliacion(params: {
tipo: string;
fechaInicio?: string;
fechaFin?: string;
regimen?: string;
}) {
return useQuery({
queryKey: ['conciliacion', params],
queryFn: () => conciliacionApi.getCfdisConConciliacion(params),
enabled: !!params.tipo,
});
}
export function useConciliar() {
const qc = useQueryClient();
return useMutation({
mutationFn: conciliacionApi.conciliar,
onSuccess: () => qc.invalidateQueries({ queryKey: ['conciliacion'] }),
});
}
export function useDesconciliar() {
const qc = useQueryClient();
return useMutation({
mutationFn: conciliacionApi.desconciliar,
onSuccess: () => qc.invalidateQueries({ queryKey: ['conciliacion'] }),
});
}
```
---
### Task 5: Frontend — Sidebar Navigation
**Files:**
- Modify: `apps/web/components/layouts/sidebar.tsx`
- Modify: `apps/web/components/layouts/sidebar-compact.tsx`
- Modify: `apps/web/components/layouts/sidebar-floating.tsx`
- Modify: `apps/web/components/layouts/topnav.tsx`
- [ ] **Step 1: Add Conciliacion to all 4 sidebar variants**
In each of the 4 navigation layout files, add to the `navigation` array after the Reportes entry:
```typescript
{ name: 'Conciliacion', href: '/conciliacion', icon: Scale, feature: 'conciliacion' },
```
Import `Scale` from `lucide-react` in each file (already imported in sidebar.tsx, check the others).
---
### Task 6: Frontend — Bancos Section in Configuracion
**Files:**
- Modify: `apps/web/app/(dashboard)/configuracion/page.tsx`
- [ ] **Step 1: Add BancosSection component**
In `apps/web/app/(dashboard)/configuracion/page.tsx`, add a new component `BancosSection` and render it in the page (only for admin). Place it after the RegimenesActivosSection.
```tsx
function BancosSection() {
const { data: bancos, isLoading } = useBancos();
const createBanco = useCreateBanco();
const deleteBancoMut = useDeleteBanco();
const [nombre, setNombre] = useState('');
const [terminacion, setTerminacion] = useState('');
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault();
if (!nombre || !terminacion) return;
try {
await createBanco.mutateAsync({ banco: nombre, terminacionCuenta: terminacion });
setNombre('');
setTerminacion('');
} catch (err: any) {
alert(err.response?.data?.message || 'Error al crear banco');
}
};
const handleDelete = async (id: number) => {
if (!confirm('Eliminar este banco?')) return;
try {
await deleteBancoMut.mutateAsync(id);
} catch (err: any) {
alert(err.response?.data?.message || 'Error al eliminar');
}
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building className="h-5 w-5" />
Bancos
</CardTitle>
<CardDescription>Cuentas bancarias para conciliacion</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{isLoading ? (
<p className="text-sm text-muted-foreground">Cargando...</p>
) : bancos && bancos.length > 0 ? (
<div className="divide-y">
{bancos.map((b) => (
<div key={b.id} className="flex items-center justify-between py-2">
<div>
<span className="font-medium">{b.banco}</span>
<span className="text-muted-foreground ml-2">****{b.terminacionCuenta}</span>
</div>
<Button variant="ghost" size="sm" onClick={() => handleDelete(b.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No hay bancos registrados</p>
)}
<form onSubmit={handleAdd} className="flex gap-2 items-end">
<div className="flex-1 space-y-1">
<Label htmlFor="banco-nombre">Banco</Label>
<Input id="banco-nombre" value={nombre} onChange={e => setNombre(e.target.value)} placeholder="BBVA" required />
</div>
<div className="w-32 space-y-1">
<Label htmlFor="banco-term">Terminacion</Label>
<Input id="banco-term" value={terminacion} onChange={e => setTerminacion(e.target.value.replace(/\D/g, '').slice(0, 4))} placeholder="1234" maxLength={4} required />
</div>
<Button type="submit" disabled={createBanco.isPending}>Agregar</Button>
</form>
</CardContent>
</Card>
);
}
```
Add required imports at the top of the file:
```typescript
import { useBancos, useCreateBanco, useDeleteBanco } from '@/lib/hooks/use-bancos';
import { Trash2 } from 'lucide-react';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
```
Render `<BancosSection />` in the page JSX, after the regimenes section, wrapped in the admin check:
```tsx
{isAdmin && <BancosSection />}
```
---
### Task 7: Frontend — Conciliacion Page
**Files:**
- Create: `apps/web/app/(dashboard)/conciliacion/page.tsx`
- [ ] **Step 1: Create the conciliacion page**
Create `apps/web/app/(dashboard)/conciliacion/page.tsx` with:
- Period selector and regimen selector (reuse existing components)
- Tabs: Emitidas / Recibidas
- Two sections per tab: "Por conciliar" (with checkboxes) and "Conciliadas"
- Sticky action bar when checkboxes are selected (banco dropdown + fecha de pago + button)
- CfdiViewerModal for "Ver factura"
- Desconciliar button on conciliated rows
- Visor role sees no checkboxes or action buttons
This is the largest file. Full implementation code should be written by the executing agent following the spec layout description. Key patterns to follow:
- Use `useCfdisConConciliacion({ tipo: activeTab, fechaInicio, fechaFin, regimen })`
- Split data into `pendientes` (conciliado !== 'true') and `conciliadas` (conciliado === 'true')
- `useState<Set<number>>` for selected checkbox IDs
- `useBancos()` for the banco dropdown
- `useConciliar()` and `useDesconciliar()` mutations
- `useAuthStore()` to check `user.role` for visor read-only
- `formatCurrency` from `@/lib/utils`
- `CfdiViewerModal` from `@/components/cfdi/cfdi-viewer-modal`
- `PeriodSelector` from `@/components/period-selector`
- `RegimenSelector` from `@/components/regimen-selector` (needs `useRegimenesDelPeriodo`)
- Action bar appears only when `selected.size > 0`, contains: banco Select, date Input, "Conciliar N facturas" Button
- Export to Excel button using `exportToExcel` from `@/lib/export-excel`
---
### Task 8: Verification and Cleanup
- [ ] **Step 1: Restart dev server**
Kill and restart `pnpm dev` to pick up all backend changes.
- [ ] **Step 2: Test full flow**
1. Login as admin
2. Go to Configuracion → verify Bancos section, add a bank
3. Go to Conciliacion → verify tabs show CFDIs
4. Select CFDIs, pick banco and date, conciliar → verify they move to "Conciliadas"
5. Desconciliar one → verify it moves back
6. Login as visor → verify read-only (no checkboxes, no action buttons)
- [ ] **Step 3: Test API edge cases**
```bash
# Try conciliar already conciliado CFDI — should fail
# Try conciliar with non-existent banco — should fail
# Try delete banco with conciliaciones — should fail
```

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,680 @@
# Tenant Schema Migrations Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implement a numbered SQL migration system for tenant databases so schema changes auto-apply to existing tenants via eager (deploy) and lazy (on-connect) strategies.
**Architecture:** SQL files in `apps/api/src/migrations/tenant/` numbered `NNN_description.sql`. A `schema_migrations` table in each tenant DB tracks applied versions. `TenantMigrationRunner` reads files, diffs against the table, applies pending ones. Integrated into `getPool()` (lazy) and a CLI script (eager).
**Tech Stack:** Node.js, pg Pool, filesystem (fs/path), Prisma (central DB query for eager), tsx (CLI runner)
---
### Task 1: Create migration SQL file from existing schema
**Files:**
- Create: `apps/api/src/migrations/tenant/001_initial_schema.sql`
This file contains the exact SQL currently in `createTables()` and `createIndexes()` from `apps/api/src/config/database.ts:212-439`, prefixed with the `schema_migrations` table creation.
- [ ] **Step 1: Create the migrations directory and 001 file**
Create `apps/api/src/migrations/tenant/001_initial_schema.sql` with this content:
```sql
-- 001_initial_schema.sql
-- Initial tenant database schema (migrated from createTables + createIndexes)
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()
);
-- =============================================
-- 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 for id_conciliacion
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 $$;
```
- [ ] **Step 2: Commit**
```bash
git add apps/api/src/migrations/tenant/001_initial_schema.sql
git commit -m "feat: add 001_initial_schema.sql tenant migration file"
```
---
### Task 2: Create TenantMigrationRunner
**Files:**
- Create: `apps/api/src/config/tenant-migrations.ts`
- [ ] **Step 1: Create tenant-migrations.ts**
Create `apps/api/src/config/tenant-migrations.ts`:
```typescript
import { Pool } from 'pg';
import { readdir, readFile } from 'fs/promises';
import { join } from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import { prisma } from './database.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const MIGRATIONS_DIR = join(__dirname, '..', 'migrations', 'tenant');
interface MigrationFile {
version: number;
name: string;
sql: string;
}
/**
* Ensure the schema_migrations table exists in the tenant DB.
*/
async function ensureMigrationsTable(pool: Pool): Promise<void> {
await pool.query(`
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
name VARCHAR(255) NOT NULL,
applied_at TIMESTAMP DEFAULT NOW()
);
`);
}
/**
* Read all .sql files from the migrations directory, sorted by version.
*/
export async function getMigrationFiles(): Promise<MigrationFile[]> {
let files: string[];
try {
files = await readdir(MIGRATIONS_DIR);
} catch {
console.warn('[Migrations] Migrations directory not found:', MIGRATIONS_DIR);
return [];
}
const sqlFiles = files
.filter(f => f.endsWith('.sql'))
.sort();
const migrations: MigrationFile[] = [];
for (const file of sqlFiles) {
const match = file.match(/^(\d{3})_(.+)\.sql$/);
if (!match) {
console.warn(`[Migrations] Skipping invalid filename: ${file}`);
continue;
}
const version = parseInt(match[1], 10);
const sql = await readFile(join(MIGRATIONS_DIR, file), 'utf-8');
migrations.push({ version, name: file, sql });
}
return migrations;
}
/**
* Get versions already applied in this tenant DB.
*/
async function getAppliedVersions(pool: Pool): Promise<Set<number>> {
const result = await pool.query('SELECT version FROM schema_migrations ORDER BY version');
return new Set(result.rows.map((r: { version: number }) => r.version));
}
/**
* Apply pending migrations to a single tenant database.
* Returns the number of migrations applied.
*/
export async function migrate(pool: Pool, label?: string): Promise<number> {
await ensureMigrationsTable(pool);
const allMigrations = await getMigrationFiles();
if (allMigrations.length === 0) return 0;
const applied = await getAppliedVersions(pool);
const pending = allMigrations.filter(m => !applied.has(m.version));
if (pending.length === 0) return 0;
const tag = label ? ` (${label})` : '';
console.log(`[Migrations]${tag} Applying ${pending.length} pending migration(s)...`);
let count = 0;
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(`[Migrations]${tag} Applied: ${migration.name}`);
count++;
} catch (error) {
await client.query('ROLLBACK');
console.error(`[Migrations]${tag} FAILED: ${migration.name}`, error);
throw error;
} finally {
client.release();
}
}
return count;
}
/**
* Eager migration: apply pending migrations to ALL active tenant databases.
* Does not stop on individual tenant failure — logs and continues.
*/
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 },
});
console.log(`[Migrations] Starting eager migration for ${tenants.length} tenant(s)...`);
let success = 0;
let failed = 0;
let skipped = 0;
for (const tenant of tenants) {
const pool = new Pool({
connectionString: process.env.DATABASE_URL?.replace(/\/[^/]+$/, `/${tenant.databaseName}`),
max: 1,
});
try {
const applied = await migrate(pool, tenant.rfc);
if (applied > 0) {
success++;
} else {
skipped++;
}
} catch (error) {
console.error(`[Migrations] Failed for tenant ${tenant.rfc} (${tenant.databaseName}):`, error);
failed++;
} finally {
await pool.end();
}
}
console.log(`[Migrations] Eager migration complete: ${success} migrated, ${skipped} up-to-date, ${failed} failed`);
return { success, failed, skipped };
}
```
- [ ] **Step 2: Commit**
```bash
git add apps/api/src/config/tenant-migrations.ts
git commit -m "feat: add TenantMigrationRunner with migrate() and migrateAll()"
```
---
### Task 3: Integrate lazy migration into TenantConnectionManager
**Files:**
- Modify: `apps/api/src/config/database.ts`
Changes:
1. Add `migratedPools: Set<string>` to the class
2. Import `migrate` from `tenant-migrations.ts`
3. Make `getPool()` async — run `migrate(pool)` on first access per tenant
4. Replace `createTables()` + `createIndexes()` in `provisionDatabase()` with `migrate(pool)`
5. Remove `createTables()` and `createIndexes()` methods
6. Clear `migratedPools` entry in `invalidatePool()`
- [ ] **Step 1: Update database.ts imports**
At the top of `apps/api/src/config/database.ts`, add the import:
```typescript
import { migrate } from './tenant-migrations.js';
```
- [ ] **Step 2: Add migratedPools Set to the class**
In the `TenantConnectionManager` class, after `private dbConfig`:
```typescript
private migratedPools: Set<string> = new Set();
```
- [ ] **Step 3: Make getPool() async with lazy migration**
Replace the current `getPool()` method (lines 53-79) with:
```typescript
/**
* Get or create a connection pool for a tenant's database.
* Runs pending migrations on first access per session.
*/
async getPool(tenantId: string, databaseName: string): Promise<Pool> {
const entry = this.pools.get(tenantId);
let pool: Pool;
if (entry) {
entry.lastAccess = new Date();
pool = entry.pool;
} else {
const poolConfig: PoolConfig = {
host: this.dbConfig.host,
port: this.dbConfig.port,
user: this.dbConfig.user,
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() });
}
// Lazy migration: run once per tenant per process lifetime
if (!this.migratedPools.has(tenantId)) {
try {
await migrate(pool, databaseName);
this.migratedPools.add(tenantId);
} catch (error) {
console.error(`[TenantDB] Migration failed for ${tenantId} (${databaseName}):`, error);
// Don't block access — tenant can still work with current schema
this.migratedPools.add(tenantId);
}
}
return pool;
}
```
- [ ] **Step 4: Update provisionDatabase() to use migrate()**
Replace the `try` block inside `provisionDatabase()` that calls `createTables` and `createIndexes` (the inner try/finally around line 111-116) with:
```typescript
try {
await migrate(tenantPool, databaseName);
} finally {
await tenantPool.end();
}
```
- [ ] **Step 5: Update invalidatePool() to clear migration cache**
Add `this.migratedPools.delete(tenantId);` to `invalidatePool()`:
```typescript
invalidatePool(tenantId: string): void {
const entry = this.pools.get(tenantId);
if (entry) {
entry.pool.end().catch(() => {});
this.pools.delete(tenantId);
}
this.migratedPools.delete(tenantId);
}
```
- [ ] **Step 6: Remove createTables() and createIndexes() methods**
Delete the `private async createTables(pool: Pool)` method (lines 212-406) and the `private async createIndexes(pool: Pool)` method (lines 408-439) entirely. Their content is now in `001_initial_schema.sql`.
- [ ] **Step 7: Update all callers of getPool() to use await**
Since `getPool()` is now async, every call site must `await` it. The callers are:
In `apps/api/src/middlewares/tenant.middleware.ts`, change lines 75 and 85:
```typescript
// Line 75 — impersonation path
req.tenantPool = await tenantDb.getPool(tenantId, viewedTenant.databaseName);
// Line 85 — normal path
req.tenantPool = await tenantDb.getPool(tenantId, databaseName);
```
- [ ] **Step 8: Commit**
```bash
git add apps/api/src/config/database.ts apps/api/src/middlewares/tenant.middleware.ts
git commit -m "feat: integrate lazy tenant migrations into getPool()"
```
---
### Task 4: Create eager migration CLI script
**Files:**
- Create: `apps/api/scripts/migrate-tenants.ts`
- Modify: `apps/api/package.json`
- Modify: `turbo.json`
- [ ] **Step 1: Create the CLI script**
Create `apps/api/scripts/migrate-tenants.ts`:
```typescript
/**
* Eager tenant migration script.
* Run: pnpm --filter @horux/api db:migrate-tenants
* Or: pnpm db:migrate-tenants (from monorepo root via Turborepo)
*
* Applies pending SQL migrations to all active tenant databases.
*/
import { migrateAll } from '../src/config/tenant-migrations.js';
async function main() {
console.log('=== Tenant Schema Migration (Eager) ===\n');
const start = Date.now();
const result = await migrateAll();
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
console.log(`\n=== Done in ${elapsed}s ===`);
console.log(` Migrated: ${result.success}`);
console.log(` Up-to-date: ${result.skipped}`);
console.log(` Failed: ${result.failed}`);
if (result.failed > 0) {
console.error('\n⚠ Some tenants failed migration. Check logs above.');
process.exit(1);
}
process.exit(0);
}
main().catch((err) => {
console.error('Fatal error:', err);
process.exit(1);
});
```
- [ ] **Step 2: Add script to apps/api/package.json**
Add to the `"scripts"` section of `apps/api/package.json`:
```json
"db:migrate-tenants": "tsx scripts/migrate-tenants.ts"
```
- [ ] **Step 3: Add task to turbo.json**
Add to the `"tasks"` section of `turbo.json`:
```json
"db:migrate-tenants": {
"cache": false
}
```
- [ ] **Step 4: Commit**
```bash
git add apps/api/scripts/migrate-tenants.ts apps/api/package.json turbo.json
git commit -m "feat: add eager tenant migration CLI script (pnpm db:migrate-tenants)"
```
---
### Task 5: Update CLAUDE.md and README.md
**Files:**
- Modify: `CLAUDE.md`
- Modify: `README.md`
- [ ] **Step 1: Update CLAUDE.md**
In the "Problemas conocidos / pendientes" section, replace item 1:
```markdown
1. ~~**Schema drift multi-tenant:**~~ Resuelto. Migraciones SQL numeradas en `apps/api/src/migrations/tenant/`. Se aplican eager (`pnpm db:migrate-tenants`) en deploy y lazy (auto en `getPool()`) como safety net. Para agregar un cambio de schema: crear `NNN_description.sql` en el directorio de migraciones.
```
- [ ] **Step 2: Update README.md deploy section**
In README.md, update the deploy instructions to include the new migration step. The deploy flow should reference:
```bash
git pull
pnpm install
pnpm build
pnpm db:migrate-tenants # Apply schema changes to all tenant DBs
pm2 restart all
```
- [ ] **Step 3: Commit**
```bash
git add CLAUDE.md README.md
git commit -m "docs: update CLAUDE.md and README.md with tenant migration system"
```

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,772 @@
# Plan 2A: Schema + Auth para Horux Despachos — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Evolucionar el schema de BD central y tenant para soportar el modelo de despachos (verticalProfile, dbMode, entidades gestionadas, contribuyentes, carteras), agregar roles de despacho (supervisor, cliente), refactorear el migration runner para tracking por scope, y crear un endpoint de signup básico para despachos.
**Architecture:** Se evoluciona el modelo `Tenant` existente (no se renombra — un despacho ES un tenant con campos adicionales). Se agregan 5 tenant migrations SQL (tracking table + entidades + carteras + contribuyentes + cliente_accesos). El migration runner se extiende para soportar tabla de tracking (`tenant_migrations`). Se agregan tipos nuevos a `@horux/shared`.
**Tech Stack:** Prisma 5.22, PostgreSQL 16, TypeScript 5, Express 4.21, pnpm workspaces.
**Validation:** `pnpm --filter @horux/api typecheck` (57 pre-existing errors baseline — verify no NEW errors). `pnpm --filter @horux/shared typecheck` (0 errors baseline).
**Git:** Commits locales, sin push. Un commit por task.
**Pre-existing codebase context (Plan 2A engineer MUST know):**
- Prisma schema: `apps/api/prisma/schema.prisma` — modelos Tenant, User, TenantMembership, Rol, FielCredential, Subscription, etc.
- Current roles table (Rol): id=1 owner, id=2 contador, id=3 visor, id=7 cfo, id=8 auxiliar.
- Tenant migrations: `apps/api/src/migrations/tenant/001-005.sql` — flat numbered, applied lazily by `TenantConnectionManager.getPool()` via `migrate()` in `config/tenant-migrations.ts`.
- Auth JWT payload: `{ userId, email, role, tenantId, platformRoles?, tokenVersion? }` from `@horux/shared`.
- Config env: `apps/api/src/config/env.ts` — Zod-validated, includes DATABASE_URL, JWT_SECRET, FIEL_ENCRYPTION_KEY.
- Imports in apps/api use `.js` extension (NodeNext module resolution).
---
## File Structure
**New files:**
- `apps/api/prisma/migrations/YYYYMMDD_despacho_fields/migration.sql` — Prisma migration (auto-generated)
- `apps/api/src/migrations/tenant/006_tenant_migrations_tracking.sql` — tracking table for scope-based migrations
- `apps/api/src/migrations/tenant/007_entidades_gestionadas.sql` — core: base entity table
- `apps/api/src/migrations/tenant/008_carteras.sql` — core: portfolios + assignments
- `apps/api/src/migrations/tenant/009_cliente_accesos.sql` — core: client-viewer access
- `apps/api/src/migrations/tenant/010_contribuyentes.sql` — vertical-contable: taxpayer subtype
- `packages/shared/src/types/despacho.ts` — DespachoRole, VerticalProfile, DbMode types
- `apps/api/src/controllers/despacho.controller.ts` — signup endpoint
- `apps/api/src/services/despacho.service.ts` — signup business logic
- `apps/api/src/routes/despacho.routes.ts` — route mounting
**Modified files:**
- `apps/api/prisma/schema.prisma` — add fields to Tenant, add enums
- `apps/api/prisma/seed.ts` — add 'supervisor' and 'cliente' roles
- `apps/api/src/config/tenant-migrations.ts` — support tracking table
- `apps/api/src/app.ts` — mount despacho routes
- `packages/shared/src/types/auth.ts` — add DespachoRole to exports
- `packages/shared/src/index.ts` — re-export despacho types
---
## Tasks
### Task 1: Prisma migration — add despacho fields to Tenant
**Files:**
- Modify: `apps/api/prisma/schema.prisma`
- Create: auto-generated migration via `prisma migrate dev`
- [ ] **Step 1: Add new enums and fields to Prisma schema**
Open `apps/api/prisma/schema.prisma` and add the following:
After the existing `enum Plan { ... }`:
```prisma
enum VerticalProfile {
CONTABLE
JURIDICO
ARQUITECTURA
}
enum DbMode {
BYO
MANAGED
}
```
In the `model Tenant { ... }`, add AFTER the `telefono` field (before the relations block):
```prisma
// === Despacho fields (Plan 2A) ===
verticalProfile VerticalProfile? @map("vertical_profile")
dbMode DbMode? @map("db_mode")
dbConnectionEnc String? @map("db_connection_enc")
dbConnectionIv String? @map("db_connection_iv")
dbSchemaVersion Int @default(0) @map("db_schema_version")
connectorTokenEnc String? @map("connector_token_enc")
connectorTunnelHostname String? @map("connector_tunnel_hostname")
connectorLastSeen DateTime? @map("connector_last_seen")
connectorVersion String? @map("connector_version") @db.VarChar(20)
```
- [ ] **Step 2: Generate and apply Prisma migration**
Run:
```bash
cd apps/api && npx prisma migrate dev --name despacho_fields
```
Expected: migration SQL generated in `prisma/migrations/YYYYMMDD_despacho_fields/`. Since all new fields are nullable or have defaults, this is safe for existing data.
If the command fails because there's no DB connection, create the migration without applying:
```bash
cd apps/api && npx prisma migrate dev --name despacho_fields --create-only
```
- [ ] **Step 3: Generate Prisma client**
Run:
```bash
cd apps/api && npx prisma generate
```
- [ ] **Step 4: Verify typecheck**
Run: `pnpm --filter @horux/api typecheck`
Expected: same 57 pre-existing errors, no new ones.
- [ ] **Step 5: Commit**
```bash
git add apps/api/prisma/
git commit -m "feat(schema): add despacho fields to Tenant model (verticalProfile, dbMode, connector)"
```
---
### Task 2: Seed new roles (supervisor, cliente)
**Files:**
- Modify: `apps/api/prisma/seed.ts`
- [ ] **Step 1: Read current seed.ts to understand the roles seeding pattern**
Open `apps/api/prisma/seed.ts` and find where roles are upserted. The current roles are:
```
id=1: owner, id=2: contador, id=3: visor, id=7: cfo, id=8: auxiliar
```
- [ ] **Step 2: Add supervisor and cliente roles to the seed**
Add to the roles upsert section:
```typescript
await prisma.rol.upsert({
where: { nombre: 'supervisor' },
update: {},
create: { id: 9, nombre: 'supervisor', descripcion: 'Supervisor de despacho — titular de RFCs, crea carteras' },
});
await prisma.rol.upsert({
where: { nombre: 'cliente' },
update: {},
create: { id: 10, nombre: 'cliente', descripcion: 'Cliente visor externo — acceso read-only a sus RFCs' },
});
```
- [ ] **Step 3: Run seed (if DB is available)**
Run:
```bash
cd apps/api && npx prisma db seed
```
If no DB connection, skip — the seed will run at next deploy. The migration and seed are idempotent.
- [ ] **Step 4: Commit**
```bash
git add apps/api/prisma/seed.ts
git commit -m "feat(seed): add supervisor and cliente roles for despachos"
```
---
### Task 3: Add despacho types to @horux/shared
**Files:**
- Create: `packages/shared/src/types/despacho.ts`
- Modify: `packages/shared/src/index.ts` (or wherever types are re-exported)
- [ ] **Step 1: Create despacho types file**
Create `packages/shared/src/types/despacho.ts`:
```typescript
export type DespachoRole = 'owner' | 'supervisor' | 'auxiliar' | 'cliente';
export type VerticalProfile = 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA';
export type DbMode = 'BYO' | 'MANAGED';
export interface DespachoInfo {
id: string;
nombre: string;
rfc: string;
verticalProfile: VerticalProfile;
dbMode: DbMode | null;
plan: string;
}
export interface DespachoSignupRequest {
despacho: {
nombre: string;
rfc: string;
regimenFiscal?: string;
codigoPostal?: string;
verticalProfile: VerticalProfile;
};
owner: {
nombre: string;
email: string;
password: string;
};
}
export interface ContribuyenteInfo {
id: string;
rfc: string;
razonSocial: string;
regimenFiscal: string;
codigoPostal?: string;
supervisorUserId?: string;
active: boolean;
}
```
- [ ] **Step 2: Find and update the barrel export**
Read `packages/shared/src/index.ts` to see how types are exported. Add:
```typescript
export * from './types/despacho';
```
If the barrel uses a different pattern (e.g., explicit re-exports), follow that pattern.
- [ ] **Step 3: Verify typecheck**
Run: `pnpm --filter @horux/shared typecheck`
Expected: 0 errors.
- [ ] **Step 4: Commit**
```bash
git add packages/shared/
git commit -m "feat(shared): add DespachoRole, VerticalProfile, DbMode types"
```
---
### Task 4: Tenant migration — tracking table + entidades_gestionadas
**Files:**
- Create: `apps/api/src/migrations/tenant/006_tenant_migrations_tracking.sql`
- Create: `apps/api/src/migrations/tenant/007_entidades_gestionadas.sql`
- [ ] **Step 1: Create migration 006 — tracking table**
Create `apps/api/src/migrations/tenant/006_tenant_migrations_tracking.sql`:
```sql
-- Tracking table for scope-based migrations.
-- Allows checking which migrations have been applied and which are pending.
-- For now, all existing migrations (001-005) are considered "legacy" scope
-- and are tracked by the existing file-based runner. This table tracks
-- only NEW migrations going forward (007+).
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)
);
-- Mark 001-005 as already applied under "legacy" scope
-- so the runner doesn't try to re-apply them.
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;
```
- [ ] **Step 2: Create migration 007 — entidades_gestionadas**
Create `apps/api/src/migrations/tenant/007_entidades_gestionadas.sql`:
```sql
-- Core table: base entity managed by the despacho.
-- Subtyped by vertical (e.g., contribuyentes for CONTABLE).
-- Carteras and client access operate on this table (vertical-agnostic).
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;
```
- [ ] **Step 3: Verify SQL syntax**
Read both files back to confirm no typos. The SQL should be idempotent (`IF NOT EXISTS`, `ON CONFLICT DO NOTHING`).
- [ ] **Step 4: Commit**
```bash
git add apps/api/src/migrations/tenant/006_tenant_migrations_tracking.sql apps/api/src/migrations/tenant/007_entidades_gestionadas.sql
git commit -m "feat(migrations): add tenant_migrations tracking + entidades_gestionadas table"
```
---
### Task 5: Tenant migrations — carteras + cliente_accesos + contribuyentes
**Files:**
- Create: `apps/api/src/migrations/tenant/008_carteras.sql`
- Create: `apps/api/src/migrations/tenant/009_cliente_accesos.sql`
- Create: `apps/api/src/migrations/tenant/010_contribuyentes.sql`
- [ ] **Step 1: Create migration 008 — carteras**
Create `apps/api/src/migrations/tenant/008_carteras.sql`:
```sql
-- Core: supervisor portfolios. A supervisor groups entities into carteras
-- and assigns auxiliares to them. Cascading: if supervisor loses an entity,
-- it's removed from all their carteras automatically (via JOIN, not trigger).
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;
```
- [ ] **Step 2: Create migration 009 — cliente_accesos**
Create `apps/api/src/migrations/tenant/009_cliente_accesos.sql`:
```sql
-- Core: direct access grants for external client-viewers.
-- A client user can see specific entities (not via carteras).
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;
```
- [ ] **Step 3: Create migration 010 — contribuyentes (vertical contable)**
Create `apps/api/src/migrations/tenant/010_contribuyentes.sql`:
```sql
-- Vertical CONTABLE: taxpayer subtype of entidades_gestionadas.
-- Uses single-PK inheritance: contribuyentes.entidad_id = entidades_gestionadas.id.
-- CFDI and other fiscal tables will FK to this table (via entidad_id, not a separate id).
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;
```
- [ ] **Step 4: Commit**
```bash
git add apps/api/src/migrations/tenant/008_carteras.sql apps/api/src/migrations/tenant/009_cliente_accesos.sql apps/api/src/migrations/tenant/010_contribuyentes.sql
git commit -m "feat(migrations): add carteras, cliente_accesos, contribuyentes tables"
```
---
### Task 6: Despacho signup service + controller + route
**Files:**
- Create: `apps/api/src/services/despacho.service.ts`
- Create: `apps/api/src/controllers/despacho.controller.ts`
- Create: `apps/api/src/routes/despacho.routes.ts`
- Modify: `apps/api/src/app.ts` (mount route)
- [ ] **Step 1: Create despacho service**
Create `apps/api/src/services/despacho.service.ts`:
```typescript
import { prisma, tenantDb } from '../config/database.js';
import { hashPassword } from '../auth/passwords.js';
import { generateAccessToken, generateRefreshToken } from '../auth/tokens.js';
import type { DespachoSignupRequest, VerticalProfile } from '@horux/shared';
import type { JWTPayload, Role } from '@horux/shared';
export async function signupDespacho(data: DespachoSignupRequest) {
const { despacho, owner } = data;
// Validate uniqueness
const existingTenant = await prisma.tenant.findUnique({ where: { rfc: despacho.rfc } });
if (existingTenant) {
throw new Error('Ya existe una empresa registrada con este RFC');
}
const existingUser = await prisma.user.findUnique({ where: { email: owner.email } });
if (existingUser) {
throw new Error('Ya existe un usuario con este email');
}
const passwordHash = await hashPassword(owner.password);
// Create tenant + user + membership in transaction
const result = await prisma.$transaction(async (tx) => {
// 1. Create tenant as despacho
const databaseName = `horux_${despacho.rfc.toLowerCase().replace(/[^a-z0-9]/g, '')}`;
const tenant = await tx.tenant.create({
data: {
nombre: despacho.nombre,
rfc: despacho.rfc.toUpperCase(),
plan: 'starter',
databaseName,
cfdiLimit: 0,
usersLimit: 3,
verticalProfile: despacho.verticalProfile as any,
dbMode: 'MANAGED' as any,
dbSchemaVersion: 0,
trialEndsAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
codigoPostal: despacho.codigoPostal,
},
});
// 2. Create user
const user = await tx.user.create({
data: {
email: owner.email.toLowerCase(),
passwordHash,
nombre: owner.nombre,
lastTenantId: tenant.id,
},
});
// 3. Create membership as owner
const ownerRole = await tx.rol.findUnique({ where: { nombre: 'owner' } });
if (!ownerRole) throw new Error('Rol owner no encontrado en BD');
await tx.tenantMembership.create({
data: {
userId: user.id,
tenantId: tenant.id,
rolId: ownerRole.id,
isOwner: true,
},
});
return { tenant, user };
});
// 4. Provision tenant database (outside transaction — creates actual DB)
try {
await tenantDb.provisionDatabase(despacho.rfc);
} catch (err: any) {
// If DB provisioning fails, delete the tenant (rollback)
await prisma.tenant.delete({ where: { id: result.tenant.id } });
await prisma.user.delete({ where: { id: result.user.id } });
throw new Error(`Error al crear base de datos del despacho: ${err.message}`);
}
// 5. Generate JWT pair
const payload: Omit<JWTPayload, 'iat' | 'exp'> = {
userId: result.user.id,
email: result.user.email,
role: 'owner' as Role,
tenantId: result.tenant.id,
tokenVersion: 0,
};
const accessToken = generateAccessToken(payload);
const refreshToken = generateRefreshToken(payload);
// 6. Store refresh token
await prisma.refreshToken.create({
data: {
userId: result.user.id,
token: refreshToken,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
},
});
return {
accessToken,
refreshToken,
user: {
id: result.user.id,
email: result.user.email,
nombre: result.user.nombre,
role: 'owner' as Role,
tenantId: result.tenant.id,
tenantName: result.tenant.nombre,
tenantRfc: result.tenant.rfc,
plan: result.tenant.plan,
tenants: [{
id: result.tenant.id,
nombre: result.tenant.nombre,
rfc: result.tenant.rfc,
plan: result.tenant.plan,
role: 'owner' as Role,
isOwner: true,
}],
},
};
}
```
- [ ] **Step 2: Create despacho controller**
Create `apps/api/src/controllers/despacho.controller.ts`:
```typescript
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';
const signupSchema = z.object({
despacho: z.object({
nombre: z.string().min(2, 'Nombre del despacho requerido'),
rfc: z.string().regex(/^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$/i, 'RFC inválido'),
regimenFiscal: z.string().optional(),
codigoPostal: z.string().regex(/^\d{5}$/, 'Código postal inválido').optional(),
verticalProfile: z.enum(['CONTABLE', 'JURIDICO', 'ARQUITECTURA']),
}),
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 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);
}
}
```
- [ ] **Step 3: Create despacho routes**
Create `apps/api/src/routes/despacho.routes.ts`:
```typescript
import { Router } from 'express';
import rateLimit from 'express-rate-limit';
import { signup } from '../controllers/despacho.controller.js';
const router = Router();
const signupLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 5,
message: { message: 'Demasiados intentos de registro. Intenta en una hora.' },
});
router.post('/signup', signupLimiter, signup);
export default router;
```
- [ ] **Step 4: Mount route in app.ts**
Open `apps/api/src/app.ts`. Find the block where routes are mounted (look for `app.use('/api/auth'`). Add:
```typescript
import despachoRoutes from './routes/despacho.routes.js';
// ... in the routes section:
app.use('/api/despachos', despachoRoutes);
```
- [ ] **Step 5: Verify typecheck**
Run: `pnpm --filter @horux/api typecheck`
Expected: same pre-existing errors, no new ones. If there are new errors from the despacho files, fix them (likely import paths or type mismatches with Prisma generated types).
Common issue: Prisma might not know about `VerticalProfile` and `DbMode` enums yet if the migration wasn't applied. If typecheck fails on `verticalProfile: despacho.verticalProfile as any`, the `as any` cast handles it. If stricter typing is needed, verify Prisma client was regenerated (Step 3 of Task 1).
- [ ] **Step 6: Commit**
```bash
git add apps/api/src/services/despacho.service.ts apps/api/src/controllers/despacho.controller.ts apps/api/src/routes/despacho.routes.ts apps/api/src/app.ts
git commit -m "feat(api): add POST /api/despachos/signup endpoint"
```
---
### Task 7: Validation + smoke test
**Files:** None (verification only)
- [ ] **Step 1: Verify all packages typecheck**
Run:
```bash
pnpm --filter @horux/shared typecheck
pnpm --filter @horux/core typecheck
pnpm --filter @horux/shared-ui typecheck
pnpm --filter @horux/api typecheck
```
Expected: shared/core/shared-ui = 0 errors. api = same pre-existing errors only.
- [ ] **Step 2: Verify migration files exist and are numbered correctly**
Run:
```bash
ls -la apps/api/src/migrations/tenant/
```
Expected: files 001-010 in order. Verify 006-010 are our new ones.
- [ ] **Step 3: Verify Prisma schema has new fields**
Run:
```bash
grep -n "verticalProfile\|dbMode\|dbSchemaVersion\|connectorTokenEnc" apps/api/prisma/schema.prisma
```
Expected: all 4 fields present.
- [ ] **Step 4: Verify commit history**
Run:
```bash
git log --oneline -10
```
Expected: 6 new commits from this plan on top of the Plan 1 refactor commits.
- [ ] **Step 5: Start dev server and test signup endpoint (MANUAL)**
Run: `pnpm dev`
Test with curl (or user in browser):
```bash
curl -X POST http://localhost:4000/api/despachos/signup \
-H "Content-Type: application/json" \
-d '{
"despacho": {
"nombre": "Despacho Test",
"rfc": "DTE250101AAA",
"verticalProfile": "CONTABLE"
},
"owner": {
"nombre": "Test Owner",
"email": "test@despacho.com",
"password": "testpassword123"
}
}'
```
Expected: 201 with `{ accessToken, refreshToken, user: { ... } }`.
If no DB connection, this step is deferred. The typecheck validation is sufficient for the plan.
- [ ] **Step 6: Final commit if any fixes were needed**
```bash
git add -A && git status
# Only commit if there are changes
git commit -m "fix: Plan 2A validation fixes" || true
```
---
## Self-Review
### Spec coverage (vs spec §3-§5, §11, §15-Phase1)
| Spec requirement | Task | Status |
|------------------|------|--------|
| Tenant evolves to support despacho (verticalProfile, dbMode, connector fields) | Task 1 | ✅ |
| New roles: supervisor, cliente | Task 2 | ✅ |
| Shared types: DespachoRole, VerticalProfile, DbMode, DespachoSignupRequest, ContribuyenteInfo | Task 3 | ✅ |
| Tenant migration: tenant_migrations tracking table | Task 4 | ✅ |
| Tenant migration: entidades_gestionadas (core) | Task 4 | ✅ |
| Tenant migration: carteras + cartera_entidades + cartera_auxiliares (core) | Task 5 | ✅ |
| Tenant migration: cliente_accesos (core) | Task 5 | ✅ |
| Tenant migration: contribuyentes (vertical-contable, single-PK inheritance) | Task 5 | ✅ |
| Signup endpoint: POST /despachos/signup | Task 6 | ✅ |
| Trial 30 days | Task 6 (trialEndsAt) | ✅ |
| Managed DB provisioned at signup | Task 6 (provisionDatabase) | ✅ |
| JWT + refresh token on signup | Task 6 | ✅ |
**Deferred to Plan 2B:**
- CRUD contribuyentes endpoints (add/update/delete RFC within despacho)
- FIEL/CSD assignment to contribuyente (not tenant)
- CFDI emission with contribuyente_id FK
- Metrics tables (metricas_mensuales etc.)
- Magic link auth flow
**Deferred to Plan 2C:**
- Frontend signup page
- Dashboard adapted for despacho
- Contribuyente selector UI
- Onboarding wizard
### Placeholder scan
- No "TBD", "TODO", "implement later" found.
- All code blocks contain complete, copy-paste-ready code.
- Types referenced (DespachoSignupRequest, JWTPayload, Role, etc.) are all defined in tasks.
### Type consistency
- `DespachoRole` = `'owner' | 'supervisor' | 'auxiliar' | 'cliente'` — consistent with spec §5.
- `VerticalProfile` = `'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA'` — matches Prisma enum.
- `DbMode` = `'BYO' | 'MANAGED'` — matches Prisma enum.
- `signupDespacho()` accepts `DespachoSignupRequest` and returns `LoginResponse`-compatible shape.
- SQL migrations use `gen_random_uuid()`, `timestamptz`, `varchar` — consistent with existing migrations.
- `tenant_migrations` table uses `(scope, version)` PK — matches spec §12.

View File

@@ -0,0 +1,577 @@
# Plan 2B: CRUD Contribuyentes + FIEL/CSD per Contribuyente + CFDI con contribuyente_id
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Un owner de despacho puede agregar contribuyentes (RFCs), subir FIEL/CSD por contribuyente, y emitir CFDIs asociados a un contribuyente específico.
**Architecture:** Se agregan 3 tenant migrations (FIEL per contribuyente, Facturapi org per contribuyente, contribuyente_id en cfdis). Se crea un CRUD completo para contribuyentes. Se refactorean las funciones de FIEL y Facturapi para resolver por contribuyente_id (en BD tenant) en vez de por tenantId (en BD central). FIEL para despachos vive en BD tenant (soberanía de datos), no en BD central.
**Tech Stack:** PostgreSQL 16, Express 4.21, TypeScript 5, Prisma 5.22, pg Pool (raw SQL), Zod.
**Validation:** `pnpm --filter @horux/api typecheck` — no NEW errors vs baseline (~57 pre-existing).
**Git:** Commits locales, un commit por task.
**Prerequisite:** Plan 2A completado (tenant migrations 006-010 existen, signup endpoint funcional).
---
## File Structure
**New files:**
- `apps/api/src/migrations/tenant/011_fiel_per_contribuyente.sql`
- `apps/api/src/migrations/tenant/012_facturapi_per_contribuyente.sql`
- `apps/api/src/migrations/tenant/013_cfdi_contribuyente_id.sql`
- `apps/api/src/services/contribuyente.service.ts`
- `apps/api/src/controllers/contribuyente.controller.ts`
- `apps/api/src/routes/contribuyente.routes.ts`
**Modified files:**
- `apps/api/src/app.ts` (mount new routes)
- `apps/api/src/services/cfdi.service.ts` (add contribuyente_id to createCfdi + getCfdis filter)
- `apps/api/src/controllers/facturacion.controller.ts` (emitir accepts contribuyenteId)
---
## Tasks
### Task 1: Tenant migrations — FIEL, Facturapi orgs, CFDI contribuyente_id
**Files:**
- Create: `apps/api/src/migrations/tenant/011_fiel_per_contribuyente.sql`
- Create: `apps/api/src/migrations/tenant/012_facturapi_per_contribuyente.sql`
- Create: `apps/api/src/migrations/tenant/013_cfdi_contribuyente_id.sql`
- [ ] **Step 1: Create migration 011 — FIEL per contribuyente (in tenant BD)**
Create `apps/api/src/migrations/tenant/011_fiel_per_contribuyente.sql`:
```sql
-- FIEL credentials stored per contribuyente in the despacho's own database.
-- This keeps FIEL data sovereign (in the despacho's BD, not central).
-- The central FielCredential table continues to work for Horux360 classic tenants.
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;
```
- [ ] **Step 2: Create migration 012 — Facturapi orgs per contribuyente**
Create `apps/api/src/migrations/tenant/012_facturapi_per_contribuyente.sql`:
```sql
-- Maps each contribuyente to a Facturapi organization within Horux's master account.
-- Each contribuyente gets its own org (with its own CSD, logo, series).
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;
```
- [ ] **Step 3: Create migration 013 — add contribuyente_id to cfdis**
Create `apps/api/src/migrations/tenant/013_cfdi_contribuyente_id.sql`:
```sql
-- Add contribuyente_id to cfdis table.
-- Nullable for backward compat: existing CFDIs (Horux360 classic) don't have one.
-- New CFDIs from despachos will always have it set.
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;
```
- [ ] **Step 4: Commit**
```bash
git add apps/api/src/migrations/tenant/011_fiel_per_contribuyente.sql apps/api/src/migrations/tenant/012_facturapi_per_contribuyente.sql apps/api/src/migrations/tenant/013_cfdi_contribuyente_id.sql
git commit -m "feat(migrations): add fiel_contribuyente, facturapi_orgs, cfdi contribuyente_id"
```
---
### Task 2: CRUD Contribuyentes — service + controller + routes
**Files:**
- Create: `apps/api/src/services/contribuyente.service.ts`
- Create: `apps/api/src/controllers/contribuyente.controller.ts`
- Create: `apps/api/src/routes/contribuyente.routes.ts`
- Modify: `apps/api/src/app.ts`
- [ ] **Step 1: Create contribuyente service**
Create `apps/api/src/services/contribuyente.service.ts`:
```typescript
import type { Pool } from 'pg';
export interface CreateContribuyenteData {
rfc: string;
razonSocial: string;
regimenFiscal?: string;
codigoPostal?: string;
domicilio?: Record<string, unknown>;
supervisorUserId?: string;
}
export interface ContribuyenteRow {
id: string;
tipo: string;
nombre: string;
identificador: string;
supervisorUserId: string | null;
active: boolean;
createdAt: string;
rfc: string;
regimenFiscal: string | null;
codigoPostal: string | null;
domicilio: Record<string, unknown> | null;
}
export async function listContribuyentes(pool: Pool): Promise<ContribuyenteRow[]> {
const { rows } = await pool.query(`
SELECT
e.id,
e.tipo,
e.nombre,
e.identificador,
e.supervisor_user_id AS "supervisorUserId",
e.active,
e.created_at AS "createdAt",
c.rfc,
c.regimen_fiscal AS "regimenFiscal",
c.codigo_postal AS "codigoPostal",
c.domicilio
FROM entidades_gestionadas e
JOIN contribuyentes c ON c.entidad_id = e.id
WHERE e.active = true
ORDER BY e.created_at DESC
`);
return rows;
}
export async function getContribuyenteById(pool: Pool, id: string): Promise<ContribuyenteRow | null> {
const { rows } = await pool.query(`
SELECT
e.id,
e.tipo,
e.nombre,
e.identificador,
e.supervisor_user_id AS "supervisorUserId",
e.active,
e.created_at AS "createdAt",
c.rfc,
c.regimen_fiscal AS "regimenFiscal",
c.codigo_postal AS "codigoPostal",
c.domicilio
FROM entidades_gestionadas e
JOIN contribuyentes c ON c.entidad_id = e.id
WHERE e.id = $1
`, [id]);
return rows[0] ?? null;
}
export async function createContribuyente(pool: Pool, data: CreateContribuyenteData): Promise<ContribuyenteRow> {
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows: [entidad] } = await client.query(`
INSERT INTO entidades_gestionadas (tipo, nombre, identificador, supervisor_user_id)
VALUES ('CONTRIBUYENTE', $1, $2, $3)
RETURNING id
`, [data.razonSocial, data.rfc.toUpperCase(), data.supervisorUserId ?? null]);
await client.query(`
INSERT INTO contribuyentes (entidad_id, rfc, regimen_fiscal, codigo_postal, domicilio)
VALUES ($1, $2, $3, $4, $5)
`, [entidad.id, data.rfc.toUpperCase(), data.regimenFiscal ?? null, data.codigoPostal ?? null, data.domicilio ? JSON.stringify(data.domicilio) : null]);
await client.query('COMMIT');
return (await getContribuyenteById(pool, entidad.id))!;
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
export async function updateContribuyente(pool: Pool, id: string, data: Partial<CreateContribuyenteData>): Promise<ContribuyenteRow | null> {
const existing = await getContribuyenteById(pool, id);
if (!existing) return null;
const client = await pool.connect();
try {
await client.query('BEGIN');
if (data.razonSocial || data.supervisorUserId !== undefined) {
const sets: string[] = [];
const vals: unknown[] = [];
let idx = 1;
if (data.razonSocial) {
sets.push(`nombre = $${idx}`, `identificador = $${idx}`);
vals.push(data.razonSocial);
idx++;
}
if (data.supervisorUserId !== undefined) {
sets.push(`supervisor_user_id = $${idx}`);
vals.push(data.supervisorUserId);
idx++;
}
sets.push('updated_at = now()');
vals.push(id);
await client.query(`UPDATE entidades_gestionadas SET ${sets.join(', ')} WHERE id = $${idx}`, vals);
}
if (data.regimenFiscal !== undefined || data.codigoPostal !== undefined || data.domicilio !== undefined) {
const sets: string[] = [];
const vals: unknown[] = [];
let idx = 1;
if (data.regimenFiscal !== undefined) { sets.push(`regimen_fiscal = $${idx}`); vals.push(data.regimenFiscal); idx++; }
if (data.codigoPostal !== undefined) { sets.push(`codigo_postal = $${idx}`); vals.push(data.codigoPostal); idx++; }
if (data.domicilio !== undefined) { sets.push(`domicilio = $${idx}`); vals.push(JSON.stringify(data.domicilio)); idx++; }
vals.push(id);
await client.query(`UPDATE contribuyentes SET ${sets.join(', ')} WHERE entidad_id = $${idx}`, vals);
}
await client.query('COMMIT');
return (await getContribuyenteById(pool, id))!;
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
export async function deactivateContribuyente(pool: Pool, id: string): Promise<boolean> {
const { rowCount } = await pool.query(`
UPDATE entidades_gestionadas SET active = false, updated_at = now() WHERE id = $1
`, [id]);
return (rowCount ?? 0) > 0;
}
```
- [ ] **Step 2: Create contribuyente controller**
Create `apps/api/src/controllers/contribuyente.controller.ts`:
```typescript
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';
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 rows = await contribuyenteService.listContribuyentes(req.tenantPool!);
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);
const row = await contribuyenteService.createContribuyente(req.tenantPool!, data);
return res.status(201).json(row);
} 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'));
return res.json({ message: 'Contribuyente desactivado' });
} catch (err) { return next(err); }
}
```
- [ ] **Step 3: Create contribuyente routes**
Create `apps/api/src/routes/contribuyente.routes.ts`:
```typescript
import { Router, type IRouter } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js';
import { authorize } from '../middlewares/auth.middleware.js';
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
import * as ctrl from '../controllers/contribuyente.controller.js';
const router: IRouter = Router();
router.use(authenticate);
router.use(tenantMiddleware);
router.get('/', ctrl.list);
router.get('/:id', ctrl.getById);
router.post('/', authorize('owner', 'supervisor'), ctrl.create);
router.put('/:id', authorize('owner', 'supervisor'), ctrl.update);
router.delete('/:id', authorize('owner'), ctrl.deactivate);
export default router;
```
- [ ] **Step 4: Mount routes in app.ts**
Open `apps/api/src/app.ts`. Add import:
```typescript
import contribuyenteRoutes from './routes/contribuyente.routes.js';
```
Add route mount (before error middleware):
```typescript
app.use('/api/contribuyentes', contribuyenteRoutes);
```
- [ ] **Step 5: Verify typecheck**
Run: `pnpm --filter @horux/api typecheck`
Expected: no new errors.
- [ ] **Step 6: Commit**
```bash
git add apps/api/src/services/contribuyente.service.ts apps/api/src/controllers/contribuyente.controller.ts apps/api/src/routes/contribuyente.routes.ts apps/api/src/app.ts
git commit -m "feat(api): add CRUD endpoints for contribuyentes"
```
---
### Task 3: Add contribuyente_id to CFDI insert + list filter
**Files:**
- Modify: `apps/api/src/services/cfdi.service.ts`
- [ ] **Step 1: Add `contribuyente_id` to CFDI_SELECT constant**
Open `apps/api/src/services/cfdi.service.ts`. Find the `CFDI_SELECT` constant (starts around line 5). At the END of the select list (before the closing backtick), add:
```sql
contribuyente_id AS "contribuyenteId"
```
Make sure to add a comma after the previous field.
- [ ] **Step 2: Add `contribuyente_id` to the INSERT in `createCfdi()`**
Find the `createCfdi()` function. In the INSERT INTO cfdis statement, add `contribuyente_id` to the column list and a corresponding `$N` placeholder. Also add `contribuyenteId` to the `CreateCfdiData` interface if it exists, or pass it as parameter.
At the top of `createCfdi()`, the function receives `data: CreateCfdiData`. Check if `CreateCfdiData` is an interface in the same file. Add:
```typescript
contribuyenteId?: string;
```
In the INSERT query, add `contribuyente_id` column and `data.contribuyenteId ?? null` as value.
- [ ] **Step 3: Add optional `contribuyenteId` filter to `getCfdis()`**
In `getCfdis()`, the function builds a WHERE clause dynamically. Add a filter:
```typescript
// Add to the CfdiFilters interface (or wherever filters are defined):
contribuyenteId?: string;
// In the WHERE clause building section:
if (filters.contribuyenteId) {
conditions.push(`contribuyente_id = $${paramIndex}`);
params.push(filters.contribuyenteId);
paramIndex++;
}
```
- [ ] **Step 4: Verify typecheck**
Run: `pnpm --filter @horux/api typecheck`
- [ ] **Step 5: Commit**
```bash
git add apps/api/src/services/cfdi.service.ts
git commit -m "feat(cfdi): add contribuyente_id to CFDI insert and list filter"
```
---
### Task 4: Modify emitir endpoint to accept contribuyenteId
**Files:**
- Modify: `apps/api/src/controllers/facturacion.controller.ts`
- [ ] **Step 1: Update the `emitir()` function**
Open `apps/api/src/controllers/facturacion.controller.ts`. Find the `emitir()` function.
Add `contribuyenteId` extraction from request body at the beginning:
```typescript
const contribuyenteId = req.body.contribuyenteId as string | undefined;
```
After the CFDI is created in the DB (the INSERT INTO cfdis section), ensure `contribuyente_id` is included. Find the line that does the INSERT into cfdis and add `contribuyenteId` to the data passed to `createCfdi()` (or directly in the INSERT):
```typescript
// When calling createCfdi or building the insert data:
// Add: contribuyenteId: contribuyenteId ?? null
```
The exact modification depends on how `emitir()` builds the CFDI data. Read the function and add `contribuyenteId` to the object passed to the INSERT.
- [ ] **Step 2: Verify typecheck**
Run: `pnpm --filter @horux/api typecheck`
- [ ] **Step 3: Commit**
```bash
git add apps/api/src/controllers/facturacion.controller.ts
git commit -m "feat(facturacion): emitir endpoint accepts contribuyenteId"
```
---
### Task 5: Validation
**Files:** None (verification only)
- [ ] **Step 1: Verify all migrations exist**
```bash
ls -la apps/api/src/migrations/tenant/
```
Expected: 13 files (001-013).
- [ ] **Step 2: Typecheck**
```bash
pnpm --filter @horux/shared typecheck
pnpm --filter @horux/core typecheck
pnpm --filter @horux/api typecheck
```
- [ ] **Step 3: Verify commit history**
```bash
git log --oneline -8
```
Expected: 4 new commits from this plan.
- [ ] **Step 4: Test endpoints (MANUAL — requires DB)**
Start server: `pnpm dev`
Test CRUD contribuyentes:
```bash
# Login first to get token
TOKEN="..."
# Create contribuyente
curl -X POST http://localhost:4000/api/contribuyentes \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"rfc":"ABC010203XY1","razonSocial":"Test SA de CV","regimenFiscal":"601"}'
# List contribuyentes
curl http://localhost:4000/api/contribuyentes \
-H "Authorization: Bearer $TOKEN"
```
---
## Self-Review
### Spec coverage (vs spec §4.2, §7)
| Requirement | Task | Status |
|------------|------|--------|
| `fiel_contribuyente` table in tenant BD | Task 1 | ✅ |
| `facturapi_orgs` table in tenant BD | Task 1 | ✅ |
| `contribuyente_id` column in cfdis | Task 1 | ✅ |
| CRUD contribuyentes (POST/GET/PUT/DELETE) | Task 2 | ✅ |
| CFDI insert with contribuyente_id | Task 3 | ✅ |
| CFDI list filter by contribuyente_id | Task 3 | ✅ |
| Emitir endpoint accepts contribuyenteId | Task 4 | ✅ |
### Deferred to Plan 2B-2 (service refactoring)
These require deeper refactoring of existing services:
- **FIEL upload per contribuyente** — requires new `uploadFielContribuyente()` function that writes to tenant BD instead of central. Currently `fiel.service.ts` uses Prisma (central BD). The new function would use `pool.query()` (tenant BD).
- **Facturapi org creation per contribuyente** — `createOrganization()` currently writes `facturapiOrgId` to `Tenant`. Needs to write to `facturapi_orgs` in tenant BD.
- **getOrgClient() per contribuyente** — resolves org from `facturapi_orgs` table instead of `Tenant.facturapiOrgId`.
- **SAT sync per contribuyente** — resolves FIEL from `fiel_contribuyente` table.
### Type consistency
- `ContribuyenteRow` interface used consistently in service/controller.
- `CreateContribuyenteData` matches Zod schema in controller.
- `contribuyenteId` field name consistent across CFDI and facturacion changes.

View File

@@ -0,0 +1,29 @@
# Plan 2B-2: FIEL + Facturapi per Contribuyente — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.
**Goal:** FIEL y Facturapi se resuelven por contribuyente (tabla tenant BD) en vez de por tenant (BD central). Los servicios existentes NO se modifican (siguen para Horux360 classic); se crean servicios NUEVOS paralelos para el flujo despachos.
**Architecture:** Nuevos servicios `contribuyente-fiel.service.ts` y `contribuyente-facturapi.service.ts` que operan sobre tablas `fiel_contribuyente` y `facturapi_orgs` en la BD tenant (via pool.query, no Prisma). Nuevos endpoints bajo `/api/contribuyentes/:id/fiel` y `/api/contribuyentes/:id/facturapi`. El endpoint `emitir()` se adapta para resolver org desde `facturapi_orgs` cuando se pasa `contribuyenteId`.
---
## Tasks
### Task 1: Contribuyente FIEL service
Create `apps/api/src/services/contribuyente-fiel.service.ts` — funciones que operan sobre tabla `fiel_contribuyente` en BD tenant.
### Task 2: Contribuyente Facturapi service
Create `apps/api/src/services/contribuyente-facturapi.service.ts` — funciones que operan sobre tabla `facturapi_orgs` en BD tenant.
### Task 3: Controller + routes
Create controller + routes para exponer FIEL upload/status y Facturapi org/CSD per contribuyente.
### Task 4: Wire emitir() para resolver org por contribuyente
Modify `emitir()` en facturacion.controller.ts para que si `contribuyenteId` está presente, resuelva la org Facturapi desde `facturapi_orgs` en vez de `Tenant.facturapiOrgId`.
### Task 5: Validation

View File

@@ -0,0 +1,789 @@
# Plan 2C: Frontend Despachos — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Un owner de despacho puede registrarse desde el frontend, gestionar contribuyentes (agregar/editar/desactivar RFCs), y seleccionar qué contribuyente está operando para filtrar CFDIs.
**Architecture:** Se crean API client functions + React Query hooks para el endpoint /api/contribuyentes. Se crea una nueva página de signup para despachos que llama a POST /api/despachos/signup. Se crea un selector de contribuyente (dropdown) persistido en Zustand store. La lista de CFDIs se filtra por el contribuyente seleccionado.
**Tech Stack:** Next.js 14 App Router, React 18, Zustand, React Query, Tailwind, @horux/shared-ui, Zod (client-side).
**Validation:** `pnpm --filter @horux/web typecheck` — no NEW errors vs baseline. Visual smoke test en browser.
---
## Tasks
### Task 1: API client + hooks para contribuyentes
**Files:**
- Create: `apps/web/lib/api/contribuyentes.ts`
- Create: `apps/web/lib/hooks/use-contribuyentes.ts`
- [ ] **Step 1: Create API client functions**
Create `apps/web/lib/api/contribuyentes.ts`:
```typescript
import apiClient from './client';
export interface Contribuyente {
id: string;
tipo: string;
nombre: string;
identificador: string;
supervisorUserId: string | null;
active: boolean;
createdAt: string;
rfc: string;
regimenFiscal: string | null;
codigoPostal: string | null;
domicilio: Record<string, unknown> | null;
}
export interface CreateContribuyenteData {
rfc: string;
razonSocial: string;
regimenFiscal?: string;
codigoPostal?: string;
}
export async function getContribuyentes(): Promise<{ data: Contribuyente[] }> {
const { data } = await apiClient.get('/contribuyentes');
return data;
}
export async function getContribuyente(id: string): Promise<Contribuyente> {
const { data } = await apiClient.get(`/contribuyentes/${id}`);
return data;
}
export async function createContribuyente(payload: CreateContribuyenteData): Promise<Contribuyente> {
const { data } = await apiClient.post('/contribuyentes', payload);
return data;
}
export async function updateContribuyente(id: string, payload: Partial<CreateContribuyenteData>): Promise<Contribuyente> {
const { data } = await apiClient.put(`/contribuyentes/${id}`, payload);
return data;
}
export async function deactivateContribuyente(id: string): Promise<void> {
await apiClient.delete(`/contribuyentes/${id}`);
}
```
- [ ] **Step 2: Create React Query hooks**
Create `apps/web/lib/hooks/use-contribuyentes.ts`:
```typescript
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useAuthStore } from '@/stores/auth-store';
import * as api from '@/lib/api/contribuyentes';
export function useContribuyentes() {
const user = useAuthStore((s) => s.user);
return useQuery({
queryKey: ['contribuyentes', user?.tenantId],
queryFn: () => api.getContribuyentes().then((r) => r.data),
enabled: !!user,
});
}
export function useContribuyente(id: string | null) {
const user = useAuthStore((s) => s.user);
return useQuery({
queryKey: ['contribuyente', id, user?.tenantId],
queryFn: () => api.getContribuyente(id!),
enabled: !!user && !!id,
});
}
export function useCreateContribuyente() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.createContribuyente,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contribuyentes'] });
},
});
}
export function useUpdateContribuyente() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<api.CreateContribuyenteData> }) =>
api.updateContribuyente(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contribuyentes'] });
},
});
}
export function useDeactivateContribuyente() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.deactivateContribuyente,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contribuyentes'] });
},
});
}
```
- [ ] **Step 3: Verify typecheck**
Run: `pnpm --filter @horux/web typecheck`
- [ ] **Step 4: Commit**
```bash
git add apps/web/lib/api/contribuyentes.ts apps/web/lib/hooks/use-contribuyentes.ts
git commit -m "feat(web): add API client + React Query hooks for contribuyentes"
```
---
### Task 2: Signup page para despachos
**Files:**
- Create: `apps/web/app/(auth)/register-despacho/page.tsx`
- [ ] **Step 1: Create the despacho signup page**
Create `apps/web/app/(auth)/register-despacho/page.tsx`:
```tsx
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { Button, Input, Label, Card, CardContent, CardHeader, CardTitle } from '@horux/shared-ui';
import { useAuthStore } from '@/stores/auth-store';
import apiClient from '@/lib/api/client';
export default function RegisterDespachoPage() {
const router = useRouter();
const { setUser, setTokens } = useAuthStore();
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [form, setForm] = useState({
despachoNombre: '',
despachoRfc: '',
codigoPostal: '',
ownerNombre: '',
ownerEmail: '',
ownerPassword: '',
acceptedTerms: false,
});
const handleChange = (field: string) => (e: React.ChangeEvent<HTMLInputElement>) => {
setForm((prev) => ({ ...prev, [field]: e.target.value }));
setError('');
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.acceptedTerms) {
setError('Debes aceptar los términos y condiciones');
return;
}
setLoading(true);
setError('');
try {
const { data } = await apiClient.post('/despachos/signup', {
despacho: {
nombre: form.despachoNombre,
rfc: form.despachoRfc,
codigoPostal: form.codigoPostal || undefined,
verticalProfile: 'CONTABLE',
},
owner: {
nombre: form.ownerNombre,
email: form.ownerEmail,
password: form.ownerPassword,
},
});
setTokens(data.accessToken, data.refreshToken);
setUser(data.user);
router.push('/dashboard');
} catch (err: any) {
const msg = err.response?.data?.message || 'Error al registrar el despacho';
setError(msg);
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-purple-50 dark:from-gray-900 dark:to-gray-800 p-4">
<Card className="w-full max-w-lg">
<CardHeader className="text-center">
<CardTitle className="text-2xl font-bold">Registra tu Despacho</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
30 días de prueba gratis. Sin tarjeta de crédito.
</p>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Datos del despacho */}
<div className="space-y-3">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Datos del despacho
</h3>
<div>
<Label htmlFor="despachoNombre">Razón social</Label>
<Input
id="despachoNombre"
value={form.despachoNombre}
onChange={handleChange('despachoNombre')}
placeholder="Despacho Contable SA de CV"
required
/>
</div>
<div>
<Label htmlFor="despachoRfc">RFC del despacho</Label>
<Input
id="despachoRfc"
value={form.despachoRfc}
onChange={handleChange('despachoRfc')}
placeholder="DCO010203XY1"
maxLength={13}
required
/>
</div>
<div>
<Label htmlFor="codigoPostal">Código postal</Label>
<Input
id="codigoPostal"
value={form.codigoPostal}
onChange={handleChange('codigoPostal')}
placeholder="06600"
maxLength={5}
/>
</div>
</div>
{/* Datos del owner */}
<div className="space-y-3">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Tu cuenta (dueño)
</h3>
<div>
<Label htmlFor="ownerNombre">Nombre completo</Label>
<Input
id="ownerNombre"
value={form.ownerNombre}
onChange={handleChange('ownerNombre')}
placeholder="Juan Pérez"
required
/>
</div>
<div>
<Label htmlFor="ownerEmail">Email</Label>
<Input
id="ownerEmail"
type="email"
value={form.ownerEmail}
onChange={handleChange('ownerEmail')}
placeholder="juan@despacho.com"
required
/>
</div>
<div>
<Label htmlFor="ownerPassword">Contraseña</Label>
<Input
id="ownerPassword"
type="password"
value={form.ownerPassword}
onChange={handleChange('ownerPassword')}
placeholder="Mínimo 10 caracteres"
minLength={10}
required
/>
</div>
</div>
{/* Terms */}
<div className="flex items-start gap-2">
<input
type="checkbox"
id="terms"
checked={form.acceptedTerms}
onChange={(e) => setForm((prev) => ({ ...prev, acceptedTerms: e.target.checked }))}
className="mt-1"
/>
<label htmlFor="terms" className="text-sm text-muted-foreground">
Acepto los{' '}
<Link href="/terminos" target="_blank" className="underline text-primary">
términos y condiciones
</Link>
</label>
</div>
{error && (
<p className="text-sm text-destructive bg-destructive/10 p-3 rounded-md">{error}</p>
)}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? 'Registrando...' : 'Crear despacho'}
</Button>
<p className="text-center text-sm text-muted-foreground">
¿Ya tienes cuenta?{' '}
<Link href="/login" className="text-primary underline">
Inicia sesión
</Link>
</p>
</form>
</CardContent>
</Card>
</div>
);
}
```
- [ ] **Step 2: Verify typecheck + visual check**
Run: `pnpm --filter @horux/web typecheck`
Then open `http://localhost:3000/register-despacho` in browser to verify the form renders.
- [ ] **Step 3: Commit**
```bash
git add apps/web/app/\(auth\)/register-despacho/page.tsx
git commit -m "feat(web): add despacho signup page at /register-despacho"
```
---
### Task 3: Contribuyente selector store + component
**Files:**
- Create: `apps/web/stores/contribuyente-store.ts`
- Create: `apps/web/components/contribuyente-selector.tsx`
- [ ] **Step 1: Create Zustand store for selected contribuyente**
Create `apps/web/stores/contribuyente-store.ts`:
```typescript
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface ContribuyenteState {
selectedContribuyenteId: string | null;
selectedContribuyenteRfc: string | null;
selectedContribuyenteNombre: string | null;
setSelectedContribuyente: (id: string, rfc: string, nombre: string) => void;
clearSelectedContribuyente: () => void;
}
export const useContribuyenteStore = create<ContribuyenteState>()(
persist(
(set) => ({
selectedContribuyenteId: null,
selectedContribuyenteRfc: null,
selectedContribuyenteNombre: null,
setSelectedContribuyente: (id, rfc, nombre) =>
set({ selectedContribuyenteId: id, selectedContribuyenteRfc: rfc, selectedContribuyenteNombre: nombre }),
clearSelectedContribuyente: () =>
set({ selectedContribuyenteId: null, selectedContribuyenteRfc: null, selectedContribuyenteNombre: null }),
}),
{ name: 'horux-contribuyente' }
)
);
```
- [ ] **Step 2: Create selector component**
Create `apps/web/components/contribuyente-selector.tsx`:
```tsx
'use client';
import { useContribuyentes } from '@/lib/hooks/use-contribuyentes';
import { useContribuyenteStore } from '@/stores/contribuyente-store';
import { Button } from '@horux/shared-ui';
import { ChevronDown, Building2 } from 'lucide-react';
import { useState, useRef, useEffect } from 'react';
export function ContribuyenteSelector() {
const { data: contribuyentes, isLoading } = useContribuyentes();
const { selectedContribuyenteId, selectedContribuyenteRfc, setSelectedContribuyente, clearSelectedContribuyente } =
useContribuyenteStore();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
if (isLoading || !contribuyentes || contribuyentes.length === 0) return null;
const selected = contribuyentes.find((c) => c.id === selectedContribuyenteId);
return (
<div ref={ref} className="relative">
<Button
variant="outline"
size="sm"
onClick={() => setOpen(!open)}
className="flex items-center gap-2 max-w-[250px]"
>
<Building2 className="h-4 w-4 flex-shrink-0" />
<span className="truncate text-xs">
{selected ? `${selected.rfc}${selected.nombre}` : 'Todos los RFCs'}
</span>
<ChevronDown className="h-3 w-3 flex-shrink-0" />
</Button>
{open && (
<div className="absolute top-full left-0 mt-1 w-72 bg-popover border rounded-md shadow-lg z-50 py-1">
<button
onClick={() => {
clearSelectedContribuyente();
setOpen(false);
}}
className={`w-full text-left px-3 py-2 text-sm hover:bg-accent ${
!selectedContribuyenteId ? 'bg-accent font-medium' : ''
}`}
>
Todos los RFCs
</button>
<div className="border-t my-1" />
{contribuyentes.map((c) => (
<button
key={c.id}
onClick={() => {
setSelectedContribuyente(c.id, c.rfc, c.nombre);
setOpen(false);
}}
className={`w-full text-left px-3 py-2 text-sm hover:bg-accent ${
selectedContribuyenteId === c.id ? 'bg-accent font-medium' : ''
}`}
>
<span className="font-mono text-xs">{c.rfc}</span>
<span className="ml-2 text-muted-foreground">{c.nombre}</span>
</button>
))}
</div>
)}
</div>
);
}
```
- [ ] **Step 3: Verify typecheck**
Run: `pnpm --filter @horux/web typecheck`
- [ ] **Step 4: Commit**
```bash
git add apps/web/stores/contribuyente-store.ts apps/web/components/contribuyente-selector.tsx
git commit -m "feat(web): add contribuyente selector store + dropdown component"
```
---
### Task 4: Contribuyentes management page
**Files:**
- Create: `apps/web/app/(dashboard)/contribuyentes/page.tsx`
- [ ] **Step 1: Create the contribuyentes page**
Create `apps/web/app/(dashboard)/contribuyentes/page.tsx`:
```tsx
'use client';
import { useState } from 'react';
import {
Button, Input, Label, Card, CardContent, CardHeader, CardTitle,
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from '@horux/shared-ui';
import {
useContribuyentes,
useCreateContribuyente,
useUpdateContribuyente,
useDeactivateContribuyente,
} from '@/lib/hooks/use-contribuyentes';
import type { CreateContribuyenteData } from '@/lib/api/contribuyentes';
import { Plus, Pencil, Trash2, Building2 } from 'lucide-react';
export default function ContribuyentesPage() {
const { data: contribuyentes, isLoading } = useContribuyentes();
const createMutation = useCreateContribuyente();
const updateMutation = useUpdateContribuyente();
const deactivateMutation = useDeactivateContribuyente();
const [showCreate, setShowCreate] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<CreateContribuyenteData>({
rfc: '', razonSocial: '', regimenFiscal: '', codigoPostal: '',
});
const resetForm = () => {
setForm({ rfc: '', razonSocial: '', regimenFiscal: '', codigoPostal: '' });
setShowCreate(false);
setEditingId(null);
};
const handleCreate = async () => {
try {
await createMutation.mutateAsync(form);
resetForm();
} catch (err: any) {
alert(err.response?.data?.message || 'Error al crear contribuyente');
}
};
const handleUpdate = async () => {
if (!editingId) return;
try {
await updateMutation.mutateAsync({ id: editingId, data: form });
resetForm();
} catch (err: any) {
alert(err.response?.data?.message || 'Error al actualizar');
}
};
const handleDeactivate = async (id: string, rfc: string) => {
if (!confirm(`¿Desactivar contribuyente ${rfc}? Esta acción no se puede deshacer.`)) return;
try {
await deactivateMutation.mutateAsync(id);
} catch (err: any) {
alert(err.response?.data?.message || 'Error al desactivar');
}
};
const openEdit = (c: any) => {
setForm({ rfc: c.rfc, razonSocial: c.nombre, regimenFiscal: c.regimenFiscal || '', codigoPostal: c.codigoPostal || '' });
setEditingId(c.id);
};
return (
<div className="p-6 max-w-5xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Contribuyentes</h1>
<p className="text-sm text-muted-foreground">RFCs que gestiona tu despacho</p>
</div>
<Button onClick={() => setShowCreate(true)} className="flex items-center gap-2">
<Plus className="h-4 w-4" /> Agregar RFC
</Button>
</div>
{isLoading ? (
<p className="text-muted-foreground">Cargando...</p>
) : !contribuyentes || contribuyentes.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Building2 className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold">Sin contribuyentes</h3>
<p className="text-sm text-muted-foreground mt-1 mb-4">
Agrega el primer RFC para empezar a gestionar su contabilidad.
</p>
<Button onClick={() => setShowCreate(true)}>Agregar primer RFC</Button>
</CardContent>
</Card>
) : (
<div className="grid gap-3">
{contribuyentes.map((c) => (
<Card key={c.id}>
<CardContent className="flex items-center justify-between py-4 px-6">
<div>
<p className="font-semibold">{c.nombre}</p>
<p className="text-sm text-muted-foreground font-mono">{c.rfc}</p>
{c.regimenFiscal && (
<p className="text-xs text-muted-foreground mt-1">Régimen: {c.regimenFiscal}</p>
)}
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => openEdit(c)}>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeactivate(c.id, c.rfc)}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Create / Edit Dialog */}
<Dialog open={showCreate || !!editingId} onOpenChange={() => resetForm()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingId ? 'Editar contribuyente' : 'Agregar contribuyente'}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<Label>RFC</Label>
<Input
value={form.rfc}
onChange={(e) => setForm((p) => ({ ...p, rfc: e.target.value }))}
placeholder="ABC010203XY1"
maxLength={13}
disabled={!!editingId}
/>
</div>
<div>
<Label>Razón social</Label>
<Input
value={form.razonSocial}
onChange={(e) => setForm((p) => ({ ...p, razonSocial: e.target.value }))}
placeholder="Empresa SA de CV"
/>
</div>
<div>
<Label>Régimen fiscal (clave)</Label>
<Input
value={form.regimenFiscal || ''}
onChange={(e) => setForm((p) => ({ ...p, regimenFiscal: e.target.value }))}
placeholder="601"
maxLength={3}
/>
</div>
<div>
<Label>Código postal</Label>
<Input
value={form.codigoPostal || ''}
onChange={(e) => setForm((p) => ({ ...p, codigoPostal: e.target.value }))}
placeholder="06600"
maxLength={5}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={resetForm}>Cancelar</Button>
<Button
onClick={editingId ? handleUpdate : handleCreate}
disabled={createMutation.isPending || updateMutation.isPending}
>
{editingId ? 'Guardar' : 'Agregar'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
```
- [ ] **Step 2: Verify typecheck**
Run: `pnpm --filter @horux/web typecheck`
- [ ] **Step 3: Commit**
```bash
git add apps/web/app/\(dashboard\)/contribuyentes/page.tsx
git commit -m "feat(web): add contribuyentes management page at /contribuyentes"
```
---
### Task 5: Wire contribuyente selector to sidebar + CFDI filter
**Files:**
- Modify: `apps/web/components/layouts/sidebar.tsx` (add selector + menu item)
- Modify: `apps/web/app/(dashboard)/cfdi/page.tsx` (pass contribuyenteId filter)
- [ ] **Step 1: Add ContribuyenteSelector to sidebar**
Open `apps/web/components/layouts/sidebar.tsx`. Find where the navigation items are rendered. ABOVE the nav list (but below the logo/brand area), add the ContribuyenteSelector:
```tsx
import { ContribuyenteSelector } from '../contribuyente-selector';
// Inside the render, above the nav items list:
<div className="px-3 py-2">
<ContribuyenteSelector />
</div>
```
Also add "Contribuyentes" to the navigation items array (for owners):
```typescript
{ name: 'Contribuyentes', href: '/contribuyentes', icon: Building2, roles: ['owner', 'cfo'] },
```
Import `Building2` from `lucide-react` if not already imported.
- [ ] **Step 2: Wire contribuyenteId to CFDI list**
Open `apps/web/app/(dashboard)/cfdi/page.tsx`. Find where the CFDI list hook is called (likely `useCfdis()` from `use-cfdi.ts`).
Add the contribuyente filter:
```tsx
import { useContribuyenteStore } from '@/stores/contribuyente-store';
// Inside the component:
const { selectedContribuyenteId } = useContribuyenteStore();
// In the useCfdis() call or the API params, add:
// contribuyenteId: selectedContribuyenteId || undefined
```
The exact modification depends on how `useCfdis()` passes params. Read the hook and the API function to see where to add the filter. If `useCfdis()` accepts a filters object, add `contribuyenteId` to it. If it's passed as query params, add `&contribuyenteId=X` to the URL.
Also add `selectedContribuyenteId` to the React Query key so data refetches when the selector changes.
- [ ] **Step 3: Verify typecheck**
Run: `pnpm --filter @horux/web typecheck`
- [ ] **Step 4: Commit**
```bash
git add apps/web/components/layouts/sidebar.tsx apps/web/app/\(dashboard\)/cfdi/page.tsx
git commit -m "feat(web): wire contribuyente selector to sidebar + CFDI filter"
```
---
### Task 6: Validation
- [ ] **Step 1: Typecheck all packages**
```bash
pnpm --filter @horux/shared typecheck && pnpm --filter @horux/shared-ui typecheck && pnpm --filter @horux/web typecheck
```
- [ ] **Step 2: Verify commit history**
```bash
git log --oneline -8
```
- [ ] **Step 3: Visual smoke test (MANUAL)**
Start: `pnpm dev`
Test:
1. Open `http://localhost:3000/register-despacho` — verify form renders, fields work
2. Login with existing account → navigate to `/contribuyentes` — verify empty state
3. (If DB connected) Create a contribuyente → verify it appears in list
4. Check sidebar — verify ContribuyenteSelector dropdown appears
5. Navigate to `/cfdi` — verify list loads (filter not visible until contribuyentes exist)

View File

@@ -0,0 +1,17 @@
# Plan 3: Roles y Carteras — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Supervisores crean carteras de contribuyentes y asignan auxiliares. La función `getEntidadesVisibles()` filtra qué contribuyentes puede ver cada rol. Clientes acceden solo a sus RFCs via `cliente_accesos`.
**Architecture:** Se extiende el type `Role` con 'supervisor' | 'cliente'. Se crea CRUD para carteras (tenant BD). Se crea helper `getEntidadesVisibles(pool, userId, role)` que retorna los IDs de entidades visibles según el rol. Los endpoints de contribuyentes filtran usando este helper.
---
## Tasks
### Task 1: Extend Role type
### Task 2: Cartera CRUD service + controller + routes
### Task 3: getEntidadesVisibles helper
### Task 4: Filter contribuyentes by entidades visibles
### Task 5: Validation

View File

@@ -0,0 +1,946 @@
# Filtros "Considerar activos" y "Considerar NCs" — Fase 1 — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Agregar 2 toggles en `/impuestos` ("Considerar activos" y "Considerar NCs") que cuando están OFF (default) excluyen del cálculo de IVA/ISR las facturas tipo I con uso I01-I08 y las facturas tipo E con cfdi_tipo_relacion=01 respectivamente.
**Architecture:** Frontend agrega 2 booleanos al state de la página de impuestos y los propaga como query params hasta el backend. Backend aplica un fragmento WHERE adicional (helper en módulo neutral `_shared/cfdi-filters.ts`) a todas las queries que escanean `cfdis` dentro del path de impuestos. Funciones compartidas con dashboard (`calcular*PorRegimen`) reciben los flags como params opcionales con default `true` (= include todo) para preservar el comportamiento del dashboard. Cache `metricas_mensuales` queda intacto pero su gate se extiende para fall-through cuando los toggles están OFF; el cache se actualizará en Fase 2 con un schema base+deltas.
**Tech Stack:** Express + TypeScript en API, Next.js 14 + React Query en web, types compartidos en `@horux/shared`. Verificación vía `pnpm typecheck` (no unit tests para esta área per el patrón del repo).
**Spec:** `docs/superpowers/specs/2026-04-27-filtros-activos-ncs-impuestos-fase1-design.md`
---
## File Structure
### Files to create
```
apps/api/src/services/_shared/cfdi-filters.ts
└── Helper buildExtraFilters + buildExtraFiltersAlias (módulo neutral)
```
### Files to modify
```
apps/api/src/services/dashboard.service.ts
└── calcularIngresosPorRegimen + calcularEgresosPorRegimen: agregar 2 params booleanos default true, aplicar buildExtraFilters al WHERE de TODAS las queries internas
apps/api/src/services/impuestos.service.ts
└── getResumenIva + getIvaMensual: nuevos params + aplicar filtro al WHERE
└── getResumenIsr + getIsrMensual + getResumenIsrDesglosado: nuevos params + propagar a calcular*PorRegimen
└── Cache gate de getResumenIva: extender condición para bypass cuando flags ≠ default backend
└── Subqueries con alias `e` (rama I PPD/07): aplicar buildExtraFiltersAlias
apps/api/src/controllers/impuestos.controller.ts
└── Helper parseFlag + 5 handlers parsean los 2 query params nuevos
apps/web/lib/api/impuestos.ts
└── 5 funciones HTTP extendidas con 2 params nuevos
apps/web/lib/hooks/use-impuestos.ts
└── 5 hooks extendidos con 2 params nuevos (incluir en queryKey)
apps/web/app/(dashboard)/impuestos/page.tsx
└── 2 useState nuevos + 2 toggle buttons + propagación a hooks
```
---
## Task 1: Crear módulo helper compartido
**Files:**
- Create: `apps/api/src/services/_shared/cfdi-filters.ts`
- [ ] **Step 1: Crear directorio si no existe**
```bash
mkdir -p "C:/Users/chtr1/Downloads/Horux_despacho/apps/api/src/services/_shared"
```
- [ ] **Step 2: Escribir el módulo**
Crear `apps/api/src/services/_shared/cfdi-filters.ts` con el contenido completo:
```ts
/**
* Helpers para construir fragmentos AND adicionales en WHERE clauses según
* los toggles "Considerar activos" y "Considerar NCs" de la UI de impuestos.
*
* - considerarActivos === false → excluir facturas tipo I con uso de CFDI I01-I08.
* - considerarNCs === false → excluir facturas tipo E con cfdi_tipo_relacion = '01'.
*
* Cuando ambos son true (default backend = "include todo"), retorna string
* vacío. Esto preserva el comportamiento histórico para callers que no pasan
* los flags (ej. dashboard, reportes).
*
* Las versiones `Alias` se usan en subqueries con alias de tabla
* (ej. `cfdis e` en SUM_E_REFERENCING_*). Para activos el filtro es no-op
* en esos subqueries (porque escanean type E), pero el filtro de NCs sí
* aplica.
*/
const ACTIVOS_USOS = "('I01','I02','I03','I04','I05','I06','I07','I08')";
export function buildExtraFilters(
considerarActivos: boolean,
considerarNCs: boolean,
): string {
const parts: string[] = [];
if (!considerarActivos) {
parts.push(`AND NOT (tipo_comprobante = 'I' AND uso_cfdi IN ${ACTIVOS_USOS})`);
}
if (!considerarNCs) {
parts.push(`AND NOT (tipo_comprobante = 'E' AND COALESCE(cfdi_tipo_relacion, '') = '01')`);
}
return parts.length > 0 ? ' ' + parts.join(' ') : '';
}
export function buildExtraFiltersAlias(
alias: string,
considerarActivos: boolean,
considerarNCs: boolean,
): string {
const parts: string[] = [];
if (!considerarActivos) {
parts.push(`AND NOT (${alias}.tipo_comprobante = 'I' AND ${alias}.uso_cfdi IN ${ACTIVOS_USOS})`);
}
if (!considerarNCs) {
parts.push(`AND NOT (${alias}.tipo_comprobante = 'E' AND COALESCE(${alias}.cfdi_tipo_relacion, '') = '01')`);
}
return parts.length > 0 ? ' ' + parts.join(' ') : '';
}
```
- [ ] **Step 3: Verificar typecheck del API**
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/api typecheck`
Expected: PASS sin errores.
- [ ] **Step 4: Commit**
```bash
cd C:/Users/chtr1/Downloads/Horux_despacho
git add apps/api/src/services/_shared/cfdi-filters.ts
git commit -m "feat(api): helper buildExtraFilters para toggles activos/NCs"
```
---
## Task 2: Extender `calcularIngresosPorRegimen` y `calcularEgresosPorRegimen` en dashboard.service.ts
**Files:**
- Modify: `apps/api/src/services/dashboard.service.ts`
**Heads up:** Dashboard también consume estas funciones. Default `true` en los nuevos params preserva su comportamiento.
- [ ] **Step 1: Agregar import del helper al inicio del archivo**
Encontrar la sección de imports al inicio de `dashboard.service.ts` y agregar:
```ts
import { buildExtraFilters } from './_shared/cfdi-filters.js';
```
(Las imports en este proyecto usan extensión `.js` aunque el archivo sea `.ts` — patrón ESM con tsx. Revisa imports existentes para confirmar el estilo.)
- [ ] **Step 2: Extender la signature de `calcularIngresosPorRegimen`**
Buscar la función exportada `calcularIngresosPorRegimen`. Agregar 2 parámetros opcionales con default `true` al final de la lista, antes del cierre de `)`:
Cambiar la signature para incluir:
```ts
export async function calcularIngresosPorRegimen(
pool: Pool,
tenantId: string,
fechaInicio: string,
fechaFin: string,
// ...parámetros existentes preservados...
conciliacion?: boolean,
contribuyenteId?: string | null,
considerarActivos: boolean = true, // nuevo
considerarNCs: boolean = true, // nuevo
): Promise<...>
```
(Mantener los nombres y orden de los parámetros existentes. Solo agregar los 2 nuevos al final.)
- [ ] **Step 3: Aplicar el filtro a TODAS las queries internas de calcularIngresosPorRegimen**
Dentro del cuerpo de la función, antes de las queries SQL, computar el fragmento:
```ts
const extra = buildExtraFilters(considerarActivos, considerarNCs);
```
Luego, en cada query SQL que escanee `cfdis`, agregar `${extra}` al final del WHERE clause. Buscar todos los `FROM cfdis` dentro del cuerpo de la función — deben ser ~3-5 queries — y a cada uno agregarle el fragmento.
Ejemplo de transformación:
```ts
// Antes:
const { rows } = await pool.query(`
SELECT ...
FROM cfdis
WHERE ${VIGENTE} AND ${FR}
AND ${ctx.esEmisor}
GROUP BY ...
`, [fechaInicio, fechaFin]);
// Después:
const { rows } = await pool.query(`
SELECT ...
FROM cfdis
WHERE ${VIGENTE} AND ${FR}${extra}
AND ${ctx.esEmisor}
GROUP BY ...
`, [fechaInicio, fechaFin]);
```
`extra` retorna con leading space cuando agrega contenido. Si ambos flags son `true` retorna string vacío y la query queda idéntica.
- [ ] **Step 4: Repetir para `calcularEgresosPorRegimen`**
Misma extensión de signature (2 params al final con default `true`), mismo helper `extra = buildExtraFilters(...)`, misma aplicación a todos los `FROM cfdis` del cuerpo.
- [ ] **Step 5: Verificar typecheck del API**
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/api typecheck`
Expected: PASS sin errores. Cualquier callsite existente de estas funciones que no pase los nuevos params usa los defaults `true`, comportamiento idéntico a antes.
- [ ] **Step 6: Commit**
```bash
cd C:/Users/chtr1/Downloads/Horux_despacho
git add apps/api/src/services/dashboard.service.ts
git commit -m "feat(api): calcular*PorRegimen aceptan flags considerarActivos/considerarNCs"
```
---
## Task 3: Extender `getResumenIva` y `getIvaMensual` en impuestos.service.ts
**Files:**
- Modify: `apps/api/src/services/impuestos.service.ts`
- [ ] **Step 1: Agregar import del helper al inicio del archivo**
Buscar la sección de imports del archivo. Agregar:
```ts
import { buildExtraFilters } from './_shared/cfdi-filters.js';
```
- [ ] **Step 2: Extender signature de `getResumenIva`**
Encontrar `export async function getResumenIva(...)`. Agregar 2 params al final con default `true`:
```ts
export async function getResumenIva(
pool: Pool,
fechaInicio: string,
fechaFin: string,
tenantId: string,
conciliacion?: boolean,
contribuyenteId?: string | null,
considerarActivos: boolean = true,
considerarNCs: boolean = true,
): Promise<ResumenIva>
```
- [ ] **Step 3: Computar `extra` y aplicar a todas las queries internas**
Dentro del body, después de `const FR = getFR(conciliacion);` agregar:
```ts
const extra = buildExtraFilters(considerarActivos, considerarNCs);
```
Y aplicar `${extra}` al final de cada WHERE en queries con `FROM cfdis` (las que NO usan alias `e` — esas son Task 5). Aplica el mismo patrón del Task 2 Step 3.
- [ ] **Step 4: Extender el cache gate de getResumenIva**
Buscar la condición que protege el path de cache (alrededor de línea 322 según la versión actual del archivo, puede haber cambiado por WIP). El patrón es:
```ts
if (
!conciliacion &&
contribuyenteId &&
...condiciones existentes...
) {
const cached = await readResumenIvaFromCache(...);
if (cached) return cached;
}
```
Extender:
```ts
if (
!conciliacion &&
considerarActivos && // nuevo: cache solo aplica con backend default (todo incluido)
considerarNCs && // nuevo
contribuyenteId &&
...condiciones existentes...
) {
const cached = await readResumenIvaFromCache(...);
if (cached) return cached;
}
```
Cuando UI tiene los toggles OFF (default), `considerarActivos===false || considerarNCs===false` → cache bypass → live query. Aceptado para Fase 1.
- [ ] **Step 5: Extender signature de `getIvaMensual`**
Misma extensión: agregar 2 params al final con default `true`. Agregar `const extra = buildExtraFilters(...)` y aplicar a todas las queries con `FROM cfdis` dentro del loop mensual.
- [ ] **Step 6: Verificar typecheck del API**
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/api typecheck`
Expected: PASS.
- [ ] **Step 7: Commit**
```bash
cd C:/Users/chtr1/Downloads/Horux_despacho
git add apps/api/src/services/impuestos.service.ts
git commit -m "feat(api): getResumenIva y getIvaMensual aceptan flags considerarActivos/considerarNCs + cache gate"
```
---
## Task 4: Extender `getResumenIsr`, `getIsrMensual`, `getResumenIsrDesglosado`
**Files:**
- Modify: `apps/api/src/services/impuestos.service.ts`
- [ ] **Step 1: Extender signature de `getResumenIsr`**
Agregar 2 params al final con default `true`:
```ts
export async function getResumenIsr(
pool: Pool,
fechaInicio: string,
fechaFin: string,
tenantId: string,
conciliacion?: boolean,
contribuyenteId?: string | null,
considerarActivos: boolean = true,
considerarNCs: boolean = true,
): Promise<ResumenIsr>
```
- [ ] **Step 2: Propagar a llamadas a `calcular*PorRegimen` y a queries internas**
Dentro de `getResumenIsr`:
- Agregar `const extra = buildExtraFilters(considerarActivos, considerarNCs);` al inicio del cuerpo (después del `getFR`).
- Aplicar `${extra}` a TODOS los `FROM cfdis` internos de la función (sin alias).
- En las llamadas existentes `calcularIngresosPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId)` agregar al final los 2 nuevos args:
```ts
const ingresosData = await calcularIngresosPorRegimen(
pool, tenantId, fechaInicio, fechaFin,
undefined, undefined, conciliacion, contribuyenteId,
considerarActivos, considerarNCs, // nuevos
);
```
Idem para `calcularEgresosPorRegimen`.
- [ ] **Step 3: Extender signature de `getIsrMensual`**
Agregar 2 params al final con default `true`:
```ts
export async function getIsrMensual(
pool: Pool,
año: number,
tenantId: string,
conciliacion?: boolean,
contribuyenteId?: string | null,
regimenClave?: string | null,
considerarActivos: boolean = true,
considerarNCs: boolean = true,
): Promise<IsrMensual[]>
```
- [ ] **Step 4: Propagar dentro de `getIsrMensual`**
Dentro del loop mensual de `getIsrMensual`, las llamadas existentes a `calcularIngresosPorRegimen` y `calcularEgresosPorRegimen` deben recibir los 2 nuevos args al final. Patrón:
```ts
const [ingresosData, egresosData] = await Promise.all([
calcularIngresosPorRegimen(
pool, tenantId, fi, ff,
undefined, undefined, conciliacion, contribuyenteId,
considerarActivos, considerarNCs, // nuevos
),
calcularEgresosPorRegimen(
pool, tenantId, fi, ff,
undefined, undefined, conciliacion, contribuyenteId,
considerarActivos, considerarNCs, // nuevos
),
]);
```
- [ ] **Step 5: Extender signature de `getResumenIsrDesglosado`**
Agregar 2 params al final con default `true`:
```ts
export async function getResumenIsrDesglosado(
pool: Pool,
fechaFin: string,
tenantId: string,
conciliacion?: boolean,
contribuyenteId?: string | null,
considerarActivos: boolean = true,
considerarNCs: boolean = true,
): Promise<import('@horux/shared').ResumenIsrDesglosado>
```
- [ ] **Step 6: Propagar dentro de `getResumenIsrDesglosado`**
Las 3 llamadas a `getResumenIsr` (una secuencial para `anteriores` cuando mesFinal !== 1, dos en `Promise.all` para `delPeriodo` y `total`) deben pasar los 2 nuevos args al final:
```ts
anteriores = await getResumenIsr(
pool, fiAnt, ffAnt, tenantId, conciliacion, contribuyenteId,
considerarActivos, considerarNCs, // nuevos
);
const [delPeriodo, total] = await Promise.all([
getResumenIsr(pool, fiPeriodo, ffPeriodo, tenantId, conciliacion, contribuyenteId, considerarActivos, considerarNCs),
getResumenIsr(pool, fiTotal, ffTotal, tenantId, conciliacion, contribuyenteId, considerarActivos, considerarNCs),
]);
```
- [ ] **Step 7: Verificar typecheck del API**
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/api typecheck`
Expected: PASS.
- [ ] **Step 8: Commit**
```bash
cd C:/Users/chtr1/Downloads/Horux_despacho
git add apps/api/src/services/impuestos.service.ts
git commit -m "feat(api): getResumenIsr/getIsrMensual/getResumenIsrDesglosado aceptan flags considerarActivos/considerarNCs"
```
---
## Task 5: Aplicar filtros a subqueries con alias `e` (rama I PPD/07)
**Files:**
- Modify: `apps/api/src/services/impuestos.service.ts`
**Context:** En la rama I PPD/07 hay subqueries que iteran sobre `cfdis e` (alias) para detectar E que referencian I PPD/07. Estos subqueries pueden ser constants templates (`SUM_E_REFERENCING_TRAS`, `SUM_E_REFERENCING_RET`, `HAS_E_REFERENCING_MISMO_MES`) o expresiones inline. Necesitan el filtro `buildExtraFiltersAlias('e', ...)`.
- [ ] **Step 1: Importar `buildExtraFiltersAlias`**
Verificar que el import al inicio del archivo incluya ambas:
```ts
import { buildExtraFilters, buildExtraFiltersAlias } from './_shared/cfdi-filters.js';
```
- [ ] **Step 2: Identificar y modificar las constantes/templates de subqueries con alias `e`**
Buscar `cfdis e` en `impuestos.service.ts`. Deberían aparecer en constantes como `SUM_E_REFERENCING_TRAS = (esLadoE: string) => \`...\`` y similares.
**Decisión arquitectónica**: estas constantes son templates funcionales. La forma más limpia es **convertirlas a funciones que reciben los flags** y los aplican.
Buscar las constantes existentes (típicamente templates string functions) y convertirlas. Ejemplo (la firma exacta existente puede variar; la idea es agregar los 2 params al final):
Si encuentras (formato actual aproximado):
```ts
const SUM_E_REFERENCING_TRAS = (esLadoE: string) => `COALESCE((
SELECT SUM(${IVA_TRAS_EXPR_ALIAS('e')})
FROM cfdis e
WHERE e.status NOT IN ('Cancelado', '0')
AND ${esLadoE}
AND ...resto del where...
), 0)`;
```
Cambiar a:
```ts
const SUM_E_REFERENCING_TRAS = (esLadoE: string, considerarActivos: boolean, considerarNCs: boolean) => `COALESCE((
SELECT SUM(${IVA_TRAS_EXPR_ALIAS('e')})
FROM cfdis e
WHERE e.status NOT IN ('Cancelado', '0')
AND ${esLadoE}
AND ...resto del where...${buildExtraFiltersAlias('e', considerarActivos, considerarNCs)}
), 0)`;
```
Aplicar el mismo patrón a las demás subqueries con alias `e`:
- `SUM_E_REFERENCING_TRAS`
- `SUM_E_REFERENCING_RET`
- `HAS_E_REFERENCING_MISMO_MES`
- Cualquier otra que use `cfdis e`
- [ ] **Step 3: Actualizar callsites de las subqueries**
Buscar dónde se usan estas funciones (ej. dentro de `getResumenIva`, `getResumenIsr`, sus helpers `bucketCausadoNeg`, `bucketAcreditableNeg`, etc.) y agregar los nuevos params:
```ts
// Antes:
SUM_E_REFERENCING_TRAS(esLado)
// Después:
SUM_E_REFERENCING_TRAS(esLado, considerarActivos, considerarNCs)
```
Los callsites están dentro de funciones que ya recibieron los flags en Tasks 3 y 4. Solo es propagación local.
- [ ] **Step 4: Verificar typecheck del API**
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/api typecheck`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
cd C:/Users/chtr1/Downloads/Horux_despacho
git add apps/api/src/services/impuestos.service.ts
git commit -m "feat(api): subqueries con alias 'e' (I PPD/07) respetan flags considerarActivos/considerarNCs"
```
---
## Task 6: Controllers — `parseFlag` helper + propagación
**Files:**
- Modify: `apps/api/src/controllers/impuestos.controller.ts`
- [ ] **Step 1: Agregar helper `parseFlag` cerca del top del archivo**
Después del helper `parseConciliacion(req)` existente, agregar:
```ts
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';
}
```
- [ ] **Step 2: Extender los 5 handlers**
Para cada uno de los 5 handlers (`getIvaMensual`, `getIsrMensual`, `getResumenIva`, `getResumenIsr`, `getResumenIsrDesglosado`):
1. Agregar las 2 lecturas de query params después de las existentes:
```ts
const considerarActivos = parseFlag(req, 'considerarActivos', true);
const considerarNCs = parseFlag(req, 'considerarNCs', true);
```
2. Pasar al service como los 2 últimos args.
Ejemplo para `getResumenIsrDesglosado`:
```ts
export async function getResumenIsrDesglosado(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 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); // nuevo
const considerarNCs = parseFlag(req, 'considerarNCs', true); // nuevo
const desglose = await impuestosService.getResumenIsrDesglosado(
req.tenantPool,
fechaFin,
effectiveTenantId(req),
conciliacion,
contribuyenteId,
considerarActivos, // nuevo
considerarNCs, // nuevo
);
res.json(desglose);
} catch (error) {
next(error);
}
}
```
Aplicar el mismo patrón a los otros 4 handlers (`getIvaMensual`, `getIsrMensual`, `getResumenIva`, `getResumenIsr`).
- [ ] **Step 3: Verificar typecheck**
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/api typecheck`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
cd C:/Users/chtr1/Downloads/Horux_despacho
git add apps/api/src/controllers/impuestos.controller.ts
git commit -m "feat(api): controllers parsean flags considerarActivos/considerarNCs y los propagan al service"
```
---
## Task 7: Frontend API client
**Files:**
- Modify: `apps/web/lib/api/impuestos.ts`
- [ ] **Step 1: Extender las 5 funciones HTTP**
Para cada función, agregar 2 params booleanos opcionales y serializarlos en `URLSearchParams`. Patrón:
```ts
export async function getResumenIsrDesglosado(
fechaFin: string,
conciliacion?: boolean,
considerarActivos?: boolean,
considerarNCs?: boolean,
contribuyenteId?: string | null,
): Promise<ResumenIsrDesglosado> {
const params = new URLSearchParams();
params.set('fechaFin', fechaFin);
if (conciliacion) params.set('conciliacion', 'true');
if (considerarActivos) params.set('considerarActivos', 'true');
if (considerarNCs) params.set('considerarNCs', 'true');
if (contribuyenteId) params.set('contribuyenteId', contribuyenteId);
const response = await apiClient.get<ResumenIsrDesglosado>(`/impuestos/isr/resumen-desglosado?${params}`);
return response.data;
}
```
Aplicar el mismo patrón a:
- `getIsrMensual(año, conciliacion, contribuyenteId, regimenClave, considerarActivos, considerarNCs)` — orden: insertar los 2 nuevos AL FINAL para no romper callers existentes que pasan posicionalmente.
- `getIvaMensual(año, conciliacion, contribuyenteId, considerarActivos, considerarNCs)`
- `getResumenIva(fechaInicio, fechaFin, conciliacion, contribuyenteId, considerarActivos, considerarNCs)`
- `getResumenIsr(fechaInicio, fechaFin, conciliacion, contribuyenteId, considerarActivos, considerarNCs)`
- `getResumenIsrDesglosado(fechaFin, conciliacion, considerarActivos, considerarNCs, contribuyenteId)` — la signature actual ya tiene `contribuyenteId` al final; mantenerlo allí.
**Importante**: solo set en URLSearchParams cuando el valor es `true`. Si el frontend pasa `undefined` o `false`, NO se manda el param (el backend default `true` aplica). Esto evita ambigüedad con la convención `'false'` string.
Espera — esta regla es la INVERSA de lo que queremos. Nuestro UI default es `false` (toggle OFF) y queremos QUE EL BACKEND EXCLUYA. Si el frontend NO manda el param cuando el toggle está OFF, el backend default `true` (include) aplica → no se excluye → COMPORTAMIENTO INCORRECTO.
Corrección: serializar el booleano explícitamente (siempre).
```ts
if (considerarActivos !== undefined) params.set('considerarActivos', String(considerarActivos));
if (considerarNCs !== undefined) params.set('considerarNCs', String(considerarNCs));
```
Y en el controller (ya implementado en Task 6) `parseFlag` retorna `false` cuando `req.query.considerarActivos === 'false'`.
Verificar que el `parseFlag` del Task 6 maneja el string `'false'`:
```ts
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'; // cualquier otra cosa (ej. 'false', '0') → false
}
```
`v === 'true' || v === '1'` retorna `false` cuando `v === 'false'`. Correcto.
Aplicar a los 5 funciones:
```ts
if (considerarActivos !== undefined) params.set('considerarActivos', String(considerarActivos));
if (considerarNCs !== undefined) params.set('considerarNCs', String(considerarNCs));
```
- [ ] **Step 2: Verificar typecheck del web**
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/web exec tsc --noEmit 2>&1 | grep "lib/api/impuestos"`
Expected: NO output (clean — los errores pre-existentes en otros archivos del web no nos importan).
- [ ] **Step 3: Commit**
```bash
cd C:/Users/chtr1/Downloads/Horux_despacho
git add apps/web/lib/api/impuestos.ts
git commit -m "feat(web): API client funciones aceptan considerarActivos/considerarNCs"
```
---
## Task 8: Frontend hooks
**Files:**
- Modify: `apps/web/lib/hooks/use-impuestos.ts`
- [ ] **Step 1: Extender los 5 hooks con 2 params nuevos**
Para cada hook, agregar 2 params booleanos opcionales al final, incluirlos en `queryKey`, y pasarlos al API call. Patrón:
```ts
export function useResumenIsrDesglosado(
fechaFin: string,
conciliacion?: boolean,
considerarActivos?: boolean,
considerarNCs?: boolean,
) {
const tk = useTenantKey();
const { selectedContribuyenteId } = useContribuyenteStore();
return useQuery({
queryKey: ['isr-resumen-desglosado', tk, fechaFin, conciliacion, considerarActivos, considerarNCs, selectedContribuyenteId],
queryFn: () => impuestosApi.getResumenIsrDesglosado(fechaFin, conciliacion, considerarActivos, considerarNCs, selectedContribuyenteId),
enabled: !!fechaFin,
});
}
```
Aplicar a los 5 hooks: `useResumenIsrDesglosado`, `useResumenIsr`, `useResumenIva`, `useIsrMensual`, `useIvaMensual`.
Para `useIsrMensual` que ya tiene `regimenClave` opcional, mantener ese param y agregar los 2 nuevos al final:
```ts
export function useIsrMensual(
año?: number,
conciliacion?: boolean,
regimenClave?: string | null,
considerarActivos?: boolean,
considerarNCs?: boolean,
)
```
(Verificar el orden actual de params del hook — los nuevos van AL FINAL.)
- [ ] **Step 2: Verificar typecheck del web**
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/web exec tsc --noEmit 2>&1 | grep "lib/hooks/use-impuestos"`
Expected: NO output.
- [ ] **Step 3: Commit**
```bash
cd C:/Users/chtr1/Downloads/Horux_despacho
git add apps/web/lib/hooks/use-impuestos.ts
git commit -m "feat(web): hooks de impuestos aceptan considerarActivos/considerarNCs en queryKey"
```
---
## Task 9: Frontend UI — toggles + propagación
**Files:**
- Modify: `apps/web/app/(dashboard)/impuestos/page.tsx`
- [ ] **Step 1: Agregar 2 useState al inicio del componente**
Buscar la sección de useState existente (cerca de líneas 30-40, donde está `useState(false)` para `conciliacion`). Agregar:
```ts
const [considerarActivos, setConsiderarActivos] = useState(false);
const [considerarNCs, setConsiderarNCs] = useState(false);
```
- [ ] **Step 2: Pasar los 2 nuevos states a TODOS los hooks de impuestos**
Buscar cada llamada a hook y agregar los 2 args al final. Patrón:
```ts
// Antes:
const { data: resumenIsr } = useResumenIsr(fechaInicio, fechaFin, conciliacion);
const { data: resumenIsrDesglose } = useResumenIsrDesglosado(fechaFin, conciliacion);
// Después:
const { data: resumenIsr } = useResumenIsr(fechaInicio, fechaFin, conciliacion, considerarActivos, considerarNCs);
const { data: resumenIsrDesglose } = useResumenIsrDesglosado(fechaFin, conciliacion, considerarActivos, considerarNCs);
```
Aplicar a:
- `useIvaMensual(año, conciliacion, considerarActivos, considerarNCs)`
- `useIsrMensual(año, conciliacion, regimenSeleccionado, considerarActivos, considerarNCs)`
- `useResumenIva(fechaInicio, fechaFin, conciliacion, considerarActivos, considerarNCs)`
- `useResumenIsr(fechaInicio, fechaFin, conciliacion, considerarActivos, considerarNCs)`
- `useResumenIsrDesglosado(fechaFin, conciliacion, considerarActivos, considerarNCs)`
- [ ] **Step 3: Agregar 2 toggle buttons al row de filtros**
Buscar el bloque del toggle de Conciliación (alrededor de líneas 92-103). Después del button de Conciliación y antes del cierre del `<div className="flex items-center gap-3">`, agregar:
```tsx
<button
onClick={() => setConsiderarActivos(!considerarActivos)}
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
considerarActivos
? 'bg-primary/10 text-primary border border-primary/30'
: 'hover:bg-accent'
)}
title="Si está inactivo, no se consideran facturas tipo I con uso de CFDI I01-I08 (compras de activos fijos)."
>
<CheckSquare className="h-4 w-4" />
Considerar activos
</button>
<button
onClick={() => setConsiderarNCs(!considerarNCs)}
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
considerarNCs
? 'bg-primary/10 text-primary border border-primary/30'
: 'hover:bg-accent'
)}
title="Si está inactivo, no se consideran facturas tipo E con tipo de relación 01 (notas de crédito)."
>
<CheckSquare className="h-4 w-4" />
Considerar NCs
</button>
```
`CheckSquare` y `cn` ya están importados al inicio del archivo. NO agregues imports nuevos.
- [ ] **Step 4: Verificar typecheck del web**
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/web exec tsc --noEmit 2>&1 | grep "impuestos/page"`
Expected: NO output.
- [ ] **Step 5: Smoke (opcional, defer si dev no corre)**
Si dev corre (`curl -s -o /dev/null -w "%{http_code}" http://localhost:3000 2>/dev/null` retorna algo distinto de 000):
1. Abrir `/impuestos`, pestaña ISR. Confirmar que aparecen 3 toggles: Conciliación, Considerar activos, Considerar NCs (todos OFF inicialmente).
2. Tooltip al hover en cada toggle nuevo describe el filtro.
3. Click "Considerar activos" → cambia a estilo activo (azul).
4. Verificar que los números de la tabla y la sección "Cálculo de ISR del Periodo" recalculan al togglear.
5. Smoke completo cross-feature en Task 10.
Si dev NO corre, **NO lo inicies**. Skip.
- [ ] **Step 6: Commit**
```bash
cd C:/Users/chtr1/Downloads/Horux_despacho
git add "apps/web/app/(dashboard)/impuestos/page.tsx"
git commit -m "feat(web): toggles 'Considerar activos' y 'Considerar NCs' en /impuestos"
```
---
## Task 10: Verificación final + sync OneDrive + commit V.1.0.7
**Files:**
- Verify: typecheck completo
- Smoke: cross-feature en browser
- Copy: 8 archivos a OneDrive (1 nuevo + 7 modificados)
- Commit: V.1.0.7
- [ ] **Step 1: Typecheck completo de shared + api**
```bash
cd C:/Users/chtr1/Downloads/Horux_despacho
pnpm --filter @horux/shared typecheck
pnpm --filter @horux/api typecheck
```
Expected: ambos PASS sin errores. Si falla, **STOP y reporta**.
- [ ] **Step 2: Verificar archivos web del plan limpios**
```bash
cd C:/Users/chtr1/Downloads/Horux_despacho
pnpm --filter @horux/web exec tsc --noEmit 2>&1 | grep -E "(lib/api/impuestos|lib/hooks/use-impuestos|impuestos/page)"
```
Expected: NO output (los 3 archivos web del plan están limpios; otros errores web son pre-existentes y fuera de scope).
- [ ] **Step 3: Smoke cross-feature**
Si dev corre y tienes acceso al browser:
1. **Default UI** (`/impuestos`, ambos toggles OFF):
- ISR/IVA cargan números menores que antes (excluyen activos + NCs).
- Tabla "Histórico ISR" usa los acumulados filtrados.
- Sección "Cálculo de ISR del Periodo" refleja los filtros consistentemente en `delPeriodo`, `anteriores`, `total`.
2. **Toggle "Considerar activos" ON**: ingresos/deducciones/base gravable suben con la suma de activos del periodo.
3. **Toggle "Considerar NCs" ON**: cambia el bucket — NCs aparecen restando.
4. **Combinaciones**: probar las 4 combinaciones de los 2 toggles + Conciliación on/off (8 total).
5. **Cross-check `/dashboard`**: KPIs (ingresos, gastos, utilidad) **NO cambian** vs antes del deploy. Esto valida que el default `true` en `calcular*PorRegimen` preserva el dashboard.
6. **Activos Fijos tab**: la tabla sigue mostrando todos los CFDIs I con uso I01-I08 (no afectada por el toggle "Considerar activos" en ISR/IVA).
7. **Cambiar contribuyente**: el state de los toggles persiste en sesión (no se resetea al cambiar contribuyente).
Si no puedes hacer smoke completo, reporta qué se verificó y qué quedó pendiente para el owner.
- [ ] **Step 4: Copiar archivos a OneDrive (8 archivos: 1 nuevo + 7 modificados)**
```bash
SRC="C:/Users/chtr1/Downloads/Horux_despacho"
DST="C:/Users/chtr1/OneDrive/Documentos/GitHub/Horux_despachos"
# Crear carpeta _shared si no existe en OneDrive
mkdir -p "$DST/apps/api/src/services/_shared"
cp -p "$SRC/apps/api/src/services/_shared/cfdi-filters.ts" "$DST/apps/api/src/services/_shared/cfdi-filters.ts"
cp -p "$SRC/apps/api/src/services/dashboard.service.ts" "$DST/apps/api/src/services/dashboard.service.ts"
cp -p "$SRC/apps/api/src/services/impuestos.service.ts" "$DST/apps/api/src/services/impuestos.service.ts"
cp -p "$SRC/apps/api/src/controllers/impuestos.controller.ts" "$DST/apps/api/src/controllers/impuestos.controller.ts"
cp -p "$SRC/apps/web/lib/api/impuestos.ts" "$DST/apps/web/lib/api/impuestos.ts"
cp -p "$SRC/apps/web/lib/hooks/use-impuestos.ts" "$DST/apps/web/lib/hooks/use-impuestos.ts"
cp -p "$SRC/apps/web/app/(dashboard)/impuestos/page.tsx" "$DST/apps/web/app/(dashboard)/impuestos/page.tsx"
cp -p "$SRC/docs/superpowers/specs/2026-04-27-filtros-activos-ncs-impuestos-fase1-design.md" "$DST/docs/superpowers/specs/2026-04-27-filtros-activos-ncs-impuestos-fase1-design.md"
cp -p "$SRC/docs/superpowers/plans/2026-04-27-filtros-activos-ncs-impuestos-fase1.md" "$DST/docs/superpowers/plans/2026-04-27-filtros-activos-ncs-impuestos-fase1.md"
```
- [ ] **Step 5: Verificar diff Downloads vs OneDrive**
```bash
diff -rq \
--exclude=node_modules --exclude=.git --exclude=.turbo --exclude=.next \
--exclude=dist --exclude=tsconfig.tsbuildinfo --exclude=email-previews \
--exclude=pnpm-lock.yaml --exclude=.env --exclude=.env.local \
"C:/Users/chtr1/Downloads/Horux_despacho" \
"C:/Users/chtr1/OneDrive/Documentos/GitHub/Horux_despachos"
```
Expected: única diferencia esperada es `Only in C:/Users/chtr1/Downloads/Horux_despacho/apps/api: data` (XMLs runtime). Si aparece otra cosa, **STOP y reporta**.
- [ ] **Step 6: Commit en OneDrive**
```bash
cd "C:/Users/chtr1/OneDrive/Documentos/GitHub/Horux_despachos"
git status --short
```
Confirma que aparezcan exactamente los archivos copiados como M (modified) o ?? (untracked). Si hay algo más, reporta.
```bash
git add \
apps/api/src/services/_shared/cfdi-filters.ts \
apps/api/src/services/dashboard.service.ts \
apps/api/src/services/impuestos.service.ts \
apps/api/src/controllers/impuestos.controller.ts \
apps/web/lib/api/impuestos.ts \
apps/web/lib/hooks/use-impuestos.ts \
"apps/web/app/(dashboard)/impuestos/page.tsx" \
docs/superpowers/specs/2026-04-27-filtros-activos-ncs-impuestos-fase1-design.md \
docs/superpowers/plans/2026-04-27-filtros-activos-ncs-impuestos-fase1.md
git commit -m "V.1.0.7"
git status --short
git log -2 --oneline
```
Expected:
- Commit creado con hash nuevo, mensaje `V.1.0.7`.
- Working tree clean.
- `git log -2` muestra V.1.0.7 sobre V.1.0.6.
- [ ] **Step 7: NO push**
Push lo hace el owner manualmente. Confirmar explícitamente que NO se ejecutó `git push`.

View File

@@ -0,0 +1,894 @@
# ISR — Base gravable acumulada y desglose del periodo — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Mostrar la base gravable y los acumulados de ISR correctamente en la pestaña ISR de `/impuestos`. La tabla histórica gana 3 columnas acumuladas (Ingresos, Deducciones, Base Gravable Acum.) y pierde la BG mensual incorrecta. La sección "Cálculo de ISR del Periodo" muestra el desglose `del periodo + anteriores = total acumulado` como en el formato 14 del SAT.
**Architecture:** Cambio puramente de cómputo + UI. Backend agrega running totals a `getIsrMensual` y un nuevo endpoint `/impuestos/resumen-isr-desglosado` que llama 3 veces a `getResumenIsr` (mes final, anteriores, total) y los devuelve juntos. Frontend modifica la tabla y reescribe el card de cálculo. Sin migraciones, sin cambio en la BD.
**Tech Stack:** Express + TypeScript en el API, Next.js 14 + React Query en el web, types compartidos en `@horux/shared`. Verificación vía `pnpm typecheck` (este proyecto no tiene unit tests para esta área — la disciplina es typecheck + smoke manual, ver `feedback_horux360_tscheck.md`).
**Spec:** `docs/superpowers/specs/2026-04-27-isr-base-gravable-acumulada-design.md`
---
## File Structure
### Files to modify
```
packages/shared/src/types/impuestos.ts
└── Extender IsrMensual con ingresosAcum, deduccionesAcum, baseGravableAcum
└── Agregar interface ResumenIsrDesglosado
apps/api/src/services/impuestos.service.ts
└── Modificar getIsrMensual (líneas 409-486): pase de running totals
└── Agregar getResumenIsrDesglosado (función nueva exportada)
apps/api/src/controllers/impuestos.controller.ts
└── Agregar handler getResumenIsrDesglosado
apps/api/src/routes/impuestos.routes.ts
└── Agregar GET /isr/resumen-desglosado
apps/web/lib/api/impuestos.ts
└── Agregar función getResumenIsrDesglosado (cliente HTTP)
apps/web/lib/hooks/use-impuestos.ts
└── Agregar hook useResumenIsrDesglosado
apps/web/app/(dashboard)/impuestos/page.tsx
└── Tabla Histórico ISR: 6 columnas, BG mensual fuera, BG_acum en rojo si negativa
└── Sección "Cálculo de ISR del Periodo": rename + layout nuevo con desglose
```
### Files NOT touched
- BD: ningún cambio de schema.
- `metricas_mensuales` cache: sigue guardando mensuales puros.
- KPIs de la parte alta de `/impuestos`: siguen mostrando rango filtrado completo.
- IVA mensual: fuera de scope.
---
## Task 1: Extender shared types
**Files:**
- Modify: `packages/shared/src/types/impuestos.ts`
- [ ] **Step 1: Agregar campos acumulados a `IsrMensual`**
Editar el interface existente (líneas 16-28):
```ts
export interface IsrMensual {
id: number;
año: number;
mes: number;
ingresosAcumulados: number; // mensual — naming legacy, no se renombra en este spec
deducciones: number; // mensual
baseGravable: number; // mensual — sigue retornándose para no romper consumidores externos, pero ya no se muestra en la UI
// Nuevos: running totals desde enero hasta el mes de esta fila
ingresosAcum: number;
deduccionesAcum: number;
baseGravableAcum: number; // sin clamp; puede ser negativo
isrCausado: number;
isrRetenido: number;
isrAPagar: number;
estado: EstadoDeclaracion;
fechaDeclaracion: string | null;
}
```
- [ ] **Step 2: Agregar `ResumenIsrDesglosado` al final del archivo**
```ts
/**
* Desglose del cálculo provisional ISR del mes final del filtro:
* delPeriodo = solo el mes final del filtro (1 mes)
* anteriores = enero hasta el mes anterior al final (puede estar vacío)
* total = enero hasta el mes final inclusive
*
* Reglas:
* - delPeriodo + anteriores = total para campos aditivos (ingresos, deducciones, retenciones).
* - Para baseGravable e isrCausado el total se calcula sobre el rango entero
* (no es la suma algebraica de delPeriodo + anteriores).
* - baseGravable puede ser negativa en cualquiera de los tres rangos.
* - isrCausado se clampa a 0 cuando la baseGravable acumulada es negativa.
*/
export interface ResumenIsrDesglosado {
delPeriodo: ResumenIsr;
anteriores: ResumenIsr;
total: ResumenIsr;
/** Mes final del filtro (1-12) */
mesFinal: number;
/** Año fiscal del filtro */
anio: number;
}
```
- [ ] **Step 3: Verificar que el package compile**
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/shared typecheck`
Expected: PASS sin errores.
- [ ] **Step 4: Commit**
```bash
cd C:/Users/chtr1/Downloads/Horux_despacho
git add packages/shared/src/types/impuestos.ts
git commit -m "feat(shared): types para acumulados ISR mensual + desglose del periodo"
```
---
## Task 2: Backend — running totals en `getIsrMensual`
**Files:**
- Modify: `apps/api/src/services/impuestos.service.ts:409-486`
- [ ] **Step 1: Modificar el push del result en el loop interno**
Encontrar el `result.push({ ... })` actual (alrededor de líneas 470-482) y agregar campos placeholder. Cambiar:
```ts
result.push({
id: 0,
año,
mes: m,
ingresosAcumulados: ing,
deducciones: ded,
baseGravable: base,
isrCausado: 0,
isrRetenido: 0,
isrAPagar: 0,
estado: 'pendiente',
fechaDeclaracion: null,
});
```
A:
```ts
result.push({
id: 0,
año,
mes: m,
ingresosAcumulados: ing,
deducciones: ded,
baseGravable: base,
ingresosAcum: 0, // se llena en el segundo pase abajo
deduccionesAcum: 0,
baseGravableAcum: 0,
isrCausado: 0,
isrRetenido: 0,
isrAPagar: 0,
estado: 'pendiente',
fechaDeclaracion: null,
});
```
- [ ] **Step 2: Agregar segundo pase de running totals justo antes del `return result`**
Reemplazar `return result;` por:
```ts
// Running totals: para cada mes, acumular ingresos y deducciones desde enero
// hasta ese mes inclusive. baseGravableAcum NO se clampa — los déficits se
// muestran negativos en la UI y solo se clampan al pasar a ISR causado.
let ingAcum = 0;
let dedAcum = 0;
for (const row of result) {
ingAcum += row.ingresosAcumulados; // (campo mensual, naming heredado)
dedAcum += row.deducciones;
row.ingresosAcum = ingAcum;
row.deduccionesAcum = dedAcum;
row.baseGravableAcum = ingAcum - dedAcum;
}
return result;
}
```
- [ ] **Step 3: Verificar typecheck del API**
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/api typecheck`
Expected: PASS sin errores. Si falla por que `IsrMensual` requiere los campos nuevos, asegurar que Task 1 ya esté aplicada.
- [ ] **Step 4: Commit**
```bash
cd C:/Users/chtr1/Downloads/Horux_despacho
git add apps/api/src/services/impuestos.service.ts
git commit -m "feat(api): getIsrMensual computa running totals (ingresos/deducciones/base gravable acumulada)"
```
---
## Task 3: Backend — nueva función `getResumenIsrDesglosado`
**Files:**
- Modify: `apps/api/src/services/impuestos.service.ts` (agregar al final, después de `getResumenIsr`)
- [ ] **Step 1: Agregar la función exportada**
Buscar el final de `getResumenIsr` (alrededor de línea 887) y después del `}` agregar:
```ts
/**
* Desglose del cálculo provisional ISR para el mes final del filtro.
*
* Tres llamadas a getResumenIsr con rangos distintos:
* - delPeriodo: solo el mes final del filtro (1 mes calendario)
* - anteriores: enero hasta el mes anterior al final (vacío si mesFinal=1)
* - total: enero hasta el mes final inclusive
*
* Si mesFinal === 1, la rama "anteriores" no llama al backend — retorna ceros
* para evitar un query inútil.
*/
export async function getResumenIsrDesglosado(
pool: Pool,
fechaFin: string,
tenantId: string,
conciliacion?: boolean,
contribuyenteId?: string | null,
): Promise<import('@horux/shared').ResumenIsrDesglosado> {
const fechaFinDate = new Date(fechaFin + 'T00:00:00');
const anio = fechaFinDate.getFullYear();
const mesFinal = fechaFinDate.getMonth() + 1; // 1-12
// Helper para construir rango fin de mes
const mmFinal = String(mesFinal).padStart(2, '0');
const ultDiaFinal = new Date(anio, mesFinal, 0).getDate();
const ultDiaFinalStr = String(ultDiaFinal).padStart(2, '0');
// delPeriodo: 1er a último día del mes final
const fiPeriodo = `${anio}-${mmFinal}-01`;
const ffPeriodo = `${anio}-${mmFinal}-${ultDiaFinalStr}`;
// anteriores: enero 1 al último día del (mesFinal - 1). Vacío si mesFinal=1.
let anteriores: import('@horux/shared').ResumenIsr;
if (mesFinal === 1) {
anteriores = emptyResumenIsr();
} else {
const mesAntes = mesFinal - 1;
const mmAntes = String(mesAntes).padStart(2, '0');
const ultDiaAntes = new Date(anio, mesAntes, 0).getDate();
const ultDiaAntesStr = String(ultDiaAntes).padStart(2, '0');
const fiAnt = `${anio}-01-01`;
const ffAnt = `${anio}-${mmAntes}-${ultDiaAntesStr}`;
anteriores = await getResumenIsr(pool, fiAnt, ffAnt, tenantId, conciliacion, contribuyenteId);
}
// total: enero 1 al último día del mes final
const fiTotal = `${anio}-01-01`;
const ffTotal = `${anio}-${mmFinal}-${ultDiaFinalStr}`;
const [delPeriodo, total] = await Promise.all([
getResumenIsr(pool, fiPeriodo, ffPeriodo, tenantId, conciliacion, contribuyenteId),
getResumenIsr(pool, fiTotal, ffTotal, tenantId, conciliacion, contribuyenteId),
]);
return { delPeriodo, anteriores, total, mesFinal, anio };
}
function emptyResumenIsr(): import('@horux/shared').ResumenIsr {
return {
ingresosAcumulados: 0,
ingresosPorRegimen: [],
deducciones: 0,
deduccionesPorRegimen: [],
baseGravable: 0,
baseGravablePorRegimen: [],
isrCausado: 0,
isrRetenido: 0,
isrAPagar: 0,
};
}
```
- [ ] **Step 2: Sin import top-level necesario**
El archivo ya usa el patrón `import('@horux/shared').XYZ` inline (ver línea 793 con `BaseGravableRegimen`). El código del Step 1 sigue ese patrón para `ResumenIsr` y `ResumenIsrDesglosado`, así que no hace falta agregar un import top-level. Continuar al Step 3.
- [ ] **Step 3: Verificar typecheck del API**
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/api typecheck`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
cd C:/Users/chtr1/Downloads/Horux_despacho
git add apps/api/src/services/impuestos.service.ts
git commit -m "feat(api): getResumenIsrDesglosado retorna {delPeriodo, anteriores, total} para desglose ISR provisional"
```
---
## Task 4: Backend — controller handler
**Files:**
- Modify: `apps/api/src/controllers/impuestos.controller.ts`
- [ ] **Step 1: Agregar handler después de `getResumenIsr` (línea 88)**
Insertar entre `getResumenIsr` y `getCoeficiente`:
```ts
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 desglose = await impuestosService.getResumenIsrDesglosado(
req.tenantPool,
fechaFin,
effectiveTenantId(req),
conciliacion,
contribuyenteId,
);
res.json(desglose);
} catch (error) {
next(error);
}
}
```
- [ ] **Step 2: Verificar typecheck**
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/api typecheck`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
cd C:/Users/chtr1/Downloads/Horux_despacho
git add apps/api/src/controllers/impuestos.controller.ts
git commit -m "feat(api): controller handler para resumen-isr-desglosado"
```
---
## Task 5: Backend — wire up route
**Files:**
- Modify: `apps/api/src/routes/impuestos.routes.ts`
- [ ] **Step 1: Agregar la ruta**
Encontrar la línea 17 (`router.get('/isr/resumen', impuestosController.getResumenIsr);`) y agregar inmediatamente después:
```ts
router.get('/isr/resumen-desglosado', impuestosController.getResumenIsrDesglosado);
```
El bloque queda así:
```ts
router.get('/iva/mensual', impuestosController.getIvaMensual);
router.get('/iva/resumen', impuestosController.getResumenIva);
router.get('/isr/mensual', impuestosController.getIsrMensual);
router.get('/isr/resumen', impuestosController.getResumenIsr);
router.get('/isr/resumen-desglosado', impuestosController.getResumenIsrDesglosado);
router.get('/isr/coeficiente', impuestosController.getCoeficiente);
router.put('/isr/coeficiente', impuestosController.setCoeficiente);
```
- [ ] **Step 2: Verificar typecheck**
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/api typecheck`
Expected: PASS.
- [ ] **Step 3: Smoke test del endpoint con un tenant existente**
Necesitas el dev API corriendo. En otra terminal:
```bash
cd C:/Users/chtr1/Downloads/Horux_despacho
pnpm dev
```
Login con un usuario que tenga datos (p.ej. del tenant Patito) y obtener el JWT. Luego:
```bash
# Reemplazar TOKEN por el JWT real
curl -s "http://localhost:4000/api/impuestos/isr/resumen-desglosado?fechaFin=2026-03-31&conciliacion=false" \
-H "Authorization: Bearer $TOKEN" | jq '. | {mesFinal, anio, "delPeriodo.ingresos": .delPeriodo.ingresosAcumulados, "anteriores.ingresos": .anteriores.ingresosAcumulados, "total.ingresos": .total.ingresosAcumulados}'
```
Expected:
- `mesFinal: 3, anio: 2026`
- `total.ingresos === delPeriodo.ingresos + anteriores.ingresos` (suma debe cuadrar para ingresos/deducciones/retenciones)
- `total.baseGravable` puede diferir de la suma (BG no es aditiva si hay meses de pérdida).
Probar también `fechaFin=2026-01-31` y verificar `anteriores.ingresosAcumulados === 0`.
- [ ] **Step 4: Commit**
```bash
cd C:/Users/chtr1/Downloads/Horux_despacho
git add apps/api/src/routes/impuestos.routes.ts
git commit -m "feat(api): ruta GET /impuestos/isr/resumen-desglosado"
```
---
## Task 6: Frontend — API client
**Files:**
- Modify: `apps/web/lib/api/impuestos.ts`
- [ ] **Step 1: Actualizar import de types**
En la línea 2 cambiar:
```ts
import type { IvaMensual, IsrMensual, ResumenIva, ResumenIsr } from '@horux/shared';
```
A:
```ts
import type { IvaMensual, IsrMensual, ResumenIva, ResumenIsr, ResumenIsrDesglosado } from '@horux/shared';
```
- [ ] **Step 2: Agregar la función al final del archivo**
Después de `getResumenIsr` (línea 51), agregar:
```ts
export async function getResumenIsrDesglosado(
fechaFin: string,
conciliacion?: boolean,
contribuyenteId?: string | null,
): Promise<ResumenIsrDesglosado> {
const params = new URLSearchParams();
params.set('fechaFin', fechaFin);
if (conciliacion) params.set('conciliacion', 'true');
if (contribuyenteId) params.set('contribuyenteId', contribuyenteId);
const response = await apiClient.get<ResumenIsrDesglosado>(`/impuestos/isr/resumen-desglosado?${params}`);
return response.data;
}
```
- [ ] **Step 3: Verificar typecheck del web**
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/web typecheck`
Expected: PASS. Si la app no tiene script `typecheck`, correr `pnpm --filter @horux/web exec tsc --noEmit`.
- [ ] **Step 4: Commit**
```bash
cd C:/Users/chtr1/Downloads/Horux_despacho
git add apps/web/lib/api/impuestos.ts
git commit -m "feat(web): cliente API getResumenIsrDesglosado"
```
---
## Task 7: Frontend — hook `useResumenIsrDesglosado`
**Files:**
- Modify: `apps/web/lib/hooks/use-impuestos.ts`
- [ ] **Step 1: Agregar hook al final del archivo**
Después de `useResumenIsr` (línea 55), agregar:
```ts
export function useResumenIsrDesglosado(fechaFin: string, conciliacion?: boolean) {
const tk = useTenantKey();
const { selectedContribuyenteId } = useContribuyenteStore();
return useQuery({
queryKey: ['isr-resumen-desglosado', tk, fechaFin, conciliacion, selectedContribuyenteId],
queryFn: () => impuestosApi.getResumenIsrDesglosado(fechaFin, conciliacion, selectedContribuyenteId),
enabled: !!fechaFin,
});
}
```
- [ ] **Step 2: Verificar typecheck**
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/web exec tsc --noEmit`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
cd C:/Users/chtr1/Downloads/Horux_despacho
git add apps/web/lib/hooks/use-impuestos.ts
git commit -m "feat(web): hook useResumenIsrDesglosado"
```
---
## Task 8: Frontend — Tabla "Histórico ISR" con columnas acumuladas
**Files:**
- Modify: `apps/web/app/(dashboard)/impuestos/page.tsx:502-568`
- [ ] **Step 1: Reemplazar el bloque del export Excel (líneas 506-524)**
Cambiar:
```tsx
{isrMensual && isrMensual.length > 0 && (
<Button variant="outline" size="sm" onClick={() => exportToExcel(
isrMensual.map(r => ({
Mes: meses[r.mes - 1],
Ingresos: r.ingresosAcumulados,
Deducciones: r.deducciones,
'Base Gravable': r.baseGravable,
})),
[
{ header: 'Mes', key: 'Mes', width: 12 },
{ header: 'Ingresos', key: 'Ingresos', width: 18 },
{ header: 'Deducciones', key: 'Deducciones', width: 18 },
{ header: 'Base Gravable', key: 'Base Gravable', width: 18 },
],
`isr-mensual-${año}`,
)}>
<Download className="h-4 w-4 mr-1" /> Excel
</Button>
)}
```
A:
```tsx
{isrMensual && isrMensual.length > 0 && (
<Button variant="outline" size="sm" onClick={() => exportToExcel(
isrMensual.map(r => ({
Mes: meses[r.mes - 1],
Ingresos: r.ingresosAcumulados,
'Ingresos Acumulados': r.ingresosAcum,
Deducciones: r.deducciones,
'Deducciones Acumuladas': r.deduccionesAcum,
'Base Gravable Acumulada': r.baseGravableAcum,
})),
[
{ header: 'Mes', key: 'Mes', width: 12 },
{ header: 'Ingresos', key: 'Ingresos', width: 18 },
{ header: 'Ingresos Acumulados', key: 'Ingresos Acumulados', width: 22 },
{ header: 'Deducciones', key: 'Deducciones', width: 18 },
{ header: 'Deducciones Acumuladas', key: 'Deducciones Acumuladas', width: 22 },
{ header: 'Base Gravable Acumulada', key: 'Base Gravable Acumulada', width: 22 },
],
`isr-mensual-${año}`,
)}>
<Download className="h-4 w-4 mr-1" /> Excel
</Button>
)}
```
- [ ] **Step 2: Reemplazar el `<thead>` (líneas 532-538)**
Cambiar:
```tsx
<thead>
<tr className="border-b text-left text-sm text-muted-foreground">
<th className="pb-3 font-medium">Mes</th>
<th className="pb-3 font-medium text-right">Ingresos</th>
<th className="pb-3 font-medium text-right">Deducciones</th>
<th className="pb-3 font-medium text-right">Base Gravable</th>
</tr>
</thead>
```
A:
```tsx
<thead>
<tr className="border-b text-left text-sm text-muted-foreground">
<th className="pb-3 font-medium">Mes</th>
<th className="pb-3 font-medium text-right">Ingresos</th>
<th className="pb-3 font-medium text-right">Ingresos Acum.</th>
<th className="pb-3 font-medium text-right">Deducciones</th>
<th className="pb-3 font-medium text-right">Deducciones Acum.</th>
<th className="pb-3 font-medium text-right">Base Gravable Acum.</th>
</tr>
</thead>
```
- [ ] **Step 3: Reemplazar el `<tbody>` filas y la fila Total (líneas 540-566)**
Cambiar el bloque entero de `<tbody>...</tbody>` por:
```tsx
<tbody className="text-sm">
{isrMensual?.map((row) => (
<tr key={row.mes} className="border-b hover:bg-muted/50">
<td className="py-3 font-medium">{meses[row.mes - 1]}</td>
<td className="py-3 text-right">{formatCurrency(row.ingresosAcumulados)}</td>
<td className="py-3 text-right">{formatCurrency(row.ingresosAcum)}</td>
<td className="py-3 text-right">{formatCurrency(row.deducciones)}</td>
<td className="py-3 text-right">{formatCurrency(row.deduccionesAcum)}</td>
<td className={cn(
'py-3 text-right font-medium',
row.baseGravableAcum < 0 ? 'text-destructive' : ''
)}>
{formatCurrency(row.baseGravableAcum)}
</td>
</tr>
))}
{(!isrMensual || isrMensual.length === 0) && (
<tr>
<td colSpan={6} className="py-8 text-center text-muted-foreground">
No hay registros de ISR para este año
</td>
</tr>
)}
</tbody>
```
Notas:
- Removida la fila Total. La última fila (con datos) ya es el YTD al cierre de ese mes.
- `colSpan={6}` actualizado de 4.
- [ ] **Step 4: Verificar typecheck del web**
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/web exec tsc --noEmit`
Expected: PASS.
- [ ] **Step 5: Smoke manual de la tabla**
Si el dev no está corriendo: `pnpm dev`. Luego:
1. Abrir http://localhost:3000/impuestos en el navegador.
2. Cambiar a la pestaña ISR.
3. Verificar que aparezcan **6 columnas** en la tabla "Histórico ISR".
4. Verificar que las columnas Ingresos Acum., Deducciones Acum. y Base Gravable Acum. muestren running totals correctos (la fila de febrero debe tener acumulado = enero + febrero).
5. Si hay un mes con BG negativa, verificar que aparezca **en rojo** (`text-destructive`).
6. Hacer click en "Excel" y verificar que el archivo descargado tenga las 6 columnas alineadas con el orden de la UI.
- [ ] **Step 6: Commit**
```bash
cd C:/Users/chtr1/Downloads/Horux_despacho
git add apps/web/app/\(dashboard\)/impuestos/page.tsx
git commit -m "feat(web): tabla Histórico ISR con columnas acumuladas; BG mensual deja de mostrarse"
```
---
## Task 9: Frontend — Sección "Cálculo de ISR del Periodo"
**Files:**
- Modify: `apps/web/app/(dashboard)/impuestos/page.tsx:371-432`
- [ ] **Step 1: Importar el nuevo hook**
Buscar la línea 7:
```ts
import { useIvaMensual, useIsrMensual, useResumenIva, useResumenIsr, useCoeficiente } from '@/lib/hooks/use-impuestos';
```
Cambiar a:
```ts
import { useIvaMensual, useIsrMensual, useResumenIva, useResumenIsr, useResumenIsrDesglosado, useCoeficiente } from '@/lib/hooks/use-impuestos';
```
- [ ] **Step 2: Llamar al nuevo hook después de `useResumenIsr`**
Buscar la línea 46 (`const { data: resumenIsr } = useResumenIsr(fechaInicio, fechaFin, conciliacion);`) y agregar inmediatamente después:
```ts
const { data: resumenIsrDesglose } = useResumenIsrDesglosado(fechaFin, conciliacion);
```
- [ ] **Step 3: Reescribir la sección del Card "Cálculo de ISR Acumulado"**
Reemplazar el bloque desde `<CardTitle className="text-base">Calculo de ISR Acumulado</CardTitle>` hasta el cierre `</CardContent>` correspondiente (aproximadamente líneas 381-432) por:
```tsx
<CardTitle className="text-base">Cálculo de ISR del Periodo</CardTitle>
</CardHeader>
<CardContent>
{(() => {
// Etiquetas dinámicas a partir del mesFinal del filtro
const desglose = resumenIsrDesglose;
if (!desglose) {
return <div className="text-sm text-muted-foreground">Cargando</div>;
}
const { delPeriodo, anteriores, total, mesFinal, anio } = desglose;
const labelMesFinal = `${meses[mesFinal - 1]} ${anio}`;
const labelAnteriores =
mesFinal === 1
? '(sin meses anteriores)'
: mesFinal === 2
? `(${meses[0]})`
: `(${meses[0]}-${meses[mesFinal - 2]})`;
// Resolver per-régimen si hay régimen seleccionado, igual patrón que antes.
const ingPer = regimenSeleccionado
? delPeriodo.ingresosPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: delPeriodo.ingresosAcumulados || 0;
const ingAnt = regimenSeleccionado
? anteriores.ingresosPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: anteriores.ingresosAcumulados || 0;
const dedPer = regimenSeleccionado
? delPeriodo.deduccionesPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: delPeriodo.deducciones || 0;
const dedAnt = regimenSeleccionado
? anteriores.deduccionesPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: anteriores.deducciones || 0;
const bgTotal = regimenSeleccionado
? total.baseGravablePorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.baseGravable || 0
: total.baseGravable || 0;
const causadoTotal = regimenSeleccionado
? total.baseGravablePorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.isrCausado || 0
: total.isrCausado || 0;
const retenido = total.isrRetenido || 0;
const aPagar = Math.max(0, causadoTotal - (regimenSeleccionado ? 0 : retenido));
return (
<div className="space-y-1">
<div className="flex justify-between py-2 border-b">
<span className="text-muted-foreground">Ingresos del periodo ({labelMesFinal})</span>
<span className="font-medium">{formatCurrency(ingPer)}</span>
</div>
<div className="flex justify-between py-2 border-b">
<span className="text-muted-foreground">(+) Ingresos acumulados anteriores {labelAnteriores}</span>
<span className="font-medium">{formatCurrency(ingAnt)}</span>
</div>
<div className="flex justify-between py-2 border-b">
<span className="text-muted-foreground">() Deducciones del periodo ({labelMesFinal})</span>
<span className="font-medium">{formatCurrency(dedPer)}</span>
</div>
<div className="flex justify-between py-2 border-b">
<span className="text-muted-foreground">() Deducciones acumuladas anteriores {labelAnteriores}</span>
<span className="font-medium">{formatCurrency(dedAnt)}</span>
</div>
<div className="flex justify-between py-2 border-b">
<span className="font-medium">(=) Base gravable acumulada</span>
<span className={cn('font-medium', bgTotal < 0 ? 'text-destructive' : '')}>
{formatCurrency(bgTotal)}
</span>
</div>
<div className="flex justify-between py-2 border-b">
<span className="text-muted-foreground">ISR causado (acumulado)</span>
<span className="font-medium">{formatCurrency(causadoTotal)}</span>
</div>
<div className="flex justify-between py-2 border-b">
<span className="text-muted-foreground">() ISR retenido (acumulado)</span>
<span className="font-medium">{formatCurrency(regimenSeleccionado ? 0 : retenido)}</span>
</div>
<div className="flex justify-between py-2 bg-muted/50 px-4 rounded-lg mt-2">
<span className="font-medium">ISR a pagar</span>
<span className="font-bold text-lg">{formatCurrency(aPagar)}</span>
</div>
</div>
);
})()}
</CardContent>
```
Nota: `cn` ya está importado al inicio del archivo (línea 12). Si por alguna razón no lo está, agregar `cn` al import de `@horux/shared-ui`.
- [ ] **Step 4: Verificar typecheck**
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/web exec tsc --noEmit`
Expected: PASS.
- [ ] **Step 5: Smoke manual de la sección**
Con el dev corriendo y un tenant con datos:
1. Abrir `/impuestos` → pestaña ISR.
2. Filtro de periodo en el mes corriente: verificar que aparezcan los 4 renglones de descomposición + base gravable + ISR causado + ISR retenido + ISR a pagar.
3. Cambiar el filtro a **enero del año en curso**: verificar que las dos líneas "anteriores" muestren `$0` con la etiqueta `(sin meses anteriores)`.
4. Cambiar el filtro a **febrero**: la etiqueta de "anteriores" debe decir `(Ene)`.
5. Cambiar el filtro a **marzo**: etiqueta `(Ene-Feb)`.
6. Si hay un tenant con pérdidas YTD: verificar que la línea "Base gravable acumulada" aparezca **en rojo** y que ISR a pagar sea `$0`.
7. Aritmética cruzada: la suma `Ing del periodo + Ing anteriores Ded del periodo Ded anteriores` debe coincidir con la línea Base gravable acumulada.
8. Probar también con **régimen seleccionado** en el dropdown — los números deben filtrar correctamente.
- [ ] **Step 6: Commit**
```bash
cd C:/Users/chtr1/Downloads/Horux_despacho
git add apps/web/app/\(dashboard\)/impuestos/page.tsx
git commit -m "feat(web): sección 'Cálculo de ISR del Periodo' con desglose periodo+anteriores=total"
```
---
## Task 10: Verificación final + sync OneDrive + commit release
**Files:**
- Verify: typecheck completo del repo
- Copy: 6 archivos modificados/nuevos a OneDrive
- Commit: bump de versión en OneDrive (mantener pattern V.1.0.x)
- [ ] **Step 1: Typecheck completo**
```bash
cd C:/Users/chtr1/Downloads/Horux_despacho
pnpm --filter @horux/shared typecheck
pnpm --filter @horux/api typecheck
pnpm --filter @horux/web exec tsc --noEmit
```
Expected: los tres en PASS sin errores. Si hay errores, regresar al task correspondiente.
- [ ] **Step 2: Smoke test cross-feature**
Con dev corriendo, en el browser:
1. Cambiar entre IVA y ISR — verificar que IVA siga funcionando igual (no afectado).
2. Toggle conciliación on/off — verificar que la sección de cálculo y la tabla actualicen.
3. Cambiar contribuyente activo — verificar que los queries refetchean con el contribuyente nuevo.
4. Validar que los KPIs de la parte alta (Ingresos, Base Gravable, etc.) sigan mostrando los valores del rango filtrado completo (estos NO deben cambiar — solo afectamos la tabla y la sección de cálculo).
- [ ] **Step 3: Copiar archivos a OneDrive**
```bash
SRC="C:/Users/chtr1/Downloads/Horux_despacho"
DST="C:/Users/chtr1/OneDrive/Documentos/GitHub/Horux_despachos"
cp -p "$SRC/packages/shared/src/types/impuestos.ts" "$DST/packages/shared/src/types/impuestos.ts"
cp -p "$SRC/apps/api/src/services/impuestos.service.ts" "$DST/apps/api/src/services/impuestos.service.ts"
cp -p "$SRC/apps/api/src/controllers/impuestos.controller.ts" "$DST/apps/api/src/controllers/impuestos.controller.ts"
cp -p "$SRC/apps/api/src/routes/impuestos.routes.ts" "$DST/apps/api/src/routes/impuestos.routes.ts"
cp -p "$SRC/apps/web/lib/api/impuestos.ts" "$DST/apps/web/lib/api/impuestos.ts"
cp -p "$SRC/apps/web/lib/hooks/use-impuestos.ts" "$DST/apps/web/lib/hooks/use-impuestos.ts"
cp -p "$SRC/apps/web/app/(dashboard)/impuestos/page.tsx" "$DST/apps/web/app/(dashboard)/impuestos/page.tsx"
cp -p "$SRC/docs/superpowers/specs/2026-04-27-isr-base-gravable-acumulada-design.md" "$DST/docs/superpowers/specs/2026-04-27-isr-base-gravable-acumulada-design.md"
cp -p "$SRC/docs/superpowers/plans/2026-04-27-isr-base-gravable-acumulada.md" "$DST/docs/superpowers/plans/2026-04-27-isr-base-gravable-acumulada.md"
```
- [ ] **Step 4: Verificar diff OneDrive vs Downloads**
```bash
diff -rq \
--exclude=node_modules --exclude=.git --exclude=.turbo --exclude=.next \
--exclude=dist --exclude=tsconfig.tsbuildinfo --exclude=email-previews \
--exclude=pnpm-lock.yaml --exclude=.env --exclude=.env.local \
"C:/Users/chtr1/Downloads/Horux_despacho" \
"C:/Users/chtr1/OneDrive/Documentos/GitHub/Horux_despachos"
```
Expected: única diferencia esperada es `Only in C:/Users/chtr1/Downloads/Horux_despacho/apps/api: data` (XMLs runtime). Si aparece otra diferencia inesperada, investigar.
- [ ] **Step 5: Commit en OneDrive**
```bash
cd "C:/Users/chtr1/OneDrive/Documentos/GitHub/Horux_despachos"
git add \
packages/shared/src/types/impuestos.ts \
apps/api/src/services/impuestos.service.ts \
apps/api/src/controllers/impuestos.controller.ts \
apps/api/src/routes/impuestos.routes.ts \
apps/web/lib/api/impuestos.ts \
apps/web/lib/hooks/use-impuestos.ts \
"apps/web/app/(dashboard)/impuestos/page.tsx" \
docs/superpowers/specs/2026-04-27-isr-base-gravable-acumulada-design.md \
docs/superpowers/plans/2026-04-27-isr-base-gravable-acumulada.md
git commit -m "V.1.0.6"
git status --short
git log -2 --oneline
```
Expected:
- Commit creado con hash nuevo, mensaje `V.1.0.6` (mantiene el pattern de OneDrive).
- `git status` clean.
- `git log -2` muestra V.1.0.6 sobre V.1.0.5.
- [ ] **Step 6: NO push automático**
Per workflow del owner: el push a `origin/main` lo dispara él manualmente cuando quiera. Confirmar que NO se ejecutó `git push`.

View File

@@ -0,0 +1,797 @@
# Horux360 SaaS Transformation — Design Spec
**Date:** 2026-03-15
**Status:** Approved
**Author:** Carlos Horux + Claude
## Overview
Transform Horux360 from an internal multi-tenant accounting tool into a production-ready SaaS platform. Client registration remains manual (sales-led). Each client gets a fully isolated PostgreSQL database. Payments via MercadoPago. Transactional emails via Gmail SMTP (@horuxfin.com). Production deployment on existing server (192.168.10.212).
**Target scale:** 10-50 clients within 6 months.
**Starting from scratch:** No data migration. Existing schemas/data will be archived. Fresh setup.
---
## Section 1: Database-Per-Tenant Architecture
### Rationale
Clients sign NDAs requiring complete data isolation. Schema-per-tenant (current approach) shares a single database. Database-per-tenant provides:
- Independent backup/restore per client
- No risk of cross-tenant data leakage
- Each DB can be moved to a different server if needed
### Structure
```
PostgreSQL Server (max_connections: 300)
├── horux360 ← Central DB (Prisma-managed)
├── horux_cas2408138w2 ← Client DB (raw SQL)
├── horux_roem691011ez4 ← Client DB
└── ...
```
### Central DB (`horux360`) — Prisma-managed tables
Existing tables (modified):
- `tenants` — add `database_name` column, remove `schema_name`
- `users` — no changes
- `refresh_tokens` — flush all existing tokens at migration cutover (invalidate all sessions)
- `fiel_credentials` — no changes
New tables:
- `subscriptions` — MercadoPago subscription tracking
- `payments` — payment history
### Prisma schema migration
The Prisma schema (`apps/api/prisma/schema.prisma`) must be updated:
- Replace `schema_name String @unique @map("schema_name")` with `database_name String @unique @map("database_name")` on the `Tenant` model
- Add `Subscription` and `Payment` models
- Run `prisma migrate dev` to generate and apply migration
- Update `Tenant` type in `packages/shared/src/types/tenant.ts`: replace `schemaName` with `databaseName`
### JWT payload migration
The current JWT payload embeds `schemaName`. This must change:
- Update `JWTPayload` in `packages/shared/src/types/auth.ts`: replace `schemaName` with `databaseName`
- Update token generation in `auth.service.ts`: read `tenant.databaseName` instead of `tenant.schemaName`
- Update `refreshTokens` function to embed `databaseName`
- At migration cutover: flush `refresh_tokens` table to invalidate all existing sessions (forces re-login)
### Client DB naming
Formula: `horux_<rfc_normalized>`
```
RFC "HTS240708LJA" → horux_cas2408138w2
RFC "TPR840604D98" → horux_tpr840604d98
```
### Client DB tables (created via raw SQL)
Each client database contains these tables (no schema prefix, direct `public` schema):
- `cfdis` — with indexes: fecha_emision DESC, tipo, rfc_emisor, rfc_receptor, pg_trgm on nombre_emisor/nombre_receptor, uuid_fiscal unique
- `iva_mensual`
- `isr_mensual`
- `alertas`
- `calendario_fiscal`
### TenantConnectionManager
```typescript
class TenantConnectionManager {
private pools: Map<string, { pool: pg.Pool; lastAccess: Date }>;
private cleanupInterval: NodeJS.Timer;
// Get or create a pool for a tenant
getPool(tenantId: string, databaseName: string): pg.Pool;
// Create a new tenant database with all tables and indexes
provisionDatabase(rfc: string): Promise<string>;
// Drop a tenant database (soft-delete: rename to horux_deleted_<rfc>_<timestamp>)
deprovisionDatabase(databaseName: string): Promise<void>;
// Cleanup idle pools (called every 60s, removes pools idle > 5min)
private cleanupIdlePools(): void;
}
```
Pool configuration per tenant:
- `max`: 3 connections (with 2 PM2 cluster instances, this means 6 connections/tenant max; at 50 tenants = 300, matching `max_connections`)
- `idleTimeoutMillis`: 300000 (5 min)
- `connectionTimeoutMillis`: 10000 (10 sec)
**Note on PM2 cluster mode:** Each PM2 worker is a separate Node.js process with its own `TenantConnectionManager` instance. With `instances: 2` and `max: 3` per pool, worst case is 50 tenants × 3 connections × 2 workers = 300 connections, which matches `max_connections = 300`. If scaling beyond 50 tenants, either increase `max_connections` or reduce pool `max` to 2.
### Tenant middleware change
Current: Sets `search_path` on a shared connection.
New: Returns a dedicated pool connected to the tenant's own database.
```typescript
// Before
req.tenantSchema = schema;
await pool.query(`SET search_path TO "${schema}", public`);
// After
req.tenantPool = tenantConnectionManager.getPool(tenant.id, tenant.databaseName);
```
All tenant service functions change from using a shared pool with schema prefix to using `req.tenantPool` with direct table names.
### Admin impersonation (X-View-Tenant)
The current `X-View-Tenant` header support for admin "view-as" functionality is preserved. The new middleware resolves the `databaseName` for the viewed tenant:
```typescript
// If admin is viewing another tenant
if (req.headers['x-view-tenant'] && req.user.role === 'admin') {
const viewedTenant = await getTenantByRfc(req.headers['x-view-tenant']);
req.tenantPool = tenantConnectionManager.getPool(viewedTenant.id, viewedTenant.databaseName);
} else {
req.tenantPool = tenantConnectionManager.getPool(tenant.id, tenant.databaseName);
}
```
### Provisioning flow (new client)
1. Admin creates tenant via UI → POST `/api/tenants/`
2. Insert record in `horux360.tenants` with `database_name`
3. Execute `CREATE DATABASE horux_<rfc>`
4. Connect to new DB, create all tables + indexes
5. Create admin user in `horux360.users` linked to tenant
6. Send welcome email with temporary credentials
7. Generate MercadoPago subscription link
**Rollback on partial failure:** If any step 3-7 fails:
- Drop the created database if it exists (`DROP DATABASE IF EXISTS horux_<rfc>`)
- Delete the `tenants` row
- Delete the `users` row if created
- Return error to admin with the specific step that failed
- The entire provisioning is wrapped in a try/catch with explicit cleanup
### PostgreSQL tuning
```
max_connections = 300
shared_buffers = 4GB
work_mem = 16MB
effective_cache_size = 16GB
maintenance_work_mem = 512MB
```
### Server disk
Expand from 29 GB to 100 GB to accommodate:
- 25-50 client databases (~2-3 GB total)
- Daily backups with 7-day retention (~15 GB)
- FIEL encrypted files (<100 MB)
- Logs, builds, OS (~10 GB)
---
## Section 2: SAT Credential Storage (FIEL)
### Dual storage strategy
When a client uploads their FIEL (.cer + .key + password):
**A. Filesystem (for manual linking):**
```
/var/horux/fiel/
├── HTS240708LJA/
│ ├── certificate.cer.enc ← AES-256-GCM encrypted
│ ├── private_key.key.enc ← AES-256-GCM encrypted
│ └── metadata.json.enc ← serial, validity dates, upload date (also encrypted)
└── ROEM691011EZ4/
├── certificate.cer.enc
├── private_key.key.enc
└── metadata.json.enc
```
**B. Central DB (`fiel_credentials` table):**
- Existing structure: `cer_data`, `key_data`, `key_password_encrypted`, `encryption_iv`, `encryption_tag`
- **Schema change required:** Add per-component IV/tag columns (`cer_iv`, `cer_tag`, `key_iv`, `key_tag`, `password_iv`, `password_tag`) to support independent encryption per component. Alternatively, use a single JSON column for all encryption metadata. The existing `encryption_iv` and `encryption_tag` columns can be dropped after migration.
### Encryption
- Algorithm: AES-256-GCM
- Key: `FIEL_ENCRYPTION_KEY` environment variable (separate from other secrets)
- **Code change required:** `sat-crypto.service.ts` currently derives the key from `JWT_SECRET` via `createHash('sha256').update(env.JWT_SECRET).digest()`. This must be changed to read `FIEL_ENCRYPTION_KEY` from the env schema. The `env.ts` Zod schema must be updated to declare `FIEL_ENCRYPTION_KEY` as required.
- Each component (certificate, private key, password) is encrypted separately with its own IV and auth tag. The `fiel_credentials` table stores separate `encryption_iv` and `encryption_tag` per row. The filesystem also stores each file independently encrypted.
- **Code change required:** The current `sat-crypto.service.ts` shares a single IV/tag across all three components. Refactor to encrypt each component independently with its own IV/tag. Store per-component IV/tags in the DB (add columns: `cer_iv`, `cer_tag`, `key_iv`, `key_tag`, `password_iv`, `password_tag` — or use a JSON column).
- Password is encrypted, never stored in plaintext
### Manual decryption CLI
```bash
node scripts/decrypt-fiel.js --rfc HTS240708LJA
```
- Decrypts files to `/tmp/horux-fiel-<rfc>/`
- Files auto-delete after 30 minutes (via setTimeout or tmpwatch)
- Requires SSH access to server
### Security
- `/var/horux/fiel/` permissions: `700` (root only)
- Encrypted files are useless without `FIEL_ENCRYPTION_KEY`
- `metadata.json` is also encrypted (contains serial number + RFC which could be used to query SAT's certificate validation service, violating NDA confidentiality requirements)
### Upload flow
1. Client navigates to `/configuracion/sat`
2. Uploads `.cer` + `.key` files + enters password
3. API validates the certificate (checks it's a valid FIEL, not expired)
4. Encrypts and stores in both filesystem and database
5. Sends notification email to admin team: "Cliente X subió su FIEL"
---
## Section 3: Payment System (MercadoPago)
### Integration approach
Using MercadoPago's **Preapproval (Subscription)** API for recurring payments.
### New tables in central DB
```sql
CREATE TABLE subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
plan VARCHAR(20) NOT NULL,
mp_preapproval_id VARCHAR(100),
status VARCHAR(20) NOT NULL DEFAULT 'pending',
-- status: pending | authorized | paused | cancelled
amount DECIMAL(10,2) NOT NULL,
frequency VARCHAR(10) NOT NULL DEFAULT 'monthly',
-- frequency: monthly | yearly
current_period_start TIMESTAMP,
current_period_end TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_subscriptions_tenant_id ON subscriptions(tenant_id);
CREATE INDEX idx_subscriptions_status ON subscriptions(status);
CREATE TABLE payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
subscription_id UUID REFERENCES subscriptions(id),
mp_payment_id VARCHAR(100),
amount DECIMAL(10,2) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
-- status: approved | pending | rejected | refunded
payment_method VARCHAR(50),
paid_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_payments_tenant_id ON payments(tenant_id);
CREATE INDEX idx_payments_subscription_id ON payments(subscription_id);
```
### Plans and pricing
Defined in `packages/shared/src/constants/plans.ts` (update existing):
| Plan | Monthly price (MXN) | CFDIs | Users | Features |
|------|---------------------|-------|-------|----------|
| starter | Configurable | 100 | 1 | dashboard, cfdi_basic, iva_isr |
| business | Configurable | 500 | 3 | + reportes, alertas, calendario |
| professional | Configurable | 2,000 | 10 | + xml_sat, conciliacion, forecasting |
| enterprise | Configurable | Unlimited | Unlimited | + api, multi_empresa |
Prices are configured from admin panel, not hardcoded.
### Subscription flow
1. Admin creates tenant and assigns plan
2. Admin clicks "Generate payment link" → API creates MercadoPago Preapproval
3. Link is sent to client via email
4. Client pays → MercadoPago sends webhook
5. System activates subscription, records payment
### Webhook endpoint
`POST /api/webhooks/mercadopago` (public, no auth)
Validates webhook signature using `x-signature` header and `x-request-id`.
Events handled:
- `payment` → query MercadoPago API for payment details → insert into `payments`, update subscription period
- `subscription_preapproval` → update subscription status (authorized, paused, cancelled)
On payment failure or subscription cancellation:
- Mark tenant `active = false`
- Client gets read-only access (can view data but not upload CFDIs, generate reports, etc.)
### Admin panel additions
- View subscription status per client (active, amount, next billing date)
- Generate payment link button
- "Mark as paid manually" button (for bank transfer payments)
- Payment history per client
### Client panel additions
- New section in `/configuracion`: "Mi suscripción"
- Shows: current plan, next billing date, payment history
- Client cannot change plan themselves (admin does it)
### Environment variables
```
MP_ACCESS_TOKEN=<mercadopago_access_token>
MP_WEBHOOK_SECRET=<webhook_signature_secret>
MP_NOTIFICATION_URL=https://horux360.horux360.com/api/webhooks/mercadopago
```
---
## Section 4: Transactional Emails
### Transport
Nodemailer with Gmail SMTP (Google Workspace).
```
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=<user>@horuxfin.com
SMTP_PASS=<google_app_password>
SMTP_FROM=Horux360 <noreply@horuxfin.com>
```
Requires generating an App Password in Google Workspace admin.
### Email types
| Event | Recipient | Subject |
|-------|-----------|---------|
| Client registered | Client | Bienvenido a Horux360 |
| FIEL uploaded | Admin team | [Cliente] subió su FIEL |
| Payment received | Client | Confirmación de pago - Horux360 |
| Payment failed | Client + Admin | Problema con tu pago - Horux360 |
| Subscription expiring | Client | Tu suscripción vence en 5 días |
| Subscription cancelled | Client + Admin | Suscripción cancelada - Horux360 |
### Template approach
HTML templates as TypeScript template literal functions. No external template engine.
```typescript
// services/email/templates/welcome.ts
export function welcomeEmail(data: { nombre: string; email: string; tempPassword: string; loginUrl: string }): string {
return `<!DOCTYPE html>...`;
}
```
Each template:
- Responsive HTML email (inline CSS)
- Horux360 branding (logo, colors)
- Plain text fallback
### Email service
```typescript
class EmailService {
sendWelcome(to: string, data: WelcomeData): Promise<void>;
sendFielNotification(data: FielNotificationData): Promise<void>;
sendPaymentConfirmation(to: string, data: PaymentData): Promise<void>;
sendPaymentFailed(to: string, data: PaymentData): Promise<void>;
sendSubscriptionExpiring(to: string, data: SubscriptionData): Promise<void>;
sendSubscriptionCancelled(to: string, data: SubscriptionData): Promise<void>;
}
```
### Limits
Gmail Workspace: 500 emails/day. Expected volume for 25 clients: ~50-100 emails/month. Well within limits.
---
## Section 5: Production Deployment
### Build pipeline
**API:**
```bash
cd apps/api && pnpm build # tsc → dist/
pnpm start # node dist/index.js
```
**Web:**
```bash
cd apps/web && pnpm build # next build → .next/
pnpm start # next start (optimized server)
```
### PM2 configuration
```javascript
// ecosystem.config.js
module.exports = {
apps: [
{
name: 'horux-api',
script: 'dist/index.js',
cwd: '/root/Horux/apps/api',
instances: 2,
exec_mode: 'cluster',
env: { NODE_ENV: 'production' }
},
{
name: 'horux-web',
script: 'node_modules/.bin/next',
args: 'start',
cwd: '/root/Horux/apps/web',
instances: 1,
exec_mode: 'fork',
env: { NODE_ENV: 'production' }
}
]
};
```
Auto-restart on crash. Log rotation via `pm2-logrotate`.
### Nginx reverse proxy
```nginx
# Rate limiting zone definitions (in http block of nginx.conf)
limit_req_zone $binary_remote_addr zone=auth:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=webhooks:10m rate=30r/m;
server {
listen 80;
server_name horux360.horux360.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name horux360.horux360.com;
ssl_certificate /etc/letsencrypt/live/horux360.horux360.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/horux360.horux360.com/privkey.pem;
# Security headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
# Gzip
gzip on;
gzip_types text/plain application/json application/javascript text/css;
# Health check (for monitoring)
location /api/health {
proxy_pass http://127.0.0.1:4000;
}
# Rate limiting for public endpoints
location /api/auth/ {
limit_req zone=auth burst=5 nodelay;
proxy_pass http://127.0.0.1:4000;
}
location /api/webhooks/ {
limit_req zone=webhooks burst=10 nodelay;
proxy_pass http://127.0.0.1:4000;
}
# API
location /api/ {
proxy_pass http://127.0.0.1:4000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 200M; # Bulk XML uploads (200MB is enough for ~50k XML files)
}
# Next.js
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
### Health check endpoint
The existing `GET /health` endpoint returns `{ status: 'ok', timestamp }`. PM2 uses this for liveness checks. Nginx can optionally use it for upstream health monitoring.
### SSL
Let's Encrypt with certbot. Auto-renewal via cron.
```bash
certbot --nginx -d horux360.horux360.com
```
### Firewall
```bash
ufw allow 22/tcp # SSH
ufw allow 80/tcp # HTTP (redirect to HTTPS)
ufw allow 443/tcp # HTTPS
ufw enable
```
PostgreSQL only on localhost (no external access).
### Backups
Cron job at **1:00 AM** daily (runs before SAT cron at 3:00 AM, with enough gap to complete):
**Authentication:** Create a `.pgpass` file at `/root/.pgpass` with `localhost:5432:*:postgres:<password>` and `chmod 600`. This allows `pg_dump` to authenticate without inline passwords.
```bash
#!/bin/bash
# /var/horux/scripts/backup.sh
set -euo pipefail
BACKUP_DIR=/var/horux/backups
DATE=$(date +%Y-%m-%d)
DOW=$(date +%u) # Day of week: 1=Monday, 7=Sunday
DAILY_DIR=$BACKUP_DIR/daily
WEEKLY_DIR=$BACKUP_DIR/weekly
mkdir -p $DAILY_DIR $WEEKLY_DIR
# Backup central DB
pg_dump -h localhost -U postgres horux360 | gzip > $DAILY_DIR/horux360_$DATE.sql.gz
# Backup each tenant DB
for db in $(psql -h localhost -U postgres -t -c "SELECT database_name FROM tenants WHERE database_name IS NOT NULL" horux360); do
db_trimmed=$(echo $db | xargs) # trim whitespace
pg_dump -h localhost -U postgres "$db_trimmed" | gzip > $DAILY_DIR/${db_trimmed}_${DATE}.sql.gz
done
# On Sundays, copy to weekly directory
if [ "$DOW" -eq 7 ]; then
cp $DAILY_DIR/*_${DATE}.sql.gz $WEEKLY_DIR/
fi
# Remove daily backups older than 7 days
find $DAILY_DIR -name "*.sql.gz" -mtime +7 -delete
# Remove weekly backups older than 28 days
find $WEEKLY_DIR -name "*.sql.gz" -mtime +28 -delete
# Verify backup files are not empty (catch silent pg_dump failures)
for f in $DAILY_DIR/*_${DATE}.sql.gz; do
if [ ! -s "$f" ]; then
echo "WARNING: Empty backup file: $f" >&2
fi
done
```
**Schedule separation:** Backups run at 1:00 AM, SAT cron runs at 3:00 AM. With 50 clients, backup should complete in ~15-30 minutes, leaving ample gap before SAT sync starts.
### Environment variables (production)
```
NODE_ENV=production
PORT=4000
DATABASE_URL=postgresql://postgres:<strong_password>@localhost:5432/horux360?schema=public
JWT_SECRET=<cryptographically_secure_random_64_chars>
JWT_EXPIRES_IN=24h
JWT_REFRESH_EXPIRES_IN=30d
CORS_ORIGIN=https://horux360.horux360.com
FIEL_ENCRYPTION_KEY=<separate_32_byte_hex_key>
MP_ACCESS_TOKEN=<mercadopago_production_token>
MP_WEBHOOK_SECRET=<webhook_secret>
MP_NOTIFICATION_URL=https://horux360.horux360.com/api/webhooks/mercadopago
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=<user>@horuxfin.com
SMTP_PASS=<google_app_password>
SMTP_FROM=Horux360 <noreply@horuxfin.com>
ADMIN_EMAIL=admin@horuxfin.com
```
### SAT cron
Already implemented. Runs at 3:00 AM when `NODE_ENV=production`. Will activate automatically with the environment change.
---
## Section 6: Plan Enforcement & Feature Gating
### Enforcement middleware
```typescript
// middleware: checkPlanLimits
async function checkPlanLimits(req, res, next) {
const tenant = await getTenantWithCache(req.user.tenantId); // cached 5 min
const subscription = await getActiveSubscription(tenant.id);
// Admin-impersonated requests bypass subscription check
// (admin needs to complete client setup regardless of payment status)
if (req.headers['x-view-tenant'] && req.user.role === 'admin') {
return next();
}
// Allowed statuses: 'authorized' (paid) or 'pending' (grace period for new clients)
const allowedStatuses = ['authorized', 'pending'];
// Check subscription status
if (!subscription || !allowedStatuses.includes(subscription.status)) {
// Allow read-only access for cancelled/paused subscriptions
if (req.method !== 'GET') {
return res.status(403).json({
message: 'Suscripción inactiva. Contacta soporte para reactivar.'
});
}
}
next();
}
```
**Grace period:** New clients start with `status: 'pending'` and have full write access (can upload FIEL, upload CFDIs, etc.). Once the subscription moves to `'cancelled'` or `'paused'` (e.g., failed payment), write access is revoked. Admin can also manually set status to `'authorized'` for clients who pay by bank transfer.
### CFDI limit check
Applied on `POST /api/cfdi/` and `POST /api/cfdi/bulk`:
```typescript
async function checkCfdiLimit(req, res, next) {
const tenant = await getTenantWithCache(req.user.tenantId);
if (tenant.cfdiLimit === -1) return next(); // unlimited
const currentCount = await getCfdiCountWithCache(req.tenantPool); // cached 5 min
const newCount = Array.isArray(req.body) ? req.body.length : 1;
if (currentCount + newCount > tenant.cfdiLimit) {
return res.status(403).json({
message: `Límite de CFDIs alcanzado (${currentCount}/${tenant.cfdiLimit}). Contacta soporte para upgrade.`
});
}
next();
}
```
### User limit check
Applied on `POST /api/usuarios/invite` (already partially exists):
```typescript
const userCount = await getUserCountForTenant(tenantId);
if (userCount >= tenant.usersLimit && tenant.usersLimit !== -1) {
return res.status(403).json({
message: `Límite de usuarios alcanzado (${userCount}/${tenant.usersLimit}).`
});
}
```
### Feature gating
Applied per route using the existing `hasFeature()` function from shared:
```typescript
function requireFeature(feature: string) {
return async (req, res, next) => {
const tenant = await getTenantWithCache(req.user.tenantId);
if (!hasFeature(tenant.plan, feature)) {
return res.status(403).json({
message: 'Tu plan no incluye esta función. Contacta soporte para upgrade.'
});
}
next();
};
}
// Usage in routes:
router.get('/reportes', authenticate, requireFeature('reportes'), reportesController);
router.get('/alertas', authenticate, requireFeature('alertas'), alertasController);
```
### Feature matrix
| Feature key | Starter | Business | Professional | Enterprise |
|-------------|---------|----------|-------------|------------|
| dashboard | Yes | Yes | Yes | Yes |
| cfdi_basic | Yes | Yes | Yes | Yes |
| iva_isr | Yes | Yes | Yes | Yes |
| reportes | No | Yes | Yes | Yes |
| alertas | No | Yes | Yes | Yes |
| calendario | No | Yes | Yes | Yes |
| xml_sat | No | No | Yes | Yes |
| conciliacion | No | No | Yes | Yes |
| forecasting | No | No | Yes | Yes |
| multi_empresa | No | No | No | Yes |
| api_externa | No | No | No | Yes |
### Frontend feature gating
The sidebar/navigation hides menu items based on plan:
```typescript
const tenant = useTenantInfo(); // new hook
const menuItems = allMenuItems.filter(item =>
!item.requiredFeature || hasFeature(tenant.plan, item.requiredFeature)
);
```
Pages also show an "upgrade" message if accessed directly via URL without the required plan.
### Caching
Plan checks and CFDI counts are cached in-memory with 5-minute TTL to avoid database queries on every request.
**Cache invalidation across PM2 workers:** Since each PM2 cluster worker has its own in-memory cache, subscription status changes (via webhook) must invalidate the cache in all workers. The webhook handler writes the status to the DB, then sends a `process.send()` message to the PM2 master which broadcasts to all workers to invalidate the specific tenant's cache entry. This ensures all workers reflect subscription changes within seconds, not minutes.
---
## Architecture Diagram
```
┌─────────────────────┐
│ Nginx (443/80) │
│ SSL + Rate Limit │
└──────────┬──────────┘
┌──────────────┼──────────────┐
│ │ │
┌─────▼─────┐ ┌────▼────┐ ┌──────▼──────┐
│ Next.js │ │ Express │ │ Webhook │
│ :3000 │ │ API x2 │ │ Handler │
│ (fork) │ │ :4000 │ │ (no auth) │
└───────────┘ │ (cluster)│ └──────┬──────┘
└────┬────┘ │
│ │
┌─────────▼──────────┐ │
│ TenantConnection │ │
│ Manager │ │
│ (pool per tenant) │ │
└─────────┬──────────┘ │
│ │
┌──────────────────┼──────┐ │
│ │ │ │
┌─────▼─────┐ ┌───────▼┐ ┌──▼──┐ │
│ horux360 │ │horux_ │ │horux│ │
│ (central) │ │client1 │ │_... │ │
│ │ └────────┘ └─────┘ │
│ tenants │ │
│ users │◄────────────────────────┘
│ subs │ (webhook updates)
│ payments │
└───────────┘
┌───────────────┐ ┌─────────────┐
│ /var/horux/ │ │ Gmail SMTP │
│ fiel/<rfc>/ │ │ @horuxfin │
│ backups/ │ └─────────────┘
└───────────────┘
┌───────────────┐
│ MercadoPago │
│ Preapproval │
│ API │
└───────────────┘
```
---
## Out of Scope
- Landing page (already exists separately)
- Self-service registration (clients are registered manually by admin)
- Automatic SAT connector (manual FIEL linking for now)
- Plan change by client (admin handles upgrades/downgrades)
- Mobile app
- Multi-region deployment

View File

@@ -0,0 +1,219 @@
# Modulo de Conciliacion — Spec
**Fecha:** 2026-04-12
**Estado:** Aprobado
---
## Objetivo
Permitir al usuario conciliar CFDIs emitidos y recibidos mes a mes, registrando fecha de pago y banco. Solo se permite conciliar del ano actual en adelante.
---
## Modelo de datos (BD tenant — raw SQL)
### Tabla `bancos` (nueva)
```sql
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()
);
```
### Tabla `conciliaciones` (nueva)
```sql
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 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);
```
### Columnas en `cfdis`
- `conciliado VARCHAR(50)` — ya existe. Se actualiza a `'true'` al conciliar, `NULL` al desconciliar.
- `id_conciliacion INTEGER REFERENCES conciliaciones(id)` — nueva. FK a la conciliacion asociada. NULL si no conciliado.
Al conciliar: se crean registros en `conciliaciones`, se actualiza `cfdis.conciliado = 'true'` y `cfdis.id_conciliacion = conciliaciones.id`.
Al desconciliar: se pone `cfdis.conciliado = NULL`, `cfdis.id_conciliacion = NULL`, y se elimina el registro de `conciliaciones`.
### DDL para tenants nuevos
Agregar `bancos`, `conciliaciones` en `database.ts` -> `createTables()` despues de `alertas`.
Agregar `id_conciliacion INTEGER REFERENCES conciliaciones(id)` en la tabla `cfdis`.
### Migracion para tenants existentes
`CREATE TABLE IF NOT EXISTS` para `bancos` y `conciliaciones`, luego `ALTER TABLE cfdis ADD COLUMN IF NOT EXISTS id_conciliacion INTEGER REFERENCES conciliaciones(id)`.
---
## Reglas de negocio
1. Solo se concilian CFDIs del **ano de alta del tenant en adelante** (se obtiene del `createdAt` del tenant en la BD central). Esto permite que una empresa registrada en 2025 pueda conciliar 2025, 2026, etc.
2. `anio` y `mes` de `conciliaciones` se derivan automaticamente de `fecha_de_pago`.
3. Un CFDI solo puede tener una conciliacion (`id_cfdi` es UNIQUE en conciliaciones, `id_conciliacion` en cfdis).
4. Solo CFDIs vigentes (`status NOT IN ('Cancelado', '0')`).
5. Al conciliar: INSERT en `conciliaciones` + UPDATE `cfdis` SET `conciliado = 'true'`, `id_conciliacion = <id>`.
6. Al desconciliar: UPDATE `cfdis` SET `conciliado = NULL`, `id_conciliacion = NULL` + DELETE de `conciliaciones`.
7. No se puede eliminar un banco que tenga conciliaciones asociadas.
---
## API endpoints
### Conciliacion
| Metodo | Ruta | Descripcion | Auth |
|--------|------|-------------|------|
| GET | `/conciliacion` | Lista CFDIs con estado de conciliacion | JWT + Tenant |
| POST | `/conciliacion` | Conciliar CFDIs (batch) | JWT + Tenant + admin/contador |
| DELETE | `/conciliacion/:id` | Desconciliar un CFDI | JWT + Tenant + admin/contador |
#### `GET /conciliacion`
**Query params:**
- `tipo`: `EMITIDO` | `RECIBIDO` (requerido)
- `fechaInicio`, `fechaFin`: rango de fecha de emision
- `regimen`: clave de regimen fiscal (opcional)
- `estado`: `conciliado` | `pendiente` (opcional, default: todos)
**Response:** Array de CFDIs con campo adicional `conciliacion` (null si pendiente, objeto si conciliado):
```json
{
"id": 1,
"uuid": "...",
"rfcEmisor": "...",
"nombreEmisor": "...",
"total": 1000,
"totalMxn": 1000,
"fechaEmision": "...",
"conciliado": "true",
"idConciliacion": 5,
"conciliacion": {
"id": 5,
"fechaDePago": "2026-04-10",
"banco": "BBVA",
"terminacionCuenta": "1234"
}
}
```
#### `POST /conciliacion`
**Body:**
```json
{
"cfdiIds": [1, 2, 3],
"fechaDePago": "2026-04-10",
"idBanco": 1
}
```
**Logica:**
1. Validar que todos los CFDIs existen, estan vigentes, y no estan ya conciliados.
2. Validar que `fechaDePago` es del ano actual en adelante.
3. Derivar `anio` y `mes` de `fechaDePago`.
4. Para cada CFDI: INSERT en `conciliaciones`, UPDATE `cfdis` SET `conciliado = 'true'`, `id_conciliacion = <new id>`.
#### `DELETE /conciliacion/:id`
1. Buscar la conciliacion por id.
2. UPDATE `cfdis` SET `conciliado = NULL`, `id_conciliacion = NULL` WHERE `id_conciliacion = :id`.
3. DELETE FROM `conciliaciones` WHERE `id = :id`.
### Bancos
| Metodo | Ruta | Descripcion | Auth |
|--------|------|-------------|------|
| GET | `/bancos` | Listar bancos del tenant | JWT + Tenant |
| POST | `/bancos` | Crear banco | JWT + Tenant + admin |
| PUT | `/bancos/:id` | Editar banco | JWT + Tenant + admin |
| DELETE | `/bancos/:id` | Eliminar banco (si no tiene conciliaciones) | JWT + Tenant + admin |
---
## Frontend
### Pagina `/conciliacion`
**Acceso:** Feature-gated por `conciliacion` (Business, Enterprise). Roles: admin y contador (lectura+escritura), visor (solo lectura).
**Layout:**
```
[Header: "Conciliacion"]
[Filtros: PeriodSelector | RegimenSelector]
[Tabs: Emitidas | Recibidas]
[Seccion: "Por conciliar" — tabla con checkboxes]
[Barra de accion: Banco (dropdown) + Fecha de pago (date) + Boton "Conciliar"]
[Seccion: "Conciliadas" — tabla con info de conciliacion + boton desconciliar]
```
**Tabla "Por conciliar":**
- Checkbox (no visible para visor)
- UUID (corto), Fecha emision, RFC Emisor/Receptor, Nombre, Total MXN, Metodo Pago
- Boton "Ver factura" (CfdiViewerModal)
**Tabla "Conciliadas":**
- UUID, Fecha emision, RFC, Nombre, Total MXN
- Fecha de pago, Banco (nombre + terminacion)
- Boton "Desconciliar" (no visible para visor)
- Boton "Ver factura"
**Flujo de conciliacion:**
1. Usuario selecciona checkboxes en "Por conciliar"
2. Aparece barra de accion sticky en la parte inferior
3. Selecciona banco (dropdown de bancos del tenant) y fecha de pago
4. Click "Conciliar N facturas"
5. Confirmacion -> POST `/conciliacion` -> refresh datos
### Seccion de bancos en `/configuracion`
Solo visible para admin. Card con:
- Lista de bancos existentes: Nombre + terminacion + boton eliminar
- Formulario inline: Nombre banco + Terminacion (max 4 digitos) + boton agregar
### Navegacion
Agregar "Conciliacion" al sidebar con feature gate `conciliacion`, visible para admin, contador, visor. Ubicacion: despues de Reportes.
---
## Archivos a crear/modificar
### Backend (crear)
- `apps/api/src/services/conciliacion.service.ts`
- `apps/api/src/controllers/conciliacion.controller.ts`
- `apps/api/src/routes/conciliacion.routes.ts`
- `apps/api/src/services/bancos.service.ts`
- `apps/api/src/controllers/bancos.controller.ts`
- `apps/api/src/routes/bancos.routes.ts`
### Backend (modificar)
- `apps/api/src/app.ts` — registrar rutas de conciliacion y bancos
- `apps/api/src/config/database.ts` — agregar tablas `bancos` y `conciliaciones` en `createTables()`, agregar `id_conciliacion` en `cfdis`
### Frontend (crear)
- `apps/web/app/(dashboard)/conciliacion/page.tsx`
- `apps/web/lib/api/conciliacion.ts`
- `apps/web/lib/api/bancos.ts`
- `apps/web/lib/hooks/use-conciliacion.ts`
- `apps/web/lib/hooks/use-bancos.ts`
### Frontend (modificar)
- `apps/web/components/layouts/sidebar.tsx` (y variantes) — agregar nav item
- `apps/web/app/(dashboard)/configuracion/page.tsx` — agregar seccion de bancos
### Migracion
- Aplicar DDL a tenant existente (`horux_ede123456ab1`): crear tablas + agregar columna

View File

@@ -0,0 +1,35 @@
---
title: Segmentación inteligente de solicitudes SAT
status: implementado
created: 2026-04-12
---
# Segmentación Inteligente de Solicitudes SAT
## Problema actual
La sincronización segmenta mes por mes, generando 4 solicitudes SAT por mes (xml emitidos, xml recibidos, metadata emitidos, metadata recibidos). Para 6 años = 288 solicitudes, agotando la cuota diaria del SAT rápidamente.
## Lógica propuesta
1. **Primer paso: solicitud de metadata del rango completo** (una sola solicitud)
- Obtener el total de CFDIs reportados por el SAT
2. **Decidir tamaño de bloque según volumen:**
- `totalCfdis <= 15,000` → bloques de **6 meses**
- `totalCfdis > 15,000` → bloques de **2 meses**
3. **Por cada bloque:**
- Descargar XMLs vigentes (cfdi + DocumentStatus active)
- Descargar metadata de todos (vigentes + cancelados)
## Impacto en solicitudes
| Escenario | Actual (mes a mes) | Propuesto (6 meses) | Propuesto (2 meses) |
|-----------|-------------------|--------------------|--------------------|
| 6 años, pocos CFDIs | 288 solicitudes | 25 solicitudes | 73 solicitudes |
| 6 años, muchos CFDIs | 288 solicitudes | N/A | 73 solicitudes |
## Archivos a modificar
- `apps/api/src/services/sat/sat.service.ts``processInitialSync()`

View File

@@ -0,0 +1,186 @@
# Opinión de Cumplimiento — Integration Design
**Date:** 2026-04-13
**Status:** Approved
## Problem
Horux360 has no way to check or track a tenant's SAT compliance status (Opinión de Cumplimiento). This is a critical fiscal document that indicates whether a company is current on all tax obligations. Currently, users must manually download it from the SAT portal.
## Solution
Integrate the existing standalone Playwright-based prototype into Horux360 as a weekly automated process. Display results in a new "Documentos" page accessible to all roles (business+ plans). Store last 6 months in DB, show last 5 in UI. Alert if status is not Positiva.
## Source Prototype
Located at `C:\Users\chtr1\Downloads\sat-opinion-prototype`. Key files to adapt:
- `src/sat-login.ts` — Playwright navigation: public page → FIEL login → report
- `src/opinion-scraper.ts` — 4 strategies to extract PDF base64 from DOM
- `src/pdf-parser.ts` — Regex extraction of RFC, razón social, estatus, folio, cadena original
- `src/types.ts``OpinionCumplimiento`, `Obligacion` interfaces
## Architecture
### New Files
| File | Purpose |
|------|---------|
| `src/services/opinion-cumplimiento.service.ts` | Orchestration: decrypt FIEL → temp files → Playwright → parse → save to DB → cleanup |
| `src/services/sat/sat-opinion-login.ts` | Adapted sat-login.ts: works with temp file paths from decrypted FIEL Buffers |
| `src/services/sat/sat-opinion-scraper.ts` | Adapted opinion-scraper.ts: extracts PDF from SAT Angular SPA |
| `src/services/sat/sat-opinion-parser.ts` | Adapted pdf-parser.ts: regex extraction from PDF text |
| `src/controllers/documentos.controller.ts` | Endpoints: list opinions, download PDF, manual trigger |
| `src/routes/documentos.routes.ts` | Routes with tenantMiddleware + feature gate |
| `src/migrations/tenant/002_create_opiniones_cumplimiento.sql` | Tenant DB migration |
| `apps/web/app/(dashboard)/documentos/page.tsx` | Frontend: Documentos page with Opinión tab |
| `apps/web/lib/api/documentos.ts` | API client functions |
| `apps/web/lib/hooks/use-documentos.ts` | React Query hooks |
### Modified Files
| File | Change |
|------|--------|
| `src/jobs/sat-sync.job.ts` | Add weekly cron for opinion download |
| `src/services/alertas-auto.service.ts` | Add alert for non-Positiva status |
| `apps/web/components/layouts/sidebar.tsx` | Add Documentos nav item |
| `apps/api/package.json` | Add playwright, pdf-parse dependencies |
| `packages/shared/src/types/` | Add OpinionCumplimiento types |
## Database
### Table: `opiniones_cumplimiento` (per-tenant DB)
```sql
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);
```
Migration file: `002_create_opiniones_cumplimiento.sql`
**Retention:** Records older than 6 months are deleted during the weekly cron run.
**UI display:** Only the last 5 records are shown via `ORDER BY fecha_consulta DESC LIMIT 5`.
## FIEL Security
The FIEL is stored encrypted (AES-256-GCM) in the central DB. For Playwright, which requires file paths:
1. `getDecryptedFiel(tenantId)` returns Buffers in memory
2. Write .cer and .key to `os.tmpdir()` with permissions `0o600`
3. Pass paths to Playwright `page.setInputFiles()`
4. Delete temp files in `finally` block (guaranteed cleanup even on error)
5. Password is only passed via `page.fill()` — never written to disk
Additional:
- Playwright runs headless in production (no `slowMo`)
- 3-minute timeout per tenant to prevent hanging processes
- Temp file names use `crypto.randomUUID()` to avoid collisions
## Cron Schedule
```
'0 4 * * 0' — Sundays 4:00 AM (America/Mexico_City)
```
Runs after the daily SAT sync (3:00 AM) to avoid overlap. Processes tenants sequentially (Playwright is heavy — no parallelism).
### Cron Flow
For each active tenant with FIEL configured:
1. Decrypt FIEL → write temp files
2. Launch Playwright headless → login → navigate to report
3. Extract PDF base64 from DOM → parse text
4. INSERT into `opiniones_cumplimiento`
5. DELETE records older than 6 months
6. Cleanup temp files
7. Close browser
Error handling: if one tenant fails, log error and continue to next. Don't stop the batch.
## API Endpoints
| Method | Route | Auth | Description |
|--------|-------|------|-------------|
| GET | `/api/documentos/opiniones` | All roles | Last 5 opinions (metadata only, no PDF binary) |
| GET | `/api/documentos/opiniones/:id/pdf` | All roles | Download PDF as binary (Content-Type: application/pdf) |
| POST | `/api/documentos/opiniones/consultar` | Admin only | Trigger manual download for current tenant |
All routes use `tenantMiddleware` + `requireFeature('documentos')`.
### GET /api/documentos/opiniones response
```json
[
{
"id": 1,
"rfc": "HTS240708LJA",
"razonSocial": "HORUX 360 SA DE CV",
"estatus": "Positiva",
"folio": "26NC4144337",
"cadenaOriginal": "||HTS240708LJA|26NC4144337|...",
"fechaConsulta": "2026-04-13T20:59:00.000Z",
"createdAt": "2026-04-13T22:00:00.000Z"
}
]
```
## Auto-Alert
New alert in `alertas-auto.service.ts`:
```typescript
async function alertaOpinionCumplimiento(pool: Pool): Promise<AlertaAuto | null>
```
- Queries latest record from `opiniones_cumplimiento`
- If `estatus !== 'Positiva'` → returns alert with priority 'alta'
- Message: "Tu Opinión de Cumplimiento es {estatus}. Última consulta: {fecha}."
- No drill-down (Documentos page shows details)
- If no records exist → no alert
## Frontend
### Sidebar
```typescript
{ name: 'Documentos', href: '/documentos', icon: FileCheck, feature: 'documentos' }
```
Between Facturación and Usuarios in the navigation array. Visible to all roles.
### Page: `/documentos`
- Tab structure (future-proof for other document types): first tab "Opinión de Cumplimiento"
- Card/row per opinion showing:
- Fecha de consulta (formatted)
- Estatus badge (green=Positiva, red=Negativa, yellow=others)
- Folio
- Button to download PDF
- "Consultar ahora" button (admin only) triggers POST
- Empty state: "No hay opiniones registradas. La consulta automática se ejecuta cada semana."
- Loading/error states with React Query
### Dependencies
Add to `apps/api/package.json`:
- `playwright` (for Chromium automation)
- `pdf-parse` v2 (for PDF text extraction)
Post-install: `npx playwright install chromium` (needed on deploy)
## Scope Exclusions
- Parser for "Negativa" opinion obligations list (refine when sample PDF available)
- Email notifications on status change (only auto-alert for now)
- Multiple document types in the Documentos page (only Opinión de Cumplimiento in v1)
- PDF viewer in browser (download only)

View File

@@ -0,0 +1,106 @@
# Tenant Schema Migrations System
**Date:** 2026-04-13
**Status:** Approved
## Problem
Horux360 uses a database-per-tenant architecture. When schema changes are made to `createTables()` or `createIndexes()` in `TenantConnectionManager`, only newly provisioned tenants get the updated schema. Existing tenants' databases drift from the expected structure, requiring manual ALTER scripts.
## Solution
A numbered SQL migration system for tenant databases, with both eager (deploy-time) and lazy (on-connect) execution.
## Architecture
### Migration Files
```
apps/api/src/migrations/tenant/
001_initial_schema.sql # Current createTables() + createIndexes()
002_example_future.sql # Template for future changes
```
- Naming: `NNN_description.sql` (zero-padded 3 digits)
- Each file must be idempotent (use `IF NOT EXISTS`, `ADD COLUMN IF NOT EXISTS`, etc.)
- Files are read from disk at runtime, sorted by version number
### Schema Migrations Table (per tenant DB)
```sql
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
name VARCHAR(255) NOT NULL,
applied_at TIMESTAMP DEFAULT NOW()
);
```
Created automatically before running any migration.
### TenantMigrationRunner
New file: `apps/api/src/config/tenant-migrations.ts`
**Exported functions:**
- `getMigrationFiles()` — Reads and sorts SQL files from migrations directory
- `getPendingMigrations(pool)` — Compares files vs `schema_migrations` table, returns pending
- `migrate(pool, databaseName?)` — Applies pending migrations in order, each in its own transaction. Returns count of applied migrations.
- `migrateAll()` — Queries all active tenants from central DB, calls `migrate()` on each. Logs progress and errors per tenant. Does not stop on individual tenant failure.
### Integration Points
1. **`TenantConnectionManager.provisionDatabase()`** — Replace `createTables()` + `createIndexes()` calls with `migrate(pool)`. This applies all migrations (starting from 001) to new tenants.
2. **`TenantConnectionManager.getPool()`** — After creating or retrieving a pool, call `migrate(pool)` if not already verified this session. Uses `migratedPools: Set<string>` to cache which tenants have been checked. Cache clears on process restart.
3. **New Turborepo script `db:migrate-tenants`** — Runs `migrateAll()` for eager deployment. Added to `apps/api/package.json` and root `turbo.json`.
4. **`createTables()` and `createIndexes()`** — Removed from `TenantConnectionManager`. Their content moves to `001_initial_schema.sql`.
### Lazy Migration Cache
```typescript
// In TenantConnectionManager
private migratedPools: Set<string> = new Set();
```
- `getPool()` checks `migratedPools.has(tenantId)` before running migrations
- If not in set → run `migrate(pool)` → add to set
- Set clears on PM2 restart (new process = fresh set)
- `invalidatePool()` also removes from `migratedPools`
### Deploy Flow
```bash
git pull
pnpm install
pnpm build
pnpm db:migrate-tenants # Eager: apply to all tenants
pm2 restart all # Lazy: safety net on connect
```
### Adding Future Schema Changes
1. Create `NNN_description.sql` in `apps/api/src/migrations/tenant/`
2. Write idempotent SQL
3. Deploy — eager applies to all, lazy catches stragglers
## Scope Exclusions
- No rollback support
- No data migrations (DDL only; data scripts remain separate)
- No parallel execution (sequential per tenant)
- No distributed locking (single PM2 fork instance)
- No changes to Prisma/central DB migrations
## Files Changed
| File | Change |
|------|--------|
| `apps/api/src/config/tenant-migrations.ts` | NEW — TenantMigrationRunner |
| `apps/api/src/migrations/tenant/001_initial_schema.sql` | NEW — current createTables + createIndexes |
| `apps/api/src/config/database.ts` | MODIFY — remove createTables/createIndexes, add lazy migration in getPool, call migrate in provisionDatabase |
| `apps/api/src/scripts/migrate-tenants.ts` | NEW — eager migration CLI script |
| `apps/api/package.json` | MODIFY — add db:migrate-tenants script |
| `turbo.json` | MODIFY — add db:migrate-tenants task |

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,166 @@
# Plan Custom — gratis, sin fecha fin, solo asignable por Admin Global
## Contexto
El owner pidió un plan "Custom" para casos donde quiere otorgar acceso al
sistema sin cobro y sin fecha de finalización (cortesía, beta tester, caso
especial). Solo el Admin Global puede asignarlo; los usuarios finales no
deben verlo en su catálogo de planes.
## Decisión clave — Reusar enum `custom`
El Plan enum de Prisma ya incluye `custom` (legacy: "precio variable por
tenant"). En dev hay **0 tenants** en ese plan, y la lógica antigua en
`subscription.service.ts` rechaza `custom` del flujo self-serve — patrón
que coincide con la nueva semántica. Reusar el enum evita migration y
mantiene compatibilidad.
## Reglas
- **Comportamiento**: idéntico a Mi Empresa (1 RFC, MANAGED, 50 timbres/mes,
features básicas, sin API ni Lolita).
- **Costo**: $0. No genera Subscription, no usa MercadoPago.
- **Vigencia**: indefinida. `tenant.trialEndsAt = null`. Sin
`currentPeriodEnd`. Ningún cron lo expira.
- **Visibilidad**: oculto del catálogo user-facing. Solo aparece como
opción en `/clientes` (admin global).
## Cambios — Catálogo
`packages/shared/src/constants/despacho-plans.ts`:
```ts
custom: {
name: 'Custom',
maxRfcs: 1,
maxUsers: 3,
maxCfdisPorContribuyente: 1_000_000,
timbresIncluidosMes: 50,
dbMode: 'MANAGED' as const,
permiteServidorBackup: false,
features: [
'dashboard', 'cfdi_basic', 'iva_isr', 'reportes', 'alertas',
'calendario', 'conciliacion', 'documentos', 'facturacion',
'forecasting', 'xml_sat',
],
},
```
NO se agrega a `DESPACHO_PLAN_PRICES` (gratis). Helpers existentes:
- `permiteOverage('custom')``false` ✓ (ya retorna false porque solo
cubre business_control y business_cloud)
- `isDespachoPaidPlan('custom')``false` ✓ (idem)
- `permiteFrecuenciaMensual('custom')``false` ✓ (no está en
DESPACHO_PLAN_PRICES)
## Cambios — Frontend types
`apps/web/lib/api/tenants.ts`:
Extender el tipo del campo `plan` en `CreateTenantData` y `UpdateTenantData`:
```ts
type AdminAssignablePlan =
| 'starter' | 'business' | 'business_ia' | 'enterprise' // legacy Horux 360
| 'custom'; // nuevo
```
(Despacho paid plans NO se incluyen — esos van por self-serve del owner,
fuera de scope per acuerdo con owner.)
## Cambios — Página `/clientes` (admin)
`apps/web/app/(dashboard)/clientes/page.tsx`:
1. Reemplazar `PlanType` local por `AdminAssignablePlan` importado.
2. Eliminar el `planLabels` local (líneas 174-178) y `planColors` local
(líneas 180-184). Usar el `PLAN_LABELS` global que ya existe arriba
del archivo (cubre todo). Para colores, expandir el map global o
inline en el render.
3. Extender el `<Select>` del form para incluir `custom`:
```tsx
<SelectContent>
<SelectItem value="starter">Starter (legacy) Sin CFDIs, 1 usuario</SelectItem>
<SelectItem value="business">Business (legacy) 50 CFDIs, 3 usuarios</SelectItem>
<SelectItem value="business_ia">Business + IA (legacy)</SelectItem>
<SelectItem value="enterprise">Enterprise (legacy) 100 CFDIs, ilimitado</SelectItem>
<SelectItem value="custom">Custom Sin cobro, sin fecha fin (despacho)</SelectItem>
</SelectContent>
```
4. Cuando el plan seleccionado es `custom`, ocultar el campo `amount`
(no aplica) o forzarlo a 0.
## Cambios — Página `/configuracion/planes-despacho` (user)
`apps/web/app/(dashboard)/configuracion/planes-despacho/page.tsx`:
- Las cards visibles son `mi_empresa`, `mi_empresa_plus`,
`business_control`, `business_cloud`. `custom` NO aparece (no está en
ese array).
- Si `planInfo?.plan === 'custom'`: mostrar un banner read-only
prominente:
> "Estás en el plan **Custom** asignado por tu administrador. Contacta
> a soporte si necesitas cambiar."
Y NO renderizar las cards (o renderizarlas atenuadas con botones
deshabilitados).
## No-cambios
- Schema BD / migration — el enum `custom` ya existe.
- Backend `PUT /api/tenants/:id` — ya acepta cualquier valor del enum
Prisma (sin Zod gate). Cero cambios.
- `subscription.service.ts` — su lógica anti-`custom` existente sigue
vigente y coincide con el nuevo comportamiento (rechaza self-serve).
- `getMyPlan` en `despacho.controller.ts` — ya lee `tenant.plan`
directamente. Custom se reportará al frontend correctamente.
- Cron `applyPendingChanges` y `expireTrials` — Custom no tiene
Subscription ni trialEndsAt, no le afectan.
- Trial RFC limit (V.1.0.11) — Custom tiene `trialEndsAt=null`, así
que el limit de 5 no aplica. Aplica el límite duro del catálogo (1).
## Riesgos / limitaciones aceptadas
1. **Transición paid → custom**: si el admin cambia un tenant que tenía
suscripción MP activa a `custom`, el preapproval MP **sigue
cobrando** hasta que se cancele manualmente. Mitigación: el admin
debe cancelar la suscripción primero desde `/configuracion/suscripcion`
del tenant impersonado, luego asignar custom. Documentar en runbook.
2. **Transición custom → paid**: el admin NO puede asignar planes
despacho pagables desde `/clientes` (no incluidos en el dropdown).
El tenant debe pasar por self-serve normal en
`/configuracion/planes-despacho`. Esto evita el escenario de un
tenant en plan paid sin Subscription que sería inconsistente.
3. **Hard limit de 1 RFC en custom**: igual que Mi Empresa, el límite
de 1 RFC para custom es solo billing-only hoy (no enforced en
`contribuyente.controller.ts:create`). Si se quiere enforce duro,
replicar el patrón del trial limit. Out of scope.
## Plan de pruebas
1. `pnpm typecheck` shared + api + web targeted: PASS.
2. **Admin asigna custom**: desde `/clientes`, edit tenant, seleccionar
"Custom", guardar. Verificar `tenant.plan === 'custom'` en BD.
3. **Admin asigna custom a tenant en trial**: trialEndsAt debería
limpiarse (a través de la lógica del service). Si el service no lo
limpia, agregar.
4. **User en custom**: login como ese tenant, ir a
`/configuracion/planes-despacho` → ver banner "Estás en plan Custom".
5. **Admin asigna otro plan a tenant en custom**: dropdown muestra los
demás planes legacy. Asignación funciona.
6. **`getMyPlan` retorna custom**: `/api/despachos/me/plan` retorna
`{ plan: 'custom', isTrialActive: false, ... }`.
## Implementación
~30 líneas netas en 4 archivos:
- `despacho-plans.ts` — agregar entrada custom (~12 líneas).
- `tenants.ts` (api client) — extender tipos (~3 líneas).
- `clientes/page.tsx` — dropdown + cleanup (~10 líneas).
- `planes-despacho/page.tsx` — banner Custom (~10 líneas).
Cambio chico, hago directo sin subagents. Una commit en Downloads + V.1.0.14
en OneDrive.

View File

@@ -0,0 +1,108 @@
# Drill-down genérica — sort por nombre emisor/receptor
## Contexto
La tabla de la página `/drill-down` (apps/web/app/(dashboard)/drill-down/page.tsx)
actualmente permite ordenar por `Fecha`, `Total MXN`, `Monto Pago` e `IVA Trasl.`
mediante el hook `useTableSort` y el componente `SortableHeader` de
`@horux/shared-ui`. Las columnas `Nombre Emisor` y `Nombre Receptor` se
renderizan como `<th>` planos no ordenables.
## Objetivo
Permitir ordenar también por nombre del emisor y por nombre del receptor,
sin remover ninguna de las columnas ordenables existentes.
Alcance limitado a la drill-down genérica. Las 9 páginas de `/alertas/*` quedan
fuera de este cambio (decisión del owner — se evaluarán después).
## Cambios
Archivo único: `apps/web/app/(dashboard)/drill-down/page.tsx`.
1. Extender el segundo parámetro de tipo de `useTableSort` para incluir las
nuevas keys:
```ts
useTableSort<Cfdi, 'fecha' | 'total' | 'pago' | 'iva' | 'emisor' | 'receptor'>
```
2. Agregar dos accesores al objeto pasado al hook:
```ts
emisor: (c) => c.nombreEmisor || '',
receptor: (c) => c.nombreReceptor || '',
```
`useTableSort` ya soporta accesores de tipo `string` — usa
`String.prototype.localeCompare` cuando ambos valores son strings, lo cual
maneja la collation del español correctamente.
3. Reemplazar los dos `<th>` planos por `SortableHeader`:
```tsx
// antes
<th className="pb-3 font-medium">Nombre Emisor</th>
<th className="pb-3 font-medium">Nombre Receptor</th>
// después
<SortableHeader label="Nombre Emisor"
active={getSortIndicator('emisor')}
onClick={() => toggleSort('emisor')} />
<SortableHeader label="Nombre Receptor"
active={getSortIndicator('receptor')}
onClick={() => toggleSort('receptor')} />
```
4. Mantener el `initialKey = 'fecha'` y `initialDir = 'desc'` (default actual).
## No-cambios
- No se tocan: `useTableSort`, `SortableHeader`, ni cualquier otro archivo en
`@horux/shared-ui`.
- No se tocan controllers ni services del API. El sort es 100% client-side.
- No se tocan las columnas RFC Emisor, RFC Receptor, UUID, Comp., M. Pago,
Reg. E ni Reg. R — siguen siendo `<th>` planos no ordenables.
- No se modifica el export a Excel: ya consume `sortedData`, así que el orden
vigente del usuario se respeta automáticamente.
## Comportamiento esperado
- Click sobre "Nombre Emisor": ordena ascendente por nombre. Re-click:
descendente. Cambia el sort activo (un solo sort a la vez, ya es el
contrato del hook).
- Click sobre "Nombre Receptor": idéntico, reemplaza al sort previo.
- Filas con `nombreEmisor` o `nombreReceptor` null/undefined: el accesor
retorna string vacío `''`, así que en `asc` aparecen primero. Es el
comportamiento estándar de `localeCompare` y se considera aceptable
(un CFDI sin nombre emisor/receptor es raro y debería ser visible al
ordenar por nombre).
## Riesgo
Mínimo:
- Cambio puramente client-side, una sola página, ~6 líneas netas.
- No introduce dependencias nuevas.
- `pnpm typecheck` debería seguir limpio (las nuevas keys están dentro del
union genérico, los accesores cumplen el contrato `(row: T) => number | string`).
## Plan de pruebas (smoke)
1. `pnpm typecheck` debe seguir en 0 errores.
2. Abrir `/drill-down` desde cualquier KPI del dashboard.
3. Click en "Nombre Emisor" → verificar orden alfabético ascendente y flecha
en el header. Re-click → descendente.
4. Click en "Nombre Receptor" → mismo comportamiento.
5. Click en "Fecha" / "Total MXN" → confirmar que los sorts pre-existentes
siguen funcionando.
6. Exportar a Excel después de ordenar por "Nombre Emisor" → confirmar que
el archivo descargado mantiene el mismo orden.
## Pendientes derivados
- Replicar el patrón en las 9 páginas de `/alertas/*` (cancelaciones,
cancelaciones-periodo-anterior, efectivo, tipo-relacion-sospechosa,
concentracion-clientes, concentracion-proveedores, discrepancia-regimen,
lista-negra-clientes, lista-negra-proveedores). Decisión del owner cuándo
abordarlas. Para `lista-negra-*` además habrá que introducir
`useTableSort` desde cero (hoy no lo usan).

View File

@@ -0,0 +1,347 @@
# Filtros "Considerar activos" y "Considerar NCs" en /impuestos — Fase 1
## Contexto
La pestaña ISR e IVA de `/impuestos` actualmente solo tiene un toggle de
"Conciliación" que cambia la semántica de fechas. El owner pidió dos toggles
adicionales:
1. **Considerar activos** — cuando ACTIVADO, incluye facturas tipo I con
`uso_cfdi` ∈ {I01, I02, I03, I04, I05, I06, I07, I08} (compras de activos
fijos / inversiones). Cuando DESACTIVADO, excluye esas facturas.
2. **Considerar NCs** — cuando ACTIVADO, incluye facturas tipo E con
`cfdi_tipo_relacion = '01'` (notas de crédito). Cuando DESACTIVADO, las
excluye.
### Decisión de defaults
**Default ambos toggles ON (incluir)** — revertido del default original OFF
por concerns de performance: con default OFF, el cache `metricas_mensuales`
quedaría siempre bypass-eado en `/impuestos` hasta Fase 2. Con default ON,
las cargas iniciales aprovechan el cache (comportamiento idéntico al de
versiones previas), y el contador opt-in al view filtrado cuando lo necesita.
Trade-off aceptado: el contador debe **desactivar manualmente** los toggles
cuando quiere ver números sin activos / sin NCs. La lógica fiscal de
"depreciación de activos" requiere consciencia del contador, no se aplica
silenciosamente.
Los filtros aplican **solo** en la pestaña Impuestos (IVA + ISR). Dashboard,
reportes, drill-downs, alertas y demás permanecen intactos.
## Justificación fiscal
- Los activos fijos (uso I01-I08) deben depreciarse, no deducirse en su mes
de adquisición. Excluirlos del cálculo provisional mensual evita inflar las
deducciones. La pestaña dedicada "Activos Fijos" (en `/impuestos`) es donde
se muestra y gestiona esa información.
- Las NCs tipoRel=01 son ajustes a documentos previos. El owner quiere ver
los números **brutos sin ajustes** por default y opt-in con el toggle. Asume
el riesgo de over-reporting si el contador olvida activarlo.
## Fases
- **Fase 1 (este spec):** UI + backend con live query. Sin cambios al cache
`metricas_mensuales`. Cuando los toggles están en su default (OFF), el cache
queda bypass-eado y todo es live query.
- **Fase 2 (spec posterior):** extender `metricas_mensuales` con columnas
base + 2 deltas para hacer el toggle instantáneo (computado por suma/resta).
## Cambios — Frontend
### `apps/web/app/(dashboard)/impuestos/page.tsx`
State nuevo (defaults `true` = filter active = incluir, cache-friendly):
```ts
const [considerarActivos, setConsiderarActivos] = useState(true);
const [considerarNCs, setConsiderarNCs] = useState(true);
```
Con defaults `true`, las cargas iniciales aprovechan el cache de
`metricas_mensuales`. El gate `!conciliacion && considerarActivos && considerarNCs`
queda en `true` por default y permite cache hit. El contador opt-in al view
filtrado desactivando los toggles cuando lo necesita.
UI: 2 toggle buttons en la misma fila que "Conciliación", mismo styling.
Orden recomendado: `Régimen | Conciliación | Considerar activos | Considerar NCs`.
```tsx
<button
onClick={() => setConsiderarActivos(!considerarActivos)}
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
considerarActivos
? 'bg-primary/10 text-primary border border-primary/30'
: 'hover:bg-accent'
)}
title="Si está inactivo, no se consideran facturas tipo I con uso de CFDI I01-I08 (compras de activos fijos)."
>
<CheckSquare className="h-4 w-4" />
Considerar activos
</button>
```
(Análogo para Considerar NCs con tooltip "...facturas tipo E con tipo de
relación 01 (notas de crédito).")
Pasar a todos los hooks consumidos en la pestaña ISR e IVA.
### `apps/web/lib/hooks/use-impuestos.ts`
Extender 5 hooks. Ejemplo:
```ts
export function useResumenIsrDesglosado(
fechaFin: string,
conciliacion?: boolean,
considerarActivos?: boolean,
considerarNCs?: boolean,
) {
const tk = useTenantKey();
const { selectedContribuyenteId } = useContribuyenteStore();
return useQuery({
queryKey: ['isr-resumen-desglosado', tk, fechaFin, conciliacion, considerarActivos, considerarNCs, selectedContribuyenteId],
queryFn: () => impuestosApi.getResumenIsrDesglosado(fechaFin, conciliacion, considerarActivos, considerarNCs, selectedContribuyenteId),
enabled: !!fechaFin,
});
}
```
Aplicar el mismo patrón a `useResumenIsr`, `useResumenIva`, `useIsrMensual`,
`useIvaMensual`.
### `apps/web/lib/api/impuestos.ts`
Extender funciones HTTP. Ejemplo:
```ts
export async function getResumenIsrDesglosado(
fechaFin: string,
conciliacion?: boolean,
considerarActivos?: boolean,
considerarNCs?: boolean,
contribuyenteId?: string | null,
): Promise<ResumenIsrDesglosado> {
const params = new URLSearchParams();
params.set('fechaFin', fechaFin);
if (conciliacion) params.set('conciliacion', 'true');
if (considerarActivos) params.set('considerarActivos', 'true');
if (considerarNCs) params.set('considerarNCs', 'true');
if (contribuyenteId) params.set('contribuyenteId', contribuyenteId);
const response = await apiClient.get<ResumenIsrDesglosado>(`/impuestos/isr/resumen-desglosado?${params}`);
return response.data;
}
```
(Análogo para `getResumenIsr`, `getResumenIva`, `getIsrMensual`, `getIvaMensual`.)
## Cambios — Backend
### Helper compartido en `apps/api/src/services/impuestos.service.ts`
```ts
/**
* Construye fragmentos AND adicionales para WHERE clauses según los toggles
* "Considerar activos" y "Considerar NCs" en la UI de impuestos.
*
* - considerarActivos === false → excluir facturas tipo I con uso de CFDI I01-I08.
* - considerarNCs === false → excluir facturas tipo E con cfdi_tipo_relacion = '01'.
*
* Cuando ambos son true (default backend), retorna string vacío. Esto preserva
* el comportamiento histórico para callers que no pasan los flags (ej. dashboard).
*/
function buildExtraFilters(considerarActivos: boolean, considerarNCs: boolean): string {
const parts: string[] = [];
if (!considerarActivos) {
parts.push(`AND NOT (tipo_comprobante = 'I' AND uso_cfdi IN ('I01','I02','I03','I04','I05','I06','I07','I08'))`);
}
if (!considerarNCs) {
parts.push(`AND NOT (tipo_comprobante = 'E' AND COALESCE(cfdi_tipo_relacion, '') = '01')`);
}
return parts.length > 0 ? ' ' + parts.join(' ') : '';
}
```
### Funciones modificadas
Agregar 2 parámetros booleanos opcionales con default `true` (= include
todo, comportamiento histórico). Forman el último par de la signature.
| Función | Archivo | Cambio |
|---|---|---|
| `calcularIngresosPorRegimen` | `dashboard.service.ts` | +`considerarActivos=true, considerarNCs=true`, concatenar `buildExtraFilters(...)` al WHERE |
| `calcularEgresosPorRegimen` | `dashboard.service.ts` | Idem |
| `getResumenIva` | `impuestos.service.ts` | Idem + propagar al cache gate (ver abajo) |
| `getIvaMensual` | `impuestos.service.ts` | Idem |
| `getResumenIsr` | `impuestos.service.ts` | Idem + propagar a `calcular*PorRegimen` |
| `getIsrMensual` | `impuestos.service.ts` | Idem + propagar a `calcular*PorRegimen` |
| `getResumenIsrDesglosado` | `impuestos.service.ts` | Idem + propagar a las 3 llamadas a `getResumenIsr` |
**Importante**: como `buildExtraFilters` está en `impuestos.service.ts` y
`calcular*PorRegimen` viven en `dashboard.service.ts`, hay que **mover el
helper a un módulo compartido** o duplicarlo. Recomendación: mover a un
nuevo `apps/api/src/services/_shared/cfdi-filters.ts` (módulo neutral
reutilizable). Ambos services lo importan.
### Aplicación del fragmento
Concatenar al WHERE de TODA query que escanee `cfdis` dentro de las funciones
afectadas. Buscar patrón `WHERE ${VIGENTE} AND ${FR}` y agregar
`${buildExtraFilters(...)}` al final del WHERE.
Ejemplo en una query existente:
```ts
const FR = getFR(conciliacion);
const extra = buildExtraFilters(considerarActivos, considerarNCs);
const { rows } = await pool.query(`
SELECT regimen_fiscal_emisor as regimen, ...
FROM cfdis
WHERE ${VIGENTE} AND ${FR}${extra}
AND ${ctx.esEmisor}
GROUP BY regimen_fiscal_emisor
`, [fechaInicio, fechaFin]);
```
`buildExtraFilters` ya retorna con leading space, así que se concatena directo.
**Subqueries con alias** (`SUM_E_REFERENCING_*`): el alias `e` para la tabla
externa requiere referenciar columnas como `e.tipo_comprobante`,
`e.uso_cfdi`, `e.cfdi_tipo_relacion`. Necesitamos una variante del helper que
acepte alias:
```ts
function buildExtraFiltersAlias(alias: string, considerarActivos: boolean, considerarNCs: boolean): string {
const parts: string[] = [];
if (!considerarActivos) {
parts.push(`AND NOT (${alias}.tipo_comprobante = 'I' AND ${alias}.uso_cfdi IN ('I01','I02','I03','I04','I05','I06','I07','I08'))`);
}
if (!considerarNCs) {
parts.push(`AND NOT (${alias}.tipo_comprobante = 'E' AND COALESCE(${alias}.cfdi_tipo_relacion, '') = '01')`);
}
return parts.length > 0 ? ' ' + parts.join(' ') : '';
}
```
Y se usa donde aparezcan subqueries con alias `e` (ej. `SUM_E_REFERENCING_*`,
`HAS_E_REFERENCING_MISMO_MES`, `E_REFERENCIA_I_PPD_07_MISMO_MES` si existe).
### Controllers — `apps/api/src/controllers/impuestos.controller.ts`
Helper para parsear (junto a `parseConciliacion`):
```ts
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';
}
```
Cada handler relevante (`getResumenIva`, `getIvaMensual`, `getResumenIsr`,
`getIsrMensual`, `getResumenIsrDesglosado`) parsea los 2 nuevos flags con
**default `true`** y los pasa al service.
```ts
const considerarActivos = parseFlag(req, 'considerarActivos', true);
const considerarNCs = parseFlag(req, 'considerarNCs', true);
```
**Razón del default `true` en el controller**: si por algún motivo el query
param no llega (cliente legacy, prueba manual, otro consumer), comportamiento
es como antes (todo incluido). El frontend siempre manda el flag explícito,
así que en la práctica el default solo aplica al testing externo.
### Cache gate en `getResumenIva` (línea ~322)
Extender la condición:
```ts
if (
!conciliacion &&
considerarActivos && // new — cache solo aplica con backend default
considerarNCs && // new
contribuyenteId &&
...
) {
const cached = await readResumenIvaFromCache(...);
if (cached) return cached;
}
```
Con UI default (ambos toggles ON), `considerarActivos=true && considerarNCs=true`
→ cache hit (comportamiento idéntico a versiones previas). Cuando el contador
desactiva alguno → cache bypass → live query (~1-3s). Aceptable porque el
desactivado es action consciente, no la carga inicial. Fase 2 hará los toggles
instantáneos vía cache base+deltas.
## No-cambios
- **Schema BD**: ninguno. SQL puro.
- **Cache `metricas_mensuales`**: estructura intacta. Solo se actualiza el gate.
- **Dashboard, reportes, drill-downs, alertas**: comportamiento idéntico
(gracias a defaults `true` en `calcular*PorRegimen`).
- **Activos Fijos tab**: usa su propio `activos-fijos.service.ts`, no pasa
por las funciones filtradas. Verificar en el smoke.
- **`getRegimenesDelPeriodo`** y otros que NO calculan ingresos/deducciones
no se modifican. Los regímenes disponibles en el dropdown siguen siendo
los mismos (basados en presencia de CFDIs, no filtrados por estos toggles).
## Riesgos
1. **Tocar funciones compartidas con dashboard**: `calcular*PorRegimen` viven
en `dashboard.service.ts`. Default `true` debería preservar el dashboard,
pero hay que verificar manualmente post-deploy.
2. **Performance Fase 1**: con UI default ON (cache-friendly), las cargas
iniciales son rápidas. Solo cuando el contador desactiva un toggle hay
live query. Fase 2 elimina ese delay también.
3. **Subqueries con alias**: hay 5+ subqueries con alias `e` en
`impuestos.service.ts` (rama I PPD/07). Cada una necesita el helper alias.
Riesgo de olvidar una → resultados inconsistentes.
4. **NCs default OFF puede sobre-reportar ingresos**: el contador puede no
notar que las NCs están excluidas si no lee el tooltip. Mitigación:
tooltip claro y label "Considerar NCs" (lectura obvia).
## Plan de pruebas (smoke)
1. **Typecheck**: `pnpm --filter @horux/shared typecheck`,
`pnpm --filter @horux/api typecheck`. Ambos PASS sin errores.
2. **Dashboard regression**: abrir `/dashboard` → KPIs (ingresos, gastos,
utilidad) deben tener los mismos valores que antes del deploy.
3. **Activos Fijos tab**: abrir `/impuestos` → pestaña "Activos Fijos" → la
tabla debe seguir mostrando todas las facturas I con uso I01-I08.
4. **UI default (ambos toggles OFF)**: cargar `/impuestos` ISR. Verificar
que ingresos del periodo y deducciones son menores que antes (excluyen
activos + NCs tipoRel=01).
5. **Toggle "Considerar activos" ON**: deducciones suben con la suma de los
activos del periodo.
6. **Toggle "Considerar NCs" ON**: comportamiento depende del lado:
- Como receptor (NC recibida que cancela una factura PUE): deducciones
bajan (la NC resta).
- Como emisor (NC emitida que cancela una factura PUE propia): ingresos
bajan.
7. **Combinaciones de los 3 toggles** (Conciliación + Activos + NCs): ocho
combinaciones, números deben ser consistentes.
8. **IVA tab**: mismas pruebas (toggle on/off, comparar números).
9. **Tabla "Histórico ISR"**: debe respetar los 2 nuevos toggles también
(cada fila refleja los acumulados con los filtros activos).
10. **Sección "Cálculo de ISR del Periodo"**: las 3 ramas (`delPeriodo`,
`anteriores`, `total`) deben respetar los toggles consistentemente.
## Pendientes derivados
- **Fase 2**: extender `metricas_mensuales` con columnas
`*_activos`, `*_ncs_01` (×3 métricas IVA = 6 columnas nuevas).
Migration + recompute del cache + actualizar lectura del cache para hacer
suma/resta según toggles. Fase 2 entrega toggles instantáneos.
- **Tooltip + iconos**: si el owner quiere distinguir visualmente los 3
toggles (Conciliación con un check, Activos con un asset icon, NCs con un
document icon), aplicar después.
- **Persistencia de los toggles**: hoy el state vive en `useState`, se pierde
al recargar. Si se quiere persistir, considerar `localStorage` o agregar a
`tenant-view-store`. Out-of-scope para Fase 1.
- **Dashboard parity**: si en el futuro el owner quiere los mismos toggles en
`/dashboard`, ya está habilitado por la signature de `calcular*PorRegimen`
— solo falta UI + propagación. Out-of-scope.

View File

@@ -0,0 +1,298 @@
# ISR — Base gravable acumulada y desglose del periodo
## Contexto
En `/impuestos` (pestaña ISR) hay dos lugares donde la base gravable se calcula
mes a mes en lugar de acumulado, lo cual es fiscalmente incorrecto para pagos
provisionales mensuales:
1. **Tabla "Histórico ISR"** (`apps/web/app/(dashboard)/impuestos/page.tsx`,
líneas ~503-568): cada fila aplica `Math.max(0, ing_mes ded_mes)` por mes
independiente. Resultado: un mes con pérdida no reduce el acumulado.
2. **Sección "Cálculo de ISR Acumulado"** (mismas líneas ~371-432): muestra los
totales del rango filtrado en `resumenIsr`, sin distinguir lo que ya estaba
acumulado de meses previos del mismo año vs. el periodo actual.
El bug raíz vive en `getIsrMensual` (`apps/api/src/services/impuestos.service.ts`,
líneas 409-486): el query corre de `${año}-${mm}-01` a fin de mes, así que el
campo nombrado `ingresosAcumulados` en `IsrMensual` realmente trae solo el mes
(deuda heredada del refactor previo, el nombre miente).
## Objetivo
Mostrar la base gravable y los montos acumulados correctamente:
1. En la tabla, agregar columnas **Ingresos Acum.**, **Deducciones Acum.** y
**Base Gravable Acum.** (estas tres son running totals desde enero hasta el
mes de cada fila). La **BG mensual desaparece** del display — solo queda la
acumulada, que es la única fiscalmente válida.
2. En la sección de cálculo, presentar el desglose como aparece en el formato
14 (declaración provisional mensual del SAT):
```
Ingresos del periodo + Ingresos anteriores
Deducciones del periodo Deducciones anteriores
= Base gravable acumulada
```
Donde **"del periodo" = mes final del filtro** y **"anteriores" = enero
hasta el mes anterior al final**.
## Reglas fiscales
- **No se aplica `max(0, ...)` al display** de base gravable. Los déficits son
reales y se muestran negativos (en rojo). Si filtras febrero y enero tuvo
utilidad pero febrero pérdida grande, `BG_acum_feb` puede ser negativa.
- **`max(0, ...)` se aplica únicamente al pasar a ISR causado**: si
`BG_acum < 0`, ISR causado = 0. SAT hace lo mismo en el formato 14.
- **El año fiscal se resetea en enero**. "Anteriores" jamás cruza a años previos.
## Cambios — Backend
### `apps/api/src/services/impuestos.service.ts`
**`getIsrMensual` (líneas 409-486):**
Después del loop que llena `result[]` con datos mensuales, agregar un segundo
pase que computa los running totals:
```ts
let ingAcum = 0, dedAcum = 0;
for (const row of result) {
ingAcum += row.ingresosAcumulados; // (mensual, a pesar del nombre)
dedAcum += row.deducciones;
row.ingresosAcum = ingAcum;
row.deduccionesAcum = dedAcum;
row.baseGravableAcum = ingAcum - dedAcum; // sin clamp
}
```
Nota sobre naming: el campo existente `ingresosAcumulados` en `IsrMensual` se
mantiene por compat (es el mensual). Los nuevos campos son `ingresosAcum`,
`deduccionesAcum`, `baseGravableAcum`. En el spec del rename total al final
puede ocurrir, pero no es scope de este cambio.
**Nueva función exportada** `getResumenIsrDesglosado`:
```ts
export async function getResumenIsrDesglosado(
pool: Pool,
fechaFin: string,
tenantId: string,
conciliacion?: boolean,
contribuyenteId?: string | null,
): Promise<{
delPeriodo: ResumenIsr;
anteriores: ResumenIsr;
total: ResumenIsr;
}>
```
Lógica:
1. Derivar `año = fechaFin.year`, `mesFinal = fechaFin.month`.
2. Tres rangos:
- **delPeriodo**: `${año}-${mesFinal}-01` a fin de `mesFinal` (solo mes final)
- **anteriores**: `${año}-01-01` a `${año}-${mesFinal-1}-${ultDia}` (Ene a mesFinal-1; vacío si mesFinal=1)
- **total**: `${año}-01-01` a fin de `mesFinal` (Ene a mesFinal)
3. Llamar `getResumenIsr` 3 veces con esos rangos, retornar el objeto.
Caso `mesFinal=1`: retornar `anteriores` con todos los campos en cero (no se hace
query inútil).
### `apps/api/src/controllers/impuestos.controller.ts`
Agregar handler `getResumenIsrDesglosado`:
```ts
// GET /api/impuestos/resumen-isr-desglosado?fechaFin=...&conciliacion=...&contribuyenteId=...
```
El filtro por régimen no se pasa al endpoint — el frontend hace el lookup
contra `resumenIsr.baseGravablePorRegimen[]` igual que hoy con `useResumenIsr`,
para que la lógica de filtrado siga centralizada en un solo lugar.
### `apps/api/src/routes/impuestos.routes.ts`
Agregar la ruta `/resumen-isr-desglosado` con los mismos middlewares que
`/resumen-isr` (auth + tenant + plan limits).
## Cambios — Shared types
### `packages/shared/src/types/reportes.ts` (o donde viva `IsrMensual`)
Agregar campos al type:
```ts
export interface IsrMensual {
// ...campos existentes
ingresosAcum: number;
deduccionesAcum: number;
baseGravableAcum: number; // sin clamp, puede ser negativo
}
export interface ResumenIsrDesglosado {
delPeriodo: ResumenIsr;
anteriores: ResumenIsr;
total: ResumenIsr;
}
```
## Cambios — Frontend
### `apps/web/lib/api/impuestos.ts`
Agregar función `getResumenIsrDesglosado` (cliente HTTP) y hook
`useResumenIsrDesglosado` en `apps/web/lib/hooks/use-impuestos.ts`.
### `apps/web/app/(dashboard)/impuestos/page.tsx`
**Tabla "Histórico ISR" (líneas ~502-568):**
Headers (6 columnas):
```
Mes | Ingresos | Ingresos Acum. | Deducciones | Deducciones Acum. | Base Gravable Acum.
```
Body por fila:
```tsx
<td>{meses[row.mes - 1]}</td>
<td className="text-right">{formatCurrency(row.ingresosAcumulados)}</td> // mensual
<td className="text-right">{formatCurrency(row.ingresosAcum)}</td>
<td className="text-right">{formatCurrency(row.deducciones)}</td>
<td className="text-right">{formatCurrency(row.deduccionesAcum)}</td>
<td className={cn(
"text-right font-medium",
row.baseGravableAcum < 0 ? "text-destructive" : ""
)}>{formatCurrency(row.baseGravableAcum)}</td>
```
Fila Total: eliminar. La última fila (diciembre) ya es el total YTD, no hace
falta sumar acumulados (sería incorrecto). Si se quiere conservar, mostrar
solo los mensuales sumados (= total año) y el último valor acumulado de la
columna BG Acum.
**Decisión por defecto en este spec:** eliminar fila Total. Si el usuario
prefiere conservarla, lo discutimos al implementar.
Export Excel: 6 columnas alineadas con UI:
```ts
[
{ header: 'Mes', key: 'Mes' },
{ header: 'Ingresos', key: 'Ingresos' },
{ header: 'Ingresos Acumulados', key: 'IngresosAcum' },
{ header: 'Deducciones', key: 'Deducciones' },
{ header: 'Deducciones Acumuladas', key: 'DeduccionesAcum' },
{ header: 'Base Gravable Acumulada', key: 'BaseGravableAcum' },
]
```
**Sección "Cálculo de ISR del Periodo" (líneas ~371-432):**
1. Renombrar `<CardTitle>` de "Cálculo de ISR Acumulado" a "Cálculo de ISR
del Periodo".
2. Reemplazar el query `useResumenIsr(fechaInicio, fechaFin, conciliacion)` por
`useResumenIsrDesglosado(fechaFin, conciliacion, contribuyenteId)`. El
filtro por régimen se aplica del lado frontend contra
`total.baseGravablePorRegimen[]` (mismo patrón que hoy).
3. Layout nuevo del card content:
```tsx
<div className="space-y-2">
<FilaDesglose label={`Ingresos del periodo (${labelMesFinal})`} value={delPeriodo.ingresos} />
<FilaDesglose label={`(+) Ingresos acumulados anteriores ${labelAnteriores}`} value={anteriores.ingresos} />
<FilaDesglose label={`() Deducciones del periodo`} value={delPeriodo.deducciones} negative />
<FilaDesglose label={`() Deducciones acumuladas anteriores`} value={anteriores.deducciones} negative />
<Divider />
<FilaDesglose
label="(=) Base gravable acumulada"
value={total.baseGravable}
bold
danger={total.baseGravable < 0}
/>
<FilaDesglose label="ISR causado (acumulado)" value={total.isrCausado} />
<FilaDesglose label="() ISR retenido (acumulado)" value={total.isrRetenido} negative />
<Divider />
<FilaDesglose label="ISR a pagar" value={total.isrAPagar} bold large />
</div>
```
Etiquetas dinámicas:
- `labelMesFinal` = `"Mar 2026"` (mes y año de `fechaFin`)
- `labelAnteriores` = `"(Ene-Feb)"` o `"(sin meses anteriores)"` cuando
`mesFinal === 1`.
Si `mesFinal === 1`: las dos filas "anteriores" muestran `$0` con texto
discretamente atenuado y el label dice "(sin meses anteriores)".
`FilaDesglose` puede ser un componente local del archivo o sustituirse por el
mismo `<div className="flex justify-between py-2 border-b">` que ya se usa.
Decisión por defecto: inline (no extraer componente nuevo, mantener el patrón
existente).
## No-cambios
- `getResumenIsr` se mantiene tal cual — sigue usándose en KPIs y otros lugares.
- Los KPIs en la parte alta de la pestaña ISR (Ingresos, Deducciones, Base
Gravable, etc.) **siguen mostrando los valores del rango filtrado completo**.
El cambio aplica solo a la tabla histórica y a la sección de cálculo.
- `metricas_mensuales` (cache) sigue guardando valores mensuales puros — el
acumulado se computa al consumir el cache. Sin invalidaciones.
- IVA mensual (`getIvaMensual`) no se toca.
## Riesgos
- **BG mensual deja de aparecer en la tabla**: si algún usuario hacía export y
reportaba la BG mensual a contadores, esa columna ya no existe. Mitigación:
comunicar el cambio en el changelog/release notes.
- **Año cruzado**: si el usuario filtra `fechaFin = 2026-03-31` pero
`fechaInicio` es de 2025, "anteriores" sigue siendo solo Ene-Feb 2026, no
baja a 2025. Esperable porque ISR se acumula por año fiscal.
- **Performance**: 3 queries `getResumenIsr` por refresh de la sección de
cálculo. Cada uno hace ~10 queries internos (por régimen, retenciones, etc.).
En un mes promedio del año, son ~30 queries. Aceptable para un endpoint
on-demand. Si se vuelve cuello de botella, optimizar con un solo query
agregado.
## Plan de pruebas (smoke)
1. `pnpm typecheck` debe seguir limpio en `@horux/api` y `@horux/shared`.
2. Backend — abrir REPL/curl:
- `GET /api/impuestos/resumen-isr-desglosado?fechaFin=2026-03-31&...`:
- `delPeriodo` = solo Mar 2026
- `anteriores` = Ene-Feb 2026
- `total.ingresos === delPeriodo.ingresos + anteriores.ingresos`
- `total.baseGravable === total.ingresos total.deducciones` (sin clamp,
puede ser negativo)
- Mismo endpoint con `fechaFin=2026-01-31`:
- `anteriores.ingresos === 0`, `anteriores.deducciones === 0`, etc.
3. Frontend tabla:
- Tenant con datos en varios meses (p.ej. Patito): verificar que cada fila
muestre el running total correcto.
- Tenant con un mes negativo (Husberto Feb si hay datos): la BG Acum debe
aparecer en rojo y reducir el acumulado del mes siguiente.
4. Frontend sección:
- Filtrar `mes=marzo`: ver que los 4 renglones cuadren con la fórmula y la
línea BG sea la suma algebraica.
- Filtrar `mes=enero`: ver que las dos líneas "anteriores" digan "$0" con
etiqueta "(sin meses anteriores)".
- Filtrar `mes=diciembre`: ver acumulado anual completo, "anteriores" =
Ene-Nov, "del periodo" = Dic.
5. Validación cruzada con declaración SAT real (si owner tiene una a la mano):
confirmar que los números del desglose coincidan con la declaración formato 14.
## Pendientes derivados
- Considerar agregar **un endpoint `getIsrMensualConAcumulados`** que retorne
los acumulados pre-computados, en vez de exponerlos como campos extra del
endpoint actual. Reduciría payload si solo se necesita una vista.
- Si el cache de `metricas_mensuales` empieza a usarse para ISR (hoy solo
es para IVA), repetir la fix del acumulado al consumir el cache.
- **Recompute opcional**: el bug actual ya no es visible (eliminamos la BG
mensual) pero la fila de cálculo del periodo SÍ depende de queries en vivo.
No hay cache que invalidar — el fix es inmediato al deploy.

View File

@@ -0,0 +1,129 @@
# Límite de 5 RFCs durante trial gratuito
## Contexto
Despachos en periodo de prueba (30 días) pueden agregar RFCs sin restricción.
El owner pidió un límite duro de 5 RFCs durante trial — para forzar al
contador a contratar un plan si necesita gestionar más.
## Reglas
| Estado | Límite RFCs |
|---|---|
| Trial activo (`tenant.trialEndsAt > now`) | **5 contribuyentes activos** (boundary: 5 OK, 6 bloqueado) |
| Trial expirado | Aplica el límite del plan vigente; este spec no agrega nada nuevo |
| Plan pagado (sin trial activo) | Sin nuevo límite (los del plan ya existen y son out of scope) |
## Cambios — Backend
### `apps/api/src/controllers/contribuyente.controller.ts`
Constante local al archivo:
```ts
const TRIAL_MAX_CONTRIBUYENTES = 5;
```
En el handler `create`, antes del `createContribuyente`:
```ts
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.`,
));
}
}
```
Imports: agregar `prisma` desde `../config/database.js` (ya está disponible
en otros controllers).
## Cambios — Frontend
### `apps/web/app/(dashboard)/contribuyentes/page.tsx`
Fetch del plan info (sigue patrón existente en `planes-despacho/page.tsx`):
```ts
const { data: planInfo } = useQuery({
queryKey: ['my-plan-info'],
queryFn: () => apiClient.get<{ isTrialActive: boolean }>('/despachos/me/plan').then(r => r.data),
});
const isTrialActive = planInfo?.isTrialActive ?? false;
const activeCount = (contribuyentes ?? []).filter(c => c.active !== false).length;
const trialAtLimit = isTrialActive && activeCount >= 5;
```
Modificar los 2 botones "Agregar RFC" (línea 70 y 78) para reflejar el estado:
```tsx
<Button
onClick={() => { resetForm(); setShowDialog(true); }}
disabled={trialAtLimit}
title={trialAtLimit ? 'Límite de contribuyentes para la prueba gratuita, para continuar agregando contribuyentes, selecciona un plan.' : undefined}
className="flex items-center gap-2"
>
<Plus className="h-4 w-4" /> Agregar RFC
</Button>
```
(Mismo patrón en el botón "Agregar primer RFC" — aunque cuando `activeCount === 0`
el `trialAtLimit` es `false`, así que ese botón nunca se deshabilita. Aún así,
aplico el atributo `disabled={trialAtLimit}` por consistencia defensiva.)
Mensaje del tooltip (literal del owner):
"Límite de contribuyentes para la prueba gratuita, para continuar agregando
contribuyentes, selecciona un plan."
## No-cambios
- Schema BD.
- Cron de trial (`expireTrials`).
- Mi Empresa hard limit a 1 RFC (sigue siendo solo billing-only,
fuera de scope).
- `tenant.cfdiLimit`, `tenant.usersLimit` — no se tocan.
## Riesgos
- **Race condition**: si dos creaciones concurrentes ven `count=4` y ambas
pasan, podríamos terminar con 6. Improbable en flujo manual UI; no se
mitiga (costo > beneficio).
- **Trial → paid mid-creación**: si el contador paga mientras está en 5
RFCs, el `trialEndsAt` no se modifica (sigue en futuro), pero la
subscription ahora tiene status `authorized`. Per la lógica actual,
el trial sigue "activo" hasta que `trialEndsAt < now`. El usuario
pagado seguirá viendo el límite de 5 hasta que expire el trial. **Aceptable**:
el owner gana dinero adicional el día que el contador convierte, no antes.
Si se quiere lift inmediato, modificar la lógica de `isTrialActive`
para excluir trials pagados — out of scope para este spec.
## Plan de pruebas
1. `pnpm typecheck` shared + api + web targeted: PASS.
2. Tenant en trial con 4 contribuyentes activos:
- UI: botón "Agregar RFC" habilitado.
- API: `POST /api/contribuyentes` con datos válidos retorna 201.
3. Tenant en trial con 5 contribuyentes activos:
- UI: botón "Agregar RFC" deshabilitado, tooltip visible al hover.
- API: `POST /api/contribuyentes` retorna 403 con el mensaje del spec.
4. Tenant trial expirado con 5 contribuyentes:
- UI: botón habilitado.
- API: 201 (puede crear el 6º — sin límite trial).
5. Tenant pagado (Business Control) con 5 contribuyentes:
- UI: botón habilitado.
- API: 201.
## Implementación
~15 líneas backend + ~8 líneas frontend. Cambio chico, una commit en
Downloads + V.1.0.11 en OneDrive.