Saltearse al contenido

08 - Máquina de Estados del Ciclo de Vida de Venta

Validated Corrections

  • Existe un riesgo real de generacion duplicada de cuotas tanto en aliado como en marketplace: VentaService::crearVenta() genera cuotas durante la creacion en ambos flujos (crearVentaUnica:294, crearVentasPorEmpresa:442) y ProcesarPagareDigital::handle() vuelve a invocar generarCuotas o generarCuotasPorOrden tras la firma del pagare, sin ningun guard de idempotencia.
  • La semantica de estados de OrdenCompra en la practica esta tensionada por bugs reales: GenerarCreditoDeVenta::handle() marca la orden como PROCESADA en el primer venta que aprueba (app/Jobs/GenerarCreditoDeVenta.php:91), aunque otras ventas de la misma orden sigan pendientes o terminen en RECHAZADA. Los caminos de rechazo tampoco siempre dejan la orden en un estado coherente.
  • PlanCredito y sus transiciones deben tratarse como no operativos en el estado actual del repo; el modelo y la migracion no existen, aunque PlanCreditoService, los DTOs y el enum EstadoPlanCredito permanezcan en el codigo.

1. Valores de Enum (Exactos)

EstadoVenta (App\Enum\Facturacion\EstadoVenta)

CaseValorLabelColor
PENDIENTE'pendiente'Pendienteyellow
APROBADA'aprobada'Aprobadagreen
ENTREGADA'entregada'Entregadateal
DEVUELTA'devuelta'Devueltaorange
LEGALIZADA'legalizada'Legalizadablue
COMPLETADA'completada'Completadapurple
RECHAZADA'rechazada'Rechazadared
ABANDONADA'abandonada'Abandonadared

EstadoCuota (App\Enum\Facturacion\EstadoCuota)

CaseValor
PENDIENTE'pendiente'
PAGADA'pagada'
VENCIDA'vencida'
PARCIAL'parcial'

OrdenCompraEstado (App\Enum\Facturacion\OrdenCompraEstado)

CaseValorLabel
PENDIENTE'pendiente'Pendiente
PROCESADA'procesada'Procesada
RECHAZADA'rechazada'Rechazada
ABANDONADA'abandonada'Abandonada

EstadoPlanCredito (App\Enum\Facturacion\EstadoPlanCredito)

CaseValor
ACTIVO'activo'
COMPLETADO'completado'
CANCELADO'cancelado'
VENCIDO'vencido'

2. Diagrama de Estados de Venta

stateDiagram-v2
    [*] --> pendiente : VentaService::crearVenta()

    pendiente --> aprobada : GenerarCreditoDeVenta::handle()\n[crédito aprobado, cupo suficiente]
    pendiente --> rechazada : GenerarCreditoDeVenta::rechazarVenta()\n[falló creación de cliente en crédito O falló generación de crédito]
    pendiente --> rechazada : ValidarPagareDigital::rechazarTodasLasOrdenes()\n[estado de pagaré = 'blocked']
    pendiente --> abandonada : ProcesarOrdenesAbandonadas::handle()\n[orden pendiente > 60 min]
    pendiente --> abandonada : ValidarPagareDigital::rechazarTodasLasOrdenes()\n[estado de pagaré = 'expired']

    aprobada --> entregada : Aliado\VentaController::update()\n[aliado marca como entregado]

    entregada --> legalizada : (no se encontró disparador en código)\n[se presume manual/externo]
    legalizada --> completada : (no se encontró disparador en código)\n[se presume: todas las cuotas pagadas]

    aprobada --> devuelta : (no se encontró disparador en código)\n[se presume: devolución de producto]
    entregada --> devuelta : (no se encontró disparador en código)\n[se presume: devolución de producto]

    rechazada --> [*] : Estado terminal
    abandonada --> [*] : Estado terminal
    completada --> [*] : Estado terminal

    note right of pendiente
        Estado por defecto al crearse.
        La venta espera la firma del pagaré
        y la aprobación del sistema externo de crédito.
    end note

    note right of aprobada
        scopeAprobadas incluye:
        aprobada, entregada, legalizada, completada
    end note

3. Diagrama de Estados de OrdenCompra

