# Plan: Bancard VPOS (mock) + Compra Asistida (flujo completo)

**Fecha**: Abril 2026 (revisión)
**Módulos**: Pay-01 (Bancard VPOS — fase MOCK) + CA-01 (Compra Asistida)
**Documentación oficial Bancard**: `docs/docs-pdf/ICP-Integración CAJA POS - Android 2.0 v1.7 202410-071024-205527.md`

---

## Decisiones de diseño confirmadas (2026-04-26)

| # | Decisión | Valor |
|---|---|---|
| 1 | Cómo paga el cliente | **Link público enviado por el operador** (`/pay/ca/:token`) — sin login |
| 2 | Estado en que aparece el link | **`cotizado`** — el pago **es** la confirmación |
| 3 | Monto a cobrar | **Total completo** en una sola transacción |
| 4 | Cotización USD→Gs | **Congelada al cotizar** (estado `cotizado`) |
| 5a | Cuándo se factura SIFEN | **Manual al entregar** (`entregado → facturado`) |
| 5b | Desglose de la factura | **Items del pedido + 3 líneas extra** (servicio, logística, comisión) |
| 6 | Asiento contable al cobrar | **NO** — solo se asienta al **facturar** |
| 7 | Cancelación post-pago | **Bloqueada** — solo admin con permiso especial puede forzar refund |
| 8 | Integración Bancard real | **MOCK** por ahora — UI completa con respuestas fake. Real cuando aprueben presupuesto. |

---

## Flujo objetivo

```
┌─────────────┐     ┌─────────────┐     ┌─────────────────┐     ┌──────────────┐
│  Borrador   │ ──→ │  Cotizado   │ ──→ │ Pago Recibido   │ ──→ │ En Compra CN │
│ (operador)  │     │ + link pago │     │ (webhook mock)  │     │  (operador)  │
└─────────────┘     └─────────────┘     └─────────────────┘     └──────┬───────┘
                            │                                          │
                            ↓ (cliente abre link)                      ↓
                    ┌────────────────┐                          ┌─────────────┐
                    │ /pay/ca/:token │                          │ En Tránsito │
                    │  UI Bancard    │                          └──────┬──────┘
                    │  fake mock     │                                 ↓
                    └────────────────┘                          ┌─────────────┐
                                                                │  En Aduana  │
                                                                └──────┬──────┘
                                                                       ↓
┌─────────────┐     ┌──────────────┐     ┌──────────────────┐  ┌──────────────┐
│  Facturado  │ ←── │ + asiento    │ ←── │  Form Factura    │ ←│  Entregado   │
│   (final)   │     │ contable cont│     │ precargado de CA │  │  (operador)  │
└─────────────┘     └──────────────┘     └──────────────────┘  └──────────────┘
```

**Cancelación**: permitida solo en `borrador` y `cotizado` (sin pago). Post-pago requiere permiso `CA_REFUND`.

---

## Estado de implementación

| Fase | Descripción | Estado |
|---|---|---|
| 1A | Migración SQL Bancard (`pago_bancard`, `bancard_log`, `bancard_config`) | ✅ |
| 1B | Backend NestJS Bancard (service, token, webhook, config AES) | ✅ |
| 1C | Frontend: BancardConfigPanel + BancardPagoModal + PagoPublico | ✅ (modal — necesita reemplazo por flujo público) |
| 1D-mock | **`BancardMockService` que implementa `IPaymentGateway`** | ⏳ pendiente |
| 1E | Tests T01–T10 con tarjetas staging | ⏳ pendiente (cuando se integre real) |
| 2A | Migración cabecera-detalle (`compra_asistida` + items) | ✅ |
| 2B | Backend Compra Asistida v2 (CRUD + máquina estados) | ✅ |
| 2C | Frontend `CompraAsistidaTab` + `NuevaCompraAsistidaPage` | ✅ |
| **2D** | **Generación de link público + página `/pay/ca/:token`** | ✅ |
| **2E** | **Webhook mock que avanza `cotizado → pago_recibido`** | ✅ |
| **2F** | **Botón "Facturar" + form precargado desde CA** | ⏳ **pendiente** |
| **2G** | **Asiento contable post-facturación (no post-pago)** | ⏳ **pendiente** |
| **2H** | **Permiso `CA_REFUND` + flujo de refund admin** | ⏳ **pendiente** |
| **2I** | **Migración: `estado` VARCHAR → enum + eliminar `confirmado`** | ✅ |

---

