Files
website/docs/superpowers/specs/2026-03-16-configurable-options-build-your-own-design.md
Claude Dev b4ef90465c feat: complete pre-launch audit — frontend polish, churn prevention, login history, financial reports, configurable checkout
Includes all work from phases 6-9+ and frontend polish rounds 1 & 2:

- Login history with device trust, new device notifications, session management
- Churn prevention: cancellation surveys, winback campaigns with email sequences
- Financial reports: revenue, P&L, tax, aging, refund, subscription reports with PDF/CSV/JSON export
- Configurable checkout: plan config groups/options, build-your-own VPS
- Frontend polish: fix broken legal links, add SEO meta tags, favicon, font display=swap,
  Head titles on all 14 marketing pages, mobile responsive fixes, AuthLayout legal footer,
  remove false 24/7 claims, hide empty stats, correct uptime SLA to 99.9%,
  GameServers notify buttons linked to /contact, 301 redirects for /terms and /privacy
- WHMCS migration scripts
- Update legal page effective dates to March 16, 2026

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 11:39:25 -04:00

20 KiB
Raw Blame History

Configurable Options & Build Your Own System

Date: 2026-03-16 Status: Approved Author: Claude + Andrew

Overview

Add a configurable options system that serves two purposes:

  1. Preset plan add-ons — Customers customize orders with dropdowns, radios, quantities (RAM tiers, drive bays, management level)
  2. Build Your Own — Customers build custom specs from scratch using sliders with per-unit pricing

Both use the same database schema. Build Your Own is a special mode of configurable option groups where options are slider-type with per-unit pricing.

Service Type Support

Service Type Preset Config Options Build Your Own Hourly Billing
VPS Yes (IPv4, Windows, Management, Block Storage) Yes (CPU/RAM/Disk sliders) Yes (with monthly cap)
Dedicated Yes (RAM, Drive Bays, NVMe, Network, RAID, Management) No No
Hosting No No No
MySQL Yes (Region) Yes (Storage/Connections sliders) Yes (with monthly cap)
Game No (existing plans) Yes (RAM/Disk/Slots sliders) Yes (with monthly cap)
Backups Yes (qty-based: VMs, Agents, Storage) No No

Database Schema

plan_config_groups

Stores groups of related configurable options. Groups can be preset (attached to specific plans) or build-your-own (attached to a service type).

plan_config_groups
  id bigint unsigned PK auto_increment
  name varchar(255) NOT NULL
  description text NULL
  mode varchar(20) NOT NULL DEFAULT 'preset'  -- 'preset' | 'build_your_own'
  service_type varchar(50) NULL               -- only for BYO groups
  is_active bool NOT NULL DEFAULT true        -- enables soft-disabling without deletion
  sort_order int NOT NULL DEFAULT 0
  created_at timestamp NULL
  updated_at timestamp NULL
  deleted_at timestamp NULL                   -- SoftDeletes for archiving

plan_config_group_plan (pivot)

Many-to-many pivot between config groups and plans. Only used for preset groups.

plan_config_group_plan
  plan_config_group_id bigint unsigned FK CASCADE
  plan_id bigint unsigned FK CASCADE
  UNIQUE(plan_config_group_id, plan_id)

plan_config_options

Individual configurable options within a group.

plan_config_options
  id bigint unsigned PK auto_increment
  group_id bigint unsigned FK CASCADE
  name varchar(255) NOT NULL
  description text NULL
  type varchar(50) NOT NULL                  -- dropdown, radio, quantity, checkbox, text, slider
  provisioning_key varchar(100) NULL         -- machine-readable key: cpu_cores, ram_gb, disk_gb, etc.
  required bool NOT NULL DEFAULT false
  is_active bool NOT NULL DEFAULT true       -- enables soft-disabling individual options
  min_qty int NULL                           -- for quantity/slider types
  max_qty int NULL                           -- for quantity/slider types
  step int NULL DEFAULT 1                    -- slider step increment
  unit_label varchar(50) NULL                -- "cores", "GB", "slots", "addresses"
  hourly_price decimal(10,4) NULL            -- per-unit hourly price (slider/qty)
  monthly_price decimal(10,2) NULL           -- per-unit monthly price (slider/qty)
  quarterly_price decimal(10,2) NULL
  semi_annual_price decimal(10,2) NULL
  annual_price decimal(10,2) NULL
  sort_order int NOT NULL DEFAULT 0
  created_at timestamp NULL
  updated_at timestamp NULL

