Files
HoruxDespachosNuevo/docs/superpowers/plans/2026-04-12-conciliacion-implementation.md

29 KiB

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:

      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:

        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:

      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:

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

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:

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:

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:

import { bancosRoutes } from './routes/bancos.routes.js';
// ... after regimenRoutes line:
app.use('/api/bancos', bancosRoutes);
  • Step 5: Verify bancos API
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:

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:

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:

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:

import { conciliacionRoutes } from './routes/conciliacion.routes.js';
// ... after bancosRoutes:
app.use('/api/conciliacion', conciliacionRoutes);
  • Step 5: Verify conciliacion API
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:

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:

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:

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:

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:

{ 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.

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:

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:

{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
# Try conciliar already conciliado CFDI — should fail
# Try conciliar with non-existent banco — should fail
# Try delete banco with conciliaciones — should fail