R RockAI docs

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).

ColumnNotes
NameDisplay name. Editable.
SlugStable identifier. Locked after creation โ€” workspaces.plan_id resolves by slug indirectly through the Plan table, and changing it would break invoices.
Monthly conversationsQuota.
PriceMonthly price. Changing it archives the old Stripe Price + creates a new one.
WorkspacesHow many workspaces are on this plan today.
Stripe IDsProduct + Price IDs after sync. Free / custom plans show "โ€”".
ActiveToggle. 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).
FieldWhat it capsHow it's counted
agents_limitAgents per workspaceAgent::where('workspace_id', X) โ€” every non-trashed agent.
sources_limitKnowledge sources across all agents in the workspaceSum of Source rows whose agent is owned by the workspace.
workflows_limitWorkflows per workspaceWorkflow::where('workspace_id', X)
integrations_limitSlack/etc connections + outbound webhook subscriptionsIntegrationConnection + WebhookSubscription rows, summed.
members_limitSeats per workspaceAccepted workspace_users rows + non-expired pending invitations.
api_accessWhether workspaces on this plan can mint API tokensCheckbox. 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_id is 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.