Files
website/docs/superpowers/plans/2026-03-16-configurable-options.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

63 KiB

Configurable Options & Build Your Own 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: Add a configurable options system supporting preset plan add-ons and a Build Your Own configurator with sliders, per-unit pricing, and dynamic Stripe billing.

Architecture: Unified schema where both preset add-ons and BYO use the same tables (groups, options, values, selections). BYO is a special mode where options are slider-type with per-unit pricing. Custom plans (vps-custom, etc.) have internal status and $0 base price — all cost comes from config selections. Dynamic Stripe prices created at checkout for the computed total.

Tech Stack: Laravel 12, Vue 3 + Vuetify 3 + Inertia.js v2, TypeScript, Pest 4, Stripe (Laravel Cashier)

Spec: docs/superpowers/specs/2026-03-16-configurable-options-build-your-own-design.md


File Map

New Files

Migrations:

  • database/migrations/2026_03_17_000001_create_plan_config_groups_table.php
  • database/migrations/2026_03_17_000002_create_plan_config_group_plan_table.php
  • database/migrations/2026_03_17_000003_create_plan_config_options_table.php
  • database/migrations/2026_03_17_000004_create_plan_config_values_table.php
  • database/migrations/2026_03_17_000005_create_subscription_config_selections_table.php

Models:

  • app/Models/PlanConfigGroup.php
  • app/Models/PlanConfigOption.php
  • app/Models/PlanConfigValue.php
  • app/Models/SubscriptionConfigSelection.php

Factories:

  • database/factories/PlanConfigGroupFactory.php
  • database/factories/PlanConfigOptionFactory.php
  • database/factories/PlanConfigValueFactory.php

Controllers:

  • app/Http/Controllers/Admin/ConfigGroupController.php

Form Requests:

  • app/Http/Requests/StoreConfigGroupRequest.php

Vue Pages:

  • resources/ts/Pages/Admin/ConfigGroups/Index.vue
  • resources/ts/Pages/Admin/ConfigGroups/Create.vue
  • resources/ts/Pages/Admin/ConfigGroups/Edit.vue
  • resources/ts/Components/Admin/ConfigOptionBuilder.vue
  • resources/ts/Components/Marketing/BuildYourOwn.vue
  • resources/ts/Components/Checkout/ConfigOptions.vue

Tests:

  • tests/Feature/Admin/ConfigGroupTest.php
  • tests/Feature/ConfigurableCheckoutTest.php

Seeders:

  • database/seeders/ConfigOptionSeeder.php

Modified Files

  • app/Models/Plan.php — add configGroups() relationship, update isAvailable(), add scopePublic()
  • app/Models/User.php — no changes needed
  • app/Http/Controllers/Account/CheckoutController.php — load config groups, add showCustom(), update store()
  • resources/ts/Pages/Checkout/Show.vue — render config options, handle BYO mode
  • resources/ts/Pages/Marketing/Pricing.vue — add Preset/BYO toggle, render BYO configurator
  • resources/ts/types/index.ts — add TypeScript interfaces
  • resources/ts/navigation/admin.ts — add Config Options nav item
  • resources/ts/utils/resolvers.ts — no changes needed
  • routes/admin.php — add config group routes
  • routes/account.php — add custom checkout route
  • database/seeders/PlanSeeder.php — add custom plans (vps-custom, mysql-custom, game-custom)

Chunk 1: Database & Models

Task 1: Migrations

Files:

  • Create: database/migrations/2026_03_17_000001_create_plan_config_groups_table.php

  • Create: database/migrations/2026_03_17_000002_create_plan_config_group_plan_table.php

  • Create: database/migrations/2026_03_17_000003_create_plan_config_options_table.php

  • Create: database/migrations/2026_03_17_000004_create_plan_config_values_table.php

  • Create: database/migrations/2026_03_17_000005_create_subscription_config_selections_table.php

  • Step 1: Create plan_config_groups migration

<?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_config_groups', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->text('description')->nullable();
            $table->string('mode', 20)->default('preset');
            $table->string('service_type', 50)->nullable();
            $table->boolean('is_active')->default(true);
            $table->integer('sort_order')->default(0);
            $table->timestamps();
            $table->softDeletes();

            $table->index(['mode', 'service_type']);
            $table->index('is_active');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('plan_config_groups');
    }
};
  • Step 2: Create plan_config_group_plan pivot migration
<?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_config_group_plan', function (Blueprint $table) {
            $table->foreignId('plan_config_group_id')->constrained()->cascadeOnDelete();
            $table->foreignId('plan_id')->constrained()->cascadeOnDelete();
            $table->unique(['plan_config_group_id', 'plan_id'], 'config_group_plan_unique');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('plan_config_group_plan');
    }
};
  • Step 3: Create plan_config_options migration
<?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_config_options', function (Blueprint $table) {
            $table->id();
            $table->foreignId('group_id')->constrained('plan_config_groups')->cascadeOnDelete();
            $table->string('name');
            $table->text('description')->nullable();
            $table->string('type', 50);
            $table->string('provisioning_key', 100)->nullable();
            $table->boolean('required')->default(false);
            $table->boolean('is_active')->default(true);
            $table->integer('min_qty')->nullable();
            $table->integer('max_qty')->nullable();
            $table->integer('step')->nullable()->default(1);
            $table->string('unit_label', 50)->nullable();
            $table->decimal('hourly_price', 10, 4)->nullable();
            $table->decimal('monthly_price', 10, 2)->nullable();
            $table->decimal('quarterly_price', 10, 2)->nullable();
            $table->decimal('semi_annual_price', 10, 2)->nullable();
            $table->decimal('annual_price', 10, 2)->nullable();
            $table->integer('sort_order')->default(0);
            $table->timestamps();

            $table->index(['group_id', 'sort_order']);
            $table->index('is_active');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('plan_config_options');
    }
};
  • Step 4: Create plan_config_values migration
<?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_config_values', function (Blueprint $table) {
            $table->id();
            $table->foreignId('option_id')->constrained('plan_config_options')->cascadeOnDelete();
            $table->string('label');
            $table->string('value')->nullable();
            $table->decimal('hourly_price', 10, 4)->default(0);
            $table->decimal('monthly_price', 10, 2)->default(0);
            $table->decimal('quarterly_price', 10, 2)->default(0);
            $table->decimal('semi_annual_price', 10, 2)->default(0);
            $table->decimal('annual_price', 10, 2)->default(0);
            $table->boolean('is_default')->default(false);
            $table->integer('sort_order')->default(0);
            $table->timestamps();

            $table->index(['option_id', 'sort_order']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('plan_config_values');
    }
};
  • Step 5: Create subscription_config_selections migration
