# Laravel Cashier (Stripe) — Integración de Suscripciones

Fecha de integración: 2026-03-09  
Versión de Cashier: v14.14.0  
Procesador: Stripe  
Laravel: 9.x

---

## Para el equipo de Frontend — Qué se reemplazó y cómo funciona el flujo

### Lo que se reemplazó

Antes, el flujo de compra/suscripción de un producto era manejado **localmente** con una vista de carrito propia del sistema. Esa vista de carrito fue **reemplazada por Stripe Checkout**, la pasarela de pago hosteada de Stripe.

```
ANTES:
Producto → [Vista carrito propia] → Confirmación manual

AHORA:
Producto → POST /subscriptions/checkout/{product} → [Stripe Checkout] → /subscriptions/success
```

Stripe Checkout es una página hosteada por Stripe (fuera del sistema) que maneja:
- Formulario de tarjeta de crédito/débito
- Validación PCI compliant
- Soporte para Apple Pay / Google Pay
- Emails de confirmación automáticos

---

### Flujo completo paso a paso

```
1. El usuario selecciona un producto
        ↓
2. El frontend hace POST a /subscriptions/checkout/{product_id}
        ↓
3. El backend (Cashier) crea una Stripe Checkout Session
   y redirige al usuario a checkout.stripe.com/...
        ↓
4. El usuario completa el pago en la pasarela de Stripe
        ↓
5. Stripe redirige al usuario a /subscriptions/success
        ↓
6. Stripe envía un webhook a /stripe/webhook
   → Cashier actualiza la tabla subscriptions automáticamente
        ↓
7. El usuario ahora tiene acceso al contenido del producto
```

---

### Cómo invocar el checkout desde el frontend

El botón o link de "Suscribirse" / "Comprar" debe hacer un **POST** a:

```
POST /subscriptions/checkout/{product_id}
```

Ejemplo con un formulario Blade:

```blade
<form action="{{ route('subscriptions.checkout', $product) }}" method="POST">
    @csrf
    <button type="submit" class="btn btn-primary">
        Suscribirse — ${{ $product->price }}/mes
    </button>
</form>
```

Ejemplo con JavaScript (fetch/axios):

```javascript
async function iniciarCheckout(productId) {
    const response = await fetch(`/subscriptions/checkout/${productId}`, {
        method: 'POST',
        headers: {
            'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
            'Accept': 'application/json',
        }
    });
    // Cashier devuelve un redirect 303 a Stripe
    // El navegador lo sigue automáticamente
    window.location.href = response.url;
}
```

> **Importante:** El usuario debe estar autenticado. Si no lo está, será redirigido al login automáticamente por el middleware `auth`.

---

### Verificar si el usuario ya está suscrito (en Blade)

```blade
@if(auth()->user()->subscribedToPrice($product->stripe_price_id))
    {{-- Ya tiene suscripción activa --}}
    <span class="badge badge-success">Suscripto</span>
    <form action="{{ route('subscriptions.cancel', $product) }}" method="POST">
        @csrf
        <button type="submit">Cancelar suscripción</button>
    </form>
@else
    {{-- No tiene suscripción --}}
    <form action="{{ route('subscriptions.checkout', $product) }}" method="POST">
        @csrf
        <button type="submit">Suscribirse</button>
    </form>
@endif
```

---

### Páginas que hay que crear en el frontend

Estas vistas existen como rutas pero **aún no tienen diseño** — el equipo de frontend debe crearlas:

| Vista | Ruta | Descripción |
|---|---|---|
| `resources/views/subscriptions/index.blade.php` | `GET /subscriptions` | Panel del usuario con sus suscripciones activas |
| `resources/views/subscriptions/success.blade.php` | `GET /subscriptions/success` | Página de confirmación post-pago |

#### Datos disponibles en `subscriptions/index.blade.php`

```blade
@foreach($subscriptions as $sub)
    <p>Plan: {{ $sub->name }}</p>
    <p>Estado: {{ $sub->stripe_status }}</p>
    <p>Renovación: {{ $sub->ends_at?->format('d/m/Y') ?? 'Activa' }}</p>
@endforeach
```

#### Tarjeta de prueba (modo test)

```
Número:   4242 4242 4242 4242
Fecha:    cualquier fecha futura (ej: 12/29)
CVC:      cualquier 3 dígitos (ej: 123)
```

---

## Resumen

---

## Archivos modificados / creados

| Archivo | Acción | Descripción |
|---|---|---|
| `composer.json` | Modificado | Se agregó `laravel/cashier` y se desactivó `optimize-autoloader` |
| `.env` | Modificado | Variables de Stripe y Airtable |
| `config/cashier.php` | Creado | Configuración publicada de Cashier |
| `config/services.php` | Modificado | Se agregó bloque `airtable` |
| `app/Models/User.php` | Modificado | Se agregó el trait `Billable` |
| `app/Models/Product.php` | Modificado | Se agregó `stripe_price_id` al `$fillable` y el método `hasStripePrice()` |
| `app/Services/AirtableService.php` | Creado | Servicio para crear registros en Airtable vía API |
| `app/Http/Controllers/SubscriptionController.php` | Modificado | Se añade `metadata` al checkout para identificar producto y usuario |
| `app/Http/Controllers/StripeWebhookController.php` | Creado | Extiende el webhook de Cashier; registra en Airtable al confirmar pago |
| `app/Http/Middleware/EnsureProductSubscription.php` | Creado | Middleware para proteger rutas por suscripción |
| `app/Http/Middleware/VerifyCsrfToken.php` | Modificado | Se excluyó `stripe/webhook` del CSRF |
| `app/Http/Kernel.php` | Modificado | Se registró el alias `subscribed.to` |
| `app/Providers/AppServiceProvider.php` | Modificado | Se llama `Cashier::ignoreRoutes()` para usar el webhook controller custom |
| `routes/web.php` | Modificado | Se agregó ruta manual `POST stripe/webhook` y grupo `/subscriptions` |
| `database/migrations/2019_05_03_000001_create_customer_columns.php` | Creado | Cashier: columnas en tabla `users` |
| `database/migrations/2019_05_03_000002_create_subscriptions_table.php` | Creado | Cashier: tabla `subscriptions` |
| `database/migrations/2019_05_03_000003_create_subscription_items_table.php` | Creado | Cashier: tabla `subscription_items` |
| `database/migrations/2026_03_09_142205_add_stripe_price_id_to_products_table.php` | Creado | Columna `stripe_price_id` en `products` |

