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

2023 lines
63 KiB
Markdown

# 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
<?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
<?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
<?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
<?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
<?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**
```bash
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
<?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
<?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
<?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
<?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`:
```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
<?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**
```bash
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
<?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
<?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
<?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**
```bash
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:
```typescript
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**
```bash
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
<?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**
```bash
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
<?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
<?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`:
```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:
```typescript
{ title: 'Config Options', to: '/config-groups', icon: 'tabler-adjustments' },
```
- [ ] **Step 5: Commit**
```bash
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**
```bash
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`:
```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**
```bash
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):
```php
// 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:
```php
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**
```bash
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:
```php
$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**
```php
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:
```php
// 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:
```php
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`:
```php
Route::get('/checkout/custom/{serviceType}', [CheckoutController::class, 'showCustom'])
->where('serviceType', 'vps|mysql|game')
->name('checkout.custom');
```
- [ ] **Step 5: Commit**
```bash
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**
```bash
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:
```php
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**
```bash
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**
```bash
php artisan db:seed --class=ConfigOptionSeeder
```
- [ ] **Step 3: Commit**
```bash
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**
```bash
npm run build
vendor/bin/pint --dirty --format agent
```
- [ ] **Step 4: Commit**
```bash
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**
```bash
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**
```bash
git add -A
git commit -m "feat: configurable options and Build Your Own system complete"
```