# Manejo de Errores Stripe y Estrategias de Reintento

Stripe clasifica los errores en categorías con semántica distinta. Manejarlos
correctamente determina si el usuario ve un mensaje útil, si el sistema se
recupera solo, o si un pago legítimo se pierde por un error transitorio.

---

## Tipos de errores Stripe

### Jerarquía de excepciones del SDK Python

```
stripe.error.StripeError          # Base — todos los errores Stripe
├── stripe.error.CardError        # Tarjeta declinada por el banco emisor
├── stripe.error.RateLimitError   # Demasiadas peticiones (429)
├── stripe.error.InvalidRequestError  # Parámetros inválidos (400)
├── stripe.error.AuthenticationError  # API key inválida (401)
├── stripe.error.PermissionError  # Sin permisos para la operación (403)
├── stripe.error.APIConnectionError   # Error de red — Stripe no respondió
├── stripe.error.IdempotencyError # Idempotency key reutilizada incorrectamente
└── stripe.error.APIError         # Error interno de Stripe (5xx)
```

### Atributos clave en CardError

```python
try:
    stripe.PaymentIntent.create(...)
except stripe.error.CardError as e:
    error = e.error
    # error.code: 'card_declined', 'insufficient_funds', 'expired_card', etc.
    # error.decline_code: sub-código del banco emisor
    # error.message: mensaje técnico de Stripe (inglés)
    # error.param: parámetro que causó el error (si aplica)
    print(error.code, error.decline_code)
```

---

## Clasificación por tipo y estrategia de manejo

| Error | Tipo | ¿Reintentar? | Acción recomendada |
|-------|------|-------------|-------------------|
| `card_declined` | CardError | No | Pedir nueva tarjeta al usuario |
| `insufficient_funds` | CardError | No | Informar fondos insuficientes |
| `expired_card` | CardError | No | Pedir nueva tarjeta |
| `incorrect_cvc` | CardError | No (max 3x) | Pedir CVV de nuevo |
| `authentication_required` | CardError | Sí (con 3DS) | Redirigir a 3D Secure |
| `rate_limit_error` | RateLimitError | Sí (backoff) | Retry con espera exponencial |
| `api_connection_error` | APIConnectionError | Sí (backoff) | Retry — error de red |
| `api_error` (5xx) | APIError | Sí (backoff) | Retry — error interno Stripe |
| `idempotency_error` | IdempotencyError | No | Revisar la key — bug en código |
| `invalid_request_error` | InvalidRequestError | No | Bug en la llamada — no reintentar |

---

## Retry con backoff exponencial para errores transitorios

```python
import asyncio
import logging
import stripe
from typing import TypeVar, Callable, Awaitable

logger = logging.getLogger(__name__)
T = TypeVar("T")

# Errores que justifican reintento automático
ERRORES_REINTENTABLES = (
    stripe.error.RateLimitError,
    stripe.error.APIConnectionError,
    stripe.error.APIError,
)


async def stripe_con_retry(
    operacion: Callable[[], T | Awaitable[T]],
    max_reintentos: int = 3,
    base_delay_segundos: float = 1.0,
    operacion_nombre: str = "stripe_call",
) -> T:
    """
    Ejecuta una operación de Stripe con retry exponencial.
    Solo reintenta errores transitorios — nunca CardError ni errores de validación.
    """
    ultimo_error: Exception | None = None

    for intento in range(max_reintentos + 1):
        try:
            resultado = operacion()
            if asyncio.iscoroutine(resultado):
                return await resultado
            return resultado

        except ERRORES_REINTENTABLES as exc:
            ultimo_error = exc
            if intento == max_reintentos:
                logger.error(
                    "Operación Stripe agotó reintentos",
                    operacion=operacion_nombre,
                    intentos=intento + 1,
                    error=str(exc),
                )
                raise

            # Backoff exponencial con jitter: 1s, 2s, 4s + ruido aleatorio
            import random
            delay = (base_delay_segundos * (2 ** intento)) + random.uniform(0, 0.5)
            logger.warning(
                "Error transitorio Stripe — reintentando",
                operacion=operacion_nombre,
                intento=intento + 1,
                delay_segundos=round(delay, 2),
                error_tipo=type(exc).__name__,
            )
            await asyncio.sleep(delay)

        except (
            stripe.error.CardError,
            stripe.error.InvalidRequestError,
            stripe.error.AuthenticationError,
            stripe.error.IdempotencyError,
        ):
            # Errores no reintentables — fallar inmediatamente
            raise

    raise RuntimeError("No debería llegar aquí") from ultimo_error


# Uso
async def crear_intent_con_retry(orden: Orden) -> stripe.PaymentIntent:
    return await stripe_con_retry(
        lambda: stripe.PaymentIntent.create(
            amount=int(orden.total * 100),
            currency="mxn",
            idempotency_key=f"pi_{orden.id}",
        ),
        operacion_nombre=f"crear_payment_intent_{orden.id}",
    )
```

