diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 0692f36..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "permissions": { - "allow": [ - "WebSearch", - "Bash(ls:*)", - "WebFetch(domain:lowendbox.com)", - "WebFetch(domain:www.lowendtalk.com)", - "Bash(php artisan tinker:*)", - "WebFetch(domain:www.vultr.com)", - "WebFetch(domain:www.digitalocean.com)", - "WebFetch(domain:www.linode.com)", - "Bash(vendor/bin/pint:*)", - "Bash(php artisan make:request:*)", - "Bash(php artisan make:controller:*)", - "Bash(php artisan make:test:*)", - "Bash(php artisan test:*)", - "Bash(npm run build:*)" - ] - }, - "outputStyle": "default", - "spinnerTipsEnabled": false -} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c9f5d34 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.superpowers/ +.mcp.json +.claude/settings.local.json +ezscale-discovery-*/ diff --git a/docs/superpowers/plans/2026-03-14-vps-pricing-overhaul.md b/docs/superpowers/plans/2026-03-14-vps-pricing-overhaul.md new file mode 100644 index 0000000..09c863a --- /dev/null +++ b/docs/superpowers/plans/2026-03-14-vps-pricing-overhaul.md @@ -0,0 +1,1377 @@ +# 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_annually` → `semi_annual`, `annually` → `annual` +- `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 +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** + +```bash +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 + 'decimal:2', + ]; + } + + public function plan(): BelongsTo + { + return $this->belongsTo(Plan::class); + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +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 +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: +```php +use App\Models\PlanPrice; +``` + +Add after the `services()` method (after line 50): +```php + 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** + +```bash +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: +```php + // 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: +```php + // ─── 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 `}`: + +```php + // ─── 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`: + +```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** + +```bash +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 + +```php + 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** + +```bash +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: +```php +$subscription = $user->newSubscription($plan->slug, $plan->stripe_price_id); +``` +to: +```php + $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`): +```php +'gateway_price_id' => $stripePriceId, +``` + +- [ ] **Step 2: Update swapSubscription to accept billing cycle** + +Change the `swapSubscription` method signature and body. Replace lines 95-122: + +```php + 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): + +```php + 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** + +```bash +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: + +```php + 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 +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** + +```bash +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 + '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 +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** + +```bash +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: + +```php +$plans = Plan::where('status', 'active') + ->where('service_type', $serviceType) // or however it's currently filtered + ->with('prices') + ->orderBy('sort_order') + ->get(); +``` + +- [ ] **Step 2: Commit** + +```bash +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 ` + + diff --git a/website/resources/ts/Components/Admin/DateRangePicker.vue b/website/resources/ts/Components/Admin/DateRangePicker.vue new file mode 100644 index 0000000..d55f133 --- /dev/null +++ b/website/resources/ts/Components/Admin/DateRangePicker.vue @@ -0,0 +1,134 @@ + + + diff --git a/website/resources/ts/Components/Admin/ReportChart.vue b/website/resources/ts/Components/Admin/ReportChart.vue new file mode 100644 index 0000000..41aca74 --- /dev/null +++ b/website/resources/ts/Components/Admin/ReportChart.vue @@ -0,0 +1,132 @@ + + + diff --git a/website/resources/ts/Components/Checkout/ConfigOptions.vue b/website/resources/ts/Components/Checkout/ConfigOptions.vue new file mode 100644 index 0000000..9ccb5bc --- /dev/null +++ b/website/resources/ts/Components/Checkout/ConfigOptions.vue @@ -0,0 +1,357 @@ + + + + + diff --git a/website/resources/ts/Components/Marketing/BuildYourOwn.vue b/website/resources/ts/Components/Marketing/BuildYourOwn.vue new file mode 100644 index 0000000..fad1f49 --- /dev/null +++ b/website/resources/ts/Components/Marketing/BuildYourOwn.vue @@ -0,0 +1,317 @@ + + + + + diff --git a/website/resources/ts/Components/Marketing/HeroSection.vue b/website/resources/ts/Components/Marketing/HeroSection.vue index f486395..b03678f 100644 --- a/website/resources/ts/Components/Marketing/HeroSection.vue +++ b/website/resources/ts/Components/Marketing/HeroSection.vue @@ -5,7 +5,7 @@ interface Props { } withDefaults(defineProps(), { - minHeight: '80vh', + minHeight: 'auto', showGrid: true, }) @@ -13,7 +13,7 @@ withDefaults(defineProps(), { Reset Password + + + Force Logout +