Saltearse al contenido

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() en app/Services/AprobarCupoService.php:17-38) valida que >= 5 tipos de proceso (ProcessType) tengan su evento mas reciente del mes calendario en curso igual a FINISH_SUCCESS. El orden de ejecucion entre fases no se valida.
  • LegalCheckService integra con TransUnion, no con Experian. El UML oficial (docs/DIAGRAMA_APROBAR_CUPO_UML.md) describe el servicio bajo “Expirian CrossCore”, pero LegalCheckService.php:37 llama Http::transunion()->post('/ws/LegalcheckWSRest/legalcheck/consulta', ...) y Http::transunion() esta registrado en app/Providers/AppServiceProvider.php:19-30 con basic auth contra TRANSUNION_API_URL. Cuando UML y codigo discrepan, el codigo manda.
  • HDCValidationService no 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 via DataCreditoService::consultarHistorialPorTipoDocumento(), y compara ReportHDCplus.productResult.responseCode === 13. Devuelve false ante 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 del questionnaire_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:

  1. ClienteService (usado por la fase 7 para actualizar(...))
  2. AprobarCupoService (gate + limitador diario)
  3. LegalCheckService (fase 1)
  4. IdentityValidationService (fases 2-5b)
  5. HDCValidationService (fase 6)
  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:

#MetodoRutaMetodo del ControladorFase
1GET/usuario/cupo/legal-checklegalCheck1
2GET/usuario/cupo/identity-validationidentityValidation2
3GET/usuario/cupo/identity-validation-generate-otpidentityValidationGenerateOTPCode3
4POST/usuario/cupo/identity-validation-verify-otpidentityValidationVerifyOTPCode4
5GET/usuario/cupo/identity-validation-generate-questionsidentityValidationGenerateQuestions5a
6POST/usuario/cupo/identity-validation-verify-questionsidentityValidationVerifyQuestions5b
7GET/usuario/cupo/hdc-validationhdcValidation6
8GET/usuario/cupo/aprobaraprobarCupo7

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

PasoAccionDetalle
1Valida entrada del formulariodireccion, barrio, ciudad, departamento, factura_numero_1, factura_numero_2
2Guarda: ya completadoSi registro_completado_en no es null, devuelve 400
3Llama a EMCALIEmcaliMembresiaService::consultarMembresia(numero_contrato)
4Valida facturasClienteService::validarFacturasDeUltimoSeisMeses() — compara facturas enviadas contra los datos de EMCALI
5Valida direccionClienteService::validarSimilitudDeDirecciones() — match difuso entre la direccion enviada y la de EMCALI
6Asigna cupo por estratoEstrato 1 = $2,500,000; Estrato 2 = $3,000,000; Estrato 3 = $3,500,000; Estrato 4-6 = $4,000,000
7Guarda clienteSetea 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

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

PasoAccionDetalle
1Mapea el tipo de documentoConvierte tipo_dni al codigo de TransUnion via config
2Loguea evento STARTValidationLog(LEGAL_CHECK, START)
3Llama a TransUnionPOST /ws/LegalcheckWSRest/legalcheck/consulta con Basic Auth
4Valida nombresimilar_text() comparando nombre del usuario vs. nombre de TransUnion, umbral desde config (por defecto 70%)
5Verifica estado del documentoDebe ser "VIGENTE"
6Verifica listas legalesItera los IDs de listas restringidas configuradas; falla si algun finding === true
7Loguea evento de resultadoFINISH_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

PasoAccionDetalle
1Obtiene token OAuthPOST /oauth2/.../v1/token (password grant, scope expco_evidente_master)
2Loguea evento STARTValidationLog(IDENTITY_VALIDATION, START)
3Llama a ExperianPOST /op/evidentemaster/v1/identificacion/validar
4Evalua resultado"01" = exito; "09" = limite diario excedido; otro = falla generica
5Cachea el estado de validacionregValidacion cacheado bajo {userId}.validacion por 60 minutos
6Loguea evento de resultadoFINISH_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

PasoAccionDetalle
1Verifica precondicionesvalidacion cacheada debe existir (de la Fase 2)
2Obtiene token OAuth frescoToken nuevo por llamada (sin cache)
3Loguea evento STARTValidationLog(IV_OTP_GENERATION, START)
4Inicializa OTPPOST /da/evidente/v3/otp/initialize
5Evalua riesgo y envia OTPPOST /da/evidente/v3/otp/evaluation (envia OTP a correo/telefono)
6Cachea estadotransaccion_otp_id (30 min), requiere_cuestionario (30 min)
7Loguea evento de resultadoFINISH_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)

