Plan upgrade¶
An organization admin upgrades their subscription to a higher plan. The new plan takes effect immediately, the next invoice is prorated, and the admin receives a confirmation message.
Upgrading is the most common self-serve change an admin makes to what they pay Beacon, and it's the path a growing customer takes to unlock more seats and higher-tier features. Three of Beacon's services touch it: the admin drives it from the web UI, billing-service is the authority that validates and applies the change, and messaging-service sends the confirmation. Because the change is immediate and prorated, the interesting parts of this feature are mostly about timing and money — when the new entitlements switch on, and what the customer is charged for the remainder of the current cycle.
This page runs from the admin's experience down to the wire-level flow. If you only need the product behaviour, the first two sections are enough; keep reading for the service contract, edge cases, and the gotchas worth knowing before you touch this path.
User journey¶
- Admin opens Settings → Billing and picks a higher plan.
- They confirm the prorated charge shown in the dialog.
- The UI shows the new plan as active, and a confirmation email arrives shortly after.
What the admin actually sees, in a little more detail:
- Only an admin gets here. The upgrade entry point lives in the org's billing settings, which non-admin users can't open. There's no per-user upgrade — a subscription belongs to the whole Organization, so one admin's change applies to everyone in the org.
- The dialog shows the prorated amount before they commit. When the admin
selects a higher plan,
UpgradeDialog.tsxasks billing-service for the prorated difference and renders it ("you'll be charged $X today"). This is a real charge for the remainder of the current cycle, not a preview of the next invoice — see the Discrepancy note below, because the help center disagrees with the code here. - The switch is instant. As soon as the confirm call returns
200, the org is on the new plan: higher seat limit, new feature set, new price. The admin doesn't wait for the next billing cycle and doesn't need to do anything else. - Confirmation arrives out-of-band. The email isn't part of the upgrade response — it's sent asynchronously by messaging-service after the fact, so a brief delay between "plan active" and "email received" is normal and expected.
What counts as an upgrade vs. a downgrade
An upgrade is a plan change to a plan the customer pays more for, and it
applies immediately with a prorated charge. Moving to a cheaper plan is a
downgrade, which Beacon applies at period end rather than immediately (so the
customer keeps what they paid for through the cycle). Note the implementation
wrinkle: there is a POST /subscriptions/{id}/downgrade route, but it's
suspected dead — current callers appear to run
downgrades through the upgrade endpoint with a lower plan_id. If you're
tracing the downgrade path, start from that finding rather than the route name.
How it works¶
The UI calls billing-service, which validates the change against the
organization's state in accounts-service, updates the Subscription,
and emits a subscription.upgraded event. messaging-service consumes that
event and sends the confirmation.
sequenceDiagram
participant UI
participant Billing as billing-service
participant Accounts as accounts-service
participant MQ as RabbitMQ
participant Messaging as messaging-service
UI->>Billing: POST /subscriptions/{id}/upgrade {plan_id}
Billing->>Accounts: GET /orgs/{id} (seat count, status)
Accounts-->>Billing: org details
Billing->>Billing: validate + prorate, update Subscription
Billing-->>UI: 200 {subscription}
Billing-)MQ: publish subscription.upgraded
MQ-)Messaging: subscription.upgraded
Messaging->>Messaging: render + send confirmation
Walking the path the way the data actually flows:
1. The UI submits the change. UpgradeDialog.tsx posts to
POST /subscriptions/{id}/upgrade with the target plan_id. The request is keyed
on the subscription id, not the org — billing-service resolves the org from the
subscription's org_id.
2. billing-service reads the org from accounts-service. Before applying
anything, it calls GET /orgs/{id} on accounts-service to fetch the org's current
status and seat count. This is a synchronous, read-only HTTP call across a service
boundary — billing-service never writes org data, and per the
Organization authority boundary it must not treat a
cached copy as long-term truth. This is the step that makes a suspended org's
upgrade fail (see edge cases).
3. It validates and prorates, then persists. With the org details in hand,
billing-service checks the change is legal (org is usable, the subscription is in a
state that can be upgraded), computes the proration for
the rest of the cycle in Billing::Proration, and updates the
Subscription row in MySQL — plan_id, the new
seats/limit, and so on. Status transitions are governed by
Billing::Subscription::StateMachine in application code, not the database. The new
seat limit is also propagated to accounts-service so the org's seat_limit stays in
sync with what the plan now allows.
4. It returns 200 with the updated subscription. At this point the change is
durable and the customer is on the new plan; the UI re-renders from the returned
subscription.
5. It emits subscription.upgraded to RabbitMQ. This happens after the
response — the confirmation message is a downstream side effect, deliberately
decoupled so a slow or failing message provider never blocks the upgrade itself.
6. messaging-service sends the confirmation. It consumes the event, renders the
confirmation template (templates live in PostgreSQL), sends via the email provider,
and logs a delivery event to MongoDB. It then emits
message.delivered (or message.failed). messaging-service owns no subscription or
org state — it reacts to the event and nothing more.
Why the event comes after the response
Beacon splits the commit (apply the plan change, charge proration) from the notify (send confirmation). The commit is synchronous so the admin sees an immediate, trustworthy result; the notify is asynchronous over RabbitMQ so provider latency or outages can't fail or slow an upgrade. The trade-off is the one the journey calls out: the email lags the UI confirmation slightly, and is only eventually guaranteed. A dropped event means a missing confirmation, not a missing upgrade.
Services & data involved¶
- Services: billing-service (owns the change), accounts-service (org state), messaging-service (confirmation).
- Models: Subscription, Organization.
The three services map cleanly onto the three steps — drive, decide, notify:
| Service | Stack | Role in an upgrade |
|---|---|---|
| beacon-web (UI) | TypeScript / React | Renders the dialog, shows the prorated amount, posts the upgrade |
| billing-service | Ruby/Rails on MySQL | Validates, prorates, updates the subscription, emits the event — the authority |
| accounts-service | Java/Spring on MySQL | Read for org status + seats; receives the synced seat limit |
| messaging-service | Elixir/Phoenix on PostgreSQL + MongoDB | Consumes the event, sends + logs the confirmation |
A note on accounts-service's stack
The services index, the architecture diagram, and
organization.md all describe accounts-service as
Java/Spring on MySQL, and its own service page
— frontmatter (language: java) and prose alike — agrees. That page is still a
stub, though, so for the data it touches in an upgrade (org status, seat
count, seat_limit) the Organization model is the
deeper authority.
Endpoints and the event, at a glance¶
| Interface | Direction | What it carries |
|---|---|---|
POST /subscriptions/{id}/upgrade |
UI → billing-service | { plan_id }; returns the updated subscription |
GET /orgs/{id} |
billing-service → accounts-service | Org status + seat count, read before applying |
subscription.upgraded |
billing-service → RabbitMQ → messaging-service | Triggers the confirmation message |
message.delivered / message.failed |
messaging-service → RabbitMQ | Records the confirmation's outcome |
The HTTP endpoints are part of billing-service's OpenAPI contract (authored in
TypeSpec); subscription.upgraded is on the messaging side's AsyncAPI. See each
service page for the formal contract links.
Notes & nuances¶
Proration lives in application code
The prorated amount is computed in billing-service (Billing::Proration), not
by the database or the payment provider. The contract exposes the result but not
the formula.
Edge cases & permutations
A few situations change the outcome:
- Suspended org. If accounts-service reports the org
statusassuspended, billing-service rejects the upgrade — a suspended org can't send and shouldn't be taking on new entitlements. The fix is to resolve the suspension first, then retry. - Subscription not in an upgradeable state. Upgrades assume a live
subscription. A
canceledsubscription has no plan to upgrade from; apast_dueone is mid-dunning and the sensible action is to recover payment rather than change plans. The legal transitions are enforced byBilling::Subscription::StateMachine(see the Subscription model), not the database. - Same or lower plan. Posting the current
plan_idis a no-op upgrade; posting a lower-priced plan is really a downgrade and is expected to apply at period end — but note the downgrade path's suspected-dead status above. - Seats already over the new limit. Seats in use are derived from accounts-service, so an upgrade that raises the limit is always safe; the constraint matters in the downgrade direction, not here.
- Confirmation never arrives. Because the email is a decoupled side effect,
a lost
subscription.upgradedevent or a provider failure means no confirmation even though the upgrade succeeded. The delivery event log andmessage.failedare where you'd look to tell "never sent" from "sent but bounced."
Discrepancy
The help center says an upgrade is free
until the next billing cycle; the code charges the prorated difference
immediately (Billing::Proration). Code wins — the help article is stale
(BILL-1423).
Suspected dead
The UI still sends a coupon_code field on upgrade, but billing-service ignores
it — coupon handling appears to have moved to the checkout flow. Needs
confirmation; if dead, remove from the contract and the UI.
Related pages¶
Related: Models, Organization, Plan, Roles & permissions, Services, Subscription
Confidence: 78/100 · Last reviewed: 2026-06-14 · Status: active
Verified against: beacon-web@9f8e7d6 (2026-06-02), beacon-billing@a1b2c3d (2026-05-30), beacon-accounts@4c5d6e7 (2026-05-28)