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

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