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

45 KiB

VPS Pricing Overhaul Implementation Plan

For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Replace flat monthly-only VPS pricing with multi-cycle billing (1/3/6/12 months), new plan tiers, tiered I/O limits, and IPv4 addon billing through Stripe.

Architecture: New plan_prices table stores per-cycle prices with individual Stripe price IDs. Services resolve the correct PlanPrice internally via Plan::priceForCycle(). IPv4 addon uses Cashier's multi-price subscription API. Old plans archived, not deleted.

Tech Stack: Laravel 12, Pest 4, Stripe/Cashier v16, Vue 3 + Inertia v2 + TypeScript + Vuetify 3

Spec: docs/superpowers/specs/2026-03-14-vps-pricing-overhaul-design.md


File Structure

Files to Create

  • website/database/migrations/2026_03_14_000001_create_plan_prices_table.php — plan_prices schema
  • website/app/Models/PlanPrice.php — PlanPrice model
  • website/database/factories/PlanPriceFactory.php — PlanPrice factory
  • website/app/Console/Commands/MigrateVpsPlans.php — customer migration command
  • website/tests/Feature/PlanPriceTest.php — PlanPrice + seeder tests
  • website/tests/Feature/MultiCycleCheckoutTest.php — checkout with billing cycles
  • website/tests/Feature/MigrateVpsPlansTest.php — migration command tests

Files to Modify

  • website/app/Models/Plan.php — add prices() relationship, priceForCycle() helper
  • website/database/seeders/PlanSeeder.php — new VPS plans + plan_prices rows
  • website/app/Console/Commands/SyncStripePrices.php — multi-price sync, fix semi_annually
  • website/app/Services/Billing/StripeBillingService.php — cycle-specific price, swap fix, IPv4 addon
  • website/app/Services/Billing/BillingServiceInterface.php — add $billingCycle to swapSubscription, $configuration to createSubscription
  • website/app/Http/Controllers/Account/CheckoutController.php — cycle-aware checkout, pass config to billing service
  • website/app/Http/Controllers/Admin/CustomerController.php — fix semi_annuallysemi_annual, annuallyannual
  • website/resources/ts/Pages/Admin/Customers/Show.vue — fix semi_annually/annually naming
  • website/resources/ts/types/index.ts — add PlanPrice interface, update Plan interface
  • website/app/Http/Controllers/Admin/CustomerController.php — fix semi_annually (line 307)
  • website/app/Http/Controllers/Marketing/MarketingController.php — eager-load plan prices
  • website/resources/ts/Pages/Marketing/Pricing.vue — billing cycle toggle
  • website/resources/ts/Pages/Checkout/Show.vue — cycle selection, price display
  • website/app/Http/Resources/ServiceResource.php — include pricing data
  • website/app/Http/Resources/SubscriptionResource.php — include cycle/pricing

Chunk 1: Database & Models

Task 1: Create plan_prices migration

Files:

  • Create: website/database/migrations/2026_03_14_000001_create_plan_prices_table.php

  • Step 1: Create migration

