Saltearse al contenido

Errores Conocidos y Minas

Las minas — un campo con banderas de advertencia

Última actualización: 2026-05-26 Audiencia: desarrolladores nuevos que entran sin contexto previo al codebase de Mi Plante Fuente: validado contra docs/audit/16-deep-validation-study.md e inspección directa del código a nivel de archivo:línea


Cómo leer este documento

Estos son los peligros activos en el codebase a la fecha indicada arriba. No son riesgos teóricos sacados de una lista de verificación — cada entrada a continuación ha sido confirmada leyendo el código real en el archivo y rango de líneas citado. Lea esta sección antes de tocar el flujo de aprobación de crédito, el flujo de ventas, los jobs de cola o cualquier handler de webhook.

El codebase fue construido rápido y en solitario por un único desarrollador en un cronograma comprimido. El resultado es un sistema que funciona para el camino feliz pero contiene un conjunto real de minas: lugares donde el cambio obvio es el incorrecto, donde el nombre de la función miente sobre lo que hace, donde una página de frontend contradice silenciosamente al backend con el que se comunica, donde un job de cola deja el estado de la base de datos en una configuración peligrosa al fallar. Ninguno de estos problemas existe porque el desarrollador anterior fuera incompetente — son el tipo de problemas que afloran en cualquier sistema escrito bajo presión de tiempo sin un segundo par de ojos. Ahora que hay un equipo, cada uno de ellos es solucionable. El primer trabajo de este documento es asegurar que usted no pise una por accidente antes de que se aplique esa solución.

Cómo usar este documento en la práctica:

  1. Antes de editar cualquier archivo, busque la ruta del archivo en este documento. Si una mina coincide, léala antes de cambiar cualquier cosa.
  2. La tabla Índice por Archivo al final es la forma más rápida de hacer grep para entrar.
  3. La tabla Índice por Flujo de Trabajo agrupa las minas por flujo de negocio (aprobación de crédito, ciclo de vida de ventas, checkout, etc.) — úsela cuando el cambio que está haciendo sea conceptual en lugar de limitado a un archivo.
  4. Cuando arregle una mina, no elimine la entrada de este archivo. Márquela como resuelta con un hash de commit y la fecha. La historia importa más que el orden.

Rúbrica de severidad

Usamos cuatro niveles de severidad. Se refieren al impacto en producción, no a cómo se ve el código.

SeveridadSignificadoEjemplo
CriticalCorrupción de datos, brecha de seguridad, error monetario visible al cliente o ruptura general del sistema. Debe abordarse antes de escalar tráfico u onboarding de más socios.Un bug que permite a cualquier usuario autenticado crearse una cuenta de administrador en dos peticiones HTTP.
HighFallos silenciosos, estado persistente inconsistente o un flujo de trabajo que se desvía silenciosamente de la intención. No hará que la aplicación se caiga, pero producirá datos incorrectos que son difíciles de detectar después.Un job de cola que permite que una OrdenCompra llegue a PROCESADA mientras una de sus filas hijas Venta sigue en PENDIENTE.
MediumComportamiento incorrecto en casos límite (carreras de concurrencia, formas de entrada inusuales, rutas de código muertas que podrían revivirse). Producción raramente los alcanza, pero a veces lo hace.Un composable de frontend que asume una respuesta de array desde un backend que pagina.
LowCosmético, fricción de experiencia de desarrollador o código obsoleto que no se ejecuta. Vale la pena arreglarlo por higiene pero no afecta a los clientes.La UI muestra el estado cancelada para ventas cuyo valor real de enum es rechazada o abandonada.

La severidad asignada a continuación es conservadora — cuando un bug está entre dos niveles, elegí el más alto.


Formato de registro

Cada registro de mina a continuación usa la misma estructura para que pueda escanearlos rápidamente:

  • Severidad y Categoría en la parte superior
  • Archivos afectados con path:line-range
  • Qué está mal, Impacto, Reproducción (cuando es factible)
  • Por qué existe (hipótesis basada en arqueología de código — especulativa, no una crítica personal)
  • ¿Es seguro tocar? con guardarraíles explícitos
  • Solución recomendada — esquema de código donde sea útil
  • Minas relacionadas para que pueda traer los peligros conectados de una sola vez

Minas de seguridad

L-01 — Bypass de checkout vía POST directo a mis-compras

Severidad: Critical Categoría: Seguridad / Bypass de Lógica de Negocio Componente: Marketplace del cliente / Checkout Archivos afectados:

  • routes/web.php:99-120
  • app/Http/Controllers/Market/VentaController.php:26-65, 133-223

Qué está mal

GET /checkout está protegido por dos middleware: cliente_registro_completo y verificar_cliente_presenta_mora. Estos middleware imponen dos reglas de negocio — el Cliente debe haber terminado su registro y el Cliente no debe estar en estado de mora. Sin embargo, las rutas que realmente crean la compra — POST /mis-compras (llama a store) y POST /mis-compras/procesar-carrito (llama a procesarCarrito) — están dentro del mismo grupo Route::middleware('auth') pero no detrás de esos dos middleware de reglas de negocio. Solo requieren autenticación.

Mirando las definiciones de las rutas:

// routes/web.php:99-106
Route::get('checkout', function() { ... })
->middleware('cliente_registro_completo', 'verificar_cliente_presenta_mora')
->name('checkout.view');
// routes/web.php:108-120
Route::prefix('mis-compras')->group(function () {
Route::post('/', [MarketVentaController::class, 'store']);
Route::post('procesar-carrito', [MarketVentaController::class, 'procesarCarrito']);
// ... no cliente_registro_completo, no verificar_cliente_presenta_mora
});

Impacto

Cualquier usuario autenticado puede saltarse las verificaciones de reglas de negocio enviando el POST directamente. Un Cliente con registro incompleto puede comprar. Un Cliente en mora puede comprar. Ambas verificaciones del camino feliz viven en el GET de la página de checkout, no en el endpoint de creación real. La UI hace primero la llamada GET, así que un flujo humano normal funciona — pero los controles no están aplicados del lado del servidor.

Reproducción

  1. Autenticarse como un Cliente que tiene cliente_registro_completo = false o que está en mora.
  2. Saltarse el flujo del navegador y hacer curl -X POST /mis-compras/procesar-carrito con un payload válido de beneficiario y al menos un item del Carrito.
  3. La Venta se crea. Ningún middleware la bloquea.

Por qué existe

Los middleware parecen diseñados para proteger la experiencia de usuario (no permitir que alguien llegue a la página de checkout si no está listo), no la integridad de los datos (no permitir crear una Venta si el Cliente no es elegible). El controlador (VentaController::store y VentaController::procesarCarrito) sí verifica comprasPendientes() y puede_intentar_firmar_pagare_en, pero no vuelve a verificar las condiciones de registro completo o mora. Este es un error de manual de “frontend como aplicación de reglas”.

¿Es seguro tocar? Sí, con estos guardarraíles:

  • Agregue los dos middleware al grupo de rutas mis-compras, no solo al GET /checkout.
  • No quite los middleware del GET — ambas rutas los necesitan.
  • Vuelva a ejecutar cualquier test de feature que toque el flujo de órdenes abandonadas; ese flujo puede crear Ventas en estados que este middleware ahora bloquearía.

Solución recomendada

routes/web.php
Route::prefix('mis-compras')
->middleware(['cliente_registro_completo', 'verificar_cliente_presenta_mora'])
->group(function () {
Route::get('/', [OrdenCompraController::class, 'index']);
Route::get('{id}', [OrdenCompraController::class, 'show']);
Route::post('/', [MarketVentaController::class, 'store']);
Route::post('procesar-carrito', [MarketVentaController::class, 'procesarCarrito']);
Route::get('{ventaId}/detalles', [MarketVentaController::class, 'detalles']);
});

Nota: el GET / y GET /{id} son endpoints de listado — aplicarles el middleware está bien porque son vistas post-compra; un usuario sin registro completo no tendría nada que mostrar de todas formas.

Minas relacionadas: L-15 (bug del monto en procesarCarrito), L-16 (cuotas hardcoded en procesarCarrito)


L-02 — Endpoint público de historial de DataCrédito

Severidad: Critical Categoría: Seguridad / Exposición de Datos Sensibles / Regulatorio Componente: API / Integración con buró de crédito Archivos afectados:

  • routes/api.php:24-26
  • app/Http/Controllers/Api/DataCreditoController.php

Qué está mal

GET /api/v1/datacredito/historial está montada fuera de cualquier middleware de autenticación. La definición de la ruta es directa:

// routes/api.php:24-26
Route::prefix('v1')->group(function () {
Route::get('/datacredito/historial', [DataCreditoController::class, 'consultarHistorial'])
->name('api.datacredito.historial');
// ...
});

El endpoint consulta el buró DataCrédito (un buró de crédito colombiano equivalente a Experian) y devuelve el historial crediticio de la persona consultada.

Impacto

Cualquiera en internet público puede:

  1. Consultar el buró de crédito en nombre de Mi Plante usando números de DNI arbitrarios.
  2. Quemar créditos de API del buró / alcanzar límites de tasa y forzar a Mi Plante a penalizaciones del buró.
  3. Peor: cosechar datos de historial crediticio de colombianos arbitrarios y exfiltrarlos.

Esta es una exposición regulatoria de Habeas Data (Ley 1581 / Ley 1266), no solo una de seguridad. Una investigación de la SIC desencadenada por una queja de terceros podría resultar en multas de hasta 2.000 SMLMV (~$2.5B COP) por incidente.

Tampoco hay rate limiting en este endpoint, así que el costo del ataque es lo que el buró cobre por consulta multiplicado por el throughput que el atacante quiera.

Reproducción

Ventana de terminal
curl "https://miplante.com/api/v1/datacredito/historial?numero_documento=12345678&tipo_documento=CC&apellido=GARCIA"

Sin header Authorization, sin cookie de sesión, sin CSRF. Devuelve datos del buró.

Por qué existe

Este endpoint puede haber sido diseñado como una llamada solo-interna para el flujo público /consultar-cupo (consulta de Cupo por número de contrato). En ese flujo tiene sentido que un usuario no autenticado quiera revisar su Cupo. Pero montar la llamada al buró como una llamada de API pública genérica en lugar de como un helper del lado del servidor para el flujo de Cupo es lo que crea la fuga.

¿Es seguro tocar? Sí — esto debe corregirse antes de cualquier despliegue a producción que se abra a clientes reales.

Solución recomendada

Solución en dos capas:

  1. Mover el endpoint detrás de auth:web (o auth:app si es una herramienta de Aliado):
    Route::middleware('auth')->prefix('v1')->group(function () {
    Route::get('/datacredito/historial', [DataCreditoController::class, 'consultarHistorial'])
    ->middleware('throttle:10,1');
    });
  2. Mejor: no exponer el buró como endpoint HTTP en absoluto. Hacer que DataCreditoService::consultarHistorialCredito sea llamable solo desde flujos del lado del servidor (AprobarCupoService, HDCValidationService, ConsultarCupoService). El wrapper actual del controlador es una conveniencia que beneficia principalmente a los atacantes.

Minas relacionadas: L-17 (cache key insuficiente — apellido faltante), L-23 (sin rate limiting en API sensible), y entradas en docs/audit/17-critical-additional-findings.md que cubren logging de PII de respuestas de DataCrédito.


L-03 — CRUD público en marcas

Severidad: Critical Categoría: Seguridad / Integridad de Catálogo Componente: Marketplace del cliente / Catálogo de Marcas Archivos afectados:

  • routes/web.php:29 (el registro de apiResource)
  • app/Http/Controllers/Market/MarcaController.php

Qué está mal

// routes/web.php:29
Route::apiResource('marcas', MarcaController::class)->names('marcas');

Este registro está fuera del bloque Route::middleware('auth')->group(...) que comienza en la línea 55. Así que POST /marcas, PUT /marcas/{marca} y DELETE /marcas/{marca} son todas públicas.

Existe un registro separado, restringido, para administradores Aliados en routes/ally/web.php:39:

Route::apiResource('marcas', MarcaController::class)
->only(['store', 'update', 'destroy'])
->names('aliado.marcas');

Pero la versión pública ya expone los mismos endpoints. La versión del Aliado es adicional, no de reemplazo.

Impacto

Cualquier cliente no autenticado puede crear, modificar o eliminar Marcas en el catálogo. Las Marcas son referenciadas por productos.marca_id, así que eliminar una Marca cascadea (o rompe restricciones FK, dependiendo del esquema) hacia el huérfano de Productos.

Reproducción

Ventana de terminal
curl -X POST https://miplante.com/marcas -d '{"nombre":"hack"}' -H "Content-Type: application/json"
# 200 / 201 — Marca creada.
curl -X DELETE https://miplante.com/marcas/1
# 200 — Marca 1 destruida.

Por qué existe

Route::apiResource('marcas', MarcaController::class) fue probablemente agregado cuando se construía el lado público del catálogo — listar Marcas públicamente es una característica normal de marketplace. El calificador ->only(['index', 'show']) que debería restringirlo a solo-lectura fue omitido. El resultado es que los siete verbos REST del recurso están montados públicamente.

¿Es seguro tocar? Sí — aplique el filtro ->only(['index', 'show']) inmediatamente.

Solución recomendada

routes/web.php
Route::apiResource('marcas', MarcaController::class)
->only(['index', 'show'])
->names('marcas');

La misma solución aplica a lineas (línea 32 de routes/web.php) — mismo patrón, mismo problema. Agregue ->only(['index', 'show']) allí también. Vea L-31 para el problema de ruta muerta relacionado en lineas.

Minas relacionadas: L-31 (rutas de escritura de lineas referencian métodos de controlador no implementados)


L-04 — Registro de Aliado no vinculado a enlace firmado en el POST

Severidad: High Categoría: Seguridad / Escalación de Privilegios Componente: Portal de Aliado / Registro Archivos afectados:

  • routes/ally/auth.php:13-16
  • app/Http/Controllers/AliadoAuth/RegisteredUserController.php (el método store)

Qué está mal

El flujo de URL firmada para el registro de Aliado va:

  1. Un administrador aprueba una PostulacionAliado. El sistema envía al postulante una URL firmada por correo.
  2. El postulante hace clic en el enlace → GET /aliados/postulacion/{postulacion}/registro renderiza el formulario de registro.
  3. El postulante envía → POST /aliados/postulacion/registro crea el User y la Empresa.

