Saltearse al contenido

Trazar una Petición

Este documento recorre una petición HTTP real desde el clic del usuario en el navegador hasta el pixel final en pantalla. Léelo con el código abierto en otra pestaña. El ejemplo principal es GET /usuario/cupo/legal-check — el punto de entrada del pipeline de aprobación de crédito. Toca autenticación, la cadena de middleware custom, un controller, un service, una API externa, la base de datos, y una respuesta Inertia/axios. Un segundo ejemplo más breve traza POST /api/v1/webhooks/certicamara para mostrar el camino API + webhook + queue.

Si solo lees una cosa, lee la sección 3 — la cadena de middleware — porque es la parte de Mi Plante que más sorprende a desarrolladores nuevos.


1. La ruta que estamos trazando

GET /usuario/cupo/legal-check

Este es el primer paso de la aprobación de crédito de 7 fases. El usuario se ha registrado, completado su perfil anclado a EMCALI, y clickeado “Iniciar validación” en el componente Vue ModalValidate. El frontend llama a axios.get('/usuario/cupo/legal-check') y espera de vuelta un envelope JSON.

Por qué esta ruta es un buen ejemplo:

  • Está protegida por auth.
  • Usa el mismo stack de middleware web que cualquier otra ruta autenticada de cliente (HandleInertiaRequests, HandleAppearance, etc.) — útil para entender qué se agrega a cada petición.
  • Tiene el middleware dedicado check_intentos_limite_diarios encima.
  • Llama a una API externa (TransUnion) a través de un macro HTTP.
  • Escribe a la base de datos vía un log de auditoría event-sourced.
  • Retorna JSON, no un render de página Inertia.

La definición real de la ruta está en routes/web.php:69:

Route::middleware('check_intentos_limite_diarios')->group(function () {
Route::get('legal-check', [AprobarCupoController::class, 'legalCheck'])
->name('user.aprobar-cupo.legal-check');
// ...
});

Ese grupo vive dentro de Route::prefix('/cupo') que está dentro de Route::prefix('usuario') que está dentro de Route::middleware('auth')->group(...). Entonces el stack completo de middleware para esta ruta es: el grupo global web + auth + check_intentos_limite_diarios.


2. La secuencia completa

sequenceDiagram
    actor User
    participant Vue as ModalValidate.vue
    participant AX as axios
    participant Idx as public/index.php
    participant App as bootstrap/app.php
    participant GW as Web group middleware<br/>(CSRF, session,<br/>HandleAppearance,<br/>HandleInertiaRequests)
    participant Auth as Authenticate
    participant Lim as CheckIntentosLimiteDiarios
    participant Ctrl as AprobarCupoController
    participant Svc as LegalCheckService
    participant TU as TransUnion API
    participant Evt as ValidationLog event<br/>+ StoreValidationLog
    participant DB as MySQL
    participant Browser

    User->>Vue: clicks "Iniciar validación"
    Vue->>AX: axios.get('/usuario/cupo/legal-check')
    AX->>Idx: HTTP/1.1 GET<br/>Cookie + X-XSRF-TOKEN<br/>X-Requested-With: XMLHttpRequest
    Idx->>App: bootstrap Laravel kernel<br/>register HTTP macros (transunion, ...)
    App->>GW: web group middleware
    GW->>GW: CSRF check (XSRF cookie matches header)
    GW->>GW: session decrypted, user loaded
    GW->>GW: HandleInertiaRequests::share() builds shared props<br/>(BUT this is a JSON axios call, not Inertia)
    GW->>Auth: forward
    Auth->>Auth: check guard 'web' session
    Auth-->>GW: user authenticated, continue
    GW->>Lim: forward
    Lim->>DB: SELECT COUNT(*) FROM aprobar_cupo_eventos<br/>WHERE cliente_id=? AND tipo_proceso='legal_check'<br/>AND evento='validation_start' AND creado_en >= today
    DB-->>Lim: count = N
    alt N > 2 attempts today
        Lim-->>Browser: 422 ValidationException
    end
    Lim->>Ctrl: forward
    Ctrl->>Ctrl: $user = auth()->user()->load(['cliente','persona'])
    Ctrl->>Svc: $legalCheckService->manejar($user)
    Svc->>Evt: event(ValidationLog START)
    Evt->>DB: INSERT aprobar_cupo_eventos (LEGAL_CHECK, START)
    Svc->>TU: Http::transunion()->post('/legalcheck/consulta', dni)
    TU-->>Svc: {nombre, estadoDocumento, data:[]}
    Svc->>Svc: similar_text(nombre user, nombre TU) >= 70
    Svc->>Svc: estadoDocumento === 'VIGENTE'
    Svc->>Svc: no legal findings
    Svc-->>Ctrl: LegalCheckResult (success or error)
    Ctrl->>Evt: event(ValidationLog FINISH_SUCCESS or FINISH_UNSUCCESS)
    Evt->>DB: INSERT aprobar_cupo_eventos (LEGAL_CHECK, FINISH_*)
    Ctrl-->>Browser: response()->json({message, data:null, success, error:null})
    Browser->>Vue: axios promise resolves
    Vue->>User: update UI, enable Phase 2