stateDiagram-v2
    [*] --> pendiente : VentaService::crearVenta()\n[OrdenCompra creada con PENDIENTE]

    pendiente --> procesada : GenerarCreditoDeVenta::handle()\n[primera venta de la orden alcanza APROBADA]
    pendiente --> rechazada : ValidarPagareDigital::rechazarTodasLasOrdenes()\n[estado de pagaré = 'blocked']
    pendiente --> rechazada : ProcesarPagareDigital::handle()\n[la orden no tiene ventas]
    pendiente --> abandonada : ProcesarOrdenesAbandonadas::handle()\n[pendiente > 60 min de timeout]
    pendiente --> abandonada : ValidarPagareDigital::rechazarTodasLasOrdenes()\n[estado de pagaré = 'expired']

    procesada --> [*] : Estado terminal
    rechazada --> [*] : Estado terminal
    abandonada --> [*] : Estado terminal

    note right of pendiente
        Permanece pendiente mientras espera:
        1. Firma del pagaré en Certicámara
        2. Aprobación del sistema externo de crédito
    end note

    note right of procesada
        GenerarCreditoDeVenta actualiza la OrdenCompra
        a PROCESADA por venta cuando tiene éxito
        (app/Jobs/GenerarCreditoDeVenta.php:91).
        En órdenes multi-venta, la primera venta que
        tenga éxito ya cambia el estado de la orden,
        incluso si otras ventas luego terminan en RECHAZADA.
    end note

4. Diagrama de Estados de Cuota

stateDiagram-v2
    [*] --> pendiente : VentaService::generarCuotas()\nO VentaService::generarCuotasPorOrden()

    pendiente --> pagada : PlanCreditoService::registrarPagoCuota()\n[monto_pagado >= monto]
    pendiente --> parcial : PlanCreditoService::registrarPagoCuota()\n[monto_pagado < monto]
    pendiente --> vencida : Cuota::marcarComoVencidaSiCorresponde()\nO PlanCreditoService::marcarCuotasVencidas()\n[fecha_vencimiento está pasada Y no está pagada]

    parcial --> pagada : PlanCreditoService::registrarPagoCuota()\n[monto_pagado acumulado >= monto]
    parcial --> vencida : PlanCreditoService::marcarCuotasVencidas()\n[fecha_vencimiento está pasada]

    pagada --> [*] : Estado terminal

    note right of pendiente
        Creada con monto_pagado = 0.
        La primera cuota puede incluir estudio de crédito.
        El primer cuarto de cuotas puede incluir fianza.
    end note

    note right of vencida
        Dispara cambio de estado de PlanCredito
        de ACTIVO a VENCIDO.
    end note

Advertencia: PlanCredito no es operacional. El modelo PlanCredito (app/Models/Facturacion/PlanCredito.php) no existe y no hay migración de base de datos correspondiente. Aunque PlanCreditoService y el enum EstadoPlanCredito están definidos en el código, las transiciones de pago de cuotas vía PlanCreditoService::registrarPagoCuota() y los cambios de estado de PlanCredito (ACTIVO → COMPLETADO/VENCIDO) no pueden funcionar actualmente. El método Cuota::marcarComoVencidaSiCorresponde() funciona independientemente de PlanCredito. El método PlanCreditoService::marcarCuotasVencidas() también funciona para marcar cuotas como vencida, pero las actualizaciones de estado asociadas de PlanCredito fallarían.


5. Flujo Combinado del Ciclo de Vida

flowchart TD
    subgraph Creation ["1 - Creación de Venta (VentaService::crearVenta)"]
        A[Cliente agrega al carrito] --> B[VentaService::crearVenta]
        B --> C[OrdenCompra creada: PENDIENTE]
        B --> D["Venta(s) creada(s): PENDIENTE\n(una por empresa/sucursal)"]
        B --> E[Registros de VentaDetalle creados\nInventario decrementado]
        B --> F{¿Cliente tiene pagaré firmado?}
        F -->|No| G[CerticamaraService::crearPagare\ncerticamara_uuid guardado en Cliente]
        F -->|Sí, ya firmado| H[Despachar GenerarCreditoDeVenta\ndirectamente a la cola 'creditos']
    end

    subgraph Pagare ["2 - Firma del Pagaré (Webhook)"]
        G --> I[Webhook de Certicámara recibido]
        I --> J[ValidarPagareDigital despachado]
        J --> K{¿Estado del pagaré?}
        K -->|signed| L[procesarOrdenesPendientes\nCliente.pagare_firmado_en = now]
        L --> M["ProcesarPagareDigital despachado\n(uno por cada OrdenCompra pendiente)"]
        M --> N[generarCuotas o generarCuotasPorOrden]
        N --> O["GenerarCreditoDeVenta despachado\n(uno por cada Venta)"]
        K -->|blocked| P["OrdenCompra -> RECHAZADA\nVenta -> RECHAZADA\nInventario restaurado\ncerticamara_uuid limpiado"]
        K -->|expired| Q["OrdenCompra -> ABANDONADA\nVenta -> ABANDONADA\nInventario restaurado\ncerticamara_uuid limpiado"]
    end

    subgraph Credit ["3 - Procesamiento de Crédito (GenerarCreditoDeVenta)"]
        O --> R[CreditoService::crearClienteEnCredito]
        H --> R
        R --> S{¿Cliente creado en\nsistema externo de crédito?}
        S -->|Sí| T[CreditoService::generarCredito]
        S -->|No| U["Venta -> RECHAZADA\nInventario restaurado"]
        T --> V{¿Crédito generado?}
        V -->|Sí| W["OrdenCompra -> PROCESADA (por venta)\nVenta -> APROBADA\nCliente.cupo_disponible decrementado"]
        V -->|No| U
    end

    subgraph Fulfillment ["4 - Entrega y Completación"]
        W --> X[Aliado marca como entregado vía API]
        X --> Y["Venta -> ENTREGADA\n(solo desde APROBADA)"]
        Y --> Z["Venta -> LEGALIZADA\n(se presume externo/manual)"]
        Z --> AA["Venta -> COMPLETADA\n(se presume: todas las cuotas pagadas)"]
    end

    subgraph Cuotas ["5 - Ciclo de Vida de las Cuotas"]
        N --> BA["Cuotas creadas: PENDIENTE\n(calendario mensual)"]
        BA --> BB{¿Pago recibido?}
        BB -->|Pago completo| BC["Cuota -> PAGADA\nfecha_pago asignada"]
        BB -->|Pago parcial| BD["Cuota -> PARCIAL\nmonto_pagado acumulado"]
        BD --> BE{¿Más pago?}
        BE -->|Completa monto| BC
        BE -->|Pasada fecha de vencimiento| BF["Cuota -> VENCIDA"]
        BB -->|Pasada fecha de vencimiento| BF
        BC --> BG{¿Todas las cuotas pagadas?}
        BG -->|Sí| BH["PlanCredito -> COMPLETADO"]
        BF --> BI["PlanCredito -> VENCIDO"]
    end

    subgraph Abandonment ["6 - Abandono (Job Programado)"]
        C --> CA{¿Pendiente > 60 min?}
        CA -->|Sí| CB[ProcesarOrdenesAbandonadas]
        CB --> CC["OrdenCompra -> ABANDONADA\nTodas las Ventas -> ABANDONADA\nInventario restaurado\ncerticamara_uuid limpiado"]
    end

