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>
2023 lines
63 KiB
Markdown
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"
|
|
```
|