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

519 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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