Trazar una Venta

Este documento recorre una compra desde que el cliente añade un ítem a su carrito, hasta que el core bancario SHIVAM registra el crédito y la venta se vuelve aprobada. Después recorre la variante del portal aliado, que es similar pero sutilmente diferente. Luego lista cada punto frágil.
Este es el doc más relevante para producción del kit de onboarding. Las ventas son el camino del ingreso. La mayoría de las minas en el código están en este ciclo de vida. Léelo dos veces.
Fuentes primarias: app/Services/VentaService.php, app/Jobs/ValidarPagareDigital.php, app/Jobs/ProcesarPagareDigital.php, app/Jobs/GenerarCreditoDeVenta.php, app/Http/Controllers/Market/VentaController.php, app/Http/Controllers/Aliado/VentaController.php, docs/audit/08-sale-lifecycle-state-machine.md, docs/audit/16-deep-validation-study.md.
Léelo en pareja con el UML oficial del módulo:
docs/onboarding/media/diagrams/07-uml-venta-module.md— diagrama de clases producido por el equipo original. Es la fuente más autoritativa sobre la intención estructural (clases, métodos, relaciones). Este documento describe el runtime real. Cuando hay diferencia, el runtime gana en producción pero el UML revela diseño.
1. Los dos caminos
Hay dos formas en que se crea una fila Venta en Mi Plante. La máquina de estados que sigue es la misma en ambos, pero el camino de código río arriba es diferente.
graph TB
subgraph "Path A: Customer (Market)"
CART["Customer adds item to cart"]
CO["Checkout page (Vue)"]
STORE["POST /mis-compras<br/>Market\\VentaController::store"]
CXEMP["VentaService::crearVentasPorEmpresa()<br/>(groups by Empresa)"]
MULTI["1 OrdenCompra -> N Ventas<br/>(one per Empresa)"]
MASTERC["generarCuotasPorOrden()<br/>writes master cuotas + per-venta cuotas"]
end
subgraph "Path B: Aliado direct"
ALYUI["Aliado uses portal"]
ALYPOST["POST /aliados/ventas/...<br/>Aliado\\VentaController"]
UNICA["VentaService::crearVentaUnica()<br/>(single sucursal)"]
ONE["1 OrdenCompra -> 1 Venta"]
IMMEDC["generarCuotas() IMMEDIATELY<br/>(at sale creation)"]
end
subgraph "Common downstream"
PAGARE["Certicamara crearPagare()<br/>certicamara_uuid stored on Cliente"]
WAIT[("Venta state = pendiente<br/>OrdenCompra state = pendiente")]
WHOOK["Webhook: signed/blocked/expired"]
VAL["ValidarPagareDigital job"]
PROC["ProcesarPagareDigital job<br/>generates cuotas (AGAIN for Path B)"]
GEN["GenerarCreditoDeVenta job<br/>talks to SHIVAM"]
DONE([Venta = aprobada<br/>OrdenCompra = procesada])
end
CART --> CO --> STORE --> CXEMP --> MULTI --> MASTERC
ALYUI --> ALYPOST --> UNICA --> ONE --> IMMEDC
MULTI --> PAGARE
ONE --> PAGARE
PAGARE --> WAIT
WAIT --> WHOOK
WHOOK --> VAL --> PROC --> GEN --> DONE
Dos cosas para fijar en tu cabeza de inmediato:
- El Camino A crea muchas Ventas desde un solo carrito de compras (una Venta por Empresa). El Camino B crea una Venta por llamada.
- El Camino A genera cuotas una vez; el Camino B puede generar cuotas dos veces. El
crearVentaUnica()del Camino B llamagenerarCuotas()al crear la venta; despuésProcesarPagareDigitalllamagenerarCuotas()de nuevo. El Camino A solo genera cuotas víagenerarCuotasPorOrden()al momento de la creación, yProcesarPagareDigitalpara ese camino genera las cuotas por venta — estas dos escrituras también pueden producir duplicados porque las cuotas a nivel controller ya existen. Ver sección 6.
2. La máquina de estados
stateDiagram-v2
[*] --> pendiente : VentaService::crearVenta()
pendiente --> aprobada : GenerarCreditoDeVenta::handle()<br/>[crearClienteEnCredito + generarCredito both succeed]
pendiente --> rechazada : GenerarCreditoDeVenta::rechazarVenta()<br/>[SHIVAM failure: client creation or credit generation]
pendiente --> rechazada : ValidarPagareDigital::rechazarTodasLasOrdenes()<br/>[pagare state = 'blocked']
pendiente --> abandonada : ValidarPagareDigital::rechazarTodasLasOrdenes()<br/>[pagare state = 'expired']
pendiente --> abandonada : ProcesarOrdenesAbandonadas::handle()<br/>[orden pendiente > 60 min]
aprobada --> entregada : Aliado\VentaController::update()<br/>[ally marks delivered]
aprobada --> devuelta : (no code trigger)<br/>[product return, presumed external]
entregada --> legalizada : (no code trigger)<br/>[presumed manual/external]
entregada --> devuelta : (no code trigger)
legalizada --> completada : (no code trigger)<br/>[presumed: all cuotas paid]
rechazada --> [*]
abandonada --> [*]
completada --> [*]
Enum exacto en app/Enum/Facturacion/EstadoVenta.php:
| Caso | Valor | Etiqueta | 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 |
El scope scopeAprobadas de la query del modelo Venta trata aprobada, entregada, legalizada, completada como los estados “buenos”. Este scope se usa en las estadísticas del dashboard del aliado.
Tres estados no tienen trigger en código: legalizada, completada, devuelta. Doc 08 sección 6.5 confirma que aparecen en datos reales, implicando actualizaciones externas/manuales. Si añades una funcionalidad que depende de una transición a estos estados, debes añadir el trigger tú mismo.
OrdenCompra tiene su propia máquina (más simple):
stateDiagram-v2
[*] --> pendiente : VentaService::crearVenta()
pendiente --> procesada : GenerarCreditoDeVenta::handle()<br/>[credit OK for THIS venta -- see warning in sec 7]
pendiente --> rechazada : ValidarPagareDigital<br/>[pagare blocked]
pendiente --> rechazada : ProcesarPagareDigital<br/>[orden has no ventas]
pendiente --> abandonada : ProcesarOrdenesAbandonadas<br/>[60 min timeout]
pendiente --> abandonada : ValidarPagareDigital<br/>[pagare expired]
procesada --> [*]
rechazada --> [*]
abandonada --> [*]
Valores de OrdenCompraEstado: PENDIENTE, PROCESADA, RECHAZADA, ABANDONADA.
3. Camino A — Flujo del Cliente (Market)
Este es el camino en el que la mayoría de las revisiones de código se enfocan porque es el visible al cliente. Lo trazamos de extremo a extremo.
3.1 El carrito
El cliente añade ítems a su Carrito vía axios.post(route('carrito.store'), { precio_id, cantidad }). El endpoint es app/Http/Controllers/Market/CarritoController.php. Cada fila en carritos vincula un User con un Precio con una cantidad. El carrito se limpia después de una venta exitosa.
HandleInertiaRequests precarga auth.carritoData en cada petición autenticada, cacheado como carrito_{user_id} (ver app/Http/Middleware/HandleInertiaRequests.php:163-189). Las mutaciones del carrito llaman Cache::forget('carrito_' . $user->id).
UI del carrito en frontend: resources/js/pages/Compras/Index.vue y resources/js/components/Cart/.
3.2 Entrada al checkout
El cliente navega a /checkout. Rutas (routes/web.php:99-106):
Route::get('checkout', function() { $user = Auth::user(); $hasItems = $user && $user->carrito()->count() > 0; if (! $hasItems) { return redirect()->route('home'); } return \Inertia\Inertia::render('Checkout/Index');})->middleware('cliente_registro_completo', 'verificar_cliente_presenta_mora')->name('checkout.view');Nota el middleware: cliente_registro_completo (el cliente debe haber completado /usuario/completar-registro y tener un cupo_vence_en no expirado) más verificar_cliente_presenta_mora (el cliente no debe estar en mora según SHIVAM, vía CreditoService::clientePresentaMora()). Esta petición GET está protegida.
BUG (hallazgo #1 del doc 16): El endpoint POST que realmente crea la venta, POST /mis-compras, no tiene estos middlewares. Mira las líneas 109-120 de routes/web.php:
Route::prefix('mis-compras')->group(function () { Route::get('/', [OrdenCompraController::class, 'index'])->name('cliente.compras.index'); Route::get('{id}', [OrdenCompraController::class, 'show'])->name('cliente.compras.show'); Route::post('/', [MarketVentaController::class, 'store'])->name('cliente.compras.store'); Route::post('procesar-carrito', [MarketVentaController::class, 'procesarCarrito'])->name('cliente.compras.procesar-carrito'); Route::get('{ventaId}/detalles', [MarketVentaController::class, 'detalles'])->name('cliente.compras.detalles');});Estas rutas solo heredan auth del grupo exterior en la línea 55. Un usuario autenticado puede saltarse cliente_registro_completo y verificar_cliente_presenta_mora golpeando POST /mis-compras directamente con un payload manipulado. Este es un agujero real de seguridad/integridad de datos.
3.3 Enviando el carrito: POST /mis-compras
El frontend construye un payload con los ítems del carrito más un bloque beneficiario (un tercero que puede recibir los bienes) y lo POSTea. El controller es app/Http/Controllers/Market/VentaController.php::store():
public function store(CrearVentaClienteRequest $request): JsonResponse{ $user = Auth::user(); $cliente = $user->cliente;
if ($user->comprasPendientes()->exists()) { throw ValidationException::withMessages([ 'error' => ['El cliente tiene una compra pendiente. ...'], ]); }
if ($cliente->puede_intentar_firmar_pagare_en !== null && $cliente->puede_intentar_firmar_pagare_en->isFuture()) { throw ValidationException::withMessages([ 'error' => ['Aún no puedes continuar con tu proceso de compra. ...'], ]); }
$validatedData = $request->validated(); $validatedData['user_id'] = $user->id;
$dto = CrearVentaDTO::fromArray($validatedData); $venta = $this->ventaService->crearVenta($dto);
try { $user->carrito()->delete(); Cache::forget('carrito_' . $user->id); } catch (\Throwable $e) {}
return response()->json([ 'success' => true, 'data' => $venta, 'message' => 'Compra realizada exitosamente' ], 201);}Qué hace:
- Rechaza si hay ya una compra pendiente.
- Rechaza si el cliente está en un cooldown de reintento de pagaré.
- Construye
CrearVentaDTOdesde el request validado. - Llama a
VentaService::crearVenta($dto). - Vacía el carrito.
- Retorna JSON.
3.4 Dentro de VentaService::crearVenta()
app/Services/VentaService.php:133-206. El método es un wrapper DB::transaction(...). Hace:
- Detecta
es_indeterminado(flag especial para ventas no-producto — ver sección 5). - Calcula
$subTotalCalculadodesde$dto->detallescomosum(cantidad * monto). - Valida
cliente.cupo_disponible >= subtotal— lanzaValidationExceptionen caso contrario. - Carga registros
Precioconproducto.empresa.sucursales(eager). - Valida inventario: para cada detalle,
precio.inventario >= cantidad. - Se ramifica en
sucursal_id:- Si el DTO tiene
sucursal_id→ llamacrearVentaUnica()(venta única, usada por aliados). - Si no → llama
crearVentasPorEmpresa()(multi-venta agrupada por empresa, usada por clientes).
- Si el DTO tiene
El camino del cliente usa crearVentasPorEmpresa() porque el cliente no elige una sucursal — el flujo del marketplace asigna una automáticamente.
3.5 crearVentasPorEmpresa()
VentaService.php:304-450. La lógica, en plain English:
- Agrupa los detalles por
empresa_id(víaprecio.producto.empresa_id). - Crea una fila
OrdenCompraconestado=PENDIENTE,subtotal,total. - Llama
registrarEnCerticamara($cliente, $dto)— ver sección 3.6. - Crea el
Beneficiario(si se provee). - Para cada grupo de empresa:
- Calcula el subtotal de la empresa y el descuento proporcional.
- Selecciona la primera sucursal de la empresa.
- Crea una fila
Ventaconorden_compra_id,user_id,sucursal_id,subtotal,total,numero_cuotas,causal. - Si
tienePagare && !esIndeterminado && cliente->pagare_firmado_en !== null, despachaGenerarCreditoDeVentainmediatamente. Esta rama nunca se ejecuta hoy porqueregistrarEnCerticamara()retorna false incondicionalmente (ver 3.6). - Crea las filas
VentaDetalle(precio_id,cantidad,monto, etc.). - Decrementa
precio.inventario.
- Después del loop, llama
generarCuotasPorOrden($ordenCompra, $ventasCreadas, $numeroCuotas). - Retorna ya sea una Venta única o una Collection.
3.6 registrarEnCerticamara() — el bug silencioso
VentaService.php:452-500:
private function registrarEnCerticamara(Cliente $cliente, CrearVentaDTO $dto): bool{ if (!is_null($cliente->certicamara_uuid)) return true; // Ya registrado // ... build payload from cliente, persona, user ... $result = $this->certicamaraService->crearPagare([...]);
Log::info('Respuesta de Certicámara al crear pagaré', [ 'cliente_id' => $cliente->id, 'response' => $result, ]);
$cliente->update([ 'certicamara_uuid' => $result['uuid'] ?? null, ]);
return false; // <-- BUG: always false}Dos resultados:
- Si el cliente ya tiene un
certicamara_uuid: retornatruetemprano. - Si el cliente no: llama a Certicámara, escribe el UUID — y retorna
falseindependientemente de si la llamada tuvo éxito.
El efecto río abajo es que la rama de dispatch inmediato en crearVentaUnica/crearVentasPorEmpresa nunca se ejecuta en la primera venta. La única forma en que la generación de crédito sucede es vía el camino del webhook. Ver hallazgo #7 del doc 16.
Riesgo de arreglarlo: cambiar el return a true súbitamente habilitaría el camino de dispatch inmediato. Ese camino se salta ProcesarPagareDigital, así que las cuotas se generarían solo por generarCuotas() / generarCuotasPorOrden() al crear la venta — no dos veces. Pero también significa que el crédito SHIVAM se genera antes de que el pagaré sea firmado, lo cual la lógica de negocio casi con certeza no quiere. Coordina con producto antes de tocar este método.
3.7 generarCuotasPorOrden()
VentaService.php:644-752. Esto genera dos conjuntos de cuotas:
- Cuotas maestras para la OrdenCompra:
Cuota::create([... 'venta_id' => null, 'orden_compra_id' => $ordenCompra->id, ...]). Estas representan la obligación mensual total del cliente a través de todas las ventas de la orden. - Cuotas por venta: para cada venta en la orden, filas adicionales distribuyendo el principal proporcionalmente.
Cuota::create([... 'venta_id' => $venta->id, 'orden_compra_id' => $ordenCompra->id, ...]).
Entonces para una orden multi-venta con numero_cuotas = 6 y 2 ventas, la tabla de cuotas obtiene 6 + 6 + 6 = 18 filas (6 maestras + 6 para venta A + 6 para venta B).
El cálculo de las cuotas incluye:
- Capital (fórmula de amortización o línea recta si
tasa_nominal == 0). - Interés sobre el principal restante.
- Seguro de vida (factor proporcional de
principal * seguro_vidapor cuota). - Estudio de crédito (solo en la primera cuota, valor de
config('app.monto_estudio_credito')). - Fianza (agregada en las primeras
floor(numero_cuotas / 4)cuotas — ver línea 578).
El observaciones de la primera cuota recibe una nota legible para humano sobre el estudio incluido.
Atención — el bug en la línea 582: $fechaVencimiento = $venta->created_at ?? now();. Pero Venta extiende Modelo, cuyo CREATED_AT es creado_en, no created_at. El accessor $venta->created_at retorna null. Así que el calendario empieza desde now(), no desde el timestamp real de creación de la venta. Para ventas creadas cerca del fin de mes esto puede correr la primera fecha de vencimiento por horas o días. Hallazgo #9 del doc 16.
3.8 El cliente retorna la respuesta
El controller retorna 201 con el payload de la venta. El frontend Vue redirige a /mis-compras/{id} — pero la venta todavía está pendiente. En este punto:
OrdenCompraestáPENDIENTE.- Una o más filas
VentaestánPENDIENTE. - Las cuotas están escritas (maestras + por venta).
- El inventario está decrementado.
Cliente.certicamara_uuidestá establecido (Certicámara ha sido notificada sobre el pagaré).Cliente.cupo_disponibleno ha sido decrementado todavía — eso pasa enGenerarCreditoDeVenta::handle().
Al cliente ahora se le dice que vaya a firmar el pagaré en el portal de Certicámara (link que recibe por email, o instrucción in-app). Hasta que lo haga, la venta está en limbo por hasta 60 minutos (el timeout de abandono).
3.9 El cliente firma el pagaré → camino del webhook
El cliente firma en el portal de Certicámara. Certicámara POSTea /api/v1/webhooks/certicamara con {uuid, state: 'signed', message}. El webhook no está autenticado (hallazgo del doc 10).
CerticamaraController::__invoke() busca el cliente por certicamara_uuid, despacha ValidarPagareDigital, retorna 200.
3.10 Job ValidarPagareDigital
app/Jobs/ValidarPagareDigital.php. El job recibe el cliente y el payload completo del webhook.
Para el estado signed:
- Consulta
OrdenCompra::where('user_id', cliente.user_id)->where('estado', PENDIENTE). - Actualiza
cliente.pagare_firmado_en = now(). - Para cada OrdenCompra pendiente: despacha
ProcesarPagareDigital.
Para el estado blocked o expired (o cualquier otro):
- Calcula
puede_intentar_firmar_pagare_en = now() + config('pagare.retry_minutes'). El default del código es 1440 minutos (24 horas), peroconfig/pagare.phpusaenv('PAGARE_RETRY_MINUTES', 20)así que el default efectivo es 20 minutos. - Anula
certicamara_uuidypagare_firmado_en. - Para cada OrdenCompra pendiente:
- Actualiza
orden.estadoaRECHAZADA(si blocked) oABANDONADA(si expired). - Para cada venta en la orden, incrementa el inventario en cada
Precio(lo restaura) y actualizaventa.estadoaRECHAZADA/ABANDONADA.
- Actualiza
3.11 Job ProcesarPagareDigital
app/Jobs/ProcesarPagareDigital.php. Un job por OrdenCompra. El job:
- Carga la(s) venta(s) con
sucursal.empresa, user.cliente. - Si no hay ventas: marca la orden como RECHAZADA, retorna. (Esto no debería suceder en flujo normal.)
- Si 1 venta: llama
VentaService::generarCuotas($venta)luego despachaGenerarCreditoDeVenta. - Si 2+ ventas: verifica que todas tengan el mismo
numero_cuotas(loguea warning si no), llamaVentaService::generarCuotasPorOrden(orden, ventas, numero), luego despachaGenerarCreditoDeVentapor venta.
EL PROBLEMA DE CUOTAS DUPLICADAS (hallazgo #8 del doc 16):
En el Camino A (multi-venta marketplace), generarCuotasPorOrden ya corrió al crear la venta en crearVentasPorEmpresa() línea 442. Ahora ProcesarPagareDigital llama generarCuotasPorOrden de nuevo para la misma orden. Resultado: cada Cuota está duplicada.
En casos de venta única vía crearVentasPorEmpresa (una empresa, una venta), la rama de venta única de ProcesarPagareDigital llama generarCuotas(). La orden ya tenía cuotas maestras + por venta de generarCuotasPorOrden. Ahora añadimos otro conjunto de cuotas por venta. Más duplicación.
Por qué no se ha detectado visualmente: no hay superficie de UI que pinte las cuotas maestras vs cuotas por venta como filas separadas. El cliente solo ve los totales maestros; el aliado solo ve el monto por venta. La duplicación está en la tabla.
Hasta que esto se arregle, no extiendas generarCuotas, generarCuotasPorOrden, o ProcesarPagareDigital::handle sin primero coordinar una estrategia de deduplicación. La sección 7 lista la restricción.
3.12 Job GenerarCreditoDeVenta
app/Jobs/GenerarCreditoDeVenta.php. Un job por Venta.
public function handle(CreditoService $creditoService): void{ try { if ($this->cliente->cupo_disponible < $this->venta->total) { throw new \Exception('Cupo insuficiente para procesar la venta'); }
$resultadoCliente = $creditoService->crearClienteEnCredito($this->cliente); if ($resultadoCliente !== true) { $this->rechazarVenta("CC:{$resultadoCliente}", 'error al crear cliente en crédito'); return; }
$resultadoCredito = $creditoService->generarCredito($this->cliente, $this->empresa, $this->venta->total, $this->venta->numero_cuotas); if ($resultadoCredito !== true) { $this->rechazarVenta("CC:{$resultadoCredito}", 'error al generar crédito'); return; }
DB::beginTransaction(); $ordenCompra = $this->venta->ordenCompra; $ordenCompra->update(['estado' => OrdenCompraEstado::PROCESADA]); $this->venta->update(['estado' => EstadoVenta::APROBADA]); $this->cliente->update(['cupo_disponible' => $this->cliente->cupo_disponible - $this->venta->total]); DB::commit(); } catch (\Exception $e) { DB::rollBack(); throw $e; }}Qué hace en éxito:
- Verifica
cliente.cupo_disponible >= venta.total(defensivo). - Llama
CreditoService::crearClienteEnCredito($cliente)— SOAP/XML a SHIVAM. Idempotente en SHIVAM. - Llama
CreditoService::generarCredito(...)— registra el monto de la compra + calendario de pago en SHIVAM. - Transiciones:
OrdenCompra→ PROCESADA,Venta→ APROBADA,Cliente.cupo_disponible-=Venta.total.
Qué hace en fallo (manejado vía rechazarVenta):
- Restaura inventario:
precio.increment('inventario', detalle.cantidad). venta.estado = RECHAZADA,venta.causal = "CC:{error}".- No toca la OrdenCompra. En una orden multi-venta esto significa que una venta puede estar RECHAZADA mientras las otras tienen éxito — y la OrdenCompra todavía puede ir a PROCESADA, porque la primera venta en tener éxito voltea la orden.
Qué hace en excepción (lanza):
DB::rollBack(), luegothrow $e. Laravel reintentará hasta 3 veces con backoff de 60s (tries = 3,backoff = 60).- Después del 3er fallo, el job va a
failed_jobs. La venta se queda PENDIENTE para siempre a menos que alguien la re-ejecute manualmente.
LOS BUGS CONOCIDOS AQUÍ (hallazgos #10, #11, #12 del doc 16):
- Orden marcada PROCESADA demasiado temprano (#10): en una orden multi-venta, el éxito de la primera venta transiciona la OrdenCompra a PROCESADA. Si las ventas subsecuentes fallan, la orden todavía se lee como “hecha.”
- Sin callback
failed()(#11): el job tienetries = 3pero no tiene métodofailed(). Después de 3 fallos, la venta se queda PENDIENTE y nada alerta. No hay job de retry y no hay dashboard de operaciones. - Decremento de cupo no atómico (#12):
$cliente->update(['cupo_disponible' => $cliente->cupo_disponible - $venta->total])lee luego escribe. Jobs concurrentes para el mismo cliente pueden perder actualizaciones. Usa$cliente->decrement('cupo_disponible', $venta->total)en su lugar.
3.13 Diagrama de secuencia del camino-cliente (completo)
sequenceDiagram
actor C as Customer
participant Vue as Checkout/Index.vue
participant AX as axios
participant Ctrl as Market\VentaController
participant Svc as VentaService
participant CertSvc as CerticamaraService
participant DB as MySQL
participant Cert as Certicamara portal
participant API as POST /api/v1/webhooks/certicamara
participant Q as Queue (creditos)
participant VPJ as ValidarPagareDigital
participant PPJ as ProcesarPagareDigital
participant GCJ as GenerarCreditoDeVenta
participant Core as SHIVAM
C->>Vue: confirm checkout
Vue->>AX: POST /mis-compras {detalles, beneficiario, numero_cuotas}
AX->>Ctrl: store(CrearVentaClienteRequest)
Ctrl->>Svc: crearVenta(dto)
Svc->>DB: BEGIN
Svc->>DB: INSERT orden_compra (PENDIENTE)
Svc->>CertSvc: crearPagare({...})
CertSvc->>Cert: POST /pagare
Cert-->>CertSvc: {uuid}
Svc->>DB: UPDATE cliente SET certicamara_uuid
Svc->>DB: INSERT beneficiario
loop per empresa
Svc->>DB: INSERT venta (PENDIENTE)
Svc->>DB: INSERT venta_detalle[]
Svc->>DB: UPDATE precio SET inventario -= cantidad
end
Svc->>DB: INSERT cuotas (master + per-venta) -- generarCuotasPorOrden
Svc->>DB: COMMIT
Svc-->>Ctrl: Venta(s)
Ctrl->>DB: DELETE carrito
Ctrl-->>AX: 201 {success:true, data:venta}
AX-->>Vue: success
Vue->>C: "Compra realizada. Firma tu pagaré."
C->>Cert: opens portal, signs
Cert->>API: POST /webhooks/certicamara {uuid, state:'signed'}
API->>Q: dispatch(ValidarPagareDigital)
API-->>Cert: 200 OK
Q->>VPJ: handle
VPJ->>DB: SELECT pending orden_compras for user_id
VPJ->>DB: UPDATE cliente SET pagare_firmado_en = now()
VPJ->>Q: dispatch(ProcesarPagareDigital) per order
Q->>PPJ: handle
PPJ->>DB: SELECT ventas with relations
PPJ->>Svc: generarCuotas / generarCuotasPorOrden --- DUPLICATE WRITE (BUG)
PPJ->>Q: dispatch(GenerarCreditoDeVenta) per venta
Q->>GCJ: handle per venta
GCJ->>Core: crearCliente SOAP
GCJ->>Core: registrarCompra SOAP
Core-->>GCJ: VCARD + ApplNo
GCJ->>DB: BEGIN
GCJ->>DB: UPDATE orden SET estado=PROCESADA --- happens per venta (BUG)
GCJ->>DB: UPDATE venta SET estado=APROBADA
GCJ->>DB: UPDATE cliente SET cupo_disponible -= venta.total --- non-atomic (BUG)
GCJ->>DB: COMMIT
4. Camino B — Flujo directo del Aliado
El portal del aliado permite a los vendedores crear una venta a nombre de un cliente (ej., ventas en tienda). El flujo es similar pero:
- Siempre especifica un
sucursal_id(el aliado actúa a nombre de una sucursal). - Produce una sola Venta por OrdenCompra (sin agrupación por empresa, porque el aliado es de una empresa).
- Genera cuotas al crear la venta vía
generarCuotas().
4.1 Rutas
routes/ally/web.php contiene rutas aliado.ventas.*. El controller es app/Http/Controllers/Aliado/VentaController.php. El auth guard es app. La cadena de middleware es más liviana: auth:app + adiciones del grupo web. Sin cliente_registro_completo etc. — porque el aliado está actuando sobre un cliente que se asume válido.
4.2 Aliado\VentaController::store (entrada típica)
El controller valida un CrearVentaRequest, construye un CrearVentaDTO con sucursal_id establecido, y llama a VentaService::crearVenta($dto). Como sucursal_id está establecido, el service rutea a crearVentaUnica().
4.3 crearVentaUnica()
VentaService.php:212-298:
- Calcula totales.
OrdenCompra::create(['estado' => PENDIENTE, ...]).registrarEnCerticamara()— mismo bug que el Camino A, siempre retorna false.Venta::create([...]).- Si
$tienePagare && !is_null($cliente->pagare_firmado_en): despachaGenerarCreditoDeVentainmediatamente. Como el Camino A, esta rama nunca se ejecuta hoy. - Crea filas
VentaDetalle. Decrementa inventario. - Crea
Beneficiario(si se provee). - Llama
generarCuotas($venta)inmediatamente en la línea 294.
Ese último paso es la diferencia clave. crearVentaUnica escribe cuotas ahora mismo; crearVentasPorEmpresa escribe vía generarCuotasPorOrden (que técnicamente también es al momento de creación, pero produce los conjuntos maestro + por venta).
4.4 Las cuotas duplicadas, otra vez
El cliente (o aliado-actuando-como-cliente) se va a firmar el pagaré. El webhook de Certicámara se dispara. ProcesarPagareDigital::handle() para este caso de venta única toma la rama de 1 venta (líneas 67-94 del job) y llama $ventaService->generarCuotas($venta) — para una venta que ya tiene cuotas de la llamada crearVentaUnica.
Así que el Camino B produce confiablemente 2x filas de cuotas por venta en la tabla.
4.5 Diagrama de secuencia del camino-aliado
sequenceDiagram
actor A as Aliado
participant Vue as ally/ventas/create
participant AX as axios
participant Ctrl as Aliado\VentaController
participant Svc as VentaService
participant CertSvc as CerticamaraService
participant DB as MySQL
participant Cert as Certicamara portal
participant API as Webhook
participant Q as Queue
participant VPJ as ValidarPagareDigital
participant PPJ as ProcesarPagareDigital
participant GCJ as GenerarCreditoDeVenta
participant Core as SHIVAM
A->>Vue: fills sale form
Vue->>AX: POST /aliados/ventas
AX->>Ctrl: store(CrearVentaRequest)
Ctrl->>Svc: crearVenta(dto with sucursal_id)
Svc->>DB: BEGIN
Svc->>DB: INSERT orden_compra (PENDIENTE)
Svc->>CertSvc: crearPagare
CertSvc->>Cert: POST /pagare
Cert-->>CertSvc: {uuid}
Svc->>DB: UPDATE cliente SET certicamara_uuid
Svc->>DB: INSERT venta (PENDIENTE, sucursal_id, empleado_id)
Svc->>DB: INSERT venta_detalle[], UPDATE precios
Svc->>DB: INSERT beneficiario (if any)
Svc->>Svc: generarCuotas(venta) --- writes N cuotas
Svc->>DB: INSERT cuotas (N rows)
Svc->>DB: COMMIT
Svc-->>Ctrl: Venta
Ctrl-->>AX: 201
Note over A,Cert: Customer goes to Certicamara, signs
Cert->>API: POST /webhooks/certicamara
API->>Q: dispatch(ValidarPagareDigital)
Q->>VPJ: handle
VPJ->>Q: dispatch(ProcesarPagareDigital) per order
Q->>PPJ: handle
PPJ->>Svc: generarCuotas(venta) --- DUPLICATES (writes another N rows)
PPJ->>Q: dispatch(GenerarCreditoDeVenta)
Q->>GCJ: handle
GCJ->>Core: SOAP crearCliente + registrarCompra
GCJ->>DB: venta=APROBADA, orden=PROCESADA, cupo decremento
5. Caso especial: ventas es_indeterminado
Una venta “indeterminada” (es_indeterminado = true) es una donde no hay un producto específico. El aliado puede querer registrar una venta por servicios o ítems no en el catálogo.
Diferencias:
- Sin
precio_idenVentaDetalle. - Sin validación de inventario, sin decremento de inventario.
montoviene del primer detalle del DTO, no calculado desde precio × cantidad.- El campo
descriptordocumenta lo que realmente se vendió. crearVentasPorEmpresa()no auto-despachaGenerarCreditoDeVenta(línea 400 excluye indeterminado).crearVentaUnica()sí despacha para indeterminado (línea 244 no lo excluye). Esto es una inconsistencia.
Si encuentras ventas indeterminadas en QA, verifica dos veces la matemática de cuotas — esos montos alimentan el generador de cuotas directamente.
6. Timing de generación de cuotas — la tabla maestra
| Camino | Cuándo | Método llamado | Cuotas escritas | Riesgo de duplicación |
|---|---|---|---|---|
| Camino A (marketplace, 1 empresa) | en crearVentasPorEmpresa() línea 442 | generarCuotasPorOrden() | maestras + por venta (2N filas) | SÍ — la rama 1-venta de ProcesarPagareDigital llama generarCuotas() de nuevo, agregando otras N |
| Camino A (marketplace, 2+ empresas) | en crearVentasPorEmpresa() línea 442 | generarCuotasPorOrden() | maestras + por venta (N + N×Ventas filas) | SÍ — la rama multi-venta de ProcesarPagareDigital llama generarCuotasPorOrden() de nuevo |
| Camino B (aliado directo) | en crearVentaUnica() línea 294 | generarCuotas() | por venta (N filas) | SÍ — la rama 1-venta de ProcesarPagareDigital llama generarCuotas() de nuevo, agregando otras N |
No hay flujo hoy que produzca un conjunto limpio de cuotas. Cualquiera que lea la tabla de cuotas con propósitos contables debe estar consciente. El frontend la lee a través de scopes específicos que pueden filtrar incidentalmente — pero la duplicación es real a nivel de fila.
La forma del fix (cuando alguien esté listo para hacerlo) es:
ProcesarPagareDigitaldebería saltarse la generación de cuotas si ya existen para la orden/venta.- Alternativamente, eliminar las escrituras de cuotas al momento de creación en
crearVentaUnicaycrearVentasPorEmpresa, y dejar queProcesarPagareDigitalsea el único escritor.
Cualquiera de los enfoques requiere migración cuidadosa de cualquier duplicado existente en producción.
7. Las partes frágiles — cada BUG en el flujo de venta
Bugs y riesgos documentados. Cada uno tiene una referencia file:line al código ofensor.
| # | Bug | Ubicación | Efecto | Fix recomendado |
|---|---|---|---|---|
| 1 | Bypass de POST /mis-compras | routes/web.php:109-120 | El cliente puede comprar sin los middlewares cliente_registro_completo y verificar_cliente_presenta_mora | Mover ambos middlewares al grupo mis-compras, no solo a la ruta GET checkout |
| 2 | Doble multiplicación de procesarCarrito | app/Http/Controllers/Market/VentaController.php:185-197 | El monto de cada detalle = precio * cantidad, luego VentaService::crearVenta línea 152 calcula subtotal = cantidad * monto, inflando el subtotal por un factor de cantidad | Pasar monto = precio (precio unitario) o detener la multiplicación en el service |
| 3 | procesarCarrito fuerza numero_cuotas = 6 | app/Http/Controllers/Market/VentaController.php:202 | Ignora el plazo_minimo/maximo por línea y la selección del frontend | Aceptar el valor del request, validar contra linea_id |
| 4 | registrarEnCerticamara() siempre retorna false | app/Services/VentaService.php:499 | La rama de dispatch inmediato nunca se ejecuta; toda la generación de crédito va por el webhook | Retornar true en éxito; coordinar con producto antes de cambiarlo porque cambia cuándo se genera el crédito SHIVAM |
| 5 | Cuotas duplicadas | app/Services/VentaService.php:294, 442 + app/Jobs/ProcesarPagareDigital.php:80, 119-123 | 2x cuotas en la tabla por cada venta exitosa | Sección 6. Política de escritor único. |
| 6 | created_at vs creado_en | app/Services/VentaService.php:582, 657, 704 | Cuotas calculadas desde now() en lugar del timestamp de creación venta/orden | Usar creado_en directamente: $venta->creado_en ?? now() |
| 7 | GenerarCreditoDeVenta voltea la orden por venta | app/Jobs/GenerarCreditoDeVenta.php:91 | La primera venta exitosa en una orden multi-venta marca la orden PROCESADA, incluso si las ventas posteriores fallan | Solo marcar PROCESADA después de que todas las ventas en la orden tengan estados terminales; rastrear vía consulta de hermanas |
| 8 | Callback failed() faltante | app/Jobs/GenerarCreditoDeVenta.php (sin método) | Fallo final del job deja la venta en PENDIENTE para siempre, sin alerta | Añadir failed(Throwable $e) que marque la venta como RECHAZADA con causal clara, despache una alerta |
| 9 | Decremento de cupo no atómico | app/Jobs/GenerarCreditoDeVenta.php:93 | Jobs concurrentes para el mismo cliente pueden perder actualizaciones | Usar $cliente->decrement('cupo_disponible', $venta->total) |
| 10 | Restauración de inventario no en transacción | app/Jobs/ValidarPagareDigital.php:206 + app/Jobs/GenerarCreditoDeVenta.php:131 | Un fallo parcial durante la restauración puede dejar inventario inconsistente | Envolver el loop de restauración + actualización de estado en DB::transaction |
| 11 | Estado Cancelada en UI pero no en enum | resources/js/pages/ally/ventas/Page.vue referencia cancelada | El backend usa rechazada y abandonada en su lugar | Actualizar la UI para manejar los dos estados reales |
| 12 | Desajuste de config de retry de pagaré | app/Jobs/ValidarPagareDigital.php:150 default 1440 minutos; default env de config/pagare.php 20 minutos | La ventana efectiva de retry es corta — el cliente que perdió el SMS solo tiene 20 minutos | Actualizar el default del código para que coincida con config, o documentar el override de env |
| 13 | Webhook no idempotente | app/Http/Controllers/Webhooks/CerticamaraController.php | Mismo webhook entregado dos veces → la cadena entera corre dos veces → cuotas duplicadas + doble crédito | Hashear el payload, guardar hash en tabla de dedup; abandonar si se vio recientemente |
| 14 | Webhook no autenticado | routes/api.php:29 | Cualquier llamador puede falsear un webhook dado un certicamara_uuid | Añadir verificación de firma o secreto compartido + verificación HMAC |
8. Qué NO tocar sin coordinación
Si eres un dev nuevo y te encuentras alcanzando uno de estos, ve más despacio y pregunta:
VentaService::registrarEnCerticamara— cambiar el valor de retorno reactiva un camino de código con cambios semánticos que afectan cuándo se genera el crédito SHIVAM.VentaService::generarCuotasygenerarCuotasPorOrden— y las llamadas correspondientes enProcesarPagareDigital. Estas son la superficie de cuotas-duplicadas. El fix requiere escoger un escritor y migrar datos.GenerarCreditoDeVenta::handlelínea 91 (volteo de orden) — el problema de atomicidad multi-venta. Hay que mirar toda la orden, no solo una venta.- El handler de excepciones de
bootstrap/app.php— elreturnfaltante parece un fix fácil, pero activar el envelope estructurado cambia cada contrato de error que el frontend actualmente parsea defensivamente. Coordina con frontend. - El decremento de
cliente.cupo_disponible— cambiar adecrement()es correcto, pero el resto del código leecupo_disponibleen muchos lugares, incluido con valores cacheados. Verifica la invalidación de caché. - La relación
OrdenCompra → Cuota— hay una FK en la DB pero no una relación Eloquent. Si añades una, los scopes en otros lugares pueden empezar a incluir cuotas maestras que antes no. Doc 01 lo nota. - El threshold de 60 minutos de
ProcesarOrdenesAbandonadas— acortarlo disparará abandonos en clientes que aún están en el portal de Certicámara. Alargarlo infla el estado pendiente. Toca vía config, no hardcodeado.
9. Orden de lectura para internalizar el ciclo de vida de venta
Lee estos archivos en este orden:
routes/web.phplíneas 99-120 — rutas de checkout + mis-compras.app/Http/Controllers/Market/VentaController.php— tantostore()comoprocesarCarrito().app/Services/VentaService.php—crearVenta(),crearVentaUnica(),crearVentasPorEmpresa(),registrarEnCerticamara(),generarCuotas(),generarCuotasPorOrden().app/Http/Controllers/Webhooks/CerticamaraController.php— entrada del webhook.app/Jobs/ValidarPagareDigital.php— job del webhook, ramas signed + blocked + expired.app/Jobs/ProcesarPagareDigital.php— el despachador cuota+crédito.app/Jobs/GenerarCreditoDeVenta.php— el puente con SHIVAM.app/Jobs/ProcesarOrdenesAbandonadas.php— el limpiador de 60 minutos.app/Http/Controllers/Aliado/VentaController.php— el camino de venta directa del portal del aliado.app/Enum/Facturacion/EstadoVenta.php+OrdenCompraEstado.php+EstadoCuota.php— los enums.docs/audit/08-sale-lifecycle-state-machine.md— el diagrama de estados completo y casos límite.docs/audit/16-deep-validation-study.md— el reporte de validación listando cada bug.
Después de este orden de lectura, deberías poder mirar cualquier fila Venta en producción y reconstruir:
- qué camino la creó,
- qué jobs corrieron sobre ella,
- qué debería pasar a continuación,
- y cuáles de los bugs de la sección 7 podrían haberla tocado.
10. Referencias cruzadas
01-from-30000-feet.md— el mapa del código.02-trace-a-request.md— el walkthrough de la petición HTTP.04-trace-a-credit-approval.md— el pipeline de crédito (precondición para vender).docs/audit/08-sale-lifecycle-state-machine.md— fuente primaria para transiciones de estado.docs/audit/11-data-flow-diagram.md— diagramas de secuencia para carrito + checkout + webhook.docs/audit/16-deep-validation-study.md— cada bug confirmado, con prioridad.docs/onboarding/04-the-landmines/— write-ups enfocados de cada mina (la siguiente carpeta principal del kit de onboarding).