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>
1378 lines
45 KiB
Markdown
1378 lines
45 KiB
Markdown
# 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
|
|
<?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**
|
|
|
|
```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
|
|
<?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**
|
|
|
|
```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
|
|
<?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:
|
|
```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
|
|
<?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**
|
|
|
|
```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
|
|
<?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
|
|
<?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**
|
|
|
|
```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 `<script setup lang="ts">` section, add:
|
|
|
|
```typescript
|
|
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):
|
|
|
|
```html
|
|
<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:
|
|
|
|
```html
|
|
<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:
|
|
|
|
```html
|
|
: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**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```typescript
|
|
// 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**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```php
|
|
'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:
|
|
|
|
```php
|
|
'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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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
|