Saltearse al contenido

Arqueología de Código

Audiencia: desarrolladores que entran sin contexto previo al codebase de Mi Plante Archivo complementario: 01-known-bugs.md (los peligros activos). Este archivo es sobre patrones e historia — lo que el codebase revela sobre cómo creció y lo que eso significa para usted.


Por qué existe esto

Mi Plante fue construido rápido y en solitario. El desarrollador anterior envió un marketplace fintech funcionando con 38 tablas, 29 servicios, 39 DTOs, 139 rutas, 58 páginas Vue, seis integraciones externas y un pipeline de aprobación de crédito contra un buró colombiano y un core bancario — solo, en un cronograma comprimido. Esa es una cantidad impresionante de software funcional.

El costo del desarrollo de velocidad-en-solitario son capas. Una feature se comienza de una forma, el desarrollador aprende algo y la siguiente feature usa un mejor patrón. La primera feature no se reescribe — no hay un segundo par de ojos para insistir en ello. Seis meses después, el codebase lleva dos formas de hacer lo mismo, a veces tres, sin documentación sobre cuál es la actual. Algunos experimentos abandonados se quedan en el árbol porque eliminarlos se siente más arriesgado que dejarlos.

Este documento es un recorrido por esas capas. Leerlo debe reducir la posibilidad de que “arregle” algo que fue intencionalmente saltado, o “complete” una feature que fue abandonada por una razón que no puede ver solo desde el código.

Cada sección sigue la misma forma:

  • El patrón — qué hay en el código.
  • Lo que sugiere — la historia más probable.
  • Lo que esto significa para usted — guía concreta para nuevos contribuyentes.

1. La cadena de eventos de facturación abandonada

El patrón. Existen cuatro archivos para un ciclo de vida de Ventas dirigido por eventos que nunca se usa:

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

grep -rn "VentaCompletada::dispatch\|event(new VentaCompletada\|VentaCancelada::dispatch\|event(new VentaCancelada" app/ devuelve cero coincidencias. Nada los dispatcha. La ruta activa de Ventas es:

crearVenta (sync) → ProcesarPagareDigital (queued, after Certicámara webhook) → GenerarCreditoDeVenta (queued)

— invocación directa de método/job, sin eventos.

Lo que sugiere. La arquitectura inicialmente planificó un ciclo de vida de Ventas dirigido por eventos. VentaCompletada se dispararía después de que la Venta se cerrara y un listener manejaría los efectos secundarios (decrementar Cupo, decrementar inventario, generar plan de crédito). En algún punto del camino el equipo (o el desarrollador en solitario) decidió que el dispatch directo era más limpio — más fácil de testear, más fácil de leer en un stack trace, sin problemas de ordenamiento implícito entre listeners. El scaffolding de eventos se dejó en su lugar porque eliminarlo se sintió como trabajo extra que no estaba bloqueando nada.

El cuerpo de VentaCompletadaListener confirma esto. Realiza trabajo que el flujo actual ahora hace inline: el decremento de Cupo ocurre en GenerarCreditoDeVenta, el decremento de inventario ocurre dentro de VentaService::crearVentaUnica (y de nuevo en la rama de incremento de rechazarVenta). Si alguien dispatchara VentaCompletada hoy, esos efectos secundarios se ejecutarían una segunda vez — doble decremento de Cupo e inventario.

VentaCanceladaListener es más dramático. Importa una clase que ya no existe (App\Enum\Facturacion\Estado) y referencia un caso (Estado::CANCELADA) que nunca estuvo en el enum EstadoVenta en vivo. En el momento que alguien dispare el evento, el listener crashea.

Lo que esto significa para usted. No “complete” esta cadena de eventos agregando llamadas de dispatch. Si quiere semánticas dirigidas por eventos de vuelta, primero necesita eliminar los efectos secundarios inline de crearVentaUnica y GenerarCreditoDeVenta, luego actualizar los cuerpos de los listeners para coincidir con el enum actual, y luego dispatchar. Eso es un refactor del orden de un sprint, no una corrección de una línea. Vea las minas L-32 y L-33 en 01-known-bugs.md para la descripción completa del peligro.


2. PlanCredito — la feature construida a la mitad

El patrón. La capa de plan de crédito tiene scaffolding pero sin conexión en vivo:

  • app/Services/PlanCreditoService.php — servicio completo con obtenerPlanesCredito, crearPlanCredito, actualizar, registrarPagoCuota y más.
  • app/Models/Facturacion/PlanCredito.php — el modelo.
  • app/Enum/Facturacion/EstadoPlanCredito.php — el enum.
  • app/DTOs/PlanCredito/CrearPlanCreditoDTO.php, ActualizarPlanCreditoDTO.php — DTOs.
  • app/DTOs/Cuota/RegistrarPagoCuotaDTO.php — DTO de pago-en-Cuota.
  • Una referencia a la tabla plan_creditos en el modelo (así que una migración corrió en algún punto).

