Saltearse al contenido

Gotchas Comunes

Dieciocho entradas. Cada una es algo con lo que un dev nuevo se va a topar en su primera semana o dos, y cada una le costó tiempo al equipo de auditoría antes de que lo entendiéramos. Lee esto una vez antes de empezar a escribir código. Reléelo la primera vez que algo se sienta raro.

El formato de cada gotcha:

G-XX — <título>
Síntoma: lo que ves
Causa: lo que realmente está pasando
Solución: cómo desatorarte ahora mismo
Evitar en el futuro: cómo no volver a caer

Para cobertura más profunda de los bugs reales (los “landmines”), ver docs/onboarding/04-the-landmines/ y docs/audit/16-deep-validation-study.md + 17-critical-additional-findings.md.


G-01 — created_at no existe en la mayoría de los modelos de dominio

Síntoma: Una query de Eloquent como $venta->created_at retorna null aunque la fila claramente acaba de ser insertada. O $venta->where('created_at', '>', now()->subDay()) retorna vacío.

Causa: La clase base App\Models\Modelo renombra created_at / updated_at a creado_en / actualizado_en. La mayoría de los modelos de dominio (Venta, OrdenCompra, Cuota, Cliente, Producto, etc.) extienden Modelo y heredan esta convención. Los asistentes de IA y los snippets copiados de Laravel usan rutinariamente created_at porque ese es el default de Laravel.

Solución: Usa $venta->creado_en y $venta->actualizado_en. Para queries:

$venta->where('creado_en', '>', now()->subDay())->get();

Evitar en el futuro: Cuando veas un nombre de modelo en español, espera columnas de timestamp en español. Las tablas de Laravel que no son de dominio (users, cache, sessions, jobs, failed_jobs) mantienen los defaults en inglés — estate atento a la frontera.

Bonus: VentaService::generarCuotas() en app/Services/VentaService.php:582 usa $venta->created_at ?? now() — ese es el bug L-11, porque created_at siempre es null en Venta, así que siempre cae al now(). La persona de soporte al cliente Esteban tiene tickets sobre exactamente este bug.


G-02 — Tu queue job no se está ejecutando

Síntoma: Despachaste un job (ProcesarPagareDigital, GenerarCreditoDeVenta, IniciarCargaMasivaProducto). La tabla de jobs muestra la fila pero no pasa nada. Los efectos secundarios downstream no se disparan.

Causa: composer dev arranca php artisan queue:listen --tries=1 como uno de sus cuatro procesos concurrentes. Si corriste php artisan serve directamente, el queue listener no está corriendo. Los jobs se encolan pero nunca se ejecutan.

Solución: O usa composer dev, o corre php artisan queue:listen --tries=1 en una terminal separada.

Evitar en el futuro: Usa composer dev como tu default. Si debes dividir procesos, agrega queue:listen a tu rutina.

Nota: --tries=1 significa que un job que lance excepción se mueve inmediatamente a failed_jobs. En prod con --tries=3 tendrías reintentos. En dev local, --tries=1 está bien — quieres fallos ruidosos.


G-03 — Tu conteo de Cuotas es el doble de lo que esperas

Síntoma: El cliente compra 12 cuotas. Inspeccionas la BD y encuentras 24 filas de cuotas para esa venta.

Causa: Landmine L-10 (cadena F-001). VentaService::crearVenta() llama a generarCuotas() cuando la venta se crea. Luego App\Jobs\ProcesarPagareDigital llama a generarCuotas() otra vez después de la firma del pagaré. Ningún lado revisa cuotas existentes. Ambas corridas hacen INSERT, así que las cuotas se duplican.

Solución (inmediata, manual): Cuota::where('venta_id', $ventaId)->delete() antes de re-disparar, en Tinker.

Evitar en el futuro: Al investigar problemas de cuotas, siempre corre:

SELECT venta_id, COUNT(*) FROM cuotas GROUP BY venta_id HAVING COUNT(*) > numero_cuotas;

El arreglo implica hacer generarCuotas() idempotente — ya sea borrando primero las cuotas existentes (transaccional), o saltándose si existen cuotas. Este es un landmine Tier-0 por arreglar.


G-04 — El total/subtotal de la Venta es muy grande

Síntoma: El cliente hace checkout de 2 unidades de un producto de 500.000 COP. La fila de Venta muestra subtotal = 2.000.000 (doblado) en lugar de 1.000.000. Las cuotas se calculan sobre el número inflado.

Causa: Landmine L-15 (F-001). En app/Http/Controllers/Market/VentaController.php alrededor de la línea 187, procesarCarrito() construye el payload del detalle como monto = precio × cantidad y pasa cantidad por separado. Luego en app/Services/VentaService.php:152-154, crearVenta() hace:

