# 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; 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 | null; } export async function listContribuyentes(pool: Pool): Promise { 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 { 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 { 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): Promise { 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 { 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.