Skip to content

Stripe — pagos

Stripe es el único proveedor de pagos en el MVP. HUMAE usa Stripe Checkout (sesión hosted) para evitar manejar datos de tarjeta directamente.

Variables de entorno

En humae_backend/.env:

env
STRIPE_KEY=pk_live_...                 # public key (frontend la podría usar)
STRIPE_SECRET=sk_live_...              # secret key (nunca al frontend)
STRIPE_WEBHOOK_SECRET=whsec_...        # firma de webhooks
STRIPE_CURRENCY=mxn                    # moneda por defecto
STRIPE_PRICE_CANDIDATE_6M=price_...    # opcional: price ID pre-creado (si no, se usa price_data inline)

En test mode, usar pk_test_... y sk_test_....

Arquitectura de cobro

Frontend                Backend                     Stripe
   │                       │                          │
   │ clic "Contratar"      │                          │
   ├──────────────────────▶│                          │
   │                       │ create CheckoutSession   │
   │                       ├─────────────────────────▶│
   │                       │                          │
   │                       │ ◀──── session (url,id) ──│
   │                       │                          │
   │ ◀── { url: "..." } ───│                          │
   │                                                  │
   │ window.location = url                            │
   ├─────────────────────────────────────────────────▶│
   │                       │                          │
   │                       │  El candidato paga       │
   │                       │  en checkout.stripe.com  │
   │                       │                          │
   │ ◀───────── redirect success_url ─────────────────│
   │                       │                          │
   │                       │ ◀─── webhook ────────────│
   │                       │   checkout.session.      │
   │                       │   completed              │
   │                       │                          │
   │                       │ valida firma             │
   │                       │ activa membership        │
   │                       │                          │
   │                       │ ─── 200 OK ─────────────▶│

Implementación

StripeClient helper

Wrapper sobre la librería stripe/stripe-php:

php
namespace App\Helpers;

class StripeClient
{
    private \Stripe\StripeClient $client;

    public function __construct()
    {
        $this->client = new \Stripe\StripeClient(config('services.stripe.secret'));
    }

    public function createCheckoutSession(array $params): CheckoutSession
    {
        return $this->client->checkout->sessions->create($params);
    }
}

Se inyecta en el MembershipService por constructor (facilita mocking en tests).

Creación de Checkout Session

En MembershipService::createCheckoutSession:

php
$session = $this->stripe->createCheckoutSession([
    'mode' => 'payment',
    'success_url' => $frontendUrl.'/membership/success?session_id={CHECKOUT_SESSION_ID}',
    'cancel_url' => $frontendUrl.'/membership/cancel',
    'customer_email' => $user->email,
    'client_reference_id' => (string) $user->id,
    'line_items' => [[
        'quantity' => 1,
        'price_data' => [
            'currency' => 'mxn',
            'unit_amount' => 49900, // centavos
            'product_data' => [
                'name' => 'Membresía Candidato 6 meses',
                'description' => 'Acceso completo al directorio...',
            ],
        ],
    ]],
    'metadata' => [
        'user_id' => (string) $user->id,
        'membership_plan_id' => (string) $plan->id,
        'plan_code' => 'candidate_6m',
    ],
]);

Guarda un Payment con status = pending y stripe_session_id = $session->id.

Webhook endpoint

URL: POST /api/webhooks/stripeMiddleware: solo throttle:60,1 (no auth, es público).

php
public function __invoke(Request $request)
{
    $payload = $request->getContent();
    $sigHeader = $request->header('Stripe-Signature');
    $secret = config('services.stripe.webhook_secret');

    try {
        $event = \Stripe\Webhook::constructEvent($payload, $sigHeader, $secret);
    } catch (\Stripe\Exception\SignatureVerificationException $e) {
        return response('Invalid signature', 400);
    }

    if ($event->type === 'checkout.session.completed') {
        $session = $event->data->object;
        $this->membershipService->activateFromCheckoutSession($session);
    }

    return response('', 200);
}

Idempotencia

  • El webhook puede dispararse múltiples veces (Stripe reintenta).
  • MembershipService::activateFromCheckoutSession verifica Payment.status:
php
if ($payment->status === PaymentStatus::Succeeded) {
    return $payment; // ya procesado
}
  • Solo crea la membership la primera vez.

Test que valida este comportamiento: tests/Feature/Api/V1/Membership/WebhookTest.php — dispara el webhook dos veces y asserta una sola Membership.

Eventos Stripe manejados

EventoQué se hace
checkout.session.completedActiva membresía (caso principal)
payment_intent.payment_failed(Fase 2) — notificar candidato
charge.refunded(Fase 2) — marcar payment como refunded
customer.subscription.*(Fase 3) — si migramos a suscripciones recurrentes

Configuración de webhooks en Stripe dashboard

  1. En https://dashboard.stripe.com/webhooks, crear endpoint.
  2. URL: https://api.humae.com.mx/api/webhooks/stripe.
  3. Eventos: checkout.session.completed (por ahora).
  4. Copiar el Signing secret (empieza con whsec_) a STRIPE_WEBHOOK_SECRET.

En desarrollo local

Usar Stripe CLI:

bash
stripe listen --forward-to localhost:8000/api/webhooks/stripe

Esto imprime un secret temporal. Copiar a .env.

Para disparar un evento manualmente:

bash
stripe trigger checkout.session.completed

Monedas

  • MVP: solo MXN.
  • Si el candidato paga con tarjeta extranjera, Stripe hace la conversión automáticamente en el lado del cliente.
  • El campo Payment.amount siempre queda en MXN (lo que cobramos).

Reembolsos

  • No automatizado en el MVP.
  • Admin ejecuta refund manualmente desde https://dashboard.stripe.com/payments.
  • Después, marca la Membership como cancelled desde /admin/memberships/{id}/cancel.

En Fase 2:

  • Endpoint POST /admin/payments/{id}/refund que llama Stripe API y actualiza la DB.

Datos que NO tocamos

  • Número de tarjeta: nunca pasa por nuestro backend. Stripe Checkout lo maneja todo.
  • CVC: idem.
  • Customer ID de Stripe: sí lo guardamos (stripe_customer_id) para futuros cargos — es un identificador opaco sin datos sensibles.

Somos SAQ A en términos de PCI compliance (el alcance más bajo posible).

Monitoreo

  • Stripe Dashboard muestra todos los intentos y su estado.
  • Logs de Laravel loguean cada webhook recibido con [StripeWebhook] type=... status=....
  • Fase 2: dashboard admin /admin/payments con reconciliación.

Siguiente

Almacenamiento de archivos: Storage local →

Manual de usuario HUMAE · Uso interno