## Cambios pendientes — detalle

### Fase 2I — Migración: enum `compra_asistida_estado` + eliminar `confirmado`

**Archivo**: `prisma/migrations/20260426_compra_asistida_enum/migration.sql`

```sql
-- Nuevo enum (sin 'confirmado' — el pago confirma)
DO $$ BEGIN
  CREATE TYPE compra_asistida_estado AS ENUM (
    'borrador', 'cotizado', 'pago_recibido',
    'en_compra_china', 'en_transito', 'en_aduana',
    'entregado', 'facturado', 'cancelado'
  );
EXCEPTION WHEN duplicate_object THEN NULL; END $$;

-- Migrar registros existentes en estado 'confirmado' → 'cotizado' (vuelve atrás, se va a regenerar el link)
UPDATE compra_asistida SET estado = 'cotizado' WHERE estado = 'confirmado';

-- Convertir columna VARCHAR → enum
ALTER TABLE compra_asistida
  ALTER COLUMN estado DROP DEFAULT,
  ALTER COLUMN estado TYPE compra_asistida_estado USING estado::compra_asistida_estado,
  ALTER COLUMN estado SET DEFAULT 'borrador';
```

**Schema Prisma**:
```prisma
enum compra_asistida_estado {
  borrador
  cotizado
  pago_recibido
  en_compra_china
  en_transito
  en_aduana
  entregado
  facturado
  cancelado
}

model compra_asistida {
  ...
  estado compra_asistida_estado @default(borrador)
}
```

**Post-migración** (ejecutar una vez corrida la SQL):
```bash
npx prisma generate
# Luego en service.ts y dto: cambiar tipo local por import { compra_asistida_estado } from '@prisma/client'
```

**Backend** (`compra-asistida.service.ts`): actualizar `TRANSICIONES`:
```typescript
const TRANSICIONES: Record<compra_asistida_estado, compra_asistida_estado[]> = {
  borrador:        ['cotizado', 'cancelado'],
  cotizado:        ['borrador', 'cancelado'],   // pago_recibido NO manual — solo vía webhook
  pago_recibido:   ['en_compra_china'],
  en_compra_china: ['en_transito'],
  en_transito:     ['en_aduana'],
  en_aduana:       ['entregado'],
  entregado:       ['facturado'],                // facturado NO manual — solo vía endpoint /facturar
  facturado:       [],
  cancelado:       [],
};
```

---

### Fase 2D — Generación de link público + página `/pay/ca/:token`

#### Backend

**Tabla nueva**: `pago_link` (token público con expiración)

**Archivo**: `prisma/migrations/20260426_pago_link/migration.sql`

```sql
DO $$ BEGIN
  CREATE TYPE pago_link_estado AS ENUM ('activo', 'pagado', 'expirado', 'cancelado');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;

CREATE TABLE IF NOT EXISTS pago_link (
  id            UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  empresa_id    UUID NOT NULL REFERENCES empresas(id),
  token         VARCHAR(64) UNIQUE NOT NULL,    -- URL-safe random 32 bytes
  origen_modulo VARCHAR(30) NOT NULL,            -- 'compra_asistida'
  origen_id     UUID NOT NULL,                   -- compra_asistida.id
  monto_total   DECIMAL(14,2) NOT NULL,
  moneda        VARCHAR(3) NOT NULL DEFAULT 'PYG',
  estado        pago_link_estado NOT NULL DEFAULT 'activo',
  pago_bancard_id UUID REFERENCES pago_bancard(id),
  fecha_expiracion TIMESTAMP NOT NULL,           -- created_at + 7 días por defecto
  created_at    TIMESTAMP DEFAULT NOW(),
  updated_at    TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_pago_link_token ON pago_link(token);
CREATE INDEX idx_pago_link_origen ON pago_link(origen_modulo, origen_id);
```

**Endpoints nuevos**:

```
POST   /compra-asistida/:id/generar-link        (JWT)   → crea pago_link, devuelve URL pública
POST   /compra-asistida/:id/regenerar-link      (JWT)   → invalida link previo y crea nuevo

GET    /pago-publico/:token                     (público) → datos del pago para mostrar UI
POST   /pago-publico/:token/iniciar-pago        (público) → simula iniciar pago Bancard, devuelve { processId, urlPagoMock }
POST   /pago-publico/:token/confirmar-mock      (público) → MOCK: simula respuesta exitosa de Bancard
POST   /pago-publico/:token/cancelar            (público) → cliente cancela
```

