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