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. The /app/agents Knowledge column renders this as used/limit (or just used when the plan is unlimited) — see [[agent-index-knowledge-column]]. |
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.
Free trial plans
Toggle Free trial plan on a plan to turn it into a no-credit-card, time-limited trial, and set a Trial length in days (commonly 7, 14, or 30; blank defaults to 14). When a new user signs up on this plan, their workspace starts a trial that runs for that many days — no payment method is collected up front.
Pair it with Default plan for new signups to put every new account on a trial automatically. The quotas and feature flags you set on the trial plan are exactly what the customer gets during the trial, so you control how much of the product the trial unlocks.
When the trial period ends and the customer hasn't subscribed to a paid plan, the workspace is walled: every customer screen redirects to the billing page with an "upgrade to continue" prompt. Their agents, sources, and conversations are preserved — nothing is deleted — and access is restored the moment they pick a paid plan. The billing page, account settings, and sign-out stay reachable so the user is never trapped. A slim "X days left in your trial" banner appears across the app while the trial is active.
Trial state is computed from the workspace's plan-subscription row
(status trialing with a current_period_end in
the future), so there is no scheduler to run — a trial lapses the instant
its end date passes.
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.