Plan¶
The package an organization pays for — its price, billing interval, seat limit, and feature set. A Subscription references exactly one plan via
plan_id, and that plan's seat limit is what flows over to the org'sseat_limitwhen the plan changes. Owned by billing-service (Ruby/Rails on MySQL).
Still a stub
This page documents the known shape of a plan — what it is, the fields we're
confident exist, and how it connects to the rest of billing — but it has not
been verified field-by-field against the billing.plans schema. The
Schema section is marked unverified and confidence is held low (20)
until someone reads db/schema.rb and the Plan model directly. To deepen, see
Sources & confidence. The DOC-DEEPEN backlog entry
tracks this.
A plan is the menu item a customer chooses. Everything an org is entitled to — how many people can use Beacon, which features are on, what they're charged and how often — is decided by the plan its Subscription points at. The plan itself is static catalog data: it describes the offer. The Subscription is where the running state lives (which plan this org is on right now, whether they're paying, when they renew). Keeping those two apart is the whole reason there are two models: a Plan is shared by many orgs and rarely changes; a Subscription is per-org and changes constantly.
Business view¶
Plain language — safe for non-engineers. No table or column names here.
| Attribute | What it means | Rules / behavior |
|---|---|---|
| Name | What the plan is called | Shown on pricing and in the app (e.g. "Starter", "Growth") |
| Price | What the org pays per period | Charged each billing interval; mid-cycle changes are billed prorated |
| Billing interval | How often they're charged | Monthly or annual; sets the length of a billing period |
| Seat limit | Most users the plan allows | Becomes the org's seat limit when the plan is chosen or changed |
| Feature set | What's turned on | Gates which capabilities the org can use |
The everyday way to think about it: an org picks a plan, and the plan's numbers become the org's limits. Move to a bigger plan and the seat limit goes up (and the org is charged the prorated difference straight away); the plan's feature set decides what's switched on. None of this state lives on the plan per org — the plan is the template, the Subscription is the instance.
Where each number ends up living
The plan is the source of these values, but at runtime they're read through the subscription, and the seat limit is copied onto the organization:
- Price / interval → drive invoicing on the Subscription
(
current_period_endis set tonow + plan.intervalat activation). - Seat limit → copied to Organization.
seat_limitby billing-service whenever the plan changes (see below). - Feature set → read to decide what the org may do.
How it maps to storage¶
A plan lives in MySQL alongside the rest of billing (billing.plans, same database
as billing.subscriptions). Unlike Subscription — whose most
interesting fields are view-derived or app-governed — a plan is expected to be
plain catalog rows: each column is a real stored value, set when the plan is
defined and seldom touched after. There's no view backing and no per-org state to
compute, because a plan is shared across every org that's on it.
Two connections are worth understanding before the field tables, because they're the reason a plan matters to the rest of the system:
plan_idis an app-only reference, not a foreign key. A Subscription points at a plan viaplan_id. Both tables live in the same MySQL database, but no foreign key is declared — the relationship is enforced (or not) in application code. The DB will happily store a subscription whoseplan_idmatches no plan.- Seat limit flows out to the org on plan change. A plan's seat limit isn't read
live by accounts-service. Instead, when an org changes plan, billing-service
writes the new limit onto Organization.
seat_limit(a DB-enforced column over in accounts). So the plan is the source of truth for the offer, but the org carries its own copy of the current limit. If a plan's seat limit is edited later, existing orgs don't automatically pick up the change — only a plan change re-syncs it.
flowchart LR
plan["Plan (billing.plans)"]
sub["Subscription (billing.subscriptions)"]
org["Organization (accounts.organizations)"]
plan -->|"plan_id (app-only ref)"| sub
plan -->|"seat_limit copied on plan change"| org
Everything below is engineer-facing reference.
Schema¶
Primary store: billing.plans (MySQL).
Unverified — stub
The fields below are inferred from how a plan is used (the canon shape: price,
billing interval, seat limit, feature set) and from the plan_id reference on
Subscription. They have not been confirmed against
db/schema.rb or the Plan model. Types, nullability, and exact column names are
placeholders — verify before relying on them.
| Field | Type | Null | Backed by |
|---|---|---|---|
id |
bigint |
No | column (PK) — referenced as plan_id on subscriptions |
name |
varchar (unverified) |
No (unverified) | column |
price_cents |
int (unverified) |
? | column — exact money representation unverified |
interval |
varchar / enum (unverified) |
? | column — e.g. month / year |
seat_limit |
int (unverified) |
? | column — copied to org on plan change |
| feature set | ? (unverified) | ? | shape unknown — could be columns, a flags blob, or a join table |
Open: how is the feature set actually stored?
Canon says a plan has a feature set, but how it's stored isn't established.
Plausible shapes to check for when deepening: a set of boolean columns, a JSON
flags blob, a plan_features join table, or a named feature tier string. Until
confirmed, treat "feature set" as a concept, not a column.
Defaults & derivations¶
Unverified — no defaults have been confirmed against the schema or model. Catalog rows are typically authored explicitly (each field set when the plan is defined) rather than relying on DB defaults, but confirm this when deepening.
| Field | DB default | App-level default / derivation |
|---|---|---|
interval |
unverified | unverified — likely set explicitly per plan |
seat_limit |
unverified | unverified |
Constraints & validation¶
Unverified — listed as expectations to confirm, not established facts. Given the billing pattern elsewhere (see Subscription, where status is app-governed with no DB check), assume validation lives in the Rails model unless the schema proves otherwise.
| Rule | Enforced in | Detail |
|---|---|---|
plan_id on a subscription references a real plan |
app-only (expected) | same DB, but no FK declared — confirm |
price / seat_limit non-negative |
unverified | check the Plan model validations |
interval ∈ a known set |
unverified | check whether it's an enum or free string |
Relationships¶
| Related model | Via | Cardinality | Enforcement |
|---|---|---|---|
| Subscription | plan_id (on subscription) |
one plan → many subscriptions | app-only (same DB, no FK declared) |
| Organization | seat_limit copied on plan change |
one plan → many orgs (indirect) | none — value copied, not referenced |
erDiagram
PLAN ||--o{ SUBSCRIPTION : "chosen by"
PLAN ||..o{ ORGANIZATION : "seat_limit copied to"
Notes & nuances¶
The point to carry away: a plan is shared, static catalog data, while everything per-org and changing lives on the Subscription. When you find a number that an org is "on" — its seat limit, its price — trace it back here for the offer, but read the runtime value from the subscription (or, for the seat limit, the org's own copied column).
Plan vs. legacy tier
Subscription carries a legacy tier column (bronze /
silver / gold) that predates plan_id. tier is not a Plan — it's an
old, coarser grouping kept only for subscriptions created before 2023-04. New code
keys off plan_id; the eventual cleanup is to backfill plan_id from tier and
drop tier. Don't confuse the two.
Future / cleanup
The deepen pass should: (1) read billing.plans from db/schema.rb and the
Plan model to confirm every field, type, and validation; (2) establish how the
feature set is stored; (3) confirm the plan_id relationship really has no FK;
and (4) trace the seat-limit sync to Organization in code to
verify it's a copy-on-change, not a live read.
Suspected dead
Subscription carries a promo_ref column that no current code
path appears to write — likely a remnant of an old promotions feature that may
once have tied into plans/pricing. Flagged on the Subscription page; noted here in
case the promotions concept resurfaces while deepening plan pricing. Confirm
before assuming any plan-side promo logic exists.
Sources & confidence¶
This page is derived from canon and usage, not yet from the plan's own schema:
the role of a plan (price, interval, seat limit, feature set) and the plan_id
reference on Subscription, plus the seat-limit sync to
Organization described in billing-service's behavior. Confidence
is intentionally low (20) because no field-level detail has been verified against
billing.plans in beacon-billing — the Schema, defaults, and
constraints sections are explicitly marked unverified. To promote this page out of
stub: read db/schema.rb and the Plan model on main, confirm the field tables,
resolve the feature-set question, stamp sources: with the verified sha, and raise
the confidence and status accordingly.
Related pages¶
Related: Models, Overview, Subscription
Confidence: 20/100 · Last reviewed: 2026-06-14 · Status: stub