Type behavior:

  • dropdown — VSelect, single choice from values list
  • radio — VRadioGroup, single choice from values list
  • quantity — VTextField (number), uses per-unit pricing from option row
  • checkbox — VCheckbox, on/off, price from single value row
  • text — VTextField, no pricing (e.g., hostname)
  • slider — VSlider, uses per-unit pricing from option row, respects min/max/step

plan_config_values

Discrete choices for dropdown/radio/checkbox options. Not used for slider/quantity/text types (those use the per-unit price on the option itself).

plan_config_values
  id bigint unsigned PK auto_increment
  option_id bigint unsigned FK CASCADE
  label varchar(255) NOT NULL
  value varchar(255) NULL                    -- internal/raw value
  hourly_price decimal(10,4) NOT NULL DEFAULT 0
  monthly_price decimal(10,2) NOT NULL DEFAULT 0
  quarterly_price decimal(10,2) NOT NULL DEFAULT 0
  semi_annual_price decimal(10,2) NOT NULL DEFAULT 0
  annual_price decimal(10,2) NOT NULL DEFAULT 0
  is_default bool NOT NULL DEFAULT false
  sort_order int NOT NULL DEFAULT 0
  created_at timestamp NULL
  updated_at timestamp NULL

subscription_config_selections

What the customer selected, with prices locked at time of purchase.

subscription_config_selections
  id bigint unsigned PK auto_increment
  subscription_id bigint unsigned FK CASCADE
  option_id bigint unsigned FK RESTRICT      -- RESTRICT: cannot delete option with existing selections
  value_id bigint unsigned FK SET NULL NULL   -- SET NULL: if value deleted, selection preserved
  quantity int NULL                           -- for quantity/slider
  text_value varchar(500) NULL               -- for text type
  locked_price decimal(10,4) NOT NULL        -- total price for this selection (4 decimals for hourly precision)
  locked_hourly_price decimal(10,4) NULL
  billing_cycle varchar(50) NOT NULL
  is_custom_build bool NOT NULL DEFAULT false
  created_at timestamp NULL
  updated_at timestamp NULL
  UNIQUE(subscription_id, option_id)

New plans (base $0, internal status for BYO checkout)

BYO custom plans use status: 'internal' — a new status value that:

  • Is treated as available for checkout (bypasses isAvailable() check)
  • Is excluded from public plan listings (pricing page, marketing pages)
  • Is distinct from 'hidden' (which is for grandfathered legacy plans)

The Plan::isAvailable() method is updated: return in_array($this->status, ['active', 'internal']);

  • vps-custom (service_type: vps, status: internal)
  • mysql-custom (service_type: mysql, status: internal)
  • game-custom (service_type: game, status: internal)

Stripe Billing Strategy

Preset Plans with Config Options

Base plan has a pre-existing Stripe price. Config add-on total is computed at checkout and a single dynamic Stripe price is created via Stripe\Price::create() for the total amount (base + add-ons). This ensures the Stripe invoice reflects the actual amount charged.

$totalMonthly = $plan->priceForCycle($cycle) + $configTotal;
$stripePrice = \Stripe\Price::create([
    'unit_amount' => (int) ($totalMonthly * 100),
    'currency' => 'usd',
    'recurring' => ['interval' => $stripeInterval],
    'product' => $plan->stripe_product_id,
]);

Build Your Own

Same approach — total computed from slider values, single dynamic Stripe price created. The {type}-custom plan has a Stripe product but no pre-set price.

Price Changes / Upgrades (Future)

When a customer upgrades config options on an existing subscription, a new Stripe price is created and the subscription is swapped to it. Deferred to v2.

Coupon Application

Coupons apply to the total computed price (base plan + all config selections), not just the base price. For BYO where base is $0, the coupon applies to the config total. The coupon discount is applied before creating the Stripe price:

$totalBeforeCoupon = $basePlanPrice + $configTotal;
$discount = $coupon->calculateDiscount($totalBeforeCoupon);
$finalPrice = $totalBeforeCoupon - $discount;

Models

PlanConfigGroup

  • use SoftDeletes
  • hasMany(PlanConfigOption::class, 'group_id')
  • belongsToMany(Plan::class, 'plan_config_group_plan')
  • Scopes: preset(), buildYourOwn(), forServiceType($type), active()

PlanConfigOption

  • belongsTo(PlanConfigGroup::class, 'group_id')
  • hasMany(PlanConfigValue::class, 'option_id')
  • Scope: active()
  • Methods: isSlider(), isQuantity(), isDropdown(), isRadio(), isCheckbox(), isText()
  • Method: calculatePrice(int $quantity, string $cycle): float
  • Method: getHourlyPrice(int $quantity): float

PlanConfigValue

  • belongsTo(PlanConfigOption::class, 'option_id')
  • Method: getPriceForCycle(string $cycle): float

SubscriptionConfigSelection

  • belongsTo(Subscription::class)
  • belongsTo(PlanConfigOption::class, 'option_id')
  • belongsTo(PlanConfigValue::class, 'value_id')

Plan (updated)

  • Add: configGroups(): BelongsToMany
  • Update: isAvailable() returns true for 'active' and 'internal' status
  • Update: scopePublic() scope excludes 'internal' and 'hidden' plans from public listings

Subscription (updated)

  • Add: configSelections(): HasMany
  • Add: totalConfigPrice(string $cycle): float
  • Add: totalHourlyPrice(): float

TypeScript Interfaces (types/index.ts)

export interface PlanConfigGroup {
  id: number
  name: string
  description: string | null
  mode: 'preset' | 'build_your_own'
  service_type: string | null
  is_active: boolean
  sort_order: number
  options: PlanConfigOption[]
  plans?: Plan[]
}

export interface PlanConfigOption {
  id: number
  group_id: number
  name: string
  description: string | null
  type: 'dropdown' | 'radio' | 'quantity' | 'checkbox' | 'text' | 'slider'
  provisioning_key: string | null
  required: boolean
  is_active: boolean
  min_qty: number | null
  max_qty: number | null
  step: number | null
  unit_label: string | null
  hourly_price: number | null
  monthly_price: number | null
  quarterly_price: number | null
  semi_annual_price: number | null
  annual_price: number | null
  sort_order: number
  values: PlanConfigValue[]
}

export interface PlanConfigValue {
  id: number
  option_id: number
  label: string
  value: string | null
  hourly_price: number
  monthly_price: number
  quarterly_price: number
  semi_annual_price: number
  annual_price: number
  is_default: boolean
  sort_order: number
}

export interface SubscriptionConfigSelection {
  id: number
  subscription_id: number
  option_id: number
  value_id: number | null
  quantity: number | null
  text_value: string | null
  locked_price: number
  locked_hourly_price: number | null
  billing_cycle: string
  is_custom_build: boolean
  option?: PlanConfigOption
  value?: PlanConfigValue
}

Admin UI

Pages

  • Admin/ConfigGroups/Index.vue — List all groups with VDataTable, filter tabs (All/Preset/BYO)
  • Admin/ConfigGroups/Create.vue — Create form with inline options + values builder
  • Admin/ConfigGroups/Edit.vue — Edit form, same structure as create

Admin Controller: ConfigGroupController

  • index() — paginated list with option/plan counts
  • create() — form with available plans
  • store(StoreConfigGroupRequest) — create group + options + values in transaction
  • edit(PlanConfigGroup) — form pre-populated
  • update(UpdateConfigGroupRequest, PlanConfigGroup) — sync options/values
  • destroy(PlanConfigGroup) — delete (soft-block if selections exist)

Form Request: StoreConfigGroupRequest

Validates nested structure:

  • name, description, mode, service_type
  • options[].name, options[].type, options[].required, options[].min_qty, options[].max_qty, options[].step
  • options[].values[].label, options[].values[].monthly_price, etc.