<?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('subscription_config_selections', function (Blueprint $table) {
            $table->id();
            $table->foreignId('subscription_id')->constrained()->cascadeOnDelete();
            $table->unsignedBigInteger('option_id');
            $table->unsignedBigInteger('value_id')->nullable();
            $table->integer('quantity')->nullable();
            $table->string('text_value', 500)->nullable();
            $table->decimal('locked_price', 10, 4);
            $table->decimal('locked_hourly_price', 10, 4)->nullable();
            $table->string('billing_cycle', 50);
            $table->boolean('is_custom_build')->default(false);
            $table->timestamps();

            $table->foreign('option_id')->references('id')->on('plan_config_options')->restrictOnDelete();
            $table->foreign('value_id')->references('id')->on('plan_config_values')->nullOnDelete();
            $table->unique(['subscription_id', 'option_id']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('subscription_config_selections');
    }
};
  • Step 6: Run migrations

Run: php artisan migrate Expected: All 5 tables created successfully.

  • Step 7: Commit
git add database/migrations/2026_03_17_*.php
git commit -m "feat: add configurable options migrations (5 tables)"

Task 2: Models

Files:

  • Create: app/Models/PlanConfigGroup.php

  • Create: app/Models/PlanConfigOption.php

  • Create: app/Models/PlanConfigValue.php

  • Create: app/Models/SubscriptionConfigSelection.php

  • Modify: app/Models/Plan.php

  • Step 1: Create PlanConfigGroup model

<?php

declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;

class PlanConfigGroup extends Model
{
    use HasFactory, SoftDeletes;

    protected $fillable = [
        'name',
        'description',
        'mode',
        'service_type',
        'is_active',
        'sort_order',
    ];

    protected function casts(): array
    {
        return [
            'is_active' => 'boolean',
            'sort_order' => 'integer',
        ];
    }

    public function options(): HasMany
    {
        return $this->hasMany(PlanConfigOption::class, 'group_id')->orderBy('sort_order');
    }

    public function plans(): BelongsToMany
    {
        return $this->belongsToMany(Plan::class, 'plan_config_group_plan');
    }

    public function scopePreset(Builder $query): Builder
    {
        return $query->where('mode', 'preset');
    }

    public function scopeBuildYourOwn(Builder $query): Builder
    {
        return $query->where('mode', 'build_your_own');
    }

    public function scopeForServiceType(Builder $query, string $serviceType): Builder
    {
        return $query->where('service_type', $serviceType);
    }

    public function scopeActive(Builder $query): Builder
    {
        return $query->where('is_active', true);
    }
}
  • Step 2: Create PlanConfigOption model
<?php

declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

class PlanConfigOption extends Model
{
    use HasFactory;

    protected $fillable = [
        'group_id',
        'name',
        'description',
        'type',
        'provisioning_key',
        'required',
        'is_active',
        'min_qty',
        'max_qty',
        'step',
        'unit_label',
        'hourly_price',
        'monthly_price',
        'quarterly_price',
        'semi_annual_price',
        'annual_price',
        'sort_order',
    ];

    protected function casts(): array
    {
        return [
            'required' => 'boolean',
            'is_active' => 'boolean',
            'min_qty' => 'integer',
            'max_qty' => 'integer',
            'step' => 'integer',
            'hourly_price' => 'decimal:4',
            'monthly_price' => 'decimal:2',
            'quarterly_price' => 'decimal:2',
            'semi_annual_price' => 'decimal:2',
            'annual_price' => 'decimal:2',
            'sort_order' => 'integer',
        ];
    }

    public function group(): BelongsTo
    {
        return $this->belongsTo(PlanConfigGroup::class, 'group_id');
    }

    public function values(): HasMany
    {
        return $this->hasMany(PlanConfigValue::class, 'option_id')->orderBy('sort_order');
    }

    public function scopeActive(Builder $query): Builder
    {
        return $query->where('is_active', true);
    }

    public function isSlider(): bool
    {
        return $this->type === 'slider';
    }

    public function isQuantity(): bool
    {
        return $this->type === 'quantity';
    }

    public function isDropdown(): bool
    {
        return $this->type === 'dropdown';
    }

    public function isRadio(): bool
    {
        return $this->type === 'radio';
    }

    public function isCheckbox(): bool
    {
        return $this->type === 'checkbox';
    }

    public function isText(): bool
    {
        return $this->type === 'text';
    }

    public function calculatePrice(int $quantity, string $cycle): float
    {
        $priceField = match ($cycle) {
            'monthly' => 'monthly_price',
            'quarterly' => 'quarterly_price',
            'semi_annual' => 'semi_annual_price',
            'annual' => 'annual_price',
            default => 'monthly_price',
        };

        return round((float) $this->{$priceField} * $quantity, 4);
    }

    public function getHourlyPrice(int $quantity): float
    {
        return round((float) $this->hourly_price * $quantity, 4);
    }
}
  • Step 3: Create PlanConfigValue model
<?php

declare(strict_types=1);

namespace App\Models;

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

class PlanConfigValue extends Model
{
    use HasFactory;

    protected $fillable = [
        'option_id',
        'label',
        'value',
        'hourly_price',
        'monthly_price',
        'quarterly_price',
        'semi_annual_price',
        'annual_price',
        'is_default',
        'sort_order',
    ];

    protected function casts(): array
    {
        return [
            'hourly_price' => 'decimal:4',
            'monthly_price' => 'decimal:2',
            'quarterly_price' => 'decimal:2',
            'semi_annual_price' => 'decimal:2',
            'annual_price' => 'decimal:2',
            'is_default' => 'boolean',
            'sort_order' => 'integer',
        ];
    }

    public function option(): BelongsTo
    {
        return $this->belongsTo(PlanConfigOption::class, 'option_id');
    }

    public function getPriceForCycle(string $cycle): float
    {
        return (float) match ($cycle) {
            'monthly' => $this->monthly_price,
            'quarterly' => $this->quarterly_price,
            'semi_annual' => $this->semi_annual_price,
            'annual' => $this->annual_price,
            default => $this->monthly_price,
        };
    }
}
  • Step 4: Create SubscriptionConfigSelection model
<?php

declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Laravel\Cashier\Subscription;

class SubscriptionConfigSelection extends Model
{
    protected $fillable = [
        'subscription_id',
        'option_id',
        'value_id',
        'quantity',
        'text_value',
        'locked_price',
        'locked_hourly_price',
        'billing_cycle',
        'is_custom_build',
    ];

    protected function casts(): array
    {
        return [
            'quantity' => 'integer',
            'locked_price' => 'decimal:4',
            'locked_hourly_price' => 'decimal:4',
            'is_custom_build' => 'boolean',
        ];
    }

    public function subscription(): BelongsTo
    {
        return $this->belongsTo(Subscription::class);
    }

    public function option(): BelongsTo
    {
        return $this->belongsTo(PlanConfigOption::class, 'option_id');
    }

    public function value(): BelongsTo
    {
        return $this->belongsTo(PlanConfigValue::class, 'value_id');
    }
}
  • Step 5: Update Plan model

Add to app/Models/Plan.php:

// Add import at top
use Illuminate\Database\Eloquent\Relations\BelongsToMany;

// Add relationship method
public function configGroups(): BelongsToMany
{
    return $this->belongsToMany(PlanConfigGroup::class, 'plan_config_group_plan');
}

// Update isAvailable() to include 'internal' status
public function isAvailable(): bool
{
    return in_array($this->status, ['active', 'internal'])
        && ($this->stock_quantity === null || $this->stock_quantity > 0);
}

// Add scope for public-facing plan listings
public function scopePublic(Builder $query): Builder
{
    return $query->whereNotIn('status', ['hidden', 'internal', 'inactive']);
}
  • Step 6: Add configSelections to Subscription via Cashier config

Check config/cashier.php for 'model' => ... setting. If Cashier is configured to use a custom Subscription model, add the relationship there. If it uses the default Laravel\Cashier\Subscription, create a helper service class instead:

Create app/Services/ConfigSelectionService.php:

<?php

declare(strict_types=1);

namespace App\Services;

use App\Models\SubscriptionConfigSelection;
use Illuminate\Support\Collection;
use Laravel\Cashier\Subscription;

class ConfigSelectionService
{
    public function getSelections(Subscription $subscription): Collection
    {
        return SubscriptionConfigSelection::where('subscription_id', $subscription->id)
            ->with('option', 'value')
            ->get();
    }

    public function totalConfigPrice(Subscription $subscription, string $cycle): float
    {
        return (float) SubscriptionConfigSelection::where('subscription_id', $subscription->id)
            ->where('billing_cycle', $cycle)
            ->sum('locked_price');
    }

    public function totalHourlyPrice(Subscription $subscription): float
    {
        return (float) SubscriptionConfigSelection::where('subscription_id', $subscription->id)
            ->sum('locked_hourly_price');
    }
}
  • Step 7: Run Pint and commit
vendor/bin/pint --dirty --format agent
git add app/Models/PlanConfig*.php app/Models/SubscriptionConfigSelection.php app/Models/Plan.php
git commit -m "feat: add configurable options models with relationships and scopes"

Task 3: Factories

Files:

  • Create: database/factories/PlanConfigGroupFactory.php

  • Create: database/factories/PlanConfigOptionFactory.php

  • Create: database/factories/PlanConfigValueFactory.php

  • Step 1: Create PlanConfigGroupFactory

<?php

declare(strict_types=1);

namespace Database\Factories;

use App\Models\PlanConfigGroup;
use Illuminate\Database\Eloquent\Factories\Factory;

class PlanConfigGroupFactory extends Factory
{
    protected $model = PlanConfigGroup::class;

    public function definition(): array
    {
        return [
            'name' => fake()->words(3, true),
            'description' => fake()->sentence(),
            'mode' => 'preset',
            'service_type' => null,
            'is_active' => true,
            'sort_order' => 0,
        ];
    }

    public function buildYourOwn(string $serviceType = 'vps'): static
    {
        return $this->state(['mode' => 'build_your_own', 'service_type' => $serviceType]);
    }

    public function inactive(): static
    {
        return $this->state(['is_active' => false]);
    }
}
  • Step 2: Create PlanConfigOptionFactory
<?php

declare(strict_types=1);

namespace Database\Factories;

use App\Models\PlanConfigGroup;
use App\Models\PlanConfigOption;
use Illuminate\Database\Eloquent\Factories\Factory;

class PlanConfigOptionFactory extends Factory
{
    protected $model = PlanConfigOption::class;

    public function definition(): array
    {
        return [
            'group_id' => PlanConfigGroup::factory(),
            'name' => fake()->words(2, true),
            'description' => null,
            'type' => 'dropdown',
            'provisioning_key' => null,
            'required' => false,
            'is_active' => true,
            'min_qty' => null,
            'max_qty' => null,
            'step' => 1,
            'unit_label' => null,
            'hourly_price' => null,
            'monthly_price' => null,
            'quarterly_price' => null,
            'semi_annual_price' => null,
            'annual_price' => null,
            'sort_order' => 0,
        ];
    }

    public function slider(float $monthlyPerUnit, float $hourlyPerUnit = 0): static
    {
        return $this->state([
            'type' => 'slider',
            'monthly_price' => $monthlyPerUnit,
            'hourly_price' => $hourlyPerUnit,
            'min_qty' => 1,
            'max_qty' => 16,
            'step' => 1,
        ]);
    }

    public function quantity(): static
    {
        return $this->state(['type' => 'quantity', 'min_qty' => 0, 'max_qty' => 10]);
    }

    public function checkbox(): static
    {
        return $this->state(['type' => 'checkbox']);
    }

    public function required(): static
    {
        return $this->state(['required' => true]);
    }
}
  • Step 3: Create PlanConfigValueFactory
<?php

declare(strict_types=1);

namespace Database\Factories;

use App\Models\PlanConfigOption;
use App\Models\PlanConfigValue;
use Illuminate\Database\Eloquent\Factories\Factory;

class PlanConfigValueFactory extends Factory
{
    protected $model = PlanConfigValue::class;

    public function definition(): array
    {
        return [
            'option_id' => PlanConfigOption::factory(),
            'label' => fake()->words(2, true),
            'value' => null,
            'hourly_price' => 0,
            'monthly_price' => 0,
            'quarterly_price' => 0,
            'semi_annual_price' => 0,
            'annual_price' => 0,
            'is_default' => false,
            'sort_order' => 0,
        ];
    }

    public function asDefault(): static
    {
        return $this->state(['is_default' => true]);
    }

    public function withPrice(float $monthly, float $hourly = 0): static
    {
        return $this->state([
            'monthly_price' => $monthly,
            'quarterly_price' => round($monthly * 3 * 0.95, 2),
            'semi_annual_price' => round($monthly * 6 * 0.90, 2),
            'annual_price' => round($monthly * 12 * 0.85, 2),
            'hourly_price' => $hourly,
        ]);
    }
}
  • Step 4: Commit
git add database/factories/PlanConfig*.php
git commit -m "feat: add configurable options factories"

Task 4: TypeScript Interfaces

Files:

  • Modify: resources/ts/types/index.ts

  • Step 1: Add interfaces to types/index.ts

Add the following interfaces (from the spec) to the end of the file:

export interface PlanConfigGroup {
  id: number
  name: string
  description: string | null
  mode: 'preset' | 'build_your_own'
  service_type: string | null
  is_active: boolean
  sort_order: number
  options: PlanConfigOption[]
  plans?: Plan[]
}

export interface PlanConfigOption {
  id: number
  group_id: number
  name: string
  description: string | null
  type: 'dropdown' | 'radio' | 'quantity' | 'checkbox' | 'text' | 'slider'
  provisioning_key: string | null
  required: boolean
  is_active: boolean
  min_qty: number | null
  max_qty: number | null
  step: number | null
  unit_label: string | null
  hourly_price: number | null
  monthly_price: number | null
  quarterly_price: number | null
  semi_annual_price: number | null
  annual_price: number | null
  sort_order: number
  values: PlanConfigValue[]
}

export interface PlanConfigValue {
  id: number
  option_id: number
  label: string
  value: string | null
  hourly_price: number
  monthly_price: number
  quarterly_price: number
  semi_annual_price: number
  annual_price: number
  is_default: boolean
  sort_order: number
}

export interface SubscriptionConfigSelection {
  id: number
  subscription_id: number
  option_id: number
  value_id: number | null
  quantity: number | null
  text_value: string | null
  locked_price: number
  locked_hourly_price: number | null
  billing_cycle: string
  is_custom_build: boolean
  option?: PlanConfigOption
  value?: PlanConfigValue
}
  • Step 2: Commit
git add resources/ts/types/index.ts
git commit -m "feat: add TypeScript interfaces for configurable options"

Task 5: Model Unit Tests

Files:

  • Create: tests/Feature/Admin/ConfigGroupTest.php

  • Step 1: Write model and relationship tests

<?php

declare(strict_types=1);

use App\Models\Plan;
use App\Models\PlanConfigGroup;
use App\Models\PlanConfigOption;
use App\Models\PlanConfigValue;
use Database\Seeders\RoleAndPermissionSeeder;

beforeEach(function (): void {
    $this->seed(RoleAndPermissionSeeder::class);
});

describe('PlanConfigGroup Model', function (): void {
    it('creates a preset group with options and values', function (): void {
        $group = PlanConfigGroup::factory()->create(['mode' => 'preset']);
        $option = PlanConfigOption::factory()->create([
            'group_id' => $group->id,
            'type' => 'dropdown',
            'name' => 'RAM',
        ]);
        $value = PlanConfigValue::factory()->withPrice(15.00)->create([
            'option_id' => $option->id,
            'label' => '64 GB',
        ]);

        expect($group->options)->toHaveCount(1);
        expect($option->values)->toHaveCount(1);
        expect($value->getPriceForCycle('monthly'))->toBe(15.0);
    });

    it('scopes preset and build_your_own groups', function (): void {
        PlanConfigGroup::factory()->create(['mode' => 'preset']);
        PlanConfigGroup::factory()->buildYourOwn('vps')->create();
        PlanConfigGroup::factory()->buildYourOwn('game')->create();

        expect(PlanConfigGroup::preset()->count())->toBe(1);
        expect(PlanConfigGroup::buildYourOwn()->count())->toBe(2);
        expect(PlanConfigGroup::buildYourOwn()->forServiceType('vps')->count())->toBe(1);
    });

    it('attaches to plans via pivot', function (): void {
        $group = PlanConfigGroup::factory()->create();
        $plan = Plan::factory()->create();
        $group->plans()->attach($plan->id);

        expect($group->plans)->toHaveCount(1);
        expect($plan->configGroups)->toHaveCount(1);
    });

    it('soft deletes and restores', function (): void {
        $group = PlanConfigGroup::factory()->create();
        $group->delete();

        expect(PlanConfigGroup::count())->toBe(0);
        expect(PlanConfigGroup::withTrashed()->count())->toBe(1);

        $group->restore();
        expect(PlanConfigGroup::count())->toBe(1);
    });
});

describe('PlanConfigOption Model', function (): void {
    it('calculates slider price correctly', function (): void {
        $option = PlanConfigOption::factory()->slider(2.00, 0.003)->create([
            'name' => 'CPU Cores',
            'unit_label' => 'cores',
        ]);

        expect($option->calculatePrice(4, 'monthly'))->toBe(8.0);
        expect($option->getHourlyPrice(4))->toBe(0.012);
        expect($option->isSlider())->toBeTrue();
    });

    it('calculates price for different billing cycles', function (): void {
        $option = PlanConfigOption::factory()->create([
            'type' => 'quantity',
            'monthly_price' => 3.00,
            'quarterly_price' => 8.55,
            'semi_annual_price' => 16.20,
            'annual_price' => 30.60,
        ]);

        expect($option->calculatePrice(2, 'monthly'))->toBe(6.0);
        expect($option->calculatePrice(2, 'quarterly'))->toBe(17.1);
        expect($option->calculatePrice(2, 'annual'))->toBe(61.2);
    });

    it('identifies option types correctly', function (): void {
        $dropdown = PlanConfigOption::factory()->create(['type' => 'dropdown']);
        $slider = PlanConfigOption::factory()->slider(1.00)->create();
        $checkbox = PlanConfigOption::factory()->checkbox()->create();

        expect($dropdown->isDropdown())->toBeTrue();
        expect($dropdown->isSlider())->toBeFalse();
        expect($slider->isSlider())->toBeTrue();
        expect($checkbox->isCheckbox())->toBeTrue();
    });
});

describe('PlanConfigValue Model', function (): void {
    it('returns price for each billing cycle', function (): void {
        $value = PlanConfigValue::factory()->create([
            'monthly_price' => 15.00,
            'quarterly_price' => 42.75,
            'semi_annual_price' => 81.00,
            'annual_price' => 153.00,
        ]);

        expect($value->getPriceForCycle('monthly'))->toBe(15.0);
        expect($value->getPriceForCycle('quarterly'))->toBe(42.75);
        expect($value->getPriceForCycle('semi_annual'))->toBe(81.0);
        expect($value->getPriceForCycle('annual'))->toBe(153.0);
    });
});

describe('Plan Model Updates', function (): void {
    it('treats internal status as available', function (): void {
        $active = Plan::factory()->create(['status' => 'active']);
        $internal = Plan::factory()->create(['status' => 'internal']);
        $hidden = Plan::factory()->create(['status' => 'hidden']);

        expect($active->isAvailable())->toBeTrue();
        expect($internal->isAvailable())->toBeTrue();
        expect($hidden->isAvailable())->toBeFalse();
    });

    it('excludes internal and hidden from public scope', function (): void {
        Plan::factory()->create(['status' => 'active']);
        Plan::factory()->create(['status' => 'internal']);
        Plan::factory()->create(['status' => 'hidden']);

        expect(Plan::public()->count())->toBe(1);
    });
});
  • Step 2: Run tests

Run: php artisan test --compact tests/Feature/Admin/ConfigGroupTest.php Expected: All tests pass.

  • Step 3: Commit
git add tests/Feature/Admin/ConfigGroupTest.php
git commit -m "test: add configurable options model tests"

Chunk 2: Admin CRUD

Task 6: Admin Controller & Routes

Files:

  • Create: app/Http/Controllers/Admin/ConfigGroupController.php

  • Create: app/Http/Requests/StoreConfigGroupRequest.php

  • Modify: routes/admin.php

  • Modify: resources/ts/navigation/admin.ts

  • Step 1: Create StoreConfigGroupRequest

<?php

declare(strict_types=1);

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreConfigGroupRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:255'],
            'description' => ['nullable', 'string'],
            'mode' => ['required', 'in:preset,build_your_own'],
            'service_type' => ['nullable', 'required_if:mode,build_your_own', 'in:vps,dedicated,hosting,mysql,game,backups'],
            'is_active' => ['boolean'],
            'plan_ids' => ['nullable', 'array'],
            'plan_ids.*' => ['exists:plans,id'],
            'options' => ['required', 'array', 'min:1'],
            'options.*.name' => ['required', 'string', 'max:255'],
            'options.*.description' => ['nullable', 'string'],
            'options.*.type' => ['required', 'in:dropdown,radio,quantity,checkbox,text,slider'],
            'options.*.provisioning_key' => ['nullable', 'string', 'max:100'],
            'options.*.required' => ['boolean'],
            'options.*.is_active' => ['boolean'],
            'options.*.min_qty' => ['nullable', 'integer', 'min:0'],
            'options.*.max_qty' => ['nullable', 'integer', 'min:0'],
            'options.*.step' => ['nullable', 'integer', 'min:1'],
            'options.*.unit_label' => ['nullable', 'string', 'max:50'],
            'options.*.hourly_price' => ['nullable', 'numeric', 'min:0'],
            'options.*.monthly_price' => ['nullable', 'numeric', 'min:0'],
            'options.*.quarterly_price' => ['nullable', 'numeric', 'min:0'],
            'options.*.semi_annual_price' => ['nullable', 'numeric', 'min:0'],
            'options.*.annual_price' => ['nullable', 'numeric', 'min:0'],
            'options.*.values' => ['nullable', 'array'],
            'options.*.values.*.label' => ['required', 'string', 'max:255'],
            'options.*.values.*.value' => ['nullable', 'string', 'max:255'],
            'options.*.values.*.hourly_price' => ['nullable', 'numeric', 'min:0'],
            'options.*.values.*.monthly_price' => ['nullable', 'numeric', 'min:0'],
            'options.*.values.*.quarterly_price' => ['nullable', 'numeric', 'min:0'],
            'options.*.values.*.semi_annual_price' => ['nullable', 'numeric', 'min:0'],
            'options.*.values.*.annual_price' => ['nullable', 'numeric', 'min:0'],
            'options.*.values.*.is_default' => ['boolean'],
        ];
    }
}
  • Step 2: Create ConfigGroupController
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Admin;

