Initial commit: Horux Despachos project
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
|
||||
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
Reference in New Issue
Block a user