B Blengi 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).
  • Marketing bullets — up to 8 short USP lines (≤80 chars each) rendered under the plan name on the public /pricing card. Top-to-bottom order matches what visitors see. Empty list hides the bullet section. Stored as the typed marketing_bullets JSON column; the controller falls back to legacy features.bullets for 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 /pricing page. 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).
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. The /app/agents Knowledge column renders this as used/limit (or just used when the plan is unlimited).
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, 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_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.

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.