use App\Http\Controllers\Controller;
use App\Http\Requests\StoreConfigGroupRequest;
use App\Models\Plan;
use App\Models\PlanConfigGroup;
use App\Models\PlanConfigOption;
use App\Models\PlanConfigValue;
use App\Models\SubscriptionConfigSelection;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;

class ConfigGroupController extends Controller
{
    public function index(): Response
    {
        $groups = PlanConfigGroup::query()
            ->withCount('options', 'plans')
            ->orderBy('sort_order')
            ->paginate(20);

        return Inertia::render('Admin/ConfigGroups/Index', [
            'groups' => $groups,
        ]);
    }

    public function create(): Response
    {
        return Inertia::render('Admin/ConfigGroups/Create', [
            'plans' => Plan::query()->whereIn('status', ['active', 'internal'])->orderBy('name')->get(['id', 'name', 'service_type']),
        ]);
    }

    public function store(StoreConfigGroupRequest $request): RedirectResponse
    {
        DB::transaction(function () use ($request): void {
            $group = PlanConfigGroup::create($request->safe()->only([
                'name', 'description', 'mode', 'service_type', 'is_active',
            ]));

            if ($request->input('mode') === 'preset' && $request->has('plan_ids')) {
                $group->plans()->sync($request->input('plan_ids'));
            }

            $this->syncOptions($group, $request->input('options', []));
        });

        return redirect()->route('config-groups.index')->with('success', 'Config group created.');
    }

