Update: nueva version Horux Despachos
This commit is contained in:
116
docs/superpowers/INDEX.md
Normal file
116
docs/superpowers/INDEX.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# Horux Despachos — Índice del proyecto
|
||||
|
||||
Este documento es el punto de entrada para cualquier sesión de trabajo en el pivote "Horux Despachos". Empieza aquí.
|
||||
|
||||
**Producto:** SaaS para despachos contables mexicanos (MVP), extensible a otras verticales profesionales (jurídica, arquitectura).
|
||||
**Base de código:** fork de Horux360 en esta carpeta.
|
||||
**Estado:** diseño aprobado, Plan 1 listo para ejecución, Planes 2-8 pendientes de brainstorm.
|
||||
**Fecha inicio:** 2026-04-16.
|
||||
|
||||
---
|
||||
|
||||
## Lo primero que debes leer
|
||||
|
||||
1. **[Spec de diseño](specs/2026-04-16-horux-despachos-design.md)** — 16 secciones, decisiones arquitectónicas, modelo de datos, flujos, riesgos, roadmap de fases. Fuente de verdad para qué se construye.
|
||||
|
||||
2. **[Plan 1 — Refactor preparatorio del monorepo](plans/2026-04-16-refactor-monorepo-packages.md)** — 20 tasks, ~100 steps. Se ejecuta primero porque desbloquea todas las fases siguientes.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap de 8 planes
|
||||
|
||||
Cada plan se construye sobre los anteriores. Cada uno pasa por su propio ciclo brainstorm → spec → plan → ejecución con la skill apropiada.
|
||||
|
||||
| # | Plan | Estado | Estimado | Spec | Plan |
|
||||
|---|------|--------|----------|------|------|
|
||||
| **1** | **Refactor preparatorio del monorepo** | ✅ Plan listo | 1-2 sem | Ver spec §13 | [Plan 1](plans/2026-04-16-refactor-monorepo-packages.md) |
|
||||
| 2 | Cimientos de Despachos | ⏳ Pendiente | 3-4 sem | Pendiente | Pendiente |
|
||||
| 3 | Roles y carteras | ⏳ Pendiente | 2 sem | Pendiente | Pendiente |
|
||||
| 4 | Pricing y pagos | ⏳ Pendiente | 2-3 sem | Pendiente | Pendiente |
|
||||
| 5 | Connector BYO-DB | ⏳ Pendiente | 2 sem | Pendiente | Pendiente |
|
||||
| 6 | Admin global + dashboard cross-despacho | ⏳ Pendiente | 2 sem | Pendiente | Pendiente |
|
||||
| 7 | Métricas pre-calculadas | ⏳ Pendiente | 2 sem | Pendiente | Pendiente |
|
||||
| 8 | Polish + launch privado | ⏳ Pendiente | 1-2 sem | Pendiente | Pendiente |
|
||||
|
||||
**Total estimado:** 15-19 semanas hasta launch privado.
|
||||
|
||||
Detalle de alcance de cada plan en el spec §15.
|
||||
|
||||
---
|
||||
|
||||
## Cómo arrancar la próxima sesión
|
||||
|
||||
### Si vas a ejecutar el Plan 1 (refactor):
|
||||
|
||||
1. Verifica estado del repo:
|
||||
```bash
|
||||
cd /c/Users/chtr1/Downloads/Horux_despacho
|
||||
git status
|
||||
git log --oneline -5
|
||||
```
|
||||
|
||||
2. Asegura deps instaladas:
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm -r typecheck # baseline sin errores antes de empezar
|
||||
```
|
||||
|
||||
3. Invoca la skill de ejecución:
|
||||
- **Recomendada:** `superpowers:subagent-driven-development` — fresh subagent por task, review entre tasks, contexto limpio.
|
||||
- **Alternativa:** `superpowers:executing-plans` — ejecución inline con checkpoints.
|
||||
|
||||
4. Apunta la skill al plan:
|
||||
- Path: `docs/superpowers/plans/2026-04-16-refactor-monorepo-packages.md`
|
||||
|
||||
5. Commits locales frecuentes (uno por task); **NO hacer `git push`** (no hay remote todavía).
|
||||
|
||||
### Si vas a brainstormear el siguiente plan (Plan 2):
|
||||
|
||||
1. Leer el spec completo (ya aprobado — no re-brainstorm decisiones ya cerradas).
|
||||
2. Invocar `superpowers:brainstorming` con contexto: "brainstorm Plan 2 — Cimientos de Despachos: BD Central (Prisma), BD Tenant (migrations core + vertical contable), auth con despachoId, signup → trial → agregar contribuyente → FIEL/CSD → primera emisión CFDI."
|
||||
3. El brainstorm genera un spec propio en `specs/` y termina en writing-plans que escribe el Plan 2 en `plans/`.
|
||||
|
||||
---
|
||||
|
||||
## Decisiones cerradas (no re-brainstorm)
|
||||
|
||||
Estas decisiones están aprobadas en el spec y NO deben revisarse salvo nueva evidencia:
|
||||
|
||||
- **Hosting:** Opción A (SaaS central + BYO-DB / Managed).
|
||||
- **Tiers BD:** dos tiers (BYO barato, Managed premium).
|
||||
- **Roles despacho:** Owner / Supervisor / Auxiliar / Cliente.
|
||||
- **Carteras:** jerárquicas con cascada; Owner = Supervisor implícito.
|
||||
- **Facturapi:** cuenta maestra Horux broker; 1 org por contribuyente; pool único timbres por despacho.
|
||||
- **Connector:** Cloudflare Tunnel + Docker `horux/connector`.
|
||||
- **Pricing:** tiers fijos + add-ons + paquetes one-shot.
|
||||
- **Monorepo:** unificado, refactor preparatorio a `packages/core`, `packages/vertical-contable`, `packages/shared-ui`.
|
||||
- **Admins globales:** impersonación con motivo + audit + dashboard cross-despacho.
|
||||
- **Clientes-visores:** password + magic link + multi-RFC + multi-despacho.
|
||||
- **Multi-vertical:** arquitectura preparada desde MVP.
|
||||
- **Métricas:** hot/cold con drill-down + formula_version + invalidación dirigida.
|
||||
|
||||
---
|
||||
|
||||
## Archivos del directorio
|
||||
|
||||
```
|
||||
docs/superpowers/
|
||||
├── INDEX.md ← este archivo
|
||||
├── specs/
|
||||
│ └── 2026-04-16-horux-despachos-design.md ← spec completo
|
||||
└── plans/
|
||||
└── 2026-04-16-refactor-monorepo-packages.md ← Plan 1
|
||||
```
|
||||
|
||||
Los Planes 2-8 agregarán specs y plans a sus respectivas carpetas conforme se vayan brainstormeando.
|
||||
|
||||
---
|
||||
|
||||
## Convenciones del proyecto
|
||||
|
||||
- **Disciplina TS:** `pnpm typecheck` tras cada cambio relevante. CI/guardrail local.
|
||||
- **Tests:** Plan 1 NO agrega framework de tests (eso es proyecto aparte); valida con typecheck + smoke test manual.
|
||||
- **Git:** commits locales por task; sin push (no hay remote); no `--amend` sobre commits existentes; no `--no-verify`.
|
||||
- **Working directory único:** todo en `Downloads/Horux_despacho`. El repo de Horux360 en OneDrive NO se toca para este pivote.
|
||||
- **Convenciones Prisma:** modelos en PascalCase, campos camelCase; relaciones explícitas con `@relation`.
|
||||
- **Convenciones SQL (BD tenant):** snake_case, FK NOT NULL donde aplica, índices sobre `contribuyente_id` en todas las tablas verticales.
|
||||
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`.
|
||||
797
docs/superpowers/specs/2026-03-15-saas-transformation-design.md
Normal file
797
docs/superpowers/specs/2026-03-15-saas-transformation-design.md
Normal file
@@ -0,0 +1,797 @@
|
||||
# Horux360 SaaS Transformation — Design Spec
|
||||
|
||||
**Date:** 2026-03-15
|
||||
**Status:** Approved
|
||||
**Author:** Carlos Horux + Claude
|
||||
|
||||
## Overview
|
||||
|
||||
Transform Horux360 from an internal multi-tenant accounting tool into a production-ready SaaS platform. Client registration remains manual (sales-led). Each client gets a fully isolated PostgreSQL database. Payments via MercadoPago. Transactional emails via Gmail SMTP (@horuxfin.com). Production deployment on existing server (192.168.10.212).
|
||||
|
||||
**Target scale:** 10-50 clients within 6 months.
|
||||
|
||||
**Starting from scratch:** No data migration. Existing schemas/data will be archived. Fresh setup.
|
||||
|
||||
---
|
||||
|
||||
## Section 1: Database-Per-Tenant Architecture
|
||||
|
||||
### Rationale
|
||||
|
||||
Clients sign NDAs requiring complete data isolation. Schema-per-tenant (current approach) shares a single database. Database-per-tenant provides:
|
||||
- Independent backup/restore per client
|
||||
- No risk of cross-tenant data leakage
|
||||
- Each DB can be moved to a different server if needed
|
||||
|
||||
### Structure
|
||||
|
||||
```
|
||||
PostgreSQL Server (max_connections: 300)
|
||||
├── horux360 ← Central DB (Prisma-managed)
|
||||
├── horux_cas2408138w2 ← Client DB (raw SQL)
|
||||
├── horux_roem691011ez4 ← Client DB
|
||||
└── ...
|
||||
```
|
||||
|
||||
### Central DB (`horux360`) — Prisma-managed tables
|
||||
|
||||
Existing tables (modified):
|
||||
- `tenants` — add `database_name` column, remove `schema_name`
|
||||
- `users` — no changes
|
||||
- `refresh_tokens` — flush all existing tokens at migration cutover (invalidate all sessions)
|
||||
- `fiel_credentials` — no changes
|
||||
|
||||
New tables:
|
||||
- `subscriptions` — MercadoPago subscription tracking
|
||||
- `payments` — payment history
|
||||
|
||||
### Prisma schema migration
|
||||
|
||||
The Prisma schema (`apps/api/prisma/schema.prisma`) must be updated:
|
||||
- Replace `schema_name String @unique @map("schema_name")` with `database_name String @unique @map("database_name")` on the `Tenant` model
|
||||
- Add `Subscription` and `Payment` models
|
||||
- Run `prisma migrate dev` to generate and apply migration
|
||||
- Update `Tenant` type in `packages/shared/src/types/tenant.ts`: replace `schemaName` with `databaseName`
|
||||
|
||||
### JWT payload migration
|
||||
|
||||
The current JWT payload embeds `schemaName`. This must change:
|
||||
- Update `JWTPayload` in `packages/shared/src/types/auth.ts`: replace `schemaName` with `databaseName`
|
||||
- Update token generation in `auth.service.ts`: read `tenant.databaseName` instead of `tenant.schemaName`
|
||||
- Update `refreshTokens` function to embed `databaseName`
|
||||
- At migration cutover: flush `refresh_tokens` table to invalidate all existing sessions (forces re-login)
|
||||
|
||||
### Client DB naming
|
||||
|
||||
Formula: `horux_<rfc_normalized>`
|
||||
```
|
||||
RFC "HTS240708LJA" → horux_cas2408138w2
|
||||
RFC "TPR840604D98" → horux_tpr840604d98
|
||||
```
|
||||
|
||||
### Client DB tables (created via raw SQL)
|
||||
|
||||
Each client database contains these tables (no schema prefix, direct `public` schema):
|
||||
|
||||
- `cfdis` — with indexes: fecha_emision DESC, tipo, rfc_emisor, rfc_receptor, pg_trgm on nombre_emisor/nombre_receptor, uuid_fiscal unique
|
||||
- `iva_mensual`
|
||||
- `isr_mensual`
|
||||
- `alertas`
|
||||
- `calendario_fiscal`
|
||||
|
||||
### TenantConnectionManager
|
||||
|
||||
```typescript
|
||||
class TenantConnectionManager {
|
||||
private pools: Map<string, { pool: pg.Pool; lastAccess: Date }>;
|
||||
private cleanupInterval: NodeJS.Timer;
|
||||
|
||||
// Get or create a pool for a tenant
|
||||
getPool(tenantId: string, databaseName: string): pg.Pool;
|
||||
|
||||
// Create a new tenant database with all tables and indexes
|
||||
provisionDatabase(rfc: string): Promise<string>;
|
||||
|
||||
// Drop a tenant database (soft-delete: rename to horux_deleted_<rfc>_<timestamp>)
|
||||
deprovisionDatabase(databaseName: string): Promise<void>;
|
||||
|
||||
// Cleanup idle pools (called every 60s, removes pools idle > 5min)
|
||||
private cleanupIdlePools(): void;
|
||||
}
|
||||
```
|
||||
|
||||
Pool configuration per tenant:
|
||||
- `max`: 3 connections (with 2 PM2 cluster instances, this means 6 connections/tenant max; at 50 tenants = 300, matching `max_connections`)
|
||||
- `idleTimeoutMillis`: 300000 (5 min)
|
||||
- `connectionTimeoutMillis`: 10000 (10 sec)
|
||||
|
||||
**Note on PM2 cluster mode:** Each PM2 worker is a separate Node.js process with its own `TenantConnectionManager` instance. With `instances: 2` and `max: 3` per pool, worst case is 50 tenants × 3 connections × 2 workers = 300 connections, which matches `max_connections = 300`. If scaling beyond 50 tenants, either increase `max_connections` or reduce pool `max` to 2.
|
||||
|
||||
### Tenant middleware change
|
||||
|
||||
Current: Sets `search_path` on a shared connection.
|
||||
New: Returns a dedicated pool connected to the tenant's own database.
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
req.tenantSchema = schema;
|
||||
await pool.query(`SET search_path TO "${schema}", public`);
|
||||
|
||||
// After
|
||||
req.tenantPool = tenantConnectionManager.getPool(tenant.id, tenant.databaseName);
|
||||
```
|
||||
|
||||
All tenant service functions change from using a shared pool with schema prefix to using `req.tenantPool` with direct table names.
|
||||
|
||||
### Admin impersonation (X-View-Tenant)
|
||||
|
||||
The current `X-View-Tenant` header support for admin "view-as" functionality is preserved. The new middleware resolves the `databaseName` for the viewed tenant:
|
||||
|
||||
```typescript
|
||||
// If admin is viewing another tenant
|
||||
if (req.headers['x-view-tenant'] && req.user.role === 'admin') {
|
||||
const viewedTenant = await getTenantByRfc(req.headers['x-view-tenant']);
|
||||
req.tenantPool = tenantConnectionManager.getPool(viewedTenant.id, viewedTenant.databaseName);
|
||||
} else {
|
||||
req.tenantPool = tenantConnectionManager.getPool(tenant.id, tenant.databaseName);
|
||||
}
|
||||
```
|
||||
|
||||
### Provisioning flow (new client)
|
||||
|
||||
1. Admin creates tenant via UI → POST `/api/tenants/`
|
||||
2. Insert record in `horux360.tenants` with `database_name`
|
||||
3. Execute `CREATE DATABASE horux_<rfc>`
|
||||
4. Connect to new DB, create all tables + indexes
|
||||
5. Create admin user in `horux360.users` linked to tenant
|
||||
6. Send welcome email with temporary credentials
|
||||
7. Generate MercadoPago subscription link
|
||||
|
||||
**Rollback on partial failure:** If any step 3-7 fails:
|
||||
- Drop the created database if it exists (`DROP DATABASE IF EXISTS horux_<rfc>`)
|
||||
- Delete the `tenants` row
|
||||
- Delete the `users` row if created
|
||||
- Return error to admin with the specific step that failed
|
||||
- The entire provisioning is wrapped in a try/catch with explicit cleanup
|
||||
|
||||
### PostgreSQL tuning
|
||||
|
||||
```
|
||||
max_connections = 300
|
||||
shared_buffers = 4GB
|
||||
work_mem = 16MB
|
||||
effective_cache_size = 16GB
|
||||
maintenance_work_mem = 512MB
|
||||
```
|
||||
|
||||
### Server disk
|
||||
|
||||
Expand from 29 GB to 100 GB to accommodate:
|
||||
- 25-50 client databases (~2-3 GB total)
|
||||
- Daily backups with 7-day retention (~15 GB)
|
||||
- FIEL encrypted files (<100 MB)
|
||||
- Logs, builds, OS (~10 GB)
|
||||
|
||||
---
|
||||
|
||||
## Section 2: SAT Credential Storage (FIEL)
|
||||
|
||||
### Dual storage strategy
|
||||
|
||||
When a client uploads their FIEL (.cer + .key + password):
|
||||
|
||||
**A. Filesystem (for manual linking):**
|
||||
```
|
||||
/var/horux/fiel/
|
||||
├── HTS240708LJA/
|
||||
│ ├── certificate.cer.enc ← AES-256-GCM encrypted
|
||||
│ ├── private_key.key.enc ← AES-256-GCM encrypted
|
||||
│ └── metadata.json.enc ← serial, validity dates, upload date (also encrypted)
|
||||
└── ROEM691011EZ4/
|
||||
├── certificate.cer.enc
|
||||
├── private_key.key.enc
|
||||
└── metadata.json.enc
|
||||
```
|
||||
|
||||
**B. Central DB (`fiel_credentials` table):**
|
||||
- Existing structure: `cer_data`, `key_data`, `key_password_encrypted`, `encryption_iv`, `encryption_tag`
|
||||
- **Schema change required:** Add per-component IV/tag columns (`cer_iv`, `cer_tag`, `key_iv`, `key_tag`, `password_iv`, `password_tag`) to support independent encryption per component. Alternatively, use a single JSON column for all encryption metadata. The existing `encryption_iv` and `encryption_tag` columns can be dropped after migration.
|
||||
|
||||
### Encryption
|
||||
|
||||
- Algorithm: AES-256-GCM
|
||||
- Key: `FIEL_ENCRYPTION_KEY` environment variable (separate from other secrets)
|
||||
- **Code change required:** `sat-crypto.service.ts` currently derives the key from `JWT_SECRET` via `createHash('sha256').update(env.JWT_SECRET).digest()`. This must be changed to read `FIEL_ENCRYPTION_KEY` from the env schema. The `env.ts` Zod schema must be updated to declare `FIEL_ENCRYPTION_KEY` as required.
|
||||
- Each component (certificate, private key, password) is encrypted separately with its own IV and auth tag. The `fiel_credentials` table stores separate `encryption_iv` and `encryption_tag` per row. The filesystem also stores each file independently encrypted.
|
||||
- **Code change required:** The current `sat-crypto.service.ts` shares a single IV/tag across all three components. Refactor to encrypt each component independently with its own IV/tag. Store per-component IV/tags in the DB (add columns: `cer_iv`, `cer_tag`, `key_iv`, `key_tag`, `password_iv`, `password_tag` — or use a JSON column).
|
||||
- Password is encrypted, never stored in plaintext
|
||||
|
||||
### Manual decryption CLI
|
||||
|
||||
```bash
|
||||
node scripts/decrypt-fiel.js --rfc HTS240708LJA
|
||||
```
|
||||
|
||||
- Decrypts files to `/tmp/horux-fiel-<rfc>/`
|
||||
- Files auto-delete after 30 minutes (via setTimeout or tmpwatch)
|
||||
- Requires SSH access to server
|
||||
|
||||
### Security
|
||||
|
||||
- `/var/horux/fiel/` permissions: `700` (root only)
|
||||
- Encrypted files are useless without `FIEL_ENCRYPTION_KEY`
|
||||
- `metadata.json` is also encrypted (contains serial number + RFC which could be used to query SAT's certificate validation service, violating NDA confidentiality requirements)
|
||||
|
||||
### Upload flow
|
||||
|
||||
1. Client navigates to `/configuracion/sat`
|
||||
2. Uploads `.cer` + `.key` files + enters password
|
||||
3. API validates the certificate (checks it's a valid FIEL, not expired)
|
||||
4. Encrypts and stores in both filesystem and database
|
||||
5. Sends notification email to admin team: "Cliente X subió su FIEL"
|
||||
|
||||
---
|
||||
|
||||
## Section 3: Payment System (MercadoPago)
|
||||
|
||||
### Integration approach
|
||||
|
||||
Using MercadoPago's **Preapproval (Subscription)** API for recurring payments.
|
||||
|
||||
### New tables in central DB
|
||||
|
||||
```sql
|
||||
CREATE TABLE subscriptions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||
plan VARCHAR(20) NOT NULL,
|
||||
mp_preapproval_id VARCHAR(100),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
-- status: pending | authorized | paused | cancelled
|
||||
amount DECIMAL(10,2) NOT NULL,
|
||||
frequency VARCHAR(10) NOT NULL DEFAULT 'monthly',
|
||||
-- frequency: monthly | yearly
|
||||
current_period_start TIMESTAMP,
|
||||
current_period_end TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_subscriptions_tenant_id ON subscriptions(tenant_id);
|
||||
CREATE INDEX idx_subscriptions_status ON subscriptions(status);
|
||||
|
||||
CREATE TABLE payments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||
subscription_id UUID REFERENCES subscriptions(id),
|
||||
mp_payment_id VARCHAR(100),
|
||||
amount DECIMAL(10,2) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
-- status: approved | pending | rejected | refunded
|
||||
payment_method VARCHAR(50),
|
||||
paid_at TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_payments_tenant_id ON payments(tenant_id);
|
||||
CREATE INDEX idx_payments_subscription_id ON payments(subscription_id);
|
||||
```
|
||||
|
||||
### Plans and pricing
|
||||
|
||||
Defined in `packages/shared/src/constants/plans.ts` (update existing):
|
||||
|
||||
| Plan | Monthly price (MXN) | CFDIs | Users | Features |
|
||||
|------|---------------------|-------|-------|----------|
|
||||
| starter | Configurable | 100 | 1 | dashboard, cfdi_basic, iva_isr |
|
||||
| business | Configurable | 500 | 3 | + reportes, alertas, calendario |
|
||||
| professional | Configurable | 2,000 | 10 | + xml_sat, conciliacion, forecasting |
|
||||
| enterprise | Configurable | Unlimited | Unlimited | + api, multi_empresa |
|
||||
|
||||
Prices are configured from admin panel, not hardcoded.
|
||||
|
||||
### Subscription flow
|
||||
|
||||
1. Admin creates tenant and assigns plan
|
||||
2. Admin clicks "Generate payment link" → API creates MercadoPago Preapproval
|
||||
3. Link is sent to client via email
|
||||
4. Client pays → MercadoPago sends webhook
|
||||
5. System activates subscription, records payment
|
||||
|
||||
### Webhook endpoint
|
||||
|
||||
`POST /api/webhooks/mercadopago` (public, no auth)
|
||||
|
||||
Validates webhook signature using `x-signature` header and `x-request-id`.
|
||||
|
||||
Events handled:
|
||||
- `payment` → query MercadoPago API for payment details → insert into `payments`, update subscription period
|
||||
- `subscription_preapproval` → update subscription status (authorized, paused, cancelled)
|
||||
|
||||
On payment failure or subscription cancellation:
|
||||
- Mark tenant `active = false`
|
||||
- Client gets read-only access (can view data but not upload CFDIs, generate reports, etc.)
|
||||
|
||||
### Admin panel additions
|
||||
|
||||
- View subscription status per client (active, amount, next billing date)
|
||||
- Generate payment link button
|
||||
- "Mark as paid manually" button (for bank transfer payments)
|
||||
- Payment history per client
|
||||
|
||||
### Client panel additions
|
||||
|
||||
- New section in `/configuracion`: "Mi suscripción"
|
||||
- Shows: current plan, next billing date, payment history
|
||||
- Client cannot change plan themselves (admin does it)
|
||||
|
||||
### Environment variables
|
||||
|
||||
```
|
||||
MP_ACCESS_TOKEN=<mercadopago_access_token>
|
||||
MP_WEBHOOK_SECRET=<webhook_signature_secret>
|
||||
MP_NOTIFICATION_URL=https://horux360.horux360.com/api/webhooks/mercadopago
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Section 4: Transactional Emails
|
||||
|
||||
### Transport
|
||||
|
||||
Nodemailer with Gmail SMTP (Google Workspace).
|
||||
|
||||
```
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=<user>@horuxfin.com
|
||||
SMTP_PASS=<google_app_password>
|
||||
SMTP_FROM=Horux360 <noreply@horuxfin.com>
|
||||
```
|
||||
|
||||
Requires generating an App Password in Google Workspace admin.
|
||||
|
||||
### Email types
|
||||
|
||||
| Event | Recipient | Subject |
|
||||
|-------|-----------|---------|
|
||||
| Client registered | Client | Bienvenido a Horux360 |
|
||||
| FIEL uploaded | Admin team | [Cliente] subió su FIEL |
|
||||
| Payment received | Client | Confirmación de pago - Horux360 |
|
||||
| Payment failed | Client + Admin | Problema con tu pago - Horux360 |
|
||||
| Subscription expiring | Client | Tu suscripción vence en 5 días |
|
||||
| Subscription cancelled | Client + Admin | Suscripción cancelada - Horux360 |
|
||||
|
||||
### Template approach
|
||||
|
||||
HTML templates as TypeScript template literal functions. No external template engine.
|
||||
|
||||
```typescript
|
||||
// services/email/templates/welcome.ts
|
||||
export function welcomeEmail(data: { nombre: string; email: string; tempPassword: string; loginUrl: string }): string {
|
||||
return `<!DOCTYPE html>...`;
|
||||
}
|
||||
```
|
||||
|
||||
Each template:
|
||||
- Responsive HTML email (inline CSS)
|
||||
- Horux360 branding (logo, colors)
|
||||
- Plain text fallback
|
||||
|
||||
### Email service
|
||||
|
||||
```typescript
|
||||
class EmailService {
|
||||
sendWelcome(to: string, data: WelcomeData): Promise<void>;
|
||||
sendFielNotification(data: FielNotificationData): Promise<void>;
|
||||
sendPaymentConfirmation(to: string, data: PaymentData): Promise<void>;
|
||||
sendPaymentFailed(to: string, data: PaymentData): Promise<void>;
|
||||
sendSubscriptionExpiring(to: string, data: SubscriptionData): Promise<void>;
|
||||
sendSubscriptionCancelled(to: string, data: SubscriptionData): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
### Limits
|
||||
|
||||
Gmail Workspace: 500 emails/day. Expected volume for 25 clients: ~50-100 emails/month. Well within limits.
|
||||
|
||||
---
|
||||
|
||||
## Section 5: Production Deployment
|
||||
|
||||
### Build pipeline
|
||||
|
||||
**API:**
|
||||
```bash
|
||||
cd apps/api && pnpm build # tsc → dist/
|
||||
pnpm start # node dist/index.js
|
||||
```
|
||||
|
||||
**Web:**
|
||||
```bash
|
||||
cd apps/web && pnpm build # next build → .next/
|
||||
pnpm start # next start (optimized server)
|
||||
```
|
||||
|
||||
### PM2 configuration
|
||||
|
||||
```javascript
|
||||
// ecosystem.config.js
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'horux-api',
|
||||
script: 'dist/index.js',
|
||||
cwd: '/root/Horux/apps/api',
|
||||
instances: 2,
|
||||
exec_mode: 'cluster',
|
||||
env: { NODE_ENV: 'production' }
|
||||
},
|
||||
{
|
||||
name: 'horux-web',
|
||||
script: 'node_modules/.bin/next',
|
||||
args: 'start',
|
||||
cwd: '/root/Horux/apps/web',
|
||||
instances: 1,
|
||||
exec_mode: 'fork',
|
||||
env: { NODE_ENV: 'production' }
|
||||
}
|
||||
]
|
||||
};
|
||||
```
|
||||
|
||||
Auto-restart on crash. Log rotation via `pm2-logrotate`.
|
||||
|
||||
### Nginx reverse proxy
|
||||
|
||||
```nginx
|
||||
# Rate limiting zone definitions (in http block of nginx.conf)
|
||||
limit_req_zone $binary_remote_addr zone=auth:10m rate=5r/m;
|
||||
limit_req_zone $binary_remote_addr zone=webhooks:10m rate=30r/m;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name horux360.horux360.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name horux360.horux360.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/horux360.horux360.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/horux360.horux360.com/privkey.pem;
|
||||
|
||||
# Security headers
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Frame-Options DENY;
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
|
||||
# Gzip
|
||||
gzip on;
|
||||
gzip_types text/plain application/json application/javascript text/css;
|
||||
|
||||
# Health check (for monitoring)
|
||||
location /api/health {
|
||||
proxy_pass http://127.0.0.1:4000;
|
||||
}
|
||||
|
||||
# Rate limiting for public endpoints
|
||||
location /api/auth/ {
|
||||
limit_req zone=auth burst=5 nodelay;
|
||||
proxy_pass http://127.0.0.1:4000;
|
||||
}
|
||||
|
||||
location /api/webhooks/ {
|
||||
limit_req zone=webhooks burst=10 nodelay;
|
||||
proxy_pass http://127.0.0.1:4000;
|
||||
}
|
||||
|
||||
# API
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:4000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
client_max_body_size 200M; # Bulk XML uploads (200MB is enough for ~50k XML files)
|
||||
}
|
||||
|
||||
# Next.js
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Health check endpoint
|
||||
|
||||
The existing `GET /health` endpoint returns `{ status: 'ok', timestamp }`. PM2 uses this for liveness checks. Nginx can optionally use it for upstream health monitoring.
|
||||
|
||||
### SSL
|
||||
|
||||
Let's Encrypt with certbot. Auto-renewal via cron.
|
||||
|
||||
```bash
|
||||
certbot --nginx -d horux360.horux360.com
|
||||
```
|
||||
|
||||
### Firewall
|
||||
|
||||
```bash
|
||||
ufw allow 22/tcp # SSH
|
||||
ufw allow 80/tcp # HTTP (redirect to HTTPS)
|
||||
ufw allow 443/tcp # HTTPS
|
||||
ufw enable
|
||||
```
|
||||
|
||||
PostgreSQL only on localhost (no external access).
|
||||
|
||||
### Backups
|
||||
|
||||
Cron job at **1:00 AM** daily (runs before SAT cron at 3:00 AM, with enough gap to complete):
|
||||
|
||||
**Authentication:** Create a `.pgpass` file at `/root/.pgpass` with `localhost:5432:*:postgres:<password>` and `chmod 600`. This allows `pg_dump` to authenticate without inline passwords.
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# /var/horux/scripts/backup.sh
|
||||
set -euo pipefail
|
||||
|
||||
BACKUP_DIR=/var/horux/backups
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
DOW=$(date +%u) # Day of week: 1=Monday, 7=Sunday
|
||||
DAILY_DIR=$BACKUP_DIR/daily
|
||||
WEEKLY_DIR=$BACKUP_DIR/weekly
|
||||
|
||||
mkdir -p $DAILY_DIR $WEEKLY_DIR
|
||||
|
||||
# Backup central DB
|
||||
pg_dump -h localhost -U postgres horux360 | gzip > $DAILY_DIR/horux360_$DATE.sql.gz
|
||||
|
||||
# Backup each tenant DB
|
||||
for db in $(psql -h localhost -U postgres -t -c "SELECT database_name FROM tenants WHERE database_name IS NOT NULL" horux360); do
|
||||
db_trimmed=$(echo $db | xargs) # trim whitespace
|
||||
pg_dump -h localhost -U postgres "$db_trimmed" | gzip > $DAILY_DIR/${db_trimmed}_${DATE}.sql.gz
|
||||
done
|
||||
|
||||
# On Sundays, copy to weekly directory
|
||||
if [ "$DOW" -eq 7 ]; then
|
||||
cp $DAILY_DIR/*_${DATE}.sql.gz $WEEKLY_DIR/
|
||||
fi
|
||||
|
||||
# Remove daily backups older than 7 days
|
||||
find $DAILY_DIR -name "*.sql.gz" -mtime +7 -delete
|
||||
|
||||
# Remove weekly backups older than 28 days
|
||||
find $WEEKLY_DIR -name "*.sql.gz" -mtime +28 -delete
|
||||
|
||||
# Verify backup files are not empty (catch silent pg_dump failures)
|
||||
for f in $DAILY_DIR/*_${DATE}.sql.gz; do
|
||||
if [ ! -s "$f" ]; then
|
||||
echo "WARNING: Empty backup file: $f" >&2
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
**Schedule separation:** Backups run at 1:00 AM, SAT cron runs at 3:00 AM. With 50 clients, backup should complete in ~15-30 minutes, leaving ample gap before SAT sync starts.
|
||||
|
||||
### Environment variables (production)
|
||||
|
||||
```
|
||||
NODE_ENV=production
|
||||
PORT=4000
|
||||
DATABASE_URL=postgresql://postgres:<strong_password>@localhost:5432/horux360?schema=public
|
||||
JWT_SECRET=<cryptographically_secure_random_64_chars>
|
||||
JWT_EXPIRES_IN=24h
|
||||
JWT_REFRESH_EXPIRES_IN=30d
|
||||
CORS_ORIGIN=https://horux360.horux360.com
|
||||
FIEL_ENCRYPTION_KEY=<separate_32_byte_hex_key>
|
||||
MP_ACCESS_TOKEN=<mercadopago_production_token>
|
||||
MP_WEBHOOK_SECRET=<webhook_secret>
|
||||
MP_NOTIFICATION_URL=https://horux360.horux360.com/api/webhooks/mercadopago
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=<user>@horuxfin.com
|
||||
SMTP_PASS=<google_app_password>
|
||||
SMTP_FROM=Horux360 <noreply@horuxfin.com>
|
||||
ADMIN_EMAIL=admin@horuxfin.com
|
||||
```
|
||||
|
||||
### SAT cron
|
||||
|
||||
Already implemented. Runs at 3:00 AM when `NODE_ENV=production`. Will activate automatically with the environment change.
|
||||
|
||||
---
|
||||
|
||||
## Section 6: Plan Enforcement & Feature Gating
|
||||
|
||||
### Enforcement middleware
|
||||
|
||||
```typescript
|
||||
// middleware: checkPlanLimits
|
||||
async function checkPlanLimits(req, res, next) {
|
||||
const tenant = await getTenantWithCache(req.user.tenantId); // cached 5 min
|
||||
const subscription = await getActiveSubscription(tenant.id);
|
||||
|
||||
// Admin-impersonated requests bypass subscription check
|
||||
// (admin needs to complete client setup regardless of payment status)
|
||||
if (req.headers['x-view-tenant'] && req.user.role === 'admin') {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Allowed statuses: 'authorized' (paid) or 'pending' (grace period for new clients)
|
||||
const allowedStatuses = ['authorized', 'pending'];
|
||||
|
||||
// Check subscription status
|
||||
if (!subscription || !allowedStatuses.includes(subscription.status)) {
|
||||
// Allow read-only access for cancelled/paused subscriptions
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(403).json({
|
||||
message: 'Suscripción inactiva. Contacta soporte para reactivar.'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
```
|
||||
|
||||
**Grace period:** New clients start with `status: 'pending'` and have full write access (can upload FIEL, upload CFDIs, etc.). Once the subscription moves to `'cancelled'` or `'paused'` (e.g., failed payment), write access is revoked. Admin can also manually set status to `'authorized'` for clients who pay by bank transfer.
|
||||
|
||||
### CFDI limit check
|
||||
|
||||
Applied on `POST /api/cfdi/` and `POST /api/cfdi/bulk`:
|
||||
|
||||
```typescript
|
||||
async function checkCfdiLimit(req, res, next) {
|
||||
const tenant = await getTenantWithCache(req.user.tenantId);
|
||||
if (tenant.cfdiLimit === -1) return next(); // unlimited
|
||||
|
||||
const currentCount = await getCfdiCountWithCache(req.tenantPool); // cached 5 min
|
||||
const newCount = Array.isArray(req.body) ? req.body.length : 1;
|
||||
|
||||
if (currentCount + newCount > tenant.cfdiLimit) {
|
||||
return res.status(403).json({
|
||||
message: `Límite de CFDIs alcanzado (${currentCount}/${tenant.cfdiLimit}). Contacta soporte para upgrade.`
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
```
|
||||
|
||||
### User limit check
|
||||
|
||||
Applied on `POST /api/usuarios/invite` (already partially exists):
|
||||
|
||||
```typescript
|
||||
const userCount = await getUserCountForTenant(tenantId);
|
||||
if (userCount >= tenant.usersLimit && tenant.usersLimit !== -1) {
|
||||
return res.status(403).json({
|
||||
message: `Límite de usuarios alcanzado (${userCount}/${tenant.usersLimit}).`
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Feature gating
|
||||
|
||||
Applied per route using the existing `hasFeature()` function from shared:
|
||||
|
||||
```typescript
|
||||
function requireFeature(feature: string) {
|
||||
return async (req, res, next) => {
|
||||
const tenant = await getTenantWithCache(req.user.tenantId);
|
||||
if (!hasFeature(tenant.plan, feature)) {
|
||||
return res.status(403).json({
|
||||
message: 'Tu plan no incluye esta función. Contacta soporte para upgrade.'
|
||||
});
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
// Usage in routes:
|
||||
router.get('/reportes', authenticate, requireFeature('reportes'), reportesController);
|
||||
router.get('/alertas', authenticate, requireFeature('alertas'), alertasController);
|
||||
```
|
||||
|
||||
### Feature matrix
|
||||
|
||||
| Feature key | Starter | Business | Professional | Enterprise |
|
||||
|-------------|---------|----------|-------------|------------|
|
||||
| dashboard | Yes | Yes | Yes | Yes |
|
||||
| cfdi_basic | Yes | Yes | Yes | Yes |
|
||||
| iva_isr | Yes | Yes | Yes | Yes |
|
||||
| reportes | No | Yes | Yes | Yes |
|
||||
| alertas | No | Yes | Yes | Yes |
|
||||
| calendario | No | Yes | Yes | Yes |
|
||||
| xml_sat | No | No | Yes | Yes |
|
||||
| conciliacion | No | No | Yes | Yes |
|
||||
| forecasting | No | No | Yes | Yes |
|
||||
| multi_empresa | No | No | No | Yes |
|
||||
| api_externa | No | No | No | Yes |
|
||||
|
||||
### Frontend feature gating
|
||||
|
||||
The sidebar/navigation hides menu items based on plan:
|
||||
|
||||
```typescript
|
||||
const tenant = useTenantInfo(); // new hook
|
||||
const menuItems = allMenuItems.filter(item =>
|
||||
!item.requiredFeature || hasFeature(tenant.plan, item.requiredFeature)
|
||||
);
|
||||
```
|
||||
|
||||
Pages also show an "upgrade" message if accessed directly via URL without the required plan.
|
||||
|
||||
### Caching
|
||||
|
||||
Plan checks and CFDI counts are cached in-memory with 5-minute TTL to avoid database queries on every request.
|
||||
|
||||
**Cache invalidation across PM2 workers:** Since each PM2 cluster worker has its own in-memory cache, subscription status changes (via webhook) must invalidate the cache in all workers. The webhook handler writes the status to the DB, then sends a `process.send()` message to the PM2 master which broadcasts to all workers to invalidate the specific tenant's cache entry. This ensures all workers reflect subscription changes within seconds, not minutes.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Diagram
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ Nginx (443/80) │
|
||||
│ SSL + Rate Limit │
|
||||
└──────────┬──────────┘
|
||||
│
|
||||
┌──────────────┼──────────────┐
|
||||
│ │ │
|
||||
┌─────▼─────┐ ┌────▼────┐ ┌──────▼──────┐
|
||||
│ Next.js │ │ Express │ │ Webhook │
|
||||
│ :3000 │ │ API x2 │ │ Handler │
|
||||
│ (fork) │ │ :4000 │ │ (no auth) │
|
||||
└───────────┘ │ (cluster)│ └──────┬──────┘
|
||||
└────┬────┘ │
|
||||
│ │
|
||||
┌─────────▼──────────┐ │
|
||||
│ TenantConnection │ │
|
||||
│ Manager │ │
|
||||
│ (pool per tenant) │ │
|
||||
└─────────┬──────────┘ │
|
||||
│ │
|
||||
┌──────────────────┼──────┐ │
|
||||
│ │ │ │
|
||||
┌─────▼─────┐ ┌───────▼┐ ┌──▼──┐ │
|
||||
│ horux360 │ │horux_ │ │horux│ │
|
||||
│ (central) │ │client1 │ │_... │ │
|
||||
│ │ └────────┘ └─────┘ │
|
||||
│ tenants │ │
|
||||
│ users │◄────────────────────────┘
|
||||
│ subs │ (webhook updates)
|
||||
│ payments │
|
||||
└───────────┘
|
||||
|
||||
┌───────────────┐ ┌─────────────┐
|
||||
│ /var/horux/ │ │ Gmail SMTP │
|
||||
│ fiel/<rfc>/ │ │ @horuxfin │
|
||||
│ backups/ │ └─────────────┘
|
||||
└───────────────┘
|
||||
|
||||
┌───────────────┐
|
||||
│ MercadoPago │
|
||||
│ Preapproval │
|
||||
│ API │
|
||||
└───────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Landing page (already exists separately)
|
||||
- Self-service registration (clients are registered manually by admin)
|
||||
- Automatic SAT connector (manual FIEL linking for now)
|
||||
- Plan change by client (admin handles upgrades/downgrades)
|
||||
- Mobile app
|
||||
- Multi-region deployment
|
||||
219
docs/superpowers/specs/2026-04-12-conciliacion-design.md
Normal file
219
docs/superpowers/specs/2026-04-12-conciliacion-design.md
Normal file
@@ -0,0 +1,219 @@
|
||||
# Modulo de Conciliacion — Spec
|
||||
|
||||
**Fecha:** 2026-04-12
|
||||
**Estado:** Aprobado
|
||||
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Permitir al usuario conciliar CFDIs emitidos y recibidos mes a mes, registrando fecha de pago y banco. Solo se permite conciliar del ano actual en adelante.
|
||||
|
||||
---
|
||||
|
||||
## Modelo de datos (BD tenant — raw SQL)
|
||||
|
||||
### Tabla `bancos` (nueva)
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS bancos (
|
||||
id SERIAL PRIMARY KEY,
|
||||
banco VARCHAR(100) NOT NULL,
|
||||
terminacion_cuenta VARCHAR(4) NOT NULL,
|
||||
creado_en TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
### Tabla `conciliaciones` (nueva)
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS conciliaciones (
|
||||
id SERIAL PRIMARY KEY,
|
||||
anio VARCHAR(4) NOT NULL,
|
||||
mes VARCHAR(2) NOT NULL,
|
||||
id_cfdi INTEGER NOT NULL UNIQUE REFERENCES cfdis(id),
|
||||
fecha_de_pago DATE NOT NULL,
|
||||
id_banco INTEGER NOT NULL REFERENCES bancos(id),
|
||||
creado_en TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_conciliaciones_anio_mes ON conciliaciones(anio, mes);
|
||||
CREATE INDEX IF NOT EXISTS idx_conciliaciones_id_cfdi ON conciliaciones(id_cfdi);
|
||||
```
|
||||
|
||||
### Columnas en `cfdis`
|
||||
|
||||
- `conciliado VARCHAR(50)` — ya existe. Se actualiza a `'true'` al conciliar, `NULL` al desconciliar.
|
||||
- `id_conciliacion INTEGER REFERENCES conciliaciones(id)` — nueva. FK a la conciliacion asociada. NULL si no conciliado.
|
||||
|
||||
Al conciliar: se crean registros en `conciliaciones`, se actualiza `cfdis.conciliado = 'true'` y `cfdis.id_conciliacion = conciliaciones.id`.
|
||||
Al desconciliar: se pone `cfdis.conciliado = NULL`, `cfdis.id_conciliacion = NULL`, y se elimina el registro de `conciliaciones`.
|
||||
|
||||
### DDL para tenants nuevos
|
||||
|
||||
Agregar `bancos`, `conciliaciones` en `database.ts` -> `createTables()` despues de `alertas`.
|
||||
Agregar `id_conciliacion INTEGER REFERENCES conciliaciones(id)` en la tabla `cfdis`.
|
||||
|
||||
### Migracion para tenants existentes
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS` para `bancos` y `conciliaciones`, luego `ALTER TABLE cfdis ADD COLUMN IF NOT EXISTS id_conciliacion INTEGER REFERENCES conciliaciones(id)`.
|
||||
|
||||
---
|
||||
|
||||
## Reglas de negocio
|
||||
|
||||
1. Solo se concilian CFDIs del **ano de alta del tenant en adelante** (se obtiene del `createdAt` del tenant en la BD central). Esto permite que una empresa registrada en 2025 pueda conciliar 2025, 2026, etc.
|
||||
2. `anio` y `mes` de `conciliaciones` se derivan automaticamente de `fecha_de_pago`.
|
||||
3. Un CFDI solo puede tener una conciliacion (`id_cfdi` es UNIQUE en conciliaciones, `id_conciliacion` en cfdis).
|
||||
4. Solo CFDIs vigentes (`status NOT IN ('Cancelado', '0')`).
|
||||
5. Al conciliar: INSERT en `conciliaciones` + UPDATE `cfdis` SET `conciliado = 'true'`, `id_conciliacion = <id>`.
|
||||
6. Al desconciliar: UPDATE `cfdis` SET `conciliado = NULL`, `id_conciliacion = NULL` + DELETE de `conciliaciones`.
|
||||
7. No se puede eliminar un banco que tenga conciliaciones asociadas.
|
||||
|
||||
---
|
||||
|
||||
## API endpoints
|
||||
|
||||
### Conciliacion
|
||||
|
||||
| Metodo | Ruta | Descripcion | Auth |
|
||||
|--------|------|-------------|------|
|
||||
| GET | `/conciliacion` | Lista CFDIs con estado de conciliacion | JWT + Tenant |
|
||||
| POST | `/conciliacion` | Conciliar CFDIs (batch) | JWT + Tenant + admin/contador |
|
||||
| DELETE | `/conciliacion/:id` | Desconciliar un CFDI | JWT + Tenant + admin/contador |
|
||||
|
||||
#### `GET /conciliacion`
|
||||
|
||||
**Query params:**
|
||||
- `tipo`: `EMITIDO` | `RECIBIDO` (requerido)
|
||||
- `fechaInicio`, `fechaFin`: rango de fecha de emision
|
||||
- `regimen`: clave de regimen fiscal (opcional)
|
||||
- `estado`: `conciliado` | `pendiente` (opcional, default: todos)
|
||||
|
||||
**Response:** Array de CFDIs con campo adicional `conciliacion` (null si pendiente, objeto si conciliado):
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"uuid": "...",
|
||||
"rfcEmisor": "...",
|
||||
"nombreEmisor": "...",
|
||||
"total": 1000,
|
||||
"totalMxn": 1000,
|
||||
"fechaEmision": "...",
|
||||
"conciliado": "true",
|
||||
"idConciliacion": 5,
|
||||
"conciliacion": {
|
||||
"id": 5,
|
||||
"fechaDePago": "2026-04-10",
|
||||
"banco": "BBVA",
|
||||
"terminacionCuenta": "1234"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `POST /conciliacion`
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{
|
||||
"cfdiIds": [1, 2, 3],
|
||||
"fechaDePago": "2026-04-10",
|
||||
"idBanco": 1
|
||||
}
|
||||
```
|
||||
|
||||
**Logica:**
|
||||
1. Validar que todos los CFDIs existen, estan vigentes, y no estan ya conciliados.
|
||||
2. Validar que `fechaDePago` es del ano actual en adelante.
|
||||
3. Derivar `anio` y `mes` de `fechaDePago`.
|
||||
4. Para cada CFDI: INSERT en `conciliaciones`, UPDATE `cfdis` SET `conciliado = 'true'`, `id_conciliacion = <new id>`.
|
||||
|
||||
#### `DELETE /conciliacion/:id`
|
||||
|
||||
1. Buscar la conciliacion por id.
|
||||
2. UPDATE `cfdis` SET `conciliado = NULL`, `id_conciliacion = NULL` WHERE `id_conciliacion = :id`.
|
||||
3. DELETE FROM `conciliaciones` WHERE `id = :id`.
|
||||
|
||||
### Bancos
|
||||
|
||||
| Metodo | Ruta | Descripcion | Auth |
|
||||
|--------|------|-------------|------|
|
||||
| GET | `/bancos` | Listar bancos del tenant | JWT + Tenant |
|
||||
| POST | `/bancos` | Crear banco | JWT + Tenant + admin |
|
||||
| PUT | `/bancos/:id` | Editar banco | JWT + Tenant + admin |
|
||||
| DELETE | `/bancos/:id` | Eliminar banco (si no tiene conciliaciones) | JWT + Tenant + admin |
|
||||
|
||||
---
|
||||
|
||||
## Frontend
|
||||
|
||||
### Pagina `/conciliacion`
|
||||
|
||||
**Acceso:** Feature-gated por `conciliacion` (Business, Enterprise). Roles: admin y contador (lectura+escritura), visor (solo lectura).
|
||||
|
||||
**Layout:**
|
||||
```
|
||||
[Header: "Conciliacion"]
|
||||
[Filtros: PeriodSelector | RegimenSelector]
|
||||
[Tabs: Emitidas | Recibidas]
|
||||
[Seccion: "Por conciliar" — tabla con checkboxes]
|
||||
[Barra de accion: Banco (dropdown) + Fecha de pago (date) + Boton "Conciliar"]
|
||||
[Seccion: "Conciliadas" — tabla con info de conciliacion + boton desconciliar]
|
||||
```
|
||||
|
||||
**Tabla "Por conciliar":**
|
||||
- Checkbox (no visible para visor)
|
||||
- UUID (corto), Fecha emision, RFC Emisor/Receptor, Nombre, Total MXN, Metodo Pago
|
||||
- Boton "Ver factura" (CfdiViewerModal)
|
||||
|
||||
**Tabla "Conciliadas":**
|
||||
- UUID, Fecha emision, RFC, Nombre, Total MXN
|
||||
- Fecha de pago, Banco (nombre + terminacion)
|
||||
- Boton "Desconciliar" (no visible para visor)
|
||||
- Boton "Ver factura"
|
||||
|
||||
**Flujo de conciliacion:**
|
||||
1. Usuario selecciona checkboxes en "Por conciliar"
|
||||
2. Aparece barra de accion sticky en la parte inferior
|
||||
3. Selecciona banco (dropdown de bancos del tenant) y fecha de pago
|
||||
4. Click "Conciliar N facturas"
|
||||
5. Confirmacion -> POST `/conciliacion` -> refresh datos
|
||||
|
||||
### Seccion de bancos en `/configuracion`
|
||||
|
||||
Solo visible para admin. Card con:
|
||||
- Lista de bancos existentes: Nombre + terminacion + boton eliminar
|
||||
- Formulario inline: Nombre banco + Terminacion (max 4 digitos) + boton agregar
|
||||
|
||||
### Navegacion
|
||||
|
||||
Agregar "Conciliacion" al sidebar con feature gate `conciliacion`, visible para admin, contador, visor. Ubicacion: despues de Reportes.
|
||||
|
||||
---
|
||||
|
||||
## Archivos a crear/modificar
|
||||
|
||||
### Backend (crear)
|
||||
- `apps/api/src/services/conciliacion.service.ts`
|
||||
- `apps/api/src/controllers/conciliacion.controller.ts`
|
||||
- `apps/api/src/routes/conciliacion.routes.ts`
|
||||
- `apps/api/src/services/bancos.service.ts`
|
||||
- `apps/api/src/controllers/bancos.controller.ts`
|
||||
- `apps/api/src/routes/bancos.routes.ts`
|
||||
|
||||
### Backend (modificar)
|
||||
- `apps/api/src/app.ts` — registrar rutas de conciliacion y bancos
|
||||
- `apps/api/src/config/database.ts` — agregar tablas `bancos` y `conciliaciones` en `createTables()`, agregar `id_conciliacion` en `cfdis`
|
||||
|
||||
### Frontend (crear)
|
||||
- `apps/web/app/(dashboard)/conciliacion/page.tsx`
|
||||
- `apps/web/lib/api/conciliacion.ts`
|
||||
- `apps/web/lib/api/bancos.ts`
|
||||
- `apps/web/lib/hooks/use-conciliacion.ts`
|
||||
- `apps/web/lib/hooks/use-bancos.ts`
|
||||
|
||||
### Frontend (modificar)
|
||||
- `apps/web/components/layouts/sidebar.tsx` (y variantes) — agregar nav item
|
||||
- `apps/web/app/(dashboard)/configuracion/page.tsx` — agregar seccion de bancos
|
||||
|
||||
### Migracion
|
||||
- Aplicar DDL a tenant existente (`horux_ede123456ab1`): crear tablas + agregar columna
|
||||
35
docs/superpowers/specs/2026-04-12-sat-sync-chunking.md
Normal file
35
docs/superpowers/specs/2026-04-12-sat-sync-chunking.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
title: Segmentación inteligente de solicitudes SAT
|
||||
status: implementado
|
||||
created: 2026-04-12
|
||||
---
|
||||
|
||||
# Segmentación Inteligente de Solicitudes SAT
|
||||
|
||||
## Problema actual
|
||||
|
||||
La sincronización segmenta mes por mes, generando 4 solicitudes SAT por mes (xml emitidos, xml recibidos, metadata emitidos, metadata recibidos). Para 6 años = 288 solicitudes, agotando la cuota diaria del SAT rápidamente.
|
||||
|
||||
## Lógica propuesta
|
||||
|
||||
1. **Primer paso: solicitud de metadata del rango completo** (una sola solicitud)
|
||||
- Obtener el total de CFDIs reportados por el SAT
|
||||
|
||||
2. **Decidir tamaño de bloque según volumen:**
|
||||
- `totalCfdis <= 15,000` → bloques de **6 meses**
|
||||
- `totalCfdis > 15,000` → bloques de **2 meses**
|
||||
|
||||
3. **Por cada bloque:**
|
||||
- Descargar XMLs vigentes (cfdi + DocumentStatus active)
|
||||
- Descargar metadata de todos (vigentes + cancelados)
|
||||
|
||||
## Impacto en solicitudes
|
||||
|
||||
| Escenario | Actual (mes a mes) | Propuesto (6 meses) | Propuesto (2 meses) |
|
||||
|-----------|-------------------|--------------------|--------------------|
|
||||
| 6 años, pocos CFDIs | 288 solicitudes | 25 solicitudes | 73 solicitudes |
|
||||
| 6 años, muchos CFDIs | 288 solicitudes | N/A | 73 solicitudes |
|
||||
|
||||
## Archivos a modificar
|
||||
|
||||
- `apps/api/src/services/sat/sat.service.ts` → `processInitialSync()`
|
||||
186
docs/superpowers/specs/2026-04-13-opinion-cumplimiento-design.md
Normal file
186
docs/superpowers/specs/2026-04-13-opinion-cumplimiento-design.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# Opinión de Cumplimiento — Integration Design
|
||||
|
||||
**Date:** 2026-04-13
|
||||
**Status:** Approved
|
||||
|
||||
## Problem
|
||||
|
||||
Horux360 has no way to check or track a tenant's SAT compliance status (Opinión de Cumplimiento). This is a critical fiscal document that indicates whether a company is current on all tax obligations. Currently, users must manually download it from the SAT portal.
|
||||
|
||||
## Solution
|
||||
|
||||
Integrate the existing standalone Playwright-based prototype into Horux360 as a weekly automated process. Display results in a new "Documentos" page accessible to all roles (business+ plans). Store last 6 months in DB, show last 5 in UI. Alert if status is not Positiva.
|
||||
|
||||
## Source Prototype
|
||||
|
||||
Located at `C:\Users\chtr1\Downloads\sat-opinion-prototype`. Key files to adapt:
|
||||
- `src/sat-login.ts` — Playwright navigation: public page → FIEL login → report
|
||||
- `src/opinion-scraper.ts` — 4 strategies to extract PDF base64 from DOM
|
||||
- `src/pdf-parser.ts` — Regex extraction of RFC, razón social, estatus, folio, cadena original
|
||||
- `src/types.ts` — `OpinionCumplimiento`, `Obligacion` interfaces
|
||||
|
||||
## Architecture
|
||||
|
||||
### New Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/services/opinion-cumplimiento.service.ts` | Orchestration: decrypt FIEL → temp files → Playwright → parse → save to DB → cleanup |
|
||||
| `src/services/sat/sat-opinion-login.ts` | Adapted sat-login.ts: works with temp file paths from decrypted FIEL Buffers |
|
||||
| `src/services/sat/sat-opinion-scraper.ts` | Adapted opinion-scraper.ts: extracts PDF from SAT Angular SPA |
|
||||
| `src/services/sat/sat-opinion-parser.ts` | Adapted pdf-parser.ts: regex extraction from PDF text |
|
||||
| `src/controllers/documentos.controller.ts` | Endpoints: list opinions, download PDF, manual trigger |
|
||||
| `src/routes/documentos.routes.ts` | Routes with tenantMiddleware + feature gate |
|
||||
| `src/migrations/tenant/002_create_opiniones_cumplimiento.sql` | Tenant DB migration |
|
||||
| `apps/web/app/(dashboard)/documentos/page.tsx` | Frontend: Documentos page with Opinión tab |
|
||||
| `apps/web/lib/api/documentos.ts` | API client functions |
|
||||
| `apps/web/lib/hooks/use-documentos.ts` | React Query hooks |
|
||||
|
||||
### Modified Files
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/jobs/sat-sync.job.ts` | Add weekly cron for opinion download |
|
||||
| `src/services/alertas-auto.service.ts` | Add alert for non-Positiva status |
|
||||
| `apps/web/components/layouts/sidebar.tsx` | Add Documentos nav item |
|
||||
| `apps/api/package.json` | Add playwright, pdf-parse dependencies |
|
||||
| `packages/shared/src/types/` | Add OpinionCumplimiento types |
|
||||
|
||||
## Database
|
||||
|
||||
### Table: `opiniones_cumplimiento` (per-tenant DB)
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS opiniones_cumplimiento (
|
||||
id SERIAL PRIMARY KEY,
|
||||
rfc VARCHAR(14) NOT NULL,
|
||||
razon_social VARCHAR(255),
|
||||
estatus VARCHAR(50) NOT NULL,
|
||||
folio VARCHAR(50),
|
||||
cadena_original TEXT,
|
||||
fecha_consulta TIMESTAMP NOT NULL,
|
||||
pdf BYTEA NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_opiniones_fecha ON opiniones_cumplimiento(fecha_consulta DESC);
|
||||
```
|
||||
|
||||
Migration file: `002_create_opiniones_cumplimiento.sql`
|
||||
|
||||
**Retention:** Records older than 6 months are deleted during the weekly cron run.
|
||||
**UI display:** Only the last 5 records are shown via `ORDER BY fecha_consulta DESC LIMIT 5`.
|
||||
|
||||
## FIEL Security
|
||||
|
||||
The FIEL is stored encrypted (AES-256-GCM) in the central DB. For Playwright, which requires file paths:
|
||||
|
||||
1. `getDecryptedFiel(tenantId)` returns Buffers in memory
|
||||
2. Write .cer and .key to `os.tmpdir()` with permissions `0o600`
|
||||
3. Pass paths to Playwright `page.setInputFiles()`
|
||||
4. Delete temp files in `finally` block (guaranteed cleanup even on error)
|
||||
5. Password is only passed via `page.fill()` — never written to disk
|
||||
|
||||
Additional:
|
||||
- Playwright runs headless in production (no `slowMo`)
|
||||
- 3-minute timeout per tenant to prevent hanging processes
|
||||
- Temp file names use `crypto.randomUUID()` to avoid collisions
|
||||
|
||||
## Cron Schedule
|
||||
|
||||
```
|
||||
'0 4 * * 0' — Sundays 4:00 AM (America/Mexico_City)
|
||||
```
|
||||
|
||||
Runs after the daily SAT sync (3:00 AM) to avoid overlap. Processes tenants sequentially (Playwright is heavy — no parallelism).
|
||||
|
||||
### Cron Flow
|
||||
|
||||
For each active tenant with FIEL configured:
|
||||
1. Decrypt FIEL → write temp files
|
||||
2. Launch Playwright headless → login → navigate to report
|
||||
3. Extract PDF base64 from DOM → parse text
|
||||
4. INSERT into `opiniones_cumplimiento`
|
||||
5. DELETE records older than 6 months
|
||||
6. Cleanup temp files
|
||||
7. Close browser
|
||||
|
||||
Error handling: if one tenant fails, log error and continue to next. Don't stop the batch.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Route | Auth | Description |
|
||||
|--------|-------|------|-------------|
|
||||
| GET | `/api/documentos/opiniones` | All roles | Last 5 opinions (metadata only, no PDF binary) |
|
||||
| GET | `/api/documentos/opiniones/:id/pdf` | All roles | Download PDF as binary (Content-Type: application/pdf) |
|
||||
| POST | `/api/documentos/opiniones/consultar` | Admin only | Trigger manual download for current tenant |
|
||||
|
||||
All routes use `tenantMiddleware` + `requireFeature('documentos')`.
|
||||
|
||||
### GET /api/documentos/opiniones response
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"rfc": "HTS240708LJA",
|
||||
"razonSocial": "HORUX 360 SA DE CV",
|
||||
"estatus": "Positiva",
|
||||
"folio": "26NC4144337",
|
||||
"cadenaOriginal": "||HTS240708LJA|26NC4144337|...",
|
||||
"fechaConsulta": "2026-04-13T20:59:00.000Z",
|
||||
"createdAt": "2026-04-13T22:00:00.000Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Auto-Alert
|
||||
|
||||
New alert in `alertas-auto.service.ts`:
|
||||
|
||||
```typescript
|
||||
async function alertaOpinionCumplimiento(pool: Pool): Promise<AlertaAuto | null>
|
||||
```
|
||||
|
||||
- Queries latest record from `opiniones_cumplimiento`
|
||||
- If `estatus !== 'Positiva'` → returns alert with priority 'alta'
|
||||
- Message: "Tu Opinión de Cumplimiento es {estatus}. Última consulta: {fecha}."
|
||||
- No drill-down (Documentos page shows details)
|
||||
- If no records exist → no alert
|
||||
|
||||
## Frontend
|
||||
|
||||
### Sidebar
|
||||
|
||||
```typescript
|
||||
{ name: 'Documentos', href: '/documentos', icon: FileCheck, feature: 'documentos' }
|
||||
```
|
||||
|
||||
Between Facturación and Usuarios in the navigation array. Visible to all roles.
|
||||
|
||||
### Page: `/documentos`
|
||||
|
||||
- Tab structure (future-proof for other document types): first tab "Opinión de Cumplimiento"
|
||||
- Card/row per opinion showing:
|
||||
- Fecha de consulta (formatted)
|
||||
- Estatus badge (green=Positiva, red=Negativa, yellow=others)
|
||||
- Folio
|
||||
- Button to download PDF
|
||||
- "Consultar ahora" button (admin only) triggers POST
|
||||
- Empty state: "No hay opiniones registradas. La consulta automática se ejecuta cada semana."
|
||||
- Loading/error states with React Query
|
||||
|
||||
### Dependencies
|
||||
|
||||
Add to `apps/api/package.json`:
|
||||
- `playwright` (for Chromium automation)
|
||||
- `pdf-parse` v2 (for PDF text extraction)
|
||||
|
||||
Post-install: `npx playwright install chromium` (needed on deploy)
|
||||
|
||||
## Scope Exclusions
|
||||
|
||||
- Parser for "Negativa" opinion obligations list (refine when sample PDF available)
|
||||
- Email notifications on status change (only auto-alert for now)
|
||||
- Multiple document types in the Documentos page (only Opinión de Cumplimiento in v1)
|
||||
- PDF viewer in browser (download only)
|
||||
106
docs/superpowers/specs/2026-04-13-tenant-migrations-design.md
Normal file
106
docs/superpowers/specs/2026-04-13-tenant-migrations-design.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Tenant Schema Migrations System
|
||||
|
||||
**Date:** 2026-04-13
|
||||
**Status:** Approved
|
||||
|
||||
## Problem
|
||||
|
||||
Horux360 uses a database-per-tenant architecture. When schema changes are made to `createTables()` or `createIndexes()` in `TenantConnectionManager`, only newly provisioned tenants get the updated schema. Existing tenants' databases drift from the expected structure, requiring manual ALTER scripts.
|
||||
|
||||
## Solution
|
||||
|
||||
A numbered SQL migration system for tenant databases, with both eager (deploy-time) and lazy (on-connect) execution.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Migration Files
|
||||
|
||||
```
|
||||
apps/api/src/migrations/tenant/
|
||||
001_initial_schema.sql # Current createTables() + createIndexes()
|
||||
002_example_future.sql # Template for future changes
|
||||
```
|
||||
|
||||
- Naming: `NNN_description.sql` (zero-padded 3 digits)
|
||||
- Each file must be idempotent (use `IF NOT EXISTS`, `ADD COLUMN IF NOT EXISTS`, etc.)
|
||||
- Files are read from disk at runtime, sorted by version number
|
||||
|
||||
### Schema Migrations Table (per tenant DB)
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
applied_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
Created automatically before running any migration.
|
||||
|
||||
### TenantMigrationRunner
|
||||
|
||||
New file: `apps/api/src/config/tenant-migrations.ts`
|
||||
|
||||
**Exported functions:**
|
||||
|
||||
- `getMigrationFiles()` — Reads and sorts SQL files from migrations directory
|
||||
- `getPendingMigrations(pool)` — Compares files vs `schema_migrations` table, returns pending
|
||||
- `migrate(pool, databaseName?)` — Applies pending migrations in order, each in its own transaction. Returns count of applied migrations.
|
||||
- `migrateAll()` — Queries all active tenants from central DB, calls `migrate()` on each. Logs progress and errors per tenant. Does not stop on individual tenant failure.
|
||||
|
||||
### Integration Points
|
||||
|
||||
1. **`TenantConnectionManager.provisionDatabase()`** — Replace `createTables()` + `createIndexes()` calls with `migrate(pool)`. This applies all migrations (starting from 001) to new tenants.
|
||||
|
||||
2. **`TenantConnectionManager.getPool()`** — After creating or retrieving a pool, call `migrate(pool)` if not already verified this session. Uses `migratedPools: Set<string>` to cache which tenants have been checked. Cache clears on process restart.
|
||||
|
||||
3. **New Turborepo script `db:migrate-tenants`** — Runs `migrateAll()` for eager deployment. Added to `apps/api/package.json` and root `turbo.json`.
|
||||
|
||||
4. **`createTables()` and `createIndexes()`** — Removed from `TenantConnectionManager`. Their content moves to `001_initial_schema.sql`.
|
||||
|
||||
### Lazy Migration Cache
|
||||
|
||||
```typescript
|
||||
// In TenantConnectionManager
|
||||
private migratedPools: Set<string> = new Set();
|
||||
```
|
||||
|
||||
- `getPool()` checks `migratedPools.has(tenantId)` before running migrations
|
||||
- If not in set → run `migrate(pool)` → add to set
|
||||
- Set clears on PM2 restart (new process = fresh set)
|
||||
- `invalidatePool()` also removes from `migratedPools`
|
||||
|
||||
### Deploy Flow
|
||||
|
||||
```bash
|
||||
git pull
|
||||
pnpm install
|
||||
pnpm build
|
||||
pnpm db:migrate-tenants # Eager: apply to all tenants
|
||||
pm2 restart all # Lazy: safety net on connect
|
||||
```
|
||||
|
||||
### Adding Future Schema Changes
|
||||
|
||||
1. Create `NNN_description.sql` in `apps/api/src/migrations/tenant/`
|
||||
2. Write idempotent SQL
|
||||
3. Deploy — eager applies to all, lazy catches stragglers
|
||||
|
||||
## Scope Exclusions
|
||||
|
||||
- No rollback support
|
||||
- No data migrations (DDL only; data scripts remain separate)
|
||||
- No parallel execution (sequential per tenant)
|
||||
- No distributed locking (single PM2 fork instance)
|
||||
- No changes to Prisma/central DB migrations
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `apps/api/src/config/tenant-migrations.ts` | NEW — TenantMigrationRunner |
|
||||
| `apps/api/src/migrations/tenant/001_initial_schema.sql` | NEW — current createTables + createIndexes |
|
||||
| `apps/api/src/config/database.ts` | MODIFY — remove createTables/createIndexes, add lazy migration in getPool, call migrate in provisionDatabase |
|
||||
| `apps/api/src/scripts/migrate-tenants.ts` | NEW — eager migration CLI script |
|
||||
| `apps/api/package.json` | MODIFY — add db:migrate-tenants script |
|
||||
| `turbo.json` | MODIFY — add db:migrate-tenants task |
|
||||
1462
docs/superpowers/specs/2026-04-16-horux-despachos-design.md
Normal file
1462
docs/superpowers/specs/2026-04-16-horux-despachos-design.md
Normal file
File diff suppressed because it is too large
Load Diff
166
docs/superpowers/specs/2026-04-27-custom-plan-design.md
Normal file
166
docs/superpowers/specs/2026-04-27-custom-plan-design.md
Normal file
@@ -0,0 +1,166 @@
|
||||
# Plan Custom — gratis, sin fecha fin, solo asignable por Admin Global
|
||||
|
||||
## Contexto
|
||||
|
||||
El owner pidió un plan "Custom" para casos donde quiere otorgar acceso al
|
||||
sistema sin cobro y sin fecha de finalización (cortesía, beta tester, caso
|
||||
especial). Solo el Admin Global puede asignarlo; los usuarios finales no
|
||||
deben verlo en su catálogo de planes.
|
||||
|
||||
## Decisión clave — Reusar enum `custom`
|
||||
|
||||
El Plan enum de Prisma ya incluye `custom` (legacy: "precio variable por
|
||||
tenant"). En dev hay **0 tenants** en ese plan, y la lógica antigua en
|
||||
`subscription.service.ts` rechaza `custom` del flujo self-serve — patrón
|
||||
que coincide con la nueva semántica. Reusar el enum evita migration y
|
||||
mantiene compatibilidad.
|
||||
|
||||
## Reglas
|
||||
|
||||
- **Comportamiento**: idéntico a Mi Empresa (1 RFC, MANAGED, 50 timbres/mes,
|
||||
features básicas, sin API ni Lolita).
|
||||
- **Costo**: $0. No genera Subscription, no usa MercadoPago.
|
||||
- **Vigencia**: indefinida. `tenant.trialEndsAt = null`. Sin
|
||||
`currentPeriodEnd`. Ningún cron lo expira.
|
||||
- **Visibilidad**: oculto del catálogo user-facing. Solo aparece como
|
||||
opción en `/clientes` (admin global).
|
||||
|
||||
## Cambios — Catálogo
|
||||
|
||||
`packages/shared/src/constants/despacho-plans.ts`:
|
||||
|
||||
```ts
|
||||
custom: {
|
||||
name: 'Custom',
|
||||
maxRfcs: 1,
|
||||
maxUsers: 3,
|
||||
maxCfdisPorContribuyente: 1_000_000,
|
||||
timbresIncluidosMes: 50,
|
||||
dbMode: 'MANAGED' as const,
|
||||
permiteServidorBackup: false,
|
||||
features: [
|
||||
'dashboard', 'cfdi_basic', 'iva_isr', 'reportes', 'alertas',
|
||||
'calendario', 'conciliacion', 'documentos', 'facturacion',
|
||||
'forecasting', 'xml_sat',
|
||||
],
|
||||
},
|
||||
```
|
||||
|
||||
NO se agrega a `DESPACHO_PLAN_PRICES` (gratis). Helpers existentes:
|
||||
- `permiteOverage('custom')` → `false` ✓ (ya retorna false porque solo
|
||||
cubre business_control y business_cloud)
|
||||
- `isDespachoPaidPlan('custom')` → `false` ✓ (idem)
|
||||
- `permiteFrecuenciaMensual('custom')` → `false` ✓ (no está en
|
||||
DESPACHO_PLAN_PRICES)
|
||||
|
||||
## Cambios — Frontend types
|
||||
|
||||
`apps/web/lib/api/tenants.ts`:
|
||||
|
||||
Extender el tipo del campo `plan` en `CreateTenantData` y `UpdateTenantData`:
|
||||
|
||||
```ts
|
||||
type AdminAssignablePlan =
|
||||
| 'starter' | 'business' | 'business_ia' | 'enterprise' // legacy Horux 360
|
||||
| 'custom'; // nuevo
|
||||
```
|
||||
|
||||
(Despacho paid plans NO se incluyen — esos van por self-serve del owner,
|
||||
fuera de scope per acuerdo con owner.)
|
||||
|
||||
## Cambios — Página `/clientes` (admin)
|
||||
|
||||
`apps/web/app/(dashboard)/clientes/page.tsx`:
|
||||
|
||||
1. Reemplazar `PlanType` local por `AdminAssignablePlan` importado.
|
||||
2. Eliminar el `planLabels` local (líneas 174-178) y `planColors` local
|
||||
(líneas 180-184). Usar el `PLAN_LABELS` global que ya existe arriba
|
||||
del archivo (cubre todo). Para colores, expandir el map global o
|
||||
inline en el render.
|
||||
3. Extender el `<Select>` del form para incluir `custom`:
|
||||
|
||||
```tsx
|
||||
<SelectContent>
|
||||
<SelectItem value="starter">Starter (legacy) — Sin CFDIs, 1 usuario</SelectItem>
|
||||
<SelectItem value="business">Business (legacy) — 50 CFDIs, 3 usuarios</SelectItem>
|
||||
<SelectItem value="business_ia">Business + IA (legacy)</SelectItem>
|
||||
<SelectItem value="enterprise">Enterprise (legacy) — 100 CFDIs, ilimitado</SelectItem>
|
||||
<SelectItem value="custom">Custom — Sin cobro, sin fecha fin (despacho)</SelectItem>
|
||||
</SelectContent>
|
||||
```
|
||||
|
||||
4. Cuando el plan seleccionado es `custom`, ocultar el campo `amount`
|
||||
(no aplica) o forzarlo a 0.
|
||||
|
||||
## Cambios — Página `/configuracion/planes-despacho` (user)
|
||||
|
||||
`apps/web/app/(dashboard)/configuracion/planes-despacho/page.tsx`:
|
||||
|
||||
- Las cards visibles son `mi_empresa`, `mi_empresa_plus`,
|
||||
`business_control`, `business_cloud`. `custom` NO aparece (no está en
|
||||
ese array).
|
||||
- Si `planInfo?.plan === 'custom'`: mostrar un banner read-only
|
||||
prominente:
|
||||
|
||||
> "Estás en el plan **Custom** asignado por tu administrador. Contacta
|
||||
> a soporte si necesitas cambiar."
|
||||
|
||||
Y NO renderizar las cards (o renderizarlas atenuadas con botones
|
||||
deshabilitados).
|
||||
|
||||
## No-cambios
|
||||
|
||||
- Schema BD / migration — el enum `custom` ya existe.
|
||||
- Backend `PUT /api/tenants/:id` — ya acepta cualquier valor del enum
|
||||
Prisma (sin Zod gate). Cero cambios.
|
||||
- `subscription.service.ts` — su lógica anti-`custom` existente sigue
|
||||
vigente y coincide con el nuevo comportamiento (rechaza self-serve).
|
||||
- `getMyPlan` en `despacho.controller.ts` — ya lee `tenant.plan`
|
||||
directamente. Custom se reportará al frontend correctamente.
|
||||
- Cron `applyPendingChanges` y `expireTrials` — Custom no tiene
|
||||
Subscription ni trialEndsAt, no le afectan.
|
||||
- Trial RFC limit (V.1.0.11) — Custom tiene `trialEndsAt=null`, así
|
||||
que el limit de 5 no aplica. Aplica el límite duro del catálogo (1).
|
||||
|
||||
## Riesgos / limitaciones aceptadas
|
||||
|
||||
1. **Transición paid → custom**: si el admin cambia un tenant que tenía
|
||||
suscripción MP activa a `custom`, el preapproval MP **sigue
|
||||
cobrando** hasta que se cancele manualmente. Mitigación: el admin
|
||||
debe cancelar la suscripción primero desde `/configuracion/suscripcion`
|
||||
del tenant impersonado, luego asignar custom. Documentar en runbook.
|
||||
2. **Transición custom → paid**: el admin NO puede asignar planes
|
||||
despacho pagables desde `/clientes` (no incluidos en el dropdown).
|
||||
El tenant debe pasar por self-serve normal en
|
||||
`/configuracion/planes-despacho`. Esto evita el escenario de un
|
||||
tenant en plan paid sin Subscription que sería inconsistente.
|
||||
3. **Hard limit de 1 RFC en custom**: igual que Mi Empresa, el límite
|
||||
de 1 RFC para custom es solo billing-only hoy (no enforced en
|
||||
`contribuyente.controller.ts:create`). Si se quiere enforce duro,
|
||||
replicar el patrón del trial limit. Out of scope.
|
||||
|
||||
## Plan de pruebas
|
||||
|
||||
1. `pnpm typecheck` shared + api + web targeted: PASS.
|
||||
2. **Admin asigna custom**: desde `/clientes`, edit tenant, seleccionar
|
||||
"Custom", guardar. Verificar `tenant.plan === 'custom'` en BD.
|
||||
3. **Admin asigna custom a tenant en trial**: trialEndsAt debería
|
||||
limpiarse (a través de la lógica del service). Si el service no lo
|
||||
limpia, agregar.
|
||||
4. **User en custom**: login como ese tenant, ir a
|
||||
`/configuracion/planes-despacho` → ver banner "Estás en plan Custom".
|
||||
5. **Admin asigna otro plan a tenant en custom**: dropdown muestra los
|
||||
demás planes legacy. Asignación funciona.
|
||||
6. **`getMyPlan` retorna custom**: `/api/despachos/me/plan` retorna
|
||||
`{ plan: 'custom', isTrialActive: false, ... }`.
|
||||
|
||||
## Implementación
|
||||
|
||||
~30 líneas netas en 4 archivos:
|
||||
- `despacho-plans.ts` — agregar entrada custom (~12 líneas).
|
||||
- `tenants.ts` (api client) — extender tipos (~3 líneas).
|
||||
- `clientes/page.tsx` — dropdown + cleanup (~10 líneas).
|
||||
- `planes-despacho/page.tsx` — banner Custom (~10 líneas).
|
||||
|
||||
Cambio chico, hago directo sin subagents. Una commit en Downloads + V.1.0.14
|
||||
en OneDrive.
|
||||
@@ -0,0 +1,108 @@
|
||||
# Drill-down genérica — sort por nombre emisor/receptor
|
||||
|
||||
## Contexto
|
||||
|
||||
La tabla de la página `/drill-down` (apps/web/app/(dashboard)/drill-down/page.tsx)
|
||||
actualmente permite ordenar por `Fecha`, `Total MXN`, `Monto Pago` e `IVA Trasl.`
|
||||
mediante el hook `useTableSort` y el componente `SortableHeader` de
|
||||
`@horux/shared-ui`. Las columnas `Nombre Emisor` y `Nombre Receptor` se
|
||||
renderizan como `<th>` planos no ordenables.
|
||||
|
||||
## Objetivo
|
||||
|
||||
Permitir ordenar también por nombre del emisor y por nombre del receptor,
|
||||
sin remover ninguna de las columnas ordenables existentes.
|
||||
|
||||
Alcance limitado a la drill-down genérica. Las 9 páginas de `/alertas/*` quedan
|
||||
fuera de este cambio (decisión del owner — se evaluarán después).
|
||||
|
||||
## Cambios
|
||||
|
||||
Archivo único: `apps/web/app/(dashboard)/drill-down/page.tsx`.
|
||||
|
||||
1. Extender el segundo parámetro de tipo de `useTableSort` para incluir las
|
||||
nuevas keys:
|
||||
|
||||
```ts
|
||||
useTableSort<Cfdi, 'fecha' | 'total' | 'pago' | 'iva' | 'emisor' | 'receptor'>
|
||||
```
|
||||
|
||||
2. Agregar dos accesores al objeto pasado al hook:
|
||||
|
||||
```ts
|
||||
emisor: (c) => c.nombreEmisor || '',
|
||||
receptor: (c) => c.nombreReceptor || '',
|
||||
```
|
||||
|
||||
`useTableSort` ya soporta accesores de tipo `string` — usa
|
||||
`String.prototype.localeCompare` cuando ambos valores son strings, lo cual
|
||||
maneja la collation del español correctamente.
|
||||
|
||||
3. Reemplazar los dos `<th>` planos por `SortableHeader`:
|
||||
|
||||
```tsx
|
||||
// antes
|
||||
<th className="pb-3 font-medium">Nombre Emisor</th>
|
||||
<th className="pb-3 font-medium">Nombre Receptor</th>
|
||||
|
||||
// después
|
||||
<SortableHeader label="Nombre Emisor"
|
||||
active={getSortIndicator('emisor')}
|
||||
onClick={() => toggleSort('emisor')} />
|
||||
<SortableHeader label="Nombre Receptor"
|
||||
active={getSortIndicator('receptor')}
|
||||
onClick={() => toggleSort('receptor')} />
|
||||
```
|
||||
|
||||
4. Mantener el `initialKey = 'fecha'` y `initialDir = 'desc'` (default actual).
|
||||
|
||||
## No-cambios
|
||||
|
||||
- No se tocan: `useTableSort`, `SortableHeader`, ni cualquier otro archivo en
|
||||
`@horux/shared-ui`.
|
||||
- No se tocan controllers ni services del API. El sort es 100% client-side.
|
||||
- No se tocan las columnas RFC Emisor, RFC Receptor, UUID, Comp., M. Pago,
|
||||
Reg. E ni Reg. R — siguen siendo `<th>` planos no ordenables.
|
||||
- No se modifica el export a Excel: ya consume `sortedData`, así que el orden
|
||||
vigente del usuario se respeta automáticamente.
|
||||
|
||||
## Comportamiento esperado
|
||||
|
||||
- Click sobre "Nombre Emisor": ordena ascendente por nombre. Re-click:
|
||||
descendente. Cambia el sort activo (un solo sort a la vez, ya es el
|
||||
contrato del hook).
|
||||
- Click sobre "Nombre Receptor": idéntico, reemplaza al sort previo.
|
||||
- Filas con `nombreEmisor` o `nombreReceptor` null/undefined: el accesor
|
||||
retorna string vacío `''`, así que en `asc` aparecen primero. Es el
|
||||
comportamiento estándar de `localeCompare` y se considera aceptable
|
||||
(un CFDI sin nombre emisor/receptor es raro y debería ser visible al
|
||||
ordenar por nombre).
|
||||
|
||||
## Riesgo
|
||||
|
||||
Mínimo:
|
||||
- Cambio puramente client-side, una sola página, ~6 líneas netas.
|
||||
- No introduce dependencias nuevas.
|
||||
- `pnpm typecheck` debería seguir limpio (las nuevas keys están dentro del
|
||||
union genérico, los accesores cumplen el contrato `(row: T) => number | string`).
|
||||
|
||||
## Plan de pruebas (smoke)
|
||||
|
||||
1. `pnpm typecheck` debe seguir en 0 errores.
|
||||
2. Abrir `/drill-down` desde cualquier KPI del dashboard.
|
||||
3. Click en "Nombre Emisor" → verificar orden alfabético ascendente y flecha
|
||||
en el header. Re-click → descendente.
|
||||
4. Click en "Nombre Receptor" → mismo comportamiento.
|
||||
5. Click en "Fecha" / "Total MXN" → confirmar que los sorts pre-existentes
|
||||
siguen funcionando.
|
||||
6. Exportar a Excel después de ordenar por "Nombre Emisor" → confirmar que
|
||||
el archivo descargado mantiene el mismo orden.
|
||||
|
||||
## Pendientes derivados
|
||||
|
||||
- Replicar el patrón en las 9 páginas de `/alertas/*` (cancelaciones,
|
||||
cancelaciones-periodo-anterior, efectivo, tipo-relacion-sospechosa,
|
||||
concentracion-clientes, concentracion-proveedores, discrepancia-regimen,
|
||||
lista-negra-clientes, lista-negra-proveedores). Decisión del owner cuándo
|
||||
abordarlas. Para `lista-negra-*` además habrá que introducir
|
||||
`useTableSort` desde cero (hoy no lo usan).
|
||||
@@ -0,0 +1,347 @@
|
||||
# Filtros "Considerar activos" y "Considerar NCs" en /impuestos — Fase 1
|
||||
|
||||
## Contexto
|
||||
|
||||
La pestaña ISR e IVA de `/impuestos` actualmente solo tiene un toggle de
|
||||
"Conciliación" que cambia la semántica de fechas. El owner pidió dos toggles
|
||||
adicionales:
|
||||
|
||||
1. **Considerar activos** — cuando ACTIVADO, incluye facturas tipo I con
|
||||
`uso_cfdi` ∈ {I01, I02, I03, I04, I05, I06, I07, I08} (compras de activos
|
||||
fijos / inversiones). Cuando DESACTIVADO, excluye esas facturas.
|
||||
2. **Considerar NCs** — cuando ACTIVADO, incluye facturas tipo E con
|
||||
`cfdi_tipo_relacion = '01'` (notas de crédito). Cuando DESACTIVADO, las
|
||||
excluye.
|
||||
|
||||
### Decisión de defaults
|
||||
|
||||
**Default ambos toggles ON (incluir)** — revertido del default original OFF
|
||||
por concerns de performance: con default OFF, el cache `metricas_mensuales`
|
||||
quedaría siempre bypass-eado en `/impuestos` hasta Fase 2. Con default ON,
|
||||
las cargas iniciales aprovechan el cache (comportamiento idéntico al de
|
||||
versiones previas), y el contador opt-in al view filtrado cuando lo necesita.
|
||||
|
||||
Trade-off aceptado: el contador debe **desactivar manualmente** los toggles
|
||||
cuando quiere ver números sin activos / sin NCs. La lógica fiscal de
|
||||
"depreciación de activos" requiere consciencia del contador, no se aplica
|
||||
silenciosamente.
|
||||
|
||||
Los filtros aplican **solo** en la pestaña Impuestos (IVA + ISR). Dashboard,
|
||||
reportes, drill-downs, alertas y demás permanecen intactos.
|
||||
|
||||
## Justificación fiscal
|
||||
|
||||
- Los activos fijos (uso I01-I08) deben depreciarse, no deducirse en su mes
|
||||
de adquisición. Excluirlos del cálculo provisional mensual evita inflar las
|
||||
deducciones. La pestaña dedicada "Activos Fijos" (en `/impuestos`) es donde
|
||||
se muestra y gestiona esa información.
|
||||
- Las NCs tipoRel=01 son ajustes a documentos previos. El owner quiere ver
|
||||
los números **brutos sin ajustes** por default y opt-in con el toggle. Asume
|
||||
el riesgo de over-reporting si el contador olvida activarlo.
|
||||
|
||||
## Fases
|
||||
|
||||
- **Fase 1 (este spec):** UI + backend con live query. Sin cambios al cache
|
||||
`metricas_mensuales`. Cuando los toggles están en su default (OFF), el cache
|
||||
queda bypass-eado y todo es live query.
|
||||
- **Fase 2 (spec posterior):** extender `metricas_mensuales` con columnas
|
||||
base + 2 deltas para hacer el toggle instantáneo (computado por suma/resta).
|
||||
|
||||
## Cambios — Frontend
|
||||
|
||||
### `apps/web/app/(dashboard)/impuestos/page.tsx`
|
||||
|
||||
State nuevo (defaults `true` = filter active = incluir, cache-friendly):
|
||||
|
||||
```ts
|
||||
const [considerarActivos, setConsiderarActivos] = useState(true);
|
||||
const [considerarNCs, setConsiderarNCs] = useState(true);
|
||||
```
|
||||
|
||||
Con defaults `true`, las cargas iniciales aprovechan el cache de
|
||||
`metricas_mensuales`. El gate `!conciliacion && considerarActivos && considerarNCs`
|
||||
queda en `true` por default y permite cache hit. El contador opt-in al view
|
||||
filtrado desactivando los toggles cuando lo necesita.
|
||||
|
||||
UI: 2 toggle buttons en la misma fila que "Conciliación", mismo styling.
|
||||
Orden recomendado: `Régimen | Conciliación | Considerar activos | Considerar NCs`.
|
||||
|
||||
```tsx
|
||||
<button
|
||||
onClick={() => setConsiderarActivos(!considerarActivos)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
||||
considerarActivos
|
||||
? 'bg-primary/10 text-primary border border-primary/30'
|
||||
: 'hover:bg-accent'
|
||||
)}
|
||||
title="Si está inactivo, no se consideran facturas tipo I con uso de CFDI I01-I08 (compras de activos fijos)."
|
||||
>
|
||||
<CheckSquare className="h-4 w-4" />
|
||||
Considerar activos
|
||||
</button>
|
||||
```
|
||||
|
||||
(Análogo para Considerar NCs con tooltip "...facturas tipo E con tipo de
|
||||
relación 01 (notas de crédito).")
|
||||
|
||||
Pasar a todos los hooks consumidos en la pestaña ISR e IVA.
|
||||
|
||||
### `apps/web/lib/hooks/use-impuestos.ts`
|
||||
|
||||
Extender 5 hooks. Ejemplo:
|
||||
|
||||
```ts
|
||||
export function useResumenIsrDesglosado(
|
||||
fechaFin: string,
|
||||
conciliacion?: boolean,
|
||||
considerarActivos?: boolean,
|
||||
considerarNCs?: boolean,
|
||||
) {
|
||||
const tk = useTenantKey();
|
||||
const { selectedContribuyenteId } = useContribuyenteStore();
|
||||
return useQuery({
|
||||
queryKey: ['isr-resumen-desglosado', tk, fechaFin, conciliacion, considerarActivos, considerarNCs, selectedContribuyenteId],
|
||||
queryFn: () => impuestosApi.getResumenIsrDesglosado(fechaFin, conciliacion, considerarActivos, considerarNCs, selectedContribuyenteId),
|
||||
enabled: !!fechaFin,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Aplicar el mismo patrón a `useResumenIsr`, `useResumenIva`, `useIsrMensual`,
|
||||
`useIvaMensual`.
|
||||
|
||||
### `apps/web/lib/api/impuestos.ts`
|
||||
|
||||
Extender funciones HTTP. Ejemplo:
|
||||
|
||||
```ts
|
||||
export async function getResumenIsrDesglosado(
|
||||
fechaFin: string,
|
||||
conciliacion?: boolean,
|
||||
considerarActivos?: boolean,
|
||||
considerarNCs?: boolean,
|
||||
contribuyenteId?: string | null,
|
||||
): Promise<ResumenIsrDesglosado> {
|
||||
const params = new URLSearchParams();
|
||||
params.set('fechaFin', fechaFin);
|
||||
if (conciliacion) params.set('conciliacion', 'true');
|
||||
if (considerarActivos) params.set('considerarActivos', 'true');
|
||||
if (considerarNCs) params.set('considerarNCs', 'true');
|
||||
if (contribuyenteId) params.set('contribuyenteId', contribuyenteId);
|
||||
const response = await apiClient.get<ResumenIsrDesglosado>(`/impuestos/isr/resumen-desglosado?${params}`);
|
||||
return response.data;
|
||||
}
|
||||
```
|
||||
|
||||
(Análogo para `getResumenIsr`, `getResumenIva`, `getIsrMensual`, `getIvaMensual`.)
|
||||
|
||||
## Cambios — Backend
|
||||
|
||||
### Helper compartido en `apps/api/src/services/impuestos.service.ts`
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Construye fragmentos AND adicionales para WHERE clauses según los toggles
|
||||
* "Considerar activos" y "Considerar NCs" en la UI de impuestos.
|
||||
*
|
||||
* - considerarActivos === false → excluir facturas tipo I con uso de CFDI I01-I08.
|
||||
* - considerarNCs === false → excluir facturas tipo E con cfdi_tipo_relacion = '01'.
|
||||
*
|
||||
* Cuando ambos son true (default backend), retorna string vacío. Esto preserva
|
||||
* el comportamiento histórico para callers que no pasan los flags (ej. dashboard).
|
||||
*/
|
||||
function buildExtraFilters(considerarActivos: boolean, considerarNCs: boolean): string {
|
||||
const parts: string[] = [];
|
||||
if (!considerarActivos) {
|
||||
parts.push(`AND NOT (tipo_comprobante = 'I' AND uso_cfdi IN ('I01','I02','I03','I04','I05','I06','I07','I08'))`);
|
||||
}
|
||||
if (!considerarNCs) {
|
||||
parts.push(`AND NOT (tipo_comprobante = 'E' AND COALESCE(cfdi_tipo_relacion, '') = '01')`);
|
||||
}
|
||||
return parts.length > 0 ? ' ' + parts.join(' ') : '';
|
||||
}
|
||||
```
|
||||
|
||||
### Funciones modificadas
|
||||
|
||||
Agregar 2 parámetros booleanos opcionales con default `true` (= include
|
||||
todo, comportamiento histórico). Forman el último par de la signature.
|
||||
|
||||
| Función | Archivo | Cambio |
|
||||
|---|---|---|
|
||||
| `calcularIngresosPorRegimen` | `dashboard.service.ts` | +`considerarActivos=true, considerarNCs=true`, concatenar `buildExtraFilters(...)` al WHERE |
|
||||
| `calcularEgresosPorRegimen` | `dashboard.service.ts` | Idem |
|
||||
| `getResumenIva` | `impuestos.service.ts` | Idem + propagar al cache gate (ver abajo) |
|
||||
| `getIvaMensual` | `impuestos.service.ts` | Idem |
|
||||
| `getResumenIsr` | `impuestos.service.ts` | Idem + propagar a `calcular*PorRegimen` |
|
||||
| `getIsrMensual` | `impuestos.service.ts` | Idem + propagar a `calcular*PorRegimen` |
|
||||
| `getResumenIsrDesglosado` | `impuestos.service.ts` | Idem + propagar a las 3 llamadas a `getResumenIsr` |
|
||||
|
||||
**Importante**: como `buildExtraFilters` está en `impuestos.service.ts` y
|
||||
`calcular*PorRegimen` viven en `dashboard.service.ts`, hay que **mover el
|
||||
helper a un módulo compartido** o duplicarlo. Recomendación: mover a un
|
||||
nuevo `apps/api/src/services/_shared/cfdi-filters.ts` (módulo neutral
|
||||
reutilizable). Ambos services lo importan.
|
||||
|
||||
### Aplicación del fragmento
|
||||
|
||||
Concatenar al WHERE de TODA query que escanee `cfdis` dentro de las funciones
|
||||
afectadas. Buscar patrón `WHERE ${VIGENTE} AND ${FR}` y agregar
|
||||
`${buildExtraFilters(...)}` al final del WHERE.
|
||||
|
||||
Ejemplo en una query existente:
|
||||
|
||||
```ts
|
||||
const FR = getFR(conciliacion);
|
||||
const extra = buildExtraFilters(considerarActivos, considerarNCs);
|
||||
|
||||
const { rows } = await pool.query(`
|
||||
SELECT regimen_fiscal_emisor as regimen, ...
|
||||
FROM cfdis
|
||||
WHERE ${VIGENTE} AND ${FR}${extra}
|
||||
AND ${ctx.esEmisor}
|
||||
GROUP BY regimen_fiscal_emisor
|
||||
`, [fechaInicio, fechaFin]);
|
||||
```
|
||||
|
||||
`buildExtraFilters` ya retorna con leading space, así que se concatena directo.
|
||||
|
||||
**Subqueries con alias** (`SUM_E_REFERENCING_*`): el alias `e` para la tabla
|
||||
externa requiere referenciar columnas como `e.tipo_comprobante`,
|
||||
`e.uso_cfdi`, `e.cfdi_tipo_relacion`. Necesitamos una variante del helper que
|
||||
acepte alias:
|
||||
|
||||
```ts
|
||||
function buildExtraFiltersAlias(alias: string, considerarActivos: boolean, considerarNCs: boolean): string {
|
||||
const parts: string[] = [];
|
||||
if (!considerarActivos) {
|
||||
parts.push(`AND NOT (${alias}.tipo_comprobante = 'I' AND ${alias}.uso_cfdi IN ('I01','I02','I03','I04','I05','I06','I07','I08'))`);
|
||||
}
|
||||
if (!considerarNCs) {
|
||||
parts.push(`AND NOT (${alias}.tipo_comprobante = 'E' AND COALESCE(${alias}.cfdi_tipo_relacion, '') = '01')`);
|
||||
}
|
||||
return parts.length > 0 ? ' ' + parts.join(' ') : '';
|
||||
}
|
||||
```
|
||||
|
||||
Y se usa donde aparezcan subqueries con alias `e` (ej. `SUM_E_REFERENCING_*`,
|
||||
`HAS_E_REFERENCING_MISMO_MES`, `E_REFERENCIA_I_PPD_07_MISMO_MES` si existe).
|
||||
|
||||
### Controllers — `apps/api/src/controllers/impuestos.controller.ts`
|
||||
|
||||
Helper para parsear (junto a `parseConciliacion`):
|
||||
|
||||
```ts
|
||||
function parseFlag(req: Request, key: string, defaultValue = true): boolean {
|
||||
const v = req.query[key];
|
||||
if (v === undefined || v === null) return defaultValue;
|
||||
return v === 'true' || v === '1';
|
||||
}
|
||||
```
|
||||
|
||||
Cada handler relevante (`getResumenIva`, `getIvaMensual`, `getResumenIsr`,
|
||||
`getIsrMensual`, `getResumenIsrDesglosado`) parsea los 2 nuevos flags con
|
||||
**default `true`** y los pasa al service.
|
||||
|
||||
```ts
|
||||
const considerarActivos = parseFlag(req, 'considerarActivos', true);
|
||||
const considerarNCs = parseFlag(req, 'considerarNCs', true);
|
||||
```
|
||||
|
||||
**Razón del default `true` en el controller**: si por algún motivo el query
|
||||
param no llega (cliente legacy, prueba manual, otro consumer), comportamiento
|
||||
es como antes (todo incluido). El frontend siempre manda el flag explícito,
|
||||
así que en la práctica el default solo aplica al testing externo.
|
||||
|
||||
### Cache gate en `getResumenIva` (línea ~322)
|
||||
|
||||
Extender la condición:
|
||||
|
||||
```ts
|
||||
if (
|
||||
!conciliacion &&
|
||||
considerarActivos && // new — cache solo aplica con backend default
|
||||
considerarNCs && // new
|
||||
contribuyenteId &&
|
||||
...
|
||||
) {
|
||||
const cached = await readResumenIvaFromCache(...);
|
||||
if (cached) return cached;
|
||||
}
|
||||
```
|
||||
|
||||
Con UI default (ambos toggles ON), `considerarActivos=true && considerarNCs=true`
|
||||
→ cache hit (comportamiento idéntico a versiones previas). Cuando el contador
|
||||
desactiva alguno → cache bypass → live query (~1-3s). Aceptable porque el
|
||||
desactivado es action consciente, no la carga inicial. Fase 2 hará los toggles
|
||||
instantáneos vía cache base+deltas.
|
||||
|
||||
## No-cambios
|
||||
|
||||
- **Schema BD**: ninguno. SQL puro.
|
||||
- **Cache `metricas_mensuales`**: estructura intacta. Solo se actualiza el gate.
|
||||
- **Dashboard, reportes, drill-downs, alertas**: comportamiento idéntico
|
||||
(gracias a defaults `true` en `calcular*PorRegimen`).
|
||||
- **Activos Fijos tab**: usa su propio `activos-fijos.service.ts`, no pasa
|
||||
por las funciones filtradas. Verificar en el smoke.
|
||||
- **`getRegimenesDelPeriodo`** y otros que NO calculan ingresos/deducciones
|
||||
no se modifican. Los regímenes disponibles en el dropdown siguen siendo
|
||||
los mismos (basados en presencia de CFDIs, no filtrados por estos toggles).
|
||||
|
||||
## Riesgos
|
||||
|
||||
1. **Tocar funciones compartidas con dashboard**: `calcular*PorRegimen` viven
|
||||
en `dashboard.service.ts`. Default `true` debería preservar el dashboard,
|
||||
pero hay que verificar manualmente post-deploy.
|
||||
2. **Performance Fase 1**: con UI default ON (cache-friendly), las cargas
|
||||
iniciales son rápidas. Solo cuando el contador desactiva un toggle hay
|
||||
live query. Fase 2 elimina ese delay también.
|
||||
3. **Subqueries con alias**: hay 5+ subqueries con alias `e` en
|
||||
`impuestos.service.ts` (rama I PPD/07). Cada una necesita el helper alias.
|
||||
Riesgo de olvidar una → resultados inconsistentes.
|
||||
4. **NCs default OFF puede sobre-reportar ingresos**: el contador puede no
|
||||
notar que las NCs están excluidas si no lee el tooltip. Mitigación:
|
||||
tooltip claro y label "Considerar NCs" (lectura obvia).
|
||||
|
||||
## Plan de pruebas (smoke)
|
||||
|
||||
1. **Typecheck**: `pnpm --filter @horux/shared typecheck`,
|
||||
`pnpm --filter @horux/api typecheck`. Ambos PASS sin errores.
|
||||
2. **Dashboard regression**: abrir `/dashboard` → KPIs (ingresos, gastos,
|
||||
utilidad) deben tener los mismos valores que antes del deploy.
|
||||
3. **Activos Fijos tab**: abrir `/impuestos` → pestaña "Activos Fijos" → la
|
||||
tabla debe seguir mostrando todas las facturas I con uso I01-I08.
|
||||
4. **UI default (ambos toggles OFF)**: cargar `/impuestos` ISR. Verificar
|
||||
que ingresos del periodo y deducciones son menores que antes (excluyen
|
||||
activos + NCs tipoRel=01).
|
||||
5. **Toggle "Considerar activos" ON**: deducciones suben con la suma de los
|
||||
activos del periodo.
|
||||
6. **Toggle "Considerar NCs" ON**: comportamiento depende del lado:
|
||||
- Como receptor (NC recibida que cancela una factura PUE): deducciones
|
||||
bajan (la NC resta).
|
||||
- Como emisor (NC emitida que cancela una factura PUE propia): ingresos
|
||||
bajan.
|
||||
7. **Combinaciones de los 3 toggles** (Conciliación + Activos + NCs): ocho
|
||||
combinaciones, números deben ser consistentes.
|
||||
8. **IVA tab**: mismas pruebas (toggle on/off, comparar números).
|
||||
9. **Tabla "Histórico ISR"**: debe respetar los 2 nuevos toggles también
|
||||
(cada fila refleja los acumulados con los filtros activos).
|
||||
10. **Sección "Cálculo de ISR del Periodo"**: las 3 ramas (`delPeriodo`,
|
||||
`anteriores`, `total`) deben respetar los toggles consistentemente.
|
||||
|
||||
## Pendientes derivados
|
||||
|
||||
- **Fase 2**: extender `metricas_mensuales` con columnas
|
||||
`*_activos`, `*_ncs_01` (×3 métricas IVA = 6 columnas nuevas).
|
||||
Migration + recompute del cache + actualizar lectura del cache para hacer
|
||||
suma/resta según toggles. Fase 2 entrega toggles instantáneos.
|
||||
- **Tooltip + iconos**: si el owner quiere distinguir visualmente los 3
|
||||
toggles (Conciliación con un check, Activos con un asset icon, NCs con un
|
||||
document icon), aplicar después.
|
||||
- **Persistencia de los toggles**: hoy el state vive en `useState`, se pierde
|
||||
al recargar. Si se quiere persistir, considerar `localStorage` o agregar a
|
||||
`tenant-view-store`. Out-of-scope para Fase 1.
|
||||
- **Dashboard parity**: si en el futuro el owner quiere los mismos toggles en
|
||||
`/dashboard`, ya está habilitado por la signature de `calcular*PorRegimen`
|
||||
— solo falta UI + propagación. Out-of-scope.
|
||||
@@ -0,0 +1,298 @@
|
||||
# ISR — Base gravable acumulada y desglose del periodo
|
||||
|
||||
## Contexto
|
||||
|
||||
En `/impuestos` (pestaña ISR) hay dos lugares donde la base gravable se calcula
|
||||
mes a mes en lugar de acumulado, lo cual es fiscalmente incorrecto para pagos
|
||||
provisionales mensuales:
|
||||
|
||||
1. **Tabla "Histórico ISR"** (`apps/web/app/(dashboard)/impuestos/page.tsx`,
|
||||
líneas ~503-568): cada fila aplica `Math.max(0, ing_mes − ded_mes)` por mes
|
||||
independiente. Resultado: un mes con pérdida no reduce el acumulado.
|
||||
|
||||
2. **Sección "Cálculo de ISR Acumulado"** (mismas líneas ~371-432): muestra los
|
||||
totales del rango filtrado en `resumenIsr`, sin distinguir lo que ya estaba
|
||||
acumulado de meses previos del mismo año vs. el periodo actual.
|
||||
|
||||
El bug raíz vive en `getIsrMensual` (`apps/api/src/services/impuestos.service.ts`,
|
||||
líneas 409-486): el query corre de `${año}-${mm}-01` a fin de mes, así que el
|
||||
campo nombrado `ingresosAcumulados` en `IsrMensual` realmente trae solo el mes
|
||||
(deuda heredada del refactor previo, el nombre miente).
|
||||
|
||||
## Objetivo
|
||||
|
||||
Mostrar la base gravable y los montos acumulados correctamente:
|
||||
|
||||
1. En la tabla, agregar columnas **Ingresos Acum.**, **Deducciones Acum.** y
|
||||
**Base Gravable Acum.** (estas tres son running totals desde enero hasta el
|
||||
mes de cada fila). La **BG mensual desaparece** del display — solo queda la
|
||||
acumulada, que es la única fiscalmente válida.
|
||||
|
||||
2. En la sección de cálculo, presentar el desglose como aparece en el formato
|
||||
14 (declaración provisional mensual del SAT):
|
||||
```
|
||||
Ingresos del periodo + Ingresos anteriores
|
||||
− Deducciones del periodo − Deducciones anteriores
|
||||
= Base gravable acumulada
|
||||
```
|
||||
Donde **"del periodo" = mes final del filtro** y **"anteriores" = enero
|
||||
hasta el mes anterior al final**.
|
||||
|
||||
## Reglas fiscales
|
||||
|
||||
- **No se aplica `max(0, ...)` al display** de base gravable. Los déficits son
|
||||
reales y se muestran negativos (en rojo). Si filtras febrero y enero tuvo
|
||||
utilidad pero febrero pérdida grande, `BG_acum_feb` puede ser negativa.
|
||||
- **`max(0, ...)` se aplica únicamente al pasar a ISR causado**: si
|
||||
`BG_acum < 0`, ISR causado = 0. SAT hace lo mismo en el formato 14.
|
||||
- **El año fiscal se resetea en enero**. "Anteriores" jamás cruza a años previos.
|
||||
|
||||
## Cambios — Backend
|
||||
|
||||
### `apps/api/src/services/impuestos.service.ts`
|
||||
|
||||
**`getIsrMensual` (líneas 409-486):**
|
||||
|
||||
Después del loop que llena `result[]` con datos mensuales, agregar un segundo
|
||||
pase que computa los running totals:
|
||||
|
||||
```ts
|
||||
let ingAcum = 0, dedAcum = 0;
|
||||
for (const row of result) {
|
||||
ingAcum += row.ingresosAcumulados; // (mensual, a pesar del nombre)
|
||||
dedAcum += row.deducciones;
|
||||
row.ingresosAcum = ingAcum;
|
||||
row.deduccionesAcum = dedAcum;
|
||||
row.baseGravableAcum = ingAcum - dedAcum; // sin clamp
|
||||
}
|
||||
```
|
||||
|
||||
Nota sobre naming: el campo existente `ingresosAcumulados` en `IsrMensual` se
|
||||
mantiene por compat (es el mensual). Los nuevos campos son `ingresosAcum`,
|
||||
`deduccionesAcum`, `baseGravableAcum`. En el spec del rename total al final
|
||||
puede ocurrir, pero no es scope de este cambio.
|
||||
|
||||
**Nueva función exportada** `getResumenIsrDesglosado`:
|
||||
|
||||
```ts
|
||||
export async function getResumenIsrDesglosado(
|
||||
pool: Pool,
|
||||
fechaFin: string,
|
||||
tenantId: string,
|
||||
conciliacion?: boolean,
|
||||
contribuyenteId?: string | null,
|
||||
): Promise<{
|
||||
delPeriodo: ResumenIsr;
|
||||
anteriores: ResumenIsr;
|
||||
total: ResumenIsr;
|
||||
}>
|
||||
```
|
||||
|
||||
Lógica:
|
||||
1. Derivar `año = fechaFin.year`, `mesFinal = fechaFin.month`.
|
||||
2. Tres rangos:
|
||||
- **delPeriodo**: `${año}-${mesFinal}-01` a fin de `mesFinal` (solo mes final)
|
||||
- **anteriores**: `${año}-01-01` a `${año}-${mesFinal-1}-${ultDia}` (Ene a mesFinal-1; vacío si mesFinal=1)
|
||||
- **total**: `${año}-01-01` a fin de `mesFinal` (Ene a mesFinal)
|
||||
3. Llamar `getResumenIsr` 3 veces con esos rangos, retornar el objeto.
|
||||
|
||||
Caso `mesFinal=1`: retornar `anteriores` con todos los campos en cero (no se hace
|
||||
query inútil).
|
||||
|
||||
### `apps/api/src/controllers/impuestos.controller.ts`
|
||||
|
||||
Agregar handler `getResumenIsrDesglosado`:
|
||||
|
||||
```ts
|
||||
// GET /api/impuestos/resumen-isr-desglosado?fechaFin=...&conciliacion=...&contribuyenteId=...
|
||||
```
|
||||
|
||||
El filtro por régimen no se pasa al endpoint — el frontend hace el lookup
|
||||
contra `resumenIsr.baseGravablePorRegimen[]` igual que hoy con `useResumenIsr`,
|
||||
para que la lógica de filtrado siga centralizada en un solo lugar.
|
||||
|
||||
### `apps/api/src/routes/impuestos.routes.ts`
|
||||
|
||||
Agregar la ruta `/resumen-isr-desglosado` con los mismos middlewares que
|
||||
`/resumen-isr` (auth + tenant + plan limits).
|
||||
|
||||
## Cambios — Shared types
|
||||
|
||||
### `packages/shared/src/types/reportes.ts` (o donde viva `IsrMensual`)
|
||||
|
||||
Agregar campos al type:
|
||||
|
||||
```ts
|
||||
export interface IsrMensual {
|
||||
// ...campos existentes
|
||||
ingresosAcum: number;
|
||||
deduccionesAcum: number;
|
||||
baseGravableAcum: number; // sin clamp, puede ser negativo
|
||||
}
|
||||
|
||||
export interface ResumenIsrDesglosado {
|
||||
delPeriodo: ResumenIsr;
|
||||
anteriores: ResumenIsr;
|
||||
total: ResumenIsr;
|
||||
}
|
||||
```
|
||||
|
||||
## Cambios — Frontend
|
||||
|
||||
### `apps/web/lib/api/impuestos.ts`
|
||||
|
||||
Agregar función `getResumenIsrDesglosado` (cliente HTTP) y hook
|
||||
`useResumenIsrDesglosado` en `apps/web/lib/hooks/use-impuestos.ts`.
|
||||
|
||||
### `apps/web/app/(dashboard)/impuestos/page.tsx`
|
||||
|
||||
**Tabla "Histórico ISR" (líneas ~502-568):**
|
||||
|
||||
Headers (6 columnas):
|
||||
```
|
||||
Mes | Ingresos | Ingresos Acum. | Deducciones | Deducciones Acum. | Base Gravable Acum.
|
||||
```
|
||||
|
||||
Body por fila:
|
||||
```tsx
|
||||
<td>{meses[row.mes - 1]}</td>
|
||||
<td className="text-right">{formatCurrency(row.ingresosAcumulados)}</td> // mensual
|
||||
<td className="text-right">{formatCurrency(row.ingresosAcum)}</td>
|
||||
<td className="text-right">{formatCurrency(row.deducciones)}</td>
|
||||
<td className="text-right">{formatCurrency(row.deduccionesAcum)}</td>
|
||||
<td className={cn(
|
||||
"text-right font-medium",
|
||||
row.baseGravableAcum < 0 ? "text-destructive" : ""
|
||||
)}>{formatCurrency(row.baseGravableAcum)}</td>
|
||||
```
|
||||
|
||||
Fila Total: eliminar. La última fila (diciembre) ya es el total YTD, no hace
|
||||
falta sumar acumulados (sería incorrecto). Si se quiere conservar, mostrar
|
||||
solo los mensuales sumados (= total año) y el último valor acumulado de la
|
||||
columna BG Acum.
|
||||
|
||||
**Decisión por defecto en este spec:** eliminar fila Total. Si el usuario
|
||||
prefiere conservarla, lo discutimos al implementar.
|
||||
|
||||
Export Excel: 6 columnas alineadas con UI:
|
||||
```ts
|
||||
[
|
||||
{ header: 'Mes', key: 'Mes' },
|
||||
{ header: 'Ingresos', key: 'Ingresos' },
|
||||
{ header: 'Ingresos Acumulados', key: 'IngresosAcum' },
|
||||
{ header: 'Deducciones', key: 'Deducciones' },
|
||||
{ header: 'Deducciones Acumuladas', key: 'DeduccionesAcum' },
|
||||
{ header: 'Base Gravable Acumulada', key: 'BaseGravableAcum' },
|
||||
]
|
||||
```
|
||||
|
||||
**Sección "Cálculo de ISR del Periodo" (líneas ~371-432):**
|
||||
|
||||
1. Renombrar `<CardTitle>` de "Cálculo de ISR Acumulado" a "Cálculo de ISR
|
||||
del Periodo".
|
||||
|
||||
2. Reemplazar el query `useResumenIsr(fechaInicio, fechaFin, conciliacion)` por
|
||||
`useResumenIsrDesglosado(fechaFin, conciliacion, contribuyenteId)`. El
|
||||
filtro por régimen se aplica del lado frontend contra
|
||||
`total.baseGravablePorRegimen[]` (mismo patrón que hoy).
|
||||
|
||||
3. Layout nuevo del card content:
|
||||
|
||||
```tsx
|
||||
<div className="space-y-2">
|
||||
<FilaDesglose label={`Ingresos del periodo (${labelMesFinal})`} value={delPeriodo.ingresos} />
|
||||
<FilaDesglose label={`(+) Ingresos acumulados anteriores ${labelAnteriores}`} value={anteriores.ingresos} />
|
||||
<FilaDesglose label={`(−) Deducciones del periodo`} value={delPeriodo.deducciones} negative />
|
||||
<FilaDesglose label={`(−) Deducciones acumuladas anteriores`} value={anteriores.deducciones} negative />
|
||||
<Divider />
|
||||
<FilaDesglose
|
||||
label="(=) Base gravable acumulada"
|
||||
value={total.baseGravable}
|
||||
bold
|
||||
danger={total.baseGravable < 0}
|
||||
/>
|
||||
<FilaDesglose label="ISR causado (acumulado)" value={total.isrCausado} />
|
||||
<FilaDesglose label="(−) ISR retenido (acumulado)" value={total.isrRetenido} negative />
|
||||
<Divider />
|
||||
<FilaDesglose label="ISR a pagar" value={total.isrAPagar} bold large />
|
||||
</div>
|
||||
```
|
||||
|
||||
Etiquetas dinámicas:
|
||||
- `labelMesFinal` = `"Mar 2026"` (mes y año de `fechaFin`)
|
||||
- `labelAnteriores` = `"(Ene-Feb)"` o `"(sin meses anteriores)"` cuando
|
||||
`mesFinal === 1`.
|
||||
|
||||
Si `mesFinal === 1`: las dos filas "anteriores" muestran `$0` con texto
|
||||
discretamente atenuado y el label dice "(sin meses anteriores)".
|
||||
|
||||
`FilaDesglose` puede ser un componente local del archivo o sustituirse por el
|
||||
mismo `<div className="flex justify-between py-2 border-b">` que ya se usa.
|
||||
Decisión por defecto: inline (no extraer componente nuevo, mantener el patrón
|
||||
existente).
|
||||
|
||||
## No-cambios
|
||||
|
||||
- `getResumenIsr` se mantiene tal cual — sigue usándose en KPIs y otros lugares.
|
||||
- Los KPIs en la parte alta de la pestaña ISR (Ingresos, Deducciones, Base
|
||||
Gravable, etc.) **siguen mostrando los valores del rango filtrado completo**.
|
||||
El cambio aplica solo a la tabla histórica y a la sección de cálculo.
|
||||
- `metricas_mensuales` (cache) sigue guardando valores mensuales puros — el
|
||||
acumulado se computa al consumir el cache. Sin invalidaciones.
|
||||
- IVA mensual (`getIvaMensual`) no se toca.
|
||||
|
||||
## Riesgos
|
||||
|
||||
- **BG mensual deja de aparecer en la tabla**: si algún usuario hacía export y
|
||||
reportaba la BG mensual a contadores, esa columna ya no existe. Mitigación:
|
||||
comunicar el cambio en el changelog/release notes.
|
||||
- **Año cruzado**: si el usuario filtra `fechaFin = 2026-03-31` pero
|
||||
`fechaInicio` es de 2025, "anteriores" sigue siendo solo Ene-Feb 2026, no
|
||||
baja a 2025. Esperable porque ISR se acumula por año fiscal.
|
||||
- **Performance**: 3 queries `getResumenIsr` por refresh de la sección de
|
||||
cálculo. Cada uno hace ~10 queries internos (por régimen, retenciones, etc.).
|
||||
En un mes promedio del año, son ~30 queries. Aceptable para un endpoint
|
||||
on-demand. Si se vuelve cuello de botella, optimizar con un solo query
|
||||
agregado.
|
||||
|
||||
## Plan de pruebas (smoke)
|
||||
|
||||
1. `pnpm typecheck` debe seguir limpio en `@horux/api` y `@horux/shared`.
|
||||
|
||||
2. Backend — abrir REPL/curl:
|
||||
- `GET /api/impuestos/resumen-isr-desglosado?fechaFin=2026-03-31&...`:
|
||||
- `delPeriodo` = solo Mar 2026
|
||||
- `anteriores` = Ene-Feb 2026
|
||||
- `total.ingresos === delPeriodo.ingresos + anteriores.ingresos`
|
||||
- `total.baseGravable === total.ingresos − total.deducciones` (sin clamp,
|
||||
puede ser negativo)
|
||||
- Mismo endpoint con `fechaFin=2026-01-31`:
|
||||
- `anteriores.ingresos === 0`, `anteriores.deducciones === 0`, etc.
|
||||
|
||||
3. Frontend tabla:
|
||||
- Tenant con datos en varios meses (p.ej. Patito): verificar que cada fila
|
||||
muestre el running total correcto.
|
||||
- Tenant con un mes negativo (Husberto Feb si hay datos): la BG Acum debe
|
||||
aparecer en rojo y reducir el acumulado del mes siguiente.
|
||||
|
||||
4. Frontend sección:
|
||||
- Filtrar `mes=marzo`: ver que los 4 renglones cuadren con la fórmula y la
|
||||
línea BG sea la suma algebraica.
|
||||
- Filtrar `mes=enero`: ver que las dos líneas "anteriores" digan "$0" con
|
||||
etiqueta "(sin meses anteriores)".
|
||||
- Filtrar `mes=diciembre`: ver acumulado anual completo, "anteriores" =
|
||||
Ene-Nov, "del periodo" = Dic.
|
||||
|
||||
5. Validación cruzada con declaración SAT real (si owner tiene una a la mano):
|
||||
confirmar que los números del desglose coincidan con la declaración formato 14.
|
||||
|
||||
## Pendientes derivados
|
||||
|
||||
- Considerar agregar **un endpoint `getIsrMensualConAcumulados`** que retorne
|
||||
los acumulados pre-computados, en vez de exponerlos como campos extra del
|
||||
endpoint actual. Reduciría payload si solo se necesita una vista.
|
||||
- Si el cache de `metricas_mensuales` empieza a usarse para ISR (hoy solo
|
||||
es para IVA), repetir la fix del acumulado al consumir el cache.
|
||||
- **Recompute opcional**: el bug actual ya no es visible (eliminamos la BG
|
||||
mensual) pero la fila de cálculo del periodo SÍ depende de queries en vivo.
|
||||
No hay cache que invalidar — el fix es inmediato al deploy.
|
||||
129
docs/superpowers/specs/2026-04-27-trial-rfc-limit-design.md
Normal file
129
docs/superpowers/specs/2026-04-27-trial-rfc-limit-design.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# Límite de 5 RFCs durante trial gratuito
|
||||
|
||||
## Contexto
|
||||
|
||||
Despachos en periodo de prueba (30 días) pueden agregar RFCs sin restricción.
|
||||
El owner pidió un límite duro de 5 RFCs durante trial — para forzar al
|
||||
contador a contratar un plan si necesita gestionar más.
|
||||
|
||||
## Reglas
|
||||
|
||||
| Estado | Límite RFCs |
|
||||
|---|---|
|
||||
| Trial activo (`tenant.trialEndsAt > now`) | **5 contribuyentes activos** (boundary: 5 OK, 6 bloqueado) |
|
||||
| Trial expirado | Aplica el límite del plan vigente; este spec no agrega nada nuevo |
|
||||
| Plan pagado (sin trial activo) | Sin nuevo límite (los del plan ya existen y son out of scope) |
|
||||
|
||||
## Cambios — Backend
|
||||
|
||||
### `apps/api/src/controllers/contribuyente.controller.ts`
|
||||
|
||||
Constante local al archivo:
|
||||
|
||||
```ts
|
||||
const TRIAL_MAX_CONTRIBUYENTES = 5;
|
||||
```
|
||||
|
||||
En el handler `create`, antes del `createContribuyente`:
|
||||
|
||||
```ts
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: req.user!.tenantId },
|
||||
select: { trialEndsAt: true },
|
||||
});
|
||||
const isTrialActive = tenant?.trialEndsAt ? tenant.trialEndsAt > new Date() : false;
|
||||
|
||||
if (isTrialActive) {
|
||||
const activeCount = await countActiveContribuyentes(req.tenantPool!);
|
||||
if (activeCount >= TRIAL_MAX_CONTRIBUYENTES) {
|
||||
return next(new AppError(
|
||||
403,
|
||||
`Durante el periodo de prueba puedes gestionar hasta ${TRIAL_MAX_CONTRIBUYENTES} contribuyentes. Contrata un plan para agregar más.`,
|
||||
));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Imports: agregar `prisma` desde `../config/database.js` (ya está disponible
|
||||
en otros controllers).
|
||||
|
||||
## Cambios — Frontend
|
||||
|
||||
### `apps/web/app/(dashboard)/contribuyentes/page.tsx`
|
||||
|
||||
Fetch del plan info (sigue patrón existente en `planes-despacho/page.tsx`):
|
||||
|
||||
```ts
|
||||
const { data: planInfo } = useQuery({
|
||||
queryKey: ['my-plan-info'],
|
||||
queryFn: () => apiClient.get<{ isTrialActive: boolean }>('/despachos/me/plan').then(r => r.data),
|
||||
});
|
||||
|
||||
const isTrialActive = planInfo?.isTrialActive ?? false;
|
||||
const activeCount = (contribuyentes ?? []).filter(c => c.active !== false).length;
|
||||
const trialAtLimit = isTrialActive && activeCount >= 5;
|
||||
```
|
||||
|
||||
Modificar los 2 botones "Agregar RFC" (línea 70 y 78) para reflejar el estado:
|
||||
|
||||
```tsx
|
||||
<Button
|
||||
onClick={() => { resetForm(); setShowDialog(true); }}
|
||||
disabled={trialAtLimit}
|
||||
title={trialAtLimit ? 'Límite de contribuyentes para la prueba gratuita, para continuar agregando contribuyentes, selecciona un plan.' : undefined}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" /> Agregar RFC
|
||||
</Button>
|
||||
```
|
||||
|
||||
(Mismo patrón en el botón "Agregar primer RFC" — aunque cuando `activeCount === 0`
|
||||
el `trialAtLimit` es `false`, así que ese botón nunca se deshabilita. Aún así,
|
||||
aplico el atributo `disabled={trialAtLimit}` por consistencia defensiva.)
|
||||
|
||||
Mensaje del tooltip (literal del owner):
|
||||
"Límite de contribuyentes para la prueba gratuita, para continuar agregando
|
||||
contribuyentes, selecciona un plan."
|
||||
|
||||
## No-cambios
|
||||
|
||||
- Schema BD.
|
||||
- Cron de trial (`expireTrials`).
|
||||
- Mi Empresa hard limit a 1 RFC (sigue siendo solo billing-only,
|
||||
fuera de scope).
|
||||
- `tenant.cfdiLimit`, `tenant.usersLimit` — no se tocan.
|
||||
|
||||
## Riesgos
|
||||
|
||||
- **Race condition**: si dos creaciones concurrentes ven `count=4` y ambas
|
||||
pasan, podríamos terminar con 6. Improbable en flujo manual UI; no se
|
||||
mitiga (costo > beneficio).
|
||||
- **Trial → paid mid-creación**: si el contador paga mientras está en 5
|
||||
RFCs, el `trialEndsAt` no se modifica (sigue en futuro), pero la
|
||||
subscription ahora tiene status `authorized`. Per la lógica actual,
|
||||
el trial sigue "activo" hasta que `trialEndsAt < now`. El usuario
|
||||
pagado seguirá viendo el límite de 5 hasta que expire el trial. **Aceptable**:
|
||||
el owner gana dinero adicional el día que el contador convierte, no antes.
|
||||
Si se quiere lift inmediato, modificar la lógica de `isTrialActive`
|
||||
para excluir trials pagados — out of scope para este spec.
|
||||
|
||||
## Plan de pruebas
|
||||
|
||||
1. `pnpm typecheck` shared + api + web targeted: PASS.
|
||||
2. Tenant en trial con 4 contribuyentes activos:
|
||||
- UI: botón "Agregar RFC" habilitado.
|
||||
- API: `POST /api/contribuyentes` con datos válidos retorna 201.
|
||||
3. Tenant en trial con 5 contribuyentes activos:
|
||||
- UI: botón "Agregar RFC" deshabilitado, tooltip visible al hover.
|
||||
- API: `POST /api/contribuyentes` retorna 403 con el mensaje del spec.
|
||||
4. Tenant trial expirado con 5 contribuyentes:
|
||||
- UI: botón habilitado.
|
||||
- API: 201 (puede crear el 6º — sin límite trial).
|
||||
5. Tenant pagado (Business Control) con 5 contribuyentes:
|
||||
- UI: botón habilitado.
|
||||
- API: 201.
|
||||
|
||||
## Implementación
|
||||
|
||||
~15 líneas backend + ~8 líneas frontend. Cambio chico, una commit en
|
||||
Downloads + V.1.0.11 en OneDrive.
|
||||
Reference in New Issue
Block a user