B Blengi docs

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_addons schema + 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 /pricing page (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

  1. Open /admin/addons and click New add-on.
  2. Fill in the display name, price (in major units), currency (EUR / USD / GBP / etc.), and a one-paragraph description.
  3. Add up to 8 bullet points describing what's included (max 200 chars each). Use the up/down arrows to reorder.
  4. Set a sort order โ€” lower numbers render first when the public pricing surface ships.
  5. Toggle Active only after the delivery process is ready. Inactive add-ons stay in the DB but don't appear to customers.
  6. Save.

Field reference

ColumnTypeMeaning
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 eventEffect 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.