ML Engine Updates: - Updated BTCUSD with Polygon API data (2024-2025): 215,699 new records - Re-trained all ML models: Attention (R²: 0.223), Base, Metamodel (87.3% confidence) - Backtest results: +176.71R profit with aggressive_filter strategy Documentation Consolidation: - Created docs/99-analisis/_MAP.md index with 13 new analysis documents - Consolidated inventories: removed duplicates from orchestration/inventarios/ - Updated ML_INVENTORY.yml with BTCUSD metrics and training results - Added execution reports: FASE11-BTCUSD, correction issues, alignment validation Architecture & Integration: - Updated all module documentation with NEXUS v3.4 frontmatter - Fixed _MAP.md indexes across all folders - Updated orchestration plans and traces Files: 229 changed, 5064 insertions(+), 1872 deletions(-) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
477 lines
16 KiB
Markdown
477 lines
16 KiB
Markdown
---
|
|
id: "RF-PAY-006"
|
|
title: "Sistema de Reembolsos"
|
|
type: "Requirement"
|
|
status: "Done"
|
|
priority: "Alta"
|
|
epic: "OQI-005"
|
|
project: "trading-platform"
|
|
version: "1.0.0"
|
|
created_date: "2025-12-05"
|
|
updated_date: "2026-01-04"
|
|
---
|
|
|
|
# RF-PAY-006: Sistema de Reembolsos
|
|
|
|
**Version:** 1.0.0
|
|
**Fecha:** 2025-12-05
|
|
**Estado:** 📋 Planificado
|
|
**Prioridad:** P2 (Media)
|
|
**Story Points:** 6
|
|
**Épica:** [OQI-005](../_MAP.md)
|
|
|
|
---
|
|
|
|
## Descripción
|
|
|
|
El sistema debe permitir procesar reembolsos parciales y totales de pagos completados, tanto iniciados por usuarios como por administradores, cumpliendo con políticas de reembolso y manteniendo auditoría completa de transacciones.
|
|
|
|
---
|
|
|
|
## Objetivo de Negocio
|
|
|
|
- Mejorar satisfacción del cliente con proceso claro
|
|
- Cumplir con derecho de desistimiento (14 días en EU/LATAM)
|
|
- Reducir disputas y chargebacks
|
|
- Mantener reputación de la marca
|
|
- Automatizar aprobaciones simples, escalar casos complejos
|
|
|
|
---
|
|
|
|
## Tipos de Reembolso
|
|
|
|
| Tipo | Descripción | Aprobación | Plazo |
|
|
|------|-------------|------------|-------|
|
|
| **Automático** | Dentro de 7 días, curso no iniciado | Automática | Inmediato |
|
|
| **Manual** | Fuera de 7 días o curso en progreso | Admin | 1-3 días hábiles |
|
|
| **Parcial** | Devolución de % del monto | Admin | 1-3 días hábiles |
|
|
| **Por Disputa** | Chargeback iniciado por banco | Automático | Según banco |
|
|
|
|
---
|
|
|
|
## Requisitos Funcionales
|
|
|
|
### RF-PAY-006.1: Solicitud de Reembolso por Usuario
|
|
|
|
**DEBE:**
|
|
1. Permitir solicitar reembolso desde historial de pagos
|
|
2. Mostrar elegibilidad según políticas
|
|
3. Solicitar motivo (lista predefinida + campo libre)
|
|
4. Confirmar acción con advertencia de consecuencias
|
|
5. Enviar email de confirmación de solicitud
|
|
|
|
**Motivos de Reembolso:**
|
|
```typescript
|
|
enum RefundReason {
|
|
NOT_AS_DESCRIBED = 'not_as_described', // Producto no es como se describió
|
|
ACCIDENTAL_PURCHASE = 'accidental_purchase', // Compra accidental
|
|
DUPLICATE = 'duplicate', // Pago duplicado
|
|
TECHNICAL_ISSUE = 'technical_issue', // Problema técnico
|
|
DISSATISFIED = 'dissatisfied', // No satisfecho con el contenido
|
|
OTHER = 'other', // Otro (requiere descripción)
|
|
}
|
|
```
|
|
|
|
### RF-PAY-006.2: Validación de Elegibilidad
|
|
|
|
**DEBE validar:**
|
|
1. Pago existe y status = `succeeded`
|
|
2. No se ha reembolsado previamente
|
|
3. Dentro de ventana de reembolso permitida
|
|
4. Usuario es el propietario del pago
|
|
5. Producto/servicio es reembolsable
|
|
|
|
**Reglas de elegibilidad:**
|
|
```typescript
|
|
function isRefundEligible(payment: Payment): boolean {
|
|
// 1. Pago exitoso
|
|
if (payment.status !== 'succeeded') return false;
|
|
|
|
// 2. No reembolsado previamente
|
|
if (payment.refundedAt) return false;
|
|
|
|
// 3. Dentro de ventana de reembolso
|
|
const daysSincePurchase = daysBetween(payment.createdAt, new Date());
|
|
|
|
if (payment.type === 'course_purchase') {
|
|
// Curso: 7 días si no ha iniciado
|
|
const courseProgress = await getCourseProgress(payment.userId, payment.courseId);
|
|
if (courseProgress === 0 && daysSincePurchase <= 7) return true;
|
|
|
|
// 14 días con progreso < 20%
|
|
if (courseProgress < 20 && daysSincePurchase <= 14) return true;
|
|
|
|
return false; // Fuera de ventana
|
|
}
|
|
|
|
if (payment.type === 'subscription') {
|
|
// Suscripción: NO reembolsable (solo cancelación)
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
```
|
|
|
|
### RF-PAY-006.3: Aprobación Automática vs Manual
|
|
|
|
**Aprobación Automática** (procesamiento inmediato):
|
|
- Pago < 7 días
|
|
- Curso con progreso = 0%
|
|
- Monto < $50 USD
|
|
- Usuario sin historial de reembolsos abusivos
|
|
|
|
**Aprobación Manual** (requiere revisión admin):
|
|
- Pago > 7 días
|
|
- Curso con progreso > 0%
|
|
- Monto > $50 USD
|
|
- Usuario con > 2 reembolsos en 30 días
|
|
|
|
### RF-PAY-006.4: Procesamiento de Reembolso
|
|
|
|
**Backend DEBE:**
|
|
1. Crear registro en `billing.refunds`
|
|
2. Llamar Stripe API: `stripe.refunds.create()`
|
|
3. Revocar acceso al recurso (curso, suscripción)
|
|
4. Actualizar `payment.status = 'refunded'`
|
|
5. Generar nota de crédito (factura)
|
|
6. Enviar email de confirmación
|
|
|
|
**Implementación:**
|
|
```typescript
|
|
async function processRefund(refundRequest: RefundRequest): Promise<Refund> {
|
|
const payment = await db.payment.findOne({ id: refundRequest.paymentId });
|
|
|
|
await db.transaction(async (tx) => {
|
|
// 1. Crear Refund en Stripe
|
|
const stripeRefund = await stripe.refunds.create({
|
|
payment_intent: payment.stripePaymentIntentId,
|
|
amount: refundRequest.amount, // En centavos (null = total)
|
|
reason: refundRequest.reason,
|
|
metadata: {
|
|
userId: payment.userId,
|
|
refundRequestId: refundRequest.id,
|
|
}
|
|
});
|
|
|
|
// 2. Guardar en BD
|
|
const refund = await tx.refund.create({
|
|
id: uuid(),
|
|
paymentId: payment.id,
|
|
userId: payment.userId,
|
|
amount: stripeRefund.amount / 100,
|
|
currency: stripeRefund.currency,
|
|
status: stripeRefund.status, // succeeded | pending | failed
|
|
stripeRefundId: stripeRefund.id,
|
|
reason: refundRequest.reason,
|
|
notes: refundRequest.notes,
|
|
processedBy: refundRequest.approvedBy, // userId del admin o 'system'
|
|
});
|
|
|
|
// 3. Actualizar Payment
|
|
await tx.payment.update({ id: payment.id }, {
|
|
status: 'refunded',
|
|
refundedAt: new Date(),
|
|
});
|
|
|
|
// 4. Revocar acceso
|
|
if (payment.type === 'course_purchase') {
|
|
await this.courseService.revokeAccess(payment.userId, payment.courseId);
|
|
}
|
|
|
|
return refund;
|
|
});
|
|
|
|
// 5. Generar nota de crédito
|
|
await this.invoiceService.generateCreditNote(payment.id);
|
|
|
|
// 6. Enviar email
|
|
await this.emailService.sendRefundConfirmation(refund);
|
|
}
|
|
```
|
|
|
|
### RF-PAY-006.5: Reembolso Parcial
|
|
|
|
**DEBE:**
|
|
1. Permitir admin especificar monto exacto a reembolsar
|
|
2. Validar: `0 < amount <= payment.amount`
|
|
3. Actualizar payment parcialmente (no marcar como refunded)
|
|
4. Mantener acceso parcial si aplica
|
|
5. Registrar monto parcial en `refunds.amount`
|
|
|
|
**Caso de uso:** Usuario completó 50% del curso, reembolsar 50% del precio.
|
|
|
|
### RF-PAY-006.6: Reembolso a Wallet vs Método Original
|
|
|
|
**DEBE:**
|
|
1. Por defecto, reembolsar a método de pago original (tarjeta)
|
|
2. Si pago fue con wallet, reembolsar a wallet
|
|
3. Permitir usuario elegir wallet (si pago fue con tarjeta)
|
|
4. Procesar reembolso a wallet instantáneamente
|
|
5. Reembolso a tarjeta toma 5-10 días hábiles (limitación bancaria)
|
|
|
|
### RF-PAY-006.7: Dashboard de Reembolsos (Admin)
|
|
|
|
**DEBE:**
|
|
1. Listar todas las solicitudes pendientes
|
|
2. Mostrar información del pago y usuario
|
|
3. Mostrar motivo y notas adicionales
|
|
4. Permitir aprobar/rechazar con un click
|
|
5. Permitir ajustar monto (reembolso parcial)
|
|
6. Agregar notas internas
|
|
|
|
---
|
|
|
|
## Flujo de Reembolso
|
|
|
|
### Flujo Automático
|
|
|
|
```
|
|
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
|
│ Usuario │ │ Frontend │ │ Backend │ │ Stripe │
|
|
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘
|
|
│ │ │ │
|
|
│ Click "Solicitar │ │ │
|
|
│ reembolso" │ │ │
|
|
│──────────────────▶│ │ │
|
|
│ │ │ │
|
|
│ Selecciona motivo │ │ │
|
|
│ + confirma │ │ │
|
|
│──────────────────▶│ │ │
|
|
│ │ │ │
|
|
│ │ POST /refunds │ │
|
|
│ │ { paymentId, │ │
|
|
│ │ reason, notes } │ │
|
|
│ │──────────────────▶│ │
|
|
│ │ │ │
|
|
│ │ │ 1. Validate │
|
|
│ │ │ eligibility │
|
|
│ │ │ │
|
|
│ │ │ 2. Check if │
|
|
│ │ │ auto-approve │
|
|
│ │ │ (YES) │
|
|
│ │ │ │
|
|
│ │ │ 3. Create Refund │
|
|
│ │ │──────────────────▶│
|
|
│ │ │◀──────────────────│
|
|
│ │ │ { refund } │
|
|
│ │ │ │
|
|
│ │ │ 4. Revoke access │
|
|
│ │ │ │
|
|
│ │ │ 5. Update Payment │
|
|
│ │ │ │
|
|
│ │◀──────────────────│ │
|
|
│ │ { status: │ │
|
|
│ │ 'approved' } │ │
|
|
│ │ │ │
|
|
│◀──────────────────│ │ │
|
|
│ "Reembolso │ │ │
|
|
│ procesado" │ │ │
|
|
│ │ │ │
|
|
```
|
|
|
|
### Flujo Manual (Requiere Aprobación)
|
|
|
|
```
|
|
Usuario → Solicita reembolso
|
|
↓
|
|
Backend → Crear RefundRequest (status: pending)
|
|
↓
|
|
Admin → Recibe notificación en dashboard
|
|
↓
|
|
Admin → Revisa motivo y datos del usuario
|
|
↓
|
|
Admin → Decide:
|
|
├─ Aprobar (full o parcial) → Procesar reembolso
|
|
└─ Rechazar → Enviar email con justificación
|
|
```
|
|
|
|
---
|
|
|
|
## Modelo de Datos
|
|
|
|
```typescript
|
|
@Entity({ name: 'refund_requests', schema: 'billing' })
|
|
class RefundRequest {
|
|
id: string; // UUID
|
|
paymentId: string; // FK a payments
|
|
userId: string; // FK a users
|
|
amount: Decimal; // Monto solicitado (null = total)
|
|
reason: RefundReason;
|
|
notes?: string; // Notas del usuario
|
|
status: RefundRequestStatus; // pending | approved | rejected | processed
|
|
reviewedBy?: string; // userId del admin
|
|
reviewNotes?: string; // Notas del admin
|
|
approvedAt?: Date;
|
|
rejectedAt?: Date;
|
|
processedAt?: Date;
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
}
|
|
|
|
@Entity({ name: 'refunds', schema: 'billing' })
|
|
class Refund {
|
|
id: string; // UUID
|
|
paymentId: string; // FK a payments
|
|
userId: string; // FK a users
|
|
amount: Decimal; // Monto reembolsado
|
|
currency: string; // USD
|
|
status: RefundStatus; // succeeded | pending | failed | canceled
|
|
stripeRefundId: string; // re_xxx
|
|
reason: RefundReason;
|
|
notes?: string;
|
|
processedBy: string; // 'system' | userId del admin
|
|
destination: RefundDestination; // original_payment_method | wallet
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Reglas de Negocio
|
|
|
|
### RN-001: Ventanas de Reembolso
|
|
|
|
| Producto | Condición | Días | Tipo |
|
|
|----------|-----------|------|------|
|
|
| Curso | Progreso 0% | 7 | Automático |
|
|
| Curso | Progreso < 20% | 14 | Manual |
|
|
| Curso | Progreso > 20% | 0 | No elegible |
|
|
| Suscripción | Cualquiera | 0 | Solo cancelación |
|
|
| Wallet topup | Saldo no usado | 30 | Manual |
|
|
|
|
### RN-002: Límites de Reembolso
|
|
|
|
- **Máximo 3 reembolsos por usuario por año**
|
|
- Usuario con > 3 reembolsos/año → Flagged para revisión
|
|
- Usuario con > 5 reembolsos/año → Bloqueado de compras
|
|
|
|
### RN-003: Fees de Stripe
|
|
|
|
- Stripe **NO devuelve** el fee de procesamiento (~2.9% + $0.30)
|
|
- Trading Platform absorbe este costo
|
|
- Calcular pérdida real: `loss = refundAmount * 0.029 + 0.30`
|
|
|
|
### RN-004: Reembolso de Suscripciones
|
|
|
|
- Suscripciones **NO son reembolsables**
|
|
- Usuario puede cancelar y mantener acceso hasta `currentPeriodEnd`
|
|
- Excepción: Error técnico grave → reembolso manual por admin
|
|
|
|
---
|
|
|
|
## Seguridad
|
|
|
|
### Prevención de Abuso
|
|
|
|
- Trackear ratio de reembolsos por usuario
|
|
- Alertar si `refunds / purchases > 50%`
|
|
- Bloquear usuarios con patrón abusivo
|
|
- Requerir KYC para reembolsos > $100
|
|
|
|
### Auditoría
|
|
|
|
- Registrar todos los cambios de estado
|
|
- Logs de aprobaciones/rechazos de admin
|
|
- Notificar a finanzas de reembolsos > $500
|
|
|
|
---
|
|
|
|
## Políticas de Reembolso (Visible al Usuario)
|
|
|
|
```markdown
|
|
# Política de Reembolsos
|
|
|
|
## Cursos
|
|
- **7 días de garantía** si no has iniciado el curso
|
|
- **14 días** si has completado menos del 20% del contenido
|
|
- Reembolso completo a tu método de pago original
|
|
|
|
## Suscripciones
|
|
- No son reembolsables
|
|
- Puedes cancelar en cualquier momento
|
|
- Mantienes acceso hasta el final de tu período de facturación
|
|
|
|
## Procesamiento
|
|
- Reembolsos automáticos: Inmediatos (a wallet) o 5-10 días (a tarjeta)
|
|
- Reembolsos manuales: 1-3 días hábiles de revisión
|
|
|
|
## Contacto
|
|
Si tienes dudas, contacta a support@trading.com
|
|
```
|
|
|
|
---
|
|
|
|
## Webhooks Relacionados
|
|
|
|
| Evento | Acción |
|
|
|--------|--------|
|
|
| `charge.refunded` | Actualizar Refund a succeeded, enviar email |
|
|
| `charge.refund.updated` | Sincronizar estado del reembolso |
|
|
|
|
---
|
|
|
|
## Manejo de Errores
|
|
|
|
| Error | Código | Mensaje Usuario |
|
|
|-------|--------|-----------------|
|
|
| No elegible | 400 | Este pago no es elegible para reembolso. |
|
|
| Ya reembolsado | 409 | Este pago ya fue reembolsado. |
|
|
| Fuera de ventana | 400 | El período de reembolso ha expirado. |
|
|
| Stripe error | 502 | Error al procesar reembolso. Intenta más tarde. |
|
|
| Límite excedido | 403 | Has excedido el límite de reembolsos permitidos. |
|
|
|
|
---
|
|
|
|
## Configuración Requerida
|
|
|
|
```env
|
|
# Refund Policy
|
|
REFUND_WINDOW_DAYS_ZERO_PROGRESS=7
|
|
REFUND_WINDOW_DAYS_LOW_PROGRESS=14
|
|
REFUND_PROGRESS_THRESHOLD=20 # %
|
|
REFUND_AUTO_APPROVE_AMOUNT=50 # USD
|
|
MAX_REFUNDS_PER_YEAR=3
|
|
|
|
# Admin Notifications
|
|
REFUND_APPROVAL_SLACK_WEBHOOK=https://hooks.slack.com/...
|
|
```
|
|
|
|
---
|
|
|
|
## Métricas de Negocio
|
|
|
|
- **Refund Rate:** (Reembolsos / Pagos totales) * 100
|
|
- **Avg Refund Amount:** Promedio de monto reembolsado
|
|
- **Refund Reasons:** Distribución de motivos
|
|
- **Time to Refund:** Tiempo promedio de procesamiento
|
|
- **Stripe Fee Loss:** Total de fees perdidos por reembolsos
|
|
|
|
---
|
|
|
|
## Criterios de Aceptación
|
|
|
|
- [ ] Usuario puede solicitar reembolso desde historial de pagos
|
|
- [ ] Sistema valida elegibilidad según políticas
|
|
- [ ] Reembolsos elegibles se aprueban automáticamente
|
|
- [ ] Reembolsos complejos van a dashboard de admin
|
|
- [ ] Admin puede aprobar/rechazar solicitudes pendientes
|
|
- [ ] Admin puede hacer reembolsos parciales
|
|
- [ ] Acceso a curso se revoca después de reembolso
|
|
- [ ] Reembolso a tarjeta se procesa via Stripe
|
|
- [ ] Reembolso a wallet es instantáneo
|
|
- [ ] Nota de crédito se genera automáticamente
|
|
- [ ] Email de confirmación se envía al usuario
|
|
- [ ] Usuario con reembolsos abusivos es flagged
|
|
|
|
---
|
|
|
|
## Especificación Técnica Relacionada
|
|
|
|
- [ET-PAY-006: Refund System](../especificaciones/ET-PAY-006-refunds.md)
|
|
|
|
## Historias de Usuario Relacionadas
|
|
|
|
- [US-PAY-010: Ver Historial de Pagos](../historias-usuario/US-PAY-010-ver-historial.md)
|