---

## Manejo de declinación de tarjeta: mensajes user-friendly

Los mensajes técnicos de Stripe están en inglés y son inapropiados para mostrar
al usuario. Mapear a mensajes en español claros y útiles:

```python
# services/mensajes_pago.py

MENSAJES_DECLINACION: dict[str | None, str] = {
    # Códigos de CardError.code
    "card_declined": "Tu tarjeta fue declinada. Verifica que los datos sean correctos o intenta con otra tarjeta.",
    "insufficient_funds": "Tu tarjeta no tiene fondos suficientes para esta compra.",
    "expired_card": "Tu tarjeta ha expirado. Por favor usa una tarjeta vigente.",
    "incorrect_cvc": "El código de seguridad (CVV) es incorrecto. Revísalo e intenta de nuevo.",
    "incorrect_number": "El número de tarjeta es incorrecto.",
    "invalid_expiry_month": "El mes de vencimiento no es válido.",
    "invalid_expiry_year": "El año de vencimiento no es válido.",
    "card_velocity_exceeded": "Has excedido el límite de intentos. Intenta más tarde o usa otra tarjeta.",
    "do_not_honor": "Tu banco no autorizó el cargo. Contacta a tu banco para más información.",
    "fraudulent": "Esta transacción fue bloqueada por seguridad. Contacta a tu banco.",
    "authentication_required": "Tu banco requiere autenticación adicional. Completa el proceso de verificación.",
    # Fallback para códigos desconocidos
    None: "No pudimos procesar tu pago. Verifica los datos de tu tarjeta o intenta con otra.",
}

# Códigos del banco emisor (decline_code) — más específicos
MENSAJES_DECLINE_CODE: dict[str, str] = {
    "insufficient_funds": "Fondos insuficientes en tu tarjeta.",
    "lost_card": "Esta tarjeta fue reportada como perdida. Contacta a tu banco.",
    "stolen_card": "Esta tarjeta fue reportada como robada. Contacta a tu banco.",
    "generic_decline": "Tu banco no autorizó el pago. Intenta con otra tarjeta o contacta a tu banco.",
    "do_not_honor": "Tu banco no autorizó el cargo. Contacta a tu banco directamente.",
    "card_not_supported": "Tu tarjeta no está habilitada para pagos en línea.",
    "currency_not_supported": "Tu tarjeta no acepta pagos en esta moneda.",
}


def obtener_mensaje_declinacion(error: stripe.error.CardError) -> str:
    """Retorna un mensaje en español apropiado para mostrar al usuario."""
    err = error.error

    # Intentar con decline_code primero (más específico)
    if err.decline_code and err.decline_code in MENSAJES_DECLINE_CODE:
        return MENSAJES_DECLINE_CODE[err.decline_code]

    # Luego con code
    return MENSAJES_DECLINACION.get(
        err.code,
        MENSAJES_DECLINACION[None],
    )
```

---

## Dunning management: reintentos automáticos en suscripciones

Cuando el cobro de una suscripción falla, Stripe implementa "dunning": reintentos
automáticos según el schedule configurado en el dashboard. El sistema maneja esto
de forma transparente, pero la app debe reaccionar a los eventos:

```python
# Configuración recomendada en el dashboard de Stripe (Smart Retries):
# - Reintento 1: 3 días después del fallo
# - Reintento 2: 5 días después del primer reintento
# - Reintento 3: 7 días después del segundo reintento
# - Después del último reintento: cancelar suscripción o marcar como unpaid

# La app debe manejar estos eventos para degradar o restaurar acceso:

async def manejar_invoice_payment_failed(invoice: dict) -> None:
    """
    Se llama cuando Stripe no pudo cobrar una factura.
    Estado de suscripción: active → past_due → unpaid → canceled
    """
    from app.models.subscripciones import Subscripcion
    from sqlalchemy import select, update

    stripe_subscription_id = invoice.get("subscription")
    if not stripe_subscription_id:
        return

    # Obtener el número de intento para decidir la acción
    attempt_count = invoice.get("attempt_count", 1)

    async with AsyncSessionLocal() as db:
        sub = await db.scalar(
            select(Subscripcion).where(
                Subscripcion.stripe_subscription_id == stripe_subscription_id
            )
        )
        if sub is None:
            return

        if attempt_count == 1:
            # Primer fallo — notificar al usuario, no restringir aún
            await enviar_notificacion_pago_fallido(sub.usuario_id, intento=1)
        elif attempt_count == 2:
            # Segundo fallo — advertencia urgente
            await enviar_notificacion_pago_fallido(sub.usuario_id, intento=2)
        else:
            # Tercer fallo o más — restringir acceso parcialmente
            await db.execute(
                update(Subscripcion)
                .where(Subscripcion.id == sub.id)
                .values(acceso_restringido=True)
            )
            await enviar_notificacion_suspension_inminente(sub.usuario_id)
        await db.commit()


async def manejar_subscripcion_cancelada_por_no_pago(sub: dict) -> None:
    """
    Se llama cuando Stripe cancela la suscripción por reintentos agotados.
    Revocar acceso completamente.
    """
    async with AsyncSessionLocal() as db:
        from sqlalchemy import update
        from app.models.subscripciones import Subscripcion

        await db.execute(
            update(Subscripcion)
            .where(Subscripcion.stripe_subscription_id == sub["id"])
            .values(estado="cancelada", acceso_restringido=True)
        )
        await db.commit()
```

