Subscription¶
What an organization is currently paying for: a plan, a billing cycle, and a lifecycle status. Owned by billing-service; read by several others as the authority on what a customer is entitled to.
A subscription is the contract between an organization and Beacon. It answers three questions at once: which plan the org is on (and so what it can do and how much it costs), how many seats it has paid for, and whether it's in good standing right now. Almost every entitlement decision elsewhere in the product traces back to a subscription — whether an org can send, whether it's near its seat limit, what it gets invoiced.
Because it's the authority on entitlement, this model is read far more widely than it's written. Only billing-service writes it; other services read it (or read facts derived from it) and must treat it as the source of truth rather than keeping their own copy. That ownership boundary is the single most important thing to keep in mind when you work with this data — see How it maps to storage for where the seams are.
This page runs business → technical. The first two sections are enough if you only need the product behaviour; keep scrolling for the schema, the app-only rules, and the gotchas worth knowing before you touch this data.
Business view¶
Plain language — safe for non-engineers.
| Attribute | What it means | Rules / behavior |
|---|---|---|
| Plan | The package the org pays for | Upgrades take effect immediately; downgrades at period end |
| Status | Whether the subscription is live | Trial → active once paid; lapses to past due on failed payment |
| Seats | How many people they've paid for | Set at purchase; changes are billed prorated |
| Seats in use | How many are actually assigned | Used to warn admins when they're near the limit |
| Renewal date | When they'll next be charged | Drives invoicing and reminder messages |
A subscription starts life as a trial, becomes active on the first successful payment, and from there stays active as long as payments keep clearing. A failed payment doesn't cancel anything outright — it moves the subscription to past due and starts dunning, Beacon's automated grace period that keeps retrying and notifying. Dunning has two ways out: payment recovers and the subscription returns to active, or dunning gives up and the subscription is canceled. An admin can also cancel directly from an active subscription.
Lifecycle, in business terms:
stateDiagram-v2
[*] --> Trialing
Trialing --> Active: first payment
Active --> PastDue: payment fails
PastDue --> Active: payment recovers
Active --> Canceled: admin cancels
PastDue --> Canceled: dunning gives up
Canceled --> [*]
A few product rules are worth pulling out, because they trip people up:
- Upgrades and downgrades are not symmetric. An upgrade (to a pricier plan) applies immediately with a prorated charge for the rest of the cycle. A downgrade applies at period end, so the customer keeps what they paid for through the current cycle. The full path is documented on Plan upgrade.
- "Seats" and "seats in use" are different numbers. Seats is what the org paid for; seats in use is what's actually assigned to people. The gap between them is what drives "you're almost out of seats" warnings — and the rule that you can't assign more people than you've paid for.
- A subscription belongs to the whole org, not a person. There's one active subscription per organization; any admin's change applies to everyone.
How it maps to storage¶
Most attributes are plain columns on billing.subscriptions
(MySQL), but two are not — which is the reason to read this
page rather than the schema:
- Seats in use is view-derived. It comes from
active_subscriptions_v, which joins seat assignments owned by accounts-service. It is not a stored column, so querying the base table alone won't show it. This is also why it's always current: it reflects accounts truth at read time rather than a number billing-service has to keep in sync. - Status is governed by application code. The column is a free-form string; the
legal values and transitions live in
Billing::Subscription::StateMachine. The database will store an invalid status without complaint.
The second point is the one that surprises people, so it's worth dwelling on: the
status column is just a varchar. The database has no idea that
trialing → canceled → active is illegal, or that frobnicate isn't a real status.
Everything that makes status trustworthy lives in Ruby. If a row is ever written
outside the state machine — a manual UPDATE, a bad migration, a future service that
doesn't go through the model — nothing at the storage layer will stop it. Treat the
state machine, not the schema, as the contract.
The same "enforced in app, not in the DB" pattern shows up again in
Constraints & validation below: one-active-subscription-per-org
and seats ≥ seats_in_use are both app rules with no database backstop. Surfacing
exactly where each rule lives is the highest-value thing this page does.
flowchart LR
subgraph billing[billing-service · MySQL]
base[(subscriptions<br/>base table)]
view[active_subscriptions_v<br/>view]
base --> view
end
subgraph accounts[accounts-service · MySQL]
seats[seat assignments]
end
seats -. joined at read time .-> view
view -->|seats_in_use| reader[any reader]
Everything below is engineer-facing reference.
Schema¶
Primary store: billing.subscriptions (MySQL).
| Field | Type | Null | Backed by |
|---|---|---|---|
id |
bigint |
No | column (PK) |
org_id |
bigint |
No | column |
plan_id |
bigint |
No | column |
status |
varchar(32) |
No | column (values governed in app) |
seats |
int |
No | column |
seats_in_use |
int |
— | view active_subscriptions_v (computed) |
current_period_end |
datetime |
No | column |
tier |
varchar(16) |
Yes | column (legacy — see historical) |
promo_ref |
varchar(64) |
Yes | column (see suspected dead) |
A typical active row, read through the view, looks like this:
{
"id": 4821,
"org_id": 173,
"plan_id": 7,
"status": "active",
"seats": 25,
"seats_in_use": 19,
"current_period_end": "2026-07-01T00:00:00Z",
"tier": null,
"promo_ref": null
}
Note seats_in_use (19) is not stored on the base row — it's computed by the view at
read time. Read the base table directly and you simply won't see that field. tier
and promo_ref are null on any subscription created by current code; they only
carry values on old rows (see the notes at the bottom).
Defaults & derivations¶
| Field | DB default | App-level default / derivation |
|---|---|---|
status |
none | trialing on create (Subscription.new) |
seats |
1 |
overridden by the purchase flow |
seats_in_use |
— | computed in the view from accounts seat assignments |
current_period_end |
none | set to now + plan.interval at activation |
current_period_end is worth a word: it's not set at create time but at
activation — when the trial converts to active on first payment — using the chosen
plan's billing interval. It's the field that drives renewal invoicing and
the reminder messages that go out before a charge, so a wrong value here ripples into
both billing and messaging.
Constraints & validation¶
| Rule | Enforced in | Detail |
|---|---|---|
status ∈ |
app | StateMachine; no DB check constraint |
| Legal status transitions | app | e.g. can't go canceled → active |
| One active subscription per org | app | enforced in service, not a DB unique index |
seats ≥ seats_in_use |
app | checked on seat assignment, not in DB |
org_id references a real org |
none | cross-store; not validated at write time |
Read this table as a map of where things can go wrong. Every rule marked app is one the database will happily let you violate with a direct write; the only true storage-level guarantees on this model are the primary key and the foreign key to invoices. In particular:
- One active subscription per org is a service invariant, not a unique index. The
schema would let two
activerows share anorg_id. Don't rely on the DB to keep this true — go through billing-service. seats ≥ seats_in_useis checked by accounts-service at seat assignment time, not when the subscription is written. Loweringseatsbelow the number of people already assigned is therefore a downgrade-time concern, handled in the assignment path rather than enforced here.org_idis validated nowhere at write time — it crosses a service boundary. See the soft-reference note below for what that means in practice.
Relationships¶
| Related model | Via | Cardinality | Enforcement |
|---|---|---|---|
| Organization | org_id |
many subs → one org | cross-store (none) — different service/DB |
| Plan | plan_id |
many subs → one plan | app-only (same DB, no FK declared) |
| Invoice | subscription_id (on invoice) |
one sub → many invoices | DB FK |
erDiagram
ORGANIZATION ||--o{ SUBSCRIPTION : has
SUBSCRIPTION ||--o{ INVOICE : generates
SUBSCRIPTION }o--|| PLAN : "on"
Three relationships, three different levels of enforcement — a useful illustration of Beacon's data boundaries:
- Organization (
org_id) is cross-store. The org lives in a different service and a different database (accounts-service), so there's no foreign key and nothing stops a subscription from pointing at an org that no longer exists. Reads must tolerate a missing org (see the dangling-reference note). - Plan (
plan_id) is app-only. Plans live in the same MySQL database as subscriptions, but no foreign key is declared — the relationship is maintained by application code. The integrity is real in practice, but the database isn't the thing guaranteeing it. - Invoice is a real DB foreign key. This is the one relationship the database
itself enforces: invoices carry
subscription_idand the FK guarantees they point at a real subscription. A subscription accumulates many invoices over its life — one per billing period, plus prorated adjustments from mid-cycle plan changes.
Notes & nuances¶
Soft references can dangle
Because org_id crosses a service boundary, a deleted org can briefly leave an
orphaned subscription until the cleanup job runs. Reads should tolerate a missing
org.
Reading state safely: status vs. the view
Two practical habits when you read this model. First, if you care about
seats_in_use, read through active_subscriptions_v, not the base table —
otherwise the field isn't there at all. Second, never infer status from anything
other than the status column as set by the state machine; don't, for example,
treat "has a future current_period_end" as a proxy for "active." Past-due
subscriptions still have a period end.
Deprecated / historical
tier (bronze/silver/gold) predates plan_id and is retained only for
subscriptions created before 2023-04. New code ignores it; reporting still reads
it for old rows. On any modern subscription it's null, which is the tell that the
Plan relationship (plan_id) is now the single source of "what package
is this."
Future / cleanup
Backfill plan_id for pre-2023 rows from tier, then drop tier. Once status
values are known-clean, add a real DB check constraint to promote that rule from
app-only to DB-enforced. (The same "promote an app rule to the DB once the data is
clean" idea applies to the one-active-subscription-per-org invariant, which could
become a partial unique index.)
Suspected dead
promo_ref is never written by any current code path we can find — likely a
remnant of an old promotions feature. Confirm before removing.
Where this fits¶
- Service: owned and written by billing-service;
exposed over its OpenAPI contract (
GET /subscriptions/{id},POST /subscriptions/{id}/upgrade). - Feature: the Plan upgrade flow is the main path that mutates a subscription — read it for the read-validate-update-emit sequence and the proration timing.
- Related models: an Organization has exactly one active
subscription; each subscription is on exactly one Plan. When billing
changes the plan, it also syncs the org's
seat_limitover in accounts-service. - Downstream: an upgrade emits
subscription.upgraded, which messaging-service turns into a confirmation message and a delivery event.
Sources & confidence¶
Derived from the Rails models in billing-service, the active_subscriptions_v
definition, and observed rows. Confidence held below 90 because the pre-2023 tier
path and promo_ref are not fully traced.
Related pages¶
Used by: Plan upgrade
Owned by: billing-service
Related: Delivery event, Models, Organization, Overview, Plan, Services
Confidence: 80/100 · Last reviewed: 2026-06-14 · Status: active
Verified against: beacon-billing@a1b2c3d (2026-05-30)