Planes de equipo

Una organización paga por puesto y su equipo se desactiva, sin perder nada, hasta que pague.

Para un producto donde una organización paga por las personas de su equipo. El ejemplo es un plan Business que se cobra por puesto.

El modelo

En tu aplicaciónEn Billete
La organización org_7Un cliente con externalId: "org_7"
Las personas del equipoMiembros de ese cliente, con el ID de cada usuario como externalId
Quien administra el equipoUn miembro con priority: true
BusinessUn plan por puesto, sin respaldo y con suspensionDays
  • El cliente es la organización, no quien paga. La suscripción, los puestos y las cuotas son del equipo: si cambia quien administra o la tarjeta con la que se paga, nada se mueve.
  • Sin respaldo. Un equipo que deja de pagar no tiene un plan gratuito al que volver: lo que corresponde es desactivarlo.
  • Con suspensión. Al terminar la gracia sin pago, la suscripción pasa a suspended: el equipo queda sin acceso pero conserva su plan, sus puestos y sus miembros, y el Cobro de renovación sigue abierto. Si lo paga dentro de la ventana, vuelve con un periodo nuevo; si no, la suscripción expira. Mira suspensión.
  • El equipo se identifica por el cliente. Si la suscripción expira y la organización vuelve a contratar meses después, la suscripción nueva tiene otro ID; el cliente org_7 es el mismo.

1. Crea el plan

const business = await billete.plans.create({
  name: "Business",
  lookupKey: "business",
  currency: "USD",
  prices: [{ months: 1, amount: 1500 }, { months: 12, amount: 15000 }],
  seats: { mode: "per_seat", min: 2, max: 200 },
  gracePeriodDays: 3,
  suspensionDays: 30,
  features: { mensajes: 5000 },
});

Con per_seat, cada precio es por puesto y la suscripción guarda los puestos comprados en quantity. suspensionDays admite 7, 14, 30, 60 o 90; un plan suspende o tiene respaldo, nunca ambos (suspension_with_fallback). Si cada persona debe tener su propia cuota en vez de compartir la del equipo, crea la cuota con per: "member".

2. Cobra el primer periodo

const checkout = await billete.checkouts.create({
  plan: PLANS.business,
  months: 1,
  quantity: 5,
  customer: { externalId: org.id, email: org.billingEmail, name: org.name },
  successUrl: "https://tuapp.com/equipos/org_7?checkout={CHECKOUT_ID}",
});

return redirect(checkout.url);

Sin quantity, quien paga elige los puestos en el checkout. Para empezar con una prueba gratuita, usa subscriptions.create con los mismos datos y trialDays.

3. Agrega al equipo

Los puestos existen desde que hay una suscripción: antes, agregar un miembro responde seat_limit_reached. Agrega a quien administra con prioridad, para que nunca se quede sin puesto si faltan:

await billete.members.create(org.id, { externalId: admin.id, name: admin.name, priority: true });
await billete.members.create(org.id, { externalId: user.id, name: user.name });

// Al sacar a alguien del equipo se libera su puesto.
await billete.members.remove(org.id, user.id);

members.create es idempotente por externalId, así que puedes llamarlo cada vez que alguien acepta una invitación. Para más puestos, cambia la suscripción con changes.create (quantity con when: "now" cobra la parte que falta del periodo; una reducción se programa con when: "renewal"), o deja que lo haga el portal.

Para saber si una persona puede usar algo, consulta la cuota con su miembro. Responde enabled: false si se quedó sin puesto o si el equipo está suspendido:

const access = await billete.features.check(org.id, "mensajes", { member: user.id });

4. Sigue el estado del equipo

El estado del equipo es el de su suscripción Business más reciente. Las entregas pueden repetirse y llegar en otro orden, así que cada evento solo dispara una consulta:

async function syncTeam(customerId: string) {
  const customer = await billete.customers.retrieve(customerId);
  const { data: [subscription] } = await billete.subscriptions.list({
    customer: customerId,
    plan: PLANS.business,
    limit: 1,
  });
  if (!customer.externalId || !subscription) return;

  switch (subscription.status) {
    case "trialing":
    case "active":
    case "past_due":
      return db.teams.update(customer.externalId, { status: "active" });
    case "suspended":
      return db.teams.update(customer.externalId, {
        status: "suspended",
        payUrl: subscription.renewal?.invoiceUrl,
        payBefore: subscription.renewal?.suspensionEndsAt,
      });
    case "canceled":
    case "expired":
      return db.teams.update(customer.externalId, { status: "ended" });
  }
}

// En tu webhook, ya verificado:
switch (event.type) {
  case "subscription.created":
  case "subscription.past_due":
  case "subscription.suspended":
  case "subscription.reactivated":
  case "subscription.canceled":
  case "subscription.expired":
    await syncTeam(event.data.customerId);
}
EventoQué pasóQué hace tu aplicación
subscription.createdPagó el primer periodo o empezó la prueba.Activa el equipo y agrega a quien administra.
subscription.past_dueVenció sin pago. El equipo conserva el acceso durante la gracia.Avisa a quien administra.
subscription.suspendedTerminó la gracia sin pago.Desactiva el equipo, sin borrar nada. Muestra a quien administra el enlace para pagar (renewal.invoiceUrl) y hasta cuándo (renewal.suspensionEndsAt).
subscription.reactivatedPagó durante la suspensión. Empieza un periodo nuevo y los miembros recuperan su puesto.Reactiva el equipo.
subscription.canceledLa organización canceló y la suscripción terminó.Cierra el equipo.
subscription.expiredTerminó la suspensión sin pago.Cierra el equipo. Recién aquí aplica tu política de borrado de datos.

Billete le recuerda el pago al cliente durante la suspensión; tu aplicación no necesita enviar esos correos. Mientras está suspendida, la suscripción no acepta cambios (subscription_suspended): solo se puede pagar o cancelar.

Referencia de suscripciones · Referencia de planes · Miembros