$subTotalCalculado = collect($dto->detalles)->sum(function ($detalle) {
return $detalle['cantidad'] * $detalle['monto']; // cantidad × (precio × cantidad)
});

Eso es cantidad² — para cantidad = 2 obtienes 4× el precio unitario. El 17-critical-additional-findings.md de la auditoría cuantifica el impacto: el cliente sobrepagó ~128.5% en un plan de 12 cuotas.

Solución (inmediata): cuando razones sobre una Venta con cantidad > 1, divide mentalmente el subtotal por la cantidad.

Evitar en el futuro: una solución defendible es cambiar procesarCarrito() para pasar monto = precio (precio unitario, no extendido), de modo que crearVenta() haga la multiplicación exactamente una vez. También hay una alternativa: cambiar crearVenta() para que espere monto como el precio extendido y no multiplique por cantidad otra vez. Sea cual sea el lado que arregles, agrega un test que afirme que subtotal === precio_unitario × cantidad.


G-05 — 401 en una ruta Inertia después de iniciar sesión como aliado

Síntoma: Iniciaste sesión en /aliados/login como testadmin@example.com. Haces clic en un link a (ej.) /usuario/perfil y obtienes un redirect 401 al login del cliente.

Causa: La ruta está en routes/web.php y usa el guard web (cliente). La sesión que acabas de crear está en el guard app (portal de partners). Son contextos de auth separados aunque compartan el mismo dominio de cookie.

Solución: Usa las rutas del portal de partners (/aliados/...) cuando estés logueado como aliado. Si realmente quieres que una ruta funcione para ambos guards, configura el middleware en la ruta acordemente — pero eso es raro. Los dos portales son intencionalmente separados.

Evitar en el futuro: Cuando dudes sobre qué guard usa una ruta, revisa el archivo donde vive:

  • routes/web.php → guard web (cliente)
  • routes/ally/web.php → guard app (aliado/admin)
  • routes/ally/auth.php → flujos de auth del guard app
  • routes/auth.php → flujos de auth del guard web
  • routes/api.php → típicamente auth:sanctum o público

G-06 — La actualización en settings/Profile.vue falla silenciosamente

Síntoma: Cambias tu nombre en la página de configuración del perfil, haces clic en guardar, el request tiene éxito (200 OK) pero los datos en realidad no se actualizan.

Causa: Landmine L-18. El frontend resources/js/pages/settings/Profile.vue envía un único campo name. El backend app/Http/Requests/Settings/ProfileUpdateRequest.php espera nombres y apellidos por separado. El validator del request silenciosamente descarta name (no está en el array validado) y no actualiza nada.

Solución (inmediata): envía nombres y apellidos por separado al probar. La UI de la página miente; el contrato de la API requiere la separación.

Evitar en el futuro: esta es una deriva en el contrato frontend-backend. Arréglalo actualizando cualquiera de los dos lados. La solución es directa: cambia la página Vue para tener dos inputs y enviar ambos campos. Agrega un test que afirme que el round-trip funcione para un usuario con ambos campos llenos.


G-07 — La Wishlist no carga

Síntoma: Los contadores de wishlist muestran 0 en la barra de navegación aunque tienes items en tu lista. O el composable useWishlist() retorna un array vacío.

Causa: Landmine L-19. El backend ListaDeseoController retorna un paginator de Laravel (con data, links, meta, etc.). El frontend resources/js/composables/useWishlist.ts asume un array plano. La estructura de envoltura del paginator no se desempaqueta.

Solución (inmediata): en el composable, cambia el acceso a .data por .data.data o donde sea que viva el array de items en la respuesta del paginator. Mejor: haz que el controller retorne un array plano específicamente para el endpoint de preload.

Evitar en el futuro: cuando un controller de Laravel retorna Model::paginate(...), el consumidor debe saber que es un paginator. Si el frontend espera un array plano, ya sea el controller debe hacer ->get()/->all() o el frontend debe desempaquetar. Elige uno explícitamente.


G-08 — Mi middleware personalizado no sobreescribe lo que está en bootstrap/app.php

Síntoma: Escribiste una nueva clase de middleware. La agregaste a una ruta. No se dispara (o se dispara después de algo que querías interceptar).

Causa: Laravel 12 ya no usa app/Http/Kernel.php. Los alias de middleware y los stacks globales viven en bootstrap/app.php. Si tu alias no está en el bloque withMiddleware(), las referencias al alias a nivel de ruta fallan silenciosamente.

