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