# 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). ```sql 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. ```sql 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. ```sql 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). ```sql 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. ```sql 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. ```php $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: ```php $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) ```typescript 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: ```php $configGroups = $plan->configGroups()->with('options.values')->get(); // Pass to Inertia as 'configGroups' prop ``` **showCustom(string $serviceType)** — new method for BYO: ```php $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: ```php // 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