Pero grep -rn "PlanCreditoService" app/Http/Controllers/ devuelve cero. Ningún controlador depende de él. Ninguna ruta lo expone. El TODO en VentaCompletadaListener:50 (// TODO: Generar plan de crédito con sus cuotas) es el único hook — y ese listener está él mismo muerto según la sección 1.

Lo que sugiere. Un PlanCredito estaba destinado a agregar las Cuotas del Cliente en un único plan pagable — probablemente coincidiendo con la estructura del core bancario SHIVAM Core Crédito (que piensa en planes, no en cuotas individuales). El flujo Cuota-directo se envió primero porque era más simple, y la capa PlanCredito fue generada con scaffolding para una fase que nunca comenzó.

Lo que esto significa para usted. Este no es código activo. Si se encuentra leyéndolo y pensando “debería conectar esto para que las Cuotas cuelguen de un PlanCredito”, hable con producto primero. O complételo con un PRD o quítelo. El camino del medio — conectarlo parcialmente, agregar una ruta, dejar el resto — es la opción más cara, porque el siguiente desarrollador heredará una feature que está parcialmente viva y parcialmente muerta sin documentación. Vea L-34.


3. Spatie Permission — instalado, no forzado

El patrón. spatie/laravel-permission está en composer.json. Los aliases están registrados en bootstrap/app.php:30-33:

$middleware->alias(aliases: [
'role' => RoleMiddleware::class,
'permission' => PermissionMiddleware::class,
'role_or_permission' => RoleOrPermissionMiddleware::class,
// ...
]);

User (y probablemente otros modelos) usan el trait HasRoles. Las migraciones de Spatie están presentes (tablas roles, permissions, model_has_*).

Pero la autorización en runtime en este codebase es vía separación de guards, no vía verificaciones de permisos:

  • guard web → Clientes.
  • guard app → Aliados (subdivididos en roles como administrador, administrador_comercial, administrador_financiero, aliado, Empleado — verificados por lógica artesanal if ($user->rol === 'administrador') en algunos controladores).

grep -rn "->can(\|@can(\|hasRole\|hasPermission" app/Http/Controllers/ encuentra muy pocos usos, y tienden a leer una columna rol en el User en lugar de un registro de permiso de Spatie.

Lo que sugiere. Spatie Permission se instaló temprano — probablemente durante el mismo paso de scaffolding que agregó Laravel Breeze. El equipo pretendía usarlo. A medida que la presión de features aumentó, las verificaciones de roles se escribieron inline como if ($user->rol === 'X') — más rápido de escribir, inmediatamente visible en el controlador. Las tablas de Spatie nunca se poblaron significativamente.

La migración add_rol_to_users_table (fechada 2026-01-11) confirma esto. Agregar una columna rol a users solo tiene sentido si no se va a usar el pivot de Spatie.

Lo que esto significa para usted. Si agrega una nueva verificación basada en rol, no escriba $user->hasPermissionTo('X') y espere que se aplique — las filas de permiso probablemente no existen. Use el mismo patrón if ($user->rol === '...') que el resto de los controladores, o invierta en conectar realmente Spatie (sembrar roles + permisos, reemplazar las verificaciones inline, agregar middleware permission: a las rutas). El camino del medio es de nuevo el peor camino.


4. División de timestamps español / inglés

El patrón. El modelo base app/Models/Modelo.php:

class Modelo extends Model
{
const CREATED_AT = 'creado_en';
const UPDATED_AT = 'actualizado_en';
}

La mayoría de los modelos extienden Modelo en lugar del Model por defecto. Sus columnas de base de datos son creado_en y actualizado_en.

Pero esparcido a través de servicios, jobs y controladores, el código aún lee $model->created_at:

  • 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());

$venta->created_at no lee la columna creado_en. Lee una propiedad inexistente, obtiene null y el fallback ?? now() se dispara. La aritmética de fechas se basa entonces en now(), no en el tiempo de creación real de la Venta.

Lo que sugiere. El naming en español fue una elección intencional (el README, las migraciones, los nombres de rutas, los términos de dominio son todos en español). La base Modelo se agregó para unir los defaults en inglés de Eloquent a los nombres de columnas en español. Pero el puente solo funciona si cada lector usa el puente — es decir, llama a $model->creado_en o se basa en la magia de created_at de Eloquent que el remapping constante se supone que provee.

