Platform admin
Plans & Stripe sync
Plans are the only piece of customer-facing data that admins create
directly. The Plan CRUD page (/admin/plans) is paired with
Stripe so you never touch the Stripe dashboard to provision Products
and Prices โ every save here syncs to Stripe automatically.
The plans table
/admin/plans lists every plan with its core attributes,
the workspace count using it, and a sync status pill (green = in sync
with Stripe, amber = pending, gray = local-only / free).
| Column | Notes |
|---|---|
| Name | Display name. Editable. |
| Slug | Stable identifier. Locked after creation โ workspaces.plan_id resolves by slug indirectly through the Plan table, and changing it would break invoices. |
| Monthly conversations | Quota. |
| Price | Monthly price. Changing it archives the old Stripe Price + creates a new one. |
| Workspaces | How many workspaces are on this plan today. |
| Stripe IDs | Product + Price IDs after sync. Free / custom plans show "โ". |
| Active | Toggle. Inactive plans aren't selectable on the customer side. |
Creating a plan
New plan opens the form. Fields:
- Name โ required.
- Monthly conversations โ required.
0= unlimited. - Price (cents) โ required.
0= free / custom (skips Stripe). - Features โ toggles:
remove_branding(and future flags). - Active โ defaults to true.
On save, the server creates the local row, then triggers
StripeProductSync::syncPlan(). If the price is > 0, a
Stripe Product + Price are created and their IDs saved on the plan row.
If Stripe is unreachable or misconfigured, the local row is kept and a
flash error explains the failure โ you can retry the sync without
re-saving the form.
The Sync button
Each row has a Sync action that fires
StripeProductSync::syncPlan() directly. Returns JSON with
the result so the UI can show "Synced" / error inline without a page
reload. Useful when:
- You changed the Stripe key and want to re-bind everything.
- A previous sync failed and you've fixed the underlying issue.
- You want to verify a plan's Stripe state without touching the form.
Editing
Edits behave intuitively except for two subtleties:
- Price changes rotate the Stripe Price. Stripe Prices are immutable, so we archive the old and create a new one. Existing subscriptions stay on the old Price (grandfathered); only new subscriptions use the new one.
- Slug is locked. The form input is disabled in edit mode.
Deleting
Plans are never destructively deleted. The
destroy action soft-deletes (is_active = false)
and archives the Stripe Product. Reasons:
workspaces.plan_idis a real foreign key โ deleting would orphan or cascade.- Historical invoices reference the plan; we need to be able to look it up forever.
- Subscriptions in flight need a stable plan to attach to.
Reactivating a soft-deleted plan: edit it and toggle Active back on. The Stripe Product is unarchived and the plan is selectable again.
Free / custom plans
Plans with price_cents = 0 never sync to Stripe. They live
only in Pitchbar โ useful for the default Free plan and for hand-rolled
enterprise deals where you want the quota and feature flags but invoice
out-of-band.
Currency
Set globally via CASHIER_CURRENCY in the environment.
Defaults to USD. Changing the currency mid-flight on a deployment with
existing Prices is a manual migration โ you'd archive every Stripe
Price, change the env var, then sync each plan to mint new Prices in
the new currency.