Saltearse al contenido

Trazar una Venta

Los dos caminos de la venta — market y aliado

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:

  1. El Camino A crea muchas Ventas desde un solo carrito de compras (una Venta por Empresa). El Camino B crea una Venta por llamada.
  2. El Camino A genera cuotas una vez; el Camino B puede generar cuotas dos veces. El crearVentaUnica() del Camino B llama generarCuotas() al crear la venta; después ProcesarPagareDigital llama generarCuotas() de nuevo. El Camino A solo genera cuotas vía generarCuotasPorOrden() al momento de la creación, y ProcesarPagareDigital para 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:

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

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:

  1. Rechaza si hay ya una compra pendiente.
  2. Rechaza si el cliente está en un cooldown de reintento de pagaré.
  3. Construye CrearVentaDTO desde el request validado.
  4. Llama a VentaService::crearVenta($dto).
  5. Vacía el carrito.
  6. Retorna JSON.

3.4 Dentro de VentaService::crearVenta()

app/Services/VentaService.php:133-206. El método es un wrapper DB::transaction(...). Hace:

  1. Detecta es_indeterminado (flag especial para ventas no-producto — ver sección 5).
  2. Calcula $subTotalCalculado desde $dto->detalles como sum(cantidad * monto).
  3. Valida cliente.cupo_disponible >= subtotal — lanza ValidationException en caso contrario.
  4. Carga registros Precio con producto.empresa.sucursales (eager).
  5. Valida inventario: para cada detalle, precio.inventario >= cantidad.
  6. Se ramifica en sucursal_id:
    • Si el DTO tiene sucursal_id → llama crearVentaUnica() (venta única, usada por aliados).
    • Si no → llama crearVentasPorEmpresa() (multi-venta agrupada por empresa, usada por clientes).

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:

  1. Agrupa los detalles por empresa_id (vía precio.producto.empresa_id).
  2. Crea una fila OrdenCompra con estado=PENDIENTE, subtotal, total.
  3. Llama registrarEnCerticamara($cliente, $dto) — ver sección 3.6.
  4. Crea el Beneficiario (si se provee).
  5. 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 Venta con orden_compra_id, user_id, sucursal_id, subtotal, total, numero_cuotas, causal.
    • Si tienePagare && !esIndeterminado && cliente->pagare_firmado_en !== null, despacha GenerarCreditoDeVenta inmediatamente. Esta rama nunca se ejecuta hoy porque registrarEnCerticamara() retorna false incondicionalmente (ver 3.6).
    • Crea las filas VentaDetalle (precio_id, cantidad, monto, etc.).
    • Decrementa precio.inventario.
  6. Después del loop, llama generarCuotasPorOrden($ordenCompra, $ventasCreadas, $numeroCuotas).
  7. 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: retorna true temprano.
  • Si el cliente no: llama a Certicámara, escribe el UUID — y retorna false independientemente 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:

  1. 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.
  2. 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_vida por 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:

  • OrdenCompra está PENDIENTE.
  • Una o más filas Venta están PENDIENTE.
  • Las cuotas están escritas (maestras + por venta).
  • El inventario está decrementado.
  • Cliente.certicamara_uuid está establecido (Certicámara ha sido notificada sobre el pagaré).
  • Cliente.cupo_disponible no ha sido decrementado todavía — eso pasa en GenerarCreditoDeVenta::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:

  1. Consulta OrdenCompra::where('user_id', cliente.user_id)->where('estado', PENDIENTE).
  2. Actualiza cliente.pagare_firmado_en = now().
  3. Para cada OrdenCompra pendiente: despacha ProcesarPagareDigital.

Para el estado blocked o expired (o cualquier otro):

  1. Calcula puede_intentar_firmar_pagare_en = now() + config('pagare.retry_minutes'). El default del código es 1440 minutos (24 horas), pero config/pagare.php usa env('PAGARE_RETRY_MINUTES', 20) así que el default efectivo es 20 minutos.
  2. Anula certicamara_uuid y pagare_firmado_en.
  3. Para cada OrdenCompra pendiente:
    • Actualiza orden.estado a RECHAZADA (si blocked) o ABANDONADA (si expired).
    • Para cada venta en la orden, incrementa el inventario en cada Precio (lo restaura) y actualiza venta.estado a RECHAZADA/ABANDONADA.

3.11 Job ProcesarPagareDigital