    public function edit(PlanConfigGroup $configGroup): Response
    {
        $configGroup->load('options.values', 'plans');

        return Inertia::render('Admin/ConfigGroups/Edit', [
            'configGroup' => $configGroup,
            'plans' => Plan::query()->whereIn('status', ['active', 'internal'])->orderBy('name')->get(['id', 'name', 'service_type']),
        ]);
    }

    public function update(StoreConfigGroupRequest $request, PlanConfigGroup $configGroup): RedirectResponse
    {
        DB::transaction(function () use ($request, $configGroup): void {
            $configGroup->update($request->safe()->only([
                'name', 'description', 'mode', 'service_type', 'is_active',
            ]));

            if ($request->input('mode') === 'preset') {
                $configGroup->plans()->sync($request->input('plan_ids', []));
            } else {
                $configGroup->plans()->detach();
            }

            $this->syncOptions($configGroup, $request->input('options', []));
        });

        return redirect()->route('config-groups.index')->with('success', 'Config group updated.');
    }

    public function destroy(PlanConfigGroup $configGroup): RedirectResponse
    {
        $hasSelections = SubscriptionConfigSelection::query()
            ->whereIn('option_id', $configGroup->options()->pluck('id'))
            ->exists();

        if ($hasSelections) {
            $configGroup->update(['is_active' => false]);
            $configGroup->delete(); // soft delete

            return redirect()->route('config-groups.index')->with('success', 'Config group archived.');
        }

        $configGroup->forceDelete();

        return redirect()->route('config-groups.index')->with('success', 'Config group deleted.');
    }