Admin Navigation

Under Infrastructure: { title: 'Config Options', to: '/config-groups', icon: 'tabler-adjustments' }

Admin Routes

GET    /config-groups              → ConfigGroupController@index
GET    /config-groups/create       → ConfigGroupController@create
POST   /config-groups              → ConfigGroupController@store
GET    /config-groups/{id}/edit    → ConfigGroupController@edit
PUT    /config-groups/{id}         → ConfigGroupController@update
DELETE /config-groups/{id}         → ConfigGroupController@destroy

Checkout Flow

Path 1: Preset Plan (enhanced existing flow)

Pricing Page → Pick Plan → /checkout/{plan_id}?cycle=monthly

Checkout/Show.vue enhanced:

  • After billing cycle selection, loads configurable option groups for the plan
  • Renders options by type (VSelect, VRadioGroup, VSlider, VCheckbox, VTextField)
  • Price summary updates live: base + config selections
  • Existing VPS provisioning config (OS, SSH key) remains separate

Path 2: Build Your Own (new)

Pricing Page → Toggle "Build Your Own" → Configure sliders → Deploy Now
→ /checkout/custom/{serviceType}?config={encoded_selections}

New route: GET /checkout/custom/{serviceType} renders Checkout/Show.vue in custom mode.

Checkout/Show.vue Modes

  • mode: 'preset' — plan card + optional config options
  • mode: 'custom' — slider configurator as main content, no plan card

Price Summary Sidebar (both modes)

Order Summary
─────────────
Base plan:       $30.00   (or $0 for BYO)
RAM: 64 GB       +$15.00
NVMe: 2x 1TB    +$30.00
Management: Semi +$25.00
──────────────────────
Monthly:         $100.00
Hourly:      $0.137/hr
Monthly cap:     $100.00

Billing: [Monthly ▼]
[ Deploy Now ]

CheckoutController Changes

show(Plan $plan) — enhanced to load config groups:

$configGroups = $plan->configGroups()->with('options.values')->get();
// Pass to Inertia as 'configGroups' prop

showCustom(string $serviceType) — new method for BYO:

$configGroup = PlanConfigGroup::buildYourOwn()->forServiceType($serviceType)->first();
$plan = Plan::where('slug', "{$serviceType}-custom")->firstOrFail();
// Render same checkout component in 'custom' mode

store(Request $request, Plan $plan) — enhanced to validate and save selections:

// Validate config selections against option types/ranges
// Calculate and lock prices at time of purchase
// Create subscription
// Create subscription_config_selections rows
// Dispatch SubscriptionCreated event

Validation Rules for Config Selections

  • Dropdown/radio: value_id must belong to the option
  • Quantity/slider: quantity must be between min_qty and max_qty, divisible by step
  • Checkbox: value_id present (checked) or null (unchecked)
  • Text: string, max 500 chars
  • Required options must have a selection

Pricing Page Changes

Marketing/Pricing.vue enhanced:

  • Segmented toggle at top: Preset Plans | Build Your Own
  • Service type tabs work in both modes
  • In BYO mode: Only tabs for supported service types are shown (VPS, MySQL, Game). If user is on a non-BYO tab (e.g., Dedicated) and toggles to BYO, auto-switch to the first supported tab (VPS).
  • Preset mode: existing plan cards (unchanged)
  • Build Your Own mode: vertical slider configurator (Layout A — sliders left, price summary right)
  • Billing cycle switcher works in both modes (hourly shown only in BYO mode)
  • "Deploy Now" in BYO routes to /checkout/custom/{serviceType}

Build Your Own Configurator UI

Layout A: Vertical Sliders + Side Summary

