# Plan: Corregir / Eliminar Asientos + Enums Contabilidad

**Fecha**: 2026-04-24  
**Módulo**: Contabilidad — Asientos Manuales  
**Repositorios**:
- Backend: `/var/www/html/proyectos/smartfactvoice-backend` (NestJS + Prisma + PostgreSQL)
- Frontend: `/var/www/html/proyectos/pos-ventas` (React + TanStack Query v5 + styled-components + MUI)

---

## Objetivo

Agregar dos acciones en el módulo de asientos contables:

| Acción | Estado requerido | Comportamiento |
|---|---|---|
| **Corregir** | CONFIRMADO | Revierte el asiento original → abre el formulario pre-llenado para registrar el asiento de corrección |
| **Eliminar** | BORRADOR | Elimina el asiento permanentemente |

Además, **migrar los campos `estado` / `tipo` de VARCHAR estático a tipos ENUM en PostgreSQL** para garantizar integridad a nivel de base de datos.

---

## Contexto técnico

### Estado actual de la BD (todo VARCHAR, sin enum)

| Tabla | Columna | Tipo actual | Valores usados |
|---|---|---|---|
| `cont_asientos` | `estado` | `VARCHAR(20)` | BORRADOR, CONFIRMADO, REVERTIDO |
| `cont_documentos` | `estado` | `VARCHAR(20)` | BORRADOR, CONFIRMADO, REVERTIDO |
| `cont_documentos` | `tipo` | `VARCHAR(30)` | MANUAL, FACTURA, COBRO, COMPRA, PAGO, REVERSION, APERTURA, CIERRE, AJUSTE, BANCARD |
| `cont_ejercicios` | `estado` | `VARCHAR(20)` | ABIERTO, CERRADO |
| `cont_periodos` | `estado` | `VARCHAR(20)` | ABIERTO, CERRADO, AJUSTE, BLOQUEADO |

### Endpoints existentes en `AsientosController`

```
GET    /api/v1/contabilidad/asientos
GET    /api/v1/contabilidad/asientos/:id
POST   /api/v1/contabilidad/asientos
PUT    /api/v1/contabilidad/asientos/:id
PATCH  /api/v1/contabilidad/asientos/:id/confirmar
POST   /api/v1/contabilidad/asientos/:id/revertir
```

### Permisos existentes usados en el módulo

- `CONT_VER` — ver asientos
- `CONT_CREAR_ASIENTO` — crear y editar borradores
- `CONT_CONFIRMAR_ASIENTO` — confirmar borrador
- `CONT_REVERTIR_ASIENTO` — revertir confirmado

### Hooks TanStack existentes en `ContabilidadStack.jsx`

```
useAsientosQuery, useCrearAsientoMutation, useEditarAsientoMutation,
useConfirmarAsientoMutation, useRevertirAsientoMutation,
usePlanCuentasQuery, useEjerciciosQuery, usePeriodosQuery,
useCentrosCostoQuery, useTiposCambioQuery
```

---

## Fase 1 — Migración SQL: enums

**Archivo**: `prisma/migrations/20260424_cont_enums/migration.sql`

```sql
-- Enums contables
DO $$ BEGIN
  CREATE TYPE cont_asiento_estado AS ENUM ('BORRADOR', 'CONFIRMADO', 'REVERTIDO');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;

DO $$ BEGIN
  CREATE TYPE cont_ejercicio_estado AS ENUM ('ABIERTO', 'CERRADO');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;

DO $$ BEGIN
  CREATE TYPE cont_periodo_estado AS ENUM ('ABIERTO', 'CERRADO', 'AJUSTE', 'BLOQUEADO');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;

DO $$ BEGIN
  CREATE TYPE cont_documento_tipo AS ENUM (
    'MANUAL', 'FACTURA', 'COBRO', 'COMPRA', 'PAGO',
    'REVERSION', 'APERTURA', 'CIERRE', 'AJUSTE', 'BANCARD'
  );
EXCEPTION WHEN duplicate_object THEN NULL; END $$;

-- Migrar columnas (USING convierte el VARCHAR existente al enum sin tocar los datos)
ALTER TABLE cont_asientos
  ALTER COLUMN estado TYPE cont_asiento_estado
    USING estado::cont_asiento_estado;

ALTER TABLE cont_documentos
  ALTER COLUMN estado TYPE cont_asiento_estado
    USING estado::cont_asiento_estado,
  ALTER COLUMN tipo TYPE cont_documento_tipo
    USING tipo::cont_documento_tipo;

ALTER TABLE cont_ejercicios
  ALTER COLUMN estado TYPE cont_ejercicio_estado
    USING estado::cont_ejercicio_estado;

ALTER TABLE cont_periodos
  ALTER COLUMN estado TYPE cont_periodo_estado
    USING estado::cont_periodo_estado;
```

