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.
Approach: Aprovechar el WebView de MPay para todo el flujo de apelación. Issuing solo parsea estados y muestra información.
Cambios principales:
- Parsear nuevos campos de MPay API: Agregar DTO
ApplicationStatusDtopara leerappeal_needed,appeal_completed,final_decision - Mapear a estados existentes: Reutilizar
Step=KYCpara apelación pendiente, agregarAPPEAL_SUBMITTEDyAPPEAL_DENIED - Actualizar lógica: Modificar
DetermineStep()para priorizar estados de apelación
- Extender enum: Agregar 2 casos a
BnplStep(appealSubmitted, appealDenied) - 2 pantallas nuevas: Informativas simples (en proceso, denegada) sin estado complejo
- Reutilizar WebView existente: Mismo flujo que KYC - MPay maneja upload de documentos
- 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
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
¿Necesitamos guardar el URL en DB/Storage? ❌ NO
Razón: El onboardingUrl viene fresco del MPay API en cada request:
-
Primera vez (usuario inicia onboarding):
- MPay API retorna
onboardingUrl: https://mpay.com/kyc/abc123 - Frontend almacena en Cubit state (memoria)
- Abre WebView
- MPay API retorna
-
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
onboardingUrlactualizado (mismo usuario, puede continuar donde quedó) - Frontend abre WebView con nuevo URL
-
Para apelación es igual:
- MPay retorna
onboardingUrlespecífico para apelación - Mismo mecanismo de reanudación
- MPay retorna
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.
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:
- Reconocer nuevos estados de MPay API
- Mostrar 2 pantallas simples informativas (apelación en proceso, denegada)
- Abrir WebView de MPay cuando el usuario necesite apelar
- 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
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
}| Condición MPay | Step App | Acción | onboardingUrl |
|---|---|---|---|
appeal_needed=true + appeal_completed=false |
KYC |
Abrir WebView (MPay maneja upload) | Sí |
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.
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.
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.
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.
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.
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.
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.
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.
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.
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(),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.
El WebView ya tiene mecanismo de detección (línea 287-303 en bnpl_webview_page.dart):
- Monitorea cambios de URL via
setOnUrlChange - Cuando MPay redirige a URL de cierre, hace
Navigator.pop(true) - La pantalla que abrió el WebView recibe
true
Lo que FALTA: Refrescar estado del usuario cuando WebView cierra.
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.
| 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)
| 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
| 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.
- Backend retorna
Step=KYCcononboardingUrl - App abre WebView de MPay
- Usuario completa upload de documentos en WebView de MPay
- MPay WebView muestra pantalla de documentos
- Usuario puede adjuntar múltiples PDFs
- MPay valida documentos
- MPay marca
appeal_completed=true - Backend retorna
Step=APPEAL_SUBMITTED - App muestra
AppealInProgressPage
- Backend retorna
Step=KYC_FAILED - App muestra pantalla existente de rechazo
- (Flujo actual - sin cambios)
- Backend consulta MPay API
- Si
appeal_needed=trueyappeal_completed=false→ Step=KYC - App abre WebView con URL de MPay (usuario puede continuar)
- Backend retorna
Step=APPEAL_SUBMITTED - App muestra
AppealInProgressPage - Mensaje: "Estamos revisando tu información"
- Webhook de MPay:
status=Rejected - Backend actualiza
Step=APPEAL_DENIED - App muestra
AppealDeniedPage - (Opcional) Email enviado
- Webhook de MPay:
status=Applied - Backend actualiza
Step=HOME - App muestra monto aprobado (flujo normal)
- (Opcional) Email enviado
No hay migraciones DB ✅
Deployment:
- Merge PR
- Deploy a staging
- Probar con cuenta de prueba en staging (MPay debe tener data de prueba con appeal states)
- Deploy a producción
Validación Post-Deploy:
- Verificar que parsing de
application_statusfunciona (logs) - Verificar que
DetermineStepretorna estados correctos
No hay build_runner ✅ (solo cambios en enum, no en DTOs con Freezed)
Deployment:
- Merge PR
- Build y deploy a TestFlight
- Probar manualmente los 8 escenarios
- 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)
- MPay debe desplegar primero: Validar en staging que
error.application_statusexiste - Graceful degradation: Si
application_statuses null, fallback a lógica actual funciona - No breaking changes: Todo es backward compatible
| Riesgo Original | Mitigación Simplificada |
|---|---|
| No aplica - MPay WebView maneja | |
| No aplica - MPay WebView maneja | |
| 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)
- Agregar
ApplicationStatusDtoaMercanduProductsResponse.cs - Agregar constantes a
FinancingProfileSteps.cs - Modificar
DetermineStep()enFinancingService.cs - (Opcional) Modificar webhook handler
- Probar con Postman/unit tests
- Agregar 2 casos a
BnplStepenum enuser_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
- Probar 8 escenarios con data de prueba
- Verificar WebView se abre correctamente
- Verificar pantallas informativas
- Verificar backward compatibility
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!