Files
website/docs/superpowers/specs/2026-03-14-vps-pricing-overhaul-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

307 lines
15 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.
# 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)