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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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