**`pago-publico.controller.ts`** (sin JWT, en módulo `bancard`):

```typescript
@Controller('pago-publico')
export class PagoPublicoController {
  constructor(
    private readonly pagoLinkService: PagoLinkService,
    private readonly bancardMock: BancardMockService,   // implementa IPaymentGateway
  ) {}

  @Get(':token')
  async getPagoData(@Param('token') token: string) {
    const link = await this.pagoLinkService.findByToken(token);
    if (link.estado === 'expirado') throw new GoneException('Link expirado');
    if (link.estado === 'pagado') throw new BadRequestException('Pago ya realizado');
    if (new Date() > link.fecha_expiracion) {
      await this.pagoLinkService.marcarExpirado(link.id);
      throw new GoneException('Link expirado');
    }

    // Cargar detalle según origen_modulo
    if (link.origen_modulo === 'compra_asistida') {
      const ca = await this.compraAsistidaService.getByIdSinAuth(link.origen_id);
      return {
        token,
        monto_total: link.monto_total,
        moneda: link.moneda,
        empresa: { nombre, ruc, logo_url },
        cliente: { nombre, ruc },
        items: ca.items,
        cotizacion: ca.cotizacion_usd_gs,
        desglose: { producto_gs, servicio_gs, logistica_gs, comision_gs },
        fecha_expiracion: link.fecha_expiracion,
      };
    }
  }

  @Post(':token/confirmar-mock')
  async confirmarMock(
    @Param('token') token: string,
    @Body() body: { metodo: 'tarjeta' | 'qr' | 'zimple', datos_tarjeta?: any },
  ) {
    // 1. Validar link activo
    // 2. Crear registro en pago_bancard (mock)
    const pago = await this.bancardMock.iniciarPago(empresaId, { ... });
    // 3. Simular delay 2-3s
    // 4. Devolver respuesta exitosa fake
    // 5. Marcar pago_link.estado = 'pagado', vincular pago_bancard_id
    // 6. Disparar post-hook (avanzar estado de la CA a pago_recibido)
    return { aprobado: true, ticket: 'MOCK-12345', authorization: '00' };
  }
}
```

**`bancard-mock.service.ts`** — implementa `IPaymentGateway`:

```typescript
@Injectable()
export class BancardMockService implements IPaymentGateway {
  constructor(private readonly prisma: PrismaService) {}

  async iniciarPago(empresaId: string, data: IniciarPagoDto): Promise<SesionPago> {
    // Crear pago_bancard en estado 'procesando'
    const shop_process_id = await this.generarShopProcessId(empresaId);
    await this.prisma.pago_bancard.create({
      data: {
        empresa_id: empresaId,
        shop_process_id,
        process_id: `MOCK-${Date.now()}`,
        tipo_operacion: 'single_buy',
        origen_modulo: data.origenModulo,
        origen_id: data.origenId,
        cliente_id: data.clienteId,
        monto: data.monto,
        moneda: 'PYG',
        estado: 'procesando',
        meta_json: { mock: true },
      },
    });
    return { processId: `MOCK-${shop_process_id}`, urlPago: `mock://vpos/${shop_process_id}` };
  }

  async consultarEstado(empresaId: string, spid: number): Promise<EstadoPago> {
    const pago = await this.prisma.pago_bancard.findFirst({ where: { empresa_id: empresaId, shop_process_id: spid }});
    return {
      aprobado: pago?.estado === 'aprobado',
      responseCode: '00',
      ticketNumber: 'MOCK-' + spid,
      authorizationNumber: '00',
    };
  }

  async confirmarPagoMock(spid: number): Promise<void> {
    // Marca el pago como aprobado y dispara el post-hook
    await this.prisma.pago_bancard.update({
      where: { shop_process_id: spid },
      data: { estado: 'aprobado', fecha_confirmacion: new Date() },
    });
    // Llamar al post-hook (avanzar estado de la CA)
  }

  async anular() { throw new Error('Mock — usar admin refund'); }
  async refund() { throw new Error('Mock — usar admin refund'); }
}
```

#### Frontend

**Archivos nuevos**:
- `src/pages/PagoPublicoCA.jsx` — página `/pay/ca/:token` (sin login)
- `src/components/pago-publico/MetodoSelector.jsx` — botones tarjeta/QR/Zimple
- `src/components/pago-publico/FormularioTarjeta.jsx` — input tarjeta + cvv + vencimiento (mock)
- `src/components/pago-publico/QrViewer.jsx` — QR fake con countdown
- `src/components/pago-publico/PagoExitoso.jsx` — pantalla post-pago

**Modificaciones**:
- `CompraAsistidaTab.jsx` — agregar opción "Generar link de pago" en menú cuando `estado === 'cotizado'`
- Modal/diálogo que muestra el link generado + botón "Copiar" + botón "WhatsApp" + "Email"

**Ruta pública** en `routes.jsx`:
```jsx
<Route path="/pay/ca/:token" element={<PagoPublicoCA />} />
```

**Diseño UI público**:
- Header con logo de la empresa
- Card con resumen: número CA, items, totales (USD + Gs)
- Tabs/cards para método de pago
- Pago con tarjeta: form fake con validación visual (Luhn no requerido)
- QR: imagen fake con countdown 60s
- Zimple: input nro celular + botón "Enviar push"
- Botón "PAGAR Gs. X.XXX.XXX"
- Loading 2-3s simulando procesamiento
- Pantalla de éxito con datos del comprobante mockeado

---

### Fase 2E — Webhook mock que avanza `cotizado → pago_recibido`

Cuando `BancardMockService.confirmarPagoMock()` ejecuta, debe disparar un post-hook idéntico al que disparará el webhook real cuando se integre.

**Archivo nuevo**: `src/bancard/bancard-post-pago.hook.ts`

```typescript
@Injectable()
export class BancardPostPagoHook {
  constructor(
    private readonly prisma: PrismaService,
    private readonly compraAsistidaService: CompraAsistidaService,
  ) {}

