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_diariosencima. - 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.tsawait axios.get('/usuario/cupo/legal-check')Axios está configurado globalmente con:
timeout: 300_000(5 minutos — acomoda respuestas lentas de TransUnion)withXSRFToken: truewithCredentials: trueheaders: { '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.1Host: miplante.localCookie: laravel_session=...; XSRF-TOKEN=...X-XSRF-TOKEN: <same token, URL-decoded>X-Requested-With: XMLHttpRequestAccept: application/json, text/plain, */*Referer: https://miplante.local/usuario/perfilSin 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:
-
Archivos de routing:
routes/web.php,routes/api.php,routes/console.php. El endpoint de health/upse registra automáticamente. Los archivosroutes/ally/web.phpyroutes/ally/auth.phpse cargan desderoutes/web.phphaciendorequire __DIR__ . '/ally/web.php'etc. -
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,]); -
Cookies exentas de encripción:
appearance,sidebar_state. Tienen que ser leíbles por JavaScript sin desencriptación. -
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 peticionesapi. 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. -
Manejo de excepciones: líneas 48-98 configuran un callback
respond()custom. Aquí vive el bug deApiExceptionHandler.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
returnantes 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 deApiExceptionHandler).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 enresources/js/plugins/errorHandling.tslo 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:
-
Middleware web global (de
bootstrap/app.phpy defaults de Laravel):Illuminate\Cookie\Middleware\EncryptCookiesIlluminate\Cookie\Middleware\AddQueuedCookiesToResponseIlluminate\Session\Middleware\StartSessionIlluminate\View\Middleware\ShareErrorsFromSessionIlluminate\Foundation\Http\Middleware\VerifyCsrfTokenIlluminate\Routing\Middleware\SubstituteBindingsApp\Http\Middleware\HandleAppearance(custom — establece cookie/share de apariencia)App\Http\Middleware\HandleInertiaRequests(custom — shared props)Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets
-
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íaCache::remember’d por 3600s. - Detecta si el usuario pertenece a una
Empresa(aliado) o no (cliente). Se ramifica:- Aliado: carga
empresaconlineas. - Cliente: carga
clienteData(el modeloCliente) ycarritoData(el array curado del carrito — vergetCarrito()en la línea 163).
- Aliado: carga
- Agrega
auth.user,auth.role,ziggy(rutas nombradas para el frontend), bloque de configuracióncredito, bloque de configuraciónlinks, 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 auth → App\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_diarios → App\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
auth→AuthenticationException→ para axios: 401. Para una visita Inertia: 302 a home con?login=true. - Fallo de
cliente_registro_completo→ 302 a/usuario/perfil(verEnsureClienteRegistroCompleto.php:30). - Fallo de
verificar_cliente_presenta_mora→ 302 ahomecon flash de alerta. - Fallo de
check_intentos_limite_diarios→ 422ValidationExceptioncon el error en la claveerror.
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:
- Carga eagerly
clienteypersonaen el usuario autenticado. Requerido porque el service inspecciona nombres y números de documento. - Delega a
LegalCheckService::manejar($user)— todo el trabajo real está aquí. - Al retornar, dispara un evento
ValidationLogcon el resultado. El listenerStoreValidationLoglo maneja sincrónicamente y escribe una filaAprobarCupoEvento. - 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:
-
Mapea el
persona->tipo_dnidel usuario al código de documento TransUnion (mapeo manejado por config). -
Dispara
ValidationLog(LEGAL_CHECK, START)— este es el evento que el middleware de límite diario cuenta. -
Llama a TransUnion:
$response = Http::transunion()->post('/ws/LegalcheckWSRest/legalcheck/consulta', ['tipoIdentificacion' => $tipoTU,'numeroIdentificacion' => $persona->dni,]);El macro
Http::transunion()(registrado enAppServiceProvider) preconfigura la base URL, las credenciales Basic Auth desdeconfig('services.transunion.*'), el timeout (300s), y el content-type JSON. -
En fallo HTTP, dispara
ValidationLog(LEGAL_CHECK, FINISH_UNSUCCESS)y lanzaValidationException. -
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; cualquierfinding === truees un fallo duro.
- Calcula
-
Construye un DTO
LegalCheckResult(App\DTOs\LegalCheck\LegalCheckResult) concompletado=truey sin error, ocompletado=falsey un mensaje de error para el usuario. -
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.php → services.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 →
ValidationExceptiondesde el service. El eventoFINISH_UNSUCCESSdel 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 enAppServiceProvider::register(). Si un macro falta, obtienes unBadMethodCallExceptionen 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:
- La promesa resuelve.
- El handler del componente desempaqueta
response.data. - La máquina de estados Vue avanza a la siguiente fase.
- 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 enresolvePageComponent).
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 mal | Qué ve el usuario | Qué debes saber |
|---|---|---|
| Sesión expirada | 401 (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 custom | Caso 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 hoy | 422 desde CheckIntentosLimiteDiarios | El 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 persona | LegalCheckService lanza | La 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 ValidationException | El service lanza. El evento START ya fue escrito. El usuario ve un error. Puede reintentar — cuenta contra el límite diario. |
| TransUnion retorna no-2xx | El service lanza | Igual que arriba. |
| Similitud de nombre < 70% | 200 con success: false | No 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 mes | El endpoint aprobar tendrá éxito temprano | validarSiElClienteTieneSuCupoAprobado() 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 falla | Listener síncrono lanza | La 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 flujo | Visita 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
apicorre, no el grupoweb. Sin HandleInertiaRequests, sin cookie de sesión, sin CSRF. Authenticateno 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_uuidválido puede falsearlo. No hay verificación de firma enCerticamaraController. Doc 10 lo marcó. - Sin idempotencia. Si Certicámara reintenta el mismo webhook,
ValidarPagareDigitalcorre 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
aprobadahoy. PorqueregistrarEnCerticamara()siempre retorna false (hallazgo #7 del doc 16), el camino directo de dispatch enVentaService::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:
routes/web.php— encuentra la ruta legal-check.bootstrap/app.php— lee el alias de middleware y las adiciones al grupo web.app/Http/Middleware/Authenticate.php— ve cómo escoge un guard.app/Http/Middleware/CheckIntentosLimiteDiarios.php+app/Services/AprobarCupoService.php:46-59— el contador diario.app/Http/Middleware/HandleInertiaRequests.php— lee todo el métodoshare().app/Http/Controllers/AprobarCliente/AprobarCupoController.php— el métodolegalCheck().app/Services/LegalCheckService.php— la llamada real a TransUnion.app/Providers/AppServiceProvider.php— el macroHttp::transunion().app/Listeners/StoreValidationLog.php+app/Services/AprobarCupoService.php::crear()— cómo se escribe la fila de auditoría.
Luego traza el camino del webhook:
routes/api.php— la ruta del webhook.app/Http/Controllers/Webhooks/CerticamaraController.php— el lookup + dispatch.app/Jobs/ValidarPagareDigital.php— el orquestador.app/Jobs/ProcesarPagareDigital.php— genera cuotas, despacha jobs de crédito.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.