> **Nota**: los valores existentes en BD (BORRADOR, CONFIRMADO, etc.) son idénticos a los valores del enum — el CAST no pierde datos.

---

## Fase 2 — Schema Prisma

**Archivo**: `prisma/schema.prisma`

### 2A. Agregar declaraciones de enum (en cualquier lugar del archivo, fuera de los `model`)

```prisma
enum cont_asiento_estado {
  BORRADOR
  CONFIRMADO
  REVERTIDO
}

enum cont_ejercicio_estado {
  ABIERTO
  CERRADO
}

enum cont_periodo_estado {
  ABIERTO
  CERRADO
  AJUSTE
  BLOQUEADO
}

enum cont_documento_tipo {
  MANUAL
  FACTURA
  COBRO
  COMPRA
  PAGO
  REVERSION
  APERTURA
  CIERRE
  AJUSTE
  BANCARD
}
```

### 2B. Actualizar campos en los modelos

En `model cont_asientos` reemplazar:
```prisma
// antes
estado  String  @default("BORRADOR") @db.VarChar(20)

// después
estado  cont_asiento_estado  @default(BORRADOR)
```

En `model cont_documentos` reemplazar:
```prisma
// antes
tipo    String  @db.VarChar(30)
estado  String  @default("BORRADOR") @db.VarChar(20)

// después
tipo    cont_documento_tipo
estado  cont_asiento_estado  @default(BORRADOR)
```

En `model cont_ejercicios` reemplazar:
```prisma
// antes
estado  String  @default("ABIERTO") @db.VarChar(20)

// después
estado  cont_ejercicio_estado  @default(ABIERTO)
```

En `model cont_periodos` reemplazar:
```prisma
// antes
estado  String  @default("ABIERTO") @db.VarChar(20)

// después
estado  cont_periodo_estado  @default(ABIERTO)
```

### 2C. Regenerar el cliente Prisma

```bash
npx prisma generate
```

---

## Fase 3 — Backend NestJS

### 3A. Nuevos métodos en `AsientosService`

**Archivo**: `src/contabilidad/services/asientos.service.ts`

Agregar después del método `revertir` (o antes de `validarLineas`):

```typescript
async corregir(id: string, empresaId: string, usuarioId: string) {
  const asiento = await this.prisma.cont_asientos.findFirst({
    where: { id, empresa_id: empresaId },
    include: { lineas: true },
  });
  if (!asiento) throw new NotFoundException('Asiento no encontrado');
  if (asiento.estado !== 'CONFIRMADO' && asiento.estado !== 'REVERTIDO') {
    throw new BadRequestException('Solo se pueden corregir asientos confirmados');
  }
  // Idempotente: si ya fue revertido, solo devuelve los datos para pre-llenar
  if (asiento.estado === 'CONFIRMADO') {
    await this.revertir(id, empresaId, usuarioId);
  }
  return {
    datos_originales: {
      glosa: asiento.glosa,
      moneda_origen: asiento.moneda_origen,
      lineas: asiento.lineas.map((l) => ({
        cuenta_id: l.cuenta_id,
        descripcion: l.descripcion,
        debe_moneda: Number(l.debe_moneda),
        haber_moneda: Number(l.haber_moneda),
        centro_costo_id: l.centro_costo_id,
      })),
    },
  };
}

async eliminar(id: string, empresaId: string, usuarioId: string) {
  const asiento = await this.prisma.cont_asientos.findFirst({
    where: { id, empresa_id: empresaId },
  });
  if (!asiento) throw new NotFoundException('Asiento no encontrado');
  if (asiento.estado !== 'BORRADOR') {
    throw new BadRequestException('Solo se pueden eliminar asientos en estado BORRADOR');
  }
  await this.prisma.cont_asientos.delete({ where: { id } });
  this.audit.log({
    empresa_id: empresaId,
    user_id: usuarioId,
    action: 'DELETE',
    entity_type: 'cont_asientos',
    entity_id: id,
    descripcion: `Eliminación de asiento N° ${asiento.numero}`,
  });
  return { ok: true };
}
```