    private function syncOptions(PlanConfigGroup $group, array $options): void
    {
        $existingOptionIds = $group->options()->pluck('id')->toArray();
        $incomingOptionIds = [];

        foreach ($options as $index => $optionData) {
            $optionAttributes = [
                'group_id' => $group->id,
                'name' => $optionData['name'],
                'description' => $optionData['description'] ?? null,
                'type' => $optionData['type'],
                'provisioning_key' => $optionData['provisioning_key'] ?? null,
                'required' => $optionData['required'] ?? false,
                'is_active' => $optionData['is_active'] ?? true,
                'min_qty' => $optionData['min_qty'] ?? null,
                'max_qty' => $optionData['max_qty'] ?? null,
                'step' => $optionData['step'] ?? 1,
                'unit_label' => $optionData['unit_label'] ?? null,
                'hourly_price' => $optionData['hourly_price'] ?? null,
                'monthly_price' => $optionData['monthly_price'] ?? null,
                'quarterly_price' => $optionData['quarterly_price'] ?? null,
                'semi_annual_price' => $optionData['semi_annual_price'] ?? null,
                'annual_price' => $optionData['annual_price'] ?? null,
                'sort_order' => $index,
            ];

            if (isset($optionData['id']) && in_array($optionData['id'], $existingOptionIds)) {
                $option = PlanConfigOption::find($optionData['id']);
                $option->update($optionAttributes);
                $incomingOptionIds[] = $option->id;
            } else {
                $option = PlanConfigOption::create($optionAttributes);
                $incomingOptionIds[] = $option->id;
            }

            // Sync values for dropdown/radio/checkbox types
            if (in_array($optionData['type'], ['dropdown', 'radio', 'checkbox'])) {
                $this->syncValues($option, $optionData['values'] ?? []);
            }
        }

        // Delete removed options (only if no selections reference them)
        $removedIds = array_diff($existingOptionIds, $incomingOptionIds);
        foreach ($removedIds as $removedId) {
            $hasSelections = SubscriptionConfigSelection::where('option_id', $removedId)->exists();
            if (! $hasSelections) {
                PlanConfigOption::destroy($removedId);
            }
        }
    }

    private function syncValues(PlanConfigOption $option, array $values): void
    {
        $existingValueIds = $option->values()->pluck('id')->toArray();
        $incomingValueIds = [];

        foreach ($values as $index => $valueData) {
            $valueAttributes = [
                'option_id' => $option->id,
                'label' => $valueData['label'],
                'value' => $valueData['value'] ?? null,
                'hourly_price' => $valueData['hourly_price'] ?? 0,
                'monthly_price' => $valueData['monthly_price'] ?? 0,
                'quarterly_price' => $valueData['quarterly_price'] ?? 0,
                'semi_annual_price' => $valueData['semi_annual_price'] ?? 0,
                'annual_price' => $valueData['annual_price'] ?? 0,
                'is_default' => $valueData['is_default'] ?? false,
                'sort_order' => $index,
            ];

            if (isset($valueData['id']) && in_array($valueData['id'], $existingValueIds)) {
                PlanConfigValue::find($valueData['id'])->update($valueAttributes);
                $incomingValueIds[] = $valueData['id'];
            } else {
                $value = PlanConfigValue::create($valueAttributes);
                $incomingValueIds[] = $value->id;
            }
        }

        // Delete removed values
        $removedIds = array_diff($existingValueIds, $incomingValueIds);
        PlanConfigValue::whereIn('id', $removedIds)->delete();
    }
}
  • Step 3: Add routes

Add to routes/admin.php:

use App\Http\Controllers\Admin\ConfigGroupController;