  async procesar(pagoId: string) {
    const pago = await this.prisma.pago_bancard.findUnique({ where: { id: pagoId }});
    if (pago.estado !== 'aprobado') return;

    if (pago.origen_modulo === 'compra_asistida') {
      await this.prisma.compra_asistida.update({
        where: { id: pago.origen_id },
        data: { estado: 'pago_recibido', pago_bancard_id: pago.id },
      });
      // Notificar (email/WhatsApp al operador)
    }
    // Otros módulos: factura, cobro, etc.
  }
}
```

Inyectarlo en `BancardMockService.confirmarPagoMock` y en el futuro `BancardWebhookController`.

---

### Fase 2F — Botón "Facturar" + form precargado desde CA

#### Backend

**Endpoint nuevo**: `POST /compra-asistida/:id/facturar`

```typescript
async facturar(id: string, empresaId: string, usuarioId: string, datosExtra: { punto_venta, timbrado, ... }) {
  const ca = await this.getById(id, empresaId);
  if (ca.estado !== 'entregado') throw new BadRequestException('Solo se factura desde entregado');
  if (ca.factura_id) throw new BadRequestException('Ya tiene factura asociada');
  if (!ca.cliente_id) throw new BadRequestException('La compra no tiene cliente asignado');

  // Construir items para la factura SIFEN
  const items = [
    // Items del pedido (precio_usd × cotización)
    ...ca.items.map((it, idx) => ({
      descripcion: it.descripcion,
      cantidad: it.cantidad,
      precio_unitario: Number(it.precio_usd) * Number(ca.cotizacion_usd_gs),
      iva_tipo: 10,
    })),
    // 3 líneas extra
    ...(Number(ca.servicio_gs)   > 0 ? [{ descripcion: 'Servicio de compra asistida', cantidad: 1, precio_unitario: Number(ca.servicio_gs),   iva_tipo: 10 }] : []),
    ...(Number(ca.logistica_gs)  > 0 ? [{ descripcion: 'Logística internacional',     cantidad: 1, precio_unitario: Number(ca.logistica_gs),  iva_tipo: 10 }] : []),
    ...(Number(ca.comision_gs)   > 0 ? [{ descripcion: 'Comisión administrativa',     cantidad: 1, precio_unitario: Number(ca.comision_gs),   iva_tipo: 10 }] : []),
  ];

  // Llamar al servicio existente de facturación SIFEN
  const factura = await this.facturacionService.crear(empresaId, usuarioId, {
    cliente_id: ca.cliente_id,
    moneda: 'PYG',
    items,
    origen: 'compra_asistida',
    origen_id: ca.id,
    ...datosExtra,
  });

  // Vincular y avanzar estado
  await this.prisma.compra_asistida.update({
    where: { id },
    data: { factura_id: factura.id, estado: 'facturado' },
  });

  return { compra_asistida_id: id, factura };
}
```

#### Frontend

**Modificación a `CompraAsistidaTab.jsx`**: agregar opción "Facturar" en menú cuando `estado === 'entregado'`.

**Página/modal nueva**: form similar a `NuevaFacturaPage` pero precargado desde la CA. Permite editar timbrado, punto de venta, observación. Al confirmar → llama `POST /compra-asistida/:id/facturar`.

---

### Fase 2G — Asiento contable post-facturación

**Archivo nuevo**: `src/contabilidad/services/integracion-compra-asistida.service.ts`

Cuando se emite la factura SIFEN desde una CA, generar asiento:

```
Cuenta                              Debe         Haber
─────────────────────────────────────────────────────────
Bancard por liquidar                X (bruto)
  Ventas - Compra Asistida          (subtotal items convertidos)
  Ingresos por Servicio                          (servicio_gs)
  Ingresos por Logística                         (logistica_gs)
  Ingresos por Comisión                          (comision_gs)
  IVA Débito 10%                                 (10% del total grav.)
