---
name: stripe-pagos
description: >
  Integración de Stripe: Payment Intents, Checkout Sessions, suscripciones,
  webhooks con verificación de firma, idempotency keys y manejo de eventos
  async. Cargar cuando se implemente cualquier flujo de pago con Stripe:
  cobros únicos, suscripciones recurrentes, reembolsos, o configuración
  de webhooks.
version: "1.0.0"
herramientasPermitidas: [Read, Bash]
evolvable: true  # default para skill estandar
exclusiones:
  - "No cargar para integrar otros procesadores de pago (Conekta, Mercado Pago, PayPal, Adyen) — este skill es exclusivo de la API de Stripe; para otros procesadores cargar o crear el skill correspondiente."
  - "No cargar para análisis de fraude o reglas de detección en Stripe Radar — para configurar Radar y reglas de fraude usar la documentación de Stripe Radar directamente."
  - "No cargar para facturación electrónica fiscal (CFDI en México, facturas SAT) — la factura Stripe no es equivalente al CFDI; para CFDI cargar el skill de facturación fiscal correspondiente."
  - "No cargar para implementar criptomonedas o pagos en cripto con Stripe Crypto — este skill cubre flujos de pago fiat; para cripto consultar la documentación de Stripe Crypto directamente."
---
# Stripe Pagos — Guía de Integración Completa

## Cuándo NO cargar

- La tarea es integrar Conekta, Mercado Pago, PayPal o Adyen: cargar el skill del procesador correspondiente.
- La tarea es facturación electrónica fiscal CFDI (SAT México): la factura Stripe no es CFDI; cargar el skill de facturación fiscal.
- La tarea es configurar reglas de fraude en Stripe Radar: usar la documentación de Stripe Radar directamente.
- La tarea es pagos en criptomonedas: usar la documentación de Stripe Crypto directamente.


Stripe es el procesador de pagos más completo disponible. Esta guía cubre
los patrones de implementación correctos para cada flujo de pago, con énfasis
en seguridad, idempotencia y manejo correcto del ciclo de vida de los pagos.

---

## 1. Flujos de pago disponibles y cuándo usar cada uno

```
Payment Intents (API directa)  → Control máximo, SPA, móvil nativo
Checkout Session               → Página de pago hosted por Stripe (más simple, más seguro)
PaymentElement                 → UI embedding en tu app, Stripe maneja seguridad
Subscriptions + Price          → Cobros recurrentes (mensual, anual, uso medido)
Connect                        → Marketplace: pagos entre plataforma y vendedores
```

### Matriz de decisión detallada

| Criterio | Checkout Session | Payment Intents | Subscriptions |
|----------|-----------------|-----------------|---------------|
| Alcance PCI | SAQ-A (mínimo) | SAQ-A-EP | SAQ-A-EP |
| Control de UI | Ninguno (Stripe UI) | Total | Total |
| Apple/Google Pay | Automático | Manual | Manual |
| Cobros recurrentes | No | No (usar Subscriptions) | Sí |
| Plataformas móviles | Redirección | Nativo (SDK) | Nativo (SDK) |
| Implementación | Muy simple | Moderada | Moderada-compleja |

**Regla**: usar Checkout Session por defecto. Cambiar a Payment Intents solo si
se requiere control total del UI o la integración es móvil nativa.

---

## 2. Webhook handler seguro (CRÍTICO)

El webhook es la fuente de verdad de todos los eventos de pago. La verificación
de firma es OBLIGATORIA — sin ella cualquiera puede enviar eventos falsos.