Left panel (sliders):

  • Each resource as a labeled VSlider with current value display
  • Min/max labels at slider ends
  • Per-unit price shown next to current value
  • Sliders use EZSCALE navy blue (#1d4ed8) accent

Right panel (sticky summary):

  • Hourly rate (large)
  • Monthly cap
  • Line-item breakdown (CPU: $X, RAM: $X, Disk: $X)
  • Billing cycle selector
  • "Deploy Now" CTA button

Build Your Own Resource Configuration

VPS

Resource Min Max Step Unit Hourly/unit Monthly/unit
CPU Cores 1 16 1 cores $0.003 $2.00
RAM 1 64 1 GB $0.0015 $1.00
SSD Storage 25 1000 25 GB $0.0001 $0.05
Bandwidth Included

MySQL

Resource Min Max Step Unit Hourly/unit Monthly/unit
Storage 5 500 5 GB $0.0003 $0.20
Max Connections 50 1000 50 conns $0.0001 $0.05
Daily Backups 0 1 1 toggle $2.00

Game Servers

Resource Min Max Step Unit Hourly/unit Monthly/unit
RAM 1 16 1 GB $0.002 $1.50
Storage 10 200 10 GB $0.0001 $0.08
Player Slots 10 200 10 slots $0.0001 $0.05

Note: Per-unit prices are initial values. Adjusted via admin UI without code changes.

Hourly Billing

Display Model (MVP)

  • Hourly rate calculated and displayed to customer: sum(unit_hourly_price × quantity)
  • Monthly cap calculated: sum(unit_monthly_price × quantity)
  • Customer billed at monthly cap on their billing cycle
  • Actual hourly metering deferred to Phase 2

Future: Real Hourly Metering (Phase 2)

  • usage_records table tracks server uptime hours
  • Cron job calculates charges: min(hourly_rate × hours, monthly_cap)
  • Prepaid credit system for hourly customers
  • Server auto-suspend when credits depleted

Provisioning Integration

Preset Plans

Existing provisioning flow unchanged. Config selections stored but provisioning uses the plan's package_id.

Build Your Own VPS

VirtFusion's servers_create API accepts raw resource parameters alongside a packageId. For BYO:

  1. Use a designated BYO package in VirtFusion as a base template (handles network profile, storage profile, etc.)
  2. Override resource params from slider selections using provisioning_key mapping:
    • cpuCores ← option where provisioning_key = 'cpu_cores'
    • memory ← option where provisioning_key = 'ram_gb' × 1024 (MB)
    • storage ← option where provisioning_key = 'disk_gb'
  3. The provisioning_key field on plan_config_options makes this mapping explicit and admin-configurable

Note: If VirtFusion requires a package for server creation, create a minimal "BYO Base" package in VirtFusion with minimum resources and override via the API params. Test this approach before implementation — if VirtFusion doesn't support resource overrides on servers_create, fall back to creating dynamic packages per BYO order via the packages API.

Build Your Own MySQL

Manual provisioning for MVP. The subscription is created with config selections stored, and an admin notification is sent for manual setup. Automated provisioning deferred until a MySQL provisioning API is available.

Build Your Own Game

Pterodactyl API supports resource allocation on server creation (memory, disk, cpu). Map from provisioning_key values on the slider options, similar to VPS approach.

Migration Script Integration

Phase 2 Enhancement

Import WHMCS configurable option groups, options, and values:

  • Map WHMCS config group IDs to EZSCALE config group IDs
  • Create plan_config_groups with mode='preset'
  • Attach to mapped plans via pivot
  • Import all options and values with pricing

Phase 3 Enhancement

Import customer config selections:

  • For each WHMCS service with configoptions, create subscription_config_selections
  • Lock prices from WHMCS recurring amounts

Testing

Unit Tests

  • PlanConfigOption::calculatePrice() with various types
  • PlanConfigOption::getHourlyPrice() calculation
  • Subscription::totalConfigPrice() aggregation
  • Subscription::totalHourlyPrice() aggregation

Feature Tests

  • Admin CRUD for config groups (create/read/update/delete)
  • Nested option/value creation and validation
  • Preset checkout with config options selected
  • BYO checkout with slider values
  • Price calculation matches expected totals
  • Config selections locked at purchase price
  • Required option validation
  • Slider min/max/step validation
  • Customer cannot access admin config pages

Out of Scope (Future)

  • Real-time hourly billing with usage metering (Phase 2)
  • Config option dependencies (e.g., NVMe requires RAID H730)
  • Config option stock/inventory limits
  • Upgrade/downgrade config options on existing subscriptions
  • Config option comparison across plans