Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesMembershipsMemberships — Behavior

Memberships — Behavior

State machines

Membership status (memberships.status, enum membership_status)

FromToTrigger
(insert)activeOwner self-create on org-create; convertLead; acceptPendingInvitations for first-time / re-accept paths.
(insert)pending_invitationcreateInvitation for known or new email — shell membership inserted alongside the invitation row.
pending_invitationactiveacceptPendingInvitations (webhook on user.created, or fallback in GET /users/me).
pending_invitationcancelledrevokeInvitation, or Clerk’s invitation.revoked webhook (revokeInvitationFromClerk).
activesuspendedupdateMembership (owner protected).
activecancelledupdateMembership; usersService.deleteSelf cascade.
anyactiveRe-accept of a fresh invitation to a previously-cancelled user (existing membership flipped back to active).
cancelledcancelled + deletedAtMemberErasureService.eraseMember — permanent erasure (see below). Terminal.
invited—Never written: no API path sets status = 'invited' (grep "status: 'invited'" apps/api/src is empty; invitations create pending_invitation shells). The enum value survives and is read as a synonym of pending_invitation by member-status.ts and the accountStatus=invited roster filter.

MEMBERSHIP_ACTIVATED event fires whenever a row transitions into active from any other state (including the create-with-active path).

The member activation gate (apps/api/src/member-activation-gate/) re-emits it as MEMBERSHIP_ACTIVATION_CONFIRMED, which is what the staff newMember mail and the new_member automation read. It holds a join-link signup that chose a plan (joinPlanId on the acceptJoinLink event, or the join form’s landing intent) until the purchase settles. See staff-newsletter behavior.

End date and “Left” (memberships.end_date, joined_at)

end_date is when a membership record stopped counting as a member. Analytics read it, and only it, for “Left” (members ribbon “Left this month”, members summary, growth chart “Left”, retention report). The contract lives in apps/api/src/memberships/membership-lifecycle.ts:

  • Every transition into cancelled stamps end_date = now: updateMembership (only on a real transition — a repeated cancel keeps the first date), markInvitationRevoked (revokeInvitation / revokeInvitationFromClerk), discardUnclaimedJoinShell, PaymentMonitoringService.releaseJoinMembership, self-delete (UsersService.softDeleteUser, keeps an existing date), platform-admin erase (AdminUsersService.erase).
  • Every transition out of cancelled clears it: updateMembership (cancelled → anything), desk registration and both pending re-invite upserts, invitation acceptance, join-link acceptance, late-payment revival (WebhookProcessingService, PresaleCheckoutService), platform-admin restore (UsersService.restore).
  • A record that never held a member also clears joined_at: a revoked invitation’s shell, a discarded join shell and a join-link registrant released for never paying. joined_at defaults to the insert time, so without this every such shell would read as a member who joined and left. Re-activation restores it with COALESCE(joined_at, now()).
  • Imports write end_date only from the source file (membershipEnd); an Arbox-inactive row is cancelled with no date.

Left = role = 'member', participation = 'trains', status = 'cancelled', end_date in the window, joined_at IS NOT NULL, not soft-deleted, and not a departure that predates its import (source = 'import' AND end_date <= created_at). An imported member cancelled in-app later counts. A cancelled row with no end_date has an unknown end and is never Left.

Lapsed (?paymentStatus=churned, the members.recent_churn card) is a different question: the member’s last plan ended and nothing live replaced it, whatever the membership record says. The roster row usually stays active. UI copy says “Plan lapsed”; the enum value stays churned for saved audiences.

Rows cancelled before end_date was written are filled once by scripts/backfill-membership-end-date.ts (dry-run by default, --apply to write): end_date := updated_at for cancelled, non-import rows without one, and joined_at := NULL for the never-joined shells it can identify from the audit trail and the invitations table (a revoked-invitation shell also needs no subscription and no booking on the membership, so a record that went live by another route keeps its joined_at). Imported rows are skipped: nothing records whether they were inactive at import or cancelled later.

Membership role (membership_role)

owner > admin > coach > member. Service-level rules (in createInvitation / updateMembership):

  • Only owners can invite or promote-to owners.
  • Owners and admins can invite or promote-to admins.
  • Coaches and members cannot invite anyone.
  • Cannot demote the last owner to a non-owner role (single-owner protection).

Payment status (membership_payment_status)

none | current | past_due | debt (past_due is legacy; billing now writes debt from the first missed payment, kept while a plan cancelled for non-payment still owes). Set by the payments / subscriptions layer, not this module. Read here only on listMember responses.

This mirror is write-mostly and drifts — nothing gates on it. Do not read it to answer “is this member paying”; use the derived billing state below, computed from live subscription rows.

Account status (accountStatus)

accountStatus is a derived response field and list filter, not a memberships.status value. It keeps the live-record question and the sign-in question separate:

ValueRule
activatedusers.clerk_id exists.
invitedNo Clerk account and this organization has a live invitation for the user’s email (case-insensitive): status = 'pending', not deleted, expires_at > now.
lapsedNo Clerk account, no live invitation, and at least one ended one: expired, revoked, or still pending but past expires_at.
not_invitedNo Clerk account and no invitation history at all.

The rule lives once, in member-account-status.ts (accountStatusCondition for SQL, resolveAccountStatuses for rows in hand), and the list filter (?accountStatus=), the member detail, the activation callout (GET …/analytics/members/activation, which reports imported | invited | lapsed | activated, summing to total) and the members.invitations_lapsed insight all read it. Expiry is checked against expires_at directly, so a row is correct before the sweep below has marked it. List rows also carry pendingInvitationId — the live invitation behind invited, null otherwise — so a row can resend or revoke it.

An imported member can be active, hold a plan, and still be not_invited. An invited account status is likewise distinct from memberStatus: invited, which is the training-access state. The account-status filter and activation callout exclude cancelled memberships so a revoked shell never appears as awaiting an invitation.

Invitation lifecycle (invitations.status)

FromToTrigger
(insert)pendingcreateInvitation, createMember (sendInvite), sendMemberInvitation, bulkInvite, createJoinInvitation. expires_at = now + 7 days, and every Clerk createInvitation (resend included) passes expiresInDays from the same constant, so Clerk’s ticket and our row run out together.
pendingacceptedacceptPendingInvitations — user.created webhook, GET /users/me, POST /invitations/accept-pending, and Clerk’s invitation.accepted webhook when the email already belongs to a linked account (otherwise user.created follows and accepts).
pendingpending (held)acceptPendingInvitations on an invitation that is not source = 'join_link' while the account’s email ownership is unproven (see Email ownership proof below). Neither the invitation nor the pending_invitation shell changes; an audit row membership.invitation_held is written per held invitation per call.
pendingrevokedrevokeInvitation (staff; also revokes in Clerk) or Clerk’s invitation.revoked webhook (DB only). Both cancel the pending_invitation shell. Conditional on the row still being pending, so retries and races are no-ops.
pendingexpiredDaily InvitationExpirySweepService.expireStale (03:40 UTC, @ScheduledJob) for expires_at < now; also lazily in acceptPendingInvitations. Memberships are left untouched. Clerk emits no expiry event.

GET /invitations returns only live invitations (pending, not deleted, not past expiry). A new invitation is blocked only by a live one: sendMemberInvitation and bulkInvite match emails case-insensitively and ignore deleted or expired rows, so a lapsed member can be re-invited. ?accountStatus= accepts a comma-separated list; the bulk-invite dialog asks for not_invited,lapsed, while the imported callout still counts not_invited only.

Email ownership proof and the held-invitation rule

An invitation carries data the inviting gym typed about the invitee (phone, birth date, encrypted national id, emergency contact), and staff roles grant access to a gym. An account that only claimed an email must not auto-claim it.

  • Definition (resolveEmailOwnership, apps/api/src/auth/email-ownership.ts; source of truth is Clerk publicMetadata, no DB column): proven when emailProvenAt is set, or joinToken is absent (emailed invitation, Clerk Dashboard invite, OAuth, course-storefront email-code sign-up), or joinInviteDelivery === 'email' (a join ticket delivered by email, which every public join request now is). unproven otherwise — i.e. a join-ticket account whose sign-up link was handed to the browser, so whoever typed the address could create the account. Clerk unreachable ⇒ unproven (fail closed).
  • Hold rule: acceptPendingInvitations accepts an invitation only when source === 'join_link' (self-served join; Clerk is never read for those) or ownership is proven. Every other invitation — any role, member included — is held. Expired invitations are still marked expired without a Clerk read.
  • Signed-in join link (acceptJoinLink) honours the same rule. When the org holds a live non-join_link invitation for the user’s email and ownership is unproven, the join is refused unless the user is already an active member (403 { code: 'INVITATION_HELD' }, audit membership.invitation_held with via: 'join_link_accept') — whether or not a pending_invitation shell exists, since joining around the invitation would strand it. Only join_link invitations are settled for such an account. A public link never grants staff access: a cancelled coach/admin, or the shell of a lapsed staff invitation, re-joins as member. Only the shell of a live staff-sent invitation whose email is proven keeps its invited role.
  • How an account becomes proven: only a platform admin, via Mark email verified (POST /admin/users/:id/verify-email-ownership, emailProvenBy='admin', emailProvenByActor), after confirming the person out-of-band. Accepting the invitation ticket is not proof: for an address that already has an account, Clerk signs the clicker into that account, which someone else may have pre-registered and still hold the password to.
  • What the user sees: GET /users/me returns invitationHeld: true when the user has a pending_invitation shell and a live non-join_link invitation for their email and ownership is unproven. The web pending screen then shows invitation-held (“Confirm it’s your email” — Taikan support must confirm the address; contact [email protected]).
  • Promotion inside one gym (updateMembership) is not an invitation and is unaffected.

