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. - Monthly messages โ optional. Caps every visitor message across the workspace for the calendar month. Leave blank for no extra cap.
- Max tokens per response โ optional. Hard ceiling on LLM reply length. Min 100, max 8000.
- Price (cents) โ required.
0= free / custom (skips Stripe). - Features โ toggles:
remove_branding(and future flags). - Active โ defaults to true.
Resource limits (per-workspace caps)
The Resource limits card lets admins differentiate plan
tiers beyond AI rate quotas. Every field accepts a positive integer, the
literal 0, or blank:
- Blank = unlimited. Every pre-1.3 plan was migrated to NULL on all six columns, so existing customers are never retroactively capped.
- 0 = hard block. Useful for the Free tier ("no integrations on this plan").
- Positive integer = absolute cap. Counting honours soft-deletes (a trashed agent does not count) and pending invitations DO count toward the member cap (otherwise a workspace could queue 100 invites and accept them all later).
| Field | What it caps | How it's counted |
|---|---|---|
agents_limit | Agents per workspace | Agent::where('workspace_id', X) โ every non-trashed agent. |
sources_limit | Knowledge sources across all agents in the workspace | Sum of Source rows whose agent is owned by the workspace. |
workflows_limit | Workflows per workspace | Workflow::where('workspace_id', X) |
integrations_limit | Slack/etc connections + outbound webhook subscriptions | IntegrationConnection + WebhookSubscription rows, summed. |
members_limit | Seats per workspace | Accepted workspace_users rows + non-expired pending invitations. |
api_access | Whether workspaces on this plan can mint API tokens | Checkbox. Defaults to ON for back-compat. |
When a workspace hits a cap, the affected "Create" endpoint redirects
back with a flash error message:
"You've reached your plan's limit of N agents. Upgrade to add more."
Frontends render the flash banner without needing per-resource code.
The enforcement lives in
App\Services\Billing\PlanLimits. Tests under
tests/Feature/PlanLimitsTest.php cover every controller +
every "NULL = unlimited" back-compat path.
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.