El remapping constante funciona para las internals propias de Eloquent (como Model::$timestamps = true sabiendo qué columna actualizar). No reescribe retroactivamente el código escrito a mano que referencia $model->created_at como una propiedad. Esas lecturas aún buscan la propiedad inexistente.

Este es el tipo de error que nunca harías en un proyecto que comenzó español-primero. Es el tipo de error que es casi inevitable en un proyecto que comenzó con defaults en inglés y se españolizó a mitad de camino.

Lo que esto significa para usted. Cuando escriba código que lea timestamps de un modelo:

  • Columnas en español (cuando extiende Modelo): use $model->creado_en / $model->actualizado_en. Siempre.
  • Columnas en inglés (cuando extiende Model plano, ej., de terceros): use $model->created_at.
  • Nunca asuma que Eloquent traduce automáticamente — no lo hace.

Agregue un feature test para cualquier nuevo código que haga aritmética de fechas desde un timestamp de modelo; asserte contra una línea base conocida y correcta. Vea L-11.


5. La tríada de logger respaldada por DB

El patrón. Tres destinos de logger personalizados escriben a tablas MySQL, más el canal por defecto de Laravel:

  1. Tabla logs — escrita por el canal de log database de Laravel (configurado en config/logging.php).
  2. Tabla backend_request_logs — escrita vía Log::channel('database_backend_request') (referenciada en bootstrap/app.php:82). La migración 2026_02_06_100000_create_backend_request_logs_table.php es reciente.
  3. Tabla frontend_error_logs — poblada vía POST /api/error-logs desde el interceptor de axios del frontend. La migración 2026_02_07_002652_create_frontend_error_logs_table.php es reciente.
  4. Canal single por defecto de Laravel — escribe a storage/logs/laravel.log en disco.

Más, en config/logging.php:

  • Una definición de canal slack con env SLACK_WEBHOOK_URL (vacío en .env.example).
  • Una definición de canal papertrail con PAPERTRAIL_URL / PAPERTRAIL_PORT (también vacíos).

Lo que sugiere. Un empuje reciente (principios de 2026) para capturar más datos operacionales. Los logs de peticiones de backend y los logs de errores de frontend fueron agregados con un día de diferencia. La motivación probablemente fue la necesidad de ver qué estaba pasando en producción sin tener acceso SSH — almacenar todo en MySQL y luego exponerlo vía opcodesio/log-viewer (montado en /aliados/log-viewer).

Las definiciones de Slack y Papertrail son sobrantes de un template de Laravel. Nadie llenó los URLs porque no se configuró ningún workspace de Slack / cuenta de Papertrail.

Lo que esto significa para usted. El logging está fragmentado y demasiado acoplado a MySQL (ver sección 12). Antes de extenderlo:

  • Decida si el nuevo volumen de logs va a archivo, DB o externo (Sentry).
  • No agregue un cuarto destino de logger sin primero consolidar.
  • Esté consciente de la contaminación de frontend_error_logs — vea L-26. Cada llamada axios exitosa se loggea actualmente. Corrija eso antes de confiar en la tabla para cualquier cosa.
  • Lea bootstrap/app.php:50-83 — el handler global de excepciones hace mucha captura de contexto (headers, body, query, IP, user). Algo de eso es PII por Habeas Data (ver F-PII-4 del doc 17). No extienda ciegamente el mismo patrón.

6. Redis configurado pero sin usar

El patrón. composer.json incluye predis/predis. El .env.example tiene:

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

Pero todo lo que podría ser Redis es database:

SESSION_DRIVER=database
QUEUE_CONNECTION=database
CACHE_STORE=database

grep -rn "Redis::" app/ devuelve cero. Ningún código usa Redis directamente.

Lo que sugiere. Redis fue planeado. O bien el dev que generó el scaffolding del proyecto lo hizo contra el template “cómo debería verse esto en producción” de Laravel, o alguien a mitad de la construcción decidió que sesiones + cache + queue deberían moverse a Redis “el próximo sprint”. Ese sprint nunca llegó. La config de Redis es un fósil del plan no realizado.

Lo que esto significa para usted. Redis está disponible infraestructuralmente (las variables de entorno sugieren que el entorno de producción lo tiene aprovisionado), pero mover cualquiera de los cuatro backends a Redis es un cambio coordinado:

  • Cache → Redis: bajo riesgo; la cache es efímera.
  • Sesiones → Redis: riesgo medio; los usuarios pueden necesitar volver a loguearse en el cutover.
  • Queue → Redis: riesgo más alto; los jobs en vuelo deben drenar primero.
  • Canales de log personalizados → Redis pub/sub: no es un patrón Laravel-default; necesita diseño.