```python
# routes/webhooks.py
import stripe
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
from app.core.config import settings
from app.services.pagos import PagosService

router = APIRouter()
stripe.api_key = settings.STRIPE_SECRET_KEY


@router.post("/webhook/stripe")
async def stripe_webhook(
    request: Request,
    background_tasks: BackgroundTasks,
) -> dict:
    payload = await request.body()
    sig_header = request.headers.get("stripe-signature")

    # SIEMPRE verificar la firma antes de procesar
    try:
        evento = stripe.Webhook.construct_event(
            payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
        )
    except stripe.error.SignatureVerificationError:
        raise HTTPException(status_code=400, detail="Firma inválida")
    except ValueError:
        raise HTTPException(status_code=400, detail="Payload inválido")

    # Responder 200 inmediatamente — procesar en background
    # Stripe reintenta si no recibe respuesta en 30 segundos
    background_tasks.add_task(PagosService.procesar_evento_webhook, evento)
    return {"status": "recibido"}


# services/pagos.py
class PagosService:

    @staticmethod
    async def procesar_evento_webhook(evento: stripe.Event) -> None:
        """Procesa un evento de Stripe. Idempotente por diseño."""
        from app.db.session import AsyncSessionLocal
        from app.models.eventos import EventoStripe

        async with AsyncSessionLocal() as db:
            # Idempotencia: verificar si ya procesamos este evento
            if await PagosService._evento_ya_procesado(evento.id, db):
                return

            match evento.type:
                case "checkout.session.completed":
                    await PagosService._procesar_checkout_completado(
                        evento.data.object, db
                    )
                case "customer.subscription.created":
                    await PagosService._procesar_subscripcion_creada(
                        evento.data.object, db
                    )
                case "customer.subscription.updated":
                    await PagosService._procesar_subscripcion_actualizada(
                        evento.data.object, db
                    )
                case "customer.subscription.deleted":
                    await PagosService._procesar_subscripcion_cancelada(
                        evento.data.object, db
                    )
                case "invoice.payment_failed":
                    await PagosService._procesar_pago_fallido(
                        evento.data.object, db
                    )
                case "invoice.payment_succeeded":
                    await PagosService._procesar_pago_exitoso(
                        evento.data.object, db
                    )
                case _:
                    pass  # Ignorar eventos no manejados — no es un error

            await PagosService._registrar_evento_procesado(evento.id, db)
            await db.commit()

    @staticmethod
    async def _evento_ya_procesado(evento_id: str, db: AsyncSession) -> bool:
        from sqlalchemy import select
        from app.models.eventos import EventoStripe

        resultado = await db.execute(
            select(EventoStripe).where(EventoStripe.stripe_event_id == evento_id)
        )
        return resultado.scalar_one_or_none() is not None

    @staticmethod
    async def _registrar_evento_procesado(evento_id: str, db: AsyncSession) -> None:
        from app.models.eventos import EventoStripe

        db.add(EventoStripe(stripe_event_id=evento_id))
        await db.flush()
```

---

## 3. Idempotency keys obligatorias

Una idempotency key garantiza que si la operación se envía dos veces
(retry de red, doble clic, worker duplicado), Stripe solo la ejecuta una vez.
Stripe retiene el resultado por 24 horas.

```python
import stripe
from app.core.config import settings

stripe.api_key = settings.STRIPE_SECRET_KEY


async def crear_payment_intent(orden: Orden) -> stripe.PaymentIntent:
    """NUNCA crear un PaymentIntent sin idempotency key."""
    return stripe.PaymentIntent.create(
        amount=int(orden.total * 100),  # Stripe trabaja en centavos
        currency="mxn",
        customer=orden.usuario.stripe_customer_id,
        description=f"Orden #{orden.numero}",
        metadata={
            "orden_id": str(orden.id),
            "usuario_id": str(orden.usuario_id),
        },
        idempotency_key=f"pi_{orden.id}",  # OBLIGATORIO
    )


async def crear_subscripcion(usuario: Usuario, price_id: str) -> stripe.Subscription:
    """Idempotency key incluye usuario Y plan — evita duplicados por plan."""
    return stripe.Subscription.create(
        customer=usuario.stripe_customer_id,
        items=[{"price": price_id}],
        expand=["latest_invoice.payment_intent"],
        metadata={
            "usuario_id": str(usuario.id),
            "price_id": price_id,
        },
        idempotency_key=f"sub_{usuario.id}_{price_id}",  # OBLIGATORIO
    )
```

### Convención de idempotency keys por operación

| Operación | Formato recomendado |
|-----------|---------------------|
| PaymentIntent.create | `pi_{orden.id}` |
| Subscription.create | `sub_{usuario.id}_{price_id}` |
| checkout.Session.create | `checkout_{orden.id}` |
| Refund.create (total) | `refund_{pago.id}` |
| Refund.create (parcial) | `refund_partial_{pago.id}_{monto}` |
| Customer.create | `customer_{usuario.id}` |

---

## 4. Checkout Session completo

