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) yProcesarPagareDigital::handle()vuelve a invocargenerarCuotasogenerarCuotasPorOrdentras la firma del pagare, sin ningun guard de idempotencia. - La semantica de estados de
OrdenCompraen la practica esta tensionada por bugs reales:GenerarCreditoDeVenta::handle()marca la orden comoPROCESADAen el primer venta que aprueba (app/Jobs/GenerarCreditoDeVenta.php:91), aunque otras ventas de la misma orden sigan pendientes o terminen enRECHAZADA. Los caminos de rechazo tampoco siempre dejan la orden en un estado coherente. PlanCreditoy sus transiciones deben tratarse como no operativos en el estado actual del repo; el modelo y la migracion no existen, aunquePlanCreditoService, los DTOs y el enumEstadoPlanCreditopermanezcan en el codigo.
1. Valores de Enum (Exactos)
EstadoVenta (App\Enum\Facturacion\EstadoVenta)
| Case | Valor | Label | Color |
|---|---|---|---|
PENDIENTE | 'pendiente' | Pendiente | yellow |
APROBADA | 'aprobada' | Aprobada | green |
ENTREGADA | 'entregada' | Entregada | teal |
DEVUELTA | 'devuelta' | Devuelta | orange |
LEGALIZADA | 'legalizada' | Legalizada | blue |
COMPLETADA | 'completada' | Completada | purple |
RECHAZADA | 'rechazada' | Rechazada | red |
ABANDONADA | 'abandonada' | Abandonada | red |
EstadoCuota (App\Enum\Facturacion\EstadoCuota)
| Case | Valor |
|---|---|
PENDIENTE | 'pendiente' |
PAGADA | 'pagada' |
VENCIDA | 'vencida' |
PARCIAL | 'parcial' |
OrdenCompraEstado (App\Enum\Facturacion\OrdenCompraEstado)
| Case | Valor | Label |
|---|---|---|
PENDIENTE | 'pendiente' | Pendiente |
PROCESADA | 'procesada' | Procesada |
RECHAZADA | 'rechazada' | Rechazada |
ABANDONADA | 'abandonada' | Abandonada |
EstadoPlanCredito (App\Enum\Facturacion\EstadoPlanCredito)
| Case | Valor |
|---|---|
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. AunquePlanCreditoServicey el enumEstadoPlanCreditoestán definidos en el código, las transiciones de pago de cuotas víaPlanCreditoService::registrarPagoCuota()y los cambios de estado de PlanCredito (ACTIVO → COMPLETADO/VENCIDO) no pueden funcionar actualmente. El métodoCuota::marcarComoVencidaSiCorresponde()funciona independientemente de PlanCredito. El métodoPlanCreditoService::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_idenVentaDetalle, sin decremento de inventario. - El monto viene del DTO directamente — no se calcula a partir de precio x cantidad.
- VentaDetalle creado con
precio_id = nullydescriptorcomo documentación. - No puede auto-despachar
GenerarCreditoDeVentaen 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 —
$sucursalIdpermanece 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
PENDIENTEpor más de 60 minutos (now()->subMinutes(60)). - Procesamiento: Chunks de 10 órdenes a la vez para evitar problemas de memoria.
- Acciones por orden:
- El inventario de cada venta se restaura (
precio.increment('inventario', detalle.cantidad)). - Cada venta se establece en
ABANDONADAcon causal “El firmante abandonó la firma del pagaré.” - Si el cliente tiene
certicamara_uuidpero nopagare_firmado_en, el UUID se limpia. - La orden se establece en
ABANDONADAcon observaciones “Orden abandonada por parte del cliente”.
- El inventario de cada venta se restaura (
- 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 Vuelve | Venta Se Vuelve |
|---|---|---|
blocked | RECHAZADA | RECHAZADA |
expired | ABANDONADA | ABANDONADA |
| cualquier otro | RECHAZADA (fallback por defecto blocked) | RECHAZADA |
Efectos adicionales:
certicamara_uuidlimpiado en el cliente.puede_intentar_firmar_pagare_enestablecido ennow() + config('pagare.retry_minutes', 1440). El fallback del código es 1440 minutos, peroconfig/pagare.phpestablece el valor por defecto actual enenv('PAGARE_RETRY_MINUTES', 20), así que el valor por defecto efectivo es de 20 minutos.pagare_firmado_enestablecido 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:
crearClienteEnCreditofalla — Venta establecida enRECHAZADAcon causal"CC:{error_message}". Inventario restaurado. OrdenCompra permanece enPENDIENTE(no se actualiza).generarCreditofalla — Igual que arriba: VentaRECHAZADA, inventario restaurado, OrdenCompra permanece enPENDIENTE.- 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:
| Estado | Disparador Presunto |
|---|---|
LEGALIZADA | Sistema externo o acción manual de admin tras confirmación de entrega |
COMPLETADA | Todas las cuotas pagadas / el sistema externo de crédito marca como completo |
DEVUELTA | Devolució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
| De | A | Disparador | Ubicación |
|---|---|---|---|
| (nuevo) | pendiente | Creación de venta | VentaService::crearVenta() |
pendiente | aprobada | Crédito aprobado | GenerarCreditoDeVenta::handle() |
pendiente | rechazada | Crédito falló | GenerarCreditoDeVenta::rechazarVenta() |
pendiente | rechazada | Pagaré bloqueado | ValidarPagareDigital::rechazarTodasLasOrdenes() |
pendiente | abandonada | Pagaré expirado | ValidarPagareDigital::rechazarTodasLasOrdenes() |
pendiente | abandonada | Timeout de 60 min | ProcesarOrdenesAbandonadas::handle() |
aprobada | entregada | Aliado confirma entrega | Aliado\VentaController::update() |
entregada | legalizada | (sin disparador en código) | Se presume externo/manual |
legalizada | completada | (sin disparador en código) | Se presume todas las cuotas pagadas |
aprobada/entregada | devuelta | (sin disparador en código) | Se presume devolución de producto |
Transiciones de OrdenCompra
| De | A | Disparador | Ubicación |
|---|---|---|---|
| (nuevo) | pendiente | Creación de venta | VentaService::crearVenta() |
pendiente | procesada | Primera venta de la orden alcanza APROBADA | GenerarCreditoDeVenta::handle() (app/Jobs/GenerarCreditoDeVenta.php:91) |
pendiente | rechazada | Pagaré bloqueado | ValidarPagareDigital::rechazarTodasLasOrdenes() |
pendiente | rechazada | Sin ventas en la orden | ProcesarPagareDigital::handle() |
pendiente | abandonada | Pagaré expirado | ValidarPagareDigital::rechazarTodasLasOrdenes() |
pendiente | abandonada | Timeout de 60 min | ProcesarOrdenesAbandonadas::handle() |
Transiciones de Cuota
| De | A | Disparador | Ubicación |
|---|---|---|---|
| (nuevo) | pendiente | Generación de cuotas | VentaService::generarCuotas() / generarCuotasPorOrden() |
pendiente | pagada | Pago completo | PlanCreditoService::registrarPagoCuota() |
pendiente | parcial | Pago parcial | PlanCreditoService::registrarPagoCuota() |
pendiente | vencida | Pasada fecha de vencimiento | Cuota::marcarComoVencidaSiCorresponde() / PlanCreditoService::marcarCuotasVencidas() |
parcial | pagada | Pago restante | PlanCreditoService::registrarPagoCuota() |
parcial | vencida | Pasada fecha de vencimiento | PlanCreditoService::marcarCuotasVencidas() |