Initial commit: Horux Despachos project
This commit is contained in:
2252
docs/superpowers/plans/2026-03-15-saas-transformation.md
Normal file
2252
docs/superpowers/plans/2026-03-15-saas-transformation.md
Normal file
File diff suppressed because it is too large
Load Diff
895
docs/superpowers/plans/2026-04-12-conciliacion-implementation.md
Normal file
895
docs/superpowers/plans/2026-04-12-conciliacion-implementation.md
Normal 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
|
||||
```
|
||||
1176
docs/superpowers/plans/2026-04-13-opinion-cumplimiento.md
Normal file
1176
docs/superpowers/plans/2026-04-13-opinion-cumplimiento.md
Normal file
File diff suppressed because it is too large
Load Diff
680
docs/superpowers/plans/2026-04-13-tenant-migrations.md
Normal file
680
docs/superpowers/plans/2026-04-13-tenant-migrations.md
Normal 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"
|
||||
```
|
||||
2455
docs/superpowers/plans/2026-04-16-refactor-monorepo-packages.md
Normal file
2455
docs/superpowers/plans/2026-04-16-refactor-monorepo-packages.md
Normal file
File diff suppressed because it is too large
Load Diff
772
docs/superpowers/plans/2026-04-17-plan2a-schema-auth.md
Normal file
772
docs/superpowers/plans/2026-04-17-plan2a-schema-auth.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
789
docs/superpowers/plans/2026-04-17-plan2c-frontend-despachos.md
Normal file
789
docs/superpowers/plans/2026-04-17-plan2c-frontend-despachos.md
Normal 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)
|
||||
17
docs/superpowers/plans/2026-04-17-plan3-roles-carteras.md
Normal file
17
docs/superpowers/plans/2026-04-17-plan3-roles-carteras.md
Normal 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
|
||||
Reference in New Issue
Block a user