POST /join/:token/request-invite is public, and the Clerk invitation URL it mints authenticates its holder. For an address that already has a Clerk user, Clerk turns that URL into a sign-in ticket (__clerk_status=sign_in), which logs the holder in with no password or second factor. createJoinInvitation decides who gets the ticket from the address alone. No flag gates this:

AddressOutcome
Has a login (a users row with clerkId, or any Clerk user per clerk.users.getUserList({ emailAddress }))409 { code: 'ACCOUNT_EXISTS' } with nothing minted or written. The page shows join-account-exists with a Sign in button whose redirect_url is this join link (with ?plan=), and the signed-in join path takes over from there. Emits join.blocked with reason: 'account_exists'. If Clerk can’t be asked, the answer is 503 and no ticket (fail closed).
Unclaimed profile someone else created (clerkId null and any non-deleted membership that isn’t this org’s join_link: imported, staff-invited, another gym’s join link; or no membership at all, e.g. a guardian named for a managed member)The ticket is emailed to that address (delivery: 'email', joinInviteDelivery: 'email' metadata, so the resulting account is proven) and never returned. Nothing the form-filler typed is attached: no prefill, national ID or consent row, and the email greets nobody by name. A membership it creates here is source: 'invite', never join_link, so a repeat submit is emailed again. Response { deliveryMode: 'email', email }, whatever the flag.
New address, or a re-submit of this org’s own join form (an unclaimed shell whose only memberships are this org’s join_link)Emailed as well ({ deliveryMode: 'email', email }), with the form’s consent and drafts attached to the shell. See join-link email delivery.

Why: the first login with an address inherits the unclaimed profile filed under it (findOrCreateFromClerk), along with its memberships, guardianships and invitations. Whoever holds the ticket gets that profile, so the ticket goes only to its inbox.

The ticket outlives this check: a browser ticket minted for an empty address stays live for 7 days. Claim time closes the gap. An unproven login takes a row only while it holds nothing but join-link self-registrations. If a gym has filed someone under the address since (an import, a desk registration, a staff invitation, a membership at another gym), the login gets 403 EMAIL_OWNERSHIP_REQUIRED until support verifies the address. See users-auth.

Every request counts against the invite caps (DEFAULT_JOIN_INVITE_CAPS, or the join-invite-caps payload) before anything is minted. A capped request mints nothing, writes nothing and sends nothing, so the public form cannot resend to a known member’s inbox on demand.

POST /join/:token/request-invite never returns the Clerk sign-up URL. Not flagged.

Response{ deliveryMode: 'email', email } (normalized address). The { url } shape stays in requestJoinInviteResultSchema so an older client still parses, but the API does not send it
DeliveryClerk invitation minted with notify: false and publicMetadata: { joinToken, joinInviteDelivery: 'email' }; the API emails the URL itself in a branded, localized template (joinInviteLinkEmail, the form’s language wins over the org’s)
Webjoin-cta shows the “Check your email” state (join-check-email, with the address and a “use a different email” back link)
Resulting accountproven (only the inbox owner can open the link)
Send failure503 { code: 'JOIN_EMAIL_DELIVERY_FAILED' }, no URL; the visitor resubmits (the invite row is reused). EmailService.send returning null (provider rejection or outage) counts as a failure, as does an API built without EmailService. Emits join.blocked with reason: 'email_delivery_failed'; success emits join.invite_emailed with deliveryReason: 'email_proof'
Invite capsAlways applied (join-invite-caps payload or DEFAULT_JOIN_INVITE_CAPS); the flag carries only the thresholds, because the form mails whatever address is typed

Consent rows, compliance drafts, landing intent, invite caps and the seat gate all run before the email. A link opened in another browser starts a new join-session id for later funnel steps (same limitation as the App-Store round-trip). Accounts created earlier from a browser-delivered ticket stay unproven.

Registration status on the roster

  • Status column: a row whose memberStatus is no_plan/invited and whose accountStatus is not activated shows the registration state (pending registration / invitation lapsed / not invited) instead, without the separate account badge. Rows with a plan state keep both badges.
  • Status filter: invited becomes “Pending registration” and filters accountStatus=invited (this covers pending_invitation shells, which have no account and a live invitation); an “Invitation lapsed” option filters accountStatus=lapsed. “Former” selects ?rosterBucket=former (suspended or cancelled records) — an alias of the donut slice, so picking either shows the other selected. It is worded “former” (he “לקוחות לשעבר”), never “left” / “עזבו”: Left is the cancelled-with-end_date metric and Former also holds suspended records. The no-filter option reads “Current members” (he “לקוחות נוכחיים”, ru “Текущие клиенты”), because it lists every active record; “Active members” is reserved for the dashboards’ trainingMemberCondition count (training or leaving), a narrower set.
  • Roster bucket invited: a member-role row with accountStatus = invited and no plan state — ranked after former, inDebt, overdue, ending and withPlans, before noPlan, so a paying member who has not registered stays in the plan slices (plan state wins, as in the status column). Decided server-side in rosterBucketCondition, so roster-summary, the donut and ?rosterBucket=invited agree. Labelled “Pending registration”.
  • One list: “Pending invitations” shows staff invitations only; member rows get resend/revoke (invited) or send invitation (lapsed, not invited).
  • Members by default: the list opens on role=member and keeps the role in the URL (role=all included), so a chosen role survives a reload; the role filter gains staff (owner/admin/coach, also accepted by the API). trainedThisWeek counts member-role bookings only; newThisMonth excludes source = 'import' memberships.
  • Action Center: members.invitations_lapsed (owner/admin) counts lapsed member-role, non-cancelled memberships; info, warning at ≥ 10; drills into ?accountStatus=lapsed.

Roster semantics and orgs without billing

GET /organizations/:orgId/members (MembershipsService.listMembers) is a register, not a billing view. It returns every non-deleted membership of the org with participation = 'trains', in any status, unless ?status= or ?role= narrows it. That API default is unchanged on purpose: agent tools and audience fan-outs rely on it.

The members screen opens on active records. The web list sends status=active (rosterStatusParam in member-status-filter-options.ts) unless a filter already names a population outside the active roster — memberStatus left / suspended / invited, rosterBucket former / invited, or any accountStatus (a pending_invitation shell is not active). Those requests go out exactly as before. The default is not written to the URL; the page’s server prefetch uses the same default request.

Roster summary. GET /organizations/:orgId/members/roster-summary (getRosterSummary, contract rosterSummaryResponseSchema): total counts active member records that train — the number the members screen prints. invited (invited / pending_invitation records) and former (suspended or cancelled, equal to byBucket.former) are reported beside it, never inside it. byBucket covers all three populations, so sum(byBucket) = total + invited + former. The ribbon prints Total with “+N invited, not joined yet” (he ”+{count} הוזמנו וטרם הצטרפו”) and “N former members” (he “לקוחות לשעבר”) beside it. The two “not registered” numbers on that card are different counts on purpose: the one beside Total is RECORD status (invited / pending_invitation memberships, whatever their account), while the donut’s “Pending registration” slice is ACCOUNT state (bucket invited: no account and a live invitation, whatever the record status, and only when no plan state ranks first). A CSV-imported active member with a live invitation is in the slice and not beside Total; an invited record whose invitation expired is beside Total and lands in the “no plan” slice. The MCP memberships.roster_summary and memberships.list descriptions say the same.

Nothing moves a membership out of active when its last subscription ends: the subscription lifecycle job cancels the subscription, and the membership row stays active with the member resolving to no_plan. The only billing path that cancels a membership is PaymentMonitoringService.releaseJoinMembership, which discards a join-link shell whose checkout never completed.

Orgs without billing. An org with no payment provider and no plans resolves every member to no_plan by design. The web decides this with hasBilling() (payment config status = 'active' or any plan, archived included; see payments behavior). When it is false the members screen shows the registration state in every status cell (activated included; suspended and departed rows keep their record state), drops the plan states from the status filter, hides the payment filter (a saved ?paymentStatus= link is dropped from both the list request and the URL, since there is no control left to clear it), the money donut slices, the at-risk callout and the row Enroll action, and the member page hides Manage plans and Enroll. The “left this month” churn tile stays. Unflagged: copy and layout only.

Billing filter (?paymentStatus= on the member list)

memberships.status = 'active' says a person is on the roster; it says nothing about money, which is what an owner actually wants from the members screen. GET /organizations/:orgId/members?paymentStatus=… filters the roster by billing situation, matched against subscriptions via EXISTS (never the write-mostly memberships.paymentStatus mirror — see above), in MembershipsService.listMembers (memberships.service.ts). Contract: listMembersQuerySchema in @taikan/shared.

paymentStatusMeaning
at_riskThe roster’s inDebt ∪ overdue on a live membership: a member seat, membership active, with a past_due subscription, a debt one, or any non-deleted row with debt_amount_in_cents > 0 (atRiskMemberCondition, member-status-predicates.ts). The members.at_risk_billing insight and analytics.getAtRiskMembers count the same predicate, so the card’s count is the size of this list. See the analytics definitions.
endingA live subscription (active/paused/past_due/debt) with cancel_at_period_end = true — still paying, on the way out. See below.
payingHolds an activated subscription, or a presale sale with the card on file (scheduled).
not_payingNo live billing relationship at all — a bare pending checkout still counts as not paying, since no money has arrived; exactly the population an owner chases once a join link goes public.
withdrawnBacked out of a presale sale before opening day; nothing live now — presale churn.
churnedLapsed (UI “Plan lapsed”): previously activated, everything since ended. Not “Left”, which counts cancelled membership records — see “End date and Left” above.

Two distinctions worth keeping straight:

  • An abandoned checkout is not_paying, not churn. Walking away from a payment page was never a membership; reporting it as churn would fill the owner’s churn list with closed payment pages.
  • withdrawn and churned are separate on purpose. Mid-presale, the owner needs to see who backed out of a sale distinctly from who ended a membership that actually ran.

ending and at_risk are not mutually exclusive with each other the way the churn/no-plan states are — a past_due member who has also given notice matches both filters, since one is a money question and the other is a decision the member already made.

This filter list is a distinct concept from the per-row resolved member state (memberStatus, leaving | training | non_payment | payment_due | presale | paused | awaiting_payment | expired | no_plan | invited | suspended | left, access-first — non_payment (a non-course plan cancelled for non-payment with debt still owed) is checked before leaving and training, and a plan in its grace period keeps the member training with billingDebt and atDate = the pay-by day — see member-status.ts/resolveMemberStatus) that every list row also carries; a paymentStatus=ending member typically resolves to memberStatus: 'training' (or 'presale'), since giving notice doesn’t revoke access before the effective date.

The cancellations queue (paymentStatus=ending)

Filtering paymentStatus=ending additionally attaches an ending: EndingSummary | undefined object to each matched row (MemberWithUser.ending, endingSummarySchema in @taikan/shared) — omitted, not null, on every other query, so a client that doesn’t ask for ending sees no shape change. One bounded query over the page’s membership ids (MembershipsService.loadEndingSummaries), not N+1: cancellationNoticeAt, cancellationEffectiveAt, cancellationReasonCode, a retention: RetentionCase | null summary (kind, task id, owner id, acknowledged/contacted timestamps, contact attempts, outcome), and the retention case owner’s resolved ownerName. A membership can in principle carry more than one live subscription with a notice on it; ordering by cancellationEffectiveAt ascending and keeping the first row per membership picks the one ending soonest.

See ../subscriptions-plans/behavior.md for what opens and closes a retention case, and ../dashboard/behavior.md for the cancellations-queue UI this filter drives.

Desk registration (POST /organizations/:orgId/members)

MembershipsService.createMember registers a member at the counter with status: 'active' immediately, no Clerk account required.

It exists because the invite path cannot finish the job: an invited member sits at pending_invitation until they sign in, and both POST /members/:id/register-card and POST /members/:id/enroll refuse that status — so an owner could not take a card or assign a plan for someone standing in front of them. A person at the desk is a member.

  • Email is optional (a walk-in who never uses the app is still a member); it is required only when sendInvite is true.
  • The Clerk invitation is best-effort and never fails the registration — the membership is what the desk needs, and a mail provider hiccup must not cost it. invitationSent in the response says what happened.
  • Same tier seat cap and role grant matrix as the invite path; an existing cancelled/suspended membership is reactivated rather than duplicated.
  • Emits MEMBERSHIP_ACTIVATED (membershipSource: 'manual'), so welcome fan-out and the CRM’s won-lead conversion behave as they do for any other activation.

Unverified logins (assertStaffMayRegister)

Desk registration, CRM lead conversion, Sales workspace conversion and member import attach an email to the roster directly, with no acceptance step. When the email already belongs to a login whose email is unproven (see Email ownership proof), they refuse with 422 { code: 'STAFF_REGISTRATION_UNVERIFIED' } before writing anything. Without this, a squatter who typed someone’s address into a public join form would receive the gym’s record, the details staff type in, or a staff role.

  • Allowed:
    • an email with no login;
    • a proven login;
    • a login already active, pending_invitation or invited at this gym, since that path attaches nothing new: desk registration reports “already a member” and lead conversion links the existing member;
    • with role member only: a login whose only live memberships are its own join_link registration at this gym, i.e. the released, never-paid registrant coming back to the desk.
  • Refused: everything else, including any staff role (coach, admin) for an unproven login.
  • Staff see members.unverifiedRegistration, inline in the wizard and the convert dialog: the email already tried to register and never confirmed; send an invitation and ask them to check their inbox; if they don’t remember registering, report it to Taikan support. The invitation then follows the held-invitation rule until support verifies.
  • Trace: each refusal writes membership.registration_refused (org-scoped audit_events; targetType: 'user', targetId = the login’s users.id; metadata { via: 'desk_registration' | 'lead_conversion' | 'sales_conversion', attemptedRole, email, reason: 'email_ownership_unproven' }; actor, IP and user agent from the request) and a Staff registration refused API warning log. Platform admins read it in the user’s ownership trail (Admin → Users).
  • The Sales workspace runs the check before its transaction (it reads Clerk) and uses 422, not 409, because the dialog treats any 409 as a version conflict.
  • Import (CSV/Arbox, ImportService.processSingleMember) runs the same check per row for an existing login with no membership here. A refused row is not attached and is counted as skipped. Its email is listed in results.members.unverified, and the import toast lists those addresses with the same “send an invitation” guidance. The audit row carries via: 'import' and importJobId; import_jobs.started_by (a user id) says who ran it, since import jobs run outside a request and have no actor.

Held invitations at a second gym

The “Confirm it’s your email” screen only covers people with no gym yet. When someone already active at gym A has an invitation from gym B held (invitationHeld: true with a pending_invitation membership):

  • The member keeps using gym A and sees a banner (invitation-held-banner, auth.invitationHeldBanner) naming gym B and pointing to Taikan support.
  • Gym B’s staff see Awaiting email verification on that row in Pending Invitations (invitation-awaiting-verification). GET /organizations/:orgId/invitations returns awaitingEmailVerification: true for a pending invitation with a membership.invitation_held audit row. That is, the invitee tried to accept and was held. It comes from the hold’s own audit rows, with no Clerk call per invitation.
  • Member invitations are not in this table; they are acted on from the member rows, which don’t show the label yet. Staff invitations still do.

The dashboard drives this through RegisterMemberWizard (details → card → plan), which opens the gateway card page in a new tab and polls for the token rather than redirecting away and losing the half-finished registration.

Invariants

  • UNIQUE(user_id, organization_id) — at most one membership per (user, org), regardless of status.
  • Last-owner safety — updateMembership blocks demoting the last owner+active row, and blocks setting it to suspended or cancelled.
  • Tier audiences — active clients (role='member') consume maxMembers; active coaches consume maxCoaches; owners and admins consume neither. Invitations and direct active-registration paths enforce the matching allowance.
  • Activation event invariant — MEMBERSHIP_ACTIVATED is emitted exactly once per transition to active. (Consumers must be idempotent — webhook retries are possible.)
  • Cache invalidation — requireMembership cache has 30s TTL. Direct membership writes don’t auto-invalidate; callers that mutate must call invalidateMembershipCache(orgId, userId) (TODO: verify whether updateMembership actually invokes it — appears not to in the read code).
  • Invitation expiry — INVITE_EXPIRY_DAYS = 7. Checked only at accept time.
  • Search across users + memberships — listMembers filters by ilike against users.first_name, users.last_name, users.email.
  • Soft-deleted memberships are invisible — listMembers and getMemberDetail filter deletedAt IS NULL, so both self-serve account deletion and owner-initiated erasure remove the member from the org’s view.
  • Profile validation is channel-independent — HTTP DTOs and memberships.update agent input both enforce a valid phone (normalizePhone: local Israeli form, or any country with its + code), valid adult date of birth, and minimum profile-name lengths before MembershipsService.updateMembership runs.

Seat caps

Tier maxMembers counts active role='member' memberships (-1 = unlimited, e.g. Elite). Enforcement points: invitation / desk-registration creation (assertRoleSeatAvailable) and the member import.

Import refusal (ImportService.fanOutMemberJobs, both CSV and Arbox): before any member job is enqueued the whole import is refused when active members now + new active candidates > maxMembers. New candidates are rows with an email, not Arbox-inactive (those become cancelled and never count), passing the email whitelist, and whose email is not already a membership of the org — so re-running the same file never trips the cap. A refused job is persisted as import_jobs.status='failed' with errorMessage set to the localized importExport.seatCapExceeded copy (org locale; names {limit}, {current}, {requested}) and zero results; nothing is created. The import dialog renders the message verbatim. Partial imports are deliberately not supported: an owner would be left with an unexplained subset.

Permanent erasure (right-to-be-forgotten)

DELETE /organizations/:orgId/members/:membershipId/permanent → MemberErasureService.eraseMember. Owner-only, target must not be an owner, and the membership must already be cancelled (deliberate two-step: cancel, then erase). The web danger zone shows the action only for cancelled members, with a type-the-name confirmation.

Org-scoped by design. Users are platform-level; the owner destroys the member’s data within their org only. When this org is the user’s entire footprint (no other live memberships, no active course entitlements sold by other orgs) the erasure escalates automatically: the users row is scrubbed in place (email becomes a deleted+{id}@deleted.taikan.fit tombstone, all PII columns nulled), device tokens are soft-deleted, notification prefs and agent conversations dropped, and the Clerk identity is deleted.

Data classes:

ClassTreatmentTables
Personal contentHard-delete (+R2 objects)progress photos, body metrics + settings, goals, workout results/PRs/assignments, feed items + comments + reactions, bookings, program enrollments, DM messages + attachments, inbox conversations, announcement reads, automation enrollments, member profile, pending invitations
Financial / legalKeep row, scrub personpayment transactions + subscriptions (point at the soft-deleted membership), card tokens blanked in place, course entitlements revoked not deleted, signed forms + legal consents retained as legal artifacts, source lead contact fields nulled
Org-operationalUntouchedcoach-authored content, schedules, task rows (structured linkedUserId cleared)

Every erasure writes an audit_logs row (member.erased, actor, org, escalation flag, R2 object count) and a member_erased tracking event. R2 deletions run best-effort after the DB transaction commits; failures are logged for manual reconcile.

Known v1 gaps (accepted): exercise-comment bodies authored by the member survive (author is scrubbed on escalation); free-text task descriptions mentioning the member are not rewritten; the R2 form-upload binaries under {orgId}/forms/... are retained with the signed PDFs by design.

Search (?search=) is term-based, not one substring over the whole box. The value is split on whitespace/commas (max 6 terms, LIKE wildcards escaped) and every term must ILIKE one of users.first_name, users.last_name, users.email. So “Jane Doe” and “Doe, Jane” both find Jane Doe — a single %Jane Doe% pattern matches no column on its own and used to return nothing. Shared helper: apps/api/src/common/search-terms.ts.

Pagination is ?page= (1-based) + ?limit= (default 20, max MEMBERS_LIST_MAX_LIMIT = 100); the response carries { data, total, page, limit }. The web table fixes the page size at 20 and shows its prev/next footer only once there is a second page. Changing a filter or the search resets to page 1.

Golden paths

G1 — Owner invites a single member

  1. POST /:orgId/invitations with {email, role:'member'}.
  2. Service:
    • requireMembership check (owner/admin).
    • Role gating (cannot invite owner unless caller is owner).
    • Tier check against the invitee’s audience: clients use maxMembers, coaches use maxCoaches, and owner/admin roles are not tier-capped.
    • Reject if existing membership in active/pending/invited state.
    • Reject if pending invitation already exists for this email in this org.
    • Call Clerk invitations.createInvitation (sends email).
    • INSERT invitations row.
    • INSERT memberships shell with status='pending_invitation' (creates shell user if needed).
  3. Returns the invitation row.

G2 — New user accepts invitation

  1. User clicks email, signs up at Clerk.
  2. Webhook user.created lands at the API.
  3. findOrCreateFromClerk either creates or links the user row.
  4. acceptPendingInvitations(clerkId):
    • Find all invitations matching the user’s email (case-insensitive) with status='pending'.
    • Clerk’s invitation.accepted runs the same path when the account is already linked.
    • For each: if expired, mark expired and continue. Otherwise transaction: if a membership row exists (pending_invitation), flip it to active. Else insert a fresh memberships row. Mark invitation accepted with accepted_at.
    • Invitations that are not source='join_link' are skipped (held) while the account’s email is unproven — see Email ownership proof above.
    • Emit MEMBERSHIP_ACTIVATED for each new activation.

G3 — Bulk invite imported members

  1. Owner has imported members with clerk_id=NULL. POST /:orgId/members/bulk-invite with their membershipIds, or with { allInvitable: true, excludeMembershipIds } to invite everyone invitable (resolved server-side). There is no per-request cap: the audience is bounded by the tier’s member limit (imports and desk registration are seat-capped), and Clerk’s hourly limit is handled in step 3.
  2. Service fetches each (membership_id, user_id, role, email, clerk_id), and the org’s pending invitations.
  3. For each: skip if clerk_id exists or a live invite exists for the email, case-insensitively (with reason strings). Otherwise call Clerk + INSERT invitations. If Clerk answers 429 (instance limit: 100 invitations/hour) the loop stops: that target and every remaining one go to failed with reason: 'rate_limited', and the web dialog shows bulk-invite-rate-limited with the count. Retry in an hour. No seat check: targets are already active imported members, so no new seat is consumed.
  4. Returns {sent, skipped, failed, summary:{total, sent, skipped, failed}}.

G4 — Member self-stats

  1. GET /:orgId/members/me/stats.
  2. Service computes classesThisMonth and totalClasses by joining bookings → class_sessions → class_types → programs with programs.organization_id=orgId and bookings.membership_id=callerMembershipId.

G5 — Update role/status/profile

  1. Owner PATCH /:orgId/members/:id with {role: 'coach'}.
  2. Service: requireMembership (owner/admin); load target; if target is owner, run owner-protection guards.
  3. Optional: if updates.profile present, call usersService.updateProfile(target.userId, updates.profile).
  4. UPDATE the membership row.

Edge cases & error states

ScenarioBehavior
Invite an existing active member400, code INVITE_ALREADY_MEMBER, body carries membershipId, membershipStatus, role. The invite dialog offers “change role” (PATCH members/:membershipId).
Invite an email with a pending_invitation already400, code INVITE_INVITATION_PENDING, body carries invitationId. The invite dialog offers resend or revoke.
Invite an email in mixed caseThe address is lowercased before lookup, so it matches the stored user.
Invite an existing member while the plan seat cap is reachedDuplicate answer wins; the seat check runs after it.
Resend a non-pending invitation400 ‘Invitation is no longer pending’.
Revoke a non-pending invitation400 ‘Invitation is no longer pending’.
Member tries to invite403 ‘Only owners and admins can invite members’.
Admin tries to invite an owner403 ‘Only owners can invite owners’.
Demote last owner403 ‘Cannot change the role of the last owner’.
Suspend/cancel the owner403 ‘Cannot suspend or cancel the owner’.
Send-invitation to a member who has clerk_id already400 ‘Member already has a Clerk account’.
Tier exceeded403 ‘Member limit reached (N/M). Upgrade your plan to add more.’ for clients, or 403 ‘Coach limit reached (N/M). Upgrade your plan to add more.’ for coaches.
Accept a staff-sent invitation with an unproven email (join-ticket account)Held: invitation stays pending, shell stays pending_invitation, audit membership.invitation_held, /users/me.invitationHeld=true. Opening the invitation email link or an admin “Mark email verified” releases it.
Bulk invite with more than 50 ids400 ‘At most 50 members per batch’.
Clerk 429 mid bulk-inviteRemaining targets failed with reason: 'rate_limited'; loop stops.
Accept expired invitationSilently flips invitation to expired; no membership change; user not added.
Webhook auto-accept races with manual accept-pending POSTBoth transactions tolerate each other — the existingMembership branch handles already-active rows by skipping.
Cross-org GET /:orgId/members/:id404 ‘Member not found’.
requireMembership for non-active membership403 ‘Not a member of this organization’ (status=‘active’ filter in the SELECT).

Side effects

OperationSide effects
Create invitationClerk invitation email; DB invitation row; shell user (if email new) + shell membership.
Revoke invitationBest-effort Clerk revoke; status='revoked'; cancel the pending_invitation membership for that email. The invitation.revoked webhook does the same without the Clerk call.
Expiry sweepstatus='expired' for pending rows past expires_at; no membership change.
Resend invitationBest-effort Clerk revoke of old token; new Clerk invitation; clerk_invitation_id + expires_at updated.
Accept pendingMemberships activated (held invitations excluded); invitations marked accepted; MEMBERSHIP_ACTIVATED event per activation.
Convert lead (from leads-crm)New membership inserted with source_lead_id set; MEMBERSHIP_ACTIVATED { source:'lead_converted' }.
Update membership profileProfile mutates users row (org-wide impact).

Permissions

Endpointowneradmincoachmember
GET /members✓✓✓✓ (returns all)
GET /members/me/statsselfselfselfself
GET /members/:id✓✓✓✓
GET /members/:id/stats✓✓✗✗
PATCH /members/:id✓✓✗✗
GET /invitations✓✓✓✓
POST /invitations✓✓✗✗
POST /invitations/:id/resend✓✓✗✗
DELETE /invitations/:id✓✓✗✗
POST /members/bulk-invite✓✓✗✗
POST /members/:id/send-invitation✓✓✗✗
POST /invitations/accept-pending (bearer-auth)selfselfselfself

Members can list and get-by-id other members in the same org (e.g. for community feed UX). They cannot read or write profile/stats of others, and they cannot invite.

Managed members

Spec: managed-members/behavior.md §4.1. Behind the managed-members flag except where noted.

  • memberships.participation: trains (default) or guardian_only, an adult who is a member only to manage a child. guardian_only adults are not counted on the roster or in analytics, and can’t book for themselves on any route (409 guardian_only_cannot_book; see scheduling-bookings/behavior.md).
  • Promotion (ungated): a guardian_only adult whose own plan goes live (active or scheduled: paid, free, staff-assigned or a completed presale) becomes trains (promoteToTrainsOnOwnPlan, apps/api/src/memberships/participation.ts:15). A pending checkout promotes nothing; an abandoned checkout leaves the adult guardian_only. Paying for a child’s plan never changes the payer.
  • A child’s membership is an ordinary membership keyed on the child’s users row (users.kind = 'managed', no email, no login). It is never shown as “not signed in” or “invite pending” (apps/api/src/memberships/member-account-status.ts:86-89).
  • Responsible adults filter: GET …/members?responsibleAdults=true lists every adult with an active or pending link, training or not (memberships.service.ts:578).
  • Member detail: a guardian may read a child’s detail through GuardianAccessService.actingMembership; anyone else gets 403. Staff see the household section and Add child.