PasoAccionDetalle
1Valida entradaotp_code string requerido
2Verifica precondicionesvalidacion cacheada y transaccion_otp_id deben existir
3Hash de OTPHash SHA-256 del codigo enviado
4Loguea evento STARTValidationLog(IV_OTP_VERIFICATION, START)
5Llama a ExperianPOST /da/evidente/v3/otp/verify
6Valida respuestacodigoValido debe ser true Y resultadoValidacion debe ser "1"
7Devuelve resultadoIncluye el flag requiere_cuestionario del cache
8Loguea evento de resultadoFINISH_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)

PasoAccionDetalle
1Verifica precondicionesvalidacion cacheada debe existir
2Obtiene token OAuthToken fresco
3Loguea evento STARTValidationLog(IV_QUESTION_GENERATION, START)
4Llama a ExperianPOST /op/evidentemaster/v1/identificacion/preguntas
5Evalua codigo de resultado"00" inactivo, "02" ya generadas, "07" insuficientes, "10"/"11"/"12" limites agotados
6Cachea estadocuestionario_id (30 min), registro_cuestionario (30 min)
7Parsea preguntasExtrae id (orden), texto, y opciones de respuesta
8Loguea evento de resultadoFINISH_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)

PasoAccionDetalle
1Valida entradarespuestas[] con pregunta_id y respuesta_id por item
2Verifica precondicionescuestionario_id cacheado y registro_cuestionario deben existir
3Obtiene token OAuthToken fresco
4Loguea evento STARTValidationLog(IV_QUESTION_VERIFICATION, START)
5Llama a ExperianPOST /op/evidentemaster/v1/identificacion/verificar
6Valida respuestaresultado, preguntasCompletas, y aprobacion deben ser todos true
7Loguea evento de resultadoFINISH_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() en app/Services/HDCValidationService.php realmente 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.

PasoAccionDetalle
1Loguea evento STARTEl controlador dispara ValidationLog(HDC_VALIDATION, START) (AprobarCupoController.php:243). El servicio en si no despacha eventos.
2Verifica datos del usuarioAuth::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.
3Llama a DataCreditoDataCreditoService::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.
4Evalua respuestadata_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.
5Loguea evento de resultadoEl 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 de apellidos)
  • 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

PasoAccionDetalle
1Valida fases aprobadasAprobarCupoService::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.
2Setea expiracion del cupocupo_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.
3Intenta extension de cupoExtenderCupoService::extender()
3aConsulta EMCALIObtiene los niveles de cupo aprobados de la membresia
3bCompara con cupo baseSi el cupo aprobado de EMCALI > cupo base del estrato, calcula la diferencia
3cConsulta score de DataCreditoObtiene scoreValue del reporte de historial crediticio
3dMapea score a porcentaje0-350: 0%, 351-470: 25%, 471-670: 50%, 671-815: 75%, 816-1000: 100%, null: 25%
3eCalcula incrementoincremento = (cupo_emcali - cupo_base) * porcentaje
3fPersiste y sincronizaActualiza 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 ProcessType tienen un FINISH_SUCCESS como su evento mas reciente este mes: ValidationException con '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 a https://test.miplante.com/api/v1/datacredito/historial

3. Llamadas a Servicios Externos Por Paso