> Con enum Prisma, si TypeScript se queja al comparar `asiento.estado !== 'CONFIRMADO'`, usar el enum: `asiento.estado !== cont_asiento_estado.CONFIRMADO` (o importar y usar el tipo del cliente Prisma generado).

### 3B. Nuevos endpoints en `AsientosController`

**Archivo**: `src/contabilidad/controllers/asientos.controller.ts`

Agregar `Delete` al import de `@nestjs/common`:
```typescript
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, UseGuards } from '@nestjs/common';
```

Agregar los dos endpoints después del método `revertir`:
```typescript
@Post(':id/corregir')
@RequirePermission('CONTABILIDAD', 'CONT_REVERTIR_ASIENTO')
@ApiOperation({ summary: 'Corregir asiento: revierte y devuelve datos originales para nuevo asiento de corrección' })
corregir(@Param('id') id: string, @GetUser() user: LoginUserInfo) {
  return this.service.corregir(id, user.empresa_id, user.id);
}

@Delete(':id')
@RequirePermission('CONTABILIDAD', 'CONT_CREAR_ASIENTO')
@ApiOperation({ summary: 'Eliminar asiento BORRADOR' })
eliminar(@Param('id') id: string, @GetUser() user: LoginUserInfo) {
  return this.service.eliminar(id, user.empresa_id, user.id);
}
```

---

## Fase 4 — Frontend

### 4A. API service

**Archivo**: `src/api/contabilidad.service.js`

Agregar junto a `confirmarAsiento` y `revertirAsiento`:

```javascript
export const corregirAsiento = async (id) => {
  const response = await api.post(`/contabilidad/asientos/${id}/corregir`);
  return response.data;
};

export const eliminarAsiento = async (id) => {
  const response = await api.delete(`/contabilidad/asientos/${id}`);
  return response.data;
};
```

### 4B. Hooks TanStack

**Archivo**: `src/tanstack/ContabilidadStack.jsx`

Agregar el import de las dos funciones nuevas junto a los imports existentes de asientos. Luego agregar los dos hooks después de `useRevertirAsientoMutation`:

```javascript
export const useCorregirAsientoMutation = () => {
  // NO invalida ni muestra toast en onSuccess — el componente usa mutateAsync
  // y maneja el resultado directamente para pre-llenar el form
  return useMutation({
    mutationFn: (id) => corregirAsiento(id),
    onError: (error) => {
      toast.error(error?.response?.data?.message || 'Error al corregir el asiento');
    },
  });
};

export const useEliminarAsientoMutation = () => {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (id) => eliminarAsiento(id),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['asientos'] });
      toast.success('Asiento eliminado');
    },
    onError: (error) => {
      toast.error(error?.response?.data?.message || 'Error al eliminar el asiento');
    },
  });
};
```

### 4C. Componente AsientosTab

**Archivo**: `src/components/contabilidad/AsientosTab.jsx`

#### Imports MUI a agregar

```javascript
// En la línea de imports MUI existente, agregar:
Divider, IconButton, ListItemIcon, ListItemText, Menu, Popover, Typography
```

#### Imports de hooks a agregar

```javascript
// En el import de ContabilidadStack, agregar:
useCorregirAsientoMutation, useEliminarAsientoMutation
```

#### Estados y refs nuevos (dentro de `AsientosTab`)

```javascript
const corrigiendoRef = useRef(false);
const [menuAnchor, setMenuAnchor] = useState(null);   // elemento DOM ancla del menú ⋮
const [menuAsiento, setMenuAsiento] = useState(null); // asiento del menú abierto
const [helpAnchor, setHelpAnchor] = useState(null);   // ancla del popover de ayuda
```

#### Instanciar las mutaciones