```python
import time
import stripe
from app.core.config import settings
from app.models.ordenes import Orden
from app.models.usuarios import Usuario

stripe.api_key = settings.STRIPE_SECRET_KEY


async def crear_checkout_session(orden: Orden, usuario: Usuario) -> str:
    """Crea una Checkout Session y retorna la URL de pago."""
    session = stripe.checkout.Session.create(
        payment_method_types=["card"],
        mode="payment",  # "subscription" para cobros recurrentes
        customer_email=usuario.email,
        customer=usuario.stripe_customer_id,  # Si ya existe el customer
        line_items=[
            {
                "price_data": {
                    "currency": "mxn",
                    "unit_amount": int(orden.total * 100),  # centavos
                    "product_data": {
                        "name": orden.descripcion,
                        "description": f"Orden #{orden.numero}",
                    },
                },
                "quantity": 1,
            }
        ],
        # URLs de retorno — NUNCA asumir que success_url = pago completado
        success_url=(
            f"{settings.BASE_URL}/pagos/exitoso"
            "?session_id={CHECKOUT_SESSION_ID}"
        ),
        cancel_url=f"{settings.BASE_URL}/pagos/cancelado",
        metadata={
            "orden_id": str(orden.id),
            "usuario_id": str(usuario.id),
        },
        expires_at=int(time.time()) + 1800,  # Expira en 30 minutos
        idempotency_key=f"checkout_{orden.id}",  # OBLIGATORIO
    )
    return session.url


async def verificar_checkout_session(session_id: str) -> stripe.checkout.Session:
    """Verificar el estado real de la sesión — no confiar solo en el redirect."""
    return stripe.checkout.Session.retrieve(
        session_id,
        expand=["payment_intent", "customer"],
    )
```

---

## 5. Suscripciones con estado correcto

Los estados de Stripe tienen semántica específica. Mapearlos correctamente
es crítico para dar o quitar acceso al servicio:

```python
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
import stripe

# Mapeo canónico de estados de Stripe a estados de la app
ESTADOS_STRIPE: dict[str, str] = {
    "active": "activa",           # Todo correcto — dar acceso
    "trialing": "en_prueba",      # Periodo de prueba — dar acceso
    "past_due": "pago_vencido",   # Reintentos en progreso — dar acceso temporal
    "unpaid": "suspendida",       # Reintentos agotados — restringir acceso
    "canceled": "cancelada",      # Cancelada definitivamente — revocar acceso
    "incomplete": "incompleta",   # Pago inicial fallido — no dar acceso
    "incomplete_expired": "expirada",  # Nunca completó pago inicial
    "paused": "pausada",          # Pausada manualmente — sin cobros
}


async def sincronizar_subscripcion(
    sub: stripe.Subscription, db: AsyncSession
) -> None:
    """Sincroniza el estado local con el estado real de Stripe."""
    from sqlalchemy import select, update
    from app.models.subscripciones import Subscripcion

    nuevo_estado = ESTADOS_STRIPE.get(sub.status, "desconocido")

    await db.execute(
        update(Subscripcion)
        .where(Subscripcion.stripe_subscription_id == sub.id)
        .values(
            estado=nuevo_estado,
            periodo_actual_inicio=datetime.fromtimestamp(sub.current_period_start),
            periodo_actual_fin=datetime.fromtimestamp(sub.current_period_end),
            cancelar_al_periodo_fin=sub.cancel_at_period_end,
            actualizado_en=datetime.utcnow(),
        )
    )
    await db.flush()


async def cancelar_al_periodo_fin(
    subscripcion_id: str, db: AsyncSession
) -> stripe.Subscription:
    """Cancela la suscripción al final del periodo pagado (no inmediatamente)."""
    from sqlalchemy import select
    from app.models.subscripciones import Subscripcion

    sub_local = await db.scalar(
        select(Subscripcion).where(Subscripcion.id == subscripcion_id)
    )
    if sub_local is None:
        raise ValueError(f"Subscripción {subscripcion_id} no encontrada")

    sub_stripe = stripe.Subscription.modify(
        sub_local.stripe_subscription_id,
        cancel_at_period_end=True,
    )
    await sincronizar_subscripcion(sub_stripe, db)
    return sub_stripe
```

---

## 6. Reembolsos

```python
import stripe
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings

stripe.api_key = settings.STRIPE_SECRET_KEY


async def reembolsar_pago(
    pago_id: str,
    db: AsyncSession,
    monto_parcial_centavos: int | None = None,
    razon: str = "requested_by_customer",
) -> stripe.Refund:
    """
    Reembolsa un pago. Si monto_parcial_centavos es None, reembolsa el total.
    razon: 'requested_by_customer' | 'fraudulent' | 'duplicate'
    """
    from sqlalchemy import select
    from app.models.pagos import Pago

    pago = await db.scalar(select(Pago).where(Pago.id == pago_id))
    if pago is None:
        raise ValueError(f"Pago {pago_id} no encontrado")
    if pago.estado in ("reembolsado", "cancelado"):
        raise ValueError(f"Pago {pago_id} ya fue reembolsado o cancelado")

    # Construir idempotency key diferenciada por tipo de reembolso
    if monto_parcial_centavos:
        idem_key = f"refund_partial_{pago_id}_{monto_parcial_centavos}"
    else:
        idem_key = f"refund_{pago_id}"

    kwargs: dict = {
        "payment_intent": pago.stripe_payment_intent_id,
        "reason": razon,
        "idempotency_key": idem_key,
    }
    if monto_parcial_centavos:
        kwargs["amount"] = monto_parcial_centavos

    reembolso = stripe.Refund.create(**kwargs)

    # Actualizar estado local — el webhook también llegará, debe ser idempotente
    nuevo_estado = "parcialmente_reembolsado" if monto_parcial_centavos else "reembolsado"
    from sqlalchemy import update
    await db.execute(
        update(Pago)
        .where(Pago.id == pago_id)
        .values(
            estado=nuevo_estado,
            stripe_refund_id=reembolso.id,
        )
    )
    await db.flush()
    return reembolso
```

