Initial commit - Horux Despachos NL
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user