Trazar una Aprobación de Crédito

Este es el documento más largo y complejo del recorrido del código. La razón de ser de Mi Plante es extender crédito revolvente a clientes que compran a aliados. Cada cliente que pueda realizar una compra debe primero pasar este pipeline de 7 fases. El pipeline integra con cinco sistemas externos (TransUnion, Experian CrossCore, DataCrédito, EMCALI, Core Crédito SHIVAM), abarca ~20 métodos de controller, ~7 services, ~12 endpoints, y escribe a un log de auditoría inmutable event-sourced (aprobar_cupo_eventos).
La auditoría (doc 16) marcó esto como el área donde la aplicación en runtime diverge más de la intención documentada. Lee todo este documento antes de cambiar nada en app/Services/AprobarCupo*, app/Http/Controllers/AprobarCliente/, app/Services/Legal*, app/Services/Identity*, o app/Services/HDC*.
Fuentes primarias: docs/audit/12-credit-approval-workflow-diagram.md, app/Http/Controllers/AprobarCliente/AprobarCupoController.php, app/Services/AprobarCupoService.php, app/Services/LegalCheckService.php, app/Services/IdentityValidationService.php, app/Services/HDCValidationService.php, app/Services/ExtenderCupoService.php, app/Services/DataCreditoService.php, app/Models/AprobarCupoEvento.php, app/Enum/AprobarCupo/ProcessType.php, app/Enum/AprobarCupo/EventType.php.
Léelo en pareja con el UML oficial del módulo:
docs/onboarding/media/diagrams/08-uml-aprobar-cupo-module.md— diagrama de clases producido por el equipo original. Es la fuente más autoritativa sobre la intención del módulo. OJO: el UML afirma reglas que difieren del runtime en tres puntos (regla de aprobación3+vs runtime>= 5, proveedor del Legal Check Transunion vs Experian, HDC como placeholder). Lee el bloque de “DISCREPANCY ALERT” en la cabecera del UML antes de confiar en cualquiera de los dos números. La fuente final de verdad es siempre el código enapp/Services/AprobarCupoService::validarSiElClienteTieneSuCupoAprobado().
1. Por qué existe este pipeline
Mi Plante extiende crédito a clientes de retail en Cali, Colombia. El producto es “marketplace fintech”: navegar bienes de aliados, comprar a cuotas, pagar a través de facturas de servicios EMCALI.
Ese modelo solo funciona si Mi Plante puede responder “¿es esta persona quien dice ser, y es solvente crediticiamente?” — sin tener licencia bancaria propia. Así que el pipeline es una secuencia cuidadosa de chequeos de buró e identidad calibrados a la regulación colombiana y las integraciones que Mi Plante ha contratado:
- Identidad: Experian CrossCore (Evidente).
- Autenticación: OTP + preguntas basadas en conocimiento (Experian).
- Anti-lavado de dinero / sanciones: TransUnion LegalCheck.
- Historia crediticia y score: DataCrédito HDC Plus.
- Anclaje del cupo (y validación de dirección): Membresía EMCALI (cuenta de servicios prueba residencia + estrato).
- Core bancario para el registro real del crédito: Core Crédito SHIVAM.
Los clientes deben completar el gauntlet antes de poder hacer pedidos. El resultado del gauntlet se registra en clientes.cupo_asignado, cupo_disponible, cupo_vence_en, más una referencia vcard para SHIVAM.
2. Vista general de las 7 fases
flowchart TD
P0([Phase 0: Registration completion<br/>POST /usuario/completar-registro<br/>EMCALI membership + estrato])
P1[Phase 1: Legal Check<br/>GET /usuario/cupo/legal-check<br/>TransUnion]
P2[Phase 2: Identity Validation<br/>GET /usuario/cupo/identity-validation<br/>Experian CrossCore]
P3[Phase 3: OTP Generation<br/>GET /usuario/cupo/identity-validation-generate-otp<br/>Experian — sends OTP to email + phone]
P4[Phase 4: OTP Verification<br/>POST /usuario/cupo/identity-validation-verify-otp<br/>Experian — SHA-256 hashed code]
DEC{requiere_cuestionario?}
P5a[Phase 5a: Question Generation<br/>GET /usuario/cupo/identity-validation-generate-questions<br/>Experian]
P5b[Phase 5b: Question Verification<br/>POST /usuario/cupo/identity-validation-verify-questions<br/>Experian]
P6[Phase 6: HDC Validation<br/>GET /usuario/cupo/hdc-validation<br/>DataCredito — responseCode == 13]
P7[Phase 7: Cupo Approval + Extension<br/>GET /usuario/cupo/aprobar<br/>EMCALI + DataCredito + SHIVAM]
DONE([Cliente can shop])
P0 --> P1 --> P2 --> P3 --> P4 --> DEC
DEC -- yes --> P5a --> P5b --> P6
DEC -- no --> P6
P6 --> P7 --> DONE
La Fase 0 no está en el grupo de rutas /cupo/*; está en POST /usuario/completar-registro. Es la precondición para que el cliente siquiera tenga permitido empezar el gauntlet de crédito. Las fases restantes todas viven bajo routes/web.php líneas 65-79 con el prefijo /usuario/cupo/.
Cada fase excepto la Fase 0 y la Fase 7 está detrás del middleware check_intentos_limite_diarios. La Fase 7 (aprobar) no tiene ese middleware — el límite diario solo cuenta los starts de LEGAL_CHECK.
3. El enum TipoProcesoAprobarCupo
Hay dos enums en app/Enum/AprobarCupo/:
3.1 ProcessType (app/Enum/AprobarCupo/ProcessType.php)
| Caso | Valor | Fase |
|---|---|---|
LEGAL_CHECK | 'legal_check' | Fase 1 |
IDENTITY_VALIDATION | 'identity_validation' | Fase 2 |
IV_OTP_GENERATION | 'iv_otp_generation' | Fase 3 |
IV_OTP_VERIFICATION | 'iv_otp_verification' | Fase 4 |
IV_QUESTION_GENERATION | 'iv_question_generation' | Fase 5a |
IV_QUESTION_VERIFICATION | 'iv_question_verification' | Fase 5b |
HDC_VALIDATION | 'hdc_validation' | Fase 6 |
Hay 7 tipos de proceso en total. No hay valor de enum para la Fase 0 (registro) o la Fase 7 (aprobación) — esas no son partes del log de eventos, lo leen.
3.2 EventType (app/Enum/AprobarCupo/EventType.php)
| Caso | Valor | Significado |
|---|---|---|
START | 'validation_start' | Fase intentada (el middleware de límite diario cuenta estos para LEGAL_CHECK) |
FINISH_SUCCESS | 'validation_finish_successfull' | Fase completada exitosamente |
FINISH_UNSUCCESS | 'validation_finish_unsuccessfull' | Fase corrió pero el resultado fue negativo o errado |
Cada fila AprobarCupoEvento es la combinación (cliente_id, tipo_proceso, evento, contexto JSON, creado_en). Primary key UUID. La columna contexto almacena el payload request/response para auditoría — ver sección 5.4 para la preocupación de privacidad.
4. La cadena de middleware
Cada endpoint de aprobación de crédito corre a través de la misma cadena — la sección 3.7 de 02-trace-a-request.md la recorre en detalle. Aquí está el resumen enfocado:
| Middleware | Rutas | Propósito |
|---|---|---|
grupo web (default + adiciones Mi Plante) | todas | Sesiones, CSRF, shared props Inertia |
auth | Todos los endpoints de crédito | Confirma que el cliente está logueado (guard: web) |
check_intentos_limite_diarios | Fases 1-6 (NO Fase 7) | Aborta con 422 si >2 eventos LEGAL_CHECK / START hoy |
Nota lo que no está aquí:
cliente_registro_completoNO está en los endpoints de crédito. Un usuario podría teóricamente alcanzar la Fase 1 antes de completar la Fase 0 (sin estrato, sin cupo_asignado). Las fases por sí mismas no aplican la completitud de la Fase 0 — asumen que los registrosclienteypersonason cargables. Sipersonaes null,LegalCheckServicelanza.- Sin middleware
requires_otp_verified. La Fase 5 (preguntas) sí verifica unvalidacioncacheado, pero no verifica que la Fase 4 (verificación OTP) tuvo éxito. Un cliente inteligente podría llamar a la Fase 5a/5b después de la Fase 3 (OTP generado, nunca verificado). Doc 16 lo marcó — riesgo de bypass para el gate del cuestionario.
La auditoría (docs/audit/12-credit-approval-workflow-diagram.md validated corrections) enfatizó: el diagrama muestra lo que debería aplicarse, pero el backend mayormente aplica vía el patrón cache-y-eventos, no vía middleware. Trata el diagrama como UX deseada, no como política de seguridad.
5. La tabla aprobar_cupo_eventos — la fuente de verdad
5.1 Schema
CREATE TABLE aprobar_cupo_eventos ( id CHAR(36) PRIMARY KEY, -- UUID v4 cliente_id BIGINT NOT NULL, tipo_proceso VARCHAR(64) NOT NULL, -- ProcessType enum value evento VARCHAR(64) NOT NULL, -- EventType enum value contexto JSON NULL, -- DTO::toArray() creado_en TIMESTAMP NOT NULL, FOREIGN KEY (cliente_id) REFERENCES clientes(id));PK UUID. contexto JSON. creado_en en español (el modelo extiende Modelo).
5.2 Cómo se escriben las filas
El listener StoreValidationLog maneja cada evento ValidationLog sincrónicamente. El listener llama AprobarCupoService::crear(CrearCupoEventoDTO), que hace AprobarCupoEvento::create(...). El toArray() completo del DTO del resultado va al contexto.
Dos patrones de escritura:
- El service escribe START, el controller escribe FINISH. Usado por las Fases 2, 3, 4, 5a, 5b. Ejemplo:
IdentityValidationService::validarIdentidad()disparaSTART;AprobarCupoController::identityValidation()disparaFINISH_SUCCESSoFINISH_UNSUCCESS. - El service escribe ambos START y FINISH en fallo HTTP; el controller escribe FINISH en el camino de éxito. Usado por la Fase 1 (Legal Check).
- El controller escribe ambos START y FINISH. Usado por la Fase 6 (HDC Validation) — el service está silencioso.
Doc 12 sección 5 documenta estas excepciones. Son inconsistentes y tienen implicaciones para el límite diario que cuenta START (solo LEGAL_CHECK se cuenta, así que la inconsistencia es benigna por ahora, pero cualquiera que refactorice los eventos debe preservar la semántica START-on-attempt de LEGAL_CHECK).
5.3 La consulta de decisión de aprobació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;}Lee esto cuidadosamente. Agrupa los eventos del cliente desde el inicio del mes actual por tipo_proceso. Para cada grupo calcula:
MAX(creado_en)— el evento más reciente para ese proceso este mes.MAX(CASE WHEN evento = 'validation_finish_successfull' THEN creado_en END)— el evento más reciente de finalización exitosa.
El HAVING filtra grupos donde los dos máximos son iguales — significando que el evento más reciente para ese proceso fue un éxito.
Cuenta esos grupos. Si 5 o más tipos de proceso califican, el cliente está aprobado.
Implicaciones críticas:
- El orden no importa. Las fases pueden hacerse en cualquier orden; la consulta solo mira el evento más reciente por proceso.
- 5 de 7 es suficiente. Con 7 ProcessTypes, el cliente solo necesita 5 finalizaciones exitosas. Dos fases pueden faltar enteramente — el cuestionario es opcional, así que en el camino feliz un cliente sin
requiere_cuestionariosolo tiene 5 fases (1, 2, 3, 4, 6) y eso cumple exactamente el threshold. - Un fallo posterior sobrescribe un éxito anterior. Si un cliente pasa la Fase 1 a principios de enero y falla la Fase 1 de nuevo más tarde en el mes, el evento más reciente para
legal_checkesFINISH_UNSUCCESSy el proceso no cuenta. - Reset en el límite de mes. El
where('creado_en', '>=', startOfMonth)de la consulta significa que a la medianoche del día 1, el conteo de cada cliente se resetea a 0. Deben rehacer al menos 5 fases cada mes para mantener la aprobación.
(4) es el diseño detrás de que cupo_vence_en se establezca al fin del 6to día del mes siguiente (ver Fase 7). El cliente está incentivado a refrescar su aprobación a mitad de mes.
5.4 Preocupación de privacidad — contexto JSON
La columna contexto almacena la salida completa del DTO, que puede incluir:
- Nombres completos, DNI, tipo de documento.
- Hallazgos de listas legales de TransUnion.
- Extractos del reporte HDC de DataCrédito.
- IDs de transacción OTP de Experian.
- Fragmentos de dirección de EMCALI.
No hay enmascaramiento de PII. No hay política de retención. La auditoría (doc 09) marcó esto como un riesgo: cada intento de crédito persiste datos sensibles financieros y de identidad en JSON plano para siempre.
Cuando cambies un service para añadir nuevos campos a su DTO *Result, piensa primero si esos campos deberían estar en contexto.
6. La regla “5 de 7 fases” — insight clave
Los documentos de auditoría (doc 12 y doc 16) enfatizan esto porque la intención documentada y la aplicación en runtime difieren:
- Intención documentada (flujo UX): “El cliente pasa por las fases 1, 2, 3, 4, [opcionalmente 5], 6, 7 en orden.”
- Aplicación en runtime: “El cliente debe tener cualquier 5 ProcessTypes distintos con su evento más reciente = FINISH_SUCCESS este mes.”
Lo que se deriva de la regla en runtime:
6.1 El gate del cuestionario es suave
La Fase 4 (verificación OTP) retorna un flag requiere_cuestionario en data.requiere_cuestionario. El frontend lo usa para decidir si enviar al usuario por la Fase 5a/5b. Pero el backend no rechaza registrar eventos de la Fase 5 sin verificación OTP. Así que un cliente malicioso o buggy puede avanzar a la Fase 5b y escribir un evento IV_QUESTION_VERIFICATION / FINISH_SUCCESS, que cuenta como 1 de los 5.
6.2 Las fases del cuestionario pueden usarse para “llenar” fases faltantes
Si un cliente falla la Fase 1 (legal check) dos días seguidos y se queda sin intentos diarios, no puede completar la Fase 1 hoy. Pero si ya hizo las Fases 2, 3, 4, 5a, 5b, 6 — eso son 6 tipos de proceso exitosos. Calificarán para aprobación de cupo sin la Fase 1.
Esto casi con certeza no es intencional. La forma del fix:
- Requerir que
LEGAL_CHECKsiempre sea uno de los 5. - O requerir que
HDC_VALIDATIONsiempre sea uno de los 5. - O simplemente requerir 7 de 7.
El >= 5 actual es una solución temporal para la Fase 5 condicional. Un enfoque más limpio es requerir todas las fases no-condicionales (1, 2, 3, 4, 6) más las condicionales si requiere_cuestionario fue true.
6.3 Qué significa esto para añadir nuevas fases
Si añades un 8vo ProcessType (digamos, IV_FACIAL_RECOGNITION), el threshold se queda en >= 5 y el nuevo check se vuelve opcional. Para hacerlo requerido, ya sea:
- Subir el threshold a
>= 6. - Reestructurar
validarSiElClienteTieneSuCupoAprobadopara tomar una lista explícita de ProcessTypes requeridos.
Ambos cambios tocan un método que controla todas las decisiones de crédito. Coordina.
7. Límites de intentos diarios
Lógica del límite en app/Services/AprobarCupoService.php:46-59 (leer en 02-trace-a-request.md sección 7.8):
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;}Hechos clave:
- Cuenta solo eventos
LEGAL_CHECK / validation_start. - El límite es “más de 2” — significando que el 3er intento está bloqueado.
- El contador se resetea a medianoche.
- El middleware
CheckIntentosLimiteDiariosestá en cada fase excepto la Fase 7 (porroutes/web.php:68-76). La Fase 7 es/cupo/aprobary se lo salta.
Hay un endpoint cliente no bloqueante en GET /usuario/cupo/verificar-limite-intentos (VerificarLimiteIntentosController::index) para que el frontend pueda verificar estado sin disparar el límite.
Implicación sutil de corrección: el límite solo cuenta los starts de LEGAL_CHECK. Si añades una nueva fase y la instrumentas con su propio evento START, los intentos de la nueva fase no se cuentan. El cliente puede reintentar esa fase indefinidamente (sujeto a cualquier throttling que la API externa haga). Si quieres simetría, ya sea renombra el límite a “intentos de aprobacion” y cuenta starts de múltiples fases, o documenta explícitamente que LEGAL_CHECK es el punto de entrada rate-limited.
8. Services externos por fase
| Fase | Clase Service | API Externa | Auth | Caché | Timeout |
|---|---|---|---|---|---|
| 0 | EmcaliMembresiaService + ClienteService | EMCALI Membresias | Ninguna (API abierta) | 24h (éxito + errores cliente) | 30s |
| 1 | LegalCheckService | TransUnion LegalCheck | HTTP Basic | Ninguna | 300s |
| 2 | IdentityValidationService::validarIdentidad | Experian Okta + CrossCore | OAuth password grant | {userId}.validacion 60 min | 300s |
| 3 | IdentityValidationService::generarOTP | Experian CrossCore (/otp/initialize, /otp/evaluation) | OAuth | transaccion_otp_id 30 min, requiere_cuestionario 30 min | 300s |
| 4 | IdentityValidationService::verificarOTP | Experian CrossCore (/otp/verify) | OAuth | Ninguna (lee caché) | 300s |
| 5a | IdentityValidationService::generarCuestionario | Experian CrossCore (/identificacion/preguntas) | OAuth | cuestionario_id 30 min, registro_cuestionario 30 min | 300s |
| 5b | IdentityValidationService::verificarCuestionario | Experian CrossCore (/identificacion/verificar) | OAuth | Ninguna | 300s |
| 6 | HDCValidationService + DataCreditoService | DataCrédito HDC Plus | OAuth client credentials | Token 590s; respuesta HDC 24h | 60s |
| 7 | AprobarCupoService + ExtenderCupoService + CreditoService | EMCALI + DataCrédito + Core Crédito SHIVAM | Mixto | EMCALI 24h, DataCrédito 24h | mixto |
Los macros HTTP (registrados en app/Providers/AppServiceProvider.php) preconfiguran base URLs y auth. Los services llaman Http::transunion(), Http::expirianCrossCore(), etc.
config/services.php mantiene las credenciales. La mayoría de las env vars siguen el patrón <SERVICE>_<FIELD> — ej., DATACREDITO_CLIENT_ID, EXPIRIAN_CROSS_CORE_USERNAME, CERTICAMARA_API_KEY.
9. Los bypasses y huecos (hallazgos del doc 16, priorizados)
Estos son reales, validados contra el código. Trátalos como minas conocidas. Cada entrada abajo es un lugar donde la aplicación en runtime es más débil que lo que la documentación implica.
9.1 Endpoint público de proxy DataCrédito
routes/api.php:25 expone GET /api/v1/datacredito/historial con sin autenticación:
Route::get('/datacredito/historial', [DataCreditoController::class, 'consultarHistorial']) ->name('api.datacredito.historial');El handler valida query params (tipo_documento, numero_documento, apellido), luego llama DataCreditoService::consultarHistorialPorTipoDocumento() y retorna la respuesta cruda. Cualquiera en internet con el DNI y apellido de alguien puede consultar su historial crediticio a través de este endpoint.
La auditoría (hallazgo #2 del doc 16) confirmó esto. El endpoint aparentemente existe para desarrollo local (las correcciones del doc 11 notan que en el entorno local el service de DataCrédito proxea a https://test.miplante.com/api/v1/datacredito/historial). Pero está registrado en api.php incondicionalmente — producción también lo sirve.
Forma del fix: añadir middleware auth:web en la ruta API. Si se necesita un proxy local-only para tests, controla con app()->environment('local') o mueve a web.php con auth.
9.2 La clave de caché de DataCrédito omite apellido
DataCreditoService::consultarHistorialPorTipoDocumento cachea la respuesta con una clave compuesta de tipo_documento + numero_documento, pero no apellido. Así que si dos clientes comparten un DNI de alguna manera (datos de prueba, dígitos mistyped), o si el mismo DNI se consulta con diferentes apellidos, la segunda consulta retorna la respuesta cacheada de la primera.
Forma del fix: incluir todos los inputs distintivos en la clave de caché.
9.3 El requestUUID de DataCrédito es fijo
DataCreditoService envía un requestUUID constante en cada llamada (hallazgo #16 del doc 16). DataCrédito lo usa para idempotencia de petición y auditoría en su lado; usar un valor fijo hace inútiles sus logs y puede causar problemas de replay.
Forma del fix: generar Str::uuid() por llamada.
9.4 OTP no enforced antes del cuestionario
La Fase 5a (generarCuestionario) verifica un validacion cacheado (de la Fase 2) pero no un OTP verificado (de la Fase 4). El frontend usa requiere_cuestionario para controlar la UX, pero una llamada directa a /usuario/cupo/identity-validation-generate-questions correrá si el usuario tiene cualquier caché validacion válida — la Fase 4 es bypasseable.
Forma del fix: en IdentityValidationService::generarCuestionario y verificarCuestionario, requerir un otp_verified_at cacheado (establecido en la Fase 4) dentro de un TTL.
9.5 El score mínimo no se aplica
La Fase 7 (ExtenderCupoService::extender) lee el score crediticio de DataCrédito y lo mapea a un porcentaje de extensión (0-350: 0%, 351-470: 25%, 471-670: 50%, 671-815: 75%, 816-1000: 100%, null: 25%). Pero no hay gate de score mínimo. Incluso un cliente con un score bajo (0-350) se vuelve “aprobado” en la Fase 7 si tiene >= 5 tipos de proceso exitosos este mes; simplemente no obtiene la extensión de cupo. Su cupo base (de estrato) aún aplica.
Si la intención del negocio es “el score debe estar por encima de X para calificar para cupo siquiera,” el gate falta.
Forma del fix: añadir un paso validarScoreMinimo($cliente) dentro del controller aprobarCupo() antes de validarSiElClienteTieneSuCupoAprobado.
9.6 Off-by-one de cupo_vence_en
AprobarCupoController::aprobarCupo() líneas 277-278:
$dto = ActualizarClienteDTO::fromArray([ 'cupo_vence_en' => Carbon::now()->addMonth()->startOfMonth()->addDays(5)->endOfDay(),]);La matemática: addMonth()->startOfMonth() = primer día del mes siguiente. addDays(5) = el 6to día. endOfDay() = 23:59:59. Si el negocio dice “el cupo expira el 5to día del mes siguiente,” esto está corrido por uno.
La auditoría (doc 12) marcó esto como un posible bug off-by-one. Ya sea corregir la matemática a addDays(4) o corregir la especificación del negocio.
9.7 Registro de aliado no enforced con link firmado
No es parte directa del pipeline de crédito, pero vale la pena saberlo: el registro de aliado usa una URL firmada en el GET (GET /aliados/postulacion/{postulacion}/registro es signed), pero el POST que realmente crea la cuenta no valida la firma ni recibe el parámetro {postulacion}. Hallazgo #3 del doc 16. Así que el flujo de invitación firmada es teatro — la creación real de la cuenta no tiene verificación de firma.
9.8 Desajuste de contrato wishlist en frontend
El composable useWishlist() espera un array plano; ListaDeseoController::index retorna un paginator. Hallazgo #18 del doc 16. No es un issue de aprobación de crédito, pero está en la misma área del código que maneja la forma de datos del cliente, y vale la pena estar consciente.
10. Los caminos infelices
10.1 Qué pasa en rechazo por fase
| Fase | Modo de fallo | Qué pasa |
|---|---|---|
| 0 | EMCALI caído | ValidationException “Error al consultar Emcali” — la UI muestra error, el usuario puede reintentar |
| 0 | Facturas/dirección no coinciden | ValidationException — el usuario re-envía |
| 0 | Estrato no soportado (sin cupo configurado) | ValidationException — terminal |
| 1 | Error HTTP de TransUnion | ValidationException — la UI muestra error genérico, FINISH_UNSUCCESS logueado, reintento cuenta hacia límite diario |
| 1 | Similitud de nombre < 70% | 200 con success: false, FINISH_UNSUCCESS logueado, mensaje mostrado |
| 1 | Documento no VIGENTE | Igual que arriba |
| 1 | Hallazgo en lista legal | Igual que arriba — pero esto es regulatorio; el usuario no puede continuar, reintentar no ayuda |
| 1 | 3er intento hoy | 422 del middleware — debe esperar hasta mañana |
| 2 | Fallo de token OAuth | Excepción lanzada — la petición falla con 500 (debido al bug de ApiExceptionHandler) o lo que sea que Laravel haga para excepciones no capturadas. Evento FINISH_UNSUCCESS NO logueado. |
| 2 | Experian resultado = “09” (límite diario de su lado) | DTO de error, FINISH_UNSUCCESS logueado — usuario espera |
| 2 | Otro resultado | DTO de error, FINISH_UNSUCCESS — usuario reintenta |
| 3 | validacion cacheada faltante (TTL expirado) | DTO de error “Realice la validacion primero” — reiniciar desde Fase 2 |
| 3 | OTP no enviado a email/teléfono (no coincide el contacto) | DTO de error — el info de contacto no coincide con Experian; el usuario debe arreglar el perfil |
| 4 | Código OTP inválido | DTO de error — el usuario re-ingresa; puede solicitar nuevo OTP vía Fase 3 |
| 4 | resultadoValidacion != “1” | DTO de error — Experian dice “no” |
| 5a | código resultado 10/11/12 (límites de intento) | DTO de error — los límites contados por Experian están agotados, terminal para esta ronda |
| 5b | aprobacion false | DTO de error — el cuestionario falló, terminal para esta ronda |
| 6 | Fallo HTTP de DataCrédito | Retorna false, FINISH_UNSUCCESS, reintento posible |
| 6 | responseCode != 13 | Retorna false, FINISH_UNSUCCESS |
| 7 | < 5 tipos de proceso exitosos | ValidationException “El cliente no tiene su cupo aprobado” — debe completar las fases faltantes |
| 7 | Falla la extensión (EMCALI caído, DataCredito caído) | No-extensión graciosa — la Fase 7 aún tiene éxito, el cliente obtiene solo cupo base |
| 7 | SHIVAM (Core Credito) falla al registrar el cliente | Actualmente se loguea silenciosamente dentro de crearClienteEnCredito; el controller aún retorna éxito — ver hallazgo del doc 16 |
10.2 Qué pasa en timeout de API externa
Todas las llamadas Experian / Identity tienen set_time_limit(300) en el método del controller para permitir 5 minutos. Combinado con el timeout de 5 minutos de axios, eso le da al usuario hasta 5 minutos de espera. Si la llamada aún no retorna, axios hace timeout (error de red en el navegador).
Desde la perspectiva del controller, si Http::expirianCrossCore()->post(...) por sí mismo hace timeout, lanza una excepción. La excepción se propaga hacia arriba por service → controller → middleware → kernel. El ApiExceptionHandler habría producido una respuesta 5xx estructurada, pero por el bug del bootstrap se descarta y se retorna la respuesta default de Laravel. El evento FINISH nunca fue escrito. El usuario ve un error HTTP.
10.3 Qué pasa en reintento
Cada fase excepto la Fase 1 puede reintentarse indefinidamente (limitada solo por la API externa). La Fase 1 tiene rate-limit diario vía el middleware.
La Fase 7 (aprobación final) no escribe un evento ProcessType propio. Re-llamar a la Fase 7 simplemente re-verifica el log de eventos existente. Así que si el cliente está aprobado, llamar /aprobar de nuevo es idempotente sobre el estado del cupo (sobrescribe cupo_vence_en con el mismo valor) y dispara ExtenderCupoService::extender de nuevo (que puede producir una extensión diferente si EMCALI o DataCrédito retornaron datos diferentes).
10.4 Qué pasa al exceder intentos diarios
CheckIntentosLimiteDiarios lanza ValidationException antes de que corra cualquier método del controller. El usuario ve el toast de error. Debe esperar hasta la medianoche (tiempo de Colombia, ya que Carbon::now()->startOfDay() usa el tz del servidor — verifica que app.timezone esté correctamente seteado en config).
11. Cómo añadir un nuevo paso
Un checklist concreto si se requiere un nuevo check de crédito:
-
Decide el ProcessType. Añade un caso a
app/Enum/AprobarCupo/ProcessType.php. -
Decide los eventos. El patrón estándar es
START(en service) +FINISH_SUCCESS/FINISH_UNSUCCESS(en controller). Apégate al estándar a menos que tengas una fuerte razón (la Fase 1 y la Fase 6 son excepciones; ambas son no-ideales). -
Crea el Service. Nuevo service bajo
app/Services/. Inyecta cualquier API externa vía unHttp::macro()registrado enAppServiceProvider. -
*Crea el DTO Result. Bajo
app/DTOs/<NewDomain>/. UsaResultTraitparahasError()/getError()/getData(). -
Crea el método del Controller. Añade un método en
AprobarCupoController(o un nuevo controller bajoapp/Http/Controllers/AprobarCliente/). Sigue el patrón:public function newStep() {set_time_limit(300);$user = auth()->user()->load(['cliente', 'persona']);$result = $this->newService->manejar($user);if ($result->hasError()) {event(new ValidationLog(cliente: $user->cliente,processType: ProcessType::NEW_STEP,eventType: EventType::FINISH_UNSUCCESS,context: $result->toArray(),));throw ValidationException::withMessages(['error' => $result->getError()]);}event(new ValidationLog(cliente: $user->cliente,processType: ProcessType::NEW_STEP,eventType: EventType::FINISH_SUCCESS,context: $result->toArray(),));return response()->json(['message' => '...','data' => null,'success' => true,'error' => null,]);} -
Registra la ruta. Añade a
routes/web.phpbajo el bloqueRoute::middleware('check_intentos_limite_diarios')->group(...)en la línea 68. Usa un nombre comouser.aprobar-cupo.new-step. -
Actualiza el threshold (si es obligatorio). Si el nuevo paso debe ser requerido, sube
validarSiElClienteTieneSuCupoAprobadode>= 5a un número más alto O reestructura para tomar una lista requerida explícita. Esto afecta a los clientes existentes — súbitamente necesitan una fase extra para mantener la aprobación. -
Frontend. Añade el paso a la máquina de estados Vue
ModalValidate. Ajusta el indicador de progreso. -
Tests. Añade tests Feature bajo
tests/Feature/AprobarCliente/. UsaHttp::fake()para la API externa. -
Documenta las nuevas env vars. Actualiza
.env.exampleydocs/onboarding/03-development-setup/. -
Audita la privacidad del nuevo contexto. Decide qué escribe tu DTO *Result a
contexto.
Ese es el checklist oficial. Hay 11 pasos porque el pipeline es event-sourced y la decisión en runtime se lee de los eventos; añadir una nueva fuente de evento requiere tocar también el lado de lectura.
12. Diagrama de secuencia del camino feliz
sequenceDiagram
actor C as Customer
participant FE as ModalValidate.vue
participant MW as auth + limit
participant CR as CompletarRegistroController
participant AC as AprobarCupoController
participant LCS as LegalCheckService
participant IVS as IdentityValidationService
participant HDCVS as HDCValidationService
participant ACS as AprobarCupoService
participant ECS as ExtenderCupoService
participant CS as CreditoService
participant EMCALI as EMCALI API
participant TU as TransUnion API
participant OKTA as Experian Okta
participant EXP as Experian CrossCore
participant DC as DataCredito API
participant SHIVAM as Core Credito
participant DB as MySQL
Note over C,DB: Phase 0 — Registration completion
C->>FE: fills form
FE->>CR: POST /usuario/completar-registro
CR->>EMCALI: GET consultarMembresia
EMCALI-->>CR: invoices, address, estrato
CR->>CR: ClienteService::validarFacturasDeUltimoSeisMeses
CR->>CR: ClienteService::validarSimilitudDeDirecciones
CR->>CR: assign cupo from estrato (E1=2.5M ... E4-6=4M)
CR->>DB: UPDATE clientes SET cupo_asignado, cupo_disponible, registro_completado_en
CR-->>FE: 200
Note over C,DB: Phase 1 — Legal Check
FE->>MW: GET /usuario/cupo/legal-check
MW->>ACS: haSuperadoLimiteIntentosDiarios
ACS->>DB: SELECT count of LEGAL_CHECK START today
ACS-->>MW: <= 2
MW->>AC: legalCheck
AC->>LCS: manejar
LCS->>DB: INSERT aprobar_cupo_eventos LEGAL_CHECK START
LCS->>TU: POST /legalcheck/consulta
TU-->>LCS: {nombre, estadoDocumento: VIGENTE, data: []}
LCS-->>AC: LegalCheckResult::success
AC->>DB: INSERT LEGAL_CHECK FINISH_SUCCESS
AC-->>FE: 200 success
Note over C,DB: Phase 2 — Identity Validation
FE->>AC: GET /usuario/cupo/identity-validation
AC->>IVS: validarIdentidad
IVS->>OKTA: POST /oauth2/token
OKTA-->>IVS: access_token
IVS->>DB: INSERT IDENTITY_VALIDATION START
IVS->>EXP: POST /identificacion/validar
EXP-->>IVS: {resultado: "01", regValidacion}
IVS->>IVS: Cache::put(userId.validacion, 60min)
IVS-->>AC: success
AC->>DB: INSERT IDENTITY_VALIDATION FINISH_SUCCESS
AC-->>FE: 200
Note over C,DB: Phase 3 — OTP Generation
FE->>AC: GET /usuario/cupo/identity-validation-generate-otp
AC->>IVS: generarOTP
IVS->>OKTA: POST /oauth2/token
IVS->>DB: INSERT IV_OTP_GENERATION START
IVS->>EXP: POST /otp/initialize
EXP-->>IVS: {idTransaccionOTP}
IVS->>EXP: POST /otp/evaluation
EXP-->>IVS: {enviadoOtpCorreo: true, enviadoOtpCelular: true, requiereCuestionario: false}
IVS->>IVS: Cache::put(transaccion_otp_id, 30min)
IVS-->>AC: success
AC->>DB: INSERT IV_OTP_GENERATION FINISH_SUCCESS
AC-->>FE: {correo:true, telefono:true}
Note over C,DB: Phase 4 — OTP Verification
C->>FE: enters OTP code
FE->>AC: POST /verify-otp {otp_code}
AC->>IVS: verificarOTP
IVS->>IVS: SHA-256 hash code
IVS->>OKTA: POST /oauth2/token
IVS->>DB: INSERT IV_OTP_VERIFICATION START
IVS->>EXP: POST /otp/verify
EXP-->>IVS: {codigoValido: true, resultadoValidacion: "1"}
IVS-->>AC: success {requiere_cuestionario: false}
AC->>DB: INSERT IV_OTP_VERIFICATION FINISH_SUCCESS
AC-->>FE: {valido:true, requiere_cuestionario:false}
Note over C,DB: Phase 5 SKIPPED (requiere_cuestionario was false)
Note over C,DB: Phase 6 — HDC Validation
FE->>AC: GET /usuario/cupo/hdc-validation
AC->>DB: INSERT HDC_VALIDATION START
AC->>HDCVS: manejar
HDCVS->>DC: POST /spla/oauth2/v1/token (cached 590s)
HDCVS->>DC: POST /cs/credit-history/v1/hdcplus
DC-->>HDCVS: {ReportHDCplus: {productResult: {responseCode: 13}}}
HDCVS-->>AC: true
AC->>DB: INSERT HDC_VALIDATION FINISH_SUCCESS
AC-->>FE: 200
Note over C,DB: Phase 7 — Approval + Extension
FE->>AC: GET /usuario/cupo/aprobar
AC->>ACS: validarSiElClienteTieneSuCupoAprobado
ACS->>DB: GROUP BY tipo_proceso HAVING latest is FINISH_SUCCESS
ACS-->>AC: count = 5 (legal, identity, otp gen, otp verify, hdc)
AC->>DB: UPDATE clientes SET cupo_vence_en = next month 6th EOD
AC->>ECS: extender(cliente)
ECS->>EMCALI: GET consultarMembresia
EMCALI-->>ECS: {approved tiers}
ECS->>DC: POST /hdcplus (cached)
DC-->>ECS: {models: [{scoreValue: 720}]}
ECS->>ECS: map score to 75%
ECS->>ECS: increment = (emcali_cupo - base) * 0.75
ECS->>DB: UPDATE clientes SET cupo_asignado, cupo_disponible
ECS->>CS: crearClienteEnCredito (force=true)
CS->>SHIVAM: POST SOAP
SHIVAM-->>CS: {ApplNo, ErrorID}
CS->>DB: UPDATE clientes SET vcard
CS-->>ECS: true
ECS-->>AC: extension result
AC-->>FE: {success:true, data:cliente, extension}
FE-->>C: Approved. Cupo: $3,875,000
13. Estado del cliente a través del pipeline
stateDiagram-v2
[*] --> registrado : POST /register
registrado --> registroCompleto : Phase 0 ok\n[registro_completado_en set,\ncupo_asignado from estrato]
registroCompleto --> enValidacion : Phase 1 START\n[legal_check START event]
enValidacion --> validacionParcial : at least 1 FINISH_SUCCESS\nthis month
validacionParcial --> aprobado : >= 5 ProcessTypes\nwith latest = FINISH_SUCCESS\n[cupo_vence_en set,\nvcard set in SHIVAM]
aprobado --> validacionParcial : month rollover\n[events from prior months\nno longer count]
aprobado --> rechazado : a phase fails again\n[latest becomes FINISH_UNSUCCESS]
rechazado --> validacionParcial : phase re-attempted\n[new FINISH_SUCCESS]
aprobado --> expirado : cupo_vence_en past
expirado --> validacionParcial : visit pipeline again
aprobado --> [*] : never (terminal until expiration)
Esta es la perspectiva del cliente sobre el pipeline. El estado aprobado es el único en el que EnsureClienteRegistroCompleto permite acceso a /checkout.
La transición aprobado -> validacionParcial al cambio de mes merece un señalamiento: a la medianoche del día 1, cada cliente previamente-aprobado pierde la aprobación porque la consulta >= 5 filtra por inicio del mes actual. Mantienen su cupo_vence_en hasta que esa fecha pase, y EnsureClienteRegistroCompleto lee cupo_vence_en directamente — así que un cliente cuyo cupo_vence_en era el 5to de este mes todavía puede comprar hasta el 5to, incluso si su historial de eventos ha rolado.
Después del 5to, cupo_vence_en está pasado, el middleware los redirige a /usuario/perfil, y deben re-correr el pipeline.
Este comportamiento de refresco mensual es el latido del modelo de riesgo crediticio de Mi Plante.
14. Orden de lectura para internalizar esto
El orden de lectura para entender completamente la aprobación de crédito:
routes/web.phplíneas 55-84 — todos los endpoints de crédito.app/Enum/AprobarCupo/ProcessType.php+EventType.php— el vocabulario.app/Models/AprobarCupoEvento.php— la forma de la fila.app/Services/AprobarCupoService.php— las consultas de límite-diario + aprobación-final.app/Http/Middleware/CheckIntentosLimiteDiarios.php— cómo se aplica el límite.app/Http/Controllers/AprobarCliente/AprobarCupoController.php— cada endpoint de fase.app/Services/LegalCheckService.php— Fase 1, la llamada a TransUnion.app/Services/IdentityValidationService.php— Fases 2-5 en un solo service grande.app/Services/HDCValidationService.php— Fase 6.app/Services/ExtenderCupoService.php— lógica de extensión de la Fase 7.app/Services/DataCreditoService.php— el cliente DataCrédito (token, caching, retry-on-401).app/Services/CerticamaraService.php— solo la Fase 7 no toca esto; es el puente al ePagaré usado en el flujo de venta.app/Listeners/StoreValidationLog.php— cómo los eventosValidationLogse vuelven filas DB.app/Providers/AppServiceProvider.php— los cinco macros HTTP.docs/audit/12-credit-approval-workflow-diagram.md— el workflow completo de la auditoría.docs/audit/16-deep-validation-study.md— cada bypass confirmado.
Después de este orden de lectura deberías poder:
- Leer cualquier fila en
aprobar_cupo_eventosy explicar qué fase la produjo. - Predecir si un cliente será aprobado dado su log de eventos este mes.
- Identificar qué API externa es responsable de cualquier modo de fallo dado.
- Nombrar los huecos de seguridad y explicar la forma del fix para cada uno.
15. Referencias cruzadas
01-from-30000-feet.md— el mapa del código.02-trace-a-request.md— el ciclo de vida de petición (la Fase 1 es el ejemplo).03-trace-a-sale.md— qué pasa después de que un cliente está aprobado.docs/audit/12-credit-approval-workflow-diagram.md— fuente primaria de la auditoría.docs/audit/16-deep-validation-study.md— inventario de bypasses + bugs.docs/audit/10-external-integrations-map.md— credenciales y endpoints.docs/onboarding/04-the-landmines/— write-ups enfocados de cada mina identificada arriba.