Vea L-21 para el encuadre más amplio “un único MySQL es punto único de fallo”.


7. El bucle de retroalimentación del logger de errores del frontend

El patrón. resources/js/plugins/errorInterceptor.ts:26-39 instala un interceptor de respuesta de axios:

instance.interceptors.response.use(
async (response) => {
if (response.config.url && !response.config.url.includes('/api/error-logs')) {
await errorLogger.logSuccess({
url: response.config.url,
method: response.config.method?.toUpperCase(),
statusCode: response.status,
responseData: response.data,
trackingId: response.config.metadata?.trackingId,
});
}
return response;
},
async (error) => { /* error path */ }
);

Esto loggea cada respuesta exitosa de axios — URL completa, código de estado, cuerpo de respuesta — a /api/error-logs. El endpoint es público y sin throttle (routes/api.php:20-21). El cuerpo de respuesta se persiste en frontend_error_logs en MySQL.

Lo que sugiere. El interceptor se agregó para una sesión de debugging — “quiero ver qué está obteniendo realmente el frontend del backend”. Se hizo commit y no se eliminó. La intención fue probablemente un diagnóstico a corto plazo, no una feature permanente.

El resultado es un bucle de retroalimentación: cada carga de página dispara N llamadas axios; cada llamada escribe una fila a la tabla; la tabla crece; la DB crece; cargar el log-viewer para investigar algo se vuelve más lento porque la tabla está enterrada en ruido.

Lo que esto significa para usted. Este es el ejemplo canónico de “lo agregué para debugging y olvidé”. Antes de agregar un interceptor propio, escriba qué dispara su eliminación. Vea L-26.


8. Convenciones de handlers de webhooks

El patrón. app/Http/Controllers/Webhooks/ contiene unos pocos puntos de entrada de webhook. El crítico es Certicámara:

  • POST /api/v1/webhooks/certicamaraCerticamaraController::__invoke

grep -n "Log::info" app/Http/Controllers/Webhooks/CerticamaraController.php muestra que el controlador hace Log::info('Notificación recibida de Certicámara', $request->all()) cerca del comienzo — loggeando el cuerpo completo de la petición, en texto plano.

El doc 17 (F-005, F-PII-3) confirma que no hay verificación de firma HMAC, sin allowlist de IP, sin nonce de replay, y el cuerpo de la petición (que incluye metadata biométrica y la URL del PDF firmado) se loggea en texto plano tanto al canal de archivo como al canal de base de datos.

Lo que sugiere. Los webhooks fueron agregados bajo presión de tiempo. Certicámara es el crítico porque es cómo el evento de firma del Pagaré alcanza la aplicación — sin él, ninguna compra cierra. La implementación más simple (aceptar POST, loggearlo, dispatchar el job) fue lo suficientemente buena para enviar. La verificación de firma fue diferida.

Lo que esto significa para usted. Los handlers de webhooks son lo siguiente que hay que endurecer. Específicamente:

  • Agregar verificación HMAC usando el secreto compartido del socio (Certicámara, EMCALI, cualquier otro).
  • Agregar allowlisting de IP vía middleware donde el socio dé un bloque de IP estable.
  • Agregar idempotencia: almacenar (provider, event_id) en una tabla webhook_events; rechazar duplicados.
  • Limpiar PII del log antes de persistir.
  • Mover el cuerpo a un objeto tipado (WebhookRequest) en lugar de $request->all().

Este es trabajo significativo. Hasta entonces, trate los endpoints de webhooks como APIs públicas no confiables y verifique todo en el interior.


9. El patrón “ApiExceptionHandler return faltante”

El patrón. En bootstrap/app.php:48-99, el closure del handler global de excepciones tiene múltiples ramas pero el valor de retorno del handler personalizado específico de API nunca se usa:

$exceptions->respond(function(Response $response, Throwable $exception, Request $request) {
// ...
if (array_key_exists($className, $handlers)) {
$method = $handlers[$className];
$apiHandler = new \App\Exceptions\ApiExceptionHandler();
$apiHandler->$method($exception, $request); // <-- side effect only; return value discarded
} else { /* ... */ }
// ... other branches ...
return $response;
});

El handler personalizado se ejecuta (loggea, construye un Response), pero el closure devuelve el $response original del framework, no el personalizado.

