Skip to content

Instantly share code, notes, and snippets.

@jluisflo
Created January 20, 2026 19:36
Show Gist options
  • Select an option

  • Save jluisflo/b47b0ee4a97d4567ccdf075ddc4c2a46 to your computer and use it in GitHub Desktop.

Select an option

Save jluisflo/b47b0ee4a97d4567ccdf075ddc4c2a46 to your computer and use it in GitHub Desktop.
Plan: Flujo de Apelación CEC (Solución Simplificada)

Plan: Flujo de Apelación CEC (Solución Simplificada)

Design Doc - High Level

Problema

Los usuarios sin historial crediticio o con crédito denegado necesitan poder apelar, subiendo documentos de soporte. MPay ya expone esta funcionalidad via su WebView y nuevos estados en su API, pero issuing no los reconoce.

Solución

Approach: Aprovechar el WebView de MPay para todo el flujo de apelación. Issuing solo parsea estados y muestra información.

Cambios principales:

Backend (3 archivos, ~30 min)

  1. Parsear nuevos campos de MPay API: Agregar DTO ApplicationStatusDto para leer appeal_needed, appeal_completed, final_decision
  2. Mapear a estados existentes: Reutilizar Step=KYC para apelación pendiente, agregar APPEAL_SUBMITTED y APPEAL_DENIED
  3. Actualizar lógica: Modificar DetermineStep() para priorizar estados de apelación

Frontend (6 archivos, ~35 min)

  1. Extender enum: Agregar 2 casos a BnplStep (appealSubmitted, appealDenied)
  2. 2 pantallas nuevas: Informativas simples (en proceso, denegada) sin estado complejo
  3. Reutilizar WebView existente: Mismo flujo que KYC - MPay maneja upload de documentos
  4. Refresh post-WebView: Cuando WebView cierra, refrescar estado de usuario

Ventajas:

  • ✅ Zero campos nuevos en DB (tabla se deprecará pronto)
  • ✅ Zero endpoints nuevos (MPay WebView maneja uploads)
  • ✅ Reutiliza infraestructura existente (WebView, navegación)
  • ✅ Quick win: ~1 día vs 7-9 días

Flujo Técnico

Usuario → Backend consulta MPay API
              ↓
         MPay retorna:
         - appeal_needed=true + appeal_completed=false
         - onboardingUrl (para continuar apelación)
              ↓
         Backend: Step=KYC + onboardingUrl
              ↓
         Frontend: Abre WebView con URL
              ↓
         Usuario sube docs en MPay WebView
              ↓
         MPay redirige a close URL
              ↓
         WebView detecta + retorna true
              ↓
         Frontend: Refresh estado
              ↓
         Backend: MPay ahora retorna appeal_completed=true
              ↓
         Backend: Step=APPEAL_SUBMITTED
              ↓
         Frontend: Muestra AppealInProgressPage

Persistencia del onboardingUrl

¿Necesitamos guardar el URL en DB/Storage? ❌ NO

Razón: El onboardingUrl viene fresco del MPay API en cada request:

  1. Primera vez (usuario inicia onboarding):

    • MPay API retorna onboardingUrl: https://mpay.com/kyc/abc123
    • Frontend almacena en Cubit state (memoria)
    • Abre WebView
  2. Usuario cierra app antes de terminar:

    • Cubit state se pierde (OK, es temporal)
    • Usuario regresa días después
    • Backend consulta MPay API de nuevo
    • MPay retorna onboardingUrl actualizado (mismo usuario, puede continuar donde quedó)
    • Frontend abre WebView con nuevo URL
  3. Para apelación es igual:

    • MPay retorna onboardingUrl específico para apelación
    • Mismo mecanismo de reanudación

MPay maneja la persistencia del progreso del usuario en su lado. El onboardingUrl es un token de sesión que permite reanudar, y lo generan fresh cada vez que consultamos.