---

## 7. MUST DO / MUST NOT DO

### MUST DO

- **SIEMPRE** verificar la firma del webhook con `stripe.Webhook.construct_event`
  antes de procesar cualquier evento.
- **Idempotency key** en TODA operación de creación de Stripe (`create`).
- Usar modo `test` (`sk_test_...`) en desarrollo. NUNCA live keys en código.
- Almacenar en tu BD: `stripe_customer_id`, `stripe_subscription_id`,
  `stripe_payment_intent_id`, `stripe_refund_id`.
- Manejar webhooks de forma asíncrona: responder 200 rápido, procesar en background.
- Reconciliar estados: siempre confiar en el webhook, no en el redirect de `success_url`.
- Verificar el estado de la sesión o intent vía API al mostrar la pantalla de éxito.
- Registrar todos los eventos procesados en una tabla para garantizar idempotencia.
- Manejar `invoice.payment_failed` para suspender acceso cuando los reintentos se agotan.

### MUST NOT DO

- **NUNCA** almacenar números de tarjeta, CVV ni fechas de expiración.
- **NUNCA** procesar un webhook sin verificar la firma.
- **NUNCA** asumir que `success_url` significa que el pago fue completado.
- **NUNCA** reutilizar idempotency keys entre operaciones distintas.
- **NUNCA** hardcodear stripe keys — siempre variables de entorno.
- **NUNCA** ignorar el evento `invoice.payment_failed` en suscripciones.
- **NUNCA** loggear payloads de webhooks sin filtrar datos sensibles.
- **NUNCA** hacer `db.commit()` dentro del service — solo en el endpoint o tarea.
- **NUNCA** construir un formulario que reciba el número de tarjeta en tu servidor.

---

## 8. Testing con Stripe CLI

```bash
# Paso 1: instalar y autenticar Stripe CLI
# https://stripe.com/docs/stripe-cli
stripe login

# Paso 2: escuchar webhooks localmente (mantener en terminal aparte)
stripe listen --forward-to localhost:8000/webhook/stripe

# Paso 3: disparar eventos específicos en otra terminal
stripe trigger checkout.session.completed
stripe trigger customer.subscription.created
stripe trigger customer.subscription.updated
stripe trigger customer.subscription.deleted
stripe trigger invoice.payment_failed
stripe trigger invoice.payment_succeeded
stripe trigger charge.dispute.created

# Disparar con datos personalizados (fixture)
stripe trigger payment_intent.succeeded \
  --override payment_intent:metadata.orden_id=test-123

# Ver eventos recientes
stripe events list --limit 10

# Tarjetas de prueba para Payment Intents
# 4242 4242 4242 4242  → pago exitoso
# 4000 0000 0000 0002  → tarjeta declinada (insufficient_funds)
# 4000 0025 0000 3155  → requiere 3D Secure
# 4000 0000 0000 9995  → fondos insuficientes
# 4000 0000 0000 0069  → tarjeta expirada
```

### Test unitario de webhook con firma simulada

```python
# tests/test_webhook_stripe.py
import time
import json
import stripe
import pytest
from httpx import AsyncClient

WEBHOOK_SECRET = "whsec_test_secret"

def firmar_payload(payload: dict, secret: str) -> tuple[bytes, str]:
    """Genera un payload firmado para tests de webhook."""
    body = json.dumps(payload).encode()
    timestamp = int(time.time())
    sig = stripe.WebhookSignature.compute_signature(
        f"{timestamp}.{body.decode()}", secret
    )
    header = f"t={timestamp},v1={sig}"
    return body, header


@pytest.mark.asyncio
async def test_webhook_checkout_completado(client: AsyncClient):
    payload = {
        "id": "evt_test_001",
        "type": "checkout.session.completed",
        "data": {
            "object": {
                "id": "cs_test_abc",
                "payment_status": "paid",
                "metadata": {"orden_id": "orden-123"},
            }
        },
    }
    body, header = firmar_payload(payload, WEBHOOK_SECRET)
    response = await client.post(
        "/webhook/stripe",
        content=body,
        headers={"stripe-signature": header, "content-type": "application/json"},
    )
    assert response.status_code == 200


@pytest.mark.asyncio
async def test_webhook_firma_invalida_retorna_400(client: AsyncClient):
    response = await client.post(
        "/webhook/stripe",
        json={"type": "checkout.session.completed"},
        headers={"stripe-signature": "firma_invalida"},
    )
    assert response.status_code == 400
```

