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
bancosandconciliacionestables 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_conciliacioncolumn tocfdisDDL
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') andconciliadas(conciliado === 'true') useState<Set<number>>for selected checkbox IDsuseBancos()for the banco dropdownuseConciliar()anduseDesconciliar()mutationsuseAuthStore()to checkuser.rolefor visor read-onlyformatCurrencyfrom@/lib/utilsCfdiViewerModalfrom@/components/cfdi/cfdi-viewer-modalPeriodSelectorfrom@/components/period-selectorRegimenSelectorfrom@/components/regimen-selector(needsuseRegimenesDelPeriodo)- Action bar appears only when
selected.size > 0, contains: banco Select, date Input, "Conciliar N facturas" Button - Export to Excel button using
exportToExcelfrom@/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
- Login as admin
- Go to Configuracion → verify Bancos section, add a bank
- Go to Conciliacion → verify tabs show CFDIs
- Select CFDIs, pick banco and date, conciliar → verify they move to "Conciliadas"
- Desconciliar one → verify it moves back
- 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