---

## Dispute handling: evidencia requerida por tipo de disputa

Una disputa (chargeback) ocurre cuando el tarjetahabiente impugna un cargo
ante su banco. Stripe genera el evento `charge.dispute.created`.

```python
async def manejar_disputa(disputa: stripe.Dispute) -> None:
    """
    Responde automáticamente a disputas con evidencia disponible.
    El plazo para responder es típicamente 7-21 días según el banco emisor.
    """
    from app.models.ordenes import Orden

    # Obtener la orden relacionada
    payment_intent_id = disputa.payment_intent
    orden = await Orden.objects.aget(
        stripe_payment_intent_id=payment_intent_id
    )

    # Tipos de disputa y evidencia requerida
    razon = disputa.reason
    evidencia: dict = {}

    if razon in ("fraudulent", "unrecognized"):
        # El cliente dice no reconocer el cargo
        evidencia = {
            "billing_address": orden.direccion_facturacion,
            "customer_email_address": orden.usuario.email,
            "customer_ip_address": orden.ip_cliente,
            "receipt": orden.url_recibo,
            "shipping_documentation": orden.url_guia_envio,
            "uncategorized_text": (
                f"El cliente {orden.usuario.email} realizó la compra el "
                f"{orden.creado_en.strftime('%d/%m/%Y')} desde la IP "
                f"{orden.ip_cliente}. Se enviaron notificaciones de confirmación "
                f"a su correo registrado."
            ),
        }

    elif razon == "product_not_received":
        # El cliente dice no haber recibido el producto
        evidencia = {
            "shipping_documentation": orden.url_guia_envio,
            "shipping_tracking_number": orden.numero_guia,
            "shipping_carrier": orden.paqueteria,
            "shipping_date": orden.fecha_envio.strftime("%Y-%m-%d"),
            "uncategorized_text": (
                f"El pedido fue enviado el {orden.fecha_envio.strftime('%d/%m/%Y')} "
                f"por {orden.paqueteria} con guía {orden.numero_guia}. "
                f"Estado de entrega: {orden.estado_entrega}."
            ),
        }

    elif razon == "duplicate":
        # El cliente dice que el cargo está duplicado
        evidencia = {
            "duplicate_charge_id": disputa.charge,
            "duplicate_charge_explanation": (
                "Este cargo corresponde a una única transacción. "
                f"El ID de orden es {orden.numero}."
            ),
            "receipt": orden.url_recibo,
        }

    if evidencia:
        stripe.Dispute.modify(
            disputa.id,
            evidence=evidencia,
            submit=True,  # Enviar inmediatamente a Stripe para revisión
        )
        logger.info(
            "Evidencia de disputa enviada",
            disputa_id=disputa.id,
            razon=razon,
            orden_id=str(orden.id),
        )
```

### Tipos de disputa y evidencia óptima

| Razón de disputa | Evidencia más efectiva |
|-----------------|----------------------|
| `fraudulent` | IP del cliente, email de confirmación, historial de compras |
| `unrecognized` | Email de confirmación, descripción del producto, IP |
| `product_not_received` | Guía de envío, número de rastreo, fecha de entrega |
| `product_unacceptable` | Política de devoluciones, comunicación con el cliente |
| `duplicate` | Explicación del cargo, recibo original |
| `subscription_canceled` | Fecha de cancelación, términos de servicio |
| `credit_not_processed` | Documentación del reembolso ya procesado |

---

## Alertas y monitoreo de salud de pagos

```python
# Métricas clave a monitorear (enviar a tu sistema de alertas)

ALERTAS_PAGOS = {
    # Tasa de declinación > 10% en 1 hora → posible fraude o problema técnico
    "tasa_declinacion_alta": {
        "umbral": 0.10,
        "ventana_minutos": 60,
        "severidad": "alta",
    },
    # Más de 3 disputas en 24 horas → revisar calidad del producto/servicio
    "disputas_frecuentes": {
        "umbral": 3,
        "ventana_horas": 24,
        "severidad": "media",
    },
    # Webhook sin procesar por más de 5 minutos → posible problema en el endpoint
    "webhook_delayed": {
        "umbral_minutos": 5,
        "severidad": "alta",
    },
}
```
