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>
This commit is contained in:
Claude Dev
2026-03-16 11:39:25 -04:00
parent 5be235d35e
commit b4ef90465c
187 changed files with 27317 additions and 1840 deletions

View File

@@ -0,0 +1,306 @@
# VPS Pricing Overhaul — Design Spec
## Overview
Replace the current flat pricing model (one price per plan, monthly only) with a multi-cycle pricing system (1/3/6/12 months), updated VPS plan tiers reflecting actual infrastructure costs ($1,500/mo datacenter), hardware generation (Xeon E5-2680 v2/v4), unmetered bandwidth, and tiered disk I/O limits. Includes migration path for all existing customers and VirtFusion package deprecation.
## Context
### Infrastructure
- 3 hypervisor nodes in Atlanta (E5-2680 v2/v4, 1,158 GB RAM, NFS-backed SATA SSD storage)
- Monthly infrastructure cost: $1,500
- 82 VMs total, ~14 are EZSCALE internal, ~68 customer
- RAM is 89% allocated — the binding constraint
- Storage is NFS-backed SATA SSD (not local NVMe)
### Problems with current pricing
- Plans named inconsistently (Nano/Micro/Mini/Standard/Plus/Pro vs VirtFusion's Micro/Mini/Basic/Standard/Advanced/Pro)
- Only monthly billing — no commitment incentives, higher churn
- Prices too low for actual cost basis ($3.50 Nano loses money at $22/VM cost)
- Stripe only gets one price per plan — multi-cycle discounts calculated client-side only
- IPv4 addon ($3/mo) not charged through Stripe
- Bandwidth caps listed but offering unmetered (AUP)
## New Plan Lineup
### VPS Plans
| Plan Slug | vCPU | RAM | SSD | Bandwidth | IOPS R/W | MB/s R/W |
|-----------|------|-----|-----|-----------|----------|----------|
| vps-1 | 1 | 1 GB | 25 GB | Unmetered | 2,500/2,500 | 40/40 |
| vps-2 | 1 | 2 GB | 50 GB | Unmetered | 3,000/3,000 | 50/50 |
| vps-4 | 2 | 4 GB | 80 GB | Unmetered | 4,000/4,000 | 75/75 |
| vps-8 | 4 | 8 GB | 160 GB | Unmetered | 5,000/5,000 | 100/100 |
| vps-16 | 6 | 16 GB | 320 GB | Unmetered | 6,000/6,000 | 150/150 |
| vps-32 | 8 | 32 GB | 640 GB | Unmetered | 8,000/8,000 | 200/200 |
### Storage VPS Plans
| Plan Slug | vCPU | RAM | SSD | Bandwidth | IOPS R/W | MB/s R/W |
|-----------|------|-----|-----|-----------|----------|----------|
| stor-500 | 2 | 2 GB | 500 GB | Unmetered | 3,000/3,000 | 75/75 |
| stor-1tb | 2 | 4 GB | 1 TB | Unmetered | 4,000/4,000 | 100/100 |
### Pricing (USD)
| Plan | 1-mo | 3-mo (5% off) | 6-mo (10% off) | 12-mo (15% off) |
|------|------|---------------|----------------|-----------------|
| VPS-1 | $5.00 | $14.25 | $27.00 | $51.00 |
| VPS-2 | $8.00 | $22.80 | $43.20 | $81.60 |
| VPS-4 | $15.00 | $42.75 | $81.00 | $153.00 |
| VPS-8 | $30.00 | $85.50 | $162.00 | $306.00 |
| VPS-16 | $55.00 | $156.75 | $297.00 | $561.00 |
| VPS-32 | $99.00 | $282.15 | $534.60 | $1,009.80 |
| STOR-500 | $18.00 | $51.30 | $97.20 | $183.60 |
| STOR-1TB | $28.00 | $79.80 | $151.20 | $285.60 |
### Addons
- Extra IPv4: $3.00/mo per address (billed per cycle with same discount tier)
- /64 IPv6: included with every VPS
### I/O Limits
- Applied as internal VirtFusion package settings only
- NOT advertised on pricing page
- Scale with tier to prevent single-customer abuse of shared NFS storage
## Customer Migration Map
### Package → Plan Mapping
| Old Package (VirtFusion ID) | Old Specs | New Plan | New Specs | Spec Changes |
|-----------------------------|-----------|----------|-----------|--------------|
| Micro (19, 32) | 1 vCPU, 1 GB, 25 GB, 500 GB BW | VPS-1 | 1 vCPU, 1 GB, 25 GB, Unmetered | BW upgrade |
| Mini (20) | 1 vCPU, 2 GB, 50 GB, 4 TB BW | VPS-2 | 1 vCPU, 2 GB, 50 GB, Unmetered | BW upgrade |
| Basic (21) | 2 vCPU, 4 GB, 80 GB, 6 TB BW | VPS-4 | 2 vCPU, 4 GB, 80 GB, Unmetered | BW upgrade |
| Standard (22) | 4 vCPU, 8 GB, 160 GB, 8 TB BW | VPS-8 | 4 vCPU, 8 GB, 160 GB, Unmetered | BW upgrade |
| Advanced (23) | 6 vCPU, 16 GB, 320 GB, 10 TB BW | VPS-16 | 6 vCPU, 16 GB, 320 GB, Unmetered | BW upgrade |
| Pro (24) | 8 vCPU, 32 GB, 640 GB, 16 TB BW | VPS-32 | 8 vCPU, 32 GB, 640 GB, Unmetered | BW upgrade |
| Dev Starter (40) | 2 vCPU, 2 GB, 60 GB, 4 TB BW | VPS-4 | 2 vCPU, 4 GB, 80 GB, Unmetered | RAM +2 GB, disk +20 GB |
| Storage Box (41) | 2 vCPU, 2 GB, 500 GB, 8 TB BW | STOR-500 | 2 vCPU, 2 GB, 500 GB, Unmetered | BW upgrade |
| RAM Optimized (42) | 4 vCPU, 16 GB, 240 GB, 10 TB BW | VPS-16 | 6 vCPU, 16 GB, 320 GB, Unmetered | CPU +2, disk +80 GB |
| VPS-3-Custom (11) | 4 vCPU, 8 GB, 60 GB, 4 TB BW | VPS-8 | 4 vCPU, 8 GB, 160 GB, Unmetered | Disk +100 GB |
| Base Package (43) | 2 vCPU, 1 GB, 10 GB, 200 GB BW | VPS-1 | 1 vCPU, 1 GB, 25 GB, Unmetered | CPU -1, disk +15 GB |
### Deprecation Actions
- All old packages set to status 'archived' in the plans table
- Old VirtFusion packages disabled (not deleted, for audit trail)
- New VirtFusion packages created with new I/O limits
- Existing VMs migrated to new packages in VirtFusion (package swap, no rebuild)
### Special Case: Base Package (ID 43)
Loses 1 vCPU (2→1). Check if any customer on this package needs 2 cores. If so, migrate to VPS-4 instead of VPS-1.
## Database Changes
### New Table: `plan_prices`
```sql
CREATE TABLE plan_prices (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
plan_id BIGINT UNSIGNED NOT NULL,
billing_cycle ENUM('monthly', 'quarterly', 'semi_annual', 'annual') NOT NULL,
price DECIMAL(10,2) NOT NULL,
stripe_price_id VARCHAR(255) NULL,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
FOREIGN KEY (plan_id) REFERENCES plans(id) ON DELETE CASCADE,
UNIQUE(plan_id, billing_cycle)
);
```
### Plans Table Changes
- Existing `price` column retained as base monthly price (for display/sorting)
- Existing `billing_cycle` column retained for backward compatibility
- New plans use `plan_prices` for actual billing; `plans.price` = monthly base price
### No Schema Changes Needed
- `subscriptions.billing_cycle` already supports monthly/quarterly/semi_annual/annual
- `subscriptions.provisioning_config` already supports `additional_ipv4` count
- `plans.features` JSON already stores arbitrary plan metadata
## Application Changes
### New: PlanPrice Model
- `belongsTo(Plan)`
- `$fillable = ['plan_id', 'billing_cycle', 'price', 'stripe_price_id']`
- `$casts = ['price' => 'decimal:2']`
### Updated: Plan Model
- Add `prices(): HasMany(PlanPrice)` relationship
- Add `priceForCycle(string $cycle): ?PlanPrice` helper method (queries plan_prices for the given cycle)
- Deprecate direct use of `plans.stripe_price_id` — keep column for backward compat but all new code uses `PlanPrice->stripe_price_id`
### Updated: PlanSeeder
- Create 8 new plans with features JSON:
```json
{
"vcpu": "2",
"ram": "4 GB",
"storage": "80 GB SSD",
"bandwidth": "Unmetered",
"ipv4": "1 Included",
"ipv6": "/64 Included",
"iops_read": 4000,
"iops_write": 4000,
"mbps_read": 75,
"mbps_write": 75,
"virtfusion_package_id": null
}
```
- Create 4 `plan_prices` per plan
- Archive old plans (status → 'archived') but do NOT delete — existing subscriptions still reference them
- Include `migration_map` array mapping old plan slugs to new ones
### Updated: SyncStripePrices Command
- **Fix `semi_annually` → `semi_annual`:** The existing command uses `semi_annually` in two match arms (lines 46, 54). Normalize to `semi_annual` to match the rest of the codebase (CheckoutController, StorePlanRequest, frontend).
- Iterate `plan_prices` rows instead of `plans`
- Create one Stripe Product per Plan
- Create one Stripe Price per PlanPrice row:
- monthly → `interval: 'month', interval_count: 1`
- quarterly → `interval: 'month', interval_count: 3`
- semi_annual → `interval: 'month', interval_count: 6`
- annual → `interval: 'year', interval_count: 1`
- Store `stripe_price_id` on each PlanPrice row
- Create a separate Stripe Product for "Additional IPv4 Address"
- Create 4 Stripe Prices for IPv4 addon (one per cycle, with discount baked in):
- monthly: $3.00/mo
- quarterly: $8.55/3mo ($3 × 3 × 0.95)
- semi_annual: $16.20/6mo ($3 × 6 × 0.90)
- annual: $30.60/yr ($3 × 12 × 0.85)
- Store IPv4 addon Stripe price IDs in config or a dedicated `addon_prices` config array
### Updated: CheckoutController
- Validate `billing_cycle` from request
- Look up `PlanPrice` for selected plan + cycle
- Pass billing cycle to billing service (service resolves price internally)
- Handle IPv4 addon quantity from `configuration.additional_ipv4`
### Updated: BillingServiceInterface
- **No signature change.** The existing `createSubscription(User, Plan, ?paymentMethodId, ?couponCode, billingCycle)` signature already accepts `billingCycle`.
- Services resolve the correct `PlanPrice` internally via `$plan->priceForCycle($billingCycle)->stripe_price_id`
- This keeps the interface stable and avoids breaking PayPal.
### Updated: StripeBillingService
- `createSubscription()`: Use `$plan->priceForCycle($billingCycle)->stripe_price_id` instead of `$plan->stripe_price_id`
- Add IPv4 addon as additional subscription item using Cashier's multi-price API:
```php
$subscription = $user->newSubscription($plan->slug)
->price($planPrice->stripe_price_id)
->price($ipv4PriceId, $additionalIpv4Count) // only if count > 0
->create($paymentMethodId);
```
- `swapSubscription()`: Must also resolve cycle-specific price. Accept billing cycle parameter, use `$newPlan->priceForCycle($billingCycle)->stripe_price_id` for the swap. If customer is swapping plans but keeping the same cycle, pull cycle from the existing subscription record.
- Calculate `current_period_end` based on billing cycle
### Updated: Pricing.vue (Marketing)
- Add billing cycle toggle: Monthly | Quarterly | Semi-Annual | Annual
- Show savings percentage on non-monthly cycles
- Display cycle-specific price from `plan_prices` data (plans eager-loaded with prices)
- Pass selected cycle to checkout URL as query param
- TypeScript interface:
```typescript
interface PlanPrice {
id: number
plan_id: number
billing_cycle: 'monthly' | 'quarterly' | 'semi_annual' | 'annual'
price: string
}
```
- Plan interface updated: `prices: PlanPrice[]`
### Updated: Checkout/Show.vue
- Pre-select billing cycle from URL query param (or default monthly)
- Show cycle-specific price from `plan_prices`
- IPv4 addon total uses cycle-specific IPv4 Stripe price (discount already baked in)
- Display breakdown: plan price + IPv4 addon = total
### Updated: Admin Plans Edit
- Add price fields for all 4 billing cycles
- Save to `plan_prices` table
- Trigger Stripe price sync on save
### Updated: MarketingController
- Eager-load `plan_prices` when passing plans to Pricing.vue:
```php
$plans = Plan::where('status', 'active')
->where('service_type', 'vps')
->with('prices')
->orderBy('sort_order')
->get();
```
## Existing Subscription Handling
### Archived plans remain functional
- Archived plans are hidden from new purchases (Pricing.vue, Checkout) via `status = 'active'` filter
- Archived plans are NOT filtered from subscription lookups, dashboard display, or webhook handlers
- Customer dashboard shows the plan name from whatever plan_id is on their subscription — works for both active and archived plans
- Webhook handlers that look up subscriptions by `stripe_subscription_id` are unaffected (they don't filter by plan status)
### Stripe subscription migration timing
- Existing Stripe subscriptions continue on their current Stripe price until manually migrated
- Migration happens per-customer: either at renewal, or via an admin bulk action
- No automatic migration — this prevents surprise billing changes
### Plan swaps with archived plans
- If a customer on an archived plan wants to upgrade, the swap creates a new subscription on the new plan/cycle price
- `swapSubscription()` always uses the target plan's `PlanPrice`, never the source plan's
## Billing Cycle Naming Fix
**Existing inconsistency:** `SyncStripePrices.php` (lines 46, 54) and `CustomerController.php` (line 307) use `semi_annually`. All other code uses `semi_annual`. Standardize to `semi_annual` everywhere as part of this work.
## Files to Create
- `database/migrations/XXXX_create_plan_prices_table.php`
- `app/Models/PlanPrice.php`
- `app/Console/Commands/MigrateVpsPlans.php` — artisan command to migrate existing subscriptions/services from old plan IDs to new plan IDs
### MigrateVpsPlans Command
- Maps old plan IDs → new plan IDs using the migration map
- Updates `subscriptions.plan_id` for active subscriptions on old plans
- Updates `services.plan_id` if services reference plans
- Logs all changes (old_plan_id → new_plan_id, subscription_id, user_id)
- Idempotent: skips subscriptions already on new plans
- Dry-run mode: `--dry-run` flag to preview changes without applying
- Does NOT change Stripe subscriptions (those migrate separately)
## Files to Modify
- `app/Models/Plan.php` — add prices() relationship, priceForCycle() helper
- `database/seeders/PlanSeeder.php` — new plans + plan_prices + archive old
- `app/Console/Commands/SyncStripePrices.php` — multi-price sync, fix semi_annually→semi_annual
- `app/Http/Controllers/Account/CheckoutController.php` — cycle-aware checkout
- `app/Http/Controllers/Admin/CustomerController.php` — fix semi_annually→semi_annual (line 307)
- `app/Services/Billing/StripeBillingService.php` — cycle-specific price, IPv4 addon, swapSubscription fix
- `app/Services/Billing/PayPalBillingService.php` — cycle-specific plan (if PayPal used)
- `resources/ts/Pages/Marketing/Pricing.vue` — billing cycle toggle
- `resources/ts/Pages/Checkout/Show.vue` — cycle selection, price display
- `resources/ts/Pages/Admin/Plans/Edit.vue` — multi-cycle price editing
- `app/Http/Controllers/Marketing/MarketingController.php` — eager-load plan_prices
- `resources/ts/types/models.ts` — add PlanPrice interface, update Plan interface
- `app/Http/Resources/SubscriptionResource.php` — include billing_cycle and plan pricing
- `app/Http/Resources/ServiceResource.php` — include plan pricing data
## Testing
### New Test Files
- `tests/Feature/PlanPriceTest.php` — PlanPrice model, Plan::priceForCycle(), seeder verification
- `tests/Feature/MultiCycleCheckoutTest.php` — checkout with each billing cycle, IPv4 addon billing
- `tests/Feature/MigrateVpsPlansTest.php` — migration command dry-run and execution
### Test Cases
- PlanPrice model: CRUD, unique constraint on (plan_id, billing_cycle), cascade delete
- Plan::priceForCycle(): returns correct price for each cycle, returns null for invalid cycle
- PlanSeeder: creates all 8 plans with 4 prices each (32 plan_prices rows)
- Checkout: monthly/quarterly/semi_annual/annual each use correct stripe_price_id
- Checkout: IPv4 addon adds correct Stripe line item with quantity
- Checkout: invalid billing cycle returns validation error
- SyncStripePrices: creates 4 Stripe prices per plan, stores IDs
- MigrateVpsPlans: maps old plans to new, skips already-migrated, dry-run works
- swapSubscription: uses cycle-specific price from target plan
## Out of Scope
- VirtFusion API package creation (manual via VirtFusion admin panel)
- Bulk Stripe subscription price migration (per-customer at renewal or future admin tool)
- PayPal multi-cycle support (existing limitation, separate effort)
- Customer communication/email about pricing changes
- Mid-subscription IPv4 quantity changes (add/remove IPs after checkout — future feature)

View File

@@ -0,0 +1,518 @@
# 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