Por lo tanto: Reutilizamos el campo onboardingUrl existente en UserStatusModel. No agregamos campos nuevos.


Resumen Ejecutivo

Implementar el flujo de apelación aprovechando el WebView de MPay para manejar la subida de documentos y el proceso de apelación. Nuestra app solo necesita:

  1. Reconocer nuevos estados de MPay API
  2. Mostrar 2 pantallas simples informativas (apelación en proceso, denegada)
  3. Abrir WebView de MPay cuando el usuario necesite apelar
  4. Zero campos nuevos en DB - todo se determina desde MPay API en cada request

Approach: MPay hace el trabajo pesado via WebView. Nosotros solo parseamos estados y mostramos info.

Referencia: Design Doc MPay | User Story

Nuevos Estados de MPay

MPay expone en error.application_status:

{
  "step": "appeal|evaluation|search",
  "final_decision": "approved|denied|rejected|null",
  "rejection_reason": "customer_not_found|age_violations|...",
  "appeal_needed": true/false,
  "appeal_completed": true/false
}

Mapeo Simplificado de Estados

Condición MPay Step App Acción onboardingUrl
appeal_needed=true + appeal_completed=false KYC Abrir WebView (MPay maneja upload)
appeal_needed=true + appeal_completed=true APPEAL_SUBMITTED Pantalla "En proceso" No
final_decision="denied" APPEAL_DENIED Pantalla "Denegada" No
final_decision="rejected" APPEAL_DENIED Pantalla "Denegada" (sin opción apelar) No
final_decision="approved" HOME Flujo normal No

Insight clave: Reutilizamos Step=KYC para estados de apelación pendiente. El WebView de MPay ya maneja documentos.

Flujo Simplificado

Usuario inicia CEC
    ↓
Backend consulta MPay API
    ↓
    ├─ appeal_needed=true, appeal_completed=false
    │   → Step=KYC + onboardingUrl → App abre WebView
    │   → Usuario sube docs en WebView de MPay
    │   → MPay marca appeal_completed=true
    │
    ├─ appeal_needed=true, appeal_completed=true
    │   → Step=APPEAL_SUBMITTED → App muestra "En proceso"
    │
    ├─ final_decision="denied"/"rejected"
    │   → Step=APPEAL_DENIED → App muestra "No aprobado"
    │
    └─ Approved
        → Step=HOME → Flujo normal

No hay upload manual, no hay nuevos endpoints, no hay nuevos campos en DB.


Implementación Backend (SOLO 3 CAMBIOS)

Cambio 1: Agregar DTO para Parsear Nuevos Campos

Archivo: n1co-finance-backend/src/Infrastructure/Services/MercanduPay/Model/MercanduProductsResponse.cs

Agregar clase:

public class ApplicationStatusDto
{
    [JsonPropertyName("step")]
    public string? Step { get; set; }

    [JsonPropertyName("final_decision")]
    public string? FinalDecision { get; set; }

    [JsonPropertyName("rejection_reason")]
    public string? RejectionReason { get; set; }

    [JsonPropertyName("appeal_needed")]
    public bool AppealNeeded { get; set; }

    [JsonPropertyName("appeal_completed")]
    public bool AppealCompleted { get; set; }
}

Modificar ErrorDto:

public class ErrorDto
{
    [JsonPropertyName("code")]
    public string Code { get; set; }

    [JsonPropertyName("burofax_request_url")]
    public string OnboardingUrl { get; set; }

    [JsonPropertyName("application_status")]  // NUEVO
    public ApplicationStatusDto? ApplicationStatus { get; set; }
}

Impacto: Zero breaking changes. Campos opcionales.

Cambio 2: Agregar Constantes para Nuevos Steps

Archivo: n1co-finance-backend/src/Application/Common/Constants/FinancingProfileSteps.cs

Agregar 2 constantes:

public const string AppealSubmitted = "APPEAL_SUBMITTED";
public const string AppealDenied = "APPEAL_DENIED";

NO agregamos PendingAppeal - reutilizamos KYC existente.

Cambio 3: Actualizar DetermineStep Logic

Archivo: n1co-finance-backend/src/Infrastructure/Services/MercanduPay/FinancingService.cs

Modificar método DetermineStep() (línea ~202-215):

private static string DetermineStep(MercanduProductsResponse financingInfo)
{
    var product = financingInfo?.Products?.FirstOrDefault();

    // Si tiene crédito aprobado → HOME
    if (product?.ApprovedCreditAmount != null && product.ApprovedCreditAmount > 0)
        return FinancingProfileSteps.Home;

    var appStatus = financingInfo?.Error?.ApplicationStatus;

    // NUEVO: Manejar estados de apelación
    if (appStatus != null)
    {
        // Apelación denegada o rechazo manual
        if (appStatus.FinalDecision == "denied" || appStatus.FinalDecision == "rejected")
            return FinancingProfileSteps.AppealDenied;

        // Necesita apelar
        if (appStatus.AppealNeeded)
        {
            // Si ya completó apelación → En proceso
            if (appStatus.AppealCompleted)
                return FinancingProfileSteps.AppealSubmitted;

            // Si no ha completado → Abrir WebView (reusar KYC)
            // MPay retorna onboardingUrl para continuar apelación
            return FinancingProfileSteps.Kyc;
        }
    }

    // Fallback a lógica existente
    return financingInfo?.Error?.Code switch
    {
        MpayProductErrorCodes.BurofaxProcess => FinancingProfileSteps.Kyc,
        MpayProductErrorCodes.BurofaxRequestAppeal => FinancingProfileSteps.KycFailed,
        _ => FinancingProfileSteps.KycFailed
    };
}

Eso es TODO en el backend para manejo de estados.

(Opcional) Cambio 4: Webhook para Transiciones

Archivo: n1co-finance-backend/src/Application/Financing/Commands/FinancingCallbackCommand.cs

En HandleAssignedAmountUpdated, agregar:

// Detectar apelación aprobada
if (profile.Step == FinancingProfileSteps.AppealSubmitted &&
    currentStatus == AssignedAmountStatus.Applied)
{
    profile.Step = FinancingProfileSteps.Home;
    profile.HasApprovedFinancingAmount = true;
    // Enviar notificación (email/push) - usar evento existente o crear nuevo
}

// Detectar apelación denegada
if (profile.Step == FinancingProfileSteps.AppealSubmitted &&
    currentStatus == AssignedAmountStatus.Rejected)
{
    profile.Step = FinancingProfileSteps.AppealDenied;
    // Enviar notificación
}

Emails: Reusar templates existentes de crédito aprobado/denegado o crear nuevos si se requiere copy diferente.


Implementación Frontend (SOLO 2 PANTALLAS NUEVAS)

Cambio 1: Extender BnplStep Enum

Archivo: n1co-app/lib/app/domain/model/bnpl/user_status_model.dart

Agregar 2 nuevos estados (línea ~35-52):

enum BnplStep {
  kyc,
  kycFailed,
  home,
  appealSubmitted,    // NUEVO
  appealDenied;       // NUEVO

  static BnplStep fromString(String step) {
    switch (step.toUpperCase()) {
      case 'KYC':
        return BnplStep.kyc;
      case 'KYC_FAILED':
        return BnplStep.kycFailed;
      case 'HOME':
        return BnplStep.home;
      case 'APPEAL_SUBMITTED':          // NUEVO
        return BnplStep.appealSubmitted;
      case 'APPEAL_DENIED':              // NUEVO
        return BnplStep.appealDenied;
      default:
        return BnplStep.kyc;
    }
  }
}

NO ejecutar build_runner - no hay cambios en DTOs.

Cambio 2: Crear AppealInProgressPage

