Files
HoruxDespachos/docs/superpowers/plans/2026-04-17-plan2b-contribuyentes-fiel-cfdi.md
2026-04-27 22:09:36 -06:00

20 KiB

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:

-- 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:

-- 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:

-- 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
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:

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:

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:

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:

import contribuyenteRoutes from './routes/contribuyente.routes.js';

Add route mount (before error middleware):

app.use('/api/contribuyentes', contribuyenteRoutes);
  • Step 5: Verify typecheck

Run: pnpm --filter @horux/api typecheck Expected: no new errors.

  • Step 6: Commit
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:

  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:

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:

// 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
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:

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):

// 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
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
ls -la apps/api/src/migrations/tenant/

Expected: 13 files (001-013).

  • Step 2: Typecheck
pnpm --filter @horux/shared typecheck
pnpm --filter @horux/core typecheck
pnpm --filter @horux/api typecheck
  • Step 3: Verify commit history
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:

# 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 contribuyentecreateOrganization() 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.