```javascript
const corregirMutation = useCorregirAsientoMutation();
const eliminarMutation = useEliminarAsientoMutation();
```

#### Handler corregir (usar `mutateAsync` + try/catch — evita el bug de doble callback de TanStack v5)

```javascript
const handleCorregir = async (asiento) => {
  if (corrigiendoRef.current) return;
  corrigiendoRef.current = true;
  setMenuAnchor(null);
  setMenuAsiento(null);
  try {
    const data = await corregirMutation.mutateAsync(asiento.id);
    const d = data?.datos_originales;
    if (!d) throw new Error('Respuesta inesperada del servidor');
    setForm((f) => ({
      ...f,
      glosa: d.glosa ? `CORRECCIÓN: ${d.glosa}` : '',
      moneda_origen: d.moneda_origen || 'PYG',
      lineas: d.lineas?.length > 0 ? d.lineas : [{ ...LINEA_VACIA }, { ...LINEA_VACIA }],
    }));
    setOpenNew(true);
    toast.info(`Asiento #${asiento.numero} revertido. Complete el asiento de corrección.`, { duration: 6000 });
  } catch (error) {
    toast.error(error?.response?.data?.message || 'Error al corregir el asiento');
  } finally {
    corrigiendoRef.current = false;
  }
};
```

#### Handler eliminar

```javascript
const handleEliminar = (asiento) => {
  setMenuAnchor(null);
  setMenuAsiento(null);
  if (!window.confirm(`¿Eliminar el asiento #${asiento.numero}? Esta acción no se puede deshacer.`)) return;
  eliminarMutation.mutate(asiento.id);
};
```

#### Menú contextual ⋮ por fila (reemplaza los botones inline actuales en la columna Acciones)

```jsx
<Td onClick={(e) => e.stopPropagation()}>
  <RowActions>
    {/* Botones inline que ya existen para BORRADOR (Editar) y CONFIRMADO (Confirmar, Revertir) */}
    {a.estado === 'BORRADOR' && can('CONT_CREAR_ASIENTO') && (
      <Tooltip title="Editar asiento">
        <ActionBtn onClick={() => handleOpenEdit(a)}>
          <Icon icon="lucide:pencil" /> Editar
        </ActionBtn>
      </Tooltip>
    )}
    {a.estado === 'BORRADOR' && can('CONT_CONFIRMAR_ASIENTO') && (
      <Tooltip title="Confirmar asiento">
        <ActionBtn $green onClick={() => confirmarMutation.mutate(a.id)} disabled={confirmarMutation.isPending}>
          <Icon icon="lucide:check-circle" /> Confirmar
        </ActionBtn>
      </Tooltip>
    )}
    {a.estado === 'CONFIRMADO' && can('CONT_REVERTIR_ASIENTO') && (
      <Tooltip title="Revertir asiento (crea asiento espejo)">
        <ActionBtn $red onClick={() => setRevertirConfirm(a)}>
          <Icon icon="lucide:undo-2" /> Revertir
        </ActionBtn>
      </Tooltip>
    )}

    {/* Menú ⋮ para acciones secundarias */}
    {(
      (a.estado === 'BORRADOR' && can('CONT_CREAR_ASIENTO')) ||
      (a.estado === 'CONFIRMADO' && can('CONT_REVERTIR_ASIENTO'))
    ) && (
      <Tooltip title="Más acciones">
        <IconButton
          size="small"
          onClick={(e) => { setMenuAnchor(e.currentTarget); setMenuAsiento(a); }}
        >
          <Icon icon="lucide:more-vertical" style={{ width: 16, height: 16 }} />
        </IconButton>
      </Tooltip>
    )}

    <ExpandIconSm $open={!!expanded[a.id]}>
      <Icon icon="lucide:chevron-down" />
    </ExpandIconSm>
  </RowActions>
</Td>
```

#### Componente Menu (colocar fuera del `map`, dentro del return principal)

```jsx
<Menu
  anchorEl={menuAnchor}
  open={!!menuAnchor}
  onClose={() => { setMenuAnchor(null); setMenuAsiento(null); }}