Mire las rutas:

// routes/ally/auth.php:13-16
Route::prefix('postulacion')->group(function () {
Route::get('{postulacion}/registro', [RegisteredUserController::class, 'index'])
->name('aliado-postulacion.register')
->middleware('signed');
Route::post('registro', [RegisteredUserController::class, 'store'])
->name('aliado-postulacion.register.store');
});

El GET tiene el middleware signed y enlaza {postulacion}. El POST no tiene ni el middleware signed ni el binding de la ruta {postulacion}. No hay conexión del lado del servidor entre el enlace firmado en el que el usuario hizo clic y los datos que el usuario envía luego.

Impacto

Cualquiera en internet puede hacer POST /aliados/postulacion/registro con un cuerpo de petición que nombre un postulacion_id existente. El endpoint lo aceptará (porque no hay verificación de firma), creará un User contra esa Postulación y omitirá la invitación de enlace firmado por completo. Combine esto con la ausencia de rate limiting en el portal de Aliado (L-05) y el resultado es que los enlaces de invitación no son realmente invitaciones: son avisos.

Adicionalmente, la URL firmada tiene un TTL de 3 meses por defecto (ver el hallazgo correspondiente F-SEC-7 en docs/audit/17-critical-additional-findings.md). Incluso si el POST estuviera firmado, el enlace sería reusable por 90 días.

Reproducción

  1. Disparar la aprobación de una Postulación de administrador; el sistema crea la fila postulacion_aliados con id N.
  2. Sin abrir el correo nunca, enviar:
    Ventana de terminal
    curl -X POST https://miplante.com/aliados/postulacion/registro \
    -H "Content-Type: application/json" \
    -d '{ "postulacion_id": N, "email": "attacker@example.com", "password": "...", "empresa": {...} }'
  3. El User y la Empresa se crean contra esa Postulación.

Por qué existe

En una aplicación Laravel no-API, el middleware signed funciona contra una firma de URL completa que incluye query string. Una vez que el usuario está en la página y envía un formulario, la página re-publica a una ruta separada que no es parte de la URL firmada. El patrón de dos rutas es correcto; el bug está en no propagar el binding (y en no exigir que el POST lleve un token de un solo uso).

¿Es seguro tocar? Sí — pero tenga cuidado. Arreglar esto ingenuamente (solo agregar signed al POST) romperá las invitaciones que funcionan porque el formulario se envía sin la firma. La solución correcta involucra un token de un solo uso almacenado en la Postulación.

Solución recomendada

  1. Mover el POST bajo el mismo parámetro {postulacion} y aplicar signed:
    Route::post('{postulacion}/registro', [RegisteredUserController::class, 'store'])
    ->name('aliado-postulacion.register.store')
    ->middleware('signed');
  2. Llevar la URL firmada al atributo de acción del formulario en el renderizado del GET.
  3. Agregar un timestamp consumido_en (o token_consumido_en) a postulacion_aliados y rechazar el POST si ya está establecido. Esto hace que el enlace sea de un solo uso.
  4. Reducir el TTL de la URL firmada de 3 meses a 7 días (esto requiere un cambio de configuración en config/app.php o donde se genere la URL firmada).

Minas relacionadas: L-05 (sin rate limit en el portal de Aliado — superficie de ataque combinada)


L-05 — Login de Aliado no tiene rate limiting

Severidad: High Categoría: Seguridad / Protección Anti-Fuerza Bruta Componente: Portal de Aliado / Autenticación Archivos afectados:

  • app/Http/Controllers/AliadoAuth/AuthenticatedSessionController.php
  • routes/ally/auth.php:21
  • Comparar contra: app/Http/Requests/Auth/LoginRequest.php

Qué está mal

El login del lado del Cliente usa LoginRequest que llama a RateLimiter::for(...) y Lockout::class para throttle de intentos:

// app/Http/Requests/Auth/LoginRequest.php (usa Illuminate\Support\Facades\RateLimiter y Lockout)

El login del lado del Aliado en routes/ally/auth.php:21 es simplemente:

Route::post('login', [AuthenticatedSessionController::class, 'login'])
->name('aliado.login');

Sin middleware throttle:. Sin invocación de RateLimiter dentro del controlador.

Impacto

Hacer brute-force a las credenciales de Aliado es ilimitado. Combinado con otros dos hechos en este codebase:

  1. El portal de Aliado otorga el guard auth:app con privilegios elevados sobre datos del socio.
  2. Hay seeders que crean cuentas de admin de prueba con contraseñas hardcoded (ver F-011 en doc 17 — testadmin@example.com / Test1234.). Si esos seeders alguna vez se ejecutaron en producción o si el patrón de su contraseña se filtró, la ausencia de rate limiting significa que un login exitoso está a una petición HTTP de distancia.

Reproducción

Ejecute cualquier herramienta de credential stuffing contra POST /aliados/login. Sin throttling. La única fricción es la latencia HTTP.

Por qué existe

La autenticación del Cliente fue probablemente generada con Laravel Breeze, que incluye rate limiting por defecto. La autenticación de Aliado fue escrita a mano (AuthenticatedSessionController en el namespace AliadoAuth) sin reconstruir el rate limiter.

¿Es seguro tocar? Sí — agregue throttle:5,1 (5 intentos por minuto) a la ruta.

Solución recomendada

routes/ally/auth.php
Route::middleware('guest:app')->group(function () {
Route::post('login', [AuthenticatedSessionController::class, 'login'])
->middleware('throttle:5,1')
->name('aliado.login');
// ...
});

Aún mejor: escriba un AllyLoginRequest paralelo que refleje el LoginRequest del lado del Cliente con disparo completo del evento Lockout.

Minas relacionadas: L-04 (bypass de enlace firmado — vector de ataque combinado), L-23 (higiene)


L-06 — Encriptación de sesión deshabilitada

Severidad: High Categoría: Seguridad / Defensa en Profundidad Componente: Configuración de la aplicación Archivos afectados:

  • .env.example:38SESSION_ENCRYPT=false
  • config/session.php (lee env('SESSION_ENCRYPT'))

Qué está mal

El driver de sesión de Laravel es database (las sesiones se almacenan en la tabla sessions) y SESSION_ENCRYPT=false. El payload de sesión, incluyendo _token y cualquier dato puesto en la sesión, se almacena en MySQL en texto plano.

Impacto

Si MySQL es comprometido — fuga de backup, exposición de réplica de lectura, movimiento lateral desde otro tenant — el atacante lee todas las sesiones activas. El payload de sesión incluye el token CSRF, mensajes flash (que a veces filtran estado de negocio) y cualquier dato adjuntado vía session()->put().

Esto se agrava con L-21 (todo en un único MySQL) — una vez que MySQL se compromete, tienes sesiones, cache, queue y datos de negocio todos juntos.

Por qué existe

La encriptación de sesión en Laravel agrega un pequeño costo de CPU por petición. El valor por defecto en versiones antiguas de Laravel estaba apagado. Las versiones más nuevas de Laravel lo recomiendan. El .env.example que viene con este repo mantiene la configuración apagada.

¿Es seguro tocar? Sí — invierta la variable de entorno. No hay cambio de esquema; la columna es compatible con BLOB de todos modos.

Solución recomendada

SESSION_ENCRYPT=true

Y en el .env de producción. Después de desplegar, las sesiones existentes serán invalidadas, así que los usuarios necesitarán iniciar sesión de nuevo — agende el cambio en consecuencia.

Minas relacionadas: L-21 (un único MySQL es punto único de fallo)


L-07 — Sin headers CSP, sin configuración CORS

Severidad: High Categoría: Seguridad / Defensa en Profundidad Componente: Middleware HTTP Archivos afectados:

  • bootstrap/app.php (sin middleware Content-Security-Policy registrado)
  • config/cors.php — el archivo no existe

Qué está mal

No hay middleware Content-Security-Policy en el pipeline global. No hay config/cors.php. Laravel 12 espera uno si quiere comportamiento CORS no-por-defecto, y el comportamiento por defecto es no permitir nada CORS — lo cual es técnicamente seguro, pero significa que cualquiera que depure problemas cross-origin no encontrará documentación en el repo.

Más importante: sin CSP, la aplicación está totalmente expuesta a XSS almacenado en cualquier contenido generado por el usuario. El F-012 del audit (en docs/audit/17-critical-additional-findings.md) identifica un sink real de XSS almacenado en resources/js/pages/product/Detail.vue vía v-html="desc".

Impacto

Combinado con el hallazgo de XSS almacenado, un atacante que pueda editar una descripción de Producto puede ejecutar JavaScript arbitrario en el navegador de cualquier visitante. Un CSP no sería una solución completa al XSS, pero reduciría dramáticamente el radio de impacto (script-src 'self' bloquearía cargas de scripts externos, eval e inline scripts).

Por qué existe

Los headers de hardening son un paso que nadie recuerda agregar hasta que sucede una auditoría. Lo mismo es cierto para CORS — cuando no tienes un cliente cross-origin, no piensas en él.

¿Es seguro tocar? Sí — pero agregue los headers gradualmente con monitoreo. Un CSP demasiado agresivo rompe Inertia y Vite-dev.

Solución recomendada

  1. Instalar spatie/laravel-csp o escribir un middleware pequeño SecurityHeaders.
  2. Comenzar en modo solo-reporte por una semana; recolectar violaciones vía report-uri.
  3. Promover a forzado después de que la tabla de violaciones esté vacía.
  4. Crear config/cors.php incluso si los valores por defecto están bien — tener el archivo documenta la política.

Minas relacionadas: hallazgo de XSS almacenado en doc 17 (F-012).


Minas de Bug / Integridad de Datos

L-08 — ApiExceptionHandler invocado pero la respuesta se descarta

Severidad: Critical Categoría: Bug / Manejo de Errores Componente: Handler global de excepciones Archivos afectados:

  • bootstrap/app.php:48-99
  • app/Exceptions/ApiExceptionHandler.php

Qué está mal

El ApiExceptionHandler personalizado es llamado dentro de withExceptions(...) así:

// bootstrap/app.php:50-57
$exceptions->respond(function(Response $response, Throwable $exception, Request $request) {
$className = get_class($exception);
$handlers = \App\Exceptions\ApiExceptionHandler::$handlers;
if (array_key_exists($className, $handlers)) {
$method = $handlers[$className];
$apiHandler = new \App\Exceptions\ApiExceptionHandler();
$apiHandler->$method($exception, $request);
// ^^^ ¡no hay `return` aquí!
} else {
// log warning ...
}
// ... bunch of other branches ...
return $response; // devuelve el $response ORIGINAL, no el personalizado
});

El handler construye una respuesta JSON personalizada — pero la respuesta nunca se devuelve al framework. El closure finalmente devuelve el $response original que Laravel calculó antes de que se ejecutara el closure. El sobre de error personalizado (código de estado, mensaje, código de error, contexto de debug) se descarta.

Impacto

Cada consumidor de API que dependa de una forma específica de error ({ success: false, message: ... }) obtiene el default genérico de Laravel en su lugar. Esto causa:

  • Que los toasts de error del frontend muestren “Server Error” en lugar del mensaje significativo.
  • Que los clientes de API que ramifican según códigos de error tomen la rama incorrecta.
  • Los handlers personalizados cuidadosamente escritos en app/Exceptions/ApiExceptionHandler.php son efectivamente código muerto.

El interceptor de error del frontend (resources/js/plugins/errorInterceptor.ts) luego loggea el error genérico a frontend_error_logs, contaminando la tabla con ruido que no apunta a ninguna causa raíz accionable.

Por qué existe

Laravel 11 cambió el bootstrap de app/Exceptions/Handler.php a bootstrap/app.php. El callback respond en Laravel 12 espera que devuelvas la respuesta que quieres enviar. En una migración desde el estilo antiguo, es fácil llamar a tu handler personalizado por sus efectos secundarios (logging, registro) y olvidar que también necesitas usar su valor de retorno.

¿Es seguro tocar? Sí — pero lea todas las ramas del closure primero. El closure tiene cuatro puntos de salida (éxito del handler personalizado, log de fallback, página de error web, mismatch CSRF, retorno default). Algunas de esas necesitan la respuesta personalizada y otras no.

Solución recomendada

Esquema:

$exceptions->respond(function(Response $response, Throwable $exception, Request $request) {
$className = get_class($exception);
$handlers = \App\Exceptions\ApiExceptionHandler::$handlers;
if (array_key_exists($className, $handlers)) {
$method = $handlers[$className];
$apiHandler = new \App\Exceptions\ApiExceptionHandler();
$customResponse = $apiHandler->$method($exception, $request);
if ($customResponse instanceof Response) {
return $customResponse; // <-- EL RETURN QUE FALTABA
}
} else {
// ... fallback logging unchanged
}
// ... web error page / CSRF branches unchanged
return $response;
});

Verifique que cada handler en ApiExceptionHandler realmente devuelva un Response. Algunos pueden estar escribiendo solo al log.

Minas relacionadas: ninguna directamente; esta es una corrección de punto único.


L-09 — registrarEnCerticamara() siempre devuelve false

Severidad: Critical Categoría: Bug / Desajuste de Efecto Secundario Componente: Flujo de creación de Venta / Integración con Certicámara Archivos afectados:

  • app/Services/VentaService.php:452-500

Qué está mal

El método crea el Pagaré en Certicámara, actualiza cliente.certicamara_uuid, registra la respuesta — y luego devuelve incondicionalmente false:

// app/Services/VentaService.php:452-500
private function registrarEnCerticamara(Cliente $cliente, CrearVentaDTO $dto): bool
{
if (!is_null($cliente->certicamara_uuid)) return true; // Ya registrado
// ... build payload ...
$result = $this->certicamaraService->crearPagare([...]);
// ... log result ...
$cliente->update(['certicamara_uuid' => $result['uuid'] ?? null]);
return false; // <-- siempre false en el primer registro
}

Los llamadores usan el valor de retorno:

// app/Services/VentaService.php:244-246
$tienePagare = $this->registrarEnCerticamara($cliente, $dto);
if ($tienePagare && !is_null($cliente->pagare_firmado_en)) {
dispatch(new GenerarCreditoDeVenta($empresa, $cliente, $venta))->onQueue('creditos');
}

