Platform admin
One-time add-ons (Pro AI Setup, etc.)
Add-ons are one-time purchasable services that customers buy alongside (or after) a subscription plan. The first concrete add-on is the Professional AI Setup at โฌ499 โ a hands-on service where the team does the heavy lifting on onboarding, knowledge-base setup, AI configuration, and initial optimisation.
Add-ons live in their own table
(plan_addons) next to plans. They share
the global-catalog model: every workspace can see and buy them, and
access to the admin CRUD is gated by the super_admin
middleware (not by workspace scope).
What this card ships
- The
plan_addonsschema + Eloquent model. - Admin CRUD at
/admin/addons(super_admin only). - A seeded but disabled "Professional AI Setup" add-on (โฌ499 EUR). Activate it from the admin once the delivery process is ready on your side.
What this card does NOT ship (yet)
- The signup-time checkout integration (next Phase 2 card โ attaches the addon as a one-time line item to the Stripe Checkout session).
- The in-workspace "Services" page where existing customers can buy add-ons after signup (Phase 2 card after that).
- The customer-facing addon card on the public
/pricingpage (lands with the signup checkout card).
These hang off the same schema, so creating add-ons now is safe even before the buying surfaces ship.
Creating an add-on
- Open
/admin/addonsand click New add-on. - Fill in the display name, price (in major units), currency (EUR / USD / GBP / etc.), and a one-paragraph description.
- Add up to 8 bullet points describing what's included (max 200 chars each). Use the up/down arrows to reorder.
- Set a sort order โ lower numbers render first when the public pricing surface ships.
- Toggle Active only after the delivery process is ready. Inactive add-ons stay in the DB but don't appear to customers.
- Save.
Field reference
| Column | Type | Meaning |
|---|---|---|
slug |
string(64), unique |
URL-safe identifier. Auto-derived from the name on create. Locked on update so external Stripe metadata references stay stable. |
name |
string(120) |
Display name shown on the addon card. |
description |
text, nullable |
One-paragraph pitch shown under the name. |
bullets |
json, nullable |
Array of strings rendered as the included-items list. Max 8 ร 200 chars. |
price_cents |
integer |
Price in minor units of the addon's own currency. |
currency |
string(3) |
ISO 4217 currency code. Defaults to EUR. |
stripe_price_id |
string, nullable |
Cached after first purchase. Empty until a customer actually checks out. Lazy provisioning lands with the next Phase 2 card. |
is_active |
boolean, default false |
Visibility flag. Inactive add-ons stay in the DB but don't appear on any customer-facing surface. |
sort_order |
integer, default 0 |
Lower numbers render first. |
Deactivation behaviour
Deleting an add-on from the admin is a soft delete
โ the row is kept but is_active is flipped to
false. The workspace_addon_purchases
ledger resolves purchases by plan_addon_id; physically
removing the row would orphan that history.
Webhook handling & refunds
Once a customer buys an add-on, the Stripe webhook handler at
POST /billing/webhook drives the row lifecycle:
| Stripe event | Effect on the purchase row |
|---|---|
invoice.payment_succeeded |
Flips pending โ paid and
dispatches the team notification job. Resolves the row
via session id โ subscription id โ invoice id (in that
order) so it works for both signup-bundle and in-app
"Buy Services" 3DS flows. |
invoice.payment_failed |
Flips pending โ failed. Will
not regress a row that's already paid /
delivered / refunded. |
charge.refunded |
Flips a paid or delivered row
to refunded with refunded_at
stamped. Resolves the row via Stripe's
payment_intent first, then invoice id.
Dispatches NotifyBuyerOfAddonRefundJob
which emails the workspace owner via
AddonRefundedMail so they have a written
record of the refund (Stripe's own dashboard email is
operator-side only). |
customer.subscription.created |
For bundled signup checkouts, stamps the row's
stripe_subscription_id from the metadata
so the subsequent invoice.payment_succeeded
can find it even when Stripe omits
checkout_session on the invoice payload. |
Every handler short-circuits on Stripe event-id replay via
WebhookIdempotency โ a duplicate retry returns 200
without re-running the side effects.
Production webhook signature requirement
The Stripe webhook signature is enforced
unconditionally outside local and
testing environments. If a deploy ships with an empty
STRIPE_WEBHOOK_SECRET, the
VerifyWebhookSignature middleware still attaches and
returns 403 on every request โ fail-closed. This is intentional:
Cashier's default gate (skip the middleware when no secret is set)
is the wrong default in production. Set
STRIPE_WEBHOOK_SECRET in your deploy environment
before promoting any release that touches the webhook handler.
See also: Plans & Stripe sync for subscription plans, and the customer-facing Buy services page for the in-workspace purchase flow.