Run: cd website && php artisan make:migration create_plan_prices_table --no-interaction

  • Step 2: Write migration schema
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('plan_prices', function (Blueprint $table) {
            $table->id();
            $table->foreignId('plan_id')->constrained()->cascadeOnDelete();
            $table->string('billing_cycle'); // monthly, quarterly, semi_annual, annual
            $table->decimal('price', 10, 2);
            $table->string('stripe_price_id')->nullable();
            $table->timestamps();

            $table->unique(['plan_id', 'billing_cycle']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('plan_prices');
    }
};
  • Step 3: Run migration

Run: cd website && php artisan migrate Expected: Migration runs successfully, plan_prices table created.

  • Step 4: Commit
cd website && git add database/migrations/*create_plan_prices_table* && git commit -m "feat: add plan_prices migration for multi-cycle billing"

Task 2: Create PlanPrice model

Files:

  • Create: website/app/Models/PlanPrice.php

  • Step 1: Create model

Run: cd website && php artisan make:model PlanPrice --no-interaction

  • Step 2: Write PlanPrice model
<?php

declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class PlanPrice extends Model
{
    protected $fillable = [
        'plan_id',
        'billing_cycle',
        'price',
        'stripe_price_id',
    ];

    protected function casts(): array
    {
        return [
            'price' => 'decimal:2',
        ];
    }

    public function plan(): BelongsTo
    {
        return $this->belongsTo(Plan::class);
    }
}
  • Step 3: Commit
cd website && git add app/Models/PlanPrice.php && git commit -m "feat: add PlanPrice model"

Task 3: Update Plan model with prices relationship

Files:

  • Modify: website/app/Models/Plan.php

  • Step 1: Write failing test

Create website/tests/Feature/PlanPriceTest.php:

<?php

declare(strict_types=1);

use App\Models\Plan;
use App\Models\PlanPrice;

it('has prices relationship', function () {
    $plan = Plan::factory()->create();

    PlanPrice::create([
        'plan_id' => $plan->id,
        'billing_cycle' => 'monthly',
        'price' => 5.00,
    ]);

    PlanPrice::create([
        'plan_id' => $plan->id,
        'billing_cycle' => 'annual',
        'price' => 51.00,
    ]);

    expect($plan->prices)->toHaveCount(2);
    expect($plan->prices->first())->toBeInstanceOf(PlanPrice::class);
});

it('resolves price for cycle', function () {
    $plan = Plan::factory()->create();

    PlanPrice::create([
        'plan_id' => $plan->id,
        'billing_cycle' => 'monthly',
        'price' => 5.00,
    ]);

    PlanPrice::create([
        'plan_id' => $plan->id,
        'billing_cycle' => 'quarterly',
        'price' => 14.25,
    ]);

    $monthly = $plan->priceForCycle('monthly');
    expect($monthly)->not->toBeNull();
    expect((float) $monthly->price)->toBe(5.00);

    $quarterly = $plan->priceForCycle('quarterly');
    expect((float) $quarterly->price)->toBe(14.25);

    $invalid = $plan->priceForCycle('weekly');
    expect($invalid)->toBeNull();
});

it('enforces unique plan_id and billing_cycle', function () {
    $plan = Plan::factory()->create();

    PlanPrice::create([
        'plan_id' => $plan->id,
        'billing_cycle' => 'monthly',
        'price' => 5.00,
    ]);

    PlanPrice::create([
        'plan_id' => $plan->id,
        'billing_cycle' => 'monthly',
        'price' => 6.00,
    ]);
})->throws(\Illuminate\Database\QueryException::class);

it('cascade deletes plan prices when plan is deleted', function () {
    $plan = Plan::factory()->create();

    PlanPrice::create([
        'plan_id' => $plan->id,
        'billing_cycle' => 'monthly',
        'price' => 5.00,
    ]);

    PlanPrice::create([
        'plan_id' => $plan->id,
        'billing_cycle' => 'annual',
        'price' => 51.00,
    ]);

    expect(PlanPrice::where('plan_id', $plan->id)->count())->toBe(2);

    $plan->delete();

    expect(PlanPrice::where('plan_id', $plan->id)->count())->toBe(0);
});
  • Step 2: Run tests to verify they fail

Run: cd website && php artisan test --compact --filter=PlanPriceTest Expected: FAIL — priceForCycle method does not exist, prices relationship missing.

  • Step 3: Add relationship and helper to Plan model

In website/app/Models/Plan.php, add the import and methods:

Add import at top:

use App\Models\PlanPrice;

Add after the services() method (after line 50):

    public function prices(): HasMany
    {
        return $this->hasMany(PlanPrice::class);
    }

    public function priceForCycle(string $cycle): ?PlanPrice
    {
        return $this->prices()->where('billing_cycle', $cycle)->first();
    }
  • Step 4: Run tests to verify they pass

Run: cd website && php artisan test --compact --filter=PlanPriceTest Expected: All 4 tests PASS.

  • Step 5: Run Pint

Run: cd website && vendor/bin/pint --dirty --format agent

  • Step 6: Commit
cd website && git add app/Models/Plan.php tests/Feature/PlanPriceTest.php && git commit -m "feat: add Plan prices relationship and priceForCycle helper"

Chunk 2: Plan Seeder & Data

Task 4: Update PlanSeeder with new VPS plans and prices

Files:

  • Modify: website/database/seeders/PlanSeeder.php

  • Step 1: Replace VPS plans in PlanSeeder

Replace the VPS plan section (lines 23-200) and the archive logic (lines 14-21) with new plans. Keep dedicated, hosting, game, and mysql plans unchanged.

The archive logic at the top should now archive the OLD slugs:

        // Archive old VPS plans (replaced by new pricing tiers)
        Plan::query()
            ->where('service_type', 'vps')
            ->whereNotIn('slug', [
                'vps-1', 'vps-2', 'vps-4', 'vps-8', 'vps-16', 'vps-32',
                'stor-500', 'stor-1tb',
            ])
            ->update(['status' => 'archived']);

Replace the 8 old VPS plan arrays with:

            // ─── VPS Plans (2026 SSD Lineup) ──────────────────────────────
            [
                'name' => 'VPS-1',
                'slug' => 'vps-1',
                'description' => 'Entry-level VPS for development, bots, and lightweight applications.',
                'service_type' => 'vps',
                'price' => 5.00,
                'billing_cycle' => 'monthly',
                'features' => [
                    'cpu' => '1 vCPU',
                    'ram' => '1 GB',
                    'storage' => '25 GB SSD',
                    'bandwidth' => 'Unmetered',
                    'ipv4' => '1 Included',
                    'ipv6' => '/64 Included',
                    'control_panel' => 'VirtFusion',
                    'os' => 'Linux & Windows (BYOL)',
                    'virtfusion_package_id' => 19,
                ],
                'sort_order' => 1,
            ],
            [
                'name' => 'VPS-2',
                'slug' => 'vps-2',
                'description' => 'Small VPS for WordPress, web apps, and personal projects.',
                'service_type' => 'vps',
                'price' => 8.00,
                'billing_cycle' => 'monthly',
                'features' => [
                    'cpu' => '1 vCPU',
                    'ram' => '2 GB',
                    'storage' => '50 GB SSD',
                    'bandwidth' => 'Unmetered',
                    'ipv4' => '1 Included',
                    'ipv6' => '/64 Included',
                    'control_panel' => 'VirtFusion',
                    'os' => 'Linux & Windows (BYOL)',
                    'virtfusion_package_id' => 20,
                ],
                'sort_order' => 2,
            ],
            [
                'name' => 'VPS-4',
                'slug' => 'vps-4',
                'description' => 'Production VPS for web apps, databases, and CI runners.',
                'service_type' => 'vps',
                'price' => 15.00,
                'billing_cycle' => 'monthly',
                'features' => [
                    'cpu' => '2 vCPU',
                    'ram' => '4 GB',
                    'storage' => '80 GB SSD',
                    'bandwidth' => 'Unmetered',
                    'ipv4' => '1 Included',
                    'ipv6' => '/64 Included',
                    'control_panel' => 'VirtFusion',
                    'os' => 'Linux & Windows (BYOL)',
                    'virtfusion_package_id' => 21,
                ],
                'sort_order' => 3,
            ],
            [
                'name' => 'VPS-8',
                'slug' => 'vps-8',
                'description' => 'High-performance VPS for production workloads and multi-service stacks.',
                'service_type' => 'vps',
                'price' => 30.00,
                'billing_cycle' => 'monthly',
                'features' => [
                    'cpu' => '4 vCPU',
                    'ram' => '8 GB',
                    'storage' => '160 GB SSD',
                    'bandwidth' => 'Unmetered',
                    'ipv4' => '1 Included',
                    'ipv6' => '/64 Included',
                    'control_panel' => 'VirtFusion',
                    'os' => 'Linux & Windows (BYOL)',
                    'virtfusion_package_id' => 22,
                ],
                'sort_order' => 4,
            ],
            [
                'name' => 'VPS-16',
                'slug' => 'vps-16',
                'description' => 'Enterprise VPS for large databases, analytics, and heavy workloads.',
                'service_type' => 'vps',
                'price' => 55.00,
                'billing_cycle' => 'monthly',
                'features' => [
                    'cpu' => '6 vCPU',
                    'ram' => '16 GB',
                    'storage' => '320 GB SSD',
                    'bandwidth' => 'Unmetered',
                    'ipv4' => '1 Included',
                    'ipv6' => '/64 Included',
                    'control_panel' => 'VirtFusion',
                    'os' => 'Linux & Windows (BYOL)',
                    'virtfusion_package_id' => 23,
                ],
                'sort_order' => 5,
            ],
            [
                'name' => 'VPS-32',
                'slug' => 'vps-32',
                'description' => 'Maximum power VPS for enterprise applications and large-scale services.',
                'service_type' => 'vps',
                'price' => 99.00,
                'billing_cycle' => 'monthly',
                'features' => [
                    'cpu' => '8 vCPU',
                    'ram' => '32 GB',
                    'storage' => '640 GB SSD',
                    'bandwidth' => 'Unmetered',
                    'ipv4' => '1 Included',
                    'ipv6' => '/64 Included',
                    'control_panel' => 'VirtFusion',
                    'os' => 'Linux & Windows (BYOL)',
                    'virtfusion_package_id' => 24,
                ],
                'sort_order' => 6,
            ],
            [
                'name' => 'Storage 500',
                'slug' => 'stor-500',
                'description' => 'Storage-optimized VPS for backups, media, and file servers.',
                'service_type' => 'vps',
                'price' => 18.00,
                'billing_cycle' => 'monthly',
                'features' => [
                    'cpu' => '2 vCPU',
                    'ram' => '2 GB',
                    'storage' => '500 GB SSD',
                    'bandwidth' => 'Unmetered',
                    'ipv4' => '1 Included',
                    'ipv6' => '/64 Included',
                    'control_panel' => 'VirtFusion',
                    'os' => 'Linux & Windows (BYOL)',
                    'virtfusion_package_id' => 41,
                ],
                'sort_order' => 7,
            ],
            [
                'name' => 'Storage 1TB',
                'slug' => 'stor-1tb',
                'description' => 'High-capacity storage VPS for large-scale file storage and databases.',
                'service_type' => 'vps',
                'price' => 28.00,
                'billing_cycle' => 'monthly',
                'features' => [
                    'cpu' => '2 vCPU',
                    'ram' => '4 GB',
                    'storage' => '1 TB SSD',
                    'bandwidth' => 'Unmetered',
                    'ipv4' => '1 Included',
                    'ipv6' => '/64 Included',
                    'control_panel' => 'VirtFusion',
                    'os' => 'Linux & Windows (BYOL)',
                    'virtfusion_package_id' => 41,
                ],
                'sort_order' => 8,
            ],
  • Step 2: Add plan_prices seeding after the plan loop

After the existing foreach ($plans as $plan) loop (which creates plans via updateOrCreate), add pricing data. Insert this after the closing } of the foreach loop, before the method's closing }:

        // ─── Seed plan_prices for VPS plans ─────────────────────────────
        $vpsPricing = [
            'vps-1'    => ['monthly' => 5.00,  'quarterly' => 14.25,  'semi_annual' => 27.00,  'annual' => 51.00],
            'vps-2'    => ['monthly' => 8.00,  'quarterly' => 22.80,  'semi_annual' => 43.20,  'annual' => 81.60],
            'vps-4'    => ['monthly' => 15.00, 'quarterly' => 42.75,  'semi_annual' => 81.00,  'annual' => 153.00],
            'vps-8'    => ['monthly' => 30.00, 'quarterly' => 85.50,  'semi_annual' => 162.00, 'annual' => 306.00],
            'vps-16'   => ['monthly' => 55.00, 'quarterly' => 156.75, 'semi_annual' => 297.00, 'annual' => 561.00],
            'vps-32'   => ['monthly' => 99.00, 'quarterly' => 282.15, 'semi_annual' => 534.60, 'annual' => 1009.80],
            'stor-500' => ['monthly' => 18.00, 'quarterly' => 51.30,  'semi_annual' => 97.20,  'annual' => 183.60],
            'stor-1tb' => ['monthly' => 28.00, 'quarterly' => 79.80,  'semi_annual' => 151.20, 'annual' => 285.60],
        ];

        foreach ($vpsPricing as $slug => $prices) {
            $plan = Plan::where('slug', $slug)->first();
            if (! $plan) {
                continue;
            }

            foreach ($prices as $cycle => $price) {
                \App\Models\PlanPrice::updateOrCreate(
                    ['plan_id' => $plan->id, 'billing_cycle' => $cycle],
                    ['price' => $price],
                );
            }
        }
  • Step 3: Write seeder verification test

Add to website/tests/Feature/PlanPriceTest.php:

it('seeds all VPS plans with 4 price cycles each', function () {
    $this->seed(\Database\Seeders\PlanSeeder::class);

    $vpsSlugs = ['vps-1', 'vps-2', 'vps-4', 'vps-8', 'vps-16', 'vps-32', 'stor-500', 'stor-1tb'];

    foreach ($vpsSlugs as $slug) {
        $plan = \App\Models\Plan::where('slug', $slug)->first();
        expect($plan)->not->toBeNull("Plan {$slug} should exist");
        expect($plan->status)->toBe('active');
        expect($plan->prices)->toHaveCount(4, "Plan {$slug} should have 4 price cycles");

        $cycles = $plan->prices->pluck('billing_cycle')->sort()->values()->toArray();
        expect($cycles)->toBe(['annual', 'monthly', 'quarterly', 'semi_annual']);
    }
});

it('archives old VPS plan slugs', function () {
    // Create an old plan first
    \App\Models\Plan::factory()->create(['slug' => 'vps-nano', 'service_type' => 'vps', 'status' => 'active']);

    $this->seed(\Database\Seeders\PlanSeeder::class);

    $oldPlan = \App\Models\Plan::where('slug', 'vps-nano')->first();
    expect($oldPlan->status)->toBe('archived');
});

it('sets correct monthly base prices', function () {
    $this->seed(\Database\Seeders\PlanSeeder::class);

    $expectedPrices = [
        'vps-1' => 5.00,
        'vps-2' => 8.00,
        'vps-4' => 15.00,
        'vps-8' => 30.00,
        'vps-16' => 55.00,
        'vps-32' => 99.00,
        'stor-500' => 18.00,
        'stor-1tb' => 28.00,
    ];

    foreach ($expectedPrices as $slug => $expectedPrice) {
        $plan = \App\Models\Plan::where('slug', $slug)->first();
        expect((float) $plan->price)->toBe($expectedPrice, "Plan {$slug} monthly price mismatch");
        expect((float) $plan->priceForCycle('monthly')->price)->toBe($expectedPrice);
    }
});
  • Step 4: Run seeder tests

Run: cd website && php artisan test --compact --filter=PlanPriceTest Expected: All 7 tests PASS.

  • Step 5: Run the seeder on dev database

Run: cd website && php artisan db:seed --class=PlanSeeder Expected: 8 new VPS plans created, old plans archived, 32 plan_prices rows created.

  • Step 6: Run Pint and commit
cd website && vendor/bin/pint --dirty --format agent
git add database/seeders/PlanSeeder.php tests/Feature/PlanPriceTest.php && git commit -m "feat: seed new VPS plans with multi-cycle pricing"

Chunk 3: Stripe Sync & Billing Service Updates

Task 5: Update SyncStripePrices for multi-price plans

Files:

  • Modify: website/app/Console/Commands/SyncStripePrices.php

  • Step 1: Rewrite SyncStripePrices command

Replace the entire handle() method. The new version:

  • Creates one Stripe Product per Plan
  • Creates one Stripe Price per PlanPrice row (4 prices per plan)
  • Stores stripe_price_id on each PlanPrice row
  • Stores stripe_product_id on the Plan
  • Fixes semi_annually → uses PlanPrice billing_cycle directly
    public function handle(): int
    {
        Stripe::setApiKey(config('cashier.secret'));

        $plans = Plan::where('status', 'active')->with('prices')->get();

        $this->info("Syncing {$plans->count()} plans with Stripe...");

        $progressBar = $this->output->createProgressBar($plans->count());
        $progressBar->start();

        foreach ($plans as $plan) {
            try {
                // Create Stripe product (one per plan)
                $product = $plan->stripe_product_id && ! $this->option('force')
                    ? Product::retrieve($plan->stripe_product_id)
                    : Product::create([
                        'name' => $plan->name,
                        'description' => "EZSCALE {$plan->service_type} - {$plan->name}",
                        'metadata' => [
                            'plan_id' => $plan->id,
                            'plan_slug' => $plan->slug,
                        ],
                    ]);

                $plan->update(['stripe_product_id' => $product->id]);

                // Create one Stripe Price per billing cycle
                foreach ($plan->prices as $planPrice) {
                    if ($planPrice->stripe_price_id && ! $this->option('force')) {
                        continue;
                    }

                    $interval = match ($planPrice->billing_cycle) {
                        'monthly' => 'month',
                        'quarterly' => 'month',
                        'semi_annual' => 'month',
                        'annual' => 'year',
                        default => 'month',
                    };

                    $intervalCount = match ($planPrice->billing_cycle) {
                        'monthly' => 1,
                        'quarterly' => 3,
                        'semi_annual' => 6,
                        'annual' => 1,
                        default => 1,
                    };

                    $price = Price::create([
                        'product' => $product->id,
                        'currency' => 'usd',
                        'unit_amount' => (int) round($planPrice->price * 100),
                        'recurring' => [
                            'interval' => $interval,
                            'interval_count' => $intervalCount,
                        ],
                        'metadata' => [
                            'plan_id' => $plan->id,
                            'plan_slug' => $plan->slug,
                            'billing_cycle' => $planPrice->billing_cycle,
                        ],
                    ]);

                    $planPrice->update(['stripe_price_id' => $price->id]);
                }

                // Also update legacy plan.stripe_price_id with the monthly price
                $monthlyPrice = $plan->priceForCycle('monthly');
                if ($monthlyPrice?->stripe_price_id) {
                    $plan->update(['stripe_price_id' => $monthlyPrice->stripe_price_id]);
                }

                $progressBar->advance();
            } catch (\Exception $e) {
                $this->newLine();
                $this->error("Failed to sync {$plan->name}: {$e->getMessage()}");
                $progressBar->advance();
            }
        }

        $progressBar->finish();
        $this->newLine(2);
        $this->info('Stripe prices synced successfully!');

        return self::SUCCESS;
    }
  • Step 2: Run Pint and commit
cd website && vendor/bin/pint --dirty --format agent
git add app/Console/Commands/SyncStripePrices.php && git commit -m "feat: update SyncStripePrices for multi-cycle plan pricing"

Task 6: Update StripeBillingService for cycle-specific pricing

Files:

  • Modify: website/app/Services/Billing/StripeBillingService.php

  • Step 1: Update createSubscription to use PlanPrice

In createSubscription() (line 27), change:

$subscription = $user->newSubscription($plan->slug, $plan->stripe_price_id);

to:

        $planPrice = $plan->priceForCycle($billingCycle);
        $stripePriceId = $planPrice?->stripe_price_id ?? $plan->stripe_price_id;

        $subscription = $user->newSubscription($plan->slug, $stripePriceId);

Also update line 46 (gateway_price_id):

'gateway_price_id' => $stripePriceId,
  • Step 2: Update swapSubscription to accept billing cycle

Change the swapSubscription method signature and body. Replace lines 95-122:

    public function swapSubscription(User $user, string $subscriptionId, Plan $newPlan, string $billingCycle = 'monthly'): array
    {
        $subscription = $user->subscriptions()->where('stripe_id', $subscriptionId)->first();

        if (! $subscription) {
            throw new \RuntimeException('Subscription not found.');
        }

        $planPrice = $newPlan->priceForCycle($billingCycle);
        $stripePriceId = $planPrice?->stripe_price_id ?? $newPlan->stripe_price_id;

        try {
            $subscription->swap($stripePriceId);

            $subscription->update([
                'plan_id' => $newPlan->id,
                'billing_cycle' => $billingCycle,
                'gateway_price_id' => $stripePriceId,
                'current_period_end' => $this->calculatePeriodEnd($billingCycle),
            ]);

            return [
                'subscription_id' => $subscription->stripe_id,
                'status' => $subscription->stripe_status,
            ];
        } catch (IncompletePayment $e) {
            return [
                'subscription_id' => $subscription->stripe_id,
                'status' => 'incomplete',
                'client_secret' => $e->payment->clientSecret(),
            ];
        }
    }
  • Step 3: Update BillingServiceInterface

In website/app/Services/Billing/BillingServiceInterface.php, update the swapSubscription signature (line 29):

    public function swapSubscription(User $user, string $subscriptionId, Plan $newPlan, string $billingCycle = 'monthly'): array;
  • Step 4: Update PayPalBillingService swapSubscription signature

Check if PayPalBillingService has swapSubscription and add the $billingCycle parameter with default 'monthly' to match the interface.

  • Step 5: Fix semi_annually in CustomerController

In website/app/Http/Controllers/Admin/CustomerController.php, find semi_annually (line ~307) and replace with semi_annual.

  • Step 6: Run Pint and commit
cd website && vendor/bin/pint --dirty --format agent
git add app/Services/Billing/StripeBillingService.php app/Services/Billing/BillingServiceInterface.php app/Services/Billing/PayPalBillingService.php app/Http/Controllers/Admin/CustomerController.php && git commit -m "feat: update billing services for cycle-specific pricing and fix semi_annually naming"

Task 7: Update CheckoutController for cycle-aware checkout

Files:

  • Modify: website/app/Http/Controllers/Account/CheckoutController.php

  • Step 1: Update show() to pass plan prices

In the show() method, eager-load prices and pass them to the view. Change line 71-78:

        return Inertia::render('Checkout/Show', [
            'plan' => $plan->load('prices'),
            'paymentMethods' => $stripeService->getPaymentMethods($user),
            'intent' => $user->hasStripeId() ? $user->createSetupIntent() : null,
            'stripeKey' => config('cashier.key'),
            'osTemplates' => $osTemplates,
            'osTemplateGroups' => $osTemplateGroups,
        ]);
  • Step 2: Write checkout test

Create website/tests/Feature/MultiCycleCheckoutTest.php:

<?php

declare(strict_types=1);

use App\Models\Plan;
use App\Models\PlanPrice;
use App\Models\User;

beforeEach(function () {
    $this->plan = Plan::factory()->create([
        'slug' => 'vps-test',
        'service_type' => 'vps',
        'price' => 15.00,
        'status' => 'active',
        'features' => ['cpu' => '2 vCPU', 'ram' => '4 GB'],
    ]);

    PlanPrice::create(['plan_id' => $this->plan->id, 'billing_cycle' => 'monthly', 'price' => 15.00]);
    PlanPrice::create(['plan_id' => $this->plan->id, 'billing_cycle' => 'quarterly', 'price' => 42.75]);
    PlanPrice::create(['plan_id' => $this->plan->id, 'billing_cycle' => 'semi_annual', 'price' => 81.00]);
    PlanPrice::create(['plan_id' => $this->plan->id, 'billing_cycle' => 'annual', 'price' => 153.00]);
});

it('loads checkout page with plan prices', function () {
    $user = User::factory()->create();

    $response = $this->actingAs($user)
        ->get("http://account.ezscale.dev/checkout/{$this->plan->id}");

    $response->assertOk();
    $response->assertInertia(fn ($page) => $page
        ->component('Checkout/Show')
        ->has('plan.prices', 4)
    );
});

it('validates billing_cycle on checkout', function () {
    $user = User::factory()->create();

    $response = $this->actingAs($user)
        ->post("http://account.ezscale.dev/checkout/{$this->plan->id}", [
            'gateway' => 'stripe',
            'billing_cycle' => 'weekly', // invalid
        ]);

    $response->assertSessionHasErrors('billing_cycle');
});
  • Step 3: Run checkout tests

Run: cd website && php artisan test --compact --filter=MultiCycleCheckoutTest Expected: All tests PASS.

  • Step 4: Run Pint and commit
cd website && vendor/bin/pint --dirty --format agent
git add app/Http/Controllers/Account/CheckoutController.php tests/Feature/MultiCycleCheckoutTest.php && git commit -m "feat: update checkout for cycle-aware pricing with plan_prices"

Chunk 4: Customer Migration Command

Task 8: Create MigrateVpsPlans artisan command

Files:

  • Create: website/app/Console/Commands/MigrateVpsPlans.php

  • Create: website/tests/Feature/MigrateVpsPlansTest.php

  • Step 1: Create command

Run: cd website && php artisan make:command MigrateVpsPlans --no-interaction

  • Step 2: Write migration command
<?php

declare(strict_types=1);

namespace App\Console\Commands;

use App\Models\Plan;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

class MigrateVpsPlans extends Command
{
    protected $signature = 'plans:migrate-vps {--dry-run : Preview changes without applying}';

    protected $description = 'Migrate existing subscriptions and services from old VPS plans to new plan IDs';

    private const MIGRATION_MAP = [
        'vps-nano' => 'vps-1',
        'vps-micro' => 'vps-2',
        'vps-mini' => 'vps-2',
        'vps-standard' => 'vps-8',
        'vps-plus' => 'vps-16',
        'vps-pro' => 'vps-32',
        'vps-storage-500' => 'stor-500',
        'vps-storage-1tb' => 'stor-1tb',
    ];

    public function handle(): int
    {
        $dryRun = $this->option('dry-run');

        if ($dryRun) {
            $this->info('DRY RUN — no changes will be applied.');
        }

        $this->info('Migrating VPS subscriptions to new plan IDs...');

        $migrated = 0;
        $skipped = 0;

        foreach (self::MIGRATION_MAP as $oldSlug => $newSlug) {
            $oldPlan = Plan::where('slug', $oldSlug)->first();
            $newPlan = Plan::where('slug', $newSlug)->first();

            if (! $oldPlan || ! $newPlan) {
                $this->warn("Skipping {$oldSlug}{$newSlug}: plan not found.");
                $skipped++;

                continue;
            }

            // Find subscriptions on the old plan
            $subscriptions = DB::table('subscriptions')
                ->where('plan_id', $oldPlan->id)
                ->get();

            foreach ($subscriptions as $subscription) {
                $this->line("  Subscription #{$subscription->id} (user {$subscription->user_id}): {$oldSlug}{$newSlug}");

                if (! $dryRun) {
                    DB::table('subscriptions')
                        ->where('id', $subscription->id)
                        ->update(['plan_id' => $newPlan->id]);

                    Log::info('Migrated subscription to new plan', [
                        'subscription_id' => $subscription->id,
                        'user_id' => $subscription->user_id,
                        'old_plan' => $oldSlug,
                        'new_plan' => $newSlug,
                    ]);
                }

                $migrated++;
            }

            // Migrate services too
            $services = DB::table('services')
                ->where('plan_id', $oldPlan->id)
                ->get();

            foreach ($services as $service) {
                $this->line("  Service #{$service->id}: {$oldSlug}{$newSlug}");

                if (! $dryRun) {
                    DB::table('services')
                        ->where('id', $service->id)
                        ->update(['plan_id' => $newPlan->id]);
                }

                $migrated++;
            }
        }

        $this->newLine();
        $this->info("Migration complete: {$migrated} records " . ($dryRun ? 'would be' : '') . " migrated, {$skipped} plan mappings skipped.");

        return self::SUCCESS;
    }
}
  • Step 3: Write migration command tests

Create website/tests/Feature/MigrateVpsPlansTest.php:

<?php

declare(strict_types=1);

use App\Models\Plan;
use App\Models\User;
use Illuminate\Support\Facades\DB;

beforeEach(function () {
    $this->seed(\Database\Seeders\PlanSeeder::class);
});

it('migrates subscriptions from old plans to new plans', function () {
    $oldPlan = Plan::where('slug', 'vps-nano')->first();
    $newPlan = Plan::where('slug', 'vps-1')->first();

    // Skip if old plan was fully removed rather than archived
    if (! $oldPlan || ! $newPlan) {
        $this->markTestSkipped('Old plan vps-nano not found (may have been removed).');
    }

    $user = User::factory()->create();

    // Create a subscription on the old plan
    DB::table('subscriptions')->insert([
        'user_id' => $user->id,
        'type' => 'vps-nano',
        'stripe_id' => 'sub_test_' . uniqid(),
        'stripe_status' => 'active',
        'plan_id' => $oldPlan->id,
        'billing_cycle' => 'monthly',
        'created_at' => now(),
        'updated_at' => now(),
    ]);

    $this->artisan('plans:migrate-vps')
        ->assertSuccessful();

    $subscription = DB::table('subscriptions')->where('user_id', $user->id)->first();
    expect($subscription->plan_id)->toBe($newPlan->id);
});

it('dry run does not modify data', function () {
    $oldPlan = Plan::where('slug', 'vps-nano')->first();

    if (! $oldPlan) {
        $this->markTestSkipped('Old plan vps-nano not found.');
    }

    $user = User::factory()->create();

    DB::table('subscriptions')->insert([
        'user_id' => $user->id,
        'type' => 'vps-nano',
        'stripe_id' => 'sub_dry_' . uniqid(),
        'stripe_status' => 'active',
        'plan_id' => $oldPlan->id,
        'billing_cycle' => 'monthly',
        'created_at' => now(),
        'updated_at' => now(),
    ]);

    $this->artisan('plans:migrate-vps', ['--dry-run' => true])
        ->assertSuccessful();

    $subscription = DB::table('subscriptions')->where('user_id', $user->id)->first();
    expect($subscription->plan_id)->toBe($oldPlan->id); // unchanged
});
  • Step 4: Run tests

Run: cd website && php artisan test --compact --filter=MigrateVpsPlansTest Expected: All tests PASS.

  • Step 5: Run Pint and commit
cd website && vendor/bin/pint --dirty --format agent
git add app/Console/Commands/MigrateVpsPlans.php tests/Feature/MigrateVpsPlansTest.php && git commit -m "feat: add MigrateVpsPlans command for customer migration"

Chunk 5: Frontend — Pricing Page

Task 9: Update MarketingController to pass plan prices

Files:

  • Modify: website/app/Http/Controllers/Marketing/MarketingController.php

  • Step 1: Find the pricing method and eager-load prices

In the method that serves the Pricing page, update the Plan query to eager-load the prices relationship:

$plans = Plan::where('status', 'active')
    ->where('service_type', $serviceType) // or however it's currently filtered
    ->with('prices')
    ->orderBy('sort_order')
    ->get();
  • Step 2: Commit
cd website && vendor/bin/pint --dirty --format agent
git add app/Http/Controllers/Marketing/MarketingController.php && git commit -m "feat: eager-load plan prices in marketing controller"

Task 10: Update Pricing.vue with billing cycle toggle

Files:

  • Modify: website/resources/ts/Pages/Marketing/Pricing.vue

  • Step 1: Read current Pricing.vue

Read website/resources/ts/Pages/Marketing/Pricing.vue to understand the current structure and component patterns.

  • Step 2: Add PlanPrice interface and billing cycle state

In the <script setup lang="ts"> section, add:

interface PlanPrice {
  id: number
  plan_id: number
  billing_cycle: 'monthly' | 'quarterly' | 'semi_annual' | 'annual'
  price: string
}

interface Plan {
  id: number
  name: string
  slug: string
  description: string
  service_type: string
  price: string
  features: Record<string, string>
  prices: PlanPrice[]
}

const billingCycles = [
  { value: 'monthly', label: 'Monthly', months: 1, discount: 0 },
  { value: 'quarterly', label: 'Quarterly', months: 3, discount: 5 },
  { value: 'semi_annual', label: 'Semi-Annual', months: 6, discount: 10 },
  { value: 'annual', label: 'Annual', months: 12, discount: 15 },
] as const

const selectedCycle = ref<string>('monthly')

function getPlanPrice(plan: Plan): string {
  const planPrice = plan.prices?.find(p => p.billing_cycle === selectedCycle.value)
  if (planPrice) {
    return planPrice.price
  }
  return plan.price
}

function getMonthlyEquivalent(plan: Plan): string {
  const planPrice = plan.prices?.find(p => p.billing_cycle === selectedCycle.value)
  const cycle = billingCycles.find(c => c.value === selectedCycle.value)
  if (planPrice && cycle) {
    return (parseFloat(planPrice.price) / cycle.months).toFixed(2)
  }
  return plan.price
}

function getSavingsPercent(): number {
  const cycle = billingCycles.find(c => c.value === selectedCycle.value)
  return cycle?.discount ?? 0
}
  • Step 3: Add billing cycle toggle UI

Add a billing cycle selector above the plan cards. Use a segmented control pattern (custom div-based, not VBtnToggle with chips — per CLAUDE.md convention):

<div class="d-flex justify-center mb-8">
  <div class="billing-toggle d-inline-flex rounded-pill pa-1" style="background: rgba(var(--v-theme-surface-variant), 0.3)">
    <v-btn
      v-for="cycle in billingCycles"
      :key="cycle.value"
      :color="selectedCycle === cycle.value ? 'primary' : undefined"
      :variant="selectedCycle === cycle.value ? 'flat' : 'text'"
      rounded="pill"
      size="small"
      class="mx-1"
      @click="selectedCycle = cycle.value"
    >
      {{ cycle.label }}
      <v-chip v-if="cycle.discount" size="x-small" color="success" class="ml-1">
        -{{ cycle.discount }}%
      </v-chip>
    </v-btn>
  </div>
</div>
  • Step 4: Update plan card price display

In the plan card template, replace the static plan.price display with:

<div class="text-h4 font-weight-bold">
  ${{ getPlanPrice(plan) }}
</div>
<div v-if="selectedCycle !== 'monthly'" class="text-caption text-medium-emphasis">
  ${{ getMonthlyEquivalent(plan) }}/mo equivalent
</div>
<div class="text-body-2 text-medium-emphasis">
  {{ billingCycles.find(c => c.value === selectedCycle)?.label }}
</div>
  • Step 5: Update CTA links to include billing cycle

Update the checkout CTA link to pass the selected cycle:

:href="`${accountUrl}/checkout/${plan.id}?cycle=${selectedCycle}`"
  • Step 6: Build and verify

Run: cd website && npm run build Then take a screenshot: google-chrome --headless=new --disable-gpu --no-sandbox --screenshot=/tmp/pricing.png --window-size=1920,1080 --virtual-time-budget=15000 "http://ezscale.dev/pricing"

  • Step 7: Commit
cd website && vendor/bin/pint --dirty --format agent
git add resources/ts/Pages/Marketing/Pricing.vue && git commit -m "feat: add billing cycle toggle to pricing page"

Chunk 6: Frontend — Checkout Updates

Task 11: Update Checkout/Show.vue for cycle selection

Files:

  • Modify: website/resources/ts/Pages/Checkout/Show.vue

  • Step 1: Read current Checkout/Show.vue

Read the file to understand the current billing cycle handling and pricing display.

  • Step 2: Update to use plan.prices data

The checkout page already has billing cycle selection. Key changes:

  1. Pre-select billing cycle from URL query param (?cycle=quarterly)
  2. Update price display to use plan.prices instead of client-side discount calculation
  3. Remove hardcoded discount percentages — use actual PlanPrice amounts

In the <script setup lang="ts"> section, update the price calculation:

// Initialize from URL query param
const urlParams = new URLSearchParams(window.location.search)
const initialCycle = urlParams.get('cycle') || 'monthly'

// In the form or ref:
const billingCycle = ref(initialCycle)

// Replace client-side discount calculation with server data
function getSelectedPrice(): number {
  const planPrice = props.plan.prices?.find(
    (p: { billing_cycle: string }) => p.billing_cycle === billingCycle.value
  )
  return planPrice ? parseFloat(planPrice.price) : parseFloat(props.plan.price)
}
  • Step 3: Build and verify

Run: cd website && npm run build

  • Step 4: Commit
cd website && git add resources/ts/Pages/Checkout/Show.vue && git commit -m "feat: update checkout to use server-side plan prices"

Chunk 7: API Resources & Final Touches

Task 12: Update API Resources

Files:

  • Modify: website/app/Http/Resources/ServiceResource.php

  • Modify: website/app/Http/Resources/SubscriptionResource.php

  • Step 1: Update SubscriptionResource

Add billing cycle and pricing data:

'billing_cycle' => $this->billing_cycle,
'plan' => $this->plan_id ? [
    'name' => $this->plan?->name ?? $this->plan_name ?? null,
    'price' => $this->plan?->price ?? null,
    'prices' => $this->plan?->prices?->mapWithKeys(fn ($p) => [
        $p->billing_cycle => $p->price,
    ]) ?? [],
] : null,
  • Step 2: Update ServiceResource

Add pricing data when plan is loaded:

'plan' => $this->whenLoaded('plan', fn () => [
    'name' => $this->plan->name,
    'price' => $this->plan->price,
    'billing_cycle' => $this->plan->billing_cycle,
    'prices' => $this->plan->prices?->mapWithKeys(fn ($p) => [
        $p->billing_cycle => $p->price,
    ]) ?? [],
]),
  • Step 3: Run Pint and commit
cd website && vendor/bin/pint --dirty --format agent
git add app/Http/Resources/ServiceResource.php app/Http/Resources/SubscriptionResource.php && git commit -m "feat: include plan pricing data in API resources"

Task 13: Run full test suite

  • Step 1: Run all tests

Run: cd website && php artisan test --compact Expected: All existing tests pass plus new tests (PlanPriceTest, MultiCycleCheckoutTest, MigrateVpsPlansTest).

  • Step 2: Fix any failures

If tests fail, read the error output and fix. Common issues:

  • Seeder tests may need RefreshDatabase trait

  • Checkout tests may need Vite manifest (run npm run build first)

  • Old tests referencing vps-nano slug may need updating

  • Step 3: Run Pint one final time

Run: cd website && vendor/bin/pint --dirty --format agent

  • Step 4: Final commit
cd website && git add -A && git commit -m "fix: resolve test failures from pricing overhaul"

Post-Implementation Checklist

After all tasks are complete:

  • Run php artisan db:seed --class=PlanSeeder on dev database
  • Run php artisan plans:migrate-vps --dry-run to preview customer migration
  • Run php artisan stripe:sync-prices to create Stripe prices (requires Stripe test keys in .env)
  • Verify pricing page visually with screenshots
  • Verify checkout flow with each billing cycle
  • Run php artisan plans:migrate-vps to apply customer migration (when ready)
  • Create new VirtFusion packages manually via VirtFusion admin panel with I/O limits
  • Disable old VirtFusion packages