Skip to content

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

PropiedadValor
Precio499 MXN
Duración180 días (≈ 6 meses)
MonedaMXN (Stripe acepta automáticamente conversión si el candidato paga desde otro país)
Renovación automáticaNo — el candidato debe comprar una nueva al expirar
ReembolsoManual, decisión de admin (flujo de cancelación disponible)
Upgrade/downgradeNo 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 manualmente

Enum: 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):
    1. Lee el plan candidate_6m activo.
    2. Llama a Stripe: checkout.sessions.create con price_data inline.
    3. Crea un registro Payment con status = pending, stripe_session_id + stripe_customer_id.
    4. Devuelve {url: "https://checkout.stripe.com/c/...", session_id, payment_id}.

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:

  1. Valida la firma HMAC con Webhook::constructEvent(payload, sigHeader, secret).
  2. Si la firma falla, responde 400 y termina.
  3. Si es checkout.session.completed, llama MembershipService::activateFromCheckoutSession.
  4. Dentro de una transacción DB:
    • Busca el Payment por stripe_session_id.
    • Si ya está en succeeded, no hace nada (idempotencia).
    • Crea la Membership con status = active, expires_at = now + 180 days.
    • Actualiza el Payment: status = succeeded, membership_id, stripe_payment_intent_id, paid_at.
    • Envía MembershipActivatedNotification al candidato (email + in-app).
  5. 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/membership devuelve la membresía vigente con expires_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 las 00:15 America/Mexico_City
  • Query: todas las memberships con status=active y expires_at <= now() pasan a status = 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}/cancel con {reason: "Reembolso por ..."}.
  • Cambia status a cancelled, guarda cancelled_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

EscenarioQué pasa
Tarjeta rechazadaStripe muestra error en checkout; el Payment queda en pending, sin Membership
Candidato cierra Stripe sin pagarRedirección a /membership/cancel; el Payment queda pending (puede reintentar)
Webhook llega duplicadoIdempotencia: segundo webhook no crea otra Membership
Firma del webhook inválidaBackend responde 400; Stripe reintenta; si nunca valida, alerta en logs
Webhook responde 500Stripe reintenta automáticamente con backoff
Membership expira durante entrevista agendadaLa entrevista sigue en pie; solo se invisibiliza el perfil del directorio

Notificaciones relacionadas

EventoCanalDestinatarioPlantilla
Checkout iniciado(sin notificación)
Pago exitoso + membresía activadaEmail + in-appCandidatoMembershipActivatedNotification
Expirando en 15 díasEmail (Fase 2)Candidato
Membresía expiradaEmail (Fase 2)Candidato
Cancelación manualEmail (Fase 2)Candidato

Siguiente

Con la membresía activa, el siguiente paso es construir el perfil profesional completo. Perfil profesional →

Manual de usuario HUMAE · Uso interno