6. Casos Borde

6.1 Ventas Indeterminadas (es_indeterminado = true)

Las ventas indeterminadas se crean cuando no hay una referencia específica de producto/precio. Diferencias clave:

  • Sin validación de inventario — sin precio_id en VentaDetalle, sin decremento de inventario.
  • El monto viene del DTO directamente — no se calcula a partir de precio x cantidad.
  • VentaDetalle creado con precio_id = null y descriptor como documentación.
  • No puede auto-despachar GenerarCreditoDeVenta en el flujo de marketplace (crearVentasPorEmpresa) — la condición excluye explícitamente indeterminado: if ($tienePagare && !$esIndeterminado && !is_null($cliente->pagare_firmado_en)).
  • Puede despachar en flujo aliado (crearVentaUnica) — la condición NO excluye indeterminado: if ($tienePagare && !is_null($cliente->pagare_firmado_en)).
  • Sin sucursal_id cuando se crea vía flujo de marketplace — $sucursalId permanece null para ventas indeterminadas agrupadas por empresa.

6.2 Órdenes Abandonadas (ProcesarOrdenesAbandonadas)

  • Disparador: Job programado (no encolado — sin ShouldQueue), corre en cron.
  • Umbral: Órdenes en estado PENDIENTE por más de 60 minutos (now()->subMinutes(60)).
  • Procesamiento: Chunks de 10 órdenes a la vez para evitar problemas de memoria.
  • Acciones por orden:
    1. El inventario de cada venta se restaura (precio.increment('inventario', detalle.cantidad)).
    2. Cada venta se establece en ABANDONADA con causal “El firmante abandonó la firma del pagaré.”
    3. Si el cliente tiene certicamara_uuid pero no pagare_firmado_en, el UUID se limpia.
    4. La orden se establece en ABANDONADA con observaciones “Orden abandonada por parte del cliente”.
  • Aislamiento de errores: Cada orden se procesa en su propio try/catch — una falla no bloquea otras.

6.3 Ventas Rechazadas (Pagaré Bloqueado)

Cuando ValidarPagareDigital recibe un webhook con state !== 'signed':

Estado del PagaréOrdenCompra Se VuelveVenta Se Vuelve
blockedRECHAZADARECHAZADA
expiredABANDONADAABANDONADA
cualquier otroRECHAZADA (fallback por defecto blocked)RECHAZADA

Efectos adicionales:

  • certicamara_uuid limpiado en el cliente.
  • puede_intentar_firmar_pagare_en establecido en now() + config('pagare.retry_minutes', 1440). El fallback del código es 1440 minutos, pero config/pagare.php establece el valor por defecto actual en env('PAGARE_RETRY_MINUTES', 20), así que el valor por defecto efectivo es de 20 minutos.
  • pagare_firmado_en establecido en null.
  • Inventario restaurado para todas las ventas afectadas.