```

Las cuentas se obtienen del plan de cuentas configurado (`bancard_config` + cuentas por defecto en `cont_plan_cuentas`).

Al conciliar la liquidación bancaria (proceso aparte en Tesorería):
```
Banco recaudador                    neto
Comisión bancaria                   comisión
IVA Crédito s/ comisión             iva comisión
  Bancard por liquidar                          bruto
```

---

### Fase 2H — Permiso `CA_REFUND` + flujo refund admin

**Migración**: agregar permiso al sistema de roles.

**Backend**:
- Nuevo endpoint `POST /compra-asistida/:id/forzar-cancelacion` que requiere permiso `CA_REFUND`
- Llama a `BancardMockService.refundMock` (en mock siempre exitoso)
- Cambia estado a `cancelado`, asienta reverso de la factura si existe

**Frontend**:
- En menú de acciones, mostrar "Forzar cancelación + refund" solo si usuario tiene permiso `CA_REFUND` Y estado ∈ {`pago_recibido`, `en_compra_china`, `en_transito`, `en_aduana`, `entregado`} (no se permite si ya está facturado, en ese caso es nota de crédito)

---

## Mapa de archivos

### Nuevos
```
prisma/migrations/20260426_compra_asistida_enum/migration.sql        ← Fase 2I
prisma/migrations/20260426_pago_link/migration.sql                   ← Fase 2D
src/bancard/bancard-mock.service.ts                                  ← Fase 2D
src/bancard/pago-link.service.ts                                     ← Fase 2D
src/bancard/pago-publico.controller.ts                               ← Fase 2D (sin JWT)
src/bancard/bancard-post-pago.hook.ts                                ← Fase 2E
src/contabilidad/services/integracion-compra-asistida.service.ts     ← Fase 2G
pos-ventas/src/pages/PagoPublicoCA.jsx                               ← Fase 2D
pos-ventas/src/components/pago-publico/MetodoSelector.jsx            ← Fase 2D
pos-ventas/src/components/pago-publico/FormularioTarjeta.jsx         ← Fase 2D
pos-ventas/src/components/pago-publico/QrViewer.jsx                  ← Fase 2D
pos-ventas/src/components/pago-publico/PagoExitoso.jsx               ← Fase 2D
pos-ventas/src/components/ventas/GenerarLinkPagoModal.jsx            ← Fase 2D
pos-ventas/src/api/pago-publico.service.js                           ← Fase 2D
pos-ventas/src/api/pago-link.service.js                              ← Fase 2D
```

### Modificados
```
src/compra-asistida/compra-asistida.service.ts            ← Fase 2I (enum + transiciones), Fase 2F (facturar)
src/compra-asistida/compra-asistida.controller.ts         ← Fase 2D (generar-link), Fase 2F (facturar), Fase 2H (forzar-cancelacion)
src/compra-asistida/compra-asistida.module.ts             ← imports BancardModule
src/bancard/bancard.module.ts                             ← exports BancardMockService + PagoLinkService
prisma/schema.prisma                                      ← enums + tabla pago_link
pos-ventas/src/components/ventas/CompraAsistidaTab.jsx    ← menú: generar link, facturar, forzar-cancelacion
pos-ventas/src/routers/routes.jsx                         ← ruta pública /pay/ca/:token
pos-ventas/src/tanstack/CompraAsistidaStack.jsx           ← nuevos hooks (generar link, facturar)
```

---

## Patrones obligatorios (recordatorio)

- **Enums SQL**: `DO $$ BEGIN CREATE TYPE ...; EXCEPTION WHEN duplicate_object THEN NULL; END $$;`
- **Migración VARCHAR → enum**: `ALTER COLUMN estado TYPE compra_asistida_estado USING estado::compra_asistida_estado`
- **Guards JWT**: `@UseGuards(AuthGuard('jwt'))` + `@GetUser() user: LoginUserInfo`
- **Controllers públicos** (sin JWT): clase separada, anotar claramente con comentario `// PÚBLICO — sin autenticación`
- **Token público**: `crypto.randomBytes(32).toString('base64url')` — 43 chars URL-safe
- **TanStack**: useQuery + useMutation + invalidateQueries + toast
- **Mock Bancard**: estructura idéntica a la real, solo cambia el "transporte" (sin HTTP a vpos.infonet.com.py)
- **Cuando se integre Bancard real**: solo se reemplaza `BancardMockService` por `BancardVposService` siguiendo el PDF oficial