Lo que sugiere. Laravel 11 movió el manejo de excepciones fuera de app/Exceptions/Handler.php a bootstrap/app.php. La nueva API withExceptions(...) difiere en formas sutiles del viejo Handler::render. El traductor (quien migró este codebase a Laravel 11/12) entendió la nueva forma pero pasó por alto que el callback respond devuelve la respuesta — llamar al handler personalizado solo por sus efectos secundarios no reemplaza realmente la respuesta.

Este es el tipo de bug que un code review cuidadoso atraparía inmediatamente, y que un desarrollador en solitario con una fecha límite envía sin atrapar.

Lo que esto significa para usted. Toda la configuración de bootstrap/app.php merece una re-lectura cuidadosa — no solo el bloque respond, sino también los aliases de middleware, la lista de excepciones de encryptCookies (que actualmente es ['appearance', 'sidebar_state']) y la configuración de ruta de health. Acechan aquí desajustes sutiles de versión de Laravel. Vea L-08.


10. Rutas con métodos de controlador fantasma

El patrón. Algunos registros de Route::apiResource(...) generan rutas para verbos que el controlador no implementa.

Ejemplo: routes/web.php:32:

Route::apiResource('lineas', LineaController::class)->names('lineas');

Esto genera index, show, store, update, destroy. LineaController solo implementa index y show. Una llamada a POST /lineas pega a un controlador que no tiene un método store → Laravel levanta un 500.

Lo mismo es cierto para algunas rutas en el portal de Aliado — vea F-TEN-16 del doc 17 (“ruta ventasPorUsuario apunta a método que no existe”).

Lo que sugiere. apiResource fue el atajo conveniente. El desarrollador planeó implementar los métodos faltantes. Otras prioridades surgieron. Las rutas quedan registradas, listas para crashear.

Lo que esto significa para usted. Inventario de rutas ≠ inventario funcional. La existencia de una ruta no garantiza que funcione. Cuando audite la superficie API, haga cross-check contra los métodos del controlador. Cuando llame a una ruta desde un script o test, confirme que está realmente en vivo.

Para las rutas de escritura fantasma, la corrección correcta es ->only(['index', 'show']) para restringir el registro del recurso. Vea L-03, L-31.


11. La arquitectura “todo en MySQL”

El patrón. Como se cubrió en la sección 6, MySQL es el único servicio con estado:

  • Cache.
  • Sesiones.
  • Queue.
  • Tres tablas de logger personalizadas.
  • Todos los datos de negocio.
  • Más el log de archivo por defecto de Laravel en disco (redundante con la tabla logs).

No hay uso de Redis, sin servicio de logging separado, sin capa de cache separada, sin pool de workers de queue separado.

Lo que sugiere. Una filosofía de despliegue de servicios mínimos. Conseguir un MySQL, un grupo de procesos PHP y enviar. Esta es la elección correcta para un MVP — minimiza la complejidad operacional y el costo. Es la elección incorrecta para una fintech preparándose para escalar.

Lo que esto significa para usted. Esta es L-21 en 01-known-bugs.md. Trate cualquier cambio que agregue carga a MySQL con sospecha. Los nuevos jobs de queue agregan contención en la tabla jobs. Las nuevas escrituras de cache agregan churn en la tabla cache. Las nuevas features de sesión agregan lecturas en la tabla sessions en cada petición.

Si está por agregar una escritura o lectura de muy alta frecuencia (ej., analytics por carga de página), no la ponga en MySQL. Planee un destino separado (Redis, ClickHouse, un servicio externo) desde el día uno.


12. Pistas del historial de migraciones

El patrón. Leer las fechas y títulos de las migraciones le dice en qué estaba trabajando el equipo, cuándo:

  • Septiembre–Noviembre 2025 — configuración inicial del esquema; tablas fundacionales (users, empresas, productos, ventas, etc.).
  • 22 Noviembre – 3 Diciembre 2025 — refinamiento del esquema: alter_empresas_table, alter_sucursales_table (dos veces), alter_clientes_table. El equipo aún estaba encontrando la forma correcta para las entidades del lado del socio.
  • 29 Diciembre 2025 — múltiples migraciones alter en el mismo día: clientes, venta_detalles, ventas, productos. Sprint de fin de año ajustando el flujo de Ventas.
  • Enero 2026 — sucesión rápida: alter_cuotas_table (5 enero), alter_ventas_table (8 enero), add_es_indeterminado_to_ventas_table (9 enero), add_rol_to_users_table (11 enero), add_causal_to_postulacion_aliados_table (14 enero), alter_clientes_table (21 enero), add_rechazada_and_abandonada_to_orden_compras_estado (24 enero), add_puede_intentar_firmar_pagare_en_to_clientes_table (24 enero).
  • Febrero 2026create_backend_request_logs_table (6 feb), create_frontend_error_logs_table (7 feb). El empuje de logging.

