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>
20 KiB
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:
- Preset plan add-ons — Customers customize orders with dropdowns, radios, quantities (RAM tiers, drive bays, management level)
- 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 listradio— VRadioGroup, single choice from values listquantity— VTextField (number), uses per-unit pricing from option rowcheckbox— VCheckbox, on/off, price from single value rowtext— 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 SoftDeleteshasMany(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 builderAdmin/ConfigGroups/Edit.vue— Edit form, same structure as create
Admin Controller: ConfigGroupController
index()— paginated list with option/plan countscreate()— form with available plansstore(StoreConfigGroupRequest)— create group + options + values in transactionedit(PlanConfigGroup)— form pre-populatedupdate(UpdateConfigGroupRequest, PlanConfigGroup)— sync options/valuesdestroy(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 optionsmode: '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_recordstable 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:
- Use a designated BYO package in VirtFusion as a base template (handles network profile, storage profile, etc.)
- Override resource params from slider selections using
provisioning_keymapping:cpuCores← option whereprovisioning_key = 'cpu_cores'memory← option whereprovisioning_key = 'ram_gb'× 1024 (MB)storage← option whereprovisioning_key = 'disk_gb'
- The
provisioning_keyfield onplan_config_optionsmakes 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