---

## Variables de entorno

```env
# Mantener (para fase real futura)
BANCARD_ENCRYPTION_KEY=                       # AES-256-CBC para private_key
BANCARD_VPOS_BASE_URL_STAGING=https://vpos.infonet.com.py:8888/vpos/api/0.3
BANCARD_VPOS_BASE_URL_PROD=https://vpos.infonet.com.py/vpos/api/0.3

# Nuevas
PAGO_LINK_BASE_URL=https://app.novasis.io     # base para construir /pay/ca/:token
PAGO_LINK_EXPIRACION_DIAS=7                   # default expiración
BANCARD_MODE=mock                             # mock | real — switch del IPaymentGateway provider
```

---

## Orden de ejecución recomendado

1. **2I** — migración enum + actualizar TRANSICIONES (rompe compatibilidad pero datos preservados)
2. **2D-backend** — `pago_link` + `BancardMockService` + `PagoPublicoController`
3. **2D-frontend** — `PagoPublicoCA.jsx` + componentes de UI mock
4. **2D-integración** — botón "Generar link" en CompraAsistidaTab
5. **2E** — `BancardPostPagoHook` (avance automático de estado)
6. **2F** — endpoint `/facturar` + form precargado (frontend)
7. **2G** — asiento contable post-facturación
8. **2H** — permiso `CA_REFUND` + flujo refund admin

**Total estimado**: ~7-9 días hábiles para todo el flujo completo (mock).

---

## Casos de prueba

| ID | Caso | Resultado esperado |
|----|------|---|
| CA-T01 | Operador genera link en estado `cotizado` | URL `/pay/ca/:token` válida + link copy/share |
| CA-T02 | Cliente abre link expirado | HTTP 410 Gone, mensaje "Link expirado" |
| CA-T03 | Cliente paga con tarjeta mock | Pago aprobado, CA pasa a `pago_recibido` automáticamente |
| CA-T04 | Cliente intenta pagar 2 veces el mismo link | Segunda vez: BadRequest "Pago ya realizado" |
| CA-T05 | Operador intenta generar link en `borrador` | BadRequest "Solo en estado cotizado" |
| CA-T06 | Operador cancela CA en `pago_recibido` sin permiso | Forbidden "Requiere permiso CA_REFUND" |
| CA-T07 | Admin con `CA_REFUND` cancela CA pagada | Pago revertido (mock), estado `cancelado` |
| CA-T08 | Operador factura CA en `entregado` | Factura SIFEN creada con items + 3 líneas extra, asiento generado |
| CA-T09 | Operador intenta facturar CA en `pago_recibido` | BadRequest "Solo desde entregado" |
| CA-T10 | Operador intenta facturar CA ya facturada | BadRequest "Ya tiene factura asociada" |

---

## Cuando se integre Bancard real (post-presupuesto)

1. Implementar `BancardVposService implements IPaymentGateway` siguiendo PDF oficial
2. Cambiar `BANCARD_MODE=real` en `.env`
3. Cambiar provider en `bancard.module.ts`:
   ```typescript
   {
     provide: 'IPaymentGateway',
     useClass: process.env.BANCARD_MODE === 'real' ? BancardVposService : BancardMockService,
   }
   ```
4. Reemplazar `POST /pago-publico/:token/confirmar-mock` por flujo real con iframe VPOS + webhook firmado
5. Ejecutar tests T01-T10 con tarjetas staging del PDF
6. Configurar `BANCARD_ENCRYPTION_KEY` real en `.env` de producción
