Apariencia
Membresía y pago
La membresía es lo que habilita al candidato a ser visible en el directorio de recruiters. Sin ella, su perfil es invisible incluso si está 100% completo.
Características de la membresía
| Propiedad | Valor |
|---|---|
| Precio | 499 MXN |
| Duración | 180 días (≈ 6 meses) |
| Moneda | MXN (Stripe acepta automáticamente conversión si el candidato paga desde otro país) |
| Renovación automática | No — el candidato debe comprar una nueva al expirar |
| Reembolso | Manual, decisión de admin (flujo de cancelación disponible) |
| Upgrade/downgrade | No aplica (solo existe 1 plan en el MVP) |
Código del plan (seed MembershipPlansSeeder): candidate_6m.
Estados de la membresía
[ninguna] → candidato no ha pagado nunca
│
▼
active → membresía vigente, candidato visible
│
▼
expired → ha pasado expires_at (automático vía job)
│
▼
cancelled → admin cancela manualmenteEnum: App\Enums\MembershipStatus (active, expired, cancelled).
Flujo de compra
1. Iniciar checkout
En /dashboard/membresia, el candidato da clic en "Contratar membresía".
- Frontend llama
POST /api/v1/me/membership/checkout - Rate limit: 10 por minuto.
- Backend (
MembershipService::createCheckoutSession):- Lee el plan
candidate_6mactivo. - Llama a Stripe:
checkout.sessions.createconprice_datainline. - Crea un registro
Paymentconstatus = pending,stripe_session_id+stripe_customer_id. - Devuelve
{url: "https://checkout.stripe.com/c/...", session_id, payment_id}.
- Lee el plan
2. Redirección a Stripe Checkout
El frontend recibe la URL y hace window.location.href = url.
En Stripe Checkout:
- El candidato ve el nombre del plan, precio, moneda.
- Ingresa su tarjeta (en test mode puede usar
4242 4242 4242 4242, cualquier fecha futura, CVC cualquiera). - Stripe valida el cargo.
3. Callback de éxito
Stripe redirige a humae_frontend/membership/success?session_id={CHECKOUT_SESSION_ID}.
El frontend muestra un loader: "Procesando tu membresía…" y consulta el estado.
4. Webhook de activación
Paralelamente (y esto es lo que realmente activa la membresía):
Stripe ────▶ POST /api/webhooks/stripe
Content-Type: application/json
Stripe-Signature: t=...,v1=...
body: {
type: "checkout.session.completed",
data: { object: { id: "cs_test_...", ... } }
}El endpoint:
- Valida la firma HMAC con
Webhook::constructEvent(payload, sigHeader, secret). - Si la firma falla, responde 400 y termina.
- Si es
checkout.session.completed, llamaMembershipService::activateFromCheckoutSession. - Dentro de una transacción DB:
- Busca el
Paymentporstripe_session_id. - Si ya está en
succeeded, no hace nada (idempotencia). - Crea la
Membershipconstatus = active,expires_at = now + 180 days. - Actualiza el
Payment:status = succeeded,membership_id,stripe_payment_intent_id,paid_at. - Envía
MembershipActivatedNotificational candidato (email + in-app).
- Busca el
- Responde 200 a Stripe.
Si el webhook responde 5xx, Stripe reintenta automáticamente (backoff exponencial, hasta 3 días).
5. El candidato ve su membresía activa
GET /api/v1/me/membershipdevuelve la membresía vigente conexpires_at.- En
/dashboard/membresia: tarjeta verde con "Activa hasta el DD/MM/YYYY" y contador de días restantes. - El candidato ahora es visible en el directorio.
Expiración automática
Un job programado corre diariamente:
- Comando:
php artisan schedule:run(cron) - Job:
App\Jobs\ExpireMembershipsJob— corre a las00:15America/Mexico_City - Query: todas las memberships con
status=activeyexpires_at <= now()pasan astatus = expired. - Se envía
MembershipExpiredNotification(fase 2 — el MVP solo cambia el estado).
Catálogo completo de cronjobs: Cronjobs y tareas programadas.
Al expirar:
- El candidato desaparece del directorio.
- Su perfil sigue existiendo, sus datos se preservan.
- Puede renovar contratando otra membresía (crea una nueva
Membership, no reactiva la anterior).
Expirando pronto
El dashboard del candidato muestra una alerta amarilla si expires_at - now() < 15 días:
Tu membresía expira en 12 días. Renueva para seguir apareciendo en el directorio.
El endpoint admin GET /admin/reports/expiring-memberships?days=30 devuelve el listado de memberships que expirarán en los próximos N días.
Cancelación manual
Un admin puede cancelar una membresía desde el panel:
- Endpoint:
POST /admin/memberships/{id}/cancelcon{reason: "Reembolso por ..."}. - Cambia
statusacancelled, guardacancelled_at+cancel_reason. - No reembolsa automáticamente en Stripe. El admin hace el refund por separado en el dashboard de Stripe si aplica.
Servicio: MembershipService::cancel(Membership, reason).
Historial de pagos
GET /api/v1/me/membership/history devuelve todas las memberships + payments del candidato:
json
{
"success": true,
"data": {
"memberships": [
{
"id": 42,
"plan": { "code": "candidate_6m", "name": "Candidato 6 meses", "price": "499.00" },
"status": "active",
"started_at": "2026-01-05T10:00:00Z",
"expires_at": "2026-07-04T10:00:00Z"
}
],
"payments": [
{
"id": 55,
"amount": "499.00",
"status": "succeeded",
"paid_at": "2026-01-05T10:01:12Z",
"provider": "stripe",
"stripe_payment_intent_id": "pi_test_..."
}
]
}
}Casos de error
| Escenario | Qué pasa |
|---|---|
| Tarjeta rechazada | Stripe muestra error en checkout; el Payment queda en pending, sin Membership |
| Candidato cierra Stripe sin pagar | Redirección a /membership/cancel; el Payment queda pending (puede reintentar) |
| Webhook llega duplicado | Idempotencia: segundo webhook no crea otra Membership |
| Firma del webhook inválida | Backend responde 400; Stripe reintenta; si nunca valida, alerta en logs |
| Webhook responde 500 | Stripe reintenta automáticamente con backoff |
| Membership expira durante entrevista agendada | La entrevista sigue en pie; solo se invisibiliza el perfil del directorio |
Notificaciones relacionadas
| Evento | Canal | Destinatario | Plantilla |
|---|---|---|---|
| Checkout iniciado | — | — | (sin notificación) |
| Pago exitoso + membresía activada | Email + in-app | Candidato | MembershipActivatedNotification |
| Expirando en 15 días | Email (Fase 2) | Candidato | — |
| Membresía expirada | Email (Fase 2) | Candidato | — |
| Cancelación manual | Email (Fase 2) | Candidato | — |
Siguiente
Con la membresía activa, el siguiente paso es construir el perfil profesional completo. Perfil profesional →