FaseServicio ExternoEndpoint APIMetodo de AuthTimeoutDevuelve
0EMCALI MembresiasGET {EMCALI_MEMBRESIAS_API_URL}Ninguna30sDatos de membresia, cupos aprobados por nivel, facturas, direccion, estrato
1TransUnion LegalCheckPOST /ws/LegalcheckWSRest/legalcheck/consultaHTTP Basic Auth300snombre, estadoDocumento, arreglo de hallazgos en listas legales
2Experian OktaPOST /oauth2/.../v1/tokenBasic Auth300saccess_token
2Experian CrossCorePOST /op/evidentemaster/v1/identificacion/validarOAuth + claves API300scodigo resultado, regValidacion
3Experian OktaPOST /oauth2/.../v1/tokenBasic Auth300saccess_token
3Experian CrossCorePOST /da/evidente/v3/otp/initializeOAuth + claves API300sresultadoOTP, idTransaccionOTP
3Experian CrossCorePOST /da/evidente/v3/otp/evaluationOAuth + claves API300senviadoOtpCorreo, enviadoOtpCelular, requiereCuestionario
4Experian OktaPOST /oauth2/.../v1/tokenBasic Auth300saccess_token
4Experian CrossCorePOST /da/evidente/v3/otp/verifyOAuth + claves API300scodigoValido, resultadoValidacion
5aExperian OktaPOST /oauth2/.../v1/tokenBasic Auth300saccess_token
5aExperian CrossCorePOST /op/evidentemaster/v1/identificacion/preguntasOAuth + claves API300sArreglo de preguntas con opciones de respuesta
5bExperian OktaPOST /oauth2/.../v1/tokenBasic Auth300saccess_token
5bExperian CrossCorePOST /op/evidentemaster/v1/identificacion/verificarOAuth + claves API300sresultado, preguntasCompletas, aprobacion, score
6DataCreditoPOST /spla/oauth2/v1/tokenClient ID/Secret60sToken OAuth (cacheado 590s)
6DataCreditoPOST /cs/credit-history/v1/hdcplusToken Bearer + claves API60sReportHDCplus con responseCode, models[].scoreValue
7EMCALI MembresiasGET {EMCALI_MEMBRESIAS_API_URL}Ninguna30sCupos aprobados para el calculo de extension
7DataCreditoPOST /cs/credit-history/v1/hdcplusToken Bearer + claves API60sScore crediticio para el porcentaje de extension
7Core Credito SHIVAMPOST (SOAP XML)Headers personalizados60sErrorID, ApplNo (VCARD), CreditLimitApp

4. Rutas de Error y Rechazo

FaseCondicion de ErrorComportamientoRecuperable?
0Error inesperado de EMCALIValidationException lanzadaSi — reintentar luego
0Discrepancia de facturasValidationException lanzadaSi — reenviar datos correctos
0Discrepancia de direccionValidationException lanzadaSi — reenviar datos correctos
0Sin cupo para el estratoValidationException lanzadaNo — estrato no soportado
1Tipo de documento invalidoDTO de error devuelto (sin llamada a API)No — el usuario debe corregir el tipo de documento
1Falla HTTP de TransUnionValidationException lanzadaSi — reintentar
1Nombre < 70% similitudDTO de error, FINISH_UNSUCCESS logueadoNo — discrepancia de datos
1Documento no “VIGENTE”DTO de error, FINISH_UNSUCCESS logueadoNo — problema con el documento
1Hallazgos en listas legalesDTO de error, FINISH_UNSUCCESS logueadoNo — bloqueo regulatorio
2Falla en token OAuthException lanzadaSi — reintentar
2resultado = “09” (limite diario)DTO de error + FINISH_UNSUCCESSNo — esperar al dia siguiente
2resultado != “01”DTO de error + FINISH_UNSUCCESSSi — reintentar con datos correctos
3Falta validacion cacheadaDTO de error (debe rehacer Fase 2)Si — reiniciar desde Fase 2
3Falla inicializacion OTPDTO de error + FINISH_UNSUCCESSSi — reintentar
3Discrepancia de contacto (sin entrega)DTO de error + FINISH_UNSUCCESSNo — correo/telefono no coinciden
4Falta estado cacheadoDTO de error (debe rehacer Fases 2-3)Si — reiniciar
4Codigo OTP invalidoDTO de error + FINISH_UNSUCCESSSi — reingresar codigo
4OTP no aprobadoDTO de error + FINISH_UNSUCCESSSi — solicitar nuevo OTP
5aCodigo “10”/“11”/“12”DTO de error (limites de intento agotados)No — esperar (dia/mes/ano)
5aCodigo “07” (preguntas insuficientes)DTO de errorNo — limitacion del sistema
5bCuestionario no aprobadoDTO de error + FINISH_UNSUCCESSNo — verificacion de identidad fallo
6responseCode != 13Devuelve false, FINISH_UNSUCCESSSi — reintentar
6Cualquier exceptionCapturada, devuelve falseSi — reintentar
7Menos 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
7La extension fallaSin extension de forma gracefull (la aprobacion aun tiene exito)N/A

5. Logueo de Eventos AprobarCupoEvento

Enum ProcessType (App\Enum\AprobarCupo\ProcessType)

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

Enum EventType (App\Enum\AprobarCupo\EventType)

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

  1. Capa de servicio dispara el evento ValidationLog con tipo de evento START (antes de la llamada a la API externa)
  2. Se realiza la llamada a la API externa
  3. Capa de controlador dispara el evento ValidationLog con FINISH_SUCCESS o FINISH_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