Archivo nuevo: n1co-app/lib/app/ui/pages/bnpl/appeal_in_progress_page.dart

class AppealInProgressPage extends StatelessWidget {
  const AppealInProgressPage({super.key});

  static Widget create() => const AppealInProgressPage();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Compras en Cuotas'),
        leading: IconButton(
          icon: const Icon(Icons.close),
          onPressed: () => Navigator.of(context).popUntil((route) => route.isFirst),
        ),
      ),
      body: Padding(
        padding: const EdgeInsets.all(24.0),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(Icons.schedule, size: 80, color: Colors.orange),
            SizedBox(height: 24),
            Text(
              'Tu solicitud está en proceso',
              style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
              textAlign: TextAlign.center,
            ),
            SizedBox(height: 16),
            Text(
              'Estamos revisando tu información. Te notificaremos por email cuando tengamos una respuesta.',
              style: TextStyle(fontSize: 16),
              textAlign: TextAlign.center,
            ),
            SizedBox(height: 32),
            ElevatedButton(
              onPressed: () => Navigator.of(context).popUntil((route) => route.isFirst),
              child: Text('Volver al inicio'),
            ),
          ],
        ),
      ),
    );
  }
}

Diseño: Pantalla informativa simple. Sin estado, sin Cubits, sin BlocProvider.

Cambio 3: Crear AppealDeniedPage

Archivo nuevo: n1co-app/lib/app/ui/pages/bnpl/appeal_denied_page.dart

class AppealDeniedPage extends StatelessWidget {
  const AppealDeniedPage({super.key});

  static Widget create() => const AppealDeniedPage();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Compras en Cuotas'),
        leading: IconButton(
          icon: const Icon(Icons.close),
          onPressed: () => Navigator.of(context).popUntil((route) => route.isFirst),
        ),
      ),
      body: Padding(
        padding: const EdgeInsets.all(24.0),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(Icons.cancel_outlined, size: 80, color: Colors.red),
            SizedBox(height: 24),
            Text(
              'Solicitud no aprobada',
              style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
              textAlign: TextAlign.center,
            ),
            SizedBox(height: 16),
            Text(
              'Lamentablemente tu solicitud no pudo ser aprobada en este momento. Para más información, contacta a soporte.',
              style: TextStyle(fontSize: 16),
              textAlign: TextAlign.center,
            ),
            SizedBox(height: 32),
            ElevatedButton(
              onPressed: () => Navigator.of(context).popUntil((route) => route.isFirst),
              child: Text('Entendido'),
            ),
          ],
        ),
      ),
    );
  }
}

Diseño: Pantalla informativa simple. Sin estado, sin Cubits.

(Opcional): Si el backend retorna rejection_reason en el modelo, se puede pasar como parámetro y mostrar mensaje específico.

Cambio 4: Agregar Rutas

Archivo: n1co-app/lib/app/routes/app_pages.dart

Agregar 2 constantes:

static const BNPL_APPEAL_IN_PROGRESS_PAGE = '/bnpl/appeal-in-progress';
static const BNPL_APPEAL_DENIED_PAGE = '/bnpl/appeal-denied';

Agregar a routes map:

Routes.BNPL_APPEAL_IN_PROGRESS_PAGE: (context) => AppealInProgressPage.create(),
Routes.BNPL_APPEAL_DENIED_PAGE: (context) => AppealDeniedPage.create(),

Cambio 5: Actualizar Navegación

Archivo: n1co-app/lib/app/ui/pages/bnpl/services/bnpl_navigation_handler.dart

Agregar 2 cases al switch (después de línea ~59):

case BnplStep.appealSubmitted:
  if (popAndPush) {
    navigator.popAndPushNamed(Routes.BNPL_APPEAL_IN_PROGRESS_PAGE);
  } else {
    navigator.pushNamed(Routes.BNPL_APPEAL_IN_PROGRESS_PAGE);
  }
  break;

