Organization¶
A customer account — the top-level tenant that users belong to and that holds a Subscription. Owned by accounts-service (Java/Spring, MySQL), which is the single source of truth for who exists and whether they're allowed to send.
An organization is the unit a customer is in Beacon. When someone signs up, they
get an org; everyone they invite becomes a user inside that
org; the org carries one Subscription that decides what they pay
for and are entitled to; and every message Beacon sends is sent on behalf of an
org. It's the spine the other two domains hang off — billing
attaches a subscription to an org, and messaging
stamps every delivery event with the org_id it was sent for.
Despite sitting at the center of everything, the model itself is deliberately small. accounts-service keeps the org record lean — name, status, seat limit — and lets the other services own the richer state (what they pay, what they've sent) and merely reference the org. That split is the most important thing to understand here, and it's the reason the "Authority boundary" note matters as much as the schema.
Business view¶
Plain language — safe for non-engineers. No table or column names here.
| Attribute | What it means | Rules / behavior |
|---|---|---|
| Name | Display name of the customer | Free text; shown across the app and on invoices |
| Status | Whether the account is usable | An active account works normally; a suspended one can't send and most actions are blocked |
| Seat limit | The most users the org is allowed to have | Comes from the plan; raised on upgrade, lowered on downgrade |
A few product rules worth knowing before the table makes you think it's trivial:
- Suspension is the big lever. Suspending an org is how Beacon stops a customer from sending without deleting anything — accounts, history, and the subscription all stay intact, and lifting the suspension restores normal service. It's used for things like abuse or non-payment escalation. See Suspend an organization for the full flow.
- The seat limit isn't set here. Admins don't edit it directly; it tracks the plan. When an admin runs a Plan upgrade, the new limit is what the plan allows, and billing-service pushes that number onto the org.
- One org, one subscription, many users. A subscription belongs to the whole org, not to any one person — so a single admin's plan change applies to everyone.
Lifecycle¶
The org's status has only two states, and the transition is reversible — that's the whole point of suspension as opposed to deletion:
stateDiagram-v2
[*] --> Active: org created
Active --> Suspended: suspend (abuse / non-payment)
Suspended --> Active: reinstate
Active --> [*]: closed
Note what's not in that diagram: there's no past_due or canceled here. Those
are Subscription states, not org states. A customer can be
past_due on billing while their org is still active — billing handles
dunning on the subscription, and only escalates to an org
suspension if recovery ultimately fails. Keeping the two lifecycles separate is
deliberate: the org answers "is this account allowed to operate?" and the
subscription answers "what have they paid for?".
How it maps to storage¶
This one is refreshingly literal. Unlike Subscription — where
seats-in-use is view-derived and status lives in a state machine — every attribute
on Organization is a real, plainly-typed column on accounts.organizations, and the
rules that matter are enforced by the database itself rather than by application code.
That's unusual enough in this codebase to be worth saying out loud: if you read a
row, what you see is the truth, with no app-layer derivation sitting on top.
The one piece of "behavior" that isn't visible in the schema is who writes it.
seat_limit is a column on the accounts DB, but accounts-service is not the thing
that decides its value — billing-service is, and it writes the new number through the
accounts-service API whenever the plan changes. So the column is owned by accounts
but driven by billing. The "Synced, not authored" note covers
the consequences.
Everything below is engineer-facing reference.
Schema¶
Primary store: accounts.organizations (MySQL).
| Field | Type | Null | Backed by |
|---|---|---|---|
id |
bigint |
No | column (PK); referenced as org_id everywhere else |
name |
varchar(255) |
No | column |
status |
enum('active','suspended') |
No | column — DB-enforced enum, default active |
seat_limit |
int |
No | column; written by billing-service on plan change |
The PK deserves a callout: there is no org_id column on this table — the org's
own primary key is id. org_id is the name that same value goes by once it crosses
into another store: it's the foreign-ish key on Subscription, the
tag on every delivery event, and the parameter on
GET /orgs/{id}. None of those are real database foreign keys back to this table
(they're in different services and, for delivery events, a different database
entirely), which is exactly why the relationship enforcement below reads
"cross-store (none)".
Defaults & derivations¶
| Field | DB default | App-level default / derivation |
|---|---|---|
status |
active |
none needed — the DB default does the work |
seat_limit |
none | set by billing-service to the plan's seat limit at create and on every plan change |
Compared to Subscription, there's almost nothing here, and that's the point: status
gets its default from the column definition, not from app code, so there's no way to
create an org in a weird half-initialized state. seat_limit has no DB default
because there's no sensible plan-independent value — it's always sourced from the
plan the org is on.
Constraints & validation¶
| Rule | Enforced in | Detail |
|---|---|---|
status ∈ |
DB | real enum column — an invalid value is rejected at write time |
name is present |
DB | NOT NULL |
seat_limit is present |
DB | NOT NULL |
seat_limit ≥ seats actually assigned |
none | not checked here; the active-seat count lives in the seat-assignment data, and "are we over the limit" is computed by billing-service against the Subscription view, not enforced on this row |
The contrast with Subscription is the lesson. There, the high-value rows in this
table are the app-only and none ones — the DB will happily store a status it
shouldn't. Here, status is the one rule you don't have to worry about, because the
column enum enforces it. The single unenforced rule is the cross-store one: nothing
stops seat_limit from being set below the number of seats currently in use. In
practice billing-service avoids that because it only raises the limit on upgrade
and applies downgrades carefully at period end, but the org row itself offers no
guarantee — don't assume seat_limit is always ≥ assigned seats.
Relationships¶
| Related model | Via | Cardinality | Enforcement |
|---|---|---|---|
| User | org_id (on user) |
one org → many users | DB FK |
| Subscription | org_id (on subscription) |
one org → one active sub | cross-store (none) — different service / DB |
| Plan | indirect — via the org's subscription | one org → one plan (at a time) | n/a — there's no direct link; the org reaches its plan through the subscription |
| Delivery event | org_id (on each event) |
one org → many events | cross-store (none) — events live in MongoDB in messaging-service |
erDiagram
ORGANIZATION ||--o{ USER : contains
ORGANIZATION ||--|| SUBSCRIPTION : "billed via"
SUBSCRIPTION }o--|| PLAN : "on"
ORGANIZATION ||--o{ DELIVERY_EVENT : "sends"
The split in enforcement tells the whole architecture story in one table. Users live
in the same database as the org, so that relationship is a real DB foreign key —
delete an org and the database itself has something to say about its users.
Everything else crosses a service boundary and is therefore cross-store with no
enforcement: the subscription is in billing's MySQL, delivery events are in
messaging's MongoDB, and the "link" is nothing more than an org_id value that
matches by convention. The database can't and won't stop those references from
dangling.
Notes & nuances¶
Authority boundary
accounts-service is the only writer of org data. billing-service and others
read it over the API (GET /orgs/{id}) and must not cache it as truth for
long. The reason is correctness under change: an org's status can flip to
suspended at any moment, and a stale cached "active" could let a suspended
customer slip an action through. Read it fresh at decision time — for example,
Plan upgrade re-reads the org's status from
accounts-service before applying the change, rather than trusting anything it
saw earlier.
Synced, not authored — seat_limit
seat_limit is a column accounts-service stores but does not decide. The
authority on the right value is the plan, and billing-service writes
the number onto the org (through the accounts-service API) whenever the plan
changes. So if you ever see seat_limit disagree with the plan the org is on,
suspect a missed or failed sync from billing-service, not a bug in accounts —
accounts is faithfully holding whatever it was last told.
Soft references can dangle
Because Subscription and delivery events
reference the org only by a cross-store org_id with no foreign key, a deleted
org can briefly leave orphaned subscriptions or events until cleanup runs. Reads
on the other side should tolerate a missing org rather than assume every org_id
resolves. (The same caution appears on the Subscription page, from its side of
the boundary.)
Future / cleanup
Two things would make this page sturdier: a real User model page to make the
DB-FK relationship resolve to its own reference (accounts-service is still a
stub), and confirmation of whether anything
reads seat_limit off the org directly versus going through the subscription —
if nothing does, the column is pure denormalized convenience and could be noted as
such.
Sources & confidence¶
Derived from Organization.java in beacon-accounts and the accounts.organizations
schema. Confidence is a solid 85 — the model is small and DB-enforced, so there's
little hidden app behavior to get wrong — held below 90 mainly because
accounts-service itself is still a stub: the User model, the suspend endpoint's
exact contract, and the precise mechanism by which billing-service writes
seat_limit are documented from the consuming side, not yet verified against the
accounts source directly.
Related pages¶
Used by: Message settings, Plan upgrade, Send a message, Suspend an organization
Owned by: accounts-service
Related: Models, Overview, Plan, Roles & permissions, Services, Subscription
Confidence: 85/100 · Last reviewed: 2026-06-14 · Status: active
Verified against: beacon-accounts@4c5d6e7 (2026-05-28)