Solución (inmediata): Abre bootstrap/app.php. Encuentra el bloque ->withMiddleware(). Agrega tu alias:

->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'mi_nuevo_alias' => \App\Http\Middleware\MiNuevoMiddleware::class,
// ... los existentes
]);
})

Evitar en el futuro: cuando crees un middleware, agrega inmediatamente el alias. Si estás buscando el comportamiento de un middleware, busca primero en bootstrap/app.php, luego en la clase del middleware.


G-09 — composer test falla con “no MySQL test connection”

Síntoma: Corres composer test y PHPUnit explota al inicio con errores de conexión a base de datos.

Causa: phpunit.xml configura una conexión MySQL de tests. La base de datos miplante_testing (o como la hayas llamado) no existe, no está migrada, o tu .env.testing no apunta a las credenciales correctas.

Solución:

Ventana de terminal
# Crear la BD de testing
mysql -u root -p
> CREATE DATABASE miplante_testing CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
> GRANT ALL ON miplante_testing.* TO 'miplante'@'localhost';
> EXIT;
# Migrarla
php artisan migrate --env=testing

Evitar en el futuro: El pipeline de CI actualmente cae al fallback de SQLite (según el L-25 de la auditoría, roto). No confíes solo en CI verde — corre los tests localmente sobre MySQL.


G-10 — Los cambios en .env no toman efecto

Síntoma: Actualizaste TASA_NOMINAL=0.05 en .env, refrescaste la página, y el simulador sigue mostrando la tasa vieja.

Causa: Laravel cachea la configuración fusionada cuando config:cache ha sido ejecutado. Incluso sin caching explícito, a veces los valores de env se cargan en el proceso en ejecución al arranque y quedan obsoletos hasta el reinicio.

Solución:

Ventana de terminal
php artisan config:clear

Luego reinicia el servidor PHP que tengas corriendo (composer dev necesitará un Ctrl+C y reinicio).