El resto de este documento recorre cada caja.


3. El navegador

resources/js/pages/ConsultarCupo/Index.vue monta el flujo ModalValidate. El composable orquesta las 7 fases como una máquina de estados secuencial en el lado cliente. Cada fase dispara un axios.get(...) o axios.post(...) y espera el envelope JSON.

La llamada relevante:

// pseudo-shape; the actual axios client is configured in resources/js/app.ts
await axios.get('/usuario/cupo/legal-check')

Axios está configurado globalmente con:

  • timeout: 300_000 (5 minutos — acomoda respuestas lentas de TransUnion)
  • withXSRFToken: true
  • withCredentials: true
  • headers: { 'X-Requested-With': 'XMLHttpRequest' }

El header X-Requested-With es lo que le dice a Laravel que trate esto como una petición JSON (Request::expectsJson() retorna true). Esto importa cuando se lanzan excepciones — Laravel retornará JSON en lugar de un redirect HTML.

Un trackingId también se adjunta vía un interceptor de petición (resources/js/plugins/errorHandling.ts); el backend lo loguea para trazado transversal.

La protección CSRF está basada en cookies. El middleware de sesión establece una cookie XSRF-TOKEN; axios la lee (gracias a withXSRFToken: true) y la copia al header de petición X-XSRF-TOKEN. El middleware CSRF (un default del grupo web de Laravel) compara ambos.

Lo que no sucede: porque esto es una llamada JSON de axios y no una visita Inertia, no hay header X-Inertia. La respuesta será JSON plano, no un partial de página Inertia. Discutiremos la distinción en la sección 11.


4. El cable

La petición HTTP sale del navegador con esta apariencia aproximada:

GET /usuario/cupo/legal-check HTTP/1.1
Host: miplante.local
Cookie: laravel_session=...; XSRF-TOKEN=...
X-XSRF-TOKEN: <same token, URL-decoded>
X-Requested-With: XMLHttpRequest
Accept: application/json, text/plain, */*
Referer: https://miplante.local/usuario/perfil

Sin body — es un GET.

La cookie de sesión lleva la identidad de auth. El par XSRF lleva la protección CSRF. No hay header Authorization — Mi Plante no usa tokens bearer para flujos del cliente.


5. public/index.php y bootstrap/app.php

public/index.php es el front controller de Laravel. Define LARAVEL_START, requiere el autoloader de Composer, requiere bootstrap/app.php (que retorna la instancia configurada de Application), y llama a $app->handleRequest(Request::capture()).

bootstrap/app.php es donde Mi Plante hace su única personalización del kernel. El archivo es corto — 99 líneas — y vale la pena memorizarlo.

Lo que configura:

  1. Archivos de routing: routes/web.php, routes/api.php, routes/console.php. El endpoint de health /up se registra automáticamente. Los archivos routes/ally/web.php y routes/ally/auth.php se cargan desde routes/web.php haciendo require __DIR__ . '/ally/web.php' etc.

  2. Aliases de middleware: líneas 29-38 registran los ocho aliases de nombre corto:

    $middleware->alias([
    'auth' => Authenticate::class,
    'role' => RoleMiddleware::class,
    'permission' => PermissionMiddleware::class,
    'role_or_permission' => RoleOrPermissionMiddleware::class,
    'cliente_registro_completo' => EnsureClienteRegistroCompleto::class,
    'consultar_cupo_cliente' => ConsultarCupoDelCliente::class,
    'verificar_cliente_presenta_mora' => VerificarClientePresentaMora::class,
    'check_intentos_limite_diarios' => CheckIntentosLimiteDiarios::class,
    ]);
  3. Cookies exentas de encripción: appearance, sidebar_state. Tienen que ser leíbles por JavaScript sin desencriptación.

  4. Adiciones al grupo web: líneas 42-46 agregan tres middleware al grupo global web:

    $middleware->web(append: [
    HandleAppearance::class,
    HandleInertiaRequests::class,
    AddLinkHeadersForPreloadedAssets::class,
    ]);

    Estos corren en cada petición web — incluida esta. No corren en peticiones api. Esta distinción confunde a devs nuevos (y el doc 14 original de la auditoría los mezcló). Las rutas API tienen un stack de middleware más liviano: rate limiting + substituteBindings, nada más.

  5. Manejo de excepciones: líneas 48-98 configuran un callback respond() custom. Aquí vive el bug de ApiExceptionHandler.

    Líneas 55-58:

    if (array_key_exists($className, $handlers)) {
    $method = $handlers[$className];
    $apiHandler = new \App\Exceptions\ApiExceptionHandler();
    $apiHandler->$method($exception, $request); // <-- result discarded
    } else { ... }

    La intención era: pasar la excepción a un método handler específico, y retornar su respuesta JSON estructurada. Pero no hay return antes de $apiHandler->$method(...). El método corre, construye su envelope JSON, y el resultado se descarta. En la línea 97 se retorna la respuesta original del framework. Por lo tanto:

    • ValidationException → estándar Laravel 422 con {errors: {...}, message: '...'} (no la forma de ApiExceptionHandler).
    • NotFoundHttpException → estándar Laravel 404 page o {message: 'Not Found'} para peticiones JSON.
    • AuthenticationException → 401 / redirect vía el middleware Authenticate (este está bien porque el middleware maneja su propio redirect antes de que la excepción suba).

    Hasta que esto se arregle, no le prometas al frontend un envelope estructurado {data, success, error} para casos de error. Nunca llegará. El código del frontend en resources/js/plugins/errorHandling.ts lo trabaja defensivamente.

AppServiceProvider se carga después (durante el boot del kernel). Su método register() llama Http::macro('transunion', ...), Http::macro('datacredito', ...), Http::macro('certicamara', ...), etc. — cinco macros preconfigurados con base URL, headers de auth, y un timeout de 300 segundos. El LegalCheckService usará Http::transunion()->post(...) más tarde.


6. Resolución de ruta

El router de Laravel recorre las rutas registradas para el grupo web. La coincidencia ocurre en routes/web.php líneas 55-84:

Route::middleware('auth')->group(function () {
Route::prefix('usuario')->group(function () {
// ...
Route::prefix('/cupo')->group(function () {
Route::get('verificar-limite-intentos', ...)->name('user.aprobar-cupo.verificar-limite-intentos');
Route::middleware('check_intentos_limite_diarios')->group(function () {
Route::get('legal-check', [AprobarCupoController::class, 'legalCheck'])
->name('user.aprobar-cupo.legal-check');
// ...
});
// ...
});
});
});

Después del match, Laravel calcula la lista de middleware para la ruta:

  1. Middleware web global (de bootstrap/app.php y defaults de Laravel):

    • Illuminate\Cookie\Middleware\EncryptCookies
    • Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse
    • Illuminate\Session\Middleware\StartSession
    • Illuminate\View\Middleware\ShareErrorsFromSession
    • Illuminate\Foundation\Http\Middleware\VerifyCsrfToken
    • Illuminate\Routing\Middleware\SubstituteBindings
    • App\Http\Middleware\HandleAppearance (custom — establece cookie/share de apariencia)
    • App\Http\Middleware\HandleInertiaRequests (custom — shared props)
    • Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets
  2. Middleware del grupo de ruta: auth, check_intentos_limite_diarios.

Corren en ese orden. Recorramos la cadena caja por caja.


7. La cadena de middleware para rutas /aprobar-cupo/*

7.1 EncryptCookies (built-in)

Desencripta las cookies laravel_session y XSRF-TOKEN. Exime appearance y sidebar_state (por bootstrap/app.php:40).

7.2 StartSession (built-in)

Carga la sesión del driver configurado (file, redis, o database — ver config/session.php). En este punto $request->session() es usable.

7.3 ShareErrorsFromSession + VerifyCsrfToken + SubstituteBindings (built-in)

Laravel estándar. CSRF verifica la cookie vs. el header X-XSRF-TOKEN. SubstituteBindings resuelve route model binding (no se usa aquí — la ruta no tiene parámetro {model}).

7.4 App\Http\Middleware\HandleAppearance

Lee la cookie appearance y la comparte como variable de vista. Barato.

7.5 App\Http\Middleware\HandleInertiaRequests

app/Http/Middleware/HandleInertiaRequests.php extiende Inertia\Middleware. Su método share() (líneas 44-115) corre en cada petición web — incluso para llamadas JSON-only de axios como esta. Los props ensamblados estarán disponibles si el controller decide retornar Inertia::render(...), pero aquí terminan sin usarse porque retornamos response()->json(...).

Qué hace share():

  • Llama a getLineas(), getBrandsAllied(), getPopularCategories(), cada uno cacheado vía Cache::remember’d por 3600s.
  • Detecta si el usuario pertenece a una Empresa (aliado) o no (cliente). Se ramifica:
    • Aliado: carga empresa con lineas.
    • Cliente: carga clienteData (el modelo Cliente) y carritoData (el array curado del carrito — ver getCarrito() en la línea 163).
  • Agrega auth.user, auth.role, ziggy (rutas nombradas para el frontend), bloque de configuración credito, bloque de configuración links, etc.

Por qué esto importa incluso para llamadas axios: porque HandleInertiaRequests está en el grupo web, corre en cada GET autenticado, y las consultas que hace (lineas, brands, popular_categories, user stats, cart) suceden en cada petición. Las llamadas Cache::remember protegen contra volver a ejecutar las consultas subyacentes — ver config('app.cache_global_ttl') (default 3600) para el TTL.

Para nuestra llamada axios: esos valores cacheados se calculan y se adjuntan al bag de shared-props del response, pero axios no los pide y el controller retorna response()->json(...), así que no se usan. El costo es un lookup Redis/file por clave de caché, sin SQL.

7.6 AddLinkHeadersForPreloadedAssets

Agrega hints HTTP/2 push para los assets de Vite. Costo despreciable.

7.7 authApp\Http\Middleware\Authenticate

app/Http/Middleware/Authenticate.php es el autenticador multi-guard custom. Lógica en líneas 75-88:

protected function authenticate($request, array $guards)
{
if (empty($guards)) {
$guards = [null];
}
foreach ($guards as $guard) {
if ($this->auth->guard($guard)->check()) {
return $this->auth->shouldUse($guard);
}
}
$this->unauthenticated($request, $guards);
}

Para nuestra ruta, $guards = [null], así que verifica el guard default (web). Si la sesión tiene un usuario logueado, $this->auth->shouldUse('web') hace que auth()->user() resuelva al cliente. Continuamos.

Si no está autenticado, unauthenticated() lanza AuthenticationException. Para un cliente (guard web), el target de redirect es route('home', ['login' => 'true']) — el storefront con el modal de login forzado abierto (líneas 110-114). Para una petición JSON de axios, el redirect de la excepción se ignora y Laravel retorna un 401 en su lugar.

7.8 check_intentos_limite_diariosApp\Http\Middleware\CheckIntentosLimiteDiarios

app/Http/Middleware/CheckIntentosLimiteDiarios.php:

public function handle(Request $request, Closure $next): Response
{
$user = auth()->user();
if ($user && $user->cliente) {
if ($this->aprobarCupoService->haSuperadoLimiteIntentosDiarios($user->cliente->id)) {
throw ValidationException::withMessages([
'error' => 'Ha superado el límite de intentos diarios para esta verificación.'
]);
}
}
return $next($request);
}

La lógica real está en app/Services/AprobarCupoService.php:46-59:

public function haSuperadoLimiteIntentosDiarios($clienteId): bool
{
$today = Carbon::now()->startOfDay();
$tomorrow = Carbon::now()->addDay()->startOfDay();
$intentosDia = AprobarCupoEvento::where('cliente_id', $clienteId)
->where('tipo_proceso', ProcessType::LEGAL_CHECK->value)
->where('evento', EventType::START->value)
->where('creado_en', '>=', $today)
->where('creado_en', '<', $tomorrow)
->count();
return $intentosDia > 2;
}

La regla: más de 2 eventos legal_check / validation_start hoy bloquean la petición. Así que el tercer intento en adelante dispara el 422.

Este es un contador pequeño pero importante. Se lee aquí; se escribe en LegalCheckService::manejar() unas líneas más adelante. Si alguna vez agregas una ruta que esquiva LegalCheckService pero aún corre validación de identidad, también esquivas este contador — ten cuidado.

7.9 Qué pasa si algún middleware redirige o aborta

  • Fallo de authAuthenticationException → para axios: 401. Para una visita Inertia: 302 a home con ?login=true.
  • Fallo de cliente_registro_completo → 302 a /usuario/perfil (ver EnsureClienteRegistroCompleto.php:30).
  • Fallo de verificar_cliente_presenta_mora → 302 a home con flash de alerta.
  • Fallo de check_intentos_limite_diarios → 422 ValidationException con el error en la clave error.

Estos redirects importan para Inertia: cuando una petición Inertia recibe un 302, el adaptador de Inertia lo sigue y renderiza la página destino. Cuando axios recibe un 302 redirect a una página HTML, la respuesta es un cuerpo HTML en axios — casi siempre un bug. Si ves errores “Unexpected token < in JSON” en el frontend, estás recibiendo un redirect HTML para una llamada axios.


8. El controller

app/Http/Controllers/AprobarCliente/AprobarCupoController.php::legalCheck() en la línea 40:

public function legalCheck()
{
$user = auth()->user()->load(['cliente', 'persona']);
$result = $this->legalCheckService->manejar($user);
$data = $result->getData();
if ($result->hasError()) {
event(new ValidationLog(
cliente: $user->cliente,
processType: ProcessType::LEGAL_CHECK,
eventType: EventType::FINISH_UNSUCCESS,
context: $result->toArray(),
));
} else {
event(new ValidationLog(
cliente: $user->cliente,
processType: ProcessType::LEGAL_CHECK,
eventType: EventType::FINISH_SUCCESS,
context: $result->toArray(),
));
}
// Log::info(...) — duplicates the response into the application log
return response()->json([
'message' => !empty($data['completado'])
? 'Información legal validado correctamente'
: 'Gracias por completar tu validación. ...',
'data' => null,
'success' => $data['completado'] ?? false,
'error' => null,
]);
}

Qué hace:

  1. Carga eagerly cliente y persona en el usuario autenticado. Requerido porque el service inspecciona nombres y números de documento.
  2. Delega a LegalCheckService::manejar($user) — todo el trabajo real está aquí.
  3. Al retornar, dispara un evento ValidationLog con el resultado. El listener StoreValidationLog lo maneja sincrónicamente y escribe una fila AprobarCupoEvento.
  4. Retorna un envelope JSON.

Este controller es tan delgado como los controllers de Mi Plante pueden ser — y ese es el objetivo. Si un controller en Mi Plante tiene más de 50 líneas de lógica de negocio, debería refactorizarse a un service.

Un detalle sutil: nota que el controller dispara FINISH_SUCCESS / FINISH_UNSUCCESS, pero el service dispara START. La división existe porque el evento START es un hecho sobre el intento de llamada (relevante para el límite diario), mientras que el evento FINISH codifica el resultado (relevante para la consulta de estado-de-aprobación). Ver LegalCheckService::manejar() y doc 12 sección 5 para el patrón completo de logging de eventos.


9. El service

app/Services/LegalCheckService::manejar(User $user) hace:

  1. Mapea el persona->tipo_dni del usuario al código de documento TransUnion (mapeo manejado por config).

  2. Dispara ValidationLog(LEGAL_CHECK, START) — este es el evento que el middleware de límite diario cuenta.

  3. Llama a TransUnion:

    $response = Http::transunion()->post('/ws/LegalcheckWSRest/legalcheck/consulta', [
    'tipoIdentificacion' => $tipoTU,
    'numeroIdentificacion' => $persona->dni,
    ]);

    El macro Http::transunion() (registrado en AppServiceProvider) preconfigura la base URL, las credenciales Basic Auth desde config('services.transunion.*'), el timeout (300s), y el content-type JSON.

  4. En fallo HTTP, dispara ValidationLog(LEGAL_CHECK, FINISH_UNSUCCESS) y lanza ValidationException.

  5. En éxito HTTP, parsea la respuesta:

    • Calcula similar_text($user->persona->nombres . ' ' . $user->persona->apellidos, $response['nombre']) — debe ser >= el threshold configurado (default 70%).
    • Verifica $response['estadoDocumento'] === 'VIGENTE'.
    • Itera $response['data'] para hallazgos en listas legales; cualquier finding === true es un fallo duro.
  6. Construye un DTO LegalCheckResult (App\DTOs\LegalCheck\LegalCheckResult) con completado=true y sin error, o completado=false y un mensaje de error para el usuario.

  7. Retorna el DTO.

El DTO usa ResultTrait (app/Traits/ResultTrait.php) que provee hasError(), getError(), getData(), toArray(). Todos los DTOs *Result en Mi Plante siguen este mismo patrón.


10. Llamadas externas

Esta ruta toca exactamente una API externa: TransUnion LegalCheck. El macro se define en app/Providers/AppServiceProvider.php y obtiene las credenciales desde config/services.phpservices.transunion.*.

Si TransUnion está lento, la petición toma hasta 300 segundos. El cliente axios está configurado con timeout: 300_000 precisamente por esto. Si TransUnion está caído:

  • Fallo HTTP → ValidationException desde el service. El evento FINISH_UNSUCCESS del controller nunca se dispara (el service lo dispara en fallo HTTP). La excepción burbujea. Laravel retorna un 422 con {errors: {error: [...]}}. El frontend muestra el toast de error.

Cosas que saber sobre las llamadas externas en Mi Plante en general:

  • Los cinco macros principales (transunion, expirianCrossCoreAuth, expirianCrossCore, datacredito, certicamara) se registran en AppServiceProvider::register(). Si un macro falta, obtienes un BadMethodCallException en el sitio de la llamada.
  • El macro de DataCrédito tiene un valor hardcoded de requestUUID — issue conocido (hallazgo #16 del doc 16). Si añades un nuevo sitio de llamada DataCrédito, genera un UUID fresco por petición.
  • Los macros HTTP actualmente no establecen políticas de retry. El service de DataCrédito tiene lógica manual de retry-on-401; nada más reintenta.

11. La escritura a base de datos

Después de que el controller retorna del service, dispara ValidationLog. El listener es App\Listeners\StoreValidationLog, que es síncrono (no implementa ShouldQueue). El listener llama a AprobarCupoService::crear(CrearCupoEventoDTO), que llama a AprobarCupoEvento::create([...]).

La fila que se escribe:

INSERT INTO aprobar_cupo_eventos (
id, -- UUID v4
cliente_id,
tipo_proceso, -- 'legal_check'
evento, -- 'validation_finish_successfull' or 'validation_finish_unsuccessfull'
contexto, -- JSON: serialized LegalCheckResult->toArray()
creado_en -- now()
) VALUES (...);

La columna contexto es un dump JSON del resultado completo del DTO toArray(). Este es el rastro de auditoría que soporta la decisión final de aprobación en la Fase 7. También puede persistir datos sensibles (DNI completo, nombres, dirección de TransUnion) — doc 09 marcó esto como un riesgo.

La columna creado_en usa la convención de naming en español porque AprobarCupoEvento extiende Modelo (la base custom) y usa la constante Modelo::CREATED_AT = 'creado_en'.


12. Construcción de la respuesta

El controller retorna response()->json([...]). Respuesta JSON estándar de Laravel, 200 OK, Content-Type: application/json.

La forma:

{
"message": "Información legal validado correctamente",
"data": null,
"success": true,
"error": null
}

Este es el envelope que cada endpoint de aprobación de crédito retorna. Es similar pero no idéntico al envelope que ApiExceptionHandler debía producir. El código del frontend que lo lee está en resources/js/pages/ConsultarCupo/ModalValidate.vue (o el composable equivalente) — lee response.data.success para decidir si avanzar a la siguiente fase.


13. Render en el frontend

Para una llamada axios:

  1. La promesa resuelve.
  2. El handler del componente desempaqueta response.data.
  3. La máquina de estados Vue avanza a la siguiente fase.
  4. El componente re-renderiza. No sucede una nueva petición HTTP hasta que el usuario clickee el botón del siguiente paso.

Para una visita Inertia (ej., navegando a /aliados/ventas/listado en su lugar), el camino diverge desde el paso 12:

  • El controller retorna Inertia::render('ally/ventas/Index', [...]).
  • El middleware Inertia lo convierte ya sea en una respuesta HTML completa (primera visita) o un partial JSON (visitas subsecuentes con header X-Inertia).
  • Los shared props de HandleInertiaRequests::share() se incluyen.
  • El adaptador cliente Inertia del navegador cambia el componente de página, re-ejecutando setup() en la nueva página.
  • Los layouts envuelven según lo definido por el defineOptions({ layout: AllyLayout }) de la página (o el layout auto-resuelto en resolvePageComponent).

De cualquier forma, el usuario ve una UI actualizada sin una recarga completa de página después de la primera visita. Este es el atractivo de Inertia: comportamiento SPA, routing renderizado por servidor.


14. Casos límite y modos de fallo

Qué sale malQué ve el usuarioQué debes saber
Sesión expirada401 (axios) o 302 a login (visita Inertia)El interceptor axios del frontend resources/js/plugins/errorHandling.ts maneja 401 disparando un redirect.
CSRF token mismatch (419)419 con mensaje customCaso especial en bootstrap/app.php:88-95. Para JSON: retorna {message: "CSRF token mismatch. Por favor recarga la página e intenta de nuevo."}. Para web: back()->with('message', 'The page has expired.').
3er intento hoy422 desde CheckIntentosLimiteDiariosEl contador se reinicia a medianoche. Hay un GET no bloqueante /usuario/cupo/verificar-limite-intentos que la UI puede llamar para verificar estado.
Cliente sin personaLegalCheckService lanzaLa mayoría de endpoints de crédito asumen que el usuario ya completó /usuario/completar-registro. El middleware cliente_registro_completo lo aplica en la mayoría de las rutas; legal-check no tiene ese middleware en la cadena. Así que un usuario podría teóricamente alcanzar este endpoint antes de completar el registro. Vale la pena revisar.
TransUnion timeout (>300s)422 ValidationExceptionEl service lanza. El evento START ya fue escrito. El usuario ve un error. Puede reintentar — cuenta contra el límite diario.
TransUnion retorna no-2xxEl service lanzaIgual que arriba.
Similitud de nombre < 70%200 con success: falseNo es una excepción. El usuario recibe un mensaje cortés de “no se pudo completar”. Se escribe el evento FINISH_UNSUCCESS.
Ya aprobado este mesEl endpoint aprobar tendrá éxito tempranovalidarSiElClienteTieneSuCupoAprobado() consulta el log de eventos; si >= 5 eventos exitosos este mes, retorna true sin rehacer TransUnion. Pero el usuario aún puede re-ejecutar los pasos y quemar su presupuesto de 2 intentos diarios.
Dispatch del evento ValidationLog fallaListener síncrono lanzaLa excepción se propaga. La ruta retorna un 500. El efecto secundario de TransUnion ya sucedió; la fila en aprobar_cupo_eventos NO sucedió. Drift de estado posible.
Usuario refresca a mitad del flujoVisita Inertia re-ejecuta HandleInertiaRequests::share()Shared props cacheados (lineas, brands) se sirven desde caché. clienteData es fresco desde DB. El carrito_{id} cacheado puede estar obsoleto si un cambio del carrito ocurrió justo antes — CarritoService hace Cache::forget() después de escrituras.

15. El segundo ejemplo: POST /api/v1/webhooks/certicamara

Esto recorre un camino fundamentalmente diferente. El webhook llega desde los servidores de Certicámara cuando un cliente firma (o bloquea/expira) un pagaré en su portal.

15.1 La ruta

routes/api.php:29:

Route::prefix('v1')->group(function () {
Route::prefix('webhooks')->group(function () {
Route::post('/certicamara', CerticamaraController::class)
->name('api.webhooks.certicamara');
});
});

Está en routes/api.php, no routes/web.php. Eso significa:

  • El grupo middleware api corre, no el grupo web. Sin HandleInertiaRequests, sin cookie de sesión, sin CSRF.
  • Authenticate no está en la cadena. El webhook no está autenticado.
  • Throttling (rate limiting) se aplica vía el throttle default del grupo api.

15.2 El controller

app/Http/Controllers/Webhooks/CerticamaraController.php es invocable (método único):

public function __invoke(Request $request)
{
Log::info('Notificación recibida de Certicámara', $request->all());
$uuid = $request->input('uuid');
$cliente = Cliente::where('certicamara_uuid', $uuid)->first();
if ($cliente === null) {
Log::warning('Cliente no encontrado con certicamara_uuid', ['uuid' => $uuid]);
return response()->json([
'message' => 'Cliente no encontrado',
'success' => false,
], 404);
}
ValidarPagareDigital::dispatch($cliente, $request->all())->onQueue('creditos');
return response()->json([
'message' => 'Notificación recibida correctamente',
'success' => true,
], 200);
}

El controller hace trabajo mínimo — lookup, dispatch, return. Siguiendo la mejor práctica de webhooks: responder rápido (200), hacer trabajo asíncrono.

15.3 La cadena asíncrona

sequenceDiagram
    participant Cert as Certicamara
    participant API as POST /api/v1/webhooks/certicamara
    participant Ctrl as CerticamaraController
    participant Q as Queue (creditos)
    participant VPJ as ValidarPagareDigital
    participant PPJ as ProcesarPagareDigital
    participant GCJ as GenerarCreditoDeVenta
    participant DB as MySQL
    participant Core as Core Credito SHIVAM

    Cert->>API: POST {uuid, state, message}
    API->>Ctrl: __invoke
    Ctrl->>DB: Cliente::where('certicamara_uuid', uuid)
    DB-->>Ctrl: cliente
    Ctrl->>Q: dispatch(ValidarPagareDigital, cliente, payload)
    Ctrl-->>Cert: 200 {success: true}

    Note over Q,GCJ: Worker picks up async

    Q->>VPJ: handle()
    alt state == 'signed'
        VPJ->>DB: UPDATE cliente SET pagare_firmado_en=now()
        VPJ->>DB: SELECT pending OrdenCompras for user
        loop per order
            VPJ->>Q: dispatch(ProcesarPagareDigital, orden)
        end
        Q->>PPJ: handle()
        PPJ->>DB: Load ventas with sucursal.empresa, user.cliente
        PPJ->>DB: VentaService::generarCuotas() or generarCuotasPorOrden()
        loop per venta
            PPJ->>Q: dispatch(GenerarCreditoDeVenta, empresa, cliente, venta)
        end
        Q->>GCJ: handle()
        GCJ->>Core: crearClienteEnCredito (SOAP)
        GCJ->>Core: generarCredito (SOAP)
        GCJ->>DB: UPDATE venta SET estado=APROBADA; cupo_disponible -= total; orden=PROCESADA
    else state == 'blocked' or 'expired'
        VPJ->>DB: cliente.certicamara_uuid = null
        VPJ->>DB: puede_intentar_firmar_pagare_en = now() + retry
        VPJ->>DB: pagare_firmado_en = null
        loop per pending order
            VPJ->>DB: increment inventario on each precio
            VPJ->>DB: orden -> RECHAZADA/ABANDONADA; venta -> RECHAZADA/ABANDONADA
        end
    end

Este es el camino productivo que convierte una venta pendiente en aprobada. Lee 03-trace-a-sale.md para el recorrido más profundo.

15.4 Qué necesitas recordar sobre webhooks

  • Sin auth. Certicámara no firma las peticiones en la configuración actual. Cualquiera que conozca la URL y un certicamara_uuid válido puede falsearlo. No hay verificación de firma en CerticamaraController. Doc 10 lo marcó.
  • Sin idempotencia. Si Certicámara reintenta el mismo webhook, ValidarPagareDigital corre dos veces y despacha la cadena dos veces. Esto puede duplicar cuotas en el flujo aliado. Doc 08 hallazgo 6.6 y hallazgo #8 del doc 16.
  • La cadena del webhook es la única forma en que una venta del marketplace se vuelve aprobada hoy. Porque registrarEnCerticamara() siempre retorna false (hallazgo #7 del doc 16), el camino directo de dispatch en VentaService::crearVentaUnica() línea 244 nunca se ejecuta.

16. Orden de lectura para internalizar esto

Abre estos archivos lado a lado y traza el camino tú mismo:

  1. routes/web.php — encuentra la ruta legal-check.
  2. bootstrap/app.php — lee el alias de middleware y las adiciones al grupo web.
  3. app/Http/Middleware/Authenticate.php — ve cómo escoge un guard.
  4. app/Http/Middleware/CheckIntentosLimiteDiarios.php + app/Services/AprobarCupoService.php:46-59 — el contador diario.
  5. app/Http/Middleware/HandleInertiaRequests.php — lee todo el método share().
  6. app/Http/Controllers/AprobarCliente/AprobarCupoController.php — el método legalCheck().
  7. app/Services/LegalCheckService.php — la llamada real a TransUnion.
  8. app/Providers/AppServiceProvider.php — el macro Http::transunion().
  9. app/Listeners/StoreValidationLog.php + app/Services/AprobarCupoService.php::crear() — cómo se escribe la fila de auditoría.

Luego traza el camino del webhook:

  1. routes/api.php — la ruta del webhook.
  2. app/Http/Controllers/Webhooks/CerticamaraController.php — el lookup + dispatch.
  3. app/Jobs/ValidarPagareDigital.php — el orquestador.
  4. app/Jobs/ProcesarPagareDigital.php — genera cuotas, despacha jobs de crédito.
  5. app/Jobs/GenerarCreditoDeVenta.php — habla con SHIVAM, transiciona el estado de la venta.

Una vez que hayas recorrido esto en orden, entiendes el 70% del comportamiento HTTP de Mi Plante. El 30% restante es el ciclo de vida de venta y el pipeline de aprobación de crédito — cubiertos en los otros dos archivos de esta carpeta.


17. Referencias cruzadas

  • 01-from-30000-feet.md — el mapa de todo el código.
  • 03-trace-a-sale.md — el ciclo de vida completo de la venta, incluyendo la cadena del webhook en detalle.
  • 04-trace-a-credit-approval.md — el pipeline de aprobación de 7 fases, en profundidad.
  • docs/audit/14-request-lifecycle-flowchart.md — los diagramas del ciclo de vida de petición de la auditoría.
  • docs/audit/11-data-flow-diagram.md — los diagramas de secuencia de la auditoría por escenario.
  • docs/audit/16-deep-validation-study.md — el reporte de validación listando cada bug confirmado y issue de seguridad.