Plan add-ons
Optional extras customers stack on top of their base plan — extra storage, premium template packs, dedicated sending IP, priority support, AI usage boosts. Each add-on has its own pricing (one-time or recurring) and a free-form JSON payload that the consuming feature reads at runtime.
Create an add-on
- Admin → Add-ons → New add-on.
- Identity — name (customer-facing), slug (machine readable), description, category (storage / templates / sending / support / other), icon (lucide name).
- Pricing — amount in cents, currency, billing interval (one-time / monthly / yearly), trial days for recurring.
- Feature payload — JSON dictionary. Any key prefixed
bonus_<quota_key>is added on top of the buyer's plan quota byCustomer::quota(); boolean bonuses OR onto the plan flag. Examples that work end-to-end today:
Non-bonus keys (like{ "bonus_max_subscribers": 5000 } { "bonus_max_sends_per_month": 50000, "bonus_max_sends_per_day": 5000 } { "bonus_max_ai_calls_per_month": 1000 } { "bonus_max_lead_enrichments_per_month": 500 } { "bonus_allow_custom_tracking_domain": true }template_packorsupport_tier) are stored for reference; the specific consuming feature reads them. - Visibility — Active (shown to customers) + Featured (accent ring on the customer page) + sort order.
documentation/screenshots/admin-addons.png/admin/addons — cards with price, category, active subs counter.
Suggested catalogue
| Add-on | Category | Pricing | Payload |
|---|---|---|---|
| Extra 50 GB storage | storage | $5/mo | { "extra_storage_gb": 50 } |
| Premium templates pack | templates | $29 one-time | { "template_pack": "premium-2026" } |
| Dedicated sending IP | sending | $80/mo | { "dedicated_ip": true } |
| Priority support | support | $50/mo | { "priority_tier": "p1" } |
| AI tokens — 1M extra | other | $10/mo | { "ai_extra_tokens_per_month": 1000000 } |
Customer marketplace (new in v1.2.1)
Buyers see every active add-on at /addons on their sidebar (Account group).
Each card shows the price, the bonus summary rendered from the payload
(+5,000 subscribers, +50,000 sends/month), and a Get add-on
button. Featured add-ons carry an accent ring.
Purchase flow:
- Customer clicks Get add-on. A
Paymentrow is created withmetadata.kind = addon_purchase. On manual-payment installs, the customer sees "Request sent to admin — you'll be contacted for payment." - Admin approves the payment in Admin → Manual payments (existing surface). The
approve action detects
kind = addon_purchaseand auto-creates aCustomerAddon(status=active)withstarts_at = now()andends_atderived from the add-on's billing interval (month → +30d, year → +365d, one-time → null). - On the customer's next request,
Customer::quota()folds the add-on's payload into the effective quota. Numericbonus_*keys stack; boolean flags OR onto the plan flag. - Customer can cancel any active add-on with the Cancel button on their
/addonspage. Cancelling flips status to cancelled and stampsends_at = now(); the bonus applies until the current period ends per billing-interval semantics.
How customer-side enforcement works
Each consuming feature checks the customer's active add-on payload before allowing the action. Pseudocode:
$active = CustomerAddon::query()
->where('customer_id', $customer->id)
->where('status', 'active')
->with('addon')
->get();
$bonusStorage = $active->sum(fn ($a) => $a->addon->payload['extra_storage_gb'] ?? 0);
$storageLimit = ($customer->quota('max_storage_mb') ?? 0) + ($bonusStorage * 1024);
Architecture
Two tables:
plan_addons— catalogue. Admin CRUD.customer_addons— purchase records. Status (active / cancelled / expired) + linked gateway + payment_id for audit.
Provider-agnostic schema — same architecture supports future GitHub Marketplace / Apple In-App Purchase / etc. without table changes.
customer_addons rows manually (via tinker or future admin "Grant add-on" button) to test the enforcement path.