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>
15 KiB
15 KiB
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
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
pricecolumn retained as base monthly price (for display/sorting) - Existing
billing_cyclecolumn retained for backward compatibility - New plans use
plan_pricesfor actual billing;plans.price= monthly base price
No Schema Changes Needed
subscriptions.billing_cyclealready supports monthly/quarterly/semi_annual/annualsubscriptions.provisioning_configalready supportsadditional_ipv4countplans.featuresJSON 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): ?PlanPricehelper 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 usesPlanPrice->stripe_price_id
Updated: PlanSeeder
- Create 8 new plans with features 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_pricesper plan - Archive old plans (status → 'archived') but do NOT delete — existing subscriptions still reference them
- Include
migration_maparray mapping old plan slugs to new ones
Updated: SyncStripePrices Command
- Fix
semi_annually→semi_annual: The existing command usessemi_annuallyin two match arms (lines 46, 54). Normalize tosemi_annualto match the rest of the codebase (CheckoutController, StorePlanRequest, frontend). - Iterate
plan_pricesrows instead ofplans - 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
- monthly →
- Store
stripe_price_idon 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_pricesconfig array
Updated: CheckoutController
- Validate
billing_cyclefrom request - Look up
PlanPricefor 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 acceptsbillingCycle. - Services resolve the correct
PlanPriceinternally 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_idinstead of$plan->stripe_price_id- Add IPv4 addon as additional subscription item using Cashier's multi-price API:
$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_idfor the swap. If customer is swapping plans but keeping the same cycle, pull cycle from the existing subscription record.- Calculate
current_period_endbased 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_pricesdata (plans eager-loaded with prices) - Pass selected cycle to checkout URL as query param
- TypeScript interface:
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_pricestable - Trigger Stripe price sync on save
Updated: MarketingController
- Eager-load
plan_priceswhen passing plans to Pricing.vue:$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_idare 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'sPlanPrice, 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.phpapp/Models/PlanPrice.phpapp/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_idfor active subscriptions on old plans - Updates
services.plan_idif 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-runflag to preview changes without applying - Does NOT change Stripe subscriptions (those migrate separately)
Files to Modify
app/Models/Plan.php— add prices() relationship, priceForCycle() helperdatabase/seeders/PlanSeeder.php— new plans + plan_prices + archive oldapp/Console/Commands/SyncStripePrices.php— multi-price sync, fix semi_annually→semi_annualapp/Http/Controllers/Account/CheckoutController.php— cycle-aware checkoutapp/Http/Controllers/Admin/CustomerController.php— fix semi_annually→semi_annual (line 307)app/Services/Billing/StripeBillingService.php— cycle-specific price, IPv4 addon, swapSubscription fixapp/Services/Billing/PayPalBillingService.php— cycle-specific plan (if PayPal used)resources/ts/Pages/Marketing/Pricing.vue— billing cycle toggleresources/ts/Pages/Checkout/Show.vue— cycle selection, price displayresources/ts/Pages/Admin/Plans/Edit.vue— multi-cycle price editingapp/Http/Controllers/Marketing/MarketingController.php— eager-load plan_pricesresources/ts/types/models.ts— add PlanPrice interface, update Plan interfaceapp/Http/Resources/SubscriptionResource.php— include billing_cycle and plan pricingapp/Http/Resources/ServiceResource.php— include plan pricing data
Testing
New Test Files
tests/Feature/PlanPriceTest.php— PlanPrice model, Plan::priceForCycle(), seeder verificationtests/Feature/MultiCycleCheckoutTest.php— checkout with each billing cycle, IPv4 addon billingtests/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)