Route::resource('config-groups', ConfigGroupController::class)
    ->except(['show'])
    ->parameters(['config-groups' => 'configGroup']);
  • Step 4: Add admin navigation

Add to resources/ts/navigation/admin.ts under Infrastructure heading:

{ title: 'Config Options', to: '/config-groups', icon: 'tabler-adjustments' },
  • Step 5: Commit
vendor/bin/pint --dirty --format agent
git add app/Http/Controllers/Admin/ConfigGroupController.php app/Http/Requests/StoreConfigGroupRequest.php routes/admin.php resources/ts/navigation/admin.ts
git commit -m "feat: add admin config group controller, routes, and navigation"

Task 7: Admin Vue Pages

Files:

  • Create: resources/ts/Pages/Admin/ConfigGroups/Index.vue
  • Create: resources/ts/Pages/Admin/ConfigGroups/Create.vue
  • Create: resources/ts/Pages/Admin/ConfigGroups/Edit.vue
  • Create: resources/ts/Components/Admin/ConfigOptionBuilder.vue

These are complex nested form components. The ConfigOptionBuilder is the reusable component for adding/editing/removing options and their nested values dynamically.

  • Step 1: Create ConfigOptionBuilder component

This component renders a dynamic list of config options. Each option has fields for name, type, pricing, and for dropdown/radio/checkbox types, a nested values list. It emits update:modelValue with the full options array.

Key features:

  • "Add Option" button at bottom
  • Each option row: name, type, required toggle, min/max/step (for slider/qty), unit_label, per-cycle prices (for slider/qty)
  • Nested "Add Value" for dropdown/radio/checkbox options
  • Each value row: label, value, per-cycle prices, is_default
  • Remove buttons with confirmation for options that have selections
  • Drag handles for reordering (optional — use sort_order index)

Props: modelValue: PlanConfigOption[], mode: 'preset' | 'build_your_own' Emits: update:modelValue

Implementation: Use VExpansionPanels for each option, VTextField/VSelect/VSwitch for fields, nested VTable for values.

  • Step 2: Create Index.vue

Standard admin index page. VDataTable with columns: Name, Mode (VChip), Service Type, Plans, Options count, Active (VChip), Actions (edit/delete). Filter tabs: All | Preset | Build Your Own. Create button in top-right.

Layout: AdminLayout.

  • Step 3: Create Create.vue

Form with:

  • Name (VTextField)
  • Description (VTextarea)
  • Mode (VSelect: Preset / Build Your Own)
  • Service Type (VSelect, shown when BYO mode selected)
  • Plan assignment (VSelect multi, shown when Preset mode)
  • Active toggle (VSwitch)
  • ConfigOptionBuilder component for managing options

Submit POST to /config-groups. Layout: AdminLayout.

  • Step 4: Create Edit.vue

Same as Create, pre-populated with configGroup prop data. Submit PUT to /config-groups/{id}. Layout: AdminLayout.

  • Step 5: Build and verify

Run: npm run build Expected: Build succeeds with no errors.

  • Step 6: Commit
git add resources/ts/Pages/Admin/ConfigGroups/ resources/ts/Components/Admin/ConfigOptionBuilder.vue
git commit -m "feat: add admin config group CRUD pages with option builder"

Task 8: Admin CRUD Tests

Files:

  • Modify: tests/Feature/Admin/ConfigGroupTest.php (add CRUD tests to existing file)

  • Step 1: Add admin CRUD tests

Append to the existing ConfigGroupTest.php:

describe('Admin Config Group CRUD', function (): void {
    it('lists config groups', function (): void {
        $admin = User::factory()->admin()->create();
        PlanConfigGroup::factory()->count(3)->create();

        $this->actingAs($admin)
            ->get('http://'.config('app.domains.admin').'/config-groups')
            ->assertOk()
            ->assertInertia(fn ($page) => $page
                ->component('Admin/ConfigGroups/Index')
                ->has('groups.data', 3)
            );
    });

    it('creates a preset config group with options and values', function (): void {
        $admin = User::factory()->admin()->create();
        $plan = Plan::factory()->create();

        $this->actingAs($admin)
            ->post('http://'.config('app.domains.admin').'/config-groups', [
                'name' => 'Server Management',
                'mode' => 'preset',
                'is_active' => true,
                'plan_ids' => [$plan->id],
                'options' => [
                    [
                        'name' => 'Management Level',
                        'type' => 'dropdown',
                        'required' => true,
                        'values' => [
                            ['label' => 'None', 'monthly_price' => 0, 'is_default' => true],
                            ['label' => 'Semi-Managed', 'monthly_price' => 25],
                            ['label' => 'Fully Managed', 'monthly_price' => 60],
                        ],
                    ],
                ],
            ])
            ->assertRedirect();

        $group = PlanConfigGroup::where('name', 'Server Management')->first();
        expect($group)->not->toBeNull();
        expect($group->plans)->toHaveCount(1);
        expect($group->options)->toHaveCount(1);
        expect($group->options->first()->values)->toHaveCount(3);
    });

    it('creates a BYO config group with slider options', function (): void {
        $admin = User::factory()->admin()->create();

        $this->actingAs($admin)
            ->post('http://'.config('app.domains.admin').'/config-groups', [
                'name' => 'VPS Builder',
                'mode' => 'build_your_own',
                'service_type' => 'vps',
                'is_active' => true,
                'options' => [
                    [
                        'name' => 'CPU Cores',
                        'type' => 'slider',
                        'provisioning_key' => 'cpu_cores',
                        'required' => true,
                        'min_qty' => 1,
                        'max_qty' => 16,
                        'step' => 1,
                        'unit_label' => 'cores',
                        'hourly_price' => 0.003,
                        'monthly_price' => 2.00,
                    ],
                ],
            ])
            ->assertRedirect();

        $group = PlanConfigGroup::where('name', 'VPS Builder')->first();
        expect($group->mode)->toBe('build_your_own');
        expect($group->service_type)->toBe('vps');
        expect($group->options->first()->provisioning_key)->toBe('cpu_cores');
    });

    it('updates a config group', function (): void {
        $admin = User::factory()->admin()->create();
        $group = PlanConfigGroup::factory()->create();
        PlanConfigOption::factory()->create(['group_id' => $group->id]);

        $this->actingAs($admin)
            ->put('http://'.config('app.domains.admin')."/config-groups/{$group->id}", [
                'name' => 'Updated Name',
                'mode' => 'preset',
                'is_active' => true,
                'options' => [
                    ['name' => 'New Option', 'type' => 'checkbox'],
                ],
            ])
            ->assertRedirect();

        $group->refresh();
        expect($group->name)->toBe('Updated Name');
    });

    it('archives config group with existing selections', function (): void {
        $admin = User::factory()->admin()->create();
        $customer = User::factory()->customer()->create();
        $subscription = \Laravel\Cashier\Subscription::factory()->create(['user_id' => $customer->id]);
        $group = PlanConfigGroup::factory()->create();
        $option = PlanConfigOption::factory()->create(['group_id' => $group->id]);
        SubscriptionConfigSelection::create([
            'subscription_id' => $subscription->id,
            'option_id' => $option->id,
            'locked_price' => 10.00,
            'billing_cycle' => 'monthly',
        ]);

        $this->actingAs($admin)
            ->delete('http://'.config('app.domains.admin')."/config-groups/{$group->id}")
            ->assertRedirect();

        expect(PlanConfigGroup::find($group->id))->toBeNull();
        expect(PlanConfigGroup::withTrashed()->find($group->id))->not->toBeNull();
    });

    it('prevents customer from accessing config groups', function (): void {
        $customer = User::factory()->customer()->create();

        $this->actingAs($customer)
            ->get('http://'.config('app.domains.admin').'/config-groups')
            ->assertForbidden();
    });

    it('validates required fields', function (): void {
        $admin = User::factory()->admin()->create();

        $this->actingAs($admin)
            ->post('http://'.config('app.domains.admin').'/config-groups', [])
            ->assertSessionHasErrors(['name', 'mode', 'options']);
    });
});
  • Step 2: Run tests