Entonces:

  • Primera llamada (Cliente no tiene certicamara_uuid): crea Pagaré, devuelve false. El job no se dispatcha incluso si pagare_firmado_en está establecido (lo cual no estaría en la primera llamada de todos modos).
  • Llamada subsiguiente (Cliente ya tiene uuid): retorna temprano true. El job se dispatcha si pagare_firmado_en está establecido.

En la práctica esto significa: la ruta de dispatch síncrono de GenerarCreditoDeVenta desde dentro de crearVenta solo se dispara en la segunda Venta o posteriores para un Cliente que ya firmó una vez. En la primerísima Venta, el trabajo de generación de crédito se deja a la ruta de webhook (evento de firma de Certicámara → ProcesarPagareDigitalGenerarCreditoDeVenta).

Impacto

La ramificación no está realmente rota — la ruta del webhook recoge el caso de la primera venta. Pero el código tal como está escrito es deshonesto sobre lo que devuelve. Cualquiera que lea la función y los llamadores concluirá que la creación del Pagaré tuvo éxito → se devolvió true → ocurre el dispatch. Lo opuesto es lo que ocurre.

Esto se vuelve peligroso tan pronto como alguien reutilice registrarEnCerticamara en otro lugar y espere que el valor de retorno refleje “el registro ocurrió exitosamente”.

Por qué existe

La historia más probable: el método fue originalmente escrito para significar “el Cliente ya tiene un Pagaré que ha sido firmado”. Cuando la integración con Certicámara se agregó inline, la ruta de retorno temprano se mantuvo (return true para “ya registrado”) y se agregó un return false a la nueva rama (significando “acabo de crear uno, la firma aún no ha ocurrido”). La semántica es coherente si la lees así — pero el nombre del método (registrar) y el tipo booleano ambos mienten al respecto.

¿Es seguro tocar? Con supervisión — este método se conecta a múltiples flujos downstream.

Solución recomendada

Renombre y reforme. La versión honesta:

private function asegurarPagareCerticamara(Cliente $cliente, CrearVentaDTO $dto): PagareEstado
{
if (!is_null($cliente->certicamara_uuid)) {
return $cliente->pagare_firmado_en
? PagareEstado::FIRMADO
: PagareEstado::PENDIENTE_FIRMA;
}
// ... create pagaré ...
$cliente->update(['certicamara_uuid' => $result['uuid'] ?? null]);
return PagareEstado::CREADO_PENDIENTE_FIRMA;
}

Luego en crearVentaUnica / crearVentasPorEmpresa:

$estadoPagare = $this->asegurarPagareCerticamara($cliente, $dto);
if ($estadoPagare === PagareEstado::FIRMADO) {
dispatch(new GenerarCreditoDeVenta(...))->onQueue('creditos');
}

Minas relacionadas: L-10 (generación duplicada de Cuota — mismo flujo), L-13 (sin handler failed en GenerarCreditoDeVenta).


L-10 — Generación duplicada de Cuotas

Severidad: Critical Categoría: Bug / Integridad de Datos / Financiero Componente: Generación de cronograma de Cuotas Archivos afectados:

  • app/Services/VentaService.php:293-295, 441-443, 562-632, 644-752
  • app/Jobs/ProcesarPagareDigital.php:79-81, 118-123

Qué está mal

Las Cuotas (filas de cuotas mensuales) pueden ser creadas dos veces para la misma Venta en el flujo multi-Empresa. Trácelo:

Camino A (síncrono, dentro de crearVenta):

// VentaService::crearVentaUnica
if (!empty($dto->numero_cuotas) && $dto->numero_cuotas > 0) {
$this->generarCuotas($venta); // <-- crea Cuotas ahora
}
// VentaService::crearVentasPorEmpresa
if (!empty($dto->numero_cuotas) && $dto->numero_cuotas > 0) {
$this->generarCuotasPorOrden($ordenCompra, $ventasCreadas, $dto->numero_cuotas);
// ^^^ crea Cuotas maestras a nivel ORDEN Y Cuotas por Venta
}

Camino B (asíncrono, después del webhook de Certicámara):

// ProcesarPagareDigital::handle (llamado cuando el webhook dice Pagaré firmado)
if ($ventas->count() === 1) {
$ventaService->generarCuotas($venta); // <-- crea Cuotas DE NUEVO
} else {
$ventaService->generarCuotasPorOrden($this->ordenCompra, $ventas, $numeroCuotasUsadas);
// ^^^ crea Cuotas de orden + por Venta DE NUEVO
}

No hay guarda en generarCuotas o generarCuotasPorOrden verificando si las Cuotas ya existen para esa Venta. Cada llamada agrega filas frescas de Cuota.

Impacto

Si ambos caminos se ejecutan para la misma Venta (lo cual ocurre cada vez que un Cliente firma el Pagaré después de una venta dispatcheada sincrónicamente), el Cliente termina con 2N Cuotas para un plan de N cuotas. La integración Core Crédito SHIVAM recibe el monto mensual duplicado. Cada verificación de mora, asentamiento de pago y reconciliación ahora opera sobre datos duplicados.

No hay una restricción UNIQUE(venta_id, numero_cuota) en la tabla cuotas para respaldar esto (ver historial de migración 2026_01_05_015307_alter_cuotas_table.php — no se agregó clave única).

Reproducción

  1. El Cliente hace una compra de Empresa única. VentaService::crearVentacrearVentaUnicagenerarCuotas → se crean 6 filas de Cuota.
  2. El Pagaré de Certicámara del Cliente se firma; el webhook dispara POST /api/v1/webhooks/certicamara.
  3. ProcesarPagareDigital::handle se ejecuta → generarCuotas de nuevo → se crean 6 filas más de Cuota.
  4. El Cliente ahora tiene 12 filas de Cuota para un plan de 6 cuotas.

(En la práctica esto depende de la ramificación de L-09 — ver esa mina para saber por qué a veces se dispara el dispatch síncrono y a veces no. Pero generarCuotas se ejecuta incondicionalmente mientras numero_cuotas > 0, sin importar si el job de generación de crédito se dispatcha.)

Por qué existe

El flujo fue probablemente diseñado en dos etapas:

  1. Primera versión: crear Cuotas inline cuando se crea la Venta. Síncrono, simple.
  2. Segunda versión: se agregó el webhook de Certicámara para que las Cuotas solo existieran después de la firma. El handler del webhook comenzó a llamar a generarCuotas.
  3. Paso olvidado: la llamada inline del paso 1 nunca se eliminó.

Ambos sitios de llamada todavía están presentes.

¿Es seguro tocar? Con supervisión — esto afecta cada Venta activa.

Solución recomendada

Dos capas:

  1. Restricción de base de datos (prevenir duplicados sin importar las rutas del código):

    // nueva migración
    Schema::table('cuotas', function (Blueprint $table) {
    $table->unique(['venta_id', 'numero_cuota']);
    });

    Más una corrección para las Cuotas maestras a nivel de orden (venta_id = null):

    $table->unique(['orden_compra_id', 'numero_cuota'], 'cuotas_master_unique')
    ->whereNull('venta_id'); // partial unique — MySQL 8.0+ via functional index, or add a check helper
  2. Eliminar las llamadas síncronas en VentaService::crearVentaUnica y crearVentasPorEmpresa. Las Cuotas deben generarse solo mediante ProcesarPagareDigital después de que se dispare el webhook. Hasta que se firme el Pagaré, la Venta está PENDIENTE y las Cuotas aún no existen.

Agregue una guarda en generarCuotas para defensa:

public function generarCuotas(Venta $venta): void
{
if ($venta->cuotas()->exists()) {
Log::warning('generarCuotas: cuotas already exist for venta', ['venta_id' => $venta->id]);
return;
}
// ... existing logic ...
}

Minas relacionadas: L-09 (valor de retorno de Certicámara), L-12 (transiciones de estado de orden), L-13 (sin handler failed), L-11 (created_at vs creado_en)


L-11 — Vencimientos de Cuotas computados desde created_at en modelos que usan creado_en

Severidad: High Categoría: Bug / Integridad de Datos / Tiempo Componente: Generación de cronograma de Cuotas Archivos afectados:

  • app/Services/VentaService.php:582 ($fechaVencimiento = $venta->created_at ?? now();)
  • app/Services/VentaService.php:657 ($fechaVencimiento = $ordenCompra->created_at ?? now();)
  • app/Services/VentaService.php:704 ($fechaV = $ordenCompra->created_at ?? ($venta->created_at ?? now());)
  • app/Models/Modelo.php — declara const CREATED_AT = 'creado_en'

Qué está mal

El modelo base Modelo (que Venta y OrdenCompra extienden) redefine las constantes de columnas de timestamp de Laravel:

app/Models/Modelo.php
class Modelo extends Model
{
const CREATED_AT = 'creado_en';
const UPDATED_AT = 'actualizado_en';
}

Eloquent usa estas constantes para encontrar las columnas de timestamp. La columna real de la base de datos es creado_en, no created_at. Cuando el código se refiere a $venta->created_at, Eloquent no lo mapea automáticamente de vuelta a creado_en$venta->created_at devuelve null porque no existe tal columna o accesor.

En generarCuotas:

$fechaVencimiento = $venta->created_at ?? now(); // <-- null ?? now() === now()

Entonces la fecha de vencimiento de la primera Cuota se computa como now()->addMonth(), no (tiempo de creación de venta)->addMonth().

Para las Cuotas generadas sincrónicamente al momento de la creación de la Venta, los timestamps coinciden (la Venta fue creada hace segundos). Pero para las Cuotas generadas después de que se dispara el webhook de Certicámara — lo cual puede ser minutos, horas o días después — las fechas de vencimiento se desplazan hacia adelante por ese retraso.

Impacto

Las fechas de vencimiento de las Cuotas no reflejan cuándo se creó la Venta. Reflejan cuándo se generaron las Cuotas. Para los Clientes que firman su Pagaré unos días después de la compra (común), cada fecha de pago posterior se desplaza, así que:

  • La expectativa del Cliente de “primera Cuota en 30 días desde la compra” es incorrecta.
  • El registro Core Crédito SHIVAM no concuerda con el cuotas.fecha_vencimiento local.
  • Los jobs de marcado de mora (MarcarCuotasVencidas) operan sobre el calendario incorrecto.

Por qué existe

El codebase eligió nombres de columnas en español (creado_en, actualizado_en) a nivel de esquema, pero los defaults de Eloquent son created_at y updated_at. La base Modelo se agregó para unir los dos — pero cada línea de código de negocio que ya existía (o cada línea escrita mientras el desarrollador pensaba en modo default de Laravel) todavía dice created_at. Este es un refactor parcial.

grep -rn "->created_at\|->updated_at" app/Services mostrará cada otra ocurrencia. Cada una necesita ser verificada.

¿Es seguro tocar? Sí — pero use loadMissing o fresh() para asegurar que el modelo tenga los timestamps antes de leerlos, y prefiera $venta->creado_en directamente.

Solución recomendada

Reemplace $model->created_at con $model->creado_en en todos lados en app/Services, app/Jobs y app/Http/Controllers. Las líneas específicas:

// Before
$fechaVencimiento = $venta->created_at ?? now();
// After
$fechaVencimiento = $venta->creado_en ?? now();

Agregue un feature test que cree una Venta con un creado_en conocido, luego genere Cuotas y aserte que el fecha_vencimiento de la primera Cuota equivale a creado_en + 1 month.

Minas relacionadas: L-10 (duplicación de Cuotas), esto se vuelve peor cuando L-10 se dispara dos veces con diferentes timestamps base.


L-12 — GenerarCreditoDeVenta marca OrdenCompra como PROCESADA por Venta

Severidad: High Categoría: Bug / Integridad de Datos / Máquina de Estados Componente: Queue / Generación de crédito Archivos afectados:

  • app/Jobs/GenerarCreditoDeVenta.php:88-95

Qué está mal

Cuando el Carrito de un Cliente contiene Productos de múltiples Aliados, procesarCarrito (o crearVenta) divide la compra en N Ventas, todas atadas a una OrdenCompra. Después de que se dispara el webhook, ProcesarPagareDigital dispatcha un job GenerarCreditoDeVenta por Venta. Dentro de cada job:

// app/Jobs/GenerarCreditoDeVenta.php:88-95
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();

Cada job establece ordenCompra.estado = PROCESADA. El primero en tener éxito actualiza la orden. Los jobs restantes pueden seguir ejecutándose.

Impacto

Escenario: el Carrito tiene Productos del Aliado A y Aliado B. La OrdenCompra tiene Venta_A y Venta_B.

  1. GenerarCreditoDeVenta(Venta_A) tiene éxito → orden = PROCESADA, Venta_A = APROBADA.
  2. GenerarCreditoDeVenta(Venta_B) falla → Venta_B = RECHAZADA, pero la orden se mantiene PROCESADA porque el job anterior ya hizo commit de esa transición.

Ahora el Cliente tiene una OrdenCompra marcada PROCESADA (significando “todo salió bien”) pero una de sus Ventas está RECHAZADA. Los reportes del valor total de órdenes procesadas estarán mal; la experiencia del Cliente (recibe un Producto pero no el otro) es incoherente.

Por qué existe

El flujo de Empresa única fue el caso común durante el desarrollo; los Carritos multi-Empresa se agregaron después. La actualización de estado a nivel de orden fue escrita para el caso único y nunca se refactorizó para esperar a que todas las Ventas hijas finalizaran.

¿Es seguro tocar? Con supervisión — arreglar esto requiere o bien un job batch o un paso final de reconciliación.

Solución recomendada

Opción A (recomendada) — usar job batches de Laravel:

// ProcesarPagareDigital
$jobs = $ventas->map(fn($venta) => new GenerarCreditoDeVenta(
$venta->sucursal->empresa, $venta->user->cliente, $venta
))->all();
Bus::batch($jobs)
->then(function (Batch $batch) {
// all succeeded
$orden = OrdenCompra::find($this->ordenCompra->id);
$orden->update(['estado' => OrdenCompraEstado::PROCESADA]);
})
->catch(function (Batch $batch, Throwable $e) {
// at least one failed — leave orden PENDIENTE, alert ops
})
->onQueue('creditos')
->dispatch();

