Apariencia
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::activateFromCheckoutSessionverificaPayment.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
| Evento | Qué se hace |
|---|---|
checkout.session.completed | Activa 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
- En
https://dashboard.stripe.com/webhooks, crear endpoint. - URL:
https://api.humae.com.mx/api/webhooks/stripe. - Eventos:
checkout.session.completed(por ahora). - Copiar el
Signing secret(empieza conwhsec_) aSTRIPE_WEBHOOK_SECRET.
En desarrollo local
Usar Stripe CLI:
bash
stripe listen --forward-to localhost:8000/api/webhooks/stripeEsto imprime un secret temporal. Copiar a .env.
Para disparar un evento manualmente:
bash
stripe trigger checkout.session.completedMonedas
- 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.amountsiempre 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
Membershipcomocancelleddesde/admin/memberships/{id}/cancel.
En Fase 2:
- Endpoint
POST /admin/payments/{id}/refundque 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/paymentscon reconciliación.
Siguiente
Almacenamiento de archivos: Storage local →