Evitar en el futuro: Corre php artisan config:clear cada vez que edites .env o config/*.php. Agrégalo a tu reflejo de “cosas que me sorprenden — primero limpia caches”.


G-11 — El simulador muestra un número distinto al de la venta real

Síntoma: El cliente simula una compra: 12 cuotas de 156.165 COP. Hace checkout por el mismo monto. La primera cuota llega a 181.165 (correcto), pero las cuotas 2-12 son de 168.265 cada una — más altas de lo que el simulador mostró.

Causa: resources/js/lib/credit.ts y app/Services/VentaService.php::generarCuotas() han divergido. El frontend usa monthlyBail = totalBail / monthsWithBail (correcto). El backend en VentaService.php:579 se salta la división en el cálculo de la fianza, así que la fianza se sobrecobra.

Solución (inmediata): al razonar sobre matemáticas de cuotas, compara ambas funciones lado a lado. No confíes solo en el simulador.

Evitar en el futuro: cuando cambies la matemática de las cuotas, cambia ambos archivos en el mismo PR. Agrega un test de integración que tome algunos inputs de muestra y afirme que el frontend y el backend coinciden. Ver 03-the-money-flow.md sección 11 para un esquema de test inicial.


G-12 — DataCrédito retorna los datos cacheados equivocados

Síntoma: Consultas el HDC de DataCrédito para un cliente (ej. María, DNI 12345678 con apellido García). Más tarde, consultas por un cliente distinto con el mismo DNI pero diferente apellido (ej. DNI 12345678, apellido López). Obtienes los datos de María de vuelta.

Causa: Landmine L-17. App\Services\DataCreditoService cachea respuestas HDC indexadas por numero_documento + tipo_documento, omitiendo apellido. En Colombia, los números de DNI son únicos por individuo — pero la cache key no está garantizada de coincidir con una sola persona si los inputs difieren (o si el DNI se reutiliza para testing).

Solución (inmediata): en Tinker, Cache::forget('datacredito.historial.<key>') para invalidar la entrada de cache mala. O php artisan cache:clear para purgar todo.

Evitar en el futuro: cada vez que caches una respuesta de API externa, la cache key debe incluir cada input que pueda cambiar la respuesta. Agrega apellido a la cache key en DataCreditoService como solución a largo plazo.


G-13 — La ruta existe pero retorna 500

Síntoma: Ves POST /lineas en php artisan route:list. La golpeas. Obtienes un 500 con “Method does not exist” o “Call to undefined method”.

Causa: El LineaController está registrado como apiResource, así que todas las rutas CRUD se auto-generan. Pero el controller solo implementa index() y show(). Las rutas store, update, destroy son rutas fantasma — registradas pero no funcionales. Lo mismo para algunos otros API resources.

Solución (inmediata): no llames esas rutas desde código nuevo. No funcionan.

Evitar en el futuro: cuando agregues un registro apiResource, implementa todos los métodos o limítalo explícitamente a ->only(['index', 'show']). De lo contrario publicas rutas que crashean.

El 02-route-inventory.md de la auditoría las marca como “registered but not functional”.


G-14 — El hot reload no toma los cambios del backend

Síntoma: Editas un controller PHP. El cambio es visible en el archivo. El navegador sigue mostrando el comportamiento viejo.

Causa: Vite hace hot-reload solo de assets de frontend (.vue, .ts, .css). Los cambios PHP requieren un page reload completo porque la respuesta se regenera en cada request — pero solo si el request vuelve al PHP. Inertia cachea la respuesta previa en algunos flujos.

Solución: Cmd+R / Ctrl+R para un reload completo. En casos tercos, Cmd+Shift+R / Ctrl+Shift+R para un hard refresh.

Evitar en el futuro: Los cambios PHP no tienen HMR. Construye el músculo de “cambié PHP, refresco la página”.

Para cambios PHP más grandes (nuevo middleware, nuevas rutas, nueva config), también corre php artisan config:clear y reinicia php artisan serve.


G-15 — El middleware de permisos de Spatie no aplica nada

Síntoma: Ves Spatie Permission instalado en composer.json. Esperas que las directivas @can o middleware('permission:foo') controlen el acceso a las rutas. No lo hacen.

Causa: Spatie Permission está instalado y las migraciones están corridas, pero el paquete no está conectado a la autorización en runtime para este codebase. La autorización se hace vía:

  • Verificaciones de guard (web vs app)
  • Verificaciones manuales de rol en los controllers (ej. if ($user->rol() !== 'administrador') abort(403);)
  • Middleware a nivel de ruta que verifica condiciones de negocio específicas

Solución (inmediata): para cualquier autorización nueva, usa los patrones existentes. No te apoyes en Spatie @can o Gate::allows().

Evitar en el futuro: cuando agregues una feature nueva que necesita acceso basado en rol, mira el patrón existente (ej. cómo aliado.ventas.reporte-comercial verifica administrador_comercial). Refléjalo. Hay un candidato real Tier-2 para realmente conectar Spatie, pero hasta entonces, no pretendas que está conectado.

La auditoría (docs/audit/07-auth-authorization-map.md y 16-deep-validation-study.md) lo marca explícitamente.


G-16 — ¿Cómo hago ProcesarPagareDigital idempotente localmente?

Síntoma: Estás probando el flujo del pagaré. Re-disparas ProcesarPagareDigital para probar un arreglo. Crea un segundo set de cuotas cada vez.

Causa: Landmine L-10 (igual que G-03). El job no es idempotente.

Solución (inmediata): antes de re-correr el job, limpia manualmente las cuotas para esa venta:

// En Tinker
use App\Models\Facturacion\Cuota;
Cuota::where('venta_id', $ventaId)->delete();

Luego re-dispara el job. O, mejor, despacha el job a un queue worker e inspecciona vía failed_jobs si no se comporta.

Evitar en el futuro: al arreglar la idempotencia, agrega una verificación al inicio de ProcesarPagareDigital::handle():

if (Cuota::where('venta_id', $venta->id)->exists()) {
Log::warning('Cuotas already exist; skipping regeneration', ['venta_id' => $venta->id]);
return;
}

O usa DB::transaction() con firstOrCreate(). Cualquiera de los dos patrones funciona.


G-17 — Los logs de error del frontend están llenando la BD

Síntoma: La tabla frontend_error_logs crece en cientos o miles de filas por día, aunque nada está obviamente roto en el frontend.

Causa: Landmine L-26. El interceptor de axios del frontend loguea cada request, incluyendo los exitosos (200s), no solo los errores. El interceptor probablemente fue escrito asumiendo que corría solo en errores.

Solución (inmediata): trunca periódicamente la tabla en dev:

TRUNCATE TABLE frontend_error_logs;

Evitar en el futuro: arregla el interceptor para que solo loguee status >= 400. La verificación es una línea en cualquier archivo del interceptor de axios que haga el logging (resources/js/... — busca el endpoint POST frontend_error_logs o error-logs).

Esto también es peligroso en producción: un sitio con tráfico puede llenar la BD con ruido de éxitos.


G-18 — ¿Por qué /api/v1/datacredito/historial es público?

Síntoma: Estás leyendo routes/api.php y notas que GET /api/v1/datacredito/historial no tiene middleware auth:sanctum. Cualquiera puede golpearlo, incluyendo navegadores no autenticados.

Causa: Landmine L-02 (seguridad). Es un hueco de seguridad conocido de la auditoría (docs/audit/16-deep-validation-study.md hallazgo #2). El endpoint expone una consulta sensible (historia de crédito) sin auth.

Solución (inmediata): no escribas código nuevo que dependa de que esto sea público. Trátalo como si fuera a ser bloqueado inminentemente.

Evitar en el futuro: al agregar rutas que tocan datos sensibles (PII, crédito, financiero), aplica auth:sanctum (para API) o guards apropiados (auth:web, auth:app). Cuando veas una ruta no autenticada sobre datos sensibles, márcala al equipo de auditoría — casi con certeza es un descuido, no intencional.

La solución recomendada por la auditoría: agregar auth:sanctum + throttle:30,1 a este endpoint. El flujo orientado al cliente que lo consume (probablemente una feature de auto-consulta de crédito) necesita conectarse a través de la sesión autenticada del cliente.


Bonus G-19 — “Funcionó en feature tests pero se rompe en el navegador real”

Síntoma: Tu test (tests/Feature/...) pasa. Manualmente recorres el mismo flujo en el navegador y da 500.

Causa: Los tests usan Http::fake() para cortocircuitar APIs externas. El navegador golpea el upstream real y el upstream real no está configurado en tu .env.

Solución (inmediata): ver 01-local-environment.md sección 8 — corre solo el test que no ejercita APIs externas, o construye el LocalIntegrationMockProvider para habilitar mocking del lado del navegador.

Evitar en el futuro: cuando planees una feature, separa “probado vía feature test de PHPUnit” de “verificado manualmente en navegador”. Hoy no son la misma cobertura.


Bonus G-20 — “¿Por qué obtengo ‘Tipo de documento no válido’?”

Síntoma: Inicias el flujo de aprobación de crédito con un documento CC. El Legal Check (Fase 1) te rechaza inmediatamente.

Causa: LegalCheckService::manejar() valida el tipo de documento contra una lista permitida. Si la persona fue seedeada con un tipo_dni no estándar (ej. 'NIT' para un cliente en vez de 'CC'), la Fase 1 rechazará antes incluso de llamar a TransUnion.

El ClienteAdministradorSeeder crea personas con tipo_dni = 'NIT' (porque se crean a partir de NITs de empresas). Si accidentalmente intentas usar una de esas personas como cliente, vas a caer en esto.

Solución (inmediata): al crear un Cliente para testing local vía Tinker, usa siempre tipo_dni = 'CC' (Cédula de Ciudadanía, el documento individual estándar colombiano). O 'TI' (Tarjeta de Identidad) para menores de 18.

Evitar en el futuro: al seedear nuevas Personas para testing de cliente, usa por defecto 'CC' a menos que tengas una razón específica para usar otro tipo.


Bonus G-21 — El log del backend no muestra lo que espero

Síntoma: Agregaste Log::info('I am here', $data) a un controller. No lo ves en la terminal donde está corriendo php artisan pail.

Causa: Pail filtra por nivel de log. El nivel por defecto es debug, pero si LOG_LEVEL=info o más alto en tu .env, los mensajes de nivel debug se descartan silenciosamente.

Además: LOG_CHANNEL=stack agrega múltiples canales. Si solo ves la salida del canal “single” y no del “daily”, revisa LOG_STACK en .env.

Solución: asegura LOG_LEVEL=debug en .env, reinicia Pail.

También útil: abre /aliados/log-viewer en un navegador para ver todo lo que llegó a los canales de log configurados de Laravel.

Evitar en el futuro: para logs de diagnóstico importantes en dev, usa Log::warning() o Log::error() para que el filtro de nivel no los descarte.


Cómo usar este documento

Léelo una vez ahora. Marcápalo. Cuando algo se sienta raro, abre este archivo y Ctrl+F por palabras clave (cuota, cantidad, cache, wishlist, etc.) — probablemente este documento ya lo cubre.

Cuando te topes con algo nuevo que te cueste más de 30 minutos, agrega una entrada G-XX aquí. La siguiente persona te lo va a agradecer.


Lectura relacionada

  • docs/onboarding/04-the-landmines/ — el catálogo completo de landmines (más profundo que los gotchas).
  • docs/audit/16-deep-validation-study.md — la auditoría canónica de bugs y riesgos.
  • docs/audit/17-critical-additional-findings.md — los hallazgos críticos de segunda pasada.
  • docs/audit/05-queue-catalog.md — para gotchas relacionados a queue/jobs (G-02, G-03, G-16).
  • docs/audit/03-env-config-matrix.md — para gotchas relacionados a env vars (G-10).