# 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 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 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 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 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 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 '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 '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 '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 '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 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 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 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 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 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 ['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 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" ```