Saltearse al contenido

Trazar una Aprobación de Crédito

El pipeline de aprobación — siete puertas

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ón 3+ 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 en app/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)

CasoValorFase
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)

CasoValorSignificado
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:

MiddlewareRutasPropósito
grupo web (default + adiciones Mi Plante)todasSesiones, CSRF, shared props Inertia
authTodos los endpoints de créditoConfirma que el cliente está logueado (guard: web)
check_intentos_limite_diariosFases 1-6 (NO Fase 7)Aborta con 422 si >2 eventos LEGAL_CHECK / START hoy

Nota lo que no está aquí:

  • cliente_registro_completo NO 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 registros cliente y persona son cargables. Si persona es null, LegalCheckService lanza.
  • Sin middleware requires_otp_verified. La Fase 5 (preguntas) sí verifica un validacion cacheado, 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() dispara START; AprobarCupoController::identityValidation() dispara FINISH_SUCCESS o FINISH_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:

  1. El orden no importa. Las fases pueden hacerse en cualquier orden; la consulta solo mira el evento más reciente por proceso.
  2. 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_cuestionario solo tiene 5 fases (1, 2, 3, 4, 6) y eso cumple exactamente el threshold.
  3. 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_check es FINISH_UNSUCCESS y el proceso no cuenta.
  4. 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_CHECK siempre sea uno de los 5.
  • O requerir que HDC_VALIDATION siempre 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 validarSiElClienteTieneSuCupoAprobado para 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 CheckIntentosLimiteDiarios está en cada fase excepto la Fase 7 (por routes/web.php:68-76). La Fase 7 es /cupo/aprobar y 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

FaseClase ServiceAPI ExternaAuthCachéTimeout
0EmcaliMembresiaService + ClienteServiceEMCALI MembresiasNinguna (API abierta)24h (éxito + errores cliente)30s
1LegalCheckServiceTransUnion LegalCheckHTTP BasicNinguna300s
2IdentityValidationService::validarIdentidadExperian Okta + CrossCoreOAuth password grant{userId}.validacion 60 min300s
3IdentityValidationService::generarOTPExperian CrossCore (/otp/initialize, /otp/evaluation)OAuthtransaccion_otp_id 30 min, requiere_cuestionario 30 min300s
4IdentityValidationService::verificarOTPExperian CrossCore (/otp/verify)OAuthNinguna (lee caché)300s
5aIdentityValidationService::generarCuestionarioExperian CrossCore (/identificacion/preguntas)OAuthcuestionario_id 30 min, registro_cuestionario 30 min300s
5bIdentityValidationService::verificarCuestionarioExperian CrossCore (/identificacion/verificar)OAuthNinguna300s
6HDCValidationService + DataCreditoServiceDataCrédito HDC PlusOAuth client credentialsToken 590s; respuesta HDC 24h60s
7AprobarCupoService + ExtenderCupoService + CreditoServiceEMCALI + DataCrédito + Core Crédito SHIVAMMixtoEMCALI 24h, DataCrédito 24hmixto

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.

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

FaseModo de falloQué pasa
0EMCALI caídoValidationException “Error al consultar Emcali” — la UI muestra error, el usuario puede reintentar
0Facturas/dirección no coincidenValidationException — el usuario re-envía
0Estrato no soportado (sin cupo configurado)ValidationException — terminal
1Error HTTP de TransUnionValidationException — la UI muestra error genérico, FINISH_UNSUCCESS logueado, reintento cuenta hacia límite diario
1Similitud de nombre < 70%200 con success: false, FINISH_UNSUCCESS logueado, mensaje mostrado
1Documento no VIGENTEIgual que arriba
1Hallazgo en lista legalIgual que arriba — pero esto es regulatorio; el usuario no puede continuar, reintentar no ayuda
13er intento hoy422 del middleware — debe esperar hasta mañana
2Fallo de token OAuthExcepció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.
2Experian resultado = “09” (límite diario de su lado)DTO de error, FINISH_UNSUCCESS logueado — usuario espera
2Otro resultadoDTO de error, FINISH_UNSUCCESS — usuario reintenta
3validacion cacheada faltante (TTL expirado)DTO de error “Realice la validacion primero” — reiniciar desde Fase 2
3OTP 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
4Código OTP inválidoDTO de error — el usuario re-ingresa; puede solicitar nuevo OTP vía Fase 3
4resultadoValidacion != “1”DTO de error — Experian dice “no”
5acó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
5baprobacion falseDTO de error — el cuestionario falló, terminal para esta ronda
6Fallo HTTP de DataCréditoRetorna false, FINISH_UNSUCCESS, reintento posible
6responseCode != 13Retorna false, FINISH_UNSUCCESS
7< 5 tipos de proceso exitososValidationException “El cliente no tiene su cupo aprobado” — debe completar las fases faltantes
7Falla 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
7SHIVAM (Core Credito) falla al registrar el clienteActualmente 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:

  1. Decide el ProcessType. Añade un caso a app/Enum/AprobarCupo/ProcessType.php.

  2. 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).

  3. Crea el Service. Nuevo service bajo app/Services/. Inyecta cualquier API externa vía un Http::macro() registrado en AppServiceProvider.

  4. *Crea el DTO Result. Bajo app/DTOs/<NewDomain>/. Usa ResultTrait para hasError()/getError()/getData().

  5. Crea el método del Controller. Añade un método en AprobarCupoController (o un nuevo controller bajo app/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,
    ]);
    }
  6. Registra la ruta. Añade a routes/web.php bajo el bloque Route::middleware('check_intentos_limite_diarios')->group(...) en la línea 68. Usa un nombre como user.aprobar-cupo.new-step.

  7. Actualiza el threshold (si es obligatorio). Si el nuevo paso debe ser requerido, sube validarSiElClienteTieneSuCupoAprobado de >= 5 a 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.

  8. Frontend. Añade el paso a la máquina de estados Vue ModalValidate. Ajusta el indicador de progreso.

  9. Tests. Añade tests Feature bajo tests/Feature/AprobarCliente/. Usa Http::fake() para la API externa.

  10. Documenta las nuevas env vars. Actualiza .env.example y docs/onboarding/03-development-setup/.

  11. 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:

  1. routes/web.php líneas 55-84 — todos los endpoints de crédito.
  2. app/Enum/AprobarCupo/ProcessType.php + EventType.php — el vocabulario.
  3. app/Models/AprobarCupoEvento.php — la forma de la fila.
  4. app/Services/AprobarCupoService.php — las consultas de límite-diario + aprobación-final.
  5. app/Http/Middleware/CheckIntentosLimiteDiarios.php — cómo se aplica el límite.
  6. app/Http/Controllers/AprobarCliente/AprobarCupoController.php — cada endpoint de fase.
  7. app/Services/LegalCheckService.php — Fase 1, la llamada a TransUnion.
  8. app/Services/IdentityValidationService.php — Fases 2-5 en un solo service grande.
  9. app/Services/HDCValidationService.php — Fase 6.
  10. app/Services/ExtenderCupoService.php — lógica de extensión de la Fase 7.
  11. app/Services/DataCreditoService.php — el cliente DataCrédito (token, caching, retry-on-401).
  12. app/Services/CerticamaraService.php — solo la Fase 7 no toca esto; es el puente al ePagaré usado en el flujo de venta.
  13. app/Listeners/StoreValidationLog.php — cómo los eventos ValidationLog se vuelven filas DB.
  14. app/Providers/AppServiceProvider.php — los cinco macros HTTP.
  15. docs/audit/12-credit-approval-workflow-diagram.md — el workflow completo de la auditoría.
  16. 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_eventos y 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.