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). - Marketing bullets — up to 8 short USP lines (≤80 chars each) rendered under the plan name on the public
/pricingcard. Top-to-bottom order matches what visitors see. Empty list hides the bullet section. Stored as the typedmarketing_bulletsJSON column; the controller falls back to legacyfeatures.bulletsfor rows that pre-date the typed column. - Features — toggles stored as JSON on the plan:
remove_branding— hides the "Powered by" widget footer.highlight— "Mark as Most popular". Renders the green Most popular badge on this plan's card on the public/pricingpage. Toggle it on whichever plan you want to push; turn it off on the others so only one is highlighted.
- 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). |
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, disabling & re-enabling
Plans are never destructively deleted. The
trash action in the plans table (destroy)
soft-deletes (is_active = false) and archives the plan on
every enabled gateway. 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.
An inactive plan disappears from the public /pricing page and
from checkout, but its row stays in the table. To re-enable
it, click the green Activate (power) button that replaces
the trash icon on inactive rows — one click flips is_active
back on and re-syncs the plan onto every enabled gateway (paid plans only;
free / Calendly plans skip the gateway step). Editing the plan and toggling
Active back on does the same thing.
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
ledger: the latest trialing window (signup trial or
an admin grant) with a current_period_end in the future.
There is no scheduler to run — a trial lapses the instant its end date
passes. Owners are warned by email + in-app notification
before access stops (default 7 days, configurable via
PLAN_ENDING_SOON_DAYS): running trials, admin grants, and
subscriptions cancelled at period close all get the reminder;
auto-renewing subscriptions don't — Stripe already emails their renewal
invoices. A second notification fires once after expiry. The wall only
fires when no purchase has taken the trial's
place: any active or past_due subscription row,
or a pending pay-by-invoice subscription (unpaid — the bank
transfer is on its way), keeps the workspace open. A canceled or
abandoned checkout row does not. Pending invoices stay honest both
ways: when Stripe voids the invoice (manually or after dunning), the
webhook flips the pending row to canceled — and the billing page
double-checks the invoice against Stripe before showing the
unpaid-invoice banner, self-healing the row if the invoice is gone.
Pricing-card button label
Each plan's call-to-action button on the public /pricing
page is labelled to match that plan's next step, automatically:
- Book a call — Calendly plans
(
cta_type = calendly, e.g. Managed). - Start trial — trial plans (the Free trial plan toggle is on).
- Get started — regular paid plans.
- Start free — free plans (price 0).
To override the wording for a single plan, set a cta_label
string in that plan's features JSON — a custom value always
wins over the type default. Every label is translated through the
interface catalog, so a Dutch visitor sees the localised button
(e.g. Start proefperiode, Aan de slag).
Calendly plans inside the dashboard
The consultation-first rule follows the plan into the customer
dashboard. On /app/billing a cta_type = calendly
plan shows a Book a call button (opening the plan's
calendly_url) instead of a checkout button, with a note that
the plan is activated after a short consultation. This is enforced
server-side too: direct card checkout, pay-by-invoice checkout, and
in-place plan swaps all reject a Calendly plan with a friendly error —
the plan can only be provisioned by your team after the call. Workspaces
already on such a plan keep their normal manage / cancel controls.
Currency
The global default lives in the Currency field on
/settings/system (Stripe card) or
CASHIER_CURRENCY in the environment. Defaults to USD.
Per-plan, the + Add currency control on the plan form adds
price overrides per ISO code — the first checkout in each currency
lazily mints the matching Stripe Price.
Which currency a visitor sees, in priority order: explicit
?currency= toggle → the workspace's previous checkout
currency → country (Cloudflare geo header) → the operator default
(when it isn't USD) → browser-language guess → USD. Setting the
default to eur therefore makes EUR the house currency
for everyone the geo signal doesn't place elsewhere — and EUR
sessions are what unlock iDEAL / SEPA at Stripe Checkout.
You do not need a per-currency override on every plan for
the house currency to show. The public /pricing page lists the
operator default first and falls back to each plan's base amount
(price_cents) for it, so an install whose default is
eur renders € on the existing numbers immediately —
it never snaps back to $. Add a + Add currency
override only when a tier should cost a different amount in another
currency (and to localise the figure for genuinely multi-currency installs).
Repricing is self-healing: Stripe Prices are immutable, so when you change a plan's amount for some currency (or an add-on's price / currency), the cached Stripe Price is detected as stale on the next checkout and a fresh one is minted automatically. Existing subscriptions stay grandfathered on the price they signed up with — Stripe cannot change a live subscription's currency.
Switching the whole install to one currency
To flip an install to a single house currency in one shot — set the default, give every paid plan an amount in that currency, and mint the Stripe prices — use the command instead of editing each plan by hand:
php artisan billing:set-currency eur --make-default
# preview first, no writes
php artisan billing:set-currency eur --make-default --dry-run
# convert amounts by an FX rate instead of keeping the number
php artisan billing:set-currency eur --rate=0.92 --make-default
# also re-currency add-ons
php artisan billing:set-currency eur --make-default --addons
For each active paid plan it writes
prices[<currency>] (the USD base × --rate,
default 1.0 = same number, e.g. 49 → €49),
mints the Stripe Price, and with --make-default sets the
cashier_currency app-setting so display + checkout +
invoices default to it. An amount already set for that currency is
kept unless you pass --force. --dry-run
prints the table without writing. It never edits .env
and clears the config cache for you on a real run.
Why --make-default also touches workspaces.
A workspace's own preferred_currency outranks the
operator default in CurrencyResolver — so an existing
workspace (the operator's own, say) would keep showing the old
currency on /app/billing even after the default flips,
while the logged-out marketing page already shows the new one. To
make the switch consistent everywhere, --make-default
moves every workspace onto the new currency too. Genuinely
multi-currency installs that want per-workspace choices preserved
pass --keep-workspace-prefs.