Update: nueva version Horux Despachos
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
|
||||
@@ -0,0 +1,946 @@
|
||||
# Filtros "Considerar activos" y "Considerar NCs" — Fase 1 — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Agregar 2 toggles en `/impuestos` ("Considerar activos" y "Considerar NCs") que cuando están OFF (default) excluyen del cálculo de IVA/ISR las facturas tipo I con uso I01-I08 y las facturas tipo E con cfdi_tipo_relacion=01 respectivamente.
|
||||
|
||||
**Architecture:** Frontend agrega 2 booleanos al state de la página de impuestos y los propaga como query params hasta el backend. Backend aplica un fragmento WHERE adicional (helper en módulo neutral `_shared/cfdi-filters.ts`) a todas las queries que escanean `cfdis` dentro del path de impuestos. Funciones compartidas con dashboard (`calcular*PorRegimen`) reciben los flags como params opcionales con default `true` (= include todo) para preservar el comportamiento del dashboard. Cache `metricas_mensuales` queda intacto pero su gate se extiende para fall-through cuando los toggles están OFF; el cache se actualizará en Fase 2 con un schema base+deltas.
|
||||
|
||||
**Tech Stack:** Express + TypeScript en API, Next.js 14 + React Query en web, types compartidos en `@horux/shared`. Verificación vía `pnpm typecheck` (no unit tests para esta área per el patrón del repo).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-27-filtros-activos-ncs-impuestos-fase1-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### Files to create
|
||||
|
||||
```
|
||||
apps/api/src/services/_shared/cfdi-filters.ts
|
||||
└── Helper buildExtraFilters + buildExtraFiltersAlias (módulo neutral)
|
||||
```
|
||||
|
||||
### Files to modify
|
||||
|
||||
```
|
||||
apps/api/src/services/dashboard.service.ts
|
||||
└── calcularIngresosPorRegimen + calcularEgresosPorRegimen: agregar 2 params booleanos default true, aplicar buildExtraFilters al WHERE de TODAS las queries internas
|
||||
|
||||
apps/api/src/services/impuestos.service.ts
|
||||
└── getResumenIva + getIvaMensual: nuevos params + aplicar filtro al WHERE
|
||||
└── getResumenIsr + getIsrMensual + getResumenIsrDesglosado: nuevos params + propagar a calcular*PorRegimen
|
||||
└── Cache gate de getResumenIva: extender condición para bypass cuando flags ≠ default backend
|
||||
└── Subqueries con alias `e` (rama I PPD/07): aplicar buildExtraFiltersAlias
|
||||
|
||||
apps/api/src/controllers/impuestos.controller.ts
|
||||
└── Helper parseFlag + 5 handlers parsean los 2 query params nuevos
|
||||
|
||||
apps/web/lib/api/impuestos.ts
|
||||
└── 5 funciones HTTP extendidas con 2 params nuevos
|
||||
|
||||
apps/web/lib/hooks/use-impuestos.ts
|
||||
└── 5 hooks extendidos con 2 params nuevos (incluir en queryKey)
|
||||
|
||||
apps/web/app/(dashboard)/impuestos/page.tsx
|
||||
└── 2 useState nuevos + 2 toggle buttons + propagación a hooks
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Crear módulo helper compartido
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/api/src/services/_shared/cfdi-filters.ts`
|
||||
|
||||
- [ ] **Step 1: Crear directorio si no existe**
|
||||
|
||||
```bash
|
||||
mkdir -p "C:/Users/chtr1/Downloads/Horux_despacho/apps/api/src/services/_shared"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Escribir el módulo**
|
||||
|
||||
Crear `apps/api/src/services/_shared/cfdi-filters.ts` con el contenido completo:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Helpers para construir fragmentos AND adicionales en WHERE clauses según
|
||||
* los toggles "Considerar activos" y "Considerar NCs" de la UI de impuestos.
|
||||
*
|
||||
* - considerarActivos === false → excluir facturas tipo I con uso de CFDI I01-I08.
|
||||
* - considerarNCs === false → excluir facturas tipo E con cfdi_tipo_relacion = '01'.
|
||||
*
|
||||
* Cuando ambos son true (default backend = "include todo"), retorna string
|
||||
* vacío. Esto preserva el comportamiento histórico para callers que no pasan
|
||||
* los flags (ej. dashboard, reportes).
|
||||
*
|
||||
* Las versiones `Alias` se usan en subqueries con alias de tabla
|
||||
* (ej. `cfdis e` en SUM_E_REFERENCING_*). Para activos el filtro es no-op
|
||||
* en esos subqueries (porque escanean type E), pero el filtro de NCs sí
|
||||
* aplica.
|
||||
*/
|
||||
|
||||
const ACTIVOS_USOS = "('I01','I02','I03','I04','I05','I06','I07','I08')";
|
||||
|
||||
export function buildExtraFilters(
|
||||
considerarActivos: boolean,
|
||||
considerarNCs: boolean,
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
if (!considerarActivos) {
|
||||
parts.push(`AND NOT (tipo_comprobante = 'I' AND uso_cfdi IN ${ACTIVOS_USOS})`);
|
||||
}
|
||||
if (!considerarNCs) {
|
||||
parts.push(`AND NOT (tipo_comprobante = 'E' AND COALESCE(cfdi_tipo_relacion, '') = '01')`);
|
||||
}
|
||||
return parts.length > 0 ? ' ' + parts.join(' ') : '';
|
||||
}
|
||||
|
||||
export function buildExtraFiltersAlias(
|
||||
alias: string,
|
||||
considerarActivos: boolean,
|
||||
considerarNCs: boolean,
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
if (!considerarActivos) {
|
||||
parts.push(`AND NOT (${alias}.tipo_comprobante = 'I' AND ${alias}.uso_cfdi IN ${ACTIVOS_USOS})`);
|
||||
}
|
||||
if (!considerarNCs) {
|
||||
parts.push(`AND NOT (${alias}.tipo_comprobante = 'E' AND COALESCE(${alias}.cfdi_tipo_relacion, '') = '01')`);
|
||||
}
|
||||
return parts.length > 0 ? ' ' + parts.join(' ') : '';
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verificar typecheck del API**
|
||||
|
||||
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/api typecheck`
|
||||
Expected: PASS sin errores.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd C:/Users/chtr1/Downloads/Horux_despacho
|
||||
git add apps/api/src/services/_shared/cfdi-filters.ts
|
||||
git commit -m "feat(api): helper buildExtraFilters para toggles activos/NCs"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Extender `calcularIngresosPorRegimen` y `calcularEgresosPorRegimen` en dashboard.service.ts
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/api/src/services/dashboard.service.ts`
|
||||
|
||||
**Heads up:** Dashboard también consume estas funciones. Default `true` en los nuevos params preserva su comportamiento.
|
||||
|
||||
- [ ] **Step 1: Agregar import del helper al inicio del archivo**
|
||||
|
||||
Encontrar la sección de imports al inicio de `dashboard.service.ts` y agregar:
|
||||
|
||||
```ts
|
||||
import { buildExtraFilters } from './_shared/cfdi-filters.js';
|
||||
```
|
||||
|
||||
(Las imports en este proyecto usan extensión `.js` aunque el archivo sea `.ts` — patrón ESM con tsx. Revisa imports existentes para confirmar el estilo.)
|
||||
|
||||
- [ ] **Step 2: Extender la signature de `calcularIngresosPorRegimen`**
|
||||
|
||||
Buscar la función exportada `calcularIngresosPorRegimen`. Agregar 2 parámetros opcionales con default `true` al final de la lista, antes del cierre de `)`:
|
||||
|
||||
Cambiar la signature para incluir:
|
||||
|
||||
```ts
|
||||
export async function calcularIngresosPorRegimen(
|
||||
pool: Pool,
|
||||
tenantId: string,
|
||||
fechaInicio: string,
|
||||
fechaFin: string,
|
||||
// ...parámetros existentes preservados...
|
||||
conciliacion?: boolean,
|
||||
contribuyenteId?: string | null,
|
||||
considerarActivos: boolean = true, // nuevo
|
||||
considerarNCs: boolean = true, // nuevo
|
||||
): Promise<...>
|
||||
```
|
||||
|
||||
(Mantener los nombres y orden de los parámetros existentes. Solo agregar los 2 nuevos al final.)
|
||||
|
||||
- [ ] **Step 3: Aplicar el filtro a TODAS las queries internas de calcularIngresosPorRegimen**
|
||||
|
||||
Dentro del cuerpo de la función, antes de las queries SQL, computar el fragmento:
|
||||
|
||||
```ts
|
||||
const extra = buildExtraFilters(considerarActivos, considerarNCs);
|
||||
```
|
||||
|
||||
Luego, en cada query SQL que escanee `cfdis`, agregar `${extra}` al final del WHERE clause. Buscar todos los `FROM cfdis` dentro del cuerpo de la función — deben ser ~3-5 queries — y a cada uno agregarle el fragmento.
|
||||
|
||||
Ejemplo de transformación:
|
||||
|
||||
```ts
|
||||
// Antes:
|
||||
const { rows } = await pool.query(`
|
||||
SELECT ...
|
||||
FROM cfdis
|
||||
WHERE ${VIGENTE} AND ${FR}
|
||||
AND ${ctx.esEmisor}
|
||||
GROUP BY ...
|
||||
`, [fechaInicio, fechaFin]);
|
||||
|
||||
// Después:
|
||||
const { rows } = await pool.query(`
|
||||
SELECT ...
|
||||
FROM cfdis
|
||||
WHERE ${VIGENTE} AND ${FR}${extra}
|
||||
AND ${ctx.esEmisor}
|
||||
GROUP BY ...
|
||||
`, [fechaInicio, fechaFin]);
|
||||
```
|
||||
|
||||
`extra` retorna con leading space cuando agrega contenido. Si ambos flags son `true` retorna string vacío y la query queda idéntica.
|
||||
|
||||
- [ ] **Step 4: Repetir para `calcularEgresosPorRegimen`**
|
||||
|
||||
Misma extensión de signature (2 params al final con default `true`), mismo helper `extra = buildExtraFilters(...)`, misma aplicación a todos los `FROM cfdis` del cuerpo.
|
||||
|
||||
- [ ] **Step 5: Verificar typecheck del API**
|
||||
|
||||
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/api typecheck`
|
||||
Expected: PASS sin errores. Cualquier callsite existente de estas funciones que no pase los nuevos params usa los defaults `true`, comportamiento idéntico a antes.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
cd C:/Users/chtr1/Downloads/Horux_despacho
|
||||
git add apps/api/src/services/dashboard.service.ts
|
||||
git commit -m "feat(api): calcular*PorRegimen aceptan flags considerarActivos/considerarNCs"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Extender `getResumenIva` y `getIvaMensual` en impuestos.service.ts
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/api/src/services/impuestos.service.ts`
|
||||
|
||||
- [ ] **Step 1: Agregar import del helper al inicio del archivo**
|
||||
|
||||
Buscar la sección de imports del archivo. Agregar:
|
||||
|
||||
```ts
|
||||
import { buildExtraFilters } from './_shared/cfdi-filters.js';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Extender signature de `getResumenIva`**
|
||||
|
||||
Encontrar `export async function getResumenIva(...)`. Agregar 2 params al final con default `true`:
|
||||
|
||||
```ts
|
||||
export async function getResumenIva(
|
||||
pool: Pool,
|
||||
fechaInicio: string,
|
||||
fechaFin: string,
|
||||
tenantId: string,
|
||||
conciliacion?: boolean,
|
||||
contribuyenteId?: string | null,
|
||||
considerarActivos: boolean = true,
|
||||
considerarNCs: boolean = true,
|
||||
): Promise<ResumenIva>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Computar `extra` y aplicar a todas las queries internas**
|
||||
|
||||
Dentro del body, después de `const FR = getFR(conciliacion);` agregar:
|
||||
|
||||
```ts
|
||||
const extra = buildExtraFilters(considerarActivos, considerarNCs);
|
||||
```
|
||||
|
||||
Y aplicar `${extra}` al final de cada WHERE en queries con `FROM cfdis` (las que NO usan alias `e` — esas son Task 5). Aplica el mismo patrón del Task 2 Step 3.
|
||||
|
||||
- [ ] **Step 4: Extender el cache gate de getResumenIva**
|
||||
|
||||
Buscar la condición que protege el path de cache (alrededor de línea 322 según la versión actual del archivo, puede haber cambiado por WIP). El patrón es:
|
||||
|
||||
```ts
|
||||
if (
|
||||
!conciliacion &&
|
||||
contribuyenteId &&
|
||||
...condiciones existentes...
|
||||
) {
|
||||
const cached = await readResumenIvaFromCache(...);
|
||||
if (cached) return cached;
|
||||
}
|
||||
```
|
||||
|
||||
Extender:
|
||||
|
||||
```ts
|
||||
if (
|
||||
!conciliacion &&
|
||||
considerarActivos && // nuevo: cache solo aplica con backend default (todo incluido)
|
||||
considerarNCs && // nuevo
|
||||
contribuyenteId &&
|
||||
...condiciones existentes...
|
||||
) {
|
||||
const cached = await readResumenIvaFromCache(...);
|
||||
if (cached) return cached;
|
||||
}
|
||||
```
|
||||
|
||||
Cuando UI tiene los toggles OFF (default), `considerarActivos===false || considerarNCs===false` → cache bypass → live query. Aceptado para Fase 1.
|
||||
|
||||
- [ ] **Step 5: Extender signature de `getIvaMensual`**
|
||||
|
||||
Misma extensión: agregar 2 params al final con default `true`. Agregar `const extra = buildExtraFilters(...)` y aplicar a todas las queries con `FROM cfdis` dentro del loop mensual.
|
||||
|
||||
- [ ] **Step 6: Verificar typecheck del API**
|
||||
|
||||
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/api typecheck`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
cd C:/Users/chtr1/Downloads/Horux_despacho
|
||||
git add apps/api/src/services/impuestos.service.ts
|
||||
git commit -m "feat(api): getResumenIva y getIvaMensual aceptan flags considerarActivos/considerarNCs + cache gate"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Extender `getResumenIsr`, `getIsrMensual`, `getResumenIsrDesglosado`
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/api/src/services/impuestos.service.ts`
|
||||
|
||||
- [ ] **Step 1: Extender signature de `getResumenIsr`**
|
||||
|
||||
Agregar 2 params al final con default `true`:
|
||||
|
||||
```ts
|
||||
export async function getResumenIsr(
|
||||
pool: Pool,
|
||||
fechaInicio: string,
|
||||
fechaFin: string,
|
||||
tenantId: string,
|
||||
conciliacion?: boolean,
|
||||
contribuyenteId?: string | null,
|
||||
considerarActivos: boolean = true,
|
||||
considerarNCs: boolean = true,
|
||||
): Promise<ResumenIsr>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Propagar a llamadas a `calcular*PorRegimen` y a queries internas**
|
||||
|
||||
Dentro de `getResumenIsr`:
|
||||
- Agregar `const extra = buildExtraFilters(considerarActivos, considerarNCs);` al inicio del cuerpo (después del `getFR`).
|
||||
- Aplicar `${extra}` a TODOS los `FROM cfdis` internos de la función (sin alias).
|
||||
- En las llamadas existentes `calcularIngresosPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId)` agregar al final los 2 nuevos args:
|
||||
|
||||
```ts
|
||||
const ingresosData = await calcularIngresosPorRegimen(
|
||||
pool, tenantId, fechaInicio, fechaFin,
|
||||
undefined, undefined, conciliacion, contribuyenteId,
|
||||
considerarActivos, considerarNCs, // nuevos
|
||||
);
|
||||
```
|
||||
|
||||
Idem para `calcularEgresosPorRegimen`.
|
||||
|
||||
- [ ] **Step 3: Extender signature de `getIsrMensual`**
|
||||
|
||||
Agregar 2 params al final con default `true`:
|
||||
|
||||
```ts
|
||||
export async function getIsrMensual(
|
||||
pool: Pool,
|
||||
año: number,
|
||||
tenantId: string,
|
||||
conciliacion?: boolean,
|
||||
contribuyenteId?: string | null,
|
||||
regimenClave?: string | null,
|
||||
considerarActivos: boolean = true,
|
||||
considerarNCs: boolean = true,
|
||||
): Promise<IsrMensual[]>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Propagar dentro de `getIsrMensual`**
|
||||
|
||||
Dentro del loop mensual de `getIsrMensual`, las llamadas existentes a `calcularIngresosPorRegimen` y `calcularEgresosPorRegimen` deben recibir los 2 nuevos args al final. Patrón:
|
||||
|
||||
```ts
|
||||
const [ingresosData, egresosData] = await Promise.all([
|
||||
calcularIngresosPorRegimen(
|
||||
pool, tenantId, fi, ff,
|
||||
undefined, undefined, conciliacion, contribuyenteId,
|
||||
considerarActivos, considerarNCs, // nuevos
|
||||
),
|
||||
calcularEgresosPorRegimen(
|
||||
pool, tenantId, fi, ff,
|
||||
undefined, undefined, conciliacion, contribuyenteId,
|
||||
considerarActivos, considerarNCs, // nuevos
|
||||
),
|
||||
]);
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Extender signature de `getResumenIsrDesglosado`**
|
||||
|
||||
Agregar 2 params al final con default `true`:
|
||||
|
||||
```ts
|
||||
export async function getResumenIsrDesglosado(
|
||||
pool: Pool,
|
||||
fechaFin: string,
|
||||
tenantId: string,
|
||||
conciliacion?: boolean,
|
||||
contribuyenteId?: string | null,
|
||||
considerarActivos: boolean = true,
|
||||
considerarNCs: boolean = true,
|
||||
): Promise<import('@horux/shared').ResumenIsrDesglosado>
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Propagar dentro de `getResumenIsrDesglosado`**
|
||||
|
||||
Las 3 llamadas a `getResumenIsr` (una secuencial para `anteriores` cuando mesFinal !== 1, dos en `Promise.all` para `delPeriodo` y `total`) deben pasar los 2 nuevos args al final:
|
||||
|
||||
```ts
|
||||
anteriores = await getResumenIsr(
|
||||
pool, fiAnt, ffAnt, tenantId, conciliacion, contribuyenteId,
|
||||
considerarActivos, considerarNCs, // nuevos
|
||||
);
|
||||
|
||||
const [delPeriodo, total] = await Promise.all([
|
||||
getResumenIsr(pool, fiPeriodo, ffPeriodo, tenantId, conciliacion, contribuyenteId, considerarActivos, considerarNCs),
|
||||
getResumenIsr(pool, fiTotal, ffTotal, tenantId, conciliacion, contribuyenteId, considerarActivos, considerarNCs),
|
||||
]);
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Verificar typecheck del API**
|
||||
|
||||
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/api typecheck`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
cd C:/Users/chtr1/Downloads/Horux_despacho
|
||||
git add apps/api/src/services/impuestos.service.ts
|
||||
git commit -m "feat(api): getResumenIsr/getIsrMensual/getResumenIsrDesglosado aceptan flags considerarActivos/considerarNCs"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Aplicar filtros a subqueries con alias `e` (rama I PPD/07)
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/api/src/services/impuestos.service.ts`
|
||||
|
||||
**Context:** En la rama I PPD/07 hay subqueries que iteran sobre `cfdis e` (alias) para detectar E que referencian I PPD/07. Estos subqueries pueden ser constants templates (`SUM_E_REFERENCING_TRAS`, `SUM_E_REFERENCING_RET`, `HAS_E_REFERENCING_MISMO_MES`) o expresiones inline. Necesitan el filtro `buildExtraFiltersAlias('e', ...)`.
|
||||
|
||||
- [ ] **Step 1: Importar `buildExtraFiltersAlias`**
|
||||
|
||||
Verificar que el import al inicio del archivo incluya ambas:
|
||||
|
||||
```ts
|
||||
import { buildExtraFilters, buildExtraFiltersAlias } from './_shared/cfdi-filters.js';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Identificar y modificar las constantes/templates de subqueries con alias `e`**
|
||||
|
||||
Buscar `cfdis e` en `impuestos.service.ts`. Deberían aparecer en constantes como `SUM_E_REFERENCING_TRAS = (esLadoE: string) => \`...\`` y similares.
|
||||
|
||||
**Decisión arquitectónica**: estas constantes son templates funcionales. La forma más limpia es **convertirlas a funciones que reciben los flags** y los aplican.
|
||||
|
||||
Buscar las constantes existentes (típicamente templates string functions) y convertirlas. Ejemplo (la firma exacta existente puede variar; la idea es agregar los 2 params al final):
|
||||
|
||||
Si encuentras (formato actual aproximado):
|
||||
|
||||
```ts
|
||||
const SUM_E_REFERENCING_TRAS = (esLadoE: string) => `COALESCE((
|
||||
SELECT SUM(${IVA_TRAS_EXPR_ALIAS('e')})
|
||||
FROM cfdis e
|
||||
WHERE e.status NOT IN ('Cancelado', '0')
|
||||
AND ${esLadoE}
|
||||
AND ...resto del where...
|
||||
), 0)`;
|
||||
```
|
||||
|
||||
Cambiar a:
|
||||
|
||||
```ts
|
||||
const SUM_E_REFERENCING_TRAS = (esLadoE: string, considerarActivos: boolean, considerarNCs: boolean) => `COALESCE((
|
||||
SELECT SUM(${IVA_TRAS_EXPR_ALIAS('e')})
|
||||
FROM cfdis e
|
||||
WHERE e.status NOT IN ('Cancelado', '0')
|
||||
AND ${esLadoE}
|
||||
AND ...resto del where...${buildExtraFiltersAlias('e', considerarActivos, considerarNCs)}
|
||||
), 0)`;
|
||||
```
|
||||
|
||||
Aplicar el mismo patrón a las demás subqueries con alias `e`:
|
||||
- `SUM_E_REFERENCING_TRAS`
|
||||
- `SUM_E_REFERENCING_RET`
|
||||
- `HAS_E_REFERENCING_MISMO_MES`
|
||||
- Cualquier otra que use `cfdis e`
|
||||
|
||||
- [ ] **Step 3: Actualizar callsites de las subqueries**
|
||||
|
||||
Buscar dónde se usan estas funciones (ej. dentro de `getResumenIva`, `getResumenIsr`, sus helpers `bucketCausadoNeg`, `bucketAcreditableNeg`, etc.) y agregar los nuevos params:
|
||||
|
||||
```ts
|
||||
// Antes:
|
||||
SUM_E_REFERENCING_TRAS(esLado)
|
||||
|
||||
// Después:
|
||||
SUM_E_REFERENCING_TRAS(esLado, considerarActivos, considerarNCs)
|
||||
```
|
||||
|
||||
Los callsites están dentro de funciones que ya recibieron los flags en Tasks 3 y 4. Solo es propagación local.
|
||||
|
||||
- [ ] **Step 4: Verificar typecheck del API**
|
||||
|
||||
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/api typecheck`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd C:/Users/chtr1/Downloads/Horux_despacho
|
||||
git add apps/api/src/services/impuestos.service.ts
|
||||
git commit -m "feat(api): subqueries con alias 'e' (I PPD/07) respetan flags considerarActivos/considerarNCs"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Controllers — `parseFlag` helper + propagación
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/api/src/controllers/impuestos.controller.ts`
|
||||
|
||||
- [ ] **Step 1: Agregar helper `parseFlag` cerca del top del archivo**
|
||||
|
||||
Después del helper `parseConciliacion(req)` existente, agregar:
|
||||
|
||||
```ts
|
||||
function parseFlag(req: Request, key: string, defaultValue = true): boolean {
|
||||
const v = req.query[key];
|
||||
if (v === undefined || v === null) return defaultValue;
|
||||
return v === 'true' || v === '1';
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Extender los 5 handlers**
|
||||
|
||||
Para cada uno de los 5 handlers (`getIvaMensual`, `getIsrMensual`, `getResumenIva`, `getResumenIsr`, `getResumenIsrDesglosado`):
|
||||
|
||||
1. Agregar las 2 lecturas de query params después de las existentes:
|
||||
|
||||
```ts
|
||||
const considerarActivos = parseFlag(req, 'considerarActivos', true);
|
||||
const considerarNCs = parseFlag(req, 'considerarNCs', true);
|
||||
```
|
||||
|
||||
2. Pasar al service como los 2 últimos args.
|
||||
|
||||
Ejemplo para `getResumenIsrDesglosado`:
|
||||
|
||||
```ts
|
||||
export async function getResumenIsrDesglosado(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
const now = new Date();
|
||||
const y = now.getFullYear();
|
||||
const m = now.getMonth() + 1;
|
||||
const lastDay = new Date(y, m, 0).getDate();
|
||||
const fechaFin = (req.query.fechaFin as string) || `${y}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
|
||||
const conciliacion = parseConciliacion(req);
|
||||
const contribuyenteId = (req.query.contribuyenteId as string) || null;
|
||||
const considerarActivos = parseFlag(req, 'considerarActivos', true); // nuevo
|
||||
const considerarNCs = parseFlag(req, 'considerarNCs', true); // nuevo
|
||||
|
||||
const desglose = await impuestosService.getResumenIsrDesglosado(
|
||||
req.tenantPool,
|
||||
fechaFin,
|
||||
effectiveTenantId(req),
|
||||
conciliacion,
|
||||
contribuyenteId,
|
||||
considerarActivos, // nuevo
|
||||
considerarNCs, // nuevo
|
||||
);
|
||||
res.json(desglose);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Aplicar el mismo patrón a los otros 4 handlers (`getIvaMensual`, `getIsrMensual`, `getResumenIva`, `getResumenIsr`).
|
||||
|
||||
- [ ] **Step 3: Verificar typecheck**
|
||||
|
||||
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/api typecheck`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd C:/Users/chtr1/Downloads/Horux_despacho
|
||||
git add apps/api/src/controllers/impuestos.controller.ts
|
||||
git commit -m "feat(api): controllers parsean flags considerarActivos/considerarNCs y los propagan al service"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Frontend API client
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/lib/api/impuestos.ts`
|
||||
|
||||
- [ ] **Step 1: Extender las 5 funciones HTTP**
|
||||
|
||||
Para cada función, agregar 2 params booleanos opcionales y serializarlos en `URLSearchParams`. Patrón:
|
||||
|
||||
```ts
|
||||
export async function getResumenIsrDesglosado(
|
||||
fechaFin: string,
|
||||
conciliacion?: boolean,
|
||||
considerarActivos?: boolean,
|
||||
considerarNCs?: boolean,
|
||||
contribuyenteId?: string | null,
|
||||
): Promise<ResumenIsrDesglosado> {
|
||||
const params = new URLSearchParams();
|
||||
params.set('fechaFin', fechaFin);
|
||||
if (conciliacion) params.set('conciliacion', 'true');
|
||||
if (considerarActivos) params.set('considerarActivos', 'true');
|
||||
if (considerarNCs) params.set('considerarNCs', 'true');
|
||||
if (contribuyenteId) params.set('contribuyenteId', contribuyenteId);
|
||||
const response = await apiClient.get<ResumenIsrDesglosado>(`/impuestos/isr/resumen-desglosado?${params}`);
|
||||
return response.data;
|
||||
}
|
||||
```
|
||||
|
||||
Aplicar el mismo patrón a:
|
||||
- `getIsrMensual(año, conciliacion, contribuyenteId, regimenClave, considerarActivos, considerarNCs)` — orden: insertar los 2 nuevos AL FINAL para no romper callers existentes que pasan posicionalmente.
|
||||
- `getIvaMensual(año, conciliacion, contribuyenteId, considerarActivos, considerarNCs)`
|
||||
- `getResumenIva(fechaInicio, fechaFin, conciliacion, contribuyenteId, considerarActivos, considerarNCs)`
|
||||
- `getResumenIsr(fechaInicio, fechaFin, conciliacion, contribuyenteId, considerarActivos, considerarNCs)`
|
||||
- `getResumenIsrDesglosado(fechaFin, conciliacion, considerarActivos, considerarNCs, contribuyenteId)` — la signature actual ya tiene `contribuyenteId` al final; mantenerlo allí.
|
||||
|
||||
**Importante**: solo set en URLSearchParams cuando el valor es `true`. Si el frontend pasa `undefined` o `false`, NO se manda el param (el backend default `true` aplica). Esto evita ambigüedad con la convención `'false'` string.
|
||||
|
||||
Espera — esta regla es la INVERSA de lo que queremos. Nuestro UI default es `false` (toggle OFF) y queremos QUE EL BACKEND EXCLUYA. Si el frontend NO manda el param cuando el toggle está OFF, el backend default `true` (include) aplica → no se excluye → COMPORTAMIENTO INCORRECTO.
|
||||
|
||||
Corrección: serializar el booleano explícitamente (siempre).
|
||||
|
||||
```ts
|
||||
if (considerarActivos !== undefined) params.set('considerarActivos', String(considerarActivos));
|
||||
if (considerarNCs !== undefined) params.set('considerarNCs', String(considerarNCs));
|
||||
```
|
||||
|
||||
Y en el controller (ya implementado en Task 6) `parseFlag` retorna `false` cuando `req.query.considerarActivos === 'false'`.
|
||||
|
||||
Verificar que el `parseFlag` del Task 6 maneja el string `'false'`:
|
||||
|
||||
```ts
|
||||
function parseFlag(req: Request, key: string, defaultValue = true): boolean {
|
||||
const v = req.query[key];
|
||||
if (v === undefined || v === null) return defaultValue;
|
||||
return v === 'true' || v === '1'; // cualquier otra cosa (ej. 'false', '0') → false
|
||||
}
|
||||
```
|
||||
|
||||
`v === 'true' || v === '1'` retorna `false` cuando `v === 'false'`. Correcto.
|
||||
|
||||
Aplicar a los 5 funciones:
|
||||
|
||||
```ts
|
||||
if (considerarActivos !== undefined) params.set('considerarActivos', String(considerarActivos));
|
||||
if (considerarNCs !== undefined) params.set('considerarNCs', String(considerarNCs));
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verificar typecheck del web**
|
||||
|
||||
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/web exec tsc --noEmit 2>&1 | grep "lib/api/impuestos"`
|
||||
Expected: NO output (clean — los errores pre-existentes en otros archivos del web no nos importan).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
cd C:/Users/chtr1/Downloads/Horux_despacho
|
||||
git add apps/web/lib/api/impuestos.ts
|
||||
git commit -m "feat(web): API client funciones aceptan considerarActivos/considerarNCs"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Frontend hooks
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/lib/hooks/use-impuestos.ts`
|
||||
|
||||
- [ ] **Step 1: Extender los 5 hooks con 2 params nuevos**
|
||||
|
||||
Para cada hook, agregar 2 params booleanos opcionales al final, incluirlos en `queryKey`, y pasarlos al API call. Patrón:
|
||||
|
||||
```ts
|
||||
export function useResumenIsrDesglosado(
|
||||
fechaFin: string,
|
||||
conciliacion?: boolean,
|
||||
considerarActivos?: boolean,
|
||||
considerarNCs?: boolean,
|
||||
) {
|
||||
const tk = useTenantKey();
|
||||
const { selectedContribuyenteId } = useContribuyenteStore();
|
||||
return useQuery({
|
||||
queryKey: ['isr-resumen-desglosado', tk, fechaFin, conciliacion, considerarActivos, considerarNCs, selectedContribuyenteId],
|
||||
queryFn: () => impuestosApi.getResumenIsrDesglosado(fechaFin, conciliacion, considerarActivos, considerarNCs, selectedContribuyenteId),
|
||||
enabled: !!fechaFin,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Aplicar a los 5 hooks: `useResumenIsrDesglosado`, `useResumenIsr`, `useResumenIva`, `useIsrMensual`, `useIvaMensual`.
|
||||
|
||||
Para `useIsrMensual` que ya tiene `regimenClave` opcional, mantener ese param y agregar los 2 nuevos al final:
|
||||
|
||||
```ts
|
||||
export function useIsrMensual(
|
||||
año?: number,
|
||||
conciliacion?: boolean,
|
||||
regimenClave?: string | null,
|
||||
considerarActivos?: boolean,
|
||||
considerarNCs?: boolean,
|
||||
)
|
||||
```
|
||||
|
||||
(Verificar el orden actual de params del hook — los nuevos van AL FINAL.)
|
||||
|
||||
- [ ] **Step 2: Verificar typecheck del web**
|
||||
|
||||
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/web exec tsc --noEmit 2>&1 | grep "lib/hooks/use-impuestos"`
|
||||
Expected: NO output.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
cd C:/Users/chtr1/Downloads/Horux_despacho
|
||||
git add apps/web/lib/hooks/use-impuestos.ts
|
||||
git commit -m "feat(web): hooks de impuestos aceptan considerarActivos/considerarNCs en queryKey"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Frontend UI — toggles + propagación
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/app/(dashboard)/impuestos/page.tsx`
|
||||
|
||||
- [ ] **Step 1: Agregar 2 useState al inicio del componente**
|
||||
|
||||
Buscar la sección de useState existente (cerca de líneas 30-40, donde está `useState(false)` para `conciliacion`). Agregar:
|
||||
|
||||
```ts
|
||||
const [considerarActivos, setConsiderarActivos] = useState(false);
|
||||
const [considerarNCs, setConsiderarNCs] = useState(false);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Pasar los 2 nuevos states a TODOS los hooks de impuestos**
|
||||
|
||||
Buscar cada llamada a hook y agregar los 2 args al final. Patrón:
|
||||
|
||||
```ts
|
||||
// Antes:
|
||||
const { data: resumenIsr } = useResumenIsr(fechaInicio, fechaFin, conciliacion);
|
||||
const { data: resumenIsrDesglose } = useResumenIsrDesglosado(fechaFin, conciliacion);
|
||||
|
||||
// Después:
|
||||
const { data: resumenIsr } = useResumenIsr(fechaInicio, fechaFin, conciliacion, considerarActivos, considerarNCs);
|
||||
const { data: resumenIsrDesglose } = useResumenIsrDesglosado(fechaFin, conciliacion, considerarActivos, considerarNCs);
|
||||
```
|
||||
|
||||
Aplicar a:
|
||||
- `useIvaMensual(año, conciliacion, considerarActivos, considerarNCs)`
|
||||
- `useIsrMensual(año, conciliacion, regimenSeleccionado, considerarActivos, considerarNCs)`
|
||||
- `useResumenIva(fechaInicio, fechaFin, conciliacion, considerarActivos, considerarNCs)`
|
||||
- `useResumenIsr(fechaInicio, fechaFin, conciliacion, considerarActivos, considerarNCs)`
|
||||
- `useResumenIsrDesglosado(fechaFin, conciliacion, considerarActivos, considerarNCs)`
|
||||
|
||||
- [ ] **Step 3: Agregar 2 toggle buttons al row de filtros**
|
||||
|
||||
Buscar el bloque del toggle de Conciliación (alrededor de líneas 92-103). Después del button de Conciliación y antes del cierre del `<div className="flex items-center gap-3">`, agregar:
|
||||
|
||||
```tsx
|
||||
<button
|
||||
onClick={() => setConsiderarActivos(!considerarActivos)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
||||
considerarActivos
|
||||
? 'bg-primary/10 text-primary border border-primary/30'
|
||||
: 'hover:bg-accent'
|
||||
)}
|
||||
title="Si está inactivo, no se consideran facturas tipo I con uso de CFDI I01-I08 (compras de activos fijos)."
|
||||
>
|
||||
<CheckSquare className="h-4 w-4" />
|
||||
Considerar activos
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConsiderarNCs(!considerarNCs)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
||||
considerarNCs
|
||||
? 'bg-primary/10 text-primary border border-primary/30'
|
||||
: 'hover:bg-accent'
|
||||
)}
|
||||
title="Si está inactivo, no se consideran facturas tipo E con tipo de relación 01 (notas de crédito)."
|
||||
>
|
||||
<CheckSquare className="h-4 w-4" />
|
||||
Considerar NCs
|
||||
</button>
|
||||
```
|
||||
|
||||
`CheckSquare` y `cn` ya están importados al inicio del archivo. NO agregues imports nuevos.
|
||||
|
||||
- [ ] **Step 4: Verificar typecheck del web**
|
||||
|
||||
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/web exec tsc --noEmit 2>&1 | grep "impuestos/page"`
|
||||
Expected: NO output.
|
||||
|
||||
- [ ] **Step 5: Smoke (opcional, defer si dev no corre)**
|
||||
|
||||
Si dev corre (`curl -s -o /dev/null -w "%{http_code}" http://localhost:3000 2>/dev/null` retorna algo distinto de 000):
|
||||
|
||||
1. Abrir `/impuestos`, pestaña ISR. Confirmar que aparecen 3 toggles: Conciliación, Considerar activos, Considerar NCs (todos OFF inicialmente).
|
||||
2. Tooltip al hover en cada toggle nuevo describe el filtro.
|
||||
3. Click "Considerar activos" → cambia a estilo activo (azul).
|
||||
4. Verificar que los números de la tabla y la sección "Cálculo de ISR del Periodo" recalculan al togglear.
|
||||
5. Smoke completo cross-feature en Task 10.
|
||||
|
||||
Si dev NO corre, **NO lo inicies**. Skip.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
cd C:/Users/chtr1/Downloads/Horux_despacho
|
||||
git add "apps/web/app/(dashboard)/impuestos/page.tsx"
|
||||
git commit -m "feat(web): toggles 'Considerar activos' y 'Considerar NCs' en /impuestos"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Verificación final + sync OneDrive + commit V.1.0.7
|
||||
|
||||
**Files:**
|
||||
- Verify: typecheck completo
|
||||
- Smoke: cross-feature en browser
|
||||
- Copy: 8 archivos a OneDrive (1 nuevo + 7 modificados)
|
||||
- Commit: V.1.0.7
|
||||
|
||||
- [ ] **Step 1: Typecheck completo de shared + api**
|
||||
|
||||
```bash
|
||||
cd C:/Users/chtr1/Downloads/Horux_despacho
|
||||
pnpm --filter @horux/shared typecheck
|
||||
pnpm --filter @horux/api typecheck
|
||||
```
|
||||
|
||||
Expected: ambos PASS sin errores. Si falla, **STOP y reporta**.
|
||||
|
||||
- [ ] **Step 2: Verificar archivos web del plan limpios**
|
||||
|
||||
```bash
|
||||
cd C:/Users/chtr1/Downloads/Horux_despacho
|
||||
pnpm --filter @horux/web exec tsc --noEmit 2>&1 | grep -E "(lib/api/impuestos|lib/hooks/use-impuestos|impuestos/page)"
|
||||
```
|
||||
|
||||
Expected: NO output (los 3 archivos web del plan están limpios; otros errores web son pre-existentes y fuera de scope).
|
||||
|
||||
- [ ] **Step 3: Smoke cross-feature**
|
||||
|
||||
Si dev corre y tienes acceso al browser:
|
||||
|
||||
1. **Default UI** (`/impuestos`, ambos toggles OFF):
|
||||
- ISR/IVA cargan números menores que antes (excluyen activos + NCs).
|
||||
- Tabla "Histórico ISR" usa los acumulados filtrados.
|
||||
- Sección "Cálculo de ISR del Periodo" refleja los filtros consistentemente en `delPeriodo`, `anteriores`, `total`.
|
||||
2. **Toggle "Considerar activos" ON**: ingresos/deducciones/base gravable suben con la suma de activos del periodo.
|
||||
3. **Toggle "Considerar NCs" ON**: cambia el bucket — NCs aparecen restando.
|
||||
4. **Combinaciones**: probar las 4 combinaciones de los 2 toggles + Conciliación on/off (8 total).
|
||||
5. **Cross-check `/dashboard`**: KPIs (ingresos, gastos, utilidad) **NO cambian** vs antes del deploy. Esto valida que el default `true` en `calcular*PorRegimen` preserva el dashboard.
|
||||
6. **Activos Fijos tab**: la tabla sigue mostrando todos los CFDIs I con uso I01-I08 (no afectada por el toggle "Considerar activos" en ISR/IVA).
|
||||
7. **Cambiar contribuyente**: el state de los toggles persiste en sesión (no se resetea al cambiar contribuyente).
|
||||
|
||||
Si no puedes hacer smoke completo, reporta qué se verificó y qué quedó pendiente para el owner.
|
||||
|
||||
- [ ] **Step 4: Copiar archivos a OneDrive (8 archivos: 1 nuevo + 7 modificados)**
|
||||
|
||||
```bash
|
||||
SRC="C:/Users/chtr1/Downloads/Horux_despacho"
|
||||
DST="C:/Users/chtr1/OneDrive/Documentos/GitHub/Horux_despachos"
|
||||
|
||||
# Crear carpeta _shared si no existe en OneDrive
|
||||
mkdir -p "$DST/apps/api/src/services/_shared"
|
||||
|
||||
cp -p "$SRC/apps/api/src/services/_shared/cfdi-filters.ts" "$DST/apps/api/src/services/_shared/cfdi-filters.ts"
|
||||
cp -p "$SRC/apps/api/src/services/dashboard.service.ts" "$DST/apps/api/src/services/dashboard.service.ts"
|
||||
cp -p "$SRC/apps/api/src/services/impuestos.service.ts" "$DST/apps/api/src/services/impuestos.service.ts"
|
||||
cp -p "$SRC/apps/api/src/controllers/impuestos.controller.ts" "$DST/apps/api/src/controllers/impuestos.controller.ts"
|
||||
cp -p "$SRC/apps/web/lib/api/impuestos.ts" "$DST/apps/web/lib/api/impuestos.ts"
|
||||
cp -p "$SRC/apps/web/lib/hooks/use-impuestos.ts" "$DST/apps/web/lib/hooks/use-impuestos.ts"
|
||||
cp -p "$SRC/apps/web/app/(dashboard)/impuestos/page.tsx" "$DST/apps/web/app/(dashboard)/impuestos/page.tsx"
|
||||
cp -p "$SRC/docs/superpowers/specs/2026-04-27-filtros-activos-ncs-impuestos-fase1-design.md" "$DST/docs/superpowers/specs/2026-04-27-filtros-activos-ncs-impuestos-fase1-design.md"
|
||||
cp -p "$SRC/docs/superpowers/plans/2026-04-27-filtros-activos-ncs-impuestos-fase1.md" "$DST/docs/superpowers/plans/2026-04-27-filtros-activos-ncs-impuestos-fase1.md"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verificar diff Downloads vs OneDrive**
|
||||
|
||||
```bash
|
||||
diff -rq \
|
||||
--exclude=node_modules --exclude=.git --exclude=.turbo --exclude=.next \
|
||||
--exclude=dist --exclude=tsconfig.tsbuildinfo --exclude=email-previews \
|
||||
--exclude=pnpm-lock.yaml --exclude=.env --exclude=.env.local \
|
||||
"C:/Users/chtr1/Downloads/Horux_despacho" \
|
||||
"C:/Users/chtr1/OneDrive/Documentos/GitHub/Horux_despachos"
|
||||
```
|
||||
|
||||
Expected: única diferencia esperada es `Only in C:/Users/chtr1/Downloads/Horux_despacho/apps/api: data` (XMLs runtime). Si aparece otra cosa, **STOP y reporta**.
|
||||
|
||||
- [ ] **Step 6: Commit en OneDrive**
|
||||
|
||||
```bash
|
||||
cd "C:/Users/chtr1/OneDrive/Documentos/GitHub/Horux_despachos"
|
||||
git status --short
|
||||
```
|
||||
|
||||
Confirma que aparezcan exactamente los archivos copiados como M (modified) o ?? (untracked). Si hay algo más, reporta.
|
||||
|
||||
```bash
|
||||
git add \
|
||||
apps/api/src/services/_shared/cfdi-filters.ts \
|
||||
apps/api/src/services/dashboard.service.ts \
|
||||
apps/api/src/services/impuestos.service.ts \
|
||||
apps/api/src/controllers/impuestos.controller.ts \
|
||||
apps/web/lib/api/impuestos.ts \
|
||||
apps/web/lib/hooks/use-impuestos.ts \
|
||||
"apps/web/app/(dashboard)/impuestos/page.tsx" \
|
||||
docs/superpowers/specs/2026-04-27-filtros-activos-ncs-impuestos-fase1-design.md \
|
||||
docs/superpowers/plans/2026-04-27-filtros-activos-ncs-impuestos-fase1.md
|
||||
|
||||
git commit -m "V.1.0.7"
|
||||
|
||||
git status --short
|
||||
git log -2 --oneline
|
||||
```
|
||||
|
||||
Expected:
|
||||
- Commit creado con hash nuevo, mensaje `V.1.0.7`.
|
||||
- Working tree clean.
|
||||
- `git log -2` muestra V.1.0.7 sobre V.1.0.6.
|
||||
|
||||
- [ ] **Step 7: NO push**
|
||||
|
||||
Push lo hace el owner manualmente. Confirmar explícitamente que NO se ejecutó `git push`.
|
||||
894
docs/superpowers/plans/2026-04-27-isr-base-gravable-acumulada.md
Normal file
894
docs/superpowers/plans/2026-04-27-isr-base-gravable-acumulada.md
Normal file
@@ -0,0 +1,894 @@
|
||||
# ISR — Base gravable acumulada y desglose del periodo — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Mostrar la base gravable y los acumulados de ISR correctamente en la pestaña ISR de `/impuestos`. La tabla histórica gana 3 columnas acumuladas (Ingresos, Deducciones, Base Gravable Acum.) y pierde la BG mensual incorrecta. La sección "Cálculo de ISR del Periodo" muestra el desglose `del periodo + anteriores = total acumulado` como en el formato 14 del SAT.
|
||||
|
||||
**Architecture:** Cambio puramente de cómputo + UI. Backend agrega running totals a `getIsrMensual` y un nuevo endpoint `/impuestos/resumen-isr-desglosado` que llama 3 veces a `getResumenIsr` (mes final, anteriores, total) y los devuelve juntos. Frontend modifica la tabla y reescribe el card de cálculo. Sin migraciones, sin cambio en la BD.
|
||||
|
||||
**Tech Stack:** Express + TypeScript en el API, Next.js 14 + React Query en el web, types compartidos en `@horux/shared`. Verificación vía `pnpm typecheck` (este proyecto no tiene unit tests para esta área — la disciplina es typecheck + smoke manual, ver `feedback_horux360_tscheck.md`).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-27-isr-base-gravable-acumulada-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### Files to modify
|
||||
|
||||
```
|
||||
packages/shared/src/types/impuestos.ts
|
||||
└── Extender IsrMensual con ingresosAcum, deduccionesAcum, baseGravableAcum
|
||||
└── Agregar interface ResumenIsrDesglosado
|
||||
|
||||
apps/api/src/services/impuestos.service.ts
|
||||
└── Modificar getIsrMensual (líneas 409-486): pase de running totals
|
||||
└── Agregar getResumenIsrDesglosado (función nueva exportada)
|
||||
|
||||
apps/api/src/controllers/impuestos.controller.ts
|
||||
└── Agregar handler getResumenIsrDesglosado
|
||||
|
||||
apps/api/src/routes/impuestos.routes.ts
|
||||
└── Agregar GET /isr/resumen-desglosado
|
||||
|
||||
apps/web/lib/api/impuestos.ts
|
||||
└── Agregar función getResumenIsrDesglosado (cliente HTTP)
|
||||
|
||||
apps/web/lib/hooks/use-impuestos.ts
|
||||
└── Agregar hook useResumenIsrDesglosado
|
||||
|
||||
apps/web/app/(dashboard)/impuestos/page.tsx
|
||||
└── Tabla Histórico ISR: 6 columnas, BG mensual fuera, BG_acum en rojo si negativa
|
||||
└── Sección "Cálculo de ISR del Periodo": rename + layout nuevo con desglose
|
||||
```
|
||||
|
||||
### Files NOT touched
|
||||
|
||||
- BD: ningún cambio de schema.
|
||||
- `metricas_mensuales` cache: sigue guardando mensuales puros.
|
||||
- KPIs de la parte alta de `/impuestos`: siguen mostrando rango filtrado completo.
|
||||
- IVA mensual: fuera de scope.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Extender shared types
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/shared/src/types/impuestos.ts`
|
||||
|
||||
- [ ] **Step 1: Agregar campos acumulados a `IsrMensual`**
|
||||
|
||||
Editar el interface existente (líneas 16-28):
|
||||
|
||||
```ts
|
||||
export interface IsrMensual {
|
||||
id: number;
|
||||
año: number;
|
||||
mes: number;
|
||||
ingresosAcumulados: number; // mensual — naming legacy, no se renombra en este spec
|
||||
deducciones: number; // mensual
|
||||
baseGravable: number; // mensual — sigue retornándose para no romper consumidores externos, pero ya no se muestra en la UI
|
||||
// Nuevos: running totals desde enero hasta el mes de esta fila
|
||||
ingresosAcum: number;
|
||||
deduccionesAcum: number;
|
||||
baseGravableAcum: number; // sin clamp; puede ser negativo
|
||||
isrCausado: number;
|
||||
isrRetenido: number;
|
||||
isrAPagar: number;
|
||||
estado: EstadoDeclaracion;
|
||||
fechaDeclaracion: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Agregar `ResumenIsrDesglosado` al final del archivo**
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Desglose del cálculo provisional ISR del mes final del filtro:
|
||||
* delPeriodo = solo el mes final del filtro (1 mes)
|
||||
* anteriores = enero hasta el mes anterior al final (puede estar vacío)
|
||||
* total = enero hasta el mes final inclusive
|
||||
*
|
||||
* Reglas:
|
||||
* - delPeriodo + anteriores = total para campos aditivos (ingresos, deducciones, retenciones).
|
||||
* - Para baseGravable e isrCausado el total se calcula sobre el rango entero
|
||||
* (no es la suma algebraica de delPeriodo + anteriores).
|
||||
* - baseGravable puede ser negativa en cualquiera de los tres rangos.
|
||||
* - isrCausado se clampa a 0 cuando la baseGravable acumulada es negativa.
|
||||
*/
|
||||
export interface ResumenIsrDesglosado {
|
||||
delPeriodo: ResumenIsr;
|
||||
anteriores: ResumenIsr;
|
||||
total: ResumenIsr;
|
||||
/** Mes final del filtro (1-12) */
|
||||
mesFinal: number;
|
||||
/** Año fiscal del filtro */
|
||||
anio: number;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verificar que el package compile**
|
||||
|
||||
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/shared typecheck`
|
||||
Expected: PASS sin errores.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd C:/Users/chtr1/Downloads/Horux_despacho
|
||||
git add packages/shared/src/types/impuestos.ts
|
||||
git commit -m "feat(shared): types para acumulados ISR mensual + desglose del periodo"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Backend — running totals en `getIsrMensual`
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/api/src/services/impuestos.service.ts:409-486`
|
||||
|
||||
- [ ] **Step 1: Modificar el push del result en el loop interno**
|
||||
|
||||
Encontrar el `result.push({ ... })` actual (alrededor de líneas 470-482) y agregar campos placeholder. Cambiar:
|
||||
|
||||
```ts
|
||||
result.push({
|
||||
id: 0,
|
||||
año,
|
||||
mes: m,
|
||||
ingresosAcumulados: ing,
|
||||
deducciones: ded,
|
||||
baseGravable: base,
|
||||
isrCausado: 0,
|
||||
isrRetenido: 0,
|
||||
isrAPagar: 0,
|
||||
estado: 'pendiente',
|
||||
fechaDeclaracion: null,
|
||||
});
|
||||
```
|
||||
|
||||
A:
|
||||
|
||||
```ts
|
||||
result.push({
|
||||
id: 0,
|
||||
año,
|
||||
mes: m,
|
||||
ingresosAcumulados: ing,
|
||||
deducciones: ded,
|
||||
baseGravable: base,
|
||||
ingresosAcum: 0, // se llena en el segundo pase abajo
|
||||
deduccionesAcum: 0,
|
||||
baseGravableAcum: 0,
|
||||
isrCausado: 0,
|
||||
isrRetenido: 0,
|
||||
isrAPagar: 0,
|
||||
estado: 'pendiente',
|
||||
fechaDeclaracion: null,
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Agregar segundo pase de running totals justo antes del `return result`**
|
||||
|
||||
Reemplazar `return result;` por:
|
||||
|
||||
```ts
|
||||
// Running totals: para cada mes, acumular ingresos y deducciones desde enero
|
||||
// hasta ese mes inclusive. baseGravableAcum NO se clampa — los déficits se
|
||||
// muestran negativos en la UI y solo se clampan al pasar a ISR causado.
|
||||
let ingAcum = 0;
|
||||
let dedAcum = 0;
|
||||
for (const row of result) {
|
||||
ingAcum += row.ingresosAcumulados; // (campo mensual, naming heredado)
|
||||
dedAcum += row.deducciones;
|
||||
row.ingresosAcum = ingAcum;
|
||||
row.deduccionesAcum = dedAcum;
|
||||
row.baseGravableAcum = ingAcum - dedAcum;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verificar typecheck del API**
|
||||
|
||||
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/api typecheck`
|
||||
Expected: PASS sin errores. Si falla por que `IsrMensual` requiere los campos nuevos, asegurar que Task 1 ya esté aplicada.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd C:/Users/chtr1/Downloads/Horux_despacho
|
||||
git add apps/api/src/services/impuestos.service.ts
|
||||
git commit -m "feat(api): getIsrMensual computa running totals (ingresos/deducciones/base gravable acumulada)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Backend — nueva función `getResumenIsrDesglosado`
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/api/src/services/impuestos.service.ts` (agregar al final, después de `getResumenIsr`)
|
||||
|
||||
- [ ] **Step 1: Agregar la función exportada**
|
||||
|
||||
Buscar el final de `getResumenIsr` (alrededor de línea 887) y después del `}` agregar:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Desglose del cálculo provisional ISR para el mes final del filtro.
|
||||
*
|
||||
* Tres llamadas a getResumenIsr con rangos distintos:
|
||||
* - delPeriodo: solo el mes final del filtro (1 mes calendario)
|
||||
* - anteriores: enero hasta el mes anterior al final (vacío si mesFinal=1)
|
||||
* - total: enero hasta el mes final inclusive
|
||||
*
|
||||
* Si mesFinal === 1, la rama "anteriores" no llama al backend — retorna ceros
|
||||
* para evitar un query inútil.
|
||||
*/
|
||||
export async function getResumenIsrDesglosado(
|
||||
pool: Pool,
|
||||
fechaFin: string,
|
||||
tenantId: string,
|
||||
conciliacion?: boolean,
|
||||
contribuyenteId?: string | null,
|
||||
): Promise<import('@horux/shared').ResumenIsrDesglosado> {
|
||||
const fechaFinDate = new Date(fechaFin + 'T00:00:00');
|
||||
const anio = fechaFinDate.getFullYear();
|
||||
const mesFinal = fechaFinDate.getMonth() + 1; // 1-12
|
||||
|
||||
// Helper para construir rango fin de mes
|
||||
const mmFinal = String(mesFinal).padStart(2, '0');
|
||||
const ultDiaFinal = new Date(anio, mesFinal, 0).getDate();
|
||||
const ultDiaFinalStr = String(ultDiaFinal).padStart(2, '0');
|
||||
|
||||
// delPeriodo: 1er a último día del mes final
|
||||
const fiPeriodo = `${anio}-${mmFinal}-01`;
|
||||
const ffPeriodo = `${anio}-${mmFinal}-${ultDiaFinalStr}`;
|
||||
|
||||
// anteriores: enero 1 al último día del (mesFinal - 1). Vacío si mesFinal=1.
|
||||
let anteriores: import('@horux/shared').ResumenIsr;
|
||||
if (mesFinal === 1) {
|
||||
anteriores = emptyResumenIsr();
|
||||
} else {
|
||||
const mesAntes = mesFinal - 1;
|
||||
const mmAntes = String(mesAntes).padStart(2, '0');
|
||||
const ultDiaAntes = new Date(anio, mesAntes, 0).getDate();
|
||||
const ultDiaAntesStr = String(ultDiaAntes).padStart(2, '0');
|
||||
const fiAnt = `${anio}-01-01`;
|
||||
const ffAnt = `${anio}-${mmAntes}-${ultDiaAntesStr}`;
|
||||
anteriores = await getResumenIsr(pool, fiAnt, ffAnt, tenantId, conciliacion, contribuyenteId);
|
||||
}
|
||||
|
||||
// total: enero 1 al último día del mes final
|
||||
const fiTotal = `${anio}-01-01`;
|
||||
const ffTotal = `${anio}-${mmFinal}-${ultDiaFinalStr}`;
|
||||
|
||||
const [delPeriodo, total] = await Promise.all([
|
||||
getResumenIsr(pool, fiPeriodo, ffPeriodo, tenantId, conciliacion, contribuyenteId),
|
||||
getResumenIsr(pool, fiTotal, ffTotal, tenantId, conciliacion, contribuyenteId),
|
||||
]);
|
||||
|
||||
return { delPeriodo, anteriores, total, mesFinal, anio };
|
||||
}
|
||||
|
||||
function emptyResumenIsr(): import('@horux/shared').ResumenIsr {
|
||||
return {
|
||||
ingresosAcumulados: 0,
|
||||
ingresosPorRegimen: [],
|
||||
deducciones: 0,
|
||||
deduccionesPorRegimen: [],
|
||||
baseGravable: 0,
|
||||
baseGravablePorRegimen: [],
|
||||
isrCausado: 0,
|
||||
isrRetenido: 0,
|
||||
isrAPagar: 0,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Sin import top-level necesario**
|
||||
|
||||
El archivo ya usa el patrón `import('@horux/shared').XYZ` inline (ver línea 793 con `BaseGravableRegimen`). El código del Step 1 sigue ese patrón para `ResumenIsr` y `ResumenIsrDesglosado`, así que no hace falta agregar un import top-level. Continuar al Step 3.
|
||||
|
||||
- [ ] **Step 3: Verificar typecheck del API**
|
||||
|
||||
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/api typecheck`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd C:/Users/chtr1/Downloads/Horux_despacho
|
||||
git add apps/api/src/services/impuestos.service.ts
|
||||
git commit -m "feat(api): getResumenIsrDesglosado retorna {delPeriodo, anteriores, total} para desglose ISR provisional"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Backend — controller handler
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/api/src/controllers/impuestos.controller.ts`
|
||||
|
||||
- [ ] **Step 1: Agregar handler después de `getResumenIsr` (línea 88)**
|
||||
|
||||
Insertar entre `getResumenIsr` y `getCoeficiente`:
|
||||
|
||||
```ts
|
||||
export async function getResumenIsrDesglosado(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
// fechaFin define mes_final + año. Default: último día del mes corriente.
|
||||
const now = new Date();
|
||||
const y = now.getFullYear();
|
||||
const m = now.getMonth() + 1;
|
||||
const lastDay = new Date(y, m, 0).getDate();
|
||||
const fechaFin = (req.query.fechaFin as string) || `${y}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
|
||||
const conciliacion = parseConciliacion(req);
|
||||
const contribuyenteId = (req.query.contribuyenteId as string) || null;
|
||||
|
||||
const desglose = await impuestosService.getResumenIsrDesglosado(
|
||||
req.tenantPool,
|
||||
fechaFin,
|
||||
effectiveTenantId(req),
|
||||
conciliacion,
|
||||
contribuyenteId,
|
||||
);
|
||||
res.json(desglose);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verificar typecheck**
|
||||
|
||||
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/api typecheck`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
cd C:/Users/chtr1/Downloads/Horux_despacho
|
||||
git add apps/api/src/controllers/impuestos.controller.ts
|
||||
git commit -m "feat(api): controller handler para resumen-isr-desglosado"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Backend — wire up route
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/api/src/routes/impuestos.routes.ts`
|
||||
|
||||
- [ ] **Step 1: Agregar la ruta**
|
||||
|
||||
Encontrar la línea 17 (`router.get('/isr/resumen', impuestosController.getResumenIsr);`) y agregar inmediatamente después:
|
||||
|
||||
```ts
|
||||
router.get('/isr/resumen-desglosado', impuestosController.getResumenIsrDesglosado);
|
||||
```
|
||||
|
||||
El bloque queda así:
|
||||
|
||||
```ts
|
||||
router.get('/iva/mensual', impuestosController.getIvaMensual);
|
||||
router.get('/iva/resumen', impuestosController.getResumenIva);
|
||||
router.get('/isr/mensual', impuestosController.getIsrMensual);
|
||||
router.get('/isr/resumen', impuestosController.getResumenIsr);
|
||||
router.get('/isr/resumen-desglosado', impuestosController.getResumenIsrDesglosado);
|
||||
router.get('/isr/coeficiente', impuestosController.getCoeficiente);
|
||||
router.put('/isr/coeficiente', impuestosController.setCoeficiente);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verificar typecheck**
|
||||
|
||||
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/api typecheck`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Smoke test del endpoint con un tenant existente**
|
||||
|
||||
Necesitas el dev API corriendo. En otra terminal:
|
||||
|
||||
```bash
|
||||
cd C:/Users/chtr1/Downloads/Horux_despacho
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Login con un usuario que tenga datos (p.ej. del tenant Patito) y obtener el JWT. Luego:
|
||||
|
||||
```bash
|
||||
# Reemplazar TOKEN por el JWT real
|
||||
curl -s "http://localhost:4000/api/impuestos/isr/resumen-desglosado?fechaFin=2026-03-31&conciliacion=false" \
|
||||
-H "Authorization: Bearer $TOKEN" | jq '. | {mesFinal, anio, "delPeriodo.ingresos": .delPeriodo.ingresosAcumulados, "anteriores.ingresos": .anteriores.ingresosAcumulados, "total.ingresos": .total.ingresosAcumulados}'
|
||||
```
|
||||
|
||||
Expected:
|
||||
- `mesFinal: 3, anio: 2026`
|
||||
- `total.ingresos === delPeriodo.ingresos + anteriores.ingresos` (suma debe cuadrar para ingresos/deducciones/retenciones)
|
||||
- `total.baseGravable` puede diferir de la suma (BG no es aditiva si hay meses de pérdida).
|
||||
|
||||
Probar también `fechaFin=2026-01-31` y verificar `anteriores.ingresosAcumulados === 0`.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd C:/Users/chtr1/Downloads/Horux_despacho
|
||||
git add apps/api/src/routes/impuestos.routes.ts
|
||||
git commit -m "feat(api): ruta GET /impuestos/isr/resumen-desglosado"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Frontend — API client
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/lib/api/impuestos.ts`
|
||||
|
||||
- [ ] **Step 1: Actualizar import de types**
|
||||
|
||||
En la línea 2 cambiar:
|
||||
|
||||
```ts
|
||||
import type { IvaMensual, IsrMensual, ResumenIva, ResumenIsr } from '@horux/shared';
|
||||
```
|
||||
|
||||
A:
|
||||
|
||||
```ts
|
||||
import type { IvaMensual, IsrMensual, ResumenIva, ResumenIsr, ResumenIsrDesglosado } from '@horux/shared';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Agregar la función al final del archivo**
|
||||
|
||||
Después de `getResumenIsr` (línea 51), agregar:
|
||||
|
||||
```ts
|
||||
export async function getResumenIsrDesglosado(
|
||||
fechaFin: string,
|
||||
conciliacion?: boolean,
|
||||
contribuyenteId?: string | null,
|
||||
): Promise<ResumenIsrDesglosado> {
|
||||
const params = new URLSearchParams();
|
||||
params.set('fechaFin', fechaFin);
|
||||
if (conciliacion) params.set('conciliacion', 'true');
|
||||
if (contribuyenteId) params.set('contribuyenteId', contribuyenteId);
|
||||
const response = await apiClient.get<ResumenIsrDesglosado>(`/impuestos/isr/resumen-desglosado?${params}`);
|
||||
return response.data;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verificar typecheck del web**
|
||||
|
||||
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/web typecheck`
|
||||
Expected: PASS. Si la app no tiene script `typecheck`, correr `pnpm --filter @horux/web exec tsc --noEmit`.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd C:/Users/chtr1/Downloads/Horux_despacho
|
||||
git add apps/web/lib/api/impuestos.ts
|
||||
git commit -m "feat(web): cliente API getResumenIsrDesglosado"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Frontend — hook `useResumenIsrDesglosado`
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/lib/hooks/use-impuestos.ts`
|
||||
|
||||
- [ ] **Step 1: Agregar hook al final del archivo**
|
||||
|
||||
Después de `useResumenIsr` (línea 55), agregar:
|
||||
|
||||
```ts
|
||||
export function useResumenIsrDesglosado(fechaFin: string, conciliacion?: boolean) {
|
||||
const tk = useTenantKey();
|
||||
const { selectedContribuyenteId } = useContribuyenteStore();
|
||||
return useQuery({
|
||||
queryKey: ['isr-resumen-desglosado', tk, fechaFin, conciliacion, selectedContribuyenteId],
|
||||
queryFn: () => impuestosApi.getResumenIsrDesglosado(fechaFin, conciliacion, selectedContribuyenteId),
|
||||
enabled: !!fechaFin,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verificar typecheck**
|
||||
|
||||
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/web exec tsc --noEmit`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
cd C:/Users/chtr1/Downloads/Horux_despacho
|
||||
git add apps/web/lib/hooks/use-impuestos.ts
|
||||
git commit -m "feat(web): hook useResumenIsrDesglosado"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Frontend — Tabla "Histórico ISR" con columnas acumuladas
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/app/(dashboard)/impuestos/page.tsx:502-568`
|
||||
|
||||
- [ ] **Step 1: Reemplazar el bloque del export Excel (líneas 506-524)**
|
||||
|
||||
Cambiar:
|
||||
|
||||
```tsx
|
||||
{isrMensual && isrMensual.length > 0 && (
|
||||
<Button variant="outline" size="sm" onClick={() => exportToExcel(
|
||||
isrMensual.map(r => ({
|
||||
Mes: meses[r.mes - 1],
|
||||
Ingresos: r.ingresosAcumulados,
|
||||
Deducciones: r.deducciones,
|
||||
'Base Gravable': r.baseGravable,
|
||||
})),
|
||||
[
|
||||
{ header: 'Mes', key: 'Mes', width: 12 },
|
||||
{ header: 'Ingresos', key: 'Ingresos', width: 18 },
|
||||
{ header: 'Deducciones', key: 'Deducciones', width: 18 },
|
||||
{ header: 'Base Gravable', key: 'Base Gravable', width: 18 },
|
||||
],
|
||||
`isr-mensual-${año}`,
|
||||
)}>
|
||||
<Download className="h-4 w-4 mr-1" /> Excel
|
||||
</Button>
|
||||
)}
|
||||
```
|
||||
|
||||
A:
|
||||
|
||||
```tsx
|
||||
{isrMensual && isrMensual.length > 0 && (
|
||||
<Button variant="outline" size="sm" onClick={() => exportToExcel(
|
||||
isrMensual.map(r => ({
|
||||
Mes: meses[r.mes - 1],
|
||||
Ingresos: r.ingresosAcumulados,
|
||||
'Ingresos Acumulados': r.ingresosAcum,
|
||||
Deducciones: r.deducciones,
|
||||
'Deducciones Acumuladas': r.deduccionesAcum,
|
||||
'Base Gravable Acumulada': r.baseGravableAcum,
|
||||
})),
|
||||
[
|
||||
{ header: 'Mes', key: 'Mes', width: 12 },
|
||||
{ header: 'Ingresos', key: 'Ingresos', width: 18 },
|
||||
{ header: 'Ingresos Acumulados', key: 'Ingresos Acumulados', width: 22 },
|
||||
{ header: 'Deducciones', key: 'Deducciones', width: 18 },
|
||||
{ header: 'Deducciones Acumuladas', key: 'Deducciones Acumuladas', width: 22 },
|
||||
{ header: 'Base Gravable Acumulada', key: 'Base Gravable Acumulada', width: 22 },
|
||||
],
|
||||
`isr-mensual-${año}`,
|
||||
)}>
|
||||
<Download className="h-4 w-4 mr-1" /> Excel
|
||||
</Button>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Reemplazar el `<thead>` (líneas 532-538)**
|
||||
|
||||
Cambiar:
|
||||
|
||||
```tsx
|
||||
<thead>
|
||||
<tr className="border-b text-left text-sm text-muted-foreground">
|
||||
<th className="pb-3 font-medium">Mes</th>
|
||||
<th className="pb-3 font-medium text-right">Ingresos</th>
|
||||
<th className="pb-3 font-medium text-right">Deducciones</th>
|
||||
<th className="pb-3 font-medium text-right">Base Gravable</th>
|
||||
</tr>
|
||||
</thead>
|
||||
```
|
||||
|
||||
A:
|
||||
|
||||
```tsx
|
||||
<thead>
|
||||
<tr className="border-b text-left text-sm text-muted-foreground">
|
||||
<th className="pb-3 font-medium">Mes</th>
|
||||
<th className="pb-3 font-medium text-right">Ingresos</th>
|
||||
<th className="pb-3 font-medium text-right">Ingresos Acum.</th>
|
||||
<th className="pb-3 font-medium text-right">Deducciones</th>
|
||||
<th className="pb-3 font-medium text-right">Deducciones Acum.</th>
|
||||
<th className="pb-3 font-medium text-right">Base Gravable Acum.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Reemplazar el `<tbody>` filas y la fila Total (líneas 540-566)**
|
||||
|
||||
Cambiar el bloque entero de `<tbody>...</tbody>` por:
|
||||
|
||||
```tsx
|
||||
<tbody className="text-sm">
|
||||
{isrMensual?.map((row) => (
|
||||
<tr key={row.mes} className="border-b hover:bg-muted/50">
|
||||
<td className="py-3 font-medium">{meses[row.mes - 1]}</td>
|
||||
<td className="py-3 text-right">{formatCurrency(row.ingresosAcumulados)}</td>
|
||||
<td className="py-3 text-right">{formatCurrency(row.ingresosAcum)}</td>
|
||||
<td className="py-3 text-right">{formatCurrency(row.deducciones)}</td>
|
||||
<td className="py-3 text-right">{formatCurrency(row.deduccionesAcum)}</td>
|
||||
<td className={cn(
|
||||
'py-3 text-right font-medium',
|
||||
row.baseGravableAcum < 0 ? 'text-destructive' : ''
|
||||
)}>
|
||||
{formatCurrency(row.baseGravableAcum)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{(!isrMensual || isrMensual.length === 0) && (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-8 text-center text-muted-foreground">
|
||||
No hay registros de ISR para este año
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
```
|
||||
|
||||
Notas:
|
||||
- Removida la fila Total. La última fila (con datos) ya es el YTD al cierre de ese mes.
|
||||
- `colSpan={6}` actualizado de 4.
|
||||
|
||||
- [ ] **Step 4: Verificar typecheck del web**
|
||||
|
||||
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/web exec tsc --noEmit`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Smoke manual de la tabla**
|
||||
|
||||
Si el dev no está corriendo: `pnpm dev`. Luego:
|
||||
|
||||
1. Abrir http://localhost:3000/impuestos en el navegador.
|
||||
2. Cambiar a la pestaña ISR.
|
||||
3. Verificar que aparezcan **6 columnas** en la tabla "Histórico ISR".
|
||||
4. Verificar que las columnas Ingresos Acum., Deducciones Acum. y Base Gravable Acum. muestren running totals correctos (la fila de febrero debe tener acumulado = enero + febrero).
|
||||
5. Si hay un mes con BG negativa, verificar que aparezca **en rojo** (`text-destructive`).
|
||||
6. Hacer click en "Excel" y verificar que el archivo descargado tenga las 6 columnas alineadas con el orden de la UI.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
cd C:/Users/chtr1/Downloads/Horux_despacho
|
||||
git add apps/web/app/\(dashboard\)/impuestos/page.tsx
|
||||
git commit -m "feat(web): tabla Histórico ISR con columnas acumuladas; BG mensual deja de mostrarse"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Frontend — Sección "Cálculo de ISR del Periodo"
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/app/(dashboard)/impuestos/page.tsx:371-432`
|
||||
|
||||
- [ ] **Step 1: Importar el nuevo hook**
|
||||
|
||||
Buscar la línea 7:
|
||||
|
||||
```ts
|
||||
import { useIvaMensual, useIsrMensual, useResumenIva, useResumenIsr, useCoeficiente } from '@/lib/hooks/use-impuestos';
|
||||
```
|
||||
|
||||
Cambiar a:
|
||||
|
||||
```ts
|
||||
import { useIvaMensual, useIsrMensual, useResumenIva, useResumenIsr, useResumenIsrDesglosado, useCoeficiente } from '@/lib/hooks/use-impuestos';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Llamar al nuevo hook después de `useResumenIsr`**
|
||||
|
||||
Buscar la línea 46 (`const { data: resumenIsr } = useResumenIsr(fechaInicio, fechaFin, conciliacion);`) y agregar inmediatamente después:
|
||||
|
||||
```ts
|
||||
const { data: resumenIsrDesglose } = useResumenIsrDesglosado(fechaFin, conciliacion);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Reescribir la sección del Card "Cálculo de ISR Acumulado"**
|
||||
|
||||
Reemplazar el bloque desde `<CardTitle className="text-base">Calculo de ISR Acumulado</CardTitle>` hasta el cierre `</CardContent>` correspondiente (aproximadamente líneas 381-432) por:
|
||||
|
||||
```tsx
|
||||
<CardTitle className="text-base">Cálculo de ISR del Periodo</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(() => {
|
||||
// Etiquetas dinámicas a partir del mesFinal del filtro
|
||||
const desglose = resumenIsrDesglose;
|
||||
if (!desglose) {
|
||||
return <div className="text-sm text-muted-foreground">Cargando…</div>;
|
||||
}
|
||||
const { delPeriodo, anteriores, total, mesFinal, anio } = desglose;
|
||||
const labelMesFinal = `${meses[mesFinal - 1]} ${anio}`;
|
||||
const labelAnteriores =
|
||||
mesFinal === 1
|
||||
? '(sin meses anteriores)'
|
||||
: mesFinal === 2
|
||||
? `(${meses[0]})`
|
||||
: `(${meses[0]}-${meses[mesFinal - 2]})`;
|
||||
|
||||
// Resolver per-régimen si hay régimen seleccionado, igual patrón que antes.
|
||||
const ingPer = regimenSeleccionado
|
||||
? delPeriodo.ingresosPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
|
||||
: delPeriodo.ingresosAcumulados || 0;
|
||||
const ingAnt = regimenSeleccionado
|
||||
? anteriores.ingresosPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
|
||||
: anteriores.ingresosAcumulados || 0;
|
||||
const dedPer = regimenSeleccionado
|
||||
? delPeriodo.deduccionesPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
|
||||
: delPeriodo.deducciones || 0;
|
||||
const dedAnt = regimenSeleccionado
|
||||
? anteriores.deduccionesPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
|
||||
: anteriores.deducciones || 0;
|
||||
const bgTotal = regimenSeleccionado
|
||||
? total.baseGravablePorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.baseGravable || 0
|
||||
: total.baseGravable || 0;
|
||||
const causadoTotal = regimenSeleccionado
|
||||
? total.baseGravablePorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.isrCausado || 0
|
||||
: total.isrCausado || 0;
|
||||
const retenido = total.isrRetenido || 0;
|
||||
const aPagar = Math.max(0, causadoTotal - (regimenSeleccionado ? 0 : retenido));
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between py-2 border-b">
|
||||
<span className="text-muted-foreground">Ingresos del periodo ({labelMesFinal})</span>
|
||||
<span className="font-medium">{formatCurrency(ingPer)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2 border-b">
|
||||
<span className="text-muted-foreground">(+) Ingresos acumulados anteriores {labelAnteriores}</span>
|
||||
<span className="font-medium">{formatCurrency(ingAnt)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2 border-b">
|
||||
<span className="text-muted-foreground">(−) Deducciones del periodo ({labelMesFinal})</span>
|
||||
<span className="font-medium">{formatCurrency(dedPer)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2 border-b">
|
||||
<span className="text-muted-foreground">(−) Deducciones acumuladas anteriores {labelAnteriores}</span>
|
||||
<span className="font-medium">{formatCurrency(dedAnt)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2 border-b">
|
||||
<span className="font-medium">(=) Base gravable acumulada</span>
|
||||
<span className={cn('font-medium', bgTotal < 0 ? 'text-destructive' : '')}>
|
||||
{formatCurrency(bgTotal)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2 border-b">
|
||||
<span className="text-muted-foreground">ISR causado (acumulado)</span>
|
||||
<span className="font-medium">{formatCurrency(causadoTotal)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2 border-b">
|
||||
<span className="text-muted-foreground">(−) ISR retenido (acumulado)</span>
|
||||
<span className="font-medium">{formatCurrency(regimenSeleccionado ? 0 : retenido)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2 bg-muted/50 px-4 rounded-lg mt-2">
|
||||
<span className="font-medium">ISR a pagar</span>
|
||||
<span className="font-bold text-lg">{formatCurrency(aPagar)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</CardContent>
|
||||
```
|
||||
|
||||
Nota: `cn` ya está importado al inicio del archivo (línea 12). Si por alguna razón no lo está, agregar `cn` al import de `@horux/shared-ui`.
|
||||
|
||||
- [ ] **Step 4: Verificar typecheck**
|
||||
|
||||
Run: `cd C:/Users/chtr1/Downloads/Horux_despacho && pnpm --filter @horux/web exec tsc --noEmit`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Smoke manual de la sección**
|
||||
|
||||
Con el dev corriendo y un tenant con datos:
|
||||
|
||||
1. Abrir `/impuestos` → pestaña ISR.
|
||||
2. Filtro de periodo en el mes corriente: verificar que aparezcan los 4 renglones de descomposición + base gravable + ISR causado + ISR retenido + ISR a pagar.
|
||||
3. Cambiar el filtro a **enero del año en curso**: verificar que las dos líneas "anteriores" muestren `$0` con la etiqueta `(sin meses anteriores)`.
|
||||
4. Cambiar el filtro a **febrero**: la etiqueta de "anteriores" debe decir `(Ene)`.
|
||||
5. Cambiar el filtro a **marzo**: etiqueta `(Ene-Feb)`.
|
||||
6. Si hay un tenant con pérdidas YTD: verificar que la línea "Base gravable acumulada" aparezca **en rojo** y que ISR a pagar sea `$0`.
|
||||
7. Aritmética cruzada: la suma `Ing del periodo + Ing anteriores − Ded del periodo − Ded anteriores` debe coincidir con la línea Base gravable acumulada.
|
||||
8. Probar también con **régimen seleccionado** en el dropdown — los números deben filtrar correctamente.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
cd C:/Users/chtr1/Downloads/Horux_despacho
|
||||
git add apps/web/app/\(dashboard\)/impuestos/page.tsx
|
||||
git commit -m "feat(web): sección 'Cálculo de ISR del Periodo' con desglose periodo+anteriores=total"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Verificación final + sync OneDrive + commit release
|
||||
|
||||
**Files:**
|
||||
- Verify: typecheck completo del repo
|
||||
- Copy: 6 archivos modificados/nuevos a OneDrive
|
||||
- Commit: bump de versión en OneDrive (mantener pattern V.1.0.x)
|
||||
|
||||
- [ ] **Step 1: Typecheck completo**
|
||||
|
||||
```bash
|
||||
cd C:/Users/chtr1/Downloads/Horux_despacho
|
||||
pnpm --filter @horux/shared typecheck
|
||||
pnpm --filter @horux/api typecheck
|
||||
pnpm --filter @horux/web exec tsc --noEmit
|
||||
```
|
||||
|
||||
Expected: los tres en PASS sin errores. Si hay errores, regresar al task correspondiente.
|
||||
|
||||
- [ ] **Step 2: Smoke test cross-feature**
|
||||
|
||||
Con dev corriendo, en el browser:
|
||||
|
||||
1. Cambiar entre IVA y ISR — verificar que IVA siga funcionando igual (no afectado).
|
||||
2. Toggle conciliación on/off — verificar que la sección de cálculo y la tabla actualicen.
|
||||
3. Cambiar contribuyente activo — verificar que los queries refetchean con el contribuyente nuevo.
|
||||
4. Validar que los KPIs de la parte alta (Ingresos, Base Gravable, etc.) sigan mostrando los valores del rango filtrado completo (estos NO deben cambiar — solo afectamos la tabla y la sección de cálculo).
|
||||
|
||||
- [ ] **Step 3: Copiar archivos a OneDrive**
|
||||
|
||||
```bash
|
||||
SRC="C:/Users/chtr1/Downloads/Horux_despacho"
|
||||
DST="C:/Users/chtr1/OneDrive/Documentos/GitHub/Horux_despachos"
|
||||
|
||||
cp -p "$SRC/packages/shared/src/types/impuestos.ts" "$DST/packages/shared/src/types/impuestos.ts"
|
||||
cp -p "$SRC/apps/api/src/services/impuestos.service.ts" "$DST/apps/api/src/services/impuestos.service.ts"
|
||||
cp -p "$SRC/apps/api/src/controllers/impuestos.controller.ts" "$DST/apps/api/src/controllers/impuestos.controller.ts"
|
||||
cp -p "$SRC/apps/api/src/routes/impuestos.routes.ts" "$DST/apps/api/src/routes/impuestos.routes.ts"
|
||||
cp -p "$SRC/apps/web/lib/api/impuestos.ts" "$DST/apps/web/lib/api/impuestos.ts"
|
||||
cp -p "$SRC/apps/web/lib/hooks/use-impuestos.ts" "$DST/apps/web/lib/hooks/use-impuestos.ts"
|
||||
cp -p "$SRC/apps/web/app/(dashboard)/impuestos/page.tsx" "$DST/apps/web/app/(dashboard)/impuestos/page.tsx"
|
||||
cp -p "$SRC/docs/superpowers/specs/2026-04-27-isr-base-gravable-acumulada-design.md" "$DST/docs/superpowers/specs/2026-04-27-isr-base-gravable-acumulada-design.md"
|
||||
cp -p "$SRC/docs/superpowers/plans/2026-04-27-isr-base-gravable-acumulada.md" "$DST/docs/superpowers/plans/2026-04-27-isr-base-gravable-acumulada.md"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verificar diff OneDrive vs Downloads**
|
||||
|
||||
```bash
|
||||
diff -rq \
|
||||
--exclude=node_modules --exclude=.git --exclude=.turbo --exclude=.next \
|
||||
--exclude=dist --exclude=tsconfig.tsbuildinfo --exclude=email-previews \
|
||||
--exclude=pnpm-lock.yaml --exclude=.env --exclude=.env.local \
|
||||
"C:/Users/chtr1/Downloads/Horux_despacho" \
|
||||
"C:/Users/chtr1/OneDrive/Documentos/GitHub/Horux_despachos"
|
||||
```
|
||||
|
||||
Expected: única diferencia esperada es `Only in C:/Users/chtr1/Downloads/Horux_despacho/apps/api: data` (XMLs runtime). Si aparece otra diferencia inesperada, investigar.
|
||||
|
||||
- [ ] **Step 5: Commit en OneDrive**
|
||||
|
||||
```bash
|
||||
cd "C:/Users/chtr1/OneDrive/Documentos/GitHub/Horux_despachos"
|
||||
git add \
|
||||
packages/shared/src/types/impuestos.ts \
|
||||
apps/api/src/services/impuestos.service.ts \
|
||||
apps/api/src/controllers/impuestos.controller.ts \
|
||||
apps/api/src/routes/impuestos.routes.ts \
|
||||
apps/web/lib/api/impuestos.ts \
|
||||
apps/web/lib/hooks/use-impuestos.ts \
|
||||
"apps/web/app/(dashboard)/impuestos/page.tsx" \
|
||||
docs/superpowers/specs/2026-04-27-isr-base-gravable-acumulada-design.md \
|
||||
docs/superpowers/plans/2026-04-27-isr-base-gravable-acumulada.md
|
||||
|
||||
git commit -m "V.1.0.6"
|
||||
git status --short
|
||||
git log -2 --oneline
|
||||
```
|
||||
|
||||
Expected:
|
||||
- Commit creado con hash nuevo, mensaje `V.1.0.6` (mantiene el pattern de OneDrive).
|
||||
- `git status` clean.
|
||||
- `git log -2` muestra V.1.0.6 sobre V.1.0.5.
|
||||
|
||||
- [ ] **Step 6: NO push automático**
|
||||
|
||||
Per workflow del owner: el push a `origin/main` lo dispara él manualmente cuando quiera. Confirmar que NO se ejecutó `git push`.
|
||||
Reference in New Issue
Block a user