Errores Conocidos y Minas

Ú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:
- Antes de editar cualquier archivo, busque la ruta del archivo en este documento. Si una mina coincide, léala antes de cambiar cualquier cosa.
- La tabla Índice por Archivo al final es la forma más rápida de hacer grep para entrar.
- 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.
- 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.
| Severidad | Significado | Ejemplo |
|---|---|---|
| Critical | Corrupció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. |
| High | Fallos 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. |
| Medium | Comportamiento 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. |
| Low | Cosmé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-120app/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-106Route::get('checkout', function() { ... }) ->middleware('cliente_registro_completo', 'verificar_cliente_presenta_mora') ->name('checkout.view');
// routes/web.php:108-120Route::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
- Autenticarse como un Cliente que tiene
cliente_registro_completo = falseo que está en mora. - Saltarse el flujo del navegador y hacer
curl -X POST /mis-compras/procesar-carritocon un payload válido debeneficiarioy al menos un item del Carrito. - 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 alGET /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
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-26app/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-26Route::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:
- Consultar el buró de crédito en nombre de Mi Plante usando números de DNI arbitrarios.
- Quemar créditos de API del buró / alcanzar límites de tasa y forzar a Mi Plante a penalizaciones del buró.
- 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
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:
- Mover el endpoint detrás de
auth:web(oauth:appsi es una herramienta de Aliado):Route::middleware('auth')->prefix('v1')->group(function () {Route::get('/datacredito/historial', [DataCreditoController::class, 'consultarHistorial'])->middleware('throttle:10,1');}); - Mejor: no exponer el buró como endpoint HTTP en absoluto. Hacer que
DataCreditoService::consultarHistorialCreditosea 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 deapiResource)app/Http/Controllers/Market/MarcaController.php
Qué está mal
// routes/web.php:29Route::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
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
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-16app/Http/Controllers/AliadoAuth/RegisteredUserController.php(el métodostore)
Qué está mal
El flujo de URL firmada para el registro de Aliado va:
- Un administrador aprueba una
PostulacionAliado. El sistema envía al postulante una URL firmada por correo. - El postulante hace clic en el enlace →
GET /aliados/postulacion/{postulacion}/registrorenderiza el formulario de registro. - El postulante envía →
POST /aliados/postulacion/registrocrea el User y la Empresa.
Mire las rutas:
// routes/ally/auth.php:13-16Route::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
- Disparar la aprobación de una Postulación de administrador; el sistema crea la fila
postulacion_aliadoscon id N. - 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": {...} }' - 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
- Mover el POST bajo el mismo parámetro
{postulacion}y aplicarsigned:Route::post('{postulacion}/registro', [RegisteredUserController::class, 'store'])->name('aliado-postulacion.register.store')->middleware('signed'); - Llevar la URL firmada al atributo de acción del formulario en el renderizado del GET.
- Agregar un timestamp
consumido_en(otoken_consumido_en) apostulacion_aliadosy rechazar el POST si ya está establecido. Esto hace que el enlace sea de un solo uso. - Reducir el TTL de la URL firmada de 3 meses a 7 días (esto requiere un cambio de configuración en
config/app.phpo 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.phproutes/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:
- El portal de Aliado otorga el guard
auth:appcon privilegios elevados sobre datos del socio. - 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
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:38—SESSION_ENCRYPT=falseconfig/session.php(leeenv('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=trueY 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
- Instalar
spatie/laravel-cspo escribir un middleware pequeñoSecurityHeaders. - Comenzar en modo solo-reporte por una semana; recolectar violaciones vía
report-uri. - Promover a forzado después de que la tabla de violaciones esté vacía.
- Crear
config/cors.phpincluso 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-99app/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.phpson 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-500private 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é, devuelvefalse. El job no se dispatcha incluso sipagare_firmado_enestá establecido (lo cual no estaría en la primera llamada de todos modos). - Llamada subsiguiente (Cliente ya tiene uuid): retorna temprano
true. El job sí se dispatcha sipagare_firmado_enestá 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 → ProcesarPagareDigital → GenerarCreditoDeVenta).
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-752app/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::crearVentaUnicaif (!empty($dto->numero_cuotas) && $dto->numero_cuotas > 0) { $this->generarCuotas($venta); // <-- crea Cuotas ahora}
// VentaService::crearVentasPorEmpresaif (!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
- El Cliente hace una compra de Empresa única.
VentaService::crearVenta→crearVentaUnica→generarCuotas→ se crean 6 filas de Cuota. - El Pagaré de Certicámara del Cliente se firma; el webhook dispara
POST /api/v1/webhooks/certicamara. ProcesarPagareDigital::handlese ejecuta →generarCuotasde nuevo → se crean 6 filas más de Cuota.- 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:
- Primera versión: crear Cuotas inline cuando se crea la Venta. Síncrono, simple.
- 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. - 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:
-
Restricción de base de datos (prevenir duplicados sin importar las rutas del código):
// nueva migraciónSchema::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 -
Eliminar las llamadas síncronas en
VentaService::crearVentaUnicaycrearVentasPorEmpresa. Las Cuotas deben generarse solo medianteProcesarPagareDigitaldespués de que se dispare el webhook. Hasta que se firme el Pagaré, la Venta estáPENDIENTEy 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— declaraconst 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:
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_vencimientolocal. - 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-95DB::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.
GenerarCreditoDeVenta(Venta_A)tiene éxito → orden =PROCESADA, Venta_A =APROBADA.GenerarCreditoDeVenta(Venta_B)falla → Venta_B =RECHAZADA, pero la orden se mantienePROCESADAporque 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_disponibledel Cliente no se restaura (el inventario solo se restaura dentro derechazarVenta, 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 = PENDIENTEpermanentemente.OrdenCompra.estado = PENDIENTE(oPROCESADAsi 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
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
- Configurar queue worker con
--queue=creditos --max-jobs=10y múltiples workers. - Cliente con Cupo 1.000.000 hace una compra multi-Empresa: Venta_A=500.000 y Venta_B=400.000.
- Ambos jobs se dispatchan. Ambos leen
cupo_disponible = 1.000.000. - Ambos escriben de vuelta: Venta_A escribe
1.000.000 - 500.000 = 500.000. Venta_B escribe1.000.000 - 400.000 = 600.000. - 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-197app/Services/VentaService.php:152-154
Qué está mal
Dos multiplicaciones que se componen. Dentro de procesarCarrito:
// VentaController::procesarCarrito:186-197foreach ($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
- El Cliente agrega un Producto de 500.000 COP, cantidad 2, al Carrito.
- El Cliente completa el checkout vía
POST /mis-compras/procesar-carrito. ventas.subtotalyventas.totalse registran como 2.000.000 en lugar de 1.000.000.- 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_cuotasde 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-40app/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-40const 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
nombresrequerido 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-35app/Http/Controllers/Market/ListaDeseoController.php(el métodoindex)
Qué está mal
El composable:
// resources/js/composables/useWishlist.ts:25-35async 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
- Agregar un item a la ListaDeseo.
- Recargar la página.
- 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-93app/Enum/Facturacion/EstadoVenta.php
Qué está mal
El helper de color de estado del frontend:
// resources/js/pages/ally/ventas/Page.vue:82-93getStatusColor(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=databaseconfig/logging.php(el canaldatabasey las tres clases custom de logger)
Qué está mal
La aplicación ha elegido MySQL como backend para:
- Sesiones (
SESSION_DRIVER=database) — la tablasessions. - Cache (
CACHE_STORE=database) — la tablacache. - Queue (
QUEUE_CONNECTION=database) — las tablasjobsyfailed_jobs. - Logs personalizados de aplicación — tres clases de logger (
backend_request_logs,frontend_error_logs, más el canallogs). 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=phpredisREDIS_HOST=127.0.0.1REDIS_PASSWORD=nullREDIS_PORT=6379grep -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=redisSESSION_DRIVER=redisVerifique 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=redisDrene 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_jobsy 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:
- Sentry (5k errores/mes gratis):
composer require sentry/sentry-laravel,php artisan sentry:publish, configureSENTRY_LARAVEL_DSN. Sentry captura excepciones de Laravel y errores JS no manejados con stack traces. - UptimeRobot (50 monitores gratis): apunte a
/healthy/up. Obtenga un SMS cuando el sitio se caiga. - Laravel Horizon (gratis, requiere Redis — depende de L-21 fase 1): dashboard de queue + métricas por queue. Úselo para ver
failed_jobsy 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:
Dockerfileodocker-compose.ymlnginx.conf/ config de ApacheProcfile(estilo Heroku)terraform/u otro IaCdeploy.sh/ EnvoyEnvoy.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
- Instalar
spatie/laravel-backup:Ventana de terminal composer require spatie/laravel-backupphp artisan vendor:publish --provider="Spatie\Backup\BackupServiceProvider" - Programar en
routes/console.php:Schedule::command('backup:clean')->daily()->at('01:00');Schedule::command('backup:run')->daily()->at('02:00'); - Configurar el destino en
config/backup.phppara 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.ymlphpunit.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/phpunitPero 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.xmles 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/phpunitLuego 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-39resources/js/services/errorLogger.tsapp/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-39instance.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:
- Crecimiento de DB. Cada visita bombea docenas de filas a
frontend_error_logs. La tabla crece sin límite y sin retención. - 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.
- 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:
-
Dejar de loggear éxitos:
instance.interceptors.response.use((response) => response, // pass-through, no loggingasync (error) => {if (!error.config?.url?.includes('/api/error-logs')) {await errorLogger.logAxiosError(error, 'Axios Response Interceptor', error.config.metadata?.trackingId);}return Promise.reject(error);}); -
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::storepara limpiarpassword,otp_code,dni, headersAuthorization, etc. -
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(rutahealth: '/up')
Qué está mal
// routes/web.php:21Route::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:
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étodoidentityValidationGenerateQuestions)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):
- Verificación legal
- Validación de identidad (OTP)
- Validación de identidad (cuestionario)
- Validación HDC
- Umbral de score
- 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-38public 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:
Fuente Regla que afirma docs/DIAGRAMA_APROBAR_CUPO_UML.md(UML oficial del equipo)requiere 3+ procesos exitosos en el mesapp/Services/AprobarCupoService.php:17-38(código en runtime)>= 5tipos distintos con FINISH_SUCCESS en el mes, sin secuenciaEl 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:32Route::apiResource('lineas', LineaController::class)->names('lineas');apiResource registra index, show, store, update, destroy. Pero LineaController solo implementa index y show:
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
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.phpapp/Events/Facturacion/VentaCancelada.phpapp/Listeners/Facturacion/VentaCompletadaListener.phpapp/Listeners/Facturacion/VentaCanceladaListener.php
Qué está mal
Los eventos y listeners existen:
$ grep -rn "VentaCompletada::dispatch\|event(new VentaCompletada\|VentaCancelada::dispatch\|event(new VentaCancelada" app/# zero matchesNadie los dispatcha. El flujo de facturación activo va:
crearVenta → ProcesarPagareDigital (via webhook) → GenerarCreditoDeVentaSin 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 enVentaService::crearVentaUnica:262-263yGenerarCreditoDeVentadecrementa 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
use App\Enum\Facturacion\Estado; // <-- ESTA CLASE NO EXISTEuse 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_creditosfue 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.
| ID | Severidad | Título | Archivo principal |
|---|---|---|---|
| L-01 | Critical | Bypass de checkout vía POST directo | routes/web.php, app/Http/Controllers/Market/VentaController.php |
| L-02 | Critical | Endpoint público de historial de DataCrédito | routes/api.php |
| L-03 | Critical | CRUD público en marcas | routes/web.php |
| L-08 | Critical | Respuesta de ApiExceptionHandler descartada | bootstrap/app.php |
| L-09 | Critical | registrarEnCerticamara siempre devuelve false | app/Services/VentaService.php |
| L-10 | Critical | Generación duplicada de Cuotas | app/Services/VentaService.php, app/Jobs/ProcesarPagareDigital.php |
| L-15 | Critical | procesarCarrito infla el monto | app/Http/Controllers/Market/VentaController.php |
| L-21 | Critical | Único MySQL — punto único de fallo | .env.example |
| L-33 | Critical (si se revive) | VentaCanceladaListener referencia enum inexistente | app/Listeners/Facturacion/VentaCanceladaListener.php |
| L-04 | High | Registro de Aliado no vinculado a enlace firmado en POST | routes/ally/auth.php |
| L-05 | High | Login de Aliado sin rate limiting | app/Http/Controllers/AliadoAuth/AuthenticatedSessionController.php |
| L-06 | High | Encriptación de sesión apagada | .env.example |
| L-07 | High | Sin headers CSP, sin config CORS | bootstrap/app.php |
| L-11 | High | Vencimientos de Cuotas computados desde created_at | app/Services/VentaService.php |
| L-12 | High | GenerarCreditoDeVenta marca orden PROCESADA por Venta | app/Jobs/GenerarCreditoDeVenta.php |
| L-13 | High | GenerarCreditoDeVenta sin handler failed() | app/Jobs/GenerarCreditoDeVenta.php |
| L-14 | High | Decremento de Cupo no atómico | app/Jobs/GenerarCreditoDeVenta.php |
| L-16 | High | procesarCarrito hardcodea numero_cuotas = 6 | app/Http/Controllers/Market/VentaController.php |
| L-17 | High | Cache key de DataCrédito insuficiente; requestUUID fijo | app/Services/DataCreditoService.php |
| L-22 | High | Sin Sentry / APM / monitoreo externo | (ausencia) |
| L-24 | High | Sin automatización de backups en el repo | (ausencia) |
| L-28 | High | OTP no forzado antes del cuestionario | routes/web.php, app/Services/IdentityValidationService.php |
| L-29 | High | Score mínimo no forzado | app/Services/IdentityValidationService.php |
| L-18 | Medium | settings/Profile.vue usa name no nombres/apellidos | resources/js/pages/settings/Profile.vue |
| L-19 | Medium | useWishlist espera array, backend pagina | resources/js/composables/useWishlist.ts |
| L-23 | Medium | Sin artefactos de despliegue | (ausencia) |
| L-25 | Medium | CI roto — sin servicio MySQL | .github/workflows/tests.yml |
| L-26 | Medium | Logger del frontend inunda el backend con éxitos | resources/js/plugins/errorInterceptor.ts |
| L-27 | Medium | Health checks no verifican dependencias | routes/web.php, bootstrap/app.php |
| L-30 | Medium | Aprobación requiere ≥5 tipos de proceso, no secuencia estricta | app/Services/AprobarCupoService.php |
| L-31 | Medium | Rutas de escritura de lineas carecen de métodos de controlador | routes/web.php, app/Http/Controllers/Market/LineaController.php |
| L-32 | Medium | Eventos VentaCompletada / VentaCancelada con scaffolding pero nunca dispatcheados | app/Events/Facturacion/* |
| L-34 | Medium | Feature PlanCredito construida parcialmente, no operacional | app/Services/PlanCreditoService.php |
| L-20 | Low | La página de Ventas de Aliado colorea estado cancelada inexistente | resources/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 / Archivo | Minas |
|---|---|
bootstrap/app.php | L-07, L-08, L-27 |
routes/web.php | L-01, L-03, L-27, L-31 |
routes/api.php | L-02, L-26 |
routes/ally/auth.php | L-04, L-05 |
app/Services/VentaService.php | L-09, L-10, L-11, L-15 |
app/Services/DataCreditoService.php | L-02 (consumidor), L-17 |
app/Services/AprobarCupoService.php | L-30 |
app/Services/IdentityValidationService.php | L-28, L-29 |
app/Services/PlanCreditoService.php | L-34 |
app/Jobs/GenerarCreditoDeVenta.php | L-12, L-13, L-14 |
app/Jobs/ProcesarPagareDigital.php | L-10 |
app/Jobs/ProcesarOrdenesAbandonadas.php | (relacionado con L-12, L-13 indirectamente) |
app/Http/Controllers/Market/VentaController.php | L-01, L-15, L-16 |
app/Http/Controllers/Market/LineaController.php | L-31 |
app/Http/Controllers/Market/MarcaController.php | L-03 |
app/Http/Controllers/Api/DataCreditoController.php | L-02 |
app/Http/Controllers/AliadoAuth/AuthenticatedSessionController.php | L-05 |
app/Http/Controllers/AliadoAuth/RegisteredUserController.php | L-04 |
app/Events/Facturacion/* | L-32 |
app/Listeners/Facturacion/VentaCanceladaListener.php | L-32, L-33 |
app/Listeners/Facturacion/VentaCompletadaListener.php | L-32, L-34 |
app/Models/Modelo.php | L-11 (el origen de la convención creado_en) |
resources/js/composables/useWishlist.ts | L-19 |
resources/js/pages/settings/Profile.vue | L-18 |
resources/js/pages/ally/ventas/Page.vue | L-20 |
resources/js/plugins/errorInterceptor.ts | L-26 |
.env.example | L-06, L-21 |
.github/workflows/tests.yml | L-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 trabajo | Minas 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 Aliado | L-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 integraciones | L-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 ↔ Backend | L-18, L-19, L-20, L-26 |
| Infraestructura y operaciones | L-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 observabilidad | L-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á.