Organizations
Multi-tenant organizations with member roles, email invitations, and shared workspaces funded by the organization owner's personal balance.
Multi-tenancy is owned by @repo/auth via the Better Auth organization plugin and governed by config.tenancy. With multiTenant: true (the default) users create and join organizations; with false the app is single-user and every team surface disappears.
Who pays
Money always lives on a user. An organization has no balance, no plan, and no customer record of its own, so one rule decides the payer for every session.
| Session | Payer |
|---|---|
| An organization is active | its owner |
| No active organization | the session user |
Single-tenant (multiTenant: false) | the session user |
Resolve the payer server-side, never from client state:
import { Billing } from "@repo/payments";
const billing = await Billing(session); // the account funding this session
billing.entityId; // the PAYING USER's id
billing.credits; // the balance this session draws from
billing.plan; // the plan governing this sessionWork that runs outside a request - an automated run, an AI capability, a background job - has a workspace and an acting user instead of a session:
import { BillingForWorkspace, resolvePayerFor } from "@repo/payments";
const billing = await BillingForWorkspace(organizationId, actingUserId);
const payerId = await resolvePayerFor(organizationId, actingUserId);Pass null for organizationId when the work belongs to no organization. All three entry points apply the same rule, so no surface can drift from the others.
An organization with no owner row raises BillingError with code NO_ORGANIZATION_OWNER (HTTP 400) instead of falling back to the acting member - charging the wrong person is worse than refusing. It is reachable in practice: deleting the owner's user account cascades their membership away and leaves the organization ownerless. Handle it wherever you charge.
Owning and paying are separate questions
A resource belongs to the active workspace - the active organization when there is one, otherwise the user. That is a visibility rule; who pays is then derived from the workspace by taking its owner.
| Question | Answer |
|---|---|
| Who can see and use this? | the active workspace (organizationId, else the user) |
| Whose balance does it spend? | that workspace's owner |
An automation created inside an organization belongs to that organization and every member sees it because the user was working there, not because of who pays. Funding never moves a resource, and a transfer never hides one.
Projects sit inside a workspace, not beside it: a project is owned by the active organization, or by the user when multiTenant is false. Switching project changes neither who can see a resource nor whose balance it spends - with one exception, an AI integration connection, which is placed at a project, an organization, or its connector alone, so switching project does change which connections are in reach.
What each role sees
The owner's plan governs entitlements for every member acting in the organization - a free-plan member gets the owner's tier while working there - so the plan travels to members. Only the credit balance is withheld: it is personal and funds every organization its owner runs, so showing it in one workspace would leak spending from another.
| Owner | Member | |
|---|---|---|
| Plan name, tier, and expiry of the workspace | yes | yes |
| The workspace's credit balance figure | yes | no |
| Out-of-credits state when work is blocked | yes | yes |
| Who funds this workspace | yes | yes, by name |
| Their OWN plan, balance, billing portal and invoices | yes | yes |
| Checkout or top-up for this workspace | yes | no |
A member's own subscription is not the workspace's. They are charged for it whichever workspace they
have selected, so /settings/billing shows their own plan and balance beside the workspace's and the
billing portal always opens against their own account - the only place to cancel. Switching workspace
must never take that away. Only the purchase surfaces are workspace-scoped.
The API enforces this, not the UI. Two endpoints null out the payer's private figures when the caller is not the payer, so anything you build on them inherits the rule:
| Endpoint | When self-funded | When someone else funds it |
|---|---|---|
GET /billing/status | fundedBy: null, full figures | fundedBy: { userId, name }; credits, planStartedAt, and trial fields null; plan, planConfig, planExpiresAt still present |
GET /dashboard/status | fundedByOther: false | fundedByOther: true; plan fields unchanged, since the dashboard shows no balance to withhold |
isActive and hasCredits are always present on both: a member gets the booleans to render an accurate state and the owner's name to ask, but never a figure. GET /billing/history and the auto top-up endpoints always resolve the caller's own account, so a member never reads the owner's ledger.
Only the payer may check out
canUserPurchase(userId, billing) (packages/auth/src/authorization.ts) allows a purchase only when the billing reference is the caller's own user id. Inside an organization that reference is the owner, so:
- The owner is the only member who can buy or top up for the workspace.
- Members and org admins cannot purchase against the owner's balance - it is that person's money, reachable only from their own billing settings.
- There are no spend caps, per organization or per member. A cap that halts a member mid-run becomes a support ticket for the owner, who can instead remove the member or transfer the workspace.
Transferring ownership moves the money
Transferring an organization to another member repoints its funding immediately: every member's metered work then draws on the new owner's credits and plan, while the previous owner keeps their own and stops paying for this workspace.
Ownership transfer is a money-affecting action and the only way to move a workspace's funding. Because the plan sits on a person, an organization whose owner is leaving must be transferred before their account goes away.
Everyone keeps at least one organization
The API refuses any action that would leave its caller owning none. Owning nothing is a dead end rather than an empty state: there is no workspace to fund, and nothing to check out for.
| The caller tries to | Refused when |
|---|---|
| Delete an organization | it is the last one they own |
| Leave an organization | they own it and own no other |
| Remove their own membership | same - remove-member aimed at yourself is a leave |
Give up their own owner role | it is their last owned organization |
Refusals carry the code LAST_OWNED_ORGANIZATION (HTTP 400); create another organization or hand this one over first. The rule binds the caller only, so removing or demoting another member is never blocked by it.
Single-tenant projects (multiTenant: false) opt out entirely - there is no organization to keep.
Configuration
config.tenancy is a discriminated union: { multiTenant: false }, or { multiTenant: true; organizationLimit?: number; defaultOrganizationName?: string }.
// config.tenancy - the shipped default
tenancy: {
multiTenant: true, // teams on/off
organizationLimit: 5, // max orgs a user may create
defaultOrganizationName: "Personal", // name onboarding gives the first org
}| Key | Type | Default | Description |
|---|---|---|---|
multiTenant | boolean | true | Master switch. false = single-tenant, no org UI. |
organizationLimit | number | 5 | Max organizations one user can create (multi-tenant only). |
defaultOrganizationName | string | "Personal" | Name for the organization onboarding auto-creates. Users rename it here in settings; onboarding never asks. |
There is no billing switch here and no per-organization subscription to enable: who pays is derived from ownership. If each of your organizations really is a separate paying company, give each one its own owner account.
Turning multiTenant on in a shipped app that has the desktop app is BREAKING for that app's users: chats, automations and task overrides already on their machines become unaddressable and those automations stop firing, with no migration - see Organizations on the desktop.
Read the effective limit through getOrganizationLimit() from @repo/config rather than config.tenancy directly - it resolves the single-tenant fallback (1) for you. organizationLimit caps how many organizations a user may create, not join; membership is many-to-many, and one owner's balance funds every organization they own.
What multi-tenancy gives you
- Roles: every member is
member,admin, orowner; the creator becomesowner. - Members: the active organization is tracked on the session, set to the most recently joined membership on login.
- Switching: the sidebar workspace switcher lists every membership - one tap behind the swap control when projects are on too; a session that names no organization lands on the first one.
- Invitations: owners and admins invite by email; the invitee opens
/accept-invitation/{id}to join with the assigned role. - Notifications: fired on member joined, member left, and role changed (not on the invite itself).
- Audit: every invitation lands in the audit logs; the activity feed reads them and is restricted to
owner/admin.
Who can invite, remove, or transfer is role-based - see Authorization.
When multiTenant is false
- No organization creation, switcher, members, or invitations UI; org-gated nav and admin pages disappear.
getOrganizationLimit()returns1, and every user funds themselves - the same billing path, with no organization ever active.- No schema changes needed; the organization tables go unused.
Purchases and refunds
Custom credit purchases, Stripe auto top-up, and how refunds and chargebacks reverse credits.
Projects
The project entity that scopes a user's work inside their account or inside their active organization - ownership, the API, config.projects.enabled, and the useActiveProject hook.