case BnplStep.appealDenied:
  if (popAndPush) {
    navigator.popAndPushNamed(Routes.BNPL_APPEAL_DENIED_PAGE);
  } else {
    navigator.pushNamed(Routes.BNPL_APPEAL_DENIED_PAGE);
  }
  break;

Nota: El estado KYC (apelación pendiente) ya está manejado - abre el WebView con onboardingUrl de MPay.

Cambio 6: Detectar Cierre del WebView (YA EXISTE ✅)

El WebView ya tiene mecanismo de detección (línea 287-303 en bnpl_webview_page.dart):

  1. Monitorea cambios de URL via setOnUrlChange
  2. Cuando MPay redirige a URL de cierre, hace Navigator.pop(true)
  3. La pantalla que abrió el WebView recibe true

Lo que FALTA: Refrescar estado del usuario cuando WebView cierra.

Cambio 7: Refrescar Estado Post-WebView

Archivo: Donde se abre el WebView (probablemente bnpl_onboarding_page.dart)

Agregar:

// Cuando abrimos el WebView
final result = await Navigator.pushNamed(
  context,
  Routes.BNPL_SIGNUP_PAGE,
  arguments: {'url': onboardingUrl},
);

// Si el WebView retornó true (usuario completó flujo)
if (result == true) {
  // Refrescar estado - esto llamará a MPay API de nuevo
  context.read<GetUserStatusCubit>().getUserStatus();
  // MPay ahora retornará appeal_completed=true → Step=APPEAL_SUBMITTED
  // App navegará a AppealInProgressPage automáticamente
}

Esto asegura que: Cuando el usuario complete documentos en WebView, el estado se refresca y ve la pantalla correcta.


Resumen de Cambios (Quick Wins)

Backend (3 archivos)

Archivo Cambio Líneas Tiempo
MercanduProductsResponse.cs Agregar ApplicationStatusDto class ~15 5 min
FinancingProfileSteps.cs Agregar 2 constantes 2 1 min
FinancingService.cs Actualizar DetermineStep() ~20 15 min
(Opcional) FinancingCallbackCommand.cs Detectar transiciones en webhook ~10 10 min

Total Backend: ~30 min (sin webhooks), ~40 min (con webhooks)

Frontend (6 archivos)

Archivo Cambio Líneas Tiempo
user_status_model.dart Agregar 2 casos al enum ~5 2 min
appeal_in_progress_page.dart Crear pantalla simple (NUEVO) ~50 10 min
appeal_denied_page.dart Crear pantalla simple (NUEVO) ~50 10 min
app_pages.dart Agregar 2 rutas ~5 2 min
bnpl_navigation_handler.dart Agregar 2 cases ~15 5 min
bnpl_onboarding_page.dart Refrescar estado post-WebView ~8 5 min

Total Frontend: ~35 min


Comparación: Plan Original vs Plan Simplificado

Aspecto Plan Original Plan Simplificado Ahorro
Archivos Backend 9 3 67% menos
Archivos Frontend 15+ 5 67% menos
Pantallas Nuevas 5 2 60% menos
Campos DB 5 nuevos 0 100% menos
Endpoints API 1 nuevo 0 100% menos
Webhooks Requeridos Opcionales -
Migraciones DB 1 0 100% menos
Time Estimate 7-9 días 1 día 90% menos

WebView hace: Upload de documentos, validación, tracking de estado de apelación.

Nosotros hacemos: Parsear estados, mostrar info, abrir WebView.


Verificación Manual (Escenarios User Story)

Escenario 1: Cliente sin historial crediticio

  • Backend retorna Step=KYC con onboardingUrl
  • App abre WebView de MPay
  • Usuario completa upload de documentos en WebView de MPay

Escenario 2: Cliente desea apelar

  • MPay WebView muestra pantalla de documentos
  • Usuario puede adjuntar múltiples PDFs
  • MPay valida documentos