Lo que sugiere. El esquema está aún evolucionando. Enero 2026 fue particularmente ocupado — el equipo agregó rol a users (el sistema de roles artesanal de la sección 3), expandió la máquina de estados de orden-compra (las adiciones de rechazada / abandonada que el frontend no ha alcanzado — vea L-20), y agregó el timestamp “puede el Cliente intentar firmar el Pagaré de nuevo” (la verificación de prevención de abuso).

Cada migración alter_* es una pequeña pista. Cuando ve alter_clientes_table tres veces en tres meses, espere que el layout de la tabla clientes sea reciente y pueda tener features (columnas, índices) que el código circundante aún no usa completamente.

Lo que esto significa para usted. Lea las migraciones recientes antes de extender cualquier modelo. El $fillable y $casts del modelo deben coincidir — pero el comportamiento alrededor de la columna (qué controladores la leen, qué jobs la escriben) puede no estar completamente conectado aún.

Cuando escriba una nueva migración, intente consolidar. Tres alter_clientes_table en fila es una señal de que el siguiente cambio debe ser un pase de diseño cuidadoso, no otro alter rápido.


13. La presencia de dos APIs de Ventas

El patrón. El repositorio tiene tres docs Markdown en docs/:

  • docs/API_VENTAS_ALIADO.md — flujo de Venta del portal de Aliado (un Aliado crea una Venta contra un Cliente).
  • docs/API_VENTAS_MARKET.md — flujo de compra del marketplace del Cliente.
  • docs/API_ORDENES_COMPRA.md — la capa de orden.

Existen dos controladores: app/Http/Controllers/Aliado/VentaController.php y app/Http/Controllers/Market/VentaController.php. Ambos crean Ventas, ambos pasan por VentaService::crearVenta, pero tienen formas de petición diferentes, validación diferente y bugs diferentes.

El flujo de Aliado usa crearVentaUnica (Empresa única, la Sucursal del Aliado). El flujo de Cliente usa crearVentasPorEmpresa (multi-Empresa, agrupando por Empresa del Producto).

Lo que sugiere. Se construyeron dos canales distintos de compra. El marketplace del Cliente permite un Carrito con Productos de múltiples Aliados; el portal de Aliado es de un solo Aliado por definición. La capa de servicio compartida (VentaService::crearVenta) ramifica internamente basándose en si sucursal_id está presente.

Los bugs en procesarCarrito (vea L-15, L-16) solo afectan el flujo de Cliente. Los bugs en crearVentaUnica (vea L-09) afectan a ambos. Cuando lea un reporte de bug o un request de feature que diga “las Ventas están rotas”, la primera pregunta es cuál flujo de Ventas.

Lo que esto significa para usted. Cuando esté por agregar una feature a “Ventas”, determine primero cuál flujo. Los dos flujos comparten un servicio pero divergen en la capa de controlador y en la capa de generación de Cuotas (generarCuotas vs generarCuotasPorOrden). También tienen diferente cobertura de tests en tests/Feature/Market/ y tests/Feature/Aliado/.

El servicio compartido tiene ramas internas; tocar el servicio compartido toca ambos flujos. Tocar un solo controlador toca un flujo. Planee su cambio al nivel correcto.


14. La regla de aprobación “5 de 6 tipos de proceso”

El patrón. app/Services/AprobarCupoService.php:17-38:

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

docs/audit/12-credit-approval-workflow-diagram.md describe una secuencia de 7 pasos: verificación legal → validación de identidad (OTP) → validación de identidad (cuestionario) → validación HDC → umbral de score → aprobación. El diagrama implica que la secuencia importa.

El código no fuerza la secuencia. Cuenta tipos de proceso distintos cuyo evento más reciente este mes fue FINISH_SUCCESS, y aprueba en >= 5.

Lo que sugiere. El diagrama es el flujo pretendido. El código es el flujo enviado. Los dos divergieron ya sea porque:

  • El forzado de secuencia era difícil (la máquina de estados en HTTP es problemática), y la regla >= 5 aproximaba el comportamiento correcto para el camino común.
  • La lógica de aprobación se escribió antes de que el diagrama se finalizara, y el diagrama se dibujó contra una visión idealizada en lugar del código.

De cualquier manera, el nuevo equipo debe saber: el diagrama es aspiracional, el código es operacional.

Específicamente:

  • “Los 7 pasos en secuencia” no es lo que el código requiere.
  • “Dentro del mismo día calendario” no es lo que el código requiere — los eventos de antes en el mes cuentan.
  • “Ordenamiento estricto (OTP antes del cuestionario)” — no forzado en el código, aunque las rutas están divididas por paso (vea L-28).