ColumnaTipoDescripcion
idUUIDAuto-generado
cliente_idFKEnlaza al cliente
tipo_procesoenum ProcessTypeQue fase
eventoenum EventTypeSTART / SUCCESS / UNSUCCESS
contextoarreglo JSON (nullable)Payload de la solicitud/respuesta de la API para auditoria
creado_entimestampCuando 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_proceso
FROM aprobar_cupo_eventos
WHERE cliente_id = ?
AND tipo_proceso IN (<todos los 7 valores de ProcessType>)
AND creado_en >= <inicio de este mes calendario>
GROUP BY tipo_proceso
HAVING MAX(CASE WHEN evento = 'validation_finish_successfull' THEN creado_en END) = MAX(creado_en)

Comportamiento:

  • Filtra aprobar_cupo_eventos para el mes calendario actual (desde Carbon::now()->startOfMonth()->startOfDay()).
  • Agrupa por tipo_proceso (un valor del enum ProcessType).
  • La clausula HAVING es el gate: para cada tipo_proceso, el creado_en mas reciente debe coincidir con el creado_en mas reciente de un evento FINISH_SUCCESS. Traduccion: “el ultimo evento de este mes para este process_type fue un exito.”
  • El servicio luego cuenta las filas tipo_proceso que sobrevivieron y devuelve count >= 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 un FINISH_SUCCESS.
  • El umbral de >= 5 significa 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 un ProcessType no 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:

  1. Carga la relacion cliente del usuario autenticado
  2. Llama a AprobarCupoService::haSuperadoLimiteIntentosDiarios(cliente_id)
  3. Si fue excedido: lanza ValidationException con el mensaje “Ha superado el limite de intentos diarios para esta verificacion”

Servicio: AprobarCupoService::haSuperadoLimiteIntentosDiarios()

Consulta: SELECT COUNT(*) FROM aprobar_cupo_eventos
WHERE 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-intentos permite 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 KeySeteada EnUsada EnTTLContiene
{userId}.validacionFase 2 (Validacion de Identidad)Fases 3, 4, 5a60 minregValidacion de Experian
{userId}.transaccion_otp_idFase 3 (Generacion OTP)Fase 4 (Verificacion OTP)30 minID de transaccion OTP
{userId}.requiere_cuestionarioFase 3 (Generacion OTP)Valor de retorno de Fase 430 minFlag booleano
{userId}.cuestionario_idFase 5a (Generacion de Preguntas)Fase 5b (Verificacion de Preguntas)30 minID del cuestionario
{userId}.registro_cuestionarioFase 5a (Generacion de Preguntas)Fase 5b (Verificacion de Preguntas)30 minRegistro 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 documentadaRealidad 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 obligatoriarequiere_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 cuestionarioIdentityValidationService::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 HDCHDCValidationService::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 exitososEl 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 secuencialLas 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

MetodoRutaMetodo del ControladorMiddlewareProposito
GET/usuario/completar-registroCompletarRegistroController::renderauth, verifiedRenderiza el formulario de registro
POST/usuario/completar-registroCompletarRegistroController::storeauthCompleta el registro
GET/usuario/cupo/verificar-limite-intentosVerificarLimiteIntentosController::indexauthVerifica el limite diario de intentos
GET/usuario/cupo/legal-checkAprobarCupoController::legalCheckauth, check_intentos_limite_diariosFase 1
GET/usuario/cupo/identity-validationAprobarCupoController::identityValidationauth, check_intentos_limite_diariosFase 2
GET/usuario/cupo/identity-validation-generate-otpAprobarCupoController::identityValidationGenerateOTPCodeauth, check_intentos_limite_diariosFase 3
POST/usuario/cupo/identity-validation-verify-otpAprobarCupoController::identityValidationVerifyOTPCodeauth, check_intentos_limite_diariosFase 4
GET/usuario/cupo/identity-validation-generate-questionsAprobarCupoController::identityValidationGenerateQuestionsauth, check_intentos_limite_diariosFase 5a
POST/usuario/cupo/identity-validation-verify-questionsAprobarCupoController::identityValidationVerifyQuestionsauth, check_intentos_limite_diariosFase 5b
GET/usuario/cupo/hdc-validationAprobarCupoController::hdcValidationauth, check_intentos_limite_diariosFase 6
GET/usuario/cupo/aprobarAprobarCupoController::aprobarCupoauthFase 7