Run: php artisan test --compact tests/Feature/Admin/ConfigGroupTest.php Expected: All tests pass.

  • Step 3: Commit
git add tests/Feature/Admin/ConfigGroupTest.php
git commit -m "test: add admin config group CRUD tests"

Chunk 3: Checkout Integration

Task 9: Custom Plans Seeder

Files:

  • Modify: database/seeders/PlanSeeder.php

  • Step 1: Add custom BYO plans

Add to PlanSeeder (use updateOrCreate keyed on slug):

// BYO custom plans (internal status, $0 base)
$byoPlans = [
    ['name' => 'Custom VPS', 'slug' => 'vps-custom', 'service_type' => 'vps', 'sort_order' => 99],
    ['name' => 'Custom MySQL', 'slug' => 'mysql-custom', 'service_type' => 'mysql', 'sort_order' => 99],
    ['name' => 'Custom Game Server', 'slug' => 'game-custom', 'service_type' => 'game', 'sort_order' => 99],
];

foreach ($byoPlans as $byoPlan) {
    Plan::updateOrCreate(
        ['slug' => $byoPlan['slug']],
        array_merge($byoPlan, [
            'status' => 'internal',
            'description' => 'Custom Build Your Own plan.',
        ]),
    );
}

Also seed PlanPrice rows at $0 for all billing cycles so that priceForCycle() returns a valid result and Stripe billing can work:

foreach ($byoPlans as $byoPlan) {
    $plan = Plan::where('slug', $byoPlan['slug'])->first();
    foreach (['monthly', 'quarterly', 'semi_annual', 'annual'] as $cycle) {
        PlanPrice::updateOrCreate(
            ['plan_id' => $plan->id, 'billing_cycle' => $cycle],
            ['price' => 0.00],
        );
    }
}
  • Step 2: Run seeder and commit
php artisan db:seed --class=PlanSeeder
git add database/seeders/PlanSeeder.php
git commit -m "feat: add BYO custom plans with $0 plan_prices (vps-custom, mysql-custom, game-custom)"

Task 10: Checkout Controller Updates

Files:

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

  • Modify: routes/account.php

  • Step 1: Update CheckoutController::show() to load config groups

In the show() method, after loading the plan, add:

$configGroups = $plan->configGroups()
    ->active()
    ->with(['options' => fn ($q) => $q->active()->orderBy('sort_order'), 'options.values'])
    ->orderBy('sort_order')
    ->get();

Pass 'configGroups' => $configGroups to the Inertia render.

  • Step 2: Add showCustom() method
public function showCustom(string $serviceType): Response
{
    $plan = Plan::where('slug', "{$serviceType}-custom")->firstOrFail();

    $configGroup = PlanConfigGroup::buildYourOwn()
        ->active()
        ->forServiceType($serviceType)
        ->with(['options' => fn ($q) => $q->active()->orderBy('sort_order'), 'options.values'])
        ->first();

    if (! $configGroup) {
        abort(404, 'Build Your Own is not available for this service type.');
    }

    $stripeService = $this->billingFactory->make('stripe');
    $paymentMethods = $stripeService->getPaymentMethods(request()->user());
    $intent = request()->user()->createSetupIntent();

    return Inertia::render('Checkout/Show', [
        'plan' => $plan->load('prices'),
        'configGroups' => [$configGroup],
        'mode' => 'custom',
        'paymentMethods' => $paymentMethods,
        'intent' => $intent,
        'stripeKey' => config('cashier.key'),
        'osTemplates' => [],
        'osTemplateGroups' => [],
    ]);
}
  • Step 3: Update store() to handle config selections and dynamic Stripe pricing

Before creating the Stripe subscription, compute the config total and create a dynamic Stripe price:

// Calculate config total
$configTotal = 0;
$configSelections = $request->input('config_selections', []);
foreach ($configSelections as $selection) {
    $configTotal += (float) ($selection['locked_price'] ?? 0);
}

// Apply coupon to TOTAL (base + config), not just base
$basePlanPrice = $plan->priceForCycle($request->input('billing_cycle'))?->price ?? 0;
$totalBeforeCoupon = $basePlanPrice + $configTotal;
$couponDiscount = 0;
if ($request->filled('coupon_code')) {
    $coupon = Coupon::where('code', $request->input('coupon_code'))->active()->first();
    if ($coupon) {
        $couponDiscount = $coupon->calculateDiscount($totalBeforeCoupon);
    }
}
$finalTotal = max(0, $totalBeforeCoupon - $couponDiscount);

// Create dynamic Stripe price for the computed total
$stripeInterval = match ($request->input('billing_cycle')) {
    'monthly' => 'month',
    'quarterly' => 'month', // 3-month interval
    'semi_annual' => 'month', // 6-month interval
    'annual' => 'year',
    default => 'month',
};
$stripeIntervalCount = match ($request->input('billing_cycle')) {
    'quarterly' => 3,
    'semi_annual' => 6,
    default => 1,
};

$stripePrice = \Stripe\Price::create([
    'unit_amount' => (int) round($finalTotal * 100),
    'currency' => 'usd',
    'recurring' => [
        'interval' => $stripeInterval,
        'interval_count' => $stripeIntervalCount,
    ],
    'product' => $plan->stripe_product_id,
]);

// Pass the dynamic stripe_price_id to the billing service instead of the plan's default
// This requires updating createSubscription() to accept an optional $stripePriceId override

After creating the subscription, save config selections:

if (! empty($configSelections)) {
    foreach ($configSelections as $selection) {
        SubscriptionConfigSelection::create([
            'subscription_id' => $subscription->id,
            'option_id' => $selection['option_id'],
            'value_id' => $selection['value_id'] ?? null,
            'quantity' => $selection['quantity'] ?? null,
            'text_value' => $selection['text_value'] ?? null,
            'locked_price' => $selection['locked_price'],
            'locked_hourly_price' => $selection['locked_hourly_price'] ?? null,
            'billing_cycle' => $request->input('billing_cycle'),
            'is_custom_build' => $request->input('mode') === 'custom',
        ]);
    }
}

Also update StripeBillingService::createSubscription() to accept an optional ?string $stripePriceId = null parameter. When provided, use it instead of looking up the plan's stripe_price_id. This keeps the existing flow working for plans without config options.

  • Step 4: Add route for custom checkout

Add to routes/account.php:

Route::get('/checkout/custom/{serviceType}', [CheckoutController::class, 'showCustom'])
    ->where('serviceType', 'vps|mysql|game')
    ->name('checkout.custom');
  • Step 5: Commit