Escenario 3: Cliente adjunta documentos y finaliza

  • MPay marca appeal_completed=true
  • Backend retorna Step=APPEAL_SUBMITTED
  • App muestra AppealInProgressPage

Escenario 4: Cliente rechazado

  • Backend retorna Step=KYC_FAILED
  • App muestra pantalla existente de rechazo
  • (Flujo actual - sin cambios)

Escenario 5: Cliente no completó apelación, retoma

  • Backend consulta MPay API
  • Si appeal_needed=true y appeal_completed=false → Step=KYC
  • App abre WebView con URL de MPay (usuario puede continuar)

Escenario 6: Cliente completó apelación

  • Backend retorna Step=APPEAL_SUBMITTED
  • App muestra AppealInProgressPage
  • Mensaje: "Estamos revisando tu información"

Escenario 7: Apelación denegada

  • Webhook de MPay: status=Rejected
  • Backend actualiza Step=APPEAL_DENIED
  • App muestra AppealDeniedPage
  • (Opcional) Email enviado

Escenario 8: Apelación aprobada

  • Webhook de MPay: status=Applied
  • Backend actualiza Step=HOME
  • App muestra monto aprobado (flujo normal)
  • (Opcional) Email enviado

Despliegue Simplificado

Backend

No hay migraciones DB

Deployment:

  1. Merge PR
  2. Deploy a staging
  3. Probar con cuenta de prueba en staging (MPay debe tener data de prueba con appeal states)
  4. Deploy a producción

Validación Post-Deploy:

  • Verificar que parsing de application_status funciona (logs)
  • Verificar que DetermineStep retorna estados correctos

Frontend

No hay build_runner ✅ (solo cambios en enum, no en DTOs con Freezed)

Deployment:

  1. Merge PR
  2. Build y deploy a TestFlight
  3. Probar manualmente los 8 escenarios
  4. Deploy a producción

Validación Post-Deploy:

  • Verificar navegación a nuevas pantallas
  • Verificar que WebView se abre correctamente
  • Verificar Analytics events (si se agregan)

Coordinación

  • MPay debe desplegar primero: Validar en staging que error.application_status existe
  • Graceful degradation: Si application_status es null, fallback a lógica actual funciona
  • No breaking changes: Todo es backward compatible

Riesgos Minimizados

Riesgo Original Mitigación Simplificada
PDFs exceden límite No aplica - MPay WebView maneja
Validación de archivos No aplica - MPay WebView maneja
Upload failures No aplica - MPay WebView maneja
MPay API no lista application_status es opcional, fallback a flujo actual
Estado desincronizado Cada request consulta MPay, siempre fresh

Risk Score: Bajo ⬇️ (vs Alto en plan original)


Checklist de Implementación

Backend

  • Agregar ApplicationStatusDto a MercanduProductsResponse.cs
  • Agregar constantes a FinancingProfileSteps.cs
  • Modificar DetermineStep() en FinancingService.cs
  • (Opcional) Modificar webhook handler
  • Probar con Postman/unit tests

Frontend

  • Agregar 2 casos a BnplStep enum en user_status_model.dart
  • Crear AppealInProgressPage
  • Crear AppealDeniedPage
  • Agregar rutas en app_pages.dart
  • Agregar cases en bnpl_navigation_handler.dart
  • Agregar refresh de estado post-WebView en bnpl_onboarding_page.dart
  • Probar navegación manualmente

QA

  • Probar 8 escenarios con data de prueba
  • Verificar WebView se abre correctamente
  • Verificar pantallas informativas
  • Verificar backward compatibility

Estimación Final

Backend: 30-40 minutos Frontend: 30-35 minutos Testing: 1-2 horas QA Manual: 2-3 horas

Total: 0.5-1 día (vs 7-9 días en plan original)

🎯 Quick Win Achieved!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment