Initial commit: Horux Despachos project

This commit is contained in:
consultoria-as
2026-04-27 01:11:06 -06:00
commit 56a05ba767
604 changed files with 121723 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,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