12 - Diagrama del Flujo de Aprobacion de Credito
Validated Corrections
- El pipeline esta bien documentado como proceso ideal, pero el backend actual no exige una secuencia dura de todas las fases para aprobar el cupo; el gate (
AprobarCupoService::validarSiElClienteTieneSuCupoAprobado()enapp/Services/AprobarCupoService.php:17-38) valida que>= 5tipos de proceso (ProcessType) tengan su evento mas reciente del mes calendario en curso igual aFINISH_SUCCESS. El orden de ejecucion entre fases no se valida. LegalCheckServiceintegra con TransUnion, no con Experian. El UML oficial (docs/DIAGRAMA_APROBAR_CUPO_UML.md) describe el servicio bajo “Expirian CrossCore”, peroLegalCheckService.php:37llamaHttp::transunion()->post('/ws/LegalcheckWSRest/legalcheck/consulta', ...)yHttp::transunion()esta registrado enapp/Providers/AppServiceProvider.php:19-30con basic auth contraTRANSUNION_API_URL. Cuando UML y codigo discrepan, el codigo manda.HDCValidationServiceno es un placeholder. El UML lo describe como “Actualmente devuelve true (placeholder para implementacion futura)”; en realidad el servicio (app/Services/HDCValidationService.php) construye persona/tipo/apellido, consulta DataCredito viaDataCreditoService::consultarHistorialPorTipoDocumento(), y comparaReportHDCplus.productResult.responseCode === 13. Devuelvefalseante falla HTTP, ausencia de persona, exception, o codigo distinto de 13.- El cuestionario no esta completamente blindado por backend detras de un OTP ya verificado y del flag
requiere_cuestionario; varias precondiciones reales dependen solo de cache previa. - El score minimo del cuestionario y el score minimo de HDC estan comentados en codigo (
IdentityValidationService.php:577-584, mencion delquestionnaire_min_score); no se aplican hoy. - La fase final debe leerse como “aprobacion posible si las evidencias minimas del backend alcanzan el umbral”, no como garantia de que todas las etapas del diagrama ocurrieron exactamente en ese orden.
- Este documento sigue siendo de los mas fuertes del set, pero necesita dejar mas claro el desfase entre workflow ideal y enforcement real.
0. Anatomia del Controlador
AprobarCupoController (app/Http/Controllers/AprobarCliente/AprobarCupoController.php) es el orquestador para las fases 1-7. Su constructor inyecta 6 servicios (5 colaboradores distintos mas ClienteService compartido con la fase 0), y la clase expone 8 endpoints:
Servicios inyectados:
ClienteService(usado por la fase 7 paraactualizar(...))AprobarCupoService(gate + limitador diario)LegalCheckService(fase 1)IdentityValidationService(fases 2-5b)HDCValidationService(fase 6)ExtenderCupoService(extension de cupo de la fase 7)
El diagrama de clases UML lista 5 servicios inyectados (omite
ExtenderCupoService). El constructor real inyecta los 6.
Endpoints:
| # | Metodo | Ruta | Metodo del Controlador | Fase |
|---|---|---|---|---|
| 1 | GET | /usuario/cupo/legal-check | legalCheck | 1 |
| 2 | GET | /usuario/cupo/identity-validation | identityValidation | 2 |
| 3 | GET | /usuario/cupo/identity-validation-generate-otp | identityValidationGenerateOTPCode | 3 |
| 4 | POST | /usuario/cupo/identity-validation-verify-otp | identityValidationVerifyOTPCode | 4 |
| 5 | GET | /usuario/cupo/identity-validation-generate-questions | identityValidationGenerateQuestions | 5a |
| 6 | POST | /usuario/cupo/identity-validation-verify-questions | identityValidationVerifyQuestions | 5b |
| 7 | GET | /usuario/cupo/hdc-validation | hdcValidation | 6 |
| 8 | GET | /usuario/cupo/aprobar | aprobarCupo | 7 |
Los endpoints 1-7 estan protegidos por el grupo de middleware check_intentos_limite_diarios (routes/web.php:68-76). El endpoint 8 (/usuario/cupo/aprobar) esta fuera de ese grupo de middleware (routes/web.php:78), por lo que el limitador de intentos diarios no aplica a la llamada de aprobacion final.
Patron Result DTO. Cada servicio en este flujo devuelve un DTO *Result que usa ResultTrait (app/Traits/ResultTrait.php): LegalCheckResult, IdentityValidationResult, GenerateOTPResult, VerifyOTPResult, GenerateQuestionnarieResult, VerifyQuestionnarieResult. El trait le da a cada DTO hasError(): bool, getError(): string, getData(): array, toArray(): array, y fabricas estaticas success(array) / error(string, array). HDCValidationService::manejar() es la excepcion — devuelve un bool crudo, no un DTO Result.
1. Diagrama de Flujo Completo del Pipeline
flowchart TD
START([El cliente registra cuenta]) --> REG_PAGE[Visita /usuario/completar-registro]
%% ===== FASE 0: FINALIZACION DEL REGISTRO =====
subgraph PHASE0 ["Fase 0: Finalizacion del Registro"]
REG_PAGE --> REG_AUTH{Autenticado?}
REG_AUTH -->|No| LOGIN_REDIRECT[Redirige a login]
REG_AUTH -->|Si| REG_ALREADY{Ya completado?<br/>registro_completado_en != null}
REG_ALREADY -->|Si| REDIRECT_PERFIL[Redirige a /usuario/perfil]
REG_ALREADY -->|No| REG_FORM["Enviar formulario de registro:<br/>direccion, barrio, ciudad,<br/>departamento, factura_numero_1, factura_numero_2"]
REG_FORM --> EMCALI_CALL["API EMCALI: consultarMembresia()<br/>GET con cuerpo JSON<br/>Sin auth"]
EMCALI_CALL --> EMCALI_ERR{Error inesperado?}
EMCALI_ERR -->|Si| REG_FAIL_EMCALI[/"ValidationException:<br/>'Error al consultar Emcali'"/]
EMCALI_ERR -->|No| VALIDATE_INVOICES{Validar que las facturas coincidan<br/>con los ultimos 6 meses?}
VALIDATE_INVOICES -->|No| REG_FAIL_DATA[/"ValidationException:<br/>'Datos no coinciden con Emcali'"/]
VALIDATE_INVOICES -->|Si| VALIDATE_ADDRESS{Validar similitud<br/>de direcciones?}
VALIDATE_ADDRESS -->|No| REG_FAIL_DATA
VALIDATE_ADDRESS -->|Si| ASSIGN_CUPO["Asigna cupo por estrato:<br/>E1=2.5M, E2=3M,<br/>E3=3.5M, E4-6=4M"]
ASSIGN_CUPO --> CUPO_FOUND{Cupo encontrado<br/>para estrato?}
CUPO_FOUND -->|No| REG_FAIL_ESTRATO[/"ValidationException:<br/>'No cupo for estrato'"/]
CUPO_FOUND -->|Si| SAVE_REG["Guarda cliente:<br/>cupo_asignado, cupo_disponible,<br/>registro_completado_en = now()"]
SAVE_REG --> REG_COMPLETE([Registro Completo])
end
%% ===== VERIFICACION DE LIMITE DIARIO =====
REG_COMPLETE --> CUPO_FLOW[Visita /usuario/cupo/legal-check]
CUPO_FLOW --> MW_AUTH{Middleware: auth}
MW_AUTH -->|No| LOGIN_REDIRECT2[Redirige a login]
MW_AUTH -->|Si| MW_LIMIT{"Middleware: check_intentos_limite_diarios<br/>Cuenta eventos LEGAL_CHECK START hoy<br/>para este cliente_id"}
MW_LIMIT -->|"> 2 intentos"| LIMIT_FAIL[/"ValidationException:<br/>'Ha superado el limite<br/>de intentos diarios'"/]
MW_LIMIT -->|"<= 2 intentos"| PHASE1_START
%% ===== FASE 1: LEGAL CHECK =====
subgraph PHASE1 ["Fase 1: Legal Check"]
PHASE1_START[LegalCheckService::manejar] --> DOC_TYPE_CHECK{Tipo de<br/>documento valido?}
DOC_TYPE_CHECK -->|No| LC_FAIL_DOC["LegalCheckResult::error<br/>'Tipo de documento no valido'"]
DOC_TYPE_CHECK -->|Si| LC_EVENT_START["Evento: ValidationLog<br/>LEGAL_CHECK / START"]
LC_EVENT_START --> TU_CALL["API TransUnion:<br/>POST /ws/LegalcheckWSRest/<br/>legalcheck/consulta<br/>Basic Auth, timeout 300s"]
TU_CALL --> TU_HTTP_OK{Exito HTTP?}
TU_HTTP_OK -->|No| LC_EVENT_FAIL_HTTP["Evento: LEGAL_CHECK /<br/>FINISH_UNSUCCESS"]
LC_EVENT_FAIL_HTTP --> LC_THROW[/"ValidationException:<br/>'No se pudo completar<br/>la verificacion legal'"/]
TU_HTTP_OK -->|Si| NAME_CHECK{"Similitud de nombre >=<br/>umbral configurado<br/>(por defecto 70%)?"}
NAME_CHECK -->|No| LC_FAIL_NAME["LegalCheckResult::error<br/>'Nombre no coincide'"]
NAME_CHECK -->|Si| DOC_STATUS{"Estado del documento<br/>== 'VIGENTE'?"}
DOC_STATUS -->|No| LC_FAIL_STATUS["LegalCheckResult::error<br/>'Documento no vigente'"]
DOC_STATUS -->|Si| LEGAL_LISTS{Hallazgos en<br/>listas legales?}
LEGAL_LISTS -->|Si| LC_FAIL_LEGAL["LegalCheckResult::error<br/>'Hallazgos legales'"]
LEGAL_LISTS -->|No| LC_SUCCESS["LegalCheckResult::success<br/>completado = true"]
end
LC_FAIL_DOC --> LC_EVENT_UNSUCCESS_1["Evento: LEGAL_CHECK / FINISH_UNSUCCESS"]
LC_FAIL_NAME --> LC_EVENT_UNSUCCESS_1
LC_FAIL_STATUS --> LC_EVENT_UNSUCCESS_1
LC_FAIL_LEGAL --> LC_EVENT_UNSUCCESS_1
LC_SUCCESS --> LC_EVENT_SUCCESS_1["Evento: LEGAL_CHECK / FINISH_SUCCESS"]
%% ===== FASE 2: VALIDACION DE IDENTIDAD =====
LC_EVENT_SUCCESS_1 --> PHASE2_START
subgraph PHASE2 ["Fase 2: Validacion de Identidad"]
PHASE2_START["IdentityValidationService::<br/>validarIdentidad"] --> IV_TOKEN["Experian Okta:<br/>POST /oauth2/.../v1/token<br/>OAuth password grant"]
IV_TOKEN --> IV_TOKEN_OK{Token obtenido?}
IV_TOKEN_OK -->|No| IV_TOKEN_FAIL[/"Exception lanzada"/]
IV_TOKEN_OK -->|Si| IV_EVENT_START["Evento: ValidationLog<br/>IDENTITY_VALIDATION / START"]
IV_EVENT_START --> IV_CALL["Experian CrossCore:<br/>POST /op/evidentemaster/<br/>v1/identificacion/validar<br/>Headers OAuth + API key"]
IV_CALL --> IV_RESULT{"resultado == '01'?"}
IV_RESULT -->|"'09'"| IV_FAIL_LIMIT["Error: 'Limite de<br/>intentos por dia'"]
IV_RESULT -->|"Otro"| IV_FAIL_GENERIC["Error: 'No fue posible<br/>validar la informacion'"]
IV_RESULT -->|"'01'"| IV_CACHE["Cache regValidacion<br/>TTL 60 min"]
IV_CACHE --> IV_SUCCESS["IdentityValidationResult::success"]
end
IV_FAIL_LIMIT --> IV_EVENT_UNSUCCESS["Evento: IDENTITY_VALIDATION /<br/>FINISH_UNSUCCESS"]
IV_FAIL_GENERIC --> IV_EVENT_UNSUCCESS
IV_SUCCESS --> IV_EVENT_SUCCESS["Evento: IDENTITY_VALIDATION /<br/>FINISH_SUCCESS"]
%% ===== FASE 3: GENERACION DE OTP =====
IV_EVENT_SUCCESS --> PHASE3_START
subgraph PHASE3 ["Fase 3: Generacion de OTP"]
PHASE3_START["IdentityValidationService::<br/>generarOTP"] --> OTP_PRECOND{Existe validacion<br/>en cache?}
OTP_PRECOND -->|No| OTP_FAIL_PRE["Error: 'Realice la<br/>validacion primero'"]
OTP_PRECOND -->|Si| OTP_GEN_TOKEN["Obtener token OAuth fresco"]
OTP_GEN_TOKEN --> OTP_EVENT_START["Evento: IV_OTP_GENERATION / START"]
OTP_EVENT_START --> OTP_INIT["Experian CrossCore:<br/>POST /da/evidente/v3/<br/>otp/initialize"]
OTP_INIT --> OTP_INIT_OK{resultadoOTP<br/>== true?}
OTP_INIT_OK -->|No| OTP_FAIL_INIT["Error: 'No se pudo<br/>generar OTP'"]
OTP_INIT_OK -->|Si| OTP_EVAL["Experian CrossCore:<br/>POST /da/evidente/v3/<br/>otp/evaluation<br/>(envia OTP a correo/telefono)"]
OTP_EVAL --> OTP_SENT{Enviado a correo<br/>O telefono?}
OTP_SENT -->|Ninguno| OTP_FAIL_CONTACT["Error: 'Correo y celular<br/>no coinciden'"]
OTP_SENT -->|Si| OTP_CACHE["Cache:<br/>transaccion_otp_id (30m)<br/>requiere_cuestionario (30m)"]
OTP_CACHE --> OTP_SUCCESS["GenerateOTPResult::success"]
end
OTP_FAIL_PRE --> OTP_EVENT_UNSUCCESS["Evento: IV_OTP_GENERATION /<br/>FINISH_UNSUCCESS"]
OTP_FAIL_INIT --> OTP_EVENT_UNSUCCESS
OTP_FAIL_CONTACT --> OTP_EVENT_UNSUCCESS
OTP_SUCCESS --> OTP_EVENT_SUCCESS["Evento: IV_OTP_GENERATION /<br/>FINISH_SUCCESS"]
%% ===== FASE 4: VERIFICACION DE OTP =====
OTP_EVENT_SUCCESS --> PHASE4_START
subgraph PHASE4 ["Fase 4: Verificacion de OTP"]
PHASE4_START["IdentityValidationService::<br/>verificarOTP"] --> VOTP_PRECOND{Validacion cacheada<br/>Y transaccion_otp_id?}
VOTP_PRECOND -->|No| VOTP_FAIL_PRE["Error: Estado<br/>previo faltante"]
VOTP_PRECOND -->|Si| VOTP_HASH["Hash SHA-256 del<br/>codigo OTP enviado"]
VOTP_HASH --> VOTP_EVENT_START["Evento: IV_OTP_VERIFICATION / START"]
VOTP_EVENT_START --> VOTP_CALL["Experian CrossCore:<br/>POST /da/evidente/v3/<br/>otp/verify"]
VOTP_CALL --> VOTP_VALID{codigoValido<br/>== true?}
VOTP_VALID -->|No| VOTP_FAIL_CODE["Error: 'Codigo OTP<br/>no valido'"]
VOTP_VALID -->|Si| VOTP_APPROVED{resultadoValidacion<br/>== '1'?}
VOTP_APPROVED -->|No| VOTP_FAIL_APPROVAL["Error: 'Codigo OTP<br/>no aprobado'"]
VOTP_APPROVED -->|Si| VOTP_SUCCESS["VerifyOTPResult::success<br/>+ flag requiere_cuestionario"]
end
VOTP_FAIL_PRE --> VOTP_EVENT_UNSUCCESS["Evento: IV_OTP_VERIFICATION /<br/>FINISH_UNSUCCESS"]
VOTP_FAIL_CODE --> VOTP_EVENT_UNSUCCESS
VOTP_FAIL_APPROVAL --> VOTP_EVENT_UNSUCCESS
VOTP_SUCCESS --> VOTP_EVENT_SUCCESS["Evento: IV_OTP_VERIFICATION /<br/>FINISH_SUCCESS"]
%% ===== FASE 5: CUESTIONARIO (CONDICIONAL) =====
VOTP_EVENT_SUCCESS --> Q_REQUIRED{requiere_cuestionario<br/>== true?}
Q_REQUIRED -->|No| PHASE6_ENTRY
Q_REQUIRED -->|Si| PHASE5_START
subgraph PHASE5 ["Fase 5: Preguntas de Conocimiento (Condicional)"]
PHASE5_START["IdentityValidationService::<br/>generarCuestionario"] --> Q_PRECOND{Existe validacion<br/>en cache?}
Q_PRECOND -->|No| Q_FAIL_PRE["Error: Estado<br/>previo faltante"]
Q_PRECOND -->|Si| Q_EVENT_START["Evento: IV_QUESTION_GENERATION /<br/>START"]
Q_EVENT_START --> Q_CALL["Experian CrossCore:<br/>POST /op/evidentemaster/<br/>v1/identificacion/preguntas"]
Q_CALL --> Q_HTTP_OK{HTTP exitoso?}
Q_HTTP_OK -->|No| Q_FAIL_HTTP["Error: 'No se pudo<br/>generar cuestionario'"]
Q_HTTP_OK -->|Si| Q_RESULTADO{"codigo resultado?"}
Q_RESULTADO -->|"00"| Q_FAIL_INACTIVE["Error: 'Cuestionario<br/>no activo'"]
Q_RESULTADO -->|"02"| Q_FAIL_ALREADY["Error: 'Preguntas<br/>ya generadas'"]
Q_RESULTADO -->|"07"| Q_FAIL_INSUFFICIENT["Error: 'Insuficientes<br/>preguntas'"]
Q_RESULTADO -->|"10/11/12"| Q_FAIL_ATTEMPTS["Error: 'Intentos<br/>agotados'"]
Q_RESULTADO -->|"01 / otro"| Q_CACHE["Cache: cuestionario_id (30m)<br/>registro_cuestionario (30m)"]
Q_CACHE --> Q_PARSE["Parsea las preguntas<br/>con opciones de respuesta"]
Q_PARSE --> Q_HAS_QUESTIONS{Preguntas<br/>devueltas?}
Q_HAS_QUESTIONS -->|No| Q_FAIL_EMPTY["Error: 'No se<br/>encontraron preguntas'"]
Q_HAS_QUESTIONS -->|Si| Q_SUCCESS["GenerateQuestionnarieResult::<br/>success"]
end
Q_FAIL_PRE --> Q_EVENT_UNSUCCESS_GEN["Evento: IV_QUESTION_GENERATION /<br/>FINISH_UNSUCCESS"]
Q_FAIL_HTTP --> Q_EVENT_UNSUCCESS_GEN
Q_FAIL_INACTIVE --> Q_EVENT_UNSUCCESS_GEN
Q_FAIL_ALREADY --> Q_EVENT_UNSUCCESS_GEN
Q_FAIL_INSUFFICIENT --> Q_EVENT_UNSUCCESS_GEN
Q_FAIL_ATTEMPTS --> Q_EVENT_UNSUCCESS_GEN
Q_FAIL_EMPTY --> Q_EVENT_UNSUCCESS_GEN
Q_SUCCESS --> Q_EVENT_SUCCESS_GEN["Evento: IV_QUESTION_GENERATION /<br/>FINISH_SUCCESS"]
Q_EVENT_SUCCESS_GEN --> PHASE5B_START
subgraph PHASE5B ["Fase 5b: Verificacion del Cuestionario"]
PHASE5B_START["El usuario responde las preguntas<br/>POST respuestas[]"] --> QV_PRECOND{cuestionario_id cacheado<br/>Y registro?}
QV_PRECOND -->|No| QV_FAIL_PRE["Error: Estado del<br/>cuestionario faltante"]
QV_PRECOND -->|Si| QV_EVENT_START["Evento: IV_QUESTION_VERIFICATION /<br/>START"]
QV_EVENT_START --> QV_CALL["Experian CrossCore:<br/>POST /op/evidentemaster/<br/>v1/identificacion/verificar"]
QV_CALL --> QV_RESULTADO{resultado<br/>== true?}
QV_RESULTADO -->|No| QV_FAIL_RESULT["Error: 'Verificacion<br/>fallo'"]
QV_RESULTADO -->|Si| QV_COMPLETE{preguntasCompletas<br/>== true?}
QV_COMPLETE -->|No| QV_FAIL_INCOMPLETE["Error: 'Preguntas<br/>incompletas'"]
QV_COMPLETE -->|Si| QV_APPROVED{aprobacion<br/>== true?}
QV_APPROVED -->|No| QV_FAIL_REJECTED["Error: 'Cuestionario<br/>no aprobado'"]
QV_APPROVED -->|Si| QV_SUCCESS["VerifyQuestionnarieResult::<br/>success (approved: true)"]
end
QV_FAIL_PRE --> QV_EVENT_UNSUCCESS["Evento: IV_QUESTION_VERIFICATION /<br/>FINISH_UNSUCCESS"]
QV_FAIL_RESULT --> QV_EVENT_UNSUCCESS
QV_FAIL_INCOMPLETE --> QV_EVENT_UNSUCCESS
QV_FAIL_REJECTED --> QV_EVENT_UNSUCCESS
QV_SUCCESS --> QV_EVENT_SUCCESS["Evento: IV_QUESTION_VERIFICATION /<br/>FINISH_SUCCESS"]
QV_EVENT_SUCCESS --> PHASE6_ENTRY
%% ===== FASE 6: VALIDACION HDC =====
subgraph PHASE6 ["Fase 6: Validacion HDC (Historial Crediticio)"]
PHASE6_ENTRY["HDCValidationService::manejar"] --> HDC_EVENT_START["Evento: HDC_VALIDATION / START"]
HDC_EVENT_START --> HDC_USER{Usuario autenticado<br/>con persona?}
HDC_USER -->|No| HDC_FAIL_USER["Devuelve false"]
HDC_USER -->|Si| HDC_CALL["API DataCredito:<br/>POST /cs/credit-history/<br/>v1/hdcplus<br/>OAuth Bearer + claves API"]
HDC_CALL --> HDC_HTTP_OK{HTTP exitoso?}
HDC_HTTP_OK -->|No| HDC_FAIL_HTTP["Devuelve false"]
HDC_HTTP_OK -->|Si| HDC_CODE{"responseCode == 13?"}
HDC_CODE -->|No| HDC_FAIL_CODE["Devuelve false"]
HDC_CODE -->|Si| HDC_SUCCESS["Devuelve true"]
end
HDC_FAIL_USER --> HDC_EVENT_UNSUCCESS["Evento: HDC_VALIDATION /<br/>FINISH_UNSUCCESS"]
HDC_FAIL_HTTP --> HDC_EVENT_UNSUCCESS
HDC_FAIL_CODE --> HDC_EVENT_UNSUCCESS
HDC_SUCCESS --> HDC_EVENT_SUCCESS["Evento: HDC_VALIDATION /<br/>FINISH_SUCCESS"]
%% ===== FASE 7: APROBACION DE CREDITO =====
HDC_EVENT_SUCCESS --> PHASE7_START
subgraph PHASE7 ["Fase 7: Aprobacion de Credito y Extension de Cupo"]
PHASE7_START["AprobarCupoController::<br/>aprobarCupo"] --> VALIDATE_ALL{"Los 5+ tipos de proceso<br/>tienen ultimo evento =<br/>FINISH_SUCCESS<br/>este mes?"}
VALIDATE_ALL -->|No| APPROVE_FAIL[/"ValidationException:<br/>'No tiene su cupo aprobado'"/]
VALIDATE_ALL -->|Si| SET_EXPIRY["Asigna cupo_vence_en:<br/>proximo mes, dia 6, fin del dia<br/>(startOfMonth + 5 dias)"]
SET_EXPIRY --> EXTEND_CUPO["ExtenderCupoService::extender()"]
EXTEND_CUPO --> EXT_EMCALI["API EMCALI: consultarMembresia()"]
EXT_EMCALI --> EXT_EMCALI_OK{Exito EMCALI?}
EXT_EMCALI_OK -->|No| NO_EXTENSION["No se aplica extension"]
EXT_EMCALI_OK -->|Si| EXT_MAX_CUPO{"Cupo aprobado EMCALI ><br/>cupo base del estrato?"}
EXT_MAX_CUPO -->|No| NO_EXTENSION
EXT_MAX_CUPO -->|Si| EXT_DC["API DataCredito:<br/>consultarHistorialPorTipoDocumento()"]
EXT_DC --> EXT_SCORE["Obtener score de<br/>models[0].scoreValue"]
EXT_SCORE --> EXT_PCT["Mapear score a % de extension:<br/>0-350=0%, 351-470=25%,<br/>471-670=50%, 671-815=75%,<br/>816-1000=100%, null=25%"]
EXT_PCT --> EXT_CALC["incremento = (cupo_emcali - cupo_base) * pct<br/>Actualiza cupo_asignado y cupo_disponible"]
EXT_CALC --> EXT_CORE["Core Credito SHIVAM:<br/>crearClienteEnCredito(force=true)<br/>Sincroniza nuevo cupo al core bancario"]
EXT_CORE --> APPROVED([Credito Aprobado])
NO_EXTENSION --> APPROVED
end
%% ===== POST-APROBACION: FLUJO DE COMPRA =====
APPROVED --> PURCHASE_FLOW["El cliente ahora puede comprar<br/>(protegido por cliente_registro_completo<br/>y verificar_cliente_presenta_mora)"]
PURCHASE_FLOW --> PAGARE_FLOW["En el checkout, se crea el pagare<br/>digital de Certicamara<br/>y se firma via webhook"]
2. Desglose Fase por Fase
Fase 0: Finalizacion del Registro
Ruta: POST /usuario/completar-registro
Controlador: CompletarRegistroController::store()
Middleware: auth, verified (ruta GET render); solo auth (ruta POST store). Nota: La ruta POST store no tiene el middleware verified, lo que significa que un usuario con correo no verificado podria potencialmente enviar el formulario de registro via POST directo.
Servicio Externo: API EMCALI Membresias
| Paso | Accion | Detalle |
|---|---|---|
| 1 | Valida entrada del formulario | direccion, barrio, ciudad, departamento, factura_numero_1, factura_numero_2 |
| 2 | Guarda: ya completado | Si registro_completado_en no es null, devuelve 400 |
| 3 | Llama a EMCALI | EmcaliMembresiaService::consultarMembresia(numero_contrato) |
| 4 | Valida facturas | ClienteService::validarFacturasDeUltimoSeisMeses() — compara facturas enviadas contra los datos de EMCALI |
| 5 | Valida direccion | ClienteService::validarSimilitudDeDirecciones() — match difuso entre la direccion enviada y la de EMCALI |
| 6 | Asigna cupo por estrato | Estrato 1 = $2,500,000; Estrato 2 = $3,000,000; Estrato 3 = $3,500,000; Estrato 4-6 = $4,000,000 |
| 7 | Guarda cliente | Setea cupo_asignado, cupo_disponible, registro_completado_en = now() |
Rutas de error:
- Error inesperado de EMCALI:
ValidationException - Discrepancia de facturas o direccion:
ValidationException - Sin cupo para el estrato:
ValidationException
Fase 1: Legal Check
Ruta: GET /usuario/cupo/legal-check
Controlador: AprobarCupoController::legalCheck()
Servicio: LegalCheckService::manejar()
Servicio Externo: API TransUnion LegalCheck (el UML en docs/DIAGRAMA_APROBAR_CUPO_UML.md atribuye el Legal Check a Experian, pero LegalCheckService.php:37 llama Http::transunion()->post('/ws/LegalcheckWSRest/legalcheck/consulta', ...), y la macro en app/Providers/AppServiceProvider.php:19-30 esta cableada a services.transunion.endpoint con Basic Auth. Codigo manda.)
| Paso | Accion | Detalle |
|---|---|---|
| 1 | Mapea el tipo de documento | Convierte tipo_dni al codigo de TransUnion via config |
| 2 | Loguea evento START | ValidationLog(LEGAL_CHECK, START) |
| 3 | Llama a TransUnion | POST /ws/LegalcheckWSRest/legalcheck/consulta con Basic Auth |
| 4 | Valida nombre | similar_text() comparando nombre del usuario vs. nombre de TransUnion, umbral desde config (por defecto 70%) |
| 5 | Verifica estado del documento | Debe ser "VIGENTE" |
| 6 | Verifica listas legales | Itera los IDs de listas restringidas configuradas; falla si algun finding === true |
| 7 | Loguea evento de resultado | FINISH_SUCCESS o FINISH_UNSUCCESS |
Rutas de error:
- Tipo de documento invalido: devuelve DTO de error (sin llamada externa)
- Falla HTTP: se lanza
ValidationException - Discrepancia de nombre, documento no vigente, hallazgos legales: devuelve
LegalCheckResult::error
Fase 2: Validacion de Identidad
Ruta: GET /usuario/cupo/identity-validation
Controlador: AprobarCupoController::identityValidation()
Servicio: IdentityValidationService::validarIdentidad()
Servicio Externo: Experian CrossCore (Evidente) via Okta OAuth
| Paso | Accion | Detalle |
|---|---|---|
| 1 | Obtiene token OAuth | POST /oauth2/.../v1/token (password grant, scope expco_evidente_master) |
| 2 | Loguea evento START | ValidationLog(IDENTITY_VALIDATION, START) |
| 3 | Llama a Experian | POST /op/evidentemaster/v1/identificacion/validar |
| 4 | Evalua resultado | "01" = exito; "09" = limite diario excedido; otro = falla generica |
| 5 | Cachea el estado de validacion | regValidacion cacheado bajo {userId}.validacion por 60 minutos |
| 6 | Loguea evento de resultado | FINISH_SUCCESS o FINISH_UNSUCCESS |
Rutas de error:
- Falla en la generacion del token: exception lanzada (unsupported_grant_type, invalid_grant, invalid_scope)
- Tipo de documento invalido: DTO de error devuelto
- Resultado != “01”: DTO de error con mensaje visible para el usuario
Fase 3: Generacion de OTP
Ruta: GET /usuario/cupo/identity-validation-generate-otp
Controlador: AprobarCupoController::identityValidationGenerateOTPCode()
Servicio: IdentityValidationService::generarOTP()
Servicio Externo: Experian CrossCore (Evidente) — 2 llamadas secuenciales a la API
| Paso | Accion | Detalle |
|---|---|---|
| 1 | Verifica precondiciones | validacion cacheada debe existir (de la Fase 2) |
| 2 | Obtiene token OAuth fresco | Token nuevo por llamada (sin cache) |
| 3 | Loguea evento START | ValidationLog(IV_OTP_GENERATION, START) |
| 4 | Inicializa OTP | POST /da/evidente/v3/otp/initialize |
| 5 | Evalua riesgo y envia OTP | POST /da/evidente/v3/otp/evaluation (envia OTP a correo/telefono) |
| 6 | Cachea estado | transaccion_otp_id (30 min), requiere_cuestionario (30 min) |
| 7 | Loguea evento de resultado | FINISH_SUCCESS o FINISH_UNSUCCESS |
Rutas de error:
- Falta cache de validacion previa: DTO de error
- Falla en la inicializacion del OTP: DTO de error
- OTP no enviado ni a correo ni a telefono: DTO de error (discrepancia de info de contacto)
Fase 4: Verificacion de OTP
Ruta: POST /usuario/cupo/identity-validation-verify-otp
Controlador: AprobarCupoController::identityValidationVerifyOTPCode()
Servicio: IdentityValidationService::verificarOTP()
Servicio Externo: Experian CrossCore (Evidente)
| Paso | Accion | Detalle |
|---|---|---|
| 1 | Valida entrada | otp_code string requerido |
| 2 | Verifica precondiciones | validacion cacheada y transaccion_otp_id deben existir |
| 3 | Hash de OTP | Hash SHA-256 del codigo enviado |
| 4 | Loguea evento START | ValidationLog(IV_OTP_VERIFICATION, START) |
| 5 | Llama a Experian | POST /da/evidente/v3/otp/verify |
| 6 | Valida respuesta | codigoValido debe ser true Y resultadoValidacion debe ser "1" |
| 7 | Devuelve resultado | Incluye el flag requiere_cuestionario del cache |
| 8 | Loguea evento de resultado | FINISH_SUCCESS o FINISH_UNSUCCESS |
Rutas de error:
- Falta estado cacheado: DTO de error
- Codigo OTP invalido: DTO de error
- OTP no aprobado: DTO de error
Fase 5: Preguntas de Conocimiento (Condicional)
Solo se ejecuta si requiere_cuestionario == true fue seteado durante la generacion del OTP (Fase 3).
Fase 5a: Generacion de Preguntas
Ruta: GET /usuario/cupo/identity-validation-generate-questions
Controlador: AprobarCupoController::identityValidationGenerateQuestions()
Servicio: IdentityValidationService::generarCuestionario()
Servicio Externo: Experian CrossCore (Evidente)
| Paso | Accion | Detalle |
|---|---|---|
| 1 | Verifica precondiciones | validacion cacheada debe existir |
| 2 | Obtiene token OAuth | Token fresco |
| 3 | Loguea evento START | ValidationLog(IV_QUESTION_GENERATION, START) |
| 4 | Llama a Experian | POST /op/evidentemaster/v1/identificacion/preguntas |
| 5 | Evalua codigo de resultado | "00" inactivo, "02" ya generadas, "07" insuficientes, "10"/"11"/"12" limites agotados |
| 6 | Cachea estado | cuestionario_id (30 min), registro_cuestionario (30 min) |
| 7 | Parsea preguntas | Extrae id (orden), texto, y opciones de respuesta |
| 8 | Loguea evento de resultado | FINISH_SUCCESS o FINISH_UNSUCCESS |
Rutas de error:
- Falta cache de validacion: DTO de error
- Falla HTTP: DTO de error
- Codigos de resultado 00/02/07/10/11/12: mensajes de error especificos
- Arreglo vacio de preguntas: DTO de error
Fase 5b: Verificacion de Preguntas
Ruta: POST /usuario/cupo/identity-validation-verify-questions
Controlador: AprobarCupoController::identityValidationVerifyQuestions()
Servicio: IdentityValidationService::verificarCuestionario()
Servicio Externo: Experian CrossCore (Evidente)
| Paso | Accion | Detalle |
|---|---|---|
| 1 | Valida entrada | respuestas[] con pregunta_id y respuesta_id por item |
| 2 | Verifica precondiciones | cuestionario_id cacheado y registro_cuestionario deben existir |
| 3 | Obtiene token OAuth | Token fresco |
| 4 | Loguea evento START | ValidationLog(IV_QUESTION_VERIFICATION, START) |
| 5 | Llama a Experian | POST /op/evidentemaster/v1/identificacion/verificar |
| 6 | Valida respuesta | resultado, preguntasCompletas, y aprobacion deben ser todos true |
| 7 | Loguea evento de resultado | FINISH_SUCCESS o FINISH_UNSUCCESS |
Rutas de error:
- Falta estado cacheado del cuestionario: DTO de error
- resultado false: “Verificacion fallo”
- preguntasCompletas false: “Preguntas incompletas”
- aprobacion false: “Cuestionario no aprobado”
Fase 6: Validacion HDC (Historial Crediticio)
Ruta: GET /usuario/cupo/hdc-validation
Controlador: AprobarCupoController::hdcValidation()
Servicio: HDCValidationService::manejar()
Servicio Externo: API DataCredito HDC Plus
El UML oficial (
docs/DIAGRAMA_APROBAR_CUPO_UML.md) etiqueta este servicio como “actualmente devuelve true (placeholder para implementacion futura)”. Esa descripcion esta desactualizada:HDCValidationService::manejar()enapp/Services/HDCValidationService.phprealmente ejecuta la llamada a DataCredito que se ve abajo y devuelve un booleano real derivado del codigo de respuesta. El codigo es la verdad; el UML esta equivocado en este punto.
| Paso | Accion | Detalle |
|---|---|---|
| 1 | Loguea evento START | El controlador dispara ValidationLog(HDC_VALIDATION, START) (AprobarCupoController.php:243). El servicio en si no despacha eventos. |
| 2 | Verifica datos del usuario | Auth::guard()->user() debe devolver un usuario; $user->persona debe estar seteado; tipo_dni, dni, y Str::before($user->apellidos, ' ') deben ser todos no-vacios. Cualquier pieza faltante loguea y devuelve false. |
| 3 | Llama a DataCredito | DataCreditoService::consultarHistorialPorTipoDocumento(tipoDocumento, dni, apellido) — bajo el env local esto se convierte en un HTTP GET a https://test.miplante.com/api/v1/datacredito/historial; en otro caso POST /cs/credit-history/v1/hdcplus con OAuth Bearer + claves API. |
| 4 | Evalua respuesta | data_get($data, 'ReportHDCplus.productResult.responseCode') debe ser igual a 13. La constante HDCValidationService::RESPONSE_CODE_EXITOSO = 13 es el unico gate de exito. No se exige un score minimo. |
| 5 | Loguea evento de resultado | El controlador dispara FINISH_SUCCESS o FINISH_UNSUCCESS basado en el bool devuelto por el servicio. |
Rutas de error (todas devuelven false, todas se loguean via Log::error/warning):
- No hay usuario autenticado
- No hay registro persona
- Datos incompletos (faltan
tipo_dni,dni, o el primer segmento deapellidos) - Falla HTTP de DataCredito
responseCode != 13- Cualquier exception durante la llamada
Fase 7: Aprobacion de Credito y Extension de Cupo
Ruta: GET /usuario/cupo/aprobar
Controlador: AprobarCupoController::aprobarCupo()
Servicios: AprobarCupoService, ClienteService, ExtenderCupoService
Servicios Externos: EMCALI Membresias, DataCredito, Core Credito SHIVAM
| Paso | Accion | Detalle |
|---|---|---|
| 1 | Valida fases aprobadas | AprobarCupoService::validarSiElClienteTieneSuCupoAprobado() — examina todos los 7 valores de ProcessType para este cliente dentro del mes calendario actual, pero solo requiere que >= 5 tengan su evento mas reciente como FINISH_SUCCESS. El orden entre fases es irrelevante; las fases del cuestionario son efectivamente opcionales. |
| 2 | Setea expiracion del cupo | cupo_vence_en = proximo mes, dia 6, fin del dia. Nota: El codigo usa Carbon::now()->addMonth()->startOfMonth()->addDays(5)->endOfDay() lo que produce el dia 6 (1 + 5 dias = 6). Esto puede ser un bug off-by-one si la intencion era el dia 5. |
| 3 | Intenta extension de cupo | ExtenderCupoService::extender() |
| 3a | Consulta EMCALI | Obtiene los niveles de cupo aprobados de la membresia |
| 3b | Compara con cupo base | Si el cupo aprobado de EMCALI > cupo base del estrato, calcula la diferencia |
| 3c | Consulta score de DataCredito | Obtiene scoreValue del reporte de historial crediticio |
| 3d | Mapea score a porcentaje | 0-350: 0%, 351-470: 25%, 471-670: 50%, 671-815: 75%, 816-1000: 100%, null: 25% |
| 3e | Calcula incremento | incremento = (cupo_emcali - cupo_base) * porcentaje |
| 3f | Persiste y sincroniza | Actualiza cupo_asignado/cupo_disponible localmente, luego crearClienteEnCredito(force=true) para sincronizar con el core bancario SHIVAM via SOAP XML |
Rutas de error:
- Menos de 5 valores de
ProcessTypetienen unFINISH_SUCCESScomo su evento mas reciente este mes:ValidationExceptioncon'El cliente no tiene su cupo aprobado' - La extension falla en cualquier paso (error de EMCALI, sin cupo calificado, el score da 0%, exception en DataCredito): devuelve gracefully
extendido: false(la aprobacion aun tiene exito)
Excepciones del Patron de Logueo de Eventos:
- Fase 1 (Legal Check): El servicio dispara tanto START como FINISH_UNSUCCESS (ante falla HTTP). Para fallas de regla de negocio, solo el controlador dispara FINISH. Esto difiere de todas las otras fases.
- Fase 6 (Validacion HDC): El controlador dispara START (no el servicio). El controlador tambien dispara FINISH. El servicio en si no despacha eventos.
- Todas las otras fases (2-5): El servicio dispara START, el controlador dispara FINISH — el patron estandar.
Comportamientos de cache no mostrados en los diagramas:
- Respuestas EMCALI (exito + errores de cliente) cacheadas por 24 horas
- Respuestas HDC de DataCredito cacheadas por 24 horas (cache key:
datacredito.historial.{doc}.{type})- Tokens OAuth de DataCredito cacheados por 590 segundos
- DataCredito reintenta automaticamente ante 401 (limpia cache de token, regenera, reintenta una vez)
- En entorno
local, DataCredito hace proxy ahttps://test.miplante.com/api/v1/datacredito/historial
3. Llamadas a Servicios Externos Por Paso
| Fase | Servicio Externo | Endpoint API | Metodo de Auth | Timeout | Devuelve |
|---|---|---|---|---|---|
| 0 | EMCALI Membresias | GET {EMCALI_MEMBRESIAS_API_URL} | Ninguna | 30s | Datos de membresia, cupos aprobados por nivel, facturas, direccion, estrato |
| 1 | TransUnion LegalCheck | POST /ws/LegalcheckWSRest/legalcheck/consulta | HTTP Basic Auth | 300s | nombre, estadoDocumento, arreglo de hallazgos en listas legales |
| 2 | Experian Okta | POST /oauth2/.../v1/token | Basic Auth | 300s | access_token |
| 2 | Experian CrossCore | POST /op/evidentemaster/v1/identificacion/validar | OAuth + claves API | 300s | codigo resultado, regValidacion |
| 3 | Experian Okta | POST /oauth2/.../v1/token | Basic Auth | 300s | access_token |
| 3 | Experian CrossCore | POST /da/evidente/v3/otp/initialize | OAuth + claves API | 300s | resultadoOTP, idTransaccionOTP |
| 3 | Experian CrossCore | POST /da/evidente/v3/otp/evaluation | OAuth + claves API | 300s | enviadoOtpCorreo, enviadoOtpCelular, requiereCuestionario |
| 4 | Experian Okta | POST /oauth2/.../v1/token | Basic Auth | 300s | access_token |
| 4 | Experian CrossCore | POST /da/evidente/v3/otp/verify | OAuth + claves API | 300s | codigoValido, resultadoValidacion |
| 5a | Experian Okta | POST /oauth2/.../v1/token | Basic Auth | 300s | access_token |
| 5a | Experian CrossCore | POST /op/evidentemaster/v1/identificacion/preguntas | OAuth + claves API | 300s | Arreglo de preguntas con opciones de respuesta |
| 5b | Experian Okta | POST /oauth2/.../v1/token | Basic Auth | 300s | access_token |
| 5b | Experian CrossCore | POST /op/evidentemaster/v1/identificacion/verificar | OAuth + claves API | 300s | resultado, preguntasCompletas, aprobacion, score |
| 6 | DataCredito | POST /spla/oauth2/v1/token | Client ID/Secret | 60s | Token OAuth (cacheado 590s) |
| 6 | DataCredito | POST /cs/credit-history/v1/hdcplus | Token Bearer + claves API | 60s | ReportHDCplus con responseCode, models[].scoreValue |
| 7 | EMCALI Membresias | GET {EMCALI_MEMBRESIAS_API_URL} | Ninguna | 30s | Cupos aprobados para el calculo de extension |
| 7 | DataCredito | POST /cs/credit-history/v1/hdcplus | Token Bearer + claves API | 60s | Score crediticio para el porcentaje de extension |
| 7 | Core Credito SHIVAM | POST (SOAP XML) | Headers personalizados | 60s | ErrorID, ApplNo (VCARD), CreditLimitApp |
4. Rutas de Error y Rechazo
| Fase | Condicion de Error | Comportamiento | Recuperable? |
|---|---|---|---|
| 0 | Error inesperado de EMCALI | ValidationException lanzada | Si — reintentar luego |
| 0 | Discrepancia de facturas | ValidationException lanzada | Si — reenviar datos correctos |
| 0 | Discrepancia de direccion | ValidationException lanzada | Si — reenviar datos correctos |
| 0 | Sin cupo para el estrato | ValidationException lanzada | No — estrato no soportado |
| 1 | Tipo de documento invalido | DTO de error devuelto (sin llamada a API) | No — el usuario debe corregir el tipo de documento |
| 1 | Falla HTTP de TransUnion | ValidationException lanzada | Si — reintentar |
| 1 | Nombre < 70% similitud | DTO de error, FINISH_UNSUCCESS logueado | No — discrepancia de datos |
| 1 | Documento no “VIGENTE” | DTO de error, FINISH_UNSUCCESS logueado | No — problema con el documento |
| 1 | Hallazgos en listas legales | DTO de error, FINISH_UNSUCCESS logueado | No — bloqueo regulatorio |
| 2 | Falla en token OAuth | Exception lanzada | Si — reintentar |
| 2 | resultado = “09” (limite diario) | DTO de error + FINISH_UNSUCCESS | No — esperar al dia siguiente |
| 2 | resultado != “01” | DTO de error + FINISH_UNSUCCESS | Si — reintentar con datos correctos |
| 3 | Falta validacion cacheada | DTO de error (debe rehacer Fase 2) | Si — reiniciar desde Fase 2 |
| 3 | Falla inicializacion OTP | DTO de error + FINISH_UNSUCCESS | Si — reintentar |
| 3 | Discrepancia de contacto (sin entrega) | DTO de error + FINISH_UNSUCCESS | No — correo/telefono no coinciden |
| 4 | Falta estado cacheado | DTO de error (debe rehacer Fases 2-3) | Si — reiniciar |
| 4 | Codigo OTP invalido | DTO de error + FINISH_UNSUCCESS | Si — reingresar codigo |
| 4 | OTP no aprobado | DTO de error + FINISH_UNSUCCESS | Si — solicitar nuevo OTP |
| 5a | Codigo “10”/“11”/“12” | DTO de error (limites de intento agotados) | No — esperar (dia/mes/ano) |
| 5a | Codigo “07” (preguntas insuficientes) | DTO de error | No — limitacion del sistema |
| 5b | Cuestionario no aprobado | DTO de error + FINISH_UNSUCCESS | No — verificacion de identidad fallo |
| 6 | responseCode != 13 | Devuelve false, FINISH_UNSUCCESS | Si — reintentar |
| 6 | Cualquier exception | Capturada, devuelve false | Si — reintentar |
| 7 | Menos de 5 valores de ProcessType pasan el gate (el ultimo evento del mes por process_type es FINISH_SUCCESS) | ValidationException lanzada con 'El cliente no tiene su cupo aprobado' | Si — reintentar las fases fallidas hasta alcanzar el gate |
| 7 | La extension falla | Sin extension de forma gracefull (la aprobacion aun tiene exito) | N/A |
5. Logueo de Eventos AprobarCupoEvento
Enum ProcessType (App\Enum\AprobarCupo\ProcessType)
| 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 |
Enum EventType (App\Enum\AprobarCupo\EventType)
| Caso | Valor |
|---|---|
START | 'validation_start' |
FINISH_SUCCESS | 'validation_finish_successfull' |
FINISH_UNSUCCESS | 'validation_finish_unsuccessfull' |
Flujo de Logueo de Eventos
El patron estandar (Fases 2-5) es:
- Capa de servicio dispara el evento
ValidationLogcon tipo de eventoSTART(antes de la llamada a la API externa) - Se realiza la llamada a la API externa
- Capa de controlador dispara el evento
ValidationLogconFINISH_SUCCESSoFINISH_UNSUCCESS(despues de evaluar el resultado)
Excepciones: La Fase 1 (Legal Check) tiene al servicio disparando tanto START como FINISH_UNSUCCESS ante falla HTTP, con solo el controlador disparando FINISH para resultados de regla de negocio. La Fase 6 (Validacion HDC) tiene al controlador disparando tanto START como FINISH; el servicio no despacha eventos. Ver “Excepciones del Patron de Logueo de Eventos” en la seccion de Desglose Fase por Fase para mas detalles.
El evento ValidationLog es manejado por el listener StoreValidationLog, que crea un registro AprobarCupoEvento via AprobarCupoService::crear().
Estructura del Registro AprobarCupoEvento
| Columna | Tipo | Descripcion |
|---|---|---|
id | UUID | Auto-generado |
cliente_id | FK | Enlaza al cliente |
tipo_proceso | enum ProcessType | Que fase |
evento | enum EventType | START / SUCCESS / UNSUCCESS |
contexto | arreglo JSON (nullable) | Payload de la solicitud/respuesta de la API para auditoria |
creado_en | timestamp | Cuando ocurrio el evento |
Como se Usan los Eventos para la Aprobacion Final
AprobarCupoService::validarSiElClienteTieneSuCupoAprobado() en app/Services/AprobarCupoService.php:17-38 es el unico gate de aprobacion. La consulta:
SELECT tipo_procesoFROM aprobar_cupo_eventosWHERE cliente_id = ? AND tipo_proceso IN (<todos los 7 valores de ProcessType>) AND creado_en >= <inicio de este mes calendario>GROUP BY tipo_procesoHAVING MAX(CASE WHEN evento = 'validation_finish_successfull' THEN creado_en END) = MAX(creado_en)Comportamiento:
- Filtra
aprobar_cupo_eventospara el mes calendario actual (desdeCarbon::now()->startOfMonth()->startOfDay()). - Agrupa por
tipo_proceso(un valor del enumProcessType). - La clausula
HAVINGes el gate: para cadatipo_proceso, elcreado_enmas reciente debe coincidir con elcreado_enmas reciente de un eventoFINISH_SUCCESS. Traduccion: “el ultimo evento de este mes para este process_type fue un exito.” - El servicio luego cuenta las filas
tipo_procesoque sobrevivieron y devuelvecount >= 5.
Consecuencias:
- La secuencia es irrelevante. Un cliente puede hacer Legal Check hoy y Validacion de Identidad manana; lo que cuenta es “el ultimo intento de este mes por process_type fue un exito.”
- Una falla posterior anula un exito anterior para el mismo process_type dentro del mes: el
MAX(creado_en)toma el evento mas reciente, que luego debe ser unFINISH_SUCCESS. - El umbral de
>= 5significa que las fases del cuestionario (IV_QUESTION_GENERATION,IV_QUESTION_VERIFICATION) son efectivamente opcionales cuando no son requeridas (5 de 5 process_types no-cuestionario son suficientes). Cuando el cuestionario SI es requerido, el cliente aun solo necesita 5 de los 7 para pasar. - El umbral esta hard-codeado en el servicio (
>= 5). Agregar o eliminar unProcessTypeno ajusta automaticamente el umbral. - El limite mensual resetea el gate. A las 00:00 del dia 1 del mes, las aprobaciones previas se vuelven invalidas y el cliente debe reconstruir la evidencia.
6. Logica de Limitacion de Intentos Diarios
Middleware: CheckIntentosLimiteDiarios
Aplicado a: Todas las rutas bajo /usuario/cupo/* excepto /cupo/verificar-limite-intentos y /cupo/aprobar
Logica:
- Carga la relacion
clientedel usuario autenticado - Llama a
AprobarCupoService::haSuperadoLimiteIntentosDiarios(cliente_id) - Si fue excedido: lanza
ValidationExceptioncon el mensaje “Ha superado el limite de intentos diarios para esta verificacion”
Servicio: AprobarCupoService::haSuperadoLimiteIntentosDiarios()
Consulta: SELECT COUNT(*) FROM aprobar_cupo_eventosWHERE cliente_id = ? AND tipo_proceso = 'legal_check' AND evento = 'validation_start' AND creado_en >= hoy 00:00:00 AND creado_en < manana 00:00:00
Devuelve: count > 2 (es decir, 3+ intentos disparan el limite)Detalles clave:
- El limite es 2 intentos por dia (el 3er intento se bloquea)
- Cuenta solo eventos
LEGAL_CHECK/START(el primer paso del pipeline) - El contador se resetea a medianoche
- Un endpoint dedicado
GET /usuario/cupo/verificar-limite-intentospermite al frontend verificar el estado del limite sin disparar el middleware
Verificacion del Lado Cliente
VerificarLimiteIntentosController::index() provee un endpoint JSON no-bloqueante que devuelve:
{ "data": { "limite_excedido": true|false }, "success": true}Esto le permite al frontend mostrar una advertencia antes de que el usuario intente iniciar el flujo de aprobacion de credito.
7. Diagrama de Secuencia del Happy Path
sequenceDiagram
actor C as Cliente
participant FE as Frontend
participant MW as Middleware
participant CR as CompletarRegistro<br/>Controller
participant AC as AprobarCupo<br/>Controller
participant LCS as LegalCheck<br/>Service
participant IVS as IdentityValidation<br/>Service
participant HDCVS as HDCValidation<br/>Service
participant ACS as AprobarCupo<br/>Service
participant ECS as ExtenderCupo<br/>Service
participant CS as CreditoService
participant EMCALI as API EMCALI
participant TU as API TransUnion
participant OKTA as Experian Okta
participant EXP as Experian CrossCore
participant DC as API DataCredito
participant SHIVAM as Core Credito SHIVAM
participant DB as Base de Datos<br/>(AprobarCupoEvento)
Note over C,DB: Fase 0: Finalizacion del Registro
C->>FE: Completa formulario de registro
FE->>CR: POST /usuario/completar-registro
CR->>EMCALI: GET consultarMembresia(numero_contrato)
EMCALI-->>CR: Datos de membresia (facturas, direccion, estrato)
CR->>CR: Valida facturas y similitud de direccion
CR->>CR: Asigna cupo por estrato (p. ej., $3,500,000 para E3)
CR->>DB: UPDATE clientes SET cupo_asignado, cupo_disponible,<br/>registro_completado_en = now()
CR-->>FE: 200 OK (registro completo)
Note over C,DB: Fase 1: Legal Check
C->>FE: Inicia aprobacion de credito
FE->>MW: GET /usuario/cupo/legal-check
MW->>ACS: haSuperadoLimiteIntentosDiarios(cliente_id)
ACS->>DB: COUNT eventos legal_check START hoy
DB-->>ACS: count = 0 (bajo el limite)
ACS-->>MW: false (no excedido)
MW->>AC: Reenvia la solicitud
AC->>LCS: manejar(user)
LCS->>DB: INSERT AprobarCupoEvento(LEGAL_CHECK, START)
LCS->>TU: POST /consulta (Basic Auth)
TU-->>LCS: {nombre, estadoDocumento: "VIGENTE", data: []}
LCS->>LCS: Verifica similitud de nombre >= 70%
LCS->>LCS: Verifica estadoDocumento == "VIGENTE"
LCS->>LCS: Verifica listas legales (sin hallazgos)
LCS-->>AC: LegalCheckResult::success
AC->>DB: INSERT AprobarCupoEvento(LEGAL_CHECK, FINISH_SUCCESS)
AC-->>FE: {success: true, completado: true}
Note over C,DB: Fase 2: Validacion de Identidad
FE->>AC: GET /usuario/cupo/identity-validation
AC->>IVS: validarIdentidad(user)
IVS->>OKTA: POST /oauth2/.../v1/token (password grant)
OKTA-->>IVS: access_token
IVS->>DB: INSERT AprobarCupoEvento(IDENTITY_VALIDATION, START)
IVS->>EXP: POST /identificacion/validar
EXP-->>IVS: {resultado: "01", regValidacion: "abc123"}
IVS->>IVS: Cachea regValidacion (60 min)
IVS-->>AC: IdentityValidationResult::success
AC->>DB: INSERT AprobarCupoEvento(IDENTITY_VALIDATION, FINISH_SUCCESS)
AC-->>FE: {success: true}
Note over C,DB: Fase 3: Generacion de OTP
FE->>AC: GET /usuario/cupo/identity-validation-generate-otp
AC->>IVS: generarOTP(user)
IVS->>OKTA: POST /oauth2/.../v1/token
OKTA-->>IVS: access_token
IVS->>DB: INSERT AprobarCupoEvento(IV_OTP_GENERATION, START)
IVS->>EXP: POST /otp/initialize
EXP-->>IVS: {resultadoOTP: true, idTransaccionOTP: "tx123"}
IVS->>EXP: POST /otp/evaluation (envia OTP a correo + telefono)
EXP-->>IVS: {enviadoOtpCorreo: true, enviadoOtpCelular: true,<br/>requiereCuestionario: false}
IVS->>IVS: Cachea transaccion_otp_id (30 min)
IVS-->>AC: GenerateOTPResult::success
AC->>DB: INSERT AprobarCupoEvento(IV_OTP_GENERATION, FINISH_SUCCESS)
AC-->>FE: {success: true, data: {correo: true, telefono: true}}
Note over C,DB: Fase 4: Verificacion de OTP
C->>FE: Ingresa codigo OTP recibido por correo/SMS
FE->>AC: POST /usuario/cupo/identity-validation-verify-otp<br/>{otp_code: "123456"}
AC->>IVS: verificarOTP(user, "123456")
IVS->>IVS: Hash SHA-256 del codigo OTP
IVS->>OKTA: POST /oauth2/.../v1/token
OKTA-->>IVS: access_token
IVS->>DB: INSERT AprobarCupoEvento(IV_OTP_VERIFICATION, START)
IVS->>EXP: POST /otp/verify (OTP con hash + ID transaccion)
EXP-->>IVS: {codigoValido: true, resultadoValidacion: "1"}
IVS-->>AC: VerifyOTPResult::success {requiere_cuestionario: false}
AC->>DB: INSERT AprobarCupoEvento(IV_OTP_VERIFICATION, FINISH_SUCCESS)
AC-->>FE: {success: true, data: {valido: true,<br/>requiere_cuestionario: false}}
Note over C,DB: Fase 5: Cuestionario OMITIDO (requiere_cuestionario = false)
Note over C,DB: Fase 6: Validacion HDC
FE->>AC: GET /usuario/cupo/hdc-validation
AC->>DB: INSERT AprobarCupoEvento(HDC_VALIDATION, START)
AC->>HDCVS: manejar()
HDCVS->>DC: POST /spla/oauth2/v1/token
DC-->>HDCVS: Token OAuth (cacheado 590s)
HDCVS->>DC: POST /cs/credit-history/v1/hdcplus
DC-->>HDCVS: {ReportHDCplus: {productResult: {responseCode: 13}}}
HDCVS-->>AC: true (aprobado)
AC->>DB: INSERT AprobarCupoEvento(HDC_VALIDATION, FINISH_SUCCESS)
AC-->>FE: {success: true}
Note over C,DB: Fase 7: Aprobacion de Credito y Extension
FE->>AC: GET /usuario/cupo/aprobar
AC->>ACS: validarSiElClienteTieneSuCupoAprobado(cliente_id)
ACS->>DB: Consulta: 5+ tipos de proceso con ultimo evento = FINISH_SUCCESS este mes
DB-->>ACS: 5 procesos exitosos (todas las fases pasaron)
ACS-->>AC: true
AC->>DB: UPDATE clientes SET cupo_vence_en = proximo_mes_dia_6_EOD
AC->>ECS: extender(cliente)
ECS->>EMCALI: GET consultarMembresia(numero_contrato)
EMCALI-->>ECS: {4000000_12: "Si", 3500000_6: "Si"}
ECS->>ECS: mayor_cupo_emcali = 4,000,000 > base 3,500,000
ECS->>DC: POST /cs/credit-history/v1/hdcplus
DC-->>ECS: {models: [{scoreValue: 720}]}
ECS->>ECS: Score 720 -> 75% de extension
ECS->>ECS: incremento = (4M - 3.5M) * 0.75 = 375,000
ECS->>DB: UPDATE clientes SET cupo_asignado = 3,875,000,<br/>cupo_disponible = 3,875,000
ECS->>CS: crearClienteEnCredito(cliente, force=true)
CS->>SHIVAM: POST SOAP XML (modSolicitud)
SHIVAM-->>CS: {ErrorID: "S0000", ApplNo: "VCARD123"}
CS->>DB: UPDATE clientes SET vcard = "VCARD123"
CS-->>ECS: true
ECS-->>AC: {extendido: true, incremento: 375000}
AC-->>FE: {success: true, message: "Cupo asignado exitosamente",<br/>extension: {extendido: true}}
FE-->>C: Credito aprobado! Cupo: $3,875,000
8. Dependencias de Estado en Cache
El flujo de aprobacion de credito usa la cache de Laravel para pasar estado entre fases secuenciales. Si las entradas de cache expiran entre fases, el usuario debe reiniciar desde la fase que originalmente seteo el valor cacheado.
| Cache Key | Seteada En | Usada En | TTL | Contiene |
|---|---|---|---|---|
{userId}.validacion | Fase 2 (Validacion de Identidad) | Fases 3, 4, 5a | 60 min | regValidacion de Experian |
{userId}.transaccion_otp_id | Fase 3 (Generacion OTP) | Fase 4 (Verificacion OTP) | 30 min | ID de transaccion OTP |
{userId}.requiere_cuestionario | Fase 3 (Generacion OTP) | Valor de retorno de Fase 4 | 30 min | Flag booleano |
{userId}.cuestionario_id | Fase 5a (Generacion de Preguntas) | Fase 5b (Verificacion de Preguntas) | 30 min | ID del cuestionario |
{userId}.registro_cuestionario | Fase 5a (Generacion de Preguntas) | Fase 5b (Verificacion de Preguntas) | 30 min | Registro del cuestionario |
Importante: El TTL de 30 minutos en las caches de OTP y cuestionario significa que el usuario debe completar esos sub-flujos dentro de 30 minutos o reiniciar desde la fase anterior. La cache de validacion de identidad tiene un TTL mas generoso de 60 minutos.
8b. Lo Que el Backend NO Exige
El frontend ModalValidate.vue lleva al usuario por las fases 1-7 secuencialmente, pero el backend no requiere esa secuencia. Ejemplos concretos de las brechas entre “intencion documentada vs enforcement en runtime”:
| Intencion documentada | Realidad en runtime |
|---|---|
| La Fase 4 (verificacion OTP) debe tener exito antes de la Fase 5a (generacion del cuestionario) | IdentityValidationService::generarCuestionario() solo verifica Cache::get("{userId}.validacion") (seteada en la Fase 2). NO verifica el resultado de la Fase 4. Una llamada directa a /usuario/cupo/identity-validation-generate-questions despues de pasar la Fase 2 tiene exito incluso si la Fase 4 nunca se intento o fallo. |
requiere_cuestionario de la Fase 3 determina si la ruta del cuestionario es obligatoria | requiere_cuestionario se setea en Cache::put("{userId}.requiere_cuestionario", true, 30 min) solo cuando la respuesta de OTP-evaluation de Experian lo trae. El backend expone el flag al frontend via la respuesta de la Fase 4 pero NO bloquea la Fase 5 si el flag fue false; el frontend es responsable de omitir. |
| La Fase 5b exige un score minimo de cuestionario | IdentityValidationService::verificarCuestionario() solo requiere que resultado, preguntasCompletas, y aprobacion sean todos true. La verificacion de score esta comentada en IdentityValidationService.php:577-584; EXPIRIAN_CROSS_CORE_API_QUESTIONNAIRE_MIN_SCORE se lee en config pero no se aplica. |
| La Fase 6 exige un score minimo HDC | HDCValidationService::manejar() solo verifica responseCode === 13. No se aplica ningun umbral de score. El score es consumido solo por ExtenderCupoService para el porcentaje de extension de cupo, post-aprobacion. |
| La Fase 7 requiere que todos los 7 tipos de fase sean exitosos | El gate es >= 5 valores de ProcessType exitosos con el ultimo evento del mes siendo FINISH_SUCCESS. Las fases del cuestionario son efectivamente opcionales. |
| El pipeline es secuencial | Las fases pueden ser invocadas en cualquier orden; el gate de la Fase 7 solo inspecciona “ultimo evento por process_type este mes.” |
Validacion HDC es un placeholder que devuelve true (afirmacion del UML) | Validacion HDC hace la llamada real a DataCredito y devuelve un booleano real de responseCode === 13. El UML en este punto esta desactualizado. |
Sospecha off-by-one en la Fase 7.
cupo_vence_en = Carbon::now()->addMonth()->startOfMonth()->addDays(5)->endOfDay()resuelve al 6to dia del proximo mes al fin del dia (1 + 5 dias = 6). Si la intencion del producto es “expira el dia 5”, esto es off-by-one. Confirmar con negocio antes de cambiar.
9. Resumen de Rutas
| Metodo | Ruta | Metodo del Controlador | Middleware | Proposito |
|---|---|---|---|---|
| GET | /usuario/completar-registro | CompletarRegistroController::render | auth, verified | Renderiza el formulario de registro |
| POST | /usuario/completar-registro | CompletarRegistroController::store | auth | Completa el registro |
| GET | /usuario/cupo/verificar-limite-intentos | VerificarLimiteIntentosController::index | auth | Verifica el limite diario de intentos |
| GET | /usuario/cupo/legal-check | AprobarCupoController::legalCheck | auth, check_intentos_limite_diarios | Fase 1 |
| GET | /usuario/cupo/identity-validation | AprobarCupoController::identityValidation | auth, check_intentos_limite_diarios | Fase 2 |
| GET | /usuario/cupo/identity-validation-generate-otp | AprobarCupoController::identityValidationGenerateOTPCode | auth, check_intentos_limite_diarios | Fase 3 |
| POST | /usuario/cupo/identity-validation-verify-otp | AprobarCupoController::identityValidationVerifyOTPCode | auth, check_intentos_limite_diarios | Fase 4 |
| GET | /usuario/cupo/identity-validation-generate-questions | AprobarCupoController::identityValidationGenerateQuestions | auth, check_intentos_limite_diarios | Fase 5a |
| POST | /usuario/cupo/identity-validation-verify-questions | AprobarCupoController::identityValidationVerifyQuestions | auth, check_intentos_limite_diarios | Fase 5b |
| GET | /usuario/cupo/hdc-validation | AprobarCupoController::hdcValidation | auth, check_intentos_limite_diarios | Fase 6 |
| GET | /usuario/cupo/aprobar | AprobarCupoController::aprobarCupo | auth | Fase 7 |