Quite la línea $ordenCompra->update(['estado' => OrdenCompraEstado::PROCESADA]) de GenerarCreditoDeVenta.

Opción B (más simple pero más gruesa) — después de cada job, recomputar el estado de la orden a partir de sus Ventas:

$ordenCompra = $this->venta->ordenCompra;
$ventasEstados = $ordenCompra->ventas->pluck('estado');
if ($ventasEstados->every(fn($e) => $e === EstadoVenta::APROBADA->value)) {
$ordenCompra->update(['estado' => OrdenCompraEstado::PROCESADA]);
}

De cualquier manera, el estado de la orden ahora es manejado por el estado de los hijos, no por cualquier job que haga commit primero.

Minas relacionadas: L-13 (handler failed()), L-14 (decremento de Cupo no atómico — mismo job).


L-13 — GenerarCreditoDeVenta no tiene handler failed()

Severidad: High Categoría: Bug / Recuperación de Error Componente: Queue / Generación de crédito Archivos afectados:

  • app/Jobs/GenerarCreditoDeVenta.php (clase completa)

Qué está mal

El job declara $tries = 3 y $backoff = 60. Después de tres fallos, Laravel escribe el job a failed_jobs y deja de reintentar. No se define un método failed(Throwable $e), así que:

  • La Venta se mantiene en el estado que estaba antes de que el job comenzara (típicamente PENDIENTE).
  • El cupo_disponible del Cliente no se restaura (el inventario solo se restaura dentro de rechazarVenta, que solo se dispara cuando CoreCredito devuelve un código limpio de “cliente no puede crearse” — no cuando el job lanza una excepción).
  • No se envía notificación al Cliente ni a operaciones.
  • No llega ninguna alerta a nadie.

Impacto

Una Venta cuyo job GenerarCreditoDeVenta lanza tres veces deja al sistema en un medio-estado para siempre:

  • Venta.estado = PENDIENTE permanentemente.
  • OrdenCompra.estado = PENDIENTE (o PROCESADA si L-12 ya la actualizó).
  • Sin restauración de inventario para esa Venta.
  • El Cliente ve la compra como aún pendiente en su UI.

La limpieza requiere SQL manual.

Reproducción

Forzar que CreditoService::generarCredito lance una excepción no manejada tres veces (ej., desconectar del SOAP SHIVAM upstream). Después del tercer reintento, observar failed_jobs. La fila de Venta no cambió.

Por qué existe

failed() es una de esas características de Laravel que encuentras cuando lees los docs cuidadosamente. Si escribiste el job desde un fragmento de código que no lo incluía, nunca sabes que existe.

¿Es seguro tocar? Sí — agregar un método failed es puramente aditivo.

Solución recomendada

app/Jobs/GenerarCreditoDeVenta.php
public function failed(\Throwable $exception): void
{
Log::critical('GenerarCreditoDeVenta: job permanently failed', [
'venta_id' => $this->venta->id,
'cliente_id' => $this->cliente->id,
'empresa_id' => $this->empresa->id,
'exception' => $exception->getMessage(),
]);
DB::transaction(function () {
// restore inventory
$this->venta->loadMissing('detalles.precio');
foreach ($this->venta->detalles as $detalle) {
if ($detalle->precio) {
$detalle->precio->increment('inventario', $detalle->cantidad);
}
}
// mark venta as terminally failed
$this->venta->update([
'estado' => EstadoVenta::RECHAZADA,
'causal' => 'Falló la generación del crédito tras 3 intentos: ' . $exception->getMessage(),
]);
});
// alert ops
Notification::route('slack', config('services.slack.alerts'))
->notify(new GenerarCreditoFalloPermanente($this->venta, $exception));
}

El alertado por Slack necesita un webhook configurado (ver L-26 — el URL de Slack está vacío en .env.example).

Minas relacionadas: L-12 (estado de orden), L-14 (decremento de Cupo), L-22 (sin monitoreo externo para realmente recibir esta alerta).


L-14 — Decremento de Cupo no atómico

Severidad: High Categoría: Bug / Condición de Carrera / Financiero Componente: Queue / Generación de crédito Archivos afectados:

  • app/Jobs/GenerarCreditoDeVenta.php:93

Qué está mal

// app/Jobs/GenerarCreditoDeVenta.php:93
$this->cliente->update([
'cupo_disponible' => $this->cliente->cupo_disponible - $this->venta->total
]);

Esto es un read-modify-write sin lock a nivel de fila y sin uso de la aritmética atómica de MySQL. $this->cliente fue deserializado del payload del job (ver SerializesModels), así que su cupo_disponible es cualquiera que haya sido el valor cuando el job se dispatchó, no cuando se ejecuta.

Si dos jobs GenerarCreditoDeVenta se ejecutan concurrentemente para el mismo Cliente (Carrito multi-Empresa → múltiples Ventas → múltiples jobs), ambos leen el Cupo obsoleto, ambos restan, ambos escriben — el último gana. Una resta se pierde.

Impacto

El Cliente efectivamente obtiene “Cupo gratis” igual al total de una de las Ventas concurrentes. A través de muchas compras concurrentes multi-Empresa, el Cupo deriva hacia arriba. El Cliente puede seguir comprando más allá de su límite.

Esta es la misma clase de bug que F-CONC-3 / F-CONC-9 en doc 17.

Reproducción

  1. Configurar queue worker con --queue=creditos --max-jobs=10 y múltiples workers.
  2. Cliente con Cupo 1.000.000 hace una compra multi-Empresa: Venta_A=500.000 y Venta_B=400.000.
  3. Ambos jobs se dispatchan. Ambos leen cupo_disponible = 1.000.000.
  4. Ambos escriben de vuelta: Venta_A escribe 1.000.000 - 500.000 = 500.000. Venta_B escribe 1.000.000 - 400.000 = 600.000.
  5. El Cupo final es 500.000 o 600.000 (el que el job haga commit último), no el correcto 100.000.

Por qué existe

El update(['col' => expression]) de Eloquent es conveniente y se lee naturalmente. El decremento atómico ($model->decrement('col', $amount)) es un idiom menos obvio a menos que te haya picado exactamente esta carrera.

Adicionalmente, SerializesModels lleva un snapshot del modelo al payload del job. Cuando el job se ejecuta, el modelo en memoria está obsoleto. Por esto importa la atomicidad a nivel MySQL: la base de datos es la única fuente consistente de verdad.

¿Es seguro tocar? Sí — pero verifique con una prueba de carga bajo el escenario multi-Empresa.

Solución recomendada

Use decremento atómico con un row lock:

DB::transaction(function () {
$cliente = Cliente::lockForUpdate()->find($this->cliente->id);
if ($cliente->cupo_disponible < $this->venta->total) {
throw new \Exception('Cupo insuficiente al momento de procesar (race)');
}
$cliente->decrement('cupo_disponible', $this->venta->total);
});

Esto mantiene un row lock desde lockForUpdate() hasta el commit, así que los jobs concurrentes serializan en esta fila.

Aplique el mismo patrón en el middleware ConsultarCupoDelCliente y en ExtenderCupoService — ambos también tocan cupo_disponible (ver F-CONC-5 en doc 17).

Minas relacionadas: L-12 (estado de orden), L-13 (handler failed — si el lock bloquea demasiado, el job hace timeout a los 900s y queremos un handler failed), F-CONC-* en doc 17.


L-15 — procesarCarrito infla el monto: subtotal × cantidad × cantidad

Severidad: Critical Categoría: Bug / Financiero / Sobrecargo al Cliente Componente: Marketplace del cliente / Checkout Archivos afectados:

  • app/Http/Controllers/Market/VentaController.php:186-197
  • app/Services/VentaService.php:152-154

Qué está mal

Dos multiplicaciones que se componen. Dentro de procesarCarrito:

// VentaController::procesarCarrito:186-197
foreach ($carrito as $item) {
$montoItem = $item->precio->precio * $item->cantidad; // <-- precio * cantidad
$subtotal += $montoItem;
$detalles[] = [
'precio_id' => $item->precio_id,
'cantidad' => $item->cantidad,
'descuento_aplicado' => 0,
'descriptor' => $item->precio->producto->nombre . ' x' . $item->cantidad,
'monto' => $montoItem // <-- pasa precio * cantidad como `monto`
];
}

Luego dentro de VentaService::crearVenta:

// VentaService::crearVenta:152-154
$subTotalCalculado = collect($dto->detalles)->sum(function ($detalle) {
return $detalle['cantidad'] * $detalle['monto']; // <-- multiplica de nuevo
});

Entonces el subtotal real registrado se vuelve cantidad * (precio * cantidad) = precio * cantidad^2.

Impacto

Para cantidad = 1, el bug es silencioso — 1 * 1 == 1. Para cantidad > 1, al Cliente se le cobra el cuadrado. Un Cliente comprando 2 unidades de un Producto de 500.000 COP es cargado por 2.000.000 COP en lugar de 1.000.000 COP. El Cupo se decrementa por el monto incorrecto, las Cuotas se computan contra el principal incorrecto, y el registro Core Crédito SHIVAM refleja el monto inflado.

Por qué existe

Dos contratos en desacuerdo. El DTO CrearVentaDTO y la forma por-detalle que espera usa monto como el precio unitario por item, con cantidad siendo multiplicada después. Pero procesarCarrito fue escrito tratando monto como el total de línea. Ambas formas son internamente consistentes; el bug es cuando se encuentran.

El flujo de Venta del portal de Aliado (Aliado/VentaController::store) usa la forma del DTO correctamente, así que este bug solo se manifiesta en el checkout del lado del Cliente.

Reproducción

  1. El Cliente agrega un Producto de 500.000 COP, cantidad 2, al Carrito.
  2. El Cliente completa el checkout vía POST /mis-compras/procesar-carrito.
  3. ventas.subtotal y ventas.total se registran como 2.000.000 en lugar de 1.000.000.
  4. El Cupo del Cliente se decrementa por 2.000.000.

¿Es seguro tocar? Sí — pero lea los tests para tanto procesarCarrito como el flujo directo store. Ambos necesitan estar de acuerdo en el significado de monto.

Solución recomendada

Decida un contrato para monto y atengáse a él. La solución más limpia es hacer monto = precio unitario:

// VentaController::procesarCarrito — fix the line
$detalles[] = [
'precio_id' => $item->precio_id,
'cantidad' => $item->cantidad,
'descuento_aplicado' => 0,
'descriptor' => $item->precio->producto->nombre . ' x' . $item->cantidad,
'monto' => $item->precio->precio, // <-- precio unitario, NO multiplicado
];
// Also fix the local $subtotal accumulator
$subtotal += $item->precio->precio * $item->cantidad;

Agregue un feature test que recorra el Carrito de cantidad>1 y aserte que ventas.subtotal coincide con la expectativa de precio-unitario-por-cantidad.

Minas relacionadas: L-16 (mismo controlador, Cuotas hardcoded), L-14 (decremento de Cupo incorrecto porque el total está mal).


L-16 — procesarCarrito hardcodea numero_cuotas = 6

Severidad: High Categoría: Bug / Lógica de Negocio Componente: Marketplace del cliente / Checkout Archivos afectados:

  • app/Http/Controllers/Market/VentaController.php:202

Qué está mal

// VentaController::procesarCarrito:199-209
$ventaData = [
'user_id' => $user->id,
'numero_cuotas' => 6, // Valor por defecto
// ...
];

El método hardcodea 6 Cuotas sin importar:

  • El rango permitido de numero_cuotas de la Línea (lineas.numero_cuotas_min / numero_cuotas_max).
  • Lo que el usuario seleccionó en la página de checkout.
  • Si el Cupo / score del Cliente permite 6 Cuotas para ese total.

Impacto

Cada compra basada en Carrito es de 6 Cuotas. Los Clientes que compran items de alto ticket que normalmente seleccionarían 12 o 24 Cuotas obtienen 6, poniendo el pago mensual fuera de su presupuesto. Los Clientes que compran items de bajo ticket obtienen 6 Cuotas cuando 3 serían suficientes.

Como esto también corre a través de crearVenta, las Cuotas se generan contra este valor hardcoded — no hay recurso.

Por qué existe

El endpoint fue probablemente generado para un MVP y nunca conectado a la UI de selección de Cuotas. El comentario // Valor por defecto confirma que esto era un placeholder.

¿Es seguro tocar? Sí — pero asegúrese que la UI de checkout envíe el numero_cuotas seleccionado y agregue validación de petición contra el rango permitido de la Línea.

Solución recomendada

// VentaController::procesarCarrito
$validated = $request->validate([
'numero_cuotas' => 'required|integer|min:1|max:24',
'beneficiario' => 'required|array',
// ... rest of validation
]);
$ventaData = [
'user_id' => $user->id,
'numero_cuotas' => $validated['numero_cuotas'], // <-- from request
// ...
];

Luego verifique contra el rango de la Línea:

// Optional: cross-check against the cart's product lines
$lineas = $carrito->map(fn($item) => $item->precio->producto->linea)->unique('id');
$minCuotas = $lineas->max('numero_cuotas_min');
$maxCuotas = $lineas->min('numero_cuotas_max');
if ($validated['numero_cuotas'] < $minCuotas || $validated['numero_cuotas'] > $maxCuotas) {
throw ValidationException::withMessages([
'numero_cuotas' => "Las líneas permiten entre {$minCuotas} y {$maxCuotas} cuotas",
]);
}

Minas relacionadas: L-15 (mismo controlador, inflación de monto).


L-17 — Cache key de DataCrédito insuficiente; requestUUID fijo

Severidad: High Categoría: Bug / Integridad de Datos / Regulatorio Componente: Integración con DataCrédito Archivos afectados:

  • app/Services/DataCreditoService.php:141 (cache key)
  • app/Services/DataCreditoService.php:185 (requestUUID fijo)

Qué está mal

Dos problemas distintos en el mismo método.

Problema 1 — la cache key omite apellido:

// DataCreditoService.php:141
$cacheKey = self::HISTORIAL_CACHE_PREFIX . ".{$personIdNumber}.{$personIdType}";

