Run your workspace
Billing & plans
Billing is Stripe-backed via Laravel Cashier, with PayPal, Razorpay and bank transfer as optional payment methods. Each workspace lives on one plan at a time. This page covers what plans exist, how usage is metered, and what happens when you hit a limit.
Plans
Plans are managed by platform admins (see
Plans & Stripe sync) and
visible to customers at /app/billing. A plan has:
| Field | What it does |
|---|---|
name | Display name (Free, Pro, Enterprise). |
slug | Stable identifier โ never changes after creation, even if the name does. |
monthly_conversations | Quota of new conversations per calendar month. 0 means unlimited. |
monthly_messages | Optional. Per-message quota counted across every visitor turn this calendar month. Leave blank for no extra cap; the conversation count alone gates the workspace. |
max_tokens_per_response | Optional. Caps the LLM's max_tokens for every reply on this plan. Leave blank to use the default of 800. Useful for keeping the free tier short and the paid tiers verbose. |
price_cents | Plan price in cents (charged once per interval). 0 means free / custom (skips gateway sync). |
interval | Billing cadence โ month or year. Defaults to month. Stripe Prices, PayPal billing_cycles, and Razorpay periods all derive from this column. |
features.remove_branding | Hides the "Powered by" footer in the widget. |
Monthly + Yearly variants
To offer an annual discount, create the same plan twice โ one with
interval=month and one with interval=year โ and
set the yearly price below 12ร the monthly. The marketing pricing
page detects both variants and renders a Monthly/Yearly toggle.
Each gateway syncs to its own native cadence:
- Stripe โ
recurring.interval = month|yearon the Price. - PayPal โ
billing_cycles[].frequency.interval_unit = MONTH|YEAR. - Razorpay โ
period = monthly|yearlyon the Plan.
Workspaces still subscribe to one plan row at a time (one
workspaces.plan_id), and switching from monthly to
yearly is a normal plan change โ the gateway either prorates
(Stripe / PayPal) or starts the new cycle at the next billing
boundary depending on workspace setting.
AI rate limits
The two optional cap dials (monthly_messages and
max_tokens_per_response) live under "AI rate limits"
in the plan form. They're enforced at runtime:
-
Every visitor message records a
messagerow inusage_events.MeteredBilling::canSendMessage()sums them for the current calendar month and short-circuits the SSE stream with amessage_quota_exceedederror event when the total hitsmonthly_messages. -
MessageStreamControllerreadsmaxTokensFor()once per turn (cheap โ one row from the workspace's plan, on a request the controller is already loading) and threads it through both the tool-resolution loop and the final streaming call.
Stripe sync
When an admin creates or updates a paid plan, the
StripeProductSync service ensures a matching Stripe Product
+ Price exists. Customers never deal with Stripe directly until checkout
โ they pick a plan in the PoriFyx Chat UI and get sent to Stripe Checkout
via Cashier.
On price changes, the old Stripe Price is archived and a new one is created (Stripe Prices are immutable). Existing subscriptions stay grandfathered on the old price; new subscriptions use the new one. This is the same behavior every Stripe-native SaaS uses.
Subscribing
From /billing, a workspace member with the
billing.manage permission can:
- Pick a plan from the comparison table.
- Get redirected to Stripe Checkout.
- Pay; Stripe redirects back to
/billingwith a success flash. - The Stripe webhook updates the workspace's
plan_id+ creates aplan_subscriptionrow.
Card on file is managed via Stripe's Customer Portal. The
Manage card button on /billing opens it.
Paying by bank transfer
When the platform admin offers bank transfer (set up under
Bank transfers & manual payments),
the workspace owner can pay that way from /app/billing:
- Choose a plan and Bank transfer as the payment method.
- Pick monthly or yearly (when the plan offers both). The dialog shows the exact amount, the bank details and a suggested payment reference.
- Transfer the amount with that reference, then submit the reference (change it if your bank gave you another one) and an optional note, such as the account holder the money comes from.
- The billing page shows Payment pending approval with the reference. Your current plan stays active meanwhile. Only one payment can wait for approval at a time.
- Once the payment is confirmed you get an email, the plan is active until the date shown on the billing page, and the payment appears under Order history. If it can't be confirmed, you get an email with the reason and stay on your current plan.
A bank-transfer period does not renew by itself. A week before it ends you get a reminder; send a new payment before the end date and the new period is added after the current one, so paying early loses no days. Without a new payment, the workspace moves to the Free plan when the period ends โ agents, sources and conversations are kept.
Bank transfer is not available while the workspace still pays through Stripe, PayPal or Razorpay. Cancel that subscription first so you are never charged twice.
Public /pricing matrix parity
The public /pricing comparison table and the in-app
/billing plan cards must surface the same ten feature
rows so prospects don't see a thinner pitch than what customers
get in-product: Published agents, Monthly conversations, AI
messages per month, Workspace members, Workspaces per owner,
Knowledge sources, Workflows, Integrations, API access, Branding
removed. The
tests/Feature/Marketing/PricingMatrixParityTest
regression hard-fails CI if a row drifts off the public matrix.
Quotas
The free plan caps monthly new conversations. Enforcement is on the hot
path โ every /v1/widget/init call asks
MeteredBilling::canStartConversation() whether the
workspace is under its plan limit. If not:
{
"error": {
"code": "plan_limit_reached",
"message": "This workspace has reached its monthly conversation limit. Upgrade to continue."
}
}
Returned as 429. The widget's loader gracefully hides the launcher when it sees this โ visitors don't see a broken state.
What counts as a conversation
Every distinct conversation row counts as 1, fired by
IncrementUsageJob when the conversation's first turn
completes. Playground conversations (is_playground=true)
don't count, so the agent's owners can test freely.
Resumed conversations don't count again โ only the original init bumps the meter.
Branding removal
Plans with features.remove_branding = true hide the
"Powered by PoriFyx Chat" footer in the widget. The Free plan ships with
branding on; paid plans typically off. The Plan model exposes this as
$plan->removesBranding(), called at init time.
Invoices
Stripe sends invoices to the billing email on file. The full history is
available in the Stripe Customer Portal (Manage card โ Invoices). Cashier
also exposes $workspace->invoices() server-side if you
want to render them in-app.
Automatic tax (Stripe Tax)
PoriFyx Chat can let Stripe Tax calculate VAT / sales tax automatically from each customer's billing address. Opt-in, in this order โ flipping the flag first breaks checkout:
- Stripe dashboard first: activate Stripe Tax (Settings โ Tax), set your origin address, and add a tax registration for every country where you're registered (e.g. your local VAT number). Stripe Tax is a paid Stripe feature (billed per transaction where tax is calculated).
- Then enable in PoriFyx Chat:
CASHIER_AUTO_TAX=true # .env (default off)
php artisan config:clear
From that point every new subscription, one-off invoice,
and Stripe Checkout session carries automatic_tax;
Checkout also collects the customer's billing address and offers a
business tax-ID field automatically.
Caveats: never retroactive โ existing active subscriptions keep invoicing without tax until they're individually updated in Stripe. Prices are treated as tax-exclusive by default (tax added on top at checkout); if your customers expect VAT-inclusive prices, set your Stripe prices' tax behavior to "inclusive" in the dashboard before enabling. PayPal / Razorpay flows are unaffected โ Stripe Tax is Stripe-only.
Lifecycle: cancel, resume, swap
The customer-facing controls live on /app/billing:
- Cancel subscription. Stripe schedules a cancel at the end of the current period (you keep access until then). PayPal cancels immediately (PayPal makes CANCELLED a terminal state). Razorpay schedules a cancel at the cycle end.
-
Resume subscription. Only Stripe, and only if the
cancel hasn't yet taken effect (still inside Cashier's
onGracePeriod). PayPal CANCELLED can't be resumed; you subscribe again. Razorpay similarly does not support resume on a cancelled subscription. -
Plan swap (upgrade / downgrade). Stripe does an
in-place swap with proration on the next invoice. PayPal and
Razorpay don't have a clean in-place swap, so clicking another
plan cancels the current subscription and re-enters checkout. The
orphan-cleanup branch of
CheckoutControllermakes sure you're never paying both subscriptions at once.
Plan-change email
When a workspace's plan actually changes, the owner is emailed a
"Your plan was changed to {new plan}" confirmation
(PlanChangedMail). This is necessary because Stripe
sends nothing on a plain swap: a downgrade is a proration
credit (no payment, so no receipt email) and no invoice is
finalized at swap time (so no invoice email) โ and Stripe has no
"plan changed" email at all. So without this, a customer who
downgrades hears nothing.
The email fires from the confirmed source of truth โ the
customer.subscription.updated webhook
(WebhookController::syncWorkspacePlan), the moment the
workspace's plan_id flips to a different plan โ so it
covers in-app swaps, upgrades, downgrades, and changes made directly
in the Stripe Dashboard. It is not sent on the initial
subscribe (customer.subscription.created) or on the many
no-op updated events Stripe fires for renewals, card
updates, and status changes (the plan hasn't changed, so no email).
Delivery is best-effort and queued: a mail failure never fails the
webhook.
Post-checkout reconciliation
The SubscriptionReconciler service is the safety net for
webhook delivery. After a successful checkout the customer redirects
to /app/billing?checkout=success (Stripe also appends
session_id={CHECKOUT_SESSION_ID}) and the controller
pulls live subscription state directly from the gateway, flipping
workspace.plan_id in-band. The page renders the correct
plan even when:
- The Stripe webhook endpoint isn't registered yet in the customer's Stripe dashboard (very common on a fresh install).
- The webhook fires but our endpoint is briefly down / the signature mismatched / it's rejected by an upstream WAF.
- The webhook eventually arrives but takes 30+ seconds, during which the customer reloads the billing page and panics.
The reconciler is idempotent โ safe to call on every page load. The
webhook still does the same job whenever it lands; the two paths
converge on the same row in plan_subscriptions.
Custom plans
Plans with price_cents = 0 aren't free in the customer
sense โ they're local-only, never synced to Stripe, and used
for hand-rolled enterprise deals or for replacing the Free plan. Admins
create them the same way; the Stripe sync simply skips.