app/Jobs/ProcesarPagareDigital.php. Un job por OrdenCompra. El job:

  1. Carga la(s) venta(s) con sucursal.empresa, user.cliente.
  2. Si no hay ventas: marca la orden como RECHAZADA, retorna. (Esto no debería suceder en flujo normal.)
  3. Si 1 venta: llama VentaService::generarCuotas($venta) luego despacha GenerarCreditoDeVenta.
  4. Si 2+ ventas: verifica que todas tengan el mismo numero_cuotas (loguea warning si no), llama VentaService::generarCuotasPorOrden(orden, ventas, numero), luego despacha GenerarCreditoDeVenta por 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:

  1. Verifica cliente.cupo_disponible >= venta.total (defensivo).
  2. Llama CreditoService::crearClienteEnCredito($cliente) — SOAP/XML a SHIVAM. Idempotente en SHIVAM.
  3. Llama CreditoService::generarCredito(...) — registra el monto de la compra + calendario de pago en SHIVAM.
  4. Transiciones: OrdenCompra → PROCESADA, Venta → APROBADA, Cliente.cupo_disponible -= Venta.total.

Qué hace en fallo (manejado vía rechazarVenta):

  1. Restaura inventario: precio.increment('inventario', detalle.cantidad).
  2. venta.estado = RECHAZADA, venta.causal = "CC:{error}".
  3. 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(), luego throw $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 tiene tries = 3 pero no tiene método failed(). 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:

  1. Calcula totales.
  2. OrdenCompra::create(['estado' => PENDIENTE, ...]).
  3. registrarEnCerticamara() — mismo bug que el Camino A, siempre retorna false.
  4. Venta::create([...]).
  5. Si $tienePagare && !is_null($cliente->pagare_firmado_en): despacha GenerarCreditoDeVenta inmediatamente. Como el Camino A, esta rama nunca se ejecuta hoy.
  6. Crea filas VentaDetalle. Decrementa inventario.
  7. Crea Beneficiario (si se provee).
  8. 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_id en VentaDetalle.
  • Sin validación de inventario, sin decremento de inventario.
  • monto viene del primer detalle del DTO, no calculado desde precio × cantidad.
  • El campo descriptor documenta lo que realmente se vendió.
  • crearVentasPorEmpresa() no auto-despacha GenerarCreditoDeVenta (línea 400 excluye indeterminado).
  • crearVentaUnica() 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

CaminoCuándoMétodo llamadoCuotas escritasRiesgo de duplicación
Camino A (marketplace, 1 empresa)en crearVentasPorEmpresa() línea 442generarCuotasPorOrden()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 442generarCuotasPorOrden()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 294generarCuotas()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:

  • ProcesarPagareDigital deberí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 crearVentaUnica y crearVentasPorEmpresa, y dejar que ProcesarPagareDigital sea 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.

#BugUbicaciónEfectoFix recomendado
1Bypass de POST /mis-comprasroutes/web.php:109-120El cliente puede comprar sin los middlewares cliente_registro_completo y verificar_cliente_presenta_moraMover ambos middlewares al grupo mis-compras, no solo a la ruta GET checkout
2Doble multiplicación de procesarCarritoapp/Http/Controllers/Market/VentaController.php:185-197El monto de cada detalle = precio * cantidad, luego VentaService::crearVenta línea 152 calcula subtotal = cantidad * monto, inflando el subtotal por un factor de cantidadPasar monto = precio (precio unitario) o detener la multiplicación en el service
3procesarCarrito fuerza numero_cuotas = 6app/Http/Controllers/Market/VentaController.php:202Ignora el plazo_minimo/maximo por línea y la selección del frontendAceptar el valor del request, validar contra linea_id
4registrarEnCerticamara() siempre retorna falseapp/Services/VentaService.php:499La rama de dispatch inmediato nunca se ejecuta; toda la generación de crédito va por el webhookRetornar true en éxito; coordinar con producto antes de cambiarlo porque cambia cuándo se genera el crédito SHIVAM
5Cuotas duplicadasapp/Services/VentaService.php:294, 442 + app/Jobs/ProcesarPagareDigital.php:80, 119-1232x cuotas en la tabla por cada venta exitosaSección 6. Política de escritor único.
6created_at vs creado_enapp/Services/VentaService.php:582, 657, 704Cuotas calculadas desde now() en lugar del timestamp de creación venta/ordenUsar creado_en directamente: $venta->creado_en ?? now()
7GenerarCreditoDeVenta voltea la orden por ventaapp/Jobs/GenerarCreditoDeVenta.php:91La primera venta exitosa en una orden multi-venta marca la orden PROCESADA, incluso si las ventas posteriores fallanSolo marcar PROCESADA después de que todas las ventas en la orden tengan estados terminales; rastrear vía consulta de hermanas
8Callback failed() faltanteapp/Jobs/GenerarCreditoDeVenta.php (sin método)Fallo final del job deja la venta en PENDIENTE para siempre, sin alertaAñadir failed(Throwable $e) que marque la venta como RECHAZADA con causal clara, despache una alerta
9Decremento de cupo no atómicoapp/Jobs/GenerarCreditoDeVenta.php:93Jobs concurrentes para el mismo cliente pueden perder actualizacionesUsar $cliente->decrement('cupo_disponible', $venta->total)
10Restauración de inventario no en transacciónapp/Jobs/ValidarPagareDigital.php:206 + app/Jobs/GenerarCreditoDeVenta.php:131Un fallo parcial durante la restauración puede dejar inventario inconsistenteEnvolver el loop de restauración + actualización de estado en DB::transaction
11Estado Cancelada en UI pero no en enumresources/js/pages/ally/ventas/Page.vue referencia canceladaEl backend usa rechazada y abandonada en su lugarActualizar la UI para manejar los dos estados reales
12Desajuste de config de retry de pagaréapp/Jobs/ValidarPagareDigital.php:150 default 1440 minutos; default env de config/pagare.php 20 minutosLa ventana efectiva de retry es corta — el cliente que perdió el SMS solo tiene 20 minutosActualizar el default del código para que coincida con config, o documentar el override de env
13Webhook no idempotenteapp/Http/Controllers/Webhooks/CerticamaraController.phpMismo webhook entregado dos veces → la cadena entera corre dos veces → cuotas duplicadas + doble créditoHashear el payload, guardar hash en tabla de dedup; abandonar si se vio recientemente
14Webhook no autenticadoroutes/api.php:29Cualquier llamador puede falsear un webhook dado un certicamara_uuidAñ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:

  1. 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.
  2. VentaService::generarCuotas y generarCuotasPorOrden — y las llamadas correspondientes en ProcesarPagareDigital. Estas son la superficie de cuotas-duplicadas. El fix requiere escoger un escritor y migrar datos.
  3. GenerarCreditoDeVenta::handle línea 91 (volteo de orden) — el problema de atomicidad multi-venta. Hay que mirar toda la orden, no solo una venta.
  4. El handler de excepciones de bootstrap/app.php — el return faltante parece un fix fácil, pero activar el envelope estructurado cambia cada contrato de error que el frontend actualmente parsea defensivamente. Coordina con frontend.
  5. El decremento de cliente.cupo_disponible — cambiar a decrement() es correcto, pero el resto del código lee cupo_disponible en muchos lugares, incluido con valores cacheados. Verifica la invalidación de caché.
  6. 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.
  7. 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:

  1. routes/web.php líneas 99-120 — rutas de checkout + mis-compras.
  2. app/Http/Controllers/Market/VentaController.php — tanto store() como procesarCarrito().
  3. app/Services/VentaService.phpcrearVenta(), crearVentaUnica(), crearVentasPorEmpresa(), registrarEnCerticamara(), generarCuotas(), generarCuotasPorOrden().
  4. app/Http/Controllers/Webhooks/CerticamaraController.php — entrada del webhook.
  5. app/Jobs/ValidarPagareDigital.php — job del webhook, ramas signed + blocked + expired.
  6. app/Jobs/ProcesarPagareDigital.php — el despachador cuota+crédito.
  7. app/Jobs/GenerarCreditoDeVenta.php — el puente con SHIVAM.
  8. app/Jobs/ProcesarOrdenesAbandonadas.php — el limpiador de 60 minutos.
  9. app/Http/Controllers/Aliado/VentaController.php — el camino de venta directa del portal del aliado.
  10. app/Enum/Facturacion/EstadoVenta.php + OrdenCompraEstado.php + EstadoCuota.php — los enums.
  11. docs/audit/08-sale-lifecycle-state-machine.md — el diagrama de estados completo y casos límite.
  12. 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).