Lo que esto significa para usted. Antes de construir una feature encima del flujo de aprobación de crédito (ej., “re-ejecutaremos la aprobación si el Cupo del Cliente expira”), lea el código real en AprobarCupoService, IdentityValidationService y HDCValidationService. No confíe en el diagrama del flujo de trabajo como una especificación — confíe en él como una lista de deseos.

Cuando corrija la divergencia (recomendado — vea L-30), alinee el diagrama, el código y la suite de tests al mismo tiempo, para que el siguiente desarrollador no herede un nuevo desajuste.


15. La capa DTO + Servicio

El patrón. Esto no es una mina — es un patrón bien hecho que no debe romper. El codebase consistentemente usa:

  • Controladores — delgados, validan la petición, construyen el DTO desde los datos validados, delegan a un servicio.
  • DTOs — objetos de valor tipados (29 servicios, ~39 DTOs).
  • Servicios — son dueños de la lógica de negocio, devuelven modelos o paginadores.

app/DTOs/Venta/CrearVentaDTO.php, app/Services/VentaService.php, app/Http/Controllers/Market/VentaController.php — el trío es consistente a través de la mayoría de los dominios.

Lo que sugiere. Quien configuró el scaffolding temprano se comprometió a una arquitectura limpia y se mantuvo en ella. Este es uno de los puntos de higiene más fuertes en el codebase.

Lo que esto significa para usted. Preserve el patrón. Cuando agregue un nuevo endpoint:

  1. Escriba un FormRequest o use $request->validate(...) inline en el controlador.
  2. Construya el DTO desde $request->validated().
  3. Pase el DTO a un método de servicio.
  4. El servicio devuelve un modelo / paginador / colección.
  5. El controlador envuelve el resultado en un JsonResponse o una respuesta de Inertia.

No ponga lógica de negocio en el controlador. No evite el DTO por “solo un campo extra”. Cuando vea un controlador haciendo más de 20-30 líneas de trabajo, esa es una señal de que el servicio debe absorber ese trabajo.


16. La brecha esquema-vs-eloquent en las relaciones

El patrón. La sección del doc 16 sobre el diagrama ER nota: “El documento mezcla relación de esquema con relación implementada en Eloquent. orden_compras -> cuotas existe a nivel FK, pero no está expuesto como relación Eloquent real.”

En la práctica: existen llaves foráneas a nivel de base de datos para relaciones que los modelos no exponen. Así que Cuota::ordenCompra() puede o no existir como un método de relación, aunque cuotas.orden_compra_id es una columna FK.

Lo que sugiere. Las migraciones y los modelos evolucionaron en diferentes líneas de tiempo. Agregar una FK en una migración es un movimiento rutinario de esquema; agregar el método de relación al modelo es un follow-up que se olvida. Lo inverso también puede suceder — un método de relación declarado en el modelo que no tiene una restricción FK respaldándolo.

Lo que esto significa para usted. Cuando haga eager-load a una relación (with('ordenCompra')), confirme que el método de relación existe en el modelo fuente. Si asume FK = relación, obtendrá una BadMethodCallException en runtime.

Cuando agregue un nuevo método de relación, agregue también la restricción FK vía migración. Cuando agregue una nueva FK en una migración, agregue también el método de relación en ambos lados.


17. La configuración sobrante de Slack / Papertrail

El patrón. config/logging.php define:

'slack' => [
'driver' => 'slack',
'url' => env('SLACK_WEBHOOK_URL'),
// ...
],
'papertrail' => [
'driver' => 'monolog',
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
// ...
],

Pero .env.example ni siquiera incluye SLACK_WEBHOOK_URL o LOG_PAPERTRAIL_HANDLER. Los canales están definidos pero sin configurar.

Lo que sugiere. Template de logging por defecto de Laravel. Nadie configuró Slack o Papertrail. Las definiciones quedan ahí porque eliminarlas se siente como un cambio innecesario.

Lo que esto significa para usted. No loggee a los canales slack o papertrail esperando que aterricen en algún lugar — silenciosamente se tragarán. Si realmente necesita alertas de Slack (recomendado; vea L-13, L-22), configure SLACK_WEBHOOK_URL, reinicie los workers, y escriba un test pequeño que dispare un log y assertee que el workspace recibió el ping.


18. La cadena de eventos de validation-log

El patrón. app/Events/ValidationLog.php existe y se dispatcha a través del flujo de aprobación de crédito. app/Listeners/StoreValidationLog.php escribe el evento a aprobar_cupo_eventos.