>
  {menuAsiento?.estado === 'CONFIRMADO' && can('CONT_REVERTIR_ASIENTO') && (
    <MenuItem onClick={() => handleCorregir(menuAsiento)} disabled={corregirMutation.isPending}>
      <ListItemIcon><Icon icon="lucide:file-pen" style={{ width: 16, height: 16 }} /></ListItemIcon>
      <ListItemText>Corregir (revertir + nuevo asiento)</ListItemText>
    </MenuItem>
  )}
  {menuAsiento?.estado === 'BORRADOR' && can('CONT_CREAR_ASIENTO') && (
    <MenuItem onClick={() => handleEliminar(menuAsiento)} sx={{ color: '#ef4444' }}>
      <ListItemIcon><Icon icon="lucide:trash-2" style={{ width: 16, height: 16, color: '#ef4444' }} /></ListItemIcon>
      <ListItemText>Eliminar asiento</ListItemText>
    </MenuItem>
  )}
</Menu>
```

#### Popover de ayuda (botón `?` junto al título de la página)

En el `TopBar`, junto al título `Asientos Contables`:

```jsx
<PageTitle>
  <Icon icon="lucide:book-open-text" /> Asientos Contables
  <Tooltip title="¿Cómo funciona?">
    <IconButton size="small" onClick={(e) => setHelpAnchor(e.currentTarget)} sx={{ ml: 0.5 }}>
      <Icon icon="lucide:circle-help" style={{ width: 16, height: 16, color: '#64748b' }} />
    </IconButton>
  </Tooltip>
</PageTitle>

<Popover
  open={!!helpAnchor}
  anchorEl={helpAnchor}
  onClose={() => setHelpAnchor(null)}
  anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
>
  <Typography sx={{ p: 2, maxWidth: 320, fontSize: 13, lineHeight: 1.7 }}>
    <strong>Flujo de asientos contables</strong>
    <br />
    <br />
    1. <strong>BORRADOR</strong> — recién creado. Se puede editar, confirmar o eliminar.
    <br />
    2. <strong>CONFIRMADO</strong> — validado y contabilizado. Se puede revertir o corregir.
    <br />
    3. <strong>REVERTIDO</strong> — anulado con asiento espejo. Solo lectura.
    <br />
    <br />
    <strong>Corregir</strong>: revierte el asiento original y abre un nuevo formulario pre-llenado con los datos del asiento original para que registres la versión correcta.
  </Typography>
</Popover>
```

---

## Resumen de archivos a modificar

| # | Archivo | Tipo de cambio |
|---|---|---|
| 1 | `prisma/migrations/20260424_cont_enums/migration.sql` | **NUEVO** — 4 CREATE TYPE + 5 ALTER TABLE |
| 2 | `prisma/schema.prisma` | +4 enums, actualizar 4 modelos |
| 3 | `src/contabilidad/services/asientos.service.ts` | +`corregir()`, +`eliminar()` |
| 4 | `src/contabilidad/controllers/asientos.controller.ts` | +import `Delete`, +`POST .../corregir`, +`DELETE .../:id` |
| 5 | `src/api/contabilidad.service.js` | +`corregirAsiento`, +`eliminarAsiento` |
| 6 | `src/tanstack/ContabilidadStack.jsx` | +`useCorregirAsientoMutation`, +`useEliminarAsientoMutation` |
| 7 | `src/components/contabilidad/AsientosTab.jsx` | +menú ⋮, +popover ayuda, +`handleCorregir`, +`handleEliminar` |

---

## Orden de ejecución recomendado

1. Ejecutar `migration.sql` en la BD
2. Actualizar `schema.prisma` + `npx prisma generate`
3. Implementar service y controller (backend)
4. Reiniciar el servidor NestJS y verificar endpoints en Swagger (`/docs`)
5. Implementar frontend (API → hooks → componente)
6. Probar flujo completo: crear borrador → confirmar → corregir → verificar que se abre el form pre-llenado
7. Probar eliminar borrador y verificar que asientos confirmados no se pueden eliminar

## Verificación manual

```
POST /api/v1/contabilidad/asientos/:id/corregir
  → 200: { datos_originales: { glosa, moneda_origen, lineas[] } }
  → 400 si estado es BORRADOR

DELETE /api/v1/contabilidad/asientos/:id
  → 200: { ok: true }
  → 400 si estado no es BORRADOR
```