El método de consulta toma personIdNumber, personIdType y personLastName. Se llama al buró con los tres. La cache key usa solo dos. Así que una consulta para (12345678, CC, "GARCIA") y una consulta posterior para (12345678, CC, "RAMIREZ") ambas pegarán al mismo entry de cache — incluso aunque son personas diferentes.

En la práctica, el buró trata a personLastName como un campo de verificación (el buró rechaza la consulta si personLastName no coincide con sus registros). Así que dos consultas con el mismo DNI pero apellido diferente normalmente producirían resultados diferentes. La cache las colapsa.

Problema 2 — requestUUID está hardcoded:

// DataCreditoService.php:185
$uuid = '3fa85f64-5717-4562-b3fc-2c963f66afa6';

Un UUID placeholder de debugging se envía a producción. La línea que debería generar un UUID aleatorio por petición está comentada (// $uuid = $requestUUID ?? Str::uuid()->toString(); en la línea 184). Cada petición a DataCrédito lleva el mismo requestUUID.

Impacto

Problema 1: Contaminación cruzada de resultados del buró en cache. Podría resultar en aprobar a la Persona A basándose en el historial crediticio de la Persona B. Esto es tanto un bug de integridad de datos como una violación de Habeas Data (Ley 1266 — usar datos crediticios de una persona para evaluar a otra).

Problema 2: El audit trail del buró de “a quién consultó Mi Plante y cuándo” está roto. Mi Plante no puede reconstruir qué transacción real desencadenó qué llamada al buró. Los reguladores esperan trazabilidad por SFC Capítulo IV.

Por qué existe

La línea comentada de generación de UUID muestra lo que se pretendía. El UUID 3fa85f64... es el UUID de ejemplo de OpenAPI — un placeholder conocido. Esto fue claramente un ajuste manual de debugging (quizás para probar reproducibilidad contra el sandbox del buró) que nunca se revirtió.

La cache key probablemente comenzó más simple antes de que personLastName se agregara como parámetro, y nunca se actualizó cuando la firma cambió.

¿Es seguro tocar? Sí — ambas correcciones están aisladas.

Solución recomendada

// fix cache key (line 141)
$cacheKey = self::HISTORIAL_CACHE_PREFIX
. ".{$personIdNumber}.{$personIdType}"
. '.' . md5(strtoupper(trim($personLastName)));
// fix requestUUID (line 185)
$uuid = \Illuminate\Support\Str::uuid()->toString();

Restaure también la firma del parámetro (re-agregar ?string $requestUUID = null y por defecto a Str::uuid()).

Minas relacionadas: L-02 (el mismo endpoint de DataCrédito es público, así que el envenenamiento de cache es peor).


Minas de contrato frontend

L-18 — settings/Profile.vue usa name; el backend espera nombres + apellidos

Severidad: Medium Categoría: Contrato Frontend / Feature Roto Componente: Configuración del Cliente Archivos afectados:

  • resources/js/pages/settings/Profile.vue:31-40
  • app/Http/Requests/Settings/ProfileUpdateRequest.php

Qué está mal

El formulario de Profile.vue vincula y envía un solo campo name:

// resources/js/pages/settings/Profile.vue:31-40
const form = useForm({
name: user.name,
email: user.email,
});
const submit = () => {
form.patch(route('profile.update'), { preserveScroll: true });
};

Pero el ProfileUpdateRequest del backend valida nombres y apellidos. El modelo User (y el resto de la app) no tiene una columna name — los nombres están divididos.

Impacto

El formulario de actualización de perfil no funciona. Enviarlo o:

  • Pega un error de validación (campo nombres requerido faltante), que el usuario ve como un error genérico de Inertia.
  • Silenciosamente no hace nada si el backend tiene validación laxa.

De cualquier manera, los Clientes no pueden editar su perfil.

Por qué existe

Esta página es directamente de un scaffold de Laravel Breeze (name + email es el default de Breeze). Cuando el modelo User se dividió en nombres y apellidos, la página no se actualizó. El ProfileUpdateRequest correspondiente se actualizó, así que los dos están desincronizados.

¿Es seguro tocar? Sí — puramente frontend. Sin riesgo de datos.

Solución recomendada

<script setup lang="ts">
// ...
const form = useForm({
nombres: user.nombres,
apellidos: user.apellidos,
email: user.email,
});
</script>
<template>
<!-- Replace the single Name input with two -->
<div class="grid gap-2">
<Label for="nombres">Nombres</Label>
<Input id="nombres" v-model="form.nombres" required autocomplete="given-name" />
<InputError :message="form.errors.nombres" />
</div>
<div class="grid gap-2">
<Label for="apellidos">Apellidos</Label>
<Input id="apellidos" v-model="form.apellidos" required autocomplete="family-name" />
<InputError :message="form.errors.apellidos" />
</div>
<!-- email field unchanged -->
</template>

Actualice también la etiqueta del breadcrumb y el título de la página al español para coincidir con el resto de la app.

Minas relacionadas: L-19, L-20 (patrón de drift de contrato frontend-backend)


L-19 — Composable useWishlist espera array, el backend devuelve paginador

Severidad: Medium Categoría: Contrato Frontend / Feature Roto Componente: Marketplace del cliente / ListaDeseo Archivos afectados:

  • resources/js/composables/useWishlist.ts:25-35
  • app/Http/Controllers/Market/ListaDeseoController.php (el método index)

Qué está mal

El composable:

// resources/js/composables/useWishlist.ts:25-35
async function load(force = false) {
if (loaded.value && !force) return;
try {
const { data } = await axios.get(route('lista-deseos.index'));
items.value = Array.isArray(data)
? data.map((d: any) => ({ id: d.id, precio_id: d.precio_id }))
: [];
ensureIndex();
loaded.value = true;
} catch {
// silencioso
}
}

Array.isArray(data) devuelve false cuando el backend responde con un paginador de Laravel (que es un objeto con data: [], current_page, last_page, ...). Así que items.value se establece a [] y la ListaDeseo parece vacía.

Impacto

La página de ListaDeseo no renderiza nada. Los items se agregan exitosamente (POST) pero la siguiente carga muestra la lista como vacía hasta que el usuario haga algo manualmente. Los usuarios de largo plazo con cientos de items guardados perciben una pérdida de datos.

Reproducción

  1. Agregar un item a la ListaDeseo.
  2. Recargar la página.
  3. La ListaDeseo está vacía.

Por qué existe

Se agregó un paginador al backend (probablemente por razones de rendimiento en ListaDeseos grandes), pero el composable se escribió cuando el controlador devolvía un array plano.

¿Es seguro tocar? Sí — corregir en un solo lugar.

Solución recomendada

async function load(force = false) {
if (loaded.value && !force) return;
try {
const { data } = await axios.get(route('lista-deseos.index'));
// Backend may return either a raw array or a paginator { data: [...], ... }
const list = Array.isArray(data) ? data : (data?.data ?? []);
items.value = list.map((d: any) => ({ id: d.id, precio_id: d.precio_id }));
ensureIndex();
loaded.value = true;
} catch {
// silencioso
}
}

A largo plazo: decida una forma única de respuesta y documente. El método add ya maneja ambos (Array.isArray(data) ? data[0] : data), que es la misma pista de que el contrato es inestable.

Minas relacionadas: L-18, L-20 — mismo patrón de drift de forma frontend-backend.


L-20 — La página de Ventas de Aliado colorea un estado que no existe (cancelada)

Severidad: Low Categoría: Contrato Frontend / Cosmético Componente: Portal de Aliado / Página de Ventas Archivos afectados:

  • resources/js/pages/ally/ventas/Page.vue:82-93
  • app/Enum/Facturacion/EstadoVenta.php

Qué está mal

El helper de color de estado del frontend:

// resources/js/pages/ally/ventas/Page.vue:82-93
getStatusColor(status: string) {
switch (status.toLowerCase()) {
case 'completada': return 'bg-green-100 text-green-800';
case 'pendiente': return 'bg-yellow-100 text-yellow-800';
case 'cancelada': return 'bg-red-100 text-red-800';
default: return 'bg-gray-100 text-gray-800';
}
}

Pero el enum real EstadoVenta es:

case PENDIENTE = 'pendiente';
case APROBADA = 'aprobada';
case ENTREGADA = 'entregada';
case DEVUELTA = 'devuelta';
case LEGALIZADA = 'legalizada';
case COMPLETADA = 'completada';
case RECHAZADA = 'rechazada';
case ABANDONADA = 'abandonada';

No hay estado cancelada. Los estados reales de fallo son rechazada y abandonada. La rama cancelada del frontend es código muerto.

Las ramas para aprobada, entregada, devuelta, legalizada, rechazada, abandonada todas caen al default gris — así que cada Venta que no sea pendiente o completada se ve idéntica.

Impacto

La UI está degradada en información. Los administradores Aliados no pueden distinguir a simple vista una Venta rechazada de una entregada. Ambas se ven del mismo color.

Por qué existe

El enum se extendió (la migración 2026_01_24_024750_add_rechazada_and_abandonada_to_orden_compras_estado.php muestra cuándo se agregaron rechazada y abandonada). El frontend no se sincronizó. Nota: esa migración es sobre orden_compras; ventas probablemente tuvo la misma evolución.

¿Es seguro tocar? Sí — corrección puramente cosmética del frontend.

Solución recomendada

getStatusColor(status: string) {
switch (status.toLowerCase()) {
case 'completada':
case 'aprobada':
case 'entregada':
case 'legalizada':
return 'bg-green-100 text-green-800';
case 'pendiente':
return 'bg-yellow-100 text-yellow-800';
case 'rechazada':
return 'bg-red-100 text-red-800';
case 'abandonada':
return 'bg-orange-100 text-orange-800';
case 'devuelta':
return 'bg-blue-100 text-blue-800';
default:
return 'bg-gray-100 text-gray-800';
}
}

A largo plazo: genere el mapa estado → color desde una fuente única de verdad (ej., serializar EstadoVenta::color() desde PHP a un archivo JSON consumido por el frontend).

Minas relacionadas: L-18, L-19 (patrón de drift frontend-backend)


Minas de Infraestructura / higiene

L-21 — Todo el estado crítico en un único MySQL — punto único de fallo

Severidad: Critical Categoría: Infraestructura / Confiabilidad Componente: Configuración de la aplicación Archivos afectados:

  • .env.example: SESSION_DRIVER=database, CACHE_STORE=database, QUEUE_CONNECTION=database
  • config/logging.php (el canal database y las tres clases custom de logger)

Qué está mal

La aplicación ha elegido MySQL como backend para:

  • Sesiones (SESSION_DRIVER=database) — la tabla sessions.
  • Cache (CACHE_STORE=database) — la tabla cache.
  • Queue (QUEUE_CONNECTION=database) — las tablas jobs y failed_jobs.
  • Logs personalizados de aplicación — tres clases de logger (backend_request_logs, frontend_error_logs, más el canal logs). Todas tablas MySQL.
  • Datos de negocio — las 38 tablas de aplicación.

Redis está configurado en .env.example (líneas 48-51) pero nunca usado:

REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379

grep -rn "Redis::" app/ devuelve cero coincidencias.

Impacto

Si MySQL se vuelve no disponible:

  • El sitio web está caído (sin lectura/escritura de datos de negocio).
  • Los usuarios activos pierden sus sesiones.
  • Los jobs en cola no pueden ejecutarse.
  • Los misses de cache cascadean en más carga de DB (un amplificador de Thundering-Herd cuando MySQL ya está en problemas).
  • Los logs de flujo crítico (aprobar_cupo_eventos, backend_request_logs) no pueden escribirse — pero la aplicación sigue intentando, agregando más presión.

No hay ruta de degradación graceful. MySQL es el punto único de fallo para todo el sistema.

Para una fintech que procesa compras a crédito, esto está por debajo de la barra requerida para cualquier SLA significativo.

Por qué existe

La filosofía de despliegue de servicios mínimos: conseguir un servidor MySQL y un servidor PHP y enviar. Funciona para un MVP. No escala a una fintech en producción.

¿Es seguro tocar? Sí — pero planifique la migración. Mover sesiones y cache a Redis es un cambio de configuración en runtime (sin migración de esquema); mover la queue es más delicado porque los jobs en vuelo necesitan drenar primero.

Solución recomendada

Fase 1 (victorias rápidas — misma semana):

CACHE_STORE=redis
SESSION_DRIVER=redis

Verifique que Redis es alcanzable; despliegue. La cache se vuelve efímera (costo de warm-up), las sesiones se mueven a Redis (los usuarios activos pueden necesitar volver a loguearse dependiendo de la política de retención del session-store).

Fase 2 (drenado + cutover):

QUEUE_CONNECTION=redis

Drene la queue de base de datos primero (detenga el dispatch, espere a que la tabla jobs se vacíe), luego cambie la config y reinicie los workers.

Fase 3 (réplica de lectura separada):

Pre-prepare una réplica de lectura MySQL para queries de dashboard / reporte. El primario entonces maneja solo OLTP. Esto reduce el radio de impacto si el primario se ralentiza.

Minas relacionadas: L-06 (encriptación de sesión — relevante porque la fuga de datos de sesión desde backup de MySQL), L-22 (sin monitoreo para detectar estrés de MySQL antes de que cascadee).


L-22 — Sin Sentry / APM / monitoreo externo

Severidad: High Categoría: Infraestructura / Observabilidad Componente: Toda la aplicación Archivos afectados:

  • (Ausencia de integración; sin SDK de Sentry, sin SDK de APM en composer.json / package.json)
  • .env.example (URL del webhook de Slack está vacío)

Qué está mal

No hay integración de Sentry, Bugsnag, Rollbar, Flare, Raygun, New Relic, Datadog o Laravel Telescope/Horizon/Pulse en el repo. El logger de errores por defecto escribe a storage/logs/laravel.log (basado en archivo, no centralizado) y a la tabla MySQL logs (que tiene su propio problema L-21).

Cuando algo se rompe en producción:

  • Un 500 en Laravel se loggea a un archivo en el servidor de prod. Nadie lo ve a menos que alguien haga SSH.
  • Un job de cola que falla tres veces aterriza en failed_jobs y se queda ahí. Sin alerta.
  • Una excepción de frontend se envía a frontend_error_logs (MySQL), contamina la tabla y nunca se investiga (porque no hay dashboard de triaje).
  • Un pico en errores de validación 422 se ve idéntico a un pico en éxitos 200 cuando nadie está mirando.

Impacto

El equipo se entera de los problemas de producción cuando un Cliente o socio llama. Para entonces el Cliente ha experimentado el problema por horas.

Para una fintech con flujos de trabajo que tocan dinero, la ausencia de monitoreo es en sí misma una preocupación regulatoria (riesgo operacional bajo las expectativas del Capítulo IV de la SFC).

Por qué existe

El monitoreo es una de esas cosas que pospones “hasta que tengamos Clientes”. Luego tienes Clientes y lo pospones hasta “después de este sprint”. Luego envías y tus incidentes operacionales se convierten en ejercicios de arqueología.

¿Es seguro tocar? Sí — integraciones puramente aditivas.

Solución recomendada

Semana 1, tier gratuito, instale en orden:

  1. Sentry (5k errores/mes gratis): composer require sentry/sentry-laravel, php artisan sentry:publish, configure SENTRY_LARAVEL_DSN. Sentry captura excepciones de Laravel y errores JS no manejados con stack traces.
  2. UptimeRobot (50 monitores gratis): apunte a /health y /up. Obtenga un SMS cuando el sitio se caiga.
  3. Laravel Horizon (gratis, requiere Redis — depende de L-21 fase 1): dashboard de queue + métricas por queue. Úselo para ver failed_jobs y para reintentar desde la UI.

Configure el webhook de Slack (variable de entorno SLACK_WEBHOOK_URL, conectada en config/logging.php y config/services.php) para que la alerta en el handler failed() de L-13 realmente aterrice en algún lugar.

Minas relacionadas: L-13 (sin handler failed — el alertado depende de esto), L-26 (los logs del frontend están contaminados).


L-23 — Sin artefactos de despliegue en el repo

Severidad: Medium Categoría: Infraestructura / Reproducibilidad Componente: Estructura del repositorio Archivos afectados:

  • (Ausencia de Dockerfile, docker-compose.yml, Procfile, deploy.sh, IaC, configuración del servidor web)

Qué está mal

ls de la raíz del repo no muestra artefactos de despliegue. No hay:

  • Dockerfile o docker-compose.yml
  • nginx.conf / config de Apache
  • Procfile (estilo Heroku)
  • terraform/ u otro IaC
  • deploy.sh / Envoy Envoy.blade.php

Si el servidor de producción se pierde, no hay una forma documentada en el repo para recrearlo. Lo que viva en el servidor es la única fuente de verdad para la configuración del runtime.

Impacto

  • El onboarding de un nuevo desarrollador a un entorno local es difícil (tienes que escribir el docker-compose tú mismo o seguir el servidor de producción a mano).
  • Reproducir producción localmente para depurar es casi imposible.
  • El despliegue a producción requiere a quien tenga acceso SSH; el despliegue es una dependencia de una sola persona.

Por qué existe

Probablemente despliegue manual vía git pull + php artisan migrate directamente en el servidor. Común para proyectos pequeños de fintech.

¿Es seguro tocar? Sí — agregue los artefactos incrementalmente.

Solución recomendada

Fase 1: un docker-compose.yml de desarrollo (PHP-FPM, MySQL, Redis, Mailpit). Haga que un nuevo desarrollador sea productivo con un comando.

Fase 2: un Dockerfile de calidad de producción (Alpine, multi-stage, non-root). Combínelo con docker-compose.prod.yml o un Helm chart.

Fase 3: un script deploy.sh (o job de deploy de GitHub Actions) que documente los pasos reales de producción: git pull, composer install --no-dev, npm run build, php artisan migrate --force, php artisan config:cache, php artisan queue:restart.

Documente la lista de variables de entorno en el mismo lugar. Vea docs/audit/03-env-config-matrix.md para las variables de entorno en uso.

Minas relacionadas: L-25 (CI está roto — arreglar CI también ayuda con la reproducibilidad del despliegue).


L-24 — Sin automatización de backups en el repo

Severidad: High Categoría: Infraestructura / Recuperación ante Desastres Componente: Base de datos / Almacenamiento Archivos afectados:

  • (Ausencia; sin spatie/laravel-backup, sin tarea programada, sin scripts)

Qué está mal

Sin paquete spatie/laravel-backup, sin tarea cron que haga dump de MySQL, sin procedimiento de backup documentado. Los backups que existan son externos al codebase — gestionados por el panel del proveedor de hosting o por un procedimiento manual de ops.

Impacto

Si MySQL se compromete, se pierde o se corrompe, el punto de recuperación es desconocido. Para una fintech que registra historial de compras e historial crediticio, esto es una exposición regulatoria (Habeas Data + obligaciones de retención de la SFC).

Por qué existe

Mismo patrón que L-23 — los pasos operacionales viven fuera del repo.

¿Es seguro tocar? Sí — fácil de agregar.

Solución recomendada

  1. Instalar spatie/laravel-backup:
    Ventana de terminal
    composer require spatie/laravel-backup
    php artisan vendor:publish --provider="Spatie\Backup\BackupServiceProvider"
  2. Programar en routes/console.php:
    Schedule::command('backup:clean')->daily()->at('01:00');
    Schedule::command('backup:run')->daily()->at('02:00');
  3. Configurar el destino en config/backup.php para apuntar a un bucket compatible con S3 fuera del servidor de producción. Un backup que vive en el mismo disco que la base de datos no es un backup.

Minas relacionadas: L-21 (un único MySQL significa que los backups son aún más importantes).


L-25 — Pipeline de CI roto: sin servicio MySQL en GitHub Actions

Severidad: Medium Categoría: Infraestructura / Puerta de Calidad Componente: Integración Continua Archivos afectados:

  • .github/workflows/tests.yml
  • phpunit.xml

Qué está mal

El workflow de CI en .github/workflows/tests.yml no declara un service container de MySQL. La sección relevante termina con:

- name: Tests
run: ./vendor/bin/phpunit

Pero phpunit.xml declara:

<env name="DB_CONNECTION" value="mysql"/>
<env name="DB_HOST" value="127.0.0.1"/>
<env name="DB_PORT" value="3306"/>
<env name="DB_DATABASE" value="marketplace_db"/>
<env name="DB_USERNAME" value="root"/>
<env name="DB_PASSWORD" value="root"/>

No hay un bloque services: proveyendo un container MySQL. Así que phpunit o:

  • Se cuelga intentando conectar.
  • Crashea con un error de conexión.
  • Cae a SQLite si hay un override de env (no lo hay — phpunit.xml es explícito sobre mysql).

Impacto

Los tests no se ejecutan realmente contra la base de datos configurada en CI. O CI está silenciosamente verde porque los tests fallan de una manera que se traga, o está rojo y la gente está mergeando de todas formas. De cualquier manera, la suite de tests no es una puerta real.

Por qué existe

shivammathur/setup-php@v2 no aprovisiona MySQL; eso requiere services.mysql: en el workflow. El workflow original fue probablemente copiado de un template de Laravel que usaba SQLite, luego phpunit.xml se cambió a mysql cuando el equipo se movió de SQLite localmente, pero CI no se actualizó.

¿Es seguro tocar? Sí — cambio de workflow puramente aditivo.

Solución recomendada

Agregue un bloque services:

jobs:
ci:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: marketplace_db
ports: ['3306:3306']
options: >-
--health-cmd="mysqladmin ping -uroot -proot"
--health-interval=10s
--health-timeout=5s
--health-retries=10
steps:
# ... existing steps ...
- name: Run migrations
run: php artisan migrate --force
env:
DB_CONNECTION: mysql
DB_HOST: 127.0.0.1
DB_PORT: 3306
DB_DATABASE: marketplace_db
DB_USERNAME: root
DB_PASSWORD: root
- name: Tests
run: ./vendor/bin/phpunit

Luego observe la siguiente ejecución de CI — probablemente habrá fallas de tests que el equipo no ha estado viendo. Arréglelas.

Minas relacionadas: L-23 (sin artefactos de despliegue — arreglar CI también ayuda a la paridad de despliegue).


L-26 — El logger de errores del frontend inunda el backend con cada petición exitosa

Severidad: Medium Categoría: Bug / Contaminación de Observabilidad / PII Componente: Interceptor de axios del frontend Archivos afectados:

  • resources/js/plugins/errorInterceptor.ts:26-39
  • resources/js/services/errorLogger.ts
  • app/Http/Controllers/ErrorLogController.php

Qué está mal

El interceptor loggea cada respuesta exitosa al endpoint del backend /api/error-logs:

// resources/js/plugins/errorInterceptor.ts:26-39
instance.interceptors.response.use(
async (response) => {
if (response.config.url && !response.config.url.includes('/api/error-logs')) {
await errorLogger.logSuccess({
url: response.config.url,
method: response.config.method?.toUpperCase(),
statusCode: response.status,
responseData: response.data, // <-- full response body
trackingId: response.config.metadata?.trackingId,
});
}
return response;
},
...
);

Para cada llamada axios que hace el frontend — incluyendo cargar el Carrito, cargar la ListaDeseo, cargar detalles de Producto, dashboards — el cuerpo de respuesta completo se hace POST a /api/error-logs y se almacena en la tabla MySQL frontend_error_logs.

/api/error-logs también es pública (Route::post('/error-logs', [ErrorLogController::class, 'store']) en routes/api.php:20-21 está montada fuera de cualquier middleware de auth).

Impacto

Tres problemas que se componen:

  1. Crecimiento de DB. Cada visita bombea docenas de filas a frontend_error_logs. La tabla crece sin límite y sin retención.
  2. Fuga de PII. Los cuerpos de respuesta contienen el perfil del Cliente, Cupo, items del Carrito, dni en algunos lugares (por F-PII-6 en doc 17). Todo persistido en texto plano, PII regulada visible para cualquiera con acceso a la DB.
  3. Señal-a-ruido en observabilidad. La tabla está supuesta a sacar a la luz errores reales. Los errores reales ahora están diluidos 1000:1 por éxitos.

Por qué existe

El interceptor fue probablemente escrito durante una sesión de debugging para capturar “qué devuelve realmente la API” — y luego se hizo commit sin recortar la rama de éxito. El comentario // Log successful requests es honesto sobre lo que hace el código; simplemente es lo incorrecto a hacer en producción.

¿Es seguro tocar? Sí — elimine la llamada logSuccess.

Solución recomendada

Corrección en dos capas:

  1. Dejar de loggear éxitos:

    instance.interceptors.response.use(
    (response) => response, // pass-through, no logging
    async (error) => {
    if (!error.config?.url?.includes('/api/error-logs')) {
    await errorLogger.logAxiosError(error, 'Axios Response Interceptor', error.config.metadata?.trackingId);
    }
    return Promise.reject(error);
    }
    );
  2. Asegurar /api/error-logs:

    Route::middleware(['auth', 'throttle:30,1'])
    ->post('/error-logs', [ErrorLogController::class, 'store'])
    ->name('api.error-logs.store');

    Agregue también un límite de tamaño de petición y un filtro de PII en ErrorLogController::store para limpiar password, otp_code, dni, headers Authorization, etc.

  3. Programar limpieza de la contaminación histórica:

    Schedule::call(fn () => DB::table('frontend_error_logs')->where('creado_en', '<', now()->subDays(30))->delete())->daily();

Minas relacionadas: L-22 (sin monitoreo real, así que esta contaminación ahoga la señal), L-02 (otras APIs públicas sin rate limiting), F-PII-6 en doc 17.


L-27 — Los health checks no verifican DB / queue / dependencias

Severidad: Medium Categoría: Infraestructura / Observabilidad Componente: Endpoints de health Archivos afectados:

  • routes/web.php:21 (ruta /health)
  • bootstrap/app.php:26 (ruta health: '/up')

Qué está mal

// routes/web.php:21
Route::get('/health', fn() => response('ok', 200));

Y el endpoint /up integrado de Laravel (configurado en bootstrap/app.php:26 vía health: '/up') devuelve un 200 plano si el framework arranca.

Ninguno de los endpoints:

  • Hace ping a MySQL.
  • Verifica la conexión de queue.
  • Toca Redis (cuando se usa).
  • Verifica que las integraciones upstream son alcanzables.

Impacto

Un load balancer o sonda de monitoreo obtiene 200 de /health incluso si MySQL está caído. La sonda dice “servicio saludable”; el servicio de hecho está sirviendo 500s para cada petición real. El equipo se entera por un Cliente.

Por qué existe

El /up por defecto de Laravel es intencionalmente mínimo. El /health personalizado se agregó como una corrección rápida y nunca se extendió.

¿Es seguro tocar? Sí — escriba un endpoint más rico.

Solución recomendada

Construya una sonda de salud real:

app/Http/Controllers/HealthController.php
class HealthController extends Controller
{
public function check(): JsonResponse
{
$checks = [
'db' => $this->checkDb(),
'queue' => $this->checkQueue(),
'cache' => $this->checkCache(),
];
$ok = collect($checks)->every(fn($c) => $c['ok']);
return response()->json([
'status' => $ok ? 'ok' : 'degraded',
'checks' => $checks,
], $ok ? 200 : 503);
}
private function checkDb(): array { /* SELECT 1, time it */ }
private function checkQueue(): array { /* assert queue connection */ }
private function checkCache(): array { /* set + get a temp key */ }
}

Conecte a /health. Actualice las sondas del load balancer para usar el nuevo endpoint y esperar 200 con el payload status: ok.

Minas relacionadas: L-22 (stack de observabilidad — /health debería ser una entrada entre muchas), L-21 (un único MySQL — /health lo reporta cuando muere).


Brechas en el pipeline de crédito

L-28 — OTP no se hace cumplir antes del paso del cuestionario

Severidad: High Categoría: Bug / Seguridad / Forzado del Flujo de Trabajo Componente: Flujo de aprobación de crédito Archivos afectados:

  • routes/web.php:71-74 (rutas de OTP)
  • app/Http/Controllers/AprobarCliente/AprobarCupoController.php (el método identityValidationGenerateQuestions)
  • app/Services/IdentityValidationService.php

Qué está mal

El flujo de aprobación de crédito está documentado como un pipeline secuencial (ver docs/audit/12-credit-approval-workflow-diagram.md):

  1. Verificación legal
  2. Validación de identidad (OTP)
  3. Validación de identidad (cuestionario)
  4. Validación HDC
  5. Umbral de score
  6. Aprobación

La intención es clara: OTP precede al cuestionario. La implementación no fuerza esto estrictamente. El resultado de verificación-OTP se cachea, pero los endpoints del cuestionario no requieren una verificación de existencia-y-verificado-recientemente en esa cache antes de proceder.

Mirando routes/web.php:71-76:

Route::get('identity-validation-generate-otp', [...])->name('user.aprobar-cupo.identity-validation-generate-otp');
Route::post('identity-validation-verify-otp', [...])->name('user.aprobar-cupo.identity-validation-verify-otp');
Route::get('identity-validation-generate-questions', [...])->name('user.aprobar-cupo.identity-validation-generate-questions');
Route::post('identity-validation-verify-questions', [...])->name('user.aprobar-cupo.identity-validation-verify-questions');

Las cuatro están bajo el mismo middleware check_intentos_limite_diarios. Ninguna requiere una guarda “OTP-verificado-en-esta-sesión”.

Impacto

Un Cliente puede hacer GET /usuario/cupo/identity-validation-generate-questions directamente sin hacer nunca el paso del OTP. El modelo de seguridad pretendido (OTP confirma un número de teléfono que controlas antes de permitirte responder preguntas KBA) no se sostiene.

Combinado con F-SEC-9 en doc 17 (brute-force de OTP ~10^6 en ventana de cache de 30 min) y F-CONC-9 (ID de transacción OTP sobrevive a la verificación — ventana de replay), el flujo de aprobación de crédito tiene múltiples puntos débiles.

Por qué existe

La aplicación de flujo lineal en HTTP sin estado requiere o una máquina de estados almacenada en sesión o un estado de proceso del lado del servidor. El doc 12 describe la secuencia pretendida; la implementación real rastrea cada paso independientemente vía aprobar_cupo_eventos. No hay middleware que diga “no puedes alcanzar el paso N+1 a menos que el paso N sea un FINISH_SUCCESS en esta sesión”.

¿Es seguro tocar? Con supervisión — agregar la guarda podría romper aprobaciones en vuelo.

Solución recomendada

Agregue un middleware (require_otp_verified) y aplíquelo a los endpoints del cuestionario:

Route::middleware('check_intentos_limite_diarios')->group(function () {
Route::get('legal-check', ...);
Route::get('identity-validation', ...);
Route::get('identity-validation-generate-otp', ...);
Route::post('identity-validation-verify-otp', ...);
Route::middleware('require_otp_verified')->group(function () {
Route::get('identity-validation-generate-questions', ...);
Route::post('identity-validation-verify-questions', ...);
});
Route::middleware('require_questionnaire_passed')->group(function () {
Route::get('hdc-validation', ...);
});
});

RequireOtpVerified lee de aprobar_cupo_eventos (o una cache anclada a sesión) y rechaza con 403 si el evento OTP más reciente para este Cliente no es FINISH_SUCCESS dentro de los últimos 15 minutos.

Minas relacionadas: L-29, L-30.


L-29 — Mínimo de score no se hace cumplir (enforcement comentado)

Severidad: High Categoría: Bug / Forzado del Flujo de Trabajo / Riesgo Financiero Componente: Flujo de aprobación de crédito Archivos afectados:

  • app/Services/IdentityValidationService.php (verificación de score del cuestionario — comentada)
  • app/Services/HDCValidationService.php (mínimo HDC — parcial)

Qué está mal

Según el audit doc 16 y doc 17, el umbral de score del cuestionario / HDC se computa pero no se hace cumplir — la rama de enforcement está comentada. El flujo procede sin importar si el Cliente alcanzó el mínimo.

Impacto

Los Clientes con scores bajo el umbral pueden avanzar a la aprobación. El modelo de riesgo es saltado. Esto es una exposición directa de ingresos + cartera vencida.

Por qué existe

El código de enforcement de umbral comentado es el clásico patrón “deshabilité esto para probar el flujo de extremo a extremo y olvidé re-habilitarlo”. Sin una suite de tests que asserte “Cliente bajo umbral no puede avanzar”, nadie nota.

¿Es seguro tocar? Con supervisión — re-habilitar la puerta bloqueará a Clientes que actualmente pasan; verifique con producto primero.

Solución recomendada

Descomente la verificación de umbral. Agregue un feature test:

public function test_questionnaire_below_threshold_is_rejected(): void
{
// ... seed cliente, mock questionnaire result with score 30 (below threshold) ...
$response = $this->actingAs($user)->postJson('/usuario/cupo/identity-validation-verify-questions', $payload);
$response->assertStatus(422)->assertJsonPath('error.code', 'SCORE_BELOW_MINIMUM');
}

Documente el umbral mínimo de score en docs/audit/12-credit-approval-workflow-diagram.md.

Minas relacionadas: L-28, L-30.


L-30 — La aprobación requiere >= 5 tipos de proceso en el mes, no secuencia estricta

Severidad: Medium Categoría: Bug / Desajuste de Flujo de Trabajo Componente: Aprobación de crédito / Puerta final de aprobación Archivos afectados:

  • app/Services/AprobarCupoService.php:17-38

Qué está mal

La puerta final de aprobación validarSiElClienteTieneSuCupoAprobado mira aprobar_cupo_eventos para el mes actual y cuenta los tipos de proceso cuyo evento más reciente fue FINISH_SUCCESS:

// app/Services/AprobarCupoService.php:17-38
public function validarSiElClienteTieneSuCupoAprobado($clienteId): bool
{
$allProcessTypes = array_map(fn($case) => $case->value, ProcessType::cases());
$finishSuccess = EventType::FINISH_SUCCESS->value;
$procesosExitosos = AprobarCupoEvento::where('cliente_id', $clienteId)
->whereIn('tipo_proceso', $allProcessTypes)
->where('creado_en', '>=', Carbon::now()->startOfMonth()->startOfDay())
->select('tipo_proceso')
->groupBy('tipo_proceso')
->havingRaw('MAX(CASE WHEN evento = ? THEN creado_en END) = MAX(creado_en)', [$finishSuccess])
->count();
return $procesosExitosos >= 5;
}

El doc 12 describe una secuencia estricta de 7 pasos. Este código solo verifica: “¿hay 5 tipos distintos de proceso cuyo evento más reciente en este mes es FINISH_SUCCESS?” No verifica:

  • Que esos 5 tipos de proceso sean los 5 correctos (ej., LEGAL_CHECK debe estar entre ellos).
  • Que los eventos sucedieron en algún orden particular.
  • Que se usó el mismo día calendario o la misma sesión.
  • Que los 5 sucedieron “hoy” — solo “este mes”.

Doble lectura — UML vs código:

FuenteRegla que afirma
docs/DIAGRAMA_APROBAR_CUPO_UML.md (UML oficial del equipo)requiere 3+ procesos exitosos en el mes
app/Services/AprobarCupoService.php:17-38 (código en runtime)>= 5 tipos distintos con FINISH_SUCCESS en el mes, sin secuencia

El UML está desactualizado. El código exige cinco, no tres, y no aplica ningún orden. La auditoría (docs/audit/12) ya refleja la regla del código. La fuente final de verdad es el código.

Impacto

La regla de aprobación es mucho más débil de lo que sugiere la documentación. Un Cliente con eventos FINISH_SUCCESS obsoletos de antes en el mes (quizás para intentos diferentes) puede ser aprobado sin rehacer cada paso. El paso 6 podría ser el único paso que el Cliente realmente completó hoy.

El número 5 también es una bandera roja: hay 6-7 tipos de proceso, así que “al menos 5” implícitamente permite saltarse cualquiera.

Por qué existe

Forzar la secuencia estricta a nivel de base de datos es difícil. La regla >= 5 es una heurística que probablemente aproxima el comportamiento correcto para el camino común. La laxitud es flexibilidad intencional, pero no está documentada en ningún lado excepto en este código.

¿Es seguro tocar? Con supervisión — cambiar esto cambia la tasa de aprobación.

Solución recomendada

Dos opciones:

A. Endurecer la regla para coincidir con la intención documentada:

public function validarSiElClienteTieneSuCupoAprobado($clienteId): bool
{
$requiredProcessTypes = [
ProcessType::LEGAL_CHECK->value,
ProcessType::IDENTITY_OTP->value,
ProcessType::IDENTITY_QUESTIONS->value,
ProcessType::HDC_VALIDATION->value,
ProcessType::SCORE_CHECK->value,
];
$today = Carbon::now()->startOfDay();
foreach ($requiredProcessTypes as $type) {
$latest = AprobarCupoEvento::where('cliente_id', $clienteId)
->where('tipo_proceso', $type)
->where('creado_en', '>=', $today)
->orderByDesc('creado_en')
->first();
if (!$latest || $latest->evento !== EventType::FINISH_SUCCESS->value) {
return false;
}
}
return true;
}

B. Documentar la heurística como intencional y agregar un test asertando qué combinaciones de 5-de-N aprueban. Ambas opciones requieren alineación de producto.

Minas relacionadas: L-28, L-29.


Minas de riesgo por código muerto

L-31 — Rutas de escritura de lineas referencian métodos de controlador no implementados

Severidad: Medium Categoría: Código Muerto / Riesgo 500 Componente: Marketplace del cliente / Catálogo Archivos afectados:

  • routes/web.php:32 (el registro de apiResource)
  • app/Http/Controllers/Market/LineaController.php

Qué está mal

// routes/web.php:32
Route::apiResource('lineas', LineaController::class)->names('lineas');

apiResource registra index, show, store, update, destroy. Pero LineaController solo implementa index y show:

app/Http/Controllers/Market/LineaController.php
class LineaController extends Controller
{
public function __construct(private LineaService $lineaService) {}
public function index() { ... }
public function show(Request $request, Linea $linea) { ... }
// NO store, update, destroy methods
}

Llamar a POST /lineas, PUT /lineas/{id} o DELETE /lineas/{id} resulta en un 500 (método no encontrado en el controlador).

Esta es la misma forma que L-03 (rutas de escritura de marcas son públicas) pero peor — estas rutas ni siquiera funcionan como escrituras legítimas.

Impacto

  • Un llamador ingenuo (un desarrollador aprendiendo la API, un scanner automatizado, un usuario curioso) obtiene un 500.
  • Los 500s contaminan los logs.
  • Combinado con L-08 (ApiExceptionHandler no devolviendo), la UX de error es opaca.
  • Esto también es una superficie pública de escritura como L-03 — excepto que siempre haría 500. Ruido molesto, no una vulnerabilidad per se.

Por qué existe

apiResource es conveniente cuando se pretende implementar los cinco verbos. Se tomó el atajo; la implementación no alcanzó.

¿Es seguro tocar? Sí — restringa el registro del recurso.

Solución recomendada

routes/web.php
Route::apiResource('lineas', LineaController::class)
->only(['index', 'show'])
->names('lineas');

Si se necesitan operaciones de escritura en lineas, deberían vivir en routes/ally/web.php detrás de auth:app — de la misma forma en que las operaciones de escritura de marcas viven en las rutas de Aliado (ver routes/ally/web.php:39).

Minas relacionadas: L-03 (mismo patrón, con marcas).


L-32 — Eventos VentaCompletada / VentaCancelada con scaffolding pero nunca dispatcheados

Severidad: Medium Categoría: Código Muerto / Mina si se Revive Componente: Ciclo de vida de Ventas Archivos afectados:

  • app/Events/Facturacion/VentaCompletada.php
  • app/Events/Facturacion/VentaCancelada.php
  • app/Listeners/Facturacion/VentaCompletadaListener.php
  • app/Listeners/Facturacion/VentaCanceladaListener.php

Qué está mal

Los eventos y listeners existen:

Ventana de terminal
$ grep -rn "VentaCompletada::dispatch\|event(new VentaCompletada\|VentaCancelada::dispatch\|event(new VentaCancelada" app/
# zero matches

Nadie los dispatcha. El flujo de facturación activo va:

crearVenta → ProcesarPagareDigital (via webhook) → GenerarCreditoDeVenta

Sin eventos en esa cadena.

Pero los listeners están conectados en EventServiceProvider (o autodescubrimiento), así que si alguien dispatchara los eventos, los listeners se ejecutarían. Y los listeners son minas:

VentaCompletadaListener haría:

  • Decrementar cupo_disponible (pero el inventario ya fue decrementado en VentaService::crearVentaUnica:262-263 y GenerarCreditoDeVenta decrementa Cupo). Resultado: doble decremento de Cupo y doble decremento de inventario.

VentaCanceladaListener es peor — ver L-33.

Impacto

Los listeners son minas que explotan el día que alguien “completa” la cadena de eventos abandonada agregando una llamada de dispatch. La intención es clara (ciclo de vida de Ventas dirigido por eventos) — pero la implementación fue abandonada a mitad de camino. Un nuevo desarrollador que lea los eventos y piense “déjame conectar estos” detona el sistema.

Por qué existe

Una versión temprana de la arquitectura usaba eventos. Una versión posterior se movió a dispatch directo de jobs (control de flujo más limpio, debugging más fácil). Los eventos no se eliminaron.

¿Es seguro tocar? Eliminarlos después de confirmar con el dueño del producto que el modelo dirigido por eventos no se está reviviendo.

Solución recomendada

Dos opciones:

A. Eliminar el código muerto. Quitar los cuatro archivos y cualquier referencia en EventServiceProvider. Agregar una nota en un CHANGELOG o doc de migración diciendo que el ciclo de vida de Ventas dirigido por eventos fue retirado.

B. Marcar con @deprecated y enviar una verificación de CI (ej., una regla phpstan o un feature test) que asserte que estas clases nunca se dispatchan.

Si alguien quiere reutilizar el modelo dirigido por eventos (porque realmente es más limpio a largo plazo), primero necesitan eliminar los efectos secundarios inline de crearVentaUnica y GenerarCreditoDeVenta, luego dispatchar los eventos. Este es un refactor significativo.

Minas relacionadas: L-33 (la peor mitad de este par).


L-33 — VentaCanceladaListener referencia un enum inexistente

Severidad: Critical (si el evento alguna vez se dispatcha) Categoría: Código Muerto / Crash en Runtime Componente: Ciclo de vida de Ventas Archivos afectados:

  • app/Listeners/Facturacion/VentaCanceladaListener.php:6, 26

Qué está mal

app/Listeners/Facturacion/VentaCanceladaListener.php
use App\Enum\Facturacion\Estado; // <-- ESTA CLASE NO EXISTE
use App\Events\Facturacion\VentaCancelada;
class VentaCanceladaListener
{
public function handle(VentaCancelada $event): void
{
$venta = $event->venta;
$actualizarVentaDTO = ActualizarVentaDTO::fromArray([
'estado' => Estado::CANCELADA, // <-- crashes here
]);
$venta->update($actualizarVentaDTO->toArray());
}
}

App\Enum\Facturacion\Estado no existe. El enum real es App\Enum\Facturacion\EstadoVenta, que no tiene caso CANCELADA (los más cercanos son RECHAZADA y ABANDONADA).

grep lo confirma: no hay app/Enum/Facturacion/Estado.php.

Impacto

En el momento que alguien dispare event(new VentaCancelada($venta)), este listener lanza Error: Class "App\Enum\Facturacion\Estado" not found. Como VentaCancelada es el tipo de evento que dispatcharías en un controlador o servicio para cancelar una Venta, el crash del listener mata esa petición — y también puede dejar la Venta en un estado inconsistente si el dispatch estaba dentro de una transacción (la transacción se revierte, pero el error visible al usuario es opaco).

Ahora mismo nadie dispatcha el evento, así que la mina está inactiva. Tan pronto como alguien escriba:

event(new VentaCancelada($venta));

… explota.

Por qué existe

Refactor en progreso. App\Enum\Facturacion\Estado fue probablemente el enum paraguas original que se dividió en EstadoVenta, EstadoCuota, OrdenCompraEstado. El listener no se actualizó.

¿Es seguro tocar? Sí — pero en línea con L-32, elimine el listener completamente o corrija el import (decida sobre el caso correcto del enum).

Solución recomendada

Si se mantiene el modelo dirigido por eventos: reemplace Estado::CANCELADA con EstadoVenta::RECHAZADA o EstadoVenta::ABANDONADA (la que coincida con la semántica de cancelación) y actualice el import.

Si se retira (recomendado — ver L-32): elimine el listener y el evento.

Minas relacionadas: L-32 (padre), L-20 (mismo drift entre el viejo cancelada y los nuevos rechazada/abandonada).


L-34 — Feature PlanCredito construida parcialmente, no operacional

Severidad: Medium Categoría: Código Muerto / Feature Ambigua Componente: Gestión de plan de crédito Archivos afectados:

  • app/Services/PlanCreditoService.php
  • (El modelo y migraciones existen para la tabla plan_creditos; sin controlador, sin ruta, sin consumidor en vivo)

Qué está mal

PlanCreditoService existe con métodos para obtener, crear y actualizar registros de PlanCredito:

// app/Services/PlanCreditoService.php (excerpt)
class PlanCreditoService
{
public function obtenerPlanesCredito(): Collection { ... }
public function obtenerPlanCredito(int $id): ?PlanCredito { ... }
public function obtenerPlanCreditoPorVenta(int $ventaId): ?PlanCredito { ... }
// ... more methods ...
}

Hay un modelo Facturacion\PlanCredito y una tabla referenciada. Pero:

  • grep -rn "PlanCreditoService" app/Http/Controllers/ → cero coincidencias. Ningún controlador inyecta este servicio.
  • grep -rn "plan_creditos" routes/ → cero coincidencias. Ninguna ruta lo expone.
  • grep -rn "PlanCredito::" app/ muestra solo el archivo del servicio mismo.

Así que el servicio es invocable desde PHP pero nada en la aplicación corriente lo llama.

Hay también un comentario TODO en VentaCompletadaListener:50:

// TODO: Generar plan de crédito con sus cuotas
// Aquí iría la lógica para crear el plan de crédito

— pero por L-32, ese listener está muerto.

Impacto

No activo. Pero la huella de la feature es real:

  • Existen migraciones (una tabla plan_creditos fue creada en algún punto).
  • Existe código de servicio.
  • Existen DTOs (CrearPlanCreditoDTO, ActualizarPlanCreditoDTO).
  • Existe un enum EstadoPlanCredito.

Un nuevo desarrollador explorando el código podría asumir que esta es una feature activa, escribir contra ella y encontrar que sus cambios no corren. Peor: podría conectarla, sin darse cuenta de que el resto del sistema estaba intencionalmente saltándola en favor del flujo Cuota-directo.

Por qué existe

Una feature planificada: un PlanCredito por Venta (o por Cliente) estaba destinado a agregar las Cuotas del Cliente en un plan coherente. Posiblemente destinado para la integración SHIVAM Core Crédito. El flujo Cuota-directo se envió primero; la capa PlanCredito se generó con scaffolding pero nunca se conectó.

¿Es seguro tocar? Con supervisión — hable con producto antes de eliminar o completar.

Solución recomendada

Elija un lado:

A. Complételo. Escriba un PRD para lo que PlanCredito debe significar, conecte un controlador y ruta, maneje el ciclo de vida desde eventos de Venta (con L-32 arreglado primero). Trabajo significativo — probablemente un sprint.

B. Quítelo. Suelte las migraciones, elimine el servicio / DTOs / enum / modelo. Trabajo menor — un PR, racional claro.

Si no puede decidir ahora mismo, al menos marque la clase de servicio con una anotación @deprecated apuntando a esta mina.

Minas relacionadas: L-32 (el hook dirigido por eventos al que esto se adjuntaría).


Índices

Índice por severidad

Ordenado por severidad (Critical → Low), luego por ID.

IDSeveridadTítuloArchivo principal
L-01CriticalBypass de checkout vía POST directoroutes/web.php, app/Http/Controllers/Market/VentaController.php
L-02CriticalEndpoint público de historial de DataCréditoroutes/api.php
L-03CriticalCRUD público en marcasroutes/web.php
L-08CriticalRespuesta de ApiExceptionHandler descartadabootstrap/app.php
L-09CriticalregistrarEnCerticamara siempre devuelve falseapp/Services/VentaService.php
L-10CriticalGeneración duplicada de Cuotasapp/Services/VentaService.php, app/Jobs/ProcesarPagareDigital.php
L-15CriticalprocesarCarrito infla el montoapp/Http/Controllers/Market/VentaController.php
L-21CriticalÚnico MySQL — punto único de fallo.env.example
L-33Critical (si se revive)VentaCanceladaListener referencia enum inexistenteapp/Listeners/Facturacion/VentaCanceladaListener.php
L-04HighRegistro de Aliado no vinculado a enlace firmado en POSTroutes/ally/auth.php
L-05HighLogin de Aliado sin rate limitingapp/Http/Controllers/AliadoAuth/AuthenticatedSessionController.php
L-06HighEncriptación de sesión apagada.env.example
L-07HighSin headers CSP, sin config CORSbootstrap/app.php
L-11HighVencimientos de Cuotas computados desde created_atapp/Services/VentaService.php
L-12HighGenerarCreditoDeVenta marca orden PROCESADA por Ventaapp/Jobs/GenerarCreditoDeVenta.php
L-13HighGenerarCreditoDeVenta sin handler failed()app/Jobs/GenerarCreditoDeVenta.php
L-14HighDecremento de Cupo no atómicoapp/Jobs/GenerarCreditoDeVenta.php
L-16HighprocesarCarrito hardcodea numero_cuotas = 6app/Http/Controllers/Market/VentaController.php
L-17HighCache key de DataCrédito insuficiente; requestUUID fijoapp/Services/DataCreditoService.php
L-22HighSin Sentry / APM / monitoreo externo(ausencia)
L-24HighSin automatización de backups en el repo(ausencia)
L-28HighOTP no forzado antes del cuestionarioroutes/web.php, app/Services/IdentityValidationService.php
L-29HighScore mínimo no forzadoapp/Services/IdentityValidationService.php
L-18Mediumsettings/Profile.vue usa name no nombres/apellidosresources/js/pages/settings/Profile.vue
L-19MediumuseWishlist espera array, backend paginaresources/js/composables/useWishlist.ts
L-23MediumSin artefactos de despliegue(ausencia)
L-25MediumCI roto — sin servicio MySQL.github/workflows/tests.yml
L-26MediumLogger del frontend inunda el backend con éxitosresources/js/plugins/errorInterceptor.ts
L-27MediumHealth checks no verifican dependenciasroutes/web.php, bootstrap/app.php
L-30MediumAprobación requiere ≥5 tipos de proceso, no secuencia estrictaapp/Services/AprobarCupoService.php
L-31MediumRutas de escritura de lineas carecen de métodos de controladorroutes/web.php, app/Http/Controllers/Market/LineaController.php
L-32MediumEventos VentaCompletada / VentaCancelada con scaffolding pero nunca dispatcheadosapp/Events/Facturacion/*
L-34MediumFeature PlanCredito construida parcialmente, no operacionalapp/Services/PlanCreditoService.php
L-20LowLa página de Ventas de Aliado colorea estado cancelada inexistenteresources/js/pages/ally/ventas/Page.vue

Índice por directorio

Cuando esté por editar un archivo, encuentre su directorio abajo y verifique primero las minas listadas.

Directorio / ArchivoMinas
bootstrap/app.phpL-07, L-08, L-27
routes/web.phpL-01, L-03, L-27, L-31
routes/api.phpL-02, L-26
routes/ally/auth.phpL-04, L-05
app/Services/VentaService.phpL-09, L-10, L-11, L-15
app/Services/DataCreditoService.phpL-02 (consumidor), L-17
app/Services/AprobarCupoService.phpL-30
app/Services/IdentityValidationService.phpL-28, L-29
app/Services/PlanCreditoService.phpL-34
app/Jobs/GenerarCreditoDeVenta.phpL-12, L-13, L-14
app/Jobs/ProcesarPagareDigital.phpL-10
app/Jobs/ProcesarOrdenesAbandonadas.php(relacionado con L-12, L-13 indirectamente)
app/Http/Controllers/Market/VentaController.phpL-01, L-15, L-16
app/Http/Controllers/Market/LineaController.phpL-31
app/Http/Controllers/Market/MarcaController.phpL-03
app/Http/Controllers/Api/DataCreditoController.phpL-02
app/Http/Controllers/AliadoAuth/AuthenticatedSessionController.phpL-05
app/Http/Controllers/AliadoAuth/RegisteredUserController.phpL-04
app/Events/Facturacion/*L-32
app/Listeners/Facturacion/VentaCanceladaListener.phpL-32, L-33
app/Listeners/Facturacion/VentaCompletadaListener.phpL-32, L-34
app/Models/Modelo.phpL-11 (el origen de la convención creado_en)
resources/js/composables/useWishlist.tsL-19
resources/js/pages/settings/Profile.vueL-18
resources/js/pages/ally/ventas/Page.vueL-20
resources/js/plugins/errorInterceptor.tsL-26
.env.exampleL-06, L-21
.github/workflows/tests.ymlL-25

Índice por flujo de trabajo

Cuando esté trabajando en un flujo de negocio en lugar de un archivo único, use este lente.

Flujo de trabajoMinas activas
Aprobación de crédito (aprobación de Cupo)L-28 (OTP no forzado), L-29 (score no forzado), L-30 (regla 5-de-6, no secuencia estricta), L-17 (cache DataCrédito + UUID)
Creación de Ventas (Empresa única y multi-Empresa)L-09 (valor de retorno de Certicámara), L-10 (Cuota duplicada), L-11 (created_at vs creado_en), L-12 (estado de orden por Venta), L-13 (handler failed), L-14 (carrera de decremento de Cupo), L-15 (inflación de monto), L-16 (Cuotas hardcoded)
Checkout (lado del Cliente)L-01 (bypass de middleware), L-15 (inflación de monto), L-16 (Cuotas hardcoded), L-18 (contrato de perfil), L-19 (contrato de ListaDeseo)
Portal de AliadoL-04 (enlace firmado), L-05 (rate limit), L-20 (color de estado), L-31 (rutas de Línea)
Catálogo (Marcas, Líneas, Productos)L-03 (CRUD público de Marcas), L-31 (rutas de escritura de Líneas rotas)
Webhooks e integracionesL-09 (Certicámara), L-13 (sin handler failed), L-17 (DataCrédito), y entradas en docs/audit/17-critical-additional-findings.md (F-005 HMAC del webhook, F-010 TLS)
Contratos Frontend ↔ BackendL-18, L-19, L-20, L-26
Infraestructura y operacionesL-06, L-07, L-21, L-22, L-23, L-24, L-25, L-26, L-27
Código muerto (no “arreglar” sin contexto)L-31, L-32, L-33, L-34
Manejo de errores y observabilidadL-08 (ApiExceptionHandler), L-22 (sin APM), L-26 (contaminación de logs del frontend), L-27 (health checks)

Lo que este documento no cubre

  • Hallazgos críticos del Doc 17. Este archivo documenta lo que está validado contra el doc 16 — los 19 hallazgos originales más las brechas de higiene. El doc 17 (docs/audit/17-critical-additional-findings.md) lista ~91 hallazgos adicionales descubiertos en la segunda pasada profunda. Esos incluyen brechas de aislamiento de tenants, mass assignment, inyección XML en SOAP, clientes HTTP con TLS deshabilitado, XSS en descripción de Productos, condiciones de carrera en decremento de inventario, violaciones de Habeas Data en logging y más. Lea el doc 17 en su totalidad antes de trabajar en multi-tenancy, SOAP Core Crédito, webhook de Certicámara o cualquier handler que toque PII.
  • Rendimiento e indexado. El doc 17 lista una pasada de agente pendiente sobre Rendimiento / N+1 / índices. Aún no está en este archivo.
  • Brechas de tests. El audit nota cero tests de frontend y cobertura parcial de tests de backend. No son minas per se — pero la ausencia es un multiplicador sobre cada otro riesgo.

Si encuentra una mina que no esté documentada aquí, agréguela con el siguiente ID (L-35, L-36, …). Use el mismo formato. Enlace minas relacionadas. Mantenga este documento actualizado — es la pieza única de onboarding escrita en la que el siguiente desarrollador confiará.