vendor/bin/pint --dirty --format agent
git add app/Http/Controllers/Account/CheckoutController.php routes/account.php
git commit -m "feat: integrate config options into checkout flow with BYO support"

Task 11: Checkout Vue Updates

Files:

  • Create: resources/ts/Components/Checkout/ConfigOptions.vue

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

  • Step 1: Create ConfigOptions component

Renders configurable options for a plan. Handles all option types:

  • dropdown → VSelect
  • radio → VRadioGroup
  • quantity → VTextField type=number with +/- buttons
  • checkbox → VCheckbox
  • text → VTextField
  • slider → VSlider with value display and per-unit price

Props: configGroups: PlanConfigGroup[], billingCycle: string, modelValue: ConfigSelection[] Emits: update:modelValue, update:totalPrice

Each option change recalculates the total and emits updated selections with locked prices.

  • Step 2: Update Checkout/Show.vue

Add configGroups and mode to props. When mode === 'custom', hide the plan card and show sliders as main content. When mode === 'preset', show config options below the billing cycle selector.

Update price calculation to include config option prices. Update form submission to include config_selections array.

  • Step 3: Build and verify

Run: npm run build Expected: Build succeeds.

  • Step 4: Commit
git add resources/ts/Components/Checkout/ConfigOptions.vue resources/ts/Pages/Checkout/Show.vue
git commit -m "feat: add config options rendering to checkout page"

Task 12: Pricing Page BYO Toggle

Files:

  • Create: resources/ts/Components/Marketing/BuildYourOwn.vue

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

  • Step 1: Create BuildYourOwn component

The slider configurator component. Layout A: vertical sliders left, price summary right.

Props: serviceType: string, configGroup: PlanConfigGroup, billingCycles: string[] Emits: deploy (with config selections)

Features:

  • VSlider for each slider-type option with min/max/step

  • Current value display next to slider

  • Per-unit price shown

  • Right-side sticky summary with hourly rate, monthly cap, line-item breakdown

  • Billing cycle selector (hourly displayed as info, billed monthly)

  • "Deploy Now" button routes to /checkout/custom/{serviceType}

  • Step 2: Update marketing route to pass BYO config groups

Modify the pricing route in routes/marketing.php to load BYO config groups:

Route::get('/pricing', function () {
    $plans = Plan::query()
        ->public()  // use new scope to exclude hidden/internal
        ->where('status', 'active')
        ->with('prices')
        ->orderBy('sort_order')
        ->get();

    $byoConfigGroups = PlanConfigGroup::buildYourOwn()
        ->active()
        ->with(['options' => fn ($q) => $q->active()->orderBy('sort_order'), 'options.values'])
        ->orderBy('sort_order')
        ->get()
        ->keyBy('service_type');

    return Inertia::render('Marketing/Pricing', [
        'plans' => $plans,
        'byoConfigGroups' => $byoConfigGroups,
    ]);
})->name('pricing');
  • Step 3: Update Pricing.vue

Add segmented toggle: Preset Plans | Build Your Own When BYO active:

  • Filter service type tabs to only VPS, MySQL, Game

  • Auto-switch to VPS if on unsupported tab

  • Render BuildYourOwn component instead of plan cards

  • Receive byoConfigGroups prop from backend

  • Step 3: Build and verify

Run: npm run build Expected: Build succeeds.

  • Step 4: Commit
git add resources/ts/Components/Marketing/BuildYourOwn.vue resources/ts/Pages/Marketing/Pricing.vue
git commit -m "feat: add Build Your Own configurator to pricing page"

Chunk 4: Seeder & Tests

Task 13: Config Option Seeder

Files:

  • Create: database/seeders/ConfigOptionSeeder.php

  • Step 1: Create seeder with all preset and BYO config groups

Seed the following config groups:

BYO Groups:

  • VPS Builder (sliders: CPU $2/core, RAM $1/GB, SSD $0.05/GB)
  • MySQL Builder (sliders: Storage $0.20/GB, Connections $0.05/50)
  • Game Server Builder (sliders: RAM $1.50/GB, Storage $0.08/GB, Slots $0.05/10)

Preset Groups (attached to dedicated server plans):

  • DDR4 ECC RAM (dropdown: 32GB-768GB with prices from earlier discussion)
  • Drive Bays LFF (dropdown per bay: None through 20TB HDD with prices)
  • Drive Slots SFF (dropdown per slot: None through 8TB SSD)
  • M.2 NVMe (dropdown: None through 2x2TB)
  • Network (radio: 100Mbit free through 10Gbit $650)
  • Public Bandwidth (dropdown: 20TB free through Unmetered)
  • Server Management (dropdown: None/$25/$60) — shared across VPS and dedicated
  • IPv4 Addresses (quantity: min 1, max 8, $3/mo for VPS; /29 included for dedicated)
  • Windows License (checkbox: free)
  • HDD Block Storage (quantity: 0-10 TB, $5/TB/mo for VPS)
  • RAID Controller (dropdown: HBA330 through H730P)

All use updateOrCreate keyed on group name for idempotency.

  • Step 2: Run seeder
php artisan db:seed --class=ConfigOptionSeeder
  • Step 3: Commit
git add database/seeders/ConfigOptionSeeder.php
git commit -m "feat: seed all configurable option groups for VPS, dedicated, MySQL, and game"

Task 14: Checkout Integration Tests

Files:

  • Create: tests/Feature/ConfigurableCheckoutTest.php

  • Step 1: Write checkout tests

Test cases:

  • Preset plan checkout loads config groups
  • Config selections are saved on subscription
  • Config selections calculate correct locked price
  • Stripe price created with correct total (base + config - coupon)
  • BYO checkout route loads configurator
  • BYO checkout creates subscription with $0 base + config total
  • Required config options are validated
  • Slider values respect min/max/step bounds
  • Invalid value_id for dropdown is rejected
  • Inactive options excluded from checkout page load
  • Inactive groups excluded from checkout page load
  • Coupon applies to total (base + config), not just base
  • Customer can view their config selections on subscription detail

Also update resources/ts/Pages/Subscriptions/Show.vue to display config selections if they exist on the subscription. Show a "Configuration" section with each selection: option name, selected value/quantity, locked price.

  • Step 2: Run all tests

Run: php artisan test --compact Expected: All tests pass (existing + new).

  • Step 3: Final build verification
npm run build
vendor/bin/pint --dirty --format agent
  • Step 4: Commit
git add tests/Feature/ConfigurableCheckoutTest.php
git commit -m "test: add configurable checkout integration tests"

Task 15: Final Cleanup

  • Step 1: Update test DB schema
mysql -u ezscale -pyour_password_here -e "DROP DATABASE IF EXISTS ezscale_billing_test; CREATE DATABASE ezscale_billing_test CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;"
mysqldump -u ezscale -pyour_password_here --no-data --skip-lock-tables ezscale_billing > /tmp/schema.sql
mysql -u ezscale -pyour_password_here ezscale_billing_test < /tmp/schema.sql
mysql -u ezscale -pyour_password_here ezscale_billing_test -e "INSERT INTO migrations SELECT * FROM ezscale_billing.migrations;"
  • Step 2: Run full test suite

Run: php artisan test --compact Expected: All tests pass.

  • Step 3: Final commit
git add -A
git commit -m "feat: configurable options and Build Your Own system complete"