Esta es una de las pocas cadenas de eventos que está realmente conectada y activa. Vale la pena conocerla porque:

  • El listener escribe payloads JSON grandes a aprobar_cupo_eventos.contexto. Por doc 17 (F-PII-2, F-PII-10), esos payloads incluyen IDs de OTP, hallazgos de HDC, respuestas de verificación legal de TransUnion — PII.
  • El listener es síncrono (no encolado). Así que la petición HTTP de aprobación de crédito espera a que el listener haga commit. Si MySQL está lento, todo el flujo de aprobación está lento.

Lo que sugiere. Esta cadena de eventos fue diseñada para propósitos de audit trail (los reguladores quieren ver quién intentó qué cuándo). Se mantuvo síncrona porque la afirmación auditable es “este evento fue persistido antes de que decidiéramos aprobar”.

Lo que esto significa para usted. No ponga este listener en cola “por rendimiento” sin entender las implicaciones del audit trail. No saltee el evento con escrituras directas a la DB — el listener puede crecer responsabilidades adicionales (ej., hashing de compliance).

Sí recorte lo que se persiste. La columna contexto no debe contener respuestas crudas del buró o códigos OTP — debe contener resúmenes sanitizados.


19. La clase base Modelo está haciendo muy poco

El patrón. app/Models/Modelo.php es actualmente solo:

class Modelo extends Model
{
const CREATED_AT = 'creado_en';
const UPDATED_AT = 'actualizado_en';
}

Eso es todo. Sin casts, sin fillable, sin traits.

Lo que sugiere. La clase base se introdujo específicamente para remapear los timestamps al español. Se delimitó estrechamente a ese único trabajo y nunca creció. Como resultado, cada modelo concreto sigue redeclarando todo el boilerplate de Eloquent (fillable, casts, hidden, etc.).

Lo que esto significa para usted. Esta es una oportunidad, no un bug. Si los patrones se repiten a través de muchos modelos (ej., 'deleted_at' => 'datetime', o una convención FK creado_por user), la base Modelo es el lugar para consolidarlos. Pero antes de agregar cualquier cosa: confirme que cada modelo consumidor lo necesita. La clase base se aprovecha ampliamente; un cambio allí se propaga en todos lados.


20. Los seeders no son seguros para correr en producción

El patrón. database/seeders/EmpresaSeeder.php crea tres cuentas de admin con contraseñas hardcoded (Test1234.). database/seeders/ClienteAdministradorSeeder.php establece contraseñas iguales a empresa.identificacion (el NIT, un número conocido públicamente).

Por doc 17 (F-011), si estos seeders alguna vez se ejecutaron contra una base de datos de producción — o si una base de datos de producción se migró desde un entorno de staging donde se ejecutaron — hay cuentas administrativas con contraseñas triviales o públicas aún activas.

Lo que sugiere. Los seeders se escribieron para conveniencia de desarrollo local. No estaban protegidos por if (app()->environment('local')). Ejecutar php artisan db:seed en producción es una catástrofe de un comando.

Lo que esto significa para usted. Antes de desplegar cualquier cambio de seeder a producción:

  • Audite qué seeders se ejecutan en DatabaseSeeder.
  • Envuelva cualquier seeder que cree usuarios admin en una verificación de entorno.
  • Documente un “conjunto de seeders seguros para producción” (ej., solo los seeders para datos de referencia: catálogos de Líneas, catálogos de Marcas, códigos de país) y un “conjunto de seeders solo-dev” (usuarios de prueba, órdenes de prueba, Productos de prueba).

Por ahora: no ejecute php artisan db:seed en producción, punto. Agregue una verificación de CI que falle si db:seed aparece en cualquier paso de despliegue.


Notas finales

Un codebase le dice en qué pasaron tiempo sus desarrolladores. El codebase de Mi Plante le dice:

  • Se construyó para velocidad, por una persona, y funcionó.
  • El patrón que está bien hecho — capas servicio-DTO, naming de dominio en español, enums tipados — es lo suficientemente consistente para ser una fundación.
  • Los patrones que están a medio hacer — cadenas de eventos, autorización basada en roles, timestamps en español, monitoreo — son las costuras donde vive el siguiente bug.
  • Las elecciones de infraestructura — un único MySQL para todo, logging basado en archivo por defecto, sin Sentry, sin Docker — fueron pragmáticas para un MVP y peligrosas para una fintech en producción.

Lea este archivo junto a 01-known-bugs.md. Cuando encuentre una nueva capa o patrón que este documento no cubre, agregue una sección. Numérela secuencialmente. Enlace con minas en 01-known-bugs.md. El siguiente desarrollador se lo agradecerá.