---

## 9. Tabla de referencias

| Tema | Archivo |
|------|---------|
| Connect: marketplace, transfers, splits de pago | [recursos/stripe-connect.md](recursos/stripe-connect.md) |
| Manejo de errores Stripe y reintentos | [recursos/errores-reintentos.md](recursos/errores-reintentos.md) |
| Documentación oficial Stripe | https://docs.stripe.com |
| Stripe CLI reference | https://docs.stripe.com/stripe-cli |
| Tarjetas de prueba | https://docs.stripe.com/testing |

---

## Gotchas / Errores comunes no obvios

**`checkout.session.completed` llega con `payment_status: "unpaid"` para suscripciones con trial period y el código que asume `payment_status: "paid"` nunca activa el acceso del usuario**: un Checkout Session de suscripción con `trial_period_days: 14` dispara `checkout.session.completed` inmediatamente después del registro, pero el campo `payment_status` es `"unpaid"` porque no hubo cobro real. Causa: para suscripciones con trial, Stripe completa la sesión (el usuario dio sus datos de pago) antes del primer cobro. Fix: al procesar `checkout.session.completed` para suscripciones, verificar `session.mode == "subscription"` y activar el acceso basándose en el estado de la suscripción (campo `subscription`), no en `payment_status`. Escuchar también `customer.subscription.created` con `status: "trialing"` como señal canónica de trial activo.

**Las idempotency keys con `idem_key = f"create_payment_{order_id}"` causan errores `IdempotencyError` cuando el mismo endpoint se llama con parámetros diferentes para la misma orden (ej: montos actualizados)**: si el usuario modifica su carrito y vuelve a intentar pagar, la segunda llamada a `PaymentIntent.create` con la misma `order_id` pero diferente `amount` choca con el PaymentIntent ya creado. Stripe retorna error porque los parámetros no coinciden con el primer intento. Causa: la idempotency key en Stripe es una promesa de que "esta llamada idéntica puede enviarse múltiples veces"; si los parámetros cambian, es una operación diferente. Fix: incluir un hash de los parámetros relevantes en la key: `idem_key = f"create_pi_{order_id}_{amount_cents}_{currency}"`, o usar un UUID v4 por intento y manejar idempotencia en tu propio backend comparando el estado de la orden.

**Los webhooks de Stripe pueden llegar fuera de orden y procesarlos en secuencia sin verificar el estado actual puede revertir actualizaciones válidas**: el evento `customer.subscription.updated` con `status: "active"` puede llegar después de `customer.subscription.deleted` si hubo reintento de un evento antiguo, sobreescribiendo el estado `canceled` correcto con `active`. Causa: Stripe garantiza entrega at-least-once pero no orden de llegada; los reintentos de eventos fallidos anteriores pueden llegar después que eventos más recientes. Fix: siempre verificar el timestamp del evento (`event.created`) antes de actualizar la BD: solo procesar si `event.created > ultima_actualizacion_en_bd`. Alternativamente, consultar el estado actual vía API Stripe antes de cada actualización: `stripe.Subscription.retrieve(subscription_id)`.

**`stripe.Webhook.construct_event` lanza `SignatureVerificationError` en producción con FastAPI porque el body fue parseado como JSON antes de llegar al handler del webhook, y `request.body()` devuelve bytes del JSON serializado en lugar del payload raw original**: el middleware de FastAPI que hace `await request.json()` consume el stream del body; cuando el handler del webhook intenta leer el body raw para verificar la firma, recibe el JSON re-serializado que puede tener diferente formato (ordenamiento de claves, espacios) que el payload original de Stripe. Causa: la firma HMAC de Stripe se calcula sobre el payload binario exacto; cualquier transformación invalida la firma. Fix: en el endpoint de webhook, leer el body raw directamente sin pasar por middleware de parsing: `raw_body = await request.body()` y no declarar el parámetro como `body: dict` en la firma de la función FastAPI.