---

## Variables de entorno requeridas

Completar en `.env` con las claves reales del dashboard de Stripe y Airtable:

```env
# Stripe
STRIPE_KEY=pk_test_...          # Clave pública (Dashboard > Developers > API keys)
STRIPE_SECRET=sk_test_...       # Clave secreta
STRIPE_WEBHOOK_SECRET=whsec_... # Secreto del webhook (Dashboard > Webhooks)
CASHIER_CURRENCY=usd
CASHIER_CURRENCY_LOCALE=en

# Airtable
AIRTABLE_TOKEN=pat...           # Token de acceso personal (airtable.com/create/tokens)
AIRTABLE_BASE_ID=app...         # ID de la base (URL de Airtable: /app.../...)
AIRTABLE_SUBSCRIPTIONS_TABLE=tbl...  # ID de la tabla de suscripciones
```

---

## Configurar un producto para suscripción

1. En el [Dashboard de Stripe](https://dashboard.stripe.com/products), crear un Producto con un Precio recurrente (mensual/anual).
2. Copiar el `Price ID` (formato `price_xxxxxxxx`).
3. En el admin del sistema, editar el `Product` y pegar el `Price ID` en el campo `stripe_price_id`.

---

## Rutas disponibles

| Método | URI | Nombre | Descripción |
|---|---|---|---|
| `GET` | `/subscriptions` | `subscriptions.index` | Lista suscripciones activas del usuario |
| `POST` | `/subscriptions/checkout/{product}` | `subscriptions.checkout` | Inicia Stripe Checkout |
| `GET` | `/subscriptions/success` | `subscriptions.success` | Página de confirmación post-pago |
| `POST` | `/subscriptions/cancel/{product}` | `subscriptions.cancel` | Cancela suscripción al fin del período |
| `POST` | `/subscriptions/resume/{product}` | `subscriptions.resume` | Reactiva suscripción cancelada |
| `GET` | `/subscriptions/portal` | `subscriptions.portal` | Redirige al Stripe Customer Portal |
| `POST` | `/stripe/webhook` | — | Webhook automático de Cashier (excluido del CSRF) |

---

## Uso del middleware de acceso

Para proteger una ruta y requerir suscripción activa al producto con ID `5`:

```php
Route::get('/contenido-premium', [MiController::class, 'show'])
    ->middleware(['auth', 'subscribed.to:5']);
```

---

## Integración con Airtable

Al confirmar un pago (`checkout.session.completed`), el sistema registra automáticamente una fila en la tabla de Airtable con los siguientes campos:

| Campo Airtable | Origen | Ejemplo |
|---|---|---|
| `Project` | `user.name` del usuario autenticado | `Juan Flores` |
| `Plan` | `product.name` del producto suscrito | `Plan Pro` |
| `Amount_USD` | `amount_total / 100` de la sesión Stripe | `49.99` |
| `Currency` | `currency` de la sesión Stripe | `USD` |
| `Billing_Start` | Timestamp del evento Stripe | `2026-03-09` |
| `Last_Payment_Date` | Igual a `Billing_Start` | `2026-03-09` |
| `Status` | Siempre `Active` al crear | `Active` |
| `Payment_Method` | Siempre `Stripe` | `Stripe` |
| `Auto_Renew` | Siempre `true` | `true` |
| `Notes` | Email, slug del producto, session ID | `Email: j@... | Slug: plan-pro | Session: cs_...` |

> El `AirtableService` usa `Illuminate\Support\Facades\Http` (cliente HTTP de Laravel) para llamar a la API REST de Airtable. No requiere paquetes adicionales.

---

## Configurar Webhook en Stripe

1. Ir a [Dashboard > Webhooks](https://dashboard.stripe.com/webhooks)
2. Crear un nuevo endpoint con la URL: `https://tu-dominio.com/stripe/webhook`
3. Seleccionar los eventos:
   - `checkout.session.completed`  ← **Necesario para Airtable**
   - `customer.subscription.created`
   - `customer.subscription.updated`
   - `customer.subscription.deleted`
   - `invoice.payment_succeeded`
   - `invoice.payment_failed`
4. Copiar el **Signing Secret** (`whsec_...`) al `.env` en `STRIPE_WEBHOOK_SECRET`

> Para desarrollo local, usar [Stripe CLI](https://stripe.com/docs/stripe-cli):
> ```bash
> stripe listen --forward-to localhost:8000/stripe/webhook
> ```

---

## Verificar la instalación

```bash
# Confirmar que las tablas existen
php artisan tinker
>>> Schema::hasTable('subscriptions')   // true
>>> Schema::hasColumn('products', 'stripe_price_id')  // true

# Ver las rutas registradas
php artisan route:list --name=subscriptions
```

---

## Nota sobre `optimize-autoloader`

Se cambió `"optimize-autoloader": true` a `false` en `composer.json` porque en este proyecto causaba tiempos de generación de autoload superiores a 10 minutos. Para producción, se recomienda activarlo solo en el proceso de deploy:

```bash
composer install --optimize-autoloader --no-dev
```