6.4 Rechazo del Sistema de Crédito (GenerarCreditoDeVenta)

Dos puntos de falla distintos en el flujo de crédito:

  1. crearClienteEnCredito falla — Venta establecida en RECHAZADA con causal "CC:{error_message}". Inventario restaurado. OrdenCompra permanece en PENDIENTE (no se actualiza).
  2. generarCredito falla — Igual que arriba: Venta RECHAZADA, inventario restaurado, OrdenCompra permanece en PENDIENTE.
  3. Excepción lanzada (p. ej. cupo insuficiente) — Transacción revertida. El job reintenta hasta 3 veces con backoff de 60s. En la falla final, la venta permanece en PENDIENTE.

Importante: Cuando se llama a rechazarVenta(), solo la Venta se establece en RECHAZADA. La OrdenCompra no se actualiza. En una orden multi-venta, una venta puede ser rechazada mientras otras tienen éxito.

6.5 Estados Sin Disparadores en Código

Los siguientes estados existen en el enum pero no tienen código de transición explícito en el código fuente de la aplicación:

EstadoDisparador Presunto
LEGALIZADASistema externo o acción manual de admin tras confirmación de entrega
COMPLETADATodas las cuotas pagadas / el sistema externo de crédito marca como completo
DEVUELTADevolución de producto — probablemente manejado por sistema externo o feature futura

Estos estados son reconocidos por el query scope scopeAprobadas (que incluye APROBADA, ENTREGADA, LEGALIZADA, COMPLETADA) y por el importador de CSV, confirmando que se usan en datos de producción.

6.6 Potencial Generación Duplicada de Cuotas

VentaService::crearVenta() siempre genera cuotas durante la creación de la venta: en el flujo aliado crearVentaUnica llama a generarCuotas() en la línea 294 de VentaService.php, y en el flujo marketplace crearVentasPorEmpresa llama a generarCuotasPorOrden() en la línea 442. Después, cuando se dispara el webhook de Certicámara, ProcesarPagareDigital::handle() vuelve a invocar $ventaService->generarCuotas() (línea 80) o $ventaService->generarCuotasPorOrden() (línea 119) para cada OrdenCompra pendiente antes de despachar GenerarCreditoDeVenta. Ninguna ruta verifica si las cuotas ya existen, por lo que cualquier venta cuyo pagaré sea firmado posteriormente puede producir un segundo calendario completo de cuotas. Esto afecta tanto al flujo aliado como al de marketplace.


7. Tabla Resumen de Transiciones de Estado

Transiciones de Venta

DeADisparadorUbicación
(nuevo)pendienteCreación de ventaVentaService::crearVenta()
pendienteaprobadaCrédito aprobadoGenerarCreditoDeVenta::handle()
pendienterechazadaCrédito fallóGenerarCreditoDeVenta::rechazarVenta()
pendienterechazadaPagaré bloqueadoValidarPagareDigital::rechazarTodasLasOrdenes()
pendienteabandonadaPagaré expiradoValidarPagareDigital::rechazarTodasLasOrdenes()
pendienteabandonadaTimeout de 60 minProcesarOrdenesAbandonadas::handle()
aprobadaentregadaAliado confirma entregaAliado\VentaController::update()
entregadalegalizada(sin disparador en código)Se presume externo/manual
legalizadacompletada(sin disparador en código)Se presume todas las cuotas pagadas
aprobada/entregadadevuelta(sin disparador en código)Se presume devolución de producto

Transiciones de OrdenCompra

DeADisparadorUbicación
(nuevo)pendienteCreación de ventaVentaService::crearVenta()
pendienteprocesadaPrimera venta de la orden alcanza APROBADAGenerarCreditoDeVenta::handle() (app/Jobs/GenerarCreditoDeVenta.php:91)
pendienterechazadaPagaré bloqueadoValidarPagareDigital::rechazarTodasLasOrdenes()
pendienterechazadaSin ventas en la ordenProcesarPagareDigital::handle()
pendienteabandonadaPagaré expiradoValidarPagareDigital::rechazarTodasLasOrdenes()
pendienteabandonadaTimeout de 60 minProcesarOrdenesAbandonadas::handle()

Transiciones de Cuota

DeADisparadorUbicación
(nuevo)pendienteGeneración de cuotasVentaService::generarCuotas() / generarCuotasPorOrden()
pendientepagadaPago completoPlanCreditoService::registrarPagoCuota()
pendienteparcialPago parcialPlanCreditoService::registrarPagoCuota()
pendientevencidaPasada fecha de vencimientoCuota::marcarComoVencidaSiCorresponde() / PlanCreditoService::marcarCuotasVencidas()
parcialpagadaPago restantePlanCreditoService::registrarPagoCuota()
parcialvencidaPasada fecha de vencimientoPlanCreditoService::marcarCuotasVencidas()