# 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)